1.背景—
Deco 人工幹預頁面編輯器是 Deco(https://ling-deco.jd.com[1])工作流重要的一環,Deco 編輯器實作對 Deco 智能還原鍊路 輸出的結果進行可視化編排,在 Deco 編輯器中修改智能還原輸出的 Schema ,最後改造後的 Schema 經過 DSL 處理之後下載下傳目标代碼。
為了賦能業務,打造智能代碼生态,Deco 編輯器除了滿足通用的靜态代碼下載下傳場景,還需要針對不同的業務方做個性化定制開發,這就必須讓 Deco 編輯器架構設計更加開放,同時在開發層面需要能滿足二次開發的場景。
基于上述背景,在進行編輯器的架構設計時主要追求以下幾個目标:
- 編輯器界面可配置,可實作定制化開發;
- 實作第三方元件實時更新渲染;
- 資料、狀态與視圖解耦,子產品之間高内聚低耦合;
2.業務邏輯—
2.1 業務邏輯分析
Deco 工作流中貫穿始終的是 D2C Schema ,Deco 編輯器的主要工作就是解析 Schema 生成布局并操作 Schema ,最後再通過 Schema 來生成代碼。
入參:已語義化處理之後的 schema json 資料
出參:經過人工幹預之後的 schema json 資料
相關 Schema 的介紹可以檢視凹凸技術揭秘·Deco 智能代碼·開啟産研效率革命[2]。
2.2 業務架構分析
Deco 編輯器主要由
導航狀态欄
、
節點樹
、
渲染畫布
、
樣式/屬性編輯面闆
、
面闆控制欄
等組成。
核心流程是對 schema 的處理過程,是以核心子產品是節點樹 + 渲染畫布 + 樣式/屬性編輯面闆。
節點樹、樣式/屬性編輯面闆屬于較為獨立的子產品(業務邏輯摻雜較少,大部分是互動邏輯),可單獨作為獨立的子產品開發。畫布部分涉及布局渲染邏輯,可作為核心子產品開發,導航狀态以及面闆控制都需要作為核心子產品處理。
業務分析完成之後,我們對編輯器有了一個業務模型的初認識,選擇一個合适的技術方案來實作這樣的業務模型至關重要。
3.技術方案設計參考—
3.1 system.js + single-spa 微前端架構
基于以上前端業務架構分析,在進行技術方案設計的時候,不難第一時間想到微前端的方案。
将編輯器中各個業務子產品拆分成各個微應用,使用 single-spa[3] 在工作台的內建環境中管理各個微應用。有以下特點:
- 在無需重新整理的情況下,同一個頁面可運作不同架構的應用;
- 基于不同架構實作的前端應用可以獨立部署;
- 支援應用内腳本懶加載;
缺點:
- 應用和應用之間狀态管理困難,需要自己實作一個狀态管理機制;
Deco 編輯器暫無多應用需求。契合指數:★★
3.2 Angular
Angular[4] 是一個成熟的前端架構,具有元件子產品管理,有以下特點:
- 内置 module 管理功能,可将不同功能子產品打包成一個 module;
- 内置依賴注入功能,将功能子產品注入到應用中;
缺點:
- 學習曲線陡峭,對新加入項目的同學不友好;
- 加載第三方元件較複雜;
契合指數:★★★
3.3 React + theia widget + inversify.js
使用 inversify 這個依賴注入架構來對不同的 React Widget[5] 進行注入,同時每個 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 |
思路:
- 搭建核心元件子產品與面闆控制大體架構,獨立子產品可動态注入并渲染
- 異步拉取子產品配置檔案,通過配置渲染面闆,并動态加載面闆内容
- 獨立子產品單獨開發,使用 lerna 管理
- 業務元件(大促/誇克)皆可作為獨立子產品加載
- 使用依賴注入管理各個業務子產品,使得資料、狀态與視圖解耦
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 容器:
每個容器分别承載對應的前端子產品,我們設計了一個子產品配置檔案
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 處理流程如下:
4.2 CoreStore
CoreStore 用來管理整個應用的狀态,包括 NodeTree 、History(曆史記錄)等。它的主要業務邏輯分為以下幾點:
- 擷取 D2C Schema
- 将 Schema 轉換成 Node 結構樹
- 通過修改、添加、删除、替換等操作生成新的 Node 結構樹
- 将最新的 Node 結構樹推入到 CoreStore 裡注入進來的 History 執行個體
- 儲存 Node 結構樹生成新的 D2C Schema
- 擷取最新的 D2C Schema 下載下傳代碼
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 節點的子節點。
這樣,便将管理資料的子產品從 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 的實踐[6]。
5.1.2 mobx
得益于 mobx 觀察者模式的狀态更新機制,使得狀态管理與視圖更新更加解耦,為編輯器的狀态維護和子產品管理提供了很大的便利。不同的資料狀态(如 AppStore 與 UserStore)之間互相獨立并且互不幹擾。
5.2 頁面節點樹的查找與更新
頁面節點樹(NodeTree)是一個針對 Schema 設計的抽象樹,它的主要功能是對頁面節點進行增删改查等操作,同時它還映射到渲染子產品進行頁面畫布的更新渲染,最後通過一個轉化方法再轉為 Schema 。
NodeTree 是頁面節點的抽象表現,當頁面設計稿比較大(比如大促設計稿)的情況下,節點樹也是一顆相當龐大的抽象樹,在對節點進行查找的時候,如果通過簡單的深度周遊算法進行查找将有巨大的性能損耗。針對這種情況,我們通過拿到每個節點的位置資訊(如0-0)進行索引比對查找,這樣基本實作了無傷查找。另外,基于 React 更新的機制,NodeTree 節點添加或删除之後,索引自動更新,省去了手動更新位置資訊的麻煩。
同時,也是基于節點位置資訊的設計,實作了前面介紹的資料上下文節點的空間資訊維護。
5.3 第三方元件的加載與渲染
在 Deco智慧代碼618應用[7]中有提到 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能力來實作設計稿的解析與識别,感興趣的童鞋歡迎關注我們的賬号「凹凸實驗室」(知乎[8]、)。
8 引用連結
—
[1]
deco: https://ling-deco.jd.com
[2]
凹凸技術揭秘·Deco 智能代碼·開啟産研效率革命: https://jelly.jd.com/article/5ffbc4fcdd7c080151c80c74
[3]
single-spa: https://single-spa.js.org/docs/getting-started-overview
[4]
Angular: https://angular.io/
[5]
React Widget: https://github1s.com/eclipse-theia/theia/blob/HEAD/packages/core/src/browser/widgets/widget.ts
[6]
羚珑 SNS 服務 AOP 與 IoC 的實踐: https://jelly.jd.com/article/5fb3623d157ab9926af31198
[7]
Deco智慧代碼618應用: http://shendeng.jd.com/article/detail/926
[8]
知乎: https://www.zhihu.com/people/o2team
[10]
設計稿一鍵生成代碼,研發智能化探索與實踐: https://jelly.jd.com/article/61a6eb9f2a070818620bac2e