搭建編輯器的可擴展架構探索和實踐

背景

大量的業務運作離不開紛繁複雜的頁面製作,比如淘寶,無論是行業類目還是形態各異的導購營銷方式,背後都是需要製作大量的頁面支撐。對這些頁面製作的過程進行抽象,於是有了 “頁面搭投系統”。

天馬搭建服務就是提供這種頁面搭建能力的服務,然而僅提供實現搭建能力的 API 服務對於服務的接入方而言還是有不少的工作量,尤其是需要自行實現頁面搭建和數據配置這類複雜的前端交互邏輯,而這部分前端交互邏輯又幾乎趨同,於是將這部分邏輯抽離出來,單獨開發了搭建編輯器實現了一套統一的基礎的搭建交互邏輯,於是搭建編輯器 1.0 版本產生了。

儘管搭建編輯器 1.0 能夠已經能夠滿足大部分的頁面搭建需求,但是隨着業務的發展,不同的場景對頁面搭建產生了新的訴求。比如:

  1. 增加新能力:想要在頁面發佈之前添加有一個審覈流程

  2. 去除無關信息:釘釘場景下,物料的數據配置只需要上傳一張圖片,不想有電商屬性的配置干擾

  3. 僅想複用搭建編輯器的部分能力:自定義的頁面編輯,複用搭建編輯器的發佈流程

  4. ...

對於這些訴求,有以下的解決方案:

  1. 方案一:接入方不再使用搭建編輯器,自己重新開發一個新的搭建編輯器,實現自己期望的功能。

  2. 方案二:我們幫業務在搭建編輯器中實現,在搭建編輯器中通過不同的分支語句實現不同場景的需求。

  3. 方案三:將搭建編輯器進行細粒度的拆分,提供基礎組件供接入方按需使用

方案一,存在重複建設,而且當搭建服務推出新功能或者做能力升級的時候,接入方需要重新開發 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 兩部分。

第三步: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  defaultComponent={PublishComponent} {...defalutProps}/>
 
// 替換髮布組件
registerComponent({
  name:'publish-component',
  component:'https://new-publish-component'
})

這樣一來既支持修改業務組件的局部邏輯也支持了擴展的動態按需加載。而且業務組件的任何子組件只需要通過 InjectComponent 進行包裝就可以成爲一個可被替換的組件。

第五步:共享業務組件內部的數據狀態

由於接入方開發的擴展組件不會打包到搭建編輯器內部,這時候就需要有一種方法來獲取搭建編輯器內部的狀態。這時候一個常見的想法就是預先設計好,需要使用的數據狀態通過 props 傳入,只要替換組件與原組件的 props 保持一致,就可以使用搭建編輯器傳入的數據狀態

<InjectComponent  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  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  defaultComponent={默認組件} {...默認的props}/>

即可快速將一個現有的業務組件快速修改爲一個具備擴展能力的組件。

未來

目前的實現相當於將現有的 "橡皮" + "鉛筆" 組合成 "帶橡皮的鉛筆",雖然能夠達到想要的效果,但是還是存在一些問題:

1、搭建編輯器會提供默認的組件,即使進行了組件替換,原組件還是打包在搭建編輯器內部,增加了代碼體積。未來還是期望能夠按照擴展的配置文件,按需打包組件。
2、尚未形成像 vs code 這樣的擴展生態。需要建立統一的擴展開發標準和擴展開發腳手架,逐步建立搭建編輯器的擴展生態,方便對擴展的治理和維護。
3、豐富頁面搭建的能力。 頁面=頁面結構(數據) 是產生一張頁面的基本範式,頁面與數據之間的接口是固定的,但產生頁面結構的方式是靈活的,通過擴展的方式,可以豐富頁面搭建的能力,在不同的場景下使用不同的搭建方式。 
4、目前技術方案中採用的狀態管理是 icestore,微模塊替換方案是 icestack/module,所以還是期望能夠將這套方案集成到 ice 體系,一起開源出去。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/-tMbifp7KvXBO1tmajuRmA