React 基础体系 · 第 42/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React 与 Web Components:属性、事件、Ref、类型和互操作
React 和 Web Components 解决的是两个不同层次的问题:
- React负责声明式地描述 UI,并根据状态变化更新 DOM。
- Web Components是浏览器原生提供的自定义元素、Shadow DOM、模板和生命周期机制。
二者可以直接组合,但“能写进 JSX”不等于“语义完全相同”。属性如何传递、事件如何冒泡、Ref 指向什么对象、TypeScript 如何检查标签,以及服务端渲染时哪些信息能够保留下来,都需要分别理解。
本文以 React 19、现代 TypeScript 和浏览器原生 Custom Elements 为基础,重点讨论 React 应用消费 Web Component 的边界。示例中的 Web Component 使用客户端浏览器 API,因此不会在 Node.js 服务端直接执行。
一、先建立两个模型:React 元素和 Web Component
1. React 元素不是 DOM 元素
下面的 JSX:
<MyButton disabled={disabled} onClick={handleClick} />
首先会被 React 解释为一个元素描述。MyButton 是 React 组件,disabled 和 onClick 是传给组件函数的 props。组件函数返回另一个 React 元素后,React 才决定如何创建或更新真实 DOM。
因此,React 组件中的 props 本质上是 JavaScript 数据:
type Props = {
user: {
id: string;
name: string;
};
};
user 可以直接是对象,不需要被转换成字符串。
2. Web Component 是真实 DOM 自定义元素
Web Component 通常由一个继承 HTMLElement 的类实现:
class XCounter extends HTMLElement {
connectedCallback() {
this.textContent = "0";
}
}
customElements.define("x-counter", XCounter);
使用它时:
<x-counter></x-counter>
x-counter 是浏览器 DOM 树中的真实节点,具有:
- HTML 属性;
- JavaScript 属性;
- DOM 事件;
addEventListener;- 元素生命周期;
- 可选的 Shadow DOM。
这里有一个重要区别:
<x-counter value="3"></x-counter>
HTML 中的 value="3" 是字符串属性;而下面的 JavaScript:
element.value = 3;
修改的是 DOM 对象上的 JavaScript 属性,值可以是数字、对象、函数或其他任意 JavaScript 值。
属性(attribute)和属性值(property)不是同一个概念。
二、属性和 Property:React 传给 Web Component 的数据到底去了哪里
1. HTML 属性、DOM Property 和反射
对标准元素而言,属性和 Property 经常存在“反射”关系:
<input disabled>
可以通过:
input.disabled; // true
input.getAttribute("disabled"); // ""
读取。
但是二者仍然有不同的类型和生命周期:
input.value = "hello";
input.setAttribute("value", "initial");
value 是当前输入值,value 属性通常只影响初始值,不能简单认为二者永远同步。
自定义元素也可以定义自己的 Property:
class XUserCard extends HTMLElement {
user: { id: string; name: string } | null = null;
}
此时:
element.user = { id: "u1", name: "Ada" };
可以传递对象,但 HTML 属性无法原样表达这个对象:
<x-user-card user="[object Object]"></x-user-card>
这不是等价写法。
2. React 19 对自定义元素的客户端行为
React 19 改进了自定义元素支持。客户端渲染或客户端更新自定义元素时,React 会根据元素实例上是否存在相应 Property 来决定更新方式:
- 如果元素上存在对应 Property,React 会设置 Property;
- 如果不存在,React 通常会使用 HTML 属性;
- 字符串、数字、布尔值等基础值可以作为属性传递;
- 对象、数组、函数等复杂值更适合通过 Property 传递。
例如:
<x-user-card
user={user}
display-mode="compact"
/>
假设 user-card 已经被定义,并且实例上存在 user Property:
class XUserCard extends HTMLElement {
user: User | null = null;
}
客户端上的效果可以近似理解为:
const element = document.querySelector("x-user-card") as XUserCard;
element.user = user;
element.setAttribute("display-mode", "compact");
这里的“存在 Property”是运行时判断,不是 TypeScript 判断。TypeScript 即使认识 user,浏览器运行时也必须真的能在元素实例或其原型链上找到它。
3. 为什么自定义元素应尽早注册
考虑以下顺序:
root.render(<x-user-card user={user} />);
customElements.define("x-user-card", XUserCard);
在 React 首次处理这个标签时,浏览器可能还只把它当作普通的未知 HTML 元素。此时 user Property 尚不存在,React 可能将其当作属性处理。
如果对象被序列化为字符串,组件升级后再读取:
this.getAttribute("user");
得到的可能是:
[object Object]
而不是原始对象。
更可靠的加载顺序是:
import "./x-user-card";
root.render(<App />);
让 customElements.define("x-user-card", XUserCard) 在 React 创建元素之前执行。
如果无法保证注册时机,可以在客户端通过 Ref 显式设置:
const ref = useRef<XUserCardElement>(null);
useEffect(() => {
if (ref.current) {
ref.current.user = user;
}
}, [user]);
这会牺牲一部分“完全由 JSX 描述”的便利,但能明确保证赋值发生在自定义元素升级之后。
4. 复杂对象不能依赖服务端属性序列化
React 服务端渲染只能生成 HTML。HTML 属性最终是文本,无法直接保存:
const config = {
theme: "dark",
permissions: ["read", "write"],
};
下面的写法不能期待在服务端 HTML 中保留同一个对象:
<x-panel config={config} />
React 19 的服务端自定义元素支持与客户端不同:服务端可以将可序列化的基础值输出为属性,但非基础对象通常不会作为 HTML 属性输出。即使服务端生成了某种文本,浏览器解析后也只是字符串,不会自动恢复为原对象。
因此:
<x-panel config={config} />
适合客户端边界内的 Property 传递,不适合作为服务端和客户端之间传输复杂对象的唯一机制。
如果需要 SSR,可考虑:
<x-panel config-json={JSON.stringify(config)} />
然后由 Web Component 解析:
const raw = this.getAttribute("config-json");
if (raw) {
const config = JSON.parse(raw);
}
但这会引入新的风险:
- JSON 需要满足可序列化约束;
- 数据体积会增加;
- 解析失败必须处理;
- 内容进入 HTML 时要考虑转义和注入风险;
- 对象更新会产生新的字符串序列化成本。
如果数据来自服务端,也可以先由 React 服务端渲染普通 HTML,再在客户端初始化 Web Component,并通过客户端状态设置对象 Property。
三、属性变化如何驱动 Web Component
Web Component 可以通过 observedAttributes 监听 HTML 属性:
class XToggle extends HTMLElement {
static observedAttributes = ["disabled"];
attributeChangedCallback(
name: string,
oldValue: string | null,
newValue: string | null,
) {
if (name === "disabled") {
this.render();
}
}
render() {
const disabled = this.hasAttribute("disabled");
this.textContent = disabled ? "Disabled" : "Enabled";
}
}
customElements.define("x-toggle", XToggle);
React 更新:
<x-toggle disabled={isDisabled} />
如果 React 通过属性更新,那么 Web Component 的 attributeChangedCallback 会被触发。
但是,如果组件定义的是 Property:
class XToggle extends HTMLElement {
private _disabled = false;
get disabled() {
return this._disabled;
}
set disabled(value: boolean) {
this._disabled = value;
this.render();
}
render() {
this.textContent = this._disabled ? "Disabled" : "Enabled";
}
}
那么组件不能只依赖 attributeChangedCallback。Property setter 和属性回调是两条不同的更新路径。
一个同时支持两种方式的实现可以明确规定同步方向:
class XToggle extends HTMLElement {
static observedAttributes = ["disabled"];
get disabled() {
return this.hasAttribute("disabled");
}
set disabled(value: boolean) {
if (value) {
this.setAttribute("disabled", "");
} else {
this.removeAttribute("disabled");
}
}
attributeChangedCallback() {
this.render();
}
render() {
this.textContent = this.disabled ? "Disabled" : "Enabled";
}
}
这里的逻辑是:
- React 设置
disabledProperty; - setter 将布尔值转换为属性存在或不存在;
- 浏览器触发
attributeChangedCallback; - 组件重新渲染。
需要避免反向递归。例如,attributeChangedCallback 中再次无条件设置 this.disabled,而 setter 又无条件修改属性,就可能造成循环更新。修改前应比较新旧状态,或者只在真正变化时写入。
布尔属性的特殊性
HTML 布尔属性看“是否存在”,而不是看字符串值:
<button disabled="false"></button>
这个按钮仍然是禁用的,因为 disabled 属性存在。
所以 Web Component 不应简单使用:
const disabled = this.getAttribute("disabled") === "true";
更符合 HTML 语义的是:
const disabled = this.hasAttribute("disabled");
在 React 中:
<x-toggle disabled={false} />
和:
<x-toggle disabled={true} />
的正确语义应分别是删除属性和保留属性。具体传递路径仍受 React 版本、元素注册时机和元素 Property 定义影响,因此自定义元素最好对布尔输入建立明确的 Property/Attribute 约定。
四、事件:React 事件系统和 DOM 自定义事件不是一回事
1. DOM 事件的基本结构
Web Component 通常使用 CustomEvent 通知外部:
this.dispatchEvent(
new CustomEvent("valuechange", {
detail: { value: this.value },
bubbles: true,
composed: true,
}),
);
这里有四个关键概念:
valuechange:事件名,大小写和连字符都是事件协议的一部分;detail:自定义数据;bubbles:是否向父节点冒泡;composed:是否允许穿过 Shadow DOM 边界。
CustomEvent 默认并不会自动冒泡,也不会自动穿过 Shadow DOM。因此下面这种写法可能让外部 React 组件收不到事件:
this.dispatchEvent(
new CustomEvent("valuechange", {
detail: { value: this.value },
}),
);
如果事件要从 Shadow DOM 内部传到 React 所在的宿主元素,通常需要:
{
bubbles: true,
composed: true,
}
事件是否可取消由 cancelable 决定。只有设置:
cancelable: true
后,监听器中的 event.preventDefault() 才可能影响 dispatchEvent 的返回结果。
2. detail 不是事件目标的属性
下面的事件处理函数:
function handleChange(event: Event) {
console.log(event.target);
}
只能可靠地获得事件目标,而自定义载荷通常在:
function handleChange(event: Event) {
const customEvent = event as CustomEvent<{ value: number }>;
console.log(customEvent.detail.value);
}
如果事件来自 Shadow DOM,event.target 还可能经过 retargeting,显示为 Shadow DOM 宿主元素,而不是内部真正触发事件的节点。需要查看传播路径时,可以使用:
event.composedPath();
3. React 19 的自定义事件处理
React 19 增强了对自定义元素事件的支持。对于符合 React 自定义元素事件约定的事件 prop,React 可以为自定义元素注册事件监听器。例如:
<x-counter onIncremented={handleIncremented} />
组件实际派发的事件通常是:
new CustomEvent("incremented", {
detail: { value: 1 },
bubbles: true,
composed: true,
});
事件名的大小写必须与 React 识别规则和 Web Component 的事件协议一致。不要把下面几种名称当作同一个事件:
valuechange
valueChange
value-change
DOM 自定义事件名是字符串匹配,浏览器不会替你进行驼峰、短横线或大小写转换。
在跨版本库、第三方 Web Component,或者事件名非常规时,直接使用 Ref 和 addEventListener 更明确:
function CounterHost() {
const elementRef = useRef<XCounterElement>(null);
useEffect(() => {
const element = elementRef.current;
if (!element) {
return;
}
const handleIncremented = (event: Event) => {
const { value } =
(event as CustomEvent<{ value: number }>).detail;
console.log("new value:", value);
};
element.addEventListener("incremented", handleIncremented);
return () => {
element.removeEventListener("incremented", handleIncremented);
};
}, []);
return <x-counter ref={elementRef} />;
}
这里的生命周期是:
- React 提交 DOM,Ref 指向
<x-counter>; useEffect注册监听器;- Web Component 派发
incremented; - React 组件读取
event.detail; - React 卸载或 effect 依赖变化时执行清理;
- 清理函数移除同一个函数对象对应的监听器。
不能写成:
useEffect(() => {
elementRef.current?.addEventListener("incremented", () => {
// ...
});
return () => {
elementRef.current?.removeEventListener("incremented", () => {
// ...
});
};
}, []);
这两个箭头函数不是同一个对象,removeEventListener 无法移除原监听器,最终可能导致重复响应和内存泄漏。
4. 何时使用 JSX 事件,何时使用 addEventListener
可以按事件协议的稳定性做选择:
- 自己控制 React 19 和 Web Component 两端,事件名遵循约定:可以使用 JSX 事件 prop;
- 需要精确匹配任意 DOM 事件名:使用
addEventListener; - 事件来自 Shadow DOM:确认
bubbles和composed; - 需要强类型
CustomEvent.detail:通过 Ref 注册监听器通常最清晰; - 组件可能被多个框架消费:优先设计标准 DOM 事件协议,而不是依赖 React 专有行为。
五、Ref:从 React 声明式树进入 Web Component 命令式接口
1. Ref 指向什么
对原生自定义元素:
const ref = useRef<XCounterElement>(null);
return <x-counter ref={ref} />;
ref.current 指向真实的 XCounterElement DOM 实例,而不是 React 组件实例。
这意味着可以调用它暴露的 Property 和方法:
ref.current?.increment();
ref.current?.value;
ref.current?.focus();
Ref 不会因为普通状态更新自动触发 React 重新渲染。它适合:
- 调用命令式方法;
- 读取当前 DOM 状态;
- 注册 DOM 事件;
- 将 React 状态同步到 Web Component Property;
- 处理焦点、选择区、媒体播放等命令式操作。
2. React 19 中 ref 可以作为函数组件的普通输入
React 19 支持函数组件直接接收 ref prop,不再要求为了转发 Ref 而必须使用 forwardRef:
import type { Ref } from "react";
type CounterProps = {
step?: number;
ref?: Ref<XCounterElement>;
};
function Counter(props: CounterProps) {
return (
<x-counter
ref={props.ref}
step={props.step}
/>
);
}
使用:
const counterRef = useRef<XCounterElement>(null);
<Counter ref={counterRef} step={1} />;
这里的 ref 仍然是 React 的特殊能力,不是普通 HTML 属性。它不会被序列化到服务端 HTML,也不能作为字符串传递给 Web Component。
forwardRef 在 React 19 中不再是这种场景的必需品,但旧代码、第三方库和兼容 React 18 的组件仍可能继续使用它。若组件需要同时支持 React 18 和 React 19,应根据支持范围保留兼容实现。
3. Ref 的时序
Ref 在 React 提交阶段指向已创建的 DOM 节点:
function Example() {
const ref = useRef<XCounterElement>(null);
useLayoutEffect(() => {
ref.current?.focus();
}, []);
return <x-counter ref={ref} />;
}
useLayoutEffect 在浏览器提交 DOM 后、浏览器绘制前执行,适合必须在首次绘制前完成的 DOM 同步。普通 useEffect 在客户端也能访问 Ref,但通常在绘制后执行。
服务端渲染时:
useRef可以创建引用容器;- 但没有浏览器 DOM;
ref.current不会指向可操作的元素;useEffect和useLayoutEffect不在服务端执行。
因此,依赖 Web Component 方法、window、document 或 customElements 的代码必须位于客户端路径。
六、一个完整的 React 19 与 Web Component 示例
下面实现一个可接收 Property、发出自定义事件、支持方法调用的计数器。
1. Web Component 实现
// x-counter.ts
export type CounterIncrementedDetail = {
value: number;
};
export class XCounterElement extends HTMLElement {
private _value = 0;
private _step = 1;
private button?: HTMLButtonElement;
get value(): number {
return this._value;
}
set value(next: number) {
if (!Number.isFinite(next)) {
throw new TypeError("value must be a finite number");
}
if (next === this._value) {
return;
}
this._value = next;
this.render();
}
get step(): number {
return this._step;
}
set step(next: number) {
if (!Number.isFinite(next) || next <= 0) {
throw new TypeError("step must be a positive finite number");
}
this._step = next;
}
connectedCallback() {
this.attachShadow({ mode: "open" });
const button = document.createElement("button");
button.type = "button";
button.addEventListener("click", () => {
this.increment();
});
this.shadowRoot!.append(button);
this.button = button;
this.render();
}
disconnectedCallback() {
this.button = undefined;
}
increment() {
this.value += this._step;
this.dispatchEvent(
new CustomEvent<CounterIncrementedDetail>("incremented", {
detail: { value: this.value },
bubbles: true,
composed: true,
}),
);
}
focus() {
this.button?.focus();
}
private render() {
if (this.button) {
this.button.textContent = `Count: ${this._value}`;
}
}
}
if (!customElements.get("x-counter")) {
customElements.define("x-counter", XCounterElement);
}
这个实现有三个对外契约:
element.value = 10;
element.step = 2;
element.increment();
以及一个事件契约:
element.addEventListener("incremented", listener);
detail.value 是事件携带的新值,而不是通过读取 event.target.value 获得的。
注意 connectedCallback 中使用了 attachShadow。如果元素可能被重复连接和断开,生产实现应避免在同一元素上重复调用 attachShadow,或者将 Shadow DOM 初始化逻辑设计为幂等。
2. TypeScript 类型声明
先扩展浏览器的标签映射:
// custom-elements.d.ts
import type { XCounterElement } from "./x-counter";
declare global {
interface HTMLElementTagNameMap {
"x-counter": XCounterElement;
}
}
export {};
这样可以让下面的 DOM API 获得正确类型:
const element = document.querySelector("x-counter");
element?.increment();
在 React JSX 中,还需要让 JSX 类型系统认识这个标签。使用 React 19 类型时,可以为 react 模块中的 JSX 命名空间增加声明:
// react-elements.d.ts
import type { XCounterElement } from "./x-counter";
declare module "react" {
namespace JSX {
interface IntrinsicElements {
"x-counter": React.DetailedHTMLProps<
React.HTMLAttributes<XCounterElement>,
XCounterElement
> & {
value?: number;
step?: number;
onIncremented?: (
event: CustomEvent<{ value: number }>,
) => void;
};
}
}
}
export {};
这段声明解决的是“能否在 JSX 中写这个标签”和“属性的静态类型是什么”,并不改变浏览器运行时行为。
不同 TypeScript、@types/react、jsxImportSource 配置可能使用不同的 JSX 命名空间扩展方式。如果声明没有生效,应首先检查:
custom-elements.d.ts是否包含在tsconfig.json的include中;- 项目使用的是 React 19 对应的 React 类型包;
- 是否启用了自定义 JSX 工厂;
- 是否存在其他
JSX.IntrinsicElements声明覆盖了当前声明。
3. React 侧消费
// CounterDemo.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import type { XCounterElement } from "./x-counter";
import "./x-counter";
export function CounterDemo() {
const counterRef = useRef<XCounterElement>(null);
const [value, setValue] = useState(0);
useEffect(() => {
const element = counterRef.current;
if (!element) {
return;
}
const handleIncremented = (
event: Event,
) => {
const customEvent =
event as CustomEvent<{ value: number }>;
setValue(customEvent.detail.value);
};
element.addEventListener("incremented", handleIncremented);
return () => {
element.removeEventListener("incremented", handleIncremented);
};
}, []);
useEffect(() => {
const element = counterRef.current;
if (!element) {
return;
}
try {
element.value = value;
} catch (error) {
console.error("Failed to update x-counter.value", error);
}
}, [value]);
return (
<section>
<x-counter ref={counterRef} step={1} />
<p>React state: {value}</p>
<button
type="button"
onClick={() => {
counterRef.current?.increment();
}}
>
Increment from React
</button>
<button
type="button"
onClick={() => {
counterRef.current?.focus();
}}
>
Focus Web Component
</button>
</section>
);
}
数据流分成两条:
Web Component 内部点击
│
▼
increment()
│
├── 更新 Web Component.value
└── 派发 incremented(detail.value)
│
▼
React setValue()
│
▼
React state 更新
反向同步则是:
React state.value
│
▼
useEffect
│
▼
Web Component.value = value
这里存在一个必须意识到的风险:如果 Web Component 的事件会由 Property setter 触发,而 React effect 又在收到事件后重新设置相同 Property,就可能形成循环。示例通过 value setter 中的相等性判断减少了这种风险:
if (next === this._value) {
return;
}
这不是 React 专属规则,而是跨框架双向同步系统的基本要求:每条同步路径都应定义“何时不再产生新变化”。
七、受控和非受控:不要同时让两套状态拥有最终决定权
1. 非受控模式
Web Component 自己保存状态,React 只监听事件:
function UncontrolledCounter() {
const ref = useRef<XCounterElement>(null);
useEffect(() => {
const element = ref.current;
if (!element) return;
const handle = (event: Event) => {
const detail =
(event as CustomEvent<{ value: number }>).detail;
console.log("current value:", detail.value);
};
element.addEventListener("incremented", handle);
return () => {
element.removeEventListener("incremented", handle);
};
}, []);
return <x-counter ref={ref} />;
}
这种模式的优点是边界简单;缺点是 React 不能直接通过状态重新定义当前值。
2. 受控模式
React 状态作为唯一来源:
function ControlledCounter() {
const ref = useRef<XCounterElement>(null);
const [value, setValue] = useState(0);
useEffect(() => {
if (ref.current) {
ref.current.value = value;
}
}, [value]);
useEffect(() => {
const element = ref.current;
if (!element) return;
const handle = (event: Event) => {
const next =
(event as CustomEvent<{ value: number }>).detail.value;
setValue(next);
};
element.addEventListener("incremented", handle);
return () => {
element.removeEventListener("incremented", handle);
};
}, []);
return <x-counter ref={ref} />;
}
受控模式要求 Web Component 的内部交互必须通过事件通知 React,否则 React 状态不会变化。反过来,React 也必须把状态变化同步回 Property,否则外部按钮、路由恢复或服务端数据更新无法影响 Web Component。
一个组件不应同时把以下两者都当作“最终状态”:
React state = 最终状态
Web Component.value = 另一个最终状态
如果两边都能独立修改,系统就需要冲突解决规则。没有规则时,最后一次 effect、事件或异步回调可能覆盖另一边的结果。
八、事件类型:Event、CustomEvent 和 React 事件类型
标准 DOM 事件通常可使用:
(event: Event) => {
// event.type
// event.target
}
自定义事件需要额外表达 detail:
type ValueChangeEvent = CustomEvent<{
value: number;
source: "user" | "program";
}>;
注册监听器时:
const handleValueChange = (event: Event) => {
const typedEvent = event as ValueChangeEvent;
const value = typedEvent.detail.value;
const source = typedEvent.detail.source;
};
更严格的做法是封装一个类型安全的注册函数:
function addValueChangeListener(
element: XCounterElement,
listener: (event: CustomEvent<{ value: number }>) => void,
) {
const wrapped = (event: Event) => {
listener(event as CustomEvent<{ value: number }>);
};
element.addEventListener("incremented", wrapped);
return () => {
element.removeEventListener("incremented", wrapped);
};
}
React 的 onClick 等内置事件类型来自 React 类型系统;Web Component 的 CustomEvent.detail 则来自 DOM 事件协议。二者不能因为都写成“事件处理函数”就认为类型完全相同。
特别是下面这种类型通常不够准确:
const handle = (event: React.ChangeEvent<HTMLInputElement>) => {
// 这只适合 React 管理的 input change 事件
};
如果事件实际来自:
new CustomEvent("valuechange", {
detail: { value: 1 },
});
就应该按 CustomEvent 建模,而不是套用 React.ChangeEvent<HTMLInputElement>。
九、Shadow DOM 对 React 互操作的影响
Shadow DOM 会创建一个封装的 DOM 子树:
const shadowRoot = this.attachShadow({ mode: "open" });
React 应用通常只能直接拿到自定义元素宿主:
<x-date-picker ref={ref} />
而不能把 React 的 JSX 子节点自动渲染进该元素的 Shadow DOM。下面的内容:
<x-date-picker>
<span>Choose date</span>
</x-date-picker>
通常会成为自定义元素的 light DOM 子节点,除非 Web Component 使用 <slot> 接收它:
<slot></slot>
Shadow DOM 内部事件到达外部需要同时满足:
new CustomEvent("selected", {
bubbles: true,
composed: true,
});
其中:
bubbles: true允许事件沿 DOM 父子关系传播;composed: true允许事件跨越 Shadow DOM 边界;- 缺少任意一个,都可能导致 React 宿主监听不到事件。
事件即使成功传出,event.target 也可能是宿主元素,而不是内部按钮。这是 Shadow DOM 的封装语义,不是 React 丢失数据。
十、服务端渲染和客户端边界
1. 服务端可以输出标签,但不能执行浏览器组件逻辑
服务端可以生成:
<x-counter step="1"></x-counter>
但 Node.js 服务端不能直接执行:
customElements.define("x-counter", XCounter);
document.createElement("x-counter");
this.attachShadow(...);
因为这些 API 属于浏览器环境。
在支持服务端组件的框架中,通常需要把使用 Web Component 的部分放在客户端组件中:
"use client";
import "./x-counter";
export function CounterClient() {
return <x-counter />;
}
"use client" 是特定框架的客户端边界声明,不是 React 核心 API。它表示该模块及其依赖需要在浏览器端执行,具体行为取决于所使用的框架。
2. 服务端传给客户端的 props 需要可传输
服务端组件和客户端组件之间通常只能传递可序列化数据。下面的内容存在边界问题:
<CounterClient
onIncrement={() => {
// 函数不能作为普通服务端数据传递
}}
ref={someRef}
/>
函数、DOM 节点、Ref 和复杂运行时对象不能作为普通服务端到客户端 props 传输。应在客户端组件内部创建回调和 Ref:
// Server component
<CounterClient initialValue={3} />
// Client component
"use client";
export function CounterClient({
initialValue,
}: {
initialValue: number;
}) {
const ref = useRef<XCounterElement>(null);
useEffect(() => {
if (ref.current) {
ref.current.value = initialValue;
}
}, [initialValue]);
return <x-counter ref={ref} />;
}
3. Hydration 不是 Property 恢复机制
服务端输出的 HTML 只能保存属性文本:
<x-user-card user-id="u1"></x-user-card>
客户端 hydration 后,React 才有机会把对象设置为 Property:
element.user = {
id: "u1",
name: "Ada",
};
因此需要区分:
SSR HTML 的初始外观
和:
客户端 Web Component 的运行时对象状态
如果服务端输出与客户端首次计算出的属性不一致,可能出现 hydration 警告。suppressHydrationWarning 只能抑制提示,不能把字符串恢复为对象,也不能解决组件初始化时序问题。
十一、包装器:把不稳定的互操作细节集中起来
当 Web Component 被多个 React 页面使用时,可以编写一个 React 包装器,将事件注册、Property 同步和错误处理集中到一个边界中。
import {
useEffect,
useRef,
type Ref,
} from "react";
import type { XCounterElement } from "./x-counter";
type CounterProps = {
value?: number;
step?: number;
onValueChange?: (value: number) => void;
ref?: Ref<XCounterElement>;
};
export function Counter({
value,
step = 1,
onValueChange,
ref,
}: CounterProps) {
const internalRef = useRef<XCounterElement>(null);
useEffect(() => {
const element = internalRef.current;
if (!element) return;
const handle = (event: Event) => {
const customEvent =
event as CustomEvent<{ value: number }>;
onValueChange?.(customEvent.detail.value);
};
element.addEventListener("incremented", handle);
return () => {
element.removeEventListener("incremented", handle);
};
}, [onValueChange]);
useEffect(() => {
const element = internalRef.current;
if (!element) return;
try {
element.step = step;
if (value !== undefined) {
element.value = value;
}
} catch (error) {
console.error("Failed to synchronize counter props", error);
}
}, [step, value]);
return <x-counter ref={ref ?? internalRef} />;
}
这个包装器承担了四件事:
- 将 React 的
value和step同步到 Web Component Property; - 将
CustomEvent.detail.value转换为 React 风格的回调参数; - 在 effect 清理阶段移除 DOM 监听器;
- 把 Web Component 的异常边界集中处理。
这里的 onValueChange 放在 effect 依赖中,因此父组件每次创建新的内联函数时,监听器会重新注册。功能上是正确的,但可能产生不必要的注销和注册。若事件监听器必须长期稳定,可以用稳定回调或引用保存最新回调,但应先确认项目是否真的需要优化,而不是为了避免一次注册就增加复杂性。
十二、常见失败表现和诊断方法
1. 对象到达 Web Component 后变成字符串
失败写法:
<x-user-card user={user} />
表现:
element.user; // undefined 或不是预期对象
element.getAttribute("user"); // "[object Object]" 或其他字符串
诊断步骤:
const element = document.querySelector("x-user-card");
console.log("defined:", customElements.get("x-user-card"));
console.log("property:", (element as any)?.user);
console.log("attribute:", element?.getAttribute("user"));
如果 customElements.get 返回 undefined,说明元素尚未注册。应检查导入顺序和客户端边界。
2. 事件处理函数不执行
按以下顺序检查:
console.log(eventName);
console.log(event.bubbles);
console.log(event.composed);
console.log(event.composedPath());
同时直接在 DOM 上监听:
const element = document.querySelector("x-counter");
element?.addEventListener("incremented", (event) => {
console.log("DOM received", event);
});
如果原生监听器也没有触发,问题在 Web Component 的 dispatchEvent。如果原生监听器能触发而 JSX 处理器不能触发,问题通常在事件命名、React 版本或 JSX 事件支持方式,应改用 Ref 注册监听器验证。
3. 事件重复执行
常见原因是 effect 没有清理,或移除监听器时使用了不同的函数对象。
错误:
useEffect(() => {
ref.current?.addEventListener("incremented", handle);
}, []);
正确:
useEffect(() => {
const element = ref.current;
if (!element) return;
element.addEventListener("incremented", handle);
return () => {
element.removeEventListener("incremented", handle);
};
}, []);
在开发环境的 Strict Mode 下,React 可能进行额外的 effect 初始化和清理检查。正确的清理逻辑应使重复挂载不会累计监听器。
4. Ref 一直是 null
需要区分三个时间点:
- 渲染函数执行时,Ref 通常还没有指向节点;
- React 提交 DOM 后,Ref 才被设置;
- 服务端执行时,不存在真实 DOM。
因此不要在渲染阶段调用:
function Example() {
const ref = useRef<XCounterElement>(null);
ref.current?.increment(); // 不应在渲染期间执行命令式操作
return <x-counter ref={ref} />;
}
应放在事件处理器、useEffect 或 useLayoutEffect 中。
5. TypeScript 报“找不到 JSX 元素”
这通常不是浏览器不支持,而是 JSX 类型声明没有被 TypeScript 加载。检查声明文件是否进入编译范围:
{
"include": [
"src",
"src/**/*.d.ts"
]
}
还要确认标签名包含连字符:
<x-counter></x-counter>
自定义元素名称必须符合 Custom Elements 的命名要求,通常使用带连字符的名称,以避免和未来或现有标准 HTML 元素冲突。
十三、如何划分 React 和 Web Component 的职责
一个可维护的边界通常包含以下约定:
React
├── 保存页面级状态
├── 决定何时显示组件
├── 传入可序列化配置
└── 处理业务层事件
Web Component
├── 管理内部 DOM 和 Shadow DOM
├── 暴露 Property
├── 暴露命令式方法
└── 通过 CustomEvent 通知外部
对于简单的展示属性,可以优先使用 HTML 属性:
<x-dialog size="large" modal />
对于对象、数组和函数,应使用 Property 或明确的客户端初始化:
dialog.options = options;
dialog.open();
对于状态变化,应使用事件:
dialog.addEventListener("closed", handleClosed);
对于需要外部命令控制的能力,应使用 Ref:
dialogRef.current?.open();
dialogRef.current?.close();
这个边界的核心不是“所有内容都必须写成属性”,而是为每种数据选择与其语义匹配的通道:
| 数据或操作 | 合适的通道 |
|---|---|
| 字符串、简单标志 | Attribute |
| 对象、数组、函数 | Property |
| 内部状态变化通知 | CustomEvent |
| 打开、关闭、聚焦等命令 | Ref 暴露的方法 |
| SSR 需要保留的初始数据 | 可序列化属性或客户端初始化 |
最终,React 与 Web Components 的互操作可以归纳为一条因果链:
HTML 属性保存文本
JavaScript Property 保存运行时值
CustomEvent 传递变化
Ref 进入命令式接口
TypeScript 声明静态契约
客户端边界决定这些浏览器能力何时可用
只要分别处理这六个层次,React JSX、DOM 生命周期、Shadow DOM 事件和服务端渲染之间的差异就不会被混淆。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React 动画:CSS、Transition、Motion、布局动画和减少动态
- 下一篇:React Router 数据路由:Loader、Action、Revalidation 和错误边界
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论