可视化在线编辑器架构设计

1 背景

本文开发框架基于 React,涉及 React 部分会对背景做简单铺垫。

前端开源江湖非常有意思,竞争是公平的,而且不需要成本,任何一个初入茅庐的学徒都可以找江湖高手过招,且迟早会自成门派,而今前端门派已经灿若繁星,知名的门派也不计其数,其『供需链』大致如下:

w3c规范 ==> 浏览器实现 ==> 开发引擎 ==> 数据框架 ==> UI框架 ==> 开发者 ==> 用户

『可视化在线编辑器』指的是引擎这一环,虽然开发引擎在前端并不常见,但看看游戏界就能知道,脱离游戏引擎编码是多么痛苦的一件事。前端和游戏共同点是都要考虑 UI 和 数据逻辑,其实微软在做界面开发时就有很多引擎出现,现在前端一点一点向全栈迈进,架构越来越重,分工越来越细,因为 node 让许多后端开发者接触前端,将后端沉淀的精髓带到了前端,而今前端又将触手延伸到客户端、PC端甚至硬件领域,逐渐吸收了开发引擎的思想,促进前端进入工业时代。

在线编辑器是我在百度负责的主要项目之一,因为需要在 RN 的支持下兼容三端,因此就要设计得更加通用,为了循序渐进的讲解,我准备以 设计理念 功能实现 拓展架构设计 的顺序叙述。

2 设计理念

在头脑风暴之前,我们有几个目标需要提前明确,就像做游戏引擎一样,如果整体架构没有设计好,之后的开发将非常痛苦,以下是我重构两次后总结出的整体要领。

2.1 模块化

  1. 各司其责,组件化。编辑器只是引擎中的一环,还有负责部署在各端的展示器,提供最细粒度"积木"的基础组件,使用 typescript 的用户需要的类型库组件。
  2. 精简核心。编辑器 的核心功能是组件聚合,包括UI聚合与数据流聚合,以及提供依赖注入的功能,业务功能只要提供编辑区域渲染拖拽功能
  3. 插件是第一等公民。所有核心功能都通过插件提供,插件的UI、数据流都可以接入编辑器。

2.2 编辑器核心功能精简

所有编辑功能由插件提供,编辑器只需要实现"任何位置和功能都能由插件替代"的功能即可(拓展架构设计详细说明),这样编辑器可以理解为一块神奇磁铁,其特殊的引力将插件规律的吸附在四周。

2.3 展示器不关注平台细节

即不要对组件进行 dom 结构的包装,就可以适应任何平台(由组件内部实现决定)。

2.4 事件设计

事件可以让程序活起来,就像 Playmaker 可以不用写一行代码,在 Unity3d 做一款小游戏一样。事件分为 触发条件动作效果

  1. 触发条件的拓展点在于组件的生命周期,比如滚动条组件的 onScroll、按钮组件的 onClick 都可以作为触发条件。
  2. 动作效果的拓展点在于调用平台特征与修改自身属性。调用平台特征一大好处在于不关心组件实现细节,任何地方都可以调用,比如分享、调起相机等等。修改自身属性也是通用特征,可以用来显示模态框、修改数据源等。

2.5 数据流设计

Mobx 是一个双向绑定库,奇特之处在于自动绑定实例用到的属性,并且在数据变化时仅更新依赖于它的实例。Inversify 库实现了依赖注入。

React 本身只是 View 层,仅提供了组件内部状态 State 以及不建议使用的 Context 维护简单数据状态。编辑器复杂度较高,必须借助外部数据流管理,我们使用 Mobx 以及 Inversify 实现双向绑定和依赖注入,数据流向如下图所示:

可视化在线编辑器架构设计_第1张图片
1

React 触发刷新常见有三种,除了组件内部调用 setState 更新内部状态、或者 forceUpdate 强制刷新之外,父级传参 props 发生了变化一般也会触发刷新。

React 概念中 props 是传参,即父级 A 对子组件 B 传递了参数 x,那么 x 就是 B 组件的 props 属性,对 B 来说是 readyOnly 的。

从页面组件开始看,将 ActionStore 分别注入到页面中,由于希望数据变动后页面立刻刷新,我们用 mobxStore 注入到组件的 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 分支已修复。

我们将 SortablejsReact 结合即可完成拖拽功能,在结合前先介绍一下 Reactdom 方面的特征:

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 会直接更新目标组件实例,如图所示:

可视化在线编辑器架构设计_第2张图片
2

Map 中有一个根节点,从根节点开始渲染,每个节点从数据库中取到自身数据,如果有子元素,则会递归渲染,子元素再从数据库获取子元素自身的数据,依次循环,当循环完毕后,我们会得到一颗与 Map 数据一一对应绑定的dom 树,Map 中任何一个元素发生改变,Mobx 会通过之前 getter 记录的关联关系,主动找到绑定的实例执行 forceUpdate 刷新。

mobx 接入组件的 props 数据不会触发 render,而是仅通过实例对应关系主动触发组件的 forceUpdateMobx 会在 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 有一个子元素 bkey 分别是 keyA keyB,因为组件 map 需要保证 key 的唯一性,生成一对新的 key keyC keyD,但 keyB 父级关联的 keyA 同时也会改为 keyC

3.2 展示器

可视化在线编辑器架构设计_第3张图片
3

如图所示,展示器负责部署在各端,目前支持网页、安卓和苹果。核心思想是利用 react-native 将组件直接渲染到端上,为了同时适配网页,使用 react-native-web 配合 webpack,将 react-native 代码在网页端编译时 aliasreact-native-web,用其提供的兼容样式展现。

展示器还负责将仅预览状态有效的 事件机制、变量配置、动作等激活,利用自身生命周期,以及子组件的回调函数挂上动作钩子。

3.3 动态拓展

如果说编辑与展示给了应用健壮的躯体,那动态拓展就让应用活了起来。

动态数据对编辑器来说,是一个拓展功能,分别可以拓展组件的 功能数据来源 以及 融入应用自身的数据流

3.3.1 功能注入

就是将平台特有的功能注入到编辑器生成的页面中,其实这是一种反向注入的过程,编辑器申明自己想要什么,具体功能是如何实现,效果如何,都完全交由各平台自己去实现。

更加自由的方式是申明回调函数,编辑器可以发出带有任意参数的回调,供部署到的平台任意拓展,平台部署的伪代码如下:

{ // .. do something } } />

3.3.2 传参注入

在网页显示一篇文章,一定是通过 url 获取 id,在端上也是通过页面传参拿到的,我们在部署端将可能拿到的参数全部注入到展示器中。

3.3.3 数据流接入

如果页面部署在普通网页上,比如做运营页,那就没有数据流概念一说。如果部署在端上,或者部署在一个网页平台上,那部署端自身一定有自己的数据流系统,可能是 redux mobx 等等 mvc mvp 的设计,我们需要考虑将数据流接入这些自有体系中。

  1. 端上将自身数据流抽取出来,端上实例化一份数据实例,每个组件根据数据接口进行数据注入,调用 Action 的方式展现与操作数据。也就是让每个组件都依赖数据接口,组件即便拆出来单独使用,但一旦部署到端上,将会自动接入端上数据流。
  2. 编辑器与展示器都不需要额外处理。

3.4 事件

高阶组件(HOC),原理类似高阶函数,即在原有组件基础之上包装一个组件,这个包装的就是高阶组件,好处是享有一套独立的生命周期,不对原组件产生影响,却又能拓展每个组件的功能。

事件只发生在展示器阶段,事件分为 触发条件动作效果,我们在展示器对每个组件包一层高阶组件,让其支持触发和响应事件。

3.4.1 触发条件

  1. 初始化。在高阶组件初始化的生命周期中触发。
  2. 监听事件。高阶组件初始化时监听事件。
  3. 生命周期。指的是组件自身生命周期也是触发条件的一部分,在调用子组件时,将子组件的回调函数指向动作效果函数即可,但要同一生命周期可以定义多个事件,但回调函数可不一定支持多个,我们需要做序列化处理,伪代码如下:
// 将事件数组按照触发条件聚合,转换成 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 动作效果

  1. 触发事件。展示器实例维护了一个事件实例,通过这个事件系统派发事件。
  2. 修改属性。修改组件自身属性,对 propsmerge 即可。
  3. 调用注入方法。触发展示器的回调函数,调用部署平台的功能。

事件的整体流程如下图所示:

可视化在线编辑器架构设计_第4张图片
4

4 拓展架构设计

为了让编辑器拓展性更强,我们可以将编辑器所有功能以插件方式组装,插件可以插入到编辑器任何位置,也可以插件嵌套插件;插件可以使用编辑器数据流,也可以提供数据流供其它插件使用。

也就是拓展分为数据流拓展UI拓展

mobx-react 是适配 react 的库,将 MobxStore 注入到任意 React,为了保证操作的是同一份实例,初始化时先将所有 Store 实例化一份,并通过传参给根组件 Provider,分发到各个组件。

在 数据流设计 这一章提到了非常灵活的数据注入,首先 mobx-react 利用 context 实现了任意 Action Store 注入在任意 React 组件中,我们只需要实现在 ActionStore 中相互注入即可。

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 份编辑器,比如显示模态框通过 storeshowModal 控制,不至于出现点击一个编辑器的按钮,所有模态框都弹出的结果。

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-reactprovider 中,实现了 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 拓展架构总结

用一张图总结插件拓展的全貌:

可视化在线编辑器架构设计_第5张图片
5

插件与编辑器的数据流是双向互通的,插件的UI可以插入编辑器UI,插件也可以插入插件的UI(不能循环引用)。

5 结语

看到这里,其实编辑器实现原理倒并不重要了,重要的是对数据流、拓展性的设计思路,这些思想迁移到普通类型项目依然适用。当然,如果还有兴趣可以读读编辑器实现源码。

使用遇到的问题,可以用钉钉扫码加群讨论:

可视化在线编辑器架构设计_第6张图片
image.png

你可能感兴趣的:(可视化在线编辑器架构设计)