移动端编辑器需求复盘

移动端编辑器需求复盘

十二月 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事件传参区分来判断是否需要存储历史,为了统一交互,暂时这么实现。

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

需要区分动作

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

需要区分加入历史的动作

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

总结下来就是:是在舞台上的操作才需要加入历史

存入历史链表的数据

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

粗略总结如下。

删除 添加 更新 组合 解散
撤销 删除前的组件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这时候的本事就凸显出来了。但碍于时间,并没有去尝试。之后有时间研究一番,再另起一篇文章

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的阻断更新

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