mustache源码学习

Vue是前端从业人员绕不开的一个框架,随着学习的深入不免要通过观看Vue的源码深入学习,但是直接看Vue的源码并不是一件简单的事情,我们不如把Vue的各部分内容拆开来一点一点学习,由浅入深。

Vue的模板是一个很方便很受人喜欢的功能,mustache是一个比较老牌的模板引擎,跟Vue有很多相似之处,所以不妨先从mustache学起。

前排说明,关于mustache的内容是通过尚硅谷的一名讲师的课程学习的,感觉老师的教导。

模板引擎就是将数据变成视图的一种优雅的解决方案。

mustache基本用法

在学习mustache之前,我们要先了解mustache应该怎么用。

接下来给出几个小例子来简单说下用法,熟悉Vue的同学应该会感觉很亲切。

在md的代码块中写模板字符串,上传之后解析的格式有问题,大家可以复制放到编辑器中看。

基础应用: 渲染

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<body>
<div id="container"></div>
<script type="module">
// 记得把mustache的包引入进来哦
import mustache from './mustache.js'
// 模板
let templateStr = `
<h1>我买了新出的{{phone}}!</h1>
`
// 数据
let data = {
phone: 'Iphone 13'
}
// 通过模板和数据生成最终视图
let domStr = mustache.render(templateStr, data)
document.getElementById('container').innerHTML = domStr
</script>
</body>

基础应用:简单循环

1
2
3
循环通过{{ #循环的数组 }} {{/循环的数组}} 来指定要循环的数组
简单循环内部通过 {{ . }} 来指代循环拿到的内容
分别对应Vue中的v-for指令和循环的item
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<body>
<div id="container"></div>
<script type="module">
import mustache from './mustache.js'
// 模板
let templateStr = `
<ul>
{{#arr}}
<li>{{.}}</li>
{{/arr}}
</ul>
`
// 数据
let data = {
arr: ['苹果', '三星', '华为']
}
// 通过模板和数据生成最终视图
let domStr = mustache.render(templateStr, data)
document.getElementById('container').innerHTML = domStr
</script>
</body>

基础应用: 循环

1
2
可以直接使用循环内容中的属性
对应Vue中的 item.key 来拿到循环内容
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<body>
<div id="container"></div>
<script type="module">
import mustache from './mustache.js'
// 模板
let templateStr = `
<ul>
{{#arr}}
<li>
<p>姓名: {{name}}</p>
<p>年龄: {{age}}</p>
<p>性别: {{gender}}</p>
</li>
{{/arr}}
</ul>
`
// 数据
let data = {
arr: [
{name: '小明', age: 12, gender: 'male'},
{name: '小红', age: 13, gender: 'femail'}
]
}
// 通过模板和数据生成最终视图
let domStr = mustache.render(templateStr, data)
document.getElementById('container').innerHTML = domStr
</script>
</body>

基础应用:嵌套数组循环

1
2
在循环中在嵌套一层循环
对应Vue中 v-for 内部可以继续使用 v-for
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<body>
<div id="container"></div>
<script type="module">
import mustache from './mustache.js'
// 模板
let templateStr = `
<ul>
{{#arr}}
<li>
<p>姓名: {{name}}</p>
<p>年龄: {{age}}</p>
<p>
爱好:
{{#hobbies}}
<span>{{.}}</span>
{{/hobbies}}
</p>
</li>
{{/arr}}
</ul>
`
// 数据
let data = {
arr: [
{'name': '小明', 'age': 22, 'hobbies': ['游泳', '羽毛球']},
{'name': '小明', 'age': 22, 'hobbies': ['游泳', '羽毛球']},
{'name': '小明', 'age': 22, 'hobbies': ['游泳', '羽毛球']},
]
}
// 通过模板和数据生成最终视图
let domStr = mustache.render(templateStr, data)
document.getElementById('container').innerHTML = domStr
</script>
</body>

基础应用:布尔值

1
2
3
通过 {{#bool}}{{/bool}} 的方式,来根据布尔值决定是否需要显示内部包裹的内容
对应于 Vue 的 v-show
不过需要注意的是,mustach必须直接给定一个布尔值,而不能给一个算术运算,即便运算结果是一个布尔值,mustache并不会进行运算
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<body>
<div id="container"></div>
<script type="module">
import mustache from './mustache.js'
// 模板
let templateStr = `
<ul>
{{#show}}
<h1>我可以根据布尔值来显示和隐藏哦</h1>
{{/show}}
</ul>
`
// 数据
let data = {
// 为true则显示,为false则隐藏
show: true
}
// 通过模板和数据生成最终视图
let domStr = mustache.render(templateStr, data)
document.getElementById('container').innerHTML = domStr
</script>
</body>

mustache的底层思想

我在学习mustache的时候,第一个想到的就是通过正则表达式来实现,然而mustache库是不能用简单的正则表达式来实现的,而是通过将模板字符串转译为tokens,然后结合数据生成dom字符串实现。

正则表达式示例

正则表达式其实是可以实现一个比较简单的模板引擎的,但是比较完善的模板引擎肯定是不合适的。

在此就给出一个错误示例了,下面的内容只是通过正则表达式实现一个简单的渲染。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<body>
<div id="container"></div>
<script type="module">
let templateStr = `<h1>我买了一个{{thing}},好{{mood}}啊</h1>`
let data = {
thing: 'Iphone 13',
mood: '开心'
}
// 简单正则,仅匹配{{}}
let reg = /\{\{(\w+)\}\}/
// render函数
function render(templateStr, data){
return templateStr.replace(reg, function(_, str, c ,d){
// _ 匹配到的内容, str 圆括号捕获到的内容
// c 捕获到的内容的下标 d 原来的字符串
return data[str]
})
}
let domStr = render(templateStr, data)
document.getElementById('container').innerHTML = domStr
</script>
</body>

真正的核心: tokens

tokens是一个JS的嵌套数组,说白了就是模板字符串的Js表示

我们熟知的抽象语法树、虚拟节点也是学习了tokens的理念

假设我们有一个模板字符串

1
<h1>我买了一个{{thing}},好{{mood}}啊</h1>

mustache会将其解析为tokens,形式如下:

1
2
3
4
5
6
7
tokens = [ // 嵌套数组
["text", "<h1>我买了一个"],
["name", "thing"],
["text", ",好"],
["name", "mood"],
["text", "啊</h1>"]
]

当然这只是简单模板的情况,如果模板字符串更为复杂,比如有循环存在的时候,就会被编译为有更深层嵌套的tokens

假设我们有如下的模板字符串:

1
2
3
4
5
6
7
<div>
<ul>
{{#arr}}
<li>{{.}}</li>
{{/arr}}
</ul>
</div>

由于模板中有循环的存在,所以tokens也会有更深层的嵌套

1
2
3
4
5
6
7
8
9
tokens = [
["text", "<div><ul>"],
["#", "arr", [
["text", "<li>"],
["name", "."],
["text", "</li>"]
]],
["text", "</ul></div>"]
]

如果有双重循环呢?那么tokens的嵌套层次也随之更深一层。

我们知道了mustache会把模板字符串编译为tokens,但这远远不够,mustache还会将tokens结合数据解析为dom字符串,这个我们后续再说。

总结一下,mustache库底层主要做了两件事情:

  • 将模板字符串编译为tokens形式
  • 将tokens结合数据,解析为dom字符串

手写mustache库的简单实现

Scanner类

Scanner类用于扫描模板字符串

我们模仿mustache的用法,自己实现一个MyTemplate。我们的html内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
<body>
<script src="./src/main.js"></script>
<script>
// 模板字符串
let templateStr = "我买了一个{{thing}},好{{mood}}啊"
// 数据
let data = {
thing: '手机',
mood: '开心'
}
MyTemplate.render(templateStr, data)
</script>
</body>

声明Scanner类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// Scanner.js
/**
*
* 扫描器,扫描模板字符串
*/

export default class Scanner{
constructor(templateStr) {
console.log('我是Scanner类',templateStr)
// 模板
this.templateStr = templateStr
// 指针
this.pos = 0
// 尾巴--当前指针位置及其后边的内容
this.tail = templateStr
}

// 官方的scan方法,用于跳过花括号,然后在用scanUntil收集花括号内部内容
// 走过指定内容
scan(tag){
if (this.tail.indexOf(tag) == 0){
// tag有多长,指针就后移多少位
this.pos += tag.length
this.tail = this.templateStr.substring(this.pos)
}
}

// 官方的sacnUntil方法,用于收集非花括号的内容
// 指针进行扫描,直到遇见指定内容结束,并且能够返回之前路过的内容
scanUntil(stopTag){
// 记录每次开始执行本方法的时候pos的值
let pos_backup = this.pos
// 当尾巴的开头不是结束标记的时候,就说明还没扫描到,还要通过eos避免死循环
while (this.eos() && this.tail.indexOf(stopTag) != 0){
this.pos++ // 没找到stopTag则指针向后走
// 尾巴跟随指针的位置
this.tail = this.templateStr.substring(this.pos)
}
// 返回本次执行该方法路过的所有字符
return this.templateStr.substring(pos_backup, this.pos)
}

// 判断指针是否走完,避免 死循环
eos() {
return this.pos < this.templateStr.length
}
}

main.js是我们的js程序入口文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// main.js
// 引入扫描器
import Scanner from './Scanner.js'
// 全局提供MyTemplate
window.MyTemplate = {
render(templateStr, data){
// 先将模板字符串编译为tokens,通过Scanner类实现
// 实例化一个扫描器,模板字符串作为参数传递
let scanner = new Scanner(templateStr)
// let words = scanner.scanUntil('{{')
// console.log(scanner.pos)
// console.log(words)
// 交替执行scan和scanUntil方法,拿到模板的所有内容
while (scanner.pos != templateStr.length){
// 先拿到{{之前的内容,跳过{{再拿到}}之前的内容,再跳过}}以此往复
let words = scanner.scanUntil('{{')
console.log(words)
scanner.scan('{{')
words = scanner.scanUntil('}}')
console.log(words)
scanner.scan('}}')
}
}
}

将html变为tokens

当然接下来的内容还仅限于非嵌套的内容

我们不再通过main.js来调用Scanner,而是通过一个新的方法来实现

Scanner类不做修改,main.js移除对Scanner的调用

1
2
3
4
5
6
7
8
9
10
11
12
//main.js

// 引入
import parseTemplateToTokens from './parseTemplateToTokens.js'
// 全局提供MyTemplate
window.MyTemplate = {
render(templateStr, data){
// 通过parseTemplateToTokens()让模板字符变为tokens数组
let tokens = parseTemplateToTokens(templateStr)
console.log(tokens)
}
}

我们用于将Html变为tokens的新函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// parseTemplateToTokens.js
/**
*
* 将模板字符串转换为tokens
*/
// 引入扫描器
import Scanner from './Scanner.js'
export default function parseTemplateToTokens(templateStr){
let tokens = [];
let words;
// 创建扫描器
let scanner = new Scanner(templateStr)
// 让扫描器工作
while(scanner.eos()){
// 开始标记出现之前的
words = scanner.scanUntil('{{')
if(words != ''){
tokens.push(['text', words])
}
scanner.scan('{{')
// 标记之间的字符
words = scanner.scanUntil('}}')
if(words != ''){
// 判断{{}}中间的内容的首字符是否为#
if(words[0] == '#'){
tokens.push(['#', words.substring(1)])
// 判断是否为/
}else if (words[0] == '/'){
tokens.push(['/', words.substring(1)])
// 不是特殊符号,所以直接存起来
}else{
tokens.push(['name', words])
}
}
scanner.scan('}}')
}
return tokens;
}

嵌套数组的形式

截止到仙子啊,我们的parseTemplateToTokens方法仅仅将所有内容收集,但是并没有将循环内部的内容生成嵌套数组,所以我们需要对其进行一些修改。

parseTemplateToTokens不再直接返回tokens,而是返回nestTokens(tokens)

真正处理完成的tokensnestTokens函数返回

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// parseTemplateToTokens.js
/**
*
* 将模板字符串转换为tokens
*/
// 引入扫描器
import Scanner from './Scanner.js'
// 引入tokens折叠函数、
import nestTokens from './nestTokens.js';

export default function parseTemplateToTokens(templateStr){
let tokens = [];
let words;
// 创建扫描器
let scanner = new Scanner(templateStr)
// 让扫描器工作
while(scanner.eos()){
// 开始标记出现之前的
words = scanner.scanUntil('{{')
if(words != ''){
tokens.push(['text', words])
}
scanner.scan('{{')
// 标记之间的字符
words = scanner.scanUntil('}}')
if(words != ''){
// 判断{{}}中间的内容的首字符是否为#
if(words[0] == '#'){
tokens.push(['#', words.substring(1)])
// 判断是否为/
}else if (words[0] == '/'){
tokens.push(['/', words.substring(1)])
// 不是特殊符号,所以直接存起来
}else{
tokens.push(['name', words])
}
}
scanner.scan('}}')
}
return nestTokens(tokens);
}

而我们在nestTokens中使用栈的数据结构来方便的收集循环内部的内容,实现嵌套数组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// nestTokens.js
/**
*
* 折叠token,将tokens整合为嵌套数组
*/
export default function nestTokens(tokens){
// 结果数组
let nestedTokens = []
// 栈结构
let sections = []
// 收集器,默认指向nestedTokens数组,引用类型值,所以指向的是同一个数组
let collector = nestedTokens

for(let i=0; i<tokens.length; i++){
let token = tokens[i]
switch(token[0]){
case '#': // 碰到#,入栈
// 收集器中放入这个token,此时collector指向的数据也会放入这个token
// 就比如第一次碰到#,nestedTokens也会放入这个token
collector.push(token)
// 入栈
sections.push(token)
// 碰到# 更换收集器
// 给token设置下标为2的项,并且让收集器指向它
collector = token[2] = []
break;
case '/': // 碰到/ 出栈
sections.pop() // 弹出项
// 出栈之后改变收集器为栈顶的项
// 如果栈为空了,则指向nestedTokens
collector = sections.length > 0 ? sections[sections.length-1][2] : nestedTokens
break;
default:
collector.push(token)

}
}
return nestedTokens;
}

lookup函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// lookup.js
/**
*
* 用于查询data中的嵌套数据
* 可以在dataObj对象中,寻找用连续点符号的keyName对象
* 比如:
* dataObj = {
* a:{
* b: {
* c: 100
* }
* }
* }
* lookup(dataObj, 'a.b.c') 的结果应该是 100
*/
export default function lookup(dataObj, keyName){
if (keyName.indexOf('.') != -1){ //如果有.符号,但这里没有考虑完全,没有考虑循环中.指代数据的情况
// 临时的变量值用于逐层深入查找值
let temp = dataObj
let names = keyName.split('.'); // 'a.b.c' => ['a', 'b', 'c']
for(let i=0; i<names.length; i++){
temp = temp[names[i]]
}
return temp
}
// 如果没有.符号直接返回值
return dataObj[keyName]


}

renderTemplate函数用于将数组变为dom字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// renderTemplate.js
/**
*
* 函数的功能是让tokens数组变为dom字符串
*/
import lookup from "./lookup"
export default function renderTemplate(tokens, data){
// 结果字符串
let resultStr = ''
// 遍历tokens
for(let i = 0,length = tokens.length; i<length; i++){
let token = tokens[i]
// 判断类型对结果字符串进行拼接
if(token[0] == 'text'){
resultStr += token[1]
} else if(token[0] == 'name'){
// 使用lookup函数防止数据是嵌套的
resultStr += lookup(data,token[1])
}else if(token[0] == '#'){
// #则需要递归
// resultStr += renderTemplate()
}
}
return resultStr
}

在主程序main.js中引入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// main.js
// 引入
import parseTemplateToTokens from './parseTemplateToTokens.js'
import renderTemplate from './renderTemplate.js'
// 全局提供MyTemplate
window.MyTemplate = {
render(templateStr, data){
// 通过parseTemplateToTokens()让模板字符变为tokens数组
let tokens = parseTemplateToTokens(templateStr)
// 调用renderTemplate函数,让tokens数组变为dom字符串
let domStr = renderTemplate(tokens, data)
console.log(domStr)
}
}

完成递归内容

本部分对lookup进行修改,对parseArray进行了定义,对renderTemplate进行了补充,后边两个互相引入,递归调用,多理解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// lookup.js
/**
*
* 用于查询data中的嵌套数据
* 可以在dataObj对象中,寻找用连续点符号的keyName对象
* 比如:
* dataObj = {
* a:{
* b: {
* c: 100
* }
* }
* }
* lookup(dataObj, 'a.b.c') 的结果应该是 100
*/
export default function lookup(dataObj, keyName){
if (keyName.indexOf('.') != -1 && keyName != '.'){ //如果有.符号且不是.本身,因为简单数组循环中.可以指代数据
// 临时的变量值用于逐层深入查找值
let temp = dataObj
let names = keyName.split('.'); // 'a.b.c' => ['a', 'b', 'c']
for(let i=0; i<names.length; i++){
temp = temp[names[i]]
}
return temp
}
// 如果没有.符号直接返回值
return dataObj[keyName]


}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// renderTemplate.js
/**
*
* 函数的功能是让tokens数组变为dom字符串
*/
import lookup from "./lookup.js" // 解析嵌套数据
import parseArray from './parseArray.js' // 处理递归
export default function renderTemplate(tokens, data){
// 结果字符串
let resultStr = ''
// 遍历tokens
for(let i = 0,length = tokens.length; i<length; i++){
let token = tokens[i]
// 判断类型对结果字符串进行拼接
if(token[0] == 'text'){
resultStr += token[1]
} else if(token[0] == 'name'){
// 使用lookup函数防止数据是嵌套的
resultStr += lookup(data,token[1])
}else if(token[0] == '#'){
// #则需要递归
resultStr += parseArray(token, data)
}
}
return resultStr
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// parseArray.js
/**
*
* 处理数组,结合renderTemplate实现递归
*/
import lookup from "./lookup";
import renderTemplate from "./renderTemplate";
// 接收token,而不收tokens
export default function parseArray(token, data){
// 递归调用的次数由data的长度决定
let v = lookup(data, token[1]) // 拿到#后边的数组名
// 结果字符串
let resultStr = ''
// 遍历v这个数组
for(let i=0; i<v.length; i++){
// 递归调用renderTemplate函数
// 这里要补一个'.'属性,因为如果是一个简单数组,.要指代v[i]
resultStr += renderTemplate(token[2],{
// 所以我们现在是在v[i]展开的基础上,补一个.属性
// 用于解决简单数据,用.代替数据的方法
...v[i],
'.': v[i],
})
}
return resultStr

}

在主程序main.js中返回最后的DOM字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// main.js

// 引入
import parseTemplateToTokens from './parseTemplateToTokens.js'
import renderTemplate from './renderTemplate.js'
// 全局提供MyTemplate
window.MyTemplate = {
render(templateStr, data){
// 通过parseTemplateToTokens()让模板字符变为tokens数组
let tokens = parseTemplateToTokens(templateStr)
// 调用renderTemplate函数,让tokens数组变为dom字符串
let domStr = renderTemplate(tokens, data)

return domStr;
}
}

现在我们就可以在html中使用更复杂的模板,而且可以挂载到真实DOM

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<body>
<div id="container">

</div>
<script src="/xuni/bundle.js"></script>
<script>
// 模板字符串
let templateStr = `
<div>
<ol>
{{#students}}
<li>
学生{{name}}的爱好是
<ol>
{{#hobbies}}
<li>{{.}}</li>
{{/hobbies}}
</ol>
</li>
{{/students}}
</ol>
</div>
`
// 数据
let data = {
students: [
{
'name': '小明',
'hobbies': ['游泳', '健身']
},
{
'name': '小红',
'hobbies': ['足球', '篮球', '羽毛球']
},
{
'name': '小强',
'hobbies': ['吃饭', '睡觉']
}
]
}
// 拿到dom
let realDomStr = MyTemplate.render(templateStr, data)
// 渲染
document.getElementById('container').innerHTML = realDomStr
</script>
</body>
作者

胡兆磊

发布于

2021-11-16

更新于

2022-10-23

许可协议