本文完整阅读约需 16 分钟,如时间较长请考虑收藏后慢慢阅读~

提到JavaScript下的setTimeout()setInterval()函数,接触过JavaScript的读者一定非常熟悉:两个函数都接收一个函数和一个延时两个参数,前者用于设置超时,后者用于设置定时执行。但其实这两个函数除了以上两个参数外还有第三个参数。

0x01 提出问题

通常情况下,我们是这样使用setTimeout()setInterval()函数的:

setTimeout(function() { alert("Hello"); }, 3000);
setInterval(function() { console.log("Hello"); }, 3000);

这里的例子是大部分JavaScript程序员会使用的方式,即在第一个参数中使用匿名函数实现调用。

那如果要调用其他函数怎么办呢?我想大部分程序员会使用以下的方式:

function print() { console.log("Hello"); }
setTimeout(function() { print(); }, 3000);
setInterval(function() { print(); }, 3000);

这样的确可以起到调用的效果,但如果是要一边调用函数一边传入参数呢?这个问题可能也难不倒读者们:

function print(text) { console.log(text); }
var text = "Hello";
setTimeout(function() { print(text); }, 3000);
setInterval(function() { print(text); }, 3000);

但这种方法存在诸多问题:

  1. 新建匿名函数后调用,相对于直接调用存在一定的性能损失
  2. 如果匿名函数外的变量/对象在超时时间内变化,则超时执行的函数无法捕获到定义时的值/对象。

对于第二点,我们可以用一个简单的例子来说明:

function print(text) { console.log(text); }
var text = "Hello";
setTimeout(function() { print(text); }, 3000);
setInterval(function() { print(text); }, 3000);
text = "World";

setTimeoutsetInterval执行完成后,text的值就已经从Hello变成了World,3000ms后两个函数被执行,此时外部的text值已经变成了World

0x02 分析问题

如果你在互联网上搜索,可能会搜索到这样的网页:How can I pass a parameter to a setTimeout() callback?。但里面的方法大多都非常复杂,而且写起来很丑陋,举个例子:

function print(text) { console.log(text); }
var text = "Hello";
setTimeout("print('" + text + "')", 3000);
setInterval("print('" + text + "')", 3000);
text = "World";

这里利用的是JavaScript弱类型的特点,第一个参数接收一个函数,但传入的是一个字符串,于是JavaScript引擎会将其用eval()包裹起来并执行。在极丑无比的字符串组合下,调用变成了print('Hello')

更麻烦的的是这一办法不仅丑陋,而且并不实用。举例说:如果我想传入的不是字符串,是对象,那这个办法就彻底没辙了。

此外,在开启了CSP策略的网页中,如果不授权unsafe-eval权限,那么所有的eval()函数都是会被拒绝的,比如GitHub。读者可以查看这篇讨论,或自己在GitHub的网页中尝试运行以上脚本。

那么,真的没有更好的办法了吗?

0x03 解决问题

卖了这么多关子,这里就要引出本文的重点了:其实setTimeoutsetInterval有第三个参数。

这第三个参数在哪里呢?其实只要阅读一下这两个函数中任意一个的文档,就能看到第三个参数(甚至第四个第五个…因为它其实是可变参数)的存在。

这里我摘录一段关键内容:

param1, …, paramN 可选
附加参数,一旦定时器到期,它们会作为参数传递给function

备注:需要注意的是,IE9 及更早的 IE 浏览器不支持向回调函数传递额外参数(第一种语法)。如果你想要在IE中达到同样的功能,你必须使用一种兼容代码 (查看 兼容旧环境(polyfill) 一段).

可以看到,多出来的参数主要目的就是传递给第一个参数。

需要注意的是,这里的参数在定义时状态就已被固定,只是在定时器到期时传递到函数中,因此使用这样的方法可以保持所引用变量的值为定义时的值。

我们再来做一个实验验证一下上面所说的内容:

function print(text) { console.log(text); }
var text = "Hello";
setTimeout(print, 3000, text);
setInterval(print, 3000, text);
text = "World";

可以看到这样可以更加优雅的实现参数传递。

但如果想要传递对象,只是使用这样的方式是不行的。倒不是因为这两个函数的缺陷,而是JavaScript所有的对象传递都默认为地址拷贝:

function print(text) { console.log(text.text); }
var text = {
    text: "Hello"
};
setTimeout(function(text) { print(text); }, 3000, text);
setInterval(function(text) { print(text); }, 3000, text);
text.text = "World";

可以从截图中看到,由于传递的text是地址传递,因此在实际执行的时候,依旧引用的是外部已经变化的text对象。这时候我们要做的是对第三个参数外面套一层拷贝函数。如果只是需要使用对象中的值,那么简单的JSON.parse(JSON.stringify(obj));就足够解决问题:

function deepValueCopy(origialObj) { return JSON.parse(JSON.stringify(origialObj)); }
function print(text) { console.log(text.text); }
var text = {
    text: "Hello"
};
setTimeout(function(text) { print(text); }, 3000, deepValueCopy(text));
setInterval(function(text) { print(text); }, 3000, deepValueCopy(text));
text.text = "World";

如果是需要拷贝对象,那么还需要对这个对象复制函数进行改进,限于篇幅,本文不再赘述。更多关于对象传递和深/浅拷贝的内容可以参考这篇文章:JavaScript 中的对象拷贝

需要注意的是,该方法在IE9及更早的浏览器中不受支持,需要按照文档中的建议进行兼容处理,通常情况插入以下代码在<head>中即可:

<!--[if lte IE 9]><script>
(function(f){
window.setTimeout=f(window.setTimeout);
window.setInterval=f(window.setInterval);
})(function(f){return function(c,t){
var a=[].slice.call(arguments,2);return f(function(){c instanceof Function?c.apply(this,a):eval(c)},t)}
});
</script><![endif]-->

0x04 后记

这就是setTimeoutsetInterval函数『不为人知』的第三个参数。说它不为人知其实有些夸张,毕竟文档里写得非常清楚,甚至还给了示例和兼容方式,但我发现身边绝大部分从事开发工作的朋友都选择性的忽略了这一特点。

因此我决定撰写这篇文章,一方面能分享知识给读者们,另一方面则是警醒自己:基础知识要足够踏实,才能在开发过程中避免走弯路,用更短的时间、更少的代码写出更优雅的程序。希望这篇文章能起到抛砖引玉的作用,启发读者们主动发掘软件开发中更多『不为人知』的细节。