字符串模板浅析

做者:崔静javascript

前言

虽然如今有各类前端框架来提升开发效率,可是在某些状况下,原生 JavaScript 实现的组件也是不可或缺的。例如在咱们的项目中,须要给业务方提供一个通用的支付组件,可是业务方使用的技术栈多是 VueReact 等,甚至是原生的 JavaScript。那么为了实现通用性,同时保证组件的可维护性,实现一个原生 JavaScript 的组件也就显得颇有必要了。css

下面左图为咱们的 Panel 组件的大概样子,右图则为咱们项目的大概目录结构:html

咱们将一个组件拆分为 .html.js.css 三种文件,例如 Panel 组件,包含 panel.html、panel.js、panel.css 三个文件,这样能够将视图、逻辑和样式拆解开来便于维护。为了提高组件灵活性,咱们 Panel 中的标题,button 的文案,以及中间 item 的个数、内容等均由配置数据来控制,这样,咱们就能够根据配置数据动态渲染组件。这个过程当中,为了使数据、事件流向更为清晰,参考 Vue 的设计,咱们引入了数据处理中心 data center 的概念,组件须要的数据统一存放在 data center 中。data center 数据改变会触发组件的更新,而这个更新的过程,就是根据不一样的数据对视图进行从新渲染。前端

panel.html 就是咱们常说的“字符串模板”,而对其进行解析变成可执行的 JavaScript 代码的过程则是“模板引擎”所作的事情。目前有不少的模板引擎供选择,且通常都提供了丰富的功能。可是在不少状况下,咱们可能只是处理一个简单的模板,没有太复杂的逻辑,那么简单的字符串模板已足够咱们使用。vue

几种字符串模板方式和简单原理

主要分为如下几类:java

  1. 简单粗暴——正则替换react

    最简单粗暴的方式,直接使用字符串进行正则替换。可是没法处理循环语句和 if / else 判断这些。git

    a. 定义一个字符串变量的写法,好比用 <%%> 包裹github

    const template = (
      '<div class="toast_wrap">' +
        '<div class="msg"><%text%></div>' +
        '<div class="tips_icon <%iconClass%>"></div>' +
      '</div>'
    )
    复制代码

    b. 而后经过正则匹配,找出全部的 <%%>, 对里面的变量进行替换正则表达式

    function templateEngine(source, data) {
      if (!data) {
        return source
      }
      return source.replace(/<%([^%>]+)?%>/g, function (match, key) {  
        return data[key] ? data[key] : ''
      })
    }
    templateEngine(template, {
      text: 'hello',
      iconClass: 'warn'
    })
    复制代码
  2. 简单优雅——ES6 的模板语法

    使用 ES6 语法中的模板字符串,上面的经过正则表达式实现的全局替换,咱们能够简单的写成

    const data = {
      text: 'hello',
      iconClass: 'warn'
    }
    const template = ` <div class="toast_wrap"> <div class="msg">${data.text}</div> <div class="tips_icon ${data.iconClass}"></div> </div> `
    复制代码

    在模板字符串的 ${} 中能够写任意表达式,可是一样的,对 if / else 判断、循环语句没法处理。

  3. 简易模板引擎

    不少状况下,咱们渲染 HTML 模板时,尤为是渲染 ul 元素时, 一个 for 循环显得尤其必要。那么就须要在上面简单逻辑的基础上加入逻辑处理语句。

    例如咱们有以下一个模板:

    var template = (
      'I hava some menu lists:' +
      '<% if (lists) { %>' +
        '<ul>' +
          '<% for (var index in lists) { %>' +
            '<li><% lists[i].text %></li>' +
          '<% } %>' +
        '</ul>' +
      '<% } else { %>' +
        '<p>list is empty</p>' +
      '<% } %>'
    )
    复制代码

    直观的想,咱们但愿模板能转化成下面的样子:

    'I hava some menu lists:'
    if (lists) {
      '<ul>'
      for (var index in lists) {
        '<li>'
        lists[i].text
        '</li>'
      }
      '</ul>'
    } else {
     '<p>list is empty</p>'
    }
    复制代码

    为了获得最后的模板,咱们将散在各处的 HTML 片断 push 到一个数组 html 中,最后经过 html.join('') 拼接成最终的模板。

    const html = []
    html.push('I hava some menu lists:')
    if (lists) {
      html.push('<ul>')
      for (var index in lists) {
        html.push('<li>')
        html.push(lists[i].text)
        html.push('</li>')
      }
      html.push('</ul>')
    } else {
     html.push('<p>list is empty</p>')
    }
    return html.join('')
    复制代码

    如此,咱们就获得了能够执行的 JavaScript 代码。对比一下,容易看出从模板到 JavaScript 代码,经历了几个转换:

    1. <%%> 中若是是逻辑语句(if/else/for/switch/case/break),那么中间的内容直接转成 JavaScript 代码。经过正则表达式 /(^( )?(var|if|for|else|switch|case|break|;))(.*)?/g 将要处理的逻辑表达式过滤出来。
    2. <% xxx %> 中若是是非逻辑语句,那么咱们替换成 html.push(xxx) 的语句
    3. <%%> 以外的内容,咱们替换成 html.push(字符串)
    const re = /<%(.+?)%>/g
    const reExp = /(^( )?(var|if|for|else|switch|case|break|;))(.*)?/g
    let code = 'var r=[];\n'
    let cursor = 0
    let result
    let match
    const add = (line, js) => {
      if (js) { // 处理 `<%%>` 中的内容,
        code += line.match(reExp) ? line + '\n' : 'r.push(' + line + ');\n'
      } else { // 处理 `<%%>` 外的内容
        code += line !== '' ? 'r.push("' + line.replace(/"/g, '\\"') + '");\n' : ''
      }
      return add
    }
    
    while (match = re.exec(template)) { // 循环找出全部的 <%%> 
      add(template.slice(cursor, match.index))(match[1], true)
      cursor = match.index + match[0].length
    }
    // 处理最后一个<%%>以后的内容
    add(template.substr(cursor, template.length - cursor))
    // 最后返回
    code = (code + 'return r.join(""); }').replace(/[\r\t\n]/g, ' ')
    复制代码

    到此咱们获得了“文本”版本的 JavaScript 代码,利用 new Function 能够将“文本”代码转化为真正的可执行代码。

    最后还剩一件事——传入参数,执行该函数。

    方式一:能够把模板中全部的参数统一封装在一个对象 (data) 中,而后利用 apply 绑定函数的 this 到这个对象。这样在模板中,咱们即可经过 this.xx 获取到数据。

    new Function(code).apply(data)
    复制代码

    方式二:老是写 this. 会感受略麻烦。能够把函数包裹在 with(obj) 中来运行,而后把模板用到的数据当作 obj 参数传入函数。这样一来,能够像前文例子中的模板写法同样,直接在模板中使用变量。

    let code = 'with (obj) { ...'
    ...
    new Function('obj', code).apply(data, [data])
    复制代码

    可是须要注意,with 语法自己是存在一些弊端的。

    到此咱们就获得了一个简单的模板引擎。

    在此基础上,能够进行一些包装,拓展一下功能。好比能够增长一个 i18n 多语言处理方法。这样能够把语言的文案从模板中单独抽离出来,在全局进行一次语言设置以后,在后期的渲染中,直接使用便可。

    基本思路:对传入模板的数据进行包装,在其中增长一个 $i18n 函数。而后当咱们在模板中写 <p><%$i18n("something")%></p> 时,将会被解析为 push($i18n("something"))

    具体代码以下:

    // template-engine.js
    import parse from './parse' // 前面实现的简单的模板引擎
    class TemplateEngine {
      constructor() {
        this.localeContent = {}
      }
    
      // 参数 parentEl, tpl, data = {} 或者 tpl, data = {}
      renderI18nTpl(tpl, data) {
        const html = this.render(tpl, data)
        const el = createDom(`<div>${html}</div>`)
        const childrenNode = children(el)
        // 多个元素则用<div></div>包裹起来,单个元素则直接返回
        const dom = childrenNode.length > 1 ? el : childrenNode[0]
        return dom
      }
      setGlobalContent(content) {
        this.localeContent = content
      }
      // 在传入模板的数据中多增长一个$i18n的函数。
      render(tpl, data = {}) {
        return parse(tpl, {
          ...data,
          $i18n: (key) => {
            return this.i18n(key)
          }
        })
      }
      i18n(key) {
        if (!this.localeContent) {
          return ''
        }
        return this.localeContent[key]
      }
    }
    export default new TemplateEngine()
    复制代码

    经过 setGlobalContent 方法,设置全局的文案。而后在模板中能够经过<%$i18n("contentKey")%>来直接使用

    import TemplateEngine from './template-engine'
    const content = {
      something: 'zh-CN'
    }
    TemplateEngine.setGlobalContent(content)
    const template = '<p><%$i18n("something")%></p>'
    const divDom = TemplateEngine.renderI18nTpl(template)
    复制代码

    在咱们介绍的方法中使用 '<%%>' 的来包裹逻辑语块和变量,此外还有一种更为常见的方式——使用双大括号 {{}},也叫 mustache 标记。在 Vue, Angular 以及微信小程序的模板语法中都使用了这种标记,通常也叫作插值表达式。下面咱们来看一个简单的 mustache 语法模板引擎的实现。

  4. 模板引擎 mustache.js 的原理

    有了方法3的基础,咱们理解其余的模板引擎原理就稍微容易点了。咱们来看一个使用普遍的轻量级模板 mustache 的原理。

    简单的例子以下:

    var source = ` <div class="entry"> {{#author}} <h1>{{name.first}}</h1> {{/author}} </div> `
    var rendered = Mustache.render(source, {
      author: true,
      name: {
        first: 'ana'
      }
    })
    复制代码
    • 模板解析

      模板引擎首先要对模板进行解析。mustache 的模板解析大概流程以下:

      1. 正则匹配部分,伪代码以下:
      tokens = []
      while (!剩余要处理的模板字符串是否为空) {
        value = scanner.scanUntil(openingTagRe);
        value = 模板字符串中第一个 {{ 以前全部的内容
        if (value) {
          处理value,按字符拆分,存入tokens中。例如 <div class="entry">
          tokens = [
            {'text', "<", 0, 1},
            {'text', "d"< 1, 2},
            ...
          ]
        }
        if (!匹配{{) break;
        type = 匹配开始符 {{ 以后的第一个字符,获得类型,如{{#tag}},{{/tag}}, {{tag}}, {{>tag}}等
        value = 匹配结束符以前的内容 }},value中的内容则是 tag
        匹配结束符 }}
        token = [ type, value, start, end ]
        tokens.push(token)
      }
      复制代码
      1. 而后经过遍历 tokens,将连续的 text 类型的数组合并。

      2. 遍历 tokens,处理 section 类型(即模板中的 {{#tag}}{{/tag}}{{^tag}}{{/tag}})。section 在模板中是成对儿出现的,须要根据 section 进行嵌套,最后和咱们的模板嵌套类型达到一致。

    • 渲染

      解析完模板以后,就是进行渲染了:根据传入的数据,获得最终的 HTML 字符串。渲染的大体过程以下:

      首先将渲染模板的数据存入一个变量 context 中。因为在模板中,变量是字符串形式表示的,如 'name.first'。在获取的时候首先经过 . 来分割获得 'name''first' 而后经过 trueValue = context['name']['first'] 设值。为了提升性能,能够增长一个 cache 将该次获取到的结果保存起来,cache['name.first'] = trueValue 以便于下次使用。

      渲染的核心过程就是遍历 tokens,获取到类型,和变量 (value) 的正真的值,而后根据类型、值进行渲染,最后将获得的结果拼接起来,即获得了最终的结果。

找到适合的模板引擎

众多模板引擎中,如何锁定哪一个是咱们所需的呢?下面提供几个能够考虑的方向,但愿能够帮助你们来选择:

  • 功能

    选择一个工具,最主要的是看它可否知足咱们所需。好比,是否支持变量、逻辑表达式,是否支持子模板,是否会对 HTML 标签进行转义等。下面表格仅仅作几个模板引擎的简单对比。 不一样模板引擎除了基本功能外,还提供了本身的特有的功能,好比 artTemplate 支持在模板文件上打断点,使用时方便调试,还有一些辅助方法;handlesbars 还提供一个 runtime 的版本,能够对模板进行预编译;ejs 逻辑表达式写法和 JavaScript 相同;等等在此就不一一例举了。

  • 大小

    对于一个轻量级组件来讲,咱们会格外在乎组件最终的大小。功能丰富的模板引擎便会意味着体积较大,因此在功能和大小上咱们须要进行必定的衡量。artTemplate 和 doT 较小,压缩后仅几 KB,而 handlebars 就较大,4.0.11 版本压缩后依然有 70+KB。 (注:上图部分数据来源于 https://cdnjs.com/ 上 min.js 的大小,部分来源于 git 上大小。大小为非 gzip 的大小)

  • 性能

    若是有很是多的频繁 DOM 更新或者须要渲染的 DOM 数量不少,渲染时,咱们就须要关注一下模板引擎的性能了。

最后,以咱们的项目为例子,咱们要实现的组件是一个轻量级的组件(主要为一个浮层界面,两个页面级的全覆盖界面)同时用户的交互也很简单,组件不会进行频繁从新渲染。可是对组件的总体大小会很在乎,并且还有一点特殊的是,在组件的文案咱们须要支持多语言。因此最终咱们选定了上文介绍的第三种方案。

参考文档
相关文章
相关标签/搜索