likes
comments
collection
share

老生常谈之this

作者站长头像
站长
· 阅读数 43

this是我们在面试或者开发过程中都会经常遇到的,本文依据《你不知道的JavaScript》上卷中提到的this相关的知识,进行统一整理。

为什么要用this

thisjs 中非常复杂的机制,他真的值得我们付出时间和精力去学习吗?存在的意义到底是什么?

使代码简洁并且易于复用

this提供了一种更优雅的方式来隐式“传递”一个对象引用,因此可以将API设计得更加简洁并且易于复用。

比如:

function identify() { 
    return this.name.toUpperCase(); 
} 

function speak() { 
    var greeting = "Hello, I'm " + identify.call(this); 
    console.log(greeting); 
} 

var me = { name: "Kyle" }; 

var you = { name: "Reader" }; 

identify.call(me); // KYLE 
identify.call(you); // READER 

speak.call(me); // Hello, I'm KYLE
speak.call(you); // Hello, I'm READER

如果不使用this,那就需要给identify()和speak()显式传入一个上下文对象:

function identify(context) { 
    return context.name.toUpperCase(); 
} 
function speak(context) { 
    var greeting = "Hello, I'm " + identify (context); 
    console.log(greeting); 
} 
identify(you); // READER 
speak(me); //hello, I'm KYLE

对于this的误解

误解一:指向自身

我们经常会以为this指向函数本身,从英语语法的角度这是说的通的,但是this并不像我们所以为的那样指向函数本身。

比如我们想记录函数的调用次数:

function foo(num) {
    console.log("foo: " + num);

    // 记录foo被调用的次数
    this.count++;
}

foo.count = 0;

var i;
for (i=0; i<10; i++) {
    if (i > 5) {
      foo(i);
    }
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9

// foo被调用了多少次?
console.log(foo.count); // 0 -- 什么?!

因为在js中,函数也是对象,所以当执行foo.count = 0;时,会向函数对象foo中添加一个count属性。

但是函数中的this.count并没有指向函数对象foo,所以即使函数调用了4次,foo.count 并没有变化。

话外音:如何在函数中正确的引用自身?

1.具名函数使用函数名

function foo() {
    foo.count = 4; // foo指向它自身
}

2.匿名函数使用arguments.callee

这是唯一一种可以从匿名函数对象内部引用自身的方法。然而,更好的方式是避免使用匿名函数,至少在需要自引用时使用具名函数(表达式)。arguments.callee已经被弃用,不应该再使用它。

误解二:指向函数的作用域

第二种常见的误解是,this指向函数的作用域。这个问题有点复杂,因为在某种情况下它是正确的,但是在其他情况下它却是错误的。

需要明确的是,this在任何情况下都不指向函数的词法作用域(词法作用域是由你在写代码时将变量和作用域写在哪里决定的,无论何函数在哪里被调用,词法作用域都由函数被声明时所处的位置决定)

比如:

function foo() {
    var a = 2;
    this.bar();
}

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

foo(); // ReferenceError: a is not defined

首先,这段代码试图通过this.bar()来引用bar()函数。这样调用能成功纯属意外。调用bar()最自然的方法是省略前面的this,直接使用词法引用标识符。

此外,编写这段代码的开发者还试图使用this联通foo()bar()的词法作用域,从而让bar()可以访问foo()作用域里的变量a。这是不可能实现的,使用this不可能在词法作用域中查到什么。

this到底是什么

this是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调用时的各种条件。this的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。

当一个函数被调用时,会创建一个活动记录(有时候也称为执行上下文)。这个记录会包含函数在哪里被调用(调用栈)、函数的调用方式、传入的参数等信息。this就是这个记录的一个属性,会在函数执行的过程中用到。

this全面解析

调用位置

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

确定调用位置最重要的是要分析调用栈(就是为了到达当前执行位置所调用的所有函数)。我们关心的调用位置就在当前正在执行的函数的前一个调用中

比如:

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.默认绑定

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

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

foo函数的调用位置是全局作用域,所以this.a中的this指向全局。

2.隐式绑定

隐式绑定需要考虑调用位置是否有上下文对象,或者说是否被某个对象拥有或者包含。

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

首先需要注意的是foo()的声明方式,及其之后是如何被当作引用属性添加到obj中的。但是无论是直接在obj中定义还是先定义再添加为引用属性,这个函数严格来说都不属于obj对象。

然而,调用位置会使用obj上下文来引用函数,因此你可以说函数被调用时obj对象“拥有”或者“包含”它。

无论如何称呼这个模式,当foo()调用时,它的前面确实加上了对obj的引用。当函数引用有上下文对象时,隐式绑定规则会把函数调用中的this绑定到这个上下文对象。因为调用foo()this被绑定到obj,因此this.aobj.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"

虽然barobj.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"

3.显式绑定

通过callapply将函数的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

由于硬绑定是一种非常常用的模式,所以ES5提供了内置的方法Function.prototype.bindbind(..)会返回一个硬编码的新函数,它会把你指定的参数设置为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

4.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的时候会将this绑定到新创建的对象,然后将新创建的对象返回。所以上例中this指向bar

优先级

之前我们已经了解了四条规则,确定this指向需要做的就是找到函数的调用位置并判断应当应用哪条规则。

但是,如果某个调用位置可以应用多条规则该怎么办?为了解决这个问题就必须给这些规则设定优先级。

  1. 函数是否在new中调用(new绑定)?如果是的话this绑定的是新创建的对象。
  2. 函数是否通过call、apply(显式绑定)或者硬绑定调用?如果是的话,this绑定的是指定的对象。
  3. 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this绑定的是那个上下文对象。
  4. 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到undefined,否则绑定到全局对象。

按照上述规则进行判断,大多情况下会让我们正确找到this的指向,之所以说是大多情况,那是因为凡事都有例外。接下来就说说例外。

绑定例外

被忽略的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可能是一个不错的选择,就像代码所示的那样。

但在ES6中,可以用...操作符代替apply(..)来“展开”数组,foo(...[1,2])foo(1,2)是一样的,这样可以避免不必要的this绑定。可惜,在ES6中没有柯里化的相关语法,因此还是需要使用bind(..)

然而,总是使用null来忽略this绑定可能产生一些副作用。如果某个函数确实使用了this(比如第三方库中的一个函数),那默认绑定规则会把this绑定到全局对象(在浏览器中这个对象是window),这将导致不可预计的后果(比如修改全局对象)。显而易见,这种方式可能会导致许多难以分析和追踪的bug。一种“更安全”的做法是传入一个特殊的对象,把this绑定到这个对象不会对你的程序产生任何副作用。

间接引用

有可能(有意或者无意地)创建一个函数的“间接引用”,在这种情况下,调用这个函数会应用默认绑定规则。间接引用最容易在赋值时发生:

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强制绑定到指定的对象(除了使用new时),防止函数调用应用默认绑定规则。问题在于,硬绑定会大大降低函数的灵活性,使用硬绑定之后就无法使用隐式绑定或者显式绑定来修改this

如果可以给默认绑定指定一个全局对象和undefined以外的值,那就可以实现和硬绑定相同的效果,同时保留隐式绑定或者显式绑定修改this的能力。

可以通过一种被称为软绑定的方法来实现我们想要的效果:

if (!Function.prototype.softBind) {
    Function.prototype.softBind = function (obj) {
        var fn = this;
        // 捕获所有 curried 参数
        var curried = [].slice.call(arguments, 1);
        var bound = function () {
            return fn.apply(
                (!this || this === (window || global)) ?
                    obj : this,
                curried.concat.apply(curried, arguments)
            );
        };
        bound.prototype = Object.create(fn.prototype);
        return bound;
    };
}

除了软绑定之外,softBind(..)的其他原理和ES5内置的bind(..)类似。它会对指定的函数进行封装,首先检查调用时的this,如果this绑定到全局对象或者undefined,那就把指定的默认对象obj绑定到this,否则不会修改this

接下来验证下:

if (!Function.prototype.softBind) {
    Function.prototype.softBind = function (obj) {
        var fn = this;
        // 捕获所有 curried 参数
        var curried = [].slice.call(arguments, 1);
        var bound = function () {
            return fn.apply(
                (!this || this === (window || global)) ?
                    obj : this,
                curried.concat.apply(curried, arguments)
            );
        };
        bound.prototype = Object.create(fn.prototype);
        return bound;
    };
}

function foo() {
    console.log("name: " + this.name);
}

var obj = { name: "obj" },
    obj2 = { name: "obj2" },
    obj3 = { name: "obj3" };

var fooOBJ = foo.softBind(obj);

fooOBJ(); // name: obj

obj2.foo = foo.softBind(obj);
obj2.foo(); // name: obj2 <---- 看!! !

fooOBJ.call(obj3); // name: obj3 <---- 看!

setTimeout(obj2.foo, 10);
  // name: obj   <---- 应用了软绑定

箭头函数

箭头函数不使用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也不行!)

总结

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

  1. 由new调用?绑定到新创建的对象。
  2. 由call或者apply(或者bind)调用?绑定到指定的对象。
  3. 由上下文对象调用?绑定到那个上下文对象。
  4. 默认:在严格模式下绑定到undefined,否则绑定到全局对象。

一定要注意,有些调用可能在无意中使用默认绑定规则。如果想“更安全”地忽略this绑定,你可以使用一个DMZ对象,比如ø = Object.create(null),以保护全局对象。

ES6中的箭头函数并不会使用四条标准的绑定规则,而是根据当前的词法作用域来决定this,具体来说,箭头函数会继承外层函数调用的this绑定(无论this绑定到什么)。这其实和ES6之前代码中的self = this机制一样。

转载自:https://juejin.cn/post/7254384497659347003
评论
请登录