给注塑车间看板上Next.js 15,构建速度从47秒掉到3秒,但一次Server Actions报错让质检停了整整4小时

我叫沈青锋,现在做的事,是用AI去啃制造业里最脏最累的活儿。我们给江浙一带的注塑工厂做了一套实时生产监控与AI质检系统,覆盖了20条产线,每秒钟几千个传感器数据点,还有几十路摄像头在做外观缺陷的模型推理。去年下半年,这套系统的前端和后端还趴在Next.js 14上,用着Webpack,开发体验就像在泥浆里跑步。今年初我们把整个项目切到了Next.js 15,Turbopack稳定版一上,打包时间断崖式下跌,React 19的新范式让很多页面的代码量缩减了三分之一,但在上线第三周,一个Server Actions的边界情况直接让整个质检看板白屏了四个小时,车间主任的电话差点把我的手机打炸。这篇文章不是技术评测,而是我在真实生产环境里,用真金白银、停产损失和客户信任换来的几个血淋淋的经验。

30秒速览

  • - Turbopack将构建时间从47秒降到3秒,但迁移时需要处理自定义Webpack Loader和旧图标插件的兼容问题,整体CI加速有限。
  • - React 19的use()和Server Actions极大简化代码,但use()在服务端闭包下可能导致数据快照不一致,Server Actions的未捕获异常会引发白屏和重复提交。
  • - PPR让看板首屏从2.8秒砍到0.6秒,但拆分静态/动态边界时要扔掉原有的懒加载策略,重新设计缓存粒度。
  • - 这次升级最终带来了可衡量的性能收益和工人操作效率提升,但因为一次Server Actions事故导致停产4小时,直接亏掉前面所有的ROI。

从47秒到3秒的诱惑:我们为什么非要立刻切Turbopack

我们这项目最让开发团队抓狂的,不是并发、不是模型推理延迟,而是前端构建速度。应用里有接近200个页面,涉及大量的图表组件、实时WebSocket数据流和自定义的Canvas渲染逻辑。在Next.js 14上用Webpack,全量构建平均耗时47秒,热模块替换经常卡到怀疑人生。前端同事每次改一行css,都要站起来接杯水。后来我们在文档里看到Next.js 15把Turbopack标为稳定,立刻决定在内部分支上做一次完整的迁移评估。

迁移过程比想象中顺滑,但依赖地狱差点让我们翻车

升级Next.js版本到15.0.0-rc.0很简单,把next.config里的webpack配置清空,启用experimental.turbo,然后删掉一堆已经不再需要的babel插件,过程比预想的顺畅。Turbopack在开发模式下的启动时间,从原来Webpack的8秒降到1秒出头,HMR几乎无感。最让我震惊的是生产构建:同样的200页应用,turbo打包只用了3.2秒,而之前Webpack需要41-47秒。当时我们在会议室投屏跑了一次,全组沉默五秒钟,然后集体鼓掌。

但真正部署到预览环境时,问题来了。我们有一个自定义的Webpack loader,用来在构建时把产线工位的静态配置注入到页面中,这个loader完全不符合Rust的插件体系。此外,我们用到了svg-sprite-loader,Turbopack不支持它,所有图标全部挂掉。我们试过一个方案,用next.config的webpack配置在turbopack模式下做部分降级,但官方明确说明两者不能并存。最后我们不得不忍痛把那个静态配置改成构建前用Node脚本生成一个JS模块,才勉强绕过。图标问题则全部迁移到@svgr/webpack的替代方案,用了一个社区插件,花了整整两天调试。这就是典型的“技术债务在升级时集体讨债”。(延伸阅读:GitHub Copilot Chat的上下文感知就像论文里的RepoCoder,但生产环境里它用了一套让索引工程师沉默的捷径

构建变快,不代表交付变快——部署管道的另一块短板

打包快了,但CI/CD环节并没有得到相同的加速。我们用的是GitHub Actions的免费runner,构建前需要先下载2GB的基础镜像、安装依赖、跑lint、跑单元测试。Turbopack只优化了其中的“next build”这一步,从47秒削减到3秒,但整体CI流水线只快了不到30秒。后来才发现,瓶颈出在node_modules的缓存和TypeScript类型检查上。我们不得不额外投入时间去拆分monorepo,把类型检查并行化,才把部署流程从8分钟压缩到5分钟。如果不是为了向客户承诺的“紧急修复15分钟上线”,我们可能根本不会去动这一块。所以,Turbopack这个快感,如果不搭配整个交付链路的梳理,最后就只能是开发者的自嗨。

React 19在产线看板里闯的祸:use()把我们用了三年的状态管理干掉了,但服务端闭包差点让数据失真

切到Next.js 15之后,我们自然开始尝试React 19那些新出的API,最让我们眼馋的就是use() hook。它能直接在客户端组件里“读”一个Promise,而不用再包裹Suspense边界或者写一堆effect。这玩意儿在我们看板的数据加载场景中简直是降维打击。

我们有一个工单实时完成率的组件,原来需要用一个redux saga去管理三步异步请求:先拿工单列表,再拿每个工单的实时产量,最后计算完成率并用WebSocket订阅变更。整个状态树加上saga代码超过400行。用use()改造后,我们把请求逻辑抽到一个服务端函数里,直接返回Promise,客户端组件简单到只有三十几行。(延伸阅读:Copilot for Azure省下了$21,000,我却连夜删掉了它的“闲置回收”自动化——一个5年投资顾问的技术账

// 改造后的工单完成率组件,use()简化异步数据流
import { use } from 'react';
import { getWorkOrderStats } from '@/app/actions/work-orders';

export function WorkOrderCompletion({ orderId }: { orderId: string }) {
  const stats = use(getWorkOrderStats(orderId));
  
  return (
    

{stats.productName}

目标产量:{stats.target}

当前完成:{stats.completed}

完成率:{((stats.completed / stats.target) * 100).toFixed(1)}%

); }

开发阶段跑得完美,数据加载很快,我们甚至把原本依赖客户端的WebSocket推送也改成了服务端使用fetch长轮询配合React的流式渲染,一切看起来都很“现代”。然而上线第二天,车间主任发现,同一个工单在不同操作员的屏幕上显示的完成数字不一样。查了一个多小时,最终定位到use()在服务端渲染时,如果Promise依赖了闭包变量,可能会产生快照不一致的情况。我们的getWorkOrderStats内部缓存了一个数据库连接,在多次请求间意外地复用了已经过期的快照,导致A用户看到的是30秒前的数据,B用户看到的是最新的数据。这个问题在react文档中其实有提到“不要在渲染期间产生副作用”,但在实际项目里,把复杂的服务端逻辑塞进Server Actions再丢给use(),很容易就把副作用带进去了。最后我们给数据函数加了一个简单的请求级缓存清除标记,才彻底解决。

更疼的一个坑,是useFormStatus和Server Actions搭配时,错误处理回退的问题。(延伸阅读:我在Jetson Orin上压测DeepSeek-V3:代码生成吞吐翻倍,但真实机械臂延迟抖动让抓取失败43次

Server Actions改进的甜蜜点,与错误处理的致命疏忽

Next.js 15对Server Actions做了很大的增强,特别是错误序列化和边界处理。我们很自然地用Server Actions来替代原来那套API Route + 手动fetch的方案,比如工人提交质检记录、班组长审批异常流程。代码确实清爽了无数倍,一个form action就能搞定原来需要前后端各写一堆的CRUD。

质检提交的Server Action,一个未捕获的SQL异常让产线停摆

我们的质检页面有一个很重的表单,需要同时上传缺陷图片、填写缺陷类型、记录模具温度和注射压力等信息。我们在Server Action里做了大量的数据库写入,包括更新质检记录、同步到MES系统的临时表、以及推送一条通知到钉钉。这个逻辑复杂,但用Server Actions实现起来也就是一个异步函数。

// submitInspection.ts – 一个简化的Server Action
'use server';

import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';

export async function submitInspection(formData: FormData) {
  const orderId = formData.get('orderId') as string;
  const defectType = formData.get('defectType') as string;
  const imageFile = formData.get('image') as File;
  
  // 存储图片到OSS
  const imageUrl = await uploadToOSS(imageFile);
  
  // 写入质检记录
  await db.inspection.create({
    data: {
      orderId,
      defectType,
      imageUrl,
      operatorId: getCurrentUser().id, // 这里隐式依赖当前登录态
    },
  });
  
  // 更新MES临时表,这个操作偶尔会因为网络抖动失败
  await syncToMES(orderId, defectType);
  
  revalidatePath(`/orders/${orderId}/inspections`);
  return { success: true };
}

上面这个Action在测试环境跑了上百遍都没问题。直到一个夜班,MES服务器的网络出现了间歇性丢包,syncToMES函数连续三次重试后抛出了一个未捕获的异常。Next.js 15的Server Action在遇到未捕获异常时,并没有像我们预期的那样,把错误序列化后返回给客户端处理,而是直接返回了一个通用的500错误,同时把整个响应流截断。更要命的是,我们前端的useFormState没有正确处理这个错误状态,导致页面一直在加载态,操作员以为系统卡死,连续刷新,结果造成同一条质检记录被重复提交了六次,而MES那边还没收到任何数据。(延伸阅读:我把汽车零部件厂的质检系统升级Next.js 15:构建从55秒降到4秒,但一次路由缓存失误差点引发批量召回

更严重的是,revalidatePath在那个异常之前已经执行了一部分,导致缓存状态和数据不一致,看板上的缺陷数量瞬间错乱。车间主任看到屏幕上显示“0缺陷”,但实际产品已经堆满了废料框,直接拉停了整条产线。这次事故总共持续了4个小时,损失了超过六万个塑料件的产量。

错误边界与渐进增强:我们是怎样修补这个漏洞的

事故复盘后,我们做了三件事:第一,在所有Server Actions的入口处包裹了统一的try/catch,并用next-safe-action这样的库来规范化返回错误对象;第二,前端强制使用react-hook-form的progressive enhancement模式,并显式处理error状态,不让UI停留在loading态;第三,把会触发外部调用的操作从Server Actions中剥离,改用队列异步处理,Server Action只负责写入自己的数据库并返回确认。虽然代码量又多了回来,但整个系统的容错能力不再是纸糊的。

这次事故让我想明白一件事:Server Actions虽然简化了开发模型,但它把原本暴露在API层面的错误边界,藏到了一个黑盒里。如果你不刻意去设计错误序列化,它就会在用户面前爆炸,而且爆炸的方式往往是不可观测的。(延伸阅读:Vercel AI SDK 3.0 这一步棋,下在了所有 LLM 应用开发者的心坎上

部分预渲染(PPR)把看板首屏速度推向极致,但也暴露了我们架构里的懒加载反模式

Next.js 15的PPR是我认为最能带来用户体验质变的一个特性。我们的生产看板首页是一个典型的仪表盘,包含实时产量趋势图、设备状态网格和告警列表。之前使用的是完全服务端渲染,首次加载接近2.8秒,用户看到的是白屏。启用PPR后,我们将页面拆分为静态外壳和动态岛屿,静态部分(导航、布局、站点标题)在构建时预渲染为静态HTML,动态内容(数据卡片、图表)则以流的形式渐进式注入。首屏内容可见时间从2.8秒降到了0.6秒。

静态外壳的“过度优化”陷阱

在实现PPR时,我们想当然地把所有不包含数据的UI组件都打成了静态部分,比如设备状态网格的骨架屏、告警列表的容器。但问题来了,我们原本在前端用了大量的dynamic import配合React.lazy做客户端懒加载。这些懒加载组件的边界和PPR的静态/动态边界产生了冲突:某些组件被标记为静态,但内部又通过dynamic导入了一个客户端图表库,导致在构建阶段就出现了模块解析错误。我们试了三天不同的调整方案,最后把懒加载全部去掉,改成用Suspense包裹动态导图,并且只对真正的第三方重型组件(如ECharts)使用next/dynamic的ssr: false。页面整体加载反而更快了,因为减少了JavaScript块的初始下载量。

另一个教训是,PPR的缓存策略与我们原来基于page级revalidate的方案完全不是一个粒度。我们有一个告警列表,每5秒通过revalidateTag刷新一次。但在PPR模式下,如果静态外壳中引用了告警列表的容器,会导致整个外壳在revalidate时也重新渲染静态部分,反而增加了服务器压力。最终我们把所有会变动的部分全部抽成独立的RSC组件,并单独设置缓存策略,才把这个问题收敛住。这让我意识到,PPR不是简单地在page层面加一个配置就能完事的,它需要你重新审视整个组件树的边界划分。

踩完这些坑,我算了一笔ROI账

抛开事故不谈,Next.js 15确实让我们的开发效率和系统性能有了质的提升。我让团队记录了三组关键指标:构建效率、开发体验、生产性能。

指标 Next.js 14 + Webpack Next.js 15 + Turbopack
全量构建时间(生产) 41-47秒 3.2秒
热模块替换(HMR) 2-8秒 <100ms
首屏可交互时间(看板首页) 2.8秒 0.6秒(PPR)
前端JS体积(gzip) 420KB 310KB

如果按一个6人的前端团队计算,每天节省的构建等待时间大约有1.5个小时,折算成人力成本,一年能省下接近二十万。而因为PPR带来的页面加载提速,操作员的等待时间减少了,按车间反馈,平均每条产线每天可以多产出约0.3%的良品,换算到我们客户年产值1.2亿的规模,每年就是三十六万的额外利润。虽然这个数字很粗糙,但至少说明,前端架构的投入是可以直接转换成车间里的钱的。

然而,那4小时的停产损失,高达八万多元,直接抹平了前面所有的ROI。如果我能在升级前花一周时间做更充分的服务端压力测试和错误注入演练,那八万多本可以不花。这也是我现在每次向其他创业者推荐新技术时,一定要补上的一句话:任何性能数据的提升,都顶不住一个生产事故带来的信任崩塌。

本文由 AI 辅助生成,经人工审核后发布。内容由 沈青锋 基于实战经验指导完成。

觉得有用?

沈青锋

连续创业者,第三个项目在做AI+制造业。前两个项目一个做SaaS一个做IoT,都和技术+产业的结合有关。认为AI最大的价值不在聊天机器人,而在让传统行业运转得更好。写文章的目的是分享创业路上的思考和教训。

发表评论