Next.js 与 Vite SPA 混合架构下的前端导航提速实践
最近在做自己的项目 Lumen。随着工作台功能逐渐变重,前端导航在开发后期明显慢了下来。最初让我不舒服的不是某个接口慢,而是一个很具体的现象:Dashboard、项目、素材和热门视频明明属于同一个工作台,来回切换时却仍然像在重新进入一个页面。
顶栏会重走导航,页面边界重新建立,业务数据再发请求。对于一个用户会连续使用很久的创作工具,这种等待会在一天里重复很多次。单独优化某个 Mongo 查询,解决不了这件事。
我一开始也把原因粗略归结为“SSR 每次切页都要重新渲染”。这个说法并不严谨。Next.js App Router 的客户端导航不等于浏览器整页刷新,也不是每次都重新 hydration;它可以预取并复用客户端状态。但应用区的路由仍然属于 Next.js 运行时,未命中的路由段和 RSC 数据可能需要再次经过服务端边界。
真正需要拆开的,是两件原本绑在一起的事:切到哪个页面,以及这个页面要读取什么业务数据。
卡顿不在一个接口,而在路由所有权
Lumen 原来只有 lumen-studio。落地页、登录、注册、工作台和面向浏览器的同源 /api/* 都在同一个 Next.js 项目里。
这对门面页面很合适。陌生用户第一次进入,需要服务端生成内容、处理语言和登录跳转,也可能关心搜索引擎抓取。但登录后的应用区不是这种访问模式。用户会在 /dashboard、/canvas/projects、/materials 和 Canvas 之间连续操作,页面结构稳定,变化的主要是客户端状态和 API 数据。
如果继续让 Next.js 持有这些路由,每次导航都可能穿过服务端路由协议。即使页面组件本身已经是 Client Component,路由边界仍然没有离开 Next.js。
这次改造的判断不是“SSR 慢,SPA 快”,而是:
门面页需要服务端参与首屏,应用区更需要一个长期存活的客户端运行时。
于是 Lumen 没有把整个站改成 SPA,而是把 /app 下的页面路由所有权交给浏览器;/app/api/* 仍然是服务端接口。
两个 commit 切出了第二个运行时
这次变化主要落在两个 commit 上。
6224269 perf(studio): split app shell 建立了新的 apps/lumen-app:Vite 负责打包,React 负责渲染,TanStack Router 负责 /app/* 页面路由。与此同时,Dashboard、Materials、Canvas 等旧 Next.js 页面被改成跳转到新的 SPA 地址。
18de48a perf(studio): make app navigation instant 继续补齐应用内导航链路:加入 Home 和 Hot Videos 路由,引入持久 App Shell、路由骨架、代码预热、数据预取和内存响应缓存。仓库没有这两次提交前后的导航计时,因此这里描述的是链路变化,不是未经测量的毫秒收益。
最终边界是这样的:
| 请求 | 所属运行时 | 处理方式 |
|---|---|---|
| 落地页、登录、注册、分享页 | lumen-studio |
Next.js 门面 |
/app/assets/* |
lumen-app 产物 |
Vite hash 静态资源 |
其他 /app/* 页面 |
lumen-app |
返回 index.html,由 TanStack Router 接管 |
/app/api/* |
lumen-studio |
绕过 SPA,进入 Next.js Route Handler |
/api/* |
lumen-studio |
原有 Next.js Route Handler |
/ws/flow |
lumen-studio |
Node 自定义服务器与 WebSocket Gateway |
这里说“门面”比笼统说“SSR 站点”更准确。Next.js 仍然负责公开页面、重定向、鉴权入口和后端接口,但并不意味着其中每个页面都必须采用同一种渲染策略。
这也是一次渐进式迁移,不是硬切换。/home、/hot-videos 的 Next.js 入口仍然保留;分享页继续在服务端读取分享信息、检查登录并克隆项目,成功后才跳进 SPA Canvas。
运行时和构建边界放在一张图里;手机端可以点击查看原图。
一个进程怎样托住两套前端
部署时没有多起一套 Web 服务。lumen-studio 的 Node 自定义服务器先判断请求是不是 /app/*,命中就直接返回 Vite 产物,否则再交给 Next.js:
const server = createServer((req, res) => {
const parsedUrl = parse(req.url ?? '/', true);
if (serveStudioApp(req.url ?? '/', res)) return;
handle(req, res, parsedUrl);
});
/app/assets/* 返回带 hash 的 JS、CSS,响应头是一年 immutable;其他 /app/* 路径都返回同一份 index.html,并使用 no-cache。所以直接刷新 /app/canvas/123 时,服务器不需要认识这个项目 ID,只需要把 SPA 壳交给浏览器,TanStack Router 再从 URL 恢复页面。
这里有一条不能写错的分流规则:/app/api/* 必须绕过 SPA 静态处理,继续进入 Next.js。否则一个期待 JSON 的客户端会收到 index.html,状态码可能还是 200,错误会一直拖到 response.json() 才暴露。
生产构建也明确保留了这两套产物:
pnpm build:shared
pnpm build:app
NEXT_DIST_DIR="$STUDIO_BUILD_DIR" pnpm build:studio
SPA 没有增加独立 Web 进程。lumen-studio 同时承担 Next.js 门面、API 和 SPA 静态文件服务器;整个 Lumen 仍然另外运行 lumen-agent 与 lumen-engine。
没有重写页面,靠的是一层源码兼容
单独建立 Vite 项目最怕的事情,是把已有工作台再实现一遍。Lumen 没这么做。
lumen-app 的 @ alias 直接指向 lumen-studio/src,Dashboard、Workspace、Materials、Hot Videos 和 CanvasWorkbench 继续使用原来的组件源码:
resolve: {
alias: [
{ find: '@', replacement: studioSrc },
{ find: 'next/link', replacement: path.resolve(__dirname, 'src/compat/next-link.tsx') },
{ find: 'next/navigation', replacement: path.resolve(__dirname, 'src/compat/next-navigation.ts') },
{ find: '@clerk/nextjs', replacement: path.resolve(__dirname, 'src/compat/clerk-next.ts') },
{ find: '@sentry/nextjs', replacement: path.resolve(__dirname, 'src/compat/sentry-next.ts') },
],
}
结果是“两套打包,一套业务组件”。Next.js 页面仍可使用原来的 import;Vite 构建时把其中几个框架依赖替换为浏览器版本。
兼容层做的事情很克制:
next/link被换成普通<a>,内部地址点击时阻止整页跳转,改用router.navigate();next/navigation的push、replace、prefetch映射到 TanStack Router;@clerk/nextjs和@sentry/nextjs分别转发到@clerk/react、@sentry/react;- 历史的
/dashboard、/materials、/canvas/*仍能进入,但最终都会收敛到/app/*。
这不是一个可以无限扩张的抽象层。比如兼容版 redirect() 只能在浏览器里改地址,notFound() 也没有 Next.js 的服务端语义。它适合承接现有客户端组件,不适合假装 Vite 拥有完整的 Next.js Runtime。
国际化也从 URL 路由问题变成了状态同步问题。SPA 地址不再带 /zh 或 /en,语言依赖 React state、Cookie 和 LocalStorage;path-map.ts 还要压平历史上出现过的 /app/zh/app/... 脏路径。双运行时减少了热路径等待,也留下了必须长期维护的路径治理。
App Shell 必须比页面活得更久
SPA 能不能变快,关键不在 URL 有没有变,而在导航时什么东西不再重建。
Lumen 的根路由长期持有 Clerk、Mantine、Jotai、i18n、QueryClient、API token bridge、Topbar 和背景层。TanStack Router 切页时只替换 <Outlet />:
<AppProviders>
<StudioAppShell>
<ProtectedRoute>
<Outlet />
</ProtectedRoute>
</StudioAppShell>
</AppProviders>
这样 Clerk 会话、语言状态、全局原子状态和顶部导航都不必因为工作台切页重新初始化。Home 等旧组件会通过 AppShellChromeProvider 知道外层已经渲染了 Topbar,避免同一套外壳出现两次。
Canvas 是例外。它需要完整工作区,不显示普通页面的 Topbar 和 Aurora 背景,但仍然留在同一个 Provider 树中。这个处理比给 Canvas 再建一个应用入口更重要:视觉外壳可以变化,身份和全局状态边界不能跟着断开。
代码预热有三层,不是一个 prefetch 开关
当前实现把代码加载分成了三层。
第一层发生在 React 挂载之前。main.tsx 先根据 window.location.pathname 动态 import 当前页面,再开始 createRoot()。这不是等待页面模块下载完成,而是提前发起请求,让当前路由 chunk 与 React、Clerk 初始化并行。
第二层发生在应用挂载之后。一个零延时 timer 会 import Home、Dashboard、Hot Videos、Materials、Workspace、CanvasEntryLoader 和 CanvasWorkbench。它的目标是让后续切页尽量只剩一次内存路由切换。
这里要纠正一个容易写得过于漂亮的说法:全路由代码预热不是 idle 任务。它在挂载后很快开始,未登录用户也可能下载私有工作台代码。换来的确实是后续导航速度,但代价是首轮带宽和 JavaScript 解析工作。
第三层来自用户意图。兼容版 Link 会在 pointerenter、focus、mousedown 和 touchstart 时同时做两件事:
void router.preloadRoute({ to: target.to, search: target.search });
void warmAppRouteResources(href);
前者让 TanStack Router 准备路由,后者继续加载页面模块和该页面的默认数据。真正点击时,代码和数据往往已经在路上。
登录后的全局数据预热会再等 150ms,然后放进 requestIdleCallback:
/api/projects
/api/folders
/api/material-assets?category=item&limit=80
/api/hot-videos?limit=24
Home 和 Dashboard 数据不在这组登录后全局预取里,但触发时机并不相同。Dashboard 主要跟随 SPA 链接意图;Next 落地页上的 useHomeRoutePreload() 会在 requestIdleCallback,或不支持时延迟 450ms,提前请求 /app/home、Home 两个 API 和海报图片。这个差别很重要:预取策略不只由目标页面决定,也取决于用户从哪一个运行时进入。
缓存命中的单位是身份、语言和完整 URL
预取只有在页面能消费结果时才有意义。否则只是把同一个请求发了两遍。
Lumen 现在通过全局 window.fetch 拦截器处理同源 /api/* 请求。只有 TTL 白名单内、状态成功并且 Content-Type 包含 application/json 的 GET Response 才会写入 Map。缓存 key 不是“项目列表”这样的业务名称,而是:
return `${headers.get('authorization') ?? 'anon'}|${
headers.get('x-lumen-locale') ?? 'en'
}|${url.pathname}${url.search}`;
Bearer token 存在时,它会成为缓存 key 的身份维度;locale 可以隔开不同语言,完整 query 则保证筛选条件不同的请求不会混在一起。但 token 超时或缺失时,key 会回退成 anon,请求仍可能通过 Clerk Session Cookie 在服务端完成鉴权。当前实现因此不能被当成完整的用户隔离层:更稳妥的做法是使用稳定的用户或 Session 标识,并在身份变化时主动清空缓存。
当前 TTL 也不是一刀切:项目列表和 /api/me 是 1 分钟,项目详情、目录和素材是 5 分钟,热门视频和 Home 数据是 30 分钟。项目、目录或素材发生 mutation 后,对应缓存会被主动清理。
这里还有一个和代码表面不一致的事实:项目已经创建了 React Query 的 QueryClient,但当前页面没有使用 useQuery、prefetchQuery 或 ensureQueryData。真正工作的缓存是这张进程内 Map,query-client.ts 里的 staleTime 还没有成为页面请求的事实来源。
这张 Map 能解决“预取完成后页面又 GET 一次”的问题,却没有合并 in-flight Promise。用户在预取尚未结束时立刻进入页面,预取请求和页面请求仍可能同时到达服务端。它也不持久化,刷新标签页后全部失效。
还有一个更隐蔽的边界:拦截器会对所有匹配的 GET 查 Map,包括调用方声明了 cache: 'no-store' 的请求。Canvas 使用 mutation 失效和 ?fresh=1 绕开了一部分问题,但 fresh=1 只是生成了不同的前端缓存 key,并让服务端 Repository 绕过缓存;第一次 fresh=1 Response 仍可能进入 Map,后续相同 URL 继续命中。它不是严格遵循 Fetch cache 语义的通用缓存层。
“变快”至少要拆成三种收益
如果把所有变化都叫“性能优化”,很容易把预取造成的服务器负载也算成收益。我更愿意按下面三类看。
| 类型 | Lumen 中的例子 | 服务端请求是否减少 |
|---|---|---|
| 真正消除 | /app/* 内部导航不再请求 Next.js 路由/RSC;TTL 内重复 GET 直接读 Map;新建 Canvas 后不再补一次项目 GET;SPA 内语言切换只改 state 和 cookie |
是 |
| 只是提前 | 当前路由 chunk 抢跑;hover 时加载目标 chunk;登录后预取项目、目录、素材和热门视频 | 第一次通常不减少,只是移到点击之前 |
| 仅改善感知 | Topbar 立即切换 pendingPath;不同页面显示对应骨架;Canvas 用全屏 Entry Loader 遮住节点尚未就绪的阶段 |
否 |
新建 Canvas 的处理尤其值得保留。POST /api/projects 已经返回完整项目后,Workbench 直接把项目写进 state,用 history.replaceState() 把 /app/canvas/new 改成真实项目地址,再通过 skipNextLoadAfterCreateRef 跳过紧接着的 load effect。
这里少掉的是一次确切的项目详情 GET,也避免了 Workbench 重挂载。相比“加一个更漂亮的 loading”,这是更扎实的优化。
SPA 没有取消 API 的鉴权边界
/app/* 的 index.html 是静态文件,服务器返回它时不会判断用户是否有权限看到某个项目。真正的边界由客户端入口和 API 一起承担。
ProtectedRoute 等待 Clerk 状态完成;除 /app/home 外,未登录用户只看到 fallback,随后回到登录页。全局 fetch 拦截器会为同源 /api/* 注入 Bearer token 和 x-lumen-locale。
遇到 401 或 403 时,代码也没有因为一次失败就立刻把用户踢走。Clerk 快照明确为未登录时会直接判定 signed-out;如果 Clerk 仍显示登录,但连续两次 /api/me 都返回 401/403,也会判定退出。单次鉴权失败、网络异常或其他状态只返回 unknown,不会触发跳转。这是在防止 token 短暂未就绪或单次网络错误被误判成登出。
这意味着 SPA 化以后仍然存在这些真实往返:
- Clerk SDK 的首次会话初始化;
- 每个未命中缓存的业务 API;
- 登录后延迟 2.5 秒发起的
/api/me检查; - 每 60 秒以及窗口重新聚焦、恢复可见时的
/api/me心跳; - Canvas 项目详情、工作流状态、上传和 WebSocket 通信。
客户端路由可以隐藏页面,不可能代替 API 对 owner、session 和 token 的校验。静态 SPA 壳可以公开,私有数据不能因此变成公开接口。
当前 bundle 说明 Vite 也不是轻装通行证
我用当前 main 的 162f49b 在本地执行了一次 Vite 7.3.5 production build。构建转换了 8060 个模块。下面是 Vite 报告的原始大小和 gzip 估算,不是线上网络实测:
| 产物 | 原始大小 | gzip 估算 |
|---|---|---|
| 初始 entry + 6 个 modulepreload JS | 约 671 KB | 约 211 KB |
| 全局 CSS | 348.50 KB | 53.45 KB |
CanvasWorkbench |
259.86 KB | 71.27 KB |
canvas-vendor |
176.67 KB | 57.67 KB |
media-vendor |
732.70 KB | 188.05 KB |
sentry-vendor |
473.86 KB | 156.81 KB |
初始 HTML 会 preload React、Router/Query、Mantine、Icons、Clerk 和 Motion。Sentry.init() 本身在 requestIdleCallback 或延时回调中执行,不阻塞根节点挂载;但 CanvasWorkbench 静态依赖 sentry-vendor,零延时全路由预热仍可能较早下载这个 chunk。Canvas 和媒体代码虽然有独立 chunk,也不等于它们会一直等到用户点击才加载。
但前面提到的“挂载后预热所有主要路由”会很快把部分重 chunk 拉下来。这条 SPA 路径移除了应用内导航对服务端路由响应的依赖,也形成了更清晰的 chunk 边界,却没有自动消除 JavaScript 成本。当前策略是在“首次进入后立刻补下载”和“第一次点 Canvas 时再等待”之间,明确选择了前者。
构建还有两条提示:CanvasRoute 和 CanvasEntryLoader 同时被静态与动态 import,因此对应的动态 import 不会再把它们移动到独立 chunk。这不影响正确性,但提醒我不能只看源码里有没有 import(),还要看最终产物是否真的发生拆包。
这些数字只说明代码边界和下载成本,不能替代点击到可交互内容的前后对照。当前仓库没有导航 RUM 或基准测试,因此文章能确认“哪段链路被移除”,不能给出提速百分比。
发布链路还有一个原子性缺口
Next.js 产物已经使用版本目录:先构建 .next-build-<release>,全部成功后再把 .next-current 软链接原子切过去。SPA 还没有同等级别的保护。
pnpm build:app 直接写固定的 apps/lumen-app/dist。正在运行的旧 lumen-studio 进程每次请求都会从这个目录读取 index.html 和 hash 资源。于是部署期间可能出现三种中间状态:
- Vite 清空旧
dist、新文件尚未写完时,线上静态资源短暂缺失; - 新 SPA 已经被服务器读到,但 Next.js、Agent 或 Engine 仍是旧版本;
- 后续任一构建失败,新 SPA 已经暴露,部署脚本却没有对应的激活或回滚动作。
这里甚至有一个生命周期假设冲突:Node Server 在启动时只检查一次 dist 是否可用,注释假设运行期间目录不会变化;部署脚本却会在旧进程仍提供请求时原地改写同一个目录。
这比继续压缩几十 KB 更值得先修。更稳妥的做法是把 SPA、Next、Agent 和 Engine 都构建到同一个 releases/<release> 根目录,完成类型检查和 smoke test 后,只原子切换一次 .release-current,再 reload 进程。旧 release 至少保留一份,回滚也只需要把这一个入口切回去。
只有当前端产物和它依赖的 API 作为一个 release 被激活,这套混合架构的性能收益才不会换来新的发布不确定性。
