阅读视图

发现新文章,点击刷新页面。

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

原文: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 项目

原文: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

原文: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 企业级落地

原文: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 泛型

原文: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 的“可用性”和“约束力”同时到位。

用 HTMX 为 React Data Grid 加速实时更新

原文:Integrating HTMX into a React Data Grid for Real‑Time Updates in Next.js

翻译:TUARAN

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

React 非常适合构建动态交互界面,但随着应用增长,客户端渲染开销、包体积和状态管理复杂度会逐渐增加。

HTMX 提供了另一条路径:通过 HTML 属性驱动请求与局部替换,把一部分更新逻辑交还给服务端。

本文将演示如何在 Next.js(React 19)中集成 HTMX,并结合 Syncfusion React Data Grid,通过单个 SSE 连接实现实时更新。

为什么 HTMX 适合 React + Next.js

HTMX 并不是为了替代 React,而是作为一个轻量增强层:

  • 通过 hx-gethx-swaphx-trigger 等属性,浏览器可以在指定事件触发时自动发起请求,并把响应片段直接更新到 DOM。
  • 在 Data Grid 这种“更新频繁、改动局部”的场景中,让服务端返回 HTML 片段,通常比在客户端维护大量同步状态更直接。

典型例子是仪表盘或 CRUD 页面:某些单元格需要高频刷新。如果完全由客户端状态管理驱动,复杂度和性能压力会快速上升;而 HTMX 正擅长这种“局部、频繁、小改动”的更新模式。

在 Next.js(React 19)项目中接入 HTMX

前置条件

  • Node.js 20+
  • npm / pnpm / yarn
  • Next.js 15.1+
  • React / React-DOM 19
  • 任意编辑器(如 VS Code)

第 1 步:创建 Next.js 项目

npx create-next-app@latest my-htmx-app --typescript --app
cd my-htmx-app && npm install

第 2 步:在 Layout 中加载 HTMX

HTMX 尽量在页面生命周期更早的阶段加载,作者建议直接放进 app/layout.tsx,确保 hx-* 属性立即可用,同时启用 SSE 扩展。

示例(原文思路整理版):

import Script from "next/script";

export default function RootLayout({ children }: { children: React.ReactNode }) {
   return (
      <html lang="en">
         <head>
            <Script src="https://unpkg.com/htmx.org@2.0.1" strategy="beforeInteractive" />
            <Script
               src="https://unpkg.com/htmx.org@1.9.10/dist/ext/sse.js"
               strategy="beforeInteractive"
            />
         </head>
         <body>{children}</body>
      </html>
   );
}

作者给出的理由是:

  • 让 Next.js 处理脚本加载顺序
  • 不需要打包或额外的构建配置
  • HTMX 可同时作用于 SSR/CSR 渲染出的 DOM

第 3 步:安装并配置 Syncfusion React 组件

npm install @syncfusion/ej2-react-grids @syncfusion/ej2-react-buttons

在全局样式中引入样式(原文使用 Tailwind 主题样式):

@import "@syncfusion/ej2-react-grids/styles/tailwind.css";
@import "@syncfusion/ej2-react-buttons/styles/tailwind.css";

在 React Data Grid 中实现实时更新

作者举了一个简单的订单列表:列包括 OrderIDCustomerIDFreight,并让 Freight 每 5 秒更新一次,模拟实时价格变化。

常见误区:每行一个 SSE 连接

直觉上,你可能会让每一行自己开一条 SSE 连接来收更新。但浏览器对并发 SSE 连接数有上限,作者指出“前几行能工作,后面就不行了”。

解决方案:一个 SSE 端点广播所有行

核心思路:

  • 只建立 一个 SSE 连接
  • 服务端每次推送时,为每行发送一个具名事件,例如 freight-updated-1001
  • 每个单元格只监听属于自己的事件名

这样可以绕开连接数限制,同时依然做到“行级别、单元格级别”的更新。

创建 Data Grid(React + HTMX)

作者给出的示例代码(保留原意并整理为可读格式)。注意:原文示例里 Freight 字段名处存在一个引号/拼写小问题,这里按语义修正为 Freight

import { useEffect } from "react";
import { GridComponent, ColumnsDirective, ColumnDirective } from "@syncfusion/ej2-react-grids";

declare global {
   interface Window {
      htmx?: any;
   }
}

const data = Array.from({ length: 10 }, (_, i) => ({
   OrderID: 1000 + i + 1,
   CustomerID: ["ALFKI", "ANANTR", "ANTON", "BLONP", "BOLID"][Math.floor(Math.random() * 5)],
   OrderDate: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString(),
   Freight: (2.1 * (i + 1)).toFixed(2),
}));

export default function Home() {
   useEffect(() => {
      if (typeof window === "undefined" || !window.htmx) {
         console.error("HTMX not loaded");
         return;
      }

      const container = document.querySelector("#htmx-container");
      if (container) {
         window.htmx.process(container);
      }

      const observer = new MutationObserver(() => {
         if (container) window.htmx.process(container);
      });

      observer.observe(container || document.body, { childList: true, subtree: true });
      return () => observer.disconnect();
   }, []);

   return (
      <div id="htmx-container" className="p-6 max-w-4xl mx-auto">
         <GridComponent dataSource={data} className="border rounded-lg shadow" allowPaging={false}>
            <ColumnsDirective>
               <ColumnDirective field="OrderID" headerText="Order ID" width="80" textAlign="Right" />
               <ColumnDirective field="CustomerID" headerText="Customer" width="100" />
               <ColumnDirective
                  field="Freight"
                  headerText="Freight"
                  width="80"
                  textAlign="Right"
                  template={(props: any) => (
                     <div
                        data-hx-sse={`connect:/api/updates swap:freight-updated-${props.OrderID}`}
                        data-hx-target="this"
                        data-hx-swap="innerHTML"
                        className="p-1"
                     >
                        {props.Freight}
                     </div>
                  )}
               />
            </ColumnsDirective>
         </GridComponent>
      </div>
   );
}

关键点是 data-hx-sse:它负责连接 SSE 并监听事件,然后把事件数据替换到当前单元格里。

创建 SSE 端点

服务端用一个静态 API 路由持续输出 text/event-stream

import { NextResponse } from "next/server";

export async function GET(request: Request) {
   const headers = {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
   };

   const stream = new ReadableStream({
      async start(controller) {
         const interval = setInterval(() => {
            for (let i = 1001; i <= 1010; i++) {
               const newFreight = (Math.random() * 100).toFixed(2);
               const payload = `event: freight-updated-${i}\ndata: ${newFreight}\n\n`;
               controller.enqueue(new TextEncoder().encode(payload));
            }
         }, 5000);

         request.signal.addEventListener("abort", () => {
            clearInterval(interval);
            controller.close();
         });
      },
   });

   return new NextResponse(stream, { headers });
}

这样,单个 SSE 连接就能把 10 行(甚至更多)的 Freight 更新“广播”出去,而每个单元格只消费自己关心的事件。

最终效果(原文动图):

GitHub 参考

示例代码仓库:

常见问题(FAQ)

为什么要把 HTMX 和 React 混用?

作者的回答是:HTMX 负责“快、轻、局部”的 HTML 替换(表单、懒加载区块、局部刷新、实时更新等),React 负责复杂、状态密集的 UI 部分。组合起来的结果是:

  • 更小的包体积
  • 更快的主观速度
  • 更少的前端状态与胶水代码

什么时候选择 React + HTMX,而不是全靠 React/Next.js?

适合这些情况:

  • 你想把 JS 负载压到更小
  • 交互大多可以通过服务端驱动的局部更新完成
  • 后端本来就能产出不错的 HTML
  • “速度 + 简洁”比“复杂的客户端状态”更重要

结语

把 HTMX 和 Next.js / React Data Grid 组合在一起,你可以同时得到:React 的组件化能力 + HTMX 的轻量局部更新能力。在需要实时更新、但又不想引入额外复杂状态层的 Data Grid 场景里,这是一条非常值得尝试的路线。

让 JavaScript 更容易「善后」的新能力

原文:It’s about to get a lot easier for your JavaScript to clean up after itself

翻译:TUARAN

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

wechat_2026-03-01_220216_455.png

JavaScript 开发者大致可以分成两类:一类偏“随性”,一类偏“整理控”。作者说自己在现实生活里并不整洁,但写 JavaScript 时会非常在意秩序:默认使用 const、重视作用域,并希望代码在完成工作后把资源也清理干净。

也正因为如此,他对 TC39 的 Explicit Resource Management(显式资源管理)提案非常兴奋:这个提案不仅把许多已有实践系统化,还希望给 JavaScript 提供统一、可靠的资源清理机制。

本文会先介绍“隐式资源管理”,再进入“显式资源管理”的核心能力:[Symbol.dispose] 与新的 using 声明。

隐式资源管理(Implicit resource management)

如果你用过 WeakSetWeakMap,其实已经见过一种“隐式资源管理”的思想。

WeakSet / WeakMap 的 “weak(弱引用)”含义是:它们对值(或 key)的引用不会阻止垃圾回收(GC)。当某个对象在程序里不再被其他地方引用时,它就有机会被回收;一旦被回收,WeakSet/WeakMap 里对应的条目也可能随之消失。

因此,WeakSet/WeakMap 只能存放可被 GC 的值:对象引用,以及未注册到全局 Symbol 注册表的 Symbol。比如尝试把 true 这种原始值放进 WeakSet,会报错:

const theWeakSet = new WeakSet([true]);

WeakMap 的典型用途是:给某个对象“外挂”一些关联数据,但又不把数据真的挂在对象本身上,同时也不阻止对象被 GC:

const theObject = {};
const theWeakMap = new WeakMap([[theObject, "A string, say, describing the object."]]);

console.log(theWeakMap.get(theObject));

看上去很美:对象没了,关联数据也应该跟着消失——像极了“代码会自己打扫卫生”。

不过作者也提醒:垃圾回收何时发生是不确定的。也就是说,即便对象已经没有其他引用,你也不能保证它立刻被回收;因此 WeakMap 里的条目也不一定马上消失。

隐式资源管理的好处是“你不用管”;坏处是“你也管不了”。

显式资源管理(Explicit resource management)

显式资源管理并不是让你手动管理内存(GC 依然是引擎的事),它解决的是另一类更常见、更工程化的问题:

当某个资源“用完了”,我们希望能确定执行一组清理动作。

这里的“资源”可以理解为:有明确“结束状态”的对象。例如:文件句柄、WebSocket 连接、流、锁、订阅、观察者、以及各种需要 close() / disconnect() / abort() 的东西。

作者用 generator 举例,说明“生命周期结束时执行清理”在 JS 里并不陌生:generator 的 done 会在迭代结束时变成 true;并且你可以在 generator 内用 try...finally 来保证收尾逻辑被执行。

一个简化示例:

function* generatorFunction() {
try {
yield true;
yield false;
} finally {
console.log("All done.");
}
}

const generatorObject = generatorFunction();

console.log(generatorObject.next());
console.log(generatorObject.next());
console.log(generatorObject.next());

如果你提前调用 return(),也会走到 finally

console.log(generatorObject.return());

作者把这种“我明确地让它现在结束并清理”的方式称为命令式(imperative)资源管理:比如你手动调用 close()abort()disconnect()

问题在于:这些清理方法在不同 API 里名字五花八门,而我们做的事却高度一致——“把它关掉、清理掉”。于是提案引入了一个统一约定:

  • 对需要清理的资源,提供一个标准方法:[Symbol.dispose]()

以 generator 为例,它可以把 [Symbol.dispose] 标准化为对 return() 的包装:

console.log(generatorObject[Symbol.dispose]());

这在 generator 场景里看起来变化不大,但意义很大:它为“任何需要清理的资源”提供了统一入口。

using:声明式资源管理

有了统一的 [Symbol.dispose](),提案就可以再向前一步:提供声明式(declarative)资源管理

也就是:不再靠“记得手动调用 dispose”,而是把资源的清理动作绑定到作用域生命周期上。

提案为此引入了一个新的变量声明关键字:using

  • using 声明是块级作用域(和 const / let 类似)。
  • using 声明的绑定不可重新赋值(像 const)。
  • 当代码执行离开该作用域时,引擎会自动调用资源的 disposer,即 resource[Symbol.dispose]()

一个最小示例:

{
using theObject = {
[Symbol.dispose]() {
console.log("All done.");
},
};
// 离开作用域时,会自动输出 "All done."
}

需要注意:using 不是“更酷的 const”。它只能用于:

  • null / undefined
  • 或者拥有 [Symbol.dispose]() 的对象

比如这样会报错(因为 {} 没有 disposer):

{
using theObject = {};
}

并且 using 必须处在某个明确的作用域中(块、函数体、静态初始化块、for/for-of/for-await-of 的初始化部分,或模块顶层),否则它就没有“离开作用域”这一刻,也就失去了意义。

回到文章前面那个“把文件开着就走了”的 generator 场景:如果用 using 来声明 generator 对象,那么在离开作用域时就会自动触发清理:

{
function* generatorFunction() {
console.log("Open a file.");
try {
yield true;
yield false;
} finally {
console.log("Close the file.");
}
}

using generatorObject = generatorFunction();
console.log(generatorObject.next());
}

同理,如果你写一个类实例需要“用完自动收尾”,也可以直接实现 [Symbol.dispose]()

class TheClass {
theFile;

constructor(theFile) {
this.theFile = theFile;
console.log(`Open ${theFile}`);
}

[Symbol.dispose]() {
console.log(`Close ${this.theFile}`);
}
}

const theFile = "./some-file";

if (theFile) {
using fileOpener = new TheClass(theFile);
console.log(`Do things with ${fileOpener.constructor.name}, then...`);
}

现状与落地

作者提到:该提案已进入 TC39 Stage 3(推荐实现),并且大多数浏览器已经支持(Safari 仍缺席)。你可以在 caniuse 上查看:

当然,Stage 3 仍然意味着“可能还有语法细节会变”,所以更适合现在就开始在实验/非生产环境熟悉它。

作者最后把这件事总结为一种很朴素、但非常工程化的收益:

JS 终于开始从“全靠自觉的清理”走向“语言级别帮助你不忘记清理”。

用 CSS 打造完美的饼图

原文:Trying to Make the Perfect Pie Chart in CSS

翻译:TUARAN

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

说到图表……你上次使用饼图是什么时候?如果你是那些需要到处做演示的人之一,那么恭喜!你既在我个人的地狱里……也被饼图包围着。幸运的是,我想我很久没需要用过它们了,至少直到最近是这样。

去年,我自愿为墨西哥的一个儿童慈善机构制作网页。一切都很标准,但工作人员希望在他们的落地页上以饼图展示一些数据。他们给我们的时间不多,所以我承认我走了捷径,使用了众多用于制作图表的 JavaScript 库之一。

看起来不错,但内心深处我感到不安;为几个简单的饼图引入整个库。感觉像是走捷径,而不是打造真正的解决方案。

我想弥补这一点。在本文中,我们将尝试用 CSS 制作完美的饼图。这意味着在解决手写饼图带来的主要头痛问题的同时,尽可能减少 JavaScript。但首先,让我们设定我们的「完美」应该遵守的一些目标。

按优先级排序:

  1. 应该将 JavaScript 保持在最低限度!不是对 JavaScript 有意见,只是这样更有趣。
  2. 应该是 HTML 可定制的!一旦 CSS 完成,我们只需要修改标记就可以自定义饼图。
  3. 必须是语义化的!这意味着屏幕阅读器应该能够理解饼图中显示的数据。

完成后,我们应该得到像这样的饼图:

这要求太多吗?也许吧,但无论如何我们会试试。

圆锥渐变(conic gradients)不是最佳选择

我们不能在谈论饼图时不先谈谈圆锥渐变。如果你读过任何与 conic-gradient() 函数相关的内容,那么你可能已经看到它们可以用来在 CSS 中创建简单的饼图。见鬼,甚至我在年鉴条目中也这么说过。为什么不呢?只需要一个元素和一行 CSS……

.gradient {
  background: conic-gradient(blue 0% 12.5%, lightblue 12.5% 50%, navy 50% 100%);
}

我们可以得到无缝完美的饼图:

CodePen Embed Fallback

然而,这种方法公然违背了我们语义化饼图的第一个目标。正如同一条目后面所指出的:

不要使用 conic-gradient() 函数创建真正的饼图或任何其他信息图。它们不包含任何语义含义,应仅用于装饰目的。

请记住,渐变是图像,因此将渐变显示为 background-image 不会告诉屏幕阅读器关于饼图本身的任何信息;它们只能看到一个空元素。

这也违背了我们的第二条规则,即让饼图可通过 HTML 定制,因为对于每个饼图,我们都必须更改其对应的 CSS。

那么我们是否应该完全抛弃 conic-gradient()?尽管我很想这么做,但它的语法太好了,不能错过,所以让我们至少尝试弥补它的缺点,看看能带我们走到哪里。

改进语义

conic-gradient() 第一个也是最严重的问题是它的语义。我们想要一个包含所有数据的丰富标记,以便屏幕阅读器能够理解。我必须承认我不知道语义化书写的最佳方式,但在使用 NVDA 测试后,我相信这是一个足够好的标记:

<figure>
  <figcaption>上月售出的糖果</figcaption>
  <ul class="pie-chart">
    <li data-percentage="35" data-color="#ff6666"><strong>巧克力</strong></li>
    <li data-percentage="25" data-color="#4fff66"><strong>软糖</strong></li>
    <li data-percentage="25" data-color="#66ffff"><strong>硬糖</strong></li>
    <li data-percentage="15" data-color="#b366ff"><strong>泡泡糖</strong></li>
  </ul>
</figure>

理想情况下,这就是我们饼图所需要的全部,一旦样式完成,只需编辑 data-* 属性或添加新的 <li> 元素即可更新我们的饼图。

不过有一点:在目前的状态下,data-percentage 属性不会被屏幕阅读器朗读出来,所以我们必须将它作为伪元素附加到每个项目的末尾。记得在末尾加上「%」以便一起朗读:

.pie-chart li::after {
  content: attr(data-percentage) "%";
}

CodePen Embed Fallback

那么,它是否具有可访问性?至少在 NVDA 中测试时是的。这是 Windows 上的效果:

你可能对我为什么选择这个或那个有一些疑问。如果你信任我,我们继续,但如果不,这是我的思考过程:

为什么使用 data 属性而不是直接写入每个百分比?

我们很容易将它们写在每个 <li> 里面,但使用属性我们可以通过 attr() 函数在 CSS 中获取每个百分比。正如我们稍后将看到的,这使得在 CSS 中使用它变得容易得多。

为什么用 <figure>

<figure> 元素可以作为我们饼图的自包含包装器使用,除了图像之外,它也经常用于图表。很方便,因为我们可以通过 <figcaption> 给它一个标题,然后在无序列表中写出数据,我之前不知道 figure 允许的内容 中包括 ul 作为流内容

为什么不用 ARIA 属性?

我们可以使用 aria-description 属性让屏幕阅读器朗读每个项目对应的百分比,这可能是最重要的部分。然而,我们可能也需要在视觉上显示图例。这意味着在语义和视觉上都有百分比没有优势,因为它们可能会被朗读两次:(1)在 aria-description 上一次,(2)在伪元素上又一次。

做成饼图

我们已经在纸上有了数据。现在是时候让它看起来像一个真正的饼图了。我首先想到的是,「这应该很容易,有了标记,我们现在可以使用 conic-gradient() 了!」

嗯……我大错特错了,但不是因为语义,而是因为 CSS 层叠的工作原理。

让我们再看看 conic-gradient() 的语法。如果我们有以下数据:

  • 项目 3:50%
  • 项目 2:35%
  • 项目 1:15%

……那么我们会写下以下 conic-gradient()

.gradient {
  background: 
    conic-gradient(
      blue 0% 15%, 
      lightblue 15% 50%, 
      navy 50% 100%
    );
}

这基本上是说:「从 0 到 15% 画第一种颜色,下一种颜色从 15% 到 50%(所以差值是 35%),以此类推。」

你看到问题了吗?饼图是在单个 conic-gradient() 中绘制的,这等于单个元素。你可能看不到,但这很糟糕!如果我们想在 data-percentage 中显示每个项目的权重——让一切更漂亮——那么我们需要一种从父元素访问所有这些百分比的方法。这是不可能的!

我们能够利用 data-percentage 简单性的唯一方法是每个项目绘制自己的扇形。然而,这并不意味着我们不能使用 conic-gradient(),而是我们需要使用多个。

计划是让每个项目都有自己的 conic-gradient() 绘制其扇形,然后将它们全部叠在一起:

为此,我们首先给每个 <li> 一些尺寸。我们不会硬编码大小,而是定义一个 --radius 属性,这在后面保持样式可维护时会很有用。

.pie-chart li {
  --radius: 20vmin;

  width: calc(var(--radius) * 2); /* 半径的两倍 = 直径 */
  aspect-ratio: 1;
  border-radius: 50%;
}

然后,我们使用 attr() 及其新类型语法data-percentage 属性引入 CSS,该语法允许我们将属性解析为字符串以外的内容。请注意,在我写这篇文章时,新语法目前仅限于 Chromium。

然而,在 CSS 中使用小数(如 0.1)比使用百分比(如 10%)更好,因为我们可以将它们乘以其他单位。所以我们将 data-percentage 属性解析为 <number>,然后除以 100 得到小数形式的百分比。

.pie-chart li {
  /* ... */
  --weighing: calc(attr(data-percentage type(<number>)) / 100);
}

我们仍然需要它作为百分比,这意味着将结果乘以 1%

.pie-chart li {
  /* ... */
  --percentage: calc(attr(data-percentage type(<number>)) * 1%);
}

最后,我们再次使用 attr() 从 HTML 获取 data-color 属性,但这次使用 <color> 类型而不是 <number>

.pie-chart li {
  /* ... */
  --bg-color: attr(data-color type(<color>));
}

让我们暂时把 --weighing 变量放在一边,使用另外两个变量创建 conic-gradient() 扇形。它们应该从 0% 到所需百分比,然后 thereafter 变为透明:

.pie-chart li {
  /* ... */
   background: conic-gradient(
   var(--bg-color) 0% var(--percentage),
   transparent var(--percentage) 100%
  );
}

我显式定义了起始 0% 和结束 100%,但由于这些是默认值,我们 technically 可以删除它们。

这是我们目前的进度:

CodePen Embed Fallback

如果你的浏览器不支持新的 attr() 语法,也许一张图片会有所帮助:

现在所有扇形都完成了,你会注意到每个扇形都从顶部开始,顺时针方向延伸。我们需要将它们定位成,你知道的,饼图形状,所以下一步是适当旋转它们以形成圆形。

就在这时我们遇到了一个问题:每个扇形旋转的量取决于它前面的项目数量。我们必须将项目旋转前面扇形的大小。理想情况下,有一个累加器变量(如 --accum)保存每个项目之前百分比的总和。然而,由于 CSS 层叠的工作方式,我们既不能在兄弟之间共享状态,也不能在每个兄弟上更新变量。

相信我,我真的努力绕过这些问题。但我们似乎被迫在两个选项之间做出选择:

  1. 使用 JavaScript 计算 --accum 变量。
  2. 在每个 <li> 元素上硬编码 --accum 变量。

如果我们重新审视我们的目标,选择并不难:硬编码 --accum 会否定灵活的 HTML,因为移动项目或更改百分比会迫使我们再次手动计算 --accum 变量。

然而,JavaScript 使这变得微不足道:

const pieChartItems = document.querySelectorAll(".pie-chart li");

let accum = 0;

pieChartItems.forEach((item) => {
  item.style.setProperty("--accum", accum);
  accum += parseFloat(item.getAttribute("data-percentage"));
});

有了 --accum,我们可以使用 from 语法 旋转每个 conic-gradient(),该语法告诉圆锥渐变旋转的起点。问题是它只接受角度,不接受百分比。(我觉得百分比也应该可以工作,但这是另一个话题)。

为了解决这个问题,我们必须创建另一个变量——我们称它为 --offset——它等于转换为角度的 --accum。这样,我们可以将值插入每个 conic-gradient()

.pie-chart li {
  /* ... */
  --offset: calc(360deg * var(--accum) / 100);

  background: conic-gradient(
    from var(--offset),
    var(--bg-color) 0% var(--percentage),
    transparent var(--percentage) 100%
  );
}

我们看起来好多了!

CodePen Embed Fallback

剩下的就是把所有项目叠在一起。当然有很多方法可以做到这一点,但最简单的可能是 CSS Grid。

.pie-chart {
  display: grid;
  place-items: center;
}

.pie-chart li {
  /* ... */
  grid-row: 1;
  grid-column: 1;
}

这几行 CSS 将所有扇形排列在 .pie-chart 容器的正中心,每个扇形覆盖容器的唯一行和列。它们不会碰撞,因为它们被正确旋转了!

CodePen Embed Fallback

除了那些重叠的标签,我们的状态真的非常非常好!让我们清理一下。

定位标签

现在,<li> 里面的名称和百分比标签彼此散落在一起。我们希望它们浮动在各自扇形的旁边。为了修复这个问题,让我们首先使用与容器本身相同的网格居中技巧,将所有项目移动到 .pie-chart 容器的中心:

.pie-chart li {
  /* ... */
  display: grid;
  place-items: center;
}

.pie-chart li::after,
strong {
  grid-row: 1;
  grid-column: 1;
}

幸运的是,我已经探索过如何使用较新的 CSS 的 cos()sin() 在圆上布局东西。去看看那些链接,因为那里有很多上下文。简而言之,给定一个角度和半径,我们可以使用 cos()sin() 来获取圆上每个项目的 X 和 Y 坐标。

为此,我们需要——你猜对了!——另一个表示角度的 CSS 变量(我们称之为 --theta),我们将在那里放置每个标签。我们可以用下一个公式计算该角度:

.pie-chart li {
  /* ... */
  --theta: calc((360deg * var(--weighing)) / 2 + var(--offset) - 90deg);
}

值得了解该公式在做什么:

  • - 90degcos()sin() 的角度从右边测量,但 conic-gradient() 从顶部开始。这部分通过 -90deg 校正每个角度。
  • + var(--offset):移动角度以匹配当前偏移。
  • 360deg * var(--weighing)) / 2:将百分比作为角度获取,然后除以二以找到中点。

我们可以使用 --theta--radius 变量找到 X 和 Y 坐标,如下面的伪代码:

x = cos(theta) * radius
y = sin(theta) * radius

翻译成……

.pie-chart li {
  /* ... */
  --pos-x: calc(cos(var(--theta)) * var(--radius));
  --pos-y: calc(sin(var(--theta)) * var(--radius));
}

这会将每个项目放在饼图的边缘,所以我们会在它们之间添加一个 --gap

.pie-chart li {
  /* ... */
  --gap: 4rem;
  --pos-x: calc(cos(var(--theta)) * (var(--radius) + var(--gap)));
  --pos-y: calc(sin(var(--theta)) * (var(--radius) + var(--gap)));
}

然后我们用 --pos-x--pos-y 平移每个标签:

.pie-chart li::after,
strong {
  /* ... */
  transform: translateX(var(--pos-x)) translateY(var(--pos-y));
}

哦等等,还有一个小细节。每个项目的标签和百分比仍然叠在一起。幸运的是,修复就像在 Y 轴上再多平移一点百分比一样简单:

.pie-chart li::after {
  --pos-y: calc(sin(var(--theta)) * (var(--radius) + var(--gap)) + 1lh);
}

现在我们在用煤气做饭了!

CodePen Embed Fallback

让我们确保这对屏幕阅读器友好:

暂时就这些……

我会称这是朝着「完美」饼图迈出的非常好的第一步,但仍有一些我们可以改进的地方:

  • 这似乎迫切需要一种漂亮的悬停效果,比如 maybe 放大扇形并显示它?
  • 不同类型的图表呢?柱状图,有人要吗?
  • data-color 属性很好,但如果没有提供,我们仍然应该提供一种让 CSS 生成颜色的方式。也许是 color-mix() 的好工作?
  • 饼图假设你会自己写百分比,但应该有一种方式输入原始项目数量,然后计算它们的百分比。

这就是我目前能想到的全部,但我已经在计划在后续文章中逐步解决这些问题(懂吗?!)。此外,没有大量反馈就没有完美,所以告诉我你会改变或添加什么到这个饼图中,让它真正完美!


纯 CSS 实现弹性文字效果

原文:How to Create a CSS-only Elastic Text Effect

翻译:TUARAN

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

每个字母单独动画的文字效果总是很酷、很吸睛。这类错峰动画通常依赖 JavaScript 库实现,对我们要实现的这种相对轻量的设计效果来说,代码往往偏重。本文将探索只用 CSS、无需 JavaScript 实现 fancy 文字效果的技巧(意味着需要手动拆分字符)。

截至撰写时,仅 Chrome 和 Edge 完全支持我们使用的特性。

将鼠标悬停在下方演示的文字上,即可看到效果:

CodePen Embed Fallback

很酷吧?仅靠 CSS 就实现了逼真的弹性效果,而且灵活易调。在深入代码之前,先做一个重要声明。这个效果不错,但有几个明显的缺点。

关于可访问性的重要声明

我们要做的效果依赖于把单词拆成单个字母,一般来说这种做法非常不推荐。

一个简单链接通常是这样写的:

<a href="#">About</a>Code language: HTML, XML (xml)

但要分别控制每个字母的样式,我们会改成这样:

<a href="#">
  <span>A</span><span>b</span><span>o</span><span>u</span><span>t</span>
</a>Code language: HTML, XML (xml)

这会带来可访问性问题。

很容易想到用 aria-* 属性来弥补。至少我之前是这么想的。网上有不少资料推荐类似下面的结构:

<a href="#" aria-label="About">
  <span aria-hidden="true">
    <span>A</span><span>b</span><span>o</span><span>u</span><span>t</span>
  </span>
</a>Code language: HTML, XML (xml)

看起来没问题吧?不!这种结构依然很糟糕。实际上,网上能找到的大多数结构都有问题。我不是这个领域的专家,所以请教了一些人,发现 Adrian Roselli 的两篇博客很有参考价值:

强烈建议读一读,理解为什么把单词拆成字母是个坏主意(以及可能的替代方案)。

那我为什么还要做这个演示?

我更倾向于把它当作一次探索现代 CSS 特性的实验。这个效果里可能有很多你还不熟悉的属性,是了解它们的好机会。可以用在娱乐或 side project 中,但在广泛使用或关键场景中引入前,请三思。

好了,声明完毕,我们开始。

原理说明

思路是使用 offset() 属性,定义字母沿一条路径运动。这条路径是一条曲线,我们沿曲线做动画。offset() 是一个被低估的特性,但潜力很大,尤其配合现代 CSS 使用时。我曾用它做过无限跑马灯动画、让元素沿圆精确排布、做图片画廊等。

下面是一个简化示例,帮助理解我们要用的技巧:

CodePen Embed Fallback

上面的演示使用了来自 SVG 的 path() 值。三个字母最初沿第一条路径,悬停时切换到第二条路径。借助 transition,就形成了平滑的效果。

可惜的是,使用 SVG 并不理想,因为你只能创建静态、基于像素的路径,无法用 CSS 控制。因此我们将转而使用新的 shape() 函数,它可以定义复杂形状(包括曲线),并方便地用 CSS 控制。

本文只用到 shape() 的简单用法(只需要一条曲线),如果想深入了解这个强大函数,可以参考我之前的文章:

开始写代码

用到的 HTML:

<ul>
  <li>
    <a href="#"><span>A</span><span>b</span><span>o</span><span>u</span><span>t</span></a>
  </li>
  <!-- 更多 li 元素 -->
</ul>Code language: HTML, XML (xml)

CSS:

ul li a {
  display: flex;
  font-family: monospace;
}
ul li a span {
  offset-path: shape(???);
  offset-distance: ???;
}
ul li a:hover {
  offset-path: shape(???);
}Code language: CSS (css)

目前还比较朴素

CodePen Embed Fallback

用 flex 让字母并排,并用等宽字体,确保每个字母宽度一致。

接下来用下面的代码定义路径:

offset-path: shape(from Xa Ya, curve to Xb Yb with Xc Yc / Xd Yd );Code language: CSS (css)

这里用 curve 命令在 A 到 B 之间画贝塞尔曲线,控制点为 C 和 D。

然后通过调整控制点的坐标(尤其是 Y 值)来驱动曲线动画。当 Y 与 A、B 的 Y 相同时是直线;更大时变成曲线。

曲线的代码大致如下:

offset-path: shape(from Xa Y, curve to Xb Y with Xc Y1 / Xd Y1);

直线的代码如下:

offset-path: shape(from Xa Y, curve to Xb Y with Xc Y / Xd Y);

注意我们只改控制点的 Y,其他保持不变。

现在来确定各参数。使用 offset 时有两个要点:

  1. 默认以元素中心作为在路径上的位置。
  2. 定义在子元素上,但参考框是父容器。

第一个字母应在路径起点,最后一个在终点,所以 A 是第一个字母中心,B 是最后一个字母中心:

Y = 50%Xa = .5chXb = 100% - Xa = 100% - .5ch

C 和 D 的 X 没有固定规则,可以任意指定。我选 Xc = 30%Xd = 100% - Xc = 70%。你可以自己调整这些值试验不同的曲线形态。

路径现在可以这样写:

offset-path: shape(from .5ch 50%, curve to calc(100% - .5ch) 50% with 30% Y / 70% Y);

Y 是变量,可以是 50%(与 A、B 相同)或别的值,我们设成 50% - HH 越大,弹性越强。

试试看:

CodePen Embed Fallback

一团糟!因为我们没定义 offset-distance,所有字母都叠在一起了。

是不是要给每个字母单独设位置?那太麻烦了。

我们必须给每个字母不同的位置,好在可以用一个公式配合 sibling-index()sibling-count() 搞定。

第一个字母在 0%,最后一个在 100%。共 N 个字母,步长为 100%/(N - 1),字母从 0%100% 依次排布,公式为:

offset-distance: (100% * i)/(N - 1)

其中 i 从 0 开始。

写成 CSS:

offset-distance: calc(100%*(sibling-index() - 1)/(sibling-count() - 1))Code language: CSS (css)

CodePen Embed Fallback

几乎完美。除了最后一个字母外都位置正确。由于某种原因,0%100% 被当成同一个点。offset-distance 不限于 0%–100%,可以取任意值(包括负值),有一种取模行为形成环路。你可以从 0%100% 走完整条路径,到 100% 后又回到起点,还能继续从 100%200%,如此往复。

虽然有点反直觉,但修复很简单:把 100% 换成 99.9%。有点 hack,但有效!

CodePen Embed Fallback

现在排布完美了,悬停时可以看到直线变成曲线的过程。

最后加上 transition,就大功告成!

CodePen Embed Fallback

可能还不算完全搞定,因为动画似乎有些异常。这很可能是 bug(我已在此提交),不过问题不大,因为我本来就打算重构,避免重复写两次 shape,改为动画一个变量:

@property --_s {
  syntax: "<number>";
  initial-value: 0;
  inherits: true;
}
ul li a {
  --h: 20px; /* 控制效果强度 */
 
  display: flex;
  font: bold 40px monospace;
  transition: --_s .3s;
}
ul li a:hover {
  --_s: 1;
}
ul li a span {
  offset-path: 
    shape(
      from .5ch 50%, curve to calc(100% - .5ch) 50% 
      with 30% calc(50% - var(--_s)*var(--h)) / 70% calc(50% - var(--_s)*var(--h))
    );
  offset-distance: calc(99.9%*(sibling-index() - 1)/(sibling-count() - 1));
}Code language: CSS (css)

现在有了 --h 变量来调节路径曲率,以及一个内部变量在 0 到 1 之间动画,实现从直线到曲线的过渡。

CodePen Embed Fallback

嗒哒!动画完美了!但弹性感呢?

要得到弹性效果,需要调整缓动,用到 linear()。这是最简单的部分,我用生成器生成取值。

多调几次直到满意。我得到的是:

CodePen Embed Fallback

效果已经不错,但如果微调曲线还能更好。目前所有单词的曲线「高度」是一样的,理想情况是根据单词长度变化。为此我会在公式里加入 sibling-count(),让单词越宽时高度越大。

CodePen Embed Fallback

让效果具备方向感知

效果已经可用,但既然做到这里,不妨再进一步:根据鼠标方向决定曲线向上还是向下。

向上的曲线已经通过 --_s: 1 实现:

ul li a:hover {
  --_s: 1;
}Code language: CSS (css)

若改为 -1,就得到向下的曲线:

CodePen Embed Fallback

现在需要把两种情况结合起来。从上方悬停时,使用向下曲线 --_s: -1;从下方悬停时,使用向上曲线 --_s: 1

首先给 li 加一个伪元素,填满上半部分并位于链接上方:

ul li {
  position: relative;
}
ul li:after {
  content: "";
  position: absolute;
  inset: 0 0 50%;
  cursor: pointer;
}Code language: CSS (css)

CodePen Embed Fallback

然后定义两个不同的选择器。当悬停伪元素时,相当于也悬停了 li,所以可以用:

ul li:hover a {
  --_s: -1;
}Code language: CSS (css)

悬停 a 时,同样会悬停 li,上面的规则也会生效。但若悬停的是伪元素,则没有悬停 a,因此可以用:

ul li:has(a:hover) a {
  --_s: 1;
}Code language: CSS (css)

有点绕?没关系,我们把两个选择器放在一起看:

ul li:hover a {
  --_s: -1;
}
ul li:has(a:hover) a {
  --_s: 1;
}Code language: CSS (css)

我们可以从上方(通过伪元素)或从下方(通过 a)悬停。前者会触发第一个选择器,因为我们在悬停 li,但不会触发第二个,因为 li「并没有悬停其 a」。当我们悬停 a 时,两个选择器都会触发,后者会胜出。

方向感知就这么实现了!

CodePen Embed Fallback

能用,但不如开头的演示那么流畅。当鼠标移动穿过整个元素时,会突然停止一个动画并切换到另一个。

可以调整伪元素的大小来改善。悬停时让它覆盖整个元素,这样就不会再触达下方的 a,第二个动画就不会触发。而悬停 a 时,把伪元素高度设为 0,就无法悬停它,从而不会触发第一个动画。

CodePen Embed Fallback

好多了!把伪元素设为透明,效果就很自然。

CodePen Embed Fallback

小结

希望你喜欢这次 CSS 小实验。再提醒一次:在项目中投入使用前请三思。这是一个很好的 demos 来了解 shape()linear()sibling-index() 等现代特性,但为这类效果牺牲可访问性并不值得。

❌