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