【进阶3-3期】深度解析 call 和 apply 原理、使用场景及实现

9次阅读

共计 8822 个字符,预计需要花费 23 分钟才能阅读完成。

之前文章详细介绍了 this 的使用,不了解的查看【进阶 3 - 1 期】。
call() 和 apply()
call() 方法调用一个函数, 其具有一个指定的 this 值和分别地提供的参数(参数的列表)。
call() 和 apply()的区别在于,call()方法接受的是若干个参数的列表,而 apply()方法接受的是一个包含多个参数的数组
举个例子:
var func = function(arg1, arg2) {

};

func.call(this, arg1, arg2); // 使用 call,参数列表
func.apply(this, [arg1, arg2]) // 使用 apply,参数数组
使用场景
下面列举一些常用用法:
1、合并两个数组
var vegetables = [‘parsnip’, ‘potato’];
var moreVegs = [‘celery’, ‘beetroot’];

// 将第二个数组融合进第一个数组
// 相当于 vegetables.push(‘celery’, ‘beetroot’);
Array.prototype.push.apply(vegetables, moreVegs);
// 4

vegetables;
// [‘parsnip’, ‘potato’, ‘celery’, ‘beetroot’]
当第二个数组 (如示例中的 moreVegs) 太大时不要使用这个方法来合并数组,因为一个函数能够接受的参数个数是有限制的。不同的引擎有不同的限制,JS 核心限制在 65535,有些引擎会抛出异常,有些不抛出异常但丢失多余参数。
如何解决呢?方法就是将参数数组切块后循环传入目标方法
function concatOfArray(arr1, arr2) {
var QUANTUM = 32768;
for (var i = 0, len = arr2.length; i < len; i += QUANTUM) {
Array.prototype.push.apply(
arr1,
arr2.slice(i, Math.min(i + QUANTUM, len) )
);
}
return arr1;
}

// 验证代码
var arr1 = [-3, -2, -1];
var arr2 = [];
for(var i = 0; i < 1000000; i++) {
arr2.push(i);
}

Array.prototype.push.apply(arr1, arr2);
// Uncaught RangeError: Maximum call stack size exceeded

concatOfArray(arr1, arr2);
// (1000003) [-3, -2, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, …]
2、获取数组中的最大值和最小值
var numbers = [5, 458 , 120 , -215];
Math.max.apply(Math, numbers); //458
Math.max.call(Math, 5, 458 , 120 , -215); //458

// ES6
Math.max.call(Math, …numbers); // 458
为什么要这么用呢,因为数组 numbers 本身没有 max 方法,但是 Math 有呀,所以这里就是借助 call / apply 使用 Math.max 方法。
3、验证是否是数组
function isArray(obj){
return Object.prototype.toString.call(obj) === ‘[object Array]’;
}
isArray([1, 2, 3]);
// true

// 直接使用 toString()
[1, 2, 3].toString(); // “1,2,3”
“123”.toString(); // “123”
123.toString(); // SyntaxError: Invalid or unexpected token
Number(123).toString(); // “123”
Object(123).toString(); // “123”
可以通过 toString() 来获取每个对象的类型,但是不同对象的 toString()有不同的实现,所以通过 Object.prototype.toString() 来检测,需要以 call() / apply() 的形式来调用,传递要检查的对象作为第一个参数。
另一个验证是否是数组的方法
var toStr = Function.prototype.call.bind(Object.prototype.toString);
function isArray(obj){
return toStr(obj) === ‘[object Array]’;
}
isArray([1, 2, 3]);
// true

// 使用改造后的 toStr
toStr([1, 2, 3]); // “[object Array]”
toStr(“123”); // “[object String]”
toStr(123); // “[object Number]”
toStr(Object(123)); // “[object Number]”
上面方法首先使用 Function.prototype.call 函数指定一个 this 值,然后 .bind 返回一个新的函数,始终将 Object.prototype.toString 设置为传入参数。其实等价于 Object.prototype.toString.call()。
这里有一个前提是 toString()方法没有被覆盖
Object.prototype.toString = function() {
return ”;
}
isArray([1, 2, 3]);
// false
4、类数组对象(Array-like Object)使用数组方法
var domNodes = document.getElementsByTagName(“*”);
domNodes.unshift(“h1”);
// TypeError: domNodes.unshift is not a function

var domNodeArrays = Array.prototype.slice.call(domNodes);
domNodeArrays.unshift(“h1”); // 505 不同环境下数据不同
// (505) [“h1”, html.gr__hujiang_com, head, meta, …]
类数组对象有下面两个特性

1、具有:指向对象元素的数字索引下标和 length 属性
2、不具有:比如 push、shift、forEach 以及 indexOf 等数组对象具有的方法

要说明的是,类数组对象是一个对象。JS 中存在一种名为类数组的对象结构,比如 arguments 对象,还有 DOM API 返回的 NodeList 对象都属于类数组对象,类数组对象不能使用 push/pop/shift/unshift 等数组方法,通过 Array.prototype.slice.call 转换成真正的数组,就可以使用 Array 下所有方法。
类数组对象转数组的其他方法:
// 上面代码等同于
var arr = [].slice.call(arguments);

ES6:
let arr = Array.from(arguments);
let arr = […arguments];
Array.from() 可以将两类对象转为真正的数组:类数组对象和可遍历(iterable)对象(包括 ES6 新增的数据结构 Set 和 Map)。
PS 扩展一:为什么通过 Array.prototype.slice.call() 就可以把类数组对象转换成数组?
其实很简单,slice 将 Array-like 对象通过下标操作放进了新的 Array 里面。
下面代码是 MDN 关于 slice 的 Polyfill,链接 Array.prototype.slice()
Array.prototype.slice = function(begin, end) {
end = (typeof end !== ‘undefined’) ? end : this.length;

// For array like object we handle it ourselves.
var i, cloned = [],
size, len = this.length;

// Handle negative value for “begin”
var start = begin || 0;
start = (start >= 0) ? start : Math.max(0, len + start);

// Handle negative value for “end”
var upTo = (typeof end == ‘number’) ? Math.min(end, len) : len;
if (end < 0) {
upTo = len + end;
}

// Actual expected size of the slice
size = upTo – start;

if (size > 0) {
cloned = new Array(size);
if (this.charAt) {
for (i = 0; i < size; i++) {
cloned[i] = this.charAt(start + i);
}
} else {
for (i = 0; i < size; i++) {
cloned[i] = this[start + i];
}
}
}

return cloned;
};
}
PS 扩展二:通过 Array.prototype.slice.call() 就足够了吗?存在什么问题?
在低版本 IE 下不支持通过 Array.prototype.slice.call(args)将类数组对象转换成数组,因为低版本 IE(IE < 9)下的 DOM 对象是以 com 对象的形式实现的,js 对象与 com 对象不能进行转换。
兼容写法如下:
function toArray(nodes){
try {
// works in every browser except IE
return Array.prototype.slice.call(nodes);
} catch(err) {
// Fails in IE < 9
var arr = [],
length = nodes.length;
for(var i = 0; i < length; i++){
// arr.push(nodes[i]); // 两种都可以
arr[i] = nodes[i];
}
return arr;
}
}
PS 扩展三:为什么要有类数组对象呢?或者说类数组对象是为什么解决什么问题才出现的?
JavaScript 类型化数组是一种类似数组的对象,并提供了一种用于访问原始二进制数据的机制。Array 存储的对象能动态增多和减少,并且可以存储任何 JavaScript 值。JavaScript 引擎会做一些内部优化,以便对数组的操作可以很快。然而,随着 Web 应用程序变得越来越强大,尤其一些新增加的功能例如:音频视频编辑,访问 WebSockets 的原始数据等,很明显有些时候如果使用 JavaScript 代码可以快速方便地通过类型化数组来操作原始的二进制数据,这将会非常有帮助。
一句话就是,可以更快的操作复杂数据。
5、调用父构造函数实现继承
function SuperType(){
this.color=[“red”, “green”, “blue”];
}
function SubType(){
// 核心代码,继承自 SuperType
SuperType.call(this);
}

var instance1 = new SubType();
instance1.color.push(“black”);
console.log(instance1.color);
// [“red”, “green”, “blue”, “black”]

var instance2 = new SubType();
console.log(instance2.color);
// [“red”, “green”, “blue”]
在子构造函数中,通过调用父构造函数的 call 方法来实现继承,于是 SubType 的每个实例都会将 SuperType 中的属性复制一份。
缺点:

只能继承父类的实例属性和方法,不能继承原型属性 / 方法
无法实现复用,每个子类都有父类实例函数的副本,影响性能

更多继承方案查看我之前的文章。JavaScript 常用八种继承方案
call 的模拟实现
以下内容参考自 JavaScript 深入之 call 和 apply 的模拟实现

先看下面一个简单的例子
var value = 1;
var foo = {
value: 1
};

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

bar.call(foo); // 1
通过上面的介绍我们知道,call()主要有以下两点

1、call()改变了 this 的指向
2、函数 bar 执行了

模拟实现第一步
如果在调用 call()的时候把函数 bar()添加到 foo()对象中,即如下
var foo = {
value: 1,
bar: function() {
console.log(this.value);
}
};

foo.bar(); // 1
这个改动就可以实现:改变了 this 的指向并且执行了函数 bar。
但是这样写是有副作用的,即给 foo 额外添加了一个属性,怎么解决呢?
解决方法很简单,用 delete 删掉就好了。
所以只要实现下面 3 步就可以模拟实现了。

1、将函数设置为对象的属性:foo.fn = bar

2、执行函数:foo.fn()

3、删除函数:delete foo.fn

代码实现如下:
// 第一版
Function.prototype.call2 = function(context) {
// 首先要获取调用 call 的函数,用 this 可以获取
context.fn = this; // foo.fn = bar
context.fn(); // foo.fn()
delete context.fn; // delete foo.fn
}

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

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

bar.call2(foo); // 1
完美!
模拟实现第二步
第一版有一个问题,那就是函数 bar 不能接收参数,所以我们可以从 arguments 中获取参数,取出第二个到最后一个参数放到数组中,为什么要抛弃第一个参数呢,因为第一个参数是 this。
类数组对象转成数组的方法上面已经介绍过了,但是这边使用 ES3 的方案来做。
var args = [];
for(var i = 1, len = arguments.length; i < len; i++) {
args.push(‘arguments[‘ + i + ‘]’);
}
参数数组搞定了,接下来要做的就是执行函数 context.fn()。
context.fn(args.join(‘,’) ); // 这样不行
上面直接调用肯定不行,args.join(‘,’)会返回一个字符串,并不会执行。
这边采用 eval 方法来实现,拼成一个函数。
eval(‘context.fn(‘ + args +’)’)
上面代码中 args 会自动调用 args.toString() 方法,因为 ’context.fn(‘ + args +’)’ 本质上是字符串拼接,会自动调用 toString()方法,如下代码:
var args = [“a1”, “b2”, “c3”];
console.log(args);
// [“a1”, “b2”, “c3”]

console.log(args.toString());
// a1,b2,c3

console.log(“” + args);
// a1,b2,c3
所以说第二个版本就实现了,代码如下:
// 第二版
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
完美!!
模拟实现第三步
还有 2 个细节需要注意:

1、this 参数可以传 null 或者 undefined,此时 this 指向 window
2、函数是可以有返回值的

实现上面的两点很简单,代码如下
// 第三版
Function.prototype.call2 = function (context) {
context = context || window; // 实现细节 1
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; // 实现细节 2
}

// 测试一下
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
// {
// value: 1,
// name: ‘kevin’,
// age: 18
// }
完美!!!
call 和 apply 模拟实现汇总
call 的模拟实现
ES3:
Function.prototype.call = function (context) {
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;
}
ES6:
Function.prototype.call = function (context) {
context = context || window;
context.fn = this;

let args = […arguments].slice(1);
let result = context.fn(…args);

delete context.fn
return result;
}
apply 的模拟实现
ES3:
Function.prototype.apply = function (context, arr) {
context = 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;
}
ES6:
Function.prototype.apply = function (context, arr) {
context = context || window;
context.fn = this;

let result;
if (!arr) {
result = context.fn();
} else {
result = context.fn(…arr);
}

delete context.fn
return result;
}
思考题
call 和 apply 的模拟实现有没有问题?欢迎思考评论。

PS: 上期思考题留到下一期讲解,下一期介绍重点介绍 bind 原理及实现
参考

JavaScript 深入之 call 和 apply 的模拟实现 MDN 之 Array.prototype.push()
MDN 之 Function.prototype.apply()
MDN 之 Array.prototype.slice()
MDN 之 Array.isArray()
JavaScript 常用八种继承方案
深入浅出 妙用 Javascript 中 apply、call、bind

进阶系列目录

【进阶 1 期】调用堆栈
【进阶 2 期】作用域闭包
【进阶 3 期】this 全面解析
【进阶 4 期】深浅拷贝原理
【进阶 5 期】原型 Prototype
【进阶 6 期】高阶函数
【进阶 7 期】事件机制
【进阶 8 期】Event Loop 原理
【进阶 9 期】Promise 原理
【进阶 10 期】Async/Await 原理
【进阶 11 期】防抖 / 节流原理
【进阶 12 期】模块化详解
【进阶 13 期】ES6 重难点
【进阶 14 期】计算机网络概述
【进阶 15 期】浏览器渲染原理
【进阶 16 期】webpack 配置
【进阶 17 期】webpack 原理
【进阶 18 期】前端监控
【进阶 19 期】跨域和安全
【进阶 20 期】性能优化
【进阶 21 期】VirtualDom 原理
【进阶 22 期】Diff 算法
【进阶 23 期】MVVM 双向绑定
【进阶 24 期】Vuex 原理
【进阶 25 期】Redux 原理
【进阶 26 期】路由原理
【进阶 27 期】VueRouter 源码解析
【进阶 28 期】ReactRouter 源码解析

交流
进阶系列文章汇总如下,内有优质前端资料,觉得不错点个 star。
https://github.com/yygmind/blog
我是木易杨,网易高级前端工程师,跟着我每周重点攻克一个前端面试重难点。接下来让我带你走进高级前端的世界,在进阶的路上,共勉!

正文完
 0