Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
ascoders opened this issue Nov 25, 2016 · 0 comments
Open

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

ascoders opened this issue Nov 25, 2016 · 0 comments

Comments

@ascoders
Copy link
Owner

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

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

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

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

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

3.3 动态拓展

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

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

3.3.1 功能注入

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

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

<GaeaPreview onCall={ (functionName, params)=>{ // .. 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<FitGaea.EventData>, key: string) => {
    props[key] = (...args: any[]) => {
        value.forEach(eachValue => {
	        // 执行动作效果,将参数打散传入
            runEvent.apply(this, [eachValue, ...args])
        })
    }
})

3.4.2 动作效果

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

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

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

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

5 结语

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant