16.3 状态机的开启与停止
用 isEnd 判定「当前状态机该不该继续消费」:模板解析完就停,遇到与自己同名的结束标签也停——而且要和整个父级节点栈比较,而不是只看栈顶。
一、状态机的开启与停止规则
- 当解析器遇到开始标签时:
- 会将该标签压入父级节点栈,同时通过递归调用
parseChildren开启新的状态机;
- 会将该标签压入父级节点栈,同时通过递归调用
- 当解析器遇到结束标签,并且父级节点栈中存在与该标签同名的开始标签节点时:
- 停止当前正在运行的状态机,控制权交还给上级状态机。
二、isEnd 的两种实现思路
以 <div><span></div></span> 为例:
- 直接比较结束标签的名称与栈顶节点的标签名称
- 这么做的确可行,但严格来讲有瑕疵;
- 「状态机 1」遇到
<div>→ 开启「状态机 2」;「状态机 2」遇到<span>→ 开启「状态机 3」; - 「状态机 3」遇到
</div>时,父级节点栈栈顶是span而不是div,于是状态机 3 不停止,反而遭遇了不符合预期的状态,抛出错误:「无效的结束标签」。
- 只要父级节点栈中存在与当前结束标签同名的节点,就停止状态机(本书采用)
- 判断时不应只与栈顶比较,而是与整个父级节点栈中的所有节点比较;
- 「状态机 3」遇到
</div>时发现栈中存在div,立即停止;随后上级状态机依次停止并完成闭合检查,最终在「完整的内容」部分解析完毕后,报告<span> 标签缺少闭合标签。
| 实现方式 | <div><span></div></span> 的报错 | 是否合理 |
|---|---|---|
| 只与栈顶比较 | 无效的结束标签 | ✗ 指向了错误的标签 |
| 与整个栈比较 | <span> 标签缺少闭合标签 | ✓ 指出了真正的问题 |
三、isEnd 的实现
function isEnd(context, ancestors) {
// 1. 模板内容解析完毕,停止
if (!context.source) return true
// 2. 与父级节点栈内所有节点做比较(不是只比栈顶)
for (let i = ancestors.length - 1; i >= 0; --i) {
// 只要栈中存在与当前结束标签同名的节点,就停止状态机
if (context.source.startsWith(`</${ancestors[i].tag}`)) {
return true
}
}
}
配套的 parseElement 需要在递归前后维护父级节点栈:
function parseElement(context, ancestors) {
const element = parseTag(context)
if (element.isSelfClosing) return element // 自闭合标签没有子节点
ancestors.push(element) // 入栈 → 开启新状态机
element.children = parseChildren(context, ancestors)
ancestors.pop() // 出栈
if (context.source.startsWith(`</${element.tag}`)) {
parseTag(context, 'end') // 消费结束标签
} else {
console.error(`${element.tag} 标签缺少闭合标签`)
}
return element
}
四、DATA 模式下遇到结束标签
- 在
parseChildren中,DATA 模式下遇到以</开头的内容,说明该结束标签缺少与之对应的开始标签:
} else if (source[1] === '/') {
// 缺少对应的开始标签
console.error('无效的结束标签')
continue
}
易错点:这条
continue分支若既不消费内容也不抛错,while就无法推进,会陷入死循环;实际实现中应考虑抛错退出。
相关
- 16.2 递归下降算法构造模板 AST:状态机、节点栈与
parseChildren的整体骨架 - 16.4 解析标签节点:开始/结束标签的统一解析
parseTag