你的浏览器不支持canvas

Love You Ten Thousand Years

让人头疼的函数内this的指向

Date: Author: M/J

本文章采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可。转载请注明来自小可嗒嗒的博客

JavaScript 颇让费解的东西 this 为何出现呢,机制是提供更优雅的方式来隐式地“传递”一个对象引用。对于 this 的很多误解和使用,建议收看 You-Dont-Know-JS。本文主要是在它的基础上整合的内容。

每个函数在被调用时都会自动取得两个特殊的变量: this 和 arguments。需要说明的是,this 不是编写时绑定,而是运行时绑定。它依赖于函数调用的上下文条件。this 绑定与函数声明的位置没有任何关系,而与函数被调用的方式紧密相连。

首先,提到台面上来。this 是一个完全根据调用点(call-site)来为函数调用建立绑定。

这句话不太好理解

决定 this 的指向有四种规则。

默认绑定 – Default binding

这种 this 规则是在没有其他规则适用情况下的默认规则。

最常见的情况就是: 独立函数调用。

function foo() {
    console.log( this.a ); // 2
}
var a = 2;
foo(); //函数的调用点

foo() 被一个直白的,毫无修饰的函数引用(即 foo,对于命名函数来讲,它就是一个引用)调用的,所以 this 指向全局对象(Global)。

如果 strict mode 在这里生效,那么对于默认绑定来说全局对象是不合法的,所以 this 将被设置为 undefined。


隐式绑定 – Implicit Binding

第二种规则是考虑调用点是否有一个上下文对象(context object)

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

foo() 被声明然后作为引用属性添加到 obj。调用点使用 obj 这个上下文(环境) 来调用 foo() 函数,可以说 obj 对象在函数被调用的时间点上“拥有”或“包含”这个函数引用。所以,obj 就是 foo() 调用的 this。

敲黑板,划重点了

隐式丢失 – Implicitly Lost

当一个 隐式绑定丢失了它的绑定,这通常意味着它会退回到 默认绑定。

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"

和上面的例子一致,fn 不过是 foo 的另一个引用,所以结果和上面的例子一样。


显式绑定 – Explicit Binding

如果你想强制一个函数调用使用某个特定对象作为 this 绑定,具体地说,函数拥有 call()apply() 方法,它们提供一些特殊的功能。你创建的所有函数,都可以访问 call() 和 apply()。

这些工具接收的第一个参数都是一个用于 this 的对象,之后使用这个指定的 this 来调用函数。

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

但是,单独依靠 显式绑定 并不能解决函数“丢失”自己原本的 this 绑定的这个问题。

??? why??

我们还是用一个回调函数来讨论一下:

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

在我们的函数调用点处产生了 隐式丢失 的情况。如果此处我们使用显式绑定 call()

setTimeout( obj.foo.call(obj), 100 ); //throw new TypeError('"callback" argument must be a function');

报错的信息是回调参数必须是一个函数。所以我们改成这样就顺利通过了。

setTimeout( function () {
    obj.foo.call(obj);
}, 100 ); // "oops, global"

硬绑定 – Hard Binding

Hard binding 是 明确绑定 的一个变种。

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

barfoothis 在函数内部强制绑定到 obj。之后,无论怎样调用函数 bar(),它总是手动使用 obj 调用 foo。这种绑定态度明确又坚定,所以称之为 hard binding。

hard binding 的用法一:为所有传入的参数和传出的的返回值创建一个通道

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

hard binding 的用法二:创建一个可复用的帮助函数

function foo(something) {
    console.log( this.a, something );
    return this.a + something;
}
// 简单的 `bind` 帮助函数
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

正因为 harding 是一个如此常用的模式,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

在比如我们 隐式丢失 的那个例子:

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

new 绑定 – new Binding

这是最后一种 this 绑定规则。在 JavaScript 中,“构造器”仅仅是一个函数,也叫做构造函数。不依附于类,也不初始化一个类,甚至都不是一种特殊的函数种类。本质是一般的函数,只是被使用 new 来调用时改变了行为。

在函数前面加入 new 调用时,会自动完成下面的事情:

  • 一个全新的对象被构建
  • 这个新构建的对象会被接入原型链
  • 新构建的对象被设置为函数调用的 this 绑定
  • 除非函数返回一个它自己的其他对象,否则这个被 new 调用的函数将自动返回这个新构建的对象
function foo(a) {
    this.a = a;
}
var bar = new foo( 2 );
console.log( bar.a ); // 2

四种规则的顺序

  1. 函数是通过 new 被调用的吗(new 绑定)?如果是,this 就是新构建的对象。
    var bar = new Foo()
    
  2. 函数是通过 call 或 apply 被调用(显式绑定),甚至是隐藏在 bind 硬绑定 之中吗?如果是,this 就是那个被明确指定的对象。
    var bar = foo.call( obj2 )
    
  3. 函数是通过上下文对象(也称为拥有者或容器对象)被调用的吗(隐式绑定)?如果是,this 就是那个环境对象
    var bar = obj1.foo()
    
  4. 否则,使用默认的 this(默认绑定)。如果在 strict mode 下,就是 undefined,否则是 global 对象。
    var bar = foo()
    

特殊的 this

在闭包中使用 this

匿名函数的执行环境具有全局性,因此其 this 对象通常指向 global 对象。

var a = 0;
function foo(){
    /*
    ... 这些范围内,this 指向 obj
    */
    function test(){
        console.log(this.a);
        /*
        ... 这些范围内,this 指向 global
        */
    }
    return test;
};
var obj = {
    a : 2,
    foo:foo
}
obj.foo()();//0

这种情况其实也是可以使用我们的四个规则来进行判断的,首先看我们的调用点。第一个调用点是 obj.foo() 这种是满足我们 隐式绑定规则的,this 指向我们的 obj。接着我们的第二个调用点是 obj.foo()(),这种是非常直白的函数调用,所以退回到 默认绑定,this 指向 global 对象。上面的例子和下面的写法一致,但是表意更清楚。

var a = 0;
function foo(){
    /*
    ... 这些范围内,this 指向 obj
    */
    function test(){
        console.log(this.a);
        /*
        ... 这些范围内,this 指向 global
        */
    }
    return test;
};
var obj = {
    a : 2,
    foo:foo
}
var bar = obj.foo();
bar();

我们通常采用将外部的 this 保存到一个闭包能访问的变量里来访问该对象。

var a = 0;
function foo(){
    var self = this;
    /*
    ... 这些范围内,this 指向 obj
    */
    function test(){
        console.log(self.a);
        /*
        ... 这些范围内,this 指向 global
        */
    }
    return test;
};
var obj = {
    a : 2,
    foo:foo
}
obj.foo()(); // 2

这似乎是不错的解决方案,但都是为了逃避 this 而非接受它。

IIFE 中使用 this

通常说,IIFE 中的 this 指向 global 对象。下面是一个非常简单的 IIFE 函数。

var name = 2;
(function () {
    var name = 3,
        self = this;
    console.log(self.name);  // 2
})();

但是,上述的代码和下面的代码没有任何区别:

var name = 2;
function foo() {
    var name = 3,
        self = this;
    console.log(self.name);  // 2
}
foo();

很浅显了吧,非常直白的 默认绑定。

传递 null 或 undefined

如果你传递 null 或 undefined 作为 call、apply 或 bind 的 this 绑定参数,那么这些值会被忽略掉,取而代之的是 默认绑定 规则将适用于这个调用。

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

箭头函数

ES6 的箭头函数和上述四种规则不同的是,箭头函数从封闭它的(函数或全局)作用域采用 this 绑定。

最常见的用法是用于回调。

function foo() {
    setTimeout(() => {
        // 这里的 `this` 是词法上从 `foo()` 采用
    console.log( this.a );
    },100);
}
var obj = {
    a: 2
};
foo.call( obj ); // 2

箭头函数提供除了使用 bind() 外,另外一种在函数上来确保 this 的方式。但是这种写法和我们上面提到的 self = this 并无二致。也就是说,上面的代码和下面的代码效果一致:

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

没啥区别,对吧。所以,总的来说,都和这四种规则分不开。


call、apply 和 bind

call()apply() 的区别不再赘述,我们这里想要关注的是 它们的返回值。返回值是它们与 bind() 函数的区别。在 MDN 中,是这样描述的:

The result of calling the function with the specified this value and arguments. – MDN

官方文档的描述在这里: Function.prototype.apply

Return the result of calling the [[Call]] internal method of func, providing thisArg as the this value and argList as the list of arguments. – [ES5]

两者表达一个意思,就是返回 调用函数的使用特定的 this 和参数所返回的结果

举个例子:

function foo(b) {
    console.log(this.a,b.name); //2 'yuer'
}
var obj = {
    a: 2
};
console.log(foo.call(obj,{name:'yuer'})); //undefined

调用函数 foo() 没有返回值,所以返回的是 undefined

function foo(b) {
    console.log(this.a,b.name); //2 'yuer'
    return b;
}
var obj = {
    a: 2
};
console.log(foo.call(obj,{name:'yuer'})); //{ name: 'yuer' }

foo() 有返回值,所以返回 foo() 的返回值。

而,bind() 函数返回的是一个 新的函数

The bind method takes one or more arguments, thisArg and (optionally) arg1, arg2, etc, and returns a new function. – – [ES5]

也就是说,参数设置 bind()call() 是一样的,但是,bind() 返回一个函数的拷贝。

function foo(b) {
    console.log(this.a,b.name);
    return b;
}
var obj = {
    a: 2
};
console.log(foo.bind(obj,{name:'yuer'})); //[Function: bound foo]

这也就解释了我们上面提到的 call() 和 apply() 无法解决隐式绑定丢失的问题,就是因为要求返回的是一个函数,而 bind() 正好有这个功能。


new 的四个步骤

  • 一个全新的对象被构建
  • 这个新构建的对象会被接入原型链
  • 新构建的对象被设置为函数调用的 this 绑定
  • 除非函数返回一个它自己的其他对象,否则这个被 new 调用的函数将自动返回这个新构建的对象

这四句话看似简单,实则意义深刻。

第一句,new 出来的是一个新对象。第二句,对象被接入原型链?怎么接入,使用 __prototype__。第三句,将对象作为构造函数调用的 this 绑定。至此,我们来模拟一下:

function Fun() {
}
Fun.prototype.getName = function(){
    return this.name;
};
Fun.prototype.name = 'yuer';

function objectFac(Fun) {
    var obj = new Object();
    obj.__proto__ = Fun.prototype;
    Fun.apply(obj);
    return obj;
}
var per = objectFac(Fun);
console.log(per.name); // 'yuer'

这个看似不错,但是这是一种简单的使用原型来构建对象的一种方法。更一般的,我们是组合使用构造函数模式和原型模式。

function Fun(name){
    this.name = name;
}
Fun.prototype = {
    constructor: Fun;
    getName: function(){
        return this.name;
    }
}

这种情况下,我们将 Fun 作为 arguments 的一部分来进行传递。

function Fun(name){
    this.name = name;
}
Fun.prototype = {
    constructor: Fun,
    getName: function(){
    return this.name;
}
};
function objectFac() {
    var obj = new Object();
    var Constructor = [].shift.call(arguments);  // arguments 是一个类数组,所以不能直接用数组方法,但是可以借用 Array.prototype 上的方法
    //var Constructor = Array.prototype.shift.call(arguments);
    obj.__proto__ = Constructor.prototype;
    Constructor.apply(obj, arguments); // 可以简单地将 arguments 作为参数传递,但是没有办法使用数组方法
    return obj;
}

var per = objectFac(Fun,'yuer');
console.log(per.name);

上述代码实现了 new 操作符的第一、二和三步。即,创建新的对象、接入原型链、改变 this 的指向。那第四部如何理解呢?我们看一下简化例子:

function Fun(name){
    this.name = name;

    return {
        who: 'xiaoke',
        what: 'love',
        how: 'yuer'
    }
}
Fun.prototype = {
    constructor: Fun,
    getName: function(){
    return this.name;
}
};

var per = new Fun('yuer');
console.log(per); // { who: 'xiaoke', what: 'love', how: 'yuer' }
console.log(name); // undefined

构造函数有返回值,我们只能访问返回内容中的属性。这也就是 new 操作符的第四项内容。然后我们要如何处理 objectFun() 函数呢,也就是说,我们需要判断构造函数是否有返回值,如果有,就返回构造函数的内容。

function Fun(name){
    this.name = name;

    return {
        who: 'xiaoke',
        what: 'love',
        how: 'yuer'
    }
}
Fun.prototype = {
    constructor: Fun,
    getName: function(){
    return this.name;
}
};
function objectFac() {
    var obj = new Object();
    //var Constructor= Array.prototype.shift.call(arguments);
    var Constructor = [].shift.call(arguments);
    obj.__proto__ = Constructor.prototype;
    var ret = Constructor.apply(obj, arguments);

    return typeof ret == 'object' ? ret : obj;
    return obj;

}

var per = objectFac(Fun,'yuer');
//var per = new Fun('yuer');
console.log(per);

参考


对于本文内容有问题或建议的小伙伴,欢迎在文章底部留言交流讨论。