每个函数的 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,而不是报错。
隐式丢失
在特定情况下会存在隐式绑定丢失的问题,最常见的就是作为参数传递
以及变量赋值
。
- 作为参数传递
var name = '行星飞行';
let obj = {
name: '听风是风',
fn: function () {
console.log(this.name);
}
};
function fn1(param) {
param();
};
fn1(obj.fn); //行星飞行
将 obj.fn 也就是一个函数传递进 fn1 中执行,这里只是单纯传递了一个函数,this并没有跟函数绑在一起,所以this丢失,这里指向了window。参数传递其实就是一种隐式赋值。
- 变量赋值
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有什么区别?
-
call、apply 与 bind 都用于改变this绑定,但 call、apply 在改变this指向的
同时
还会执行函数
,而 bind 只是负责绑定 this 并返回一个新方法,不会执行。这也是例子中bind后还加了一对括号 ()的原因。 -
bind 属于硬绑定,返回的 boundFunction 的 this 指向无法再次通过bind、apply或 call 修改(但可以通过
new 绑定
修改);call与apply的绑定只适用当前调用,下次调用需要重新绑定。 -
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
调用类的构造函数
时会执行的操作阐述:
- 在内存中创建一个新对象。
- 这个新对象内部的 [[Prototype]] 指针被赋值为构造函数的 prototype 属性。
- 构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)。
- 执行构造函数内部的代码(给新对象添加属性)。
- 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。
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
可以根据优先级来判断函数在某个调用位置应用的是哪条规则。可以按照下面的顺序进行判断:
- 函数是否在 new 中调用(new绑定)?this 绑定的是
新创建的对象
。
var bar = new foo () - 函数是否通过 call、apply(显示绑定)或者硬绑定调用?this 绑定的是
指定的对象
。
var bar = foo.call (obj) - 函数是否在某个上下文对象中调用(隐式绑定)?this 绑定的是那个
上下文对象
。
var bar = obj1.foo () - 如果都不是,使用默认绑定。(函数体)严格模式下,this 绑定到 undefined,否则绑定到
全局对象
。
var bar = foo ()
4. 绑定例外
4.1 被忽略的 this
如果把 null
或 undefined
作为 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…