Better

Ethan的博客,欢迎访问交流

动手实现模版引擎

最近突然感悟,前后端的发展,其实有着类似的轨迹,在 Web 初期,由服务器构建 HTML 页面然后发送给浏览器,由最初的静态 HTML,发展到CGI(觉得拼接HTML麻烦),再发展到后台的模版引擎(JSP,PHP等),AJAX 技术出现以后,前后端更多的是通过接口进行交互,拼接 HTML 的任务就交给了前端,在 jQuery 时代,也是通过拼接 HTML 操作 DOM,前端工程师不满,为什么这么苦逼的工作交给我,开始思考就有了前端的模版引擎,后来发展到如今的 MVVM 框架,view-model 彻底将前端工程师从 HTML 中解脱出来!这里我们解开模版引擎的神秘面纱!

基础储备

反斜杠的作用

  • 使用反斜杠用来在文本字符串中插入省略号、换行符、引号和其他特殊字符
  • 这种由反斜杠后接字母或数字组合构成的字符组合就叫做“转义序列”。
  • 值得注意的是,转义序列会被视为单个字符。
  • 我们常见的转义序列还有 \n 表示换行、\t 表示制表符、\r 表示回车等等。

转义序列

  • 在 JavaScript 中,字符串值是一个由零或多个 Unicode 字符(字母、数字和其他字符)组成的序列。
  • 字符串中的每个字符均可由一个转义序列表示。比如字母 a,也可以用转义序列 \u0061 表示。
  • 转义序列以反斜杠 \ 开头,它的作用是告知 JavaScript 解释器下一个字符是特殊字符。
  • 转义序列的语法为 \uhhhh,其中 hhhh 是四位十六进制数。

根据这个规则,我们可以算出常见字符的转义序列,以字母 m 为例:

// 1. 求出字符 `m` 对应的 unicode 值
var unicode = 'm'.charCodeAt(0) // 109
// 2. 转成十六进制
var result = unicode.toString(16); // "6d"

值得注意的是: \n 虽然也是一种转义序列,但是也可以使用上面的方式:

var unicode = '\n'.charCodeAt(0) // 10
var result = unicode.toString(16); // "a"

在 ES5 中,有四个字符被认为是行终结符,其他的折行字符都会被视为空白。

  • \u000A 换行符
  • \u000D 回车符
  • \u2028 行分隔符
  • \u2029 段落分隔符

在 Function 构造函数的实现中,首先会将函数体代码字符串进行一次 ToString 操作,然后再检测代码字符串是否符合代码规范,在 JavaScript 中,字符串表达式中是不允许换行的。因为在模板引擎的实现中,就是使用了 Function 构造函数,如果我们在模板字符串中使用了行终结符,便有可能会出现一样的错误,所以我们必须要对这四种行终结符进行特殊的处理。

除了这四种行终结符之外,我们还要对两个字符进行处理。

  • 一个是 \。其实我们是想打印 '1\23',但是因为把 \ 当成了特殊字符的标记进行处理,所以最终打印了 1。
  • 第二个是 '。如果我们在模板引擎中使用了 ',因为我们会拼接诸如 p.push(' ') 等字符串,因为 ' 的原因,字符串会被错误拼接,也会导致错误。
  • 所以总共我们需要对六种字符进行特殊处理,处理的方式,就是正则匹配出这些特殊字符,然后比如将 \n 替换成 \n,\ 替换成 \,' 替换成 \',处理的代码为:

所以总共我们需要对六种字符进行特殊处理,处理的方式,就是正则匹配出这些特殊字符,然后比如将 \n 替换成 \n,\ 替换成 \,' 替换成 \',处理的代码为:

var escapes = {
    "'": "'",
    '\\': '\\',
    '\r': 'r',
    '\n': 'n',
    '\u2028': 'u2028',
    '\u2029': 'u2029'
};

var escapeRegExp = /\\|'|\r|\n|\u2028|\u2029/g;

var escapeChar = function(match) {
    return '\\' + escapes[match];
};

replace 函数

在模版引擎中,replace 函数是关键,在这里深入了解一下

语法

str.replace(regexp|substr, newSubStr|function)

replace 的第一个参数,可以传一个字符串,也可以传一个正则表达式。

第二个参数,可以传一个新字符串,也可以传一个函数。

我们重点看下传入函数的情况,直接来一个复杂的例子:

function replacer(match, p1, p2, p3, offset, string) {
    // match,表示匹配的子串 abc12345#$*%
    // p1,第 1 个括号匹配的字符串 abc
    // p2,第 2 个括号匹配的字符串 12345
    // p3,第 3 个括号匹配的字符串 #$*%
    // offset,匹配到的子字符串在原字符串中的偏移量 0
    // string,被匹配的原字符串 abc12345#$*%
    return [p1, p2, p3].join(' - ');
}
var newString = 'abc12345#$*%'.replace(/([^\d]*)(\d*)([^\w]*)/, replacer); // abc - 12345 - #$*%

如果第一个参数是正则表达式,并且其为全局匹配模式, 那么这个方法将被多次调用,每次匹配都会被调用。

正则

这里只简单聊聊,更多请看这里:非常不错的正则基础知识和JS正则的使用

正则表达式创建

  1. var reg = /ab+c/i;
  2. new RegExp('ab+c', 'i');

每个正则表达式对象都有一个 source 属性,返回当前正则表达式对象的模式文本的字符串:

var regex = /fooBar/ig;
console.log(regex.source); // "fooBar",不包含 /.../ 和 "ig"。

基本规则

  • \d 就表示了匹配一个数字,等价于 [0-9]。
  • \w 就表示了匹配一个字母或数字
  • + 号代表前面的字符必须至少出现一次(1次或多次)。
  • * 号代表字符可以不出现,也可以出现一次或者多次(0次、或1次、或多次)。
  • ? 问号代表前面的字符最多只可以出现一次(0次、或1次)。

看几个例子

/^[0-9]+abc$/
# ^ 为匹配输入字符串的开始位置。
# [0-9]+匹配多个数字, [0-9] 匹配单个数字,+ 匹配一个或者多个。
# abc$匹配字母 abc 并以 abc 结尾,$ 为匹配输入字符串的结束位置。

/<%=(.+?)%>/g 和 /<%=([\s\S]+?)%>/g 区别
# \s 表示匹配一个空白符,包括空格、制表符、换页符、换行符和其他 Unicode 空格,\S匹配一个非空白符,[\s\S]就表示匹配所有的内容,可是为什么我们不直接使用 . 呢?
# .匹配除行终结符之外的任何单个字符

接下来了解一下惰性匹配

我们知道 x+ 表示匹配 x 1 次或多次。x? 表示匹配 x 0 次或 1 次,但是 +? 是个什么鬼?

实际上如果在数量词 *、+、? 或 {}, 任意一个后面紧跟该符号(?),会使数量词变为非贪婪( non-greedy) ,即匹配次数最小化。反之,默认情况下,是贪婪的(greedy),即匹配次数最大化。

console.log("aaabc".replace(/a+/g, "d")); // dbc

console.log("aaabc".replace(/a+?/g, "d")); // dddbc

模版引擎实现

直接上代码展示 underscore 的实现

(function (target) {
    // underscore实现
    var settings = {
        // 求值
        evaluate: /<%([\s\S]+?)%>/g,
        // 插入
        interpolate: /<%=([\s\S]+?)%>/g,
    };

    var escapes = {
        "'": "'",
        '\\': '\\',
        '\r': 'r',
        '\n': 'n',
        '\u2028': 'u2028',
        '\u2029': 'u2029'
    };

    var escapeRegExp = /\\|'|\r|\n|\u2028|\u2029/g;

    target.template = function (templateid) {
        var text = document.getElementById(templateid).innerHTML;

        // 加上头
        var source = "var __p='';\n";
        source = source + "with(obj){\n"
        source = source + "__p+='";

        var main = text
            // 处理6个特殊字符
            .replace(escapeRegExp, function (match) {
                return '\\' + escapes[match];
            })
            // 处理<%=xxx%>标签
            .replace(settings.interpolate, function (match, interpolate) {
                // return "'+\n" + interpolate + "+\n'"
                // 处理interpolate值为空的情况
                return "'+\n((__t=(" + interpolate + "))==null?'':__t)+\n'"
            })
            // 处理<%xxx%>标签
            .replace(settings.evaluate, function (match, evaluate) {
                return "';\n " + evaluate + "\n__p+='"
            })

        // 加上尾
        source = source + main + "';\n }; \n return __p;";

        console.log(source)

        var render = new Function('obj', source);

        return render;
    };

    /**
     * 分段处理
     */
    target.template2 = function (text) {
        // 为什么还要加个 |$ 呢?我们之所以匹配 $,是为了获取最后一个字符串的位置,这样当我们 text.slice(index, offset)的时候,就可以截取到最后一个字符。
        var matcher = RegExp([
            (settings.interpolate).source,
            (settings.evaluate).source
        ].join('|') + '|$', 'g');

        var index = 0;
        var source = "__p+='";

        text.replace(matcher, function (match, interpolate, evaluate, offset) {
            source += text.slice(index, offset).replace(escapeRegExp, function (match) {
                return '\\' + escapes[match];
            });

            index = offset + match.length;

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

            return match;
        });

        source += "';\n";

        source = 'with(obj||{}){\n' + source + '}\n'

        source = "var __t, __p='';" +
            source + 'return __p;\n';

        var render = new Function('obj', source);

        return render;
    };
})(Engine)

我们需要了解一下 Function 构造函数的用法

var adder = new Function("a", "b", "return a + b");
adder(2, 6); // 8

代码其实并不难,只是比较繁琐,反复正则替换达到自己的目的,这里有一个知识点就是使用 js 的 with 语句来达到与外部数据解耦的功能。

with(person) {
 console.log('my name is ' + name + ', age is ' + age + '.')
}

其他



留言