在页面 CSS 中保留共享设计令牌,将组件专用变量作为样式 API 暴露,并通过由页面管理的小型映射将两者连接起来。本教程将构建一个本地 Mac 演示,清晰区分这些职责。
页面负责什么,组件负责什么?
对于在 Mac 上编辑自编网页的人来说,具体任务是为可复用组件设置主题,同时避免组件实现依赖页面的令牌名称。下面的示例使用一个合成的 orbit-tile 组件,其中包含一个抽象形状和一段简短标签。
设计令牌和公开组件变量都可以是 CSS 自定义属性。两者的区别取决于你记录的契约。普通的未注册自定义属性会参与层叠并继承;在 :root 上声明后,它们会通过继承在整个文档中可用。CSS 没有专门的关键字能将某个属性变成设计令牌。使用 CSS 自定义属性(变量)
使用这张所有权表来判断某项声明应放在哪里。这些名称仅用于说明,且只适用于此示例。
| 层级 | 示例 | 职责 |
|---|---|---|
| 页面设计令牌 | --scene-accent、--scene-rounding |
页面共享的视觉选择 |
| 页面集成规则 | 针对 orbit-tile 的规则 |
将页面选择转换为组件 API 的值 |
| 公开组件变量 | --orbit-tile-fill、--orbit-tile-radius |
面向使用者公开并记录的控制项 |
| 组件实现 | .shape、布局规则、字面量默认值 |
渲染组件,无需页面令牌 |
实用的检验标准是可移植性:另一个页面能否使用不同的令牌名称,同时让组件保持不变?如果由页面负责映射,就可以。新页面只需将自己的令牌映射到相同的公开组件变量。
样式契约如何跨越 Shadow DOM?
Shadow DOM 将选择器匹配与继承分开。页面选择器通常无法穿过 shadow 边界选中内部形状。继承的属性值仍然可以通过宿主元素传递到内部。CSS Shadow 规范描述了这种关系所使用的扁平化元素树;选择器树彼此分离并不会消除继承。CSS Shadow Module Level 1
在组件内部不要设置公开变量,而是在使用变量的位置提供默认值。例如,background-color: var(--orbit-tile-fill, #667085) 接受使用者继承的值,同时提供独立使用时的默认值。如果改为在 :host 上声明 --orbit-tile-fill: #667085,宿主元素就会拥有自己的值,从而阻止它继承祖先提供的值。使用者仍然可以直接选中宿主元素。设置 Web 组件样式
在此示例中,只记录两个公开控制项:
| 公开变量 | 此示例接受的值 | 缺省时的默认值 | 效果 |
|---|---|---|---|
--orbit-tile-fill |
有效的 CSS 颜色 | #667085 |
形状的背景色 |
--orbit-tile-radius |
一个非负长度或百分比 | 1rem |
形状的圆角半径 |
这些可接受值构成 API 约定。基础实现不会注册或自动验证它们。内部类名属于实现细节,因此页面作者应自定义公开变量,而不要依赖 .shape。
在 Mac 上构建完整的本地示例
使用纯文本编辑器和支持 CSS 自定义属性、Shadow DOM 以及自主自定义元素的浏览器。此示例使用一个继承自 HTMLElement 的自主元素;MDN 将这种方式与 Safari 计划不支持的自定义内置元素区分开来。使用自定义元素
在 TextEdit 中,选择 File > New,然后选择 Format > Make Plain Text。粘贴下面的完整文档,并将其保存为 token-demo.html。如果出现提示,请选择 Use .html。重新打开文件进行编辑时,请在打开对话框中选择 Ignore rich text commands,或在设置中启用 Display HTML files as HTML code instead of formatted text。在 Mac 上使用 TextEdit 处理 HTML 文稿
文档的第一个样式表负责令牌、集成规则和页面布局。组件会在自己的 shadow root 中创建一个单独的样式表。HTML 将 style 元素定义为嵌入 CSS 的方式;把所有源代码放在一个文件中,可以明确展示此示例的资源设置。HTML 标准:style 元素
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Orbit tile token demo</title>
<style>
/* Page-owned shared tokens */
:root {
--scene-accent: #4058b8;
--scene-rounding: 12px;
}
/* Page-owned integration mapping */
orbit-tile {
--orbit-tile-fill: var(--scene-accent);
--orbit-tile-radius: var(--scene-rounding);
}
/* A local page theme */
.alternate {
--scene-accent: #276f64;
--scene-rounding: 24px;
}
/* An intentional override for one component instance */
orbit-tile.special {
--orbit-tile-fill: #a65b20;
--orbit-tile-radius: 50%;
}
body {
max-width: 36rem;
margin: 3rem auto;
padding: 0 1rem;
font: 1rem/1.5 system-ui, sans-serif;
}
main { display: grid; gap: 1rem; }
</style>
</head>
<body>
<main>
<orbit-tile>Sample A</orbit-tile>
<section class="alternate" aria-label="Alternate samples">
<orbit-tile>Sample B</orbit-tile>
</section>
<orbit-tile class="special">Sample C</orbit-tile>
</main>
<script>
class OrbitTile extends HTMLElement {
constructor() {
super();
const root = this.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = `
:host { display: block; }
.tile {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: #f2f4f6;
color: #202630;
}
.shape {
display: block;
width: 4rem;
height: 4rem;
flex: 0 0 4rem;
background-color: var(--orbit-tile-fill, #667085);
border-radius: var(--orbit-tile-radius, 1rem);
}
`;
const tile = document.createElement('div');
tile.className = 'tile';
const shape = document.createElement('span');
shape.className = 'shape';
shape.setAttribute('aria-hidden', 'true');
tile.append(shape, document.createElement('slot'));
root.append(style, tile);
}
}
customElements.define('orbit-tile', OrbitTile);
</script>
</body>
</html>
在 Safari、Chrome 或 Firefox 中打开保存的文件。此示例不使用外部脚本、字体、图像或样式表。JavaScript 已通过语法检查;以下视觉结果是根据声明推断出的预期效果,并非浏览器执行情况的报告。
| 实例 | 预期形状 | 值的传递路径 |
|---|---|---|
Sample A |
蓝色,圆角为 12px |
根令牌 → 宿主映射 → 组件 |
Sample B |
绿色,圆角为 24px |
区段令牌 → 宿主映射 → 组件 |
Sample C |
橙色圆形 | 实例覆盖值 → 组件 |
请注意,组件的 JavaScript 中没有 --scene-* 引用。它的样式表只使用公开的 --orbit-tile-* 名称。页面可以重新组织主题术语,而无需更改组件。
令牌到组件的映射应放在哪里?
在能够获取预期主题的位置映射值。在此演示中,orbit-tile 规则作用于每个宿主元素。因此,第二个宿主元素可以在继承区段的绿色值后解析 --scene-accent。
如果不考虑解析时机,请不要将映射移到 :root。如果 --orbit-tile-fill: var(--scene-accent) 在那里计算,后代会继承它已解析的值。只在后面的区段更改 --scene-accent,并不会重新生成继承的别名。自定义属性会在其计算值被继承之前解析其中的 var() 引用。CSS 层叠变量自定义属性模块第 1 级
实际需要考虑的是作用范围:如果每个实例都应遵循其周围页面令牌,请使用宿主级映射。如果某个区域有意共享一个公开组件值,那么在区域上声明该公开变量可能合适;但应删除该变量对应的宿主映射,以免它替换继承的值。
对于内部的重复使用,实现别名可以将回退声明集中起来。像 --_orbit-tile-fill 这样的名称可以表示内部约定,但下划线并不能带来强制性的私有性。像完整示例所示,若每个值只使用一次,直接使用该变量更容易理解。设置 Web 组件样式
如何诊断不起作用的覆盖值?
将这三个实例作为一项小型契约测试。每次只更改一条声明,然后保存并重新加载。以下检查有助于区分资源缺失、继承问题和无效值。
- 将根级强调色改为
#7251a1。预期只有Sample A会采用它;区段和实例各自有单独的选择。 - 将区段强调色改为
#32758a。预期Sample B会改变,无需编辑组件代码。 - 删除整个集成映射。预期
Sample A和Sample B会使用组件的灰色填充和1rem圆角;Sample C保留其明确设置的公开覆盖值。 - 继续其他实验前,恢复映射。
如果所有形状都消失,先检查文档是否保存为 HTML,以及脚本是否完整。如果标签出现但形状没有样式,请检查组件样式表。仅有文档样式表并不能通过普通选择器设置 shadow 后代的样式;实现必须将内部样式附加到 shadow 树。MDN 介绍了可用于此目的的嵌入样式和构造式样式表。使用 shadow DOM
如果只有祖先覆盖值不起作用,请查找是否直接在宿主元素上声明了公开变量。在此示例中,集成映射就是这样的声明。你可以更改为其提供值的页面令牌、在实例上覆盖公开变量,或在预期的契约是由祖先提供公开主题时删除映射。
如果赋值 --orbit-tile-fill: 12px 后形状变为透明,回退值不会修复它。该变量确实存在,但其替换后的值对 background-color 无效。该声明会在计算值阶段变为无效;它不会回退到更早的背景声明。请移除该赋值或提供有效颜色。CSS 层叠变量自定义属性模块第 1 级
公开变量应使用 @property 吗?
此契约不要求注册。@property 会增加语法约束、初始值以及明确的继承行为。MDN 将它标记为 Baseline 2024,自 2024 年 7 月起已在当前浏览器中可用,同时指出较旧的浏览器可能不支持。完整示例不会将注册作为前提。@property CSS at 规则
如果引入注册,请在文档级别协调处理。Chrome 的实现文档指出,shadow root 内的注册会被忽略;该报告日期为 2024 年 8 月,因此应将其视为已记录的互操作边界,而非最新的浏览器测试证据。文档所述的受支持方式是在文档作用域注册,然后由组件跨越边界使用该属性。作者定义的 CSS 名称与 Shadow DOM:规范与实践
注册也会改变行为。设置 inherits: false 会破坏示例中宿主到形状的继承。注册的初始颜色意味着未设置的属性也会有该颜色,因此使用位置的 var() 回退值不再是缺失值时的默认值。对此设计,请保留 inherits: true,并明确选择由哪种机制负责提供默认值。@property CSS at 规则
最终的契约应让页面作者能够解释每个值的传递路径:页面共享的选择、映射到有记录的组件控制项,以及由组件提供的回退值。让映射靠近其预期的主题作用范围,在组件内部不要设置需要继承的公开输入,并在修订 API 时保留这个三实例示例。
来源
- 使用 CSS 自定义属性(变量) — MDN Web Docs。
- CSS Shadow Module Level 1 — W3C CSS 工作组。
- 设置 Web 组件样式 — Chrome 开发者文档。
- 使用自定义元素 — MDN Web Docs。
- 在 Mac 上使用 TextEdit 处理 HTML 文稿 — Apple 支持。
- HTML 标准 — WHATWG。
- CSS 层叠变量自定义属性模块第 1 级 — W3C。
- 使用 shadow DOM — MDN Web Docs。
@propertyCSS at 规则 — MDN Web Docs。- 作者定义的 CSS 名称与 Shadow DOM:规范与实践 — Chrome 开发者文档。