普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月25日掘金 前端

鳌虾 AoCode:重新定义 AI 编程助手的下一代可视化工具

作者 阳火锅
2026年3月25日 11:17

前言

在 AI 代码生成工具层出不穷的今天,程序员面临着一个核心问题:如何更高效、更精准地让 AI 理解我们的需求?传统的 AI 对话模式需要我们反复描述项目背景、手动关联各种文档和技能规范,这种模式不仅效率低下,还容易因为信息不完整导致生成结果与预期相差甚远。

鳌虾(AoCode) 正是为解决这些痛点而生。它通过可视化拖拽的方式,让开发者无需手敲冗长的 Prompt,即可自动生成高质量的 AI 编程指令。更重要的是,它能与项目中的技能文件(skills)无缝结合,让 AI 始终在统一的规范下生成代码,从根本上减少"幻觉"的产生。

GitHubgithub.com/zy1992829/a…


一、工具使用:零门槛上手,三步生成 AI 指令

1.1 组件拖拽,所见即所得

image.png

鳌虾提供了一个直观的可视化页面设计器。左侧是丰富的组件库,右侧是线框图骨架画布。开发者只需从左侧拖拽组件到画布中,即可快速搭建页面结构。

支持的组件包括:

  • 页面布局:单列、双列、左侧定宽、右侧定宽等多种布局容器
  • 基础组件:搜索栏、数据表格、表单区域、可编辑表格、详情区块
  • 自定义模块:支持纯文本自定义模块

每个组件都可以单独配置其属性和关联的业务字段,满足不同的业务需求。

1.2 智能读取项目技能文件

鳌虾支持自动扫描并读取项目中的技能文件。它会按照优先级自动探测以下目录:

.trae/skills  >  .trae/rules  >  .cursor/rules  >  .windsurf/rules  >  .aocode/rules  >  docs/rules

读取逻辑采用三态模式

  • 状态一:未找到任何技能文件 → 输出"您没有任何技能约束"
  • 状态二:找到文件但文件中没有 <rules>[CODE_RULES_START] 标签 → 静默处理,不输出任何内容
  • 状态三:找到文件且文件包含标签内容 → 自动提取并注入到 AI 指令中

这种设计确保了 AI 指令的精简性——只传递必要的信息,避免噪声干扰。

1.3 页面级技能分配

在鳌虾中,每个页面都可以独立绑定不同的技能文件。比如:

  • index.vue(列表页)绑定 page.md
  • edit.vue(编辑页)绑定 edit.md
  • look.vue(详情页)绑定 look.md

这样,不同类型的页面会自动带上各自的规范约束,生成结果更加精准。

1.4 一键生成 Clipboard 指令

image.png

配置完成后,点击**"生成 AI 指令"**按钮,鳌虾会自动生成一份结构化的指令文本,包含:

  • 功能目录和路径信息
  • 页面模块及布局顺序
  • 绑定的技能规范内容
  • API 基础路径

生成后直接复制到剪贴板,粘贴到 AI 对话窗口即可。


二、工具对比:鳌虾 vs 传统 AI 编程

对比维度 传统 AI 编程 鳌虾 AoCode
Prompt 输入 每次都要手敲完整描述 可视化配置,一键生成
技能规范传递 手动复制粘贴或反复提及 自动读取并注入
多页面一致性 每个页面都要重复描述项目背景 页面级技能分配,一劳永逸
信息完整性 容易遗漏关键约束条件 结构化输出,确保信息无遗漏
技能文件管理 依赖开发者自觉遵守 系统层面强制关联
学习成本 需要学习 Prompt 编写技巧 无需任何 Prompt 经验

2.1 传统模式的痛点

传统 AI 编程中,开发者常常面临这样的困境:

  1. 重复劳动:每次对话都要重新描述项目结构、技术栈、规范要求
  2. 信息不对称:AI 无法主动了解项目规范,容易产生"幻觉"
  3. 一致性差:不同对话生成的代码风格不统一,集成困难
  4. 维护成本高:项目规范变更后,需要手动更新所有历史 Prompt

2.2 鳌虾的解决方案

  1. 零 Prompt 编写:通过可视化配置替代手写文本,降低使用门槛
  2. 技能即规范:将项目规范写入技能文件(skills),AI 随时可读
  3. 上下文共享:一次配置,多页面复用,确保输出一致性
  4. 版本可控:技能文件可纳入版本管理,规范变更有迹可循

三、快速上手:下载与安装

3.1 环境要求

  • Node.js:>= 16.0.0
  • npm:>= 8.0.0

3.2 安装步骤

使用 npm 全局安装:

npm install -g aoxia-ui-generator

# 验证安装
aocode --version

安装完成后,在任意项目目录下运行即可启动鳌虾:

aocode

服务启动后会自动打开浏览器访问 http://localhost:3000/,即可开始使用。

3.3 项目初始化

首次使用时,建议在项目根目录下创建 .trae/skills 文件夹,并放置你的技能规范文件:

my-project/
├── .trae/
│   └── skills/
│       ├── page.md      # 列表页规范
│       ├── edit.md      # 编辑页规范
│       └── look.md      # 详情页规范
└── src/
    └── views/
        └── ...

鳌虾会自动扫描并读取这些文件,让你在页面配置时自由绑定。

image.png

image.png


四、未来展望:AI 编程的下一个十年

4.1 从"工具"到"助手"的进化

当前的 AI 编程工具大多停留在"响应指令"的层面。鳌虾的愿景是成为主动协作的助手——它不仅被动响应开发者的配置,还会主动建议最优的页面结构、规范的代码组织方式。

4.2 技能生态的构建

未来,鳌虾计划构建一个开放的技能市场(Skills Market)

  • 开发者可以发布自己编写的技能文件
  • 项目可订阅行业最佳实践技能
  • 支持技能的版本管理和更新通知

4.3 多模态融合

未来的 AI 编程将不局限于文本。鳌虾计划引入:

  • 设计稿导入:直接解析 Figma、Sketch 等设计文件
  • API 文档解析:自动理解接口定义并生成对应页面
  • 代码审查集成:生成后自动检查是否符合规范

4.4 对标 OpenClaw,走向国际

鳌虾的愿景不止于国内市场。它以 OpenClaw(开源龙虾)为对标目标,致力于成为全球开发者喜爱的 AI 编程工具。开源、生态、国际化的道路,将是鳌虾下一阶段的核心方向。


结语

AI 编程的时代已经到来,但"幻觉"问题始终困扰着开发者。鳌虾通过可视化配置 + 技能文件 + 智能注入的创新模式,让 AI 始终在规范的框架内生成代码,从根本上减少了不确定性。

这不是一个简单的 Prompt 生成器,而是一套完整的AI 编程工作流解决方案。它让开发者从繁琐的文本工作中解放出来,专注于真正的业务逻辑。

当别人还在手敲 Prompt 的时候,你已经在用鳌虾生成代码了。


鳌虾 AoCode,下一代 AI 编程助手,让代码生成更精准、更高效、更可控。


【uniapp】小程序支持分包存放微信自定义组件 wxcomponents

2026年3月25日 11:10

问题

在小程序端,不少开发者都有使用小程序原生自定义组件的需求,uniapp 也是支持使用小程序自定义组件的,只不过要放在根目录的 wxcomponents、mycomponents 等下面,详见官方文档

但是,在 5.03 之前,uniapp 仅支持在根目录存放自定义组件,很多开发者面临着包体积超出的问题

image.png

5.03 起,uniapp 开始支持在分包的根目录添加 wxcomponents、mycomponents 等

源码

此部分为源码分析,感兴趣的掘友可以看下

uniapp 仓库 中,支持的每一个小程序都有一个专门的包

image.png

复制操作是通过内部的 vite 插件实现的,具体位置在 packages/uni-cli-shared/src/vite/plugins/copy.ts,感兴趣的掘友可以看下。

框架已经封装好了复制的插件,对于各端来说,只需要做好配置就行。我们是要支持分包能复制 wxcomponents、mycomponents,这看起来就很简单了,只需要处理好分包的路径就行,代码比较简单,直接贴出来了

/**
 * 在将小程序组件相关资源(例如固定目录名下的静态文件)复制到构建产物时,
 * 生成本次复制所需的目录路径与 glob 模式列表。
 *
 * 返回值始终包含:
 * - 项目根(相对复制根目录)下名为 `dir` 的目录。
 * - 每个 `uni_modules` 插件包下对应子目录的 glob:与本函数内局部变量 `uniModulesDir` 相同
 *   (前缀为 `uni_modules`、通配段、`dir`、以及递归匹配尾部)。
 *
 * 当已设置 `UNI_INPUT_DIR`、`UNI_PLATFORM`,且输入目录下存在 `pages.json` 时,会从
 * `subPackages` 或 `subpackages` 读取分包根路径;对每个 `root` 再追加两项:
 * `normalizePath(path.join(root, dir))` 与 `normalizePath(path.join(root, uniModulesDir))`。
 *
 * 若缺少环境变量或不存在 `pages.json`,则只返回上述项目根级别的两项。
 *
 * @param dir - 资源目录名称(例如 `wxcomponents`)
 * @returns 非空数组,元素为规范化后的路径或 glob 字符串,供复制或监听工具使用。
 */
export function createCopyComponentDirs(dir: string) {
  const dirs = [dir]
  const uniModulesDir = 'uni_modules/*/' + dir + '/**/*'
  dirs.push(uniModulesDir)
  const inputDir = process.env.UNI_INPUT_DIR
  const platform = process.env.UNI_PLATFORM
  if (!inputDir || !platform) {
    return dirs
  }
  const pagesJsonFile = path.resolve(normalizePath(inputDir), 'pages.json')
  if (!fs.existsSync(pagesJsonFile)) {
    return dirs
  }
  const { appJson } = parseMiniProgramPagesJson(
    fs.readFileSync(pagesJsonFile, 'utf8'),
    platform,
    { subpackages: true }
  )
  const roots: string[] = Object.values(
    appJson.subPackages || appJson.subpackages || {}
  )
    .map(({ root }) => root)
    .filter(Boolean)
  roots.forEach((root) => {
    dirs.push(
      normalizePath(path.join(root, dir)),
      normalizePath(path.join(root, uniModulesDir))
    )
  })
  return dirs
}

注意事项

wxcomponents、mycomponents 等目录下方文件的处理是 全部拷贝到产物中,没有 treeShaking,因为需要开发者梳理组件的使用和存放。

交流群

我建了一个微信群,大家可以在群里和我沟通交流 uniapp 开发遇到的问题、uniapp 的源码等问题。

mmqrcode1774407130592.png

vben5 ImageUpload 模块多图预览功能

作者 新空2022
2026年3月25日 11:01

upload 中自定义回调预览回调逻辑

<template>
  <div>
    <Upload
      v-bind="$attrs"
      v-model:file-list="fileList"
      :accept="getStringAccept"
      :before-upload="beforeUpload"
      :custom-request="customRequest"
      :disabled="disabled"
      :list-type="listType"
      :max-count="maxNumber"
      :multiple="multiple"
      :progress="{ showInfo: true }"

      @preview="handlePreview"
      @remove="handleRemove"
    >

    <!--修改为多图预览-->
    <div style="display: none" >
      <ImagePreviewGroup :preview="{ visible,current:current, onVisibleChange: vis => (visible = vis) }">
        <Image v-for="item in fileList"
          :src="item.url"
        />
      </ImagePreviewGroup>
    </div>
  </div>
</template>

handlePreview 回调

计算current 图片索引和开启预览

/** 处理图片预览 */
async function handlePreview(file: UploadFile) {

  if (!file.url && !file.preview) {
    file.preview = await getBase64<string>(file.originFileObj!);
  }
  current.value= fileList.value.findIndex((item) => item.url === file.url);

  previewImage.value = file.url || file.preview || '';
  previewOpen.value = true;
  visible.value = true;
  previewTitle.value =
    file.name ||
    previewImage.value.slice(
      Math.max(0, previewImage.value.lastIndexOf("/") + 1)
    );
}

【节点】[SplitTextureTransform节点]原理解析与实际应用

作者 SmalBox
2026年3月25日 10:51

【Unity Shader Graph 使用与特效实现】专栏-直达

在 Unity URP Shader Graph 中,Split Texture Transform 节点是一个功能强大且灵活的工具,它允许开发者对纹理资源进行精细的控制和操作。这个节点的核心价值在于它能够将纹理的平铺、偏移和原始纹理数据分离开来,为复杂的着色器效果提供了更多的可能性。通过使用这个节点,开发者可以创建出更加动态和响应式的材质效果,而无需修改原始的纹理资源。

在现代游戏开发和实时渲染中,纹理的处理和变换是创建视觉丰富场景的关键环节。Split Texture Transform 节点正是为了满足这一需求而设计的,它提供了一种高效的方式来处理纹理的变换参数,使得开发者能够在不同的上下文中以不同的方式展示相同的纹理资源。这种灵活性对于实现复杂的视觉效果,如动态环境映射、扭曲效果或自定义 UV 动画等,具有重要的意义。

描述

Split Texture Transform 节点的主要功能是分解纹理的变换参数,包括平铺(Tiling)、偏移(Offset)和原始纹理数据。通过这个节点,开发者可以单独访问和操作这些参数,从而实现更加精细和复杂的纹理效果。例如,在创建镜像效果时,可能需要在不改变原始纹理的情况下对纹理进行扭曲或平移,这时就可以使用 Split Texture Transform 节点来单独控制这些变换参数。

该节点输出的纹理平铺设置为(0,0),缩放设置为(1,1)。这种设置会激活着色器属性中的 NoScaleOffset 标志,这意味着开发者可以通过材质检查器直接修改平铺偏移(Tiling Offset)值,而无需在着色器代码中进行复杂的调整。这种设计大大简化了材质参数的调整过程,使得非技术背景的艺术家也能够轻松地调整材质的外观。

在 Unity 的术语中,平铺(Tiling)和缩放(Scaling)经常被互换使用,因为它们都指的是纹理瓦片的大小。平铺参数控制了纹理在 UV 空间中的重复次数,而偏移参数则控制了纹理在 UV 空间中的位置。通过单独控制这些参数,开发者可以创建出各种复杂的纹理效果,如无缝贴图、动态滚动纹理或基于物体表面的自定义纹理映射。

端口详解

Split Texture Transform 节点包含多个输入和输出端口,每个端口都有其特定的功能和用途。了解这些端口的作用对于正确使用该节点至关重要。

输入端口

  • In:这是节点的唯一输入端口,类型为 Texture2D。它接收来自其他 Texture 2D 节点的输入,作为要处理的纹理资源。这个端口是节点操作的起点,所有后续的分解操作都基于这个输入的纹理。

输出端口

  • Tiling:这个输出端口类型为 Vector 2,它输出每通道应用的平铺量。这些值可以通过 Material Inspector 进行设置和调整。平铺值控制了纹理在 U 和 V 方向上的重复次数,例如,平铺值(2,2)会使纹理在两个方向上各重复两次。
  • Offset:这个输出端口类型为 Vector 2,它输出每通道应用的偏移量。偏移值控制了纹理在 U 和 V 方向上的位置偏移,例如,偏移值(0.5,0.5)会使纹理在 UV 空间中移动半个纹理大小的距离。
  • Texture Only:这个输出端口类型为 Vector 2,它输出无平铺和偏移数据的原始 Texture2D 输入。这个端口特别有用当您需要访问原始纹理数据而不受任何变换影响时。

使用场景与优势

Split Texture Transform 节点在多种场景下都能发挥重要作用,以下是一些典型的使用案例:

  • 动态纹理变换:在需要根据游戏逻辑或用户输入动态改变纹理平铺或偏移的场景中,使用 Split Texture Transform 节点可以轻松实现这种效果。例如,在创建流动的水面或滚动的地面纹理时,可以通过修改平铺和偏移参数来实现动态效果。
  • 纹理效果组合:当需要将多个纹理效果组合在一起时,Split Texture Transform 节点可以提供必要的控制粒度。例如,您可能希望在一个通道中使用原始纹理,而在另一个通道中使用经过平铺和偏移变换的纹理,通过这个节点可以轻松实现这种分离。
  • 性能优化:通过分离纹理变换参数,可以在不修改原始纹理资源的情况下实现各种效果,这有助于减少内存使用和提高渲染性能。特别是当多个材质实例需要共享同一纹理但需要不同的变换参数时,使用这个节点可以避免创建多个纹理副本。
  • 艺术家友好:由于平铺和偏移参数可以通过材质检查器直接调整,这使得非程序员的团队成员(如美术师)也能够轻松地调整材质外观,而无需理解复杂的着色器代码。

实际应用示例

为了更好地理解 Split Texture Transform 节点的使用方法,以下是一个简单的应用示例:

假设您正在创建一个具有动态水面效果的材质。水面的纹理需要随着时间滚动以模拟流动效果,同时还需要根据水深或其他因素调整纹理的平铺密度。

在这种情况下,您可以使用 Split Texture Transform 节点来分离纹理的平铺和偏移参数。然后,您可以将偏移端口连接到一个基于时间的节点(如 Time 节点),以实现纹理的自动滚动效果。同时,您可以将平铺端口连接到一个基于水深的参数,以实现不同区域的不同平铺密度。

具体实现步骤:

  • 首先,将您的纹理资源连接到 Split Texture Transform 节点的 In 端口。
  • 然后,将 Tiling 输出端口连接到一个自定义参数,该参数可以根据水深或其他条件进行调整。
  • 将 Offset 输出端口连接到一个基于时间的函数,例如将 Time 节点的输出与一个速度参数相乘,然后将结果添加到 UV 坐标中。
  • 最后,将处理后的 UV 坐标连接到您的纹理采样节点。

通过这种方式,您可以创建一个动态的、可调整的水面效果,而无需修改原始纹理资源或编写复杂的着色器代码。

注意事项与最佳实践

在使用 Split Texture Transform 节点时,有一些重要的注意事项和最佳实践需要遵循:

  • 性能考虑:虽然 Split Texture Transform 节点本身不会带来显著的性能开销,但过度复杂的纹理变换操作可能会影响渲染性能。特别是在移动设备上,应谨慎使用高频率的纹理变换操作。
  • UV 空间理解:要有效使用这个节点,需要对 UV 空间有清晰的理解。UV 坐标决定了纹理如何映射到 3D 模型的表面,不正确的 UV 操作可能导致纹理拉伸或扭曲。
  • 材质实例化:当在项目中使用多个材质实例时,确保正确设置平铺和偏移参数的默认值,以便每个实例都可以独立调整这些参数而不相互干扰。
  • 与其他节点组合:Split Texture Transform 节点通常与其他 Shader Graph 节点组合使用,如 UV 节点、数学运算节点和时间节点等。了解这些节点的功能及其如何与 Split Texture Transform 节点交互,对于创建复杂的着色器效果至关重要。

高级应用技巧

除了基本用法外,Split Texture Transform 节点还可以用于一些更高级的应用场景:

  • 多纹理混合:当需要将多个纹理以不同的平铺和偏移设置混合在一起时,可以使用多个 Split Texture Transform 节点分别处理每个纹理,然后使用混合节点将它们组合起来。
  • 程序化纹理生成:结合其他程序化节点,Split Texture Transform 节点可以用于创建动态生成的纹理效果,如基于物体位置或朝向的纹理变换。
  • 特效系统:在粒子系统或其他特效中,使用 Split Texture Transform 节点可以实现基于生命周期的纹理变换,例如随着粒子年龄增长而逐渐改变纹理的平铺或偏移。
  • 环境映射:在创建反射或环境映射效果时,使用 Split Texture Transform 节点可以精确控制环境贴图的映射方式,实现更加真实的反射效果。

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

geojson-to-kml (KML 格式转换工具)

作者 giszhc
2026年3月25日 10:36

geojson-to-kml (KML 格式转换工具)

一个 简单、轻量、健壮 的 JavaScript / TypeScript 库,用于将 GeoJSON 数据转换为 KML (Keyhole Markup Language) 格式。

本库不仅支持基础的几何转换,还完整支持了 Mapbox SimpleStyle 规范,可以将 GeoJSON 中的样式属性(如 marker-colorstroke 等)转换为 KML 的样式定义。


✨ 特性

  • 🚀 零依赖:基于轻量级逻辑实现,无沉重依赖。
  • 🛡️ 类型安全:原生 TypeScript 支持,完美适配 geojson 类型定义。
  • 🎨 样式支持:支持 Mapbox SimpleStyle,自动生成 KML Style 标签。
  • 📊 数据保留:GeoJSON 的 properties 会自动转换为 KML 的 ExtendedData
  • 🧩 全类型支持:涵盖所有 Geometry、Feature、FeatureCollection。
  • 🌲 Tree Shaking:现代化 ESM 导出,支持按需引入。

📦 安装

# npm
npm install @giszhc/geojson-to-kml

# pnpm
pnpm add @giszhc/geojson-to-kml

🚀 快速上手

基础用法

TypeScript

import tokml from '@giszhc/geojson-to-kml';

const geojson = {
  type: 'Point',
  coordinates: [120.123, 30.456]
};

const kml = tokml(geojson);
console.log(kml);
// 输出包含 <Placemark><Point>... 的 XML 字符串

带样式的转换 (SimpleStyle)

TypeScript

const feature = {
  type: 'Feature',
  properties: {
    name: '我的位置',
    'marker-color': '#ff0000',
    'stroke': '#00ff00',
    'stroke-width': 3
  },
  geometry: {
    type: 'Point',
    coordinates: [120, 30]
  }
};

const kml = tokml(feature, {
  simplestyle: true, // 开启样式转换
  name: 'name'       // 指定使用哪个属性作为节点名称
});

🛠️ API 参数说明

tokml(geojson: GeoJSON, options?: TokmlOptions): string

参数名 类型 描述 默认值
documentName string KML <Document> 节点的名称 undefined
documentDescription string KML <Document> 节点的描述 undefined
name string properties 中提取哪个字段作为 <name> 'name'
description string properties 中提取哪个字段作为 <description> 'description'
simplestyle boolean 是否将 Mapbox 样式属性转换为 KML 样式 false
timestamp string properties 中提取哪个字段作为 <TimeStamp> 'timestamp'

🎨 支持的样式属性 (SimpleStyle)

当开启 simplestyle: true 时,以下 GeoJSON 属性将被识别并转换:

属性名 描述 示例
marker-color 标记点的颜色 (Hex) #ff0000
marker-size 标记点大小 (small, medium, large) large
marker-symbol 标记点图标符号 bus, star
stroke 线条或多边形边界颜色 #0000ff
stroke-opacity 线条透明度 (0.0 - 1.0) 0.5
stroke-width 线条宽度 (像素) 2
fill 多边形填充颜色 #00ff00
fill-opacity 填充透明度 (0.0 - 1.0) 0.3

⚠️ 注意事项

  1. 坐标系:KML 官方规范要求使用 WGS84 (EPSG:4326) 经纬度。转换前请确保您的 GeoJSON 坐标正确。
  2. 数据类型:所有的 properties 都会被放入 <ExtendedData> 中,这有助于在 Google Earth 等软件中查看完整的业务数据。
  3. 命名空间:生成的 KML 默认包含 xmlns="http://www.opengis.net/kml/2.2"

完结,撒花✿✿ヽ(°▽°)ノ✿

跟随系统暗黑模式动态更改已有的颜色

作者 江湖文人
2026年3月25日 10:21

永远只跟随系统、不要被手动切换影响

:deep(.arco-menu) {
      background-color: var(--color-bg);
}

可以通过“改写 CSS 变量本身”的方式实现:default-layout.vue 里继续写 background-color: var(--color-bg); 不动,只在全局样式里让 --color-bg 在 light/dark 时取不同值即可。

body {
  --color-bg: #f6f8fa;
}

body[arco-theme='light'] {
  --color-bg: #f6f8fa;
}

body[arco-theme='dark'] {
  --color-bg: var(--color-bg-2);
}

@media (prefers-color-scheme: dark) {
  body:not([arco-theme]) {
    --color-bg: var(--color-bg-2);
  }
}

如果页面上没有显式设置 arco-theme,就会走 prefers-color-scheme 的媒体查询:系统暗黑 → --color-bg 自动变为暗色(这里用的是 Arco 自带的 --color-bg-2,会随暗黑主题变化)。

我用 Zustand 三年了,直到遇见 easy-model...

作者 张一凡93
2026年3月25日 10:19

不是说Zustand不好,而是有些场景它真的hold不住。

故事是这样的

我们公司有个中后台项目,状态管理一直用Zustand。讲真,Zustand确实香——API简洁、性能好、类型推断也还行。

直到有一天,产品经理提了一个需求:

"做一个操作日志中心,用户每做一个操作就记录下来,支持撤销重做。而且要能在列表页看到嵌套对象的变化轨迹。"

我自信满满地开始写,然后就被打脸了。

Zustand的痛点

1. 状态一多就成了"函数大杂烩"

// store.ts
const useStore = create((set, get) => ({
  user: null,
  orders: [],
  filters: {},
  pagination: { page: 1, size: 10 },
  loading: false,

  setUser: (user) => set({ user }),
  setOrders: (orders) => set({ orders }),
  setFilters: (filters) => set({ filters }),
  setPagination: (pagination) => set({ pagination }),
  setLoading: (loading) => set({ loading }),

  fetchOrders: async () => {
    const { filters, pagination } = get();
    set({ loading: true });
    const res = await api.getOrders(filters, pagination);
    set({ orders: res.data, loading: false });
  },

  // ... 200行后
}));

一个文件写了500行,到后面自己都不想看了。

2. 撤销重做?自己实现吧

Zustand没有内置history支持。网上倒是有zundo这种中间件,但:

  • 配置繁琐
  • 类型推断经常出问题
  • 和业务代码集成麻烦

3. 监听嵌套对象?不好意思,做不到

const orders = useStore((s) => s.orders);
// 改变了 orders[0].items[0].price
// 组件不会更新!因为引用没变

你得用subscribe或者自己写selector,关键是一旦嵌套深了,selector写得怀疑人生。

然后我发现了easy-model

// 用类来组织,一个领域一个类
class OrderModel {
  orders: Order[] = [];
  filters: FilterParams = {};
  pagination = { page: 1, size: 10 };
  loading = false;

  async fetchOrders() {
    this.loading = true;
    const res = await api.getOrders(this.filters, this.pagination);
    this.orders = res.data;
    this.loading = false;
  }

  setFilter(key: string, value: any) {
    this.filters[key] = value;
  }
}

// 内置history支持
const order = useModel(OrderModel, []);
const history = useModelHistory(order);

// 撤销重做,一行搞定
history.back();
history.forward();
history.reset();

这才是面向对象!

深度监听,真香

class ComplexModel {
  user = {
    profile: {
      address: { city: "北京" },
    },
  };
  orders = [];
}

// 监听嵌套对象变化
watch(user, (keys, prev, next) => {
  // keys: ['profile', 'address', 'city']
  console.log("变化了", keys, prev, next);
});

user.profile.address.city = "上海";
// 自动触发监听,拿到完整的变化路径

还有IoC?

// 定义依赖
const apiSchema = object({
  baseUrl: string(),
}).describe("API配置");

// 注入
class OrderApi {
  @inject(apiSchema)
  config?: { baseUrl: string };

  async getOrders() {
    return fetch(`${this.config?.baseUrl}/orders`);
  }
}

// 配置
config(
  <Container>
    <CInjection
      schema={apiSchema}
      ctor={OrderApi}
      params={["https://api.example.com"]}
    />
  </Container>,
);

这不妥妥的企业级架构?

性能对比

官方benchmark(10万个元素,5轮批量更新):

方案 耗时
Zustand 0.6ms
easy-model 3.1ms
MobX 16.9ms
Redux 51.5ms

easy-model比Zustand慢3倍,但换来了:

  • 类模型组织方式
  • 内置IoC能力
  • 深度监听
  • History支持

这波不亏!

怎么选?

  • 小项目、简单状态 → Zustand依旧真香
  • 中大型、需要领域模型、需要IoC、需要history → easy-model真香

Github: github.com/ZYF93/easy-…

觉得有帮助的点个⭐️支持下 🙏

arcgis-to-geojson双向转换工具库

作者 giszhc
2026年3月25日 10:06

@giszhc/arcgis-to-geojson

一个轻量级的 ArcGIS JSON 与 GeoJSON 双向转换工具库,支持点、线、面等多种几何类型,开箱即用,并针对现代前端开发进行了 Tree Shaking 优化。

支持以下特性:

  • 双向转换:支持 ArcGIS JSON 转 GeoJSON 和 GeoJSON 转 ArcGIS JSON
  • 几何类型:完整支持 Point、MultiPoint、LineString、MultiLineString、Polygon、MultiPolygon
  • 环处理:自动处理多边形的内外环方向,符合 RFC 7946 和 ArcGIS 标准
  • 坐标验证:智能判断外环与内环的包含关系,正确处理孔洞
  • 要素支持:支持 Feature 和 FeatureCollection 结构,保留属性信息
  • ID 映射:自动识别 OBJECTID、FID 等字段作为要素 ID
  • 坐标系警告:检测非 WGS84 坐标系时发出警告
  • TypeScript:完善的类型定义支持

在这里插入图片描述

方法列表

ArcGIS JSON 转 GeoJSON

  • arcgisToGeoJSON - 将 ArcGIS JSON 转换为 GeoJSON 格式

GeoJSON 转 ArcGIS JSON

  • geojsonToArcGIS - 将 GeoJSON 转换为 ArcGIS JSON 格式

安装

你可以通过 npm 安装该库:

pnpm install @giszhc/arcgis-to-geojson

使用方法

arcgisToGeoJSON(arcgis: any, idAttribute?: string): any

将 ArcGIS JSON 转换为 GeoJSON 格式。支持点、线、面、要素集合等多种类型。

import { arcgisToGeoJSON } from '@giszhc/arcgis-to-geojson';

// 转换点
const point = {
    x: 116.4,
    y: 39.9
};
const geojson = arcgisToGeoJSON(point);
console.log(geojson);
// { type: 'Point', coordinates: [116.4, 39.9] }

// 转换多边形(带孔洞)
const polygon = {
    rings: [
        [[116.0, 39.0], [117.0, 39.0], [117.0, 40.0], [116.0, 40.0], [116.0, 39.0]],
        [[116.5, 39.5], [116.8, 39.5], [116.8, 39.8], [116.5, 39.8], [116.5, 39.5]]
    ]
};
const geojson2 = arcgisToGeoJSON(polygon);
console.log(geojson2);
// { type: 'Polygon', coordinates: [...] }

// 转换要素集合
const featureCollection = {
    features: [
        {
            geometry: { x: 116.4, y: 39.9 },
            attributes: { name: '北京', OBJECTID: 1 }
        }
    ]
};
const fc = arcgisToGeoJSON(featureCollection);
console.log(fc);
// { type: 'FeatureCollection', features: [...] }

// 指定自定义 ID 字段
const feature = {
    geometry: { x: 116.4, y: 39.9 },
    attributes: { name: '北京', customId: 'BJ001' }
};
const geojson3 = arcgisToGeoJSON(feature, 'customId');
// id: 'BJ001'

geojsonToArcGIS(geojson: any, idAttribute?: string): any

将 GeoJSON 转换为 ArcGIS JSON 格式。自动处理环的方向和坐标系。

import { geojsonToArcGIS } from '@giszhc/arcgis-to-geojson';

// 转换点
const point = {
    type: 'Point',
    coordinates: [116.4, 39.9]
};
const arcgis = geojsonToArcGIS(point);
console.log(arcgis);
// { x: 116.4, y: 39.9, spatialReference: { wkid: 4326 } }

// 转换多边形
const polygon = {
    type: 'Polygon',
    coordinates: [
        [
            [116.0, 39.0], [116.0, 40.0], [117.0, 40.0], [117.0, 39.0], [116.0, 39.0],
            [116.5, 39.5], [116.5, 39.8], [116.8, 39.8], [116.8, 39.5], [116.5, 39.5]
        ]
    ]
};
const arcgis2 = geojsonToArcGIS(polygon);
console.log(arcgis2);
// { rings: [...], spatialReference: { wkid: 4326 } }

// 转换 FeatureCollection
const featureCollection = {
    type: 'FeatureCollection',
    features: [
        {
            type: 'Feature',
            geometry: { type: 'Point', coordinates: [116.4, 39.9] },
            properties: { name: '北京' },
            id: 1
        }
    ]
};
const fc = geojsonToArcGIS(featureCollection);
console.log(fc);
// [{ geometry: {...}, attributes: {...} }]

// 指定自定义 ID 字段名
const feature = {
    type: 'Feature',
    geometry: { type: 'Point', coordinates: [116.4, 39.9] },
    properties: { name: '北京' },
    id: 100
};
const arcgis3 = geojsonToArcGIS(feature, 'FID');
// attributes: { name: '北京', FID: 100 }

注意事项

  1. 坐标系:转换后的 GeoJSON 默认为 WGS84 坐标系(EPSG:4326)。如果 ArcGIS JSON 使用其他坐标系,会发出警告
  2. 环方向
    • ArcGIS:外环顺时针,内环(孔洞)逆时针
    • GeoJSON:外环逆时针,内环(孔洞)顺时针(RFC 7946 标准)
    • 库会自动处理方向转换
  3. ID 映射:默认使用 OBJECTIDFID 作为要素 ID,可通过 idAttribute 参数自定义
  4. 空几何:支持处理空几何体,转换为 null
  5. 三维坐标:支持 Z 坐标(高程)的转换
  6. 多部件几何:自动识别并转换为 MultiPoint、MultiLineString、MultiPolygon

完结,撒花✿✿ヽ(°▽°)ノ✿

Flutter ngspice 插件

作者 GoCoding
2026年3月25日 09:59

Flutter FFI 绑定 ngspice C 接口,实现一个 plugin 库:

  • 可通过 pubspec.yaml 引入
  • 上层提供 Dart API 接口
  • 底层通过 FFI 调用 C 代码

ngspice 是一个开源的电路仿真器,主要用于电子电路的分析和设计。

准备

创建项目

创建 Flutter FFI plugin 项目,

flutter create -t plugin_ffi --org cn.nebul --platforms ios,android,windows,linux,macos mozsim_ngspice

cd mozsim_ngspice/

flutter pub outdated
flutter pub upgrade --major-versions

flutter pub add ffi

获取共享库

获取 ngspice shared libs,可以找预编译库,不然就从源码编译。

1)预编译库

Windows 下载预编译库进 windows/ngspice-44.2_dll_64 目录。

$ tree windows -aF -L 2 --dirsfirst
windows
|-- ngspice-44.2_dll_64/
|                   `-- Spice64_dll/
|-- .gitignore
`-- CMakeLists.txt

2)从源码编译

# ngspice 44.2 Commit [e011d1] master
git clone -b master --depth 1 https://git.code.sf.net/p/ngspice/ngspice ngspice

# Windows
# - Building ngspice with MS Visual Studio 2022
#   - flex-bison/: https://sourceforge.net/projects/winflexbison/
#   - ngspice/visualc/sharedspice.sln: open as admin, run with ReleaseOpenMP x64

# Linux
./compile_linux_shared.sh

测试

Plugin 使用样例,

cd mozsim_ngspice/example/
flutter run

mozsim_ngspice_example.png

Dart 接口测试,

$ cd mozsim_ngspice/
$ dart test/mozsim_ngspice_test.dart
mozsim_ngspice_test ...
|Char| stdout Hello from ngspice
|Char| stdout Note: No compatibility mode selected!
|Char| stdout Circuit: * voltage divider netlist
|Stat| Prepare Deck
|Stat| Parse
|Char| stdout Doing analysis at TEMP = 27.000000 and TNOM = 27.000000
|Stat| Device Setup
|Char| stdout Using SPARSE 1.3 as Direct Linear Solver
|Stat| op
|Char| stdout No. of Data Rows : 1
|Char| stdout out = 6.666667e-01
|Char| stdout ngspice-44.2 done
|Char| stdout Note: 'quit' asks for resetting or detaching ngspice.dll.
|Exit| status 0 immediate false quit true
NgSpiceException: NgSpiceRequestType.command return error, ret=1
mozsim_ngspice_test done

C++ 接口测试,

# Windows
> cd mozsim_ngspice/
> test\build\Debug\mozsim_ngspice_test.exe
mozsim_ngspice_test ...
|Char| stderr Warning: can't find the initialization file spinit.
|Char| stdout ******
|Char| stdout ** ngspice-44.2 shared library
|Char| stdout ** Creation Date: Jan 11 2025   14:16:40
|Char| stdout ******
|Char| stdout Hello from ngspice
|Char| stdout Note: No compatibility mode selected!
|Char| stdout Note: No compatibility mode selected!
|Char| stdout Circuit: * voltage divider netlist
|Stat| Prepare Deck
|Stat| Parse
|Char| stdout Doing analysis at TEMP = 27.000000 and TNOM = 27.000000
|Stat| Device Setup
|Char| stdout Using SPARSE 1.3 as Direct Linear Solver
|Stat| op
|Char| stdout No. of Data Rows : 1
|Char| stdout out = 6.666667e-01

接口

Dart 接口重生成,

# https://pub.dev/packages/ffigen
dart run ffigen --config ffigen.yaml

Dart 接口使用样例,

import 'package:mozsim_ngspice/ngspice.dart';

void main(List<String> args) async {
  print('mozsim_ngspice_test ...');
  await _main();
  // await _main();
  print('mozsim_ngspice_test done');
}

Future<void> _main() async {
  NgSpice? ngspice;
  try {
    ngspice = await NgSpice.initByLib(
      NgSpice.libPath(NgSpice.libName, 'test/build/Debug/'),
    );

    ngspice.resp.listen(_handleResponses);

    await ngspice.sendCommand('echo Hello from ngspice');

    await ngspice.sendCircs([
      '* voltage divider netlist',
      'V1 in 0 1',
      'R1 in out 1k',
      'R2 out 0 2k',
      '.end',
    ]);

    await ngspice.sendCommands(['op', 'print out']);
    await ngspice.sendCommand('quit');
  } on NgSpiceException catch (e) {
    print(e);
  } catch (e) {
    print('Caught error: $e');
  } finally {
    await ngspice?.close();
  }
}

void _handleResponses(NgSpiceResponse resp) {
  if (resp.type == NgSpiceResponseType.print) {
    final res = resp.data as String;
    print('|Char| $res');
  } else if (resp.type == NgSpiceResponseType.stat) {
    final res = resp.data as String;
    print('|Stat| $res');
  } else if (resp.type == NgSpiceResponseType.exit) {
    final (status, immediate, quit) = resp.data as (int, bool, bool);
    print('|Exit| status $status immediate $immediate quit $quit');
  }
}

参考

AbortController 实战:竞态取消、超时兜底与请求生命周期管理

2026年3月25日 09:54

AbortController 实战:竞态取消、超时兜底与请求生命周期管理

项目越大,请求越多,Bug 越诡异。 你一定见过这些场景:

  • 搜索结果偶尔“闪一下又变回旧数据”
  • 提交按钮点快了,后台多出几条脏数据
  • 页面都切走了,接口还在跑,甚至回来还触发 setState warning

这些问题看起来毫无规律,但本质上只是一件事:

请求没有被正确“结束”。

大部分团队会优化接口、加缓存、做防抖,却很少有人认真思考:

一个请求,什么时候应该继续?什么时候必须终止?

AbortController 应该在项目初期就被当作基础设施来搭建,而不是等线上出了问题才到处打补丁。这篇文章是我踩完所有坑之后的经验沉淀,把竞态取消、超时控制、组件卸载清理这几个场景串起来,给出一套在 React 和 Vue 中都能落地的防御性编排方案。

竞态取消:搜索场景的三种方案对比

竞态问题是异步请求里最常见也最容易被忽视的坑。回到搜索框的例子,用户快速输入,多个请求并发,我们只关心最后一次的结果。怎么确保展示的一定是最新请求的数据?

方案一:标记法(能用,但粗糙)

最朴素的思路——给每次请求打一个版本号,回调里检查是不是最新版本。

let currentRequestId = 0

async function search(keyword: string) {
  const requestId = ++currentRequestId
  const res = await fetch(`/api/search?q=${keyword}`)
  const data = await res.json()

  if (requestId === currentRequestId) {
    setResults(data) // 版本号匹配才更新 UI
  }
}

实现简单、零依赖,但有一个明显的问题:请求并没有被真正取消。"杭"的请求还是跑完了全部流程,占了带宽和连接池,只是回调里没处理结果而已。我们项目初期就是用的这个方案,当时觉得"能用就行"。后来在性能分析里发现,搜索页面在快速输入时,Network 面板里密密麻麻全是 pending 请求,Chrome 的同域 6 连接上限直接被打满,导致其他关键请求(比如用户鉴权、埋点上报)被阻塞。

方案二:纯 AbortController(真正取消请求)

标记法的核心缺陷是请求仍然在跑,只是忽略了结果。AbortController 可以从网络层真正中断请求,释放连接。

let currentController: AbortController | null = null

async function search(keyword: string) {
  currentController?.abort() // 取消上一个请求
  const controller = new AbortController()
  currentController = controller

  try {
    const res = await fetch(`/api/search?q=${keyword}`, {
      signal: controller.signal
    })
    const data = await res.json()
    setResults(data)
  } catch (err) {
    if ((err as DOMException).name !== 'AbortError') throw err
    // AbortError 说明是我们主动取消的,静默忽略
  }
}

相比标记法,abort() 调用后浏览器会立即中断 TCP 连接(或阻止请求发出),被取消的请求在 Network 面板中会显示为 (canceled) 状态,不再占用同域的 6 个并发连接。缺点是在高频输入场景下,每次按键都会发出一个请求然后立即取消上一个,虽然连接被释放了,但请求的绝对数量仍然很多,服务端压力并没有减轻。所以对于搜索框这类场景,还需要配合防抖进一步优化。

方案三:防抖 + AbortController(生产环境的选择)

单纯的 AbortController 取消还不够,还需要配合防抖来减少请求频率。这里有个容易搞错的地方:防抖和取消的职责不一样,不能互相替代。防抖解决的是"减少发出的请求数量",取消解决的是"已经发出的请求不再需要了"。两个要一起用。

function useDebouncedSearch(delay = 300) {
  const controllerRef = useRef<AbortController | null>(null)
  const timerRef = useRef<number>()
  const [results, setResults] = useState([])

  const search = useCallback((keyword: string) => {
    clearTimeout(timerRef.current) // 清掉防抖定时器

    timerRef.current = window.setTimeout(async () => {
      controllerRef.current?.abort() // 取消上一个还在飞的请求
      const controller = new AbortController()
      controllerRef.current = controller

      try {
        const res = await fetch(`/api/search?q=${keyword}`, {
          signal: controller.signal
        })
        setResults(await res.json())
      } catch (err) {
        if ((err as DOMException).name !== 'AbortError') throw err
      }
    }, delay)
  }, [delay])

  useEffect(() => () => {
    clearTimeout(timerRef.current)
    controllerRef.current?.abort()
  }, [])

  return { results, search }
}

防抖定时器和 AbortController 各管各的:防抖控制"什么时候发请求",AbortController 控制"已经发出的请求要不要保留"。组件卸载时两个都要清理,缺一不可。我们项目最终落地的就是这个方案。改完之后,搜索页面的无效请求从平均每次搜索 5-6 个降到了 0-1 个,Network 面板终于清爽了。

三种方案放在一起对比:

方案 请求真正取消 实现复杂度 适用场景
标记法 否,幽灵请求仍占连接 小项目、低频请求
AbortController 是,网络层中断 大多数场景
防抖 + AbortController 是,且减少请求频次 搜索、筛选等高频输入场景

React 中的异步副作用编排

useEffect 中的正确姿势

function UserProfile({ userId }: { userId: string }) {
  const [profile, setProfile] = useState(null)

  useEffect(() => {
    const controller = new AbortController()

    async function loadProfile() {
      try {
        const res = await fetch(`/api/users/${userId}`, {
          signal: controller.signal
        })
        setProfile(await res.json())
      } catch (err) {
        if ((err as DOMException).name === 'AbortError') return
        console.error('加载用户信息失败:', err)
      }
    }
    loadProfile()

    return () => controller.abort()
  }, [userId])

  return <div>{profile?.name}</div>
}

这段代码看起来简单,有两个细节容易踩坑。

AbortController 的创建必须在 useEffect 内部。因为每次 effect 执行需要一个独立的 controller 实例,放外面会被多个 effect 共享,取消逻辑就乱了。

async 函数不能直接作为 useEffect 的回调——effect 要求返回 cleanup 函数或 undefined,不能返回 Promise。所以需要在内部定义一个 async 函数再调用。这个限制看起来别扭,但它强制你把"发请求"和"清理"分开思考,反而减少了遗漏 cleanup 的概率。

封装通用的 useAbortableFetch

当团队有十几个页面都需要这种模式时,重复写 AbortController 的样板代码就不合适了。我们封装了一个自定义 Hook:

function useAbortableFetch<T>(url: string | null, options?: { timeout?: number }) {
  const [state, setState] = useState<{
    data: T | null; loading: boolean; error: Error | null
  }>({ data: null, loading: false, error: null })

  useEffect(() => {
    if (!url) return

    const controller = new AbortController()
    const { timeout = 10000 } = options ?? {}
    const signal = AbortSignal.any
      ? AbortSignal.any([controller.signal, AbortSignal.timeout(timeout)])
      : controller.signal

    setState(prev => ({ ...prev, loading: true, error: null }))

    fetch(url, { signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`)
        return res.json()
      })
      .then(data => setState({ data, loading: false, error: null }))
      .catch(err => {
        if (err.name === 'AbortError' || err.name === 'TimeoutError') return
        setState({ data: null, loading: false, error: err })
      })

    return () => controller.abort()
  }, [url])

  return state
}

url 为 null 时不发请求,方便做条件请求;url 变了自动重新请求,旧请求自动取消;超时和手动取消合并在一个信号里处理。用起来非常干净:

function SearchPage() {
  const [keyword, setKeyword] = useState('')
  const debouncedKeyword = useDebounce(keyword, 300)

  const { data, loading, error } = useAbortableFetch<SearchResult[]>(
    debouncedKeyword ? `/api/search?q=${debouncedKeyword}` : null
  )

  return <input value={keyword} onChange={e => setKeyword(e.target.value)} />
}

手动触发场景的处理

表单提交、批量操作这类请求的特殊之处在于:取消的触发时机不是依赖变化,而是"重复操作"或"组件卸载"。

function useAbortableAction<T>() {
  const controllerRef = useRef<AbortController | null>(null)

  const execute = useCallback(async (
    asyncFn: (signal: AbortSignal) => Promise<T>
  ): Promise<T | undefined> => {
    controllerRef.current?.abort() // 新操作来了,取消上一个(防连点)
    const controller = new AbortController()
    controllerRef.current = controller

    try {
      return await asyncFn(controller.signal)
    } catch (err) {
      if ((err as DOMException).name === 'AbortError') return undefined
      throw err
    }
  }, [])

  useEffect(() => () => { controllerRef.current?.abort() }, [])
  return execute
}

使用时,把 signal 透传给请求函数即可:

const executeAction = useAbortableAction()

const handleSubmit = async (formData: OrderData) => {
  const result = await executeAction(signal =>
    fetch('/api/orders', {
      method: 'POST',
      body: JSON.stringify(formData),
      signal
    }).then(r => r.json())
  )
  if (result) navigate(`/orders/${result.id}`)
}

这里有个需要权衡的地方:POST 请求真的应该取消吗?连点两次提交按钮,取消第一个 POST 请求——网络层面是中断了,但后端可能已经处理了一半。所以对于写操作,AbortController 更多是解决"组件卸载后不再处理回调"的问题,而不是真的指望后端能回滚。防重复提交还是要靠按钮 loading 状态锁定 + 后端幂等校验。

Vue 中的 Composable 实现

在 Vue 中,watchEffect 提供的 onCleanup 回调天然适合管理请求生命周期,写起来比 React 的 useEffect 更直观。下面是与前文 useAbortableFetch 对等的 Vue Composable 实现:

import { ref, watchEffect, toValue, type Ref, type MaybeRefOrGetter } from 'vue'

function useFetchData<T>(url: MaybeRefOrGetter<string | null>, options?: { timeout?: number }) {
  const data = ref<T | null>(null) as Ref<T | null>
  const loading = ref(false)
  const error = ref<Error | null>(null)

  watchEffect((onCleanup) => {
    const resolvedUrl = toValue(url)
    if (!resolvedUrl) {
      data.value = null
      loading.value = false
      return
    }

    const controller = new AbortController()
    const { timeout = 10000 } = options ?? {}

    // 注册清理函数:依赖变化或组件卸载时自动调用
    onCleanup(() => controller.abort())

    loading.value = true
    error.value = null

    const timeoutId = setTimeout(() => controller.abort(), timeout)

    fetch(resolvedUrl, { signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`)
        return res.json()
      })
      .then(json => {
        data.value = json
        loading.value = false
      })
      .catch(err => {
        if (err.name === 'AbortError') return // 主动取消,静默忽略
        error.value = err
        loading.value = false
      })
      .finally(() => clearTimeout(timeoutId))
  })

  return { data, loading, error }
}

使用方式同样干净,响应式的 URL 变化会自动触发重新请求并取消旧请求:

<script setup lang="ts">
import { ref, computed } from 'vue'

const keyword = ref('')
const debouncedKeyword = useDebouncedRef(keyword, 300) // 假设已有防抖 ref 工具
const apiUrl = computed(() =>
  debouncedKeyword.value ? `/api/search?q=${debouncedKeyword.value}` : null
)

const { data: results, loading, error } = useFetchData<SearchResult[]>(apiUrl)
</script>

<template>
  <input v-model="keyword" />
  <div v-if="loading">搜索中...</div>
  <ul v-else-if="results">
    <li v-for="item in results" :key="item.id">{{ item.title }}</li>
  </ul>
</template>

Vue 的 onCleanup 和 React 的 useEffect return 做的是同一件事,但 Vue 的写法有个优势:onCleanup 在 effect 函数体内调用,和创建 AbortController 的代码紧挨着,不容易遗漏。React 里 cleanup 写在函数末尾的 return 里,和请求代码隔得比较远,review 时容易看漏。

边界场景与防御性思维

并发请求的批量取消

页面初始化时可能要同时发五六个请求,用户切走了要一次性全取消。核心技巧是共享一个 signal,配合 Promise.allSettled 处理结果:

useEffect(() => {
  const controller = new AbortController()

  Promise.allSettled([
    fetch('/api/user/info', { signal: controller.signal }).then(r => r.json()),
    fetch('/api/user/permissions', { signal: controller.signal }).then(r => r.json()),
    fetch('/api/dashboard/stats', { signal: controller.signal }).then(r => r.json()),
  ]).then(results => {
    const [userResult, permResult, statsResult] = results

    if (userResult.status === 'fulfilled') {
      setUserInfo(userResult.value)
    }
    if (permResult.status === 'fulfilled') {
      setPermissions(permResult.value.permissions)
    }
    if (statsResult.status === 'fulfilled') {
      setDashboardStats(statsResult.value)
    }
  })

  return () => controller.abort()
}, [])

为什么用 Promise.allSettled 而不是 Promise.all?因为 Promise.all 在任何一个请求 reject 时就会整体 reject,而 abort 会导致所有请求同时 reject,你拿不到任何有用信息。allSettled 等所有请求都有结果后才 resolve,让你能精细地处理每个请求——哪些成功了用数据,哪些被取消了忽略,哪些真正失败了需要报错。

SSR 和 Node.js 环境

如果你的项目有 SSR(Next.js、Nuxt),请求取消在服务端同样重要。Node.js 18+ 的 fetch 原生支持 AbortController,但服务端的超时策略需要比客户端更激进——SSR 请求阻塞的是页面渲染,用户在白屏面前的耐心远低于面对 loading 动画:

async function getServerSideProps() {
  const controller = new AbortController()
  const timer = setTimeout(() => controller.abort(), 3000) // SSR 超时建议 3-5 秒

  try {
    const res = await fetch('http://internal-api/data', {
      signal: controller.signal
    })
    clearTimeout(timer)
    return { props: { data: await res.json() } }
  } catch {
    clearTimeout(timer)
    return { props: { data: null } } // 超时降级,先渲染页面骨架
  }
}

在 Node.js 环境还要注意一点:没有浏览器的 6 连接上限,但有内存泄漏风险。如果请求没有超时控制,在高并发时挂起的请求会持续占用内存,最终可能 OOM。

不适用的场景

AbortController 不是银弹,有几种场景不适合或者需要特殊处理。

WebSocket 连接有自己的生命周期管理(close()),不需要也不能用 AbortController。写操作的取消要谨慎——POST/PUT/DELETE 请求,前端取消了但后端可能已经处理了,关键写操作的幂等性要在后端保证。**流式响应(SSE / ReadableStream)**虽然技术上可以用 abort() 中断,但要区分场景:AI 对话场景下用户点"停止生成",abort() 是合理的;大文件分片上传中途取消,断点续传的状态恢复逻辑需要额外处理,单靠 abort() 解决不了。

从取消到编排:一个通用模型

回顾全文的内容,请求取消只是一个切入点,背后的通用模型是异步操作的生命周期管理。任何异步操作——请求、定时器、动画、Web Worker 通信——都应该具备三个能力:启动、取消、超时。缺了任何一个,在项目规模变大后都会出问题。这个模型可以这样理解:

异步操作生命周期:

  启动 ──→ 运行中 ──→ 成功 / 失败
    │          │
    │          ├── 手动取消(用户操作 / 依赖变化 / 组件卸载)
    │          │
    │          └── 超时取消(兜底机制)
    │
    └── 创建即取消(signal 已 aborted,立即中止)

在 React 中,这个生命周期对应的是 useEffect 的"执行-清理"周期;在 Vue 中,是 watchEffect 的"执行-onCleanup"周期。框架不同,模型一致。

如果你正在做的项目还没有统一的请求生命周期管理,我的建议是分三步推进。第一步,在请求层封装一个带超时和取消能力的基础函数(类似前面的 createManagedSignal)。第二步,在框架层封装对应的 Hook / Composable(类似 useAbortableFetchuseFetchData),让业务代码不需要直接接触 AbortController。第三步,在 code review 和 CI 中把"有没有处理取消"作为一个检查项。

我们团队落地这套方案之后,做了一次前后数据对比:

指标 改造前 改造后
搜索场景无效请求数(每次搜索) 5-6 个 0-1 个
超时相关客服工单(每周) 10+ 1-2
页面切换后的 setState warning 频繁出现 完全消除
请求层代码重复率 每个页面各写一套 统一收口到 2 个 Hook/Composable
CI 自定义 lint 规则上线首周拦截的遗漏 14 处

现在我们的标准是:每个 useEffect / watchEffect 里如果有 fetch 调用,必须在 cleanup 里调用 abort(),否则 CI 的自定义 lint 规则会报错。这条规则的 ROI 极高——写规则花了半天,上线一周就拦住了 14 处遗漏,每一处都是潜在的线上 bug。

回头看,请求生命周期管理这件事并不复杂。AbortController 的 API 就那么几个,封装成 Hook / Composable 也不超过 30 行代码。真正难的是在项目早期就意识到它的重要性,把它作为基础设施搭好,而不是等线上出了问题才到处救火。

React 拖拽:无需第三方库的完整方案

2026年3月25日 09:50

拖拽是用户期望"理所当然能用"的交互之一。无论是对任务看板重新排序、通过拖动文件上传,还是让用户在仪表盘中重新排列小组件,抓取并移动的操作都让人感觉自然流畅。然而大多数 React 教程一上来就引入像 react-dnddnd-kit 这样的重量级库——它们功能强大,但对许多常见场景来说增加了过多的包体积和概念负担。

如果只需一次 Hook 调用就能获得流畅、可用于生产的拖拽行为呢?本文将从原生浏览器 API 出发,分析它们为何难用,然后用 ReactUse 中的两个轻量 Hook:useDraggableuseDropZone 来解决同样的问题。

手动实现:自行处理指针事件

让元素可拖拽的最基本方式是手动监听 pointerdownpointermovepointerup 事件。通常的写法如下:

import { useEffect, useRef, useState } from "react";

function ManualDraggable() {
  const ref = useRef<HTMLDivElement>(null);
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [isDragging, setIsDragging] = useState(false);
  const delta = useRef({ x: 0, y: 0 });

  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    const onPointerDown = (e: PointerEvent) => {
      const rect = el.getBoundingClientRect();
      delta.current = { x: e.clientX - rect.left, y: e.clientY - rect.top };
      setIsDragging(true);
    };

    const onPointerMove = (e: PointerEvent) => {
      if (!isDragging) return;
      setPosition({
        x: e.clientX - delta.current.x,
        y: e.clientY - delta.current.y,
      });
    };

    const onPointerUp = () => setIsDragging(false);

    el.addEventListener("pointerdown", onPointerDown);
    window.addEventListener("pointermove", onPointerMove);
    window.addEventListener("pointerup", onPointerUp);

    return () => {
      el.removeEventListener("pointerdown", onPointerDown);
      window.removeEventListener("pointermove", onPointerMove);
      window.removeEventListener("pointerup", onPointerUp);
    };
  }, [isDragging]);

  return (
    <div
      ref={ref}
      style={{
        position: "fixed",
        left: position.x,
        top: position.y,
        cursor: isDragging ? "grabbing" : "grab",
        padding: 16,
        background: "#4f46e5",
        color: "#fff",
        borderRadius: 8,
      }}
    >
      拖动我
    </div>
  );
}

能跑起来——但看看你需要管理多少状态。而这还只是最简单的版本。实际需求会迅速叠加更多复杂性。

为什么手动实现拖拽很难

上面的代码片段有几个不足之处,一旦超出 Demo 级别就会立刻暴露出来:

  1. 容器边界。 如果你想让元素保持在父容器内部,就需要在每次移动时读取容器尺寸并限制位置。这意味着每帧都要在两个元素上调用 getBoundingClientRect

  2. 指针类型。 上面的代码处理了鼠标事件,但触控和手写笔呢?PointerEvent API 统一了它们,但按指针类型过滤(例如禁止手写笔拖动)需要额外的条件判断。

  3. 拖拽手柄。 有时可拖拽的触发区域只是卡片内部的一个标题栏。你需要将"触发"元素和"移动"元素分离,并相应地连接事件。

  4. 事件清理。 忘记移除监听器——或者在 useEffect 中使用了错误的依赖——会导致诸如松开鼠标后元素仍在移动之类的隐蔽 Bug。

  5. 放置区域。 HTML5 拖放 API 引入了 dragenterdragoverdragleavedrop 事件。协调这些事件——尤其是子元素上臭名昭著的 dragenter/dragleave 闪烁问题——非常容易出错。

这些正是 useDraggableuseDropZone 开箱即用要解决的问题。

useDraggable:一个 Hook,完全掌控

useDraggable 接受一个目标元素的 ref 和一个可选的配置对象。它返回当前的 xy 位置、一个表示元素是否正在被拖拽的布尔值,以及一个 setter(用于程序化地移动元素)。

import { useDraggable } from "@reactuses/core";
import { useRef } from "react";

function DraggableCard() {
  const el = useRef<HTMLDivElement>(null);

  const [x, y, isDragging] = useDraggable(el, {
    initialValue: { x: 100, y: 100 },
  });

  return (
    <div
      ref={el}
      style={{
        position: "fixed",
        left: x,
        top: y,
        cursor: isDragging ? "grabbing" : "grab",
        padding: 16,
        background: isDragging ? "#4338ca" : "#4f46e5",
        color: "#fff",
        borderRadius: 8,
        transition: isDragging ? "none" : "box-shadow 0.2s",
        boxShadow: isDragging ? "0 8px 24px rgba(0,0,0,0.2)" : "none",
        userSelect: "none",
        touchAction: "none",
      }}
    >
      随意拖动我
    </div>
  );
}

这就是整个组件。无需手动事件监听器。无需清理逻辑。触控、鼠标和手写笔默认都能工作。

限制在容器内

传入一个 containerElement ref,Hook 会自动夹紧位置,使元素不会离开容器:

import { useDraggable } from "@reactuses/core";
import { useRef } from "react";

function BoundedDrag() {
  const container = useRef<HTMLDivElement>(null);
  const el = useRef<HTMLDivElement>(null);

  const [x, y, isDragging] = useDraggable(el, {
    containerElement: container,
    initialValue: { x: 0, y: 0 },
  });

  return (
    <div
      ref={container}
      style={{
        position: "relative",
        width: 400,
        height: 300,
        border: "2px dashed #cbd5e1",
        borderRadius: 8,
      }}
    >
      <div
        ref={el}
        style={{
          position: "absolute",
          left: x,
          top: y,
          width: 80,
          height: 80,
          background: "#4f46e5",
          borderRadius: 8,
          cursor: isDragging ? "grabbing" : "grab",
          touchAction: "none",
        }}
      />
    </div>
  );
}

无需手动的夹紧计算。Hook 会读取容器的滚动和客户端尺寸,自动限制元素位置。

使用拖拽手柄

通常你只想让元素的特定部分——比如一个标题栏——触发拖拽。传入 handle ref 即可:

import { useDraggable } from "@reactuses/core";
import { useRef } from "react";

function DraggablePanel() {
  const panel = useRef<HTMLDivElement>(null);
  const handle = useRef<HTMLDivElement>(null);

  const [x, y, isDragging] = useDraggable(panel, {
    handle,
    initialValue: { x: 200, y: 150 },
  });

  return (
    <div
      ref={panel}
      style={{
        position: "fixed",
        left: x,
        top: y,
        width: 280,
        background: "#fff",
        borderRadius: 8,
        boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
        overflow: "hidden",
        touchAction: "none",
      }}
    >
      <div
        ref={handle}
        style={{
          padding: "8px 12px",
          background: "#4f46e5",
          color: "#fff",
          cursor: isDragging ? "grabbing" : "grab",
          userSelect: "none",
        }}
      >
        从这里拖动
      </div>
      <div style={{ padding: 12 }}>
        <p>此内容区域不会触发拖拽。</p>
      </div>
    </div>
  );
}

面板的主体仍然是可交互的——你可以选择文本、点击按钮或滚动——而只有标题栏是拖拽触发器。

useDropZone:轻松实现文件拖放

useDropZone 解决拖放的另一半:接收放置。它处理全部四个拖拽事件(dragenterdragoverdragleavedrop),阻止浏览器默认打开文件的行为,并通过内部计数器解决了 dragleave 闪烁问题。

import { useDropZone } from "@reactuses/core";
import { useRef, useState } from "react";

function FileUploader() {
  const dropRef = useRef<HTMLDivElement>(null);
  const [files, setFiles] = useState<File[]>([]);

  const isOver = useDropZone(dropRef, (droppedFiles) => {
    if (droppedFiles) {
      setFiles((prev) => [...prev, ...droppedFiles]);
    }
  });

  return (
    <div
      ref={dropRef}
      style={{
        padding: 40,
        border: `2px dashed ${isOver ? "#4f46e5" : "#cbd5e1"}`,
        borderRadius: 8,
        background: isOver ? "#eef2ff" : "#f8fafc",
        textAlign: "center",
        transition: "all 0.15s",
      }}
    >
      {isOver ? (
        <p>松开以上传</p>
      ) : (
        <p>将文件拖到这里上传</p>
      )}
      {files.length > 0 && (
        <ul style={{ textAlign: "left", marginTop: 16 }}>
          {files.map((f, i) => (
            <li key={i}>
              {f.name} ({(f.size / 1024).toFixed(1)} KB)
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

isOver 布尔值让你在文件进入时立即重新设置区域样式,给用户清晰的视觉反馈。无需 e.preventDefault() 样板代码,不用和闪烁的 dragleave 事件斗争。

构建看板风格的卡片拖动

让我们在一个更贴近实际的例子中结合两个 Hook——一个可拖拽的卡片,松开时弹回原位,以及一个接受它的放置区域。我们还将使用 useElementBounding 来读取区域位置以做视觉反馈。

import { useDraggable, useDropZone, useElementBounding } from "@reactuses/core";
import { useRef, useState } from "react";

interface Task {
  id: string;
  title: string;
}

function KanbanBoard() {
  const [todo, setTodo] = useState<Task[]>([
    { id: "1", title: "设计原型" },
    { id: "2", title: "编写 API 规范" },
  ]);
  const [done, setDone] = useState<Task[]>([
    { id: "3", title: "搭建 CI 流水线" },
  ]);

  const doneZoneRef = useRef<HTMLDivElement>(null);
  const todoZoneRef = useRef<HTMLDivElement>(null);

  const isOverDone = useDropZone(doneZoneRef, (files) => {
    // 此示例忽略文件拖放
  });

  const isOverTodo = useDropZone(todoZoneRef, (files) => {
    // 此示例忽略文件拖放
  });

  const doneBounds = useElementBounding(doneZoneRef);

  return (
    <div style={{ display: "flex", gap: 24, padding: 24 }}>
      <div>
        <h3>待办</h3>
        <div
          ref={todoZoneRef}
          style={{
            minHeight: 200,
            padding: 12,
            background: isOverTodo ? "#fef3c7" : "#f1f5f9",
            borderRadius: 8,
          }}
        >
          {todo.map((task) => (
            <TaskCard
              key={task.id}
              task={task}
              onDrop={() => {
                setTodo((prev) => prev.filter((t) => t.id !== task.id));
                setDone((prev) => [...prev, task]);
              }}
              targetBounds={doneBounds}
            />
          ))}
        </div>
      </div>
      <div>
        <h3>完成</h3>
        <div
          ref={doneZoneRef}
          style={{
            minHeight: 200,
            padding: 12,
            background: isOverDone ? "#d1fae5" : "#f1f5f9",
            borderRadius: 8,
          }}
        >
          {done.map((task) => (
            <div
              key={task.id}
              style={{
                padding: 12,
                marginBottom: 8,
                background: "#fff",
                borderRadius: 6,
                boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
              }}
            >
              {task.title}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

function TaskCard({
  task,
  onDrop,
  targetBounds,
}: {
  task: Task;
  onDrop: () => void;
  targetBounds: ReturnType<typeof useElementBounding>;
}) {
  const el = useRef<HTMLDivElement>(null);

  const [x, y, isDragging, setPosition] = useDraggable(el, {
    initialValue: { x: 0, y: 0 },
    onEnd: (pos) => {
      // 检查卡片是否在"完成"列上方释放
      if (
        targetBounds &&
        pos.x >= targetBounds.left &&
        pos.x <= targetBounds.right &&
        pos.y >= targetBounds.top &&
        pos.y <= targetBounds.bottom
      ) {
        onDrop();
      }
      // 弹回原始位置
      setPosition({ x: 0, y: 0 });
    },
  });

  return (
    <div
      ref={el}
      style={{
        position: "relative",
        left: x,
        top: y,
        padding: 12,
        marginBottom: 8,
        background: isDragging ? "#e0e7ff" : "#fff",
        borderRadius: 6,
        boxShadow: isDragging
          ? "0 8px 24px rgba(0,0,0,0.15)"
          : "0 1px 3px rgba(0,0,0,0.1)",
        cursor: isDragging ? "grabbing" : "grab",
        zIndex: isDragging ? 50 : 1,
        touchAction: "none",
        userSelect: "none",
        transition: isDragging ? "none" : "all 0.2s ease",
      }}
    >
      {task.title}
    </div>
  );
}

几个值得注意的关键点:

  • useElementBounding 为我们提供了"完成"列的实时 leftrighttopbottom 值,以便在拖拽结束时进行碰撞检测。
  • onEnd 回调在未落在目标上时将卡片弹回 { x: 0, y: 0 }。配合 CSS transition 产生令人满意的橡皮筋效果。
  • 无需外部状态库。React 的 useState 对于这个复杂度完全够用。

配合其他 Hook 增强体验

ReactUse 的 Hook 天然可组合。以下是扩展上述示例的几种方式:

  • useMouse ——全局追踪光标位置,在拖拽过程中显示自定义拖拽光标或跟随指针的浮动提示。
  • useEventListener ——附加一个 keydown 监听器,在用户按下 Escape 时取消拖拽。
  • useElementSize ——动态读取容器的宽高以计算网格对齐位置(例如将 x 舍入到单元格宽度的最近倍数)。

例如,使用 useEventListener 添加 Escape 取消只需几行代码:

import { useDraggable, useEventListener } from "@reactuses/core";
import { useRef } from "react";

function CancelableDrag() {
  const el = useRef<HTMLDivElement>(null);
  const [x, y, isDragging, setPosition] = useDraggable(el);

  useEventListener("keydown", (e: KeyboardEvent) => {
    if (e.key === "Escape" && isDragging) {
      setPosition({ x: 0, y: 0 });
    }
  });

  return (
    <div
      ref={el}
      style={{
        position: "fixed",
        left: x,
        top: y,
        padding: 16,
        background: "#4f46e5",
        color: "#fff",
        borderRadius: 8,
        cursor: isDragging ? "grabbing" : "grab",
        touchAction: "none",
      }}
    >
      拖动我(按 Esc 重置)
    </div>
  );
}

什么时候仍然需要完整的库

useDraggableuseDropZone 用最少的代码覆盖了绝大多数拖放场景。然而,如果你的需求包含复杂的可排序列表(带动画过渡)、具有键盘无障碍访问的多容器排序,或包含上千项的虚拟化列表,像 dnd-kit 这样的专用库仍然是更好的选择。关键在于,你并不需要在每种情况下都引入一个——对许多项目来说,一对 Hook 就足够了。

安装

npm i @reactuses/core

相关 Hook


ReactUse 提供了 100+ 个 React Hook。探索所有 Hook →


本文最初发布于 ReactUse 博客

cursor接上figma mcp ,图形图像模式傻瓜式教学(包教包会版)

作者 工边页字
2026年3月25日 09:42

image.png

前言

包教包会!

其实这个技术出来有段时间了,但是感觉推广度不够,我看在很多程序员里还是在手工写css和样式。 如果你用的是figma的话,今天教给大家一个ai 几乎95%设计稿还原的方法。

为了照顾萌新同学,本文全程 用图形图像 来进行配置教程

开始!!!

第一步:创建figma token

image.png

image.png

image.png

然后点击 Generate,创建成功

image.png

再次强调,key一定要复制好,只有一次机会

第二步:配置cursor的figma mcp

  1. 按 Cmd + Shift + P,输入 MCP,选择「Open MCP setting」。 如下

image.png

image.png

点击新增以后你就会弹出一个 mcp.json文件

image.png 把这段配置复制进去,配置我写在下面,注意token替换下

{
    "mcpServers": {
        "Framelink Figma MCP": {
            "command": "npx",
            "args": [
                "-y",
                "figma-developer-mcp",
                "--figma-api-key=你的figma token",
                "--stdio"
            ]
        }
    }
}

第三步:尝试让cursor帮你做

先去你的figma项目里复制一个url过来,如下

image.png

然后回到cursor,如果你是免费用户(没有购买cursor的情况下),模型记得选 “auto”

image.png

然后直接把url复制进对话会进行对话就好

image.png

最后我们看看还原度

image.png

有条件的不要使用免费模型,auto模型有时候和弱智有的一拼

最后

如果对你有用的话

点赞收藏吃灰去呀~

CDN图片服务与动态参数优化

作者 wuhen_n
2026年3月25日 09:24

前言

在现代Web应用中,图片已经不再是简单的静态资源,而是需要根据设备、网络、浏览器能力动态优化的核心内容。CDN图片服务提供了强大的动态处理能力,结合前端的智能参数拼接,可以实现图片加载的极致优化。

一个典型的电商场景

  • 商品详情页有10张SKU图片
  • 每张图片需要支持不同尺寸(缩略图、详情图、放大镜图)
  • 需要兼容不支持WebP的老旧浏览器
  • 要求在秒级完成切换,不卡顿

本文将深入探讨如何利用 CDN 图片服务,配合前端策略,打造一个高性能、自适应、可扩展的图片系统。

CDN 图片服务是什么?

CDN 图片服务如何工作

CDN 服务:同一个图片地址,可以动态调整,加参数就能变:

https://cdn.example.com/product.jpg?width=400&quality=80&format=webp

上述地址会一个返回 400px宽、质量80、WebP格式的图片。

主流云服务商的参数格式

  • 阿里云OSS:?x-oss-process=image/resize,w_400/quality,q_80/format,webp
  • 七牛云:?imageView2/2/w/400/q/80/format/webp
  • 腾讯云COS:?imageMogr2/thumbnail/400x/quality/80/format/webp

核心处理操作

操作类型 参数 说明 示例
缩放 resize,w_400 按宽度等比缩放 /resize,w_400
裁剪 crop,w_400,h_400 从中心裁剪固定尺寸 /crop,w_400,h_400
格式转换 format,webp 转换为WebP/AVIF /format,webp
质量调整 quality,q_80 设置压缩质量(1-100) /quality,q_80
锐化 sharpen,s_100 图片锐化处理 /sharpen,s_100
水印 watermark,text_xxx 添加文字/图片水印 /watermark,text_SAMPLE

动态参数拼接 - 让每张图都量身定制

检测设备信息

// utils/device.js
export function getDeviceInfo() {
  // 设备像素比(Retina屏需要更高清的图)
  const dpr = window.devicePixelRatio || 1
  
  // 屏幕宽度
  const screenWidth = window.screen.width
  
  // 网络类型
  const connection = navigator.connection
  const networkType = connection?.effectiveType || '4g'
  const isSlowNetwork = ['slow-2g', '2g'].includes(networkType)
  
  // 是否移动设备
  const isMobile = /Android|iPhone|iPad/i.test(navigator.userAgent)
  
  return { dpr, screenWidth, networkType, isSlowNetwork, isMobile }
}

// 使用
const device = getDeviceInfo()
console.log(device)
// { dpr: 3, screenWidth: 390, isSlowNetwork: false, isMobile: true }

计算最佳图片尺寸

// utils/imageCalculator.js
export function calculateImageSize(targetWidth, deviceInfo) {
  const { dpr, isSlowNetwork, isMobile } = deviceInfo
  
  // 基础尺寸 = 目标宽度 × 像素比
  let width = Math.ceil(targetWidth * dpr)
  
  // 慢速网络下降级
  if (isSlowNetwork) {
    width = Math.floor(width * 0.7)
  }
  
  // 计算质量
  let quality = 80
  if (isSlowNetwork) {
    quality = 60
  } else if (isMobile) {
    quality = 75
  }
  
  // 确定格式
  const format = supportsWebP() ? 'webp' : 'jpg'
  
  return { width, quality, format }
}

检测 WebP 支持

// utils/webpDetect.js
let webpSupported = null

export function supportsWebP() {
  if (webpSupported !== null) return webpSupported
  
  // 创建一个1x1的WebP图片测试
  const canvas = document.createElement('canvas')
  canvas.width = 1
  canvas.height = 1
  const dataURL = canvas.toDataURL('image/webp')
  
  webpSupported = dataURL.indexOf('image/webp') === 5
  return webpSupported
}

CDN URL构建器

// utils/cdnUrl.js
export function buildCDNUrl(baseUrl, imageKey, options) {
  const { width, quality, format } = options
  
  // 阿里云OSS格式
  const params = [
    `resize,w_${width}`,
    `quality,q_${quality}`,
    format !== 'jpg' ? `format,${format}` : null
  ].filter(Boolean).join('/')
  
  return `${baseUrl}/${imageKey}?x-oss-process=image/${params}`
}

// 使用示例
const device = getDeviceInfo()
const size = calculateImageSize(400, device)
const url = buildCDNUrl('https://cdn.example.com', 'product.jpg', size)

// 结果:https://cdn.example.com/product.jpg?x-oss-process=image/resize,w_800/quality,q_80/format,webp

WebP兼容检测 - 让浏览器自己选

为什么需要检测?

不是所有浏览器都支持 WebP,比如 iOS Safari 14 之前不支持,因此直接使用 WebP 会显示不出来,我们需要让浏览器自己告诉服务器它支持什么格式。

服务端检测(推荐)

// Node.js 后端中间件
app.use((req, res, next) => {
  const accept = req.headers['accept'] || ''
  const supportsWebP = accept.includes('image/webp')
  const supportsAVIF = accept.includes('image/avif')
  
  // 把结果存起来,方便后面用
  res.locals.supportsWebP = supportsWebP
  res.locals.supportsAVIF = supportsAVIF
  
  next()
})

// 在返回HTML时注入
app.get('/', (req, res) => {
  res.render('index', {
    supportsWebP: res.locals.supportsWebP,
    supportsAVIF: res.locals.supportsAVIF
  })
})

前端检测(备选)

// 如果后端拿不到,前端也能检测
export function checkWebPSupport() {
  return new Promise((resolve) => {
    const img = new Image()
    img.onload = () => resolve(true)
    img.onerror = () => resolve(false)
    // 一个1x1的WebP图片的Base64编码
    img.src = 'data:image/webp;base64,UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA=='
  })
}

动态选择格式

// composables/useImageFormat.js
import { ref } from 'vue'

export function useImageFormat() {
  const format = ref('jpg')
  
  async function detect() {
    // 优先检测AVIF(最新,压缩率最高)
    const avifSupported = await checkAVIFSupport()
    if (avifSupported) {
      format.value = 'avif'
      return
    }
    
    // 其次WebP
    const webpSupported = await checkWebPSupport()
    if (webpSupported) {
      format.value = 'webp'
      return
    }
    
    // 最后JPEG
    format.value = 'jpg'
  }
  
  detect()
  
  return { format }
}

域名分片 - 突破浏览器并发限制

为什么需要域名分片?

浏览器对同一域名的并发请求数有限制(通常为6-8个)。当页面需要同时加载大量图片时,这些请求会排队等待,导致加载缓慢。

问题示例

// 20张图片使用同一个域名
const urls = images.map(img => `https://cdn.example.com/${img}.jpg`)
// 浏览器最多同时下载6张,剩下14张等待

域名分片实现

// utils/cdnSharding.js
export class CDNSharding {
  constructor(baseDomain, shardCount = 4) {
    // 生成多个子域名
    // 0.cdn.example.com, 1.cdn.example.com, ...
    this.domains = []
    for (let i = 0; i < shardCount; i++) {
      this.domains.push(`https://${i}${baseDomain}`)
    }
    this.current = 0
  }
  
  // 轮询分配
  getUrl(imagePath) {
    const domain = this.domains[this.current % this.domains.length]
    this.current++
    return `${domain}${imagePath}`
  }
  
  // 基于图片ID的一致性分配(同一个图片始终用同一个域名,利于缓存)
  getUrlConsistent(imagePath, imageId) {
    const index = imageId % this.domains.length
    return `${this.domains[index]}${imagePath}`
  }
  
  // 基于路径哈希分配
  getUrlByHash(imagePath) {
    let hash = 0
    for (let i = 0; i < imagePath.length; i++) {
      hash = ((hash << 5) - hash) + imagePath.charCodeAt(i)
      hash = hash & hash
    }
    const index = Math.abs(hash) % this.domains.length
    return `${this.domains[index]}${imagePath}`
  }
}

// 使用
const sharding = new CDNSharding('.cdn.example.com', 4)

// 原来:一个域名
const oldUrls = images.map(img => `https://cdn.example.com/${img}`)

// 现在:4个域名
const newUrls = images.map(img => sharding.getUrlByHash(img))

DNS预解析优化

<!-- 在HTML头部添加DNS预解析 -->
<head>
  <link rel="dns-prefetch" href="//0.cdn.example.com">
  <link rel="dns-prefetch" href="//1.cdn.example.com">
  <link rel="dns-prefetch" href="//2.cdn.example.com">
  <link rel="dns-prefetch" href="//3.cdn.example.com">
  
  <!-- 预连接(包含TCP握手) -->
  <link rel="preconnect" href="//0.cdn.example.com">
  <link rel="preconnect" href="//1.cdn.example.com">
</head>

性能对比

图片数量 单域名 3个分片 4个分片
10张 2.8秒 1.5秒 1.2秒
20张 5.2秒 2.8秒 2.1秒
50张 12秒 6秒 4.5秒

图片上传组件 - 前端压缩再上传

为什么要前端压缩?

如果我们直接将原始图片(5MB)上传到服务器和 CDN,会非常慢!

但如果我们将图片在前端压缩后(500KB),再上传到服务器和 CDN,就会非常快了!

使用浏览器压缩库

安装

npm install browser-image-compression

使用

<!-- ImageUploader.vue -->
<template>
  <div class="uploader">
    <div class="dropzone" @drop="handleDrop" @dragover.prevent>
      <input type="file" @change="handleFileSelect" accept="image/*">
      <p>点击或拖拽图片上传</p>
    </div>
    
    <div v-if="compressing" class="progress">
      压缩中... {{ progress }}%
    </div>
    
    <div v-if="preview" class="preview">
      <img :src="preview" alt="preview">
      <button @click="upload">上传</button>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import imageCompression from 'browser-image-compression'

const file = ref(null)
const preview = ref('')
const compressing = ref(false)
const progress = ref(0)

// 压缩配置
const options = {
  maxSizeMB: 1,           // 最大1MB
  maxWidthOrHeight: 1920, // 最大1920px
  useWebWorker: true,     // 使用Web Worker,不卡主线程
  fileType: 'image/webp', // 转成WebP
  initialQuality: 0.8     // 质量80%
}

async function handleFileSelect(event) {
  const rawFile = event.target.files[0]
  if (!rawFile) return
  
  compressing.value = true
  
  try {
    // 压缩图片
    const compressedFile = await imageCompression(rawFile, options)
    file.value = compressedFile
    
    // 预览
    preview.value = URL.createObjectURL(compressedFile)
    
    console.log(`压缩前: ${rawFile.size} bytes`)
    console.log(`压缩后: ${compressedFile.size} bytes`)
    console.log(`节省: ${(1 - compressedFile.size/rawFile.size)*100}%`)
    
  } catch (error) {
    console.error('压缩失败', error)
  } finally {
    compressing.value = false
  }
}

async function upload() {
  if (!file.value) return
  
  const formData = new FormData()
  formData.append('image', file.value)
  
  const response = await fetch('/api/upload', {
    method: 'POST',
    body: formData
  })
  
  const result = await response.json()
  console.log('上传成功:', result.url)
}
</script>

实战:电商SKU图片切换的秒级加载优化

问题分析

电商商品详情页的 SKU 图片切换是一个典型性能挑战:

  • 用户点击不同规格(颜色、尺寸)时,需要切换对应图片
  • 要求切换无延迟,体验流畅
  • 图片需要同时满足缩略图、主图、放大镜等多种尺寸需求

预加载策略

// composables/useSKUImages.js
import { ref } from 'vue'

export function useSKUImages() {
  const images = ref([])
  const currentIndex = ref(0)
  
  // 预加载队列
  const preloadQueue = []
  
  // 加载所有SKU图片
  async function loadSKUs(productId) {
    const response = await fetch(`/api/products/${productId}/skus`)
    const skus = await response.json()
    
    images.value = skus.map(sku => ({
      id: sku.id,
      thumbnail: buildCDNUrl(sku.key, { width: 200, quality: 70 }),
      main: buildCDNUrl(sku.key, { width: 800, quality: 80 }),
      zoom: buildCDNUrl(sku.key, { width: 1600, quality: 90 })
    }))
    
    // 预加载第一张图片
    preloadImages(0, 3)
  }
  
  // 预加载指定范围的图片
  function preloadImages(start, count) {
    for (let i = start; i < start + count && i < images.value.length; i++) {
      const img = images.value[i]
      
      // 用 link 标签预加载
      const link = document.createElement('link')
      link.rel = 'preload'
      link.as = 'image'
      link.href = img.main
      document.head.appendChild(link)
    }
  }
  
  // 切换SKU
  function switchSKU(index) {
    if (index === currentIndex.value) return
    
    currentIndex.value = index
    
    // 预加载后面几张
    if (index + 2 < images.value.length) {
      preloadImages(index + 1, 2)
    }
  }
  
  return {
    images,
    currentIndex,
    currentImage: computed(() => images.value[currentIndex.value]),
    loadSKUs,
    switchSKU
  }
}

完整的SKU图片组件

<template>
  <div class="sku-images">
    <!-- 缩略图列表 -->
    <div class="thumbnails">
      <div
        v-for="(img, idx) in images"
        :key="img.id"
        class="thumbnail"
        :class="{ active: currentIndex === idx }"
        @click="switchSKU(idx)"
      >
        <img :src="img.thumbnail" :alt="'SKU ' + idx">
      </div>
    </div>
    
    <!-- 主图区域 -->
    <div class="main-image">
      <img
        :src="currentImage?.main"
        :srcset="`
          ${currentImage?.thumbnail} 200w,
          ${currentImage?.main} 800w,
          ${currentImage?.zoom} 1600w
        `"
        sizes="(max-width: 768px) 100vw, 50vw"
        @mouseenter="showZoom = true"
        @mouseleave="showZoom = false"
        @mousemove="handleMouseMove"
      >
    </div>
    
    <!-- 放大镜 -->
    <div v-if="showZoom" class="zoom-lens" :style="lensStyle">
      <img :src="currentImage?.zoom" :style="zoomImageStyle">
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'
import { useSKUImages } from './useSKUImages'

const props = defineProps({
  productId: String
})

const { images, currentIndex, currentImage, loadSKUs, switchSKU } = useSKUImages()
const showZoom = ref(false)
const mousePos = ref({ x: 0, y: 0 })

onMounted(() => {
  loadSKUs(props.productId)
})

const lensStyle = computed(() => ({
  left: `${mousePos.value.x}px`,
  top: `${mousePos.value.y}px`
}))

const zoomImageStyle = computed(() => ({
  transform: `translate(${-mousePos.value.x * 2}px, ${-mousePos.value.y * 2}px)`
}))

function handleMouseMove(e) {
  const rect = e.target.getBoundingClientRect()
  mousePos.value = {
    x: e.clientX - rect.left,
    y: e.clientY - rect.top
  }
}
</script>

最佳实践清单

实施步骤

  1. 接入CDN服务

    • 阿里云OSS / 七牛云 / 腾讯云COS
    • 配置图片处理参数
  2. 动态参数优化检测设备DPR、屏幕宽度、网络类型

    • 计算最佳图片尺寸
    • 动态生成CDN URL
  3. 格式兼容处理

    • 检测浏览器支持的格式
    • 优先AVIF → WebP → JPEG
    • 服务端通过Accept头判断
  4. 域名分片

    • 生成3-4个子域名
    • 轮询或哈希分配图片
    • 添加DNS预解析
  5. 上传优化

    • 前端压缩图片
    • 使用Web Worker不卡UI

优化策略矩阵

策略 适用场景 收益 实现成本
动态尺寸参数 所有图片 减少50-70%体积
WebP/AVIF转换 现代浏览器 额外减少30-50%
域名分片 批量图片加载 提升30-50%并发
客户端压缩 用户上传图片 减少90%上传时间
智能预加载 SKU/轮播图 切换无延迟

结语

CDN图片优化的核心是**"按需供给"**——不给任何设备加载它不需要的像素,不给任何网络传输它不需要的字节。通过动态参数、格式转换、智能预加载的组合,让图片资源真正做到"恰如其分"。

记住:用户不会因为图片加载快而赞美你,但一定会因为加载慢而离开你

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

响应式图片的工程化实践:srcset与picture

作者 wuhen_n
2026年3月25日 09:21

前言

在移动优先和多设备并存的今天,一张图片要在不同尺寸、不同分辨率的屏幕上都能完美展示,是一项极具挑战性的任务。一个简单的<img src="photo.jpg">会导致:

  • Retina屏上图片模糊:1x图在2x屏上被拉伸
  • 移动端加载超大图片:下载了PC端的大图,浪费流量
  • 横竖屏切换时构图不当:竖屏显示的图片被强行裁剪

响应式图片技术正是为解决这些问题而生。本文将深入探讨srcsetpicture的核心原理,并通过Vue组件封装和Vite插件实现,建立一套工程化的响应式图片解决方案。

为什么需要响应式图片?

传统方式:一张图片走天下

<img src="photo.jpg" alt="风景">

传统方式的问题

  • iPhone SE (小屏) → 下载 5MB 的大图 → 浪费
  • iPad (中屏) → 下载 5MB 的大图 → 还行
  • MacBook (大屏) → 下载 5MB 的大图 → 刚好
  • Retina 屏幕 → 下载 5MB 的普通图 → 模糊

设备像素比(DPR)

什么是设备像素比

**设备像素比(Device Pixel Ratio)**是物理像素与逻辑像素的比值:

// 获取当前设备的像素比
const dpr = window.devicePixelRatio || 1;
console.log(dpr); // 普通屏: 1, Retina屏: 2, 高端屏: 3或更高

设备像素比的典型值范围

  • 普通屏幕:DPR = 1
  • Retina屏幕:DPR = 2 / 3
  • 4K屏幕:DPR = 3+

为什么需要关注DPR?

当我们在CSS中设置width: 100px时,在 DPR=2 的屏幕上,实际需要 200 个物理像素来渲染。如果只提供 100px 的图片,就会被拉伸模糊。

三个核心问题

问题1:屏幕大小不同

  • 手机小屏:不需要大图
  • 平板中屏:需要中等图
  • 电脑大屏:需要高清图

问题2:像素密度不同

  • 普通屏:1x 图就够了
  • Retina 屏:需要 2x 图
  • 高端屏:需要 3x 图

问题3:屏幕方向不同

  • 横屏:适合宽幅风景
  • 竖屏:适合高耸人像

srcset - 让浏览器自己选

x描述符(根据像素密度)

告诉浏览器:我有 1x、2x、3x 三个版本:

<img 
  src="photo-1x.jpg"
  srcset="
    photo-1x.jpg 1x,
    photo-2x.jpg 2x,
    photo-3x.jpg 3x
  "
  alt="风景"
>

浏览器在解析时,就会自动选择:

  • iPhone 14 Pro (DPR=3) → 加载 photo-3x.jpg
  • iPhone SE (DPR=2) → 加载 photo-2x.jpg
  • 普通电脑 (DPR=1) → 加载 photo-1x.jpg

w描述符(根据屏幕宽度)

<img 
  src="photo-400w.jpg"
  srcset="
    photo-400w.jpg 400w,
    photo-800w.jpg 800w,
    photo-1200w.jpg 1200w
  "
  sizes="
    (max-width: 600px) 100vw,
    (max-width: 1200px) 50vw,
    800px
  "
  alt="风景"
>

sizes是怎么计算的?

sizes属性告诉浏览器在不同视口宽度下,图像的实际显示宽度,如:

sizes="
  (max-width: 600px) 100vw,   /* 小屏幕:图片占满视口宽度 */
  (max-width: 1200px) 50vw,   /* 中屏幕:图片占视口一半 */
  800px                        /* 大屏幕:图片固定800px */
"

其计算逻辑如下:

  1. 浏览器检查 sizes:sizes: "(max-width: 600px) 100vw, ..."
  2. 匹配条件 (max-width: 600px) 满足:图片宽度 = 100vw = 375px
  3. 考虑 DPR (iPhone SE DPR=2):实际需要 = 375px × 2 = 750px 的图片
  4. 从 srcset 中选择最接近的:400w 太小,1200w 太大 → 选择 800w

picture - 让开发者控制

什么时候需要 picture?

srcset 可以解决图片大小问题,但不能解决构图问题。比如:横屏时,我们需要展示完整的风景;竖屏时,我们需要展示裁剪后的人像,此时 picture 就派上用场了!

picture 的元素的结构

<picture>
  <!-- 针对宽屏的横图 -->
  <source 
    media="(min-width: 1200px)" 
    srcset="hero-wide.jpg"
  >
  <!-- 针对平板的方图 -->
  <source 
    media="(min-width: 768px)" 
    srcset="hero-square.jpg"
  >
  <!-- 针对手机的竖图 -->
  <source 
    media="(max-width: 767px)" 
    srcset="hero-tall.jpg"
  >
  <!-- 降级方案 -->
  <img src="hero-fallback.jpg" alt="Hero image">
</picture>

浏览器会按顺序检查 <source> 元素,选择第一个匹配的媒体条件。

不同格式降级

picture还可以根据浏览器支持的格式提供不同的降级方案:

<picture>
  <!-- 优先使用AVIF(压缩率最高) -->
  <source srcset="image.avif" type="image/avif">
  <!-- 其次使用WebP(广泛支持) -->
  <source srcset="image.webp" type="image/webp">
  <!-- 降级到JPEG(兜底) -->
  <img src="image.jpg" alt="Fallback">
</picture>

srcset vs picture 选择策略

场景 推荐方案 原因
不同分辨率(2x/3x屏) srcset + x描述符 简单直接,浏览器自动选择
不同视口宽度 srcset + w描述符 + sizes 精确控制加载尺寸
不同构图/裁剪 picture + media 艺术指导需求
不同格式降级 picture + type 渐进增强,兼容老旧浏览器

Vue 组件封装:<ResponsiveImage>的设计与实现

组件设计

<!-- ResponsiveImage.vue -->
<template>
  <picture v-if="usePicture">
    <!-- 为每种格式生成 source -->
    <source
      v-for="source in pictureSources"
      :key="source.type"
      :type="source.type"
      :srcset="source.srcset"
      :media="source.media"
    >
    <!-- 兜底图 -->
    <img :src="fallbackSrc" :alt="alt" loading="lazy">
  </picture>
  
  <img
    v-else
    :src="src"
    :srcset="srcsetString"
    :sizes="sizes"
    :alt="alt"
    loading="lazy"
  >
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps({
  // 基础配置
  src: String,           // 原图地址
  alt: String,           // 替代文本
  
  // 响应式配置
  widths: {
    type: Array,
    default: () => [400, 800, 1200]
  },
  formats: {
    type: Array,
    default: () => ['webp', 'avif']
  },
  sizes: {
    type: String,
    default: '100vw'
  },
  
  // 艺术指导
  mobile: String,        // 手机版图片
  tablet: String,        // 平板版图片
  desktop: String        // 桌面版图片
})

// 判断是否使用 picture 模式
const usePicture = computed(() => {
  return props.mobile || props.tablet || props.desktop
})

// 生成 srcset 字符串
const generateSrcset = (basePath, widths, format) => {
  return widths
    .map(w => `${basePath}-${w}w.${format} ${w}w`)
    .join(', ')
}

// picture 模式的 sources
const pictureSources = computed(() => {
  const sources = []
  
  // 为每种格式生成 source
  props.formats.forEach(format => {
    // 桌面版
    if (props.desktop) {
      sources.push({
        media: '(min-width: 1200px)',
        srcset: generateSrcset(props.desktop, props.widths, format),
        type: `image/${format}`
      })
    }
    
    // 平板版
    if (props.tablet) {
      sources.push({
        media: '(min-width: 768px) and (max-width: 1199px)',
        srcset: generateSrcset(props.tablet, props.widths, format),
        type: `image/${format}`
      })
    }
    
    // 手机版
    if (props.mobile) {
      sources.push({
        media: '(max-width: 767px)',
        srcset: generateSrcset(props.mobile, props.widths, format),
        type: `image/${format}`
      })
    }
  })
  
  return sources
})

// 兜底图片
const fallbackSrc = computed(() => {
  return props.desktop || props.tablet || props.mobile || props.src
})

// 非 picture 模式的 srcset
const srcsetString = computed(() => {
  if (usePicture.value) return ''
  return generateSrcset(props.src, props.widths, 'jpg')
})
</script>

组件使用示例

<template>
  <!-- 方案1:普通响应式图片 -->
  <ResponsiveImage
    src="/images/photo.jpg"
    :widths="[400, 800, 1200]"
    sizes="(max-width: 600px) 100vw, 50vw"
    alt="风景"
  />
  
  <!-- 方案2:艺术指导(不同屏幕不同构图) -->
  <ResponsiveImage
    mobile="/images/hero-mobile.jpg"
    tablet="/images/hero-tablet.jpg"
    desktop="/images/hero-desktop.jpg"
    :widths="[400, 800, 1200]"
    alt="英雄图"
  />
</template>

自动生成多尺寸图片 - Vite 插件

为什么需要插件生成?

假如我们需要手动为每张图片生成:

  • photo-400w.jpg
  • photo-800w.jpg
  • photo-1200w.jpg
  • photo-400w.webp
  • photo-800w.webp
  • photo-1200w.webp
  • photo-400w.avif
  • photo-800w.avif
  • photo-1200w.avif

相当于一张图片就要配置 9 个文件;随着图片数量的增加,这将是一场噩梦!

插件原理与设计

  1. 识别项目中的图片导入
  2. 根据配置生成多种尺寸和格式
  3. 注入对应的 srcset 信息

Vite插件完整实现

/// vite-plugin-responsive-images.js
import sharp from 'sharp'
import { glob } from 'fast-glob'

export default function responsiveImagesPlugin(options) {
  const {
    widths = [400, 800, 1200],
    formats = ['webp', 'avif'],
    quality = 80
  } = options
  
  return {
    name: 'vite-plugin-responsive-images',
    
    async buildStart() {
      // 找到所有图片
      const files = await glob('src/assets/images/**/*.{jpg,jpeg,png}')
      
      console.log(`📸 找到 ${files.length} 张图片`)
      
      for (const file of files) {
        // 为每个尺寸和格式生成图片
        for (const width of widths) {
          for (const format of formats) {
            const outputPath = file
              .replace('src/assets', 'dist/assets')
              .replace(/\.(jpg|jpeg|png)$/, `-${width}w.${format}`)
            
            await sharp(file)
              .resize(width, null, { withoutEnlargement: true })
              .toFormat(format, { quality })
              .toFile(outputPath)
          }
        }
      }
      
      console.log('✅ 图片生成完成')
    }
  }
}

配置插件

// vite.config.js
import responsiveImages from './vite-plugin-responsive-images'

export default {
  plugins: [
    responsiveImages({
      widths: [400, 800, 1200, 1600],
      formats: ['webp', 'avif'],
      quality: 75
    })
  ]
}

性能对比:不同方案下的图片加载体积

测试数据对比

基于典型电商商品详情页的测试结果:

图片类型 原始大小 WebP AVIF 节省空间
商品主图 (1200×1200) 850KB 320KB 210KB 62%-75%
商品缩略图 (400×400) 120KB 45KB 28KB 62%-77%
轮播大图 (1920×1080) 1.2MB 480KB 320KB 60%-73%

响应式方案加载体积对比

设备 传统单图 仅WebP 响应式srcset 响应式+WebP+AVIF
iPhone SE (375pt) 下载1200w图 (850KB) 下载1200w图 (320KB) 下载400w图 (120KB) 下载400w WebP (45KB)
iPad (768pt) 下载1200w图 (850KB) 下载1200w图 (320KB) 下载800w图 (280KB) 下载800w WebP (98KB)
MacBook Pro 下载1200w图 (850KB) 下载1200w图 (320KB) 下载1200w图 (850KB) 下载1200w WebP (320KB)
平均节省 基准 62% 51% 80%

加载性能指标提升

指标 优化前 优化后 提升
LCP (最大内容绘制) 3.2s 1.4s 56%
图片请求数 12 8 33%
总图片体积 4.2MB 1.1MB 74%
移动端数据消耗 4.2MB/次访问 0.6MB/次访问 86%

最佳实践清单

配置建议

图片尺寸断点:
├─ 400w:手机小屏
├─ 800w:手机大屏/平板
├─ 1200w:笔记本电脑
├─ 1600w:台式机
└─ 2000w:4K 屏幕

图片格式优先级:
├─ AVIF(最新,压缩率最高)
├─ WebP(广泛支持)
└─ JPEG/PNG(兜底)

sizes 设置:
├─ 手机:(max-width: 600px) 100vw
├─ 平板:(max-width: 1200px) 50vw
└─ 电脑:800px

实施策略选择矩阵

场景 技术方案 关键配置
普通内容图片 srcset + sizes 提供3-5种宽度,设置合理sizes
图标/Logo srcset + x描述符 提供1x/2x/3x版本
不同构图需求 picture + media 针对断点设计不同裁剪
现代格式降级 picture + type AVIF → WebP → JPEG
用户上传内容 动态生成 + CDN处理 根据设备实时转换

实施清单

  • 所有图片提供 3-5 种尺寸
  • 生成 WebP 和 AVIF 格式
  • 使用 <picture> 实现格式降级
  • 设置正确的 sizes 属性
  • 关键图片设置 loading="eager"
  • 非关键图片设置 loading="lazy"
  • 使用 Vite 插件自动生成多尺寸

结业

用户可能不会注意到图片加载很快,但一定会注意到图片加载很慢。响应式图片优化,是对用户体验最深情的告白。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

为什么要有 Neovate Code?

作者 LeonGao
2026年3月25日 09:10

0、先破后立:别把它当“又一个写代码的 AI”,那样你会完全用错。**

很多团队引入工具的起点是:写得更快、补全更强、能多写点功能。但现实是,真正拖慢交付的通常不是“敲代码速度”,而是对齐成本、返工成本、代码漂移、质量不可控。Neovate Code 的存在价值更偏工程:把“写出来”变成“交付得出去”,把“能用一次”变成“能持续用”。


1、交付:需要 Neovate Code,因为团队缺的不是产出文字,而是产出可合并的变更。

中心论点:它解决的是交付链路断层,让改动从一开始就长得像工程提交。
很多通用模型输出像草稿:缺文件边界、缺变更范围说明、缺回归点、缺运行步骤;你要把它再加工成一个能进仓库的提交。Neovate Code 更应该做的是:

  • 用更像“补丁”的方式给结果:改哪些文件、为什么改、怎么回滚。
  • 默认带上自检:最小可行测试、边界用例、常见失败点。
  • 把交付口径写清:输入输出、依赖、兼容性、风险提示。

2、可控:需要 Neovate Code,因为工程怕的不是“不会写”,是“写了你不敢合”。

中心论点:它的意义是把改动变得可控、可审、可收敛。
团队真实痛点往往是:AI 改的东西太散、太大、风格乱、还喜欢顺手重构;你看不清它改了什么,就不敢点合并。Neovate Code 应该把控制权交回给人:

  • 改动范围可锁定:只动指定模块,不碰接口与目录。
  • 输出结构稳定:固定 diff/提交说明/验证步骤的格式。
  • 不确定就停:遇到缺信息时给出“需要补的材料清单”,而不是硬猜。

3、复现:需要 Neovate Code,因为一次性成功不值钱,可重复成功才值钱。

中心论点:它把“这次能跑”升级为“下次还一样”。
团队协作的本质是复现:同事能复现、CI 能复现、线上能复现。很多工具做的是即时灵感,但工程要的是可重复过程。Neovate Code 的价值在于:

  • 把假设写出来:环境、版本、配置、数据前置条件。
  • 把验收写成步骤:怎么测、测哪些边界、出错怎么看。
  • 把决策可追溯:为什么这么改,替代方案是什么,风险点在哪。

4、成本:需要 Neovate Code,因为真正贵的是隐性成本:返工、沟通、事故,而不是模型调用费。

中心论点:它的定位是降低“总成本”,不是降低“生成成本”。
如果一个工具让你多写了 30% 代码,但让 Review 更难、回归更痛、线上更不稳,那就是反向省钱。Neovate Code 应该把钱省在刀刃上:

  • 减少返工:第一次就按团队规范交付。
  • 减少对齐:把需求拆成可执行任务,减少来回解释。
  • 减少事故概率:默认补齐校验、错误处理、回滚思路。

5、安全:需要 Neovate Code,因为企业用代码工具,安全是门槛,不是加分题。

中心论点:它必须把风险挡在生成阶段,而不是上线之后。
通用模型常见问题是:功能写得快,但安全意识薄;鉴权、校验、注入、防泄露这些点不稳定。Neovate Code 的必要性在于,它可以把安全变成默认动作:

  • 生成时就考虑最小权限与输入校验。
  • 输出时就提示敏感信息处理与日志脱敏。
  • 给出风险清单:哪些地方要安全评审、哪些地方要加审计。

6、工程化适配:需要 Neovate Code,因为团队要的不是“聪明”,是“能接进流水线”。

中心论点:它应该天然适配仓库、规范、CI、代码所有权,而不是单人玩具。
真实开发不是“写完就完”,而是要接入团队流程:分支策略、提交规范、测试门禁、代码风格、依赖治理。Neovate Code 的存在感来自它能对齐这些东西:

  • 按既有项目结构产出,不自创目录。
  • 默认尊重 lint/test/CI,输出可直接跑门禁。
  • 支持“最小改动原则”:能小改就不大动,能补丁就不重写。

快速自测清单:它是不是“有必要”,跑一轮就知道

  1. 补丁交付:给一个真实 bug,让它“先写失败用例,再修,再补回归”。
  2. 范围锁定:要求只改 1 个文件/1 个函数,检查是否越界。
  3. 规范遵守:指定 lint、提交信息格式、错误码规范,看是否照做。
  4. 复现步骤:要求输出运行命令、环境假设、验收流程,看是否完整。
  5. 安全底线:让它处理上传/SQL/鉴权任务,检查是否默认做校验与权限控制。
  6. 成本对比:统计从输出到合并的时间、返工次数、Review 评论条数。
  7. 稳定性:同一任务跑 5 次,看结构与结论是否收敛。
  8. 团队可读性:把输出交给同事 Review,看是否能快速看懂改动意图与风险点。

结语:为什么要有 Neovate Code?因为团队真正缺的是“工程可控的交付”,不是“会说的代码”。
当一个工具能让提交更小、意图更清、验证更快、风险更低,它就不是“锦上添花”,而是在把研发从反复返工里拉出来。Neovate Code 的价值,应该被衡量在:你敢不敢合、合完稳不稳、下次能不能复现。

JS深浅拷贝全解析|常用方法+手写实现+避坑指南(附完整代码)

作者 cmd
2026年3月25日 09:05

一、拷贝

  • 浅拷贝和深拷贝都复制了值和地址,都是为了解决引用类型赋值后互相影响的问题。
  • 但是浅拷贝只进行一层复制,深层次的引用类型还是共享内存地址,原对象和拷贝对象还是会互相影响。
  • 深拷贝就是无限层级拷贝,深拷贝后的原对象不会和拷贝对象互相影响。

二、浅拷贝

可实现浅拷贝的方式如下:

1. Object.assign

const obj = {
  name: 'lin'
}
const newObj = Object.assign({}, obj)
obj.name = 'xxx' // 改变原来的对象
console.log(newObj) // { name: 'lin' } 新对象不变
console.log(obj == newObj) // false 两者指向不同地址

2. 数组的slice和concat方法

const newArr = arr.slice(0)

arr[2] = 'rich' // 改变原来的数组

console.log(newArr) // ['lin', 'is', 'handsome']

console.log(arr == newArr) // false 两者指向不同地址
const arr = ['lin', 'is', 'handsome']
const newArr = [].concat(arr)

arr[2] = 'rich' // 改变原来的数组

console.log(newArr) // ['lin', 'is', 'handsome'] // 新数组不变

console.log(arr == newArr) // false 两者指向不同地址

3. 数组的静态方法 Array.from

const arr = ['lin', 'is', 'handsome']
const newArr = Array.from(arr)

arr[2] = 'rich' // 改变原来的数组

console.log(newArr) // ['lin', 'is', 'handsome']

console.log(arr == newArr) // false 两者指向不同地址

4.扩展运算符

const arr = ['lin', 'is', 'handsome']
const newArr = [...arr]

arr[2] = 'rich' // 改变原来的数组

console.log(newArr) // ['lin', 'is', 'handsome'] // 新数组不变

console.log(arr == newArr) // false 两者指向不同地址
const obj = {
  name: 'lin'
}

const newObj = { ...obj }

obj.name = 'xxx' // 改变原来的对象

console.log(newObj) // { name: 'lin' } // 新对象不变

console.log(obj == newObj) // false 两者指向不同地址

5. 循环遍历赋值

function clone (obj) {
  const cloneObj = {} // 创建一个新的对象
  for (const key in obj) { // 遍历需克隆的对象
    cloneObj[key] = obj[key] // 将需要克隆对象的属性依次添加到新对象上
  }
  return cloneObj
}

三、深拷贝

1. 序列化

JSON.parse(JSON.stringify(obj))

const obj = {
  person: {
    name: 'lin'
  }
}

const newObj = JSON.parse(JSON.stringify(obj))
obj.person.name = 'xxx' // 改变原来的深层对象

console.log(newObj) // { person: { name: 'lin' } } 新的深层对象不变

使用序列化的方式来实现深度克隆有些许弊端;

  • 会忽略undefinedsymbol函数
  • NaN Infinity -Infinity会被序列化为null
  • Map序列化返回是空对象{}; 个人认为Map的结构类似键值对,然后value是个函数,因函数的缘故无法序列化

2. 递归实现

要求:

  • 支持对象、数组、日期、正则的拷贝。
  • 处理原始类型(原始类型直接返回,只有引用类型才有深拷贝这个概念)。
  • 处理 Symbol 作为键名的情况。
  • 处理函数(函数直接返回,拷贝函数没有意义,两个对象使用内存中同一个地址的函数,问题不大)。
  • 处理 DOM 元素(DOM 元素直接返回,拷贝 DOM 元素没有意义,都是指向页面中同一个)。
  • 额外开辟一个储存空间 WeakMap,解决循环引用递归爆栈问题(引入 WeakMap 的另一个意义,配合垃圾回收机制,防止内存泄漏)。

Reflect.ownKeys() 方法返回一个由目标对象(自身)的属性键组成的数组(包括Symbol);它的返回值等同于Object.getOwnPropertyNames(target).concat(Object.getOwnPropertySymbols(target))

function getType(target) {
  return Object.prototype.toString.call(target).slice(8, -1)
}
function deepClone(target, hash = new WeakMap()) {
  // 处理 原始值 null、undefined、number、string、symbol、bigInt、boolean
  if (typeof target !== 'object' || target === null) {
    return target
  }
  // 处理 array
  if (Array.isArray(target)) {
    return target.map((e) => deepClone(e))
  }
  // 处理 function
  if (getType(target) === 'Function') {
    return eval(`(${target.toString()})`).bind(this) // function 声明需要用"("、")"包裹
  }
  // 拷贝日期 
  if(getType(target) === 'Date') {
    return new Date(target.valueOf()) 
 }
  // 拷贝正则
  if(getType(target) === 'RegExp') {
    return new RegExp(target)
 }
  // 处理 map
  if (getType(target) === 'Map') {
    let map = new Map()
    target.forEach((v, k) => {
      map.set(k, deepClone(v))
    })
    return map
  }
  // 处理 set
  if (getType(target) === 'Set') {
    let set = new Set()
    for (let val of target.values()) {
      set.add(deepClone(val))
    }
    return set
  }
    
  if (hash.get(target) return hash.get(target) // 当需要拷贝当前对象时,先去存储空间中找,如果有的话直接返回
  const cloneTarget = new target.constructor() // 通过target的构造函数创建一个新的与之一样类型的对象,这样写的就不需要判断类型了
  hash.set(target, cloneTarget) // 如果存储空间中没有就存进存储空间 hash 里

  // 处理 object
  if (getType(target) === 'Object') {
Reflect.ownKeys(target).forEach(key => {
    cloneTarget[key] = deepClone(target[key], hash) // 递归拷贝每一层
  })
    return cloneTarget
  }
  return target
}

3. structuredClone

结构化克隆算法是由HTML5规范定义的用于复制复杂JavaScript对象的算法;H5定义的全局深度克隆方法;

const obj = {
  person: {
    name: 'lin'
  }
}

const newObj = structuredClone(obj) // 
obj.person.name = 'xxx' // 改变原来的对象

console.log('原来的对象', obj)
console.log('新的对象', newObj)

console.log('更深层的对象指向不同的地址', obj.person == newObj.person)

缺点:

  • Error 以及 Function 对象是不能被结构化克隆算法复制的;如果你尝试这样子去做,这会导致抛出 DATA_CLONE_ERR 的异常。

  • 企图去克隆 DOM 节点同样会抛出 DATA_CLONE_ERR 异常。

  • 对象的某些特定参数也不会被保留

    • RegExp对象的 lastIndex 字段不会被保留
    • 属性描述符,setters 以及 getters(以及其他类似元数据的功能)同样不会被复制。例如,如果一个对象用属性描述符标记为 read-only,它将会被复制为 read-write,因为这是默认的情况下。
    • 原形链上的属性也不会被追踪以及复制。
  • Symbol除外可复制

最后说一局,推荐使用lodash,lodash的实现是很全面的

感谢您抽出宝贵的时间观看本文;本文是 JavaScript 系列的第 5 篇,后续会持续更新!欢迎关注~

JavaScript 和 React Component 实现井字棋游戏

作者 Jeff_Wang
2026年3月25日 01:31

成品: nanojs.net/tool/game/t…

井字棋游戏,也叫圈叉棋,英文:Tic-Tac-Toe

规则:

  • 3×3 的九宫格棋盘。
  • 一方画“○”,一方画“×”。
  • 轮流在空格中画自己的符号。
  • 先在横、竖、或对角线上连成一条直线的玩家获胜。
  • 如果格子填满且无人连成一线,则为平局。

用一个 9 个元素的数组表示棋盘

const [board, setBoard] = useState(Array(9).fill(null));

用一个 boolean 表示轮到 X 还是 O

const [xIsNext, setXIsNext] = useState(true);

总共只有 8 种胜利方法,分别是三横,三竖,两个斜线。枚举 8 种胜利情况判断是否胜利

  const winPatterns = [
    [0, 1, 2], [3, 4, 5], [6, 7, 8],
    [0, 3, 6], [1, 4, 7], [2, 5, 8],
    [0, 4, 8], [2, 4, 6]
  ];

  const winner = winPatterns.some(([a, b, c]) =>
    board[a] && board[a] === board[b] && board[a] === board[c]
  );

如果没有胜利且棋盘满了,则为平局

const isDraw = !winner && board.every(cell => cell !== null);

实现下棋

const handleClick = (index) => {
  if (board[index] || winner || isDraw) return;
  
  const newBoard = board.map((cell, i) => 
    i === index ? (xIsNext ? 'X' : 'O') : cell
  );
  setBoard(newBoard);
  setXIsNext(!xIsNext);
};

完整 React Component

import React, { useState } from 'react';

export default function TicTacToe() {
  const [board, setBoard] = useState(Array(9).fill(null));
  const [xIsNext, setXIsNext] = useState(true);

  const winPatterns = [
    [0, 1, 2], [3, 4, 5], [6, 7, 8],
    [0, 3, 6], [1, 4, 7], [2, 5, 8],
    [0, 4, 8], [2, 4, 6]
  ];

  const winner = winPatterns.some(([a, b, c]) =>
    board[a] && board[a] === board[b] && board[a] === board[c]
  );

  const isDraw = !winner && board.every(cell => cell !== null);

  const handleClick = (index) => {
    if (board[index] || winner || isDraw) return;
    
    const newBoard = board.map((cell, i) => 
      i === index ? (xIsNext ? 'X' : 'O') : cell
    );
    setBoard(newBoard);
    setXIsNext(!xIsNext);
  };

  const resetGame = () => {
    setBoard(Array(9).fill(null));
    setXIsNext(true);
  };

  const getStatus = () => {
    if (winner) return `Player ${xIsNext ? 'O' : 'X'} Wins!`;
    if (isDraw) return "It's a Draw!";
    return `Current: Player ${xIsNext ? 'X' : 'O'}`;
  };

  const getStatusStyle = () => {
    if (winner) return { ...styles.status, color: '#16a34a' };
    if (isDraw) return { ...styles.status, color: '#f59e0b' };
    return styles.status;
  };

  const getCellBorders = (index) => {
    const row = Math.floor(index / 3);
    const col = index % 3;
    
    return {
      borderTop: row > 0 ? '2px solid #e2e8f0' : 'none',
      borderBottom: row < 2 ? '2px solid #e2e8f0' : 'none',
      borderLeft: col > 0 ? '2px solid #e2e8f0' : 'none',
      borderRight: col < 2 ? '2px solid #e2e8f0' : 'none',
    };
  };

  return (
    <div style={styles.container}>
      <h2 style={styles.title}>Tic-Tac-Toe</h2>
      
      <div style={getStatusStyle()}>
        {getStatus()}
      </div>

      <div style={styles.board}>
        {board.map((cell, index) => (
          <button
            key={index}
            style={{
              ...styles.cell,
              ...getCellBorders(index),
              ...(cell === 'X' ? styles.cellX : {}),
              ...(cell === 'O' ? styles.cellO : {}),
            }}
            onClick={() => handleClick(index)}
            disabled={!!cell || winner || isDraw}
          >
            {cell}
          </button>
        ))}
      </div>

      {(winner || isDraw) && (
        <button style={styles.newGameButton} onClick={resetGame}>
          New Game
        </button>
      )}
    </div>
  );
}

const styles = {
  container: {
    maxWidth: '400px',
    margin: '20px auto',
    padding: '20px',
    fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
    display: 'flex',
    flexDirection: 'column',
    alignItems: 'center',
  },
  title: {
    fontSize: '28px',
    fontWeight: '700',
    color: '#1e40af',
    margin: '0 0 20px 0',
  },
  status: {
    fontSize: '20px',
    fontWeight: '700',
    color: '#1e293b',
    marginBottom: '20px',
  },
  board: {
    display: 'grid',
    gridTemplateColumns: 'repeat(3, 80px)',
    gridTemplateRows: 'repeat(3, 80px)',
    gap: '0',
    margin: '0 auto 20px',
  },
  cell: {
    width: '80px',
    height: '80px',
    fontSize: '36px',
    fontWeight: '700',
    borderRadius: '0',
    background: '#fff',
    cursor: 'pointer',
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
    transition: 'all 0.2s ease',
    padding: '0',
    boxSizing: 'border-box',
  },
  cellX: {
    color: '#3b82f6',
    background: '#eff6ff',
  },
  cellO: {
    color: '#ef4444',
    background: '#fef2f2',
  },
  newGameButton: {
    padding: '12px 32px',
    fontSize: '16px',
    fontWeight: '700',
    color: '#fff',
    background: 'linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)',
    border: 'none',
    borderRadius: '10px',
    cursor: 'pointer',
    boxShadow: '0 4px 14px rgba(59, 130, 246, 0.4)',
  },
};

Promise/A+ 解析

作者 乘方
2026年3月24日 20:24

关于 Promise 要掌握的点

Promise 中有三种状态

  • 分别是 pending fulfilled rejected
  • 状态一旦从 pending 变为 fulfilled/rejected ,就会“凝固”,不能再改变。
  • executor() resolve() reject() 默认都是同步执行的。
const executor = (resolve, reject) => {
  resolve("fulfilled"); // 将状态变为 '成功'
  reject("rejected"); // 不会再执行了
};
new Promise(executor);

executor 中执行 reject() 和 throw new Error()

  • executor 的同步阶段,throw new Error() 会抛出一个异常,Promise 内部会用 try...catch 捕获这个异常,然后自动调用 reject(error)
  • 所以在同步代码里,reject(reason)throw new Error() 都会让 Promise 进入 rejected
  • catchthen(_, onRejected) 捕获后,如果返回普通值,后续链会转为 fulfilled
Promise.reject("初始错误")
  .catch((err) => {
    console.log("捕获到错误:", err);
    return "恢复后的值"; // 返回普通值,后续链会进入 fulfilled 状态
  })
  .then((value) => {
    console.log("后续 then 收到:", value); // 输出: 后续 then 收到: 恢复后的值
    return "继续传递";
  })
  .catch((err) => {
    console.log("这个 catch 不会执行,因为错误已被处理");
  });
  • 但在异步回调里直接 throw new Error(),不属于 Promise 构造时的同步执行上下文,通常不会被这个 Promise 的 .catch 捕获。
// 异步 throw 无法被捕获
new Promise((resolve, reject) => {
  setTimeout(() => {
    throw new Error("异步错误"); // 全局未捕获异常
  }, 0);
}).catch((e) => console.log(e)); // 不会执行

// 正确写法是把异步错误显式交给 reject:
new Promise((resolve, reject) => {
  setTimeout(() => {
    try {
      // ...可能报错的逻辑
      throw new Error("异步错误");
    } catch (e) {
      reject(e);
    }
  }, 0);
}).catch((e) => console.log("捕获到:", e.message));

then(onFulfilled, onRejected) 回调的执行时机

  • p.then(...) 执行也是同步的。
  • Promise 实例 p 的状态决定是否执行或者执行哪个 reaction(onFulfilled / onRejected)
    1. p 的状态确定后,进入 then 方法,根据状态将对应 reaction 回调。通过 queueMicrotask 加入到微任务队列,结束 then,返回一个新的 Promise 实例。

      queueMicrotask 是一个全局函数,用于将一个回调函数添加到微任务队列(microtask queue)中。是 HTML 标准和 Node.js 都支持的 API,在 ECMAScript 2020 中被正式纳入规范。

    2. p 的状态还未定:比如在 executor 中用定时器包裹了resolve() / reject(),因为 then 是同步的,此时进入then,p 的状态是 pending 还未凝固。Promise 将 reactionasyncRun处理,暂存到 Promise 类的全局的回调数组。待真正触发resolve() / reject() 后,将队列的方法依次取出,并执行 asyncRunreaction 放入微任务队列。此时回调还未执行,只是放到了微任务队列里面,等待同步任务执行完毕,才会依次执行

      asyncRun 的作用就是将回调函数交给 queueMicrotask 处理的“包装器”。 因此 reaction 回调的执行是异步的。

// 加入微任务队列
function asyncRun(fn) {
  if (typeof queueMicrotask === "function") {
    queueMicrotask(fn);
    return;
  }
  setTimeout(fn, 0);
}
// In Promise.then()
/**
 * 执行 fulfilled 分支
 * - 用微任务/异步任务包装,保证 then 回调异步执行
 * - 回调结果 x 交给 resolvePromise 统一处理
 */
const runFulfilled = () => {
  asyncRun(() => {
    try {
      const x = realOnFulfilled(this.value); // x: 第一个 then 中的 onFulfilled 返回值,它决定 promise2 的状态
      resolvePromise(promise2, x, resolve, reject);
    } catch (error) {
      reject(error);
    }
  });
};
// 判断是哪种情况
if (this.state === FULFILLED) {
  runFulfilled();
} else if (this.state === REJECTED) {
  runRejected();
} else {
  // pending:先存回调,等 resolve/reject 时再统一触发
  this.onFulfilledCallbacks.push(runFulfilled);
  this.onRejectedCallbacks.push(runRejected);
}

Promise.then() 的链式调用,都做了些什么

示例:

const p2 = p1.then(fn1, fn2);
const p3 = p2.then(fn3, fn4);

问题:由上面可知,这种链式关系,在执行同步代码就已经形成。但是在链式中 fn 的执行受什么影响?

  • 一个 Promise 实例的状态由 resolve() / reject() 来决定。

  • 实例 p1 的状态,决定执行 fn1 / fn2, 并且返回一个新的实例 p2;

  • 同理 p2 的状态,影响着后续 fn3 / fn4 的执行。因此 then 中核心则是如何决定 p2 状态

  • 新返回 promise2 实例的状态受回调函数 fn 返回值影响。(通过控制其 resolve() / reject() 来实现)

    • 如果 fn 的返回值 x 不是 thenable 类型(实现了 then 接口,可以被 then 调用),那么 p2 fulfilled(x)给 fn3。
    • 如果 x 是对象或者函数,那么 x 就有可能是 thenable 类型
    • thenable 类型的 x 的状态是自己实现的,由自己控制,且 p2 的状态就会和 x 的状态进行挂钩。
    • 如果外层 Promise 直接 resolve(内层Promise)。规范要求外层应“跟随”内层最终结果,而不是把“Promise 对象本身”当普通值传下去,通过递归调用 resolvePromise,可以持续“剥开”嵌套的 thenable,直到获得一个普通值,或者遇到拒绝状态时立即终止。这就是“展平(flatten)”语义。
    // 示例
    const p = new Promise((resolve, reject) => {
      const value = Promise.reject("rejected");
      resolve(value);
    });
    p.then(
      (val) => {
        console.log("成功", val); // 不执行
      },
      (reason) => {
        console.log("失败", reason); // `失败 rejected` 可以看出,p 的状态被后面 value 的状态的接管
      },
    );
    
  • 双重保险

    • resolve 中优先处理“同构 Promise”(如 value instanceof Promise)是优化。
    • resolvePromise 中通过 then.call 处理通用 thenable 是规范核心。
    • 两者结合,既有性能优化,也保证兼容外部实现/用户自定义 thenable
// resolve 实现
const resolve = (value) => {
  // 同化 Promise 实例:当前 promise 跟随 value 的最终结果
  if (value instanceof Promise) {
    value.then(resolve, reject);
    return;
  }
  // ...
};

// resolvePromise 实现
function resolvePromise(promise2, x, resolve, reject) {
  // 1. 防止 p.then(() => p2) 这类自解析死循环
  if (promise2 === x) {
    reject(new TypeError("Chaining cycle detected for promise"));
    return;
  }

  // 2. 只有对象/函数才可能是 thenable
  if (x !== null && (typeof x === "object" || typeof x === "function")) {
    // 防止 thenable 同时/重复调用 resolve 和 reject
    let called = false;

    try {
      // 取 then 时也可能抛错(例如 getter 抛异常)
      const then = x.then;

      if (typeof then === "function") {
        // 按 thenable 协议调用:then.call(x, resolveFn, rejectFn)
        then.call(
          x,
          (y) => {
            if (called) return;
            called = true;
            // 递归解析 y,直到拿到普通值或最终拒绝
            resolvePromise(promise2, y, resolve, reject);
          },
          (r) => {
            if (called) return;
            called = true;
            reject(r);
          },
        );
        return; // thenable 分支处理完毕
      }
    } catch (error) {
      // 取 then 或调用 then 过程抛错,且未决过,转 reject
      if (called) return;
      called = true;
      reject(error);
      return;
    }
  }

  // 3. 普通值:直接 fulfilled
  resolve(x);
}

Promise 的其他 API

resolve

  • 如果本来就是 Promise,直接返回
  • 否则包装成 fulfilled 的 Promise
static resolve(value) {
  if (value instanceof Promise) return value;
  return new Promise((resolve) => resolve(value));
}

reject

  • Promise 上的静态方法
  • 直接返回 rejected 的 Promise
static reject(reason) {
  return new Promise((_, reject) => reject(reason));
}

catch

  • 语法糖,等价于 then(null, onRejected)
catch(onRejected) {
  return this.then(null, onRejected);
}

finally

  • 不改变前一个 Promise 的值/错(除非 finally 自己抛错/返回 rejected)
  • 无论成功失败都会执行 onFinally
finally(onFinally) {
  const handler =
    typeof onFinally === "function" ? onFinally : () => undefined;
  return this.then(
    // 成功:先执行 finally,再把原 value 传下去
    (value) => Promise.resolve(handler()).then(() => value),
    // 失败:先执行 finally,再把原 reason 继续抛出
    (reason) =>
      Promise.resolve(handler()).then(() => {
        throw reason;
      }),
  );
}

all

  • 全部 fulfilledfulfilled(按输入顺序收集结果)
  • 任意一个 rejected 立即 rejected
static all(iterable) {
  return new Promise((resolve, reject) => {
    const items = Array.from(iterable);

    // 空数组直接 fulfilled []
    if (items.length === 0) {
      resolve([]);
      return;
    }

    const result = new Array(items.length);
    let count = 0;

    items.forEach((item, index) => {
      // 统一 Promise 化,兼容普通值
      Promise.resolve(item).then(
        (value) => {
          result[index] = value;
          count += 1;
          if (count === items.length) resolve(result);
        },
        (reason) => reject(reason),
      );
    });
  });
}

race

  • 谁先落态(fulfilled/rejected)就采用谁的结果
static race(iterable) {
  return new Promise((resolve, reject) => {
    Array.from(iterable).forEach((item) => {
      Promise.resolve(item).then(resolve, reject);
    });
  });
}

flutter web 如何确保用户收到更新

作者 p1gd0g
2026年3月24日 19:25
flutter doctor -v
Flutter assets will be downloaded from https://storage.flutter-io.cn. Make sure you trust this source!
[√] Flutter (Channel stable, 3.41.4, on Microsoft Windows [版本 10.0.26200.8039], locale zh-CN) [796ms]
    • Flutter version 3.41.4 on channel stable at D:\flutter
    • Upstream repository https://github.com/flutter/flutter.gitFramework revision ff37bef603 (3 weeks ago), 2026-03-03 16:03:22 -0800
    • Engine revision e4b8dca3f1
    • Dart version 3.11.1
    • DevTools version 2.54.1
    • Pub download mirror https://pub.flutter-io.cn
    • Flutter download mirror https://storage.flutter-io.cn
    • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android,
      enable-ios, cli-animations, enable-native-assets, omit-legacy-version-file, enable-lldb-debugging,
      enable-uiscene-migration

在老的版本中,flutter web 依靠文件哈希的方式控制版本更新,只要发现哈希不同,就会拉取最新的 js 文件。但最近这种方式已经被淘汰,main.dart.js 文件不再以有哈希后缀。

因此,用户端无法判断 main.dart.js 的版本。在开启缓存的情况下,用户可以无法更新到最新版本。直到缓存到期或者检测到 etag 不同。而 main.dart.js 非常大,如果频繁下载会非常影响用户体验。

想要控制 main.dart.js 版本的最简单方式就在拉取时补充版本信息,类似于 main.dart.js?version=1.2.3。那么要如何做到这一点呢?

如果你问 ai,他可能会告诉你,要修改 web/flutter_bootstrap.js 的 loadEntrypoint 方法的 entrypointUrl 参数:

entrypointUrl: '/main.dart.js?version=1.2.3',

但实际上这个参数已经过期了,顺带说一句,loadEntrypoint 方法也已经过期了。老的教程使用这个函数去自定义 flutter web 初始化。

  /**
   * @deprecated
   * Loads flutter main entrypoint, specified by `entrypointUrl`, and calls a
   * user-specified `onEntrypointLoaded` callback with an EngineInitializer
   * object when it's done.
   *
   * @param {*} options
   * @returns {Promise | undefined} that will eventually resolve with an
   * EngineInitializer, or will be rejected with the error caused by the loader.
   * Returns undefined when an `onEntrypointLoaded` callback is supplied in `options`.
   */
  async loadEntrypoint(options) {
    const { entrypointUrl = resolveUrlWithSegments("main.dart.js"), onEntrypointLoaded, nonce } =
      options || {};
    return this._loadJSEntrypoint(entrypointUrl, onEntrypointLoaded, nonce);
  }

当前版本,flutter web 使用 _flutter.loader.load 方法去初始化。然而,新的方法并没有类似 entrypointUrl 用法的参数(注意不要和 entrypointBaseUrl 搞混)。

但可以看到,flutter 实际拉取 main.dart.js 的逻辑是这样的:

      const mainPath = build.mainJsPath ?? "main.dart.js";
      const entrypointUrl = resolveUrlWithSegments(entrypointBaseUrl, mainPath);
      return this._loadJSEntrypoint(entrypointUrl, onEntrypointLoaded, nonce);

我们只需要在 web/flutter_bootstrap 中自己去更改 build.mainJsPath 的值就好:

_flutter.buildConfig.builds[0].mainJsPath = 'main.dart.js?version=1.2.3';

那么如何自动更改这个版本号呢?其实 flutter web 自带了一些模板代码的设置,首先将上面的代码改为:

_flutter.buildConfig.builds[0].mainJsPath = 'main.dart.js?version={{version}}';

将打包代码改为:

flutter build web --web-define=version=1.2.3

{{version}} 就会自动被替换为实际的版本号。

❌
❌