Remix开发实战中遇到的路由加载与数据获取优化经验
项目初期的技术选型
去年接了个内部管理后台的重构,需求挺典型:要支持多角色权限、带表单校验的数据录入页、带搜索/分页的列表页,还得能离线缓存部分静态资源。一开始团队里有人提 Next.js,但我翻了翻设计稿——全是 CRUD + 表单 + 基础路由跳转,没 SSR 渲染 SEO 的诉求,也没复杂客户端状态同步。这时候 Remix 冒出来了:它强调“服务端优先”、“错误边界天然隔离”、“表单提交零 JS 也能工作”,我试跑了个 demo,发现 useFetcher 处理局部提交比 React Query 手动 mutate 省心太多,就拍板了。
最大的坑:性能问题
上线前压测,首页 TTFB 300ms 左右,但首屏渲染卡顿明显,Lighthouse 分数掉到 48。我原以为是组件太重,结果发现是 loader 里一个 await db.query 拖慢了整个请求链路——而这个查询其实只用来渲染页脚的用户统计卡片,和主体内容完全无关。
开始没想到 Remix 的 loader 是“全页面阻塞式”的,不像 Next 的 getServerSideProps 可以拆成多个异步块。我试过把页脚数据挪到 client side 用 useEffect + fetch 拉,但这样就丢了 Remix 的核心优势:服务端可降级、SEO 友好、无水合错位。折腾了半天发现,得靠 defer + useDeferredValue 组合拳。
最终的解决方案
我把 loader 改成返回一个 DeferredData 对象,把主数据和页脚数据分开 resolve:
import { defer } from '@remix-run/node';
export async function loader() {
const mainData = await db.getPosts({ limit: 10 });
const footerData = db.getStats(); // 注意:这里不 await!
return defer({
posts: mainData,
stats: footerData, // 这个 Promise 会延迟 resolve
});
}
然后在组件里用 useDeferredValue 和 React.Suspense 套一层:
import { useLoaderData, Suspense } from '@remix-run/react';
export default function Index() {
const { posts, stats } = useLoaderData();
const deferredStats = useDeferredValue(stats);
return (
<div>
<main>
{posts.map(post => (
<article key={post.id}>{post.title}</article>
))}
</main>
<footer>
<Suspense fallback={<span>Loading stats...</span>}>
<StatsCard stats={deferredStats} />
</Suspense>
</footer>
</div>
);
}
function StatsCard({ stats }) {
const data = stats; // 这里 stats 已 resolve
return <p>Total users: {data.count}</p>;
}
效果立竿见影:首屏 HTML 在 120ms 内返回,主体内容立刻渲染,页脚等几毫秒后才补上。Lighthouse 跳到 86。这里注意我踩过好几次坑:第一次忘了在 Suspense 外层包 deferredStats 的解构必须放在组件顶层(不然会触发重新 render),第二次是误用了 useTransition 替代 useDeferredValue,结果页脚一直 fallback 不出来——useTransition 是为导航准备的,不是给 loader 数据用的。
表单处理:比想象中顺
表单这块反而没怎么折腾。Remix 的 action + useActionData 配合表单原生提交,让很多边界场景自动消失。比如我们有个「批量导入用户」的表单,需要上传 CSV 并预览解析结果。我直接用 FormData 接收文件,在 action 里 parse 后返回结构化数据,loader 里再读一次 session 获取上次结果 —— 完全不用管 loading 状态、错误提示、重复提交这些事,都由 Remix 自动兜底。
唯一小遗憾是文件上传进度条得自己搞。我试过在 action 里用 createUploadHandler 配合 onProgress,但 Remix 默认不支持流式响应。最后妥协方案是:前端用 XMLHttpRequest 单独上传,成功后再发普通 POST 触发 action 刷新页面。不算优雅,但不影响主流程,就没深挖。
部署时发现的冷知识
我们用 Node 部署,但上线后发现某些 API 调用超时。查日志发现是 Remix 默认的 server.ts 里 createRequestHandler 没配 timeout,导致长查询卡住整个 worker。后来加了一行:
import { createRequestHandler } from '@remix-run/express';
app.all(
'*',
createRequestHandler({
getLoadContext() {
return {};
},
mode: process.env.NODE_ENV,
future: {
v3_singleFetch: true,
},
})
);
// 在 express 中加 timeout
app.use((req, res, next) => {
req.setTimeout(30_000); // 30s
res.setTimeout(30_000);
next();
});
另外,v3_singleFetch 这个 flag 必须开,否则多个 loader 会发多次请求,我们之前没开,首页加载时看到 Network 面板里刷出 5 个 /api/* 请求,吓一跳。
回顾与反思
整体下来,Remix 最让我踏实的是它的“确定性”:每个路由的 data flow 都写在 loader/action 里,没有隐藏的状态同步逻辑;错误边界天然隔离,A 页面崩了不影响 B 页面;表单提交失败时,页面不会白屏,而是原样保留用户输入并展示错误信息——这点在内部系统里特别重要,运营同学根本不管什么是 hydration error。
当然也有不完美的地方:文档对 defer 的使用场景写得比较隐晦,官网 demo 全是简单例子;TypeScript 类型推导有时不准,比如 useLoaderData 返回类型得手动加泛型;还有就是调试 loader 里的数据库调用,得靠 console.log + 日志轮询,没 Next 那种 DevTools 的 server-side trace 好用。
不过这些都不影响它成为我当前最愿意推荐给新项目的框架——尤其是那种“不需要炫技、只求稳、要快速上线”的业务系统。如果你也在评估 SSR 方案,Remix 值得你花两天时间跑通一个真实 CRUD 流程。亲测有效。
以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多,比如配合 useFetcher 实现无限滚动、或者用 redirect 做权限拦截,后续会继续分享这类博客。有更优的实现方式欢迎评论区交流。
