Vue.js Mustache模板引擎原理
什么是模板引擎?
模板引擎是将数据渲染到视图上最优雅的解决方法。
现在有一个对象数组,我们要把它渲染成一个列表:1 | var arr = [ |
目标效果:

在模板引擎之前有这几种方法:
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<body>
<ul id="list"></ul>
<script>
var arr = [
{ name: "小红", "age": "11", sex: "女" },
{ name: "小黄", "age": "12", sex: "男" },
{ name: "小明", "age": "13", sex: "男" }
];
var list = document.getElementById("list");
for (var i = 0; i < arr.length; i++) {
var li = document.createElement("li");
var hdDiv = document.createElement("div");
hdDiv.className = "hd";
hdDiv.innerHTML = arr[i].name + '的基本信息';
var bdDiv=document.createElement("div");
bdDiv.className="bd";
var p1=document.createElement("p");
p1.innerHTML="姓名:"+arr[i].name;
var p2=document.createElement("p");
p2.innerHTML="年龄:"+arr[i].age;
var p3=document.createElement("p");
p3.innerHTML="性别:"+arr[i].sex;
bdDiv.appendChild(p1);
bdDiv.appendChild(p2);
bdDiv.appendChild(p3);
li.appendChild(hdDiv);
li.appendChild(bdDiv);
list.appendChild(li);
}
</script>
</body>数组
join
法:代码比DOM法清晰,运用了数组的join
方法,提高了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<body>
<ul id="list"></ul>
<script>
var arr = [
{ name: "小红", "age": "11", sex: "女" },
{ name: "小黄", "age": "12", sex: "男" },
{ name: "小明", "age": "13", sex: "男" }
];
var list = document.getElementById("list");
for (var i = 0; i < arr.length; i++) {
list.innerHTML += [
'<li>',
' <div class="hd">'+ arr[i].name+'的基本信息</div>',
' <div class="bd">',
' <p>姓名:'+ arr[i].name+'</p>',
' <p>年龄:'+ arr[i].age+'</p>',
' <p>性别:'+ arr[i].sex+'</p>',
' </div>',
'</li>'
].join("");
}
</script>
</body>ES6模板字符串法,代码更加清晰
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24<body>
<ul id="list"></ul>
<script>
var arr = [
{ name: "小红", "age": "11", sex: "女" },
{ name: "小黄", "age": "12", sex: "男" },
{ name: "小明", "age": "13", sex: "男" }
];
var list = document.getElementById("list");
for (var i = 0; i < arr.length; i++) {
list.innerHTML += `
<li>
<div class="hd">${arr[i].name}的基本信息</div>
<div class="bd">
<p>姓名:${arr[i].name}</p>
<p>年龄:${arr[i].age}</p>
<p>性别:${arr[i].sex}</p>
</div>
</li>`
}
</script>
</body>
可以看到虽然代码变得越来越简单可读,但是在每一次渲染的时候都需要对要渲染的数据进行循环,写法不够优雅。下面是实现同样的效果,用Mustache是怎么写的:
1 | import Mustache from './lib/mustache.js'; |
可以看出使用Mustache写法干净了很多,不用再对数据进行for
循环,而是在模板中使用{{#arr}}
、{{/arr}}
来表示循环,再通过Mustache.render(template, data)
一条语句即可生成填充好数据的HTML语句。
Mustache基本语法
渲染基础数据
1 | import Mustache from './lib/mustache.js' |
渲染普通数组
在模板中使用.代表数组中的每一个元素
1 | <body> |
渲染布尔值
这里就有点像v-if
的用法,当bool
为true
,里面的li
才会显示。
1 | <body> |
我们可以把模板给写在 <script type="text/template">
里面,这样子不会被渲染到页面中,里面的语句也不会被执行,同时也拥有编辑器的提示。类似Vue的<template>
。
Mustache原理
Mustache不能用正则实现!
在进行简单的数据填充时,其实可以用正则来实现。先看一下JavaScript中字符串的replace
方法,平时大多数时候使用的时候,replace
方法的第一个参数是要被替换的值,第二个参数是要替换成的值,其实replace
方法的第二个参数可以是一个回调函数:
1 | var template = "我叫张三,我今年18岁了。"; |
可以看到回调函数的第一个参数是匹配的值,第二个参数是匹配的值的位置,第三个参数是原串。这个回调也可以对正则进行捕获:
1 | var template = "<h1>我叫{{name}},今年{{age}}岁了。</h1>"; |
这时第一个参数是匹配的值,第二个参数是捕获的值。获取到捕获的值后,就是Mustache中的键名,所以可以这样写:
1 | var template = "<h1>我叫{{name}},今年{{age}}岁了。</h1>"; |
可以看到模板已经被成功渲染出数据了。但是Mustache原理并不是这个,当遇到循环、布尔等复杂数据时,用正则就不起作用了。
Mustache的思想

Mustache的作用就是把模板字符串给转换成DOM节点,在转化的过程中,引入了tokens
这一概念,用来做为中间人,Mustache先把模板字符串转化成tokens
,然后将要渲染的数据与tokens
结合,然后再把tokens
给解析成DOM节点。
看一下tokens
打开Mustache的源码,找到parseTemplate
这个函数,这个就是将模板字符串转化成tokens
的函数。拉到函数的末尾,将函数的返回值打印出来。
1 | var tokens = nestTokens(squashTokens(tokens)); |
然后用上一节的例子,对模板字符串进行解析:
1 | import Mustache from "./lib/mustache.js"; |
查看控制台,tokens被打印出来了:
1 | [ |
这里的tokens
是一个二维数组,子数组就是一个token
,token
分为四部分,是一个元素是token
的类别,text
表示纯文本,name
表示一个值,就是要渲染的数值,第二个元素是这个token
的值,第三个和第四个元素是token
的起始坐标和结束坐标,并没有起到作用。
再看一下有循环的tokens
:
1 | <script type="text/template" id="template"> |
1 | [ |
有循环的tokens
是一个多维数组,其中循环的token
的类型是#
,然后里面又包含了其他几个token
。
Scanner
接下来开始写Mustache的底层源码,想要把模板字符串变成tokens
,首先要对模板字符串进行扫描,在Mustache源码中,定义了一个Scanner
类,用于对模板字符串进行扫描。
Scanner
类有两个重要属性,一个是pos
,这个属性是指针,记录了扫描器扫描模板字符串的位置;另一个是tail
,这个属性是指针之后的字符串。
Scanner
有两个扫描方法,一个叫做scan
,一个是scanUntil
,主要用于扫描的函数是scanUntil
函数,函数接收一个stopTag
,以此做为pos
移动的标识,当pos
移动到stopTag
时,停止扫描,并且返回扫描到的内容。scan函数接收stopTag
,并且将pos
前移stopTag
的长度,没有返回值。
下面是它的具体实现:
1 | export default class Scanner { |
parseTemplate
然后我们创建parseTemplate
函数,parseTemplate
函数中接受一个模板字符串,把它交给一个Scanner
实例进行扫描,然后根据扫描结果生成tokens
。函数从头开始扫描字符串,当扫描到{{`时,就停止扫描,把扫描到的内容添加到`tokens`中,做为`text`;然后跳过`{{`,继续扫描,直到扫描到`}}
,把扫描到的内容添加到tokens
中,做为name
,重复上面的步骤,直到字符串的结尾。
1 | import Scanner from "./Scanner.js"; |
这时候已经可以生成简单的token
了。新建一个全局对象TemplateEngine
来测试一下这个函数,这个其实就是我们写的Mustache,包含一个函数render
,用来将数组渲染到模板字符串中,先在render
函数中调用parseTemplate
函数,测试一下:
1 | import parseTemplate from './parseTemplate.js'; |
1 | import TemplateEngine from "../src/index.js"; |
可以看到这时打印出来的tokens:
1 | [ |
这时的tokens
是正确的,但这只是简单使用场景,如果模板字符串中有嵌套,这时就行不通了:
1 | var template = ` |
1 | [ |
可以看到循环的部分没有被正确转换,因此还需要改写一下parseTemplate
函数,当函数获取到{{}}
里的内容时,要对内容的首字符进行判断:
1 | export default function parseTemplate(templateStr) { |
现在tokens
里的循环token
并不完全,但是可以看出tokens
循环的变量了:
1 | [ |
接下来就是要把零散的循环token
嵌套起来。
nestTokens
nestTokens
函数的作用是把零散的token嵌套起来,在nestTokens
中,我们遍历parseTemplate
生成的token
数组,然后判断每一个token
的第一个元素,如果第一个元素是#
或者/
,那就要对该token
进行特殊的处理,如果是普通的token
,就不做处理。
函数中引入了一个栈(sections
),一个收集器(collector
),用来收集token
,collector
是一个可变的数组,收集tokens
直到循环结束,然后把收集到的tokens
放到栈顶的数组中。
collector
初始是指向nestedTokens
的,它运用到的JavaScript的引用类型的特性,在下面代码中,collector
和nestedTokens
指向的是同一个数组,往collector
里插入元素,就相当于在nestedToken
里插入元素。
1 | var nestedTokens = []; |
当循环只是循环到普通token
时,直接往collector
中push
这个token
。
当循环遇到#时,也是往collector
里push
这个token
,并且也要向sections
里push
这个token
,用来记录当前循环的层数,同时还要把collector
指向当前token
的坐标为2的元素,也就是一个数组,用来收集这个循环token
的子项。
当循环遇到/
时,sections
栈顶的元素出栈,然后collector
指向section
栈顶的token
的坐标为2的元素,如果sections
为空,那就把collector
指向nestedTokens
。
下面是具体的代码实现:
1 | export default function nestTokens(tokens) { |
将数据插入进tokens
里
上面我们已经完成了将模板字符串转变为token
的过程了,接下来我们要完成的就是将数据插入到tokens
中。思路就是遍历tokens
,然后根据每一个token
的类型来判断对该token
的操作:如果token
的类型是text
,那么结果字符串就直接追加这个字符串;如果是name
,那么就从data
中查找这个数据,并追加它的值;如果是#
,那就要对这个token
进行一个递归的操作。
首先要解决当token
类型为name
时的问题,现在有一个对象:
1 | var data = { |
我们要获取a.b.c
的值,如果我们直接用data["a.b.c"]
来获取,是获取不到的,因为JavaScript没有解析.运算符的能力,所以我们要创建一个函数来解析a.b.c
这样带点的路径字符串,我们把它叫做lookup
函数,lookup
函数接受一个数据对象和一个键名,先要判断键名中有没有.,并且键名不是.,然后对键名以.进行分割,然后对分割结果遍历,类似与数据响应式原理中的parsePath
函数:
1 | export default function lookup(data, key) { |
然后我们可以写renderTemplate
函数,用于生成DOM字符串:
1 | import parseArray from "./parseArray.js"; |
这里需要注意的是循环的情况,需要新建一个parseArray
函数对循环token进行处理,parseArray
函数接受当前的循环token
和data
对象,然后通过lookup
函数获取到被循环的数组,接着循环该数组,因为数组有多少个元素,就要生成多少个DOM字符串,然后在循环中递归调用renderTemplate
函数生成DOM字符串:
1 | import renderTemplate from "./renderTemplate.js"; |
至此模板引擎已经完全开发完毕了。
总结
Mustache的原理其实并不难,关键是要理解其中模板字符串->tokens->DOM的思想。
我们写一个实例看看:
1 | <body> |
这时已经可以正确生成DOM字符串了:
1 | <ul> |
代码
index.js
1
2
3
4
5
6
7
8
9
10
11
12import parseTemplate from './parseTemplate.js';
import renderTemplate from './renderTemplate.js';
var TemplateEngine = {
render: function (templateStr, data) {
var tokens = parseTemplate(templateStr);
var domStr = renderTemplate(tokens, data);
return domStr;
}
};
export default TemplateEngine;lookup.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14// 用于获取数据
export default function lookup(data, key) {
if (key.indexOf('.') != -1 && key != '.') {
console.log(key);
let keys = key.split('.');
let result = data;
for (let i = 0; i < keys.length; i++) {
result = result[keys[i]];
}
return result;
}
return data[key];
}nestTokens.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
25
26
27
28
29
30
31
32
33
34export default function nestTokens(tokens) {
// 结果数组
var nestedTokens = [];
// 栈结构,用于存放tokens
var sections = [];
// 收集器,收集tokens,直到遇到结束标签,然后把收集到的tokens放到栈顶的数组中
var collector = nestedTokens;
for (let i = 0; i < tokens.length; i++) {
let token = tokens[i];
switch (token[0]) {
case "#":
// 收集器收集token
collector.push(token);
// 栈收集token,用来记录嵌套的层级
sections.push(token);
// 把收集器指向当前token[2],用于收集循环的token,进入下一层
// sections的栈顶的token[2]就是collector
collector = token[2] = [];
break;
case "/":
sections.pop();
// 收集器指向sections的栈顶,用于收集结束标签后的token,如果sections为空,collector指向nestedTokens
collector = sections.length > 0 ? sections[sections.length - 1][2] : nestedTokens;
break;
default:
collector.push(token);
}
}
return nestedTokens;
}parseArray.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16import renderTemplate from "./renderTemplate.js";
import lookup from "./lookup.js";
export default function parseArray(token, data) {
var resultStr = "";
// 获取被循环的数组
var v = lookup(data, token[1]);
for (let i = 0; i < v.length; i++) {
resultStr += renderTemplate(token[2], {
...v[i],
".": v[i]
});
}
return resultStr;
}parseTemplate.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
25
26
27import Scanner from "./Scanner.js";
import nestTokens from "./nestedTokens.js";
export default function parseTemplate(templateStr) {
let scanner = new Scanner(templateStr);
let tokens = [];
let word;
while (!scanner.eos()) {
word = scanner.scanUntil("{{");
scanner.scan("{{");
if (word != '') {
tokens.push(["text", word]);
}
word = scanner.scanUntil("}}");
scanner.scan("}}");
if (word != '') {
if (word[0] == "#") {
tokens.push(['#', word.substring(1)]);
} else if (word[0] == "/") {
tokens.push(['/', word.substring(1)]);
} else {
tokens.push(["name", word]);
}
}
}
return nestTokens(tokens);
}renderTemplate.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17import parseArray from "./parseArray.js";
import lookup from "./lookup.js";
export default function renderTemplate(tokens, data) {
var resultStr = '';
for (let i = 0; i < tokens.length; i++) {
let token = tokens[i];
if (token[0] == 'text') {
resultStr += token[1];
} else if (token[0] == 'name') {
resultStr += lookup(data, token[1]);
} else if (token[0] == '#') {
resultStr += parseArray(token, data);
}
}
return resultStr;
}Scanner.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
25
26
27
28
29
30
31
32
33
34
35export default class Scanner {
constructor(templateStr) {
this.templateStr = templateStr;
this.pos = 0;
this.tail = templateStr;
}
// 跳过指定内容
scan(tag) {
if (this.tail.indexOf(tag) == 0) {
this.pos += tag.length;
this.tail = this.templateStr.substring(this.pos);
}
}
// 让指针进行扫描,直到遇见指定内容结束,并且能够返回结束之前路过的文字
scanUntil(stopTag) {
// 记录一下执行本方法的时候pos的值
const pos_backup = this.pos;
// 当尾巴的开头不是stopTag的时候,就说明没有扫描到stopTag,继续扫描
while (this.tail.indexOf(stopTag) !== 0 && !this.eos()) {
// 每扫描一个字符,pos就要向后移动一位
this.pos++;
// 更新尾字符串
this.tail = this.templateStr.substring(this.pos);
}
// 返回扫描到的字符串
return this.templateStr.substring(pos_backup, this.pos);
}
// 判断指针是否已经到头,end of string
eos() {
return this.pos >= this.templateStr.length;
}
}