Vue3 的编译器原理(篇一:基本实现)

#vue

目录

总结

  • tokenize(HTML字符串)list → 模板 AST ==树形结构== →
    • → 遍历多叉树(插件化架构),主要两个时机,进入和离开,两个时机分别再 return 一个函数
      • 然后就可以生成 JS AST
        • 然后再根据 JS AST 生成 ==渲染函数==

1. 教科书式的编译模型

编译器其实只是一段程序,它用来将“一种语言 A” 翻译成 “另外一种语言 B”。

完整的编译流程如下:

|920

2. Vue.js 模板编译器

教科书式的编译模型如上,但 Vue 模板有所不同,

Vue.js 的模板JSX 都属于 领域特定语言(DSL)Vue.js 模板编译器的作用如下图

图片&文件

  • 源码是:.vue 模板
  • 目标代码:可执行的渲染函数

2.1. Vue.js 模板编译为渲染函数的完整流程

|792

其中,str 即为 .vue文件的内容

分开说,分为一下几部分:

  • 用来将模板字符串解析为模板 AST 的解析器(parser);
  • 用来将模板 AST 转换为 JavaScript AST 的转换器 (transformer);
  • 用来根据 JavaScript AST 生成渲染函数代码的生成器 (generator)

2.2. 模板 生成 模板AST

图片&文件

2.3. 模板AST 生成 JS AST 转换器

因为 Vue.js 模板编译器的最终目标是生成渲染函数,而渲染函数本质上是 JavaScript 代码,所以我们需要将模板 AST 转换成用于描述渲染函数的 JavaScript AST

所以JS AST 转换器用于描述渲染函数的 JavaScript AST

2.4. jsAST 生成最终可执行的 渲染函数

图片&文件

3. Vue3 的模板编译器实现原理

3.1. 如何用有限状态自动机构造一个词法分析器

图片&文件

按照有限状态自动机的状态迁移过程,我们可以很容易地编写对应的代码实现。

因此,有限状态自动机可以帮助我们完成对模板的 (tokenized),最终我们将得到一系列 Token 列表,具体代码如下:

const template = `<p>Vue</p>`;

// 定义状态机的状态
const State = {
  initial: 1,
  tagOpen: 2,
  tagName: 3,
  text: 4,
  tagEnd: 5,
  tagEndName: 6,
};

// 判断是否是字母
function isAlpha(char) {
  return (char >= "a" && char <= "z") || (char >= "A" && char <= "Z");
}

function tokenize(str) {
  // 初始化状态
  let currentState = State.initial;
  // 用于存放字符
  const chars = [];
  // 用于存放token
  const tokens = [];
  while (str) {
    const char = str[0];
    switch (currentState) {
      case State.initial:
        if (char === "<") {
          currentState = State.tagOpen;
          str = str.slice(1);
        } else if (isAlpha(char)) {
          currentState = State.text;
          chars.push(char);
          str = str.slice(1);
        }
        break;
      case State.tagOpen:
        if (isAlpha(char)) {
          currentState = State.tagName;
          chars.push(char);
          str = str.slice(1);
        } else if (char === "/") {
          currentState = State.tagEnd;
          str = str.slice(1);
        }
        break;
      case State.tagName:
        if (isAlpha(char)) {
          chars.push(char);
          str = str.slice(1);
        } else if (char === ">") {
          currentState = State.initial;
          tokens.push({
            type: "tag",
            name: chars.join(""),
          });
          chars.length = 0;
          str = str.slice(1);
        }
        break;
      case State.text:
        if (isAlpha(char)) {
          chars.push(char);
          str = str.slice(1);
        } else if (char === "<") {
          currentState = State.tagOpen;
          tokens.push({
            type: "text",
            content: chars.join(""),
          });
          chars.length = 0;
          str = str.slice(1);
        }
        break;
      case State.tagEnd:
        if (isAlpha(char)) {
          currentState = State.tagEndName;
          chars.push(char);
          str = str.slice(1);
        }
        break;
      case State.tagEndName:
        if (isAlpha(char)) {
          chars.push(char);
          str = str.slice(1);
        } else if (char === ">") {
          currentState = State.initial;
          tokens.push({
            type: "tagEnd",
            name: chars.join(""),
          });
          chars.length = 0;
          str = str.slice(1);
        }
        break;
    }
  }

  // 返回token
  return tokens;
}

console.log(tokenize(template));

[
    {
        "type": "tag",
        "name": "p"
    },
    {
        "type": "text",
        "content": "Vue"
    },
    {
        "type": "tagEnd",
        "name": "p"
    }
]

[!tip] 也可以使用正则表达式,因为编写正则表达式本质就是编写有限自动机;但通过这种方式来实现更能说明什么是有限状态自动机

词法分析的过程就是状态机在不同状态之间迁移的过程。在此过程中,状态机会产生一个个 Token,形成一个 Token 列表

3.1.1. 示例

tokenize(<div><p>Vue</p><p>Template</p></div>) , 可得到如下结果:

[
    {
        "type": "tag",
        "name": "div"
    },
    {
        "type": "tag",
        "name": "p"
    },
    {
        "type": "text",
        "content": "Vue"
    },
    {
        "type": "tagEnd",
        "name": "p"
    },
    {
        "type": "tag",
        "name": "p"
    },
    {
        "type": "text",
        "content": "Template"
    },
    {
        "type": "tagEnd",
        "name": "p"
    },
    {
        "type": "tagEnd",
        "name": "div"
    }
]

词法分析的过程就是,状态机在不同状态之间的迁移过程

3.2. 将上面构造出的 token 列表构造成树形模板AST

实现一个 parse 函数,即将 上面的token 列表输出为如下树结构,如下结构就是 AST 或者说是 Vnode

图片&文件

{
    "type": "Root",
    "children": [
        {
            "type": "Element",
            "tag": "div",
            "children": [
                {
                    "type": "Element",
                    "tag": "p",
                    "children": [
                        {
                            "type": "Text",
                            "content": "Vue"
                        }
                    ]
                },
                {
                    "type": "Element",
                    "tag": "p",
                    "children": [
                        {
                            "type": "Text",
                            "content": "Template"
                        }
                    ]
                }
            ]
        }
    ]
}

扫描 Token 列表并维护一个开始标签

  • 每当扫描到一个开始标签节点,就将其压入栈顶。
  • 栈顶的节点始终作为下一个扫描的节点的父节点。
  • 这样,当所有 Token 扫描完毕后,即可构建出一棵树型 AST

具体代码如下:

const template = `<div><p>Vue</p><p>Template</p></div>`;
function parse(str) {
  const tokens = tokenize(str);
  // 自定义一个根节点
  const root = {
    type: "Root",
    children: [],
  };
  // 标签栈
  const elementStack = [root];

  while (tokens.length) {
    const parent = elementStack[elementStack.length - 1];
    const t = tokens[0];
    switch (t.type) {
      case "tag":
        // 元素节点定义
        const elementNode = {
          type: "Element",
          tag: t.name,
          children: [],
        };
        parent.children.push(elementNode);
        elementStack.push(elementNode);
        break;
      // 文本节点定义
      case "text":
        const textNode = {
          type: "Text",
          content: t.content,
        };
        parent.children.push(textNode);
        break;
      // 记得 pop   
      case "tagEnd":
        elementStack.pop();
        break;
    }
    tokens.shift();
  }

  return root;
}


这只是简单实现,没有考虑闭合的场景,可执行的代码见 codes 目录

3.3. 完成流程示意图

图片&文件

这就是一个特定的打平一维数组结构构造成树形结构的算法问题

3.4. 如何有效的遍历 AST 树?(插件化架构)

深度遍历如下 AST 树

{
    "type": "Root",
    "children": [
        {
            "type": "Element",
            "tag": "div",
            "children": [
                {
                    "type": "Element",
                    "tag": "p",
                    "children": [
                        {
                            "type": "Text",
                            "content": "Vue"
                        }
                    ]
                },
                {
                    "type": "Element",
                    "tag": "p",
                    "children": [
                        {
                            "type": "Text",
                            "content": "Template"
                        }
                    ]
                }
            ]
        }
    ]
}

为了解耦节点的访问和操作,我们设计了插件化架构, 将节点的操作封装到独立的转换函数中。

  • 这些转换函数可以通过 context.nodeTransforms 来注册。
  • 这里的 context 称为转换上下文,上下文对象中通常会维护程序的当前状态,例如
    • 当前访问的节点
    • 当前访问的节点的父节点
    • 当前访问的节点的位置索引等信息。
    • 有了上下文对象及其包含的重要信息后,
    • 这样我们即可轻松地实现节点的替换、删除等能力。
  • 遍历的进入离开阶段
    • 有时,当前访问节点的转换工作依赖于其子节点的转换结果
      • 所以为了优先完成子节点的转换,我们将整个转换过程分为“进入阶段”与“退出阶段”
    • 每个转换函数都分两个阶段执行,这样就可以实现更加细粒度的转换控制

3.4.1. 具体代码,很好理解,就是一个多叉树的遍历而已

关键点:

  • 前序位置,传入节点转换函数
    • 因为转换函数有个 return 值,所以这里也会收集相应的 return 的函数
  • 后续位置
    • 需要执行上面收集到的 return 函数

// 转换函数 1 :用于转换元素节点
function transformElement(node) {
  console.log(`进入:${JSON.stringify(node)}`);
  return () => {
    console.log(`退出:${JSON.stringify(node)}`);
  };
}
// 转换函数 2 :用于转换文本节点
function transformText(node, context) {
  console.log(`进入:${JSON.stringify(node)}`);

  return () => {
    console.log(`退出:${JSON.stringify(node)}`);
  };
}

// 遍历节点,深度优先遍历,传入节点和上下文对象
function traverseNode(ast, context) {
  context.currentNode = ast;

  // 遍历节点转换函数的 return 值, 用于退出时执行
  const exitFns = [];
  const transforms = context.nodeTransforms;
  // ***************************** 前序位置  ***************************** 
  // 遍历节点转换函数, 传入当前节点和上下文对象,然后执行
  for (let i = 0; i < transforms.length; i++) {
    // 执行节点转换函数,返回一个函数,用于退出时执行
    const onExit = transforms[i](context.currentNode, context);
    if (onExit) {
      exitFns.push(onExit);
    }
    if (!context.currentNode) return;
  }

  // 遍历子节点,递归遍历
  const children = context.currentNode.children;
  if (children) {
    for (let i = 0; i < children.length; i++) {
      // 记得更新上下文信息
      context.parent = context.currentNode;
      context.childIndex = i;
      traverseNode(children[i], context);
    }
  }

  // ***************************** 后续位置  ***************************** 
  // 退出时执行,走到这儿说明当前节点已经遍历完了
  // 后序位置,这是离开节点时执行的函数
  let i = exitFns.length;
  while (i--) {
    exitFns[i]();
  }
}

function transform(ast) {
  // 遍历的上下文对象,包括
  // 当前节点父节点
  // 当前节点在父节点中的索引
  // 替换节点
  // 删除节点
  // 节点转换函数
  const context = {
    currentNode: null,
    parent: null,
    replaceNode(node) {
      context.currentNode = node;
      context.parent.children[context.childIndex] = node;
    },
    removeNode() {
      if (context.parent) {
        context.parent.children.splice(context.childIndex, 1);
        context.currentNode = null;
      }
    },
    // 插件的方式扩展功能
    // 节点转换函数, 用于对节点进行转换
    // 传入节点和上下文对象,
    //  返回一个函数,用于在退出节点时执行
    nodeTransforms: [transformElement, transformText],
  };
  // 调用 traverseNode 完成转换,传入 AST 和上下文对象
  // 这是一个DFS递归遍历的过程
  traverseNode(ast, context);
  // 打印 AST 信息
  console.log(dump(ast));
}

const ast = parse(`<div><p>Vue</p><p>Template</p></div>`);
transform(ast);

3.4.2. 附: Context(上下文) 的其他应用

  • 在编写 React 应用时
    • 我们可以使用 React.createContext 函数创建一个上下文对象,该上下文对象允许我们将数据通过组件 树一层层地传递下去。无论组件树的层级有多深,只要组件在这 棵组件树的层级内,那么它就能够访问上下文对象中的数据。
  • 在编写 Vue.js 应用时
    • 我们也可以通过 provide/inject 等能力,向一整棵组件树提供数据。这些数据可以称为上下文。
  • 在编写 Koa 应用时
    • 中间件函数接收的 context 参数也是一种上 下文对象,所有中间件都可以通过 context 来访问相同的数据

3.5. 将模板 AST 转换为用于描述渲染函数的 JavaScript AST

3.5.1. JavaScript AST 介绍

JavaScript AST 用于描述 JavaScript 代码。只有把模板 AST 转换为 JavaScript AST 后,我们才能据此生成最终的渲染函数代码。

function render() {
  return h("div", [h("p", "Vue"), h("p", "Template")]);
}

上面代码对应的 js AST 是什么呢?如下

图片&文件

下面是具体结构

// 最终生成的 jsAST
const jsAST = {
  // 代表该节点是一个函数声明
  type: "FunctionDecl",
  // 标识符,本身也是一个节点,所以有自己的type和name属性
  id: {
    type: "Identifier",
    name: "render", // 函数名
  },
  params: [], // 参数列表
  body: [
    // 函数体
    {
      type: "ReturnStatement", // 代表该节点是一个返回语句
      return: {
        type: "CallExpression",
        callee: {
          type: "Identifier",
          name: "h",
        },
        arguments: [
          {
            type: "StringLiteral",
            value: "div",
          },
          {
            type: "ArrayExpression",
            elements: [
              {
                type: "CallExpression",
                callee: {
                  type: "Identifier",
                  name: "h",
                },
                arguments: [
                  {
                    type: "StringLiteral",
                    value: "p",
                  },
                  {
                    type: "StringLiteral",
                    value: "Vue",
                  },
                ],
              },
              {
                type: "CallExpression",
                callee: {
                  type: "Identifier",
                  name: "h",
                },
                arguments: [
                  {
                    type: "StringLiteral",
                    value: "p",
                  },
                  {
                    type: "StringLiteral",
                    value: "Template",
                  },
                ],
              },
            ],
          },
        ],
      },
    },
  ],
};

3.5.2. 将模板 AST 转换 JS AST

先写几个生成 JS AST 的辅助函数

// 创建 AST 节点: StringLiteral 节点
function createStringLiteral(value) {
  return {
    type: "StringLiteral",
    value,
  };
}
// 创建 AST 节点: Identifier  节点
function createIdentifier(name) {
  return {
    type: "Identifier",
    name,
  };
}
// 创建 AST 节点: ArrayExpression  节点
function createArrayExpression(elements) {
  return {
    type: "ArrayExpression",
    elements,
  };
}
// 创建 AST 节点: CallExpression 节点
function createCallExpression(callee, arguments) {
  return {
    type: "CallExpression",
    callee: createIdentifier(callee),
    arguments,
  };
}

完整代码:

function traverseNode(ast, context) {
  context.currentNode = ast;

  const exitFns = [];
  const transforms = context.nodeTransforms;
  for (let i = 0; i < transforms.length; i++) {
    const onExit = transforms[i](context.currentNode, context);
    if (onExit) {
      exitFns.push(onExit);
    }
    if (!context.currentNode) return;
  }

  const children = context.currentNode.children;
  if (children) {
    for (let i = 0; i < children.length; i++) {
      context.parent = context.currentNode;
      context.childIndex = i;
      traverseNode(children[i], context);
    }
  }

  let i = exitFns.length;
  while (i--) {
    exitFns[i]();
  }
}

function transform(ast) {
  const context = {
    currentNode: null,
    parent: null,
    replaceNode(node) {
      context.currentNode = node;
      context.parent.children[context.childIndex] = node;
    },
    removeNode() {
      if (context.parent) {
        context.parent.children.splice(context.childIndex, 1);
        context.currentNode = null;
      }
    },
    nodeTransforms: [transformRoot, transformElement, transformText],
  };
  // 调用 traverseNode 完成转换
  traverseNode(ast, context);
}

// =============================== AST 工具函数 ===============================

// 创建 AST 节点: StringLiteral 节点
function createStringLiteral(value) {
  return {
    type: "StringLiteral",
    value,
  };
}
// 创建 AST 节点: Identifier  节点
function createIdentifier(name) {
  return {
    type: "Identifier",
    name,
  };
}
// 创建 AST 节点: ArrayExpression  节点
function createArrayExpression(elements) {
  return {
    type: "ArrayExpression",
    elements,
  };
}
// 创建 AST 节点: CallExpression 节点
function createCallExpression(callee, arguments) {
  return {
    type: "CallExpression",
    callee: createIdentifier(callee),
    arguments,
  };
}

// =============================== AST 工具函数 ===============================

// 模板 AST 文本 转换 JS  StringLiteral 节点
function transformText(node) {
  if (node.type !== "Text") {
    return;
  }

  node.jsNode = createStringLiteral(node.content);
}

// 模板 AST 元素 转换 JS  CallExpression 节点,因为要调用 h 函数
function transformElement(node) {
  return () => {
    if (node.type !== "Element") {
      return;
    }

    const callExp = createCallExpression("h", [createStringLiteral(node.tag)]);
    node.children.length === 1
      ? callExp.arguments.push(node.children[0].jsNode)
      : callExp.arguments.push(
          createArrayExpression(node.children.map((c) => c.jsNode)),
        );

    node.jsNode = callExp;
  };
}

// 模板 AST 根节点 转换 JS  FunctionDecl 节点
// 根节点只有一个子节点,所以直接返回子节点的 jsNode
function transformRoot(node) {
  return () => {
    if (node.type !== "Root") {
      return;
    }

    const vnodeJSAST = node.children[0].jsNode;

    node.jsNode = {
      type: "FunctionDecl",
      id: { type: "Identifier", name: "render" },
      params: [],
      body: [
        {
          type: "ReturnStatement",
          return: vnodeJSAST,
        },
      ],
    };
  };
}

const ast = parse(`<div><p>Vue</p><p>Template</p></div>`);
transform(ast);

console.log(ast);

3.6. 渲染函数代码的生成

代码生成的过程就是字符串拼接的过程。我们需要为不同的 AST 节点编写对应的代码生成函数。

  • 为了让生成的代码具有更强的可读性,我们可对生成的代码进行缩进和换行。
    • 我们将用于缩进和换行的代码封装为工具函数,并且定义到代码生成过程中的上下文对象

3.6.1. 先看调用 generate 的效果

// 最终生成的 jsAST
const jsAST = {
  type: "FunctionDecl",
  id: {
    type: "Identifier",
    name: "render",
  },
  params: [],
  body: [
    {
      type: "ReturnStatement",
      return: {
        type: "CallExpression",
        callee: {
          type: "Identifier",
          name: "h",
        },
        arguments: [
          {
            type: "StringLiteral",
            value: "div",
          },
          {
            type: "ArrayExpression",
            elements: [
              {
                type: "CallExpression",
                callee: {
                  type: "Identifier",
                  name: "h",
                },
                arguments: [
                  {
                    type: "StringLiteral",
                    value: "p",
                  },
                  {
                    type: "StringLiteral",
                    value: "Vue",
                  },
                ],
              },
              {
                type: "CallExpression",
                callee: {
                  type: "Identifier",
                  name: "h",
                },
                arguments: [
                  {
                    type: "StringLiteral",
                    value: "p",
                  },
                  {
                    type: "StringLiteral",
                    value: "Template",
                  },
                ],
              },
            ],
          },
        ],
      },
    },
  ],
};

console.log(generate(jsAST));

// function render () {
//   return h('div', [h('p', 'Vue'), h('p', 'Template')])
// }

3.6.2. generate 的实现

就是字符串的拼接工作,没有什么特别的

function generate(node) {
  const context = {
    code: "",
    push(code) {
      context.code += code;
    },
    currentIndent: 0,
    newline() {
      context.code += "\n" + `  `.repeat(context.currentIndent);
    },
    indent() {
      context.currentIndent++;
      context.newline();
    },
    deIndent() {
      context.currentIndent--;
      context.newline();
    },
  };

  genNode(node, context);

  return context.code;
}

function genNode(node, context) {
  switch (node.type) {
    case "FunctionDecl":
      genFunctionDecl(node, context);
      break;
    case "ReturnStatement":
      genReturnStatement(node, context);
      break;
    case "CallExpression":
      genCallExpression(node, context);
      break;
    case "StringLiteral":
      genStringLiteral(node, context);
      break;
    case "ArrayExpression":
      genArrayExpression(node, context);
      break;
  }
}

function genFunctionDecl(node, context) {
  const { push, indent, deIndent } = context;

  push(`function ${node.id.name} `);
  push(`(`);
  genNodeList(node.params, context);
  push(`) `);
  push(`{`);
  indent();

  node.body.forEach((n) => genNode(n, context));

  deIndent();
  push(`}`);
}

function genNodeList(nodes, context) {
  const { push } = context;
  for (let i = 0; i < nodes.length; i++) {
    const node = nodes[i];
    genNode(node, context);
    if (i < nodes.length - 1) {
      push(", ");
    }
  }
}

function genReturnStatement(node, context) {
  const { push } = context;

  push(`return `);
  genNode(node.return, context);
}

function genCallExpression(node, context) {
  const { push } = context;
  const { callee, arguments: args } = node;
  push(`${callee.name}(`);
  genNodeList(args, context);
  push(`)`);
}

function genStringLiteral(node, context) {
  const { push } = context;

  push(`'${node.value}'`);
}

function genArrayExpression(node, context) {
  const { push } = context;
  push("[");
  genNodeList(node.elements, context);
  push("]");
}

3.7. 最后

至此完成了基本的模板编译成渲染函数的工作