# JavaScript 函数
《JavaScript 忍者秘籍》一书中讲到:像普通人一样编写代码和像“忍者”一样编写代码的最大差别在于是否把 JavaScript 作为函数式语言来理解。对这一点的认知水平决定了你编写的代码水平。
函数及函数式概念之所以如此重要,其原因之一在于函数是程序执行过程中的主要模块单元。除了全局 JavaScript 代码(脚本、模块)是在页面构建的阶段执行的,其他的所有代码都将在一个个函数内执行。
# 函数是一等公民
JavaScript 中最关键的概念是:函数是第一类对象(first-class objects),或者说函数被称为一等公民(first-class citizens)。
# 为什么函数是第一类对象
先看一下对象的几个特点:
- 对象可以通过字面量来创建。
- 对象可以赋值给变量、数组项,或其他对象的属性。
- 对象可以作为参数传递给函数
- 对象可以作为函数的返回值。
- 对象能够具有动态创建和分配的属性。
let ninja = {}; //对象通过字面量创建,赋值给变量
ninja.data = {}; //给某个对象分配一个新属性,并把这个属性赋值为一个新对象
function hide(ninja) {
ninja.visibility = false;
}
hide({}); //一个新创建的对象作为参数传递给函数
function returnNewNinja() {
return {}; //从函数中返回一个新对象
}
对比上面的对象特点来看,当我们说函数是第一类对象的时候,就是说函数也能够实现以下功能:
- 通过字面创建。
- 赋值给变量、数组项或其他对象的属性。
- 作为函数的参数来传递。
- 作为函数的返回值。
- 具有动态创建和分配的属性。
function ninjaFunction () {} //通过字面量创建
var ninjaFunction = function () {}; //为变量赋值一个新函数
ninja.data = function () {}; //给某个对象的属性赋值为一个新函数
function call (ninjaFunction) {
ninjaFunction();
}
call(function () {}); //一个新函数作为参数传递给函数
function returnNinjaFunction() {
return function () {}; //返回一个新函数
}
var ninjaFunction = function () {};
ninjaFunction.ninja = "Hanzo"; //为函数增加一个新属性
JavaScript 中函数拥有对象的所有能力,也因此函数可被作为任意其他类型对象来对待。对象能做的任何一件事,函数也能做。函数也是对象(函数实际上是对象,每个函数都是 Function 类型的实例,而且都与其他引用类型一样具有属性和方法。),唯一的特殊之处在于它是可调用的(invokable),即函数会被调用以便执行某项动作。
# 函数作为第一类对象的用处
把函数作为第一类对象也是函数式编程(functional programming)的第一步。函数式编程是一种编程风格,它通过书写函数式(而不是指定一系列执行步骤,就像那种更主流的命令式编程)代码来解决问题。函数式编程可以让代码更容易测试、扩展和模块化, 具体参见函数式编程一文 。
# 函数作为函数的参数来传递——回调函数
第一类对象的特点之一是:它能够作为参数传入函数。
对于函数而言,这项特性也表明:如果我们将某个函数作为参数传入另一个函数,传入的函数会在应用程序执行的未来某个时间点才执行,这个概念就是回调函数(callback function)。
回调(callback)这个术语源自于这样一个事实,即在执行过程中,我们建立的函数会被其他函数在稍后的某个时间点“再回来调用”。有效运用 JavaScript 的关键就在于回调函数。
# 简单举例
为了透彻、完整地理解回调函数的概念,先用最简单的形式来展示一下,此例中的函数完全没有什么实际用处,但它反应了函数的一种能力,即将函数作为另一个函数的参数,随后通过参数来调用该函数。
function useless(ninjaCallback) {
return ninjaCallback();
}
useless(function () {});
# 复杂的例子
下面的例子中,我们会让排序算法能够获取一个比较函数作为回调,使算法在其需要比较的时候,每次都能够调用回调。sort 方法的回调函数的期望返回值为:如果传入值的顺序需要被调换,返回正数;不需要调换,返回负数,两个值相等,返回 0.
const values = [0,3,2,5,7,4,8,1]
function diff(type) {
return function (a,b) {
if(a > b) {
return type === 'asc'? 1:-1;
}else if(a < b) {
return type === 'asc'?-1:1;
}else {
return 0;
}
}
}
console.log(values.sort(diff('desc'))) //[8, 7, 5, 4, 3, 2, 1, 0]
函数式方式让我们能把函数作为一个单独实体来创建,像我们对待其他类型一样,创建它、作为参数传入一个方法并将它作为一个参数来接收。函数就这样显示了它一等公民的地位。
# 函数作为对象的用法
# 1、在集合中存储函数使我们轻易管理相关联的函数。例如:某些特定情况下必须调用的回调函数。
例如我们需要管理某个事件发生后需要调用的的回调函数集合,一般来说,管理回调函数集合时,我们并不希望存在重复函数,否则一个事件会导致同一个回调函数被多次调用。我们可以使用函数的属性,用适当的复杂度来实现它。
const store = {
nextId: 1,
cache: [],
add: function(fn) {
if(!fn.id) {
fn.id = this.nextId++;
this.cache.push(fn)
}
}
}
function ninja() {}
store.add(ninja)
store.add(ninja)
console.log(store.cache) // [f] 虽然添加了两次ninja,但是数组里只有一个函数
# 2、记忆函数能记住上次计算得到的值,从而提高后续调用的性能。
使用函数属性时,可以通过该函数修改函数自身,这个技巧可以用于记忆前一个计算得到的值,位置后计算节省时间。
记忆化(memoization)是一种构建函数的处理过程,能够记住上次计算结果。当函数计算得到结果时就将该结果按照参数存储起来。采用这种方式时,如果另外一个调用也使用相同的参数,我们则可以直接返回上次存储的结果而不是在计算一遍。像这样避免既重复又复杂的计算可以显著地提高性能。对于动画中的计算、搜索不经常变化的数据或任何耗时的数学计算(通过字符串生成 MD5 算法)来说,记忆化这种方式是十分有用的。
举个简单的例子,计算素数。
function isPrime(value) {
if(!isPrime.answers) {
isPrime.answers = {}; //创建缓存
}
if(isPrime.answers[value] !== undefined) {
return isPrime.answers[value];
}
let prime = value !== 0 && value !== 1; // 1不是素数
for(let i = 2; i < value; i++) {
if(value % i === 0) {
prime = false
break;
}
}
return isPrime.answers[value] = prime;
}
isPrime(5); // true
console.log(isPrime.answers) // {5: true}
这个方法有两个优点:
- 由于函数调用时会寻找之前调用所得到的的值,所以用户最终会乐于看到所获得的性能收益。
- 它几乎是无缝地发生在后台,最终用户和页面作者都不需要执行任何特殊请求,也不需要做任何额外的初始化,就能顺利工作。
不过也有缺点,需要权衡利弊:
- 任何类型的缓存都必然会为性能牺牲内存。
- 纯粹主义者会认为缓存逻辑不应该和业务逻辑混合,函数或方法只需要把一件事做好。
- 对于这类问题很难做负载测试或估算算法复杂度,因为结果依赖于函数之前的输入。
# 函数定义
JavaScript 函数通常由函数字面量(function literal)来创建函数值,就像数字字面量创建一个数字值一样。作为第一类对象,函数是可以用在编程语言中的值,就像字符串或数字的值。
# 函数定义方式分类
# 1、函数定义(function declarations 或称函数声明)和函数表达式(function expression)
用 function 关键字定义的普通函数。
最常用的在定义函数上却有微妙不同的两种方式,通常不会独立地看待他们,但是意识到两者的不同能帮我们理解函数何时能够被调用。
function f() {} //函数声明
const f = function () {}; //函数表达式
# 2、箭头函数
用 => 运算符定义的函数,是一种能让我们以尽量简洁的语法定义函数的方式。通常被叫做 lambda 函数。
const f = myArg => myArg * 2;
# 3、函数构造函数
一种不常使用的函数定义方式,能让我们以字符串形式动态构造一个函数,这样得到的函数是动态生成的。
const f = new Function('a','b','return a + b')
f(1,2) // 3
这个例子动态地创建了一个函数,其参数为 a 和 b,返回值为两个数的和。
从技术角度讲,这是一个函数表达式。但是不推荐使用这种方式定义函数,因为这种语法会导致解析两次代码(第一次是解析常规 JavaScript 代码,第二次是解析传入构造函数中的字符串),从而影响性能。
# 4、生成器函数
用 function*
定义的函数。能让我们创建不同于普通函数的函数,在应用程序执行过程中,这种函数能够退出再重新进入。在这些在进入之间保留函数内变量的值。我们可以定义生成器版本的函数声明、函数表达式、函数构造函数(需要用特殊的方法先取到生成器函数的构造函数)。
function* g() { yield 1; }
# 5、异步函数
普通函数、箭头函数和生成器函数加上 async 关键字。
async function f() {}
var f = async () => {}
async function* f() {}
# 5、类
用 class 定义的类,实际上也是函数。
class F {
constructor() {
}
}
# 6、方法
在 class 中定义的函数。
class F {
f () {
}
}
# 函数声明和函数表达式
# 函数声明
强制性的 function 开头,其后紧接着强制性的函数名。
作为一个单独的 JavaScript 语句,函数声明必须独立,是独立的 JavaScript 代码块(但也能够被包含在其他函数或代码块中)。
函数声明必须有函数名是因为它们是独立语句。一个函数的基本要求是它应该能够被调用,所以它必须具有一种被引用的方式,于是唯一的方式就是通过它的名字。
扩展:由于函数是对象,因此函数名实际上是一个指向函数对象引用的指针,不会与某个函数绑定。
# 函数表达式
作为赋值表达式的右值,或者作为其他函数的参数,这种总是其他表达式的一部分的函数叫做函数表达式。
函数表达式非常重要,在于它能准确地在我们需要使用的地方定义函数,这个过程能让代码易于理解。
对于函数表达式来说,函数名则完全是可选的。因为函数表达式是其他 JavaScript 表达式的一部分,所以我们就有了除了函数名之外调用它们的替代方法。例如一个函数表达式赋值给一个变量,就可以用这个变量调用函数。这种情况下创建的函数叫做匿名函数(anonymous function),因为 function 关键字后面没有标识符。(匿名函数有时候也叫拉姆达函数)匿名函数的 name 属性是空字符串。
# 立即函数表达式
首先创建一个函数,然后立即调用这个新创建的函数。这种函数叫做立即调用函数表达式(IIFE),或者简写为立即函数。
(function () {})()
上面的例子中立即函数表达式的函数表达式被包裹在一对括号内,这样做的原因是纯语法层面的。JavaScript 解析器必须能够轻易区分函数声明和函数表达式之间的区别。如果去掉包裹函数表达式的括号,把立即调用作为一个独立语句 function () {},JavaScript 开始解析时便会结束,因为这个独立语句以 function 开头,那么解析器就会认为它在处理一个函数声明。每个函数声明必须有一个名字(然而这里并没有指定名字),所以程序执行到这里会报错。为了避免错误,函数表达式要放在括号内,为 JavaScript 解析器指明它正在处理一个函数表达式而不是函数声明语句。
# 创建立即函数表达式的其他方式
(function () {}()) //不常用
void function () {}(); //推荐
+function () {}();
-function () {}();
!function () {}();
~function () {}();
不管是加括号,还是使用一元操作符的方式区分函数表达式和函数声明,这些做法都是在向 JavaScript 引擎指明它处理的是表达式,而不是函数声明语句。
# 函数声明和函数表达式的区别
解析器在向执行环境中加载数据时,解析器会率先读取函数声明(预解析),并使其在执行任何代码之前可用(可以访问);至于函数表达式,则必须等到解析器执行到它所在的代码行,才会真正被解释执行。
sum(10,10);
var sum = function(num1, num2) {
return num1 + num2;
}
上面的代码会报错,sum is not a function
,在执行到函数所在的语句之前,变量 sum 中不会保存有对函数的引用,而且,由于第一行代码就报错,实际上也不会执行到下一行。
当然也可以同时使用函数声明和函数表达式,但实际效果跟函数表达式类似,还是会报错。
sum(10,10);
var sum = function sum (num1, num2) {
return num1 + num2;
}
# 箭头函数
由于 JavaScript 中会使用大量函数,增加简化创建函数方式的语法十分有意义。在很多方式中,箭头函数是函数表达式的简化版。
箭头函数还能帮助我们规避一些在很多标准函数中可能遇到的难以捉摸的缺陷,比如 this 。
# 箭头函数的定义
- 箭头函数左侧参数
箭头函数的定义以一串可选参数名列表开头,参数名以逗号分隔。如果没有参数或者多于一个参数时,参数列表就必须包裹在括号内。但如果只有一个参数时,括号就不是必须的。参数列表之后必须跟着一个胖箭头符号,以此向我们和 JavaScript 引擎指明当前处理的是箭头函数。
新操作符——胖箭头符号=>(等号后面跟着大括号)是定义箭头函数的核心。
- 箭头函数右侧函数体
如果箭头函数的函数体是一个表达式,则该箭头函数的返回值就是表达式的值。(默认带有 return 语句)
如果函数体是一个代码块,那么返回值则与普通函数一样。(如果没有 return 语句,返回值是 undefined,反之,返回值就是 return 表达式的值)
# 函数返回值
JavaScript 中无需指定函数的返回值,因为任何 JavaScript 函数都可以在任何时候返回任何值。
实际上,未指定返回值的函数返回的是一个特殊的 undefined 值。
# 严格模式对函数的一些限制
- 不能把函数名命名为 eval 或 arguments;
- 不能把参数命名为 eval 或 arguments;
- 不能出现两个命名参数同名的情况。
# 函数参数
# 显式的函数参数
函数参数分为实参(argument)和形参(parameter)。
- 形参是我们定义函数时所列举的变量
- 实参是我们调用函数时所传递给函数的值
当函数调用时提供了一系列实参,这些实参会以形参在函数中定义的顺序被赋值到形参上。如果实参的数量大于形参,那么额外的实参不会赋值给任何形参。反之,如果形参的数量大于实参,那么那些没有对应实参的形参则会被设为 undefined。
# 剩余参数和默认参数
# 剩余参数
为函数的最后一个命名参数前加上三个点 ... 做前缀,这个参数就变成了一个叫作剩余参数的数组,数组内包含着传入的剩余的参数。
function f(a, b, ...theArgs) {}
注意:只有函数的最后一个参数才能是剩余参数。试图把省略号放在不是最后一个形参的任意形参之前之前都会报错。
# 默认参数
JavaScript 创建默认参数的语法是为函数的形参赋值。
function performAction(ninja, action="skulking") {
return ninja + " " + action;
}
可以为默认参数赋任何值,它既可以是数字或者字符串这样的基本类型,也可以是对象、数组甚至函数这样的复杂类型。每次函数调用时都会从左到右求得参数的值,并且当对后面的默认参数赋值时可以引用前面的参数。
# 隐式的函数参数
隐式的函数参数 this 和 arguments。两者会被静默地传递给函数,并且可以像函数体内显式声明的参数一样被正常访问。
参数 this 表示被调用函数的上下文对象,而 arguments 对象参数表示函数调用过程中传递的所有参数。
# arguments 参数
arguments 参数是传递给函数的所有参数(实参)集合。无论是否有明确定义对应的形参,通过它我们都可以访问到传入函数的所有参数。
arguments 对象有一个名为 length 的属性,表示实参的确切个数。通过数组索引(数组下标)的方式可以获取单个参数的值,需要注意:这里也包括没有和函数形参相关联的其他多余的参数。
Function.length (opens new window) 是函数对象的一个属性值,指该函数期望传入的参数类型,即形参的个数,不过要注意,形参的数量不包括剩余参数个数,仅包括第一个具有默认值之前的参数个数,与之对比的是,arguments.length 是函数被调用时实际传参的个数。
# arguments 对象作为函数参数的别名
arguments 对象可以作为函数参数的别名,如果改变了 arguments 对象的值,同时也会影响对应的函数参数;反之亦然,如果更改了某个参数的值,会同时影响参数和 arguments 对象。
function f(a, b, c) {
console.log(a,arguments[0]) // 1 1
arguments[0] = 10
console.log(a,arguments[0]) // 10 10
a = 100
console.log(a,arguments[0]) // 100 100
}
f(1,2,3)
如上面的代码所示,arguments[0]是参数 a 的别名,如果改变了 arguments[0]的值,参数 a 的值也会更改,如果更改了 a 的值,也会更改 arguments[0]的值。
# 形参和实参需要对应
arguments 对象作为函数参数的别名的前提是函数的形参和实参对应的情况下,必须在最初的时候就函数的形参和 arguemnts 对应的下标位置必须都有值,否则两者就不是别名的关系。
function f(a, b, c) {
console.log(c,arguments[2]) // undefined undefined
arguments[2] = 3
console.log(c,arguments[2]) // undefined 3
c = 30
console.log(c,arguments[2]) // 30 3
}
f(1,2)
# arguments 参数不是数组
arguments 参数不是 JavaScript 数组,arguments 对象仅是一个类数组的结构,一定要注意避免把 arguments 参数当作数组。虽然它有 length 属性,而且可以通过数组下标的方式访问到每一个元素,但是如果尝试在 arguments 对象上使用数组的方法(例如 sort 方法),会发现最终报错。
# 严格模式下 arguments 对象的别名无法使用
将 arguments 对象作为函数参数的别名使用时会影响代码的可读性,因此在 JavaScript 提供的严格模式(strict mode)中将无法再使用它。
# 剩余参数(rest parameter)和 arguments 参数的区别
- 剩余参数只包含那些没有对应形参的实参,而 arguments 对象包含了传给函数的所有实参。
- arguments 对象不是一个真正的数组,而剩余参数是真正的 Array 实例(也就是说能够在它上面直接使用所有的数组方法,例如 sort,map,forEach,pop 等)
- arguments 对象还有一些附加的属性(如 callee 属性)
# arguments.callee 属性
该属性是一个指针,指向拥有这个 arguments 对象的函数。这在函数的名称是未知时很有用。
例如:定义阶乘函数,在函数有名字并且名字以后也不会变的情况下可以直接用函数名实现递归,如果函数的名字之后有变化,就可能会出现问题。
function factorial(num) {
if(num <= 1) {
return 1;
}else {
return num * factorial(num - 1);
}
}
factorial(5) // 120
let trueFactorial = factorial; // 实际上是在另一个位置保存了一个函数的指针
trueFactorial(5) //120
factorial = function () {
return 0;
}
factorial(5) // 0
trueFactorial(5) // 0
最后的 trueFactorial 函数还是上面的阶乘函数,只不过在走到 else 中后,调用的 factorial 函数已经不再是阶乘函数了。
function factorial(num) {
if(num <= 1) {
return 1;
}else {
return num * arguments.callee(num - 1);
}
}
factorial(5) // 120
let trueFactorial = factorial;
trueFactorial(5) //120
factorial = function () {
return 0;
}
factorial(5) // 0
trueFactorial(5) // 120
重写后的 factorial 函数的函数体内,没有再引用函数名,而是使用 arguments.callee,这样,无论引用函数时使用的是什么名字,都可以保证正常完成递归调用。
注意:严格模式下,ES5 禁止使用 arguments.callee()。当一个函数必须调用自身的时候,避免使用 arguments.callee(),通过要么给函数表达式一个名字,要么使用一个函数声明。
早期版本的 JavaScript 不允许使用命名函数表达式,所以就无法穿件一个递归函数表达式。然后就出现了通过加入 arguments.callee 解决这个问题的方案。不过这实际上是一个非常糟糕的解决方案,因为这样就不可能实现内联和尾递归,而且还会造成另外一个问题:递归调用会获取到不同的 this 值。后来,ES3 通过允许命名函数表达式解决这些问题。
# this 参数
当调用函数时,this 参数也会默认地传递给函数。this 参数是面向对象 JavaScript 编程的一个重要组成部分,代表函数调用相关联的对象。因此,this 通常称之为函数上下文。
函数上下文是来自面向对象语言(如 Java)的一个概念。在这些语言中,this 通常指向定义当前方法的类的实例。
在 JavaScript 中,this 参数的指向不仅是由定义函数的方式和位置决定的,同时还严重受到函数调用方式的影响。
this 是一个很复杂的机制,JavaScript 标准定义了[[thisMode]]私有属性。
[[thisMode]]私有属性有三个取值:
- global:表示当 this 为 undefined 时,取全局对象,对应了普通函数。
- lexical:表示从上下文中找 this,这对应了箭头函数。
- strict:当严格模式时使用,this 严格按照调用时传入的值,可能为 null 或者 undefined。
# 普通函数的 this
普通函数的 this 值由“调用它所使用的引用”决定。因为,我们获取函数的表达式,它实际上返回的并非函数本身,而是一个 Reference 类型。Reference 类型由两部分组成:一个对象和一个属性值。类似函数调用、delete 等操作,都需要用到 Reference 类型中的对象。函数调用时,Reference 类型中的对象被当做 this 值,传入了执行函数时上下文当中。
至此,我们对 this 的解析已经非常清晰了:调用函数时使用的引用,决定了函数执行时的 this 值。
从运行时的角度来看,this 跟面向对象毫无关联,它是与函数调用时使用的表达式相关。这个设计来自 JavaScript 早年,通过这样的方式,巧妙地模仿了 Java 的语法,但是仍然保持了纯粹的“无类”运行时设施。
function showThis() {
console.log(this)
}
var o = {
showThis: showThis
}
showThis() // global
o.showThis() // o ,函数调用是reference类型,此例中o是对象,showThis是属性值
# 箭头函数的 this
箭头函数,不论用什么引用来调用它,都不影响它的 this 值。
var showThis = () => {
console.log(this)
}
var o = {
showThis: showThis
}
showThis() // global
o.showThis() // global
函数创建新的执行上下文中的词法环境记录时,会根据 [[thisMode]] 来标记新纪录的 [[ThisBindingStatus]] 私有属性。代码执行遇到 this 时,会逐层检查当前词法环境记录中的 [[ThisBindingStatus]] ,当找到有 this 的环境记录时获取 this 的值。这样的规则的实际效果是,嵌套的箭头函数中的代码都指向外层的 this。
var o = {};
o.foo = function foo() {
console.log(this);
return () => {
console.log(this);
return () => console.log(this);
}
}
o.foo()()() // o, o, o
# 实例方法中的 this
实例调用实例中的方法,方法中 this 的值是实例,单独调用实例方法,得到的是 undefined。
class C {
showThis() {
console.log(this);
}
}
var o = new C();
var showThis = o.showThis;
showThis(); // undefined,默认按严格模式执行
o.showThis(); // o
非常有意思的是,上面实例方法的行为跟普通函数有差异,恰恰是因为 class 设置成了默认按 strict 模式执行。
# 其他函数的行为
生成器函数、异步生成器函数和异步普通函数跟普通函数行为是一致的,异步箭头函数与箭头函数行为是一致的。
# 深入参数
# 理解参数
JavaScript 函数的参数与大多数其他语言中函数的参数有所不同。JavaScript 函数不介意传递进来多少个参数,也不在乎传进来的参数是什么数据类型。原因就是 JavaScript 中的参数在内部是用一个数组表示的。函数接收到的始终都是这个数组,而不关心数组中包含哪些参数(如果有参数的话)。如果这个数组中不包含任何元素。无所谓;如果包含多个元素,也没有问题。实际上,在函数体内可以通过 arguments 对象来访问这个参数数组,从而获取传递给函数的每一个参数。
# 参数传递的都是值
JavaScript 中的所有参数传递的都是值(按值传递),不可能通过引用传递参数。
按值传递也就是说,把函数外部的值复制给函数内部的参数,就和把值从一个变量复制到另一个变量一样。
# 变量传递的原理
在 JavaScript 中,原始类型的值存储在栈中,而对象的值存储在堆中,然后把指向堆的地址(指针)存储在栈中。原始变量以及它们的值存储在栈中,当把一个原始变量传递给另一个变量时,是把一个栈房间的东西复制到另一个栈房间,且两个原始变量互不影响。引用值是把引用变量的名字存储在栈中,但是其实际对象存储在堆中,且存在一个指针由变量名指向指向存储在堆中的实际对象。当把引用对象传递给另一个变量时,复制的其实是指向实际对象的指针,此时两者指向的是同一个数据,若通过方法改变其中一个变量的值,则访问另一个变量时,其值也会随之加以改变;但如果重新赋值,此时相当于重新开了一个房间,该值的原指针改变,而另外一个值不会随它的改变而改变。
let value = 1;
function f(v) {
v = 2;
console.log(v); // 2
}
f(value) // 当传递value到f中,相当于拷贝了一份value,假设拷贝的这份值叫_value,函数中修改的都是_value,而不会影响原来的value值。
console.log(value) // 1
function setName(obj){
obj.name = 'Tom' //参数按值传递,obj和person访问的是同一个对象,修改对象会表现在参数person上
obj = {name: 'John'}; // obj指向一个新的地址,与person不再指向同一个地址
}
let person = new Object()
setName(person)
console.log(person.name) // Tom
# 函数调用和函数上下文
函数的调用方式对函数内代码的执行有很大的影响,主要体现在 this 参数(表示函数上下文)是如何建立的。
# 函数调用的四种方式
- 作为一个函数(function)—— skulk(),直接被调用。
- 作为一个方法(method)—— ninja.skulk(),关联在一个对象上,实现面向对象编程。
- 作为一个构造函数(constructor)—— new Ninja(),实例化一个新的对象。
- 通过函数的 apply 或者 call 方法 —— skulk.apply(ninja) 或者 skulk.call(ninja)。
除了 call 和 apply 的方式外,函数调用的操作符都是函数表达式之后加一对圆括号。
# 作为函数被直接调用
通过 ()
运算符调用一个函数,且被执行的函数表达式不是作为一个对象的属性存在时,就属于这种调用方式。
在意这种方式调用时,函数上下文(this 关键字的值)有两种可能性:
- 在非严格模式下,它将是全局上下文(window 对象或 global 对象)
- 在严格模式下,它将是 undefined
扩展:显然,在多数情况下,严格模式比非严格模式更简单易懂。使用函数调用的方式,并没有指定函数被调用的对象,因此在我们看来,this 关键字的确应该被设置为 undefined(在严格模式下的结果),而不应该是全局的 window 对象(在非严格模式下)。
# 作为方法被调用
当一个函数被赋值给一个对象的属性,并且通过对象属性引用的方式调用函数时,函数会作为对象的方法被调用。
当函数作为某个对象的方法被调用时,该对象会成为函数的上下文,并且在函数内部可以通过 this 参数访问到。这也是 JavaScript 实现面向对象编程的主要方式之一。(构造函数是另一种方式)
将函数作为方法调用对于实现 JavaScript 面向对象编程至关重要。这样就可以通过 this 在任何方法中引用该方法的“宿主”对象——这也是面向对象编程的一个基本概念。
# 作为构造函数调用
函数作为构造函数调用最主要的区别就是调用函数的方式,若要通过构造函数的方式调用,需要在函数调用之前使用关键字 new。构造函数的声明和其他函数类似,不过只有普通函数和类能够作为构造函数跟 new 搭配使用,其他的函数类型,例如箭头函数、方法、、生成器,异步普通函数、、异步箭头函数、异步生成器函数都不能作为构造函数。
一般来讲,当调用构造函数时会发生一系列特殊的操作,使用关键字 new 调用函数会触发一下几个动作:
- 以构造器的 prototype 属性为原型,创建一个新的对象。
- 该对象作为 this 参数传递给构造函数,从而成为构造函数的函数上下文。
- 新构造的对象作为 new 运算符的返回值(除了一些特殊情况,这些特殊的情况总结如下:如果构造函数返回一个对象,则该对象将作为整个表达式的值返回,而传入构造函数的 this 将被丢弃;但是,如果构造函数返回的是非对象类型,则忽略返回值,返回新创建的对象。)
**构造函数的目的是根据初始条件对函数调用创建的新对象进行初始化。**虽然这些函数也可以被“正常”调用,或者被赋值为对象属性从而作为方法调用,但这样并没有太大的意义。
因为构造函数通常以不同于普通函数的方式编码和使用,并且只有作为构造函数调用时才有意义,因此出现了命名约定来区分构造函数和普通的函数及方法。
- 函数和方法的命名通常以描述其行为的动词开头,且第一个字母小写。(skulk, creep, sneak 等)
- 构造函数则通常以描述所构造对象的名词命名,并以大写字母开头(Ninja,Emperor 等)
# 扩展:函数的构造器和构造函数区别
函数的构造器,它可以通过字符串来构造一个新的函数。例如 new Function('a', 'b', 'return a+b')
将创建一个函数,它包含两个形参 a 和 b,函数的返回结果是想着的和。通过函数的构造器我们可以将动态创建的字符串创建为函数,构造函数是我们用来创建和初始化对象实例的函数。注意不要把这些函数的构造器和构造函数混为一谈,虽然差别很小,但却至关重要。
# 使用 apply 和 call 方法调用
不同类型函数调用之间的主要区别在于:最终作为函数上下文(this 参数)传递给执行函数的对象不同。但是,如果想改变函数上下文怎么办?如果想要显示指定它怎么办?可以使用 apply 和 call 方法显示地设置函数上下文。
JavaScript 为我们提供了一种调用函数的方式,从而可以显示地指定任何对象作为函数的上下文。就是使用每个函数上都存在的这两个方法(正是函数的方法)来完成:apply 和 call。
扩展:函数是由内置的 Function 构造函数所处案件的,作为第一类对象,函数可以像其他对象类型一样拥有属性,也包括方法。
若想使用 apply 方法调用函数,需要为其传递两个参数:作为函数上下文的对象和一个数组作为函数调用的参数。call 方法使用方式类似,不同点在于是直接以参数列表的形式,而不再是作为数组传递。
例如:
function juggle() {
let result = 0;
for(let i = 0; i < arguments.length; i++) {
result += arguments[i]
}
this.result = result;
}
let ninja1 = {};
let ninja2 = {};
juggle.apply(ninja1,[1,2,3])
juggle.call(ninja2, 4,5,6)
console.log(ninja1.result) // 6
console.log(ninja2.result) // 15
传入 call 和 apply 方法的第一个参数都会被作为函数上下文,不同之处在于后续的参数。apply 方法只需要一个额外的参数,也就是一个包含参数值的数组;call 方法则需要传入任意数量的参数值。
call 和 apply 这两个方法对于我们要特殊指定一个函数的上下文对象时特别有用。在执行回调函数时可能会经常用到。
apply 和 call 功能类似,具体怎么选择用哪一个?答案就是选择与现有参数相匹配的方法。如果有一组无关的值,则直接使用 call 方法。若已有参数是数组类型,apply 方法更佳。
# 函数上下文
JavaScript 用一个栈(stack)来管理执行上下文(execution context),这个栈中的每一项又包含一个链表(词法环境 lexical environment)。当函数调用时,会入栈一个新的执行上下文,函数调用结束时,执行上下文被出栈。
# 解决函数上下文的问题
处理 JavaScript 函数上下文时可能会遇到一些问题,在回调函数中(例如事件处理器),函数上下文与预期不符,除了可以使用 call 和 apply 方法解决外,还有另外两个选择:箭头函数和 bind 方法。
解决函数上下文的问题的四种方式:
- 函数的 apply 方法(能改变函数上下文)
- 函数的 call 方法(能改变函数上下文)
- 箭头函数(能绕过函数上下文)
- 函数的 bind 方法(能改变函数上下文)
# 使用箭头函数绕过函数上下文
箭头函数相比于传统的函数声明和函数表达式,除了可以更优雅地创建函数,还有另一个特性是:箭头函数没有单独的 this 值,箭头函数的 this 与声明所在的上下文相同。(即箭头函数的 this 是在声明时而非调用时确定的)
调用箭头函数时,不会隐式传入 this 参数,而是从定义时的函数继承上下文。即使使用 call,apply 或者 bind 都无效。
# 注意:箭头函数和对象字面量
由于 this 值是在箭头函数创建时确定的,所以会导致一些看似奇怪的行为。
例如:
console.log(this) // window
function Button() {
this.clicked = false;
this.click = () => {
this.clicked = true;
console.log(button.clicked) // true
console.log(this) // Button
console.log(this.clicked) // true
}
}
var button = new Button()
// 注意箭头函数和对象字面量一起使用时的表现
var button1 = {
clicked: false,
click: () => {
this.clicked = true;
console.log(button1.clicked) // false
console.log(this) // window
console.log(this.clicked) // true
}
}
setTimeout(button.click,0) // 模拟浏览器的事件触发
setTimeout(button1.click,0) // 模拟浏览器的事件触发
# 使用 bind 方法更改函数上下文
除了 call 和 apply 两个方法外,我们还可以访问 bind 方法创建新函数。无论函数使用哪种方式调用,bind 方法创建的新函数与原始函数的函数体相同(具有一致的行为),新函数被绑定到指定的对象上。
所有函数均可访问 bind 方法,可以创建并返回一个新函数,并绑定在新传入的对象上。 无论如何调用该函数,this 均被设置为对象本身。
var button = {
clicked: false,
click: function () {
this.clicked = true;
console.log(button.clicked) // 第一次结果:false,第二次使用bind结果:true
console.log(this) // 第一次结果:window,第二次使用bind结果:button
}
}
setTimeout(button.click,0) // 模拟浏览器的事件触发
setTimeout(button.click.bind(button),0) // 模拟浏览器的事件触发,不过使用了bind
# 注意:调用 bind 方法不会修改原始函数,而是创建了一个全新的函数
//... 接上面的代码
var boundFunction = button.click.bind(button);
console.log(boundFunction === button.click) // false
# 注意:call、bind 和 apply 用于不接受 this 的函数类型如箭头、class 都不会报错。这时候,它们无法实现改变 this 的能力,但是可以实现传参。
# 函数闭包和作用域
# 函数闭包
闭包是指有权访问另一个函数作用域中的变量的函数。实际上 JavaScript 中跟闭包对应的概念就是“函数”。
闭包其实只是一个绑定了执行环境的函数,闭包和普通函数的区别是,它携带了执行的环境,就像人在外星中需要自带吸氧的装备一样,这个函数也带有在程序中生存的环境。实际上 JavaScript 中跟闭包对应的概念就是“函数”。
古典的闭包定义中,闭包包含两个部分:环境部分和表达式部分,环境部分又分为环境和标识符列表。JavaScript 中的函数完全符合闭包的定义。它的环境部分是函数词法环境部分组成,它的标识符列表是函数中用到的未声明变量(未使用 var、let、const 声明的变量),它的表达式就是函数体。
闭包可以访问创建函数时所在作用域内的全部变量,闭包与作用域密切相关,闭包对 JavaScript 的作用域规则产生了直接影响:闭包是 JavaScript 作用域规则的副作用,当函数创建时所在的作用域消失后,仍然能够调用函数,访问作用域。
闭包允许函数访问并操作函数外部的变量。只要变量或函数存在于声明函数时的作用域内,闭包即可使函数能够访问这些变量和函数。但是要注意,所声明的函数可以在声明之后的任何时间被调用,甚至当该函数声明的作用域消失之后仍然可以调用。
闭包内的函数不仅可以在创建的时刻访问这些变量,而且当闭包内部的函数执行时,还可以更新这些变量的值。闭包不是在创建的那一时刻的状态的快照,而是一个真实的状态封装,只要闭包存在,就可以对变量进行修改。
# 函数闭包示例
例如:
var outerValue = "samurai";
var later;
function outerFunction () {
var innerValue = "ninja";
function innerFunction () {
console.log(outerValue)
console.log(innerValue)
}
later = innerFunction;
}
outerFunction()
later() // 执行后打印:samurai 、 ninja
当在外部函数中声明内部函数时,不仅定义了函数的声明,而且还创建了一个闭包,该闭包不仅包含了函数的声明,还包含了在函数声明时该作用域中的所有变量。当最终执行内部函数时,尽管声明时的作用域已经消失了,但是通过闭包,仍然能够访问到原始作用域。
正如保护气泡一样,只要内部函数一直存在,内部函数的闭包就一直保存着该函数的作用域中的变量。
谨记:每一个通过闭包访问变量的函数都具有一个作用域链,作用域链包含闭包的全部信息。
# 函数闭包的性能问题
虽然闭包是非常有用的,但不能过度使用。使用闭包时,所有的信息都会存储在内存中,知道 JavaScript 引擎确保这些信息不再使用(可以安全地进行垃圾回收)或页面卸载时,才会清理这些信息。
# 概念误区
这里容易产生的一个常见的概念误区,有些人会把 JavaScript 执行上下文,或者作用域(Scope,ES3 中规定的执行上下文的一部分)这个概念当做闭包。
实际上 JavaScript 中跟闭包对应的概念就是“函数”,可能是这个概念太过于普通,跟闭包看起来又没什么联系,所以大家猜不自觉地把这个概念对应到了看起来更特别的“作用域”。
# 函数作用域
JavaScript 引擎通过词法环境跟踪标识符(俗称作用域)。作用域也称为词法作用域或静态作用域,通过它就能预测代码在执行过程中如何查找标识符。简单说作用域就是指变量的可访问性和可见性。
现在 JavaScript 有全局作用域、函数作用域、块级作用域
函数的词法环境可以理解为函数的作用域。
# 作用域和作用域链
函数能够引用定义时的变量,也能记住定义时的 this,因此,函数内部必定有一个机制来保存这些信息。
在 JavaScript 标准中,为函数规定了用来保存定义时上下文的私有属性[[Environment]],当一个函数执行时,会创建一条新的执行环境记录,记录的外部词法环境(outer lexical environment)会被设置成函数的[[Environment]]。这个动作就是切换上下文。
JavaScript 用一个栈来管理执行上下文,这个栈中的每一项又包含一个链表。无论何时调用函数,都会创建一个新的执行上下文,被推入执行上下文栈,每个执行上下文都有一个与之关联的词法环境,词法环境中包含了在上下文中定义的标识符的映射表(ES3 中就是使用 arguments 和其他命名参数的值来初始化函数的活动对象-activation object)。
此外被调用的函数在一开始定义的时候,会创建一个与之相关联的外部词法环境(outer lexical environment),并存储在名为 [[Environment]]
的函数内部属性上。两个中括号用于表示内部属性。最重要的是:外部环境与新建的词法环境之间的关系,JavaScript 引擎将调用函数内置 [[Environment]]
属性与定义函数时的外部环境进行关联。
# 扩展
# 词法环境
词法环境(lexical environment)是 JavaScript 引擎内部用来跟踪标识符和特定变量之间的映射关系。
词法环境是 JavaScript 作用域的内部实现机制,人们通常称为作用域(scopes)。
# 私有属性[[Environment]]
在 JavaScript 标准中,为函数规定了用来保存定义时上下文的私有属性[[Environment]],私有属性也叫内部属性,内部属性无法直接访问或操作
# 函数执行原理
# 执行上下文
当 JS 引擎解析到可执行代码片段(通常是函数调用)的时候,就会先做一些执行前的准备工作,这个准备工作就是生成执行上下文(execution context)简称 EC,有时也被叫做执行环境。
JavaScript 标准把一段代码(包括函数),执行所需的所有信息定义为:“执行上下文”。生成执行上下文的过程基本包含 this 绑定,创建词法环境,创建变量环境等。
除了函数执行上下文外,还有全局执行上下文,代码开始执行时就会创建,将其压入执行栈的栈底,每个生命周期内只有一份。
JavaScript 中与闭包“环境部分”相对应的属于是“词法环境”,但是 JavaScript 函数比 λ 函数要复杂的多,还要处理 this,变量声明、with 等等一系列的复杂语法,所以在 JavaScript 的设计中,词法环境只是 JavaScript 执行上下文的一部分。
执行上下文在 ES3 中,包含三个部分:
- scope:作用域,也常常被叫做作用域链。
- variable object:变量对象,用于存储变量的对象
- this value:this 值。
在 ES5 中,改进了命名方式:
- lexical environment:词法环境,当获取变量时使用。
- variable environment: 变量环境,当声明变量时使用。
- this value:this 值
在 ES2018 中,执行上下文又变成了下面这个样子,this 值被归入 lexical environment
- lexical environment:词法环境,这是与代码中变量和函数的作用域相关的概念。词法环境由两部分组成:环境记录(Environment Record)和外部词法环境的引用(a reference to the outer environment)。环境记录存储了在相应作用域内声明的所有局部变量(包括函数参数、局部变量和函数声明)的实际绑定。外部词法环境的引用则表示它可以访问其父级作用域。
- variable environment:变量环境,专门用来存储使用 var 关键字声明的变量的绑定。
- code evaluation state:用于恢复代码执行位置。在中断执行(如在生成器还书)后,代码可以从中断点恢复执行。
- Function:执行的任务是函数时使用,表示正在被执行的函数。
- ScriptOrModule: 执行的任务是脚本或者模块时使用,表示正在被执行的代码。
- Realm:使用的基础库和内置对象实例。
- Generator:仅生成器上下文有这个属性,表示当前生成器。
建议使用最新的 ES2018 中规定的术语定义。
# Realm
在最新的标准(9.0)中,JavaScript 引入了一个新概念 Realm。Realm 中包含一组完整的内置对象,而且是复制关系。
对不同 Realm 中的对象操作,会有一些需要格外注意的问题,比如 instanceOf 几乎是失效的。
以下代码展示了在浏览器环境中获取来自两个 Realm 的对象,它们跟本土的 Object 做 instanceOf 时会产生差异。
var iframe = document.createElement('iframe')
document.documentElement.appendChild(iframe)
iframe.src="javascript:var b = {};"
var b1 = iframe.contentWindow.b;
var b2 = {};
console.log(typeof b1, typeof b2); //object object
console.log(b1 instanceof Object, b2 instanceof Object); //false true
可以看到,由于 b1、b2 由同样的代码“{}”在不同的 Realm 中执行,所以表现出了不同的行为。
# JavaScript 基于单线程的执行模型
在 JavaScript 中,代码执行的基础单元是函数。JavaScript 代码有两种类型:一种是全局代码(包括脚本、模块),在所有函数外部定义;一种是函数代码,位于函数内部。
JavaScript 引擎执行代码时,每一条语句都处于特定的执行上下文(execution context)中 。既然具有两种类型的代码,那么就有两种执行上下文:全局执行上下文和函数执行上下文。二者最重要的差别是:全局执行上下文只有一个,当 JavaScript 程序开始执行时就已经创建了全局上下文;而函数执行上下文是在每次调用函数时,就会创建一个新的。(ES3 中每个执行上下文都有一个表示变量的对象——变量对象。全局环境的变量对象(global object)始终存在,而函数这样的局部环境的变量对象(variable object),只在函数执行的过程中存在(activation object)。调用函数时,会创建一个预先包含全局变量对象的作用域链,这个作用域链被保存在内部的[[Scope]]属性中,当调用函数是,会创建一个执行环境,然后通过复制函数的[[Scope]]属性中的对象构建起执行环境的作用域链)
在某个特定的时刻只能执行特定的代码。一旦发生函数调用,当前的执行上下文必须停止执行,并创建新的函数执行上下文来执行函数。当函数执行完成后,将函数执行上下文销毁,并重新回到发生调用时的执行上下文中(以上所说的过程也称为“切换上下文”)。所以需要跟踪执行上下文——正在执行的上下文以及正在等待的上下文。最简单的跟踪方法是使用执行上下文栈(execution context stack)或称为调用栈(call stack)
例如:
function skulk(ninja) {
report(ninja + " skulking");
}
function report(message) {
console.log(message);
}
skulk('Kuma')
上面的代码,执行上下文的行为如下:
- 每个 JavaScript 程序只创建一个全局执行上下文,并从全局执行上下文开始。当执行全局代码时,全局执行上下文处于活跃状态。
- 首先在全局代码中定义两个函数:skulk 和 report,然后调用 skulk('Kuma')。由于在同一个特定时刻只能执行特定代码,所以 JavaScript 引擎停止执行全局代码,开始执行带有 Kuma 参数的 skulk 函数。创建新的函数执行上下文,并置于执行上下文栈的顶部。
- skulk 函数进而调用 report 函数。又一次因为在同一个特定时刻只能执行特定代码,所以,暂停 skulk 执行上下文,创建新的 Kuma skulking 作为参数的 report 函数的执行上下文,并置于执行上下文栈的顶部。
- report 通过内置函数 console.log 打印出消息后,report 函数执行完成,代码又回到了 skulk 函数。report 函数执行上下文从执行上下文栈的顶部弹出,skulk 函数执行上下文重新激活,skulk 函数继续执行。
- skulk 函数执行完成后也发生类似的事情:skulk 函数执行上下文从栈顶端弹出,重新激活一直在等待的全局执行上下文并恢复执行。JavaScript 的全局代码恢复执行。
# 函数上下文和函数执行上下文的区别
当调用函数时可以通过 this 关键字访问函数上下文,而函数执行上下文,虽然也称为上下文,但完全不是一样的概念。执行上下文是内部的 JavaScript 概念,JavaScript 引擎使用执行上下文来跟踪函数的执行。
# 函数概念扩展
# 函数重载
JavaScript 函数不能像传统意义上那样实现重载。而在其他语言(如 Java)中,可以为一个函数编写两个定义,只要这两个定义的签名(接受的参数的类型和数量)不同即可。
但是 JavaScript 函数没有签名,因为其参数是由包含零或多个值的数组表示的,而没有函数签名,真正的重载是不可能做到的。
除此之外,将函数名想象为指针,也有助于理解为什么 JavaScript 中没有函数重载的概念。如果声明了两个同名函数,结果就是后面的函数覆盖了前面的函数。