4、类访问器装饰器
先来看看 TypeScript 5 之前的访问器装饰器。
访问器是一种 get/set
方法,我们使用代码来创建或多或少的属性。也就是说,我们通常将 get/set
与现有属性一起使用,但它们也可以与属性分开使用。
像这样:
ts
class Example {
#name: string;
@Decorator
set name(n: string) { this.#name = n; }
get name() { return #name; }
#width: number;
#height: number;
@Decorator
get area() { return this.#width * this.#height; }
}
#fieldName
是 JavaScript 中的一项新功能,TypeScript 也支持该功能,可实现字段数据的适当隐私保护。这些私有字段只能在类内部访问。对于 name,访问函数与现有的同名属性直接相关。对于 area,访问函数根据其他两个属性计算其值。
乍看起来,访问器修饰器和方法修饰器并无不同。当静态或实例方法加上get
或set
前缀,它们就变成了访问器。如果方法具有get
前缀(getter 函数),它的属性描述符会使用get
字段而不是value
保存函数体。类似的,setter 函数的函数体保存在set
字段。
4.1 类访问器装饰函数
访问器装饰器附加在访问器方法上。访问器可以与同名属性关联,也可以不关联。访问器装饰器函数接收三个参数:
- 静态成员的类构造函数,或实例成员的类原型;
- 成员名称;
- 成员的属性描述符;
这些参数与属性装饰器的参数相同,但增加了 PropertyDescriptor
对象。在使用属性装饰器时,我们遇到了一些限制,因为它们无法接收此对象。这使得访问者装饰器在应用程序必须拥有该描述符的情况下非常有用。
PropertyDescriptor
的定义如下:
ts
interface PropertyDescriptor {
configurable?: boolean;
enumerable?: boolean;
value?: any;
writable?: boolean;
get?(): any;
set?(v: any): void;
}
在实现装饰器时,我们将以多种方式使用该对象。
4.2 类访问器装饰器应用
让我们从一个简单的例子开始探索,打印装饰器函数接收到的信息:
ts
function LogAccessor(
target: Object,
propertyKey: string,
descriptor: PropertyDescriptor
) {
console.log(`LogAccessor`, {
target, propertyKey, descriptor
});
}
class Simple {
#num: number;
@LogAccessor
set num(w: number) { this.#num = w; }
get num() { return this.#num; }
}
访问者装饰器是附加到这两个方法之一的。不允许在两个方法上都添加装饰器。通常,为文档顺序中出现的两个方法中的第一个附加一个装饰器。
如果在两个方法中都附加了装饰器,则会收到此错误信息:
ts
error TS1207: Decorators cannot be applied to multiple get/set accessors of the same name.
测试一下:
ts
const s1 = new Simple();
const s2 = new Simple();
s1.num = 1;
s2.num = 1;
console.log(`${s1.num} ${s2.num}`);
s1.num = s1.num + s2.num;
s2.num = s1.num + s2.num;
console.log(`${s1.num} ${s2.num}`);
s1.num = s1.num + s2.num;
s2.num = s1.num + s2.num;
console.log(`${s1.num} ${s2.num}`);
s1.num = s1.num + s2.num;
s2.num = s1.num + s2.num;
console.log(`${s1.num} ${s2.num}`);
s1.num = s1.num + s2.num;
s2.num = s1.num + s2.num;
console.log(`${s1.num} ${s2.num}`);
s1.num = s1.num + s2.num;
s2.num = s1.num + s2.num;
console.log(`${s1.num} ${s2.num}`);
输出:
ts
LogAccessor {
target: {},
propertyKey: 'num',
descriptor: {
get: [Function: get num],
set: [Function: set num],
enumerable: false,
configurable: true
}
}
1 1
2 3
5 8
13 21
34 55
89 144
我们可以看到 target
是一个空对象,而 propertyKey
是属性的名称。描述符 descriptor
包含 get 和 set
函数,将其标记为不可枚举和可配置。get 和 set
字段的内容就是我们在代码中编写的函数。
通过计算访问函数,我们可以得到什么描述符对象?为了弄清楚这个问题,让我们创建一个新的简单示例:
ts
function LogAccessor(target: Object,
propertyKey: string,
descriptor: PropertyDescriptor) {
// .. as above
}
class Rectangle {
#width: number;
#height: number;
@LogAccessor
get area() {
return this.#width * this.#height;
}
constructor(width: number, height: number) {
this.#width = width;
this.#height = height;
}
}
const r1 = new Rectangle(3, 5);
console.log(r1.area);
矩形类没有名为面积的属性,获取面积函数的值是根据宽度和高度值计算得出的。运行该函数,我们可以得到:
ts
LogAccessor {
target: {},
propertyKey: 'area',
descriptor: {
get: [Function: get area],
set: undefined,
enumerable: false,
configurable: true
}
}
15
在本例中,生成的 PropertyDescriptor
只有一个 get 函数,因为只创建了一个 get 函数。
4.3 监听类中的访问器行为
作为对 PropertyDescriptor
的初步探索,让我们来实现一个装饰器,通过访问器打印 get/set
活动。
ts
function AccessorSpy<T>() {
return function (target: Object, propertyKey: string,
descriptor: PropertyDescriptor) {
const originals = {
get: descriptor.get,
set: descriptor.set
};
if (originals.get) {
descriptor.get = function (): T {
const ret: T = originals.get.call(this);
console.log(`AccessorSpy get ${String(propertyKey)}`, ret);
return ret;
};
}
if (originals.set) {
descriptor.set = function(newval: T) {
console.log(`AccessorSpy set ${String(propertyKey)}`, newval);
originals.set.call(this, newval);
};
}
}
}
我们使用泛型语法传递访问器应使用的数据类型。该装饰器可与任何访问器一起使用,因此我们必须使用正确的数据类型。
我们还保留现有的访问函数。我们将 set 和 get 保存到原始对象中。然后,对于这两个对象,我们用一个调用原始对象并打印其结果的函数替换任何现有函数。
这个替换函数要求在调用原始函数时,this 具有正确的值。this 的值必须是调用 getter 或 setter 访问器所针对的对象实例。经过多次修改(例如,箭头函数不能用作替换函数),我们发现此处所示的模式可以正常工作。
内部函数中的这个值与内部函数有关。但是,descriptor.get
和 descriptor.set
替换函数以及 originals.get
和 originals.set
执行时,必须将此值设置为正确的对象实例。使用调用方法,我们可以调用函数,同时为此设置其值。
测试方法如下:
ts
class ToSpy {
#num: number;
@AccessorSpy<number>()
set num(w: number) { this.#num = w; }
get num() { return this.#num; }
}
const tsp1 = new ToSpy();
const tsp2 = new ToSpy();
tsp1.num = 1;
tsp2.num = 2;
console.log(`${tsp1.num} ${tsp2.num}`);
tsp1.num = tsp1.num + tsp2.num;
tsp2.num = tsp1.num + tsp2.num;
console.log(`${tsp1.num} ${tsp2.num}`);
tsp1.num = tsp1.num + tsp2.num;
tsp2.num = tsp1.num + tsp2.num;
console.log(`${tsp1.num} ${tsp2.num}`);
tsp1.num = tsp1.num + tsp2.num;
tsp2.num = tsp1.num + tsp2.num;
console.log(`${tsp1.num} ${tsp2.num}`);
我们要确保在设置属性值时,只影响给定的对象实例,而且每个对象实例都有不同的值。
执行得到以下结果:
ts
AccessorSpy set num 1
AccessorSpy set num 2
AccessorSpy get num 1
AccessorSpy get num 2
1 2
AccessorSpy get num 1
AccessorSpy get num 2
AccessorSpy set num 3
AccessorSpy get num 3
AccessorSpy get num 2
AccessorSpy set num 5
AccessorSpy get num 3
AccessorSpy get num 5
3 5
AccessorSpy get num 3
AccessorSpy get num 5
AccessorSpy set num 8
AccessorSpy get num 8
AccessorSpy get num 5
AccessorSpy set num 13
AccessorSpy get num 8
AccessorSpy get num 13
8 13
AccessorSpy get num 8
AccessorSpy get num 13
AccessorSpy set num 21
AccessorSpy get num 21
AccessorSpy get num 13
AccessorSpy set num 34
AccessorSpy get num 21
AccessorSpy get num 34
21 34
事实上,我们可以看到,AccessorSpy
函数为我们提供了一个很好的视角,让我们了解该对象的 get/set
活动。我们还可以看到,两个实例之间的值仍然是不同的。
4.4 使用访问器装饰器控制可枚举设置
我们希望将某些访问器视为其他属性。到目前为止,PropertyDescriptor
的可枚举字段一直是 false。但是,如果我们希望访问器返回的值包含在 for...in
或 for...of
循环等扫描的字段中,该怎么办呢?这就需要将 enumerable
设置为 true
。
ts
export function Enumerable(val: boolean) {
return (target: Object, propertyKey: string,
descriptor: PropertyDescriptor) => {
if (typeof val !== 'undefined') {
descriptor.enumerable = val;
}
}
}
class SetEnumerable {
#num: number;
@LogAccessor
@Enumerable(true)
@LogAccessor
@AccessorSpy<number>()
set num(w: number) { this.#num = w; }
get num() { return this.#num; }
}
const en1 = new SetEnumerable();
en1.num = 1;
for (let key in en1) {
console.log(`en1 ${key} ${en1[key]}`);
}
Enumerable
装饰器只需在描述符中设置 enumerable
标志。我们只需输入 true 或 false 即可,非常简单。我们前后都使用了 LogAccessor
,以确保看到设置已更改。此外,我们还使用了 AccessorSpy
来跟踪 num 访问器上的活动。
然后,我们生成一个实例,并设置一个值。实际上,for...in
循环可以让我们知道该访问器是否可枚举。如果是,num 将显示为循环中扫描的键之一。
ts
LogAccessor {
target: {},
propertyKey: 'num',
descriptor: {
get: [Function (anonymous)],
set: [Function (anonymous)],
enumerable: false,
configurable: true
}
}
LogAccessor {
target: {},
propertyKey: 'num',
descriptor: {
get: [Function (anonymous)],
set: [Function (anonymous)],
enumerable: true,
configurable: true
}
}
AccessorSpy set num 1
AccessorSpy get num 1
en1 num 1
事实上,第二个 LogAccessor
输出显示 enumerable
设置为 true
。然后,我们在底部看到循环内部的打印输出,表明 num
作为键之一被返回。要验证这一点,请设置 Enumerable(false)
并重新运行脚本,以确保在 enumerable
为 false
时不会打印最后一行。
4.5 使用访问器装饰器进行简单的运行时数据验证
我们刚刚证明的是,访问者装饰器可以用我们的代码覆盖 get/set
函数,并在运行时执行。我们用它来监视进出属性的数据。
但是,我们还可以利用这一新发现的能力做更多事情。也就是说,我们可以实现运行时数据验证。
ts
function Validate<T>(validator: Function) {
return (target: Object, propertyKey: string,
descriptor: PropertyDescriptor) => {
const originals = {
get: descriptor.get,
set: descriptor.set
};
if (originals.set) {
descriptor.set = function(newval: T) {
console.log(`Validate set ${String(propertyKey)}`, newval);
if (validator) {
if (!validator(newval)) {
throw new Error(`Invalid value for ${propertyKey} -- ${newval}`);
}
}
originals.set.call(this, newval);
};
}
}
}
class CarSeen {
#speed: number;
@Validate<number>((speed: number) => {
console.log(`Validate speed ${speed}`);
if (typeof speed !== 'number') return false;
if (speed < 10 || speed > 65) return false;
return true;
})
set speed(speed) {
console.log(`set speed ${speed}`);
this.#speed = speed; }
get speed() { return this.#speed; }
}
Validate
是一个装饰器工厂,它接收一个我们将用于验证的函数。在内部函数中,我们只覆盖了 set 访问器。这是因为我们要确保该属性中不会出现错误的值。
在我们的 set 函数中,如果验证器函数存在,我们就会调用它,并提供候选值。如果返回 true,我们就说一切正常,然后继续调用原始的 setter
,否则我们就会抛出一个错误。
我们在各处添加了 console.log
语句,以确保所有步骤都按预期进行。
要测试它,请在脚本中添加以下内容:
ts
const cs1 = new CarSeen();
cs1.speed = 22;
console.log(cs1.speed);
cs1.speed = 33;
console.log(cs1.speed);
cs1.speed = 44;
console.log(cs1.speed);
cs1.speed = 55;
console.log(cs1.speed);
cs1.speed = 66;
console.log(cs1.speed);
When executed we get this:
ts
Validate set speed 22
Validate speed 22
set speed 22
22
Validate set speed 33
Validate speed 33
set speed 33
33
Validate set speed 44
Validate speed 44
set speed 44
44
Validate set speed 55
Validate speed 55
set speed 55
55
Validate set speed 66
Validate speed 66
.../simple/lib/accessors/validation.ts:16
throw new Error(`Invalid value for ${propertyKey} -- ${newval}`);
^
Error: Invalid value for speed -- 66
我们可以设置任何我们想要的速度,直到设置一个超出指定范围的值。
这就是运行时数据验证。我们分配了一个无效值,验证装饰器确保防止该值污染属性。根据这些信息,你可以看到每一步都被执行了。对于我们的无效值,前两步都执行了,但由于抛出了异常,最后一步没有执行。因此,属性值没有被修改。
4.6 TypeScript 5 以后的类访问器装饰器
accessor
还是比较强大的,但有的时候我们只想在 getter
或者 setter
的时机去做一些事情,新的装饰器还具有直接修饰 getter
和 setter
的能力,它的类型签名如下:
ts
type ClassGetterDecorator = (
value: Function,
context: {
kind: 'getter';
name: string | symbol;
static: boolean;
private: boolean;
access: { get: () => unknown };
addInitializer(initializer: () => void): void;
}
) => Function | void;
type ClassSetterDecorator = (
value: Function,
context: {
kind: 'setter';
name: string | symbol;
static: boolean;
private: boolean;
access: { set: (value: unknown) => void };
addInitializer(initializer: () => void): void;
}
) => Function | void;
我们通过 getter
装饰器来实现一个延迟计算的能力:
ts
function lazy(value, {kind, name, addInitializer}) {
if (kind === 'getter') {
return function () {
const result = value.call(this);
Object.defineProperty(
this, name,
{
value: result,
writable: false,
}
);
return result;
};
}
}
class People {
@lazy
get value() {
console.log('一些计算。。。');
return '计算后的结果';
}
}
console.log('1 new People()');
const inst = new People();
console.log('2 inst.value');
assert.equal(inst.value, '计算后的结果');
console.log('3 inst.value');
assert.equal(inst.value, '计算后的结果');
console.log('4 end');
// 1 new People()
// 2 inst.value
// 一些计算。。。
// 3 inst.value
// 4 end
我们通过 getter
来定义这个字段,这样计算只会在读取这个字段的时候执行,然后我们通过 lazy
装饰器包装原始的 getter
:当第一次读取该字段时,它会调用 getter
方法并进行计算,然后装饰器将计算后的结果缓存下来,后续再读取这个字段就会直接读取已经计算好的值。
4.7 自动访问器装饰器
自动访问器是一种新的语言特性,简化了 getter
和 setter
对的创建:
ts
class C {
accessor x = 1;
}
// Same
class C {
#x = 1;
get x() {
return this.#x;
}
set x(val) {
this.#x = val;
}
}
这不仅是表达简单访问器对的一种方便方式,还有助于避免装饰器作者尝试在实例上使用访问器替换实例字段时出现的问题,因为当它们被安装在实例上时,ECMAScript 实例字段会遮盖访问器。 它还可以使用装饰器,例如以下只读自动访问器:
ts
function readOnly<This, Return>(
target: ClassAccessorDecoratorTarget<This, Return>,
context: ClassAccessorDecoratorContext<This, Return>
) {
const result: ClassAccessorDecoratorResult<This, Return> = {
get(this: This) {
return target.get.call(this);
},
set() {
throw new Error(
`Cannot assign to read-only property '${String(context.name)}'.`
);
},
};
return result;
}
class MyClass {
@readOnly accessor myValue = 123;
}
const obj = new MyClass();
console.log(obj.myValue);
obj.myValue = 456; // Error: Cannot assign to read-only property 'myValue'.
console.log(obj.myValue);
4.8 小结
从创建和使用访问器装饰器,到自动运行时数据验证的基础,我们已经学到了不少关于访问器装饰器的知识。
因为访问者装饰器为我们提供了 PropertyDescriptor
对象,所以我们可以做很多事情,只要我们小心操作。特别是,在替换 get 和 set 函数时,我们必须仔细确保替换函数能正确执行。
这种数据验证方法的可能性最大。其他数据验证软件包要求程序员明确编写数据验证代码。这让人很容易忘记做这件事,不是吗?使用这种方法,你可以附加装饰器,然后每次对属性赋值时都会运行验证,这样你就可以确保不正常的数据值不会进入任何受保护的属性。
5、参数装饰器
方法参数也可以附加装饰器。像这样:
ts
@ClassDecorator()
class A {
// ...
@MethodDecorator()
fly(
@ParameterDecorator(?? optional parameters)
meters: number
) {
// code
}
// ...
}
正如我们将看到的,由于装饰器函数接收的信息极少,我们无法对参数装饰器本身做太多处理。因此,在参数装饰器和其他代码(如方法装饰器)之间共享数据就显得尤为重要。
5.1 参数装饰器函数
参数装饰器附加在类构造函数或类成员方法的参数上。参数装饰器不能与独立函数一起使用,否则会出现装饰器在此处无效的错误。它们只能用于作为类定义一部分的函数参数,如上图所示。
参数装饰器函数的签名是
- 对于静态成员,可以使用类的构造函数;对于实例成员,可以使用类的原型。
- 给出属性名称的字符串
- 参数在函数参数列表中的序号索引
前两个参数与提供给属性和访问器装饰器函数的参数类似。第三个参数指的是类方法参数列表中的位置:
ts
class ClassName {
method(param0, param1, param2, param3, ...) { .. }
}
如图所示,参数以 0 为索引编号。第三个参数是一个简单的整数,表示索引,如 0、1、2 等。
对于其他装饰器,第三个参数中会出现一个名为 PropertyDescriptor
的对象。使用该描述符可以做很多有趣的事情,但参数装饰器无法使用。
5.2 参数装饰器使用
为了了解它是如何工作的,让我们举一个简单的例子:
ts
import * as util from 'util';
function logParameter(target: Object, propertyKey: string | symbol,
parameterIndex: number) {
console.log(`logParameter ${target} ${util.inspect(target)} ${String(propertyKey)} ${parameterIndex}`);
}
class ParameterExample {
member(@logParameter x: number,
@logParameter y: number) {
console.log(`member ${x} ${y}`);
}
}
const pex = new ParameterExample();
pex.member(2, 3);
pex.member(3, 5);
pex.member(5, 8);
target 的类型指定为通用对象。propertyKey 是函数名称,在本例中为 member。parameterIndex 是一个整数,从 0 开始,枚举了该装饰器所连接的参数。
运行此脚本后,我们会得到以下输出结果:
ts
logParameter [object Object] {} member 1
logParameter [object Object] {} member 0
member 2 3
member 3 5
member 5 8
目标原来是一个匿名对象。除此之外,键值和索引值都符合预期。
请注意,参数装饰器没有机会针对包含参数的对象实例执行代码。相反,它的影响范围是在创建类对象的过程中。与其他装饰器不同的是,我们没有获得任何可以修改以影响类实例行为的对象。
相反,参数装饰器主要是作为一个标记,为方法参数添加信息。官方文档清楚地说明了这一点:
参数装饰器只能用于观察一个方法是否声明了参数。
在大多数情况下,使用参数装饰器进行任何重要操作都需要与其他装饰器合作。例如,参数装饰器可以使用 Reflection 和 Reflection Metadata API 存储数据,其他装饰器可以在实现其他功能时使用这些数据。
5.3 深入了解参数装饰器可用的数据
深入检查目标对象具有潜在价值。我们从 TypeScript 文档中看到它是类对象,所以让我们来验证一下这意味着什么:
ts
export function ParameterInspector(target: Object,
propertyKey: string | symbol,
parameterIndex: number) {
const ret = {
target, propertyKey, parameterIndex,
ownKeys: Object.getOwnPropertyNames(target),
members: {}
};
for (const key of Object.getOwnPropertyNames(target)) {
ret.members[key] = {
obj: target[key],
descriptor: util.inspect(
Object.getOwnPropertyDescriptor(target, key)
)
};
}
console.log(ret);
}
它检索属性名称列表,然后获取这些属性的更多详细信息。
如果我们用 @ParameterInspector
代替上例中的 @logInspector
,就会得到这样的输出结果:
ts
{
target: {},
propertyKey: 'member',
parameterIndex: 0,
ownKeys: [ 'constructor', 'member' ],
members: {
constructor: {
obj: [class ParameterExample],
descriptor: '{\n' +
' value: [class ParameterExample],\n' +
' writable: true,\n' +
' enumerable: false,\n' +
' configurable: true\n' +
'}'
},
member: {
obj: [Function: member],
descriptor: '{\n' +
' value: [Function: member],\n' +
' writable: true,\n' +
' enumerable: false,\n' +
' configurable: true\n' +
'}'
}
}
}
事实上,这清楚地表明 target 就是上面显示的类。getOwnPropertyNames
返回的列表是方法名称,包括作为方法的构造函数,尽管我们没有明确创建构造函数。甚至还有一个 PropertyDescriptor
可用。
5.4 参数装饰器实际应用
我们刚刚讨论了如何将参数装饰器数据保存在某个地方,以便其他装饰器可以使用这些数据。正如我们在讨论类装饰器和属性装饰器时所说的那样,你的装饰器可以是一个 "框架 "的一部分,在这个 "框架 "中,每个装饰器都为一个更大的目标而协同工作。
举个例子,在一个代表路由器(Router)的类中,有一个路由处理方法,它位于一个网络框架(如 Express)中。我们可能希望将从 URL 中的查询字符串或 POST 请求中的主体参数中获取的值注入到参数中。
ts
@Router('/blog')
class BlogRouter {
@Get('/view/:id')
viewPost(req, res, next,
@URLParam('id') id: string
) {
// handle route
}
}
用于 Express
的 Reflet
装饰器库有类似的参数装饰器,以及此处显示的其他装饰器。在本例中,我们只实现 URLParam
中记录数据的部分。当我们使用方法装饰器时,我们将创建一个更完整的示例,让方法和参数装饰器一起工作。
ts
const registered = [];
function URLParam(id: string) {
return (target: Object,
propertyKey: string | symbol,
parameterIndex: number) => {
const topush = {
target, propertyKey, parameterIndex, urlparam: id,
ownKeys: Object.getOwnPropertyNames(target),
function: target[propertyKey],
// funcDescriptor: Object.getOwnPropertyDescriptor(target, propertyKey)
};
registered.push(topush);
}
}
class BlogRouter {
viewPost(req, res, next,
@URLParam('id') id: string
) {
console.log(`viewPost`);
}
viewComments(req, res, next,
@URLParam('id') id: string,
@URLParam('commentID') commentID: string
) {
console.log(`viewComments`);
}
}
console.log(registered);
URLParam
是一个参数描述符函数,用于收集有关参数装饰器和包含参数的方法的一些数据。它将这些数据保存到一个数组中,目的是让其他装饰器或框架使用这些数据来构建一些有用的东西。在讨论反射和元数据 API 时,我们将看到一种更实用的存储数据数组的方法。
在 BlogRouter
类中,我们有两个方法,它们之间有几个参数,其中一些参数带有 @URLParam 装饰器。
然后,我们可以这样运行脚本:
ts
[
{
target: {},
propertyKey: 'viewPost',
parameterIndex: 3,
urlparam: 'id',
ownKeys: [ 'constructor', 'viewPost', 'viewComments' ],
function: [Function: viewPost]
},
{
target: {},
propertyKey: 'viewComments',
parameterIndex: 4,
urlparam: 'commentID',
ownKeys: [ 'constructor', 'viewPost', 'viewComments' ],
function: [Function: viewComments]
},
{
target: {},
propertyKey: 'viewComments',
parameterIndex: 3,
urlparam: 'id',
ownKeys: [ 'constructor', 'viewPost', 'viewComments' ],
function: [Function: viewComments]
}
]
这样我们就得到了三个相应的数据对象。propertyKey
字段包含包含参数的方法名称,而 parameterIndex
包含参数列表中的索引。然后,我们在 urlparam
中记录要从 URL 抓取的项目。然后,我们记录函数名称列表以及方法的函数对象,因为这些可能很有用。
我们已经证明,在另一个位置记录有关属性的任何数据都非常容易。
5.5 小结
这样我们就得到了三个相应的数据对象。propertyKey
字段包含包含参数的方法名称,而 parameterIndex
包含参数列表中的索引。然后,我们在 urlparam
中记录要从 URL 抓取的项目。然后,我们记录函数名称列表以及方法的函数对象,因为这些可能很有用。
我们已经证明,在另一个位置记录有关属性的任何数据都非常容易。