4

工作中会遇到很多 this对象 指向不明的问题,你可能不止一次用过 _self = this 的写法来传递this对象,它每每会让我们觉得困惑和抓狂,我们很可能会好奇其中到底发生了什么。

一个问题

现在先来看一个具体的问题:

var name = 'The Window';
var obj = {
    name: 'My obj',
    getName: function() {
        return this.name;
    }
};

// 猜测下面的输出和背后的逻辑(非严格模式下)
object.getName();
(object.getName)();
(object.getName = object.getName)();

如果上面的三个你都能答对并知道都发生了什么,那么你对JS的this了解的比我想象的要多,可以跳过这篇文章了,如果没答对或者不明白,那么这篇文章会告诉你并帮你梳理下相关的知识。
它们的答案是:

object.getName();    // 'My Obj'
(object.getName)();    // 'My Obj'
(object.getName = object.getName)();    // 'The Window'

函数的作用域

在函数被调用的时候,会创建一个执行环境及相应的作用域链,然后,使用arguments以及其他命名参数的值来初始化函数的活动对象(activation object,简称AO)。在作用域上,函数会逐层复制自身调用点的函数属性,完成作用域链的构建,直到全局执行环境。

function compare(value1, value2) {
    return value1 - value2;
}

var result = compare(5, 10);

图片描述

在这段代码中,result通过var进行了变量声明提升,compare通过function函数声明提升,在代码执行之前我们的全局变量对象中就会有这两个属性。

每个执行环境都会有一个变量对象,包含存在的所有变量的对象。全局环境的变量对象始终存在,而像compare函数这样的局部环境的变量对象,则只在函数执行的过程中存在。当创建compare()函数时,会创建一个预先包含全局变量对象的作用域链,这个作用域链保存在内部的[[Scope]]属性中。

在调用compare函数时,会为它创建一个执行环境,然后复制函数的[[scope]]属性中的对象构建起执行环境的作用域链。此后,又有一个活动对象(变量对象)被创建并被推入执行环境作用域链的前端。此时作用域链包含两个变量对象:本地活动对象和全局变量对象。显然,作用域链本质上是一个指向变量对象的指针列表,它只引用但不包含实际的变量对象。

当访问函数的变量时,就会从作用域链中搜索。当函数执行完毕后,局部活动对象就会被销毁,内存中仅保存全局作用域。

闭包

但是,闭包的情况有所不同,在一个函数内部定义的函数会将外部函数的活动对象添加到它的作用域链中去。

function create(property) {
    return function(object1, object2) {
        console.log(object1[property], object2[property]);
    };
}

var compare = create('name');
var result = compare({name: 'Nicholas'}, {name: 'Greg'}); // Nicholas Greg

// 删除对匿名函数的引用,以便释放内存
compare = null;

在匿名函数从create()中被返回后,它的作用域链被初始化为包含create()函数的活动对象和全局变量对象。这样,该匿名函数就可以访问create中定义的所有遍历,更为重要的是当create()函数执行完毕后,其作用域链被销毁,但是活动对象不会销毁,因为依然被匿名函数引用。当匿名函数别compare()被销毁后,create()的活动对象才会被销毁。

图片描述

闭包与变量

我们要注意到,闭包只能取到任意变量的最后值,也就是我们保存的是活动对象,而不是确定值。

function create() {
    var result = [];
    for (var i = 0; i < 10; i++) {
        result[i] = function() {
            return i;
        };
    }
    return result;
}

create()[3](); // 10

我们通过闭包,让每一个result的元素都能够返回i的值,但是闭包包含的是同一个活动对象i,而不是固定的1-10的值,所以返回的都是10。但我们可以通过值传递的方式创建另外一个匿名函数来满足我们的需求。

function create() {
    var result = [];
    for (var i = 0; i < 10; i++) {
        // 通过值传递的方式固定i值
        result[i] = function(num) {
            // 这里闭包固定后的i值,即num值,来满足我们的需求
            return function() {
                return num;
            };
        }(i);
    }
    return result;
}

create()[3](); // 3

闭包与this

我们知道this对象是基于函数的执行环境绑定的,在全局的时候,this等于window,而当函数作为某个对象的方法调用时,this等于那个对象。不过,匿名函数的执行环境具有全局性,因此this常常指向window。

var name = 'The Window';
var obj = {
    name: 'My obj',
    getName: function() {
        return function() {
            return this.name;
        };
    }
};

obj.getName()(); // 'The Window'

前面说过,函数在被调用时会自动取得两个特殊变量: this和arguments,内部函数在搜索这两个变量时,只会搜索到其活动对象,所以永远不会访问到外部函数的这两个变量。如果我们想满足需求,可以固定this对象并更名即可。

var name = 'The Window';
var obj = {
    name: 'My obj',
    getName: function() {
        // 固定this对象,形成闭包,防止跟特殊的this重名
        var that = this;
        return function() {
            return that.name;
        };
    }
};

obj.getName()(); // 'My obj'

this的绑定

上面对this的说明可以说是非常的浅薄了,现在我们详细的整理下this关键字,它是函数作用域的特殊关键字,进入函数执行环境时会被自动定义,实现原理相当于自动传递调用点的对象:

var obj = {
    name: 'Nicholas',
    speak() {
        return this.name;
    },
    anotherSpeak(context) {
        console.log(context.name, context === this);
    }
};

obj.name;    //'Nicholas'
obj.speak();    // 'Nicholas'
obj.anotherSpeak(obj);    // 'Nicholas' true

可以看到,我们在anotherSpeak()中传递的context就是obj,也就是函数调用时,执行环境的this值。引擎的这种实现简化了我们的工作,自动传递调用点的环境对象作为this对象。

我们要注意的是this只跟调用点有关,而跟声明点无关。这里你需要知道调用栈,也就是使我们到达当前执行位置而被调用的所有方法的栈,即所有嵌套的函数栈。

function baz() {
    // 调用栈是: `baz`
    // 我们的调用点是global scope(全局作用域)

    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对象绑定的规则:

默认绑定

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

window.a;    // 2
foo();    // 2 true

在这种规则下,函数调用为独立的毫无修饰的函数引用调用的,此时foo的调用环境就是全局环境window,所以this就指向window,而在全局下声明的所有对象都属于window,导致结果为2。

但是在严格模式下,this不会被默认绑定到全局对象。MDN文档上写到:

第一,在严格模式下通过this传递给一个函数的值不会被强制转换为一个对象。对一个普通的函数来说,this总会是一个对象:不管调用时this它本来就是一个对象;还是用布尔值,字符串或者数字调用函数时函数里面被封装成对象的this;还是使用undefined或者null调用函数式this代表的全局对象(使用call, apply或者bind方法来指定一个确定的this)。这种自动转化为对象的过程不仅是一种性能上的损耗,同时在浏览器中暴露出全局对象也会成为安全隐患,因为全局对象提供了访问那些所谓安全的JavaScript环境必须限制的功能的途径。所以对于一个开启严格模式的函数,指定的this不再被封装为对象,而且如果没有指定this的话它值是undefined。

function foo() {
    "use strict";
    console.log( this );
}

foo();    // undefined

关于严格模式还需要注意的是,它的作用范围只有当前的函数或者<script>标签内部,而不包括嵌套的函数体:

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

var a = 2;

(function(){
    "use strict";

    foo(); // 2
})();

隐含绑定

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

obj.foo(); // 2

在这个函数调用时,其调用点为环境对象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

如果函数并不直接执行,而是先引用后执行,那么我们应该明白,该变量获得的是另一个指向该函数对象的指针,而脱离了引用的环境,所以自然失去了this的绑定,这被称为隐含绑定的丢失

function foo() {
    console.log( this.a );
}
var obj = {
    a: 2,
    foo: foo
};
// 函数引用!其实得到的是另一个指向该函数的指针,脱离了obj环境
var bar = obj.foo;

var a = "oops, global";

bar(); // "oops, global"

明确绑定

我们除了上面的两种默认绑定方式,还可以对其进行明确的绑定,主要通过函数内置的call/apply/bind方法,通过它们可以指定你想要的this对象是什么:

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

var obj = {
    a: 2
};

foo.call( obj ); // 2

我们给foo的调用指定了obj作为它的this对象,所以this.a即obj.a,结果为2。

call/apply方法需要传递一个对象,如果你传递的为简单原始类型值null,undefined,则this会指向全局对象。如果传递的为基本包装对象,则this会指向他们的自动包装对象,即new String(), new Boolean(), new Number(),这个过程称为封箱(boxing)。

这里我们应该清楚call/apply方法都只在最后一层嵌套生效,所以我们称呼它为明确绑定:

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

var obj = {
    a: 2
};

var bar = function() {
    foo.call( obj );
};

// `bar`将`foo`的`this`硬绑定到`obj`, 所以它不可以被覆盖
bar.call( window ); // 2

但如果我们想复用并返回一个新函数,并固定this值时,可以这样做:

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

这种方式被称为硬绑定,也是明确绑定的一种,这个函数在被创建时就已经明确的声明了作用域,也就是该对象被放置在了[[Scope]]属性里。这种方式有时很常用,所以被内置在ES5后的版本里,其内部实现(Polyfill低版本补丁)为:

if (!Function.prototype.bind) {
    Function.prototype.bind = function(oThis) {
        if (typeof this !== "function") {
            // 可能的与 ECMAScript 5 内部的 IsCallable 函数最接近的东西
            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 &&
                        oThis ? this : oThis
                    ),
                    aArgs.concat( Array.prototype.slice.call( arguments ) )
                );
            }
        ;

        fNOP.prototype = this.prototype;
        fBound.prototype = new fNOP();

        return fBound;
    };
}

在ES6里,bind()生成的硬绑定函数拥有一个name属性,源自于目标函数,此时显示为bound foo

在一些语言内置的函数里,提供了可选参数作为函数执行时的this对象,这些函数的内部实现方式和bind()类似,也是通过apply/call来明确绑定了你传递的参数作为this对象,如:

var obj = {
    name: 'Nicholas'
};
[1,2,3].forEach(function(item) {
    console.log(item, this.name);
}, obj);
// 1 "Nicholas"
// 2 "Nicholas"
// 3 "Nicholas"

new绑定

new操作符会调用对象的构造器函数来初始化类成为一个实例。它的执行过程为:

  1. 一个全新的对象被凭空创建
  2. 这个新构造的对象被接入原型链(__proto__指向该构造函数的prototype)
  3. 这个新构造的对象被绑定为函数调用的this对象
  4. 除非函数返回一个其它对象,这个被new调用的函数将返回这个新构建的对象。

实质上加new关键字和()只不过是该函数的不同调用方式而已,前者为构造器调用,后者为执行调用,在调用过程中,this指向不同,返回值不同。

在new绑定的规则中,this指向新创建的对象。

箭头函数绑定

现在我们看一个十分特别的this绑定,ES6中加入的箭头函数,前面的四种都是函数执行时通过调用点确认this对象,而箭头函数是在词法作用域确定this对象,即在词法解析到该箭头时为该函数绑定this对象为当前对象:

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

var obj = {
    a: 2
};

foo.call( obj ); // 2

绑定的优先级

通过一些具体的实例对比,我们可以得出不同绑定方式的优先级:
new绑定 > 明确绑定 > 隐含绑定 > 默认绑定

箭头函数属于词法作用域绑定,所以其优先级更高,但是跟上面的不冲突。

最初的问题

现在我们再来看下最初的问题:

var name = 'The Window';
var obj = {
    name: 'My obj',
    getName: function() {
        return this.name;
    }
};

// 猜测下面的输出和背后的逻辑(非严格模式下)
obj.getName();    // 'My obj'
(obj.getName)();    // 'My obj'
(obj.getName = obj.getName)();    // 'The Window'

我们可以看出第一个直接绑定this对象为obj,第二个加上括号好像是引用了一个函数,但object.getName(object.getName)定义一致,所以this依然指向obj;第三个赋值语句会返回函数本身,所以作为匿名函数来执行,就会返回'The Window'。

参考资料

  1. 简书 - this与对象原型: http://www.jianshu.com/p/11d8...
  2. MDN - bind: https://developer.mozilla.org...
  3. Github - 深入变量对象:https://github.com/mqyqingfen...
  4. JS高级程序设计:第五章(引用类型),第七章(函数表达式)

赵帅强
3.3k 声望380 粉丝

前端打工人