Use Cases (更新: 2026/6/2)

用 Claude Code 构建 Web Components 的实践指南

用 Claude Code 实现 Web Components:Custom Elements、Shadow DOM、React/Vue 集成、评审提示词和测试。

用 Claude Code 构建 Web Components 的实践指南

为什么把 Web Components 交给 Claude Code

Web Components 是浏览器原生的组件技术。Custom Elements 让我们注册自己的 HTML 标签,Shadow DOM 把组件内部的结构和样式隔离起来,模板和插槽让组件更容易复用。它的价值不是替代 React 或 Vue,而是在不同技术栈之间提供一个小而稳定的 UI 契约。

Claude Code 很适合这类工作,因为 Web Components 容易在“边界不清楚”时出问题。属性是什么,属性和属性对象上的 property 如何同步,事件叫什么,Shadow DOM 里的样式如何被主题覆盖,哪些行为必须测试,这些问题如果不先写清楚,生成出来的组件通常只能在一个 demo 里工作。

Masa 在内容站和内部工具里踩过的坑是:先做了一个 React 专用的数量选择器,后来想放进 CMS 和 Vue 管理页时发现必须一起搬 React 运行时。把它改成 Web Component 后,CMS、静态 HTML、React 仪表盘和 Vue 页面都能使用同一个标签。真正难的不是 customElements.define,而是 CSS 边界、事件设计、可访问性和版本管理。

本文会做一个可复制的 quantity-stepper。它包括 Custom Element 最小实现、属性监视、CustomEvent、Shadow DOM 样式、React/Vue 使用方式、Claude Code 评审指令和 Vitest 测试。设计系统整体落地可以继续参考 Claude Code 设计系统指南

官方资料请以 MDN Web ComponentsMDN Using custom elementsMDN CustomEvent 为准。

适合的使用场景

Web Components 最适合“小组件、多环境、稳定 API”的场景。如果组件拥有整页状态、复杂路由或服务器渲染策略,继续使用应用框架更自然。如果它只是一个需要在多个前端里复用的 UI 单元,Web Components 就很有价值。

场景为什么适合给 Claude Code 的要求
设计系统分发React、Vue、静态站点可以使用同一套部件tokens、尺寸、状态、无障碍规则
CMS 嵌入编辑页可以加入交互 UI,不必加载完整 app不依赖全局 CSS,script 加载顺序安全
多框架共用 UI迁移期间旧页面和新页面共用同一个标签属性 API、事件名、破坏性变更策略
表单部件数量、评分、地址补全等可以单独发布label、错误状态、键盘操作、表单联动
内部 widget管理后台、BI、门户页面复用相同工具不把 secret 放进属性,记录版本和日志

数量选择、星级评分、复制按钮、帮助入口、搜索框都很适合。把整个订单页、复杂编辑器或路由系统做成 Web Component 通常不划算。Claude Code 的提示词里最好写清楚“这个组件只负责什么,不负责什么”。

flowchart LR
  A["Design tokens"] --> B["Web Component package"]
  B --> C["React app"]
  B --> D["Vue app"]
  B --> E["CMS page"]
  B --> F["Static HTML"]
  B --> G["Internal widget"]

Custom Element 最小实现

下面的代码保存为 quantity-stepper.ts。它从 HTML 属性读取字符串,内部转换为数字,点击按钮后更新属性和 property,并向外发送 quantity-change 事件。

const toNumber = (value: string | null, fallback: number) => {
  const parsed = Number(value);
  return Number.isFinite(parsed) ? parsed : fallback;
};

class QuantityStepper extends HTMLElement {
  static observedAttributes = ["value", "step", "label"];

  #root = this.attachShadow({ mode: "open" });
  #value = 0;
  #step = 1;

  connectedCallback() {
    this.#syncFromAttributes();
    this.#render();
  }

  attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) {
    if (oldValue === newValue || !this.isConnected) return;
    this.#syncFromAttributes();
    this.#render();
  }

  get value() {
    return this.#value;
  }

  set value(nextValue: number) {
    const normalized = Number.isFinite(nextValue) ? nextValue : 0;
    if (normalized === this.#value) return;

    this.#value = normalized;
    this.setAttribute("value", String(normalized));
    this.#emitChange();
    this.#render();
  }

  #syncFromAttributes() {
    this.#value = toNumber(this.getAttribute("value"), 0);
    this.#step = toNumber(this.getAttribute("step"), 1);
  }

  #update(direction: -1 | 1) {
    this.value = this.#value + this.#step * direction;
  }

  #emitChange() {
    this.dispatchEvent(
      new CustomEvent("quantity-change", {
        detail: { value: this.#value },
        bubbles: true,
        composed: true,
      }),
    );
  }

  #render() {
    const label = this.getAttribute("label") || "Quantity";

    this.#root.innerHTML = `
      <style>
        :host {
          --quantity-accent: #2563eb;
          --quantity-border: #cbd5e1;
          --quantity-bg: #ffffff;
          --quantity-text: #0f172a;
          display: inline-flex;
          font-family: system-ui, sans-serif;
        }
        .control {
          display: inline-grid;
          grid-template-columns: 2.5rem minmax(3rem, auto) 2.5rem;
          align-items: center;
          border: 1px solid var(--quantity-border);
          border-radius: 8px;
          background: var(--quantity-bg);
          color: var(--quantity-text);
          overflow: hidden;
        }
        button {
          min-width: 2.5rem;
          min-height: 2.5rem;
          border: 0;
          background: transparent;
          color: var(--quantity-accent);
          font: inherit;
          cursor: pointer;
        }
        button:focus-visible {
          outline: 2px solid var(--quantity-accent);
          outline-offset: -2px;
        }
        output {
          min-width: 3rem;
          text-align: center;
          font-weight: 700;
        }
        .sr-only {
          position: absolute;
          width: 1px;
          height: 1px;
          padding: 0;
          margin: -1px;
          overflow: hidden;
          clip: rect(0, 0, 0, 0);
          white-space: nowrap;
          border: 0;
        }
      </style>

      <div class="control" role="group" aria-label="${label}">
        <button part="button decrement" data-action="decrement" type="button">
          <span aria-hidden="true">-</span>
          <span class="sr-only">Decrease ${label}</span>
        </button>
        <output part="value" aria-live="polite">${this.#value}</output>
        <button part="button increment" data-action="increment" type="button">
          <span aria-hidden="true">+</span>
          <span class="sr-only">Increase ${label}</span>
        </button>
      </div>
    `;

    this.#root
      .querySelector('[data-action="decrement"]')
      ?.addEventListener("click", () => this.#update(-1));

    this.#root
      .querySelector('[data-action="increment"]')
      ?.addEventListener("click", () => this.#update(1));
  }
}

if (!customElements.get("quantity-stepper")) {
  customElements.define("quantity-stepper", QuantityStepper);
}
<script type="module" src="/src/quantity-stepper.ts"></script>

<quantity-stepper value="2" step="1" label="Seats"></quantity-stepper>

属性、property 与事件

初学者最容易混淆的是 attribute 和 property。attribute 是 HTML 里的字符串,property 是 JavaScript 对象上的值。value="2" 是字符串,element.value = 2 可以是数字。组件必须明确两者如何同步。

这份实现把外部 HTML 当作 attribute 输入,把 JavaScript 操作放在 value property 上,把通知统一成 quantity-change 事件。事件名不要只叫 change,否则容易和原生表单事件混在一起。因为事件要从 Shadow DOM 里传到外层应用,所以设置了 bubbles: truecomposed: true

const stepper = document.querySelector("quantity-stepper");

stepper?.addEventListener("quantity-change", (event) => {
  const { value } = (event as CustomEvent<{ value: number }>).detail;
  console.log("Selected quantity:", value);
});

请让 Claude Code 检查三条路径:外部改 attribute、代码改 property、用户点击按钮。三条路径最终都应该得到同一个值、同一个显示、同一种事件结构。

Shadow DOM 样式策略

Shadow DOM 会隔离样式。外部页面的 .button 不会随便影响组件内部,这对 CMS 和第三方嵌入很安全。但如果完全没有主题入口,设计系统也无法控制颜色、边框和尺寸。

推荐同时公开 CSS Custom Properties 和 part。前者传入主题 token,后者暴露少量可控的内部元素。

quantity-stepper {
  --quantity-accent: #16a34a;
  --quantity-border: #94a3b8;
  --quantity-bg: #f8fafc;
}

quantity-stepper::part(button) {
  font-weight: 700;
}

quantity-stepper::part(value) {
  min-width: 4rem;
}

无障碍也要和样式一起检查。不要移除 focus ring,不要只靠颜色表达状态,内部尽量使用原生 button,数值变化用 aria-live 传达。相关检查可以参考 Claude Code 无障碍实现指南

在 React 和 Vue 中使用

React 可以直接渲染自定义标签,但 CustomEvent 最稳妥的接法是通过 ref 绑定原生事件。

import { useEffect, useRef, useState } from "react";
import type { DetailedHTMLProps, HTMLAttributes } from "react";
import "./quantity-stepper";

type QuantityStepperElement = HTMLElement & { value: number };

declare global {
  namespace JSX {
    interface IntrinsicElements {
      "quantity-stepper": DetailedHTMLProps<
        HTMLAttributes<QuantityStepperElement>,
        QuantityStepperElement
      > & {
        value?: string;
        step?: string;
        label?: string;
      };
    }
  }
}

export function QuantityField() {
  const [quantity, setQuantity] = useState(2);
  const ref = useRef<QuantityStepperElement>(null);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const handleQuantityChange = (event: Event) => {
      setQuantity((event as CustomEvent<{ value: number }>).detail.value);
    };

    element.addEventListener("quantity-change", handleQuantityChange);
    return () => {
      element.removeEventListener("quantity-change", handleQuantityChange);
    };
  }, []);

  return (
    <div>
      <quantity-stepper ref={ref} value="2" step="1" label="Seats" />
      <p>Selected: {quantity}</p>
    </div>
  );
}

Vue 里可以用模板 ref 绑定和清理事件。如果编译器提示未知标签,再在 Vite 配置里声明它是 Custom Element。

<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from "vue";
import "./quantity-stepper";

const quantity = ref(2);
const stepper = ref<HTMLElement | null>(null);

const handleQuantityChange = (event: Event) => {
  quantity.value = (event as CustomEvent<{ value: number }>).detail.value;
};

onMounted(() => {
  stepper.value?.addEventListener("quantity-change", handleQuantityChange);
});

onBeforeUnmount(() => {
  stepper.value?.removeEventListener("quantity-change", handleQuantityChange);
});
</script>

<template>
  <quantity-stepper ref="stepper" value="2" step="1" label="Seats" />
  <p>Selected: {{ quantity }}</p>
</template>
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    vue({
      template: {
        compilerOptions: {
          isCustomElement: (tag) => tag === "quantity-stepper",
        },
      },
    }),
  ],
});

Claude Code 评审提示词

You are editing only src/components/quantity-stepper.ts and its tests.

Goal:
Build a framework-agnostic Web Component named quantity-stepper.

Public API:
- attributes: value, step, label
- property: value as number
- event: quantity-change with detail { value: number }
- event must bubble and cross Shadow DOM with composed: true

Implementation rules:
- Use Custom Elements and Shadow DOM.
- Do not depend on React or Vue.
- Expose theme hooks with CSS Custom Properties.
- Expose ::part(button) and ::part(value).
- Keep keyboard and screen reader behavior usable.
- Do not remove visible focus styles.

Review before finishing:
- Attribute/property synchronization
- Event naming and detail shape
- Shadow DOM styling boundaries
- Accessibility labels and aria-live
- Test coverage for click, attribute update, and event emission
Review this Web Component as production code.
Prioritize bugs over style preferences.
Report issues in this order:
1. Broken public API or breaking change risk
2. Accessibility defects
3. Shadow DOM styling leaks
4. React/Vue integration problems
5. Missing tests

For every finding, include the file, line, why it fails, and a minimal fix.

这种提示词会让 Claude Code 关注公开契约,而不是只调整外观。团队可以把精简版放进 CLAUDE.md

测试公开契约

import { beforeEach, describe, expect, it } from "vitest";
import "./quantity-stepper";

describe("quantity-stepper", () => {
  beforeEach(() => {
    document.body.innerHTML = "";
  });

  it("increments by step and emits a composed event", () => {
    const element = document.createElement("quantity-stepper") as HTMLElement & {
      value: number;
    };

    element.setAttribute("value", "3");
    element.setAttribute("step", "2");
    element.setAttribute("label", "Seats");
    document.body.append(element);

    const events: Array<CustomEvent<{ value: number }>> = [];
    element.addEventListener("quantity-change", (event) => {
      events.push(event as CustomEvent<{ value: number }>);
    });

    element.shadowRoot
      ?.querySelector<HTMLButtonElement>('[data-action="increment"]')
      ?.click();

    expect(element.getAttribute("value")).toBe("5");
    expect(element.value).toBe(5);
    expect(events).toHaveLength(1);
    expect(events[0].detail.value).toBe(5);
    expect(events[0].bubbles).toBe(true);
    expect(events[0].composed).toBe(true);
  });

  it("updates rendering when the value attribute changes", () => {
    const element = document.createElement("quantity-stepper");
    document.body.append(element);

    element.setAttribute("value", "9");

    expect(element.shadowRoot?.querySelector("output")?.textContent).toBe("9");
  });
});

测试重点不是内部 HTML 快照,而是公开 API:attribute、property、事件、Shadow DOM 传播和可访问结构。生产环境还应加 Playwright 检查键盘操作和真实浏览器焦点样式。

常见陷阱

第一,Shadow DOM 和 CSS。完全依赖全局 CSS 会被宿主页面污染,完全封闭又无法主题化。请公开 CSS 变量和 part

第二,attribute/property 同步。不要只测试点击按钮,还要测试外部改属性和代码改 property。

第三,事件设计。事件名、detail 结构、是否 composed 都是公开 API,后续修改要按破坏性变更处理。

第四,可访问性。自定义标签本身没有按钮语义,内部要使用原生控件、label、focus ring 和键盘操作。

第五,SSR 和 hydration。组件定义加载前,标签只是未升级的 HTML。重要购买或注册流程需要降级方案。

第六,版本管理。属性名、事件 payload、CSS 变量和 part 名都要写进 README 或 Storybook,发布 npm 包时遵守 semver。

总结与验证记录

Web Components 适合把小型 UI 契约分发到多个技术栈。Claude Code 的价值在于把实现、评审和测试流程固定下来,减少跨框架复用时的隐性错误。复杂组件可以考虑 Lit,但公开 API 和测试原则仍然相同。

ClaudeCodeLab 可以帮助团队把这些组件整理成设计系统、CMS 嵌入方案、内部 widget 库和 Claude Code 评审流程。如果需要用真实仓库一起梳理,请查看 Claude Code 培训与咨询

验证记录:本文示例围绕同一个 quantity-stepper 契约检查 attribute、property、Shadow DOM 按钮、quantity-change 事件以及 React/Vue 监听方式。上线前建议再补充 Playwright 键盘测试和真实浏览器焦点检查。

#Claude Code #Web Components #Custom Elements #Shadow DOM #Lit
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。