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

這是可視化編輯器 Gaea-Editor 的第一篇連載分析文章崇呵,希望我能在有限的篇幅講清楚制作這個(gè)網(wǎng)頁(yè)編輯器的動(dòng)機(jī),以及可能帶來(lái)的美好使用前景(畫大餅)秀睛。它會(huì)具有如下幾個(gè)特征:

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

當(dāng)然看完這篇文章偷崩,不僅限于了解這個(gè)編輯器的功能辟拷,我會(huì)非常詳細(xì)介紹其設(shè)計(jì)細(xì)節(jié),只要你仔細(xì)讀它阐斜,完全可以做出自己的網(wǎng)頁(yè)編輯器 _衫冻。

在說(shuō)這個(gè)可視化編輯器之前,不得不提到 React谒出,這是我創(chuàng)作它的動(dòng)機(jī)隅俘。雖然不確定 React 能火多久,但它帶來(lái)的組件化掀起了一場(chǎng)前端界的工業(yè)革命笤喳,當(dāng)然为居,組件化這個(gè)理念也不是 React 首創(chuàng),但 React 大大降低了組件化的成本杀狡,就像發(fā)明了活字印刷術(shù)蒙畴,讓只有貴族才買得起的書本普及到了千家萬(wàn)戶。

在全民組件化的時(shí)代里捣卤,我寫過(guò)幾篇文章介紹如何應(yīng)用和管理組件 :http://www.reibang.com/p/aaca5047a149 以及組件庫(kù)的維護(hù)經(jīng)驗(yàn):https://github.com/fex-team/fit/issues/3 ∪坛椋現(xiàn)在組件化正在越來(lái)越普及,我們掌握了組件開(kāi)發(fā)和管理的規(guī)律后董朝,項(xiàng)目結(jié)構(gòu)組織鸠项,團(tuán)隊(duì)間協(xié)作已經(jīng)取得了飛速進(jìn)步,組件化帶來(lái)效率的提升也會(huì)日漸枯竭子姜,但可視化編輯可能是一條突破瓶頸之路祟绊,第一楼入,在有了現(xiàn)成組件的基礎(chǔ)上,將其遷移到可視化編輯平臺(tái)的成本非常小牧抽,第二嘉熊,代碼之外的頁(yè)面開(kāi)發(fā)更加直觀,加上部分代碼的輔佐會(huì)讓結(jié)構(gòu)組織更高效(類似 Unity 引擎)扬舒。

React 與原生拖拽結(jié)合

網(wǎng)頁(yè)編輯器第一步阐肤,也是最重要的一步,就是拖拽功能了讲坎,我們希望最終效果如圖所示:

drag.gif

如圖所示孕惜,支持隨意拖拽、拖拽動(dòng)畫晨炕,跨父級(jí)拖拽衫画。我們使用 sortablejs 可以達(dá)到此效果,這篇文章重點(diǎn)就是介紹如何結(jié)合到 React瓮栗。

使用 sortable.js

為了支持嵌套拖拽削罩,我們使用開(kāi)發(fā)版地址安裝 "sortablejs":"git://github.com/RubaXa/Sortable.git#dev"

將 sortable 與 react 結(jié)合我們首先會(huì)想到在拖拽結(jié)束后重新 render,但這樣做有如下幾個(gè)缺點(diǎn):

  • sortable 因?yàn)橥献н^(guò)程中改變了 dom 結(jié)構(gòu)费奸,所以操作流暢弥激,但因此生成的 dom 節(jié)點(diǎn)脫離了 react 的控制
  • 排序拖拽后會(huì),sortable 會(huì)刪除之前拖拽的節(jié)點(diǎn)愿阐,導(dǎo)致 react diff 算法刪除元素時(shí)發(fā)現(xiàn) dom 已經(jīng)消失

總結(jié)來(lái)說(shuō)就是既要讓 sortable 操作 dom秆撮,又不能讓 dom 操作導(dǎo)致脫離 react 的控制,我們采用操作回放的方式换况,將 sortable 操作結(jié)束后的 dom 修改回退职辨,再將操作結(jié)果狀態(tài)用 react 刷新

右側(cè)菜單配置

對(duì)右側(cè)菜單配置如下:

Sortable.create(ReactDOM.findDOMNode(this.dragContainerInstance), {
    // 放在一個(gè)組里,可以跨組拖拽
    group: {
        name: 'gaea-layout',
        pull: 'clone',
        put: false
    },
    sort: false,
    delay: 0,
    onStart: (event: any) => {
        // 存儲(chǔ)開(kāi)始拖拽的位置和拖拽結(jié)束的位置
        // ...
    },
    onEnd: (event: any) => {
        // 拖拽菜單時(shí)戈二,真實(shí)元素會(huì)被拖拽走舒裤,拖拽成功的話元素會(huì)重復(fù), 沒(méi)成功拖拽會(huì)被添加到末尾
        // 所以先移除 clone 的元素(吐槽下, 拖走的才是真的, 留下的才是 clone 的)
        // 有 parentNode, 說(shuō)明拖拽完畢還是沒(méi)有被清除, 說(shuō)明被拖走了, 因?yàn)槿绻麤](méi)真正拖動(dòng)成功, clone 元素會(huì)被刪除
        if (event.clone.parentNode) {
            // 有 clone, 說(shuō)明已經(jīng)真正拖走了
            this.dragContainerDomInstance.removeChild(event.clone)
            // 再把真正移過(guò)去的弄回來(lái)
            if (this.lastDragStartIndex === this.dragContainerDomInstance.childNodes.length) {
                // 如果拖拽的是最后一個(gè)
                this.dragContainerDomInstance.appendChild(event.item)
            } else {
                // 拖拽的不是最后一個(gè)
                this.dragContainerDomInstance.insertBefore(event.item, this.dragContainerDomInstance.childNodes[this.lastDragStartIndex])
            }
        } else {
            // 沒(méi)拖走, 只是晃了一下, 不用管了
        }
    }
})

如上代碼注釋寫的很詳盡,解釋一下就是觉吭,從菜單拖拽的配置要用 pull:clone 的方式配置腾供,這樣同一個(gè)元素才可以拖拽多次。put:false 讓菜單不能被其它元素拖入鲜滩。

當(dāng)開(kāi)始拖拽時(shí)伴鳖,保存拖拽后的位置,便于找到用戶拖拽的元素徙硅,在頁(yè)面生成實(shí)例榜聂,同時(shí)保存拖拽前的位置,便于拖拽結(jié)束后恢復(fù)元素嗓蘑。

所以拖拽結(jié)束后须肆,先判斷 event.clone.parentNode匿乃,如果是空,說(shuō)明元素并沒(méi)有被拖走豌汇,所以不需要處理幢炸,否則需要先刪除原先位置留下的 clone dom,因?yàn)檫@個(gè)元素不受 react 控制拒贱,再將真實(shí)拖走的元素還原到之前的位置

視圖區(qū)域配置

編輯器視圖區(qū)域的 sortable 配置比較長(zhǎng)宛徊,因此拆解分析。

group 配置:

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

這個(gè)很容易理解逻澳,因?yàn)橐晥D區(qū)域的元素可以被移走岩调,也可以被其它元素移入,因此 pullput 都是 true赡盘。

開(kāi)始拖拽時(shí)

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

拖拽結(jié)束時(shí)

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

拖拽結(jié)束不需要做特殊處理缰揪,但可以做一些視覺(jué)設(shè)置陨享,比如告訴用戶拖拽結(jié)束了。

有元素新增時(shí)

onAdd: (event: any)=> {
    // 取消 srotable 對(duì) dom 的修改
    // 刪掉 dom 元素, 讓 react 去生成 dom
    if (this.props.viewport.currentMovingComponent.isNew) {
        // 是新拖進(jìn)來(lái)的, 不用管, 因?yàn)楣ぞ邫跁?huì)把它收回去
        // 為什么不刪掉? 因?yàn)檫@個(gè)元素不論是不是 clone, 都被移過(guò)來(lái)了, 不還回去 react 在更新 dom 時(shí)會(huì)無(wú)法找到
    } else {
        // 如果是從某個(gè)元素移過(guò)來(lái)的(新增的,而不是同一個(gè)父級(jí)改變排序)
        // 把這個(gè)元素還給之前拖拽的父級(jí)
        if (this.props.viewport.dragStartParentElement.childNodes.length === 0) {
            // 之前只有一個(gè)元素
            this.props.viewport.dragStartParentElement.appendChild(event.item)
        } else if (this.props.viewport.dragStartParentElement.childNodes.length === this.props.viewport.dragStartIndex) {
            // 是上一次位置是最后一個(gè), 而且父元素有多個(gè)元素
            this.props.viewport.dragStartParentElement.appendChild(event.item)
        } else {
            // 不是最后一個(gè), 而且有多個(gè)元素
            // 插入到它下一個(gè)元素的前一個(gè)
            this.props.viewport.dragStartParentElement.insertBefore(event.item, this.props.viewport.dragStartParentElement.childNodes[this.props.viewport.dragStartIndex])
        }
    }
}

有元素新增后钝腺,有兩種情況:新增元素抛姑,或者從已有元素中拖拽進(jìn)來(lái)新增。

如果是從工具欄拖拽進(jìn)來(lái)新增的元素艳狐,只需要用 react 重新渲染一遍即可定硝。

如果是從其它視圖元素中移入進(jìn)來(lái)的,需要把這個(gè)元素還原到之前拖拽的位置毫目,這樣就回退到 sortable 操作之前的狀態(tài)蔬啡,再用 react 渲染這兩個(gè)父級(jí)組件。

同一父級(jí)內(nèi)元素位置更新時(shí)

onUpdate: (event: any)=> {
    // 同一個(gè)父級(jí)下子元素交換父級(jí)
    // 取消 srotable 對(duì) dom 的修改, 讓元素回到最初的位置即可復(fù)原
    const oldIndex = event.oldIndex as number
    const newIndex = event.newIndex as number
    if (this.props.viewport.dragStartParentElement.childNodes.length === oldIndex + 1) {
        // 是從最后一個(gè)元素開(kāi)始拖拽的
        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)
}

我們只需要對(duì)元素位置進(jìn)行還原镀虐,之后根據(jù)起點(diǎn)位置和終點(diǎn)位置模擬元素移動(dòng)箱蟆,再使用 react 渲染即可。這里需要注意刮便, sortable 的拖拽不是簡(jiǎn)單的a b互換空猜,而是 a -> b,下面用圖簡(jiǎn)單描述一下:

Paste_Image.png

如上圖所示恨旱,同一個(gè)父級(jí)下有6個(gè)元素辈毯,當(dāng)我們拖拽第一個(gè)元素到第5個(gè)元素時(shí),排序不是變成了 5 2 3 4 1 6搜贤,而是如下圖所示:

Paste_Image.png

不可避免的產(chǎn)生了互換谆沃,我們逐一互換元素位置,然后更新父級(jí)元素子元素的位置仪芒。注意此時(shí)最佳狀態(tài)是不觸發(fā) react 元素渲染管毙,我們只要保證子元素的 key 不變, react diff 算法會(huì)自動(dòng)移動(dòng) dom 節(jié)點(diǎn)腿椎,而不是重新渲染 1 2 3 4 5 這 5 個(gè)子節(jié)點(diǎn)。

當(dāng)元素被移走時(shí)

onRemove: (event: any)=> {
    // 渲染父級(jí)元素夭咬,減少一個(gè)子元素在當(dāng)前位置
}

當(dāng)元素被移走時(shí)啃炸,不會(huì)觸發(fā) onUpdate 方法,而會(huì)觸發(fā) onAdd 方法卓舵,但是我們已經(jīng)在 onAdd 方法中將移走的元素還原回去南用,因此這里不需要做任何處理,相當(dāng)于沒(méi)有改動(dòng)掏湾,我們只需要更新 react 父級(jí)元素重新渲染裹虫,讓 react 將元素移走即可。

總結(jié)

基于以上菜單區(qū)域和視圖區(qū)域的博弈融击,終于將 sortable 與 react 渲染完美結(jié)合起來(lái)筑公,然而不用擔(dān)心有什么副作用,因?yàn)槲覀円呀?jīng)將所有 sortable 的操作還原尊浪,所以實(shí)際上只用了它的拖拽過(guò)程已經(jīng)拖拽結(jié)果匣屡,忙到后來(lái)其實(shí)沒(méi)有改變?nèi)魏?dom 結(jié)構(gòu),最終 dom 元素的變化還是由 react 來(lái)控制拇涤。

后續(xù)系列我們會(huì)繼續(xù)剖析實(shí)現(xiàn)部分捣作,以及放上倉(cāng)庫(kù)地址。解析到底是如何將元素放在視圖區(qū)域鹅士,并且并支持無(wú)限層級(jí)嵌套的券躁,敬請(qǐng)期待!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末掉盅,一起剝皮案震驚了整個(gè)濱河市也拜,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌趾痘,老刑警劉巖搪泳,帶你破解...
    沈念sama閱讀 221,635評(píng)論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異扼脐,居然都是意外死亡岸军,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,543評(píng)論 3 399
  • 文/潘曉璐 我一進(jìn)店門瓦侮,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)艰赞,“玉大人,你說(shuō)我怎么就攤上這事肚吏》窖” “怎么了?”我有些...
    開(kāi)封第一講書人閱讀 168,083評(píng)論 0 360
  • 文/不壞的土叔 我叫張陵罚攀,是天一觀的道長(zhǎng)党觅。 經(jīng)常有香客問(wèn)我雌澄,道長(zhǎng),這世上最難降的妖魔是什么杯瞻? 我笑而不...
    開(kāi)封第一講書人閱讀 59,640評(píng)論 1 296
  • 正文 為了忘掉前任镐牺,我火速辦了婚禮,結(jié)果婚禮上魁莉,老公的妹妹穿的比我還像新娘睬涧。我一直安慰自己,他們只是感情好旗唁,可當(dāng)我...
    茶點(diǎn)故事閱讀 68,640評(píng)論 6 397
  • 文/花漫 我一把揭開(kāi)白布畦浓。 她就那樣靜靜地躺著,像睡著了一般检疫。 火紅的嫁衣襯著肌膚如雪讶请。 梳的紋絲不亂的頭發(fā)上,一...
    開(kāi)封第一講書人閱讀 52,262評(píng)論 1 308
  • 那天屎媳,我揣著相機(jī)與錄音夺溢,去河邊找鬼。 笑死剿牺,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的环壤。 我是一名探鬼主播晒来,決...
    沈念sama閱讀 40,833評(píng)論 3 421
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼郑现!你這毒婦竟也來(lái)了湃崩?” 一聲冷哼從身側(cè)響起,我...
    開(kāi)封第一講書人閱讀 39,736評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤接箫,失蹤者是張志新(化名)和其女友劉穎攒读,沒(méi)想到半個(gè)月后,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體辛友,經(jīng)...
    沈念sama閱讀 46,280評(píng)論 1 319
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡薄扁,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,369評(píng)論 3 340
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了废累。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片邓梅。...
    茶點(diǎn)故事閱讀 40,503評(píng)論 1 352
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖邑滨,靈堂內(nèi)的尸體忽然破棺而出日缨,到底是詐尸還是另有隱情,我是刑警寧澤掖看,帶...
    沈念sama閱讀 36,185評(píng)論 5 350
  • 正文 年R本政府宣布匣距,位于F島的核電站面哥,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏毅待。R本人自食惡果不足惜尚卫,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,870評(píng)論 3 333
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望恩静。 院中可真熱鬧焕毫,春花似錦、人聲如沸驶乾。這莊子的主人今日做“春日...
    開(kāi)封第一講書人閱讀 32,340評(píng)論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)级乐。三九已至疙咸,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間风科,已是汗流浹背撒轮。 一陣腳步聲響...
    開(kāi)封第一講書人閱讀 33,460評(píng)論 1 272
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留贼穆,地道東北人题山。 一個(gè)月前我還...
    沈念sama閱讀 48,909評(píng)論 3 376
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像故痊,于是被迫代替她去往敵國(guó)和親顶瞳。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,512評(píng)論 2 359

推薦閱讀更多精彩內(nèi)容

  • 原教程內(nèi)容詳見(jiàn)精益 React 學(xué)習(xí)指南愕秫,這只是我在學(xué)習(xí)過(guò)程中的一些閱讀筆記慨菱,個(gè)人覺(jué)得該教程講解深入淺出,比目前大...
    leonaxiong閱讀 2,842評(píng)論 1 18
  • 發(fā)現(xiàn) 關(guān)注 消息 iOS 第三方庫(kù)戴甩、插件符喝、知名博客總結(jié) 作者大灰狼的小綿羊哥哥關(guān)注 2017.06.26 09:4...
    肇東周閱讀 12,120評(píng)論 4 61
  • Android 自定義View的各種姿勢(shì)1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 172,284評(píng)論 25 707
  • 生活里有這么一類人缴川,事事為他人考慮囱稽,現(xiàn)在所做的一切都是為了他人,這類人是好人二跋,他對(duì)所有的人都很好战惊。 當(dāng)他的自身利益...
    子耳子耳閱讀 854評(píng)論 0 0
  • 每天睜開(kāi)眼的那一瞬間,你的腦子里閃出的是什么呢?是一個(gè)念頭吞获,還是整個(gè)宇宙况凉。一天即將結(jié)束的時(shí)候,感覺(jué)到踏實(shí)各拷,還是覺(jué)得...
    黑夢(mèng)Fay閱讀 217評(píng)論 1 0