本文接上篇 《mustache 模板引擎 - 01》继续分享对于 mustache 的学习笔记
上篇我们大致介绍了什么是模板引擎并举了几个小例子帮助理解。本篇的主要内容则分为两大部分:mustache 的底层 token 思想,以及正式开始手写实现 mustache
1. mustache 的底层 token 思想
我们知道,mustache 模板引擎的作用是将字符串模板变为 dom 模板,最后结合数据挂载到 dom 树上,在页面渲染呈现。这个过程中,mustache 引入了一个名为 tokens 的概念,用来作为“中间人”。所谓一图胜千言,直接放图
简而言之, tokens 是模板字符串的 js 嵌套数组表示。它是一个数组,里面包含了很多个 token,每个 token 又是基于规则生成的一个数组。
当模板字符串中存在循环,它将编译为嵌套更深的 tokens:
我们可以通过修改 mustache 源码的方式直接在浏览器控制台打印输出 tokens。在源码中找到 parseTemplate
函数,然后在该函数的函数体末尾处,return 的 nestTokens(squashTokens(tokens))
其实就是 tokens。做如下的稍加修改,以便在浏览器中打印查看
const myTokens = nestTokens(squashTokens(tokens))
console.log(myTokens)
return myTokens
例如有这样一个模板字符串
const templateStr = `<div>{{name}}的基本信息</div>`
则最终打印输出的 tokens 将如下图所示
2. 开始手写实现 mustache
了解完 tokens,现在开始自己写一个 My_TemplateEngine
对象来实现之前文章中提到的 Mustache.render()
里的 Mustache
的功能。我们将把独立的功能拆写为独立的 js 文件,通常是一个独立的类,每个单独的功能都应能完成独立的单元测试。
实现将模板字符串编译为 tokens
实现 Scanner 类
Scanner
类的实例就是一个扫描器,用来扫描构造时作为参数提供的那个模板字符串。
属性
pos
:指针,用于记录当前扫描到字符串的位置;tail
:尾巴,值为当前指针之后的字符串(包括指针当前指向的那个字符)。
方法
scan
:无返回值,让指针跳过传入的结束标识stopTag
;scanUntil
:传入一个指定内容stopTag
作为让指针 pos 结束扫描的标识,并返回扫描内容。
// Scanner.js
export default class Scanner {
constructor(templateStr) {
this.templateStr = templateStr
// 指针
this.pos = 0
// 尾巴
this.tail = templateStr
}
scan(stopTag) {
this.pos += stopTag.length // 指针跳过 stopTag,比如 stopTag 是 {{,则 pos 就会加 2
this.tail = this.templateStr.substring(this.pos) // substring 不传第二个参数直接截取到末尾
}
scanUntil(stopTag) {
const pos_backup = this.pos // 记录本次扫描开始时的指针位置
// 当指针还没扫到最后面,并且当尾巴开头不是 stopTag 时,继续移动指针进行扫描
// 注意 && 的必要性,可以避免死循环的发生
while (!this.eos() && this.tail.indexOf(stopTag) !== 0){
this.pos++ // 移动指针
this.tail = this.templateStr.substring(this.pos) // 更新尾巴
}
return this.templateStr.substring(pos_backup, this.pos) // 返回扫描过的字符串,不包括 this.pos 处
}
// 指针是否已经抵达字符串末端,返回布尔值 eos(end of string)
eos() {
return this.pos >= this.templateStr.length
}
}
根据模板字符串生成 tokens
有了 Scanner
类后,就可以着手去根据传入的模板字符串生成一个 tokens
数组了。最终想要生成的 tokens
里的每一条 token 数组的第一项用 name
(数据) 或 text
(非数据文本) 或 #
(循环开始) 或 /
(循环结束) 作为标识符。
新建一个 parseTemplateToTokens.js 文件来实现:
// parseTemplateToTokens.js
import Scanner from './Scanner.js'
import nestTokens from './nestTokens' // 后面会解释
// 函数 parseTemplateToTokens
export default templateStr => {
const tokens = []
const scanner = new Scanner(templateStr)
let word
while (!scanner.eos()) {
word = scanner.scanUntil('{{')
word && tokens.push(['text', word]) // 保证 word 有值再往 tokens 里添加
scanner.scan('{{')
word = scanner.scanUntil('}}')
/**
* 判断从 {{ 和 }} 之间收集到的 word 的开头是不是特殊字符 # 或 /,
* 如果是则这个 token 的第一个元素相应的为 # 或 /, 否则为 name
*/
word && (word[0] === '#' ? tokens.push(['#', word.substr(1)]) :
word[0] === '/' ? tokens.push(['/', word]) : tokens.push(['name', word]))
scanner.scan('}}')
}
return nestTokens(tokens) // 返回折叠后的 tokens, 详见下文
}
在 index.js 引入 parseTemplateToTokens
// index.js
import parseTemplateToTokens from './parseTemplateToTokens.js'
window.My_TemplateEngine = {
render(templateStr, data) {
const tokens = parseTemplateToTokens(templateStr)
console.log(tokens)
}
}
这样我们就可以把传入的 templateStr 初步转成 tokens 了,比如 templateStr 为
const templateStr = `
<ul>
{{#arr}}
<li>
<div>{{name}}的基本信息</div>
<div>
<p>{{name}}</p>
<p>{{age}}</p>
<div>
<p>爱好:</p>
<ol>
{{#hobbies}}
<li>{{.}}</li>
{{/hobbies}}
</ol>
</div>
</div>
</li>
{{/arr}}
</ul>
`
那么目前经过 parseTemplateToTokens 处理将得到如下的 tokens:
接下去,就是要想办法,让循环的部分,也就是 #
和 /
之间的内容,作为以 #
为数组第 1 项元素的那个 token 的第 3 项元素。也就是把红框里的内容插入到 ["#","arr"] 内,成为其第 3 项元素;同理,将蓝框里的内容插入到 ["#","hobbies"] 内成为其第 3 项元素:
实现 tokens 的嵌套
新建 nestTokens.js 文件,定义 nestTokens
函数来做 tokens
的嵌套功能,将传入的 tokens
处理成包含嵌套的 nestTokens
数组返回。
然后在 parseTemplateToTokens.js 引入 nestTokens
,在最后 return nestTokens(tokens)
。
-
实现思路
在 nestTokens
中,我们遍历传入的 tokens
的每一个 token,遇到第一项是 #
和 /
的分别做处理,其余的做一个默认处理。大致思路是当遍历到的 token 的第一项为 #
时,就把直至遇到配套的 /
之前,遍历到的每一个 token 都放入一个容器(collector
)中,把这个容器放入当前 token 里作为第 3 项元素。
但这里有个问题:在遇到匹配的 /
之前又遇到 #
了怎么办?也就是如何解决循环里面嵌套循环的情况?
解决的思路是新建一个栈数据类型的数组(stack
),遇到一个 #
,就把当前 token 放入这个栈中,让 collector
指向这个 token 的第三个元素。遇到下一个 #
就把新的 token 放入栈中,collector
指向新的 token 的第三个元素。遇到 /
就把栈顶的 token 移出栈,collector
指向移出完后的栈顶 token。这就利用了栈的先进后出的特点,保证了遍历的每个 token 都能放在正确的地方,也就是 collector
都能指向正确的地址。
-
具体代码
// nestTokens.js
export default (tokens) => {
const nestTokens = []
const stack = []
let collector = nestTokens // 一开始让收集器 collector 指向最终返回的数组 nestTokens
tokens.forEach(token => {
switch (token[0]) {
case '#':
stack.push(token)
collector.push(token)
collector = token[2] = [] // 连等赋值
break
case '/':
stack.pop()
collector = stack.length > 0 ? stack[stack.length-1][2] : nestTokens
break;
default:
collector.push(token)
break
}
})
return nestTokens
}
One More Thing
上面的代码中有用到 collector = token[2] = []
,是为连等赋值,相当于
token[2] = []
collector = token[2]
看着简单,其实暗含着小坑,除非你真的了解它,否则尽量不要使用。比如我在别处看到这么一个例子,
let a = {n:1};
a.x = a = {n:2};
console.log(a.x); // 输出?
答案是 undefined,你做对了吗?
本篇为 vue 中用到的 {{}} 背后原理学习笔记,后续笔记《mustache 模板引擎 - 03》