阅读视图

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

《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/…

踩坑记录:iOS Safari 软键盘下的"幽灵弹窗"问题

最近在做移动端 H5 登录页面时,遇到了一个诡异的 bug:在 iOS Safari 中,弹窗明明显示在屏幕中央,点击按钮却毫无反应。检查元素后发现,DOM 的实际位置和视觉位置完全对不上。折腾了一番后终于搞清楚了原因,记录一下。

问题现象

我们的登录模块有两个页面:账号密码登录和验证码登录,可以互相切换。两个页面都有一个协议确认弹窗,使用 position: fixed 定位。

诡异的事情发生了:

  1. 在账号密码页面输入手机号(软键盘弹出),触发弹窗,点击取消 —— 正常
  2. 切换到验证码页面,触发弹窗,点击取消 —— 正常
  3. 再切换回账号密码页面,触发弹窗,点击取消 —— 没反应!

打开 Safari 调试器一看,弹窗的 DOM 位置和屏幕上显示的位置差了好几百像素。点击事件实际触发在了空白区域。

这就是传说中的"幽灵弹窗"。


前置知识:理解移动端视口

要搞清楚这个问题,得先理解移动端浏览器的视口概念。这部分内容稍微有点绕,但理解了之后很多移动端的坑就都能解释了。

三种视口

移动端浏览器有三种视口:

1. 布局视口(Layout Viewport)

布局视口是 CSS 布局的基准。当你写 width: 100% 时,这个 100% 就是相对于布局视口的宽度。

在没有 <meta name="viewport"> 标签的情况下,移动端浏览器的布局视口默认是 980px(不同浏览器可能略有差异)。这就是为什么早期的桌面网页在手机上看起来特别小 —— 浏览器把 980px 的内容硬塞进了 320px 的屏幕。

<!-- 这行代码让布局视口等于设备宽度 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0">

2. 视觉视口(Visual Viewport)

视觉视口是用户当前能看到的区域。当用户双指缩放页面时,布局视口不变,但视觉视口会变小(放大时)或变大(缩小时)。

可以这样理解:布局视口是一张大地图,视觉视口是你手里的放大镜。放大镜移动或缩放,地图本身不会变。

3. 理想视口(Ideal Viewport)

理想视口是设备屏幕的实际尺寸。width=device-width 就是让布局视口等于理想视口。

用代码获取视口信息

// 布局视口
const layoutWidth = document.documentElement.clientWidth;
const layoutHeight = document.documentElement.clientHeight;

// 视觉视口(需要 Visual Viewport API)
const visualWidth = window.visualViewport?.width;
const visualHeight = window.visualViewport?.height;
const offsetTop = window.visualViewport?.offsetTop; // 视觉视口相对于布局视口的偏移

// 屏幕尺寸
const screenWidth = window.screen.width;
const screenHeight = window.screen.height;

视口与 position: fixed 的关系

这里是关键点:position: fixed 的元素是相对于视觉视口定位的,但它的点击区域是根据布局视口计算的

正常情况下,视觉视口和布局视口是重合的,所以没问题。但当软键盘弹出时,iOS Safari 会改变视觉视口而不改变布局视口,这就导致了视觉位置和点击区域的错位。


浏览器渲染原理:为什么会"错位"

要理解"幽灵弹窗",还需要了解浏览器是怎么渲染页面的。

渲染流水线

浏览器渲染一个页面大致经过以下步骤:

HTML → DOM Tree
              ↘
                → Render Tree → Layout → Paint → Composite
              ↗
CSS  → CSSOM
  1. 解析 HTML:构建 DOM 树
  2. 解析 CSS:构建 CSSOM(CSS Object Model)
  3. 合并:DOM + CSSOM = Render Tree(渲染树)
  4. 布局(Layout/Reflow):计算每个元素的位置和大小
  5. 绘制(Paint):把元素画到屏幕上
  6. 合成(Composite):把不同图层合并

重排(Reflow)与重绘(Repaint)

重排(Reflow)

当元素的几何属性(位置、大小)发生变化时,浏览器需要重新计算布局,这就是重排。

触发重排的操作:

  • 改变窗口大小
  • 改变字体大小
  • 添加/删除 DOM 元素
  • 改变元素的 widthheightmarginpadding
  • 读取某些属性:offsetTopscrollTopclientTopgetComputedStyle()

重绘(Repaint)

当元素的外观(颜色、背景、阴影等)发生变化,但几何属性不变时,只需要重绘,不需要重排。

触发重绘的操作:

  • 改变 colorbackgroundvisibility

性能影响

重排的代价比重绘大得多,因为重排会导致整个渲染树的重新计算。这就是为什么我们要尽量避免频繁触发重排。

图层(Layer)与合成

现代浏览器会把页面分成多个图层,每个图层独立渲染,最后合成到一起。某些 CSS 属性会触发元素提升为独立图层:

  • transform
  • opacity
  • will-change
  • position: fixed(在某些浏览器中)

独立图层的好处是:当这个元素变化时,不会影响其他图层,只需要重新合成,不需要重排整个页面。

这就是为什么我们在解决方案中使用 transform: translate3d(0, 0, 0) —— 它强制创建一个独立的合成层,让弹窗的渲染与页面其他部分隔离。


问题根因深度分析

现在我们有了足够的背景知识,来深入分析"幽灵弹窗"的成因。

iOS Safari 的"独特"处理方式

当软键盘弹出时,不同平台的处理方式不同:

Android Chrome 的做法:

软键盘弹出前:
┌─────────────────────┐
│                     │
│   Layout Viewport   │ = Visual Viewport
│   (100vh)           │
│                     │
└─────────────────────┘

软键盘弹出后:
┌─────────────────────┐
│   Layout Viewport   │ = Visual Viewport(缩小了)
│   (变小了)           │
├─────────────────────┤
│      软键盘          │
└─────────────────────┘

Android 的做法是缩小布局视口,position: fixed 的元素会相对于新的视口重新定位,一切正常。

iOS Safari 的做法:

软键盘弹出前:
┌─────────────────────┐
│                     │
│   Layout Viewport   │ = Visual Viewport
│   (100vh)           │
│                     │
└─────────────────────┘

软键盘弹出后:
┌─────────────────────┐ ← Layout Viewport(不变)
│   ↑                 │
│   │ 页面被推上去     │
│   ↓                 │
├─────────────────────┤ ← Visual Viewport(变小了)
│      软键盘          │
└─────────────────────┘

iOS 保持布局视口不变,只是把页面内容往上"推"。这时候:

  • position: fixed 的元素根据布局视口计算位置
  • 但用户看到的是视觉视口
  • 两者不一致,就出现了"幽灵"现象

为什么多次切换后才出问题?

每次软键盘弹出,window.scrollY 都会产生变化。当用户在多个页面之间切换时:

  1. 页面 A:软键盘弹出,scrollY = 200
  2. 切换到页面 B:scrollY 可能没有完全重置
  3. 页面 B:软键盘弹出,scrollY 累加
  4. 切换回页面 A:累积的偏移量导致计算错误

Vue Router 的 router.replace() 不会触发完整的页面刷新,滚动状态会残留。

为什么 iOS Chrome 也有问题?

这里要提一个很多人不知道的事实:iOS 上所有浏览器都是 Safari 套壳

Apple 的 App Store 政策要求:iOS 上的所有浏览器必须使用 WebKit 引擎。所以:

浏览器 Android 引擎 iOS 引擎
Chrome Blink WebKit
Firefox Gecko WebKit
Edge Blink WebKit
Safari - WebKit

iOS 上的 Chrome 只是换了个 UI 的 Safari,底层渲染引擎完全一样。这个问题在 iOS 上是无差别攻击。


解决方案

核心思路

弹窗显示时,把页面"冻住",让布局视口和视觉视口强制保持一致。

let savedScrollY = 0;
let savedBodyStyle = '';

// 锁定
const lockBodyScroll = () => {
  // 兼容性写法,确保能获取到滚动位置
  savedScrollY = window.scrollY || window.pageYOffset || document.documentElement.scrollTop || 0;
  savedBodyStyle = document.body.style.cssText;

  document.body.style.cssText = `
    ${savedBodyStyle};
    position: fixed;
    top: -${savedScrollY}px;
    left: 0;
    right: 0;
    width: 100%;
    overflow: hidden;
  `;
};

// 解锁
const unlockBodyScroll = () => {
  document.body.style.cssText = savedBodyStyle;
  window.scrollTo(0, savedScrollY);
};

原理解析:

  1. position: fixed 让 body 脱离文档流,不再滚动
  2. top: -${savedScrollY}px 用负值补偿当前滚动位置,保持视觉一致
  3. overflow: hidden 禁止滚动
  4. 关闭弹窗时恢复原始样式,并用 scrollTo 恢复滚动位置

Vue 组件中的使用

import { watch, toRefs, nextTick } from 'vue';

const props = defineProps<{ show: boolean }>();
const { show } = toRefs(props);

watch(show, (val) => {
  if (val) {
    nextTick(() => {
      lockBodyScroll();
    });
  } else {
    unlockBodyScroll();
  }
}, { immediate: true });

// 组件卸载时确保解锁,防止状态残留
onBeforeUnmount(() => {
  if (show.value) {
    unlockBodyScroll();
  }
});

CSS 优化

.dialog-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;

  // 动态视口高度,iOS 15+ 支持
  // 会随软键盘弹出自动调整
  min-height: 100vh;
  min-height: 100dvh;

  // 强制创建独立合成层
  // 让弹窗的渲染与页面隔离
  transform: translate3d(0, 0, 0);
  -webkit-transform: translate3d(0, 0, 0);

  // 禁用触摸滚动手势
  touch-action: none;
  -webkit-touch-callout: none;

  // 防止滚动穿透到父元素
  overscroll-behavior: contain;
}

CSS 属性解析:

属性 作用
100dvh 动态视口高度,会随软键盘变化
transform: translate3d(0,0,0) 触发 GPU 加速,创建独立图层
touch-action: none 禁用所有触摸手势
overscroll-behavior: contain 滚动到边界时不会触发父元素滚动

踩过的坑

坑 1:忘记在组件卸载时解锁

如果用户在弹窗打开状态下切换页面(比如点击了弹窗里的链接),bodyfixed 样式会残留,导致新页面无法滚动。

解决: 一定要在 onBeforeUnmount 里清理。

坑 2:滚动位置恢复不准确

window.scrollY 在某些老旧浏览器或特殊情况下可能是 undefined

解决: 使用兼容性写法:

const scrollY = window.scrollY || window.pageYOffset || document.documentElement.scrollTop || 0;

坑 3:100vh 在 iOS 上不靠谱

iOS Safari 的 100vh 是一个"理想值",包含了地址栏的高度。当地址栏收起或软键盘弹出时,实际可视区域会变化,但 100vh 不变。

解决: 使用 100dvh(动态视口高度),但要注意兼容性:

  • iOS 15.4+
  • Chrome 108+
  • Firefox 101+

对于不支持的浏览器,可以用 JS 动态计算:

const setVh = () => {
  const vh = window.innerHeight * 0.01;
  document.documentElement.style.setProperty('--vh', `${vh}px`);
};
window.addEventListener('resize', setVh);

坑 4:多个弹窗嵌套

如果页面上有多个弹窗组件,每个都调用 lockBodyScroll,会互相覆盖 savedScrollY

解决: 使用引用计数:

let lockCount = 0;
let savedScrollY = 0;
let savedBodyStyle = '';

const lockBodyScroll = () => {
  if (lockCount === 0) {
    savedScrollY = window.scrollY || 0;
    savedBodyStyle = document.body.style.cssText;
    document.body.style.cssText = `...`;
  }
  lockCount++;
};

const unlockBodyScroll = () => {
  lockCount--;
  if (lockCount === 0) {
    document.body.style.cssText = savedBodyStyle;
    window.scrollTo(0, savedScrollY);
  }
};

延伸:其他解决方案

方案二:使用 Visual Viewport API

const adjustDialogPosition = () => {
  if (window.visualViewport) {
    const offsetY = window.innerHeight - window.visualViewport.height;
    dialogRef.value.style.transform = `translateY(-${offsetY / 2}px)`;
  }
};

onMounted(() => {
  window.visualViewport?.addEventListener('resize', adjustDialogPosition);
});

onBeforeUnmount(() => {
  window.visualViewport?.removeEventListener('resize', adjustDialogPosition);
});

优点: 不需要锁定 body,更"优雅" 缺点: 需要持续监听,有性能开销;弹窗位置会跳动

方案三:软键盘收起后再显示弹窗

const showDialog = () => {
  // 先让输入框失焦,收起软键盘
  (document.activeElement as HTMLElement)?.blur();

  // 等待软键盘收起动画完成
  setTimeout(() => {
    dialogVisible.value = true;
  }, 300);
};

优点: 简单粗暴,绕过问题 缺点: 用户体验不好,有明显延迟


总结

这个问题本质上是 iOS WebKit 的 viewport 处理机制position: fixed 定位 的冲突。

关键点:

  1. iOS Safari 软键盘弹出时只改变 Visual Viewport,不改变 Layout Viewport
  2. position: fixed 根据 Layout Viewport 计算位置,但渲染在 Visual Viewport
  3. 页面切换时滚动状态累积,加剧了偏移

解决方案的核心就一句话:弹窗显示时锁定 body 滚动,关闭时恢复

这不是 bug,是 Apple 的"设计选择"。作为开发者,我们只能见招拆招。

希望这篇文章能帮到遇到同样问题的朋友,少走点弯路。


参考资料:

当 JS 阻塞主线程时,为什么你的 CSS 动画还在跑?

—— 从一个 React Demo 看浏览器渲染与合成线程

传送门:tools.mind-elixir.com/zh/learn-js…

动画阻塞实验.png

在前端开发中,我们常听到一句话:“不要阻塞主线程(Main Thread)”。我们也常听到另一条性能优化建议:“尽量使用 transformopacity 做动画”。

这两者之间有什么深层联系?为什么当一段死循环 JavaScript 代码把页面卡死时,某些动画却能像“幸存者”一样流畅运行,而其他的则瞬间冻结?

今天,我们通过一个具体的 React Demo 来通过实验揭开浏览器渲染机制的面纱。

实验现场:一个“卡顿”的按钮

我们构建了一个简单的 React 应用。核心逻辑在于一个名为“Block Main Thread”的按钮。点击它,会触发一段耗时 3 秒的同步 while 循环:

// 模拟主线程阻塞
const handleBlock = () => {
  setIsBlocking(true);
  setTimeout(() => {
    const start = Date.now();
    const duration = 3000; 
    // 💀 死循环:彻底霸占主线程 3000ms
    while (Date.now() - start < duration) {
      // Blocking main thread
    }
    setIsBlocking(false);
  }, 100);
};

在这 3 秒内,浏览器的主线程(Main Thread)被完全挂起。它无法响应点击、无法滚动页面、无法执行其他的 JavaScript 代码。

但在页面上,我们放置了四个正在运行的动画方块(Case A, B, C, D)。当我们点击阻塞按钮时,奇怪的现象发生了:

  1. 方块 C(JS 驱动):立即停止。
  2. 方块 D(Margin 动画):立即停止。
  3. 方块 B(Transform + Width):立即停止(或变得极度卡顿)。
  4. 方块 A(纯 Transform)居然还在流畅旋转!

为什么方块 A 能突破 JS 的封锁?

必须了解的两位主角:主线程 vs. 合成线程

要理解这个现象,我们需要知道浏览器内部也是“多线程”工作的。其中最重要的两个线程是:

  1. 主线程 (Main Thread)
  • 它是最忙碌的。负责运行 JavaScript、计算 HTML 元素的样式(Style)、计算页面布局(Layout)、绘制图层内容(Paint)。
  • 它是“单线程”的。如果你写了一个死循环,它就没空去算布局,也没空绘图。
  1. 合成线程 (Compositor Thread)
  • 它是主线程的助手,通常由 GPU 协助。
  • 它的工作是将主线程画好的各个图层(Layers)接收过来,进行合成(Composite),并最终显示在屏幕上。
  • 重点:它可以独立于主线程处理一些特定的视觉变换,比如位移(Translate)、旋转(Rotate)、缩放(Scale)和透明度(Opacity)

案件分析

让我们结合代码逐一分析四个方块的命运。

受害者一:Case C (JavaScript 驱动)

// JS 驱动的动画逻辑
const animateJS = (time: number) => {
  // ...计算位置 x ...
  if (jsBoxRef.current) {
    jsBoxRef.current.style.transform = `translateX(${x}px)`;
  }
  requestRef.current = requestAnimationFrame(animateJS);
};

死因:依赖主线程。 requestAnimationFrame 是请求浏览器在下一次重绘前调用指定的回调函数。这个回调函数是在主线程上执行的。当主线程被 while 循环卡死时,animateJS 函数根本得不到执行机会。所以动画瞬间冻结。

受害者二:Case D (Layout 属性 - Margin)

/* 改变 margin-left */
@keyframes pure-margin {
  0% { margin-left: 0; }
  50% { margin-left: 150px; } /* 触发 Layout */
  100% { margin-left: 0; }
}

死因:触发重排(Reflow/Layout)。 margin 属性决定了元素在文档流中的位置。修改它会影响周围元素的排版。浏览器必须重新计算布局(Layout 阶段),这个计算过程必须在主线程完成。主线程忙着跑死循环,没空算布局,所以动画停止。

受害者三:Case B (混合属性 - Transform + Width)

@keyframes mixed-transform {
  0% { transform: rotate(0deg); width: 48px; }
  50% { transform: rotate(180deg); width: 120px; } /* Width 触发 Layout */
  100% { transform: rotate(360deg); width: 48px; }
}

死因:被队友拖累。 虽然它包含 transform(由合成线程处理),但它同时也改变了 widthwidth 的改变会触发 Layout。浏览器通常需要每一帧都确认 Layout 的结果,才能进行后续的绘制和合成。因为 width 的计算被主线程阻塞了,整个动画帧的提交被延后,导致即使是旋转部分也被迫停止。

幸存者:Case A (纯 Transform)

/* 仅改变 transform */
@keyframes pure-transform {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}

生还原因脱离主线程(Off Main Thread)。 这是性能优化的核心魔法。

  1. 当浏览器分析这个 CSS 动画时,它发现这个动画涉及 transform
  2. transform 不会改变元素的布局(Layout),也不需要重新绘制内容(Paint),它只需要把现有的图层拿来旋转一下。
  3. 主线程会将这个动画任务及其图层信息直接提交(Commit)给合成线程
  4. 合成线程说:“收到,剩下的交给我。”

此时,无论主线程是在跑死循环,还是在进行复杂的计算,合成线程都在独立工作。它不断地指示 GPU 旋转那个图层,并更新屏幕。

这就是为什么即便你的 JS 卡死了,Loading 转圈动画(如果写得对)依然在转。

总结与技术启示

通过这个 Demo,我们可以得出明确的性能优化原则:

  1. 避免主线程阻塞
  • 长耗时的计算任务(如大数组处理、复杂算法)不应在 UI 渲染路径(React Render, Event Handlers)中同步执行。
  • 考虑使用 Web Worker 将计算任务移出主线程。
  • 利用 React Concurrent ModeTime Slicing 切分任务。
  1. 动画性能黄金法则
  • **坚持使用 transformopacity**。只有这两个属性可以保证 100% 在合成线程运行,完全不受 JS 阻塞影响。
  • 避免动画化布局属性(如 width, height, margin, padding, left, top)。这些属性不仅开销大(触发 Layout),而且一旦主线程卡顿,动画就会掉帧。
  • 警惕“混合污染”:不要在一个 @keyframes 中同时包含合成属性(transform)和布局属性(width),否则高性能的属性也会被拖累。

下一步行动

检查你项目中的 Loading 组件或过渡动画。确保它们是基于 CSS transform 实现的,而不是 JavaScript 或 margin。这样,即使你的后端接口返回慢,或者前端数据处理卡顿,用户依然能看到一个丝般顺滑的加载指示器,从而减少“应用已崩溃”的错觉。

一次 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 展示、参数面板、素材与发布流程做成可复现的交付闭环。

OC消息转发机制

OC的消息转发机制(Message Forwarding)是 Objective-C 动态特性的核心之一。它允许对象在无法直接响应某个消息时,有机会将其转发给其他对象处理,而不是直接崩溃。

这个机制分为三个阶段,按顺序执行:


第一阶段:动态方法解析(Dynamic Method Resolution)

  • 方法名resolveInstanceMethod: (实例方法) 和 resolveClassMethod: (类方法)
  • 调用时机:当对象在自己的方法列表(objc_method_list)中找不到对应的方法实现时,会首先调用这个方法。
  • 作用:允许对象动态地添加新的方法实现。
  • 返回值:返回 YES 表示已成功添加方法,NO 表示未处理。
  • 关键点:这个阶段可以使用 class_addMethod 函数来添加方法。

示例代码:

// 假设有一个类 MyObject
@interface MyObject : NSObject
@end

@implementation MyObject

// 第一阶段:动态方法解析
+ (BOOL)resolveInstanceMethod:(SEL)sel {
    // 检查是否是我们想动态添加的方法
    if (sel == @selector(someDynamicMethod)) {
        // 动态添加方法实现
        IMP newIMP = imp_implementationWithBlock(^{
            NSLog(@"This method was added dynamically!");
        });
        
        // 将新方法添加到类中
        class_addMethod([self class], sel, newIMP, "v@:");
        return YES; // 表示已处理
    }
    
    // 其他方法交给后续阶段处理
    return [super resolveInstanceMethod:sel];
}

// 原始方法(这里我们不定义,让其走转发流程)
// - (void)someDynamicMethod; // 这个方法在类中没有实现

@end

// 使用示例
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        MyObject *obj = [[MyObject alloc] init];
        
        // 调用动态添加的方法
        [obj someDynamicMethod]; // 输出: This method was added dynamically!
        
        // 如果调用一个不存在的方法,会进入第二阶段
        // [obj undefinedMethod]; // 会进入第二阶段
        
    }
    return 0;
}

第二阶段:备选接收者(Forwarding Target)

  • 方法名forwardingTargetForSelector:
  • 调用时机:如果第一阶段没有处理该方法,且对象实现了这个方法,系统会调用它。
  • 作用:允许对象将消息转发给另一个对象(备选接收者)。
  • 返回值:返回一个对象,该对象将接收后续的消息。如果返回 nil,则进入第三阶段。
  • 关键点:这个阶段是直接转发,不改变消息的 selector

示例代码:

@interface AnotherObject : NSObject
- (void)forwardedMethod;
@end

@implementation AnotherObject
- (void)forwardedMethod {
    NSLog(@"This method is forwarded to AnotherObject!");
}
@end

@interface MyObject : NSObject
@property (nonatomic, strong) AnotherObject *anotherObject; // 备选接收者
@end

@implementation MyObject

// 第二阶段:提供备选接收者
- (id)forwardingTargetForSelector:(SEL)aSelector {
    // 检查是否是特定方法,如果是,则转发给 anotherObject
    if (aSelector == @selector(forwardedMethod)) {
        return self.anotherObject; // 转发给 anotherObject
    }
    
    // 其他方法不转发,进入第三阶段
    return nil;
}

// 第一阶段:动态方法解析(这里不处理 forwardMethod)
// + (BOOL)resolveInstanceMethod:(SEL)sel { ... }

@end

// 使用示例
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        MyObject *obj = [[MyObject alloc] init];
        obj.anotherObject = [[AnotherObject alloc] init];
        
        // 调用一个不在 MyObject 中定义的方法,但会转发给 anotherObject
        [obj forwardedMethod]; // 输出: This method is forwarded to AnotherObject!
        
    }
    return 0;
}

第三阶段:完整的消息转发(Full Forwarding Mechanism)

  • 方法名

    • methodSignatureForSelector::获取方法签名(NSMethodSignature)。
    • forwardInvocation::实际转发 NSInvocation 对象。
  • 调用时机:如果前两个阶段都没有处理该消息,系统会进入这个阶段。

  • 作用:允许你完全控制消息的转发过程,包括方法签名和参数。

  • 关键点

    • 首先调用 methodSignatureForSelector: 获取方法签名,如果返回 nil,则消息转发失败。
    • 然后调用 forwardInvocation:,传入封装了消息的 NSInvocation 对象。
    • 这个阶段允许你修改参数、执行不同的逻辑、或者将消息转发给多个对象

示例代码:

@interface TargetObject : NSObject
- (void)targetMethod:(NSString *)param1 andNumber:(NSInteger)num;
@end

@implementation TargetObject
- (void)targetMethod:(NSString *)param1 andNumber:(NSInteger)num {
    NSLog(@"TargetObject received: %@, %@", param1, @(num));
}
@end

@interface MyObject : NSObject
@property (nonatomic, strong) TargetObject *targetObject;
@end

@implementation MyObject

// 第三阶段:完整转发机制
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
    // 检查是否是我们想转发的方法
    if (aSelector == @selector(targetMethod:andNumber:)) {
        // 返回方法签名,用于后续的 invocation 构造
        return [NSMethodSignature signatureWithObjCTypes:"v@:@i"];
    }
    
    // 其他方法交给超类处理
    return [super methodSignatureForSelector:aSelector];
}

- (void)forwardInvocation:(NSInvocation *)anInvocation {
    // 检查 invocation 的 selector 是否是我们要处理的
    SEL selector = [anInvocation selector];
    if (selector == @selector(targetMethod:andNumber:)) {
        // 执行转发逻辑,例如调用 targetObject
        [anInvocation invokeWithTarget:self.targetObject];
        // 或者执行其他逻辑
        // NSLog(@"Forwarding via NSInvocation...");
    } else {
        // 如果不是我们处理的,调用超类的 forwardInvocation
        [super forwardInvocation:anInvocation];
    }
}

// 第一阶段和第二阶段:这里不处理特定方法,让其进入完整转发

@end

// 使用示例
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        MyObject *obj = [[MyObject alloc] init];
        obj.targetObject = [[TargetObject alloc] init];
        
        // 调用一个不在 MyObject 中定义的方法,会进入完整转发
        [obj targetMethod:@"Hello" andNumber:42]; // 输出: TargetObject received: Hello, 42
        
    }
    return 0;
}

总结

OC的消息转发机制是一个强大的特性,允许开发者在运行时灵活处理未知消息。它分为三个阶段:

  1. 动态方法解析:允许对象动态添加方法。
  2. 备选接收者:允许对象将消息转发给另一个对象。
  3. 完整转发机制:允许开发者完全控制消息的转发和执行过程。

关键理解点:

  • 顺序性:严格按照上述三个阶段进行。
  • 最终兜底:如果所有转发机制都没处理,会调用 -doesNotRecognizeSelector:,默认抛出异常。
  • 灵活性:可用于实现动态代理拦截器协议适配器等功能。
  • 性能考虑:消息转发会带来一定的性能开销,应谨慎使用。

这个机制是理解OC动态性、实现高级功能(如KVO、运行时、协议实现)的基础。

应用场景

消息转发机制(Message Forwarding)在实际开发中有许多重要的应用场景,它利用了Objective-C的动态特性,提供了强大的灵活性和扩展性。以下是一些关键的应用:

1. 拦截器/切面编程(Interceptor/AOP)

通过消息转发,可以实现类似AOP(面向切面编程)的功能,对方法调用前后进行增强。

应用场景:

  • 日志记录:自动记录方法调用、参数、返回值。
  • 性能监控:测量方法执行时间。
  • 权限检查:在方法执行前进行权限验证。
  • 缓存机制:将方法结果缓存起来。

示例:

@interface LoggingInterceptor : NSObject
@property (nonatomic, strong) id target;
@end

@implementation LoggingInterceptor

- (id)forwardingTargetForSelector:(SEL)aSelector {
    // 为所有方法添加日志记录
    NSLog(@"[LOG] Calling method: %@", NSStringFromSelector(aSelector));
    return self.target; // 转发给实际的目标对象
}

- (void)forwardInvocation:(NSInvocation *)anInvocation {
    // 在调用前记录参数
    NSLog(@"[LOG] Parameters: %@", [self getInvocationArguments:anInvocation]);
    
    // 执行实际方法
    [anInvocation invokeWithTarget:self.target];
    
    // 在调用后记录返回值
    id returnValue;
    [anInvocation getReturnValue:&returnValue];
    NSLog(@"[LOG] Return value: %@", returnValue);
}

- (NSString *)getInvocationArguments:(NSInvocation *)invocation {
    // 获取参数信息(简化示例)
    return @"(arguments)";
}

@end

2. 动态方法注册(Dynamic Method Registration)

在运行时根据条件动态地注册或启用某些方法。

应用场景:

  • 功能开关:根据配置启用/禁用某些功能。
  • 插件系统:动态加载插件并注册其方法。
  • 条件编译:根据不同环境(Debug/Release)注册不同方法。

示例:

@interface ConditionalObject : NSObject
@property (nonatomic, assign) BOOL debugEnabled;
@end

@implementation ConditionalObject

+ (BOOL)resolveInstanceMethod:(SEL)sel {
    if (sel == @selector(debugLog:)) {
        if ([self debugEnabled]) {
            // 动态添加调试日志方法
            IMP debugIMP = imp_implementationWithBlock(^(__unsafe_unretained id self, NSString *message) {
                NSLog(@"DEBUG: %@", message);
            });
            class_addMethod([self class], sel, debugIMP, "v@:@");
            return YES;
        }
    }
    return [super resolveInstanceMethod:sel];
}

@end

3. 模拟多重继承(Multiple Inheritance Simulation)

虽然Objective-C不直接支持多重继承,但可以通过消息转发模拟类似效果。

应用场景:

  • 混合类:让一个类同时拥有多个协议的行为。
  • 组合模式:将多个对象的行为组合到一个类中。

示例:

@interface CompositeObject : NSObject
@property (nonatomic, strong) id<Printable> printer;
@property (nonatomic, strong) id<Serializable> serializer;
@end

@implementation CompositeObject

- (id)forwardingTargetForSelector:(SEL)aSelector {
    // 如果方法与打印相关,转发给printer
    if ([self printer] && [self printer respondsToSelector:aSelector]) {
        return self.printer;
    }
    
    // 如果方法与序列化相关,转发给serializer
    if ([self serializer] && [self serializer respondsToSelector:aSelector]) {
        return self.serializer;
    }
    
    return nil;
}

@end

4. 与KVO和运行时的结合

消息转发机制常与KVO、运行时(Runtime)特性结合使用,实现更高级的功能。

应用场景:

  • 自定义KVO:实现更灵活的观察者模式。
  • 运行时方法交换:在运行时动态替换方法实现。

示例(结合运行时):

@interface RuntimeSwapper : NSObject
@property (nonatomic, strong) id target;
@end

@implementation RuntimeSwapper

- (void)swizzleMethod:(SEL)originalSel withMethod:(SEL)swizzledSel {
    // 运行时方法交换
    Method originalMethod = class_getInstanceMethod([self.target class], originalSel);
    Method swizzledMethod = class_getInstanceMethod([self class], swizzledSel);
    
    // 交换方法实现
    method_exchangeImplementations(originalMethod, swizzledMethod);
}

- (void)forwardInvocation:(NSInvocation *)anInvocation {
    // 在转发过程中,可以进行额外的处理
    // 例如:记录调用、修改参数等
    
    // 执行原始方法
    [anInvocation invokeWithTarget:self.target];
}

@end

5. 实现respondsToSelector:instancesRespondToSelector:的增强

通过消息转发机制,可以实现更复杂的响应判断逻辑。

示例:

@interface EnhancedObject : NSObject
@end

@implementation EnhancedObject

- (BOOL)respondsToSelector:(SEL)aSelector {
    // 先检查原生方法
    if ([super respondsToSelector:aSelector]) {
        return YES;
    }
    
    // 然后检查通过转发能处理的方法
    // 可以通过动态方法解析或转发机制来判断
    // 这里简化处理
    return NO;
}

@end

总结

消息转发机制在iOS开发中提供了强大的灵活性,使得开发者能够:

  • 增强现有功能:无需修改原始代码即可添加新行为。
  • 实现设计模式:如代理、装饰器、适配器等。
  • 提高代码复用性:通过通用转发逻辑处理多种情况。
  • 构建动态系统:根据运行时条件调整行为。
  • 实现高级架构:如插件系统、配置驱动API等。

注意事项:

  • 性能影响:消息转发会带来额外的开销,应谨慎使用。
  • 调试困难:转发链复杂时,调试和追踪问题会变得困难。
  • 文档重要性:使用消息转发的代码需要详细的文档说明其行为。

彻底搞懂大文件上传:从幼儿园到博士阶段的全栈方案

第一层:幼儿园阶段 —— 为什么要搞复杂上传?

想象一下:你要把家里的**一万本书(10GB 文件)**搬到新家。

普通方案(简单上传):你试图一次性把一万本书塞进一个小三轮车。结果:车爆胎了(浏览器内存溢出),或者路上堵车太久,新家管理员等得不耐烦把门关了(请求超时)。

全栈方案(分片上传):你把书装成 100 个小箱子,一箱一箱运。即便路上有一箱丢了,你只需要补发那一箱,而不是重新搬一万本书。

为什么这样做?

  • 降低单次传输的数据量,避免内存溢出
  • 减少单个请求的处理时间,降低超时风险
  • 支持断点续传,提高上传成功率

第二层:小学阶段 —— 简单上传的极限

对于小于 10MB 的图片或文档,我们用 FormData。

前端 (Vue):input type="file" 获取文件,封入 FormData,通过 axios 发送。

后端 (Node):使用 multer 或 formidable 中间件接收。

数据库 (MySQL):切记! 数据库不存文件二进制流,只存文件的访问路径(URL)、文件名、大小和上传时间。

为什么数据库不存文件?

  • 文件体积太大,影响数据库性能
  • 数据库备份和迁移变得困难
  • 磁盘空间浪费,难以清理

简单上传的代码示例

// 前端
const formData = new FormData();
formData.append('file', fileInput.files[0]);
axios.post('/upload', formData);

// 后端
const upload = multer({ dest: 'uploads/' });
app.post('/upload', upload.single('file'), (req, res) => {
  // 处理上传
});

第三层:中学阶段 —— 分片上传 (Chunking) 的逻辑公式

这是面试的核心起点。请背诵流程:

切片:利用 File 对象的 slice 方法(底层是 Blob),将大文件切成 N 份。 标识:给每个片起个名字,通常是 文件名 + 下标。 并发发送:同时发送多个 HTTP 请求。 合并:前端发个"指令",后端把所有碎片按顺序合成一个完整文件。

为什么要用slice方法?

  • 它不会复制整个文件到内存,而是创建一个指向原文件部分的引用
  • 内存占用极小,适合处理大文件
  • 可以精确控制每个分片的大小

分片上传的实现逻辑

// 分片函数
function createFileChunks(file, chunkSize = 1024 * 1024 * 2) { // 2MB per chunk
  const chunks = [];
  let start = 0;
  
  while (start < file.size) {
    const end = Math.min(start + chunkSize, file.size);
    const chunk = file.slice(start, end); // 核心API
    chunks.push({
      blob: chunk,
      index: chunks.length,
      start,
      end
    });
    start = end;
  }
  
  return chunks;
}

第四层:大学阶段 —— 秒传与唯一标识 (MD5)

面试官问:"如果用户上传一个服务器已经有的文件,怎么实现秒传?"

核心:文件的"身份证"。不能用文件名,要用文件的内容生成 MD5 哈希值。

流程

  1. 前端用 spark-md5 计算文件哈希。
  2. 上传前先问后端:"这个 MD5 对应的文件你有没有?"
  3. 后端查 MySQL,如果有,直接返回成功——这就是秒传。

为什么用MD5而不是文件名?

  • 文件名可以重复,但内容不同的文件
  • 文件名可以被修改,但内容不变
  • MD5是内容的数字指纹,相同内容必定有相同的MD5

MD5计算示例

import SparkMD5 from 'spark-md5';

function calculateFileHash(file) {
  return new Promise((resolve) => {
    const fileReader = new FileReader();
    const spark = new SparkMD5.ArrayBuffer();
    let chunkIndex = 0;
    const chunkSize = 2097152; // 2MB
    
    const loadNext = () => {
      const start = chunkIndex * chunkSize;
      const end = Math.min(start + chunkSize, file.size);
      
      fileReader.readAsArrayBuffer(file.slice(start, end));
    };
    
    fileReader.onload = (e) => {
      spark.append(e.target.result);
      chunkIndex++;
      
      if (chunkIndex * chunkSize < file.size) {
        loadNext(); // 继续读取下一个分片
      } else {
        resolve(spark.end()); // 返回最终哈希值
      }
    };
    
    loadNext();
  });
}

第五层:博士阶段 —— 断点续传 (Resumable)

如果传到一半断网了,剩下的怎么办?

方案 A:前端记录(不可靠) 方案 B(推荐):后端 MySQL 记录已收到的分片序号。

流程

  1. 重新上传前,调用 checkChunks 接口
  2. 后端查库返回:{ uploadedList: [1, 2, 5] }
  3. 前端过滤掉已存在的序号,只发剩下的

为什么选择后端记录?

  • 前端存储不可靠(localStorage可能被清除)
  • 支持跨设备续传(从手机上传一半,从电脑继续)
  • 数据一致性更好,不容易出现脏数据

断点续传实现

// 检查已上传分片
async function checkUploadedChunks(fileHash) {
  const response = await api.checkChunks({ fileHash });
  return response.uploadedList || []; // 已上传的分片索引数组
}

// 过滤未上传的分片
const uploadedList = await checkUploadedChunks(fileHash);
const needUploadChunks = allChunks.filter(chunk => 
  !uploadedList.includes(chunk.index)
);

第六层:性能巅峰 —— 只有 1% 的人知道的 Worker 计算

浏览器是"单线程"的,JavaScript 引擎和页面渲染(DOM 树构建、布局、绘制)共用一个线程,如果你在主线程执行一个耗时 10 秒的循环(比如计算大文件的 MD5),浏览器会直接卡死。用户点不动按钮、动画停止、甚至浏览器弹出"页面无响应"。

屏幕刷新率通常是 60Hz,意味着浏览器每 16.7ms 就要渲染一帧。如果你的计算任务占据了这 16.7ms,页面就会掉帧、卡顿。

二、 什么是 Web Worker?(定义)

Web Worker 是 HTML5 标准引入的一项技术,它允许 JavaScript 脚本在后台线程中运行,不占用主线程。

它的地位: 它是主线程的"打工仔"。

它的环境: 它运行在另一个完全独立的环境中,拥有自己的全局对象(self 而不是 window)。

三、 Web Worker 能干什么?(职责与局限)

能干什么:

CPU 密集型计算: MD5 计算、加密解密、图像/视频处理、大数据排序。

网络请求: 可以在后台轮询接口。

不能干什么(面试必考点):

不能操作 DOM: 它拿不到 window、document、parent 对象。

不能弹窗: 无法使用 alert()。

受限通信: 它和主线程之间只能通过 "消息传递"(PostMessage)沟通。

四、 怎么干?(核心 API 实战)

我们要实现的目标是:在不卡顿页面的情况下,计算一个 1GB 文件的 MD5。

步骤 1:主线程逻辑(Vue/JS 环境)

主线程负责雇佣 Worker,并给它派活。

// 1. 创建 Worker 实例 (路径指向 worker 脚本)
const myWorker = new Worker('hash-worker.js');

// 2. 发送任务 (把文件对象传给 Worker)
myWorker.postMessage({ file: fileObject });

// 3. 接收结果 (监听 Worker 回传的消息)
myWorker.onmessage = (e) => {
    const { hash, percentage } = e.data;
    if (hash) {
        console.log("计算完成!MD5 为:", hash);
    } else {
        console.log("当前进度:", percentage);
    }
};

// 4. 异常处理
myWorker.onerror = (err) => {
    console.error("Worker 报错了:", err);
};

步骤 2:Worker 线程逻辑(hash-worker.js)

Worker 负责埋头苦干。

// 引入计算 MD5 的库 (Worker 内部引用脚本的方式)
importScripts('spark-md5.min.js');

self.onmessage = function(e) {
    const { file } = e.data;
    const spark = new SparkMD5.ArrayBuffer(); // 增量计算实例
    const reader = new FileReader();
    const chunkSize = 2 * 1024 * 1024; // 每次读 2MB
    let currentChunk = 0;

    // 分块读取的核心逻辑
    reader.onload = function(event) {
        spark.append(event.target.result); // 将 2MB 数据喂给 spark
        currentChunk++;

        if (currentChunk < Math.ceil(file.size / chunkSize)) {
            loadNext(); // 继续读下一块
            // 反馈进度
            self.postMessage({ 
                percentage: (currentChunk / Math.ceil(file.size / chunkSize) * 100).toFixed(2) 
            });
        } else {
            // 全部读完,生成最终 MD5
            const md5 = spark.end();
            self.postMessage({ hash: md5 }); // 完工,回传结果
        }
    };

    function loadNext() {
        const start = currentChunk * chunkSize;
        const end = Math.min(start + chunkSize, file.size);
        // 关键点:使用 slice 切片读取,避免一次性读入内存
        reader.readAsArrayBuffer(file.slice(start, end));
    }

    loadNext(); // 开始任务
};

五、 关键思路过程(给面试官讲出深度)

当你向面试官复述这个过程时,要按照这个逻辑链条:

实例化(New Worker):

"首先,我通过 new Worker 启动一个独立线程,将耗时的计算逻辑从主线程剥离。"

数据传输(PostMessage):

"主线程将 File 对象发送给 Worker。这里要注意,File 对象是 File 句柄,发送它并不会瞬间占据大量内存,因为它是基于 Blob 的,是惰性的。"

分块读取与增量计算(Chunked Hashing):

"这是最核心的一步。即便在 Worker 内部,我也不能直接读取整个文件(比如 5GB 读进内存会直接让 Worker 进程挂掉)。

我使用了 file.slice 配合 FileReader,每次只读取 2MB 数据。

配合 spark-md5 的 append 方法,将数据'喂'给计算引擎,处理完后,之前的内存块会被释放。"

异步通信(Messaging):

"在计算过程中,我不断通过 self.postMessage 向主线程发送计算进度,以便用户在界面上能看到动态的百分比。

最后计算完成,通过消息回传最终 MD5。"

六、 总结:核心 API 记事本

new Worker('path'): 开启招聘。

postMessage(data): 互发短信。

onmessage: 接收短信。

importScripts(): Worker 内部加载插件。

file.slice(): 物理上的"切片",MD5 不崩溃的秘诀。

FileReader.readAsArrayBuffer(): 将二进制内容读入内存进行计算。

七. 谈 内存管理 (Memory Management)

面试官可能会问: "在Worker内部如何避免内存溢出?"

回答: "在 Worker 内部,我没有使用 fileReader.readAsArrayBuffer(file) 直接读取整个文件。因为 4GB 的文件如果直接读入内存,V8 引擎会直接 OOM (Out of Memory) 崩溃。我采用了 '分块读取 -> 增量哈希' 的策略。利用 spark.append() 每次只处理 2MB 的数据,处理完后 V8 的垃圾回收机制会自动释放这块内存,从而实现用极小的内存开销处理极大的文件。"

八. 谈 抽样哈希 (性能杀手锏) —— 只有 1% 的人知道的黑科技

面试官: "如果文件 100GB,Worker 计算也要好几分钟,用户等不及怎么办?"

你的杀手锏: "如果对完整性校验要求不是 100% 严苛,我会采用 '抽样哈希' 方案:

  • 文件头 2MB 全部取样
  • 文件尾 2MB 全部取样
  • 中间部分:每隔一段距离取样几个字节

这样 10GB 的文件我也只需要计算 10MB 左右的数据,MD5 计算会在 1秒内 完成,配合后端校验,能实现'秒级'预判,极大提升用户体验。"

D. 总结:面试加分关键词

  • 增量计算 (Incremental Hashing):不是一次性算,是攒着算
  • Blob.slice:文件切片的核心底层 API
  • 非阻塞 (Non-blocking):Worker 的核心价值
  • OOM 预防:通过分块读取控制内存峰值
  • 抽样哈希:大文件快速识别的有效手段

"其实 Web Worker 也有开销,创建它需要时间和内存。对于几百 KB 的小文件,直接在主线程算可能更快;但对于大文件上传,Worker 是保证 UI 响应性 的唯一正确解。"

第七层:Node.js 后端压测 —— 碎片合并的艺术

当 1000 个切片传上来,Node 如何高效合并?

初级:fs.readFileSync。 瞬间撑爆内存,Node.js(V8)默认内存限制通常在 1GB~2GB 左右。执行 readFile 合并大文件,服务器会瞬间 Crash。

高级:fs.createReadStream 和 fs.createWriteStream。

利用"流(Stream)"的管道模式,边读边写,内存占用极低。

一、 什么是 Stream?(本质定义)

流(Stream)是 Node.js 提供的处理 流式数据 的抽象接口。它将数据分成一小块一小块(Buffer),像流水一样从一头流向另一头。

Readable(可读流): 数据的源头(如:分片文件)。

Writable(可写流): 数据的终点(如:最终合并的大文件)。

Pipe(管道): 连接两者的水管,数据通过管道自动流过去。

二、 能干什么?

在文件上传场景中,它能:

低内存合并: 无论文件多大(1G 或 100G),内存占用始终稳定在几十 MB。

边读边写: 读入一小块分片,立即写入目标文件,不需要等待整个分片读完。

自动背压处理(Backpressure): 如果写的速度慢,读的速度快,管道会自动让读取慢下来,防止内存积压。

三、 怎么干?(核心 API 与实战)

1. 核心 API 记事本

fs.createReadStream(path):创建一个指向分片文件的水龙头。

fs.createWriteStream(path, { flags: 'a' }):创建一个指向目标文件的接收桶。flags: 'a' 表示追加写入(Append)。

reader.pipe(writer):把水龙头接到桶上。

const fs = require('fs');
const path = require('path');

/**
 * @param {string} targetFile 最终文件存放路径
 * @param {string} chunkDir 分片临时存放目录
 * @param {number} chunkCount 分片总数
 */
async function mergeFileChunks(targetFile, chunkDir, chunkCount) {
    // 1. 创建一个可写流,准备写入最终文件
    // flags: 'a' 表示追加模式,如果文件已存在,就在末尾接着写
    const writeStream = fs.createWriteStream(targetFile, { flags: 'a' });

    for (let i = 0; i < chunkCount; i++) {
        const chunkPath = path.join(chunkDir, `${i}.part`);
        
        // 2. 依次读取每个分片
        const readStream = fs.createReadStream(chunkPath);

        // 3. 核心:通过 Promise 封装,确保"按顺序"合并
        // 必须等第 0 片合完了,才能合第 1 片,否则文件内容会错乱
        await new Promise((resolve, reject) => {
            // 将读取流的内容导向写入流
            // 注意:end: false 表示读完这一个分片后,不要关闭目标文件写入流
            readStream.pipe(writeStream, { end: false });
            
            readStream.on('end', () => {
                // 读取完后,删除这个临时分片(节省空间)
                fs.unlinkSync(chunkPath); 
                resolve();
            });
            
            readStream.on('error', (err) => {
                reject(err);
            });
        });
    }

    // 4. 所有分片读完了,手动关闭写入流
    writeStream.end();
    console.log("合并完成!");
}

为什么用Stream而不是readFileSync?

  • readFileSync会将整个文件加载到内存,大文件会爆内存
  • Stream是流式处理,内存占用固定
  • 性能更好,适合处理大文件

吊打面试官:

"在大文件合并的处理上,我绝对不会使用 fs.readFileSync。我会使用 Node.js 的 Stream API。

具体的实现思路是:

首先,创建一个 WriteStream 指向最终文件。

然后,遍历所有分片,通过 createReadStream 逐个读取。

关键点在于利用 pipe 管道将读流导向写流。为了保证文件的正确性,我会通过 Promise 包装实现串行合并。同时,设置 pipe 的 end 参数为 false,确保写入流在合并过程中不被提前关闭。

这种做法的优势在于:利用了 Stream 的背压机制,内存占用极低(通常只有几十 KB),即便是在低配置的服务器上,也能稳定合并几十 GB 的大文件。"

第八层:MySQL 表结构设计 (实战架构)

你需要两张表来支撑这个系统:

文件表 (Files):id, file_md5, file_name, file_url, status(0:上传中, 1:已完成)。 切片记录表 (Chunks):id, file_md5, chunk_index, chunk_name。

查询优化:给 file_md5 加唯一索引,极大提升查询速度。

-- 文件主表
CREATE TABLE files (
  id INT AUTO_INCREMENT PRIMARY KEY,
  file_md5 VARCHAR(32) UNIQUE NOT NULL COMMENT '文件MD5',
  file_name VARCHAR(255) NOT NULL COMMENT '原始文件名',
  file_size BIGINT NOT NULL COMMENT '文件大小(字节)',
  file_url VARCHAR(500) COMMENT '存储路径',
  status TINYINT DEFAULT 0 COMMENT '状态:0-上传中,1-已完成',
  upload_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_file_md5 (file_md5)
);

-- 分片记录表
CREATE TABLE chunks (
  id INT AUTO_INCREMENT PRIMARY KEY,
  file_md5 VARCHAR(32) NOT NULL,
  chunk_index INT NOT NULL COMMENT '分片索引',
  chunk_name VARCHAR(100) NOT NULL COMMENT '分片文件名',
  upload_status TINYINT DEFAULT 0 COMMENT '上传状态',
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (file_md5) REFERENCES files(file_md5),
  INDEX idx_file_chunk (file_md5, chunk_index)
);

为什么file_md5要加索引?

  • 秒传查询时需要快速定位文件是否存在
  • 断点续传时需要快速获取某个文件的所有分片
  • 提升查询性能,避免全表扫描

一、 为什么要这么建表?(核心痛点)

如果不存数据库,只存本地文件系统,你会面临三个死穴:

断点续传没依据: 用户刷新网页,前端内存丢了,怎么知道哪些片传过?必须查库。

秒传无法实现: 10GB 的文件,怎么瞬间判断服务器有没有?全盘扫描物理文件?太慢!必须查索引。

并发合并风险: 多个请求同时触发合并逻辑怎么办?需要数据库的状态锁(Status)来控制。

二、 表结构详解:它们各司其职

1. 文件元数据表 (files) —— "身份证"

file_md5 (核心): 这是文件的唯一物理标识。不管文件名叫"高清.mp4"还是"学习资料.avi",只要内容一样,MD5 就一样。

status: 标记文件状态。

0 (Uploading): 还没传完或正在合并。

1 (Completed): 已经合并成功,可以直接访问。

file_url: 最终合并后的访问路径。

2. 切片记录表 (chunks) —— "进度表"

chunk_index: 记录这是第几个分片。

关系: 通过 file_md5 关联。一个 files 记录对应多个 chunks 记录。

三、 怎么建立联系?(逻辑关联图)

一对多关系:

files.file_md5 (1) <————> chunks.file_md5 (N)

秒传逻辑:

前端传 MD5 给后端。

SQL: SELECT file_url FROM files WHERE file_md5 = 'xxx' AND status = 1;

结果: 有记录?直接返回 URL(秒传成功)。没记录?进入下一步。

续传逻辑:

前端问:"这个 MD5 我传了多少了?"

SQL: SELECT chunk_index FROM chunks WHERE file_md5 = 'xxx';

结果: 后端返回 [0, 1, 2, 5],前端发现少了 3 和 4,于是只补传 3 和 4。

四、 关键思路与实战 SQL

1. 为什么加唯一索引(Unique Index)?

file_md5 必须是 UNIQUE。

面试点: "我给 file_md5 加了唯一索引,这不仅是为了查询快,更是一种业务兜底。在高并发下,如果两个用户同时上传同一个文件,数据库的唯一约束能防止产生两条重复的文件记录。"

2. 复合索引优化

在 chunks 表,我建议建立复合索引:INDEX idx_md5_index (file_md5, chunk_index)。

原因: 续传查询时,我们经常要查"某个 MD5 下的索引情况",复合索引能让这种搜索达到毫秒级。

五、 全流程演练(怎么干)

第一步:初始化 (Pre-check)

用户选好文件,计算 MD5,发给后端。

-- 尝试插入主表,如果 MD5 已存在则忽略(或返回已存在记录)
INSERT IGNORE INTO files (file_md5, file_name, file_size, status) 
VALUES ('abc123hash', 'video.mp4', 102400, 0);

第二步:分片上传 (Chunk Upload)

每收到一个片,存入 chunks 表。

-- 记录已收到的分片
INSERT INTO chunks (file_md5, chunk_index, chunk_name) 
VALUES ('abc123hash', 0, 'abc123hash_0.part');

第三步:合并触发 (Merge)

前端发合并指令,后端校验分片数量。

-- 检查分片是否齐全
SELECT COUNT(*) FROM chunks WHERE file_md5 = 'abc123hash';
-- 如果 Count == 总片数,开始 Stream 合并

第四步:完工 (Finish)

合并成功,更新主表。

-- 更新状态和最终路径
UPDATE files SET status = 1, file_url = '/uploads/2023/video.mp4' 
WHERE file_md5 = 'abc123hash';

六、 总结话术(吊打面试官版)

"我的数据库设计核心思路是 '内容标识胜于文件标识'。

我通过 files 表存储文件的 MD5 和全局状态,配合 UNIQUE 索引实现秒传的快速检索和并发控制。

通过 chunks 表记录每一个分片的到达状态,实现断点续传。

值得注意的细节是:

我使用了 BIGINT 来存储 file_size,因为 4GB 以上的文件 INT 会溢出。

我给 file_md5 做了索引优化,确保在百万级文件记录中,校验文件状态依然是 O(1) 的复杂度。

合并逻辑完成后,我会通过事务或状态锁更新 status 字段,确保数据的一致性。"

第九层:上帝视角 —— 并发控制 (Concurrency Control)

这一层是面试中的高光时刻。如果说 MD5 是为了"准确",Stream 是为了"稳健",那么并发控制就是为了"平衡"。

如果一个文件切了 1000 片,浏览器瞬间发出 1000 个请求,会导致浏览器崩溃或服务器宕机。

面试加分项:异步并发限制队列。

限制同时只有 6 个请求在跑(Chrome 的默认限制)。

async function sendRequest(tasks, limit = 6) {
    const pool = new Set();
    for (const task of tasks) {
        const promise = task();
        pool.add(promise);
        promise.then(() => pool.delete(promise));
        if (pool.size >= limit) await Promise.race(pool);
    }
}

为什么限制6个并发?

  • 浏览器对同一域名有最大连接数限制(通常为6)
  • 避免过多HTTP请求导致网络拥堵
  • 平衡上传速度和系统稳定性

一、 为什么要搞并发控制?(痛点)

  1. 浏览器"自保"机制: Chrome 浏览器对同一个域名的 HTTP 连接数有限制(通常是 6 个)。 如果你瞬间发起 1000 个请求,剩下的 994 个会处于 Pending(排队)状态。虽然不会直接崩溃,但会阻塞该域名的其他所有请求(比如你同时想加载一张图片,都要排在 900 多个切片后面)。

  2. 内存与性能压力: 前端: 1000 个 Promise 对象被创建,会瞬间吃掉大量内存。 后端(Node): 服务器瞬间接收 1000 个并发连接,磁盘 IO 会被占满,CPU 可能会飙升,甚至触发服务器的拒绝服务保护。

二、 什么是并发控制?(本质定义)

并发控制(Concurrency Control) 就像是一个"十字路口的红绿灯"或者"银行的排号机"。

它不改变任务总量。

它控制同一时刻正在运行的任务数量。

三、 怎么干?(核心逻辑公式)

我们要实现一个"工作池(Pool)",逻辑如下:

填满: 先一次性发出 limit(比如 6 个)个请求。

接替: 只要这 6 个请求中任何一个完成了,空出一个位子,就立刻补上第 7 个。

循环: 始终保持有 6 个请求在跑,直到 1000 个全部发完。

四、 代码详解:这 10 行代码值 5k 薪资

这是利用 Promise.race 实现的极其精妙的方案。

/**
 * @param {Array} tasks - 所有的上传任务(函数数组,执行函数才发请求)
 * @param {number} limit - 最大并发数
 */
async function sendRequest(tasks, limit = 6) {
    const pool = new Set(); // 正在执行的任务池
    const results = [];     // 存储所有请求结果

    for (const task of tasks) {
        // 1. 开始执行任务 (task 是一个返回 Promise 的函数)
        const promise = task();
        results.push(promise);
        pool.add(promise);

        // 2. 任务执行完后,从池子里删掉自己
        promise.then(() => pool.delete(promise));

        // 3. 核心:如果池子满了,就等最快的一个完成
        if (pool.size >= limit) {
            // Promise.race 会在 pool 中任何一个 promise 完成时 resolve
            await Promise.race(pool);
        }
    }

    // 4. 等最后剩下的几个也跑完
    return Promise.all(results);
}

五、 关键思路拆解(给面试官讲透)

  1. 为什么要传入 tasks 函数数组,而不是 Promise 数组? 回答: "因为 Promise 一旦创建就会立即执行。如果我传 [axios(), axios()],那并发控制就没意义了。我必须传 [() => axios(), () => axios()],这样我才能在循环里手动控制什么时候执行它。"

  2. Promise.race 起到了什么作用? 回答: "它充当了'阻塞器'。当池子满了,await Promise.race(pool) 会让 for 循环停下来。只有当池子里最快的一个请求完成了,race 才会解除阻塞,循环继续,从而发起下一个请求。"

  3. 为什么是 6 个? 回答: "这是基于 RFC 2616 标准建议和主流浏览器(Chrome/Firefox)的默认限制。超过 6 个,浏览器也会让剩下的排队。所以我们将并发数设为 6,既能榨干带宽,又能保持浏览器的响应顺畅。"

六、 进阶:如果请求失败了怎么办?(断点续传的结合)

在实战中,我们还需要加上重试机制。

// 伪代码:带重试的 task
const createTask = (chunk) => {
    return async () => {
        let retries = 3;
        while (retries > 0) {
            try {
                return await axios.post('/upload', chunk);
            } catch (err) {
                retries--;
                if (retries === 0) throw err;
            }
        }
    };
};

七、 总结

"在大文件上传场景下,盲目发起成百上千个切片请求会导致浏览器网络层阻塞和服务器压力过大。

我的解决方案是实现一个 '异步并发控制队列'。

核心思想是利用 Promise.race。我将并发数限制在 6 个。在 for 循环中,我维护一个 Set 结构的执行池。每当一个切片请求开始,就加入池子;完成后移出。当池子达到限制数时,利用 await Promise.race 阻塞循环,实现**'走一个,补一个'**的动态平衡。

这样做不仅遵守了浏览器的连接限制,更重要的是保证了前端页面的流畅度和后端 IO 的稳定性。如果遇到失败的请求,我还会配合重试逻辑和断点续传记录,确保整个上传过程的强壮性。"

第十层:终极回答策略 (架构师收网版)

如果面试官让你总结一套"完美的文件上传方案",请按照五个维度深度收网:

用户体验层 (Performance):

采用 Web Worker 开启后台线程,配合 Spark-MD5 实现增量哈希计算。

核心亮点:通过"分块读取 -> 增量累加"策略避免 4GB+ 大文件导致的浏览器 OOM(内存溢出),并利用 Worker 的非阻塞特性确保 UI 响应始终保持 60fps。对于超大文件,可选抽样哈希方案,秒级生成指纹。

传输策略层 (Strategy):

秒传:上传前预检 MD5,实现"内容即路径"的瞬间完成。

断点续传:以后端存储为准,通过接口查询 MySQL 中已存在的 chunk_index,前端执行 filter 增量上传。

并发控制:手写异步并发池,利用 Promise.race 实现"走一个,补一个"的槽位控制(限制 6 个并发),既榨干带宽又防止 TCP 阻塞及服务器 IO 爆表。

后端处理层 (Processing):

流式合并:放弃 fs.readFile,坚持使用 Node.js Stream (pipe)。

核心亮点:利用 WriteStream 的追加模式与读流对接,通过 Promise 串行化 保证切片顺序,并依靠 Stream 的背压(Backpressure)机制自动平衡读写速度,将内存占用稳定在 30MB 左右。

持久化设计层 (Database):

文件元数据管理:MySQL 记录文件 MD5、状态与最终存储 URL。

查询优化:给 file_md5 加 Unique Index(唯一索引),不仅提升秒传查询效率,更在数据库层面兜底高并发下的重复写入风险。

安全防护层 (Security):

二进制校验:不信任前端后缀名,后端读取文件流前 8 字节的 Magic Number(魔数) 校验二进制头,防止伪造后缀的木马攻击。

总结话术:一分钟"吊打"面试官

"在处理大文件上传时,我的核心思路是 '分而治之' 与 '状态持久化'。

在前端层面,我通过 Web Worker 配合增量哈希 解决了计算大文件 MD5 时的 UI 阻塞和内存溢出问题。利用 Blob.slice 实现逻辑切片后,我没有盲目发起请求,而是设计了一个基于 Promise.race 的并发控制队列,在遵守浏览器 TCP 限制的同时,保证了传输的平稳性。

在状态管理上,我采用 '后端驱动'的断点续传方案。前端在上传前会通过接口查询 MySQL 获取已上传分片列表,这种方案比 localStorage 更可靠,且天然支持跨设备续传。

在后端处理上,我深度使用了 Node.js 的 Stream 流 进行分片合并。通过管道模式与背压处理,我确保了服务器在处理几十 GB 数据时,内存水位依然保持在极低范围。

在安全性与严谨性上,我通过 MySQL 唯一索引 处理并发写冲突,通过 文件头二进制校验 过滤恶意文件。

这套方案不仅是功能的堆砌,更是对浏览器渲染机制、网络拥塞控制、内存管理以及服务器 IO 瓶颈的综合优化方案。"

💡 面试官可能追问的"补丁"

追问:如果合并过程中服务器断电了怎么办?

回答: 由于我们是通过数据库记录 status 的,合并未完成前 status 始终为 0。服务器重启后,可以根据 status=0 且切片已齐全的记录,重新触发合并任务,或者由前端下次触发预检时重新合并。

追问:切片大小设为多少合适?

回答: 通常建议 2MB - 5MB。太小会导致请求碎片过多(HTTP 头部开销大),太大容易触发网络波动导致的单个请求失败。

追问:上传过程中进度条如何实现?

回答: 两个维度。一是 MD5 进度(Worker 返回),二是上传进度(使用 axios 的 onUploadProgress 监控每个分片,结合已上传分片数量计算加权平均进度)。

JS-手写系列:防抖与节流

前言

在前端开发中,某些事件(如 resizescrollinputmousemove)会在短时间内频繁触发。如果处理函数涉及 DOM 操作或网络请求,频繁执行会导致页面卡顿或服务器压力过大。防抖节流正是解决这一问题的两把“手术刀”。


一、 防抖(Debounce)

1. 核心概念

触发事件后 nn 秒内函数只会执行一次。如果 nn 秒内事件再次被触发,则重新计算时间。“等最后一个人说完再行动。”

2. 使用场景

  • 搜索框输入:用户连续输入文字,只在停止输入后的 nn 毫秒发送搜索请求。
  • 窗口调整window.resize 时,只在用户停止拖拽后重新计算布局。

3. 实现

  function debounce(fn, delay) {
    let timer = null;

    return function (...args) {
      // 如果定时器存在,则清除,重新计时
      if (timer) clearTimeout(timer);

      // 正常的防抖逻辑
      timer = setTimeout(() => {
        fn.apply(this, args);
      }, delay);
    };
  }

  // 测试用例
  let count = 0;
  function handleInput() {
    count++;
    console.log('执行次数:', count);
  }

  const debouncedInput = debounce(handleInput, 1000);

  // 模拟快速调用5次
  debouncedInput();
  debouncedInput();
  debouncedInput();
  debouncedInput();
  debouncedInput();

  // 1秒后只会执行一次
  setTimeout(() => {
    console.log('最终执行次数应该是 1');
  }, 1100);

二、 节流(Throttle)

1. 核心概念

连续触发事件,但在 nn 秒内只允许执行一次。节流会显著稀释函数的执行频率。“技能冷却中。”

2. 使用场景

  • 鼠标点击:抢购按钮不断点击,规定时间内只发一次请求。
  • 滚动监听:页面无限加载时,每隔一段时间请求一次数据,而不是停下才请求。

3. 实现方案对比

方案 A:时间戳版

  • 特点:第一次触发立即执行。
 function throttleTimestamp(fn, delay) {
    let previous = 0;
    return function (...args) {
      const now = Date.now();
      if (now - previous > delay) {
        fn.apply(this, args);
        previous = now;
      }
    };
  }

  // 测试用例
  let count = 0;
  function handleClick() {
    count++;
    console.log('执行次数:', count, '时间:', Date.now());
  }

  const throttledClick = throttleTimestamp(handleClick, 1000);

  // 快速调用5次
  throttledClick();
  throttledClick();
  throttledClick();
  throttledClick();
  throttledClick();

  console.log('立即执行次数应该是 1');

  // 1.1秒后再调用,应该执行第二次
  setTimeout(() => {
    throttledClick();
    console.log('1.1秒后执行次数应该是 2');
  }, 1100);

方案 B:定时器版

  • 特点:第一次触发不会立即执行(需等待延迟)
  function throttleTimer(fn, delay) {
    let timer = null;
    return function (...args) {
      if (!timer) {
        timer = setTimeout(() => {
          timer = null;
          fn.apply(this, args);
        }, delay);
      }
    };
  }

  // 测试用例
  let count = 0;
  function handleClick() {
    count++;
    console.log('执行次数:', count, '时间:', Date.now());
  }

  const throttledClick = throttleTimer(handleClick, 1000);

  // 快速调用5次
  throttledClick();
  throttledClick();
  throttledClick();
  throttledClick();
  throttledClick();

  console.log('立即执行次数应该是 1');

  // 1.1秒后再调用,应该执行第二次
  setTimeout(() => {
    throttledClick();
    console.log('1.1秒后执行次数应该是 2');
  }, 1100);

三、 防抖与节流的本质区别

为了方便记忆,我们可以通过下表进行对比:

特性 防抖 (Debounce) 节流 (Throttle)
核心逻辑 重置计时器,只认最后一次 锁定计时器,在冷却期内忽略触发
执行频率 连续触发时,可能永远不执行(直到停止) 连续触发时,按固定频率执行
比喻 坐电梯:有人进来门就重新开,直到没人进来才走 坐地铁:每隔 10 分钟发一班车,准点出发

四、 进阶:如何选择?

  • 如果你的需求是 “只需要最终结果” (如输入框验证),选 防抖
  • 如果你的需求是 “过程中的平滑反馈” (如滚动加载、地图缩放),选 节流

JS-一文带你彻底搞懂 Promise 并发控制:all, race, any, allSettled

前言

在处理异步任务时,我们经常需要同时发起多个请求。Promise 提供的静态方法能让我们优雅地控制多个并发异步任务。本文将深度对比 allraceanyallSettled 的区别与应用场景。

一、 方法详解与对比

1. Promise.all() —— “全员通过制”

  • 概念:将多个 Promise 实例包装成一个。

  • 状态决定

    • Fulfilled:所有实例都成功。
    • Rejected:只要有一个失败,整体立即失败。
  • 应用场景:多个接口联动,必须全部拿到数据才能渲染页面。

  const p1 = Promise.resolve(1);
  const p2 = Promise.resolve(2);
  const p3 = Promise.resolve(3);

  Promise.all([p1, p2, p3])
    .then((results) => {
      console.log(results); // [1, 2, 3] 顺序与传入一致
    })
    .catch((err) => {
      console.error('其中一个失败了', err);
    });

2. Promise.race() —— “竞速制”

  • 概念:谁跑得快就听谁的。
  • 状态决定:状态取决于第一个改变状态的实例。
  • 应用场景:请求超时控制。
  const p1 = new Promise((resolve) =>
    setTimeout(() => resolve('1秒后成功'), 1000)
  );
  const p2 = new Promise((resolve) =>
    setTimeout(() => resolve('500毫秒后成功'), 500)
  );
  Promise.race([p1, p2]).then(
    (res) => console.log(`测试1结果:${res} (竞速赢家)`, 'success'),
    (err) => console.log(`测试1失败:${err}`, 'error')
  );

3. Promise.any() —— “择优录取制”

  • 概念:只要有一个成功就算成功。

  • 状态决定

    • Fulfilled:只要有一个成功。
    • Rejected全部都失败时才失败(返回 AggregateError)。
  • 应用场景:从多个备用服务器获取相同资源。

  const p1 = Promise.resolve(1);
  const p2 = Promise.reject(2);
  const p3 = Promise.reject(3);
  Promise.any([p1, p2, p3])
    .then((res) => console.log(`有一个成功了:${res}`, 'success'))
    .catch((err) => console.log(`所有都失败了:${err}`, 'error'));

4. Promise.allSettled() —— “结果导向制”

  • 概念:无论成功失败,我全都要。
  • 状态决定:永远是 fulfilled(在所有实例都结束后)。
  • 应用场景:执行多个互不影响的操作,最后统一统计结果。

二、 核心差异对比表

为了 scannability(易读性),我们通过表格直观对比:

方法 成功条件 (Fulfilled) 失败条件 (Rejected) 结果返回值
.all() 全部成功 任意一个失败 成功结果数组(按序)
.race() 任意一个最先成功 任意一个最先失败 第一个改变状态的值
.any() 任意一个成功 全部失败 第一个成功的值
.allSettled() 所有任务结束 从不(状态永远成功) 包含状态和值的对象数组

三、 总结

1. Promise.all 与 Promise.race 的直观区别

  • Promise.all:照顾“跑得最慢”的。必须等最慢的一个完成,且全员合格,才给最终结果。
  • Promise.race:关注“跑得最快”的。最快的那个一旦过线,无论输赢,比赛立刻结束。

2. 进阶使用

在项目中,配合 async/await 使用更加优雅:

const fetchData = async () => {
  try {
    // 强类型约束结果数组
    const [user, orders] = await Promise.all<[UserType, OrderType[]]>([
      getUserInfo(),
      getOrderList()
    ]);
    console.log(user, orders);
  } catch (error) {
    // 处理第一个捕获到的错误
  }
};

JS-手写系列:从零手写 Promise

前言

Promise 是 JavaScript 处理异步编程的基石。虽然我们在日常开发中频繁使用 async/await,但手动实现一个符合 Promise规范的类,不仅能让你在面试中脱颖而出,更能让你深刻理解微任务与链式调用的本质。


一、 Promise 核心设计方案

实现一个标准的 Promise,必须紧扣以下四个核心点:

  1. 状态机机制:存在 PENDING(等待)、FULFILLED(成功)、REJECTED(失败)三种状态,状态转换不可逆。
  2. 立即执行:构造函数中的执行器 executor(resolve, reject) 是同步立即执行的。
  3. 微任务队列:回调函数的执行必须是异步的,通常使用 queueMicrotask 来实现。
  4. 链式调用then 方法必须返回一个新的 Promise,并将前一个 Promise 的输出作为后一个 Promise 的输入。

二、 代码实现

      // 定义Promise的三种状态
      const PENDING = 'pending';
      const FULFILLED = 'fulfilled';
      const REJECTED = 'rejected';

      /**
       * 自定义Promise实现
       * @param {Function} executor 执行器函数,接收resolve和reject参数
       */
      function MyPromise(executor) {
        const self = this; // 保存this指向,避免回调中丢失
        self.status = PENDING; // 初始状态为pending
        self.value = undefined; // 成功的结果值
        self.reason = undefined; // 失败的原因
        self.onFulfilledCallbacks = []; // 存储成功回调
        self.onRejectedCallbacks = []; // 存储失败回调

        // 成功回调函数
        function resolve(value) {
          // 只有pending状态才能改变
          if (self.status === PENDING) {
            self.status = FULFILLED;
            self.value = value;
            // 异步执行所有成功回调,因为then方法是异步的,所以要等executor执行完再执行
            queueMicrotask(() => {
              self.onFulfilledCallbacks.forEach((callback) => {
                callback(self.value);
              });
            });
          }
        }

        // 失败回调函数
        function reject(reason) {
          // 只有pending状态才能改变
          if (self.status === PENDING) {
            self.status = REJECTED;
            self.reason = reason;
            // 异步执行所有失败回调
            setTimeout(() => {
              self.onRejectedCallbacks.forEach((callback) => {
                callback(self.reason);
              });
            });
          }
        }

        try {
          // 立即执行执行器函数
          executor(resolve, reject);
        } catch (error) {
          // 执行器抛出异常时,直接调用reject
          reject(error);
        }
      }

      /** then方法实现
       * @param {Function} onFulfilled 成功回调
       * @param {Function} onRejected 失败回调
       * @returns {MyPromise} 返回新的Promise实现链式调用
       */
      MyPromise.prototype.then = function (onFulfilled, onRejected) {
        const self = this;
        // 处理默认回调(兼容不传回调的情况)
        onFulfilled =
          typeof onFulfilled === 'function' ? onFulfilled : (value) => value;
        onRejected =
          typeof onRejected === 'function'
            ? onRejected
            : (reason) => {
                throw reason;
              };

        // 返回新的Promise实现链式调用,因为后续函数也可以通过then方法来处理
        return new MyPromise((resolve, reject) => {
          // 处理成功状态
          if (self.status === FULFILLED) {
            setTimeout(() => {
              try {
                // 执行成功回调并获取返回值
                const result = onFulfilled(self.value);
                // 根据返回值处理新Promise的状态
                resolvePromise(result, resolve, reject);
              } catch (error) {
                reject(error);
              }
            });
          }


          // 处理失败状态
          if (self.status === REJECTED) {
            setTimeout(() => {
              try {
                // 执行失败回调并获取返回值
                const result = onRejected(self.reason);
                // 根据返回值处理新Promise的状态
                resolvePromise(result, resolve, reject);
              } catch (error) {
                reject(error);
              }
            });
          }

          // 处理等待状态(暂存回调)
          if (self.status === PENDING) {
            self.onFulfilledCallbacks.push(() => {
              try {
                const result = onFulfilled(self.value);
                resolvePromise(result, resolve, reject);
              } catch (error) {
                reject(error);
              }
            });

            self.onRejectedCallbacks.push(() => {
              try {
                const result = onRejected(self.reason);
                resolvePromise(result, resolve, reject);
              } catch (error) {
                reject(error);
              }
            });
          }
        });
      };

      /** catch方法实现(语法糖,等价于then(null, onRejected))
       * @param {Function} onRejected 失败回调
       * @returns {MyPromise}
       */
      MyPromise.prototype.catch = function (onRejected) {
        return this.then(null, onRejected);
      };

      //静态方法resolve - 返回一个已完成的Promise
      MyPromise.resolve = function (value) {
        if (value instanceof MyPromise) {
          return value;
        }
        return new MyPromise((resolve) => {
          resolve(value);
        });
      };

      //静态方法reject - 返回一个已失败的Promise
      MyPromise.reject = function (reason) {
        return new MyPromise((_, reject) => {
          reject(reason);
        });
      };

      // 处理then回调返回值的工具函数
      function resolvePromise(result, resolve, reject) {
        // 如果返回值是当前Promise,抛出循环引用错误
        if (result instanceof MyPromise) {
          result.then(resolve, reject);
        } else {
          // 普通值直接resolve
          resolve(result);
        }
      }
    
      // 测试用例
      console.log('--- 测试用例开始 ---');

      // 1. 基本 resolve
      const p1 = new MyPromise((resolve) => {
        setTimeout(() => resolve('成功1'), 100);
      })
      p1.then(res => {
        console.log('Test 1: 成功1', res); // 成功1
      })
      p1.catch(err => {
        console.log('Test 1: 失败1', err);
      })

      // 2. 基本 reject + catch
      // const p2 = new MyPromise((_, reject) => {
      //   setTimeout(() => reject('失败1'), 100);
      // })
      // p2.then(success => {
      //   console.log('Test 2: 成功1', success); // 失败1
      // }, (err) => {
      //   console.log('Test 2: 失败1', err); // 失败1
      // })
      // p2.catch(err => {
      //   console.log('Test 2: 失败2', err); // 失败1
      // })

      // 3. executor 抛出异常
      // new MyPromise(() => {
      //   throw new Error('executor error');
      // }).catch(err => {
      //   console.log('Test 4: 异常', err.message); // executor error
      // });

      // 4. Promise 链式嵌套
      // const p4 = new MyPromise((resolve) => resolve(1))
      //   .then((v) => v + 1)
      //   .then((v) => MyPromise.resolve(v * 2))
      //   .then((v) => {
      //     console.log('Test 5:', v); // 4
      //   });

三、 深度细节解析

1. 为什么使用 queueMicrotask

根据 Promise规范,onFulfilledonRejected 必须在执行上下文栈仅包含平台代码时执行。这意味着回调必须是异步的。使用微任务而不是宏任务(如 setTimeout),是为了保证在当前任务循环结束前尽可能快地执行回调。

2. 状态不可逆性

resolvereject 函数中,我们首先判断 state === 'pending'。一旦状态变为 fulfilledrejected,后续任何调用都将被忽略,这保证了 Promise 的稳定性。


四、 总结

  1. 同步执行 executor
  2. 异步收集 回调函数。
  3. 递归解析 返回值,实现链式调用。

JS-手写系列:树与数组相互转换

前言

在前端业务中,后端返回的扁平化数组(Array)往往需要转换为树形结构(Tree)来适配 UI 组件(如 Element UI 的 Tree 或 Cascader)。掌握多种转换思路及性能差异,是进阶高级前端的必备技能。

一、 核心概念:结构对比

  • 数组结构:每一项通过 parentId 指向父级。

      const nodes = [
        { id: 3, name: '节点C', parentId: 1 },
        { id: 6, name: '节点F', parentId: 3 },
        { id: 0, name: 'root', parentId: null },
        { id: 1, name: '节点A', parentId: 0 },
        { id: 8, name: '节点H', parentId: 4 },
        { id: 4, name: '节点D', parentId: 1 },
        { id: 2, name: '节点B', parentId: 0 },
        { id: 5, name: '节点E', parentId: 2 },
        { id: 7, name: '节点G', parentId: 2 },
        { id: 9, name: '节点I', parentId: 5 },
      ];
    
  • 树形结构:父级通过 children 数组包裹子级。

      let tree = [
        {
          id: 1,
          name: 'text1',
          parentId: 1,
          children: [
            {
              id: 2,
              name: 'text2',
              parentId: 1,
              children: [
                {
                  id: 4,
                  name: 'text4',
                  parentId: 2,
                },
              ],
            },
            {
              id: 3,
              name: 'text3',
              parentId: 1,
            },
          ],
        },
      ];
    

二、 数组转树

1. 递归思路

原理

  1. 首先需要传递给函数两个参数:数组、当前的父节点id
  2. 设置一个结果数组res,遍历数组,先找到子元素的父节点id与父节点id一致的子项
  3. 将这个子项的id作为父节点id传入函数,继续遍历
  4. 将遍历的结果作为children返回,并给当前项添加children
  5. 将这个当前项,插入到res里面,并返回

注意:如果不想影响原数组,需要先深拷贝一下数组。const cloneArr = JSON.parse(JSON.stringify (arr))

  const nodes = [
    { id: 3, name: '节点C', parentId: 1 },
    { id: 6, name: '节点F', parentId: 3 },
    { id: 0, name: 'root', parentId: null },
    { id: 1, name: '节点A', parentId: 0 },
    { id: 8, name: '节点H', parentId: 4 },
    { id: 4, name: '节点D', parentId: 1 },
    { id: 2, name: '节点B', parentId: 0 },
    { id: 5, name: '节点E', parentId: 2 },
    { id: 7, name: '节点G', parentId: 2 },
    { id: 9, name: '节点I', parentId: 5 },
  ];
  //递归写法
  const arrToTree1 = (arr, id) => {
    const res = [];
    arr.forEach((item) => {
      if (item.parentId === id) {
        const children = arrToTree1(arr, item.id);
        //如果希望每个元素都有children属性,可以直接赋值
        if (children.length !== 0) {
          item.children = children;
        }
        res.push(item);
      }
    });
    return res;
  };
  console.log(arrToTree1(nodes, null));

2. 非递归思路

原理:利用 filter 进行二次筛选。虽然写法简洁,但在大数据量下性能较差(O(n2)O(n^2))。

  1. 函数只需要接受一个参数,也就是需要转换的数组arr
  2. 第一层过滤数组,直接返回一个parentId为根id的元素
  3. 但是在返回之间,需要再根据当前id过滤里面的每一项(过滤规则为如果子项的paentId为当前的id,则在当前项的children插入这个子项)
  const arrToTree2 = (arr) => {
    return arr.filter((father) => {
      const childrenArr = arr.filter((children) => {
        return children.parentId === father.id;
      });
      //如果希望每个元素都有children属性,可以直接赋值
      if (childrenArr.length !== 0) {
        father.children = childrenArr;
      }
      return father.parentId === null;
    });
  };
  console.log(arrToTree2(nodes));

3. Map 对象方案(O(n)O(n) 时间复杂度)

原理:利用对象的引用性质。先将数组转为 Map,再遍历一次即可完成。这是在大数据量下的首选方案。

  const arrToTree3 = (arr) => {
    const map = {};
    const res = [];

    // 1. 建立映射表
    arr.forEach((item) => {
      map[item.id] = { ...item, children: [] };
    });

    // 2. 组装树结构
    arr.forEach((item) => {
      const node = map[item.id];
      if (item.parentId === null) {
        res.push(node);
      } else {
        if (map[item.parentId]) {
          map[item.parentId].children.push(node);
        }
      }
    });
    return res;
  };
  console.log(arrToTree3(nodes));

三、 树转数组

1. 递归遍历思路

原理:定义一个结果数组,递归遍历树的每一层,将节点信息(排除 children)推入数组。

  1. 首先定义一个结果数组res,遍历传入的树
  2. 直接将当前项的id、name、parentId包装在一个新对象里插入
  3. 判断是否有children属性,如果有则遍历children属性每一项,继续执行2、3步骤
  let tree = [
    {
      id: 1,
      name: 'text1',
      parentId: 1,
      children: [
        {
          id: 2,
          name: 'text2',
          parentId: 1,
          children: [
            {
              id: 4,
              name: 'text4',
              parentId: 2,
            },
          ],
        },
        {
          id: 3,
          name: 'text3',
          parentId: 1,
        },
      ],
    },
  ];
  const treeToArr = (tree) => {
    const res = [];
    tree.forEach((item) => {
      const loop = (data) => {
        res.push({
          id: data.id,
          name: data.name,
          parseId: data.parentId,
        });
        if (data.children) {
          data.children.forEach((itemChild) => {
            loop(itemChild);
          });
        }
      };
      loop(item);
    });
    return res;
  };
  console.log(treeToArr(tree));

四、 注意事项:深拷贝的必要性

在处理这些转换时,由于 JS 的对象是引用类型,直接修改 item.children 会改变原始数组的内容。

  • 快捷方案const cloneArr = JSON.parse(JSON.stringify(arr))
  • 避坑点:如果数组项中包含 Date 对象、RegExpFunctionJSON.parse 会导致数据失真,此时应使用其他深拷贝方案。

JS-手写系列:call、apply、bind

前言

在 JavaScript 中,this 的指向总是让人捉摸不透。callapplybind 作为改变 this 指向的三大杀手锏,其底层实现原理是面试中的高频考点。本文将带你通过手写实现,彻底搞懂它们背后的逻辑。

一、 手写 call

1. 核心思路

利用“对象调用方法时,方法内部 this 指向该对象”这一隐式绑定规则。

  • 将函数设为目标对象的一个属性。
  • 执行该函数。
  • 删除该临时属性,返回结果。

2. 实现

 Function.prototype.myCall = function (target, ...args) {
    // 1. 处理 target 为空的情况,默认为 window
    if (target === undefined || target === null) {
      target = window;
    }
    // 2. 创建唯一键,避免覆盖目标对象原有属性
    const fnKey = Symbol('fn');
    // 3. 将当前函数(this)指向目标对象的属性
    target[fnKey] = this;
    // 4. 执行函数并展开参数
    const result = target[fnKey](...args);
    // 5. 善后处理:删除临时属性
    delete target[fnKey];

    return result;
  };
  const obj = {
    age: 18,
    name: 'a',
    getName: function (job, hobby) {
      console.log(this.name, job, hobby);
    },
  };
  obj.getName.call(); // undefined undefined
  obj.getName.call({ name: 'b' }, 1, 2, 3); // b 1,2

  obj.getName.myCall(); // undefined undefined
  obj.getName.myCall({ name: 'b' }, 1, 2, 3); // b,1,2
};

二、 手写 apply

思路与call一致,都是利用“对象调用方法时,方法内部 this 指向该对象”这一隐式绑定规则

1. 实现

  //唯一区别:参数处理方式,call需要使用...展开
  Function.prototype.myApply = function (target, args) {
    // 1. 处理 target 为空的情况,默认为 window
    if (target === undefined || target === null) {
      target = window;
    }
    // 2. 创建唯一键,避免覆盖目标对象原有属性
    const fnKey = Symbol('fn');
    // 3. 将当前函数(this)指向目标对象的属性
    target[fnKey] = this;
    // 4. 执行函数并展开参数
    const result = target[fnKey](...(args || []));
    // 5. 善后处理:删除临时属性
    delete target[fnKey];

    return result;
  };
  const obj = {
    age: 18,
    name: 'a',
    getName: function (job, hobby) {
      console.log(this.name, job, hobby);
    },
  };
  obj.getName.apply(); // undefined undefined
  obj.getName.apply({ name: 'b' }, [1, 2, 3]); // b 1,2

  obj.getName.myApply(); // undefined undefined
  obj.getName.myApply({ name: 'b' }, [1, 2, 3]); // b,1,2

二、 手写 bind

bind 的实现比前两者复杂,因为它涉及两个核心特性:闭包返回函数支持 new 实例化。当 bind 返回的函数被用作 new 构造函数时:

  • this 绑定失效:生成的实例 this 应该指向 new 创建的对象,而非 bind 绑定的对象。
  • 原型链继承:实例需要能够访问到原函数原型(prototype)上的属性和方法。

2. 实现

  Function.prototype.myBind = function (fn, ...args1) {
    const self = this; // 保存原函数
    const bound = function (...args2) {
      // 如果 this 是 bound 的实例,说明是 new 调用,此时 fn 应该失效
      return self.apply(this instanceof bound ? this : fn, [
        ...args1,
        ...args2,
      ]);
    };
    // 修改原型链,使实例能继承原函数原型, 使用 Object.create 避免直接修改导致相互影响
    bound.prototype = Object.create(self.prototype);
    bound.prototype.constructor = self;
    return bound;
  };

  const obj = {
    age: 18,
    name: 'a',
    getName: function (job, hobby) {
      console.log(this.name, job, hobby);
    },
  };

  const boundGetName1 = obj.getName.bind({ name: 'b' }, 7, 8);
  const boundGetName2 = obj.getName.myBind({ name: 'b' }, 7, 8);
  boundGetName1(); // b 7 8
  boundGetName2(); // b 7 8

  let newFunc1 = obj.getName.bind({ name: 'aa' }, 7, 8);
  let newFunc2 = obj.getName.myBind({ name: 'aa' }, 7, 8);
  newFunc1(); // aa 7 8
  newFunc2(); // aa 7 8

三、 总结与核心差异

方法 参数传递 返回值 核心原理
call 参数列表 (obj, a, b) 函数执行结果 临时属性挂载(隐式绑定)
apply 数组/类数组 (obj, [a, b]) 函数执行结果 临时属性挂载(隐式绑定)
bind 参数列表 (obj, a) 返回新函数 闭包 + apply

JS-手写系列:new操作符

前言

在 JavaScript 中,new 关键字就像是一个“工厂加工器”。虽然它看起来只是简单地创建了一个实例,但其背后涉及到了原型链接、上下文绑定以及返回值的特殊处理。掌握 new 的实现原理,是通往 JS 高级开发者的必经之路。

一、 new 操作符的 4 个核心步骤

当我们执行 new Constructor() 时,JavaScript 引擎在后台完成了以下四件事:

  1. 开辟空间:创建一个全新的空对象。
  2. 原型链接:将该对象的隐式原型(__proto__)指向构造函数的显式原型(prototype)。
  3. 绑定 this:执行构造函数,并将其内部的 this 绑定到这个新对象上。
  4. 返回结果:根据构造函数的返回值类型,决定最终返回的对象。

二、 代码实现

在实现中,我们不仅要处理常规逻辑,还要兼容构造函数可能返回引用类型的情况。

  function myNew(Constructor, ...args) {
    // 1. 创建一个空对象,并将其原型指向构造函数的 prototype
      const obj = {};
      obj.__proto__ = Constructor.prototype;
    // 2. 执行构造函数,并将 this 绑定到新创建的对象上
    const result = Constructor.apply(obj, args);

    // 3. 处理返回值逻辑:如果构造函数显式返回了一个对象或函数,则返回该结果; 否则,返回我们创建的新对象 obj
    const isObject = typeof result === 'object' && result !== null;
    const isFunction = typeof result === 'function';

    return (isObject || isFunction) ? result : obj;
  }

  // 测试用例
  function Person(name, age) {
    this.name = name;
    this.age = age;
  }

  const per1 = new (Person)('ouyange', 23);
  const per2 = myNew(Person, 'ouyange', 23);

  console.log('原生 new 结果:', per1);
  console.log('手写 myNew 结果:', per2);

三、 细节解析

1. 构造函数返回值的坑

  • 如果构造函数 return 123(原始类型),new 会忽略它,依然返回实例对象。

  • 如果构造函数 return { a: 1 }(对象类型),new 会丢弃原本生成的实例,转而返回这个对象。

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 框架或框架特性。但这并不代表事情没有进展。

从递归爆炸到闭包优化:彻底搞懂斐波那契数列的性能演进

在前端算法面试中,斐波那契数列(Fibonacci Sequence)不仅仅是一道考察递归逻辑的数学题,更是一块考察候选人对时间复杂度记忆化(Memoization)以及JavaScript 闭包机制理解深度的试金石。

本文将基于一段代码的三个演进版本,带你深入理解如何将一个 

O(2n) ->2的n次方

 的“灾难级”代码优化为 

O(n)

 的生产级代码。

一、 数学定义与暴力递归的陷阱

斐波那契数列的定义非常简洁:从 0 和 1 开始,后续每一项都等于前两项之和。
即:

f(n)=f(n−1)+f(n−2)f(n)=f(n−1)+f(n−2)

将这个公式直接翻译成代码,就是我们最常见的递归写法:

JavaScript

// 版本 1:暴力递归
function fib(n) {
    // 退出条件
    if(n <= 1){
        return n;
    }
    // 递归公式
    return fib(n-1) + fib(n-2);
}

为什么这种写法在面试中只能得 50 分?

虽然代码逻辑正确,但它存在严重的性能问题。当你执行 fib(10) 时,计算很快;但一旦尝试 fib(50),浏览器可能会直接卡死。

原因是大量的重复计算

为了计算 fib(5),程序需要计算 fib(4) 和 fib(3)。
而在计算 fib(4) 时,又需要计算 fib(3) 和 fib(2)。
注意到了吗?fib(3) 被重复计算了多次。随着 n 的增加,这种重复计算呈指数级爆炸,时间复杂度为 

O(2n) ->2的n次方

image.png

二、 核心优化思想:用空间换时间

既然瓶颈在于“重复计算”,解决思路就是:凡是算过的,都记下来。下次再遇到相同的参数,直接读取结果,不再重新递归。这就是“记忆化(Memoization)”。

优化第一步:引入全局缓存

我们可以引入一个对象作为缓存容器(HashMap):

JavaScript

// 版本 2:外部缓存
const cache = {}; // 用空间换时间

function fib(n) {
    // 1. 先查缓存
    if(n in cache){
        return cache[n];
    }
    // 2. 边界条件
    if(n <= 1){
        cache[n] = n;
        return n;
    }
    // 3. 计算并写入缓存
    const result = fib(n-1) + fib(n-2);
    cache[n] = result;
    return result;
}

通过引入 cache,每个数字只会被计算一次。时间复杂度从指数级 

O(2n) ->2的n次方

 降维到了线性 

O(n)

。这是一个巨大的性能飞跃。

image.png

三、 进阶实现:闭包与 IIFE 的完美结合

版本 2 虽然解决了性能问题,但在工程上有一个致命缺陷:cache 变量定义在函数外部,是一个全局变量(或模块级变量)。这意味着任何外部代码都可以修改它,导致程序不安全,且污染了作用域。

在 JavaScript 中,完美的解决方案是结合 IIFE(立即执行函数表达式)  和 闭包(Closure)

JavaScript

// 版本 3:闭包封装
const fib = (function() {
    // 闭包内的私有变量,外部无法访问
    const cache = {}; 
    
    // 返回实际执行的函数
    return function(n) {
        if(n in cache){
            return cache[n];
        }
        if(n <= 1){
            cache[n] = n;
            return n;
        }
        // 注意:这里的 fib 指向的是外部接收 IIFE 返回值的那个变量
        cache[n] = fib(n-1) + fib(n-2);
        return cache[n];
    }
})()

代码解析

  1. IIFE (function(){...})() :函数定义后立即执行,创建了一个独立的作用域。
  2. 闭包:IIFE 返回的内部函数引用了外层作用域的 cache 变量。即便 IIFE 执行完毕,cache 依然驻留在内存中,不会被销毁,也不会被外部访问。
  3. 数据持久化:当你多次调用 fib(10)、fib(20) 时,它们共享同一个 cache,计算过的结果可以跨函数调用被复用。

这种写法不仅实现了性能优化,还体现了优秀的代码封装思想,是面试中的高分回答。

image.png

四、 面试总结

当面试官让你手写斐波那契数列时,建议按照以下逻辑展开:

  1. 先写基本解法:快速写出递归版本,并主动指出其 

    O(2n) ->2的n次方
    

     的复杂度问题和爆栈风险。

  2. 提出优化方案:阐述“用空间换时间”的记忆化思想。

  3. 展示语言特性:使用 IIFE + 闭包 的方式实现记忆化函数。这能展示你对 JavaScript 作用域链和闭包机制的深刻理解。

  4. 扩展思维:如果面试官进一步追问,可以提到通用记忆化函数(Memoize Helper)的编写,或者提到使用迭代(循环)方式来进一步避免递归深度的限制。

掌握这段代码的演进过程,不仅是为了解决一道算法题,更是为了掌握 JavaScript 函数式编程中“状态保持”的核心模式。

CSS 绘制几何图形原理与实战指南

在前端面试中,CSS 绘制图形(尤其是三角形)是一个考察基础知识深度的经典问题。这个问题看似简单,实则可以考察开发者对 盒模型(Box Model)  的底层理解,以及对 现代 CSS 属性(如 clip-path, transform)  的掌握程度。

本文将从原理、实战代码及面试回答策略三个维度进行解析。

一、 经典方案:利用 Border(边框)挤压

这是面试中最常被问到的方案,也是兼容性最好的方案(支持 IE6+)。核心在于理解 CSS 边框在盒模型中的渲染机制。

原理分析

在标准盒模型中,边框是围绕在内容区(Content)之外的。当一个元素的 width 和 height 都设置为 0 时,内容区域消失,元素的大小完全由边框决定。

此时,如果设置了较粗的边框,四条边框会在中心汇聚。由于边框连接处需要平滑过渡,浏览器在渲染时,会以 45度角(正方形时)或根据宽高比计算的斜角 对边框交界处进行斜切处理。

如果不设置颜色,边框看起来是一个矩形;但如果给四条边框设置不同的颜色,视觉上会呈现出四个三角形拼合在一起的效果。

代码实战

CSS

.triangle-border {
    width: 0;
    height: 0;
    /* 核心步骤1:设置足够宽的边框,并将其颜色设为透明 */
    border: 50px solid transparent; 
    /* 核心步骤2:单独指定某一个方向的边框颜色 */
    /* 想要箭头朝上,就给下边框上色 */
    border-bottom-color: #007bff; 
}

面试考察点

  1. 为什么宽高要设为 0?
    是为了消除中间的内容矩形区域,让四条边框在中心直接接触,从而利用边框交界处的斜切特性形成尖角。

  2. 如何调整三角形形状?
    通过调节不同方向 border-width 的数值。

    • 等腰/等边三角形:保持左右边框宽度一致。
    • 直角三角形:将某一条相邻边框的宽度设为 0(例如 border-top: 0),利用剩余边框的挤压形成直角。

image.png

二、 现代方案:利用 Clip-path(裁剪路径)

随着浏览器技术的发展,clip-path 成为了绘制不规则图形的最优解。与 Border 法利用“副作用”不同,Clip-path 是“声明式”的绘图方式。

原理分析

clip-path 属性会在元素内部创建一个裁剪区域:区域内的内容可见,区域外的内容被隐藏。

使用 polygon() 函数可以通过定义一系列坐标点 (x y) 来绘制多边形。坐标系以元素的左上角为原点 (0, 0),右下角为 (100%, 100%)。

代码实战

CSS

.triangle-clip {
    /* 与 Border 法不同,这里需要元素有实际宽高 */
    width: 100px;
    height: 100px;
    background-color: #007bff;
    /* 定义三个顶点:顶部中间、右下、左下 */
    clip-path: polygon(50% 0, 100% 100%, 0 100%);
}

优缺点对比(面试加分项)

  • Border 法:兼容性极好,但在处理背景图片、渐变色或阴影时非常困难,本质上是 Hack 手段。
  • Clip-path 法:语义清晰,支持背景图片裁剪、支持渐变色,且不影响盒模型实际占据的布局空间。

image.png

三、 实用变体:利用 Transform 与 Border-radius

除了基础三角形,面试中常涉及箭头、扇形等图形,这些通常结合 transform 和 border-radius 实现。

1. 空心箭头 (Chevron)

常用于下拉菜单或翻页按钮。
原理:创建一个正方形,只保留相邻的两条边框(如上边和右边),然后旋转 45 度。

CSS

.arrow {
    width: 10px;
    height: 10px;
    border: 2px solid #333;
    /* 隐藏两条边 */
    border-bottom: none;
    border-left: none;
    /* 旋转 */
    transform: rotate(45deg);
}

2. 扇形 (Sector)

原理:利用 border-radius 可以单独控制每个角半径的特性。将正方形的一个角设为圆形(半径等于边长),其余角为 0。

CSS

.sector {
    width: 50px;
    height: 50px;
    background-color: red;
    /* 顺序:左上 右上 右下 左下 */
    /* 将左上角设为半径,形成 1/4 圆 */
    border-radius: 50px 0 0 0; 
}

image.png

总结:面试回答策略

如果在面试中被问到“如何用 CSS 画三角形”,建议按以下逻辑条理清晰地回答:

  1. 首选方案(Border 法) :首先演示 Border 法,因为这是最基础的 CSS 原理。重点解释“宽高为 0”和“透明边框”如何利用盒模型渲染机制形成三角形。
  2. 进阶方案(Clip-path 法) :随后补充说明,如果场景需要显示背景图片或渐变色,clip-path 是更现代、更规范的解决方案,这能体现你对新特性的关注。
  3. 特殊场景:如果是画空心箭头,指出使用 transform: rotate 配合单侧边框是最高效的方法。
  4. 避坑指南:提及尽量避免使用 linear-gradient 拼凑三角形,因为其计算复杂且容易产生锯齿,维护成本高。
❌