为什么 es5 不能完美继承数组

@wengjq 2018-03-13 12:55:41发表于 wengjq/Blog Vue

1、为什么要继承数组

我们可以定义“数组子类”作为创建从原生数组对象(在其原型链中具有 Array.prototype)继承的对象的过程,并遵循与原生数组相似(或相同)的行为。

关于类似于原生数组的行为非常重要,我们后面会看到。 拥有数组的“子类”可以被认为能够创建一个数组对象,而不是直接从 Array 继承的对象,而是从另一个对象继承,然后才从 Array 继承。

换句话说,我们需要类似这样的行为:

var sub = new SubArray(1, 2, 3);
sub; // [1, 2, 3]

sub.length; // 3
sub[1]; // 2

sub.push(4);
sub; // [1, 2, 3, 4]

// 等等.

sub intanceof SubArray; // true
sub intanceof Array; // true

注意 SubArray 构造函数如何创建一个与数组行为相同的子对象(对象具有 “length” 属性,数字 “0”,“1”,“2” 属性,并继承 Array.prototype 上的方法)。 同时,SubArray 是直接继承的子对象,而不是 Array 。

那么做这一切的目的究竟是什么? 为什么以这种方式对数组进行继承?

通常有两个原因:

  • 避免污染全局

利用 Javascript 原型扩展数组对象方法很方便。 如下代码:

Array.prototype.last = function () {
  return this[this.length - 1];
};
// ...
[1, 2, 3].last(); // 3

但是,扩展 Array.prototype 有代价的。当脚本与应用程序中的其他脚本共存时,这些脚本有可能相互冲突。扩展 Array.prototype 虽然诱人并且看起来很有用,但不幸的是在多样化的环境中不是很安全。不同的脚本可能最终定义相同名称的方法,但具有不同的行为。这种情况往往会导致不一致的行为和难以追踪的错误。
使用 Array 以外的构造函数 - 但具有相同的行为 - 可以避免这种冲突。不是扩展 Array.prototype,而是扩展另一个对象(比如 SubArray.prototype),然后用来初始化(子)数组对象。任何依赖 Array.prototype 方法的第三方代码仍然能够安全地使用它们。

  • 继承数组的数据结构方法

继承数组的另一个原因是能够使用从数组继承的数据结构方法; 例如 Stack,List,Queue,Set (push,pop,shift,unshift 等)等方法。

2、天真的做法

我们可以使用原型式克隆方法:

function clone(obj) {
  function F() { }
  F.prototype = obj;
  return new F();
}

然后设置如下的继承:

function Child() { }
Child.prototype = clone(Parent.prototype);

这里的原型链:

new Child()
    |
    | [[Prototype]]
    |
    v
Child.prototype
    |
    | [[Prototype]]
    |
    v
Parent.prototype
    |
    | [[Prototype]]
    |
    v
Object.prototype
    |
    | [[Prototype]]
    |
    v
   null

开始实现继承数组:

function SubArray() {
  // 将传递给构造函数的任何参数添加到实例中
  this.push.apply(this, arguments);
}
SubArray.prototype = clone(Array.prototype);

var sub = new SubArray(1, 2, 3);

3、天真方法的问题

那么使用克隆方法继承数组究竟有什么错误? 让我们来看看之前声明的 SubArray 函数的行为。 我们将使用原生数组对象来进行比较。

var arr = new Array(1, 2, 3);
var sub = new SubArray(1, 2, 3);

arr.length; // 3
sub.length; // 0 (in IE<8)

arr.length = 2;
sub.length = 2;

arr; // [1, 2]
sub; // [1, 2, 3]

arr[10] = 'foo';
sub[10] = 'foo';

arr.length; // 11
sub.length; // 2

这里显然有些不一致。 即使我们忽略 IE < 8 中的错误。 但是,数组中的长度和数字属性之间的这种奇怪的关系是什么? 为什么不是和 Array 的行为相同? 为了理解这一点,我们需要查看 JavaScript 中的数组对象。

4、数组的特殊之处

在 Javascript 中的数组几乎就像普通的 Object 对象,除了行为上的一点小差异。 如下引自 es 规范

Array objects give special treatment to a certain class of property names. A property name P (in the form of a string value) is an array index if and only if ToString(ToUint32(P)) is equal to P and ToUint32(P) is not equal to 2^32 - 1. Every Array object has a length property whose value is always a nonnegative integer less than 2^32. The value of the length property is numerically greater than the name of every property whose name is an array index; whenever a property of an Array object is created or changed, other properties are adjusted as necessary to maintain this invariant. Specifically, whenever a property is added whose name is an array index, the length property is changed, if necessary, to be one more than the numeric value of that array index; and whenever the length property is changed, every property whose name is an array index whose value is not smaller than the new length is automatically deleted. This constraint applies only to properties of the Array object itself and is unaffected by length or array index properties that may be inherited from its prototype.

可以概括为:数组对象以特殊的方式处理 “numeric” 属性。 只要这些属性发生变化,数组的 “length” 属性的值也会被调整; 它的调整是为了确保它总是比数组的最大索引大 1 。 类似地,当“长度”属性发生变化时,“numeric” 属性会相应地进行调整。

4.1、当创建数组对象时,其 “length” 属性设置为比数组最大索引大 1 。

  var arr = ['x', 'y', 'z'];
  arr.length; // 3 

  arr = ['foo'];
  arr.length; // 1 

4.2、当 “numeric” 属性发生变化时,“长度”也会发生变化 - 以保持比最大索引大 1 的关系。

var arr = ['x', 'y'];
arr.length; // 2

arr[2] = 'z'; 
arr.length; 

4.3、当“length”属性改变时,“numeric” 属性会进行调整,使得最大索引比“length”的值小 1 。

var arr = ['x', 'y', 'z'];
arr.length = 2;

arr; // ['x', 'y'] 

arr.length = 4;

arr; // ['x', 'y']  // “增加”长度不会影响数字属性...

arr.join(); // "x,y,,"  // 但在其他情况下可以看到后果,例如使用 `Array.prototype.push` 时

arr.push('z');
arr; // ['x', 'y', undefined, undefined, 'z']

现在你知道 Javascript 中的 Array 对象的“特殊”之处了,它处于 “length” 和 “numeric” 属性之间的关系中。 还有一个值得注意的细节是数组的 “length” 属性必须总是具有小于 2 ^ 32 的非负整数值。 只要违反这个条件,就会引发 RangeError 。

var arr = [];
arr.length = Math.pow(2, 32); // RangeError

arr.length; // 0 (长度仍然是0,就像它最初一样)

arr.length = Math.pow(2, 32) - 1; // 将长度设置为最大允许值

arr.length++; // RangeError (明确设置长度时)
arr.push(1); // RangeError (或者在隐式设置长度时)

5、函数对象和构造器

为什么通过 SubArray 和 Array 函数创建的对象的行为存在差异。即使 SubArray 创建了一个从
Array.prototype 继承的对象,该对象完全没有数组的特殊行为。 SubArray 实例只不过是一个普通的
Object 对象(就像它是通过对象字面量创建的一样)。

但为什么 SubArray 创建一个 Object 对象而不是一个 Array 对象?这个问题的核心是 ECMAScript 中函数的工作方式。

当 new 运算符应用于对象时(如在新的 SubArray 中),调用该对象的内部 [[Constructor]] 方法。在我们的例子中,它是 SubArray 函数的 [[Constructor]] 。 SubArray - 作为本地函数 - 具有 [[Constructor]],它指定创建一个普通的 Object 对象,并调用提供新创建对象的相应函数作为此值。任何本地函数(包括SubArray)都应创建一个 Object 对象并将其作为结果返回。

现在值得一提的是,可以通过从构造函数显式返回对象来对 [[Constructor]] 的返回值进行排序:

function SubArray() {
  this.push.apply(this, arguments);
  return []; // 显式返回数组对象
}

但在这种情况下,返回的对象不会继承构造函数的“原型”(在这种情况下是 SubArray.prototype); 构造函数也不会被该对象调用。

var sub = new SubArray(1, 2, 3);

// 对象没有 1,2,3,因为构造函数从未被调用,返回的是 this 值引用 
object
sub; // []

// SubArray 不在返回对象的原型链中
sub instanceof SubArray; // false

综上,创建一个从 Array.prototype 继承的对象只是开始。 最大的问题是保留长度和数字属性的特殊关系。 这就是为什么使用常规克隆方法不能完成的原因。

6、数组特殊行为的重要性

“为什么数组的特殊行为很重要”? 为什么当继承一个数组时,我们想要保持长度和数字属性之间的关系?

以 Array.prototype.push 为例, 要确定从哪个位置开始插入元素,push 将检索数组的 “length” 值。 如果长度未正确保存,则将元素插入错误的位置:

var arr = ['x', 'y'];
arr.length = 5;
arr.push('z'); // 'z' 被插入到第 5 个索引处,因为这是 “length” 的值
arr; // ['x', 'y', undefined, undefined, undefined, 'z']

采取另一种方法 Array.prototype.join ,Array.prototype.join 还使用 length 属性来确定何时停止连接值:

var arr = ['x', 'y'];
arr.join(); // "x,y"
arr.length = 5;
arr.join(); // "x,y,,,"

Array.prototype.concat 同样适用:

var arr = ['x'];
arr.length = 3;
arr.concat('y'); // ['x', undefined, undefined, 'y']

最后,特殊行为通常在其他情况下被巧妙利用,例如“清除”数组(即删除其所有数字属性):

var arr = [1, 2, 3];
arr.length = 0;
arr; // [] — 将长度设置为0会有效地移除数组的所有数值属性(元素)

7、现有的解决方案

现在我们熟悉了这个理论,让我们来看看在实践中对数组进行继承的情况。 这里有几个最受欢迎的:

Andrea Giammarchi解决方案
最近的一个实现是Andrea Giammarchi的 Stack,它看起来像这样:

var Stack = (function () { // (C) Andrea Giammarchi - Mit Style License

  function Stack(length) {
    if (arguments.length === 1 && typeof length === "number") {
      this.length = -1 < length && length === length << 1 >> 1 ? length : this.push(length);
    }
    else if (arguments.length) {
      this.push.apply(this, arguments);
    }
  };

  function Array() { };
  Array.prototype = [];

  Stack.prototype = new Array;
  Stack.prototype.length = 0;
  Stack.prototype.toString = function () {
    return this.slice(0).toString();
  };

  Stack.prototype.constructor = Stack;
  return Stack;
})();

这是一个有趣的解决方案,它主要针对 Array.prototype.push 和 length 属性的 IE < 8 错误。 但是,现在应该很明显,它并没有真正解决维护长度和数字属性之间关系的问题:

var stack = new Stack('x', 'y');
stack.length;           // 2

// 到现在为止还挺好

stack.push('z');
stack.length;           // 3

// 还好

stack[3] = 'foo';
stack.length;           // 3

// 不是很好(长度应该改为4)

stack.length = 2;
stack[2];               // 'z'

// 仍然不好(第二个索引元素应该被删除)

8、Dean Edwards 解决方案

另一个受欢迎的解决方案是 Dean Edwards。 采用了完全不同的方法 - 不是创建一个从Array.prototype 继承的对象,而是从另一个 iframe 的上下文中“借用”实际的 Array 构造函数。

// 创建一个 <iframe>
var iframe = document.createElement("iframe");
iframe.style.display = "none";
document.body.appendChild(iframe);

// 将脚本写入 iframe 并窃取其 Array 对象
frames[frames.length - 1].document.write(
  "<script>parent.Array2 = Array;<\/script>";
);

这种“有效”的原因是由于浏览器为文档中的每个框架创建单独的执行环境。 每个这样的环境都有一套独立的内置和宿主对象。 内置对象包括全局数组构造函数等。 一个 iframe 的数组对象与另一个 iframe 的数组对象不同。 他们也没有任何种类的等级关系:

// 假设 SubArray 是从另一个 iframe 借用的

var sub = new SubArray(1, 2, 3);

sub instanceof SubArray; // true
sub instanceof Array; // false
sub instanceof Object; // false

注意 sub 为什么不是 Array 的一个实例,也不是 Object 的一个实例。 这是因为 Array 和 Object都不在子对象的原型链中。 相反,原型链包含 SubArray.prototype,接着是来自另一个 iframe 的 Object .prototype:

new SubArray()
    |
    | [[Prototype]]
    |
    v
<another iframe>.Array.prototype
    |
    | [[Prototype]]
    |
    v
<another iframe>.Object.prototype
    |
    | [[Prototype]]
    |
    v
   null

这使我们对这种方法有了一个疑问 - 难以确定从这种 iframe 派生的对象的性质。 不再可能使用 instanceof 或构造函数检查来确定对象是数组:

  // is this object an array?

  sub instanceof Array; // false
  sub.constructor === Array; // false

但是,仍然可以使用 [[Class]] 检查(稍后我们将讨论 [[Class]]:

 Object.prototype.toString.call(sub) === '[object Array]'; // true

这种方法的另一个比较大的缺点是它不适用于非浏览器环境(或者更确切地说,在任何不支持 iframe 的环境中)。 鉴于服务器端 Javascript 实现速度非常快,这个问题可能会变得更大。

最后,据报道,数组借入可能导致 IE6 中出现混合内容警告,这是其他一些小问题。

除此之外,基于 iframe 的数组 “subclassing” 不存在像 Stack 这样的解决方案的缺点,因为我们处理的是真正的数组对象,并且具有适当的长度/索引关系。

9、ECMAScript 5 属性访问器

我们来谈谈 ECMAScript 5,正如我在一开始提到的,它带来了一些有助于继承数组的东西。这个“东西”其实不过是属性访问器。这些有用的语言结构已经在一些流行的实现(SpiderMonkey,JavaScriptCore 等)中作为非标准扩展出现了很长一段时间。现在它们已经在新版本实现了。

使用访问器,创建一个具有特殊长度/索引关系的 Object 对象是相当简单的 - 这与 Array 对象的关系相同!而且由于我们已经知道如何在其原型链中创建一个具有 Array.prototype 的对象,所以将这两个方面结合起来就可以完全模拟数组。

有一个关于实施的细节。由于 ECMAScript(包括 last,5th 版本)不提供任何 catch-all(aka noSuchMethod)机制,因此在修改 numeric 属性时无法更改对象的 length 属性值;换句话说,我们不能拦截 '0','1','2','15' 等属性被设置的场景。但是,访问器允许我们截取 length 属性的任何读取访问权限并返回适当的值,具体取决于当时具有哪个数字属性对象。而这是我们真正需要的。

这是它的一个实现,大约有45行代码:

var makeSubArray = (function(){

  var MAX_SIGNED_INT_VALUE = Math.pow(2, 32) - 1,
      hasOwnProperty = Object.prototype.hasOwnProperty;

  function ToUint32(value) {
    return value >>> 0;
  }

  function getMaxIndexProperty(object) {
    var maxIndex = -1, isValidProperty;

    for (var prop in object) {

      isValidProperty = (
        String(ToUint32(prop)) === prop &&
        ToUint32(prop) !== MAX_SIGNED_INT_VALUE &&
        hasOwnProperty.call(object, prop));

      if (isValidProperty && prop > maxIndex) {
        maxIndex = prop;
      }
    }
    return maxIndex;
  }

  return function(methods) {
    var length = 0;
    methods = methods || { };

    methods.length = {
      get: function() {
        var maxIndexProperty = +getMaxIndexProperty(this);
        return Math.max(length, maxIndexProperty + 1);
      },
      set: function(value) {
        var constrainedValue = ToUint32(value);
        if (constrainedValue !== +value) {
          throw new RangeError();
        }
        for (var i = constrainedValue, len = this.length; i < len; i++) {
          delete this[i];
        }
        length = constrainedValue;
      }
    };
    methods.toString = {
      value: Array.prototype.join
    };
    return Object.create(Array.prototype, methods);
  };
})();

我们现在可以通过 makeSubArray 函数创建“子数组”。 它接受一个参数 - 一个带有方法的对象,将其添加到 [[Prototype]] 返回的“子数组”中。

var subMethods = {
  last: {
    value: function() {
      return this[this.length - 1];
    }
  }
};
var sub = makeSubArray(subMethods);
var sub2 = makeSubArray(subMethods);
// 等等

我们也可以将这个工厂方法隐藏在构造函数的后面,使其与 Array 的类似:

var SubArray = (function () {
  var methods = {
    last: {
      value: function() {
        return this[this.length - 1];
      }
    }
  };
  return function() {
    var arr = makeSubArray(methods);
    if (arguments.length === 1) {
      arr.length = arguments[0];
    }
    else {
      arr.push.apply(arr, arguments);
    }
    return arr;
  };
})();

然后像使用常规 Array 构造函数一样使用它:

var sub = new SubArray(1, 2, 3);

sub.length; // 3
sub; // [1, 2, 3]

sub.length = 1;
sub; // [1]

sub[10] = 'x';
sub.push(1);

10、[[Class]] 限制

我们刚刚看到利用属性访问器的实现。它不需要任何主机对象(如iframe);它保留长度和数字属性之间的关系;它甚至不允许长度或指数超出范围的值。它只需要支持 ES5(甚至只是Object.create方法)。

但是 [[Class]] 值 - ECMAScript 仍然没有完全控制。

在解释如何检测数组时,我之前曾写过 [[Class]] 。简而言之,[[Class]] 是 ECMAScript 中对象的内部属性。它的值从不直接暴露,但仍可以使用某些方法(例如 Object.prototype.toString)进行检查。 [[Class]]
的用处在于,它允许检测对象的类型,而不依赖于 instanceof 运算符或检查对象的构造函数 - 两者都不足以检测来自其他上下文(例如 iframe)的对象,如前所述。

现在,由于 makeSubArray 创建的对象只是普通的 Object 对象(只有特殊长度的 getter / setter),它们的 [[Class] ]也是 “Object” 而不是 “Array”!我们已经考虑了长度/索引关系,我们设置了 Array.prototype 继承,但是没有办法改变对象的 [[Class]] 值。所以这个解决方案不能说是完美的。

11、[[Class]] 是否重要?

您可能想知道 - 这些数组对象的 [Object] 的 [[Class]] 不是 “Array” 的实际含义是什么。实际上,不能继承[[Class]] 会有不能对象检测的问题。

// assuming that `sub` is a pseudo-array
Object.prototype.toString.call(sub) === '[object Array]'; // false

另一个可能更重要的含义是,ECMAScript 中的一些方法实际上依赖于 [[Class]] 值。 例如,一个众所周知的 Function.prototype.apply 接受一个数组作为它的第二个参数(以及一个参数对象)。 ES3 的 15.3.4.3节说 - “如果 argArray 既不是数组也不是参数对象(见10.1.8),则抛出 TypeError 异常”。 这意味着如果我们传递伪数组对象作为第二个参数来应用它将抛出 TypeError 。 应用程序不知道或关心一个对象是否从
Array.prototype 继承; 它也不关心实现特殊长度/指数行为的对象。 它所关心的只是对象是适当的类型 - 我们很遗憾不能模拟这种类型。

// 假设 `sub` 是一个伪数组
someFunction.apply(this, sub); // TypeError 

这方面的规定有些模糊。 例如,在 Date.prototype.setTime spec 中说“如果这个值不是一个 Date 对象,则抛出一个 TypeError 异常”,但在 Date.prototype.getTime 中,它使用 [[Class]] 而不是 “not a Date 对象“ - ”如果此值不是其 [[Class]] 属性为 “Date” 的对象,则引发 TypeError 异常“。

假设这两个短语 - “ Date 对象”和 “Date ['Class] 中的对象”)具有相同的含义可能是安全的。 “ Array 对象”和“ Array [] 的 [[Class]] 对象”以及其他对象也是类似的。

Function.prototype.apply 不是对对象 [[Class]] 敏感的唯一方法。 例如,Array.prototype.concat 基于对象是否为数组(不管是否具有 [[Class] ]“Array”),都遵循不同的算法。

// array ([[Class]] == "Array")
var arr = ['x', 'y'];

// object with numeric properties ([[Class]] == "Object")
var obj = { '0': 'x', '1': 'y' };

[1,2,3].concat(arr); // [1, 2, 3, 'x', 'y']
[1,2,3].concat(obj); // [1, 2, 3, { '0': 'x', '1': 'y' }]

正如你所看到的,数组的值是“扁平的”,而非数组的则保持不变。 当然可以给这些伪数组自定义 concat
实现(并“修复” Array.prototype 上方法中的任何其他方法),但是 Function.prototype.apply 的问题无法解决。

值得一提的是,基于存取器的数组方法的另一个缺点是性能。 我还没有做过任何测试,但很明显,每次访问 length 属性时必须枚举所有数字属性的实现并不会很好。 这就是为什么我不能推荐这个解决方案的原因。

12、包装, 直接属性注入

在 Javascript 中实现数组的继承有些徒劳无功,通常会使替代解决方案看起来非常有吸引力。 其中一种解决方案是使用包装。 包装方法避免了设置继承或模拟长度/索引关系。 相反,类似工厂的函数可以创建一个普通的 Array 对象,然后使用任何自定义方法直接对其进行扩充。 由于返回的对象是一个数组,所以它保持适当的长度/索引关系,以及“数组”的 [[Class]] 。 它也自然地从 Array.prototype 继承。

function makeSubArray() {
  var arr = [ ];
  arr.push.apply(arr, arguments);
  arr.last = function() {
    return this[this.length - 1];
  };
  return arr;
}

var sub = makeSubArray(1, 2, 3);
sub instanceof Array; // true

sub.length; // 3
sub.last(); // 3

尽管数组对象的直接扩展是一个美观,简单的解决方案,但它并非没有缺点。 主要缺点是每次调用构造函数时,需要使用 N 个方法来扩展数组。 创建数组所需的时间不再是常量(如果方法在
SubArray.prototype 上),而是与需要添加的方法的数量成正比。

13、包装, 原型链注入

为了克服“N方法”的问题,可以使用包装器的另一种变体 - 其中对象的原型链增加的变体,而不是对象本身。 让我们看看如何做到这一点:

function SubArray() { }
  SubArray.prototype = new Array;
  SubArray.prototype.last = function() {
  return this[this.length - 1];
};

function makeSubArray() {
  var arr = [ ];
  arr.push.apply(arr, arguments);
  arr.__proto__ = SubArray.prototype;
  return arr;
}

这个想法很简单。 当执行 makeSubArray 函数时,会发生两件事:
1)创建一个数组对象并使用任何传递的参数填充;
2)对象的原型链以这种方式增加,以便下一个对象是 SubArray.prototype,而不是原始Array.prototype。 原型链的扩充是通过非标准 proto 属性完成的。

但是,makeSubArray 函数中发生的事情当然只是任务的一半。 为了确保该对象在其原型链中具有Array.prototype,我们需要使 SubArray.prototype 从它继承。 这正是这段代码的第二行(SubArray.prototype = new Array)所做的。 从 makeSubArray 返回的对象的原型链如下所示:

new SubArray()
    |
    | [[Prototype]]
    |
    v
SubArray.prototype
    |
    | [[Prototype]]
    |
    v
Array.prototype
    |
    | [[Prototype]]
    |
    v
Object.prototype
    |
    | [[Prototype]]
    |
    v
   null

因为返回的对象实际上是一个数组,而不是对象,我们也得到长度/指数关系以及适当的 [[Class]] 值。 实际上,我们可以更进一步并将初始化逻辑移入 SubArray 构造函数本身:

function SubArray() {
  var arr = [ ];
  arr.push.apply(arr, arguments);
  arr.__proto__ = SubArray.prototype;
  return arr;
}
SubArray.prototype = new Array;
SubArray.prototype.last = function() {
  return this[this.length - 1];
};

var sub = new SubArray(1, 2, 3);

sub instanceof SubArray; // true
sub instanceof Array; // true

尽管扩充原型链是一个更高性能的解决方案,但它有一个明显的缺点 - 它依赖于非标准的 proto 属性。 不幸的是,ECMAScript 不允许设置一个对象的 [[Prototype]] - 在其原型链中引用直接祖先的内部属性。 即使在第五版中也没有。 尽管 proto 被大量的实现支持,但它远没有真正兼容。

14、后记

本篇文章翻译自 http://perfectionkills.com/how-ecmascript-5-still-does-not-allow-to-subclass-an-array,如果不想看我蹩脚的翻译,可以直接查看原文。