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 1 | mdx-rehype-strip-ec-scripts.mjs | 编译时从 HAST 树剥离所有 <script> |
| Layer 2 | ExpressiveCodeScripts.tsx | Server Component 调 EC API 提取 JS 模块 |
| Layer 3 | ExpressiveCodeScriptInjector.tsx | Client Component 用 hydration 骗术安全注入 |
Layer 1:rehype 插件——把 script 全部删掉
第一步最直接:写一个 rehype 插件,遍历 HAST 树,把 Expressive Code 插入的所有 <script> 节点移除。
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 模块。
import { createRenderer, type RehypeExpressiveCodeOptions } from 'rehype-expressive-code'import { ecOptions } from '../../expressive-code.config.mjs'import ExpressiveCodeScriptInjector from './ExpressiveCodeScriptInjector'
// 模块级缓存,避免重复创建 rendererlet 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} />}几个关键点:
createRenderer返回的jsModules就是 EC 在 rehype 阶段注入的那些脚本内容,是字符串数组- 模块级缓存
cachedScripts:createRenderer是异步的,且会解析主题配置、加载插件,开销不小。用模块级变量缓存后,整个 Node 进程生命周期只会调一次 - 这是一个 async Server Component,可以在渲染时执行异步操作,不会增加客户端 bundle 大小
这个组件放在 layout 里全局挂载,而不是每篇文章里重复渲染。
Layer 3:Client Component——hydration 骗术
现在 JS 代码拿到了,但怎么安全地注入到页面里?如果在 Server Component 里直接写 <script dangerouslySetInnerHTML=...>,React 19 同样会警告。
这里用了一个巧妙的 trick:
'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不存在):scriptProps为undefined,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:
'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 插件的顺序必须正确:
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],顺序约束有两条:
- TOC 提取必须在 Expressive Code 之前:EC 会把代码块里的 heading(比如注释里的
# xxx)也包裹成复杂结构,如果 TOC 插件在 EC 之后运行,可能会提取到代码块内的假标题 - strip-ec-scripts 必须在 Expressive Code 之后:显而易见——得先让 EC 插入 script,才能删它
颠倒任何一个顺序都会导致功能异常,但不会有明确的报错提示,只会表现为「目录为空」或「script 没删干净」,排查起来很费时间。
总结
| 层级 | 做什么 | 为什么 |
|---|---|---|
| Layer 1: rehype 插件 | 从 HAST 树删除所有 <script> | 消除 React 19 的 hydration 警告源头 |
| Layer 2: Server Component | 调 createRenderer 提取 jsModules | 把被删掉的脚本内容找回来 |
| Layer 3: Client Component | SSR 不加 type → 正常执行;Client 加 type="application/json" → 骗过 React | 安全注入脚本,SSR 执行 + 无 hydration 警告 |
本质上就是绕了一大圈:先删 → 再提取 → 再用特殊方式注入回去。看起来是过度工程,但在 React 19 严格模式下,这是目前最稳定的方案。等 Expressive Code 官方适配 React 19 之后,Layer 1 和 Layer 2 就可以移除了。