走進科學(xué)之神秘的拖拽碧油雞
一、寫在前面
小朋友,你是不是有很多的問號。學(xué)過的知識點過段時間就忘了,下次遇到的時候還是需要百度。如果你也遇到了這種情況,那么我們就是異父異母的親兄弟啊!!
好兄弟我經(jīng)過多次的坎坷之后,終于找到了一個好辦法!那就是在修 bug 中學(xué)習(xí)。就像彼得-帕克的叔叔對他說的:能力越大,責(zé)任越大。我也得到了屬于我自己的座右銘,那就是:
bug 越多,能力越大。[手動滑稽]
開個玩笑哈。不過話糙理不糙,當(dāng)我們見識過、修復(fù)過大量的稀奇古怪的 bug 之后,我們的知識也會在修復(fù)過程中融會貫通,并且記憶十分深刻。因為這背后都是一個個加班辛勞的夜晚啊!
最近我就遇到了一個很奇怪的 bug,剛開始我一點頭緒都沒有,只能呆坐在工位上痛苦的撓頭撓頭。后續(xù)在解決這個 bug 的過程中,總結(jié)了以前學(xué)習(xí)的知識點,得到了明顯的進步!!因此寫一篇文章,與大家分享一下。
二、碧油雞是什么雞
首先簡單說一下業(yè)務(wù)場景:在一個管理后臺的列表頁有著多條數(shù)據(jù),數(shù)據(jù)之間存在著順序,現(xiàn)在需要每條數(shù)據(jù)能夠拖拽排序。需求看上去很簡單,只涉及到一個排序接口,難點在于對列表數(shù)據(jù)進行拖拽。
我們?nèi)粘S龅酵献ь愋枨箝_發(fā)的時候,當(dāng)然要看看有沒有合適的輪子使用了。以前開發(fā)中我使用過 vue-draggable 做過兩個容器之間數(shù)據(jù)的拖拽。但是這個列表數(shù)據(jù)拖拽沒有那么復(fù)雜 ,有點大材小用。這個時候另一個輕量級的 js 插件走進了我的眼簾:SortableJS。
1. SostableJS 的使用
SortableJS 很輕量,官網(wǎng)上的 demo 很直觀。但是它的配置項說明文檔寫的很差,有些 api 的說明都找不到,讓人排查問題的時候很是費勁。
- <template>
- <div>
- <!-- 表單 table -->
- <el-table v-loading="loading" :data="currentLessonList" class="p-course-classes-wrapper--class-table">
- <el-table-column prop="lessonName" label="課時名稱"></el-table-column>
- <el-table-column prop="lessonCode" label="課時 ID"></el-table-column>
- <el-table-column prop="gmtCreated" label="添加時間">
- <template slot-scope="scope">
- <span>{{ formatTime(scope.row.gmtCreated )}}</span>
- </template>
- </el-table-column>
- <el-table-column prop="surveyName" label="隨堂測試">
- <template slot-scope="scope">
- <span>{{ scope.row.surveyName || '--'}}</span>
- </template>
- </el-table-column>
- </el-table>
- </div>
- </template>
- <script>
- import Sortable from 'sortablejs'
- export default {
- ...
- activated () {
- // 初始化排序列表
- this.$nextTick(() => {
- const that = this
- const tbody = document.querySelector('.el-table__body-wrapper tbody')
- this.sortObj = new Sortable(tbody, {
- animation: 150,
- sort: true,
- disabled: !that.isCanDrag,
- onEnd: async function (evt) {
- // SortableJS 不改變數(shù)據(jù)的實際順序,但是傳遞新舊索引值,需要開發(fā)者手動根據(jù)索引值改變數(shù)據(jù)順序
- that.currentLessonList.splice(evt.newIndex, 0, that.currentLessonList.splice(evt.oldIndex, 1)[0])
- that.currentLessonList = that.currentLessonList.map((item, index) => {
- return {
- ...item,
- sort: index + 1
- }
- })
- await that.updateLessonsOrder()
- }
- })
- })
- }
- }
- </script>
如上面的代碼所示,SortableJS 的使用很簡單,只需要在頁面初始化的時候,獲取到指定的 dom 節(jié)點,然后 new 一個 Sortable 的實例即可。需要注意的是當(dāng)你站在拖拽列表數(shù)據(jù)的時候,雖然視圖層面上數(shù)據(jù)的順序發(fā)生改變了,但是模型層上的數(shù)據(jù)順序是沒有改變的。所以需要我們在 onEnd 函數(shù)中,手動改變列表數(shù)據(jù)順序。
2. 遇到的神秘問題
完成了上面的代碼的書寫,我原本以為已經(jīng)結(jié)束了,可以安心提測了。但是這個時候,一個神秘的 bug 出現(xiàn)了。當(dāng)我調(diào)試的時候,出現(xiàn)了詭異的現(xiàn)象:第一行的數(shù)據(jù)和第三行的數(shù)據(jù)拖拽交換位置之后,相應(yīng)的 data 數(shù)組順序和視圖完全不一致。
這是什么鬼?重新審視之前寫的代碼,我們的操作似乎沒啥問題。在 SortableJS 移動了真實的 DOM 后,我們在 onEnd 中也改變了 data 中的列表數(shù)組順序。列表數(shù)組數(shù)據(jù)渲染的順序應(yīng)該和真實 DOM 的順序是一致的,但是為什么詭異不一致呢?
3. 問題分析
任何 bug 的修復(fù)都需要進行全面的問題分析,既然視圖層的順序已經(jīng)改變,模型層的數(shù)據(jù)沒有正確改變。那么問題就出在了模型層列表數(shù)組數(shù)據(jù)的更改上!回顧上面寫的代碼,唯一的對于數(shù)組數(shù)據(jù)順序的操作就在 onEnd 函數(shù)中。那么是不是這里出了問題呢?
然而生活沒有這么一帆風(fēng)順的,在 debugger 了 onEnd 函數(shù)后,發(fā)現(xiàn)我所做的操作是正確的。但是就是最終列表數(shù)組順序沒有跟視圖層保持一致!!太難了啊!!
4. 問題解決
在我痛苦的撓頭了一個下午后,終于在谷歌的幫助下找到了答案。先說問題的最終解決方案:那就是在 el-table 標簽上加一個 key,區(qū)分每一條數(shù)據(jù)的唯一性。
- <template>
- <el-table :data="currentLessonList" :row-key="row => row.pkId">
- ....
- </el-table>
- </template>
難以置信,讓我痛苦了一個下午的問題僅僅就需要一行代碼就解決了。
三、探究神秘 bug 的根源
完成了 bug 的修復(fù)之后,我靜下心來梳理一下這個 bug 的來源。這種詭異并且脫離控制的情況讓我很是好奇,想要弄清楚這背后的原因。閱讀了前輩的博客之后,我知道了這個問題發(fā)生的根本原因:Virtual DOM 和真實 DOM 之間出現(xiàn)了不一致。
1. Virtual DOM 與 真實 DOM
在 Vue 框架興起之前,前端開發(fā)使用的還是原生和封裝的 JQuery 框架。但是其本質(zhì)還是對于頁面 DOM 節(jié)點的操作,頂多就是操作的更加方便了。在這個背景下,前端開發(fā)的步驟繞不開獲取 dom 節(jié)點的過程。等到 Vue,React 等框架的流行之后,前端開發(fā)出現(xiàn)了新的開發(fā)模式:不需要關(guān)注和操作 dom 節(jié)點了。
這個時候一個新的概念誕生了——Virtual Dom。虛擬 DOM 是相對于真實的 DOM 而言的。真實 DOM 就是頁面的 DOM 模型,其中有大量的 dom 節(jié)點。在 JQuery 時代,我們開發(fā)過程就是與這些真實 dom 節(jié)點打交道的過程。
我們回憶一下 Vue 的實現(xiàn)原理,在 Vue2.0 之前是通過 defineProperty 依賴注入和跟蹤的方式實現(xiàn)雙向綁定。針對 v-for 指令,如果指定了唯一的 key,則會通過高效的 Diff 算法計算出數(shù)組內(nèi)元素的差異,進行最少的移動或刪除操作。而 Vue2.0 之后引入了 Virtual Dom之后,子元素的 Dom Diff 算法和前者其實是相似的,唯一的區(qū)別就是,2.0 之前 Diff 直接針對 v-for 指定的數(shù)組對象,2.0 之后則針對的是 Virtual Dom。
2. 具體實例
假設(shè)我們的列表元素數(shù)組是:
- let tableData = ['A', 'B', 'C', 'D']
渲染出來后的 DOM 節(jié)點是:
- let tableDate_dom = ['$A', '$B', '$C', '$D']
那么 Virtual Dom 對應(yīng)的結(jié)構(gòu)就是:
- let tableData_vm = [
- {
- el: '$A',
- data: 'A'
- },
- {
- el: '$B',
- data: 'B'
- },
- {
- el: '$C',
- data: 'C'
- },
- {
- el: '$D',
- data: 'D'
- },
- ]
假設(shè)拖拽排序之后,真實的 DOM 變?yōu)椋?/p>
- ['$B', '$A', '$C', '$D']
因為 SortableJS 只操作了真實 DOM,改變了它的位置,而 Virtual Dom 的結(jié)構(gòu)并沒有發(fā)生改變,依然是:
- let tableData_vm = [
- {
- el: '$A',
- data: 'A'
- },
- {
- el: '$B',
- data: 'B'
- },
- {
- el: '$C',
- data: 'C'
- },
- {
- el: '$D',
- data: 'D'
- },
- ]
而我們在實例化 Sortable 實例的時候,在 onEnd 函數(shù)中做了更改數(shù)組數(shù)據(jù)排序的操作,把列表元素也改為和真實 DOM 排序一致:
- ['B', 'A', 'C', 'D']
列表元素更改了之后,這個時候會根據(jù) Diff 算法,重新渲染頁面導(dǎo)致了 bug 的發(fā)生。操作路徑可以粗略的理解為:
拖拽移動真實 DOM -> 操作數(shù)據(jù)數(shù)組 -> Patch 算法再更新真實 DOM
3. 更近一步的探究
筆者在寫到這里的時候,感覺到了力有未逮。原本以為自己已經(jīng)理解了這個 bug 的原因,但是隨著文章的書寫,對之前的開發(fā)細節(jié)進行復(fù)盤的時候,卻發(fā)現(xiàn)知識的網(wǎng)絡(luò)還是多有漏洞。更近一步的探究,就需要去學(xué)習(xí) DOM-Diff 算法的細節(jié),才能真正地得知為什么只需要設(shè)置一個唯一的 key,就能解決這個奇怪并且難以排查的 bug。這一點我還欠缺了很多,希望以后工作中能夠多問一個為什么。
四、自省
回顧整個 bug 修復(fù)的過程,我自身的編碼和知識學(xué)習(xí)存在了幾個問題,梳理出來待以后彌補。
1. 日常編碼規(guī)范的不嚴謹
編碼規(guī)范的問題可以說是貫徹了職業(yè)生涯的開始到結(jié)束。這個問題可大可小,但是如果拉長整個時間線到 10 年、20 年的話,它足以對于我們職業(yè)生涯產(chǎn)生重大影響。就拿這次的問題來說,Vue 官方文檔就說了在使用 v-for 指令時,不推薦直接使用數(shù)組數(shù)據(jù)的 index 作為 key 屬性的值。但是回顧我以前的編碼中,都是圖省事直接使用的 index。因為不涉及到真實 DOM 的改變,所以也沒有出現(xiàn)什么問題。而正是這種沒有什么問題才更加縱容我繼續(xù)使用這種不推薦的書寫方式,終于在這個拖拽需求上栽了個跟頭。
糾正并形成嚴謹?shù)木幋a規(guī)范,不僅提前的避免了一些問題的產(chǎn)生,更是培養(yǎng)了開發(fā)者優(yōu)秀的編程思維。日積月累下來,遵循嚴謹?shù)木幋a規(guī)范的開發(fā)者,對于編程的理解潛移默化中都會得到提高。
2. 知其然不知其所以然
知其然很容易做到。當(dāng)我們遇到了一個 bug 時,采用窮舉、詢問的方式都可以找到解決問題的辦法。但是如果只停留在這里,不去更近一步探究問題發(fā)生的根本原因的話。我們始終都是個編碼的工具人,嗚嗚嗚!
正如現(xiàn)在前端流行的框架 Vue 和 React,大部分人包括我自己更多的停留在框架的使用上面。對于框架的原理一知半解,更多的都是遇到問題臨時抱佛腳。這種情況下,我們的知識深度不夠。那么在遇到一些奇怪的 bug 時,我們的思維被局限在一個很小的空間里面,無法透過現(xiàn)象看本質(zhì)的定位到問題的根源。
老話新說,珍貴的東西總是不能輕易得到的??邕^高山,得到的成就感足以讓我們開心很久很久。
五、小結(jié)
前面給大家喂了一波雞湯后,還是要總結(jié)一下本篇文章。本片文章從筆者工作過程中遇到了一個奇怪的拖拽 bug 談起,描述了業(yè)務(wù)場景,然后談到了具體的解決方案。接著探究 bug 發(fā)生的原因:虛擬 DOM 和真實 DOM 的不一致。然后從這個日常開發(fā)過程很少碰到的情況,通過簡單的 demo 描述了發(fā)生的原因。并進一步定位到了問題的根源:Dom-diff 算法。隨后沒有深入講述 diff 算法,留給各位朋友自行學(xué)習(xí)研究。
讓筆者感慨的是,一個普通的 bug 背后牽扯到了各個方面、深度的知識。那么反過來想,我們在學(xué)習(xí)這些知識的時候。如果只是零敲碎打的學(xué)習(xí),而沒有將其納入到一個系統(tǒng)的知識框架中的話。那么我們永遠也無法提升我們的技術(shù)水平。希望本篇文章能夠給大家?guī)硪恍椭院蟮娜兆永锎蠹乙黄饘W(xué)習(xí)進步哈!
六、參考文章
深入淺出 Vue 中的 key 值:https://juejin.cn/post/6844903865930743815
Vue 中使用 SortableJS:https://www.jianshu.com/p/d92b9efe3e6a
virtual-dom(Vue 實現(xiàn))簡析:https://segmentfault.com/a/1190000010090659
許浩星,微醫(yī)前端技術(shù)部前端工程師。一個認為人生的樂趣一半在靜,一半在動的有志青年!