前端工程手记React 渲染与集成客服挂件落地

RENDER WHERE YOU ARE WELCOME.

把 React 组件嵌入非 React 页面从 createRoot 渲染原理,到 HtmlFragment 客服挂件

React 页面是一整栋「房子」,
但现在只想把其中一个「房间」——客服面板,
搬进官网、商城等非 React 架构的房子里。

SPARROW 前端实践2026.09.27含 React 官方依据与完整代码
React 组件 → HTML 片段 → 非 React 页面 Talk / 客服组件 WebSocket · 会话 · 登录态 HtmlFragment 组件 服务端 return null · 客户端 createRoot 静态导出 index.html 脚本 + 完整 <html>…</html> 外壳 #content-container(非 React 页面)
01 /

结论先行

把一个 React 组件渲染出的 HTML 片段,嵌入非 React 架构的页面,靠的是两个能力:React 18 的 createRoot 把「渲染根」挂到任意 DOM 节点,useEffect 把「渲染时机」从服务端推迟到客户端。

具体做法:组件在服务端返回 null(不输出任何内容),到了浏览器里再通过 createRoot(container).render(<App/>) 把真实 UI 手动挂进宿主页面预留的容器。这样「React 渲染的片段」就接进了「非 React 的页面」,而宿主不需要知道 React 的存在。

01 · MOUNT POINT

挂载点解耦

createRoot(domNode) 不要求目标节点由 React 管理,任何 getElementById 拿到的容器都能成为 React 树的根。

02 · RENDER TIMING

时机推迟

useEffect 只在客户端执行,SSR/SSG 阶段组件返回 null,真正渲染被推迟到浏览器挂载之后。

03 · DOC SHELL

外壳剥离

静态导出(output:"export")会生成完整 <html>…</body></html>,宿主侧 strip 掉即可,</body></html> 就是这么来的。

一句话总结

服务端什么都不渲染,客户端才动手:用 createRoot 把组件「种」进宿主页面的容器里。所谓「HTML 片段嵌入」,本质是把 React 的客户端渲染结果,注入到非 React 的 DOM 里,而不是在服务端拼一段静态 HTML 字符串。

依据:React · createRoot / React · useEffect
02 /

问题背景

客服 / IM 是一类典型的重交互组件:要维护 WebSocket 长连接、收发消息、管理会话列表、和登录态打通,还要做多语言。这类需求天然适合用 React 组织——状态、生命周期、组件树都现成。

但现实是,官网、商城、门户可能是 jQuery 拼的、模板引擎渲染的,或者干脆是后端直出的 HTML。它们不是 React 架构,也不会为了一个客服功能整体迁到 React。于是问题变成了:如何不动宿主,把一个 React 组件「贴」进任意一个非 React 页面?

拆开看,这里其实有三个子问题:

  1. 位置:React 渲染的结果放到哪?——需要宿主页面先留出一个容器节点。
  2. 时机:什么时候渲染?——不能在服务端直出,得等宿主页面加载后、在浏览器里执行。
  3. 产物:交付给宿主的是什么?——是一段能自己「长」出 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> 这层固定包裹。
03 /

详细内容

STEP 01React 渲染的本质:从 render 到 createRoot

React 的渲染,本质是把一棵「虚拟 DOM 树」通过协调(reconciliation)映射到真实的 DOM。它先算出一棵描述界面的元素树,再 diff 出与上一次的差异,最后把差异提交给浏览器。这个「提交」需要一个目标:把树「种」到哪个 DOM 节点里。

React 18 之前用 ReactDOM.render(<App/>, el);React 18 起改用 createRoot。变化的关键点:根节点不再要求是 React 专属的挂载点,任何真实 DOM 元素都可以。

React 18 · createRoot 基础用法
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,在客户端把它挂进去。

common/src/common/components/i18n/HtmlFragement.tsx
"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 里被这样使用:

app/(fragment)/[locale]/talk/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>,这层包裹是硬编码的,没有「只输出片段」的开关。

next.config.mjs · 静态导出
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 的宿主页面,事情反而最简单:先留一个容器,再引入一段安装脚本。

官网 / 商城等非 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 拉过来、剥掉外壳、注入容器:

imInstaller.js · 注入核心
$.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)为例:

  1. 写客服组件:用 React 组织 Talk(会话列表、消息、WebSocket、登录态),这是要复用的「房间」。
  2. 写挂载组件:用 HtmlFragment 把 Talk 包起来,声明目标容器 content-container,服务端返回 null。
  3. 配静态导出:output: "export" 生成 out/xx/talk/index.html(含脚本 + 完整外壳)。
  4. 写安装脚本:imInstaller.js 负责拉取、剥离外壳、注入容器、克隆样式。
  5. 宿主接入:非 React 页面只加容器和一句 <script>,其余交给脚本。
  6. 浏览器运行:脚本执行 → React 启动 → useEffect → createRoot → 客服面板出现。
01 / REUSE

组件零改动复用

客服组件本身不感知宿主是谁,React 组件树、状态、多语言全部原样复用。

02 / HOST-NEUTRAL

宿主保持中立

官网 / 商城无需引入 React,只需一个容器和一句脚本,架构不被绑架。

03 / AUTH

登录态自然打通

因为仍在浏览器同一域 / 跨域存储体系内,token 与访客身份照常共享。

04 / INDEPENDENT

独立演进

客服功能可以独立构建、独立发布,宿主页面不受影响。

DESIGN DECISION

服务端不渲染,客户端才接管。

用 createRoot 解耦挂载点,用 useEffect 推迟渲染时机,组件在服务端返回 null、在客户端把 React 片段种进宿主页面的容器。这就是把一个 React 组件嵌入非 React 架构页面的全部秘密。

静态导出带来的 </body></html> 只是文档外壳,剥掉即可;真正的 UI 由浏览器里的 React 运行时渲染,与宿主是否是 React 无关。

04 /

官方依据与阅读

下列文档支撑「createRoot 挂载」与「useEffect 客户端执行」的说明;「HTML 片段嵌入非 React 页面」的整体做法,是结合本项目(客服挂件)背景给出的工程实践。文档核对日期:2026-09-27。

  1. [01]
    React · createRoot ↗

    createRoot 的挂载语义,以及它与旧 ReactDOM.render 的差异。

  2. [02]
    React · useEffect ↗

    useEffect 的执行时机:组件提交到 DOM 之后,且只在客户端运行。

  3. [03]
    React · hydrateRoot ↗

    水合与全新挂载的区别,理解本文为何选择 createRoot 而非 hydrateRoot。

  4. [04]
    Next.js · Static Exports ↗

    output:"export" 的静态导出行为,以及每个路由生成的完整 HTML 文档。