來淘寶團隊 | 搭建編輯器的可擴展架構探索和實踐
背景
大量的業(yè)務運作離不開紛繁復雜的頁面制作,比如淘寶,無論是行業(yè)類目還是形態(tài)各異的導購營銷方式,背后都是需要制作大量的頁面支撐。對這些頁面制作的過程進行抽象,于是有了“頁面搭投系統(tǒng)”。
天馬搭建服務就是提供這種頁面搭建能力的服務,然而僅提供實現搭建能力的API服務對于服務的接入方而言還是有不少的工作量,尤其是需要自行實現頁面搭建和數據配置這類復雜的前端交互邏輯,而這部分前端交互邏輯又幾乎趨同,于是將這部分邏輯抽離出來,單獨開發(fā)了搭建編輯器實現了一套統(tǒng)一的基礎的搭建交互邏輯,于是搭建編輯器1.0版本產生了。
盡管搭建編輯器1.0能夠已經能夠滿足大部分的頁面搭建需求,但是隨著業(yè)務的發(fā)展,不同的場景對頁面搭建產生了新的訴求。比如:
增加新能力:想要在頁面發(fā)布之前添加有一個審核流程 去除無關信息:釘釘場景下,物料的數據配置只需要上傳一張圖片,不想有電商屬性的配置干擾 僅想復用搭建編輯器的部分能力:自定義的頁面編輯,復用搭建編輯器的發(fā)布流程 ...
對于這些訴求,有以下的解決方案:
方案一:接入方不再使用搭建編輯器,自己重新開發(fā)一個新的搭建編輯器,實現自己期望的功能。 方案二:我們幫業(yè)務在搭建編輯器中實現,在搭建編輯器中通過不同的分支語句實現不同場景的需求。 方案三:將搭建編輯器進行細粒度的拆分,提供基礎組件供接入方按需使用
方案一,存在重復建設,而且當搭建服務推出新功能或者做能力升級的時候,接入方需要重新開發(fā)UI交互才能做新功能的統(tǒng)一升級。
方案二,雖然能夠實現,但是有了一次就會有第二次,會有越來越多的業(yè)務讓你幫助實現不同場景下的邏輯,最后搭建編輯器的代碼就都是分支語句,而且很難維護。
if(場景1){
xxxx
}
if(場景2){
xxxx
}
if(場景3){
xxxx
}
...
方案三,可行但價值不大。一方面增加了對細粒度組件的維護成本,另一方面接入方需要知道搭建編輯器中的數據流狀態(tài),自行將細粒度組件組合起來,增加了接入成本,再一方面只有部分業(yè)務對部分組件有定制需求,拆解出細粒度組件本質上是為了讓部分業(yè)務定制。所以能不能探尋一種方案,在不拆解現有組件的情況下,讓業(yè)務接入方可以快速的定制。
我們對這些訴求做了如下梳理:接入方想要使用搭建編輯器80%的能力,其中20%的能力想要自己做一下拓展定制,包括在指定的位置執(zhí)行渲染邏輯和使用搭建編輯器中的數據流。
抽象一層就是,期望搭建編輯器能夠給定一個渲染組件的節(jié)點并支持訪問內部的數據狀態(tài),而且可以按需獨立加載。
于是,我們開始對下一代搭建編輯器進行探索和實踐,期望實現以下兩種能力:
(1)支持內部組件替換
(a)組件替換
(b)屏蔽不需要的交互邏輯
(2)共享業(yè)務組件內部的數據狀態(tài)
(a)獲取組件的內部狀態(tài)
(b)修改組件的內部狀態(tài)
**
兩個思路
可擴展架構
所有接入方在使用搭建編輯器的時候對接的后端服務本質上都是天馬提供的服務,所以可以理解為同一套后端服務在不同業(yè)務場景下的前端交互實現。其本質是讓搭建編輯器在不同場景下具備不同的能力,參考vscode的設計,我們讓搭建編輯器具備擴展的能力,讓使用方通過開發(fā)擴展的方式來豐富搭建編輯器的能力,從而滿足不同場景下的需求,搭建編輯器則提供最核心的搭投能力。
擴展只能夠新增一些能力,如果對于現有的搭建編輯器能力有定制需求,期望可以通過替換內部組件的方式進行修改。我們可以將搭建編輯器視為一個組件的容器,其中的每一個子組件都在容器中進行注冊
{
name:'組件名',
component:'組件實例'
}
當容器中組件的映射關系發(fā)生變化的時候就動態(tài)渲染組件,這樣就可以通過全局注冊組件的方式來進行組件替換。
組件數據狀態(tài)共享
Redux這樣的數據狀態(tài)管理庫已經能夠在同一個組件的不同子組件之間跨級共享數據狀態(tài),但是兩個業(yè)務組件之間共享數據狀態(tài)則需要在這兩個業(yè)務組件之間加一個公共父組件,再通過Redux這樣的數據管理方案進行數據狀態(tài)的共享,但是想要從業(yè)務組件A訪問當業(yè)務組件B中的數據狀態(tài)就非常的難。
如果業(yè)務組件A和業(yè)務組件B的數據狀態(tài)能夠分別維護在組件內部,但是又可以通過某種方式相互訪問那就最好不過了。
為了實現上述兩個思路,我們做了以下的實現。
設計實現
第一步:核心搭投流程梳理
首先我們對核心搭投流程進行梳理,確定搭建編輯器需要提供的核心能力,也就是搭建編輯器的內核所具備的能力。
定義一次搭投流程:新建頁面->選擇搭建類型->進入搭建編輯器->頁面搭建->數據投放->發(fā)布 目前主要為模塊搭建為核心流程
第二步:搭建編輯器層級設計
按照搭建流程中使用的核心能力,我們將搭建編輯器設計成UI和Data兩部分。
UI: Header:收斂頁面級別的操作:頁面設置和頁面發(fā)布。 Editor:頁面的核心編輯區(qū)域,包括頁面插件、搭建頁面預覽、模塊管理和數據配置 Data: Model:全局的數據狀態(tài)管理
第三步:Model層設計
原來組件之間的數據狀態(tài)可以通過props傳遞進行共享,例如,當需要實現點擊模塊列表的添加模塊按鈕時彈出模塊中心,這時候需要在模塊列表和模塊中心的公共父組件中通過props將數據狀態(tài)傳遞給模塊中心和模塊列表來實現。
// 模塊中心和模塊列表偽代碼
// 模塊中心和模塊列表的公共父組件偽代碼
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}/>
</>
)
}
由于搭建編輯設計之初并沒有考慮組件替換和數據狀態(tài)共享的能力,所以我如果想改變交互方式,能夠在Header中點擊打開模塊中心就成了一件難事。
為了讓組件的數據狀態(tài)能跨區(qū)域在多個位置調用,我們需要對Model層進行設計,將原來復雜的依賴關系抽離出來統(tǒng)一管理,用一個全局的數據狀態(tài)進行管理。
之后,再多一個數據狀態(tài),只需要在全局的數據狀態(tài)管理器上進行注冊,其余組件就可以通過全局數據狀態(tài)拿到這里值了。
我們按照操作的類型將Model層劃分為四類:
全局配置 模塊操作 頁面操作 插件相關

第四步:支持內部組件替換
下一代搭建編輯器核心還是能夠修改業(yè)務組件的內部細節(jié), 很重要的一點就是組件替換。如果內部組件可以被替換,這樣使用者只需要替換自己有定制訴求的那個組件,將開發(fā)整個業(yè)務組件的成本降低為只開發(fā)部分功能組件。比如,某個業(yè)務并不需要復雜的發(fā)布流程,僅需要一個發(fā)布審核,這時候替換掉發(fā)布流程是最快復用搭建編輯器的方式。
原發(fā)布流程
新增發(fā)布審核
我們對可替換的組件進行了接口規(guī)范約束:
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)
}
}
//讓發(fā)布組件可替換,將發(fā)布組件用如下方式實現
registerComponent({
name:'publish-component',
component:PublishComponent
})
//defaultComponent用來注冊默認的組件
<InjectComponent name="publish-component" defaultComponent={PublishComponent} {...defalutProps}/>
// 替換發(fā)布組件
registerComponent({
name:'publish-component',
component:'https://new-publish-component'
})
這樣一來既支持修改業(yè)務組件的局部邏輯也支持了擴展的動態(tài)按需加載。而且業(yè)務組件的任何子組件只需要通過 InjectComponent 進行包裝就可以成為一個可被替換的組件。
第五步:共享業(yè)務組件內部的數據狀態(tài)
由于接入方開發(fā)的擴展組件不會打包到搭建編輯器內部,這時候就需要有一種方法來獲取搭建編輯器內部的狀態(tài)。這時候一個常見的想法就是預先設計好,需要使用的數據狀態(tài)通過props傳入,只要替換組件與原組件的props保持一致,就可以使用搭建編輯器傳入的數據狀態(tài)
<InjectComponent name="publish-component" props1={props1} props2={props} {...otherProps}/>
這種方式會有一個限制,只允許使用傳入的props,如果想要使用其他數據狀態(tài)就需要修改搭建編輯器的代碼,增加額外的props。
如果業(yè)務組件的數據狀態(tài)是掛載在全局應用的狀態(tài)中的,那么就可以全局共享業(yè)務組件中的數據狀態(tài)了。
一個想法就是有一個狀態(tài)管理庫是一個單例模式,通過命名空間來管理數據狀態(tài),當同時使用這個狀態(tài)管理庫的兩個組件在一個應用中使用的時候就可以通過命名空間訪問到對應的數據狀態(tài)。
全局狀態(tài)管理庫只需要具備兩個方法registerModel,useModel,偽代碼表示:
// 業(yè)務組件內部的狀態(tài)管理
import {registerModel} from 'golbalStore'; // 全局狀態(tài)管理庫
import {useState} from 'react'
function ModleA(){
// 也可以使用Redux,這里為了方便使用useState
const [state1,setState1] = useState()
return {
state1,setState1
}
}
// 按照name進行注冊到全局單例上
export default registerModel(ModuleA,{name:'ComA-ModuleA'})
// 在擴展組件中獲取業(yè)務組件A中的數據狀態(tài)
import {useModel} from 'golbalStore';
function ExtCom() {
// 通過命名空間找到單例上的Model
const comAModelA = useModel('ComA-ModuleA');
// 訪問數據狀態(tài)
console.log(comAModelA.state1)
...
}
我提供了一個類似Redux的狀態(tài)管理工具,將Model層注冊到全局的單例中。這樣,擴展組件只需要通過這個單例就能夠快速訪問和修改數據狀態(tài)。
第六步:實現類中間件的方式修改局部狀態(tài)
有時候只是想修改組件的部分狀態(tài)并不需要替換掉整個組件,比如,一個搭建編輯器的一個Button文案想要從“發(fā)布”修改為“發(fā)布頁面”,其實只是修改文案,Button的點擊邏輯還是想保留,這時候組件替換需要重新實現一遍Button的點擊邏輯。

// Button 的使用
import Button from 'Button'
function App(){
return <Button text="發(fā)布"}/>
}
假如,Button的文案是通過props傳入的,那我們其實只需要一個類似中間件的能力,對傳入的props做中轉處理,返回我們想要的結果即可。
搭建編輯器實現
<Injectcomponent name="publish-button" component={Button} text="發(fā)布"/>
在Injectcomponent內部修改props
function InjectComponent(props){
const = {name,component,...otherProps} = props;
// 調用中間件處理
const newProps = FnModdileWay(otherProps)
...
React.createElement(component,newProps)
}
再舉一個復雜的案例,對于想要新增一個發(fā)布節(jié)點的需求。將問題簡化一下就是有一個List中插入一個item的問題
[a,b,c] => [a,d,b,c]
此時,我們需要獲取到原始傳入的List [a,b,c] 之后對這個List進行操作,添加一個 d 獲得新的List [a,d,b,c] ,然后消費新的List。
如果將發(fā)布流程中的節(jié)點抽象出來作為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找到對應的中間件處理函數隊列,依次執(zhí)行中間件函數,會對otherprops進行deepClone
const extProps = useExtension(name,otherprops);
if(isRemote(component)) {
return <MicroModule url={component} {...extProps}/>;
}else {
return React.createElement(component,extProps)
}
}
這樣就能方便修改內部組件的props了,從而修改局部的狀態(tài),對于新增發(fā)布流程節(jié)點就是為props新增一個符合節(jié)點抽象規(guī)范的新節(jié)點。
一種通用的將業(yè)務組件可擴展化的方案
至此搭建編輯器已經修改為具備可擴展能力的業(yè)務組件。而且這種改造方式十分簡單,可以被快速移植,只需要將一個應用的狀態(tài)用一個全局的單例進行管理,并將需要改造的組件修改為下面的方式:
<InjectComponent name="組件名稱" defaultComponent={默認組件} {...默認的props}/>
即可快速將一個現有的業(yè)務組件快速修改為一個具備擴展能力的組件。
未來
目前的實現相當于將現有的"橡皮" + "鉛筆" 組合成 "帶橡皮的鉛筆",雖然能夠達到想要的效果,但是還是存在一些問題:
1、搭建編輯器會提供默認的組件,即使進行了組件替換,原組件還是打包在搭建編輯器內部,增加了代碼體積。未來還是期望能夠按照擴展的配置文件,按需打包組件。
2、尚未形成像vs code 這樣的擴展生態(tài)。需要建立統(tǒng)一的擴展開發(fā)標準和擴展開發(fā)腳手架,逐步建立搭建編輯器的擴展生態(tài),方便對擴展的治理和維護。
3、豐富頁面搭建的能力。 頁面=頁面結構(數據) 是產生一張頁面的基本范式,頁面與數據之間的接口是固定的,但產生頁面結構的方式是靈活的,通過擴展的方式,可以豐富頁面搭建的能力,在不同的場景下使用不同的搭建方式。
4、目前技術方案中采用的狀態(tài)管理是icestore,微模塊替換方案是icestack/module,所以還是期望能夠將這套方案集成到ice體系,一起開源出去。
