用JavaScript创建范围-奇怪的语法


129

我在es-discuss邮件列表中遇到了以下代码:

Array.apply(null, { length: 5 }).map(Number.call, Number);

这产生

[0, 1, 2, 3, 4]

为什么这是代码的结果?这里发生了什么事?


2
IMO Array.apply(null, Array(30)).map(Number.call, Number)易于阅读,因为它避免了假装普通对象是数组。
fncomp

10
@fncomp请不要使用任何一个来实际创建范围。它不仅比简单的方法慢,而且还不那么容易理解。在这里很难理解语法(嗯,实际上是API而不是语法),这使这成为一个有趣的问题,但生产代码IMO却很糟糕。
本杰明·格伦鲍姆

是的,不建议任何人使用它,但相对于对象文字版本而言,它仍更易于阅读。
fncomp

1
我不确定为什么有人会这样做。用这种方式创建数组所需的时间本来可以稍微减少一些但可以更快地完成:jsperf.com/basic-vs-extreme
Eric Hodonsky 2013年

Answers:


263

了解此“ hack”需要了解几件事:

  1. 为什么我们不只是做 Array(5).map(...)
  2. 如何Function.prototype.apply处理论点
  3. 如何Array处理多个参数
  4. Number函数如何处理参数
  5. 是什么Function.prototype.call

它们是javascript中相当高级的主题,因此它的时间要长得多。我们将从顶部开始。系好安全带!

1.为什么不只是Array(5).map

真的是什么数组?包含整数键的常规对象,它们映射到值。它具有其他特殊功能,例如魔术length变量,但key => value与其他任何对象一样,它的核心是规则映射。让我们玩一下数组吧?

var arr = ['a', 'b', 'c'];
arr.hasOwnProperty(0); //true
arr[0]; //'a'
Object.keys(arr); //['0', '1', '2']
arr.length; //3, implies arr[3] === undefined

//we expand the array by 1 item
arr.length = 4;
arr[3]; //undefined
arr.hasOwnProperty(3); //false
Object.keys(arr); //['0', '1', '2']

我们了解到数组中的项目arr.lengthkey=>value与数组中具有的映射数之间的内在差异,该差异可能不同于arr.length

通过扩展数组arr.length 不会创建任何新数组key=>value映射,因此不是数组具有未定义的值,也不具有这些键。当您尝试访问不存在的属性时会发生什么?你得到undefined

现在,我们可以稍微抬起头来,看看为什么像arr.map这样的函数不会跳过这些属性。如果arr[3]只是未定义,并且键存在,则所有这些数组函数将像其他任何值一样遍历它:

//just to remind you
arr; //['a', 'b', 'c', undefined];
arr.length; //4
arr[4] = 'e';

arr; //['a', 'b', 'c', undefined, 'e'];
arr.length; //5
Object.keys(arr); //['0', '1', '2', '4']

arr.map(function (item) { return item.toUpperCase() });
//["A", "B", "C", undefined, "E"]

我故意使用方法调用来进一步证明密钥本身不存在的观点:调用undefined.toUpperCase会引发错误,但事实并非如此。为了证明

arr[5] = undefined;
arr; //["a", "b", "c", undefined, "e", undefined]
arr.hasOwnProperty(5); //true
arr.map(function (item) { return item.toUpperCase() });
//TypeError: Cannot call method 'toUpperCase' of undefined

现在我们要说的是:Array(N)事情如何进行。15.4.2.2节介绍了该过程。有很多我们不关心的庞然大物,但是如果您设法在两行之间阅读(或者您可以在这行上相信我,但是不要),则基本上可以归结为:

function Array(len) {
    var ret = [];
    ret.length = len;
    return ret;
}

(在假设条件下运行(在实际规格中进行了检查) len有效的uint32且不只是任意值)下运行)

所以现在您可以看到为什么这样做Array(5).map(...)行不通-我们没有len在数组上定义项目,我们没有创建key => value映射,我们只是改变了length属性。

现在我们已经解决了这个问题,让我们来看第二个神奇的东西:

2.如何 Function.prototype.apply工作原理

什么apply确实基本上是采取一个数组,并展开其作为函数调用的参数。这意味着以下内容几乎相同:

function foo (a, b, c) {
    return a + b + c;
}
foo(0, 1, 2); //3
foo.apply(null, [0, 1, 2]); //3

现在,我们可以apply通过简单地记录arguments特殊变量来简化查看工作方式的过程:

function log () {
    console.log(arguments);
}

log.apply(null, ['mary', 'had', 'a', 'little', 'lamb']);
 //["mary", "had", "a", "little", "lamb"]

//arguments is a pseudo-array itself, so we can use it as well
(function () {
    log.apply(null, arguments);
})('mary', 'had', 'a', 'little', 'lamb');
 //["mary", "had", "a", "little", "lamb"]

//a NodeList, like the one returned from DOM methods, is also a pseudo-array
log.apply(null, document.getElementsByTagName('script'));
 //[script, script, script, script, script, script, script, script, script, script, script, script, script, script, script, script, script, script, script, script]

//carefully look at the following two
log.apply(null, Array(5));
//[undefined, undefined, undefined, undefined, undefined]
//note that the above are not undefined keys - but the value undefined itself!

log.apply(null, {length : 5});
//[undefined, undefined, undefined, undefined, undefined]

倒数第二个例子很容易证明我的主张:

function ahaExclamationMark () {
    console.log(arguments.length);
    console.log(arguments.hasOwnProperty(0));
}

ahaExclamationMark.apply(null, Array(2)); //2, true

(是的,是双关语)。key => value我们传递给的数组中可能不存在该映射apply,但是它确实存在于arguments变量中。这与上一个示例起作用的原因相同:键不存在于我们传递的对象上,但确实存在于arguments

这是为什么?让我们看一下第15.3.4.3节,在哪里Function.prototype.apply定义。大多数情况下,我们并不在乎,但这是有趣的部分:

  1. 令len为使用参数“ length”调用argArray的[[Get]]内部方法的结果。

基本上是指:argArray.length。然后,规范继续对各个项目进行简单的for循环length,从而生成一个list对应的值(list是一些内部伏都教具,但基本上是一个数组)。就非常非常松散的代码而言:

Function.prototype.apply = function (thisArg, argArray) {
    var len = argArray.length,
        argList = [];

    for (var i = 0; i < len; i += 1) {
        argList[i] = argArray[i];
    }

    //yeah...
    superMagicalFunctionInvocation(this, thisArg, argList);
};

因此,argArray在这种情况下,我们需要模拟的是带有length属性的对象。现在,我们可以看到为什么未定义值,但是键却没有定义arguments:我们创建了key=>value映射。

ew,所以这可能不比上一部分短。但是,当我们完成时会有蛋糕,所以请耐心等待!但是,在接下来的小节(我保证会很短)之后,我们可以开始剖析表达式。万一您忘记了,问题是以下工作原理:

Array.apply(null, { length: 5 }).map(Number.call, Number);

3.如何Array处理多个参数

所以!我们看到了将length参数传递给时会发生什么Array,但是在表达式中,我们传递了几件事作为参数(undefined确切地说是5的数组)。15.4.2.1节告诉我们该怎么做。最后一段对我们来说至关重要,其措辞确实很奇怪,但可以归结为:

function Array () {
    var ret = [];
    ret.length = arguments.length;

    for (var i = 0; i < arguments.length; i += 1) {
        ret[i] = arguments[i];
    }

    return ret;
}

Array(0, 1, 2); //[0, 1, 2]
Array.apply(null, [0, 1, 2]); //[0, 1, 2]
Array.apply(null, Array(2)); //[undefined, undefined]
Array.apply(null, {length:2}); //[undefined, undefined]

多田 我们得到一个包含几个未定义值的数组,然后返回这些未定义值的数组。

表达式的第一部分

最后,我们可以解密以下内容:

Array.apply(null, { length: 5 })

我们看到它返回一个包含5个未定义值的数组,并且所有键都存在。

现在,到表达式的第二部分:

[undefined, undefined, undefined, undefined, undefined].map(Number.call, Number)

这将是更简单,更轻松的部分,因为它并不太依赖晦涩的hack。

4.如何 Number对待输入

Doing Number(something)第15.7.1节)将转换something为数字,仅此而已。它的操作方式有些复杂,尤其是在字符串的情况下,但是操作在9.3节中定义。如果您感兴趣的话中。

5.游戏 Function.prototype.call

call第15.3.4.4节中apply定义的的兄弟。它不采用参数数组,而只是采用接收到的参数并将它们向前传递。

当您将多个链接call在一起时,事情变得很有趣,将怪异的事物提高到11:

function log () {
    console.log(this, arguments);
}
log.call.call(log, {a:4}, {a:5});
//{a:4}, [{a:5}]
//^---^  ^-----^
// this   arguments

直到您掌握正在发生的事情,这才是相当值得的。log.call只是一个函数,等效于任何其他函数的call方法,因此call本身也具有一个方法:

log.call === log.call.call; //true
log.call === Function.call; //true

怎么call办?它接受thisArg和一堆参数,并调用其父函数。我们可以通过apply (再次,非常松散的代码,将无法使用)进行定义:

Function.prototype.call = function (thisArg) {
    var args = arguments.slice(1); //I wish that'd work
    return this.apply(thisArg, args);
};

让我们跟踪一下这种情况如何:

log.call.call(log, {a:4}, {a:5});
  this = log.call
  thisArg = log
  args = [{a:4}, {a:5}]

  log.call.apply(log, [{a:4}, {a:5}])

    log.call({a:4}, {a:5})
      this = log
      thisArg = {a:4}
      args = [{a:5}]

      log.apply({a:4}, [{a:5}])

后面的部分 .map全部

还没结束。让我们看看为大多数数组方法提供函数时会发生什么:

function log () {
    console.log(this, arguments);
}

var arr = ['a', 'b', 'c'];
arr.forEach(log);
//window, ['a', 0, ['a', 'b', 'c']]
//window, ['b', 1, ['a', 'b', 'c']]
//window, ['c', 2, ['a', 'b', 'c']]
//^----^  ^-----------------------^
// this         arguments

如果我们自己不提供this参数,则默认为window。请注意将参数提供给我们的回调的顺序,让我们再次将其一直弄到11:

arr.forEach(log.call, log);
//'a', [0, ['a', 'b', 'c']]
//'b', [1, ['a', 'b', 'c']]
//'b', [2, ['a', 'b', 'c']]
// ^    ^

哇哇...让我们备份一下。这里发生了什么?我们可以在15.4.4.18节中看到,在这里forEach定义了以下内容:

var callback = log.call,
    thisArg = log;

for (var i = 0; i < arr.length; i += 1) {
    callback.call(thisArg, arr[i], i, arr);
}

因此,我们得到以下信息:

log.call.call(log, arr[i], i, arr);
//After one `.call`, it cascades to:
log.call(arr[i], i, arr);
//Further cascading to:
log(i, arr);

现在我们可以看到.map(Number.call, Number)工作原理:

Number.call.call(Number, arr[i], i, arr);
Number.call(arr[i], i, arr);
Number(i, arr);

它将i当前索引的转换返回到一个数字。

结论,

表达方式

Array.apply(null, { length: 5 }).map(Number.call, Number);

分为两个部分:

var arr = Array.apply(null, { length: 5 }); //1
arr.map(Number.call, Number); //2

第一部分创建了一个由5个未定义项组成的数组。第二个遍历该数组并获取其索引,从而生成一个元素索引数组:

[0, 1, 2, 3, 4]

@Zirak请帮助我理解以下内容ahaExclamationMark.apply(null, Array(2)); //2, true。为什么会分别返回2true?您不是只通过一个论点Array(2)吗?
极客

4
@Geek我们仅将一个参数传递给apply,但是该参数被“分割”为传递给函数的两个参数。在第一个apply示例中,您可以更轻松地看到这一点。console.log然后,第一个显示确实,我们收到了两个参数(两个数组项),第二个console.log显示该数组key=>value在第一个插槽中有一个映射(如答案的第一部分所述)。
Zirak 2013年

4
由于(某些)要求,您现在可以享受音频版本:dl.dropboxusercontent.com/u/24522528/SO-answer.mp3
Zirak

1
请注意,log.apply(null, document.getElementsByTagName('script'));不需要像in 中那样将作为宿主对象的NodeList传递给本机方法,并且在某些浏览器中也不起作用,并且[].slice.call(NodeList)将NodeList转换为数组在它们中也不起作用。
RobG 2013年

2
一种更正:this仅默认Window为非严格模式。
ComFreek

21

免责声明:这是上面代码的非常正式的描述-这就是知道如何解释它的方式。对于一个简单的答案-检查上面的Zirak的好答案。这是您脸上的更详细的说明,更少了“啊哈”。


这里发生了几件事。让我们分解一下。

var arr = Array.apply(null, { length: 5 }); // Create an array of 5 `undefined` values

arr.map(Number.call, Number); // Calculate and return a number based on the index passed

在第一行中,该阵列构造函数作为函数调用Function.prototype.apply

  • thisnull对Array构造函数无关紧要(与15.3.4.3.2.a中的上下文this相同this
  • 然后new Array,将其传递给具有length属性的对象称为-由于该对象.apply中的以下子句,导致该对象成为数组,就像所有相关的事情一样.apply
    • 令len为使用参数“ length”调用argArray的[[Get]]内部方法的结果。
  • 因此,.apply被传递参数从0到.length,由于主叫[[Get]]{ length: 5 }具有值0〜4的产率undefined的阵列构造函数被调用与五个参数,其值是undefined(获得的对象的未声明属性)。
  • 使用0、2或更多参数调用数组构造函数。根据规范将新构造的数组的length属性设置为参数数量,并将值设置为相同的值。
  • 因此var arr = Array.apply(null, { length: 5 });创建了五个未定义值的列表。

注意:请注意Array.apply(0,{length: 5})和之间的区别Array(5),第一个创建原始值类型的五倍,undefined第二个创建长度为5的空数组。特别是由于.map的行为(8.b)和特有[[HasProperty]

因此,上述符合规范中的代码与以下代码相同:

var arr = [undefined, undefined, undefined, undefined, undefined];
arr.map(Number.call, Number); // Calculate and return a number based on the index passed

现在转到第二部分。

  • Array.prototype.mapNumber.call在数组的每个元素上调用回调函数(在这种情况下为),并使用指定的this值(在这种情况下,将this值设置为`Number')。
  • map中回调的第二个参数(在本例中为Number.call)是索引,第一个是this值。
  • 这意味着Number使用thisas undefined(数组值)和index作为参数来调用。因此,这基本上与将每个映射undefined到其数组索引相同(因为调用Number执行类型转换,在这种情况下,从数字到数字不更改索引)。

因此,以上代码采用了五个未定义的值,并将每个值映射到其在数组中的索引。

这就是为什么我们将结果保存到代码中的原因。



1
但是,为什么它仅与Array.apply(null, { length: 2 })而不起作用,而Array.apply(null, [2])这也将调用Array构造函数2作为长度值传递呢?小提琴
安德里亚斯

@Andreas Array.apply(null,[2])就像这样Array(2),它将创建一个长度为2 的数组,而不是undefined两次包含原始值的数组。在第一部分后面的注释中查看我最近的编辑,请让我知道是否足够清楚,否则我将予以澄清。
Benjamin Gruenbaum 2013年

我还不了解它在第一次运行时的工作方式……在二读之后,它才有意义。{length: 2}伪造具有两个元素的数组,Array构造函数将其插入到新创建的数组中。由于没有实际的数组访问,因此不存在的元素产生undefined并随后插入。好招:)
Andreas

5

如您所说,第一部分:

var arr = Array.apply(null, { length: 5 }); 

创建一个由5个undefined值组成的数组。

第二部分调用map数组的函数,该函数接受2个参数并返回相同大小的新数组。

第一个采用的参数map实际上是一个应用于数组中每个元素的函数,它应该是一个采用3个参数并返回值的函数。例如:

function foo(a,b,c){
    ...
    return ...
}

如果我们将函数foo作为第一个参数传递,则将为每个元素调用

  • a作为当前迭代元素的值
  • b作为当前迭代元素的索引
  • c作为整个原始数组

接受的第二个参数map将传递给您作为第一个参数传递的函数。但它不会是在以下情况下,A,B,也不Ç foo,这将是this

两个例子:

function bar(a,b,c){
    return this
}
var arr2 = [3,4,5]
var newArr2 = arr2.map(bar, 9);
//newArr2 is equal to [9,9,9]

function baz(a,b,c){
    return b
}
var newArr3 = arr2.map(baz,9);
//newArr3 is equal to [0,1,2]

另一个只是为了使它更清楚:

function qux(a,b,c){
    return a
}
var newArr4 = arr2.map(qux,9);
//newArr4 is equal to [3,4,5]

那么Number.call呢?

Number.call 是一个带有2个参数的函数,并尝试将第二个参数解析为一个数字(我不确定第一个参数的作用)。

由于map传递的第二个参数是索引,因此将在该索引处放置在新数组中的值等于索引。就像baz上面示例中的函数一样。Number.call将尝试解析索引-它自然会返回相同的值。

map在代码中传递给函数的第二个参数实际上对结果没有影响。请纠正我,如果我错了。


1
Number.call不是解析数字参数的特殊函数。只是=== Function.prototype.call。只有第二个参数,即作为this-value 传递给的函数call,是相关的- .map(eval.call, Number).map(String.call, Number)并且.map(Function.prototype.call, Number)都是等效的。
Bergi

0

数组只是一个包含“长度”字段和某些方法(例如推入)的对象。因此arr in var arr = { length: 5}基本上与数组0..4具有未定义的默认值(即arr[0] === undefined产生true)的数组相同。
至于第二部分,顾名思义,map从一个数组映射到一个新数组。它通过遍历原始数组并在每个项目上调用映射功能来实现。

剩下的就是说服您映射功能的结果就是索引。诀窍是使用名为“ call”(*)的方法,该方法将调用一个函数,但有一个小的例外:第一个参数设置为“ this”上下文,第二个参数设置为第一个参数(依此类推)。巧合的是,当调用映射函数时,第二个参数是索引。

最后但并非最不重要的一点,所调用的方法是Number“ Class”,正如我们在JS中所知道的,“ Class”只是一个函数,而这个(Number)期望第一个参数是值。

(*)在Function的原型中找到(而Number是一个函数)。

玛莎


1
[undefined, undefined, undefined, …]和之间有巨大的区别- new Array(n){length: n}后者稀疏,即它们没有元素。这与有关map,这就是为什么使用奇数的原因Array.apply
Bergi 2013年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.