资讯动态

React Router 的 ADR-0009:为什么正确性不能依赖 Tree-shaking,以及 `.server` 模块的源码级实现

发布时间:2026/9/7 18:22:57 来源:尧图企业网站定制
React Router 的 ADR-0009为什么正确性不能依赖 Tree-shaking以及.server模块的源码级实现【免费下载链接】react-routerDeclarative routing for React项目地址: https://gitcode.com/GitHub_Trending/re/react-router本文基于 React Router 仓库中的架构决策记录 0009-do-not-rely-on-treeshaking-for-correctness.md状态accepted日期 2024-01-02展开。它回答一个框架层的核心问题当路由文件里同时包含服务端代码loader、action、headers等导出和客户端代码时如何保证服务端专属代码永远不会泄漏进客户端 bundle。读完本文你将理解正确性不依赖优化这一设计原则背后的四层论证并看到该决策在react-router-devVite 插件中的具体落地从SERVER_ONLY_ROUTE_EXPORTS白名单、removeExports的显式死代码消除到.server模块的构建期硬隔离。一、背景同一份代码同时跑在两端该 ADR 的开篇Context 部分说明框架模式下允许编写同时运行在服务端与客户端的代码一个路由文件里同时存在服务端与客户端代码是很常见的。便利的代价是——必须确保 server-only 代码绝不进入客户端。原因有二服务端才能访问的密钥secrets会泄漏到客户端依赖服务端环境的代码在浏览器里直接崩溃。ADR 将 server-only 代码归纳为三种形态路由的 server-only 导出如loader、action、headers等从.server目录或.server文件中的导入从 server-only 包如node:fs的导入。仓库当前的实现中第一种形态的server-only 路由导出被明确编码为一个白名单常量位于 packages/react-router-dev/vite/plugin.tsconst SERVER_ONLY_ROUTE_EXPORTS [loader, action, middleware, headers];可以看到当前版本比 2024 年 ADR 撰写时多了middleware——这正是该决策落地后、能力演进时仍然遵循显式标记思路的例证新增的服务端导出不是靠打包器猜出来剔除的而是加入这份显式清单。二、曾经的方案依赖 Tree-shaking以及它的缺陷ADR 回顾了 RemixReact Router 的前身早期的两条技术路线两者本质相同——先移除路由的 server 导出再让打包器对未使用的代码做 tree-shaking每个路由一个虚拟模块virtual modules只 re-export 客户端安全的导出短暂使用过的AST 转换方案直接从路由模块中删除 server-only 导出。这种路线的最大好处服务端与客户端代码可以共存于同一模块图、甚至同一文件开发者无需手动标注或拆分。但 ADR 给出了三点否定论证2.1 人为失误隐式安全防线挡不住显式引用server-only 在这个方案里是隐式的完全依赖 tree-shaking 的彻底性去推断。哪怕 tree-shaking 是完美的漏洞依然成立只要团队里有人从客户端代码里意外引用了服务端代码打包器会高高兴兴地把这段代码连同密钥打进客户端 bundle。构建时不会有任何提示只有运行期才暴露——轻则浏览器崩溃重则密钥泄漏。2.2 不完美优化tree-shaking 在设计上就不承担正确性ADR 指出 tree-shaking 本身是一个难问题在 JavaScript 这种动态语言里更难真实世界中它不完美。这正是为什么 tree-shaking 被设计为一种优化slim down bundle你的代码在 tree-shaking 之前就应该已经是正确的。打包器被允许自行权衡做多少 tree-shaking甚至减少tree-shaking 都不需要主版本号变更——所以靠它保正确性的地基是晃动的。还有一个细节常被忽视只有已知无副作用side-effect free的代码才能被 tree-shaken。但很多完全无副作用的包的package.json里根本没写sideEffects: false反过来有些服务端包的副作用正是我们想要的要留在服务端 bundle那又如何把它从客户端 bundle 里排掉ADR 的结论是能做到但所有办法都hacky and brittle脆弱且不可靠。2.3 Vite 架构dev 模式下的按需编译与 tree-shaking 不相容ADR 的最后一击来自工具链本身Remix 正在变成 Vite 插件而Vite 的 dev 模式按需编译on-demand compilation与 tree-shaking 不相容。由于编译是按需触发的Vite 只知道当前这个模块的当前 importer而不是所有可能的 importer——跨模块的 tree-shaking 判定在 dev 阶段根本无法完成。2.4 ADR 的四点总结原文 Summary 部分可直接作为检查清单引用即使 tree-shaking 是完美的它也为人为失误留了后门.server模块保证 server-only 代码被排除在客户端之外tree-shaking 是不完美的优化应用必须在没有 tree-shaking 时也能正确工作并排除 server-only 代码Vite 的架构使 dev 阶段的 tree-shaking 不可行。三、决策显式移除 构建期报错Decision 部分只有两条但每一条都对应了仓库里的真实实现Forcibly remove server-only route exports and then explicitly run a dead-code elimination pass强制移除 server-only 路由导出然后显式执行一遍死代码消除Explicitly mark server-only code and throw a build time error if server code is still referenced in the client显式标记 server-only 代码若客户端仍引用它则抛出构建期错误3.1 决策 1 的落地removeExports—— 显式的死代码消除packages/react-router-dev/vite/remove-exports.ts 实现了第一句话基于 Babel 的 AST 变换精确移除指定的导出而不是等待打包器来摇export const removeExports ( ast: ParseResultBabel.File, exportsToRemove: readonly string[], ) { let previouslyReferencedIdentifiers findReferencedIdentifiers(ast); // ... };它的处理粒度非常细export { foo }/export { bar } from ./module过滤掉被点名的导出说明符若该语句所有说明符都被移除则整句删除export const foo ...按变量声明逐个过滤兼容export const foo ..., bar ...的混合声明export function foo() {}/export class Foo {}整句标记删除export default移除时还会记录被导出的本地标识符removedExportLocalNames用于后续清理顶层的foo.hydrate true这类属性赋值语句如clientLoader.hydrate true也会被连带移除。关键在最后一步——它没有止步于删导出而是显式跑了一遍死代码消除if (markedForRemoval.size 0 || exportsFiltered) { for (let path of markedForRemoval) { path.remove(); } // Run dead code elimination on any newly unreferenced identifiers deadCodeElimination(ast, previouslyReferencedIdentifiers); }deadCodeElimination来自 babel-dead-code-elimination作用于因移除导出而新变得无引用的标识符。这正是 ADR 中forcibly remove … and then explicitly run a dead-code elimination pass的逐字实现DCE 是框架自己主动执行的一个构建步骤而不是打包器顺带提供的优化。一个值得注意的防御性细节对于无法安全删除的解构导出如export const [foo] ...validateDestructuredExports 不会悄悄降级而是直接抛出Cannot remove destructured export name错误——把不确定转化为构建失败而不是留到运行期。RSC 集成测试 rsc-virtual-route-modules-test.ts 验证了这条链路removes server-only route exports before scanning client deps——在扫描客户端依赖之前先移除 server-only 导出并断言转换后的代码不再包含server-only-package的任何痕迹。3.2 决策 1 的另一半虚拟模块只 re-export 客户端安全导出ADR 提到的 virtual modules 路线并非被抛弃而是被保留为正向白名单式实现客户端构建中路由模块被替换为一个只 re-export 客户端安全导出的虚拟模块。在 packages/react-router-dev/vite/plugin.ts 的react-router:build-client-route插件里let reexports sourceExports .filter((exportName) { let isRouteEntryExport (options?.ssr SERVER_ONLY_ROUTE_EXPORTS.includes(exportName)) || CLIENT_ROUTE_EXPORTS.includes(exportName); // ... }) .join(, ); return export { ${reexports} } from ./${routeFileName};;即SSR 构建保留SERVER_ONLY_ROUTE_EXPORTS中的服务端导出客户端构建只保留CLIENT_ROUTE_EXPORTS白名单内的导出。这里的方向与依赖 tree-shaking截然相反——不是删掉服务端导出后祈祷其余被摇走而是客户端入口从一开始就只能看到白名单内的名字。虚拟模块 id 的生成见 virtual-module.tsvirtual:react-router/*前缀。3.3 决策 2 的落地构建期错误而不是运行期崩溃ADR 的 Consequences 部分承诺Build-time errors instead of runtime errors。仓库中对应的实现至少有两处SPA 模式导出校验SPA 模式下不存在服务端因此任何SERVER_ONLY_ROUTE_EXPORTS中的导出loader/action/middleware/headers出现在路由模块里都是错误构建时会报出具体文件名与非法导出清单见 plugin.ts 中SPA Mode: N invalid route export(s) in ...的错误生成逻辑。.server模块进入客户端模块图即失败官方文档 server-modules.md 明确写道——The build will fail if any code in a.serverfile or.serverdirectory accidentally ends up in the client module graph.若.server文件或目录里的任何代码意外进入客户端模块图构建会失败。四、.server模块唯一能保证排除的手段ADR 专门用一节论述了.server模块的冗余悖论Theoretically,.servermodules are a redundancy. A perfect module graph with perfect treeshaking shouldntneed.servermodules. But in practice,.servermodules are indispensable. They are the only guaranteed way to exclude code from the client.理论上完美的模块图加完美的 tree-shaking 并不需要.server模块但实践中它们不可或缺因为这是唯一有保证能从客户端排除代码的方式。当前仓库的 server-modules.md 文档完整继承了这一机制单个文件文件名加.server后缀如app/auth.server.ts整个目录目录名含.server如app/.server/auth.ts语义这些模块被标记为整个模块 server-only其中的每个导出都被视为 server-only。典型用法示例原文档的 Database Connection / Authentication 场景// app/utils/db.server.ts import { PrismaClient } from prisma/client; // This would expose database credentials on the client const db new PrismaClient({ datasources: { db: { url: process.env.DATABASE_URL, }, }, }); export { db };// app/routes/login.tsx —— 只在 action服务端中引用 .server 模块 import { hashPassword, createToken } from ../utils/auth.server; import { db } from ../utils/db.server; export async function action({ request }: ActionFunctionArgs) { const formData await request.formData(); const hashedPassword await hashPassword(formData.get(password) as string); const user await db.user.create({ data: { email, password: hashedPassword } }); return redirect(/dashboard, { headers: { Set-Cookie: token${createToken(user.id)}; HttpOnly; Secure }, }); }文档同时给出了一条重要约束路由模块本身不能命名为.server或.client——路由模块需要在服务端与客户端两套模块图中都被引用特殊处理会因此冲突强行这么做会触发构建错误。与之对称的是.client模块client-modules.md文件加.client后缀或置于.client目录强制把代码排挤出服务端bundle注意其导出值在服务端均为undefined因此只能在useEffect或事件处理器中使用。五、决策的后果Consequences与源码印证ADR 结尾列出的 Consequences 是这份决策的验收标准可以逐条对照当前实现不再依赖优化保证正确性——removeExports显式执行 DCE而非等待打包器 tree-shake构建期错误替代运行期错误——SPA 模式非法导出校验、.server模块进入客户端即构建失败Vite 下 dev 与 prod 行为一致——因为正确性来自显式的模块图改写虚拟模块 白名单 re-export而不是仅在 build 阶段生效的 tree-shakingdev 的按需编译模式与 prod 的打包模式得到同一套保证导出默认被视为客户端安全除非显式标记为 server-only——两个方向都是显式的.server模块将其所有导出标记为 server-only路由的loader、action、headers现含middleware等导出是例外——它们天然已知是 server-only即 plugin.ts 中的SERVER_ONLY_ROUTE_EXPORTS。六、给开发者的实践要点结合该 ADR 与当前仓库实现可以提炼出在 React Router framework 模式下的几条准则密钥与服务器专属逻辑一律放入.server文件/目录如*.server.ts、app/.server/这是唯一有构建保证的隔离手段客户端构建只消费CLIENT_ROUTE_EXPORTS白名单 普通模块导出服务端导出loader/action/middleware/headers由SERVER_ONLY_ROUTE_EXPORTS显式枚举新增服务端能力时必须显式加入该清单而非指望打包器推断在共享模块中顺手 import 一下服务端工具是危险的这正是 ADR 警告的人为失误场景在 tree-shaking 方案下会静默泄漏在当前方案下则应被显式标记机制或.server隔离在构建期拦截把 tree-shaking 只当体积优化remove-exports.ts的存在本身就是宣言——凡是与正确性相关的剔除都由框架用显式 AST 变换完成打包器的优化行为只影响 bundle 大小不影响安全性。这份 ADR 的价值在于它把一个常见的打包器会帮我们清理的假设替换为一套可验证的工程契约显式白名单、显式 DCE、显式构建期报错。仓库中 remove-exports.ts 的精确 AST 处理、remove-exports-test.ts 的回归测试以及 rsc-virtual-route-modules-test.ts 中移除后再扫描客户端依赖的断言都是这一契约在源码层的直接证据。【免费下载链接】react-routerDeclarative routing for React项目地址: https://gitcode.com/GitHub_Trending/re/react-router创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价