Angular 依赖注入(DI)高级主题解析:Key、Injector 分层结构与 Host/Visibility 可见性边界
本文以仓库内的 packages/docs/di/di_advanced.md 为骨架,结合 packages/docs/di/di.md 以及当前仓库中
packages/core的 DI 实现源码,系统讲解 Angular 依赖注入中三类常被忽略的高级机制:Key(内部键)、ProtoInjector 与 Injector 的职责分离,以及 Host 宿主边界与 Provider 可见性(Visibility)。读完本文,你将理解依赖查找"为什么向上走、在哪里停下、谁能看见什么",并能看懂providers与viewProviders在视图树上形成的双重视图,从而在真实组件层级中精确设计服务的作用域。
这份文档定位特殊:它由 Angular 核心团队留存,深入描述 DI 模块的内部工作原理,且明确声明"不掌握这些内容也能正常使用 DI"。因此它更适合把 Angular 当作研究对象、或需要在性能敏感场景定制注入逻辑的读者。值得注意的是,文档中的示例大量使用早期架构的底层 API(bind、Injector.resolveAndCreate、Key.get、ProtoInjector、BindingWithVisibility 等),这些类已不再存在于当前源码树中(在 packages 下仅文档目录还能搜到它们),但其中的心智模型与可见性规则被完整保留在了现代 Ivy 的 NodeInjector 实现中。下文会先忠实还原原文档的推导过程,再给出与当前源码的对照。
文档背景:它讲述的是哪一代 DI
先厘清概念所处的时代,避免读者在 API 层面产生混淆:
- 本文所述核心抽象(
Injector、Binding、Dependency)是文档的姊妹篇 packages/docs/di/di.md 的主题。简单回顾:injector 由一组 binding 创建、负责解析依赖并创建对象;binding 把 token(字符串或类)映射到工厂函数与依赖列表;dependency 指向某个 token 并携带注入时的附加约束信息。 - 而
bind(Engine).toFactory(...)、Key、ProtoInjector、BindingWithVisibility、Visibility.Public/Private/PublicAndPrivate这批 API 属于早期(Angular 2 起步阶段)的内部 DI 架构,后来经历了 Provider 对象标准化与 Ivy 重写。如今这些名称仅作为历史文档存留,但它们抽象出的问题(token 归一化、静态元数据与实例分离、解析边界与可见性)在每一代实现中都被继承。
理解"问题本身没变、只是实现换了名字",是阅读下文所有代码示例的正确姿势。
Key:token 到内部键的映射
大多数场景下开发者不需要直接接触 Key——这也是文档把它列为第一个高级主题的原因:你天天在用,只是没意识到它的存在。
普通写法中,token(这里是 Engine 类)会被自动映射成内部键:
var inj = Injector.resolveAndCreate([
bind(Engine).toFactory(() => new TurboEngine()) //the passed in token Engine gets mapped to a key
]);
var engine = inj.get(Engine); //the passed in token Engine gets mapped to a key
显式使用 Key 的写法如下——注意此时不再发生映射:
var ENGINE_KEY = Key.get(Engine);
var inj = Injector.resolveAndCreate([
bind(ENGINE_KEY).toFactory(() => new TurboEngine()) // no mapping
]);
var engine = inj.get(ENGINE_KEY); // no mapping
核心机制就一句话:
每个 Key 都拥有一个 id,DI 用这个 id 来存储 provider 与实例。因此 Injector 在内部总是借助 Key 工作,纯粹是为了性能——拿整数 id 做哈希表键,比拿任意 token(尤其运行时对象)做键快得多。
把这段历史设计与当前代码对照,会发现"token 需要归一化为可高效检索的标识"这一诉求在现代实现中以另一种形态延续:Ivy 在实例化注入器时给每个指令 token 打上唯一的 NG_ELEMENT_ID 编号(见下文 "bloom filter" 部分,源码位于 packages/core/src/render3/di.ts),本质上仍是"给 token 分配稳定 id、用 id 驱动查找"。当初是 Key.id,今天是 NG_ELEMENT_ID,思路一脉相承。
ProtoInjector 与 Injector:静态元数据与运行实例的分离
问题:应用中常常需要创建大量"结构相同"的注入器。以 Angular 为例,每一种组件元素类型都会以完全相同的方式配置自己的注入器。如果对每个实例都重新解析一遍 provider,会非常浪费:
function createComponentInjector(parent, providers: Binding[]) {
return parent.resolveAndCreateChild(providers);
}
上面的写法意味着:每创建一个组件实例,就要重新 resolve 并存储一次 providers。
解法:把"关于 injectable 的元信息(providers 及其依赖关系)"与"injectable 本身"分开存储:
ProtoInjector(原型注入器)持有静态元信息——providers 与依赖关系只解析一次,按组件类型复用;Injector持有运行期实例——按组件实例创建,内部引用同一个 proto。
var proto = new ProtoInjector(providers); // done once
function createComponentInjector(parent, proto) {
return new Injector(proto, parent);
}
便捷入口 Injector.resolveAndCreate 做的事其实是把两者一并创建出来:先 resolve 出 proto,再基于 proto 建出 injector。同理,resolveAndCreateChild 之于父子注入器也是同样两步。
这与 packages/docs/di/di.md 中"先用 Injector.resolve([...]) 得到已解析 provider、再用 fromResolvedProviders 快速建注入器"的思路完全一致——预先 resolve、按需复用,是性能敏感区域的推荐做法。
对照现代 Ivy:编译期的 TView.data / 指令定义(ɵdir/ɵcmp 中的 providers、viewProviders 数组)承担了"按类型存储元信息"的角色,运行期的 LView 数组则按实例存放已创建的 injectable 与注入器结构(索引与 TView.data 一一对应)。getOrCreateNodeInjectorForNode 只在"元素上确有指令/provider"且尚未创建时才初始化节点注入器——这正是下文"每个元素都有一个 Injector"心智模型的工程化落点。
Host 宿主边界与 Provider 可见性
什么是 Host 边界
注入器可以有父注入器,而父关系可以被显式标记为 host(宿主):
var child = new Injector(proto, parent, true /* host */);
Host 标记的作用是约束依赖解析的搜索范围:解析遇到 host 便停止向上。例如下面的构造器声明了 @Host(),那么 DI 在沿着注入器链找到 host 之后,就不会再继续往 host 之上寻找 Engine:
class Car {
constructor(@Host() e: Engine) {}
}
Public / Private / PublicAndPrivate 三档可见性
设想这样的注入器拓扑——ParentInjector 同时拥有两类子注入器,其中 Child2 把自己与父节点的关系标记为 host:
ParentInjector
/ \
/ \ host
Child1 Child2
此时 ParentInjector 可能需要向"普通子节点"与"host 子节点"暴露两套不同的 providers:
- 对"普通子节点"可见的 provider 称为 public(公共);
- 对"host 子节点"可见的 provider 称为 private(私有)。
这是 Angular 内部真实使用的高级场景:组件可以分别向"它的子级"和"它的视图"提供不同集合的服务。理解了这一点,providers 与 viewProviders 的存在理由就呼之欲出了。
下面用三组实验把语义讲透。先看默认情况——Engine 标记为 public:
class Car {
constructor(@Host() e: Engine) {}
}
var parentProto = new ProtoInjector([
new BindingWithVisibility(Engine, Visibility.Public),
new BindingWithVisibility(Car, Visibility.Public)
]);
var parent = new Injector(parentProto);
var hostChildProto = new ProtoInjector([new BindingWithVisibility(Car, Visibility.Public)]);
var hostChild = new Injector(hostChildProto, parent, true);
var regularChildProto = new ProtoInjector([new BindingWithVisibility(Car, Visibility.Public)]);
var regularChild = new Injector(regularChildProto, parent, false);
hostChild.get(Car); // will throw because public dependencies declared at the host cannot be seen by child injectors
parent.get(Car); // this works
regularChild.get(Car); // this works
结论 A:Visibility.Public 的 provider 只对"普通子节点"可见;当一个 host 子节点沿链向上搜索时,宿主上标记为 public 的 provider 对它不可见,于是抛错。注意 Car 本身仍在子节点自己的 proto 中声明,所以 hostChild.get(Car) 的失败并非找不到 Car,而是实例化 Car 时无法解析 @Host() Engine。
再把 Engine 标记为 private:
class Car {
constructor(@Host() e: Engine) {}
}
var parentProto = new ProtoInjector([
new BindingWithVisibility(Engine, Visibility.Private),
new BindingWithVisibility(Car, Visibility.Public)
]);
var parent = new Injector(parentProto);
var hostChildProto = new ProtoInjector([new BindingWithVisibility(Car, Visibility.Public)]);
var hostChild = new Injector(hostChildProto, parent, true);
var regularChildProto = new ProtoInjector([new BindingWithVisibility(Car, Visibility.Public)]);
var regularChild = new Injector(regularChildProto, parent, false);
hostChild.get(Car); // this works
parent.get(Car); // this throws
regularChild.get(Car); // this throws
结论 B:Visibility.Private 的 provider 只对"host 子节点"可见。于是宿主自己取不到、普通子节点也取不到 Engine(因为二者的解析视角都看不到它),唯有 host 子节点可以。这正是组件视图(由宿主边界隔开的子注入器)读取 viewProviders 的底层模型。
最后,把 Engine 同时标记为 public 与 private:
class Car {
constructor(@Host() e: Engine) {}
}
var parentProto = new ProtoInjector([
new BindingWithVisibility(Engine, Visibility.PublicAndPrivate),
new BindingWithVisibility(Car, Visibility.Public)
]);
var parent = new Injector(parentProto);
var hostChildProto = new ProtoInjector([new BindingWithVisibility(Car, Visibility.Public)]);
var hostChild = new Injector(hostChildProto, parent, true);
var regularChildProto = new ProtoInjector([new BindingWithVisibility(Car, Visibility.Public)]);
var regularChild = new Injector(regularChildProto, parent, false);
hostChild.get(Car); // this works
parent.get(Car); // this works
regularChild.get(Car); // this works
结论 C:Visibility.PublicAndPrivate 对两类子节点都可见,最典型的用途就是组件自身——组件既要能被自己的普通子级注入,也要能被自己的视图注入。
三种可见性速查
| 可见性 | 普通子节点(Child1) | host 子节点(Child2 / 视图) | 宿主自身 |
|---|---|---|---|
Public |
✅ | ❌ | ✅ |
Private |
❌ | ✅ | ❌ |
PublicAndPrivate |
✅ | ✅ | ✅ |
Host 概念在现代源码中的落点是注入标志位:InternalInjectFlags 用二进制位定义了解析约束,其中 Host = 0b0001、Self = 0b0010、SkipSelf = 0b0100(见 packages/core/src/di/interface/injector.ts),而 @Host 装饰器在 packages/core/src/di/metadata.ts 中通过 attachInjectFlag(makeParamDecorator('Host'), InternalInjectFlags.Host) 实现——它本质就是给参数注入信息打上一个"遇到宿主即止"的位标记。普通约束 @Self(止于当前注入器)、@SkipSelf(从父注入器开始向上)与 @Host(止于最近的宿主边界)三者在 packages/docs/di/di.md 中有更完整的解析算法说明。
Angular 如何应用 DI:每个元素一个 Injector
心智模型
理解 Angular 中 DI 的正确心智模型是:
每个 DOM 元素上都挂着一个 Injector。(实践中只有"含有指令的有趣元素"才会真正创建注入器,这纯粹是性能优化。)
于是 HTML 树直接对应出一棵注入器树。配置这棵树的两个核心属性是:
providers:作用于当前元素及其子元素;viewProviders:作用于组件自己的视图。
可见性规则的表述是:
任何指令都能通过
providers声明可注入项,但只有组件能声明viewProviders。
viewProviders 在 packages/core/src/metadata/directives.ts 的 Component 接口中定义("Defines the set of injectable objects that are visible to its view DOM children"),且只存在于 Component 而非 Directive 上,与上述规则一致。
一个复杂例子:注入器树如何生成
考虑如下模板——MyComponent 与 MyDirective 创建在同一个元素上,元素内部还嵌了两个子元素:
<my-component my-directive>
<needs-service></needs-service>
</my-component>
对应的组件与指令声明:
@Component({
selector: 'my-component',
providers: [
bind('componentService').toValue('Host_MyComponentService')
],
viewProviders: [
bind('viewService').toValue('View_MyComponentService')
],
template: `<needs-view-service></needs-view-service>`,
directives: [NeedsViewService]
})
class MyComponent {}
@Directive({
selector: '[my-directive]',
providers: [
bind('directiveService').toValue('MyDirectiveService')
]
})
class MyDirective {
}
两个需要注入服务的子指令:
@Directive({
selector: 'needs-view-service'
})
class NeedsViewService {
constructor(@Host() @Inject('viewService') viewService) {}
}
@Directive({
selector: 'needs-service'
})
class NeedsService {
constructor(@Host() @Inject('componentService') service1,
@Host() @Inject('directiveService') service2) {}
}
最终生成的注入器树如下:
Injector1 [
{binding: MyComponent, visibility: Visibility.PublicAndPrivate},
{binding: 'componentService', visibility: Visibility.PublicAndPrivate},
{binding: 'viewService', visibility: Visibility.Private},
{binding: MyDirective visibility: Visibility.Public},
{binding: 'directiveService', visibility: Visibility.Public}
]
/ \
| \ host
Injector2 [ Injector3 [
{binding: NeedsService, visibility: Visibility.Public} {binding: NeedsViewService, visibility: Visibility.Public}
] ]
对照模板逐条验证,能得出三条清晰规则:
Injector1(宿主元素):MyComponent自身与它的providers(componentService)是PublicAndPrivate——组件既要"看见自己视图里的注入需求",也要让"外层元素"能看到。viewProviders(viewService)是Private——只对视图方向可见。MyDirective及它的directiveService是Public——它只是普通指令,服务只对自己的子级(即NeedsService所在的外层子元素)可见。Injector2(<needs-service>,普通子节点):能注入componentService(父级 PublicAndPrivate)与directiveService(父级 Public),且二者都加了@Host()——查找恰好在Injector1这个宿主边界内完成。Injector3(<needs-view-service>,组件视图,host 子节点):沿 host 边界向上能看见viewService(Private 对视图可见)与MyComponent自身,但看不见directiveService(Public 对 host 子节点不可见),因此NeedsViewService只注入viewService一个依赖。
一句话总结这幅图:
组件自身及其
providers,既能被子级看见也能被视图看见;viewProviders只有视图能看见;其它指令的providers只有它们的子级能看见。
现代 Ivy 实现对照:可见性如何被编码
原文档的 BindingWithVisibility 与 ProtoInjector 已从代码库消失,但三条可见性规则在现代 Ivy 的 NodeInjector 中依然成立,且工程实现更为精巧。相关逻辑集中在 packages/core/src/render3/di.ts:
- per-element、按需创建:
getOrCreateNodeInjectorForNode只对真正需要的节点初始化注入器,并在tView.data与lView中铺设 bloom filter 数组(BLOOM_SIZE = 256槽位,8 个 32 位桶)。每个 token 被分配全局唯一NG_ELEMENT_ID后按位取模写入过滤器,查找时用bloomHasToken做 O(1) 预筛,把"绝大多数注入器根本没有这个 provider"的情况以极低成本排除。 - includeViewProviders 开关:模块级变量
includeViewProviders控制当前实例化能否访问viewProviders。源码注释给出典型场景:组件构造函数里inject字符串 token 时应命中viewProviders(值为'viewProviders'),而组件自己通过providers提供的MyService内再注入同一个 token 时,则必须命中providers(值为'providers')——getNodeInjectable在调用工厂前后通过setIncludeViewProviders(factory.canSeeViewProviders)保存与恢复该开关,保证"声明在viewProviders之外的服务无法偷窥viewProviders"。这与文档中"viewService 是 Private"的规则完全同构。 - @Host 的宿主角:解析时若带有
Host标志,lookupTokenUsingNodeInjector会记录hostTElementNode;shouldSearchParent遇到"第一个 host 节点"即终止向上;而locateDirectiveOrProvider中存在一个明确的 host 特例——当查找点恰是 host 元素时,endIndex被限制在injectablesStart + cptViewProvidersCount,即只能看到组件自身的 viewProviders 与组件类型本身,其余普通 provider 与兄弟指令一概不可见。这正是文档"public dependencies declared at the host cannot be seen by host child injectors"的现代实现。
对照原文档树中的 Injector1,现代编译产物会把 componentService、viewService、directiveService 等按 "component viewProviders(私有段)→ component providers → directive providers"的顺序排布在节点 provider 区间,cptViewProvidersCount 记录私有段长度,canAccessViewProviders 决定从私有段还是公共段开始扫描——三段可见性被压缩成了"一段数组 + 两个偏移量",比 BindingWithVisibility 时代更为紧凑。
结语与自查清单
di_advanced.md 的价值不在于那几个已经被淘汰的类名,而在于它揭示了 DI 设计中反复出现的三条不变法则,可在排错时作为自查清单:
- token 会被归一化:注入器内部永远以"带 id 的键 / 带编号的 bloom 位"工作,
inj.get(Engine) === inj.get(Key.get(Engine))只是同一件事的两种写法。 - 元信息与实例分离:provider 解析一次、按类型共享(ProtoInjector / TView),实例按需创建并缓存(Injector / LView)——这也是
Injector.resolveAndCreate会"偷偷"先建 proto 的原因。 - 可见性决定谁能拿到服务:
providers面向元素与子级、viewProviders面向组件视图、@Host划定查找的终点;当注入结果与直觉不符时,先画出元素树,标出哪条边是 host 边界,再套用 Public/Private 规则即可定位问题。
对普通应用开发者,只要遵守"把跨组件共享的服务放进 providers、把仅限本组件视图使用的服务放进 viewProviders"这一实践准则,便已足够;本文所述机制更适合在需要理解或调试复杂嵌套组件层级、封装可复用组件库时翻阅。
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 StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00