zoukankan      html  css  js  c++  java
  • js 实现call和apply方法,超详细思路分析

    壹 ❀ 引

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

    贰 ❀ 关于call与apply1

    贰 ✿ 壹 call与apply区别

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

    var fn = function (arg1, arg2) {
        // do something
    };
    
    fn.call(this, arg1, arg2); // 参数散列
    fn.apply(this, [arg1, arg2]) // 参数使用数组包裹
    

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

    贰 ✿ 贰 使用场景

    call与apply在日常开发中非常实用,我们在此列举几个实用的例子。

    检验数据类型:

    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类数组操作:

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

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

    叁 ❀ 实现一个call方法

    我们从一个简单的例子解析call方法

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

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

    • 修改了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模拟实现全部结束。bind实现存在部分不同,我另起了一篇文章,详情请见js 手动实现bind方法,超详细思路分析!

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

    参考

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

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

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

  • 相关阅读:
    QOMO Linux 4.0 正式版发布
    LinkChecker 8.1 发布,网页链接检查
    pgBadger 2.1 发布,PG 日志分析
    Aletheia 0.1.1 发布,HTTP 调试工具
    Teiid 8.2 Beta1 发布,数据虚拟化系统
    zLogFabric 2.2 发布,集中式日志存储系统
    开源电子工作套件 Arduino Start Kit 登场
    Piwik 1.9 发布,网站访问统计系统
    Ruby 1.9.3p286 发布,安全修复版本
    toBraille 1.1.2 发布,Java 盲文库
  • 原文地址:https://www.cnblogs.com/echolun/p/12144344.html
Copyright © 2011-2022 走看看