普通视图

发现新文章,点击刷新页面。
昨天以前首页

从平面到空间:用 React Three Fiber 构建 3D 产品网格

2026年3月3日 10:00

原文:From Flat to Spatial: Creating a 3D Product Grid with React Three Fiber

翻译:TUARAN

欢迎关注 {{前端周刊}},每周更新国外论坛的前端热门文章,紧跟时事,掌握前端技术动态。

一篇实用的实战讲解:使用 React Three Fiber 和 GLSL 构建一个弯曲的 3D 商品网格,涵盖着色器、动画与性能。

作者:Matt Greenberg 分类:Tutorials 日期:2026 年 2 月 24 日

Demo

Code

免费课程推荐:通过 34 节免费视频课、循序渐进的项目以及可上手的演示,用 GSAP 精通 JavaScript 动画。立即报名 →

商品网格就像电商里的“白盒画廊”——默认中性,设计上尽量不冒犯任何人。奇怪的是,真正能推动产品销售的线下体验一直都知道:环境本身就是销售的一部分。光线会替你做决定。陈列传达价值。空间本身也有立场。

而网页版通常会把这些全部放弃。

我想看看要怎样才能缩小这道差距——不是为了新奇噱头,而是一次真正的尝试:让浏览商品的感觉更像“身处某个地方”。这篇文章会带你走完我构建它的过程:用 React Three Fiber 做一个弯曲的 3D 商品网格,配上地形图式的 GLSL 背景、全息风格的选中态,以及带弹簧阻尼的相机控制架构。过程中也会提到一些值得借鉴的模式——包括着色器架构、动画如何做到可打断,以及如何划分 React state 和可变 refs 的边界。

技术栈(The Stack)

这个项目使用的技术栈是 Next.jsReact Three FiberTailwindMotion。两个自定义着色器用 GLSL 编写,并通过 glslify 的 webpack 流水线作为 ES 模块导入。

这里值得单独强调一下 glslify 的配置,因为它是让着色器开发变得“现代化”的关键基础设施。在 next.config.mjs 里用两个 loader 串起来,就可以在 GLSL 内部写 #pragma glslify: snoise = require('glsl-noise/simplex/2d'),并把编译后的结果作为字符串导入。

架构(Architecture)

整个系统分为四层;搞清楚每一层的起止边界,是保持项目整洁的关键:

┌─────────────────────────────────────────────────┐ │  DOM Layer (Framer Motion)                      │ │  Control bar, filters, minimap, overlays        │ ├─────────────────────────────────────────────────┤ │  Scene Layer (React Three Fiber)                │ │  Canvas, camera rig, lighting                   │ ├─────────────────────────────────────────────────┤ │  Tile Layer (per-card useFrame loops)           │ │  Position, scale, opacity, shader uniforms      │ ├─────────────────────────────────────────────────┤ │  Shader Layer (raw GLSL)                        │ │  Topography background, holographic card sheen  │ └─────────────────────────────────────────────────┘

数据流(Data flow)。 鞋子数据是一个 JSON 数组。每个集合(Nike、New Balance、Budget)都会映射到一个独立数组。筛选(filters)是在某个集合内部缩小范围;切换集合(collection switches)则是直接替换整个数组。

交互循环(Interaction loop)。 画布上的指针事件会更新一个可变的 rigState 对象。相机控制架构(camera rig)每帧读取它,并以阻尼方式向目标值收敛。每个 tile 也读取同一个 rigState 来判断自己是否被选中,然后调整自己的位置、缩放以及着色器的 uniforms。

影响一切的决策,是哪些东西放进 React state、哪些东西用可变 refs 来保存。我是吃过亏才学到的:任何以 60fps 变化的东西——相机位置、tile 的动画进度、着色器 uniforms——都不能放在 React state 里。调和(reconciliation)的开销会把你拖垮。这些值应该放在普通的可变对象里,让 useFrame 回调直接读取。React state 只留给离散的用户行为:当前激活的是哪个集合、设置了哪些筛选条件、选中了哪个 tile。

网格系统(The Grid)

第一个问题是布局。我需要把一份平铺的鞋子列表,排列成 3D 空间中居中的网格,并且要足够灵活,以支持筛选(会改变项目数量)和集合切换(会把一切都换掉)。

Configuration

所有网格参数都放在一个可变的单例里——不是 React state,也不是 context,只是一个普通对象:

const CONFIG = {
  gridCols: 8,
  itemSize: 2.5,
  gap: 0.4,
  zoomIn: 12,
  zoomOut: 31,
  curvatureStrength: 0.06,
  dampFactor: 0.2,
  tiltFactor: 0.08,
  cullDistance: 14,
};

开发期间,我把每一个值都接进了 Leva 的调试控制面板。拖动一个“curvature(曲率)”滑块,看着网格的“碗”形实时加深,对于调出理想手感非常有价值——这是用写死的常量加上不断刷新页面的方式根本做不到的。

Positioning

Tile 的位置通过简单的“按列优先(column-major)”数学计算得到,并以原点为中心:

const spacing = CONFIG.itemSize + CONFIG.gap;
const col = filteredIdx % CONFIG.gridCols;
const row = Math.floor(filteredIdx / CONFIG.gridCols);
const x = col * spacing - gridWidth / 2 + spacing / 2;
const y = -(row * spacing) + gridHeight / 2 - spacing / 2;

X 轴从左到右。Y 轴从上到下。Z 轴则完全留给深度效果——曲率、聚焦以及过渡动画。让 Z 保持“空闲”,事实证明是我早期做过的更好决策之一:这意味着我可以把多个深度效果用叠加的方式组合起来,而不会互相打架。

卡片系统(The Cards)

每只鞋都是一个 ShoeTile —— 一个 <group>,里面包含用于命中测试的平面、带有我们自定义 Shader 材质的图片网格、文字标签以及关闭按钮。

纹理(Textures)

我会在模块级别预加载所有纹理,确保在任何组件挂载之前就完成。这一点没有商量余地——否则在切换集合时,会出现明显的“跳出/突现”(pop-in):纹理会一张张上传到 GPU,导致画面逐个补齐。

shoes.forEach((shoe) => {
  useTexture.preload(shoe.image_url);
});

每个 tile 都会基于已加载的纹理计算符合宽高比的尺寸,因此图片永远不会被拉伸变形。

动画循环(The Animation Loop)

这是整个项目的核心。每个 tile 都运行自己的 useFrame 回调——一个每帧都会执行的函数,用来管理一组动画值,这些值组合起来构成最终的渲染状态。

我一开始试过 GSAP,后来放弃了。问题在于“可中断性”。如果用户在筛选过渡进行到一半时点击某只鞋,那么所有动画都需要平滑地改道。基于时间线(timeline)的系统会和这种需求对着干——你会花更多时间处理取消与清理,而不是写动画逻辑。CSS 动画从来就不是选项;它们无法深入到 WebGL 的 uniform。

最终我选择了非常棒的 maath 里的 easing.damp()——一个与帧率无关的指数阻尼函数。你设置一个目标值,当前值就会追过去;你在动画中途改目标,它就会立刻改道继续追。无需清理,无需取消。

const focusZ = useRef(0);
const curveZ = useRef(0);
const transitionZ = useRef(0);
const animatedPos = useRef({ x, y });
const filterOpacity = useRef(1);
const filterScale = useRef(1);

最终位置由这些相互独立的通道叠加而成:

ref.current.position.set(
  x,
  y + transitionY.current,
  curveZ.current + focusZ.current + transitionZ.current
);

三个 Z 向的贡献是加法叠加的:曲率把远处的 tile 推得更远,聚焦效果把选中的卡片向前“弹出”,过渡偏移负责处理进入/退出。它们各自以不同速度阻尼收敛。由于只是简单相加,因此永远不会相互冲突。

自定义 Shaders(Custom Shaders)

我使用 drei 的 shaderMaterial() 辅助方法写了两个自定义 GLSL 材质。它会给你一个声明式的 JSX 接口(<holoCardMaterial />),背后则由原生 GLSL 驱动。

我选择“按材质写 Shader”,而不是做后期处理(post-processing),原因很明确:我的效果是交互驱动、并且是按卡片(per-card)生效的。全息光泽只会出现在被选中的卡片上;如果用后期 bloom pass,就得处理屏幕上的每个像素,只为了影响一张卡。把效果放在材质里意味着对另外 59 张卡完全没有额外开销。

地形背景(Topography Background)

背景是一个带动画的等高线场——一张“活的”地形图,为场景提供技术感、类似 CAD 的空间深度,但又不会与鞋子的图像争抢注意力。

等高线如何工作(How the Isolines Work)

片元着色器会采样 2D simplex noise(通过 glslify 引入),并让它随时间缓慢漂移:

#pragma glslify: snoise = require('glsl-noise/simplex/2d')
float n = snoise(noiseUv * uScale + uTime * 0.05);

等高线来自一种经典的 isoline 提取技巧:把噪声乘以一个频率,取小数部分来生成重复的条带,然后用一对 smoothstep 在条带边界处雕刻出细线:

float lines = fract(n * 5.0);
float pattern = smoothstep(0.5 - uLineThickness, 0.5, lines)
              - smoothstep(0.5, 0.5 + uLineThickness, lines);

这两个 smoothstep 会在 0.5 处制造一个很窄的峰值——也就是每个条带“回卷”(wrap around)的边界位置。uLineThickness(默认 0.03)控制线宽;5.0 的倍数控制每个噪声 octave 中出现多少圈同心环。我花了不少时间调这些参数——太粗会像加载中的转圈 spinner,太细则在低 DPI 屏幕上几乎看不见。

遮罩与颗粒(Masking and Grain)

一个圆形 mask 让边缘柔和渐隐,胶片颗粒(film grain)则用来防止色带(banding):

float grain = (fract(sin(dot(vUv * 2.0, vec2(12.9898, 78.233))) * 43758.5453) - 0.5) * 0.15;
vec3 finalColor = uColor + grain;
gl_FragColor = vec4(finalColor, pattern * opacity * mask * uOpacity);

整体放在 Z = -15 的平面上,并设置 depthWrite={false}renderOrder={-1},确保它永远不会遮挡卡片。当用户缩放进入某只鞋时,uOpacity 会淡出到 0.25——背景后退但不会消失。

全息卡片材质(Holographic Card Material)

当卡片被选中时,这个材质会添加一道扫过的全息光泽(holographic sheen)。这是我写得最开心的 Shader,因为整个效果完全由一个 uniform 驱动:uActive

顶点“呼吸”(Vertex Breathing)

顶点着色器会对选中的卡片施加轻微的正弦缩放振荡:

float breath = sin(uTime * 2.0) * 0.015 * uActive;
float scale = 1.0 + breath;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos * scale, 1.0);

uActive 为 0 时,呼吸量会被乘到 0——未选中的卡片不会做任何额外工作。

光泽扫过(The Sheen Sweep)

片元着色器里的光泽效果算是个“意外之喜”。我最初想要的是静态的全息渐变,但把光泽位置直接映射到 uActive 后,就免费得到了这种扫过动画——当 uniform 从 0 动到 1 时,这条光带会自然地滑过整张卡片:

float diagonal = (vUv.x * 0.8) + vUv.y;
float sheenPos = uActive * 2.5;
float sheenWidth = 0.5;
float dist = abs(diagonal - sheenPos);
float intensity = 1.0 - smoothstep(0.0, sheenWidth, dist);
intensity = pow(intensity, 3.0);

X 轴上的 0.8 倍数是“倾斜”(tilt)因子。在标准的 x+yx + y 设定里,渐变会以完美的 45° 角移动。通过让 X 轴的权重略小于 Y 轴,我们把扫光线旋转得更接近竖直方向,这更符合“卡片拿在光源下”的直觉。

pow(intensity, 3.0) 则是我们的“聚焦”(focus)控制。没有它时,光泽会变成一大片宽而浑的泛光。把强度提升到一个幂次,会把较低的值压向 0,只保留峰值,从而让衰减更锐利:从柔和的光晕变成更集中的高光(specular)条纹。

末尾的淡出可以防止光泽停住不走:

float sheenFade = 1.0 - smoothstep(0.7, 1.0, uActive);
vec3 sheenColor = vec3(0.85, 0.92, 1.0) * intensity * 0.9 * sheenFade;
vec3 finalColor = baseColor + sheenColor * texColor.a;

这种偏冷的蓝白色高光采用“叠加”的方式,并通过纹理的 alpha 进行遮罩,以确保效果始终限制在鞋子的轮廓之内。

非对称时序(Asymmetric Timing)

有一个小细节却带来了很大的差异:我在做选中与取消选中时,用不同的阻尼速度来动画 uActive

const activeDamp = isActive ? 0.6 : 0.15;
easing.damp(imageRef.current.material, "uActive", isActive ? 1 : 0, activeDamp, delta);

慢慢进入(0.6s),快速退出(0.15s)。你可以细细品味“显现”的过程,但永远不需要等待“收起”。这种不对称非常微妙,用户不会有意识地察觉到它;但一旦去掉,整个交互就会显得拖沓。

相机 Rig(The Camera Rig)

我从零开始写了一个自定义相机 rig,而不是使用 drei 的 OrbitControls。OrbitControls 提供的是围绕中心点旋转的相机轨道——而我需要的是一个 2D 平移相机:带有边界限制的拖拽、橡皮筋式边缘回弹,以及基于速度的倾斜效果。OrbitControls 里的每一条约束都会和我的需求“对着干”。

工作原理(How It Works)

这个 rig 是一个可变的单例状态,在相机组件与每一个 tile 之间共享:

const rigState = {
  target: new THREE.Vector3(0, 2, 0),
  current: new THREE.Vector3(0, 2, 0),
  velocity: new THREE.Vector3(0, 0, 0),
  zoom: CONFIG.zoomOut,
  isDragging: false,
  activeId: null,
};

指针事件会更新 target。每一帧里,current 会以阻尼方式向 target 靠拢。相机读取的是 current。这种“间接层”正是让一切感觉顺滑的原因——用户输入从来不会被直接应用到相机上。

拖拽与边界(Drag and Bounds)

我用一个距离阈值来区分点击与拖拽(桌面端 5px,触屏 15px)。拖拽灵敏度会随相机距离缩放,从而让平移在任何缩放级别下都保持一致的手感。

当拖过网格边缘时,会触发橡皮筋式阻力——你可以继续超拖 25%,之后才会被硬性夹住。松手后,相机会回弹到边界内。这和 iOS 的滚动回弹是同一种模式:它能在不“硬停”的情况下传达“你到边缘了”。

选中(Selection)

点击某个 tile 会同时触发平移与缩放。被选中的卡片会缩放到 1.5 倍,并在 Z 轴上向前弹出 2 个单位。其它所有卡片会缩小到 0.5 倍,并淡出到 15% 的不透明度——一种非常戏剧化的聚光灯效果。

筛选与集合切换(Filtering and Collection Switching)

这个应用支持两类过渡动画。有意思的是,它们需要完全不同的策略来实现。

原地筛选(In-Place Filtering)

当你在同一个集合内筛选(比如从 “All” 到 “Jordan”)时,我不会卸载再重新挂载这些 tile。那会导致纹理重新上传,而这意味着掉帧。相反,我让匹配的条目平滑地重新排布以填满更密的网格;不匹配的条目则在原地淡出并缩小:

easing.damp(animatedPos.current, "x", basePos.x, 0.2, delta);
easing.damp(animatedPos.current, "y", basePos.y, 0.2, delta);
const targetFilterOpacity = matchesFilter ? 1 : 0;
const targetFilterScale = matchesFilter ? 1 : 0.5;
easing.damp(filterOpacity, "current", targetFilterOpacity, 0.06, delta);

被隐藏的 tile 仍然保持挂载,但不可见——当不透明度低于 0.01 时,将 visible = false。这意味着筛选变化可以做到瞬时响应:没有额外的 GPU 工作,只有 uniform 的变化与位置的重新计算。

集合切换(Collection Switching)

切换集合是更重的操作——鞋子数据完全不同。我用“图层堆栈”的方式解决:旧网格与新网格会短暂共存,各自作为独立组件渲染,并拥有唯一的 React key。

const handleCollectionSwitch = (index) => {
  setGridLayers((prev) => {
    const exitingLayers = prev.map((layer) =>
      layer.mode === "enter"
        ? { ...layer, mode: "exit", startTime: now }
        : layer
    );
    const newLayer = {
      id: `grid-${index}-${now}`,
      items: collectionsData[index],
      mode: "enter",
      startTime: now,
    };
    return [...exitingLayers, newLayer];
  });
  setTimeout(() => {
    setGridLayers((prev) => prev.filter((l) => l.mode === "enter"));
  }, CONFIG.cleanupTimeout);
};

旧网格会朝相机飞来(Z +20),而新网格会从后方进入(Z -50)。每个 tile 都会获得一个随机的错峰延迟。这样读起来更像“爆散”而不是“平移”——这是刻意为之。单纯的交叉淡入淡出会显得很平。Z 轴上的运动带来真实的空间感,而随机错峰则避免了同步运动带来的机械感。

进入的新 tile 还会根据它们在网格中的位置,在 Y 轴上做“散开”:上方的条目从更高的位置开始,下方的条目从更低的位置开始——营造一种“从四面八方汇聚”的感觉。

打磨(Polish)

Dynamic Island(灵动岛)

底部控制栏借鉴了 Apple 的 Dynamic Island 模式:一个单一的玻璃拟态容器,在不同状态之间形变切换。我用的是 Framer Motion 的 layout 属性,因为它能处理 CSS 做不到的一件事——在完全不同的 DOM 结构之间进行动画过渡。

迷你地图(MiniMap)

一个 2D 的 <canvas> 覆盖层会运行自己独立的 requestAnimationFrame 循环,不依赖 R3F。每双鞋用一个点表示,被选中的鞋会发出金色光晕,而一个白色矩形表示当前可见视口。选中时,迷你地图会围绕激活的点平滑缩放到 2.5 倍。

性能(Performance)

有三种技术让我们保持在 60fps:

分片挂载(Time-sliced mounting)。 一次性挂载 60 张带纹理的卡片会造成 GPU 峰值。我改为每帧挂载 5 张,把工作分摊到约 200ms 内。快到让人无感,又慢到足以避免卡顿。我在这里没法用 InstancedMesh——因为每张卡片都有独一无二的纹理、独一无二的标签,以及独一无二的 shader 状态。实例化需要共享材质。



**三级剔除(Three-level culling)。** 每个 tile 都会做三层检查:是否已经完全退出?(直接跳过整个 `useFrame` 回调。)是否超出了视距?(把它隐藏。)它的透明度是否接近 0?(`visible = false`。)这些检查是叠加生效的——一旦某个 tile 在切换集合时已经退出,它就会跳过所有逐帧工作,而不只是跳过渲染。


**一切皆可变(Mutable everything)。** 相机位置、tile 的动画引用、着色器 uniforms ——都在 `useFrame` 里直接做可变更新,从不触碰 React state。唯一会触发重新渲染的时刻,是一些离散的用户操作:选择某个 tile、改变筛选条件、切换集合。


## 结语(Conclusion)


如果要把整个项目浓缩成一句话,那就是:难点不在 3D。难点在于让 3D 消失。没人应该看着这个就觉得“哦,一个 WebGL demo。”他们只该觉得:逛鞋子这件事,比平时稍微有趣了一点。


让我达到这个效果的那些模式——用指数阻尼替代 tween、用逐材质(per-material)着色器替代后期处理、对所有会动的东西都用可变 ref 而不是 React state——并不是什么特别稀奇的技巧。当你不再把 React Three Fiber 当作 demo 框架,而是把它当作生产级框架来对待时,这些选择会很自然地“长出来”。我在这个项目上花的大部分时间并不是在写着色器,而是在调阻尼常量、干掉不必要的重新渲染,并确保在动画进行到一半时改了筛选条件,不会把别的东西弄坏。


如果你在做类似的东西,直接抄这个架构:React 负责结构,GLSL 负责像素,一层很薄的可变状态把两者在 60fps 下桥接起来。其他一切,都是品味问题。

如何为 AI 编码代理配置 Next.js 项目

2026年3月2日 17:15

原文:How to set up your Next.js project for AI coding agents

翻译:TUARAN

欢迎关注 前端周刊,每周更新国外论坛的前端热门文章,紧跟时事,掌握前端技术动态。

image.png

最后更新:2026 年 2 月 27 日

Next.js 在 next 包内置了与版本精确匹配的文档,使 AI 编码代理可以引用准确、最新的 API 和实践模式。你只需在项目根目录放置一个 AGENTS.md,就能把代理从“训练语料记忆”引导到这份本地文档。

工作原理

安装 next 后,Next.js 文档会被打包到 node_modules/next/dist/docs/。其目录结构与 Next.js 官方文档站 保持一致:

node_modules/next/dist/docs/
├── 01-app/
│   ├── 01-getting-started/
│   ├── 02-guides/
│   └── 03-api-reference/
├── 02-pages/
├── 03-architecture/
└── index.mdx

这意味着:代理始终能读取与你本地安装版本一致的文档,不需要网络请求,也不依赖外部检索。

项目根目录下的 AGENTS.md 会明确要求代理在写代码前先阅读这些文档。包括 Claude Code、Cursor、GitHub Copilot 在内的多数编码代理,会在会话启动时自动读取 AGENTS.md

快速开始

新项目

create-next-app 会自动生成 AGENTS.mdCLAUDE.md,无需额外配置:

pnpm create next-app@canary

如果你不希望生成代理配置文件,可传入 --no-agents-md

npx create-next-app@canary --no-agents-md

既有项目

确保 Next.js 版本为 v16.2.0-canary.37 或更高,然后在项目根目录新增下列文件。

AGENTS.md(代理会读取的规则):

<!-- BEGIN:nextjs-agent-rules -->

# Next.js: ALWAYS read docs before coding

Before any Next.js work, find and read the relevant doc in `node_modules/next/dist/docs/`. Your training data is outdated — the docs are the source of truth.

<!-- END:nextjs-agent-rules -->

CLAUDE.md(通过 @ 引入 AGENTS.md,避免重复维护):

@AGENTS.md

理解 AGENTS.md

默认的 AGENTS.md 只有一条核心规则:写代码前先读内置文档。这个设计刻意保持最小化,目标是把代理从过时训练数据重定向到 node_modules/next/dist/docs/ 中版本匹配的官方文档。

<!-- BEGIN:nextjs-agent-rules --><!-- END:nextjs-agent-rules --> 定义了 Next.js 托管区块。你可以在这个区块外添加项目私有规则,后续升级时不会被覆盖。

内置文档包含 App Router 与 Pages Router 的指南、API 参考与文件约定。当代理遇到路由、数据获取或其他 Next.js 任务时,应优先查阅本地文档,而不是依赖可能过时的训练记忆。

补充:**想看这套机制在真实任务中的效果,可参考 benchmark results

下一步

Next.js MCP Server

继续阅读 Next.js MCP 支持文档,让编码代理可以访问你的应用运行时状态与上下文。

React 性能优化完全指南 2026

2026年3月2日 09:16

原文:A complete guide to React performance optimization

翻译:TUARAN

欢迎关注 前端周刊,每周更新国外论坛的前端热门文章,紧跟时事,掌握前端技术动态。

如今的用户默认就期待应用“又快又顺”。性能不再只是“锦上添花”,它是真正的产品优势,会直接影响留存、转化和收入。

难点在于:排查性能问题常常让人崩溃,因为一个应用变慢的原因实在太多了。

在这份指南中,我会分享一个循序渐进的框架:从分析 bundle 开始,一路优化到服务端渲染。按这四个阶段走,你可以在不牺牲代码质量和开发体验的前提下,把 LCP 从 28 秒降到约 1 秒(超过 93% 的提升!)。

我们会用一个“视频播放器应用”作为示例,按阶段逐步提升性能。你可以在这里获取示例代码仓库:github.com/shrutikapoo… 。这篇指南也有视频版。

🚀 订阅 The Replay Newsletter(原文站内推荐)

The Replay 是面向开发者与工程负责人(dev + engineering leaders)的每周通讯。

每周一期,精选你需要关注的前端开发讨论、正在涌现的 AI 工具、以及现代软件开发的现状。


建立基线(Establish baseline)

在改任何东西之前,我们得先知道现状。

先在 Chrome DevTools → Performance 里拿到基线数据。

  • 把网络限速设为 Slow 4G
  • 关闭缓存(Disable cache)

这样结果才能更接近真实用户环境。

录制一次应用里的“正常用户流程”,观察几个关键指标:

  • First Contentful Paint(FCP)
  • Largest Contentful Paint(LCP)
  • Time to Interactive(TTI)

这些数字能让你很快看出“慢”到底慢在哪。下面是我们起步时的结果:


阶段 1:分析并优化 bundle(Phase 1: Analyze and optimize the bundle)

优化的第一步,是搞清楚你到底给用户发了什么。

在动代码之前,先看 bundle,从中找出最值得优先优化的地方。

  1. 给构建加一个 bundle analyzer 来可视化包体:
  • Webpackwebpack-bundle-analyzer
  • Vitevite-bundle-analyzerrollup-plugin-visualizer
  1. analyzer 会给你一个交互式的 treemap,告诉你哪些包/文件占了最多空间。

你经常会发现:某个“大依赖”(通常是第三方库)吃掉了很大一块体积——这能立刻帮你明确“先优化谁”。

从这张图可以看出:一些 node modules 占了很大一部分体积,hero 图片也不小。好消息是,我们的 src 目录占比很小。

优化构建(Optimizing build)

  • 确认生产环境启用了 JS 和 CSS 的压缩(minification)。 现代构建工具大多在 production 模式默认开启,但你最好确认“真的有生效”。压缩会移除空白、缩短变量名,并做其他转换,显著减少文件体积。

  • 开启代码分割(code splitting),按路由/功能把 bundle 拆成更小的 chunk。

    与其把所有代码打成一个巨大的 JS 文件,不如只给当前页面发必需的代码,其余按需加载。

    这个项目使用 TanStack Router,所以我们会按路由拆分。这样后续就可以很容易对不常访问的路由做懒加载导入。

原文示例(节选,按可读性整理):

// vite.config.ts
export default defineConfig({
build: {
outDir: "dist",
emptyOutDir: true,
sourcemap: true,
minify: true,
cssMinify: true,
terserOptions: {
compress: false,
mangle: false,
},
},
// ...
// tanstackRouter({
//   target: 'react',
//   autoCodeSplitting: false,
// }),
});

组件懒加载(Lazy load components)

当我们放大 bundle analyzer 里 src/components 的区域,可能会发现:某些组件占了不少体积。

这时就可以通过懒加载来优化:确保它们只在用户真的导航到需要它们的页面/路径时才会被 import。

// MovieList.tsx
import { lazy } from "react";

const MovieCard = lazy(() => import("@/components/MovieCard"));

移除未使用依赖(Removing unused dependencies)

  • 运行 npx depcheck 找出 package.json 里未被实际使用的 node modules。

    depcheck 会扫描代码库并报告“没有在任何地方 import 的包”,你就可以安全移除它们,从而减少 bundle 体积。

再次测量(Measure again)

为了确认这些改动确实带来收益,我们必须再测一次。

通过 npm run build 重新打包:

影响(Impact):

仅仅通过代码分割、移除不必要的 node modules、压缩文件,我们就把 bundle 从 1.71MB 降到了 890KB!

LCP 也从 28.10 秒降到了 21.56 秒:

接下来进入更“好玩”的部分:优化 React 组件。


阶段 2:优化 React 代码(Phase 2: Optimizing React code)

在 React Compiler 出现之前,你必须手工找出性能瓶颈,然后通过 useMemo / useCallback 等手段做记忆化(memoization)来优化组件。

但现代 React 开发已经有了 React Compiler,它可以自动处理大量性能优化。

除此之外,新的性能监控工具(例如自定义的 React Performance tracks)也让“到底发生了什么”更透明,你不必再靠猜测来判断哪些组件渲染慢。

在开始优化之前,我们先看一下当前可用的工具。

1) React 19 Performance tracks

React 19 引入了自定义的 Performance tracks。它把性能分析能力直接集成进 Chrome DevTools 的 Performance 面板,让你能定位真实的渲染瓶颈,而不是凭感觉猜哪个组件慢。

它会展示每个组件在 React 生命周期四个阶段中分别花了多少时间:

  • blocking
  • transition
  • suspense
  • idling

trace(追踪)能把“长任务(long tasks)”关联回具体组件的工作和 hook 逻辑,从而快速隔离:昂贵的渲染路径、不必要的重复计算、以及可避免的重复渲染。

来源:react.dev/reference/d…

2) React Compiler

React Compiler 改变了我们今天看待记忆化的方式。

在它出现之前,开发者往往需要手动:

  • React.memo 包裹组件
  • useMemo / useCallback 包裹回调或计算

来避免不必要的重新渲染。

这种方式容易出错,而且需要你花大量精力判断“到底哪些组件需要记忆化”。即便手工做了记忆化,也很容易漏掉真正慢的部分。

React Compiler 会作为 Babel 插件接入构建流程,自动分析组件,并基于 Rules of React 施加记忆化。

它理解 React 的渲染行为,能做出比手工优化更“聪明”的决定,在很多情况下甚至能超过人肉优化。

要开始使用它,先安装 compiler 并把它加到 Babel 配置中:

npm install -D babel-plugin-react-compiler@latest

然后更新 Vite 配置(原文示例):

// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
plugins: [
react({
babel: {
plugins: ["babel-plugin-react-compiler"],
},
}),
],
});

现在,当你在 React Profiler 里打开组件,会注意到:被 compiler 自动记忆化的组件旁边会出现一个 ✨ 标记:

3) React Profiler

虽然它出现很久了,但仍然非常有用:你可以用它理解组件重渲染的次数,以及到底哪些组件在重渲染。

本文也会把它与 React Compiler、Performance tracks 一起用,来找到真正慢的组件。

测量(Measure)

使用 React Profiler 时,我们测量用户最常走的一条 UX 路径。本文测量的流程是:

  1. 点击一张电影卡片,打开电影详情
  2. 播放电影预告片
  3. 返回首页

你可以看到右上角:在这条 UX 流程中,应用重渲染了 16 次

最高的那根柱子来自 Movie List 组件,渲染耗时 25ms。

这让我们更清楚:哪个组件最慢、以及它的重渲染频率最高。

改进点(Improvements)

1) 让 React Compiler 负责记忆化

有了 React Compiler,你不必手动到处加 useMemo / useCallback

它可以自动减少不必要的重渲染和重复计算,你就能把注意力放在“真正需要改的代码问题”上。

2) 清理 useEffect

useEffect 很容易导致不必要的重渲染。

能不写就尽量不写;必须写时,确保 effect 正确清理,并且不会造成无限的 state update。

作者在另一篇文章里更深入讨论了最常见的 useEffect 错误:blog.logrocket.com/15-common-u…

3) 清理函数定义

一个常见错误是:在组件函数体里定义一些“其实不属于这里”的函数(比如纯工具函数)。

问题在于:每次组件渲染,这些函数都会被重新创建——即便它们的实现根本不变。这会给 JS 引擎带来不必要的工作。

把工具函数挪到组件外,或放到单独的工具文件里:

const formatRuntime = (minutes: number) => {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours}h ${mins}m`;
};

4) 懒加载组件

用户一开始看不到的大组件,是懒加载的绝佳候选。

像视频播放器、图表、富文本编辑器这类组件,会让初始 bundle 变大,即便大多数用户从来不会用到它们。

React 通过 React.lazy + Suspense 让这件事很容易:

  • React.lazy() 替代普通 import,让组件只在需要时加载
  • <Suspense> 包起来,在加载期间展示 fallback UI(比如 spinner / skeleton)

它和“按路由分割”的 code splitting 配合尤其好:只有用户访问某个页面时才加载对应代码。

import { lazy, Suspense } from "react";

const MovieCard = lazy(() => import("@/components/MovieCard"));

5) 列表虚拟化(Virtualized lists)

渲染包含大量 DOM 节点的长列表,是非常常见的性能问题。

多数用户甚至不会把列表滚到最底部,你却为看不见的内容做了很多渲染工作。

列表虚拟化的思路是:只渲染屏幕可见的部分(再加一点 buffer)。用户滚动时,元素在 DOM 中被动态添加/移除——列表看起来完整,但性能更好。

react-window(更轻量)或 react-virtualized(功能更丰富)这样的库,可以很容易实现它。

影响(Impact):

你会看到:应用的重渲染次数下降了,峰值也变低了,最大一次渲染为 13.1ms:

LCP 也下降了 2 秒。虽然这不是一个巨大的 LCP 改进,但仍然令人鼓舞——因为它说明我们正在朝正确方向前进。


阶段 3:把工作移到服务端(SSR)(Phase 3: Moving to the server)

客户端渲染(CSR) 往往会更慢,因为用户经常会在浏览器下载并执行 JS、再去请求数据期间,看到空白屏或 loading spinner。

这种延迟是 LCP 不佳的主要原因之一,会导致“元素渲染延迟(element render delay)”。

服务端渲染(SSR)通过在服务端先把数据取好、生成 HTML,再把页面发给浏览器来解决这个问题。

用户能立刻看到真实内容,而 JS 在后台加载并 hydration。

采用框架(Adopting a framework)

你当然可以自己搭 SSR,但像 Next.js、Remix、或 TanStack Start 这样的框架会让事情更容易,也更适合生产环境。

TanStack Start 还支持 streaming SSR:服务端可以在生成 HTML 的同时就开始往浏览器发送,而不是等整页渲染完再一次性返回。

迁移到框架通常意味着要改路由与数据获取方式,但性能收益巨大。

你不只是在微调客户端代码,而是在改变页面“何时、在哪里”渲染:数据在组件渲染前就已经在服务端准备好,从而显著降低 LCP。

Server functions

在 TanStack Start 中,你可以通过 server function 在服务端获取数据。

本文把原本在客户端 useEffect 中的数据请求迁到服务端,写成 server function。

原文“前后对比”代码(按可读性整理):

// Before: data-fetching in useEffect
useEffect(() => {
async function fetchPopularMovies() {
const token = import.meta.env.VITE_TMDB_AUTH_TOKEN;

if (!token) {
setError("Missing TMDB_AUTH_TOKEN environment variable");
setLoading(false);
return;
}

setLoading(true);
setError(null);

try {
const response = await fetch(API_URL, {
headers: {
accept: "application/json",
Authorization: `Bearer ${token}`,
},
});

if (!response.ok) {
throw new Error(`Failed to fetch movies: ${response.statusText}`);
}

const data = (await response.json()) as TMDBResponse;
setMovies(data.results);
} catch (error) {
setError((error as Error).message);
} finally {
setLoading(false);
}
}

fetchPopularMovies();
}, []);
// After: Data-fetching in TanStack Start Server Function
export const getMovies = createServerFn({
method: "GET",
}).handler(async () => {
try {
const response = await fetch(`${API_URL}/popular`, {
headers: {
accept: "application/json",
Authorization: `Bearer ${token}`,
},
});

if (!response.ok) {
throw new Error(`Failed to fetch movies: ${response.statusText}`);
}

const movies = await response.json();
return { movies };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
throw new Error(`Movies fetch failed: ${errorMessage}`);
}
});

影响(Impact)

LCP 下降到了 13.43s


阶段 4:静态资源与图片优化(Phase 4: Asset and image optimization)

图片往往是拖慢 LCP 的最大因素。

下面是一些常用的优化图片/视频资源交付的技巧。

使用 CDN(CDN usage)

把本地的大资源(例如 hero 背景)搬到 CDN(如 Cloudinary、Cloudflare),减少你自己应用服务器的压力。

很多 CDN 还能自动做图片优化:对支持的浏览器下发 WebP/AVIF,并为老浏览器回退到 JPEG/PNG。

把大资源放到 CDN 也会减少应用服务器负载,并降低 bundle 体积。

标记优先级(Priority tagging)

并不是所有图片都同等重要。

浏览器无法自动判断:哪些图片对首屏至关重要,哪些在首屏之外或某些 Tab 里用户可能永远不会打开。

你需要明确告诉浏览器:

  • 对首屏关键图片使用 fetchpriority="high"
  • 对其余图片使用 loading="lazy"

原文示例(按可读性整理):

<!-- Hero banner 是最高优先级,因此 fetchPriority=high -->
<img
src="https://res.cloudinary.com/dubc3wnbv/image/upload/v1760360925/hero-background_ksbmpq.jpg"
fetchpriority="high"
alt=""
/>

<!-- Movie Card 图片懒加载 -->
<img
src={movie?.poster_path ? TMDB_IMAGES_ASSET_URL + movie?.poster_path : "/placeholder.svg"}
alt={movie?.title}
loading="lazy"
/>

预加载关键资源(Preloading critical resources)

现代框架(比如 TanStack Router)可以自动预加载路由。

例如用户把鼠标悬停在链接上时,就可以提前加载下一页的代码和数据,等用户真的点下去时,导航会显得“瞬间完成”。

// router.tsx
const router = createTanStackRouter({
routeTree,
scrollRestoration: true,
defaultPreload: "intent",
});

你也可以预加载重要的 CSS 和字体,让它们立即开始下载,而不是等到之后才被浏览器“发现”。

这样可以减少 layout shift,并避免未样式化内容闪烁(FOUC)。

// __root.tsx
links: [
{ rel: "preload", href: appCss, as: "style" },
];

Next.js 企业级落地

2026年3月2日 09:12

原文:Next.js at Enterprise Level

翻译:TUARAN

欢迎关注 前端周刊,每周更新国外论坛的前端热门文章,紧跟时事,掌握前端技术动态。

Next.js 开箱即用,体验通常很顺手——直到流量和复杂度同时上来。本文讨论的是:在不推倒重写的前提下,如何把 Next.js 推到企业级规模,从缓存与 CDN,到横向扩容、API Gateway,再到更上层的架构策略。

Next.js 是一个构建在 React 之上的全栈框架,支持服务端渲染(SSR)与静态站点生成(SSG)。它允许你在服务端构建 UI,同时直接访问数据库、文件系统等服务端资源;并可通过 Server Actions 简化前后端通信。

框架内置了较完善的缓存机制,且配置成本不高,因此对中小型应用来说,默认配置通常就能获得不错效果。

但对于大型企业应用,真正进入生产后往往需要更深的架构理解。要在规模化场景下保持效率与稳健,开发者必须超越默认设置,把一系列高级优化手段组合起来使用。

(原文说明:本文内容由人工撰写,仅由 AI 进行校对。)

问题

在企业级场景里,可扩展性往往是首要指标。默认配置足以快速起步,但随着流量增长,这些默认策略会逐渐演变为瓶颈,并带来:

  • 性能不稳定
  • 资源利用效率低
  • 运维成本上升

要在规模化后依然保持高效和稳定,就需要把系统设计模式、性能优化手段与 Next.js 机制结合起来。

SLA / SLO 与监控体系

定义服务质量:SLA vs SLO

在上线前,应该先明确系统的“非功能性需求”(例如性能、可用性、延迟、错误率等)。文章把这些目标分成两类:

  1. 定义
  • SLA(Service Level Agreement):对用户/客户的正式服务协议。达不到 SLA 可能带来赔偿或法律风险。
  • SLO(Service Level Objective):内部的服务目标,通常比 SLA 更严格,用于留出“缓冲区”。
  1. 为什么要尽早定清楚

这些指标决定了系统是否“可用”。目标通常来自:

  • 行业标准:例如 Core Web Vitals。页面加载超过 2 秒可能影响 SEO,进而影响流量和收入。
  • 竞争基准:至少要对齐竞争对手体验。
  • 安全与关键性:在高风险系统里(例如自动驾驶),实时准确是底线。
  1. 工程与业务对齐

业务方确定目标,但工程师需要帮助解释技术取舍与每个指标的重要性。越早定清楚,越能更合理地编排架构组件与资源。

如何验证:监控 + 压测

  • 监控(Monitoring):在基础设施层面持续采集非功能指标,实时知道当前实现是否达标。
  • 压测(Load Testing):上线前用虚拟用户与合成流量模拟真实压力,尽量把问题暴露在生产之前。

Next.js 的生命周期:动态渲染 vs 静态托管

理解 Next.js 内部“请求进来到底发生了什么”对于扩容很关键:当你从单实例变成几十个副本时,清楚每一步才能预测瓶颈。

1) 动态请求生命周期(SSR / ISR)

用户请求一个运行中的 Next.js 页面时,服务端大致会经历:

  • 渲染:用内部 React 引擎在服务端渲染页面。
  • 响应下发:服务端返回包含:
    • HTML:用于浏览器立即显示
    • RSC Payload(React Server Components):用于客户端 hydration
  • Hydration:浏览器用 RSC payload 把静态 HTML 变成可交互页面。

2) 静态方案(SSG)

如果应用完全不依赖服务端资源(纯静态),生命周期会大幅简化:

  • 构建时生成:构建阶段一次性生成 HTML/CSS/JS。
  • 静态托管:不需要 Node.js 常驻服务,直接由 CDN 或 Nginx/Apache 提供静态文件。
  • 扩展优势:静态站点更易扩展,因为消除了每次请求的服务端渲染 CPU 开销。

进一步:请求进来后的拦截与缓存

请求进入 Next.js 后,会先经过 proxy.ts(Next.js <15 中叫 middleware)进行鉴权等判断;通过后再路由到具体页面。

接着,Next.js 会先查内部缓存:

  • 如果存在可复用的有效缓存,则直接命中返回。
  • 如果没有,则生成响应并缓存,供后续请求复用。

文章强调:Next.js 的缓存发生在多个阶段。

构建阶段(Build time)可能会预生成页面 HTML、静态资源以及 RSC payload 文件——这就是 SSG 的本质:请求还没来,内容已经提前算好了。

运行阶段(Runtime)每个请求也会尝试命中缓存:可能命中预生成 HTML、RSC payload,或命中 fetch 结果(通常落在文件系统缓存里)。如果你额外使用 React cache 或自定义缓存,也会参与最终响应的生成。

一个关键问题是:当你部署多个相同应用实例时,每个实例的本地缓存是彼此隔离的——这会直接引出分布式缓存挑战(后文会讲)。

让 Next.js 先跑快:横向扩容前的“容易收益”

在开始复杂的横向扩容(scale out)之前,先把低成本高收益的优化做掉。

1) CDN

CDN 往往是“ROI 最大、复杂度最低”的优化:合理设置缓存头并接入 CDN,延迟可下降 30%~70%。

示例 1:在 Route Handler 中设置 Cache-Control

// /api/hello/route.ts
import { NextResponse } from "next/server";

export async function GET() {
  return NextResponse.json(
    { message: "Hello World" },
    {
      status: 200,
      headers: {
        // CDN 缓存:缓存 1 小时,回源再验证期间允许使用旧内容
        "Cache-Control": "public, s-maxage=3600, stale-while-revalidate=59",
      },
    }
  );
}

示例 2:为静态资源自定义缓存头

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        // CDN 缓存 /public/images 下的图片 30 天
        source: "/images/:path*",
        headers: [{ key: "Cache-Control", value: "public, max-age=2592000, immutable" }],
      },
      {
        // Next.js 构建产出的 JS/CSS chunk 缓存 1 年
        source: "/_next/static/:path*",
        headers: [{ key: "Cache-Control", value: "public, max-age=31536000, immutable" }],
      },
    ];
  },
};

export default nextConfig;

2) 纵向扩容(Vertical scaling)

纵向扩容依然是快速且有效的策略:通过分析应用逻辑,确定资源基线并为峰值预留 buffer,很多情况下能在“几乎不改代码”的前提下显著提升承载能力。

3) 编码最佳实践

通过工程实践提升性能,避免基础设施过度改造:

  • 渲染策略:使用异步组件与 Partial Prerendering(PPR)来更快地交付内容、改善核心指标。
  • DOM 优化:控制 DOM 规模,减少过深嵌套,提升 SSR 与客户端渲染性能。
  • 保持框架更新
    • React 19+ 引入 React Compiler(通过配置启用),可自动做 memoization。
    • Next.js 16+ 引入 use cache,让组件/Server Actions 的缓存更省力。

文中给了 PPR 的示例(表达的是“静态壳先到,动态内容流式补上”):

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
};

export default nextConfig;
// app/page.tsx
import { Suspense } from "react";

export default function Page() {
  return (
    <>
      <h1>Static shell (sent immediately)</h1>
      <Suspense fallback={<p>Loading…</p>}>
        <AsyncContent />
      </Suspense>
    </>
  );
}

async function AsyncContent() {
  const data = await fetch("/api/data").then((r) => r.json());
  return <div>{data.message}</div>;
}

横向扩容:副本与负载均衡

**横向扩容(Horizontal scaling / scale out)**指把同一个服务部署成多个副本,由负载均衡器作为入口分发流量。常见算法包括:

  • Round Robin(轮询)
  • Weighted Round Robin(加权轮询)
  • Least Connections(最少连接)
  • IP Hash(按 IP 固定路由,形成粘性会话)

横向扩容的架构挑战

1) 实现无状态(Statelessness)

要想“任意副本都能处理任意请求”,应用最好无状态:

  • 把会话等状态外置到数据库或全局缓存
  • 如果无法做到完全无状态,可通过负载均衡的 IP Hash 实现 Sticky Sessions

2) 分布式缓存问题

Next.js 默认把缓存落到本地文件系统。多实例时,每台机器都有自己的缓存,会导致:

  • 冷缓存(cold cache)问题
  • 命中率下降

解决方向是引入所有副本可共享的缓存:

  • 共享盘(不推荐,容易引入并发与扩展瓶颈)
  • Redis 集群(推荐,通过自定义 cache handler 共享缓存)

参考:Next.js 官方示例(Redis Cache Handler)

战略性扩容与自动化

领域驱动扩容:微前端(Micro-Frontends)

不一定要整体复制整个系统。你可以按 DDD(领域驱动设计)把应用拆成多个子域,按热点域独立扩容。例如电商里“商品”域可能需要更多副本,“个人资料”域则少一些。

Kubernetes 自动扩缩容

用 Kubernetes 等平台把容器化的应用按实时负载自动扩缩容:流量高峰时扩容、副本增加;低谷时回收资源,降低成本。

API Gateway

把认证、缓存等横切关注点抽到统一层,可以显著提升可维护性与可扩展性。文章把“鉴权”作为典型例子:当每个应用都在 proxy.ts 里重复做鉴权时,它很适合被抽到 API Gateway。

API Gateway 位于受保护基础设施之前,作为反向代理并负责 TLS 终止。终止 TLS 后,网关与内部服务之间可以走内网 HTTP,降低开销。

文中用 Nginx 配置展示该模式:

http {
  upstream cart_app    { server localhost:3001; }
  upstream product_app { server localhost:3002; }
  upstream profile_app { server localhost:3003; }

  server {
    listen 80;

    location = /auth-verify {
      internal;
      proxy_pass http://your-auth-api/validate;
      proxy_pass_request_body off;
      proxy_set_header Content-Length "";
    }

    location /cart {
      auth_request /auth-verify;
      proxy_pass http://cart_app;
    }

    location /product {
      auth_request /auth-verify;
      proxy_pass http://product_app;
    }

    location /profile {
      auth_request /auth-verify;
      proxy_pass http://profile_app;
    }

    error_page 401 = @error401;
    location @error401 {
      return 401 "Unauthorized - Invalid Token";
    }
  }
}

优化文件上传:从本地磁盘到对象存储

本地存储的缺点

把用户上传文件直接存到应用服务器本地盘,会带来三类风险:

  • 持久性差:硬盘故障或机器宕机可能导致永久丢失
  • 扩展性差:大文件传输会吃带宽和 CPU,拖慢处理其他请求
  • 基础设施压力:后续再把文件搬到长期存储,徒增内部流量与复杂度

现代方案:Blob Storage + Signed URL

企业级常见做法是使用 S3 / GCS / Azure Blob 等对象存储。关键在于 Signed URL 直传

  • 应用生成临时签名 URL
  • 浏览器绕过应用服务器,直接把文件上传到对象存储

这样可以把大流量文件传输从你的应用节点“卸载”出去,提高扩展能力并降低延迟。

事件驱动架构:应对高并发交互

很多用户行为会触发一串内部调用。例如“加入购物车”可能需要:购物车服务、埋点分析、日志、库存系统(SAP)……当并发很高时,同步串行调用会让系统迅速变慢甚至崩溃。

文章建议用 事件驱动架构(EDA) 解耦:

  • 主流程只负责发出事件并快速返回
  • 下游任务异步处理
  • Kafka 等事件总线可以做缓冲:即便某个老系统很慢,前台请求也不至于被拖垮
  • 下游服务短暂故障时,主流程仍可成功,待恢复后再补处理

通信协议优化:HTTP/2 与 gRPC

HTTP/2 的优势

HTTP/2 主要通过:多路复用、二进制分帧、HPACK 头压缩 等特性,解决 HTTP/1.1 的连接与性能瓶颈。

Nginx 启用 HTTP/2 的配置示例:

server {
  listen 443 ssl http2;
  server_name api.example.com;
}

内部服务通信:gRPC vs REST

对外 API 常用 REST,但内部服务间通信 gRPC 往往更合适:

  • 默认基于 HTTP/2
  • Protobuf 二进制比 JSON 更小、更省 CPU
  • 强类型契约降低协作错误

总结

企业级 Next.js 的关键,是在默认能力之上补齐工程化与架构能力:

  • 定清 SLA / SLO 并建立监控与压测
  • 理解请求生命周期与缓存行为
  • 先做 CDN、缓存头、编码实践等“低成本高收益”优化
  • 进入横向扩容后,用共享缓存(例如 Redis)解决多副本命中率问题
  • 把鉴权等横切逻辑上移到 API Gateway
  • 文件上传用对象存储直传
  • 高吞吐交互用事件驱动解耦
  • 合适场景引入 HTTP/2 / gRPC

文章最后的建议很务实:不需要一次性把所有手段都上齐。先从你当前最痛的瓶颈开始(例如 CDN 或缓存策略),用 SLO 衡量效果,然后迭代推进。

Fun with TypeScript Generics:玩转 TS 泛型

2026年3月2日 09:11

原文:Fun with TypeScript Generics

翻译:TUARAN

欢迎关注 前端周刊,每周更新国外论坛的前端热门文章,紧跟时事,掌握前端技术动态。

关于 TypeScript 泛型的文章有很多。本文不做基础入门,而是围绕一个真实的小问题,展示如何把泛型、条件类型和函数重载组合起来,构建一个类型完善且可维护的 API。

重点不是“类型体操”,而是在实际工程里把类型约束做对。

泛型与条件类型:快速回顾

如果你已经熟悉这些内容,可以直接跳到后面的实现部分。

泛型(Generics)

把泛型理解成“类型层面的函数参数”很有帮助。

普通函数参数是“值”,比如:

function arrayLength(arr: any[]) {
  return arr.length;
}

any[] 换成泛型之后:

function arrayLengthTyped<T>(arr: T[]) {
  return arr.length;
}

调用时 T 会从传入数组元素类型推断出来。

作者也直说:这个例子虽然“更类型安全”,但没什么意义,因为 .length 对任何数组都存在。

泛型真正发光的场景,是当你需要让类型信息在“输入 → 输出”之间流动时。例如手写一个 filter

function filterUntyped(array: any[], predicate: (item: any) => boolean): any[] {
  return array.filter(predicate);
}

这个版本的问题是:传进来的 predicate 没任何类型约束,写错字段也不会提示:

type User = {
  name: string;
};

const users: User[] = [];

filterUntyped(users, (user) => user.nameX === "John");

泛型版可以把元素类型贯穿到 predicate 中:

function filterTyped<T>(array: T[], predicate: (item: T) => boolean): T[] {
  return array.filter(predicate);
}

这下 user.nameX 会被立即指出错误。

泛型约束(Constraints)

当你希望“泛型灵活,但不能什么都放进来”,就需要约束。例如你有多个 user 类型,但都至少有 name

type User = {
  name: string;
};

type AdminUser = User & {
  role: string;
};

type BannedUser = User & {
  reason: string;
};

如果把函数写死成 User[],会丢失具体子类型:

function filterUser(array: User[], predicate: (item: User) => boolean): User[] {
  return array.filter(predicate);
}

const adminUsers: AdminUser[] = [];
const adminsNamedAdam = filterUser(adminUsers, (u) => u.name === "Adam");
// adminsNamedAdam 被推断为 User[] —— 信息被“抹平”了

正确做法是保留泛型,但限制它必须是 User

function filterUserCorrect<T extends User>(array: T[], predicate: (item: T) => boolean): T[] {
  return array.filter(predicate);
}

条件类型(Conditional Types)

条件类型本质是“对类型提问,并根据答案生成新类型”。

type IsArray<T> = T extends any[] ? true : false;

type YesIsArray = IsArray<number[]>;
type NoIsNotArray = IsArray<number>;

这看起来很无聊,但配合 infer 才是精华:

type ArrayOf<T> = T extends Array<infer U> ? U : never;

type NumberType = ArrayOf<number[]>; // number
type NeverType = ArrayOf<number>; // never

你还可以加上约束,强制只接受数组:

type ArrayOf2<T extends Array<any>> = T extends Array<infer U> ? U : never;

type NumberType2 = ArrayOf2<number[]>;
type NeverType2 = ArrayOf2<number>; // Type 'number' does not satisfy the constraint 'any[]'

正文:为 refetchedQueryOptions 补齐类型

作者在使用 TanStack Start 时遇到一个工程问题:为了让 react-query 的 queryFnmeta 中记录的服务端函数信息保持一致,他写了一个 helper 来减少重复代码:

export function refetchedQueryOptions(queryKey: QueryKey, serverFn: any, arg?: any) {
  const queryKeyToUse = [...queryKey];
  if (arg != null) {
    queryKeyToUse.push(arg);
  }
  return queryOptions({
    queryKey: queryKeyToUse,
    queryFn: async () => {
      return serverFn({ data: arg });
    },
    meta: {
      __revalidate: {
        serverFn,
        arg,
      },
    },
  });
}

组合使用时是这样:

export const epicsQueryOptions = (page: number) => {
  return queryOptions({
    ...refetchedQueryOptions(["epics", "list"], getEpicsList, page),
    staleTime: 1000 * 60 * 5,
    gcTime: 1000 * 60 * 5,
  });
};

但这个 proof-of-concept 版本里,serverFnarg 都是 any,会导致两个问题:

  1. 传错参数也不会报错
  2. 更糟糕的是:queryFn 返回值也会变成 any,下游 useQuery 拿到的数据类型全部丢失

验收标准(测试用例)

作者给出了一套“应该通过/应该报错”的调用来验证类型:

import { QueryKey, queryOptions } from "@tanstack/react-query";
import { createServerFn } from "@tanstack/react-start";

export function refetchedQueryOptions(queryKey: QueryKey, serverFn: any, arg?: any) {
  const queryKeyToUse = [...queryKey];
  if (arg != null) {
    queryKeyToUse.push(arg);
  }
  return queryOptions({
    queryKey: queryKeyToUse,
    queryFn: async () => {
      return serverFn({ data: arg });
    },
    meta: {
      __revalidate: {
        serverFn,
        arg,
      },
    },
  });
}

const serverFnWithArgs = createServerFn({ method: "GET" })
  .inputValidator((arg: { value: string }) => arg)
  .handler(async () => {
    return { value: "Hello World" };
  });

const serverFnWithoutArgs = createServerFn({ method: "GET" }).handler(async () => {
  return { value: "Hello World" };
});

refetchedQueryOptions(["test"], serverFnWithArgs, { value: "" });
refetchedQueryOptions(["test"], serverFnWithoutArgs);

// wrong argument type
// @ts-expect-error
refetchedQueryOptions(["test"], serverFnWithArgs, 123);

// need an argument
// @ts-expect-error
refetchedQueryOptions(["test"], serverFnWithArgs);

目标是前两行通过,后两行必须报错。

Iteration 1:先恢复 serverFn 的类型信息

TanStack 的 server function 本质就是函数:它只有一个参数对象,常用参数在 data 字段里。既然它是函数,我们就能用 TS 内置的 Parameters / ReturnType

第一步是把 serverFn 设为泛型参数 T,并把 arg 绑定到 Parameters<T>[0]["data"]

export function refetchedQueryOptions<T extends (arg: { data: any }) => Promise<any>>(
  queryKey: QueryKey,
  serverFn: T,
  arg: Parameters<T>[0]["data"]
) {
  const queryKeyToUse = [...queryKey];
  if (arg != null) {
    queryKeyToUse.push(arg);
  }
  return queryOptions({
    queryKey: queryKeyToUse,
    queryFn: async (): Promise<Awaited<ReturnType<T>>> => {
      return serverFn({ data: arg });
    },
    meta: {
      __revalidate: {
        serverFn,
        arg,
      },
    },
  });
}

这样做以后:

  • arg 的类型会跟随传入的 serverFn 自动推断
  • queryFn 的返回值也能从 ReturnType<T> 推导出来

但很快出现一个新问题:即便 serverFn 不需要参数,refetchedQueryOptions 仍会强制传第三个参数。

undefined 传进去能工作:

refetchedQueryOptions(["test"], serverFnWithoutArgs, undefined);

这对大多数项目已经足够,但作者希望继续把 API 打磨到更理想的形态:

  • serverFn 需要参数时:第三个参数必须传
  • serverFn 不需要参数时:第三个参数就不该出现

正确方向:函数重载

你可能会想到把 arg 设为可选,但那会让“本应必传参数的 serverFn”也可不传,约束就失效了。

你也许会想到条件类型:如果 dataundefined 就不需要参数,否则需要参数。但 TS 里很难用条件类型表达“这个参数根本不存在”。

更直接、也更符合 TS 语言特性的解法是:函数重载

TypeScript 的函数重载回顾

重载在 TS 里分两层:

  • 多个“声明签名”(对外可见的 API)
  • 一个“实现签名”(真正的 JS 实现,参数/返回类型要覆盖所有声明)

例如:

function add(x: number, y: number): number;
function add(x: string, y: string): string;
function add(x: string | number, y: string | number): string | number {
  if (typeof x === "string" && typeof y === "string") return x + y;
  if (typeof x === "number" && typeof y === "number") return x + y;
  throw new Error("Invalid arguments");
}

原文还放了两张图,展示编辑器只会提示“声明签名”,而不会把实现签名暴露出来:

构建最终解:针对“有参/无参 serverFn”提供不同签名

我们想要两种重载:

  1. serverFn 有参数 → refetchedQueryOptions(queryKey, serverFn, arg)
  2. serverFn 无参数 → refetchedQueryOptions(queryKey, serverFn)

首先定义一个任意异步函数类型,方便复用:

type AnyAsyncFn = (...args: any[]) => Promise<any>;

接着写一个条件类型,提取 serverFn 参数里 data 的类型:

type ServerFnArgs<TFn extends AnyAsyncFn> = Parameters<TFn>[0] extends { data: infer TResult }
  ? TResult
  : undefined;

再用它判断“是否有参数”:

type ServerFnHasArgs<TFn extends AnyAsyncFn> = ServerFnArgs<TFn> extends undefined ? false : true;

最后把函数分成“有参版本”和“无参版本”:

type ServerFnWithArgs<TFn extends AnyAsyncFn> = ServerFnHasArgs<TFn> extends true ? TFn : never;
type ServerFnWithoutArgs<TFn extends AnyAsyncFn> = ServerFnHasArgs<TFn> extends false ? TFn : never;

作者还提醒:TS 的重载返回值最好显式写出来,因此他定义了一个返回类型:

type RefetchQueryOptions<T> = {
  queryKey: QueryKey;
  queryFn: (_?: any) => Promise<T>;
  meta: any;
};

最终重载签名

export function refetchedQueryOptions<TFn extends AnyAsyncFn>(
  queryKey: QueryKey,
  serverFn: ServerFnWithArgs<TFn>,
  arg: Parameters<TFn>[0]["data"]
): RefetchQueryOptions<Awaited<ReturnType<TFn>>>;

export function refetchedQueryOptions<TFn extends AnyAsyncFn>(
  queryKey: QueryKey,
  serverFn: ServerFnWithoutArgs<TFn>
): RefetchQueryOptions<Awaited<ReturnType<TFn>>>;

真实实现

export function refetchedQueryOptions<TFn extends AnyAsyncFn>(
  queryKey: QueryKey,
  serverFn: ServerFnWithoutArgs<TFn> | ServerFnWithArgs<TFn>,
  arg?: Parameters<TFn>[0]["data"]
): RefetchQueryOptions<Awaited<ReturnType<TFn>>> {
  const queryKeyToUse = [...queryKey];
  if (arg != null) {
    queryKeyToUse.push(arg);
  }
  return {
    queryKey: queryKeyToUse,
    queryFn: async () => {
      return serverFn({ data: arg });
    },
    meta: {
      __revalidate: {
        serverFn,
        arg,
      },
    },
  };
}

到这里就完成了:泛型 + 条件类型 + infer + 重载,组合起来可以“问对问题”,并把你想要的精确 API 表达出来。

结语

作者最后说:你大概率不会在日常工作里遇到完全相同的问题,但这套思路非常通用——它教你如何把类型系统当作工具,让 API 的“可用性”和“约束力”同时到位。

❌
❌