哈喽大家好,这次终于带来了这篇关于我是如何解决 el-table 的其中一个 issue 的文章了。相信有关注笔者的童鞋都知道,笔者之前有写过一篇分析问题原因的文章,这次终于带来了解决方案的文章啦...
好好!笔者承认是标题党了,毕竟真正的疑难点更多是在问题的分析中。回顾之前的问题分析的过程,也就是从 Issue 看 El-Table 源码,给 Element+ 提 Pr 的背后竟如此坎坷!这篇文章,里面详细介绍了 el-table 的基本源码实现和 issue 的产生原因。而本文呢,笔者主要讲一下是怎么解决这个问题的,并且发发小牢骚吧。
ok,那在进入正文之前,请让我特别感谢一下 @tolking 大哥,感谢一路以来的代码 review 和思路调整提醒,pr 最终能被合并,感激不尽,谢谢强哥。
一、 前提回顾
由于 table 这个组件也比较复杂,用户量也不少,所以整个 pr 从提交到合并基本上也经历了两个月了(中间还有段时间是春节)。这个 pr 合并且被发到正式包中到今天也有一定的时间了,目前暂时没有看到有相关的 issue,所以目测本次改动算是成功的,这也是这篇文章迟迟才出现的最主要原因。
1. 问题回顾
废话不多说了,我们来首先来回顾一下问题吧:使用 v-for 生成 <el-table-column /> 的 table ,添加 key 属性后无法改变 columns 的顺序:
如上所示,点击按钮互换列时,其中一个 table 稳如老狗,丝毫不动...这就是本文要解决的问题。那问题的根源是什么?我们接着往下。
2. 原因分析回顾
详细的了解可以点击 从 Issue 看 El-Table 源码,给 Element+ 提 Pr 的背后竟如此坎坷! 这篇文章去看看。笔者这里也不过多展开了就简单的总结概括一下原因。
首先需要明确的是 el-table-column 组件最终渲染到界面中是一个空 div。可以简单理解这个组件的存在只是为了收集用户对 table 列定义的数据,不负责 table 具体单元格的渲染。
其次,Vue3 组件更新对 v-for 带 key 和无 key 的处理差异:
- 有
key,命中Vue3的一个diff流程。 - 无
key,直接对公共长度部分进行patch(也就是这个patch,会触发column的label值的变动)。
如果大家对 v-for 组件更新的源码实现感兴趣的朋友可以去看看这个老哥的文章,写得很好:v-for 到底为啥要加上 key?。
首先我们看看,有 key 时候为什么换列失败?
这是笔者之前分析文章时候用的 gif 图,该动图可以清晰的看到,在有
key 的情况下,这个空 div 命中 diff 的逻辑,仅仅进行了 这个空 div(DOM 层面)的换位,因此并没有触发 table 组件的 rerender,并且列之间的数据也没有改变,自然没有“换列”的效果产生。
那么,无 key 时候的换列行为可以参考笔者的一个图:
这里要回顾一下上文提到 无 key 时 Vue 对组件更新的处理:直接 patch !这一点非常重要!
由上图可得,其实真正触发换列的并不是真的把当前 column 的数据跟下个 column 的数据进行互换,而是直接改变当前 column 的数据,因此也会触发 table 的 rerender ,这样一来就带我们这种互换列的行为。所以明白了原因的你,知道 table 的互换列是怎么来的了吧?
简单总结:
- 有
key时table无rerender,且column数据未变。 - 无
key时,每个column组件直接patch,会改变label、prop等响应式数据,并会触发table的rerender,因此会重新渲染一个“换列”后的table。
二、解决问题
上文分析到有 key 时会命中 diff 的逻辑,导致 column组件 仅仅进行 DOM 的位置互换,然后就没有然后了。那我们只要想办法把 DOM 的变化,同步到 columns 的数据里就行了不是吗?于是,我们就开始吧...
1. 熟悉 MutationObserver
说到要想办法监测 DOM 变化,没错,笔者首先想到的就是这个 Api ,这也是笔者解决这个 issue 的核心。当然,磨刀不误砍柴工,首先让我们来熟悉熟悉一下这个好基友,毕竟给一个算大型的开源项目提 pr,还是要准备全面一点,才容易被合并!用法笔者就不多说了,直接看文档吧,笔者主要讲讲它的一个执行时机(或者说关乎性能影响)。
基本用法参考 MDN 即可:MutationObserver
关于执行时机,我们都知道 MutationObserver 属于微任务,熟悉 Vue2 源码的同学可能也知道 nextTick 的其中一个实现方案就是用了 MutationObserver 来实现异步。那怎么理解这个微任务呢?我们接着说。
微任务也就决定了 MutationObserver 的监测是一种异步行为,并且会在本轮 EventLoop 的微任务队列中对监测到变化的 DOM 进行 notify,以执行对应的 callback 函数(这就是其属于微任务的一种体现)。首先我们来看看 MutationObserver 的定义规范:
根据规范的介绍中,我们不难发现:MutationObserver 会对 DOM 进行异步观测,将观测到有变化的 observers 放到微任务队列中,再去通知这些 observers。
这里其实很像是一个发布订阅的关系。我们监听一个 DOM 变化,提供一个 callback,当 DOM 变化时就会触发 callback 的执行。唯一不同的就是这个 api 是异步的,它不会因为我们同时改变一个 DOM 节点多次就执行多次,而仅仅在当前循环的微任务中执行一次,所以从性能角度看待它应该还算 ok。
那我们不妨直接做个小实验来验证一下。实验前准备:
- 通过浏览器的
stroe as global variabl来选中一个节点进行观测: - 初始化
MutationObserver(直接去MDN那copy一个)
小实验开始:
-
跟
Promise.then进行实验比较:由执行顺序的结果不难发现,
MutationObserver的执行按照我们的代码顺序执行。也就是说它被插入在微任务队列中,并处理两个promise.then的中间。我们试着改变一下他们的调用顺序,结果也是跟随代码顺序的: -
同步多次改变
DOM,观测callback只执行一次:如图所示,我们在同步代码中改变了
temp1的innerText两次,但只打印了一次'MutationObserver'。
好了,相信看到这里已经对 MutationObserver 这个 api 有一定的熟悉了,简单总结一下:
- 异步执行(属于微任务)。观测到的
DOM变化的observer会被添加到本轮EventLoop中的微任务队列中。
2. 看源码——解决 issue
上面花了点篇幅介绍 MutationObserver,毕竟这个就是整个解决方案的核心了。有了它,其他的实现大家都可以猜到大概的做法了,笔者就简单介绍一下实现吧。
首先我们捋清思路,其实就三步:
- 捕捉
DOM变化 - 通知
column组件并更新当前column数据的顺序(当前DOM顺序其实就是columns数据的顺序) table组件rerender。
所以,首先我们来看第一步,捕捉 DOM 变化的实现:
其实这里就是对 MutationObserver 的一个应用。很简单,在 table 组件 mounted 阶段执行监测 column 组件的实体 div 顺序变化,所以 observer 的 DOM 是 .hidden-columns。不清楚的可以看下图:
这里笔者在 MutationObserver 的 callback 中只做了一件事,就是通知一个队列中的所有观察者(这些观察者属于每一个 table-column 组件):
updateOrderFns.forEach((fn: () => void) => fn())
到这一步,我们来看看 updateOrderFns 这个队列中是观察者什么时候加进去的:
其实就在
column 组件的 mounted 阶段,笔者在当前 column 数据插入 table.store 数据时传入了一个 updateColumnOrder 函数。(这里如果有疑问的话,笔者建议结合上一篇文章来看,那篇有详细介绍 table 的基础源码实现)
最后看看 updateColumnOrder 中做了什么:
一句话总结:通过
DOM 中 div 的顺序来排序 column 数据的顺序,然后触发更新。
以上这几个步骤基本就是这个 issue 的最终解决方案的实现了。可能讲得有点乱,笔者再撸个图来帮大家捋一捋思路:
table初始化会将每列的column数据(如label、prop)都存到store中;- 每一列
column中都有一个updataFn,并且这个Fn被添加到一个数组中(观察者模式) MutataionObserver观测到DOM变化,调用数组中所有的Fn(观察者模式)updataFn执行。检查当前列顺序是否改变,改变则对store的顺序排序,并触发更新。
发发牢骚
收获与挫折
自我感觉搞开源项目是一个:高投入、长周期、低回报 的事情。毕竟从解决问题的层面上来说,提交一个 pr 需要考虑的问题有很多(自我感觉每个 pr 平均至少花掉半天时间)。再说了, ElementPlus 的 npm 周下载量平均都有 15W+,其实也不能算少了,所以每改一行代码,每加入一个功能都需要考虑很多。
当然了,仅仅作为贡献者需要如此谨慎,且都会考虑到改动可能会带来的风险。那作为这个开源库的成员,他们更是如此,所以整个 review 代码的周期也会比较长,毕竟当 pr 提交后,压力就去到项目成员那边了。
基于以上这些,不免会遭遇很多挫败... pr 没人 review,没人 approve,有人跟进也可能需要不断回炉重造,甚至还会有被 closed 的风险...如果 pr 一旦 closed 掉,那就意味着努力白费了。本来就回报甚低,投入大的贡献历程将会血本无归...
当然,有 merged 就会有 closed,有些时候想明白了这个点,也就能看开了。毕竟不是所有的努力都有回报,但是不努力就一定没有收获。
说了这么多废话,笔者也想讲讲给这个 issue 提 pr 的初心。首先就是看到不止同一个问题挂在仓库的 issue 中,并且这种不起眼的问题可能会对一些初级开发者带来很大的困扰,因为这种问题排查起来很可能就是大半天...而且会给人一种困惑,明明自己按照规范来开发,写 v-for 加上 key,甚至有些项目的 lint 工具就是没 key 就校验不通过不让提交的...
谁没经历过小白阶段呢?基于此,笔者觉得如果自己能够解决这个问题,那可能可以帮助到一些开发者避免经历这样的问题排查,可以顺利的完成业务开发,早点下班回家。于是我就来了一次为爱发电...
写到最后,笔者还是想再次感谢给 table 这个 pr review 的项目成员,小弟真的感激不尽。emmm,未来我依然会继续投入开源项目,为社区做一点微不足道的贡献吧。
参考文献:
本文正在参加「金石计划」