前言
原文来自我的个人博客
接下来的几章中我们将要在 mini-vue
中 实现一个自己的编译器,在上一章我们了解了 compiler
的作用和大致流程。
它主要经历了以下三个大的步骤:
- 解析(
parse
)template
模板,生成AST
- 转化(
transform
)AST
,得到JavaScript AST
- 生成(
generate
)render
函数
那么本章我们就先来实现编译器的第一步:依据模板生成 AST
抽象语法树
ps:
compiler
是一个非常复杂的概念,我们不会实现一个完善的编译器,而是会像之前一样严格遵循:
没有使用就当做不存在 和
最少代码的实现逻辑 这两个标准。只关注
核心 和
当前业务 相关的内容,而忽略其他。
1. 扩展知识:JavaScript与有限自动状态机
想要实现 compiler
第一步是构建 AST
对象。那么想要构建 AST
,就需要利用到 有限状态机 的概念。
有限状态机也被叫做 有限自动状态机,表示:有限个状态以及在这些状态之间的转移和动作等行为的数学计算模型)
光看概念,可能难以理解,那么下面我们来看一个具体的例子:
根据 packages/compiler-core/src/compile.ts
中的代码可知,ast
对象的生成是通过 baseParse
方法得到的。
const ast = isString(template) ? baseParse(template, options) : template
而对于 baseParse
方法而言,它接收一个 template
作为参数,返回一个 ast
对象。
即:通过 parse
方法,解析 template
,得到 ast
对象。 中间解析的过程,就需要使用到 有限自动状态机。
比如,vue
想要把如下模板解析成 AST
,那么就需要利用有限自动状态机对该模板进行分析
<div>hello world</div>
分析的过程中主要包含了三个特性:摘自阮一峰:JavaScript与有限状态机
状态总数(
state
)是有限的。- 初始状态
- 标签开始状态
- 标签名称状态
- 文本状态
- 结束标签状态
- 结束标签名称状态
- ...
- 任一时刻,只处在一种状态之中。
- 某种条件下,会从一种状态转变(
transition
)到另一种状态。
如下图所示:
- 解析
<
:由 初始状态 进入 标签开始状态 - 解析
div
:由 标签开始状态 进入 标签名称状态 - 解析
>
:由 标签名称状态 进入 初始状态 - 解析
hello world
:由 初始状态 进入 文本状态 - 解析
<
:由 文本状态 进入 标签开始状态 - 解析
/
:由 标签开始状态 进入 结束标签状态 - 解析
div
:由 结束标签状态 进入 结束标签名称状态 - 解析
>
:由 结束标签名称状态 进入 初始状态
经过这样一些列的解析,对于:
<div>hello world</div>
而言,我们将得到三个 token
:
开始标签:<div>
文本节点:hello world
结束标签:</div>
而这样一个利用有限自动状态机的状态迁移,来获取 tokens
的过程,可以叫做:对模板的标记化
总结
这一小节,我们了解了什么是有限自动状态机,也知道了它的三个特性。
vue
利用它来实现了对模板的标记化,得到了对应的 token
。
关于这些 token
有什么用,我们下一小节说。
2. 扩展知识:扫描 tokens 构建 AST 结构的方案
在上一小节中,我们已经知道可以通过自动状态机解析模板为 tokens
,而解析出来的 tokens
就是生成 AST
的关键。
生成 AST
的过程,就是 tokens
扫描的过程。
我们以以下 html
结构为例:
<div>
<p>hello</p>
<p>world</p>
</div>
该 html
可以被解析为如下 tokens
:
开始标签:<div>
开始标签:<p>
文本节点:hello
结束标签:</p>
开始标签:<p>
文本节点:world
结束标签:</p>
结束标签:</div>
具体的扫描过程为:
1. 初始状态:
2. 扫描开始标签 <div>
,在 AST
下 会生成 Element<div>
3. <p>hello</p>
标签依次入栈, AST
生成对应 Element<p>
<p>hello</p>
标签依次出栈,<p>world</p>
标签依次入栈,AST
生成对应Element<p>
5. </div>
结束标签入栈再与<div>
开始标签组合依次出栈。
6. 结束状态
在以上的图示中,我们通过 递归下降算法? 这样的一种扫描形式把 tokens
通过 栈 解析成了 AST(抽象语法树)
。
3. 源码阅读:依据模板生成 AST 抽象语法树
前面做了这么多铺垫,我们中行与可以来看一下 vue
中生成 AST
的代码了。vue
的这部分代码全部放在了 packages/compiler-core/src/parse.ts
中:
从这个文件可以看出,整体 parse
的逻辑非常复杂,整体文件有 1175
行代码。
通过 packages/compiler-core/src/compile.ts
中的 baseCompile
方法可以知道,整个 parse
的过程是从 baseParse
开始的,所以我们可以直接从这个方法开始进行 debugger
。
创建测试实例:
<script>
const { compile, h, render } = Vue
// 创建 template
const template = `<div>hello world</div>`
// 生成 render 函数
const renderFn = compile(template)
</script>
当前的 template
对应的目标极简 AST
为(我们不再关注其他的属性生成):
const ast = {
"type": 0,
"children": [
{
"type": 1,
"tag": "div",
"tagType": 0,
// 属性,目前我们没有做任何处理。但是需要添加上,否则,生成的 ats 放到 vue 源码中会抛出错误
"props": [],
"children": [{ "type": 2, "content": " hello world " }]
}
],
// loc:位置,这个属性并不影响渲染,但是它必须存在,否则会报错。所以我们给了他一个 {}
"loc": {}
}
模板解析的 token
流程为(以 <div>hello world</div>
为例):
1. <div
2. >
3. hello world
4. </div
5. >
明确好以上内容之后,我们开始。Here we go
- 进入
baseParse
方法:
- 上图描述的应该比较详细了,在 baseParse 方法中,程序首先会进入 createParserContext 方法中,这个方法会返回一个 ParserContext 类型的对象,这个对象比较复杂,我们只需要关注 source(模板源代码)属性即可
- 此时
context.source = "<div> hello world </div>"
,程序接着执行:
- 从
createParserContext
方法跳出后,程序接着执行getCursor
方法,这个方法主要获取loc
(即:location
位置),与我们的极简 `AST 无关,无需关注,接着调试:
- 接着,程序会先后执行
parseChildren
、getSelection
、createRoot
三个方法,最后返回ast
- 执行
parseChildren
方法(解析子节点) ,这个方法 非常重要,是生成AST
的核心方法:
parseChildren
方法中的核心逻辑都在while
循环中,它的目的就是循环解析模板数据,生成AST
中的children
的,最后生成的nodes
:(关于while
循环中的逻辑是很复杂的,碍于篇幅这里做了点省略,具体逻辑可以参考我下面的框架实现)
- 接下来执行
getSelection
方法:
- 这个函数非常简单,也不是那么重要,我们只要知道是关于
location
的生成就行了。 - 最后,执行
createRoot
方法,这个方法也非常简单,就是整合了前两个函数生成的children
和loc
成一个RootNode
对象(最后的AST
)并返回
4. 框架实现
AST
对象的生成颇为复杂,我们把整个过程分为成三步进行处理。
- 构建
parse
方法,生成context
实例 构建
parseChildren
,处理所有子节点(最复杂)- 构建有限自动状态机解析模板
- 扫描
token
生成AST
结构
- 生成
AST
,构建测试
4.1 构建 parse 函数,生成 context 实例
- 创建
packages/compiler-core/src/compile.ts
模块,写入如下代码:
export function baseCompile(template: string, options) {
return {}
}
- 创建
packages/compiler-dom/src/index.ts
模块,导出compile
方法:
import { baseCompile } from 'packages/compiler-core/src/compile'
export function compile(template: string, options) {
return baseCompile(template, options)
}
- 在
packages/vue/src/index.ts
中,导出compile
方法:
export { render, compile } from '@vue/runtime-dom'
- 创建
packages/compiler-core/src/parse.ts
模块下创建baseParse
方法:
/**
* 基础的 parse 方法,生成 AST
* @param content tempalte 模板
* @returns
*/
export function baseParse(content: string) {
return {}
}
- 在
packages/compiler-core/src/compile.ts
模块下的baseCompile
中,使用baseParse
方法:
import { baseParse } from './parse'
export function baseCompile(template: string, options) {
const ast = baseParse(template)
console.log(JSON.stringify(ast))
return {}
}
至此,我们就成功的触发了 baseParse
。接下来我们去生成 context
上下文对象。
- 在
packages/compiler-core/src/parse.ts
中创建createParserContext
方法,用来生成上下文对象:
/**
* 创建解析器上下文
*/
function createParserContext(content: string): ParserContext {
// 合成 context 上下文对象
return {
source: content
}
}
- 创建
ParserContext
接口:
/**
* 解析器上下文
*/
export interface ParserContext {
// 模板数据源
source: string
}
- 在
baseParse
中触发该方法:
export function baseParse(content: string) {
// 创建 parser 对象,未解析器的上下文对象
const context = createParserContext(content)
console.log(context)
return {}
}
至此我们成功得到了 context
上下文对象。
创建测试实例 packages/vue/examples/compiler/compiler-ast.html
:
<script>
const { compile } = Vue
// 创建 template
const template = `<div> hello world </div>`
// 生成 render 函数
const renderFn = compile(template)
</script>
可以成功打印 context
4.2 构建有限自动状态机解析模板,扫描 token 生成 AST 结构
接下来我们通过 parseChildren
方法处理所有的子节点,整个处理的过程分为两大块:
- 构建有限自动状态机解析模板
- 扫描
token
生成AST
结构
接下来我们来进行实现:
- 创建
parseChildren
方法:
/**
* 解析子节点
* @param context 上下文
* @param mode 文本模型
* @param ancestors 祖先节点
* @returns
*/
function parseChildren(context: ParserContext, ancestors) {
// 存放所有 node节点数据的数组
const nodes = []
/**
* 循环解析所有 node 节点,可以理解为对 token 的处理。
* 例如:<div>hello world</div>,此时的处理顺序为:
* 1. <div
* 2. >
* 3. hello world
* 4. </
* 5. div>
*/
while (!isEnd(context, ancestors)) {
/**
* 模板源
*/
const s = context.source
// 定义 node 节点
let node
if (startsWith(s, '{{')) {
}
// < 意味着一个标签的开始
else if (s[0] === '<') {
// 以 < 开始,后面跟a-z 表示,这是一个标签的开始
if (/[a-z]/i.test(s[1])) {
// 此时要处理 Element
node = parseElement(context, ancestors)
}
}
// node 不存在意味着上面的两个 if 都没有进入,那么我们就认为此时的 token 为文本节点
if (!node) {
node = parseText(context)
}
pushNode(nodes, node)
}
return nodes
}
以上代码中涉及到了 五 个方法:
isEnd
:判断是否为结束节点startsWith
:判断是否以指定文本开头pushNode
:为array
执行push
方法- 复杂:
parseElement
:解析element
- 复杂:
parseText
:解析text
- 我们先实现前三个简单方法:
- 创建
startsWith
方法:
/**
* 是否以指定文本开头
*/
function startsWith(source: string, searchString: string): boolean {
return source.startsWith(searchString)
}
- 创建
isEnd
方法:
/**
* 判断是否为结束节点
*/
function isEnd(context: ParserContext, ancestors): boolean {
const s = context.source
// 解析是否为结束标签
if (startsWith(s, '</')) {
for (let i = ancestors.length - 1; i >= 0; --i) {
if (startsWithEndTagOpen(s, ancestors[i].tag)) {
return true
}
}
}
return !s
}
/**
* 判断当前是否为《标签结束的开始》。比如 </div> 就是 div 标签结束的开始
* @param source 模板。例如:</div>
* @param tag 标签。例如:div
* @returns
*/
function startsWithEndTagOpen(source: string, tag: string): boolean {
return (
startsWith(source, '</') &&
source.slice(2, 2 + tag.length).toLowerCase() === tag.toLowerCase() &&
/[\t\r\n\f />]/.test(source[2 + tag.length] || '>')
)
}
- 创建
pushNode
方法:
/**
* nodes.push(node)
*/
function pushNode(nodes, node): void {
nodes.push(node)
}
至此三个简单的方法都被构建完成。
接下来我们来处理 parseElement
,在处理的过程中,我们需要使用到 NodeTypes
和 ElementTypes
这两个 enum
对象,所以我们需要先构建它们(直接从 vue 源码复制):
- 创建
packages/compiler-core/src/ast.ts
模块:
/**
* 节点类型(我们这里复制了所有的节点类型,但是我们实际上只用到了极少的部分)
*/
export const enum NodeTypes {
ROOT,
ELEMENT,
TEXT,
COMMENT,
SIMPLE_EXPRESSION,
INTERPOLATION,
ATTRIBUTE,
DIRECTIVE,
// containers
COMPOUND_EXPRESSION,
IF,
IF_BRANCH,
FOR,
TEXT_CALL,
// codegen
VNODE_CALL,
JS_CALL_EXPRESSION,
JS_OBJECT_EXPRESSION,
JS_PROPERTY,
JS_ARRAY_EXPRESSION,
JS_FUNCTION_EXPRESSION,
JS_CONDITIONAL_EXPRESSION,
JS_CACHE_EXPRESSION,
// ssr codegen
JS_BLOCK_STATEMENT,
JS_TEMPLATE_LITERAL,
JS_IF_STATEMENT,
JS_ASSIGNMENT_EXPRESSION,
JS_SEQUENCE_EXPRESSION,
JS_RETURN_STATEMENT
}
/**
* Element 标签类型
*/
export const enum ElementTypes {
/**
* element,例如:<div>
*/
ELEMENT,
/**
* 组件
*/
COMPONENT,
/**
* 插槽
*/
SLOT,
/**
* template
*/
TEMPLATE
}
下面就可以构建 parseElement
方法了 ,该方法的作用主要为了解析 Element
元素:
- 创建
parseElement
:
/**
* 解析 Element 元素。例如:<div>
*/
function parseElement(context: ParserContext, ancestors) {
// -- 先处理开始标签 --
const element = parseTag(context, TagType.Start)
// -- 处理子节点 --
ancestors.push(element)
// 递归触发 parseChildren
const children = parseChildren(context, ancestors)
ancestors.pop()
// 为子节点赋值
element.children = children
// -- 最后处理结束标签 --
if (startsWithEndTagOpen(context.source, element.tag)) {
parseTag(context, TagType.End)
}
// 整个标签处理完成
return element
}
- 构建
TagType
enum
:
/**
* 标签类型,包含:开始和结束
*/
const enum TagType {
Start,
End
}
- 处理开始标签,构建
parseTag
:
/**
* 解析标签
*/
function parseTag(context: any, type: TagType): any {
// -- 处理标签开始部分 --
// 通过正则获取标签名
const match: any = /^<\/?([a-z][^\r\n\t\f />]*)/i.exec(context.source)
// 标签名字
const tag = match[1]
// 对模板进行解析处理
advanceBy(context, match[0].length)
// -- 处理标签结束部分 --
// 判断是否为自关闭标签,例如 <img />
let isSelfClosing = startsWith(context.source, '/>')
// 《继续》对模板进行解析处理,是自动标签则处理两个字符 /> ,不是则处理一个字符 >
advanceBy(context, isSelfClosing ? 2 : 1)
// 标签类型
let tagType = ElementTypes.ELEMENT
return {
type: NodeTypes.ELEMENT,
tag,
tagType,
// 属性,目前我们没有做任何处理。但是需要添加上,否则,生成的 ats 放到 vue 源码中会抛出错误
props: []
}
}
- 解析标签的过程,其实就是一个自动状态机不断读取的过程,我们需要构建
advanceBy
方法,来标记进入下一步:
/**
* 前进一步。多次调用,每次调用都会处理一部分的模板内容
* 以 <div>hello world</div> 为例
* 1. <div
* 2. >
* 3. hello world
* 4. </div
* 5. >
*/
function advanceBy(context: ParserContext, numberOfCharacters: number): void {
// template 模板源
const { source } = context
// 去除开始部分的无效数据
context.source = source.slice(numberOfCharacters)
}
至此 parseElement
构建完成。此处的代码虽然不多,但是逻辑非常复杂。在解析的过程中,会再次触发 parseChildren
,这次触发表示触发 文本解析,所以下面我们要处理 parseText
方法。
- 创建
parseText
方法,解析文本:
/**
* 解析文本。
*/
function parseText(context: ParserContext) {
/**
* 定义普通文本结束的标记
* 例如:hello world </div>,那么文本结束的标记就为 <
* PS:这也意味着如果你渲染了一个 <div> hell<o </div> 的标签,那么你将得到一个错误
*/
const endTokens = ['<', '{{']
// 计算普通文本结束的位置
let endIndex = context.source.length
// 计算精准的 endIndex,计算的逻辑为:从 context.source 中分别获取 '<', '{{' 的下标,取最小值为 endIndex
for (let i = 0; i < endTokens.length; i++) {
const index = context.source.indexOf(endTokens[i], 1)
if (index !== -1 && endIndex > index) {
endIndex = index
}
}
// 获取处理的文本内容
const content = parseTextData(context, endIndex)
return {
type: NodeTypes.TEXT,
content
}
}
- 解析文本的过程需要获取到文本内容,此时我们需要构建
parseTextData
方法:
/**
* 从指定位置(length)获取给定长度的文本数据。
*/
function parseTextData(context: ParserContext, length: number): string {
// 获取指定的文本数据
const rawText = context.source.slice(0, length)
// 《继续》对模板进行解析处理
advanceBy(context, length)
// 返回获取到的文本
return rawText
}
最后在 baseParse
中触发 parseChildren
方法:
此时运行测试实例,打印出如下内容:
4.3 生成 AST,测试
当 parseChildren
处理完成之后,我们可以到 children
,那么最后我们就只需要利用 createRoot
方法,把 children
放到 ROOT
节点之下即可。
- 创建
createRoot
方法:
/**
* 生成 root 节点
*/
export function createRoot(children) {
return {
type: NodeTypes.ROOT,
children,
// loc:位置,这个属性并不影响渲染,但是它必须存在,否则会报错。所以我们给了他一个 {}
loc: {}
}
}
- 在
baseParse
中使用该方法:
/**
* 基础的 parse 方法,生成 AST
* @param content tempalte 模板
* @returns
*/
export function baseParse(content: string) {
// 创建 parser 对象,未解析器的上下文对象
const context = createParserContext(content)
const children = parseChildren(context, [])
return createRoot(children)
}
至此整个 parse
解析流程完成。我们可以在 packages/compiler-core/src/compile.ts
中打印得到的 AST
:
export function baseCompile(template: string, options) {
const ast = baseParse(template)
console.log(JSON.stringify(ast), ast)
return {}
}
得到的内容为:
{
"type": 0,
"children": [
{
"type": 1,
"tag": "div",
"tagType": 0,
"props": [],
"children": [{ "type": 2, "content": " hello world " }]
}
],
"loc": {}
}
我们可以把得到的该 AST
放入到 vue
的源码中进行解析,以此来验证是否正确。
在 vue
源码的 packages/compiler-core/src/compile.ts
模块下 baseCompile
方法中:
运行源码的 compile
方法,浏览器中依然可以渲染 hello world
:
<script>
const { compile, h, render } = Vue
// 创建 template
const template = `<div>hello world</div>`
// 生成 render 函数
const renderFn = compile(template)
// 创建组件
const component = {
render: renderFn
}
// 通过 h 函数,生成 vnode
const vnode = h(component)
// 通过 render 函数渲染组件
render(vnode, document.querySelector('#app'))
</script>
成功运行,标记着我们的 AST
处理完成。