第四章练习答案
下面用 console.assert(条件) 做小例子的自检:条件为假时控制台报告断言失败,为真时不输出。它通常不会抛出异常来终止程序,不能代替会明确判定成败的正式测试。运行时要检查控制台是否出现失败报告,而不是只看程序是否结束。
原型与查找
- 自有属性存在,即使值为
undefined也会遮蔽原型,因此先读到undefined。删除这个自有属性后才会读到原型的“黑色”。可以用Object.hasOwn(a, 'ink')观察两种状态。 - 实例都沿链读到同一个数组,
push修改该数组。应在构造器中写this.tags = [],或在类中声明实例字段tags = []。不能只在原型上放一次数组。 instanceof的默认算法检查原型链,Object.create只负责连接,没有执行初始化函数。实例可能缺少text,方法可以找到,却不满足使用约定。
用代码观察共享状态的修正
function Stamp(text) {
this.text = text;
this.tags = [];
}
Stamp.prototype.print = function () { return this.text; };
const first = new Stamp('甲');
const second = new Stamp('乙');
first.tags.push('加急');
console.log(first.tags.length, second.tags.length); // 1 0
console.log(first.print === second.print); // true状态在构造时分别分配,方法仍在原型共享。这比把所有方法也复制到每个实例更准确地表达了需要独立的部分。
构造时连接与检查时参照物
Object.create 没有执行构造代码;替换构造器的 prototype 也不会改动旧对象已经建立的原型连接。默认 instanceof 每次读取当前构造器的 prototype,再沿被检查对象的链寻找,两个时刻使用的对象不同,就可能从 true 变为 false。若仅修改旧原型上的方法,原型对象身份没变,判断不会因此改变。
额外检查 receiver 写入分支时,要分别看查找对象和接收者。可以用下面的成功版本对照正文中“接收者是访问器所以失败”的版本:
const source = { n: 1 };
const receiver = {};
console.log(Reflect.set(source, 'n', 5, receiver)); // true
console.log(source.n, receiver.n); // 1 5source 的数据属性允许写入,receiver 可新增自有数据属性,所以成功;真正变化的是 receiver。换成不可扩展 receiver 则返回 false,不会改写 source 来“帮忙完成”。
实作:独立配置与共享方法
const methods = { print() { return `[${this.text}]`; } };
function makeStamp(text) {
const stamp = Object.create(methods);
stamp.text = text;
stamp.tags = [];
return stamp;
}
const a = makeStamp('甲');
const b = makeStamp('乙');
a.tags.push('急件');
a.text = '修改后';
console.assert(b.tags.length === 0);
console.assert(a.print === b.print && !Object.hasOwn(a, 'print'));
console.assert(a.print() === '[修改后]' && b.print() === '[乙]');每次工厂调用都分配 tags,方法只存一次;调用表达式 a.print() 决定接收者。若把 tags 放在 methods 上,状态隔离断言就会失败。此工厂只承诺这些字段与方法,不需要用 instanceof 证明初始化。
类与组合
- 基类字段在基类构造器之前安装,派生字段在
super()返回之后安装。两次观察处于不同初始化时刻。不要在基类构造器里依赖派生状态。 - 类字段直接定义自有属性,绕开继承来的 setter;普通赋值走
[[Set]],可能调用 setter。这也解释了为什么两种转译策略可能不等价。 - 可以。
freeze收紧普通自有属性的描述符,不管理#value。私有状态的更新由类方法控制,不能用对象冻结代替类接口约束。
setter 是否执行,取决于此前已有的属性
const calls = [];
class Parent {
set value(n) { calls.push(n); }
}
class OwnField extends Parent {
value = 1;
constructor() { super(); this.value = 2; }
}
class Assignment extends Parent {
constructor() { super(); this.value = 3; }
}
console.log(new OwnField().value); // 2
new Assignment();
console.log(calls.join(',')); // 3OwnField 先创建自有数据属性,后续赋值在自身就找到它;Assignment 没有这个字段,查到父原型 setter 才调用。删除 OwnField 的字段声明,结果也会走向 setter,因此不能仅凭构造器中的一行赋值判断行为。
构造时调用派生方法导致私有字段失败,可以把调用推迟到构造完成后,或让基类构造器只访问其自身已安装状态。不要把私有字段改成公开字段来掩盖时序问题:公开字段那时也可能尚未初始化,只是得到 undefined,而不是立刻报错。
实作:把变化的能力交给参数
class Recorder {
#records = [];
#write;
constructor(write) { this.#write = write; }
add(text) {
this.#write(text);
this.#records.push(text);
}
get size() { return this.#records.length; }
}
const left = [], right = [];
const a = new Recorder(text => left.push(text));
const b = new Recorder(text => right.push(text));
a.add('甲');
console.assert(a.size === 1 && b.size === 0);
console.assert(left[0] === '甲' && right.length === 0);
const failed = new Recorder(() => { throw new Error('写入失败'); });
try { failed.add('乙'); } catch (error) { console.assert(error.message === '写入失败'); }
console.assert(failed.size === 0);先完成 write,再保存记录,才能保持“只统计成功写入”的约定。输出能力通过参数注入,不必为数组、控制台各建一套继承层。这个接口只处理同步函数;异步写入要在第五章学会等待后再改变承诺,不能把返回 Promise 当成已经成功。
描述符与 Symbol
- 展开要收集实际值,所以调用 getter;描述符保存的是 getter 函数引用,本身不请求该属性值。前者获得快照,后者保留访问行为,两者都不意味着深复制。
- Map 条目在内部槽中,不是通过普通自有数据属性存储。冻结 Map 外壳不阻止内部条目的改变;真正的只读接口应不暴露修改方法和原始可变引用。
- 分别调用
Symbol得到不同身份,即使描述相同也不冲突;相同字符串的Symbol.for得到同一个注册表 Symbol。共享命名时应给注册表键加明确命名空间。
保留访问器与获得快照的对照
const source = { base: 2, get twice() { return this.base * 2; } };
const snapshot = { ...source };
const copy = Object.create(null, Object.getOwnPropertyDescriptors(source));
snapshot.base = 5;
copy.base = 5;
console.log(snapshot.twice, copy.twice); // 4 10两个新对象都有自己的 base,但 snapshot.twice 已经变成数值 4;copy 保留 getter,读取时才以 copy 为接收者计算。这个副本没有原型,是为了让差异只聚焦到描述符;需要保留原型时把 null 换成原对象原型。
复制结果与保留定义是两种需求
assign 对目标赋值,因此目标 setter 有机会接收读取出的值;展开只在新对象上定义数据属性,不把源 setter 复制成新对象的 setter。获取描述符不执行 getter,只拿到其函数引用。若目标是数据快照,展开符合目的;若目标是保留动态访问行为,则要复制描述符并考虑闭包是否仍共享。
对不可配置、可写的数据属性,可以改 value,也可将 writable 收紧为 false;收紧之后不能恢复为 true。重复定义同一个值没有改变承诺,仍可成功。这种“只收紧不放宽”的推理,比背“不可配置后完全不能碰”更准确。
实作:复制属性规则而不触发读取
function cloneRecord(source) {
return Object.create(Object.getPrototypeOf(source),
Object.getOwnPropertyDescriptors(source));
}
let reads = 0;
const mark = Symbol('mark');
const original = { nested: { n: 1 }, get value() { reads++; return 7; } };
Object.defineProperty(original, 'hidden', { value: 3 });
original[mark] = true;
const copy = cloneRecord(original);
console.assert(reads === 0 && copy.hidden === 3 && copy[mark] === true);
console.assert(!Object.keys(copy).includes('hidden'));
console.assert(copy.value === 7 && reads === 1);
copy.nested.n = 9;
console.assert(original.nested.n === 9);读取描述符保存访问器函数,不调用它。getOwnPropertyDescriptors 同时覆盖字符串与 Symbol 自有键;Object.create 的第二参按这些描述符定义属性。嵌套对象与访问器闭包仍共享。Date、Map、带私有字段的类实例需要各自初始化规则,这个函数不能复制它们的内部状态。
Proxy 与 Reflect
target[key]以base执行 getter,读取 10 并返回 20;Reflect.get保留discount作为 receiver,读取 6 并返回 12。- 不能。不可扩展目标的
ownKeys结果必须与目标键集合一致,否则抛TypeError。 - 绑定会让内部
this.x绕开代理;每次绑定还产生不同函数,影响相等判断、事件解绑等。它适用于明确需要原始接收者的包装,不能无条件用于所有方法。
保留 receiver 的完整对照
const base = { n: 2, get twice() { return this.n * 2; } };
const bad = new Proxy(base, { get(target, key) { return target[key]; } });
const good = new Proxy(base, { get(target, key, receiver) {
return Reflect.get(target, key, receiver);
} });
const a = Object.create(bad);
const b = Object.create(good);
a.n = b.n = 7;
console.log(a.twice, b.twice); // 4 14原型查找路径一样,区别只有 getter 接收者:bad 固定为 base,good 保留实际读取者 b。输出差异验证了转发参数的意义。
方法绑定之前,getter 可能已经执行
Map 的 get 是普通读取返回的函数,随后可以绑定 map 再调用;size 是访问器,Reflect.get 必须先执行 getter 才能得到数值,因此可能在绑定分支之前就因 proxy 缺少内部槽而失败。若要向外暴露只读查询,明确写 get size() { return map.size } 与 get(key) { return map.get(key) } 比无条件代理一切更清楚;它也有意没有暴露 set/delete 等修改能力。
同一赋值触发 set 和 defineProperty 是普通写入流程对 receiver 创建属性造成的嵌套操作。日志系统应定义记录哪个层面,不能靠删除 receiver 参数改变语言语义来消除重复日志。
实作:给代理补上失败路径
function watch(target, keys) {
return new Proxy(target, {
get(object, key, receiver) {
keys.push(key);
return Reflect.get(object, key, receiver);
},
set(object, key, value, receiver) {
if (key === 'count' && (!Number.isInteger(value) || value < 0)) {
throw new TypeError('count');
}
return Reflect.set(object, key, value, receiver);
}
});
}
const target = { count: 1, get double() { return this.count * 2; } };
const keys = [];
const proxy = watch(target, keys);
const child = Object.create(proxy);
child.count = 4;
console.assert(child.double === 8 && keys.includes('double'));
let rejected = false;
try { proxy.count = -1; } catch (error) { rejected = error instanceof TypeError; }
console.assert(rejected && target.count === 1);
Object.freeze(target);
console.assert(Reflect.set(proxy, 'count', 2) === false);校验通过只代表值满足我们的条件,不代表目标允许写入,所以必须返回 Reflect.set 的真实结果。该包装只校验普通赋值;Object.defineProperty 是另一种操作,且拿到原 target 的人仍可绕过代理,因此不能把它当成完整访问控制。