[JS]带你掌握this指向

186 阅读6分钟

最近想重新系统地整理一下前端的知识,因此写了这个专栏。我会尽量写一些业务相关的小技巧和前端知识中的重点内容,核心思想。

前言

其实本来是没有打算专门写文章,作this相关的回顾的,但在逛博客的过程中发现好像this现在还是一个出现频率挺高的内容。所以今天我们就来花点时间,来回顾一下这方面的知识点。

this

个人觉得函数的 this 关键字在JavaScript知识中是一个很特殊的存在。不同于其他的知识点,我们总能从js的运行逻辑中解释如闭包,作用链之类的内容。但对于this,查阅大多数资料之后,好像也没有发现有人可以从底层很清晰地解释this的机制。幸好他的执行规律并不复杂,只要记住就可以应对this的各种情况了。

this指的是什么?

首先我们要明确的是,this只存在执行上下文的作用域中,也就是说this无论如此都只是一个指针变量。那么问题来了,this究竟指向什么?

区别于作用域链,this 的指向是在函数执行的时候才定下来的。也就是说,不同的执行方式,this 的指向不一样。

全局执行上下文

console.log(this)
// 指向的是window

函数上下文

var func = function (){
	console.log(this);
}
func()
// 指向window

方法(对象属性)执行

var obj = {
  name:"john",
  say:function(){
  	console.log(this.name)
  }
}
obj.say();
// 指向obj对象

方法直接调用

如果我们把方法单独拿出来调用,本质其实是函数上下文调用(直接调用存在堆中的函数)

var obj = {
  name:"john",
  say:function(){
    console.log(this);
  }
};
var say = obj.say;
say();
// 指向window

构造函数

根据著名面试题——【new 一个函数时发生了什么】?我们可以知道构造函数的this指向的是new出来的对象。

function Cat(name){
  this.name = name;
  console.log(this);
}

var cat = new Cat('kitty');
// 指向新的实例对象

ES6箭头函数

为了解决this指向不明确,从而带来的开发问题。ES6为我们提供了一个新的处理方案——【箭头函数】。因此箭头函数非常特别,它上下文的this是在箭头函数定义的时候就定好的,不会在执行的时候改变。箭头函数的this继承于上一级的定义时,上一级的上下文的this。

var func = ()=>{
	console.log(this);
}

func()
// 指向全局上下文的this————window

/****************************************************/

var obj = {
  name:"john",
  say:function(){
  	console.log(this)
  }
}

obj.say();
var say = obj.say;
say();
// 指向window
// 还是指向window

/****************************************************/
var func = null;
function Cat(name){
  this.name = name;
  func = ()=>{
  	console.log(this);
  }
}

var cat = new Cat('kitty');
func();
// 指向 cat实例

主动修改this指向

当然,如果想要修改this的指向,JavaScript还给我们提供了3种方式——call,apply,bind

call

call的用法很简单只要把想要this指向的内容作为call函数的第一个参数传过去,其余的操作与直接指向函数一致即可。如果想要往原函数传参,只要在call的第二个参数开始,正常传递即可。

var func = function(a,b,c){
	console.log(this);
  console.log(a,b,c);
}
func(1,2,3);
// window
// 1,2,3

var obj = {
	name:'john'
}
// 第一个参数是this 的指向,第二个开始是原函数的入参。
func.call(obj,1,2,3);
// obj
// 1,2,3

apply

apply的用法跟call基本一致,只是入参的方式不同,apply往原函数传参时,需要用数组的方式传递。

var func = function(a,b,c){
	console.log(this);
  console.log(a,b,c);
}
func(1,2,3);
// window
// 1,2,3

var obj = {
	name:'john'
}
// 第一个参数是this 的指向,第二个参数是一个数组,js会把数组的内容拆开当成原函数的入参。
func.apply(obj,[1,2,3]);
// obj
// 1,2,3

bind

bind的用法跟call一致,不同点是bind执行之后,原函数并不会立即执行,而是返回一个新的函数。之后只要执行这个新函数,this的指向就会被固定为我们指定的内容。

var func = function(a,b,c){
	console.log(this);
  console.log(a,b,c);
}
func(1,2,3);
// window
// 1,2,3

var obj = {
	name:'john'
}
// 第一个参数是this 的指向,第二个开始是原函数的入参。
var newfunc = func.bind(obj,1,2,3);
// 没有输出

newfunc();
// obj
// 1,2,3

var obj2 = {
	name:'tom',
  func:newfunc
}
// 就算是用对象属性的形式执行,this还是我们指定的内容
obj2.func();
// obj

手写call

在搞清楚call,apply,bind的使用之后,我们就可以尝试自己手写了。

思路:

  1. 因为call是函数的方法,我们可以写在函数原型Function.prototype上。
  2. 判断入参。
  3. 利用对象属性方法执行,this会指向该对象的规则执行原函数。
  4. 原函数执行的结果返回。
// 因为是函数的方法,我们可以挂在函数的原型上
Function.prototype.call = function call(context, ...params) {
    // 如果传入的this指向是null,我们就默认当作是window
    if (context == null) context = window;
    // 判断如果进来的参数不是对象或者类,我们就强制转成对象
    if (!/^(object|function)$/i.test(typeof context)) context = Object(context);
  	// 创一个独一无二的key,防止污染其他内容。
    let key = Symbol();
    let  result;
  	// 把原函数挂载对象方法中。
  	// 这里的this实际上是原函数,因为原函数执行call的时候是 func.call()执行的。
    context[key] = this;
  	// 执行原函数,传参
    result = context[key](...params);
  	// 把我们添加的key删除
    context[key] = undefined;
    return result;
};

手写bind

成功写出call之后就会发现其实bind只是在call的基础上再套一层。

思路:

  1. 直接利用闭包,把调用bind的时候传入的this保存。
  2. 返回一个新的函数,新函数执行时,把保存的this传过去。
Function.prototype.bind = function bind(context, ...params) {
    // 如果传入的this指向是null,我们就默认当作是window
    if (context == null) context = window;
    // 判断如果进来的参数不是对象或者类,我们就强制转成对象
    if (!/^(object|function)$/i.test(typeof context)) context = Object(context);  
  
  	// 这里的this实际上是原函数,因为原函数执行call的时候是 func.bind()执行的。
    let self = this;
    return function (...args) {
      	// 这里我们直接用了call,如果面试时要求不能用call,就把上面的call手写一遍。
      	// bind函数在调用新函数时,入参要实现柯里化
        return self.call(context, ...params.concat(args));
    };
};

练习题

好接下我们做2道面试题,检验一下掌握的成果。

题目1

function fn1(){console.log(1);}
function fn2(){console.log(2);}
fn1.call(fn2);
fn1.call.call(fn2);

上述题目中输出的2个内容是什么?

// 答案是
// 1
// 2 

第一题很好理解,因为fn2只是修改了this指向。真正执行的是fn1。

第二题可能会有点绕,fn1.call.cal 实际上原函数是fn1.call 也就是call函数本身。我们在手写call的时候知道,call函数中最后执行的其实是上下文的this。而这里我们把this执行了fn2。所以最后的效果变成了执行fn2。可以参考下方代码:

Function.prototype.call(fn1);
// 不输出
Function.prototype.call.call(fn1);
// 1 执行了fn1

题目2

稍微加点内容,尝试第二题。

var name = 'hello world';
function A(x,y){
    var res=x+y;
    console.log(res,this.name);
}

function B(x,y){
    var res=x-y;
    console.log(res,this.name);
}
B.call(A,40,30);
B.call.call.call(A,20,10);

上述题目中输出的2个内容是什么?

// 答案是
// 10 'A'
// NaN undefined

同样第一题很好理解执行的是B把this指向A。传入40 30,40-30 res等于10。函数属性name就是函数的名字。所以this.name 就是字符串'A'。

第二题其实也跟刚刚的一样,最后执行的其实是A函数,20会被call认为是需要修改的this指向。10是参数x,y没有传就是undefined。10+undefined ,undefined隐式转为NaN,10+ NaN等于NaN。同时this.name 由于数字20没有name属性。所以输出undefined。

第二题额外的考察点是函数的name属性,以及undefined隐式转数字时会转为NaN。(null会转为0)

总结

今天我们回顾了this的知识点,this的指向并不会延申很多,只要记住他的规律就行。另外我们还手写了实现了call与bind。其实this除了面试题之外,在我们的日常开发中出现的频率还是挺高的,掌握这方面的知识,可以在开发遇到问题时,快速排除。当然当今开发还是建议用箭头函数,毕竟可以不用考虑调用时this指向的问题。

参考

developer.mozilla.org/zh-CN/docs/…