这是深入理解 JavaScript 语法合集的第六篇,其余文章见文末。
文末介绍了一些辅助知识—— JavaScript 的词法作用域、动态作用域,以及
this
和 动态作用域的关系。如果这一部分还有疑问可以直接跳转到最后先阅读一下。
前言
在开始之前,我们要摆正两个观念:
this
并不是指向本身this
在任何情况下,都不指向函数的词法作用域
那 this
到底什么呢?this 的绑定和函数声明的位置没有任何关系,它是在函数被调用时发生的绑定,它指向什么完全取决于函数在哪里调用。换句话说,它只取决于函数的调用方式。
全文大纲总览
- 绑定规则
- 默认绑定
- 隐式绑定
- 显示绑定
new
绑定
- 优先级
- 软绑定
- 箭头函数的
this
指向 - 你可能不知道的词法作用域
- 作用域
- 词法作用域
- 动态作用域
一、绑定规则
(1)默认绑定
首先要介绍的是最常用的函数调用类型:独立函数调用。可以把这条规则当成是,当其它规则都无法应用时的默认选项。我们先来看一个例子:
1 | function foo() { |
我们可以看到,当我们调用 foo()
函数时,调用 this.a
,this
解析了全局变量 a
。为什么呢?因为函数在调用时应用了 默认绑定,因此 this
指向了全局对象。
值得注意的是,如果函数使用严格模式,则不能将全局对象用于默认绑定,因此 this
会被绑定到 undefined
。
1 | function foo() { |
(2)隐式绑定
根据调用位置是否有上下文对象判断是否为隐式绑定。
1 | function foo() { |
从输出的结果中,我们可以判断:当函数引用上下文对象时,隐式绑定规则会把函数调用的 this
绑定到这个上下文对象。因为调用 foo()
时 this
被绑定到 obj
,因此 this.a
和 obj.a
是一样的。
但是,并不是所有的事情都能随我们心愿的。在一些情况下,这个隐式绑定会丢失。我们来看一个例子:
1 | function foo() { |
看!和我们想象中的不一样了。这是为什么呢?这是因为,虽然 bar
是 obj.foo
的一个引用,但是实际上,它引用的仅仅是 foo
函数本身,因此此时的 bar()
其实是一个不带任何修饰的函数调用,所以这里使用的规则变成了默认绑定。
另一种隐式丢失的情况是发生在参数传递的时候。当我们将函数作为参数传递时,该函数也会被隐式的赋值。
1 | function foo() { |
(3)显示绑定
在分析隐式绑定时,我们必须在一个对象内部包含一个指向函数的属性,并通过这个属性间接的引用函数,从而把 this
间接的绑定到这个对象上。
那么如果我们不想在对象内部包含函数引用,而想在某个对象上强制调用函数,该怎么做呢?
可以使用 call()
和 apply()
方法。这两个方法在 this
的应用是一致的,其它的区别请见:apply、call的区别和用途。
1 | function foo() { |
可是,显示绑定仍然无法解决我们之前提到的绑定丢失的问题。下面有两种方法来解决这个问题。
1.硬绑定
1 | function foo() { |
我们通过接用 bar
,在其内部将 foo
的 this
强制绑定到 obj
上。这种方式就叫做硬绑定。
硬绑定比较有两个比较典型的应用场景,第一个是创建一个包裹函数,负责接受参数并返回值:
1 | function foo(something) { |
另一种是创建一个可以重复使用的辅助函数:
1 | function foo(something) { |
由于硬绑定是一种非常常用的方式,所以ES5提供了内置的方法 Function.prototype.bind
,它会返回一个硬编码的新函数,把指定的参数设置为 this
的上下文,并调用原始函数。bind
函数详解请看这里:深度解析bind原理、使用场景及模拟实现
1 | function foo(something) { |
2.API调用上下文
许多的第三方库,以及 JavaScript 语言和宿主中许多新的内置函数,都提供了一个可选的参数,通常被称为“上下文”,其作用和 bind()
一样,确保你的回调函数使用指定的 this
。
1 | function foo(el) { |
(4)new
绑定
在讨论 new
绑定之前,我们先来认清楚一件事情。在许多语言中(如java),使用 new
初始化类时会调用类中的构造函数。那 JavaScript 是这样的嘛?我们来看看 Number()
作为构造函数时的行为,ES5.1 是这样描述的:
15.7.2 Number 构造函数
当
Number
在new
表达式中被调用时,它是一个构造函数:它会初始化新创建的对象。
现在我们可以重新来定义一下 JavaScript 中的“构造函数”了:在 JavaScript 中,构造函数只是一些使用 new
操作符时被调用的函数。它不会属于某个类,也不会实例化一个类。实际上,它们只是被 new
操作符调用的普通函数而已。
当我们使用 new
操作符时,会自动执行以下四个操作:
- 创建一个全新的对象
- 这个新对象会被执行
[[Prototype]]
连接 - 这个新对象会绑定到函数调用的
this
- 如果函数没有返回其它对象,那么
new
表达式中的函数会自动返回这个新对象。
1 | function foo(a) { |
二、优先级
先说结论:按照上面讨论的顺序,默认绑定 < 隐式绑定 < 显示绑定 < new
绑定。
不用说,默认绑定的优先级肯定是最低的。下面我们来对其它情况测试一下:
(1)隐式绑定和显示绑定
1 | function foo() { |
我们看到,先将 this
绑定到对象 obj1
上,输出 2
。当我们再将 this
显示的绑定给对象 obj2
时,输出了 3
。
(2)隐式绑定和 new
绑定
1 | function foo(something) { |
(3)显示绑定和 new
绑定
我们前面在讨论 bind
函数时了解到,这个函数会忽略当前的 this
绑定,并把我们提供的对象绑定到对象上。这样看,好像硬绑定(显示绑定的一种)会比 new
的优先级更高一些,我们来看看是不是这样:
1 | function foo(something) { |
bar
被硬绑定到了 obj
上,但是 new bar(3)
并没有我们预计的那样把 obj.a
修改为 3
。相反,new
修改了硬绑定(到 obj
的)调用 bar()
中的 this
。
三、软绑定
前面提到,硬绑定会强制的将 this
绑定到指定的对象(除了 new
),防止函数调用默认绑定规则。但是,这样做会大大的降低函数的灵活性,使用硬绑定之后就没有办法使用隐式绑定或显示绑定来修改 this
。
如果可以给默认绑定指定一个全局对象和 undefined
以外的值,那就可以实现和硬绑定一样的效果,同时保留隐式绑定或显示绑定修改 this
的能力。
可以通过一种被称为软绑定的方法来实现我们想要的效果:
1 | if(!Function.prototype.softBind) { |
四、箭头函数的 this
指向
或许只有少部分人才会关注箭头函数的 this
指向,但是这确实 ES6 引入箭头函数的一个重要原因之一——解决开发过程中 this
随着上下文动态调整以至于难以判断的问题。箭头函数不使用我们前面提到的四种标准规则,而是根据外层(函数或者全局)作用域来决定 this
。
1 | function foo() { |
foo()
内部创建的箭头函数会捕获调用时 foo()
的 this
。由于 foo()
的 this
绑定到 obj1
,bar
的 this
也会绑定到 obj1
,箭头函数的绑定无法被修改,new
也不行。
五、你可能不知道的词法作用域
(1)作用域
你可能在文章的最一开始就对“词法作用域”产生了疑惑,这是什么?
《你不知道的 JavaScript 》上卷第一章第一节介绍作用域时这样写道:
但是将变量引入程序会引起几个有意思的问题,也正是我们将要讨论的:这些变量住在哪里?换句话说,它们存储在哪里?最重要的是,程序需要时如何找到它们?
这些问题说明需要一套良好的规则来存储变量,并且之后可以方便的找到这些变量。这套规则被称为作用域。
作用域共有两种主要的工作模型。第一种是最为普遍的,被大多数编程语言所采作用的词法作用域,即静态作用域,还有一种叫做动态作用域。
JavaScript 所采用的就是前者,词法作用域。
(2)词法作用域(静态作用域)
那什么是词法作用域呢?它的定义是这样的:词法作用域就是定义在词法阶段的作用域。
换句话说,词法作用域是基于作用域嵌套的,它在我们写代码或者定义时就确定的。比起动态作用域,它更关注我们的代码在何处声明。我们来看一个经典的栗子:1
2
3
4
5
6
7
8
9
10
11
12let a = 3;
function bar() {
console.log(a);
}
function foo() {
let a = 4;
bar();
}
foo(); // 3
看到没?最后输出的是全局作用域中的 3
,而不是调用它的 foo
函数中的 4
。它的原理就是,当我们在函数 bar
中打印变量 a
,但是 bar
这个函数作用域中并没有这个变量,于是引擎就会去该构造函数作用域的外层作用域——全局作用域来查找,于是找到了变量 a
输出。
(3)动态作用域
那动态作用域是如何表现的呢?我们来看看相同的栗子(使用 JavaScript 代码,仅仅是展示效果):
1 | let a = 3; |
如果是动态作用域,那当我们调用 foo()
时,它会输出 4
。原因是因为 动态作用域中,当我们在当前作用域找不到该变量时,就会沿着调用栈向上查找。引擎在函数 bar
中找不到变量 a
,就会从沿着调用栈向上查找,找到了 foo
函数作用域中,这里定义了 a
,于是最后返回了 4
。
可能你也发现了,
this
的隐式绑定和动态作用域的查找有些相似。它们都是基于调用栈来进行的。
最后,我们再总结一下:词法作用域是在写代码或者说是定义的时候确定的,而动态作用域是在运行时确定的(this
也是)。词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用。
这是深入理解 JavaScript 语法合集的其中一篇,其它合集:
1. 深入理解 JavaScript —— 从设计一门语言讲起
2. 你真的掌握了 JavaScript 变量和类型嘛?(上)
3. 你真的掌握了 JavaScript 变量和类型嘛?(下)
4. JavaScript 的原型和原型链
5. JavaScript 中的对象
参考文章:
1.《你不知道的 JavaScript 》上卷 by Kyle Simpson
2. JavaScript深入之词法作用域和动态作用域-2017-04-23 by mqyqingfeng
有问题?发送 issues 给我~