epic-stack 采用 Vite 构建:从 Remix 编译器到 Vite 插件的迁移决策与工程实践
【免费下载链接】epic-stackThis is a Full Stack app starter with the foundational things setup and configured for you to hit the ground running on your next EPIC idea.项目地址: https://gitcode.com/GitHub_Trending/ep/epic-stack
导读
本文基于 epic-stack 仓库的架构决策记录 docs/decisions/036-vite.md,完整还原了这个 Full Stack 启动模板从 Remix 官方编译器迁移到 Vite 构建体系的背景、决策过程与落地细节。你将了解到:为什么"不采用 Vite 就会被困在 Remix v2"、Vite 插件对服务端/客户端代码分离提出的新规则、handle导出中服务端代码的vite-env-only例外处理,以及 epic-stack 当前的 Vite 配置全景与配套的 React Router 构建配置、测试与脚本体系。读完本文,你能理解这套构建方案的核心约束,并能在自己的 React Router / Remix 项目中复刻同样的代码组织规范与配置思路。
一、决策背景:为什么必须迁移到 Vite
docs/decisions/036-vite.md记录于 2024 年 2 月,彼时 Remix 团队正式发布了稳定版的 Remix Vite 插件,它可以替代原有的 Remix 编译器。这份决策文档给出了三个核心理由:
- 这是唯一的前进方向:在 Remix v3 中,Vite 插件将成为构建 Remix 应用的唯一受支持方式。如果项目不采用 Vite,就只能永远停留在 Remix v2 上(原文用 "stuck on Remix v2 forever 🙃" 来描述这种窘境)。
- 更好的开发体验:采用 Vite 意味着获得更好的热模块替换(Hot Module Replacement,HMR),这是开发效率的关键提升点。
- 生态红利:Vite 拥有一个庞大且活跃的工具生态,采用它意味着与其他使用 Vite 的项目共享建设成果,工具链的投入可以复用。
从当前仓库的 package.json 可以看到,这一决策已经彻底落地并持续演进:项目依赖中vite已升级到^7.3.1,@react-router/dev为^7.16.0(Remix 已演进为 React Router v7,插件也随之更名为@react-router/dev/vite),同时配套了vite-env-only、vite-plugin-icons-spritesheet、@vitest/coverage-v8等一整条 Vite 生态工具链。
二、决策本身:Adopt Vite
决策结论只有一句话:采用 Vite。这一决策被标注为 "Status: accepted",意味着它已经正式生效。结合 docs/decisions/README.md 对决策记录目录的说明("记录我们为这个启动模板所做的所有决策,方便后人理解当初为什么这么做"),可以看到这类文档的价值不在于"下结论",而在于保留决策上下文——尤其是那些"回头看很容易被误解"的工程取舍。
在后续演进中,项目还基于 Vite 生态做了更多配套决策,例如 docs/decisions/045-rr-auto-routes.md 使用react-router-auto-routes替换remix-flat-routes生成路由清单,使路由文件系统约定更贴近 React Router 上游原生约定,进一步降低自研工具链的维护面。
三、迁移带来的硬约束:服务端/客户端代码分离规则
采用 Vite 并非没有代价。决策文档明确指出:在 Vite 中,路由模块不能导出任何使用了服务端专用代码的函数。迁移前,epic-stack 的少数路由模块把服务端工具与服务端/客户端混合代码放在了一起,迁移时这些工具必须被移出路由文件。
文档给出了三条简洁的规则:
规则一:Remix(React Router)导出可以留在路由里
像loader、action这类框架约定的导出,是路由模块的正常组成部分,可以原样保留在路由文件中。
规则二:自有工具导出必须放进.server文件
如果是项目自己写的、被路由导出的工具函数(文档举例为/verify路由中曾经的requireRecentVerification),则必须移动到独立的.server文件(或.server.tsx)中。
这条规则在当前仓库的代码结构中可以清晰印证:app/routes/_auth/verify.server.ts 就是一个典型的.server文件,它导出了requireRecentVerification、getRedirectToUrl、VerifyFunctionArgs等验证相关工具;而同目录的 app/routes/_auth/verify.tsx 只保留 UI 与纯前端相关导出,两者通过导入关系协作而非在单个文件中混合导出。类似地,app/routes/_auth/login.server.ts 承载登录逻辑,而login.tsx只负责表单界面与提交。整个app/routes/_auth/目录下,凡是带.server后缀的文件都严格遵循这一约定。
规则三:不导出的服务端工具函数没有问题
如果服务端专用工具函数只是被路由文件内部使用、不对外导出,那么它留在路由文件中也是安全的。问题只出在"被导出"这一点上。
为什么"能构建就等于能运行"
决策文档还提到一个重要的工程保障:Vite 插件在构建时如果发现任何违反上述规则的问题,会直接让构建失败。这意味着"如果它能构建,它就一定能工作"——迁移过程中的代码分离问题会被构建器强制暴露,而不是留到运行时才炸出来。这同时带来一个附带收益:迫使项目形成更干净的"服务端代码"与"服务端/客户端混合代码"分离,长期来看是件好事。
四、一个有趣的例外:handle导出中的服务端代码与vite-env-only
规则看似简单,但存在一个特殊场景:handle导出。handle是路由模块中的一个导出对象,它会同时进入客户端与服务端构建产物,但某些handle的字段(例如 SEO 场景下的getSitemapEntries)只应运行在服务端。
以 docs/seo.md 中记录的 epic-stack 用法为例,站点地图生成依赖@nasa-gcn/remix-seo提供的SEOHandle类型,配合vite-env-only/macros的serverOnly$宏,可以保证该函数在客户端构建中被彻底移除:
// routes/blog/_layout.tsx(示例) import { type SEOHandle } from '@nasa-gcn/remix-seo' import { serverOnly$ } from 'vite-env-only/macros' export const handle: SEOHandle = { getSitemapEntries: serverOnly$(async (request) => { const blogs = await db.blog.findMany() return blogs.map((blog) => { return { route: `/blog/${blog.slug}`, priority: 0.7 } }) }), }与之对应,如果某个页面不需要进入站点地图,只需让getSitemapEntries返回null:
// 在不需要进 sitemap 的路由中 import { type SEOHandle } from '@nasa-gcn/remix-seo' export const handle: SEOHandle = { getSitemapEntries: () => null, }serverOnly$之所以必要,是因为handle本身是路由导出对象,会同时参与客户端与服务端构建,而getSitemapEntries内部调用了数据库等仅服务端可用的能力,必须保证客户端构建产物中不含该函数。vite-env-only的这套支持在 vite.config.ts 中已经预配置完毕(见下文envOnlyMacros()插件)。
在仓库的实际实现中,站点地图资源路由 app/routes/_seo/sitemap[.]xml.ts 通过@nasa-gcn/remix-seo的generateSitemap生成最终 XML,并设置了public, max-age=300的缓存策略;配合robots.txt资源路由(app/routes/_seo/robots[.]txt.ts)构成完整的 SEO 基础设施。
五、仓库中的 Vite 配置全景
决策文档只给出了方向,真正的工程细节体现在 vite.config.ts 中。这份配置完整呈现了"以 React Router 为核心、多插件协同"的构建体系:
1. 核心构建插件reactRouter()
import { reactRouter } from '@react-router/dev/vite'这是 Remix Vite 插件在 React Router v7 时代的形态,替代了原 Remix 编译器,负责把app/routes目录编译为 React Router 应用。注意一个细节:在测试模式下该插件会被跳过(isTest ? null : reactRouter()),因为单元测试不需要完整构建应用。
2.envOnlyMacros()—— 构建期代码裁剪
import { envOnlyMacros } from 'vite-env-only' envOnlyMacros(),这就是第四节serverOnly$/clientOnly$宏得以生效的底层支撑:它作为 Vite 插件在构建期识别宏调用,并按目标环境(服务端/客户端)剔除对应代码。
3.tailwindcss()—— CSS 编译
import tailwindcss from '@tailwindcss/vite'Tailwind CSS v4 的官方 Vite 插件,替代了传统的 PostCSS 链式配置,样式入口为 app/styles/tailwind.css。
4.reactRouterDevTools()—— 开发期调试
集成react-router-devtools,为开发环境提供路由调试面板。
5.iconsSpritesheet()—— SVG 图标雪碧图
iconsSpritesheet({ inputDir: './other/svg-icons', outputDir: './app/components/ui/icons', fileName: 'sprite.svg', withTypes: true, iconNameTransformer: (name) => name, })把 other/svg-icons 目录下的 SVG 图标编译为雪碧图并自动生成类型声明,供 app/components/ui/icon.tsx 这类图标组件使用。
6. Sentry 构建集成(条件启用)
mode === 'production' && process.env.SENTRY_AUTH_TOKEN ? sentryReactRouter(sentryConfig, config) : null,只有生产构建且配置了SENTRY_AUTH_TOKEN时才启用 Sentry 插件,避免本地开发时无谓的构建开销。sentryConfig通过SENTRY_AUTH_TOKEN、SENTRY_ORG、SENTRY_PROJECT、COMMIT_SHA等环境变量驱动,并将 sourcemap 设为hidden——即生成映射文件用于 Sentry 源码映射上传,但不向浏览器暴露//# sourceMappingURL=,防止公开资源泄露映射地址。
7. 测试环境的缓存桩插件
const cacheServerStubPlugin = { name: 'vitest-cache-server-stub', enforce: 'pre' as const, resolveId(source: string) { if (!process.env.VITEST) return null if (source.endsWith('cache.server.ts')) { return path.resolve('tests/mocks/cache-server.ts') } return null }, }这是 epic-stack 为单元测试定制的轻量插件:在 Vitest 运行时把对cache.server.ts的导入解析到 tests/mocks/cache-server.ts 的 mock 实现,避免真实缓存依赖(对应决策 docs/decisions/047-mock-cache-server-in-tests.md)。
8. 内联的 Vitest 配置
vite.config.ts中的test字段直接承载了 Vitest 配置:测试范围app/**/*.test.{ts,tsx}、setup 文件 tests/setup/setup-test-env.ts、全局 setup tests/setup/global-setup.ts,以及覆盖app/**/*.{ts,tsx}的覆盖率统计。此外build.rollupOptions.external将node:*模块与fsevents标记为外部依赖,SSR 构建入口指向 server/app.ts。
六、配套的 React Router 构建配置与脚本
构建体系不止一个配置文件,react-router.config.ts 承担 React Router 层面的构建编排:
ssr: true:保持服务端渲染模式(注释明确提示"设为 false 将启用全路由 SPA 模式");routeDiscovery: { mode: 'initial' }:控制路由发现策略;future.unstable_optimizeDeps: true:启用依赖预优化;buildEnd钩子:生产构建且配置了SENTRY_AUTH_TOKEN时,调用sentryOnBuildEnd在构建收尾阶段上传 sourcemap 与 release 信息(依据COMMIT_SHA关联提交)。
package.json 中的脚本与这套构建体系一一对应:
| 脚本 | 作用 |
|---|---|
npm run build | 执行react-router build,即通过 Vite 完成应用构建 |
npm run dev | 以开发模式启动(MOCKS=true启用 mock) |
npm run typecheck | react-router typegen && tsc,先生成路由类型再静态检查 |
npm run test | 运行 Vitest 单元测试 |
npm run test:e2e:run | 先构建再运行 Playwright 端到端测试 |
npm run validate | 串行执行单元测试、lint、typecheck、e2e 的完整校验 |
依赖清单也印证了 Vite 生态的深度整合:vite-env-only(构建期环境裁剪)、vite-plugin-icons-spritesheet(图标雪碧图)、vitest与@vitest/coverage-v8(测试与覆盖率)、@tailwindcss/vite(样式)、react-router-devtools(调试工具)等。
七、后果评估:更复杂的规则,换来更少的意外
决策文档的 "Consequences" 部分对迁移结果做了坦诚的总结:
- 几乎一切都变好了("Almost everything is better"):更好的 HMR、更丰富的生态、持续跟进的框架升级路径;
- 服务端/客户端代码分离的规则变复杂了一些:需要严格遵守"框架导出留在路由、自有工具导出移入
.server文件、handle中的服务端代码用vite-env-only裁剪"这三条规则; - 但总体上规则是更好的:清晰的边界带来更少的运行时惊喜,构建期强制校验把问题前置暴露。
对于正在迁移或新上手 React Router / Remix + Vite 项目的开发者,可以从这套实践中直接借鉴:
- 为
.server文件建立明确约定:凡是被路由导出的服务端工具,统一放入.server.ts/.server.tsx,让"能否进客户端 bundle"从隐式约束变成文件系统层面的显式信号; - 信任构建器的失败:Vite 插件会在构建期发现违规导出并报错,因此"构建通过"本身就是一个高价值的安全网,把构建纳入 CI 必检项(如
npm run validate); - 善用
vite-env-only处理混合导出对象:凡是对客户端、服务端同时可见的导出(如handle),其中的环境专属字段一律用serverOnly$/clientOnly$包裹; - 按环境条件挂载重型插件:如 Sentry 仅在生产且配置 token 时启用,避免拖慢本地开发。
本文核心依据:决策记录 docs/decisions/036-vite.md;配置实现 vite.config.ts、react-router.config.ts、package.json;代码组织实例 app/routes/_auth/verify.server.ts 与 app/routes/_auth/verify.tsx;SEO 例外处理实例 docs/seo.md 与 app/routes/_seo/sitemap[.]xml.ts。
【免费下载链接】epic-stackThis is a Full Stack app starter with the foundational things setup and configured for you to hit the ground running on your next EPIC idea.项目地址: https://gitcode.com/GitHub_Trending/ep/epic-stack
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考