谈谈JavaScript中的this机制

1,245 阅读9分钟

thisJavaScript中比较复杂的机制之一,本篇文章希望可以带大家了解this相关的知识。本文内容来自书籍《你不知道的JavaScript(上卷)》,只是自己稍微整理一下。

☕️为什么使用this

问题来了,既然this比较复杂,我们为什么还要使用呢?看一段代码:

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, 我是 KYLE
speak.call( you ); // Hello, 我是 READER

这段代码可以在不同的上下文对象(meyou)复用函数,并且代码中使用了this,如果不使用this代码会是这个样子


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, 我是 KYLE

可以看出来,比起显示地传递上下文对象,使用this这种隐式的传递一个对象的引用,更加方便

⬇️this的误区

关于this,由于它的语义性的问题,会带来很多的误解:

误区一:指向自身

function foo(num) {
    console.log( "foo: " + num );
    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

运行后我们发现foo.count仍然是0,说明this并没有指向foo自身。

误区二:指向它的作用域

在某种情况下这个说法是正确的,而在某些情况下这个说法又是错误的,但是要注意!!this 在任何情况下都不指向函数的词法作用域!! 为什么这么说呢?

function bar() { 
    console.log(1);
}
this.bar(); // 1

在上例中,this指向了全局作用域,但是只是特殊情况,因此会有这个说法是正确的,而在某些情况下这个说法又是错误的结论

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

上文this.a视图引用foo词法作用域定义的变量a,这是永远也不可能实现的

❤️this到底是什么

说了它的使用方式以及误区,那么this到底是什么呢?首先明确一点:this的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。

调用位置

this是在调用时被绑定的,完全取决于函数的调用位置,因此要搞清楚函数的调用位置,但是某些编程模式会隐藏函数的调用位置,最重要的分析它的调用栈(就是为了达到当前运行位置的所有调用函数)

function baz() {
    // 当前调用栈是: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绑定的4种规则,下次看到this出现时,便可以使用这些规则

默认绑定

这是比较常见的函数调用类型:独立函数调用

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

var a = 2;
foo(); // 2

如何判断应用了默认绑定呢?foo是直接使用不带任何修饰符的函数进行引用调用的

注意:如果使用了严格模式,this会绑定到undefined

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

var a = 2;
foo(); // TypeError: this is undefined

隐式绑定

当函数引用有上下文对象时(严格来说函数被对象“拥有”或者“包含”),隐式绑定规则会把函数调用中的this绑定到这个上下文对象

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

严格来说,foo不属于obj对象,但是落脚点却指向obj对象,因此你可以说函数被调用时 obj 对象“拥 有”或者“包含”它。

隐式丢失

一个最常见的问题就是:隐式绑定会丢失绑定对象,从而执行默认绑定

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"

综上所述:有两种情况会导致隐式绑定的绑定丢失。

  • 进行引用赋值var bar = obj.foo;
  • 进行传递参数doFoo( obj.foo );

显式绑定

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

通过foo.call(..)可以在调用时强制把this绑定到obj上,但是这样的方式也无法解决掉丢失绑定问题

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

我们发现this随着调用一直在改变,即this丢失。

我们可以通过以下方式解决:

硬绑定

创建一个包裹函数,传入所有的参数并返回接收到的所有值

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

API调用的上下文

许多内置函数都提供了一个可选参数,通常被称为上下文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

new绑定

JavaScript中的new机制与面向对象的语言完全不同,实际上,在JavaScript中并不存在所谓的"构造函数",只有对与函数的"构造调用"

使用new来调用函数,或者说发生构造函数调用时的流程:

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

❤️优先级

上面介绍了this的4种绑定规则,那么它们的优先级谁高谁低呢,首先,确认一点的是默认绑定的优先级最低

比较隐式绑定和显示绑定

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

// new 和 隐式绑定同时存在,obj1的a是2,而this指向了bar
var bar = new obj1.foo( 4 ); 
console.log( obj1.a ); // 2 
console.log( bar.a ); // 4

可以看出来new绑定高于隐式绑定

比较new绑定和显示绑定

由于newcall/apply无法一起使用,我们可以使用硬绑定测试优先级

function foo(something) { 
    this.a = something;
}
var obj1 = {};

var bar = foo.bind( obj1 ); 
bar( 2 );

console.log( obj1.a ); // 2

var baz = new bar(3); 
console.log( obj1.a ); // 2 
console.log( baz.a ); // 3

首先bar被强制绑定到obj1上,但是new bar(3)没有预期把obj1.a修改为 3 因此new的优先级大于硬绑定。

但是使用刚开始的裸bind

function foo(something) { 
    this.a = something;
}

function bind(obj, fn) {
    return function() {
        fn.apply(obj. arguments);
    }
}
var obj1 = {};

var bar = bind( obj1, foo ); 
bar( 2 );

console.log( obj1.a ); // 2

var baz = new bar(3); 
console.log( obj1.a ); // 3 
console.log( baz.a ); // undefined

会惊奇地发现,new bar(3)obj1.a修改为 3 因此内置bind的实现是非常复杂的,不在此进行研究,既然这么复杂,为什么还要使用呢?

这种做法称为“部 分应用”,是“柯里化”的一种,它的主要目的是预设函数的一些参数,这样在使用new进行初始化时就可以只传入其余的参数。

function foo(p1,p2) { this.val = p1 + p2;
}
// 之所以使用 null 是因为在本例中我们并不关心硬绑定的 this 是什么 
// 反正使用 new 时 this 会被修改
var bar = foo.bind( null, "p1" );
var baz = new bar( "p2" ); 
baz.val; // p1p2

从上我们可以总结出可以通过以下顺序判断this

  • 函数是否在new中调用(new绑定)?
  • 函数是否通过call、apply(显式绑定)或者硬绑定调用
  • 函数是否在某个上下文对象中调用(隐式绑定)
  • 如果都不是的话,使用默认绑定

☕️绑定例外

规则总有例外,当你认为应用了其他规则时,有可能只应用了默认规则

被忽略的this

如果我们把null或者undefined作为this的绑定对象传递入callapply、或者bind,会使用默认绑定规则

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

那么什么情况下会使用这种方式呢?利用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

es6中可以使用...来代替``apply(...)```,但是ES6中没有柯里化的相关方法

忽略this会存在一个问题,比如第三方库的函数真的使用了this,我们这种方式把this绑定到了全局作用域,会存在问题,需要使用更安全的this,创建空的非委托的对象Object.create( null )

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

间接引用

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 的能力

这种叫做软绑定。

if (!Function.prototype.softBind) {
    Function.prototype.softBind = function(obj) {
        var fn = this;
        console.log('fn', this);
        // 捕获所有 curried 参数
        var curried = [].slice.call( arguments, 1 );
        var bound = function() {
            console.log('this', this);
            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词法

最后介绍es6中的箭头函数,箭头函数不使用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 !

foothis绑定到了obj1bar引用箭头函数的this也会绑定到obj1,箭头函数的绑定无法被修改