如何实现 new、apply、call、bind ?这篇文章告诉你
在上一节,我们一起探究了 JavaScript
中的6种继承实现方式,并且使用到了 new
、call
等方法,那么像 new
、apply
、call
它们底层是如何实现的呢?它们为何能在继承中使用呢?今天我们就一起继续来学习一下,以加强我们对 JavaScript
继承的理解。
在 JavaScript
中,apply
、 call
和 bind
方法是前端代码开发中相当重要的概念,并且它们与 this
的指向密切相关。如果我们要深入学习 JavaScript
,那么就需要先对这些基础的知识有一个较深的理解,那我们就开始吧!
带着问题来学习
和上一节一样,我们还是先来思考几个问题,这样在学习的过程中,针对解题的思路,能够加强学习的效果和记忆,让我们一起来看问题。
new
关键词底层是如何实现的,它的作用是什么?apply
、call
、bind
这三个方法之间有什么区别?- 怎么手写实现一个
apply
或call
的方法?
带着问题来学习,可以加深对知识的理解,当然也可以顺便当做面试题来学习了,一举两得。
原理解析: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
apply
、 call
和 bind
是挂在 Function 对象上的三个方法,因此调用这个三个方法的必须是一个函数,让我们一起看一下它们的使用语法,代码如下:
func.call(thisArg, param1, param2, ...);
func.apply(thisArg, [param1, param2, ...]);
func.bind(thisArg, param1, param2, ...)();
在上述的代码中,func
是要调用的函数,thisArg
一般为 this
所指向的对象,后面的 param1
, param2
为 func
的多个参数,如果 func
不需要参数,则可以不写。
这三个函数的共同点是它们都可以改变函数 func
的 this
指向,其中 call
和 apply
的区别是传参的写法不同。apply
的第二个参数是一个数组,而 call
第二个至第 N 个参数都是给 func
的传参。而 bind
又和这两个方法不同,bind
虽然可以改变 func
的 this
指向,但它不是立即执行,call
和 apply
在改变函数的指向后则会立即执行。下面通过一段代码来加深对这三个方法的理解,代码如下:
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
有关的。
下面再来看一下这三个方法的应用场景,这几种应用场景的理念都是 “借用” 方法的思路:
- 第一种场景:判断数据的类型。通过 Object.prototype.toString 几乎可以判断所有类型的数据
return typeof obj !== 'object' ? typeof obj : Object.prototype.toString.call(obj).replace(/^\$/, '$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}
- 第三种场景:获取数组中的最大、最小值。通过使用
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
- 第四种场景:继承。在上一节内容中,通过 call 方法实现父类构造函数的继承,具体代码可以查看上一节
手写 new
在上述 new
的分析中,我们知道了 new
被调用后大致做了如下几件事情:
- 让实例可以访问私有属性
- 让实例可以访问构造函数的原型
(constructor.prototype)
所在原型链上的属性 - 构造函数返回的最后结果是引用数据类型
基于上述的分析,最终实现的代码如下:
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
apply
和 call
的基本原理都差不多,只是它们的传参方式不一样,因此实现其中一个方法,另外一个就能在前面的基础上很简单的实现,代码如下:
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;
}
通过上述的代码可以发现,call
和 apply
内部的实现原理其实一样,唯一的区别就是它们的传参方式不同。这两个方法和 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
的底层是如何实现的,以及它在实际开发中的作用是什么。
通过对比 call
、apply
和 bind
方法,以及对它们底层的实现,加深了对这三个方法是理解和使用。
那么,最开始的问题,你现在知道该如何回答了吗?