Deco 編輯器高擴充套件性技術架構解析

凹凸實驗室發表於2021-12-17

1.背景

Deco 人工干預頁面編輯器是 Deco 工作流重要的一環,Deco 編輯器實現對 Deco 智慧還原鏈路 輸出的結果進行視覺化編排,在 Deco 編輯器中修改智慧還原輸出的 Schema ,最後改造後的 Schema 經過 DSL 處理之後下載目的碼。

編輯器

為了賦能業務,打造智慧程式碼生態,Deco 編輯器除了滿足通用的靜態程式碼下載場景,還需要針對不同的業務方做個性化定製開發,這就必須讓 Deco 編輯器架構設計更加開放,同時在開發層面需要能滿足二次開發的場景。

基於上述背景,在進行編輯器的架構設計時主要追求以下幾個目標:

  • 編輯器介面可配置,可實現定製化開發;
  • 實現第三方元件實時更新渲染;
  • 資料、狀態與檢視解耦,模組之間高內聚低耦合;

2.業務邏輯

2.1 業務邏輯分析

Deco 工作流中貫穿始終的是 D2C Schema ,Deco 編輯器的主要工作就是解析 Schema 生成佈局並操作 Schema ,最後再通過 Schema 來生成程式碼。

入參:已語義化處理之後的 schema json 資料

出參:經過人工干預之後的 schema json 資料

img1

相關 Schema 的介紹可以檢視凹凸技術揭祕·Deco 智慧程式碼·開啟產研效率革命

2.2 業務架構分析

Deco 編輯器主要由 導航狀態列節點樹渲染畫布樣式/屬性編輯皮膚皮膚控制欄等組成。

img2

核心流程是對 schema 的處理過程,所以核心模組是節點樹 + 渲染畫布 + 樣式/屬性編輯皮膚。

節點樹、樣式/屬性編輯皮膚屬於較為獨立的模組(業務邏輯摻雜較少,大部分是互動邏輯),可單獨作為獨立的模組開發。畫布部分涉及佈局渲染邏輯,可作為核心模組開發,導航狀態以及皮膚控制都需要作為核心模組處理。

業務分析完成之後,我們對編輯器有了一個業務模型的初認識,選擇一個合適的技術方案來實現這樣的業務模型至關重要。

3.技術方案設計參考

3.1 system.js + single-spa 微前端框架

基於以上前端業務架構分析,在進行技術方案設計的時候,不難第一時間想到微前端的方案。

將編輯器中各個業務模組拆分成各個微應用,使用 single-spa 在工作臺的整合環境中管理各個微應用。有以下特點:

  • 在無需重新整理的情況下,同一個頁面可執行不同框架的應用;
  • 基於不同框架實現的前端應用可以獨立部署;
  • 支援應用內指令碼懶載入;

缺點:

  • 應用和應用之間狀態管理困難,需要自己實現一個狀態管理機制;

Deco 編輯器暫無多應用需求。契合指數:★★

3.2 Angular

Angular 是一個成熟的前端框架,具有元件模組管理,有以下特點:

  • 內建 module 管理功能,可將不同功能模組打包成一個 module;
  • 內建依賴注入功能,將功能模組注入到應用中;

缺點:

  • 學習曲線陡峭,對新加入專案的同學不友好;
  • 載入第三方元件較複雜;

契合指數:★★★

3.3 React + theia widget + inversify.js

使用 inversify 這個依賴注入框架來對不同的 React Widget 進行注入,同時每個 Widget 可獨立發包。

Widget 的編寫方法參考 theia browser widget 寫法,有以下特點:

  • Widget 代表一個功能模組,如屬性編輯模組、樣式編輯模組;
  • Widget 有自己的生命週期,比如在裝載和解除安裝時有相應鉤子處理方法;
  • 通過 WidgetManager 統一管理所有 Widget;
  • Widget 相互獨立,擴充套件性強;

缺點:

  • 和傳統元件搭建方式區別比較大,有一定挑戰性;
  • API 多且複雜,不易上手;

契合指數:★★★★

3.4 React + inversify.js + mobx + 全域性外掛化元件載入

使用 inversify 來對不同的外掛化元件進行注入,每個外掛化元件獨立發包,同時使用 mobx 來管理全域性狀態以及狀態分發。

使用外掛化元件具有以下特點:

  • 外掛化元件獨立開發,可以通過配置檔案非同步載入到全域性並渲染;
  • 外掛化元件可共享全域性 mobx 狀態,通過 observer 自動更新;
  • 通過 Module Registry 註冊外掛,統一管理外掛載入;
  • 天然契合外部業務元件載入以及渲染方式;

缺點:

  • 外掛開發模式較複雜,需要起不同的服務。

契合指數:★★★★★

基於以上技術方案設計與參考,最終確定了全域性外掛化元件方案,總體的技術棧如下:

描述名稱特性
前端渲染React目前支援動態載入模組
模組管理inversify.js依賴注入,獨立模組可注入各類 Service
狀態管理mobx.js可觀察物件自動繫結元件更新
樣式處理postcss/sass原生 css 預處理
包管理lerna輕鬆搞定monorepo
開發工具vite基於 ES6 Module 載入模組,極速HMR

思路:

  1. 搭建核心元件模組與皮膚控制大體框架,獨立模組可動態注入並渲染
  2. 非同步拉取模組配置檔案,通過配置渲染皮膚,並動態載入皮膚內容
  3. 獨立模組單獨開發,使用 lerna 管理
  4. 業務元件(大促/夸克)皆可作為獨立模組載入
  5. 使用依賴注入管理各個業務模組,使得資料、狀態與檢視解耦

4.技術架構設計

基於以上確定的技術方案以及思路,將編輯器技術架構主要分為一下幾個模組:

  • ModuleRegistry
  • HistoryManager
  • DataCenter
  • CoreStore
  • UserStore

使用 inversify.js 進行模組依賴管理,通過掛載在 window 下的 Container 統一管理:

模組圖

Container 是一個管理各個類例項的容器,在 Container 中獲取類例項可通過 Container.get() 方法獲取。

通過 inversify.js 依賴注入的特性,我們將 HistoryManager、DataCenter 注入到 CoreStore 中,同時模組註冊時使用單例模式,CoreStore 中或 Container 中引用的 HistoryManager 和 DataCenter 就會指向同一個例項,這對於整個應用的狀態一致性提供了保證。

4.1 ModuleRegistry

ModuleRegistry 是用來註冊編輯器中各個容器,Nav、Panels等等,它的主要工作是用來管理容器(載入、解除安裝、切換皮膚等)。

工作臺主要分為 Nav 容器、Left 容器、Main 容器、Panels 容器:

img

每個容器分別承載對應的前端模組,我們設計了一個模組配置檔案module-manifest.json,用於每個容器內載入對應的 js 模組檔案:

{
  "version": "0.0.1",
  "name": "deco.workbench",
  "modules": {
    "nav": {
      "version": "0.0.1",
      "key": "deco.workbench.nav",
      "files": {
        "js": [
          "http://dev.jd.com:3000/nav/dist/nav.umd.js"
        ],
        "css": [
          "http://dev.jd.com:3000/nav/dist/style.css"
        ]
      },
    },
    "left": {
      "version": "0.0.1",
      "key": "deco.workbench.layoute-tree",
      "files": {
        "js": [
          "http://dev.jd.com:3000/layout-tree/dist/layout-tree.umd.js"
        ],
        "css": [
          "http://dev.jd.com:3000/layout-tree/dist/style.css"
        ]
      }
    }
  }
}

ModuleRegistry 處理流程如下:

img2

4.2 CoreStore

CoreStore 用來管理整個應用的狀態,包括 NodeTree 、History(歷史記錄)等。它的主要業務邏輯分為以下幾點:

  1. 獲取 D2C Schema
  2. 將 Schema 轉換成 Node 結構樹
  3. 通過修改、新增、刪除、替換等操作生成新的 Node 結構樹
  4. 將最新的 Node 結構樹推入到 CoreStore 裡注入進來的 History 例項
  5. 儲存 Node 結構樹生成新的 D2C Schema
  6. 獲取最新的 D2C Schema 下載程式碼

CoreStore

CoreStore 從 Container 中注入了 HistoryManager 以及 DataCenter 的例項,大致的使用方式是:

import { injectable, inject } from 'inversify'
import { Context, ContextData } from './context'
import { HistoryManager } from './history'
import { Schema, TYPE } from '../types'

type HistoryData = {
  nodeTree: Schema,
  context: ContextData
}

@injectable() // 宣告可注入模組
class Store {
  /**
   * 歷史記錄
   */
  private history: HistoryManager<HistoryData>
  /**
   * 上下文資料(資料中心)
   */
  private context: Context

  constructor (
    // 依賴注入
    @inject(TYPE.HISTORY_MANAGER) history: HistoryManager<HistoryData>,
    @inject(TYPE.DATA_CONTEXT) context: Context
  ) {
    this.history = history
    this.context = context
  }
}

在以上程式碼塊中,歷史記錄以及資料中心均作為獨立的模組被注入到 CoreStore 中,這裡對相應例項的修改會影響到 Container 下的例項物件,因為它們都指向同一個例項。

4.3 HistoryManager

HistoryManager 主要是用來管理使用者操作歷史記錄資訊,基於依賴注入特性,它可以直接注入到 CoreStore 中使用,並且也可以通過 Container.get() 方法獲取到最新的例項。

HistoryManager 是一個雙向連結串列結構的抽象類,通過儲存資料快照到每一個連結串列節點上,方便且快捷地穿梭歷史記錄。與普通雙向連結串列略有不同的地方是,當 History 連結串列中插入一個節點時,前面的連結串列節點會重新鏈出一個新的分支。

歷史記錄

4.4 DataCenter

資料中心是整個 Deco 編輯器用來管理樓層資料的一個獨立模組,它一開始只用來服務於編輯器本身的應用開發,後來為了方便使用者在編輯器應用裡除錯,資料中心正式以一個功能的方式沉澱了下來。

資料中心

樓層資料是頁面節點在進行資料繫結時所用的真實資料,通過當前節點的資料上下文獲取。如果將這些真實資料繫結在原有的 NodeTree 上,那我們的 NodeTree 將是一個儲存了所有資訊的節點樹,邏輯相當複雜並且冗餘,同時在做 Schema 同步時也是一個無比困難的任務。因此,我們考慮將樓層資料單獨抽出來一個模組進行管理。

如下圖,ContextTree 是資料上下文的資料節點樹,它和 NodeTree 上的節點一一對應繫結,並且通過位置資訊(如 0-0,代表根節點的第一個子節點)繫結在一起,與 NodeTree 不同的是,它是一個具有空間關係的節點樹,如位置 0-2 的節點需要插入一個上下文節點的話,需要將位置為 0-2 的 context 節點插入到位置為 0 的子節點中去,同時將位置為 0-2-0 的 context 節點設為 0-2 節點的子節點。同理,若將 0-2 節點從 ContextTree 中刪掉,則需要將 0-2 節點從 0 節點子節點中刪掉,並且把 0-2-0 節點設為 0 節點的子節點。

context tree

這樣,便將管理資料的模組從 NodeTree 中抽離了出來,DataCenter 獨立管理該頁面的資料上下文,這樣不僅使得我們在程式碼層面做到更加解耦,同時沉澱出了“資料中心”這個功能模組,方便使用者在資料繫結時進行除錯工作。

5 技術難點

5.1 模組管理

5.1.1 inversify

通過以上的架構分析,我們不難看出,雖然 Deco 編輯器主要業務功能邏輯較為簡單,但是其中各個模組相互獨立且相互配合,合作完成編輯器應用的資料、狀態、歷史以及渲染更新的操作,如果只是簡單通過 ES6 Module 的模組管理是遠遠不夠的。由此我們引入了 inversify.js 進行模組的依賴注入管理。

inversify 是一個 IoC(Inversion of Control,控制反轉)庫,它是 AOP(Aspect Oriented Programming,面向切面程式設計)的一個 JavaScript 實現。

編輯器使用 “Singleton” 單例模式,每次從容器中獲取類的時候都是同一個例項。不管是從類中的依賴獲得例項還是從全域性 Container 中獲得例項都是同一個,這樣的特性為整個編輯器應用狀態的一致性提供了有力的保證。AOP 天然的優勢就是模組解耦,它使得編輯器應用的擴充套件性得到了一定程度的提高。

更多關於 AOP 與 IoC 的介紹可參考文章羚瓏 SNS 服務 AOP 與 IoC 的實踐

5.1.2 mobx

得益於 mobx 觀察者模式的狀態更新機制,使得狀態管理與檢視更新更加解耦,為編輯器的狀態維護和模組管理提供了很大的便利。不同的資料狀態(如 AppStore 與 UserStore)之間互相獨立並且互不干擾。

5.2 頁面節點樹的查詢與更新

頁面節點樹(NodeTree)是一個針對 Schema 設計的抽象樹,它的主要功能是對頁面節點進行增刪改查等操作,同時它還對映到渲染模組進行頁面畫布的更新渲染,最後通過一個轉化方法再轉為 Schema 。

NodeTree 是頁面節點的抽象表現,當頁面設計稿比較大(比如大促設計稿)的情況下,節點樹也是一顆相當龐大的抽象樹,在對節點進行查詢的時候,如果通過簡單的深度遍歷演算法進行查詢將有巨大的效能損耗。針對這種情況,我們通過拿到每個節點的位置資訊(如0-0)進行索引匹配查詢,這樣基本實現了無傷查詢。另外,基於 React 更新的機制,NodeTree 節點新增或刪除之後,索引自動更新,省去了手動更新位置資訊的麻煩。

節點位置資訊圖

同時,也是基於節點位置資訊的設計,實現了前面介紹的資料上下文節點的空間資訊維護。

5.3 第三方元件的載入與渲染

Deco智慧程式碼618應用中有提到 Deco 元件識別工作的流程,在 Deco 中,一份元件樣本(檢視)對應一個元件配置,基於元件配置的多樣性,一個元件可能有多個樣本。對於編輯器來說,元件識別服務返回的相似元件推薦其實就是返回了元件的屬性配置資訊,編輯器只要找到對應的樣本元件配置資訊,就可以進行相應的替換工作。那麼,第三方元件是如何載入的呢?

在文章的開頭,我們便介紹了外掛化開發模式,對於 Deco 編輯器來說,第三方元件也是一個外掛,所以只需要將第三方元件庫打包成一個 UMD 格式的 JavaScript 檔案,並且在 module-manifest.json 檔案中配置 deps 外掛資訊即可,這樣第三方元件便以外掛的形式被載入到了編輯器的全域性環境中去。

同時,編輯器儲存了一份第三方元件的配置表,在使用者進行相似元件替換時,通過該配置表獲取對應樣本的配置資訊給到編輯器的畫布模組進行渲染。這裡預設規定第三方元件使用 React 開發,編輯器在渲染的時候使用 React.createElement 原生方法進行元件渲染。

// 元件配置資訊資料結構
export interface AtomComponent {
  id: string
  componentName: string
  logicHoc: string
  type: string
  image: string
  name: string
  props: any
  pkg: string
  tableName: string
  value?: string | number
  children?: (Partial<AtomComponent> | string)[] | string
  propsComponent?: Partial<AtomComponent>[]
}

目前,這份配置表是打包在程式碼裡面的,在編輯器未來的版本中,將會把這份配置表和 Deco 開放平臺相融合,開放給使用者編輯,編輯器在進行初始化載入時會以第三方配置的方式載入進來。

6 最後

目前 Deco 已經支援了 618 、11.11 等背景下的大促會場開發,並且打通了內部低程式碼平臺一鍵進行程式碼構建和頁面預覽,通過 Deco 搭建的數十個樓層成功上線,效率提升達到 48%。

Deco 智慧程式碼專案是凹凸實驗室在「前端智慧化」方向上的探索,我們嘗試從設計稿生成程式碼(DesignToCode)這個切入點入手,對現有的設計到研發這一環節進行能力補全,進而提升產研效率。其中使用到不少演算法能力和AI能力來實現設計稿的解析與識別,感興趣的童鞋歡迎關注我們的賬號「凹凸實驗室」(知乎掘金)。

7 更多文章

設計稿一鍵生成程式碼,研發智慧化探索與實踐

助力雙 11 個性化會場高效交付:Deco 智慧程式碼技術揭祕

超基礎的機器學習入門-原理篇

相關文章