阅读视图

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

告别 `any`:TypeScript 中 `try...catch` 的最佳实践

在 TypeScript 项目中,你是否经常为了通过编译而写出这种代码?

try {
  // 某些逻辑
} catch (err: any) { // ❌ 违背了 TS 类型安全的初衷
  console.log(err.message); 
}

随着 TS 配置趋于严格,catch(err: any) 往往会触发 ESLint 警告或编译错误。本文将介绍处理 catch 块中错误对象的几种最佳实践

1. 理解 unknown 的必然性

在现代 TypeScript(4.0+)中,推荐将捕获到的错误声明为 unknown。这是因为在运行时刻,你无法保证捕获到的一定是 Error 实例。

try {
  throw "意外的错误字符串"; // 这里的错误甚至不是一个对象
} catch (err: unknown) {
  // ❌ 报错:'err' is of type 'unknown'
  // console.log(err.message); 
}

2. 方案一:类型守卫(Type Guards)—— 最稳健的方法

这是官方推荐的做法。通过显式的 instanceof 检查,TS 会在代码块内自动收窄(Narrowing)类型。

try {
  await fetchData();
} catch (err: unknown) {
  if (err instanceof Error) {
    // ✅ TS 现在知道 err 是 Error 类型
    console.error(err.message);
    console.error(err.stack);
  } else {
    // 处理非标准错误(如 throw "string")
    console.error("发生了未知类型的错误", err);
  }
}

3. 方案二:自定义工具函数(封装大法)

如果你觉得到处写 if (err instanceof Error) 太麻烦,可以封装一个工具函数。这是目前大型项目中最流行的做法。

编写工具函数

function toError(err: unknown): Error {
  if (err instanceof Error) return err;
  return new Error(String(err));
}

业务中使用

try {
  doSomething();
} catch (err: unknown) {
  const error = toError(err);
  console.log(error.message); // ✅ 永远安全
}

4. 方案三:函数式处理(类似 Rust/Go)

如果你讨厌深层嵌套的 try...catch,可以使用封装好的包装器,将错误作为返回值返回。

async function safeRun<T>(promise: Promise<T>): Promise<[Error | null, T | null]> {
  try {
    const data = await promise;
    return [null, data];
  } catch (err: unknown) {
    return [toError(err), null];
  }
}

// 使用:
const [err, data] = await safeRun(fetchUser(id));
if (err) {
  handle(err);
} else {
  render(data);
}

5. 进阶:处理 Axios 等库的特定错误

如果你在使用 Axios,可以使用它内置的类型守卫:

import axios from 'axios';

try {
  await axios.get('/api/user');
} catch (err: unknown) {
  if (axios.isAxiosError(err)) {
    // 这里可以访问 err.response, err.status 等特有属性
    console.log(err.response?.data);
  }
}

总结:该选哪一个?

场景 推荐做法
临时处理/小型脚本 if (err instanceof Error)
标准大型项目 封装 toError() 工具函数,确保类型安全
追求代码扁平化 采用 safeRun 包装器返回 [err, data]
第三方库请求 优先使用库提供的 isError 判断函数

核心原则: 永远不要相信 catch 捕获到的内容,永远在访问属性前进行类型检查。这不仅是过编译的要求,更是写出健壮代码的基石。

vite 是如何加载解析 vite.config.js 配置文件的?

当我们在终端运行 vite dev,Vite 启动开发服务器的首个关键步骤就是解析配置。本文将深入剖析 Vite 加载配置文件的三种模式。

loadConfigFromFile 的完整流程

loadConfigFromFile 是配置文件加载的核心函数,其完整流程如下:

  1. 确定配置文件路径(自动查找或使用 --config 指定的路径)。
  2. 根据文件后缀和 package.json 中的 type 字段判断模块格式(是否为 ESM)。
  3. 根据 configLoader加载器配置来加载配置文件和转换代码。
    • bundle模式,调用 bundleConfigFile 使用 rolldown 打包配置文件,获取转换后的代码和依赖列表。调用 loadConfigFromBundledFile 将打包后的代码转成配置对象。
    • runner模式,使用 Vite 的 ModuleRunner 动态转换任何文件。它的核心机制是利用 RunnableDevEnvironment 提供的 runner.import 函数,在独立的执行环境中加载并运行模块
    • native模式,利用原生动态引入。
  4. 如果用户导出的是函数,则调用该函数传入 configEnv(包含 commandmode 等参数),获取最终配置对象。
  5. 返回配置对象、配置文件路径以及依赖列表 dependencies
  let { configFile } = config
  if (configFile !== false) {
    // 从文件加载配置
    const loadResult = await loadConfigFromFile(
      configEnv,
      configFile,
      config.root,
      config.logLevel,
      config.customLogger,
      config.configLoader,
    )
    if (loadResult) {
      config = mergeConfig(loadResult.config, config)
      configFile = loadResult.path
      configFileDependencies = loadResult.dependencies
    }
  }

image.png

如果在执行 vite dev 时没有使用 --config 参数指定配置文件,Vite 将按照以下顺序自动查找并加载配置文件。

const DEFAULT_CONFIG_FILES: string[] = [
  'vite.config.js',
  'vite.config.mjs',
  'vite.config.ts',
  'vite.config.cjs',
  'vite.config.mts',
  'vite.config.cts',
]

Vite 提供了三种配置加载机制

当配置文件被定位后,Vite 如何读取并执行它的内容?这取决于 configLoader 配置选项。Vite 提供了三种机制来加载配置文件,默认使用 bundle 模式。

const resolver =
  configLoader === 'bundle'
    ? bundleAndLoadConfigFile // 处理配置文件的预构建
    : configLoader === 'runner'
      ? runnerImportConfigFile // 处理配置文件的运行时导入
      : nativeImportConfigFile // 处理配置文件的原生导入

bundle (默认)

使用打包工具(Rolldown)将配置文件及其依赖打包成一个临时文件,再加载执行。

function bundleAndLoadConfigFile(resolvedPath: string) {
  // 检查是否为 ESM 模块
  const isESM =
    // 在 Deno 环境中运行
    typeof process.versions.deno === 'string' || isFilePathESM(resolvedPath)

  // 配置文件打包
  // 打包过程会处理配置文件的依赖,将其转换为可执行的代码
  const bundled = await bundleConfigFile(resolvedPath, isESM)
  // 配置加载
  const userConfig = await loadConfigFromBundledFile(
    resolvedPath,
    bundled.code,
    isESM,
  )

  return {
    // 加载的用户配置
    configExport: userConfig,
    // 配置文件的依赖项
    dependencies: bundled.dependencies,
  }
}

image.png

image.png

image.png

image.png

image.png

image.png

bundle.code 字符串

import "node:module";
import { defineConfig } from "file:///Users/xxxxx/Documents/code/cloudcode/vue3-vite-cube/node_modules/vite/dist/node/index.js";
import vue from "file:///Users/xxxxx/Documents/code/cloudcode/vue3-vite-cube/node_modules/@vitejs/plugin-vue/dist/index.mjs";
import vueJsx from "file:///Users/xxxxx/Documents/code/cloudcode/vue3-vite-cube/node_modules/@vitejs/plugin-vue-jsx/dist/index.mjs";
import VueRouter from "file:///Users/xxxx/Documents/code/cloudcode/vue3-vite-cube/node_modules/vue-router/dist/unplugin/vite.mjs";
Object.create;
Object.defineProperty;
Object.getOwnPropertyDescriptor;
Object.getOwnPropertyNames;
Object.getPrototypeOf;
Object.prototype.hasOwnProperty;
var vite_config_default = defineConfig({
plugins: [
VueRouter({
routesFolder: "src/pages",
extensions: [".vue"],
dts: "src/typed-router.d.ts",
importMode: "async",
root: process.cwd(),
watch: true
}),
vue(),
vueJsx()
],
resolve: { alias: { "@": "/src" } },
css: { preprocessorOptions: { less: {
additionalData: \`@import "@/styles/variables.less";\`,
javascriptEnabled: true
} } },
mode: "development"
});
//#endregion
export { vite_config_default as default };

//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidml0ZS5jb25maWcuanMiLCJuYW1lcyI6W10sInNvdXJjZXMiOlsiL1VzZXJzL2hhaXlhbi9Eb2N1bWVudHMvY29kZS9jbG91ZGNvZGUvdnVlMy12aXRlLWN1YmUvdml0ZS5jb25maWcudHMiXSwic291cmNlc0NvbnRlbnQiOlsiLy8gaW1wb3J0IHsgZmlsZVVSTFRvUGF0aCwgVVJMIH0gZnJvbSAnbm9kZTp1cmwnXG5pbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tICd2aXRlJ1xuaW1wb3J0IHZ1ZSBmcm9tICdAdml0ZWpzL3BsdWdpbi12dWUnXG5pbXBvcnQgdnVlSnN4IGZyb20gJ0B2aXRlanMvcGx1Z2luLXZ1ZS1qc3gnXG5pbXBvcnQgdnVlRGV2VG9vbHMgZnJvbSAndml0ZS1wbHVnaW4tdnVlLWRldnRvb2xzJ1xuaW1wb3J0IFZ1ZVJvdXRlciBmcm9tICd2dWUtcm91dGVyL3ZpdGUnXG5cblxuLy8gaHR0cHM6Ly92aXRlLmRldi9jb25maWcvXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoe1xuICBwbHVnaW5zOiBbXG4gICAgLy8g5b+F6aG76KaB5ZyoIHZ1ZSDmj5Lku7bkuYvliY1cbiAgICBWdWVSb3V0ZXIoe1xuICAgICAgcm91dGVzRm9sZGVyOiAnc3JjL3BhZ2VzJywgLy8g6buY6K6kIHBhZ2VzXG4gICAgICBleHRlbnNpb25zOiBbJy52dWUnXSwgLy8g5Yy56YWN5paH5Lu25ZCO57yAXG4gICAgICBkdHM6ICdzcmMvdHlwZWQtcm91dGVyLmQudHMnLCAvLyDnlJ/miJDnsbvlnovmlofku7ZcbiAgICAgIC8vIOWHuueOsCBSYW5nZUVycm9yOiBNYXhpbXVtIGNhbGwgc3RhY2sgc2l6ZSBleGNlZWRlZFxuICAgICAgLy8gZ2V0Um91dGVOYW1lOiAocm91dGUpID0+IHtcbiAgICAgIC8vICAgY29uc29sZS5sb2coJ2dldFJvdXRlTmFtZScscm91dGUpXG4gICAgICAvLyAgIHJldHVybiByb3V0ZS5uYW1lIHx8IHJvdXRlLnBhdGhcbiAgICAgIC8vIH0sXG5cbiAgICAgICAvLyDmt7vliqDosIPor5XpgInpoblcbiAgICAgIC8vIGxvZ3M6IHRydWUsXG5cbiAgICAgIC8vIHJvdXRlQmxvY2tMYW5nOiAnanNvbjUnLCAvLyDot6/nlLHlnZfor63oqIDvvIzpu5jorqQganNvblxuICAgICAgaW1wb3J0TW9kZTogJ2FzeW5jJyxcbiAgICAgIHJvb3Q6IHByb2Nlc3MuY3dkKCksXG5cbiAgICAgIC8vIOWcqOmFjee9ruaWh+S7tuWGmeWFpeWJje+8jOaJi+WKqOS/ruaUuei3r+eUsemFjee9ru+8iOWmgua3u+WKoOWFqOWxgOi3r+eUseWuiOWNq+OAgeiwg+aVtOi3r+eUseWFg+S/oeaBr+OAgei/h+a7pOi3r+eUseetie+8iVxuICAgICAgLy8gYmVmb3JlV3JpdGVGaWxlczogKGVkaXRlZFJvdXRlcykgPT4ge1xuICAgICAgLy8gICBjb25zb2xlLmxvZygnYmVmb3JlV3JpdGVGaWxlcycsIGVkaXRlZFJvdXRlcylcbiAgICAgIC8vIH0sXG4gICAgICB3YXRjaDogdHJ1ZSwgLy8g5byA5ZCv6Lev55Sx5Z2X5paH5Lu255uR5ZCsXG4gICAgICAvLyDlvIDlkK/lrp7pqozmgKflip/og71cbiAgICAgIC8vIGV4cGVyaW1lbnRhbDoge1xuICAgICAgIFxuICAgICAgLy8gfSxcbiAgICB9KSxcbiAgICB2dWUoKSxcbiAgICB2dWVKc3goKSxcbiAgICAvLyB2dWVEZXZUb29scygpLFxuICBdLFxuICByZXNvbHZlOiB7XG4gICAgLy8gYWxpYXM6IHtcbiAgICAvLyAgICdAJzogZmlsZVVSTFRvUGF0aChuZXcgVVJMKCcuL3NyYycsIGltcG9ydC5tZXRhLnVybCkpXG4gICAgLy8gfSxcbiAgICBhbGlhczoge1xuICAgICAgJ0AnOiAnL3NyYycsXG4gICAgfSxcbiAgICAvLyB0c2NvbmZpZ1BhdGhzOiB0cnVlLCAgLy8g6Ieq5Yqo6K+75Y+WIHRzY29uZmlnIHBhdGhzXG4gIH0sXG4gIGNzczoge1xuICAgIHByZXByb2Nlc3Nvck9wdGlvbnM6IHtcbiAgICAgIGxlc3M6IHtcbiAgICAgICAgYWRkaXRpb25hbERhdGE6IGBAaW1wb3J0IFwiQC9zdHlsZXMvdmFyaWFibGVzLmxlc3NcIjtgLFxuICAgICAgICBqYXZhc2NyaXB0RW5hYmxlZDogdHJ1ZVxuICAgICAgfVxuICAgIH1cbiAgfSxcbiAgbW9kZTogJ2RldmVsb3BtZW50JyxcbiAgLy8gc2VydmVyOiB7XG4gIC8vICAgd3M6IGZhbHNlLFxuICAvLyB9LFxuICAvLyBvcHRpbWl6ZURlcHM6IHtcbiAgLy8gICBpbmNsdWRlOiBbJ3ZpcnR1YWw6dnVlLWluc3BlY3Rvci1wYXRoOmxvYWQuanMnXSxcbiAgLy8gfSxcblxufSkiXSwibWFwcGluZ3MiOiI7Ozs7Ozs7Ozs7O0FBU0EsSUFBQSxzQkFBZSxhQUFhO0NBQzFCLFNBQVM7RUFFUCxVQUFVO0dBQ1IsY0FBYztHQUNkLFlBQVksQ0FBQyxPQUFPO0dBQ3BCLEtBQUs7R0FXTCxZQUFZO0dBQ1osTUFBTSxRQUFRLEtBQUs7R0FNbkIsT0FBTztHQUtSLENBQUM7RUFDRixLQUFLO0VBQ0wsUUFBUTtFQUVUO0NBQ0QsU0FBUyxFQUlQLE9BQU8sRUFDTCxLQUFLLFFBQ04sRUFFRjtDQUNELEtBQUssRUFDSCxxQkFBcUIsRUFDbkIsTUFBTTtFQUNKLGdCQUFnQjtFQUNoQixtQkFBbUI7RUFDckIsRUFDRixFQUNEO0NBQ0QsTUFBTTtDQVFQLENBQUEifQ==

dependencies

[
  "/Users/xxxx/Documents/code/cloudcode/vue3-vite-cube/vite.config.ts",
]

临时文件

image.png

image.png

vue3-vite-cube/node_modules/.vite-temp/vite.config.ts.timestamp-1775361732369-f30607f0da0d6.mjs 文件内容如下:

import "node:module";
import { defineConfig } from "file:///Users/xxxx/Documents/code/cloudcode/vue3-vite-cube/node_modules/vite/dist/node/index.js";
import vue from "file:///Users/xxxx/Documents/code/cloudcode/vue3-vite-cube/node_modules/@vitejs/plugin-vue/dist/index.mjs";
import vueJsx from "file:///Users/xxxx/Documents/code/cloudcode/vue3-vite-cube/node_modules/@vitejs/plugin-vue-jsx/dist/index.mjs";
import VueRouter from "file:///Users/xxxx/Documents/code/cloudcode/vue3-vite-cube/node_modules/vue-router/dist/unplugin/vite.mjs";
Object.create;
Object.defineProperty;
Object.getOwnPropertyDescriptor;
Object.getOwnPropertyNames;
Object.getPrototypeOf;
Object.prototype.hasOwnProperty;
var vite_config_default = defineConfig({
plugins: [
VueRouter({
routesFolder: "src/pages",
extensions: [".vue"],
dts: "src/typed-router.d.ts",
importMode: "async",
root: process.cwd(),
watch: true
}),
vue(),
vueJsx()
],
resolve: { alias: { "@": "/src" } },
css: { preprocessorOptions: { less: {
additionalData: `@import "@/styles/variables.less";`,
javascriptEnabled: true
} } },
mode: "development"
});
//#endregion
export { vite_config_default as default };

//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidml0ZS5jb25maWcuanMiLCJuYW1lcyI6W10sInNvdXJjZXMiOlsiL1VzZXJzL2hhaXlhbi9Eb2N1bWVudHMvY29kZS9jbG91ZGNvZGUvdnVlMy12aXRlLWN1YmUvdml0ZS5jb25maWcudHMiXSwic291cmNlc0NvbnRlbnQiOlsiLy8gaW1wb3J0IHsgZmlsZVVSTFRvUGF0aCwgVVJMIH0gZnJvbSAnbm9kZTp1cmwnXG5pbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tICd2aXRlJ1xuaW1wb3J0IHZ1ZSBmcm9tICdAdml0ZWpzL3BsdWdpbi12dWUnXG5pbXBvcnQgdnVlSnN4IGZyb20gJ0B2aXRlanMvcGx1Z2luLXZ1ZS1qc3gnXG5pbXBvcnQgdnVlRGV2VG9vbHMgZnJvbSAndml0ZS1wbHVnaW4tdnVlLWRldnRvb2xzJ1xuaW1wb3J0IFZ1ZVJvdXRlciBmcm9tICd2dWUtcm91dGVyL3ZpdGUnXG5cblxuLy8gaHR0cHM6Ly92aXRlLmRldi9jb25maWcvXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoe1xuICBwbHVnaW5zOiBbXG4gICAgLy8g5b+F6aG76KaB5ZyoIHZ1ZSDmj5Lku7bkuYvliY1cbiAgICBWdWVSb3V0ZXIoe1xuICAgICAgcm91dGVzRm9sZGVyOiAnc3JjL3BhZ2VzJywgLy8g6buY6K6kIHBhZ2VzXG4gICAgICBleHRlbnNpb25zOiBbJy52dWUnXSwgLy8g5Yy56YWN5paH5Lu25ZCO57yAXG4gICAgICBkdHM6ICdzcmMvdHlwZWQtcm91dGVyLmQudHMnLCAvLyDnlJ/miJDnsbvlnovmlofku7ZcbiAgICAgIC8vIOWHuueOsCBSYW5nZUVycm9yOiBNYXhpbXVtIGNhbGwgc3RhY2sgc2l6ZSBleGNlZWRlZFxuICAgICAgLy8gZ2V0Um91dGVOYW1lOiAocm91dGUpID0+IHtcbiAgICAgIC8vICAgY29uc29sZS5sb2coJ2dldFJvdXRlTmFtZScscm91dGUpXG4gICAgICAvLyAgIHJldHVybiByb3V0ZS5uYW1lIHx8IHJvdXRlLnBhdGhcbiAgICAgIC8vIH0sXG5cbiAgICAgICAvLyDmt7vliqDosIPor5XpgInpoblcbiAgICAgIC8vIGxvZ3M6IHRydWUsXG5cbiAgICAgIC8vIHJvdXRlQmxvY2tMYW5nOiAnanNvbjUnLCAvLyDot6/nlLHlnZfor63oqIDvvIzpu5jorqQganNvblxuICAgICAgaW1wb3J0TW9kZTogJ2FzeW5jJyxcbiAgICAgIHJvb3Q6IHByb2Nlc3MuY3dkKCksXG5cbiAgICAgIC8vIOWcqOmFjee9ruaWh+S7tuWGmeWFpeWJje+8jOaJi+WKqOS/ruaUuei3r+eUsemFjee9ru+8iOWmgua3u+WKoOWFqOWxgOi3r+eUseWuiOWNq+OAgeiwg+aVtOi3r+eUseWFg+S/oeaBr+OAgei/h+a7pOi3r+eUseetie+8iVxuICAgICAgLy8gYmVmb3JlV3JpdGVGaWxlczogKGVkaXRlZFJvdXRlcykgPT4ge1xuICAgICAgLy8gICBjb25zb2xlLmxvZygnYmVmb3JlV3JpdGVGaWxlcycsIGVkaXRlZFJvdXRlcylcbiAgICAgIC8vIH0sXG4gICAgICB3YXRjaDogdHJ1ZSwgLy8g5byA5ZCv6Lev55Sx5Z2X5paH5Lu255uR5ZCsXG4gICAgICAvLyDlvIDlkK/lrp7pqozmgKflip/og71cbiAgICAgIC8vIGV4cGVyaW1lbnRhbDoge1xuICAgICAgIFxuICAgICAgLy8gfSxcbiAgICB9KSxcbiAgICB2dWUoKSxcbiAgICB2dWVKc3goKSxcbiAgICAvLyB2dWVEZXZUb29scygpLFxuICBdLFxuICByZXNvbHZlOiB7XG4gICAgLy8gYWxpYXM6IHtcbiAgICAvLyAgICdAJzogZmlsZVVSTFRvUGF0aChuZXcgVVJMKCcuL3NyYycsIGltcG9ydC5tZXRhLnVybCkpXG4gICAgLy8gfSxcbiAgICBhbGlhczoge1xuICAgICAgJ0AnOiAnL3NyYycsXG4gICAgfSxcbiAgICAvLyB0c2NvbmZpZ1BhdGhzOiB0cnVlLCAgLy8g6Ieq5Yqo6K+75Y+WIHRzY29uZmlnIHBhdGhzXG4gIH0sXG4gIGNzczoge1xuICAgIHByZXByb2Nlc3Nvck9wdGlvbnM6IHtcbiAgICAgIGxlc3M6IHtcbiAgICAgICAgYWRkaXRpb25hbERhdGE6IGBAaW1wb3J0IFwiQC9zdHlsZXMvdmFyaWFibGVzLmxlc3NcIjtgLFxuICAgICAgICBqYXZhc2NyaXB0RW5hYmxlZDogdHJ1ZVxuICAgICAgfVxuICAgIH1cbiAgfSxcbiAgbW9kZTogJ2RldmVsb3BtZW50JyxcbiAgLy8gc2VydmVyOiB7XG4gIC8vICAgd3M6IGZhbHNlLFxuICAvLyB9LFxuICAvLyBvcHRpbWl6ZURlcHM6IHtcbiAgLy8gICBpbmNsdWRlOiBbJ3ZpcnR1YWw6dnVlLWluc3BlY3Rvci1wYXRoOmxvYWQuanMnXSxcbiAgLy8gfSxcblxufSkiXSwibWFwcGluZ3MiOiI7Ozs7Ozs7Ozs7O0FBU0EsSUFBQSxzQkFBZSxhQUFhO0NBQzFCLFNBQVM7RUFFUCxVQUFVO0dBQ1IsY0FBYztHQUNkLFlBQVksQ0FBQyxPQUFPO0dBQ3BCLEtBQUs7R0FXTCxZQUFZO0dBQ1osTUFBTSxRQUFRLEtBQUs7R0FNbkIsT0FBTztHQUtSLENBQUM7RUFDRixLQUFLO0VBQ0wsUUFBUTtFQUVUO0NBQ0QsU0FBUyxFQUlQLE9BQU8sRUFDTCxLQUFLLFFBQ04sRUFFRjtDQUNELEtBQUssRUFDSCxxQkFBcUIsRUFDbkIsTUFBTTtFQUNKLGdCQUFnQjtFQUNoQixtQkFBbUI7RUFDckIsRUFDRixFQUNEO0NBQ0QsTUFBTTtDQVFQLENBQUEifQ==
/**
 * 用于从打包后的代码加载 Vite 配置。
 * 它根据模块类型(ESM 或 CommonJS)采用不同的加载策略,确保配置文件能够被正确执行并返回配置对象
 * @param fileName  文件路径
 * @param bundledCode 打包转换后代码
 * @param isESM 是否为 ESM 格式
 * @returns 
 */
async function loadConfigFromBundledFile(
  fileName: string,
  bundledCode: string,
  isESM: boolean,
): Promise<UserConfigExport> {
  // for esm, before we can register loaders without requiring users to run node
  // with --experimental-loader themselves, we have to do a hack here:
  // write it to disk, load it with native Node ESM, then delete the file.
  if (isESM) {
    // Storing the bundled file in node_modules/ is avoided for Deno
    // because Deno only supports Node.js style modules under node_modules/
    // and configs with `npm:` import statements will fail when executed.
    // 查找最近的 node_modules 目录
    let nodeModulesDir =
      typeof process.versions.deno === 'string'
        ? undefined
        : findNearestNodeModules(path.dirname(fileName))

    if (nodeModulesDir) {
      try {
        // 创建临时目录
        // node_modules/.vite-temp/
        await fsp.mkdir(path.resolve(nodeModulesDir, '.vite-temp/'), {
          recursive: true,
        })
      } catch (e) {
        if (e.code === 'EACCES') {
          // If there is no access permission, a temporary configuration file is created by default.
          nodeModulesDir = undefined
        } else {
          throw e
        }
      }
    }
    // 生成 hash 值
    const hash = `timestamp-${Date.now()}-${Math.random().toString(16).slice(2)}`
    // 生成临时文件名
    const tempFileName = nodeModulesDir
      ? path.resolve(
          nodeModulesDir,
          `.vite-temp/${path.basename(fileName)}.${hash}.mjs`,
        )
      : `${fileName}.${hash}.mjs`
      // 写入临时文件
    await fsp.writeFile(tempFileName, bundledCode)
    try {
      // 将文件系统路径转换为 file:// 协议的 URL 对象
      // 原因:ESM 的 import() 语法要求模块标识符为 URL 格式(对于本地文件),不能直接使用文件系统路径
      // 动态加载 ESM 格式配置文件
      // 执行过程:
      // 1、Node.js 读取并执行 tempFileName 指向的文件
      // 2、执行文件中的代码,构建模块的导出
      // 3、生成包含所有导出的模块命名空间对象
      // 4、Promise 解析为该命名空间对象
      return (await import(pathToFileURL(tempFileName).href)).default
    } finally {
      fs.unlink(tempFileName, () => {}) // Ignore errors
    }
  }
  // for cjs, we can register a custom loader via `_require.extensions`
  else {
    // 获取文件扩展名
    const extension = path.extname(fileName)
    // We don't use fsp.realpath() here because it has the same behaviour as
    // fs.realpath.native. On some Windows systems, it returns uppercase volume
    // letters (e.g. "C:\") while the Node.js loader uses lowercase volume letters.
    // See https://github.com/vitejs/vite/issues/12923
    // 获取文件的真实路径
    // 避免 Windows 系统上的路径大小写问题
    const realFileName = await promisifiedRealpath(fileName)
    // 确定加载器扩展名
    // require.extensions 标记已废弃
    const loaderExt = extension in _require.extensions ? extension : '.js'
    const defaultLoader = _require.extensions[loaderExt]!
    // 注册自定义加载器
    _require.extensions[loaderExt] = (module: NodeModule, filename: string) => {
      if (filename === realFileName) {
        // 执行打包后的代码
        ;(module as NodeModuleWithCompile)._compile(bundledCode, filename)
      } else {
        // 使用默认加载器
        defaultLoader(module, filename)
      }
    }
    // clear cache in case of server restart
    // 清除缓存
    delete _require.cache[_require.resolve(fileName)]
    // 加载配置文件
    const raw = _require(fileName)
    // 恢复默认加载器
    _require.extensions[loaderExt] = defaultLoader
    return raw.__esModule ? raw.default : raw
  }
}

runner (实验性)

runner 模式不会创建临时配置文件,而是使用 Vite 的 ModuleRunner 动态转换任何文件。它的核心机制是利用 RunnableDevEnvironment 提供的 runner.import 函数,在独立的执行环境中加载并运行模块。

{
   "start": "vite --configLoader=runner",
}
/**
 * 用于通过 runner 方式导入配置文件。
 * 它使用 runnerImport 函数动态加载配置文件,提取默认导出作为配置对象,并返回配置对象及其依赖项。
 * @param resolvedPath 配置文件路径
 * @returns 
 */
async function runnerImportConfigFile(resolvedPath: string) {
  const { module, dependencies } = await runnerImport<{
    default: UserConfigExport
  }>(resolvedPath)
  return {
    configExport: module.default,
    dependencies,
  }
}

image.png

async function runnerImport<T>(
  moduleId: string,
  inlineConfig?: InlineConfig,
): Promise<RunnerImportResult<T>> {

  // 模块同步条件检查
  const isModuleSyncConditionEnabled = (await import('#module-sync-enabled'))
    .default

  // 配置解析
  const config = await resolveConfig(
    // 合并配置
    mergeConfig(inlineConfig || {}, {
      configFile: false, // 禁用配置文件解析
      envDir: false, // 禁用环境变量目录解析
      cacheDir: process.cwd(), // 缓存目录设置为当前工作目录
      environments: {
        inline: {
          // 指定环境的消费方为服务器端
          consumer: 'server',
          dev: {
            // 启用模块运行器转换
            moduleRunnerTransform: true,
          },
          // 模块解析配置
          resolve: {
            // 启用外部模块解析,将依赖视为外部模块,不进行打包
            // 影响:减少打包体积,提高模块加载速度
            external: true,
            // 清空主字段数组
            // 不使用 package.json 中的主字段进行模块解析
            // 避免因主字段优先级导致的解析问题,确保一致性
            mainFields: [],
            // 指定模块解析条件
            conditions: [
              'node',
              ...(isModuleSyncConditionEnabled ? ['module-sync'] : []),
            ],
          },
        },
      },
    } satisfies InlineConfig),
    'serve', // 确保是 serve 命令
  )
  // 创建可运行的开发环境
  const environment = createRunnableDevEnvironment('inline', config, {
    runnerOptions: {
      hmr: {
        logger: false, // 禁用 HMR 日志记录
      },
    },
    hot: false, // 禁用 HMR
  })
  // 初始化环境
  // 准备模块运行器,确保能够正确加载模块
  await environment.init()
  try {
    // 使用环境的运行器导入模块
    // 模块加载与执行:
    // 1、ModuleRunner 解析 moduleId,处理路径解析
    // 2、加载模块文件内容
    // 3、应用必要的转换(如 ESM 到 CommonJS 的转换)
    // 4、执行模块代码
    // 5、收集模块的依赖项
    const module = await environment.runner.import(moduleId)

    // 获取所有评估过的模块
    const modules = [
      ...environment.runner.evaluatedModules.urlToIdModuleMap.values(),
    ]
    // 过滤出所有外部化模块和当前模块
    // 这些模块不是依赖项,因为它们是 Vite 内部使用的模块
    const dependencies = modules
      .filter((m) => {
        // ignore all externalized modules
        // 忽略没有meta的模块 或者标记为外部化的模块
        if (!m.meta || 'externalize' in m.meta) {
          return false
        }
        // ignore the current module
        // 忽略当前模块,因为它不是依赖项
        return m.exports !== module
      })
      .map((m) => m.file)

    return {
      module,
      dependencies,
    }
  } finally {
    // 关闭环境
    // 释放所有资源,避免内存泄漏等问题
    await environment.close()
  }
}

image.png

module

{
  default: {
    plugins: [
      {
        name: "vue-router",
        enforce: "pre",
        resolveId: {
          filter: {
            id: {
              include: [
                {
                },
                {
                },
                {
                },
              ],
            },
          },
          handler: function(...args) {
            const [id] = args;
            if (!supportNativeFilter(this, key) && !filter(id)) return;
            return handler.apply(this, args);
          },
        },
        buildStart: async buildStart() {
          await ctx.scanPages(options.watch);
        },
        buildEnd: function() {
          ctx.stopWatcher();
        },
        transform: {
          filter: {
            id: {
              include: [
                "/Users/xxxx/Documents/code/cloudcode/vue3-vite-cube/src/pages/**/*.vue",
                {
                },
              ],
              exclude: [
              ],
            },
          },
          handler: function(...args) {
            const [code, id] = args;
            if (plugin.transformInclude && !plugin.transformInclude(id)) return;
            if (!supportNativeFilter(this, key) && !filter(id, code)) return;
            return handler.apply(this, args);
          },
        },
        load: {
          filter: {
            id: {
              include: [
                {
                },
                {
                },
                {
                },
              ],
            },
          },
          handler: function(...args) {
            const [id] = args;
            if (plugin.loadInclude && !plugin.loadInclude(id)) return;
            if (!supportNativeFilter(this, key) && !filter(id)) return;
            return handler.apply(this, args);
          },
        },
        vite: {
          configureServer: function(server) {
            ctx.setServerContext(createViteContext(server));
          },
        },
        configureServer: function(server) {
          ctx.setServerContext(createViteContext(server));
        },
      },
      {
        name: "vite:vue",
        api: {
          options: {
            isProduction: false,
            compiler: null,
            customElement: {
            },
            root: "/Users/xxxx/Documents/code/cloudcode/vue3-vite-cube",
            sourceMap: true,
            cssDevSourcemap: false,
          },
          include: {
          },
          exclude: undefined,
          version: "6.0.5",
        },
        handleHotUpdate: function(ctx) {
          ctx.server.ws.send({
          type: "custom",
          event: "file-changed",
          data: { file: normalizePath(ctx.file) }
          });
          if (options.value.compiler.invalidateTypeCache) options.value.compiler.invalidateTypeCache(ctx.file);
          let typeDepModules;
          const matchesFilter = filter.value(ctx.file);
          if (typeDepToSFCMap.has(ctx.file)) {
          typeDepModules = handleTypeDepChange(typeDepToSFCMap.get(ctx.file), ctx);
          if (!matchesFilter) return typeDepModules;
          }
          if (matchesFilter) return handleHotUpdate(ctx, options.value, customElementFilter.value(ctx.file), typeDepModules);
        },
        config: function(config) {
          const parseDefine = (v) => {
          try {
          return typeof v === "string" ? JSON.parse(v) : v;
          } catch (err) {
          return v;
          }
          };
          return {
          resolve: { dedupe: config.build?.ssr ? [] : ["vue"] },
          define: {
          __VUE_OPTIONS_API__: options.value.features?.optionsAPI ?? parseDefine(config.define?.__VUE_OPTIONS_API__) ?? true,
          __VUE_PROD_DEVTOOLS__: (options.value.features?.prodDevtools || parseDefine(config.define?.__VUE_PROD_DEVTOOLS__)) ?? false,
          __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: (options.value.features?.prodHydrationMismatchDetails || parseDefine(config.define?.__VUE_PROD_HYDRATION_MISMATCH_DETAILS__)) ?? false
          },
          ssr: { external: config.legacy?.buildSsrCjsExternalHeuristics ? ["vue", "@vue/server-renderer"] : [] }
          };
        },
        configResolved: function(config) {
          options.value = {
          ...options.value,
          root: config.root,
          sourceMap: config.command === "build" ? !!config.build.sourcemap : true,
          cssDevSourcemap: config.css?.devSourcemap ?? false,
          isProduction: config.isProduction,
          devToolsEnabled: !!(options.value.features?.prodDevtools || config.define.__VUE_PROD_DEVTOOLS__ || !config.isProduction)
          };
          const _warn = config.logger.warn;
          config.logger.warn = (...args) => {
          if (args[0].match(/\[lightningcss\] '(deep|slotted|global)' is not recognized as a valid pseudo-/)) return;
          _warn(...args);
          };
          transformCachedModule = config.command === "build" && options.value.sourceMap && config.build.watch != null;
        },
        options: function() {
          optionsHookIsCalled = true;
          plugin.transform.filter = { id: {
          include: [...makeIdFiltersToMatchWithQuery(ensureArray(include.value)), /[?&]vue\b/],
          exclude: exclude.value
          } };
        },
        shouldTransformCachedModule: function({ id }) {
          if (transformCachedModule && parseVueRequest(id).query.vue) return true;
          return false;
        },
        configureServer: function(server) {
          options.value.devServer = server;
        },
        buildStart: function() {
          const compiler = options.value.compiler = options.value.compiler || resolveCompiler(options.value.root);
          if (compiler.invalidateTypeCache) options.value.devServer?.watcher.on("unlink", (file) => {
          compiler.invalidateTypeCache(file);
          });
        },
        resolveId: {
          filter: {
            id: [
              {
              },
              {
              },
            ],
          },
          handler: function(id) {
            if (id === EXPORT_HELPER_ID) return id;
            if (parseVueRequest(id).query.vue) return id;
          },
        },
        load: {
          filter: {
            id: [
              {
              },
              {
              },
            ],
          },
          handler: function(id, opt) {
            if (id === EXPORT_HELPER_ID) return helperCode;
            const ssr = opt?.ssr === true;
            const { filename, query } = parseVueRequest(id);
            if (query.vue) {
            if (query.src) return fs.readFileSync(filename, "utf-8");
            const descriptor = getDescriptor(filename, options.value);
            let block;
            if (query.type === "script") block = resolveScript(descriptor, options.value, ssr, customElementFilter.value(filename));
            else if (query.type === "template") block = descriptor.template;
            else if (query.type === "style") block = descriptor.styles[query.index];
            else if (query.index != null) block = descriptor.customBlocks[query.index];
            if (block) return {
            code: block.content,
            map: block.map
            };
            }
          },
        },
        transform: {
          handler: function(code, id, opt) {
            const ssr = opt?.ssr === true;
            const { filename, query } = parseVueRequest(id);
            if (query.raw || query.url) return;
            if (!filter.value(filename) && !query.vue) return;
            if (!query.vue) return transformMain(code, filename, options.value, this, ssr, customElementFilter.value(filename));
            else {
            const descriptor = query.src ? getSrcDescriptor(filename, query) || getTempSrcDescriptor(filename, query) : getDescriptor(filename, options.value);
            if (query.src) this.addWatchFile(filename);
            if (query.type === "template") return transformTemplateAsModule(code, filename, descriptor, options.value, this, ssr, customElementFilter.value(filename));
            else if (query.type === "style") return transformStyle(code, descriptor, Number(query.index || 0), options.value, this, filename);
            }
          },
        },
      },
      {
        name: "vite:vue-jsx",
        config: function(config) {
          const parseDefine = (v) => {
          try {
          return typeof v === "string" ? JSON.parse(v) : v;
          } catch (err) {
          return v;
          }
          };
          const isRolldownVite = this && "rolldownVersion" in this.meta;
          return {
          [isRolldownVite ? "oxc" : "esbuild"]: tsTransform === "built-in" ? { exclude: /\.jsx?$/ } : { include: /\.ts$/ },
          define: {
          __VUE_OPTIONS_API__: parseDefine(config.define?.__VUE_OPTIONS_API__) ?? true,
          __VUE_PROD_DEVTOOLS__: parseDefine(config.define?.__VUE_PROD_DEVTOOLS__) ?? false,
          __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: parseDefine(config.define?.__VUE_PROD_HYDRATION_MISMATCH_DETAILS__) ?? false
          },
          optimizeDeps: isRolldownVite ? { rolldownOptions: { transform: { jsx: "preserve" } } } : {}
          };
        },
        configResolved: function(config) {
          needHmr = config.command === "serve" && !config.isProduction;
          needSourceMap = config.command === "serve" || !!config.build.sourcemap;
          root = config.root;
        },
        resolveId: {
          filter: {
            id: {
            },
          },
          handler: function(id) {
            if (id === ssrRegisterHelperId) return id;
          },
        },
        load: {
          filter: {
            id: {
            },
          },
          handler: function(id) {
            if (id === ssrRegisterHelperId) return ssrRegisterHelperCode;
          },
        },
        transform: {
          order: undefined,
          filter: {
            id: {
              include: {
              },
              exclude: undefined,
            },
          },
          handler: async handler(code, id, opt) {
            const ssr = opt?.ssr === true;
            const [filepath] = id.split("?");
            if (filter(id) || filter(filepath)) {
            const plugins = [[jsx, babelPluginOptions], ...babelPlugins];
            if (id.endsWith(".tsx") || filepath.endsWith(".tsx")) if (tsTransform === "built-in") plugins.push([await import("@babel/plugin-syntax-typescript").then((r) => r.default), { isTSX: true }]);
            else plugins.push([await import("@babel/plugin-transform-typescript").then((r) => r.default), {
            ...tsPluginOptions,
            isTSX: true,
            allowExtensions: true
            }]);
            if (!ssr && !needHmr) plugins.push(() => {
            return { visitor: { CallExpression: { enter(_path) {
            if (isDefineComponentCall(_path.node, defineComponentName)) {
            const callee = _path.node.callee;
            callee.name = `/* @__PURE__ */ ${callee.name}`;
            }
            } } } };
            });
            else plugins.push(() => {
            return { visitor: { ExportDefaultDeclaration: { enter(_path) {
            const unwrappedDeclaration = unwrapTypeAssertion(_path.node.declaration);
            if (isDefineComponentCall(unwrappedDeclaration, defineComponentName)) {
            const declaration = unwrappedDeclaration;
            const nodesPath = _path.replaceWithMultiple([types.variableDeclaration("const", [types.variableDeclarator(types.identifier("__default__"), types.callExpression(declaration.callee, declaration.arguments))]), types.exportDefaultDeclaration(types.identifier("__default__"))]);
            _path.scope.registerDeclaration(nodesPath[0]);
            }
            } } } };
            });
            const result = babel.transformSync(code, {
            babelrc: false,
            ast: true,
            plugins,
            sourceMaps: needSourceMap,
            sourceFileName: id,
            configFile: false
            });
            if (!ssr && !needHmr) {
            if (!result.code) return;
            return {
            code: result.code,
            map: result.map
            };
            }
            const declaredComponents = [];
            const hotComponents = [];
            for (const node of result.ast.program.body) {
            if (node.type === "VariableDeclaration") {
            const names = parseComponentDecls(node, defineComponentName);
            if (names.length) declaredComponents.push(...names);
            }
            if (node.type === "ExportNamedDeclaration") {
            if (node.declaration && node.declaration.type === "VariableDeclaration") hotComponents.push(...parseComponentDecls(node.declaration, defineComponentName).map((name) => ({
            local: name,
            exported: name,
            id: getHash(id + name)
            })));
            else if (node.specifiers.length) {
            for (const spec of node.specifiers) if (spec.type === "ExportSpecifier" && spec.exported.type === "Identifier") {
            if (declaredComponents.find((name) => name === spec.local.name)) hotComponents.push({
            local: spec.local.name,
            exported: spec.exported.name,
            id: getHash(id + spec.exported.name)
            });
            }
            }
            }
            if (node.type === "ExportDefaultDeclaration") {
            if (node.declaration.type === "Identifier") {
            const _name = node.declaration.name;
            if (declaredComponents.find((name) => name === _name)) hotComponents.push({
            local: _name,
            exported: "default",
            id: getHash(id + "default")
            });
            } else if (isDefineComponentCall(unwrapTypeAssertion(node.declaration), defineComponentName)) hotComponents.push({
            local: "__default__",
            exported: "default",
            id: getHash(id + "default")
            });
            }
            }
            if (hotComponents.length) {
            if (needHmr && !ssr && !/\?vue&type=script/.test(id)) {
            let code = result.code;
            let callbackCode = ``;
            for (const { local, exported, id } of hotComponents) {
            code += `\n${local}.__hmrId = "${id}"\n__VUE_HMR_RUNTIME__.createRecord("${id}", ${local})`;
            callbackCode += `\n__VUE_HMR_RUNTIME__.reload("${id}", __${exported})`;
            }
            const newCompNames = hotComponents.map((c) => `${c.exported}: __${c.exported}`).join(",");
            code += `\nimport.meta.hot.accept(({${newCompNames}}) => {${callbackCode}\n})`;
            result.code = code;
            }
            if (ssr) {
            const normalizedId = normalizePath(path.relative(root, id));
            let ssrInjectCode = `\nimport { ssrRegisterHelper } from "${ssrRegisterHelperId}"\nconst __moduleId = ${JSON.stringify(normalizedId)}`;
            for (const { local } of hotComponents) ssrInjectCode += `\nssrRegisterHelper(${local}, __moduleId)`;
            result.code += ssrInjectCode;
            }
            }
            if (!result.code) return;
            return {
            code: result.code,
            map: result.map
            };
            }
          },
        },
      },
    ],
    resolve: {
      alias: {
        "@": "/src",
      },
    },
    css: {
      preprocessorOptions: {
        less: {
          additionalData: "@import \"@/styles/variables.less\";",
          javascriptEnabled: true,
        },
      },
    },
    mode: "development",
  },
}

导出的内容就是 vite.config.js 中配置信息

image.png

ModuleRunner模块运行器

  public async import<T = any>(url: string): Promise<T> {
    // 获取缓存模块
    const fetchedModule = await this.cachedModule(url)
    // 执行模块请求
    return await this.cachedRequest(url, fetchedModule)
  }

image.png

native (实验性)

native 模式直接通过 Node.js 原生的动态 import() 加载配置文件,不经过任何打包步骤。

只能编写纯 JavaScript,可以指定 --configLoader native 来使用环境的原生运行时加载配置文件。

{
   "start": "vite --configLoader=native",
}
  • 它的优点是简单快速,调试时断点可以直接定位到源码,不受临时文件干扰。
  • 但这种模式有一个重要限制:配置文件导入的模块的更新不会被检测到,因此不会自动重启 Vite 服务器
async function nativeImportConfigFile(resolvedPath: string) {
  const module = await import(
    pathToFileURL(resolvedPath).href + '?t=' + Date.now()
  )
  return {
    configExport: module.default,
    dependencies: [],
  }
}

在 native 模式下,由于没有经过打包工具分析依赖,Vite 无法知道配置文件引入了哪些本地模块。因此依赖列表被硬编码为空数组,意味着当配置文件导入的其他本地文件(如 ./utils.js)发生变化时,Vite 不会自动重启服务器。这是 native 模式的重要限制。

三者的区别

image.png

从零到一:在React前端中集成The Graph查询Uniswap V3池数据实战

从零到一:在React前端中集成The Graph查询Uniswap V3池数据实战

背景

上个月,我接了一个DeFi策略分析面板的前端开发需求。其中一个核心功能是展示Uniswap V3上特定交易对(比如ETH/USDC)的流动性池详情,包括当前价格、流动性总量、手续费率等。我的第一反应是直接用 ethers.jsviem 去读取对应的智能合约。这确实能行,我写了几个 readContract 调用,数据也拿到了。

但问题很快来了。当我想展示这个池子最近24小时的交易量变化,或者想列出这个池子所有大的流动性提供者(LP)时,直接查合约就变得非常笨重和低效。我需要遍历大量历史事件,这在浏览器端几乎不可能实现,而且会消耗天量的RPC请求。项目需要一个既能查询实时状态又能高效检索历史事件的解决方案。这时,我想到了 The Graph——一个专门用于索引和查询区块链数据的去中心化协议。理论上,我可以通过它订阅一个已经索引好的Uniswap V3子图,用GraphQL轻松拿到所有结构化数据。

问题分析

一开始,我以为集成The Graph会很简单:找个现成的Uniswap V3子图,用 fetchaxios 发个GraphQL请求不就完了?但上手后发现,事情没那么直白。

首先,我找到了Uniswap官方在The Graph托管服务上部署的V3子图。但我直接用自己的前端项目去请求它的公开端点时,遇到了CORS(跨域资源共享)错误。浏览器的安全策略阻止了我的本地开发服务器向 https://api.thegraph.com 发起请求。这是第一个拦路虎。

其次,即使CORS问题解决了,GraphQL查询的编写也让我有点懵。子图暴露的数据模式(Schema)和我直接从合约里读到的原始数据格式不一样,它是被索引和加工过的实体(Entities)。我需要搞清楚有哪些实体可用,以及它们之间的关联关系。

最后,我希望能有一个类型安全的开发体验。GraphQL查询返回的 any 类型在TypeScript项目里用着心里发虚,后期维护也容易出错。我需要一种方法能为查询结果生成明确的TypeScript接口。

最初的“简单fetch方案”显然走不通,我需要一个更正式、更健壮的前端集成方案。

核心实现

1. 选择客户端与绕过CORS

直接调用The Graph的公共HTTPS端点遇到CORS限制,这是前端开发中常见的问题。The Graph的托管服务默认可能没有配置允许所有来源。解决这个问题有几种思路:配置自己的代理服务器,或者使用支持自定义端点的Graph客户端库。

我选择了 Apollo Client。它是一个功能强大的GraphQL客户端,不仅帮我管理请求状态、缓存,更重要的是,它通常用于服务端渲染(SSR)或静态生成(SSG)场景,在这些场景中,请求发自Node.js环境而非浏览器,从而天然避开了CORS问题。对于我的纯前端项目,我可以先通过配置一个简单的本地开发代理来解决CORS问题,未来部署时可以考虑使用无服务器函数作为代理。

首先,我安装了必要的依赖:

npm install @apollo/client graphql

然后,我创建了Apollo Client的实例,指向Uniswap V3在以太坊主网的子图端点。

// src/lib/apolloClient.ts
import { ApolloClient, InMemoryCache, HttpLink, from } from '@apollo/client';
import { onError } from '@apollo/client/link/error';

// 注意:在浏览器中直接使用此端点会因CORS失败
// 在开发环境中,我们需要配置代理或使用其他方法
const GRAPHQL_ENDPOINT = 'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3';

const httpLink = new HttpLink({
  uri: GRAPHQL_ENDPOINT,
});

const errorLink = onError(({ graphQLErrors, networkError }) => {
  if (graphQLErrors)
    graphQLErrors.forEach(({ message, locations, path }) =>
      console.error(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`)
    );
  if (networkError) console.error(`[Network error]: ${networkError}`);
});

export const apolloClient = new ApolloClient({
  link: from([errorLink, httpLink]),
  cache: new InMemoryCache(),
});

这里有个坑:在本地开发时,如果你在浏览器控制台看到CORS错误,一个快速的解决方案是在 vite.config.tswebpack.config.js 中配置开发服务器代理,将 /subgraph 路径的请求转发到The Graph API。

// vite.config.ts 示例
export default defineConfig({
  // ... 其他配置
  server: {
    proxy: {
      '/subgraph-api': {
        target: 'https://api.thegraph.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/subgraph-api/, ''),
      },
    },
  },
});

然后,将 GRAPHQL_ENDPOINT 改为 ‘/subgraph-api/subgraphs/name/uniswap/uniswap-v3’。这样,浏览器请求的是同源地址,由开发服务器代为转发,就绕过了CORS。

2. 编写并执行GraphQL查询

接下来,我需要编写正确的GraphQL查询。我先去The Graph的Explorer查看了 uniswap-v3 子图的Schema。我找到了几个关键实体:Pool(流动性池)、Token(代币)、Swap(兑换事件)等。

我的目标是查询一个特定池子的基本信息。我知道Uniswap V3池子的合约地址是由两个代币地址和手续费层级(feeTier)共同决定的。但更方便的是,子图已经为每个池子生成了一个唯一的ID,通常是合约地址。所以,我可以直接用池子合约地址来查询。

我在项目中创建了一个GraphQL查询文件:

# src/graphql/queries/poolInfo.graphql
query GetPoolInfo($poolId: ID!) {
  pool(id: $poolId) {
    id
    token0 {
      id
      symbol
      name
      decimals
    }
    token1 {
      id
      symbol
      name
      decimals
    }
    feeTier
    liquidity
    sqrtPrice
    tick
    volumeUSD
    txCount
    # 当前价格,需要根据sqrtPrice和代币精度计算
    # 这里我们先取出来原始数据,在前端转换
  }
}

然后,在React组件中,我使用 @apollo/clientuseQuery hook来执行这个查询。我选择了一个知名的ETH/USDC 0.3%费率的池子地址作为示例。

// src/components/PoolInfo.tsx
import { useQuery, gql } from '@apollo/client';
import React from 'react';

// 使用gql标签定义查询
const GET_POOL_INFO = gql`
  query GetPoolInfo($poolId: ID!) {
    pool(id: $poolId) {
      id
      token0 {
        id
        symbol
        name
        decimals
      }
      token1 {
        id
        symbol
        name
        decimals
      }
      feeTier
      liquidity
      sqrtPrice
      tick
      volumeUSD
      txCount
    }
  }
`;

// 一个已知的ETH/USDC 0.3%池地址
const SAMPLE_POOL_ID = '0x8ad599c3a0ff1de082011efddc58f1908eb6e6d8';

export const PoolInfo: React.FC = () => {
  const { loading, error, data } = useQuery(GET_POOL_INFO, {
    variables: { poolId: SAMPLE_POOL_ID },
  });

  if (loading) return <p>Loading pool data from The Graph...</p>;
  if (error) return <p>Error :( {error.message}</p>;

  const pool = data.pool;
  // 计算当前价格:价格 = (sqrtPrice^2) / 2^(192) * (10^decimals1 / 10^decimals0)
  // 简化处理:这里只展示一个概念
  const token0Decimals = pool.token0.decimals;
  const token1Decimals = pool.token1.decimals;

  return (
    <div>
      <h2>Pool: {pool.token0.symbol} / {pool.token1.symbol}</h2>
      <p>Fee Tier: {pool.feeTier / 10000}%</p>
      <p>Liquidity: {parseFloat(pool.liquidity).toLocaleString()}</p>
      <p>Volume (USD): ${parseFloat(pool.volumeUSD).toLocaleString(undefined, { maximumFractionDigits: 2 })}</p>
      <p>Transaction Count: {pool.txCount}</p>
      <p>Pool Contract: <code>{pool.id}</code></p>
    </div>
  );
};

注意这个细节sqrtPricetick 是Uniswap V3用于表示价格的核心变量。前端需要根据公式将它们转换为人类可读的价格。上面的计算只是示意,实际项目中需要实现精确的转换函数。

3. 实现类型安全(Codegen)

手动为GraphQL查询结果定义TypeScript接口非常繁琐且容易出错。我决定使用 GraphQL Code Generator 来自动完成这项工作。

首先,安装必要的开发依赖:

npm install -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations

然后,创建配置文件 codegen.yml

# codegen.yml
overwrite: true
schema: 'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3'
documents: 'src/graphql/**/*.graphql'
generates:
  src/generated/graphql.ts:
    plugins:
      - 'typescript'
      - 'typescript-operations'
    config:
      skipTypename: false
      withHooks: true # 如果使用React,可以生成对应的hooks

package.json 中添加一个脚本:

"scripts": {
  "codegen": "graphql-codegen --config codegen.yml",
  "codegen:watch": "graphql-codegen --config codegen.yml --watch"
}

运行 npm run codegen 后,会在 src/generated/graphql.ts 中自动生成所有类型定义和可能的React Hooks。现在,我可以以完全类型安全的方式重写我的查询:

// src/components/PoolInfoTyped.tsx
import React from 'react';
import { useGetPoolInfoQuery } from '../generated/graphql'; // 自动生成的Hook
import { apolloClient } from '../lib/apolloClient';
import { ApolloProvider } from '@apollo/client';

const SAMPLE_POOL_ID = '0x8ad599c3a0ff1de082011efddc58f1908eb6e6d8';

const PoolInfoInner: React.FC = () => {
  // 现在,`data`、`variables` 的类型都是自动推断的!
  const { loading, error, data } = useGetPoolInfoQuery({
    variables: { poolId: SAMPLE_POOL_ID },
  });

  if (loading) return <p>Loading (with types)...</p>;
  if (error) return <p>Error (with types): {error.message}</p>;
  // TypeScript知道`data.pool`可能为null,因为GraphQL查询可能返回空
  if (!data || !data.pool) return <p>No pool found.</p>;

  const pool = data.pool;
  return (
    <div>
      <h2>Pool: {pool.token0.symbol} / {pool.token1.symbol}</h2>
      <p>Pool ID: <code>{pool.id}</code></p>
      {/* 访问其他属性都有完整的类型提示 */}
    </div>
  );
};

// 需要在外层提供Apollo Client
export const PoolInfoTyped: React.FC = () => (
  <ApolloProvider client={apolloClient}>
    <PoolInfoInner />
  </ApolloProvider>
);

通过Codegen,我获得了完美的开发体验:自动补全、类型检查、以及查询字段变更时的编译时报错,大大提升了代码的可靠性和开发效率。

4. 处理分页与复杂查询

基础信息查询搞定后,我需要实现更复杂的功能,比如列出该池子最近的Swap事件。这类列表查询通常涉及分页。The Graph的子图查询支持经典的 firstskipwhere 过滤参数。

我编写了一个分页查询Swap事件的GraphQL:

# src/graphql/queries/poolSwaps.graphql
query GetPoolSwaps($poolId: ID!, $first: Int!, $skip: Int!) {
  swaps(
    where: { pool: $poolId }
    orderBy: timestamp
    orderDirection: desc
    first: $first
    skip: $skip
  ) {
    id
    timestamp
    transaction {
      id
    }
    sender
    recipient
    amount0
    amount1
    amountUSD
  }
}

在React组件中,我可以结合 useQuery 和分页状态(如当前页码)来动态获取数据。对于无限滚动或加载更多,Apollo Client的 fetchMore 函数非常好用。

// 使用 useQuery 的 fetchMore 示例片段
const { data, loading, fetchMore } = useGetPoolSwapsQuery({
  variables: {
    poolId: SAMPLE_POOL_ID,
    first: 10,
    skip: 0,
  },
});

const loadMore = () => {
  fetchMore({
    variables: {
      skip: data?.swaps.length || 0,
    },
    // 更新查询结果的方式
    updateQuery: (prev, { fetchMoreResult }) => {
      if (!fetchMoreResult) return prev;
      return {
        swaps: [...prev.swaps, ...fetchMoreResult.swaps],
      };
    },
  });
};

完整代码示例

以下是一个简化但可运行的React组件示例,集成了上述所有关键点(假设已配置代理解决CORS,并已运行Codegen生成类型)。

// src/App.tsx
import React from 'react';
import { ApolloProvider } from '@apollo/client';
import { apolloClient } from './lib/apolloClient';
import { PoolDashboard } from './components/PoolDashboard';

function App() {
  return (
    <ApolloProvider client={apolloClient}>
      <div className="App">
        <h1>Uniswap V3 Pool Dashboard (Powered by The Graph)</h1>
        <PoolDashboard />
      </div>
    </ApolloProvider>
  );
}

export default App;
// src/components/PoolDashboard.tsx
import React, { useState } from 'react';
import { useGetPoolInfoQuery, useGetPoolSwapsQuery } from '../generated/graphql';

const ETH_USDC_POOL = '0x8ad599c3a0ff1de082011efddc58f1908eb6e6d8';
const PAGE_SIZE = 5;

export const PoolDashboard: React.FC = () => {
  // 查询池子基本信息
  const { data: poolData, loading: poolLoading, error: poolError } = useGetPoolInfoQuery({
    variables: { poolId: ETH_USDC_POOL },
  });

  // 查询Swap事件,带分页
  const [swapsSkip, setSwapsSkip] = useState(0);
  const {
    data: swapsData,
    loading: swapsLoading,
    error: swapsError,
    fetchMore,
  } = useGetPoolSwapsQuery({
    variables: {
      poolId: ETH_USDC_POOL,
      first: PAGE_SIZE,
      skip: swapsSkip,
    },
  });

  const handleLoadMore = () => {
    const currentLength = swapsData?.swaps.length || 0;
    fetchMore({
      variables: { skip: currentLength },
      updateQuery: (prev, { fetchMoreResult }) => {
        if (!fetchMoreResult) return prev;
        return {
          swaps: [...prev.swaps, ...fetchMoreResult.swaps],
        };
      },
    }).then(() => {
      setSwapsSkip(currentLength);
    });
  };

  if (poolLoading) return <div>Loading pool info...</div>;
  if (poolError) return <div>Error loading pool: {poolError.message}</div>;
  if (!poolData?.pool) return <div>Pool not found.</div>;

  const pool = poolData.pool;

  return (
    <div style={{ padding: '20px' }}>
      <section>
        <h2>
          {pool.token0.symbol} / {pool.token1.symbol} Pool (Fee: {pool.feeTier / 10000}%)
        </h2>
        <p>
          <strong>Liquidity:</strong> {parseInt(pool.liquidity).toLocaleString()}
        </p>
        <p>
          <strong>24h Volume USD:</strong> $
          {parseFloat(pool.volumeUSD).toLocaleString(undefined, {
            maximumFractionDigits: 0,
          })}
        </p>
      </section>

      <section style={{ marginTop: '40px' }}>
        <h3>Recent Swaps</h3>
        {swapsError && <p>Error loading swaps: {swapsError.message}</p>}
        {swapsLoading && <p>Loading swaps...</p>}
        <ul>
          {swapsData?.swaps.map((swap) => (
            <li key={swap.id} style={{ marginBottom: '10px', borderBottom: '1px solid #eee', paddingBottom: '5px' }}>
              <div>Tx: {swap.transaction.id.slice(0, 10)}...</div>
              <div>
                Amounts: {parseFloat(swap.amount0).toFixed(4)} {pool.token0.symbol} /{' '}
                {parseFloat(swap.amount1).toFixed(4)} {pool.token1.symbol}
              </div>
              <div>Value: ${parseFloat(swap.amountUSD).toFixed(2)}</div>
              <div>Time: {new Date(parseInt(swap.timestamp) * 1000).toLocaleString()}</div>
            </li>
          ))}
        </ul>
        <button onClick={handleLoadMore} disabled={swapsLoading}>
          {swapsLoading ? 'Loading...' : 'Load More Swaps'}
        </button>
      </section>
    </div>
  );
};

踩坑记录

  1. CORS错误:如前所述,在浏览器中直接调用The Graph托管服务API会遇到CORS。解决方法:在开发环境配置本地代理(如Vite的 server.proxy),在生产环境可以考虑使用Cloudflare Worker、AWS Lambda等无服务器函数作为代理,或者寻找支持CORS的公共网关(有些社区提供)。
  2. 查询返回 null:我传入一个正确的合约地址,但 pool 查询返回 null原因:子图索引的ID可能不是合约地址本身,而是小写格式。另外,有些池子可能因为索引延迟或尚未被索引而不存在。解决方法:确保ID格式正确(全小写),并检查子图是否已经同步到最新区块。可以在The Graph Explorer中先用相同ID测试查询。
  3. 类型生成失败:运行 graphql-codegen 时失败,报错“无法获取schema”。原因:网络问题或端点URL错误。解决方法:检查 codegen.yml 中的 schema URL是否正确且可访问。有时需要科学上网。也可以先将schema下载到本地文件,然后指向本地文件路径。
  4. 分页性能与 skip 限制:使用 skip 参数进行深度分页(例如 skip: 10000)在The Graph上可能非常慢甚至超时,因为底层数据库查询效率问题。解决方法:尽量避免大数值的 skip。推荐使用基于游标(cursor)的分页,即使用 where: { id_gt: $lastId }orderBy: id。但需要注意的是,这要求子图的Schema设计支持这种模式,并非所有查询都适用。

小结

这次实战让我彻底打通了从前端到链上索引数据的管道。The Graph + Apollo Client + GraphQL Codegen 的组合,为Web3前端提供了一套类型安全、高效且强大的数据查询方案。下一步,我计划深入研究子图的定义和部署,为自己项目的合约定制专属索引,从而解锁更复杂的数据展示和分析功能。

JavaScript设计模式(九):工厂模式实现与应用

在 JavaScript 中,有一个 new 操作符,用于创建对象,经常会写各种“创建对象”的代码,比如:

  • 创建不同类型的消息提示组件,比如 SuccessMessageErrorMessageWarningMessage
  • 创建不同类型的表单项,比如 InputSelectTreeSelectDatePicker
  • 创建不同平台的上传实例,比如本地上传、阿里云上传、七牛云上传。
  • 创建不同环境下的请求实例,比如开发环境的 mock 请求实例、测试环境实例、生产环境实例。

刚开始这些创建逻辑看起来都不复杂,直接 new 一下就完事了。

但项目一旦复杂起来,你很快就会发现一个问题:对象的创建逻辑也会越来越乱

比如你有 successwarningerror 三种消息提示,一开始可能只是在某个地方 new SuccessMessage(),后面随着项目变大,很多地方都开始根据 type 去写 if-elseswitch 来创建对象。再往后如果还要加默认配置、埋点、主题、平台差异,那这些“创建逻辑”就会慢慢变成一团。

这种场景,就很适合用 工厂模式

1、工厂模式定义

工厂模式的核心思想就是:把对象的创建过程封装起来,对外只暴露一个统一的创建入口。

通俗点来讲就是:

  • 你告诉工厂“我要什么对象”。
  • 工厂负责决定“具体该怎么创建”。
  • 外部不用关心里面到底是 new 了哪个类,也不用关心初始化细节。

它的重点不在“对象本身”,而在“把创建对象这件事集中管理起来”。

2、核心思想

  1. 创建逻辑收敛:不要让对象创建逻辑散落在业务代码各处。
  2. 使用者和具体类解耦:使用者只关心“我要什么”,不关心“它具体是谁创建出来的”。
  3. 方便扩展:后面新增一种对象类型时,通常只需要改工厂,而不需要满项目去找 new

3、例子:创建不同类型的消息提示组件

在前端项目里,消息提示组件特别常见,比如:

  • 成功提示 success
  • 失败提示 error
  • 警告提示 warning

看起来只是几个不同样式的小组件,但如果这些对象的创建逻辑散落在业务代码里,后面就很难统一管理。

3.1 不用工厂模式(到处自己 new)

不用工厂模式的代码,大概率会先这么写:

class SuccessMessage {
  constructor(text) {
    this.text = text;
    this.type = 'success';
  }

  show() {
    console.log(`[success]: ${this.text}`);
  }
}

class ErrorMessage {
  constructor(text) {
    this.text = text;
    this.type = 'error';
  }

  show() {
    console.log(`[error]: ${this.text}`);
  }
}

class WarningMessage {
  constructor(text) {
    this.text = text;
    this.type = 'warning';
  }

  show() {
    console.log(`[warning]: ${this.text}`);
  }
}

saveBtn.onclick = () => {
  const message = new SuccessMessage('保存成功');
  message.show();
};

submitBtn.onclick = async () => {
  if (!validateForm()) {
    const message = new WarningMessage('表单校验未通过');
    message.show();
    return;
  }

  try {
    const res = await submitForm();

    if (res.code === 0) {
      const message = new SuccessMessage('提交成功');
      message.show();
    } else {
      const message = new ErrorMessage(res.message || '提交失败');
      message.show();
    }
  } catch (error) {
    const message = new ErrorMessage('网络异常,请稍后重试');
    message.show();
  }
};

uploadBtn.onclick = () => {
  const message = new WarningMessage('文件体积过大');
  message.show();
};

这种写法虽然能跑,但存在两个问题:

  1. 创建逻辑散落在业务代码各处:保存、提交、上传这些地方都在自己 new 对象。
  2. 调用方直接依赖具体类名:业务代码必须知道 SuccessMessageErrorMessageWarningMessage 这些实现细节。
  3. 后面不方便统一改规则:如果消息对象都要增加默认图标、默认时长、主题配置,就得一处处去改。也就是说,如果 new SuccessMessage 需要传一些默认参数,比如 new SuccessMessage('保存成功', { icon: 'xxx.png', duration: 2000, theme: 'dark' }),那么所有 SuccessMessage 都需要改,维护起来就比较麻烦。

3.2 使用工厂模式

更合理一点的做法是,把“创建消息对象”这件事单独收敛到工厂里。

先定义不同类型的消息对象:

class SuccessMessage {
  constructor(text) {
    this.text = text;
    this.type = 'success';
  }

  show() {
    console.log(`[success]: ${this.text}`);
  }
}

class ErrorMessage {
  constructor(text) {
    this.text = text;
    this.type = 'error';
  }

  show() {
    console.log(`[error]: ${this.text}`);
  }
}

class WarningMessage {
  constructor(text) {
    this.text = text;
    this.type = 'warning';
  }

  show() {
    console.log(`[warning]: ${this.text}`);
  }
}

然后定义一个统一的工厂:

class MessageFactory {
  static create(type, text) {
    if (type === 'success') {
      return new SuccessMessage(text);
    }

    if (type === 'error') {
      return new ErrorMessage(text);
    }

    if (type === 'warning') {
      return new WarningMessage(text);
    }

    throw new Error(`Unknown message type: ${type}`);
  }
}

业务代码里就不需要再自己 new 具体类了:

const message1 = MessageFactory.create('success', '保存成功');
message1.show();

const message2 = MessageFactory.create('error', '保存失败');
message2.show();

这样改造之后,代码的职责就清楚很多了:

  • SuccessMessageErrorMessageWarningMessage 只负责各自的展示逻辑。
  • MessageFactory 只负责统一创建对象。
  • 业务代码只负责“使用对象”,而不用关心对象是怎么来的。

这就是工厂模式最核心的价值:把“对象怎么创建”从“对象怎么使用”里拆出来。

3.3 工厂模式真正解决的是什么?

很多同学第一次看工厂模式,会觉得它只是“帮你少写几个 new”。

其实不是。

工厂模式真正解决的是:把对象创建逻辑收敛,让变化尽量集中发生。

比如后面需求变了:

  • success 消息要自动带一个绿色图标。
  • error 消息要统一上报错误日志。
  • 所有消息实例都要带默认展示时长。

如果没有工厂,这些创建细节可能散落在很多地方;但如果有工厂,你只需要改工厂或者改具体产品类,外部调用方式基本不用动。

也就是说,工厂模式的价值从来都不只是“简化创建”,更重要的是“统一管理创建逻辑”。

4、JavaScript 里怎么理解工厂模式

JavaScript 里,工厂模式不一定非得写成 class 形式。

很多时候,我们更常见的是“工厂函数”:

function createMessage(type, text) {
  if (type === 'success') {
    return {
      type,
      text,
      show() {
        console.log(`[success]: ${text}`);
      }
    };
  }

  if (type === 'error') {
    return {
      type,
      text,
      show() {
        console.log(`[error]: ${text}`);
      }
    };
  }

  throw new Error(`Unknown message type: ${type}`);
}

const message = createMessage('success', '操作成功');
message.show();

这种写法本质上也是工厂模式,因为你依然是在做同一件事:

  • 对外提供统一创建入口。
  • 内部封装对象创建细节。
  • 外部只拿结果,不关心内部过程。

所以在 JavaScript 里理解工厂模式,不要把注意力只放在“是不是 class”,更重要的是看:你有没有把对象创建逻辑集中封装起来。

顺手说一个大家其实天天都在用的例子:

const div = document.createElement('div');
const span = document.createElement('span');

document.createElement() 本质上就是一种很典型的工厂思想:

  • 你只告诉它“我要什么标签”。
  • 浏览器负责创建对应的 DOM 对象。
  • 你并不需要自己 new HTMLDivElement()

5、工厂模式和 new / 构造函数的区别

很多同学学到这里,还会有一个很自然的问题:工厂模式和直接 new 有什么区别?

区别就在于关注点不一样:

  • 直接 new:你必须知道具体要实例化哪个类。
  • 工厂模式:你只需要表达“我要什么对象”,至于具体实例化哪个类,交给工厂决定。

比如下面这两种写法:

const message1 = new SuccessMessage('保存成功');
const message2 = MessageFactory.create('success', '保存成功');

看起来都能拿到对象,但它们的含义不一样:

  • new SuccessMessage() 说明调用方知道具体类名,也直接依赖这个类。
  • MessageFactory.create('success') 说明调用方只表达需求,不直接依赖具体实现类。

所以工厂模式不是为了替代 new,而是为了在合适的场景下,把 new 隐藏到更合适的地方去

6、工厂模式和策略模式的区别

工厂模式和策略模式都很容易出现 type 判断,所以很多同学会把这两个模式搞混。

但它们解决的问题并不一样:

  • 工厂模式:更关注“创建什么对象”。
  • 策略模式:更关注“选择哪种算法或行为”。

你可以简单理解为:

  • 工厂模式是在回答:“这次我要造谁?”
  • 策略模式是在回答:“这次我要怎么做?”

举个很直观的例子:

  • 根据 type 创建 SuccessMessageErrorMessageWarningMessage,这是工厂模式
  • 根据 type 选择不同支付逻辑、校验逻辑、请求策略,这是策略模式

所以一个偏“创建”,一个偏“行为选择”。

7、抽象工厂是什么?

前面讲的这种写法,本质上更接近我们平时最常说的简单工厂思路:给你一个统一入口,然后根据条件创建某一个对象。

但工厂模式往上再走一步,其实还有一个很经典的概念,叫做抽象工厂

抽象工厂你可以先不用把它想得太玄乎,它本质上就是:

不只是创建一个对象,而是创建一整组彼此有关联的对象。

如果说前面的 MessageFactory 更像是在回答:

  • “这次我要创建哪一种消息组件?”

那抽象工厂更像是在回答:

  • “这次我要创建哪一整套组件?”

比如在前端项目里,这种场景就特别常见:

  • 你有一套 PC 端组件。
  • 你有一套 Mobile 端组件。
  • 每一套里都包含 ButtonDialogInput 等一组相关组件。

这时候你要解决的,就不是“创建一个按钮”这么简单了,而是“创建一整套同风格、同平台、彼此配套的组件族”。

7.1 一个前端里很常见的例子

假设我们现在有两套 UI 体系:

  • PC 端组件
  • Mobile 端组件

每套体系里,都有两个相关组件:

  • Button
  • Dialog

JavaScript 里虽然没有 abstract class 这种语法,但我们完全可以用普通类来表达“抽象层”,只是在那些不希望被直接使用的方法里,手动 throw new Error

你可以把这种写法理解成:JavaScript 的普通类,去模拟抽象类的约束能力。

可以把它写成这样:

class AbstractButton {
  render() {
    throw new Error('render 方法必须由子类实现');
  }
}

class AbstractDialog {
  render() {
    throw new Error('render 方法必须由子类实现');
  }
}

class AbstractUIFactory {
  createButton() {
    throw new Error('createButton 方法必须由子类实现');
  }

  createDialog() {
    throw new Error('createDialog 方法必须由子类实现');
  }
}

class PcButton extends AbstractButton {
  render() {
    console.log('渲染 PC 按钮');
  }
}

class PcDialog extends AbstractDialog {
  render() {
    console.log('渲染 PC 弹窗');
  }
}

class MobileButton extends AbstractButton {
  render() {
    console.log('渲染 Mobile 按钮');
  }
}

class MobileDialog extends AbstractDialog {
  render() {
    console.log('渲染 Mobile 弹窗');
  }
}

class PcUIFactory extends AbstractUIFactory {
  createButton() {
    return new PcButton();
  }

  createDialog() {
    return new PcDialog();
  }
}

class MobileUIFactory extends AbstractUIFactory {
  createButton() {
    return new MobileButton();
  }

  createDialog() {
    return new MobileDialog();
  }
}

这里的关系其实就很清楚了:

  • AbstractButtonAbstractDialog 是抽象产品。
  • AbstractUIFactory 是抽象工厂。
  • PcUIFactoryMobileUIFactory 是具体工厂。
  • PcButtonPcDialogMobileButtonMobileDialog 是具体产品。

业务代码里只需要选择当前要用哪一套工厂:

function createPage(factory) {
  const button = factory.createButton();
  const dialog = factory.createDialog();

  button.render();
  dialog.render();
}

createPage(new PcUIFactory());
createPage(new MobileUIFactory());

这样做的好处很明显:

  • 你切换的是“一整套产品族”,而不是一个个零散产品。
  • PC 按钮一定搭配 PC 弹窗。
  • Mobile 按钮一定搭配 Mobile 弹窗。
  • 外部不用自己关心这一套里每个对象该怎么创建。

这就是抽象工厂模式最核心的点:它不是在创建单个对象,而是在创建一组相互关联、相互匹配的对象。

7.2 抽象工厂和普通工厂的区别

很多同学第一次学到这里,会觉得:这不还是工厂吗?

是的,它当然还是工厂思想,但它解决的问题比普通工厂更大一层。

你可以这样理解:

  • 普通工厂 / 简单工厂:更像是在创建“一个产品”。
  • 抽象工厂:更像是在创建“一整个产品族”。

比如:

  • 创建一个 success message,这是普通工厂更擅长的事。
  • 创建一整套 PC UIMobile UI 组件,这是抽象工厂更擅长的事。

所以它们的区别,不是“谁更高级”,而是抽象层级不一样

7.3 抽象工厂适合什么场景?

抽象工厂通常适合下面这类场景:

  1. 系统里存在多套彼此对应的产品族。
  2. 这些产品之间有明显的配套关系,不能随便混用。
  3. 你希望切换的是一整套规则,而不是某一个单独对象。

像前端里这些场景,其实都挺适合:

  • PC 端和 Mobile 端组件体系。
  • 浅色主题和深色主题组件体系。
  • 国内环境和海外环境下的一整套服务实例。

所以如果说前面的工厂模式解决的是“对象怎么统一创建”,那抽象工厂解决的就是“一整套对象怎么统一创建”。

8、工厂模式的优缺点

8.1 优点:

  • 解耦性强:使用者不需要依赖具体类名。
  • 创建逻辑集中管理:对象创建规则可以统一收敛。
  • 便于扩展:新增一种对象类型时,通常不需要满项目改调用代码。
  • 代码更清晰:业务层更专注“使用对象”,而不是“创建对象”。

8.2 缺点:

  • 会增加一层抽象:相比直接 new,工厂模式会多一层封装。
  • 简单场景可能显得有点重:如果对象创建本来就非常简单,硬加工厂未必划算。
  • 工厂本身也可能变复杂:如果所有创建逻辑都堆在一个大工厂里,工厂类本身也会越来越臃肿。

9、工厂模式的应用

工厂模式在前端和日常业务开发里其实非常常见,比如:

  1. 根据不同 type 创建不同类型的消息提示组件、弹窗组件。
  2. 根据配置动态创建表单项,比如 InputSelectTreeSelectDatePicker
  3. 根据运行环境创建不同请求实例,比如开发环境的 mock 请求实例、测试环境实例、生产环境实例。
  4. 根据平台创建不同上传器实例,比如本地上传、阿里云上传、七牛云上传。
  5. 浏览器里的 document.createElement(),本质上也带有非常典型的工厂思想。

小结

上面介绍了Javascript中非常经典的工厂模式,它的核心思想就是:把对象创建逻辑封装起来,对外提供统一的创建入口,从而让使用者和具体实现解耦。

对于前端开发来说,工厂模式非常实用,像消息组件创建、表单项创建、上传器创建、请求实例创建这些场景里,都能看到它的影子。它本质上就是帮我们把“怎么创建对象”从“怎么使用对象”里拆开,这样代码会更清晰,后面扩展起来也更从容。

如果系统里不仅仅是创建“一个对象”,而是要创建“一整套相互配套的对象”,那就可以继续往抽象工厂的方向去设计。你可以简单把它理解成:普通工厂负责造单品,抽象工厂负责造整套。

往期回顾

Node.js 从入门到上线实战指南(零基础 → 高手)

覆盖:Node 基础、ES6 模块化、npm、Express、MongoDB、项目实战、部署上线、HTTPS 证书配置


一、什么是 Node.js?

Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行环境,让 JS 能运行在服务器端。

核心特点

  • 单线程 + 事件循环(Event Loop)
  • 非阻塞 I/O(高并发)
  • npm 生态强大

二、环境准备

1. 安装 Node.js

官网下载安装(LTS版本)

node -v
npm -v

三、Node.js 基础(零基础)

1. 第一个程序

console.log("Hello Node.js");

运行:

node app.js

2. 内置模块

const fs = require("fs");

fs.readFile("test.txt", "utf-8", (err, data) => {
  console.log(data);
});

常用模块:

  • fs(文件)
  • path(路径)
  • http(服务)

3. 创建 HTTP 服务(原生)

const http = require("http");

http.createServer((req, res) => {
  res.end("hello world");
}).listen(3000);

四、ES6 模块化(现代写法)

1. 开启模块化

{
  "type": "module"
}

2. 使用 import/export

// math.js
export const add = (a, b) => a + b;

// app.js
import { add } from "./math.js";
console.log(add(1, 2));

五、npm(包管理)

1. 初始化项目

npm init -y

2. 安装依赖

npm install express
npm install -D nodemon

3. scripts 脚本

"scripts": {
  "dev": "nodemon app.js",
  "start": "node app.js"
}

六、Express 框架(重点)

1. 快速启动服务

import express from "express";

const app = express();

app.get("/", (req, res) => {
  res.send("Hello Express");
});

app.listen(3000);

2. 路由系统

app.get("/user", (req, res) => {
  res.json({ name: "Tom" });
});

3. 静态资源

app.use(express.static("public"));

4. 中间件

app.use(express.json());

app.use((req, res, next) => {
  console.log("请求来了");
  next();
});

七、MongoDB 数据库

1. 启动 MongoDB(Docker 推荐)

docker run -d -p 27017:27017 mongo

2. 使用 mongoose

npm install mongoose

3. 连接数据库

import mongoose from "mongoose";

mongoose.connect("mongodb://localhost:27017/test");

4. 定义模型

const UserSchema = new mongoose.Schema({
  name: String,
  age: Number
});

const User = mongoose.model("User", UserSchema);

5. CRUD

// 新增
await User.create({ name: "Tom", age: 20 });

// 查询
const users = await User.find();

八、项目实战(REST API)

1. 项目结构

project/
├── app.js
├── routes/
├── models/
├── public/

2. 示例 API

app.get("/api/users", async (req, res) => {
  const users = await User.find();
  res.json(users);
});

九、项目部署上线


1. Linux 服务器准备

sudo apt update
sudo apt install nodejs npm

2. 使用 PM2(进程管理)

npm install -g pm2
pm2 start app.js
pm2 save
pm2 startup

3. 使用 Nginx 反向代理

server {
  listen 80;

  location / {
    proxy_pass http://localhost:3000;
  }
}

十、HTTPS 证书配置(重点)

使用 Let's Encrypt 免费证书


1. 安装 certbot

sudo apt install certbot python3-certbot-nginx

2. 申请证书

sudo certbot --nginx

3. 自动续期

certbot renew --dry-run

十一、生产优化

1. 环境变量

NODE_ENV=production

2. 日志管理

使用 winston / morgan


3. 安全

npm install helmet

4. 跨域

import cors from "cors";
app.use(cors());

十二、总结

从零到上线你学会了:

  • Node.js 基础
  • ES6 模块化
  • npm 管理
  • Express 框架
  • MongoDB 数据库
  • REST API
  • PM2 部署
  • Nginx 反向代理
  • HTTPS 证书

🚀 进阶方向

  • 微服务架构
  • Docker 容器化
  • K8s 部署
  • GraphQL
  • WebSocket 实时通信

🚀 进阶方向(从工程到架构)

当你掌握了 Node.js + Express + MongoDB 的基础后,下一步要从“能写接口”进阶到“能做系统架构”。


一、微服务架构(Microservices)

📌 什么是微服务?

把一个大型系统拆分成多个小服务,每个服务独立运行:

用户服务  →  登录 / 注册
订单服务  →  下单 / 支付
商品服务  →  商品管理

🧱 Node.js 实现方式

最简单通信方式(HTTP):

// user-service
app.get("/user", (req, res) => {
  res.json({ id: 1, name: "Tom" });
});

// order-service 调用 user-service
import axios from "axios";

const user = await axios.get("http://localhost:3001/user");

🚀 常用技术

  • API Gateway(网关)
  • 服务注册发现(Consul / Nacos)
  • 消息队列(RabbitMQ / Kafka)

🎯 优点

  • 可独立部署
  • 高扩展性
  • 团队协作友好

二、Docker 容器化

📌 为什么要用 Docker?

解决“我本地能跑,你那跑不了”的问题


🐳 Dockerfile 示例

FROM node:18

WORKDIR /app

COPY . .

RUN npm install

EXPOSE 3000

CMD ["node", "app.js"]

▶️ 构建运行

docker build -t my-node-app .
docker run -p 3000:3000 my-node-app

📦 docker-compose(推荐)

version: "3"
services:
  app:
    build: .
    ports:
      - "3000:3000"
  mongo:
    image: mongo

🎯 优点

  • 环境一致
  • 快速部署
  • 易扩展

三、Kubernetes(K8s)部署

📌 是什么?

容器编排平台,用来管理大量 Docker 容器


🧱 Deployment 示例

apiVersion: apps/v1
kind: Deployment
metadata:
  name: node-app
spec:
  replicas: 3
  selector:
    matchLabels:
      app: node
  template:
    metadata:
      labels:
        app: node
    spec:
      containers:
        - name: node-app
          image: my-node-app
          ports:
            - containerPort: 3000

🌐 Service 暴露服务

apiVersion: v1
kind: Service
metadata:
  name: node-service
spec:
  type: NodePort
  selector:
    app: node
  ports:
    - port: 3000
      targetPort: 3000

🎯 能力

  • 自动扩容(HPA)
  • 服务发现
  • 灰度发布

四、GraphQL(替代 REST)

📌 为什么用 GraphQL?

客户端可以“按需获取数据”


🚀 Node 示例

npm install graphql express-graphql
import { graphqlHTTP } from "express-graphql";
import { buildSchema } from "graphql";

const schema = buildSchema(`
  type Query {
    hello: String
  }
`);

const root = {
  hello: () => "Hello GraphQL"
};

app.use("/graphql", graphqlHTTP({
  schema,
  rootValue: root,
  graphiql: true
}));

🎯 优点

  • 减少接口数量
  • 前端更灵活
  • 强类型

五、WebSocket 实时通信

📌 应用场景

  • 聊天系统
  • 实时通知
  • 在线游戏

🚀 Node 实现(ws)

npm install ws
import { WebSocketServer } from "ws";

const wss = new WebSocketServer({ port: 8080 });

wss.on("connection", (ws) => {
  ws.send("连接成功");

  ws.on("message", (msg) => {
    console.log("收到:", msg.toString());
  });
});

🎯 特点

  • 双向通信
  • 实时性强
  • 比 HTTP 更高效

🧠 进阶学习路线总结

Node基础
   ↓
Express + MongoDB
   ↓
REST API
   ↓
Docker(容器化)
   ↓
微服务架构
   ↓
K8s(集群部署)
   ↓
GraphQL / WebSocket(高级能力)

🎯 最终目标

👉 从“写接口”升级为:

  • ✔ 架构设计能力
  • ✔ 分布式系统思维
  • ✔ 可扩展系统构建能力

🚀 一句话总结

真正的 Node.js 高手,不只是会写接口,而是能设计系统。


计算机网络基础知识-WebSocket

WebSocket 协议概述

WebSocket 是 HTML5 提供的一种浏览器与服务器进行全双工通讯的网络技术,属于应用层协议,基于 RFC 6455 标准。

核心工作机制

WebSocket 基于 TCP 传输协议,并复用 HTTP 的握手通道。浏览器和服务器只需要完成一次握手,两者之间就可以创建持久性的连接,并进行双向数据传输。

WebSocket 的出现解决了半双工通信的弊端,其最大特点是:服务器可以向客户端主动推送消息,客户端也可以主动向服务器推送消息。

WebSocket 原理

客户端向 WebSocket 服务器通知一个带有所有接收者 ID 的事件,服务器接收后立即通知所有活跃的客户端,只有 ID 在接收者 ID 序列中的客户端才会处理这个事件。

WebSocket 核心特性

  1. 支持双向通信,实时性更强:客户端和服务器可以同时发送和接收数据。
  2. 可以发送文本,也可以发送二进制数据:支持多种数据类型传输。
  3. 建立在 TCP 协议之上,服务端的实现比较容易:基于成熟的 TCP 协议栈。
  4. 数据格式比较轻量,性能开销小,通信高效:最小头部仅 2 字节,传输效率高。
  5. 没有同源限制,客户端可以与任意服务器通信:跨域通信能力(需服务器允许)。
  6. 协议标识符是 ws(如果加密,则为 wss),服务器网址就是 URL:统一的 URL 标识方案。
  7. 与 HTTP 协议有着良好的兼容性:默认端口也是 80/443,握手阶段使用 HTTP 协议,因此握手时不容易屏蔽,能通过各种 HTTP 代理服务器。

WebSocket 握手过程

客户端请求

WebSocket 握手基于 HTTP 协议升级机制,客户端发送升级请求:

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Version: 13

服务器响应

服务器验证请求后返回协议切换响应:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=

Sec-WebSocket-Accept 的生成算法为:base64(sha1(Sec-WebSocket-Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))

WebSocket 数据帧格式

WebSocket 协议的核心是轻量级的数据帧格式:

 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5
+-+-+-+-+-------+-+-------------+
|F|R|R|R| opcode|M| Payload Len |
|I|S|S|S|  (4)  |A|     (7)     |
|N|V|V|V|       |S|             |
| |1|2|3|       |K|             |
+-+-+-+-+-------+-+-------------+

关键字段说明

  • FIN(1bit):标识是否为消息的最后一帧
  • Opcode(4bit):帧类型标识
    • 0x0:延续帧(分片消息的中间帧)
    • 0x1:文本帧(UTF-8编码)
    • 0x2:二进制帧
    • 0x8:关闭连接帧
    • 0x9:Ping帧(心跳检测)
    • 0xA:Pong帧(心跳响应)
  • Mask(1bit):是否使用掩码(客户端到服务器必须为1)
  • Payload Len:数据负载长度

WebSocket 心跳机制

为了保持连接活跃,WebSocket 协议定义了 Ping/Pong 帧用于心跳检测:

// 服务器定时发送心跳
setInterval(() => {
    if (ws.readyState === WebSocket.OPEN) {
        ws.ping('heartbeat');
    }
}, 25000);

// 客户端自动回复
ws.on('pong', (data) => {
    console.log('连接状态正常');
});

推荐的心跳间隔为 25-30 秒,避免与常见的网络超时设置冲突。

WebSocket 客户端实现

基本使用

// 创建WebSocket连接
const ws = new WebSocket('wss://api.example.com/chat');

// 连接建立事件
ws.onopen = (event) => {
    console.log('WebSocket连接已建立');
    ws.send(JSON.stringify({type: 'join', room: 'general'}));
};

// 消息接收事件
ws.onmessage = (event) => {
    const data = JSON.parse(event.data);
    console.log('收到消息:', data);
};

// 错误处理
ws.onerror = (error) => {
    console.error('连接错误:', error);
};

// 连接关闭事件
ws.onclose = (event) => {
    console.log(`连接关闭: ${event.code} - ${event.reason}`);
};

连接状态管理

WebSocket 连接有4种状态:

  • CONNECTING(0):连接正在建立
  • OPEN(1):连接已建立,可以通信
  • CLOSING(2):连接正在关闭
  • CLOSED(3):连接已关闭

即时通讯技术对比分析

短轮询(Short Polling)

基本思路:浏览器每隔一段时间向服务器发送 HTTP 请求,服务器在收到请求后,不论是否有数据更新,都直接进行响应。

工作机制:这种方式实现的即时通信,本质上还是浏览器发送请求、服务器接受请求的一个过程,通过让客户端不断地进行请求,使得客户端能够模拟实时地收到服务器端的数据的变化。

优点:实现简单,易于理解,兼容性最好。

缺点:由于需要不断的建立 HTTP 连接,严重浪费了客户端和服务器端的资源。当用户增加时,服务器端的压力就会变大。

长轮询(Long Polling)

基本思路:首先由客户端向服务器发起请求,当服务器收到客户端发来的请求后不会直接进行响应,而是先将这个请求挂起,然后判断数据是否有更新。如果有更新,则进行响应;如果一直没有数据,则到达一定的时间限制才返回。客户端 JavaScript 响应处理函数会在处理完服务器返回的信息后,再次发出请求,重新建立连接。

优点:长轮询和短轮询比起来,明显减少了很多不必要的 HTTP 请求次数,相比之下节约了资源。

缺点:连接挂起也会导致资源的浪费。

服务器发送事件(SSE)

基本思路:服务器使用流信息向客户端推送信息。严格地说,HTTP 协议无法做到服务器主动推送信息。但是,有一种变通方法,就是服务器向客户端声明,接下来要发送的是流信息。也就是说,发送的不是一次性的数据包,而是一个数据流,会连续不断地发送过来。这时,客户端不会关闭连接,会一直等着服务器发过来的新的数据流,视频播放就是这样的例子。SSE 就是利用这种机制,使用流信息向客户端推送信息。

优点:SSE 相对于前面两种方式来说,不需要建立过多的 HTTP 请求,相比之下节约了资源。

限制:SSE 基于 HTTP 协议,目前除了 IE/Edge,其他浏览器都支持。SSE 是单向通信的,只能由服务器向客户端推送信息,如果客户端需要发送信息就是属于下一个 HTTP 请求了。

WebSocket

基本思路:WebSocket 是 HTML5 定义的一个新协议,与传统的 HTTP 协议不同,该协议允许由服务器主动向客户端推送信息。WebSocket 是一个全双工的协议,也就是通信双方是平等的,可以相互发送消息。

优点:真正的实时双向通信,延迟最低,性能最优。

缺点:在服务器端的配置比较复杂,兼容性相对较差。

四种通信协议深度对比

协议基础对比

对比维度 短轮询 长轮询 SSE WebSocket
协议基础 HTTP HTTP HTTP TCP
通信方式 客户端主动查询 服务器挂起响应 服务器单向推送 全双工双向
连接建立 每次请求新建 每次请求新建 一次连接持久 一次握手持久
数据流向 双向(请求-响应) 双向(请求-响应) 单向(服务器→客户端) 双向同时

性能对比分析

性能指标 短轮询 长轮询 SSE WebSocket
实时性 低(取决于轮询间隔) 中(服务器有数据立即响应) 高(服务器主动推送) 最高(真正实时)
带宽效率 低(大量无效请求) 中(减少无效请求) 高(流式传输) 最高(轻量级帧)
服务器负载 高(频繁建立连接) 中(连接挂起消耗) 低(持久连接) 最低(一次连接)
客户端资源 高(频繁请求) 中(连接管理复杂) 低(自动处理) 最低(事件驱动)

兼容性与适用场景

方案 浏览器兼容性 适用场景 不适用场景
短轮询 完美兼容所有浏览器 兼容性要求极高、数据更新频率低 高频实时通信
长轮询 很好兼容 中等实时性要求、无法使用 WebSocket 需要真正实时双向通信
SSE 良好(除IE/Edge) 服务器向客户端单向推送、新闻推送、监控 需要客户端主动发送数据
WebSocket 良好(现代浏览器) 聊天、游戏、实时交易、高频数据交换 兼容性要求极高、服务器不支持

选择依据总结

选择 WebSocket,当

  • 需要真正的实时双向通信
  • 数据交换频率高(每秒多次)
  • 对延迟敏感的应用
  • 需要传输二进制数据

选择其他方案,当

  • 兼容性要求极高 → 短轮询
  • 服务器环境不支持 WebSocket → 长轮询
  • 只需要服务器向客户端的单向推送 → SSE

技术选型指南

从性能的角度来看:WebSocket > 长连接(SSE) > 长轮询 > 短轮询

如果考虑浏览器的兼容性问题:短轮询 > 长轮询 > 长连接(SSE) > WebSocket

要根据具体的使用场景来判断使用哪种方式

  • 对于实时性要求极高的场景(如在线游戏、金融交易),优先选择 WebSocket
  • 对于只需要服务器推送的场景(如新闻推送、监控),SSE 是更好的选择
  • 在兼容性要求极高的环境中,短轮询或长轮询是必要的备选方案

总结

WebSocket 协议通过一次握手建立持久连接,实现了真正的全双工实时通信。其轻量级的数据帧格式和内置的心跳机制,使其在高频数据交换场景中具有显著优势。

在实际应用中,需要根据具体的业务需求、性能要求和兼容性约束选择最合适的实时通信方案。WebSocket 特别适合需要低延迟、高频双向通信的场景,而其他方案在特定场景下也有各自的优势。

通过深入理解各种通信协议的特点和适用场景,可以做出更合理的技术选型决策,为用户提供优质的实时交互体验。

购物车数字怎么更新?一个前端问题的三种架构答案

在做电商项目的时候,有一个看起来很小的问题:用户在商品页加了一件东西进购物车,Header 右上角的数字要 +1。

这个需求本身不复杂,但我在三个不同的项目里,见到了三种完全不同的解法。每一种解法背后,都是一套不同的架构决策——状态到底归谁管?

这篇文章是我在读 Medusa(一个开源电商 SaaS)源码时引发的思考,整理了我对这个问题的理解过程。


先定义清楚这个问题

"Header 购物车数字怎么更新",本质上是一个跨组件状态同步问题:

商品页的"加入购物车"按钮(触发方)
         ↓
    购物车数量变了
         ↓
Header 的数字组件(响应方)

这两个组件在页面上没有直接的父子关系,但需要共享同一份数据。

不同架构对这个问题的回答,决定了整个前端的数据流长什么样。


解法一:客户端拥有状态(Monorepo 单体前端)

方案描述

在一个 Monorepo 单体前端项目里,一种常见的做法是用 useReducer + SessionStorage 来管理跨页面的状态:

// 环境:React + Next.js(Pages Router)
// 场景:用 useReducer 管理 cart 状态,并持久化到 SessionStorage

type CartState = {
  items: CartItem[]
  totalCount: number
}

type CartAction =
  | { type: 'ADD_ITEM'; payload: CartItem }
  | { type: 'REMOVE_ITEM'; payload: string }
  | { type: 'INIT'; payload: CartState }

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM':
      return {
        ...state,
        items: [...state.items, action.payload],
        totalCount: state.totalCount + action.payload.quantity,
      }
    case 'REMOVE_ITEM':
      return {
        ...state,
        items: state.items.filter(item => item.id !== action.payload),
        totalCount: state.totalCount - 1,
      }
    case 'INIT':
      return action.payload
    default:
      return state
  }
}

// 每次 dispatch 同步写入 SessionStorage
function useCartWithSession() {
  const [state, dispatch] = useReducer(cartReducer, { items: [], totalCount: 0 })

  const persistedDispatch = (action: CartAction) => {
    dispatch(action)
    const nextState = cartReducer(state, action)
    sessionStorage.setItem('cart', JSON.stringify(nextState))
  }

  // 页面初始化时从 SessionStorage 读取
  useEffect(() => {
    const saved = sessionStorage.getItem('cart')
    if (saved) {
      persistedDispatch({ type: 'INIT', payload: JSON.parse(saved) })
    }
  }, [])

  return { state, dispatch: persistedDispatch }
}

数据流:

用户点击"加入购物车"
       ↓
dispatch(ADD_ITEM)
       ↓
reducer 更新内存中的 state
       ↓
同步写入 SessionStorage
       ↓
所有订阅了这个 context 的组件重渲染
       ↓
Header 数字更新

这个方案在做什么

状态存在客户端的内存和 SessionStorage 里。内存保证当前页面的响应速度,SessionStorage 保证页面跳转后状态不丢失。

组件间的同步靠 React Context——谁订阅了这个 context,谁就能感知到 dispatch 触发的变化。

权衡

优势:

  • 直觉清晰,数据流可追踪
  • 不依赖网络,操作响应快
  • 状态变化立即反映在 UI

代价:

  • 需要手动管理"内存状态"和"持久化状态"的同步
  • 如果有多个 tab 打开,状态会不一致
  • 客户端状态和服务端实际数据可能出现偏差(比如库存已售罄但客户端不知道)

解法二:消灭状态(微前端架构)

方案描述

在另一个电商项目里,前端是微前端架构——PDP(商品详情页)、Cart、Checkout 各自是独立部署的应用,挂载在一个 CMS(内容管理系统)的页面上。

这种架构下,"跨组件状态同步"这个问题根本不存在——因为根本没有一个"全局前端"。

CMS Shell(Header 在这里)
  ├── /products/[id]  →  PDP 微前端(只管商品详情)
  ├── /cart           →  Cart 微前端(只管购物车)
  └── /checkout       →  Checkout 微前端(只管结账流程)

跨应用的状态传递靠 URL 参数

// 环境:微前端,PDP 应用
// 场景:加购后跳转到 Cart,通过 URL 传递 cart_id

async function handleAddToCart(variantId: string) {
  // 调用 cart-service 创建或更新 cart
  const response = await fetch('/api/cart', {
    method: 'POST',
    body: JSON.stringify({ variantId, quantity: 1 }),
  })
  const { cartId } = await response.json()

  // 跳转到 Cart 微前端,通过 URL 传递 cart_id
  window.location.href = `/cart?cart_id=${cartId}`
}

// Cart 微前端初始化时从 URL 读取
function CartApp() {
  const cartId = new URLSearchParams(window.location.search).get('cart_id')

  useEffect(() => {
    if (cartId) {
      // 用 cart_id 查询购物车数据,初始化页面
      fetchCartData(cartId)
    }
  }, [cartId])
}

数据流:

用户在 PDP 点击"加入购物车"
       ↓
调用 cart-service API,拿到 cart_id
       ↓
跳转到 /cart?cart_id=xxx
       ↓
Cart 微前端用 cart_id 初始化,fetch 购物车数据

这个方案在做什么

这里没有"全局状态管理",而是用物理边界把问题消灭了。

每个微前端只管自己的数据,应用之间通过 URL 传递"通行证"(cart_id),谁拿到 cart_id 谁去查数据,不需要任何前端间的状态共享。

至于 Header 的购物车数字——那是 CMS 的职责,不在这个微前端的边界内。CMS 自己有机制处理。

权衡

优势:

  • 边界极其清晰,每个应用只关心自己的事
  • 应用间没有状态污染,独立部署,独立维护
  • 技术栈可以不统一

代价:

  • 全局体验难以协调(Header 的状态由谁来维护?)
  • 跨应用通信变复杂,URL 能传递的信息有限
  • 每次跨应用跳转都是完整的页面刷新,体验有损

解法三:服务端拥有状态(单体前端 + Server Cache)

方案描述

读 Medusa 的源码时,我在找 Cart 相关的 Context——搜索 createContext,整个仓库只有两个结果:一个是 modal,一个是 Stripe 支付。Cart 根本没有用 Context。

然后我搜 revalidateTag,在 cart.ts 里找到了答案:

// 环境:Next.js App Router,Server Action
// 来源:Medusa nextjs-starter-medusa/src/lib/data/cart.ts
// 场景:加购操作

"use server"

export async function addToCart({
  variantId,
  quantity,
  countryCode,
}: {
  variantId: string
  quantity: number
  countryCode: string
}) {
  // 确保 cart 存在,没有就创建,并把 cart_id 写入 cookie
  const cart = await getOrSetCart(countryCode)

  if (!cart) {
    throw new Error("Error retrieving or creating cart")
  }

  // 调用 Medusa API 写入数据
  await sdk.store.cart
    .createLineItem(
      cart.id,
      { variant_id: variantId, quantity },
      {},
      headers
    )
    .then(async () => {
      // 操作成功后,让相关缓存失效
      const cartCacheTag = await getCacheTag("carts")
      revalidateTag(cartCacheTag)               // Cart 数据缓存失效

      const fulfillmentCacheTag = await getCacheTag("fulfillment")
      revalidateTag(fulfillmentCacheTag)        // 履约数据缓存失效
    })
  // 注意:没有返回值
}

没有返回值。函数只负责两件事:调 API 写数据,然后让缓存失效。

数据流:

用户点击"加入购物车"
       ↓
Client Component 调用 addToCart()(Server Action)
       ↓
【在服务端执行】
getOrSetCart() — 确保 cart 存在,cart_id 存入 cookie
       ↓
sdk.store.cart.createLineItem() — 调 Medusa API
       ↓
revalidateTag("carts") — 让 cart 相关缓存失效
       ↓
Next.js 自动重新 fetch 所有标记了 "carts" tag 的数据
       ↓
Header 数字、Cart 页面列表,同时自动更新

cart_id 的持久化也值得注意——它存在 cookie 里,而不是 URL 参数:

// 环境:Next.js Server Action
// 场景:创建 cart 后持久化 cart_id

async function getOrSetCart(countryCode: string) {
  // 尝试读取已有的 cart
  let cart = await retrieveCart(undefined, "id,region_id")

  if (!cart) {
    // 创建新 cart
    const cartResp = await sdk.store.cart.create(
      { region_id: region.id },
      {},
      headers
    )
    cart = cartResp.cart

    // cart_id 写入 cookie(不是 URL)
    await setCartId(cart.id)

    // 同时让缓存失效,触发 UI 更新
    const cartCacheTag = await getCacheTag("carts")
    revalidateTag(cartCacheTag)
  }

  return cart
}

这个方案在做什么

状态的真正归属地在服务端。前端不持有 cart 数据,只持有一个 cart_id(存在 cookie 里)。

每次需要数据,就去 fetch——但 Next.js 会自动缓存这个 fetch 的结果,打上 tag。当数据变化时,revalidateTag 让这个 tag 失效,所有依赖这份数据的组件在下次渲染时自动重新 fetch。

组件之间不需要任何显式的"通知"机制,因为它们都从同一个源头取数据,源头失效了大家一起重取。

权衡

优势:

  • 无需手写状态同步逻辑,Next.js 自动处理
  • 服务端数据是真正的 single source of truth
  • 跨组件共享"零成本"——读同一个 cache tag 就够了

代价:

  • 需要理解和信任 Next.js 的缓存机制
  • 实时性强的数据(库存、限时价格)需要绕过缓存直接请求
  • 出了缓存问题比较难调试

三种解法的本质对比

解法一(Reducer + SessionStorage) 解法二(微前端 + URL) 解法三(Server Cache)
状态归属 客户端内存 不存在全局状态 服务端缓存
cart_id 存在哪 SessionStorage URL 参数 Cookie
跨组件同步 React Context + dispatch 物理隔离,无需同步 revalidateTag 自动触发
Header 谁更新 订阅 context 自动更新 CMS 负责,不在前端边界内 和 cart 用同一份 cache
手写同步逻辑 需要 不需要(问题不存在) 不需要(框架处理)
状态一致性风险 客户端 vs 服务端可能偏差 每次跳转重新 fetch,一致 服务端是唯一来源,一致

怎么选?

我的理解是,这三种方案解决的不是同一个层次的问题:

解法二(微前端)适合大型组织,团队边界清晰,每个前端 app 由不同团队维护,宁愿牺牲一些全局体验,换取团队间的独立性。

解法一(Reducer + SessionStorage)适合中小型单体前端,需要快速响应、离线支持,或者还没有引入 Next.js App Router 等新范式的项目。

解法三(Server Cache)适合以 Next.js App Router 为核心的单体前端,服务端数据是可信来源,且对全局状态一致性要求高的场景。

没有绝对的优劣,选择背后是对应用边界、团队结构、实时性要求的权衡。


还没想清楚的地方

这三种方案里,AI 介入之后会发生什么?

addToCart 成功后触发 revalidateTag——如果这个时机要插入一个导购 Agent 的推荐逻辑,它应该在哪里?是 .then() 里同步执行,还是作为一个独立的事件监听,还是需要一个完全独立的"AI 介入层"?

在微前端架构里,AI 的判断结果算谁的状态?它跨越了应用边界,现有的通信机制能承载吗?

这些是我接下来想探索的问题,如果你有想法,欢迎交流。


参考资料

Ajax学习笔记

什么是Ajax

Ajax:异步刷新技术

Ajax作用:实现异步请求的技术

什么是异步请求?

同步请求:就是实时处理,比如服务器已接受客户端请求,马上响应,这样客户端可以在最短的时间内得到结果。

如同打电话,通信双方不能断(我们是同时进行,同步),你一句我一句,好处是对方表达的信息我马上能收到,但是我正在打电话,无法总别的事。

异步请求:就是分时处理,服务器接收到客户端请求后并不是立即处理,而是等待服务器比较空闲的时候加以处理,可以避免拥塞。

异步如同收发短信,对方不用保证此刻我一定在手机旁,同时,我也不用时刻留意手机有没有来短信。

对于服务器,如果客户端的同步请求过多,就会造成阻塞

服务器-请求Ajax->表现层Servlet->业务逻辑层->数据访问层->服务器

服务器<-响应Ajax<-表现层Servlet<-业务逻辑层<-数据访问层<-服务器

我们用AJAX和服务器进行通信,将JSP替换为HTML+AJAX

! Ajax语法

Ajax可以通过原生的XMLHttpRequest对象发出HTTP请求,使用起来比较复杂和繁琐,实际开发中基本不使用,我们使用JQuery发送Ajax请求

$.ajax({name:value, name:value,...})

常用参数

参数 类型 说明
url String 发送请求的地址,默认为当前页地址
type String 请求方式,默认为GET
data PlainObject或String或Array 发送到服务器的数据
data Type String 预期服务器返回的数据类型,包括:XML、HTML、Script、JSON、JSONP、text
timeout Number 设置请求超时时间
global Boolean 表示是否触发全局AJax事件,默认为true

常用函数参数

参数 类型 说明
beforeSend Function(jqXHR jqxhr,PlainObject settings) 发送请求前调用的函数
success Function(任意类型 result,String textStatus,jqXHR jqxhr) 请求成功后调用的函数参数result:可选,由服务器返回的数据
error Function(jqXHR jqxhr,String textStatus,String errorThrown) 请求失败时调用的函数
complete Function(jqXHR jqxhr,String textStatus) 请求完成后(无论成功还是失败)调用的函数

规范示例

$.ajax({
    "url":"url", //要请求后台Servlet的URL路径
    "type":"get", //发送请求的方式
    "data":data, //要发送到服务器的数据
    "dataType":"text", //指定传输的数据格式
    "success":function(result){
        //请求成功后要执行的代码
    },
    "error":function(){
        //请求失败后要执行的代码
    },
});

Ajax快速入门

需求

在完成用户注册时,当用户名输入框失去焦点时,向后台发送请求,校验用户名是否在数据库已存在,如果存在则提示该用户名已存在。

分析

  • 前端完成的逻辑

    1. 给用户名输入框绑定光标失去焦点事件onblur
    2. 发送ajax请求,携带username参数
    3. 处理响应:是否显示提示信息
  • 后端完成的逻辑

    1.接收用户名 2.调用service查询User,判断用户是否存在 3.返回标记

整体流程如下

register.html

  1. 给用户名输入框绑定光标失去焦点事件onblur
  2. 发送ajax请求,携带username参数
  3. 处理响应:是否显示提示信息

------------↓请求-----------↑响应-----------

CheckNameServlet.java

  1. 接收用户名
  2. 调用servlet查询User,判断用户是否存在
  3. 返回标记

代码

register.html

<!DOCTYPE html>

<html>

<head>

<meta charset="UTF-8">

<title>Insert title here</title>

<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.min.js"></script>

</head>

<body>

<div>

<input type="text" class="login_input" id="userName" placeholder="请输入长度为6-10个字符的用户名"/>

<span id="userName_prompt" class="prompt"></span>

</div>

<script>

//给用户名输入框添加失去焦点事件

$("#userName").blur(checkUserName);

//给用户名输入框添加失去焦点事件

function checkUserName(){

var userName =$("#userName").val();

<!--发送ajax请求-->

$.ajax({

"url" :"checkName", //要提交的URL路径

"type" :"GET", //发送请求的方式

"data":"userName="+userName, //要发送到服务器的数据

"success" : function(data){

//响应成功后要执行的代码

if(data=="true"){

$("#userName_prompt").html("该用户名已存在");

}

}

});

$("#userName_prompt").html("");

return true;

}

</script>

  


</body>

</html>

CheckNameServlet.java

package com.servlet;

import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@WebServlet("/checkName")
public class CheckNameServlet extends HttpServlet {
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
response.setContentType("text/html;charset=utf-8");
String userName = request.getParameter("userName");

// 模拟:如果用户名是 admin 就已存在
if("admin".equals(userName)) {
response.getWriter().print("true");
}else {
response.getWriter().print("false");
}
}
}

Ajax简化写法

普通写法

$.ajax({
    "url":"url", //要请求后台Servlet的URL路径
    "type":"get", //发送请求的方式
    "data":data, //要发送到服务器的数据
    "dataType":"text", //指定传输的数据格式
    "success":function(result){
        //请求成功后要执行的代码
    },
    "error":function(){
        //请求失败后要执行的代码
    },
});

发送GET请求

$.get()方式通过HTTP GET,请求成服务器上请求数据

语法

$.get(URL,callback);
//or
$.get(URL,[data],[callback],[dataType])
  • URL:发送请求的URL字符串。
  • data:可选的,发送给服务器的字符串或key/value键值对。
  • calback:可选的,请求成功后执行的回调函数。
  • dataType:可选的,从服务器返回的数据类型。默认:智能猜测(可以是xml.json,script,或html)。

发送Post请求

.post()方法通过HTTPPOST请求,向服务器提交数据,写法上和.post()方法通过HTTP POST请求,向服务器提交数据,写法上和.get()很相似

语法

$.post(URL,callback);
//or
$.post(URL,[data],[callback],[dataType])
  • RUL:发送请求的URL字符串。
  • data:可选的,发送给服务器的字符串或key/value键值对。
  • calback:可选的,请求成功后执行的回调函数
  • dataTYpe:可选的,从服务器返回的数据类型。默认:智能猜测(可以是mxl.json,script,或html)。 注意:后端的CheckNameServlet对应post请求的支持

万字长文带你了解React架构之原理篇(基于React V19)

前言:为什么要读 React 源码?

当我们调用 setCount 时,React 底层究竟发生了什么?为什么有时候页面丝滑无比,有时候却卡顿掉帧?为什么 useEffect 的清理函数总是在奇怪的时候执行?

想要彻底搞懂这些玄学问题,就必须深入 React 的核心 Fiber 架构。React 的核心进化动力是: “将同步的递归过程,变为可中断的异步循环” 。为了实现这一点,它将渲染链路拆解为了三个各司其职的核心模块。

我们可以把 React 的运行比作一个高效的工厂生产线

  1. Scheduler (调度器) —— 调度员:维护任务优先级,利用“时间切片”在浏览器空闲时触发回调,防止长任务阻塞主线程。
  2. Reconciler (协调器) —— 施工队:负责 Diff 算法。在内存中构建 Fiber 树,打上增删改的标记(Flags)。这个过程是可中断的。
  3. Renderer (渲染器) —— 装修工:负责把计算好的变更一次性同步到宿主环境(如 DOM)。这个过程是不可中断的。

前前言:源码准备

为了下一篇的实践篇做准备,建议不要直接 clone 官方的 facebook/react 仓库。因为官方仓库使用了复杂的 Monorepo 结构、Flow 类型检查(部分切 TypeScript)以及自定义的打包配置。

我们只需要看最核心的模块就可以了,于是推荐查看 node_modules 下的React源码,在合适文件夹处打开终端并输入以下命令。

npx create-react-app react-source-debug
cd react-source-debug
npm start

为了方便追踪更新流程,把 src/App.js 改成一个最简单的计数器:

import { useState } from "react";

function App() {
  const [count, setCount] = useState(0);
  const click = () => {
    setCount(count + 1);
  };
  return (
    <div className="App">
      <h1>调试代码</h1>
      <p>{count}</p>
      <button onClick={click}>加 1</button>
    </div>
  );
}

export default App;

主包的版本如下:

"dependencies": {
    "@testing-library/dom": "^10.4.1",
    "@testing-library/jest-dom": "^6.9.1",
    "@testing-library/react": "^16.3.2",
    "@testing-library/user-event": "^13.5.0",
    "react": "^19.2.4",
    "react-dom": "^19.2.4",
    "react-scripts": "5.0.1",
    "web-vitals": "^2.1.4"
  },

第一站:Scheduler (调度器) —— 掌控时间的艺术

如果你的组件树极其庞大,React 一次性计算所有的差异(Diff)可能会花费几百毫秒,导致主线程卡死。Scheduler 的作用就是 “时间切片” (Time Slicing)

1. 双队列设计

当你触发更新时,Scheduler 内部会将任务分发到两个最小堆(队列)中:

  • timerQueue:未到时间的延迟任务(候补区)。
  • taskQueue:已到时间的就绪任务(核心工作区)。 系统会通过定时器将到期的任务从 timerQueue 移入 taskQueue,并开始执行工作循环 (workLoop)。

2. 时间切片与 MessageChannel

React 默认的时间切片是 5ms。如果一个任务执行超过了 5ms,React 就会通过 shouldYieldToHost 中断执行,把主线程还给浏览器去渲染 UI。


image.png

接下来让我们从入口开始一步步阅读到底Scheduler做了哪些事情:

exports.unstable_scheduleCallback = function (
      priorityLevel,
      callback,
      options
    ) {
      // 1. 计算任务的“开始时间” (startTime)
      // 判断开发者有没有传入 delay(延迟执行时间)。
      // 如果有 delay,任务的开始时间就是 当前时间 + delay。
      // 如果没有,开始时间就是 当前时间。
      var currentTime = exports.unstable_now(); // 相当于performance.now(),获取时间戳,用来代码执行时间分片
      if ("object" === typeof options && null !== options) {
        options = options.delay; // 变量复用 options 从配置对象变成了 delay 数值
        if (typeof options === "number" && options > 0) {
          options = currentTime + options; // options 变成了 startTime
        } else {
          options = currentTime;
        }
      } else {
        options = currentTime;
      }
      // 2. 计算任务的“过期时间” (expirationTime)
      // 根据权重来计算时间片,默认为5000us(微秒),也就是5ms(毫秒)
      // Scheduler 内部定义了 5 种优先级。优先级越高,timeout 越小,意味着任务越容易“过期”。
      // 一旦任务过期,即使破坏帧率,React 也会强制同步执行它。
      switch (priorityLevel) {
        case 1: var timeout = -1; break;         // Immediate (立即执行,已经过期了)
        case 2: timeout = 250; break;            // UserBlocking (用户阻塞级,如点击事件)
        case 5: timeout = 1073741823; break;     // Idle (空闲,相当于永不过期)
        case 4: timeout = 1e4; break;            // Low (低优先级)
        default: timeout = 5e3;                  // Normal (普通优先级,默认 5ms)
      }
      timeout = options + timeout; // timeout 变成了 expirationTime (开始时间 + 超时时间)
      // 3. 组装“任务对象” (Task Object)
      priorityLevel = { // 变量复用
        id: taskIdCounter++,
        callback: callback,
        priorityLevel: priorityLevel,
        startTime: options, // options真实语义:startTime
        expirationTime: timeout, // timeout真实语义:expirationTime
        sortIndex: -1
      };
      // 4. 双队列分发与唤醒
      // 两个小根堆:timerQueue:未到时间的任务(候补区)。taskQueue:已到时间的就绪任务(核心工作区)。
      // 逻辑like:如果你预约的是明天的号,就先去 timerQueue 呆着,系统会定个闹钟;如果你挂的是今天的号,就直接进 taskQueue,并立刻呼叫医生(唤醒 workLoop)开始接诊。
      if (options > currentTime) {
        // // 场景 A:startTime > currentTime,说明这是一个【延迟任务】
        priorityLevel.sortIndex = options; // 延迟任务按照 startTime 排序
        push(timerQueue, priorityLevel); // 放进专门存放延迟任务的 timerQueue
        // 如果当前没有就绪任务,且这是当前任务在堆顶,也就是最先需要唤醒的延迟任务
        if (peek(taskQueue) === null && priorityLevel === peek(timerQueue)) {
          if (isHostTimeoutScheduled) {
            localClearTimeout(taskTimeoutID);
            taskTimeoutID = -1;
          } else {
            isHostTimeoutScheduled = true;
          }
          // 设置一个定时器 (setTimeout),到点后把它移到 taskQueue 里
          requestHostTimeout(handleTimeout, options - currentTime);
        }
      } else {
        // 场景 B:这是一个【立即需要执行的任务】
        priorityLevel.sortIndex = timeout; // 就绪任务按照 expirationTime 排序
        push(taskQueue, priorityLevel); // 放进就绪队列 taskQueue
        // 如果现在没有在执行任务,就通知宿主环境(浏览器)启动宏任务开始干活!
        if (!isHostCallbackScheduled && !isPerformingWork) {
          isHostCallbackScheduled = true;
          if (!isMessageLoopRunning) {
            isMessageLoopRunning = true;
            schedulePerformWorkUntilDeadline();
          }
        }
      }
      return priorityLevel;
    };

当你点击页面上 加 1,调用 setCount 的时候,Scheduler会先包装该任务,入队 taskQueue 队列。

image.png

接下来看看最后的函数 schedulePerformWorkUntilDeadline 做了什么,查找该定义发现其实该函数做了一些 polyfill 降级处理:

// schedulePerformWorkUntilDeadline降级
    // 1. 首选:setImmediate (主要针对 Node.js 和 老版 IE)
    if ("function" === typeof localSetImmediate)
      var schedulePerformWorkUntilDeadline = function () {
        localSetImmediate(performWorkUntilDeadline);
      };
    // 2. 备选:MessageChannel (绝大部分现代浏览器,如 Chrome, Safari, Firefox)
    // MessageChannel 是 HTML5 引入的一个 API,允许我们创建一个新的消息通道,包含两个 MessagePort 对象。
    // 我们可以在任意一个 port 上发送消息,而另一个 port 则会触发 onmessage 事件。
    // 这使得 MessageChannel 成为一种非常高效的方式来安排任务在浏览器的事件循环中执行。
    else if ("undefined" !== typeof MessageChannel) {
      var channel = new MessageChannel(),
        port = channel.port2;
      channel.port1.onmessage = performWorkUntilDeadline; // 真正执行调度任务
      schedulePerformWorkUntilDeadline = function () {
        port.postMessage(null); // 发送消息,触发宏任务
      };
    } else // 3. 兜底方案:setTimeout
      schedulePerformWorkUntilDeadline = function () {
        localSetTimeout(performWorkUntilDeadline, 0);
      };

原理解析:为什么是 MessageChannel?

React 的“时间切片”本质上就是:干 5ms 活 -> 停下来 -> 让浏览器渲染 UI / 响应点击 -> 再干 5ms 活

为了实现“停下来再继续”,React 必须把剩下的活儿放到浏览器的事件循环 (Event Loop) 中排队。

  1. React 为什么不用 setTimeout(fn, 0)?因为在浏览器嵌套调用时,setTimeout 会有至少 4ms 的强制最小延迟,这会严重浪费性能。

  2. React 为什么不用 requestIdleCallback?因为这个 API 的触发频率极不稳定,有时候一秒钟才触发一两次,在 Safari 上还不支持。

  3. 所以,React 选择了 MessageChannel。它是一个纯粹的宏任务(MacroTask) ,没有哪怕 1ms 的人为延迟,非常适合用来实现高频的任务调度。

可以看到 schedulePerformWorkUntilDeadline 最后通过 MessageChannel 调用了 performWorkUntilDeadline

function performWorkUntilDeadline() {
      needsPaint = false; // 每次工作前,先重置紧急绘制标志
      if (isMessageLoopRunning) {
        var currentTime = exports.unstable_now();
        startTime = currentTime; // 记录当前宏任务的开始时间 (用于给 shouldYieldToHost 计算 5ms)
        var hasMoreWork = true;
        try {
          a: {
            isHostCallbackScheduled = !1;
            isHostTimeoutScheduled &&
              ((isHostTimeoutScheduled = !1),
              localClearTimeout(taskTimeoutID),
              (taskTimeoutID = -1));
            isPerformingWork = !0;
            var previousPriorityLevel = currentPriorityLevel;
            try {
              b: {
                // 1. 把 timerQueue 里到期的任务挪到 taskQueue 里
                advanceTimers(currentTime);
                // 2. 核心循环 workLoop
                for (
                  currentTask = peek(taskQueue); // taskQueue里取出任务
                  null !== currentTask &&
                  !( // 这里任务过期和不需要让出主线程是一个或的关系,满足其一就继续执行任务
                    currentTask.expirationTime > currentTime && // 任务已过期,也就是到了执行时间
                    shouldYieldToHost() // 不需要让出主线程
                  );
                ) {
                  var callback = currentTask.callback;
                  if ("function" === typeof callback) {
                    currentTask.callback = null;
                    currentPriorityLevel = currentTask.priorityLevel;
                    var continuationCallback = callback( // 【真正执行任务的地方】比如去执行 Reconciler 构建 Fiber 树
                      currentTask.expirationTime <= currentTime 
                    );
                    currentTime = exports.unstable_now();
                    if ("function" === typeof continuationCallback) { // 如果任务执行完返回了一个函数,说明任务没干完被中断了
                      currentTask.callback = continuationCallback;
                      advanceTimers(currentTime);
                      hasMoreWork = !0;
                      break b; // 跳出循环,准备让出控制权
                    }
                    currentTask === peek(taskQueue) && pop(taskQueue); // 出队列
                    advanceTimers(currentTime); // 再次排序
                  } else pop(taskQueue); // 当前任务callback不是函数,直接出队列
                  currentTask = peek(taskQueue); // 再次取出堆顶
                }
                if (null !== currentTask) hasMoreWork = !0;
                else {
                  var firstTimer = peek(timerQueue);
                  null !== firstTimer &&
                    requestHostTimeout(
                      handleTimeout,
                      firstTimer.startTime - currentTime
                    );
                  hasMoreWork = !1;
                }
              }
              break a;
            } finally {
              (currentTask = null),
                (currentPriorityLevel = previousPriorityLevel),
                (isPerformingWork = !1); // 结束工作,重置状态
            }
            hasMoreWork = void 0;
          }
        } finally {
          // 如果活没干完,再发一个宏任务消息,重新排队
          if (hasMoreWork) {
            schedulePerformWorkUntilDeadline();
          } else {
            isMessageLoopRunning = !1;
          }
        }
      }
    }

到底是怎么“把控制权交给浏览器”的?

首先看看如何判断是不是需要让出主线程呢:

function shouldYieldToHost() {
      return needsPaint
        ? !0  // 翻译:如果 needsPaint 是 true,立刻返回 true (让出主线程)
        : exports.unstable_now() - startTime < frameInterval
          ? !1 // 翻译:如果没有到 5ms,返回 false (继续干活)
          : !0; // 翻译:如果超过了 5ms,返回 true (让出主线程)
    }

很多人觉得 shouldYieldToHost() 像魔法一样,以为调用它就能“暂停”代码运行。其实在普通的 JavaScript 里,没有魔法,函数一旦执行就必须运行到底(Run-to-completion)。

React 的“交出控制权”其实是一个主动退出的策略,它利用了浏览器的 Event Loop(事件循环)机制

  1. 打断施法:当 shouldYieldToHost() 返回 true 时,React 只是简单地执行了 break,跳出了那个巨大的 for 循环。
  2. 安排后事:循环跳出后,走到最后的 finally 块。因为发现还有任务没做完(hasMoreWork = true),于是调用 schedulePerformWorkUntilDeadline()。注意,这并不是递归调用!这是往 MessageChannel 里发一条消息,在宏任务队列的末尾重新排队
  3. 彻底结束:发完消息后,performWorkUntilDeadline 这个函数就执行完了,调用栈(Call Stack)清空。
  4. 浏览器接管:此时当前宏任务结束。浏览器的 Event Loop 一看,调用栈空了!于是它开心地去处理用户的点击事件、去计算 CSS、去重新渲染页面(Paint)。
  5. 再次循环:浏览器把自己的活儿干完后,一看宏任务队列,发现刚才 React 排队的消息又到跟前了,于是再次触发 performWorkUntilDeadline,接着干上一轮没干完的活儿。

到这里 Scheduler 的秘密已经揭晓了,其实就是通过任务优先级往两个小根堆里添加任务。当浏览器空闲时,就会取出任务并执行,期间也会判断是否需要让出主线程,也可以被页面绘制任务打断。

完整版本源码解释看文章结尾。


第二站:Reconciler (协调器) —— 内存中的 Fiber 施工队

当 Scheduler 分配好时间后,真正干活的就是 Reconciler。它把原本不可中断的“递归” Diff,变成了带有状态指针 (workInProgress) 的 while 循环

1. “递”与“归”的艺术

React 构建 Fiber 树的过程,本质上就是一个深度优先遍历 (DFS) ,但是 React 把原本不可中断的“递归”函数调用,巧妙地改写成了带有状态指针 (workInProgress) 的 while/for 循环

整个过程分为两个极其重要的阶段:

  1. beginWork (递 - 向下挖掘)

    • 顾名思义就是“开始工作”。它会对比新旧节点(Diff 算法的核心发源地),计算出需要更新的属性,并创建下一个子节点
    • 如果它返回了子节点,workInProgress 就会一直往下指,直到某个节点没有子节点为止。
  2. completeUnitOfWork (归 - 向上收尾)

    • beginWork 走到叶子节点(返回 null)时,就会触发 completeUnitOfWork

    • 这个函数内部会负责在这个节点上“收集副作用”(比如给 DOM 打上增删改的 Tag),并尝试构建真实的 DOM 节点结构(在内存中)

    • 关键寻路逻辑:在 completeUnitOfWork 内部,它做完当前节点的活儿之后,会先看看自己有没有兄弟节点 (sibling)

      • 如果有兄弟节点,它就把 workInProgress 扔给兄弟,让兄弟去执行 beginWork
      • 如果没有兄弟节点,说明这一层的节点都处理完了,它就退回到父节点 (return) ,继续执行父节点的 completeUnitOfWork
    • 在内存里创建真实的 DOM 节点时,会巧妙地向上冒泡收集 subtreeFlags

核心入口 workLoopSync,有两种模式,这种是同步模式,并发模式下核心入口为 workLoopConcurrentByScheduler:

function workLoopConcurrentByScheduler() {
// 相较于workLoopSync,多了是否让出主线程的判断,其实就是Scheduler里的判断
  for (; null !== workInProgress && !shouldYield(); )
    performUnitOfWork(workInProgress);
}

image.png

image.png

// 处理单个 Fiber 节点
    function performUnitOfWork(unitOfWork) {
      var current = unitOfWork.alternate; // 获取当前节点在旧树上对应的节点(双缓存机制)

      // 【递阶段】:调用 beginWork,向下构建节点,并返回下一个要处理的子节点
      if ((unitOfWork.mode & 2) !== NoMode) {
        startProfilerTimer(unitOfWork);
        current = runWithFiberInDEV(
          unitOfWork,
          beginWork, // 对比新旧节点(Diff 算法的核心发源地),计算出需要更新的属性,并创建下一个子节点。
          current,
          unitOfWork,
          entangledRenderLanes
        );
        stopProfilerTimerIfRunningAndRecordDuration(unitOfWork);
      } else {
        current = runWithFiberInDEV(
          unitOfWork,
          beginWork,
          current,
          unitOfWork,
          entangledRenderLanes
        );
      }
      // 记录已经处理完的 props
      unitOfWork.memoizedProps = unitOfWork.pendingProps;

      if (null === current) {
        // 【归阶段】:如果没有子节点了,说明当前分支走到底了(叶子节点),开始往回走
        // 负责在这个节点上“收集副作用”(比如给 DOM 打上增删改的 Tag),并尝试构建真实的 DOM 节点结构(在内存中)
        completeUnitOfWork(unitOfWork);
      } else {
        // 如果还有子节点,把全局指针 workInProgress 指向它,下次循环继续处理它
        workInProgress = current;
      }
    }

假设我们有如下非常简单的组件树:

<App>
  <Parent>
    <ChildA />
    <ChildB />
  </Parent>
</App>

当 React 开始构建这棵树,workInProgress 指针初始指向 <App>

  1. performUnitOfWork 执行,调用 <App>beginWork,返回了 <Parent>
  2. workInProgress 移动到 <Parent>
  3. 再次循环,调用 <Parent>beginWork,返回了 <ChildA>
  4. workInProgress 移动到 <ChildA>
  5. <ChildA>没有子节点,它会返回 null
  6. 然后走到completeUnitOfWork(<ChildA>),之后wip指针指向<ChildA>sibling节点,也就是<ChildB>
  7. 下一把开始进行<ChildB>beginWork
function beginWork(current, workInProgress, renderLanes) {
  // 1. 热替换(Hot Reload)特殊处理:_debugNeedsRemount 强制替换整个 Fiber
  if (workInProgress._debugNeedsRemount && null !== current) {
    // ... 创建新 Fiber,替代当前 workInProgress,并标记删除旧节点
    return renderLanes; // 返回新创建的 Fiber
  }

  // 2. 判断是否能直接复用当前节点(Bailout 优化)
  if (null !== current) {
    // 对比 props 和 type 是否变化
    if (current.memoizedProps !== workInProgress.pendingProps ||
        workInProgress.type !== current.type) {
      didReceiveUpdate = true;
    } else {
      // props 和 type 没变,检查是否有待处理的更新或副作用
      if (!checkScheduledUpdateOrContext(current, renderLanes) &&
          (workInProgress.flags & 128) === 0) {
        didReceiveUpdate = false;
        // 尝试提前退出(bailout),复用子树
        return attemptEarlyBailoutIfNoScheduledUpdate(current, workInProgress, renderLanes);
      }
      didReceiveUpdate = (current.flags & 131072) !== 0;
    }
  } else {
    didReceiveUpdate = false;
    // 首次渲染时的 hydration 相关处理(服务端渲染)
    // ...
  }

  // 3. 清除当前 Fiber 上暂存的 lanes(即将进入具体处理)
  workInProgress.lanes = 0;

  // 4. 根据 Fiber 的 tag 类型,分发到不同的具体处理函数
  switch (workInProgress.tag) {
    case 0: return updateFunctionComponent(current, workInProgress, ...);
    case 1: return updateClassComponent(current, workInProgress, ...);
    case 3: return updateHostRoot(current, workInProgress, ...);   // 根节点
    case 5: return updateHostComponent(current, workInProgress, ...); // DOM 元素
    case 6: return updateHostText(current, workInProgress);       // 文本节点
    case 13: return updateSuspenseComponent(current, workInProgress, renderLanes);
    // ... 其他 tag(forwardRef, memo, context provider/consumer, offscreen 等)
    default: throw new Error("Unknown unit of work tag");
  }
}

我们再来看看 completeUnitOfWork

function completeUnitOfWork(unitOfWork) {
  var completedWork = unitOfWork;          // 当前要完成的节点
  do {
    // 1. 检查是否需要异常回退(Incomplete 标志)
    if (0 !== (completedWork.flags & 32768)) {
      unwindUnitOfWork(completedWork, workInProgressRootDidSkipSuspendedSiblings);
      return;
    }

    var current = completedWork.alternate; // 旧树中的对应节点
    var returnFiber = completedWork.return; // 父节点
    startProfilerTimer(completedWork);      // 性能计时

    // 2. 调用 completeWork 完成节点工作
    current = runWithFiberInDEV(
      completedWork,
      completeWork,
      current,
      completedWork,
      entangledRenderLanes
    );

    // 性能计时结束
    if ((completedWork.mode & 2) !== NoMode) {
      stopProfilerTimerIfRunningAndRecordIncompleteDuration(completedWork);
    }

    // 3. 如果 completeWork 返回了新的节点(罕见),则继续向下处理
    if (null !== current) {
      workInProgress = current;
      return;
    }

    // 4. 尝试处理兄弟节点
    var siblingFiber = completedWork.sibling;
    if (null !== siblingFiber) {
      workInProgress = siblingFiber;
      return;
    }

    // 5. 没有兄弟节点,则向上回溯到父节点
    workInProgress = completedWork = returnFiber;
  } while (null !== completedWork);  // 一直回溯到根节点

  // 6. 整个根节点树已完成
  if (workInProgressRootExitStatus === RootInProgress) {
    workInProgressRootExitStatus = RootCompleted;
  }
}

2. 精准打击的秘密

我们可以看到在completeUnitOfWork调用了completeWork,其实completeWork 是 React Fiber 协调器中针对单个节点执行“完成”操作的函数,而 completeUnitOfWork 是驱动整个后序(归)阶段遍历的调度循环。二者配合完成渲染阶段的“归”过程。

对于传入的一个 workInProgress 节点(正在构建的新 Fiber),completeWork 根据其 tag(组件类型)执行对应的收尾工作,主要包括:

  1. 创建或更新 DOM 实例(对宿主组件 tag=5、文本节点 tag=6 等)
  2. 将子节点附加到父节点(如 appendAllChildren
  3. 处理 Hydration(服务端渲染水合) :验证并复用已有的 DOM 节点
  4. 冒泡副作用(bubbleProperties :将子树的 flags(如 Placement、Update)合并到当前 Fiber 的 subtreeFlags 中,方便 commit 阶段快速查找
  5. 处理 Suspense、Offscreen 等特殊组件的逻辑(如显示 fallback、标记隐藏)
  6. 弹出上下文(如 Provider、HostContext、SuspenseContext)

冒泡副作用(bubbleProperties 让React可以在Commit阶段跳过没有副作用的子树,极大的提升了性能。

function bubbleProperties(completedWork) {
  // 1. 判断当前节点是否“保退”(bailout)了
  var didBailout = 
      null !== completedWork.alternate &&
      completedWork.alternate.child === completedWork.child;
  // 如果 current 树和 workInProgress 树的 child 指向同一个 Fiber 节点,
  // 说明当前节点没有重新构建子树(复用了旧子节点)。
  // 此时冒泡时只能合并某些特定的 flags(如悬浮、挂起相关),不能完全合并所有 flags。

  var newChildLanes = 0,    // 用于汇总子节点的 lanes 和 childLanes
      subtreeFlags = 0;     // 用于汇总子节点的 flags 和 subtreeFlags

  if (didBailout) {
    // 情况 A:当前节点复用了旧的子节点(没有重新协调子树)
    if ((completedWork.mode & 2) !== NoMode) {
      // 性能追踪模式(ProfileMode):还要累加时长
      var _treeBaseDuration = completedWork.selfBaseDuration,
          _child2 = completedWork.child;
      while (_child2 !== null) {
        // 合并子节点的 lanes
        newChildLanes |= _child2.lanes | _child2.childLanes;
        // 注意:这里只合并了部分 flags(0x65011712 是一个掩码,代表某些特定 flags)
        // 因为在 bailout 时,子树的某些 flags 不能简单向上冒泡(需要特殊处理)
        subtreeFlags |= _child2.subtreeFlags & 65011712;
        subtreeFlags |= _child2.flags & 65011712;
        // 累加基础时长(用于性能分析)
        _treeBaseDuration += _child2.treeBaseDuration;
        _child2 = _child2.sibling;
      }
      completedWork.treeBaseDuration = _treeBaseDuration;
    } else {
      // 非性能追踪模式,只合并 lanes 和部分 flags,不累加时长
      for (var _child = completedWork.child; _child !== null; _child = _child.sibling) {
        newChildLanes |= _child.lanes | _child.childLanes;
        subtreeFlags |= _child.subtreeFlags & 65011712;
        subtreeFlags |= _child.flags & 65011712;
        _child.return = completedWork;   // 确保父指针正确
      }
    }
  } else {
    // 情况 B:当前节点是全新构建的(或至少没有完全复用旧子节点)
    if ((completedWork.mode & 2) !== NoMode) {
      // 性能追踪模式:完整合并所有 flags 和时长
      var _actualDuration = completedWork.actualDuration;
      var _treeBaseDuration2 = completedWork.selfBaseDuration;
      for (var _child3 = completedWork.child; _child3 !== null; _child3 = _child3.sibling) {
        newChildLanes |= _child3.lanes | _child3.childLanes;
        subtreeFlags |= _child3.subtreeFlags;
        subtreeFlags |= _child3.flags;
        _actualDuration += _child3.actualDuration;
        _treeBaseDuration2 += _child3.treeBaseDuration;
      }
      completedWork.actualDuration = _actualDuration;
      completedWork.treeBaseDuration = _treeBaseDuration2;
    } else {
      // 非性能追踪模式:完整合并所有 flags
      for (var _child4 = completedWork.child; _child4 !== null; _child4 = _child4.sibling) {
        newChildLanes |= _child4.lanes | _child4.childLanes;
        subtreeFlags |= _child4.subtreeFlags;
        subtreeFlags |= _child4.flags;
        _child4.return = completedWork;
      }
    }
  }

  // 2. 将合并后的子节点标志挂到当前节点上
  completedWork.subtreeFlags |= subtreeFlags;
  completedWork.childLanes = newChildLanes;

  // 3. 返回是否发生了 bailout(供上层可能使用)
  return didBailout;
}

下面用一个例子来理解冒泡副作用: 假设一个组件树:

Root
 ├─ App
 │   ├─ Header (无更新)
 │   └─ Content (需要更新)
  • 在 completeWork 阶段,Header 的 subtreeFlags 为 0,Content 的 flags 包含 UpdatesubtreeFlags 可能也包含子孙的 flags。
  • bubbleProperties 在 App 节点上会将 Content 的 flags 和 subtreeFlags 冒泡到 App.subtreeFlags
  • commit 阶段从 Root 开始,看到 Root.subtreeFlags 非 0,进入 App;看到 App.subtreeFlags 非 0,再进入 Header 和 ContentHeader.subtreeFlags 为 0,跳过;Content.flags 非 0,处理更新。
  • 如果没有 bubbleProperties,commit 阶段每次都要遍历所有节点,性能会差很多。

3. Bailout (急救退出) 机制

如果在 beginWork 时发现 oldProps === newPropsstate 没变,React 会直接克隆旧的 Fiber 子节点,并 return null 阻断当前分支的向下遍历。这就是 React.memo 提升性能的底层原理。


第三站:Renderer (渲染器) —— 雷厉风行的装修工

当 Reconciler 在内存中把整棵树建好后,就会进入 Renderer (commitRoot)。 Renderer 的底线是:绝对不可中断! 否则用户就会看到一半新一半旧的“UI 撕裂(Tearing)”。

它严格分为三个同步阶段:

  1. BeforeMutation:DOM 突变前。
  2. Mutation:操作真实 DOM,增删改查。
  3. Layout:真实 DOM 已更新,同步执行 useLayoutEffect

commitRoot 是 React commit 阶段的总入口。它的任务是将渲染阶段(render phase)生成的 finishedWork 树(即最新的 workInProgress 树)上的副作用(如 DOM 更新、生命周期调用、useEffect 等)同步地应用到真实 UI 上。

function commitRoot(root, finishedWork, lanes, ...) {
  // 1. 清空待处理的“被动效果”(useEffect 清理/执行)
  do flushPendingEffects(); while (pendingEffectsStatus !== NO_PENDING_EFFECTS);

  // 2. 打印 StrictMode 相关警告
  ReactStrictModeWarnings.flushLegacyContextWarning();
  ReactStrictModeWarnings.flushPendingUnsafeLifecycleWarnings();

  // 3. 检查是否已经在工作中,防止嵌套 commit
  if ((executionContext & (RenderContext | CommitContext)) !== NoContext)
    throw Error("Should not already be working.");

  // 4. 记录渲染阶段的各种日志(性能、错误、恢复等)
  //    (根据 exitStatus 调用不同的 log 函数)
  if (exitStatus === RootErrored) { ... }
  else if (recoverableErrors) { ... }
  else { ... }

  // 5. 如果 finishedWork 有效,开始正式提交
  if (null !== finishedWork) {
    // 5.1 标记根节点为已完成,清理待处理 lanes,更新根节点的 current 指针
    markRootFinished(root, lanes, ...);
    // 5.2 清空全局 workInProgress 相关变量
    root === workInProgressRoot && (workInProgress = workInProgressRoot = null, ...);

    // 5.3 将 finishedWork 等信息存到全局 pending 变量,供被动效果使用
    pendingFinishedWork = finishedWork;
    pendingEffectsRoot = root;
    // ...

    // 5.4 判断是否有需要触发的“被动效果”(useEffect 的清理或执行)
    if (有副作用标记(如 10256 等)) {
      // 调度一个普通优先级的回调,稍后执行 flushPassiveEffects
      scheduleCallback(NormalPriority$1, function () {
        flushPassiveEffects();   // 执行所有 useEffect
        return null;
      });
    } else {
      // 没有被动效果,直接清空回调
      root.callbackNode = null;
      root.callbackPriority = 0;
    }

    // 5.5 记录 commit 开始时间
    commitStartTime = now();

    // 5.6 执行 before mutation 阶段(调用 getSnapshotBeforeUpdate 等)
    if (finishedWork.subtreeFlags & 13878 || finishedWork.flags & 13878) {
      // 保存当前上下文,进入 CommitContext
      commitBeforeMutationEffects(root, finishedWork, lanes);
    }

    // 5.7 标记进入 mutation 阶段,并执行 DOM 突变操作
    pendingEffectsStatus = PENDING_MUTATION_PHASE;
    flushMutationEffects();   // 执行 DOM 增删改

    // 5.8 执行 layout 阶段(同步调用 useLayoutEffect 等)
    flushLayoutEffects();

    // 5.9 清理可能产生的额外工作
    flushSpawnedWork();
  }
}

Scheduler完整源码

Renderer和Reconciler源码过于复杂,我们只关注上述核心流程即可。

/**
 * @license React
 * scheduler.development.js
 *
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 */

"use strict";
"production" !== process.env.NODE_ENV &&
  (function () {
    // 调度任务执行者
    function performWorkUntilDeadline() {
      needsPaint = false; // 每次工作前,先重置紧急绘制标志
      if (isMessageLoopRunning) {
        var currentTime = exports.unstable_now();
        startTime = currentTime; // 记录当前宏任务的开始时间 (用于给 shouldYieldToHost 计算 5ms)
        var hasMoreWork = true;
        try {
          a: {
            isHostCallbackScheduled = !1;
            isHostTimeoutScheduled &&
              ((isHostTimeoutScheduled = !1),
              localClearTimeout(taskTimeoutID),
              (taskTimeoutID = -1));
            isPerformingWork = !0;
            var previousPriorityLevel = currentPriorityLevel;
            try {
              b: {
                // 1. 把 timerQueue 里到期的任务挪到 taskQueue 里
                advanceTimers(currentTime);
                // 2. 核心循环 workLoop
                for (
                  currentTask = peek(taskQueue); // taskQueue里取出任务
                  null !== currentTask &&
                  !( // 这里任务过期和不需要让出主线程是一个或的关系,满足其一就继续执行任务
                    currentTask.expirationTime > currentTime && // 任务已过期,也就是到了执行时间
                    shouldYieldToHost() // 不需要让出主线程
                  );
                ) {
                  var callback = currentTask.callback;
                  if ("function" === typeof callback) {
                    currentTask.callback = null;
                    currentPriorityLevel = currentTask.priorityLevel;
                    var continuationCallback = callback( // 【真正执行任务的地方】比如去执行 Reconciler 构建 Fiber 树
                      currentTask.expirationTime <= currentTime 
                    );
                    currentTime = exports.unstable_now();
                    if ("function" === typeof continuationCallback) { // 如果任务执行完返回了一个函数,说明任务没干完被中断了
                      currentTask.callback = continuationCallback;
                      advanceTimers(currentTime);
                      hasMoreWork = !0;
                      break b; // 跳出循环,准备让出控制权
                    }
                    currentTask === peek(taskQueue) && pop(taskQueue); // 出队列
                    advanceTimers(currentTime); // 再次排序
                  } else pop(taskQueue); // 当前任务callback不是函数,直接出队列
                  currentTask = peek(taskQueue); // 再次取出堆顶
                }
                if (null !== currentTask) hasMoreWork = !0;
                else {
                  var firstTimer = peek(timerQueue);
                  null !== firstTimer &&
                    requestHostTimeout(
                      handleTimeout,
                      firstTimer.startTime - currentTime
                    );
                  hasMoreWork = !1;
                }
              }
              break a;
            } finally {
              (currentTask = null),
                (currentPriorityLevel = previousPriorityLevel),
                (isPerformingWork = !1); // 结束工作,重置状态
            }
            hasMoreWork = void 0;
          }
        } finally {
          // 如果活没干完,再发一个宏任务消息,重新排队
          if (hasMoreWork) {
            schedulePerformWorkUntilDeadline();
          } else {
            isMessageLoopRunning = !1;
          }
        }
      }
    }

    // ---- 小根堆相关api ------
    function push(heap, node) {
      var index = heap.length;
      heap.push(node);
      a: for (; 0 < index; ) {
        var parentIndex = (index - 1) >>> 1,
          parent = heap[parentIndex];
        if (0 < compare(parent, node))
          (heap[parentIndex] = node),
            (heap[index] = parent),
            (index = parentIndex);
        else break a;
      }
    }
    function peek(heap) {
      return 0 === heap.length ? null : heap[0];
    }
    function pop(heap) {
      if (0 === heap.length) return null;
      var first = heap[0],
        last = heap.pop();
      if (last !== first) {
        heap[0] = last;
        a: for (
          var index = 0, length = heap.length, halfLength = length >>> 1;
          index < halfLength;

        ) {
          var leftIndex = 2 * (index + 1) - 1,
            left = heap[leftIndex],
            rightIndex = leftIndex + 1,
            right = heap[rightIndex];
          if (0 > compare(left, last))
            rightIndex < length && 0 > compare(right, left)
              ? ((heap[index] = right),
                (heap[rightIndex] = last),
                (index = rightIndex))
              : ((heap[index] = left),
                (heap[leftIndex] = last),
                (index = leftIndex));
          else if (rightIndex < length && 0 > compare(right, last))
            (heap[index] = right),
              (heap[rightIndex] = last),
              (index = rightIndex);
          else break a;
        }
      }
      return first;
    }
    function compare(a, b) {
      var diff = a.sortIndex - b.sortIndex;
      return 0 !== diff ? diff : a.id - b.id;
    }
    // 把 timerQueue 里到期的任务挪到 taskQueue 里
    function advanceTimers(currentTime) {
      for (var timer = peek(timerQueue); null !== timer; ) {
        if (null === timer.callback) pop(timerQueue);
        else if (timer.startTime <= currentTime)
          pop(timerQueue),
            (timer.sortIndex = timer.expirationTime),
            push(taskQueue, timer);
        else break;
        timer = peek(timerQueue);
      }
    }
    function handleTimeout(currentTime) {
      isHostTimeoutScheduled = !1;
      advanceTimers(currentTime);
      if (!isHostCallbackScheduled)
        if (null !== peek(taskQueue))
          (isHostCallbackScheduled = !0),
            isMessageLoopRunning ||
              ((isMessageLoopRunning = !0), schedulePerformWorkUntilDeadline());
        else {
          var firstTimer = peek(timerQueue);
          null !== firstTimer &&
            requestHostTimeout(
              handleTimeout,
              firstTimer.startTime - currentTime
            );
        }
    }
    function shouldYieldToHost() {
      return needsPaint
        ? !0  // 翻译:如果 needsPaint 是 true,立刻返回 true (让出主线程)
        : exports.unstable_now() - startTime < frameInterval
          ? !1 // 翻译:如果没有到 5ms,返回 false (继续干活)
          : !0; // 翻译:如果超过了 5ms,返回 true (让出主线程)
    }
    function requestHostTimeout(callback, ms) {
      taskTimeoutID = localSetTimeout(function () {
        callback(exports.unstable_now());
      }, ms);
    }
    "undefined" !== typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ &&
      "function" ===
        typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart &&
      __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart(Error());

    // performance.now() 的 降级方案
    exports.unstable_now = void 0;
    if (
      "object" === typeof performance &&
      "function" === typeof performance.now
    ) {
      var localPerformance = performance;
      exports.unstable_now = function () {
        return localPerformance.now();
      };
    } else {
      var localDate = Date,
        initialTime = localDate.now();
      exports.unstable_now = function () {
        return localDate.now() - initialTime;
      };
    }


    var taskQueue = [],
      timerQueue = [],
      taskIdCounter = 1,
      currentTask = null,
      currentPriorityLevel = 3,
      isPerformingWork = !1,
      isHostCallbackScheduled = !1,
      isHostTimeoutScheduled = !1,
      needsPaint = !1,
      localSetTimeout = "function" === typeof setTimeout ? setTimeout : null,
      localClearTimeout =
        "function" === typeof clearTimeout ? clearTimeout : null,
      localSetImmediate =
        "undefined" !== typeof setImmediate ? setImmediate : null,
      isMessageLoopRunning = !1, // 核心标志位:表示是否已经安排了宏任务
      taskTimeoutID = -1,
      frameInterval = 5,
      startTime = -1;
  
    // schedulePerformWorkUntilDeadline降级
    // 1. 首选:setImmediate (主要针对 Node.js 和 老版 IE)
    if ("function" === typeof localSetImmediate)
      var schedulePerformWorkUntilDeadline = function () {
        localSetImmediate(performWorkUntilDeadline);
      };
    // 2. 备选:MessageChannel (绝大部分现代浏览器,如 Chrome, Safari, Firefox)
    // MessageChannel 是 HTML5 引入的一个 API,允许我们创建一个新的消息通道,包含两个 MessagePort 对象。
    // 我们可以在任意一个 port 上发送消息,而另一个 port 则会触发 onmessage 事件。
    // 这使得 MessageChannel 成为一种非常高效的方式来安排任务在浏览器的事件循环中执行。
    else if ("undefined" !== typeof MessageChannel) {
      var channel = new MessageChannel(),
        port = channel.port2;
      channel.port1.onmessage = performWorkUntilDeadline;
      schedulePerformWorkUntilDeadline = function () {
        port.postMessage(null); // 发送消息,触发宏任务
      };
    } else // 3. 兜底方案:setTimeout
      schedulePerformWorkUntilDeadline = function () {
        localSetTimeout(performWorkUntilDeadline, 0);
      };

    // 调度优先级
    exports.unstable_IdlePriority = 5;
    exports.unstable_ImmediatePriority = 1;
    exports.unstable_LowPriority = 4;
    exports.unstable_NormalPriority = 3;
    exports.unstable_Profiling = null;
    exports.unstable_UserBlockingPriority = 2;
    exports.unstable_cancelCallback = function (task) {
      task.callback = null;
    };
    // 保持fps稳定在0-125之间,过高的fps不受支持
    exports.unstable_forceFrameRate = function (fps) {
      0 > fps || 125 < fps
        ? console.error(
            "forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"
          )
        : (frameInterval = 0 < fps ? Math.floor(1e3 / fps) : 5);
    };
    exports.unstable_getCurrentPriorityLevel = function () {
      return currentPriorityLevel;
    };
    exports.unstable_next = function (eventHandler) {
      switch (currentPriorityLevel) {
        case 1:
        case 2:
        case 3:
          var priorityLevel = 3;
          break;
        default:
          priorityLevel = currentPriorityLevel;
      }
      var previousPriorityLevel = currentPriorityLevel;
      currentPriorityLevel = priorityLevel;
      try {
        return eventHandler();
      } finally {
        currentPriorityLevel = previousPriorityLevel;
      }
    };
    // 有时候 5ms 的时间切片还没用完,但用户突然触发了某些高频或紧急交互(如点击、输入、滚动等),这时我们就需要立刻让出主线程,去处理这些交互事件,以保证界面响应的流畅性。
    exports.unstable_requestPaint = function () {
      needsPaint = !0;
    };
    exports.unstable_runWithPriority = function (priorityLevel, eventHandler) {
      switch (priorityLevel) {
        case 1:
        case 2:
        case 3:
        case 4:
        case 5:
          break;
        default:
          priorityLevel = 3;
      }
      var previousPriorityLevel = currentPriorityLevel;
      currentPriorityLevel = priorityLevel;
      try {
        return eventHandler();
      } finally {
        currentPriorityLevel = previousPriorityLevel;
      }
    };
    exports.unstable_scheduleCallback = function (
      priorityLevel,
      callback,
      options
    ) {
      // 1. 计算任务的“开始时间” (startTime)
      // 判断开发者有没有传入 delay(延迟执行时间)。
      // 如果有 delay,任务的开始时间就是 当前时间 + delay。
      // 如果没有,开始时间就是 当前时间。
      var currentTime = exports.unstable_now(); // 相当于performance.now(),获取时间戳,用来代码执行时间分片
      if ("object" === typeof options && null !== options) {
        options = options.delay; // 变量复用 options 从配置对象变成了 delay 数值
        if (typeof options === "number" && options > 0) {
          options = currentTime + options; // options 变成了 startTime
        } else {
          options = currentTime;
        }
      } else {
        options = currentTime;
      }
      // 2. 计算任务的“过期时间” (expirationTime)
      // 根据权重来计算时间片,默认为5000ms
      // Scheduler 内部定义了 5 种优先级。优先级越高,timeout 越小,意味着任务越容易“过期”。
      // 一旦任务过期,即使破坏帧率,React 也会强制同步执行它。
      switch (priorityLevel) {
        case 1: var timeout = -1; break;         // Immediate (立即执行,已经过期了)
        case 2: timeout = 250; break;            // UserBlocking (用户阻塞级,如点击事件)
        case 5: timeout = 1073741823; break;     // Idle (空闲,相当于永不过期)
        case 4: timeout = 1e4; break;            // Low (低优先级)
        default: timeout = 5e3;                  // Normal (普通优先级,默认 5000ms)
      }
      timeout = options + timeout; // timeout 变成了 expirationTime (开始时间 + 超时时间)
      // 3. 组装“任务对象” (Task Object)
      priorityLevel = { // 变量复用
        id: taskIdCounter++,
        callback: callback,
        priorityLevel: priorityLevel,
        startTime: options, // options真实语义:startTime
        expirationTime: timeout, // timeout真实语义:expirationTime
        sortIndex: -1
      };
      // 4. 双队列分发与唤醒
      // 两个小根堆:timerQueue:未到时间的任务(候补区)。taskQueue:已到时间的就绪任务(核心工作区)。
      // 逻辑like:如果你预约的是明天的号,就先去 timerQueue 呆着,系统会定个闹钟;如果你挂的是今天的号,就直接进 taskQueue,并立刻呼叫医生(唤醒 workLoop)开始接诊。
      if (options > currentTime) {
        // // 场景 A:startTime > currentTime,说明这是一个【延迟任务】
        priorityLevel.sortIndex = options; // 延迟任务按照 startTime 排序
        push(timerQueue, priorityLevel); // 放进专门存放延迟任务的 timerQueue
        // 如果当前没有就绪任务,且这是当前任务在堆顶,也就是最先需要唤醒的延迟任务
        if (peek(taskQueue) === null && priorityLevel === peek(timerQueue)) {
          if (isHostTimeoutScheduled) {
            localClearTimeout(taskTimeoutID);
            taskTimeoutID = -1;
          } else {
            isHostTimeoutScheduled = true;
          }
          // 设置一个定时器 (setTimeout),到点后把它移到 taskQueue 里
          requestHostTimeout(handleTimeout, options - currentTime);
        }
      } else {
        // 场景 B:这是一个【立即需要执行的任务】
        priorityLevel.sortIndex = timeout; // 就绪任务按照 expirationTime 排序
        push(taskQueue, priorityLevel); // 放进就绪队列 taskQueue
        // 如果现在没有在执行任务,就通知宿主环境(浏览器)启动宏任务开始干活!
        if (!isHostCallbackScheduled && !isPerformingWork) {
          isHostCallbackScheduled = true;
          if (!isMessageLoopRunning) {
            isMessageLoopRunning = true;
            schedulePerformWorkUntilDeadline();
          }
        }
      }
      return priorityLevel;
    };
    exports.unstable_shouldYield = shouldYieldToHost;
    exports.unstable_wrapCallback = function (callback) {
      var parentPriorityLevel = currentPriorityLevel;
      return function () {
        var previousPriorityLevel = currentPriorityLevel;
        currentPriorityLevel = parentPriorityLevel;
        try {
          return callback.apply(this, arguments);
        } finally {
          currentPriorityLevel = previousPriorityLevel;
        }
      };
    };
    "undefined" !== typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ &&
      "function" ===
        typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop &&
      __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop(Error());
  })();

数据请求库:React Query 核心概念

引言

在现代前端开发中,数据请求管理是一个核心挑战。传统的 useEffect + useState 模式虽然简单,但面临着缓存、重复请求、加载状态管理等诸多问题。React Query(现更名为 TanStack Query)应运而生,成为 React 生态中最流行的服务端状态管理库。

为什么需要 React Query?

在 React 应用中,我们通常将状态分为两类:

  1. 客户端状态:UI 状态,如表单输入、模态框开关等
  2. 服务端状态:从服务器获取的数据,如用户信息、文章列表等

React Query 专注于管理服务端状态,提供以下核心能力:

  • 自动缓存和后台更新
  • 重复请求去重
  • 加载/错误状态管理
  • 分页和无限加载
  • 乐观更新

核心概念

1. Query(查询)

Query 是 React Query 的基础单元,用于获取数据:

import { useQuery } from '@tanstack/react-query'

function UserProfile({ userId }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json())
  })
  
  if (isLoading) return <div>加载中...</div>
  if (error) return <div>加载失败</div>
  
  return <div>{data.name}</div>
}

关键点:

  • queryKey:唯一标识查询的数组,用于缓存和失效
  • queryFn:返回 Promise 的函数,用于获取数据

2. Mutation(变更)

Mutation 用于创建、更新或删除数据:

import { useMutation, useQueryClient } from '@tanstack/react-query'

function CreatePost() {
  const queryClient = useQueryClient()
  
  const mutation = useMutation({
    mutationFn: (newPost) => fetch('/api/posts', {
      method: 'POST',
      body: JSON.stringify(newPost)
    }),
    onSuccess: () => {
      // 失效并重新获取帖子列表
      queryClient.invalidateQueries(['posts'])
    }
  })
  
  const handleSubmit = (e) => {
    e.preventDefault()
    mutation.mutate({ title: '新文章', content: '内容...' })
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <button disabled={mutation.isPending}>
        {mutation.isPending ? '提交中...' : '提交'}
      </button>
    </form>
  )
}

3. 缓存策略

React Query 的智能缓存机制:

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000,      // 5 分钟内数据新鲜
      gcTime: 10 * 60 * 1000,        // 10 分钟后清理缓存
      retry: 3,                       // 失败重试 3 
      refetchOnWindowFocus: true,    // 窗口聚焦时重新获取
    },
  },
})

4. 依赖查询

处理有依赖关系的查询:

// 只有获取到用户后,才获取用户文章
const { data: user } = useQuery({
  queryKey: ['user', userId],
  queryFn: getUser
})

const { data: posts } = useQuery({
  queryKey: ['posts', userId],
  queryFn: getPosts,
  enabled: !!user  // 用户存在时才执行
})

最佳实践

1. 合理的 Query Key 设计

// ✅ 好的设计
['users', userId, 'posts', postId]
['todos', { status: 'active', page: 1 }]

// ❌ 避免
['data']  // 太笼统
['user-' + userId]  // 应该用数组

2. 错误处理

const { error } = useQuery({
  queryKey: ['user', userId],
  queryFn: fetchUser,
  retry: (failureCount, error) => {
    // 只在 409 错误时重试
    if (error.status === 409) return true
    return false
  }
})

3. 预取数据

// 鼠标悬停时预取
queryClient.prefetchQuery({
  queryKey: ['user', userId],
  queryFn: fetchUser
})

总结

React Query 通过声明式 API 和智能缓存机制,极大简化了服务端状态管理。掌握其核心概念后,你可以:

  • 减少 80% 以上的数据请求代码
  • 自动处理加载、错误、缓存状态
  • 实现更好的用户体验(后台更新、乐观更新)

建议从简单的 query 开始,逐步探索 mutation、无限加载、实时订阅等高级特性。

从富文本图片粘贴失败,吃透剪贴板与DataTransfer底层原理

从富文本图片粘贴失败,吃透剪贴板与DataTransfer底层原理

在开发富文本编辑器相关功能时,遇到了一个看似简单却暗藏底层逻辑的问题:同样一段包含图片的HTML内容,第一次粘贴能正常识别并上传CDN,第二次手动复制字符串粘贴却完全失效。排查过程中,不仅解决了实际问题,更串联起剪贴板机制、DataTransfer对象、浏览器安全策略等一系列前端核心理论知识。本文将完整还原问题排查过程,拆解背后的技术原理,帮助大家避开同类坑,同时系统掌握相关知识点。

一、问题背景与现象

需求:实现“自动生成带图片的富文本内容,并模拟用户粘贴到编辑器,触发图片自动上传CDN”功能。

异常现象:

  1. 第一次从网页复制图片/带图片的HTML,粘贴到富文本编辑器(Tiptap),能正常识别图片并触发CDN上传;
  2. 将控制台打印的HTML字符串(含base64图片)手动复制,再次粘贴到编辑器,仅显示纯文本,图片无法识别,也不触发上传;
  3. 尝试用navigator.clipboard.write()写入剪贴板,直接报错:NotAllowedError: Failed to execute 'write' on 'Clipboard': Document is not focused;
  4. 改用DataTransfer构造粘贴事件,模拟粘贴行为,图片能正常识别并上传。

二、问题排查过程(从现象到本质)

1. 初步排查:粘贴内容的格式差异

通过监听剪贴板粘贴事件,打印clipboardData中的数据格式,发现关键差异:

  • 第一次粘贴(成功):clipboardData中存在text/plain和text/html两种格式,其中text/html包含完整的<img src="data:...">结构,且开头带有<meta charset='utf-8'>;
  • 第二次粘贴(失败):clipboardData中仅存在text/plain格式,内容是完整的HTML字符串,但text/html格式为空。

初步结论:富文本编辑器识别图片,核心依赖text/html格式,而非纯文本中的HTML字符串。

2. 深入排查:剪贴板的底层机制

为什么手动复制HTML字符串,text/html格式会为空?这就需要从剪贴板的底层结构说起。

现代操作系统(Windows/macOS)的剪贴板,并非存储单一字符串,而是一个“多格式数据包”,本质是{ MIME类型: 数据 }的键值对结构。浏览器复制网页内容时,会自动生成多种格式(text/plain、text/html、图片二进制等)并写入剪贴板;而手动复制控制台的字符串,来源是DevTools文本面板,仅会写入text/plain格式,不会生成text/html,因此编辑器无法识别为富文本。

3. 再遇卡点:navigator.clipboard的权限限制

尝试用JS代码手动写入text/html和text/plain到系统剪贴板,代码如下:

const html = '<p><img src="data:image/png;base64,xxx"></p>';
navigator.clipboard.write([  new ClipboardItem({    'text/plain': new Blob([html], { type: 'text/plain' }),
    'text/html':  new Blob([html], { type: 'text/html' })
  })
])

运行后直接报错,核心原因是浏览器的安全策略:无用户交互(如click、keydown)时,禁止脚本读写系统剪贴板,防止网页偷偷复制/修改用户剪贴板内容。

4. 最终解决方案:用DataTransfer模拟粘贴事件

既然系统剪贴板受权限限制,转而使用浏览器内置的DataTransfer对象,手动构造粘贴事件,绕开权限限制,代码如下:

// 转换纯文本(兼容纯文本场景)
const plainText = convertHtmlToPlainText(htmlContent)
// 1. 构造虚拟剪贴板(DataTransfer)
const clipboardData = new DataTransfer()
// 2. 存入多格式数据(关键:必须有text/html)
clipboardData.setData('text/html', htmlContent)
clipboardData.setData('text/plain', plainText)

// 3. 构造粘贴事件
const pasteEvent = new ClipboardEvent('paste', {
  bubbles: true,
  cancelable: true,
  clipboardData
})

// 4. 修复clipboardData属性(部分浏览器需手动定义)
Object.defineProperty(pasteEvent, 'clipboardData', {
  value: clipboardData,
  configurable: true
})

// 5. 触发粘贴事件,编辑器自动识别
targetElement.dispatchEvent(pasteEvent)
dispatchEditorEvents(targetElement)

运行后完美生效:编辑器无法区分这是模拟粘贴还是真实用户粘贴,会正常解析text/html中的图片,触发CDN上传。

三、核心技术原理拆解(技术广度延伸)

1. 剪贴板底层机制(必懂)

(1)剪贴板的本质:多格式数据包

剪贴板的核心设计目的是“跨应用兼容”,同一份内容会同时存储多种表示形式,供不同应用按需取用,常见格式分为3大类:

  • 文本类:text/plain(纯文本)、text/html(富文本)、text/uri-list(链接);
  • 图像类:macOS下的public.tiff、public.png、public.jpeg,Windows下的CF_BITMAP,对应JS中的image/png、image/jpeg;
  • 文件类:public.file-url(本地文件引用,如file:///.file/id=xxx)、application/octet-stream(二进制文件)。
(2)不同复制场景的剪贴板数据差异
复制场景 剪贴板中的格式 编辑器行为
网页复制带图片的HTML text/plain + text/html + 图片二进制 识别图片,触发上传
控制台复制HTML字符串 仅text/plain 视为纯文本,不识别图片
访达(macOS)复制本地图片 public.file-url + 图片二进制 读取本地文件,触发上传
截图后粘贴 public.tiff(macOS)/ CF_BITMAP(Windows) 识别为图片,触发上传
(3)编辑器识别图片的核心规则

富文本编辑器判断“是否是图片粘贴”,仅看2点,优先级从高到低:

  1. 剪贴板中是否有图像类格式(public.tiff、public.png、image/png等);
  2. 剪贴板中是否有text/html格式,且包含结构;
  3. 仅存在text/plain格式(无论内容是否含HTML标签),均视为纯文本,不识别图片。

2. DataTransfer对象(前端模拟交互的核心)

(1)官方定位与核心作用

DataTransfer是浏览器原生对象,专门用于“在页面内部搬运多格式数据”,核心场景是:拖放(Drag & Drop)、剪贴板事件(copy/cut/paste)、手动模拟交互。它的本质是“内存版虚拟剪贴板”,不涉及系统剪贴板,无权限限制,可自由构造多格式数据。

(2)与系统剪贴板的区别
特性 DataTransfer(虚拟剪贴板) 系统剪贴板(navigator.clipboard)
权限要求 无,纯前端内存操作 需用户交互(click/keydown)
数据存储 仅页面内存,页面刷新丢失 系统级存储,跨应用共享
使用场景 模拟粘贴、拖放、页面内部数据传递 跨页面、跨应用复制粘贴
兼容性 所有现代浏览器,无兼容问题 部分浏览器需HTTPS环境
(3)常用API(实战必备)
// 1. 创建DataTransfer实例
const dt = new DataTransfer()

// 2. 存入数据(支持多格式)
dt.setData('text/plain', '纯文本内容')
dt.setData('text/html', '<p>富文本内容</p>')

// 3. 读取数据
const html = dt.getData('text/html')
const plain = dt.getData('text/plain')

// 4. 操作文件(拖放/模拟文件上传)
dt.items.add(new File([blob], 'test.png', { type: 'image/png' }))
const files = dt.files // 获取文件列表

// 5. 查看所有数据类型
const types = dt.types // 如:["text/plain", "text/html"]

// 6. 清空数据(可指定格式)
dt.clearData() // 清空所有
dt.clearData('text/plain') // 仅清空纯文本
(4)核心使用场景(不止模拟粘贴)
  • 模拟粘贴:本文核心场景,绕开系统剪贴板权限限制,让编辑器识别富文本;
  • 模拟拖放:构造文件/数据,触发drop事件,实现“自动拖入文件”功能;
  • 拦截修改粘贴内容:监听paste事件,净化HTML、过滤无效样式,优化粘贴体验;
  • 页面内部数据传递:跨组件、跨区域传递多格式数据(如拖拽排序、组件间数据搬运)。

3. 浏览器安全策略补充

本次排查中遇到的navigator.clipboard报错,本质是浏览器的安全限制,核心规则如下:

  • 剪贴板API(navigator.clipboard)仅允许在“用户主动交互”时调用,无交互时会拒绝执行;
  • DataTransfer属于页面内部操作,不涉及系统资源,因此无此限制;
  • 富文本编辑器不信任纯文本中的HTML标签,本质是为了防止XSS攻击(避免恶意脚本通过粘贴注入)。

四、问题总结与实战建议

1. 问题核心总结

本次图片粘贴失败的核心原因,是“剪贴板格式不完整”和“系统剪贴板权限限制”:

  • 手动复制HTML字符串,仅生成text/plain格式,编辑器无法识别为富文本;
  • 系统剪贴板受交互权限限制,无法直接通过脚本写入;
  • DataTransfer可构造完整的多格式数据,模拟真实粘贴事件,完美解决上述问题。

2. 实战避坑建议

  1. 模拟富文本粘贴时,优先使用DataTransfer,而非navigator.clipboard,避免权限问题;
  2. 构造text/html时,务必包含开头,符合浏览器富文本粘贴标准;
  3. 监听粘贴事件时,优先读取text/html格式,而非text/plain,确保编辑器能识别富文本结构;
  4. 遇到剪贴板相关问题,先打印clipboardData.types和对应格式的数据,快速定位是否是格式缺失。

3. 技术延伸思考

本次排查看似是一个简单的粘贴问题,却串联起剪贴板机制、DataTransfer、浏览器安全策略等多个前端底层知识点。前端开发中,很多“看似诡异”的问题,本质都是对底层原理理解不透彻——比如为什么不同软件粘贴同一张图片表现不同?为什么手动复制和网页复制的效果有差异?掌握这些底层逻辑,不仅能快速解决问题,更能应对复杂场景的开发需求。

五、最终可用工具函数(直接复用)

封装模拟富文本粘贴的万能工具函数,可直接用于各类富文本编辑器:

/**
 * 模拟富文本粘贴,触发编辑器图片上传
 * @param {HTMLElement} targetElement - 富文本编辑器容器
 * @param {string} htmlContent - 带图片的HTML内容(需包含<meta charset='utf-8'>)
 */
function simulateRichTextPaste(targetElement, htmlContent) {
  // 转换纯文本(兼容纯文本场景)
  const convertHtmlToPlainText = (html) => {
    const temp = document.createElement('div');
    temp.innerHTML = html;
    return temp.textContent || temp.innerText || '';
  };
  const plainText = convertHtmlToPlainText(htmlContent);

  // 构造虚拟剪贴板
  const clipboardData = new DataTransfer();
  clipboardData.setData('text/html', htmlContent);
  clipboardData.setData('text/plain', plainText);

  // 构造粘贴事件
  const pasteEvent = new ClipboardEvent('paste', {
    bubbles: true,
    cancelable: true,
    clipboardData
  });

  // 修复部分浏览器clipboardData属性不可访问问题
  Object.defineProperty(pasteEvent, 'clipboardData', {
    value: clipboardData,
    configurable: true
  });

  // 触发粘贴事件
  targetElement.dispatchEvent(pasteEvent);

  // 触发编辑器内部事件(根据编辑器类型调整,如Tiptap/Quill)
  const dispatchEditorEvents = (el) => {
    const inputEvent = new Event('input', { bubbles: true });
    el.dispatchEvent(inputEvent);
  };
  dispatchEditorEvents(targetElement);

  console.log('✅ 模拟富文本粘贴成功,编辑器已识别内容');
}

// 使用示例
const html = `
<meta charset='utf-8'>
<html>
<head></head>
<body>
  <p>测试粘贴图片</p>
  <p><img src="data:image/png;base64,xxx" alt="测试图片"></p>
</body>
</html>
`;
// 传入编辑器容器和HTML内容
simulateRichTextPaste(document.querySelector('.editor'), html);

六、结语

前端开发中,很多问题看似“偶发”,实则是底层原理的必然体现。本次从富文本图片粘贴失败的排查,深入学习了剪贴板的多格式机制、DataTransfer的核心用法和浏览器安全策略,不仅解决了实际问题,更完善了前端知识体系。希望本文能帮助大家在遇到同类问题时少走弯路,同时也能感受到“从问题出发,深挖底层原理”的学习价值——只有理解本质,才能真正做到举一反三。

深入解析 Vite dev:从命令行到浏览器热更新的完整旅程

在前端工程化领域,Vite 凭借其极致的开发体验和强大的构建能力,已成为新一代开发工具链的事实标准。随着 Vite 8 的正式发布,这套工具在性能和架构上再次实现突破——底层打包器统一为 Rust 编写的 Rolldown,开发环境启动速度和热更新响应迈入毫秒级时代。而作为开发者每天最常接触的命令行入口,vite dev 和 vite serve 背后承载着怎样的设计理念?它们又有哪些鲜为人知的细节?本文将为你一一揭晓。

命令本质:开发服务器的统一入口

在 Vite 8 中,vite dev 与 vite serve 实际上是同一个命令的两种不同叫法,二者完全等价。

vite
vite dev
vite serve

之所以保留两个名称,主要是为了兼容过往的习惯(如 serve 源自早期版本,而 dev 更直观地表达开发用途)。

vite 启动通用的命令行参数?

// 定义 Vite 命令行工具
const cli = cac('vite')

cli
  .option('-c, --config <file>', `[string] use specified config file`)
  .option('--base <path>', `[string] public base path (default: /)`, {
    type: [convertBase],
  })
  .option('-l, --logLevel <level>', `[string] info | warn | error | silent`)
  .option('--clearScreen', `[boolean] allow/disable clear screen when logging`)
  .option(
    '--configLoader <loader>',
    `[string] use 'bundle' to bundle the config with Rolldown, or 'runner' (experimental) to process it on the fly, or 'native' (experimental) to load using the native runtime (default: bundle)`,
  )
  .option('-d, --debug [feat]', `[string | boolean] show debug logs`)
  .option('-f, --filter <filter>', `[string] filter debug logs`)
  .option('-m, --mode <mode>', `[string] set env mode`)
# 指定配置文件路径
vite dev --config my.config.js

# 设置公共路径,默认 /
vite dev --base /my-app/

# 设置日志级别
vite dev --logLevel error` # 只输出错误

vite dev --clearScreen # 启用清屏
vite dev --no-clearScreen # 禁用清屏

# `bundle`(默认):使用 Rolldown 将配置文件打包后执行。
# `runner`(实验性):使用动态 `import()` 即时处理配置文件。
# `native`(实验性):使用原生 Node.js 模块加载(需配置文件为 ESM)。
vite dev --configLoader runner # 使用 Rolldown 将配置文件打包后执行

vite dev --debug               # 开启全部调试日志
vite dev --debug vite:hmr      # 仅显示 HMR 相关调试信息

# 指定运行模式(如 `development`、`production`、`staging`)。
# Vite 会加载对应的环境变量文件(例如 `.env.[mode]`),并影响 `import.meta.env.MODE` 的值。
vite dev --mode staging

vite dev 启动接收的命令行参数

在当前目录下启动 Vite 开发服务器。vite dev 和 vite serve 是 vite 的别名。

cli
  .command('[root]', 'start dev server') // default command
  .alias('serve') // the command is called 'serve' in Vite's API
  .alias('dev') // alias to align with the script name
  .option('--host [host]', `[string] specify hostname`, { type: [convertHost] })
  .option('--port <port>', `[number] specify port`)
  .option('--open [path]', `[boolean | string] open browser on startup`)
  .option('--cors', `[boolean] enable CORS`)
  .option('--strictPort', `[boolean] exit if specified port is already in use`)
  .option(
    '--force',
    `[boolean] force the optimizer to ignore the cache and re-bundle`,
  )
  .option(
    '--experimentalBundle',
    `[boolean] use experimental full bundle mode (this is highly experimental)`,
  )
# 指定项目的根目录。如果不提供,默认使用当前工作目录(`process.cwd()`)
vite dev ./my-project

vite dev --host               # 监听所有接口
vite dev --host localhost     # 仅监听本地

vite dev --port 3000

vite dev --open               # 打开 http://localhost:5173/
vite dev --open /admin        # 打开 http://localhost:5173/admin

# 强制依赖优化器忽略缓存,重新预构建所有依赖(`optimizeDeps`)
vite dev --force

# 启用实验性的“全量打包开发模式”(`bundledDev`)
vite dev --experimentalBundle

启用实验性的“全量打包开发模式”,文件会被打包。会减少大量请求。

image.png

命令行执行 vite 后做了什么?

  1. 创建 server 实例
  2. 启动监听端口
async (
  root: string,
  options: ServerOptions & ExperimentalDevOptions & GlobalCLIOptions,
) => {
  filterDuplicateOptions(options)
  // output structure is preserved even after bundling so require()
  // is ok here
  // 动态导入并创建开发服务器
  const { createServer } = await import('./server')
  try {
    const server = await createServer({
      root,
      base: options.base,
      mode: options.mode,
      configFile: options.config,
      configLoader: options.configLoader,
      logLevel: options.logLevel,
      clearScreen: options.clearScreen,
      server: cleanGlobalCLIOptions(options),
      forceOptimizeDeps: options.force,
      experimental: {
        bundledDev: options.experimentalBundle,
      },
    })

    // 校验服务器实例并启动
    if (!server.httpServer) {
      throw new Error('HTTP server not available')
    }

    // 启动 HTTP 服务器监听指定端口
    await server.listen()

    // 输出启动日志
    const info = server.config.logger.info

    const modeString =
    // 非 development 模式,输出环境模式
      options.mode && options.mode !== 'development'
        ? `  ${colors.bgGreen(` ${colors.bold(options.mode)} `)}`
        : ''

    // 启动耗时(计算从 Vite 启动到服务器就绪的时间)
    const viteStartTime = global.__vite_start_time ?? false
    const startupDurationString = viteStartTime
      ? colors.dim(
          `ready in ${colors.reset(
            colors.bold(Math.ceil(performance.now() - viteStartTime)),
          )} ms`,
        )
      : ''
    // 检查是否有已存在的日志输出(避免重复打印)
    const hasExistingLogs =
      process.stdout.bytesWritten > 0 || process.stderr.bytesWritten > 0

    // 输出核心启动日志(Vite 版本 + 模式 + 启动耗时)
    info(
      `\n  ${colors.green(
        `${colors.bold('VITE')} v${VERSION}`,
      )}${modeString}  ${startupDurationString}\n`,
      {
        clear: !hasExistingLogs,
      },
    )

    // 打印服务器访问地址(如 http://localhost:3000/)
    server.printUrls()
    const customShortcuts: CLIShortcut<typeof server>[] = []
    if (profileSession) {
      customShortcuts.push({
        key: 'p',
        description: 'start/stop the profiler',
        async action(server) {
          if (profileSession) {
            await stopProfiler(server.config.logger.info)
          } else {
            const inspector = await import('node:inspector').then(
              (r) => r.default,
            )
            await new Promise<void>((res) => {
              profileSession = new inspector.Session()
              profileSession.connect()
              profileSession.post('Profiler.enable', () => {
                profileSession!.post('Profiler.start', () => {
                  server.config.logger.info('Profiler started')
                  res()
                })
              })
            })
          }
        },
      })
    }
    // 绑定快捷键到服务器(print: true 表示打印快捷键说明)
    server.bindCLIShortcuts({ print: true, customShortcuts })
  } catch (e) {
    const logger = createLogger(options.logLevel)
    logger.error(
      colors.red(`error when starting dev server:\n${inspect(e)}`),
      {
        error: e,
      },
    )
    await stopProfiler(logger.info)
    process.exit(1)
  }
},

image.png

image.png

// 启动 HTTP 服务器监听指定端口
async listen(port?: number, isRestart?: boolean) {
  // 解析主机名
  const hostname = await resolveHostname(config.server.host)
  if (httpServer) {
    httpServer.prependListener('listening', () => {
      // 解析服务器监听的 URL 地址
      server.resolvedUrls = resolveServerUrls(
        httpServer,
        config.server,
        hostname,
        httpsOptions,
        config,
      )
    })
  }
  // 启动 HTTP 服务器
  await startServer(server, hostname, port)
  if (httpServer) {
    // 如果不是重启,配置了 open 选项打开浏览器
    if (!isRestart && config.server.open) server.openBrowser()
  }
  return server
},

createServer 函数做了什么工作?

  1. 参数解析与配置校验。
  2. 服务器基础设施创建(HTTP/WS/中间件/文件监听)。
  3. 多环境(environments)初始化。
  4. 服务器对象构建与向后兼容。
  5. 中间件栈构建。
  6. 文件变化监听与 HMR。
  7. 启动服务器逻辑。
  8. 返回 server 实例。

一、config 解析

  1. 加载配置文件:读取 vite.config.js / vite.config.ts(可通过 --config 指定其他文件)。如果文件是 TypeScript,Vite 会使用 esbuild 或 rolldown 动态编译。
  2. 合并命令行参数:命令行选项优先级高于配置文件。
  3. 应用默认值:补充未提供的选项(如 root 默认为 process.cwd()base 默认为 /)。
  4. 加载环境变量:根据 mode(默认 development)读取 .env 和 .env.[mode] 文件,注入 process.env 和 import.meta.env
  5. 加载插件:收集用户配置中的 plugins 数组,调用每个插件的 config 钩子(允许插件修改配置),最后调用 configResolved 钩子通知插件配置已解析完成。
  6. 生成 ResolvedConfig:输出完整的、只读的配置对象,包含 serverbuildoptimizeDepsenvironments 等字段。

image.png

image.png

二、服务器基础设施创建(HTTP/WS/中间件/文件监听)

  // 3、网络服务构建
  const middlewares = connect() as Connect.Server

  // middlewareMode 为 true 时,不解析 HTTP 服务器,以中间件模式创建;否则解析 HTTP 服务器
  const httpServer = middlewareMode
    ? null
    : await resolveHttpServer(middlewares, httpsOptions)

  // 创建 WebSocket 服务器
  const ws = createWebSocketServer(httpServer, config, httpsOptions)

新建 HTTP 服务

async function resolveHttpServer(
  app: Connect.Server,
  httpsOptions?: HttpsServerOptions,
): Promise<HttpServer> {
  // 如果没有 httpsOptions,创建 HTTP 服务器
  if (!httpsOptions) {
    // http 模块在 net 的基础上增加了 HTTP 协议解析和封装能力。
    // 当你创建一个 HTTP 服务器时,实际底层是一个 net.Server
    const { createServer } = await import('node:http')
    return createServer(app) // 创建 HTTP 服务器
  }

  // 如果有 httpsOptions,创建 HTTPS 服务器
  const { createSecureServer } = await import('node:http2')
  return createSecureServer(
    {
      // Manually increase the session memory to prevent 502 ENHANCE_YOUR_CALM
      // errors on large numbers of requests
      maxSessionMemory: 1000, // 增加会话内存,防止 502 错误
      // Increase the stream reset rate limit to prevent net::ERR_HTTP2_PROTOCOL_ERROR
      // errors on large numbers of requests
      streamResetBurst: 100000, // 增加流重置突发量,防止 net::ERR_HTTP2_PROTOCOL_ERROR 错误
      streamResetRate: 33, // 增加流重置速率,防止 net::ERR_HTTP2_PROTOCOL_ERROR 错误
      ...httpsOptions, // 合并 httpsOptions 选项
      allowHTTP1: true, // 允许 HTTP/1 协议
    },
    // @ts-expect-error TODO: is this correct?
    app,
  )
}

三、 多环境(environments)初始化

Vite 8 引入了多环境(Environments)概念,每个环境(如 clientssr)拥有独立的模块图、插件容器和依赖优化器。

  const environments: Record<string, DevEnvironment> = {}

  // 多环境(Environments)初始化
  await Promise.all(
    Object.entries(config.environments).map(
      async ([name, environmentOptions]) => {
        const environment = await environmentOptions.dev.createEnvironment(
          name,
          config,
          {
            ws,
          },
        )
        environments[name] = environment

        const previousInstance =
          options.previousEnvironments?.[environment.name]
        await environment.init({ watcher, previousInstance })
      },
    ),
  )

四、环境向后兼容

在 Vite 8 引入多环境(environments)之前,Vite 只有一个全局的模块图。升级到 Vite 8 后,每个环境(clientssr)有了自己独立的模块图,但为了不破坏现有的插件和 API,Vite 需要提供一个兼容层,使得老代码依然可以通过 server.moduleGraph 访问模块图。

五、中间件栈构建

  1. 请求计时器(仅 DEBUG 模式)
  2. 拒绝无效请求(过滤包含空格等非法字符的请求)
  3. CORS 中间件(默认启用)
  4. 主机验证(防止 DNS 重绑定攻击)
  5. 用户插件 configureServer 钩子(允许插件注入自定义中间件)
  6. 缓存转换中间件(若未启用 bundledDev
  7. 代理中间件(将 /api 等请求转发到后端服务器)
  8. Base 路径中间件(处理 base 配置)
  9. 编辑器打开支持/__open-in-editor
  10. HMR Ping 处理(响应客户端心跳)
  11. public 目录静态服务(直接返回 public 下的文件)
  12. 转换中间件(核心) :拦截对 .js.vue.ts 等文件的请求,调用插件链进行转换,返回最终代码。
  13. 静态文件服务:返回项目根目录下未被转换的静态资源。
  14. HTML fallback(SPA 模式下,未匹配路径返回 index.html
  15. index.html 转换中间件:注入客户端脚本(/@vite/client)和环境变量。
  16. 404 处理
  17. 错误处理中间件

六、利用 chokidar,文件变化监听

  // 9、文件变更事件处理
  // 监听文件变化事件
  watcher.on('change', async (file) => {
    file = normalizePath(file)
    // 检查是否是 TypeScript 配置文件变化,如果是则重启服务器
    reloadOnTsconfigChange(server, file)

    await Promise.all(
      Object.values(server.environments).map((environment) =>
        // 通知所有环境的插件容器文件已更新
        environment.pluginContainer.watchChange(file, { event: 'update' }),
      ),
    )
    // invalidate module graph cache on file change
    for (const environment of Object.values(server.environments)) {
      environment.moduleGraph.onFileChange(file)
    }
    // 触发热模块替换更新,将变更同步到客户端
    await onHMRUpdate('update', file)
  })

  // 监听文件添加事件
  watcher.on('add', (file) => {
    onFileAddUnlink(file, false)
  })
  // 监听文件删除事件
  watcher.on('unlink', (file) => {
    onFileAddUnlink(file, true)
  })
修改 tsconfig.app.json

image.png

修改 tsconfig.json文件

会全量刷新,执行 location.reload()

4:13:20 PM [vite] changed tsconfig file detected: /Users/xxxx/Documents/code/cloudcode/vue3-vite-cube/tsconfig.json - Clearing cache and forcing full-reload to ensure TypeScript is compiled with updated config values. (x2)

image.png

{
    "type": "custom",
    "event": "file-changed",
    "data": {
        "file": "/Users/xxx/Documents/code/cloudcode/vue3-vite-cube/tsconfig.json"
    }
}
修改 vue 页面的 script setup 块template 块
{
    "type": "custom",
    "event": "file-changed",
    "data": {
        "file": "/Users/xxx/Documents/code/cloudcode/vue3-vite-cube/src/pages/home/index.vue"
    }
}
{
    "type": "update",
    "updates": [
        {
            "type": "js-update",
            "timestamp": 1775036864943,
            "path": "/src/pages/home/index.vue",
            "acceptedPath": "/src/pages/home/index.vue",
            "explicitImportRequired": false,
            "isWithinCircularImport": false
        }
    ]
}

image.png

修改 vue 页面的 style 块

image.png

{
    "type": "custom",
    "event": "file-changed",
    "data": {
        "file": "/Users/xxxxxx/Documents/code/cloudcode/vue3-vite-cube/src/pages/home/index.vue"
    }
}
{
    "type": "update",
    "updates": [
        {
            "type": "js-update",
            "timestamp": 1775037234261,
            "path": "/src/pages/home/index.vue",
            "acceptedPath": "/src/pages/home/index.vue",
            "explicitImportRequired": false,
            "isWithinCircularImport": false
        },
        {
            "type": "js-update",
            "timestamp": 1775037234261,
            "path": "/src/pages/home/index.vue?vue&type=style&index=0&scoped=2c5296db&lang.css",
            "acceptedPath": "/src/pages/home/index.vue?vue&type=style&index=0&scoped=2c5296db&lang.css",
            "explicitImportRequired": false, // 示是否需要显式动态导入新模块
            "isWithinCircularImport": false // 表示是否处于循环依赖中
        }
    ]
}

image.png

七、启动服务器逻辑

真正启动服务器在 cli 中 server.listen 执行。

这里只是重写 listen方法 ,待启动服务器时执行。

  • 调用 server.listen()
  • 监听配置的端口(默认 5173)
  • 启动完成后执行回调
  • 自动打开浏览器(如果配置 server.open
  • 终端打印
  let initingServer: Promise<void> | undefined
  let serverInited = false // 标记服务器是否已初始化

  if (!middlewareMode && httpServer) {
    // overwrite listen to init optimizer before server start
    const listen = httpServer.listen.bind(httpServer)
    // 重写 listen 方法,确保在服务器启动前初始化优化器
    httpServer.listen = (async (port: number, ...args: any[]) => {
      try {
        await initServer(true)
      } catch (e) {
        httpServer.emit('error', e)
        return
      }
      // 调用原始 listen 方法启动服务器
      return listen(port, ...args)
    }) as any
  } else {
    await initServer(false)
  }
  const initServer = async (onListen: boolean) => {
    if (serverInited) return // 如果服务器已初始化,直接返回
    if (initingServer) return initingServer // 如果服务器正在初始化,直接返回

    initingServer = (async function () {
      // 如果没有配置 bundledDev,则在初始化服务器时调用 buildStart 方法
      if (!config.experimental.bundledDev) {
        // For backward compatibility, we call buildStart for the client
        // environment when initing the server. For other environments
        // buildStart will be called when the first request is transformed
        await environments.client.pluginContainer.buildStart()
      }

      // ensure ws server started
      // 确保 WebSocket 服务器已启动
      if (onListen || options.listen) {
        await Promise.all(
          // 确保所有环境的服务器都启动
          Object.values(environments).map((e) => e.listen(server)),
        )
      }

      initingServer = undefined // 清空初始化 Promise
      serverInited = true // 标记服务器已初始化
    })()
    return initingServer
  }

热更新

Vite 的热更新(HMR)基于原生 ES 模块和 WebSocket 实现,能在文件修改后仅更新受影响的模块,无需刷新页面,从而保留应用状态。其原理可分为服务端和客户端两个阶段。

服务端:变化检测与消息推送

一、文件检测

Vite 使用 chokidar 库来监听文件系统的变化。在 _createServer 函数中,会创建一个文件监听器(watcher),监听范围包括:

  • 项目根目录(root
  • 配置文件依赖(config.configFileDependencies
  • 环境变量文件(.env 等)
  • public 目录
(chokidar.watch(
    // config file dependencies and env file might be outside of root
    [
      ...(config.experimental.bundledDev ? [] : [root]),
      ...config.configFileDependencies,
      ...getEnvFilesForMode(config.mode, config.envDir),
      // Watch the public directory explicitly because it might be outside
      // of the root directory.
      ...(publicDir && publicFiles ? [publicDir] : []),
    ],

    resolvedWatchOptions,
  ) as FSWatcher)

二、模块图与依赖分析

1、 模块图的数据结构

  • urlToModuleMap:根据 URL 查找模块节点。
  • fileToModulesMap:根据文件路径查找对应的模块节点(一个文件可能对应多个模块,如 ?import 和 ?url 查询)。
  • 每个模块节点(ModuleNode)记录了:
    • importers:依赖该模块的模块(即父模块)。
    • importedModules:该模块导入的子模块。

2、依赖分析

当文件发生变化时,handleHMRUpdate 会执行以下步骤:

  1. 根据文件路径找到对应的模块节点(moduleGraph.getModulesByFile(file))。
  2. 遍历这些模块节点,收集所有受影响的模块(包括自己以及所有 importers 链上的模块)。
  3. 通过模块图向上追溯,找到所有依赖该模块的模块,直到没有更多依赖者为止

三、重新编译与生成更新消息

对于每个受影响的模块,Vite 调用 environment.transformRequest(url) 重新进行转换。该函数会经过完整的插件链(resolveId → load → transform),生成新的模块代码和 source map,并更新模块图中的 transformResult 缓存。

编译过程中,Vite 会记录一个时间戳(timestamp),用于客户端绕过浏览器缓存。

四、Websocket 推送消息

Vite 开发服务器内置了一个 WebSocket 服务器,用于与客户端通信。当 update 消息生成后,Vite 会通过 WebSocket 将其推送给所有已连接的客户端。

客户端:接收消息并执行更新

一、客户端初始化与 Websocket 连接

1、注入客户端脚本

当浏览器请求 index.html 时,Vite 的 indexHtmlMiddleware 会调用 clientPlugin 的 transformIndexHtml 钩子,在 HTML 中自动注入 <script type="module" src="/@vite/client">,该脚本负责建立 WebSocket 连接,暴露 HMR API。

image.png

2、建立 WebSocket 连接

客户端脚本首先会创建一个 WebSocket 连接指向开发服务器(默认地址 ws://localhost:5173)。同时,它会监听 openmessagecloseerror 等事件。

连接成功后,服务端会发送 { type: 'connected' } 消息,客户端收到后标记为就绪状态。

3、暴露 import.meta.hot API

客户端在全局维护了几个 Map 结构,用于存储每个模块注册的 HMR 回调(acceptdispose 等)。同时,它定义了一个 createHotContext 函数,该函数返回一个包含 acceptdisposeinvalidate 等方法的对象。

二、接收消息与类型分发

客户端 WebSocket 的 message 事件处理函数负责解析 JSON 消息,并根据 type 字段分发到不同的处理逻辑。

客户端 WebSocket 收到消息后,根据 type 进行处理:

  • connected, 标记就绪,可发送预热请求。
  • update:遍历 updates 数组,对每个更新执行热替换。
  • full-reload:调用 location.reload() 刷新页面。
  • prune,
  • custom,自定义事件。
  • error:在页面上显示错误覆盖层。
  • ping,不做处理。
async function handleMessage(payload: HotPayload) {
  switch (payload.type) {
    // WebSocket 和服务器握手成功,打印日志。
    case 'connected':
      console.debug(`[vite] connected.`)
      break
    // JS/CSS 热更新
    case 'update':
      // 通知所有插件 / 监听:马上要热更新了
      // 用于在热更新前执行自定义逻辑,例如刷新页面
      await hmrClient.notifyListeners('vite:beforeUpdate', payload)
      if (hasDocument) {
        // if this is the first update and there's already an error overlay, it
        // means the page opened with existing server compile error and the whole
        // module script failed to load (since one of the nested imports is 500).
        // in this case a normal update won't work and a full reload is needed.
        // 首次更新容错 + 清理错误
        if (isFirstUpdate && hasErrorOverlay()) {
          // 如果页面一打开就报错(编译失败),第一次热更新直接全页刷新,确保能正常运行
          location.reload() // 刚打开页面就报错,直接刷新修复
          return
        } else {
          if (enableOverlay) {
            clearErrorOverlay() // 清空之前的报错
          }
          isFirstUpdate = false
        }
      }
      // 所有文件更新并行处理,速度极快
      await Promise.all(
        payload.updates.map(async (update): Promise<void> => {
          if (update.type === 'js-update') {
            return hmrClient.queueUpdate(update) // 交给核心引擎更新JS
          }

          // css-update
          // this is only sent when a css file referenced with <link> is updated
          const { path, timestamp } = update
          const searchUrl = cleanUrl(path)
          // can't use querySelector with `[href*=]` here since the link may be
          // using relative paths so we need to use link.href to grab the full
          // URL for the include check.
          // 找到页面对应的旧 <link> 标签
          // 页面 <link href="style.css"> 是相对路径
          // e.href 会返回 http://localhost:5173/src/style.css 完整 URL
          const el = Array.from(
            document.querySelectorAll<HTMLLinkElement>('link'),
          ).find(
            (e) =>
              !outdatedLinkTags.has(e) && cleanUrl(e.href).includes(searchUrl),
          )

          if (!el) {
            return
          }

          // 拼接带时间戳的新 CSS 路径
          const newPath = `${base}${searchUrl.slice(1)}${
            searchUrl.includes('?') ? '&' : '?'
          }t=${timestamp}`

          // rather than swapping the href on the existing tag, we will
          // create a new link tag. Once the new stylesheet has loaded we
          // will remove the existing link tag. This removes a Flash Of
          // Unstyled Content that can occur when swapping out the tag href
          // directly, as the new stylesheet has not yet been loaded.
          return new Promise((resolve) => {
            // 克隆新 link 标签,不直接改旧 href
            const newLinkTag = el.cloneNode() as HTMLLinkElement
            newLinkTag.href = new URL(newPath, el.href).href
            const removeOldEl = () => {
              el.remove()
              console.debug(`[vite] css hot updated: ${searchUrl}`)
              resolve()
            }
            // 等新 CSS 加载完成后,再删除旧标签
            newLinkTag.addEventListener('load', removeOldEl)
            newLinkTag.addEventListener('error', removeOldEl)
            // 缓存新标签,避免重复删除
            outdatedLinkTags.add(el)
            // 插入新标签到旧标签后面
            el.after(newLinkTag)
          })
        }),
      )
      // 触发更新完成事件
      // 通知插件 / 框架:热更新完成
      await hmrClient.notifyListeners('vite:afterUpdate', payload)
      break
    //  处理 custom 自定义消息
    case 'custom': {
      await hmrClient.notifyListeners(payload.event, payload.data)

      if (payload.event === 'vite:ws:disconnect') {
        // dom环境,且页面未卸载
        if (hasDocument && !willUnload) {
          console.log(`[vite] server connection lost. Polling for restart...`)
          const socket = payload.data.webSocket as WebSocket
          const url = new URL(socket.url)
          url.search = '' // remove query string including `token`
          await waitForSuccessfulPing(url.href) // 轮询等待服务器重启
          location.reload() // 服务器回来后,自动刷新页面
        }
      }
      break
    }
    // 处理 full-reload 全页刷新
    case 'full-reload':
      await hmrClient.notifyListeners('vite:beforeFullReload', payload)
      if (hasDocument) {
        if (payload.path && payload.path.endsWith('.html')) {
          // if html file is edited, only reload the page if the browser is
          // currently on that page.
          const pagePath = decodeURI(location.pathname)
          const payloadPath = base + payload.path.slice(1)
          if (
            pagePath === payloadPath ||
            payload.path === '/index.html' ||
            (pagePath.endsWith('/') && pagePath + 'index.html' === payloadPath)
          ) {
            pageReload()
          }
          return
        } else {
          pageReload()
        }
      }
      break
    //  处理 prune 清理模块
    case 'prune':
      await hmrClient.notifyListeners('vite:beforePrune', payload)
      await hmrClient.prunePaths(payload.paths)
      break
    // 显示红色错误遮罩
    case 'error': {
      await hmrClient.notifyListeners('vite:error', payload)
      if (hasDocument) {
        const err = payload.err
        if (enableOverlay) {
          createErrorOverlay(err)
        } else {
          console.error(
            `[vite] Internal Server Error\n${err.message}\n${err.stack}`,
          )
        }
      }
      break
    }
    // 处理 ping 消息,心跳检测,不处理任何逻辑
    case 'ping': // noop
      break
    // 处理默认情况
    default: {
      const check: never = payload
      return check
    }
  }
}

三、处理 update 消息(热更新)

  1. 请求新模块(带时间戳),每个 update 对象包含 pathacceptedPathtimestamp 等字段。客户端构造新的 UR,利用 ?t=timestamp 强制绕过浏览器缓存。使用动态 import() 获取模块的导出对象。
  2. 执行 dispose 回调(清理旧资源),在替换模块之前,需要先执行旧模块注册的 dispose 回调(如果有),以便清理定时器、事件监听等。
  3. 找到接受更新的模块,
  4. 针对css处理。如果 update.type === 'css-update',客户端不会通过 import() 请求,而是直接替换页面中的 <link> 或 <style> 标签。
  5. 失败回退(full-reload),如果更新过程中发生错误(例如网络请求失败、回调抛出异常),或者找不到任何接受回调,客户端会发送 full-reload 指令,刷新整个页面以确保应用状态正确。

image.png

image.png

客户端执行 js-update

importUpdatedModule 是 Vite HMR 的模块更新加载器:拼接带时间戳的最新 URL,动态加载新代码 ,循环依赖异常时自动刷新。

  // 普通 ESM 模式
  // 动态加载最新的模块代码 → 解决浏览器缓存 → 处理循环依赖错误
  async function importUpdatedModule({
    acceptedPath, // 要更新的模块路径
    timestamp, // 模块更新时间戳
    explicitImportRequired, // 是否显式导入
    isWithinCircularImport, // 是否在循环依赖里
  }) {
    // 拆分路径
    const [acceptedPathWithoutQuery, query] = acceptedPath.split(`?`)
    const importPromise = import(
      /* @vite-ignore */ // 告诉 vite 不解析这个动态导入,由浏览器负责加载
      base +
      // 移除前导斜杠,确保路径正确
        acceptedPathWithoutQuery.slice(1) +
        // timestamp 用于刷新浏览器缓存,确保加载最新代码
        `?${explicitImportRequired ? 'import&' : ''}t=${timestamp}${
          query ? `&${query}` : ''
        }`
    )
    if (isWithinCircularImport) {
      // 循环依赖, 热更失败 → 自动刷新页面
      importPromise.catch(() => {
        console.info(
          `[hmr] ${acceptedPath} failed to apply HMR as it's within a circular import. Reloading page to reset the execution order. ` +
            `To debug and break the circular import, you can run \`vite --debug hmr\` to log the circular dependency path if a file change triggered it.`,
        )
        pageReload()
      })
    }
    // 返回模块
    return await importPromise
  }

importUpdatedModule 负责用原生 ESM 加载最新代码,通知 Rolldown 运行时更新模块导出,循环依赖异常自动刷新页面。

  // 打包开发模式(bundledDev)
  async function importUpdatedModule({
    url,
    acceptedPath,
    isWithinCircularImport, // 是否在循环依赖里
  }) {
    // 加载新代码,并通知 Rolldown 运行时更新模块
    // import(base + url!) 浏览器原生 ESM 动态导入
    // 浏览器发起网络请求 → 访问 Vite 开发服务器
    // url 已带时间戳 → 强制不缓存,加载最新版
    const importPromise = import(base + url!).then(() =>
      // @ts-expect-error globalThis.__rolldown_runtime__
      // 全局运行时.loadExports
      // __rolldown_runtime__:Rolldown 运行时(Vite 新一代底层打包 / 运行核心)
      // loadExports(acceptedPath)
        // → 告诉运行时:重新收集这个模块的最新导出
        // → 运行时会自动更新所有引用该模块的地方
      globalThis.__rolldown_runtime__.loadExports(acceptedPath),
    )
    // 循环依赖容错
    if (isWithinCircularImport) {
      // 热更失败 → 自动刷新页面
      importPromise.catch(() => {
        console.info(
          `[hmr] ${acceptedPath} failed to apply HMR as it's within a circular import. Reloading page to reset the execution order. ` +
            `To debug and break the circular import, you can run \`vite --debug hmr\` to log the circular dependency path if a file change triggered it.`,
        )
        pageReload()
      })
    }
    return await importPromise
  }

更新文件

  /**
   * 处理 HMR 更新
   * @param type 文件操作类型
   * @param file 文件路径
   */
  const onHMRUpdate = async (
    type: 'create' | 'delete' | 'update',
    file: string,
  ) => {
    // 如果 HMR 已启用,则处理 HMR 更新
    if (serverConfig.hmr !== false) {
      await handleHMRUpdate(type, file, server)
    }
  }

新增文件/删除文件


  /**
   * 处理文件添加或删除
   * @param file 文件路径
   * @param isUnlink 是否删除文件
   */
  const onFileAddUnlink = async (file: string, isUnlink: boolean) => {
    file = normalizePath(file)
    // 「检测文件是否为 tsconfig.json/jsconfig.json,若是则触发服务器重启」
    // 因为这类配置文件变更会影响模块解析规则,必须重启才能生效。
    reloadOnTsconfigChange(server, file)

    await Promise.all(
      // 通知所有环境的插件容器,同步文件变更事件
      Object.values(server.environments).map((environment) =>
        // 对每个环境,调用其插件容器的 watchChange 方法
        // 传递文件路径和事件类型('delete' 或 'create')
        environment.pluginContainer.watchChange(file, {
          event: isUnlink ? 'delete' : 'create',
        }),
      ),
    )

    if (publicDir && publicFiles) {
      if (file.startsWith(publicDir)) {
        const path = file.slice(publicDir.length)
        publicFiles[isUnlink ? 'delete' : 'add'](path)

        // 新增文件时:清理同名模块的 ETag 缓存,保证公共文件优先响应
        // Vite 会为模块生成 ETag(实体标签),用于「ETag 快速路径」—— 客户端请求时,若 ETag 未变,直接返回缓存的模块内容
        if (!isUnlink) {
          // 获取客户端环境的模块图实例
          const clientModuleGraph = server.environments.client.moduleGraph
          // 根据路径 path(如 /image.png)查找模块图中是否存在同名模块
          const moduleWithSamePath =
            await clientModuleGraph.getModuleByUrl(path)

          const etag = moduleWithSamePath?.transformResult?.etag

          // 如果有etag ,则删除。
          // 保证 public 下文件等优先级
          if (etag) {
            // The public file should win on the next request over a module with the
            // same path. Prevent the transform etag fast path from serving the module
            clientModuleGraph.etagToModuleMap.delete(etag)
          }
        }
      }
    }
    // 文件删除时,清理模块依赖图缓存
    if (isUnlink) {
      // invalidate module graph cache on file change
      for (const environment of Object.values(server.environments)) {
        environment.moduleGraph.onFileDelete(file)
      }
    }
    // 触发 HMR 更新,同步变更到客户端
    await onHMRUpdate(isUnlink ? 'delete' : 'create', file)
  }

禁止热更新

  server: {
    ws: false,
  }

修改文件浏览器内容不会自动更新。

image.png

image.png

image.png

重启服务器

什么场景会触发开发服务器重启?

  1. 修改 vite.config.js 配置文件。
  2. 依赖文件修改,如 package.json
  3. 创建/修改 .env 环境文件。
  4. 插件中调用 server.restart

image.png

  // 配置文件、配置文件依赖、环境文件变化时,自动重启服务器
  if (isConfig || isConfigDependency || isEnv) {
    // auto restart server
    debugHmr?.(`[config change] ${colors.dim(shortFile)}`)

    // 打印日志
    config.logger.info(
      colors.green(
        `${normalizePath(
          path.relative(process.cwd(), file),
        )} changed, restarting server...`,
      ),
      { clear: true, timestamp: true },
    )
    try {
      // 重启服务器
      await restartServerWithUrls(server)
    } catch (e) {
      config.logger.error(colors.red(e))
    }
    return
  }

server.restart

重启服务器前,会先关闭服务器(包含 停止HTTP服务,停止Websocket 服务,关闭文件监听,关闭所有环境的 DevEnvironment 实例,释放模块图、插件容器、依赖优化器等资源)。

// 重启 Vite 开发服务器,同时处理并发重启请求,确保同一时间只有一个重启操作在执行。
async restart(forceOptimize?: boolean) {
  // 如果没有重启 Promise,创建一个
  if (!server._restartPromise) {
    // 设置是否强制优化依赖
    server._forceOptimizeOnRestart = !!forceOptimize
    // 重启服务器
    server._restartPromise = restartServer(server).finally(() => {
      // 重启完成后,重置重启 Promise 和强制优化依赖
      server._restartPromise = null
      server._forceOptimizeOnRestart = false
    })
  }
  // 如果存在,说明已经有一个重启操作在进行中,直接返回该 Promise
  return server._restartPromise
},

全量更新

什么场景会触发全量刷新?

  1. 修改index.html文件。
  2. 修改main.ts文件。
  3. 修改路由配置 router/index.ts 文件。

image.png

image.png

{
    "type": "full-reload",
    "triggeredBy": "/Users/xxxxxx/Documents/code/cloudcode/vue3-vite-cube/src/common/utils.ts",
    "path": "*"
}
  // (dev only) the client itself cannot be hot updated.
  // Vite 客户端自身文件变更 → 不能热更 → 必须整页刷新
  if (file.startsWith(withTrailingSlash(normalizedClientDir))) {
    environments.forEach(({ hot }) =>
      hot.send({
        type: 'full-reload',
        path: '*',
        triggeredBy: path.resolve(config.root, file),
      }),
    )
    return
  }

最后

  1. Websoket
  2. vite server 配置项

Flutter Signals 学习与项目实践

Flutter Signals 学习与项目实践

前言:
  • 最近抽了一些下班后的时间,把项目里原有的 Bloc/Cubit 状态管理完整迁到了 Signals。一开始学习 Signals 的时候,也看了不少文章和示例,很多内容都在讲 signalcomputedeffect 这些基础概念,但真正结合项目页面、网络请求、分页刷新、局部更新来讲的并不算多。
  • 所以这篇文章就结合当前这个 Demo,聊一聊 Signals 在 Flutter 项目中的实际使用方式、设计思路和常见问题。项目中也顺手对网络请求、页面状态、通用壳子、列表刷新做了一轮收敛,现在整体已经不只是“把 Bloc 改个名字”,而是逐步形成了一套更轻的 Signals 项目写法。
  • 本篇文章更偏实战一些,重点讲的是 Signals 在项目中的落地,而不是单独讲 API。老规矩,先从整体思路开始。
正文:

当前项目中的 Signals 使用,主要从下面几个维度来理解:

  1. Signals 的基本思想
  2. Signals 在页面中的完整使用流程
  3. Signals 如何做颗粒化刷新和局部刷新
  4. Signals 如何组织网络请求和页面状态
  5. 项目中基础层、路由和通用组件的搭配思路
  • Signals 基本思想

Signals 的核心目标很简单,就是让“状态”和“依赖这个状态的界面”建立更直接的响应关系。

在传统的 Bloc 模式里,我们通常是:

  • Event 触发操作
  • Bloc 处理逻辑
  • emit(state) 更新状态
  • BlocBuilder 根据新 state 重建页面

而在 Signals 里,思路会更直接:

  • 页面或 store 改变某个 signal
  • 依赖这个 signalWatch 自动更新

也就是说,Signals 更像是把“状态变更”和“界面依赖”做成了天然绑定,不需要再专门经过 Event -> emit -> rebuild 这条链路。

这套思路最大的好处就是:

  • 写法更轻

  • 状态拆分更自然

  • 局部刷新更容易实现

  • 不必为了一个小状态变动去维护一整个 State 对象

  • Signals 的核心概念:
  • signal 最基础的响应式状态。它持有一个值,当值发生变化时,依赖它的地方会自动更新。

  • computed 派生状态。它不直接存数据,而是基于其他 signal 计算得到,适合做按钮是否可点击、列表是否为空、是否显示某段 UI 这种逻辑。

  • effect 副作用监听。适合做日志、埋点、导航、联动处理等。项目里我更建议把大多数业务逻辑放到 store 里,effect 只做少量副作用处理。

  • Watch signals_flutter 提供的监听组件。Watch 内部只要读取了某个 signal,当这个 signal 改变时,对应 builder 就会自动刷新。

  • Store 项目里我比较推荐用 feature store 的思路,也就是每个页面或者每条业务链路一个 store,把该页面需要的 signal、请求逻辑、分页逻辑、副作用逻辑都收在一起。

  • 使用 Signals 开发一个页面完整流程

下面以“网络列表页”为例,说明一个页面从 0 到 1 的常见写法。

  1. 创建 store
class CartoonStore {
  final netState = signal(NetState.loadingState);
  final items = signal<List<CartoonModelDataInfos>>(<CartoonModelDataInfos>[]);
  final page = signal(1);
  final isNoMoreData = signal(false);

  Future<void> refresh() async {
    page.value = 1;
    isNoMoreData.value = false;
    await _requestData(isRefresh: true);
  }

  Future<void> loadMore() async {
    if (isNoMoreData.value) return;
    page.value++;
    await _requestData(isRefresh: false);
  }

  Future<void> _requestData({required bool isRefresh}) async {
    if (isRefresh) {
      netState.value = NetState.loadingState;
    }

    final response = await LttHttp().request<CartoonModel>(
      '/mock/cartoon/page_${page.value}.json',
      method: HttpConfig.mock,
    );

    if (response == null) {
      netState.value = NetState.errorShowRefresh;
      return;
    }

    final list = response.data?.infos ?? <CartoonModelDataInfos>[];

    if (isRefresh) {
      items.value = list;
    } else {
      items.value = [...items.value, ...list];
    }

    isNoMoreData.value = list.isEmpty;
    netState.value = items.value.isEmpty
        ? NetState.emptyDataState
        : NetState.dataSuccessState;
  }
}
  1. 在页面中持有 store
class SignalsListPage extends StatefulWidget {
  const SignalsListPage({super.key});

  @override
  State<SignalsListPage> createState() => _SignalsListPageState();
}

class _SignalsListPageState extends State<SignalsListPage> {
  final store = CartoonStore();

  @override
  void initState() {
    super.initState();
    store.refresh();
  }

  @override
  Widget build(BuildContext context) {
    return PageScaffold(
      title: 'signals-list',
      body: Watch(
        (_) => PageStateView(
          netState: store.netState.value,
          onRetry: store.refresh,
          child: ListView.builder(
            itemCount: store.items.value.length,
            itemBuilder: (context, index) {
              final item = store.items.value[index];
              return Text(item.title ?? '');
            },
          ),
        ),
      ),
    );
  }
}

完成上面几步,一个典型的网络列表页就基本搭起来了。

这套流程和 Bloc 最大的差异点就在于:

  • 没有 Event

  • 没有 emit

  • 不需要维护一个庞大的 State clone()

  • 改哪个状态,就直接改哪颗 signal

  • 哪段 UI 依赖了哪个状态,就只刷新哪段 UI

  • Signals 适合做什么

如果从日常业务开发的角度看,Signals 特别适合下面这些场景:

  • 登录页输入联动
  • 列表页刷新和分页
  • 详情页加载和局部更新
  • 导航栏透明度变化
  • 点赞、收藏、关注这种单点交互
  • Tab 切换、筛选条件变化、局部区域刷新

项目里现在就有这些实际例子:

  • signals-list

  • signals-grid

  • signals-stagger

  • signals-login

  • 单页面多接口并发请求 + 局部刷新案例

  • 单页面多接口串行请求 + 局部刷新案例

  • 颗粒化刷新或局部刷新

这是 Signals 最舒服的地方之一。

Bloc 里,我们常常需要借助:

  • buildWhen
  • listenWhen
  • 多个 BlocBuilder
  • 甚至一个页面拆两个 Bloc

来控制“哪里该刷新,哪里不该刷新”。

Signals 下,局部刷新会自然很多。

比如详情页里有两种变化:

  1. 页面初次进入,请求主数据、推荐数据、系列数据
  2. 页面滚动时,只让导航栏透明度变化

这时候最推荐的写法不是“一个大对象全塞进去”,而是把状态拆开:

final detailState = signal(NetState.loadingState);
final mainModel = signal<CartoonModelData?>(null);
final seriesList = signal<List<CartoonSeriesDataSeriesComics>>([]);
final recommendList = signal<List<CartoonRecommendDataInfos>>([]);
final navAlpha = signal(0.0);

然后页面里:

  • 内容区域依赖 detailStatemainModelseriesListrecommendList
  • 导航栏区域只依赖 navAlpha

这样当页面滚动时,只会刷新导航栏对应的 Watch,不会把整个页面根节点重新构建一遍。

这就是 Signals 下最自然的一种颗粒化刷新方式。

  • 单页面多网络请求实现思路

在实际项目里,经常会碰到一个页面需要多个接口的情况,比如:

  • 详情主数据
  • 同系列数据
  • 推荐数据

Signals 下常见有两种写法:

  • 思路1:串行请求 适合有依赖关系的接口,前一个结果决定后一个请求参数。写法简单,但整体耗时会更长。

  • 思路2:并行请求 适合相互独立的接口,使用 Future.wait 并行获取数据,整体体验更好。

示例:

final results = await Future.wait([
  LttHttp().request<CartoonModelData>(mainPath, method: HttpConfig.mock),
  LttHttp().request<CartoonSeriesData>(seriesPath, method: HttpConfig.mock),
  LttHttp().request<CartoonRecommendData>(recommendPath, method: HttpConfig.mock),
]);

mainModel.value = results[0]?.data;
seriesList.value = results[1]?.data?.seriesComics ?? [];
recommendList.value = results[2]?.data?.infos ?? [];
detailState.value = NetState.dataSuccessState;

Bloc 相比,这里不需要等所有数据准备好以后再统一 emit(state.clone()),拿到哪个结果就可以更新哪个 signal。如果业务允许,甚至可以边回来边展示,体验会更自然。

  • computed 的常见使用场景

很多同学刚开始用 Signals 时,会把所有逻辑都堆到页面里,其实 computed 非常适合做“派生状态”。

比如登录页里,手机号和验证码输入完成后按钮才可点击:

final phone = signal('');
final code = signal('');
late final canSubmit = computed(() {
  return phone.value.length == 11 && code.value.length == 6;
});

这样页面里只关心:

Watch((_) => ElevatedButton(
  onPressed: store.canSubmit.value ? store.submit : null,
  child: const Text('登录'),
))

这比手动在多个输入回调里判断按钮状态要清晰很多。

  • Signals 中常见的几个误区
  • 误区1:把整个页面状态塞进一个大对象,再用一个 signal(State()) 包起来

这其实只是把原来的 Bloc state 平移了一下,并没有真正发挥 Signals 的优势。更推荐拆成多颗更明确的 signal

  • 误区2:一个超大的 Watch 包整个页面

这样虽然也能跑,但依赖范围过大,状态一变就容易整页刷新。更好的方式是按功能区拆 Watch

  • 误区3:页面直接写太多业务逻辑

网络请求、分页、错误处理、空态判断这些,最好都收进 store,页面只负责组织 UI。

  • 误区4:该用 computed 的地方,全部手动 set

只要某个值是“根据其他状态推导出来”的,就优先考虑 computed,这样更稳定也更不容易漏改。

  • 针对 Signals 特性封装网络请求

项目中现在使用的是统一的网络入口:

final response = await LttHttp().request<CartoonModelData>(
  path,
  method: HttpConfig.get,
);

这套做法的好处有几点:

  • 调用方式统一
  • 支持泛型解析
  • 接口返回值统一落到 ResponseModel<T>
  • 便于在 store 里根据结果更新不同的 signal

现在项目里的网络层主要包括这些能力:

  • 基础请求封装
  • 统一错误映射
  • 多环境基础支持
  • header 注入
  • 超时控制
  • 基础重试

Signals 架构下,网络层最重要的一点就是:请求方法要尽量返回明确结果,不要在回调里东一块西一块改页面状态。这样 store 才能清晰地决定该更新哪颗 signal

  • 页面状态设计

项目里目前保留了一个比较轻的 NetState 枚举,用来描述页面状态:

enum NetState {
  initializeState,
  loadingState,
  error404State,
  errorShowRefresh,
  emptyDataState,
  timeOutState,
  dataSuccessState,
}

它的作用很直接,就是统一页面的:

  • 加载中
  • 空数据
  • 网络错误
  • 超时
  • 加载成功

但和之前不一样的是,现在不再要求所有页面都继承一个沉重的 BaseState,而是每个 store 按需持有:

final netState = signal(NetState.loadingState);

需要分页的页面,再额外加:

final isNoMoreData = signal(false);
final page = signal(1);

这种方式比过去“所有页面都继承同一个大 state”要轻很多。

  • PageScaffold 设计

常规设计,满足日常开发使用。和之前重型 BasePage 不同,现在这一层更强调“轻壳子 + 组合”,主要负责:

  • 页面标题栏
  • 背景色
  • 安全区域处理
  • 通用布局结构

页面自己的业务内容,仍然由页面本身去组织。

  • PageStateView 设计

在真实项目里,一个页面往往要根据网络状态展示:

  • 加载中
  • 空数据
  • 网络错误
  • 超时
  • 正常内容

这类逻辑如果每个页面都手写一遍,代码会非常重复,所以项目里抽了一个轻量的 PageStateView 来统一处理。

它和以前最大的区别是:

  • 不再强依赖某个大而全的 BaseState
  • 只关心当前页面的 NetState
  • 页面仍然可以自由组合自己的业务 UI

组合起来会比较直白:

return PageScaffold(
  title: 'signals-grid',
  body: Watch(
    (_) => PageStateView(
      netState: store.netState.value,
      onRetry: store.refresh,
      child: mainWidget(),
    ),
  ),
);

这样做的好处也很明显:

  • 没有强继承

  • 页面结构更直观

  • 通用能力还在

  • 业务页不会被基础层反向绑架

  • 路由设计

项目里路由还是统一管理的思路,这一点不管是 Bloc 还是 Signals 都一样重要。

统一路由的好处主要有:

  • 跳转入口集中
  • 页面参数更清晰
  • 后续维护成本更低

状态管理变轻之后,页面跳转反而更建议把参数传清楚,不要再去页面里偷偷找全局状态。

  • 通用列表、通用刷新组件设计

为了提高开发效率,项目里保留了一些高复用的基础组件,比如:

  • 通用列表壳
  • 通用网格壳
  • 通用刷新壳
  • 自定义刷新头和刷新尾

但和以前不同的是,现在更强调“保留真正高复用的壳”,而不是把业务判断都塞进基类。也就是说:

  • 通用组件负责样式和基础能力
  • store 负责状态
  • 页面负责拼装

这样职责边界会更清楚。

  • Signals 在项目中的几个实践建议
  1. 一个页面一个 store,但不要一个页面只放一颗总 signal
  2. 能拆分的状态尽量拆分,不要迷信“大而全”的 State
  3. computed 用来做派生状态,别把简单推导也写成手动更新
  4. 页面少写业务逻辑,把请求、分页、异常处理放进 store
  5. Watch 不要无脑包根节点,优先包真正会变化的区域
  6. 网络层尽量统一返回结果,不要在回调里四处修改 UI
  • 当前项目中的 Signals 架构总结

经过这一轮整理,项目里的核心链路基本已经形成了比较稳定的 Signals 写法:

  • 登录页使用本地 store + computed
  • Tab 使用轻量 store
  • 列表页使用 netState + items + page + isNoMoreData
  • 详情页按业务拆成多颗 signal
  • 页面通过 PageScaffold + PageStateView + Watch 组合
  • 网络层统一请求和错误处理
  • 基础层不再强依赖重型 BasePage / BaseState

如果只从学习成本和开发效率来看,Signals 在 Flutter 项目里确实是一种很舒服的状态管理方式,特别适合中小型页面和强调局部刷新的场景。

当然,它也不是“银弹”。如果一个项目的状态流特别复杂、事件流特别多、多人协作对流程约束要求很强,那还是要根据实际业务来判断是否适合。

结束:

这篇文章就先写到这里。相比 BlocSignals 更轻、更直接,也更适合做细粒度响应式更新。但真正落到项目里时,重点并不只是把 Bloc 改成 signal,而是要重新思考:

  • 状态应该怎么拆
  • 页面应该怎么组合
  • 网络请求怎么和状态更新配合
  • 基础层怎么做到“高复用但不过度设计”

如果这些点都理顺了,Signals 在实际项目中会非常顺手。

技术这东西,本质上还是拿来沟通和解决问题的。文章里有理解不到位或者更好的实践方式,也欢迎一起交流。

声明:

仅开源供大家学习使用,禁止从事商业活动,如出现一切法律问题自行承担。

仅学习使用,如有侵权,造成影响,请联系删除,谢谢。

Demo下载地址 Demo

实现一个水纹涟漪轮播图

说在前面

办公室下午 4 点,产品同学把电脑转过来:

“这个轮播图能不能高级一点?现在一切图就像 PPT 翻页”

我:“要多高级?”

产品:“像水面一样扩散开那种,最好再有点电影感”

大家平时做轮播图,常见方案基本是 opacity 淡入淡出、translateX 滑动、或者 3D 翻转。够用是够用,但“惊喜感”比较有限。

效果展示

实现思路

整体结构

HTML :一个 canvas + 左右按钮 + 底部指示器。

<div class="carousel" id="carousel">
  <canvas id="canvas"></canvas>
</div>
<button class="nav-btn prev" id="prevBtn"></button>
<button class="nav-btn next" id="nextBtn"></button>
<div class="indicators" id="indicators"></div>
  • 轮播“画面”只交给 Canvas 负责。
  • 所有控件都是常规 DOM,交互实现更简单。
  • 视觉重活在 JS 里做,样式层只负责外观。

cover 裁剪 + DPR 适配

很多同学写 Canvas 容易糊,问题常出在高清屏适配。

function resizeCanvas() {
  const rect = canvas.parentElement.getBoundingClientRect();
  canvas.width = rect.width * devicePixelRatio;
  canvas.height = rect.height * devicePixelRatio;
  ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
}

这段代码的作用:

  1. 物理像素devicePixelRatio 放大,保证清晰度。
  2. 再用 setTransform 把坐标系映射回 CSS 像素,避免你后面算坐标时崩溃。

另外它还实现了 cover 裁剪(类似 CSS background-size: cover),保证不同尺寸图片都能完整铺满画布,不留黑边。

“谁该出现”判定

const relDist = waveFront - dist;

if (relDist > waveWidth * waveCount) {
  useNewImage = true;
} else if (relDist > 0) {
  const wavePhase = (relDist / waveWidth) * Math.PI * 2;
  const amplitude = waveAmplitude * Math.sin(wavePhase)
    * Math.exp(-relDist / (waveWidth * waveCount) * 2);

  const angle = Math.atan2(dy, dx);
  srcX = x + Math.cos(angle) * amplitude;
  srcY = y + Math.sin(angle) * amplitude;

  useNewImage = relDist > waveWidth;
}

拆开理解:

  1. dist 是当前像素到波纹中心的距离。
  2. waveFront 是当前时刻波前推进到哪里。
  3. relDist = waveFront - dist 可以理解成“波前相对这个像素的位置关系”。
  4. 根据 relDist 分三段:
    • 波已过去:显示新图。
    • 波正在影响:做径向位移(扭曲),并按圈层逐步切换到新图。
    • 波还没到:继续显示旧图。

这就是为什么你会看到“像水波推着画面前进”的感觉,而不是生硬切换。

为什么要用离屏 Canvas?

这里用了 fromCanvastoCanvasoutputCanvas 三个离屏画布

const fromData = fromCtx.getImageData(0, 0, pw, ph);
const toData = toCtx.getImageData(0, 0, pw, ph);
const outputData = outputCtx.createImageData(pw, ph);

作用有三点:

  1. 避免每一帧重复去主画布读像素(成本高)。
  2. 输出先在离屏里 putImageData,最后一次性 drawImage 到主画布,流程更稳。
  3. 绕开 putImageData 不受 transform 影响的问题,DPR 场景更可控。

性能小技巧

像素级循环最怕卡顿,这里用了一个很实用的策略:按块采样。

const step = 2;
for (let y = 0; y < ph; y += step) {
  for (let x = 0; x < pw; x += step) {
    // 计算一次后,填充 step x step 区域
  }
}

这相当于“每 2x2 像素算一次”,能明显减轻运算量。因为波纹本身是连续变化的,人眼很难察觉这点采样损失,性能却能换来很大提升。

交互

这个文件里交互是完整闭环:

  1. goTo(index) 统一处理切换入口,防抖靠 isTransitioning
  2. next/prev 处理循环索引。
  3. setInterval(next, 4500) 自动播放。
  4. 鼠标悬停暂停、离开恢复。
  5. 键盘左右箭头也能切。
  6. visibilitychange 时页面隐藏就停播,避免后台空转。

源码地址

gitee

gitee.com/zheng_yongt…

github

github.com/yongtaozhen…


🌟 觉得有帮助的可以点个 star~

🖊 有什么问题或错误可以指出,欢迎 pr~

📬 有什么想要实现的功能或想法可以联系我~


公众号推广

关注公众号 前端也能这么有趣 ,获取更多有趣内容。

发送 加群 还可以加入群聊,一起来学习(摸鱼)吧~

说在后面

🎉 这里是 JYeontu,现在是一名前端工程师,有空会刷刷算法题,平时喜欢打羽毛球 🏸 ,平时也喜欢写些东西,既为自己记录 📋,也希望可以对大家有那么一丢丢的帮助,写的不好望多多谅解 🙇,写错的地方望指出,定会认真改进 😊,偶尔也会在自己的公众号『前端也能这么有趣』发一些比较有趣的文章,有兴趣的也可以关注下。在此谢谢大家的支持,我们下文再见 🙌。

从零到一:在React前端中集成The Graph查询Uniswap V3池数据的实战记录

背景

最近在参与一个DeFi收益聚合器项目的前端开发,我的任务是构建一个“流动性池分析”面板。这个面板需要展示Uniswap V3上某个特定交易对(比如ETH/USDC)池子的关键数据:当前价格、流动性总量、24小时交易量、手续费收入,以及最近的一批交易记录。

一开始,我的思路很“朴素”:直接用 ethers.jsviem 去读取池子合约的 slot0 获取价格,循环读取 MintBurnSwap 事件来计算其他数据。我很快写了个原型,但问题立刻出现了。性能是第一个拦路虎。为了计算24小时交易量,我需要遍历过去24小时的所有 Swap 事件,这在主网上是一个海量操作,RPC调用次数爆炸,页面加载时间长达几十秒,用户体验极差。数据聚合是第二个难题。手续费收入需要累加所有交易的手续费,这又涉及到大量的事件筛选和计算。我意识到,这种“实时计算”的路子在前端根本走不通。

团队讨论后,方向转向了使用预索引数据的解决方案。我们提到了Chainlink、Covalent,但最终选择了 The Graph。原因很直接:Uniswap V3官方已经部署了完善的、社区维护的子图,它已经把池子、交易、流动性位置等数据都索引好了,我只需要用GraphQL去查询即可。这听起来很美,但作为一个之前只用过The Graph Explorer网站的前端,如何在自己的React应用里集成它,我其实心里没底。这就是本次实战要解决的问题:在我的React前端中,稳定、高效地查询The Graph上的Uniswap V3数据。

问题分析

我的最初想法是:“不就是个GraphQL API吗?我用 axios 或者 fetch 发个POST请求不就行了?” 于是我找到了Uniswap V3在以太坊主网的子图端点:https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3

我写了一个简单的 fetch 尝试查询一个池子:

const query = `
  query {
    pools(where: { id: "0x8ad599c3a0ff1de082011efddc58f1908eb6e6d8" }) {
      id
      token0 { symbol }
      token1 { symbol }
    }
  }
`;

const response = await fetch('https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ query }),
});

结果返回了数据!这让我兴奋了一下,以为问题解决了。但当我开始构建复杂的查询,比如关联查询池子的交易记录、需要分页、需要按时间筛选时,代码迅速变得混乱。手动拼接GraphQL查询字符串很容易出错,类型安全更是无从谈起。更重要的是,错误处理和加载状态管理变得很繁琐。每个查询我都要自己写 try...catch,管理 loadingerror state。

我意识到,我需要一个更专业的GraphQL客户端来管理这些请求。在Web2项目中,我常用Apollo Client。那么,在Web3的React项目里,能不能用Apollo Client来连接The Graph呢?理论上完全可行。接下来的核心实现,就是围绕 “在React + TypeScript项目中,使用Apollo Client查询The Graph子图” 来展开。

核心实现

第一步:项目初始化与依赖安装

首先,我创建了一个新的React + TypeScript项目(如果是在已有项目中集成,跳过这一步)。

npx create-react-app my-graph-demo --template typescript
cd my-graph-demo

然后,安装必需的依赖:@apollo/clientgraphql。Apollo Client是一个全面的GraphQL状态管理库,graphql包用于解析GraphQL查询。

npm install @apollo/client graphql

第二步:配置Apollo Client实例

这是最关键的一步。我需要创建一个Apollo Client实例,并配置其连接到The Graph的HTTPS端点。

我在 src 目录下创建了一个 apollo/client.ts 文件:

import { ApolloClient, InMemoryCache, HttpLink, from } from '@apollo/client';
import { onError } from '@apollo/client/link/error';

// 1. 定义子图端点
const UNISWAP_V3_SUBGRAPH_URL = 'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3';

// 2. 创建HTTP链接
const httpLink = new HttpLink({
  uri: UNISWAP_V3_SUBGRAPH_URL,
});

// 3. (可选)错误处理中间件
const errorLink = onError(({ graphQLErrors, networkError }) => {
  if (graphQLErrors)
    graphQLErrors.forEach(({ message, locations, path }) =>
      console.error(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`)
    );
  if (networkError) console.error(`[Network error]: ${networkError}`);
});

// 4. 创建Apollo Client实例
// 使用 `from` 组合多个中间件链接
export const apolloClient = new ApolloClient({
  link: from([errorLink, httpLink]),
  cache: new InMemoryCache(),
  defaultOptions: {
    watchQuery: {
      fetchPolicy: 'cache-first', // 默认先查缓存,对不常变的数据很友好
    },
  },
});

这里有个坑:最初我直接 new HttpLink({ uri: ... }) 就把链接传给 ApolloClient,忽略了错误处理。当网络波动或查询语法错误时,错误信息很难追踪。加上 onError 链接后,调试体验好了很多。

第三步:将Apollo Provider集成到React应用中

Apollo Client使用React的Context机制来向下传递客户端实例。我需要用 ApolloProvider 包裹我的应用根组件。

修改 src/index.tsx

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import { ApolloProvider } from '@apollo/client';
import { apolloClient } from './apollo/client'; // 导入上一步创建的客户端

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);

root.render(
  <React.StrictMode>
    {/* 用 ApolloProvider 包裹 App,并传入 client */}
    <ApolloProvider client={apolloClient}>
      <App />
    </ApolloProvider>
  </React.StrictMode>
);

第四步:编写GraphQL查询并使用useQuery Hook

现在可以开始写查询了。我计划先查询一个特定池子的基本信息。为了更好的类型安全和代码提示,我倾向于将查询语句用 gql 模板标签定义。

首先,在 src 下创建 graphql/queries.ts

import { gql } from '@apollo/client';

// 查询池子基本信息
export const POOL_DETAIL_QUERY = gql`
  query GetPoolDetail($poolId: String!) {
    pools(where: { id: $poolId }) {
      id
      token0 {
        id
        symbol
        decimals
      }
      token1 {
        id
        symbol
        decimals
      }
      feeTier
      liquidity
      sqrtPrice
      tick
      volumeUSD
      feesUSD
      # 注意:子图中的字段名可能是 feeGrowthGlobal0X128,需要查文档确认
    }
  }
`;

// 查询该池子的最新交易(分页)
export const POOL_SWAPS_QUERY = gql`
  query GetPoolSwaps($poolId: String!, $first: Int!, $skip: Int!) {
    swaps(
      where: { pool: $poolId }
      orderBy: timestamp
      orderDirection: desc
      first: $first
      skip: $skip
    ) {
      id
      timestamp
      amount0
      amount1
      amountUSD
      sender
      recipient
    }
  }
`;

注意这个细节:查询中的字段名必须和子图模式(schema)里定义的完全一致。比如手续费字段,我一开始想当然写了 feesUSD,但实际查询返回空,后来去 The Graph Explorer 上查看该子图的“Playground”,用文档浏览器才发现正确的字段名是 totalValueLockedUSD 或其他。一定要查官方文档或子图自带的schema!

然后,在React组件中使用 useQuery hook来执行查询。创建 src/components/PoolDashboard.tsx

import React from 'react';
import { useQuery } from '@apollo/client';
import { POOL_DETAIL_QUERY, POOL_SWAPS_QUERY } from '../graphql/queries';

// 定义我们关心的池子ID,这里用的是ETH/USDC 0.3%池
const TARGET_POOL_ID = '0x8ad599c3a0ff1de082011efddc58f1908eb6e6d8';

const PoolDashboard: React.FC = () => {
  // 查询池子详情
  const {
    loading: poolLoading,
    error: poolError,
    data: poolData,
  } = useQuery(POOL_DETAIL_QUERY, {
    variables: { poolId: TARGET_POOL_ID },
  });

  // 查询交易记录,每次取10条
  const {
    loading: swapsLoading,
    error: swapsError,
    data: swapsData,
  } = useQuery(POOL_SWAPS_QUERY, {
    variables: {
      poolId: TARGET_POOL_ID,
      first: 10,
      skip: 0, // 用于分页,skip = page * first
    },
  });

  if (poolLoading || swapsLoading) return <div>Loading data from The Graph...</div>;
  if (poolError) return <div>Error loading pool: {poolError.message}</div>;
  if (swapsError) return <div>Error loading swaps: {swapsError.message}</div>;

  const pool = poolData?.pools[0];
  const swaps = swapsData?.swaps || [];

  if (!pool) return <div>Pool not found.</div>;

  // 简单计算当前价格:sqrtPrice转换需要公式,这里仅示意
  // 真实公式: price = (sqrtPrice^2) / 2^192 * 10^(token1.decimals - token0.decimals)
  // 此处省略具体实现,仅展示数据获取成功

  return (
    <div>
      <h2>Pool: {pool.token0.symbol} / {pool.token1.symbol} ({pool.feeTier / 10000}%)</h2>
      <p><strong>Pool Address:</strong> {pool.id}</p>
      <p><strong>Liquidity:</strong> {parseFloat(pool.liquidity).toLocaleString()}</p>
      <p><strong>Volume USD (24h):</strong> ${parseFloat(pool.volumeUSD).toFixed(2)}</p>
      <hr />
      <h3>Recent Swaps</h3>
      <ul>
        {swaps.map((swap: any) => (
          <li key={swap.id}>
            {new Date(swap.timestamp * 1000).toLocaleString()} - 
            Amount USD: ${parseFloat(swap.amountUSD).toFixed(2)}
          </li>
        ))}
      </ul>
    </div>
  );
};

export default PoolDashboard;

第五步:实现分页查询

上面的交易查询只取了前10条。要实现“加载更多”,我需要用到 fetchMore 函数,这是 useQuery 返回的另一个实用工具。

我对 PoolDashboard 组件进行改造,添加分页状态和“加载更多”按钮:

import React, { useState } from 'react';
import { useQuery } from '@apollo/client';
import { POOL_SWAPS_QUERY } from '../graphql/queries';

// 在组件内部...
const [swapsPage, setSwapsPage] = useState(0);
const SWAPS_PER_PAGE = 5;

const {
  loading: swapsLoading,
  error: swapsError,
  data: swapsData,
  fetchMore, // 获取 fetchMore 函数
} = useQuery(POOL_SWAPS_QUERY, {
  variables: {
    poolId: TARGET_POOL_ID,
    first: SWAPS_PER_PAGE,
    skip: 0, // 初始跳过0条
  },
});

const handleLoadMore = () => {
  const nextPage = swapsPage + 1;
  fetchMore({
    variables: {
      skip: nextPage * SWAPS_PER_PAGE,
      // first 保持不变
    },
    // 更新查询结果的方式:将新数据合并到旧数据中
    updateQuery: (prevResult, { fetchMoreResult }) => {
      if (!fetchMoreResult) return prevResult;
      return {
        swaps: [...prevResult.swaps, ...fetchMoreResult.swaps],
      };
    },
  }).then(() => {
    setSwapsPage(nextPage);
  });
};

// 在渲染部分...
<button onClick={handleLoadMore} disabled={swapsLoading}>
  {swapsLoading ? 'Loading...' : 'Load More Swaps'}
</button>

这里有个大坑updateQuery 函数必须返回与原始查询结果结构完全一致的对象。我一开始只返回了 { swaps: fetchMoreResult.swaps },导致点击“加载更多”后,列表里只剩下新加载的5条,之前的被覆盖了。正确的做法是合并数组。

完整代码示例

以下是一个简化但可运行的 App.tsx 和关联文件,展示了集成The Graph查询的核心流程。

1. src/apollo/client.ts

import { ApolloClient, InMemoryCache, HttpLink, from } from '@apollo/client';
import { onError } from '@apollo/client/link/error';

const UNISWAP_V3_SUBGRAPH_URL = 'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3';

const httpLink = new HttpLink({ uri: UNISWAP_V3_SUBGRAPH_URL });

const errorLink = onError(({ graphQLErrors, networkError }) => {
  if (graphQLErrors) graphQLErrors.forEach((err) => console.error(`[GraphQL error]: ${err.message}`));
  if (networkError) console.error(`[Network error]: ${networkError}`);
});

export const apolloClient = new ApolloClient({
  link: from([errorLink, httpLink]),
  cache: new InMemoryCache(),
});

2. src/graphql/queries.ts

import { gql } from '@apollo/client';

export const SIMPLE_POOL_QUERY = gql`
  query GetSimplePoolInfo($poolId: String!) {
    pools(where: { id: $poolId }) {
      id
      token0 { symbol }
      token1 { symbol }
      feeTier
      liquidity
    }
  }
`;

3. src/components/SimplePoolView.tsx

import React from 'react';
import { useQuery } from '@apollo/client';
import { SIMPLE_POOL_QUERY } from '../graphql/queries';

const TARGET_POOL_ID = '0x8ad599c3a0ff1de082011efddc58f1908eb6e6d8';

const SimplePoolView: React.FC = () => {
  const { loading, error, data } = useQuery(SIMPLE_POOL_QUERY, {
    variables: { poolId: TARGET_POOL_ID },
  });

  if (loading) return <p>Loading from The Graph...</p>;
  if (error) return <p>Error: {error.message}</p>;

  const pool = data?.pools[0];
  if (!pool) return <p>Pool not found.</p>;

  return (
    <div style={{ border: '1px solid #ccc', padding: '1rem', margin: '1rem' }}>
      <h3>Uniswap V3 Pool Info (via The Graph)</h3>
      <p><strong>Pair:</strong> {pool.token0.symbol} / {pool.token1.symbol}</p>
      <p><strong>Fee Tier:</strong> {pool.feeTier / 10000}%</p>
      <p><strong>Liquidity:</strong> {Number(pool.liquidity).toLocaleString()}</p>
      <p><small>Pool ID: {pool.id}</small></p>
    </div>
  );
};

export default SimplePoolView;

4. src/App.tsx

import React from 'react';
import './App.css';
import SimplePoolView from './components/SimplePoolView';

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <h1>The Graph + React 实战</h1>
        <SimplePoolView />
      </header>
    </div>
  );
}

export default App;

5. src/index.tsx (如前所述,用 ApolloProvider 包裹)

运行 npm start,如果一切正常,你将看到一个显示Uniswap V3 ETH/USDC池基本信息的卡片。

踩坑记录

  1. “字段不存在”错误:这是我遇到的第一个坑。我按照自己对数据的理解写了字段名,比如 txCount,结果查询返回 Cannot query field \"txCount\" on type \"Pool\"解决方法:老老实实打开The Graph Explorer,找到对应的子图,在“Playground”的文档浏览器里查看该实体(如 Pool)下所有可用的字段,或者直接运行一个简单查询看返回的结构。

  2. 分页合并数据覆盖:如上文所述,在实现 fetchMoreupdateQuery 时,我错误地只返回了新数据,导致旧数据丢失。解决方法:仔细阅读Apollo文档关于 updateQuery 的示例,确保是合并(merge)而不是替换(replace)数据。

  3. 查询变量类型不匹配:子图中 ID 类型通常是 String!,但我最初在查询中定义变量类型为 ID!,导致某些查询不返回数据且没有明显错误。解决方法:在子图的GraphQL Playground的“Schema”页签下,查看查询(如 pools)的参数确切类型,并在前端查询定义中保持一致。

  4. 网络错误与速率限制:在开发过程中频繁刷新,偶尔会收到 429 Too Many Requests 或网络错误。The Graph的公开端点有速率限制。解决方法:对于生产环境,可以考虑使用The Graph的托管服务或自建索引节点以获得更稳定的速率。在开发时,添加良好的错误处理UI(如重试按钮),并避免在 useEffect 中无脑地频繁轮询。

小结

通过这次实战,我彻底打通了React前端与The Graph子图之间的数据通道。核心收获是:将Apollo Client作为GraphQL状态管理工具引入Web3前端,能极大地简化对索引化链上数据的查询、状态管理和错误处理。这让前端开发者可以更专注于数据展示和交互逻辑,而不是底层的数据获取和聚合。接下来,我可以继续探索The Graph的更高级特性,比如订阅(Subscription)实时数据更新,或者为自定义的智能合约部署专属子图。

浏览器是怎么把代码变成页面的?

你在地址栏输入一个URL,敲下回车,页面就出现了。但浏览器内部到底经历了什么?HTML、CSS、JS是如何变成你看到的页面的?

今天用**"装修房子"**的故事,聊聊浏览器的渲染原理。


原文地址

墨渊书肆/浏览器是怎么把代码变成页面的?


从URL到页面:渲染总览

当你在浏览器输入URL并回车,浏览器内部经历了:

浏览器地址栏
├── URL输入
├── DNS解析
   └── 域名  IP地址
├── TCP连接
   └── 三次握手
├── HTTP响应
   └── 服务器返回HTML/CSS/JS
└── 渲染进程处理
    ├── 构建阶段:HTML解析 + CSS解析
    └── 绘制阶段:布局  分层  绘制  合成

渲染流水线可以分为构建阶段绘制阶段

构建阶段(并行):
┌─────────────┐     ┌─────────────┐
  HTML解析          CSS解析    
   生成DOM           生成CSSOM  
└──────┬──────┘     └──────┬──────┘
                           
       └────────┬───────────┘
                
          渲染树构建
                
绘制阶段:
```yaml
绘制阶段
├── 布局计算
   └── 计算每个元素的位置、大小、边距
├── 分层
   └── 哪些元素需要独立图层(fixed/动画/视频)
├── 绘制
   └── 生成绘制指令(矩形、文字、线条)
└── 合成输出
    └── GPU合并图层  显示到屏幕

解读

  • 构建阶段:HTML和CSS解析同时进行(并行),完成后合并成渲染树
  • 绘制阶段:按顺序执行布局、分层、绘制、合成,最终输出画面
阶段 输入 输出
HTML解析 HTML字符串 DOM树
CSS解析 CSS字符串 CSSOM树
渲染树构建 DOM + CSSOM 渲染树
布局 渲染树 盒模型信息
分层 布局信息 图层树
绘制 图层 绘制指令
合成 图层+指令 画面

第一步:HTML解析 → DOM树

浏览器收到HTML响应后,首先要解析HTML,构建DOM树

DOM是什么?

DOM(Document Object Model,文档对象模型)是HTML/XML文档的编程接口。浏览器把HTML文档解析成一棵树状结构,每个HTML标签都是树上的一个节点

<html>
  <head>
    <title>标题</title>
  </head>
  <body>
    <h1>欢迎</h1>
    <p>这是段落</p>
  </body>
</html>

DOM树结构:

html
├── head
   └── title  "标题"
└── body
    ├── h1  "欢迎"
    └── p  "这是段落"

HTML解析过程

解析器从上到下读取HTML,遇到<head>标签创建head节点,遇到<body>标签创建body节点,遇到嵌套标签创建子节点...

HTML解析器:逐行读取  创建节点  构建DOM树
<html>  html节点
<head>  head节点  title节点  文本节点  关闭title  关闭head
<body>  body节点  h1节点  文本节点  关闭h1  p节点  文本节点  关闭p  关闭body  关闭html
 DOM树构建完成

遇到JS会怎样?

HTML解析器遇到<script>标签时会暂停解析,先执行JS:

解析HTML  构建DOM  完成
    
遇到<script>:暂停  执行JS  继续

因为JS可能document.write()修改DOM,所以HTML解析器必须等JS执行完成才能继续。

这就是为什么把JS放在body底部可以加快首屏渲染——让HTML先解析完,显示内容,JS最后再执行。


第二步:CSS解析 → CSSOM树

HTML解析的同时,浏览器也在解析CSS,构建CSSOM树(CSS Object Model)。

CSSOM是什么?

CSSOM是CSS样式表的树状结构,描述了每个元素的样式信息。

body { font-size: 16px; }
h1 { color: red; font-size: 24px; }
p { color: blue; }

CSSOM树结构:

body
├── font-size: 16px
├── color: (inherited)
└── children
    ├── h1
       ├── color: red
       └── font-size: 24px
    └── p
        └── color: blue

CSS解析特性

与HTML不同,CSS解析是上下文相关的

标签选择器:p { color: blue; }      所有<p>生效
类选择器:.title { ... }          class="title"生效
ID选择器:#header { ... }        id="header"生效

CSS解析器需要考虑选择器优先级(ID > 类 > 标签)、层叠规则、继承规则等。


第三步:渲染树(Render Tree)

DOM树 + CSSOM = 渲染树(Render Tree)

渲染树只包含可见节点——display: none的元素不会出现在渲染树中。

DOM + CSSOM = 渲染树

DOM节点 CSSOM样式 渲染树
display:none ✗ 不显示
容器样式 body
├─h1 color:red h1(red)
├─p display:none ✗ 不显示
└─span color:green span(green)

注意<p style="display: none">不会生成渲染树节点,但<p style="visibility: hidden">会生成(只是不可见)。


第四步:布局(Layout)

渲染树构建完成后,浏览器计算每个元素的几何信息:位置、大小、边距、边框等。

布局计算

渲染树  布局计算  盒模型信息
元素1:x=0, y=0, width=200, height=50
元素2:x=0, y=50, width=200, height=30
元素3:x=0, y=80, width=100, height=80
 每个元素都有精确的位置和大小

盒模型(Box Model)

CSS中的盒模型定义了元素的空间占用:

┌─margin─────────────────────────────┐
  ┌─border───────────────────────┐  
    ┌─padding──────────────────┐   
      ┌─content─────────────┐    
         width × height       
      └─────────────────────┘    
    └──────────────────────────┘   
  └───────────────────────────────┘  
└─────────────────────────────────────┘
属性 说明
content 内容区域(width × height)
padding 内边距,内容与边框之间的空间
border 边框,围绕内边距的线条
margin 外边距,边框与其他元素之间的空间

回流(Reflow)

当元素的几何信息发生变化时,浏览器需要重新计算布局,这称为回流(Reflow)

触发回流的操作:

  • 添加/删除可见DOM元素
  • 元素位置/尺寸变化
  • 浏览器窗口大小变化
  • 获取元素的offsetWidth/Height(强制触发计算)
回流过程:
修改DOM  重新计算布局  重绘(耗时操作)

回流比重绘更昂贵,因为它需要重新计算整棵布局树。


第五步:分层(Layer)

布局完成后,浏览器根据一定规则把页面分成多个图层(Layer)

为什么要分层?

分层可以让页面的不同部分独立绘制和合成,避免互相影响。

分层示意:
Layer 3: 固定定位的导航栏(最顶层)
Layer 2: 主体内容
Layer 1: 背景图片
Layer 0: 页面根元素(最底层)

哪些元素会生成独立图层?

生成独立图层的触发条件:

  • position: fixed(固定定位)
  • will-change: transform(transform动画)
  • <video><canvas>元素
  • 3D变换:transform: translate3d()
  • CSS动画:@keyframes + transform
  • 加速属性:opacitytransform

浏览器会为这些元素创建独立的合成层(Compositing Layer),让它们的渲染不影响其他图层。

CSS Containment

contain属性可以告诉浏览器元素内容独立于页面其他部分,帮助浏览器优化:

.container {
  contain: content;  /* 布局、样式、绘制都独立 */
}

第六步:绘制(Paint)

分层后,每个图层内部需要绘制,生成绘制指令。

绘制顺序

浏览器按从后到前的顺序绘制各图层:

绘制顺序:
1. 背景色(最底层)
2. 背景图片
3. 边框
4. 内容(从左上到右下)
5. 伪元素
6. 轮廓(最顶层)

绘制指令

绘制不是直接画像素,而是生成绘制指令列表(Paint Records):

绘制指令示例:
1. drawRect(x=0, y=0, w=100, h=50)  矩形
2. drawText("Hello", x=10, y=30)   文字
3. drawRect(x=0, y=50, w=200, h=1)  分割线

这些指令会交给**光栅线程(Raster)**执行,将指令转换为实际像素。

重绘(Repaint)

当元素的外观改变但不影响布局时,触发重绘:

触发重绘(不改布局):改变颜色、改变可见性、改变边框样式
改变样式  重绘  完成(比回流快)

重绘比回流快,因为它不需要重新计算布局。


第七步:合成(Composite)

绘制完成后,所有图层提交给GPU,GPU将各图层合成成最终画面。

合成过程

Layer 0(背景层)
Layer 1(内容层)
Layer 2(浮动层)
    
GPU合成  输出到屏幕

为什么需要合成层?

  1. 滚动流畅:合成层有自己的GPU加速,滚动不经过主线程
  2. 动画流畅:transform/opacity动画在合成线程执行,不被JS阻塞
  3. 分离更新:只有一个图层内容变化,只需重绘该图层
传统渲染(无合成层)
└── JS修改  重排  重绘  合成  输出
    └── 主线程执行(可能被JS阻塞)

现代渲染(有合成层)
├── JS修改  重排  重绘  合成  输出
└── 合成线程独立执行(不受JS阻塞)

关键渲染路径(Critical Rendering Path)

关键渲染路径是浏览器从接收HTML到首次绘制页面的最短路径

优化关键渲染路径

想让页面更快显示?优化关键渲染路径:

优化目标 说明
减少关键资源数量 合并文件,减少请求
减少关键资源大小 压缩文件,删除注释空格
缩短关键路径长度 内联CSS、JS放底部、懒加载

回流与重绘:性能杀手

浏览器渲染过程中最怕什么?频繁的回流和重绘

强制回流/重绘

某些CSS属性和方法会强制触发回流或重绘:

// 读取以下属性会强制触发回流
element.offsetWidth;     // 布局信息
element.offsetHeight;
element.scrollTop;
element.clientWidth;
getComputedStyle(element).width;

// 修改DOM结构
element.appendChild(child);
element.removeChild(child);

批量读写原则

读写分离,避免交叉触发回流:

// 错误:每次读取触发一次回流
element.width = element.offsetWidth * 2;
element.height = element.offsetHeight * 2;
element.marginTop = element.offsetTop * 2;

// 正确:先读后写,写只触发一次回流
const width = element.offsetWidth;
const height = element.offsetHeight;
const marginTop = element.offsetTop;
element.style.width = width * 2;
element.style.height = height * 2;
element.style.marginTop = marginTop * 2;

requestAnimationFrame

对于需要连续动画的场景,使用requestAnimationFrame代替setTimeout/setInterval

// 不推荐:可能在帧之间执行
setTimeout(() => {
  element.style.transform = 'translateX(100px)';
}, 16);

// 推荐:在下一帧开始前执行
requestAnimationFrame(() => {
  element.style.transform = 'translateX(100px)';
});

总结:渲染流水线

阶段 输入 输出 耗时
HTML解析 HTML字符串 DOM树
CSS解析 CSS字符串 CSSOM树
渲染树构建 DOM + CSSOM 渲染树
布局 渲染树 盒模型信息
分层 布局信息 图层树
绘制 图层 绘制指令
合成 图层+指令 画面

核心思想:浏览器渲染页面如同装修房子——先搭骨架(DOM),再刷墙(CSS),然后布局家具位置(Layout),最后上色绘制(Paint),不同房间(Layer)可以同时施工,最后统一验收(Composite)。

理解渲染原理,才能写出性能更好的页面。


扩展阅读

概念 说明
虚拟DOM React等框架用JS对象模拟DOM,减少真实DOM操作
增量更新 只更新变化的部分,不全量重渲染
Content-visibility CSS新属性,跳过屏幕外内容的渲染
渲染性能指标 LCP(最大内容绘制)、CLS(布局偏移)、FID(首次输入延迟)

微前端状态管理的真相:Module Federation + 跨应用通信实战

本周大前端要闻

  • Compose Multiplatform v1.11.10-alpha01:进一步完善跨平台 UI 状态同步能力,ViewModel 共享机制改进

  • KotlinConf'26 演讲阵容公布:多场 Session 聚焦 Kotlin 多平台架构与状态管理,值得关注

  • Retrofit 3.0.0 正式发布:全面迁移 OkHttp 4.12 Kotlin 版,影响 Android 端异步状态层设计

  • Android Studio Panda 3 稳定版发布:Gemma 4 AICore 本地模型开发预览,AI 辅助架构决策成可能

  • Kotlin 2.3.20 发布:K2 编译器稳定,多平台构建配置大幅简化,跨端状态共享门槛降低

大前端架构

微前端落地的第一个坑,往往不是路由,不是样式隔离,而是状态

你可以把微前端的状态共享问题想象成一栋合租公寓:每个租客(子应用)都想自己管钥匙,但门禁系统(全局权限)必须所有人共用,钥匙到底放在哪里、谁来备份,是每次搬来新租客都要重新协商的问题。你不可能让每个人都带一把门禁主机回自己房间,但也不能强迫大家每次开门都绕到物业前台。

当你把一个大型 SPA 拆分成五个独立部署的子应用,之前塞在 Redux store 里的全局状态——用户信息、购物车、权限、主题——突然成了一个需要多方协商的共识问题。每个子应用都想拥有自己的状态层,但又免不了要互相感知。

这篇文章讲微前端架构下状态管理的真实困境,以及用 Module Federation + 事件总线 + 共享 Store 三种方案组合应对的实战经验。不讲概念,讲决策。

① 先把问题讲清楚

微前端状态管理的核心矛盾是:独立性 vs 一致性

每个子应用(微应用)应该是自治的——独立开发、独立部署、独立测试。自治意味着它应该有自己的状态层,不依赖其他子应用的运行时。

但现实是,你的"购物车"微应用需要知道"用户"微应用里的登录态;"商品详情"需要感知"权限"模块的配置;主框架需要协调子应用之间的 loading 状态。

这些跨应用的状态共享需求是客观存在的,不是设计失误。问题在于:怎么共享才不破坏隔离?

我见过三种常见的错误做法:

错误一:全局 window 对象共享

window.__globalStorewindow.__userInfo——简单,但污染全局命名空间,无法追踪来源,测试噩梦。

错误二:主应用向子应用注入 props/context

主框架把 store 当 props 传进子应用——形成强依赖,子应用无法独立运行,违背微前端的核心价值。

错误三:每个子应用都维护同一份状态的副本

购物车微应用和订单微应用各自维护用户信息——同步问题是噩梦,race condition 必然出现。

所以合理的架构思路是什么?

② 状态分层:先把状态按归属划分

在选方案之前,先做状态分层——这是一切的前提。并非所有状态都需要跨应用共享。

状态归属三层模型:

 全局共享层

用户身份/权限、全局主题/语言、路由元信息、全局弹窗/通知队列

→ 由主框架或专用 Store 微应用负责,单一数据源

 跨应用协作层

购物车状态、跨模块业务流程(下单 → 支付 → 物流)

→ 通过事件总线或共享 Store 片段协调,需要明确的所有权归属

 局部私有层

表单状态、UI 交互态(展开/折叠)、分页参数、列表缓存

→ 子应用内部自管,Zustand/Redux Toolkit 均可,外部无需感知

这个分层做完,你会发现:真正需要跨应用的状态其实很少,大多数都是局部的。过度设计的"全局 store"是微前端架构腐化的主要原因之一。

③ Module Federation:把 Store 当模块共享

Webpack 5 的 Module Federation(MF)通常被当成"代码共享"工具,但它对状态管理有一个极为关键的能力:共享单例(singleton)

核心思路是:把 Zustand/Redux store 实例封装为一个独立的共享模块,通过 MF 的 singleton: true 配置,确保所有微应用共用同一个 store 实例,而不是各自实例化一份。

3.1 共享 Store 微应用配置

先创建一个专门的 store-provider 微应用,只负责提供全局状态。关键点是用 singleton: true 告诉 MF "这个模块全局只允许有一个实例":

// store-provider/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'storeProvider',
      filename: 'remoteEntry.js',
      exposes: {
        // 暴露用户 Store
        './userStore': './src/stores/userStore',
        './cartStore': './src/stores/cartStore',
        './eventBus': './src/eventBus',
      },
      shared: {
        // 关键:zustand 必须 singleton,否则各子应用状态不共享
        zustand: { singleton: true, requiredVersion: '^4.5.0' },
        react: { singleton: true, requiredVersion: '^18.3.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.3.0' },
      },
    }),
  ],
};

3.2 子应用消费共享 Store

消费方直接 import 远程模块,得到的是同一个 store 实例——对子应用代码来说,和用本地 store 没有区别:

// cart-app/webpack.config.js
new ModuleFederationPlugin({
  name: 'cartApp',
  remotes: {
    // 指向 store-provider 的 remoteEntry
    storeProvider: 'storeProvider@https://cdn.example.com/store/remoteEntry.js',
  },
  shared: {
    zustand: { singleton: true },  // 消费方也必须声明 singleton
    react: { singleton: true },
  },
})

// cart-app/src/CartPage.tsx
// 直接 import 共享 store——得到的是同一个实例
import { useCartStore } from 'storeProvider/cartStore';

export function CartPage() {
  const { items, addItem, removeItem } = useCartStore();
  return (
    
      {items.map(item => (
        
      ))}
    
  );
}

这种方式的核心优势:子应用在 独立运行时可以 fallback 到本地 store,在集成环境自动使用共享 store——只需在 store 初始化时做条件判断。下面这段展示了如何在 store 本身不感知"是否在微前端环境"的前提下,做到透明切换:

// cart-app/src/stores/cartStore.ts
// 独立运行时用本地 store,集成时被 MF singleton 覆盖
import { create } from 'zustand';

interface CartState {
  items: CartItem[];
  addItem: (item: CartItem) => void;
  removeItem: (id: string) => void;
}

export const useCartStore = create()((set) => ({
  items: [],
  addItem: (item) => set((s) => ({ items: [...s.items, item] })),
  removeItem: (id) => set((s) => ({ items: s.items.filter(i => i.id !== id) })),
}));

④ 事件总线:解耦跨应用通信

并非所有跨应用交互都适合共享 store。有些场景更适合「发布-订阅」模型:子应用 A 完成了某个操作,通知子应用 B 做出响应,但 A 和 B 互相不知道对方的存在。

典型场景:用户在"商品详情"微应用点击"加入购物车",触发"购物车"微应用的角标更新、"推荐"微应用的埋点上报。这是一对多的通知关系,强制绑定 store 反而引入不必要的耦合。

4.1 类型安全的事件总线实现

// shared/eventBus.ts(通过 MF 暴露给所有子应用)
type EventMap = {
  'cart:item-added': { productId: string; quantity: number };
  'user:logged-in': { userId: string; token: string };
  'user:logged-out': void;
  'order:created': { orderId: string; totalAmount: number };
  'global:theme-changed': { theme: 'light' | 'dark' };
};

type Handler = (payload: T) => void;

class TypedEventBus {
  private handlers = new Map>>();

  on(
    event: K,
    handler: Handler
  ): () => void {
    if (!this.handlers.has(event as string)) {
      this.handlers.set(event as string, new Set());
    }
    this.handlers.get(event as string)!.add(handler);
    // 返回取消订阅函数,避免内存泄漏
    return () => this.handlers.get(event as string)?.delete(handler);
  }

  emit(event: K, payload: EventMap[K]): void {
    this.handlers.get(event as string)?.forEach(h => h(payload));
  }
}

// 单例导出,通过 MF singleton 确保全局唯一
export const eventBus = new TypedEventBus();

4.2 在 React 子应用中使用事件总线

// product-app/src/ProductDetail.tsx
import { eventBus } from 'storeProvider/eventBus';

export function ProductDetail({ product }: { product: Product }) {
  const handleAddToCart = () => {
    // 发布事件——不依赖购物车应用是否存在
    eventBus.emit('cart:item-added', {
      productId: product.id,
      quantity: 1,
    });
  };
  return 加入购物车;
}

// cart-app/src/CartBadge.tsx
import { useEffect, useState } from 'react';
import { eventBus } from 'storeProvider/eventBus';

export function CartBadge() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    // 订阅事件,返回值是取消订阅函数
    const unsubscribe = eventBus.on('cart:item-added', () => {
      setCount(c => c + 1);
    });
    return unsubscribe; // cleanup:组件卸载时自动取消订阅
  }, []);

  return  {count};
}

注意事件取消订阅的必要性——微前端环境下子应用频繁挂载/卸载,内存泄漏问题比普通 SPA 更严重。用返回值 cleanup 函数是最干净的写法。

⑤ 隔离的另一面:避免状态污染

共享状态解决了"无法沟通"的问题,但也引入了"沟通太多"的风险。

最常见的状态污染场景:子应用 A 在路由离开时没有清理 store,下次子应用 B 挂载时拿到了脏数据。或者子应用 A 的定时任务在后台继续修改共享 store,导致 UI 出现幽灵更新。

5.1 子应用生命周期钩子清理状态

// 以 qiankun/single-spa 为例
// product-app/src/main.ts
import { useProductStore } from './stores/productStore';
import { eventBus } from 'storeProvider/eventBus';

let eventUnsubscribers: Array void> = [];

// 子应用挂载时注册监听
export async function mount(props: any) {
  const unsub1 = eventBus.on('user:logged-out', () => {
    useProductStore.getState().reset(); // 登出时清空商品缓存
  });
  eventUnsubscribers.push(unsub1);
  renderApp(props);
}

// 子应用卸载时清理
export async function unmount() {
  // 取消所有事件订阅
  eventUnsubscribers.forEach(fn => fn());
  eventUnsubscribers = [];
  
  // 重置私有 store
  useProductStore.getState().reset();
  
  // 销毁 React 根
  root.unmount();
}

5.2 Zustand store 的 reset 设计

在 store 设计时,提前预留 reset 接口是微前端架构的最佳实践:

const initialState = {
  products: [] as Product[],
  selectedId: null as string | null,
  loading: false,
};

export const useProductStore = create void }
>()((set) => ({
  ...initialState,
  reset: () => set(initialState), // 一键重置到初始状态
}));

⑥ 方案选型总结

三种模式适合不同场景,不是非此即彼的关系:

方案 适用状态类型 隔离性 调试难度
MF 共享 Store 全局身份/权限/主题 低(共享实例) 低(Redux DevTools 支持)
事件总线 跨应用业务通知 高(松耦合) 中(需日志追踪)
子应用私有 Store UI 交互/局部业务 最高(完全隔离) 最低(单应用调试)

实战建议:大多数状态放私有,少数全局通过 MF singleton 共享,跨应用交互优先用事件总线。出现"我需要在子应用里访问另一个子应用的内部状态"时,往往是架构设计出了问题——需要重新审视边界,而不是硬加一个共享通道。

⑦ 一个踩坑笔记

最后分享一个在生产中遇到的真实问题。

我们用 Zustand + MF singleton 共享用户 store,初期一切正常。某天上线后,部分用户反馈"登出后仍然能看到上一个用户的数据"。

排查了很久,最后发现问题出在:子应用 B 在本地开发时忘记声明 zustand: { singleton: true },导致测试环境没问题(因为都是本地单进程),但生产环境子应用 B 实例化了自己的 zustand,共享 store 的更新无法传递到子应用 B。

MF singleton 是"两端约定"

提供方声明 singleton 不够——每个消费方也必须声明 singleton,否则会各自维护一个实例。建议把 shared 配置抽成团队共享的 npm 包统一维护,避免各微应用各自配置漂移。

这也是微前端架构的一个普遍特征:问题往往不出在技术实现上,而出在跨团队约定的遵守上。架构师的核心工作之一,是把这些约定变成机制(lint 规则、CI 检查、共享配置包),而不是靠口头协议。

小结

微前端的状态管理不需要一个"终极方案",需要的是清晰的分层和边界意识

  • 先做状态分层,确认哪些状态真的需要跨应用共享

  • 全局状态用 MF singleton 共享,但消费方必须对等声明

  • 跨应用通知用类型安全的事件总线,松耦合优先

  • 子应用内部状态保持完全隔离,卸载时务必清理

  • 把约定变成工具和机制,不要依赖人工记忆

状态管理本质上是一个关于"数据所有权"的问题。在微前端里,把这个问题想清楚,比选什么技术栈更重要。

如果你在微前端落地中遇到过状态管理的坑,欢迎留言交流。下期我们聊聊 Signal 机制在 Angular/Vue/Solid 中的横向对比——那是另一种完全不同的状态管理哲学。

一天时间,用 Claude Code 蹬了一个 v0 出来(附源码)

最近,出于业务需要,参考 v0 的实现,蹬了一个类似 v0 的平台出来。

先看效果:

整体采用 Next.js 做前后端服务,E2B 提供沙箱,Claude Agent SDK 完成代码生成,沙箱提供预览和代码推送部署能力。

ps: 本文不会包含任何的代码(本身也都是 AI 生成的),只会介绍相关方案的选型、核心的架构和实现原理。同时关于部署的环节,各个公司都有自己的部署流水线,并不具备参考价值,会弱化这个环节的介绍。

方案对比和设计

AI 生成前端代码,一般有这么几种方式:一份 html,一份代码块,以及直接生成项目。

生成 html

生成一份 html,然后增删改查,最终存储 html 即可,不论是预览还是部署,都最为简单。

有很多产品都是这么做的,比如 Claude 的 Artifacts,Google 的 Stitch。

这是最简单,也最轻便的方案。

这里面的关键技术点有几个:

  1. 如何让 AI 生成高质量的 HTML?当然这也无非就是需要一些非常优秀的提示词来约束 AI 的行为。

  2. 如何增量修改?通过在浏览器侧实现一个支持局部替换的 Edit Tool 即可,这也是很多 cli 工具在本地修改代码的常见策略。

  3. 后期的可维护性是这个方案最大的隐患。生成的 HTML 往往是一个几百行甚至上千行的单文件,没有组件拆分,没有模块化,样式和逻辑全部混在一起。如果需要人工介入修改,多年程序员看到这样的代码,大概会有一种被拉回刀耕火种时代的感觉——能改,但很痛苦。这也意味着,一旦走上这条路,后续的迭代就只能继续依赖 AI,项目实际上已经不再适合人来维护。

ps: 这里可能会有人好奇,为什么不是修改某一行某几列的代码,这是因为 AI 对于行号识别不准确,反而直接执行字符搜索并替换更为准确。感兴趣的可以查看 pi-mono 项目中 edit 工具的实现,这也是绝大部分 cli 工具的实现方案。

至于 html 的预览和部署,可谓是极为简单且花费最少了。

生成代码块

另一种方式是:生成代码块,存储在数据库中,预览采用 WebContainer、Sandpack,或者通过 Babel 转 CommonJS 在浏览器端模拟打包等方式来预览前端项目。

这基本是纯前端的方案,不过 WebContainer 要授权,Sandpack 倒是开源,但是加载速度上可能存在一些问题。至于 Babel 转 CommonJS 自行实现编译系统,也是 ok 的,只是要支持 jsx, vue, 要花一点时间,开发的工作量不小。

当然,除了这些建设,如何稳定 AI 的输出,也是这个方案中的一大问题,理想情况下,希望 AI 的产物是 文件名 + 内容 组合成的 json 数组。

一般可以通过几个方案来解决:

  1. 换更好的模型
  2. 运用 XML 这样的提示词技巧,来让 AI 输出的更符合预期

但是这个方案有几个比较大的问题:

  1. 编译工程复杂度比较高
  2. 增量替换的方案,输出格式可能不如工具调用那般精准,在耗时和质量上会更低效一点。
  3. 对于外部依赖的包,需要提前做编译、告知 AI 用法等,相对不那么自由

直接生成项目

直接生成项目,最终预览和部署都和普通的项目一样。这也是 v0 的方案。

这个方案本质上是给用户准备一个沙箱,这个沙箱中,直接启动一个 claude code 或者 codex 这样的工具,可以是 cli 也可以是 sdk。

同时指定一个工作目录,最终的项目生成和运行,都发生在这个工作目录下。用户输入直接指向 claude code,从而完成项目的生成。

这个方案的灵活度最高,同时由于背后是最顶尖的 AI 生成工具,所以在质量上和效率上,其实都不太需要担心。

但是最大的问题就在于需要给每一个用户都提供一个沙箱,对于运维部署的能力要求比较高。

同时沙箱的内存分配和 cpu 分配,资源上也不能少。

不过好在已经有很多服务商提供这样的服务,比如 E2B、Cloudflare 等服务商。付费调 API 的话,准备一个沙箱也很容易。

对比表格

维度 生成 HTML 生成代码块 直接生成项目
实现复杂度
预览方案 直接渲染 iframe WebContainer / Sandpack / Babel 转 CommonJS 沙箱内启动 dev server
部署复杂度 极低,存 HTML 即可 低,纯前端方案 高,需要为每个用户分配沙箱
增量修改精准度 高(字符串 Edit Tool) 中(输出格式不如工具调用稳定) 高(Agent SDK 原生工具调用)
AI 输出稳定性 高(单文件,约束简单) 中(需要结构化 JSON 输出,依赖提示词技巧) 高(由 Agent 工具链保证)
外部依赖支持 弱(只能用 CDN 引入) 弱(需要提前编译、告知 AI 用法) 强(npm install 自由安装)
代码可维护性 低(不适合人工维护) 高(标准项目结构)
资源消耗 极低 高(沙箱需要分配内存和 CPU)
灵活度
代表产品 Claude Artifacts、Google Stitch Bolt.new(基于 StackBlitz WebContainer) v0、本文实现

架构设计

整体的架构图如上,分为三块:

  1. Next.js 前端:聊天输入框、消息流展示、代码文件树、实时预览 iframe,以及打断/重试等交互控制。

  2. Next.js 后端:接收前端消息,维护会话与沙箱的映射关系,将消息转发给对应沙箱内的 Agent,并将 Agent 的流式输出透传回前端。

  3. E2B 沙箱:基于自定义模板启动,模板内预装了 Node.js 环境和项目脚手架。沙箱内运行 Claude Agent SDK,负责代码的生成与修改;同时启动 dev server 并通过 E2B 的端口暴露能力对外提供预览。

消息流转

用户操作路径如下:

  1. 用户打开平台,发起第一条消息,后端按需创建 E2B 沙箱(冷启动约需几秒)
  2. 沙箱就绪后,后端将消息投递给沙箱内的 Claude Agent SDK
  3. Agent SDK 开始工作:调用文件读写工具生成或修改代码
  4. Agent 的输出以流式事件的形式,经后端透传回前端实时展示
  5. 代码变更同步到文件树,预览 iframe 直接加载沙箱暴露的端口

会话与沙箱管理

多用户场景下,每个会话对应一个独立的沙箱实例,隔离性天然满足。

上下文的维护完全交给 Agent SDK,后端只需持久化"会话 ID → 沙箱 ID"的映射即可。考虑到沙箱有闲置超时机制,需要在映射层做好沙箱的重建和恢复逻辑,一般沙箱的服务方基本都会内置这些能力。

部署发布

代码的部署和发布,一个比较通用的方案是在沙箱内完成 Git 提交,推送到远程仓库后触发 CI/CD 流水线,从而完成项目的上线。由于这部分强依赖各公司自身的发布体系,本文不展开。

整体来讲技术卡点并不多。最核心的 AI 代码生成能力,借助 Agent SDK 即可完成,质量和直接使用 Claude Code 打平。沙箱管理和前端页面反而是 AI 最擅长的部分,蹬起来毫无压力。

心得体会

整体蹬一个 v0,让 AI 写代码花费的时间其实并不多,大概一天左右就能蹬出来。

但是有一说一,这个方案,其实来来回回跟 AI 拉扯了几天,大到从生成 HTML,到生成片段代码,再到最后的沙箱方案,而小到增量更新的解决方案,Babel 转义的优劣,都属于考量的范畴。

包括是用 Agent SDK,还是直接用 Claude CLI,也是经过多方权衡后的结果。

一切方案落定,Plan Mode 开启,Opus 一开,反而是最轻松的时刻。

基本上第一次的产物,就能达到最小 demo 的效果。

至于交互上的细节,比如打断输入,补充说明,向用户提问明确需求,这些细节上的打磨,也是花点心思就能解决的地方。

整体来讲,在没有 AI 介入之前,其实是不太能这么快完成这样一个系统的。单单是沙箱方案的选型,可能都要花费个几天,比如沙箱的暂停和恢复,费用的对比等等,也是 AI 辅助决策的结果,有了决策,实现又是几天,确确实实在效率上提升非常大。

在这个过程中,我本身也是直接退订了 Cursor,因为完全不需要自己再上手手动修代码了,单说执行这块,AI 绝对是夯爆了。

很难说不焦虑,但又感觉不必太过焦虑。这次最大的体感不是"AI 写代码很快",而是整个过程中,花时间最多的地方依然是人在做的事——判断方案的取舍,理解各种工具的边界,决定什么值得做、什么可以砍掉。执行层 AI 确实夯爆了,但执行之前的那些决策,AI 只是参谋,拍板的还得是人。

所以与其焦虑被替代,不如想清楚自己在一件事里到底在做什么。毕竟 AI 还是得有人蹬,至于蹬到哪里去,这个问题 AI 替你答不了。

源码

本文的 POC(Proof of Concept,概念验证)代码已开源,即用最小的实现跑通"用户输入 → Agent 生成代码 → 沙箱预览"这条核心流程,感兴趣的可以查看:github.com/yuzai/code-…

现代 CSS 的新力量

如果你拆开一个稍微复杂一点的 Web 应用,很容易发现一个熟悉的现象。在还没写任何业务代码之前,就已经引入了一堆 JavaScript 库,只是为了实现一些基础的 UI 行为,比如提示框定位、滚动动画、弹窗管理,甚至一个简单的下拉选择框。而现在,这种情况正在发生改变——越来越多这样的能力,开始被 CSS 接管。

以提示框(Tooltip)和弹窗(Popover)为例,过去通常需要依赖像 Floating UI Popper 这样的库来计算位置、处理溢出、实现自动翻转。这类问题本质上是“布局问题”,却一直由 JavaScript 来解决。如今,CSS 锚点定位以及原生 Popover API 已经可以直接完成这些工作,浏览器会自动处理边界和对齐,不再需要手写复杂的逻辑。

类似的变化也发生在滚动动画上。过去如果想让动画跟随滚动进度变化,几乎离不开 GSAP ScrollTrigger。Web 开发者需要监听 scroll 事件、计算位置,再把结果映射到动画上。而现在,CSS 提供了滚动驱动动画(如 scroll-timeline),可以用声明式的方式直接把滚动和动画绑定起来,不仅代码更简单,而且性能更好,因为这些计算是在浏览器内部完成的。

在交互动效方面也是如此。像 Framer Motion 这样的库长期以来负责处理状态切换、进入和离开动画,甚至页面过渡。但随着 CSS 动画能力的增强(例如更强的 transiton@starting-style 以及视图过渡),这些原本依赖 JavaScript 控制时间轴的效果,正在逐渐转向用 CSS 描述。

再看弹窗和组件行为。过去我们常借助 Radix UI Headless UI 来处理模态框的焦点管理、键盘交互和可访问性细节。这些并不只是“显示一个弹窗”,而是涉及一整套复杂行为。而现在,浏览器提供了 <dialog>popover 等原生能力,很多行为已经内建,不再需要额外的库来兜底。

最后是表单控件这个长期的痛点。由于原生 <select> 难以样式化,开发者往往选择使用诸如 React Select 这样的库,从零实现一个组件。但随着 CSS 对原生控件样式能力的不断增强,我们越来越可以在保留原生语义和可访问性的前提下,对其进行自定义,而不是彻底重写。

把这些变化放在一起看,可以发现一个清晰的趋势,我们正在从“用 JavaScript 补足浏览器能力”,转向“直接使用浏览器提供的能力”。这不仅意味着更少的代码体积,也意味着更好的性能、更低的维护成本,以及更少需要手动处理的边界问题。本质上,这是一种回归——让 CSS 负责它本该负责的事情。

换句话说,一批被期待已久的 CSS 特性正在陆续落地,而且它们的目标很明确,替代那些过去必须依赖 JavaScript 才能实现的 UI 模式。而且,这不是“并不多能用”的替代方案。它们是浏览器提供的平台级能力——能够处理边界情况,运行在正确的渲染线程上,并且不依赖任何第三方库。

接下来,我们一起来看看,这些能力已经发展到了什么程度,它们具体替代了哪些 JavaScript 方案,你可以删掉多少代码,以及还有哪些问题,CSS 依然没有解决。

锚点定位

在很长的一段时间里,从提示框(Tooltip)、下拉菜单(DropdownMenu)、弹窗(Popover)到各种浮动菜单,这类 Web UI 一直依赖 JavaScript 来“贴住”触发元素。你要么使用第三方 JavaScript 库,要么自己写一套基于 getBoundingClientRect 的计算逻辑,在滚动、缩放和布局变化时不断更新位置。归根结底,浏览器本身一直缺少一个原生能力——让一个元素跟随另一个元素定位

CSS 锚点定位从根本上改变了这一点。现在,你可以用 anchor-name 声明一个锚点元素,然后通过position-anchor 将目标元素与锚点元素关联起来,再通过 anchor() 函数或者 position-areaposition-try ,将目标元素相对于锚点元素进行定位。所有的位置计算都交给浏览器完成,而且不仅仅是“放在那里”,连溢出处理也一并解决。比如,当 Tooltip 可能被视口裁切时,你可以定义多个备用位置,浏览器会按顺序自动尝试并选择合适的方案。

.button {
    anchor-name: --my-button;
}

.tooltip {
    position: absolute;
    position-anchor: --my-button;
    top: anchor(bottom);
    left: anchor(left);
}

Demo 地址:codepen.io/airen/full/…

不需要 JavaScript,不需要 ResizeObserver,也不需要监听 scroll。一旦建立了锚点关系,浏览器就会在滚动、尺寸变化以及布局变动时,自动保持浮层元素与锚点之间的对齐关系。这类原本需要频繁计算和同步的逻辑,现在完全交由浏览器底层处理。

这里展示的是基于 CSS 锚点定位实现的一个最基础版本的 提示框组件。但它的能力远不止于此。CSS 锚点定位不仅可以用来做提示框组件,还可以扩展到许多过去必须依赖 JavaScript 的交互场景,比如:熔岩导航菜单元素之间的联动动画、类似 macOS Dock 的导航效果滑动跟随的悬浮层带“磁性吸附”的悬浮交互,甚至是元素之间的连接线效果等。这些原本需要复杂计算和事件监听的功能,现在都可以用更原生、更简洁的方式实现。

在浏览器支持方面,Google Chrome 已在 2024 年(Chrome 125)提供了较为完整的实现,而 Mozilla Firefox 和 Safari 也在持续推进中。目前,一些基础能力(如 anchor-name anchor() 函数和 position-area)已经可以在主流浏览器中使用;而像 position-tryposition-visibility 这样的高级特性,仍在逐步落地。

温馨提示:如果你希望更系统、更深入地掌握 CSS 锚点定位相关特性,建议抽时间阅读《CSS 锚点定位: 探索下一代 Web 布局》和《CSS 布局:重聊 CSS 锚点定位》这两节课程。它们会从基础到进阶,帮助你全面理解这一能力,并应用到实际项目中。

Popover API

CSS 锚点定位和 HTML Popover API,其实是在解决同一个问题的两个不同侧面。CSS 锚点定位负责“浮层出现在哪里”,HTML Popover API 负责“浮层是否出现”。也就是说,CSS 锚点定位负责处理位置,而 HTML Popover API 处理可见性、交互行为以及可访问性。一套完整的提示框(Tooltip)或弹窗(Popover)组件,通常需要两者配合使用。

在 Popover API 出现之前,构建一个真正“可用”的 Popover 、下拉菜单或非模态弹框,往往需要使用 JavaScript 实现一整套复杂逻辑:焦点管理、维护 aria-expanded 状态、处理键盘交互、监听点击外部关闭等。这些细节不仅繁琐,而且非常容易出错,这也是为什么像 Headless UI、Radix UI 这类库长期存的原因。

而现在,事情变得简单得多。通过 HTML 的 popover 属性以及配套的 popovertarget ,你只需要少量的 HTML 结构,就可以获得一个原生、可访问的 Popover 组件。浏览器会自动帮你处理一系列关键行为,包括显示与隐藏的切换、点击外部关闭、按下 Escape 键关闭、使用 ::backdrop 创建背景遮罩、焦点管理,以及顶层渲染(popover 始终会显示在页面最上层,无需再处理复杂的 `z-index``)。

<button invoketarget="ref_1" popovertarget="ref_1">roll</button>
<div  popover id="ref_1" class="card"></div>

Demo 地址:codepen.io/airen/full/…

这些能力如今已经在 Google Chrome、Mozilla Firefox 和 Safari 中成为基础功能。对于绝大多数常见场景来说,它已经可以替代过去依赖各种第三方 JavaScript 库所实现的整套交互逻辑。

Popover API 非常适合用来构建提示框下拉菜单、上下文菜单、通知提示、引导提示等各种非模态浮层——也就是那些用户点击外部时应该自动关闭的界面。但需要注意的是,它并不是用来替代模态对话框的,对于需要阻止背景交互的场景,仍然应该使用 <dialog> 元素(稍后会详细介绍)。

温馨提示,如果你想更深入的了解 Popover API 相关的特性,请移步阅读《CSS 布局:CSS 与 Popover API 的结合》!

AIM(Anchor Interpolated Morph)

AIM (Anchor Interpolated Morph,锚点插值变形)是一种前沿的 Web 动画技术,它的核心理念是将元素视为“锚点”,通过位置、尺寸和形状的插值计算,实现元素之前的平滑过渡动画。通俗来说,AIM 能让一个元素看起来像是从另一个元素位置“生长”出来,或者在关闭时平滑地返回原来的位置。与传统的 过渡或动画 不同,AIM 不仅关注单一属性的变化(例如位置或透明度),而是综合考虑元素的空间信息和布局锚点,在不同状态之间自动生成连贯的动画路径,使界面变化更加自然流畅,同时保留用户的空间感知,让用户清楚地理解元素之间的关系。从动画效果来看,AIM 与早期的 F.L.I.P(First, Last, Invert 和 Play)技术非常相似。

只不过,在技术实现上,AIM 利用了一系列 现代 CSS 特性CSS 锚点定位anchor()anchor-size())允许一个元素将另一个元素的坐标和尺寸作为起点,从而建立空间关联。 @starting-style 规则定义元素在渲染瞬间的样式,例如弹出框初次出现的状态,使入场动画自然流畅。interpolate-size 属性则支持在内联关键词尺寸(如 automin-content)与具体长度尺寸(如 300px)之间动画化,保证元素在状态变化时平滑过渡。这些功能结合在一起,让浏览器可以在渲染时自动计算元素的起始状态和目标状态,并在它们之间生成连续动画,无需 JavaScript 干预,同时动画可以自然中断,响应用户操作。

<div class="cell">
    <button popovertarget="img-1" aria-label="Open Image of Mountain Landscape">
        <img src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=700&h=400&fit=crop&auto=format" alt="Mountain landscape" loading="lazy" />
    </button>
    <img popover id="img-1" src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=1800&auto=format" alt="Mountain landscape" loading="lazy" />
</div>
:root {
    interpolate-size: allow-keywords;
}

button {
    anchor-name: --morph;
}

[popover] {
    --speed: 0.5s;
    position-anchor: --morph;
    
    @media (prefers-reduced-motion: no-preference) {
      transition:
          display var(--speed) allow-discrete,
          overlay var(--speed) allow-discrete,
          height var(--speed) ease,
          width var(--speed) ease,
          top var(--speed) ease,
          left var(--speed) ease;
    }
    
    &:popover-open {
        height: auto; /* 或 fit-content */
        max-height: 70dvb;
        width: 70dvi;
        left: 15dvi;
        top: 15dvb;
        
        @starting-style {
            left: anchor(left);
            top: anchor(top);
            width: anchor-size(width);
            height: anchor-size(height);
        }
    }
    &:not(:popover-open) {
        left: anchor(left);
        top: anchor(top);
        width: anchor-size(width);
        height: anchor-size(height);
    }
}

AIM 技术在实际应用中非常适合图片画廊、列表展开和弹窗动画等场景。例如,在图片画廊中,缩略图可以作为锚点,大图从缩略图位置动画展开,填充屏幕;关闭时,大图又平滑缩回原位。这种动画方式不仅直观、有趣,还增强了用户对界面元素来源和去向的认知,使交互更易理解。

Demo 地址:codepen.io/airen/full/…

温馨提示,如果你对 AIM 技术感兴趣的话,建议你花点时间阅读《Web 动效:锚点定位之锚点插值变形(AIM)解析》和《Web 动效:用 AIM 做出高级感 UI 过渡》!

dialog 元素

如果说 Popover API 适用于非阻塞的浮层——用户点击外部即可关闭,那么 HTML 的 <dialog> 则用于真正的模态体验(模态框):在关闭之前,必须完全占据用户注意力的那类交互。

在传统的 JavaScript 实现中,这类模态对话框往往需要一整套繁琐的逻辑,比如创建背景遮罩(通常用 div 来创建)、模态框的定位、手动设置 aria-modal ,在模态框内捕获焦点、关闭时恢复焦点,以及禁止页面滚动等。这些细节不仅复杂,而且很容易遗漏,进而影响可访问性。

而原生 <dialog> 元素把这一切都内置了。结合 ::backdrop 伪元素和 .showModal() 方法,你只需要一个 HTML 元素和少量的 JavaScript 就可以创建一个完整、可访问的模态对话框。浏览器会自动处理焦点管理、遮罩层以及交互行为,大大简化了实现过程。

<button class="button" onclick="document.getElementById('my-dialog').showModal()">Open dialog</button>

<dialog id="my-dialog">
    <div class="dialog-header">
        <div class="dialog-title">Delete this file?</div>
    </div>
    <div class="dialog-body">
        This action cannot be undone. The file will be permanently removed from your workspace.
    </div>
    <div class="dialog-footer">
        <button class="button secondary" onclick="document.getElementById('my-dialog').close()">Cancel</button>
        <button class="button" onclick="document.getElementById('my-dialog').close()">Delete</button>
    </div>
</dialog>

其中最关键的“模态陷阱”——也就是 Tab 键只在对话框内部循环、按下 Escape 键自动关闭——同样由浏览器原生处理。从 2022 年开始,这些能力已经成为主流浏览器的基础支持,让模态对话框的实现从“复杂工程”变成了“开箱即用”。

Demo 地址:codepen.io/airen/full/…

温馨提示,有关于 dialog 更详细的介绍,请移步阅读《用于美化模态框的 :modal::backdrop》!

Popover API vs. dialog 元素:有什么区别?

在 Popover API 和 dialog 元素看起来都在处理“弹出层”,但本质上并不是同一类问题。两者在可访问性和交互模式上存在明显差异,这也是它们各自适用场景不同的根本原因。Popover API 更偏轻量、非阻塞的浮层,而 dialog 则是为严格的模态交互而设计。

或者说,两者核心区别在于页面的其他部分是否仍然可以交互:

功能 Popover API dialog 元素
阻止背景交互
点击外部关闭
焦点陷阱
滚动锁定
适用场景 Tooltip、下拉菜单、Toast 等 确认框、表单、警告
API 使用方式 纯声明式 HTML 需要 JavaScript(.showModal()

两者的控制模型也体现了这一差异。Popover API 完全不需要 JavaScript —— popovertarget 就能在 HTML 中把按钮和面板绑定起来。dialog 如果只用 .show() 打开,则是非模态框,几乎没有实际用处;真正的模态框(具有模态行为)依赖 .showModal()。两都都可以用 Escape 关闭,但只有 dialog 会捕获焦点并在关闭前阻止与页面其他部分的交互。

因此,在实际使用中可以遵循一个简单的原则:大多数普通的弹出层场景优先选择 Popover API;只有当你需要一个真正阻止用户与页面其他部分交互的模态对话框时,才使用 dialog

detailssummary

原生 <details><summary> 提供了一种极其直接的方式来实现展开与折叠的交互模式。像 FAQ、手风琴、可折叠信息区域这类 UI,在过去往往需要通过 JavaScript 控制状态切换、管理类名甚至处理动画,而现在,我们可以用一个段箭洁的 HTML 原生完成。

<details>
    <summary>查看详细内容</summary>
    <p>这里是展开后的内容,用户可以点击 summary 来切换显示。</p>
</details>

更重要的是,这不仅仅是“更简单”的实现方式,它还是一种语义化且可访问的解决方案。浏览器会自动处理展开状态(open 属性)、键盘交互(如 Enter / Space 切换)、以及与屏幕阅读器的兼容性。这意味着你不再需要手动管理 aria-expanded、焦点状态或键盘事件,这些过去容易出错的细节,现在都由浏览器原生保证。

从扩展性来看,<details> 也并不局限于基础用法。你可以通过 CSS 精细控制它的外观,比如自定义 summary 的样式、隐藏默认箭头、甚至结合动画实现更流畅的展开效果。对于手风琴场景,还可以通过少量 CSS(或配合 :has() 等现代选择器)实现“同一时间只展开一个”的交互,而无需 JavaScript。

Demo 地址:codepen.io/airen/full/…

在浏览器支持方面,<details><summary> 已经成为现代浏览器的基础能力,可以放心用于生产环境。而在开发体验上,它几乎是“零成本”的:无需引入库、无需编写逻辑,就能获得完整的交互与可访问性支持。

对于绝大多数折叠类 UI 场景,这通常是最简单、最可靠、也最推荐的实现方式

温馨提示,有关于这方面更详细的介绍,请移步阅读《CSS 布局:创建手风琴组件》和《Web UI:使用现代 CSS 创建手风琴组件》!

需要特别指出的是,发展到今天,很多“内容切换”类的交互,其实已经不再依赖 JavaScript。借助现代 CSS 与原生 HTML 能力,你完全可以用纯 CSS(或极少量声明式 HTML) 实现这些效果。常见的方式包括:利用 :has() 搭配 inputcheckbox / radio)实现状态驱动的切换,使用 <dialog> 构建模态交互,通过 Popover API 控制浮层显示,以及 <details><summary> 实现折叠内容等。

这些方案不仅实现更简单,而且更加语义化、可维护,并且在可访问性方面由浏览器原生保障。如果你对这一类“用 CSS 替代 JavaScript”的实现方式感兴趣,可以进一步阅读《CSS 布局:切换内容的现代方式》深入了解。

滚动驱动动画

在过去,所谓“滚动驱动动画”,几乎等同于一套固定模式。用 JavaScript 监听 scroll 事件,配合 requestAnimationFrame ,在每一帧中读取 window.scrollY ,再去更新 CSS 自定义属性或 transformopacity 等属性值。随着页面复杂度的提升,这种做法很容易带来性能问题——频繁的主线程计算、布局抖动,以及难以维护的同步逻辑。像 GSAP ScrollTrigger 这样的库,本质上就是为了让这种模式更可控、更易管理而存在。

而现在,CSS 正在接管这一切。随着滚动驱动动画规范的引入,你可以通过 animation-timelinescroll()view())直接将动画绑定到滚动进度上。也就是说,动画不再依赖 JavaScript 去驱动,而是由浏览器根据滚动状态自动计算和执行——整个过程完全声明式,无需额外脚本。

@keyframes spin {
    to {
        rotate: y 5turn;
    }
}

@supports (animation-timeline: scroll()) {
    @media (prefers-reduced-motion: no-preference) {
        figure {
            animation: spin linear both;
            animation-timeline: scroll();
        }
    }
}

Demo 地址:codepen.io/airen/full/…

这种变化的关键在于执行方式的不同,动画运行在浏览器的合成线程上,而不是主线程。这意味着即使在复杂页面或高负载情况下,动画依然可以保持流畅,不会因为 JavaScript 阻塞而掉帧。从“手动驱动”到“浏览器原生驱动”,这正是 CSS 在这一类交互能力上的一次质变。

更重要的是,CSS 滚动驱动动画的意义,早已不只是“做滚动动画”这么简单。它本质上提供了一种基于滚动状态驱动 UI 的能力,而这正是过去大量依赖 JavaScript 才能实现的交互核心。

借助这些能力,你可以用纯 CSS 实现一整类复杂效果。例如:根据滚动位置判断内容状态(如文本是否溢出数量尺寸变化)、构建滚动进度指示器自动高亮当前章节的目录(TOC)、实现滚动遮罩效果,甚至是更具表现力的交互——比如类似 macOS Dock 的图标放大效果按钮在滚动中切换为导航栏元素的渐入渐出动画带渐变效果的滚动条滑动删除3D 翻转滚动视差,以及轮播CoverFlow 等组件。

这些过去需要大量事件监听、状态同步和手动计算的逻辑,现在可以直接通过 CSS 声明式地完成。你不再需要“监听滚动再驱动 UI”,而是把“滚动本身”作为动画时间轴,让浏览器去完成剩下的一切。这种思维方式的转变,才是它真正强大的地方。

温馨提示,如果你想进一步玩转 CSS 滚动驱动动画?不妨看看《CSS 滚动驱动动效》和《Web 动效:滚动驱动动效之实战技巧》课程!

滚动状态查询

CSS 滚动状态查询可以看作是对容器查询的一次重要扩展(容器查询在滚动状态查询之前已有**尺寸容器查询样式容器查询**)。它将由浏览器内部管理的滚动状态直接暴露给 CSS,使得 Web 开发者可以基于滚动行为进行样式控制,而完全不需要借助 JavaScript。该特性已在 Google Chrome 133 中发布,Mozilla Firefox 和 Safari 的支持也正在推进中。

这一功能的核心在于,它提供了三类常见但过去必须依赖 JavaScript 才能获取的滚动状态:stuck (粘性状态)、 snapped (吸附状态)和 scrollable (可滚动状态)

首先是 stuck。对于 position: sticky 元素,过去如果想知道它是否已经“吸附”在顶部,通常需要借助一个额外的哨兵元素,再配合 IntersectionObserver 来判断。而现在,只需声明 container-type: scroll-state,就可以在 CSS 中直接根据是否处于粘性状态来应用样式,比如在吸顶时添加阴影效果。这种从“监听状态”到“声明状态”的转变,大大简化了实现方式。

.stuck-top {
    /* 粘性定位,是必须要的 */
    position: sticky;
    top: 0;
    
    @supports (container-type: scroll-state) {
        container-type: scroll-state; /* 定义滚动状态查询容器 */
        
        .heading {
            transition: box-shadow 0.5s ease-out;
            
            @container scroll-state(stuck: top) {
                box-shadow: 
                    rgb(0 0 0 / 0.6) 0px 12px 28px 0px,
                    rgb(0 0 0 / 0.1) 0px 2px 4px 0px,
                    rgb(255 255 255 / 0.05) 0px 0px 0px 1px inset;
            }
        }
    }
}

Demo 地址:codepen.io/airen/full/…

其次是 snapped。在滚动捕捉场景中,过去要判断哪个元素当前被吸附,需要监听 scrollsnapchange 事件,并手动切换类名。而现在,被吸附的元素可以直接通过 CSS 感知自身状态,从而改变样式或影响其子元素。例如:

.list {
    scroll-snap-type: x mandatory;
    overflow-x: auto;
    scroll-padding-inline: 2ch;

    li {
        scroll-snap-align: center;

        @supports (container-type: scroll-state) {
            container-type: scroll-state;
    
            :is(.blur, .content) {
                translate: 0 100%;
                transition: translate 0.2s ease-in-out;
                
                @container scroll-state(snapped: x) {
                    translate: 0;
                }
            }
    
            figure img {
                @container  scroll-state(snapped: x) {
                    mix-blend-mode: darken;
                    scale: 1.5;
                }
                
                @container not  scroll-state(snapped: x){
                    filter: grayscale(1);
                }
            }
        }
    }
}

Demo 地址:codepen.io/airen/full/…

最后是 scrollable。像“只有在内容可滚动时才显示滚动阴影”或“滚动到一定位置才出现返回顶部按钮”这类常见需求,以往通常依赖滚动监听或观察器 API。现在,这些都可以通过 CSS 条件直接表达——当某个方向不可滚动时隐藏对应 UI,从而实现更简洁、声明式的控制。

.scroll-container {
    overflow-y: auto;
    overflow-x: hidden;
    scroll-snap-type: y mandatory;
    inline-size: 30em;
    block-size: 18.735lh;
    border: 1px solid var(--surface-1);
    scroll-padding-block: 10px;
    overscroll-behavior: contain;
    display: grid;

    > * {
      grid-area: 1/1;
    }

    > .scroll-indicator {
        place-self: end;
        position: sticky;
        inset-block-end: 10px;
        inline-size: 100%;
        text-align: center;
        transition: translate 0.2s ease;

        > svg {
            background: var(--surface-2);
            aspect-ratio: 1;
            border-radius: 1e3px;
            inline-size: 48px;
            block-size: 48px;
        }
    }

    @supports (container-type: scroll-state) {
        container-type: scroll-state size;

        > .scroll-indicator {
            @container scroll-state((scrollable: top) or (not (scrollable: bottom))) {
                translate: 0 calc(100% + 10px);
            }
    
            @container scroll-state((scrollable: top) and (not (scrollable: bottom))) {
                translate: 0 calc(100% + 10px);
                rotate: 0.5turn;
            }
        }
    }

    .item {
        scroll-snap-align: start;
        scroll-snap-stop: always;
    }
}

Demo 地址:codepen.io/airen/full/…

这三类状态查询,本质上替代了过去大量基于事件监听(scroll)或观察器(IntersectionObserver)的实现方式。你不再需要“监听滚动再更新状态”,而是可以直接在 CSS 中描述“当处于某种滚动状态时应该如何表现”,让浏览器去完成剩余的工作。这正是现代 CSS 在交互能力上的又一次重要跃迁。

温馨提示:如果你希望更深入理解滚动状态查询的原理与实际应用,建议进一步阅读《Web UI:容器查询之滚动状态容器查询》和《Web UI:CSS 中的滚动驱动方向》这两篇内容。它们会从基础概念到进阶实践,帮助你全面掌握这一能力,并将其灵活运用到真实项目中。

更有趣的是,如今结合 CSS 的 滚动驱动动画滚动捕捉等能力,你已经可以用纯 CSS 构建复杂的交互组件。像 Web 上常见的轮播Carousel)甚至 CoverFlow 这类具有空间感和动态效果的组件,过去往往需要一整套 JavaScript 逻辑来驱动,而现在则可以通过声明式的方式直接实现。例如,通过滚动捕捉控制滚动对齐,再配合滚动驱动动画绑定滚动进度,你可以轻松实现类似 Nintendo Switch 主屏幕那种带有缩放、聚焦和过渡效果的轮播体验

Demo 地址:codepen.io/airen/full/…

需要特别提出的是,CSS 正在引入一种新的查询能力——锚定容器查询。它是在尺寸容器查询、样式容器查询以及滚动状态查询之后的又一次扩展,进一步增强了 CSS 对“上下文感知”的能力。

当你结合 CSS 锚点定位使用时,这种能力会变得尤为强大。通过锚定容器查询,你可以让组件根据相对于锚点的位置变化自动调整自身表现,例如在发生位置回退时切换样式,而这一切都可以在 CSS 中声明完成。

Demo 地址:codepen.io/airen/full/…

换句话说,过去需要通过 JavaScript 检测位置、监听变化并手动更新 UI 的逻辑,现在可以完全交由 CSS 处理。这使得复杂的浮层、提示框以及自适应交互界面,能够以更简洁、更稳定的方式实现,真正做到“由 CSS 驱动 UI 行为”。有关于这方面更详细的介绍,请移步阅读《Web 组件:一种基于锚定容器查询的动态切换提示方案》!

视图过渡

在过去,单页应用(SPA)的页面切换动画——也就是路由切换时“旧页面淡出,新页面淡入”的效果,几乎完全依赖 JavaScript。无论是 React 的 react-transition-group,还是 Vue 的 <Transition> 组件,本质上都在帮你管理一套流程:克隆元素、设置绝对定位、同步多个动画状态,并手动控制 opacitytransform 的时间轴。这不仅实现成本高,而且维护起来也相当繁琐。

而 CSS 视图过渡(CSS View Transition API)将这一切交还给浏览器来完成。你只需要用 document.startViewTransition() 包裹一次 DOM 状态更新,浏览器就会自动捕捉更新前后的界面状态,并在两者之间执行平滑过渡(例如交叉淡入淡出)。整个过程无需手动管理中间状态,同时你仍然可以通过 CSS 自定义动画效果。

对于同一文档内的状态切换,这几乎就是完整的 API 使用方式。而在跨文档导航(也就是真正的页面跳转)中,只需一行 CSS —— @view-transition {navigation: auto} ——即可启用类似的过渡效果,让多页面应用也能拥有接近 SPA 的流畅体验。

更进一步,命名视图过渡还允许你针对特定元素创建跨页面动画,比如卡片从列表页“展开”进入详情页。这类效果在过去通常需要复杂的布局克隆与同步技巧,而现在可以用更直观的方式实现。

在浏览器支持方面,Google Chrome 已率先支持该特性,而 Safari 和 Mozilla Firefox 也在持续推进中。整体来看,这标志着页面过渡动画正从“框架能力”逐步转变为“平台能力”。

Demo 地址:codepen.io/airen/full/…

时至今日,CSS 视图过渡(View Transitions)已逐渐成为一种通用的界面状态过渡机制。借助它,你可以轻松实现诸如主题切换(如浅色/深色模式的平滑过渡)、展开与折叠的动效、甚至是带有氛围感的动态灯光效果等。这些原本需要精细控制时间轴和状态同步的交互,现在都可以交由浏览器统一处理。

这种能力的关键在于,它将“状态变化”与“视觉过渡”解耦:你只需要关心界面更新本身,而动画如何发生、如何衔接,则由浏览器自动完成,同时又允许你通过 CSS 精细定制细节。这让复杂动效的实现变得更加自然,也更易于维护。

如果你想进一步探索这些技巧和实际应用场景,可以移步阅读《解锁 CSS View Transitions API 的魔力》,深入了解这一能力的更多可能性。

自定义下拉选择框

自定义下拉选择框,几乎是 Web 上被“重复造轮子”最多的 Web UI 组件之一。长期以来,由于原生 <select> 难以进行深度样式化,各大设计系统不得不从零实现一整套替代方案:隐藏原生 <select> 以保留可访问性,用自定义元素作为触发器,手动定位下拉列表,实现键盘导航、焦点管理,以及为屏幕阅读器补充语义支持。为了替代一个基础的表单控件,往往需要成百上千行代码,这本身就是平台能力缺失的体现。

而“可定制下拉选择框”正是为了解决这一长期痛点。它允许 Web 开发者通过 CSS 直接控制 <select> 以及其内部结构。不令可以自定义触发按钮的外观,还可以样式化下拉容器,甚至精细控制每一个 <option> 。更进一步,你还可以在选项中使用任意 HTML 内容,让下拉选择框具备更丰富的表达能力。

@supports (appearance: base-select) {
    select {
        anchor-name: --select;
        padding-block: 0;
        
        &::picker-icon {
            display: none;
        }
      
        .icon {
            width: calc(var(--option-size) * 0.375);
            height: calc(var(--option-size) * 0.625);
        }
    }
    
    /* 美化下拉框 */
    ::picker(select) {
        --counts: 6;
        --rotation-divide: calc(360deg / var(--counts));
        position-anchor: --select;
        top: anchor(center);
        left: anchor(center);
        translate: -50% -50%;
        overflow: visible;
        transition: overlay 0.5s, display 0.5s;
        transition-behavior: allow-discrete;
        margin: 0;
        padding: 0;
        background: transparent;
        border: none;
    }
}

这项能力的意义在于,它将一个“必须用 JavaScript 重写”的组件,重新带回到原生平台层面。你不再需要在“可访问性”和“设计一致性”之间做取舍,而是可以同时获得两者。

目前,Google Chrome 已在实验性标志下提供了初步实现。虽然规范仍在持续演进中,但整体方向已经非常清晰:未来,自定义下拉选择框将不再是复杂的工程问题,而只是一次普通的 CSS 样式设计。

Demo 地址:codepen.io/airen/full/…

温馨提示:如果你想更深入了解自定义下拉选择框的实现原理与进阶用法,可以进一步阅读相关内容,深入探索这一特性的更多可能性与实践技巧。

焦点组

在组合型控件(如工具栏、标签列表、单选组或菜单)中实现键盘的箭头导航,一直是一项重复且繁琐的工作。传统做法通常需要编写大量 JavaScript,比如监听 keydown 事件、判断 ArrowRightArrowLeftArrowUpArrowDown,手动维护 tabindex,并在用户通过 Tab 重新进入组件时,记住上一次的焦点位置。几乎每一个 UI 库都有自己的一套实现方案,比如 React 中常见的 roving-tabindex 模式,或 Fluent UI 提供的类似工具(FocusZone),本质上都在解决同一个问题。

而 Open UI 提出的 focusgroup 属性,试图将这一整套逻辑声明式地交还给浏览器。只需要在容器元素上添加一个属性,浏览器就可以自动管理其内部可聚焦子元素之间的箭头键导航,无需任何 JavaScript 参与。这不仅简化了实现,也让行为更加一致和可预测。

<div role="toolbar" focusgroup aria-label="Text Formatting">
    <button type="button" tabindex="-1">Bold</button>
    <button type="button" tabindex="-1">Italic</button>
    <button type="button" tabindex="-1">Underline</button>
</div>

同时,focusgroup 还提供了一些可选配置,用来微调导航行为。例如,可以通过 inlineblock 限制导航方向(仅水平或垂直),使用 wrap 实现循环导航,以及通过 no-memory 控制每次重新聚焦时是否回到初始项,而不是上一次的焦点位置。

<div role="tablist" focusgroup="inline wrap no-memory">
    <button role="tab" tabindex="0" aria-selected="true">Mac</button>
    <button role="tab" tabindex="-1" aria-selected="false">Windows</button>
    <button role="tab" tabindex="-1" aria-selected="false">Linux</button>
</div>

更进一步,这一机制还支持嵌套结构。例如,一个水平的菜单栏内部包含垂直的子菜单,每个层级都可以拥有独立的导航轴向,从而让不同方向的箭头键各司其职,形成更自然的交互体验。

目前,focusgroup 仍处于提案阶段,尚未被浏览器正式实现。不过,其更具体的演进版本正在积极推进中。考虑到它所替代的模式在 Web 开发中极为普遍,这项能力很可能会成为未来原生平台的一部分。

瀑布流布局

像 Pinterest 这种瀑布流布局(高度不一的卡片按列紧密排列、自动填补空隙),一直是 Web 上的经典布局需求。但长期以来,这类布局几乎只能依赖 JavaScript 实现。像 Masonry.js 、Isotope 这样的库之所以流行,正是因为传统的 CSS Grid无法原生支持这种“跨行填充”的布局方式——Grid 项目始终遵循严格的行列轨道,无法自动回填空白。

而 CSS 正在补上这一块能力。最初的 Masonry 提案通过 grid-template-row: masonry (以及对应的列方向变体),让浏览器自动计算并填补布局中的空隙,从而实现真正意义上的瀑布流布局,无需额外的脚本介入。

.masonry {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    grid-template-rows: masonry;
    gap: 16px;
}

不过,规范的发展并没有止步于此。随着讨论的推进,这一能力被重新设计并更名为 Grid Lanes,强调其作为 CSS Grid 体系的一部分,而不是一个全新的 display 类型。新的方向是通过类似 grid-lanes 机制,让布局在既有 Grid 模型中具备“跨轨道填充”的能力,从而实现更加灵活的排布方式。

.masonry {
    display: grid-lanes;
    grid-template-columns: repeat(3, 1fr);
    gap: 16px;
}

这一转变的意义在于,它不只是“让瀑布流变得可用”,而是将这一能力系统性地整合到 CSS 网格布局体系。Web 开发者不再需要依赖 JavaScript在布局完成后再进行二次计算(这也是导致布局抖动和性能问题的根源),而是可以直接交由浏览器在布局阶段完成。

目前,Mozilla Firefox 已经在实验性标志下提供相关实现,Google Chrome 也在积极推进中。虽然规范仍在演进,但方向已经非常明确:未来,瀑布流布局将成为 CSS 的原生能力,而不是一个需要额外库来“修补”的特例。

Demo 地址:codepen.io/airen/full/…

温馨提示:如果你想更深入了解瀑布流布局相关的实现原理与实践方式,可以进一步阅读《CSS Grid 之瀑布流布局:masonrymasonry-auto-flow》和《CSS 布局:从 Masonry Layout 到 Grid Lanes——CSS Grid 的新能力》获取更详细的讲解。同时,如果你希望系统性掌握现代 Web 布局(包括 Grid、Flexbox 以及最新布局能力)的设计思路与实战技巧,也可以参考我的小册《现代 Web 布局

field-sizing

field-sizing: content 为表单元素带来了一项看似简单却非常实用的能力:让 <input><select><textarea> 可以根据内容自动调整尺寸。也就是说,输入多少内容,字段就增长多少空间,无需额外干预。

在过去,如果你想实现一个“自动增高”的 textarea ,通常需要借助 JavaScript 监听输入事件、读取 scrollHeight 、再手动更新高度。这不仅代码繁琐,还容易引发性能生布局问题。而现在,这一切都可以通过一行 CSS 完成:

textarea {
  field-sizing: content;
}

Demo 地址:codepen.io/airen/full/…

这种变化的意义在于,它将一个常见但细碎的交互需求,从“脚本逻辑”转变为“样式声明”。你不再需要关心何时更新尺寸,也不需要处理边界情况,浏览器会根据内容自动完成计算与渲染。

目前,Google Chrome 已在 2024 年支持该特性。虽然它只是一个相对小的功能点,但在实际开发中却非常“解放生产力”——一旦用上,就很难再回到过去的实现方式。

温馨提示:如果你想更深入了解 field-sizing 的工作原理、兼容性以及更多实际应用场景,可以移步阅读《CSS 排版:利用 field-sizing 属性实现自动尺寸调整》,进一步探索这一特性的细节与最佳实践。

sibling-index()sibling-count()

在过去,如果你想根据元素在 DOM 中的位置来做样式变化,比如实现“第几个元素有不同的样式”、“根据数量做渐变效果”或“按顺序延迟动画”,通常需要借助 JavaScript 来计算索引或统计数量,再动态添加类名或设置变量。

sibling-index()sibling-count() 的出现,让这些需求可以直接在 CSS 中完成。它们允许你获取元素在同级中的位置索引以及同级元素总数,从而实现基于结构的动态样式控制,无需任何 JavaScript 参与。

例如,sibling-index() 可以用来为列表创建基于顺序的动画延迟,而 sibling-count() 则可以帮助你根据元素数量计算布局或分布效果。两者结合,可以构建出更加灵活的 UI 表现,比如均匀分布、渐变强度、然度计算等。

.wave-bar {
    animation: wave 1.2s ease-in-out infinite;
    animation-delay: calc(sibling-index() * 0.1s);
}

Demo 地址:codepen.io/airen/full/…

这种能力的关键在于,它把“结构信息”直接暴露给 CSS,让样式可以感知上下文,而不再只是静态描述。这意味着很多过去需要“读取 DOM → 计算 → 写回样式”的流程,现在可以一步到位地用 CSS 声明完成。

虽然这些特性目前仍处于提案或实验阶段,但它们所代表的方向非常明确:CSS 正在逐步具备处理“逻辑关系”的能力,让越来越多原本属于 JavaScript 的工作,回归到样式层来解决。

温馨提示:如果你想更深入了解 sibling-index()sibling-count() 的原理与实际应用,可以继续阅读下面这几节课程,系统掌握它们在复杂 UI 场景中的使用方式与技巧。

原生 CSS 条件判断

过去,CSS 自定义属性虽然可以通过“回退值”实现一些类似条件判断的效果,但本质上只是权宜之计,并不是真正的逻辑控制。你只能在有限的范围内“模拟条件”,而无法直接达到清晰的分支判断。

.switch {
    --i: 0; /* 开关关闭,复选框未选中 */
    
    label::after {
        translate: calc(var(--i) * 100%) 0; /* 白色滑块位于左侧*/
        transition: translate 300ms;
    }
    
    &:has(:checked) {
        --i: 1; /* 开关打开,复选框选中 */
        
        label::after {
            transition: translate 300ms linear;
        }
    }
}

Demo 地址:codepen.io/airen/full/…

而 CSS 的 if() 函数则为 CSS 带来了真正的条件表达能力。你可以在属性值中直接写出类似“如果…否则…”的逻辑,根据不同状态返回不同的样式值。

例如,假设一个设计系统支持三种主题:ocean(海洋)、forest(森林)以及默认主题。过去你可能需要为不同主题维护多套样式文件,或者通过 JavaScript 动态切换类名、管理一堆变量映射。现在,借助 if(),你可以在 CSS 中直接通过条件表达式按需定义主题样式,让逻辑更加直观且集中。例如,根据当前主题变量选择不同的颜色,而无需额外的结构或脚本参与。这种方式不仅减少了样式分散的问题,也让主题系统更易于维护和扩展。

.controls:has(#shamrock:checked) ~ .card {
    --theme: shamrock;
}
.controls:has(#saffron:checked) ~ .card{
    --theme: saffron;
}
.controls:has(#amethyst:checked) ~ .card{
    --theme: amethyst;
}

.card {
    /* 基础颜色 */
    --saffron: hsl(43 74% 64%);
    --shamrock: hsl(146 50% 40%);
    --amethyst: hsl(282 47% 56%);

    /* 文本颜色 */
    --saffron-text: light-dark(
        hsl(from var(--saffron) h s 3%),
        hsl(from var(--saffron) h s 92%)
    );

    --shamrock-text: light-dark(
        hsl(from var(--shamrock) h s 3%),
        hsl(from var(--shamrock) h s 92%)
    );

    --amethyst-text: light-dark(
        hsl(from var(--amethyst) h s 3%),
        hsl(from var(--amethyst) h s 92%)
    );

    /* 背景颜色 */
    --saffron-bg: light-dark(
        var(--saffron), 
        hsl(from var(--saffron) h s 20%)
    );

    --shamrock-bg: light-dark(
        var(--shamrock),
        hsl(from var(--shamrock) h s 18%)
    );

    --amethyst-bg: light-dark(
        var(--amethyst),
        hsl(from var(--amethyst) h s 22%)
    );

    /* 边框颜色 */
    --saffron-border: light-dark(
        hsl(from var(--saffron) calc(h - 90) s 40%),
        hsl(from var(--saffron) calc(h - 90) s 65%)
    );

    --shamrock-border: light-dark(
        hsl(from var(--shamrock) calc(h - 90) s 35%),
        hsl(from var(--shamrock) calc(h - 90) s 60%)
    );

    --amethyst-border: light-dark(
      hsl(from var(--amethyst) calc(h - 90) s 38%),
      hsl(from var(--amethyst) calc(h - 90) s 62%)
    );

    /* 主题切换*/
    /* 背景 */
    --background: if(
        style(--theme: saffron): var(--saffron-bg); 
        style(--theme: shamrock):  var(--shamrock-bg); 
        style(--theme: amethyst): var(--amethyst-bg);
    );

    /* 文本 */
    --color: if(
        style(--theme: saffron): var(--saffron-text); 
        style(--theme: shamrock): var(--shamrock-text); 
        style(--theme: amethyst): var(--amethyst-text) ;
    );

    /* 边框 */
    --border-color: if(
        style(--theme: saffron): var(--saffron-border); 
        style(--theme: shamrock): var(--shamrock-border); 
        style(--theme: amethyst): var(--amethyst-border) ;
    );

    background: var(--background);
    color: var(--color);
    border-color: var(--border-color);
}

Demo 地址:codepen.io/airen/full/…

实际上,上面的主题示例,即使不使用 if(),也可以通过 @container style() 查询来实现。你可以根据容器中定义的样式变量(例如主题标识),在不同条件下应用对应的样式规则。这种方式同样能够实现按需切换主题效果,并且完全基于 CSS 的声明式能力完成。

/* Card 的容器 */
main {
    container-type: inline-size;
    --theme: saffron;
}

.card {
    /* 基础颜色 */
    --saffron: hsl(43 74% 64%);
    --shamrock: hsl(146 50% 40%);
    --amethyst: hsl(282 47% 56%);

    /* --theme: saffron */
    @container style(--theme: saffron) {
        --color: light-dark(
            hsl(from var(--saffron) h s 3%),
            hsl(from var(--saffron) h s 92%)
        );
        --background: light-dark(
            var(--saffron),
            hsl(from var(--saffron) h s 20%)
        );
        --border-color: light-dark(
            hsl(from var(--saffron) calc(h - 90) s 40%),
            hsl(from var(--saffron) calc(h - 90) s 65%)
        );
    }

    /* --theme: shamrok */
    @container style(--theme: shamrock) {
        --color: light-dark(
            hsl(from var(--shamrock) h s 3%),
            hsl(from var(--shamrock) h s 92%)
        );
        --background: light-dark(
            var(--shamrock),
            hsl(from var(--shamrock) h s 18%)
        );
        --border-color: light-dark(
            hsl(from var(--shamrock) calc(h - 90) s 35%),
            hsl(from var(--shamrock) calc(h - 90) s 60%)
        );
    }

    /* --theme: amethyst */
    @container style(--theme: amethyst) {
        --color: light-dark(
            hsl(from var(--amethyst) h s 3%),
            hsl(from var(--amethyst) h s 92%)
        );
        --background: light-dark(
            var(--amethyst),
            hsl(from var(--amethyst) h s 22%)
        );
        --border-color: light-dark(
            hsl(from var(--amethyst) calc(h - 90) s 38%),
            hsl(from var(--amethyst) calc(h - 90) s 62%)
        );
    }

    background: var(--background);
    color: var(--color);
    border-color: var(--border-color);
}

/* 切换容器 main 的 --theme 的值 */
main:has(#shamrock:checked) {
    --theme: shamrock;
}
main:has(#saffron:checked) {
    --theme: saffron;
}
main:has(#amethyst:checked) {
    --theme: amethyst;
}

Demo 地址:codepen.io/airen/full/…

这也说明一个趋势,CSS 正在逐步提供多种路径来表达“条件逻辑”。无论是 if() 这样的内联条件函数,还是 @container style() 这样的上下文查询机制,本质上都在让组件具备更强的自适应能力,从而减少对 JavaScript 的依赖。

也就是说,样式不再只是对结果的描述,而是可以根据上下文、状态和条件主动做出响应。这种能力让很多过去需要“状态判断 + DOM 操作”的场景,可以直接在 CSS 层完成。

目前,这一特性仍处于实验阶段,但它所代表的方向非常清晰——CSS 正在从“声明样式”逐步演进为“具备一定逻辑能力的系统”,进一步压缩 JavaScript 在 UI 层的职责范围。

温馨提示:如果你想更深入了解 CSS if() 函数的语法细节、使用场景以及与其他特性的组合方式,可以移步阅读《Web UI:CSS if 语句与条件逻辑》和《CSS 技巧:如何在 CSS 中正确使用 if()》,进一步探索它在实际项目中的应用潜力。

CSS @function

一直以来,如果你希望在 CSS 中封装一段可复用的“计算逻辑”,通常只能依赖 CSS 处理器(比如 Sass 的函数),或者借助 JavaScript 在运行时动态计算。原生 CSS 虽然提供了 calc()min()max()clamp() 等函数,但它们更偏向表达式计算,而不是可复用的逻辑单元。

现如今,CSS 的 @function 为 CSS 引入了更接近“原生函数”的能力。你可以在 CSS 中定义一个带参数的函数,并在不同的地方调用它,从而根据输入动态返回计算结果。这意味着,一些重复计算逻辑(比如间距比例、尺寸换算、颜色调整等),可以被统一封装,而不是在各处重复书写。

/* 返回带透明度的颜色 */
@function --opacity(--color, --opacity) {
    result: rgb(from var(--color) r g b / var(--opacity));
}
ul {
    --brand: #f50;
  
    li {
        background: --opacity(var(--brand), calc(sibling-index() * .1));
    }
}

Demo 地址:codepen.io/airen/full/…

这种能力的关键在于,它让 CSS 开始具备逻辑抽象与复用能力。你不再只是声明结果,而是可以定义“如何得到这个结果”。对于设计系统来说,这尤为重要——许多设计规则本质上都是一套可复用的计算逻辑,而 @function 正好提供了表达这些规则的原生方式。

更进一步,当 @function 与 CSS 自定义属性、if() 条件函数以及容器查询等能力结合使用时,CSS 的表达力会进一步增强。你可以在样式层完成从“输入 → 计算 → 输出”的完整流程,让组件具备更强的自适应能力,而无需依赖 JavaScript。

这也意味着,CSS 正在从“样式描述语言”逐步演进为一种具备轻量逻辑能力的系统,让更多原本属于 JavaScript 或预处理器的工作,回归到原生平台中完成。

温馨提示:如果你想更深入了解 @function 的语法细节、使用方式以及在实际项目中的应用场景,可以移步阅读《Web UI:CSS 中的自定义函数 @function》和《CSS 技巧:5 个实用 CSS 自定义函数》,进一步探索这一特性的更多可能性与实践价值。

小结

如果把这些能力放在一起重新审视,你会发现一个非常清晰的趋势——CSS 正在从一个“被动描述样式”的语言,逐步演变为一个同时具备布局、交互,甚至一定逻辑能力的系统。它不再只是用来“画界面”,而是在参与界面的行为定义。

从锚点定位、Popover,到滚动驱动动画、视图过渡,再到 if()@function 这样的条件与计算能力,这些特性并不是零散出现的,而是在系统性地填补过去长期依赖 JavaScript 的空白。浏览器开始在更底层、更高效的层级处理 UI,而不是把所有控制权都交给运行在主线程上的脚本。

这带来的变化,也不仅仅是“少写一点 JavaScript”。更深层的影响在于:你可以用更少的代码实现更复杂的效果,同时获得更好的性能表现,因为许多计算与渲染发生在浏览器内部;组件的实现也更加稳定和可维护,不再需要手动处理大量边界情况或同步状态。

当然,这并不意味着 CSS 会完全取代 JavaScript。一些复杂交互,例如拖拽(drag & drop)、手势控制或高度动态的业务逻辑,仍然属于 JavaScript 的范畴。但可以确定的是,这条边界正在快速移动,而且比很多人想象得更快。

也正因为如此,现在是一个重新思考前端实现方式的好时机。当你准备写一段 JavaScript 来“控制 UI”的时候,不妨先停一下,问自己一个问题:CSS,现在是不是已经可以做到? 很多时候,答案已经和过去截然不同。

❌