• 终于搞懂了!原来 Vue 3 的 generate 是这样生成 render 函数的


    前言

    在之前的 面试官:来说说vue3是怎么处理内置的v-for、v-model等指令? 文章中讲了transform阶段处理完v-for、v-model等指令后,会生成一棵javascript AST抽象语法树。这篇文章我们来接着讲generate阶段是如何根据这棵javascript AST抽象语法树生成render函数字符串的,本文中使用的vue版本为3.4.19

    看个demo

    还是一样的套路,我们通过debug一个demo来搞清楚render函数字符串是如何生成的。demo代码如下:

    <template>
      <p>{{ msg }}p>
    template>
    
    <script setup lang="ts">
    import { ref } from "vue";
    
    const msg = ref("hello world");
    script>
    

    上面这个demo很简单,使用p标签渲染一个msg响应式变量,变量的值为"hello world"。我们在浏览器中来看看这个demo生成的render函数是什么样的,代码如下:

    import { toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from "/node_modules/.vite/deps/vue.js?v=23bfe016";
    function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
      return _openBlock(), _createElementBlock(
        "p",
        null,
        _toDisplayString($setup.msg),
        1
        /* TEXT */
      );
    }
    

    上面的render函数中使用了两个函数:openBlockcreateElementBlock。在之前的 vue3早已具备抛弃虚拟DOM的能力了文章中我们已经讲过了这两个函数:

    • openBlock的作用为初始化一个全局变量currentBlock数组,用于收集dom树中的所有动态节点。

    • createElementBlock的作用为生成根节点p标签的虚拟DOM,然后将收集到的动态节点数组currentBlock塞到根节点p标签的dynamicChildren属性上。

    render函数的生成其实很简单,经过transform阶段处理后会生成一棵javascript AST抽象语法树,这棵树的结构和要生成的render函数结构是一模一样的。所以在generate函数中只需要递归遍历这棵树,进行字符串拼接就可以生成render函数啦!

    关注公众号:【前端欧阳】,解锁我更多vue原理文章。

    加我微信heavenyjj0012回复「666」,免费领取欧阳研究vue源码过程中收集的源码资料,欧阳写文章有时也会参考这些资料。同时让你的朋友圈多一位对vue有深入理解的人。

    generate函数

    首先给generate函数打个断点,generate函数在node_modules/@vue/compiler-core/dist/compiler-core.cjs.js文件中。

    然后启动一个debug终端,在终端中执行yarn dev(这里是以vite举例)。在浏览器中访问  http://localhost:5173/ ,此时断点就会走到generate函数中了。在我们这个场景中简化后的generate函数是下面这样的:

    function generate(ast) {
      const context = createCodegenContext();
      const { push, indent, deindent } = context;
    
      const preambleContext = context;
      genModulePreamble(ast, preambleContext);
    
      const functionName = `render`;
      const args = ["_ctx", "_cache"];
      args.push("$props", "$setup", "$data", "$options");
      const signature = args.join(", ");
      push(`function ${functionName}(${signature}) {`);
    
      indent();
      push(`return `);
      genNode(ast.codegenNode, context);
    
      deindent();
      push(`}`);
      return {
        ast,
        code: context.code,
      };
    }
    

    generate中主要分为四部分:

    • 生成context上下文对象。

    • 执行genModulePreamble函数生成:import { xxx } from "vue";

    • 生成render函数中的函数名称和参数,也就是function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {

    • 生成render函数中return的内容

    context上下文对象

    context上下文对象是执行createCodegenContext函数生成的,将断点走进createCodegenContext函数。简化后的代码如下:

    function createCodegenContext() {
      const context = {
        code: ``,
        indentLevel: 0,
        helper(key) {
          return `_${helperNameMap[key]}`;
        },
        push(code) {
          context.code += code;
        },
        indent() {
          newline(++context.indentLevel);
        },
        deindent(withoutNewLine = false) {
          if (withoutNewLine) {
            --context.indentLevel;
          } else {
            newline(--context.indentLevel);
          }
        },
        newline() {
          newline(context.indentLevel);
        },
      };
    
      function newline(n) {
        context.push("\n" + `  `.repeat(n));
      }
    
      return context;
    }
    

    为了代码具有较强的可读性,我们一般都会使用换行和锁进。context上下文中的这些属性和方法作用就是为了生成具有较强可读性的render函数。

    • code属性:当前生成的render函数字符串。

    • indentLevel属性:当前的锁进级别,每个级别对应两个空格的锁进。

    • helper方法:返回render函数中使用到的vue包中export导出的函数名称,比如返回openBlockcreateElementBlock等函数

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

    • indent方法:插入换行符,并且增加一个锁进。

    • deindent方法:减少一个锁进,或者插入一个换行符并且减少一个锁进。

    • newline方法:插入换行符。

    生成import {xxx} from "vue"

    我们接着来看generate函数中的第二部分,生成import {xxx} from "vue"。将断点走进genModulePreamble函数,在我们这个场景中简化后的genModulePreamble函数代码如下:

    function genModulePreamble(ast, context) {
      const { push, newline, runtimeModuleName } = context;
      if (ast.helpers.size) {
        const helpers = Array.from(ast.helpers);
        push(
          `import { ${helpers
            .map((s) => `${helperNameMap[s]} as _${helperNameMap[s]}`)
            .join(", ")} } from ${JSON.stringify(runtimeModuleName)}
    `,
          -1 /* End */
        );
      }
      genHoists(ast.hoists, context);
      newline();
      push(`export `);
    }
    

    其中的ast.helpers是在transform阶段收集的需要从vue中import导入的函数,无需将vue中所有的函数都import导入。在debug终端看看helpers数组中的值如下图:
    helpers

    从上图中可以看到需要从vue中import导入toDisplayStringopenBlockcreateElementBlock这三个函数。

    在执行push方法之前我们先来看看此时的render函数字符串是什么样的,如下图:
    before-import

    从上图中可以看到此时生成的render函数字符串还是一个空字符串,执行完push方法后,我们来看看此时的render函数字符串是什么样的,如下图:
    after-import

    从上图中可以看到此时的render函数中已经有了import {xxx} from "vue"了。

    这里执行的genHoists函数就是前面 搞懂 Vue 3 编译优化:静态提升的秘密文章中讲过的静态提升的入口。

    生成render函数中的函数名称和参数

    执行完genModulePreamble函数后,已经生成了一条import {xxx} from "vue"了。我们接着来看generate函数中render函数的函数名称和参数是如何生成的,代码如下:

    const functionName = `render`;
    const args = ["_ctx", "_cache"];
    args.push("$props", "$setup", "$data", "$options");
    const signature = args.join(", ");
    push(`function ${functionName}(${signature}) {`);
    

    上面的代码很简单,都是执行push方法向render函数中添加code字符串,其中args数组就是render函数中的参数。我们在来看看执行完上面这块代码后的render函数字符串是什么样的,如下图:
    before-genNode

    从上图中可以看到此时已经生成了render函数中的函数名称和参数了。

    生成render函数中return的内容

    接着来看generate函数中最后一块代码,如下:

    indent();
    push(`return `);
    genNode(ast.codegenNode, context);
    

    首先调用indent方法插入一个换行符并且增加一个锁进,然后执行push方法添加一个return字符串。

    接着以根节点的codegenNode属性为参数执行genNode函数生成return中的内容,在我们这个场景中genNode函数简化后的代码如下:

    function genNode(node, context) {
      switch (node.type) {
        case NodeTypes.SIMPLE_EXPRESSION:
          genExpression(node, context)
          break
        case NodeTypes.INTERPOLATION:
          genInterpolation(node, context);
          break;
        case NodeTypes.VNODE_CALL:
          genVNodeCall(node, context);
          break;
      }
    }
    

    这里涉及到SIMPLE_EXPRESSIONINTERPOLATIONVNODE_CALL三种AST抽象语法树node节点类型:

    • INTERPOLATION:表示当前节点是双大括号节点,我们这个demo中就是:{{msg}}这个文本节点。

    • SIMPLE_EXPRESSION:表示当前节点是简单表达式节点,在我们这个demo中就是双大括号节点{{msg}}中的更里层节点msg

    • VNODE_CALL:表示当前节点是虚拟节点,比如我们这里第一次调用genNode函数传入的ast.codegenNode(根节点的codegenNode属性)就是虚拟节点。

    genVNodeCall函数

    由于当前节点是虚拟节点,第一次进入genNode函数时会执行genVNodeCall函数。在我们这个场景中简化后的genVNodeCall函数代码如下:

    const OPEN_BLOCK = Symbol(`openBlock`);
    const CREATE_ELEMENT_BLOCK = Symbol(`createElementBlock`);
    
    function genVNodeCall(node, context) {
      const { push, helper } = context;
      const { tag, props, children, patchFlag, dynamicProps, isBlock } = node;
      if (isBlock) {
        push(`(${helper(OPEN_BLOCK)}(${``}), `);
      }
      const callHelper = CREATE_ELEMENT_BLOCK;
      push(helper(callHelper) + `(`, -2 /* None */, node);
    
      genNodeList(
        // 将参数中的undefined转换成null
        genNullableArgs([tag, props, children, patchFlag, dynamicProps]),
        context
      );
    
      push(`)`);
      if (isBlock) {
        push(`)`);
      }
    }
    

    首先判断当前节点是不是block节点,由于此时的node为根节点,所以isBlock为true。将断点走进helper方法,我们来看看helper(OPEN_BLOCK)返回值是什么。helper方法的代码如下:

    const helperNameMap = {
      [OPEN_BLOCK]: `openBlock`,
      [CREATE_ELEMENT_BLOCK]: `createElementBlock`,
      [TO_DISPLAY_STRING]: `toDisplayString`,
      // ...省略
    };
    
    helper(key) {
      return `_${helperNameMap[key]}`;
    }
    

    helper方法中的代码很简单,这里的helper(OPEN_BLOCK)返回的就是_openBlock

    将断点走到第一个push方法,代码如下:

    push(`(${helper(OPEN_BLOCK)}(${``}), `);
    

    执行完这个push方法后在debug终端看看此时的render函数字符串是什么样的,如下图:
    after-block

    从上图中可以看到,此时render函数中增加了一个_openBlock函数的调用。

    将断点走到第二个push方法,代码如下:

    const callHelper = CREATE_ELEMENT_BLOCK;
    push(helper(callHelper) + `(`, -2 /* None */, node);
    

    同理helper(callHelper)方法返回的是_createElementBlock,执行完这个push方法后在debug终端看看此时的render函数字符串是什么样的,如下图:
    after-createElementBlock

    从上图中可以看到,此时render函数中增加了一个_createElementBlock函数的调用。

    继续将断点走到genNodeList部分,代码如下:

    genNodeList(
      genNullableArgs([tag, props, children, patchFlag, dynamicProps]),
      context
    );
    

    其中的genNullableArgs函数功能很简单,将参数中的undefined转换成null。比如此时的props就是undefined,经过genNullableArgs函数处理后传给genNodeList函数的props就是null

    genNodeList函数

    继续将断点走进genNodeList函数,在我们这个场景中简化后的代码如下:

    function genNodeList(nodes, context, multilines = false, comma = true) {
      const { push } = context;
      for (let i = 0; i < nodes.length; i++) {
        const node = nodes[i];
        if (shared.isString(node)) {
          push(node);
        } else {
          genNode(node, context);
        }
        if (i < nodes.length - 1) {
          comma && push(", ");
        }
      }
    }
    

    我们先来看看此时的nodes参数,如下图:
    nodes

    这里的nodes就是调用genNodeList函数时传的数组:[tag, props, children, patchFlag, dynamicProps],只是将数组中的undefined转换成了null

    • nodes数组中的第一项为字符串p,表示当前节点是p标签。

    • 由于当前p标签没有props,所以第二项为null的字符串。

    • 第三项为p标签子节点:{{msg}}

    • 第四项也是一个字符串,标记当前节点是否是动态节点。

    在讲genNodeList函数之前,我们先来看一下如何使用h函数生成一个

    {{ msg }}

    标签的虚拟DOM节点。根据vue官网的介绍,h函数定义如下:

    // 完整参数签名
    function h(
      type: string | Component,
      props?: object | null,
      children?: Children | Slot | Slots
    ): VNode
    

    h函数接收的第一个参数是标签名称或者一个组件,第二个参数是props对象或者null,第三个参数是子节点。

    所以我们要使用h函数生成demo中的p标签虚拟DOM节点代码如下:

    h("p", null, msg)
    

    h函数生成虚拟DOM实际就是调用的createBaseVNode函数,而我们这里的createElementBlock函数生成虚拟DOM也是调用的createBaseVNode函数。两者的区别是createElementBlock函数多接收一些参数,比如patchFlagdynamicProps

    现在我想你应该已经反应过来了,为什么调用genNodeList函数时传入的第一个参数nodes为:[tag, props, children, patchFlag, dynamicProps]。这个数组的顺序就是调用createElementBlock函数时传入的参数顺序。

    所以在genNodeList中会遍历nodes数组生成调用createElementBlock函数需要传入的参数。

    先来看第一个参数tag,这里tag的值为字符串"p"。所以在for循环中会执行push(node),生成调用createElementBlock函数的第一个参数"p"。在debug终端看看此时的render函数,如下图:
    arg1

    从上图中可以看到createElementBlock函数的第一个参数"p"

    接着来看nodes数组中的第二个参数:props,由于p标签中没有props属性。所以第二个参数props的值为字符串"null",在for循环中同样会执行push(node),生成调用createElementBlock函数的第二个参数"null"。在debug终端看看此时的render函数,如下图:
    arg2

    从上图中可以看到createElementBlock函数的第二个参数null

    接着来看nodes数组中的第三个参数:children,由于children是一个对象,所以以当前children节点作为参数执行genNode函数。

    这个genNode函数前面已经执行过一次了,当时是以根节点的codegenNode属性作为参数执行的。回顾一下genNode函数的代码,如下:

    function genNode(node, context) {
      switch (node.type) {
        case NodeTypes.SIMPLE_EXPRESSION:
          genExpression(node, context)
          break
        case NodeTypes.INTERPOLATION:
          genInterpolation(node, context);
          break;
        case NodeTypes.VNODE_CALL:
          genVNodeCall(node, context);
          break;
      }
    }
    

    前面我们讲过了NodeTypes.INTERPOLATION类型表示当前节点是双大括号节点,而我们这次执行genNode函数传入的p标签children,刚好就是{{msg}}双大括号节点。所以代码会走到genInterpolation函数中。

    genInterpolation函数

    将断点走进genInterpolation函数中,genInterpolation代码如下:

    function genInterpolation(node, context) {
      const { push, helper } = context;
      push(`${helper(TO_DISPLAY_STRING)}(`);
      genNode(node.content, context);
      push(`)`);
    }
    

    首先会执行push方法向render函数中插入一个_toDisplayString函数调用,在debug终端看看执行完这个push方法后的render函数,如下图:
    toDisplayString

    从上图中可以看到此时createElementBlock函数的第三个参数只生成了一半,调用_toDisplayString函数传入的参数还没生成。

    接着会以node.content作为参数执行genNode(node.content, context);生成_toDisplayString函数的参数,此时代码又走回了genNode函数。

    将断点再次走进genNode函数,看看此时的node是什么样的,如下图:
    simple-expression

    从上图中可以看到此时的node节点是一个简单表达式节点,表达式为:$setup.msg。所以代码会走进genExpression函数。

    genExpression函数

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

    function genExpression(node, context) {
      const { content, isStatic } = node;
      context.push(
        isStatic ? JSON.stringify(content) : content,
        -3 /* Unknown */,
        node
      );
    }
    

    由于当前的msg变量是一个ref响应式变量,所以isStaticfalse。所以会执行push方法,将$setup.msg插入到render函数中。

    执行完push方法后,在debug终端看看此时的render函数字符串是什么样的,如下图:
    after-expression

    从上图中可以看到此时的render函数基本已经生成了,剩下的就是调用push方法生成各个函数的右括号")"和右花括号"}"。将断点逐层走出,直到generate函数中。代码如下:

    function generate(ast) {
      // ...省略
      genNode(ast.codegenNode, context);
    
      deindent();
      push(`}`);
      return {
        ast,
        code: context.code,
      };
    }
    

    执行完最后一个 push方法后,在debug终端看看此时的render函数字符串是什么样的,如下图:
    render

    从上图中可以看到此时的render函数终于生成啦!

    总结

    这是我画的我们这个场景中generate生成render函数的流程图:

    full-progress

    • 执行genModulePreamble函数生成:import { xxx } from "vue";

    • 简单字符串拼接生成render函数中的函数名称和参数,也就是function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {

    • 以根节点的codegenNode属性为参数调用genNode函数生成render函数中return的内容。

      • 此时传入的是虚拟节点,执行genVNodeCall函数生成return _openBlock(), _createElementBlock(和调用genNodeList函数,生成createElementBlock函数的参数。

      • 处理p标签的tag标签名和props,生成createElementBlock函数的第一个和第二个参数。此时render函数return的内容为:return _openBlock(), _createElementBlock("p", null

      • 处理p标签的children也就是{{msg}}节点,再次调用genNode函数。此时node节点类型为双大括号节点,调用genInterpolation函数。

      • genInterpolation函数中会先调用push方法,此时的render函数return的内容为:return _openBlock(), _createElementBlock("p", null, _toDisplayString(。然后以node.content为参数再次调用genNode函数。

      • node.content$setup.msg,是一个简单表达式节点,所以在genNode函数中会调用genExpression函数。执行完genExpression函数后,此时的render函数return的内容为:return _openBlock(), _createElementBlock("p", null, _toDisplayString($setup.msg

      • 调用push方法生成各个函数的右括号")"和右花括号"}",生成最终的render函数

    关注(图1)公众号:【前端欧阳】,解锁我更多vue原理文章。
    加我(图2)微信回复「666」,免费领取欧阳研究vue源码过程中收集的源码资料,欧阳写文章有时也会参考这些资料。同时让你的朋友圈多一位对vue有深入理解的人。
    公众号微信

  • 相关阅读:
    基于小程序制作一个猜拳小游戏
    Jmeter 多实例压测
    C# pythonnet(1)_传感器数据清洗算法
    Kubernetes(k8s)CNI(Calico)网络模型原理
    小程序如何关联公众号来发送模板消息
    dubbo 知识总结 dubbo配置参考
    python学习之基本语法---语法学习前的思想认识---编程思想和代码规范(一)day5
    java list集合运算
    如何去开发一个springboot starter
    汽车摄像头匿名化处理解决方案,保护信息的安全性和隐私性
  • 原文地址:https://www.cnblogs.com/heavenYJJ/p/18200376