结论先行
把一个 React 组件渲染出的 HTML 片段,嵌入非 React 架构的页面,靠的是两个能力:React 18 的 createRoot 把「渲染根」挂到任意 DOM 节点,useEffect 把「渲染时机」从服务端推迟到客户端。
具体做法:组件在服务端返回 null(不输出任何内容),到了浏览器里再通过 createRoot(container).render(<App/>) 把真实 UI 手动挂进宿主页面预留的容器。这样「React 渲染的片段」就接进了「非 React 的页面」,而宿主不需要知道 React 的存在。
挂载点解耦
createRoot(domNode) 不要求目标节点由 React 管理,任何 getElementById 拿到的容器都能成为 React 树的根。
时机推迟
useEffect 只在客户端执行,SSR/SSG 阶段组件返回 null,真正渲染被推迟到浏览器挂载之后。
外壳剥离
静态导出(output:"export")会生成完整 <html>…</body></html>,宿主侧 strip 掉即可,</body></html> 就是这么来的。
服务端什么都不渲染,客户端才动手:用 createRoot 把组件「种」进宿主页面的容器里。所谓「HTML 片段嵌入」,本质是把 React 的客户端渲染结果,注入到非 React 的 DOM 里,而不是在服务端拼一段静态 HTML 字符串。
问题背景
客服 / IM 是一类典型的重交互组件:要维护 WebSocket 长连接、收发消息、管理会话列表、和登录态打通,还要做多语言。这类需求天然适合用 React 组织——状态、生命周期、组件树都现成。
但现实是,官网、商城、门户可能是 jQuery 拼的、模板引擎渲染的,或者干脆是后端直出的 HTML。它们不是 React 架构,也不会为了一个客服功能整体迁到 React。于是问题变成了:如何不动宿主,把一个 React 组件「贴」进任意一个非 React 页面?
拆开看,这里其实有三个子问题:
- 位置:React 渲染的结果放到哪?——需要宿主页面先留出一个容器节点。
- 时机:什么时候渲染?——不能在服务端直出,得等宿主页面加载后、在浏览器里执行。
- 产物:交付给宿主的是什么?——是一段能自己「长」出 UI 的脚本和样式,而不是写死的 HTML。
对小白友好的一句话:React 页面是一整栋「房子」(html/head/body齐全),现在只想把其中一个「房间」——客服面板——搬进别人家的房子里。你要做的不是重盖一栋房,而是给房间接上水电(脚本 + 样式),让它能在新房子里的某个位置点亮。
- createRoot · 渲染根
- React 18 提供的挂载入口,把一个 DOM 节点交给 React 管理,之后所有渲染都发生在它内部。
- useEffect · 副作用钩子
- 组件提交到 DOM 后执行的回调,只在客户端运行,服务端渲染时不会触发。
- 静态导出 · output:export
- Next.js 把每个路由预渲染成完整 HTML 文档,不依赖 Node 服务。
- 文档外壳 · Document Shell
<html><head>…<body>…</body></html>这层固定包裹。
详细内容
STEP 01React 渲染的本质:从 render 到 createRoot
React 的渲染,本质是把一棵「虚拟 DOM 树」通过协调(reconciliation)映射到真实的 DOM。它先算出一棵描述界面的元素树,再 diff 出与上一次的差异,最后把差异提交给浏览器。这个「提交」需要一个目标:把树「种」到哪个 DOM 节点里。
React 18 之前用 ReactDOM.render(<App/>, el);React 18 起改用 createRoot。变化的关键点:根节点不再要求是 React 专属的挂载点,任何真实 DOM 元素都可以。
import { createRoot } from "react-dom/client";
const container = document.getElementById("app"); // 任意 DOM 节点
const root = createRoot(container); // 把它交给 React 管理
root.render(<App />); // 在它内部渲染 React 树这个能力是整篇文章的支点:既然 createRoot 不挑节点,那宿主的 <div id="content-container"> 和一个 React 应用自带的根 <div id="root"> 就没有本质区别——React 片段可以「种」进任何人的页面。
STEP 02服务端与客户端的分界:useEffect
光有挂载点还不够。如果组件在服务端就被渲染成 HTML,那它输出的是一段写死的字符串,既没有交互,也无法在宿主页面里动态挂载。要「推迟到客户端再渲染」,靠的是 useEffect 的一个特性:它只在浏览器里、组件提交之后执行,服务端渲染时根本不会运行。
于是有了这样的分工:组件在服务端返回 null(什么都不输出),在客户端用 useEffect 里执行 createRoot(...).render(...)。渲染时机被整体推迟,渲染位置则由代码里的 getElementById 决定。
| 阶段 | 发生在哪 | 组件返回 null 的结果 | useEffect 是否执行 |
|---|---|---|---|
| SSR / 静态导出 | 服务端 / 构建时 | 生成的 HTML 里没有这个组件的任何 UI | 否 |
| 浏览器挂载后 | 客户端 | 执行 createRoot().render(),UI 出现 | 是 |
服务端既然返回了 null,宿主页面里就没有可供水合(hydrate)的静态标记。所以这里用的是 createRoot 而非 hydrateRoot——它是一次全新的客户端渲染,而不是把现有 HTML 接管过来。这也是为什么它不要求宿主页面本身是 React 渲染的。
STEP 03HtmlFragment 组件拆解
把上面两步封装成一个可复用组件,就是项目里的 HtmlFragment。它只做一件事:接收一个要渲染的组件和一个容器 ID,在客户端把它挂进去。
"use client";
import { NextIntlClientProvider } from "next-intl";
import React, { useEffect } from "react";
import { createRoot } from "react-dom/client";
type Props = {
locale: string;
messages: React.ComponentProps<typeof NextIntlClientProvider>["messages"];
DynamicComponent: React.ComponentType<Record<string, unknown>>;
containerId: string;
};
export default function HtmlFragment(props: Props) {
const { locale, messages, containerId, DynamicComponent } = props;
useEffect(() => {
// 手动挂载到指定容器(只在客户端执行)
const container = document.getElementById(containerId);
if (container) {
const root = createRoot(container);
root.render(
<NextIntlClientProvider locale={locale} messages={messages}>
<DynamicComponent />
</NextIntlClientProvider>
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return null; // 服务端什么都不输出
}逐行读下来,每一处都有讲究:
return null:服务端阶段不渲染任何 UI,避免生成多余的<div>,也保证静态 HTML 里没有写死的内容。useEffect(..., []):空依赖,只在挂载后执行一次;同时因为 effect 只在客户端运行,服务端构建不会执行。getElementById(containerId):拿到宿主页面预留的容器,实现「渲染位置」与 React 的完全解耦。createRoot(container).render(...):把组件树(包着NextIntlClientProvider的多语言上下文)种进那个容器。
它在 Next.js 的 layout.tsx 里被这样使用:
const messages = await getMessages({ locale });
return (
<HtmlFragment
containerId={"content-container"}
locale={locale}
messages={messages}
DynamicComponent={Talk} // 客服组件
/>
);这里 Talk 就是客服组件本体(含 WebSocket、会话列表、登录态)。它没有在服务端被渲染成 HTML,而是作为「要种进容器的东西」被传下去,等浏览器加载后再长出来。
STEP 04静态导出为什么会有 </body></html>
当你用 output: "export" 做静态导出时,Next.js 会为每个路由生成一个完整的 HTML 文档。App Router 强制套上内置的文档外壳——<html><head>…<body>…</body></html>,这层包裹是硬编码的,没有「只输出片段」的开关。
const nextConfig = {
images: { unoptimized: true },
output: "export", // 静态导出:每个路由生成完整 HTML 文档
trailingSlash: true,
};
export default withNextIntl(nextConfig);所以即使 HtmlFragment 在服务端返回 null(body 是空的),out/zh/talk/index.html 里依然会有:
<html>/<head>/<body>这层外壳;- 一堆
<script src="/_next/static/...">(运行时 chunk); - RSC flight 数据(那一大段
self.__next_f.push([...])); - 最后的
</body></html>。
所以 </body></html> 不是「多出来的 bug」,而是静态导出的标准外壳。它提示了一个更重要的事实:这个「片段」的真正内容不在静态 HTML 里,而在那一堆脚本里——真正渲染出客服面板的是浏览器,不是构建时。
对一个纯客户端渲染的挂件来说,静态 HTML 里本来就没有可见内容。与其纠结怎么去掉外壳,不如换思路:要么在宿主侧剥离外壳(本文做法),要么干脆不要经过 Next 页面路由,把它打成一个独立的 JS/CSS bundle(见 STEP 06 对比)。
STEP 05宿主侧注入:非 React 页面只需两行
到了非 React 的宿主页面,事情反而最简单:先留一个容器,再引入一段安装脚本。
<!-- 1. 预留容器:客服面板会被挂到这里 -->
<div id="content-container"></div>
<!-- 2. 引入安装脚本,指定要加载的片段与定位 -->
<script id="talk-script"
src="/imInstaller.js?html=/en/talk&r=20rem&b=20rem"></script>安装脚本 imInstaller.js 负责把导出的 index.html 拉过来、剥掉外壳、注入容器:
$.ajax({ url, method: "GET", cache: false, dataType: "html" })
.done(function (html) {
html = html.replace("</body>", ""); // 剥掉文档外壳
html = html.replace("</html>", "");
container.append(html); // 注入脚本 + 标记
reloadAssets(container); // 把 <link> 样式克隆进 <head>
});注入之后,浏览器加载并执行那些 <script>,React 运行时启动,HtmlFragment 的 useEffect 被触发,createRoot 把客服面板渲染进 #content-container。整条链路就此闭合。
STEP 06三种嵌入方案对比
「把 React 片段嵌入非 React 页面」不止本文这一种做法。根据产物形态和是否愿意起服务,有三条路可走:
| 方案 | 产物 | 宿主接入成本 | 适用 / 代价 |
|---|---|---|---|
| A · 页面导出 + 剥离外壳 (本文做法) | Next 静态导出的 index.html | 两行:容器 + 安装脚本 | 复用 Next 的打包与路由,代价是带着整套 Next 运行时与 flight 数据注入宿主 |
| B · 独立 bundle widget | 单独打出的 im.js + im.css | 两行:容器 + <script> | 最干净,没有 <html> 外壳和 flight 数据;代价是要另起一个打包入口 |
| C · Route Handler / 服务端渲染片段 | 接口返回的 text/html 片段 | 需请求一个运行中的服务 | 适合真的需要服务端渲染出静态内容;与 output:"export" 冲突,要起 Node 服务 |
三者的本质差别在「真正的 UI 是在哪里渲染出来的」:A 和 B 都是客户端渲染,只是产物包装不同;C 是服务端渲染。对客服这种带长连接、强交互的组件,客户端渲染(A 或 B)是自然的选择。
STEP 07客服场景端到端走查
把整件事串成一条完整的落地链路,以客服(IM)为例:
- 写客服组件:用 React 组织
Talk(会话列表、消息、WebSocket、登录态),这是要复用的「房间」。 - 写挂载组件:用
HtmlFragment把Talk包起来,声明目标容器content-container,服务端返回null。 - 配静态导出:
output: "export"生成out/xx/talk/index.html(含脚本 + 完整外壳)。 - 写安装脚本:
imInstaller.js负责拉取、剥离外壳、注入容器、克隆样式。 - 宿主接入:非 React 页面只加容器和一句
<script>,其余交给脚本。 - 浏览器运行:脚本执行 → React 启动 →
useEffect→createRoot→ 客服面板出现。
组件零改动复用
客服组件本身不感知宿主是谁,React 组件树、状态、多语言全部原样复用。
宿主保持中立
官网 / 商城无需引入 React,只需一个容器和一句脚本,架构不被绑架。
登录态自然打通
因为仍在浏览器同一域 / 跨域存储体系内,token 与访客身份照常共享。
独立演进
客服功能可以独立构建、独立发布,宿主页面不受影响。
DESIGN DECISION
服务端不渲染,客户端才接管。
用 createRoot 解耦挂载点,用 useEffect 推迟渲染时机,组件在服务端返回 null、在客户端把 React 片段种进宿主页面的容器。这就是把一个 React 组件嵌入非 React 架构页面的全部秘密。
静态导出带来的 </body></html> 只是文档外壳,剥掉即可;真正的 UI 由浏览器里的 React 运行时渲染,与宿主是否是 React 无关。
官方依据与阅读
下列文档支撑「createRoot 挂载」与「useEffect 客户端执行」的说明;「HTML 片段嵌入非 React 页面」的整体做法,是结合本项目(客服挂件)背景给出的工程实践。文档核对日期:2026-09-27。
- [01]React · createRoot ↗
createRoot 的挂载语义,以及它与旧 ReactDOM.render 的差异。
- [02]React · useEffect ↗
useEffect 的执行时机:组件提交到 DOM 之后,且只在客户端运行。
- [03]React · hydrateRoot ↗
水合与全新挂载的区别,理解本文为何选择 createRoot 而非 hydrateRoot。
- [04]Next.js · Static Exports ↗
output:"export" 的静态导出行为,以及每个路由生成的完整 HTML 文档。