写点什么

vue3 编译优化之“静态提升”

作者:EquatorCoco
  • 2024-05-14
    福建
  • 本文字数:7848 字

    阅读完需:约 26 分钟

前言


在上一篇 vue3早已具备抛弃虚拟DOM的能力了文章中讲了对于动态节点,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网站中看看未开启静态提升的 render 函数的样子(网站 URL 为: https://template-explorer.vuejs.org/ ),如下图将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


如何实现静态提升


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


  • 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元素节点

  • {{ msg }}对应的是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 标签。


文章转载自:前端欧阳

原文链接:https://www.cnblogs.com/heavenYJJ/p/18190230

体验地址:http://www.jnpfsoft.com/?from=infoq

用户头像

EquatorCoco

关注

还未添加个人签名 2023-06-19 加入

还未添加个人简介

评论

发布
暂无评论
vue3编译优化之“静态提升”_JavaScript_EquatorCoco_InfoQ写作社区