模板引擎
tokens
在模板引擎的实现中,HTML模板是以tokens的形式来保存的。tokens是一个JS的嵌套数组,模板字符串的JS表示。HTML在渲染之前,会先将HTML字符串编译为tokens,然后tokens结合数据解析为DOM字符串。
解析tokens基本规则
mustache中,通过将模板字符串解析为token的方式来表示即将渲染的的dom。参考mustache实现一个简易的模板引擎。
解析token的基本规则
- 花括号({{}})之外的信息作为普通文本处理,标识为text,作为token数组的第一个元素,文本作为token的第二个元素
- 花括号内的信息如果以 # 开头,那么表示一个循环开始,标识为 #,作为token数组的第一个元素,花括号内 # 后的信息做为token的 第二个元素
- 花括号内的信息如果以 / 开头,那么表示一个循环结束,标识为 /,循环之间的内容 {{#x}}yyy{{/x}},作为token的第三个元素, 该元素也是一个tokens,用来保存循环之间的内容(即yyy)解析的token
- 花括号内的其他信息,当作变量处理,标识为name,作为token数组的第一个元素,文本作为token的第二个元素
将HTML字符串解析为tokens - 案例
// 以 {{ 和 }} 作为标记,对{{ }}之间的信息进行对应处理
// 将最后的处理结果整合起来结合为由多个token组成的数组,
/* 例一 */
const templateStr = `<h1>我买了一个{{thing}},花了{{price}}元,好{{mood}}啊</h1>`
// 对应tokens
const strTokens = [
['text', '<h1>我买了一个'],
['name', 'thing'],
['text', ',花了'],
['name', 'price'],
['text', '元,好'],
['name', 'mood'],
['text', '啊</h1>']
]
/* 例二 */
const templateFor = `
<div>
<ul>
{{#arr}}
<li>{{.}}</li>
{{/arr}}
</ul>
</div>
`
// 对应tokens
const forTokens = [
['text', '<div>\n <ul>'],
['#', 'arr', [
['text', '<li>'],
['name', '.'],
['text', '</li>']
]],
['text', '</ul>\n </div>']
]
/* 例三 */
const templateForS = `
<div>
<ol>
{{#students}}
<li>
学生{{name}}的爱好是
<ol>
{{#hobbies}}
<li>{{.}}</li>
{{/hobbies}}
</ol>
</li>
{{/students}}
</ol>
</div>
`
// 对应tokens
const forSTokens = [
['text', '<div>\n <ol>'],
['#', 'students', [
['text', '<li>\n 学生'],
['name', 'name'],
['text', '的爱好是\n <ol>'],
['#', 'hobbies', [
['text', '<li>'],
['name', '.'],
['text', '</li>']
]],
['text', '</ol>\n </li>']
]],
['text', '</ol>\n </div>']
]
将模板字符串解析为token - 实现
将模板字符串解析为token需要用到一些方法
- Scanner类:对模板字符串进行扫描
- parseTemplateToTokens:将返回的字符串整理为token
- nestToken:将所有的token整理为tokens(比如循环中存在token嵌套)
Scanner类
首先创建一个扫描模板字符串的类Scanner,这个类会保存模板字符串的原文、模板字符串扫描的剩余部分和指针位置。
Scanner类的 scan 方法用来跳过指定字符串并对指针和剩余模板字符串进行更新。比如解析模板字符串时遇到 {{ 和 }},{{ }}只是作为一个标识, 解析的时候需要跳过。
scanUntil 方法,从指针处开始扫描,到指定位置结束。首先记录当前指针位置,然后通过对模板字符串逐个扫描检查 是否是指定字符 {{ ,如果不是,则更新指针位置和模板字符串的剩余部分并对下一个字符进行扫描,否则,返回 原文的从记录指针位置到当前位置区间的内容。
export default class Scanner {
constructor(template) {
this.template = template
// 指针
this.pos = 0
// 剩余未扫描的HTML字符串,一开始是模板字符串的原文
this.tail = template
}
/*
单纯的跳过指定内容,比如扫描字字符串时,遇到{{或}},他们只是做一个标识,并没有实际意义,所以需要跳过
*/
scan (tag) {
// scan主要时跳过{{和}}字符的,在这之前已经将{{或}}之前的信息处理过了,这里防止意外再做一个检查
// 例如:当剩余字符串是 “}}元,好{{mood}}啊</h1>” ,则需要跳过 }}
// 但是,如果剩余字符串是“元,好{{mood}}啊</h1>”,则可能是出现了错误,因此多了这一层检查
if (this.tail.indexOf(tag) === 0) {
// tag有多长,比如{{长度是2,就让指针后移2位
this.pos += tag.length
// 尾巴也需要更新,改变尾巴为从当前指针这个字符开始,到最后的全部字符
this.tail = this.template.substring(this.pos)
}
}
// 让指针进行扫描直到遇见指定内容结束,并且返回结束之前扫描的信息
scanUntil (stopTag) {
// 记录调用该方法时候pos的值
const pos_backup = this.pos
/* 将模板字符换中的字符逐个检查,是否是要查找的字符 */
// 当尾巴的开头不是stopTag的时候,就说明没有扫描到stopTag
while (this.tail.indexOf(stopTag) !== 0 && !this.eos()) {
this.pos++
// 改变尾巴为从当前指针这个字符开始,到最后的全部字符
this.tail = this.template.substring(this.pos)
}
// console.log(this.pos, this.tail, this.template.substring(pos_backup, this.pos))
//
return this.template.substring(pos_backup, this.pos)
}
// 指针是否已经到头,返回布尔值
eos () {
return this.pos >= this.template.length
}
}
parseTemplateTokens
将返回的文本信息根据类型整理为特定的token。
首先实例化一个Scanner,判断scanner是否检查完毕,如果检查完毕,可以得到一个tokens数组,里面保存所有的token。
检查过程中:
首先使用scanner实例的 scanUntil 方法扫描并收集 {{ 前的内容,如果不是空,则整理为text类型的token['text', '内容']并保存到tokens数组中,然后调用scanner实例的 scan 方法跳过 {{;
同理,使用scanUtil方法扫描并收集 }} 前的内容,这段内容就是 {{ 和 }}之间的内容,这段内容 整理为token需要分为三种情况
- 以#开始:说明一个循环开始,保存为#类型的token['#', '内容'](内容需要删除#字符) - 以/开始:说明一个循环结束,保存为/类型的token['/', '内容'](内容需要删除/字符) - 其他:作为一个变量处理,保存为name类型的token['name', '内容']
将字符串解析为tokens的单次流程
为了便于理解下面代码,分析一下解析字符串的流程
比如将要解析的HTML字符串是:<h1>我买了一个{{thing}},花了{{price}}元,好{{mood}}啊</h1>
这里动态内容只有 thing、price、mood,不同于普通文本,所以这三个变量需要特殊处理。可以发现他们三个
特殊的地方在于 两边 各有一个{{和}},所以以{{和}}作为标识来解析。
单次扫描流程:
- 以“{{”为标识匹配到“{{”前的字符串信息 - 生成一个words
- 根据生成word的内容将其处理为对应的token并添加到最终的tokens中
- 跳过字符串“{{” - 上面说到{{和}}只是作为一个标识,没有实际意义,所以需要跳过
- 再以“}}”为标识匹配到“{{”和“}}”之间的内容(动态内容) - 生成一个words
- 根据生成word的内容将其处理为对应的token并添加到最终的tokens中
- 跳过“}}”
- 进入下一次循环扫描
- ...
import Scanner from "./Scanner";
import nestTokens from './nestTokens'
// 将模板字符串解析为tokens数组
export default function (template) {
const tokens = [] // 保存最终的tokens
let words // Scanner扫描到的字符串
// 实例化一个扫描器,针对传入的模板字符串进行工作
const scanner = new Scanner(template)
// 开始循环扫描
while (!scanner.eos()) { // 没有匹配到结尾
// 收集开始标记({{)之前的文字
words = scanner.scanUntil('{{')
// 保存到tokens中 - 跳过空字符串
if (words !== '') {
// 去掉空格
tokens.push(['text', words])
// tokens.push(['text', words.replace(' ', '')])
}
// 跳过 {{
scanner.scan('{{')
words = scanner.scanUntil('}}')
words ? tokens.push(judgeSaveLabel(words)) : ''
scanner.scan('}}')
}
return nestTokens(tokens)
}
// 用来决定怎么存储
function judgeSaveLabel (word) {
// 以#开始
if (word[0] === '#') {
return ['#', word.substring(1)]
} else if (word[0] === '/') {
return ['/', word.substring(1)]
} else {
return ['name', word]
}
}
目前实现的功能只能对普通的HTML字符串进行解析。根据最开始的例二和例三可以发现,循环体生成的tokens是作为被循环的数据对应的token的第三个参数保存的。现在的实现如果遇到循环,还是作为普通token保存的,并没有实现嵌套。所以在解析完tokens后,还需要对tokens处理,如果有循环将其整理为嵌套的tokens。
根据token[0]的类型决定如何整理tokens
- token[0] = #:代表一个循环开始
- 向收集器(现在nestTokens和收集器指向同一个数组对象)中添加这个token
- 向sections中添加这个token,代表入栈
- 为当前这个token添加一个下标为2的元素,并让collector指向它(循环开始即入栈,前面有说到循环中的内容需要作为token的第三个元素,
所以在遇到
token[0] = /之前,后面的token都需要添加到这个子tokens中,于是便让collector指向这个子tokens) - 开始下一轮循环
- token[0] = /:代表一个循环结束
- 删除栈中的最后一个元素,代表出栈
- 更新collector指向(如果sections中还有元素,说明还有循环,则指向它最后一个元素的下标为2的元素-这个元素是一个子tokens; 如果sections中没有元素,说明退出所有循环了,sections则指向nestTokens)
- token[0] = name:这是一个变量
- 直接向collector中添加,因为collector的指向会在需要的时候自动更新,所以不需要关心其他东西。
- 当前这个例子中sections看起来没有用处,但如果在多层循环嵌套的时候,就可以更清晰的了解到当前解析的token处于哪一层循环中
nestTokens - 相对复杂
整理所有的token,因为遇到循环的时候,循环中的内容需要作为token的第三个元素,(这个元素也是一个tokens)。
首先需要有一个结果数组(nestTokens)保存整理后的tokens;一个收集器(collector),用来间接想tokens中添加token, 收集器最开始指向结果对象;还需要有一个栈结构来保存小的tokens(循环中的多个token)。
然后开始对tokens进行循环,循环中的每个元素是一个token(['类型', '内容'])
再去判断token的第一个元素的类型
最后便能得到一个需要的结果数组
折叠tokens
看一个例子便于理解nextTokens
在看案例之前先看下这一行代码sections.push(token),section是一个栈结构,每次进入一个循环的时候都会向栈中把当前的token添加进去;在退出循环的时候又会将这个token弹出sections.pop()。
那如果遇到了嵌套循环呢?即在一个循环结束前又遇到了另一个循环,这个时候又需要更新collector的指向,开始循环更新collector指向比较简单(直接指向这个token的下标为2的元素即可)。但结束循环就不好处理了,section就是为了在一个循环结束时追踪它的上一层循环对应的token的。
在循环结束的时候通过collector = sections.length > 0 ? sections[sections.length - 1][2] : nestTokens更新collector指向。因为每次进入一个循环(入栈)都将对应的token收集了起来,所以退出循环的时候,只需要弹栈,然后将collector指向栈结构的最后一项即可。
// 上面的例二解析后得到的tokens
[
['text', '<div><ul>'],
['#', 'arr'],
['text', '<li>'],
['name', '.'],
['text', '</li>'],
['/' 'arr'],
['text', '</ul></div>']
]
// 一开始控制器指向nestTokens,所以“['text', '<div><ul>']”直接添加到nestTokens中,执行switch中的default不需要太多说明
// 将nestTokens中的变量拿出来看
// 第一次循环后的结果
nestTokens = [['text', '<div><ul>']]
sections = []
collector = [['text', '<div><ul>']]
/*
第二次循环 - 未结束
第一步:将token添加到nestTokens中 - collector.push(token)
第二步:将这个token添加到栈中 - sections.push(token)
*/
nestTokens = [['text', '<div><ul>'], ['#', 'arr']]
sections = [['#', 'arr']]
collector = [['text', '<div><ul>'], ['#', 'arr']] // 这个时候控制器还是指向nestTokens
/*
第二次循环 - 结束
第三步:给当前的token下标为2的元素赋值为一个数组 - token[2] = []
第四步:让控制器指向新添加的这个数组
*/
nestTokens = [['text', '<div><ul>'], ['#', 'arr', []]]
sections = [['text', '<div><ul>'], ['#', 'arr', []]]
collector = []
// 接下来的循环(在遇到token[0] = '/'之前)都是走的default,直接向collector中添加token,因为现在已经进入循环了,后面的都是循环体的内容,所以不需要更新collector指向,直接添加就行
/*
第六次循环
这次的switch循环中token[0] = '/',说明这个arr的遍历已经结束,需要更新collector的指向
第一步:弹出栈中的最后一项,就是collector当前指向的数组 - sections.pop()
第二步:更新collector的指向
collector = sections.length > 0 ? sections[sections.length - 1][2] : nestTokens
第一步操作完成后,如果栈中还有元素,collector就指向这个栈中的最后一个元素的下标为2的元素,否则指向nestTokens
*/
/*
* 折叠tokens,将#和/之间的tokens整合起来
* */
export default function nestTokens (tokens) {
// 结果数组
const nestTokens = []
// 栈结构,存放小token
const sections = []
// 收集器 妙啊!!!
let collector = nestTokens
// 遍历所有token
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会返回刚刚弹出的项
sections.pop()
// 收集器指向更新为栈结构尾部那项下标为2的数组或nestTokens
collector = sections.length > 0 ? sections[sections.length - 1][2] : nestTokens
break
default: // 只要不是循环开始或结束,token直接添加到控制器中
// 不用关心collector指向谁,在压栈和出栈时已经更新好了
collector.push(token)
}
}
return nestTokens
}
将token编译为HTML字符串
这一步需要三个工具
- renderTemplate:将tokens转为结果字符串
- lookUp:用来解析 . 形式的属性方法
- parseArray:处理数组,结合renderTemplate实现递归(递归调用renderTemplate,调用次数由data决定)
renderTemplate
接收两个参数:tokens:token组成的数组 data:渲染模板字符串对应的变量 需要一个作为返回值的结果字符串resultStr,遍历tokens,每一项都是一个token
['type', '内容'], 根据这个token的第1个元素判断这个token的类型
import lookUp from "./lookUp";
import parseArray from "./parseArray";
/*
* 将tokens数组转为dom字符串
* */
export default function renderTemplate (tokens, data) {
let resultStr = ''
for (let i = 0; i < tokens.length; i++) {
let token = tokens[i]
if (token[0] === 'text') { // 普通文本,直接拼接
resultStr += token[1]
} else if (token[0] === 'name') { // 变量,通过lookUp获取到它的值
// 如果是name类型,直接使用它的值
resultStr += lookUp(data, token[1])
} else if (token[0] === '#') { // 一个循环开始,通过parseArray集合token和data解析为要渲染的内容
resultStr += parseArray(token, data)
}
}
return resultStr
}
判断token的类型
- token[0] = text:说明这是一段文本,直接拼接到resultStr末尾
- token[0] = name:说明这是一个变量,通过调用lookUp方法返回这个变量实际对应的值
- token[0] = #:说明这是一个循环,需要调用parseArray来解析并返回对应的HTML字符串
parseArray
接收两个参数 token:要解析的token data:这一层tokens对应的数据 需要一个作为返回值的结果字符串resultStr,首先调用lookUp获取这个token要使用的部分数据, 使用lookUp获取到的数据一定是数组(因为只有
token[0]=#的时候才会调用这个方法,而token[0]=#时,说明一个循环开始, 那么token[1]必然是一个数组。如果不是,则是模板引擎的使用出现错误)。 对token[1]获取到的数据进行循环,每次循环都会使用renderTemplate(递归出现)获取子tokens编译后 得到的HTML字符串。(如果还有循环那么renderTemplate会继续调用parseArray来解析数组数据; 而parseArray还会继续调用renderTemplate来解析tokens,知道没有循环为止。)
const v = lookUp(data, token[1])
解释一下这行代码,它是用来获取循环对应的那个数组数据的,拿下面的例子来看:
下面的模板字符串解析为tokens后,循环开始对应的token为['#', 'pirates', [...]]
token[1]就是 pirates ,v = lookUp(data, 'pirates'),即data.pirates
const templateArr = `
<div>
<ol>
{{#pirates}}
<li>
海贼{{name}}绰号是:{{nickName}}<br>
</li>
{{/pirates}}
</ol>
</div>
`
const data = {
pirates: [
{ name: '路飞', nickName: '草帽小子,草帽当家' },
{ name: '索隆', nickName: '绿藻头,路痴,肌肉蠢男' },
{ name: '山治', nickName: '卷眉,色情厨子,色河童' }
]
}
import lookUp from "./lookUp";
import renderTemplate from "./renderTemplate";
/*
* 处理数组,结合renderTemplate实现递归
*
* 递归调用renderTemplate,调用次数由data决定
* */
export default function (token, data) {
// 得到整体数据data中这个数组要使用的部分
/*
得到这个token要使用的部分
比如:
*/
const v = lookUp(data, token[1])
// 结果字符串
let resultStr = ''
// 遍历数组,v一定是数组
// 遍历数据而不是遍历tokens,数组中的数据有几条,就需要遍历几条
// 每一次for循环就相当于解析一个没有循环的模板字符串,然后和之前的内容拼接起来并返回
for (let i = 0; i < v.length; i++) {
// 为v[i]添加一个属性名为 . 的属性,且值等于它自身v[i],即v[i]['.'] = v[i]
resultStr += renderTemplate(token[2], { ...v[i], '.': v[i] })
}
return resultStr
}
lookUp
keyName中是否存在 . 符号,且不能是 . 本身,如果成立,则需要将keyName以 . 作为分隔符拆分为数组。 然后定义一个变量(temp)指向dataObj并开始遍历该数组,第一次循环时,让temp指向
temp[key],每遍历 一次,temp都会更新一次(找的更深一层),知道遍历结束得到最终数据。 如果keyName中不存在 . 符号,则直接返回dataObj[keyName]。
/*
* 在dataObj对象中,寻找用连续点符号的keyName
* */
export default function (dataObj, keyName) {
// 检查keyName中有没有.符号,但又不能是.本身
if (keyName.indexOf('.') !== -1 && keyName !== '.') {
const keys = keyName.split('.')
// 设置一个新的临时变量,一层一层查找下去
let temp = dataObj
for (let i = 0; i < keys.length; i++) {
// 每找一层,就把它设置为新的变量
temp = temp[keys[i]]
}
return temp
}
// 如果没有点符号
return dataObj[keyName]
}