BestVue3

对VTU的一次PR解决bug的过程,来讲讲Vue3的树结构

Jokcy
jokcylou@hotmail.com

之前遇到了一个 vue-test-utils(后简称 VTU)的 bug,因为正处于 vue3 的 rc 阶段,VTU 也仍然处于 beta 阶段,所以这是一个新 bug,在 issues 列表里找到里好几个类似的问题,我在其中一个进行了回复。后面 VTU 的维护者发起了一次 PR 来说明这个问题,详细可以看这里,他写了很多来说明这个问题,以及这个问题可能的原因,和这个问题不太好解决,并@我们这些关注这个问题的人,希望我们一起讨论。

正好是礼拜天,我闲着也是闲着,就默默的 fork 了代码来看看能不能解决一下。先说结果,我第二天就提了解决的 PR,并且最终在三天后这个 PR 被成功 merge,而我也成为了 VTU for vue3 的第一个非主要维护者的贡献者。

Looks good! I will give this a little test myself and then merge it up. Thanks a lot, this is great first contribution!

维护者老哥的原话。这老哥给我一顿猛夸,搞得我都有点不好意思:

你现在肯定在想,这肯定是贡献了很多代码,解决了很复杂的问题,才会引来这样的夸奖吧。但事实可能会非常出乎你的意料,因为我解决这个 bug 的代码,只有两行!

是的,我最终给 VTU 的贡献,只修改了两行代码,以及增加了一个测试用例。可能很多人要说了:“就这?”,这怕不是又是个凑数的 PR 吧,或者什么解决了拼写问题的 PR,没什么技术含量。说实话我还真见过以前有公司用工具来刷拼写错误,来提升自己在开源项目中的 PR 占比的。

当然,我这个肯定不是的,所以接下去来讲点有技术含量的。

两行代码下面的冰山

我们先来看一下这个问题。VTU 又一个 API,wrapper.findComponent,就是在当前wrapper节点下面找到某个组件,demo:

1
import { mount } from 'vue-test-utils'
2
3
const wrapper = mount(App)
4
const compWrapper = wrapper.findComponent({ name: 'YourComponentName' })
5

这是 VTU 里面最常用的 APi 之一,但是这个 API 在之前却是不能正常执行的,他不能正常执行的原因维护者 lmiller 老哥在他的 PR 里面讲得很详细,原文看这里。概括一下主要是两点:

  1. 纯函数组件没有实例,所以无法获取到vm也就无法创建wrapper
  2. vue3 的插槽会被编译成{defualt: () => [], other: () => []}这样的结构,无法直接获取子节点的vnode,进而无法获取vm也无法创建wrapper

对于第一点,因为不是我解决的主要问题,所以简单说一下,函数组件就是一个函数,其本身是没有this的,也无法使用composition api,所以本身是没有状态的,他本身就是一个render函数而已,所以自然没有vm对象。

我们重点关注第二点,我很早就写过一篇关于 vue3 中的 render 方法,你可能不知道这些来讲解 vue3 中的关于createElement API 的用法,只是看到的人不多,可能写得比较生僻吧。

这个问题跟上面说的文章里面的内容关联性很高,最主要的就是 vue 对于slots的表示,在 vue3 中,使用插槽的节点的 API 是这样的:

1
createElement(YourComponent, props, {
2
default: () => [],
3
othern: () => [],
4
})
5

也就是说YourComponent拿到的slots其实是:

1
{
2
default: () => [],
3
othern: () => []
4
}
5

这是一个很大的区别,为什么这么说呢,因为正常的节点,不管是在 vue2 还是 react 里面,你的组件拿到的children或者slots基本上都是已经创建好的节点(你强行自己传一个函数的除外),也就是说:

1
<YourComponent><Child /></YourComponent>
2

正常来说对于YourComponent来说,他应该拿到的slots.default应该是一个对象。那么拿到对象和拿到函数有很大的区别么?是的,非常大。

1
createElement(YourComponent, props, {
2
default: createElement(Child, props),
3
})
4
5
createElement(YourComponent, props, {
6
default: () => createElement(Child, props),
7
})
8

请你花 30 秒思考一下上面这两种方式的最大区别。

答案揭晓,那就是在这两个代码执行的时候,default指代的element是否已经创建。前者的default已经是一个创建好的element,而后者的element并没有创建,需要执行了函数之后才会创建。

换句话说,前者在执行创建YourComponent的节点的时候,他的children已经创建好了,而后者还没有。 这就是解决这个 bug 的关键。

vnode

看到这里你可能还是很迷糊,这说的是个啥,跟这个 bug 有啥关系嘛?啊,到目前为止关系确实不大,前面都是铺垫,接下去我们来讲真正的问题。这个问题要从 VTU 的findComponent说起,这个 API 其实很简单,他从Appvnode开始向下遍历,遍历一遍从 vue 的vnode树中找到需要的vnode以及其对应的组件实例vm。而这其中的关键函数就是:

1
function findAllVNodes(
2
vnode: VNode,
3
selector: FindAllComponentsSelector,
4
): VNode[] {
5
const matchingNodes: VNode[] = []
6
const nodes: VNode[] = [vnode]
7
while (nodes.length) {
8
const node = nodes.shift()!
9
// match direct children
10
aggregateChildren(nodes, node.children)
11
if (node.component) {
12
// match children of the wrapping component
13
aggregateChildren(nodes, node.component.subTree.children)
14
}
15
if (node.suspense) {
16
// match children if component is Suspense
17
const { isResolved, fallbackTree, subTree } = node.suspense
18
if (isResolved) {
19
// if the suspense is resolved, we match its children
20
aggregateChildren(nodes, subTree.children)
21
} else {
22
// otherwise we match its fallback tree
23
aggregateChildren(nodes, fallbackTree.children)
24
}
25
}
26
if (matches(node, selector) && !matchingNodes.includes(node)) {
27
matchingNodes.push(node)
28
}
29
}
30
31
return matchingNodes
32
}
33

我们主要关心这个while循环,aggregateChildren大致也就是根据传入的类型把找到的节点塞入到nodes数组里面,这个循环会直到 vue 的vnode树的所有节点都被遍历一遍之后(至少期望是这样)才会结束。

我们可以看到他这里主要关心的是两个值,node.childrennode.component.subTree.children,那么这里就有两个概念需要同学们理解了。

  • children
  • subTree

我们先来看一个例子:

1
const CompA = {
2
template: `<div><slot /></div>`,
3
}
4
5
const CompB = {
6
template: `<span>Hello</span>`,
7
}
8
9
const App = {
10
template: `<CompA><CompB /></CompA>`,
11
}
12

注:以上代码并不能正常执行,仅为讲解用

在这个例子中,对于CompA来说,CompB是他的children,而div则是他的subTree。对于站在children的视角上来看,整个应用的结构如下:

1
<App>
2
<CompA>
3
<CompB />
4
</CompA>
5
</App>
6

在这种嵌套关系中,CompBCompAchildren

那么<div><slot /></div>去哪了呢?他其实只是CompA的渲染内容,所以他叫做CompAsubTree,即子树。而最终我们把所有的节点列出来(包括组件节点和 HTML 节点)的话,我们的应用其实是这样的:

1
<App>
2
<CompA>
3
<div>
4
<CompB>
5
<span>Hello</span>
6
</CompB>
7
</div>
8
</CompA>
9
</App>
10

好,知道了childrensubTree的区别之后,接下去我们就可以来讲讲 bug 的原因了。

解析

可能明眼人已经看出问题来了,源码中遍历的方式是:

1
aggregateChildren(nodes, node.children)
2
aggregateChildren(nodes, node.component.subTree.children)
3

对于上面的 demo,我们从App开始。App没有children,所以直接看subTree,而这里直接遍历的是node.component.subTree.children而这种情况下,其实CompA正是AppsubTree,所以这里的代码直接无视了CompA,而转向了他的children 所以解决这个问题方法非常简单,增加一句代码:

1
aggregateChildren(nodes, [node.component.subTree])
2

到这里呢,其实问题已经解决了。那么有同学要问了,我上面说了一大堆slots想关的跟这个有关系吗?从结论出发,好像确实没啥关系,但是这个问题的解决过程中,却免不了对于slots的理解和思考。

vue3 为了渲染性能所以在编译模板的时候,把slots编译成函数,所以在上面的例子中,其实CompAchildren是:

1
{
2
default: () => CompA
3
}
4

所以我们从CompAchildren出发,是找不到CompBvnode的。那我们的代码是不是又变得有问题了呢?不会的,因为CompB我们又可以从CompAdivchildren中找到,整个链路就是:

1
App -> subTree -> CompA -> subTree -> div -> children
2

你再看一下实现的代码,你能看出这个链路是怎么形成的么?

而这个问题其实才是一直困扰维护者 lmiller 老哥的,他一直不清楚在slots被优化编译之后如何获取其vnode

不得不说这老哥真的热情 在我表示我没写注释是因为我英语一般般之后,他说如果我想写这个 bug 相关的文章或者一些 vue 原理相关的文章的话,他可以帮我修正,有机会的话我还真想试试。

小小总结一下

从去年开始我渐渐喜欢在 github 参与开源项目,前前后后也给挺多项目提过 PR,有成功合并的也有因为一些原因没有合并的,在这个过程中让我的能力提升很多,但是最重要的是我对于代码实现细节拿捏有了更多的思考,不再仅仅局限于实现一个功能,更多是怎么更好得实现一个功能,以及思考未来可能的扩展需求并为之做好准备。在这个过程中锻炼了我很多能力,包括但不限于:

  • 写单测,我现在很多情况下甚至会先写单测
  • 插件思维,在实现功能之前就先想好如果我需要自定义扩展该怎么做
  • 站在用户角度思考 API 的设计,不再为了实现功能而写代码
  • 英语 😄

我建议各位都多去 github 逛逛,毕竟这还是目前世界上最好的开源项目社区,即便有英语这个门槛,但迟早你是要克服,我可以这么说,至少 10 年内,世界最好的技术相关的文档仍然会是以英文为主。如果你希望自己成为一个全面优质的技术人员,这个门槛迟早是要迈过去的。

另外参与开源项目真的很锻炼能力,但是你在国内的环境要参与这样的项目太难了,国内没有一个很好的开源环境,阿里系是国内在开源社区最活跃的,但是阿里的开源却又是非常功利性的,因为是 KPI 挂钩的,今年搞个开源项目,PPT 好看拿了 3.75,明年这个项目可能就不维护了,因为可能要换个项目撑 KPI 了。所以国内的环境还是比较逐利的,更别说小公司可能连业务都来不及开发,哪有资源给你搞开源呢。

说了这么多,也就是小小感慨一下,最近拉了个群同学越来越多,讨论多了也很希望群里的同学能快速提升。这种感情慢慢变成了希望国内的技术氛围能变得更好,而不要太浮躁,太趋利,只有技术能帮你做出一些有意思的东西的时候,你才能一直保持兴趣并进而提升自己。

共勉!

一手Vue3资料
Best
Vue3
长按关注BestVue3,学习不迷路

BestVue3,最优质的Vue3学习资源