mustache
引言:vue中的{{}}是使用了mustache语法,还有v-for,v-if等等指令也是基于这种模板引擎,本文对mustache进行深入,mustache的源码官方有九百行,作者在最后实现了简单的百行版本(主体功能实现,错误处理小功能等未进行处理)。
什么是模板引擎
数据变为视图的最优雅的解决方案。
历史发展:
纯DOM (手动创建dom节点和对应赋值)-> join -> es6模板字符串 -> 模板引擎
假设有这样一个数据
[
{name:'A',sex:'male'},
{name:'B',sex:'female'},
{name:'C',sex:'male'}
]
要变为这样的视图
<ul>
<li>
<div class="left">
A的信息
</div>
<div class="right">
<p>
姓名A
</p>
<p>
性别:male
</p>
</div>
</li>
<li>
<div class="left">
B的信息
</div>
<div class="right">
<p>
姓名B
</p>
<p>
性别:female
</p>
</div>
</li>
<li>
<div class="left">
C的信息
</div>
<div class="right">
<p>
姓名C
</p>
<p>
性别:male
</p>
</div>
</li>
</ul>
DOM法:
<html>
<ul class="container">
</ul>
</html>
let data= [
{name:'A',sex:'male'},
{name:'B',sex:'female'},
{name:'C',sex:'male'}
];
let ulEle = document.getElementByClass('container');
for (let i=0;i<data.length;i++){
let li = document.createElement('li');
let left = document.createElement('div');
left.className = 'left';
left.innerHtml = data[i].name+'的信息';
let right = document.createElement('');
right.className = 'right';
right.innerHtml = '<p>姓名:'+data[i].name+'</p><p>性别:'+data[i].sex+'</p>';
li.appendChild(left);
li.appendChild(right);
ulEle.appendChild(li);
}
join法(利用join生成带有结构的html字符串):
<html>
<head></head>
<body>
<div id="container">
</div>
</body>
<script>
let data= [
{name:'A',sex:'male'},
{name:'B',sex:'female'},
{name:'C',sex:'male'}
];
for (let i=0;i<data.length;i++){
let HtmlStr = [
'<li>',
'<div class="left">',
data[i].name+'的信息',
'</div>',
'<div class="right">',
'<p>姓名:'+data[i].name+'</p>',
'<p>性别:'+data[i].sex+'</p>',
'</div>',
'</li>'
].join('');
let ulEle = document.getElementById('container');
ulEle.innerHTML += HtmlStr;
}
</script>
</html>
es6 反引号`` 由于允许换行,所以基于上面join思想更加简便
<html>
<head></head>
<body>
<div id="container">
</div>
</body>
<script>
let data= [
{name:'A',sex:'male'},
{name:'B',sex:'female'},
{name:'C',sex:'male'}
];
for (let i=0;i<data.length;i++){
let HtmlStr = `
<li>
<div class="left">
${data[i].name}的信息
</div>
<div class="right">
<p>
姓名${data[i].name}
</p>
<p>
性别:${data[i].s}
</p>
</div>
</l>
`
let ulEle = document.getElementById('container');
ulEle.innerHTML += HtmlStr;
}
</script>
</html>
mustache使用
mustache是最早的模板引擎,vue只是引用了语法
引入mustache库才能使用
如上面的例子可以写成
<ul>
{{#data}}
<div class="left">
{{name}}的信息
</div>
<div class="right">
<p>
姓名:{{name}}
</p>
<p>
性别:{{sex}}
</p>
</div>
{{/data}}
</ul>
render方法将数据data可以渲染进模板字符串中,实现数据转为视图
Mustache.render(templateStr, data);生成渲染数据后的HTML字符串
data中的数组(假设arr)可以用开始的{{#arr}}和结束的{{/arr}}来声明循环渲染的内容
<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>
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>
在每次{{#array}},{{/}} 循环遍历数组,如果数组每项为基础数据值也可以用 {{. }}来渲染值。
还可以用{{#bool}} ,用data中的布尔值来确定是否渲染DOM,(类似v-show)
<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>
如果不用``,还可以用scrpit,type不为javascript来储存模板字符串。
<!-- 模板 -->
<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>
mustache的底层核心机理
结论:mustache不能用简单的正则表达式实现思路
<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>
其中的正则匹配 {{ 开头,}}结尾,中间使用()来捕获内容给回调函数的第二个参数,函数中返回要替换匹配的值,(使用第二个参数捕获的属性和 对象[str]取值) 。
实现了简单用data替换正则表达{{}}里的值,实现最简单的模板引擎
tokens
模板字符串的js来表示(js的嵌套数组),是抽象语法树,虚拟节点的起源。
根据源码分割token的打印,大致可以看出相应的分割规则。
最开始是一个二维数组,每项的第一个项为类型:文本是'text'(包括标签和换行空格等); 循环数组是'#',从#到/之间的部分都为这一个行;数据值为'name',代表要替换的基础数据
tokens数组的每一行都是由{{}}来分割的,每一行第一项为类型,第二项为值(其中text就为所有的字符串,#则为要遍历数组的属性名),第三项为在整个模板字符串的开始索引,第四项为整个模板字符串的结束索引。如果是#还有第五项和第六项:第五项为递归的二维数组,这意味着从一开始的规则重新进行分割 ,因此模板字符串嵌套层数高,tokens相应也会深层嵌套; 第六项为/的循环结束索引
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;
看下相应的tokens分解
mustache的实现
使用webpack创建工程化项目,使得js文件分功能模块更加清晰。
npm init -y
npm install webpack webpack-cli webpack-dev-server
//webpack.config.js配置文件
const path = require('path');
module.exports = {
// 模式,开发
mode: 'development',
// 入口
entry: './src/index.js',
// 打包到什么文件
output: {
filename: 'main.js'
},
// 配置一下webpack-dev-server
devServer: {
// 静态文件根目录
contentBase: path.join(__dirname, "www"),
// 不压缩
compress: false,
// 端口号
port: 8080,
// 虚拟打包的路径,main.js文件没有真正的生成
publicPath: "/xuni/"
}
};
整体实现思路
大方向为两个工作: 将模板字符串转化为tokens数组 ; 利用tokens数组和传进去的data生成填充数据后的HTML代码
细分步骤:
- 实现scanner类
- 生成tokens数组
- tokens数组的折叠
- 将数据和tokens数组进行结合生成HTML字符串
实现scanner类
scanner类实现的功能为匹配所有的{{和}},并将{{前的字符串和{{后 }}前的字符串提取出来。
方法有两个: scanUtil实现跳到指定字符串的位置并收集跳过的字符串,scan实现直接跳过指定的字符串。
具体代码如下
//scanner.js
export default class Scanner {
constructor(templateStr) {
console.log(templateStr);
this.templateStr = templateStr;
this.pos = 0;
this.tail = templateStr.substring(this.pos);
}
scan(skipStr) { //scan用于直接跳过指定的字符串
if (this.tail.indexOf(skipStr)== 0)
{
this.pos += skipStr.length;
this.tail = this.templateStr.substring(this.pos);
}
}
scanUtil(UtilStr) { //跳转到指定字符串的开头,并返回经过的字符串
let startIndex = this.pos;
while (this.tail.indexOf(UtilStr) != 0 && this.pos < this.templateStr.length) {
this.pos++;
this.tail = this.templateStr.substring(this.pos);
}
return this.templateStr.substring(startIndex,this.pos);
}
}
使用index.js测试如下
//index.js
import scanner from './scanner.js'
window.MustacheEngine = { //声明全局下的MustacheEngine对象
render : function(templateStr,Data){
let scan = new scanner(templateStr);
while(scan.pos <scan.templateStr.length){
console.log(scan.scanUtil('{{'));
scan.scan('{{');
console.log(scan.scanUtil('}}'));
scan.scan('}}');
}
}
}
生成tokens数组
创建genTokens.js文件导出genTokens函数,在index.js中引入并接收生成的tokens数组。
import Scanner from "./scanner";
import nestTokens from "./nestTokens";
export default function genTokens(templateStr){
let tokens = [];
let scanner = new Scanner(templateStr);
let words;
while (scanner.pos < scanner.templateStr.length){
words = scanner.scanUtil('{{');
if (words){
tokens.push(['text',words]);
}
scanner.scan('{{');
words = scanner.scanUtil('}}');
if (words){
switch (words[0]){
case '#':
tokens.push(['#',words.substring(1)]);
break;
case '/':
tokens.push(['/',words.substring(1)]);
break;
default:
tokens.push(['name',words])
}
}
scanner.scan('}}');
}
let resultTokens = nestTokens(tokens);
return resultTokens;
}
tokens数组的折叠
#和/要形成子数组的开始和结束,适用于栈的结构来进行操作。
遇到#就压栈,/就出栈。
新建nextTokens.js来进行tokens的折叠。
先来看官方源码
function nestTokens(tokens) {
//nestTokens是结果数组,返回折叠后的tokens数组
var nestedTokens = [];
//collector是数组的指针,会随着嵌套关系变化
var collector = nestedTokens;
//sections是栈,遇到#压栈,遇到/出栈,用来管理嵌套关系,里面存储的都是要进行嵌套的初始数组
var sections = [];
var token, section;
for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) {
token = tokens[i];
//此时的token第一项为类型,第二项为值,三四为索引,如果是#第五项要折叠成子数组
switch (token[0]) {
case '#':
case '^':
//collector压栈,代表着当前未处理数组中最深的数组先把这个嵌套数组先存储
collector.push(token);
//维护嵌套:压栈
sections.push(token);
//这里collector指向目前最深的数组
collector = token[4] = [];
break;
case '/':
//栈顶出栈
section = sections.pop();
section[5] = token[2];
//根据维护的栈情况来确定指向:如果还有项指向目前栈顶数组,没有说明嵌套完毕指向返回的结果数组。
collector = sections.length > 0 ? sections[sections.length - 1][4] : nestedTokens;
break;
default:
//所有文本都直接由collector进行压栈。
collector.push(token);
}
}
return nestedTokens;
}
实现:
//nestTokens.js
export default function nestTokens(tokens){
//结果数组
let resultTokens = [];
//数组指针,指向当前未处理的最深层次数组
let current = resultTokens;
//维护的栈
let sections = [];
for (let i=0 ;i<tokens.length;i++){
//token为未折叠的初始二维数组
let token = tokens[i];
//类型匹配
switch (token[0]){
case '#':
//压栈储存数组
current.push(token);
sections.push(token);
//重点: 开辟索引2的子数组并把指针指向子数组
current = token[2] = [];
break;
case '/':
sections.pop();
//重点: 维护的栈如果还有内容则指向栈顶进行子数组内容填充,数目0指向结果数组
current = sections.length>0 ? sections[sections.length-1][2]:resultTokens
break;
default:
//由于current是动态的,所有内容都由current来储存
current.push(token);
break;
}
}
return resultTokens;
}
将数据和tokens数组进行结合生成HTML字符串
其中提前写好lookup函数,用于{{}}中使用.运算符
//lookup.js
//对data对象实现深层次的属性查找 如a.b.c
export default function lookup(keyStr,Data){
if (keyStr.indexOf('.')!= 0 ){
let keyArr = keyStr.split('.');
let temp = Data;
for (let i=0;i< keyArr.length;i++){
temp = temp[keyArr[i]];
}
return temp;
}
return Data[keyStr];
}
创建renderTemplate,返回最后生成的HTML
import lookup from "./lookup.js";
export default function renderTemplate(tokens,Data){
let HtmlStr='';
for (let i=0;i<tokens.length;i++){
let token = tokens[i];
let type = token[0];
switch (type){
case 'text':
HtmlStr+= token[1];
break;
case 'name':
let value = lookup(token[1],Data);
HtmlStr += value;
break;
case '#':
let array = lookup(token[1],Data);
for (let i=0;i<array.length;i++){
HtmlStr += renderTemplate(token[2],{...array[i],'.':array[i]});
}
}
}
return HtmlStr;
}
最终的index.js
import genTokens from './genTokens.js'
import renderTemplate from './renderTemplate.js'
window.MustacheEngine = { //声明全局下的MustacheEngine对象
render : function(templateStr,Data){
let tokens = genTokens(templateStr);
// console.log(tokens);
let DomStr = renderTemplate(tokens,Data);
// console.log(`DomStr:${DomStr}`);
return DomStr;
}
}
总结
未涉及到vue的模板指令v-for,v-if等等,但它们都是基于mustache,都是将数据转成视图。
将数据转成视图的方法有很多(有循环则遍历数组): DOM法(手动根据data来创建相对应的HTML代码);数组join法(将模板HTML字符串每一行都写在数组中,数据进行相对应的填充,最后利用join''生成HTML);es6反引号`(join的升级版,由于可以换行,直接写在反引号中,用$填充数据,最后遍历生成HTML); 引用Mustache库函数render生成(传进模板字符串和数据生成)
模板字符串使用规则如下:
插入数据三种形式
{arg}, {a.b}, {.} 分别代表着传入数据的 arg属性值;数据的a对象属性中的b的属性值;用在循环中并且每一项都为基础属性值
遍历 {{#array}} 开始 ,{{/array}} 结束,中间的内容会循环渲染array的长度次数,里面自动转为array对象中的数据
{{#boolean}开始,{{/m}}结束,类似v-show。true添加dispaly:none的样式
实现的原理: 将传入的模板字符串转成tokens,结合传入的data生成填充数据渲染后的HTML字符串。
tokens是反映模板字符串结构和内容的数组:它的分割是基于{{}},里面的内容为name类型,值为key;外面的字符串内容都为text,值就是字符串;遇到{{#array}},会被解析为 #类型,值为array的引用,这个时候会开辟第2的索引,存放子tokens数组。遇到{{/array}},会被解析为/类型,值为array索引。
具体实现的步骤:
- Scanner类:储存templateStr,pos,tail,实现过指定字符串并返回内容的函数
- 生成tokens:生成scan实例,遍历模板字符串,使用scan{{拿到text的内容并存进tokens中;使用}}拿到要渲染的内容进行分类:如果是#开头说明要循环,存入#类型的数组,值为数组的key;如果是/开头存入/类型,值为数组;其他情况则是data的key,存入name类型
- tokens数组的折叠:维护一个栈sections和数组指针current,添加内容都在栈顶添加(栈中如果没有元素则代表是根数组tokens数组),遇到token项类型为'#',则压栈(包括sections和当前数组),开辟第三个位置数组,并将current指向栈顶元素,遇到token项类型为'/',则直接出栈,并考虑current是否指向根数组;其他情况则为name或者text的token直接存至current数组中去。
- tokens和data结合生成HTML:对tokens数组进行每一项类型检查:如果为text,直接加进htmlStr中去;如果为name,将数据值加入htmlStr;如果为#,则需要进行递归调用,将这一项的子tokens和封装了.属性的子对象传入参数。最后返回整个htmlStr。
手写版本
大致框架:
export default function render(templateStr,Data){
//生成tokens
let tokens = genTokens(templateStr);
//生成HTML
let htmlStr = renderTemplate(tokens,Data);
return htmlStr;
}
function genTokens(templateStr){
//生成初始tokens
let initTokens = initTokens(templateStr);
//生成折叠后的tokens
let resultTokens = nestTokens(initTokens);
return resultTokens;
}
function initTokens(templateStr){
let initTokens = [];
let pos = 0;
return initTokens;
}
function nestTokens(initTokens){
let resultTokens = [];
return resultTokens;
}
function renderTemplate(tokens,Data){
let htmlStr = '';
return htmlStr;
}
具体实现:
window.MustacheEngine = {};
let mustacheEngine = window.MustacheEngine;
//主函数,生成渲染后的HTML字符串
window.MustacheEngine.render = function (templateStr, Data) {
//生成tokens
let tokens = mustacheEngine.genTokens(templateStr);
//生成HTML
let htmlStr = mustacheEngine.renderTemplate(tokens, Data);
return htmlStr;
}
window.MustacheEngine.genTokens = function (templateStr) {
//生成初始tokens
let initTokens = mustacheEngine.initTokens(templateStr);
//生成折叠后的tokens
let resultTokens = mustacheEngine.nestTokens(initTokens);
return resultTokens;
}
//生成初始tokens
window.MustacheEngine.initTokens = function (templateStr) {
//初始化的tokens生成
let initTokens = [];
//记录位置
let pos = 0;
while (pos < templateStr.length) {
//收集{{之前的内容
let contentObj = mustacheEngine.scan('{{', pos, templateStr);
let content = contentObj.content;
//text类型的token
initTokens.push(['text',content]);
pos = contentObj.pos;
pos += templateStr.substring(pos).indexOf('{{')==0 ?'{{'.length:0;
//收集{{之后,}}之前的内容
contentObj = mustacheEngine.scan('}}', pos, templateStr);
content = contentObj.content;
if (content!== ''){
//判断类型
switch(content[0]){
case '#':
initTokens.push(['#',content.substring(1)]);
break;
case '/':
initTokens.push(['/',content.substring(1)]);
break;
default:
initTokens.push(['name',content])
}
}
pos = contentObj.pos;
pos += templateStr.substring(pos).indexOf('}}')==0 ?'}}'.length:0;
}
return initTokens;
}
//生成嵌套tokens
window.MustacheEngine.nestTokens = function (initTokens) {
//整体算法是利用栈先入后出,一直处理栈顶元素符合HTML结构。
//console.log(initTokens);
//折叠后的数组
let resultTokens = [];
//维护的栈
let sections = [];
//数组指针current,指向目前栈顶token,没有栈顶则处理根数组resultTokens
let current = resultTokens;
for (let i=0;i<initTokens.length;i++){
let token = initTokens[i];
let type = token[0];
switch (type){
case '#':
//#代表要循环
sections.push(token);
current.push(token);
//这里开辟子数组在索引2的位置
current = token[2] = [];
break;
case '/':
//结束循环
sections.pop();
//current指向栈顶(开辟的子数组),栈中没有则指向根数组
current = sections.length >0 ? sections[sections.length-1][2] : resultTokens;
break;
default:
current.push(token);
}
}
return resultTokens;
}
//结合tokens和数据,返回HTML字符串
window.MustacheEngine.renderTemplate = function (tokens, Data) {
// console.log(tokens);
// console.log(Data);
let htmlStr = '';
for (let i=0;i<tokens.length;i++){
let token = tokens[i];
let type = token[0];
switch (type){
case 'text':
htmlStr += token[1];
break;
case 'name':
//实现对象的.调用
let value = mustacheEngine.lookup(token[1],Data);
htmlStr += value;
break;
case '#':
//找到传入data的array引用
let array = mustacheEngine.lookup(token[1],Data);
for (let i=0;i<array.length;i++){
htmlStr += mustacheEngine.renderTemplate(token[2],{
...array[i],
'.':array[i] // 实现.代表数组每一项为基础数据值,如 ['value1','value2']
})
}
}
}
return htmlStr;
}
//扫描到指定字符串并返回之前的内容
window.MustacheEngine.scan = function (scanStr, pos, templateStr) {
let pre = pos;
let tail = templateStr.substring(pos);
while (tail.indexOf(scanStr) != 0) {
if (pos >= templateStr.length)
break;
pos++;
tail = templateStr.substring(pos);
}
return { 'pos': pos, 'content': templateStr.slice(pre, pos) };
}
//给出keyStr和对象obj,返回属性(实现.)/ a.b.c
window.MustacheEngine.lookup = function(keyStr,obj){
//注意!:这里实现对象的., keyStr就是.
if (keyStr.indexOf('.')!= -1 && keyStr!== '.'){
let keyArray = keyStr.split('.');
let temp = obj;
for (let i=0;i<keyArray.length;i++){
temp = temp[keyArray[i]];
}
return temp;
}
//没有.则直接返回obj的key属性值
return obj[keyStr];
}