你真的能够完美模拟Function.prototype.call吗?

1,001 阅读4分钟

源起

模拟Function.prototype.call?大家可能觉得超简单,梭梭梭就敲出了以下答案:

// es6写法,es3写法略,重点不在这。
Function.prototype.myCall = function(ctx, ...args) {
  const fn = Symbol();

  ctx[fn] = this;

  const res = ctx[fn](...args);

  delete ctx[fn];

  return res;
};

所以到底有啥问题?

问题在于传原始值进去就崩了啊!

Object.prototype.toString.call(false); // "[object Boolean]"
Object.prototype.toString.myCall(false); // Error

改造思路

了解问题所在后,改造开始。不就是原始值么?传进去包装一层不就完事了吗?

Function.prototype.myCall = function (ctx, ...args) {
    if (typeof ctx !== 'object' || ctx === null) {
        ctx = new Object(ctx);
    }
    ...
}

嗯......使用Object进行包装真方便,它帮我们把boolean包装成Boolean,把number包装成Number,把string包装成String,可问题是null与undefined并没有相应的包装类。

// Correct
Object.prototype.toString.myCall(false); // "[object Boolean]"
Object.prototype.toString.myCall(1); // "[object Number]"
Object.prototype.toString.myCall(""); // "[object String]"
// Wrong
Object.prototype.toString.myCall(undefined); // "[object Object]"
Object.prototype.toString.myCall(null); // "[object Object]"

所以现在的问题在于,如何将被调用方法的this绑定为null以及undefined?

在乔哥的提点下,我将问题小小地推进了一步。即在ctx为null、undefined时直接调用当前方法。

并不完美的解决方案

Function.prototype.myCall = function(ctx, ...args) {
  let res;

  if (ctx == undefined) { // 处理 null、undefined
    // 直接调用方法
    res = this();
  } else {
    const fn = Symbol();
    
    if (typeof ctx !== "object") ctx = new Object(ctx);
    
    ctx[fn] = this;

    res = ctx[fn](...args);

    delete ctx[fn];
  }

  return res;
};

从结果反推分析,Object.prototype.toString内部应该是use strict的,所在在直接调用方法的情况下内部的this是指向undefined的。但是这样一来传null与undefined结果都一样了。

Object.prototype.toString.call(null); // [object Null]
Object.prototype.toString.call(undefined); // [object Undefined]

Object.prototype.toString.myCall(null); // [object Undefined]
Object.prototype.toString.myCall(undefined); // [object Undefined]

所以,要怎么样才能使得Object.prototype.toString()里的this指向null呢?如果无解,能给出证明吗?

无解证明

先说结论,将函数的 this 改变为 null 的唯一条件是在严格模式下使用 call、bind 与 apply。

代码运行环境:浏览器端(Chrome76 | Firefox67)

让我们把令Object.prototype.toString()里的this指向null这个问题拆解:

  1. call 传入 null 等参数后, this 指向什么?
  2. Object.prototype.toString()怎么返回结果的?
  3. 令函数内部的 this 指向 null 的方法有哪些?

对于第一个问题:

function fn() {
  return this;
}
function strictFn() {
  "use strict";
  return this;
}

fn(); // Window
fn.call(); // Window
fn.call(undefined); // Window
fn.call(null); // Window
fn.call(""); // String('')
fn.call({}); // {}

strictFn(); // undefined
strictFn.call(); // undefined
strictFn.call(undefined); // undefined
strictFn.call(null); // null
strictFn.call(""); // ''
strictFn.call({}); // {}

call 传参后 this 指向会受到严格模式的影响,具体表现为:

  1. this 默认指向(直接调用或 call 空参调用):非严格模式指向全局对象,严格模式指向 undefined
  2. 处理原始值:非严格模式下 call 传入 null,undefined 不影响 this,传入其他原始值则会进行包装;严格模式下 call 传入原始值不会经过任何包装而直接对 this 进行赋值。

下面论证第二个问题:

// 证明:Object.prototype.toString为严格模式
const fn = Object.prototype.toString;
fn(); //  "[object Undefined]" 证明为真

// 证明:Object.prototype.toString返回结果与直接调用的传参无关
fn(1); // "[object Undefined]"
fn(null); // "[object Undefined]"
Object.prototype.toString(); // "[object Object]"
Object.prototype.toString(1); // "[object Object]"
Object.prototype.toString(null); // "[object Object]" 证明为真

// 证明:使用call用于改变this,可以影响Object.prototype.toString的结果
fn.call(); // "[object Undefined]"
fn.call(undefined); // "[object Undefined]"
fn.call(null); // "[object Null]"
fn.call(1); // "[object Number]"
fn.call({}); // "[object Object]"
fn.call(new Date()); // "[object Date]"

Object.prototype.toString 返回结果只与 this 有关,但尚不清楚具体转化机理。

/**
 *  Object.prototype.toString作用机理探索
 *  以下用例说明原型链继承或直接更改构造方法均不影响返回结果。
 */
Object.prototype.toString.call(new Date()); // "[object Date]"
function DateChild() {}
Object.prototype.toString.call(new DateChild()); // "[object Object]"
DateChild.prototype = new Date();
Object.prototype.toString.call(new DateChild()); // "[object Object]"
DateChild.constructor = Date;
Object.prototype.toString.call(new DateChild()); // "[object Object]"

对于第三个问题,先穷举出所有改变 this 的方法:

  1. 非严格模式 this 在直接调用情况下指向全局对象,可以通过改变全局对象影响 this
  2. 使用对象调用函数时,函数内 this 指向对象,可以通过改变对象影响 this
  3. 将函数作为构造函数使用时,this 指向新建实例
  4. 使用 call、bind、apply 方法直接改变 this

那么上面有哪几种情况能将 this 赋值为 null 呢?

  1. 不可。无法将全局对象赋值为 null
  2. 不可。将对象赋值为 null,调用也就无从说起了
  3. 不可。
  4. 可。由前面的证明得出,只有在严格模式下才可以将 this 通过 call 等方法赋值为 null。

综上所述,将函数的 this 改变为 null 的唯一条件是在严格模式下使用 call、bind 与 apply。

也就是说,我们确实无法通过js去完美模拟Function.prototype.call,我们不得不接受这一个缺憾。

在论证过程过程中我收获到一个有趣的发现,值得在这里再提一下。

非严格模式下 call 传入 null,undefined 不影响 this,传入其他原始值则会进行包装;严格模式下 call 传入原始值不会经过任何包装而直接对 this 进行赋值。

(完)


最后的论证过程并不严谨,很多地方都需要意会。如有错漏,还望大家指正。

期间感谢乔哥还有@Abiel的指点,让我得以钻完这“牛角尖”。

谢谢。