共计 9857 个字符,预计需要花费 25 分钟才能阅读完成。
call、apply、bind 都是和
this
指向有关的,这三个方法是 JavaScript 内置对象Function
的原型的方法。
基本介绍
1. 语法
func.call(thisArg, param1, param2, ...)//func 是个函数
func.apply(thisArg, [param1,param2,...])
func.bind(thisArg, param1, param2, ...)
返回值:
call / apply:返回 func
执行的结果;
bind:返回func
的拷贝,并拥有指定的 this
值和初始参数。
参数:
thisArg
(可选):
func
的this
指向thisArg
对象;
- 非严格模式下:若
thisArg
指定为 null,undefined,则func
的this
指向 window 对象; - 严格模式下:
func
的this
为undefined
; - 值为原始值 (数字,字符串,布尔值) 的 this 会指向该原始值的自动包装对象,如 String、Number、Boolean。
param1
,param2
(可选): 传给 func
的参数。
- 如果 param 不传或为 null/undefined,则表示不需要传入任何参数.
- apply 第二个参数为类数组对象,数组内各项的值为传给
func
的参数。
2. 必须是函数才能调用 call/apply/bind
call、apply 和 bind 是挂在 Function
对象上的三个方法,只有函数才有这些方法。只要是函数就可以调用它们,比如: Object.prototype.toString
就是个函数,我们经常看到这样的用法:Object.prototype.toString.call(data)
。
3. 作用
改变函数执行时的 this
指向,目前所有关于它们的运用,都是基于这一点来进行的。
4. 如何不弄混 call 和 apply
弄混这两个方法的不在少数,不要小看这个问题,记住下面的这个方法就好了。双 a 记忆法:apply
是以 a 开头,它传给 func
的参数是类 Array 对象(类数组对象),也是以 a 开头的。
call 与 apply 的唯一区别
传给 func
的参数写法不同:
- apply 是第 2 个参数,这个参数是一个类数组对象:传给
func
参数都写在数组中。 - call 从第 2~n 的参数都是传给
func
的。
call/apply 与 bind 的区别
执行:
- call/apply 改变了函数的
this
的指向并马上 执行该函数; - bind 则是返回改变了
this
指向后的函数,不执行该函数。
返回值:
- call/apply 返回
func
的执行结果; - bind 返回
func
的拷贝,并指定了func
的this
指向,保存了func
的参数。
返回值这段在下方 bind 应用中有详细的示例解析。
什么是类数组?
先说数组,这我们都熟悉。它的特征有:可以通过索引 (index) 调用,如 array[0];具有长度属性 length;可以通过 for 循环或 forEach 方法,进行遍历。
那么,类数组是什么呢?顾名思义,就是 具备与数组特征类似的对象。比如,下面的这个对象,就是一个类数组。
let arrayLike = {
0: 1,
1: 2,
2: 3,
length: 3
};
类数组 arrayLike 可以通过角标进行调用,具有 length 属性,同时也可以通过 for 循环进行遍历。
类数组,还是比较常用的,只是我们平时可能没注意到。比如,我们获取 DOM 节点的方法,返回的就是一个类数组;再比如,在一个函数体中使用 arguments 获取到的所有参数,也是一个类数组。
但是需要注意的是:类数组无法使用 forEach、splice、push 等数组原型链上的方法,毕竟它不是真正的数组。
call/apply/bind 的核心理念:借用方法
什么是借用方法,让我们打个比方。
生活中:
平时没时间做饭的我,周末想给家人炖个牛肉火锅尝尝。但是没有适合的锅,而我又不想出去买,所以就向邻居借了一个锅来用,这样既达到了目的,又节省了开支,一举两得。
程序中:
A 对象有个方法,B 对象因为某种原因也需要用到同样的方法,那么这时候我们是单独为 B 对象扩展一个方法呢,还是借用一下 A 对象的方法呢?
当然是借用 A 对象的方法更便捷,既达到了目的,又节省了内存。
这就是 call/apply/bind 的核心理念:借用方法 。 借助已实现的方法,改变方法中数据的 this 指向,减少重复代码,节省内存。
还记得刚才的类数组么?如果它想使用 Array 原型链上的 slice 方法,可以这样:
let domNodes = Array.prototype.slice.call(document.getElementsByTagName("*"));
以此类推,domNodes 就可以应用 Array 下的其他方法了。
应用场景
这些应用场景,多加体会就可以发现它们的理念都是:借用方法。
1. 判断数据类型
Object.prototype.toString
用来判断类型再合适不过,借用它我们几乎可以判断所有类型的数据:
function isType(data, type) {
const typeObj = {"[object String]": "string",
"[object Number]": "number",
"[object Boolean]": "boolean",
"[object Null]": "null",
"[object Undefined]": "undefined",
"[object Object]": "object",
"[object Array]": "array",
"[object Function]": "function",
"[object Date]": "date", // Object.prototype.toString.call(new Date())
"[object RegExp]": "regExp",
"[object Map]": "map",
"[object Set]": "set",
"[object HTMLDivElement]": "dom", // document.querySelector('#app')
"[object WeakMap]": "weakMap",
"[object Window]": "window", // Object.prototype.toString.call(window)
"[object Error]": "error", // new Error('1')
"[object Arguments]": "arguments"
};
let name = Object.prototype.toString.call(data); // 借用 Object.prototype.toString()获取数据类型
let typeName = typeObj[name] || "未知类型"; // 匹配数据类型
return typeName === type; // 判断该数据类型是否为传入的类型
}
console.log(isType({}, "object"), //>> true
isType([], "array"), //>> true
isType(new Date(), "object"), //>> false
isType(new Date(), "date") //>> true
);
2. 类数组对象借用数组的方法
因为类数组对象不是真正的数组,所以没有数组类型上自带的一些方法,所以我们需要去借用数组的方法。
比如借用数组的 push 方法:
// 类数组对象
var arrayLike = {
0: "OB",
1: "Koro1",
length: 2
};
Array.prototype.push.call(arrayLike, "添加数组项 1", "添加数组项 2");
console.log(arrayLike);
//>> {"0":"OB","1":"Koro1","2":"添加数组项 1","3":"添加数组项 2","length":4}
3. apply 获取数组最大值最小值
apply 直接传递数组做要调用方法的参数,也省一步展开数组,比如使用 Math.max、Math.min 来获取数组的最大值 / 最小值。
const arr = [15, 6, 12, 13, 16];
const max = Math.max.apply(Math, arr); // 16
const min = Math.min.apply(Math, arr); // 6
4. 继承
ES5 的继承也都是通过借用父类的构造方法来实现父类方法 / 属性的继承:
// 父类
function supFather(name) {
this.name = name;
this.colors = ['red', 'blue', 'green']; // 复杂类型
}
supFather.prototype.sayName = function (age) {console.log(this.name, 'age');
};
// 子类
function sub(name, age) {
// 借用父类的方法:修改它的 this 指向, 赋值父类的构造函数里面方法、属性到子类上
supFather.call(this, name);
this.age = age;
}
// 重写子类的 prototype,修正 constructor 指向
function inheritPrototype(sonFn, fatherFn) {sonFn.prototype = Object.create(fatherFn.prototype); // 继承父类的属性以及方法
sonFn.prototype.constructor = sonFn; // 修正 constructor 指向到继承的那个函数上
}
inheritPrototype(sub, supFather);
sub.prototype.sayAge = function () {console.log(this.age, 'foo');
};
// 实例化子类,可以在实例上找到属性、方法
const instance1 = new sub("OBKoro1", 24);
const instance2 = new sub("小明", 18);
instance1.colors.push('black')
console.log(instance1);
//>> {"name":"OBKoro1","colors":["red","blue","green","black"],"age":24}
console.log(instance2);
//>> {"name":"小明","colors":["red","blue","green"],"age":18}
类似的应用场景还有很多,就不再一一列举了。关键在于它们借用方法的理念,若不理解的话可以多看几遍。
5. call、apply 应该用哪个?
call,apply 的效果完全一样,它们的区别也在于
- 参数数量 / 顺序确定就用 call,参数数量 / 顺序不确定的话就用 apply。
- 考虑可读性:参数数量不多就用 call,参数数量比较多的话,把参数整合成数组,使用 apply。
- 参数集合已经是一个数组的情况,用 apply,比如上文的获取数组最大值 / 最小值。
参数数量 / 顺序不确定的话就用 apply,比如以下示例:
const obj = {
age: 24,
name: 'OBKoro1',
}
const obj2 = {age: 777}
callObj(obj, handle);
callObj(obj2, handle);
// 根据某些条件来决定要传递参数的数量、以及顺序
function callObj(thisAge, fn) {let params = [];
if (thisAge.name) {params.push(thisAge.name);
}
if (thisAge.age) {params.push(thisAge.age);
}
fn.apply(thisAge, params); // 数量和顺序不确定 不能使用 call
}
function handle(...params) {console.log('params', params); // do some thing
}
6. bind 的应用场景
保存函数参数
首先来看下一道经典的面试题:
for (var i = 1; i <= 5; i++) {setTimeout(function test() {console.log(i) //>> 6 6 6 6 6
}, i * 1000);
}
造成这个现象的原因是等到 setTimeout 异步执行时,i 已经变成 6 了。那么如何使他输出: 1,2,3,4,5 呢?可以通过 bind 来巧妙实现。
for (var i = 1; i <= 5; i++) {
// 缓存参数
setTimeout(function (i) {console.log('bind', i) //>> 1 2 3 4 5
}.bind(null, i), i * 1000);
}
实际上这里也用了闭包,我们知道 bind 会返回一个函数,这个函数也是闭包
它保存了函数的 this 指向、初始参数,每次 i 的变更都会被 bind 的闭包存起来,所以输出 1 -5。具体细节,下面有个手写 bind 方法,详细阅读一下就能搞明白。
回调函数 this 丢失问题
这是一个常见的问题,调试了一番才发现是 this 指向丢失的问题。
class Page {constructor(callBack) {
this.className = 'Page';
this.MessageCallBack = callBack; // 回调函数
this.MessageCallBack('发给注册页面的信息'); // 执行 PageA 的回调函数
}
}
class PageA {constructor() {
this.className = 'PageA';
// 问题在下面这句
this.pageClass = new Page(this.handleMessage);// 注册页面 传递回调函数
}
// 与页面通信回调
handleMessage(msg) {console.log('处理通信', this.className, msg); // 'Page' this 指向错误
}
}
new PageA();
回调函数 this 为何会丢失?显然声明的时候不会出现问题,执行回调函数的时候也不可能出现问题。问题出在传递回调函数的时候:
this.pageClass = new Page(this.handleMessage);
因为传递过去的 this.handleMessage
是函数内存地址,没有附带上下文对象,也就是说该函数 this.handleMessage
没有绑定它的 this 指向。
既然知道问题了,那我们只要绑定回调函数的 this 指向为 PageA 就解决问题了。
回调函数 this 丢失的解决方案
(1)bind 绑定回调函数的 this 指向:
这是典型 bind 的应用场景, 绑定 this 指向,用做回调函数。
this.pageClass = new Page(this.handleMessage.bind(this));
// 绑定回调函数的 this 指向
(2)用箭头函数绑定 this 指向:
箭头函数的 this 指向定义的时候外层第一个普通函数的 this,在这里指的是 class 类:PageA
this.pageClass = new Page(() => this.handleMessage());
// 箭头函数绑定 this 指向
用原生 JavaScript 实现 call/apply、bind
手写实现 call、apply、bind(特别是 bind)一直是比较高频的面试题,在这里我们也一起来实现一下这几个函数。
1. 实现 call
实现思路
- 参考 call 的语法规则,需要设置一个参数
thisArg
,也就是 this 的指向 - 将
thisArg
封装为一个 Object - 通过为
thisArg
创建一个临时方法,这样thisArg
就是调用该临时方法的对象了,会将该临时方法的 this 隐式指向到thisArg
上 - 执行
thisArg
的临时方法,并传递参数 - 删除临时方法,返回方法的执行结果
/**
* 用原生 JavaScript 实现 call
*/
Function.prototype.myCall = function(thisArg, ...arr) {
//1. 判断参数合法性 /////////////////////////
if (thisArg === null || thisArg === undefined) {// 指定为 null 和 undefined 的 this 值会自动指向全局对象(浏览器中为 window)
thisArg = window;
} else {thisArg = Object(thisArg);// 创建一个可包含数字 / 字符串 / 布尔值的对象,//thisArg 会指向一个包含该原始值的对象。}
//2. 搞定 this 的指向 /////////////////////////
const specialMethod = Symbol("anything"); // 创建一个不重复的常量
// 如果调用 myCall 的函数名是 func,也即以 func.myCall()形式调用;// 根据上篇文章介绍,则 myCall 函数体内的 this 指向 func
thisArg[specialMethod] = this; // 给 thisArg 对象建一个临时属性来储存 this(也即 func 函数)// 进一步地,根据上篇文章介绍,func 作为 thisArg 对象的一个方法被调用,那么 func 中的 this 便
// 指向 thisArg 对象。由此,巧妙地完成将 this 隐式地指向到 thisArg!let result = thisArg[specialMethod](...arr);
//3. 收尾
delete thisArg[specialMethod]; // 删除临时方法
return result; // 返回临时方法的执行结果
};
let obj = {name: "coffe1891"};
function func() {console.log(this.name);
}
func.myCall(obj);//>> coffe1891
认真读一下注释,基本就能理解了,主要还是用到判断 this
指向的规则。
多讲一下如何正确地判断 thisArg:
上面代码判断 thisArg
的写法是经过调整后的严谨写法,因为之前发现很多前端工程师判断参数 thisArg,只是简单的以是否为 false 来判断,比如:
// 不够严谨
thisArg = thisArg ? Object(thisArg) : window;
thisArg = thisArg || window;
经过测试,以下三种为 false 的情况,thisArg 都会意外地绑定到 window 上:
// 网上的方案: thisArg = thisArg || window;
// 这种方案不严谨,代码举例:// 参数明明传了值,本意不是要让 thisArg 指向 window,然后结果却意外地指向了 window
handle.elseCall(''); // window
handle.elseCall(0); // window
handle.elseCall(false); // window
所以应该严谨一点儿判断,如下:
// 严谨的判断
if (thisArg === null || thisArg === undefined) {// 指定为 null 和 undefined 的 this 值会自动指向全局对象(浏览器中为 window)
thisArg = window;
} else {thisArg = Object(thisArg);
// 创建一个包含原始值(数字,字符串,布尔值)的对象,//thisArg 会指向一个包含该原始值的对象。}
2. 实现 Apply
实现思路
- 传递给函数的参数处理,不太一样,其他部分跟 call 一样;
- apply 接受第二个参数为类数组对象
/**
* 用原生 JavaScript 实现 apply
*/
Function.prototype.myApply = function(thisArg) {if (thisArg === null || thisArg === undefined) {thisArg = window;} else {thisArg = Object(thisArg);
}
// 判断是否为【类数组对象】function isArrayLike(o) {
if (
o && // o 不是 null、undefined 等
typeof o === "object" && // o 是对象
isFinite(o.length) && // o.length 是有限数值
o.length >= 0 && // o.length 为非负值
o.length === Math.floor(o.length) && // o.length 是整数
o.length < 4294967296
)
// o.length < 2^32
return true;
else return false;
}
const specialMethod = Symbol("anything");
thisArg[specialMethod] = this;
let args = arguments[1]; // 获取参数数组
let result;
// 处理传进来的第二个参数
if (args) {
// 是否传递第二个参数
if (!Array.isArray(args) && !isArrayLike(args)) {
throw new TypeError("第二个参数既不为数组,也不为类数组对象。抛出错误");
} else {args = Array.from(args); // 转为数组
result = thisArg[specialMethod](...args); // 执行函数并展开数组,传递函数参数
}
} else {result = thisArg[specialMethod]();}
delete thisArg[specialMethod];
return result; // 返回函数执行结果
};
3. 实现 bind
实现思路
(1)拷贝调用函数:
- 调用函数,也即调用
myBind
的函数,用一个变量临时储存它; - 使用
Object.create
复制调用函数的 prototype 给funcForBind
;
(2)返回拷贝的函数funcForBind
;
(3)调用拷贝的函数funcForBind
:
-
new
调用判断:通过instanceof
判断函数是否通过new
调用,来决定绑定的 context; - 通过
call
绑定 this、传递参数; - 返回调用函数的执行结果。
这里给出一种基于 ES6 的实现(基于老版 ES 的实现方式也不少,但没有 ES6 的 spread 和 rest 操作符实现简洁):
/**
* 用原生 JavaScript 实现 bind
*/
Function.prototype.myBind = function(objThis, ...params) {const thisFn = this;// 存储调用函数,以及上方的 params(函数参数)
// 对返回的函数 secondParams 二次传参
let funcForBind = function(...secondParams) {
// 检查 this 是否是 funcForBind 的实例?也就是检查 funcForBind 是否通过 new 调用
const isNew = this instanceof funcForBind;
//new 调用就绑定到 this 上, 否则就绑定到传入的 objThis 上
const thisArg = isNew ? this : Object(objThis);
// 用 call 执行调用函数,绑定 this 的指向,并传递参数。返回执行结果
return thisFn.call(thisArg, ...params, ...secondParams);
};
// 复制调用函数的 prototype 给 funcForBind
funcForBind.prototype = Object.create(thisFn.prototype);
return funcForBind;// 返回拷贝的函数
};
上面的代码第 7 行可能不好理解,二次传参 (secondParams) 是说什么?举个例子:
let func = function(p,secondParams){// 其实测试用的 func 其参数可以是任意多个
console.log(p.name);
console.log(this.name);
console.log(secondParams);
}
let obj={name:"1891"}
func.myBind(obj,{name:"coffe"})("二次传参");
//>> coffe
//>> 1891
//>> 二次传参
上面第 8 行调用 myBind 之后,会返回一个新函数,然后再给这个新函数传入参数,就是二次传参。