第 28 章。对内置对象进行子类化
目录
购买书籍
(广告,请不要屏蔽。)

第 28 章。对内置对象进行子类化

JavaScript 的内置构造函数很难进行子类化。 本章解释了原因并提出了解决方案。

术语

我们使用短语 对内置对象进行子类化 并避免使用术语 扩展,因为它在 JavaScript 中有其他含义

对内置对象 A 进行子类化
创建一个给定内置构造函数 A 的子构造函数 BB 的实例也是 A 的实例。
扩展对象 obj
将一个对象的属性复制到另一个对象。Underscore.js 使用此术语,延续了 Prototype 框架建立的传统。

对内置对象进行子类化有两个障碍:具有内部属性的实例和不能作为函数调用的构造函数。

障碍 1:具有内部属性的实例

大多数内置构造函数 的实例都具有所谓的 内部属性(参见 属性的种类),其名称用双括号括起来,如下所示:[[PrimitiveValue]]。内部属性由 JavaScript 引擎管理,通常不能在 JavaScript 中直接访问。JavaScript 中的常规子类化技术是使用子构造函数的 this 将超构造函数作为函数调用(参见 第 4 层:构造函数之间的继承

function Super(x, y) {
    this.x = x;  // (1)
    this.y = y;  // (1)
}
function Sub(x, y, z) {
    // Add superproperties to subinstance
    Super.call(this, x, y);  // (2)
    // Add subproperty
    this.z = z;
}

大多数内置对象会忽略作为 this 传入的子实例 (2),下一节将介绍此障碍。此外,通常不可能将内部属性添加到现有实例 (1) 中,因为它们往往会从根本上改变实例的性质。因此,(2) 处的调用不能用于添加内部属性。以下构造函数的实例具有内部属性

包装器构造函数

BooleanNumberString 的实例包装了原始值。它们都具有内部属性 [[PrimitiveValue]],其值由 valueOf() 返回;String 还有两个额外的实例属性

  • Boolean:内部实例属性 [[PrimitiveValue]]
  • Number:内部实例属性 [[PrimitiveValue]]
  • String:内部实例属性 [[PrimitiveValue]],自定义内部实例方法 [[GetOwnProperty]],普通实例属性 length。当使用数组索引时,[[GetOwnProperty]] 允许通过从包装的字符串中读取来对字符进行索引访问。
Array
自定义内部实例方法 [[DefineOwnProperty]] 拦截要设置的属性。它通过在添加数组元素时保持 length 最新并在 length 变小时删除多余的元素,来确保 length 属性正常工作。
Date
内部实例属性 [[PrimitiveValue]] 存储日期实例表示的时间(自 1970 年 1 月 1 日 00:00:00 UTC 以来的毫秒数)。
Function
内部实例属性 [[Call]](调用实例时要执行的代码)以及其他可能的属性。
RegExp

内部实例属性 [[Match]],以及两个非内部实例属性。根据 ECMAScript 规范

[[Match]] 内部属性的值是 RegExp 对象的模式的实现相关表示。

唯一没有内部属性的内置构造函数是 ErrorObject

障碍 1 的解决方法

MyArrayArray 的子类。它有一个 getter size,它返回数组中的实际元素,忽略空洞(length 会考虑空洞)。实现 MyArray 所用的技巧是创建一个数组实例并将其方法复制到其中:[22]

function MyArray(/*arguments*/) {
    var arr = [];
    // Don’t use Array constructor to set up elements (doesn’t always work)
    Array.prototype.push.apply(arr, arguments);  // (1)
    copyOwnPropertiesFrom(arr, MyArray.methods);
    return arr;
}
MyArray.methods = {
    get size() {
        var size = 0;
        for (var i=0; i < this.length; i++) {
            if (i in this) size++;
        }
        return size;
    }
}

此代码使用辅助函数 copyOwnPropertiesFrom(),该函数在 复制对象 中显示和解释。

我们在第 (1) 行没有调用 Array 构造函数,因为有一个怪癖:如果使用单个数字参数调用它,则该数字不会成为元素,而是确定空数组的长度(参见 使用元素初始化数组(避免!))。

以下是交互

> var a = new MyArray('a', 'b')
> a.length = 4;
> a.length
4
> a.size
2

注意事项

将方法复制到实例会导致冗余,如果我们可以使用原型,则可以避免这些冗余。此外,MyArray 创建的对象不是其实例

> a instanceof MyArray
false
> a instanceof Array
true

障碍 2:不能作为函数调用的构造函数

即使 Error子类没有具有内部属性的实例,您仍然无法轻松地对其进行子类化,因为标准的子类化模式将不起作用(从前面重复):

function Super(x, y) {
    this.x = x;
    this.y = y;
}
function Sub(x, y, z) {
    // Add superproperties to subinstance
    Super.call(this, x, y);  // (1)
    // Add subproperty
    this.z = z;
}

问题在于 Error 总是会生成一个新实例,即使作为函数调用也是如此 (1);也就是说,它会忽略通过 call() 传递给它的参数 this

> var e = {};
> Object.getOwnPropertyNames(Error.call(e)) // new instance
[ 'stack', 'arguments', 'type' ]
> Object.getOwnPropertyNames(e) // unchanged
[]

在前面的交互中,Error 返回一个具有自身属性的实例,但它是一个新实例,而不是 e。只有当 Error 将自身属性添加到 this(在前面的情况下为 e)时,子类化模式才会起作用。

障碍 2 的解决方法

在子构造函数内部,创建一个新的超实例并将其自身属性复制到子实例

function MyError() {
    // Use Error as a function
    var superInstance = Error.apply(null, arguments);
    copyOwnPropertiesFrom(this, superInstance);
}
MyError.prototype = Object.create(Error.prototype);
MyError.prototype.constructor = MyError;

辅助函数 copyOwnPropertiesFrom()复制对象 中显示。试用 MyError

try {
    throw new MyError('Something happened');
} catch (e) {
    console.log('Properties: '+Object.getOwnPropertyNames(e));
}

以下是 Node.js 上的输出

Properties: stack,arguments,message,type

instanceof 关系应该如此

> new MyError() instanceof Error
true
> new MyError() instanceof MyError
true

另一种解决方案:委托

委托是子类化的一种非常简洁的替代方案。 例如,要创建自己的数组构造函数,您可以将数组保存在一个属性中:

function MyArray(/*arguments*/) {
    this.array = [];
    Array.prototype.push.apply(this.array, arguments);
}
Object.defineProperties(MyArray.prototype, {
    size: {
        get: function () {
            var size = 0;
            for (var i=0; i < this.array.length; i++) {
                if (i in this.array) size++;
            }
            return size;
        }
    },
    length: {
        get: function () {
            return this.array.length;
        },
        set: function (value) {
            return this.array.length = value;
        }
    }
});

明显的限制是您不能通过方括号访问 MyArray 的元素;您必须使用方法来访问

MyArray.prototype.get = function (index) {
    return this.array[index];
}
MyArray.prototype.set = function (index, value) {
    return this.array[index] = value;
}

Array.prototype 的普通方法可以通过以下元编程片段进行传输

[ 'toString', 'push', 'pop' ].forEach(function (key) {
    MyArray.prototype[key] = function () {
        return Array.prototype[key].apply(this.array, arguments);
    }
});

我们通过在 MyArray 实例中存储的数组 this.array 上调用 Array 方法来派生 MyArray 方法。

使用 MyArray

> var a = new MyArray('a', 'b');
> a.length = 4;
> a.push('c')
5
> a.length
5
> a.size
3
> a.set(0, 'x');
> a.toString()
'x,b,,,c'



[22] 灵感来自 Ben Nadel 的博客文章。

下一页:29. JSDoc:生成 API 文档