可視化在線編輯器架構設計
1 背景
本文開發框架基于 React,涉及 React 部分會對背景做簡單鋪墊。
前端開源江湖非常有意思,競爭是公平的,而且不需要成本,任何一個初入茅廬的學徒都可以找江湖高手過招,且遲早會自成門派,而今前端門派已經燦若繁星,知名的門派也不計其數,其『供需鏈』大致如下:
w3c規范 ==> 瀏覽器實現 ==> 開發引擎 ==> 數據框架 ==> UI框架 ==> 開發者 ==> 用戶
『可視化在線編輯器』指的是引擎這一環,雖然開發引擎在前端并不常見,但看看游戲界就能知道,脫離游戲引擎編碼是多么痛苦的一件事。前端和游戲共同點是都要考慮 UI 和 數據邏輯,其實微軟在做界面開發時就有很多引擎出現,現在前端一點一點向全棧邁進,架構越來越重,分工越來越細,因為 node 讓許多后端開發者接觸前端,將后端沉淀的精髓帶到了前端,而今前端又將觸手延伸到客戶端、PC端甚至硬件領域,逐漸吸收了 開發引擎 的思想,促進前端進入工業時代。
在線編輯器是我在百度負責的主要項目之一,因為需要在 RN 的支持下兼容三端,因此就要設計得更加通用,為了循序漸進的講解,我準備以 設計理念 功能實現 拓展架構設計 的順序敘述。
2 設計理念
在頭腦風暴之前,我們有幾個目標需要提前明確,就像做游戲引擎一樣,如果整體架構沒有設計好,之后的開發將非常痛苦,以下是我重構兩次后總結出的整體要領。
2.1 模塊化
- 各司其責,組件化。 編輯器 只是引擎中的一環,還有負責部署在各端的 展示器 ,提供最細粒度"積木"的 基礎組件 ,使用 typescript 的用戶需要的 類型庫 組件。
- 精簡核心。 編輯器 的核心功能是組件聚合,包括UI聚合與數據流聚合,以及提供依賴注入的功能,業務功能只要提供 編輯區域渲染 與 拖拽功能 。
- 插件是第一等公民。所有核心功能都通過插件提供,插件的UI、數據流都可以接入編輯器。
2.2 編輯器核心功能精簡
所有編輯功能由插件提供,編輯器只需要實現"任何位置和功能都能由插件替代"的功能即可(詳細說明),這樣編輯器可以理解為一塊神奇磁鐵,其特殊的引力將插件規律的吸附在四周。
2.3 展示器不關注平臺細節
即不要對組件進行 dom 結構的包裝,就可以適應任何平臺(由組件內部實現決定)。
2.4 事件設計
事件可以讓程序活起來,就像 Playmaker 可以不用寫一行代碼,在 Unity3d 做一款小游戲一樣。事件分為 觸發條件 與 動作效果 :
- 觸發條件的 拓展點在于組件的生命周期 ,比如滾動條組件的 onScroll 、按鈕組件的 onClick 都可以作為觸發條件。
- 動作效果的 拓展點在于調用平臺特征與修改自身屬性 。調用平臺特征一大好處在于不關心組件實現細節,任何地方都可以調用,比如分享、調起相機等等。修改自身屬性也是通用特征,可以用來顯示模態框、修改數據源等。
2.5 <span id="store">數據流設計</span>
Mobx 是一個雙向綁定庫,奇特之處在于自動綁定實例用到的屬性,并且在數據變化時僅更新依賴于它的實例。 Inversify 庫實現了依賴注入。
React 本身只是 View 層,僅提供了組件內部狀態 State 以及不建議使用的 Context 維護簡單數據狀態。編輯器復雜度較高,必須借助外部數據流管理,我們使用 Mobx 以及 Inversify 實現雙向綁定和依賴注入,數據流向如下圖所示:
React 觸發刷新常見有三種,除了組件內部調用 setState 更新內部狀態、或者 forceUpdate 強制刷新之外,父級傳參 props 發生了變化一般也會觸發刷新。
React 概念中 props 是傳參,即父級 A 對子組件 B 傳遞了參數 x ,那么 x 就是 B 組件的 props 屬性,對 B 來說是 readyOnly 的。
從頁面組件開始看,將 Action 與 Store 分別注入到頁面中,由于希望數據變動后頁面立刻刷新,我們用 mobx 將 Store 注入到組件的 props 中,而 Action 則通過 Inversify 直接注入為實例的成員變量。
Action 之間也可以相互注入,同樣 Store 也可以相互注入。只允許 Action 修改 Store ,進而觸發頁面 props 變化,頁面刷新。
3 功能實現
3.1 編輯器
需要實現兩種狀態: 編輯態 和 預覽態
3.1.1 編輯狀態
React dom 與 web dom node 不同,使用了虛擬 dom,而且組件不一定有實體 dom,就算最終掛在到了實體 dom 上,如果不將 dom 支持的基本手勢事件暴露出來,組件外部將無法調用。
編輯狀態需要捕獲 click hover 等鼠標事件,由于組件不一定將回調透傳,我們通過 ReactDOM.findDOMNode 拿到組件的 dom 節點直接監聽。
再實現與,編輯器的核心業務邏輯就完成了。
3.1.2 預覽狀態
為了方便將代碼部署在三端,優先考慮的部署方式不是生成代碼,而是生成配置,有一個專門的展示器負責解析配置,部署在不同平臺,具體細節見 展示器 。
因為預覽與實際部署效果一致,所以調用 展示器 傳入當前頁面編輯信息即可。
3.1.3 <span id="drag">拖拽功能</span>
由于支持了內部排序,與外部拖拽,社區的 SortableJs 非常合適擔此重任。
sortablejs 嵌套拖拽 event.oldIndex 在其穩定版本(1.4.2)一直是 0,但這個 bug 在 dev 分支已修復。
我們將 Sortablejs 與 React 結合即可完成拖拽功能,在結合前先介紹一下 React 在 dom 方面的特征:
React 使用虛擬 dom 進行計算,將計算后 diff 結果同步在真實 dom 中,由此 React 對真實 dom 結構依賴非常強,其操作 dom 接口過于底層沒有暴露出來,如果直接操作了 dom 會打亂 React 的算盤。
我們轉換策略,僅僅將 Sortable 庫作為中間動畫使用,并依托其拖拽生命周期,在拖拽結束后獲取用戶拖拽意圖,將 dom 的改動完全還原,再將意圖交由 React 來實現。
偽代碼 如下:
Sortable.sort({
onEnd: (event)=>{
// 將移走的 dom 還原回去,目標元素自然會消失
sourceParentElement.insertBefore(event.item, sourceIndex)
// React 修改兩個父級子元素狀態
action.moveComponent(sourceId, sourceIndex, targetId, targetIndex)
}
})
3.1.4 <span id="sync-edit">實時編輯</span>
將頁面所有編輯元素打平,每個元素渲染時綁定其對應 id 的數據,修改屬性時直接修改對應數據, mobx 會直接更新目標組件實例,如圖所示:
Map 中有一個根節點,從根節點開始渲染,每個節點從數據庫中取到自身數據,如果有子元素,則會遞歸渲染,子元素再從數據庫獲取子元素自身的數據,依次循環,當循環完畢后,我們會得到一顆與 Map 數據一一對應綁定的 dom 樹, Map 中任何一個元素發生改變, Mobx 會通過之前 getter 記錄的關聯關系,主動找到綁定的實例執行 forceUpdate 刷新。
mobx 接入組件的 props 數據不會觸發 render,而是僅通過實例對應關系主動觸發組件的 forceUpdate , Mobx 會在 shouldComponentUpdate 的生命周期中屏蔽掉 observe 類型數據的判斷,因此 Mobx 的數據不會影響 React 的更新循環。
3.1.5 設置為組合
編輯器中,除了設定好在菜單中的組件,還可以讓任意組合形成模板,將模板作為新組件放在組件菜單中。
關鍵點在于如何從打平的數據中獲取組件間關聯關系,并獨立抽出來。
生成模板配置只需獲取全量編輯信息,并進行瘦身即可, 偽代碼 如下:
// 將當前編輯狀態組件的 key、編輯信息和子元素信息一并獲取
let componentFullInfo = action.getComponentFullInfoByKey(currentEditKey)
// 根據 defaultProps 去重,刪除編輯時無用字段等
componentFullInfo = clean(componentFullInfo)
瘦身時使用 lz-string gzip 壓縮,因為配置信息重復的字段很多,甚至大段可能都是復制粘貼的,因為 js 無法傳輸二進制文件,需要轉化為 base64,體積增大了 66%,但還是將 971kb 的配置壓縮到了 78kb。
將模板插入到頁面中,首先將瘦身的信息補全,再 給內部每個組件設置一個全新的 key , 但關聯關系保持不變 ,最后將最外層組件掛載到拖拽到的父級上。 偽代碼 如下:
// 補全組件信息
const componentFullInfoExpend = expend(componentFullInfo)
// 保持父子級關系不變,將所有 Key 全部換掉
const componentFullInfoCopy = copyWithNewKey(componentFullInfoExpend)
// 添加到頁面
addToPage(componentFullInfoCopy)
關聯關系不變,比如組合是 a 有一個子元素 b , key 分別是 keyA keyB ,因為組件 map 需要保證 key 的唯一性,生成一對新的 key keyC keyD ,但 keyB 父級關聯的 keyA 同時也會改為 keyC 。
3.2 展示器
如圖所示,展示器負責部署在各端,目前支持網頁、安卓和蘋果。核心思想是利用 react-native 將組件直接渲染到端上,為了同時適配網頁,使用 react-native-web 配合 webpack ,將 react-native 代碼在網頁端編譯時 alias 到 react-native-web ,用其提供的兼容樣式展現。
展示器還負責將 僅預覽狀態有效的 事件機制、變量配置、動作等激活,利用自身生命周期,以及子組件的回調函數掛上動作鉤子。
3.3 動態拓展
如果說編輯與展示給了應用健壯的軀體,那動態拓展就讓應用活了起來。
動態數據對編輯器來說,是一個拓展功能,分別可以拓展組件的 功能 、 數據來源 以及 融入應用自身的數據流 。
3.3.1 功能注入
就是將平臺特有的功能注入到編輯器生成的頁面中,其實這是一種反向注入的過程,編輯器申明自己想要什么,具體功能是如何實現,效果如何,都完全交由各平臺自己去實現。
更加自由的方式是申明回調函數,編輯器可以發出帶有任意參數的回調,供部署到的平臺任意拓展,平臺部署的 偽代碼 如下:
<GaeaPreview onCall={ (functionName, params)=>{ // .. do something } } />
3.3.2 傳參注入
在網頁顯示一篇文章,一定是通過 url 獲取 id,在端上也是通過頁面傳參拿到的,我們在部署端將可能拿到的參數全部注入到展示器中。
3.3.3 數據流接入
如果頁面部署在普通網頁上,比如做運營頁,那就沒有數據流概念一說。如果部署在端上,或者部署在一個網頁平臺上,那部署端自身一定有自己的數據流系統,可能是 redux mobx 等等 mvc mvp 的設計,我們需要考慮將數據流接入這些自有體系中。
- 端上將自身數據流抽取出來,端上實例化一份數據實例,每個組件根據 數據接口 進行數據注入,調用 Action 的方式展現與操作數據。也就是讓每個組件都依賴 數據接口 ,組件即便拆出來單獨使用,但一旦部署到端上,將會自動接入端上數據流。
- 編輯器與展示器都不需要額外處理。
3.4 事件
高階組件(HOC),原理類似高階函數,即在原有組件基礎之上包裝一個組件,這個包裝的就是高階組件,好處是享有一套獨立的生命周期,不對原組件產生影響,卻又能拓展每個組件的功能。
事件只發生在展示器階段,事件分為 觸發條件 與 動作效果 ,我們在展示器對每個組件包一層 高階組件 ,讓其支持觸發和響應事件。
3.4.1 觸發條件
- 初始化。在高階組件初始化的生命周期中觸發。
- 監聽事件。高階組件初始化時監聽事件。
- 生命周期。指的是組件自身生命周期也是觸發條件的一部分,在調用子組件時,將子組件的回調函數指向動作效果函數即可,但要同一生命周期可以定義多個事件,但回調函數可不一定支持多個,我們需要做序列化處理, 偽代碼 如下:
// 將事件數組按照觸發條件聚合,轉換成 map 類型
const functionMap = getSelfFunctionMap()
functionMap.forEach((value: Array<FitGaea.EventData>, key: string) => {
props[key] = (...args: any[]) => {
value.forEach(eachValue => {
// 執行動作效果,將參數打散傳入
runEvent.apply(this, [eachValue, ...args])
})
}
})
3.4.2 動作效果
- 觸發事件。展示器實例維護了一個事件實例,通過這個事件系統派發事件。
- 修改屬性。修改組件自身屬性,對 props 做 merge 即可。
- 調用注入方法。觸發展示器的回調函數,調用部署平臺的功能。
事件的整體流程如下圖所示:
4 <span id="plugin">拓展架構設計</span>
為了讓編輯器拓展性更強,我們可以將編輯器所有功能以插件方式組裝,插件可以插入到編輯器任何位置,也可以插件嵌套插件;插件可以使用編輯器數據流,也可以提供數據流供其它插件使用。
也就是拓展分為 數據流拓展 與 UI拓展 。
mobx-react 是適配 react 的庫,將 Mobx 的 Store 注入到任意 React ,為了保證操作的是同一份實例,初始化時先將所有 Store 實例化一份,并通過傳參給根組件 Provider ,分發到各個組件。
在這一章提到了非常靈活的數據注入,首先 mobx-react 利用 context 實現了任意 Action Store 注入在任意 React 組件中,我們只需要實現在 Action 與 Store 中相互注入即可。
4.1 數據流拓展
我們希望任意 Action Store 之間都能隨意注入,不會引發循環依賴,可以通過引入中間人的方式解決。我們有 A.ts B.ts 兩個文件,分別在各自的類中引入對方實例,并期望所有對引用的操作都發生在同一實例下(如果組件被實例化多次,我們一定不希望多個實例共享數據),希望的結果 偽代碼 如下:
A.ts
import {inject} from 'inject-instance'
import B from './B'
export default class A {
@inject('B') private b: B
public name = 'aaa'
say() {
console.log('A inject B instance', this.b.name)
}
}</code></pre>
B.ts
import {inject} from 'inject-instance'
import A from './A'
export default class B {
@inject('A') private a: A
public name = 'bbb'
say() {
console.log('B inject A instance', this.a.name)
}
}</code></pre>
入口文件如下,期望輸入注釋中的結果:
import injectInstance from 'inject-instance'
const instances1 = injectInstance(A, B)
instances1.get('A').say()
instances1.get('B').say()
instances1.get('A').name = 'c'
instances1.get('B').say()
// A inject B instance bbb
// B inject A instance aaa
// B inject A instance c
const instances2 = injectInstance(A, B)
instances2.get('A').say()
instances2.get('B').say()
// A inject B instance bbb
// B inject A instance aaa</code></pre>
可以看出,如果實現了 inject-instance ,就可以在 componentWillMount 的生命周期調用 injectInstance ,并傳入所有 Action Store , 不同實例之間數據流獨立 。
不同實例間數據流獨立的意思是,在 class A 中操作注入實例 b 的數據,只會操作當前 class A 歸屬組件實例的數據流中的 b 。如果實例化了 N 份編輯器,比如顯示模態框通過 store 中 showModal 控制,不至于出現點擊一個編輯器的按鈕,所有模態框都彈出的結果。
4.1.1 inject-decorator 實現原理
inject-decorator 是裝飾器,給字段打一個 tag ,告訴之后要執行的 injectInstance 方法:"這個字段要注入 XXX Class,到時候幫我替換一下!"。
偽代碼 如下:
export default (injectName: string): any => (target: any, propertyKey: string, descriptor: PropertyDescriptor): any => {
// 變量值替換為注入類名稱
target[propertyKey] = injectName
// 加入一個標注變量
target['injectArray'].push(propertyKey)
}
es6 箭頭函數實現函數式非常方便,N 層嵌套可以用打平的 N 個 => 表示。
裝飾器 是個函數,如果裝飾器本身帶參數,則變成 2 層嵌套的函數。
將變量值替換成注入類名稱,只是標記到時候替換成什么類的實例,而 injectArray 字段才是打 tag ,執行 injectInstance 時會根據這個字段來替換對應成員變量。
4.1.2 injectInstance 實現原理
將傳入的所有類根據類名放入 Map (僅加快查找用,用空間換時間),因為返回對應實例,所以先全部實例化,再遍歷所有實例,根據 inject-decorator 打的 tag 變量 injectArray 將對應字段替換為實例。
最后,編輯器將得到的全部實例傳入 mobx-react 的 provider 中,實現了 UI 組件注入數據與數據流中注入的數據是統一份實例的效果。
4.2 UI拓展
就是允許插件插入到頁面任何節點,與數據注入不同,數據注入是將所有插件數據流與編輯器自身數據流混在一起,其結構是打平的,像一個 Map 。而UI注入,結構像 Tree 是層疊的,編輯器自身預留許多插槽,允許任何插件插入。
為了更好的拓展性,也允許插件留下插槽,讓其它插件插入,而這樣的好處不僅在于位置靈活,還可以優雅實現『自定義編輯功能』的能力,這個之后再說。
在編輯器或者插件中留一個插槽的 偽代碼 如下:
// 在導航條左側留一個插槽
ApplicationAction.loadingPluginByPosition('navbarLeft')
如果插件類中靜態屬性 Positon = 'navbarLeft' ,他就會插入在左側導航條中。
別忘了,依賴與 inject-instance 的數據流注入功能,插件也可以隨時調用這個方法,因此輕松實現插件預留插槽的功能。
4.2.1 利用 UI 注入實現自定義編輯類型
編輯器一般會提供基礎編輯類型,比如純文本的 text ,下拉選擇框 select 等等,如果用戶希望自定義一種 array 編輯類型,實現對數組字段編輯功能,可以用 UI 注入的方式實現。
為了實現這種方式,編輯組件中,判斷編輯類型的 偽代碼 如下:
ApplicationAction.loadingPluginByPosition('editorAttribute' + editType)
注意,預留插槽的屬性可以存在變量,而且以傳入的編輯類型為結尾,就可以拓展編輯類型了,其它類型的拓展也不在話下。
那么希望支持 array 類型時,編輯器會試圖加載 editorAttributeArray UI組件,那我們定義一個 Position = 'editorAttributeArray' 的組件就可以顯示在這個位置,之后讀取編輯器核心數據流的 currentEditComponent 對當前編輯組件進行操作即可。
4.3 拓展架構總結
用一張圖總結插件拓展的全貌:

插件與編輯器的數據流是雙向互通的,插件的UI可以插入編輯器UI,插件也可以插入插件的UI(不能循環引用)。
5 結語
看到這里,其實編輯器實現原理倒并不重要了,重要的是對數據流、拓展性的設計思路,這些思想遷移到普通類型項目依然適用。當然,如果還有興趣可以讀讀 編輯器實現源碼 。
來自:http://www.jianshu.com/p/840e0b0b2c6a