查看原文
其他

vue3 早已具备抛弃虚拟 DOM 的能力了

前端大全
2024-08-23

The following article is from 前端欧阳 Author 前端欧阳


前言

jquery时代更新视图是直接对DOM进行操作,缺点是频繁操作真实 DOM,性能差。react和vue时代引入了虚拟DOM,更新视图是对新旧虚拟DOM树进行一层层的遍历比较,然后找出需要更新的DOM节点进行更新。这样做的缺点就是如果DOM树很复杂,在进行新旧DOM树比较的时候性能就比较差了。那么有没有一种方法是不需要去遍历新旧DOM树就可以知道哪些DOM需要更新呢?

答案是:在编译时我们就能够知道哪些节点是静态的,哪些是动态的。在更新视图时只需要对这些动态的节点进行靶向更新,就可以省去对比新旧虚拟DOM带来的开销。vue3也是这样做的,甚至都可以抛弃虚拟DOM。但是考虑到渲染函数的灵活性和需要兼容vue2,vue3最终还是保留了虚拟DOM。 这篇文章我们来讲讲vue3是如何找出动态节点,以及响应式变量修改后如何靶向更新。 注:本文使用的vue版本为3.4.19

靶向更新的流程

先来看看我画的整个靶向更新的流程,如下图:

整个流程主要分为两个大阶段:编译时和运行时。

  • 编译时阶段找出动态节点,使用patchFlag属性将其标记为动态节点。

  • 运行时阶段分为两块:执行render函数阶段和更新视图阶段。

    • 执行render函数阶段会找出所有被标记的动态节点,将其塞到block节点的dynamicChildren属性数组中。

    • 更新视图阶段会从block节点的dynamicChildren属性数组中拿到所有的动态节点,然后遍历这个数组将里面的动态节点进行靶向更新。

一个简单的demo

还是同样的套路,我们通过debug一个demo,来搞清楚vue3是如何找出动态节点以及响应式变量修改后如何靶向更新的,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>

p标签绑定了响应式变量msg,点击button按钮时会将msg变量的值从hello更新为world。

在之前的文章中我们知道了vue分为编译时和运行时,由于p标签使用了msg响应式变量,所以在编译时就会找出p标签。并且将其标记为动态节点,而这里的h1标签由于没有使用响应式变量,所以不会被标记为动态节点。

在运行时阶段点击button按钮修改msg变量的值,由于我们在编译阶段已经将p标签标记为了动态节点,所以此时只需要将标记的p标签动态节点中的文本更新为最新的值即可,省去了传统patch函数中的比较新旧虚拟DOM的步骤。

编译阶段

在编译阶段对vue内置的指令、模版语法是在transform函数中处理的。在transform函数中实际干活的是一堆转换函数,每种转换函数都有不同的作用。比如v-for标签就是由transformFor转换函数处理的,而将节点标记为动态节点就是在transformElement转换函数中处理的。

首先我们需要启动一个debug终端,才可以在node端打断点。这里以vscode举例,首先我们需要打开终端,然后点击终端中的+号旁边的下拉箭头,在下拉中点击Javascript Debug Terminal就可以启动一个debug终端。然后给transformElement函数打个断点,transformElement函数在node_modules/@vue/compiler-core/dist/compiler-core.cjs.js文件中。

transformElement转换函数

接着在debug终端中执行yarn dev(这里是以vite举例)。在浏览器中访问 http://localhost:5173/,此时断点就会走到transformElement函数中了。我们看到transformElement函数中的代码是下面这样的:

const transformElement = (node, context) => {
  return function postTransformElement() {
    // ...
  }
}

从上面可以看到transformElement函数中没有做任何事情,直接返回了一个名为postTransformElement的回调函数,我们接着给这个回调函数打上断点,将transformElement函数的断点给移除了。

每处理一个node节点都会走进一次postTransformElement函数这个断点,将断点放了,直到断点走进处理到使用响应式变量的p标签node节点时。在我们这个场景中简化后的postTransformElement函数代码如下:

const transformElement = (node, context) => {
  return function postTransformElement() {
    // 第一部分
    let vnodePatchFlag;
    let patchFlag = 0;
    const child = node.children[0];
    const type = child.type;

    // 第二部分
    const hasDynamicTextChild =
      type === NodeTypes.INTERPOLATION ||
      type === NodeTypes.COMPOUND_EXPRESSION;
    if (
      hasDynamicTextChild &&
      getConstantType(child, context) === ConstantTypes.NOT_CONSTANT
    ) {
      patchFlag |= PatchFlags.TEXT;
    }
    if (patchFlag !== 0) {
      vnodePatchFlag = String(patchFlag)
    }

    // 第三部分
    node.codegenNode = createVNodeCall(
      vnodePatchFlag
      // ...省略
    );
  };
};

从上面可以看到简化后的postTransformElement函数主要分为三部分,其实很简单。

第一部分

第一部分很简单定义了vnodePatchFlagpatchFlag这两个变量,patchFlag变量的作用是标记节点是否为动态节点,vnodePatchFlag变量除了标记节点为动态节点之外还保存了一些额外的动态节点信息。child变量中存的是当前节点的子节点,type变量中存的是当前子节点的节点类型。

第二部分

const hasDynamicTextChild =
  type === NodeTypes.INTERPOLATION ||
  type === NodeTypes.COMPOUND_EXPRESSION;

我们接着来看第二部分,其中的hasDynamicTextChild变量表示当前子节点是否为动态文本子节点,很明显我们这里的p标签使用了响应式变量msg,其文本子节点当然是动态的,所以hasDynamicTextChild变量的值为true。

接着我们来看第二部分的这段if语句:

if (
  hasDynamicTextChild &&
  getConstantType(child, context) === ConstantTypes.NOT_CONSTANT
) {
  patchFlag |= PatchFlags.TEXT;
}

我们先来看这段if语句的条件,如果hasDynamicTextChild为true表示当前子节点是动态文本子节点。getConstantType函数是判断动态文本节点涉及到的变量是不是不会改变的常量,为什么判断了hasDynamicTextChild还要判断getConstantType呢?

答案是如果我们给p标签绑定一个不会改变的常量,因为确实绑定了变量,hasDynamicTextChild的值还是为true。但是由于我们绑定的是不会改变的常量,所以p标签中的文本节点永远都不会改变。比如下面这个demo:

<template>
  <div>
    <p>{{ count }}</p>
  </div>
</template>

<script setup lang="ts">
const count = 10;
</script>

我们接着来看if语句里面的内容patchFlag |= PatchFlags.TEXT,如果if的判断结果为true,那么就使用“按位或”的运算符。由于此时的patchFlag变量的值为0,所以经过“按位或”的运算符计算下来patchFlag变量的值变成了PatchFlags.TEXT变量的值。我们先来看看PatchFlags中有哪些值:

enum PatchFlags {
  TEXT = 1,         // 二进制值为 1
  CLASS = 1 << 1,   // 二进制值为 10
  STYLE = 1 << 2,   // 二进制值为 100
  // ...等等等
}

这里涉及到了位运算 <<,他的意思是向左移多少位。比如TEXT表示向左移0位,二进制表示为1。CLASS表示为左移一位,二进制表示为10。STYLE表示为左移两位,二进制表示为100。

现在你明白了为什么给patchFlag赋值要使用“按位或”的运算符了吧,假如当前p标签除了有动态的文本节点,还有动态的class。那么patchFlag就会进行两次赋值,分别是:patchFlag |= PatchFlags.TEXTpatchFlag |= PatchFlags.CLASS。经过两次“按位或”的运算符进行计算后,patchFlag的二进制值就是11,二进制值信息中包含动态文本节点和动态class,从右边数的第一位1表示动态文本节点,从右边数的第二位1表示动态class。如下图:

这样设计其实很精妙,后面拿到动态节点进行更新时,只需要将动态节点的patchFlagPatchFlags中的枚举进行&"按位与"运算就可以知道当前节点是否是动态文本节点、动态class的节点。上面之所以没有涉及到PatchFlags.CLASS相关的代码,是因为当前例子中不存在动态class,所以我省略了。

我们接着来看第二部分的第二个if语句,如下:

if (patchFlag !== 0) {
  vnodePatchFlag = String(patchFlag)
}

这段代码很简单,如果patchFlag !== 0表示当前节点是动态节点。然后将patchFlag转换为字符串赋值给vnodePatchFlag变量,在dev环境中vnodePatchFlag字符串中还包含节点是哪种动态类型的信息。如下图:

第三部分

我们接着将断点走到第三部分,这一块也很简单。将createVNodeCall方法的返回值赋值给codegenNode属性,codegenNode属性中存的就是节点经过transform转换函数处理后的信息。

node.codegenNode = createVNodeCall(
  vnodePatchFlag
  // ...省略
);

我们将断点走到执行完createVNodeCall函数后,看看当前的p标签节点是什么样的。如下图:

从上图中可以看到此时的p标签的node节点中有了一个patchFlag属性,经过编译处理后p标签已经被标记成了动态节点。

执行render函数阶段

经过编译阶段的处理p标签已经被标记成了动态节点,并且生成了render函数。此时编译阶段的任务已经完了,该到浏览器中执行的运行时阶段了。首先我们要在浏览器中找到编译后的js文件。

其实很简单直接在network上面找到你的那个vue文件就行了,比如我这里的文件是index.vue,那我只需要在network上面找叫index.vue的文件就行了。但是需要注意一下network上面有两个index.vue的js请求,分别是template模块+script模块编译后的js文件,和style模块编译后的js文件。

那怎么区分这两个index.vue文件呢?很简单,通过query就可以区分。由style模块编译后的js文件的URL中有type=style的query,如下图所示:

接下来我们来看看编译后的index.vue,简化的代码如下:

import {
  createElementBlock as _createElementBlock,
  createElementVNode as _createElementVNode,
  defineComponent as _defineComponent,
  openBlock as _openBlock,
  toDisplayString as _toDisplayString,
} from "/node_modules/.vite/deps/vue.js?v=23bfe016";

const _sfc_main = _defineComponent({
  __name: "index",
  setup(__props, { expose: __expose }) {
    // ...省略
  },
});

function _sfc_render(_ctx, _cache, $props$setup$data$options) {
  return (
    _openBlock(),
    _createElementBlock("div", null, [
      _createElementVNode("h1", null, "title", -1),
      _createElementVNode(
        "p",
        null,
        _toDisplayString($setup.msg),
        1
        /* TEXT */
      ),
      _createElementVNode(
        "button",
        { onClick: $setup.handleChange },
        "change msg"
      ),
    ])
  );
}
_sfc_main.render = _sfc_render;
export default _sfc_main;

从上面的代码可以看到经过编译后生成了一个render函数,执行这个render函数就会生成虚拟DOM。仔细来看这个render函数的返回值结构,这里使用return返回了一个括号。在括号中有两项,分别是openBlock函数的返回值和createElementBlock函数的返回值。那么这里的return返回的到底是什么呢?

答案是会先执行openBlock函数,然后将createElementBlock函数执行后的值返回。

现在我们思考一个问题,在编译阶段我们只是将p标签标记成了动态节点,如果还有其他标签也是动态节点那么也会将其标记成动态节点。这些动态节点的标记还是在DOM树中的每个标签中,如果响应式变量的值改变,那么岂不还是需要去遍历DOM树?

答案是在执行render函数生成虚拟DOM的时候会生成一个block节点作为根节点,并且将这些标记的动态节点收集起来塞到block根节点的dynamicChildren属性数组中。在dynamicChildren属性数组中存的是平铺的DOM树中的所有动态节点,和动态节点在DOM树中的位置无关。

那么根block节点又是怎么收集到所有的动态子节点的呢?

我们先来搞清楚render函数中的那一堆嵌套函数的执行顺序,我们前面已经讲过了首先会执行返回的括号中的第一项openBlock函数,然后再执行括号中的第二项createElementBlock函数。createElementBlock函数是一个层层嵌套的结构,执行顺序是内层先执行,外层再执行。所以接下来会先执行里层createElementVNode生成h1标签的虚拟DOM,然后执行createElementVNode生成p标签的虚拟DOM,最后执行createElementVNode生成button标签的虚拟DOM。内层的函数执行完了后再去执行外层的createElementBlock生成div标签的虚拟DOM。如下图:

从上图中可以看到render函数中主要就执行了这三个函数:

  • openBlock函数

  • createElementVNode函数

  • createElementBlock函数

openBlock函数

我们先来看最先执行的openBlock函数,在我们这个场景中简化后的代码如下:

let currentBlock;

function openBlock() {
  currentBlock = [];
}

首先会定义一个全局变量currentBlock,里面会存DOM树中的所有的动态节点。在openBlock函数中会将其初始化为一个空数组,所以openBlock函数需要第一个执行。

createElementVNode函数

我们接着来看createElementVNode函数,在我们这个场景中简化后的代码如下:

export { createBaseVNode as createElementVNode };

function createBaseVNode() {
  const vnode = {
    // ...省略
  };
  if (vnode.patchFlag > 0) {
    currentBlock.push(vnode);
  }
  return vnode;
}

createElementVNode函数在内部其实叫createBaseVNode函数,从上面的代码中可以看到他除了会生成虚拟DOM之外,还会去判断当前节点是否为动态节点。如果是动态节点,那么就将其push到全局的currentBlock数组中。比如我们这里的p标签绑定了msg变量,当执行createElementVNode函数生成p标签的虚拟DOM时就会将p标签的node节点收集起来push到currentBlock数组中。

createElementBlock函数

我们来看最后执行的createElementBlock函数,在我们这个场景中简化后的代码如下:

function createElementBlock() {
  return setupBlock(
    createBaseVNode()
    // ...省略
  );
}

createElementBlock函数会先执行createBaseVNode也就是上一步说的createElementVNode函数生成最外层div标签对应的虚拟DOM。由于外层div标签没有被标记为动态节点,所以执行createElementVNode函数也就只生成div标签的虚拟DOM。

然后将div标签的虚拟DOM作为参数去执行setupBlock函数,setupBlock函数的代码如下:

function setupBlock(vnode) {
  vnode.dynamicChildren = currentBlock;
  return vnode;
}

此时子节点生成虚拟DOM的createElementVNode函数全部都已经执行完了,这个div标签也就是我们的根节点,

我们前面讲过了执行顺序是内层先执行,外层再执行,所以执行到最外层的div标签时,子节点已经全部都执行完成了。此时currentBlock数组中已经存了所有的动态子节点,将currentBlock数组赋值给根block节点(这里是div节点)的dynamicChildren属性。

现在你知道我们前面提的那个问题,根block节点是怎么收集到所有的动态子节点的呢?

后续更新视图执行patch函数时只需要拿到根节点的dynamicChildren属性,就可以拿到DOM树中的所有动态子节点。

更新视图阶段

当响应式变量改变后,对应的视图就需要更新。对应我们这个场景中就是,点击button按钮后,p标签中的内容从原来的hello,更新为world。

按照传统的patch函数此时需要去遍历比较老的虚拟DOM和新的虚拟DOM,然后找出来p标签是需要修改的node节点,然后将其文本节点更新为最新值"world"。

但是我们在上一步生成虚拟DOM阶段已经将DOM树中所有的动态节点收集起来,存在了根block节点的dynamicChildren属性中。我们接着来看在新的patch函数中是如何读取dynamicChildren属性,以及如何将p标签的文本节点更新为最新值"world"。

处理div根节点

在source面板中找到vue源码中的patch函数,给patch函数打上断点。然后点击button按钮修改msg变量的值,导致render函数重新执行,接着会走进了patch函数进行视图更新。此时代码已经走到了patch函数的断点,在我们这个场景中简化后的patch函数代码如下:

const patch = (n1, n2) => {
  processElement(n1, n2);
};

从上面可以看到简化后的patch函数中实际是调用了processElement函数,接着将断点走进processElement函数,在我们这个场景中简化后的processElement函数代码如下:

const processElement = (n1, n2) => {
  patchElement(n1, n2);
};

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

const patchElement = (n1, n2) => {
  const el = (n2.el = n1.el);
  let { patchFlag, dynamicChildren } = n2;
  patchFlag = n1.patchFlag;

  if (dynamicChildren) {
    patchBlockChildren(n1.dynamicChildren, dynamicChildren);
  }

  if (patchFlag > 0) {
    if (patchFlag & PatchFlags.CLASS) {
      // 处理动态class
    }
    if (patchFlag & PatchFlags.STYLE) {
      // 处理动态style
    }
    if (patchFlag & PatchFlags.TEXT) {
      if (n1.children !== n2.children) {
        hostSetElementText(el, n2.children);
      }
    }
  }
};

从上面可以看到patchElement函数是实际干活的地方了,我们在控制台中来看看接收n1、n2这两个参数是什么样的。

先来看看n1旧虚拟DOM ,如下图:

从上面可以看到此时的n1为根block节点,此时p标签中的文本还是更新前的文本"hello",dynamicChildren属性为收集到的所有动态子节点。

接着来看n2新虚拟DOM,如下图:

从上面可以看到新虚拟DOM中p标签中的文本节点已经是更新后的文本"world"了。

我们接着来看patchElement函数中的代码,第一次处理div根节点时patchElement函数中只会执行部分代码。后面处理p标签时还会走进patchElement函数才会执行剩下的代码,当前执行的代码如下:

const patchElement = (n1, n2) => {
  let { patchFlag, dynamicChildren } = n2;
  if (dynamicChildren) {
    patchBlockChildren(n1.dynamicChildren, dynamicChildren);
  }
};

从根block节点(也就是n2新虚拟DOM)中拿到dynamicChildren。这个dynamicChildren数组我们前面讲过了,里面存的是DOM树中所有的动态节点。然后调用patchBlockChildren函数去处理所有的动态节点,我们将断点走进patchBlockChildren函数中,在我们这个场景中简化后的patchBlockChildren函数代码如下:

const patchBlockChildren = (oldChildren, newChildren) => {
  for (let i = 0; i < newChildren.length; i++) {
    const oldVNode = oldChildren[i];
    const newVNode = newChildren[i];
    patch(oldVNode, newVNode);
  }
};

patchBlockChildren函数中会去遍历所有的动态子节点,在我们这个场景中,oldVNode也就是旧的p标签的node节点,newVNode是新的p标签的node节点。然后再去调用patch函数将这个p标签动态节点更新为最新的文本节点。

如果按照vue2传统的patch函数的流程,应该是进行遍历旧的n1虚拟DOM和新的n2虚拟DOM。然后才能找出p标签是需要更新的节点,接着执行上面的patch(oldVNode, newVNode)将p标签更新为最新的文本节点。

而在vue3中由于我们在编译阶段就找出来p标签是动态节点,然后将其收集到根block节点的dynamicChildren属性中。在更新阶段执行patch函数时,就省去了遍历比较新旧虚拟DOM的过程,直接从dynamicChildren属性中就可以将p标签取出来将其更新为最新的文本节点。

处理p标签节点

我们接着来看此时执行patch(oldVNode, newVNode)是如何处理p标签的。前面已经讲过了patch函数进行层层调用后实际干活的是patchElement函数,将断点走进patchElement函数。再来回忆一下前面讲的patchElement函数代码:

const patchElement = (n1, n2) => {
  const el = (n2.el = n1.el);
  let { patchFlag, dynamicChildren } = n2;
  patchFlag = n1.patchFlag;

  if (dynamicChildren) {
    patchBlockChildren(n1.dynamicChildren, dynamicChildren);
  }
  if (patchFlag > 0) {
    if (patchFlag & PatchFlags.CLASS) {
      // 处理动态class
    }
    if (patchFlag & PatchFlags.STYLE) {
      // 处理动态style
    }
    if (patchFlag & PatchFlags.TEXT) {
      if (n1.children !== n2.children) {
        hostSetElementText(el, n2.children);
      }
    }
  }
};

此时的n1就是p标签旧的虚拟DOM节点,n2就是p标签新的虚拟DOM节点。我们在编译时通过给p标签添加patchFlag属性将其标记为动态节点,并没有给p标签赋值dynamicChildren属性。所以此时不会像处理block根节点一样去执行patchBlockChildren函数了,而是会走后面的逻辑。

还记得我们前面讲的是如何给p标签设置patchFlag属性吗?

定义了一个PatchFlags枚举:

enum PatchFlags {
  TEXT = 1,         // 二进制值为 1
  CLASS = 1 << 1,   // 二进制值为 10
  STYLE = 1 << 2,   // 二进制值为 100
  // ...等等等
}

由于一个节点可能同时是:动态文本节点、动态class节点、动态style节点。所以patchFlag中需要包含这些信息。

如果是动态文本节点,那就执行“按位或”运算符:patchFlag |= PatchFlags.TEXT。执行后patchFlag的二进制值为1

如果也是动态class节点,在前一步的执行结果基础上再次执行“按位或”运算符:patchFlag |= PatchFlags.CLASS。执行后patchFlag的二进制值为11

如果也是动态style节点,同样在前一步的执行结果基础上再次执行“按位或”运算符:patchFlag |= PatchFlags.STYLE。执行后patchFlag的二进制值为111

我们前面给p标签标记为动态节点时给c。在patchElement函数中使用patchFlag属性进行"按位与"运算,判断当前节点是否是动态文本节点、动态class节点、动态style节点。

patchFlag的值是1,转换为两位的二进制后是01。PatchFlags.CLASS1 << 1,转换为二进制值为10。01和10进行&(按位与)操作,计算下来的值为00。所以patchFlag & PatchFlags.CLASS转换为布尔值后为false,说明当前p标签不是动态class标签。如下图:

同理将patchFlag转换为三位的二进制后是001。PatchFlags.STYLE1 << 2,转换为二进制值为100。001和100进行&(按位与)操作,计算下来的值为000。所以patchFlag & PatchFlags.CLASS转换为布尔值后为false,说明当前p标签不是动态style标签。如下图:

同理将patchFlag转换为一位的二进制后还是1。PatchFlags.TEXT为1,转换为二进制值还是1。1和1进行&(按位与)操作,计算下来的值为1。所以patchFlag & PatchFlags.TEXT转换为布尔值后为true,说明当前p标签是动态文本标签。如下图:

判断到当前节点是动态文本节点,然后使用n1.children !== n2.children判断新旧文本是否相等。如果不相等就传入eln2.children执行hostSetElementText函数,其中的el为当前p标签,n2.children为新的文本。我们来看看hostSetElementText函数的代码,如下:

function setElementText(el, text) {
  el.textContent = text;
}

setElementText函数中的textContent属性你可能用的比较少,他的作用和innerText差不多。给textContent属性赋值就是设置元素的文字内容,在这里就是将p标签的文本设置为最新值"world"。

至此也就实现了当响应式变量msg修改后,靶向更新p标签中的节点。

总结

现在来看我们最开始讲的整个靶向更新的流程图你应该很容易理解了,如下图:

整个流程主要分为两个大阶段:编译时和运行时。

  • 编译时阶段找出动态节点,使用patchFlag属性将其标记为动态节点。

  • 运行时阶段分为两块:执行render函数阶段和更新视图阶段。

    • 执行render函数阶段会找出所有被标记的动态节点,将其塞到block节点的dynamicChildren属性数组中。

    • 更新视图阶段会从block节点的dynamicChildren属性数组中拿到所有的动态节点,然后遍历这个数组将里面的动态节点进行靶向更新。

如果使用了v-for或者v-if这种会改变html结构的指令,那么就不只有根节点是block节点了。v-forv-if的节点都会生成block节点,此时的这些block节点就组成了一颗block节点树。如果小伙伴们对使用了v-for或者v-if是如何实现靶向更新感兴趣,可以参考本文的debug方式去探索。又或者在评论区留言,我会在后面的文章中安排上。

在实验阶段的Vue Vapor中已经抛弃了虚拟DOM,根据vue团队成员三咲智子 所透露未来将使用<script vapor>的方式去区分Vapor组件和目前的组件。


推荐阅读  点击标题可跳转
1、React:我们的用法习惯可能是错误的2、基于 vue3+threejs 实现可视化大屏3、JavaScript 可视化:Promise执行彻底搞懂
继续滑动看下一个
前端大全
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存