this机制指北

239 阅读10分钟

this作为JavaScript中的一个关键字,拥有较为复杂的机制。this的一个重要的前提是,总是指向调用它所在方法的对象,而与声明的位置无关。即this对象是在运行时基于函数的执行环境绑定的。

this的四种绑定场景

默认绑定

默认绑定通常是作为纯粹的独立函数调用。在非严格模式下,this指向全局对象;严格模式下,this指向undefined,此时会抛出错误。

function foo() {
    console.log(this.a)
}
var a = 'global'
foo() // 'global'

function bar() {
    'use strict'
    console.log(this.a)
}
bar() // TypeError:a undefined

《你不知道的JS》书中还提到一种特别的情况:在严格模式下调用其他函数,不会影响其默认绑定:

function foo() {
    console.log(this.a)
}
var a = 'global'
function bar() {
    'use strict'
    foo() // 此时不影响其默认绑定
}
bar() // global  此时仍然指向全局

但是在一般情况下,代码中不会出现严格模式与非严格模式混用的情况。

隐式绑定

隐式绑定是作为对象方法的调用。在调用的位置,检查是否有上下文对象,若有,则将其隐式绑定在这个对象上。

function foo() {
    console.log(this.a)
}
var a = 'global'
var bar1 = {
    a: 1,
    foo: foo
}
var obj2 = {
    a: 2,
    obj1: obj1
}
bar1.foo() // 1 this指向调用函数的对象,即bar1
obj2.obj1.foo() // 1 this指向最后一层调用函数的对象

隐式绑定会还会出现this丢失的问题:

// 接上
let baz = bar1.foo
barz() // 'global' baz仅是bar1.foo的一个引用,所有此时this指向全局对象

在调用时,没有上下文对象,此处仅仅是对于函数的引用,由此导致了丢失问题。

还有一种丢失情况更为隐蔽:在传入回调函数中。

function Foo() {
    this.a = 1
    console.log(this.a)
}
var obj = {
    a: 2,
    foo(msg) {
        console.log(this.a + msg)
    }
}
 Foo(obj.foo) // 1 此情况是传入了函数的引用

显示绑定

显示绑定是通过call、apply等方法,将函数中的this绑定到指定对象上。

function foo() {
    console.log(this.a)
}
var bar = {
    a: 2
}
var a = 'global'
foo.call(bar) // 2

如果将原始值作为call、apply、bind方法的第一个参数,则会将其转换为它的对象形式。

如果将null或undefined传入,则在调用时会被忽略,从而this指向全局对象。

new绑定

new绑定是作为构造函数调用。在new的时候,会发生以下过程:

  1. 创建一个新的对象
  2. 将构造函数的作用域赋值给这个新的对象(this指向该对象)
  3. 执行构造函数中的代码
  4. 若函数没有返回对象,则会返回该新对象
function Foo(a) {
    this.a = a
}
let bar = new Foo(1)
console.log(bar.a) // 1

改变this的指向

能够改变this指向的方法除了上述提到的使用apply方法和通过new实例化对象之外。还有存在几种情况。

setTimeout

setTimeout的回调函数中,this使用的是默认绑定,即指向全局对象。

function foo() {
    console.log(this.a)
}
var a = 'global'
var bar = {
    a: 'bar',
    b: function() {
        setTimeout(function() {
            console.log(this.a)
        }, 10)
    }
}
var baz = {
    a: 'baz',
    b: foo
}
bar.b() // global
setTimeout(function() {
    baz.b()
}, 10) // baz 此时仍然为隐式绑定

箭头函数

箭头函数中,this继承于它外面第一个非箭头函数的函数的this指向;一旦绑定了上下文,则不会被改变。其不可以使用call、apply这些方法改变this指向。

function foo() {
    return ()=> {
        console.log(this.a)
    }
}
var bar1 = {
    a: 1
}
var bar2 = {
    a: 2
}
let baz = foo.call(bar1) // 此时this指向bar1
baz.call(bar2) // 1 注意此时this指向在绑定bar1后,不会改变

结合setTimeout:

function foo() {
    console.log(this.a)
}
var a = 'global'
var bar = {
    a: 'bar',
    b: function() {
        setTimeout(()=> {
            console.log(this.a)
        }, 10)
    }
}
bar.b() // bar 指向bar对象

稍微复杂一些的例子:

var obj = {
    foo: function() {
        return () => {
            console.log(this)
        }
    },
    bar: function(){
        return function() {
            return ()=>{
                console.log(this);
            }
        }
    },
    baz: () => {
        console.log(this)
    }
}
let foo = obj.foo()
foo() // 指向obj,此时为隐式绑定
let bar = obj.bar()
let bar1 = bar() // 实际上为隐式丢失情况,即执行默认绑定
bar1() // 指向window 
obj.baz() // 指向window 执行为箭头函数,obj中不存在this,箭头函数按词法作用域往上查找到全局的this

一道经典的题目

var number = 5;
var obj = {
    number: 3,
    fn1: (function () {
        var number;
        this.number *= 2;
        number = number * 2;
        number = 3;
        return function () {
            var num = this.number;
            this.number *= 2;
            console.log(num);
            number *= 3;
            console.log(number);
        }
    })()
}
var fn1 = obj.fn1;
fn1.call(null);
obj.fn1();
console.log(window.number);

var fn1 = obj.fn1定义的时候,fn1对应的闭包已经执行,此时应用的是默认绑定(注意此时非隐式绑定)。

再执行fn1.call(null),首先num的值为this.number(window.number),在闭包中已经执行this.number *= 2,所以此时num为10;number初始赋值为3,经过number *= 3后,打印为9

接着执行obj.fn1(),此时应用隐式绑定,this指向obj。此时不执行闭包中代码,对于num,此时this.number为3;由于前面的步骤执行了闭包,所以number值得以保留为9,因此当执行number *= 3时,最后打印出来的number值为27。

最后window.number的数值由于在num赋值后再执行了this.number *= 2,所以输出为20。

因此最后的结果整理可得:

10
9
3
27
20

那么如果将fn1改为普通函数呢?

var number = 5;
var obj = {
    number: 3,
    fn1: function () {
        var number;
        this.number *= 2;
        number = number * 2;
        number = 3;
        return function () {
            var num = this.number;
            this.number *= 2;
            console.log(num);
            number *= 3;
            console.log(number);
        }
    }
}
var fn1 = obj.fn1();
fn1.call(null);
obj.fn1()();
console.log(window.number);

fn1保留为fn1返回函数的引用,fn1.call(null)应用默认绑定规则,this.number指向全局,所以打印为5,number同样为9。

obj.fn1()()中this指向为全局,注意此处非隐式绑定,可以看作是

var foo = obj.fn1()
foo()

后续执行第一步相同,此时的this.number经过第一步后,变为10,所以num打印为10,number与第一步判断相同,打印9。

window.number在第二步中执行过this.number *= 2之后,打印为20。 因此最后的结果整理可得:

5
9
10
9
20

扩展

call、apply、bind对比理解

首先,call和apply的共同点在于,都能够改变函数执行时的上下文,将一个对象的方法交由给另一个对象来执行。他们之间的主要区别在于传参。

call

fun.call(thisArg, arg1, arg2, ...)

thisArg为fun函数执行时指定的this值,arg1, arg2, ... 为指定的参数。

引用MDN上的一个例子:

function list() {
  return Array.prototype.slice.call(arguments);
}
var list1 = list(1, 2, 3); // [1, 2, 3]

调用了call方法后,this指向arguments,上述例子中,我们将一个类数组对象(arguments)转换成了一个真正的数组。

假设我们现在模拟call方法,尝试写一个myCall方法,则myCall内部的实现过程主要是:

  1. 设置上下文对象,即this的指向
  2. 将this隐式绑定到传入的context上
  3. 传入参数,执行该方法

具体实现如下:

Function.prototype.myCall = function(context, ...arg) {
    if (context === null || context === undefined) {
    // context为null、空、undefined时,指向window
      context = window
    } else {
      context = Object(context) // 考虑传入原始值情况,将其隐式转换为对象
    }
    const tempProto = Symbol.for('temp') // 使用Symbol保证了不会覆盖context原有属性,且保持了唯一性
    context[tempProto] = this // 将this绑定到context上,this可看做是函数本身
    let result = context[tempProto](...arg) // 传参
    delete context[tempProto] // 删除上下文对象属性
    return result
 }

apply

apply的用法区别于call方法,其第二个参数必须是数组或类数组。

// 获取数组中最大项
var arr = [1, 2, 3]
Math.max.apply(null, arr) // 3

模拟apply方法与myCall方法类似,只是在处理传参的部分有些不同。因此在针对第二个参数的判断上,我们要先确定该参数是否为类数组。

// apply
Function.prototype.myApply = function (context) {
    if (context === null || context === undefined) {
        context = window
    } else {
        context = Object(context)
    }
    // 类数组判断
    function isArrayLike(o) {
        if (o &&                                    // o不是null、undefined等
           typeof o === 'object' &&                // o是对象
           isFinite(o.length) &&                   // o.length是有限数值
           o.length >= 0 &&                        // o.length为非负值
           o.length === Math.floor(o.length) &&    // o.length是整数
           o.length < 4294967296)                  // o.length < 2^32
           return true
        else
           return false
    }
    const tempProto = Symbol.for('temp')
    context[tempProto] = this
    let args = arguments[1]
    let result
    // 针对第二个参数判断
    const isArray = Array.isArray(args) || isArrayLike(args)
    if (args) {
        if (!isArray) {
            throw new TypeError('error')
        } else {
            args = Array.from(args)
            result = context[tempProto](...args)
        }
    } else {
        result = context[tempProto]()
    }
    delete context[tempProto]
    return result
}

bind

bind()方法创建一个新的函数,在bind()被调用时,这个新函数的this被bind的第一个参数指定,其余的参数将作为新函数的参数供调用时使用。
--MDN

fun.bind(thisArg[, arg1[, arg2[, ...]]]);

其中,thisArg为调用函数时作为this参数传递给目标函数的值。如果使用new运算符构造绑定函数,则会忽略该值。bind方法返回一个原函数的靠背,并拥有指定的this值和初始参数。

function add(arg1, arg2) {
    return arg1 + arg2
}
var res1 = add.bind(null, 1, 2)() // 3
var _add = add.bind(null, 1)
var res2 = _add(2) // 1 + 2 = 3

bind可以使得函数拥有一个预设的初始参数,如_add方法所示。

接着再来看使用new去调用bind之后的函数:

function Animal (name, type) {
    this.name = name
    this.type = type
}
var obj = {
    age: 1
}
var bindAnimal = Animal.bind(obj, 'lucky')
var dog = new bindAnimal('dog')
console.log(dog) // Animal {name: 'lucky', type: 'dog'}

此时我们可以看出,bindAnimal内部的this不在是obj。而当我们使用普通函数去调用时:

// 接上
bindAnimal('cat')
console.log(obj) // {age: 1, name: "lucky", type: "cat"}

这时候我们可以看出,this的指向符合预期地指向了obj。同时MDN中提到,作为构造函数使用的绑定函数并不是最佳的解决方案,且可能不应该用在任何生产环境中。

手动实现bind方法可参考MDN中polyfill实现:

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() {
          // this instanceof fBound === true时,说明返回的fBound被当做new的构造函数调用
          return fToBind.apply(this instanceof fBound
                 ? this
                 : oThis,
                 // 获取调用时(fBound)的传参.bind 返回的函数入参往往是这么传递的
                 aArgs.concat(Array.prototype.slice.call(arguments)));
        };

    // 维护原型关系
    if (this.prototype) {
      // Function.prototype doesn't have a prototype property
      fNOP.prototype = this.prototype; 
    }
    // 下行的代码使fBound.prototype是fNOP的实例,因此
    // 返回的fBound若作为new的构造函数,new生成的新对象作为this传入fBound,新对象的__proto__就是fNOP的实例
    fBound.prototype = new fNOP();

    return fBound;
 };

其中维护原型关系可以理解为继承操作,如果不去维护原型,则实例无法继承绑定函数的原型中的对象。

function Point(x, y) {
  this.x = x;
  this.y = y;
}

Point.prototype.toText = function() { 
  return this.x + ',' + this.y; 
};

var emptyObj = {};
var YAxisPoint = Point.bind2(emptyObj, 0);
var a = new YAxisPoint(5)

此时我们在控制台打印a实例,可以发现实例a._proto_中没有toText方法。

这里如果我们直接将绑定函数的prototype赋给fBound.prototype

// ...
fBound.prototype = this.prototype
return fBound

这种情况如果我们通过修改fBound.prototype可以直接修改this.prototype。所以polyfill中使用了借由fNOP为中介的方式,把fBound.prototype赋值给fNOP的实例。当然,我们还可以借由Object.create()实现:

// ...
fBound.prototype = Object.create(fToBind.prototype)

return fBound

此方法中未能实现oThis传入原始值的情况,如需要,则需要同上文call的实现中,执行Object()来包装对象。

this原理

我们先来看一个例子

var a = 'global'
var obj = {
    a: 'obj',
    foo() {
        console.log(this.a)
    }
}
obj.foo() // 'obj'
var foo = obj.foo
foo() // 'global'

上述代码中obj.foofoo指向同一个函数,但执行结果不同。对于前者而言,其运行在obj环境,自然指向obj;而后者运行在全局环境,所以this指向全局。

我们都知道引用类型的值是保存在内存中的对象。因此当我们将一个对象赋值给obj,实际上是在内存中生成一个对象,然后将该对象的内存地址赋值给obj。

上述例子中,obj.a属性的值保存在属性描述对象的value属性中,二对于obj.foo而言,是将函数的地址赋值给foo属性的value属性。

由于函数是一个单独的值,所以它可以在不同的环境(上下文)中执行。详情可参考JavaScript的this原理