Javascript.info-函数进阶

在 JavaScript 中,很多内建函数都支持传入任意数量的参数。

在 JavaScript 中,很多内建函数都支持传入任意数量的参数。

例如:

  • Math.max(arg1, arg2, ..., argN) —— 返回参数中的最大值。
  • Object.assign(dest, src1, ..., srcN) —— 依次将属性从 src1..N 复制到 dest
  • ……等。

在本章中,我们将学习如何编写支持传入任意数量参数的函数,以及如何将数组作为参数传递给这类函数。

Rest 参数 ...

在 JavaScript 中,无论函数是如何定义的,你都可以在调用它时传入任意数量的参数。

例如:

javascript
function sum(a, b) {
  return a + b;
}

alert( sum(1, 2, 3, 4, 5) );

虽然这里这个函数不会因为传入过多的参数而报错。但是,当然,只有前两个参数被求和了。

我们可以在函数定义中声明一个数组来收集参数。语法是这样的:...变量名,这将会声明一个数组并指定其名称,其中存有剩余的参数。这三个点的语义就是“收集剩余的参数并存进指定数组中”。

例如,我们需要把所有的参数都放到数组 args 中:

javascript
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 数组中:

javascript
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 参数会收集剩余的所有参数,因此下面这种用法没有意义,并且会导致错误:

javascript
function f(arg1, ...rest, arg2) { // arg2 在 ...rest 后面?!
  // error
}

...rest 必须写在参数列表最后。

“arguments” 变量

有一个名为 arguments 的特殊类数组对象可以在函数中被访问,该对象以参数在参数列表中的索引作为键,存储所有参数。

例如:

javascript
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 并不属于箭头函数,而是属于箭头函数外部的“普通”函数。

举个例子:

javascript
function f() {
  let showArg = () => alert(arguments[0]);
  showArg();
}

f(1); // 1

我们已经知道,箭头函数没有自身的 this。现在我们知道了它们也没有特殊的 arguments 对象。

Spread 语法

我们刚刚看到了如何从参数列表中获取数组。

有时候我们也需要做与之相反的事。

例如,内建函数 Math.max 会返回参数中最大的值:

javascript
alert( Math.max(3, 5, 1) ); // 5

如果我们有一个数组 [3, 5, 1],我们该如何用它调用 Math.max 呢?

直接“原样”传入这个数组是不会奏效的,因为 Math.max 期望的是列表形式的数值型参数,而不是一个数组:

javascript
let arr = [3, 5, 1];

alert( Math.max(arr) ); // NaN

毫无疑问,我们不能手动地去一一设置参数 Math.max(arg[0], arg[1], arg[2]),因为我们不确定这儿有多少个。在代码执行时,参数数组中可能有很多个元素,也可能一个都没有。而且,这样的代码也很不优雅。

Spread 语法 可以解决这个问题!它看起来和 rest 参数很像,也使用 ...,但是二者的用途完全相反。

当在函数调用中使用 ...arr 时,它会把可迭代对象 arr “展开”到参数列表中。

Math.max 为例:

javascript
let arr = [3, 5, 1];

alert( Math.max(...arr) ); // 5(spread 语法把数组转换为参数列表)

我们还可以通过这种方式传入多个可迭代对象:

javascript
let arr1 = [1, -2, 3, 4];
let arr2 = [8, 3, -8, 1];

alert( Math.max(...arr1, ...arr2) ); // 8

我们甚至还可以将 spread 语法与常规值结合使用:

javascript
let arr1 = [1, -2, 3, 4];
let arr2 = [8, 3, -8, 1];

alert( Math.max(1, ...arr1, 2, ...arr2, 25) ); // 25

并且,我们还可以使用 spread 语法来合并数组:

javascript
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 语法将字符串转换为字符数组:

javascript
let str = "Hello";

alert( [...str] ); // H,e,l,l,o

Spread 语法内部使用了迭代器来收集元素,与 for..of 的方式相同。

因此,对于一个字符串,for..of 会逐个返回该字符串中的字符,...str 也同理会得到 "H","e","l","l","o" 这样的结果。随后,字符列表被传递给数组初始化器 [...str]

对于这个特定任务,我们还可以使用 Array.from 来实现,因为该方法会将一个可迭代对象(如字符串)转换为数组:

javascript
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 语法也可以做同样的事情(译注:也就是进行浅拷贝)。

javascript
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

并且,也可以通过相同的方式来复制一个对象:

javascript
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” 来访问:

javascript
function sayHi() {
  alert("Hi");
}

alert(sayHi.name); // sayHi

更有趣的是,名称赋值的逻辑很智能。即使函数被创建时没有名字,名称赋值的逻辑也能给它赋予一个正确的名字,然后进行赋值:

javascript
let sayHi = function() {
  alert("Hi");
};

alert(sayHi.name); // sayHi(有名字!)

当以默认值的方式完成了赋值时,它也有效:

javascript
function f(sayHi = function() {}) {
  alert(sayHi.name); // sayHi(生效了!)
}

f();

规范中把这种特性叫做「上下文命名」。如果函数自己没有提供,那么在赋值中,会根据上下文来推测一个。

对象方法也有名字:

javascript
let user = {

  sayHi() {
    // ...
  },

  sayBye: function() {
    // ...
  }

}

alert(user.sayHi.name); // sayHi
alert(user.sayBye.name); // sayBye

这没有什么神奇的。有时会出现无法推测名字的情况。此时,属性 name 会是空,像这样:

javascript
// 函数是在数组中创建的
let arr = [function() {}];

alert( arr[0].name ); // <空字符串>
// 引擎无法设置正确的名字,所以没有值

而实际上,大多数函数都是有名字的。

属性 “length”

还有另一个内建属性 “length”,它返回函数入参的个数,比如:

javascript
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:

javascript
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 属性,用来跟踪总的调用次数:

javascript
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 函数的例子进行重写:

javascript
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 的值位于外层(函数)变量中,那么外部的代码无法访问到它,只有嵌套的那些函数可以修改它。而如果它是绑定到函数的,那么就可以这样:

javascript
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),指带有名字的函数表达式的术语。

例如,让我们写一个普通的函数表达式:

javascript
let sayHi = function(who) {
  alert(`Hello, ${who}`);
};

然后给它加一个名字:

javascript
let sayHi = function func(who) {
  alert(`Hello, ${who}`);
};

我们这里得到了什么吗?为它添加一个 "func" 名字的目的是什么?

首先请注意,它仍然是一个函数表达式。在 function 后面加一个名字 "func" 没有使它成为一个函数声明,因为它仍然是作为赋值表达式中的一部分被创建的。

添加这个名字当然也没有打破任何东西。

函数依然可以通过 sayHi() 来调用:

javascript
let sayHi = function func(who) {
  alert(`Hello, ${who}`);
};

sayHi("John"); // Hello, John

关于名字 func 有两个特殊的地方,这就是添加它的原因:

  1. 它允许函数在内部引用自己。
  2. 它在函数外是不可见的。

例如,下面的函数 sayHi 会在没有入参 who 时,以 "Guest" 为入参调用自己:

javascript
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 进行嵌套调用?

当然,在大多数情况下我们可以这样做:

javascript
let sayHi = function(who) {
  if (who) {
    alert(`Hello, ${who}`);
  } else {
    sayHi("Guest");
  }
};

上面这段代码的问题在于 sayHi 的值可能会被函数外部的代码改变。如果该函数被赋值给另外一个变量(译注:也就是原变量被修改),那么函数就会开始报错:

javascript
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 了,所以使用外部变量。而当调用时,外部的 sayHinull

我们给函数表达式添加的可选的名字,正是用来解决这类问题的。

让我们使用它来修复我们的代码:

javascript
let sayHi = function func(who) {
  if (who) {
    alert(`Hello, ${who}`);
  } else {
    func("Guest"); // 现在一切正常
  }
};
let welcome = sayHi;
sayHi = null;
welcome(); // Hello, Guest(嵌套调用有效)

现在它可以正常运行了,因为名字 func 是函数局部域的。它不是从外部获取的(而且它对外部也是不可见的)。规范确保它只会引用当前函数。

“new Function” 语法

还有一种创建函数的方法。它很少被使用,但有些时候只能选择它。

语法

创建函数的语法:

javascript
let func = new Function ([arg1, arg2, ...argN], functionBody);

该函数是通过使用参数 arg1...argN 和给定的 functionBody 创建的。

下面这个例子可以帮助你理解创建语法。这是一个带有两个参数的函数:

javascript
let sum = new Function('a', 'b', 'return a + b');

alert( sum(1, 2) ); // 3

这里有一个没有参数的函数,只有函数体:

javascript
let sayHi = new Function('alert("Hello")');
sayHi(); // Hello

与我们已知的其他方法相比,这种方法最大的不同在于,它实际上是通过运行时通过参数传递过来的字符串创建的。

以前的所有声明方法都需要我们 —— 程序员,在脚本中编写函数的代码。

但是 new Function 允许我们将任意字符串变为函数。例如,我们可以从服务器接收一个新的函数并执行它:

javascript
let str = ... 动态地接收来自服务器的代码 ...

let func = new Function(str);
func();

使用 new Function 创建函数的应用场景非常特殊,比如在复杂的 Web 应用程序中,我们需要从服务器获取代码或者动态地从模板编译函数时才会使用。

闭包

通常,闭包是指使用一个特殊的属性 [[Environment]] 来记录函数自身的创建时的环境的函数。它具体指向了函数创建时的词法环境。

但是如果我们使用 new Function 创建一个函数,那么该函数的 [[Environment]] 并不指向当前的词法环境,而是指向全局环境。

因此,此类函数无法访问外部(outer)变量,只能访问全局变量。

javascript
function getFunc() {
  let value = "test";
  let func = new Function('alert(value)');
  return func;
}

getFunc()(); // error: value is not defined

将其与常规行为进行比较:

javascript
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

语法:

javascript
let timerId = setTimeout(func|code, [delay], [arg1], [arg2], ...)

参数说明:

  • func|code

    想要执行的函数或代码字符串。 一般传入的都是函数。由于某些历史原因,支持传入代码字符串,但是不建议这样做。

  • delay

    执行前的延时,以毫秒为单位(1000 毫秒 = 1 秒),默认值是 0;

  • arg1arg2

    要传入被执行函数(或代码字符串)的参数列表

例如,在下面这个示例中,sayHi() 方法会在 1 秒后执行:

javascript
function sayHi() {
  alert('Hello');
}

setTimeout(sayHi, 1000);

带参数的情况:

javascript
function sayHi(phrase, who) {
  alert( phrase + ', ' + who );
}

setTimeout(sayHi, 1000, "Hello", "John"); // Hello, John

如果第一个参数位传入的是字符串,JavaScript 会自动为其创建一个函数。

所以这么写也是可以的:

javascript
setTimeout("alert('Hello')", 1000);

但是,不建议使用字符串,我们可以使用箭头函数代替它们,如下所示:

javascript
setTimeout(() => alert('Hello'), 1000);

传入一个函数,但不要执行它

新手开发者有时候会误将一对括号 () 加在函数后面:

javascript
// 错的!
setTimeout(sayHi(), 1000);

这样不行,因为 setTimeout 期望得到一个对函数的引用。而这里的 sayHi() 很明显是在执行函数,所以实际上传入 setTimeout 的是 函数的执行结果。在这个例子中,sayHi() 的执行结果是 undefined(也就是说函数没有返回任何结果),所以实际上什么也没有调度。

用 clearTimeout 来取消调度

setTimeout 在调用时会返回一个“定时器标识符(timer identifier)”,在我们的例子中是 timerId,我们可以使用它来取消执行。

取消调度的语法:

javascript
let timerId = setTimeout(...);
clearTimeout(timerId);

在下面的代码中,我们对一个函数进行了调度,紧接着取消了这次调度(中途反悔了)。所以最后什么也没发生:

javascript
let timerId = setTimeout(() => alert("never happens"), 1000);
alert(timerId); // 定时器标识符
clearTimeout(timerId);
alert(timerId); // 还是这个标识符(并没有因为调度被取消了而变成 null)

alert 的输出来看,在浏览器中,定时器标识符是一个数字。在其他环境中,可能是其他的东西。例如 Node.js 返回的是一个定时器对象,这个对象包含一系列方法。

我再重申一遍,这些方法没有统一的规范定义,所以这没什么问题。

setInterval

setInterval 方法和 setTimeout 的语法相同:

javascript
let timerId = setInterval(func|code, [delay], [arg1], [arg2], ...)

所有参数的意义也是相同的。不过与 setTimeout 只执行一次不同,setInterval 是每间隔给定的时间周期性执行。

想要阻止后续调用,我们需要调用 clearInterval(timerId)

下面的例子将每间隔 2 秒就会输出一条消息。5 秒之后,输出停止:

javascript
// 每 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,就像这样:

javascript
/** 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 秒等。

以下是伪代码:

javascript
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

javascript
let i = 1;
setInterval(function() {
  func(i++);
}, 100);

第二个使用的是嵌套的 setTimeout

javascript
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”:

javascript
setTimeout(() => alert("World"));

alert("Hello");

第一行代码“将调用安排到日程(calendar)0 毫秒处”。但是调度程序只有在当前脚本执行完毕时才会去“检查日程”,所以先输出 "Hello",然后才输出 "World"

此外,还有与浏览器相关的 0 延时 timeout 的高级用例,我们将在 「事件循环:微任务和宏任务」 一章中详细讲解。

零延时实际上不为零(在浏览器中)

在浏览器环境下,嵌套定时器的运行频率是受限制的。根据 HTML5 标准 所讲:“经过 5 重嵌套定时器之后,时间间隔被强制设定为至少 4 毫秒”。

让我们用下面的示例来看看这到底是什么意思。其中 setTimeout 调用会以零延时重新调度自身的调用。每次调用都会在 times 数组中记录上一次调用的实际时间。那么真正的延迟是什么样的?让我们来看看:

javascript
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)函数,该函数增加了缓存功能。正如我们将要看到的,这样做有很多好处。

下面是代码和解释:

javascript
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() 在装饰后停止工作:

javascript
// 我们将对 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

如果尝试运行下面这段代码,我们会观察到类似的问题:

javascript
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 的函数。

语法如下:

javascript
func.call(context, arg1, arg2, ...)

它运行 func,提供的第一个参数作为 this,后面的作为参数(arguments)。

简单地说,这两个调用几乎相同:

javascript
func(1, 2, 3);
func.call(obj, 1, 2, 3)

它们调用的都是 func,参数是 123。唯一的区别是 func.call 还会将 this 设置为 obj

例如,在下面的代码中,我们在不同对象的上下文中调用 sayHisayHi.call(user) 运行 sayHi 并提供了 this=user,然后下一行设置 this=admin

javascript
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

javascript
function say(phrase) {
  alert(this.name + ': ' + phrase);
}
let user = { name: "John" };
// user 成为 this,"Hello" 成为第一个参数
say.call( user, "Hello" ); // John: Hello

在我们的例子中,我们可以在包装器中使用 call 将上下文传递给原始函数:

javascript
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 是如何被传递的:

  1. 在经过装饰之后,worker.slow 现在是包装器 function (x) { ... }
  2. 因此,当 worker.slow(2) 执行时,包装器将 2 作为参数,并且 this=worker(它是点符号 . 之前的对象)。
  3. 在包装器内部,假设结果尚未缓存,func.call(this, x) 将当前的 this=worker)和当前的参数(=2)传递给原始方法。

func.apply

我们可以使用 func.apply(this, arguments) 代替 func.call(this, ...arguments)

内建方法 func.apply 的语法是:

javascript
func.apply(context, args)

它运行 func 设置 this=context,并使用类数组对象 args 作为参数列表(arguments)。

callapply 之间唯一的语法区别是,call 期望一个参数列表,而 apply 期望一个包含这些参数的类数组对象。

因此,这两个调用几乎是等效的:

javascript
func.call(context, ...args);
func.apply(context, args);

它们使用给定的上下文和参数执行相同的 func 调用。

只有一个关于 args 的细微的差别:

  • Spread 语法 ... 允许将 可迭代对象 args 作为列表传递给 call
  • apply 只接受 类数组 args

……对于即可迭代又是类数组的对象,例如一个真正的数组,我们使用 callapply 均可,但是 apply 可能会更快,因为大多数 JavaScript 引擎在内部对其进行了优化。

将所有参数连同上下文一起传递给另一个函数被称为“呼叫转移(call forwarding)”。

这是它的最简形式:

javascript
let wrapper = function() {
  return func.apply(this, arguments);
};

当外部代码调用这种包装器 wrapper 时,它与原始函数 func 的调用是无法区分的。

借用一种方法

现在,让我们对哈希函数再做一个较小的改进:

javascript
function hash(args) {
  return args[0] + ',' + args[1];
}

截至目前,它仅适用于两个参数。如果它可以适用于任何数量的 args 就更好了。

自然的解决方案是使用 arr.join 方法:

javascript
function hash(args) {
  return args.join();
}

……不幸的是,这不行。因为我们正在调用 hash(arguments)arguments 对象既是可迭代对象又是类数组对象,但它并不是真正的数组。

所以在它上面调用 join 会失败,我们可以在下面看到:

javascript
function hash() {
  alert( arguments.join() ); // Error: arguments.join is not a function
}

hash(1, 2);

不过,有一种简单的方法可以使用数组的 join 方法:

javascript
function hash() {
  alert( [].join.call(arguments) ); // 1,2
}

hash(1, 2);

这个技巧被称为 方法借用(method borrowing)

我们从常规数组 [].join 中获取(借用)join 方法,并使用 [].join.callarguments 的上下文中运行它。

它为什么有效?

那是因为原生方法 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 就丢失。

下面是使用 setTimeoutthis 是如何丢失的:

javascript
let user = {
  firstName: "John",
  sayHi() {
    alert(`Hello, ${this.firstName}!`);
  }
};
setTimeout(user.sayHi, 1000); // Hello, undefined!

正如我们所看到的,输出没有像 this.firstName 那样显示 “John”,而显示了 undefined

这是因为 setTimeout 获取到了函数 user.sayHi,但它和对象分离开了。最后一行可以被重写为:

javascript
let f = user.sayHi;
setTimeout(f, 1000); // 丢失了 user 上下文

浏览器中的 setTimeout 方法有些特殊:它为函数调用设定了 this=window(对于 Node.js,this 则会变为计时器(timer)对象,但在这儿并不重要)。所以对于 this.firstName,它其实试图获取的是 window.firstName,这个变量并不存在。在其他类似的情况下,通常 this 会变为 undefined

解决方案 1:包装器

最简单的解决方案是使用一个包装函数:

javascript
let user = {
  firstName: "John",
  sayHi() {
    alert(`Hello, ${this.firstName}!`);
  }
};

setTimeout(function() {
  user.sayHi(); // Hello, John!
}, 1000);

现在它可以正常工作了,因为它从外部词法环境中获取到了 user,就可以正常地调用方法了。

相同的功能,但是更简短:

javascript
setTimeout(() => user.sayHi(), 1000); // Hello, John!

看起来不错,但是我们的代码结构中出现了一个小漏洞。

如果在 setTimeout 触发之前(有一秒的延迟!)user 的值改变了怎么办?那么,突然间,它将调用错误的对象!

javascript
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

基本的语法是:

javascript
// 稍后将会有更复杂的语法
let boundFunc = func.bind(context);

func.bind(context) 的结果是一个特殊的类似于函数的“外来对象(exotic object)”,它可以像函数一样被调用,并且透明地(transparently)将调用传递给 func 并设定 this=context

换句话说,boundFunc 调用就像绑定了 thisfunc

举个例子,这里的 funcUser 将调用传递给了 func 同时 this=user

javascript
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,例如:

javascript
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)

现在我们来尝试一个对象方法:

javascript
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 并将其绑定到 usersayHi 是一个“绑定后(bound)”的方法,它可以被单独调用,也可以被传递给 setTimeout —— 都没关系,函数上下文都会是正确的。

这里我们能够看到参数(arguments)都被“原样”传递了,只是 thisbind 绑定了:

javascript
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

如果一个对象有很多方法,并且我们都打算将它们都传递出去,那么我们可以在一个循环中完成所有方法的绑定:

javascript
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 的完整语法如下:

javascript
let bound = func.bind(context, [arg1], [arg2], ...);

它允许将上下文绑定为 this,以及绑定函数的部分参数。

例如,我们有一个乘法函数 mul(a, b)

javascript
function mul(a, b) {
  return a * b;
}

让我们使用 bind 在该函数基础上创建一个 double 函数:

javascript
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 函数将值乘了三倍:

javascript
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

为什么我们通常会创建一个部分应用函数?

好处是我们可以创建一个具有可读性高的名字(doubletriple)的独立函数。我们可以使用它,并且不必每次都提供一个参数,因为参数是被绑定了的。

另一方面,当我们有一个非常灵活的函数,并希望有一个不那么灵活的变型时,部分应用函数会非常有用。

例如,我们有一个函数 send(from, to, text)。然后,在一个 user 对象的内部,我们可能希望对它使用 send 的部分应用函数变型:从当前 user 发送 sendTo(to, text)

在没有上下文情况下的 partial

当我们想绑定一些参数(arguments),但是不想绑定上下文 this,应该怎么办?例如,对于一个对象方法。

原生的 bind 不允许这种情况。我们不可以省略上下文直接跳到参数(arguments)。

幸运的是,仅绑定参数(arguments)的函数 partial 比较容易实现。

像这样:

javascript
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 实现。

评论

评论加载中……