React Editor 應用編輯器(1) - 拖拽功能剖析

這是可視化編輯器 Gaea-Editor 的第一篇連載分析文章,希望我能在有限的篇幅講清楚制作這個網頁編輯器的動機,以及可能帶來的美好使用前景(畫大餅)。它會具有如下幾個特征:

  1. 運行在網頁
  2. 文檔流布局,絕對定位同時支持
  3. 對插入的任何 React 組件都可以直接作為編輯元素拖拽到頁面中
  4. 兼容 React-Native 的 web 組件可以讓它生成 android 和 ios 原生頁面
  5. 擁有 Gaea-Preview 套件,傳入 Gaea-Editor 生成的 json,可以立刻生成頁面
  6. 擁有 Gaea-web-components Gaea-native-components 分別提供網頁、原生基礎最小粒度的組件
  7. 可以定制任何 React 組件插入到編輯器中
  8. 像 chrome-devtools 一樣靈活,可以跨層級排序拖拽任何編輯區的元素
  9. 可以自定義組合模板,三下五除二搞定相似的需求

當然看完這篇文章,不僅限于了解這個編輯器的功能,我會非常詳細介紹其設計細節,只要你仔細讀它,完全可以做出自己的網頁編輯器 _

在說這個可視化編輯器之前,不得不提到 React,這是我創作它的動機。雖然不確定 React 能火多久,但它帶來的組件化掀起了一場前端界的工業革命,當然,組件化這個理念也不是 React 首創,但 React 大大降低了組件化的成本,就像發明了活字印刷術,讓只有貴族才買得起的書本普及到了千家萬戶。

在全民組件化的時代里,我寫過幾篇文章介紹如何應用和管理組件 :http://www.lxweimin.com/p/aaca5047a149 以及組件庫的維護經驗:https://github.com/fex-team/fit/issues/3 。現在組件化正在越來越普及,我們掌握了組件開發和管理的規律后,項目結構組織,團隊間協作已經取得了飛速進步,組件化帶來效率的提升也會日漸枯竭,但可視化編輯可能是一條突破瓶頸之路,第一,在有了現成組件的基礎上,將其遷移到可視化編輯平臺的成本非常小,第二,代碼之外的頁面開發更加直觀,加上部分代碼的輔佐會讓結構組織更高效(類似 Unity 引擎)。

React 與原生拖拽結合

網頁編輯器第一步,也是最重要的一步,就是拖拽功能了,我們希望最終效果如圖所示:

drag.gif

如圖所示,支持隨意拖拽、拖拽動畫,跨父級拖拽。我們使用 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 刷新

右側菜單配置

對右側菜單配置如下:

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 配置:

group: {
    name: 'gaea-layout',
    pull: true,
    put: true
}

這個很容易理解,因為視圖區域的元素可以被移走,也可以被其它元素移入,因此 pullput 都是 true

開始拖拽時

onStart: (event: any) => {
    // 保存拖拽前、后的位置
}

拖拽結束時

onEnd: (event: any) => {
    // 略
}

拖拽結束不需要做特殊處理,但可以做一些視覺設置,比如告訴用戶拖拽結束了。

有元素新增時

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 渲染這兩個父級組件。

同一父級內元素位置更新時

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,下面用圖簡單描述一下:

Paste_Image.png

如上圖所示,同一個父級下有6個元素,當我們拖拽第一個元素到第5個元素時,排序不是變成了 5 2 3 4 1 6,而是如下圖所示:

Paste_Image.png

不可避免的產生了互換,我們逐一互換元素位置,然后更新父級元素子元素的位置。注意此時最佳狀態是不觸發 react 元素渲染,我們只要保證子元素的 key 不變, react diff 算法會自動移動 dom 節點,而不是重新渲染 1 2 3 4 5 這 5 個子節點。

當元素被移走時

onRemove: (event: any)=> {
    // 渲染父級元素,減少一個子元素在當前位置
}

當元素被移走時,不會觸發 onUpdate 方法,而會觸發 onAdd 方法,但是我們已經在 onAdd 方法中將移走的元素還原回去,因此這里不需要做任何處理,相當于沒有改動,我們只需要更新 react 父級元素重新渲染,讓 react 將元素移走即可。

總結

基于以上菜單區域和視圖區域的博弈,終于將 sortable 與 react 渲染完美結合起來,然而不用擔心有什么副作用,因為我們已經將所有 sortable 的操作還原,所以實際上只用了它的拖拽過程已經拖拽結果,忙到后來其實沒有改變任何 dom 結構,最終 dom 元素的變化還是由 react 來控制。

后續系列我們會繼續剖析實現部分,以及放上倉庫地址。解析到底是如何將元素放在視圖區域,并且并支持無限層級嵌套的,敬請期待!

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,001評論 6 537
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,786評論 3 423
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,986評論 0 381
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,204評論 1 315
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,964評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,354評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,410評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,554評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,106評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,918評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,093評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,648評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,342評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,755評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,009評論 1 289
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,839評論 3 395
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,107評論 2 375

推薦閱讀更多精彩內容

  • 原教程內容詳見精益 React 學習指南,這只是我在學習過程中的一些閱讀筆記,個人覺得該教程講解深入淺出,比目前大...
    leonaxiong閱讀 2,850評論 1 18
  • 發現 關注 消息 iOS 第三方庫、插件、知名博客總結 作者大灰狼的小綿羊哥哥關注 2017.06.26 09:4...
    肇東周閱讀 12,155評論 4 61
  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 172,678評論 25 708
  • 生活里有這么一類人,事事為他人考慮,現在所做的一切都是為了他人,這類人是好人,他對所有的人都很好。 當他的自身利益...
    子耳子耳閱讀 861評論 0 0
  • 每天睜開眼的那一瞬間,你的腦子里閃出的是什么呢?是一個念頭,還是整個宇宙。一天即將結束的時候,感覺到踏實,還是覺得...
    黑夢Fay閱讀 221評論 1 0