這是視覺化編輯器 Gaea-Editor 的第一篇連載分析文章,希望我能在有限的篇幅講清楚製作這個網頁編輯器的動機,以及可能帶來的美好使用前景(畫大餅)。它會具有如下幾個特徵:
- 執行在網頁
- 文件流佈局,絕對定位同時支援
- 對插入的任何 React 元件都可以直接作為編輯元素拖拽到頁面中
- 相容 React-Native 的 web 元件可以讓它生成 android 和 ios 原生頁面
- 擁有 Gaea-Preview 套件,傳入 Gaea-Editor 生成的 json,可以立刻生成頁面
- 擁有 Gaea-web-components Gaea-native-components 分別提供網頁、原生基礎最小粒度的元件
- 可以定製任何 React 元件插入到編輯器中
- 像 chrome-devtools 一樣靈活,可以跨層級排序拖拽任何編輯區的元素
- 可以自定義組合模板,三下五除二搞定相似的需求
當然看完這篇文章,不僅限於瞭解這個編輯器的功能,我會非常詳細介紹其設計細節,只要你仔細讀它,完全可以做出自己的網頁編輯器 ^_^。
在說這個視覺化編輯器之前,不得不提到 React,這是我創作它的動機。雖然不確定 React 能火多久,但它帶來的元件化掀起了一場前端界的工業革命,當然,元件化這個理念也不是 React 首創,但 React 大大降低了元件化的成本,就像發明了活字印刷術,讓只有貴族才買得起的書本普及到了千家萬戶。
在全民元件化的時代裡,我寫過幾篇文章介紹如何應用和管理元件 以及元件庫的維護經驗。現在元件化正在越來越普及,我們掌握了元件開發和管理的規律後,專案結構組織,團隊間協作已經取得了飛速進步,元件化帶來效率的提升也會日漸枯竭,但視覺化編輯可能是一條突破瓶頸之路,第一,在有了現成元件的基礎上,將其遷移到視覺化編輯平臺的成本非常小,第二,程式碼之外的頁面開發更加直觀,加上部分程式碼的輔佐會讓結構組織更高效(類似 Unity 引擎)。
React 與原生拖拽結合
網頁編輯器第一步,也是最重要的一步,就是拖拽功能了,我們希望最終效果如圖所示:
如圖所示,支援隨意拖拽、拖拽動畫,跨父級拖拽。我們使用 sortablejs
可以達到此效果,這篇文章重點就是介紹如何結合到 React。
使用 sortable.js
為了支援巢狀拖拽,我們使用開發版地址安裝
"sortablejs":"git://github.com/RubaXa/Sortable.git#dev"
將 sortable 與 react 結合我們首先會想到在拖拽結束後重新 render,但這樣做有如下幾個缺點:
- sortable 因為拖拽過程中改變了 dom 結構,所以操作流暢,但因此生成的 dom 節點脫離了 react 的控制
- 排序拖拽後會,sortable 會刪除之前拖拽的節點,導致 react diff 演算法刪除元素時發現 dom 已經消失
總結來說就是既要讓 sortable 操作 dom,又不能讓 dom 操作導致脫離 react 的控制,我們採用操作回放的方式,將 sortable 操作結束後的 dom 修改回退,再將操作結果狀態用 react 重新整理。
右側選單配置
對右側選單配置如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
Sortable.create(ReactDOM.findDOMNode(this.dragContainerInstance), { // 放在一個組裡,可以跨組拖拽 group: { name: 'gaea-layout', pull: 'clone', put: false }, sort: false, delay: 0, onStart: (event: any) => { // 儲存開始拖拽的位置和拖拽結束的位置 // ... }, onEnd: (event: any) => { // 拖拽選單時,真實元素會被拖拽走,拖拽成功的話元素會重複, 沒成功拖拽會被新增到末尾 // 所以先移除 clone 的元素(吐槽下, 拖走的才是真的, 留下的才是 clone 的) // 有 parentNode, 說明拖拽完畢還是沒有被清除, 說明被拖走了, 因為如果沒真正拖動成功, clone 元素會被刪除 if (event.clone.parentNode) { // 有 clone, 說明已經真正拖走了 this.dragContainerDomInstance.removeChild(event.clone) // 再把真正移過去的弄回來 if (this.lastDragStartIndex === this.dragContainerDomInstance.childNodes.length) { // 如果拖拽的是最後一個 this.dragContainerDomInstance.appendChild(event.item) } else { // 拖拽的不是最後一個 this.dragContainerDomInstance.insertBefore(event.item, this.dragContainerDomInstance.childNodes[this.lastDragStartIndex]) } } else { // 沒拖走, 只是晃了一下, 不用管了 } } }) |
如上程式碼註釋寫的很詳盡,解釋一下就是,從選單拖拽的配置要用 pull:clone
的方式配置,這樣同一個元素才可以拖拽多次。put:false
讓選單不能被其它元素拖入。
當開始拖拽時,儲存拖拽後的位置,便於找到使用者拖拽的元素,在頁面生成例項,同時儲存拖拽前的位置,便於拖拽結束後恢復元素。
所以拖拽結束後,先判斷 event.clone.parentNode
,如果是空,說明元素並沒有被拖走,所以不需要處理,否則需要先刪除原先位置留下的 clone dom,因為這個元素不受 react 控制,再將真實拖走的元素還原到之前的位置
檢視區域配置
編輯器檢視區域的 sortable 配置比較長,因此拆解分析。
group
配置:
1 2 3 4 5 |
group: { name: 'gaea-layout', pull: true, put: true } |
這個很容易理解,因為檢視區域的元素可以被移走,也可以被其它元素移入,因此 pull
和 put
都是 true
。
開始拖拽時
1 2 3 |
onStart: (event: any) => { // 儲存拖拽前、後的位置 } |
拖拽結束時
1 2 3 |
onEnd: (event: any) => { // 略 } |
拖拽結束不需要做特殊處理,但可以做一些視覺設定,比如告訴使用者拖拽結束了。
有元素新增時
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
onAdd: (event: any)=> { // 取消 srotable 對 dom 的修改 // 刪掉 dom 元素, 讓 react 去生成 dom if (this.props.viewport.currentMovingComponent.isNew) { // 是新拖進來的, 不用管, 因為工具欄會把它收回去 // 為什麼不刪掉? 因為這個元素不論是不是 clone, 都被移過來了, 不還回去 react 在更新 dom 時會無法找到 } else { // 如果是從某個元素移過來的(新增的,而不是同一個父級改變排序) // 把這個元素還給之前拖拽的父級 if (this.props.viewport.dragStartParentElement.childNodes.length === 0) { // 之前只有一個元素 this.props.viewport.dragStartParentElement.appendChild(event.item) } else if (this.props.viewport.dragStartParentElement.childNodes.length === this.props.viewport.dragStartIndex) { // 是上一次位置是最後一個, 而且父元素有多個元素 this.props.viewport.dragStartParentElement.appendChild(event.item) } else { // 不是最後一個, 而且有多個元素 // 插入到它下一個元素的前一個 this.props.viewport.dragStartParentElement.insertBefore(event.item, this.props.viewport.dragStartParentElement.childNodes[this.props.viewport.dragStartIndex]) } } } |
有元素新增後,有兩種情況:新增元素,或者從已有元素中拖拽進來新增。
如果是從工具欄拖拽進來新增的元素,只需要用 react 重新渲染一遍即可。
如果是從其它檢視元素中移入進來的,需要把這個元素還原到之前拖拽的位置,這樣就回退到 sortable 操作之前的狀態,再用 react 渲染這兩個父級元件。
同一父級內元素位置更新時
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
onUpdate: (event: any)=> { // 同一個父級下子元素交換父級 // 取消 srotable 對 dom 的修改, 讓元素回到最初的位置即可復原 const oldIndex = event.oldIndex as number const newIndex = event.newIndex as number if (this.props.viewport.dragStartParentElement.childNodes.length === oldIndex + 1) { // 是從最後一個元素開始拖拽的 this.props.viewport.dragStartParentElement.appendChild(event.item) } else { if (newIndex > oldIndex) { // 如果移到了後面 this.props.viewport.dragStartParentElement.insertBefore(event.item, this.props.viewport.dragStartParentElement.childNodes[oldIndex]) } else { // 如果移到了前面 this.props.viewport.dragStartParentElement.insertBefore(event.item, this.props.viewport.dragStartParentElement.childNodes[oldIndex + 1]) } } this.props.viewport.sortComponents(this.props.mapUniqueKey, event.oldIndex as number, event.newIndex as number) } |
我們只需要對元素位置進行還原,之後根據起點位置和終點位置模擬元素移動,再使用 react 渲染即可。這裡需要注意, sortable 的拖拽不是簡單的a b互換,而是 a -> b,下面用圖簡單描述一下:
如上圖所示,同一個父級下有6個元素,當我們拖拽第一個元素到第5個元素時,排序不是變成了 5 2 3 4 1 6
,而是如下圖所示:
不可避免的產生了互換,我們逐一互換元素位置,然後更新父級元素子元素的位置。注意此時最佳狀態是不觸發 react 元素渲染,我們只要保證子元素的 key
不變, react diff 演算法會自動移動 dom 節點,而不是重新渲染 1 2 3 4 5
這 5 個子節點。
當元素被移走時
1 2 3 |
onRemove: (event: any)=> { // 渲染父級元素,減少一個子元素在當前位置 } |
當元素被移走時,不會觸發 onUpdate
方法,而會觸發 onAdd
方法,但是我們已經在 onAdd
方法中將移走的元素還原回去,因此這裡不需要做任何處理,相當於沒有改動,我們只需要更新 react 父級元素重新渲染,讓 react 將元素移走即可。
總結
基於以上選單區域和檢視區域的博弈,終於將 sortable 與 react 渲染完美結合起來,然而不用擔心有什麼副作用,因為我們已經將所有 sortable 的操作還原,所以實際上只用了它的拖拽過程已經拖拽結果,忙到後來其實沒有改變任何 dom 結構,最終 dom 元素的變化還是由 react 來控制。
後續系列我們會繼續剖析實現部分,以及放上倉庫地址。解析到底是如何將元素放在檢視區域,並且並支援無限層級巢狀的,敬請期待!
打賞支援我寫出更多好文章,謝謝!
打賞作者
打賞支援我寫出更多好文章,謝謝!
任選一種支付方式