JS 模版引擎的简单实现

话说平时写码,如果不用框架的话,js 中拼接 HTML 代码片段还是很经常性的操作。

于是就有了模板引擎。现在市面上除了专门的模板引擎 handlebars.js mustache 等,lodash, underscore 这些著名的工具集也有支持模板操作。

其实自己撸一个出来,可以学到一些新东西。 其中一个问题就是,如何解决模板中任意代码被正确执行或者被替换为正确的值。

开启我们的旅程

考虑如下的 HTML 片段:

<div id="app">
</div>
<script type="text/template" id="tpl_profile">
    <h3>关于我</h3>
    <div class="profile">
        <h3 class="name">戴嘎豪我是<%=name%></h3>
        <p class="desc">我年方<%=age%>喜欢看<a href="<%=weibo_url%>"><%=weibo_name%></a>。</p>
    </div>
</script>

我们需要将里面的变量替换为真实的数据。

我们将模板内容事先放在了类型为 text/template 的 script 标签中。这个类型可以随便写,因为只要浏览器无法识别,它就以普通的文本形式存在,并且不在页面中展示。然后我们可以通过代码获取到里面的内容。

小试牛刀

此刻脑海中最直接的闪念是将其中<%=xxx%>包裹的变量解析出来,拿到变量名后去数据对象中取值,再替换回去。说干就干。

不到半分钟,我们撸出了如下代码的模板引擎v0.1。

function compile(selector, data) {
    var tplStr = document.querySelector(selector).innerHTML;
    tplStr = tplStr.replace(/<%=(.*?)%>/g, function(match, p1) {
        return data[p1];
    });
    return tplStr;
}

食用:

var data = {
    name:
        '风暴降生丹妮莉丝,不焚者,弥林的女王,安达尔人,洛伊拿人和先民的女王,七国统治者暨全境守护者,大草海的卡丽熙,奴隶解放者,龙之母,跟男下属通奸者,卡奥终结者,寡妇制造者,寡妇村纵火者。',
    age: 28,
    weibo_name: '大飚哥带路',
    weibo_url: 'https://weibo.com/u/6257076674'
};

document.getElementById('app').innerHTML = compile('#tpl_profile', data);

它拿到模板文本后,通过正则 /<%=(.*?)%>/ 去抓取出埋在模板里的变量,通过括号中的内容拿到变量名p1(也就是正则匹配结果中的分组一),然后去传入的数据 data 中取值,再替换回模板中。

于是我们就得到了能够正常显示的文本,最后设置到页面即可。

第一次尝试的成果

第一次尝试的成果

进阶

似乎很轻松就完成了一次模板的解析展示。

但上面的做法有其局限性。那就是对于外部数据的处理不够灵活。我们匹配到变量名后,通过访问属性的试去传入的对象中去取值。如果模板中变量存在多级的情况,就不适用了。

或者,需要解析的变量中,存在表达式,也处理不了。

<script type="text/template" id="tpl_profile2">
    <h3>关于我</h3>
    <div class="profile">
        <h3 class="name">戴嘎豪我是<%=name%></h3>
        <p class="desc">我年方<%=age%>喜欢看<a href="<%=hobby.weibo_url%>"><%=hobby.weibo_name%></a>。</p>
        <p class="desc">博客地址 <a href="<%=repo.author_url%>"><%=repo.author%></a>/<a href="<%=repo.repo_url%>"><%=repo.repo_name%></a></p>
        <p class="desc"><%=repo.author+'/'+repo.repo_name%></p>
    </div>
</script>

所以,对于解析出来的部分,我们需要它能够根据传入的数据正常执行。

便于分析,先考虑下面这个带表达式的简单模板:

<script type="text/template" id="tpl_simple">
    <p><%=foo.bar%></p>
</script>

为了得到满足需求的模板函数,我们不防倒推一下。对于上面的模板,我们需要怎样的函数才能将模板中的变量正确解析成传入的值。

理论上,这样一个函数,只接收数据,有了数据,他就能将模板正确地展现出来了。

function buildHTML(data) {
    return '<p>' + data.foo.bar + '</p>';
}

但如你所见,因为模板中其实不知道有 data 这个参数的存在,data 这个参数在这里只是为了获取外部的传入,叫什么名字都行。假使我们使这个函数在传入数据的上下文中执行,就不用手动加上 data.前缀来访问需要的数据了。

这种场景下,js 中有个关键词就起作用了,那就是 with

function buildHTML(data) {
    with(data){
        return '<p>' + foo.bar + '</p>';
    }
}

这样看来,我们只需要将标识符包裹的部分抽取出来,再与其余 HTML 拼接即可。上面这个函数就能根据传入的数据将模板正确展示。但问题是,里面的模板是变化的,也就是返回的字符串部分,需要动态生态。根据不同的模板,返回不同的字符串拼接。

为了得到上面这样一个函数,我们需要利用 Function 来动态生成函数。 Function 关键字允许我们在代码中动态创建函数。

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

所以我的模板引擎功能便是根据不同的模板生成上面的这样一个函数。

function compile(selector) {
    var tplStr = document.querySelector(selector).innerHTML;
    return new Function(data, 'with(data){return tplStr;');
}

但问题来了,这里的data数据,是在具体的地方调用的时候才传进来的。而我们的 compile 只负责将模板转译并生成一个接收数据的函数。就是说此时并没有 data 的存在。

所以在生成函数时,不能显式指定入参。想了一下,返回的函数体里可以使用 arguments 来代替。到时候,新生成的函数不管被传入什么参数都能正确访问了。

function compile(selector) {
    var tplStr = document.querySelector(selector).innerHTML;
    return new Function('with(arguments[0]){return tplStr;');
}

函数体的实现

有了上面的思路,接下来的重点就是如何构造这个新创建的函数的函数体了,也就是生成拼接后的字符串。

还是看上面最简单的那个模板。

<script type="text/template" id="tpl_simple">
    <p><%=foo.bar%></p>
</script>

将这个模板带入到上面得到 的编译方法中,不难看出,现在的问题成了如何将

var tplStr = "<p><%=foo.bar%></p>";

转换成:

return new Function('with(arguments[0]){return "<p>" + foo.bar + "</p>";');

还是以标识符为分割,模板部分我们用双引号包裹,变量部分我们不包裹,最后拼接起来的这个字符串便是我们需要的函数体。

经过调试不一会儿我们得到了模板引擎的 v0.2版本。

function compile(selector) {
    var delimiter = /<%=(.*?)%>/g;
    var tplStr = document.querySelector(selector).innerHTML;
    tplStr = tplStr
        .replace(/[\r\n\t]/g, '') //去掉换行,否则在 return 时会报错
        .replace(/<%=(.*?)%>/g, (match, p1) => {
            return '"+' + p1 + '+"'; //将非变量部分用字符串包裹
        });
    var fnBody = 'with(arguments[0]){return "' + tplStr + '"}';
    return new Function(fnBody);
}

下面来测试一波。

var data = {
    foo: {
        bar: 'Bingo!',
    }
};
document.getElementById('app').innerHTML = compile('#tpl_simple')(data);

解决了变量多个层级访问的版本

解决了变量多个层级访问的版本

当模板中带简单的表达式的情况:

<script type="text/template" id="tpl_simple_with_exp">
    <p><%= greeting.str1 + greeting.str2%></p>
    <p><%= '现在北京时间:' + new Date()%></p>
</script>
var data = {
    greeting: {
        str1: 'hello,',
        str2: 'world!'
    }
};
document.getElementById('app').innerHTML = compile('#tpl_simple_with_exp')(data);

带简单表达式的版本

带简单表达式的版本

效果还不错,能解析简单的表达式。 当然这里的还没有考虑到更加复杂的情况。比如标签上带 Class 属性时,上面在生成字符串时双引号的匹配就会出问题了。所以对于模板中原有的引号我们需要先转义。

考虑下面改进后的模板:

<script type="text/template" id="tpl_simple_with_exp_cls">
    <p class="title" data-blah='blah'><%=greeting.str1 + greeting.str2%></p>
    <p><%='现在北京时间:'+new Date()%></p>
</script>

这个模板里面 data- 属性故意用单引号来书写,虽然不是标准的写法但也不是错误的写法。这个模板在使用 jQuery 作者 John Resig 编写的简易引擎解析时就会报错。

考虑到转义后我们改进一下之前的代码得到 v0.3版本。

function compile(selector) {
    var delimiter = /<%=(.*?)%>/g;
    var tplStr = document.querySelector(selector).innerHTML;
    tplStr = tplStr
        .replace(/[\r\n\t]/g, '') //去掉换行,否则在 return 时会报错
        .replace(/([\"\'])/g, '\\$1') //将模板中的引号转义以防止在后面拼接字符串时出错
        .replace(/<%=(.*?)%>/g, (match, p1) => {
            return '"+' + p1 + '+"'; //将非变量部分用字符串包裹
        });
    var fnBody = 'with(arguments[0]){return "' + tplStr + '"}';
    return new Function(fnBody);
}

这个版本运行后 HTML 标签上的引号没问题,但表达式中的此号有报错,原因是我们转义时将整个模板里的引号都进行了转义,所以最终得到的字符串中,变量部分的引号是有问题的。

变量中引号的问题

变量中引号的问题

所以在解析到模板中的变量后,需要将变量中的引号恢复回来。也就是下面的0.4版本。

function compile(selector) {
    var delimiter = /<%=(.*?)%>/g;
    var tplStr = document.querySelector(selector).innerHTML;
    tplStr = tplStr
        .replace(/[\r\n\t]/g, '') //去掉换行,否则在 return 时会报错
        .replace(/([\"\'])/g, '\\$1') //将模板中的引号转义以防止在后面拼接字符串时出错
        .replace(/<%=(.*?)%>/g, (match, p1) => {
            return (
                '"+' + //将非变量部分用字符串包裹
                p1.replace(/([\\"\\'])/g, escapedItem => {
                    return escapedItem.replace('\\', '');//将变量中被转义的引号恢复
                }) +
                '+"'
            );
        });
    var fnBody = 'with(arguments[0]){return "' + tplStr + '"}';
    return new Function(fnBody);
}

解决了引号问题的版本

解决了引号问题的版本

到此,我们的模板引擎已经可以支持任意变量值灵活地获取,引号的正确处理及简单的表达式运算。

现在再来展示上面改进后的那个「自我介绍」的模板,就毫无压力了。

var data = {
    name:
        '风暴降生丹妮莉丝,不焚者,弥林的女王,安达尔人,洛伊拿人和先民的女王,七国统治者暨全境守护者,大草海的卡丽熙,奴隶解放者,龙之母,跟男下属通奸者,卡奥终结者,寡妇制造者,寡妇村纵火者。',
    age: 28,
    repo: {
        author: 'wayou',
        author_url: 'https://github.com/wayou',
        repo_name: 'wayou.github.io',
        repo_url: 'https://github.com/wayou/wayou.github.io'
    },
    hobby: {
        weibo_name: '大飚哥带路',
        weibo_url: 'https://weibo.com/u/6257076674'
    }
};

document.getElementById('app').innerHTML = compile('#tpl_profile2')(data);

增加的自我介绍

增加的自我介绍

加上逻辑执行的能力

考虑下面的模板:

<script type="text/template" id="tpl_books">
    <h3>我的书单</h3>
    <% for(var i=0;i< books.length;i++){ %>
        <div class="book">
            <p class="title"><%=books[i].title%><span class="price">$<%=books[i].price%></span></p>
            <p class="desc"><%=books[i].desc%></p>
        </div>
    <% } %>
</script>

前端实现的模板引擎只是支持了简单的表达式,而像模板中经常使用的循环则执行不了,因为按照现在的实现,for 语句只会简单地被拼接到字符串中,这不是一个合法的形式。

'<h3>我的书单</h3>'+ for(var i=0;i< books.length;i++){ + '<div class="book">...'

从功能上看,这种属于代码逻辑上的表达式,这一部分是不会展示在页面的,而 <%='现在北京时间:'+new Date()%> 这种表达式的结果是需要展示出来的。所以我们可以将这两种表达式区分开来,需要展示的用 <%=%> 来表示而代码逻辑像 iffor 还有 switch 等用 <%%> 表示,这样在处理时区别对待。

还是采用倒推的形式,如果说加了逻辑控制的代码,我们期望得到的函数体变成了:

function buildHTML(data) {
    with (arguments[0]) {
        var result = '';
        result += '<h3>我的书单</h3>';
        for (var i = 0; i < books.length; i++) {
            result += '...';
        }
        result += '...';
        return result;
    }
}

于是根据上面的目标函数我们撸出了以下的v0.5版本。

function compile(selector) {
    var delimiter = /(<%.*?%>)/g, //通用标识符
        displayDelimiter = /<%=(.*?)%>/g, //显示表达式标识符
        expDelimiter = /<%(.*?)%>/g; //逻辑表达式标识符
    var tplStr = document.querySelector(selector).innerHTML;
    var fnBody = 'with(arguments[0]){var result="";';

    tplStr
        .replace(/[\r\n\t]/g, '') //去掉换行,否则在 return 时会报错
        .replace(/([\"\'])/g, '\\$1') //将模板中的引号转义以防止在后面拼接字符串时出错
        .split(delimiter)
        .map(fragment => {
            if (displayDelimiter.test(fragment)) {
                fragment = fragment.replace(/\\([\"\'])/g, '$1'); //将变量中被转义的引号恢复
                fnBody += 'result+=' + fragment.replace(displayDelimiter, '$1') + ';';
            } else if (expDelimiter.test(fragment)) {
                fragment = fragment.replace(/\\([\"\'])/g, '$1');
                fnBody += fragment.replace(expDelimiter, '$1');
            } else {
                fnBody += 'result+="' + fragment + '";';
            }
        });
    fnBody += 'return result;}';
    
    return new Function(fnBody);
}

让我们测一波:

var data = {
    books: [
        {
            isbn: '1988',
            title: '《程序员的自我修养》',
            price: -1,
            desc: '程序员有自我修养我给你钱'
        },
        {
            isbn: '2045',
            title: '《程序员有没有自我修养》',
            price: 0,
            desc: '世纪之问,历史的拷问,扪心自问'
        },
        {
            isbn: '1984',
            title: '《程序员其实不在乎自我修养》',
            price: 998,
            desc: "Don't bb, show me the code"
        }
    ]
};

document.getElementById('app').innerHTML = compile('#tpl_books')(data);

支持逻辑表达式的版本

支持逻辑表达式的版本

Show me the code!

本文中代码可以这里得到

相关资源