mustache 模板引擎
什么是模板引擎
-
模板引擎是将数据变为视图的最优雅的解决方案,如下图所示
-
历史上曾经出现的数据变为视图的方法 实现下图页面不同方法对比
-
纯 DOM 法:非常笨拙,无实战价值
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> </head> <body> <ul id="list"> </ul> <script> var arr = [ { "name": "小明", "age": 12, "sex": "男" }, { "name": "小红", "age": 11, "sex": "女" }, { "name": "小强", "age": 13, "sex": "男" } ]; var list = document.getElementById('list'); for (var i = 0; i < arr.length; i++) { // 每遍历一项,都要用DOM方法去创建li标签 let oLi = document.createElement('li'); // 创建hd这个div let hdDiv = document.createElement('div'); hdDiv.className = 'hd'; hdDiv.innerText = arr[i].name + '的基本信息'; // 创建bd这个div let bdDiv = document.createElement('div'); bdDiv.className = 'bd'; // 创建三个p let p1 = document.createElement('p'); p1.innerText = '姓名:' + arr[i].name; bdDiv.appendChild(p1); let p2 = document.createElement('p'); p2.innerText = '年龄:' + arr[i].age; bdDiv.appendChild(p2); let p3 = document.createElement('p'); p3.innerText = '性别:' + arr[i].sex; bdDiv.appendChild(p3); // 创建的节点是孤儿节点,所以必须要上树才能被用户看见 oLi.appendChild(hdDiv); // 创建的节点是孤儿节点,所以必须要上树才能被用户看见 oLi.appendChild(bdDiv); // 创建的节点是孤儿节点,所以必须要上树才能被用户看见 list.appendChild(oLi); } </script> </body> </html>-
数组
join法 因为先前没有es6的反引号法,本质上是使用了字符串拼接的方法<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> </head> <body> <ul id="list"></ul> <script> var arr = [ { "name": "小明", "age": 12, "sex": "男" }, { "name": "小红", "age": 11, "sex": "女" }, { "name": "小强", "age": 13, "sex": "男" } ]; var list = document.getElementById('list'); // 遍历arr数组,每遍历一项,就以字符串的视角将HTML字符串添加到list中 for (let 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> </html>
-
-
ES6 的反引号法
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> </head> <body> <ul id="list"></ul> <script> var arr = [ { "name": "小明", "age": 12, "sex": "男" }, { "name": "小红", "age": 11, "sex": "女" }, { "name": "小强", "age": 13, "sex": "男" } ]; var list = document.getElementById('list'); // 遍历arr数组,每遍历一项,就以字符串的视角将HTML字符串添加到list中 for (let 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].sex}</p> <p>年龄:${arr[i].age}</p> </div> </li> `; } </script> </body> </html>
-
mustache 基本使用
循环对象数组
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="container"></div>
<!-- 模板 -->
<script type="text/template" id="mytemplate">
<ul>
{{#arr}}
<li>
<div class="hd">{{name}}的基本信息</div>
<div class="bd">
<p>姓名:{{name}}</p>
<p>性别:{{sex}}</p>
<p>年龄:{{age}}</p>
</div>
</li>
{{/arr}}
</ul>
</script>
<script src="jslib/mustache.js"></script>
<script>
var templateStr = document.getElementById('mytemplate').innerHTML;
var data = {
arr: [
{ "name": "小明", "age": 12, "sex": "男" },
{ "name": "小红", "age": 11, "sex": "女" },
{ "name": "小强", "age": 13, "sex": "男" }
]
};
var domStr = Mustache.render(templateStr, data);
var container = document.getElementById('container');
container.innerHTML = domStr;
</script>
</body>
</html>
不循环
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="container"></div>
<script src="jslib/mustache.js"></script>
<script>
var templateStr = `
<h1>我买了一个{{thing}},好{{mood}}啊</h1>
`;
var data = {
thing: '华为手机',
mood: '开心'
};
var domStr = Mustache.render(templateStr, data);
var container = document.getElementById('container');
container.innerHTML = domStr;
</script>
</body>
</html>
循环简单数组
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="container"></div>
<script src="jslib/mustache.js"></script>
<script>
var templateStr = `
<ul>
{{#arr}}
<li>{{.}}</li>
{{/arr}}
</ul>
`;
var data = {
arr: ['A', 'B', 'C']
};
var domStr = Mustache.render(templateStr, data);
var container = document.getElementById('container');
container.innerHTML = domStr;
</script>
</body>
</html>
数组的嵌套情况
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="container"></div>
<script src="jslib/mustache.js"></script>
<script>
var templateStr = `
<ul>
{{#arr}}
<li>
{{name}}的爱好是:
<ol>
{{#hobbies}}
<li>{{.}}</li>
{{/hobbies}}
</ol>
</li>
{{/arr}}
</ul>
`;
var data = {
arr: [
{'name': '小明', 'age': 12, 'hobbies': ['游泳', '羽毛球']},
{'name': '小红', 'age': 11, 'hobbies': ['编程', '写作文', '看报纸']},
{'name': '小强', 'age': 13, 'hobbies': ['打台球']},
]
};
var domStr = Mustache.render(templateStr, data);
var container = document.getElementById('container');
container.innerHTML = domStr;
</script>
</body>
</html>
布尔值
相当于 vue 中的 v-if
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="container"></div>
<script src="jslib/mustache.js"></script>
<script>
var templateStr = `
{{#m}}
<h1>你好</h1>
{{/m}}
`;
var data = {
m: false
};
var domStr = Mustache.render(templateStr, data);
var container = document.getElementById('container');
container.innerHTML = domStr;
</script>
</body>
</html>
mustache 的底层核心原理
-
mustache 库不能用简单的正则表达式思路实现
-
在较为简单的示例情况下,可以用正则表达式实现
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> </head> <body> <script> var templateStr = '<h1>我买了一个{{thing}},花了{{money}}元,好{{mood}}</h1>'; var data = { thing: '白菜', money: 5, mood: '激动' }; // 最简单的模板引擎的实现机理,利用的是正则表达式中的replace()方法。 // replace()的第二个参数可以是一个函数,这个函数提供捕获的东西的参数,就是$1 // 结合data对象,即可进行智能的替换 function render(templateStr, data) { return templateStr.replace(/\{\{(\w+)\}\}/g, function (findStr, $1) { return data[$1]; }); } var result = render(templateStr, data); console.log(result); </script> </body> </html> -
但是当情况复杂时,正则表达式的思路肯定就不行了。比如这下面的模板字符串,是不能用正则表达式的思路实现的
-
mustache库的机理
mustache库底层重点要做两个事情:
-
将模板字符串编译为tokens形式
-
tokens 是一个 JS 的嵌套数组,说白了,就是模板字符串的 JS 表示
-
它是 “抽象语法树”、“虚拟节点” 等等的开山鼻祖
<h1>我买了一个{{thing}},好{{mood}}啊</h1> // 对应的tokens如下
-
-
将tokens结合数据,解析为dom字符串
带你手写实现 mustache 库
开发时的注意事项
-
学习源码时,源码思想要借鉴,而不要抄袭。要能够发现源码中书写的精彩的地方;
-
将独立的功能拆写为独立的js文件中完成,通常是一个独立的类,每个单独的功能必须能独立的“单元测试”;
-
应该围绕中心功能,先把主干完成,然后修剪枝叶;
-
功能并不需要一步到位,功能的拓展要一步步完成,有的非核心功能甚至不需实现;
环境搭建
-
初始化项目
npm init // 连续按回车 -
安装相关依赖,利用 webpack-dev-server 提供了一个基本的 web server,并且具有 live reloading(实时重新加载) 功能
npm i -D webpack@4 webpack-cli@3 webpack-dev-server@3 -
新建 webpack.config.js 文件
const path = require('path'); module.exports = { // 模式,开发 mode: 'development', // 入口 entry: './src/index.js', // 打包到什么文件 output: { filename: 'bundle.js' }, // 配置一下webpack-dev-server devServer: { // 静态文件根目录 contentBase: path.join(__dirname, "www"), // 不压缩 compress: false, // 端口号 port: 8080, // 虚拟打包的路径,bundle.js文件没有真正的生成 publicPath: "/xuni/", // 自动打开页面 open: true, } }; -
新建 src 文件夹,在其中新建 index.js 文件
aleart("hello"); -
新建 www 文件夹,在其中新建 index.html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> <script src="/xuni/bundle.js"></script> </head> <body> <h3>我是页面hi!</h3> </body> </html> -
修改 package.json 文件,新增启动命令
{ "name": "self_template", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "dev": "webpack-dev-server" // 新增启动命令 }, "author": "", "license": "ISC", "devDependencies": { "webpack": "^4.46.0", "webpack-cli": "^3.3.12", "webpack-dev-server": "^3.11.3" } } -
到此环境搭建完成,运行
yarn dev命令启动项目
扫描器
-
首先,我们需要修改 index.html 文件,来调用模板引擎的渲染函数
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> <script src="/xuni/bundle.js"></script> </head> <body> <h3>我是页面hi!</h3> <script> // 模板字符串 var templateStr = ` <div> <ul> {{#students}} <li class="myli"> 学生{{name}}的爱好是 <ol> {{#hobbies}} <li>{{.}}</li> {{/hobbies}} </ol> </li> {{/students}} </ul> </div> `; zx_template.render(templateStr); </script> </body> </html> -
接着,需要在入口文件中定义渲染函数
window.zx_template = { render(templateStr,data) { console.log("渲染函数调用成功!") } }; -
接着我们来实现扫描器的具体代码。
-
首先我们需要明确扫描器的作用:将模板字符串分割,比如将
我都红红火火{{index}},哈哈哈{{yyds}}分割成 我都红红火火、index、,哈哈哈、yyds -
既然是分割字符串,那肯定需要对字符串进行截取,那自然少不了使用 substring/substr 了,因为还需要判断有没有扫描到最后,所以要使用 indexOf 来获取当先扫描的位置,那接下来的问题就是如何截取了,具体代码如下
/* 扫描器类 */ export default class Scanner { constructor(templateStr) { // 将模板字符串写到实例身上 this.templateStr = templateStr; // 指针 this.pos = 0; // 尾巴,一开始就是模板字符串原文 this.tail = templateStr; } // 功能弱,就是走过指定内容,没有返回值 scan(tag) { if (this.tail.indexOf(tag) == 0) { // tag有多长,比如{{长度是2,就让指针后移多少位 this.pos += tag.length; // 尾巴也要变,改变尾巴为从当前指针这个字符开始,到最后的全部字符 this.tail = this.templateStr.substring(this.pos); } } // 让指针进行扫描,直到遇见指定内容结束,并且能够返回结束之前路过的文字 scanUtil(stopTag) { // 记录一下执行本方法的时候pos的值 const pos_backup = this.pos; // 当尾巴的开头不是stopTag的时候,就说明还没有扫描到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); } // 指针是否已经到头,返回布尔值。end of string eos() { return this.pos >= this.templateStr.length; } }; -
对扫描器进行单元测试
import Scanner from "./Scanner.js" window.zx_template = { render(templateStr,data) { var scanner = new Scanner(templateStr); var word; // 当扫描器没有走到头时 while(scanner.pos !== templateStr.length) { word = scanner.scanUtil("{{"); console.log(word); scanner.scan("{{"); word = scanner.scanUtil("}}"); console.log(word); scanner.scan("}}"); } } };根据控制台输出的结果来看,扫描器的基本功能实现
- 接下来我们进一步优化扫描器,在 src 下新建 parseTemplateToTokens.js 文件,将其封装成一个函数,并让其返回数组形式的 tokens
import Scanner from "./Scanner.js" export default function parseTemplateToTokens() { var scanner = new Scanner(templateStr); var words; var tokens = []; while(scanner.pos !== templateStr.length) { words = scanner.scanUtil("{{"); if(words !== '') { tokens.push(["text",words]) } scanner.scan("{{"); words = scanner.scanUtil("}}"); if(words !== '') { if(words[0] === '#') { tokens.push(["#",words.substr(1)]) } else if(words[0] === '/') { tokens.push(["/",words.substr(1)]) } else { tokens.push(["name",words]) } } scanner.scan("}}"); } return tokens; } -
在 index.js 中进行测试
import parseTemplateToTokens from "./parseTemplateToTokens.js" window.zx_template = { render(templateStr,data) { var tokens = parseTemplateToTokens(templateStr); console.log(tokens); } };成功打印
-
折叠器
之前,我们已经将扫描器的结果处理成数组的形式,但是数组是处理不了嵌套情况的。那么接下来我们可以对其进行遍历处理成我们最终想要的结果,如下图
-
在 src 文件下新建文件 nestTokens.js 文件
export default function nestTokens(tokens) { // 结果数组 var nestedTokens = []; // 栈 var sections = []; // 收集器,根据当前栈动态变化 var collector = nestedTokens; // 遍历数组 for(let token of tokens) { switch(token[0]) { case '#' : // 收集器放入当前遍历的 token collector.push(token); // 入栈 sections.push(token); // 改变当前存储器的位置 collector = token[2] = []; break; case '/': // 出栈 sections.pop(); // 改变收集器为栈结构队尾(队尾是栈顶)那项的下标为2的数组 collector = sections.length > 0 ? sections[sections.length - 1][2] : nestedTokens; break; default : // 甭管当前的collector是谁,可能是结果nestedTokens,也可能是某个token的下标为2的数组,甭管是谁,推入collctor即可。 collector.push(token); } } return nestedTokens; } -
在 parseTemplateToTokens.js 文件中使用
import Scanner from "./Scanner.js" import nestTokens from "./nestTokens" export default function parseTemplateToTokens() { var scanner = new Scanner(templateStr); var words; var tokens = []; while(scanner.pos !== templateStr.length) { words = scanner.scanUtil("{{"); if(words !== '') { tokens.push(["text",words]) } scanner.scan("{{"); words = scanner.scanUtil("}}"); if(words !== '') { if(words[0] === '#') { tokens.push(["#",words.substr(1)]) } else if(words[0] === '/') { tokens.push(["/",words.substr(1)]) } else { tokens.push(["name",words]) } } scanner.scan("}}"); } return nestTokens(tokens); }
结合 data 生成最终的 DOM 字符串
现在,我们已经实现了下图中红框中功能了,接下来就是结合数据生成最终的 dom 字符串
首先我们先实现一个简易的版本,带大家来体验一下这个功能
-
在 src 下新建 renderTemplate.js 文件
export default function renderTemplate(tokens,data) { var resultStr = ''; for(let token of tokens) { if(token[0] === 'text') { resultStr += token[1] } else { resultStr += data[token[1]] } } return resultStr; } -
接着修改 index.html 文件中模板字符串和 data
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> <script src="/xuni/bundle.js"></script> </head> <body> <h3>我是页面hi!</h3> <script> var templateStr = "我爱{{somebody}},{{somebody}}也爱我,我今年{{a.b.c}}岁"; var data = { somebody : "love", a: { b: { c: 18 } } } zx_template.render(templateStr,data); </script> </body> </html> -
然后在 index.js 文件中引入使用
import parseTemplateToTokens from "./parseTemplateToTokens.js" import renderTemplate from "./renderTemplate.js" window.zx_template = { render(templateStr,data) { var tokens = parseTemplateToTokens(templateStr); var resultStr = renderTemplate(tokens, data); console.log(resultStr); } }; -
最终打印结果如下
我爱love,love也爱我,我今年undefined岁 -
现在问题出现了,虽然成功结合了前面的 data,但是后面较复杂的 data 确实 undefined,原因是在 js 中, data[‘a.b.c’] 肯定是识别不出来的,所以这时候我们需要封装一个函数,专门将
data[‘a.b.c’]这种语法识别
lookup
export default function lookup(keyNameObj,keyName) {
if(keyName.indexOf('.') !== -1 && keyName !== '.') {
const keys = keyName.split('.');
var result = keyNameObj;
for(let key of keys) {
result = result[key] // 洋葱卷
}
return result;
} else {
return keyNameObj[keyName]
}
}
在 renderTemplate.js 中使用
import lookup from "./lookup";
export default function renderTemplate(tokens,data) {
var resultStr = '';
for(let token of tokens) {
if(token[0] === 'text') {
resultStr += token[1]
} else {
resultStr += lookup(data,token[1]) // 使用
}
}
return resultStr;
}
parseArray
完善 renderTemplate 函数,使其支持嵌套情况
-
在 src 下新建 parseArray.js 文件
import lookup from "./lookup" import renderTemplate from "./renderTemplate" export default function parseArray(token,data) { console.log("token",token); const v = lookup(data,token[1]); console.log("v是这个",v); var resultStr = ''; for(let i of v) { console.log("循环里的i",i); // 递归调用 resultStr += renderTemplate(token[2], { ...i, '.': i }); } return resultStr; } -
在 renderTemplate.js 中使用
import lookup from "./lookup"; import parseArray from "./parseArray"; export default function renderTemplate(tokens,data) { var resultStr = ''; for(let token of tokens) { 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; }