Angular 框架源码中 Object.create(null) 的取舍准则:原型碰撞防护的 PR 评审规则与实践
在 Angular 核心框架仓库中,把普通对象字面量 {} 替换为 Object.create(null) 是一类看似无害、实则极易出错的重构。Angular 仓库的 PR 评审流程为此沉淀了一套专门的评审规则文档,明确了 Object.create(null)、{} 与 Map 三者的适用边界。本文完整继承这套规则并对照仓库源码逐一印证:读完后你将掌握一套可直接用于代码评审的判定清单——什么场景必须防原型碰撞、什么场景替换了反而引入性能或兼容性隐患,以及公开 API 对象该如何安全处理动态键。
规则文档的定位:PR 评审流程中的专项参考
这套规则并不是孤立的笔记,而是 Angular 仓库 Agent 辅助 PR 评审技能(.agent/skills/pr_review/)中的专项参考文档。PR 评审指南在“Key Focus Areas”中明确要求:审查涉及各包的修改时,优先查阅 reference/ 目录下的专题规则,并特别指出——当 PR 将 {} 替换为 Object.create(null) 时,必须对照 object_create_null.md 中的技术评估标准与规则。这背后的动机在评审指南的开头即可看出:Angular 是核心框架,任何改动都可能影响大量下游开发者,因此对“原型链剥离”这类隐蔽的行为变更设置了专门的评审闸门。
适用条件:三个前提同时满足才值得用 Object.create(null)
规则的第一节给出了明确的正向判定:只有当对象同时满足以下三个条件时,才适合使用 Object.create(null)(或 Map):
- 该对象被用作内部键值查找表或集合(internal key-value lookup map or set);
- 键是任意的、不受信任的动态字符串——规则文档点名的典型来源包括
$locationShim的 URL 查询参数、HTML sanitizer 的标签集合、以及jsaction的 DOM 事件类型解析器; - 属性存在性检查采用直接索引或
in检查(如map[key] !== undefined或key in map),使得键一旦撞上Object.prototype的成员名('toString'、'constructor'、'hasOwnProperty'等)就会产生误报命中或错误行为。
仓库中的真实用例印证
上述三个条件在 Angular 源码中都有对应的真实实现,且都集中在“动态字符串键 + 存在性判断”这一形态上:
- HTML 安全 schema(Sanitizer):dom_security_schema.ts 中定义
const createNullObj = () => Object.create(null);,并以此构建三级嵌套的SECURITY_SCHEMA(属性名 → 命名空间 → 标签名 →SecurityContext)。schema 的键来自属性/标签名这类字符串集合,而 html_sanitizer.ts 中构建BooleanRecord布尔查找表时也两次使用Object.create(null)——这正是“键集合 + 存在性检查”的典型形态。 - Router 的 URL 段树:路由子路径以任意字符串为键,create_url_tree.ts 与 url_tree.ts 中均用
const children: {[key: string]: UrlSegmentGroup} = Object.create(null);来存放UrlSegmentGroup,apply_redirects.ts 的重定向应用同理。子路径名理论上可以是toString、constructor等任意字符串,用空原型对象可保证key in children的判断不被原型链污染。 - $locationShim 的查询参数解析:location_shim.ts 中
const searchObj: {[key: string]: unknown} = Object.create(null);用于解析 URL query string——查询参数完全由外部 URL 决定,是典型的“不受信任动态键”。 - jsaction 事件类型解析:Angular 实验性事件派发机制中的
jsaction属性把“DOM 事件类型 → 处理函数名”编码为字符串(如jsaction="click:confirmPurchase"),其解析缓存见 action_resolver.ts(将jsaction属性值解析为“事件 → 限定名”的 map 并缓存),属性语法说明见 event-dispatch README。事件类型名来自 HTML 属性,属于外部输入,符合“不可信动态键”条件。
公开 API 与边界对象:绝不要盲目剥离原型
规则第二节针对一个高风险场景:当对象接收不受信任的动态键、同时又暴露给公共消费方或第三方代码时(规则点名的例子是 ngOnChanges 生命周期钩子中的 SimpleChanges),把对象改成 Object.create(null) 是禁止的盲目操作。
原因在于:剥离 Object.prototype 属于破坏性 API 变更。消费方代码如果调用 .hasOwnProperty()、.toString()、.valueOf(),或使用字符串插值(`${obj}`),会在运行时直接抛出 TypeError: obj.hasOwnProperty is not a function。
规则给出的安全替代方案有四条,均可落地:
Object.hasOwn(obj, key):框架内部做属性查找时改用Object.hasOwn,替代直接索引或in检查。这样在不破坏对象原型、不影响消费方的前提下,就能防止内部读取时的原型碰撞。- 输入键消毒(Input Key Sanitization):在填充对象时过滤或删除危险键名(
__proto__、constructor、prototype)。 Map或专用类:对需要动态键键值存储的新公共 API,优先选用Map<K, V>,或提供显式.get()/.has()方法的专用类。- 走废弃/破坏性变更流程:如果确有必要把某个公开对象的原型改为
null,必须遵循 Angular 正式的 deprecation 与 major 版本破坏性变更流程——这与 PR 评审指南中“Breaking changes require strict approval processes and deprecation periods”的要求一致。
这条规则的实质是把“原型碰撞防护”与“API 兼容性”解耦:防护责任应该放在读取侧(Object.hasOwn、Map),而不是通过改变存储侧的隐式契约(对象的 prototype)来实现。
禁用场景:五类不应替换为 Object.create(null) 的对象
规则的第三节列举了不应把 {} 换成 Object.create(null) 的五种场景,每一条都有明确的技术理由,且在仓库源码中可以找到对应形态的实例:
- 固定形状的 Struct 与 DTO:属性名是硬编码静态字符串的对象(如文档示例
let sortedBreakpoints: {breakpoints?: number[]} = {})。理由:Object.assign({}, ...)只拷贝自身的可枚举属性,源对象上的原型属性永远不会被拷贝,因此此类对象不存在原型碰撞问题,替换毫无收益。 - 数字键映射:以数字为键索引的对象(如
tasksByHandleId: {[id: number]: Task})。数字键与Object.prototype的字符串成员名不冲突,key in map的误报问题天然不存在。 - 引用哨兵(Reference Sentinels):纯粹用于引用同一性判断的对象,如
const EMPTY_OBJECT = {}与const IN_PROGRESS_RESOLUTION = {}。仓库中确实存在这两类用法:animation_transition_factory.ts 定义了EMPTY_OBJECT = {},ngtsc scope local.ts 定义了IN_PROGRESS_RESOLUTION = {}。这类对象的“身份”才是语义,原型有无成员完全无关。 - 编译器内部 AST 与 Visitor 状态:键由框架内部生成、不受信任用户输入无法投毒键名的临时对象。
- 性能敏感路径与体积敏感包:这是最容易被忽视的一条。标准
{}字面量能享受 V8 的快速隐藏类(fast hidden classes)与单态内联缓存(monomorphic inline caching);而Object.create(null)会把对象直接推进 V8 的字典模式(dictionary mode),放弃这些优化,同时还会增加压缩后的包体积。规则点名的场景是内联 polyfill(如event-dispatch-contract)和 SSR 水合包。仓库中与之对应的构建痕迹包括 event-dispatch 的 contract 二进制打包产物(其中将__jsaction_bootstrap挂到window上)以及 SSR 水合集成测试中的 copy-event-dispatch-contract.mjs——这些内联进最终产物的脚本对体积和初始化性能都极其敏感,恰是“不要为了理论上的安全性牺牲运行时性能”的典型位置。
评审应用要点速查
把三个小节合并成一份可在 PR 评审中逐条勾选的清单:
| 判定步骤 | 检查点 | 结论倾向 |
|---|---|---|
| 1 | 对象是否为内部查找表/集合,且键来自不受信任的动态字符串? | 是 → 考虑 Object.create(null) / Map;否 → 不替换 |
| 2 | 存在性检查是否为 map[key] / key in map 形式? |
是 → 原型碰撞风险真实存在;否 → 替换收益存疑 |
| 3 | 对象是否暴露给公共 API 或第三方代码? | 是 → 禁止直接改为空原型对象,改用 Object.hasOwn、键消毒、Map/专用类,或走 deprecation 流程 |
| 4 | 是否属于固定形状 DTO、数字键表、引用哨兵、编译器内部状态? | 是 → 不替换(见五类禁用场景) |
| 5 | 是否位于热路径或体积敏感产物(内联 polyfill、SSR 水合包)? | 是 → 不替换,权衡 V8 字典模式与包体积代价 |
这套规则的精髓在于:Object.create(null) 在 Angular 这类框架中不是“更干净的写法”,而是一种有明确触发条件和明确代价的防御性手段。它的收益(消除原型链上的键名碰撞)只在“不可信字符串键 + 存在性检查”的窄口径下成立;而它的代价(V8 字典模式、包体积、公共 API 破坏)在其余场景下只会是净损失。对照仓库中的真实用法——Sanitizer 的 SECURITY_SCHEMA、Router 的 URL 段树、$locationShim 的 query 解析 使用空原型对象,而 EMPTY_OBJECT、IN_PROGRESS_RESOLUTION 等哨兵对象保持普通字面量——可以看到这套评审标准与 Angular 源码现状是完全自洽的。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00