Array.prototype.slice(arguments)的猫腻

Category:
发表:
大纲
  1. 1. 前言
  2. 2. 原理
  3. 3. 猫腻
  4. 4. 参考

前言

在我博客之前有过这样一篇文章,JS中易混淆方法备忘录。其中有介绍可以利用一种约定技巧将类数组对象转换成对象。如下

function foo() {
    console.log(arguments);
    var args = Array.prototype.slice.call(arguments);
    console.log(args);
    // more logic
}

在javascript中,函数中的arguments是一个特殊的内置变量,它其实是一个对象,但是其内部的表现却跟数组一致。如下图,

其中arguments.calleearguments.Symbol属性都是不可枚举的(无法用for...in遍历)。

在经过var args = Array.prototype.slice.call(arguments)操作之后,从上图可见,args的结果是一个数组。

原理

如果你不清楚上面内容的内幕,你可能禁不住要问个为什么!下面我们来研究一下其中的内幕。

首先,在Javascript中有两个slice方法,分别是Array.prototype.sliceString.prototype.slice。如果你对xxx.prototype.xxx这种写法有疑问,我想你可能需要补充一下Javascript中原型相关的知识了(无节操小广告:推荐文章1推荐文章2推荐文章3)。这里我们要说的就是前者。

我们先来看几个例子,

var t1 = { 0: 'a', 1: 'b', 2: 'c', length: 3 };
var t2 = { 0: 'a', 1: 'b', 2: 'c', length: 3, 'name': 'larry ge' };
var t3 = { 0: 'a', 1: 'b', length: 3 };
var t4 = { length: 2 };
var t5 = { 0: 'a', 1: 'b' };

var slice = [].slice;

console.log(slice.call(t1)); // ['a', 'b', 'c']
console.log(slice.call(t2)); // ['a', 'b', 'c']
console.log(slice.call(t3)); // ['a', 'b']
console.log(slice.call(t4)); // []
console.log(slice.call(t5)); // []

可见,我们只能转化对象中以数字字符串为键值的属性,同时必须要求对象中有值为数字的length属性。(经高人提示,Object中的所有键值其实都是String类型)

到这里,我们可以猜想一下slice内部大概是如何实现的了。

Array.prototype.slice = function(start, end) {
    var result = new Array();
    start = start || 0;
    // 这里的this指代当前调用此slice方法的数组,
    // 如果使用call或者apply,这个this将会被重定向。
    // 这也就是为何在没有end参数的情况下,必须要有length属性的原因。
    end = end || this.length; 
    for(var i = start; i < end; i++){
        // result.push(this[i]);
        // 这里使用的是this[i],
        // 所以前面示例中的对象中那些不是以数字为键的属性都是拿不到的。
        result[i] = this[i]; 
    }
    return result;
}

所以当我们使用Array.prototype.slice.call或者Array.prototype.slice.apply时,将会把函数中的this自动替换成我们传入的对象。函数执行完毕之后,我们就会得到一个数组了。

以上就是这种技巧的原理了。

衍生的,我们一般会有如下三种方法将一个类数组对象转换成真正的数组,

// 方法一
var args = Array.prototype.slice.call(arguments);

// 方法二
var args = [].slice.call(arguments, 0);

// 方法三
var args = []; 
for (var i = 0; i < arguments.length; i++) { 
    // args.push(arguments[i]);
    args[i] = arguments[i]
}

猫腻

当使用[].slice.call将类数组转换数组成为一种普遍技巧时(我也是一直这么用的),我觉得这是理所当然的,应该不会有什么问题。直到我前段时间读tj/node-thunkify源码时,突然发现一个很有意思的问题。

node-thunkify是一个用来将一个普通函数转化成thunk函数的,它的源码其实很简单,其中有一段,

// more code
var args = new Array(arguments.length);

for(var i = 0; i < args.length; ++i) {
    args[i] = arguments[i];
}
// more code

当时我就奇怪了,这里怎么使用for循环遍历arguments对象,而不是使用一贯的转换技巧呢?难道有什么猫腻?

果然不出所料,我在node-thunkify的issue列表中也找到了跟我有着相关疑问的同学提出的一个issue。然后经过我一番资料的查阅才知道,原来这里还有这么多的门门道道。

首先通过github的issue,我在stackoverlfow上找到了一些相关的问题,比如这个。题主这个问题的本意是想问“难道Node.js真的没有对[].slice.call(arguments)作任何的优化操作么?”

这个问题中引出了几个其他的问题,

  • Node.js的V8引擎会对一些被称之为“hot function”的代码块进行优化
  • 有一些代码的写法会导致V8无法进行优化
  • 当操作arguments对象时,需要额外的注意,因为稍不注意就会导致V8无法优化

Node.js代码库的核心开发者isaacs为此写了一篇文章,详细说明了在Node.js中应该如何将类数组对象arguments处理成一个真正的数组。

这篇文章总结下来,就是两点需要注意,

  • 如果函数的参数不确定(数量及类型),那么不推荐你定义任何的形式参数。
  • 如果想从arguments中取出参数。那么推荐你按照如下的方式来做。
function varArgsList () {
    var args = arguments.length === 1 ? [arguments[0]] : Array.apply(null, arguments);
    // now args is a array
}

或者你可能想要获取除了第一个参数之后的所有参数(这种情况常在命令行工具中遇到),

function manualMap () {
    var l = arguments.length;
    var arr = new Array(l - 1);

    for (var i = 1; i < l; i ++) {
        arr[i - 1] = arguments[i];
    }
}

同时,Node.js的代码库中也有关于这方面的改动,主要是将EventEmitter.prototype.emit中抽取arguments的实现方式改变了。因为EventEmitter模块可以说是Node.js中最重要的模块之一,是所有事件驱动解决方案的基石,无疑EventEmitter就是所谓的“hot function”。

这里是一些相关的benchmark。

BlueBird的文档中提到了一些安全使用arguments的小tips,我觉得很有意义。在使用arguments时我们应该仅仅只从下面这几个方面切入,

  • 可以使用arguments.length
  • 可以使用arguments[i],但是i必须是数字,且不能超过边界
  • 除了arguments.lengtharguments[i]这两种形式外,不要以其他任何形式使用arguments
  • 严格要求,fn.apply(y, arguments)Function#apply这种形式都是ok的)是没问题的,但是其他的形式都是不推荐的,比如.slice

最后,其实我想说,关于这个arguments所谓的性能,可能在绝不多数场景下,都不会成为瓶颈。除非你的某一个功能模块或者业务模块承担着上亿级别的ops/s,否则这种微小的提升带来的收益基本上可以忽略不计。不过话说回来,这个作为业务知识的扩展倒是个不错的门路。而且,说不准随着日后V8引擎的更新,它会越来越智能,自动将这些问题内置解决了呢?你说对吧?😂😂

参考