前言
JavasScript 中的 this 因指向灵话、使用场景多样,一直是面试中的热点,无论对于初级开发者还是中高级开发者,几乎都是必考内
容。这个概念虽然基础,但是非常重要,是否能深刻理解 this, 是前端 JavaScript 进阶的重要一环。this 指向多变,很多隐蔽的 bug 都
源于它。与此同时,this 强大灵活,如果能熟练驾驭,就会写出更加简捷、优雅的代码。
社区中对于 this 的讲够虽然不少,但缺乏统一梳理。 本篇中,我们将直面 this 的方方面面,并通过例题真正领会与掌握 this 的用法。
this 到底指向谁
记得在面试时,面试官会从多个角度考查过我对 this 的理解:全局环境下的 this、 箭头函数中的 this 、构造函数中的 this 、 this 的显隐性和优先级等。尽管前面的问题我都能一一作答,可是最后一个问题 ── 请用一句话总结 this 的指向(注意只用一句话),却让我犯难了。
有一种广为流传的说法是“谁调用它,this 就指向谁”。
也就是说,this 的指向是在调用时确定的。这么说没有太大的问题,可是并不全面。面试官要求我用更加规范的语言进行总结,那么他到底在等什么样的回答呢?
我们还要回到 JavaScript 中一个最基本的概念──执行上下文上面,这个概念会在 02 篇中进行扩展。
关于this 指向的具体细节和规则后面再慢慢分析,这里可以先“死记硬背”以下几条规律:
- 在函数体中,非显示或隐式地简单调用函数时,在严格模式下,函数内的 this 会被绑定到 undefined 上,在非严格模式下则会被绑定到全局对象 window/global 上。
- 一般使用 new 方法调用构造函教时,构造函数内 this 会被绑定到新创建的对象上。
- 一般通过 call/apply/bind 方法显示调用函数时,函数体内的 this 会被绑定到指定参数的对象上。
- 一般通过上下文对象调用函数时,函数体内的 this 会被绑定到该对象上
- 在箭头函数中,this 的指向是由外层 (函数或全局) 作用城来决定的。
当然,真实环境多种多样,下面就根据具体环境来逐一梳理。
实战例题分析
有人说,JavaScript 的 this 在某种程度上体现了 JavaScript 初期设计的不足,因此不需要仔细研究这些“糟粕”;也有人翻出规范,照本宣读,但这也许会让读者更加感觉“云里雾里”。其实,“糟粕”真不意昧着没必要学。虽然我也不认为对于各种关于 this 的用法倒背如流就是好的,但是了解它的这些“天生特性”能够切实避免写出问题代码,也能使代码更具有可读性。
我认为某些概念“只有记死,才能用活”,所以让我们从下面的例子中体会这一点吧!先将一些用法死记硬背,我相信你慢慢地便会完全理解。
例题组合1:全局环境中的this
我们来看例题:请给出下面代码的运行结果。
function f1() {
console.log(this)
}
function f2() {
'use strict'
console.log(this)
}
f1() // window
f2() // undefined
这种情况相对简单、直接,函数在浏览器全局环境中被简单调用,在非严格模式下 this 指向 window, 在通过 use srict 指明严格模式的情况下指向undefined。
这道题比较基础,但是需要面试者格外注意其变种。例如这样一道题目:请给出下面代码的运行结果。
const foo = {
bar: 10,
fn: function() {
console.log (this)
console.log (this.bar)
}
var fnl = foo.fn;
fn1();
这里的 this 仍然指向 window。虽然 fn 函数在 foo 对象中用来作为对象的方法,但是在赋值给 fn1 之后,fn1 仍然是在 window 的全局环境中执行的。因此,以上代码会输出 window 和 undefined, 其输出结果与以下语句的等价。
console.log(window);
console.1og(window.bar);
还是上面这道题目,如果将调用改为以下形式,
const foo = {
bar: 10,
fn: function() {
console.log(this);
console.log(this.bar);
}
}
foo.fn();
则输出如下所示:
{ bar:10, fn: f }
10
这时,this 指向的是最后调用它的对象,在 foo.fn() 语句中,this 指向 foo 对象。请记住,在执行函数时不考虑显式绑定,如果函数中的 this 是被上一级的对象所调用的,那么 this 指向的就是上一级的对象;否则指向全局环境。
例题组合2: 上下文对象调用中的 this
参考上面的结论,面对“给出以下代码的输出结果”这样的问题时,我们将不再困惑。运行以下代码,最终会返回true。
const student = {
name: ' Lucas',
fn: function() {
return this
}
}
console.1og(student.fn() === student)
当存在更复杂的调用关系时,如以下代码中的嵌套关系,this 会指向最后调用它的对象,因此输出将会是 Mike。
const person = {
name: ' Lucas ',
brother:{
name: 'Mike' ,
fn: function() {
return this.name
}
}
}
console.1og (person.brother.fn())
至此,this 的上下文对象调用已经介绍得比较请楚了。我们再有一道更高阶的题目: 请描述以下代码的运行结果。
const o1 = {
text: 'o1',
fn: function() {
return this.text
}
}
const o2 = {
text: 'o2',
fn: function() {
return o1.fn();
}
}
const o3 = {
text: 'o3',
fn: function() {
var fn = o1.fn;
return fn();
}
}
console.log(o1.fn()); // o1
console.log(o2.fn()); // o1
console.log(o3.fn()); // undefined
- 第一个 console 最简单,输出 o1 不难理解。难点在第二个和第三个 console 上,关键还是看调用 this 的那个函数。
- 第二个 console 中的 o2.fn() 最终调用的还是 ol.fn(),因此运行结果仍然是 ol。
- 最后一个 cnsole 中的 o3.fn() 通过 var fn = ol.fn 的赋值进行了“裸奔”调用,因此这里的 this 指向 window, 运行结果当然是 undefined。
如果面试者回答顺利,面试官可能紧接着追问: 如果我们需要让 console.log( o2.fn() ) 语句输出 o2,该怎么做?
一般面试者可能会想到使用 bind,clll,apply 来对 this 的指向进行干预,这确实是一种思路。 但是面试官可能还会接着问:如果不能使用 bind,call, apply,还有别的方法吗?
这个问题可以考在面试者对基础知识的掌程深度及随机应变的思维能力。答案是,当然还有别的方法,如下。
const o1 = {
text: 'o1',
fn: function() {
return this.text
}
}
const o2 = {
text: 'o2',
fn: o1.fn
}
console.log(o2.fn()); // o2
以上方法同样应用了那个重要的结论:this 指向最后调用它的对象。在上面的代码中,我们提前进行了赋值操作,将函数 fn 挂载到 o2对象上,fn 最终作为 o2 对象的方法被调用。
例题组合3:通过 bind、call、apply 改变 this 指向
上文提到 bind、call、apply,与之相关的比较常见的基础考查点是: bind、call、apply 这3个方法的区别。
这样的问题相对基础,所以我们直接给出答案:用一句话总结,它们都是用来改变相关函数 this 指向的,但是 call
和 apply
是直接进行相关函数调用的; bind
不会执行相关函数,而是返回一个新的函数,这个新的函数已经自动绑定了新的 this 指向,开发者可以手动调用它。如果再说具体一点,就是 call
和 apply
之间的区别主要体现在参数设定上,不过这里就不再展开来讲了。
用代码来总结的话,以下3段代码是等价的。
// 1
const target = {}
fn.call(target, 'arg1', 'arg2')
// 2
const target = {}
fn.apply(target, ['arg1’', 'arg2'])
// 3
const target = {}
fn.bind(target, 'argl', 'arg2')()
具休用法这里不再说明,读者如果内不清楚,请自已了解下必要的物风点,下面我们来看一道例题并对其进行分析。
下面我们来看一道例题并对其进行分析。
const foo = (
name: 'lucas',
logName: function() {
console.log(this.name)
}
}
const bar = {
name : 'mike ',
}
console.log(foo.logName.call(bar))
以上代码的执行结果为 mike,这不难理解。但是对 call、apply、bind 的高级考查往往需要面试者结合构造函数及组合来实现继承(实现继承的话题之后会单独讲解)。关于构造函数的使用案例,我们会结合接下来的例题组合进行展示。
例题组合4:构造函数和this
关于构造函数和 this,我们来看一道最直接的例题,如下。
function Foo() {
this.bar = "Lucas"
}
const instance = new Foo()
console.log(instance.bar)
执行以上代码将会输出 Lucas,但是这样的场景往往伴随着一个问题: new 操作符调用构造函数时具体做了什么呢?以下答案(简略版答案)仅供参考。
- 创建一个新的对象。
- 将构造函数的 this 指向这个新的对象。
- 为这个对象添加属性、方法等。
- 最终返回新的对象。
上述过程也可以用如下代码表述。
var obj={}
obj._ proto__ = Foo.prototype
Foo.call(obj)
当然,这里对 new 的模拟是一个简单、基础的版本,更复杂的版本会在原型、原型链相关的篇章中讲述。
需要指出的是,如果在构造函数中出现了显式 return 的情况,那么需要注意,其可以细分为两种场景。
场景1:执行以下代码将输出 undefined, 此时 instance 返回的是空对象 o。
function Foo() (
this.user = "Lucas"
const o = {}
return o
}
const instance = new Foo()
console.log(instance.user)
场景2:执行以下代码将输出 Lucas,也就是说,instance 此时返回的是目标对象实例 this。
function Foo() {
this.user = "Lucas"
return 1
}
const instance = new Foo()
console.1og(instance.user)
所以,如果构造函数中显式返回一个值,且返回的是个对象 (返回复杂类型),那么 this 就指向这个返回的对象;如果返回的不是一个对象 (返回基本类型) ,那么 this 仍然指向实例。
例题组合5:箭头函数中的this
介绍例题前,我们先来温习下相关结论:在箭头函数中,this 的指向是由外层 (函数或全局) 作用域来决定的。《你不知道的JavaScript》一书中这样描述箭头函数中的 this: the enclosing(function or global)scope (说明:箭头函数中的 this 指向是由其所属函数或全局作用域决定的)。
下面来看一段示例代码。在这段代码中,this 出现在 setTimeout() 的匿名函数中,因此 this 指向 window 对象。
const foo = {
fn: function () {
setTimeout(function() (
console.log(this)
})
}
}
console.log(foo.fn())
如果需要让 this 指向 foo 这个对象,则可以巧用箭头函数来解决,代码如下。
const foo = {
fn: function () {
setTimeout(() => (
console.log(this)
})
}
}
console.log(foo.fn())
// {fn: f}
单纯的箭头函数中的 this 指向问题非常简单,但是如果综合所有情况,并结合 this 的优先级进行考查,那么这时 this 的指向并不容易确定。下面就来学习 this 优先级的相关知识。
例题组合6:this 优先级
我们常常把通过 call、apply、 bind、 new 对 this 进行绑定的情况称为显式绑定
,而把根据调用关系确定 this 指向的情况称为隐式绑定
。
那么显式绑定和隐式绑定谁的优先级更高呢? 关于这个问题的答案,我们会在接下来的例题中为大家揭晓。
执行以下示例代码。
function foo(a) (
console.log(this.a)
}
const obj1 = {
a: 1,
foo: foo
}
const obj2 = {
a: 2,
foo: foo
}
obj1.foo.call(obj2)
obj2.foo.call(obj1)
输出分别为2、1,也就是说,call、apply 的显式绑定一般来说优先级更高。下面再来看另一段实例代码。
function foo(a) (
this.a = a
}
const obj1 = {}
var bar = foo.bind(obj1)
bar(2)
console.1og(obj1.a)
上述代码通过 bind 将 bar 函数中的 this 绑定为 obj1 对象。执行 bar(2) 后,obj1.a 值为2,即执行 bar(2) 后,obj1 对象为 {a:2}。
当再使用 bar 作为构造函数时,例如执行以下代码,则会输出3。
var baz = new bar(3)
console.log(baz.a)
bar 函数本身是通过 bind 方法构造的函数,其内部已经将 this 绑定为 obj1,当它再次作为构造函数通过 new 被调用时,返回的实例就已经与obj1解绑了。也就是说,new 绑定修改了 bind 绑定中的 this 指向,因此 new 绑定的优先级比显式 bind 绑定的更高。
再来看一个示例,如下。
function foo() {
return a => {
console. log(this.a)
}
}
const obj1 = { a: 2 }
const obj2 = { a: 3 }
const bar = foo.call(obj1)
console.1og(bar.call(obj2))
以上代码的输出结果为2。由于 foo 中的 this 绑定到了 obj1 上,所以 bar (引用箭头函数)中的 this 也会绑定到 obj1 上,箭头函数的绑定无法被修改。
如果将 foo 完全写成如下所示的箭头函数的形式,则会输出123。
var a = 123
const foo = () => a => {
console. log(this.a)
}
const obj1 = { a: 2 }
const obj2 = { a: 3 }
const bar = foo.call(obj1)
console.1og(bar.call(obj2))
这里我再“抖个机灵”,仅仅将上述代码中第一处变量a的声明修改一下,即变成如下所示的样子,大家猜猜输出结果会是什么呢?
const a = 123
const foo = () => a => {
console. log(this.a)
}
const obj1 = { a: 2 }
const obj2 = { a: 3 }
const bar = foo.call(obj1)
console.1og(bar.call(obj2))
答案为 undefined,原因是使用 const 声明的变量不会挂载到 window 全局对象上。因此,this 指向 windows 时,自然也找不到 a 变量了。关于 const 或 let 等声明变量的方式不在本篇的讨论范现内,后续会进行专门介绍。
到这里,读者是否有“融会贤通”的感觉了呢?如果还有困感,也不要灰心。进阶的关键就是夯实基础,基础需要反复学习,“死记硬背” 后才能慢慢领会。
开放例题分析
不知道实战例题分析是否已经把你烧晕了。事实上,this 的指向涉及的规范繁多,优先级也较为混乱。刻意刁难面试者并不是很好的做法,对于一些细节, 面试者如果没有记住也没有太大问题。作为面试官,我往往会另辟蹊径,问一些开放性的题目。
其中,最典型的一道题目为:实现个 bind 函数。
作为面试者,我也曾经在面试流程中被问到模拟 bind 的问题。这道题井不新鲜,部分读者也会有自己的解答思路,而且社区上关于原生 bind 的研究也很多。但是,我们这里想强调的是可能被大家忽略的一些细节。 在回答时,我往往会先实现一个初级版本,然后根据ES5-shim源代码做进步。
Function.prototype.bind = Function.prototype.bind || function() {
var me = this;
var args = Array.prototype.slice.call(arguments, 1);
return function bound() {
var innerArgs = Array.prototype.slice.call(arguments);
var finalArgs = args.concat(innerArgs);
return me.apply(context, finalArgs);
}
}
这样的实现已经非常不错了。但是,就如之前在 this 优先级分析那里所展示的规则:bind 返回的函数如果作为构造函数搭配 new 关键字出现的话,绑定的 this 就会“被忽略”。
为了实现这样的规则,开发者需要考虑如何区分这两种调用方式。具体来讲就是,要在 bound 函数中进行 this instanceof 判断。
另外一个细节是,函数具有 length 属性,用来表示形参的个数。在上述实现方式中,形参的个数显然会失真。所以,改进的实现方式需要对 length 属性进行还原。可是难点在于,函数的 length 属性值是不可重写的。
这样的内容一般属于“超纲”范畴,但在面试中能够很好地体现面试者平时的积累及对源码的阅读和思考,如果面试者能够回答出来,这显然是加分项。
总结
通过本篇内容的学习,我们看到 this 的用法纷繁多象,确实不容易彻底掌握。本篇尽可能系统地对 this 的用法进行讲解、说明,例题尽可能地覆盖更多场景,但还需要读者在阅读之外继续消化与吸收。只有“记死",才能“用活”。
如果读者还有困感, 也不要灰心。 事实上,资深工程师也不敢保证针对所有场景都能给出很好的解决方案,也存在理解不到位的情况。也许区别资深工程师和菜乌工程师的点,不完全在于他们回答应试题目的准确率,更在于他们怎么思考问题、解决问题。如果不懂this 指向,那就动手实践一下:如果不了解原理,那就翻出规范来看一下,没有什么大不了的。