深入模板引擎实现

155 阅读13分钟

模板引擎

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]
}