用 Claude Code 构建 Web Components 的实践指南
用 Claude Code 实现 Web Components:Custom Elements、Shadow DOM、React/Vue 集成、评审提示词和测试。
为什么把 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 Components、MDN Using custom elements 和 MDN 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: true 和 composed: 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 键盘测试和真实浏览器焦点检查。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。