mustache: 中文意思是:髭;上唇的胡子;长髭
它是一款经典的前端模板引擎,在前后端分离的技术架构下面,一度流行。之前也用过 art-template 之类的模板插件,应该也是同样的原理。如今随着 前端三大框架 的流行,这种方式已经深入前端人心。但是我还是第一次听到这个框架,就去了解了一下。真的是,日用而不知。
Mustache
简单介绍一下我所知道的前端历史
前后端不分离
页面基本是静态页面,后端采用JSP,freemarker,jdea,babel等渲染框架对前端模板进行预编译。
前后分离
使用字符串拼接
前端获取数据以后,利用如下的集中拼接方式
var data = {name:'孙悟空',age:19}
var html = "" + data.name +"
"
document.getElementById('container').innerHTML = html
使用反引号
var data = {name:'孙悟空',age:19}
var html = `${data.name}
`
document.getElementById('container').innerHTML = html
遇到循环时候
var html = ""
var data = {student:[{name:'张三'},{name:'李四'},{name:'王五'}]}
data.students.forEach(function(stu){
html += "" + item.name + ""
})
document.getElementById('student').innerHTML = html
换一种写法: 使用join()方法, 或者 concat 方法等
var html = ""
var data = {student:[{name:'张三',age: 20},{name:'李四',age: 18},{name:'王五', age: 30}]}
data.students.forEach(function(item){
html += ["" + item.name + "","" + item.age + ""].join(" ")
})
document.getElementById('student').innerHTML = html
使用 art-template 渲染模板
var html = template('test', data);
document.getElementById(‘content’).innerHTML = html;
用 vue react等框架渲染
再后来运用vue react 等框架以后的渲染模式大家应该很清楚,这里就不再阐述了
mustache的用法
举个例子:
var templateStr =`
{{name}}
{{#students}}
-
学生{{name}}的爱好是
{{#hobbies}}
- {{.}}
{{/hobbies}}
{{/students}}
var data = {
name: '齐天大圣',
students: [
{name:'小明', hobbies: ['游泳','健身']},
{name:'小红', hobbies: ['足球','篮球', '羽毛球']},
{name:'小强', hobbies: ['吃饭','睡觉']}
]
}
对于上述的js模板,通过mustache处理以后就会变成
齐天大圣
- 学生小明的爱好是
- 游泳
- 健身
- 学生小红的爱好是
- 足球
- 篮球
- 羽毛球
- 学生小强的爱好是
- 吃饭
- 睡觉
是不是很像vue react中的语法,可以想象如今框架肯定借鉴了这会写法,并把它加以改进,发扬光大。
逻辑分析
-
对于简单的模板,我们可以用正则表达式进行实现
例如下面的简单的:
模板字符串如下:
我买了一个{{thing}},我觉得好{{mood}}
数据如下:
{ thing: '华为手机', mood: '开心' }
实现方式如下:
var data = { thing: '华为手机', mood: '开心' }
var result = '我买了一个{{thing}},我觉得好{{mood}}
'.replace(/\{\{(\w+)\}\}/g, function(match, $1){
// $1 分别是 thing mood
return data[$1]
})
console.log(result) // 我买了一个华为手机,我觉得好开心
- 但是当情况复杂时候,例如循环时候或者判断时候,正则思路就不行了,
tips: 模板字符串如下(其中.代表展开)
数据如下
{ arr: ["香蕉","苹果","橘子","西瓜"] }
原理分析
mustache 的渲染步骤分为了两步
步骤如下:
var tokens = parseTemplateToTokens(templateStr)
// 调用 renderTemplate 函数,让tokens 数组变成 dom 字符串
var domHtml = renderTemplate(tokens, data)
对于如下模板,渲染步骤:
{{name}}
{{#students}}
-
学生{{name}}的爱好是
{{#hobbies}}
- {{.}}
{{/hobbies}}
{{/students}}
-
将模板渲染位 tokens 数组,结构类似于
image-20210626212151391.png
-
将 tokens 数组转换为相应的 html,(结合data)
var data = {
name: '齐天大圣',
students: [
{ name: '小明', hobbies: ['游泳', '健身'] },
{ name: '小红', hobbies: ['足球', '篮球', '羽毛球'] },
{ name: '小强', hobbies: ['吃饭', '睡觉'] }
]
}
转变的 html 结果如下
齐天大圣
- 学生小明的爱好是
- 游泳
- 健身
- 学生小红的爱好是
- 足球
- 篮球
- 羽毛球
- 学生小强的爱好是
- 吃饭
- 睡觉
代码实现
模板变量如下
-
实现 parseTemplateToTokens 函数
-
书写一个扫描类,遍历字符串模板,里面有两个方法,一个是开始扫描,一个是扫描截止
①:跳过某个字符的扫描方法: 接受一个参数,当尾巴模板是以这个 参数 为处理更新当前指针和剩余字符串模板,比如 参数为 {{ , 就需要把当前指针向后移动两位({{的长度),并且 尾巴字符串 也要进行相应截取
②:扫描截止方法:接受一个参数,进行循环,当循环到当前参数字符串时候,就停止,并且返回开始循环到停止循环时中间的字符串。 例如当第一次扫描到 {{ 时,返回从开始位置到当前位置之间的字符串;接着扫描指针移动 {{ 的位置,再次调用,遇到 }},返回当前扫描指针到 }} 的字符,那就是{{ 和 }} 中间的变量,
③:当前再加一个方法:指针位置是否已经到最后了,返回值是一个布尔值
class Scanner {
constructor(templateStr){
// 指针
this.pos = 0
// 尾巴,一开始就是模板字符串原文
this.tail = templateStr
this.templateStr = templateStr
}
scan(tag){
if(this.tail.indexOf(tag) == 0){
// tag 有多长,比如 {{ 长度是2,就让指针后移动几位
this.pos += tag.length
this.tail = this.templateStr.substr(this.pos)
}
}
// 让指针进行扫描 直到遇到指定内容结束,并且能够返回结束之前路过的文字
scanUtil(stopTag){
// 记录一下开始的位置
var 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)
}
// 指针是否到头,返回布尔值
eos(){
return this.pos >= this.templateStr.length
}
}
返回哈哈哈哈
-
完成 parseTemplateToTokens 函数
分析: 接受一个参数:当前字符串模板,利用 Scanner 进行处理,刚开始:指针从0开始,剩余的模板字符串(也称为尾巴)为当前所有字符串。首先调用遍历到 {{ 位置的方法,获得 {{ 前面的字符串,并push到一个数组中,以及更新指针和剩余的字符串。然后调用跳过扫描 {{ 的方法更新 当前指针 和 剩余模板。接着继续执行遍历到 }} 的位置,获得{{ 和 }} 之间的变量,push 到数组中,接着调用跳过 {{ 的方法,然后重复上述步骤,直到指针走到最后一位。
tips: 当获得 {{ 和 }} 之间的字符串时,有可能是带有 # 或者 / 的这里需要进行特殊处理,往数组 push 时候增加相应类型以示区分。text 指静态文字,name 指的是 {{ 和 }} 之间不带(#、/)的变量,# 和 / 之后的变量也有进行记录
function parseTemplateToTokens(templateStr){
// 创建扫描器
var scanner = new Scanner(templateStr)
var tokens = []
var word=""
while(!scanner.eos()){
word = scanner.scanUtil("{{")
// 这里可以判断处理一下 空格问题,需要判断处理,例如 这里的空格就不能做处理
// 增加判断:空格是在 标签中的空格还是 标签间的空格
if(word){
let _word=""
let isInnerTag = false
for (let index = 0; index < word.length; index++) {
const element = word[index];
if(element === "<"){
isInnerTag = true
}else if(element === ">"){
isInnerTag = false
}
// 如果当前element 是空格,只有在 isInnerTag 为 true 时候才能加
if(/\s/.test(element)){
if(isInnerTag){
_word += element
}
}else{
_word += element
}
}
tokens.push(['text', _word])
}
scanner.scan("{{")
word = scanner.scanUtil("}}")
if(word){
if(word[0] === "#"){
// 存起来,从下标为1的项开始存取,因为下标为0的项是#
tokens.push(['#', word.substr(1)])
}else if(word[0] === "/"){
tokens.push(['/', word.substr(1)])
}else{
tokens.push(['name', word])
}
}
scanner.scan("}}")
}
return tokens
}
以上获得了 tokens 数组,
对于如下模板
{{name}}
{{#students}}
-
学生{{name}}的爱好是
{{#hobbies}}
- {{.}}
{{/hobbies}}
{{/students}}
获得到的tokens数组是这样的
然后还要处理里面的 # 和 / , 因为#和/ 是成对出现的,中间的内容应该是# 后面的子项。
所以还需要一个处理上述tokens 的数组
function nestToken(tokens){
// 结果数组
var nestTokens = []
var sections = []
// 收集器,收集子元素或者孙元素等,天生指向 nestTokens 数组,引用类型值,所以指向的是同一个数组
// 收集器的指向会发生变化。当遇见# 时候,收集器会遇到 当前token 的下标为2的新数组,
var collector = nestTokens
var isFlag = true
// 栈结构,存放小tokens, 栈顶(靠近端口的,最新进入的)tokens数组中前操作的这个tokens小数组
tokens.forEach((token,index) => {
switch (token[0]) {
case '#':
// 收集器放入这个token
collector.push(token)
// 入栈
sections.push(token)
// 收集器要换人了, 给token 添加下标为2的项目,并让收集器指向它
collector = token[2]= []
break
case '/':
// 出栈 pop 会返回刚刚弹出的项
sections.pop()
// 改变收集器为栈结构队尾(队尾就是栈顶) 那项下标为2的数组
collector = sections.length > 0 ? sections[sections.length-1][2] : nestTokens
break
default:
collector.push(token)
break
}
})
return nestTokens
}
上面代码 精妙的地方就是声明了一个 收集器 collector 数组,当遇到 # 的时候,收集器要指向当前项目的下标为2的一项并且设置为数组,此后遍历的 token项是 被收集到收集器中,也就是token[2]中变为子项,并且有一个数组 sections push 当前token项;当遇到到 / 时候,对sections进行弹栈处理,并且进行判断处理,如果之前已经有过了#(sections数组length还不为0),那么收集器就指向sections栈顶的那一项的下标为2的数组,否则就代表是最外层,收集器指向最外层 nestTokens.
经过上述函数处理以后的结果就是
完成 parseTemplateToTokens 和 nestToken 数组
- 实现 renderTemplate 函数
经过上述分析,已经拿到了 带有嵌套关系的 数组结构
function renderTemplate(tokens, data){
var resultStr = ""
for (let index = 0; index < tokens.length; index++) {
const element = tokens[index];
if(element[0] === "text"){
resultStr +=element[1]
}else if(element[0] === "name"){
// 如果是name,说明是变量,需要对齐进行其他处理,因为可能是 a.b.c
resultStr += lookUp(data, element[1])
}else if(element[0] === "#"){
// 对于数组要进行解析处理,需要循环然后调用 renderTemplate 方法
resultStr += parseArray(element[2], data[element[1]])
}
}
return resultStr
}
// 处理 数组中 name 为 a.b.c 的变量
function lookUp(dataObj, keyName){
if(keyName.indexOf('.') !==-1 && keyName !== "."){
var temp = dataObj
var keys = keyName.split('.')
for (let index = 0; index < keys.length; index++) {
const element = keys[index];
temp = temp[keys[index]]
}
return temp
}
return dataObj[keyName]
}
function parseArray(token, array){
var resultStr = ""
array.forEach(item => {
// 这里兼容 . 属性,否则会报错
resultStr += renderTemplate(token, {
...item,
'.': item
})
})
return resultStr
}
完成
至此完成了mustache 的初步解析,当然源码比之更为复杂精炼。这里只是介绍了其基本原理。
资源参考
Vue源码解析系列课程之mustache模板引擎