模板引擎

什么是模板

我喜欢这样描述模板引擎(系统):

  • 给定一个模板字符串,不同于一般的字符串,该字符串存在一些 规则,并且需要填充 数据 或者 逻辑
const tpl = 'hello {{name}}'

{{name}} 就描述了一个规则:插值(interpolation),双花括号内的变量 name 将在之后被替换。

  • 通过一个渲染器对该模板进行渲染并返回最终需要呈现的内容,渲染器通常需要知道两件事儿:(1)待渲染模板 (2)待填充数据:
const data = {name: 'wxj'};
const content = render(tpl, data);
// => 'hello wxj'

由此,我们不难得出模板引擎核心在于 规则渲染器

实现一个基本的模板引擎

规则

有了上面的认识,我们现在可以着手开始实现一个基本的 JavaScript 模板引擎,首先我们定义 规则

  • 插值:比如 {{name}} 直接用渲染数据进行替换,我们通过双花括号包裹。

显然,解析规则我们需要利用到正则表达式,因此我们最终的规则定义如下:

const rules = {
    interpolate: /{{([\s\S]+?)}}/
};

// 最终的匹配正则
const matcher = new RegExp([
    rules.interpolate.source
].join('|'), 'g');

渲染器

渲染器的核心逻辑很简单,就是遍历传入的模板字符串,当子串匹配到规则时,根据规则进行处理,最终返回一个新(渲染好的)字符串。由于我们现在只需要做插值替换,所以利用 String.prototype.replace 进行一下全局替换即可:

function render (tpl, data) {
    return tpl.replace(matcher, (match, interpolate)=>{
        return data[interpolate];
    });
}

如此,我们就实现了一个最简单的模板引擎,认识了其最基本的要素,但是,该系统还十分单薄,我们需要对其进行优化。

优化:支持模板插入代码逻辑

上面我们实现的模板只能够对模板进行数据填充,假设我们有下面内容的模板:

Students:
{ for(i=0;i<students.length;i++) }
{{ students[i].name }}

期望该模板渲染的结果为:

Students: wxj lcx

在该模板中,显然,我们支持了传入代码逻辑(一个 for 循环),并且设置其规则为通过 {} 进行包裹。为了能支持上述的模板,我们需要新建该规则服务于执行逻辑:

const rules = {
    // 插值
    interpolate: /{{([\s\S]+?)}}/,
    // 逻辑
    evaluate: /{([\s\S+?])}/
}

// 最终的匹配正则
const matcher = new RegExp([
    rules.interpolate.source,
    rules.evaluate.source
].join('|'), 'g');

渲染器现在就不单是进行字符串替换操作了,还应当支持执行传入的逻辑:

function render(tpl, data) {
    // 拼接字符串
    let concating = `content +='`;
    let index = 0;
    tpl.replace(matcher, (match, interpolate, evaluate, offset) => {
        concating += tpl.slice(index, offset);
        // 刷新拼接起点
        index = offset + match.length;
        if(evaluate) {
        // 如果是执行逻辑
        concating += `';\n${evaluate}\n content +='`;
        } else if(interpolate) {
        // 如果是插值
        concating += `'+${interpolate}+'`;
        }
        return match;
    });
    // 剩余字符拼接
    concating += tpl.slice(index);
    concating += `';\n`;
    concating = `with(obj) {\n${concating}}`;
    // 通过函数来支持逻辑执行
    const body = `let content = ''; \n${concating}; \nreturn content;`;
    const renderFunc = new Function('obj', body);
    return renderFunc(data);
}

可以看到,通过 new Function(arguments, body) 来动态构建渲染函数,我们支持了向模板传入执行逻辑。值得注意的是,该构造函数将接收两个参数:

  • obj:待渲染的数据对象,借助于 with(expression) ,我们限定了函数体中的数据来源不会受到外部作用域的干扰。

  • body:渲染函数的函数体,由我们动态拼接而成。

现在,测试一下:

const tpl = 'Students: ' +
'{ for(i=0;i<students.length;i++) }' +
'{{ students[i].name }} ';

data = {
    students: [{
        id:1,
        name: 'wxj'
    }, {
        id: 2,
        name: 'lcx'
    }]
};
const content = render(tpl, data);
// content: 'Students: wxj lcx'

优化:特殊字符逃逸

在上述的模板系统中,我稍微修改了一下模板,仅只是简单的加上了换行符 '\n'

Students: \n
{ for(i=0;i<students.length;i++) }
{{ students[i].name }}

期望该模板渲染的结果为:

Students:
wxj lcx

再次尝试调用我们上一步我们写好的渲染器:

const tpl = 'Students: \n' +
'{ for(i=0;i<students.length;i++) }' +
'{{ students[i].name }} ';

const content = render(tpl, data);

很遗憾,报错了,原因就是出在了换行符 '\n' 上,我们拼接字符串的时候没有用反斜杠 \ 对转义字符进行逃逸(escape):

// Error:
let body = `console.log('I love u!\n')`;
console.log(body);
// =>:
// "console.log('I love u!
// '))"

// Correct:
body = `console.log('I love u!\\n')`;
console.log(body);
// => "console.log('I love u!\n')"

现在,我们修改渲染器对转义字符进行逃逸:

// 需要逃逸的字符
const escapes = {
    "'": "'",
    '\\': '\\',
    '\r': 'r',
    '\n': 'n',
    '\u2028': 'u2028', // 行分隔符
    '\u2029': 'u2029' // 行结束符
};

// 逃逸正则
const escapeRegExp = /\\|'|\r|\n|\u2028|\u2029/g;

// 逃逸替换函数
function escape(match) {
    return '\\' + escapes[match];
}

function render(tpl, data) {
    // 拼接字符串
    let concating = `content +='`;
    let index = 0;
    tpl.replace(matcher, (match, interpolate, evaluate, offset) => {
        // 逃逸
        concating += tpl.slice(index, offset).replace(escapeRegExp, escape);
        // 刷新拼接起点
        index = offset + match.length;
        if(evaluate) {
            // 如果是执行逻辑
            concating += `';\n${evaluate}\n content +='`;
        } else if(interpolate) {
            // 如果是插值
            concating += `'+${interpolate}+'`;
        }
        return match;
    });
    // 剩余字符拼接
    concating += tpl.slice(index).replace(escapeRegExp, escape);;
    concating += `';\n`;
    concating = `with(obj) {\n${concating}}`;
    // 通过函数来支持逻辑执行
    const body = `let content = ''; \n${concating}; \nreturn content;`;
    const renderFunc = new Function('obj', body);
    return renderFunc(data);
}

优化:预编译

刚才我们实现的模板系统,仅只是一个简单的“替换形”模板,假设我们的模板结构不变,仅仅内容(装填数据)发生变化,就不得不重新进行渲染整个模板,不仅将造成性能上损失,也将造成复用性上的损失。接下来,我们会将 构造模板结构装填数据 拆开,首先编译模板结构:

function template(tpl) {
    // 拼接字符串
    let concating = `content +='`;
    let index = 0;
    tpl.replace(matcher, (match, interpolate, evaluate, offset) => {
        // 逃逸
        concating += tpl.slice(index, offset).replace(escapeRegExp, escape);
        // 刷新拼接起点
        index = offset + match.length;
        if(evaluate) {
            // 如果是执行逻辑
            concating += `';\n${evaluate}\n content +='`;
        } else if(interpolate) {
            // 如果是插值
            concating += `'+${interpolate}+'`;
        }
    return match;
    });
    // 剩余字符拼接
    concating += tpl.slice(index).replace(escapeRegExp, escape);;
    concating += `';\n`;
    concating = `with(obj) {\n${concating}}`;
    // 通过函数来支持逻辑执行
    const body = `let content = ''; \n${concating}; \nreturn content;`;
    const renderFunc = new Function('obj', body);
    return function(data) {
        return renderFunc(data);
    }
}

现在,我们通过函数 template(tpl) 来编译传入的模板 tpl,该函数会返回一个新的函数,由这个函数完成接收数据以后的最终渲染:

const tpl = 'hello {{name}}';
// 编译模板
const compiled = template(tpl);
const content1 = compiled({name: 'wxj'}); // => 'hello wxj'
const content2 = compiled({name: 'zxy'}); // => 'hello zxy';

可以看到,模板只需一次编译,就能再多地多个时刻进行复用。

然而这个模板引擎也并不完美,假如我们的模板中包含了 this

tpl = '{console.log(\'name\', this.name);}';

// 模板管理器
const tplManager = {
    name: 'manager',
    log: template(tpl)
};
// => 'name '
tplManager.log({});

并没有输出我们期望的字符串 'name: manager',所以我们还需要略微修改 template 函数,考虑模板运行时的上下文问题:

function template(tpl) {
// ...
    return function(data) {
        return renderFunc.call(this, data);
    }
}

间接调用函数时,通过 Function.prototype.call() 来保证上下文的正确绑定也是一种好习惯。

_.template

_.template = function (text, settings, oldSettings):根据传入的文本 text 及配置 settings,生成模板。

事实上,在前文的描述当中,我们就大致实现了 underscore 中的 _.template() 方法,之所以不直接上该函数的源码,是因为模板引擎并非一个简单的函数,读者若能够体会到从认识模板,到撰写一个基本模板引擎,再到优化模板引擎这个过程,将受益更多,也不会烦恼并且纠结于源码中一些细节的来源:

// 默认情况下,undersocre使用[ERB风格的模板](http://www.stuartellis.eu/articles/erb/)
// 但是也可以手动配置
_.templateSettings = {
    // 执行体通过<% %>包裹
    evaluate: /<%([\s\S]+?)%>/g,
    // 插入立即数通过<%= %>包裹
    interpolate: /<%=([\s\S]+?)%>/g,
    // 逃逸通过<%- %>包裹
    escape: /<%-([\s\S]+?)%>/g
};

// 如果不想使用interpolation、evaluation、escaping正则,
// 必须使用一个noMatch正则来保证不匹配的情况
var noMatch = /(.)^/;

// 定义需要逃逸的字符,以便他们之后能够被运用到模板中的字符串字面量中
var escapes = {
    "'": "'",
    '\\': '\\',
    '\r': 'r',
    '\n': 'n',
    '\u2028': 'u2028', // 行分隔符
    '\u2029': 'u2029' // 行结束符
};

// 逃逸正则
var escapeRegExp = /\\|'|\r|\n|\u2028|\u2029/g;

/**
* 转义字符
* @param {string} match
*/
var escapeChar = function (match) {
    return '\\' + escapes[match];
};

// JavaScript micro-templating, similar to John Resig's implementation.
// Underscore templating handles arbitrary delimiters, preserves whitespace,
// and correctly escapes quotes within interpolated code.
// NB: `oldSettings` only exists for backwards compatibility.
/**
* underscore实现的一个js微模板引擎
* @param {string} text
* @param {object} settings 模板配置
* @param {object} oldSettings 该参数用以向后兼容
*/
_.template = function (text, settings, oldSettings) {
    // 校正模板配置
    if (!settings && oldSettings) settings = oldSettings;
    // 获得最终的模板配置
    settings = _.defaults({}, settings, _.templateSettings);

    // Combine delimiters into one regular expression via alternation.
    // 获得最终的匹配正则
    // /<%-([\s\S]+?)%>|<%=([\s\S]+?)%>|<%([\s\S]+?)%>|$/g

    var matcher = RegExp([
        (settings.escape || noMatch).source, // /<%([\s\S]+?)%>/g.source === '<%([\s\S]+?)%>'
        (settings.interpolate || noMatch).source,
        (settings.evaluate || noMatch).source
    ].join('|') + '|$', 'g');

    // Compile the template source, escaping string literals appropriately.
    //
    var index = 0;
    // source用来保存最终的函数执行体
    var source = "__p+='";
    // 正则替换模板内容,逐个匹配,逐个替换
    text.replace(matcher, function (match, escape, interpolate, evaluate, offset) {
        // offset 匹配到的子字符串在原字符串中的偏移量。
        //(比如,如果原字符串是“abcd”,匹配到的子字符串时“bc”,那么这个参数将时1)

        // 开始拼接字符串。进行字符逃逸
        source += text.slice(index, offset).replace(escapeRegExp, escapeChar);
        // 从下一个匹配位置开始
        index = offset + match.length;

        if (escape) {
            source += "'+\n((__t=(" + escape + "))==null?'':_.escape(__t))+\n'";
        } else if (interpolate) {
            source += "'+\n((__t=(" + interpolate + "))==null?'':__t)+\n'";
        } else if (evaluate) {
            source += "';\n" + evaluate + "\n__p+='";
        }

        // Adobe VMs need the match returned to produce the correct offset.
        return match;
    });

    //
    source += "';\n";
    // 如果没有在settings中声明变量,则用with限定作用域
    if (!settings.variable) source = 'with(obj||{}){\n' + source + '}\n';

    source = "var __t,__p='',__j=Array.prototype.join," +
    "print=function(){__p+=__j.call(arguments,'');};\n" +
    source + 'return __p;\n';

    var render;
    try {
        // 动态创建渲染函数
        render = new Function(settings.variable || 'obj', '_', source);
    } catch (e) {
        e.source = source;
        throw e;
    }

    // 最终返回一个模板函数,通过给模板传递数据
    // 最后通过render来渲染结果
    var template = function (data) {
    return render.call(this, data, _);
    };

    // 保留编译后的源码
    var argument = settings.variable || 'obj';
    template.source = 'function(' + argument + '){\n' + source + '}';

    return template;
};

相比较上文写的模板引擎,underscore 提供的模板引擎还有如下特色:

  • 采用了 ERB 风格的模板,同时,也支持自定义模板风格
_.templateSettings = {
 interpolate: /\{\{(.+?)\}\}/g
};
var template = _.template("Hello {{ name }}!");
template({name: "Mustache"});
// => "Hello Mustache!"
  • 支持对 HTML 内容进行逃逸:
var template = _.template("<b><%- value %></b>");
template({value: '<script>'});
// => "<b>&lt;script&gt;</b>"
  • 支持传递一个变量标记,这样,模板编译的时候,将不会用到 with 来限定作用域,从而显著提升模板性能:
_.template("Using 'with': <%= data.answer %>", {variable: 'data'})({answer: 'no'});
// => "Using 'with': no"
  • 在返回的模板函数中,提供了一个 source 属性,来获得编译后的模板函数源码,从而支持服务端使用 JST(JavaScript Template)。比如我们在服务端的模板文件使用了 JST 如下:
window.JST = {};
JST.contact = <% _.template("<div class='contact'><%= name %> ...").source %>

后端模板引擎通过 <% %> 将源码打印到模板内,获得缓存的模板文件,假设叫 contact.js:

window.JST = {};
JST.contact = function(obj) {
 // ....
}

当我们前端请求 contact.js(字符串)后, 就能使用该模板,而不用将繁重的模板编译工作放在前端进行:

<script src='http://xxx/contact.js'></script>
<script>
 const html = JST.contact({name: 'wxj'})
</script>

多说一句:with

上文中我们提到,省略 with,将显著提高模板性能,我们可以测试一下 with 的性能:

const student = {
    name: 'wxj'
};

// 设置一个大循环,访问`student`的`name`属性
const n = 10000000;
let stuName;

console.time('Not Using with');
for(let i=0;i<n;i++) {
    stuName = student.name;
}
console.timeEnd('Not Using with');

console.time('Using with');
for(let i=0;i<n;i++) {
    with(student) {
        stuName = name;
    }
}
console.timeEnd('Using with');

// => Not Using with: 2262.372ms
// => Using with: 47.398ms

可以看到,是否使用 with,性能差距确实非常大。造成 with 性能低下的原因就在于,当面临如下的代码段时:

with(obj) {
    console.log(name);
}

为了确定变量 name 的值,JavaScript 引擎需要先查找 with 语句包裹的变量,而后是局部变量,最后是全局变量。这样,如下的代码段多了一层查找开销:

console.log(obj.name);

参考资料

results matching ""

    No results matching ""