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 最终生成渲染函数

相关推荐
别拿曾经看以后~1 小时前
【el-form】记一例好用的el-input输入框回车调接口和el-button按钮防重点击
javascript·vue.js·elementui
我要洋人死1 小时前
导航栏及下拉菜单的实现
前端·css·css3
科技探秘人1 小时前
Chrome与火狐哪个浏览器的隐私追踪功能更好
前端·chrome
科技探秘人1 小时前
Chrome与傲游浏览器性能与功能的深度对比
前端·chrome
JerryXZR1 小时前
前端开发中ES6的技术细节二
前端·javascript·es6
七星静香1 小时前
laravel chunkById 分块查询 使用时的问题
java·前端·laravel
q2498596931 小时前
前端预览word、excel、ppt
前端·word·excel
小华同学ai1 小时前
wflow-web:开源啦 ,高仿钉钉、飞书、企业微信的审批流程设计器,轻松打造属于你的工作流设计器
前端·钉钉·飞书
Gavin_9152 小时前
【JavaScript】模块化开发
前端·javascript·vue.js
懒大王爱吃狼3 小时前
Python教程:python枚举类定义和使用
开发语言·前端·javascript·python·python基础·python编程·python书籍