移动端编辑器需求复盘

移动端编辑器需求复盘

十二月 20, 2021

前言

这个项目又一次刷新我进公司做的项目的难度。这个是我们公司核心项目编辑器的移动端复刻版。是公司的核心项目。而且迭代相当长时间,代码质量很高,非常值得学习。这次需要我负责实现一个h5版本,是个非常有价值的机会。

关于这个项目的标签:移动端nocode高性能新项目,听起来就挺有意思。 而且这个项目是之前完全没接开发过的,而且算上我5个前端、两个后端,需要我带领着完成。这样就更有挑战性了。

正文

这个项目大概是通过url传入模板id或者作品id。读取作品数据后,在h5中更新数据后保存。需要实现的大功能点有:

  1. 撤销与反撤销
  2. 利用作品数据生成图片(海报)
  3. 小程序webview交互(目前是在小程序中访问的)
  4. 多个可编辑的组件(图片、文字、svg、组合、员工名片)
  5. 组件可以缩放、拖拽位置、删除与内容修改。

项目大约分为三个大模块:

  1. 工具栏:可以做一些操作
  2. 舞台:展示与操作组件的区域,即最终成品(海报)的可见即所得的可操作区域。
  3. 设置面板:对组件进行设置的操作区域

1. 定义数据结构

因为是PC版编辑器的H5版,所以数据是需要一模一样的。这个没有太多需要思考的内容。不过倒是可以学到一些东西。

  1. 复杂的项目往往需要一个良好的数据结构。

目前PC编辑器已经经过了长期的迭代,组件数据结构、页面数据结构已经有成熟的Typescript声明可以使用。而且声明文件已经独立发包,多个项目中引用。可以极大的限制各个项目的数据出错。

  1. 复杂的项目需要清晰的数据流

此项目也是如此,所有组件数据都存储在redux中,舞台上的所有渲染内容都直接或间接来自store,然后设置面板、舞台拖拽组件都直接修改redux中的数据,而不是直接修改组件。从而形成单项数据流。

虽然一开始会觉得,可能会卡或者延迟(经过这一层传递)。但实际完成后,并没有出现担心的卡顿。然后这样的数据流很清晰、调试方便(利用redux-devtools改历史即可反映到视图)。所以基本可以确定是最佳方案。

这也就叫做中介模式

  1. 本地数据需要经过本地转义。

PC编辑器也用了我在全员名片设置里用到的方法。api => store => api的方式。接口数据虽然需要符合规范。但最好是,经过一个方法(flattern与structure、全员名片的api2form与form2api)方法转义。好处是可以百分百的转换成为适合mvvm的数据结构,也方便调试。产转换数据需要的计算,相对其带来的好处。其实微不足道。

关于组件设计的一个优化

目前是每个组件都有一个类,每个类都有个create方法可以创建组件数据。类里定义了多个静态方法来辅助处理组件数据。

其实不太明白,直接通过new方法来创建组件数据,然后定义一些通用方法,处理组件岂不妙哉?

1
2
3
4
5
6
7
8
9
10
11
12
13
// 现有方式
const cmp = CmpModelClass.create();

// 如果要清除子组件
if(CmpModelClass.isContainer(cmp)) {
cmp.cmps.forEach(id => {
const child = store.cmps.byId[id];
child.gid = '';
})

cmp.cmps = [];
}

1
2
3
4
5
6
7
// 理想的方法
const cmp = new ImageModelClass();

// 如果要清除子组件
if(cmp.isContainer()) {
cmp.clearChild();
}

但碍于时间限制,并没有优化。

2. 撤销与反撤销

这功能是我做的这项目里最复杂的功能,没有之一。

PC版这个功能本身就有bug,所以就趁机重写了逻辑。

一听这个需求,就觉得这肯定是一个双向链表的数据结构,于是基于数组封装了个双向链表(关于这点也纠结了几下,一开始不用数组存储,经典的ListNode节点。但是在调试的时候遇到了困难,toString需要遍历一遍才能直观的看。后来用数组来存储数据,实际上也可以很好的实现链表,看起来性能也不会差多少),封装moveForward、moveBackward、add、isFirst、isLast方法,考虑好边界,多次调试,很好的完成了需求。

连续update判断是否要加入历史

编辑器有一些连续的变化如拖拽、resize,是未经节流的touchMove事件派发的,触发非常频繁。而真正需要保存到历史链表的只有拖拽结束后的状态。一开始考虑的是加个防抖,但是PC版是通过touchEnd事件传参区分来判断是否需要存储历史,为了统一交互,暂时这么实现。

这么实现的好处是,可以最精准的存储理想的存储点,缺点是需要传参区分是否要存历史,增加了复杂度。其实个人觉得防抖延迟存储更适合。

更新(2022/01/23):

后来在PC版编辑器的开发过程中,遇到不少需要代码中单独更新的需求。比如:在切换input组件类型之后,更新一下脱敏状态。

这时需要单独发起一次update,而不希望加入历史。所以。还是通过传参手动控制“是否需要加入历史”更有效。

需要区分动作

存入历史的数据需要区分动作。如添加动作,在撤销的时候需要 删除。删除动作在撤销时需要 添加。更新动作撤销时,也是更新。所以不同动作,撤销后的处理与数据是不一样的,所以需要加以区分。

需要区分加入历史的动作

实现过程中发现还要考虑是否是用户触发发生的改变。如更新动作,撤销后也触发了一次更新。撤销动作触发的更新就不应该计入历史。

存入历史链表的数据

如前面所说,要区分不同的动作,不同动作撤销、反撤销操作不一样,那么需要的数据也不一样。

粗略总结如下。

删除 添加 更新 组合 解散
撤销 删除前的组件id与状态 组件id 更新前状态 每个子组组合前件状态 容器状态与每个子组件状态
反撤销 组件id 删除前组件id及状态 更新后状态 容器与子组件状态 所有子组件状态

那么每个动作需要的数据就是两个操作需要的状态的和。

一开始设想是,每个节点存储一个状态,而不是一个节点存储变化前状态、变化后状态。后来实现之后,发现并不能满足需求

因为我们选择的是保存发生变更组件的快照的方式。每个节点存储变更的组件id与变更内容。如果采用每个节点存储一个状态的方式,则不能很好的表示哪个节点发生了改变。

比如,操作1:更新的组件1的大小。操作2:更新了组件2的位置。

那么节点1需要存储组件1更新前的动作,节点2需要存储组件1更新后的快照与组件2更新前的快照。节点3需要存储组件2更新后的快照。

操作1:需要把组件1的更新前快照写入节点1更新后快照写入节点2
操作2:需要把组件2的更新前快照写入节点2更新后的快照写入节点3

会导致一个动作修改两个历史。或者,存储所有组件的快照。两种方案都是无法接受的。

历史动作组合情况。

后来开发遇到一个特殊情况。组合组件只有两个子组件的时候,删除一个子组件的时候,同时需要解散组合。忘记PC版怎么实现来着(好像还是有bug)。这边实现的逻辑是,历史节点存储内容是数组。一个历史节点可以支持多个动作。

操作以上情况,可以分解为,先解散组合,再删除组件。

那么撤销时,就是先添加组件、再组合组件。
反撤销时,还是先解散组合,在删除组件。

存在一个顺序的调换。所以两个操作需要相反顺序遍历历史节点数据来进行操作。

immer

经大佬推荐,使用到一个immer的库,挺有意思,可以记录一下。

React有个特性是,setState需要传递一个全新对象。Redux的reducer也是,需要return一个全新的对象。所以我们实际开发时,总是需要扩展运算符,如

1
2
3
4
5
6
7
8
this.setState(preState => ({
...preState,
a: 'a'
obj: {
...preState.obj
b: 1
}
}))

要修改的层级越多,代码就越丑。

immer专门解决此类问题。直接看代码

1
2
3
4
5
6
7
8

import { produce } from 'immer'

this.setState(produce(preState, draft => {
draft.a = 'a'
draft.obj.b = 1
}))

immer提供了个produce方法,produce接受两个参数,第一个为初始值preState,第二个是个方法。方法的第一个参数是个preState的代理draft。这里给命名为草稿。

我们可以直接修改草稿即可,produce会根据我们对draft的修改自动返回一个新对象。

对于redux的reducer可以这么写。

1
2
3
4
5
6
7
import { produce } from 'immer'

const reducer = produce((state, action: AnyAction) => {
action.a = 'a'
})

return reducer;

getZoomValue尺寸转换

因为移动端屏幕尺寸多样,而实际海报尺寸是固定的,所以肯定需要对舞台以及舞台上的所有组件进行缩放。

为了解决这个问题,想了几个方案:

  1. css transform整体缩放舞台元素

优点:代码量少,能百分百还原布局。
缺点:文字不能以整数渲染,会模糊。触摸区域会偏移,难以准确触发交互。舞台所有内容都会缩放,包括交互按钮。

  1. 仅对需要缩放的元素独立进行转换
    优点:可以独立控制,是否缩放。文字可以以整数渲染。
    缺点:需要在舞台内很多地方单独转换。

经讨论,最终使用方案二。封装了一个getZoomValue方法转换尺寸。

而后面又遇到一个问题。在用html2canvas截图时,需要还原原始尺寸。所以需要一个字段控制getZoomValue的表现。这个字段的改变可以触发React的更新。所以想到了React的上下文。

首先需要一个上下文来判断是否需要缩放。

1
2
3
4
5
6
7
8
9
// helper.js

// 原始转换方法
export function getZoomValue(isZoom, value) {
if (!isZoom) return value;
const { scale } = store;
return value * scale;
}

1
2
3
4
5
6
7
// context.js
import { createContext } from 'react'

const Context = createContext(false);

export default Context;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Layout.tsx

import Context from './context';

function Layout() {
return (
<>
<Toolbox />
<Context.provider> // 注入上下文
<Stage />
</Context.provider>
</>
)
}

1
2
3
4
5
6
7
8
9
10
11
12
13
// useCurrent

import Context from './context';
import { getZoomValue } from './helper'
import { useContext } from 'react'

export default function useCurrent() {
const context = useContext(Context);
return {
getZoomValue: getZoomValue.bind(null, context)
}
}

1
2
3
4
5
6
7
8
9
10
11
// components.tsx
import useCurrent from './useCurrent'

function Component() {
// 这个getZoomValue则是bind了上下文的方法
const { getZoomValue } = useCurrent();
const output = getZoomValue();
return <div></div>
}


errorBoundary

可以另起一篇《js错误处理》

远大的想法

生成海报用json2canvas或者直接react-art

生成海报的功能,第一时间就想到了html2canvas。使用上是没有问题。但是,对于这个项目,是有一点多余的。

html2canvas的工作原理是,先解析dom树,把其转换位AST,再根据AST在canvas作画。而,我们后台的数据model就已经足够去描述AST。所以可以省掉dom转AST的步骤。

这不就是json2canvas的思想么。之前做小程序的时候,有过类的库。搜了一圈,并没有发现web端json2canvas库。

但是,web端json2canvas没有,vdom2canvas有,也就是React官方的包React-art。不得不说。React这时候的本事就凸显出来了。但碍于时间,并没有去尝试。之后有时间研究一番,再另起一篇文章

2022/01/20更新

后来了解到,

  1. React-art有不少问题,可能不能直接运用到项目上。
  2. 找到钉钉文档的分享文章,canvas绘画引擎是自研的。也有评论说,腾讯文档的canvas渲染引擎也是自研的。

所以基本上确定,react-art这条路走不通。。。

react内存泄露问题

项目开发后期,用chrome devtools的performance去跑了一下项目。发现堆内存和listener随着React更新飞快的增长,疯狂的GC。试了一下PC版编辑器,也是一样问题。。。。

但好在疯涨的堆内存与listener都能清除干净(每次GC都能回到相同值),并且都是触发的Minor GC,并造成明显的页面卡顿。

经后来写demo排查,即使一个最简单的demo也会造成一样的问题。

个人猜测是React更新时,未加useMemo、useCallback的变量被频繁创建,旧的变量值被释放,等待回收。

在demo里分别测试了加useMemo和useCallback是否可以解决这个问题?

结果是,useCallback没有任何优化,加了useMemo,listener数没有变化,对内存停止增长。

![[./images/Pasted image 20211226022753.png]]

基本上可以总结为“无解”。但这应该不需要解决

日常开发中,需要创建无数的执行上下文,每个执行上下文销毁后也会产生很多待回收内存。这是不可避免的。只不过,React、Redux这种框架,天生就会产生非常多的待回收对象。这个并不是问题,V8的垃圾回收机制就是这么干活的。这样的对象回收一般走的是新生代垃圾回收,非常快,并不会造成页面卡顿。

by the way。React这种重运行时的框架确实不太适合做需要频繁更新的移动端编辑器项目。。。

利用上新React的阻断更新

新项目,所以直接用上React17,按理已经支持了fiber阻断更新。但是还是没时间加上。应该给舞台与设置面板加上startTransition,增强用户体验。