早期模板渲染方式和mustache模板引擎的简单实现
在模板引擎诞生之前,为了使用js将动态数据渲染到页面上,出现了一系列模板渲染的方法,最直接的莫过于是手动创建所有节点:
<div id="root"></div>
<script>
var root = document.getElementById('root')
var container = document.createElement('h1')
var text = document.createTextNode('Hello World!!')
container.appendChild(text)
root.appendChild(container)
</script>
这种方式需要手动创建所有节点,操作繁琐,因而无法在实际开发中运用。
还有一种方式是使用innerHTML
来上树。
<div id="root"></div>
<script>
var root = document.getElementById('root')
root.innerHTML = '<h1>Hello World</h1>'
</script>
这种方式无疑是方便了许多,但是由于字符串无法换行,一旦遇到那种标签嵌套层级太多的情况,会给自己和他人的后期维护造成极大的困扰。
再后来又出现了这种表达方法
<div id="root"></div>
<script>
var root = document.getElementById("root");
var obj = {
name: "洗脚哥",
gender: "男",
hobby: "洗脚",
};
root.innerHTML = [
"<ul>",
" <li>姓名:" + obj.name + "</li>",
" <li>性别:" + obj.gender + "</li>",
" <li>爱好:" + obj.hobby + "</li>",
"</ul>"
].join('');
</script>
这个方法将不可换行字符串转成阵列结构,可读性和可维护性都大大提升。
当然,ES6的模板字符串的出现,使这种阵列结构也失去了用武之地。
<div id="root"></div>
<script>
var root = document.getElementById("root");
var obj = {
name: "xjg",
gender: "男",
hobby: "洗脚",
};
root.innerHTML = `
<ul>
<li>姓名:${obj.name} </li>
<li>性别:${obj.gender} </li>
<li>爱好: ${obj.hobby} </li>
</ul>
`
</script>
但是,在实际开发中DOM操作并不是简单的,当DOM结构更加复杂,这种方式也显得力不从心。
于是mustache库横空出世!
什么是模板引擎
模板引擎的基本机理就是替换(转换),将指定的标签转换为需要的业务数据;将指定的伪语句按照某种流程来变换输出
Mustache
一个胡子模板是一个包含任意数量的小胡子标签的字符串。标签由围绕它们的双髭指示。
{{data}}
双花括号就是mustache语法的标识符,花括号中的data表示键名,作用是直接输出与键名匹配的键值。
var template = `
<div>{{data}}</div>
`
var data = "hello world"
var html = mustache.render(template,data)
//输出
`
<div>hello world</div>
`
{{.}}
表示枚举,可循环输出整个数组,需搭配{{#data}}和{{/data}}使用。
- ###
{{#data}}和{{/data}}
该语法以#开始,并以/结束表示区块,用于对当前上下文的数据进行一次或多次的渲染,类似vue中的v-for
指令。
js
// li标签根据data属性hobbies对应的键值持续迭代
var template = `
<div>
{{#hobbies}}
<li>{{.}}</li>
{{/hobbies}}
</div>`;
var hobbies = ["唱","跳","rap"]
var html = mustache.render(template)
//输出
`
<div>
<li>唱</li>
<li>跳</li>
<li>rap</li>
</div>`
实现一下mustache模板引擎实现原理,其中包括了以上几种语法的简单实现。
Render函数
window.my_templateEngine = {
//渲染函数
render(templateStr, data) {
//将字符串模板转换成tokens数组
var tokens = parserTemplateToTokens(templateStr);
//结合数据渲染模板
var resTemplate = renderTemplate(data, tokens)
return resTemplate
}
}
一个模板引擎最核心的便是其渲染(render)函数,渲染函数从主要负责将字符串模板与数据结合,动态渲染出HTML。
嵌套数组(Tokens)
Tokens为Mustache的底层核心机理:js的嵌套数组,它是模板字符串的js表示形式,并且Tokens 是抽象语法树(AST)、虚拟节点等的思路来源。
数组形式:
0: (2) ['text', '\n <ul>\n ']
1: (3) ['#', 'students', Array(5)]
2: (2) ['text', '\n </ul>\n ']
'text'
代表普通字符串,直接拼接,第2项为字符串'name'
代表存储基本类型的键,第2项为键名'#'和'/'
表示区块,第2项为键名,第3项为数组
要将字符串模板转换成tokens数组,肯定要对传入的字符串模板进行处理,我们知道,Mustache语法是靠识别双花括号{{}}
内的参数来进行数据替换的,那么我们必须使用某种识别方法来提取{{}}
的内容。扫描器(Scanner)就是用来实现它的
扫描器(Scanner)
代码表示:
export default class Scanner {
constructor(templateStr) {
//贮存传入的字符串模板
this.templateStr = templateStr
//贮存tag之前的字符串
this.tail = templateStr
//扫描指针
this.pos = 0
}
//扫描方法,传入需要识别的tag
scanUntil(stopTag) {
//记录一下pos
var pos_backup = this.pos
//当尾巴的开头不是stopTag的时候 就说明没有扫描到stopTag
while(this.tail.indexOf(stopTag) !== 0 && !this.isEnd()) {
this.pos ++;
this.tail = this.templateStr.substr(this.pos)
}
return this.templateStr.substring(pos_backup,this.pos)
}
//跳过传入的tag
scan(tag) {
while(this.tail.indexOf(tag) === 0) {
//过掉tag
this.pos += 2;
this.tail = this.templateStr.substr(this.pos)
}
}
//判断是否到末尾
isEnd() {
return this.pos > this.templateStr.length
}
}
scanUntil
方法对传入的字符串进行检索,如果当前字符串中索引为0的字符不是'{',就将pos指针加1
,直到遇到'{',该方法会返回处于'{'之前的字符串。scan
方法负责跳过tag,因为"{{"占用了两个字符的位置并且已经完成了它的使命,所以截去并将pos位置加2
。下面是该方法示例
//示例
import Scanner from "./scanner";
var scanner = new Scanner(`<div><ul><li>{{hey}}</li></ul></div>`)
console.log(scanner.scanUntil("{{")) //<div><ul><li>
scanner.scan("{{") //跳过
console.log(scanner.scanUntil("}}")) //hey
scanner.scan("}}") //跳过
console.log(scanner.scanUntil("}}")) //</li></ul></div>
...
使用扫描器获取Tokens
import Scanner from "./scanner";
import nestTokens from "./nestTokens"
export default function parserTemplateToTokens(templateStr) {
var tokens = [];
var words = "";
var scanner = new Scanner(templateStr)
while (scanner.eos()) {
//收集mustache开始标记之前的值
words = scanner.scanUntil("{{");
//过掉标记
scanner.scan("{{")
if (words !== '') {
tokens.push(["text", words]);
}
//收集mustache结束标记之前的值
words = scanner.scanUntil("}}");
//过掉标记
scanner.scan("}}")
if (words !== '') {
if(words[0] === "#") {
tokens.push(["#", words.substring(1)]);
} else if(words[0] === "/"){
tokens.push(["/", words.substring(1)] );
} else {
tokens.push(["name", words]);
}
}
}
return tokens
}
根据示例方法,可以实现将截取的字符串形成像下面的形式的数组:
0: (2) ['text', '\n <ul>\n ']
1: (3) ['#', 'students', Array(5)]
2: (2) ['text', '\n <li>\n 学生']
3: (2) ['name', 'name']
4: (2) ['text', '的爱好是\n <ol>\n ']
5: (3) ['#', 'hobbies', Array(3)]
6: (2) ['text', '\n <li>']
7: (2) ['name', '.']
8: (2) ['text', '</li>\n ']
9: (2) ['/', 'hobbies']
10: (2) ['text', '\n </ol>\n </li>\n ']
11: (2) ['/', 'students']
12: (2) ['text', '\n </ul>\n ']
可以看到 数组中存在有hobbies和students两个区块,我们所期望的是他们是嵌套
的关系(像以下的嵌套形式),所以我们的实现方式仍然存在一些问题。
0: (2) ['text', '\n <ul>\n ']
1: Array(3)
0: "#"
1: "students"
2: Array(5)
0: (2) ['text', '\n <li>\n 学生']
1: (2) ['name', 'name']
2: (2) ['text', '的爱好是\n <ol>\n ']
3: Array(3)
0: "#"
1: "hobbies"
2: Array(3)
0: (2) ['text', '\n <li>']
1: (2) ['name', '.']
2: (2) ['text', '</li>\n ']
4: (2) ['text', '\n </ol>\n </li>\n ']
2: (2) ['text', '\n </ul>\n ']
数组折叠
export default function nestTokens(tokens) {
//遍历数组遇到 #
let nestTokens = [];
let tagStack = [];
for (let i = 0; i < tokens.length; i++) {
let token = tokens[i];
switch (token[0]) {
case "#":
//这个存的是层级(几个#),待会好回去
tagStack.push(token);
break;
case "/":
let tagStackPop = tagStack.pop();
//碰到第一个/触发出栈操作时,可以判断当前栈是否还有值,如果有则将出栈值push到栈顶数组中
if(tagStack.length) {
tagStack[tagStack.length - 1][2].push(tagStackPop)
}else {
nestTokens.push(tagStackPop)
}
break;
default:
if (tagStack.length === 0) {
nestTokens.push(token)
} else {
tagStack[tagStack.length - 1][2].push(token)
}
break;
}
}
return nestTokens
}
nestTokens
方法对传入的tokens数组进行遍历,普通项正常进入nestTokens数组,遍历过程中若是遇到嵌套开始标志#
,就将其压入tagStack,并且后续对nestTokens的push操作都是在tagStack栈顶数组中,如果遇到嵌套结束标志/
,可以判断当前tagStack是否还有值,如果有则将出栈值push到栈顶数组中,如果没有值了则将最后一个出栈的数组push到nestTokens中并将nestTokens返回。
嵌套复杂类型的读取
如果我们需要拿到对象类型的值,需要用上.
点符号,但目前的渲染函数只能识别简单的数据类型,所以我们需要再封装一个方法,来对数据中的复杂类型进行获取。
// 根据传入的带点字符串参数 查找对象的值
export default function lookup(dataObj, keyName) {
let str = ''
try {
let i = keyName.indexOf('.')
if (i !== -1 && keyName !== '.') {
str = keyName.substring(0, i);
return lookup(dataObj[str], keyName.substr(i + 1))
} else {
return dataObj[keyName]
}
} catch(err) {
console.log(err);
}
}
最后一步模板渲染
最终的Tokens数组我们已经拿到了,接下来便是最后一步但是同样重要,模板渲染
,将tokens与数据进行结合,并形成最终的domStr。
import lookup from "./lookup"
import parserArr from "./parserArr"
// 实现功能 将数据与tokens数组合成html
export default function renderTemplate(data, tokens) {
let resStr = '';
for (let i = 0; i < tokens.length; i++) {
let token = tokens[i]
if (token[0] === 'text') {
resStr += token[1]
} else if (token[0] === 'name') {
resStr += lookup(data, token[1])
} else if (token[0] === '#') {
resStr += parserArr(token, data)
}
}
return resStr
}
测试
var template = `
<ul>
{{#students}}
<li>
学生{{name}}的爱好是
<ol>
{{#hobbies}}
<li>{{.}}</li>
{{/hobbies}}
</ol>
</li>
{{/students}}
</ul>
`
var data = {
students: [{
'name': '小明',
'hobbies': ['游泳', '健身']
},
{
'name': '小红',
'hobbies': ['足球', '篮球', '羽毛球']
},
{
'name': '小坤',
'hobbies': ['唱', '跳','rap', '篮球']
},
]
}
var domStr = window.my_templateEngine.render(template, data);
var container = document.getElementById("container");
container.innerHTML = domStr