在页面 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 组件样式

如何诊断不起作用的覆盖值?

将这三个实例作为一项小型契约测试。每次只更改一条声明,然后保存并重新加载。以下检查有助于区分资源缺失、继承问题和无效值。

  1. 将根级强调色改为 #7251a1。预期只有 Sample A 会采用它;区段和实例各自有单独的选择。
  2. 将区段强调色改为 #32758a。预期 Sample B 会改变,无需编辑组件代码。
  3. 删除整个集成映射。预期 Sample A 和 Sample B 会使用组件的灰色填充和 1rem 圆角;Sample C 保留其明确设置的公开覆盖值。
  4. 继续其他实验前,恢复映射。

如果所有形状都消失,先检查文档是否保存为 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 时保留这个三实例示例。

来源