Name: Password: Sign in
玩具 · 实验 中的文章

本文在 署名-非商业性使用-相同方式共享 3.0 版权协议下发布, 转载请注明出自 kyleslight.net

一个 JavaScript 模板引擎的实现

1 模板引擎概览

模板引擎是一个函数(或者其他形式等价的实现),它以一个 string 与一个 object 为参数,返回一个经过处理的 string.

比较直观的例子:

// string
var tpl = '<p> The boy out there is {{ name }}, and his is {{ age }} </p>';
// object
var data = {
    name: 'Mellon',
    age: 10
};
// (magic process)
Template(tpl, data); // return '<p> The boy out there is Mellon, and his is 10 </p>'

看到这里你大概知道了模板引擎要干什么了. 简单说你可以用它将模板与数据组装在一起然后产生已经被渲染好的视图描述文本(例如 HTML 片段);这么做的好处不言自明:将利用 Javascript 包裹起来 HTML 与 Javascript 语句直接拼装成目标文本的丑陋实现转化为一种优雅且可复用的方法,你不用再去在乎在哪插入一串文本以及在哪通过控制流影响结果的生成,一切只需稍微改动视图描述就能完成,that’s the life in the city.

2 简单值替换

从上面的角度来看我们的工作就是做了值替换,将特殊标记中的内容

{{ name }}{{ age }}

直接替换为

Mellon10

是的,目前我们确实做了这件事. 我们首先需要一个函数对外进行包装:

var Template = function (tpl, data) {
    // (magic process)
    return result; // target string
}

为了做到替换,我们要识别出替换内容的模式,例如在这个例子中,我们可以用

var pattern = /\{\{([^\}\}]+)?\}\}/g;

来表达. 这个模式即由 {{ 开始,中间不遇到 }},最终以 }} 结尾. 知道了这一点后我们可以进行简单的测试:

'<p>{{song}} by {{band}}</p>'.match(/\{\{([^\}\}]+)?\}\}/g); // ["{{song}}", "{{band}}"]

与我们所想一致.

知道了位置还不够,我们还需要把匹配的内容拿出来做某种我们期望形式的替换,这种替换和文本替换并不完全相同,但是暂且我们只实现文本替换(注意到当字符串与 numberboolean 等简单值进行 + 运算时会被自动转换为字符串,因而我们可以不用考虑类型转换的问题):

var Template = function (tpl, data) {
    var pattern = /\{\{([^\}\}]+)?\}\}/g;
    var body = tpl.replace(pattern, function (m, g) {
        return data[g.trim()];
    });
    return body;
};

所以简单利用对象属性值替换标记已经实现了:

var tpl = '<p>The {{ name }} is opened at {{ year }}.</p>';
Template(tpl, {
    name: 'Eiffel Tower',
    year: 1889
}); // return '<p>The Eiffel Tower is opened at 1889.</p>'

3 修正属性值替换思路

以上的机制虽然可行,但这个世界并不像这样设想的美好,不美好的来源之一是人们的需求. 如果我们的数据深一层次上面的内容就不 work 了:

var tpl = '<p>The {{ name }} is opened at {{ year }}, and it\'s located at {address.road}, {address.city}. </p>';
Template(tpl, {
    name: 'Eiffel Tower',
    year: 1889,
    address: {
        road: '5 Avenue Anatole France',
        city: 'Paris'
    }
}); // expected '<p>The Eiffel Tower is opened at 1889, and it's located at 5 Avenue Anatole France, Paris.</p>', but return '<p>The Eiffel Tower is opened at 1889, and it's located at undefined, undefined. </p>'

因为 object[attr.subattr] 无法自动转换成 object.attr.subattr,对象的属性名都会被认为是字符串,即使包含有 . 也会被认为值字符串的一部分

所以我们每遇到一个 . 就处理成 subattr 从而形成 object[attr][subattr][subsubattr]... 这样的结构吗?这样做当然是可行的,但是成本高昂,而且会沿着这条道路上越走越偏. 事实上 Javascript 编译器本身就是一个可以直接利用的轮子,它已经帮我们做好这一切了.

思路的转换在于我们如何利用 Javascript 本身的表达式规则,而不在此基础上做多余的事情. 因此我们打算把一开始的字符串进行一次转换:

'<p>The {{ name }} is opened at {{ year }}, and it\'s located at {address.road}, {address.city}. </p>'

// (magic process)

'<p>The ' +  data.name  + ' is opened at ' + data.year + ', and it\'s located at ' + data.address.road + ',' + data.address.city + '. </p>'

问题是我们始终在字符串上进行操作,要让上段代码中的表达式得到执行,需要将它们放到一个可以执行的环境.

幸而做这件事是可能的,new Function 可以实例化一个真正的函数,它接收可选的参数加上函数体字符串,并返回这个函数(参见 https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function ),例如:

(new Function('n', 'return n * n;'))(3); // 9

于是我们关注的重点在于中间那串 magic 的实现,转换之后它应该在上述转换后的基础进行包装,类似于这样:

'return "<p>The " +  this.name  + " is opened at " + this.year + ", and it\'s located at " + this.address.road + "," + this.address.city + ". </p>";'

做到这一点就相对容易了:

var Template = function (tpl, data) {
    var pattern = /\{\{([^\}\}]+)?\}\}/g;
    var body = 'return "';
    body += tpl.replace(pattern, function (m, g) {
        return '" + this.' + g.trim() + ' + "';
    });
    body += '";';
    return new Function(body.replace(/[\t\n\r]/g, '')).apply(data);
};

Template(tpl, {
    name: 'Eiffel Tower',
    year: 1889,
    address: {
        road: '5 Avenue Anatole France',
        city: 'Paris'
    }
}); // return '<p>The Eiffel Tower is opened at 1889, and it's located at 5 Avenue Anatole France, Paris. </p>'

4 简单循环语句

我们同样希望这样一个引擎能够支持循环语句,这样我们能实现对返回字符串更有实际意义的控制,具体一点像这样:

var tpl = '<h2>Items</h2>\n'
       +  '<ul lk-for="item in items">\n'
       +      '\t<li>{{ item }}</li>\n'
       +  '</ul>';
var data = {
    items: ['item 1', 'item 2', 'item 3']
};
// magic process
Template(tpl, data); // return '<h2>Items</h2><ul><li>item 1</li><li>item 2</li><li>item 3</li></ul>'

上面的方法对它无能为力了,之前的 pattern 不认识 lk-for;即使认识它也无法理解诸如 item in items 这样的东西,bad time.

仔细分析一下问题,首先,我们需要找到 Loop 的 pattern,它包含有整个作用域(在上面的例子中以 <ul 开头,</ul> 结尾);然而,匹配配对的开闭标签间的内容是一个已知的使用正则表达式无法解决的问题,它需要记录匹配组开始与闭合的次数(而正则表达式只有实现贪婪与懒惰两种匹配策略)

知道这件事后我们不用太难过,使用栈来记录是一个很好的解决策略;以下是一种用于找到匹配某种属性名并返回包含该属性标签分块数组的实现:

var tagPair = function (str, mode) {
    var subSection = [], ts = [], _m, _tag, cursor = 0;
    var re = new RegExp("<([^>]*?) [^>]*lk-"+ mode +"='([^\']*)'[^>]*>");
    var updateTag = function (s) {
        s = s || '';
        var temp = s.match(re);
        var reStart = temp ? temp[1] : '';
        _tag = new RegExp('<' + reStart + '|<\/'+ reStart + '>', 'g');
    };
    var restStr = str;
    updateTag(restStr);
    while (_m = _tag.exec(restStr)) {
        var tagName = _m[0];
        if (/<\/.*/.test(tagName)) ts.pop();
        else ts.push(tagName);
        if (!ts.length) {
            subSection.push(restStr.slice(cursor, cursor = _m.index + tagName.length));
            restStr = restStr.slice(cursor);
            cursor = 0;
            updateTag(restStr);
        }
    }
    subSection.push(restStr);
    return subSection;
};

通过这样一种较为稳妥的方式拿到了分块后,我们就可以安心的将分块交给下一步处理;在每一个分块内部,我们就可以抽象出循环结构的模式:

var loop = /<(\S*?) [^>]*lk-for='([^\']*)'[^>]*>(.*)<\/\1>/g;

这个模式相比较之前的稍微麻烦了一点. 通俗的讲它匹配了第一个标签中包含 lk-for 的标签对的内容,并捕获了随 lk-for后的分组(例如上面的 item in items)与标签间(不包含开闭标签)的分组.

我们的目标是实现循环,像下面这样:

'<ul lk-for="item in items"><li>{{ item }}</li></ul>'

// =>(magic)

'<ul lk-for="item in items"><li>item 1</li><li>item 2</li><li>item 3</li></ul>'

可以想象没有模板引擎你会怎么做:你会人肉在含有 lk-for 的内层嵌套一个 loop 指令,它要遍历的数组即 lk-for 中的 items,你得想办法把它提取出来;在内部含有 item 对应的地方你需要替换成遍历当前值,例如 items[i].

这样做结束了吗?当然没有,以上的方法只能做到求值,我们需要将其保留下来,在这里可以用栈来实现,它出现在每一个需要保留的地方(非指令的部分),我们的做法类似于这样:

'<ul lk-for="item in items"><li>{{ item }}</li></ul>'

// =>(magic), and somehow we have set an empty stack s = []

's.push("<ul lk-for='item in items'>");' +
'for(var i = 0; i < this.items.length; i++) {' +
'    s.push("<li>{{this.items[i]}}</li>");' +
'}' +
's.push("</ul>");'

把上面这一串经过上一节所讲的值替换送到 Function 函数体内部就可以得到执行了,你可能会疑惑其中 lk-for 的内容是否会被当做语句执行. 答案是不会,我们使用间接的方式加入它.

顺带一提,为了避免引号冲突所有标签内的属性值都会被转换成由 ' 包裹.

以下便是上述想法的实现:

...
tpl = tpl.split('\n').map(function (s) {return s.trim();}).join('').replace(/[\t\n\r]/g, '').replace(/"/g, '\'');
...
var loop = /<(\S*?) [^>]*lk-for='([^=]*)'[^>]*>(((?!<\/\1>).)*(<\/\1>)*)<\/\1>/g;
...

var body = 'var s = []; s.push("';

var subs = tagPair(tpl, 'for');
body += subs.map(function (sub) {
    if (!sub) return '';
    return (
    sub.replace(loop, function (m, tag, _c, _sta) {
        var cv = _c.split(' '), e = cv[0], es = cv[2];
        return m.replace(_sta, function () {
            var tempS = _sta.replace(new RegExp('{{ *' + '(' + e + ')([^ ]*)' + ' *}}', 'g'), function (m, _e, _suf) {
                _tempSuf = _suf;
                return '{{' + es + '[i]' + _suf +'}}'
            });
            var tempC = 'this.' + es;
            return '");\nfor(var i = 0; i'+ layer +' < '+ tempC + '.length; i++) {\n\ts.push("' + tempS + '");\n}\ns.push("';
        });
    }));
}).join('');

body += 'return s.join("");';

// handle value
...

5 嵌套循环语句

上面的循环语句实现简单测试一下是有效的;如果你的循环是嵌套结构,you will get bad time again:

var tpl = '<h2>Regions</h2>' +
            '<div lk-for="country in region">' +
                '<h3>country</h3>' +
                '<ul lk-for="distict in country">' +
                    '<li>{{distict.text}}</li>' +
                '</ul>' +
            '</div>';

var data = {
    region: [[
        {text: 'WH'}, {text: 'HK'}
    ], [
        {text: 'NY'}, {text: 'LA'}
    ]]
};

// (process above)

var s = []; s.push("<h2>Regions</h2><div lk-for='country in region'>");
for(var i = 0; i < this.region.length; i++) {
    s.push("<h3>country</h3><ul lk-for='distict in country'><li>{{distict.text}}</li></ul>");
}
s.push("</div>");

上面这一串将会把内层内容的字符串形式原封不动地 push 两次,很显然:

  • 内层没有得到循环
  • 值替换无法理解以中间变量开头的内容 {{distict.text}},进入函数体被执行时会报 Cannot read property 'text' of undefined

问题的根源在于目前无法支持对内层继续解析;要做到这一点我们的想法也很简单:将提取出来的循环体(例如上文中的 _sta )用同样的方式做一次递归,递归以无法提取出循环体为结束,为了做到这一点,我们先改变处理循环的结构,将它封装成函数:

var convertLoop = function (str) {
    var subs = tagPair(str, 'for');
    return subs.map(function (sub) {
        if (!sub) return '';
        return (
        sub.replace(loop, function (m, tag, _c, _sta) {
            var cv = _c.split(' '), e = cv[0], es = cv[2];
            return m.replace(_sta, function () {
                var tempS = _sta.replace(new RegExp('{{ *' + '(' + e + ')([^ ]*)' + ' *}}', 'g'), function (m, _e, _suf) {
                    return '{{' + es + '[i]' + _suf +'}}'
                });
                var tempC = 'this.' + es;
                return '");\nfor(var i = 0; i < '+ tempC + '.length; i++) {\n\ts.push("' + tempS + '");\n}\ns.push("';
            });
        }));
    }).join('');
};

然后提取内层循环:

...
var innerSta = _sta;
if (loop.test(_sta)) {
    innerSta = convertLoop(_sta);
}
...

拿到了内层循环后另一个问题来了:我们还做中间值替换. 循环中间值的替换想法不难,类似于 distict 的中间值需要被替换为全局能理解的 region[i],后缀保持不变

仔细思考又会发现,循环体内的索引值 i 也要改,否则执行时外层的索引值会被内层运行结束后覆盖;这涉及到命名冲突的问题,解决方案也很简单:用一个 layer 来保留循环体的层次,每次进入递归自增,然后用 i + layer 的形式来命名即可

以下是实现:

var convertLoop = function (str, layer) {
    var subs = tagPair(str, 'for');
    return subs.map(function (sub) {
        if (!sub) return '';
        return (
        sub.replace(loop, function (m, tag, _c, _sta) {
            var innerSta = _sta;
            layer++;
            if (loop.test(_sta)) {
                innerSta = convertLoop(_sta, layer);
            }
            var cv = _c.split(' '), e = cv[0], es = cv[2];
            return m.replace(_sta, function () {
                var tempS = innerSta.replace(new RegExp('{{ *' + '(' + e + ')([^ ]*)' + ' *}}', 'g'), function (m, _e, _suf) {
                    _tempSuf = _suf;
                    return '{{' + es + '[i'+ layer +']' + _suf +'}}'
                });
                tempS = tempS.replace(new RegExp('this.('+ e +')(.*).length', 'g'), function (m, _e, _suf) {
                    return 'this.' + es + '[i'+ layer +']'+ _suf +'.length';
                });
                var tempC = 'this.' + es;
                return '");\nfor(var i' + layer +' = 0; i'+ layer +' < '+ tempC + '.length; i'+ layer +'++) {\n\ts.push("' + tempS + '");\n}\ns.push("';
            });
        }));
    }).join('');
};

6 条件语句

条件语句相较于循环简单,首先我们运用类似的方法匹配条件模式. 在 lk-if 模式下,对于不满足条件的(例如给定属性值为 false )什么也不返回,否则返回传入的内容;lk-not 模式与之相反

...
var ifSta = /<(\S*?) [^>]*lk-if='([^\']*)'[^>]*>(.*)<\/\1>/g;
var notSta = /<(\S*?) [^>]*lk-not='([^\']*)'[^>]*>(.*)<\/\1>/g;
...
var convertControl = function (str) {
    var _map = {
        'if': ifSta,
        'not': notSta
    };
    var _op = '';
    var _t = function (sub) {
        return sub.replace(_map[_op], function (m, tag, _c) {
            _c = 'this.' + _c.replace(/\{|\}/g, '');
            if (_op === 'if')
                if ((new Function('return ' + _c)).apply(data) === false) return "";
            if (_op === 'not')
                if ((new Function('return ' + _c)).apply(data) !== false) return "";
            return m;
        });
    };
    str = tagPair(str, _op = 'if').map(_t).join('');
    str = tagPair(str, _op = 'not').map(_t).join('');
    return str;
};

简单测试一下:

var tpl = '<h2>Items</h2>' +
          '<ul lk-if="ifShowBook" lk-for="book in books">' +
                '<li>{{book}}</li>' +
          '</ul>';

var data = {
    books: ['SICP', 'CSAPP'],
    ifShowBook: true
};

Template(tpl, data); // return '<h2>Items</h2><ul lk-if="ifShowBook" lk-for="book in books"><li>SICP</li><li>CSAPP</li></ul>'

// set data.isShowBook = false

Template(tpl, data); // return '<h2>Items</h2>'

7 值的处理

当然在最后使用之前我们还需要做一些微小的工作:

  • 对于 undeined 值,我们希望其被类型转换后被显示出来时为空,这样符合我们的正常预期
  • 对于进行完值替换的字符,我们希望对其进行安全处理,转义掉其中的可能会产生真实 DOM 标签的符号
  • 如果确实希望能在值中插入 DOM (在确保没有安全隐患的条件下),使用 {{{}}} 防止转义

为了做到以上三点,首先我们需要更新 value 的模式,使它匹配不到 {{{}}},然后新设 html 模式用来匹配 {{{}}}

var html = /\{\{\{([^\}\}\}]+)?\}\}\}/g;
var value = /\{\{([^\}\}]+)?\}\}(?=[^}])/g;

然后我们需要两个函数分别对转义与非转义值进行处理:

var handleValue = function(value) {if (value) {return value.replace(/>/g, "&gt;").replace(/</g, "&lt;");} return "";}; var handleHTML = function(value) {if (value) {return value;} return "";};

将其插入到函数体字符串中,最后:

var convertValue = function (str) {
    return str.replace(value, function (m, g) {
        return '" + handleValue(this.' + g.trim() + ') + "';
    });
};
var convertHTML = function (str) {
    return str.replace(html, function (m, g) {
        return '" + handleHTML(this.' + g.trim() + ') + "';
    })
};

Bingo,大功告成~

8 后续

Copyright (c) 2014-2016 Kyles Light.
Powered by Tornado.
鄂 ICP 备 15003296 号