Skip to content

本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com

前言

在上一篇 vue3 早已具备抛弃虚拟 DOM 的能力了 [1] 文章中讲了对于动态节点,vue 做的优化是将这些动态节点收集起来,然后当响应式变量修改后进行靶向更新。那么 vue 对静态节点有没有做什么优化呢?答案是:当然有,对于静态节点会进行 “静态提升”。这篇文章我们来看看 vue 是如何进行静态提升的。

什么是静态提升?

我们先来看一个 demo,代码如下:

<template>  <div>    <h1>title</h1>    <p>{{ msg }}</p>    <button @click="handleChange">change msg</button>  </div></template><script setup lang="ts">import { ref } from "vue";const msg = ref("hello");function handleChange() {  msg.value = "world";}</script>

这个 demo 代码很简单,其中的 h1 标签就是我们说的静态节点,p 标签就是动态节点。点击 button 按钮会将响应式msg变量的值更新,然后会执行 render 函数将msg变量的最新值 "world" 渲染到 p 标签中。

我们先来看看未开启静态提升之前生成的 render 函数是什么样的:

由于在 vite 项目中启动的 vue 都是开启了静态提升,所以我们需要在 Vue 3 Template Explorer[2] 网站中看看未开启静态提升的 render 函数的样子(网站 URL 为: template-explorer.vuejs.org/[3] ),如下图将hoistStatic这个选项取消勾选即可:

未开启静态提升生成的 render 函数如下:

import { createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"export function render(_ctx, _cache, $props, $setup, $data, $options) {  return (_openBlock(), _createElementBlock("template", null, [    _createElementVNode("div", null, [      _createElementVNode("h1", null, "title"),      _createElementVNode("p", null, _toDisplayString(_ctx.msg), 1 /* TEXT */),      _createElementVNode("button", { onClick: _ctx.handleChange }, "change msg", 8 /* PROPS */, ["onClick"])    ])  ]))}

每次响应式变量更新后都会执行 render 函数,每次执行 render 函数都会执行createElementVNode方法生成 h1 标签的虚拟 DOM。但是我们这个 h1 标签明明就是一个静态节点,根本就不需要每次执行 render 函数都去生成一次 h1 标签的虚拟 DOM。

vue3 对此做出的优化就是将 “执行createElementVNode方法生成 h1 标签虚拟 DOM 的代码” 提取到 render 函数外面去,这样就只有初始化的时候才会去生成一次 h1 标签的虚拟 DOM,也就是我们这篇文章中要讲的 “静态提升”。开启静态提升后生成的 render 函数如下:

import { createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"const _hoisted_1 = /*#__PURE__*/_createElementVNode("h1", null, "title", -1 /* HOISTED */)export function render(_ctx, _cache, $props, $setup, $data, $options) {  return (_openBlock(), _createElementBlock("template", null, [    _createElementVNode("div", null, [      _hoisted_1,      _createElementVNode("p", null, _toDisplayString(_ctx.msg), 1 /* TEXT */),      _createElementVNode("button", {        onClick: _cache[0] || (_cache[0] = (...args) => (_ctx.handleChange && _ctx.handleChange(...args)))      }, "change msg")    ])  ]))}

从上面可以看到生成 h1 标签虚拟 DOM 的createElementVNode函数被提取到 render 函数外面去执行了,只有初始化时才会执行一次将生成的虚拟 DOM 赋值给_hoisted_1变量。在 render 函数中直接使用_hoisted_1变量即可,无需每次执行 render 函数都去生成 h1 标签的虚拟 DOM,这就是我们这篇文章中要讲的 “静态提升”。

我们接下来还是一样的套路通过 debug 的方式来带你搞清楚 vue 是如何实现静态提升的,注:本文使用的 vue 版本为3.4.19

欧阳平时写文章参考的多本 vue 源码电子书、解锁我更多 vue 原理文章 [4]

如何实现静态提升

实现静态提升主要分为两个阶段:

  • transform阶段遍历 AST 抽象语法树,将静态节点找出来进行标记和处理,然后将这些静态节点塞到根节点的hoists数组中。

  • generate阶段遍历上一步在根节点存的hoists数组,在 render 函数外去生成存储静态节点虚拟 DOM 的_hoisted_x变量。然后在 render 函数中使用这些_hoisted_x变量表示这些静态节点。

transform 阶段

在我们这个场景中transform函数简化后的代码如下:

function transform(root, options) {
  // ...省略
  if (options.hoistStatic) {
    hoistStatic(root, context);
  }
  root.hoists = context.hoists;
}

从上面可以看到实现静态提升是执行了hoistStatic函数,我们给hoistStatic函数打个断点。让代码走进去看看hoistStatic函数是什么样的,在我们这个场景中简化后的代码如下:

function hoistStatic(root, context) {
  walk(root, context, true);
}

从上面可以看到这里依然不是具体实现的地方,接着将断点走进walk函数。在我们这个场景中简化后的代码如下:

function walk(node, context, doNotHoistNode = false) {
  const { children } = node;
  for (let i = 0; i < children.length; i++) {
    const child = children[i];
    if (
      child.type === NodeTypes.ELEMENT &&
      child.tagType === ElementTypes.ELEMENT
    ) {
      const constantType = doNotHoistNode
        ? ConstantTypes.NOT_CONSTANT
        : getConstantType(child, context);
      if (constantType > ConstantTypes.NOT_CONSTANT) {
        if (constantType >= ConstantTypes.CAN_HOIST) {
          child.codegenNode.patchFlag = PatchFlags.HOISTED + ` /* HOISTED */`;
          child.codegenNode = context.hoist(child.codegenNode);
          continue;
        }
      }
    }

    if (child.type === NodeTypes.ELEMENT) {
      walk(child, context);
    }
  }
}

我们先在 debug 终端上面看看传入的第一个参数node是什么样的,如下图:

从上面可以看到此时的node为 AST 抽象语法树的根节点,树的结构和template中的代码刚好对上。外层是 div 标签,div 标签下面有 h1、p、button 三个标签。

我们接着来看walk函数,简化后的walk函数只剩下一个 for 循环遍历node.children。在 for 循环里面主要有两块 if 语句:

  • 第一块 if 语句的作用是实现静态提升

  • 第二块 if 语句的作用是递归遍历整颗树。

我们来看第一块 if 语句中的条件,如下:

if (
  child.type === NodeTypes.ELEMENT &&
  child.tagType === ElementTypes.ELEMENT
)

在将这块 if 语句之前,我们先来了解一下这里的两个枚举。NodeTypesElementTypes

NodeTypes枚举

NodeTypes表示 AST 抽象语法树中的所有 node 节点类型,枚举值如下:

enum NodeTypes {  ROOT, // 根节点  ELEMENT,  // 元素节点,比如:div元素节点、Child组件节点  TEXT, // 文本节点  COMMENT,  // 注释节点  SIMPLE_EXPRESSION,  // 简单表达式节点,比如v-if="msg !== 'hello'"中的msg!== 'hello'  INTERPOLATION,  // 双大括号节点,比如{{msg}}  ATTRIBUTE,  // 属性节点,比如 title="我是title"  DIRECTIVE,  // 指令节点,比如 v-if=""  // ...省略}

看到这里有的小伙伴可能有疑问了,为什么 AST 抽象语法树中有这么多种节点类型呢?

我们来看一个例子你就明白了,如下:

<div v-if="msg !== 'hello'" title="我是title">msg为 {{ msg }}</div>

上面这段代码转换成 AST 抽象语法树后会生成很多 node 节点:

  • div对应的是ELEMENT元素节点

  • v-if对应的是DIRECTIVE指令节点

  • v-if中的msg !== 'hello'对应的是SIMPLE_EXPRESSION简单表达式节点

  • title对应的是ATTRIBUTE属性节点

  • msg为对应的是ELEMENT元素节点

  • 对应的是INTERPOLATION双大括号节点

ElementTypes枚举

div 元素节点、Child 组件节点都是NodeTypes.ELEMENT元素节点,那么如何区分是不是组件节点呢?就需要使用ElementTypes枚举来区分了,如下:

enum ElementTypes {
  ELEMENT,  // html元素
  COMPONENT,  // 组件
  SLOT, // 插槽
  TEMPLATE, // 内置template元素
}

现在来看第一块 if 条件,你应该很容易看得懂了:

if (
  child.type === NodeTypes.ELEMENT &&
  child.tagType === ElementTypes.ELEMENT
)

如果当前节点是 html 元素节点,那么就满足 if 条件。

当前的 node 节点是最外层的 div 节点,当然满足这个 if 条件。

接着将断点走进 if 条件内,第一行代码如下:

const constantType = doNotHoistNode
  ? ConstantTypes.NOT_CONSTANT
  : getConstantType(child, context);

在搞清楚这行代码之前先来了解一下ConstantTypes枚举

ConstantTypes枚举

我们来看看ConstantTypes枚举,如下:

enum ConstantTypes {  NOT_CONSTANT = 0, // 不是常量  CAN_SKIP_PATCH, // 跳过patch函数  CAN_HOIST,  // 可以静态提升  CAN_STRINGIFY,  // 可以预字符串化}

ConstantTypes枚举的作用就是用来标记静态节点的 4 种等级状态,高等级的状态拥有低等级状态的所有能力。比如: NOT_CONSTANT:表示当前节点不是静态节点。比如下面这个 p 标签使用了msg响应式变量:

<p>{{ msg }}</p>

const msg = ref("hello");

CAN_SKIP_PATCH:表示当前节点在重新执行 render 函数时可以跳过patch函数。比如下面这个 p 标签虽然使用了变量name,但是name是一个常量值。所以这个 p 标签其实是一个静态节点,但是由于使用了name变量,所以不能提升到 render 函数外面去。

<p>{{ name }}</p>
const name = "name";

CAN_HOIST:表示当前静态节点可以被静态提升,当然每次执行 render 函数时也无需执行patch函数。demo 如下:

<h1>title</h1>

CAN_STRINGIFY:表示当前静态节点可以被预字符串化,下一篇文章会专门讲预字符串化。 从 debug 终端中可以看到此时doNotHoistNode变量的值为 true,所以constantType变量的值为ConstantTypes.NOT_CONSTANTgetConstantType函数的作用是根据当前节点以及其子节点拿到静态节点的constantType

我们接着来看后面的代码,如下:

if (constantType > ConstantTypes.NOT_CONSTANT) {
  if (constantType >= ConstantTypes.CAN_HOIST) {
    child.codegenNode.patchFlag = PatchFlags.HOISTED + ` /* HOISTED */`;
    child.codegenNode = context.hoist(child.codegenNode);
    continue;
  }
}

前面我们已经讲过了,当前 div 节点的constantType的值为ConstantTypes.NOT_CONSTANT,所以这个 if 语句条件不通过。

我们接着看walk函数中的最后一块代码,如下:

if (child.type === NodeTypes.ELEMENT) {
  walk(child, context);
}

前面我们已经讲过了,当前 child 节点是 div 标签,所以当然满足这个 if 条件。将子节点 div 作为参数,递归调用walk函数。

我们再次将断点走进walk函数,和上一次执行walk函数不同的是,上一次walk函数的参数为 root 根节点,这一次参数是 div 节点。

同样的在walk函数内先使用 for 循环遍历 div 节点的子节点,我们先来看第一个子节点 h1 标签,也就是需要静态提升的节点。很明显 h1 标签是满足第一个 if 条件语句的:

if (
  child.type === NodeTypes.ELEMENT &&
  child.tagType === ElementTypes.ELEMENT
)

在 debug 终端中来看看 h1 标签的constantType的值,如下:

从上图中可以看到 h1 标签的constantType值为 3,也就是ConstantTypes.CAN_STRINGIFY。表明 h1 标签是最高等级的预字符串,当然也能静态提升

h1 标签的constantType当然就能满足下面这个 if 条件:

if (constantType > ConstantTypes.NOT_CONSTANT) {
  if (constantType >= ConstantTypes.CAN_HOIST) {
    child.codegenNode.patchFlag = PatchFlags.HOISTED + ` /* HOISTED */`;
    child.codegenNode = context.hoist(child.codegenNode);
    continue;
  }
}

值得一提的是上面代码中的codegenNode属性就是用于生成对应 node 节点的 render 函数。

然后以codegenNode属性作为参数执行context.hoist函数,将其返回值赋值给节点的codegenNode属性。如下:

child.codegenNode = context.hoist(child.codegenNode);

上面这行代码的作用其实就是将原本生成 render 函数的codegenNode属性替换成用于静态提升的codegenNode属性。

context.hoist方法

将断点走进context.hoist方法,简化后的代码如下:

function hoist(exp) {  context.hoists.push(exp);  const identifier = createSimpleExpression(    `_hoisted_${context.hoists.length}`,    false,    exp.loc,    ConstantTypes.CAN_HOIST  );  identifier.hoisted = exp;  return identifier;}

我们先在 debug 终端看看传入的codegenNode属性。如下图:

从上图中可以看到此时的codegenNode属性对应的就是 h1 标签,codegenNode.children对应的就是 h1 标签的 title 文本节点。codegenNode属性的作用就是用于生成 h1 标签的 render 函数。

hoist函数中首先执行 context.hoists.push(exp)将 h1 标签的codegenNode属性 push 到context.hoists数组中。context.hoists是一个数组,数组中存的是 AST 抽象语法树中所有需要被静态提升的所有 node 节点的codegenNode属性。

接着就是执行createSimpleExpression函数生成一个新的codegenNode属性,我们来看传入的第一个参数:

`_hoisted_${context.hoists.length}`

由于这里处理的是第一个需要静态提升的静态节点,所以第一个参数的值_hoisted_1。如果处理的是第二个需要静态提升的静态节点,其值为_hoisted_2,依次类推。

接着将断点走进createSimpleExpression函数中,代码如下:

function createSimpleExpression(
  content,
  isStatic = false,
  loc = locStub,
  constType = ConstantTypes.NOT_CONSTANT
) {
  return {
    type: NodeTypes.SIMPLE_EXPRESSION,
    loc,
    content,
    isStatic,
    constType: isStatic ? ConstantTypes.CAN_STRINGIFY : constType,
  };
}

这个函数的作用很简单,根据传入的内容生成一个简单表达式节点。我们这里传入的内容就是_hoisted_1

表达式节点我们前面讲过了,比如:v-if="msg !== 'hello'"中的msg!== 'hello'就是一个简单的表达式。

同理上面的_hoisted_1表示的是使用了一个变量名为_hoisted_1的表达式。

我们在 debug 终端上面看看hoist函数返回值,也就是 h1 标签新的codegenNode属性。如下图:

此时的codegenNode属性已经变成了一个简单表达式节点,表达式的内容为:_hoisted_1。后续执行generate生成 render 函数时,在 render 函数中 h1 标签就变成了表达式:_hoisted_1

最后再执行transform函数中的root.hoists = context.hoists,将context上下文中存的hoists属性数组赋值给根节点的hoists属性数组,后面在generate生成 render 函数时会用。

至此transform阶段已经完成了,主要做了两件事:

  • 将 h1 静态节点找出来,将该节点生成 render 函数的codegenNode属性 push 到根节点的hoists属性数组中,后面generate生成 render 函数时会用。

  • 将上一步 h1 静态节点的codegenNode属性替换为一个简单表达式,表达式为:_hoisted_1

generate阶段

generate阶段主要分为两部分:

  • 将原本 render 函数内调用createElementVNode生成 h1 标签虚拟 DOM 的代码,提到 render 函数外面去执行,赋值给全局变量_hoisted_1

  • 在 render 函数内直接使用_hoisted_1变量即可。

如下图:

生成 render 函数外面的_hoisted_1变量

经过transform阶段的处理,根节点的hoists属性数组中存了所有需要静态提升的静态节点。我们先来看如何处理这些静态节点,生成 h1 标签对应的_hoisted_1变量的。代码如下:

genHoists(ast.hoists, context);

将根节点的hoists属性数组传入给genHoists函数,将断点走进genHoists函数,在我们这个场景中简化后的代码如下:

function genHoists(hoists, context) {
  const { push, newline } = context;
  newline();
  for (let i = 0; i < hoists.length; i++) {
    const exp = hoists[i];
    if (exp) {
      push(`const _hoisted_${i + 1} = ${``}`);
      genNode(exp, context);
      newline();
    }
  }
}

generate部分的代码会在后面文章中逐行分析,这篇文章就不细看到每个函数了。简单解释一下genHoists函数中使用到的那些方法的作用。

  • context.code属性:此时的 render 函数字符串,可以在 debug 终端看一下执行每个函数后 render 函数字符串是什么样的。

  • newline方法:向当前的 render 函数字符串中插入换行符。

  • push方法:向当前的 render 函数字符串中插入字符串 code。

  • genNode函数:在transform阶段给会每个 node 节点生成codegenNode属性,在genNode函数中会使用codegenNode属性生成对应 node 节点的 render 函数代码。

在刚刚进入genHoists函数,我们在 debug 终端使用context.code看看此时的 render 函数字符串是什么样的,如下图:

从上图中可以看到此时的 render 函数 code 字符串只有一行 import vue 的代码。

然后执行newline方法向 render 函数 code 字符串中插入一个换行符。

接着遍历在transform阶段收集的需要静态提升的节点集合,也就是hoists数组。在 debug 终端来看看这个hoists数组,如下图:

从上图中可以看到在hoists数组中只有一个 h1 标签需要静态提升。

在 for 循环中会先执行一句push方法,如下:

push(`const _hoisted_${i + 1} = ${``}`)

这行代码的意思是插入一个名为_hoisted_1的 const 变量,此时该变量的值还是空字符串。在 debug 终端使用context.code看看执行push方法后的 render 函数字符串是什么样的,如下图:

从上图中可以看到_hoisted_1全局变量的定义已经生成了,值还没生成。

接着就是执行genNode(exp, context)函数生成_hoisted_1全局变量的值,同理在 debug 终端看看执行genNode函数后的 render 函数字符串是什么样的,如下图:

从上面可以看到 render 函数外面已经定义了一个_hoisted_1变量,变量的值为调用createElementVNode生成 h1 标签虚拟 DOM。

生成 render 函数中 return 的内容

generate中同样也是调用genNode函数生成 render 函数中 return 的内容,代码如下:

genNode(ast.codegenNode, context);

这里传入的参数ast.codegenNode是根节点的codegenNode属性,在genNode函数中会从根节点开始递归遍历整颗 AST 抽象语法树,为每个节点生成自己的createElementVNode函数,执行createElementVNode函数会生成这些节点的虚拟 DOM。

我们先来看看传入的第一个参数ast.codegenNode,也就是根节点的codegenNode属性。如下图:

从上图中可以看到静态节点 h1 标签已经变成了一个名为_hoisted_1的变量,而使用了msg变量的动态节点依然还是 p 标签。

我们再来看看执行这个genNode函数之前 render 函数字符串是什么样的,如下图:

从上图中可以看到此时的 render 函数字符串还没生成 return 中的内容。

执行genNode函数后,来看看此时的 render 函数字符串是什么样的,如下图:

从上图中可以看到,在生成的 render 函数中 h1 标签静态节点已经变成了_hoisted_1变量,_hoisted_1变量中存的是静态节点 h1 的虚拟 DOM,所以每次页面更新重新执行 render 函数时就不会每次都去生成一遍静态节点 h1 的虚拟 DOM。

总结

整个静态提升的流程图如下:

整个流程主要分为两个阶段:

  • transform阶段中:

  • 将 h1 静态节点找出来,将静态节点的codegenNode属性 push 到根节点的hoists属性数组中。

  • 将 h1 静态节点的codegenNode属性替换为一个简单表达式节点,表达式为:_hoisted_1

  • generate阶段中:

  • 在 render 函数外面生成一个名为_hoisted_1的全局变量,这个变量中存的是 h1 标签的虚拟 DOM。

  • 在 render 函数内直接使用_hoisted_1变量就可以表示这个 h1 标签。

  • 欢迎长按图片加 ssh 为好友,我会第一时间和你分享前端行业趋势,学习途径等等。2024 陪你一起度过!

关注公众号,发送消息:

指南,获取高级前端、算法学习路线,是我自己一路走来的实践。

简历,获取大厂简历编写指南,是我看了上百份简历后总结的心血。

面经,获取大厂面试题,集结社区优质面经,助你攀登高峰

因为微信公众号修改规则,如果不标星或点在看,你可能会收不到我公众号文章的推送,请大家将本公众号星标,看完文章后记得点下赞或者在看,谢谢各位!

参考资料

[1]

https://mp.weixin.qq.com/s/jNj0JZMOFs2NXTNgnyhEfg: _https://mp.weixin.qq.com/s/jNj0JZMOFs2NXTNgnyhEfg_

[2]

https://template-explorer.vuejs.org/: _https://template-explorer.vuejs.org/_

[3]

https://template-explorer.vuejs.org/: _https://template-explorer.vuejs.org/_

[4]

https://juejin.cn/pin/7362119848660336703: _https://juejin.cn/pin/7362119848660336703_