「前端每日一问(39)」手写 call 函数

1,583 阅读4分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第2天,点击查看活动详情

本题难度:⭐ ⭐ ⭐

答:

面试过关版本

Function.prototype.myCall = function (context = window) {
  context.fn = this 
  const args = [...arguments].slice(1) 
  const res = context.fn(...args) 
  delete context.fn 
  return res 
}

尽可能完善的版本

Function.prototype.myCall = function (context) {
  if (context === null || context === undefined) {
      context = window 
  } else {
      context = Object(context) 
  }
  const fn = Symbol('fn') 
  context[fn] = this 
  const args = [...arguments].slice(1) 
  let result = context[fn](...args) 
  delete context[fn]
  return result
}

要读懂本文,需掌握下面的前置知识:

一步一步实现手写 call

在手写之前,先明白 call 函数的功能,是为了改变 this 的指向,如下例所示:

var lastName = 'xxx'
const person = {
  lastName: 'lin'
}

function fn () {
  console.log(this.lastName)
}

fn.call(person) // 用call this 指向 person,输出 'lin'
fn.call() // 直接调用,this指向 window,输出 'xxx'

实现 this 的显式指向

call 是写到 Function.prototype 上的方法,我们可以自己实现一个 myCall,也写到原型链上。

Function.prototype.myCall = function(obj) {
  obj.fn = this // 这里的 this 就是调用 myCall 的函数,把函数赋值给 obj.fn,这样执行 obj.fn() 时,函数内的 this 就指向了 obj
  obj.fn() // 执行 obj上面的 fn 方法
  delete obj.fn // 执行完了,fn 方法就没用了,删除掉 
}

测试一下:

var lastName = 'xxx'
const person = {
  lastName: 'lin'
}

function fn () {
  console.log(this.lastName)
}

fn.myCall(person) // 输出 'lin'
fn.myCall(window) // 输出 'xxx'

这样就实现了 this 显式指向传入的对象。

兼容不传值的情况

但是现在还有问题,不传入要指向的对象的话会报错:

fn.myCall() // Uncaught TypeError: Cannot set properties of undefined (setting 'fn')

我们改造一下 myCall,兼容不传入上下文的情况,上下文不传入就默认指向 window,顺便改一下参数名字,不叫 obj,叫 context 更合适一点:

Function.prototype.myCall = function(context = window) {
  context.fn = this 
  context.fn() 
  delete context.fn 
}

测试一下:

var lastName = 'xxx'
function fn () {
  console.log(this.lastName)
}

fn.myCall() // 'xxx'

这样就兼容了不传值的情况。

实现参数传递

但是现在还有问题,参数没有携带进去,执行下面的代码,arguments 是空的。

const person = {
  lastName: 'lin'
}

function fn () {
  console.log('this.lastName :>> ', this.lastName)
  console.log('[...arguments] :>> ', [...arguments])
}

fn.myCall(person, 1, 2, 3, 4)

image.png

我们改造一下,执行 fn 函数时把参数携带进去。

Function.prototype.myCall = function(context = window) {
  context.fn = this 
  const args = [...arguments].slice(1) // 获取除了第一个参数之外后面的所有参数
  context.fn(...args) // 执行的时候把参数传递进去
  delete context.fn 
}

还是刚才的代码,测试结果如下:

image.png

获取返回值

但是现在还有问题,执行这个函数时的返回值没有返回,比如返回所有参数相加的和,结果是 undefiend。

function fn () {
  return [...arguments].reduce((acc, cur) => acc + cur)
}

console.log(fn.myCall(person, 1, 2, 3, 4)) // undefined

继续改造 myCall 函数,

Function.prototype.myCall = function(context = window) {
  context.fn = this 
  const args = [...arguments].slice(1)
  const res = context.fn(...args) // 用一个变量 res 来接收函数返回值
  delete context.fn 
  return res // 返回 res
}

测试一下:

function fn () {
  return [...arguments].reduce((acc, cur) => acc + cur)
}

console.log(fn.myCall(person, 1, 2, 3, 4)) // 10

至此,一个面试过关版本的手写 call 函数,就实现完了。

拓展:尽可能完善的版本

尽可能完善的版本,想办法解决一些边缘情况。

兼容原始类型

什么是边缘情况,比如,把要指向的对象指向一个原始值,就报错了:

fn.myCall(0)

image.png

这时,就需要参考一下原生的 call 函数是如何解决了的,我们打印出来看一下:

var lastName = 'xxx'
const person = {
  lastName: 'lin'
}

function fn (type) {
  console.log(type, '->', this.lastName)
}


fn.call(0, 'number')
fn.call(1n, 'bigint')
fn.call(false, 'boolean')
fn.call('123', 'string')
fn.call(undefined, 'undefined')
fn.call(null, 'null')
const a = Symbol('a')
fn.call(a, 'symbol')
fn.call([], '引用类型')

image.png

可以看到,undefined 和 null 指向了 window,原始类型和引用类型都是 undefined。

其实是因为,原始类型指向对应的包装类型,引用类型就指向这个引用类型,之所以输出值都是 undefined,是因为这些对象上都没有 lastName 属性。

改造一下我们的 myCall 函数,实现原始类型的兼容:

Function.prototype.myCall = function (context = window) {
  if (context === null || context === undefined) { 
    context = window // undefined 和 null 指向 window
  } else {
    context = Object(context) // 原始类型就包装一下
  }
  context.fn = this 
  const args = [...arguments].slice(1) 
  const res = context.fn(...args) 
  delete context.fn 
  return res 
}

symbol 处理对象的属性,防止属性重名

假设对象上本来就有一个 fn 属性,执行下面的调用,对象上的 fn 属性会被删除。

const person = {
  lastName: 'lin',
  fn: 123
}

function fn () {
  console.log(this.lastName)
}

fn.myCall(person)

console.log('person :>> ', person);

image.png

因为对象上本来的 fn 属性和 myCall 函数内部临时定义的fn 属性重名了。

可以用 symbol 来处理这个问题,继续改造 myCall 函数。

Function.prototype.myCall = function (context = window) {
  if (context === null || context === undefined) {
    context = window
  } else {
    context = Object(context)
  }
  const fn = Symbol('fn') // 用 symbol 处理一下
  context[fn] = this 
  const args = [...arguments].slice(1) 
  const res = context[fn](...args) 
  delete context[fn] 
  return res 
}

这样在处理的过程中,fn 就不会因为重名被覆盖了,如下图所示:

image.png

至此,一个尽可能完善版本的 myCall ,终于写完了。

其实用 Math.random() 来生成一个随机的属性,也可以解决属性重名问题,这里就不再赘述了。

结尾

阿林水平有限,文中如果有错误或表达不当的地方,非常欢迎在评论区指出,感谢~

如果我的文章对你有帮助,你的👍就是对我的最大支持^_^

你也可以关注《前端每日一问》这个专栏,防止失联哦~

我是阿林,输出洞见技术,再会!

上一篇:

「前端每日一问(38)」列举一些call、apply、bind 的使用场景

下一篇:

「前端每日一问(40)」手写 apply 函数