mustache 模板引擎 - 02

2,123 阅读5分钟

本文接上篇 《mustache 模板引擎 - 01》继续分享对于 mustache 的学习笔记

上篇我们大致介绍了什么是模板引擎并举了几个小例子帮助理解。本篇的主要内容则分为两大部分:mustache 的底层 token 思想,以及正式开始手写实现 mustache

1. mustache 的底层 token 思想

我们知道,mustache 模板引擎的作用是将字符串模板变为 dom 模板,最后结合数据挂载到 dom 树上,在页面渲染呈现。这个过程中,mustache 引入了一个名为 tokens 的概念,用来作为“中间人”。所谓一图胜千言,直接放图

yuque_diagram.jpg
简而言之, tokens 是模板字符串的 js 嵌套数组表示。它是一个数组,里面包含了很多个 token,每个 token 又是基于规则生成的一个数组。

image.png

当模板字符串中存在循环,它将编译为嵌套更深的 tokens:

image1.png

我们可以通过修改 mustache 源码的方式直接在浏览器控制台打印输出 tokens。在源码中找到 parseTemplate 函数,然后在该函数的函数体末尾处,return 的 nestTokens(squashTokens(tokens)) 其实就是 tokens。做如下的稍加修改,以便在浏览器中打印查看

const myTokens = nestTokens(squashTokens(tokens))
console.log(myTokens)
return myTokens

例如有这样一个模板字符串

const templateStr = `<div>{{name}}的基本信息</div>`

则最终打印输出的 tokens 将如下图所示

image.png

2. 开始手写实现 mustache

了解完 tokens,现在开始自己写一个 My_TemplateEngine 对象来实现之前文章中提到的 Mustache.render() 里的 Mustache 的功能。我们将把独立的功能拆写为独立的 js 文件,通常是一个独立的,每个单独的功能都应能完成独立的单元测试。

实现将模板字符串编译为 tokens

image.png

实现 Scanner 类

Scanner 类的实例就是一个扫描器,用来扫描构造时作为参数提供的那个模板字符串。

属性

  • pos:指针,用于记录当前扫描到字符串的位置;
  • tail:尾巴,值为当前指针之后的字符串(包括指针当前指向的那个字符)。

方法

  • scan:无返回值,让指针跳过传入的结束标识 stopTag
  • scanUntil:传入一个指定内容 stopTag 作为让指针 pos 结束扫描的标识,并返回扫描内容。
// Scanner.js
export default class Scanner {
  constructor(templateStr) {
    this.templateStr = templateStr
    // 指针
    this.pos = 0
    // 尾巴
    this.tail = templateStr
  }

  scan(stopTag) { 
    this.pos +=  stopTag.length // 指针跳过 stopTag,比如 stopTag 是 {{,则 pos 就会加 2
    this.tail = this.templateStr.substring(this.pos) // substring 不传第二个参数直接截取到末尾
  }

  scanUntil(stopTag) {
    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) // 返回扫描过的字符串,不包括 this.pos 处
  }
  
  // 指针是否已经抵达字符串末端,返回布尔值 eos(end of string)
  eos() {
    return this.pos >= this.templateStr.length
  }
}

根据模板字符串生成 tokens

有了 Scanner 类后,就可以着手去根据传入的模板字符串生成一个 tokens 数组了。最终想要生成的 tokens 里的每一条 token 数组的第一项用 name(数据) 或 text(非数据文本) 或 #(循环开始) 或 /(循环结束) 作为标识符。

新建一个 parseTemplateToTokens.js 文件来实现:

// parseTemplateToTokens.js
import Scanner from './Scanner.js'
import nestTokens from './nestTokens' // 后面会解释

// 函数 parseTemplateToTokens
export default templateStr => {
  const tokens = []
  const scanner = new Scanner(templateStr)
  let word
  while (!scanner.eos()) {
    word = scanner.scanUntil('{{')
    word && tokens.push(['text', word]) // 保证 word 有值再往 tokens 里添加
    scanner.scan('{{')
    word = scanner.scanUntil('}}')
    /** 
     *  判断从 {{ 和 }} 之间收集到的 word 的开头是不是特殊字符 # 或 /, 
     *  如果是则这个 token 的第一个元素相应的为 # 或 /, 否则为 name
     */
    word && (word[0] === '#' ? tokens.push(['#', word.substr(1)]) : 
      word[0] === '/' ? tokens.push(['/', word]) : tokens.push(['name', word]))
    scanner.scan('}}')
  }
  return nestTokens(tokens) // 返回折叠后的 tokens, 详见下文
}

在 index.js 引入 parseTemplateToTokens

// index.js
import parseTemplateToTokens from './parseTemplateToTokens.js'

window.My_TemplateEngine = {
  render(templateStr, data) {
    const tokens = parseTemplateToTokens(templateStr)
    console.log(tokens)
  }
}

这样我们就可以把传入的 templateStr 初步转成 tokens 了,比如 templateStr 为

const templateStr = `
  <ul>
      {{#arr}}
        <li>
          <div>{{name}}的基本信息</div>
          <div>
            <p>{{name}}</p>
            <p>{{age}}</p>
            <div>
              <p>爱好:</p>
              <ol>
                {{#hobbies}}
                  <li>{{.}}</li>
                {{/hobbies}}
              </ol>
            </div>
          </div>
        </li>
      {{/arr}}
  </ul>
`

那么目前经过 parseTemplateToTokens 处理将得到如下的 tokens:

image (1).png

接下去,就是要想办法,让循环的部分,也就是 #/ 之间的内容,作为以 # 为数组第 1 项元素的那个 token 的第 3 项元素。也就是把红框里的内容插入到 ["#","arr"] 内,成为其第 3 项元素;同理,将蓝框里的内容插入到 ["#","hobbies"] 内成为其第 3 项元素:

image (2).png

实现 tokens 的嵌套

新建 nestTokens.js 文件,定义 nestTokens 函数来做 tokens 的嵌套功能,将传入的 tokens 处理成包含嵌套的 nestTokens 数组返回。

然后在 parseTemplateToTokens.js 引入 nestTokens,在最后 return nestTokens(tokens)

  • 实现思路

nestTokens 中,我们遍历传入的 tokens 的每一个 token,遇到第一项是 #/ 的分别做处理,其余的做一个默认处理。大致思路是当遍历到的 token 的第一项为 # 时,就把直至遇到配套的 / 之前,遍历到的每一个 token 都放入一个容器(collector)中,把这个容器放入当前 token 里作为第 3 项元素。

但这里有个问题:在遇到匹配的 / 之前又遇到 # 了怎么办?也就是如何解决循环里面嵌套循环的情况?

解决的思路是新建一个栈数据类型的数组(stack),遇到一个 #,就把当前 token 放入这个栈中,让 collector 指向这个 token 的第三个元素。遇到下一个 # 就把新的 token 放入栈中,collector 指向新的 token 的第三个元素。遇到 / 就把栈顶的 token 移出栈,collector 指向移出完后的栈顶 token。这就利用了栈的先进后出的特点,保证了遍历的每个 token 都能放在正确的地方,也就是 collector 都能指向正确的地址。

  • 具体代码
// nestTokens.js
export default (tokens) => {
  const nestTokens = []
  const stack = []
  let collector = nestTokens // 一开始让收集器 collector 指向最终返回的数组 nestTokens
  tokens.forEach(token => {
    switch (token[0]) {
      case '#':
        stack.push(token)
        collector.push(token)
        collector = token[2] = [] // 连等赋值
        break
      case '/':
        stack.pop()
        collector = stack.length > 0 ? stack[stack.length-1][2] : nestTokens
        break;
      default:
        collector.push(token)
        break
    }
  })
  return nestTokens
}

One More Thing

上面的代码中有用到 collector = token[2] = [],是为连等赋值,相当于

token[2] = []
collector = token[2]

看着简单,其实暗含着小坑,除非你真的了解它,否则尽量不要使用。比如我在别处看到这么一个例子,

let a = {n:1};
a.x = a = {n:2};
console.log(a.x); // 输出? 

答案是 undefined,你做对了吗?

本篇为 vue 中用到的 {{}} 背后原理学习笔记,后续笔记《mustache 模板引擎 - 03》

010f985e840f22a801216518ebf3d5.gif 点赞.png