Javascript.info-函数进阶
在 JavaScript 中,很多内建函数都支持传入任意数量的参数。
在 JavaScript 中,很多内建函数都支持传入任意数量的参数。
例如:
Math.max(arg1, arg2, ..., argN)—— 返回参数中的最大值。Object.assign(dest, src1, ..., srcN)—— 依次将属性从src1..N复制到dest。- ……等。
在本章中,我们将学习如何编写支持传入任意数量参数的函数,以及如何将数组作为参数传递给这类函数。
Rest 参数 ...
在 JavaScript 中,无论函数是如何定义的,你都可以在调用它时传入任意数量的参数。
例如:
function sum(a, b) {
return a + b;
}
alert( sum(1, 2, 3, 4, 5) );虽然这里这个函数不会因为传入过多的参数而报错。但是,当然,只有前两个参数被求和了。
我们可以在函数定义中声明一个数组来收集参数。语法是这样的:...变量名,这将会声明一个数组并指定其名称,其中存有剩余的参数。这三个点的语义就是“收集剩余的参数并存进指定数组中”。
例如,我们需要把所有的参数都放到数组 args 中:
function sumAll(...args) { // 数组名为 args
let sum = 0;
for (let arg of args) sum += arg;
return sum;
}
alert( sumAll(1) ); // 1
alert( sumAll(1, 2) ); // 3
alert( sumAll(1, 2, 3) ); // 6我们也可以选择将第一个参数获取为变量,并将剩余的参数收集起来。
下面的例子把前两个参数获取为变量,并把剩余的参数收集到 titles 数组中:
function showName(firstName, lastName, ...titles) {
alert( firstName + ' ' + lastName ); // Julius Caesar
// 剩余的参数被放入 titles 数组中
// i.e. titles = ["Consul", "Imperator"]
alert( titles[0] ); // Consul
alert( titles[1] ); // Imperator
alert( titles.length ); // 2
}
showName("Julius", "Caesar", "Consul", "Imperator");Rest 参数必须放到参数列表的末尾
Rest 参数会收集剩余的所有参数,因此下面这种用法没有意义,并且会导致错误:
function f(arg1, ...rest, arg2) { // arg2 在 ...rest 后面?!
// error
}...rest 必须写在参数列表最后。
“arguments” 变量
有一个名为 arguments 的特殊类数组对象可以在函数中被访问,该对象以参数在参数列表中的索引作为键,存储所有参数。
例如:
function showName() {
alert( arguments.length );
alert( arguments[0] );
alert( arguments[1] );
// 它是可遍历的
// for(let arg of arguments) alert(arg);
}
// 依次显示:2,Julius,Caesar
showName("Julius", "Caesar");
// 依次显示:1,Ilya,undefined(没有第二个参数)
showName("Ilya");在过去,JavaScript 中不支持 rest 参数语法,而使用 arguments 是获取函数所有参数的唯一方法。现在它仍然有效,我们可以在一些老代码里找到它。
但缺点是,尽管 arguments 是一个类数组,也是可迭代对象,但它终究不是数组。它不支持数组方法,因此我们不能调用 arguments.map(...) 等方法。
此外,它始终包含所有参数,我们不能像使用 rest 参数那样只截取参数的一部分。
因此,当我们需要这些功能时,最好使用 rest 参数。
箭头函数没有 "arguments"
如果我们在箭头函数中访问 arguments,访问到的 arguments 并不属于箭头函数,而是属于箭头函数外部的“普通”函数。
举个例子:
function f() {
let showArg = () => alert(arguments[0]);
showArg();
}
f(1); // 1我们已经知道,箭头函数没有自身的 this。现在我们知道了它们也没有特殊的 arguments 对象。
Spread 语法
我们刚刚看到了如何从参数列表中获取数组。
有时候我们也需要做与之相反的事。
例如,内建函数 Math.max 会返回参数中最大的值:
alert( Math.max(3, 5, 1) ); // 5如果我们有一个数组 [3, 5, 1],我们该如何用它调用 Math.max 呢?
直接“原样”传入这个数组是不会奏效的,因为 Math.max 期望的是列表形式的数值型参数,而不是一个数组:
let arr = [3, 5, 1];
alert( Math.max(arr) ); // NaN毫无疑问,我们不能手动地去一一设置参数 Math.max(arg[0], arg[1], arg[2]),因为我们不确定这儿有多少个。在代码执行时,参数数组中可能有很多个元素,也可能一个都没有。而且,这样的代码也很不优雅。
Spread 语法 可以解决这个问题!它看起来和 rest 参数很像,也使用 ...,但是二者的用途完全相反。
当在函数调用中使用 ...arr 时,它会把可迭代对象 arr “展开”到参数列表中。
以 Math.max 为例:
let arr = [3, 5, 1];
alert( Math.max(...arr) ); // 5(spread 语法把数组转换为参数列表)我们还可以通过这种方式传入多个可迭代对象:
let arr1 = [1, -2, 3, 4];
let arr2 = [8, 3, -8, 1];
alert( Math.max(...arr1, ...arr2) ); // 8我们甚至还可以将 spread 语法与常规值结合使用:
let arr1 = [1, -2, 3, 4];
let arr2 = [8, 3, -8, 1];
alert( Math.max(1, ...arr1, 2, ...arr2, 25) ); // 25并且,我们还可以使用 spread 语法来合并数组:
let arr = [3, 5, 1];
let arr2 = [8, 9, 15];
let merged = [0, ...arr, 2, ...arr2];
alert(merged); // 0,3,5,1,2,8,9,15(0,然后是 arr,然后是 2,然后是 arr2)在上面的示例中,我们使用数组展示了 spread 语法,其实我们可以用 spread 语法这样操作任何可迭代对象。
例如,在这儿我们使用 spread 语法将字符串转换为字符数组:
let str = "Hello";
alert( [...str] ); // H,e,l,l,oSpread 语法内部使用了迭代器来收集元素,与 for..of 的方式相同。
因此,对于一个字符串,for..of 会逐个返回该字符串中的字符,...str 也同理会得到 "H","e","l","l","o" 这样的结果。随后,字符列表被传递给数组初始化器 [...str]。
对于这个特定任务,我们还可以使用 Array.from 来实现,因为该方法会将一个可迭代对象(如字符串)转换为数组:
let str = "Hello";
// Array.from 将可迭代对象转换为数组
alert( Array.from(str) ); // H,e,l,l,o运行结果与 [...str] 相同。
不过 Array.from(obj) 和 [...obj] 存在一个细微的差别:
Array.from适用于类数组对象也适用于可迭代对象。- Spread 语法只适用于可迭代对象。
因此,对于将一些“东西”转换为数组的任务,Array.from 往往更通用。
复制 array/object
还记得我们 之前讲过的 Object.assign() 吗?
使用 spread 语法也可以做同样的事情(译注:也就是进行浅拷贝)。
let arr = [1, 2, 3];
let arrCopy = [...arr]; // 将数组 spread 到参数列表中
// 然后将结果放到一个新数组
// 两个数组中的内容相同吗?
alert(JSON.stringify(arr) === JSON.stringify(arrCopy)); // true
// 两个数组相等吗?
alert(arr === arrCopy); // false(它们的引用是不同的)
// 修改我们初始的数组不会修改副本:
arr.push(4);
alert(arr); // 1, 2, 3, 4
alert(arrCopy); // 1, 2, 3并且,也可以通过相同的方式来复制一个对象:
let obj = { a: 1, b: 2, c: 3 };
let objCopy = { ...obj }; // 将对象 spread 到参数列表中
// 然后将结果返回到一个新对象
// 两个对象中的内容相同吗?
alert(JSON.stringify(obj) === JSON.stringify(objCopy)); // true
// 两个对象相等吗?
alert(obj === objCopy); // false (not same reference)
// 修改我们初始的对象不会修改副本:
obj.d = 4;
alert(JSON.stringify(obj)); // {"a":1,"b":2,"c":3,"d":4}
alert(JSON.stringify(objCopy)); // {"a":1,"b":2,"c":3}这种方式比使用 let arrCopy = Object.assign([], arr) 复制数组,或使用 let objCopy = Object.assign({}, obj) 复制对象来说更为简便。因此,只要情况允许,我们倾向于使用它。
函数对象,NFE
我们已经知道,在 JavaScript 中,函数也是一个值。
而 JavaScript 中的每个值都有一种类型,那么函数是什么类型呢?
在 JavaScript 中,函数的类型是对象。一个容易理解的方式是把函数想象成可被调用的“行为对象(action object)”。我们不仅可以调用它们,还能把它们当作对象来处理:增/删属性,按引用传递等。
属性 “name”
函数对象包含一些便于使用的属性。
比如,一个函数的名字可以通过属性 “name” 来访问:
function sayHi() {
alert("Hi");
}
alert(sayHi.name); // sayHi更有趣的是,名称赋值的逻辑很智能。即使函数被创建时没有名字,名称赋值的逻辑也能给它赋予一个正确的名字,然后进行赋值:
let sayHi = function() {
alert("Hi");
};
alert(sayHi.name); // sayHi(有名字!)当以默认值的方式完成了赋值时,它也有效:
function f(sayHi = function() {}) {
alert(sayHi.name); // sayHi(生效了!)
}
f();规范中把这种特性叫做「上下文命名」。如果函数自己没有提供,那么在赋值中,会根据上下文来推测一个。
对象方法也有名字:
let user = {
sayHi() {
// ...
},
sayBye: function() {
// ...
}
}
alert(user.sayHi.name); // sayHi
alert(user.sayBye.name); // sayBye这没有什么神奇的。有时会出现无法推测名字的情况。此时,属性 name 会是空,像这样:
// 函数是在数组中创建的
let arr = [function() {}];
alert( arr[0].name ); // <空字符串>
// 引擎无法设置正确的名字,所以没有值而实际上,大多数函数都是有名字的。
属性 “length”
还有另一个内建属性 “length”,它返回函数入参的个数,比如:
function f1(a) {}
function f2(a, b) {}
function many(a, b, ...more) {}
alert(f1.length); // 1
alert(f2.length); // 2
alert(many.length); // 2可以看到,rest 参数不参与计数。
属性 length 有时在操作其它函数的函数中用于做 内省/运行时检查(introspection)。
比如,下面的代码中函数 ask 接受一个询问答案的参数 question 和可能包含任意数量 handler 的参数 ...handlers。
当用户提供了自己的答案后,函数会调用那些 handlers。我们可以传入两种 handlers:
- 一种是无参函数,它仅在用户给出肯定回答时被调用。
- 一种是有参函数,它在两种情况都会被调用,并且返回一个答案。
为了正确地调用 handler,我们需要检查 handler.length 属性。
我们的想法是,我们用一个简单的无参数的 handler 语法来处理积极的回答(最常见的变体),但也要能够提供通用的 handler:
function ask(question, ...handlers) {
let isYes = confirm(question);
for(let handler of handlers) {
if (handler.length == 0) {
if (isYes) handler();
} else {
handler(isYes);
}
}
}
// 对于肯定的回答,两个 handler 都会被调用
// 对于否定的回答,只有第二个 handler 被调用
ask("Question?", () => alert('You said yes'), result => alert(result));这就是所谓的「多态性」 的一个例子 —— 根据参数的类型,或者根据在我们的具体情景下的 length 来做不同的处理。这种思想在 JavaScript 的库里有应用。
自定义属性
我们也可以添加我们自己的属性。
这里我们添加了 counter 属性,用来跟踪总的调用次数:
function sayHi() {
alert("Hi");
// 计算调用次数
sayHi.counter++;
}
sayHi.counter = 0; // 初始值
sayHi(); // Hi
sayHi(); // Hi
alert( `Called ${sayHi.counter} times` ); // Called 2 times属性不是变量
被赋值给函数的属性,比如 sayHi.counter = 0,不会 在函数内定义一个局部变量 counter。换句话说,属性 counter 和变量 let counter 是毫不相关的两个东西。
我们可以把函数当作对象,在它里面存储属性,但是这对它的执行没有任何影响。变量不是函数属性,反之亦然。它们之间是平行的。
函数属性有时会用来替代闭包。例如,我们可以使用函数属性将 变量作用域,闭包 章节中 counter 函数的例子进行重写:
function makeCounter() {
// 不需要这个了
// let count = 0
function counter() {
return counter.count++;
};
counter.count = 0;
return counter;
}
let counter = makeCounter();
alert( counter() ); // 0
alert( counter() ); // 1现在 count 被直接存储在函数里,而不是它外部的词法环境。
那么它和闭包谁好谁赖?
两者最大的不同就是如果 count 的值位于外层(函数)变量中,那么外部的代码无法访问到它,只有嵌套的那些函数可以修改它。而如果它是绑定到函数的,那么就可以这样:
function makeCounter() {
function counter() {
return counter.count++;
};
counter.count = 0;
return counter;
}
let counter = makeCounter();
counter.count = 10;
alert( counter() ); // 10所以,选择哪种实现方式取决于我们的需求是什么。
命名函数表达式
命名函数表达式(NFE,Named Function Expression),指带有名字的函数表达式的术语。
例如,让我们写一个普通的函数表达式:
let sayHi = function(who) {
alert(`Hello, ${who}`);
};然后给它加一个名字:
let sayHi = function func(who) {
alert(`Hello, ${who}`);
};我们这里得到了什么吗?为它添加一个 "func" 名字的目的是什么?
首先请注意,它仍然是一个函数表达式。在 function 后面加一个名字 "func" 没有使它成为一个函数声明,因为它仍然是作为赋值表达式中的一部分被创建的。
添加这个名字当然也没有打破任何东西。
函数依然可以通过 sayHi() 来调用:
let sayHi = function func(who) {
alert(`Hello, ${who}`);
};
sayHi("John"); // Hello, John关于名字 func 有两个特殊的地方,这就是添加它的原因:
- 它允许函数在内部引用自己。
- 它在函数外是不可见的。
例如,下面的函数 sayHi 会在没有入参 who 时,以 "Guest" 为入参调用自己:
let sayHi = function func(who) {
if (who) {
alert(`Hello, ${who}`);
} else {
func("Guest"); // 使用 func 再次调用函数自身
}
};
sayHi(); // Hello, Guest
// 但这不工作:
func(); // Error, func is not defined(在函数外不可见)我们为什么使用 func 呢?为什么不直接使用 sayHi 进行嵌套调用?
当然,在大多数情况下我们可以这样做:
let sayHi = function(who) {
if (who) {
alert(`Hello, ${who}`);
} else {
sayHi("Guest");
}
};上面这段代码的问题在于 sayHi 的值可能会被函数外部的代码改变。如果该函数被赋值给另外一个变量(译注:也就是原变量被修改),那么函数就会开始报错:
let sayHi = function(who) {
if (who) {
alert(`Hello, ${who}`);
} else {
sayHi("Guest"); // Error: sayHi is not a function
}
};
let welcome = sayHi;
sayHi = null;
welcome(); // Error,嵌套调用 sayHi 不再有效!发生这种情况是因为该函数从它的外部词法环境获取 sayHi。没有局部的 sayHi 了,所以使用外部变量。而当调用时,外部的 sayHi 是 null。
我们给函数表达式添加的可选的名字,正是用来解决这类问题的。
让我们使用它来修复我们的代码:
let sayHi = function func(who) {
if (who) {
alert(`Hello, ${who}`);
} else {
func("Guest"); // 现在一切正常
}
};
let welcome = sayHi;
sayHi = null;
welcome(); // Hello, Guest(嵌套调用有效)现在它可以正常运行了,因为名字 func 是函数局部域的。它不是从外部获取的(而且它对外部也是不可见的)。规范确保它只会引用当前函数。
“new Function” 语法
还有一种创建函数的方法。它很少被使用,但有些时候只能选择它。
语法
创建函数的语法:
let func = new Function ([arg1, arg2, ...argN], functionBody);该函数是通过使用参数 arg1...argN 和给定的 functionBody 创建的。
下面这个例子可以帮助你理解创建语法。这是一个带有两个参数的函数:
let sum = new Function('a', 'b', 'return a + b');
alert( sum(1, 2) ); // 3这里有一个没有参数的函数,只有函数体:
let sayHi = new Function('alert("Hello")');
sayHi(); // Hello与我们已知的其他方法相比,这种方法最大的不同在于,它实际上是通过运行时通过参数传递过来的字符串创建的。
以前的所有声明方法都需要我们 —— 程序员,在脚本中编写函数的代码。
但是 new Function 允许我们将任意字符串变为函数。例如,我们可以从服务器接收一个新的函数并执行它:
let str = ... 动态地接收来自服务器的代码 ...
let func = new Function(str);
func();使用 new Function 创建函数的应用场景非常特殊,比如在复杂的 Web 应用程序中,我们需要从服务器获取代码或者动态地从模板编译函数时才会使用。
闭包
通常,闭包是指使用一个特殊的属性 [[Environment]] 来记录函数自身的创建时的环境的函数。它具体指向了函数创建时的词法环境。
但是如果我们使用 new Function 创建一个函数,那么该函数的 [[Environment]] 并不指向当前的词法环境,而是指向全局环境。
因此,此类函数无法访问外部(outer)变量,只能访问全局变量。
function getFunc() {
let value = "test";
let func = new Function('alert(value)');
return func;
}
getFunc()(); // error: value is not defined将其与常规行为进行比较:
function getFunc() {
let value = "test";
let func = function() { alert(value); };
return func;
}
getFunc()(); // "test",从 getFunc 的词法环境中获取的new Function 的这种特性看起来有点奇怪,不过在实际中却非常实用。
想象一下我们必须通过一个字符串来创建一个函数。在编写脚本时我们不会知道该函数的代码(这也就是为什么我们不用常规方法创建函数),但在执行过程中会知道了。我们可能会从服务器或其他来源获取它。
我们的新函数需要和主脚本进行交互。
调度:setTimeout 和 setInterval
有时我们并不想立即执行一个函数,而是等待特定一段时间之后再执行。这就是所谓的“计划调用(scheduling a call)”。
目前有两种方式可以实现:
setTimeout允许我们将函数推迟到一段时间间隔之后再执行。setInterval允许我们重复运行一个函数,从一段时间间隔之后开始运行,之后以该时间间隔连续重复运行该函数。
这两个方法并不在 JavaScript 的规范中。但是大多数运行环境都有内建的调度程序,并且提供了这些方法。目前来讲,所有浏览器以及 Node.js 都支持这两个方法。
setTimeout
语法:
let timerId = setTimeout(func|code, [delay], [arg1], [arg2], ...)参数说明:
-
func|code想要执行的函数或代码字符串。 一般传入的都是函数。由于某些历史原因,支持传入代码字符串,但是不建议这样做。
-
delay执行前的延时,以毫秒为单位(1000 毫秒 = 1 秒),默认值是 0;
-
arg1,arg2…要传入被执行函数(或代码字符串)的参数列表
例如,在下面这个示例中,sayHi() 方法会在 1 秒后执行:
function sayHi() {
alert('Hello');
}
setTimeout(sayHi, 1000);带参数的情况:
function sayHi(phrase, who) {
alert( phrase + ', ' + who );
}
setTimeout(sayHi, 1000, "Hello", "John"); // Hello, John如果第一个参数位传入的是字符串,JavaScript 会自动为其创建一个函数。
所以这么写也是可以的:
setTimeout("alert('Hello')", 1000);但是,不建议使用字符串,我们可以使用箭头函数代替它们,如下所示:
setTimeout(() => alert('Hello'), 1000);传入一个函数,但不要执行它
新手开发者有时候会误将一对括号 () 加在函数后面:
// 错的!
setTimeout(sayHi(), 1000);这样不行,因为 setTimeout 期望得到一个对函数的引用。而这里的 sayHi() 很明显是在执行函数,所以实际上传入 setTimeout 的是 函数的执行结果。在这个例子中,sayHi() 的执行结果是 undefined(也就是说函数没有返回任何结果),所以实际上什么也没有调度。
用 clearTimeout 来取消调度
setTimeout 在调用时会返回一个“定时器标识符(timer identifier)”,在我们的例子中是 timerId,我们可以使用它来取消执行。
取消调度的语法:
let timerId = setTimeout(...);
clearTimeout(timerId);在下面的代码中,我们对一个函数进行了调度,紧接着取消了这次调度(中途反悔了)。所以最后什么也没发生:
let timerId = setTimeout(() => alert("never happens"), 1000);
alert(timerId); // 定时器标识符
clearTimeout(timerId);
alert(timerId); // 还是这个标识符(并没有因为调度被取消了而变成 null)从 alert 的输出来看,在浏览器中,定时器标识符是一个数字。在其他环境中,可能是其他的东西。例如 Node.js 返回的是一个定时器对象,这个对象包含一系列方法。
我再重申一遍,这些方法没有统一的规范定义,所以这没什么问题。
setInterval
setInterval 方法和 setTimeout 的语法相同:
let timerId = setInterval(func|code, [delay], [arg1], [arg2], ...)所有参数的意义也是相同的。不过与 setTimeout 只执行一次不同,setInterval 是每间隔给定的时间周期性执行。
想要阻止后续调用,我们需要调用 clearInterval(timerId)。
下面的例子将每间隔 2 秒就会输出一条消息。5 秒之后,输出停止:
// 每 2 秒重复一次
let timerId = setInterval(() => alert('tick'), 2000);
// 5 秒之后停止
setTimeout(() => { clearInterval(timerId); alert('stop'); }, 5000);alert 弹窗显示的时候计时器依然在进行计时
在大多数浏览器中,包括 Chrome 和 Firefox,在显示 alert/confirm/prompt 弹窗时,内部的定时器仍旧会继续“嘀嗒”。
所以,在运行上面的代码时,如果在一定时间内没有关掉 alert 弹窗,那么在你关闭弹窗后,下一个 alert 会立即显示。两次 alert 之间的时间间隔将小于 2 秒。
嵌套的 setTimeout
周期性调度有两种方式。
一种是使用 setInterval,另外一种就是嵌套的 setTimeout,就像这样:
/** instead of:
let timerId = setInterval(() => alert('tick'), 2000);
*/
let timerId = setTimeout(function tick() {
alert('tick');
timerId = setTimeout(tick, 2000); // (*)
}, 2000);上面这个 setTimeout 在当前这一次函数执行完时 (*) 立即调度下一次调用。
嵌套的 setTimeout 要比 setInterval 灵活得多。采用这种方式可以根据当前执行结果来调度下一次调用,因此下一次调用可以与当前这一次不同。
例如,我们要实现一个服务(server),每间隔 5 秒向服务器发送一个数据请求,但如果服务器过载了,那么就要降低请求频率,比如将间隔增加到 10、20、40 秒等。
以下是伪代码:
let delay = 5000;
let timerId = setTimeout(function request() {
...发送请求...
if (request failed due to server overload) {
// 下一次执行的间隔是当前的 2 倍
delay *= 2;
}
timerId = setTimeout(request, delay);
}, delay);并且,如果我们调度的函数占用大量的 CPU,那么我们可以测量执行所需要花费的时间,并安排下次调用是应该提前还是推迟。
嵌套的 setTimeout 相较于 setInterval 能够更精确地设置两次执行之间的延时。
下面来比较这两个代码片段。第一个使用的是 setInterval:
let i = 1;
setInterval(function() {
func(i++);
}, 100);第二个使用的是嵌套的 setTimeout:
let i = 1;
setTimeout(function run() {
func(i++);
setTimeout(run, 100);
}, 100);对 setInterval 而言,内部的调度程序会每间隔 100 毫秒执行一次 func(i++):
注意到了吗?
使用 setInterval 时,func 函数的实际调用间隔要比代码中设定的时间间隔要短!
这也是正常的,因为 func 的执行所花费的时间“消耗”了一部分间隔时间。
也可能出现这种情况,就是 func 的执行所花费的时间比我们预期的时间更长,并且超出了 100 毫秒。
在这种情况下,JavaScript 引擎会等待 func 执行完成,然后检查调度程序,如果时间到了,则 立即 再次执行它。
极端情况下,如果函数每次执行时间都超过 delay 设置的时间,那么每次调用之间将完全没有停顿。
零延时的 setTimeout
这儿有一种特殊的用法:setTimeout(func, 0),或者仅仅是 setTimeout(func)。
这样调度可以让 func 尽快执行。但是只有在当前正在执行的脚本执行完成后,调度程序才会调用它。
也就是说,该函数被调度在当前脚本执行完成“之后”立即执行。
例如,下面这段代码会先输出 “Hello”,然后立即输出 “World”:
setTimeout(() => alert("World"));
alert("Hello");第一行代码“将调用安排到日程(calendar)0 毫秒处”。但是调度程序只有在当前脚本执行完毕时才会去“检查日程”,所以先输出 "Hello",然后才输出 "World"。
此外,还有与浏览器相关的 0 延时 timeout 的高级用例,我们将在 「事件循环:微任务和宏任务」 一章中详细讲解。
零延时实际上不为零(在浏览器中)
在浏览器环境下,嵌套定时器的运行频率是受限制的。根据 HTML5 标准 所讲:“经过 5 重嵌套定时器之后,时间间隔被强制设定为至少 4 毫秒”。
让我们用下面的示例来看看这到底是什么意思。其中 setTimeout 调用会以零延时重新调度自身的调用。每次调用都会在 times 数组中记录上一次调用的实际时间。那么真正的延迟是什么样的?让我们来看看:
let start = Date.now();
let times = [];
setTimeout(function run() {
times.push(Date.now() - start); // 保存前一个调用的延时
if (start + 100 < Date.now()) alert(times); // 100 毫秒之后,显示延时信息
else setTimeout(run); // 否则重新调度
});
// 输出示例:
// 1,1,1,1,9,15,20,24,30,35,40,45,50,55,59,64,70,75,80,85,90,95,100第一次,定时器是立即执行的(正如规范里所描述的那样),接下来我们可以看到 9, 15, 20, 24...。两次调用之间必须经过 4 毫秒以上的强制延时。(译注:这里作者没说清楚,timer 数组里存放的是每次定时器运行的时刻与 start 的差值,所以数字只会越来越大,实际上前后调用的延时是数组值的差值。示例中前几次都是 1,所以延时为 0)
装饰器模式和转发,call/apply
JavaScript 在处理函数时提供了非凡的灵活性。它们可以被传递,用作对象,现在我们将看到如何在它们之间 转发(forward) 调用并 装饰(decorate) 它们。
透明缓存
假设我们有一个 CPU 重负载的函数 slow(x),但它的结果是稳定的。换句话说,对于相同的 x,它总是返回相同的结果。
如果经常调用该函数,我们可能希望将结果缓存(记住)下来,以避免在重新计算上花费额外的时间。
但是我们不是将这个功能添加到 slow() 中,而是创建一个包装器(wrapper)函数,该函数增加了缓存功能。正如我们将要看到的,这样做有很多好处。
下面是代码和解释:
function slow(x) {
// 这里可能会有重负载的 CPU 密集型工作
alert(`Called with ${x}`);
return x;
}
function cachingDecorator(func) {
let cache = new Map();
return function(x) {
if (cache.has(x)) { // 如果缓存中有对应的结果
return cache.get(x); // 从缓存中读取结果
}
let result = func(x); // 否则就调用 func
cache.set(x, result); // 然后将结果缓存(记住)下来
return result;
};
}
slow = cachingDecorator(slow);
alert( slow(1) ); // slow(1) 被缓存下来了,并返回结果
alert( "Again: " + slow(1) ); // 返回缓存中的 slow(1) 的结果
alert( slow(2) ); // slow(2) 被缓存下来了,并返回结果
alert( "Again: " + slow(2) ); // 返回缓存中的 slow(2) 的结果在上面的代码中,cachingDecorator 是一个 装饰器(decorator):一个特殊的函数,它接受另一个函数并改变它的行为。
其思想是,我们可以为任何函数调用 cachingDecorator,它将返回缓存包装器。这很棒啊,因为我们有很多函数可以使用这样的特性,而我们需要做的就是将 cachingDecorator 应用于它们。
通过将缓存与主函数代码分开,我们还可以使主函数代码变得更简单。
使用 “func.call” 设定上下文
上面提到的缓存装饰器不适用于对象方法。
例如,在下面的代码中,worker.slow() 在装饰后停止工作:
// 我们将对 worker.slow 的结果进行缓存
let worker = {
someMethod() {
return 1;
},
slow(x) {
// 可怕的 CPU 过载任务
alert("Called with " + x);
return x * this.someMethod(); // (*)
}
};
// 和之前例子中的代码相同
function cachingDecorator(func) {
let cache = new Map();
return function(x) {
if (cache.has(x)) {
return cache.get(x);
}
let result = func(x); // (**)
cache.set(x, result);
return result;
};
}
alert( worker.slow(1) ); // 原始方法有效
worker.slow = cachingDecorator(worker.slow); // 现在对其进行缓存
alert( worker.slow(2) ); // 蛤!Error: Cannot read property 'someMethod' of undefined错误发生在试图访问 this.someMethod 并失败了的 (*) 行中。你能看出来为什么吗?
原因是包装器将原始函数调用为 (**) 行中的 func(x)。并且,当这样调用时,函数将得到 this = undefined。
如果尝试运行下面这段代码,我们会观察到类似的问题:
let func = worker.slow;
func(2);因此,包装器将调用传递给原始方法,但没有上下文 this。因此,发生了错误。
但当你调用
func(x)时,只是普通函数调用,而不是对象方法调用。 所以this不会指向worker,而是undefined(在严格模式下)或window(在非严格模式下)。换句话说:
func(x); // this === undefined worker.slow(x); // this === worker当你包装了
worker.slow,它失去了原本的绑定。
让我们来解决这个问题。
有一个特殊的内建函数方法 func.call(context, …args),它允许调用一个显式设置 this 的函数。
语法如下:
func.call(context, arg1, arg2, ...)它运行 func,提供的第一个参数作为 this,后面的作为参数(arguments)。
简单地说,这两个调用几乎相同:
func(1, 2, 3);
func.call(obj, 1, 2, 3)它们调用的都是 func,参数是 1、2 和 3。唯一的区别是 func.call 还会将 this 设置为 obj。
例如,在下面的代码中,我们在不同对象的上下文中调用 sayHi:sayHi.call(user) 运行 sayHi 并提供了 this=user,然后下一行设置 this=admin:
function sayHi() {
alert(this.name);
}
let user = { name: "John" };
let admin = { name: "Admin" };
// 使用 call 将不同的对象传递为 "this"
sayHi.call( user ); // John
sayHi.call( admin ); // Admin在这里我们用带有给定上下文和 phrase 的 call 调用 say:
function say(phrase) {
alert(this.name + ': ' + phrase);
}
let user = { name: "John" };
// user 成为 this,"Hello" 成为第一个参数
say.call( user, "Hello" ); // John: Hello在我们的例子中,我们可以在包装器中使用 call 将上下文传递给原始函数:
let worker = {
someMethod() {
return 1;
},
slow(x) {
alert("Called with " + x);
return x * this.someMethod(); // (*)
}
};
function cachingDecorator(func) {
let cache = new Map();
return function(x) {
if (cache.has(x)) {
return cache.get(x);
}
let result = func.call(this, x); // 现在 "this" 被正确地传递了
cache.set(x, result);
return result;
};
}
worker.slow = cachingDecorator(worker.slow); // 现在对其进行缓存
alert( worker.slow(2) ); // 工作正常
alert( worker.slow(2) ); // 工作正常,没有调用原始函数(使用的缓存)现在一切都正常工作了。
为了让大家理解地更清晰一些,让我们更深入地看看 this 是如何被传递的:
- 在经过装饰之后,
worker.slow现在是包装器function (x) { ... }。 - 因此,当
worker.slow(2)执行时,包装器将2作为参数,并且this=worker(它是点符号.之前的对象)。 - 在包装器内部,假设结果尚未缓存,
func.call(this, x)将当前的this(=worker)和当前的参数(=2)传递给原始方法。
func.apply
我们可以使用 func.apply(this, arguments) 代替 func.call(this, ...arguments)。
内建方法 func.apply 的语法是:
func.apply(context, args)它运行 func 设置 this=context,并使用类数组对象 args 作为参数列表(arguments)。
call 和 apply 之间唯一的语法区别是,call 期望一个参数列表,而 apply 期望一个包含这些参数的类数组对象。
因此,这两个调用几乎是等效的:
func.call(context, ...args);
func.apply(context, args);它们使用给定的上下文和参数执行相同的 func 调用。
只有一个关于 args 的细微的差别:
- Spread 语法
...允许将 可迭代对象args作为列表传递给call。 apply只接受 类数组args。
……对于即可迭代又是类数组的对象,例如一个真正的数组,我们使用 call 或 apply 均可,但是 apply 可能会更快,因为大多数 JavaScript 引擎在内部对其进行了优化。
将所有参数连同上下文一起传递给另一个函数被称为“呼叫转移(call forwarding)”。
这是它的最简形式:
let wrapper = function() {
return func.apply(this, arguments);
};当外部代码调用这种包装器 wrapper 时,它与原始函数 func 的调用是无法区分的。
借用一种方法
现在,让我们对哈希函数再做一个较小的改进:
function hash(args) {
return args[0] + ',' + args[1];
}截至目前,它仅适用于两个参数。如果它可以适用于任何数量的 args 就更好了。
自然的解决方案是使用 arr.join 方法:
function hash(args) {
return args.join();
}……不幸的是,这不行。因为我们正在调用 hash(arguments),arguments 对象既是可迭代对象又是类数组对象,但它并不是真正的数组。
所以在它上面调用 join 会失败,我们可以在下面看到:
function hash() {
alert( arguments.join() ); // Error: arguments.join is not a function
}
hash(1, 2);不过,有一种简单的方法可以使用数组的 join 方法:
function hash() {
alert( [].join.call(arguments) ); // 1,2
}
hash(1, 2);这个技巧被称为 方法借用(method borrowing)。
我们从常规数组 [].join 中获取(借用)join 方法,并使用 [].join.call 在 arguments 的上下文中运行它。
它为什么有效?
那是因为原生方法 arr.join(glue) 的内部算法非常简单。
因此,从技术上讲,它需要 this 并将 this[0],this[1] ……等 join 在一起。它的编写方式是故意允许任何类数组的 this 的(不是巧合,很多方法都遵循这种做法)。这就是为什么它也可以和 this=arguments 一起使用。
装饰器和函数属性
通常,用装饰的函数替换一个函数或一个方法是安全的,除了一件小东西。如果原始函数有属性,例如 func.calledCount 或其他,则装饰后的函数将不再提供这些属性。因为这是装饰器。因此,如果有人使用它们,那么就需要小心。
例如,在上面的示例中,如果 slow 函数具有任何属性,而 cachingDecorator(slow) 则是一个没有这些属性的包装器。
一些包装器可能会提供自己的属性。例如,装饰器会计算一个函数被调用了多少次以及花费了多少时间,并通过包装器属性公开(expose)这些信息。
存在一种创建装饰器的方法,该装饰器可保留对函数属性的访问权限,但这需要使用特殊的 Proxy 对象来包装函数。我们将在后面的 Proxy 和 Reflect 中学习它。
函数绑定
当将对象方法作为回调进行传递,例如传递给 setTimeout,这儿会存在一个常见的问题:“丢失 this”。
在本章中,我们会学习如何去解决这个问题。
丢失 “this”
我们已经看到了丢失 this 的例子。一旦方法被传递到与对象分开的某个地方 —— this 就丢失。
下面是使用 setTimeout 时 this 是如何丢失的:
let user = {
firstName: "John",
sayHi() {
alert(`Hello, ${this.firstName}!`);
}
};
setTimeout(user.sayHi, 1000); // Hello, undefined!正如我们所看到的,输出没有像 this.firstName 那样显示 “John”,而显示了 undefined!
这是因为 setTimeout 获取到了函数 user.sayHi,但它和对象分离开了。最后一行可以被重写为:
let f = user.sayHi;
setTimeout(f, 1000); // 丢失了 user 上下文浏览器中的 setTimeout 方法有些特殊:它为函数调用设定了 this=window(对于 Node.js,this 则会变为计时器(timer)对象,但在这儿并不重要)。所以对于 this.firstName,它其实试图获取的是 window.firstName,这个变量并不存在。在其他类似的情况下,通常 this 会变为 undefined。
解决方案 1:包装器
最简单的解决方案是使用一个包装函数:
let user = {
firstName: "John",
sayHi() {
alert(`Hello, ${this.firstName}!`);
}
};
setTimeout(function() {
user.sayHi(); // Hello, John!
}, 1000);现在它可以正常工作了,因为它从外部词法环境中获取到了 user,就可以正常地调用方法了。
相同的功能,但是更简短:
setTimeout(() => user.sayHi(), 1000); // Hello, John!看起来不错,但是我们的代码结构中出现了一个小漏洞。
如果在 setTimeout 触发之前(有一秒的延迟!)user 的值改变了怎么办?那么,突然间,它将调用错误的对象!
let user = {
firstName: "John",
sayHi() {
alert(`Hello, ${this.firstName}!`);
}
};
setTimeout(() => user.sayHi(), 1000);
// ……user 的值在不到 1 秒的时间内发生了改变
user = {
sayHi() { alert("Another user in setTimeout!"); }
};
// Another user in setTimeout!下一个解决方案保证了这样的事情不会发生。
解决方案 2:bind
函数提供了一个内建方法 bind,它可以绑定 this。
基本的语法是:
// 稍后将会有更复杂的语法
let boundFunc = func.bind(context);func.bind(context) 的结果是一个特殊的类似于函数的“外来对象(exotic object)”,它可以像函数一样被调用,并且透明地(transparently)将调用传递给 func 并设定 this=context。
换句话说,boundFunc 调用就像绑定了 this 的 func。
举个例子,这里的 funcUser 将调用传递给了 func 同时 this=user:
let user = {
firstName: "John"
};
function func() {
alert(this.firstName);
}
let funcUser = func.bind(user);
funcUser(); // John这里的 func.bind(user) 作为 func 的“绑定的(bound)变体”,绑定了 this=user。
所有的参数(arguments)都被“原样”传递给了初始的 func,例如:
let user = {
firstName: "John"
};
function func(phrase) {
alert(phrase + ', ' + this.firstName);
}
// 将 this 绑定到 user
let funcUser = func.bind(user);
funcUser("Hello"); // Hello, John(参数 "Hello" 被传递,并且 this=user)现在我们来尝试一个对象方法:
let user = {
firstName: "John",
sayHi() {
alert(`Hello, ${this.firstName}!`);
}
};
let sayHi = user.sayHi.bind(user); // (*)
// 可以在没有对象(译注:与对象分离)的情况下运行它
sayHi(); // Hello, John!
setTimeout(sayHi, 1000); // Hello, John!
// 即使 user 的值在不到 1 秒内发生了改变
// sayHi 还是会使用预先绑定(pre-bound)的值,该值是对旧的 user 对象的引用
user = {
sayHi() { alert("Another user in setTimeout!"); }
};在 (*) 行,我们取了方法 user.sayHi 并将其绑定到 user。sayHi 是一个“绑定后(bound)”的方法,它可以被单独调用,也可以被传递给 setTimeout —— 都没关系,函数上下文都会是正确的。
这里我们能够看到参数(arguments)都被“原样”传递了,只是 this 被 bind 绑定了:
let user = {
firstName: "John",
say(phrase) {
alert(`${phrase}, ${this.firstName}!`);
}
};
let say = user.say.bind(user);
say("Hello"); // Hello, John!(参数 "Hello" 被传递给了 say)
say("Bye"); // Bye, John!(参数 "Bye" 被传递给了 say)便捷方法:bindAll
如果一个对象有很多方法,并且我们都打算将它们都传递出去,那么我们可以在一个循环中完成所有方法的绑定:
for (let key in user) {
if (typeof user[key] == 'function') {
user[key] = user[key].bind(user);
}
}JavaScript 库还提供了方便批量绑定的函数,例如 lodash 中的 _.bindAll(object, methodNames)。
部分(应用)函数(Partial functions)
到现在为止,我们只在谈论绑定 this。让我们再深入一步。
我们不仅可以绑定 this,还可以绑定参数(arguments)。虽然很少这么做,但有时它可以派上用场。
bind 的完整语法如下:
let bound = func.bind(context, [arg1], [arg2], ...);它允许将上下文绑定为 this,以及绑定函数的部分参数。
例如,我们有一个乘法函数 mul(a, b):
function mul(a, b) {
return a * b;
}让我们使用 bind 在该函数基础上创建一个 double 函数:
function mul(a, b) {
return a * b;
}
let double = mul.bind(null, 2);
alert( double(3) ); // = mul(2, 3) = 6
alert( double(4) ); // = mul(2, 4) = 8
alert( double(5) ); // = mul(2, 5) = 10对 mul.bind(null, 2) 的调用创建了一个新函数 double,它将调用传递到 mul,将 null 绑定为上下文,并将 2 绑定为第一个参数。并且,参数(arguments)均被“原样”传递。
它被称为 「函数的部分应用(partial function application)」 —— 我们通过绑定先有函数的一些参数来创建一个新函数。
请注意,这里我们实际上没有用到 this。但是 bind 需要它,所以我们必须传入 null 之类的东西。
下面这段代码中的 triple 函数将值乘了三倍:
function mul(a, b) {
return a * b;
}
let triple = mul.bind(null, 3);
alert( triple(3) ); // = mul(3, 3) = 9
alert( triple(4) ); // = mul(3, 4) = 12
alert( triple(5) ); // = mul(3, 5) = 15为什么我们通常会创建一个部分应用函数?
好处是我们可以创建一个具有可读性高的名字(double,triple)的独立函数。我们可以使用它,并且不必每次都提供一个参数,因为参数是被绑定了的。
另一方面,当我们有一个非常灵活的函数,并希望有一个不那么灵活的变型时,部分应用函数会非常有用。
例如,我们有一个函数 send(from, to, text)。然后,在一个 user 对象的内部,我们可能希望对它使用 send 的部分应用函数变型:从当前 user 发送 sendTo(to, text)。
在没有上下文情况下的 partial
当我们想绑定一些参数(arguments),但是不想绑定上下文 this,应该怎么办?例如,对于一个对象方法。
原生的 bind 不允许这种情况。我们不可以省略上下文直接跳到参数(arguments)。
幸运的是,仅绑定参数(arguments)的函数 partial 比较容易实现。
像这样:
function partial(func, ...argsBound) {
return function(...args) { // (*)
return func.call(this, ...argsBound, ...args);
}
}
// 用法:
let user = {
firstName: "John",
say(time, phrase) {
alert(`[${time}] ${this.firstName}: ${phrase}!`);
}
};
// 添加一个带有绑定时间的 partial 方法
user.sayNow = partial(user.say, new Date().getHours() + ':' + new Date().getMinutes());
user.sayNow("Hello");
// 类似于这样的一些内容:
// [10:00] John: Hello!partial(func[, arg1, arg2...]) 调用的结果是一个包装器 (*),它调用 func 并具有以下内容:
- 与它获得的函数具有相同的
this(对于user.sayNow调用来说,它是user) - 然后给它
...argsBound—— 来自于partial调用的参数("10:00") - 然后给它
...args—— 给包装器的参数("Hello")
使用 spread 可以很容易实现这些操作,对吧?
此外,还有来自 lodash 库的现成的 _.partial 实现。
评论
评论加载中……