easycodesniper

blog by chen qiyi

Vue2模板引擎

mustache模板引擎

什么是模板引擎

先上结论:模板引擎是将数据变为视图的最优雅的方案
举一个例子,我们要将一下数据变成如下的视图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//数据
[
{name: 'cqy', sex: 'man'},
{name: 'kyrie', sex: 'man'},
]

//目标视图
<ul>
<li>
<div class="hd">cqy的基本信息</div>
<div class="bd">sex: man</div>
</li>
<li>
<div class="hd">kyrie的基本信息</div>
<div class="bd">sex: man</div>
</li>
</ul>

在历史上曾经出现的将数据变成视图的方法有:

  1. 纯DOM法
  2. 数组join()法
  3. ES6反引号法
  4. 模板引擎

纯DOM法

用纯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
<body>
<ul id="list"></ul>
<script>
var arr = [
{name: 'cqy', sex: 'man'},
{name: 'kyrie', sex: 'man'},
];

let ul = document.getElementById('list')

for(let i=0;i<arr.length;i++) {
let oli = document.createElement('li')

let hdDiv = document.createElement('div')
hdDiv.className = 'hd'
hdDiv.innerText = arr[i].name + '的基本信息'

let bdDiv = document.createElement('div')
bdDiv.className = 'hd'
bdDiv.innerText = 'sex: ' + arr[i].sex

oli.appendChild(hdDiv)
oli.appendChild(bdDiv)
ul.appendChild(oli)
}
</script>
</body>

数组join()法

数组join()法的本质是使用了字符串,以字符串的视角将html字符串添加到body中
众所周知,单引号和双引号都是不支持换行的

1
2
3
4
5
// 单引号和双引号不支持换行,这样写是不合法的
let str = '
adsfd
saf
'

这个时候数组的join()方法就发挥了作用,join()可以将数组的内容拼接在一起

1
2
3
4
5
let str = [
'a',
'b',
'c'
].join('')

可能现在还看不出有什么特别之处,当我们将内容替换成html语句时,就可以显示出html语句的层次关系,比单纯的字符串写在一行要清晰

1
2
3
4
5
6
let str = [
'<li>',
' <div class="hd"></div>',
' <div class="bd"></div>',
'</li>'
].join('')

这个时候你可能还是不知道这有什么用,这时就要用到 innerHTML() 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<body>
<ul id="list"></ul>
<script>
var arr = [
{name: 'cqy', sex: 'man'},
{name: 'kyrie', sex: 'man'},
];

let ul = document.getElementById('list')

for(let i=0;i<arr.length;i++) {
//注意此处的 +=
ul.innerHTML += [
'<li>',
' <div class="hd">' + arr[i].name + '的基本信息' + '</div>',
' <div class="bd">' + 'sex: ' + arr[i].sex + '</div>',
'</li>'
].join('')
}
</script>
</body>

ES6反引号法

es6中的反引号支持换行,并且可以用${}语法糖直接插入数据,本质上和数组join()法一样,只是更加优雅

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<body>
<ul id="list"></ul>
<script>
var arr = [
{name: 'cqy', sex: 'man'},
{name: 'kyrie', sex: 'man'},
];

let ul = document.getElementById('list')

for(let i=0;i<arr.length;i++) {
ul.innerHTML += `
<li>
<div class="hd">${arr[i].name}的基本信息</div>
<div class="bd">sex: ${arr[i].sex}</div>
</li>
`
}
</script>
</body>

Mustache的基本使用

为了学习mustache我们需要引入它,可以使用npm的方法在node环境下使用,也可以通过cdn的方法引入在浏览器使用
在bootcdn.com上找到mustache,为了方便后面的源码分析,我直接将他的源码复制到本地js文件

在mustache引入之后,就会有一个Mustache对象
mustache的模板语法非常的简单,用{{}}表示,
注意 {{}}中不支持写表达式的,因为mustache是逻辑很弱的模板引擎

循环对象数组
模板语法:

1
2
3
4
5
6
7
8
9
//以#开始、以/结束表示区块,它会根据当前上下文中的键值来对区块进行一次或多次渲染
{{#data}}
// ...
{{/data}}
//在区块内,使用{{}}将要展示的数据填入
{{name}}
//调用Mustache.render(templateStr, data) 将数据填入模板字符串中
//会返回被填充好的dom结构
Mustache.render(templateStr, data)

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<script>
console.log(Mustache);
let data = {
arr: [
{name: 'cqy', sex: 'man'},
{name: 'kyrie', sex: 'man'},
]
}
let templateStr = `
<ul>
{{#arr}}
<li>
<div class="hd">{{name}}的基本信息</div>
<div class="bd">sex: {{sex}}</div>
</li>
{{/arr}}
</ul>
`
let domStr = Mustache.render(templateStr, data)
console.log(domStr);
let container = document.getElementById('container')
container.innerHTML = domStr
</script>

不进行循环,直接写入数据
模板语法: {{}}

1
2
3
4
5
6
7
8
9
10
11
<script>
let data = {
name: 'cqy',
age: '20'
}
//使用{{}}模板语法
let templateStr = `my name is {{name}}, age is {{age}}`
let domStr = Mustache.render(templateStr, data)
let container = document.getElementById('container')
container.innerHTML = domStr
</script>

循环简单数组
模板语法: {{.}}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<script>
console.log(Mustache);
let data = {
//arr: [
// {name: 'cqy', sex: 'man'},
// {name: 'kyrie', sex: 'man'},
//]
arr: ['kyrie', 'kd','harden']
}
let templateStr = `
<ul>
{{#arr}}
<li>
<p>{{.}}</p>
</li>
{{/arr}}
</ul>
`
let domStr = Mustache.render(templateStr, data)
console.log(domStr);
let container = document.getElementById('container')
container.innerHTML = domStr
</script>

循环嵌套数组
模板语法:

1
2
3
4
5
6
{{#data1}} 
// ...
{{#data2}}
// ...
{{/data2}}
{{/data1}}

例子:

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
<script>
console.log(Mustache);
let data = {
arr: [
{name: 'cqy', sex: 'man', hobbies: ['打球','敲代码']},
{name: 'kyrie', sex: 'man', hobbies: ['打球','高尔夫']},
]
}
let templateStr = `
<ul>
{{#arr}}
<li>
<div>{{name}}的爱好</div>
//嵌套数字
{{#hobbies}}
<ol>
{{.}}
</ol>
{{/hobbies}}
</li>
{{/arr}}
</ul>
`
let domStr = Mustache.render(templateStr, data)
console.log(domStr);
let container = document.getElementById('container')
container.innerHTML = domStr
</script>

布尔值
模板语法:{{#m}} m为布尔值,m为真显示区块中的内容,为假则不显示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<script>
console.log(Mustache);
let data = {
show: false
}
let templateStr = `
<div>
<p>哈哈哈</p>
{{#show}}
<div>我没有被展示</div>
{{/show}}
</div>
`
let domStr = Mustache.render(templateStr, data)
console.log(domStr);
let container = document.getElementById('container')
container.innerHTML = domStr
</script>

模拟简单的数据替换

使用字符串的replace来替换捕获到的数据
replace()的第一个参数是匹配项,可以是具体值或者正则表达式,第二个参数是要替换成的内容,可以是具体内容或者一个函数
这个函数有四个参数;

  1. 完整的匹配项
  2. 捕获组的内容
  3. 匹配项的索引
  4. 整个字符串的内容
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    <script>
    let data = {
    name: 'cqy',
    age: 20
    }

    let templateStr = "<h2>My name is {{name}}, Age is {{age}}</h2>"
    //模拟简单的数据替换
    function myRender(templateStr,data) {
    // /\{\{(\w+)\}\}/g 匹配整个{{}},并为双大括号内的数据设置捕获组
    return templateStr.replace(/\{\{(\w+)\}\}/g, function(findStr, $1) {
    //findStr就是匹配到的整个{{}} $1是捕获组
    console.log($1);
    return data[$1];
    })
    }
    let res = myRender(templateStr, data)
    console.log(res);
    </script>

但当情况复杂时,正则表达式的思路肯定是不可以得

Mustache的底层核心机理

tokens是一个JS嵌套数组,说白了,就是模板字符串的JS表示
tokens是抽象语法树和虚拟节点的开山鼻祖

简单数据下的tokens
模板字符串:<h1>my hobby is {{hobby}}, age is {{age}}</h1>

tokens:

1
2
3
4
5
6
7
8
9
//纯文本会被编译成text
//{{}}包裹的被编译成name
[
['text', '<h1>my name is '],
['name', 'hobby'],
['text', ', age is '],
['name', 'age'],
['text', '</h1>']
]

当模板字符串有循环的存在时,它将被编写成嵌套更深的tokens
模板字符串:

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

tokens:

1
2
3
4
5
6
7
8
9
10
11
12
[
['text', '<ul>'],

//将{{#arr}} {{/arr}}之间的东西都包含在这一个token里面
['#', 'arr', [
['text', '<li>'],
['name', '.'],
['text', '</li>'],
]],

['text', '</ul>']
]

由此可见,mustache的底层重点要做两件事

  1. 将模板字符串翻译为tokens形式
  2. 将tokens结合数据,解析为dom字符串

手写实现Mustache

手写Scanner类

要实现模板字符串转化成tokens,首先得可以实现有一个东西扫描整个模板字符串来找到双大括号的位置,从而将双大括号外面和双大括号里面的东西抽离出来,Scanner类就是来实现这个功能

例如:模板字符串 'my name is {{myName}}, age is {{age}}'要转化为tokens
主要过程就是:定义一个指针指向模板字符串的开头,然后向后面扫描,当指针指向第一个指定内容{时,返回在这之前扫描到的字符串,然后跳过{{`,接着扫描直到遇到指定内容`}`时,返回在这之前扫描到的字符串,然后跳过`}},重复以上步骤直到模板字符串末尾

定义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
48
49
50
51
52
53
54
/*
扫描器类
*/

//定义一个Scanner扫描器类,并向外暴露
export default class Scanner {
//构造函数,接收传入的模板字符串
constructor(templateStr) {
this.templateStr = templateStr
//指针
this.pos = 0
//尾巴字符串
//指针指到的当前字符的后面的字符串(包括指针)被称为尾巴字符串
//尾巴字符串的精妙之处在于,我们只要判断尾巴字符串的开头字符是不是指定内容(双大括号),
//就可以决定指针是继续往后走还是跳过
this.tail = templateStr
}
//模板字符串 'my name is {{myName}}, age is {{age}}'要转化为tokens,
//mustache内部定义了两个方法来转化 scan() 和 scanUntil()

// scan: 匹配到指定内容(双大括号),并跳过他,没有返回值
scan(tag) {
if(this.tail.indexOf(tag) == 0) {
//只要让指针向后移 tag的长度 就可以实现跳过指定内容的功能
this.pos += tag.length
//调整尾巴字符串
this.tail = this.templateStr.substring(this.pos)
}
}
//scanUntil: 指针从模板字符串的开头开始扫描,
//直到遇到指定内容(双大括号)结束,返回结束之前路过的字符串
scanUntil(stopTag) {
//记录本方法执行前pos指针的位置
const pos_start = this.pos
//当尾巴字符串的开头不是指定目标时循环进行
// &&后面的判断语句很有必要,防止因为找不到指定目标而进入死循环
while(this.tail.indexOf(stopTag) != 0 && !this.eos()) {
//指针后移
this.pos++
//更新尾巴字符串为当前指针后面(包含指针)的字符串
this.tail = this.templateStr.substring(this.pos)

}
//返回从本方法执行前位置到结束指针位置的字符串,
//substring默认前闭后开,this.pos正好指向指定内容位置,不会被包含进去
return this.templateStr.substring(pos_start, this.pos)
}

//eos:end of string 判断指针是否到模板字符串最后
//返回一个布尔值 到最后了返回true
eos() {
return this.pos >= this.templateStr.length
}
}

使用Scanner类

1
2
3
4
5
6
7
<body>
<script>
let templateStr = '我今天写了{{code}}, 好{{mood}}呀'
let data = {}
TemplateEngine.render(templateStr, data)
</script>
</body>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import Scanner from './Scanner'
//定义一个全局的TemplateEngine来模拟mustache
window.TemplateEngine = {
//实现模板字符串转化成tokens
render(templateStr,data) {
//实例化一个扫描器,构造时候提供一个参数,这个参数就是模板字符串
//也就是说这个扫描器就是针对这个模板字符串工作的
var scanner = new Scanner(templateStr)
while(scanner.pos != templateStr.length) {
var words = scanner.scanUntil('{{')
scanner.scan('{{')
//console.log(words, scanner.pos);
words = scanner.scanUntil('}}')
scanner.scan('}}')
//console.log(words, scanner.pos);
}
}
}

得到的结果:

手写将模板字符串变成tokens

简单的一维数组tokens

当我们的模板字符串没有嵌套多层时,可以很容易转化成一维数组的tokens

1
2
3
4
5
6
7
8
<body>
<script src="/xuni/bundle.js"></script>
<script>
let templateStr = '我今天写了{{code}}, 好{{mood}}呀'
let data = {}
TemplateEngine.render(templateStr, data)
</script>
</body>
1
2
3
4
5
6
7
8
9
//导入将模板字符串转化成tokens的函数
import parseTemplateToTokens from './parseTemplateToTokens'
window.TemplateEngine = {
render(templateStr,data) {
//调用parseTemplateToTokens函数,让模板字符串变成tokens数组
var tokens = parseTemplateToTokens(templateStr)
console.log(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
import Scanner from './Scanner'
/*
将模板字符串变为tokens数组
*/
export default function parseTemplateToTokens(templateStr) {

var tokens = []

//创建一个扫描器类
var scanner = new Scanner(templateStr)
var words = ''
while(!scanner.eos()) {
// 收集开始标记出现之前的文字
words = scanner.scanUntil('{{')

if(words != '') {
//存入tokens数组
tokens.push(['text', words])
}
//跳过指定内容
scanner.scan('{{')

// 收集结束标记出现之前的文字
words = scanner.scanUntil('}}')

if(words != '') {
//存入tokens数组
tokens.push(['name', words])
}
//跳过指定内容
scanner.scan('}}')

}

return tokens
}

更进一步的tokens

当我们遇到模板字符串的嵌套时,先初步将{{#...}} {{/...}}分离出来
在上一步的基础上做一点小改变

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 收集结束标记出现之前的文字
words = scanner.scanUntil('}}')

if(words != '') {
//此处的words是{{}}中的内容,判断words的首字母
if(words[0] == '#') {
//将 # 和 words中#之后的内容 分开存储
tokens.push(['#', words.substring(1)])
} else if(words[0] == '/') {
tokens.push(['/', words.substring(1)])
} else {
tokens.push(['name', words])
}
}
//跳过指定内容
scanner.scan('}}')

将tokens嵌套起来

我们要将tokens嵌套起来,如下图的形式

在实现嵌套tokens时我们要用到

1
2
3
4
5
6
7
8
9
import Scanner from './Scanner'
/*
将模板字符串变为tokens数组
*/
export default function parseTemplateToTokens(templateStr) {
// ...
//parseTemplateToTokens整理得到的是一个没有嵌套结构的假tokens,我们在它的返回值中调用函数来将tokens嵌套起来
return nestTokens(tokens)
}

以下代码是根据我自己的思路来实现嵌套tokens
可以实现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
39
40
41
42
43
44
45
46
47
/*
nestTokens函数的作用是折叠tokens,将'#' 和 '/'之间的内容整合在一起,作为数组的第三项
*/
export default function nestTokens(tokens) {
//最终的结果,折叠好的tokens
var nestedTokens = []

//栈
//将要嵌套的内容存入栈
var sections = []

for(let i=0 ; i < tokens.length; i++) {
let token = tokens[i]
//判断token的下标0的内容是 '#' 还是 '/' 或者 其他内容
switch(token[0]) {
case '#':
//为token的下标为2的位置创建一个数组,用于收集子元素
token[2] = []
//入栈
sections.push(token)
break;
case '/':
//出栈
let section = sections.pop()

//当遇到 '/' 时,整个嵌套内容section出栈
//判断栈是否为空 栈为空直接存到nestedTokens中,
if(sections.length == 0) {
nestedTokens.push(section)
} else {
//栈不为空,将嵌套内容存到上一层嵌套的下标为2的位置
sections[sections.length - 1][2].push(section)
}
break;
default :
// 如果栈内没有内容了,说明暂时没有嵌套的内容,将这个token直接存入最终结果中
if(sections.length == 0) {
nestedTokens.push(token)
} else {
//sections[sections.length - 1]表示栈顶的token,就是最新进去的包含 '#' 的token
// [2].push(token)在case中已经为这个token创建了下标为2的数组,将token存入最新进去的包含 '#' 的token的下标为2的位置
sections[sections.length - 1][2].push(token)
}
}
}
return nestedTokens
}

控制台打印的结果:

源码中很巧妙的使用了一个收集器,很大程度上简化了代码,并且逻辑更清晰

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
/*
nestTokens函数的作用是折叠tokens,将'#' 和 '/'之间的内容整合在一起,作为数组的第三项
*/
export default function nestTokens(tokens) {
//折叠好的tokens
var nestedTokens = []

//console.log(tokens);
//栈
//将要嵌套的内容存入栈
var sections = []

------------------------------------------
//收集器,天生指向nestedTokens,引用类型值,所以collector和nestedToken指向同一值
var collector = nestedTokens
------------------------------------------

for(let i=0 ; i < tokens.length; i++) {
let token = tokens[i]

switch(token[0]) {
case '#':
//在收集器中放入这个token
collector.push(token)
//入栈
sections.push(token)
//改变收集器指向,给token添加下标为2的项,让收集器指向它
//token[2] = [] 放在 collector.push(token) 之前 或 在此处定义 没有区别
token[2] = []
collector = token[2]
break;
case '/':
//出栈
let section = sections.pop()
//判断栈是否为空,若为空 收集器指向nestedTokens ,若不为空 收集器指向栈顶那项下标为2的项
collector = sections.length > 0 ? sections[sections.length - 1][2] : nestedTokens
break;
default :
collector.push(token)
}

}
return nestedTokens
}

控制台打印的结果:

手写将tokens变成dom字符串

我们已经得到了嵌套好的tokens数组,接下来需要结合数据将他们变成dom字符串

简单模板的测试

1
2
3
4
5
6
7
8
9
10
11
<body>
<script src="/xuni/bundle.js"></script>

<script>
let templateStr = '我是{{somebody}}门徒'
let data = {
somebody: '欧文'
}
TemplateEngine.render(templateStr, data)
</script>
</body>
1
2
3
4
5
6
7
8
9
10
11
12
import parseTemplateToTokens from './parseTemplateToTokens'
import renderTemplate from './renderTemplate';

window.TemplateEngine = {
render(templateStr,data) {
//调用parseTemplateToTokens函数,让模板字符串变成tokens数组
var tokens = parseTemplateToTokens(templateStr)
//调用renderTemplate函数,让tokens变成dom字符串
var domStr = renderTemplate(tokens, data)
//console.log(tokens);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/*
函数的作用是将tokens变成dom字符串
*/

export default function renderTemplate(tokens, data) {
console.log(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 += data[token[1]]
}
}

console.log(resultStr);
}

控制台得到的结果:可以初步实现简单的模板转化

问题
上面的代码还存在一个很大的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
for(let i=0; i<tokens.length; i++) {
let token = tokens[i]
if(token[0] == 'text') {
resultStr += token[1]
} else if(token[0] == 'name') {
//这里会出现一个问题,js无法识别字符串形式的点语法
//当模板中的数据需要使用点语法时
//let templateStr = '我是{{somebody}}门徒,我得了{{a.b.c}}分'
//let data = {
// somebody: '欧文',
// a: {
// b: {
// c: 100
// }
// }
//}
//这时候的data[token[1]]就是data['a.b.c'],无法被识别
resultStr += data[token[1]]
} else if(token[0] == '#') { // '#'标记的tokens,要递归处理它的标为2的数组
}
}

这个时候我们就需要一个函数来帮助我们实现点语法的实现

lookup函数

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
/*
函数的功能是在dataObj对象中,找出用连续点符号的keyName的属性
比如:dataObj是
{
a: {
b: {
c: 100
}
}
}
那么lookup(dataObj, 'a.b.c')的结果就是100
*/
export default function lookup(dataObj, keyName) {
//判断keyName中有没有点语法,但本身不能是'.',
//因为mustache中有{{.}}语法,在后面会解析
if(keyName.indexOf('.') != -1 && keyName != '.') {
//将点符号的各个属性分开
var keys = keyName.split('.')

var temp = dataObj
for(let i=0; i<keys.length; i++) {
//依次追加属性
temp = temp[keys[i]]
}
return temp
}
//如果没有点符号,直接返回
return dataObj[keyName]
}

这样就可以将他导入到renderTemplate函数中使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import lookup from './lookup'
/*
函数的作用是将tokens变成dom字符串
*/
export default function renderTemplate(tokens, data) {
console.log(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 += data[token[1]]
resultStr += lookup(data, token[1])
}
}

console.log(resultStr);
return resultStr
}

到现在为止,我们只是实现了text,name开头的token,还没有实现 ‘#’ 下的嵌套功能

嵌套tokens的转化,不包含{{.}}

对于嵌套tokens的转化,其实就是递归调用renderTemplate函数,一层一层转化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export default function renderTemplate(tokens, data) {
// ....
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 += data[token[1]]
resultStr += lookup(data, token[1])

} else if(token[0] == '#') { // '#'标记的tokens,要递归处理它的下标为2的数组
// 在这里调用parseArray()来解析 '#' 开头的token
resultStr += parseArray(token, data)

}
}
console.log(resultStr);
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
28
29
30
31
32
33
34
35
36
import lookup from "./lookup";
import renderTemplate from "./renderTemplate";
/*
parseArray()处理数组,结合renderTemplate函数实现递归
注意这个函数接收的是一个token,不是tokens

这个函数要递归调用renderTemplate函数,调用的次数由data数据中这部分要使用的数组的长度
例如data的形式如下,
假设我们此次传入的token要用到的是arr中的数据,那么parseArray()就要调用renderTemplate函数2次
data = {
arr: [
{name: 'cqy'},
{name: 'kyrie'}
]
}
*/
export default function parseArray(token, data) {
console.log(token, data);
//定义这个函数的结果字符串
var resultStr = ''

//得到整体数据中这个token要用到的数据
var v = lookup(data, token[1])
//console.log(v);

//这里是一个重点,它遍历的是数据,v数组保存的就是数据,v一定是个数组,根据数据(v)的长度来觉得要循环几次,而不是遍历token
//因为 {{#...}} {{/...}}之间的代码本来的需求就是根据数据的条数来循环创建的
for(let i=0; i<v.length; i++) {
//调用renderTemplate()函数,根据每一组数据来创建dom字符串,并拼接到parseArray()的结果字符串
//token[2]是要插入数据的当前项,v是包含了整个循环要用到的数据数组,v[i]就是当前项要用到的数据
resultStr += renderTemplate(token[2], v[i])
}
//返回结果parseArray()的字符串,
//拼接到renderTemplate()的结果字符串后面
return resultStr
}

控制台打印的结果:
来分析一下图中1,2,3,4的来源
最开始是在renderTemplate函数中if-else语句中的 token[0] == '#',由此进入到parseArray()函数,
在parseArray()中的此次for循环传入token[2]和v[i]来调用renderTemplate函数,renderTemplate函数将此条token结合数据转化成dom字符串返回,就是图中的1,并将结果返回给parseArray(),parseArray()中的resultStr接收到这个数据,然后parseArray()进入下一次循环
parseArray()来到下一次循环,将token[2]和新的v[i]传入来调renderTemplate函数,renderTemplate函数将此条token结合数据转化成dom字符串返回,就是图中的2,
循环次数正好到达数据的长度,即2组数据两次循环
parseArray()的整个for循环结束,resultStr将两次循环的dom字符串拼接在一起,就是图中的3,返回给最开始调用它的renderTemplate函数
renderTemplate函数接收到parseArray()返回数据,将他拼接到renderTemplate的结果字符串中,最后返回全部转化好的dom字符串,就是图中的4

解决{{.}}的问题

先使用只有点符号的数据和模板来探究问题

1
2
3
4
5
6
7
8
9
10
11
12
let templateStr = `
<ul>
{{#name}}
<li>
{{.}}
</li>
{{/name}}
</ul>
`
let data = {
name: ['kyrie', 'cqy', 'kd']
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export default function parseArray(token, data) {
console.log(token, data);

var resultStr = ''

var v = lookup(data, token[1])
console.log('我是token要用到的整体数据'+v);

for(let i=0; i<v.length; i++) {
//token[2]是要插入数据的当前项,v[i]就是当前项要用到的数据
console.log(token[2],'我是当前项要用到的数据'+v[i]);

resultStr += renderTemplate(token[2], v[i])
}
console.log(resultStr);
return resultStr
}

由控制台可以看出,v得到的是循环要用到的所用数据的一个数组,而v[i]就是循环要使用的当前数据

在每个循环中调用renderTemplate函数来将token转化成dom字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
export default function renderTemplate(tokens, data) {

//结果字符串
var resultStr = ''

for(let i=0; i<tokens.length; i++) {
// ....

else if(token[0] == 'name') {

//resultStr += data[token[1]]
resultStr += lookup(data, token[1])

}

// ...
return resultStr
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export default function lookup(dataObj, keyName) {
//console.log(dataObj, keyName);
//判断keyName中有没有点语法,但本身不能是'.',
if(keyName.indexOf('.') != -1 && keyName != '.') {
//将点符号的各个属性分开
var keys = keyName.split('.')
//console.log(keys);
var temp = dataObj
for(let i=0; i<keys.length; i++) {
//依次追加属性
temp = temp[keys[i]]
}
return temp
}
//如果没有点符号,直接返回
return dataObj[keyName]
}

用第一次循环来举例,renderTemplate()中来到token[0] == 'name'的函数体中,将kyrie(data),’.’(token[1])传给lookup函数,因为lookup函数无法解析自身是’.’的数据,所以返回undefined

我们可以在传值的时候,将’.’作为键名,将当前数据作为键值赋值给键名来解决问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export default function parseArray(token, data) {

// ...

for(let i=0; i<v.length; i++) {
//token[2]是要插入数据的当前项,v[i]就是当前项要用到的数据
console.log(token[2],'我是当前项要用到的数据'+v[i]);
//这里要添加一个'.'的属性
resultStr += renderTemplate(token[2], {
//让 . 作为键名,将当前数据作为他的键值
//拿第一次循环举例就是 '.': 'kyire'
'.': v[i]
})
}
console.log(resultStr);
return resultStr
}

这里解决了{{.}}的问题,但是复杂数组就出问题了,因为我们原本是使用v[i]传值的,现在复杂数组就查找不到这个’.’,

所以我们可以在传入的对象中将v[i]解构之后传入,相当于就是在传入v[i]的基础上又增加一个’.’属性,只不过这个’.’属性指向的是他自己

1
2
3
4
5
6
resultStr += renderTemplate(token[2], {
//让 . 作为键名,将当前数据作为他的键值
//拿第一次循环举例就是 '.': 'kyire'
...v[i],
'.': v[i]
})

用回复杂的数据来测试一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let templateStr = `
<ul>
{{#arr}}
<li>
<div>{{name}}的爱好</div>
{{#hobbies}}
<ol>
{{.}}
</ol>
{{/hobbies}}
</li>
{{/arr}}
</ul>
`
let data = {
arr: [
{name: 'cqy', hobbies: ['篮球','敲代码']},
{name: 'kyrie', hobbies: ['篮球','高尔夫']}
]
}

可以得到正确的结果

将dom字符串渲染到浏览器

在上面我们已经完成了从模板字符串到tokens的解析,以及tokens到dom字符串的转化,最后就是将他渲染到浏览器中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import parseTemplateToTokens from './parseTemplateToTokens'
import renderTemplate from './renderTemplate';

window.TemplateEngine = {
render(templateStr,data) {
//调用parseTemplateToTokens函数,让模板字符串变成tokens数组
var tokens = parseTemplateToTokens(templateStr)
//调用renderTemplate函数,让tokens变成dom字符串
var domStr = renderTemplate(tokens, data)
//console.log(tokens);

// 返回dom字符串
return domStr
}
}
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>
<div id="container"></div>
<script src="/xuni/bundle.js"></script>

<script>
let templateStr = `
<ul>
{{#arr}}
<li>
<div>{{name}}的爱好</div>
{{#hobbies}}
<ol>
{{.}}
</ol>
{{/hobbies}}
</li>
{{/arr}}
</ul>
`
let data = {
arr: [
{name: 'cqy', hobbies: ['篮球','敲代码']},
{name: 'kyrie', hobbies: ['篮球','高尔夫']}
]
}

var domStr = TemplateEngine.render(templateStr, data)
console.log(domStr);

let container = document.getElementById('container')
container.innerHTML = domStr
</script>
</body>

最后

实现了mustache模板引擎的核心内容,下面是各部分完整的代码

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
48
49
50
51
52
/*
扫描器类
*/

export default class Scanner {
constructor(templateStr) {
console.log(templateStr);
this.templateStr = templateStr
//指针
this.pos = 0
//尾巴字符串
//指针指到的当前字符的后面的字符串(包括指针)被称为尾巴字符串
//尾巴字符串的精妙之处在于,我们只要判断尾巴字符串的开头字符是不是指定内容(双大括号),
//就可以决定指针是继续往后走还是跳过
this.tail = templateStr
}
//模板字符串 'my name is {{myName}}, age is {{age}}'要转化为tokens,
//mustache内部定义了两个方法来转化 scan() 和 scanUntil()

// scan: 匹配到指定内容(双大括号),并跳过他,没有返回值
scan(tag) {
if(this.tail.indexOf(tag) == 0) {
//只要让指针向后移 tag的长度 就可以实现跳过指定内容的功能
this.pos += tag.length
this.tail = this.templateStr.substring(this.pos)
}
}
//scanUntil: 指针从模板字符串的开头开始扫描,
//直到遇到指定内容(双大括号)结束,返回结束之前路过的字符串
scanUntil(stopTag) {
//记录本方法执行前pos指针的位置
const pos_start = this.pos
//当尾巴字符串的开头不是指定目标时循环进行
// &&后面的判断语句很有必要,防止因为找不到指定目标而进入死循环
while(this.tail.indexOf(stopTag) != 0 && !this.eos()) {
//指针后移
this.pos++
//更新尾巴字符串为当前指针后面(包含指针)的字符串
this.tail = this.templateStr.substring(this.pos)

}
//返回从本方法执行前位置到结束指针位置的字符串,
//substring默认前闭后开,this.pos正好指向指定内容位置,不会被包含进去
return this.templateStr.substring(pos_start, this.pos)
}

//eos:end of string 判断指针是否到模板字符串最后
//返回一个布尔值 到最后了返回true
eos() {
return this.pos >= this.templateStr.length
}
}

nestTokens折叠嵌套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
39
40
/*
nestTokens函数的作用是折叠tokens,将'#' 和 '/'之间的内容整合在一起,作为数组的第三项
*/
export default function nestTokens(tokens) {
//折叠好的tokens
var nestedTokens = []

//console.log(tokens);
//栈
//将要嵌套的内容存入栈
var sections = []
//收集器,天生指向nestedTokens,引用类型值,所以collector和nestedToken指向同一值
var collector = nestedTokens

for(let i=0 ; i < tokens.length; i++) {
let token = tokens[i]

switch(token[0]) {
case '#':
//在收集器中放入这个token
collector.push(token)
//入栈
sections.push(token)
//改变收集器指向,给token添加下标为2的项,让收集器指向它
token[2] = []
collector = token[2]
break;
case '/':
//出栈
let section = sections.pop()
//判断栈是否为空,若为空 收集器指向nestedTokens ,若不为空 收集器指向栈顶那项下标为2的项
collector = sections.length > 0 ? sections[sections.length - 1][2] : nestedTokens
break;
default :
collector.push(token)
}

}
return nestedTokens
}

parseTemplateToTokens将模板字符串解析成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
39
40
41
42
43
44
import Scanner from './Scanner'
import nestTokens from './nestTokens'
/*
将模板字符串变为tokens数组
*/
export default function parseTemplateToTokens(templateStr) {

var tokens = []

//创建一个扫描器类
var scanner = new Scanner(templateStr)
var words = ''
while(!scanner.eos()) {
// 收集开始标记出现之前的文字
words = scanner.scanUntil('{{')

if(words != '') {
//存入tokens数组
tokens.push(['text', words])
}
//跳过指定内容
scanner.scan('{{')

// 收集结束标记出现之前的文字
words = scanner.scanUntil('}}')

if(words != '') {
//此处的words是{{}}中的内容,判断words的首字母
if(words[0] == '#') {
//将 # 和 words中#之后的内容 分开存储
tokens.push(['#', words.substring(1)])
} else if(words[0] == '/') {
tokens.push(['/', words.substring(1)])
} else {
tokens.push(['name', words])
}
}
//跳过指定内容
scanner.scan('}}')

}

return nestTokens(tokens)
}

renderTEmplate将tokens转化成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
import lookup from './lookup'
import parseArray from './parseArray';
/*
函数的作用是将tokens变成dom字符串
*/

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 += data[token[1]]
resultStr += lookup(data, token[1])

} else if(token[0] == '#') { // '#'标记的tokens,要递归处理它的下标为2的数组

resultStr += parseArray(token, data)

}
}
return resultStr
}

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
/*
函数的功能是在dataObj对象中,找出用连续点符号的keyName的属性
比如:dataObj是
{
a: {
b: {
c: 100
}
}
}
那么lookup(dataObj, 'a.b.c')的结果就是100
*/
export default function lookup(dataObj, keyName) {
//console.log(dataObj, keyName);
//判断keyName中有没有点语法,但本身不能是'.',
if(keyName.indexOf('.') != -1 && keyName != '.') {
//将点符号的各个属性分开
var keys = keyName.split('.')
//console.log(keys);
var temp = dataObj
for(let i=0; i<keys.length; i++) {
//依次追加属性
temp = temp[keys[i]]
}
return temp
}
//如果没有点符号,直接返回
return dataObj[keyName]
}

parseArray结合renderTemplate实现tokens转变成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
import lookup from "./lookup";
import renderTemplate from "./renderTemplate";
/*
处理数组,结合renderTemplate函数实现递归
注意这个函数接收的是一个token,不是tokens

这个函数要递归调用renderTemplate函数,调用的次数由data数据中这部分要使用的数组的长度
例如data的形式如下,
假设我们此次传入的token要用到的是arr中的数据,那么parseArray()就要调用renderTemplate函数2次
data = {
arr: [
{name: 'cqy'},
{name: 'kyrie'}
]
}
*/
export default function parseArray(token, data) {
//console.log(token, data);

var resultStr = ''

//得到整体数据中,这个token要用到的数据
var v = lookup(data, token[1])
//console.log('我是token要用到的整体数据'+v);

//这里是一个重点,它遍历的是数据,根据数据的长度来觉得要循环几次,而不是遍历token
//因为 {{#...}} {{/...}}之间的代码本来的需求就是根据数据的条数来循环创建的
for(let i=0; i<v.length; i++) {
//token[2]是要插入数据的当前项,v[i]就是当前项要用到的数据
//console.log(token[2],'我是当前项要用到的数据'+v[i]);
//这里要添加一个'.'的属性
resultStr += renderTemplate(token[2], {
...v[i],
'.': v[i]
})
}
//console.log(resultStr);
return resultStr
}