早期模板渲染方式和mustache模板引擎的简单实现

2021 年 11 月 5 日 星期五(已编辑)
/
17
摘要
>模板引擎的基本机理就是替换(转换),将指定的标签转换为需要的业务数据;将指定的伪语句按照某种流程来变换输出
这篇文章上次修改于 2024 年 7 月 20 日 星期六,可能部分内容已经不适用,如有疑问可询问作者。

早期模板渲染方式和mustache模板引擎的简单实现

在模板引擎诞生之前,为了使用js将动态数据渲染到页面上,出现了一系列模板渲染的方法,最直接的莫过于是手动创建所有节点:

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

这种方式需要手动创建所有节点,操作繁琐,因而无法在实际开发中运用。
还有一种方式是使用innerHTML来上树。

<div id="root"></div>
<script>
  var root = document.getElementById('root')
  root.innerHTML = '<h1>Hello World</h1>'
</script>

这种方式无疑是方便了许多,但是由于字符串无法换行,一旦遇到那种标签嵌套层级太多的情况,会给自己和他人的后期维护造成极大的困扰。

再后来又出现了这种表达方法

<div id="root"></div>
<script>
  var root = document.getElementById("root");

  var obj = {
    name: "洗脚哥",
    gender: "男",
    hobby: "洗脚",
  };

  root.innerHTML = [
    "<ul>",
    "   <li>姓名:" + obj.name + "</li>",
    "   <li>性别:" + obj.gender + "</li>",
    "   <li>爱好:" + obj.hobby + "</li>",
    "</ul>"
  ].join('');
</script>

这个方法将不可换行字符串转成阵列结构,可读性和可维护性都大大提升。

当然,ES6的模板字符串的出现,使这种阵列结构也失去了用武之地。

<div id="root"></div>
   <script>
     var root = document.getElementById("root");

     var obj = {
       name: "xjg",
       gender: "男",
       hobby: "洗脚",
     };

     root.innerHTML = `
       <ul>
          <li>姓名:${obj.name}  </li>
          <li>性别:${obj.gender}  </li>
          <li>爱好: ${obj.hobby}  </li>
       </ul>
     `
   </script>

但是,在实际开发中DOM操作并不是简单的,当DOM结构更加复杂,这种方式也显得力不从心。

于是mustache库横空出世!

什么是模板引擎

模板引擎的基本机理就是替换(转换),将指定的标签转换为需要的业务数据;将指定的伪语句按照某种流程来变换输出

Mustache

一个胡子模板是一个包含任意数量的小胡子标签的字符串。标签由围绕它们的双髭指示。

{{data}}

双花括号就是mustache语法的标识符,花括号中的data表示键名,作用是直接输出与键名匹配的键值。

var template = `
<div>{{data}}</div>
`
var data = "hello world"
var html = mustache.render(template,data)
//输出
`
<div>hello world</div>
`

{{.}}

表示枚举,可循环输出整个数组,需搭配{{#data}}和{{/data}}使用。

  • ### {{#data}}和{{/data}}

该语法以#开始,并以/结束表示区块,用于对当前上下文的数据进行一次或多次的渲染,类似vue中的v-for指令。 js // li标签根据data属性hobbies对应的键值持续迭代 var template = ` <div> {{#hobbies}} <li>{{.}}</li> {{/hobbies}} </div>`; var hobbies = ["唱","跳","rap"] var html = mustache.render(template) //输出 ` <div> <li>唱</li> <li>跳</li> <li>rap</li> </div>` 实现一下mustache模板引擎实现原理,其中包括了以上几种语法的简单实现。

Render函数

window.my_templateEngine = {
  //渲染函数
  render(templateStr, data) {
    //将字符串模板转换成tokens数组
    var tokens = parserTemplateToTokens(templateStr);
    //结合数据渲染模板
    var resTemplate = renderTemplate(data, tokens)
    return resTemplate
  }
}

一个模板引擎最核心的便是其渲染(render)函数,渲染函数从主要负责将字符串模板与数据结合,动态渲染出HTML。

嵌套数组(Tokens)

Tokens为Mustache的底层核心机理:js的嵌套数组,它是模板字符串的js表示形式,并且Tokens 是抽象语法树(AST)虚拟节点等的思路来源。

数组形式:

0: (2) ['text', '\n <ul>\n ']
1: (3) ['#', 'students', Array(5)]
2: (2) ['text', '\n </ul>\n ']

'text'代表普通字符串,直接拼接,第2项为字符串
'name'代表存储基本类型的键,第2项为键名
'#'和'/'表示区块,第2项为键名,第3项为数组

要将字符串模板转换成tokens数组,肯定要对传入的字符串模板进行处理,我们知道,Mustache语法是靠识别双花括号{{}}内的参数来进行数据替换的,那么我们必须使用某种识别方法来提取{{}}的内容。扫描器(Scanner)就是用来实现它的

扫描器(Scanner)

代码表示:

export default class Scanner {
  constructor(templateStr) {
    //贮存传入的字符串模板
    this.templateStr = templateStr
    //贮存tag之前的字符串
    this.tail = templateStr
    //扫描指针
    this.pos = 0
  }
  //扫描方法,传入需要识别的tag
  scanUntil(stopTag) {
    //记录一下pos
    var pos_backup = this.pos
    //当尾巴的开头不是stopTag的时候 就说明没有扫描到stopTag
    while(this.tail.indexOf(stopTag) !== 0 && !this.isEnd()) {
      this.pos ++;
      this.tail = this.templateStr.substr(this.pos)
    }
    return this.templateStr.substring(pos_backup,this.pos)
  }
  //跳过传入的tag
  scan(tag) {
    while(this.tail.indexOf(tag) === 0) {
    //过掉tag
      this.pos += 2;
      this.tail = this.templateStr.substr(this.pos)
    }
  }
  //判断是否到末尾
  isEnd() {
    return this.pos > this.templateStr.length
  }
}

scanUntil方法对传入的字符串进行检索,如果当前字符串中索引为0的字符不是'{',就将pos指针加1,直到遇到'{',该方法会返回处于'{'之前的字符串。
scan方法负责跳过tag,因为"{{"占用了两个字符的位置并且已经完成了它的使命,所以截去并将pos位置加2。下面是该方法示例

//示例
import Scanner from "./scanner";
var scanner = new Scanner(`<div><ul><li>{{hey}}</li></ul></div>`)
console.log(scanner.scanUntil("{{")) //<div><ul><li>
scanner.scan("{{") //跳过
console.log(scanner.scanUntil("}}")) //hey
scanner.scan("}}") //跳过
console.log(scanner.scanUntil("}}")) //</li></ul></div>
...

使用扫描器获取Tokens

import Scanner from "./scanner";
import nestTokens from "./nestTokens"
export default function parserTemplateToTokens(templateStr) {
  var tokens = [];
  var words = "";
  var scanner = new Scanner(templateStr)
  while (scanner.eos()) {
    //收集mustache开始标记之前的值
    words = scanner.scanUntil("{{");
    //过掉标记
    scanner.scan("{{")
    if (words !== '') {
      tokens.push(["text", words]);
    }
    //收集mustache结束标记之前的值
    words = scanner.scanUntil("}}");
    //过掉标记
    scanner.scan("}}")
    if (words !== '') {
      if(words[0] === "#") {
      tokens.push(["#", words.substring(1)]);
    } else if(words[0] === "/"){
      tokens.push(["/", words.substring(1)] );
    } else {
      tokens.push(["name", words]);
    }
    }
  }
  return tokens
}

根据示例方法,可以实现将截取的字符串形成像下面的形式的数组:

0: (2) ['text', '\n    <ul>\n      ']
1: (3) ['#', 'students', Array(5)]
2: (2) ['text', '\n        <li>\n          学生']
3: (2) ['name', 'name']
4: (2) ['text', '的爱好是\n          <ol>\n            ']
5: (3) ['#', 'hobbies', Array(3)]
6: (2) ['text', '\n            <li>']
7: (2) ['name', '.']
8: (2) ['text', '</li>\n              ']
9: (2) ['/', 'hobbies']
10: (2) ['text', '\n              </ol>\n        </li>\n        ']
11: (2) ['/', 'students']
12: (2) ['text', '\n    </ul>\n    ']

可以看到 数组中存在有hobbiesstudents两个区块,我们所期望的是他们是嵌套的关系(像以下的嵌套形式),所以我们的实现方式仍然存在一些问题。

0: (2) ['text', '\n    <ul>\n      ']
1: Array(3)
  0: "#"
  1: "students"
  2: Array(5)
    0: (2) ['text', '\n        <li>\n          学生']
    1: (2) ['name', 'name']
    2: (2) ['text', '的爱好是\n          <ol>\n            ']
    3: Array(3)
      0: "#"
      1: "hobbies"
      2: Array(3)
        0: (2) ['text', '\n            <li>']
        1: (2) ['name', '.']
        2: (2) ['text', '</li>\n              ']
4: (2) ['text', '\n              </ol>\n        </li>\n        ']
2: (2) ['text', '\n    </ul>\n    ']

数组折叠

export default function nestTokens(tokens) {
  //遍历数组遇到 # 
  let nestTokens = [];
  let tagStack = [];
  for (let i = 0; i < tokens.length; i++) {
    let token = tokens[i];
    switch (token[0]) {
      case "#":
        //这个存的是层级(几个#),待会好回去
        tagStack.push(token);
        break;
      case "/":
        let tagStackPop = tagStack.pop();
        //碰到第一个/触发出栈操作时,可以判断当前栈是否还有值,如果有则将出栈值push到栈顶数组中
        if(tagStack.length) {
          tagStack[tagStack.length - 1][2].push(tagStackPop)
        }else {
          nestTokens.push(tagStackPop)
        }
        break;
      default:
        if (tagStack.length === 0) {
          nestTokens.push(token)
        } else {
          tagStack[tagStack.length - 1][2].push(token)
        }
        break;
    }
  }
  return nestTokens
}

nestTokens方法对传入的tokens数组进行遍历,普通项正常进入nestTokens数组,遍历过程中若是遇到嵌套开始标志#,就将其压入tagStack,并且后续对nestTokens的push操作都是在tagStack栈顶数组中,如果遇到嵌套结束标志/,可以判断当前tagStack是否还有值,如果有则将出栈值push到栈顶数组中,如果没有值了则将最后一个出栈的数组push到nestTokens中并将nestTokens返回。

嵌套复杂类型的读取

如果我们需要拿到对象类型的值,需要用上.点符号,但目前的渲染函数只能识别简单的数据类型,所以我们需要再封装一个方法,来对数据中的复杂类型进行获取。

// 根据传入的带点字符串参数 查找对象的值
export default function lookup(dataObj, keyName) {
  let str = ''
  try {
    let i = keyName.indexOf('.')
    if (i !== -1 && keyName !== '.') {
      str = keyName.substring(0, i);
      return lookup(dataObj[str], keyName.substr(i + 1))
    } else {
      return dataObj[keyName]
    }
  } catch(err) {
    console.log(err);
  }
}

最后一步模板渲染

最终的Tokens数组我们已经拿到了,接下来便是最后一步但是同样重要,模板渲染,将tokens与数据进行结合,并形成最终的domStr。

import lookup from "./lookup"
import parserArr from "./parserArr"
// 实现功能 将数据与tokens数组合成html
export default function renderTemplate(data, tokens) {
  let resStr = '';
  for (let i = 0; i < tokens.length; i++) {
    let token = tokens[i]
    if (token[0] === 'text') {
      resStr += token[1]
    } else if (token[0] === 'name') {
      resStr += lookup(data, token[1])
    } else if (token[0] === '#') {
      resStr += parserArr(token, data)
    }
  }
  return resStr
}

测试

var template = `
   <ul>
     {{#students}}
     <li>
       学生{{name}}的爱好是
       <ol>
         {{#hobbies}}
         <li>{{.}}</li>
         {{/hobbies}}
       </ol>
     </li>
     {{/students}}
   </ul>
   `
   var data = {
     students: [{
         'name': '小明',
         'hobbies': ['游泳', '健身']
       },
       {
         'name': '小红',
         'hobbies': ['足球', '篮球', '羽毛球']
       },
       {
         'name': '小坤',
         'hobbies': ['唱', '跳','rap', '篮球']
       },
     ]
   }
   var domStr = window.my_templateEngine.render(template, data);
   var container = document.getElementById("container");
   container.innerHTML = domStr

使用社交账号登录

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...