前言
在 JavaScript 学习过程中,除了作用域与闭包之外,困扰我们的难点还有 this,甚至可以说是前端 JS 学习的噩梦。实际上 this 并没有想象中的那么复杂,只是开发者往往会将理解过程复杂化,在缺乏清楚认识的情况下,this 对我们来说就完全是一种魔法了。而此篇就来揭开 this 神秘的面纱。
注:此篇为学习《你所不知道的 JS》时的心得总结
示例分析
工作中我们常常遇到 this 指向问题,看个简单的代码:
1 | function foo(num) { |
了解比较深入的会一眼看出为什么加一个 call 就没问题(强绑定指向),了解的一般的就会通过声明一个全局变量来缓存对应的值来绕过 this,虽然用这种其它变量替代 this 能够绕过这个问题,但是这样永远也了解不到 this 的真谛。回过头来思考,为什么必须强绑定才能正确指向?如果不进行强绑定,this 默认又指向什么呢?要深入了解到 this 的原理,首先我们得认识到自己的理解误区。
理解误区
学习 this 时常见的错误理解大致有两种:
- this 指向函数自身
- this 指向函数作用域
可能因为我们最早接触的语言是 C 语言,而 C 语言的编译顺序大致为:
- 词法分析
- 语法分析
- 语义分析
- 中间代码生成
- 代码优化(可不要)
- 目的代码生成
因此在学习 JS 是会自然而然的套用此方法来理解函数。我们可以试想一下,如果 JS 也走这么一套流程来处理,那么网页的加载得多慢啊。因此在编译这部分我们得抛弃 C 语言的那一套逻辑,这样才方便理解 this 的指向。
与 C 语言编译不同,JS 的编译为运行时编译,其会先从头到尾“过”一遍待解析的文件,粗略的提取其中的变量声明,然后逐行解析编译(解释了为什么会变量提升),因此 this 的状态就并非那种能够生成中间代码的存储态,而是动态的。也就是说,当函数执行时,this 才会真正的进行绑定,而并不是在最开始(遍历 JS 语法)时绑定,毕竟没有听说过什么“ this 提升”对吧?
简而言之,因为 this 是在函数调用时才进行绑定,所以 this 并不是指向其所在的函数自身,也不指向函数所在的词法作用域,它的指向完全取决于函数在哪里被调用的。
this 全面解析
调用位置
前面有提到 this 指向取决于函数是在哪里调用的,即找到函数的调用位置,相当于我们就定位了 this。而寻找函数的调用位置,首先得找到函数所在的栈的位置,该栈所在的环境即 this 的指向。举个例子:
1 | function foo() { |
既然我们已经找到了 this 调用位置,那么 this 的值此时到底是什么,它所绑定的内容又是什么呢,而这就涉及到 this 的绑定规则。
绑定规则
找到 this 的调用位置之后,我们可以按照以下四条规则来判断 this 绑定的对象。
- 如果由 new 调用,那么 this 绑定到新创建的对象。
- 如果由 call 或者 applay(或者 bind)调用,那么绑定到其指定的对象。
- 如果由上下文对象调用,则绑定到该上下文对象。
- 默认:严格模式下绑定至 undefined,否则绑定至 window。
接下来就让我们自下而上,由面到点的了解这些绑定规则以及其排序来由。
注:上下文对象绑定即所谓的隐式绑定
默认绑定
举个很简单的例子:
1 | function foo() { |
调用 this 的调用栈在 foo 中,当前所处的位置是全局作用域,因此 this 指向全局的 window。
回头看先前调用位置时列举的例子,通过默认绑定规则,再加上引用传递,因此各部分的 this 指向都指向全局的 window 对象。
隐式绑定
隐式绑定,即规则的调用位置是否拥有其上下文(默认为全局的 window 对象)。例如:
1 | function foo() { |
在非严格模式下,全局环境的 this.name === window.name,此时所处的上下文环境为全局环境,而此例的 foo 调用是通过 obj.foo 来调用的,this 的调用位置为 foo,但是 foo 的引用落脚点此时不是 window 了,而是 obj 了,因此通过隐式绑定,this 此时之乡的是 obj,即此时的 this.name === obj.name。
此处需注意的一点是 obj.foo 的 this 绑定的是 obj(隐士绑定),这是因为,对象属性的引用链只有最顶层会影响调用位置(或者是说最后一层),例如:
1 | function foo() { |
而在隐式调用的情况下,最容易出现的问题是隐式丢失,例如:
1 | function foo() { |
注:参数传递就是一种隐式传递,其取得是对象的引用
显示绑定
JS 提供一种显示更改 this 绑定的函数:apply、call,例如:
1 | function foo() { |
通过 foo.call 可以显示的将 this 绑定在 obj 上,显示绑定无法解决隐式绑定中的引用传递问题(上面的 Case 3),而通过一种变形“硬绑定”可以解决。
硬绑定
与显示绑定不同的是,硬绑定会提前将 this 的指向在过渡函数内提前绑定上,这样当传入这个过渡函数时,this 就不会被隐式修改了,例如:
1 | function foo() { |
其实这种方式特别常见(如大多数源码中),将上例稍微变化一下,如:
1 | function foo(otherContent) { |
绕是绕了点,根据函数式编程的带入思想,其实就相当于
1 | const prefixStr = foo.apply(obj, ['age']); |
注: 硬绑定会大大降低函数的灵活性,使用硬绑定后就无法使用隐式绑定或者显示绑定来改变 this 了。
new 绑定
JavaScript 中所谓的“构造函数”其实与其它语言的类的构造函数不同,它并不属于某一个类,也不会去实例化一个类。在 JavaScript 中,构造函数只是一些使用 new 操作符时被调用的函数。使用 new 来调用函数时,会执行以下操作:
- 创建(或者说构造)一个全新的对象
- 这个新对象会被执行 [[原型]] 连接
- 这个新对象会绑定到函数调用的 this
- 如果函数没有返回其它对象,那么 new 表达式中的函数调用会自动返回这个新对象
面试题中可能会遇到这么一个题:
1 | function foo1(a) { |
知道这个 new 返回对象的“魔术”之后,我们就很清楚答案是 false 了,因为 foo2 有返回对象。而 foo1 因为没有函数返回值,因此会构造一个新对象并将 this 绑定至该新对象上。
new 是最后一种可以影响函数调用时 this 绑定行为的方法,我们称之为 new 绑定。
提示: f1.a 为 1; f2.a 为 2
优先级
现在规则我们已经比较清楚了,那么剩下的就是找到函数的调用位置,然后该使用那种规则去匹配。“默认绑定”毫无疑问是四条规则中优先级最低的,那么另外三条规则的优先级又该如何排序呢?那么我们一一来分析~
先给出结论: new 绑定 > 显示绑定 > 隐式绑定 > 默认绑定
隐式绑定和显示绑定
举个很简单的例子:
1 | function foo() { |
通过对比可以看到,显示绑定优先级更高。那么隐式绑定和 new 绑定的优先级顺序呢?
隐式绑定和 new 绑定
1 | function foo(a) { |
可以看到 new 绑定比隐式绑定优先级高,接下来就只剩 new 绑定和显示绑定的优先级了。
new 绑定和显示绑定
由于 new 和 call/apply 无法一起使用,因此无法通过 new foo.call(obj1) 来直接进行测试。但是我们可以用硬绑定来测试它们的优先级。举个例子:
1 | function foo(a) { |
在 new 中使用硬绑定函数,主要目的是预先设置函数的一些参数,这样在使用 new 进行初始化时就可以只传入其余的参数。bind(…)的功能之一就是可以把除了第一个参数(第一个参数用于绑定 this)之外的其他参数都传递给下层的函数(这种技术称为“部分应用”,是“柯里化”的一种)。举例来说:
1 | function foo(p1, p2) { |
注意,这种通过 null 的绑定其实会使用“默认绑定”规则,将 this 绑定至全局对象,这在使用第三方库时会存在风险,更为稳妥的方法是创建一个真正意义上的空对象,如: let ø = object.create(null),然后将此 ø 作为绑定的作用域(const bar = foo.bind(ø, ‘p1’);)
绑定例外
被忽略的 this
call、apply、bind 接受绑定的对象为 undefined、null 时,在调用时会被忽略,使用的是默认绑定规则,严格来说最好使用一个真正的 ø 元素来作为绑定对象(前面已经提到)。
1 | function foo() { |
间接引用
这个就不难说明了,间接引用会使得隐式绑定最顶端的对象改变,造成 this 的指向非我们所想象的对象,例如:
1 | const test = { |
箭头函数
ES6 中的箭头函数并不会使用四条标准的绑定规则,而是根据外层作用域来决定 this。例如:
1 | function foo() { |
因此,我们在使用这种 ES6 的“胖箭头”时,就可以抛弃 ES5 中的 var self = this
的“词法作用域”式的代码了。