也谈 JavaScript 的 this
最近颇有些不宁静,时代的风吹得太猛烈,想到这一年已经过去四分之一,也差不多要准备未来的事情了。关于 this 的文章网上不胜枚举,这篇也无非是拾人牙慧,权当给自己留份资料,也希望能帮助到他人。从原理到用法到面试题,洋洋洒洒一万多字,基本上是够用了。
从一道面试题说起
这是一位京东小姐姐出的面试题, 不出意外这道题代表了大厂对于 this 的考察要求. 浏览器执行下面的代码, 会输出什么呢?
var number = 5; var obj = { number: 3, fn: (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 fn = obj.fn; fn.call(null); obj.fn(); console.log(window.number);
什么是 this
this 是 JavaScript 中的一个关键字. 它一般用于函数体内, 依赖函数调用时的上下文条件.
所以 this 是在运行时绑定的, 而非在编写代码的过程. 随着函数使用场合的不同, this 的指向也会发生变化. 但是有一个总的原则: 那就是 this 总会指向调用函数的那个对象.
*本文只考虑浏览器非严格模式环境.
为什么会有 this
JavaScript 之所以有 this , 跟内存的数据结构有关, 这里直接引用阮一峰老师的例子.
const obj = { foo: 5 };
上述代码将一个对象赋值给变量 obj, JS 引擎会在内存里生成一个对象 { foo: 5 }
, 然后把这个对象的内存地址赋值给变量 obj. 因此, 变量 obj 只是个地址(指针), 如果要读取 obj.foo
, 则需要 JavaScript 引擎从 obj
中拿到内存地址, 然后再从该地址读出原始的对象. 我们知道对象里的每个属性都对应着一个属性描述符对象, 因此上面的 foo 就是以下图方式保存的.
关于属性描述符可以参照我的一篇文章 Object.defineProperty() | JavaScript API 全解析.
但当属性的属性值是一个函数时, 将不会按照上述方式来保存了.
const obj = { foo: function() {} };
JavaScript 引擎会将函数单独保存到内存中, 然后将函数的内存地址赋值给 foo
属性的 value
属性, 这也就说明了函数在内存中是个独立的个体, 调用它的方式不同, 结果也会不同.
this 的绑定规则
this 的绑定规则有四种, 分别是:
- 默认绑定
- 隐式绑定
- 显式绑定
- new 绑定
默认绑定
默认绑定是在不使用其他绑定规则时的规则, 通常是独立函数的调用.
function greeting() {
console.log(`Hello, ${this.name}`);
}
var name = 'Yancey';
greeting(); // Hello, Yancey
隐式绑定
隐式绑定指的是在一个对象上调用函数.
在此示例中, 通过 obj 调用 greeting 方法, this 就指向了 obj
function greeting() {
console.log(`Hello, ${this.name}`);
}
var name = 'Sayaka';
var obj = {
name: 'Yancey',
greeting,
};
obj.greeting(); // Hello, Yancey
下面的代码中, 将 obj.greeting 赋给了一个全局的变量 otherGreeting
, 所以在执行 otherGreeting
时, this
会指向 window
.
function greeting() { console.log(`Hello, ${this.name}`); } var name = 'Sayaka'; var obj = { name: 'Yancey', greeting, }; var otherGreeting = obj.greeting; otherGreeting(); // Hello, Sayaka
如果涉及到回调函数(异步操作), 就要小心隐式绑定的丢失问题. 看下面这个例子.
第一次调用, 因为涉及到异步, 所以 this 指向了 window, 此时 this.name 就是 Mitsuha
第二次调用, 可以理解为将 obj2.greeting
赋值给一个新的变量, 所以此时 this 也是指向了 window
第三次调用则是隐式绑定, 此时 this 指向 obj2
function greeting() {
console.log(`Hello, ${this.name}`);
}
var obj1 = {
name: 'Yancey',
greeting() {
setTimeout(function() {
console.log(`Hello, ${this.name}`);
});
},
};
var obj2 = {
name: 'Sayaka',
greeting,
};
var name = 'Mitsuha';
obj1.greeting(); // Hello, Mitsuha
setTimeout(obj2.greeting, 100); // Hello, Mitsuha
setTimeout(function() {
obj2.greeting(); // Hello, Sayaka
}, 200);
显式绑定
显示绑定就是通过 call, apply, bind 来显式地指定 this 的绑定对象. 三者的第一个参数都是传递 this 指向的对象
, call 与 apply 的区别是前者从第二个参数起传递一个参数序列, 后者传递一个数组, call, apply 和 bind 的区别是前两个都会立即执行对应的函数, 而 bind 方法不会.
所以我们通过 call 显式绑定 this 指向的对象来解决隐式绑定丢失的问题.
function greeting() {
console.log(`Hello, ${this.name}`);
}
var obj = {
name: 'Sayaka',
greeting,
};
var name = 'Mitsuha';
var otherGreeting = obj.greeting;
// 强制将 this 绑定到 obj
otherGreeting.call(obj); // Hello, Sayaka
setTimeout(obj.greeting.call(obj), 100); // Hello, Sayaka
但是显式绑定不一定保证能完全解决隐式绑定丢失的问题. 下面这个例子中, 虽然将 this 显式的指向了 obj, 但在执行 fn() 时, 相当于将 obj.greeting
赋值给了 fn, 所以此时又发生了隐式绑定丢失.
function greeting() {
console.log('Hello,', this.name);
}
var obj = {
name: 'Yancey',
greeting,
};
var name = 'Sayaka';
var otherGreeting = function(fn) {
fn();
};
otherGreeting.call(obj, obj.greeting); // Hello, Sayaka
// 我们可以直接传递函数的调用给 fn
otherGreeting.call(obj, obj.greeting()); // Hello, Yancey
除了直接传递函数的调用, 我们也可以给 fn() 也加上显式绑定, 看下面这个例子. 因为 otherGreeting 的 this 指向了 obj, 在调用时, fn.call(this);
等价于 obj.greeting.call(this)
, 显然此时 this 指向的就是 obj.
function greeting() {
console.log('Hello,', this.name);
}
var obj = {
name: 'Yancey',
greeting,
};
var name = 'Sayaka';
var otherGreeting = function(fn) {
fn.call(this);
};
otherGreeting.call(obj, obj.greeting); // Hello, Yancey
在使用显式绑定时, 如果将 null, undefined 作为第一个参数传入 call, apply 或者 bind, 实际应用的是默认绑定.
function greeting() {
console.log('Hello,', this.name);
}
var obj = {
name: 'Yancey',
greeting,
};
var name = 'Sayaka';
var otherGreeting = obj.greeting;
// this 仍然指向 window
otherGreeting.call(null); // Hello, Sayaka
最后看一个例子, foo 函数显式绑定到 bar 对象上, 会导致 bar 的 myName 的值发生变化.
let bar = { myName : "A", test1 : 1 } function foo(){ this.myName = "B" } foo.call(bar) console.log(bar) // { myName: 'B', test1: 1 }
new 绑定
首先来回忆一下 new 做了什么:
- 首先创建一个空对象
- 将构造函数的 prototype 赋值给此对象的 __proto__
- 将构造函数的 this 指向此对象
- 返回此对象
function pollfillNew(Con, ...args) {
let obj = {};
Object.setPrototypeOf(obj, Con.prototype);
let result = Con.apply(obj, args);
return result instanceof Object ? result : obj;
}
在使用 new 创建实例时, 示例就会绑定到这个构造函数的 this.
function Dog(name) { this.name = name; } const husky = new Dog('Lolita'); husky.name; // Lolita
绑定优先级
new 绑定 > 显式绑定 > 隐式绑定 > 默认绑定
箭头函数
箭头函数的使用不必多说, 这里只贴一下其注意事项.
-
函数体内的 this 对象, 继承的是外层代码块的 this.
-
不可以当作构造函数, 也就是说, 不可以使用 new 命令, 否则会抛出一个错误.
-
不可以使用 arguments 对象, 该对象在函数体内不存在. 如果要用, 可以用 rest 参数代替.
-
不可以使用 yield 命令, 因此箭头函数不能用作 Generator 函数.
-
箭头函数没有自己的 this, 因此不能使用 call(), apply(), bind()等方法改变 this 的指向.
var obj = {
hi: function() {
console.log(this);
return () => {
console.log(this);
};
},
sayHi: function() {
return function() {
console.log(this);
return () => {
console.log(this);
};
};
},
say: () => {
console.log(this);
},
};
let hi = obj.hi(); // 输出 obj 对象
hi(); // 输出 obj 对象
let sayHi = obj.sayHi();
let fun1 = sayHi(); // 输出 window
fun1(); // 输出 window
obj.say(); // 输出 window
- 第一步是隐式绑定, 此时 this 指向 obj, 所以打印出 obj 对象
- 第二步执行 hi() 方法, 虽然看着像闭包, 但这是一个箭头函数, 它会继承上一层的 this, 也就是 obj, 所以打印出 obj 对象
- 因为 obj.sayHi() 返回一个闭包, 所以 this 指向 window, 因此打印出 window 对象
- 同样箭头函数继承上一层的 this, 所以 this 指向 window, 因此打印出 window 对象
- 最后一次输出, 因为 obj 中不存在 this, 因此按作用域链找到全局的 this, 也就是 window, 所以打印出 window 对象
我们可以用箭头函数来解决上文的一个问题, 这里虽然 setTimeout 会将 this 指向全局, 但箭头函数继承上一层的 this, 也就是 obj.greeting() 的 this, 因为这是一个隐式绑定, 所以 this 指向 obj, 所以箭头函数的 this 也会指向 obj.
function greeting() {
console.log(`Hello, ${this.name}`);
}
var obj = {
name: 'Yancey',
greeting() {
setTimeout(() => {
console.log(`Hello, ${this.name}`);
});
},
};
var name = 'Sayaka';
obj.greeting(); // Hello, Yancey
面试题解析
我们逐句分析一下开篇那道面试题.
因为 obj.fn
是一个立即执行函数(this 会指向 window), 所以在 obj 创建时就会执行一次, 并返回闭包函数.
var number; // 创建了一个私有变量 number 但未赋初值 this.number *= 2; // this.number 指向的是全局那个 number, 所以 window.number = 10 number = number * 2; // 因为私有变量 number 未赋初值, 所以乘以 2 会变为 NaN number = 3; // 此时私有变量 number 变为 3
接着执行下面两句:
var fn = obj.fn; fn.call(null);
因为将 obj.fn
赋值给一个全局变量 fn, 所以此时 this 指向 window. 接着, 当 call 的第一个参数是 null 或者 undefined 时, 调用的是默认绑定, 因此 this 仍然指向 window.
var num = this.number; // 因为 window.number = 10, 所以 num 也就是 10 this.number *= 2; // window.number 变成了 20 console.log(num); // 打印出 10 number *= 3; // 因为是闭包函数, 有权访问父函数的私有变量, 所以此时 number 为 9 console.log(number); // 打印出 9
当执行 obj.fn();
时, 此时的 this 指向的是 obj:
var num = this.number; // 因为 obj.number = 3, 所以 num 也就为 3 this.number *= 2; // obj.number 变为 6 console.log(num); // 打印出 3 number *= 3; // 上一轮私有变量为变成了 9, 所以这里变成 27 console.log(number); // 打印出 27
最后打印出 window.number
就是 20
// 最终结果: 10 9 3 27 20
再来两个例子练练手
var length = 10;
function fn() {
console.log(this.length);
}
var obj = {
length: 5,
method: function(fn) {
fn();
arguments[0]();
},
};
obj.method(fn, 1);
传入了 fn 而非 fn(), 相当于把 fn 函数赋值给 method 里的 fn 执行, 所以这里是默认绑定, 此时 this 指向 window, 所以执行 fn() 时会打印出 10
arguments[0](), 就相当于执行 fn(), 所以是隐式绑定, 此时 this 指向 arguments, 所以 this.length
就相当于 arguments.length
, 因为我们传递了两个参数, 因此返回 2
window.val = 1; var obj = { val: 2, dbl: function() { this.val *= 2; val *= 2; console.log('val:', val); console.log('this.val:', this.val); }, }; obj.dbl(); var func = obj.dbl; func();
第一次调用是隐式调用, 因此 this 指向 obj, 所以 this.val 也就是 obj.val 变成了 4, 但是 dbl 方法中没有定义 val, 所以会沿着作用域链找到 window.val, 所以会依次打印出 2, 4
第二次是默认调用, this 指向 window, window.val 会经历两次乘 2 变成 8, 所以会依次打印出 8, 8
this 设计的缺陷
看下面这个例子, 第一个 console 是一个默认绑定, 没什么好说的, 但里面 bar 函数的 this 居然是 window. 这是因为对于独立调用的函数, 如果未进行有效的 this 绑定的话, this 就会绑定到 window 对象(非严格模式)或者 undefined(严格模式).
var myObj = { name: "极客时间", showThis: function() { console.log(this); function bar() { console.log(this); } bar(); } } myObj.showThis();
有两种方式可以解决, 第一种使用 self 大法来解决.
var myObj = {
name: "极客时间",
showThis: function() {
console.log(this);
const self = this;
function bar() {
console.log(self);
}
bar();
}
}
myObj.showThis();
第二种方法就是把内部函数卷成一个箭头函数. 上面也说到了, ES6 中的箭头函数并不会创建其自身的执行上下文, 所以箭头函数中的 this 取决于它的外部函数.
var myObj = { name: "极客时间", showThis: function() { console.log(this); const bar = () => { console.log(this); } bar(); } } myObj.showThis();
总结
- 函数是否在 new 中调用(new 绑定), 如果是, 那么 this 绑定的是新创建的对象.
- 函数是否通过 call, apply 调用, 或者使用了 bind(即显式绑定), 如果是, 那么 this 绑定的就是指定的对象.
- 函数是否在某个上下文对象中调用(隐式绑定), 如果是的话, this 绑定的是那个上下文对象. 一般是 obj.foo().
- 如果以上都不是, 那么使用默认绑定. 如果在严格模式下, 则绑定到 undefined, 否则绑定到全局对象.
- 如果把 Null 或者 undefined 作为 this 的绑定对象传入 call, apply 或者 bind, 这些值在调用时会被忽略, 实际应用的是默认绑定规则.
- 如果是箭头函数, 箭头函数的 this 继承的是外层代码块的 this; 而普通嵌套函数中的 this 不会继承外层函数的 this 值.
参考
PREVIOUS POST
[HTTP 系列] 第 2 篇 —— HTTP 协议那些事
NEXT POST
从 JavaScript 编译原理到作用域(链)及闭包