js 实现call和apply方法,超详细思路分析

壹 ❀ 引

我在 五种绑定策略完全弄懂this 一文中,咱们提到call,apply,bind属于显示绑定,这三个方法都能直接修改this指向。其中call与apply比较特殊,它们在修改this的同时还会直接执行方法,而bind只是返回一个修改完this的boundFunction并未执行,那么今天咱们来说讲若是经过JavaScript模拟实现call与apply方法。javascript

贰 ❀ 关于call与apply1

贰 ✿ 壹 call与apply区别

除了都能改变this指向并执行函数,call与apply惟一区别在于参数不一样,具体以下:html

var fn = function (arg1, arg2) {
    // do something
};

fn.call(this, arg1, arg2); // 参数散列
fn.apply(this, [arg1, arg2]) // 参数使用数组包裹

call第一参数为this指向,后续散列参数均为函数调用所需形参,而在apply中这些参数被包裹在一个数组中。java

贰 ✿ 贰 使用场景

call与apply在平常开发中很是实用,咱们在此列举几个实用的例子。git

检验数据类型:github

function type(obj) {
    var regexp = /\s(\w+)\]/;
    var result =  regexp.exec(Object.prototype.toString.call(obj))[1];
    return result;
};

console.log(type([123]));//Array
console.log(type('123'));//String
console.log(type(123));//Number
console.log(type(null));//Null
console.log(type(undefined));//Undefined

数组取最大/小值:数组

var arr = [11, 1, 0, 2, 3, 5];
// 取最大
var max1 = Math.max.call(null, ...arr);
var max2 = Math.max.apply(null, arr);
// 取最小
var min1 = Math.min.call(null, ...arr);
var min2 = Math.min.apply(null, arr);

console.log(max1); //11
console.log(max2); //11
console.log(min1); //0
console.log(min2); //0

函数arguments类数组操做:markdown

var fn = function () {
    var arr = Array.prototype.slice.call(arguments);
    console.log(arr); //[1, 2, 3, 4]
};
fn(1, 2, 3, 4);

关于这两个方法实用简单说到这里,毕竟本文的核心主旨是手动实现call与apply方法,咱们接着说。app

叁 ❀ 实现一个call方法

咱们从一个简单的例子解析call方法函数

var name = '时间跳跃';
var obj = {
    name: '听风是风'
};

function fn() {
    console.log(this.name);
};
fn(); //时间跳跃
fn.call(obj); //听风是风

在这个例子中,call方法主要作了两件事:this

  • 修改了this指向,好比fn()默认指向window,因此输出时间跳跃
  • 执行了函数fn

叁 ✿ 壹 改变this并执行方法

先说第一步改变this怎么实现,其实很简单,只要将方法fn添加成对象obj的属性不就行了。因此咱们能够这样:

//模拟call方法
Function.prototype.call_ = function (obj) {
    obj.fn = this; // 此时this就是函数fn
    obj.fn(); // 执行fn
    delete obj.fn; //删除fn
};
fn.call_(obj); // 听风是风

注意,这里的call_是咱们模拟的call方法,咱们来解释模拟方法中作了什么。

  • 咱们经过Function.prototype.call_的形式绑定了call_方法,因此全部函数均可以直接访问call_
  • fn.call_属于this隐式绑定,因此在执行时call_时内部this指向fn,这里的obj.fn = this就是将方法fn赋予成了obj的一条属性。
  • obj如今已经有了fn方法,执行obj.fn,由于隐式绑定的问题,fn内部的this指向obj,因此输出了听风是风
  • 最后经过delete删除了obj上的fn方法,毕竟执行完不删除会致使obj上的属性愈来愈多。

叁 ✿ 贰 传参

咱们成功改变了this指向并执行了方法,但仍有一个问题待解决,call_没法接受参数。

其实也不难,咱们知道函数有一个arguments属性,代指函数接收的全部参数,它是一个类数组,好比下方例子:

Function.prototype.call_ = function (obj) {
    console.log(arguments);
};
fn.call_(obj, 1, 2, 3);// [{name:'听风是风'},1,2,3...]

很明显arguments第一位参数是咱们须要让this指向的对象,因此从下标1开始才是真正的函数参数,这里就得对arguments进行加工,将下标1以后的参数剪切出来。

有同窗确定就想到了arguments.splice,前面说了arguments并不是数组,因此不支持Array方法。不要紧,不是还有Array.prototype.slice.call(arguments)吗,转一次数组再用。很遗憾,咱们如今是在模拟call方法,也不行。那就用最保险的for循环吧,以下:

Function.prototype.call_ = function (obj) {
    var args = [];
    // 注意i从1开始
    for (var i = 1, len = arguments.length; i < len; i++) {
        args.push(arguments[i]);
    }; 
    console.log(args);// [1, 2, 3]
};
fn.call_(obj, 1, 2, 3);

数组也不能直接做为参数传递给函数,有同窗可能想到array.join字符拼接方法,这也存在一个问题,好比咱们是但愿传递参数1 2 3三个参数进去,但通过join方法拼接,它会变成一个参数"1,2,3",函数此时接受的就只有一个参数了。

因此这里咱们不得不借用恶魔方法eval,看个简单的例子:

var fn = function (a, b, c) {
    console.log(a + b + c);
};
var arr = [1, 2, 3];

fn(1, 2, 3);//6
eval("fn(" + arr + ")");//6

你必定有疑问,为何这里数组arr都不分割一下,fn在执行时又如何分割数组呢?其实eval在执行时会将变量转为字符串,这里隐性执行了arr.toString()。来看个有趣的对比:

console.log([1, 2, 3].toString()); //"1,2,3"
console.log([1, 2, 3].join(',')); //"1,2,3"

能够看出`eval帮咱们作了数组处理,这里就不须要再使用join方法了,所以eval("fn(" + arr + ")")能够当作eval("fn(1,2,3)")

咱们整理下上面的思路,改写后的模拟方法就是这样:

var name = '时间跳跃';
var obj = {
    name: '听风是风'
};

function fn(a, b, c) {
    console.log(a + b + c + this.name);
};
//模拟call方法
Function.prototype.call_ = function (obj) {
    var args = [];
    // 注意i从1开始
    for (var i = 1, len = arguments.length; i < len; i++) {
        args.push(arguments[i]);
    };
    obj.fn = this; // 此时this就是函数fn
    eval("obj.fn(" + args + ")"); // 执行fn
    delete obj.fn; //删除fn
};
fn.call_(obj, "个人", "名字", "是");

能够了吗?很遗憾,这段代码会报错。由于咱们传递的后三个参数都是字符串。在args.push(arguments[i])这一步咱们提早将字符串进行了解析,这就致使eval在执行时,表达式变成了eval("obj.fn(个人,名字,是)");设想一下咱们普通调用函数的形式是这样obj.fn("个人","名字","是"),因此对于eval而言就像传递了三个没加引号的字符串,没法进行解析。

不信咱们能够传递三个数字,好比:

fn.call_(obj, 1,2,3); // 6听风是风

由于数字无论加不加引号,做为函数参数都是可解析的,而字符串不加引号,那就被认为是一个变量,而不存在个人这样的变量,天然就报错了。

怎么办呢?其实咱们能够在args.push(arguments[i])这里先不急着解析,改写成这样:

args.push("arguments[" + i + "]");

遍历完成的数组args最终就是这个样子["arguments[1]","arguments[2]","arguments[3]"],当执行eval时,arguments[1]此时确实是做为一个变量存在不会报错,因而被eval解析成了一个真正的字符传递给了函数。

因此改写后的call_应该是这样:

var name = '时间跳跃';
var obj = {
    name: '听风是风'
};

function fn(a, b, c) {
    console.log(a + b + c + this.name);
};
//模拟call方法
Function.prototype.call_ = function (obj) {
    var args = [];
    // 注意i从1开始
    for (var i = 1, len = arguments.length; i < len; i++) {
        args.push("arguments[" + i + "]");
    };
    obj.fn = this; // 此时this就是函数fn
    eval("obj.fn(" + args + ")"); // 执行fn
    delete obj.fn; //删除fn
};
fn.call_(obj, "个人", "名字", "是"); // 个人名字是听风是风

叁 ✿ 叁 考虑特殊this指向

咱们知道,当call第一个参数为undefined或者null时,this默认指向window,因此上面的方法还不够完美,咱们进行最后一次改写,考虑传递参数是不是有效对象:

var name = '时间跳跃';
var obj = {
    name: '听风是风'
};

function fn(a, b, c) {
    console.log(a + b + c + this.name);
};
//模拟call方法
Function.prototype.call_ = function (obj) {
    //判断是否为null或者undefined,同时考虑传递参数不是对象状况
    obj = obj ? Object(obj) : window;
    var args = [];
    // 注意i从1开始
    for (var i = 1, len = arguments.length; i < len; i++) {
        args.push("arguments[" + i + "]");
    };
    obj.fn = this; // 此时this就是函数fn
    eval("obj.fn(" + args + ")"); // 执行fn
    delete obj.fn; //删除fn
};
fn.call_(obj, "个人", "名字", "是"); // 个人名字是听风是风
fn.call_(null, "个人", "名字", "是"); // 个人名字是时间跳跃
fn.call_(undefined, "个人", "名字", "是"); // 个人名字是时间跳跃

那么到这里,对于call方法的模拟就完成了。

肆 ❀ 实现一个apply方法

apply方法由于接受的参数是一个数组,因此模拟起来就更简单了,理解了call实现,咱们就直接上代码:

var name = '时间跳跃';
var obj = {
    name: '听风是风'
};

function fn(a, b, c) {
    console.log(a + b + c + this.name);
};
//模拟call方法
Function.prototype.apply_ = function (obj, arr) {
    obj = obj ? Object(obj) : window;
    obj.fn = this;
    if (!arr) {
        obj.fn();
    } else {
        var args = [];
        // 注意这里的i从0开始
        for (var i = 0, len = arr.length; i < len; i++) {
            args.push("arr[" + i + "]");
        };
        eval("obj.fn(" + args + ")"); // 执行fn
    };
    delete obj.fn; //删除fn
};
fn.apply_(obj, ["个人", "名字", "是"]); // 个人名字是听风是风
fn.apply_(null, ["个人", "名字", "是"]); // 个人名字是时间跳跃
fn.apply_(undefined, ["个人", "名字", "是"]); // 个人名字是时间跳跃

伍 ❀ 总

上述代码总有些繁杂,咱们来总结下这两个方法:

// call模拟
Function.prototype.call_ = function (obj) {
    //判断是否为null或者undefined,同时考虑传递参数不是对象状况
    obj = obj ? Object(obj) : window;
    var args = [];
    // 注意i从1开始
    for (var i = 1, len = arguments.length; i < len; i++) {
        args.push("arguments[" + i + "]");
    };
    obj.fn = this; // 此时this就是函数fn
    var result = eval("obj.fn(" + args + ")"); // 执行fn
    delete obj.fn; //删除fn
    return result;
};
// apply模拟
Function.prototype.apply_ = function (obj, arr) {
    obj = obj ? Object(obj) : window;
    obj.fn = this;
    var result;
    if (!arr) {
        result = obj.fn();
    } else {
        var args = [];
        // 注意这里的i从0开始
        for (var i = 0, len = arr.length; i < len; i++) {
            args.push("arr[" + i + "]");
        };
        result = eval("obj.fn(" + args + ")"); // 执行fn
    };
    delete obj.fn; //删除fn
    return result;
};

若是容许使用ES6,使用拓展运算符会简单不少,实现以下:

// ES6 call
Function.prototype.call_ = function (obj) {
    obj = obj ? Object(obj) : window;
    obj.fn = this;
    // 利用拓展运算符直接将arguments转为数组
    let args = [...arguments].slice(1);
    let result = obj.fn(...args);

    delete obj.fn
    return result;
};
// ES6 apply
Function.prototype.apply_ = function (obj, arr) {
    obj = obj ? Object(obj) : window;
    obj.fn = this;
    let result;
    if (!arr) {
        result = obj.fn();
    } else {
        result = obj.fn(...arr);
    };

    delete obj.fn
    return result;
};

那么到这里,关于call与apply模拟实现所有结束。

这篇文章也是第一篇我使用markdown书写的文章,为了统同样式,我也专门修改了博客样式。

参考

JavaScript深刻之call和apply的模拟实现

深刻浅出 妙用Javascript中apply、call、bind

深度解析 call 和 apply 原理、使用场景及实现

相关文章
相关标签/搜索