写一个 javascript 模板引擎

写一个 javascript 模板引擎

我就是要自己写一个模板引擎,只要 40 行。
而且她是兼容 ie6 的 0_o


js 模板引擎有很多很多,我以前经常用 art-template ,有时候也会拿 vue 来当模板引擎用。

之前在携程商旅的时候,代码规范是 未经允许不能使用 【外部代码】 ,囧。

有了需求,那么就去写吧,当时因为一些原因没用上。后来分了产线,自己搭了一套构建,用了几个月感觉挺爽,把这小段代码按照比较大众的规范重写,跟大家分享下。

mini-tpl 在 github 的代码仓库


模板语法

首先是选择模板语法,ejs 语法是首选,因为大众,更无需去学习指令型模板引擎的那些东西。

如果写过 jsp 或者 asp/asp.net 的可以直接上手。

js
1// es6 module , typescript
2import tpl from 'mini-tpl';
3// nodejs
4// const tpl = require('mini-tpl');
5
6const content = `
7<ul>
8<% for(var i=0; i < data.length; i++){
9    var item = data[i];
10    if(item.age < 30) { %>
11        <li>我的名字是<%=item.name%>,我的年龄是<%=item.age%></li>
12    <% } else { %>
13        <li>my name is <%=item.name%>,my age is a sercet.</li>
14    <% } %>
15<% } %>
16</ul>`;
17
18const data = [
19    { name: 'tom', age: 12 },
20    { name: 'lily', age: 24 },
21    { name: 'lucy', age: 55 }
22];
23
24console.log(tpl(content, data));

核心方法

实现模板引擎的关键是 Function 方法。

new Function ([arg1[, arg2[, ...argN]],] functionBody)

arg1, arg2, ... argN
    被函数使用的参数的名称必须是合法命名的。
    参数名称是一个有效的JavaScript标识符的字符串,或者一个用逗号分隔的有效字符串的列表;
    例如“×”,“theValue”,或“A,B”。
functionBody
    一个含有包括函数定义的JavaScript语句的字符串。

使用 Function 构造器生成的函数,并不会在创建它们的上下文中创建闭包;它们一般在全局作用域中被创建。
当运行这些函数的时候,它们只能访问自己的本地变量和全局变量,不能访问 Function 构造器被调用生成的上下文的作用域。MDN

也就是说:

  1. 可以用 new Function 来动态的创建一个函数,去执行某动态生成的函数定义 js 语句。
  2. 通过 new Function 生成的函数,作用域在全局。
  3. 那么传参有 3 种:把变量放到全局(扯淡)函数传参、用 call/apply 把值传给函数的 this。

最初我用的是 call 来传值,后来想了想不太优雅,换成了 用参数传递。也就是这样:

js
1const content = 'console.log(data);';
2
3let func = new Function('data', content);
4
5func('hello world'); // hello world

到此为止,一个最简单的模板引擎的雏形已经有了。下面来拆分一下具体实现。


模板拆分

先看看模板:

js
1<% for(var i=0; i<data.length; i++) {
2    var item = data[i];
3    if(item.age < 30){%>
4        <li>我的名字是<%=item.name%>,我的年龄是<%=item.age%></li>
5    <%}else{%>
6        <li>my name is <%=item.name%>,my age is a sercet.</li>
7    <%}%>
8<% } %>

js 逻辑部分,由 <%%> 包裹,js 变量的占位,由 <%= %> 包裹,剩下的是普通的要拼接的 html 字符串部分。

也就是说,需要用正则找出的部分有 3 种:

  1. <%%> 逻辑部分的 js 内容
  2. <%=%> 占位部分的 js 内容
  3. 其它的 纯文本 内容

其中第二项,js 占位,也属于拼接文本。在使用正则的时候可以一起查出来。


正则提取

提取内容的唯一选择是使用正则表达式。
因为要多次从模板中,把 js 逻辑部分文本 依次提取出来。
对于每一次提取,都要获取提取出的内容,本次匹配最后的索引项(用于提取文本内容)。

所以我选择了 RegExp.prototype.exec,它的返回值是一个集合(伪数组):

属性/索引描述
[0]匹配的全部字符串
[1],...[n]括号中的分组捕获
index匹配到的字符位于原始字符串的基于 0 的索引值
input原始字符串

通过这样,就可以拿到匹配到的 js 逻辑部分,并通过 index 和本次匹配到的内容,来获取每个 js 逻辑部分之间的文本内容项。

要注意,在全局匹配模式下,正则表达式会接着上次匹配的结果继续匹配新的字符串。

js
1/**
2 * 从原始模板中提取 文本/js 部分
3 *
4 * @param {string} content
5 * @returns {Array<{type:number,txt:string}>}
6 */
7function transform(content) {
8    var arr = []; //返回的数组,用于保存匹配结果
9    var reg = /<%([\s\S]*?)%>/g; //用于匹配js代码的正则
10    var match; //当前匹配到的match
11    var nowIndex = 0; //当前匹配到的索引
12
13    while ((match = reg.exec(content))) {
14        // 保存当前匹配项之前的普通文本/占位
15        appendTxt(arr, content.substring(nowIndex, match.index));
16        //保存当前匹配项
17        var item = {
18            type: 1, // 类型  1- js逻辑 2- js 占位 null- 文本
19            txt: match[1] // 内容
20        };
21        if (match[1].substr(0, 1) == '=') {
22            // 如果是js占位
23            item.type = 2;
24            item.txt = item.txt.substr(1);
25        }
26        arr.push(item);
27        //更新当前匹配索引
28        nowIndex = match.index + match[0].length;
29    }
30    //保存文本尾部
31    appendTxt(arr, content.substr(nowIndex));
32    return arr;
33}
34
35/**
36 * 普通文本添加到数组,对换行部分进行转义
37 *
38 * @param {Array<{type:number,txt:string}>} list
39 * @param {string} content
40 */
41function appendTxt(list, content) {
42    content = content.replace(/\r?\n/g, '\\n');
43    list.push({ txt: content });
44}

上面是从模板里面把 js 逻辑、占位 和 普通文本提取出来,之后拼接一下,通过 new Function 动态构造一个新的方法:

js
1/**
2 * 模板 + 数据 =》 渲染后的字符串
3 *
4 * @param {string} content 模板
5 * @param {any} data 数据
6 * @returns 渲染后的字符串
7 */
8function render(content, data) {
9    data = data || {};
10    var list = ['var tpl = "";'];
11    var codeArr = transform(content); // 代码分割项数组
12
13    for (var i = 0, len = codeArr.length; i < len; i++) {
14        var item = codeArr[i]; // 当前分割项
15
16        if (item.type == 1) {
17            // js逻辑
18            list.push(item.txt);
19        } else if (item.type == 2) {
20            // js占位
21            var txt = 'tpl+=' + item.txt + ';';
22            list.push(txt);
23        } else {
24            //文本
25            var txt = 'tpl+="' + item.txt.replace(/"/g, '\\"') + '";';
26            list.push(txt);
27        }
28    }
29    list.push('return tpl;');
30
31    return new Function('data', list.join('\n'))(data);
32}

UMD 打包

套个 umd 的壳子,perfect >_<#@!

js
1(function (root, factory) {
2    if (typeof define === 'function' && define.amd) {
3        // AMD
4        define(factory);
5    } else if (typeof exports === 'object') {
6        // Node, CommonJS-like
7        // es6 module , typescript
8        var mo = factory();
9        mo.__esModule = true;
10        mo['default'] = mo;
11        module.exports = mo;
12    } else {
13        // browser
14        root.miniTpl = factory();
15    }
16}(this, function () {
17    // ...code
18    return render;
19});

至此一个超简单的模板引擎就完成了,只有几十行,兼容 ie6。

to-be-continued
avatar

闲暇时候的文章

会写一些 代码、心情、生活、食物 等东西,分享所学,验证所得,

如果碰巧你找到感兴趣的东西,可以来瞅瞅。

Copyright © 2017 - 2024 xieshuang. All Rights Reserved. Power by k8s + nestjs + next + vue + typescript.
鄂ICP备20008501号-1