This post is not available in English yet. Showing the Chinese version.

← Writing
tutorial·2026-07-04·4 min read

Expressive Code × React 19:脚本注入的三层 workaround

从 rehype 树剥离 script,到 Server Component 重注入,再到 Client 端 hydration 骗术

On this page

给博客代码块加行号、复制按钮和行高亮,Expressive Code 是目前 Astro / Next.js 生态里最成熟的选择。但升级到 React 19 之后,控制台突然冒出一堆 hydration 警告——原因是 Expressive Code 往 MDX 输出里塞了内联 <script>,而 React 19 对「组件渲染期间出现 <script> 标签」采取了更严格的策略。

这篇文章记录我最终落地的三层解法,以及插件顺序这个容易踩的坑。

背景:Expressive Code 为什么要注入脚本

Expressive Code 的复制按钮、行高亮切换等交互功能需要 JavaScript 驱动。它的 rehype 插件在编译时会往 HAST 树里插入一到多个 <script> 节点,内容是功能模块的 JS 代码。在纯 HTML 或 Astro 里这完全没问题——浏览器直接执行就行。

但在 React 19 + Next.js App Router 里,这些 <script> 标签变成了组件树的一部分。React 19 认为组件渲染期间不应该出现 <script>,会触发警告甚至 hydration mismatch。

问题复现

升级 React 19 后,打开任何包含代码块的文章页面,控制台会出现:

Warning: Cannot render a <script> inside a component. React will not hydrate this element.

同时 Expressive Code 的复制按钮和行高亮功能直接失效——脚本虽然在 SSR HTML 里存在,但 React hydration 时不会执行它。

方案概览

解法分三层,各司其职:

层级文件职责
Layer 1mdx-rehype-strip-ec-scripts.mjs编译时从 HAST 树剥离所有 <script>
Layer 2ExpressiveCodeScripts.tsxServer Component 调 EC API 提取 JS 模块
Layer 3ExpressiveCodeScriptInjector.tsxClient Component 用 hydration 骗术安全注入

Layer 1:rehype 插件——把 script 全部删掉

第一步最直接:写一个 rehype 插件,遍历 HAST 树,把 Expressive Code 插入的所有 <script> 节点移除。

mdx-rehype-strip-ec-scripts.mjs
import { visit } from 'unist-util-visit'
export default function rehypeStripEcScripts() {
return (tree) => {
visit(tree, (node, index, parent) => {
if (index == null || !parent?.children) return
const isScript =
(node.type === 'element' && node.tagName === 'script') ||
(node.type === 'mdxJsxFlowElement' && node.name === 'script')
if (isScript) {
parent.children.splice(index, 1)
return index
}
})
}
}

这里有一个细节:MDX 编译器可能把 <script> 解析为两种不同的 HAST 节点类型——标准的 element 或 MDX 特有的 mdxJsxFlowElement。两种都要匹配,否则会漏掉。

parent.children.splice(index, 1) 之后 return index,是 unist-util-visit 的惯用写法——告诉遍历器「当前索引的节点已被删除,下一个节点顶上来了,从同一个 index 继续」。

到这一步,MDX 输出里已经没有 <script> 了。React 19 不会再报警告。但代价是——复制按钮不工作了,因为 JS 代码也一起被删了。

Layer 2:Server Component——把 JS 模块提取回来

脚本不能从 MDX 里带出来,那就换一条路:直接调 Expressive Code 的 createRenderer API,从配置里提取出它需要的 JS 模块。

src/components/ExpressiveCodeScripts.tsx
import { createRenderer, type RehypeExpressiveCodeOptions } from 'rehype-expressive-code'
import { ecOptions } from '../../expressive-code.config.mjs'
import ExpressiveCodeScriptInjector from './ExpressiveCodeScriptInjector'
// 模块级缓存,避免重复创建 renderer
let cachedScripts: string | null = null
async function getScripts() {
if (cachedScripts !== null) return cachedScripts
const { jsModules } = await createRenderer(ecOptions as unknown as RehypeExpressiveCodeOptions)
cachedScripts = jsModules.length > 0 ? jsModules.join('\n') : ''
return cachedScripts
}
export default async function ExpressiveCodeScripts() {
const scripts = await getScripts()
if (!scripts) return null
return <ExpressiveCodeScriptInjector scripts={scripts} />
}

几个关键点:

  1. createRenderer 返回的 jsModules 就是 EC 在 rehype 阶段注入的那些脚本内容,是字符串数组
  2. 模块级缓存 cachedScriptscreateRenderer 是异步的,且会解析主题配置、加载插件,开销不小。用模块级变量缓存后,整个 Node 进程生命周期只会调一次
  3. 这是一个 async Server Component,可以在渲染时执行异步操作,不会增加客户端 bundle 大小

这个组件放在 layout 里全局挂载,而不是每篇文章里重复渲染。

Layer 3:Client Component——hydration 骗术

现在 JS 代码拿到了,但怎么安全地注入到页面里?如果在 Server Component 里直接写 <script dangerouslySetInnerHTML=...>,React 19 同样会警告。

这里用了一个巧妙的 trick:

src/components/ExpressiveCodeScriptInjector.tsx
'use client'
export default function ExpressiveCodeScriptInjector({ scripts }: { scripts: string }) {
const scriptProps =
typeof window === 'undefined'
? undefined
: ({ type: 'application/json' } as const)
return (
<script
id="expressive-code-scripts"
suppressHydrationWarning
{...scriptProps}
dangerouslySetInnerHTML={{ __html: scripts }}
/>
)
}

这段代码的核心在于 typeof window === 'undefined' 这个条件判断:

  • SSR 阶段(服务端,window 不存在):scriptPropsundefined,spread 后 <script> 没有 type 属性 → 浏览器收到 HTML 后会正常执行这段脚本
  • Client hydration 阶段(浏览器,window 存在):scriptProps 设为 { type: 'application/json' } → React 19 看到 type="application/json" 的 script,不会报警告(因为它不是可执行脚本)
  • suppressHydrationWarning:SSR 产出和 Client 渲染结果故意不一致(一个没有 type,一个有 type),加上这个 prop 告诉 React「我知道不一致,别报错」

效果就是:服务端输出的 HTML 里包含一个正常可执行的 <script>,浏览器首次解析 HTML 时就会执行;React hydration 时不会再触发警告。两全其美。

同样的 trick:next-themes 的 ThemeProvider

这个模式不只适用于 Expressive Code。next-themes 也会注入一个内联 <script> 来在页面加载时尽早设置主题 class(避免主题闪烁)。React 19 同样会警告。

解法一模一样——在 ThemeProvider 的包装组件里传入 scriptProps

src/components/ThemeProvider.tsx
'use client'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
export default function ThemeProvider({ children, ...props }) {
const scriptProps =
typeof window === 'undefined'
? undefined
: ({ type: 'application/json' } as const)
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
scriptProps={scriptProps}
{...props}
>
{children}
</NextThemesProvider>
)
}

这个方案来自 shadcn/ui issue #10200,社区验证过的通用模式。

关键:插件顺序

三层方案能工作,有一个前提——next.config.ts 里 rehype 插件的顺序必须正确:

next.config.ts
rehypePlugins: [
'rehype-slug', // 1. 给 heading 加 id
'@stefanprobst/rehype-extract-toc', // 2. 提取 TOC 数据
'@stefanprobst/rehype-extract-toc/mdx', // 3. 导出 TOC 变量
path.join(projectDir, 'mdx-rehype-expressive-code.mjs'), // 4. EC 处理代码块
path.join(projectDir, 'mdx-rehype-strip-ec-scripts.mjs'), // 5. 删除 EC 注入的 script
],

顺序约束有两条:

  1. TOC 提取必须在 Expressive Code 之前:EC 会把代码块里的 heading(比如注释里的 # xxx)也包裹成复杂结构,如果 TOC 插件在 EC 之后运行,可能会提取到代码块内的假标题
  2. strip-ec-scripts 必须在 Expressive Code 之后:显而易见——得先让 EC 插入 script,才能删它

颠倒任何一个顺序都会导致功能异常,但不会有明确的报错提示,只会表现为「目录为空」或「script 没删干净」,排查起来很费时间。

总结

层级做什么为什么
Layer 1: rehype 插件从 HAST 树删除所有 <script>消除 React 19 的 hydration 警告源头
Layer 2: Server ComponentcreateRenderer 提取 jsModules把被删掉的脚本内容找回来
Layer 3: Client ComponentSSR 不加 type → 正常执行;Client 加 type="application/json" → 骗过 React安全注入脚本,SSR 执行 + 无 hydration 警告

本质上就是绕了一大圈:先删 → 再提取 → 再用特殊方式注入回去。看起来是过度工程,但在 React 19 严格模式下,这是目前最稳定的方案。等 Expressive Code 官方适配 React 19 之后,Layer 1 和 Layer 2 就可以移除了。