我用 MutationObserver 解决了 ElTable 的疑难杂症!

2,423 阅读10分钟

哈喽大家好,这次终于带来了这篇关于我是如何解决 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 的顺序: el-table-demo.gif

如上所示,点击按钮互换列时,其中一个 table 稳如老狗,丝毫不动...这就是本文要解决的问题。那问题的根源是什么?我们接着往下。

2. 原因分析回顾

详细的了解可以点击 从 Issue 看 El-Table 源码,给 Element+ 提 Pr 的背后竟如此坎坷! 这篇文章去看看。笔者这里也不过多展开了就简单的总结概括一下原因。

首先需要明确的是 el-table-column 组件最终渲染到界面中是一个空 div。可以简单理解这个组件的存在只是为了收集用户对 table 列定义的数据,不负责 table 具体单元格的渲染。

其次,Vue3 组件更新对 v-forkey 和无 key 的处理差异

  1. key,命中 Vue3 的一个 diff 流程。
  2. key,直接对公共长度部分进行 patch(也就是这个 patch,会触发 columnlabel 值的变动)。

如果大家对 v-for 组件更新的源码实现感兴趣的朋友可以去看看这个老哥的文章,写得很好:v-for 到底为啥要加上 key?

首先我们看看,有 key 时候为什么换列失败? el-table-key.gif 这是笔者之前分析文章时候用的 gif 图,该动图可以清晰的看到,在有 key 的情况下,这个空 div 命中 diff 的逻辑,仅仅进行了 这个空 divDOM 层面)的换位,因此并没有触发 table 组件的 rerender,并且列之间的数据也没有改变,自然没有“换列”的效果产生。

那么,无 key 时候的换列行为可以参考笔者的一个图: column-key.png

这里要回顾一下上文提到 keyVue 对组件更新的处理直接 patch !这一点非常重要!

由上图可得,其实真正触发换列的并不是真的把当前 column 的数据跟下个 column 的数据进行互换而是直接改变当前 column 的数据因此也会触发 tablererender ,这样一来就带我们这种互换列的行为。所以明白了原因的你,知道 table 的互换列是怎么来的了吧?

简单总结:

  1. keytablererender,且 column 数据未变。
  2. key 时,每个 column 组件直接 patch,会改变 labelprop 等响应式数据,并会触发 tablererender,因此会重新渲染一个“换列”后的 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 的定义规范:

image.png

根据规范的介绍中,我们不难发现:MutationObserver 会对 DOM 进行异步观测,将观测到有变化的 observers 放到微任务队列中,再去通知这些 observers

这里其实很像是一个发布订阅的关系。我们监听一个 DOM 变化,提供一个 callback,当 DOM 变化时就会触发 callback 的执行。唯一不同的就是这个 api 是异步的,它不会因为我们同时改变一个 DOM 节点多次就执行多次,而仅仅在当前循环的微任务中执行一次,所以从性能角度看待它应该还算 ok。

那我们不妨直接做个小实验来验证一下。实验前准备:

  • 通过浏览器的 stroe as global variabl 来选中一个节点进行观测: image.png
  • 初始化 MutationObserver (直接去 MDNcopy 一个) image.png

小实验开始:

  1. Promise.then 进行实验比较: image.png 由执行顺序的结果不难发现,MutationObserver 的执行按照我们的代码顺序执行。也就是说它被插入在微任务队列中,并处理两个 promise.then 的中间。我们试着改变一下他们的调用顺序,结果也是跟随代码顺序的: image.png

  2. 同步多次改变 DOM ,观测 callback 只执行一次: image.png 如图所示,我们在同步代码中改变了 temp1innerText 两次,但只打印了一次 'MutationObserver'

好了,相信看到这里已经对 MutationObserver 这个 api 有一定的熟悉了,简单总结一下:

  • 异步执行(属于微任务)。观测到的 DOM 变化的 observer 会被添加到本轮 EventLoop 中的微任务队列中。

2. 看源码——解决 issue

上面花了点篇幅介绍 MutationObserver,毕竟这个就是整个解决方案的核心了。有了它,其他的实现大家都可以猜到大概的做法了,笔者就简单介绍一下实现吧。

首先我们捋清思路,其实就三步:

  1. 捕捉 DOM 变化
  2. 通知 column 组件并更新当前 column 数据的顺序(当前DOM 顺序其实就是 columns 数据的顺序
  3. table 组件 rerender

所以,首先我们来看第一步,捕捉 DOM 变化的实现: image.png

其实这里就是对 MutationObserver 的一个应用。很简单,在 table 组件 mounted 阶段执行监测 column 组件的实体 div 顺序变化,所以 observerDOM.hidden-columns。不清楚的可以看下图: image.png

这里笔者在 MutationObservercallback 中只做了一件事,就是通知一个队列中的所有观察者(这些观察者属于每一个 table-column 组件):

updateOrderFns.forEach((fn: () => void) => fn())

到这一步,我们来看看 updateOrderFns 这个队列中是观察者什么时候加进去的: image.png 其实就在 column 组件的 mounted 阶段,笔者在当前 column 数据插入 table.store 数据时传入了一个 updateColumnOrder 函数。(这里如果有疑问的话,笔者建议结合上一篇文章来看,那篇有详细介绍 table 的基础源码实现)

最后看看 updateColumnOrder 中做了什么: image.png 一句话总结:通过 DOMdiv 的顺序来排序 column 数据的顺序,然后触发更新。

以上这几个步骤基本就是这个 issue 的最终解决方案的实现了。可能讲得有点乱,笔者再撸个图来帮大家捋一捋思路:

issue 解决方案.png

  1. table 初始化会将每列的 column 数据(如labelprop)都存到 store 中;
  2. 每一列 column 中都有一个 updataFn,并且这个 Fn 被添加到一个数组中(观察者模式)
  3. MutataionObserver 观测到 DOM 变化,调用数组中所有的 Fn(观察者模式)
  4. 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,未来我依然会继续投入开源项目,为社区做一点微不足道的贡献吧。

参考文献:

  1. whatwg
  2. MDN MutationObserver
  3. v-for 到底为啥要加上 key?

本文正在参加「金石计划」