来淘宝团队 | 搭建编辑器的可扩展架构探索和实践
背景
大量的业务运作离不开纷繁复杂的页面制作,比如淘宝,无论是行业类目还是形态各异的导购营销方式,背后都是需要制作大量的页面支撑。对这些页面制作的过程进行抽象,于是有了“页面搭投系统”。
天马搭建服务就是提供这种页面搭建能力的服务,然而仅提供实现搭建能力的API服务对于服务的接入方而言还是有不少的工作量,尤其是需要自行实现页面搭建和数据配置这类复杂的前端交互逻辑,而这部分前端交互逻辑又几乎趋同,于是将这部分逻辑抽离出来,单独开发了搭建编辑器实现了一套统一的基础的搭建交互逻辑,于是搭建编辑器1.0版本产生了。
尽管搭建编辑器1.0能够已经能够满足大部分的页面搭建需求,但是随着业务的发展,不同的场景对页面搭建产生了新的诉求。比如:
增加新能力:想要在页面发布之前添加有一个审核流程 去除无关信息:钉钉场景下,物料的数据配置只需要上传一张图片,不想有电商属性的配置干扰 仅想复用搭建编辑器的部分能力:自定义的页面编辑,复用搭建编辑器的发布流程 ...
对于这些诉求,有以下的解决方案:
方案一:接入方不再使用搭建编辑器,自己重新开发一个新的搭建编辑器,实现自己期望的功能。 方案二:我们帮业务在搭建编辑器中实现,在搭建编辑器中通过不同的分支语句实现不同场景的需求。 方案三:将搭建编辑器进行细粒度的拆分,提供基础组件供接入方按需使用
方案一,存在重复建设,而且当搭建服务推出新功能或者做能力升级的时候,接入方需要重新开发UI交互才能做新功能的统一升级。
方案二,虽然能够实现,但是有了一次就会有第二次,会有越来越多的业务让你帮助实现不同场景下的逻辑,最后搭建编辑器的代码就都是分支语句,而且很难维护。
if(场景1){
xxxx
}
if(场景2){
xxxx
}
if(场景3){
xxxx
}
...
方案三,可行但价值不大。一方面增加了对细粒度组件的维护成本,另一方面接入方需要知道搭建编辑器中的数据流状态,自行将细粒度组件组合起来,增加了接入成本,再一方面只有部分业务对部分组件有定制需求,拆解出细粒度组件本质上是为了让部分业务定制。所以能不能探寻一种方案,在不拆解现有组件的情况下,让业务接入方可以快速的定制。
我们对这些诉求做了如下梳理:接入方想要使用搭建编辑器80%的能力,其中20%的能力想要自己做一下拓展定制,包括在指定的位置执行渲染逻辑和使用搭建编辑器中的数据流。
抽象一层就是,期望搭建编辑器能够给定一个渲染组件的节点并支持访问内部的数据状态,而且可以按需独立加载。
于是,我们开始对下一代搭建编辑器进行探索和实践,期望实现以下两种能力:
(1)支持内部组件替换
(a)组件替换
(b)屏蔽不需要的交互逻辑
(2)共享业务组件内部的数据状态
(a)获取组件的内部状态
(b)修改组件的内部状态
**
两个思路
可扩展架构
所有接入方在使用搭建编辑器的时候对接的后端服务本质上都是天马提供的服务,所以可以理解为同一套后端服务在不同业务场景下的前端交互实现。其本质是让搭建编辑器在不同场景下具备不同的能力,参考vscode的设计,我们让搭建编辑器具备扩展的能力,让使用方通过开发扩展的方式来丰富搭建编辑器的能力,从而满足不同场景下的需求,搭建编辑器则提供最核心的搭投能力。
扩展只能够新增一些能力,如果对于现有的搭建编辑器能力有定制需求,期望可以通过替换内部组件的方式进行修改。我们可以将搭建编辑器视为一个组件的容器,其中的每一个子组件都在容器中进行注册
{
name:'组件名',
component:'组件实例'
}
当容器中组件的映射关系发生变化的时候就动态渲染组件,这样就可以通过全局注册组件的方式来进行组件替换。
组件数据状态共享
Redux这样的数据状态管理库已经能够在同一个组件的不同子组件之间跨级共享数据状态,但是两个业务组件之间共享数据状态则需要在这两个业务组件之间加一个公共父组件,再通过Redux这样的数据管理方案进行数据状态的共享,但是想要从业务组件A访问当业务组件B中的数据状态就非常的难。
如果业务组件A和业务组件B的数据状态能够分别维护在组件内部,但是又可以通过某种方式相互访问那就最好不过了。
为了实现上述两个思路,我们做了以下的实现。
设计实现
第一步:核心搭投流程梳理
首先我们对核心搭投流程进行梳理,确定搭建编辑器需要提供的核心能力,也就是搭建编辑器的内核所具备的能力。
定义一次搭投流程:新建页面->选择搭建类型->进入搭建编辑器->页面搭建->数据投放->发布 目前主要为模块搭建为核心流程
第二步:搭建编辑器层级设计
按照搭建流程中使用的核心能力,我们将搭建编辑器设计成UI和Data两部分。
UI: Header:收敛页面级别的操作:页面设置和页面发布。 Editor:页面的核心编辑区域,包括页面插件、搭建页面预览、模块管理和数据配置 Data: Model:全局的数据状态管理
第三步:Model层设计
原来组件之间的数据状态可以通过props传递进行共享,例如,当需要实现点击模块列表的添加模块按钮时弹出模块中心,这时候需要在模块列表和模块中心的公共父组件中通过props将数据状态传递给模块中心和模块列表来实现。
// 模块中心和模块列表伪代码
// 模块中心和模块列表的公共父组件伪代码
import ModuleCenter from 'ModuleCenter';
import ModuleList from 'ModuleList';
import {useState} from 'react;
function App() {
const [moduleCenterVisible,setModuleCenterVisible] = useState(false);
return (
<>
<ModuleCenter
moduleCenterVisible={moduleCenterVisible}
setModuleCenterVisible={setModuleCenterVisible}
/>
<ModuleList setModuleCenterVisible={setModuleCenterVisible}/>
</>
)
}
由于搭建编辑设计之初并没有考虑组件替换和数据状态共享的能力,所以我如果想改变交互方式,能够在Header中点击打开模块中心就成了一件难事。
为了让组件的数据状态能跨区域在多个位置调用,我们需要对Model层进行设计,将原来复杂的依赖关系抽离出来统一管理,用一个全局的数据状态进行管理。
之后,再多一个数据状态,只需要在全局的数据状态管理器上进行注册,其余组件就可以通过全局数据状态拿到这里值了。
我们按照操作的类型将Model层划分为四类:
全局配置 模块操作 页面操作 插件相关
第四步:支持内部组件替换
下一代搭建编辑器核心还是能够修改业务组件的内部细节, 很重要的一点就是组件替换。如果内部组件可以被替换,这样使用者只需要替换自己有定制诉求的那个组件,将开发整个业务组件的成本降低为只开发部分功能组件。比如,某个业务并不需要复杂的发布流程,仅需要一个发布审核,这时候替换掉发布流程是最快复用搭建编辑器的方式。
原发布流程
新增发布审核
我们对可替换的组件进行了接口规范约束:
interface IInjectComponent {
name: string; // 被替换的组件的名称,全局唯一
component?: React.ComponentType | string; // 替换的组件
}
用一个全局的Map来管理name到component的映射关系,component可以是npm包或者cdn的方式。提供一个组件注册的方法registerComponent,用来修改component的映射关系
function registerComponent(props:IInjectComponent|IInjectComponent[]) {
// 替换component的映射关系
}
实现组件替换的能力:
(1)npm包方式加载的本地组件我们采用 React.createElement
的方式进行渲染;
(2)远程cdn加载的的组件,我们使用了@ice/stark-module ,它可以将UMD打包的组件进行远程加载成一个微模块。然后让组件运行时替换。
此时支持内部组件替换的方式基本完成,伪代码的实现方式就是
function InjectComponent(props) {
const {name,defaultComponent,...otherProps} = props;
const component= getComponent(name) || defaultComponent;// 通过name找到Map上注册的组件
if(isRemote(component)) {
return <MicroModule url={component} {...otherProps}/>;
}else {
return React.createElement(component,otherProps)
}
}
//让发布组件可替换,将发布组件用如下方式实现
registerComponent({
name:'publish-component',
component:PublishComponent
})
//defaultComponent用来注册默认的组件
<InjectComponent name="publish-component" defaultComponent={PublishComponent} {...defalutProps}/>
// 替换发布组件
registerComponent({
name:'publish-component',
component:'https://new-publish-component'
})
这样一来既支持修改业务组件的局部逻辑也支持了扩展的动态按需加载。而且业务组件的任何子组件只需要通过 InjectComponent
进行包装就可以成为一个可被替换的组件。
第五步:共享业务组件内部的数据状态
由于接入方开发的扩展组件不会打包到搭建编辑器内部,这时候就需要有一种方法来获取搭建编辑器内部的状态。这时候一个常见的想法就是预先设计好,需要使用的数据状态通过props传入,只要替换组件与原组件的props保持一致,就可以使用搭建编辑器传入的数据状态
<InjectComponent name="publish-component" props1={props1} props2={props} {...otherProps}/>
这种方式会有一个限制,只允许使用传入的props,如果想要使用其他数据状态就需要修改搭建编辑器的代码,增加额外的props。
如果业务组件的数据状态是挂载在全局应用的状态中的,那么就可以全局共享业务组件中的数据状态了。
一个想法就是有一个状态管理库是一个单例模式,通过命名空间来管理数据状态,当同时使用这个状态管理库的两个组件在一个应用中使用的时候就可以通过命名空间访问到对应的数据状态。全局状态管理库只需要具备两个方法registerModel,useModel,伪代码表示:
// 业务组件内部的状态管理
import {registerModel} from 'golbalStore'; // 全局状态管理库
import {useState} from 'react'
function ModleA(){
// 也可以使用Redux,这里为了方便使用useState
const [state1,setState1] = useState()
return {
state1,setState1
}
}
// 按照name进行注册到全局单例上
export default registerModel(ModuleA,{name:'ComA-ModuleA'})
// 在扩展组件中获取业务组件A中的数据状态
import {useModel} from 'golbalStore';
function ExtCom() {
// 通过命名空间找到单例上的Model
const comAModelA = useModel('ComA-ModuleA');
// 访问数据状态
console.log(comAModelA.state1)
...
}
我提供了一个类似Redux的状态管理工具,将Model层注册到全局的单例中。这样,扩展组件只需要通过这个单例就能够快速访问和修改数据状态。
第六步:实现类中间件的方式修改局部状态
有时候只是想修改组件的部分状态并不需要替换掉整个组件,比如,一个搭建编辑器的一个Button文案想要从“发布”修改为“发布页面”,其实只是修改文案,Button的点击逻辑还是想保留,这时候组件替换需要重新实现一遍Button的点击逻辑。
// Button 的使用
import Button from 'Button'
function App(){
return <Button text="发布"}/>
}
假如,Button的文案是通过props传入的,那我们其实只需要一个类似中间件的能力,对传入的props做中转处理,返回我们想要的结果即可。
搭建编辑器实现
<Injectcomponent name="publish-button" component={Button} text="发布"/>
在Injectcomponent内部修改props
function InjectComponent(props){
const = {name,component,...otherProps} = props;
// 调用中间件处理
const newProps = FnModdileWay(otherProps)
...
React.createElement(component,newProps)
}
再举一个复杂的案例,对于想要新增一个发布节点的需求。将问题简化一下就是有一个List中插入一个item的问题
[a,b,c] => [a,d,b,c]
此时,我们需要获取到原始传入的List [a,b,c]
之后对这个List进行操作,添加一个 d
获得新的List [a,d,b,c]
,然后消费新的List。
如果将发布流程中的节点抽象出来作为props传入,然后有一个中间件函数能够对props进行修改,这样就能够满足需求。我们将这类可以对props进行操作的组件称为扩展点ExtensionPoint。
首先我们需要一个中间件的注册函数来告诉组件"传给你的props需要先经过中间件加工",并注册中间件函数。register函数会将中间件函数放入一个队列中。
// 伪代码实现
// 获取ComA-ModuleA的扩展点注册函数,并采用泛型传入props的定义
const register = useExtensionPoint<ComAModuleAProps>('ComA-ModuleA');
// 注册使用
useEffect(()=>{
// 返回一个clean,用于组件卸载的时候清楚中间件和副作用
const clean = register((props)=>{ // 注册
return {
props,
state1:newState1 // 修改新的state
}
})
return () => clean();
},[])
我们将上面提到的InjectComponent进行一轮改造
function InjectComponent(props) {
const {name,...otherProps} = props;
// 通过name找到Map上注册的组件
const component= getComponent(name);
//通过name找到对应的中间件处理函数队列,依次执行中间件函数,会对otherprops进行deepClone
const extProps = useExtension(name,otherprops);
if(isRemote(component)) {
return <MicroModule url={component} {...extProps}/>;
}else {
return React.createElement(component,extProps)
}
}
这样就能方便修改内部组件的props了,从而修改局部的状态,对于新增发布流程节点就是为props新增一个符合节点抽象规范的新节点。
一种通用的将业务组件可扩展化的方案
至此搭建编辑器已经修改为具备可扩展能力的业务组件。而且这种改造方式十分简单,可以被快速移植,只需要将一个应用的状态用一个全局的单例进行管理,并将需要改造的组件修改为下面的方式:
<InjectComponent name="组件名称" defaultComponent={默认组件} {...默认的props}/>
即可快速将一个现有的业务组件快速修改为一个具备扩展能力的组件。
未来
目前的实现相当于将现有的"橡皮" + "铅笔" 组合成 "带橡皮的铅笔",虽然能够达到想要的效果,但是还是存在一些问题:
1、搭建编辑器会提供默认的组件,即使进行了组件替换,原组件还是打包在搭建编辑器内部,增加了代码体积。未来还是期望能够按照扩展的配置文件,按需打包组件。
2、尚未形成像vs code 这样的扩展生态。需要建立统一的扩展开发标准和扩展开发脚手架,逐步建立搭建编辑器的扩展生态,方便对扩展的治理和维护。
3、丰富页面搭建的能力。 页面=页面结构(数据)
是产生一张页面的基本范式,页面与数据之间的接口是固定的,但产生页面结构的方式是灵活的,通过扩展的方式,可以丰富页面搭建的能力,在不同的场景下使用不同的搭建方式。
4、目前技术方案中采用的状态管理是icestore,微模块替换方案是icestack/module,所以还是期望能够将这套方案集成到ice体系,一起开源出去。