一、组件渲染
vue 模板中的组件到渲染生产 DOM 需要经历
- 创建 vnode
- 渲染 vnode
- 生产 dom
应用程序初始化
整个组件树从根组件开始渲染
在 vue2 或者 vue3 中应用的初始化如下
// 在 Vue.js 2.x 中,初始化一个应用的方式如下
import Vue from "vue";
import App from "./App";
const app = new Vue({
render: (h) => h(App),
});
app.$mount("#app");
// 在 Vue.js 3.0 中,初始化一个应用的方式如下
import { createApp } from "vue";
import App from "./app";
const app = createApp(App);
app.mount("#app");
createApp 是 vue3 提供的新的函数,其内部实现如下,主要做了两件事
- 创建 app 对象
- 重写 app.mount 方法
const createApp = (...args) => {
// 创建 app 对象
const app = ensureRenderer().createApp(...args);
const { mount } = app;
// 重写 mount 方法
app.mount = (containerOrSelector) => {
// ...
};
return app;
};
1. 创建 app 对象
ensureRenderer().createApp() 来创建 app 对象
首先 ensureRenderer 函数用来创建一个渲染器对象,可以简单地把渲染器理解为包含平台渲染核心逻辑的 JavaScript 对象。
ensureRenderer方法
先用 ensureRenderer() 来延时创建渲染器,这样做的好处是当用户只依赖响应式包的时候,就不会创建渲染器,因此可以通过 tree-shaking 的方式移除核心渲染逻辑相关的代码。
在整个 app 对象创建过程中,Vue.js 利用闭包和函数柯里化的技巧,很好地实现了参数保留。比如,在执行 app.mount 的时候,并不需要传入渲染器 render,这是因为在执行 createAppAPI 的时候渲染器 render 参数已经被保留下来了。
// 渲染相关的一些配置,比如更新属性的方法,操作 DOM 的方法
const rendererOptions = {
patchProp,
...nodeOps
}
let renderer
// 延时创建渲染器,当用户只依赖响应式包的时候,可以通过 tree-shaking 移除核心渲染逻辑相关的代码
function ensureRenderer() {
return renderer || (renderer = createRenderer(rendererOptions))
}
function createRenderer(options) {
return baseCreateRenderer(options)
}
function baseCreateRenderer(options) {
function render(vnode, container) {
// 组件渲染的核心逻辑
}
return {
render,
createApp: createAppAPI(render)
}
}
function createAppAPI(render) {
// createApp createApp 方法接受的两个参数:根组件的对象和 prop
return function createApp(rootComponent, rootProps = null) {
const app = {
_component: rootComponent,
_props: rootProps,
mount(rootContainer) {
// 创建根组件的 vnode
const vnode = createVNode(rootComponent, rootProps)
// 利用渲染器渲染 vnode
render(vnode, rootContainer)
app._container = rootContainer
return vnode.component.proxy
}
}
return app
}
}
2. 重写app.mount 方法
createApp函数内部的app.mount方法是一个标准可跨平台的组件渲染流程,先创建vnode,再渲染vnode,。此外参数 rootContainer 也可以是不同类型的值,比如,在 Web 平台它是一个 DOM 对象,而在其他平台(比如 Weex 和小程序)中可以是其他类型的值。所以这里面的代码不应该包含任何特定平台相关的逻辑,也就是说这些代码的执行逻辑都是与平台无关的。因此我们需要在外部重写这个方法,来完善 Web 平台下的渲染逻辑。
以下是重写mount的内容
app.mount = (containerOrSelector) => {
// 标准化容器
const container = normalizeContainer(containerOrSelector)
if (!container)
return
const component = app._component
// 如组件对象没有定义 render 函数和 template 模板,则取容器的 innerHTML 作为组件模板内容
if (!isFunction(component) && !component.render && !component.template) {
component.template = container.innerHTML
}
// 挂载前清空容器内容
container.innerHTML = ''
// 真正的挂载
return mount(container)
}
- 通过normalizeContainer 标准化容器(这里可以传字符串选择器或者 DOM 对象,但如果是字符串选择器,就需要把它转成 DOM 对象,作为最终挂载的容器)
- 然后做一个 if 判断,如果组件对象没有定义 render 函数和 template 模板,则取容器的 innerHTML 作为组件模板内容
- 接着在挂载前清空容器内容
- 最终再调用 app.mount 的方法走标准的组件渲染流程
核心渲染流程:创建 vnode 和渲染 vnode
1. 创建vnode
vnode 本质上是用来描述 DOM 的 JavaScript 对象,它在 Vue.js 中可以描述不同类型的节点,比如普通元素节点、组件节点等。
a. 普通元素节点
如
<button class="btn" style="width:100px;height:50px">click me</button>
用vnode对象来表示,
- type 属性表示 DOM 的标签类型
- props 属性表示 DOM 的一些附加信息,比如 style 、class 等
- children 属性表示 DOM 的子节点,它也可以是一个 vnode 数组,只不过 vnode 可以用字符串表示简单的文本 。
const vnode = {
type: 'button',
props: {
class: 'btn',
style: {
width: '100px',
height: '50px'
}
},
children: 'click me'
}
b. 组件节点
vnode对象除了描述真实的dom,也可以用来描述组件
如,在模板中定义一个组件
<custom-component msg="test"></custom-component>
用vnode对象来表示,这里的vnode只是对组件对象的抽象描述,我们不会在页面渲染这个组件标签,而是渲染组件内部定义的html标签
const CustomComponent = {
// 在这里定义组件对象
}
const vnode = {
type: CustomComponent,
props: {
msg: 'test'
}
}
c. 其他节点
还有比如纯文本vnode,注释vnode等等,且vue3内部还对vnode的type做了更详细的分类,如Suspense,Teleport等
d. createVNode函数创建vnode
主要做了以下几点
- 对 props 做标准化处理
- 对 vnode 的类型信息编码
- 创建 vnode 对象
- 标准化子节点 children
const vnode = createVNode(rootComponent, rootProps)
function createVNode(type, props = null ,children = null) {
if (props) {
// 处理 props 相关逻辑,标准化 class 和 style
}
// 对 vnode 类型信息编码
const shapeFlag = isString(type)
? 1 /* ELEMENT */
: isSuspense(type)
? 128 /* SUSPENSE */
: isTeleport(type)
? 64 /* TELEPORT */
: isObject(type)
? 4 /* STATEFUL_COMPONENT */
: isFunction(type)
? 2 /* FUNCTIONAL_COMPONENT */
: 0
const vnode = {
type,
props,
shapeFlag,
// 一些其他属性
}
// 标准化子节点,把不同数据类型的 children 转成数组或者文本类型
normalizeChildren(vnode, children)
return vnode
}
2. 渲染vnode
render(vnode, rootContainer)
render函数的vnode如果为空,则执行销毁组件的逻辑,否则执行创建或者更新组件的逻辑
const render = (vnode, container) => {
if (vnode == null) {
// 销毁组件
if (container._vnode) {
unmount(container._vnode, null, null, true)
}
} else {
// 创建或者更新组件
patch(container._vnode || null, vnode, container)
}
// 缓存 vnode 节点,表示已经渲染
container._vnode = vnode
}
a. patch函数
patch函数的实现,patch 本意是打补丁的意思,这个函数有两个功能,一个是根据 vnode 挂载 DOM,一个是根据新旧 vnode 更新 DOM。对于初次渲染,我们这里只分析创建过程
- 第一个参数n1表示旧的vnode节点,当n1为null的时候,表示是一次挂载的过程
- 第二个参数n2表示新的vnode节点,后续会根据这个 vnode 类型执行不同的处理逻辑
- 第三个参数container表示dom容器,也就是 vnode 渲染生成 DOM 后,会挂载到 container 下面。
- 这里我们只关注对组件的处理和对普通dom元素的处理
const patch = (n1, n2, container, anchor = null, parentComponent = null, parentSuspense = null, isSVG = false, optimized = false) => {
// 如果存在新旧节点, 且新旧节点类型不同,则销毁旧节点
if (n1 && !isSameVNodeType(n1, n2)) {
anchor = getNextHostNode(n1)
unmount(n1, parentComponent, parentSuspense, true)
n1 = null
}
const { type, shapeFlag } = n2
switch (type) {
case Text:
// 处理文本节点
break
case Comment:
// 处理注释节点
break
case Static:
// 处理静态节点
break
case Fragment:
// 处理 Fragment 元素
break
default:
if (shapeFlag & 1 /* ELEMENT */) {
// 处理普通 DOM 元素
processElement(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
} else if (shapeFlag & 6 /* COMPONENT */) {
// 处理组件
processComponent(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
} else if (shapeFlag & 64 /* TELEPORT */) {
// 处理 TELEPORT
} else if (shapeFlag & 128 /* SUSPENSE */) {
// 处理 SUSPENSE
}
}
}
b. processComponent函数,对组件的处理
processComponent函数
const processComponent = (n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
if (n1 == null) {
// 挂载组件
mountComponent(n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
} else {
// 更新组件
updateComponent(n1, n2, parentComponent, optimized)
}
}
mountComponent函数,主要做了三件事
- 创建组件实例,vue3不像vue2用class的方式实例化组件,而是对象的方式创建组件实例
- 设置组件实例,实例上保留了很多组件相关的数据,维护了组件的上下文,包括对 props、插槽,以及其他实例的属性的初始化处理
- 设置并运行带副作用的渲染函数,重点看该渲染函数
const mountComponent = (initialVNode, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
// 创建组件实例
const instance = (initialVNode.component = createComponentInstance(initialVNode, parentComponent, parentSuspense))
// 设置组件实例
setupComponent(instance)
// 设置并运行带副作用的渲染函数
setupRenderEffect(instance, initialVNode, container, anchor, parentSuspense, isSVG, optimized)
}
setupRenderEffect函数
- 利用响应式库的effect函数,传入了一个副作用渲染函数componentEffect,每当组件的数据发生改变,副作用渲染函数componentEffect会重新执行一遍,从而重新渲染组件
- 副作用渲染函数componentEffect
- 生产组件vnode的子树vnode,subtree
- 把subTree挂在到container中
- 这里initialVNode和subTree的区别是initialVNode对应组件的vnode,subTree对应组件内部整个dom节点对应的vnode
- 每个组件都有render函数或者template(也会被编译成render函数),而renderComponentRoot就是去执行render函数创建整个组件树内部的vnode,把这个 vnode 再经过内部一层标准化,就得到了该函数的返回结果:子树 vnode。
- 渲染生成子树 vnode 后,接下来就是继续调用 patch 函数把子树 vnode 挂载到 container 中了
- patch函数中继续对vnode的类型判断,如果是普通元素vnode,则进入普通元素的处理流程
const setupRenderEffect = (instance, initialVNode, container, anchor, parentSuspense, isSVG, optimized) => {
// 创建响应式的副作用渲染函数
instance.update = effect(function componentEffect() {
if (!instance.isMounted) {
// 渲染组件生成子树 vnode
const subTree = (instance.subTree = renderComponentRoot(instance))
// 把子树 vnode 挂载到 container 中
patch(null, subTree, container, anchor, instance, parentSuspense, isSVG)
// 保留渲染生成的子树根 DOM 节点
initialVNode.el = subTree.el
instance.isMounted = true
} else {
// 更新组件
}
}, prodEffectOptions)
}
c. processElement函数,对普通元素的处理
const processElement = (n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
isSVG = isSVG || n2.type === 'svg'
if (n1 == null) {
//挂载元素节点
mountElement(n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
} else {
//更新元素节点
patchElement(n1, n2, parentComponent, parentSuspense, isSVG, optimized)
}
}
mountElement函数,主要做了四件事
- 创建 DOM 元素节点
- 处理 props
- 处理 children
- 挂载 DOM 元素到 container 上
const mountElement = (vnode, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
let el
const { type, props, shapeFlag } = vnode
// 创建 DOM 元素节点
el = vnode.el = hostCreateElement(vnode.type, isSVG, props && props.is)
if (props) {
// 处理 props,比如 class、style、event 等属性
for (const key in props) {
if (!isReservedProp(key)) {
hostPatchProp(el, key, null, props[key], isSVG)
}
}
}
if (shapeFlag & 8 /* TEXT_CHILDREN */) {
// 处理子节点是纯文本的情况
hostSetElementText(el, vnode.children)
} else if (shapeFlag & 16 /* ARRAY_CHILDREN */) {
// 处理子节点是数组的情况
mountChildren(vnode.children, el, null, parentComponent, parentSuspense, isSVG && type !== 'foreignObject', optimized || !!vnode.dynamicChildren)
}
// 把创建的 DOM 元素节点挂载到 container 上
hostInsert(el, container, anchor)
}
hostCreateElement函数创建dom元素节点
- 底层还是用dom api 的createElement创建元素,并没有什么神奇的地方
- 如果是其他平台,比如weex,hostCreateElement就不再是操作dom,而是平台相关的api了,这些平台相关的方法是在创建渲染器阶段作为参数传入的。
function createElement(tag, isSVG, is) {
isSVG ? document.createElementNS(svgNS, tag)
: document.createElement(tag, is ? { is } : undefined)
}
hostPatchProp函数处理props,给这个 DOM 节点添加相关的 class、style、event 等属性,并做相关的处理
mountChildren函数处理子节点,vnode和dom都是一个树,并且一一映射
- 遍历子节点children,得到每个child,并做预处理
- 递归执行patch挂载每个child,这里执行patch的原因是child有可能有其他类型的vnode
- 通过这种深度优先遍历树的方式,我们构造完整的dom树,完成组件的渲染
const mountChildren = (children, container, anchor, parentComponent, parentSuspense, isSVG, optimized, start = 0) => {
for (let i = start; i < children.length; i++) {
// 预处理 child
const child = (children[i] = optimized
? cloneIfMounted(children[i])
: normalizeVNode(children[i]))
// 递归 patch 挂载 child
patch(null, child, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
}
}
hostInsert函数,把创建的dom元素节点挂载到 container 上, 在web下
- 因为 insert 的执行是在处理子节点后,所以挂载的顺序是先子节点,后父节点,最终挂载到最外层的容器上。
function insert(child, parent, anchor) {
if (anchor) {
parent.insertBefore(child, anchor)
}
else {
parent.appendChild(child)
}
}
3. vnode的意义
a. 抽象化
引入 vnode,可以把组件和渲染过程抽象化
b. 跨平台
因为 patch vnode 的过程不同平台可以有自己的实现,基于 vnode 再做服务端渲染、Weex 平台、小程序平台的渲染都变得容易了很多。
在web平台,需要实现将vnode转换为真实dom,所以和手动操作dom的原理是一样的,这种基于 vnode 实现的 MVVM 框架,在每次 render to vnode 的过程中,渲染组件会有一定的 JavaScript 耗时,特别是大组件,比如一个 1000 * 10 的 Table 组件,render to vnode 的过程会遍历 1000 * 10 次去创建内部 cell vnode,整个耗时就会变得比较长,加上 patch vnode 的过程也会有一定的耗时,当我们去更新组件的时候,用户会感觉到明显的卡顿。虽然 diff 算法在减少 DOM 操作方面足够优秀,但最终还是免不了操作 DOM,所以说性能并不是 vnode 的优势。