这是我参与更文挑战的第3天,活动详情查看: 更文挑战 (不知道翻译算不算)。
英文原文 :The lazy-loading property pattern in JavaScript by Nicholas C. Zakas(红宝书第三版作者)
通常,开发者在 JavaScript 类中为实例中可能需要的任意数据而创建属性。对于在构造函数中随时可用的一小部分数据来说,这不是问题。但是,如果在实例中可用之前需要计算某些数据,您可能并不想预先就造成计算开销。例如,考虑这个类:
class MyClass {
constructor() {
this.data = someExpensiveComputation();
}
}
复制代码
在上面,data
属性是作为执行一些开销大计算的结果而创建的。如果您不确定是否会使用该属性,则预先执行该计算可能效率不高。幸运的是,有几种方法可以将这些操作推迟到以后。
按需属性模式
优化计算开销大操作的最简单方法是等到需要数据后再进行计算。例如,您可以使用带有 getter
的访问器属性来按需进行计算,如下所示:
class MyClass {
get data() {
return someExpensiveComputation();
}
}
复制代码
在这种情况下,直到有人第一次读取数据属性时才会进行计算,这算是一个改进。然而,每次data
属性被访问的时候都会造成同样的计算开销,它甚至比之前至少执行一次计算的例子还要更加糟糕。这并不是一个很好的解决方法,但是您可以在此基础上创造一个更好的。
凌乱的延迟加载属性模式
只在访问属性时才执行计算是一个良好的开端。您真正需要的是在此之后就缓存信息并且之后只使用缓存版本就可以了。但是您将这些信息缓存在哪里以便于访问?最简单的方法是定义一个具有相同名称的属性并将其值设置为计算数据,如下所示:
class MyClass {
get data() {
const actualData = someExpensiveComputation();
Object.defineProperty(this, "data", {
value: actualData,
writable: false,
configurable: false,
enumerable: false
});
return actualData;
}
}
复制代码
在上面,data
属性又一次在类中被定义为一个getter
,但是这次它缓存了结果。Object.defineProperty()
的调用创建了一个新属性data
,它有一个固定的actualData
值,并切它被设置为不可写、不可配置、不可枚举(以匹配getter
)。然后,这个值本身被返回。下次data
属性被访问的时候,它会读取最近创建的属性的值,而不是调用getter
:
const object = new MyClass();
// calls the getter
const data1 = object.data;
// reads from the data property
const data2 = object.data;
复制代码
实际上,所有计算仅在第一次读取数据属性时完成。每次对数据属性的后续读取都返回缓存的版本。
这种模式的一个缺点是 data
属性开始是不可枚举的原型属性,最终是不可枚举的实例属性:
const object = new MyClass();
console.log(object.hasOwnProperty("data")); // false
const data = object.data;
console.log(object.hasOwnProperty("data")); // true
复制代码
虽然这种区别在很多情况下并不重要,但理解这种模式很重要,因为当对象被传递时它可能会导致微妙的问题。幸运的是,使用改进的模式很容易解决这个问题。
译者注:属性有两种类型:数据属性和访问器属性。它们都有
configurable(可配置)
、enumerable(可枚举)
两个特性,区别在于数据属性有value
、writable(可写)
,访问器属性有setter
、getter
。它们是不能同时存在的,如果同时设置了数据属性和访问器属性会报错。原文说之后再读取data
属性不会调用getter
是因为第二次调用时把data
设置为了不可配置的数据属性,也就是说Object.defineProperty
并不会在下一次执行。
类的特有的延迟加载属性模式
如果您有一个场景是让延迟加载的属性始终存在于实例自身中,那么您可以使用Object.defineProperty()
在类构造函数中创建属性。它比前面的例子还要混乱,但它会确保该属性只存在于实例中。下面是一个例子:
class MyClass {
constructor() {
Object.defineProperty(this, "data", {
get() {
const actualData = someExpensiveComputation();
Object.defineProperty(this, "data", {
value: actualData,
writable: false,
configurable: false
});
return actualData;
},
configurable: true,
enumerable: true
});
}
}
复制代码
在上面,构造函数使用 Object.defineProperty()
创建数据访问器属性。该属性在实例中创建(通过使用this
)并且定义了一个getter
以及将属性指定为可枚举和可配置(典型的实例自身属性)。设置data
为可配置的属性是尤为重要的,这样你下一次还可以调用Object.defineProperty()
。
getter
函数执行了计算并且再次调用了Object.defineProperty()
。data
属性现在被重新定义为一个被指定由明确值的数据属性并且配置了不可写和不可配置来保护最终的数据。然后被计算的数据从getter
中返回。下次data
访问的时候,它会从缓存中读取。另外,data
属性现在只作为实例自身属性存在没并且在第一次读取之前和之后的行为都相同:
const object = new MyClass();
console.log(object.hasOwnProperty("data")); // true
const data = object.data;
console.log(object.hasOwnProperty("data")); // true
复制代码
对于类,这很可能是您要使用的模式,而对于对象字面量可以使用更简单的方法。
对象字面量的延迟加载属性模式
如果您使用对象字面量而不是类,则过程要简单得多,因为在对象字面量上定义的 getter
被定义为可枚举的自身属性(而不是原型属性),就像数据属性一样。这意味着您可以对类使用”凌乱的延迟加载属性模式”而不会混乱:
const object = {
get data() {
const actualData = someExpensiveComputation();
Object.defineProperty(this, "data", {
value: actualData,
writable: false,
configurable: false,
enumerable: false
});
return actualData;
}
};
console.log(object.hasOwnProperty("data")); // true
const data = object.data;
console.log(object.hasOwnProperty("data")); // true
复制代码
总结
在 JavaScript 中重新定义对象属性的能力提供了一个特有的机会来缓存可能计算开销大的信息。通过从重新定义为数据属性的访问器属性开始,您可以将计算推迟到第一次读取属性时,然后缓存结果以供以后使用。这种方法既适用于类,也适用于对象字面量,并且在对象字面量中更简单一些,因为您不必担心您的 getter
会在原型上结束。
提高性能的最好方法之一是避免重复执行相同的工作,因此任何时候您可以缓存结果以供以后使用,您都可以加快程序的运行速度。延迟加载属性模式等技术允许任何属性成为缓存层以提高性能。