概念
v-memo 是 Vue.js 3.2 版本新增的指令,用于缓存具体元素节点的 vnode,在节点更新时节约重新创建 vnode 的时间,节约 patch 比较的时间。 看到这个概念可能有点懵,其实它的核心就是复用 vnode。
在 react 中功能类似的 API 有:memo,useCallback
使用场景
Vue.js 官网有介绍它的使用场景:内置指令 v-memo
- 用于普通标签
- 用于列表,结合 v-for 使用 。仅供性能敏感场景的针对性优化,会用到的场景应该很少。渲染 v-for 长列表 (长度大于 1000) 可能是它最有用的场景。
:::danger 主要解决的问题就是:当组件的 selected 状态发生变化时,即使绝大多数 item 都没有发生任何变化,大量的 VNode 仍将被创建。 :::
源码分析
用于 v-for 上
来看一个示例:
html
<div v-for="item in list" :key="item.id" v-memo="[item.id === selected]">
<p>ID: {{ item.id }} - selected: {{ item.id === selected }}</p>
<p>...more child nodes</p>
</div>
借助于在线模板编译工具,可以看到其对应的 render 函数:
js
import { renderList as _renderList, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock, toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, isMemoSame as _isMemoSame, withMemo as _withMemo } from "vue"
const _hoisted_1 = /*#__PURE__*/_createElementVNode("p", null, "...more child nodes", -1 /* HOISTED */)
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.list, (item, __, ___, _cached) => {
const _memo = ([item.id === _ctx.selected])
if (_cached && _cached.key === item.id && _isMemoSame(_cached, _memo)) return _cached
const _item = (_openBlock(), _createElementBlock("div", {
key: item.id
}, [
_createElementVNode("p", null, "ID: " + _toDisplayString(item.id) + " - selected: " + _toDisplayString(item.id === _ctx.selected), 1 /* TEXT */),
_hoisted_1
]))
_item.memo = _memo
return _item
}, _cache, 0), 128 /* KEYED_FRAGMENT */))
}
基于 v-for 的列表内部是通过 renderList 函数来渲染的,来看它的实现:
js
function renderList(source, renderItem, cache, index) {
let ret
const cached = (cache && cache[index])
if (isArray(source) || isString(source)) {
ret = new Array(source.length)
for (let i = 0, l = source.length; i < l; i++) {
ret[i] = renderItem(source[i], i, undefined, cached && cached[i])
}
}
else if (typeof source === 'number') {
// source 是数字
}
else if (isObject(source)) {
// source 是对象
}
else {
ret = []
}
if (cache) {
cache[index] = ret
}
return ret
}
这里我们只分析列表 list 是数组的情况。这种情况对于每一个 item,都会执行 renderItem 函数来渲染。 renderItem 是在 render 函数中作为参数传入进去的,也就是如下这个函数:
js
(item, __, ___, _cached) => {
const _memo = ([item.id === _ctx.selected])
if (_cached && _cached.key === item.id && _isMemoSame(_cached, _memo)) return _cached
const _item = (_openBlock(), _createElementBlock("div", {
key: item.id
}, [
_createElementVNode("p", null, "ID: " + _toDisplayString(item.id) + " - selected: " + _toDisplayString(item.id === _ctx.selected), 1 /* TEXT */),
_hoisted_1
]))
_item.memo = _memo
return _item
}
可以看到:
- 在 renderItem 函数内部,维护了一个 _memo 变量,它就是用来判断是否从缓存里获取 vnode 的条件数组
- 而第四个参数 _cached 对应的就是 item 对应缓存的 vnode
接下来通过 isMemoSame 函数来判断 memo 是否相同,来看它的实现:
js
function isMemoSame(cached, memo) {
const prev = cached.memo
if (prev.length != memo.length) {
return false
}
for (let i = 0; i < prev.length; i++) {
if (prev[i] !== memo[i]) {
return false
}
}
// ...
return true
}
isMemoSame 函数内部会通过 cached.memo 拿到缓存的 memo,然后通过遍历对比 memo 数组中的每一个条件来判断和当前的 memo 是否相同。 memo 的所有条件都相同时,返回缓存的 vnode。这就是整个流程。
在 renderItem 函数的结尾,就会把 _memo 缓存到当前 item 的 vnode 中,便于下一次通过 isMemoSame 来判断这个 memo 是否相同,如果相同,说明该项没有变化,直接返回上一次缓存的 vnode。
缓存的 vnode 存储在哪里?
那么这个缓存的 vnode 具体存储到哪里呢? 原来在初始化组件实例的时候,就设计了渲染缓存:
js
const instance = {
// ...
renderCache: []
}
然后在执行 render 函数的时候,把这个缓存当做第二个参数传入:
js
const { renderCache } = instance
result = normalizeVNode(
render.call(
proxyToUse,
proxyToUse,
renderCache,
props,
setupState,
data,
ctx
)
)
然后在执行 renderList 函数的时候,把 _cache 作为第三个参数传入:
js
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.list, (item, __, ___, _cached) => {
// renderItem 实现
}, _cache, 0), 128 /* KEYED_FRAGMENT */))
}
所以实际上列表缓存的 vnode 都保留在 _cache 中,也就是 instance.renderCache 中
用于单个元素上
示例:
html
<div v-memo="[msg]">
<p>{{ msg }}</p>
<p>{{ count }}</p>
</div>
<div v-memo="[count]">
<p>{{ msg }}</p>
<p>{{ count }}</p>
</div>
编译后:
js
import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock, withMemo as _withMemo, Fragment as _Fragment } from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock(_Fragment, null, [
_withMemo([_ctx.msg], () => (_openBlock(), _createElementBlock("div", null, [
_createElementVNode("p", null, _toDisplayString(_ctx.msg), 1 /* TEXT */),
_createElementVNode("p", null, _toDisplayString(_ctx.count), 1 /* TEXT */)
])), _cache, 0),
_withMemo([_ctx.count], () => (_openBlock(), _createElementBlock("div", null, [
_createElementVNode("p", null, _toDisplayString(_ctx.msg), 1 /* TEXT */),
_createElementVNode("p", null, _toDisplayString(_ctx.count), 1 /* TEXT */)
])), _cache, 1)
], 64 /* STABLE_FRAGMENT */))
}
// Check the console for the AST
可以看到,v-memo 的标签会被 withMemo 包裹,并且 withMemo 每新增一个,则 index + 1。
withMemo 的实现
js
export function withMemo(
memo: any[],
render: () => VNode<any, any>,
cache: any[],
index: number
) {
const cached = cache[index] as VNode | undefined
if (cached && isMemoSame(cached, memo)) {
return cached
}
const ret = render()
// shallow clone
ret.memo = memo.slice()
return (cache[index] = ret)
}
withMemo 中会取 cached[index] 来取出缓存的 vnode,也是通过和上述一样的 isMemoSame 方法判断 memo 是否变化。
v-memo 不能放在 v-for 内部的节点使用
官网文档有提到 v-memo 不能放在 v-for 内部的节点使用。但是具体原因没有说。
但我们可以通过源码分析到。 以下是 v-memo 放到 v-for 内部的节点时的示例:
html
<div v-for="item in list" :key="item.id">
<p v-memo="[item.id === item.selected]">
ID:{{ item.id }}
Selected:{{ item.selected }}
</p>
<p>...more child nodes</p>
</div>
<div v-memo="[msg]">
<p>{{ msg }}</p>
<p>{{ count }}</p>
</div>
<div v-memo="[count]">
<p>{{ msg }}</p>
<p>{{ count }}</p>
</div>
编译后:
js
import { renderList as _renderList, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock, toDisplayString as _toDisplayString, createTextVNode as _createTextVNode, withMemo as _withMemo, createElementVNode as _createElementVNode } from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock(_Fragment, null, [
(_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.list, (item) => {
return (_openBlock(), _createElementBlock("div", {
key: item.id
}, [
_withMemo([item.id === item.selected], () => (_openBlock(), _createElementBlock("p", null, [
_createTextVNode(" ID:" + _toDisplayString(item.id) + " Selected:" + _toDisplayString(item.selected), 1 /* TEXT */)
])), _cache, 0),
_createElementVNode("p", null, "...more child nodes")
]))
}), 128 /* KEYED_FRAGMENT */)),
_withMemo([_ctx.msg], () => (_openBlock(), _createElementBlock("div", null, [
_createElementVNode("p", null, _toDisplayString(_ctx.msg), 1 /* TEXT */),
_createElementVNode("p", null, _toDisplayString(_ctx.count), 1 /* TEXT */)
])), _cache, 1),
_withMemo([_ctx.count], () => (_openBlock(), _createElementBlock("div", null, [
_createElementVNode("p", null, _toDisplayString(_ctx.msg), 1 /* TEXT */),
_createElementVNode("p", null, _toDisplayString(_ctx.count), 1 /* TEXT */)
])), _cache, 2)
], 64 /* STABLE_FRAGMENT */))
}
// Check the console for the AST
可以看到,如果放到 v-for 内部的节点的话,renderItem 函数中的节点生成方式会变成 withMemo 包裹,并且每个 item 传入的 index 都是一样的,这样 cache[index] 就都会取同样的缓存 vnode,从而产生错误。
这就是整个的原因,所以有的时候我们不能只停留在能使用什么和不能使用什么的阶段,我们需要去弄懂为什么它不能这么使用的原因。以便于后续在项目中更好的运用。
🟥 使用 v-memo 缓存 vnode 的优点
避免重新生成 vnode
在数据变化后,会触发组件的副作用函数 setupRenderEffect 重新执行,副作用函数中会执行 render 函数生成新的子树 vnode,也就是 nextTree
js
const setupRenderEffect = (instance, initialVNode, container, anchor, parentSuspense, isSVG, optimized) => {
// 创建响应式的副作用渲染函数
instance.update = effect(function componentEffect() {
if (!instance.isMounted) {
// 渲染组件
} else {
// 更新组件
let { next, vnode } = instance
// next 表示新的组件 vnode
if (next) {
// 更新组件 vnode 节点信息
updateComponentPreRender(instance, next, optimized)
} else {
next = vnode
}
// 渲染新的子树 vnode
const nextTree = renderComponentRoot(instance)
// 缓存旧的子树 vnode
const prevTree = instance.subTree
// 更新子树 vnode
instance.subTree = nextTree
// 组件更新核心逻辑,根据新旧子树 vnode 做 patch
patch(prevTree, nextTree,
// 如果在 teleport 组件中父节点可能已经改变,所以容器直接找旧树 DOM 元素的父节点
hostParentNode(prevTree.el),
// 参考节点在 fragment 的情况可能改变,所以直接找旧树 DOM 元素的下一个节点
getNextHostNode(prevTree),
instance,
parentSuspense,
isSVG
)
// 缓存更新后的 DOM 节点
next.el = nextTree.el
}
}, prodEffectOptions)
}
接下来深入 render 函数中看
html
<div v-for="item in list" :key="item.id" v-memo="[item.id === item.selected]">
<p>
ID:{{ item.id }}
Selected:{{ item.selected }}
</p>
<p>...more child nodes</p>
</div>
js
import { renderList as _renderList, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock, toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, isMemoSame as _isMemoSame, withMemo as _withMemo } from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.list, (item, __, ___, _cached) => {
const _memo = ([item.id === item.selected])
if (_cached && _cached.key === item.id && _isMemoSame(_cached, _memo)) return _cached
const _item = (_openBlock(), _createElementBlock("div", {
key: item.id
}, [
_createElementVNode("p", null, " ID:" + _toDisplayString(item.id) + " Selected:" + _toDisplayString(item.selected), 1 /* TEXT */),
_createElementVNode("p", null, "...more child nodes")
]))
_item.memo = _memo
return _item
}, _cache, 0), 128 /* KEYED_FRAGMENT */))
}
// Check the console for the AST
可以看到,如果在 v-for 的列表上添加了 v-memo 属性,则 renderItem 中会直接判断:
- 条件是否没变
- key 是否没变
都没变的话直接使用 _cached(缓存的 vnode),而不再执行下面的 createElementVNode 步骤
所以,就避免了重新再生成一遍 vnode
避免了 patch 的比较过程
因为 n1 和 n2 这样就引用地址完全一样了,所以直接 return,无需后续递归的 patch 子节点
javascript
const patch = (n1, n2, container, anchor = null, parentComponent = null, parentSuspense = null, isSVG = false, slotScopeIds = null, optimized = false) => {
if(n1 === n2) {
return
}
// ...
}
总结
归根结底,vue 在数据变化后,会重新执行组件的副作用渲染函数重新渲染,它只能知道该组件发生了变化,而不知道具体哪个元素发生变化。
所以 v-memo 就是告诉我们具体哪个元素发生变化 ,从而在 render 函数执行到这个组件的 createVNode 时,能直接跳过,使用缓存的 vnode 即可。 是一个空间换时间的操作。