前端面试题 ---- 手撕JavaScript call apply bind 函数(超详细)
Posted ItDaChuang
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了前端面试题 ---- 手撕JavaScript call apply bind 函数(超详细)相关的知识,希望对你有一定的参考价值。
手撕javascript call apply bind 函数
一、call apply bind 函数的基本用法
首先这三个函数都是函数对象可用的三个方法,目的是为了改变调用者内部的 this 指向问题。下面简单介绍下他们的区别,详细内容可以看 其他文档 。
例如我们定义如下 Person 函数:
function Person(){
console.log(this.name)
}
Person(); // undefined
Person.call(); // undefined
Person.apply(); // undefined
let fun = Person.bind();
fun(); // undefined
我们就可以利用 Person 这个函数对象去调用 call、apply、bind 这三个方法,这是为什么呢?其实这几个方法都在 Function 构造函数的原型对象上,而我们定义的 Person 构造函数就是 Function 构造函数的实例对象,Person 的原型 ( _proto_ ) 就是指向 Function 的原型对象的,所以可以通过原型链找到这三个方法。
这三个函数都可以用于改变调用者内部的 this 指向的,第一个参数就是用于设置内部 this 所指的对象,之后可以有多个参数都将作为调用者的参数。
function Person(arg1,arg2){
console.log(this.name+arg1+arg2);
}
let o = {
name:"Dog"
}
Person("Arg1","Arg2"); // undefinedArg1Arg2
Person.call(o,"Arg1","Arg2"); // DogArg1Arg2
Person.apply(o,["Arg1","Arg2"]); // DogArg1Arg2
let fun = Person.bind(o,"Arg1","Arg2");
fun(); // DogArg1Arg2
从上述代码可以看出以下几点注意事项:
call 和 apply 函数的调用会直接运行调用者函数,不同之处是他们传入参数的形式不一样,call 是将参数一个一个的传入,apply 是将参数放入数组中传入。
bind 是暂时将调用者与要指定的 this 对象绑定在一起,但不执行调用者函数,可以暂时的保存这个绑定的函数,随后再执行。传参的形式与 call 一样。
底层原理:
为什么 Person.call(o) 就能将 Person 函数里的 this 指向 o 对象呢?其实这行代码相当于 o.Person()。即底层相当于在 o 对象中加入了一个 Person 方法。
function Person(){
console.log(this.name);
}
let o = {
name:"Dog"
}
Person.call(o); // 相当于下面的代码
o.Person = function(){
console.log(this.name);
};
o.Person();
/* 在调用 call 过程中 o 对象其实如下,之后 o 又恢复到原来的样子
o = {
name:"Dog",
Person:function(){
console.log(this.name);
}
}
*/
二、手写 myCall 方法
ES6 实现 myCall 方法
- 确定函数位置
因为我们要实现的 myCall 方法也是每个函数对象都能调用的方法,所以也要把这个方法写在 Function 的原型对象上。
Function.prototype.myCall = function(){
}
- 确定函数参数
在来考虑这个函数的参数,第一个参数是一个对象,用来修改调用者函数内部的 this 指向的对象,之后可以有多个参数,用于产给调用者函数作为参数的(这里可以用扩展操作符把第二个之后所有的参数都收集起来)。扩展操作符参考:10.6、参数扩展与收集
Function.prototype.myCall = function(obj,...args){
}
- 确定函数内部的 this 指向
这时,我们要明白一个事情就是在 myCall 内部的 this 指向什么?
在除了箭头函数中的所有函数中 this 永远指向它的调用者。那么在我们后面要调用 myCall 的语句中例如: Person.myCall(o)
可以知道,调用 myCall 的调用者就是一个函数 Person ,所以 myCall 函数内部的 this 就是指向这个 Person 调用者函数。
- 为对象内部添加调用者函数
接下来要做的就是将这个函数添加到传入的对象内部,让该对象拥有这个方法,可以调用。
Function.prototype.myCall = function(obj,...args){
obj.fun = this;
}
上述代码将调用 myCall 函数的调用者函数保存到了传入参数 obj 对象的内部 fun 属性(新建的属性)上,即现在的 obj 对象内部多了个 fun 方法,该方法名指向调用者函数。fun 这个属性名可以随便取,没有关系。
- 调用外部的调用者函数
obj 对象有了调用者函数后,因为 myCall 函数是要直接执行调用者函数的,所以在 myCall 内部直接执行该函数,这里需要将 myCall 第二个之后的参数作为调用者函数的参数传入,我们再次利用扩展操作符将 myCall 收集到的参数 args 数组展开。
Function.prototype.myCall = function(obj,...args){
obj.fun = this;
obj.fun(...args);
}
- 删除内部的调用者函数,恢复原有结构
执行完 fun 函数后,我们需要把这个属性给删除掉,不能改变了原 obj 对象的内部结构。
Function.prototype.myCall = function(obj,...args){
obj.fun = this;
obj.fun(...args);
delete obj.fun; // 删除添加的方法 fun
}
到这里,我们就基本实现了类似于 call 功能的 myCall 函数。不过还有一些细节地方需要处理。
- 完善第一个参数问题
问题一:就是当我们的第一个参数传入的是 null 时,我们自己写的 myCall 方法不能正常工作,而 call 可以。这时我们需要对参数 obj 进行一个处理。
Function.prototype.myCall = function(obj,...args){
obj = obj || window; // 当 obj 没传或传入 null 时,默认指向 window
obj.fun = this;
obj.fun(...args);
delete obj.fun;
}
- 完善返回值问题
问题二:还有一个问题就是调用者函数的返回值问题,如下第一段代码,这个时候我们上面所写的 myCall 方法不能很好的返回调用者函数的返回值。原 call 可以。
function Person(arg1,arg2){
console.log(this.name+arg1+arg2);
return 123;
}
let o = {
name:"Dog"
}
Function.prototype.myCall = function(obj,...args){
obj = obj || window;
obj.fun = this;
obj.fun(...args);
delete obj.fun;
}
let res1 = Person.myCall(o,"Arg1","Arg2"); // DogArg1Arg2
console.log(res1); // undefined
let res2 = Person.call(o,"Arg1","Arg2"); // DogArg1Arg2
console.log(res2); // 123
- 完整版的 myCall 函数
此时只需要将 myCall 内部调用外部调用者函数的结果保存并最终返回即可。如下第二段代码。
Function.prototype.myCall = function(obj,...args){
obj = obj || window;
obj.fun = this;
let res = obj.fun(...args); // 将调用者函数运行的结果保存
delete obj.fun;
return res; // 将调用者函数运行的结果返回
}
上述代码就是 ES6 中最终的手写 myCall 方法。
ES5 实现 myCall 方法
用 ES5 实现 myCall 的主要问题是不能用 ES6 中的扩展语法实现参数的传递。这时主要就是去解决参数的传递问题。这时 myCall 的参数我们可以用内置的 arguments 来接收。但是 myCall 的第一个是不需要作为内部调用者函数执行的参数的。
Function.prototype.myCall = function(obj){
obj = obj || window;
obj.fun = this;
// 将传入的第二个之后的参数要传入调用者函数,下行代码只是示例期望的样子,不符合语法的。
let res = obj.fun(arguments[1],arguments[2],...);
delete obj.fun;
return res; // 将调用者函数运行的结果返回
}
当不使用 ES6 的扩展语法时,我们可以采用 eval 函数(能够解析 JavaScript 代码)。
我们需要将执行调用者函数的语句写成字符串的形式作为 eval 函数的参数传入如下示例,需要解决的问题是如何将 fun 的参数动态的生成如下的形式。
eval("obj.fun(arguments[1],arguments[2],...)");
这里可以新建一个数组,用来保存 myCall 传入的第二个及之后的所有参数。如下代码:
Function.prototype.myCall = function(obj){
obj = obj || window;
obj.fun = this;
const arrs = [];
for(let i = 1;i<arguments.length;i++){
arrs.push(arguments[i]);
}
}
接下来需要将数组里的内容按, 分开插入eval 函数的相应位置即可。
- 完整版的 myCall 函数
Function.prototype.myCall = function(obj){
obj = obj || window;
obj.fun = this;
const arrs = [];
for(let i = 1;i<arguments.length;i++){
arrs.push("arguments["+ i +"]");
}
// 此时 arrs 就是:[arguments[1],arguments[2],...]
eval("obj.fun("+ arrs +")");
delete obj.fun;
return res; // 将调用者函数运行的结果返回
}
这样我们 ES6 之前的手写 myCall 版本也就实现了。
上述代码中要理解一个知识点就是。当把数组加一个空字符串打印时,就是把数组的所有元素以 ,
逗号分割打印:
arr1 = [1,2,3,4];
console.log(arr1); // [1,2,3,4]
console.log(arr1+""); // 1,2,3,4
三、手写 myApply 方法
有了前面 myCall 方法的分析过程和 call 与 apply 函数的细微区别,我们可以很容易改造出自己的 myApply 方法。
- ES6 之后版本
Function.prototype.myApply = function(obj,args){
obj = obj || window;
obj.fun = this;
let res = null;
if(!args){
res = obj.fun();
}else{
res = obj.fun(...args);
}
delete obj.fun;
return res; // 将调用者函数运行的结果返回
}
- ES6 之前版本
Function.prototype.myCall = function(obj,args){
obj = obj || window;
obj.fun = this;
const arrs = [];
let res = null;
if(!args){
res = obj.fun();
}else{
for(let i = 0;i<args.length;i++){
arrs.push("args["+ i +"]");
}
eval("obj.fun("+ arrs +")");
}
delete obj.fun;
return res; // 将调用者函数运行的结果返回
}
四、手写 myBind 方法
bind 的不同之处是 bind 函数返回的是一个函数,而不执行调用者函数。第二是 bind 函数具有柯里化特性的即:参数可以在绑定 this 时传入一部分,在调用时再传入一部分。
function Person(a,b){
console.log(this.name+a+b);
}
const o = {
name:"Dog"
}
Person.bind(o,1)(2); // Dog12
上述代码中第七行的 Person.bind(o,1)
是一个函数,如果没有后面的括号2 (2)
就不会调用,有了括号才调用的,并且参数是在绑定 this 时传入了 a 为 1,调用时传入来了 b 为 2。
在写自己的 myBind 方法时,内部的 this 绑定同 myCall 和 myApply 一样,主要解决返回值和柯里化特性。myBind 要返回一个函数,所以基本结构如下:
Function.prototype.myBind = function(){
return function(){
}
}
上述代码中要分析清楚两个地方的 this 指向问题:
Function.prototype.myBind = function(){
// 这里的 this 指向调用者函数
return function(){
// 这里的 this 指向 window
}
}
弄清楚上面的两个位置的 this 指向后,最终 myBind 函数是要返回调用者函数(可能修改内部的this)的。所以我们要在 return 的函数外部把外部的调用者函数保存起来(如下第 2 行)。然后在 return 返回的函数内部使用(这里就形成了闭包)。当然这里也可以 return 一个箭头函数,那么箭头函数里面的 this 就是外部环境中的 this 了,即调用者函数。
绑定时的参数传入保存在数组 args1 中,如下第4行。调用时的参数保存在 args2 中,如下第 8 行。返回的函数中可以直接调用 apply 函数,并将外部传入的 obj 和所有的参数传入即可。
Function.prototype.myBind = function(obj){
let that = this; // 1.保存外部的调用者函数
// 2. args1 以数组的形式保存绑定时传入的第二个之后的参数
let args1 = Array.prototype.slice.call(arguments,1);
let args2 = null; // 保存调用时传入的参数
return function(){
// 3. args2 以数组的形式保存调用时传入的参数
args2 = Array.prototype.slice.call(arguments);
// 4. 将 args1 和 args2 参数拼接后作为 apply 方法参数
that.apply(obj,args1.concat(args2));
}
}
以上是关于前端面试题 ---- 手撕JavaScript call apply bind 函数(超详细)的主要内容,如果未能解决你的问题,请参考以下文章
手撕前端javascript面试题---快速排序 | 全排列 | instanceof
JavaScript手撕前端面试题:事件委托 | 判断URL是否合法 | 全排列
JavaScript手撕前端面试题:手写new操作符 | 手写Object.freeze
JavaScript手撕前端面试题:手写Object.create | 手写Function.call | 手写Function.bind