如何实现 new、apply、call、bind ?这篇文章告诉你

上一节,我们一起探究了 JavaScript 中的6种继承实现方式,并且使用到了 newcall 等方法,那么像 newapplycall 它们底层是如何实现的呢?它们为何能在继承中使用呢?今天我们就一起继续来学习一下,以加强我们对 JavaScript 继承的理解。

JavaScript 中,applycallbind 方法是前端代码开发中相当重要的概念,并且它们与 this 的指向密切相关。如果我们要深入学习 JavaScript ,那么就需要先对这些基础的知识有一个较深的理解,那我们就开始吧!

带着问题来学习

和上一节一样,我们还是先来思考几个问题,这样在学习的过程中,针对解题的思路,能够加强学习的效果和记忆,让我们一起来看问题。

  1. new 关键词底层是如何实现的,它的作用是什么?
  2. applycallbind 这三个方法之间有什么区别?
  3. 怎么手写实现一个 applycall 的方法?

带着问题来学习,可以加深对知识的理解,当然也可以顺便当做面试题来学习了,一举两得。

原理解析:new

new 关键词的主要作用就是执行一个构造函数,并返回一个实例对象。在 new 的过程中还会根据构造函数的情况,来确定是否可以接受参数的传递,我们可过一段简单的代码来看一个 new 的例子,代码如下:

function Person () {
    this.name = 'Mac';
}

const p = new Person();
console.log(p.name); // Mac

通过上述代码输出的结果可以看出,p 是一个通过 Person 构造函数生成的实例对象,那么 new 在这个实例对象的生成中到底实现了哪些功能呢?

  • 第一步,创建一个新的对象;
  • 第二步,将构造函数的作用域赋给新的对象 — this 指向新的对象
  • 第三步,执行构造函数中的代码 — 为这个新对象添加属性
  • 第四步,返回新的对象

如果我们不用 new 关键词,然后将上述的代码进行改造后会发生什么变化呢?改造后的代码如下:

function Person () {
    this.name = 'Mac';
}

const p = Person();
console.log(p); // undefined
console.log(name); // Mac
console.log(p.name); // Uncaught TypeError: Cannot read properties of undefined (reading 'name')

上述代码中,我们没有使用 new 关键词,p 在控制台中打印的结果就是 undefined。由于没有使用 new 关键词,因此在默认情况下 this 的指向是 window,此时可以直接访问 name,最终在控制台中就直接打印出 Mac,因为 p 这个对象中没有 name 属性,因此执行最后一行只就会报错。

如果当构造函数中存在 return 一个对象时,结果又是什么样的呢?让我们一起来看代码,如下:

function Person () {
    this.name = 'Mac';
    return { age: 18 };
}

const p = new Person();
console.log(p); // { age: 18 }
console.log(p.name); // undefied
console.log(p.age); // 18

在上述的代码中,当构造函数返回的是一个与 this 无关的新对象时,new 关键词会直接返回这个新对象,而不是通过 new 执行步骤生成的 this 对象,但这里要求构造函数返回的必须是一个对象,如果返回的不是一个对象,那么还是会按 new 的执行步骤,返回一个新生成的对象。接下来我们继续在将上述的代码进行修改,如下:

function Person () {
    this.name = 'Mac';
    return 'Mini';
}

const p = new Person();
console.log(p); // { name: 'Mac' }
console.log(p.name); // Mac

在上述代码中,可以看到当构造函数中返回的不是一个对象时,那么它还是会根据 new 关键词的执行逻辑生成一个新的对象,也就是绑定了最新的 this,最后返回出来。

基于上述的内容,我们可以总结一下:new 关键词执行之后总是会返回一个对象,要么是实例对象,要么是 return 语句指定的对象。

原理解析:apply & call & bind

applycallbind 是挂在 Function 对象上的三个方法,因此调用这个三个方法的必须是一个函数,让我们一起看一下它们的使用语法,代码如下:

func.call(thisArg, param1, param2, ...);
func.apply(thisArg, [param1, param2, ...]);
func.bind(thisArg, param1, param2, ...)();

在上述的代码中,func 是要调用的函数,thisArg 一般为 this 所指向的对象,后面的 param1, param2func 的多个参数,如果 func 不需要参数,则可以不写。

这三个函数的共同点是它们都可以改变函数 functhis 指向,其中 callapply 的区别是传参的写法不同。apply 的第二个参数是一个数组,而 call 第二个至第 N 个参数都是给 func 的传参。而 bind 又和这两个方法不同,bind 虽然可以改变 functhis 指向,但它不是立即执行,callapply 在改变函数的指向后则会立即执行。下面通过一段代码来加深对这三个方法的理解,代码如下:

const a = {
    name: 'mac',
    getName: function (msg) {
        return msg + ', ' + this.name;
    }
};

const b = {
    name: 'min'
};

console.log(a.getName('hello')); // hello, mac
console.log(a.getName.call(b, 'hi')); // hi, min
console.log(a.getName.apply(b, ['hi'])); // hi, min
console.log(a.getName.bind(b, 'hi')()); // hi, min

通过上述代码的执行,可以发现这三种方式都可以达到预期的效果,也就是通过改变 this 的指向,从而使 b 对象可以直接使用 a 对象中的 getName 方法,并且可以看到后面三个方法的执行的结果都是跟 min 有关的。

下面再来看一下这三个方法的应用场景,这几种应用场景的理念都是 “借用” 方法的思路:

  1. 第一种场景:判断数据的类型。通过 Object.prototype.toString 几乎可以判断所有类型的数据
return typeof obj !== 'object' ? typeof obj : Object.prototype.toString.call(obj).replace(/^\$/, '$1');
  1. 第二种场景:类数组的借用方法。因为类数组本身不是真正的数组,所有它们没有数组类型上自带的相关方法,因此可以利用一些方法去 借用 数组中的方法
const arrLike = {
    0: 'js',
    1: 'ts',
    length: 2
};

Array.prototype.push.call(arrLike, 'java', 'c++');
console.log(typeof arrLike); // 'object'
console.log(arrLike); // {0: 'js', 1: 'ts', 2: 'java', 3: 'c++', length: 4}
  1. 第三种场景:获取数组中的最大、最小值。通过使用 apply 来实现数组中判断最大、最小值, apply 直接传递一个数组作为调用方法的参数,也可以减少一步展开数组
const arr = [10, 16, 8, 20, 14];
const max = Math.max.apply(Math, arr);
const min = Math.min.apply(Math, arr);

console.log(max); // 20
console.log(min); // 8
  1. 第四种场景:继承。在上一节内容中,通过 call 方法实现父类构造函数的继承,具体代码可以查看上一节

手写 new

在上述 new 的分析中,我们知道了 new 被调用后大致做了如下几件事情:

  1. 让实例可以访问私有属性
  2. 让实例可以访问构造函数的原型(constructor.prototype)所在原型链上的属性
  3. 构造函数返回的最后结果是引用数据类型

基于上述的分析,最终实现的代码如下:

function _new (constructor, ...args) {
    if (typeoif ctor !== "function") {
        throw 'constructor must be a function';
    }
    
    const obj = new Object();
    obj.__proto__ = Object.create(constructor.prototype);
    const rest = constructor.apply(obj, ...args);
    
    const isObject = typeof rest === "object" && typeof rest !== null;
    const isFunction = typeof rest === "function";
    return isObject || isFunction ? rest : obj;
}

在上述代码中,通过判断传入的构造函数是否为 function ,如果是则创建一个新的对象,并且将传入的函数的原型绑定在新的对象上,这样最终返回的就是这个构造函数的实例对象。一般在面试中会要求面试者能够手写实现 new ,这里需要自己动手多多练习和思考。

手写 apply 和 call

applycall 的基本原理都差不多,只是它们的传参方式不一样,因此实现其中一个方法,另外一个就能在前面的基础上很简单的实现,代码如下:

Function.prototype.myCall = function (_context, ...args) {
    var context = _context || window;
    context.fn = this;
    var result = eval('context.fn(...args)');
    delete context.fn;
    return result;
}

call 实现的基础上只需要做简单的修改就可以实现 apply,代码如下:

Function.prototype.myApply = function (_context, args) {
    var context = _context || window;
    context.fn = this;
    var result = eval('context.fn(...args)');
    delete context.fn;
    return result;
}

通过上述的代码可以发现,callapply 内部的实现原理其实一样,唯一的区别就是它们的传参方式不同。这两个方法和 bind 的区别是它们之间返回执行结果,而 bind 方法是返回一个函数,因此这里可以直接使用 eval 执行来得到结果。

手写 bind

结合前面两个方法的思想,bind 的实现思路基本和 apply 一样,唯一的区别是在最后实现返回结果时,bind 不需要直接执行,因此不需要用 eval 执行,而是需要通过返回一个函数的方式将结果返回,然后再通过执行这个结果来得到想要的执行效果。最终的代码如下:

Function.prototype.myBind = function (_context, args) {
    if (typeof this !== "function") {
        throw new Error("this must be a function");
    }
    
    var _this = this;
    var fbound = function () {
        _this.apply(this instanceof _this ? this : _context, args.concat(Array.prototype.slice.call(arguments)));
    };
    
    if (this.prototype) {
        fbound.prototype = Object.create(this.prototype);
    }
    
    return fbound;
}

通过上述代码可以发现,bind 的核心是需要返回一个函数,因此需要返回 fbound,但是在返回的过程中,原型链对象上的属性不能丢失,因此这里需要使用 Object.create 将 this.prototype 上的属性挂在 fbound 的原型上面,最后再将 fbound 返回出去,这样调用 myBind 方法接收到函数的对象在通过执行接收的函数就能得到想要的结果。

最后

通过上述的学习,我们了解到 new 的底层是如何实现的,以及它在实际开发中的作用是什么。

通过对比 callapplybind 方法,以及对它们底层的实现,加深了对这三个方法是理解和使用。

那么,最开始的问题,你现在知道该如何回答了吗?