改变this指向的四种方式(模拟实现call、apply、bind、new)

2,043 阅读17分钟

call方法

call方法介绍

call() 方法使用一个指定的 this 值和单独给出的一个或多个参数来调用一个函数。

语法

function.call(thisArg, arg1, arg2, ...)

参数说明

thisArg可选的。在function函数运行时使用的this值。非严格模式下,指定为null或者undefined时会自动替换为指向全局对象。

arg1, arg2, ... 指定的参数列表

返回值

使用调用者提供的 this 值和参数调用该函数的返回值。若该方法没有返回值,则返回 undefined

OK,下面来看一下示例

示例

var hunter = {
  value: 1
}

function hunterFn () {
  console.log(this.value)
}

hunterFn.call(hunter) // 1

上面的示例总结起来就是:

  1. call()方法改变了this指向,指向了hunter
  2. hunterFn函数执行了。

模拟实现

通过上面示例的总结,如何将this指向改变

地球人都知道js中的方法,谁调用它,this就指向谁,通过这个特点,我们将上面的示例代码调整一下:

var hunter = {
  value: 1,
  hunterFn: function () {
    console.log(this.value)
  }
}
hunter.hunterFn() // 1

hunterFn方法添加为hunter对象中的一个属性,这样调用hunterFn方法时,由于是hunter对象调用的hunterFn,因此this指向了hunter。这一步非常easy。

这种方式有个很明显的缺点,改变this指向的时候,会平白无故的给hunter对象添加属性,这样搞肯定是不能滴,不过这个问题也很好解决,直接使用delete删除即可。

所以,模拟call方法的步骤我们可以分为以下3步:

  1. hunterFn方法添加为hunter对象的属性
  2. 执行该函数
  3. 删除该函数
// 第一步
hunter.func = hunterFn
// 第二步
hunter.func()
// 第三步
delete hunter.func

OK,根据上面的思路,我们自定义一个MyCall方法,代码如下:

Function.prototype.MyCall = function (obj) {
  var context = obj
  context.func = this // 第一步
  context.func() // 第二步
  delete context.func // 第三步
}

var hunter = {
  value: 1
}

function hunterFn() {
  console.log(this.value)
}

hunterFn.MyCall(hunter) // 1

上面这段代码做到了改变this指向,看起来是不是也很easy。

文章的开头介绍call方法的时候,call方法还能够接受参数,并且接受的是一个参数列表(arg1, arg2, ...)

接下来我们来搞定call方法接受参数这个问题

由于call方法接受的参数是一个参数列表,并且个数还不是固定的,因此我们可以利用下面这个知识点来解决这个难题。

arguments是一个对应于传递给函数的参数的类数组对象。arguments对象是所有(非箭头)函数都可用的局部变量,你可以使用arguments对象在函数中引用函数的参数。此对象包含传递给函数的每个参数,第一个参数在索引0处。

下面我们打印一下arguments对象中都有哪些内容

Function.prototype.MyCall = function (obj) {
  console.dir(arguments)
  // ...省略部分代码
}
// ...省略部分代码

hunterFn.MyCall(hunter, '森界降临', 27)

// 此时arguments对象内容为:
// arguments = {
//    0: {value: 1}  => func
//    1: '森界降临'
//    2: 27,
//    length: 3 
// }

通过上面的打印结果可以看出来,第0个参数为this指向的对象,后面的才是我们传进来的参数,因此我们可以从arguments对象中取出第二个到最后一个参数,放到一个数组中保存起来。由于arguments对象是一个类数组对象,我们可以使用for循环来搞定它。

// arguments = {
//    0: {value: 1}  => func
//    1: '森界降临'
//    2: 27,
//    length: 3 
// }
var args = []

for (var i = 1, len = arguments.length; i < len; i++) {
  args.push(arguments[1])
}

// args => ['森界降临', 27]

这里通过打印可以得到args得到了一个数组,但是数组不能作为参数传递给函数,因为call方法接受的不是一个数组,而是一个参数列表,那么如果使用Array.join字符拼接方法会不会解决呢,答案是No,因为数组通过join方法拼接后,它会变成一个参数,例如:传了1 2 3三个参数进去,通过join方法后,就会变成"1, 2, 3", 所以这种方法肯定是不行的了。

不过我们可以借助eval这个方法来搞定它,下面来看一下它的介绍

eval简介

eavl()函数会将传入的字符串当做 JavaScript 代码进行执行。

语法:

eval(string)

参数说明:

是一个表示 JavaScript 表达式、语句或一系列语句的字符串。表达式可以包含变量与已存在对象的属性。

返回值:

返回字符串中代码的返回值。如果返回值为空,则返回 undefined。

简单示例

function add(a, b) {
  console.log(a + b)
}

var arr = [1, 2];

add(1, 2) // 3

eval("add(" + arr + ")")  // 3

接下来我们调整一下我们刚刚实现的MyCall方法,代码如下:

Function.prototype.MyCall = function (obj) {
  var context = obj
  var args = []

  for (var i = 1, len = arguments.length; i < len; i++) {
    args.push(arguments[i])
  }

  context.func = this // 第一步

  eval('context.func('+ args + ')') // 第二步

  delete context.func // 第三步
}


var hunter = {
  value: 1
}

function hunterFn() {
  console.log(this.value)
}

hunterFn.MyCall(hunter, '森界降临', 27) // Uncaught ReferenceError: 森界降临 is not defined

运行上面这段代码,会发现并不如自己想的那样,运行结果报错了,是什么原因呢?

通过报错可以推断参数肯定是传进去了,那么我们打印一下args这个数组看一下, 打印结果如下:

// args => ["森界降临", 27]

通过打印的结果再结合上面简单介绍的eval函数的用法,问题不难看出,是因为在for循环的时候,args.push(arguments[i])这一步我们提前将字符串进行了解析,这就导致eval()方法在执行的时候,表达式变成了eval("context.func(森界降临, 27)"), 参数森界降临没有了引号,相当于变成了一个变量,而这个变量找遍了"全宇宙"也没有见到它声明过,因此会报错提示:森界降临 is not defined

搞明白了问题出在哪里之后,解决它就是手到擒来了,for循环中代码调整如下:

for (var i = 1, len = arguments.length; i < len; i++) {
  args.push('arguments[' + i + ']') 
}

这里我们可以直接使用字符串拼接,不提前解析,最终args打印出来的结果是这样的:["arguments[1]","arguments[2]"]

重新调整MyCall代码如下:

Function.prototype.MyCall = function (obj) {
  var context = obj
  var args = []

  for (var i = 1, len = arguments.length; i < len; i++) {
    args.push('arguments[' + i + ']') 
  }

  context.func = this // 第一步

  eval('context.func('+ args + ')') // 第二步

  delete context.func // 第三步
}


var hunter = {
  value: 1
}

function hunterFn() {
  console.log(this.value)
}

hunterFn.MyCall(hunter, '森界降临', 27) // 1

打印结果为1,不报错,到这里说明模拟call方法已经基本实现了,不过这并不是call方法的完整版,文章开头介绍call方法的时候,还有两点这段代码里没有实现,不过这都是小儿科了,分分钟搞定,剩下的两点如下:

  1. 指定为null或者undefined时会自动替换为指向全局对象
  2. 函数可以有返回值

上面这两个问题都很好解决,具体的我直接在代码注释里解释一下,我们直接上最终版的代码吧!

Function.prototype.MyCall = function (obj) {
  var context = obj || window // 问题一: 若obj不存在,则直接指向window
  var args = []

  for (var i = 1, len = arguments.length; i < len; i++) {
    args.push('arguments[' + i + ']') 
  }

  context.func = this 

  var result = eval('context.func('+ args + ')') // 保存执行结果

  delete context.func 

  return result // 问题二:返回func方法的返回值
}


var hunter = {
  value: 1
}

var value = 666

function hunterFn(name, age) {
  console.log(name, age, this.value)
}

hunterFn.MyCall(hunter, '森界降临', 27) // 森界降临 27 1
hunterFn.MyCall(null, '森界降临', 27) // 森界降临 27 666
hunterFn.MyCall(undefined, '森界降临', 27) // 森界降临 27 666

上面的代码量看起来稍微有点多,如果可以使用ES6来实现的话,会简洁些:

ES6方式模拟实现call方法

Function.prototype.MyCall = function (obj) {
  let context = obj || window
  let args = [...arguments].slice(1)  // 使用展开运算符处理arguments对象,转换为数组
  context.func = this 
  var result = context.func(...args)

  delete context.func 
  return result
}

到这里call方法的模拟就完成了,下面我们看一下apply方法的模拟实现。

apply方法

apply方法介绍

apply() 方法调用一个具有给定this值的函数,以及以一个数组(或类数组对象)的形式提供的参数。

语法

function.apply(thisArg, [argsArray])

参数说明

thisArg 必选的。在 function 函数运行时使用的 this 值。非严格模式下,指定为 null 或 undefined 时会自动替换为指向全局对象,原始值会被包装。

argsArray 可选的。一个数组或者类数组对象,其中的数组元素将作为单独的参数传给 function 函数。如果该参数的值为 null 或 undefined,则表示不需要传入任何参数。

返回值

调用有指定this值和参数的函数的结果。

通过上面的介绍,可以知道apply()方法与call()方法非常相似,不同之处在于提供参数的方式。下面我们来看一下apply()方法的示例:

示例

// 还是沿用上面最开始的示例代码,部分地方稍作改动
var hunter = {
  value: 1
}

function hunterFn (arg1, arg2) {
  console.log(arg1, arg2, this.value)
}

hunterFn.apply(hunter, ['森界降临', '27']) // 森界降临 27 1

其实理解了call方法的实现原理,那么实现模拟apply方法也很简单,这里就不再详细的一步一步讲解了,直接在MyCall方法的最终版代码里改动一下就可以了。代码如下:

模拟实现apply方法代码最终版

Function.prototype.MyApply = function (obj, arr) {
  var context = obj || window 
  var result;
  context.func = this 
  // 判断参数arr是否存在,不存在直接调用返回结果
  if (!arr) {
    result = context.func()
  } else {
    var args = []
    // 注意这里 索引是从0开始取的
    for (var i = 0, len = arr.length; i < len; i++) {
      args.push('arr[' + i + ']') 
    }
    result = eval('context.func('+ args + ')') // 保存执行结果
  }
  delete context.func 
  return result 
}


var hunter = {
  value: 1
}

var value = 666

function hunterFn(name, age) {
  console.log(name, age, this.value)
}

hunterFn.MyApply(hunter, ['森界降临', 27]) // 森界降临 27 1
hunterFn.MyApply(null, ['森界降临', 27]) // 森界降临 27 666
hunterFn.MyApply(undefined, ['森界降临', 27]) // 森界降临 27 666

ES6方式模拟实现apply方法

Function.prototype.MyApply = function (obj, arr) {
  let context = obj || window 
  let result;
  context.func = this 
  // 判断参数arr是否存在,不存在直接调用返回结果
  if (!arr) {
    result = context.func()
  } else {
    result = context.func(...arr)
  }
  delete context.func 
  return result 
}

接下来我们来看另一种改变this指向的方法,bind()方法

bind方法

bind方法介绍

bind() 方法创建一个新的函数,在 bind() 被调用时,这个新函数的 this 被指定为 bind() 的第一个参数,而其余参数将作为新函数的参数,供调用时使用。

语法

function.bind(thisArg[, arg1[, arg2[, ...]]])

参数说明

thisArg 调用绑定函数时作为 this 参数传递给目标函数的值。 如果使用new运算符构造绑定函数,则忽略该值。当使用 bind 在 setTimeout 中创建一个函数(作为回调提供)时,作为 thisArg 传递的任何原始值都将转换为 object。如果 bind 函数的参数列表为空,或者thisArg是null或undefined,执行作用域的 this 将被视为新函数的 thisArg

arg1, arg2, ... 当目标函数被调用时,被预置入绑定函数的参数列表中的参数。

返回值

返回一个原函数的拷贝,并拥有指定的 this 值和初始参数。

示例

OK,下面来看一下bind方法使用时的简单示例:

var hunter = {
  value: 1
}

function hunterFn () {
  console.log(this.value)
}

var fn = hunterFn.bind(hunter)

fn() // 1

通过上面的bind的介绍,我们可以得出以下几个结论:

  1. 可以修改函数的this指向
  2. 会返回一个函数,不会立即执行
  3. 有返回值
  4. 可以传入参数
  5. 支持函数柯里化,即在返回新函数的时候传了一部分参数,在调用这个新函数时还可以另外再传入参数 (可以理解为分步处理参数的过程)。
  6. 如果使用new运算符构造绑定函数,则忽略该值, 也就是说当bind返回的新函数作为构造函数使用的时候,bind()时指定的this会失效,但是参数依然有效。

根据上面总结的bind方法的特点,我们一步一步的来模拟搞定(实现)它。

第一点关于改变this指向的问题,我们可以使用call或者apply来实现,上面刚刚模拟实现了一遍,第二点返回一个函数这个也不难,我们直接return一个函数即可,第三点既然有返回值,那么我们也给一个return就OK了。 让我们一起来看一下代码:

Function.prototype.MyBind = function (context) {
  var _this = this
  // 返回一个函数
  return function () {
    // 返回值
    return _this.apply(context) // 利用apply方法改变this指向
  }
}

这一版代码是不是很easy!接下来我们继续实现第四和第五点。

对于第四点来说,接受个参数是非常简单的,而第五点需要实现函数柯里化,这个稍微有一丢丢复杂,不过也不难,下面我们先来看看柯里化的介绍:

函数柯里化

把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。 简单点说就是把一个多参数的函数,转化为单参数函数

简单示例

下面的代码是一个经典的函数柯里化示例

function add(x, y) {
  return function (y) {
    console.log(x + y)
  }
}
var _add = add(1)
_add(1) //2

add(1)(1) //2

简单了解函数柯里化之后,让我们重新调整一下刚才那一版代码:

Function.prototype.MyBind = function (context) {
  var _this = this
  // 1
  var args = Array.prototype.slice.call(arguments, 1)
  return function () {
    // 2
    var _args = Array.prototype.slice.call(arguments)
    // 3
    return _this.apply(context, args.concat(_args)) 
  }
}

我们来解释一下这段代码吧,

  • 1

var args = Array.prototype.slice.call(arguments, 1) 由于arguments对象第0个是this,所以我们从索引为1的地方开始裁剪。

  • 2

var _args = Array.prototype.slice.call(arguments) 在二次调用时,获取所有的参数中,此时不存在this了,所以传过来的我们全都收了^_^。

  • 3

return _this.apply(context, args.concat(_args)) 这里要注意args的先后顺序,args要在前,只有这样才能让先传递进来的参数与后面的参数按顺序对应。

根据我们上面实现的这版代码,我们先来验证一下,看看是否能够满足我们刚才说的第一点到第五点。

// 测试代码

Function.prototype.MyBind = function (context) {
  var _this = this
  var args = Array.prototype.slice.call(arguments, 1)
  return function () {
    var _args = Array.prototype.slice.call(arguments)
    return _this.apply(context, args.concat(_args))
  }
}

var hunter = {
  value: 1
}

function hunterFn (name, age) {
  console.log(name, age, this.value)
}

var fn = hunterFn.bind(hunter, '森界降临')

fn(27) // 森界降临 27 1 

接下来实现有点难的第六点,当返回的新函数作为构造函数来使用时,绑定的this值会失效,但是参数依然有效,先来看一下效果:

var value = 666
var hunter = {
  value: 1
}

function hunterFn (name, age) {
  this.details = 'to do'
  console.log(this.value, name, age)
}

hunterFn.prototype.music = '1 2 3 4'

var Fn = hunterFn.bind(hunter, '森界降临')

var obj = new Fn(27) // undefined "森界降临" 27

console.log(obj.details) // to do
console.log(obj.music) // 1 2 3 4

从上面打印的结果可以看到在函数体hunterFn中打印的valueundefined,即使在最外层加了var value = 666这段代码,依然是undefined, 说明确实this指向失效了, 同时我们在最下面打印了obj的属性,都能够正常访问打印出结果,说明此时this已经指向了obj了,至于为什么会在new的时候会丢失指定this的指向,是因为构造函数在被new调用的时候,本质上构造函数会创建一个实例,函数内部的this指向被创建出来的实例,当代码执行console.log(this.value, ...)时,当前的实例上面并没有value属性,导致打印出来的是undefined

因此我们想要实现这个特点需要区分一下,函数在被调用时是被new调用的还是被普通函数调用的,区分出这两点之后接下来就比较简单了。

对于如何区分该函数是被二者中哪个调用的,可以使用 instanceof 或者 constructor 这两种方式都可以,前者判断其是否在原型链中,后者则判断构造器指向谁

下面我们尝试着改动一下上面的代码:

  1. 使用instanceof方式
Function.prototype.MyBind = function (context) {
  var _this = this
  var args = Array.prototype.slice.call(arguments, 1)
  var boundFn = function () {
    var _args = Array.prototype.slice.call(arguments)
    // 1: 当被new调用时,通过判断当前的this是否在boundFn中,如果为真,
    // 那么将绑定函数的this指向该实例,这样实例就可以得到绑定函数的值(例如details属性)
    // 2: 当被普通函数调用时,将绑定函数的this指向context即可。
    return _this.apply(this instanceof boundFn ? this : context, args.concat(_args))
  }
  // 原型继承
  boundFn.prototype = this.prototype
  return boundFn
}

var value = 666
var hunter = {
  value: 1
}

function hunterFn (name, age) {
  this.details = 'to do'
  console.log(this.value, name, age)
}

hunterFn.prototype.music = '1 2 3 4'
var Fn = hunterFn.MyBind(hunter, '森界降临')

var obj = new Fn(27) // undefined "森界降临" 27

console.log(obj.details) // to do
console.log(obj.music) // 1 2 3 4
  1. 使用constructor方式
// 使用instanceof方式
Function.prototype.MyBind = function (context) {
  var _this = this
  var args = Array.prototype.slice.call(arguments, 1)
  var boundFn = function () {
    var _args = Array.prototype.slice.call(arguments)
    // 这里的判断与上面的方式稍微有些不同要注意一下
    return _this.apply(this.constructor === _this ? this : context, args.concat(_args))
  }
  // 原型链继承
  boundFn.prototype = this.prototype
  return boundFn
}
// 这种方式得到的结果与上面的一致,这里就不占用额外的地方来展示了。

到这里基本就已经完成了,但是还有两个问题有待解决,首先boundFn.prototype = this.prototype这句代码会导致修改boundFn.prototype的时候,也会直接修改this.prototype(绑定函数)的prototype的值。其次如果绑定的值不是一个函数,而是一个其它的值我们需要给出一个错误提示。

我们先来搞定报错提示这个问题,这个比较容易些, 直接上代码吧:

Function.prototype.MyBind = function (context) {

  if (typeof this !== 'function') {
    throw new Error('绑定的类型不为function~')
  }

  var _this = this
  var args = Array.prototype.slice.call(arguments, 1)
  var boundFn = function () {
    var _args = Array.prototype.slice.call(arguments)
    return _this.apply(this instanceof boundFn ? this : context, args.concat(_args))
  }
  boundFn.prototype = this.prototype
  return boundFn
}

对于修改返回的新函数原型上的内容会影响绑定函数的原型上的内容,如何去修改,如果大家对js中的几种继承方式了解的话,解决这个问题也不难,不太了解的可以看这篇文章JS的五种继承方式

我们使用空白函数来转换一下就可以了,即使用寄生组合继承中的实现方式, 直接上最终版的代码吧

模拟实现bind方法代码最终版

Function.prototype.MyBind = function (context) {
  if (typeof this !== 'function') {
    throw new Error('绑定的类型不为function~')
  }
  var _this = this
  var args = Array.prototype.slice.call(arguments, 1)
  var boundFn = function () {
    var _args = Array.prototype.slice.call(arguments)
    return _this.apply(this instanceof boundFn ? this : context, args.concat(_args))
  }
  // 使用空白函数转化 方法一
  // var TempFn = function () {}
  // TempFn.protoType = this.prototype
  // boundFn.protoType = new TempFn()

  // 使用空白函数转化 方法二
  boundFn.prototype = Object.create(this.prototype)
  boundFn.prototype.constructor = boundFn
  return boundFn
}

new 运算符

new运算符介绍

new 运算符创建一个用户定义的对象类型的实例或具有构造函数的内置对象的实例。

语法

  new constructor[([arguments])]

参数说明

constructor 一个指定对象实例的类型的类或函数。

arguments 一个用于被 constructor 调用的参数列表。

描述

  1. 创建一个空的简单JavaScript对象(即{});
  2. 链接该对象(设置该对象的constructor)到另一个对象 ;
  3. 将步骤1新创建的对象作为this的上下文 ;
  4. 如果该函数没有返回对象,则返回this。

根据上面MDN中描述的效果不难看,其简单的运行过程是下面这个样子的:

var People = function (name, age) {
  // 创建一个对象  this = {}  
  // 给当前this指向的对象赋值构造属性
  this.name = name
  this.age = age
  // return this  如果没有手动返回,则默认返回this指向的对象 
}

var person = new People('hunter', 18)

下面来看一下真正的new运算符实现的效果

示例

function People (name, age) {
  this.name = name
  this.age = age
}

People.prototype.sayHello = function () {
  console.log(`你好 ${this.name} !`)
}

var person = new People('hunter', 18)

console.log(person.name, person.age) // hunter 18
person.sayHello() // 你好 hunter !

从上面的示例中可以看出,实例person可以访问People构造函数的属性name和age, 也可以访问到原型中的属性。了解了这些特点后,接下来我们开始模拟一个简单的new方法吧!由于我们自己模拟实现的new运算符方法无法像上面讲过的几种方法一样直接覆盖,所以这里面实现的是一个函数,用来模拟new运算符, 实现的思路如下:

  • 根据构造器的prototyp属性为原型,创建一个对象
  • 使用call或者apply方法,将this和调用参数传给构造函数执行
  • 如果构造器没有手动返回对象,则默认返回第一步创建的对象

模拟实现new方法代码最终版

var simulateNew = function (Parent, ...args) {
  // 创建对象,使其具有构造函数原型上面的属性
  var child = Object.create(Parent.prototype)
  // 使用 apply,改变构造函数 this 的指向到新建的对象,这样 child 就可以访问到构造函数中的属性
  var result = Parent.apply(child, args)
  // 判断是否有手动返回对象,没有则默认返回child
  return typeof result === 'object' ? result || child : child
}

到这里就是本篇文章的所有内容了,看着感觉很长,其实顺着思路走下来并不是特别的难理解。如果有错误的地方欢迎指正^_^。

参考文章

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

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

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

github.com/mqyqingfeng…

github.com/mqyqingfeng…

github.com/mqyqingfeng…

www.cnblogs.com/echolun/p/1…

www.cnblogs.com/echolun/p/1…