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 组件,disabledonClick 是传给组件函数的 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";
  }
}

这里的逻辑是:

  1. React 设置 disabled Property;
  2. setter 将布尔值转换为属性存在或不存在;
  3. 浏览器触发 attributeChangedCallback
  4. 组件重新渲染。

需要避免反向递归。例如,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} />;
}

这里的生命周期是:

  1. React 提交 DOM,Ref 指向 <x-counter>
  2. useEffect 注册监听器;
  3. Web Component 派发 incremented
  4. React 组件读取 event.detail
  5. React 卸载或 effect 依赖变化时执行清理;
  6. 清理函数移除同一个函数对象对应的监听器。

不能写成:

useEffect(() => {
  elementRef.current?.addEventListener("incremented", () => {
    // ...
  });

  return () => {
    elementRef.current?.removeEventListener("incremented", () => {
      // ...
    });
  };
}, []);

这两个箭头函数不是同一个对象,removeEventListener 无法移除原监听器,最终可能导致重复响应和内存泄漏。

4. 何时使用 JSX 事件,何时使用 addEventListener

可以按事件协议的稳定性做选择:

  • 自己控制 React 19 和 Web Component 两端,事件名遵循约定:可以使用 JSX 事件 prop;
  • 需要精确匹配任意 DOM 事件名:使用 addEventListener
  • 事件来自 Shadow DOM:确认 bubblescomposed
  • 需要强类型 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 不会指向可操作的元素;
  • useEffectuseLayoutEffect 不在服务端执行。

因此,依赖 Web Component 方法、windowdocumentcustomElements 的代码必须位于客户端路径。


六、一个完整的 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/reactjsxImportSource 配置可能使用不同的 JSX 命名空间扩展方式。如果声明没有生效,应首先检查:

  • custom-elements.d.ts 是否包含在 tsconfig.jsoninclude 中;
  • 项目使用的是 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、事件或异步回调可能覆盖另一边的结果。


八、事件类型:EventCustomEvent 和 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} />;
}

这个包装器承担了四件事:

  1. 将 React 的 valuestep 同步到 Web Component Property;
  2. CustomEvent.detail.value 转换为 React 风格的回调参数;
  3. 在 effect 清理阶段移除 DOM 监听器;
  4. 把 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} />;
}

应放在事件处理器、useEffectuseLayoutEffect 中。

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 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。