Skip to content
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,bind等方法 #12

Open
wolfdu opened this issue Dec 18, 2017 · 0 comments
Open

JavaScript模拟实现call,apply,bind等方法 #12

wolfdu opened this issue Dec 18, 2017 · 0 comments

Comments

@wolfdu
Copy link
Owner

wolfdu commented Dec 18, 2017

https://wolfdu.fun/post?postId=5a2fd0f125322c62a62e7b05

最近正在拜读JavaScript深入系列文章,初读发现文章简洁明了,知识循序渐进,虽然有些知识点在文章中介绍的不够完全但是在评论区中的讨论却是十分火热,引人思考,所以觉得有必要学习整理梳理其中的观点与知识。

记得在初次见到代码里面有使用到apply,call,bind函数时的蒙蔽表情吗?刚好借助深度系列文章梳理并模拟这几个JavaScript中的常见方法。

Function.prototype.call()

简单口水描述一下自己的理解:

一个函数或方法在调用call() 方法时,使当前函数或方法具有一个指定的this值和若干个提供的参数值,并执行当前函数或方法。
这里是ECMAScript规则中对call方法的解释
这里是MDN中介绍和一些示例

用一个简单的例子来引入我们要模拟的方法call

var foo = {
    value: 1
};

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

bar.call(foo); // 1

这里我们可以发现这里改变了bar函数的this指向,指向了foo,并执行打印输出1

那么我们可以先来模拟bar.call(foo); 的执行现场:

var foo = {
	value: 1,
	bar: function () { // 给foo添加一个临时属性bar假设之前foo中不存在该属性
		 console.log(this.value);
	}
};

foo.bar(); // 执行-->1

// 删除临时属性
delete foo.bar

大致可以分为三个步骤:

  1. 将函数bar设置为foo的临时方法
  2. 通过foo调用该临时方法
  3. 删除临时方法

接下来我们就可以模拟出第一版的“call”方法callOne

Function.prototype.callOne = function (context) {
	// this为调用call函数的函数bar
	// 假设foo中之前不存在fn属性
	context.fn = this;
	context.fn();
	delete context.fn;
}

var foo = {
    value: 1
};

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

bar.callOne(foo); // 1

这里涉及到了执行上下文中this的绑定知识点就不扩展解释了,可自行了解

不要太开心,我们记得call函数还可以提供若干个参数。
稍稍修改一下之前的例子:

var foo = {
    value: 1
};

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

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

那我们需要怎么解决参数的问题呢?
我们都知道函数体中都有一个Arguments对象它是一个类数组对象,我们可以通过它获取传入的参数yes!!!
例如上述例子中call函数的arguments为:

arguments = {
	0: foo,
	1: 'wolfdu',
	2: 18,
	length: 3
};

Arguments相关的知识可以参考JavaScript深入之类数组对象与arguments

接下来我们来实现我们的第二版"call"方法callTwo

Function.prototype.callTwo = function (context){
	context.fn = this; // 假设foo中之前不存在fn属性
	var args = [];
	// arguments的第一个位置被foo占了,所以传递的参数是从1开始的
	for(var i = 1, len = arguments.length; i < len; i++){ 
		args.push(arguments[i])
	}
	context.fn(...args); 
	delete context.fn;
}

var foo = {
    value: 1
};

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

bar.callTwo(foo, 'wolfdu', 18);
// wolfdu 18 
// 1

哈哈哈,是不是很简单。。。但是感觉怪怪的,哪里怪?
在参数处理的时候使用了es6的...扩展运算符,也就是说我们使用es6的特性来模拟es3的方法,是不是有点欠妥呢,好吧再想想办法吧。

那换个思路,这里使用eval方法来实现参数处理。对这个方法不熟悉的可以参见MDN--eval
这就是我们使用eval实现的callTwo了:

Function.prototype.callTwo = function (context){
    context.fn = this; // 假设foo中之前不存在fn属性
    var args = [];
	// arguments的第一个位置被context占了,所以传递的参数是从1开始的
    for(var i = 1, len = arguments.length; i < len; i++){
        args.push('arguments[' + i + ']')
    }
	// 这里args会自动调用Array.toString方法:[arg1, arg2, arg3].toString() -->arg1, arg2, arg3
    eval('context.fn(' + args + ')'); 
    delete context.fn;
}

var foo = {
    value: 1
};

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

bar.callTwo(foo, 'wolfdu', 18);
// wolfdu 18 
// 1

ლↀѡↀლ 好厉害,至此我们已经解决了提供参数的问题,是不是就已经ok了呢?

并没有(;´༎ຶД༎ຶ`)

当我们调用call函数时,将指定的thisArg设置为null会出现什么情况:

var foo = {
    value: 1
};

var value = 2;

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

bar.call(null, 'wolfdu', 18); 
// wolfdu 18 
// 1

如果这个函数处于非严格模式下,则指定为null和undefined的this值会自动指向全局对象(浏览器中就是window对象)
所以这里我们只需要加一个判断,如果为null或undefined将当前this指向window即可。

如果这个函数处于非严格模式下,当thisArg值为原始值(数字,字符串,布尔值)的this会指向该原始值的自动包装对象。
额,这个就要根据thisArg的类型来判断一下类型,去指定包装类型了。

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

console.log(bar.call(1, 'wolfdu', 18));
// wolfdu 18 
// Number {
//   [[PrimitiveValue]]: 1
// }

当函数有返回值时:

var foo = {
    value: 1
};

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

console.log(bar.call(foo, 'wolfdu', 18)); 
// {
//     name: 'wolfdu',
//     age: 18,
//     value: 1
// }

这个问题也好解决,我们将fn的执行结果返回即可。
接下来我们来模拟实现以上提到的几点特性的“call”方法callThree:

// 处理context值,非严格模式下
// 当thisArg为null或undefined时将this指向window
// 当thisArg为基础类型Number,String,Boolean时this指向包装类型
function getContext (context) { 
   var contextMap = {
        number: new Number(context),
        string: new String(context),
        boolean: new Boolean(context),
        function: context,
        object: context,
        undefined: window
    }
    return context === null ? window : contextMap[typeof context];    
}

Function.prototype.callThree = function (context){
    context = getContext(context); 
    context.fn = this; // 假设foo中之前不存在fn属性
    var args = [];
	// arguments的第一个位置被context占了,所以传递的参数是从1开始的
    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 foo = {
    value: 1
};

function bar (name, age) {
    return {
        name: name,
        age: age,
        value: this // 这里返回this,便于观察为包装类型情况
    };
}

console.log(bar.callThree(1, 'wolfdu', 18));
// {
//   name: 'wolfdu',
//   age: 18, 
//   value: Number { [[PrimitiveValue]]: 1}
// }
console.log(bar.callThree(foo, 'wolfdu', 18));
// {
//   name: 'wolfdu',
//   age: 18, 
//   value: {value: 1}
// }

(╯▔皿▔)╯!!!测试用例都这么长了不容易呀,喝口水吧,,,慢着!!!(@[]@!!)
final版中还有一个注释很扎眼那就是,我们一直在假设foo上没有fn属性,那万一有这个属性foo的同名属性就会被覆盖了然后被删除掉。
我们连thisArg各种类型的this指向都模拟了,不能放过他。

既然如此,fn这个属性不能与foo之前的属性不能重复,那么这个属性就要相对foo已有属性唯一,这个需求很明显就是es6的新特性Symbol的功能,不了解的可以看看阮一峰老师的es6入门--Symbol
所以我们这里使用Symbol来完善一下:

Function.prototype.callThree = function (context){
    context = getContext(context); 
    var fn = Symbol();
    context[fn] = this;
    var args = [];
	// arguments的第一个位置被context占了,所以传递的参数是从1开始的
    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;
}

注意Symbol定义的属性值只能使用方括号访问

我们再来验证一下:

// 处理context值,非严格模式下
// 当thisArg为null或undefined时将this指向window
// 当thisArg为基础类型Number,String,Boolean时this指向包装类型
function getContext (context) { 
    var contextMap = {
        number: new Number(context),
        string: new String(context),
        boolean: new Boolean(context),
        function: context,
        object: context,
        undefined: window
    }
    return context === null ? window : contextMap[typeof context];  
}

Function.prototype.callThree = function (context){
    context = getContext(context); 
    var fn = Symbol();
    context[fn] = this; // 假设foo中之前不存在fn属性
    var args = [];
	// arguments的第一个位置被context占了,所以传递的参数是从1开始的
    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 foo = {
    value: 1,
    fn: 2
};

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

console.log(bar.callThree(foo, 'wolfdu', 18));
// {
//   name: 'wolfdu',
//   age: 18, 
//   value: {value: 1, fn: 2}
// }

\(`▽′)/ !从结果看来我们的fn并没有被覆盖,但是。。。(;´༎ຶД༎ຶ`)

Symbol是es6的特性,和之前...扩展运算符是同一个问题,虽然我们这里不能使用,,额,是不好意思使用Symbol特性但是他也为我们提供了思路,我们要做了就是提供一个随机的且与foo属性不相同的属性:

function getUniqueProperty (object) {
   var uniqueProp = '00' + Math.random(); // 生成一个随机属性名
   if (object.hasOwnProperty(uniqueProp)) { // 如果有相同的属性则再次执行函数
   	getUniqueProperty(object);
   } else {
   	return uniqueProp;
   }
}

那么我们是时候祭出终极无转折版的“call”函数了callFinal:

// 处理context值,非严格模式下
// 当thisArg为null或undefined时将this指向window
// 当thisArg为基础类型Number,String,Boolean时this指向包装类型
function getContext (context) { 
    var contextMap = {
        number: new Number(context),
        string: new String(context),
        boolean: new Boolean(context),
        function: context,
        object: context,
        undefined: window
    }
    return context === null ? window : contextMap[typeof context];  
}

function getUniqueProperty (object) {
	var uniqueProp = '00' + Math.random(); // 生成一个随机属性名
	if (object.hasOwnProperty(uniqueProp)) { // 如果有相同的属性则再次执行函数
		getUniqueProperty(object);
	} else {
		return uniqueProp;
	}
}

Function.prototype.callFinal = function (context){
    context = getContext(context); 
    var fn = getUniqueProperty(context);
    context[fn] = this;
    var args = [];
	// arguments的第一个位置被context占了,所以传递的参数是从1开始的
    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 foo = {
    value: 1,
    fn: 2
};

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

console.log(bar.callFinal(foo, 'wolfdu', 18));
// {
//   name: 'wolfdu',
//   age: 18, 
//   value: {value: 1, fn: 2}
// }

ㄟ(▔▽▔ㄟ) (╯▔▽▔)╯喜大普奔,终于完成了call函数的模拟。
似乎好像,,这才刚刚开始的样子 °(°ˊДˋ°) ° ,后面还有apply和bind函数,不过几个重要的点都在call函数模拟中搞定了,我们就捎带手的模拟一下apply和bind函数吧。

模拟apply方法

我们先自己概括一apply函数下:

一个函数或方法在调用apply() 方法时,使当前函数或方法具有一个指定的this值和若干参数值(参数由一个数组提供),并执行当前函数或方法。
ECMAScript规则中对apply方法的解释
MDN的介绍和解释

我们可以知道call()方法的作用和 apply() 方法类似,只有一个区别,就是 call()方法接受的是若干个参数的列表,而apply()方法接受的是一个包含多个参数的数组。

这就好办了我们就直接上applyFinal:

// 处理context值,非严格模式下
// 当thisArg为null或undefined时将this指向window
// 当thisArg为基础类型Number,String,Boolean时this指向包装类型
function getContext (context) { 
    var contextMap = {
        number: new Number(context),
        string: new String(context),
        boolean: new Boolean(context),
        function: context,
        object: context,
        undefined: window
    }
    return context === null ? window : contextMap[typeof context];  
}

function getUniqueProperty (object) {
	var uniqueProp = '00' + Math.random(); // 生成一个随机属性名
	if (object.hasOwnProperty(uniqueProp)) { // 如果有相同的属性则再次执行函数
		getUniqueProperty(object);
	} else {
		return uniqueProp;
	}
}

Function.prototype.applyFinal = function (context, arr){
    context = getContext(context); 
    var fn = getUniqueProperty(context);
    context[fn] = this;
    var result;
    if (!arr) { // 如果没有参数则直接执行 
        result = context[fn]();
    } else {
        var args = [];
		// 这里是直接遍历的arr参数,与call的模拟略有不同
        for(var i = 0, len = arr.length; i < len; i++){ 
            args.push('arr[' + i + ']')
        }
        result = eval('context[fn](' + args + ')');
    }
    delete context[fn];
    return result;
}

var foo = {
    value: 1,
    fn: 2
};

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

console.log(bar.applyFinal(foo, ['wolfdu', 18]));
// {
//   name: 'wolfdu',
//   age: 18, 
//   value: {value: 1, fn: 2}
// }

ヽ(*΄◞ิ౪◟ิ‵ *)ノ 有木有很舒爽的赶脚。

模拟bind函数

老规矩先自行总结下:

bind()会创建一个新的函数称为绑定函数,绑定函数被调用时函数的this指向bind()函数的第一个参数,绑定函数运行时的参数为bind()函数的第二个以及以后的参数和运行传入实参。
ECMAScript规则中对bind方法的解释
MDN--bind介绍解释

上个栗子看看吧:

var foo = {
    value: 1
};

function bar (name, age) {
    return {
        name: name,
        age: age,
        value: this.value
    };
}
// 返回一个新函数
var bindBar = bar.bind(foo, 'wolfdu');
bindBar(18);
// {
//   name: 'wolfdu',
//   age: 18, 
//   value: 1
// }

有了模拟call和apply的经验,这里先实现比较明显的特点:

  • 调用bind会返回一个函数
  • 可以传入参数

模拟出我们的第一版“bind”函数bindOne:

Function.prototype.bindOne = function (context) {
    var funToBind = this;
    // 获取bind传入的参数,不要忘了arguments是类数组对象哦
    var args = Array.prototype.slice.callFinal(arguments, 1);
    return function () {
        // 绑定函数的参数
        var bindFucArgs = Array.prototype.slice.callFinal(arguments);
        return funToBind.applyFinal(context, args.concat(bindFucArgs));
    };
}

var foo = {
    value: 1
};

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

var bindBar = bar.bindOne(foo, 'wolfdu');
bindBar(18);
// {
//   name: 'wolfdu',
//   age: 18, 
//   value: 1
// }

got you man\(`▽′)/!!!
如果这里的callFinal和applyFinal的作用不是很清楚可以按一下键盘Home键,慢慢@/"的看到这里应该就明白了。

这里的callFinalapplyFinal函数都是使用上文中call和apply的模拟函数

接下来就是其他特性了,MDN里有说到:

一个绑定函数也能使用new操作符创建对象:这种行为就像把原函数当成构造器。提供的 this 值被忽略,同时调用时的参数被提供给模拟函数。

简单的说就是当绑定函数作为构造函数时,绑定的this失效,传入的参数有效。
这里稍微调整一下我们的例子:

var foo = {
    value: 1
};

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

var bindBar = bar.bind(foo, 'wolfdu');

var b = new bindBar(18);
console.log(b);
// wolfdu 18 undefined
// bar {
//   name: 'wolfdu',
//   age: 18
// }
console.log(b.__proto__ === Bar.prototype);
// true

我们可以发现,虽然绑定了this的指向,但是作为构造函数时依然是无效的。
而且这里实例化的b的构造函数是指向bar的。
这里待你了解new运算符的运行过程应该也不会太疑惑,new调用构造函数时会将this绑定到新创建的对象上。new运算符相关内容可以参见MDN-new运算符

我们可以通过判断绑定函数的this是否是该函数的实例,即可得知当前的绑定函数是作为构造函数调用还是普通函数调用。
同时我们通过上面的例子发现,将绑定函数作构造函数调用创建的实例对象的原型是指向原函数(这里指Bar函数)的实例原型的,所以我们还需要修改一下绑定函数的原型对象。

吼啦,开始模拟我们的第二版“bind”函数bindTwo:

Function.prototype.bindTwo = function (context) {
    var funToBind = this;
    // 获取bind传入的参数,不要忘了arguments是类数组对象哦
    var args = Array.prototype.slice.callFinal(arguments, 1);
    var boundFun = function () {
    	// 绑定函数的参数
        var bindFucArgs = Array.prototype.slice.callFinal(arguments);
        // boundFun为构造函数时,this指向实例,true
        // boundFun不为构造函数,this指向context,false
        return funToBind.applyFinal(this instanceof boundFun ? this : context, args.concat(bindFucArgs));
    } 
    boundFun.prototype = funToBind.prototype;
    return boundFun;
}

var foo = {
    value: 1
};

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

var bindBar = bar.bindTwo(foo, 'wolfdu');
var b = new bindBar(18);
console.log(b);
// wolfdu 18 undefined
// bar {
//   name: 'wolfdu',
//   age: 18
// }
console.log(b.__proto__ === bar.prototype);
// true

乀(ˉεˉ乀) (╯▔(▔)╯
但是。。。(:qゝ∠)
这个版本现在还存在一个问题:我们可以通过bindBar修改bar的原型对象。这里我们可以使用一个空函数中转一下。
所以最终版就是这个样子了bindFinal

Function.prototype.bindFinal = function (context) {
    var funToBind = this;
    // 获取bind传入的参数,不要忘了arguments是类数组对象哦
    var args = Array.prototype.slice.callFinal(arguments, 1);
    var boundFun = function () {
    	// 绑定函数的参数
        var bindFucArgs = Array.prototype.slice.callFinal(arguments);
        // boundFun为构造函数时,this指向实例,true
        // boundFun不为构造函数,this指向context,false
        return funToBind.applyFinal(this instanceof F ? this : context, args.concat(bindFucArgs));
    } 
    function F () {}; // 中转函数
    F.prototype = funToBind.prototype;
    boundFun.prototype = new F();
    return boundFun;
}

var foo = {
    value: 1
};

var value = 10

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

var bindBar = bar.bindFinal(foo, 'wolfdu');
var b = new bindBar(18);
// wolfdu 18 undefined
console.log(b.__proto__ === bar.prototype);
// false

可以发现,最后实例的原型和原函数的原型比较返回false,这里是因为加入中转函数原因实, 导致例的原型和原函数的原型不相同了,但是并不影响功能

好了这次真的结束了,ヽ(*΄◞ิ౪◟ิ‵ *)ノ

总结

到这里了都,就没啥总结了,回头看看,能有啥收获不?
下面把call,apply和bind的final版都贴出对比的看一看吧。

// 处理context值,非严格模式下
// 当thisArg为null或undefined时将this指向window
// 当thisArg为基础类型Number,String,Boolean时this指向包装类型
function getContext (context) { 
    var contextMap = {
        number: new Number(context),
        string: new String(context),
        boolean: new Boolean(context),
        function: context,
        object: context,
        undefined: window
    }
    return context === null ? window : contextMap[typeof context];  
}

function getUniqueProperty (object) {
	var uniqueProp = '00' + Math.random(); // 生成一个随机属性名
	if (object.hasOwnProperty(uniqueProp)) { // 如果有相同的属性则再次执行函数
		getUniqueProperty(object);
	} else {
		return uniqueProp;
	}
}

Function.prototype.callFinal = function (context){
    context = getContext(context); 
    var fn = getUniqueProperty(context);
    context[fn] = this;
    var args = [];
	// arguments的第一个位置被context占了,所以传递的参数是从1开始的
    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;
}

Function.prototype.applyFinal = function (context, arr){
    context = getContext(context); 
    var fn = getUniqueProperty(context);
    context[fn] = this;
    var result;
    if (!arr) { // 如果没有参数则直接执行 
        result = context[fn]();
    } else {
        var args = [];
		// 这里是直接遍历的arr参数,与call的模拟略有不同
        for(var i = 0, len = arr.length; i < len; i++){ 
            args.push('arr[' + i + ']')
        }
        result = eval('context[fn](' + args + ')');
    }
    delete context[fn];
    return result;
}

Function.prototype.bindFinal = function (context) {
    var funToBind = this;
    // 获取bind传入的参数,不要忘了arguments是类数组对象哦
    var args = Array.prototype.slice.callFinal(arguments, 1);
    var boundFun = function () {
    	// 绑定函数的参数
        var bindFucArgs = Array.prototype.slice.callFinal(arguments);
        // boundFun为构造函数时,this指向实例,true
        // boundFun不为构造函数,this指向context,false
        return funToBind.applyFinal(this instanceof F ? this : context, args.concat(bindFucArgs));
    } 
    function F () {};
    F.prototype = funToBind.prototype;
    boundFun.prototype = new F();
    return boundFun;
}

参考文章:
不用call和apply方法模拟实现ES5的bind方法
JavaScript深入之call和apply的模拟实现
JavaScript深入之bind的模拟实现

若文中有知识整理错误或遗漏的地方请务必指出,非常感谢。如果对你有一丢丢帮助或引起你的思考,可以点赞鼓励一下作者=^_^=

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

No branches or pull requests

1 participant