可視化在線編輯器架構設計

GemPiscitel 8年前發布 | 23K 次閱讀 軟件架構 前端技術

1 背景

本文開發框架基于 React,涉及 React 部分會對背景做簡單鋪墊。

前端開源江湖非常有意思,競爭是公平的,而且不需要成本,任何一個初入茅廬的學徒都可以找江湖高手過招,且遲早會自成門派,而今前端門派已經燦若繁星,知名的門派也不計其數,其『供需鏈』大致如下:

w3c規范 ==> 瀏覽器實現 ==> 開發引擎 ==> 數據框架 ==> UI框架 ==> 開發者 ==> 用戶

『可視化在線編輯器』指的是引擎這一環,雖然開發引擎在前端并不常見,但看看游戲界就能知道,脫離游戲引擎編碼是多么痛苦的一件事。前端和游戲共同點是都要考慮 UI 和 數據邏輯,其實微軟在做界面開發時就有很多引擎出現,現在前端一點一點向全棧邁進,架構越來越重,分工越來越細,因為 node 讓許多后端開發者接觸前端,將后端沉淀的精髓帶到了前端,而今前端又將觸手延伸到客戶端、PC端甚至硬件領域,逐漸吸收了 開發引擎 的思想,促進前端進入工業時代。

在線編輯器是我在百度負責的主要項目之一,因為需要在 RN 的支持下兼容三端,因此就要設計得更加通用,為了循序漸進的講解,我準備以 設計理念 功能實現 拓展架構設計 的順序敘述。

2 設計理念

在頭腦風暴之前,我們有幾個目標需要提前明確,就像做游戲引擎一樣,如果整體架構沒有設計好,之后的開發將非常痛苦,以下是我重構兩次后總結出的整體要領。

2.1 模塊化

  1. 各司其責,組件化。 編輯器 只是引擎中的一環,還有負責部署在各端的 展示器 ,提供最細粒度"積木"的 基礎組件 ,使用 typescript 的用戶需要的 類型庫 組件。
  2. 精簡核心。 編輯器 的核心功能是組件聚合,包括UI聚合與數據流聚合,以及提供依賴注入的功能,業務功能只要提供 編輯區域渲染拖拽功能
  3. 插件是第一等公民。所有核心功能都通過插件提供,插件的UI、數據流都可以接入編輯器。

2.2 編輯器核心功能精簡

所有編輯功能由插件提供,編輯器只需要實現"任何位置和功能都能由插件替代"的功能即可(詳細說明),這樣編輯器可以理解為一塊神奇磁鐵,其特殊的引力將插件規律的吸附在四周。

2.3 展示器不關注平臺細節

即不要對組件進行 dom 結構的包裝,就可以適應任何平臺(由組件內部實現決定)。

2.4 事件設計

事件可以讓程序活起來,就像 Playmaker 可以不用寫一行代碼,在 Unity3d 做一款小游戲一樣。事件分為 觸發條件動作效果

  1. 觸發條件的 拓展點在于組件的生命周期 ,比如滾動條組件的 onScroll 、按鈕組件的 onClick 都可以作為觸發條件。
  2. 動作效果的 拓展點在于調用平臺特征與修改自身屬性 。調用平臺特征一大好處在于不關心組件實現細節,任何地方都可以調用,比如分享、調起相機等等。修改自身屬性也是通用特征,可以用來顯示模態框、修改數據源等。

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 的設計,我們需要考慮將數據流接入這些自有體系中。

  1. 端上將自身數據流抽取出來,端上實例化一份數據實例,每個組件根據 數據接口 進行數據注入,調用 Action 的方式展現與操作數據。也就是讓每個組件都依賴 數據接口 ,組件即便拆出來單獨使用,但一旦部署到端上,將會自動接入端上數據流。
  2. 編輯器與展示器都不需要額外處理。

3.4 事件

高階組件(HOC),原理類似高階函數,即在原有組件基礎之上包裝一個組件,這個包裝的就是高階組件,好處是享有一套獨立的生命周期,不對原組件產生影響,卻又能拓展每個組件的功能。

事件只發生在展示器階段,事件分為 觸發條件動作效果 ,我們在展示器對每個組件包一層 高階組件 ,讓其支持觸發和響應事件。

3.4.1 觸發條件

  1. 初始化。在高階組件初始化的生命周期中觸發。
  2. 監聽事件。高階組件初始化時監聽事件。
  3. 生命周期。指的是組件自身生命周期也是觸發條件的一部分,在調用子組件時,將子組件的回調函數指向動作效果函數即可,但要同一生命周期可以定義多個事件,但回調函數可不一定支持多個,我們需要做序列化處理, 偽代碼 如下:
// 將事件數組按照觸發條件聚合,轉換成 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 動作效果

  1. 觸發事件。展示器實例維護了一個事件實例,通過這個事件系統派發事件。
  2. 修改屬性。修改組件自身屬性,對 props 做 merge 即可。
  3. 調用注入方法。觸發展示器的回調函數,調用部署平臺的功能。

事件的整體流程如下圖所示:

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

 

 本文由用戶 GemPiscitel 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!