小知识,大挑战!本文正在参与“程序员必备小知识”创作活动
JavaScript
中的 apply
、call
和 bind
方法是前端代码开发中相当重要的概念,并且与 this
的指向密切相关。很多人对它们的理解还比较浅显,如果想拥有扎实的 JavaScript
编程基础,那么必须要了解这些基础常用的方法。
为了方便更好地理解本讲的内容,在课程开始前请先思考几个问题:
- 用什么样的思路可以
new
关键词? apply
、call
、bind
这三个方法之间有什么区别?- 怎样实现一个
apply
或者call
的方法?
方法的基本介绍
new 原理介绍
new
关键词的主要作用就是执行一个构造函数、返回一个实例对象,在 new
的过程中,根据构造函数的情况,来确定是否可以接受参数的传递。下面通过一段代码来看一个简单的 new
的例子。
function Person(){
this.name = 'Jack';
}
var p = new Person();
console.log(p.name) // Jack
这段代码比较容易理解,从输出结果可以看出,p
是一个通过 person
这个构造函数生成的一个实例对象,这个应该很容易理解。那么 new
在这个生成实例的过程中到底进行了哪些步骤来实现呢?总结下来大致分为以下几个步骤。
- 创建一个新对象;
- 将构造函数的作用域赋给新对象(this 指向新对象);
- 执行构造函数中的代码(为这个新对象添加属性);
- 返回新对象。
那么问题来了,如果不用new
这个关键词,结合上面的代码改造一下,去掉 new
,会发生什么样的变化呢?再来看下面这段代码。
function Person(){
this.name = 'Jack';
}
var p = Person();
console.log(p) // undefined
console.log(name) // Jack
console.log(p.name) // 'name' of undefined
从上面的代码中可以看到,我们没有使用 new
这个关键词,返回的结果就是 undefined
。其中由于 JavaScript
代码在默认情况下 this
的指向是 window
,那么 name
的输出结果就为 Jack
,这是一种不存在 new
关键词的情况。
那么当构造函数中有 return
一个对象的操作,结果又会是什么样子呢?我们再来看一段在上面的基础上改造过的代码。
function Person(){
this.name = 'Jack';
return {age: 18}
}
var p = new Person();
console.log(p) // {age: 18}
console.log(p.name) // undefined
console.log(p.age) // 18
通过这段代码又可以看出,当构造函数最后 return
出来的是一个和 this
无关的对象时,new
命令会直接返回这个新对象,而不是通过 new
执行步骤生成的this
对象。
但是这里要求构造函数必须是返回一个对象,如果返回的不是对象,那么还是会按照 new
的实现步骤,返回新生成的对象。接下来还是在上面这段代码的基础之上稍微改动一下。
function Person(){
this.name = 'Jack';
return 'tom';
}
var p = new Person();
console.log(p) // {name: 'Jack'}
console.log(p.name) // Jack
可以看出,当构造函数中 return
的不是一个对象时,那么它还是会根据 new
关键词的执行逻辑,生成一个新的对象(绑定了最新 this
),最后返回出来。
前美团技术专家若离总结:new
关键词执行之后总是会返回一个对象,要么是实例对象,要么是 return
语句指定的对象。
apply
& call
& bind
原理介绍
先来了解一下这三个方法的基本情况,call
、apply
和 bind
是挂在 Function
对象上的三个方法,调用这三个方法的必须是一个函数。
请看这三个函数的基本语法。
func.call(thisArg, param1, param2, ...)
func.apply(thisArg, [param1,param2,...])
func.bind(thisArg, param1, param2, ...)
其中 func
是要调用的函数,thisArg
一般为 this
所指向的对象,后面的 param1
、2
为函数 func
的多个参数,如果 func
不需要参数,则后面的 param1
、2
可以不写。
这三个方法共有的、比较明显的作用就是,都可以改变函数 func
的 this
指向。call
和 apply
的区别在于,传参的写法不同:apply
的第 2
个参数为数组; call
则是从第 2
个至第 N
个都是给 func
的传参;而 bind
和这两个(call
、apply
)又不同,bind
虽然改变了 func
的 this
指向,但不是马上执行,而这两个(call
、apply
)是在改变了函数的 this
指向之后立马执行。
为了更好地掌握这部分概念,结合一段代码再深入理解一下这几个方法。
let a = {
name: 'jack',
getName: function(msg) {
return msg + this.name;
}
}
let b = {
name: 'lily'
}
console.log(a.getName('hello~')); // hello~jack
console.log(a.getName.call(b, 'hi~')); // hi~lily
console.log(a.getName.apply(b, ['hi~'])) // hi~lily
let name = a.getName.bind(b, 'hello~');
console.log(name()); // hello~lily
从上面的代码执行的结果中可以发现,使用这三种方式都可以达成我们想要的目标,即通过改变 this
的指向,让 b
对象可以直接使用 a
对象中的 getName
方法。从结果中可以看到,最后三个方法输出的都是和 lily
相关的打印结果,满足了预期。
方法的应用场景
判断数据类型
用 Object.prototype.toString
来判断类型是最合适的,借用它我们几乎可以判断所有类型的数据,在 JS 的数据类型你了解多少数据类型的判断中有介绍过,将当时总结的用来判断数据类型的那部分代码粘贴在下面了,可以回忆一下。
function getType(obj){
let type = typeof obj;
if (type !== "object") {
return type;
}
return Object.prototype.toString.call(obj).replace(/^$/, '$1');
}
结合上面这段代码,以及在前面讲的 call
的方法的 “借用” 思路,那么判断数据类型就是借用了 Object
的原型链上的 toString
方法,最后返回用来判断传入的obj
的字符串,来确定最后的数据类型,这里就不再多做讲解了。
类数组借用方法
类数组因为不是真正的数组,所有没有数组类型上自带的种种方法,所以就可以利用一些方法去借用数组的方法,比如借用数组的 push
方法,看下面的一段代码。
var arrayLike = {
0: 'java',
1: 'script',
length: 2
}
Array.prototype.push.call(arrayLike, 'jack', 'lily');
console.log(typeof arrayLike); // 'object'
console.log(arrayLike);
// {0: "java", 1: "script", 2: "jack", 3: "lily", length: 4}
从上面的代码可以看到,arrayLike
是一个对象,模拟数组的一个类数组。从数据类型上看,它是一个对象。从上面的代码中可以看出,用 typeof
来判断输出的是 'object'
,它自身是不会有数组的 push
方法的,这里就用 call
的方法来借用 Array
原型链上的 push
方法,可以实现一个类数组的 push
方法,给 arrayLike
添加新的元素。
获取数组的最大 / 最小值
可以用 apply
来实现数组中判断最大 / 最小值,apply
直接传递数组作为调用方法的参数,也可以减少一步展开数组,可以直接使用 Math.max、Math.min 来获取数组的最大值 / 最小值,请看下面这段代码。
let arr = [13, 6, 10, 11, 16];
const max = Math.max.apply(Math, arr);
const min = Math.min.apply(Math, arr);
console.log(max); // 16
console.log(min); // 6
继承
在探究 JS 常见的 6 种继承方式说到了继承,它与 new
、call
共同实现了各种各样的继承方式。那么下面我们结合着这一讲的内容再来回顾一下组合继承方式,代码如下。
function Parent3 () {
this.name = 'parent3';
this.play = [1, 2, 3];
}
Parent3.prototype.getName = function () {
return this.name;
}
function Child3() {
Parent3.call(this);
this.type = 'child3';
}
Child3.prototype = new Parent3();
Child3.prototype.constructor = Child3;
var s3 = new Child3();
console.log(s3.getName()); // 'parent3'
如何自己实现这些方法
在面试中,手写实现 new
、cal
l、apply
、bind
一直是比较高频的题目,结合本讲的内容,一起来手工实现一下这几个方法。
new 的实现
刚才在讲 new
的原理时,介绍了执行 new
的过程。那么来看下在这过程中,new
被调用后大致做了哪几件事情。
- 让实例可以访问到私有属性;
- 让实例可以访问构造函数原型(constructor.prototype)所在原型链上的属性
- 构造函数返回的最后结果是引用数据类型
function _new(ctor, ...args) {
if(typeof ctor !== 'function') {
throw 'ctor must be a function';
}
let obj = new Object();
obj.__proto__ = Object.create(ctor.prototype);
let res = ctor.apply(obj, [...args]);
let isObject = typeof res === 'object' && res !== null;
let isFunction = typeof res === 'function';
return isObject || isFunction ? res : obj;
};
apply 和 call 的实现
由于 apply
和 call
基本原理是差不多的,只是参数存在区别,因此我们将这两个的实现方法放在一起讲。
依然是结合方法“借用”的原理,我们一起来思考一下这两个方法如何实现,请看下面实现的代码。
Function.prototype.call = function (context, ...args) {
var context = context || window;
context.fn = this;
var result = eval('context.fn(...args)');
delete context.fn
return result;
}
Function.prototype.apply = function (context, args) {
let context = context || window;
context.fn = this;
let result = eval('context.fn(...args)');
delete context.fn
return result;
}
从上面的代码可以看出,实现 call
和 apply
的关键就在 eval
这行代码。其中显示了用 context
这个临时变量来指定上下文,然后还是通过执行 eval
来执行 context.fn
这个函数,最后返回 result
。
要注意这两个方法和 bind
的区别就在于,这两个方法是直接返回执行结果,而 bind
方法是返回一个函数,因此这里直接用 eval
执行得到结果。
bind 的实现
结合上面两个方法的实现,bind
的实现思路基本和 apply
一样,但是在最后实现返回结果这里,bind
和 apply
有着比较大的差异,bind
不需要直接执行,因此不再需要用 eval
,而是需要通过返回一个函数的方式将结果返回,之后再通过执行这个结果,得到想要的执行效果。
那么,结合这个思路,看下 bind
这个方法的底层逻辑实现的代码是什么样的,如下所示。
Function.prototype.bind = function (context, ...args) {
if (typeof this !== "function") {
throw new Error("this must be a function");
}
var self = this;
var fbound = function () {
self.apply(this instanceof self ? 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
。这样调用 bind
方法接收到函数的对象,再通过执行接收的函数,即可得到想要的结果。
总结
通过原理以及对底层逻辑的剖析,介绍了日常开发中经常用的 new
、apply
、call
、bind
这几种方法,最后动手进行了实践。
综上,可以看到这几个方法是有区别和联系的,通过下面的表格再来梳理一下这些方法的异同点,可以更好地理解。
方法/特征 | call | apply | bind |
---|---|---|---|
方法参数 | 多个 | 单个数组 | 多个 |
方法功能 | 函数调用改变this | 函数调用改变this | 函数调用改变this |
返回结果 | 直接执行 | 直接执行 | 返回待执行函数 |
底层实现 | 通过eval | 通过eval | 间接调用apply |
在日常的前端开发工作中,大家往往会忽视对这些方法的系统性学习,其实这些方法在高级 JavaScript
编程中经常出现,尤其是看一些比较好的开源项目,经常会通过“借用”的方式去复用已有的方法,来节约内存、优化代码。
而且这些方法的底层逻辑的实现,在前端面试中出现的频率也比较高,每个实现的方法细节也比较零散,很多开发者很难有一个系统的、整体的学习,造成了在面试过程中遇到此类手写底层 API 等问题时,容易临场发怵。