阅读视图

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

⏰前端周刊第 452 期(2026年2月2日-2月8日)

📢 宣言每周更新国外论坛的前端热门文章,推荐大家阅读/翻译,紧跟时事,掌握前端技术动态,也为写作或突破新领域提供灵感~

欢迎大家访问:github.com/TUARAN/fron… 顺手点个 ⭐ star 支持,是我们持续输出的续航电池🔋✨!

在线网址:frontendweekly.cn/

前端周刊封面


💬 推荐语

本期聚焦“交互组件选择 + 浏览器行为细节 + 生态工具更新”。Web 开发部分从组合框/多选/列表框的选型指南、浏览器对“意外”变更的敏感反应,到“不要把单词拆成字母”的可访问性提醒;工具与性能板块涵盖 Deno 生态新进展、ESLint 10 发布、ViteLand 月报、以及 SVG/视频与 Node.js 版本演进的性能分析。CSS 方面关注 @scope、@container scroll-state()、bar chart 与 clamp() 等现代特性;JavaScript 则有 Temporal 提案、显式资源管理、框架选型与 React/Angular 的新范式探讨。


🗂 本期精选目录

🧭 Web 开发

🛠 工具

⚡️ 性能

🎨 CSS

💡 JavaScript

《TanStack Start 深入解析:Single Flight Mutations 机制(第二篇)》

原文:Single Flight Mutations in TanStack Start: Part 2

作者:Adam Rackis

日期:2026年1月28日

翻译:TUARAN

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


TL;DR

这篇文章延续 Part 1 的思路:把一次 mutation 所需的 UI 更新数据,在同一次网络往返里一起带回来(避免 mutation 后再额外发请求 refetch)。

Part 2 的重点是把“要 refetch 哪些查询”抽成可复用的 middleware:调用 server function 时传入 react-query 的 QueryKey[],middleware 会在客户端从 Query Cache 找到每个 query 对应的 serverFn 和参数,把这些信息通过 sendContext 送到服务端统一执行,然后把结果回传给客户端并用 setQueryData 写回缓存。


Part 1 里,我们聊过 single flight mutations:它让你在更新数据时,同时把 UI 需要的所有相关“已更新数据”重新获取回来,并且整个过程只需要一次跨网络的往返。

我们当时做了个很朴素的实现:在“更新数据”的 server function 里直接把需要的东西 refetch 一遍。它确实能用,但可扩展性和灵活性都一般(耦合也偏重)。

这篇文章我们会实现同样的效果,但方式更通用:定义一个“refetch middleware”,把它挂到任意 server function 上。这个 middleware 允许我们通过 react-query 的 key 指定要 refetch 的数据,剩下的事情它会自动完成。

我们会先做一个最简单版本,然后不断加能力、加灵活性。到最后会稍微复杂一些,但请别误会:你不需要把文中讲的全部都用上。事实上,对绝大多数应用来说,single flight mutations 可能完全无关紧要。更别被“高级做法”迷惑了:对很多小应用而言,直接在 server function 里 refetch 一点数据可能就足够了。

不过,跟着做一遍,我们会看到一些很酷的 TanStack(甚至 TypeScript)特性。即便你永远不用 single flight mutations,这些内容也很可能在别的场景派上用场。

我们的第一个 Middleware

TanStack Query(我们有时也会称它为 react-query,这是它的包名)已经有一套非常好用的层级 key 系统。如果我们的 middleware 能直接接收“要 refetch 的 query keys”,然后就……自动搞定,那该多好?

问题在于:middleware 要怎么知道“怎么 refetch”呢?第一眼看确实有点难。我们的 queries(刻意保持简单)本质上都是对 server functions 的调用。但我们没法把一个普通函数引用传到服务端;函数不可序列化,这很合理。你能把字符串/数字/布尔值序列化成 JSON 在线上传输,但一个函数可能带状态、闭包、上下文……传过去根本说不清。

除非——它是 TanStack Start 的 server function。

这个项目背后的工程师们为序列化引擎做了定制,使其支持 server functions。也就是说:你可以从客户端把一个 server function “发到”服务端,它能正常工作。底层原理是:server functions 有一个内部 ID。TanStack 会捕捉到它、发送 ID,然后在另一端把 ID 反序列化成对应的 server function。

为了让事情更简单,我们不妨把 server function(以及它需要的参数)直接放到我们已经定义好的 query options 上。这样 middleware 只要拿到 query keys,就能从 TanStack Query 的 cache 里找到对应的 query options,拿到“如何 refetch”的信息,然后把整个流程串起来。

开始吧

首先引入一些好用的东西:

import { createMiddleware, getRouterInstance } from "@tanstack/react-start";
import { QueryClient, QueryKey } from "@tanstack/react-query";

接着更新我们的 epics 列表查询(主要的 epics 列表)的 query options:

export const epicsQueryOptions = (page: number) => {
  return queryOptions({
    queryKey: ["epics", "list", page],
    queryFn: async () => {
      const result = await getEpicsList({ data: page });
      return result;
    },
    staleTime: 1000 * 60 * 5,
    gcTime: 1000 * 60 * 5,
    meta: {
      __revalidate: {
        serverFn: getEpicsList,
        arg: page,
      },
    },
  });
};

注意这个新增的 meta 区块。它允许我们往 query 上塞任何我们需要的元数据。这里我们放了 getEpicsList 这个 server function 的引用以及它需要的参数。这样写确实会有“重复”(queryFn 写了一次调用方式,meta 又写了一次),如果你觉得别扭,先别急,后面会处理。summary 查询(用于统计数量)我们也会同样更新,不过这里没贴代码。

接下来我们把 middleware 一点点拼出来:

// the server function and args are all `any`, for now, 
// to keep things simple we'll see how to type them in a bit
type RevalidationPayload = {
  refetch: {
    key: QueryKey;
    fn: any;
    arg: any;
  }[];
};

type RefetchMiddlewareConfig = {
  refetch: QueryKey[];
};

export const refetchMiddleware = createMiddleware({ type: "function" })
  .inputValidator((config?: RefetchMiddlewareConfig) => config)
  .client(async ({ next, data }) => {
    const { refetch = [] } = data ?? {};

我们为 middleware 定义了一个输入。这个输入会自动与“挂载该 middleware 的 server function 的输入”合并。

我们把输入写成可选的(config?),因为完全可能出现这种情况:你只想调用 server function,但并不想 refetch 任何东西。

然后开始写 .client 回调(在浏览器中运行):先拿到要 refetch 的 keys:

const { refetch = [] } = data ?? {};

接着我们拿到 queryClient 和它的 cache,并创建一个 payload,之后会通过 sendContext 发到 .server 回调,让它执行真正的 refetch。

如果你对 TanStack middleware 不熟,我之前写的 middleware 文章 可能会更适合作为入门。

const router = await getRouterInstance();
const queryClient: QueryClient = router.options.context.queryClient;
const cache = queryClient.getQueryCache();

const revalidate: RevalidationPayload = {
  refetch: [],
};

我们的 queryClient 已经挂在 TanStack router 的 context 上,所以只要拿到 router 再取出来即可。

还记得我们把 __revalidate 塞到 query options 的 meta 里吗?现在我们针对每个 key 去 cache 里找对应 query,并把 serverFn/arg 抽出来组装成要发给服务端的 payload。

refetch.forEach((key: QueryKey) => {
  const entry = cache.find({ queryKey: key, exact: true });
  if (!entry) return;

  const revalidatePayload: any = entry?.meta?.__revalidate ?? null;

  if (revalidatePayload) {
    revalidate.refetch.push({
      key,
      fn: revalidatePayload.serverFn,
      arg: revalidatePayload.arg,
    });
  }
});

if (!entry) return; 是为了防止请求里包含了“当前缓存里根本不存在”的 query(也就是说,它可能从未在 UI 里被请求过)。这种情况下我们拿不到 serverFn,也就无法 refetch。

你也可以把 middleware 输入扩展得更丰富:比如对那些“无论是否在缓存里都必须执行”的 refetch,直接把 serverFn + arg 一起传上去。比如你打算 mutation 后 redirect,并希望新页面的数据能预取。本文不实现这个变体,但它只是同一主题的另一种组合。

接着我们调用 next,触发真正的 server function(以及其它 middleware)。通过 sendContext 我们把 revalidate 发到服务端:

const result = await next({
  sendContext: {
    revalidate,
  },
});

result 是 server function 调用的返回值。它的 context 上会有一个 payloads 数组(由下方 .server 回调返回),其中每一项都包含 key(query key)和 result(对应数据)。我们遍历并写回 query cache。

我们稍后会修复这里用 // @ts-expect-error 遮掉的 TS 错误:

// @ts-expect-error
for (const entry of result.context?.payloads ?? []) {
  queryClient.setQueryData(entry.key, entry.result);
}

return result;

服务端回调

服务端回调完整代码如下:

.server(async ({ next, context }) => {
  const result = await next({
    sendContext: {
      payloads: [] as any[]
    }
  });

  const allPayloads = context.revalidate.refetch.map(refetchPayload => {
    return {
      key: refetchPayload.key,
      result: refetchPayload.fn({ data: refetchPayload.arg })
    };
  });

  for (const refetchPayload of allPayloads) {
    result.sendContext.payloads.push({
      key: refetchPayload.key,
      result: await refetchPayload.result
    });
  }

  return result;
});

我们会立刻调用 next(),它会执行这个 middleware 所挂载的 server function。我们在 sendContext 里传入一个 payloads 数组:这个数组决定了“服务端最终会发回给客户端回调的数据结构”(也就是 .client 里循环的那份 payloads)。

然后我们遍历客户端通过 sendContext 传上来的 revalidate payload,并从 context 上读出来(是的:send context,发上来再从 context 读出来)。接着调用所有 server functions,并把结果 push 到 payloads 数组里。

把前后拼起来,这就是完整 middleware:

export const refetchMiddleware = createMiddleware({ type: "function" })
  .inputValidator((config?: RefetchMiddlewareConfig) => config)
  .client(async ({ next, data }) => {
    const { refetch = [] } = data ?? {};

    const router = await getRouterInstance();
    const queryClient: QueryClient = router.options.context.queryClient;
    const cache = queryClient.getQueryCache();

    const revalidate: RevalidationPayload = {
      refetch: [],
    };

    refetch.forEach((key: QueryKey) => {
      const entry = cache.find({ queryKey: key, exact: true });
      if (!entry) return;

      const revalidatePayload: any = entry?.meta?.__revalidate ?? null;

      if (revalidatePayload) {
        revalidate.refetch.push({
          key,
          fn: revalidatePayload.serverFn,
          arg: revalidatePayload.arg,
        });
      }
    });

    const result = await next({
      sendContext: {
        revalidate,
      },
    });

    // @ts-expect-error
    for (const entry of result.context?.payloads ?? []) {
      queryClient.setQueryData(entry.key, entry.result);
    }

    return result;
  })
  .server(async ({ next, context }) => {
    const result = await next({
      sendContext: {
        payloads: [] as any[],
      },
    });

    const allPayloads = context.revalidate.refetch.map(refetchPayload => {
      return {
        key: refetchPayload.key,
        result: refetchPayload.fn({ data: refetchPayload.arg }),
      };
    });

    for (const refetchPayload of allPayloads) {
      result.sendContext.payloads.push({
        key: refetchPayload.key,
        result: await refetchPayload.result,
      });
    }

    return result;
  });

修复 TypeScript 报错

为什么下面这一行是无效的?

// @ts-expect-error
for (const entry of result.context?.payloads ?? []) {

这段代码运行在 .client 回调里,并且是在我们调用 next() 之后运行的。本质上,我们是在服务端读取“发送回客户端的数据”(通过 sendContext 传回来的 payload)。这段代码在运行时确实能工作,那为什么类型对不上?

我在上面提到的 middleware 文章里解释过:服务端回调能“看见”客户端发给它的内容,但反过来不成立。这种信息天生就不是双向可见的;类型推断也没法倒着跑。

解决方式很简单:把 middleware 拆成两段,让后一段 middleware 依赖前一段。

const prelimRefetchMiddleware = createMiddleware({ type: "function" })
  .inputValidator((config?: RefetchMiddlewareConfig) => config)
  .client(async ({ next, data }) => {
    const { refetch = [] } = data ?? {};

    const router = await getRouterInstance();
    const queryClient: QueryClient = router.options.context.queryClient;

    // same
    // as
    // before

    return await next({
      sendContext: {
        revalidate,
      },
    });

    // those last few lines are removed
  })
  .server(async ({ next, context }) => {
    const result = await next({
      sendContext: {
        payloads: [] as any[],
      },
    });

    // exactly the same as before

    return result;
  });

export const refetchMiddleware = createMiddleware({ type: "function" })
  .middleware([prelimRefetchMiddleware]) // <-------- connect them!
  .client(async ({ next }) => {
    const result = await next();

    const router = await getRouterInstance();
    const queryClient: QueryClient = router.options.context.queryClient;

    // and here's those last few lines we removed from above
    for (const entry of result.context?.payloads ?? []) {
      queryClient.setQueryData(entry.key, entry.result);
    }

    return result;
  });

整体逻辑不变,只是把 .client 回调里 next() 之后那部分移到了单独的 middleware 里。其余部分留在另一个 middleware 中,并作为输入传给新的这个 middleware。这样当我们在 refetchMiddleware 里调用 next 时,TypeScript 就能看到“从服务端发下来的 context 数据”,因为这些数据是在 prelimRefetchMiddleware 里发送的,而它又是本 middleware 的输入,因此 TS 可以完整看清类型流动。

接起来

现在我们回到“更新 epic”的 server function:把之前的手动 refetch 移除,改为使用 refetch middleware。

export const updateEpic = createServerFn({ method: "POST" })
  .middleware([refetchMiddleware])
  .inputValidator((obj: { id: number; name: string }) => obj)
  .handler(async ({ data }) => {
    await new Promise(resolve => setTimeout(resolve, 1000 * Math.random()));
    await db.update(epicsTable).set({ name: data.name }).where(eq(epicsTable.id, data.id));
  });

在 React 组件中通过 useServerFn 来调用它;这个 hook 会自动处理错误、重定向等。

const runSave = useServerFn(updateEpic);

还记得我说过:middleware 的输入会自动与底层 server function 的输入合并吗?当我们调用这个 server function 时就能看到:

图 1:一个 handleSaveFinal 函数的代码片段,保存输入值并调用 runSave,参数对象包含 id 和 name。转存失败,建议直接上传图片文件

unknown[] 对 react-query 的 query key 来说就是正确类型)

现在我们可以这样调用它,并指定要 refetch 的查询:

await runSave({
  data: {
    id: epic.id,
    name: newValue,
    refetch: [
      ["epics", "list", 1],
      ["epics", "list", "summary"],
    ],
  },
});

运行后,一切正常:epics 列表和 summary 都会在没有任何新网络请求的情况下更新。测试 single flight mutations 时,你其实不是在找“发生了什么”,而是在找“什么都没发生”——也就是 Network 面板里缺少那些本该出现的额外请求。

再改进

react-query 的 query keys 是层级结构的,你可能很熟悉这种写法:

queryClient.invalidateQueries({ queryKey: ["epics", "list"] });

它会 refetch 任何 key 以 ["epics", "list"] 开头的 queries。我们的 middleware 能不能也支持这种“key 前缀”呢?也就是只传一个 key prefix,让它找出所有匹配项并 refetch。

可以,开干。

匹配 key 会稍复杂一点:每个传入的 key 可能是 prefix,会匹配多条 cache entry,所以我们用 flatMap 来找出所有匹配项,再利用 cache.findAll(很好用)。

const allQueriesFound = refetch.flatMap(
  k => cache.findAll({ queryKey: k, exact: false })
);

然后循环并做和之前一样的事:

const allQueriesFound = refetch.flatMap(
  k => cache.findAll({ queryKey: k, exact: false })
);

allQueriesFound.forEach(entry => {
  const revalidatePayload: any = entry?.meta?.__revalidate ?? null;

  if (revalidatePayload) {
    revalidate.refetch.push({
      key: entry.queryKey,
      fn: revalidatePayload.serverFn,
      arg: revalidatePayload.arg,
    });
  }
});

这就能用了。

更进一步

不过我们的方案仍然不理想。假设用户在 epics 页面翻页:到第 2 页、到第 3 页、再回到第 1 页。我们的逻辑会找到第 1 页和 summary query,但也会把第 2、3 页一并找到(因为它们现在也在 cache 里)。然而第 2、3 页并不活跃,也不在屏幕上展示,我们不应该 refetch 它们。

我们可以只 refetch active queries:只要给 findAll 加上 type 参数即可。

cache.findAll({ queryKey: key, exact: false, type: "active" });

于是代码就变成这样:

const allQueriesFound = refetch.flatMap(key => cache.findAll({ queryKey: key, exact: false, type: "active" }));

allQueriesFound.forEach(entry => {
  const revalidatePayload: any = entry?.meta?.__revalidate ?? null;

  if (revalidatePayload) {
    revalidate.refetch.push({
      key: entry.queryKey,
      fn: revalidatePayload.serverFn,
      arg: revalidatePayload.arg,
    });
  }
});

更更进一步

这样就能工作了。但你仔细想想,那些 inactive 的 queries 其实应该被 invalidated。我们不希望立刻 refetch 它们(浪费资源,而且用户没在看),但如果用户又翻回那些页面,我们希望触发一次重新获取。TanStack Query 通过 invalidateQueries 很容易做到。

我们把这段加到“被依赖的那个 middleware”的 client 回调里:

data?.refetch.forEach(key => {
  queryClient.invalidateQueries({ queryKey: key, exact: false, type: "inactive", refetchType: "none" });
});

遍历传入的 query keys,把所有匹配的 inactive queries 标记为无效,但不立刻 refetch(refetchType: "none")。

下面是更新后的完整 middleware:

const prelimRefetchMiddleware = createMiddleware({ type: "function" })
  .inputValidator((config?: RefetchMiddlewareConfig) => config)
  .client(async ({ next, data }) => {
    const { refetch = [] } = data ?? {};

    const router = await getRouterInstance();
    const queryClient: QueryClient = router.options.context.queryClient;
    const cache = queryClient.getQueryCache();

    const revalidate: RevalidationPayload = {
      refetch: [],
    };

    const allQueriesFound = refetch.flatMap(key => cache.findAll({ queryKey: key, exact: false, type: "active" }));

    allQueriesFound.forEach(entry => {
      const revalidatePayload: any = entry?.meta?.__revalidate ?? null;

      if (revalidatePayload) {
        revalidate.refetch.push({
          key: entry.queryKey,
          fn: revalidatePayload.serverFn,
          arg: revalidatePayload.arg,
        });
      }
    });

    return await next({
      sendContext: {
        revalidate,
      },
    });
  })
  .server(async ({ next, context }) => {
    const result = await next({
      sendContext: {
        payloads: [] as any[],
      },
    });

    const allPayloads = context.revalidate.refetch.map(refetchPayload => {
      return {
        key: refetchPayload.key,
        result: refetchPayload.fn({ data: refetchPayload.arg }),
      };
    });

    for (const refetchPayload of allPayloads) {
      result.sendContext.payloads.push({
        key: refetchPayload.key,
        result: await refetchPayload.result,
      });
    }

    return result;
  });

export const refetchMiddleware = createMiddleware({ type: "function" })
  .middleware([prelimRefetchMiddleware])
  .client(async ({ data, next }) => {
    const result = await next();

    const router = await getRouterInstance();
    const queryClient: QueryClient = router.options.context.queryClient;

    for (const entry of result.context?.payloads ?? []) {
      queryClient.setQueryData(entry.key, entry.result, { updatedAt: Date.now() });
    }

    data?.refetch.forEach(key => {
      queryClient.invalidateQueries({ queryKey: key, exact: false, type: "inactive", refetchType: "none" });
    });

    return result;
  });

我们告诉 TanStack Query:把匹配 key 的 inactive queries 置为 invalid(但不 refetch)。

这个方案非常好用:如果你浏览到第 2、3 页,然后回到第 1 页,再编辑一个 todo,你会看到第 1 页列表和 summary 立刻更新。之后如果你再翻回第 2、3 页,你会看到网络请求触发,从而拿到新数据。

锦上添花

还记得我们把 server function 和参数塞进 query options 时的写法吗?

export const epicsQueryOptions = (page: number) => {
  return queryOptions({
    queryKey: ["epics", "list", page],
    queryFn: async () => {
      const result = await getEpicsList({ data: page });
      return result;
    },
    staleTime: 1000 * 60 * 5,
    gcTime: 1000 * 60 * 5,
    meta: {
      __revalidate: {
        serverFn: getEpicsList,
        arg: page,
      },
    },
  });
};

我之前提过:在 metaqueryFn 里重复写 serverFn/arg 有点“脏”。我们来修一下。

先从最简单的 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,
      },
    },
  });
}

这个 helper 会接收 query key、server function 和参数,然后返回 query options:

  • 拼好的 queryKey(必要时把 arg 追加进去)
  • queryFn(直接调用 server function)
  • meta.__revalidate(同样记录 server function 和参数)

于是 epics 列表 query 就可以写成:

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

它能工作,但类型不好:到处都是 any,意味着传给 server function 的参数不做类型检查;更糟的是,queryFn 的返回值也不会被检查,于是你的 query(比如这个 epics 列表)会变成返回 any

我们来加点类型。

server functions 本质上是函数:接收一个对象参数;如果 server function 定义了输入,那么这个对象会包含一个 data 属性,里面就是输入。说一堆大白话不如看调用例子:

const result = await runSaveSimple({
  data: {
    id: epic.id,
    name: newValue,
  },
});

第二版 helper 可以这样写:

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,
      },
    },
  });
}

我们把 server function 约束为一个 async 函数,且它的参数对象上有 data;然后用它来静态推断 arg 的类型。这已经不错了,但当你把它用在“没有参数”的 server function 上时会报错:

...refetchedQueryOptions(["epics", "list", "summary"], getEpicsSummary)
// Expected 3 arguments, but got 2.

你传 undefined 可以解决,功能也正常:

...refetchedQueryOptions(["epics", "list", "summary"], getEpicsSummary, undefined),

如果你是个正常人,你大概会觉得这已经很好了,而且确实如此。但如果你像我一样有点“怪”,你可能会想能不能做到更完美:

  • 当 server function 有参数时:必须传入且类型要正确
  • 当 server function 没参数时:允许省略 arg

TypeScript 有一个特性正好适合:函数重载(overloaded functions)

这篇文章已经够长了,所以我直接贴代码,解读留作读者练习(以及可能的未来文章)。

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

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

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

type ServerFnHasArgs<TFn extends AnyAsyncFn> = ServerFnArgs<TFn> extends infer U ? (U extends undefined ? false : true) : false;

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

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

type ValidateServerFunction<Provided, Expected> = Provided extends Expected ? Provided : "This server function requires an argument!";

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: ValidateServerFunction<TFn, 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 queryOptions({
    queryKey: queryKeyToUse,
    queryFn: async () => {
      return serverFn({ data: arg });
    },
    meta: {
      __revalidate: {
        serverFn,
        arg,
      },
    },
  });
}

有了它之后,当 server function 需要参数时,你可以这样调用:

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

参数类型会被正确检查:

...refetchedQueryOptions(["epics", "list"], getEpicsList, "")
// Argument of type 'string' is not assignable to parameter of type 'number'.

如果你忘了传参数,它也会报错:

...refetchedQueryOptions(["epics", "list"], getEpicsList)
// Argument of type 'RequiredFetcher<undefined, (page: number) => number, Promise<{ id: number; name: string; }[]>>' is not assignable to parameter of type '"This server function requires an argument!"'.

最后这个报错信息不算特别直观,但如果你把代码读到最后,会发现它已经在尽力提示你哪里错了,靠的就是这个小工具类型:

type ValidateServerFunction<Provided, Expected> = Provided extends Expected ? Provided : "This server function requires an argument!";

而对于“没有参数”的 server function,它也能正常工作。完整解释留给未来文章。

总结

single flight mutations 是一个很不错的优化工具:当你做一次 mutation 后,UI 需要的更新数据不必再额外发请求获取,而是可以在同一次往返里顺便带回来。

希望这篇文章把各个拼图都讲清楚了:如何用 middleware 收集要 refetch 的查询、如何借助 TanStack Start 的 server function 序列化能力把“要执行的 refetch”发送到服务端、以及如何在客户端用 setQueryData 把数据写回缓存。

一个月手搓 JavaScript runtime

原文:building a javascript runtime in one month

作者:themackabu

日期:2026年1月2日

翻译:TUARAN

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


TL;DR

我做了一个叫 Ant 的小型 JavaScript runtime(大概 2MB)。源码、测试和文档都在 GitHub:github.com/themackabu/…


我在 11 月初开始做这个项目时,脑子里只有一个简单念头:

如果能做一个足够小、能嵌进 C 程序里,但又足够完整、能跑真实代码的 JavaScript 引擎,会怎么样?

一个你可以发布出去、却不用捆上几百 MB 的 V8 或 Node 的东西。我以前也试过做“极简版 Deno”的路子,但始终不够。

我没想到这会花一个月;更没想到一个月真的做得出来。但不设 deadline 的项目有个特点:你会一直往前推,推着推着就做出来了。

第一周:纯纯生存模式

我是一边做一边学——说白了就是不断试错,然后把每一个错误也一起“发布”出去。最开始的工作只围绕最基本的东西:

  • 数值运算
  • 字符串内建函数
  • 一个非常粗糙的 CommonJS 模块系统

每一次提交都像是在虚无里抢回一点点地盘。

最核心的问题是 解析(parsing)。在其它东西能工作之前,你必须先有 parser。而 parser 往往比看起来复杂得多。JavaScript 这种语言尤其“诡异”:

  • 自动分号插入(ASI)是规范的一部分,你得处理
  • this 的绑定会随上下文变化
  • var 的提升(hoisting)意味着变量会在赋值前就“存在”
  • 甚至 window.window.window 这种写法都是合法的……

我前几天做的主要是把基本流程跑通,类似一个“能算数、也能调用函数”的计算器。由于动量已经起来了,我就一直继续。

runtime 的核心数据表示大概长这样:

typedef uint64_t jsval_t;

在这个 runtime 里,每一个 JavaScript 值都用一个 64 位整数表示:NaN-boxing

IEEE 754 浮点规范有个“洞”:理论上存在 2532^{53} 种 NaN,其中绝大多数从来不会被用到。所以我把它们“偷”来用了。

如果把一个 64 位值按 double 解释时它看起来像 NaN,同时 exponent 与 mantissa 又满足你定义的模式,那么你就可以在这些 bit 里塞一个 tag。你有足够空间同时存一个指针和一个类型标签:把对象引用和类型 tag 一起塞进 64 bit,瞬间所有 JS 值都能塞进一个 machine word。

编译期断言也证明了前提:

_Static_assert(sizeof(double) == 8, "NaN-boxing requires 64-bit IEEE 754 doubles");
_Static_assert(sizeof(uint64_t) == 8, "NaN-boxing requires 64-bit integers");
_Static_assert(sizeof(double) == sizeof(uint64_t), "double and uint64_t must have same size");

这就成了 runtime 表示“一切”的心脏:每个数字、对象、字符串、函数、Promise、参数、作用域……全部都是一个 jsval_t

没有“带标签联合体”、没有 vtable、也不需要额外分配元数据——只有 bits。为了把它调顺,我迭代了好几天;但一旦跑通,其它东西就会更快更顺。NaN 和 Infinity 当然也有坑,不过通过微调 boxing 布局也能解决。

大约第 4 天我让变量能用了,第 5 天函数能用了,第 6 天循环能跑了。早期提交非常散:箭头函数、IIFE、可选链、空值合并……我就是一边翻 MDN 一边想起啥加啥。

垃圾回收(GC)灾难

然后就撞上了真正的硬骨头:内存管理

一个 JavaScript runtime 必须有 GC,你不可能要求用户手动 free 对象。所以到第二周左右,我开始尝试自己实现 GC。

结果是一场噩梦:

  • 我加新特性会把 GC 搞崩
  • 我修 GC 又会把性能搞崩
  • 我试着接入别人写的 GC,又发现集成复杂到不可控

这段时间我非常痛苦。手写的 free-list GC 被我开开关关上百次,每次都能把另一个核心模块弄坏。有些日子我明显已经快崩了:凌晨三点 debug,试图弄清为什么协程栈没被保护好、为什么内存泄漏、为什么加了 JSON 支持之后一切都坏了。

转折点是:放弃手写 GC,改用 bdwgc

这是一个生产级 GC(很多语言都在用)。我把它和自己手写的“带前向引用跟踪的内存压缩”结合起来:它能做 mark、能做 forwarding 的哈希表、能做生产 GC 会做的所有事。

一旦集成上去,内存问题大部分就消失了。我写代码的“语气”也变了:东西开始更稳定地工作起来,我加了 process 模块、把错误信息做得更友好——速度从这里开始明显加快。

Promise / async:另一个野兽

你以为 async/await 很简单,直到你尝试自己实现它。

要实现 async/await,你需要 Promise;Promise 需要 microtask 与定时器;microtask 与定时器又需要事件循环;事件循环还要有地方存异步操作的状态。

我为这件事折腾了好几天:

  • 想让 async 工作,你需要协程
  • 协程需要调度
  • 调度需要事件循环
  • 事件循环还要知道协程什么时候结束

如果协程在等 I/O,你不能阻塞;如果某个协程死了,它也不该把整个系统拖死。

你看提交历史就能感受到痛苦:"async promise pushback""segfault when event loop empty""prevent dead task from blocking"……这些坑都是做到一半才会冒出来的。

更要命的是:JS Promise 不能“简化”。它必须支持 .then() 链式调用,必须正确 reject,还要能与 async function 配合——而 async function 本质上是 generator 的语法糖,而 generator 又是 Promise 与回调的语法糖……

大约第 10 天,我引入了 minicoro 作为协程支持。这个决定大概救了整个项目。minicoro 很优雅:你定义基于栈的协程,然后让系统在它们之间切换。有了协程,我终于能让 async 真正跑起来。

typedef struct coroutine {
struct js *js;
coroutine_type_t type;
jsval_t scope;
jsval_t this_val;
jsval_t awaited_promise;
jsval_t result;
jsval_t async_func;
jsval_t *args;
int nargs;
bool is_settled;
bool is_error;
bool is_done;
jsoff_t resume_point;
jsval_t yield_value;
struct coroutine *prev;
struct coroutine *next;
mco_coro* mco;
bool mco_started;
bool is_ready;
} coroutine_t;

所有 async 执行相关的信息都塞进了这个结构:scope、this、正在等待哪个 promise、是否出错……接着我只需要调度这些东西并管理事件循环。

有了协程以后,Promise 才“成真”:.then() 链能跑,await 会真正暂停并在之后恢复执行。runtime 的 async 侧开始成形。后面我再补齐 Promise 内建时就快很多了,因为最难的那部分已经解决。

JavaScript 的“诡异边缘案例”

中间两周基本就是:不停发现 JavaScript 比我预想中更诡异。

不可配置属性、freeze/seal、可选链的边缘语义、严格模式……听起来都不难,但每一个背后都是几十年的规范细节,真实世界的代码会依赖这些行为。

我一个个啃过去:

  • 处理冻结/密封对象
  • 支持不可配置属性
  • 第 10 次修解构
  • 给属性查找加 getter/setter 的访问器支持

每天都在撞一个新边缘案例。有时候一天修好几个:我实现一个功能、跑一致性测试、发现三个 bug、修完之后又冒出五个新 bug。

你知道 JavaScript 有多少种方式访问原型链吗?

  • __proto__
  • Object.getPrototypeOf()
  • Object.setPrototypeOf()
  • [[Prototype]] 内部槽

你得把它们全部做对,而且还要彼此一致。一个看起来很短的提交信息,比如 “use descriptor tables for getters/setters/properties”,背后可能就是几周的工作。

解构看起来也很简单:const [a, b] = arr

但稀疏数组怎么办?对象的可枚举属性怎么办?嵌套解构、默认值、...rest 参数怎么办?每次修一个点都像打地鼠:修好这里,那里又坏。

一致性测试在“最好的意义上”非常残酷:每次跑都会失败在一个我根本不知道存在的语义上。然后我修掉它,继续失败在下一个。这个循环发生了几十次。

后半程:开始变得“能用”

第二周时,我已经有了一个能执行代码的 JavaScript runtime。它不完整,但它是真的。

然后我开始加那些让它变得“有用”的东西:文件系统、路径工具、URL 模块、以及那个因为 Bun 而变得很有名的内建 HTTP server。突然之间,真实程序开始能在 Ant 上跑了。

比如一个 Web 服务器只要写:

import { join } from 'ant:path';
import { readFile } from 'ant:fs';
import { createRouter, addRoute, findRoute } from 'rou3';

const router = createRouter();

addRoute(router, 'GET', '/status/:id', async c => {
await new Promise(resolve => setTimeout(resolve, 1000));

const result = await Promise.resolve('Hello');
const name = await readFile(join(import.meta.dirname, 'name.txt'));

const base = '{{name}} {{version}} server is responding with';
const data = { name, version: Ant.version() };

return c.res.body(`${base.template(data)} ${result} ${c.params.id}!`);
});

async function handleRequest(c) {
console.log('request:', c.req.method, c.req.uri);
const result = findRoute(router, c.req.method, c.req.uri);

if (result?.data) {
c.params = result.params;
return await result.data(c);
}

c.res.body('not found: ' + c.req.uri, 404);
}

console.log('started on http://localhost:8000');
Ant.serve(8000, handleRequest);

运行起来就是:

$ ant examples/server/server.js
started on http://localhost:8000

$ curl http://localhost:8000/status/world
Ant 0.3.2.6 server is responding with Hello world!

这就是“真 JavaScript”跑在 Ant 里:async/await、文件 I/O、HTTP、带参数路由、网络、字符串操作。

之后节奏更快:每天更自信,修更多 bug,加更多特性。然后到了“冷门但必须”的阶段:Proxy、Reflection、Symbol,甚至 class 私有字段/方法。它们也许很少人用,但规范里写了就得支持。

我最喜欢的一类能力之一是 Atomics

const sharedBuffer = new SharedArrayBuffer(256);

const int32View = new Int32Array(sharedBuffer);
Atomics.store(int32View, 0, 42);
const value = Atomics.load(int32View, 0);
console.log('stored 42, loaded:', value);

Atomics.store(int32View, 1, 10);
const oldValue = Atomics.add(int32View, 1, 5);
console.log('old value:', oldValue);

Atomics.store(int32View, 2, 100);
const result = Atomics.compareExchange(int32View, 2, 100, 200);
console.log('exchanged, new value:', Atomics.load(int32View, 2));
$ ant examples/atomics.js
stored 42, loaded: 42
old value: 10
exchanged, new value: 200

最后一周:多米诺骨牌一样倒下

当 Ant 的核心 runtime 能跑、GC 稳了、Promise 也通了之后,其它东西就像多米诺骨牌一样:小问题被修掉、缺的方法补齐、边缘语义逐个处理。

我重新加回了数组 length 校验,修了对象的属性缓存失效逻辑;为了优化 hash 性能又掉进“复杂算法 + 安全影响”的兔子洞——因为我已经在打磨一个“能工作的东西”。

到第 28 天,我给一个真的能用的 runtime 收尾:支持 async/await、靠谱的内存管理、网络、文件 I/O、并通过 ES1–ES5 的一致性测试,还混搭了一堆更现代的特性。

我甚至在别人提醒之后才“想起来”打开 LTO 和一些编译器 flag 😅

uzaaft

最终结果

一个月后,Ant 作为 JavaScript runtime:

  • 通过 javascript-zoo 测试套件中 ES1 到 ES5 的每一个一致性测试(25 年规范跨度的完整兼容)
  • 实现 async/await,并具备正确的 Promise 与 microtask 行为
  • 拥有一个真的能用、且不漏内存的 GC
  • 基于 libuv 运行 Web 服务器(和 Node 类似的网络底座)
  • 支持通过 FFI 调用系统库,例如:
import { dlopen, suffix, FFIType } from 'ant:ffi';

const sqlite3 = dlopen(`libsqlite3.${suffix}`);

sqlite3.define('sqlite3_libversion', {
args: [],
returns: FFIType.string
});

console.log(`version: ${sqlite3.sqlite3_libversion()}`);
$ ant examples/ffi/basic/sqlite.js
version: 3.43.2
  • 支持读写文件与异步 I/O
  • 支持正确的作用域、提升、变量遮蔽
  • 支持 class、箭头函数、解构、展开、模板字符串、可选链
  • 覆盖一些多数人根本不会想到的“怪边缘”:__proto__ 赋值、属性描述符、不可配置属性、冻结/密封对象(可参考测试:tests/__proto__.js
  • 实现 ES Module(import / export)
  • 支持 Symbol、Proxy、Reflect、WeakMap/WeakSet、Map/Set
  • 支持共享内存与 Atomics 并发原语

把这些串起来,你会发现你面对的已经几乎是一个“完整的 JavaScript runtime”,不太像玩具。

代价

我不知道代价是什么。

可能是睡眠,可能是健康,可能是本来可以拿去做任何其它事情的大把时间。

有些日子我连续工作 10+ 小时;有些日子一天 20+ commits。项目不会减速,只会加速:每天更自信、更快、修更多 bug、加更多特性。

到最后,我开始撞上那些必须去读 ECMAScript 规范、去理解 V8 行为、去对比其它引擎怎么处理某个怪角落的工作。改符号计数、优化 class、把内部属性迁移到 slots(像 V8 那样)……这类优化正常应该等代码稳定后再做,但因为地基已经稳了,我在最后一周反而有了余力去做。

发布后:优化阶段

首个 release 是 11 月 26 日。之后是一段沉默——那种“发完版本之后就没声了”的沉默。直到 12 月 20 日左右,开发又恢复。

这一次不同:runtime 能跑、能过测试,但总有更多优化空间。xctrace 让我看清什么才是真正的瓶颈。12 月下旬和 1 月初的提交呈现一种模式:找到瓶颈 → 修复 → 测量提升。

fast

我先为 typed array 加了 arena allocator。之前 typed array 散落在 heap 的各处;我把它们集中起来,加速分配并改善 cache locality。

然后我把 getter/setter/property 从“每个 descriptor 单独分配”改成“descriptor table 批处理”:更少的分配、更少的指针追逐。

. 运算符支持 property reference 也很烦:每次查属性都要全量解析;于是我加了 reference table 跳过重复工作。

我很喜欢 dispatch table。我把 FFI、JSON 等路径改为 computed goto,让 CPU 直接跳到正确的 handler:少一次分支、少一次查找。

把 properties 迁到 slots 是最侵入的一次重构。对象之前用的是灵活但慢的属性系统;slots 则是按对象类型固定结构,让 runtime 能做更多假设,减少 indirection。

某个时刻我开始拿它对比 Node:跑同样 benchmark,Ant 表现如何?结果开始变得很好——好到你会想:我是不是能在某些点上赢 Node?

bunnerfly wow

优化 Ant 的过程中我会保留一些可工作的 snapshot:如果某次优化把东西搞坏了,我还能退回到一个稳定点。于是就能持续小步推进:每次提交都比上一次快一点。有些优化有效,有些没用,但整体模式始终成立:profile → optimize → measure → commit。

然后是 GC 的改进。在最初那一个月里 bdwgc 集成得挺好,但在优化阶段的某个时刻它被禁掉了,runtime 就开始漏内存。我重新加回“可延迟 GC”的机制,并把旧 GC 的大部分代码取消注释。

但这次不是老办法:我做的是一个 mark-copy + compact 的 GC,能真正做内存碎片整理。旧 GC 的问题是它在错误的时机运行,导致热路径卡顿。所以我让它“可延迟”:在逻辑工作单元之间再收集;同时用前向引用跟踪保证对象移动后指针不坏。GC 回来了,但更聪明:它会等到合适的点暂停,并在运行时压缩堆。

为什么会做这件事

老实说,我也不知道。

也许是赌气?也许是想证明点什么?也许是纯粹的执念。

那种“进入心流”的状态:你写着写着,八小时就过去了,已经凌晨四点,然后你把代码 commit 掉,第二天又继续。

这个项目之所以存在,是因为我脑子里某个东西决定“它必须存在”,并且直到它真的存在之前都不会停。

它并不完美。代码里可能还有没发现的 bug;可能还有没做的性能优化;可能还有漏掉的规范角落。

但它能跑:你可以写真实 JavaScript,它会执行;你可以用 async/await;你可以写服务器;你可以拿它去做真实事情。

如果你曾经好奇:一个人如果足够执着、又不睡觉,能做到什么?答案就是:做出一个规范兼容的 JavaScript 引擎。

源码、测试与文档都在:github.com/themackabu/…

一次 WebGPU 流体之旅

原文:Particles, Progress, and Perseverance: A Journey into WebGPU Fluids

作者:Hector Arellano

日期:2025年1月29日

翻译:TUARAN

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

image.png

本文是一段回顾式的长旅程:作者用十多年的时间不断尝试“浏览器里做流体”,从 WebGL 时代的各种 Hack,一路走到 WebGPU 让许多“现代图形 API 能力”变得可用。

编者按:如果你关注过 Web 图形圈子,可能知道 Hector Arellano(又名 Hat)。这篇文章不仅是技术拆解,更是一段关于坚持、试错、与 Web 图形演进的故事。

注意:Demo 依赖 WebGPU,并非所有浏览器都支持。请使用支持 WebGPU 的浏览器(例如最新版 Chrome / Edge,并确保 WebGPU 已启用)。

在继续阅读之前……先去拿杯喝的——这篇很长

13 年前……

我正盯着电脑屏幕发呆(无聊得很),一个很要好的朋友 Felix 打电话给我,非常认真又兴奋地说:Gathering Party 刚发布了一个新 Demo。它有流体模拟、粒子动画、惊艳的着色方案——最重要的是,它真的很美。

那时候 WebGL 还算“新东西”,把硬件加速的 3D 图形带进浏览器,看起来会打开很多门。我天真地以为:WebGL 也许能做出 Felix 给我看的那种东西。

但我开始研究那个 Demo 的实现方式时,就撞上了残酷现实:里面用到了一堆我从没听过的 API/特性——“Atomics(原子操作)”“Indirect Draw Calls(间接绘制调用)”“Indirect Dispatch(间接派发)”“Storage Buffers(存储缓冲区)”“Compute Shaders(计算着色器)”“3D Textures(三维纹理)”。

它们属于现代图形 API 的能力,但在当时的 WebGL 里基本不存在。

更别提它还用了很多听起来就很复杂的算法/技术:用 SPH(Smoothed Particle Hydrodynamics,平滑粒子流体动力学) 驱动粒子动画、用 histopyramids 做流压缩(我当时还想:我为什么需要这个?)、用 GPU 上的 marching cubes(从粒子生成三角形???)等等。

我完全不知道从哪里开始。更糟的是,Felix 跟我打赌:这种流体效果不可能在浏览器里“可用于生产”。

10 年前……

又过了三年,Felix 说他还有一个更炸裂的 Demo一定要我看。除了流体模拟,它还用实时光线追踪渲染了几何体——材质很震撼、画面很惊人。

这下挑战更大了:我不仅想模拟流体,我还想用光追去渲染它,得到漂亮的反射与折射。

我花了大概 3 年才把这些东西理解到能在 WebGL 里“硬凿”出来:

  • 我用 SPH 让粒子行为像流体;
  • 我用 marching cubes 从粒子生成网格(见图 1 的描述)。

当时没有 atomics,我就用多次 draw call 把数据塞进纹理的 RGBA 通道来“分层”;没有 storage buffer 和 3D 纹理,我就用纹理存数据,并用二维层来“模拟” 3D 纹理;没有 indirect draw,我就干脆按预期数量发起 draw call;没有 compute shader,我就用顶点着色器做 GPGPU 的数据重排……虽然也做不出那种“在 buffer 里随意写多个内存位置”的事,但至少我能在 GPU 里生成一个加速结构。

实现是能跑,但离“美”差得很远(Felix 直接评价:丑。确实丑,你可以想象图 2)。我那时也不太懂距离场,也不知道怎么把 shading 做得更有趣,基本就是老派 phong。

性能也限制了很多高级效果:环境光遮蔽、更复杂的反射折射……但至少我能渲染出点东西。

7 年前……

再过三年,我又做了一些进展:实现了一个混合式光追。思路是:marching cubes 先生成三角形,然后用光追去算二次射线做反射/折射;同一个光追还能遍历加速结构去做焦散。这些基本都沿用了 Matt Swoboda 的想法(那些 Demo 的原作者)。我的工作大部分就是:把他的点子尽量在 WebGL 里跑起来(祝你好运)。

效果在视觉上还不错(类似图 3),但需要非常强的 GPU。当时我用的是 NVidia 1080GTX。也就是说:即使 WebGL 可行,也不可能拿去做“生产”。手机不行,普通笔记本也扛不住。

看得到“结果”,却用不到真实项目里,这种挫败感很强。我花了太多时间,最后也没有达到期望。至少,这套代码库还能继续帮我学习。

于是我停了。

Felix 赢了赌局。

这段铺垫对一篇“教程”来说太长了,但我想把背景交代清楚:有些 Demo 看起来像“几天搞定”,实际可能是多年积累;你要花时间学很多技术,也经常要借鉴别人的想法——最后也可能仍然失败。

WebGPU 登场

还记得那些“现代图形 API 的关键词”吗?WebGPU 基于现代 API 标准,这意味着我不必再靠 Hack:

  • 我可以用 compute shader 直接操作 storage buffer;
  • 我可以用 atomics 做邻域搜索、流压缩时的索引写入;
  • 我可以用 dispatch indirect 来只生成必要数量的三角形,并用同样的方式绘制它们。

我想学习 WebGPU,于是决定把之前的流体工作迁移过来,顺便理解新范式:怎么组织 pipeline 和 binding、怎么管理 GPU 内存与资源……做一个小 Demo 很适合练手。

需要先讲清楚:本文的 Demo 并不适合生产。在 M3 Max 这类比较强的 MacBook Pro 上它可能能跑到 120fps;M1 Pro 上大概 60fps;其它不错的机器也许 50fps……但如果你拿去跑在 MacBook Air 上,“浏览器流体梦”会很快破碎。

那它为什么仍然有价值?

因为它其实是一组可拆解的技术集合。你可能对其中某个部分感兴趣:粒子动画、从势场生成表面(避免 ray marching)、间接光、世界空间 AO……你可以把仓库里的代码拿出来,只取你需要的部分来构建自己的想法。

这个 Demo 大致可以拆成 4 个主要阶段:

  • 流体模拟:用粒子模拟(基于 Position Based Dynamics 思路)驱动流体的运动。
  • 几何生成:用 GPU 上的 marching cubes,从粒子生成渲染用三角形。
  • 几何渲染:使用距离场估算几何厚度以做次表面散射(SSS),并用体素锥追踪(Voxel Cone Tracing)计算 AO。
  • 合成:地面反射模糊、调色与 Bloom 等后期。

流体模拟

很多年前,如果你想在图形圈子里“显得很酷”,你得证明你能自己做流体模拟:做 2D 就很强,做 3D 就是“封神”(当然这是我脑内的中二设定)。为了“封神”(也为了赢赌局),我开始疯狂读 3D 模拟相关的资料。

做流体的方法很多,其中一种叫 SPH。理性做法应该是先评估哪个方法更适合 Web,但我当时选它就因为名字听起来很酷。SPH 是粒子法,这一点长期来看很有好处,因为后来我把 SPH 换成了 position based 的方法。

如果你做过“群体行为(steering behaviors)”或 flocking,会更容易理解 SPH。

Three.js 有很多 flocking 示例,它基于吸引、对齐、排斥等 steering 行为。用不同的权重/函数,根据粒子之间的距离决定粒子受哪些行为影响。

SPH 的做法也有点类似:你先算每个粒子的密度,再用密度算压力;压力就像 flocking 里的吸引/排斥,使粒子靠近或远离。密度又是邻域粒子距离的函数,所以压力本质上也是“由距离间接决定的”。

SPH 的粘性项(viscosity)也类似 flocking 的对齐项(alignment):让粒子速度趋向邻域的平均速度场。

为了(过度)简化,你可以把 SPH 理解成:给 flocking 套上一组更“物理正确”的参数,让粒子更像流体。当然 SPH 还会涉及表面张力等更多步骤,且其核函数/权重远比这里描述复杂,但如果你能把 flocking 做好,理解 SPH 会更轻松。

SPH/群体行为都有一个共同难点:朴素实现是 O(n2)O(n^2),粒子多就会爆炸。你需要一个加速结构只查询附近粒子,让复杂度从 O(n2)O(n^2) 降到 O(kn)O(k\cdot n)kk 是每个粒子要检查的邻居数)。常见做法是体素网格:每个体素格子存最多 4 个粒子索引。

在这个示例里,算法会检查粒子周围 27 个体素,每个体素最多 4 个粒子,所以最多 108 次邻域检查。听起来也不少,但比检查 8 万个粒子要好太多。

但邻域遍历仍然昂贵。SPH 还要求多次 pass:密度、压力/位移、粘性、表面张力……当你意识到 GPU 绝大部分算力都在“驱动粒子”时,性能就会变得非常重要。

而且 SPH 很难调参,你得理解很多工程/物理参数才能做得好看。

后来 NVidia 提出了一套粒子动力学方法:Position Based Dynamics(PBD),其中包含刚体、软体、流体、碰撞等。课程笔记在这里

PBD 通过“约束(constraints)”直接修正粒子位置,结果稳定、调参相对容易。这让我从 SPH 转向 PBF(Position Based Fluids)。核心差别在于:PBF 用约束来定义位移,而不是像 SPH 那样先算密度。

PBF 的参数更“无量纲”,更好理解。

但它也有代价:PBD 往往要迭代多次才能得到更好结果(计算约束、应用位移、计算粘性……反复执行),稳定但更慢。

而我不想只渲染粒子,我要渲染网格:GPU 还要算三角形、做渲染。我没有足够预算做多轮迭代,所以我必须“砍角”。

幸运的是,PBD 有一种很便宜的碰撞计算方式:在施加力(forces)后做一次 pass 即可。我选择:

  • 用重力作为主力;
  • 用 curl noise 作为辅助力,增加流体感;
  • 用鼠标驱动一个很强的斥力(repulsion);
  • 让碰撞负责避免粒子聚成奇怪的团。

curl + 重力提供“像流体”的整体趋势,碰撞避免粒子聚团。它不如 PBF 那么真实,但更快。

实现上只需要一次 pass 应用所有力,同时在 storage buffer 里生成网格加速结构;atomics 写索引只需要几行代码。你可以在仓库的 PBF_applyForces.wgsl 里读到力与网格构建的实现。

粒子位置更新在 PBF_calculateDisplacements.wgsl:负责遍历邻域做碰撞,也负责和环境(不可见包围盒)碰撞。

pipeline 与绑定在 PBF.js:模拟只用三个 shader——施力、位移更新、速度积分。位置更新后,速度通过“新位置 - 旧位置”的差值得到。

最后一个 shader PBF_integrateVelocity.wgsl 还会设置一个包含粒子信息的 3D 纹理,后续会用于 marching cubes 生成势场。

Marching Cubes(几何生成)

当年我第一次用 SPH 把粒子跑起来时兴奋得不行,在办公室到处吹(基本到处都是)。Felix 当然知道怎么治我:他把我赶回去继续做“表面生成”,因为只有把流体渲染成液体表面而不是点,才算“像样”。

从粒子场渲染表面常见有三种思路:

  • Point Splatting
  • Raymarching
  • Marching Cubes

Point splatting 是最简单也最快的一种:屏幕空间效果,渲染粒子后结合可分离模糊与深度来生成法线。效果很不错,还能做焦散,实时性能也好。

image.png

Raymarching 很有趣,能做多次反射折射等复杂效果,但非常慢:你需要从粒子生成距离场,再在距离场里做步进采样(过去还没有 3D 纹理,只能软插值)。即使硬件支持三线性插值,性能也依然不太理想。画面很美,但不适合实时。

Marching cubes 听起来很吸引人:从粒子生成的势场(potential field)里提取等值面生成网格。优点是网格可直接栅格化,在高分辨率下也能稳定渲染;并且有了网格,很多“反射”就能更便宜地实现。与前两种方案相比,它更容易作为世界空间几何体融入场景。

Three.js 有 marching cubes 的例子,但那些是 CPU 上生成表面;而我的粒子数据在 GPU。我去读 Matt Swoboda 的分享,了解他如何在 GPU 上做 marching cubes,但里面有很多我当时还不懂的问题:

  • 如何从粒子场生成势场?
  • 间接 dispatch 是怎么回事?
  • 如何在 GPU 上生成三角形?

先把路线图讲清楚。Marching cubes 本质是从势场提取等值面(iso-surface)。关键步骤有:

  1. 从粒子生成势场(potential)。
  2. 在体素网格上评估势值,决定每个体素对应 256 种“case”里哪一种(每个体素会生成 0 到 5 个三角形)。
  3. GPU 上把满足条件的体素写入连续 buffer(用 atomics 追加)。
  4. 根据体素信息生成三角形。

势场生成(Potential Generation)

如果你了解 point splatting,会发现“模糊”很关键:它能把点云平滑成近似表面。同样思路也适用于 3D 纹理:对 3D 纹理做 blur,就能得到一种“穷人版距离场”。

你也可以用 Jump Flood 算法生成更精确的距离场(粒子也可以),看起来还可能比 3D blur 更快——但它有个致命缺点:它太精确了。

Jump Flood 的结果更像是一组球体的距离场,等值面阈值不同会把球“连起来”,但不会以一种“好看”的方式平滑。你得有非常多的粒子才会像连续表面,而那种情况下你倒不如直接用 point splatting。

3D blur 反而会把粒子“抹开”,去掉高频的颗粒感,让它更像表面。blur 次数越多,表面越平滑;你也能尝试不同 blur 方式混合出不同的表面效果。奇妙的是:这个简单办法在这里反而更快、更实用。

实现上,blur 用 compute shader Blur3D.wgsl,沿三个轴各 dispatch 一次;绑定与 dispatch 在 Blur3D.js

体素筛选(Checking voxels)

势场生成后,我用另一个 compute shader 扫描体素网格,找出会生成三角形的体素。仓库里的 MarchCase.wgsl 会遍历整个体素网格,为需要生成三角形的体素计算 marching cubes case,并用 atomics 把该体素的 3D 坐标与 case 连续写入 storage buffer。

然后 EncodeBuffer.wgsl 读取上一步得到的体素数量,编码出用于“间接 dispatch”的参数(三角形生成需要多少顶点)以及“间接 draw”的参数(需要绘制多少三角形)。

三角形生成(Triangles Generation)

负责生成顶点/法线的 shader 是 GenerateTriangles.wgsl。它根据每个线程的全局索引定位到对应体素和要生成的顶点,并通过 EncodeBuffer.wgsl 产生的间接 dispatch 来运行。

体素信息用于在边的两个角点之间做线性插值,得到顶点位置;法线则来自边两端角点梯度(gradient)的线性插值。

势场生成、体素收集、三角形生成这些步骤在 TrianglesGenerator.jsgenerateTriangles 函数里串起来,每次粒子位置更新后都会调用。

渲染

这些年我最大的错误之一,是把“模拟/GPGPU 技术”看得比“视觉美感”更重要。我太执着于证明自己能做复杂东西,而忽略了最终画面。

Felix 经常在我准备发布 Demo 之前拦住我:花更多时间把画面打磨得更舒服,别只做成那种“只有四个人会觉得很酷”的技术展示。

相信我:你可以做很强的物理模拟、很复杂的材质——但如果看起来很糟糕,那它就是糟糕。

流体的难点在于:你已经把大量 GPU 时间花在粒子动力学和表面生成上了,留给渲染效果的预算不多;还要给场景里其它东西留时间。所以实时流体一般很难做到“极致画质”。

实时渲染液体的最优解通常是 point splatting:反射、折射、阴影、焦散都能做,而且很“便宜”。不信可以看看这个很棒的 Demo:webgpu-ocean.netlify.app/

如果你要的是不透明/半透明但不需要“真正透明”的液体(比如颜料),marching cubes 是不错选择:你可以用 PBR 得到很好看的视觉,而且它是世界空间几何,和场景整合更简单。

在这个 Demo 的范围里,我想利用现成的体素结构(用于三角形生成)以及用于生成三角形的势场(可视作距离场),做一些“相对便宜但有效”的视觉提升。

我先做了基于体素锥追踪(Voxel Cone Tracing,VCT)的 AO。VCT 通常要求先把三角形体素化,但这个 Demo 反过来:我们本来就是从体素生成三角形。所以 VCT 所需的一大块工作已经在流程里。

我只需要稍微改一下 MarchCase.wgsl:用离散化方式更新体素网格——有三角形的体素标记为 1,没有的标记为 0;同时把地面以下一定高度的体素标 0.5 来模拟地面 AO。只多加两行代码就能准备好 VCT 的信息。

体素网格更新后,再对 3D 纹理做 mipmap(MipMapCompute.wgsl,绑定在 CalculateMipMap.js)。

你会注意到我也做了地面反射:marching cubes 生成的是网格,做反射很直接——算反射矩阵,把网格绘制两遍即可。若用 ray marching 做同样效果会贵很多。

这时我还有一点 GPU 预算,于是继续问朋友:还有什么特性值得加?有人建议做次表面散射(Subsurface Scattering,SSS),像下面这种效果。

image.png

SSS 做得好非常加分,难点在于要知道几何体的厚度(thickness),才能决定光在内部散射的程度。

很多 SSS demo 用“厚度贴图”,但流体表面无法烘焙厚度,必须实时计算。

幸运的是,我们在生成三角形之前已经有势场,它可以当距离场用来实时估算表面厚度。概念上类似 Iñigo Quilez 做 AO 的方式:在距离场里 ray marching,看表面距离如何影响遮蔽。

我采用类似思路,但把光线沿“进入几何内部”的方向发射,从而估算内部光传播被遮挡的程度——厚的地方散射弱,薄的地方散射强。效果出乎意料地好。

几何材质在 RenderMC.wgsl:顶点着色器使用存储在 storage buffer 的顶点位置与法线。因为 CPU 不知道 marching cubes 实际生成了多少三角形,所以用 EncodeBuffer.wgsl 编码出的间接 draw 来绘制。

绑定里我用了两套矩阵:一套用于正常视角,另一套用于反射网格;这些在 Main.js 完成。

到这一步,模拟、表面生成、材质都有了,接下来该谈合成(composition)。

合成(Composition)

你可能觉得自己是很厉害的图形开发者:会用 Three.js / Babylon.js / PlayCanvas 做酷炫效果……也可能你更强,很多东西自己写。

我想说的是:我不是。

我为什么知道?

因为我曾在 Active Theory(activetheory.net/)工作,身边是非常优秀的图形开发和 3D 艺术家。他们让我看清自己的短板,也帮助我把交付物推到更好的状态。

如果你能争取和他们共事,对你的职业发展非常有帮助——你会学到很多。

其中最关键的一点是:合成决定一切。

我请曾在 Active Theory 共事的 Paul-guilhem Repaux(x.com/arpeegee)帮我做合成建议。他指出了几个关键问题:

  • 地面反射过于清晰,应该更粗糙(更模糊)。
  • 黑色背景无法体现光从哪里来;背景应该营造更“有情绪”的氛围。
  • 缺少把几何体与环境融合的光效。
  • 字母之间的过渡缺乏合理性。
  • 需要调色。

(当然还有很多能改的地方,他只是很友好地挑了最关键的。)

反射

第一点可以用后期解决:根据几何体到地面的距离决定反射的模糊程度——离地越远越模糊,从而得到“粗糙度”效果。

但如果只在“有几何体高度信息”的区域模糊,周围空白区域会不模糊,结果会很怪。

为了解决这个问题,我先做了一个预处理 pass:把反射几何体做一个偏移,并把“最近的高度”写入一张纹理,用来在空白区域也决定模糊强度。

深红色是未反射几何体,绿色是反射几何体(包含偏移),你会看到绿色更“厚”。高度编码在红色通道里,可视为从地面到上方的渐变。

背景与光

SSS 的实现假设光源始终从几何体背后打过来——即便镜头移动,这个方向也得成立,否则 SSS 不明显。

这对背景设计反而是好事:背景可以做一个“背光渐变”,自然地解释光来自背后;同时背景色也可以和材质更接近,让整体更融合。

光效融合

最后,为了让背景与几何体的光融合得更好,我加了 Bloom:在几何体更薄的区域,Bloom 更强,从而强化 SSS 的视觉。

(顺带一提:我还尝试过把字母动画和 Codrops 的 Logo 对齐,但看起来像儿童识字应用,于是放弃。)

调色与氛围

最后我加了亮度、对比度、gamma 校正,选择偏暖的配色让氛围更柔和。后期由多个 compute shader 完成,在 Main.js 里调用。

完整代码库:github.com/HectorArell…

简化版仓库:github.com/HectorArell…

你可以通过在 Demo URL 末尾加 /?word=something 来更改展示的单词。

结语

本文没有深入性能优化,我觉得也没必要:这个 Demo 本来就面向强 GPU,不是移动端。

image.png

WebGPU 的 timestamp query 让定位瓶颈变得更容易(例如 Blur3D.js 里就有注释掉的查询)。

这并不意味着“这套方案能直接上生产”。Felix 做过一个用 SPH 做字母的探索,非常快也很酷,你可以看看:fluid.felixmartinez.dev/

总之,这么多年过去,Felix 还是赢着赌局,而我还在努力扭转结果……希望你也能遇到一个让你说出 “hold my beer” 的人。

小结

WebGPU 流体长文真正有价值的点在于:它把“酷炫 Demo”拆成了可复用的工程模块链路——粒子模拟、势场生成、marching cubes、间接 dispatch/draw、再到合成与调色。落地时最容易卡住的往往是性能预算与调参成本:算力都花在模拟与几何生成上,留给画面打磨的空间很小,所以工程上需要把可控的阶段拆开验证、逐步迭代。实践中可以用 RollCode 低代码平台私有化部署自定义组件静态页面发布(SSG + SEO) 来把 Demo 展示、参数面板、素材与发布流程做成可复现的交付闭环。

React 的 ViewTransition 元素

原文:React’s ViewTransition Element

作者:Chris Coyier

日期:2026年1月30日

翻译:TUARAN

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

作为一个 View Transitions 的爱好者,同时又在用 React,我自然会关注 React 现在直接提供了一个 <ViewTransition> 元素(目前在 “Canary” 预发布版本中)。

image.png

我想看看它到底怎么用,但在开始之前,我们先……用它。View Transitions 是 Web 平台本身的特性,不属于任何框架。所以 React 也无法阻止我们使用它。直接用其实也不算奇怪。

在 React 中使用 View Transitions(经典方式?)

同页 View Transitions API(对 React 更相关,而不是多页切换的那种)基本是这样:

document.startViewTransition(() => {
  // 在这里改 DOM
});

但改 DOM 这种事……是 React 的工作。它并不喜欢你自己去动它。所以与其直接操作 DOM,我们不如做些“更 React 的事”,比如更新 state。

import React, { useState } from "react";

export default function DemoOne() {
  const [buttonExpanded, setButtonExpanded] = useState(false);

  const toggleButton = () => {
    document.startViewTransition(() => {
      setButtonExpanded(!buttonExpanded);
    });
  };

  return (
    <button
      className={`button ${buttonExpanded ? "expanded" : ""}`}
      onClick={toggleButton}
    >
      Button
    </button>
  );
}

视觉效果由 CSS 完成。状态变化触发 class 变化,而 class 改变按钮的样式。

.button {
  /* button styles */

  &.expanded {
    scale: 1.4;
    rotate: -6deg;
  }
}

准备使用 <ViewTransition>

写这篇文章时,这个元素还只存在于 React 的 “Canary” 版本,所以你得显式安装:

npm install react@canary

你的 package.json 会把版本写成 canary

{
  "dependencies": {
    "react": "canary",
    "react-dom": "canary"
  }
}

如果你在客户端用 React,也可以用 CDN 的 import map:

<script type="importmap">
{
  "imports": {
    "react": "https://esm.sh/react@canary",
    "react-dom": "https://esm.sh/react-dom@canary"
  }
}
</script>

在 React 中使用 <ViewTransition>

现在我们可以导入 ViewTransition,并把它作为 JSX 元素使用,同时配合它的搭档 startTransition

import React, { startTransition, ViewTransition } from "react";

function App() {
  const [buttonExpanded, setButtonExpanded] = useState(false);

  const toggleButton = () => {
    startTransition(() => {
      // 以更“React”的方式改变 DOM
      setButtonExpanded(!buttonExpanded);
    });
  };

  return (
    <main>
      <ViewTransition>
        <button
          className={`button ${buttonExpanded ? "expanded" : ""}`}
          onClick={toggleButton}
        >
          Button
        </button>
      </ViewTransition>
    </main>
  );
}

CSS 和上面一样,因为本质还是切换 class。注意我们没有用 .classList.toggle("expanded") 这种直接 DOM 操作,而是让 React 走自己的渲染流程。

所以……两种方式都能用?

是的,至少在这些简单 demo 里都没问题。甚至同页混用也可以。

一个小差异是:如果你直接用 document.startViewTransition,需要自己加 view-transition-name;而 <ViewTransition> 会自动帮你加。这算是 <ViewTransition> 的一个小加分点。

我“讨厌”的那部分

有一部分我并不喜欢这个方案。React 并没有给出太多额外价值,它只是要求你用一种不破坏框架运行方式的写法。如果你花了很多时间去学它(而且确实有不少内容),这些知识并不太能迁移到其他地方。

我“勉强接受”的那部分

React 一直以来就希望自己掌控 DOM,这是它的核心卖点。也正因为如此,你必须让它来做一些协调工作。这意味着使用 <ViewTransition> 可以“自动与渲染生命周期、Suspense 边界、并发特性协调”,完成批量更新、防冲突、嵌套管理等你我不想操心的事情。

此外,<ViewTransition> 有一点点更“声明式”:你明确包裹了要过渡的区域,更符合很多人的心智模型。但你仍然需要调用 startTransition,所以仍然是偏命令式的。在更复杂的嵌套 UI 中,如何组织它可能会有些困惑。

我倒是挺喜欢 <ViewTransition> 上像 enterexit 这样的明确属性,它对应“自带的” CSS view transition class,比起自己通过 :only-child 技巧推断要直观一些。

总之,以上就是我的看法。更多示例请参见原文。

推荐

React 的 <ViewTransition> 把 Web 原生 View Transitions 纳入 React 的渲染与并发体系中。

直接使用 document.startViewTransition 已经足够灵活,而 <ViewTransition> 的价值更多体现在与 React 生命周期、Suspense、并发更新的自动协同上,代价是多了一层框架语义,学习成本也更偏 React 私有。它更像是一种“安全封装”,而不是必选能力。

如果你需要把这类新特性快速做成 示例页、技术文章或 Demo 站点,可以用 RollCode 低代码平台 快速搭建展示页面,把实验、讲解和转化路径一次性跑通,而不用在工程脚手架上消耗精力。

JavaScript 框架展望 2026

这一年变化很多,但更多是一种视角的变化。若说 AI 过去还不够主流,那么过去一年它已完全主导了讨论。以至于几乎没人再谈新的 JavaScript 框架或框架特性。但这并不代表事情没有进展。
❌