/ JavaScript

JavaScript 中 "this" 的工作原理和一些坑

JavaScript 中,this 是一个相对难懂的特殊变量。因为它随处可用,不局限于面向对象编程中。本文介绍 this 的工作原理,以及使用中可能会遇到的坑,以总结出最佳编程实践。

为了方便理解 ,将 this 使用场景分为三类:

  • 在函数内部: this 是一个隐含的参数。
  • 在函数外部(顶级作用域中): this 在浏览器中指向全局对象;在 Node.jS 中指 模块(module) 的接口(exports)。
  • 在传递给 eval() 的字符串中: eval() 如果是被直接调用, this 指的是当前对象;如果是被间接调用,this 指的是全局对象。
下面我们来具体测试一下这几个分类。

函数内部的 this

函数内部是 this 最常用的使用场景,因为 JavaScript 中函数代表所有可被调用的结构,扮演三种不同的角色:
  • 实函数(Real functions):this 在松散模式下是全局对象,严格模式下是 undefined
  • 构造函数:this 指向刚创建的实例
  • 方法:this 指向方法所属的对象(receiver
在函数中,this 可以理解为一个额外隐含的参数。

实函数中的this

在实函数中,this 的值取决于函数所处的模式
  • 松散模式(Sloppy mode): this 指向全 全局对象 (浏览器中就是window)。
    function sloppyFunc() {
        console.log(this === window); // true
    }
    sloppyFunc();
    
  • 严格模式: this 的值为 undefined
    function strictFunc() {
        'use strict';
        console.log(this === undefined); // true
    }
    strictFunc();
    
this 作为函数的一个隐含参数被设置为默认值,但通过 call()apply() 调用函数时可以明确指定 this 的值:
function func(arg1, arg2) {
    console.log(this); // a
    console.log(arg1); // b
    console.log(arg2); // c
}
func.call('a', 'b', 'c'); // (this, arg1, arg2)
func.apply('a', ['b', 'c']); // (this, arrayWithArgs)

构造函数中的 this

通过 new 操作符调用函数时,函数充当构造函数的角色。new 操作符创建一个新对象,并通过 this 将这个对象传入构造函数。
var savedThis;
function Constr() {
    savedThis = this;
}
var inst = new Constr();
console.log(savedThis === inst); // true
JavaScript 中 new 的实现原理大概如下面的代码所示(更复杂的实现请 看这里):
function newOperator(Constr, arrayWithArgs) {
    var thisValue = Object.create(Constr.prototype);
    Constr.apply(thisValue, arrayWithArgs);
    return thisValue;
}

方法中的 this

在对象的方法中,this 和传统的面对对象语言很类似:指向包含这个方法的对象(receiver)。
var obj = {
    method: function () {
        console.log(this === obj); // true
    }
}
obj.method();

顶级作用域中的 this

在浏览器中,顶级作用域就是全局作用域,this 指向全局对象 全局对象 (比如 window 对象):
<script>
    console.log(this === window); // true
</script>
在 Node.js 中,代码通常在 module 里执行,因此,顶级作用域是一个特别的模块作用域(module scope)。
// `global` (not `window`) refers to global object:
console.log(Math === global.Math); // true

// this doesn’t refer to the global object:
console.log(this !== global); // true
// this refers to a module’s exports:
console.log(this === module.exports); // true

eval() 中的 this

eval() 可以直接调用(调用 eval() 函数),或者通过别的方式间接调用(如 call(),或者作为 window 的方法等方式调用)。

如果间接调用 eval()this 指向全局对象:

> (0,eval)('this === window')
true

如果直接调用 eval()thiseval() 执行环境中的 this 保持一致。例如:

// Real functions
function sloppyFunc() {
    console.log(eval('this') === window); // true
}
sloppyFunc();

function strictFunc() {
    'use strict';
    console.log(eval('this') === undefined); // true
}
strictFunc();

// Constructors
var savedThis;
function Constr() {
    savedThis = eval('this');
}
var inst = new Constr();
console.log(savedThis === inst); // true

// Methods
var obj = {
    method: function () {
        console.log(eval('this') === obj); // true
    }
}
obj.method();

this 相关的坑

有三个 this 相关的坑需要多留意。下面的例子中,使用严格模式都能提高代码安全性,因为严格模式下实函数中 this 的值为 undefined,而且出错的时候会有报错信息。

坑一:忘记使用 new 操作符

如果调用一个构造函数而忘了使用 new 操作符,函数实际上像实函数一样执行,this 的值不会是预期的。松散模式下,this 指向 window,而且创建相应的全局变量:
function Point(x, y) {
    this.x = x;
    this.y = y;
}
var p = Point(7, 5); // we forgot new!
console.log(p === undefined); // true

// Global variables have been created:
console.log(x); // 7
console.log(y); // 5

还好,严格模式下会报错:
function Point(x, y) {
    'use strict';
    this.x = x;
    this.y = y;
}
var p = Point(7, 5);
// TypeError: Cannot set property 'x' of undefined

坑二:不恰当地使用方法

直接将一个对象的方法当做普通函数,把方法作为参数传入函数时,很可能会掉进坑里。现实中 setTimeout() 和注册事件处理程序都会发生类似的情况。下面使用 callIt() 函数来模拟这些场景:
/** Similar to setTimeout() and setImmediate() */
function callIt(func) {
    func();
}
松散模式下,将方法作为普通函数调用时,方法中的 this 指向全局对象,将会创建一个全局的变量:
 var counter = {
    count: 0,
    // Sloppy-mode method
    inc: function () {
        this.count++;
    }
}

callIt(counter.inc);

// Didn’t work:
console.log(counter.count); // 0

// Instead, a global variable has been created
// (NaN is result of applying ++ to undefined):
console.log(count); // NaN

在浏览器环境中执行 callIt(counter.inc) 以后,.inc() 中的 this 指向全局对象 window,并在 window 上创建了 count 变量(值为 undefined), undefined ++ 的结果为 NaN

在严格模式下,thisundefined,程序会报错:

var counter = {
    count: 0,
    // Strict-mode method
    inc: function () {
        'use strict';
        this.count++;
    }
}

callIt(counter.inc);

// TypeError: Cannot read property 'count' of undefined
console.log(counter.count);

正确的方法是使用 bind()

var counter = {
    count: 0,
    inc: function () {
        this.count++;
    }
}

callIt(counter.inc.bind(counter));

// It worked!
console.log(counter.count); // 1

bind() 创建了一个始终将 this 指向 counter 的函数。

坑三:隐含的 this

在方法中使用实函数时,很容易忽略是函数有它自己的 this,导致实函数里面的 this 没有指向预想的对象。
var obj = {
    name: 'Jane',
    friends: [ 'Tarzan', 'Cheeta' ],
    loop: function () {
        'use strict';
        this.friends.forEach(
            function (friend) {
                console.log(this.name+' knows '+friend);
            }
        );
    }
};
obj.loop();
// TypeError: Cannot read property 'name' of undefined
上面的例子中,this.name 报错,因为实函数里的 thisundefined,和 loop() 方法中的 this 不一样。下面提供三种方法来解决这个问题。

方法一:that = this

将对象的 this 保存到一个显性变量上(经常用 that / self 来命名变量)。

    loop: function () {
        'use strict';
        var that = this;
        this.friends.forEach(function (friend) {
            console.log(that.name+' knows '+friend);
        });
    }

方法二:使用 bind()

loop: function () {
    'use strict';
    this.friends.forEach(function (friend) {
        console.log(this.name+' knows '+friend);
    }.bind(this));
}

方法三:forEach 的第二个参数

forEach 的第二参数可以指定回调函数中 this 的值。

loop: function () {
    'use strict';
    this.friends.forEach(function (friend) {
        console.log(this.name+' knows '+friend);
    }, this);
}

最佳实践

理论上,我(作者)认为实函数并没有属于自己的 this,上面的解决方案也是遵循这个思想的。ECMAScript 6 支持 箭头函数(arrow functions 语法使函数没有自己的 this。在这些函数里可以随意使用 this,因为没有隐形的存在:
loop: function () {
    'use strict';
    // The parameter of forEach() is an arrow function
    this.friends.forEach(friend => {
        // `this` is loop’s `this`
        console.log(this.name+' knows '+friend);
    });
}
我不喜欢有些 API 把 this 作为实函数的附加参数:
beforeEach(function () {  
    this.addMatchers({  
        toBeInRange: function (start, end) {  
            ...
        }  
    });  
});  
把隐性的参数变成显性的传入,代码会更加直观,而且这样与箭头函数一致。
beforeEach(api => {
    api.addMatchers({
        toBeInRange(start, end) {
            ...
        }
    });
});
via JavaScript’s “this”: how it works, where it can trip you up