Vue3 compiler之parser与语义分析

152 阅读15分钟

Vue.js 的编译器(Compiler)是 Vue.js 的一部分,用于将 Vue.js 模板(template)转换为可执行的 JavaScript 渲染函数。Vue.js 提供了两种构建版本:包含编译器的版本(Full Version)和不包含编译器的版本(Runtime-only Version)。

Vue.js 的编译器内部工作流程,它将 Vue.js 的模板转换为渲染函数。这个过程可以被大致分为三个阶段:解析(Parsing)、转换(Transforming)和生成(Code Generation)。

解析(Parsing):

在解析阶段,Vue.js 的编译器会将模板字符串解析成一个抽象语法树(AST)。AST 是一个树状结构的对象,它用来表示代码的抽象语法结构。在 Vue.js 中,这个 AST 描述了模板的结构,包括标签、属性、文本内容等。这个阶段的目标是将模板的文本内容转换为一个易于操作的数据结构,以便后续的处理。

转换(Transforming):

在转换阶段,编译器会对 AST 进行处理和优化。这个阶段的主要任务是识别 Vue.js 的特定语法,进行静态标记(Static Markup)以及一些优化操作。静态标记是指识别那些在渲染过程中不会改变的部分,这些部分可以在编译时进行计算,从而提高渲染的性能。在这个阶段,也会处理一些 Vue.js 特定的指令、事件绑定等,以确保它们在渲染时能够正确地工作。

生成(Code Generation):

在生成阶段,编译器会将优化后的 AST 转换为最终的渲染函数。这个渲染函数是一个 JavaScript 函数,它接收一个参数(createElement 函数或者 hyperscript 函数),并返回一个虚拟 DOM。当渲染函数被调用时,它会根据虚拟 DOM 的描述创建真实的 DOM 元素。在这个阶段,AST 被转换为一系列的 JavaScript 代码,这些代码负责生成渲染函数的执行逻辑。

总结来说,Vue.js 的编译器通过解析、转换和生成这三个阶段,将模板转换为可执行的渲染函数。这个渲染函数最终被用于创建用户界面,并在数据变化时进行更新。这种编译的过程是 Vue.js 实现响应式数据绑定和组件化开发的基础。

parser函数

实现一个简单的分词器(tokenizer)来将模板字符串解析成 tokens 数组。下面是一个迷你实现的示例代码,实现了基本的逻辑,处理了开始标签、结束标签和普通文本的情况。

在这个示例中,tokenize 函数接受一个模板字符串作为输入,然后遍历字符串的每个字符,根据特定的规则将字符串分割成 tokens。分隔符包括 <、>、/ 和空格。每个 token 包含两个属性:type 表示 token 的类型('tagstart'、'tagend' 或 'text'),value 表示 token 的实际内容。

function tokenizer(input) {
  let tokens = []
  let type = ''
  let val = ''
  // 粗暴循环
  for (let i = 0; i < input.length; i++) {
    let ch = input[i]
    if (ch === '<') {
      push()
      if (input[i + 1] === '/') {
        type = 'tagend'
      } else {
        type = 'tagstart'
      }
    } if (ch === '>') {
      if(input[i-1]=='='){
        //箭头函数
      }else{
        push()
        type = "text"
        continue
      }
    } else if (/[\s]/.test(ch)) { // 碰见空格截断一下
      push()
      type = 'props'
      continue
    }
    val += ch
  }
  return tokens
  function push() {
    if (val) {
      if (type === "tagstart") val = val.slice(1) // <div => div
      if (type === "tagend") val = val.slice(2) // </div => div
      tokens.push({
        type,
        val
      })
      val = ''
    }
  }
}

分词器中,根据特定的规则,输入字符串会被切分为不同类型的标记,例如开始标签、结束标签、文本和属性。

function tokenizer(input) { ... }:这是一个名为 tokenizer 的函数,它接受一个输入字符串 input,并返回解析后的 tokens 数组。

let tokens = []:tokens 是一个空数组,用于存储解析后的 tokens。

let type = '' 和 let val = '':type 用于存储当前 token 的类型('tagstart'、'tagend'、'text'、'props'),而 val 用于存储当前 token 的值。

type 变量:

  • type 用于表示当前 token 的类型,它可以取以下几种值:
  • 'tagstart':表示标签的开始(例如:
    )。
  • 'tagend':表示标签的结束(例如:)。
  • 'text':表示文本内容,即标签之间的文本。
  • 'props':表示属性,即标签中的属性部分。

在分词器中,根据当前字符的不同,type 的取值会在各个条件语句中被设定,以便识别当前 token 的类型。

val 变量:

val 用于存储当前 token 的具体内容或值。在分词器的逻辑中,val 会不断地被追加,构成当前 token 的内容。当遇到特定字符(如 <、>、空格等)时,表示当前 token 结束,此时val 的内容就代表了完整的当前 token。

例如,当遇到 < 字符时,可能表示标签的开始,此时 val 可能存储的是标签的名称(例如:div)。当遇到 > 字符时,可能表示标签的结束,此时 val 可能存储的是标签之间的文本内容。或者当遇到空格字符时,可能表示属性的分隔,此时 val 可能存储的是属性的具体值。

这两个变量的作用是在分词器的解析过程中,暂时存储当前 token 的类型和值。当一个完整的 token 解析完成后,它们的值将被用于构建一个 token 对象,然后将这个对象放入 tokens 数组中,以便后续的处理和分析。

for (let i = 0; i < input.length; i++) { ... }:这是一个循环,遍历输入字符串中的每个字符。

let ch = input[i]:ch 是当前循环迭代的字符。

if (ch === '<') { ... }:如果当前字符是 <,表示可能遇到了标签的开始。在这里,push() 函数被调用,将之前的 token 推入 tokens 数组,然后根据下一个字符是 /(表示结束标签)还是其他情况(表示开始标签),设置 type 为相应的类型。

if (ch === '<') { ... }:这是一个条件语句,用于判断当前字符 ch 是否为 <。在 HTML 或 XML 中,< 字符通常表示一个标签的开始。

如果当前字符是 <,表示可能遇到了标签的开始。

push() 函数被调用:在这个条件语句中,push() 函数会被调用。这个函数的作用是将之前解析得到的部分作为一个 token 存入 tokens 数组中。为什么要在这里调用 push() 函数呢?因为当前字符是 <,标志着之前的标记(比如文本或属性)的结束,我们需要将这个标记存储为 token。

推入 tokens 数组后,根据下一个字符是 / 还是其他情况,设置 type 为相应的类型。如果下一个字符是 /,则表示可能遇到了结束标签(比如 ),所以 type 被设置为 "tagend"。如果下一个字符不是 /,则表示可能遇到了开始标签(比如

),所以 type 被设置为 "tagstart"。

综合来说,这段代码的作用是在遇到 < 字符时,将之前解析得到的部分存储为 token,并根据下一个字符是 / 还是其他字符来确定标记的类型(开始标签或结束标签)。这样,分词器就能够识别标签的开始。

if (ch === '>') { ... }:如果当前字符是 >,表示标签结束。在这里,如果前一个字符是 =(表示箭头函数),则不处理;否则,调用 push() 函数将之前的 token 推入 tokens 数组,并将 type 设置为 "text",表示文本内容。

/[\s]/.test(ch):如果当前字符是空格,表示可能遇到了属性的分隔符。在这里,调用 push() 函数将之前的 token 推入 tokens 数组,并将 type 设置为 "props",表示属性。

val += ch:将当前字符添加到 val 中,构建当前 token 的值。

function push() { ... }:这是一个内部函数,用于将当前的 type 和 val 组成一个 token,并推入 tokens 数组。在这个函数中,如果 type 是 "tagstart",则去掉值的第一个字符 <;如果 type 是 "tagend",则去掉值的前两个字符 </。然后,将 { type, val } 推入 tokens 数组,然后重置 val 为空字符串,以便构建下一个 token。

内部函数 push() 的作用:

push() 函数的主要作用是将当前的 type 和 val 组成一个 token 对象,并将这个对象推入 tokens 数组中。每当我们遇到一个标记的结束(比如 <、> 或空格),就调用 push() 函数将之前的部分作为一个 token 存入数组。

处理不同类型的标记:

如果 type 是 "tagstart",表示我们遇到了开始标签,所以我们需要将值的第一个字符 < 去掉,以获取标签的名称。

如果 type 是 "tagend",表示我们遇到了结束标签,所以我们需要将值的前两个字符 </ 去掉,以获取结束标签的名称。

构建 token 对象:

在 push() 函数中,我们首先根据 type 的不同,对 val 进行相应的处理(去掉前缀字符),然后将 { type, val } 这个对象推入 tokens 数组中。这样,我们就成功地将一个完整的标记解析成了一个 token 对象,其中 type 表示标记的类型("tagstart"、"tagend"、"props" 或 "text"),val 表示标记的具体内容。

重置 val 为空字符串:

在推入数组后,为了构建下一个 token,我们将 val 重置为空字符串,以便接下来的循环迭代可以重新开始构建新的标记。

这个 push() 函数是分词器中非常关键的一部分,它负责将解析出来的标记处理成一个个的 token,方便后续的语法分析或其他处理。

最后,函数返回 tokens 数组,其中包含了解析后的 tokens。

请注意,这个分词器是相当简单的,只能处理基本的情况,并且在实际应用中可能需要更多的逻辑来处理各种特殊情况和错误。

语义分析

抽象语法树(AST)的进一步处理,即语义分析和优化阶段。在 Vue.js 中,模板(template)中的语法需要被正确解析和理解,以便后续的编译和运行。以下是对这段描述的详细解释:

抽象语法树(AST)的生成:

在前面的步骤中,我们已经将模板字符串解析成了抽象语法树(AST)。这棵树是模板结构的抽象表示,包含了模板中的标签、属性、文本等信息。

语义分析是指对抽象语法树进行进一步的分析,理解模板中的语句要做的事情。在 Vue.js 的模板中,大括号 {{}} 包裹的字符串表示变量,而 @click 表示事件监听。语义分析的任务就是识别这些语法结构,确定它们的含义和作用。

在 Vue.js 的模板中,大括号 {{}} 包裹的字符串被用作模板插值(Template Interpolation)。这表示在渲染阶段,这部分会被动态地替换为数据中相应的值。例如,{{ message }} 会在渲染时被替换为变量 message 的实际值。这个过程就是语义分析的一部分。编译器需要理解这个结构的含义,即将模板中的数据绑定到实际的数据源。

另外,@click 是一个事件监听的语法。在 Vue.js 中,它表示一个 DOM 事件监听器,通常用于绑定用户交互事件,比如点击事件。语义分析在这里的任务是理解 @click 的含义,即在渲染的 DOM 元素上添加一个点击事件监听器。

总的来说,语义分析是为了更深入地理解代码的意义。在 Vue.js 模板中,这包括了理解模板中的变量、事件监听、指令等语法结构,并将它们映射到实际的 JavaScript 行为或 DOM 操作。通过语义分析,编译器能够准确地知道模板中的各种语法结构应该如何被翻译成最终的运行时行为,从而确保应用程序的正确运行。

使用 transform 函数进行语义分析:

在描述中提到了使用 transform 函数来实现语义分析的功能。这个函数的任务是遍历 AST,识别模板中的特殊语法,比如 {{}} 包裹的变量和事件监听符号 @。在这个过程中,可能会引入一些辅助函数,比如 toDisplayString,用于将变量转换为字符串表示。这些函数通常被存储在上下文(context)对象中。

transform 函数承担着非常重要的角色。该函数的任务是对抽象语法树(AST)进行遍历,识别和处理模板中的特殊语法结构,比如 {{}} 包裹的变量和事件监听符号 @。在这个过程中,可能会引入一些辅助函数,比如 toDisplayString,该函数用于将变量转换为字符串表示。

Transform 函数的任务:

Transform 函数在编译过程中扮演了一个处理 AST 的角色。它遍历整个 AST,识别和处理与语义相关的特殊语法结构。在 Vue.js 中,这包括了模板中的插值语法 {{}} 和事件监听符号 @。通过遍历 AST,transform 函数可以定位这些语法结构在 AST 中的位置,为后续的代码生成阶段提供必要的信息和处理逻辑

辅助函数的引入:

在 transform 过程中,可能会引入一些辅助函数,比如 toDisplayString。这些辅助函数用于处理特定的语法结构。以 toDisplayString 为例,它通常用于将 JavaScript 变量转换为字符串表示。在模板中,当我们需要将一个变量的值转换为字符串时,这个辅助函数就派上用场。这样,transform 函数可以在需要的时候调用这些辅助函数,进行特定语法结构的处理。

上下文对象(Context)中的存储:

在 transform 过程中,这些辅助函数通常被存储在上下文对象中。上下文对象充当了一个共享的存储空间,在编译过程中,各个阶段的函数可以从上下文对象中获取必要的信息和工具函数。在语义分析的阶段,transform 函数可以从上下文对象中获取需要的辅助函数,以便处理特定的语法结构。

综合来说,transform 函数在 Vue.js 编译器中扮演着一个非常重要的角色,它负责识别和处理模板中的语法结构,为最终的代码生成阶段提供准确的处理逻辑和信息。引入辅助函数,将其存储在上下文对象中,使得编译器在处理特定语法时能够更加灵活和高效。

上下文对象(Context):

在语义分析阶段,我们需要维护一个上下文对象,其中包含了语义分析所需的信息。上下文对象可能包括以下内容:

helpers:存储一些辅助函数,比如 toDisplayString,用于处理特定的语法结构。其他必要的信息,用于在语义分析过程中进行条件判断、变量引用等操作。

在编译过程中的语义分析阶段,上下文对象(Context Object)是一个非常重要的概念。这个对象包含了在语义分析过程中所需的各种信息,以便在处理抽象语法树(AST)时能够准确地理解和解释模板中的语法结构。以下是上下文对象的主要组成部分的解释:

helpers 属性用于存储一些辅助函数,这些函数在语义分析过程中被用来处理特定的语法结构。例如,toDisplayString 是一个常用的辅助函数,它用于将变量转换为字符串表示。在语义分析中,当遇到模板中的 {{ expression }} 语法时,可能需要调用 toDisplayString 函数,将表达式的值转换为字符串,以便在模板中正确地显示。

上下文对象还可能包括其他各种信息,这些信息用于在语义分析过程中进行条件判断、变量引用等操作。这些信息的具体内容取决于编译器的实现和需要。例如,上下文对象可能包括当前作用域中的变量信息,用于判断模板中的变量是否在当前作用域内定义。它还可能包括事件处理器的信息,用于处理模板中的事件监听语法(比如 @click="handler")。

总之,上下文对象是一个存储在语义分析过程中所需信息的数据结构。它提供了编译器在分析模板时所需的上下文环境,以便准确地处理模板中的语法结构,最终生成能够正确运行的代码。上下文对象的设计和使用是编译器开发中非常重要的一部分,它直接影响了编译器的性能和正确性。

为最终生成的代码做准备:

语义分析的主要目的是为最终生成的代码做准备。通过识别模板中的语法结构,我们可以确定在生成代码时应该如何处理这些结构,包括如何处理变量、事件监听等,以确保最终生成的代码能够正确地渲染用户界面,并且能够响应用户的交互。

总结来说,语义分析是在抽象语法树的基础上进一步理解模板中的语法结构,为后续的代码生成阶段提供必要的信息和处理逻辑。这个阶段是编译过程中非常重要的一部分,它确保了 Vue.js 在运行时能够正确地解释和执行模板中的语法。