本文产自于去年的一次团队内部分享,当时主要的目标是带大家一起看一下 Vue 3.0 中的渲染器执行流程。这篇文章偏向于流水账,带大家一起过一下整个的执行过程。文章内容比较枯燥,建议跟着源码一起看。预期在下一篇文章中,再对这个渲染器进行更加详细的分析。废话不说多,我们开始吧。
在 Vue.js 中,组件是非常重要的概念,整个应用是通过组件组合来实现的。本篇文章主要讲解 Vue 3.0 中,组件的渲染机制。
一、组件
组件是对一棵 DOM 树的抽象,我们在页面一般这样写一个组件:

这段在 html
页面上面的渲染内容取决于组件模板的定义。例如,这样定义 HelloWorld
组件模板:

在 Vue.js 应用中,框架会帮助我们在页面渲染出一个 div
标签,内部有一个包含 hello world
文案的 p
标签。
从上面例子的表现来看,组件的模板决定组件生成的结构,可以把上面的组件渲染过程简单的解析为以下几步:

二、Vue3.0 的初始化流程
Vue.js 3.0 中组件的渲染过程与上图的思路区别不大,源码中主要利用了一个可以描述组件信息的 JavaScript 对象来实现 创建 -> 渲染 -> 生成
这一段逻辑,这一个内部变量被命名为 VNode
。接下来,我们期望通过分析3.0 的初始化过程来理清 Vue3.0 组件的渲染过程。
首先从渲染入口开始逐步分析,下面是 Vue 3.0 的初始化方式:

本质上与 Vue 2.0 没有区别,都是将根组件挂载到某一个特定 DOM 节点上面。微小的区别:无需导入全部的Vue 源码,可以支持 tree-shaking,去掉没有使用的 Vue 特性。
进入源码内部, createApp
内部实现:

整个函数主要做了两件事情:
创建 app 对象
重写 app.mount 方法
(一)创建 app 对象

代码中的 ensureRenderer
这个方法比较有意思,看下面这一串代码:

这个函数最重要的作用是支持构建工具的 tree-shaking,减少不必要的代码引入。
继续看其中的 createRender
函数:

看注释,这个函数是为跨平台渲染做准备的,基本思路应该是传入自定义的 HostElement
,这块逻辑暂时忽略。
终于到了最核心的创建渲染器的环节了,这个逻辑主要集中在 baseCreateRenderer
这个函数,整个函数内部代码 1795 行,包含了整个生成以及 DOM diff 逻辑,下面将这个函数精简下,只讲解初始化渲染逻辑。

这个函数需要从应用层面来看,执行 createApp(App)
方法时,会把 App
组件对象作为根组件传递给 rootComponent
。这样,createApp 内部就创建了一个 app 对象,它会提供 mount 方法,这个方法是用来挂载组件的。
以上介绍的流程,包含了 函数柯里化、tree-shaking 等技巧,实现了参数保留,打包代码体积优化。并且通过参数实现了框架与默认运行环境(web)的解耦,可以用一张图来表示:

从图中可以看出,整个 App 对象中的渲染逻辑在 createdRenderer
中已经与浏览器环境解耦了,可以好处可以使的 Vue.js 支持更多的环境,例如:小程序,weex 。但是,留下了一个点,应用的默认运行环境是 web 怎么处理?
(二)重写 app.mount 方法
回到 createAppApi
中,查看 mount 这一方法

这是一个标准的跨平台渲染流程,先创建 VNode,再渲染 VNode,通过参数 rootContainer 来完成不同平台的适配。比如,在 Web 平台它是一个 DOM 对象,而在其他平台(比如 Weex 和小程序)中可以是其他类型的值,这里面的代码没有特定平台的逻辑。但是,Vue 需要有默认 web 的功能,这就需要在外部重写 mount 渲染逻辑。
这个重写在上文有提及,在 createApp 入口函数中:

重写的方法中主要做了这几件事:
normalizeContainer:标准化容器(可以传字符串选择器或者 DOM 对象,但如果是字符串选择器,就需要把它转成 DOM 对象,作为最终挂载的容器)
做一个 if 判断,如果组件对象没有定义 render 函数和 template 模板,则取容器的 innerHTML 作为组件模板内容
在挂载前清空容器内容,最终再调用 mount 的方法走标准的组件渲染流程
到这里,基本上 Vue 3.0 的入口函数逻辑介绍完毕。用一张图来描述:

三、创建 VNode
VNode 本质上是用来描述 DOM 的 javascript 对象,不同的 DOM 节点对应着不同的属性值。(详细可阅读 Virtual Dom 库 snabbdom )
举个简单的例子:

这样的元素节点,可以用下面的 js 对象来简单描述:

上面这个例子演示了VNode 怎么描述真实的 HTML 节点,那么延伸一下,

Vue 组件也可以这样描述,如下:

为什么需要 VNode 这样的数据结构?
抽象:渲染过程可以通过 VNode 抽象,提升组件的抽象能力
跨平台:不同平台都可以利用 VNode 做 patch,基于 vnode 再做服务端渲染、Weex 平台、小程序平台的渲染,成本会降低许多。
上文中的初始化流程中,createAppApi 调用 createVNode 函数在 mount 时创建 VNode:

进入 creatVNode 内部:

Vue 3.0将 VNode 节点分成 5 种来进行处理:
ELEMENT
SUSPENSE
TELEPORT
STATEFUL_COMPONENT
FUNCTIONAL_COMPONENT
对 VNode 完成标准化处理之后,接下来需要的就是将已经生成的 VNode 渲染到页面中。

四、渲染 VNode
VNode 生成之后,会进行 render 的操作,该部分代码在上文提到的 baseCreateRenderer 中:

这个渲染函数逻辑主要处理 2 块:
VNode 不存在了,则销毁组件
否则,创建或者更新组件
render 函数内部有调用 patch 函数,这个函数里面的逻辑较重,简化一下来看:

这个函数有2个作用:
根据新旧 VNode 更新 DOM(DOM diff 在这里面执行)
根据 VNode 挂载 DOM
这里只关注挂载流程,重点关注两种类型:
普通 DOM 元素
组件
(一)组件的渲染处理

考虑初次渲染,n1 参数为空,进入挂载组件流程。

mountComponent 做了 3 件事情:
通过纯函数的方式去创建了当前渲染的组件实例
设置组件实例:初始化 props,slots,ssr 相关配置
重点:设置并运行带副作用的渲染函数

effect 函数来自 @vue/reactivity
,可以先简单记下:当组件的数据发生变化时,effect 函数包裹的内部渲染函数 componentEffect 会重新执行一遍,从而达到重新渲染组件的目的。
setupRenderEffect 函数内部也分成了初次渲染以及组件更新,这里只关注初始化渲染部分。这一部分做了 2 件事:
渲染生成子树 subTree
把 subTree 更新到 container
在更新过程中有一个递归,将组件的每一层级遍历下来,直到最里面的 VNode 节点,这样就可以进入普通 DOM 元素的处理流程。
(二)普通 DOM 元素的处理

加载元素节点的方法是mountElement
:

整个函数做四件事情:
创建 DOM 元素节点(hostCreateElement)
处理 props
处理 children 元素(此方式与组件处理类似,递归调用 patch 流程)
挂载 DOM 元素到 container 上
hostCreateElement 在默认运行环境(web)里面是这么实现的:

调用了底层的 DOM API document.createElement 创建元素。
处理 children 元素时,通过递归 patch 这种深度优先遍历树的方式构造完整的 DOM 树,完成组件的渲染。
最后就是将渲染的DOM 树一次性挂载到 container 上,hostInsert
方法默认定义如下:

注意: insert 的执行是在处理子节点后,所以挂载的顺序是先子节点,后父节点,最终挂载到最外层的容器上。
上面的篇幅总体而言,比较“干”,不好理解,所以,花了一张函数调用逻辑图:

五、总结
Vue.js 3.0 是在下半年会逐步开始在正式环境落地,聊聊里面的逻辑,相信能让你对它正式落地生产环境增加些信息,本篇文章主要记录了 VNode 整个渲染过程。整个生成部分的源码看下来的收获是:
工具库的应用场景:tree-shaking 如何保证生效
深度优先算法的应用场景:DOM 树更新流程
下一篇打算做一个延伸,如果让你设计一个 Virtual DOM,用于框架上,可以怎么设计。文章的主要内容不变,换个角度,我们把它掰开揉碎吃~下周见




