CSS 天生全局作用域,组件化开发首先要解决”样式不串台”。Angular 用 ViewEncapsulation 提供了三种隔离策略:编译期模拟(Emulated)、不隔离(None)、浏览器原生 Shadow DOM。理解三者的机制与代价,才能回答两个高频问题:“为什么我的样式不生效”和”怎么覆盖第三方组件的内部样式”。
三种封装模式总览
| 模式 | 实现机制 | 隔离强度 | 典型用途 |
|---|---|---|---|
Emulated(默认) | 编译期给元素加宿主属性,改写选择器 | 组件样式不外泄、外部样式不误入 | 99% 的业务组件 |
ShadowDom | 浏览器原生 Shadow Root | 完全隔离(连 document.querySelector 都进不去) | 嵌入第三方页面的 widget、强隔离组件 |
None | 不做任何处理 | 无,样式直接进全局 | 带类名限定的全局工具样式、主题补丁 |
Emulated:默认方案的原理
Angular 编译器给组件模板中每个元素附加形如 _ngcontent-xxx 的属性,并把组件样式改写为属性选择器:
<!-- 你写的 -->
<h1>标题</h1>
<!-- 编译产物(示意) -->
<h1 _ngcontent-ng-c1234567890>标题</h1>
<style>
h1[_ngcontent-ng-c1234567890] { color: hotpink; }
</style>
同理,:host 被编译为 [_nghost-ng-c1234567890]。效果上等价于”组件样式只命中本组件模板”,但没有任何运行时开销,也不依赖 Shadow DOM 兼容性。理解这个机制后很多”灵异现象”就有了解释:
- 组件样式能命中投影进来的内容吗?不能——投影内容的
_ngcontent属性属于声明它的那个组件,样式要写到声明处组件里(这是内容样式归属的正确心智模型)。 - 全局
styles.css为什么能命中一切?全局样式没有属性限定,自然全局生效。
ShadowDom:原生隔离
import { Component, ViewEncapsulation, input } from '@angular/core';
@Component({
selector: 'app-widget',
encapsulation: ViewEncapsulation.ShadowDom,
template: `
<ng-content select="[slot=title]" />
<div class="body"><ng-content /></div>
`,
styles: `
:host { display: block; border: 1px solid #ccc; }
:host-context(.dark) { background: #1e1e1e; }
.body { padding: 12px; }
`,
})
export class WidgetComponent {
title = input('Widget');
}
在 ShadowDom 模式下,<ng-content> 会被编译为原生 <slot>,原生的 :host、:host-context、::slotted 选择器全部可用。
CSS 自定义属性:受控的”穿透”
Shadow 边界挡住了选择器,但可继承属性与 CSS 自定义属性仍能穿透,这是主题化的正规通道:
/* 全局 theme.css */
:root {
--widget-accent: #6750a4;
}
/* 组件内 */
:host {
color: var(--widget-accent, currentColor);
}
外部改变 --widget-accent,Shadow 内部即时响应——隔离与可定制并不矛盾,关键是把”可定制点”显式声明为变量 API。
ShadowDom 的坑
- 挂在
body下的浮层(Overlay、Dialog、Toast)在组件 shadow 之外,组件样式管不到它们,需要全局样式或注入自定义主题类。 - 外部脚本与测试工具的选择器进不来(
querySelector拿不到 shadow 内部节点,需shadowRoot.querySelector)。 - 全局 reset、字体样式只有可继承部分能进入 shadow;非继承属性(border、margin 等)需要在组件内自行声明。
- 增量 hydration SSR 下使用 ShadowDom 需要声明式 Shadow DOM 支持,与 Emulated 相比约束更多。
None:全局样式,谨慎使用
@Component({
encapsulation: ViewEncapsulation.None,
styles: `
.app-md-table { border-collapse: collapse; }
.app-md-table th { text-align: left; }
`,
})
export class MarkdownTableHostComponent {}
None 模式下样式不带任何限定直接注入全局。如果要用它,请遵守一条纪律:所有选择器必须带组件专属前缀类名,并配合 host: { class: '...' } 限定作用范围。没有前缀的 None 样式等于给全应用埋雷。
::ng-deep 的坑与替代
::ng-deep 是历史上强制穿透封装的选择器(v9 起文档弃用,但至今保留编译支持)。它的两个问题:
- 破坏封装的粒度是”全局”的:
::ng-deep之后的选择器不再携带组件属性限定,会命中全应用的所有匹配元素,而不仅是目标组件。 - 脆弱:穿透的是第三方组件的内部 DOM 结构,对方一次重构,样式静默失效。
| 场景 | v22 推荐做法 |
|---|---|
| 定制 Material 组件外观 | 使用官方暴露的主题 token / CSS 自定义属性(--mat-*) |
| 修饰投影进来的内容 | 在内容声明方的组件里写样式(归属正确) |
| 需要深层覆盖且组件无 token | 封装一个 wrapper 组件:ViewEncapsulation.None + 前缀类名集中管理 |
| 确实要临时穿透 | :host ::ng-deep ... 限定在宿主内,并在注释里标注依赖与清理计划 |
wrapper 模式示例:
@Component({
selector: 'app-editor-host',
encapsulation: ViewEncapsulation.None,
host: { class: 'app-editor-host' },
styles: `
/* 前缀限定,只影响本 wrapper 内部 */
.app-editor-host .editor-toolbar { font-size: 12px; }
.app-editor-host .editor-toolbar__btn { border-radius: 4px; }
`,
template: `<third-party-editor />`,
})
export class EditorHostComponent {}
样式组织建议
- 注册样式用
styleUrl(v17 起推荐单数)或styles内联;styleUrls多文件仍可用。
@Component({
selector: 'app-card',
styleUrl: './card.component.css',
// 或多文件: styleUrls: ['./card.base.css', './card.theme.css']
})
export class CardComponent {}
- 组件样式只写组件自己的视觉;主题变量、reset、工具类放全局
styles.css。 - 覆盖第三方样式集中到少量”host wrapper”或全局主题文件,禁止散落在业务组件里。
- 在
angular.json的 budgets 里为组件样式设上限,防止样式文件膨胀成垃圾场。