8.1 使用getter与setter控制属性访问
-
在JavaScript中,对象是相对简单的属性集合。保持程序状态的主要方法是修改对象的这些属性。
function Ninja (level) { this.skillLevel = level; } const ninja = new Ninja(100); -
这里定义构造函数Ninja,使用该构造函数创建实例ninja,它仅具有一个属性skillLevel。现在,想更改属性skillLevel,可通过这行代码实现:
ninja.skillLevel=20。 -
这样实现很方便,但是在如下情况中会发生什么呢?
- 需要避免意外的错误发生,例如赋值错误。例如,需要避免赋予了错误类型的值:
ninja.skillLevel="high"。 - 需要记录skillLevel属性的变化。
- 需要在网页的UI中显示skillLevel属性的值。
- 需要避免意外的错误发生,例如赋值错误。例如,需要避免赋予了错误类型的值:
-
通过getter和setter方法,可以很优雅的实现这一切。
-
可以使用getter和setter,在JavaScript的闭包中实现模拟私有对象属性。下面例子中只通过getter与setter方法访问ninja的私有属性skillLevel。
function Ninja() { let skillLevel; this.getSkillLevel = () => skillLevel; this.setSkillLevel = value => { skillLevel = value; }; } const ninja = new Ninja(); ninja.setSkillLevel(100); assert(ninja.getSkillLevel() === 100, "Our ninja is at level 100!"); -
通过Ninja构造器创建ninja实例,该实例具有私有属性skillLevel,该属性只能通过getSkillLevel和setSkillLevel方法访问:只能通过getSkillLevel方法获取属性值,也只能通过setSkillLevel方法设置新值。
-
如果要记录所有对skillLevel属性的访问,可以拓展getSkillLeve方法,同样,要记录对skillLevel属性赋值,可以拓展setSkillLevel方法:
function Ninja() { let skillLevel; this.getSkillLevel = () => { report('Getting skill level value'); return skillLevel; }; this.setSkillLevel = value => { report('Modifying skillLevel property from:', skillLevel, 'to:', value); skillLevel = value; }; } -
这样可以轻松应对所有交互属性,例如插入日志、数据验证或其他副作用,如界面修改等。
-
skillLevel属性是数值,而不是函数。为了利用所有访问控制的优点,所有与skillLevel属性交互的地方都必须显式的调用相关方法。好在,JavaScript自身支持真正的getter和setter:用于访问普通数据属性(如ninja.skillLevel ),同时可以计算属性值、校验属性值,或其他想做的事。
8.1.1 定义getter与setter
-
在JavaScript中,可以通过两种方式定义getter和setter:
- 通过对象字面量定义,或在ES6的class中定义。
- 通过使用内置的Object.defineProperty方法。
-
自ES5以来,明确支持getter和setter方法已经有一段时间了。
const ninjaCollection = { ninjas: ["Yoshi", "Kuma", "Hattori"], get firstNinja() { report("Getting firstNinja"); return this.ninjas[0]; }, set firstNinja(value) { report("Setting firstNinja"); this.ninjas[0] = value; } }; assert(ninjaCollection.firstNinja === "Yoshi", "Yoshi is the first ninja"); ninjaCollection.firstNinja = "Hachi"; assert(ninjaCollection.firstNinja === "Hachi" && ninjaCollection.ninjas[0] === "Hachi", "Now Hachi is the first ninja"); -
通过在属性名前添加关键字get定义getter方法,通过在属性名前添加关键字set定义setter方法。getter和setter都在记录日志,同时,getter返回索引为0的ninja,setter在同一个位置赋值。下面,验证访问getter属性返回的第一个ninja,Yoshi,要注意getter属性与标准对象属性的访问方法一致。
-
访问getter属性时,隐式调用关联的getter方法,记录了获取firstNinja的日志,并返回索引值为0的ninja。
-
继续使用setter方法设置firstNinja属性,正如直接为普通对象赋值过程一样:
ninjaCollection.firstNinja = "Hachi"; -
由于firstNinja属性具有setter方法,无论何时对firstNinja属性赋值,都会隐式调用setter方法,日志记录设置firstNinja的值,修改索引为0的ninja的值,索引为0的ninja的值可以通过ninjaCollection访问,也可以通过getter方法访问。
-
当通过getter访问属性时(ninjaCollection.firstNinja),将立即调用getter方法同时立即记录日志,验证输出的是Yoshi,这是记录的第一条日志,继续对firstNinja属性赋值,可以看出隐式调用setter方法,日志记录,设置firstNinja的值。
-
注意:
- 访问具有getter方法的属性时隐式调用对应的getter方法。
- 为具有getter方法的属性赋值时隐式调用对应的setter方法。
-
可以通过原生的getter和setter设置标准属性,但是这些方法是在访问属性时立即执行的。
在ES6的class中使用getter和setter
class NinjaCollection {
constructor() {
this.ninjas = ["Yoshi", "Kuma", "Hattori"];
}
get firstNinja() {
report("Getting firstNinja");
return this.ninjas[0];
}
set firstNinja(value) {
report("Setting firstNinja");
this.ninjas[0] = value;
}
}
const ninjaCollection = new NinjaCollection();
assert(ninjaCollection.firstNinja === "Yoshi",
"Yoshi is the first ninja");
ninjaCollection.firstNinja = "Hachi";
assert(ninjaCollection.firstNinja === "Hachi"
&& ninjaCollection.ninjas[0] === "Hachi",
"Now Hachi is the first ninja");
-
注意:
- 针对指定的属性不一定需要同时定义getter和setter。通常仅仅提供getter,如果在这种情况下试图写入属性值,具体的行为取决于代码是在严格模式还是非严格模式。如果在非严格模式下,对仅有getter的属性赋值不起作用,JavaScript引擎默默地忽略我们的请求。如果在严格模式下,JavaScript引擎会抛出异常,表明试图给一个仅有getter没有setter的属性赋值。
-
由于对象字面量与类、getter和setter方法不是在同一个作用域中定义的,因此那些希望作为私有对象属性的变量是无法实现的,不过可以通过
Object.defineProperty方法实现。 -
Object.defineProperty方法可以用于定义新的属性,传入属性描述对象即可。属性描述对象可以包含get和set来定义getter和setter方法。 -
使用这种新特性实现内置的getter和setter,控制私有对象属性的访问。
function Ninja() {
let _skillLevel = 0;
Object.defineProperty(this, 'skillLevel', {
get: () => {
report("The get method is called");
return _skillLevel;
},
set: value => {
report("The set method is called");
_skillLevel = value;
}
});
}
const ninja = new Ninja();
assert(typeof ninja._skillLevel === "undefined",
"We cannot access a 'private' property");
assert(ninja.skillLevel === 0, "The getter works fine!");
ninja.skillLevel = 10;
assert(ninja.skillLevel === 10, "The value was updated");
-
定义一个Ninja构造函数,该构造函数含有_skillLevel属性作为私有变量
-
通过内置的
Object.defineProperty方法,this引用新创建的对象Object.defineProperty(obj, prop, descriptor) obj:要在其上定义属性的对象。 prop:要定义或修改的属性的名称 descriptor:将被定义或修改的属性描述符。 -
由于希望通过skillLevel属性控制访问私有变量,因此定义了get和set方法,与对象字面量和类中的getter和setter不同,通过
Object.defineProperty创建的get和set方法,与私有skillLevel属性处于相同的作用域中。get和set方法分别创建了含有私有变量的闭包,只能通过get和set方法访问私有变量。 -
创建的新的ninja实例,验证无法直接访问私有变量,所有的交互必须通过getter和setter,与标准对象属性无差异。
// 访问skillLevel属性将隐式调用get方法 ninja.skillLevel === 0 // 对skillLevel属性赋值将隐式调用set方法 ninja.skillLevel = 10 -
Object.defineProperty方法比对象字面量或类更为复杂,但是,当需要实现私有对象属性时,Object.defineProperty方法就能派上用场了。 -
不管定义方式,getter和setter允许定义对象属性与标准对象属性一样,当访问属性或对属性赋值时,将会立即调用getter和setter方法。这个功能可以执行日志记录,验证属性值,甚至在发生变化时可以通知其他部分代码。
8.1.2 使用getter与setter校验属性值
- 当对属性赋值时,会立即调用setter方法。可以利用这一点,在代码试图更新属性的值时实现一些行为,例如实现值的校验。
function Ninja() {
let _skillLevel = 0;
Object.defineProperty(this, 'skillLevel', {
get: () => _skillLevel,
set: (value) => {
// 校验传入的值是否是整型,如果不是则抛出异常
if (!Number.isInteger(value)) {
throw new TypeError("Skill level should be a number");
}
_skillLevel = value;
}
});
}
const ninja = new Ninja();
// 将整型值赋值给属性skillLevel
ninja.skillLevel = 10;
assert(ninja.skillLevel === 10, "The value was updated");
try {
// 将非整型值赋值给属性skillLevel
ninja.skillLevel = "Great";
fail("Should not be here");
} catch (e) {
pass("Setting a non-integer value throws an exception");
}
- 现在无论何时对skillLevel属性赋值,都会校验该值是否是整型,如果不是,则抛出异常,并且不会修改属性
_skillLevel的值。如果是整型,则对属性_skillLevel赋值。try-catch可以规避指定属性发生类型错误异常,为此需要增加性能开销,但是,在JavaScript语言中,为了安全需要付出性能开销。
8.1.3 使用getter与setter定义如何计算属性值
- 除了能控制指定对象属性的访问之外,getter和setter还可以用于定义属性属性值的计算方法,即每次访问该属性时都会进行计算属性值。计算属性不会存储具体的值,它们提供get和set方法,用于直接提取、设置属性。下面的对象shogun具有name和clan两个属性,通过这两个属性计算fullTitle的属性值。
const shogun = {
name: "Yoshiaki",
clan: "Ashikaga",
get fullTitle() {
return this.name + " " + this.clan;
},
set fullTitle(value) {
const segments = value.split(" ");
this.name = segments[0];
this.clan = segments[1];
}
};
assert(shogun.name === "Yoshiaki", "Our shogun Yoshiaki");
assert(shogun.clan === "Ashikaga", "Of clan Ashikaga");
assert(shogun.fullTitle === "Yoshiaki Ashikaga",
"The full name is now Yoshiaki Ashikaga");
shogun.fullTitle = "Ieyasu Tokugawa";
assert(shogun.name === "Ieyasu", "Our shogun Ieyasu");
assert(shogun.clan === "Tokugawa", "Of clan Tokugawa");
assert(shogun.fullTitle === "Ieyasu Tokugawa",
"The full name is now Ieyasu Tokugawa");
- 当访问fullTitle属性时调用get方法计算该属性值,并连接name与clan属性的值。同时,set方法使用内置的split方法,对传入的参数使用空格进行分割,分割后的数组的第一个值赋值给name属性,数组的第二个值赋值给clan属性。
- 这需要同时考虑两个方面:读取fullTitle属性时计算属性值,设置fullTitle属性值时修改属性值的构成属性的属性值。
- 不需要使用计算属性,名为fullTitle的方法可能同样有用,但计算属性可以使得代码的概念更为清晰,如果一个属性值取决于对象内部的状态,这样会更为清晰地表明该值是一个数据字段、一个属性值,而不是一个函数。
- getter和setter可以用于处理日志记录、数据验证、属性值变化检测等,不好的是,getter和setter方法有时并不够。有时需要控制对象的全部交互类型,这种情况下可使用一种全新的对象类型:代理。
8.2 使用代理控制访问
代理(proxy)是通过代理控制对另一个对象的访问。通过代理可以定义当对象发生交互时可执行的自定义行为——如读取或设置属性值,或调用方法。可以将代理理解为通用化的setter和getter,区别是每个setter与getter仅能控制单个对象属性,而代理可用于对象交互的通用处理,包括调用对象的方法。
使用代理可以在代码中添加分析和性能度量,自动填充对象属性以避免null异常,包装宿主对象,例如DOM用于减少跨浏览器的不兼容性。
// 通过Proxy构造器创建代理
const emperor = {name: "Komei"};
const representative = new Proxy(emperor, {
get: (target, key) => {
report("Reading " + key + " through a proxy");
return key in target ? target[key]
: "Don’t bother the emperor!"
},
set: (target, key, value) => {
report("Writing " + key + " through a proxy");
target[key] = value;
}
});
assert(emperor.name === "Komei", "The emperor’s name is Komei");
assert(representative.name === "Komei",
"We can get the name property through a proxy");
assert(emperor.nickname === undefined,
"The emperor doesn’t have a nickname ");
assert(representative.nickname === "Don’t bother the emperor!",
"The proxy jumps in when we make inproper requests");
representative.nickname = "Tenno";
assert(emperor.nickname === "Tenno",
"The emperor now has a nickname");
assert(representative.nickname === "Tenno",
"The nickname is also accessible through the proxy");
- 创建基础对象emperor,该对象含有name属性,然后,通过使用内置的Proxy构造函数,将对象emperor包装为代理对象representative。同时向代理构造函数传入第2个参数,第2个参数是一个对象,该对象内定义了在对象执行特定行为时触发的函数。
- 指定两个方法,试图通过代理对象访问对象属性时调用的get方法,以及试图对对象属性赋值时调用的set方法。get执行以下功能:如果目标对象具有该属性,则返回该属性值;如果目标对象不具有该属性,则返回消息以示警告。
- 若通过目标对象emperor直接访问name属性,则返回Komei。但是,若通过代理对象访问,则隐式调用get方法。由于在目标对象上可以找到name属性,因此也会返回Komei。
- 注意:
激活代理方法与getter和setter是一致的。一旦执行交互,就会隐式调用对应的get方法,此时JavaScript引擎的执行过程与显示调用的普通函数类似。 - 另一方面,如果通过目标对象emperor直接访问不存在的属性nickname,毫无疑问将返回undefined。但是,如果通过代理对象访问不存在的属性nickname,将会激活get方法。由于目标不具有nickname属性,get方法将会返回消息Don’t bother the emperor!
- 接着通过代理对象分配一个新属性:representative.nickname = “Tenno”;。由于是通过代理对象进行分配的,因此调用set,记录日志消息,并对目标对象emperor设置新属性。也可以通过代理对象和目标对象访问新创建的属性。
- 使用代理对象的要点:通过Proxy构造器创建代理对象,代理对象访问目标对象时执行指定操作。
- 还有其他内置方法用于定义各种对象的行为:
- 调用函数时激活apply,使用new操作符时激活construct
- 读取/写入属性时激活get与set
- 执行for-in语句时激活enumerate
- 获取和设置属性值时激活getPrototypeOf与setPrototypeOf
8.2.1 使用代理记录日志
当试图明白代码是如何工作的或查找严重错误的根源时,最有力的工具之一是日志记录,在特定的时刻输出有用的行为信息。
// 不使用代理实现日志记录
function Ninja() {
let _skillLevel = 0;
Object.defineProperty(this, 'skillLevel', {
get: () => {
report("skillLevel get method is called");
return _skillLevel;
},
set: (value) => {
report("skillLevel set method is called");
_skillLevel = value;
}
});
}
const ninja = new Ninja();
ninja.skillLevel;
ninja.skillLevel = 4;
- 定义构造器Ninja,并为skillLevel属性添加getter和setter,分别记录读取与写入skillLevel属性的日志。
- 这不是最理想的解决方案,这混合了对象属性读写的代码与日志代码。如果将来需要ninja对象更多的属性,将不得不小心翼翼的为每个属性都添加日志记录语句。
代理的直接用途之一是在读写属性时使用一种更好的、更清洁的方式启用日志记录。
// 使用代理实现日志记录
function makeLoggable(target){
return new Proxy(target, {
get: (target, property) => {
report("Reading " + property);
return target[property];
},
set: (target, property, value) => {
report("Writing value " + value + " to " + property);
target[property] = value;
}
});
}
let ninja = { name: "Yoshi"};
ninja = makeLoggable(ninja);
assert(ninja.name === "Yoshi", "Our ninja Yoshi");
ninja.weapon = "sword";
- 定义makeLoggable函数,使用target对象作为形参,返回一个新的代理对象,该代理对象具有get和set方法。get和set方法会在读取对象属性时记录日志。
- 然后创建具有name属性的ninja对象,将该ninja对象传入makeLoggable函数,作为新创建的代理对象的目标函数。将代理对象重新复制给ninja标识符。
- 每当试图读取属性时,程序将调用get方法,记录对应的读取日志。同理,当写入属性ninja.weapon = “sword”;时也会记录日志。
- 这种日志记录方式比使用标准的getter和setter方法更容易、更透明。
8.2.2 使用代理检测性能
代理还可以在不需要修改函数代码的情况下,评估函数调用的性能。例如想要评估计算一个数值是否是素数的函数的性能。
// 使用代理评估性能
function isPrime(number){
if(number < 2) { return false; }
for(let i = 2; i < number; i++) {
if(number % i === 0) { return false; }
}
return true;
}
isPrime = new Proxy(isPrime, {
apply: (target, thisArg, args) => {
console.time("isPrime");
const result = target.apply(thisArg, args);
console.timeEnd("isPrime");
return result;
}
});
isPrime(1299827);
- 定义isPrime方法。现在需要评估isPrime函数的性能,并且不能修改该函数的代码。可以使用代理包装该函数,添加一个一旦调用该函数就会被触发的方法。
- 使用isPrime函数作为代理的目标对象。同时,添加apply方法,当调用isPrime函数时就会调用apply方法。
- 将新创建的代理对象赋值给isPrime标识符。这样,无需修改isPrime函数内部代码,就可以调用apply方法实现isPrime函数的性能评估,程序代码的其余部分可以完全无视这些变化。
- 每当调用isPrime函数时,都会进入代理的apply方法,开启内置的console.time方法秒表计时,调用原始的isPrime函数,记录运行时间,最后返回isPrime调用的结果。
8.2.3 使用代理自动填充属性
代理还可以用于自动填充属性。
// 使用代理自动填充属性
function Folder() {
return new Proxy({}, {
get: (target, property) => {
report("Reading " + property);
if(!(property in target)) {
target[property] = new Folder();
}
return target[property];
}
});
}
const rootFolder = new Folder();
try {
rootFolder.ninjasDir.firstNinjaDir.ninjaFile = "yoshi.txt";
pass("An exception wasn’t raised");
}
catch(e){
fail("An exception has occurred");
}
- 访问ninjasDir上未定义的属性firstNinjaDir。预期是抛出一个异常,但是执行代码会发现一切运行正常。
- 因为使用了代理,所以每次访问属性时,代理方法都被激活。如果访问的属性在文件夹对象存在,则直接返回对应的值;如果不存在,将会创建新的文件夹并赋值给该属性。这是ninjasDir与firstNinjaDir属性被创建的原因。
- 这样就实现了摆脱null异常的工具。
8.2.4 使用代理实现负数组索引
// 使用代理实现数组负索引
function createNegativeArrayProxy(array){
if (!Array.isArray(array)) {
throw new TypeError('Expected an array');
}
return new Proxy(array, {
get: (target, index) => {
index = +index;
return target[index < 0 ? target.length + index : index];
},
set: (target, index, val) => {
index = +index;
return target[index < 0 ? target.length + index : index] = val;
}
});
}
const ninjas = ["Yoshi", "Kuma", "Hattori"];
const proxiedNinjas = createNegativeArrayProxy(ninjas);
assert(ninjas[0] === "Yoshi" && ninjas[1] === "Kuma"
&& ninjas[2] === "Hattori",
"Array items accessed through positive indexes");
assert(proxiedNinjas[0] === "Yoshi" && proxiedNinjas[1] === "Kuma"
&& proxiedNinjas [2] === "Hattori",
"Array items accessed through positive indexes on a proxy");
assert(typeof ninjas[-1] === "undefined"
&& typeof ninjas[-2] === "undefined"
&& typeof ninjas[-3] === "undefined",
"Items cannot be accessed through negative indexes on an array");
assert(proxiedNinjas[-1] === "Hattori"
&& proxiedNinjas[-2] === "Kuma"
&& proxiedNinjas[-3] === "Yoshi");
proxiedNinjas[-1] = "Hachi";
assert(proxiedNinjas[-1] === "Hachi" && ninjas[2] === "Hachi",
"Items can be changed through negative indexes");
- 使用一元+操作符将属性名变成数值(index = +index)。如果索引值小于0,则逆向访问数组;如果索引值大于或等于0,则使用标准的数组元素。
- 正常数组上只能使用正索引。如果使用代理数组,则同时可以使用正索引和负索引。
8.2.5 代理的性能消耗
代理是通过代理对象控制对另一个对象的访问。
// 检查代理的性能限制
function createNegativeArrayProxy(array){
if (!Array.isArray(array)) {
throw new TypeError('Expected an array');
}
return new Proxy(array, {
get: (target, index) => {
index = +index;
return target[index < 0 ? target.length + index : index];
},
set: (target, index, val) => {
index = +index;
return target[index < 0 ? target.length + index : index] = val;
}
});
}
function measure(items){
const startTime = new Date().getTime();
for(let i = 0; i < 500000; i++){
items[0] === "Yoshi";
items[1] === "Kuma";
items[2] === "Hattori";
}
return new Date().getTime() - startTime;
}
const ninjas = ["Yoshi", "Kuma", "Hattori"];
const proxiedNinjas = createNegativeArrayProxy(ninjas);
console.log("Proxies are around",
Math.round(measure(proxiedNinjas)/measure(ninjas)), "times slower");
- 尽管使用代理可以创造性地控制对象的访问,但是大量的控制操作将带来性能问题。可以在对性能不敏感的程序里使用代理,但是若多次执行代码时仍要小心谨慎。建议彻底地测试代码的性能。
本文深入探讨JavaScript中使用getter和setter控制属性访问的方法,以及通过代理控制对象访问的高级技巧,包括日志记录、性能检测和自动属性填充。
257

被折叠的 条评论
为什么被折叠?



