引言
JS中的this指向灵活,这属于JS语言中的特性。传统的面向对象语言规定this只出现在类中,而JS中没有类,JS设计者便将this放在独立函数中,这样全局函数、对象中的函数、构造函数等都可以调用this,这过于自由导致了混乱。
this指向混乱是公认问题,搞不明白this指向,无论是开发时还是阅读源码中,都比较吃力,基于此本文对this相关知识进行了梳理,如果你对this的了解不是那么清晰,可以通过本文来复习一下。
1 this是什么
当一个函数被调用时,会创建一个执行上下文,其中this就是执行上下文的一个属性,this是函数在调用时JS引擎向函数内部传递的一个隐含参数。
Js基于词法作用域,我们把函数写在哪,它就活在哪个位置。而函数调用是动态的,可以在任意地方调用,JS中在函数体内定义了this关键字来获取当前调用环境。
2 this指向类型
this指向完全是由它的调用位置决定,而不是声明位置。除箭头函数外,this指向最后调用它的那个对象,因此我们第一步是找到函数调用位置。
// 声明位置
var obj = {
name: '张三',v
sayHi: function() {
console.log('你好我是' + this.name)
}
}
// 调用位置
obj.sayHi() //你好我是张三
this 在函数中指向有以下几种场景:
- 全局作用域中;
- 普通函数调用;
- 对象方法使用;
- 构造函数调用;
- 匿名函数;
- 计时器;
- 事件绑定方法;
- 箭头函数;
下面我们分别来讨论一下这些场景中 this 的指向。
2.1 默认指向
全局作用域内,this指向window:
console.log(this === window); //true
var name='张三';
console.log(this.name); //张三
普通函数调用,this指向window:
function showThis() {
console.log(this)
}
showThis() // window
我们来做个小实战,这段代码输出啥呢?
var name: '李四',
var obj = {
name: '张三',
sayHi: function() {
console.log('你好我是' + this.name)
}
}
var name = obj.sayHi();
name(); // 李四
上面这段代码,将name挂载全局对象上,name()也就是等价于window.name(),所以this指向的是全局中的name,所以输出全局name。
再做一个小实战,输出啥?
var obj = {
name: '张三',
sayHi: function() {
console.log(`你好,我是${this.name}`)
}
}
var obj2 = {
name: '李四',
sayHi: function() {
var targetFunc = obj.sayHi
targetFunc()
}
}
var name = '王五'
obj2.sayHi() // 王五
在这段代码中,Obj2的sayHi方法调用, targetFunc函数执行,虽然targetFunc这个函数是在obj2函数中sayHi方法中,但是在targetFunc最后调用时,targetFunc是作为一个普通函数调用,进而targetFunc中this指向的是window对象。
如果开启了严格模式,全局代码中的 this 也指向window:
'use strict'
console.log(this) // window
而普通函数中的this在严格模式下则会指向undefined:
'use strict'
function showThis() {
console.log(this)
}
showThis() // undefined
这里需要特别注意,像这样处于全局代码中的 this, 不管它是否处于严格模式下,它的 this 都指向 Window,只有普通函数中的this才会指向undefined。
2.2 对象方法
对象方法调用,this指向该方法所属对象。
var obj = {
name:'张三',
sayHi:function(){
console.log('你好我是'+this.name)
}
}
var obj2 = {
name:'李四',
sayHi:obj.sayHi
}
obj.sayHi();
obj2.sayHi();
上面这段代码中sayHi这个函数分别被obj和obj2调用,因此,两次调用的this也就分别指向了obj1和obj2,输出结果如下:
2.3 构造函数
构造函数形式调用,this指向实例对象。
function Obj(){
this.name = '李四';
}
var result = new Obj();
console.log(result.name);
上面这段代码中,使用new来调用obj()时,Js引擎会在底层创建一个result对象,并把result当做this。因此,输出结果如下:
特别注意的是,如果在构造函数中有return,会有两种情景:
function Obj(){
this.name = '李四';
const obj = {};
return obj;
}
var result = new Obj();
console.log(result.name);
此时将会输出 undefined,此时 result 返回的是空对象 obj。
function Obj(){
this.name = '李四';
return 1;
}
var result = new Obj();
console.log(result.name);
将会输出李四,result仍然指向目标对象实例。
总结:构造函数中this一般情况下指向实例对象,如果构造函数中有return,return一个对象,此时this就指向返回的这个对象,如果return不是一个对象,this仍然指向实例。
2.4 匿名函数
匿名函数在函数声明时调用,匿名函数中的this指向window。
var name = '王五'
var obj = {
name: '张三',
sayHi: function () {
console.log('你好我是' + this.name)
},
sayHello: function () {
(function (cb) {
// 调用位置
cb()
})(this.sayHi)
}
}
obj.sayHello();
上面这段代码中,输入结果如下,我们不难发现输出的是window.name。
2.5 计时器
计时器有setTimeout和setInterval,这两种计时器指向机制是一样的,都是指向window。
var name = '王五'
var obj = {
name: 'xiuyan',
sayHi: function() {
setTimeout(function() {
console.log('你好我是'+this.name)
})
}
}
obj.sayHi() // 你好,王五
上面这段代码中,不难发现输出的是全局的name。在计时器中里面传入的函数,指向全局window。
2.6 事件方法
事件方法调用,this指向事件源。
<body>
<button id="btn">点我</button>
</body>
<script>
var btn = document.getElementById('btn');
btn.onclick = function () {
console.log(this);
}
</script>
上面这段代码,输出结果如下:
2.7 箭头函数
箭头函数没有自己的this,箭头函数的this在书写阶段就绑定到他的父级作用域this上。无论我们后续如何调用它,他都无法再次指定新的目标对象,箭头函数的this指向是静态的,一次便是一生。
箭头函数this指向包裹箭头函数的第一个普通函数。
var name = '张三'
var obj = {
name: '李四',
sayHi: () => {
console.log('你好我是'+ this.name)
}
}
me.sayHi()
上面这段代码,this在书写的时候,他所在作用域时全局的,所以this指向的是window,输出结果如下:
我们通过一道综合案例实战,问:以下函数调用的输出结果是什么?
var a = 1
var obj = {
a: 2,
func2: () => {
console.log(this.a)
},
func3: function() {
console.log(this.a)
}
}
// func1
var func1 = () => {
console.log(this.a)
}
// func2
var func2 = obj.func2
// func3
var func3 = obj.func3
func1()
func2()
func3()
obj.func2()
obj.func3()
输出结果是:1、1、1、1、2 你做对了吗?
3 改变指向
以上我们不难发现,this严格遵循调用者走(除箭头函数指向执行上下文),这实在太被动了,我们想让this按照我们的想法指定对象,这就是我们接下来说的call、apply、bind。
3.1 call、apply、bind
call、apply和bind,都是用来改变this指向的,三者是属于大写 Function原型上的方法,只要是函数都可以使用。
call和apply的区别,体现在对入参的要求不同,call的实参是一个一个传递,apply的实参需要封装到一个数组中传递。
// thisArg是this指向的对象
// arg1,arg2是func需要的参数
func.call(thisArg,arg1,arg2,arg3)
func.apply(thisArg,[arg1,arg2,arg3])
call、apply和bind之间的区别,前者在于在改变this指向的同时,会把目标函数执行,后者则只负责改造this,不执行函数,所以bind 在调用的时候,需要变量接收,接收后在调用。我们通过下面这个案例,来看一下三者的基本使用:
function f1(a, b) {
console.log(this);
console.log(a+b);
}
var obj = {
name: '张三',
}
f1.call(obj, 100, 200);
// {name: "张三"} 300
f1.apply(obj, [100, 200])
// {name: "张三"} 300
var ff = f1.bind(obj);
ff()
// {name: "张三"} 300
如果call的第一个参数传null,代表直接执行这个函数:
function f1(a,b){
console.log(this);
console.log(a+b);
}
var obj = {
name:'张三'
}
f1.call(null,100,200);
// window 300
3.2 应用场景
使用call最典型的应用就是实现继承, 在调用父构造函数同时,把父构造函数this指向子构造函数中this,字构造函数就可以使用name/age:
function Father(name, age) {
this.name = name;
this.age = age;
}
function Son(name, age) {
// 这个时候的Father中的this已经被Son所代替
Father.call(this, name, age);
console.log(this);
// Son {name: "张三", age: 18}
}
var son = new Son('张三', 18);
console.log(son);
使用apply可以求数组中最大值:
// Math.max用法
var max = Math.max(5,10);
console.log(max);
// 求最大值
var arr = [1,5,4,3,8];
max = Math.max.apply(Math,arr);
console.log(max)
bind方法不会立即调动该函数,在三个函数中应用最为广泛,最常见的是:我们有一个按钮,当我们点击了之后,就会禁用这个按钮,3s后在开启按钮。
传统的方案是声明一个新的变量把this缓存下来:
<body>
<button>点我</button>
<button>点我</button>
<button>点我</button>
<script>
var btns = document.getElementsByTagName('button');
for (var i = 0; i < btns.length; i++) {
btns[i].onclick = function () {
// 使用_this缓存this
var _this = this
this.disabled = true;
setTimeout(function () {
_this.disabled = false;
}, 3000)
}
}
</script>
</body>
我们也可以使用bind实现,在计时器外面绑定bind方法,此时bind中的this是在计时器的外面,在onclick方法的里面,所以指向是btn。
<body>
<button>点我</button>
<button>点我</button>
<button>点我</button>
<script>
var btns = document.getElementsByTagName('button');
for (var i = 0; i < btns.length; i++) {
btns[i].onclick = function () {
this.disabled = true;
setTimeout(function () {
this.disabled = false;
// 在计时器外部绑定this
}.bind(this), 3000)
}
}
</script>
</body>
3.3 自定义call
在实现 call 方法之前,我们先来看一个 call 的调用示范:
var me = {
name: '张三'
}
function showName() {
console.log(this.name)
}
showName.call(me) // 张三
前面我们说过call方法是大写Function中方法,所有的函数都可以继承使用,所以我们自定义call 方法应该定义在 Function.prototype上,这里我们定义一个myCall。
Function.prototype.myCall=function(){
}
我们想,如果用myCall方法进行绑定,就相当于在传入的对象(这里是me)里面添加了一个原本的函数,然后在使用对象.函数调用,也就是:
var me ={
name :'张三',
person:function(){
console.log(this.name)
}
}
me.person()
根据这个思路,我们往原型对象中添加内容:
// context:我们传入的对象
Function.prototype.newCall = function(context){
// person.newcall调用,也就是函数.方法调用,JS中函数也是对象,所以对象方法调用,指向该方法所属对象,也就是person。
// 注意!这里的this是person,我们还没开始绑定呢
console.log(this)
// 1、我们为传入的对象添加属性
context.fnkey = this;
// 2、调用函数
context.fnkey();
// 3、执行完,方法删除,我们不能改写对象
delete context.fnkey
}
person.newCall(me)
当我们为形参变量添加属性时,此时的代码就如下,然后在调用这个函数,因为是对象方法调用所以this指向了me,也就是obj。
function person(){
console.log(this.name);
}
var me = {
name:'张三',
fnkey:function(){
console.log(this.name);
}
}
现在我们的mycall就实现了call的基本能力——改变this指向,第二步让我们的mycall具备读取函数入参能力,也就是读取call方法第二个到最后一个入参,这里我们用到ES6中的剩余参数...args。
剩余参数可以帮助我们将不定数量的入参变成数组,具体用法如下:
function readArr(...args) {
console.log(args)
}
readArr(1,2,3) // [1,2,3]
我们通过args这个数组拿到我们想要的入参,再把
args数组代表目标入参展开,传入目标方法,一个call方法就实现了。
Function.prototype.myCall = function(context, ...args) {
context.fnkey = this;
context.fnkey(...args);
delete context.fnkey;
}
以上,就实现了mycall的基本框架~~
但是上面的mycall还并不完善,比如说第一个参数传了null怎么办?是不是默认给他指到window或global上去;第一个参数不是对象怎么办?我们改如何保证为对象?如果context里面有这个属性怎么办?我们怎样保证属性的唯一性?
我们进行以下补充优化:
Function.prototype.myCall = function (context, ...args) {
// 补充1 如果第一个参数没传,默认指向window / Global
// globalThis浏览器环境中指window,node.js环境中指向global
if (context == null) context = globalThis
// 补充2:如果第一个参数传的值类型,数字类型,或者布尔类型
// 我们通过new Object 生成一个值类型对象,数字类型对象,布尔类型对象
if (typeof context !== 'objext') context = new Object(context)
// 补充3:防止传入对象作为属性,与context重名属性覆盖
// symbol类型不会出现属性名称覆盖
const fnkey = Symbol();
context[fnkey] = this
globalThis // window/global
console.log(new Object('哈哈'));// String {"哈哈"}
console.log(new Object(1)); // Number { 1 }
console.log(new Object(true)); //Boolean { true }
console.log(new Object(undefined));// {}
let symbol1 = Symbol(); //Symbol()
let symbol2 = Symbol(); //Symbol()
consoele.log(symbol1 === symbol2);//false
这样,我们就实现了完整mycall方法,使用mycall调用时,就相当于在传入的对象里面添加了一个原本的函数,这是实现mycall的核心,一定要理解。完整版mycall方法如下:
Function.prototype.myCall = function (context, ...args) {
// 补充1 如果第一个参数没传,默认指向window / Global
// globalThis浏览器环境中指window,node.js环境中指向global
if (context == null) context = globalThis
// 补充2:如果第一个参数传的值类型,数字类型,或者布尔类型
// 我们通过new Object 生成一个值类型对象,数字类型对象,布尔类型对象
if (typeof context !== 'objext') context = new Object(context)
// 补充3:防止传入对象作为属性,与context重名属性覆盖
// symbol类型不会出现属性名称覆盖
const fnkey = Symbol();
// step1: 给传入对象添加原函数(this就是我们要改造的原函数)
context[fnkey] = this
// step2: 执行函数,并传递参数
context[fnkey](...args)
// step3: 删除 step1 中挂到目标对象上的函数
delete context[fnkey].
}
// 测试如下:
function showFullName(secondName) {
console.log(`${this.name} ${secondName}`)
}
var me = {
name: '张三'
}
showFullName.myCall(me, '李四') // 张三 李四
showFullName.myCall(null, '李四') // 李四
showFullName.myCall(1, '李四') // undefined 李四
理解了call,那么实现apply和bind方法就小菜一碟了,apply方法关键在于更改参数的读取方式,bind方法关键在于延迟目标函数的执行时机。
3.4 自定义apply
Function.prototype.myCall = function (context, ...args) {
if (context == null) context = globalThis
if (typeof context !== 'objext') context = new Object(context)
const fnkey = Symbol();
context[fnkey] = this;
// 此时,传入的数组,不需要对数组进行拆包
context.fnkey(args);
delete context[fnkey];
}
// 测试如下:
function showFullName(secondName) {
console.log(`${this.name} ${secondName}`)
}
var me = {
name: '张三'
}
showFullName.myCall(me, ['李四','王五']) // 张三 李四 王五
3.5 自定义bind
前面我们说过,bind方法不会立即执行函数,实际上bind方法是返回了一个原函数的拷贝,函数体内的参数会和bind方法第一个以外的其他参数合并。
在实现 bind 方法之前,我们先来看一个 bind 的调用示范:
var me = {
value: 1
}
function person(name, age) {
return {
value: this.value,
name: name,
age: age
}
}
var bar = person.bind(me, '张三', 18);
console.log(bar);
// 这里将会输出person函数
console.log(bar());
// {value: 1, name: "张三", age: 18}
var bar2 = person.bind(me, '张三');
console.log(bar2(18));
// {value: 1, name: "张三", age: 18}
完整版myBind如下:
Function.prototype.myBind = function (context, ...args) {
// step1: 保存下当前 this(这里的 this 就是我们要改造的的那个函数)
const self = this;
// step2: 返回一个函数
// bind整体上会return一个函数,并还可以接受参数
return function (...argus) {
// step3: 拼接完整参数,将bind执行参数和函数调用时传入参数拼接
const fullArgs = args.concat(argus)
// step4: 调用函数
return self.apply(context,fullArgs)
}
}
// 测试如下:
function showFullName(secondName) {
console.log(`${this.name} ${secondName}`)
}
var me = {
name: '张三'
}
var result = showFullName.myBind(me, '李四')
result() // 张三 李四
4 优先级
前面讲解了this不同情境下指向规则,你需要做的就是找到调用位置,然后在判断调用方式。但是,有时候,在一个调用位置可能使用了多条规则,这里就需要判断规则的优先级。
function foo() {
console.log( this.a );
}
var obj1 = {
a: 2,
foo: foo
};
var obj2 = {
a: 3,
foo: foo
}
obj1.foo(); //2
obj1.foo.call( obj2 ); //3
从上边的代码可以看出来,call的优先级要高于对象方法调用,下边再看bind和new绑定的优先级:
function foo(a) {
this.a = a;
}
var obj = {}
var bar = foo.bind(obj);
bar(2);
console.log(obj.a); // 2
var baz = new bar(3)
console.log(baz.a) // 3
这段代码中,bar函数本身是通过bind方法构造的函数,其内部已经将this绑定为obj,它在作为构造函数,通过new调用时,返回的实例已经与obj解绑,也就是说明new绑定的优先级高于bind绑定。
综上,我们得出结论:
new > call/apply/bind > 对象方法 > 默认
5 总结
- this是什么:执行上下文的一个属性,在函数调用时,JS引擎向函数内部传递的一个隐含参数。
- 特别注意两点:一是要看函数最后的调用者;二是如果将一个变量挂载到全局中执行,也是普通函数调用的一种;
- 全局作用域中,无论是否严格模式都指向
window; - 普通函数调用,指向
window;严格模式下指向undefined; - 对象方法使用,该方法所属对象;
- 构造函数调用,指向实例化对象;
- 匿名函数中,指向
window; - 计时器中,指向
window; - 事件绑定方法,指向事件源;
- 箭头函数指向其上下文中
this; call、apply、bind都可以改变this指向,三者属于大写Function中的方法,任何函数都可以使用;apply相比call方法,入参需要传递一个数组;call、apply相比bind方法,函数不会执行,所以我们需要定义一个变量去接收执行;- 手写call:3个优化+3步骤;
- 手写apply:关键在于参数传递方式,不需要进行拆包;
- 手写bind:关键在于延迟目标函数的执行时机;
this指向优先级:new>call/apply/bind>对象方法>默认。
结语
本篇文章就到此为止啦,由于本人经验水平有限,难免会有纰漏,对此欢迎指正。如觉得本文对你有帮助的话,欢迎点赞❤❤❤,写作不易,持续输出的背后是无数个日夜的积累,您的点赞是持续写作的动力,感谢支持!