1 背景
本文开发框架基于 React,涉及 React 部分会对背景做简单铺垫。
前端开源江湖非常有意思,竞争是公平的,而且不需要成本,任何一个初入茅庐的学徒都可以找江湖高手过招,且迟早会自成门派,而今前端门派已经灿若繁星,知名的门派也不计其数,其『供需链』大致如下:
w3c规范 ==> 浏览器实现 ==> 开发引擎 ==> 数据框架 ==> UI框架 ==> 开发者 ==> 用户
『可视化在线编辑器』指的是引擎这一环,虽然开发引擎在前端并不常见,但看看游戏界就能知道,脱离游戏引擎编码是多么痛苦的一件事。前端和游戏共同点是都要考虑 UI 和 数据逻辑,其实微软在做界面开发时就有很多引擎出现,现在前端一点一点向全栈迈进,架构越来越重,分工越来越细,因为 node 让许多后端开发者接触前端,将后端沉淀的精髓带到了前端,而今前端又将触手延伸到客户端、PC端甚至硬件领域,逐渐吸收了开发引擎的思想,促进前端进入工业时代。
在线编辑器是我在百度负责的主要项目之一,因为需要在 RN 的支持下兼容三端,因此就要设计得更加通用,为了循序渐进的讲解,我准备以 设计理念 功能实现 拓展架构设计 的顺序叙述。
2 设计理念
在头脑风暴之前,我们有几个目标需要提前明确,就像做游戏引擎一样,如果整体架构没有设计好,之后的开发将非常痛苦,以下是我重构两次后总结出的整体要领。
2.1 模块化
- 各司其责,组件化。编辑器只是引擎中的一环,还有负责部署在各端的展示器,提供最细粒度"积木"的基础组件,使用
typescript
的用户需要的类型库组件。 - 精简核心。编辑器 的核心功能是组件聚合,包括UI聚合与数据流聚合,以及提供依赖注入的功能,业务功能只要提供编辑区域渲染与拖拽功能。
- 插件是第一等公民。所有核心功能都通过插件提供,插件的UI、数据流都可以接入编辑器。
2.2 编辑器核心功能精简
所有编辑功能由插件提供,编辑器只需要实现"任何位置和功能都能由插件替代"的功能即可(拓展架构设计详细说明),这样编辑器可以理解为一块神奇磁铁,其特殊的引力将插件规律的吸附在四周。
2.3 展示器不关注平台细节
即不要对组件进行 dom 结构的包装,就可以适应任何平台(由组件内部实现决定)。
2.4 事件设计
事件可以让程序活起来,就像 Playmaker 可以不用写一行代码,在 Unity3d 做一款小游戏一样。事件分为 触发条件 与 动作效果:
- 触发条件的拓展点在于组件的生命周期,比如滚动条组件的
onScroll
、按钮组件的onClick
都可以作为触发条件。 - 动作效果的拓展点在于调用平台特征与修改自身属性。调用平台特征一大好处在于不关心组件实现细节,任何地方都可以调用,比如分享、调起相机等等。修改自身属性也是通用特征,可以用来显示模态框、修改数据源等。
2.5 数据流设计
Mobx 是一个双向绑定库,奇特之处在于自动绑定实例用到的属性,并且在数据变化时仅更新依赖于它的实例。Inversify 库实现了依赖注入。
React
本身只是 View
层,仅提供了组件内部状态 State
以及不建议使用的 Context
维护简单数据状态。编辑器复杂度较高,必须借助外部数据流管理,我们使用 Mobx
以及 Inversify
实现双向绑定和依赖注入,数据流向如下图所示:
React
触发刷新常见有三种,除了组件内部调用setState
更新内部状态、或者forceUpdate
强制刷新之外,父级传参props
发生了变化一般也会触发刷新。
React
概念中props
是传参,即父级A
对子组件B
传递了参数x
,那么x
就是B
组件的props
属性,对B
来说是readyOnly
的。
从页面组件开始看,将 Action
与 Store
分别注入到页面中,由于希望数据变动后页面立刻刷新,我们用 mobx
将 Store
注入到组件的 props
中,而 Action
则通过 Inversify
直接注入为实例的成员变量。
Action
之间也可以相互注入,同样 Store
也可以相互注入。只允许 Action
修改 Store
,进而触发页面 props
变化,页面刷新。
3 功能实现
3.1 编辑器
需要实现两种状态:编辑态 和 预览态
3.1.1 编辑状态
React dom 与 web dom node 不同,使用了虚拟 dom,而且组件不一定有实体 dom,就算最终挂在到了实体 dom 上,如果不将 dom 支持的基本手势事件暴露出来,组件外部将无法调用。
编辑状态需要捕获 click
hover
等鼠标事件,由于组件不一定将回调透传,我们通过 ReactDOM.findDOMNode
拿到组件的 dom
节点直接监听。
再实现 实时编辑 与 拖拽功能 ,编辑器的核心业务逻辑就完成了。
3.1.2 预览状态
为了方便将代码部署在三端,优先考虑的部署方式不是生成代码,而是生成配置,有一个专门的展示器负责解析配置,部署在不同平台,具体细节见 展示器。
因为预览与实际部署效果一致,所以调用 展示器 传入当前页面编辑信息即可。
3.1.3 拖拽功能
由于支持了内部排序,与外部拖拽,社区的 SortableJs 非常合适担此重任。
sortablejs 嵌套拖拽
event.oldIndex
在其稳定版本(1.4.2)一直是 0,但这个 bug 在dev
分支已修复。
我们将 Sortablejs
与 React
结合即可完成拖拽功能,在结合前先介绍一下 React
在 dom
方面的特征:
React 使用虚拟 dom 进行计算,将计算后 diff 结果同步在真实 dom 中,由此 React 对真实 dom 结构依赖非常强,其操作 dom 接口过于底层没有暴露出来,如果直接操作了 dom 会打乱 React 的算盘。
我们转换策略,仅仅将 Sortable
库作为中间动画使用,并依托其拖拽生命周期,在拖拽结束后获取用户拖拽意图,将 dom 的改动完全还原,再将意图交由 React
来实现。
伪代码
如下:
Sortable.sort({
onEnd: (event)=>{
// 将移走的 dom 还原回去,目标元素自然会消失
sourceParentElement.insertBefore(event.item, sourceIndex)
// React 修改两个父级子元素状态
action.moveComponent(sourceId, sourceIndex, targetId, targetIndex)
}
})
3.1.4 实时编辑
将页面所有编辑元素打平,每个元素渲染时绑定其对应 id
的数据,修改属性时直接修改对应数据,mobx
会直接更新目标组件实例,如图所示:
Map
中有一个根节点,从根节点开始渲染,每个节点从数据库中取到自身数据,如果有子元素,则会递归渲染,子元素再从数据库获取子元素自身的数据,依次循环,当循环完毕后,我们会得到一颗与 Map
数据一一对应绑定的dom
树,Map
中任何一个元素发生改变,Mobx
会通过之前 getter
记录的关联关系,主动找到绑定的实例执行 forceUpdate
刷新。
mobx 接入组件的 props 数据不会触发 render,而是仅通过实例对应关系主动触发组件的
forceUpdate
,Mobx
会在shouldComponentUpdate
的生命周期中屏蔽掉observe
类型数据的判断,因此Mobx
的数据不会影响React
的更新循环。
3.1.5 设置为组合
编辑器中,除了设定好在菜单中的组件,还可以让任意组合形成模板,将模板作为新组件放在组件菜单中。
关键点在于如何从打平的数据中获取组件间关联关系,并独立抽出来。
生成模板配置只需获取全量编辑信息,并进行瘦身即可,伪代码
如下:
// 将当前编辑状态组件的 key、编辑信息和子元素信息一并获取
let componentFullInfo = action.getComponentFullInfoByKey(currentEditKey)
// 根据 defaultProps 去重,删除编辑时无用字段等
componentFullInfo = clean(componentFullInfo)
瘦身时使用 lz-string gzip 压缩,因为配置信息重复的字段很多,甚至大段可能都是复制粘贴的,因为 js 无法传输二进制文件,需要转化为 base64,体积增大了 66%,但还是将 971kb 的配置压缩到了 78kb。
将模板插入到页面中,首先将瘦身的信息补全,再给内部每个组件设置一个全新的 key
, 但关联关系保持不变,最后将最外层组件挂载到拖拽到的父级上。伪代码
如下:
// 补全组件信息
const componentFullInfoExpend = expend(componentFullInfo)
// 保持父子级关系不变,将所有 Key 全部换掉
const componentFullInfoCopy = copyWithNewKey(componentFullInfoExpend)
// 添加到页面
addToPage(componentFullInfoCopy)
关联关系不变,比如组合是 a
有一个子元素 b
,key
分别是 keyA
keyB
,因为组件 map 需要保证 key 的唯一性,生成一对新的 key keyC
keyD
,但 keyB
父级关联的 keyA
同时也会改为 keyC
。
3.2 展示器
如图所示,展示器负责部署在各端,目前支持网页、安卓和苹果。核心思想是利用 react-native
将组件直接渲染到端上,为了同时适配网页,使用 react-native-web 配合 webpack
,将 react-native
代码在网页端编译时 alias
到 react-native-web
,用其提供的兼容样式展现。
展示器还负责将仅预览状态有效的 事件机制、变量配置、动作等激活,利用自身生命周期,以及子组件的回调函数挂上动作钩子。
3.3 动态拓展
如果说编辑与展示给了应用健壮的躯体,那动态拓展就让应用活了起来。
动态数据对编辑器来说,是一个拓展功能,分别可以拓展组件的 功能、数据来源 以及 融入应用自身的数据流。
3.3.1 功能注入
就是将平台特有的功能注入到编辑器生成的页面中,其实这是一种反向注入的过程,编辑器申明自己想要什么,具体功能是如何实现,效果如何,都完全交由各平台自己去实现。
更加自由的方式是申明回调函数,编辑器可以发出带有任意参数的回调,供部署到的平台任意拓展,平台部署的伪代码
如下:
{ // .. do something } } />
3.3.2 传参注入
在网页显示一篇文章,一定是通过 url 获取 id,在端上也是通过页面传参拿到的,我们在部署端将可能拿到的参数全部注入到展示器中。
3.3.3 数据流接入
如果页面部署在普通网页上,比如做运营页,那就没有数据流概念一说。如果部署在端上,或者部署在一个网页平台上,那部署端自身一定有自己的数据流系统,可能是 redux
mobx
等等 mvc
mvp
的设计,我们需要考虑将数据流接入这些自有体系中。
- 端上将自身数据流抽取出来,端上实例化一份数据实例,每个组件根据数据接口进行数据注入,调用
Action
的方式展现与操作数据。也就是让每个组件都依赖数据接口,组件即便拆出来单独使用,但一旦部署到端上,将会自动接入端上数据流。 - 编辑器与展示器都不需要额外处理。
3.4 事件
高阶组件(HOC),原理类似高阶函数,即在原有组件基础之上包装一个组件,这个包装的就是高阶组件,好处是享有一套独立的生命周期,不对原组件产生影响,却又能拓展每个组件的功能。
事件只发生在展示器阶段,事件分为 触发条件 与 动作效果,我们在展示器对每个组件包一层高阶组件,让其支持触发和响应事件。
3.4.1 触发条件
- 初始化。在高阶组件初始化的生命周期中触发。
- 监听事件。高阶组件初始化时监听事件。
- 生命周期。指的是组件自身生命周期也是触发条件的一部分,在调用子组件时,将子组件的回调函数指向动作效果函数即可,但要同一生命周期可以定义多个事件,但回调函数可不一定支持多个,我们需要做序列化处理,
伪代码
如下:
// 将事件数组按照触发条件聚合,转换成 map 类型
const functionMap = getSelfFunctionMap()
functionMap.forEach((value: Array, key: string) => {
props[key] = (...args: any[]) => {
value.forEach(eachValue => {
// 执行动作效果,将参数打散传入
runEvent.apply(this, [eachValue, ...args])
})
}
})
3.4.2 动作效果
- 触发事件。展示器实例维护了一个事件实例,通过这个事件系统派发事件。
- 修改属性。修改组件自身属性,对
props
做merge
即可。 - 调用注入方法。触发展示器的回调函数,调用部署平台的功能。
事件的整体流程如下图所示:
4 拓展架构设计
为了让编辑器拓展性更强,我们可以将编辑器所有功能以插件方式组装,插件可以插入到编辑器任何位置,也可以插件嵌套插件;插件可以使用编辑器数据流,也可以提供数据流供其它插件使用。
也就是拓展分为数据流拓展与UI拓展。
mobx-react 是适配 react 的库,将
Mobx
的Store
注入到任意React
,为了保证操作的是同一份实例,初始化时先将所有Store
实例化一份,并通过传参给根组件Provider
,分发到各个组件。
在 数据流设计 这一章提到了非常灵活的数据注入,首先 mobx-react
利用 context
实现了任意 Action
Store
注入在任意 React
组件中,我们只需要实现在 Action
与 Store
中相互注入即可。
4.1 数据流拓展
我们希望任意 Action
Store
之间都能随意注入,不会引发循环依赖,可以通过引入中间人的方式解决。我们有 A.ts
B.ts
两个文件,分别在各自的类中引入对方实例,并期望所有对引用的操作都发生在同一实例下(如果组件被实例化多次,我们一定不希望多个实例共享数据),希望的结果伪代码
如下:
A.ts
import {inject} from 'inject-instance'
import B from './B'
export default class A {
@inject('B') private b: B
public name = 'aaa'
say() {
console.log('A inject B instance', this.b.name)
}
}
B.ts
import {inject} from 'inject-instance'
import A from './A'
export default class B {
@inject('A') private a: A
public name = 'bbb'
say() {
console.log('B inject A instance', this.a.name)
}
}
入口文件如下,期望输入注释中的结果:
import injectInstance from 'inject-instance'
const instances1 = injectInstance(A, B)
instances1.get('A').say()
instances1.get('B').say()
instances1.get('A').name = 'c'
instances1.get('B').say()
// A inject B instance bbb
// B inject A instance aaa
// B inject A instance c
const instances2 = injectInstance(A, B)
instances2.get('A').say()
instances2.get('B').say()
// A inject B instance bbb
// B inject A instance aaa
可以看出,如果实现了 inject-instance
,就可以在 componentWillMount
的生命周期调用 injectInstance
,并传入所有 Action
Store
,不同实例之间数据流独立。
不同实例间数据流独立的意思是,在
class A
中操作注入实例b
的数据,只会操作当前class A
归属组件实例的数据流中的b
。如果实例化了 N 份编辑器,比如显示模态框通过store
中showModal
控制,不至于出现点击一个编辑器的按钮,所有模态框都弹出的结果。
4.1.1 inject-decorator 实现原理
inject-decorator
是装饰器,给字段打一个 tag
,告诉之后要执行的 injectInstance
方法:"这个字段要注入 XXX Class,到时候帮我替换一下!"。
伪代码
如下:
export default (injectName: string): any => (target: any, propertyKey: string, descriptor: PropertyDescriptor): any => {
// 变量值替换为注入类名称
target[propertyKey] = injectName
// 加入一个标注变量
target['injectArray'].push(propertyKey)
}
es6 箭头函数实现函数式非常方便,N 层嵌套可以用打平的 N 个
=>
表示。
装饰器是个函数,如果装饰器本身带参数,则变成 2 层嵌套的函数。
将变量值替换成注入类名称,只是标记到时候替换成什么类的实例,而 injectArray
字段才是打 tag
,执行 injectInstance
时会根据这个字段来替换对应成员变量。
4.1.2 injectInstance 实现原理
将传入的所有类根据类名放入 Map
(仅加快查找用,用空间换时间),因为返回对应实例,所以先全部实例化,再遍历所有实例,根据 inject-decorator
打的 tag
变量 injectArray
将对应字段替换为实例。
最后,编辑器将得到的全部实例传入 mobx-react
的 provider
中,实现了 UI 组件注入数据与数据流中注入的数据是统一份实例的效果。
更多注入细节,查看 inject-instance。
4.2 UI拓展
就是允许插件插入到页面任何节点,与数据注入不同,数据注入是将所有插件数据流与编辑器自身数据流混在一起,其结构是打平的,像一个 Map
。而UI注入,结构像 Tree
是层叠的,编辑器自身预留许多插槽,允许任何插件插入。
为了更好的拓展性,也允许插件留下插槽,让其它插件插入,而这样的好处不仅在于位置灵活,还可以优雅实现『自定义编辑功能』的能力,这个之后再说。
在编辑器或者插件中留一个插槽的伪代码
如下:
// 在导航条左侧留一个插槽
ApplicationAction.loadingPluginByPosition('navbarLeft')
如果插件类中静态属性 Positon = 'navbarLeft'
,他就会插入在左侧导航条中。
别忘了,依赖与 inject-instance
的数据流注入功能,插件也可以随时调用这个方法,因此轻松实现插件预留插槽的功能。
4.2.1 利用 UI 注入实现自定义编辑类型
编辑器一般会提供基础编辑类型,比如纯文本的 text
,下拉选择框 select
等等,如果用户希望自定义一种 array
编辑类型,实现对数组字段编辑功能,可以用 UI 注入的方式实现。
为了实现这种方式,编辑组件中,判断编辑类型的伪代码
如下:
ApplicationAction.loadingPluginByPosition('editorAttribute' + editType)
注意,预留插槽的属性可以存在变量,而且以传入的编辑类型为结尾,就可以拓展编辑类型了,其它类型的拓展也不在话下。
那么希望支持 array
类型时,编辑器会试图加载 editorAttributeArray
UI组件,那我们定义一个 Position = 'editorAttributeArray'
的组件就可以显示在这个位置,之后读取编辑器核心数据流的 currentEditComponent
对当前编辑组件进行操作即可。
4.3 拓展架构总结
用一张图总结插件拓展的全貌:
插件与编辑器的数据流是双向互通的,插件的UI可以插入编辑器UI,插件也可以插入插件的UI(不能循环引用)。
5 结语
看到这里,其实编辑器实现原理倒并不重要了,重要的是对数据流、拓展性的设计思路,这些思想迁移到普通类型项目依然适用。当然,如果还有兴趣可以读读编辑器实现源码。
使用遇到的问题,可以用钉钉扫码加群讨论: