上篇我们了解了 PEG.js
的基础使用,忘记的童鞋建议复习一下,对于本文的食用效果会更佳哦!
光说不练,等于白学。所以本文来实现一个编译器(瞎搞、玩具、欢乐)。
我们知道 Vue
的 template
不支持中文标签名,比如下面这段代码:
<下拉框 选中值="番茄" :数据="{
"list":[
{
"名称": "🍅",
"id": "番茄"
},
{
"名称": "🍌",
"id": "香蕉"
}
],
"total": 2
}">
<子组件></子组件>
</下拉框>
使用 astexplorer[1] 生成的结果:
可以看到,vue-template-compiler[2] 将 <下拉框>
组件识别成了文本。我们的需求来了:要将上述代码编译成跟其他组件一样的 AST 。先看下正确被编译的组件 AST:
我们重点关注 type
、tag
、children
和 attrs
这四个属性,其他字段都是一些附加信息。因为本文重点是编译逻辑,其他字段都可以基于 PEG.js
的 action
去添加,所以不会详细讲解。
下面我们就来实现上图中的 zh-template-compiler
。
基于上述需求,可以分析得到我们需要识别的词法跟语法:
vue2
的模板编译中,通过正则和栈去维护开始标签和结束标签的关系,没有接触过的童鞋可以前往模板编译 了解。PEG.js
则可以直接通过规则去匹配;props
识别成 ast
中的 name
和 value
的形式,并且能够区分静态属性和动态属性(v-bind
);对于复杂类型的 value
(比如对象),期望能够表现得更好,而不是仅仅当作字符串处理;我们习惯用单测去了解框架的最小最细粒度功能,梳理场景也一样可以用这个方法。
针对上述分析的第一个需求,我们可以写出以下用例(😉 自闭合组件的逻辑没有处理哦,感兴趣的童鞋可以 fork
项目[3]去练练手):
const { parse } = require('../src/zh-template-compiler')
describe('zh-template-compiler', () => {
test('不带属性的组件', () => {
const template = `<组件></组件>`
const ast = parse(template)
expect(ast.attrs.length).toBe(0)
expect(ast.children.length).toBe(0)
expect(ast.type).toBe(1)
expect(ast.tag).toBe('组件')
})
test('包含子组件', () => {
const template = `<组件><子组件></子组件></组件>`
const ast = parse(template)
expect(ast).toMatchSnapshot()
})
test('包含多个子组件', () => {
const template = `<组件><第一个子组件></第一个子组件><第二个子组件></第二个子组件></组件>`
const ast = parse(template)
expect(ast).toMatchSnapshot()
})
test('包含多层子组件', () => {
const template = `<组件><子组件><孙子组件></孙子组件></子组件></组件>`
const ast = parse(template)
expect(ast).toMatchSnapshot()
})
})
第二个需求,识别中文的 props 并区分静态和动态:
describe('zh-template-compiler', () => {
// ...接上述代码
test('带静态属性的组件', () => {
const template = `<组件 属性="值"></组件>`
const ast = parse(template)
const attr = ast.attrs
expect(attr.length).toBe(1)
expect(attr[0]).toEqual({
isBind: false,
name: "属性",
value: "值"
})
})
test('带动态属性的组件', () => {
const template = `<组件 :属性="值"></组件>`
const ast = parse(template)
const attr = ast.attrs
expect(attr.length).toBe(1)
expect(attr[0]).toEqual({
isBind: true,
name: "属性",
value: "值"
})
})
test('复杂的属性值', () => {
const template = `
<下拉框 选中值="番茄" :数据="{
"list":[
{
"名称": "🍅",
"id": "番茄"
},
{
"名称": "🍌",
"id": "香蕉"
}
],
"total": 2
}">
<子组件></子组件>
</下拉框>`
const ast = parse(template)
expect(ast).toMatchSnapshot()
})
test('带静态+动态属性的组件', () => {
const template = `<组件 静态属性="静态属性的值" :动态属性="动态属性的值"></组件>`
const ast = parse(template)
const attrs = ast.attrs
expect(attrs.length).toBe(2)
expect(attrs).toEqual([{
isBind: false,
name: "静态属性",
value: "静态属性的值"
}, {
isBind: true,
name: "动态属性",
value: "动态属性的值"
}])
})
})
最后组件名和属性名只能包含中文的用例比较简单:
describe('zh-template-compiler', () => {
// ...接上述代码
test('组件名称只能包含汉字', () => {
const template = `<组件1></组件1>`
try {
parse(template)
} catch (e) {
console.log(e)
expect(e.message).toBe('Expected ":", ">", or [一-龥] but "1" found.')
}
})
test('属性名称只能包含汉字', () => {
const template = `<组件 属性1="值1"></组件>`
try {
parse(template)
} catch (e) {
console.log(e)
expect(e.message).toBe('Expected \"=\" or [一-龥] but \"1\" found.')
}
})
})
开始先写入口规则:
Program
= program:Tag {
return program;
}
还记得前文提到 --allowed-start-rules
的配置,如果没有配置默认就从第一条规则开始执行。紧接着就是核心的规则定义:
// 一个完整的模板定义
// ws 即空白符,开始标签前随便你输入几个空白字符
// StartTag,开始标签的匹配
// children: (Tag*) 很关键,很关键,很关键!!!反复匹配 Tag 规则。
// EndTag,结束标签的匹配
// 最后的 action 即处理函数很关键,拿到匹配信息你可以做任何的判断、格式化
// 比如这里的 start 和 end 标签的 tag 不一致即组件名不一致,那必须报错。vue2中是通过栈去维护的这个关系,可以看到 PEG.js 的处理更加简洁。
Tag
= ws
start:StartTag
children: (Tag*)
end:EndTag
ws
{
if (start.tag !== end.tag) {
throw Error('开始标签和结束标签不一致')
}
return {
...start,
children
}
}
// 开始标签和属性
// component:$zh 组件名只能是中文,zh = [\u4e00-\u9fa5]+ 匹配一个以上的汉字,有个细节,zh 前面有一个 $,这里拿到的 component 是一个匹配的中文字符串,如果不加这个 $,那拿到的是一个匹配数组。忘记这个语法的童鞋可以回到上篇再回顾哦
// attrsList: (...)* 匹配任意个 attr,并存入 attrList
// attrs:Attrs 匹配单个组件属性
// 最后 action 处理返回了一个对象,这里的 type = 1 跟 vue2 中的 VNode 保持一致,表示的是组件类型
StartTag
= "<"
ws
component:$zh
attrList: (
ws
attrs:Attrs
ws
{
if (attrs.name) {
return attrs
}
}
)*
">" {
return {
type: 1,
tag: component,
attrs: attrList
}
}
// 结束标签
EndTag
= "</"
component:$zh
">"
{
return {
tag: component
}
}
// 匹配中文字符
zh = [\u4e00-\u9fa5]+
// 匹配组件属性
// isBind:name_separator ? 匹配到 : 就返回对应的串,然后返回 null
// attrName:$zh+ 属性名称是一个中文字符串
// quotation_mark 引号
// attrValue:( $zh / JSON_text ) 属性值可以是一个中文,或者是一个 JSON 文本,JSON_text 是利用了上篇文章中那个定义哦,想了解的可以回去上文查看注释。
// 全部匹配完成之后返回匹配对象
Attrs
= isBind:name_separator ?
attrName:$zh+
"="
quotation_mark
attrValue:(
$zh / JSON_text
)
quotation_mark {
if (attrName) {
let hasVbind = isBind ? true : false
return {
isBind: hasVbind,
name: attrName,
value: attrValue
}
}
}
核心的规则定义就如上述代码所示,难点在解析子组件那里,通过利用 rule
递归(类似函数递归)的思路去解决的话就变得 so easy
。
最后,将上述规则生成编译器:
npx pegjs -o zh-template-compiler.js src/zh-template-compiler.pegjs
文章开头的 🌰 生成的 AST 结果如下:
{
"type": 1,
"tag": "下拉框",
"attrs": [
{
"isBind": false,
"name": "选中值",
"value": "番茄"
},
{
"isBind": true,
"name": "数据",
"value": {
"list": [
{
"名称": "🍅",
"id": "番茄"
},
{
"名称": "🍌",
"id": "香蕉"
}
],
"total": 2
}
}
],
"children": [
{
"type": 1,
"tag": "子组件",
"attrs": [],
"children": []
}
]
}
执行测试用例结果如下图所示:
最简单的一个中文模板编译器就完成了。通过这个练习,相信你对 PEG.js
的基础掌握得更加熟练,也能够利用它去解决日常开发中的一些问题。读完本文,想继续细化该编译器的童鞋可以 fork
zh-template-compiler[4] 接着玩哦~
下篇文章将会基于 AST 结果去生成页面上真实的下拉框,如果是你,你会怎么做?
[1]
astexplorer: https://astexplorer.net/
[2]
vue-template-compiler: https://www.npmjs.com/package/vue-template-compiler
[3]
项目: https://github.com/Jouryjc/zh-template-compiler
[4]
zh-template-compiler: https://github.com/Jouryjc/zh-template-compiler