小程序做网站百度公司官方网站
Hello,各位小伙伴,接下来的一段时间里,我会把我的课程《Vue.js 3.0 核心源码解析》中问题的答案陆续在我的公众号发布,由于课程的问题大多数都是开放性的问题,所以我的答案也不一定是标准的,仅供你参考喔。
本期的问题:Block
数组是一维的,但是动态的子节点可能有嵌套关系,patchBlockChildren
内部也是递归执行了 patch
函数,那么在整个更新的过程中,会出现子节点重复更新的情况吗,为什么?
这道题是和 Vue.js 模板编译优化相关的问题,在回答问题之前,我们先来看 Vue.js 3.0 的编译优化主要做了什么。
编译优化
我们知道,通过数据劫持和依赖收集,Vue.js 2.x 的数据更新并触发重新渲染的粒度是组件级的:
虽然 Vue.js 能保证触发更新的组件最小化,但在单个组件内部依然需要遍历该组件的整个 vnode
树,举个例子,比如我们要更新这个组件:
<template>
<div id="content">
<p class="text">static textp>
<p class="text">static textp>
<p class="text">{{message}}p>
<p class="text">static textp>
<p class="text">static textp>
div>
template>
整个 diff
过程如图所示:
可以看到,因为这段代码中只有一个动态节点,所以这里有很多 diff
和遍历其实都是不需要的,这就会导致 vnode
的性能跟模版大小正相关,跟动态节点的数量无关,当一些组件的整个模版内只有少量动态节点时,这些遍历都是性能的浪费。
而对于上述例子,理想状态只需要 diff
这个绑定 message
动态节点的 p
标签即可。
Vue.js 3.0 做到了,它通过编译阶段对静态模板的分析,编译生成了 Block Tree
。Block Tree
是一个将模版基于动态节点指令切割的嵌套区块,每个区块内部的节点结构是固定的,而且每个区块只需要以一个 Array
来追踪自身包含的动态节点。借助 Block Tree
,Vue.js 将 vnode
更新性能由与模版整体大小相关提升为与动态内容的数量相关,这是一个非常大的性能突破。
编译生成 Block Tree
那么,Vue.js 在编译阶段会把哪些节点编译生成 Block Tree
呢?
我们先来看一个简单的例子,有如下模板:
<div class="app">
<hello v-if="flag">hello>
<div v-else>
<p>hello {{ msg + test }}p>
<p>staticp>
<p>staticp>
div>
div>
我们借助 Vue.js 提供的模板导出工具平台,编译上述模板结果如下:
import { resolveComponent as _resolveComponent, createVNode as _createVNode, openBlock as _openBlock, createBlock as _createBlock, createCommentVNode as _createCommentVNode, toDisplayString as _toDisplayString } from "vue"
const _hoisted_1 = { class: "app" }
const _hoisted_2 = { key: 1 }
const _hoisted_3 = /*#__PURE__*/_createVNode("p", null, "static", -1 /* HOISTED */)
const _hoisted_4 = /*#__PURE__*/_createVNode("p", null, "static", -1 /* HOISTED */)
export function render(_ctx, _cache, $props, $setup, $data, $options) {
const _component_hello = _resolveComponent("hello")
return (_openBlock(), _createBlock("div", _hoisted_1, [
(_ctx.flag)
? (_openBlock(), _createBlock(_component_hello, { key: 0 }))
: (_openBlock(), _createBlock("div", _hoisted_2, [
_createVNode("p", null, "hello " + _toDisplayString(_ctx.msg + _ctx.test), 1 /* TEXT */),
_hoisted_3,
_hoisted_4
]))
]))
}
通过编译后的结果我们可以看到,根节点创建了一个 Block
,很好理解,因为整个组件至少需要构建一个 Block
。
此外 v-if
节点也在不同的分支创建了 Block
,这是因为同一时间,v-if
只会命中一个分支,而不同分支下面的动态的节点肯定是不同的,所以需要分别创建 Block
维护。
运行时的构造 Block Tree
接下来,我们来看 openBlock
的实现:
const blockStack = []
let currentBlock = null
function openBlock(disableTracking = false) {
blockStack.push((currentBlock = disableTracking ? null : []));
}
Vue.js 3.0 在运行时设计了一个 blockStack
和 currentBlock
,其中 blockStack
表示一个 Block Tree
,因为要考虑嵌套 Block
的情况,而 currentBlock
表示当前的 Block
。
openBlock
的实现很简单,往当前 blockStack push
一个新的 Block
,作为 currentBlock
。
设计 Block
的目的主要就是收集动态的 vnode
的节点,这样才能在 patch
阶段只比对这些动态 vnode
节点,避免不必要的静态节点的比对,优化了性能。
那么动态 vnode
节点是什么时候被收集的呢?其实是在 createVNode
阶段,我们来回顾一下它的实现:
function createVNode(type, props = null,children = null, patchFlag = 0, dynamicProps = null, isBlockNode = false) {
// 处理 props 相关逻辑,标准化 class 和 style
// 对 vnode 类型信息编码
// 创建 vnode 对象
// 标准化子节点,把不同数据类型的 children 转成数组或者文本类型。
// 添加动态 vnode 节点到 currentBlock 中
if (shouldTrack > 0 &&
!isBlockNode &&
currentBlock &&
patchFlag !== 32 /* HYDRATE_EVENTS */ &&
(patchFlag > 0 ||
shapeFlag & 128 /* SUSPENSE */ ||
shapeFlag & 64 /* TELEPORT */ ||
shapeFlag & 4 /* STATEFUL_COMPONENT */ ||
shapeFlag & 2 /* FUNCTIONAL_COMPONENT */)) {
currentBlock.push(vnode);
}
return vnode
}
createVNode
函数的最后判断 vnode
是不是一个动态节点,如果是则把它添加到 currentBlock
中,这就是动态 vnode
节点的收集过程。
我们接着来看 createBlock
的实现:
function createBlock(type, props, children, patchFlag, dynamicProps) {
// 创建 vnode
const vnode = createVNode(type, props, children, patchFlag, dynamicProps, true /* isBlock: 阻止这个 block 收集自身 */)
// 在 vnode 上保留当前 Block 收集的动态子节点
vnode.dynamicChildren = currentBlock || EMPTY_ARR
closeBlock()
// 节点本身作为父 Block 收集的子节点
if (shouldTrack > 0 && currentBlock) {
currentBlock.push(vnode)
}
return vnode
}
function closeBlock() {
// 当前 Block 恢复到父 Block
blockStack.pop()
currentBlock = blockStack[blockStack.length - 1] || null
}
在 createBlock
内部,首先会执行 createVNode
创建一个 vnode
节点,注意最后一个参数是 true
,这表明它是一个 Block node
,所以就不会把自身当作一个动态 vnode
收集到 currentBlock
中。
接着把收集动态子节点的 currentBlock
保留到当前的 Block vnode
的 dynamicChildren
中,为后续 patch
过程访问这些动态子节点所用。
最后把当前 Block
恢复到父 Block
,如果父 Block
存在的话,则把当前这个 Block node
作为动态节点添加到父 Block
中,这样就构成了 Block Tree
的树形结构。
你可能会好奇,为什么要设计 openBlock
和 createBlock
两个函数呢?比如下面这个函数:
function render() {
return (openBlock(),createBlock('div', null, [/*...*/]))
}
为什么不把 openBlock
和 createBlock
放在一个函数中执行呢,像下面这样:
function render() {
return (createBlock('div', null, [/*...*/]))
}
function createBlock(type, props, children, patchFlag, dynamicProps) {
openBlock()
// 创建 vnode
const vnode = createVNode(type, props, children, patchFlag, dynamicProps, true)
vnode.dynamicChildren = currentBlock || EMPTY_ARR
closeBlock()
// ...
return vnode
}
这样是不行的,其中原因其实很简单,createBlock
函数的第三个参数是 children
,这些 children
中的元素也是经过 createVNode
创建的,显然一个函数的调用需要先去执行参数的计算,也就是优先去创建子节点的 vnode
,然后才会执行父节点的 createBlock
或者是 createVNode
。
所以在父节点的 createBlock
函数执行前,子节点就已经通过 createVNode
创建了对应的 vnode
,如果把 openBlock
的逻辑放在了 createBlock
中,就相当于在子节点创建后才创建 currentBlock
,这样就不能正确地收集子节点中的动态 vnode
了。
再回到 createBlock
函数内部,这个时候你要明白动态子节点已经被收集到 currentBlock
中了。
patch 阶段的性能优化
Block Tree
的构造过程我们搞清楚了,那么接下来我们就来看它在 patch
阶段具体是如何工作的。
我们之前分析过,在 patch
阶段更新节点元素的时候,会执行 patchElement
函数,我们再来回顾一下它的实现:
const patchElement = (n1, n2, parentComponent, parentSuspense, isSVG, optimized) => {
const el = (n2.el = n1.el)
const oldProps = (n1 && n1.props) || EMPTY_OBJ
const newProps = n2.props || EMPTY_OBJ
// 更新 props
patchProps(el, n2, oldProps, newProps, parentComponent, parentSuspense, isSVG)
const areChildrenSVG = isSVG && n2.type !== 'foreignObject'
// 更新子节点
if (n2.dynamicChildren) {
patchBlockChildren(n1.dynamicChildren, n2.dynamicChildren, currentContainer, parentComponent, parentSuspense, isSVG);
}
else if (!optimized) {
patchChildren(n1, n2, currentContainer, currentAnchor, parentComponent, parentSuspense, isSVG);
}
}
我在课程《组件更新》的章节分析过这个流程,分析了子节点更新的部分,当时并没有考虑到优化的场景,只是分析了全量比对更新的场景。
而实际上,如果这个 vnode
是一个 Block vnode
,那么在优化的场景下,我们更新它的子节点不用通过 patchChildren
全量比对,只需要通过 patchBlockChildren
去比对并更新 Block
中的动态子节点即可。
我们来看一下它的实现:
const patchBlockChildren = (oldChildren, newChildren, fallbackContainer, parentComponent, parentSuspense, isSVG) => {
for (let i = 0; i const oldVNode = oldChildren[i]
const newVNode = newChildren[i]
// 确定待更新节点的容器
const container =
// 对于 Fragment,我们需要提供正确的父容器
oldVNode.type === Fragment ||
// 在不同节点的情况下,将有一个替换节点,我们也需要正确的父容器
!isSameVNodeType(oldVNode, newVNode) ||
// 组件的情况,我们也需要提供一个父容器
oldVNode.shapeFlag & 6 /* COMPONENT */
? hostParentNode(oldVNode.el)
:
// 在其他情况下,父容器实际上并没有被使用,所以这里只传递 Block 元素即可
fallbackContainer
patch(oldVNode, newVNode, container, null, parentComponent, parentSuspense, isSVG, true)
}
}
patchBlockChildren
的实现很简单,遍历新的动态子节点数组,拿到对应的新旧动态子节点,并执行 patch
更新子节点即可。
这样一来,更新的复杂度就变成和动态节点的数量正相关,而不与模板大小正相关,如果一个模板的动静比越低,那么性能优化的效果就越明显。
问题解答
我们从编译阶段到运行时了解了整个 Block Tree
的实现,接下来回到问题本身:Block
数组是一维的,但是动态的子节点可能有嵌套关系,patchBlockChildren
内部也是递归执行了 patch
函数,那么在整个更新的过程中,会出现子节点重复更新的情况吗,为什么?
其实你了解了整个 Block Tree
的构造过程,回答这个问题并不难,首先这个题有迷惑性,Block
数组是一维的没错,patchBlockChildren
的时候,会遍历所有的动态节点执行 patch
,动态节点有两种情况,要么是普通的动态 vnode
,要么是嵌套的 Block vnode
。
如果是普通的动态 vnode
,再次执行 patch
的时候还会执行到 patchElement
,这个时候 vnode.dynamicChildren
为 null
,并且由于 optimize
为 true
,所以压根不会执行 patchChildren
去更新子节点。言下之意,在这种优化的场景下,普通的动态 vnode
执行 patchElement
只会更新自身的 props
,而不会更新它的子节点,所以即使动态 vnode
出现嵌套也没有关系。
这么做其实是非常好理解的,因为动态 vnode
的所有动态子节点已经被收集到 currentBlock
中了,遍历 currentBlock
(也就是前面提到的 dynamicChildren
) 的时候就会完成他们的更新。
那么,如果更新的节点是一个 Block vnode
的话,那么很简单,Block vnode
是有 dynamicChildren
的,递归执行 patchBlockChildren
即可,通过递归的方式,就可以完成组件下所有动态节点的更新了。
总结
综上,我们再次复习了 Vue.js 3.0 基于 Block Tree
的模板编译优化的实现原理,当然 Vue.js 在编译时的优化不止于此,还有静态提升等其它优化手段,不过 Block Tree
的设计可谓是相当惊艳了。
而网上那些无脑吹用 JSX
写 Vue.js 3.0 项目的人,是否认真研究过 Vue.js 3.0 模板编译带来运行时的性能提升呢?
相比模板的直观,JSX
更加灵活,各有各的使用场景,没有必要一味地吹捧其中一个甚至踩另一个,适合的才是最好的。
我出这个题主要是希望你能做到以下两点:
从编译到运行时阶段全方位彻底了解
Block Tree
的实现原理。搞清楚
patch
过程在优化场景和非优化场景的异同。
要记住,分析和思考的过程远比答案重要。
交流讨论
欢迎关注公众号「前端试炼」,公众号平时会分享一些实用或者有意思的东西,发现代码之美。专注深度和最佳实践,希望打造一个高质量的公众号。
公众号后台回复「小炼」加我微信,带你飞。
如果觉得这篇文章还不错,来个【分享、点赞、在看】三连吧,让更多的人也看到~