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