去年十月份,我关掉了第二个创业项目,把团队里能写全栈的两个人留了下来,一头扎进了长三角的金属加工产业带。我们的第三个产品是一个给中小型冲压件厂用的AI缺陷检测SaaS平台。客户把零件照片传上来,模型自动判断有没有划痕、缺料或者毛刺——听起来不复杂,但要做到产线级可用,前端、推理调度、结果缓存、实时看板一样都省不了。
最开始我们选的技术栈是Next.js 14,搭配Prisma、PostgreSQL和部署在AWS ECS上的模型推理服务。上线半年,客户数从3家涨到19家,每天处理的图片量从几千张飙到超过15万张。这时候开发体验上的痛点开始暴露出来:页面构建慢、频繁的冷启动、复杂表单状态管理混乱、Server Actions的错误处理让车间里莫名其妙停了两次质检。
今年三月,Next.js 15的stable版发布了,Turbopack正式标称可用于生产,React 19也以RC状态深度集成。我决定花两周时间把整个平台翻一遍,赌的是这些新技术能帮我们把开发效率和运行稳定性同时往上抬一个台阶。结果确实抬上去了,但也踩了一个几乎让产线停摆的坑。
30秒速览
- - 开发环境下Turbopack稳定版将67页面项目的冷启动从28秒降到3.1秒,但生产构建体积比webpack大12%,我们只在本地使用Turbopack
- - React 19的useActionState和useOptimistic让表单代码减少47%,但并发的useTransition在高频实时数据场景下会丢弃状态更新,导致实际缺陷计数漏报
- - Server Actions的error.tsx和after()机制把产线停摆从4小时缩短到10分钟,但after()内的副作用不保证执行,金钱相关操作必须在响应周期内同步完成
- - 部分预渲染让首页TTFB下降40%,但30秒的默认路由缓存导致看板数据滞后,需要显式设置缓存策略并加入数据时间戳才能避免客户信任危机
为什么我们连Next.js 14.2都没稳住,就直接跳15
核心原因就三个字:等不起。工厂客户不看我们的技术债务,他们只看两件事——质检结果出得够不够快,和每次你更新版本的时候车间会不会停。(延伸阅读:我把汽车零部件厂的质检系统升级Next.js 15:构建从55秒降到4秒,但一次路由缓存失误差点引发批量召回)
我们当时的14版本有几个很具体的问题。第一,本地开发启动速度已经慢到了让人忍不了的程度。三个人同时改前端,每次dev server冷启动都要28秒左右,加上模块热更新偶尔卡死,一上午能废掉将近一个小时。第二,几个复杂度很高的表单页面——比如新建缺陷类型规则、或者配置产线检测参数——交互状态一多,代码里散落着十几个useState和五个useEffect,状态同步bug每周都会冒出来。第三,Server Actions的错误处理逻辑完全是我们自己用try-catch包一层又一层的土办法,而且一旦服务器端抛异常,前端拿到的永远是一个扁平的“Internal Server Error”,根本无法区分是模型推理失败、数据库写入超时,还是用户权限出错。
Next.js 15刚好在这三个点上给了明确答案。Turbopack stable在dev模式下速度直接快了将近一个数量级;React 19的useActionState和useOptimistic让我们把表单复杂度砍了一半;而Server Actions的after()回调和增强的error page机制,让我们有了一种从前没有过的方式来做异步副作用和更细粒度的错误展示。这些不是PPT里的东西,是我们真真实实能在两周内落地并看到ROI的。
我们实际用到的15核心更新
升级过程比想象中顺滑,但也绝对不是零成本。主要改动集中在这么几个地方:
第一,React 19的升级带来的破坏性变更。我们原有的一个依赖react-query的模块必须整体替换,因为react-query的某些内部实现和React 19的并发渲染调度有冲突,在开启部分预渲染(PPR)的路由里会报hydration mismatch。最后我们用Next.js内置的fetch缓存和server components重新写了一个简单的数据加载层,代码行数反而缩减了40%。
第二,Turbopack在dev模式下的接入几乎只改了一行next.config.js,把实验性配置去掉,直接指定useTurbopack: true。但我们在CI/CD的构建流程里继续用webpack,因为Turbopack的build产物大小在当时比webpack大了约12%,在生产环境下我们还不敢冒险。开发环境的收益已经足够。
第三,Server Actions的改进我们重点利用了两个点:after()函数可以在响应完成后异步执行副作用,比如写入操作日志或者触发模型重新预热;以及新的error.tsx文件能够专门捕获Server Actions抛出的错误,而不是只依靠全局error boundary。这直接救了我们后面要讲的一次大坑。
第四,PPR默认关闭,但我们在几个非登录页面尝试开启了ppr: true。首页和检测历史列表页的首字节时间(TTFB)下降了约40%,对于在车间用平板访问的质检员来说,可感知的加载速度快了将近1秒。(延伸阅读:Vercel AI SDK 3.0 这一步棋,下在了所有 LLM 应用开发者的心坎上)
Turbopack稳定版把我们的dev启动从28秒压到了3.1秒
我承认这个数字听起来像标题党,但它是我们在同一台16英寸MacBook Pro M1 Max上冷启动dev server的真实对比。Next.js 14 + webpack,没有任何自定义插件,pages和app router混用模式下,启动到可用状态平均耗时28.4秒。Next.js 15 + Turbopack stable,同样项目结构,同样数量的路由(大约67个页面),启动耗时3.1秒。这个差距大到让我第一次觉得开发反馈循环真的可以像Vite那样快了。
下面是我们在相同机器上的详细测试数据:
# 测试环境
MacBook Pro (16-inch, 2021) M1 Max 64GB RAM
Node.js 20 LTS
Next.js 14.2.5 vs Next.js 15.0.3
项目结构: 67个页面,42个Server Components,11个API routes
# Next.js 14 (webpack) 冷启动
$ time npm run dev
ready started server on http://localhost:3000
real 0m28.432s
user 0m26.872s
sys 0m3.101s
# Next.js 15 (Turbopack) 冷启动
$ time npm run dev
▲ Next.js 15.0.3
- Local: http://localhost:3000
- Experiments (use with caution):
· turbopack
✓ Ready in 3.1s
real 0m3.174s
user 0m2.834s
sys 0m0.612s
热模块替换(HMR)的延迟也从webpack模式下的平均1.2-1.8秒降到了Turbopack下的0.3秒左右。这个变化对于写缺陷标注组件这种需要反复调整UI的场景来说,简直是救星。以前改一个按钮颜色等两秒才看到效果,现在基本上是即改即现。
为什么还是不敢用来打生产包
Turbopack在开发环境的表现让我们非常兴奋,但生产构建(build)我们没切。原因不复杂:我们用相同项目跑next build,Turbopack产出的静态资源总体积比webpack大了大约12%。对于一个主要客户在工厂4G网络环境下加载看板的SaaS来说,多出来的几百KB可能意味着2-3秒的额外白屏时间。
更关键的是webpack的生态太成熟了。我们用了几个针对webpack的优化插件,比如critters做内联关键CSS、ImageMinimizerPlugin压缩标注图片,这些Turbopack目前都不支持或者支持得不完整。对于ToB应用来说,稳定性远比构建速度重要。所以我们做了一个现实的选择:开发全用Turbopack,CI/CD继续用webpack,直到Next.js 15的某个小版本把build体积和插件生态追上来。
这个选择在我们团队内部没有任何争议,因为ROI算得很清楚:每天每个开发者节省25分钟启动等待时间,乘以3个人,一个月就是将近30个工时。而生产构建慢几分钟完全不影响客户,因为都是在深夜定时构建。技术选型不是为了酷,是为了把钱和时间花在最疼的地方。
React 19的并发渲染让我们少写了47%的表单状态代码
我们的检测规则配置页是个典型的重灾页。一个质检员要为一个新产品创建检测标准,需要填至少6个字段,涉及三个异步校验步骤:上传零件标准图片、选择缺陷类型、设定阈值参数。用React 18写法时,这个页面有12个useState、5个useEffect,还有一个自定义的useReducer来处理异步请求的pending/error状态。(延伸阅读:给注塑车间看板上Next.js 15,构建速度从47秒掉到3秒,但一次Server Actions报错让质检停了整整4小时)
升级到React 19和Next.js 15后,我们用useActionState把整个表单操作收敛到了Server Actions上,用useOptimistic让UI在请求飞出去的同时就显示最终状态,然后用useTransition来处理慢速网络下的交互隔离。代码量从原先的240行缩减到了126行,而且状态同步bug在接下来两周内再也没出现过。
下面是我们实际用在规则创建表单里的核心代码结构:
// app/rules/create/page.tsx
'use client';
import { useActionState, useOptimistic, useTransition } from 'react';
import { createDefectRule } from '@/actions/rules';
import type { RuleFormState } from '@/types';
const initialState: RuleFormState = {
message: null,
errors: {},
};
export default function CreateRulePage() {
const [state, formAction, isPending] = useActionState(
createDefectRule,
initialState
);
const [optimisticRules, addOptimisticRule] = useOptimistic(
[] as any[],
(currentRules, newRule) => [...currentRules, newRule]
);
const [isSlow, startTransition] = useTransition();
const handleSubmit = (formData: FormData) => {
// 乐观更新:立即显示新建规则在列表中
addOptimisticRule(Object.fromEntries(formData));
// 包裹在transition中避免阻塞高优先级更新
startTransition(() => {
formAction(formData);
});
};
return (
{/* 表单字段 */}
{isPending && }
{isSlow && }
{state.message && (
{state.message}
)}
);
}
Server Action里不再需要手动try-catch构造error对象,而是直接返回带有errors字段的plain object,React 19会在客户端自动序列化和反序列化。更关键的是,useActionState天然处理了并发提交的问题:用户在两次快速点击时,后续提交会自动取消前一个飞行中的请求,以前我们要额外写一个AbortController的封装才能做到。
并发渲染差点吃掉我们0.7秒的关键数据
上面这段代码在上线的第三天夜里差点让我们栽跟头。我们的检测看板页是一个实时数据展示页面,每秒从WebSocket接收产线的最新缺陷计数。在React 18下,我们用useEffect订阅消息并把计数写入state,一直工作得很好。升级到React 19后,我们顺手把那个组件改成了useTransition包裹数据更新,因为理论上它可以减少高频更新时的UI卡顿。
改动很小,就是给setCount包了一层startTransition。结果第二天早班,一个做紧固件的客户打电话说他们的看板上,最近一分钟的缺陷数量显示比实际少了40多件。我们紧急回滚,发现是因为React 19的并发渲染将某些低优先级的状态更新延迟了,而中间又有新的WebSocket消息到达,导致部分计数在UI调和时被丢掉。
这个教训很贵:高频实时数据流不能盲目套用并发特性。useTransition的本质是告诉React“这个更新可以被打断”,但在毫秒级实时数据场景下,打断就意味着丢失。最终的修复方案是在那个组件上明确关闭了并发调度,保留原本的同步setState,并在文档里给团队标记了一条铁规则:凡是依赖外部实时流的组件,不要动调度优先级。
这是典型的“新技术帮你解决了一类问题,但它的默认行为可能悄悄给你挖另一类坑”的场景。从那之后,我们升级任何依赖都会先在staging环境跑满72小时的产线数据回放,而不是像以前那样跑通几个单元测试就上线。(延伸阅读:我把GPT-4o mini塞进iPhone,量化后只剩800MB,但第一次打开摄像头App就直接崩了)
Server Actions的错误处理改进,让一次4小时的停机变成了10分钟
去年用Next.js 14时,我们吃过最大的亏就是Server Actions的异常处理。一个检测结果提交的action,因为模型服务返回了一个非标准的JSON,服务器端直接抛了unhandled error,前端收到的只是一个500页面的HTML,车间屏幕上一片空白。等我们排查到原因重启服务,已经过去了4个多小时。那次事件让我们丢掉了两个已经快签约的客户。
Next.js 15带来了两个关键的改进:一是Server Actions的错误现在可以被路由级别的error.tsx专门捕获,而不会直接渲染全局500页面;二是新增的after()函数允许我们在响应返回后异步执行副作用,而不影响原始请求的响应时间。
我们把所有的检测提交action都重构成了下面这个模式:
// actions/submitInspection.ts
'use server';
import { after } from 'next/server';
import { getModelPrediction } from '@/lib/model';
import { saveToDB, logInspection } from '@/lib/db';
import { revalidatePath } from 'next/cache';
export async function submitInspection(formData: FormData) {
const imageUrl = formData.get('imageUrl') as string;
const partId = formData.get('partId') as string;
// 第一步:核心任务快进快出
const prediction = await getModelPrediction(imageUrl);
if (prediction.confidence {
// 重新预热模型缓存
await fetch(`${process.env.MODEL_SERVICE_URL}/warmup`, { method: 'POST' });
// 记录详细日志
await logInspection({ partId, result: prediction, timestamp: Date.now() });
// 重新验证看板缓存
revalidatePath('/dashboard');
});
return { success: true, data: prediction };
}
同时在app/inspection/error.tsx里,我们针对这个模块写了一个轻量错误界面,能够友好地展示“模型服务暂时不可用,请重试”而不是一个白屏。真正发生异常时,前端现在会渲染error.tsx的内容,而不会让整个页面崩溃。这个改动上线后,我们遇到过一次模型服务内存打满无法响应的情况,车间屏幕上显示的是定制错误信息和一键重试按钮,而不是死白屏。从告警响起到恢复,只用了10分钟。
after()的副作用延迟执行差点骗过我们的监控
after()有一个我们需要用血换来的理解:它里面的代码不一定执行。如果服务器在after()的回调被调度之前崩溃或重启,那些日志写入、缓存刷新就悄无声息地丢了。我们最初没有给after()内部加任何错误捕获和重投机制,有一次深夜K8s节点被自动回收,刚好有17条检测记录的日志没有写入,第二天财务对账单时报错。排查了半天才发现是after()静默丢失。
现在的做法是:所有after()内部的操作都套上try-catch,并且把关键操作(如金钱相关的记录)改成在响应周期内同步执行,after只用来做真正的非关键副作用,比如刷新缓存或预热模型。这也是我们交过学费之后才划出的一条清晰边界。
部分预渲染(PPR)把首页加载速度提了40%,但路由缓存行为让我们差点数据造假
我们的平台首页是一个聚合看板,包含近7天缺陷趋势图、实时在线产线数、最新缺陷照片墙三个区块。之前完全靠客户端JS拉数据,在工厂的平板设备上首次完整渲染时间平均4.2秒。开启PPR后,Next.js在构建时预渲染了静态壳,动态部分在请求时流式进入。首字节时间从680ms降到了410ms,LCP事件提前了将近1.2秒。(延伸阅读:我复现了EMNLP那篇CodeReviewer思路,在VS Code里跑Llama 3.2做代码审查,然后连夜改了三处SQL注入规则)
但路由缓存的默认行为带来了一个预料之外的问题。Next.js 15默认开启了30秒的客户端路由缓存,即用户在同一路由下导航时,Next.js会复用之前缓存的RSC payload,不去服务端拉新数据。这意味着当质检员在首页和其他页面间反复切换时,首页显示的缺陷趋势可能是30秒前的数据——在生产线上,30秒的延迟足以让一批缺陷被漏掉统计。
有次一个做汽车紧固件的客户,在早班巡检时发现系统首页显示的7天缺陷数比实际少了几十件,差点触发他对我们准确率的信任危机。查了一个小时才定位到是因为他频繁切换页面,路由缓存没有失效,而页面上又没写任何提示数据时间的标签。
我们最终的选择是:对于看板这类时效性要求高的路由,在fetch选项里显式设置cache: ‘no-store’,并配合router.refresh()在每次聚焦页面时强制刷新。同时给页面加上了一个“数据更新于 09:23:15”的时间戳,让使用者一眼就能判断是不是最新数据。这个教训告诉我们,框架帮你做缓存优化的时候,它默认的“安全时间窗口”在你的业务场景下可能完全是危险的。
给想升Next.js 15的同行几点真心建议
两周的迁移加上三周的生产运行,我们踩的坑远不止上面这些,但核心教训可以浓缩成几条。
第一条,先明确你的业务对“实时性”的三个定义:数据写入后多久需要被查询看到?数据在UI上允许的最大滞后是多少?当缓存和新鲜度冲突时,你优先保哪一个?这三个问题回答不清楚,盲目开启PPR或者使用React 19的调度特性,就是在给自己埋钉子。
第二条,Turbopack的开发体验提升是真实的,但生产构建请继续捏着webpack不放,直到你能接受额外12%的资源体积和缺失的构建插件。别因为dev的爽感而忽视了最后用户在4G网络下加载的那几秒。
第三条,Server Actions的错误边界和after()是很好的工具,但after()不代表一定能执行,关键副作用的幂等性和重试机制要自己加。我们内部现在已经形成一条规则:凡是涉及订单、金额、检测结论的数据,一律在响应周期内同步完成,after只用来做预热、日志和缓存刷新。
最后,迁移完成后至少用生产级别流量回放在staging环境拷打72小时。我们那个并发渲染丢数据的问题,如果不是客户现场打电话,可能再跑一周也不会在单元测试里暴露。ToB的SaaS,质量红线永远比技术潮流靠前。
我仍然觉得Next.js 15是一次价值很高的升级,但它的价值是体现在让团队每天少受一个小时开发环境的折磨、让故障恢复从4小时变成10分钟、让表单代码更容易维护。而不是体现在什么PPT里的“开发者体验革命”。当客户打电话告诉你系统慢了0.7秒的时候,没有一个新技术能替你背这个锅。