15.2 parser 的实现原理与状态机
parser 逐个字符扫描模板,靠一个有限状态自动机把整串字符切割成一个个 Token。
一、模板编译器的三个部件
| 部件 | 职责 |
|---|---|
| 解析器 parser | 把模板字符串解析为模板 AST |
| 转换器 transformer | 把模板 AST 转换为 JavaScript AST |
| 生成器 generator | 根据 JavaScript AST 生成渲染函数代码 |
parser 的入参是字符串模板:它逐个读取字符,按一定规则把整个字符串切割成一个个 Token。
二、为什么需要状态机
- 有限状态:状态的数量是有限的(初始、标签开始、标签名称、文本、结束标签、结束标签名称……)。
- 自动机:随着字符的输入,解析器会自动地在不同状态间迁移。
- 运行方式:从初始状态开始,看一个字符 → 判断要不要迁移状态、要不要产出 Token → 消费该字符,继续读下一个字符,直到字符串被消费尽。
WHATWG 发布的浏览器解析 HTML 规范已经定义了非常详细的状态迁移过程,例如:
| 当前状态 | 读到的字符 | 迁移到 |
|---|---|---|
| Data State(初始状态) | < | tag open state(标签开始状态) |
| tag open state | 字母 | tag name state(标签名称状态) |
| tag name state | > | Data State(并产出一个开始标签 Token) |
| Data State | 字母 | Data State(把字符累积进缓冲区) |
| tag open state | / | 结束标签开始状态 |
Vue.js 的模板是类 HTML 的实现,尽量按规范来做不会有什么坏处。 正则表达式的本质就是有限自动机:编写正则表达式其实就是在编写有限自动机。
三、状态定义与辅助函数
// 状态机的状态
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')
}
四、tokenize 的实现
// 接收模板字符串,返回 Token 列表
function tokenize(str) {
let currentState = State.initial // 当前状态
const chars = [] // 用于缓存字符
const tokens = [] // 产出的 Token
// 只要字符串没被消费尽,自动机就一直运行
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('') }) // 产出开始标签 Token
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('') }) // 产出文本 Token
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('') }) // 产出结束标签 Token
chars.length = 0
str = str.slice(1)
}
break
}
}
return tokens
}
五、Token 只有三种
| type | 产出时机 | 字段 |
|---|---|---|
tag | 标签名称读完遇到 > | name |
tagEnd | 结束标签名称读完遇到 > | name |
text | 文本状态下遇到 < | content |
tokenize('<div>hel<p>vue</p>lo</div>')
// [
// { type: 'tag', name: 'div' },
// { type: 'text', content: 'hel' },
// { type: 'tag', name: 'p' },
// { type: 'text', content: 'vue' },
// { type: 'tagEnd', name: 'p' },
// { type: 'text', content: 'lo' },
// { type: 'tagEnd', name: 'div' }
// ]
易错点:
str[0]只是查看,是否消费该字符由各分支决定;缓存字符必须chars.join('')取出后立即chars.length = 0清空,否则下一个 Token 会带上上一段残留字符。
相关
- 15.1 模板 DSL 的编译器:编译器三大阶段与 AST 的基本概念
- 15.3 构造AST:把 Token 列表还原成模板 AST