函数的this绑定与调用

567 阅读16分钟

每个函数的 this 是在调用时被绑定的,完全取决于函数的调用位置(也就是函数的调用方法)

1. 调用位置

调用位置是函数在代码中被调用的位置(而不是声明的位置)。

最重要的是要分析调用栈(就是为了到达当前执行位置所调用的所有函数)。我们需要寻找的调用位置就在当前正在执行的函数的前一个调用中。

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 的调用位置  logs:baz bar foo

2. 绑定规则

2.1 默认绑定

最常见的函数调用类型:独立函数调用。可以把这条规则理解为无法应用其他规则时的默认规则。默认绑定时this指向全局对象(非严格模式)

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

在代码中,foo() 是直接使用不带任何修饰的函数引用进行调用的,因此只能使用默认绑定,此时 this 指向全局对象

如果使用严格模式(strict mode),则不能将全局对象用于默认绑定,因此 this 会绑定到 undefined

function foo() {
    "use strict"
    console.log(this.a);
}
var a = 2;
foo()   // TypeError: Cannot read property 'a' of undefined

有一个小细节值得注意:虽然 this 的绑定完全取决于调用位置,但只有 foo() 运行在非严格模式下,默认绑定才能绑定到全局对象;在严格模式下调用 foo() 则不影响默认绑定:

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

( 即在严格模式下调用不在严格模式中的函数,并不会影响this指向。)
通常在代码中不应该混合使用 strict 模式和非 strict 模式。但存在有时候需要用到第三方库的可能性,其严格程度可能有所不同,所以需要注意这类兼容性细节。

2.2 隐式绑定

另一条需要考虑的规则是调用位置是否有上下文对象,或者说是否被某个对象拥有或包含,这种说法不完全准确。

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

foo() 这个函数严格来说不属于obj对象。但调用位置会使用obj上下文来引用函数,所以可以理解为函数被调用时obj对象“拥有”或“包含”函数引用。

当函数引用有上下文对象时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象。

对象属性引用链中只有上一层或者说最后一层在调用位置中起作用:

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

即如果函数调用前存在多个对象,this指向距离调用自己最近的对象
再看下面的例子:

function fn() {
    console.log(this.name);
};
let obj = {
    func: fn,
};
let obj1 = {
    name: '听风是风',
    o: obj
};
obj1.o.func()  //??

这里输出undefined,不要将作用域链和原型链弄混淆了,obj对象虽然是obj1的属性,但它们原型链并不相同,并不是父子关系,由于obj未提供name属性,所以是undefined。

function Fn() {};
Fn.prototype.name = '时间跳跃';

function fn() {
    console.log(this.name);\
};

let obj = new Fn();
obj.func = fn;

let obj1 = {
    name: '听风是风',
    o: obj
};
obj1.o.func() //?

这里输出时间跳跃,虽然obj对象并没有name属性,但顺着原型链,找到了产生自己的构造函数Fn,由于Fn原型链存在name属性,所以输出时间跳跃。

番外------作用域链与原型链的区别:

当访问一个变量时,解释器会先在当前作用域查找标识符,如果没有找到就去父作用域找,作用域链顶端是全局对象window,如果window都没有这个变量则报错。

当在对象上访问某属性时,首选会查找当前对象,如果没有就顺着原型链往上找,原型链顶端是null,如果全程都没找到则返一个undefined,而不是报错。

隐式丢失

在特定情况下会存在隐式绑定丢失的问题,最常见的就是作为参数传递以及变量赋值

  1. 作为参数传递
var name = '行星飞行';
let obj = {
    name: '听风是风',
    fn: function () {
        console.log(this.name);
    }
};
function fn1(param) {
    param();
};
fn1(obj.fn);  //行星飞行

将 obj.fn 也就是一个函数传递进 fn1 中执行,这里只是单纯传递了一个函数,this并没有跟函数绑在一起,所以this丢失,这里指向了window。参数传递其实就是一种隐式赋值。

  1. 变量赋值
var name = '行星飞行';
let obj = {
    name: '听风是风',
    fn: function () {
        console.log(this.name);
    }
};
let fn1 = obj.fn;
fn1(); //行星飞行

问题本质上与传参相同。虽然 fn1 是 obj.fn 的一个引用,但实际上,它引用的是 fn 函数本身,所以此时的 fn1() 可以理解为是一个不带任何修饰的函数调用,因此应用了默认绑定。
但需要注意的是,隐式绑定丢失并不是都会指向全局对象:

var name = '行星飞行';
let obj = {
    name: '听风是风',
    fn: function () {
        console.log(this.name);
    }
};
let obj1 = {
    name: '时间跳跃'
}
obj1.fn = obj.fn;
obj1.fn(); //时间跳跃

虽然丢失了 obj 的隐式绑定,但是在赋值的过程中,又建立了新的隐式绑定,这里this就指向了对象 obj1。

2.3 显示绑定

在分析隐式绑定时,我们必须在一个对象内部包含一个指向函数的属性,并通过这个属性间接引用函数,从而把 this 间接(隐式)绑定到这个对象上。 显示绑定就是不在对象内部包含函数引用,而可以在某个对象上强制调用函数。

可以使用函数的 call(..)apply(..) 以及 bind(..) 方法改变 this 的行为。它们的第一个参数是一个对象,会在调用函数时将其绑定到 this。这样可以直接指定 this 的绑定对象,所以称之为显示绑定。

let obj1 = {
    name: '听风是风'
};
let obj2 = {
    name: '时间跳跃'
};
let obj3 = {
    name: 'echo'
}
var name = '行星飞行';
function fn() {
    console.log(this.name);
};
fn(); //行星飞行
fn.call(obj1); //听风是风
fn.apply(obj2); //时间跳跃
fn.bind(obj3)(); //echo

在js中,调用一个函数习惯称之为函数调用,函数处于一个被动的状态;而call与apply让函数从被动变主动,函数能主动选择自己的上下文,所以这种写法我们又称之为函数应用。

注意,如果在使用call之类的方法改变this指向时,指向参数提供的是null或者undefined,那么 this 将指向全局对象

let obj1 = {
    name: '听风是风'
};
var name = '行星飞行';

function fn() {
    console.log(this.name);
};
fn.call(undefined); //行星飞行
fn.apply(null); //行星飞行
fn.bind(undefined)(); //行星飞行

另外,在js API中部分方法也内置了显式绑定,以forEach为例:

let obj = {
    name: '听风是风'
};
[1, 2, 3].forEach(function () {
    console.log(this.name); //听风是风 * 3
}, obj);

来看它的另一种形式:

function foo(el) {
    console.log(el, this.id);
}
let obj = {
    id: "Jackson Yee"
}
 
[1, 2, 3].forEach(foo, obj); 
// TypeError: Cannot read property 'forEach' of undefined

这里出现了报错。分析原因后,可能是因为直接用对象字面量方法创建的数组,在执行过程中被解析为Object,但无法准确判断是否是数组,所以在调用 forEach 时出现了问题。来看一下具体过程:

function foo(el) {
 console.log(el, this.id);
}
  
var obj = {
    id: 123
}
new Array() && [1,2,3].forEach(foo, obj)  // 1.

try {   // 2.
    [1,2,3].forEach(foo, obj)
} catch(err) {
    console.log(err);

首先看1,new Array() 可以理解为先向编器声明这是一个数组,因为 new Array() 为true,所以 && 后面的语句也为 true,循环顺利执行。 2 通过异常捕获机制执行后,虽然有问题,但它会继续执行循环,所以能输出理想结果。

[1,2,3] || new Array().forEach(foo, obj)  // 3.

还有这样一种写法,在这里是取的 || 后面的结果,因为数组为空,所以不会进行遍历,代码执行不会产生结果。而 1 取的是 && 之后的真实数据。这里会有所不同,需要注意一下。它 和 1 的区别就在于两个短路运算符的执行机制不同。

引申:短路运算符
const type = () => {
    return true
};

// 1
type && console.log('&&');  // &&
!type && console.log('&&');


// 2
const a = type && console.log('&&');  // &&
const b = !type && console.log('&&');  

console.log(a, b);  // undefined false

// 3
const c = type() || console.log('&&');  
const d = !type() || console.log('&&');  // &&

console.log(c, d); // true undefined 

// 4 
let e = type() && console.log('&&') && console.log('hello');  // &&
let f = !type() || console.log('&&') || console.log('hello'); // && hello

console.log(e, f);  //undefined undefined
番外-----call、apply与bind有什么区别?
  1. call、apply 与 bind 都用于改变this绑定,但 call、apply 在改变this指向的同时还会执行函数,而 bind 只是负责绑定 this 并返回一个新方法,不会执行。这也是例子中bind后还加了一对括号 ()的原因。

  2. bind 属于硬绑定,返回的 boundFunction 的 this 指向无法再次通过bind、apply或 call 修改(但可以通过new 绑定修改);call与apply的绑定只适用当前调用,下次调用需要重新绑定。

  3. call 与 apply 功能完全相同,第一个参数都指向 this 。唯一不同的是 call 方法中接受的是一个参数列表,而 apply 方法传入的形参是一个数组。在传参的情况下,call的性能要高于apply,因为apply在执行时还要多一步解析数组。

let obj1 = {
    name: '听风是风'
};
let obj2 = {
    name: '时间跳跃'
};
var name = '行星飞行';

function fn() {
    console.log(this.name);
};
fn.call(obj1); //听风是风
fn(); //行星飞行
fn.apply(obj2); //时间跳跃
fn(); //行星飞行
let boundFn = fn.bind(obj1);//听风是风
boundFn.call(obj2); //听风是风
boundFn.apply(obj2); //听风是风
boundFn.bind(obj2)(); //听风是风

2.4 new 绑定

在传统的面向类的语言中,“构造函数”是类中的一些特殊方法,使用 new 初始化类时会调用类中的构造函数。通常形式:somethig = new MyClass(..);

但在js中,构造函数只是使用 new 操作符时被调用的普通函数,它并不是一个类,最终返回的对象也不是一个实例,只是为了便于理解习惯这么说。

所以,包括内置对象函数(比如Number(..))在内的所有函数都可以用 new 来调用,这种调用被称为构造函数调用。注意:实际上并不存在所谓的构造函数,只有对于函数的“构造调用”

new一个函数,或者说发生构造函数调用时,会自动执行下面的操作,大致分为三步:

1.以构造器的prototype属性为原型,创建新对象;

2.将this(可以理解为上句创建的新对象)和调用参数传给构造器,执行;

3.如果构造器没有手动返回对象,则返回第一步创建的对象。

function Fn(){
    this.name = '听风是风';
};
let echo = new Fn();
echo.name//听风是风

使用 new 调用 Fn时,构造一个新对象echo 并把它绑定到 Fn() 调用中的this上。

红宝书上对于 使用 new 调用类的构造函数时会执行的操作阐述:

  1. 在内存中创建一个新对象。
  2. 这个新对象内部的 [[Prototype]] 指针被赋值为构造函数的 prototype 属性。
  3. 构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)。
  4. 执行构造函数内部的代码(给新对象添加属性)。
  5. 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。

3. 优先级

如果一个函数调用存在多种绑定方法,this 最终指向谁呢?

首先,默认绑定是四条规则中优先级最低的,可以先不考虑。

3.1 显示绑定和隐式绑定:

// 显式 > 隐式
let obj = {
    name:'行星飞行',
    fn:function () {
        console.log(this.name);
    }
};
obj1 = {
    name:'时间跳跃'
};
obj.fn();  //行星飞行
obj.fn.call(obj1); // 时间跳跃

显示绑定优先级高于隐式绑定。

3.2 隐式绑定和new绑定:

 function foo (something){
     this.a = something
 }
 var obj1 = {
     foo: foo
 }
 var obj2 = {}

 obj1.foo(2);
 console.log(obj1.a);   // 2

 obj1.foo.call(obj2,3)
 console.log(obj2.a);  // 3

 var bar = new obj1.foo(4)
 console.log(obj1.a);   // 2
 console.log(bar.a);    // 4

new 绑定比隐式绑定优先级更高。

3.3 new绑定和显示绑定:

new 和 call/apply 无法一起使用,因此无法通过 new foo.call(obj1) 来直接进行测试。来看下面这个例子:

function Fn(){
    this.name = '听风是风';
};
let obj = {
    name:'行星飞行'
}
let echo = new Fn().call(obj);  // TypeError: call is not a function

let echo = new Fn();  
console.log(echo);            // Fn { name: '听风是风' }
console.log(echo.__proto__);  // {}
console.log(echo.__proto__.__proto__);  // [Object: null prototype] {}

如果把 new 和 call 放在一起使用,报错结果显示 call 不是一个方法。通过观察这个新建对象的原型链,该对象没有继承Function,而call/apply 是 Function 的方法。

function Fn(){
    this.name = '听风是风';
    return function() {
        console.log("Jackson Yee");
    }
};
let obj = {
    name:'行星飞行'
}
new Fn().call(obj);  // Jackson Yee

上面的代码在 Fn() 内返回了一个匿名函数,此匿名函数继承了Function的属性和方法。

硬绑定和new绑定
function foo (something){
    this.a = something
}
var obj1 = {}

var bar = foo.bind(obj1)
bar(2)
console.log(obj1.a);  // 2

var baz = new bar(3)
console.log(obj1.a);  // 2
console.log(baz.a);   // 3

bar 被硬绑定到 obj1 上,但 new bar(3) 并没有像预计的那样把 obj1.a 的值修改为3,相反,new 修改了硬绑定(到obj1)调用bar() 中的this。通过 new 绑定,得到了一个新对象baz,且 baz.a 的值为3。
这也是前面提到的call、apply与bind区别里的第二项 bind 的 this 指向能不能被修改的问题。

3.4 判断this

可以根据优先级来判断函数在某个调用位置应用的是哪条规则。可以按照下面的顺序进行判断:

  1. 函数是否在 new 中调用(new绑定)?this 绑定的是新创建的对象
    var bar = new foo ()
  2. 函数是否通过 call、apply(显示绑定)或者硬绑定调用?this 绑定的是指定的对象
    var bar = foo.call (obj)
  3. 函数是否在某个上下文对象中调用(隐式绑定)?this 绑定的是那个上下文对象
    var bar = obj1.foo ()
  4. 如果都不是,使用默认绑定。(函数体)严格模式下,this 绑定到 undefined,否则绑定到全局对象
    var bar = foo ()

4. 绑定例外

4.1 被忽略的 this

如果把 nullundefined 作为 this 的绑定对象传入 call、apply 或者 bind,这些值在调用时会被忽略,实际上应用的是默认绑定规则:

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

什么情况下会传入 null 呢?
一种常见的做法是使用 apply (..) 来“展开”一个数组,并当作参数传入一个函数。类似地,bind (..) 可以对参数进行柯里化(预先设置一些参数),这种方法有时非常有用:

function foo(a, b){
    console.log("a:" + a + ", b:" + b);
}
foo.apply(null,[2,3])   // a:2, b:3
var bar = foo.bind(null,2)
bar(3)   // a:2, b:3

这两种方法都要传入一个参数当作 this 的绑定对象。如果函数并不关心 this,仍然需要传入一个占位值,这是 null 是一个不错的选择。

但总是使用 null 来忽略 this 绑定可能会导致许多难以分析和追踪的 bug。比如第三方库中的 this 确实使用了 this,那默认绑定规则会把 this 绑定到全局对象,这会导致不可预计的后果(比如修改全局对象)。

补充:ES6 中,可以用 ... 操作符代替 apply (..) 来“展开”数组,这样可以避免不必要的 this 绑定。foo(...[1, 2]) 等价于 foo(1, 2)。

更安全的 this

如果我们在忽略 this 绑定时总是传入一个空的非委托对象(DMZ),任何对于 this 的使用都会被限制在这个空对象中,不会对全局对象产生任何影响。

function foo(a,b){
    console.log("a:" + a + ", b:" + b);
}
var empty = Object.create(null)

foo.apply(empty, [2,3])  // a:2, b:3
var bar = foo.bind(empty,2)
bar(3)  // a:2, b:3

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

4.2 间接引用

需要注意的是,有可能无意间创建一个函数的“间接引用”,在这种情况下,调用这个函数会应用默认规则。

间接引用容易在赋值时发生:

function foo(){
    console.log(this.a);
}
var a = 2;
var o = {a: 3, foo: foo}
var p = {a: 4}
o.foo()  // 2
( p.foo() = o.foo()  //3 )

赠值表达式的返回值是目标函数的引用,所以调用位置是 foo() 而不是 p.foo() 或 o.foo()。这里会应用默认绑定。

5. 箭头函数

ES6 中介绍了一种无法使用这些规则的特殊函数类型:箭头函数。

箭头函数并不是使用 function 关键字定义的而是使用被称为“胖箭头”的操作符 => 定义的。箭头函数根据外层(函数或全局)作用域来决定 this。

function fn() {
    // 返回一个箭头函数
    return () => {
        // this 继承自 fn()
        console.log(this.name);
    };
}
let obj1 = {
    name: '听风是风'
};
let obj2 = {
    name: '时间跳跃'
};
let bar = fn.call(obj1); // fn this指向obj1
bar.call(obj2); //听风是风

fn() 内部创建的箭头函数会捕获调用时 fn() 的 this。由于 fn() 的 this 绑定到 obj1,bar(引用箭头函数)的 this 也会绑定到 obj1,且箭头函数的 this 绑定无法被修改(new 也不行)。

但因为箭头函数的 this 会继承外层函数调用的 this 绑定,因此可以修改外层函数 this 指向达到间接修改箭头函数 this 的目的。

function fn() {
    return () => {
        console.log(this.name);
    };
};
let obj1 = {
    name: '听风是风'
};
let obj2 = {
    name: '时间跳跃'
};
fn.call(obj1)(); // fn this指向obj1,箭头函数this也指向obj1
fn.call(obj2)(); // fn this 指向obj,箭头函数this也指向obj2

箭头函数体内的this对象,就是定义该函数时所在的作用域指向的对象,而不是使用时所在的作用域指向的对象。

6. 小结

有几点值得注意:

  • 默认绑定在严格模式与非严格模式下 this 指向会有所不同。
  • 隐式绑定与隐式丢失的几种情况,作用域链与原型链的区别。
  • 显式绑定以及硬绑定,绑定指向为 null 或 undefined 时 this 会指向全局(非严格模式)。
  • call、apply 与 bind 的区别。
  • new 绑定以及 new 一个函数会发生什么。
  • 箭头函数的 this 由外层作用域 this 指向决定,一旦绑定成功无法被再次修改。

练习

/*非严格模式*/
var obj1 = {
    name: '听风是风',
    fn1: function () {
        console.log(this.name)
    },
    fn2: () => {
        console.log(this.name)
    },
    fn3: function () {
        // console.log(this.name);
        return function () {
            console.log(this.name)
        }
    },
    fn4: function () {
        return () => console.log(this.name)
    }
}
var obj2 = {
    name: '行星飞行'
};

obj1.fn1();           //?  听风是风
obj1.fn1.call(obj2);  //?  行星飞行

obj1.fn2();           //?  undefined
obj1.fn2.call(obj2);  //?  undefined

obj1.fn3()();            //?  undefined
obj1.fn3().call(obj2);   //?  行星飞行
obj1.fn3.call(obj2)();   //?  undefined

obj1.fn4()();           //?  听风是风
obj1.fn4().call(obj2);  //?  听风是风
obj1.fn4.call(obj2)();  //?  行星飞行
/*非严格模式*/
var name = 'window'

function Person(name) {
  this.name = name;
  this.fn1 = function () {
    console.log(this.name);
  };
  this.fn2 = () => console.log(this.name);
  this.fn3 = function () {
    return function () {
      console.log(this.name)
    };
  };
  this.fn4 = function () {
    return () => console.log(this.name);
  }; 
};

var obj1 = new Person('听风是风');
console.dir(obj1);
var obj2 = new Person('行星飞行');

obj1.fn1();            // 听风是风
obj1.fn1.call(obj2);   // 行星飞行

obj1.fn2();            // 听风是风
obj1.fn2.call(obj2);   // 听风是风

obj1.fn3()();           // 'window' 
obj1.fn3().call(obj2);  // 行星飞行
obj1.fn3.call(obj2)();  // 'window'

obj1.fn4()();           // 听风是风
obj1.fn4().call(obj2);  // 听风是风
obj1.fn4.call(obj2)();  // 行星飞行

延申:链式调用

class Parents {
    constructor(opts) {
        this.name = opts.name;
        this.age = opts.age;
    }
    getName() {
        console.log(this.name);
        // return this;
    }
    getAge() {
        console.log(this.age)
        // return this;
    }
}

const p1 = new Parents({ name: 'zhangsan', age: 18 })

p1.getName()
p1.getAge()
// p1.getName().getAge()

一般的函数调用和链式调用的区别:
链式调用完方法后,return this 返回当前调用方法的对象。 优点是:有助于简化代码的编写工作,让代码更加简洁、易读,同时也避免多次重复使用一个对象变量。

参考:
《你不知道的Javascript上卷》
www.cnblogs.com/echolun/p/1…
www.cnblogs.com/echolun/p/1…