Vue设计与实现:编译器核心技术概览

Vue模板编译器的工作流程

  1. 分析模板,将其解析为模板 AST。
  2. 将模板 AST 转换为用于描述渲染函数的 JavaScript AST。
  3. 根据 JavaScript AST 生成渲染函数代码

将模板转换成ast

什么是模板AST

html 复制代码
<div>
 <h1 v-if="ok">Vue Template</h1>
</div>
  1. 不同类型的节点是通过节点的 type 属性进行区分的。例如标签 节点的 type 值为 'Element'。
  2. 标签节点的子节点存储在其 children 数组中。
  3. 标签节点的属性节点和指令节点会存储在 props 数组中。
  4. 不同类型的节点会使用不同的对象属性进行描述。例如指令节点 拥有 name 属性,用来表达指令的名称,而表达式节点拥有 content 属性,用来描述表达式的内容。
js 复制代码
const ast = {
  // 逻辑根节点
  type: 'Root',
  children: [
    // div 标签节点
    {
      type: 'Element',
      tag: 'div',
      children: [
        // h1 标签节点
        {
          type: 'Element',
          tag: 'h1',
          props: [
            // v-if 指令节点
            {
              type: 'Directive', // 类型为 Directive 代表指令
              name: 'if', // 指令名称为 if,不带有前缀 v19 exp: {
              // 表达式节点
              type: 'Expression',
              content: 'ok'
            }
          ]
        }
      ]
    }
  ]
}

生成tokens

html 复制代码
<p>Vue</p>
  1. 一开始状态是初始状态1
  2. 遇到<,因为是开始标签,状态进入标签开始状态2
  3. 遇到p,因为p是开始标签字母,状态进入标签名称状态3
  4. 遇到>,因为是结尾标签,状态返回到初始状态1,记录开始标签名称p
  5. 遇到V、u、e,因为是文本,状态都进入文本状态4,记录文本内容Vue
  6. 遇到<,因为是开始标签,状态进入标签开始状态2
  7. 遇到/,状态进入结束标签状态5
  8. 遇到p,因为p是结束标签字母,状态进入结束标签名称状态6
  9. 遇到>,因为是结尾标签,状态返回到初始状态1,记录结束标签名称p
思路:
  1. 定义状态机的状态、判断是否为字母辅助函数
js 复制代码
// 定义状态机的状态
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'
}

状态机当前处于初始状态

  1. 初始状态下一个会出现字母、<的情况需要分别判断处理
  2. 如果是字母,就把状态机切换到文本状态,把字母保存到chars,最后把字母删除
  3. 如果是<,就切换到标签开始状态,最后把<删除
js 复制代码
function tokenize(str) {
  // 状态机的当前状态:初始状态
  let currentState = State.initial
  // 用于缓存字符
  const chars = []
  // 生成的 Token 会存储到 tokens 数组中,并作为函数的返回值返回
  const tokens = []
  // 使用 while 循环开启自动机,只要模板字符串没有被消费尽,自动机就会一直运行
  while (str) {
    // 查看第一个字符,注意,这里只是查看,没有消费该字符
    const char = str[0]
    switch (currentState) {
      // 状态机当前处于初始状态
      case State.initial:
        // 遇到字符 <
        if (char === "<") {
          // 1. 状态机切换到标签开始状态
          currentState = State.tagOpen
          // 2. 去掉字符 <
          str = str.slice(1)
        } else if (isAlpha(char)) {
          // 1. 遇到字母,切换到文本状态
          currentState = State.text
          // 2. 将当前字母缓存到 chars 数组
          chars.push(char)
          // 3. 消费当前字符
          str = str.slice(1)
        }
        break
    }
  }
}

状态机当前处于标签开始状态

  1. <下一个会出现字母、/的情况需要分别判断处理
  2. 如果是开始标签字母,就把状态机切换到标签开始状态,把字母保存到chars,最后把字母删除
  3. 如果是/,就把切换到结束标签状态,最后把/删除
diff 复制代码
// 接收模板字符串作为参数,并将模板切割为 Token 返回
function tokenize(str) {
  // 状态机的当前状态:初始状态
  let currentState = State.initial
  // 用于缓存字符
  const chars = []
  // 生成的 Token 会存储到 tokens 数组中,并作为函数的返回值返回
  const tokens = []
  // 使用 while 循环开启自动机,只要模板字符串没有被消费尽,自动机就会一直运行
  while (str) {
    // 查看第一个字符,注意,这里只是查看,没有消费该字符
    const char = str[0]
    switch (currentState) {
+      // 状态机当前处于标签开始状态
+      case State.tagOpen:
+        if (isAlpha(char)) {
+          // 1. 遇到字母,切换到标签名称状态
+          currentState = State.tagName
+          // 2. 将当前字符缓存到 chars 数组
+          chars.push(char)
+          // 3. 消费当前字符
+          str = str.slice(1)
+        } else if (char === '/') {
+          // 1. 遇到字符 /,切换到结束标签状态
+          currentState = State.tagEnd
+          // 2. 去掉字符 /
+          str = str.slice(1)
+        }
+        break
    }
  }
}

状态机当前处于标签名称状态

  1. 标签名称下一个会出现标签名称(标签可能是多个字母)、>的情况需要分别判断处理
  2. 如果是标签名称,由于当前处于标签名称状态,所以不需要切换状态,把字母保存到chars,最后把字母删除
  3. 如果是>,说明标签已结束,切换到初始状态,需要chars数组保存到tokens类型为tag,并把chars清空、删除>
diff 复制代码
// 接收模板字符串作为参数,并将模板切割为 Token 返回
function tokenize(str) {
  // 状态机的当前状态:初始状态
  let currentState = State.initial
  // 用于缓存字符
  const chars = []
  // 生成的 Token 会存储到 tokens 数组中,并作为函数的返回值返回
  const tokens = []
  // 使用 while 循环开启自动机,只要模板字符串没有被消费尽,自动机就会一直运行
  while (str) {
    // 查看第一个字符,注意,这里只是查看,没有消费该字符
    const char = str[0]
    switch (currentState) {
+      // 状态机当前处于标签名称状态
+      case State.tagName:
+        if (isAlpha(str)) {
+          // 1. 遇到字母,由于当前处于标签名称状态,所以不需要切换状态,
+          // 但需要将当前字符缓存到 chars 数组
+          chars.push(char)
+          // 2. 消费当前字符
+          str = str.slice(1)
+        } else if (char === '>') {
+          // 1.遇到字符 >,切换到初始状态
+          currentState = State.initial
+          // 2. 同时创建一个标签 Token,并添加到 tokens 数组中
+          // 注意,此时 chars 数组中缓存的字符就是标签名称
+          tokens.push({
+            type: 'tag',
+            name: chars.join('')
+          })
+          // 3. chars 数组的内容已经被消费,清空它
+          chars.length = 0
+          // 4. 同时消费当前字符 >
+          str = str.slice(1)
+        }
+        break
    }
  }
}

状态机当前处于文本状态

  1. 文本状态会出现字母(文本内容可能是多个字母)、<的情况需要分别判断处理
  2. 如果是字母,就把状态机切换到文本状态,把字母保存到chars,最后把字母删除
  3. 如果是<,就切换到标签开始状态,因为是文本状态-标签开始状态,所以需要把文本内容chars保存到tokens类型为text,并把chars清空、把<删除
diff 复制代码
function tokenize(str) {
  // 状态机的当前状态:初始状态
  let currentState = State.initial
  // 用于缓存字符
  const chars = []
  // 生成的 Token 会存储到 tokens 数组中,并作为函数的返回值返回
  const tokens = []
  // 使用 while 循环开启自动机,只要模板字符串没有被消费尽,自动机就会一直运行
  while (str) {
    // 查看第一个字符,注意,这里只是查看,没有消费该字符
    const char = str[0]
    switch (currentState) {
+      // 状态机当前处于文本状态
+      case State.text:
+        if (isAlpha(char)) {
+          // 1. 遇到字母,保持状态不变,但应该将当前字符缓存到 chars 数组
+          chars.push(char)
+          // 2. 消费当前字符
+          str = str.slice(1)
+        } else if (char === '<') {
+          // 1. 遇到字符 <,切换到标签开始状态
+          currentState = State.tagOpen
+          // 2. 从 文本状态 --> 标签开始状态,此时应该创建文本 Token,并添加到 tokens 数组
+          // 注意,此时 chars 数组中的字符就是文本内容
+          tokens.push({
+            type: 'text',
+            content: chars.join('')
+          })
+          // 3. chars 数组的内容已经被消费,清空它
+          chars.length = 0
+          // 4. 消费当前字符
+          str = str.slice(1)
+        }
+        break
    }
  }
}

状态机当前处于标签结束状态

  1. /下一个只会存在结束标签名称,切换到结束标签名称状态,结束标签字母保存到chars,最后把/删除
diff 复制代码
function tokenize(str) {
  // 状态机的当前状态:初始状态
  let currentState = State.initial
  // 用于缓存字符
  const chars = []
  // 生成的 Token 会存储到 tokens 数组中,并作为函数的返回值返回
  const tokens = []
  // 使用 while 循环开启自动机,只要模板字符串没有被消费尽,自动机就会一直运行
  while (str) {
    // 查看第一个字符,注意,这里只是查看,没有消费该字符
    const char = str[0]
    switch (currentState) {
+      // 状态机当前处于标签结束状态
+      case State.tagEnd:
+        if (isAlpha(char)) {
+          // 1. 遇到字母,切换到结束标签名称状态
+          currentState = State.tagEndName
+          // 2. 将当前字符缓存到 chars 数组
+          chars.push(char)
+          // 3. 消费当前字符
+          str = str.slice(1)
+        }
+        break
    }
  }
}

状态机当前处于结束标签名称状态

  1. 结束标签名称状态下一个会存在标签字母(标签可能是多个字母)、>的情况需要分别判断处理
  2. 如果是标签字母,不需要切换状态,把字母保存到chars,最后把字母删除
  3. 如果是>,就切换到初始状态,因为是结束标签名称状态-初始状态,所以需要把文本内容chars保存到tokens类型为tagEnd,并把chars清空、把<删除
diff 复制代码
function tokenize(str) {
  // 状态机的当前状态:初始状态
  let currentState = State.initial
  // 用于缓存字符
  const chars = []
  // 生成的 Token 会存储到 tokens 数组中,并作为函数的返回值返回
  const tokens = []
  // 使用 while 循环开启自动机,只要模板字符串没有被消费尽,自动机就会一直运行
  while (str) {
    // 查看第一个字符,注意,这里只是查看,没有消费该字符
    const char = str[0]
    switch (currentState) {
+      // 状态机当前处于结束标签名称状态
+      case State.tagEndName:
+        if (isAlpha(char)) {
+          // 1. 遇到字母,不需要切换状态,但需要将当前字符缓存到 chars数组
+          chars.push(char)
+          // 2. 消费当前字符
+          str = str.slice(1)
+        } else if (char === '>') {
+          // 1. 遇到字符 >,切换到初始状态
+          currentState = State.initial
+          // 2. 从 结束标签名称状态 --> 初始状态,应该保存结束标签名称Token
+          // 注意,此时 chars 数组中缓存的内容就是标签名称
+          tokens.push({
+            type: 'tagEnd',
+            name: chars.join('')
+          })
+          // 3. chars 数组的内容已经被消费,清空它
+          chars.length = 0
+          // 4. 消费当前字符
+          str = str.slice(1)
+        }
+        break
    }
  }
+  // 最后,返回 tokens
+  return tokens
}
结果:
js 复制代码
console.log(tokenize(`<div><p>Vue</p><p>Template</p></div>`))

生成AST

根据Token列表构建AST的过程,其实就是对Token列表进行扫描的过程。从第一个Token开始,顺序地扫描整个Token列表,直到列表中的所有Token处理完毕

js 复制代码
const tokens = tokenize(`<div><p>Vue</p><p>Template</p></div>`)

const tokens = [
  { type: "tag", name: "div" }, // div 开始标签节点
  { type: "tag", name: "p" }, // p 开始标签节点
  { type: "text", content: "Vue" }, // 文本节点
  { type: "tagEnd", name: "p" }, // p 结束标签节点
  { type: "tag", name: "p" }, // p 开始标签节点
  { type: "text", content: "Template" }, // 文本节点
  { type: "tagEnd", name: "p" }, // p 结束标签节点
  { type: "tagEnd", name: "div" } // div 结束标签节点
]
思路:

维护一个栈elementStack,这个栈将用于维护元素间的父子关系。每遇到一个开始标签节点,我们就构造一个Element 类型的 AST 节点,并将其压入栈中。类似地,每当遇到一个结束标签节点,我们就将当前栈顶的节点弹出。这样,栈顶的节点将始终充当父节点的角色。扫描过程中遇到的所有节点,都会作为当前栈顶节点的子节点,并添加到栈顶节点的 children 属性下
初识状态

  1. elementStack栈初始节点只有Root根节点,Root根节点type、children属性
js 复制代码
// parse 函数接收模板作为参数
function parse(str) {
  // 首先对模板进行标记化,得到 tokens
  const tokens = tokenize(str)
  // 创建 Root 根节点
  const root = {
    type: "Root",
    children: []
  }
  // 创建 elementStack 栈,起初只有 Root 根节点
  const elementStack = [root]
}

type为tag(开始标签)

  1. 循环tokens,判断每个token的type(tag、text、tagEnd)
  2. 如果type是tag说明是开始标签,创建Element类型的AST节点elementNode,因为是标签所以AST节点的type是Element,tag取token的name,children为数组。将elementNode添加到父节点,将当前节点添加到elementStack栈
diff 复制代码
// parse 函数接收模板作为参数
function parse(str) {
  // 首先对模板进行标记化,得到 tokens
  const tokens = tokenize(str)
  // 创建 Root 根节点
  const root = {
    type: "Root",
    children: []
  }
  // 创建 elementStack 栈,起初只有 Root 根节点
  const elementStack = [root]
  // 开启一个 while 循环扫描 tokens,直到所有 Token 都被扫描完毕为止
+  while (tokens.length) {
+    // 获取当前栈顶节点作为父节点 parent
+    const parent = elementStack[elementStack.length - 1]
+    // 当前扫描的 Token
+    const t = tokens[0]
+    switch (t.type) {
+      case 'tag':
+        // 如果当前 Token 是开始标签,则创建 Element 类型的 AST 节点
+        const elementNode = {
+          type: 'Element',
+          tag: t.name,
+          children: []
+        }
+        // 将其添加到父级节点的 children 中
+        parent.children.push(elementNode)
+        // 将当前节点压入栈
+        elementStack.push(elementNode)
+        break
+    }
+  }
+    // 最后返回 AST
+    return root
}

type为text(文本节点)

  1. 如果type是text说明是文本,创建Text类型的AST节点textNode,textNode的type为Text,没有tag、children属性,content属性为textNode的内容,因为textNode不是标签,所以不需要添加到elementStack栈,只需要添加到父节点的 children 中
diff 复制代码
// parse 函数接收模板作为参数
function parse(str) {
  // 首先对模板进行标记化,得到 tokens
  const tokens = tokenize(str)
  // 创建 Root 根节点
  const root = {
    type: "Root",
    children: []
  }
  // 创建 elementStack 栈,起初只有 Root 根节点
  const elementStack = [root]
  // 开启一个 while 循环扫描 tokens,直到所有 Token 都被扫描完毕为止
  while (tokens.length) {
    // 获取当前栈顶节点作为父节点 parent
    const parent = elementStack[elementStack.length - 1]
    // 当前扫描的 Token
    const t = tokens[0]
    switch (t.type) {
+      case 'text':
+        // 如果当前 Token 是文本,则创建 Text 类型的 AST 节点
+        const textNode = {
+          type: 'Text',
+          content: t.content
+        }
+        // 将其添加到父节点的 children 中
+        parent.children.push(textNode)
+        break
    }
  }
    // 最后返回 AST
    return root
}

type是tagEnd(结束标签)

  1. 如果type是tagEnd说明是结束标签,需要将栈顶节点弹出,不需要创建AST节点
diff 复制代码
// parse 函数接收模板作为参数
function parse(str) {
  // 首先对模板进行标记化,得到 tokens
  const tokens = tokenize(str)
  // 创建 Root 根节点
  const root = {
    type: "Root",
    children: []
  }
  // 创建 elementStack 栈,起初只有 Root 根节点
  const elementStack = [root]
  // 开启一个 while 循环扫描 tokens,直到所有 Token 都被扫描完毕为止
  while (tokens.length) {
    // 获取当前栈顶节点作为父节点 parent
    const parent = elementStack[elementStack.length - 1]
    // 当前扫描的 Token
    const t = tokens[0]
    switch (t.type) {
+      case 'tagEnd':
+        // 遇到结束标签,将栈顶节点弹出
+        elementStack.pop()
+        break
    }
+        // 消费已经扫描过的 token
+        tokens.shift()
  }
    // 最后返回 AST
    return root
}
结果:
js 复制代码
console.log(parse(`<div><p>Vue</p><p>Template</p></div>`)

最外层为Root Root下children有div节点 div下children有两个p节点 两个p节点文本content分别为Vue、Template

将模板AST转为JavaScript AST

模版

html 复制代码
<div><p>Vue</p><p>Template</p></div>

模版对应渲染函数

js 复制代码
function render() {
  return h('div', [
    h('p', 'Vue'),
    h('p', 'Template')
  ])
}

渲染函数对应JavaScript AST

  1. id:函数名称,它是一个标识符 Identifier。
  2. params:函数的参数,它是一个数组。
  3. body:函数体,由于函数体可以包含多个语句,因此它也是一个 数组。
js 复制代码
const FunctionDeclNode = {
  type: 'FunctionDecl', // 代表该节点是函数声明
 // 函数的名称是一个标识符,标识符本身也是一个节点
 id: {
    type: 'Identifier',
    name: 'render' // name 用来存储标识符的名称,在这里它就是渲染函数的名称 render
  },
  params: [], // 参数,目前渲染函数还不需要参数,所以这里是一个空数组
  // 渲染函数的函数体只有一个语句,即 return 语句
  body: [
    {
      type: 'ReturnStatement',
      return: null // 暂时留空,在后续讲解中补全
    }
  ]
}

渲染函数返回值对应JavaScript AST

  1. callee:用来描述被调用函数的名字称,它本身是一个标识符节点。
  2. arguments:被调用函数的形式参数,多个参数的话用数组来描述
js 复制代码
const CallExp = {
  type: 'CallExpression',
  // 被调用函数的名称,它是一个标识符
  callee: {
    type: 'Identifier',
    name: 'h'
  },
  // 参数
  arguments: []
}

渲染函数字符串参数对应JavaScript AST

js 复制代码
const Str = {
  type: 'StringLiteral',
  value: 'div'
}

渲染函数数组参数对应JavaScript AST

js 复制代码
const Arr = {
  type: 'ArrayExpression',
  // 数组中的元素
  elements: []
}

最终渲染函数对应JavaScript AST

js 复制代码
const FunctionDeclNode = {
  type: 'FunctionDecl', // 代表该节点是函数声明
 // 函数的名称是一个标识符,标识符本身也是一个节点
 id: {
    type: 'Identifier',
    name: 'render' // name 用来存储标识符的名称,在这里它就是渲染函数的名称 render
  },
  params: [], // 参数,目前渲染函数还不需要参数,所以这里是一个空数组
  // 渲染函数的函数体只有一个语句,即 return 语句
  body: [
    {
      type: 'ReturnStatement',
      // 最外层的 h 函数调用
      return: {
        type: 'CallExpression',
        callee: { type: 'Identifier', name: 'h' },
        arguments: [
          // 第一个参数是字符串字面量 'div'
          {
            type: 'StringLiteral',
            value: 'div'
          },
          // 第二个参数是一个数组
          {
            type: 'ArrayExpression',
            elements: [
              // 数组的第一个元素是 h 函数的调用
              {
                type: 'CallExpression',
                callee: { type: 'Identifier', name: 'h' },
                arguments: [
                  // 该 h 函数调用的第一个参数是字符串字面量
                  { type: 'StringLiteral', value: 'p' },
                  // 第二个参数也是一个字符串字面量
                  { type: 'StringLiteral', value: 'Vue' },
                ]
              },
              // 数组的第二个元素也是 h 函数的调用
              {
                type: 'CallExpression',
                callee: { type: 'Identifier', name: 'h' },
                arguments: [
                  // 该 h 函数调用的第一个参数是字符串字面量
                  { type: 'StringLiteral', value: 'p' },
                  // 第二个参数也是一个字符串字面量
                  { type: 'StringLiteral', value: 'Template' },
                ]
              }
            ]
          }
        ]
      }
    }
  ]
}

思路:

创建节点函数

js 复制代码
// 用来创建 StringLiteral 节点
function createStringLiteral(value) {
  return {
    type: 'StringLiteral',
    value
  }
}
// 用来创建 Identifier 节点
function createIdentifier(name) {
  return {
    type: 'Identifier',
    name
  }
}
// 用来创建 ArrayExpression 节点
function createArrayExpression(elements) {
  return {
    type: 'ArrayExpression',
    elements
  }
}
// 用来创建 CallExpression 节点
function createCallExpression(callee, arguments) {
  return {
    type: 'CallExpression',
    callee: createIdentifier(callee),
    arguments
  }
}

转换文本节点

  1. 文本ast是保存到content属性中,传入createStringLiteral创建StringLiteral节点,保存到ast的jsNode属性
js 复制代码
// 转换文本节点
function transformText(node) {
  if (node.type !== 'Text') {
    return
  }
  // 文本节点对应的 JavaScript AST 节点其实就是一个字符串字面量,
  // 因此只需要使用 node.content 创建一个 StringLiteral 类型的节点即可
  // 最后将文本节点对应的 JavaScript AST 节点添加到 node.jsNode 属性下
  node.jsNode = createStringLiteral(node.content)
}

转换标签节点

  1. 使用createCallExpression创建CallExpression节点
  2. 如果ast的得子节点只有一个就直接push该子节点的jsNode到arguments,如果有多个子节点,先调用createArrayExpression创建ArrayExpression节点
  3. 最终CallExpression节点保存到ast的jsNode属性
js 复制代码
// 转换标签节点
function transformElement(node) {
  // 将转换代码编写在退出阶段的回调函数中,
  // 这样可以保证该标签节点的子节点全部被处理完毕
  return () => {
    // 如果被转换的节点不是元素节点,则什么都不做
    if (node.type !== 'Element') {
      return
    }
    // 1. 创建 h 函数调用语句,
    // h 函数调用的第一个参数是标签名称,因此我们以 node.tag 来创建一个字符串字面量节点
    // 作为第一个参数
    const callExp = createCallExpression('h', [
      createStringLiteral(node.tag)
    ])
    // 2. 处理 h 函数调用的参数
    node.children.length === 1
      // 如果当前标签节点只有一个子节点,则直接使用子节点的 jsNode 作为参数
      ? callExp.arguments.push(node.children[0].jsNode)
      // 如果当前标签节点有多个子节点,则创建一个 ArrayExpression 节点作为参数
      : callExp.arguments.push(
        // 数组的每个元素都是子节点的 jsNode
        createArrayExpression(node.children.map(c => c.jsNode))
      )
    // 3. 将当前标签节点对应的 JavaScript AST 添加到 jsNode 属性下
    node.jsNode = callExp
  }
}

转换Root根节点

  1. ast的第一个子节点的jsNode做为return的渲染函数h对应的JavaScript AST
js 复制代码
// 转换 Root 根节点
function transformRoot() {
  // 将逻辑编写在退出阶段的回调函数中,保证子节点全部被处理完毕
  return () => {
    // 如果不是根节点,则什么都不做
    if (node.type !== 'Root') {
      return
    }
    // node 是根节点,根节点的第一个子节点就是模板的根节点,
    // 当然,这里我们暂时不考虑模板存在多个根节点的情况
    const vnodeJSAST = node.children[0].jsNode
    // 创建 render 函数的声明语句节点,将 vnodeJSAST 作为 render 函数体的返回语句
    node.jsNode = {
      type: 'FunctionDecl',
      id: { type: 'Identifier', name: 'render' },
      params: [],
      body: [
        {
          type: 'ReturnStatement',
          return: vnodeJSAST
        }
      ]
    }
  }
}
  1. context对象包含上下文信息和辅助方法
  2. replaceNode方法接受一个新节点作为参数,用于替换当前节点。removeNode方法则用于删除当前节点
  3. nodeTransforms的数组,用于存储一系列转换函数。这些函数在遍历 AST 时会被调用,以便对节点进行操作或修改。transformElement 用于转换标签节点,transformText 用于转换文本节点,transformRoot用于转换根节点
js 复制代码
// 封装 transform 函数,用来对 AST 进行转换
function transform(ast) {
  // 在 transform 函数内创建 context 对象
  const context = {
    // 增加 currentNode,用来存储当前正在转换的节点
    currentNode: null,
    // 增加 childIndex,用来存储当前节点在父节点的 children 中的位置索引
    childIndex: 0,
    // 增加 parent,用来存储当前转换节点的父节点
    parent: null,
    // 用于替换节点的函数,接收新节点作为参数
    replaceNode(node) {
      // 为了替换节点,我们需要修改 AST
      // 找到当前节点在父节点的 children 中的位置:context.childIndex
      // 然后使用新节点替换即可
      context.parent.children[context.childIndex] = node
      // 由于当前节点已经被新节点替换掉了,因此我们需要将 currentNode 更新为新节点
      context.currentNode = node
    },
    // 用于删除当前节点。
    removeNode() {
      if (context.parent) {
        // 调用数组的 splice 方法,根据当前节点的索引删除当前节点
        context.parent.children.splice(context.childIndex, 1)
        // 将 context.currentNode 置空
        context.currentNode = null
      }
    },
    // 注册 nodeTransforms 数组
    nodeTransforms: [
      transformElement, // transformElement 函数用来转换标签节点
      transformText, // transformText 函数用来转换文本节点
      transformRoot
    ]
  }
  // 调用 traverseNode 完成转换
  traverseNode(ast, context)
}
  1. exitFns保存退出阶段的回调函数,最后再执行可以保证回调函数的节点已处理完
js 复制代码
function traverseNode(ast, context) {
  // 设置当前转换的节点信息 context.currentNode
  context.currentNode = ast
  // 1. 增加退出阶段的回调函数数组
  const exitFns = []
  // context.nodeTransforms 是一个数组,其中每一个元素都是一个函数
  const transforms = context.nodeTransforms
  for (let i = 0; i < transforms.length; i++) {
    // 2. 转换函数可以返回另外一个函数,该函数即作为退出阶段的回调函数
    const onExit = transforms[i](context.currentNode, context)
    if (onExit) {
      // 将退出阶段的回调函数添加到 exitFns 数组中
      exitFns.push(onExit)
    }
    if (!context.currentNode) return
  }

  // 如果有子节点,则递归地调用 traverseNode 函数进行遍历
  const children = context.currentNode.children
  if (children) {
    for (let i = 0; i < children.length; i++) {
      // 递归地调用 traverseNode 转换子节点之前,将当前节点设置为父节点
      context.parent = context.currentNode
      // 设置位置索引
      context.childIndex = i
      // 递归地调用时,将 context 透传
      traverseNode(children[i], context)
    }
  }

  // 在节点处理的最后阶段执行缓存到 exitFns 中的回调函数
  // 注意,这里我们要反序执行
  let i = exitFns.length
  while (i--) {
    exitFns[i]()
  }

}

结果:

js 复制代码
const ast = parse(`<div><p>Vue</p><p>Template</p></div>`)
transform(ast)
console.log(ast)

一开始context.currentNode是root根节点 遍历children递归traverseNode root下的div标签节点调用traverseNode

div标签节点下的p标签节点调用traverseNode

p标签节点下的文本节点调用traverseNode 因为transformText没有返回函数,转换后对应的 JavaScript AST 节点添加到 node.jsNode 属性下,文案节点下没有children,所以不执行children遍历,执行完文本节点,再遍历执行p标签节点、div标签节点的exitFns属性

生成渲染函数

思路:

  1. compile函数集成模版字符串转模版AST、模版AST转JavaScript AST、生成渲染函数
js 复制代码
function compile(template) {
  // 模板 AST
  const ast = parse(template)
  // 将模板 AST 转换为 JavaScript AST
  transform(ast)
  // 代码生成
  const code = generate(ast.jsNode)

  return code
}
  1. generate函数中,定义了上下文对象context这个对象用于存储最终生成的渲染函数代码。
  2. context.code用于保存生成的代码,context.push(code)用于将新的代码添加到 context.code中。
  3. context.currentIndent用于记录当前缩进的级别,context.newline()用于在代码字符串的后面追加一个换行符,并在需要时保留缩进。
  4. context.indent() 用于使 context.currentIndent 自增,然后调用 context.newline() 函数换行。
  5. context.deIndent() 用于使 context.currentIndent 自减,然后调用 context.newline() 函数换行
  6. 最后调用 genNode 函数完成代码生成的工作,并返回渲染函数代码。
js 复制代码
function generate(node) {
  const context = {
    // 存储最终生成的渲染函数代码
    code: '',
    // 在生成代码时,通过调用 push 函数完成代码的拼接
    push(code) {
      context.code += code
    },
    // 当前缩进的级别,初始值为 0,即没有缩进
    currentIndent: 0,
    // 该函数用来换行,即在代码字符串的后面追加 \n 字符,
    // 另外,换行时应该保留缩进,所以我们还要追加 currentIndent * 2 个空格字符
    newline() {
      context.code += '\n' + ` `.repeat(context.currentIndent)
    },
    // 用来缩进,即让 currentIndent 自增后,调用换行函数
    indent() {
      context.currentIndent++
      context.newline()
    },
    // 取消缩进,即让 currentIndent 自减后,调用换行函数
    deIndent() {
      context.currentIndent--
      context.newline()
    }

  }
  // 调用 genNode 函数完成代码生成的工作,
  genNode(node, context)
  // 返回渲染函数代码
  return context.code
}
js 复制代码
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
  }
}
  1. 把node.id.name做为渲染函数名称
  2. 将渲染函数的参数传入genNodeList生成参数字符串
  3. node.body是渲染函数内容节点集合,需要遍历递归genNode生成对应的字符串代码
js 复制代码
function genFunctionDecl(node, context) {
  // 从 context 对象中取出工具函数
  const { push, indent, deIndent } = context
  // node.id 是一个标识符,用来描述函数的名称,即 node.id.name
  push(`function ${node.id.name} `)
  push(`(`)
  // 调用 genNodeList 为函数的参数生成代码
  genNodeList(node.params, context)
  push(`) `)
  push(`{`)
  // 缩进
  indent()
  // 为函数体生成代码,这里递归地调用了 genNode 函数
  node.body.forEach(n => genNode(n, context))
  // 取消缩进
  deIndent()
  push(`}`)
}
  1. 将节点数组转成字符串格式
js 复制代码
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(', ')
    }
  }
}
  1. 将参数节点转成数组格式
js 复制代码
function genArrayExpression(node, context) {
  const { push } = context
  // 追加方括号
  push('[')
  // 调用 genNodeList 为数组元素生成代码
  genNodeList(node.elements, context)
  // 补全方括号
  push(']')
}
  1. 拼接return,将node.return的ReturnStatement节点递归genNode生成代码
js 复制代码
function genReturnStatement(node, context) {
  const { push } = context
  // 追加 return 关键字和空格
  push(`return `)
  // 调用 genNode 函数递归地生成返回值代码
  genNode(node.return, context)
}
  1. 拼接字符串
js 复制代码
function genStringLiteral(node, context) {
  const { push } = context
  // 对于字符串字面量,只需要追加与 node.value 对应的字符串即可
  push(`'${node.value}'`)
}
  1. 将CallExpression节点转成渲染函数返回值代码,callee.name做为函数名,args传入genNodeList转为字符串格式
js 复制代码
function genCallExpression(node, context) {
  const { push } = context
  // 取得被调用函数名称和参数列表
  const { callee, arguments: args } = node
  // 生成函数调用代码
  push(`${callee.name}(`)
  // 调用 genNodeList 生成参数代码
  genNodeList(args, context)
  // 补全括号
  push(`)`)
}

结果:

js 复制代码
compile(`<div><p>Vue</p><p>Template</p></div>`)

模版AST以及对应的JavaScript AST 一开始为空数组

node.body遍历递归genNode 最终生成渲染函数

相关推荐
沙漏无语2 分钟前
npm : 无法加载文件 D:\Nodejs\node_global\npm.ps1,因为在此系统上禁止运行脚本
前端·npm·node.js
supermapsupport3 分钟前
iClient3D for Cesium在Vue中快速实现场景卷帘
前端·vue.js·3d·cesium·supermap
brrdg_sefg5 分钟前
WEB 漏洞 - 文件包含漏洞深度解析
前端·网络·安全
胡西风_foxww11 分钟前
【es6复习笔记】rest参数(7)
前端·笔记·es6·参数·rest
m0_7482548812 分钟前
vue+elementui实现下拉表格多选+搜索+分页+回显+全选2.0
前端·vue.js·elementui
星就前端叭1 小时前
【开源】一款基于Vue3 + WebRTC + Node + SRS + FFmpeg搭建的直播间项目
前端·后端·开源·webrtc
m0_748234521 小时前
前端Vue3字体优化三部曲(webFont、font-spider、spa-font-spider-webpack-plugin)
前端·webpack·node.js
Web阿成1 小时前
3.学习webpack配置 尝试打包ts文件
前端·学习·webpack·typescript
苹果醋32 小时前
Golang的文件加密工具
运维·vue.js·spring boot·nginx·课程设计
jwensh2 小时前
【Jenkins】Declarative和Scripted两种脚本模式有什么具体的区别
运维·前端·jenkins