一、写在前面
小朋友,你是不是有很多的问号。学过的知识点过段时间就忘了,下次遇到的时候还是需要百度。如果你也遇到了这种情况,那么我们就是异父异母的亲兄弟啊!!
好兄弟我经过多次的坎坷之后,终于找到了一个好办法!那就是在修 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
许浩星,微医前端技术部前端工程师。一个认为人生的乐趣一半在静,一半在动的有志青年!