Skip to content

part 与 exportparts

part 全局属性用于将 Shadow DOM 内部的元素暴露给外部 CSS,使宿主页面可以使用 ::part() 伪元素选择器来定制 Web Component 内部元素的样式。exportparts 属性则允许嵌套的 Shadow DOM 将内部组件的部分(parts)转发到外层。

前置知识

阅读本节前,建议先了解:translate 翻译控制

基础概念

什么是 CSS Parts

Web Components 的 Shadow DOM 提供了强大的样式封装(Style Encapsulation),外部 CSS 无法直接选择 Shadow DOM 内部的元素。但有时我们需要允许外部定制组件的某些部分样式,part 属性就是为此设计的。

html
<!-- 组件内部:标记可被外部样式化的部分 -->
<template id="my-button">
  <style>
    /* 内部默认样式 */
    .btn { padding: 8px 16px; border: none; border-radius: 4px; }
    .label { font-size: 14px; }
  </style>
  <button class="btn">
    <span class="label"><slot></slot></span>
  </button>
</template>

part 和 exportparts 的作用

属性使用位置作用
partShadow DOM 内部元素将元素标记为可被外部样式化的"部分"
::part()宿主页面 CSS选择 Shadow DOM 内部被标记的部分
exportpartsShadow DOM host 元素将嵌套 Shadow DOM 的 parts 转发到外层

语法

定义 part

html
<!-- 在 Shadow DOM 模板中 -->
<template id="my-card">
  <div class="card" part="card">
    <div class="card-header" part="header">
      <h3 class="card-title" part="title">标题</h3>
    </div>
    <div class="card-body" part="body">
      <slot></slot>
    </div>
    <div class="card-footer" part="footer">
      <slot name="actions"></slot>
    </div>
  </div>
</template>

使用 ::part() 定制样式

css
/* 宿主页面中,使用 ::part() 选择组件内部部分 */
my-card::part(card) {
  border: 2px solid #e2e8f0;
  border-radius: 12px;
}

my-card::part(header) {
  background: linear-gradient(135deg, #667eea, #764ba2);
  color: white;
}

my-card::part(title) {
  font-size: 20px;
  font-weight: 700;
}

my-card::part(footer) {
  padding: 12px;
  border-top: 1px solid #e2e8f0;
}

多个 part 名称

一个元素可以有多个 part 名称:

html
<!-- 元素可以有多个 part 名称 -->
<div part="container wrapper">
  <button part="btn btn-primary">提交</button>
</div>

<!-- 外部可以使用任一名称选择 -->
my-widget::part(container) { /* ... */ }
my-widget::part(wrapper) { /* ... */ }
my-widget::part(btn) { /* ... */ }
my-widget::part(btn-primary) { /* ... */ }

exportparts 转发

当组件内嵌套了另一个带有 Shadow DOM 的组件时,使用 exportparts 将内层组件的 parts 暴露到外层:

html
<!-- 外层组件模板 -->
<template id="outer-component">
  <!-- 内层组件,转发其 parts -->
  <inner-component exportparts="header, body, footer"></inner-component>
</template>
html
<!-- 外层组件的使用者可以直接定制内层组件的部分 -->
<outer-component></outer-component>

<style>
  /* 直接定制嵌套在内层组件中的元素 */
  outer-component::part(header) {
    background: #1e40af;
  }
</style>

详细说明

::part() 选择器的限制

::part() 选择器有以下限制:

css
/* 允许:直接选择宿主元素 + part */
my-component::part(label) {
  color: red;
}

/* 允许:伪类和伪元素 */
my-component::part(input):focus {
  outline: 2px solid blue;
}

my-component::part(label)::before {
  content: '* ';
  color: red;
}

/* 不允许:后代选择器(不能穿透 Shadow DOM) */
/* my-component::part(card) .title { ... } */

/* 不允许:复合选择器 */
/* my-component::part(card) > .body { ... } */

限制总结:

选择器类型是否支持示例
host::part(name)支持my-card::part(title)
host::part(name):pseudo支持my-card::part(btn):hover
host::part(name)::pseudo支持my-card::part(label)::after
host::part(name) descendant不支持my-card::part(body) p
host::part(name) > child不支持my-card::part(header) > h3

与 CSS 变量配合

part 与 CSS 自定义属性配合使用可以实现更灵活的主题定制:

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>Part + CSS Variables 示例</title>
</head>
<body>
  <script>
    class MyButton extends HTMLElement {
      constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this.shadowRoot.innerHTML = `
          <style>
            button {
              /* 使用 CSS 变量作为默认值,允许外部覆盖 */
              padding: var(--btn-padding, 8px 16px);
              background: var(--btn-bg, #3b82f6);
              color: var(--btn-color, white);
              border: var(--btn-border, none);
              border-radius: var(--btn-radius, 6px);
              font-size: var(--btn-font-size, 14px);
              cursor: pointer;
              transition: all 0.2s;
            }
            button:hover {
              background: var(--btn-bg-hover, #2563eb);
            }
          </style>
          <button part="button">
            <span part="label"><slot></slot></span>
          </button>
        `;
      }
    }
    customElements.define('my-button', MyButton);
  </script>

  <!-- 使用方式 1:通过 ::part() 定制样式 -->
  <my-button class="large-btn">大按钮</my-button>
  <style>
    .large-btn::part(button) {
      padding: 12px 32px;
      font-size: 18px;
      border-radius: 8px;
    }
  </style>

  <!-- 使用方式 2:通过 CSS 变量定制 -->
  <my-button class="danger-btn">危险操作</my-button>
  <style>
    .danger-btn {
      --btn-bg: #dc2626;
      --btn-bg-hover: #b91c1c;
      --btn-radius: 20px;
    }
  </style>
</body>
</html>

实战示例

完整的自定义卡片组件

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>Part 属性实战:自定义卡片组件</title>
  <style>
    body {
      font-family: system-ui, -apple-system, sans-serif;
      padding: 40px;
      background: #f8fafc;
    }

    /* 通过 ::part() 定制组件内部样式 */
    fancy-card {
      --card-bg: white;
      --card-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
    }

    fancy-card::part(card) {
      background: var(--card-bg);
      box-shadow: var(--card-shadow);
      border-radius: 12px;
      overflow: hidden;
      transition: transform 0.3s, box-shadow 0.3s;
    }

    fancy-card:hover::part(card) {
      transform: translateY(-4px);
      box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
    }

    /* 暗色主题卡片 */
    fancy-card.dark {
      --card-bg: #1e293b;
      --card-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
    }
    fancy-card.dark::part(header) {
      background: linear-gradient(135deg, #0ea5e9, #6366f1);
    }
    fancy-card.dark::part(title) {
      color: white;
    }
    fancy-card.dark::part(body) {
      color: #94a3b8;
    }
  </style>
</head>
<body>
  <h1>产品展示</h1>

  <fancy-card class="dark">
    <span slot="title">高级套餐</span>
    <span slot="price">¥99/月</span>
    <p>包含无限存储空间、优先技术支持和高级分析功能。</p>
    <button slot="actions">立即订阅</button>
  </fancy-card>

  <script>
    class FancyCard extends HTMLElement {
      constructor() {
        super();
        const shadow = this.attachShadow({ mode: 'open' });
        shadow.innerHTML = `
          <style>
            .card {
              border: 1px solid #e2e8f0;
              background: white;
            }
            .header {
              padding: 16px 20px;
              background: #f1f5f9;
            }
            .title {
              font-size: 18px;
              font-weight: 600;
              color: #1e293b;
              margin: 0;
            }
            .price {
              font-size: 24px;
              font-weight: 700;
              color: #3b82f6;
              margin: 4px 0 0;
            }
            .body {
              padding: 20px;
              line-height: 1.6;
              color: #475569;
            }
            .footer {
              padding: 16px 20px;
              border-top: 1px solid #e2e8f0;
              text-align: right;
            }
            .footer ::slotted(button) {
              padding: 8px 24px;
              background: #3b82f6;
              color: white;
              border: none;
              border-radius: 6px;
              cursor: pointer;
            }
          </style>
          <div class="card" part="card">
            <div class="header" part="header">
              <h3 class="title" part="title"><slot name="title">标题</slot></h3>
              <div class="price" part="price"><slot name="price"></slot></div>
            </div>
            <div class="body" part="body">
              <slot></slot>
            </div>
            <div class="footer" part="footer">
              <slot name="actions"></slot>
            </div>
          </div>
        `;
      }
    }
    customElements.define('fancy-card', FancyCard);
  </script>
</body>
</html>

exportparts 嵌套转发

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>exportparts 嵌套转发示例</title>
</head>
<body>
  <script>
    // 内层组件:列表项
    class ListItem extends HTMLElement {
      constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this.shadowRoot.innerHTML = `
          <style>
            .item {
              display: flex;
              align-items: center;
              padding: 8px 12px;
              border-bottom: 1px solid #e2e8f0;
            }
            .icon { margin-right: 8px; }
            .text { flex: 1; }
            .action { margin-left: 8px; }
          </style>
          <div class="item" part="item">
            <span class="icon" part="icon"><slot name="icon"></slot></span>
            <span class="text" part="text"><slot></slot></span>
            <span class="action" part="action"><slot name="action"></slot></span>
          </div>
        `;
      }
    }
    customElements.define('list-item', ListItem);

    // 外层组件:列表容器,转发内层 parts
    class FancyList extends HTMLElement {
      constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this.shadowRoot.innerHTML = `
          <style>
            .list {
              border: 1px solid #e2e8f0;
              border-radius: 8px;
              overflow: hidden;
            }
          </style>
          <div class="list" part="list">
            <slot></slot>
          </div>
        `;
      }
    }
    customElements.define('fancy-list', FancyList);
  </script>

  <!-- 外层组件的模板中转发内层 parts -->
  <!--
    在 fancy-list 的模板中,list-item 应使用:
    <list-item exportparts="item, icon, text, action">
  -->

  <fancy-list>
    <list-item exportparts="item, icon, text, action">
      文件 A
      <span slot="action">
        <button>删除</button>
      </span>
    </list-item>
  </fancy-list>

  <!-- 外部可以直接使用转发过来的 parts -->
  <style>
    fancy-list::part(list) {
      max-height: 300px;
    }
    fancy-list::part(item) {
      /* 通过 exportparts 转发后可以直接选择 */
    }
    fancy-list::part(action) {
      /* 定制操作按钮区域 */
    }
  </style>
</body>
</html>

注意事项

安全性考虑

part 属性会暴露 Shadow DOM 的内部结构,在设计公开 API 时应谨慎:

html
<!-- 推荐:暴露语义化的 part 名称 -->
<div part="header">标题区域</div>
<div part="content">内容区域</div>

<!-- 避免:暴露内部实现细节 -->
<div part="flex-container-row">标题区域</div>
<div part="padding-20">内容区域</div>

命名规范

html
<!-- 推荐:简洁、语义化的名称 -->
<button part="button">点击</button>
<span part="label">标签文字</span>
<div part="header">头部</div>
<div part="icon">图标</div>

<!-- 避免:过长的或特定框架的名称 -->
<button part="ant-design-primary-button-outlined">点击</button>
<span part="tw-text-sm-font-medium">标签</span>

最佳实践

1. part 和 CSS 变量结合

part 适合控制结构性样式(布局、间距),CSS 变量适合控制主题性样式(颜色、字体):

css
/* ::part() 控制结构 */
my-card::part(body) {
  padding: 24px;
  display: grid;
  grid-template-columns: 1fr 1fr;
}

/* CSS 变量控制主题 */
my-card {
  --card-primary: #3b82f6;
  --card-radius: 8px;
  --card-font: system-ui;
}

2. 文档化所有 parts

javascript
// 在组件类中通过 static 属性声明可用的 parts(文档用途)
class MyButton extends HTMLElement {
  static get observedAttributes() {
    return ['variant', 'size'];
  }

  // 用于文档生成的 parts 声明
  static parts = {
    'button': '按钮容器元素',
    'label': '按钮文本标签',
    'icon': '图标区域'
  };
}

下一节

继续学习:slot 插槽

参考链接