Skip to content

第四章练习答案 ​

下面用 console.assert(条件) 做小例子的自检:条件为假时控制台报告断言失败,为真时不输出。它通常不会抛出异常来终止程序,不能代替会明确判定成败的正式测试。运行时要检查控制台是否出现失败报告,而不是只看程序是否结束。

原型与查找 ​

  1. 自有属性存在,即使值为 undefined 也会遮蔽原型,因此先读到 undefined。删除这个自有属性后才会读到原型的“黑色”。可以用 Object.hasOwn(a, 'ink') 观察两种状态。
  2. 实例都沿链读到同一个数组,push 修改该数组。应在构造器中写 this.tags = [],或在类中声明实例字段 tags = []。不能只在原型上放一次数组。
  3. instanceof 的默认算法检查原型链,Object.create 只负责连接,没有执行初始化函数。实例可能缺少 text,方法可以找到,却不满足使用约定。

用代码观察共享状态的修正 ​

js
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 写入分支时,要分别看查找对象和接收者。可以用下面的成功版本对照正文中“接收者是访问器所以失败”的版本:

js
const source = { n: 1 };
const receiver = {};
console.log(Reflect.set(source, 'n', 5, receiver)); // true
console.log(source.n, receiver.n); // 1 5

source 的数据属性允许写入,receiver 可新增自有数据属性,所以成功;真正变化的是 receiver。换成不可扩展 receiver 则返回 false,不会改写 source 来“帮忙完成”。

实作:独立配置与共享方法 ​

js
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 证明初始化。

类与组合 ​

  1. 基类字段在基类构造器之前安装,派生字段在 super() 返回之后安装。两次观察处于不同初始化时刻。不要在基类构造器里依赖派生状态。
  2. 类字段直接定义自有属性,绕开继承来的 setter;普通赋值走 [[Set]],可能调用 setter。这也解释了为什么两种转译策略可能不等价。
  3. 可以。freeze 收紧普通自有属性的描述符,不管理 #value。私有状态的更新由类方法控制,不能用对象冻结代替类接口约束。

setter 是否执行,取决于此前已有的属性 ​

js
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(',')); // 3

OwnField 先创建自有数据属性,后续赋值在自身就找到它;Assignment 没有这个字段,查到父原型 setter 才调用。删除 OwnField 的字段声明,结果也会走向 setter,因此不能仅凭构造器中的一行赋值判断行为。

构造时调用派生方法导致私有字段失败,可以把调用推迟到构造完成后,或让基类构造器只访问其自身已安装状态。不要把私有字段改成公开字段来掩盖时序问题:公开字段那时也可能尚未初始化,只是得到 undefined,而不是立刻报错。

实作:把变化的能力交给参数 ​

js
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 ​

  1. 展开要收集实际值,所以调用 getter;描述符保存的是 getter 函数引用,本身不请求该属性值。前者获得快照,后者保留访问行为,两者都不意味着深复制。
  2. Map 条目在内部槽中,不是通过普通自有数据属性存储。冻结 Map 外壳不阻止内部条目的改变;真正的只读接口应不暴露修改方法和原始可变引用。
  3. 分别调用 Symbol 得到不同身份,即使描述相同也不冲突;相同字符串的 Symbol.for 得到同一个注册表 Symbol。共享命名时应给注册表键加明确命名空间。

保留访问器与获得快照的对照 ​

js
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。重复定义同一个值没有改变承诺,仍可成功。这种“只收紧不放宽”的推理,比背“不可配置后完全不能碰”更准确。

实作:复制属性规则而不触发读取 ​

js
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 ​

  1. target[key] 以 base 执行 getter,读取 10 并返回 20;Reflect.get 保留 discount 作为 receiver,读取 6 并返回 12。
  2. 不能。不可扩展目标的 ownKeys 结果必须与目标键集合一致,否则抛 TypeError。
  3. 绑定会让内部 this.x 绕开代理;每次绑定还产生不同函数,影响相等判断、事件解绑等。它适用于明确需要原始接收者的包装,不能无条件用于所有方法。

保留 receiver 的完整对照 ​

js
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 参数改变语言语义来消除重复日志。

实作:给代理补上失败路径 ​

js
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 的人仍可绕过代理,因此不能把它当成完整访问控制。

用心记录每一步成长