由一个错误引发的对apply函数的规范探寻

前言

最近有同学报告说报表下载不了。我用Chrome看了一下,一个代码报出了一个调用栈错误,然后, 我又用firefox看了一下,功能正常,没有报错。 这个错误截图如下:

apply错误

代码

引发这个错误的代码是apply函数,如下:

1
const str = String.fromCharCode.apply(null, new Uint8Array(ab))

让我很是不解,不解的是:

  1. 为什么报出来的是调用栈错误,因为一般在死循环的情况下会出现此错误,代码里并没有循环。
  2. 为什么 firefox没有出错呢?

虽然前端er可能都知道apply,但还是想罗嗦一下:

apply是Function对象原型上的方法,即Function.prototype.apply,所有的function对象都可以调用它,它可以用来指定函数调用时的上下文(this指向),它还有一个兄弟叫call(Function.prototype.call).

具体用法可以通过MDN查看

apply函数的实现规范

代码里并没有循环,所以是apply的底层实现里使用了循环吗?为了搞清楚这个错误的来源,我去探寻了apply函数的ECMAScript规范,规范说明如下:

Function.prototype.apply (thisArg, argArray)
当以 thisArgargArray 为参数在一个 func 对象上调用 apply 方法,采用如下步骤:

  1. 如果 IsCallable(func)false, 则抛出一个 TypeError 异常 .
  2. 如果 argArraynullundefined, 则
    a. 返回提供 thisArg 作为 this 值并以空参数列表调用 func 的 [[Call]] 内部方法的结果。
  3. 如果 Type(argArray) 不是 Object, 则抛出一个 TypeError 异常 .
  4. len 为以 “length” 作为参数调用 argArray 的 [[Get]] 内部方法的结果。
  5. nToUint32(len).
  6. argList 为一个空列表 .
  7. index0.
  8. 只要 index < n 就重复
    a. 令 indexNameToString(index).
    b. 令 nextArg 为以 indexName 作为参数调用 argArray 的 [[Get]] 内部方法的结果。
    c. 将 nextArg 作为最后一个元素插入到 argList 里。
    d. 设定 indexindex + 1.
  9. 提供 thisArg 作为 this 值并以 argList 作为参数列表,调用 func 的 [[Call]] 内部方法,返回结果。 在外面传入的 thisArg 值会修改并成为 this 值。thisArg 是 undefined 或 null 时它会被替换成全局对象,所有其他值会被应用 ToObject 并将结果作为 this 值,这是第三版引入的更改。

整个规范看起来,可能不是那么直观。我们通过自己的代码简易实现一下就是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
Function.prototype.myApply = function(thisArg, argArray) {
    // 1.如果 `IsCallable(func)` 是 `false`, 则抛出一个 `TypeError` 异常。
    if (typeof this !== 'function') {
        throw new TypeError(`${this.name|| this} is not a function`)
    }
    
    // 2.如果 argArray 是 null 或 undefined, 则
    // 返回提供 thisArg 作为 this 值并以空参数列表调用 func 的 [[Call]] 内部方法的结果。
    if (argArray == null) {
        argArray = []
    }
    
    // 3.如果 Type(argArray) 不是 Object, 则抛出一个 TypeError 异常 .
    if (argArray !== new Object(argArray)) {
        throw new TypeError(`CreateListFromArrayLike called on non-object`)
    }
    
    // 在外面传入的 thisArg 值会修改并成为 this 值。thisArg 是 undefined 或 null 时它会被
    // 替换成全局对象
    if (thisArg == null) {
        thisArg = window || global;
    }
    
    // 所有其他值会被应用 ToObject 并将结果作为 this 值
    thisArg = new Object(thisArg)
    
    const _innerThisKey = '__func'
    thisArg[_innerThisKey] = this
    
    // 9.提供 thisArg 作为 this 值并以 argList 作为参数列表,调用 func 的 [[Call]] 内部
    // 方法,返回结果
    const result = thisArg[_innerThisKey](...argArray)
    
    delete thisArg[_innerThisKey]
    return result
}

在以上规范说明和代码中,也没有使用循环调用。那到底是什么原因导致了我们的调用栈错误呢?

灵机一动

这个时候我突然注意到thisArg[_innerThisKey](...argArray)这句话,如果参数过多的话,是否也会导致调用栈溢出呢?带着这个问题,我去探寻了一下函数参数的限制。

ECMA规范中并没有对函数的参数作限制,但是浏览器有:

  • Chrome(80)中, 对函数参数个数限制为 217-10759,也就是120313,如果超出了,则会报告Uncaught RangeError: Maximum call stack size exceeded
  • macOS safari(13)中,对函数参数个数限制为 216, 也就是65536,如果超出了,则会报告 RangeError: Maximum call stack size exceeded.
  • firefox(72)中,函数参数个数限制为 219-24288, 也就是500000,如果超出了,则会报告 RangeError: too many function arguments

回到代码看问题

问题已经基本清楚了。

回头看看我们出问题的代码, 这段代码是将一个文件的内容读出来然后进行转码,在使用apply函数的过程中,由于文件内容过大达到了240k,导致了applyargArray参数过于长,浏览器在apply底层处理时抛出了调用栈错误。

所以只需要规避这个问题即可。

总结

这个问题,其实是浏览器的锅,不是apply的锅。

当我们碰到问题时,应该有刨根问底的决心和毅力,这样我们就会有长足的进步。

共勉。