前端数据渲染及mustache模板引擎的简单实现

早期数据渲染的几种方式

在模板引擎没有诞生之前,为了用JS把数据渲染到页面上,诞生了一系列数据渲染的方式。

最最基础的,莫过于直接使用DOM接口创建所有节点。

<div id="root"></div>
<script>
  var root = document.getElementById('root');
  
  var title = document.createElement('h1');
  var titleText = document.createTextNode('Hello World!');
  
  title.appendChild(titleText);
  root.appendChild(title);
</script>

这种方式需要手动创建所有节点,再依次添加到父元素中,手续繁琐,基本不具有实际意义。

当然,也可以采用innerHTML的方式添加,上树:

var root = document.getElementById('root');
root.innerHTML = '<h1>Hello World!</h1>';

对于数据简单,嵌套层级较少的html代码块来说,这种方式无疑方便了许多,但是,若代码嵌套层级太多,会对代码可读性造成极大影响,因为''或者""都是不能换行的(ES6才有反引号可以换行),在一行代码里进行标签多层嵌套(想想现在看被转译压缩后的代码),这对编写和维护都会造成极大的困难。

直到有一个天才般的想法横空出世。

var root = document.getElementById('root');

var person = {
  name: 'Wango',
  age: 24,
  gender: '男'
}

root.innerHTML = [
  '<ul>',
  '  <li>姓名: ' + person.name + '</li>',
  '  <li>年龄: ' + person.age + '</li>',
  '  <li>性别: ' + person.gender + '</li>',
  '</ul>',
].join('');

这个方法将不可换行的多行字符串转换为数组的多个元素,再利用数组的join方法拼接字符串。使得代码的可读性大大提升。

当然,在ES6的模板字符串出来之后,这种hack技巧也失去了用武之地。

root.innerHTML = `
  <ul>
    <li>姓名: ${person.name}</li>
    <li>年龄: ${person.age}</li>
    <li>性别: ${person.gender}</li>
  </ul>
`;

但是同样的,数据通常不是简单的对象,当数据更加复杂,数组的嵌套层次更深的时候,即便是模板字符串也是力不从心。

于是,mustache库诞生了!

实现mustache

接触过JavaJSP或者PythonDTL(The Django template language)等模板引擎的同学对{{}}语法一定不会陌生,模板引擎从后端引入前端后得到了更广泛的支持,而如今,已经快成为前端框架的标配了。

更多关于mustache的信息可以查看GitHub仓库:
janl/mustache.js

这个mustache.js库暴露的对象只有一个render方法,接收模板和数据。

<script type="text/template" id="tplt">
  <ul>
    <li>{{name}}</li>
    <li>{{age}}</li>
    <li>{{gender}}</li>
  </ul>
</script>
<script>
 var person = {
   name: 'Wango',
   age: 24,
   gender: '男'
 }
 
 var root = document.getElementById('root');
 
 root.innerHTML = Mustache.render(
   document.getElementById('tplt').innerHTML,
   person
 )
</script>

朴素的实现

没有编译思想的同学可能想到的第一种实现方式就是使用正则表达式配合replace方法来进行替换,而对于上面一个例子使用正则确实也是可以实现的。

var root = document.getElementById('root');

function render(tplt, data) {
  // 捕获变量并使用数据进行替换
  return tplt.replace(/{{\s*(\w+)\s*}}/g, function(match, $1) {
    return data[$1];
  });
}

root.innerHTML = render(
  document.getElementById('tplt').innerHTML,
  person
)

对于简单的,单层无嵌套的结构来说,确实可以使用正则进行替换,但mustache还可以支持数组的遍历,多重嵌套遍历,对象属性的打点调用等,对于这些正则就捉襟见肘了。

编译思想的应用

在数据注入之前,我们需要在模板字符串编译为tokens数组,再将数据注入,将tokens拼接为最终的字符串,然后返回数据,这样做的好处是可以更方便地处理遍历和嵌套的问题。

于是,我们的模板引擎的render方法如下:

render(tplt, data) {
  // 转换为tokens
  const tokens = tokenizer(tplt);
  // 注入数据,让tokens转换为DOM字符串
  const html = tokens2dom(tokens, data);
  // 返回数据
  return html;
}

那么,tokenizertokens2dom又该如何实现呢?

首先来看tokenizer

这个函数的作用是将模板字符串转换为tokens数组,那么什么是token?简单来说,token指的是由类型、数据、嵌套结构等组成的数组,所有tokens就是一个二维数组,在本例中表现为

var tplt = `
    <div>
      <ol>
        {{#students}}
          <li>
            学生{{name}}的爱好是
            <ol>
              {{#hobbies}}
                <li>{{.}}</li>
              {{/hobbies}}
            </ol>
          </li>
        {{/students}}
      </ol>
    </div>
`;

转换为:

[
    ["text", "<div><ol>"],
    ["#", "students", [
        ["text", "<li>学生"],
        ["name", "name"],
        ["text", "的爱好是<ol>"],
        ["#", "hobbies", [
            ["text", "<li>"],
            ["name", "."],
            ["text", "</li>"]
        ]],
        ["text", "</ol></li>"]
    ]],
    ["text", "</ol></div>"]
]

由上例可以看出,token有代表文本的text类型,代表循环的#类型,代表变量的name类型,此为token的第一个元素,token的第二个元素为这个类型的值,如果有第三个元素,那么第三个元素为嵌套的结构,当然,在janl/mustache.js中还有更多的类型,这里只是简单的列举几项。

Scanner对象

要从模板字符串转换为tokens,第一步我们应该想得到的应该是遍历真个模板字符串,找到其中的本文,变量和嵌套结构等类型,于是可以创建一个Scanner对象,专门负责遍历模板字符串和返回找到的文本。

同时,token中是不包含{{}}的,所有还需要定义一个方法跳过这两个字符串。

class Scanner {
  constructor(tplt) {
    this.tplt  = tplt;
    // 指针
    this.pos = 0;
    // 尾巴  剩余字符
    this.tail = tplt;
  }

  /**
   * 路过指定内容
   *
   * @memberof Scanner
   */
  scan(tag) {
    if (this.tail.indexOf(tag) === 0) {
      // 直接跳过指定内容的长度
      this.pos += tag.length;
      // 更新tail
      this.tail = this.tplt.substring(this.pos);
    }
  }

  /**
   * 让指针进行扫描,直到遇见指定内容,返回路过的文字
   *
   * @memberof Scanner
   * @return str 收集到的字符串
   */
  scanUnitl(stopTag) {
    // 记录开始扫描时的初始值
    const startPos = this.pos;
    // 当尾巴的开头不是stopTg的时候,说明还没有扫描到stopTag
    while (!this.eos() && this.tail.indexOf(stopTag) !== 0 ) {
      // 改变尾巴为当前指针这个字符到最后的所有字符
      this.tail = this.tplt.substring(++this.pos);
    }
    // 返回经过的文本数据
    return this.tplt.substring(startPos, this.pos).trim();
  }

  /**
   * 判断指针是否到达文本末尾(end of string)
   *
   * @memberof Scanner
   */
  eos() {
    return this.pos >= this.tplt.length;
  }
}

扫描到了相关内容,我们就可以将数据收集起来,并转换为不含嵌套结构的token,于是定义一个collectTokens函数:

function collectTokens(scanner) {
  const tokens = [];
  let word = '';
  // 当scanner没有到头的就持续将获取的token加入数组中
  while (!scanner.eos()) {
    // 收集文本
    word = scanner.scanUnitl('{{');
    word && tokens.push(['text', word]);
    scanner.scan('{{');

    // 收集变量
    word = scanner.scanUnitl('}}');

    // 对不同类型结构进行分类标识
    switch (word[0]) {
      case '#':
        tokens.push(['#', word.substring(1)]);
        break;
      case '/':
        tokens.push(['/', word.substring(1)]);
        break;
      default:
        word && tokens.push(['name', word]);
    }

    scanner.scan('}}');
  }

  return tokens;
}

这时,我们得到了一个这样的数组:

[
    ["text", "<div>↵      <ol>"],
    ["#", "students"],
    ["text", "<li>↵            学生"],
    ["name", "name"],
    ["text", "的爱好是↵            <ol>"],
    ["#", "hobbies"],
    ["text", "<li>"],
    ["name", "."],
    ["text", "</li>"],
    ["/", "hobbies"],
    ["text", "</ol>↵          </li>"],
    ["/", "students"],
    ["text", "</ol>↵    </div>"]
]

可以看到,除了嵌套结构外,tokens的基本特征已经具备了。那么嵌套结构该如何加入呢?我们可以分析出:

["#", "students"],
["#", "hobbies"],
["/", "hobbies"],
["/", "students"],

可知#是嵌套结构的开始,/是嵌套结构的结束,同时,先出现的students反而后结束,而后出现的hobbies反而先结束。对数据结构有一些研究的同学应该立即就能想到一种数据结构: —- 一种先进后出的结构。而在JS中,可以用数组pushpop方法模拟栈结构。只要遇见#我们就压栈,记录当前是哪个层级,遇见/就出栈,退出当前层级,直到退到最外层。

于是,我们有了一个新的函数nestTokens:

function nestTokens(tokens) {
  const nestedTokens = [];
  const stack = [];
  // 收集器默认为最外层
  let collector = nestedTokens;

  for (let i = 0, len = tokens.length; i < len; i++) {
    const token = tokens[i];

    switch (token[0]) {
      case '#':
        // 收集当前token
        collector.push(token);
        // 压入栈中
        stack.push(token);
        // 由于进入了新的嵌套结构,新建一个数组保存嵌套结构
        // 并修改collector的指向
        collector = token[2] = [];
        break;
      case '/':
        // 出栈
        stack.pop();
        // 将收集器指向上一层作用域中用于存放嵌套结构的数组
        collector = stack.length > 0 
                    ? stack[stack.length - 1][2] 
                    : nestedTokens;
        break;
      default:
        collector.push(token);
    }
  }

  return nestedTokens;
}

于是我们的tokenizer函数就很好实现了,直接调用上面两个函数即可:

function tokenizer(tplt) {
  const scanner = new Scanner(tplt.trim());

  // 收集tokens,并将循环内容嵌套到tokens中,并返回
  return nestTokens(collectTokens(scanner));
}

到这里,模板引擎已经完成了一大半,剩下的就是将数据注入和返回最终的字符串了。也就是tokens2dom函数。
不过在此之前,我们还要再解决一个问题,还记得我们在使用正则替换时是怎么注入数据的吗?

tplt.replace(/{{\s*(\w+)\s*}}/g, function(match, $1) {
  return data[$1];
});

回顾一下,我们是通过data[$1]来获取对象数据的,可是,如果我的模板里写的是类似{{a.b.c}}这样的打点调用该怎么办?JS可不支持obj[a.b.c]这样的写法,而janl/mustache.js中是支持变量打点调用的。所以,在数据注入前,我们还需要一个函数来解决这个问题。

于是:

/**
 * 在对象obj中用连续的打点字符串寻找到对象值
 * 
 * @example lookup({a: {b: {c: 100}}}, 'a.b.c')
 *
 * @param {object} obj
 * @param {string} key
 * @return any
 */
function lookup(obj, key) {
  const keys = key.split('.');

  // 设置临时变量,一层一层查找
  let val = obj;
  for (const k of keys) {
    if(val === undefined) {
      console.warn(`Can't read ${k} of undefined`);
      return '';
    };
    val = val[k];
  }
  return val;
}

解决了打点调用的问题,就可以开始数据注入了,我们要对不同类型的数据进行不同的操作,文本就直接拼接,变量就查找数据,循环的就遍历,嵌套的就递归。

于是:

function tokens2dom(tokens, data) {

  let html = '';
  for (let i = 0, len = tokens.length; i < len; i++) {
    const token = tokens[i];

    // 按类型拼接字符串
    switch (token[0]) {
      case 'name':
        if (token[1] === '.') {
          html += data;
        } else {
          html += lookup(data, token[1]);
        }
        break;
      case '#':
        // 递归解决数组嵌套的情况
        for (const item of data[token[1]]) {
          html += tokens2dom(token[2], item);
        }
        break;
      default:
        html += token[1];
    }
  }

  return html;
}

到这里我们的模板引擎就全部结束啦,再向全局暴露一个对象方便调用:

// 暴露全局变量
window.TemplateEngine = {
  render(tplt, data) {
    // 转换为tokens
    const tokens = tokenizer(tplt);
    // 注入数据,让tokens转换为DOM字符串
    const html = tokens2dom(tokens, data);

    return html;
  }
}

这里只是对mustache的一个简单实现,还有类似于条件渲染等功能没有实现,同学们有兴趣的可以看看源码
janl/mustache.js

当然可以看本文实现的代码mini-tplt