你不知道的JS来聊聊this

Posted Blog of Eric Wu

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了你不知道的JS来聊聊this相关的知识,希望对你有一定的参考价值。

为什么要使用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

如果不用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 是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调用时的各种条件。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的绑定规则

现在你知道了如何找到调用位置,这时候你还需要了解关于this绑定的四条规则


 

1.默认绑定

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

声明在全局作用域中的变量(比如var a = 2)就是全局对象的一个同名属性。它们本质上就是同一个东西,并不是通过复制得到的.。

接下来我们可以看到当调用foo() 时,this.a 被解析成了全局变量a。为什么?因为在本例中,函数调用时应用了this 的默认绑定,因此this 指向全局对象。

在代码中,foo() 是直接使用不带任何修饰的函数引用进行调用的,因此只能使用默认绑定,无法应用其他规则。

或者我们可以这么理解,foo()是被全局函数调用的,如window.foo()

当函数的执行上下文环境是全局环境,那么就会使用默认绑定,即绑定到全局对象上

不过,在严格模式下,就没有默认绑定了,this此时为undefined

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

 


 

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.a 和obj.a 是一样的。也就是这里会查找foo时,会经过obj这个上下文对象,会把obj上下文对象保存下来,因此,这里的this指向的就是obj上下文对象。

 

对象属性引用链中只有最顶层或者说最后一层会影响调用位置。举例来说:

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

你可以这么理解:obj1=>obj2=>foo()。因此this找到了上下文对象后(obj2),就没必要继续去查找了,类似作用域链中查找变量。

 

隐式丢失:

一个最常见的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() 其实是一个不带任何修饰的函数调用,因此应用了默认绑定

或者可以这么理解,bar()函数指向的是一个匿名函数的引用,这时候已经和obj没有任何关系了,也就不存在obj上下文对象的引用了。

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

 

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

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"

我们知道,参数传递其实是一种隐式赋值,也就是fn = obj.foo,所以结果和之前的例子一样。

 

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

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

你可以这么理解:setTimeOut()把你的回调函数丢进去了任务队列中,然后JS引擎拿出来执行,这个执行环境的上下文其实就是全局上下文环境,因此也是使用默认绑定。

就像我们看到的那样,回调函数丢失this 绑定是非常常见的。除此之外,还有一种情况this 的行为会出乎我们意料:调用回调函数的函数可能会修改this。

在一些流行的javascript 库中事件处理器常会把回调函数的this 强制绑定到触发事件的DOM 元素上。这在一些情况下可能很有用,但是有时它可能会让你感到非常郁闷。遗憾的是,这些工具通常无法选择是否启用这个行为。

无论是哪种情况,this 的改变都是意想不到的,实际上你无法控制回调函数的执行方式,因此就没有办法控制会影响绑定的调用位置。之后我们会介绍如何通过固定this 来修复/固定这个问题。

 


 

3.显式绑定

这个比较简单,就是使用call()和apply()函数。

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

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

 

(1)硬绑定:

显示绑定仍然可能存在着丢失this绑定的问题,因此我们需要采用硬绑定,也就是:创建要给函数,在函数内部再显示绑定,如这里的bar()

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

这个常用来创建包裹函数,用于包括所有接受到的值

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

或者说创建一个绑定的辅助函数,也就是bind

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

当然这个bind函数比起正式的bind()有很多不足,正是因为硬绑定很常用,所以才有了ES5的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 的上下文并调用原始函数。

我们可以看下MDN是怎么实现的,当然这这只是一个polyfill版本的,因此还是会有.prototype,而ES5的bind()是没有.prototype的

if (!Function.prototype.bind) {
  Function.prototype.bind = function(oThis) {
    if (typeof this !== ‘function‘) {
      // closest thing possible to the ECMAScript 5
      // internal IsCallable function
      throw new TypeError(‘Function.prototype.bind - what is trying to be bound is not callable‘);
    }

    var aArgs   = Array.prototype.slice.call(arguments, 1),
        fToBind = this,
        fNOP    = function() {},
        fBound  = function() {
          return fToBind.apply(this instanceof fNOP
                 ? this
                 : oThis,
                 aArgs.concat(Array.prototype.slice.call(arguments)));
        };

    if (this.prototype) {
      // Function.prototype doesn‘t have a prototype property
      fNOP.prototype = this.prototype; 
    }
    fBound.prototype = new fNOP();

    return fBound;
  };
}

 

 

(2)API中的上下文

很多函数比如迭代函数,都提供了一个参数用于传入函数上下文来绑定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();

 


 

4.new绑定

使用new 来调用函数,或者说发生构造函数调用时,会自动执行下面的操作。
1). 创建(或者说构造)一个全新的对象。
2). 这个新对象会被执行[[ 原型]] 连接。
3). 这个新对象会绑定到函数调用的this。
4). 如果函数没有返回其他对象,那么new 表达式中的函数调用会自动返回这个新对象。

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

当然你也可以这么理解,其实new内部机制也是使用了call或者apply函数,我们可以尝试实现New方法

//实现一个new方法
function New() {
    let obj = new Object(),
        Constructor = [].shift.call(arguments);
    obj.__proto__ = Constructor.prototype;
    let ret = Constructor.apply(obj, arguments);
    return typeof ret === ‘object‘ ? ret : obj;

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

 

绑定规则的优先级

优先级:new绑定>显式绑定>隐式绑定>默认绑定

注:ES6的箭头函数在四个规则以外,箭头函数的this值为词法作用域中的this值。

判断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()

 

一些插曲:

如果你把null 或者undefined 作为this 的绑定对象传入call、apply 或者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

然而这种传入null的方式对于使用一些第三方库时可能产生副作用(把this绑到全局对象了),所以

一种“更安全”的做法是传入一个特殊的对象,把this 绑定到这个对象不会对你的程序产生任何副作用。就像网络(以及军队)一样,我们可以创建一个“DMZ”(demilitarized

zone,非军事区)对象——它就是一个空的非委托的对象,比如我们可以? = Object.create(null)创建一个空对象,以保护全局对象。

//Object.create(null) 和{} 很像, 但是并不会创建Object.prototype 这个委托,所以它比{}“更空”

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

 

此外介绍下软绑定:用软绑定之后可以使用隐式绑定或者显式绑定来修改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;
    };
}

 

以上是关于你不知道的JS来聊聊this的主要内容,如果未能解决你的问题,请参考以下文章

读书笔记《你不知道的JS-上》

《你不知道的js》——this全面解析

《你不知道的JS(中卷)》this详解

你不知道的js-行为委托

你不知道的JS系列 ( 19 ) - this 调用位置

你不知道的JS系列上( 45 ) - 隐式混入