《你不知道的JavaScript》-- 精读(七)

317 阅读14分钟

知识点

1.this全面解析

1.1 调用位置

调用位置就是函数在代码中被调用的位置(而不是声明的位置)。

通常来说,寻找调用位置就是寻找“函数被调用的位置”,最重要的是要分析调用栈(就是为了到达当前执行位置所调用的所有函数)。我们关心的调用位置就在当前正在执行的函数的前一个调用中。

function baz() {
    // 当前调用栈是: baz
    // 因此,当前调用位置是全局作用域
    console.log("baz");
    bar(); // bar的调用位置
}

function bar() {
    // 当前调用栈是baz -> bar
    // 因此,当前调用位置在baz中
    console.log("bar");
    foo(); // foo的调用位置
}

function foo() {
   // 当前调用栈是baz -> bar -> foo
   // 因此,当前调用位置在bar中
   console.log("foo");
}

baz(); // baz的调用位置

注意,我们是如何(从调用栈中)分析出真正的调用位置的,因为它决定了this的绑定。

1.2 绑定规则

我们来看看在函数的执行过程中调用位置如何决定this的绑定对象。

必须找到调用位置,然后判断需要应用下面规则中的哪一条。

1.2.1 默认绑定

首先要介绍的是最常用的函数调用类型:独立函数调用。可以把这条规则看作是无法应用其他规则时的默认规则。

function foo() {
    console.log(this.a);
}
var a = 2;
foo(); // 2

我们知道,声明在全局作用域中的变量(比如var a = 2;)就是全局对象的一个同名属性。当调用foo()时,this.a被解析成了全局变量a。因为在本例中,函数调用时应用了this的默认绑定,因此this指向全局对象。

如何判断默认绑定呢?可以通过分析调用位置来看foo()是如何调用的。在代码中,foo()是直接使用不带任何修饰的函数引用进行调用的,因此只能使用默认绑定,无法应用其他规则。

如果使用严格模式(strict mode),则不能将全局对象用于默认绑定,因此this会绑定到undefined:

function foo() {
    "use strict";
    console.log(this.a);
}
var a = 2;
foo(); // TypeError: this is undefined

虽然this的绑定规则完全取决于调用位置,但是只有foo()运行在非strict mode下时,默认绑定才能绑定到全局对象;在严格模式下,调用foo()则不影响默认绑定:

function foo() {
    console.log(this.a);
}
var a = 2;
(function(){
    "use strict";
    foo(); // 2
})()

1.2.2 隐式绑定

另一条需要考虑的规则是调用位置是否有上下文对象,或者说是否被某个对象拥有或包含,不过这种说法可能会造成一些误导。

function foo() {
    console.log(this.a);
}
var obj = {
    a: 2,
    foo: foo
}
obj.foo(); // 2

当foo()被调用时,它的前面确实加上了对obj的引用。当函数引用有上下文对象时,隐式绑定规则会把函数调用中的this绑定到这个上下文对象。因为调用foo()时this被绑定到obj,因此this.a和obj.a是一样的。

对象属性引用链中只有上一层或者说最后一层在调用位置中起作用。

function foo(){
    console.log(this.a);
}
var obj2 = {
    a: 42,
    foo: foo
}
var obj1 = {
    a: 2,
    obj2: obj2
}
obj1.obj2.foo(); // 42

隐式丢失

一个最常见的this绑定问题就是被隐式绑定的函数会丢失绑定对象,也就是说它会应用默认绑定,从而把this绑定到全局对象或者undefined上,取决于是否是严格模式。

function foo() {
    console.log(this.a);
}
var obj = {
    a: 2,
    foo: foo
}
var bar = obj.foo; // 函数别名
var a = "oops,global"; // a是全局对象的属性
bar(); // "oops,global"

虽然bar是obj.foo的一个引用,但是实际上,它引用的是foo函数本身,因此此时的bar()其实是一个不带任何修饰的函数调用,因此应用了默认绑定。

一种更微妙、更常见并且更出乎意料的情况发生在传入回调函数时:

function foo() {
    console.log(this.a);
}
function doFoo(fn) {
    // fn其实引用的是foo
    fn();
}
var obj = {
    a: 2,
    foo: foo
}
var a = "oops,global"; // a是全局对象的属性
doFoo(obj.foo); // "oops,global"

参数传递其实就是一种隐式赋值,因此我们传入函数时也会被隐式赋值,所以结果和上一个例子一样。

如果把函数传入语言内置的函数而不是传入你自己声明的函数,会发生什么呢?结果是一样的,没有区别:

function foo() {
    console.log(this.a);
}
var obj = {
    a: 2,
    foo: foo
}
var a = "oops,global"; // a是全局对象的属性
setTimeout(obj.foo,1000); // "oops,global"

JavaScript环境中内置的setTimeout()函数实现和下面的伪代码类似:

function setTimeout(fn,delay){
    // 等待delay毫秒
    fn(); // 调用位置
}

如上,回调函数丢失this绑定是非常常见的。除此之外,调用回调函数的函数也可能会修改this。

无论哪种情况,this的改变都是意想不到的,但是我们可以通过固定this来修复这个问题。

1.2.3 显式绑定

分析隐式绑定时,我们必须在一个对象内部包含一个指向函数的属性,并通过这个属性间接引用函数,从而把this间接(隐式)绑定到这个对象上。

那么如果我们不想在对象内部包含函数引用,而想在某个对象上强制调用函数,该怎么做呢?

可以使用函数的call(..)和apply(..)方法。它们的第一个参数是一个对象,是给this准备的,接着在调用函数时将其绑定到this。因为你可以直接指定this的绑定对象,因此我们称之为显式绑定。

function foo() {
    console.log(this.a);
}
var obj = {
    a: 2
}
foo.call(obj); // 2

通过foo.call(..),我们可以在调用foo时强制把它的this绑定到obj上。

如果你传入了一个原始值(字符串类型、布尔类型或者数字类型)来当做this的绑定对象,这个原始值会被转换成它的对象形式(也就是new String(..)、new Boolean(..)或者new Number(..))。这通常被称为“装箱”。

可惜,显式绑定仍然无法解决我们之前提出的丢失绑定问题。

硬绑定

但是显式绑定的一个变种可以解决这个问题。

function foo() {
    console.log(this.a);
}
var obj = {
    a: 2
}
var bar = function () {
    foo.call(obj);
}

bar(); // 2
setTimeout(bar,100); // 2
// 硬绑定的bar不可能再修改它的this
bar.call(window); // 2

我们创建了函数bar(),并在它的内部手动调用了foo.call(obj),因此强制把foo的this绑定到了obj。无论之后如何调用函数bar,它总会手动在obj上调用foo。这种绑定是一种显式的强制绑定,因此我们称之为硬绑定。

硬绑定的典型应用场景就是创建一个包裹函数,负责接收参数并返回值:

function foo(something) {
    console.log(this.a,something);
    return this.a + something;
}
var obj = {
    a: 2
}
var bar = function() {
    return foo.apply(obj,arguments);
}
var b = bar(3); // 2 3
console.log(b); // 5

另一种使用方法是创建一个可以重复使用的辅助函数:

function foo(something) {
    console.log(this.a,something);
    return this.a + something;
}
// 简单的辅助绑定函数
function bind(fn,obj) {
    return function() {
        return fn.apply(obj,arguments)
    }
}
var obj = {
    a: 2
}
var bar = bind(foo,obj);
var b = bar(3); // 2 3
console.log(b); // 5

由于硬绑定是一种非常常用的模式,所以ES5提供了内置的方法,Function.prototype.bind,它的用法如下:

function foo(something) {
    console.log(this.a,something);
    return this.a + something;
}
var obj = {
    a: 2
}
var bar = foo.bind(obj);
var b = bar(3); // 2 3
console.log(b); // 5

bind(..)会返回一个硬编码的新函数,它会把你指定的参数设置为this的上下文并调用原始函数。

API调用的“上下文”

第三方库的许多函数,以及JavaScript语言和宿主环境中许多新的内置函数,都提供了一个可选的参数,通常被称为“上下文”(context),其作用和bind(..)一样,确保你的回调函数使用指定的this。

function foo(el) {
    console.log(el,this.id)
}
var obj = {
    id: "awesome"
}
// 调用foo(..)时把this绑定到obj
[1,2,3].forEach(foo,obj);
// 1 awesome 2 awesome 3 awesome

这些函数实际上就是通过call(..)或者apply(..)实现了显式绑定。

1.2.4 new绑定

包括内置对象函数在内的所有函数都可以用new来调用,这种函数调用被称为构造函数调用。实际上,并不存在所谓的“构造函数”,只有对于函数的“构造调用”。

使用new来调用函数,或者说,发生构造函数调用时,会自动执行以下操作:

  • 1.创建(或者说构造)一个全新的对象
  • 2.这个新对象会被执行[[Prototype]]连接
  • 3.这个新对象会绑定到函数调用的this
  • 4.如果函数没有返回其他对象,那么new表达式中的函数调用会自动返回这个新对象。
function foo(a) {
    this.a = a;
}
var bar = new foo(2);
console.log(bar.a); // 2

使用new来调用foo(..)时,我们会构造一个新对象并把它绑定到foo(..)调用中的this上。new是最后一种可以影响函数调用时this绑定行为的方法,我们称之为new绑定。

1.3 优先级

现在我们知道了函数调用中this绑定的四条规则,接下来介绍的是这些规则的优先级。

毫无疑问,默认绑定的优先级是四条规则中最低的,隐式绑定和显式绑定哪个优先级更高?我们来测试一下:

function foo() {
    console.log(this.a)
}
var obj1 = {
    a: 2,
    foo: foo
}
var obj2 = {
    a: 3,
    foo: foo
}
obj1.foo(); // 2
obj2.foo(); // 3

obj1.foo.call(obj2); // 3;
obj2.foo.call(obj1); // 2

可以看到,显式绑定的优先级更高。

接下来我们看一下new绑定和隐式绑定的优先级谁高谁低:

function foo(something) {
    this.a = something
}
var obj1 = {
    foo: foo
}
var obj2 = {}
obj1.foo(2);
console.log(obj1.a); // 2

obj1.foo.call(obj2,3); 
console.log(obj2.a); // 3

var bar = new obj1.foo(4);
console.log(obj1.a); // undefined
console.log(bar.a); // 4

可以看到,new绑定比隐式绑定优先级高,但是new绑定和显式绑定谁的优先级更高呢?

function foo(p1,p2) {
    this.val = p1 + p2
}
var bar = foo.bind(null,"p1");
var baz = new bar("p2");
baz.val; // p1p2

可以看到,new绑定比显式绑定的优先级高。

判断this

现在我们可以根据优先级来判断函数在某个调用位置应用的是哪条规则。

  • 1.函数是否在new中调用(new绑定)?如果是的话this绑定的是新创建的对象。

    var bar = new foo()

  • 2.函数是否通过call、apply(显式绑定)或者硬绑定调用?如果是的话,this绑定的是指定的对象。

    var bar = foo.call(obj2)

  • 3.函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this绑定的是那个上下文对象

    var bar = obj1.foo()

  • 4.如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到undefined,否则绑定到全局对象。

    var bar = foo()

1.4 绑定例外

1.4.1 被忽略的this

如果把null或者undefined作为this的绑定对象传入call、apply或者bind,这些值在调用时会被忽略,实际应用的是默认绑定规则。

function foo() {
    console.log(this.a);
}
var a = 2;
foo.call(null); // 2

一般传入null是为了使用apply(..)来“展开”一个数组,并当做参数传入一个函数。类似地,bind(..)可以对参数进行柯里化(预先设置一些参数)

function foo(a,b) {
    console.log("a:" + a + ", b:" + b);
}
// 把数组“展开”成参数
foo.apply(null,[2,3]); // a:2, b:3
// 使用bind(..)进行柯里化
var bar = foo.bind(null,2);
bar(3); // a:2, b:3

这两种方法都需要传入一个参数当做this的绑定对象。如果函数并不关心this的话,你仍然需要传入一个占位值,这时null可能是一个不错的选择。

然而总是使用null来忽略this绑定可能产生一些副作用。因为默认绑定规则会将this绑定到全局对象。

更安全的this

一种“更安全”的做法是传入一个特殊的对象,把this绑定到这个对象不会对你的程序产生任何副作用。

function foo(a,b) {
    console.log("a:" + a + ", b:" + b);
}

// 我们的DMZ空对象
var ø = Object.create(null);

// 把数组展开成参数
foo.apply(ø,[2,3]); // a:2, b:3

// 使用bind(..)进行柯里化
var bar = foo.bind(ø,2);
bar(3); // a:2,b:3

1.4.2 间接引用

另一个需要注意的是,你有可能(有意或者无意地)创建一个函数的“间接引用”,在这种情况下,调用这个函数会应用默认绑定规则。

间接引用最容易在赋值时发生:

function foo() {
    console.log(this.a);
}

var a = 2;
var o = { a: 3, foo: foo };
var p = { a: 4 }
o.foo(); // 3
(p.foo = o.foo)(); // 2

赋值表达式p.foo=o.foo的返回值是目标函数的引用,因此调用位置是foo()而不是p.foo()或者o.foo()。所以这里会应用默认绑定。

注意:对于默认绑定来说,决定this绑定对象的并不是调用位置是否处于严格模式,而是函数体是否处于严格模式。如果函数体处于严格模式,this会被绑定到undefined,否则this会被绑定到全局对象。

1.5 this词法

箭头函数并不是使用function关键字定义的,而是使用被称为“胖箭头”的操作符=>定义的。箭头函数不使用this的四种标准规则,而是根据外层(函数或者全局)作用域来决定this。

我们来看看箭头函数的词法作用域:

function foo() {
    // 返回一个箭头函数
    return (a) => {
        // this继承自foo()
        console.log(this.a)
    }
}

var obj1 = {
    a: 2
}

var obj2 = {
    a: 3
}

var bar = foo.call(obj1);
bar.call(obj2); // 2, 不是3!

foo()内部创建的箭头函数会捕获调用时foo()的this。由于foo()的this绑定到obj1,bar(引用箭头函数)的this也会绑定到obj1,箭头函数的绑定无法被修改。(new也不行!)

箭头函数最常用于回调函数中,例如事件处理器或者定时器:

function foo() {
    setTimeout(() => {
        // 这里的this在词法上继承自foo()
        console.log(this.a);
    },100)
}

var obj = {
    a: 2
}

foo.call(obj); // 2

箭头函数可以像bind(...)一样确保函数的this被绑定到指定对象,此外,其重要性还体现在它用更常见的词法作用域取代了传统的this机制。实际上,在ES6之前我们就已经在使用一种几乎和箭头函数完全一样的模式。

function foo() {
    var self = this;
    setTimeout(function() {
        console.log(self.a);
    },100)
}

var obj = {
    a: 2
}

foo.call(obj); // 2

虽然self = this和箭头函数看起来都可以取代bind(...),但是从本质上来说,它们想替代的是this机制。

如果你经常编写this风格的代码,但是绝大部分时候都会使用self = this或者箭头函数来否定this机制,那你或许应当:

  • 1.只使用词法作用域并完全抛弃错误this风格的代码;
  • 2.完全采用this风格,在必要时使用bind(..),尽量避免使用self = this和箭头函数。

总结

如果要判断一个运行中函数的this绑定,就需要找到这个函数的直接调用位置。找到之后,就可以顺序应用下面这四条规则来判断this的绑定对象。

1.由new调用?绑定到新创建的对象。

2.由call或者apply(或者bind)调用?绑定到指定的对象。

3.由上下文对象调用?绑定到那个上下文对象。

4.默认:在严格模式下绑定到undefined,否则绑定到全局对象。

ES6中的箭头函数并不会使用四条标准的绑定规则,而是根据当前的词法作用域来决定this,具体来说,箭头函数会继承外层函数调用的this绑定(无论this绑定到什么)。

巴拉巴拉

好像有近一个月没有更新了,感觉自己是懒癌犯了,不想推脱说工作忙,或者太累,因为总是有时间刷豆瓣,刷知乎,最近发现,很多时候,被指责,被批评,第一反应是找借口开脱,不知道是不是只有我一个人这样,所以开始学会从自身找原因,你做的好,肯定不会被批,当然,也许会有特意找茬的情况,可是,我身边都是很好的人,不存在这样的情况,需要学习和提升的地方好多,只能慢慢来啦,我看这本书的时候,有很多地方会理不顺,就随手去翻了一下《JavaScript权威指南》,发现写的真好啊,值得细读,推荐给大家~。