DeerFlow 前端性能实战:Next.js App Router 静态渲染恢复与路由级 Bundle 预算
2026/9/7 16:29:28 网站建设 项目流程

DeerFlow 前端性能实战:Next.js App Router 静态渲染恢复与路由级 Bundle 预算

【免费下载链接】deer-flowAn open-source long-horizon SuperAgent harness that researches, codes, and creates. With the help of sandboxes, memories, tools, skill, subagents and message gateway, it handles different levels of tasks that could take minutes to hours.项目地址: https://gitcode.com/GitHub_Trending/de/deer-flow

本文基于 DeerFlow 仓库中的实施计划 前端 Bundle 与静态渲染计划,系统讲解如何通过根布局静态化、路由级 i18n 词典隔离、交互面板懒加载、CodeMirror 语言按需加载与 Shiki 单树高亮五步改造,恢复 DeerFlow 前端公开路由的静态渲染能力,并用可执行的字节级预算脚本把优化成果固化下来。读完本文,你将掌握 Next.js App Router 中“为什么 cookies 会拖垮静态生成”“RSC 序列化边界如何影响国际化方案”以及“如何用自动化测量脚本为每条路由锁死 JS/CSS 体积上限”的完整方法论,并能在本仓库中逐条找到对应的源码与测试佐证。

一、计划总览:目标、架构与技术约束

计划文档的开篇定义了这次性能改造的边界,这也是后文所有改造的验收基准:

  • Goal(目标):恢复公开路由的静态渲染,并且把 locale 词典、设置页、编辑器、artifact 面板和 Shiki 从“不使用它们的路由”中剥离出去;
  • Architecture(架构):根布局变为与 locale 无关的纯静态布局;公开的路由感知(locale-aware)路由各自解析自己的一份路由独占词典;交互式 auth/workspace provider 同时持有两份词典,因为 formatter 函数无法跨越 RSC 序列化边界,且语言切换必须保持“零延迟”;功能型宿主组件在用户交互边界(而非路由边界)才加载代码;语法高亮对每个代码块只生成一棵 HTML 树。
  • Tech Stack:Next.js App Router、React 19、TypeScript、Rstest、Streamdown、Shiki、CodeMirror。
  • Global Constraints(全局约束)
    1. 保持可见行为与深链(deep link)不变;
    2. 所有动态 import 必须有稳定的 loading/error 状态;
    3. 不得把认证检查移入公开路由;
    4. 所有影响用户可感知架构的改动,须同步更新对应的AGENTS.md

从仓库现状看,这五项约束在代码中都有对应落地物:根布局已静态化、i18n 词典已按路由拆分、设置页与面板已next/dynamic化、编辑器/高亮已按需加载,并存在一个专门的预算校验脚本(后文第五节详述)。

二、任务一:根布局静态化与路由样式收敛

计划要求把根布局缩减为“全局 reset + 主题 metadata +<html lang={DEFAULT_LOCALE}>”,并新增一个源码边界测试,断言根布局不导入cookiesdetectLocaleServer、KaTeX CSS、Streamdown CSS 或 workspace providers。

当前仓库的 根布局 正是该形态:

export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { return ( <html lang={DEFAULT_LOCALE} suppressContentEditableWarning suppressHydrationWarning > <body> <ThemeProvider attribute="class" enableSystem disableTransitionOnChange> {children} </ThemeProvider> </body> </html> ); }

关键点在于:布局没有任何对next/headers的依赖,lang属性直接使用DEFAULT_LOCALE常量而非运行时检测结果。这意味着//en/docs等公开路由可以在构建期完成 HTML 产出,响应不再携带仅因根布局读 cookies 而产生的private, no-store头。

而“需要动态能力”的部分被下放到子布局。例如 workspace 布局 显式声明export const dynamic = "force-dynamic",在内部调用detectLocaleServer()解析 locale,并在此处挂载I18nProvider与富文本样式(katex/dist/katex.min.cssstreamdown/styles.css);auth 布局/layout.tsx) 同理。这样“样式/词典只属于真正渲染它们的路由”成为一条可测试的边界。

边界由 layout-boundaries.test.ts 以源码扫描方式固化:

it("keeps request locale and rich-content styles out of the root layout", () => { const rootLayout = source("src/app/layout.tsx"); expect(rootLayout).not.toContain("detectLocaleServer"); expect(rootLayout).not.toContain("I18nProvider"); expect(rootLayout).not.toContain("katex/dist/katex.min.css"); expect(rootLayout).not.toContain("streamdown/styles.css"); expect(rootLayout).toContain("DEFAULT_LOCALE"); });

测试还断言 workspace 布局拥有streamdown/styles.css、docs/blog 布局拥有katex/dist/katex.min.css,且(auth)与 workspace 布局只向子树传递可序列化的 locale 状态。这类“源码边界测试”的价值在于:它不需要启动浏览器,就能防止未来有人把cookies()或重型 CSS 悄悄塞回根布局——这是静态渲染能力最常见的回退路径。

三、任务二:按路由边界拆分 locale 词典

计划的核心判断是:公开静态导入两份完整词典会让所有路由(包括登录页)都为不存在的语言切换能力买单,且 formatter 函数无法通过 RSC 序列化边界。因此拆分为“服务端按路由加载单份词典 + 交互边界持有双份词典”的结构。

服务端:穷举 loader 映射。translations.ts 用一个穷举的Record<Locale, () => Promise<Translations>>映射替代静态导入:

const translationLoaders: Record<Locale, () => Promise<Translations>> = { "en-US": async () => (await import("./locales/en-US")).enUS, "zh-CN": async () => (await import("./locales/zh-CN")).zhCN, }; export async function loadTranslations(locale: Locale): Promise<Translations> { return await translationLoaders[locale](); }

Record<Locale, ...>的穷举约束保证了新增 locale 时若忘记登记 loader 会在类型检查阶段失败。server.ts 提供三个服务端入口:detectLocaleServer()localecookie 读取并做decodeURIComponent容错后交给normalizeLocale归一化;setLocale()写回 cookie(maxAge一年、sameSite: "lax");getI18n()支持显式 locale 覆盖。

客户端:交互式 provider 持有双份词典。client-translations.ts 中的注释直接点明了设计动机:“Translation dictionaries contain formatter functions, so they must be selected inside a Client Component rather than serialized through an RSC boundary.” 即词典对象里含有函数(日期、复数等 formatter),不能作为 props 从服务端布局传入客户端组件。因此 context.tsx 的I18nProvider只接收可序列化的initialLocale,内部以useState持有当前词典,setLocale时同步切换localet,实现计划要求的“语言切换零延迟、无 loading 闪烁”,并通过useEffectdocument.documentElement.lang与状态保持同步。

该模块由 context.dom.test.tsx(DOM 环境验证立即切换与lang属性同步)与 translations.test.ts(验证loadTranslations("en-US")/loadTranslations("zh-CN")与不支持的 locale 被解析器拒绝)共同覆盖。公开路由(如首页 header)则只接收显式的默认 locale,不继承交互式双词典 provider——这正是“公开路由不继承两 locale provider”这一断言的来源。

四、任务三:设置页与 workspace 面板的交互边界懒加载

计划要求:保持唯一的SettingsDialogHost与键盘/深链打开行为不变,但设置对话框、九个设置页以及 artifact 详情、浏览器实时视图等“关闭状态下”的面板,其模块求值必须推迟到触发器打开之后。

当前仓库中设置宿主位于 settings-dialog-host.tsx(计划中写作components/settings/,实现时归入了components/workspace/settings/),lazy-panels.test.ts 用源码断言把三条边界钉死:

it("does not import the settings dialog until its store is open", () => { const host = read("src/components/workspace/settings/settings-dialog-host.tsx"); expect(host).toContain("dynamic("); expect(host).toContain("if (!open)"); expect(host).not.toContain('import { SettingsDialog } from "./settings-dialog"'); }); it("loads each settings page from its active section", () => { const dialog = read("src/components/workspace/settings/settings-dialog.tsx"); expect(dialog.match(/dynamic\(/g)).toHaveLength(10); // 禁止从 settings/ 目录静态导入任何 XxxSettingsPage }); it("keeps right-panel implementations behind dynamic imports", () => { const chatBox = read("src/components/workspace/chats/chat-box.tsx"); expect(chatBox).toContain('import dynamic from "next/dynamic"'); // 禁止静态导入 ArtifactFileDetail/ArtifactFileList/BrowserViewPanel/SidecarPanel expect(chatBox.match(/dynamic\(/g)?.length).toBeGreaterThanOrEqual(4); });

对应计划中的“九个设置页 + 对话框本体”共 10 个dynamic(调用点,以及 chat-box 中至少 4 个右侧面板的动态边界。ssr: false仅用于纯浏览器模块,其余动态边界保留 SSR 以保证首次打开不出现布局抖动;每个动态边界都配套了可见的 loading shell,满足全局约束第 2 条。

该改造的验证目标是:chats 路由的首次资产清单中不再包含设置/编辑器/浏览器面板的 chunk——这一目标由第五节的资产测量脚本闭环验证。

五、任务四:CodeMirror 语言按需加载与 Shiki 单树高亮

5.1 编辑器:语言适配器穷举 + 动态 import

计划提出新增“语言到 import 的穷举加载器”,仓库中的实现为 code-editor-extensions.ts(计划文件名code-editor-languages.ts,落地时并入 extensions 文件)。它先做一次语言归一化,把别名折叠到 7 个 canonical 语言:

export function normalizeCodeEditorLanguage( language: string | null | undefined, ): CodeEditorLanguage { switch (language?.toLowerCase()) { case "css": case "scss": case "sass": case "less": return "css"; case "html": case "xml": return "html"; case "javascript": case "typescript": case "jsx": case "tsx": return "javascript"; case "json": case "jsonc": case "json5": return "json"; case "markdown": case "mdx": return "markdown"; case "python": case "py": return "python"; default: return "text"; } }

随后loadLanguageExtension对每个 canonical 语言动态import对应的@codemirror/lang-*包,未知语言落到"text"(空扩展数组,纯文本编辑);loadTheme按主题动态加载@uiw/codemirror-theme-monokai(暗色)或@uiw/codemirror-theme-basic(亮色),两者均把背景设为透明以贴合容器样式。Promise.all并行加载语言与主题。

宿主组件 code-editor.tsx 负责“稳定的加载状态”:扩展未就绪或线程处于isLoading时渲染一个只读Textarea占位,就绪后才挂载CodeMirrorCtrl/Cmd+S触发onSave,行号/折叠等通过settings透传。由于整个扩展加载走动态 import,落地页与 chats 路由的初始资产列表中不会出现任何 CodeMirror chunk。

5.2 Shiki:一个代码块只生成一棵树

计划明确要求:高亮惰性加载在 code block 边界之后;只调用一次codeToHtml生成一份 HTML,并用“Shiki CSS 变量或单份双主题 token 树”实现主题切换,而非两次调用两棵树。

仓库实现为 shiki-highlight.ts(落地扩展名为.ts)。核心调用:

codeToHtml(code, { lang: language, themes: { light: "one-light", dark: "one-dark-pro" }, defaultColor: "light", transformers: showLineNumbers ? [lineNumberTransformer] : [], });

双主题(one-light/one-dark-pro)在单次codeToHtml中产出同一棵 DOM 树,运行时通过 CSS 变量切换主题,避免“暗/亮各高亮一次”的冗余。模块还内置了高亮缓存:HIGHLIGHT_CACHE_LIMIT = 64条、单条代码超过HIGHLIGHT_CACHE_MAX_CODE_LENGTH = 100_000字符则绕过缓存直接渲染,缓存 key 包含HIGHLIGHT_CONFIGURATION_VERSION、代码、语言与行号开关,并以 LRU 方式淘汰——对长会话中反复渲染同一代码块的场景,这是显著的成本削减点。

shiki-highlight.ts 被 code-block.tsx 以动态 import 的方式引用,useEffect中先显示原始<pre>兜底,高亮结果到达后替换;renderId引用防止异步竞态导致的陈旧状态回写:

useEffect(() => { const currentRenderId = ++renderId.current; setHtml(""); void highlightCode(code, language, showLineNumbers) .then((highlighted) => { if (currentRenderId === renderId.current) setHtml(highlighted); }) .catch(() => { // Shiki 加载失败或模型输出不支持的语言时,原始代码兜底保持可见 }); return () => { renderId.current += 1; }; }, [code, language, showLineNumbers]);

这条“原始代码兜底 + 稳定 loading 态”的实现正是全局约束第 2 条(动态 import 必须有稳定 loading/error 状态)在高亮路径上的具体体现。

六、任务五:性能门禁——路由资产测量与预算锁定

计划最后一步是把“优化成果”变成“不可回退的契约”。仓库中对应的自动化门禁由 measure-route-assets.mjs 与 performance-budgets.json 组成,package.json中的perf:check脚本即node scripts/measure-route-assets.mjs --check

测量流程(与脚本源码逐段对应):

  1. 双模式构建:/login用普通模式构建(因为它是force-dynamic的认证路由),其余路由用NEXT_PUBLIC_STATIC_WEBSITE_ONLY=true构建,验证静态产出能力;
  2. 启动next start于随机本地端口,等待就绪(30 秒超时);
  3. 对每条路由抓取 HTML,用正则提取/_next/static/下的全部src/href资产(extractAssetPaths),再对每个文件stat累加真实字节数;
  4. --check模式下将测量值与预算文件对比,任何一项超阈值即整体失败并打印超出字节数(evaluateBudgets)。

被测量的六条路由(ROUTES)覆盖了用户旅程的关键节点:

const ROUTES = [ "/", "/login", "/workspace/chats", `/workspace/chats/${DEMO_THREAD_ID}`, // 演示线程 7cfa5f8f-... "/en/docs", "/blog/posts", ];

当前锁定的预算(字节)如下,即计划中“one-time post-optimization calibration”的产物——每条上限都低于对应路由优化前的实测基线,且之后不得上调:

路由CSS 上限JS 上限预算语义
/login170,000850,000认证路由,最轻的交互面
/175,0001,050,000落地页,不得继承 workspace 重量级 chunk
/workspace/chats190,0001,750,000会话列表,不含单线程重资产
/workspace/chats/7cfa5f8f-...190,0004,100,000单线程页,允许编辑器/流式渲染资产
/en/docs270,0004,200,000nextra 文档,含 KaTeX
/blog/posts270,0004,200,000富文本博客,含 KaTeX

完整验收命令序列(计划 Task 5):

cd frontend pnpm check && pnpm test # eslint + tsc + rstest NEXT_PUBLIC_STATIC_WEBSITE_ONLY=true pnpm build pnpm perf:check # 构建 + 启动 + 逐路由测字节 + 对比预算

perf:check的测量结果会写入.next/performance-results.json并输出按路由汇总的JS x B | CSS x B | HTML x B摘要,配合“检查五个路由的资产清单、确认每个重 chunk 有且只有一个归属路由/交互”的人工核对步骤,形成“自动化断言 + 清单归属”双重门禁。

七、全局约束与回退验证方法

计划对每个任务都内置了“回退证明”步骤(例如:还原根布局的 locale 检测 → 证明测试变红 → 恢复 → 重跑),这是一种“证明测试真的在守护”的反向验证手段,确保边界测试不是永绿的空壳。四条全局约束的最终落地位置可以对照检查:

全局约束落地证据
保持可见行为与深链设置页键盘/深链打开行为不变(SettingsDialogHost唯一入口);深链路由/workspace/chats/{threadId}仍在测量清单中
动态 import 有稳定 loading/error 态CodeEditor 的Textarea占位、code-block 的原始<pre>兜底、面板的可见 loading shell
认证检查不进公开路由auth/workspace 布局各自保留getServerSideUser分支与force-dynamic,公开布局无认证调用
架构变更同步 AGENTS.md仓库在 frontend/AGENTS.md 等位置维护架构说明,约束要求同类改动同步更新

八、如何在仓库中复核本文内容

  • 计划原文(含五个任务的完整步骤与提交信息约定,如perf(frontend): restore static public layout boundaries):2026-07-31-frontend-performance-bundle-static.md
  • 静态根布局与路由布局:layout.tsx、(auth)/layout.tsx/layout.tsx)、workspace/layout.tsx
  • i18n 分层:server.ts、translations.ts、context.tsx、client-translations.ts
  • 懒加载边界与测试:settings-dialog-host.tsx、lazy-panels.test.ts、layout-boundaries.test.ts
  • 编辑器与高亮:code-editor.tsx、code-editor-extensions.ts、code-block.tsx、shiki-highlight.ts
  • 性能门禁:measure-route-assets.mjs、performance-budgets.json

这套方案的可借鉴之处在于:它没有停留在“压缩打包”层面,而是把性能约束转化为三条可长期执行的工程契约——源码边界测试(防止重型依赖回流入共享布局)、路由资产预算(防止体积回退)、以及每个动态边界的稳定加载态(防止懒加载损伤可用性)。三者共同保证公开路由的静态渲染能力不因后续迭代而被悄悄侵蚀。

【免费下载链接】deer-flowAn open-source long-horizon SuperAgent harness that researches, codes, and creates. With the help of sandboxes, memories, tools, skill, subagents and message gateway, it handles different levels of tasks that could take minutes to hours.项目地址: https://gitcode.com/GitHub_Trending/de/deer-flow

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询