Vue 3 Complier & Renderer API
Vue 3 模板资源管理器 Vue 3 Template Explorer,它允许我们查看由 Vue 3 Complier 生成的渲染函数。左边是源模版,右边是生成的渲染函数代码。
这个网页版工具很有用,当代码不能像预期一样工作,可以在左边黏贴自己的模版,就能看到代码被编译成了什么,然后看看模版出了什么问题。尤大正是用它来在开发过程中调试 Compiler。
模版浏览器右侧有很多选项,可以用来在查看时启用一些特定类型的优化。尤大用这个浏览器向我们展示了 Vue 3 中的新优化。
1. 嵌套节点(静态节点提升)
有一个嵌套的 div,我们可以启用hoistStatic
option,来查看它是否被从渲染函数中提升,以便在每个渲染器(render
)上复用它。
这个 render 函数会在每次组件更新时被调用。每当一个节点被提升,它就会被在 render 函数外创建一次。在以后的每一次渲染中,被提升的那些节点(hosited_1
、hosited_2
...)会在 render 函数内被复用。这有两个好处,一是避免重新创建对象,然后扔掉(垃圾收集的知识点),二是在我们的模式算法中,当两个节点在同一个位置时,在严格平等的情况下,我们可跳过它,因为知道它永远不会改变。
2. 事件侦听器(处理程序缓存)
在虚拟 DOM 中,当有东西改变,不会去检查所有节点、所有属性和元素,只检查特定的地方。因为编译器会生成提示 hints,以帮助 runtime 更高效。这在手写的渲染函数中很难实现,因为分析 JavaScript 比 分析模版困难得多。
比如在这个 div 节点,我们有一个 onClick 侦听器。每次更新或 diff,我们都会根据标记看一下
onClick
,以确认它没有改变。
但大多数情况,当你绑定一个 event listener,并不会去更改 event handler(的变量名)。我们会在第一次渲染时把事件处理程序转化为一个内联函数,并把缓存它。在以后的渲染中,都会始终使用同一个内联处理程序(inline handler) 。然后里面的函数会访问_ctx.onClick
,来保证总是最新的函数被调用。
因此,如果onClick
函数的内容变了,我们不需要对 vnode 本身做任何事情。这就是另一个层次的优化。
这一点尤其重要,因为如果要将event handlers
添加组件上,导致子组件不必要的渲染。
当你编写像这样的内联处理程序:<Foo onClick="()=>foo()"/>
或者
<Foo onClick="foo(123)"/>
(这也是一个隐式的内联处理程序),在 Vue 2 中即使这个Foo
组件什么都没改变,event handler 仍然会导致它的子组件在Foo
重新渲染时也再次渲染。这在大型应用中会导致连锁效应。
而 Vue 3 通过 handle 缓存,减少了大型组件树中发生很大面积的不必要的渲染。这也是一个很不错的性能改进。
事实上,这也是 React 中 一个常见的陷阱。这就是为什么有一个useMemo
和useCallback
API,作用就是允许开发人员手动缓存像这样的 event handler 来防止子组件重新渲染。
Vue 3 为用户自动完成这一切。
动态插值(精准跟踪动态节点 避免其他无关 DOM 不必要的渲染)
真实 DOM 更新时的弊端:runtime 并不能提供给我们信息。 比如节点顺序已经改变,或者从 div 变成了 p。所以 runtime 必须检查每个节点以确保 DOM 树结构稳定,把所有的 props 都区分开来再确保 props 有无改变。没有 children “随意走动”,或者 新的 children 加入。
当我们调用渲染函数,生成的 JavaScript 结构就像这样:
编译器渲染出的 vdom
渲染器更新渲染这样的 JavaScript 结构时,会有两个像这样的虚拟 DOM 树的快照(snapshots)。某些部分可能已经改变了,但渲染器(renderer)并不真正知道发生了什么变化,如果我们不提供更多相关的 hints。所以它必须经过一系列相对暴力的算法,以递归地方式遍历整棵树,比较旧节点和新节点去找出变化。
这在一般的中型应用程序也足够快,以至于你不会注意到任何性能的瓶颈。因为现代 JavaScript 引擎在迭代处理普通对象方面已经做了很好的优化。但在大型 App 中这些小的迭代成本会叠加,比如你点击一个按钮,10个组件同时被触发更新,JavaScript 开销一下子增加了,就可能会阻塞或卡顿你的程序。所以人们开始考虑如何手动优化组件树,避免不必要的重新渲染。这就是 Vue 的优势所在,让框架足够智能,那么用户甚至不用考虑这些事。
如果没有编译器生成的提示,虚拟 DOM 渲染器只能看到 JavaScript 树,并不能知道哪个部分会改变,哪些部分不会变。编译器的工作就是提供这些信息。这样 runtime 就能跳过很多不必要的工作,直接去处理可能会改变的(比如动态插值)的部分。同时还会添加一些补丁标识(比如/* TEXT */
)来表示这个节点是动态的,它应该被跟踪(tracked
)
我们实现这点的方式是将动态模版的根节点转化成所谓的 Block,通过_createElementBlock
。当_openBlock()
调用时,所有的 children 都会被评估。
等 render 函数整个被调用,这个div
将会有一个额外的属性,叫做动态子节点,只包含span
这个节点。
每个 Block 都有一个额外的数组,只跟踪其中的动态节点(无论嵌套得多深)。那么无论你的 DOM 结构多复杂,块只跟踪在一个扁平数组中的动态节点(可能改变的节点)。
v-if 结构指令
像v-if
这样的指令会改变节点结构,能控制节点从 DOM 树上出现或消失。
这样一来因为span
的父级div
也变成动态的了。我们有嵌套的Block
,每个 Block
都将在扁平数组中跟踪自己的动态子对象。这样能在大多数情况下减少数量级的递归数量(因为不必检查每个 vnode 的变化)。现在只需要去查找 Blocks
,还有提供的一些 hints,关于值内部可能发生的变化。
还有对于每个节点,补丁标识本身还编码了关于要在这个节点上做的事情的种类信息,如/* TEXT */
,表示当试图 diff 这个节点时,只需要检查它的文本内容,不必去管它的props
。
将所有这些结合起来,编译器将真正生成一个 runtime 渲染函数,它允许 runtime 利用所有这些 hints 做尽可能少的工作。
简化版渲染函数:
<style>
.red { color: red }
</style>
<div id="app"></div>
<script>
function h(tag, props, children) {
return {
tag,
props,
children
}
}
function mount(vnode, container) {
const el = document.createElement(vnode.tag)
// props
if (vnode.props) {
for (const key in vnode.props) {
const value = vnode.props[key]
el.setAttribute(key, value)
}
}
// children
if (vnode.children) {
if (typeof vnode.children === 'string') {
el.textContent = vnode.children
} else {
vnode.children.forEach(child => {
mount(child, el)
})
}
}
container.appendChild(el)
}
const vdom = h('div', { class: 'red' }, [
h('span', null, 'hello')
])
mount(vdom, document.getElementById('app'))
</script>
这段代码允许我们使用渲染函数,返回一个简单的虚拟节点,然后挂载函数将进行适当的 DOM JavaScript 调用以在浏览器中创建虚拟节点。
简化版 Patch 补丁函数:
<style>
.red { color: red }
.green { color: green }
</style>
<div id="app"></div>
<script>
function h(tag, props, children) {
return {
tag,
props,
children
}
}
function mount(vnode, container) {
const el = vnode.el = document.createElement(vnode.tag)
// props
if (vnode.props) {
for (const key in vnode.props) {
const value = vnode.props[key]
el.setAttribute(key, value)
}
}
// children
if (vnode.children) {
if (typeof vnode.children === 'string') {
el.textContent = vnode.children
} else {
vnode.children.forEach(child => {
mount(child, el)
})
}
}
container.appendChild(el)
}
const vdom = h('div', { class: 'red' }, [
h('span', null, 'hello')
])
mount(vdom, document.getElementById('app'))
function patch(n1, n2) {
if (n1.tag === n2.tag) {
const el = n2.el = n1.el
//props
const oldProps = n1.props || {}
const newProps = n2.props || {}
for (const key in newProps) {
const oldValue = oldProps[key]
const newValue = newProps[key]
if (newValue !== oldValue) {
el.setAttribute(key, newValue)
}
}
for (const key in oldProps) {
if (!(key in newProps)) {
el.removeAttribute(key)
}
}
// children
const oldChildren = n1.children
const newChildren = n2.children
if (typeof newChildren === 'string') {
if (typeof oldChildren === 'string') {
if (newChildren !== oldChildren) {
el.textContent = newChildren
}
} else {
el.textContent = newChildren
}
} else {
if (typeof oldChildren === 'string') {
el.innerHTML = ''
newChildren.forEach(child => {
mount(child, el)
})
} else {
const commonLength = Math.min(oldChildren.length, newChildren.length)
for (let i = 0; i < commonLength; i++) {
patch(oldChildren[i], newChildren[i])
}
if (newChildren.length > oldChildren.length) {
newChildren.slice(oldChildren.length).forEach(child => {
mount(child, el)
})
} else if (newChildren.length < oldChildren.length) {
oldChildren.slice(newChildren.length).forEach(child => {
el.removeChild(child.el)
})
}
}
}
} else {
//replace
}
}
const vdom2 = h('div', { class: 'green'}, [
h('span', null, 'changed!')
])
patch(vdom, vdom2)
</script>