js函数进阶内容

132 阅读24分钟

递归和堆栈

递归遍历

递归的另一个重要应用就是递归遍历。

假设我们有一家公司。人员结构可以表示为一个对象:

let company = {
  sales: [{
    name: 'John',
    salary: 1000
  }, {
    name: 'Alice',
    salary: 1600
  }],

  development: {
    sites: [{
      name: 'Peter',
      salary: 2000
    }, {
      name: 'Alex',
      salary: 1800
    }],

    internals: [{
      name: 'Jack',
      salary: 1300
    }]
  }
};

换句话说,一家公司有很多部门。

  • 一个部门可能有一 数组 的员工,比如,sales 部门有 2 名员工:John 和 Alice。

  • 或者,一个部门可能会划分为几个子部门,比如 development 有两个分支:sitesinternals,它们都有自己的员工。

  • 当一个子部门增长时,它也有可能被拆分成几个子部门(或团队)。

    例如,sites 部门在未来可能会分为 siteAsiteB。并且,它们可能会被再继续拆分。没有图示,脑补一下吧。

现在,如果我们需要一个函数来获取所有薪资的总数。我们该怎么做?

迭代方式并不容易,因为结构比较复杂。首先想到的可能是在 company 上使用 for 循环,并在第一层部分上嵌套子循环。但是,之后我们需要更多的子循环来遍历像 sites 这样的二级部门的员工…… 然后,将来可能会出现在三级部门上的另一个子循环?如果我们在代码中写 3-4 级嵌套的子循环来遍历单个对象, 那代码得多丑啊。

我们试试递归吧。

我们可以看到,当我们的函数对一个部门求和时,有两种可能的情况:

  1. 要么是由一 数组 的人组成的“简单”的部门 —— 这样我们就可以通过一个简单的循环来计算薪资的总和。
  2. 或者它是一个有 N 个子部门的 对象 —— 那么我们可以通过 N 层递归调用来求每一个子部门的薪资,然后将它们合并起来。

第一种情况是由一数组的人组成的部门,这种情况很简单,是最基础的递归。

第二种情况是我们得到的是对象。那么可将这个复杂的任务拆分成适用于更小部门的子任务。它们可能会被继续拆分,但很快或者不久就会拆分到第一种情况那样。

这个算法从代码来看可能会更简单:

let company = { // 是同一个对象,简洁起见被压缩了
  sales: [{name: 'John', salary: 1000}, {name: 'Alice', salary: 1600 }],
  development: {
    sites: [{name: 'Peter', salary: 2000}, {name: 'Alex', salary: 1800 }],
    internals: [{name: 'Jack', salary: 1300}]
  }
};

// 用来完成任务的函数
function sumSalaries(department) {
  if (Array.isArray(department)) { // 情况(1)
    return department.reduce((prev, current) => prev + current.salary, 0); // 求数组的和
  } else { // 情况(2)
    let sum = 0;
    for (let subdep of Object.values(department)) {
      sum += sumSalaries(subdep); // 递归调用所有子部门,对结果求和
    }
    return sum;
  }
}

alert(sumSalaries(company)); // 7700

代码很短也容易理解(希望是这样?)。这就是递归的能力。它适用于任何层次的子部门嵌套。

下面是调用图:

我们可以很容易地看到其原理:对于对象 {...} 会生成子调用,而数组 [...] 是递归树的“叶子”,它们会立即给出结果。

请注意,该代码使用了我们之前讲过的智能特性(smart features):

  • 数组方法 中我们介绍过的数组求和方法 arr.reduce
  • 使用循环 for(val of Object.values(obj)) 遍历对象的(属性)值:Object.values 返回它们组成的数组。

递归结构

递归(递归定义的)数据结构是一种部分复制自身的结构。

我们刚刚在上面的公司结构的示例中看过了它。

一个公司的 部门 是:

  • 一数组的人。
  • 或一个 部门 对象。

对于 Web 开发者而言,有更熟知的例子:HTML 和 XML 文档。

在 HTML 文档中,一个 HTML 标签 可能包括以下内容:

  • 文本片段。
  • HTML 注释。
  • 其它 HTML 标签(它有可能又包括文本片段、注释或其它标签等)。

这又是一个递归定义。

为了更好地理解递归,我们再讲一个递归结构的例子“链表”,在某些情况下,它可能是优于数组的选择。

链表

想象一下,我们要存储一个有序的对象列表。

正常的选择会是一个数组:

let arr = [obj1, obj2, obj3];

……但是用数组有个问题。“删除元素”和“插入元素”的操作代价非常大。例如,arr.unshift(obj) 操作必须对所有元素重新编号以便为新的元素 obj 腾出空间,而且如果数组很大,会很耗时。arr.shift() 同理。

唯一对数组结构做修改而不需要大量重排的操作就是对数组末端的操作:arr.push/pop。因此,对于大队列来说,当我们必须对数组首端的元素进行操作时,数组会很慢。(译注:此处的首端操作其实指的是在尾端以外的数组内的元素进行插入/删除操作。)

如果我们确实需要快速插入/删除,则可以选择另一种叫做 链表 的数据结构。

链表元素 是一个使用以下元素通过递归定义的对象:

  • value
  • next 属性引用下一个 链表元素 或者代表末尾的 null

例如:

let list = {
  value: 1,
  next: {
    value: 2,
    next: {
      value: 3,
      next: {
        value: 4,
        next: null
      }
    }
  }
};

链表的图形表示:

一段用来创建链表的代码:

let list = { value: 1 };
list.next = { value: 2 };
list.next.next = { value: 3 };
list.next.next.next = { value: 4 };
list.next.next.next.next = null;

在这儿我们可以清楚地看到,这里有很多个对象,每一个都有 value 和指向邻居的 next。变量 list 是链条中的第一个对象,因此顺着 next 指针,我们可以抵达任何元素。

该链表可以很容易被拆分为多个部分,然后再重新组装回去:

let secondList = list.next.next;
list.next.next = null;

合并:

list.next.next = secondList;

当然,我们可以在任何位置插入或移除元素。

比如,要添加一个新值,我们需要更新链表的头:

let list = { value: 1 };
list.next = { value: 2 };
list.next.next = { value: 3 };
list.next.next.next = { value: 4 };

// 将新值添加到链表头部
list = { value: "new item", next: list };

要从中间删除一个值,可以修改前一个元素的 next

list.next = list.next.next;

我们让 list.next1 跳到值 2。现在值 1 就被从链表中移除了。如果它没有被存储在其它任何地方,那么它会被自动从内存中删除。

与数组不同,链表没有大规模重排,我们可以很容易地重新排列元素。

当然,链表也不总是优于数组的。不然大家就都去使用链表了。

链表主要的缺点就是我们无法很容易地通过元素的编号获取元素。但在数组中却很容易:arr[n] 是一个直接引用。而在链表中,我们需要从起点元素开始,顺着 nextN 次才能获取到第 N 个元素。

……但是我们也并不是总需要这样的操作。比如,当我们需要一个队列甚至一个 双向队列 —— 有序结构必须可以快速地从两端添加/移除元素,但是不需要访问的元素。

链表可以得到增强:

  • 我们可以在 next 之外,再添加 prev 属性来引用前一个元素,以便轻松地往回移动。
  • 我们还可以添加一个名为 tail 的变量,该变量引用链表的最后一个元素(并在从末尾添加/删除元素时对该引用进行更新)。
  • ……数据结构可能会根据我们的需求而变化。

输出一个单链表

重要程度: 5

假设我们有一个单链表(在 递归和堆栈 那章有讲过):

let list = {
  value: 1,
  next: {
    value: 2,
    next: {
      value: 3,
      next: {
        value: 4,
        next: null
      }
    }
  }
};

编写一个可以逐个输出链表元素的函数 printList(list)

使用两种方式实现:循环和递归。

哪个更好:用递归还是不用递归的?

解决方案

循环解法

基于循环的解法:

let list = {
  value: 1,
  next: {
    value: 2,
    next: {
      value: 3,
      next: {
        value: 4,
        next: null
      }
    }
  }
};

function printList(list) {
  let tmp = list;

  while (tmp) {
    alert(tmp.value);
    tmp = tmp.next;
  }

}

printList(list);

请注意,我们使用了一个临时变量 tmp 来遍历链表。从技术上讲,我们可以使用函数的入参 list 来代替:

function printList(list) {

  while(list) {
    alert(list.value);
    list = list.next;
  }

}

……但是这不够明智。未来我们可能想要扩展这个函数,使用这个链表做其他的事儿,如果我们修改了 list,那么我们就失去了这个能力。

说到好的变量命名,list 在这里是链表本身。代表它的第一个元素。它应该保持原样,这是清晰可靠的。

从另一个方面来说,tmp 是充当了完全遍历链表的角色,就像 for 循环中的 i 一样。

递归解法

printList(list) 的递归实现遵循一个简单的逻辑:为了输出链表,我们应该输出 list 的当前的元素,list.next 同理:

let list = {
  value: 1,
  next: {
    value: 2,
    next: {
      value: 3,
      next: {
        value: 4,
        next: null
      }
    }
  }
};

function printList(list) {

  alert(list.value); // 输出当前元素

  if (list.next) {
    printList(list.next); // 链表中其余部分同理
  }

}

printList(list);

哪个更好呢?

从技术上讲,循环更有效。这两种解法的做了同样的事儿,但循环不会为嵌套函数调用消耗资源。

另一方面,递归解法更简洁,有时更容易理解。

反向输出单链表

重要程度: 5

反向输出前一个任务 输出一个单链表 中的单链表。

使用两种解法:循环和递归。

解决方案

使用递归

递归逻辑在这稍微有点儿棘手。

我们需要先输出列表的其它元素,然后 输出当前的元素:

let list = {
  value: 1,
  next: {
    value: 2,
    next: {
      value: 3,
      next: {
        value: 4,
        next: null
      }
    }
  }
};

function printReverseList(list) {

  if (list.next) {
    printReverseList(list.next);
  }

  alert(list.value);
}

printReverseList(list);

使用循环

循环解法也比直接输出稍微复杂了点儿。

在这而没有什么方法可以获取 list 中的最后一个值。我们也不能“从后向前”读取。

因此,我们可以做的就是直接按顺序遍历每个元素,并把它们存到一个数组中,然后反向输出我们存储在数组中的元素:

let list = {
  value: 1,
  next: {
    value: 2,
    next: {
      value: 3,
      next: {
        value: 4,
        next: null
      }
    }
  }
};

function printReverseList(list) {
  let arr = [];
  let tmp = list;

  while (tmp) {
    arr.push(tmp.value);
    tmp = tmp.next;
  }

  for (let i = arr.length - 1; i >= 0; i--) {
    alert( arr[i] );
  }
}

printReverseList(list);

请注意,递归解法实际上也是这样做的:它顺着链表,记录每一个嵌套调用里链表的元素(在执行上下文堆栈里),然后输出它们。

Rest 参数与 Spread 语法

Rest 参数 ...

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

例如:

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

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

虽然这里不会因为传入“过多”的参数而报错。但是当然,在结果中只有前两个参数被计算进去了。

Rest 参数可以通过使用三个点 ... 并在后面跟着包含剩余参数的数组名称,来将它们包含在函数定义中。这些点的字面意思是“将剩余参数收集到一个数组中”。

例如,我们需要把所有的参数都放到数组 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

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

let str = "Hello";

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

Spread 语法内部使用了迭代器来收集元素,与 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); 来复制对象写起来要短得多。因此,只要情况允许,我们更喜欢使用它。

闭包

对于 forwhile 循环也是如此:

for (let i = 0; i < 3; i++) {
  // 变量 i 仅在这个 for 循环的内部可见
  alert(i); // 0,然后是 1,然后是 2
}

alert(i); // Error, no such variable

从视觉上看,let i 位于 {...} 之外。但是 for 构造在这里很特殊:在其中声明的变量被视为块的一部分。

词法环境

这是龙!

深入的技术讲解就在下面。

尽管我很想避免编程语言的一些底层细节,但是如果没有这些细节,它们就不完整,所以请准备开始学习吧!

为了使内容更清晰,这里将分步骤进行讲解。

Step 1. 变量

建议直接观看原文!!!!!!!!!!!

当代码要访问一个变量时 —— 首先会搜索内部词法环境,然后搜索外部环境,然后搜索更外部的环境,以此类推,直到全局词法环境。

如果在任何地方都找不到这个变量,那么在严格模式下就会报错(在非严格模式下,为了向下兼容,给未定义的变量赋值会创建一个全局变量)。

Counter 是独立的吗?

重要程度: 5

在这儿我们用相同的 makeCounter 函数创建了两个计数器(counters):countercounter2

它们是独立的吗?第二个 counter 会显示什么?0,12,3 还是其他?

function makeCounter() {
  let count = 0;

  return function() {
    return count++;
  };
}

let counter = makeCounter();
let counter2 = makeCounter();

alert( counter() ); // 0
alert( counter() ); // 1

alert( counter2() ); // ?
alert( counter2() ); // ?

解决方案

答案是:0,1。

函数 countercounter2 是通过 makeCounter 的不同调用创建的。

因此,它们具有独立的外部词法环境,每一个都有自己的 count

闭包 sum

重要程度: 4

编写一个像 sum(a)(b) = a+b 这样工作的 sum 函数。

是的,就是这种通过双括号的方式(并不是错误)。

举个例子:

sum(1)(2) = 3
sum(5)(-1) = 4

解决方案

为了使第二个括号有效,第一个(括号)必须返回一个函数。

就像这样:

function sum(a) {

  return function(b) {
    return a + b; // 从外部词法环境获得 "a"
  };

}

alert( sum(1)(2) ); // 3
alert( sum(5)(-1) ); // 4

变量可见吗?

重要程度: 4

下面这段代码的结果会是什么?

let x = 1;

function func() {
  console.log(x); // ?

  let x = 2;
}

func();

P.S. 这个任务有一个陷阱。解决方案并不明显。

解决方案

答案:error

你运行一下试试:

let x = 1;

function func() {
  console.log(x); // ReferenceError: Cannot access 'x' before initialization
  let x = 2;
}

func();

在这个例子中,我们可以观察到“不存在”的变量和“未初始化”的变量之间的特殊差异。

你可能已经在 闭包 中学过了,从程序执行进入代码块(或函数)的那一刻起,变量就开始进入“未初始化”状态。它一直保持未初始化状态,直至程序执行到相应的 let 语句。

换句话说,一个变量从技术的角度来讲是存在的,但是在 let 之前还不能使用。

下面的这段代码证实了这一点。

function func() {
  // 引擎从函数开始就知道局部变量 x,
  // 但是变量 x 一直处于“未初始化”(无法使用)的状态,直到结束 let(“死区”)
  // 因此答案是 error

  console.log(x); // ReferenceError: Cannot access 'x' before initialization

  let x = 2;
}

变量暂时无法使用的区域(从代码块的开始到 let)有时被称为“死区”。

通过函数筛选

重要程度: 5

我们有一个内建的数组方法 arr.filter(f)。它通过函数 f 过滤元素。如果它返回 true,那么该元素会被返回到结果数组中。

制造一系列“即用型”过滤器:

  • inBetween(a, b) —— 在 ab 之间或与它们相等(包括)。
  • inArray([...]) —— 包含在给定的数组中。

用法如下所示:

  • arr.filter(inBetween(3,6)) —— 只挑选范围在 3 到 6 的值。
  • arr.filter(inArray([1,2,3])) —— 只挑选与 [1,2,3] 中的元素匹配的元素。

例如:

/* .. inBetween 和 inArray 的代码 */
let arr = [1, 2, 3, 4, 5, 6, 7];

alert( arr.filter(inBetween(3, 6)) ); // 3,4,5,6

alert( arr.filter(inArray([1, 2, 10])) ); // 1,2

打开带有测试的沙箱。

解决方案

inBetween 筛选器

function inBetween(a, b) {
  return function(x) {
    return x >= a && x <= b;
  };
}

let arr = [1, 2, 3, 4, 5, 6, 7];
alert( arr.filter(inBetween(3, 6)) ); // 3,4,5,6

inArray 筛选器

function inArray(arr) {
  return function(x) {
    return arr.includes(x);
  };
}

let arr = [1, 2, 3, 4, 5, 6, 7];
alert( arr.filter(inArray([1, 2, 10])) ); // 1,2

按字段排序

重要程度: 5

我们有一组要排序的对象:

let users = [
  { name: "John", age: 20, surname: "Johnson" },
  { name: "Pete", age: 18, surname: "Peterson" },
  { name: "Ann", age: 19, surname: "Hathaway" }
];

通常的做法应该是这样的:

// 通过 name (Ann, John, Pete)
users.sort((a, b) => a.name > b.name ? 1 : -1);

// 通过 age (Pete, Ann, John)
users.sort((a, b) => a.age > b.age ? 1 : -1);

我们可以让它更加简洁吗,比如这样?

users.sort(byField('name'));
users.sort(byField('age'));

这样我们就只需要写 byField(fieldName),而不是写一个函数。

编写函数 byField 来实现这个需求。

打开带有测试的沙箱。

解决方案

function byField(fieldName){
  return (a, b) => a[fieldName] > b[fieldName] ? 1 : -1;
}

函数大军

重要程度: 5

下列的代码创建一个 shooters 数组。

每个函数都应该输出其编号。但好像出了点问题……

function makeArmy() {
  let shooters = [];

  let i = 0;
  while (i < 10) {
    let shooter = function() { // shooter 函数
      alert( i ); // 应该显示其编号
    };
    shooters.push(shooter);
    i++;
  }

  return shooters;
}

let army = makeArmy();

army[0](); // 编号为 0 的 shooter 值为 10
army[5](); // 编号为 5 的 shooter 值也是 10……
// ... 所有的 shooter 的值都是 10,而不是他们的编号 0, 1, 2, 3...

为什么所有的 shooter 显示同样的值?修改代码以让代码正常工作。

打开带有测试的沙箱。

解决方案

让我们检查一下 makeArmy 内部做了什么,那么答案就显而易见了。

  1. 它创建了一个空数组 shooters

    let shooters = [];
    
  2. 在循环中,通过 shooters.push(function...) 填充它(数组)。

    每个元素都是函数,所以数组看起来是这样的:

    shooters = [
      function () { alert(i); },
      function () { alert(i); },
      function () { alert(i); },
      function () { alert(i); },
      function () { alert(i); },
      function () { alert(i); },
      function () { alert(i); },
      function () { alert(i); },
      function () { alert(i); },
      function () { alert(i); }
    ];
    
  3. 该数组返回自函数。

随后,army[5]() 从数组中获得元素 army[5](函数)并调用。

为什么现在所有函数显示的都一样呢?

这是因为 shooter 函数内没有局部变量 i。当调用一个这样的函数时,i 是来自于外部词法环境的。

i 的值是什么呢?

如果我们查看一下源头:

function makeArmy() {
  ...
  let i = 0;
  while (i < 10) {
    let shooter = function() { // shooter 函数
      alert( i ); // 应该显示它自己的编号
    };
    ...
  }
  ...
}

……我们可以看到它存在于当前 makeArmy() 运行相关的词法环境中。但调用 army[5]() 时,makeArmy已经完运行完了,i 现在为结束时的值:10while 结束时)。

因此,所有的 shooter 获得的都是外部词法环境中的同一个值,即最后的 i=10

我们可以通过将变量定义移动到循环中来修复它:

function makeArmy() {

  let shooters = [];

  for(let i = 0; i < 10; i++) {
    let shooter = function() { // shooter 函数
      alert( i ); // 应该显示它自己的编号
    };
    shooters.push(shooter);
  }

  return shooters;
}

let army = makeArmy();

army[0](); // 0
army[5](); // 5

现在正常工作了,因为每次执行代码块 for (let i=0...) {...} 中的代码时,都会为其创建一个新的词法环境,其中具有对应的 i 值。

所以,现在 i 值的位置更近了(译注:指转到了更内部的词法环境)。现在它不是在 makeArmy() 的词法环境中,而是在与当前循环迭代相对应的词法环境中。这就是它为什么现在可以正常工作了。

这里我们把 while 改写为了 for

其他技巧也是可以的,让我们了解一下,以便更好地理解这个问题:

function makeArmy() {
  let shooters = [];

  let i = 0;
  while (i < 10) {
    let j = i;
    let shooter = function() { // shooter 函数
      alert( j ); // 应该显示当前的编号
    };
    shooters.push(shooter);
    i++;
  }

  return shooters;
}

let army = makeArmy();

army[0](); // 0
army[5](); // 5

whilefor 循环差不多,每次运行都会创建了一个新的词法环境。所以在这里我们能确保 shooter 能够获取正确的值。

我们复制 let j = i。这个操作创建了循环体局部变量 j,并将 i 的值复制给了它。原始值是按值传递的,所以实际上,我们获得了属于当前循环迭代的完全独立的 i 的副本。

使用沙箱的测试功能打开解决方案。

旧时的 "var"

“var” 没有块级作用域

var 声明的变量,不是函数作用域就是全局作用域。它们在代码块外也是可见的(译注:也就是说,var 声明的变量只有函数作用域和全局作用域,没有块级作用域)。

举个例子:

if (true) {
  var test = true; // 使用 "var" 而不是 "let"
}

alert(test); // true,变量在 if 结束后仍存在

“var” 允许重新声明

如果我们用 let 在同一作用域下将同一个变量声明两次,则会出现错误:

let user;
let user; // SyntaxError: 'user' has already been declared

“var” 声明的变量,可以在其声明语句前被使用

当函数开始的时候,就会处理 var 声明(脚本启动对应全局变量)。

换言之,var 声明的变量会在函数开头被定义,与它在代码中定义的位置无关(这里不考虑定义在嵌套函数中的情况)。

那么看一下这段代码:

function sayHi() {
  phrase = "Hello";

  alert(phrase);

  var phrase;
}
sayHi();
function sayHi() {
  phrase = "Hello"; // (*)

  if (false) {
    var phrase;
  }

  alert(phrase);
}
sayHi();

人们将这种行为称为“提升”(英文为 “hoisting” 或 “raising”),因为所有的 var 都被“提升”到了函数的顶部。

所以,在上面的例子中,if (false) 分支永远都不会执行,但没关系,它里面的 var 在函数刚开始时就被处理了,所以在执行 (*) 那行代码时,变量是存在的。

声明会被提升,但是赋值不会。

我们最好用例子来说明:

function sayHi() {
  alert(phrase);

  var phrase = "Hello";
}

sayHi();

var phrase = "Hello" 这行代码包含两个行为:

  1. 使用 var 声明变量
  2. 使用 = 给变量赋值。

声明在函数刚开始执行的时候(“提升”)就被处理了,但是赋值操作始终是在它出现的地方才起作用。所以这段代码实际上是这样工作的:

function sayHi() {
  var phrase; // 在函数刚开始时进行变量声明

  alert(phrase); // undefined

  phrase = "Hello"; // ……赋值 — 当程序执行到这一行时。
}

sayHi();

全局对象

全局对象提供可在任何地方使用的变量和函数。默认情况下,这些全局变量内置于语言或环境中。

在浏览器中,它的名字是 “window”,对 Node.js 而言,它的名字是 “global”,其它环境可能用的是别的名字。

最近,globalThis 被作为全局对象的标准名称加入到了 JavaScript 中,所有环境都应该支持该名称。在有些浏览器中,即 non-Chromium Edge,尚不支持 globalThis,但可以很容易地对其进行填充(polyfilled)。

假设我们的环境是浏览器,我们将在这儿使用 “window”。如果你的脚本可能会用来在其他环境中运行,则最好使用 globalThis

全局对象的所有属性都可以被直接访问:

alert("Hello");
// 等同于
window.alert("Hello");

在浏览器中,使用 var(而不是 let/const!)声明的全局函数和变量会成为全局对象的属性。

var gVar = 5;

alert(window.gVar); // 5(成为了全局对象的属性)

如果一个值非常重要,以至于你想使它在全局范围内可用,那么可以直接将其作为属性写入:

// 将当前用户信息全局化,以允许所有脚本访问它
window.currentUser = {
  name: "John"
};

// 代码中的另一个位置
alert(currentUser.name);  // John

// 或者,如果我们有一个名为 "currentUser" 的局部变量
// 从 window 显示地获取它(这是安全的!)
alert(window.currentUser.name); // John

函数对象,NFE

我们已经知道,在 JavaScript 中,函数就是值。

JavaScript 中的每个值都有一种类型,那么函数是什么类型呢?

在 JavaScript 中,函数就是对象。

一个容易理解的方式是把函数想象成可被调用的“行为对象(action object)”。我们不仅可以调用它们,还能把它们当作对象来处理:增/删属性,按引用传递等。

函数对象包含一些便于使用的属性。

属性 “name”

比如,一个函数的名字可以通过属性 “name” 来访问:

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

alert(sayHi.name); // sayHi

自定义属性

我们也可以添加我们自己的属性。

这里我们添加了 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 有两个特殊的地方,这就是添加它的原因:

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

例如,下面的函数 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 了,所以使用外部变量。而当调用时,外部的 sayHinull

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

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

let sayHi = function func(who) {
  if (who) {
    alert(`Hello, ${who}`);
  } else {
    func("Guest"); // 现在一切正常
  }
};

let welcome = sayHi;
sayHi = null;

welcome(); // Hello, Guest(嵌套调用有效)

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

外部代码仍然有该函数的 sayHiwelcome 变量。而且 func 是一个“内部函数名”,可用于函数在自身内部进行自调用。

函数声明没有这个东西

这里所讲的“内部名”特性只针对函数表达式,而不是函数声明。对于函数声明,没有用来添加“内部”名的语法。

有时,当我们需要一个可靠的内部名时,这就成为了你把函数声明重写成函数表达式的理由了。

任意数量的括号求和

重要程度: 2

写一个函数 sum,它有这样的功能:

sum(1)(2) == 3; // 1 + 2
sum(1)(2)(3) == 6; // 1 + 2 + 3
sum(5)(-1)(2) == 6
sum(6)(-1)(-2)(-3) == 0
sum(0)(1)(2)(3)(4)(5) == 15

P.S. 提示:你可能需要创建自定义对象来为你的函数提供基本类型转换。

打开带有测试的沙箱。

解决方案

  1. 为了使整个程序无论如何都能正常工作,sum 的结果必须是函数。
  2. 这个函数必须将两次调用之间的当前值保存在内存中。
  3. 根据这个题目,当函数被用于 == 比较时必须转换成数字。函数是对象,所以转换规则会按照 对象 — 原始值转换 章节所讲的进行,我们可以提供自己的方法来返回数字。

代码如下:

function sum(a) {

  let currentSum = a;

  function f(b) {
    currentSum += b;
    return f;
  }

  f.toString = function() {
    return currentSum;
  };

  return f;
}

alert( sum(1)(2) ); // 3
alert( sum(5)(-1)(2) ); // 6
alert( sum(6)(-1)(-2)(-3) ); // 0
alert( sum(0)(1)(2)(3)(4)(5) ); // 15

请注意 sum 函数只工作一次,它返回了函数 f

然后,接下来的每一次子调用,f 都会把自己的参数加到求和 currentSum 上,然后 f 自身。

f 的最后一行没有递归。

递归是这样子的:

function f(b) {
  currentSum += b;
  return f(); // <-- 递归调用
}

在我们的例子中,只是返回了函数,并没有调用它:

function f(b) {
  currentSum += b;
  return f; // <-- 没有调用自己,只是返回了自己
}

这个 f 会被用于下一次调用,然后再次返回自己,按照需要重复。然后,当它被用做数字或字符串时 —— toString 返回 currentSum。我们也可以使用 Symbol.toPrimitive 或者 valueOf 来实现转换。

function sum(a) {

  let currentSum = a;

  function f(b) {
    currentSum += b;
    return f;
  }

  f.toString = function() {
    return currentSum;
  };

  return f;
}

"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 的这种特性看起来有点奇怪,不过在实际中却非常实用。

想象以下我们必须通过一个字符串来创建一个函数。在编写脚本时我们不会知道该函数的代码(这也就是为什么我们不用常规方法创建函数),但在执行过程中会知道了。我们可能会从服务器或其他来源获取它。

我们的新函数需要和主脚本进行交互。

如果这个函数能够访问外部(outer)变量会怎么样?

问题在于,在将 JavaScript 发布到生产环境之前,需要使用 压缩程序(minifier) 对其进行压缩 —— 一个特殊的程序,通过删除多余的注释和空格等压缩代码 —— 更重要的是,将局部变量命名为较短的变量。

例如,如果一个函数有 let userName,压缩程序会把它替换为 let a(如果 a 已被占用了,那就使用其他字符),剩余的局部变量也会被进行类似的替换。一般来说这样的替换是安全的,毕竟这些变量是函数内的局部变量,函数外的任何东西都无法访问它。在函数内部,压缩程序会替换所有使用了使用了这些变量的代码。压缩程序很聪明,它会分析代码的结构,而不是呆板地查找然后替换,因此它不会“破坏”你的程序。

但是在这种情况下,如果使 new Function 可以访问自身函数以外的变量,它也很有可能无法找到重命名的 userName,这是因为新函数的创建发生在代码压缩以后,变量名已经被替换了。

即使我们可以在 new Function 中访问外部词法环境,我们也会受挫于压缩程序。

此外,这样的代码在架构上很差并且容易出错。

当我们需要向 new Function 创建出的新函数传递数据时,我们必须显式地通过参数进行传递。

总结

语法:

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

由于历史原因,参数也可以按逗号分隔符的形式给出。

以下三种声明的含义相同:

new Function('a', 'b', 'return a + b'); // 基础语法
new Function('a,b', 'return a + b'); // 逗号分隔
new Function('a , b', 'return a + b'); // 逗号和空格分隔

使用 new Function 创建的函数,它的 [[Environment]] 指向全局词法环境,而不是函数所在的外部词法环境。因此,我们不能在 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;

  • arg1arg2

    要传入被执行函数(或代码字符串)的参数列表(IE9 以下不支持)

例如,在下面这个示例中,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(也就是说函数没有返回任何结果),所以实际上什么也没有调度。

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 就能确保延时的固定(这里是 100 毫秒)。

这是因为下一次调用是在前一次调用完成时再调度的。

垃圾回收和 setInterval/setTimeout 回调(callback)

当一个函数传入 setInterval/setTimeout 时,将为其创建一个内部引用,并保存在调度程序中。这样,即使这个函数没有其他引用,也能防止垃圾回收器(GC)将其回收。

// 在调度程序调用这个函数之前,这个函数将一直存在于内存中
setTimeout(function() {...}, 100);

对于 setInterval,传入的函数也是一直存在于内存中,直到 clearInterval 被调用。

这里还要提到一个副作用。如果函数引用了外部变量(译注:闭包),那么只要这个函数还存在,外部变量也会随之存在。它们可能比函数本身占用更多的内存。因此,当我们不再需要调度函数时,最好取消它,即使这是个(占用内存)很小的函数。

零延时的 setTimeout

这儿有一种特殊的用法:setTimeout(func, 0),或者仅仅是 setTimeout(func)

这样调度可以让 func 尽快执行。但是只有在当前正在执行的脚本执行完成后,调度程序才会调用它。

也就是说,该函数被调度在当前脚本执行完成“之后”立即执行。

例如,下面这段代码会先输出 “Hello”,然后立即输出 “World”:

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

alert("Hello");

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

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

setTimeout 会显示什么?

重要程度: 5

下面代码中使用 setTimeout 调度了一个调用,然后需要运行一个计算量很大的 for 循环,这段运算耗时超过 100 毫秒。

调度的函数会在何时运行?

  1. 循环执行完成后。
  2. 循环执行前。
  3. 循环刚开始时。

alert 会显示什么?

let i = 0;

setTimeout(() => alert(i), 100); // ?

// 假设这段代码的运行时间 >100ms
for(let j = 0; j < 100000000; j++) {
  i++;
}

解决方案

任何 setTimeout 都只会在当前代码执行完毕之后才会执行。

所以 i 的取值为:100000000

let i = 0;

setTimeout(() => alert(i), 100); // 100000000

// 假设这段代码的运行时间 >100ms
for(let j = 0; j < 100000000; j++) {
  i++;
}

装饰者模式和转发,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) ); // 一样的

alert( slow(2) ); // slow(2) 被缓存下来了
alert( "Again: " + slow(2) ); // 和前面一行结果相同

在上面的代码中,cachingDecorator 是一个 装饰者(decorator):一个特殊的函数,它接受另一个函数并改变它的行为。

其思想是,我们可以为任何函数调用 cachingDecorator,它将返回缓存包装器。这很棒啊,因为我们有很多函数可以使用这样的特性,而我们需要做的就是将 cachingDecorator 应用于它们。

通过将缓存与主函数代码分开,我们还可以使主函数代码变得更简单。

cachingDecorator(func) 的结果是一个“包装器”:function(x)func(x) 的调用“包装”到缓存逻辑中:

从外部代码来看,包装的 slow 函数执行的仍然是与之前相同的操作。它只是在其行为上添加了缓存功能。

总而言之,使用分离的 cachingDecorator 而不是改变 slow 本身的代码有几个好处:

  • cachingDecorator 是可重用的。我们可以将它应用于另一个函数。
  • 缓存逻辑是独立的,它没有增加 slow 本身的复杂性(如果有的话)。
  • 如果需要,我们可以组合多个装饰者(其他装饰者将遵循同样的逻辑)。

使用 “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.call(context, …args),它允许调用一个显式设置 this 的函数。

语法如下:

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

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

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

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

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

例如,在下面的代码中,我们在不同对象的上下文中调用 sayHisayHi.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 是如何被传递的:

  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 的语法是:

func.apply(context, args)

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

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

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

func.call(context, ...args); // 使用 spread 语法将数组作为列表传递
func.apply(context, args);   // 与使用 call 相同

这里只有很小的区别:

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

因此,当我们期望可迭代对象时,使用 call,当我们期望类数组对象时,使用 apply

对于即可迭代又是类数组的对象,例如一个真正的数组,我们使用 callapply 均可,但是 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.callarguments 的上下文中运行它。

它为什么有效?

那是因为原生方法 arr.join(glue) 的内部算法非常简单。

从规范中几乎“按原样”解释如下:

  1. glue 成为第一个参数,如果没有参数,则使用逗号 ","
  2. result 为空字符串。
  3. this[0] 附加到 result
  4. 附加 gluethis[1]
  5. 附加 gluethis[2]
  6. ……以此类推,直到 this.length 项目被粘在一起。
  7. 返回 result

因此,从技术上讲,它需要 this 并将 this[0]this[1] ……等 join 在一起。它的编写方式是故意允许任何类数组的 this 的(不是巧合,很多方法都遵循这种做法)。这就是为什么它也可以和 this=arguments 一起使用。

间谍装饰者

重要程度: 5

创建一个装饰者 spy(func),它应该返回一个包装器,该包装器将所有对函数的调用保存在其 calls 属性中。

每个调用都保存为一个参数数组。

例如:

function work(a, b) {
  alert( a + b ); // work 是一个任意的函数或方法
}

work = spy(work);

work(1, 2); // 3
work(4, 5); // 9

for (let args of work.calls) {
  alert( 'call:' + args.join() ); // "call:1,2", "call:4,5"
}

P.S. 该装饰者有时对于单元测试很有用。它的高级形式是 Sinon.JS 库中的 sinon.spy

打开带有测试的沙箱。

解决方案

spy(f) 返回的包装器应存储所有参数,然后使用 f.apply 转发调用。

function spy(func) {

  function wrapper(...args) {
    // using ...args instead of arguments to store "real" array in wrapper.calls
    wrapper.calls.push(args);
    return func.apply(this, args);
  }

  wrapper.calls = [];

  return wrapper;
}

函数绑定

丢失 “this”

我们已经看到了丢失 this 的例子。一旦方法被传递到与对象分开的某个地方 —— this 就丢失。

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

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 调用就像绑定了 thisfunc

举个例子,这里的 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 = {
  sayHi() { alert("Another user in setTimeout!"); }
};

(*) 行,我们取了方法 user.sayHi 并将其绑定到 usersayHi 是一个“绑定后(bound)”的方法,它可以被单独调用,也可以被传递给 setTimeout —— 都没关系,函数上下文都会是正确的。

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

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

为什么我们通常会创建一个偏函数?

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

另一方面,当我们有一个非常通用的函数,并希望有一个通用型更低的该函数的变体时,偏函数会非常有用。

例如,我们有一个函数 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"

修复丢失了 "this" 的函数

重要程度: 5

下面代码中对 askPassword() 的调用将会检查 password,然后基于结果调用 user.loginOk/loginFail

但是它导致了一个错误。为什么?

修改高亮的行,以使所有内容都能正常工作(其它行不用修改)。

function askPassword(ok, fail) {
  let password = prompt("Password?", '');
  if (password == "rockstar") ok();
  else fail();
}

let user = {
  name: 'John',

  loginOk() {
    alert(`${this.name} logged in`);
  },

  loginFail() {
    alert(`${this.name} failed to log in`);
  },

};

askPassword(user.loginOk, user.loginFail);

解决方案

发生了错误是因为 ask 获得的是没有绑定对象的 loginOk/loginFail 函数。

ask 调用这两个函数时,它们自然会认定 this=undefined

让我们 bind 上下文:

function askPassword(ok, fail) {
  let password = prompt("Password?", '');
  if (password == "rockstar") ok();
  else fail();
}

let user = {
  name: 'John',

  loginOk() {
    alert(`${this.name} logged in`);
  },

  loginFail() {
    alert(`${this.name} failed to log in`);
  },

};

askPassword(user.loginOk.bind(user), user.loginFail.bind(user));

现在它能正常工作了。

另一个可替换解决方案是:

//...
askPassword(() => user.loginOk(), () => user.loginFail());

通常这也能正常工作,也看起来挺好的。

但是可能会在更复杂的场景下失效,例如变量 user 在调用 askPassword 之后但在访问者应答和调用 () => user.loginOk() 之前被修改。

深入理解箭头函数

箭头函数没有 “this”

正如我们在 对象方法,"this" 一章中所学到的,箭头函数没有 this。如果访问 this,则会从外部获取。

例如,我们可以使用它在对象方法内部进行迭代:

let group = {
  title: "Our Group",
  students: ["John", "Pete", "Alice"],

  showList() {
    this.students.forEach(
      student => alert(this.title + ': ' + student)
    );
  }
};

group.showList();

这里 forEach 中使用了箭头函数,所以其中的 this.title 其实和外部方法 showList 的完全一样。那就是:group.title

不能对箭头函数进行 new 操作

不具有 this 自然也就意味着另一个限制:箭头函数不能用作构造器(constructor)。不能用 new 调用它们。

箭头函数没有 “arguments”

箭头函数也没有 arguments 变量。

当我们需要使用当前的 thisarguments 转发一个调用时,这对装饰者(decorators)来说非常有用。

例如,defer(f, ms) 获得了一个函数,并返回一个包装器,该包装器将调用延迟 ms 毫秒:

function defer(f, ms) {
  return function() {
    setTimeout(() => f.apply(this, arguments), ms)
  };
}

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

let sayHiDeferred = defer(sayHi, 2000);
sayHiDeferred("John"); // 2 秒后显示:Hello, John

不用箭头函数的话,可以这么写:

function defer(f, ms) {
  return function(...args) {
    let ctx = this;
    setTimeout(function() {
      return f.apply(ctx, args);
    }, ms);
  };
}

在这里,我们必须创建额外的变量 argsctx,以便 setTimeout 内部的函数可以获取它们。

总结

箭头函数:

  • 没有 this
  • 没有 arguments
  • 不能使用 new 进行调用
  • 它们也没有 super,但目前我们还没有学到它。我们将在 类继承 一章中学习它。

这是因为,箭头函数是针对那些没有自己的“上下文”,但在当前上下文中起作用的短代码的。并且箭头函数确实在这种使用场景中大放异彩。