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 數據
相關 Schema 的介紹可以查看凹凸技術揭祕·Deco 智能代碼·開啓產研效率革命。
2.2 業務架構分析
Deco 編輯器主要由 導航狀態欄
、節點樹
、渲染畫布
、樣式/屬性編輯面板
、面板控制欄
等組成。
核心流程是對 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 |
思路:
- 搭建核心組件模塊與面板控制大體框架,獨立模塊可動態注入並渲染
- 異步拉取模塊配置文件,通過配置渲染面板,並動態加載面板內容
- 獨立模塊單獨開發,使用 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 的實踐。
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能力來實現設計稿的解析與識別,感興趣的童鞋歡迎關注我們的賬號「凹凸實驗室」(知乎、掘金)。