New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
mqyqingfeng opened this Issue May 2, 2017 · 66 comments

Comments

Projects
None yet
@mqyqingfeng
Owner

mqyqingfeng commented May 2, 2017

call

一句话介绍 call:

call() 方法在使用一个指定的 this 值和若干个指定的参数值的前提下调用某个函数或方法。

举个例子:

var foo = {
    value: 1
};

function bar() {
    console.log(this.value);
}

bar.call(foo); // 1

注意两点:

  1. call 改变了 this 的指向,指向到 foo
  2. bar 函数执行了

模拟实现第一步

那么我们该怎么模拟实现这两个效果呢?

试想当调用 call 的时候,把 foo 对象改造成如下:

var foo = {
    value: 1,
    bar: function() {
        console.log(this.value)
    }
};

foo.bar(); // 1

这个时候 this 就指向了 foo,是不是很简单呢?

但是这样却给 foo 对象本身添加了一个属性,这可不行呐!

不过也不用担心,我们用 delete 再删除它不就好了~

所以我们模拟的步骤可以分为:

  1. 将函数设为对象的属性
  2. 执行该函数
  3. 删除该函数

以上个例子为例,就是:

// 第一步
foo.fn = bar
// 第二步
foo.fn()
// 第三步
delete foo.fn

fn 是对象的属性名,反正最后也要删除它,所以起成什么都无所谓。

根据这个思路,我们可以尝试着去写第一版的 call2 函数:

// 第一版
Function.prototype.call2 = function(context) {
    // 首先要获取调用call的函数,用this可以获取
    context.fn = this;
    context.fn();
    delete context.fn;
}

// 测试一下
var foo = {
    value: 1
};

function bar() {
    console.log(this.value);
}

bar.call2(foo); // 1

正好可以打印 1 哎!是不是很开心!(~ ̄▽ ̄)~

模拟实现第二步

最一开始也讲了,call 函数还能给定参数执行函数。举个例子:

var foo = {
    value: 1
};

function bar(name, age) {
    console.log(name)
    console.log(age)
    console.log(this.value);
}

bar.call(foo, 'kevin', 18);
// kevin
// 18
// 1

注意:传入的参数并不确定,这可咋办?

不急,我们可以从 Arguments 对象中取值,取出第二个到最后一个参数,然后放到一个数组里。

比如这样:

// 以上个例子为例,此时的arguments为:
// arguments = {
//      0: foo,
//      1: 'kevin',
//      2: 18,
//      length: 3
// }
// 因为arguments是类数组对象,所以可以用for循环
var args = [];
for(var i = 1, len = arguments.length; i < len; i++) {
    args.push('arguments[' + i + ']');
}

// 执行后 args为 ["arguments[1]", "arguments[2]", "arguments[3]"]

不定长的参数问题解决了,我们接着要把这个参数数组放到要执行的函数的参数里面去。

// 将数组里的元素作为多个参数放进函数的形参里
context.fn(args.join(','))
// (O_o)??
// 这个方法肯定是不行的啦!!!

也许有人想到用 ES6 的方法,不过 call 是 ES3 的方法,我们为了模拟实现一个 ES3 的方法,要用到ES6的方法,好像……,嗯,也可以啦。但是我们这次用 eval 方法拼成一个函数,类似于这样:

eval('context.fn(' + args +')')

这里 args 会自动调用 Array.toString() 这个方法。

所以我们的第二版克服了两个大问题,代码如下:

// 第二版
Function.prototype.call2 = function(context) {
    context.fn = this;
    var args = [];
    for(var i = 1, len = arguments.length; i < len; i++) {
        args.push('arguments[' + i + ']');
    }
    eval('context.fn(' + args +')');
    delete context.fn;
}

// 测试一下
var foo = {
    value: 1
};

function bar(name, age) {
    console.log(name)
    console.log(age)
    console.log(this.value);
}

bar.call2(foo, 'kevin', 18); 
// kevin
// 18
// 1

(๑•̀ㅂ•́)و✧

模拟实现第三步

模拟代码已经完成 80%,还有两个小点要注意:

1.this 参数可以传 null,当为 null 的时候,视为指向 window

举个例子:

var value = 1;

function bar() {
    console.log(this.value);
}

bar.call(null); // 1

虽然这个例子本身不使用 call,结果依然一样。

2.函数是可以有返回值的!

举个例子:

var obj = {
    value: 1
}

function bar(name, age) {
    return {
        value: this.value,
        name: name,
        age: age
    }
}

console.log(bar.call(obj, 'kevin', 18));
// Object {
//    value: 1,
//    name: 'kevin',
//    age: 18
// }

不过都很好解决,让我们直接看第三版也就是最后一版的代码:

// 第三版
Function.prototype.call2 = function (context) {
    var context = context || window;
    context.fn = this;

    var args = [];
    for(var i = 1, len = arguments.length; i < len; i++) {
        args.push('arguments[' + i + ']');
    }

    var result = eval('context.fn(' + args +')');

    delete context.fn
    return result;
}

// 测试一下
var value = 2;

var obj = {
    value: 1
}

function bar(name, age) {
    console.log(this.value);
    return {
        value: this.value,
        name: name,
        age: age
    }
}

bar.call2(null); // 2

console.log(bar.call2(obj, 'kevin', 18));
// 1
// Object {
//    value: 1,
//    name: 'kevin',
//    age: 18
// }

到此,我们完成了 call 的模拟实现,给自己一个赞 b( ̄▽ ̄)d

apply的模拟实现

apply 的实现跟 call 类似,在这里直接给代码,代码来自于知乎 @郑航的实现:

Function.prototype.apply = function (context, arr) {
    var context = Object(context) || window;
    context.fn = this;

    var result;
    if (!arr) {
        result = context.fn();
    }
    else {
        var args = [];
        for (var i = 0, len = arr.length; i < len; i++) {
            args.push('arr[' + i + ']');
        }
        result = eval('context.fn(' + args + ')')
    }

    delete context.fn
    return result;
}

下一篇文章

JavaScript深入之bind的模拟实现

重要参考

知乎问题 不能使用call、apply、bind,如何用 js 实现 call 或者 apply 的功能?

深入系列

JavaScript深入系列目录地址:https://github.com/mqyqingfeng/Blog

JavaScript深入系列预计写十五篇左右,旨在帮大家捋顺JavaScript底层知识,重点讲解如原型、作用域、执行上下文、变量对象、this、闭包、按值传递、call、apply、bind、new、继承等难点概念。

如果有错误或者不严谨的地方,请务必给予指正,十分感谢。如果喜欢或者有所启发,欢迎star,对作者也是一种鼓励。

@mqyqingfeng mqyqingfeng changed the title from avaScript深入之call和apply的模拟实现 to JavaScript深入之call和apply的模拟实现 May 2, 2017

@JuniorTour

This comment has been minimized.

JuniorTour commented May 4, 2017

受益匪浅!学到了很多,谢谢前辈!
有一个小问题:call2第三版和apply的函数内,是不是不必要 var context =...,直接context=...即可?

@mqyqingfeng

This comment has been minimized.

Owner

mqyqingfeng commented May 4, 2017

哈哈,确实可以,没有注意到这点,感谢指出,(๑•̀ㅂ•́)و✧

@fantasy123

This comment has been minimized.

fantasy123 commented May 17, 2017

arr.push('arguments['+i+']');
请问这里为什么是一个拼接操作呢?

@jawil

This comment has been minimized.

jawil commented May 17, 2017

eval函数接收参数是个字符串

定义和用法

eval() 函数可计算某个字符串,并执行其中的的 JavaScript 代码。

语法:
eval(string)

string必需。要计算的字符串,其中含有要计算的 JavaScript 表达式或要执行的语句。该方法只接受原始字符串作为参数,如果 string 参数不是原始字符串,那么该方法将不作任何改变地返回。因此请不要为 eval() 函数传递 String 对象来作为参数。

简单来说吧,就是用JavaScript的解析引擎来解析这一堆字符串里面的内容,这么说吧,你可以这么理解,你把eval看成是<script>标签。

eval('function Test(a,b,c,d){console.log(a,b,c,d)};Test(1,2,3,4)')
@fantasy123

@mqyqingfeng

This comment has been minimized.

Owner

mqyqingfeng commented May 17, 2017

@jawil 感谢回答哈~
@fantasy123 最终目的是为了拼出一个参数字符串,我们一步一步看:

var args = [];
for(var i = 1, len = arguments.length; i < len; i++) {
        args.push('arguments[' + i + ']');
}

最终的数组为:

var args = [arguments[1], arguments[2], ...]

然后

 var result = eval('context.fn(' + args +')');

在eval中,args 自动调用 args.toString()方法,eval的效果如 jawil所说,最终的效果相当于:

 var result = context.fn(arguments[1], arguments[2], ...);

这样就做到了把传给call的参数传递给了context.fn函数

@lz-lee

This comment has been minimized.

lz-lee commented May 29, 2017

apply郑航的实现,循环是不是应该从 i = 1 开始?

@mqyqingfeng

This comment has been minimized.

Owner

mqyqingfeng commented May 29, 2017

@lz-lee call 的实现中,是通过 arguments 取各个参数,所以从 1 开始,省略掉为 0 的 context,而apply的实现中,arr 直接就表示参数的数组,循环这个参数数组,直接就从 0 开始。

@lz-lee

This comment has been minimized.

lz-lee commented May 29, 2017

粗心大意了。感谢提醒。@mqyqingfeng

@lynn1824

This comment has been minimized.

lynn1824 commented May 31, 2017

分析的很透彻,点个赞!

@qianlongo

This comment has been minimized.

qianlongo commented May 31, 2017

赞赞赞

@mqyqingfeng

This comment has been minimized.

Owner

mqyqingfeng commented Jun 2, 2017

@lynn1824 @qianlongo 感谢夸奖,写的时候我就觉得模拟实现一遍 call 和 apply 最能让大家明白 call 和 apply 的原理 (~ ̄▽ ̄)~

@lzr900515

This comment has been minimized.

lzr900515 commented Jun 5, 2017

var context = Object(context) || window; 这里有问题吗?context为null时Object(null)返回空对象,不会被赋值为window

@mqyqingfeng

This comment has been minimized.

Owner

mqyqingfeng commented Jun 6, 2017

@lzr900515 没有什么问题哈,非严格模式下,指定为 null 或 undefined 时会自动指向全局对象,郑航写的是严格模式下的,我写的是非严格模式下的,实际上现在的模拟代码有一点没有覆盖,就是当值为原始值(数字,字符串,布尔值)的 this 会指向该原始值的自动包装对象。

@hujiulong

This comment has been minimized.

hujiulong commented Jun 13, 2017

context.fn = this;这里似乎漏掉了一个很关键的问题,如果context本来就有fn这个成员怎么办。这里只能给一个原来不存在的名字

var id = 0;
while ( context[ id ] ) {
    id ++;
}
context[ id ] = this;

不过这个方法似乎有点傻

@mqyqingfeng

This comment has been minimized.

Owner

mqyqingfeng commented Jun 13, 2017

@hujiulong 哈哈,有道理哈~ 确实会覆盖之前对象的方法,还好模拟实现 call 和 apply 的目的在于让大家通过模拟实现了解 call 和 apply 的原理,实际开发的时候还是要直接使用 call 和 apply 的~

@libin1991

This comment has been minimized.

libin1991 commented Jun 26, 2017

实在是佩服!

@jasperchou

This comment has been minimized.

jasperchou commented Jul 17, 2017

// 以上个例子为例,此时的arguments为:
// arguments = {
//      0: foo,
//      1: 'kevin',
//      2: 18,
//      length: 3
// }
// 因为arguments是类数组对象,所以可以用for循环
var args = [];
for(var i = 1, len = arguments.length; i < len; i++) {
    args.push('arguments[' + i + ']');
}

// 执行后 args为 [foo, 'kevin', 18]

// 执行后 args为 [foo, 'kevin', 18]

这一句可能造成误导。结果为:["arguments[1]", "arguments[2]"]
虽然后面确实会用eval执行,但是此处还没有。

@mqyqingfeng

This comment has been minimized.

Owner

mqyqingfeng commented Jan 20, 2018

@Ortonzhang 可以再简化一点:

Function.prototype.call2 = function (context, ...args) {
    context = context || window
    context.__fn__ = this
    let result = context.__fn__(...args)
    delete context.__fn__
    return result
}
@ZhengLiJing

This comment has been minimized.

ZhengLiJing commented Jan 24, 2018

http://jsbin.com/juhehil/10 ,为什么打印出的结果是3和1?求解,我参考的是知乎@郑航的写法。

@delayk

This comment has been minimized.

delayk commented Jan 24, 2018

@ZhengLiJing
你的context是foo, 所以第一行打印3.
result = eval("context.fn(" + arg + ")");//关键在于字符串拼接这里,相当于arg+""
这句话相当于执行了context.fn(arr[0], arr[1], arr[2]),bar的第二行打印的其实都是arr[0]

@ZhengLiJing

This comment has been minimized.

ZhengLiJing commented Jan 24, 2018

@delayk 理解了,非常感谢。

@HuangQiii

This comment has been minimized.

HuangQiii commented Feb 3, 2018

每看一次就有不一样的收获,已经很理解怎么实现的了~

还有个小笔误,第三版里验证这里应该是想表达call2吧

bar.call(null); // 2

@geekzhanglei

This comment has been minimized.

geekzhanglei commented Feb 5, 2018

为什么不是这样的呢?

Function.prototype.call2 = fucntion(context) {
    var args = [];
    for (var i = 1, len = arguments.length; i < len; i++) {
        args.push(arguments[i]);
    }
    context.__fn = this || window;
    var result = context.__fn(args.join(','));
    delete context.__fn;
    return result;
}

为什么一定要eval函数解析下?

@btea

This comment has been minimized.

btea commented Feb 5, 2018

@geekzhanglei
args.join(','),你这步操作得到的结果是一个由传入的所有参数拼接而成的字符串,相当于只传入一个数据类型是字符串的参数,最后函数执行结果肯定和你预期的不一样。

@geekzhanglei

This comment has been minimized.

geekzhanglei commented Feb 5, 2018

@btea 嗯嗯,是的,疏忽了,感谢。

@5ibinbin

This comment has been minimized.

5ibinbin commented Feb 6, 2018

内容和评论一样精彩

@mqyqingfeng

This comment has been minimized.

Owner

mqyqingfeng commented Feb 8, 2018

@HuangQiii 感谢指出哈~

@mqyqingfeng

This comment has been minimized.

Owner

mqyqingfeng commented Feb 8, 2018

@btea 感谢回答~ 😀

@sxzy

This comment has been minimized.

sxzy commented Mar 26, 2018

为什么需要用eval呢?就是我能用下面这种吗?直接用扩展符展开呢???

Function.prototype.call = function(context) {
  var context = context || window;
  context.fn = this;
  var args = Array.prototype.slice.call(arguments,1);
  var result = context.fn(...args);
  delete context.fn
  return result;
}
@sxzy

This comment has been minimized.

sxzy commented Mar 26, 2018

哈哈哈哈,看到了。我这个是用es6来写

@mqyqingfeng

This comment has been minimized.

Owner

mqyqingfeng commented Mar 27, 2018

@sxzy 当然可以啦,上面的评论中有 ES6 的版本哦~

@BeijiYang

This comment has been minimized.

BeijiYang commented Mar 29, 2018

很好的文章,感谢。
同时有一点不太没明白,请教一下:为什么在 eval() 参数中使用变量,会有数组展开的效果?

例如

let a = 1,
  b = 2,
  c = 3;

let arr = [a, b, c];

function test(p1, p2, p3) {
  console.log(`${p1} ~ ${p2} ~ ${p3} ~ `);
}

test(...arr);     //1 ~ 2 ~ 3 ~
eval(`test(${arr})`);     //1 ~ 2 ~ 3 ~
test(arr);    //1,2,3 ~~~ undefined ~~~ undefined ~~~
test(arr.toString());    //1,2,3 ~~~ undefined ~~~ undefined ~~~

@mqyqingfeng

@mqyqingfeng

This comment has been minimized.

Owner

mqyqingfeng commented Mar 29, 2018

@BeijiYang 例子中的展开效果的例子是这样的:

eval('context.fn(' + args +')')

数组的展开效果与 eval 无关,是隐式类型导致的,我们来看一个例子:

console.log('1' + [1, 2, 3]) // 11,2,3
@BeijiYang

This comment has been minimized.

BeijiYang commented Mar 29, 2018

感谢大神回复。
我是不明白为什么 args 这个数组整体没有被当成第一个参数 arguments[0],
而是将数组内部的元素 args[0] 作为 arguments[0] ,args[1] 作为 arguments[1]。

以第二版代码为例,

context.fn(args); 

就是第一种情况,输出

[ 'arguments[1]', 'arguments[2]' ]
undefined
1


eval("context.fn(" + args + ")")
就是正确情况,输出

kevin
18
1

就是不明白这里头 eval() 起的作用

@mqyqingfeng

This comment has been minimized.

Owner

mqyqingfeng commented Mar 29, 2018

@BeijiYang

假设 args 为:

var args = [arguments[1], arguments[2]]

然后

eval('context.fn(' + args +')');

传入 eval 函数中的字符串为:

'context.fn(arguments[1], arguments[2])'

eval 就解析为:

context.fn(arguments[1], arguments[2])

不知道这样解释清晰不?

@BeijiYang

This comment has been minimized.

BeijiYang commented Mar 29, 2018

清楚了!刚刚钻牛角尖了。感谢大神@mqyqingfeng ,期待大神新文章。

@johnsken-jerry

This comment has been minimized.

johnsken-jerry commented Mar 31, 2018

超级棒,今天算是加深了对call、apply的了解。
eval一直困扰着我 @mqyqingfeng 感谢大神

@thereisnowinter

This comment has been minimized.

thereisnowinter commented Apr 2, 2018

var args = [];
for(var i = 1, len = arguments.length; i < len; i++) {
        args.push('arguments[' + i + ']');
}
最终的数组为:

var args = [arguments[1], arguments[2], ...]

var args 不是应该是['argumets[1]', 'arguments[2]',...]吗?希望大佬能解答一下?

@Arvinzhu

This comment has been minimized.

Arvinzhu commented Apr 14, 2018

Function.prototype.call2 = function(context){
  context = context || window;
  let bake = context.fn === undefined? undefined :context.fn ;
  let fn=Symbol()
  context[fn] = this;
  let args = []
  for(var i =1 ,len = arguments.length;i<len;i++){
    args.push('arguments['+i+']')
  }
  let str = args.join(',')
  eval('context[fn]('+str+')');
  Reflect.deleteProperty(context,fn)
}


可以使用Symbol,这样就不用担心会覆盖context的属性了
@mqyqingfeng

This comment has been minimized.

Owner

mqyqingfeng commented Apr 16, 2018

@Arvinzhu 哈哈,感谢分享哈~ 确实这个更加严谨一些~ 而且既然已经用了 ES6 的 Symbol 和 Reflect,可以再简化一点,用上拓展运算符:

    Function.prototype.call2 = function(context, ...args) {
        context = context || window;
        let fn = Symbol()
        context[fn] = this;
        var result =  context[fn](...args)
        Reflect.deleteProperty(context, fn)
        return result;
    }
@Luoyuda

This comment has been minimized.

Luoyuda commented Oct 8, 2018

终于理解这两个东西,其他的教程都讲的云里雾里的,醉了

@linesh-simplicity

This comment has been minimized.

linesh-simplicity commented Oct 9, 2018

讲得很好,点个赞。问一下,实践中使用 apply 的场景有什么呢?感觉在写业务代码时,this 的指向一般都是确定的,参数也可以在 ES6 的参数解构出来以后得到解决。

我能想到有两个场景,一个是写框架,一个是做柯里化。不过自己尝试了一下,写柯里化其实可以不用用到 apply,而写框架什么场景下会需要用到 apply,我不是很确定。或者,是不是还有其他我没想到的使用场景呢?能否麻烦楼主解答一下呢,感谢。

@5ibinbin

This comment has been minimized.

5ibinbin commented Oct 10, 2018

你好,在Function.prototype.apply = function(context, arr)这个实现方法中,将数组用for循环遍历,再使用eval包装,是考虑到类数组的情况吗?还是有其他的考虑,烦请讲解

@wojiaofengzhongzhuifeng

This comment has been minimized.

wojiaofengzhongzhuifeng commented Oct 30, 2018

// 第一版
Function.prototype.call2 = function(context) {
    // 首先要获取调用call的函数,用this可以获取
    context.fn = this;
    context.fn();
    delete context.fn;
}

// 测试一下
var foo = {
    value: 1
};

function bar() {
    console.log(this.value);
}

bar.call2(foo); // 1

为什么能用this获取bar函数??
@mqyqingfeng

@xusongfu

This comment has been minimized.

xusongfu commented Nov 6, 2018

// 第一版
Function.prototype.call2 = function(context) {
    // 首先要获取调用call的函数,用this可以获取
    context.fn = this;
    context.fn();
    delete context.fn;
}

// 测试一下
var foo = {
    value: 1
};

function bar() {
    console.log(this.value);
}

bar.call2(foo); // 1

为什么能用this获取bar函数??

@mqyqingfeng

Function是构造函数,this指向实例化的对象function bar。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment