递归和堆栈
...
Rest参数和Spread语法
在 JavaScript 中,很多内建函数都支持传入任意数量的参数。
例如:
Math.max(arg1, arg2, ..., argN)
—— 返回参数中的最大值。Object.assign(dest, src1, ..., srcN)
—— 依次将属性从src1..N
复制到dest
。- ……等。
在本章中,我们将学习如何编写支持传入任意数量参数的函数,以及如何将数组作为参数传递给这类函数。
Rest参数 ...
在 JavaScript 中,无论函数是如何定义的,你都可以在调用它时传入任意数量的参数。
虽然这里这个函数不会因为传入过多的参数而报错。但是,当然,只有前两个参数被求和了。
我们可以在函数定义中声明一个数组来收集参数。语法是这样的:...变量名
,这将会声明一个数组并指定其名称,其中存有剩余的参数。这三个点的语义就是“收集剩余的参数并存进指定数组中”。
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
并不属于箭头函数,而是属于箭头函数外部的“普通”函数。
我们已经知道,箭头函数没有自身的this
。现在我们知道了它们也没有特殊的arguments
对象。
Spread 语法
我们刚刚看到了如何从参数列表中获取数组。
有时候我们也需要做与之相反的事。
例如,内建函数 Math.max 会返回参数中最大的值
如果我们有一个数组 [3, 5, 1]
,我们该如何用它调用 Math.max
呢?
直接“原样”传入这个数组是不会奏效的,因为 Math.max
期望的是列表形式的数值型参数,而不是一个数组
毫无疑问,我们不能手动地去一一设置参数 Math.max(arg[0], arg[1], arg[2])
,因为我们不确定这儿有多少个。在代码执行时,参数数组中可能有很多个元素,也可能一个都没有。而且,这样的代码也很不优雅。
Spread 语法 可以解决这个问题!它看起来和 rest 参数很像,也使用 ...
,但是二者的用途完全相反。
当在函数调用中使用 ...arr
时,它会把可迭代对象 arr
“展开”到参数列表中。
Spread 语法内部使用了迭代器来收集元素,与 for..of
的方式相同。
因此,对于一个字符串,for..of
会逐个返回该字符串中的字符,...str
也同理会得到 "H","e","l","l","o"
这样的结果。随后,字符列表被传递给数组初始化器 [...str]
。
对于这个特定任务,我们还可以使用 Array.from
来实现,因为该方法会将一个可迭代对象(如字符串)转换为数组
不过 Array.from(obj)
和 [...obj]
存在一个细微的差别:
Array.from
适用于类数组对象也适用于可迭代对象。- Spread 语法只适用于可迭代对象。
因此,对于将一些“东西”转换为数组的任务,Array.from
往往更通用。
复制 array/object
还记得我们 之前讲过的 Object.assign()
吗?
使用 spread 语法也可以做同样的事情(译注:也就是进行浅拷贝)。
let arrCopy = [...arr]; // 将数组 spread 到参数列表中
// 然后将结果放到一个新数组
let objCopy = { ...obj }; // 将对象 spread 到参数列表中
// 然后将结果返回到一个新对象
这种方式比使用 let arrCopy = Object.assign([], arr)
复制数组,或使用 let objCopy = Object.assign({}, obj)
复制对象来说更为简便。因此,只要情况允许,我们倾向于使用它。
变量作用域,闭包
JavaScript 是一种非常面向函数的语言。它给了我们很大的自由度。在 JavaScript 中,我们可以随时创建函数,可以将函数作为参数传递给另一个函数,并在完全不同的代码位置进行调用。
我们已经知道函数可以访问其外部的变量。
但是,如果在函数被创建之后,外部变量发生了变化会怎样?函数会获得新值还是旧值?
如果将函数作为参数(argument)传递并在代码中的另一个位置调用它,该函数将访问的是新位置的外部变量吗?
让我们通过本文来学习这些相关知识,以了解在这些场景以及更复杂的场景下到底会发生什么。
我们将在这探讨一下
let/const
在 JavaScript 中,有三种声明变量的方式:let
,const
(现代方式),var
(过去留下来的方式)。
- 在本文的示例中,我们将使用
let
声明变量。- 用
const
声明的变量的行为也相同(译注:与let
在作用域等特性上是相同的),因此,本文也涉及用const
进行变量声明。- 旧的
var
与上面两个有着明显的区别,我们将在 老旧的 "var" 中详细介绍。
代码块
如果在代码块 {...}
内声明了一个变量,那么这个变量只在该代码块内可见。我们可以使用它来隔离一段代码,该段代码执行自己的任务,并使用仅属于自己的变量
{
// 使用在代码块外不可见的局部变量做一些工作
let message = "Hello"; // 只在此代码块内可见
alert(message); // Hello
}
alert(message); // Error: message is not defined
如果没有代码块则会报错
请注意,如果我们使用let
对已存在的变量进行重复声明,如果对应的变量没有单独的代码块,则会出现错误
对于 if
,for
和 while
等,在 {...}
中声明的变量也仅在内部可见对于 for
和 while
循环也是如此
从视觉上看,let i
位于 {...}
之外。但是 for
构造在这里很特殊:在其中声明的变量被视为块的一部分。
嵌套函数
如果一个函数是在另一个函数中创建的,该函数就被称为“嵌套”函数。
在 JavaScript 中很容易实现这一点。
我们可以使用嵌套来组织代码
这里创建的 嵌套 函数 getFullName()
是为了更加方便。它可以访问外部变量,因此可以返回全名。嵌套函数在 JavaScript 中很常见。
更有意思的是,可以返回一个嵌套函数:作为一个新对象的属性或作为结果返回。之后可以在其他地方使用。不论在哪里调用,它仍然可以访问相同的外部变量。
下面的 makeCounter
创建了一个 “counter” 函数,该函数在每次调用时返回下一个数字:
function makeCounter() {
let count = 0;
return function() {
return count++;
};
}
let counter = makeCounter();
alert( counter() ); // 0
alert( counter() ); // 1
alert( counter() ); // 2
尽管很简单,但稍加变型就具有很强的实际用途,比如,用作 随机数生成器 以生成用于自动化测试的随机数值。
这是如何运作的呢?如果我们创建多个计数器,它们会是独立的吗?这里的变量是怎么回事?
理解这些内容对于掌握 JavaScript 的整体知识很有帮助,并且对于应对更复杂的场景也很有益处。因此,让我们继续深入探究。
词法环境
前方高能!
一大波深入的技术讲解即将到来。
尽管我很想避免编程语言的一些底层细节,但是如果没有它们,我们就无法完整地理解词法作用域,所以我们这就开始吧!
Step 1.变量
在 JavaScript 中,每个运行的函数,代码块 {...}
以及整个脚本,都有一个被称为 词法环境(Lexical Environment) 的内部(隐藏)的关联对象。
词法环境对象由两部分组成:
- 环境记录(Environment Record) —— 一个存储所有局部变量作为其属性(包括一些其他信息,例如
this
的值)的对象。 - 对 外部词法环境 的引用,与外部代码相关联。
词法环境是一个规范对象
“词法环境”是一个规范对象(specification object):它只存在于 语言规范 的“理论”层面,用于描述事物是如何工作的。我们无法在代码中获取该对象并直接对其进行操作。
但 JavaScript 引擎同样可以优化它,比如清除未被使用的变量以节省内存和执行其他内部技巧等,但显性行为应该是和上述的无差。
Step 2.函数声明
一个函数其实也是一个值,就像变量一样。
不同之处在于函数声明的初始化会被立即完成。
当创建了一个词法环境(Lexical Environment)时,函数声明会立即变为即用型函数(不像 let
那样直到声明处才可用)。
正常来说,这种行为仅适用于函数声明,而不适用于我们将函数分配给变量的函数表达式,例如 let say = function(name)...
。
Step 3.内部和外部的词法环境
在一个函数运行时,在调用刚开始时,会自动创建一个新的词法环境以存储这个调用的局部变量和参数。
在这个函数调用期间,我们有两个词法环境:内部一个(用于函数调用)和外部一个(全局):
- 内部词法环境与
say
的当前执行相对应。它具有一个单独的属性:name
,函数的参数。我们调用的是say("John")
,所以name
的值为"John"
。 - 外部词法环境是全局词法环境。它具有
phrase
变量和函数本身。
内部词法环境引用了outer
。
当代码要访问一个变量时 —— 首先会搜索内部词法环境,然后搜索外部环境,然后搜索更外部的环境,以此类推,直到全局词法环境。
如果在任何地方都找不到这个变量,那么在严格模式下就会报错(在非严格模式下,为了向下兼容,给未定义的变量赋值会创建一个全局变量)。
Step 4.返回函数
让我们回到 makeCounter
这个例子。
在每次 makeCounter()
调用的开始,都会创建一个新的词法环境对象,以存储该 makeCounter
运行时的变量。
不同的是,在执行 makeCounter()
的过程中创建了一个仅占一行的嵌套函数:return count++
。我们尚未运行它,仅创建了它。
所有的函数在“诞生”时都会记住创建它们的词法环境。从技术上讲,这里没有什么魔法:所有函数都有名为 [[Environment]]
的隐藏属性,该属性保存了对创建该函数的词法环境的引用。
因此,counter.[[Environment]]
有对 {count: 0}
词法环境的引用。这就是函数记住它创建于何处的方式,与函数被在哪儿调用无关。[[Environment]]
引用在函数创建时被设置并永久保存。
稍后,当调用 counter()
时,会为该调用创建一个新的词法环境,并且其外部词法环境引用获取于 counter.[[Environment]]
现在,当 counter()
中的代码查找 count
变量时,它首先搜索自己的词法环境(为空,因为那里没有局部变量),然后是外部 makeCounter()
的词法环境,并且在哪里找到就在哪里修改。
在变量所在的词法环境中更新变量。
闭包
开发者通常应该都知道“闭包”这个通用的编程术语。
闭包 是指一个函数可以记住其外部变量并可以访问这些变量。在某些编程语言中,这是不可能的,或者应该以一种特殊的方式编写函数来实现。但如上所述,在 JavaScript 中,所有函数都是天生闭包的(只有一个例外,将在 "new Function" 语法 中讲到)。
也就是说:JavaScript 中的函数会自动通过隐藏的[[Environment]]
属性记住创建它们的位置,所以它们都可以访问外部变量。
在面试时,前端开发者通常会被问到“什么是闭包?”,正确的回答应该是闭包的定义,并解释清楚为什么 JavaScript 中的所有函数都是闭包的,以及可能的关于[[Environment]]
属性和词法环境原理的技术细节。
垃圾收集
通常,函数调用完成后,会将词法环境和其中的所有变量从内存中删除。因为现在没有任何对它们的引用了。与 JavaScript 中的任何其他对象一样,词法环境仅在可达时才会被保留在内存中。
但是,如果有一个嵌套的函数在函数结束后仍可达,则它将具有引用词法环境的 [[Environment]]
属性。
请注意,如果多次调用 f()
,并且返回的函数被保存,那么所有相应的词法环境对象也会保留在内存中。
当词法环境对象变得不可达时,它就会死去(就像其他任何对象一样)。换句话说,它仅在至少有一个嵌套函数引用它时才存在。
实际开发中的优化
正如我们所看到的,理论上当函数可达时,它外部的所有变量也都将存在。
但在实际中,JavaScript 引擎会试图优化它。它们会分析变量的使用情况,如果从代码中可以明显看出有未使用的外部变量,那么就会将其删除。
在 V8(Chrome,Edge,Opera)中的一个重要的副作用是,此类变量在调试中将不可用。
打开 Chrome 浏览器的开发者工具,并尝试运行下面的代码。
当代码执行暂停时,在控制台中输入 alert(value)
。
function f() {
let value = Math.random();
function g() {
debugger; // 在 Console 中:输入 alert(value); No such variable!
}
return g;
}
let g = f();
g();
正如你所见的 —— No such variable! 理论上,它应该是可以访问的,但引擎把它优化掉了。
这可能会导致有趣的(如果不是那么耗时的)调试问题。其中之一 —— 我们可以看到的是一个同名的外部变量,而不是预期的变量
V8 引擎的这个特性你真的应该知道。如果你要使用 Chrome/Edge/Opera 进行代码调试,迟早会遇到这样的问题。
这不是调试器的 bug,而是 V8 的一个特别的特性。也许以后会被修改。你始终可以通过运行本文中的示例来进行检查。
老旧的 var
本文用于帮助理解旧脚本
本文所讲的内容对于帮助理解旧脚本很有用。
但这不是我们编写新代码的方式。
var
声明与 let
相似。大部分情况下,我们可以用 let
代替 var
或者 var
代替 let
,都能达到预期的效果
但实际上 var
却是一头非常不同的,源自远古时代的怪兽。在现代脚本中一般不再使用它,但它仍然潜伏在旧脚本中。
var 没有块级作用域
用 var
声明的变量,不是函数作用域就是全局作用域。它们在代码块外也是可见的(译注:也就是说,var
声明的变量只有函数作用域和全局作用域,没有块级作用域)。
if (true) {
var test = true; // 使用 "var" 而不是 "let"
}
alert(test); // true,变量在 if 结束后仍存在
由于 var
会忽略代码块,因此我们有了一个全局变量 test
。
对于循环也是这样的,var
声明的变量没有块级作用域也没有循环局部作用域
如果一个代码块位于函数内部,那么 var
声明的变量的作用域将为函数作用域
可以看到,var
穿透了 if
,for
和其它代码块。这是因为在早期的 JavaScript 中,块没有词法环境,而 var
就是这个时期的代表之一。
var 允许重新声明
使用 var
,我们可以重复声明一个变量,不管多少次都行。如果我们对一个已经声明的变量使用 var
,这条新的声明语句会被忽略
var 声明的变量,可以在其声明语句前就被使用
当函数开始的时候,就会处理 var
声明(脚本启动对应全局变量)。
换言之,var
声明的变量会在函数开头被定义,与它在代码中定义的位置无关(这里不考虑定义在嵌套函数中的情况)。
……甚至与这种情况也一样(记住,代码块是会被忽略的)
function sayHi() {
phrase = "Hello"; // (*)
if (false) {
var phrase;
}
alert(phrase);
}
sayHi();
人们将这种行为称为“提升”(英文为 “hoisting” 或 “raising”),因为所有的 var
都被“提升”到了函数的顶部。
所以,在上面的例子中,if (false)
分支永远都不会执行,但没关系,它里面的 var
在函数刚开始时就被处理了,所以在执行 (*)
那行代码时,变量是存在的。
声明会被提升,但是赋值不会。
var phrase = "Hello"
这行代码包含两个行为:
- 使用
var
声明变量 - 使用
=
给变量赋值。
因为所有的var
声明都是在函数开头处理的,我们可以在任何地方引用它们。但是在它们被赋值之前都是 undefined。
IIFE
在之前,JavaScript 中只有 var
这一种声明变量的方式,并且这种方式声明的变量没有块级作用域,程序员们就发明了一种模仿块级作用域的方法。这种方法被称为“立即调用函数表达式”(immediately-invoked function expressions,IIFE)。
如今,我们不应该再使用 IIFE 了,但是你可以在旧脚本中找到它们。
(function() {
var message = "Hello";
alert(message); // Hello
})();
这里,创建了一个函数表达式并立即调用。因此,代码立即执行并拥有了自己的私有变量。
函数表达式被括号 (function {...})
包裹起来,因为当 JavaScript 引擎在主代码中遇到 "function"
时,它会把它当成一个函数声明的开始。但函数声明必须有一个函数名,即使我们说:“好吧,那我们加一个名称吧”,但它仍然不工作,因为 JavaScript 不允许立即调用函数声明:
// 下面的括号会导致语法错误
function go() {
}(); // <-- 不能立即调用函数声明
因此,需要使用圆括号把该函数表达式包起来,以告诉 JavaScript,这个函数是在另一个表达式的上下文中创建的,因此它是一个函数表达式:它不需要函数名,可以立即调用。
// 创建 IIFE 的方法
(function() {
alert("Parentheses around the function");
})();
(function() {
alert("Parentheses around the whole thing");
}());
!function() {
alert("Bitwise NOT operator starts the expression");
}();
+function() {
alert("Unary plus starts the expression");
}();
在上面的所有情况中,我们都声明了一个函数表达式并立即运行它。请再注意一下:如今我们没有理由来编写这样的代码。
全局对象
全局对象提供可在任何地方使用的变量和函数。默认情况下,这些全局变量内建于语言或环境中。
在浏览器中,它的名字是 “window”,对 Node.js 而言,它的名字是 “global”,其它环境可能用的是别的名字。
最近,globalThis
被作为全局对象的标准名称加入到了 JavaScript 中,所有环境都应该支持该名称。所有主流浏览器都支持它。
假设我们的环境是浏览器,我们将在这儿使用 “window”。如果你的脚本可能会用来在其他环境中运行,则最好使用 globalThis
。
全局对象的所有属性都可以被直接访问
alert("Hello");
// 等同于
window.alert("Hello");
在浏览器中,使用 var
(而不是 let/const
!)声明的全局函数和变量会成为全局对象的属性。
函数声明(特指在主代码流中具有 function
关键字的语句,而不是函数表达式)也有这样的效果。
请不要依赖它!这种行为是出于兼容性而存在的。现代脚本使用 JavaScript modules 所以不会发生这种事情。
如果一个值非常重要,以至于你想使它在全局范围内可用,那么可以直接将其作为属性写入
// 将当前用户信息全局化,以允许所有脚本访问它
window.currentUser = {
name: "John"
};
// 代码中的另一个位置
alert(currentUser.name); // John
// 或者,如果我们有一个名为 "currentUser" 的局部变量
// 从 window 显式地获取它(这是安全的!)
alert(window.currentUser.name); // John
也就是说,一般不建议使用全局变量。全局变量应尽可能的少。与使用外部变量或全局变量相比,函数获取“输入”变量并产生特定“输出”的代码设计更加清晰,不易出错且更易于测试。
使用 polyfills
我们使用全局对象来测试对现代语言功能的支持。
例如,测试是否存在内建的 Promise
对象(在版本特别旧的浏览器中不存在)
如果没有(例如,我们使用的是旧版浏览器),那么我们可以创建 “polyfills”:添加环境不支持但在现代标准中存在的功能。
函数对象 NFE
我们已经知道,在 JavaScript 中,函数也是一个值。
而 JavaScript 中的每个值都有一种类型,那么函数是什么类型呢?
在 JavaScript 中,函数的类型是对象。
一个容易理解的方式是把函数想象成可被调用的“行为对象(action object)”。我们不仅可以调用它们,还能把它们当作对象来处理:增/删属性,按引用传递等。
属性 name
函数对象包含一些便于使用的属性。
比如,一个函数的名字可以通过属性 “name” 来访问
更有趣的是,名称赋值的逻辑很智能。即使函数被创建时没有名字,名称赋值的逻辑也能给它赋予一个正确的名字,然后进行赋值
当以默认值的方式完成了赋值时,它也有效
function f(sayHi = function() {}) {
alert(sayHi.name); // sayHi(生效了!)
}
f();
规范中把这种特性叫做「上下文命名」。如果函数自己没有提供,那么在赋值中,会根据上下文来推测一个。
这没有什么神奇的。有时会出现无法推测名字的情况。此时,属性 name
会是空,像这样
// 函数是在数组中创建的
let arr = [function() {}];
alert( arr[0].name ); // <空字符串>
// 引擎无法设置正确的名字,所以没有值
属性 length
还有另一个内建属性 “length”,它返回函数入参的个数
rest 参数不参与计数。
属性 length
有时在操作其它函数的函数中用于做 内省/运行时检查(introspection)。
比如,下面的代码中函数 ask
接受一个询问答案的参数 question
和可能包含任意数量 handler
的参数 ...handlers
。
当用户提供了自己的答案后,函数会调用那些 handlers
。我们可以传入两种 handlers
:
- 一种是无参函数,它仅在用户给出肯定回答时被调用。
- 一种是有参函数,它在两种情况都会被调用,并且返回一个答案。
为了正确地调用handler
,我们需要检查handler.length
属性。
我们的想法是,我们用一个简单的无参数的handler
语法来处理积极的回答(最常见的变体),但也要能够提供通用的 handler
这就是所谓的 多态性 的一个例子 —— 根据参数的类型,或者根据在我们的具体情景下的length
来做不同的处理。这种思想在 JavaScript 的库里有应用。
自定义属性
我们也可以添加我们自己的属性。
属性不是变量
被赋值给函数的属性,比如sayHi.counter = 0
,不会 在函数内定义一个局部变量counter
。换句话说,属性counter
和变量let counter
是毫不相关的两个东西。
我们可以把函数当作对象,在它里面存储属性,但是这对它的执行没有任何影响。变量不是函数属性,反之亦然。它们之间是平行的。
函数属性有时会用来替代闭包。
那么它和闭包谁好谁赖?
两者最大的不同就是如果 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 func(who) {
alert(`Hello, ${who}`);
};
我们这里得到了什么吗?为它添加一个 "func"
名字的目的是什么?
首先请注意,它仍然是一个函数表达式。在 function
后面加一个名字 "func"
没有使它成为一个函数声明,因为它仍然是作为赋值表达式中的一部分被创建的。
添加这个名字当然也没有打破任何东西。
函数依然可以通过 sayHi()
来调用
关于名字 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
进行嵌套调用?
问题在于 sayHi
的值可能会被函数外部的代码改变。如果该函数被赋值给另外一个变量(译注:也就是原变量被修改),那么函数就会开始报错
发生这种情况是因为该函数从它的外部词法环境获取 sayHi
。没有局部的 sayHi
了,所以使用外部变量。而当调用时,外部的 sayHi
是 null
。
我们给函数表达式添加的可选的名字,正是用来解决这类问题的。
函数声明没有这个东西
这里所讲的“内部名”特性只针对函数表达式,而不是函数声明。对于函数声明,没有用来添加“内部”名的语法。
有时,当我们需要一个可靠的内部名时,这就成为了你把函数声明重写成函数表达式的理由了。
new Funtion 语法
还有一种创建函数的方法。它很少被使用,但有些时候只能选择它。
语法
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
与我们已知的其他方法相比,这种方法最大的不同在于,它实际上是通过运行时通过参数传递过来的字符串创建的。
以前的所有声明方法都需要我们 —— 程序员,在脚本中编写函数的代码。
但是 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
new Function
的这种特性看起来有点奇怪,不过在实际中却非常实用。
想象一下我们必须通过一个字符串来创建一个函数。在编写脚本时我们不会知道该函数的代码(这也就是为什么我们不用常规方法创建函数),但在执行过程中会知道了。我们可能会从服务器或其他来源获取它。
我们的新函数需要和主脚本进行交互。
如果这个函数能够访问外部(outer)变量会怎么样?
问题在于,在将 JavaScript 发布到生产环境之前,需要使用 压缩程序(minifier) 对其进行压缩 —— 一个特殊的程序,通过删除多余的注释和空格等压缩代码 —— 更重要的是,将局部变量命名为较短的变量。
例如,如果一个函数有 let userName
,压缩程序会把它替换为 let a
(如果 a 已被占用了,那就使用其他字符),剩余的局部变量也会被进行类似的替换。一般来说这样的替换是安全的,毕竟这些变量是函数内的局部变量,函数外的任何东西都无法访问它。在函数内部,压缩程序会替换所有使用了这些变量的代码。压缩程序很聪明,它会分析代码的结构,而不是呆板地查找然后替换,因此它不会“破坏”你的程序。
但是在这种情况下,如果使 new Function
可以访问自身函数以外的变量,它也很有可能无法找到重命名的 userName
,这是因为新函数的创建发生在代码压缩以后,变量名已经被替换了。
即使我们可以在 new Function
中访问外部词法环境,我们也会受挫于压缩程序。
此外,这样的代码在架构上很差并且容易出错。
当我们需要向 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
…
要传入被执行函数(或代码字符串)的参数列表(IE9 以下不支持)
如果第一个参数位传入的是字符串,JavaScript 会自动为其创建一个函数。
但是,不建议使用字符串,我们可以使用箭头函数代替它们
传入一个函数,但不要执行它
新手开发者有时候会误将一对括号()
加在函数后面
这样不行,因为setTimeout
期望得到一个对函数的引用。而这里的sayHi()
很明显是在执行函数,所以实际上传入setTimeout
的是 函数的执行结果。在这个例子中,sayHi()
的执行结果是undefined
(也就是说函数没有返回任何结果),所以实际上什么也没有调度。
用 clearTimeout 来取消调度
setTimeout
在调用时会返回一个“定时器标识符(timer identifier)”,在我们的例子中是 timerId
,我们可以使用它来取消执行。
取消调度的语法:
let timerId = setTimeout(...);
clearTimeout(timerId);
从 alert
的输出来看,在浏览器中,定时器标识符是一个数字。在其他环境中,可能是其他的东西。例如 Node.js 返回的是一个定时器对象,这个对象包含一系列方法。
我再重申一遍,这些方法没有统一的规范定义,所以这没什么问题。
针对浏览器环境,定时器在 HTML5 的标准中有详细描述,详见 timers section。
setInterval
setInterval
方法和 setTimeout
的语法相同:
let timerId = setInterval(func|code, [delay], [arg1], [arg2], ...)
所有参数的意义也是相同的。不过与 setTimeout
只执行一次不同,setInterval
是每间隔给定的时间周期性执行。
想要阻止后续调用,我们需要调用 clearInterval(timerId)
。
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 秒等。
并且,如果我们调度的函数占用大量的 CPU,那么我们可以测量执行所需要花费的时间,并安排下次调用是应该提前还是推迟。
嵌套的 setTimeout
相较于 setInterval
能够更精确地设置两次执行之间的延时。
使用 setInterval
时,func
函数的实际调用间隔要比代码中设定的时间间隔要短!
这也是正常的,因为 func
的执行所花费的时间“消耗”了一部分间隔时间。
也可能出现这种情况,就是 func
的执行所花费的时间比我们预期的时间更长,并且超出了 100 毫秒。
在这种情况下,JavaScript 引擎会等待 func
执行完成,然后检查调度程序,如果时间到了,则 立即 再次执行它。
极端情况下,如果函数每次执行时间都超过 delay
设置的时间,那么每次调用之间将完全没有停顿。
嵌套的 setTimeout
就能确保延时的固定(这里是 100 毫秒)。
这是因为下一次调用是在前一次调用完成时再调度的。
垃圾回收和 setInterval/setTimeout 回调(callback)
当一个函数传入setInterval/setTimeout
时,将为其创建一个内部引用,并保存在调度程序中。这样,即使这个函数没有其他引用,也能防止垃圾回收器(GC)将其回收。
对于setInterval
,传入的函数也是一直存在于内存中,直到clearInterval
被调用。
这里还要提到一个副作用。如果函数引用了外部变量(译注:闭包),那么只要这个函数还存在,外部变量也会随之存在。它们可能比函数本身占用更多的内存。因此,当我们不再需要调度函数时,最好取消它,即使这是个(占用内存)很小的函数。
零延时的 setTimeout
这儿有一种特殊的用法:setTimeout(func, 0)
,或者仅仅是 setTimeout(func)
。
这样调度可以让 func
尽快执行。但是只有在当前正在执行的脚本执行完成后,调度程序才会调用它。
也就是说,该函数被调度在当前脚本执行完成“之后”立即执行。
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)
如果我们使用setInterval
而不是setTimeout
,也会发生类似的情况:setInterval(f)
会以零延时运行几次f
,然后以 4 毫秒以上的强制延时运行。
这个限制来自“远古时代”,并且许多脚本都依赖于此,所以这个机制也就存在至今。
对于服务端的 JavaScript,就没有这个限制,并且还有其他调度即时异步任务的方式。例如 Node.js 的 setImmediate。因此,这个提醒只是针对浏览器环境的。
装饰器模式和转发 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
应用于它们。
通过将缓存与主函数代码分开,我们还可以使主函数代码变得更简单。
cachingDecorator(func)
的结果是一个“包装器”:function(x)
将 func(x)
的调用“包装”到缓存逻辑中
从外部代码来看,包装的 slow
函数执行的仍然是与之前相同的操作。它只是在其行为上添加了缓存功能。
总而言之,使用分离的 cachingDecorator
而不是改变 slow
本身的代码有几个好处:
cachingDecorator
是可重用的。我们可以将它应用于另一个函数。- 缓存逻辑是独立的,它没有增加
slow
本身的复杂性(如果有的话)。 - 如果需要,我们可以组合多个装饰器(其他装饰器将遵循同样的逻辑)。
使用 func.call 设定上下文
上面提到的缓存装饰器不适用于对象方法。
// 我们将对 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
。
包装器将调用传递给原始方法,但没有上下文 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
,参数是 1
、2
和 3
。唯一的区别是 func.call
还会将 this
设置为 obj
。
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
)传递给原始方法。
传递多个参数
现在让我们把 cachingDecorator
写得更加通用。到现在为止,它只能用于单参数函数。
现在如何缓存多参数 worker.slow
方法呢?
let worker = {
slow(min, max) {
return min + max; // scary CPU-hogger is assumed
}
};
// 应该记住相同参数的调用
worker.slow = cachingDecorator(worker.slow);
之前,对于单个参数 x
,我们可以只使用 cache.set(x, result)
来保存结果,并使用 cache.get(x)
来检索并获取结果。但是现在,我们需要记住 参数组合 (min,max)
的结果。原生的 Map
仅将单个值作为键(key)。
这儿有许多解决方案可以实现:
- 实现一个新的(或使用第三方的)类似 map 的更通用并且允许多个键的数据结构。
- 使用嵌套 map:
cache.set(min)
将是一个存储(键值)对(max, result)
的Map
。所以我们可以使用cache.get(min).get(max)
来获取result
。 - 将两个值合并为一个。为了灵活性,我们可以允许为装饰器提供一个“哈希函数”,该函数知道如何将多个值合并为一个值。
对于许多实际应用,第三种方式就足够了,所以我们就用这个吧。
当然,我们需要传入的不仅是x
,还需要传入func.call
的所有参数。让我们回想一下,在function()
中我们可以得到一个包含所有参数的伪数组(pseudo-array)arguments
,那么func.call(this, x)
应该被替换为func.call(this, ...arguments)
。
这是一个更强大的cachingDecorator
:
let worker = {
slow(min, max) {
alert(`Called with ${min},${max}`);
return min + max;
}
};
function cachingDecorator(func, hash) {
let cache = new Map();
return function() {
let key = hash(arguments); // (*)
if (cache.has(key)) {
return cache.get(key);
}
let result = func.call(this, ...arguments); // (**)
cache.set(key, result);
return result;
};
}
function hash(args) {
return args[0] + ',' + args[1];
}
worker.slow = cachingDecorator(worker.slow, hash);
alert( worker.slow(3, 5) ); // works
alert( "Again " + worker.slow(3, 5) ); // same (cached)
现在这个包装器可以处理任意数量的参数了(尽管哈希函数还需要被进行调整以允许任意数量的参数。一种有趣的处理方法将在下面讲到)。
这里有两个变化:
- 在
(*)
行中它调用hash
来从arguments
创建一个单独的键。这里我们使用一个简单的“连接”函数,将参数(3, 5)
转换为键"3,5"
。更复杂的情况可能需要其他哈希函数。 - 然后
(**)
行使用func.call(this, ...arguments)
将包装器获得的上下文和所有参数(不仅仅是第一个参数)传递给原始函数。
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
的调用是无法区分的。
借用一种方法
现在,让我们对哈希函数再做一个较小的改进,截至目前,它仅适用于两个参数。如果它可以适用于任何数量的 args
就更好了。
自然的解决方案是使用 arr.join 方法:
……不幸的是,这不行。因为我们正在调用 hash(arguments)
,arguments
对象既是可迭代对象又是类数组对象,但它并不是真正的数组。
所以在它上面调用 join
会失败
不过,有一种简单的方法可以使用数组的 join 方法:
function hash() {
alert( [].join.call(arguments) ); // 1,2
}
hash(1, 2);
这个技巧被称为 方法借用(method borrowing)。
我们从常规数组 [].join
中获取(借用)join 方法,并使用 [].join.call
在 arguments
的上下文中运行它。
它为什么有效?
那是因为原生方法 arr.join(glue)
的内部算法非常简单。
从规范中几乎“按原样”解释如下:
- 让
glue
成为第一个参数,如果没有参数,则使用逗号","
。 - 让
result
为空字符串。 - 将
this[0]
附加到result
。 - 附加
glue
和this[1]
。 - 附加
glue
和this[2]
。 - ……以此类推,直到
this.length
项目被粘在一起。 - 返回
result
。
因此,从技术上讲,它需要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
,但它和对象分离开了。
浏览器中的 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);
setTimeout(() => user.sayHi(), 1000); // Hello, John!
看起来不错,但是我们的代码结构中出现了一个小漏洞。
如果在 setTimeout
触发之前(有一秒的延迟!)user
的值改变了怎么办?那么,突然间,它将调用错误的对象!
下一个解决方案保证了这样的事情不会发生。
解决方案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",
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)
:
让我们使用 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
之类的东西。
为什么我们通常会创建一个部分应用函数?
好处是我们可以创建一个具有可读性高的名字(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 实现。
深入理解箭头函数
让我们深入研究一下箭头函数。
箭头函数不仅仅是编写简洁代码的“捷径”。它还具有非常特殊且有用的特性。
JavaScript 充满了我们需要编写在其他地方执行的小函数的情况。
例如:
arr.forEach(func)
——forEach
对每个数组元素都执行func
。setTimeout(func)
——func
由内建调度器执行。- ……还有更多。
JavaScript 的精髓在于创建一个函数并将其传递到某个地方。
在这样的函数中,我们通常不想离开当前上下文。这就是箭头函数的主战场啦。
箭头函数没有 this
正如我们在 对象方法,"this" 一章中所学到的,箭头函数没有 this
。如果访问 this
,则会从外部获取。
不能对箭头函数进行
new
操作
不具有this
自然也就意味着另一个限制:箭头函数不能用作构造器(constructor)。不能用new
调用它们。
箭头函数 VS bind
箭头函数=>
和使用.bind(this)
调用的常规函数之间有细微的差别:
.bind(this)
创建了一个该函数的“绑定版本”。- 箭头函数
=>
没有创建任何绑定。箭头函数只是没有this
。this
的查找与常规变量的搜索方式完全相同:在外部词法环境中查找。
箭头函数没有 arguments
箭头函数也没有 arguments
变量。
当我们需要使用当前的 this
和 arguments
转发一个调用时,这对装饰器(decorators)来说非常有用。