一、模板引擎是什么?
模板引擎是将数据变为视图最优雅的解决方案。
1.数据转为视图的发展史
1.1纯DOM法:
效果图:
<!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>
</head>
<body>
<ul id="list">
</ul>
<script>
var arr = [
{"name":"某可","age":18,"sex":"男"},
{"name":"某鸿","age":18,"sex":"男"},
{"name":"小李","age":5,"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
let p2 = document.createElement('p')
p2.innerText = '年龄:' + arr[i].age
let p3 = document.createElement('p')
p3.innerText = '性别:' + arr[i].sex
//创建的节点都是孤儿节点,所以必须上树才能被用户看到
oLi.appendChild(hdDiv)
hdDiv.appendChild(bdDiv)
bdDiv.appendChild(p1)
bdDiv.appendChild(p2)
bdDiv.appendChild(p3)
list.appendChild(oLi)
}
</script>
</body>
</html>
总结:
纯DOM操作数据转换成视图每一步都在操作DOM,这样不仅页面开销大,而且非常麻烦,每次都需要创建并上树,数据和视图耦合度很高,也很难辨识,代码阅读性也差。
1.2数组join法:
前期背景:
由于字符串" " ’ ‘定义的字符内容是不允许换行,
反引号是ES6的新语法,以前也没有,但是数组可以换行展示,能够显示结构的层次,然后通过join(’')方法可以实现字符拼接显示
<!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>
</head>
<body>
<ul id="list">
</ul>
<script>
var arr = [
{"name":"某可","age":18,"sex":"男"},
{"name":"某鸿","age":18,"sex":"男"},
{"name":"小李","age":5,"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>
总结:
巧妙的运用了数组join方法,使得代码量大大减少了,同时由于可以换行,结构也没有那么晦涩了,但是代码和数据依旧有些粘连,无法将视图和数据分离开来
1.3ES6的反引导法:
<!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>
</head>
<body>
<ul id="list">
</ul>
<script>
var arr = [
{"name":"某可","age":18,"sex":"男"},
{"name":"某鸿","age":18,"sex":"男"},
{"name":"小李","age":5,"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>
`
}
</script>
</body>
</html>
总结:
相较于数组的话,简化了非常多的写法,减轻书写的难度,毕竟是新出的语法,效果和之前两种没有太大差异,但是听讲师说,这样写多了一个字符串解析得过程,相较于最笨的写法是慢点得,但是书写方便,我觉得还可以通过createDocumentFragment()方法降低开销
1.4模板引擎:
<script>
var templateStr = `
<ul>
{{#arr}}
<li>
<div class='hd'>{{name}}的基本信息</div>
<div class='bd'>
<p>姓名:{{name}}</p>
<p>年龄:{{age}}</p>
<p>性别:{{sex}}</p>
</div>
</li>
{{/arr}}
</ul>
`
var data = {
arr : [
{"name":"某可","age":18,"sex":"男"},
{"name":"某鸿","age":18,"sex":"男"},
{"name":"小李","age":5,"sex":"女"}
]
}
var domStr = mustache.render(templateStr,data)
var container = document.getElementById('container')
container.innerHTML = domStr
</script>
2.mustache模板引擎实现原理:
①将模板字符串编译成tokens形式
②将tokens结合数据,解析为dom字符串
1.tokens:
tokens是一个JS得嵌套数组,就是模板字符串得JS表示
它是"抽象语法树"、"虚拟节点"等等的开山鼻祖
模板字符串:
<h1> 我买了一个{{thing}},好{{mood}}啊</h1>
tokens:
其中每一项都是一个token,例如:["text",",好"],加起来就是tokens
[
["text","<h1> 我买了一个"],
["name","thing"],
["text",",好"],
["name","mood"],
["text","啊</h1>"]
]
存在循环的时候会编译成嵌套更深的tokens
<div>
<ul>
{{#arr}}
<li>{{.}}</li>
{{/arr}}
</ul>
</div>
[
["text","<div><ul>"],
["#","arr",[
["text","<li>"],
["name","."],
["text","</li>"]
]],
["text","</ul></div>]
]
二、手写实现mustache库
1.创建Scanner类
1.1作用:
扫描字符串,为模板字符串转换成tokens做服务
1.2代码:
定义Scanner类
作用是查找{{或}}将内容进行分割
export default class Scanner {
constructor(templateStr){
// 将模板字符串写到自己的属性上,或者说实例接收参数
this.templateStr = templateStr
// 指针
this.pos = 0
// 尾巴,开始就是原文
this.tail = templateStr
}
// 功能简单,就是走过指定内容,没返回值
scan(tag){
if(this.tail.indexOf(tag) == 0){
// tag有多长,就后移多少位
this.pos += tag.length
// 改变尾巴
this.tail = this.templateStr.substring(this.pos)
}
}
// 让指针进行扫描,直到遇到{{}}指定内容,并且返回结束之前的路过的文字内容
scanUtil(stopTag){
// 记录一下执行本方法的时候pos的值
const pos_backup = this.pos
// 当尾巴的开头不是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方法:
作用是将模板字符转换成tokens数组
import Scanner from './Scanner.js'
// 将模板字符串变为tokens数组
import Scanner from './Scanner.js'
import nestTokens from './nestTokens.js';
// 将模板字符串变为tokens数组
export default function parseTemplateToTokens(templateStr){
var tokens = []
// 创建扫描器
var scanner = new Scanner(templateStr)
// 让扫描器工作
var words;
while(!scanner.eos()){
// 收集标记出现之前的文字
words = scanner.scanUtil('{{')
if(words !=''){
// 需要去掉空格,但是不能去掉class
var isInJJH = false
var _words =''
for (let i =0;i<words.length;i++){
// 判断是否在标签里面
if(words[i] =='<'){
isInJJH = true
}else if (words[i] =='>'){
isInJJH = false
}
// 如果这项不是空格,拼接
if(words[i]!=' '){
_words += words[i]
}else{
if(isInJJH){
_words+=words[i]
}
}
}
tokens.push(['text',_words])
}
scanner.scan('{{')
// 收集{{}}之间的文字
words = scanner.scanUtil('}}')
if(words !=''){
// 判断首字母是不是#
if (words[0] == '#'){
//存起来,下标从1开始,表示是#
tokens.push(['#',words.substring(1)])
}else if (words[0] == '/'){
//存起来,下标从1开始,表示是/
tokens.push(['/',words.substring(1)])
}else{
tokens.push(['name',words])
}
}
scanner.scan('}}')
}
// 返回折叠收集的tokens
// console.log(tokens)
return nestTokens(tokens)
}
使用测试:
import parseTemplateToTokens from './parseTemplateToTokens'
window.YSZ_TemplateEngine = {
render(templateStr,data){
console.log("render函数被调用了")
// 将模板字符串变成tokens数组
var tokens = parseTemplateToTokens(templateStr)
}
}
输出结果:
图2.1
2.创建nestTokens方法
作用是将生成的Tokens之间的嵌套关系表达出来,利用了栈的先进后出原理
代码部分:
// 函数的功能是折叠tokens,将#和/之间的tokens能够整合起来,作为它的下标为3的项
export default function nestTokens(tokens){
// 结果数组
var nestedTokens = []
// 收集器,初始指向nestedTokens
// 充分利用了引用类型的特点,这里的指向会改变,当遇到#的时候,会指向数组下标为2的位置,没有就创建
var collector = nestedTokens
// 栈结构,存放小tokens,栈顶(靠近端口的,最新进入的)的tokens数组中当前
// 操作的这个tokens小数组
var sections = []
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的项,并且让收集器指向它
collector = token[2] = []
break
case '/':
// 出栈 pop()会返回刚刚弹出的项
let section_pop = sections.pop()
//改变收集器为栈结构队尾(队尾就是栈顶)那项的下标为2的数组
collector = sections.length >0 ? sections[sections.length - 1][2] : nestedTokens
break
default:
collector.push(token)
}
}
console.log(nestedTokens)
return nestedTokens
}
测试结果:
实现思路及代码分析:
- 首先,由于tokens的嵌套关系的不确定性,不知道内部还嵌套几层,比如students嵌套item.hobbies,hobbies内部可能依旧嵌套很多层,所以从嵌套关系来说,具有不确定性,但是有一个数据结构恰好能解决这类问题,那就是栈,栈具有先进后出,后进先出的特性,遇到#就入栈,遇到/就出栈,这样studens之所以不好处理其实就是内部层数的不确定性,但是根据栈的特性,students是最先入栈的,也就是处于栈底,所以在入栈完成之后,进行出栈处理的时候,肯定是最后处理他的,那反之则是最内层的一定是没有嵌套的也就是最简单的结构,同时也是最先出栈或被处理的数据,那么一旦这一层解决了,它的上一层也就好处理了,依次类推,当students出栈的时候,他的内部数据已经被处理完毕了,也就是确定了。
算法逻辑确定后,就开始代码实现,首先肯定是需要一个for循环,查找tokens内的所有数据,并且需要一个定义sections数组,通过push从队尾入栈,他的作用是存储入栈数组,如这里会先入栈students然后hobbies,那么对于sections数组来说现在就是一个栈结构了,数组的起始项是栈底,数组的尾部是栈顶;如此便有栈结构,有了栈结构,还需要完善出入栈时需要进行的操作,这里为了完成嵌套数据的收集,定义了一个新的变量collector(这步非常精妙,因为如果只有nestedTokens进行收集数据遇到#和/的时候需要做过多的判断操作也就是if语句,引入collector变量使得逻辑更清晰),它的作用就是收集变量,但是与nestedTokens不同的是,它起始通过引用类型特性指向nestedTokens变量,如果未遇到#,他与nestedTokens没有任何区别(因为他的引用类型一直是nestedTokens,所以它push就等于nestedTokens的push操作),但是当遇到#,他的指向就会发生变化,他会指向最新的数组,或者说更小的数组,collector = token[2] = []
由结果处可看出,token[2]
= [] 就是students内部嵌套的数组,先创建再通过collector=token[2]使其指向最新数组,这时候收集操作就是为students的这个token数组的下标为2的项做收集操作,此时若是依旧是普通数据,collector一直为students的token[2]做push操作直到遇到新的#,也就是hobbies,那么这个时候又重复步骤,先将hobbies入栈,然后调整collector指向最新的数组也就是hobbies这个token[2]项,然后为其收集数据,如果hobbies是最底层嵌套,那么根据栈的特性,我们知道接下来肯定是hobbies先做出栈处理,通过之前scanner中生成的tokens图2.1也可看出确实是先执行hobbies这项的出栈,出栈的表示是/,当遇到一个/就代表一个嵌套结构的结束,当遇到hobbies的/结构时候,证明hobbies的数据收集已经完成,并且收集数据都在hobbies的token[2]这个数组项中存储如图,
这样最内层的hobbies数据收集完成,那么这一项就需要出栈,并且将collector指回students这个token[2]项继续为其收集(这里的逻辑是这样的,开始为nestedTokens收集数据,但是遇到了#,那么collector就去为students办事了,但是途中又遇到了#,它又去为hobbies办事了,如今hobbies是最底层了,遇到hobbies的/这个标识的时候就做hobbies的出栈处理,那么它又回来为students继续做事了,收集完剩余最后一项这一项,就会遇到students的标识/,这个时候进行students这项做出栈处理,它又回去帮nestedTokens做事了,收集完最后一项,此时全部收集完成结构显示也如上图,是预期结构),这里需要注意的是当students这层收集完成后,其实栈sections中已经为空了,那么需要collector = sections.length >0 ? sections[sections.length - 1][2] : nestedTokens
出栈的时候做特殊处理,当栈内为空的时候说明已经到了最外层也就是nestedTokens层,这时候让collector指回nestedTokens即可,这里巧妙的利用了collector及其引用关系来做数据的收集操作,这样做的好处就是当遇到#的时候只需要改变collector收集器的指向即可,无需为nestedTokens做过多的判断,他就做一个干干净净收集装置,遇到#入栈以及/出栈的问题交由收集器去处理即可,如此便完成tokens嵌套结构的处理。
3.将tokens结合数据拼接成DOM字符串形式:
1.创建renderTemplate函数
作用:
通过接收tokens和data数据,将两者结合返回字符串类型的DOM结构
import lookup from "./lookup"
import parseArray from "./parseArray";
export default function renderTemplate(tokens,data){
console.log(data)
// 结果字符串
var resultStr = '';
// 遍历tokens
for(let i=0;i<tokens.length;i++){
// 拿到每组token
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]=='#'){
resultStr += parseArray(token,data)
}
}
console.log(resultStr)
return resultStr
}
问题:由于 resultStr +=data[token[1]]无法识别.语法,当数据结构为多层嵌套的时候如下图2,token[1]就是a.m.n,显然data[‘a.m.n’]是无法错误的书写方式,结果如图1,确实是undefined,为了解决这个问题需要创建lookup函数 图1
图2
2.创建lookup函数
背景:
由于无法是被.语法,lookup函数就当遇到一个多层结构,返回最底层数据,如果是普通数据直接返回结果即可
代码
export default function lookup(dataObj,keyName){
// 看看keyName里面有没有.符号,但是本身不能是.,或者说不能只是.
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函数导入并修改resultStr += lookup(data,token[1])即可,结果如下图,已经可以识别多层结构了。
3.创建parseArray函数
作用是处理嵌套数组问题,
代码:
import lookup from "./lookup"
import renderTemplate from "./renderTemplate"
// 处理数组,结合renderTemplate实现递归
// 这里的token并不是tokens,而是token=> ['#','students',[]]
// 简而言之就是这个token里面包含数组
// 重点:
// 这里需要递归调用renderTemplate函数,至于调用多少次和data数据有关
// 假设data的形式是:
// {
// students : [
// {"name":"某可","age":18,"sex":"男"},
// {"name":"某鸿","age":18,"sex":"男"},
// {"name":"小李","age":5,"sex":"女"}
// ]
// }
// 那么就需要循环调用三次,因为数组的长度是三
export default function parseArray(token,data){
console.log(token,data)
// 拿到对于数据的名字 这里是students
var v = lookup(data,token[1])
var resultStr = ''
// 遍历v数组,v一定是数组(原本可以跟Boolean值这里是简化版)
for(let i=0;i<v.length;i++){
// 这里和h函数的渲染有异曲同工之妙,token[2]里面存的又是类似于最外层这种tokens结构,v[i]就是data数据项
// ★这里有个难点,如果是复杂属性例如上述students中第一项,外层是对象,内部是键值对属性表示
// 那么v[i]传递数据肯定可以被拿到,因为这样模板就能通过name直接拿到里面的值如<p>姓名:{{name}}</p>,
// 但是如果是数组["香蕉","苹果","梨子"],这样v[i]传递的数据就无法被拿到,因为无法识别模板中的.例如<li>{{.}}</li>
// 其实就是因为数据是数组,而模板取数据通过属性名,数组没有属性名访问,一般是下标访问,所以用.符号代替
// 所以这里需要取识别.符号,也就是简单数组数据类型
// resultStr += renderTemplate(token[2],v[i])
resultStr += renderTemplate(token[2],{
// 如果是对象类型,证明已经有属性名了,那么直接拆分属性放到{}对象里面就好了
...v[i],
// 如果是简单数组类型,那么就将.符号作为它的属性名,v[i]作为他的属性值,这样就能通过.拿到他的属性值了
'.':v[i]
})
// 上面使用外层{}接受,将简单数组数据包装成{}类型,.符号就是他的属性名,属性值就是v[i]
}
return resultStr
}
数据和模板:
运行结果:
代码难点:
首先,parseArray函数是在renderTemplate中调用,其实parseArray可以不用分割出来,只看代码量来说并不多,但是其中逻辑比较难理解,从我的代码注释量就可以看出来了,所以单独抽出,这里首先用到的一个思想就是递归,如果有看过h函数渲染虚拟节点的朋友肯定对这里非常熟悉,h函数在遇到children属性的时候也是通过递归调用h函数本身来实现渲染子节点的,这里实现其实很简单但是想到需要些难度,renderTemplate的作用就是将tokens转换成DOM字符串拼接,遇到text和name属性都比较好解决,text就是字符串形式的直接拼接即可,而name属性是需要取data数据中取数据的,通过lookup函数我们也能轻松取到值,但是当遇到#的时候,代表其内部还有嵌套结构,换而言之就是这个token也需要被作为一个tokens对待,和h函数非常相似,所以这里递归递归调用了renderTemplate函数,又将除#之外的全部拼接完成后返回,其实归根结底最终一定是没有#的语句,这里循环调用的次数取决于data中的数据类型,如果如样例students,那么只需要循环调用三次就能完成全部模板的创建了,这里其实和我们Vue中常见的v-for达到的效果是一样的,最后只需要将生成的DOM字符串赋值给需要的innerHTML即可在页面中显示。
参考视频:
【尚硅谷】Vue源码解析之mustache模板引擎
图片部分来源:
【尚硅谷】Vue源码解析之mustache模板引擎