普通视图

发现新文章,点击刷新页面。
昨天 — 2026年3月1日首页

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

2026年3月1日 22:04

原文: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 更容易「善后」的新能力

2026年3月1日 22:02

原文: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 打造完美的饼图

2026年2月28日 23:59

原文: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 实现弹性文字效果

2026年3月1日 00:00

原文: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() 等现代特性,但为这类效果牺牲可访问性并不值得。

❌
❌