v-memo 实现原理

概念

v-memo 是 Vue.js 3.2 版本新增的指令,用于缓存具体元素节点的 vnode,在节点更新时节约重新创建 vnode 的时间,节约 patch 比较的时间。 看到这个概念可能有点懵,其实它的核心就是复用 vnode。

在 react 中功能类似的 API 有:memo,useCallback

使用场景

Vue.js 官网有介绍它的使用场景:内置指令 v-memo

  1. 用于普通标签
  2. 用于列表,结合 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
}

可以看到:

  1. 在 renderItem 函数内部,维护了一个 _memo 变量,它就是用来判断是否从缓存里获取 vnode 的条件数组
  2. 而第四个参数 _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 中会直接判断:

  1. 条件是否没变
  2. 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 即可。 是一个空间换时间的操作

相关推荐
夏河始溢1 分钟前
一七八、Node.js PM2使用介绍
前端·javascript·node.js·pm2
记忆深处的声音2 分钟前
vue2 + Element-ui 二次封装 Table 组件,打造通用业务表格
前端·vue.js·代码规范
熊的猫17 分钟前
webpack 核心模块 — loader & plugins
前端·javascript·chrome·webpack·前端框架·node.js·ecmascript
四喜花露水1 小时前
Vue 自定义icon组件封装SVG图标
前端·javascript·vue.js
前端Hardy1 小时前
HTML&CSS: 实现可爱的冰墩墩
前端·javascript·css·html·css3
web Rookie2 小时前
JS类型检测大全:从零基础到高级应用
开发语言·前端·javascript
工业甲酰苯胺2 小时前
C# 单例模式的多种实现
javascript·单例模式·c#
程序员爱技术5 小时前
Vue 2 + JavaScript + vue-count-to 集成案例
前端·javascript·vue.js
悦涵仙子6 小时前
CSS中的变量应用——:root,Sass变量,JavaScript中使用Sass变量
javascript·css·sass
兔老大的胡萝卜6 小时前
ppk谈JavaScript,悟透JavaScript,精通CSS高级Web,JavaScript DOM编程艺术,高性能JavaScript pdf
前端·javascript