阅读视图

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

MinIO已死,MinIO万岁

我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点。

如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。 很多人第一次打开 OpenClaw,会下意识把它当成"接在微信或 Slack 上的聊天机器人"。这种理解只对了一半。从架构上看,OpenClaw 更像一个网关:它站在你和一堆能力之间,负责路由、鉴权、记忆和工具调用。真正决定你能做多少事的,不是对话框有多好看,而是背后接了多少"身体"——也就是 Skills。

MinIO 的开源仓库已经被正式归档,不再维护。

一个时代结束了,但开源不会那么容易死去。

我创建了一个 MinIO 分支,恢复管理控制台,重建二进制分发管道,让它重新活过来。

如果你正在运行 MinIO,只需要将 minio/minio 替换为 pgsty/minio 即可。

其他一切保持不变(CVE 已修复,管理控制台 GUI 也回来了)。

死亡证明

2025 年 12 月 3 日,MinIO 在 GitHub 上宣布进入 "维护模式"。我在 MinIO 已死 一文中写过这件事。

2026 年 2 月 12 日,MinIO 又把仓库状态从 "维护模式" 更新为 "不再维护",随后直接将仓库归档。

仓库被设为只读,不再接受 PR、Issue 或任何形式的贡献。一个拥有 6 万星标、超过 10 亿次 Docker 拉取的项目,就这样变成了一块数字墓碑。

20260303180532

如果说 2025 年 12 月是临床死亡,那么 2026 年 2 月的这次提交就是正式的死亡证明。

2026 年 2 月 14 日,一篇广为流传的文章《MinIO 如何从开源宠儿变成警示故事》给出了完整的时间线:MinIO如何从开源宠儿变成警示故事

20260303180543

Percona 创始人 Peter Zaitsev 也在 LinkedIn 上,对开源基础设施的可持续性提出了担忧。

国际社区的共识很明确:

MinIO 完了。

回顾过去几年的时间线,这并不是一次突然的事故,而是一个缓慢、有意、循序渐进的关停过程:

日期 事件 性质
2021-05 Apache 2.0 → AGPL v3 许可证变更
2022-07 对 Nutanix 采取法律行动 许可证执行
2023-03 对 Weka 采取法律行动 许可证执行
2025-05 从 CE 中移除管理控制台 功能限制
2025-10 停止二进制和 Docker 分发 供应链切断
2025-12 宣布维护模式 生命周期结束信号
2026-02 仓库归档,不再维护 项目结束

一家估值 10 亿美元、共融资 1.26 亿美元的公司,用了整整五年时间,有条不紊地拆解了自己一手建立的开源生态系统。

但开源永存

通常到这里,故事就结束了,大家集体叹口气,然后继续各奔东西。

但我想讲一个不太一样的故事。这不是讣告,而是复活。

MinIO 公司可以归档一个仓库,但他们无法归档 AGPL 授予社区的权利。

讽刺的是,AGPL 本来是 MinIO 自己选的。他们从 Apache 2.0 切换到 AGPL,是为了在和 Nutanix、Weka 的纠纷中增加筹码,在保留 "开源" 标签的同时,把许可证当成法律武器。但开源许可证是一把双刃剑,同样的许可证也确保了社区有权分叉。

一旦代码以 AGPL 形式发布,许可证就不可撤销。你可以把仓库设成只读,但不能收回已经授予社区的权利。

这正是开源许可证设计的精妙之处,公司可以放弃一个项目,但不能带走那份代码。

所以,MinIO 已死,但 MinIO 也可以重生。

当然,分叉本身是最简单的部分。任何人都可以点一下 Fork 按钮。

真正的问题不是 "能不能分叉",而是 "有没有人愿意、也有能力,把它当作生产系统的一部分长期维护下去"。

我为什么要这么做?

一开始,我并没有打算接下这个担子。MinIO 进入维护模式之后,我等了几周,希望能看到有社区成员站出来。

但我始终没有等到那个人,于是只好自己上。

先说一点背景,我在维护 Pigsty,这是一个带电池的 PostgreSQL 发行版,内置了 460 多个扩展,并为 14 个 Linux 发行版 做了交叉构建。我还维护了 290 个 PG 扩展、若干 PG 分支 和数十个 Go 项目(Victoria、Prometheus 等)在所有主流平台上的打包。在这样一条流水线上再接一个项目,说实话压力不算太大。

我对 MinIO 也很熟。早在 2018 年,我们就在探探内部运行了一个 MinIO 分支(当时还是 Apache 2.0),托管了大约 25 PB 的数据,是当时中国最早、规模也最大的一批 MinIO 部署之一。

更重要的是,MinIO 也是 Pigsty 中的一个可选模块,很多用户在生产环境里,把它作为 PostgreSQL 的默认备份仓库。

我们确实认真评估过几个替代方案,但没有任何一个,能在现有工作流上做到对 MinIO 的平滑替换。

20260303180652

更多配置细节可以参考:pigsty.io/docs/minio/…

我们自己就在用 MinIO,所以让这条供应链活下去,对我们来说根本不是选项,而是硬性要求。

早在 2025 年 12 月,MinIO 刚宣布进入维护模式时,我就已经构建了包含 CVE 修复 的二进制包,并第一时间在生产中完成了切换。

20260303180717

我们已经做了什么

截至今天,我们已经完成了三件事。

1. 恢复管理控制台

这大概是最让社区糟心的一次改动。

2025 年 5 月,MinIO 从社区版中移除了完整的管理控制台,只留下一款简陋的对象浏览器。

用户管理、存储桶策略、访问控制、生命周期管理,这些东西是一夜之间统统消失的。想要它们回来?唯一途径是买企业版(大约十万美元起步)。

20260303180734

我们把它完整地带回来了。

更有意思的是,这甚至不需要任何逆向工程。

你只需要把 minio/console 子模块恢复到之前的版本。

他们当时的做法,是通过替换依赖版本,用一个阉割版控制台替换了完整版。真正的代码始终都还在那儿。

可以在这里看到具体的改动: github.com/pgsty/minio…

20260303180754

我们现在已经把完整控制台放回来了。

2. 重建二进制分发

2025 年 10 月,MinIO 停止分发预构建的二进制文件和 Docker 镜像,只保留源码。对用户的官方回答只有一句:"使用 go install 自己构建"。

但对于绝大多数用户来说,开源软件的价值远远不止是一份源码副本,真正关键的是稳定可靠的供应链。

你需要的是可以直接塞进 Dockerfile、Ansible playbook 或 CI 流水线里的稳定工件,而不是在每次部署前,都被迫先装一套 Go 编译器。

所以我们重建了分发体系:

项目 说明
Docker 镜像 pgsty/minio 已在 Docker Hub 上线,直接运行 docker pull pgsty/minio 即可使用。
RPM、DEB 包 为主流 Linux 发行版构建,遵循 MinIO 原本的打包规范。
自动化构建流水线 在 GitHub 上提供完全自动化的构建流程,持续产出稳定的构建工件。

如果你现在使用的是 Docker,只需要把 minio/minio 换成 pgsty/minio

对于原生 Linux 安装,可以从 GitHub Release 页面获取 RPM、DEB 包。

你也可以使用 pig(PG 扩展包管理器)进行一键安装,或者配置 pigsty-infra APT、DNF 仓库,从中直接安装:

curl https://repo.pigsty.io/pig | bash
pig repo add infra -u
pig install minio

装完之后,它就像你熟悉的那份 MinIO 一样工作。

3. 恢复社区版文档

MinIO 的官方文档同样在慢慢 "消失"。不少旧链接已经开始被重定向到它们的新商业产品 AIStor

我们分叉了 minio/docs,修复了损坏的链接,恢复了被删掉的控制台文档,并把整个站点部署在 这里

文档仍然沿用原始项目的 CC Attribution 4.0 许可证,并在此基础上持续维护。

20260303180816

承诺

有几件事值得提前说清楚,以免大家产生不必要的期待。

没有新功能,只保证供应链连续性

作为一款 S3 兼容的对象存储,MinIO 已经算是功能完整了,它更像是一款 "写完了" 的软件。

它现在不缺新功能,真正缺的是一个稳定、可靠、长期可用的构建。

我这边已经有 PostgreSQL 来承担那些更复杂的活儿,所以我并不需要什么 S3 表、S3 向量之类的附加功能。一个稳定扎实的 S3 核心,就是我全部的诉求。

我们现在做的事情很简单:让你始终能拿到一份可用、完整的 MinIO 二进制,其中既包含管理控制台,也包含最新的安全修复。

RPM、DEB、Docker 镜像,都会通过自动化流水线持续构建出来,并与现有的 MinIO 部署保持兼容。

在法律和技术允许的边界内,我们会最大程度保留原有的 MinIO 命名和行为。

这是生产构建,不是归档镜像

我们自己就在生产环境中运行这些构建,而且已经 "吃狗粮" 吃了三个月。

一旦有东西出问题,我们会第一时间感受到,并尽快修复。

我搭建这套东西,首要目的是为了 Pigsty 和我们自己的使用,但我也很希望它能顺带帮到更多人。

我会跟踪 CVE,也会修 Bug

如果你在使用过程中遇到问题,欢迎到 pgsty/minio 反馈。

我会尽力修复这些问题,不过请不要把它当成商业 SLA。

考虑到 AI 编码工具大大降低了修复 Bug 的成本,而且我们明确不会往里加新功能,我相信整体维护工作量是可控的。

(你上一次见到新的 MinIO 功能更新是什么时候?)

商标确实麻烦,但有问题再一起解决

免责声明

商标声明:MinIO® 是 MinIO, Inc. 的注册商标。

本项目(pgsty/minio)是在 AGPL 许可证下独立维护的社区分支。

它与 MinIO, Inc. 没有任何关联、背书或商业关系。

本文中 "MinIO" 的使用仅指这款开源软件本身,并不暗示任何形式的商业合作。

AGPLv3 明确赋予我们分叉和分发的权利,但商标法又是另一套体系。

我们已经在各处清晰标注,这是一份由社区独立维护的构建。

如果 MinIO 公司对商标使用提出异议,我们会积极配合,完成重命名(也许会叫 "silo" 或 "stow" 之类的名字)。

在那之前,我们认为在 AGPL 分支中以描述性方式使用原始名称,是合理且有利于用户理解的。此时强行把所有 MinIO 引用全部改名,反而只会让用户更困惑。

AI 已经改变了游戏规则

你可能会问:一个人真的能扛得住这么大的项目吗?

现在已经是 2026 年了,情况和过去不一样。

AI 编码工具正在彻底改变开源维护的经济学

借助 Claude Code、Codex 之类的工具,在复杂的 Go 项目里定位和修复 Bug 的成本,已经降低了一个数量级。

很多过去需要专职团队才能维护的大型基础设施项目,现在完全可以交给一位有经验的工程师,加上一位靠谱的 AI 副驾驶来共同完成。

在不引入新功能的前提下,维护一份 MinIO 构建,是一项可管理的工作。

真正的关键在于测试和验证。而我们已经有了完整的生产场景,可以在真实流量下持续验证它的兼容性、可靠性和安全性。

想一想,Elon 把 X(原 Twitter)的工程团队缩减到了大约 30 人,这个平台到现在还在运转。 相比之下,维护一个不再加新功能的 MinIO 分支,远没有想象中那么可怕。

这对你意味着什么

如果你只是远远围观 MinIO 的兴衰,这个故事听起来可能像一篇行业八卦。但如果你属于下面几类用户,这个分支和上面这些工作,其实都和你的日常生产环境直接相关。

  • 在自建数据中心里,用 MinIO 做数据库备份和归档的团队
  • MinIO 部署在私有云,用来存放用户上传文件、审计日志、模型权重的 SaaS 团队
  • 在多云环境里,把 MinIO 当作 S3 兼容层,用来屏蔽底层对象存储差异的基础设施平台
  • 需要在离线环境、内网环境中部署 S3 存储,但又无法直接使用公有云服务的企业

对这些场景来说,MinIO 不只是一个组件名字,而是一条埋在系统最底层的供应链。一旦这条链路断掉,影响到的就不仅仅是对象存储本身,而是所有依赖它的备份、恢复、扩缩容、容灾和审计流程。

社区分支的目标,就是让这条链不断掉,让你可以像过去一样,用同一套命令行、同一套配置文件、同一套控制台,继续运转你的业务。

当然,如果你的团队已经在大规模使用公有云原生对象存储服务,或者可以轻松把工作负载迁移回 AWS S3GCSAzure Blob,那你完全可以把这篇文章当作一段开源史料。真正急需一条可持续供应链的,是那些长期押注在 MinIO 上、又没有简单退路的用户。

如何开始使用 pgsty/minio

如果你已经在生产里跑 MinIO,想要最小代价切换到社区分支,可以从下面几步入手。

  1. 先在测试环境里起一套新的 pgsty/minio 集群,版本尽量与现网保持一致。
  2. 把现有 MinIO 集群的配置文件完整复制过来,重点检查访问密钥、端点地址、挂载路径、证书配置是否一致。
  3. 使用同一套客户端脚本、备份流程,在测试环境里完整跑一遍你现在依赖的关键工作流,例如数据库全量备份和增量备份、静态资源读写、日志归档等。
  4. 如果你使用 DockerKubernetes,优先从镜像名入手,把 minio/minio 替换为 pgsty/minio,其余参数保持不变,验证容器生命周期和探针是否工作正常。
  5. 确认测试环境跑通之后,再在生产环境采用渐进式方式替换,可以先切一小部分流量,观察一段时间,再逐步扩大范围。

整个过程中最重要的一点,是保留好回滚路径。无论是通过流量切换、还是通过 Helm 回滚,只要你能在短时间内切回旧版本,就可以放心在真实业务场景中验证新的构建。

直接分叉它

MinIO 公司可以归档一个 GitHub 仓库,但他们无法归档 6 万颗星标背后的真实需求,也无法归档 10 亿次 Docker 拉取背后的依赖拓扑。这些需求不会凭空消失,它们只会自己找到出口。

HashiCorp 的 Terraform 已经被社区分叉成 OpenTofu,而且运行得很好。相比之下,MinIO 的处境甚至更有利,因为 AGPL 对分叉比 BSL 更友好,社区分叉几乎不存在法律灰区。

公司可以放弃一个项目,但开源许可证本来就是为了确保代码不会因此一同消失。

分叉,是开源世界里最强力的咒语之一。当一家公司选择关门时,社区只需要说出那两个字,

分叉它。

参考

免责声明:本文最初由 Claude 从中文版本润色并翻译成英文,此处为在中文版基础上的再整理与更新。

腾讯终于对个人开放了,5 分钟在 QQ 里养一只「真能干活」的 AI 😍😍😍

我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点。

如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。 很多人第一次打开 OpenClaw,会下意识把它当成"接在微信或 Slack 上的聊天机器人"。这种理解只对了一半。从架构上看,OpenClaw 更像一个网关:它站在你和一堆能力之间,负责路由、鉴权、记忆和工具调用。真正决定你能做多少事的,不是对话框有多好看,而是背后接了多少"身体"——也就是 Skills。

3月7日消息,腾讯面向个人用户开放了 QQ 开放平台的机器人创建权限。此前这项能力仅对企业开放,现在任何持有 QQ 账号的个人开发者都可以注册并创建自己的 QQ 机器人。配合 AI 智能体框架 OpenClaw,整个绑定流程只需不到五分钟。

注册与创建只需几分钟

打开 QQ 开放平台官网,用手机 QQ 扫码就能完成开发者账号注册,不需要填写任何企业资质或繁琐表单。

image.png

扫码登录后,点击一次"创建机器人"按钮,平台会立即为你生成一个专属的 AppIDAppSecret,这两个凭据就是后续与 OpenClaw 绑定的钥匙。

image.png

三条命令完成绑定

拿到 AppIDAppSecret 之后,切到 OpenClaw 控制台,依次执行以下三条命令:

openclaw plugins install @llvera/qqbot@latest
openclaw channels add --channel qqbot --token <你的 token>
openclaw gateway restart

第一条安装 QQ Bot 插件,第二条把你的 token 写入频道配置,第三条重启网关让配置生效。整个过程不超过五分钟。

真实上手效果

配置完成后,打开手机 QQ 直接和机器人说话就行,和普通聊天窗口没有任何区别。

问它"我现在有什么 agent",它会把 openclaw.json 里登记的所有 Agent 及其当前状态一次列出来,还会告知各 Agent 之间的通信是否已启用。

2c403f2be115f7b8c368884fca0b4bad

更有意思的是,它能直接操作你本机的文件系统。让它列出桌面 video 目录里有什么文件,它会按时间分组整理成一张表格返回给你——比如 32 个 .MOV 原始视频文件,按 2024 年、2025 年 1 月……逐年分组,每个文件名和体积一目了然,总计约 51.5 GB。

f8bf698675db3653529ac084d3d725fb

如果你进一步说"帮我截第 1 秒、第 5 秒、第 10 秒、第 14 秒的画面",它会直接调用本地工具提取视频帧,以 4K 原始分辨率(3840×2160)输出截图,保存到桌面的 frames 文件夹,然后把截图一张一张发回给你。

b44b8a9c44b816616cd784e4ada860bd

31f7d731b84e17995d10c99c10e12def

你说"把图片发给我,我要看",它就把文件复制到桌面再通过 QQ 直接发送过来,不需要你去文件管理器里翻。

2fd5481de03b4d885197ddb6f482b443

这些操作全程没有打开任何 App,只是在 QQ 聊天框里说了几句话。

OpenClaw 是什么

OpenClaw(曾用名 ClawdbotMoltbot)是一款开源 AI 智能体框架,由程序员彼得·斯坦伯格(Peter Steinberger)开发,核心语言为 TypeScript,采用标志性的"蓝色龙虾"图标设计。它的 slogan 是 "The AI that actually does things",意思是真正能干活的 AI。

与传统对话式 AI 不同,OpenClaw 不是只能回答问题的聊天机器人,而是能够执行任务的数字员工。它通过自然语言指令驱动,可在本地或私有云环境中完成文件管理、代码执行、网页抓取、API 调用等操作。

image.png

用一句话概括,它实现的是从"建议"到"执行"的跨越。

几个值得关注的细节

一个 QQ 号最多可以创建 5 个独立的 QQ 机器人。

绑定至 OpenClaw 环境后,机器人支持接收和发送 Markdown、图片、语音、文件等多种格式的消息,手机端 QQ 和桌面端 QQ 均可正常使用,不限终端。

另外,OpenClaw 的工具调用具备一定的容错能力。当某个工具不可用时,它会自动降级切换到备用工具。例如搜索插件不可用时,它会调用浏览器工具直接打开网站抓取内容,整理好再返回给你,整个过程无需人工干预。

OpenClaw 能帮自媒体人做什么

对自媒体从业者来说,OpenClaw 能覆盖的场景远不止联网搜索。从内容生产到数据复盘,它可以接管那些重复、耗时却省不了的环节。

image.png

目前能直接上手的场景包括:

  • 公众号写作,从选题调研到完整初稿,按模板排版,两小时内从灵感到成品
  • 小红书爆款,自动学习你的语气风格,一次给出 5 个备选标题
  • 视频内容拆解,转写文字稿、提取金句字幕、生成话题标签和简介文案,自动适配多个平台
  • 素材批量处理,一次处理 50 个文件,批量改名、自动归类
  • 品牌合作跟进,整理合作邀约信息,不让 offer 漏掉
  • 数据复盘报告,汇总阅读量和互动率,告诉你本周内容的表现和下一步方向

这些事情放在以前,要么手动一件一件做,要么需要在多个独立工具之间来回切换。现在通过自然语言指令,交给 OpenClaw 统一处理就行。

e42bf5f8926df2a849630c92eb53c146.png

目前我正在组建 OpenClaw 中文社区,如果你也在探索用 AI 工具提效、或者想一起玩转这套工作流,欢迎添加我的微信 yunmz777,拉你进群交流。

How to Install Git on Debian 13

Git is the world’s most popular distributed version control system used by many open-source and commercial projects. It allows you to collaborate on projects with fellow developers, keep track of your code changes, revert to previous stages, create branches , and more.

This guide covers installing and configuring Git on Debian 13 (Trixie) using apt or by compiling from source.

Quick Reference

For a printable quick reference, see the Git cheatsheet .

Task Command
Install Git (apt) sudo apt install git
Check Git version git --version
Set username git config --global user.name "Your Name"
Set email git config --global user.email "you@example.com"
View config git config --list

Installing Git with Apt

This is the quickest way to install Git on Debian.

Check if Git is already installed:

Terminal
git --version

If Git is not installed, you will see a “command not found” message. Otherwise, it shows the installed version.

Use the apt package manager to install Git:

Terminal
sudo apt update
sudo apt install git

Verify the installation:

Terminal
git --version

Debian 13 stable currently provides Git 2.47.3:

output
git version 2.47.3

You can now start configuring Git.

When a new version of Git is released, you can update using sudo apt update && sudo apt upgrade.

Installing Git from Source

The main benefit of installing Git from source is that you can compile any version you want. However, you cannot maintain your installation through the apt package manager.

Install the build dependencies:

Terminal
sudo apt update
sudo apt install libcurl4-gnutls-dev libexpat1-dev cmake gettext libz-dev libssl-dev gcc wget

Visit the Git download page to find the latest version.

At the time of writing, the latest stable Git version is 2.53.0.

If you need a different version, visit the Git archive to find available releases.

Download and extract the source to /usr/src:

Terminal
wget -c https://mirrors.edge.kernel.org/pub/software/scm/git/git-2.53.0.tar.gz -O - | sudo tar -xz -C /usr/src

Navigate to the source directory and compile:

Terminal
cd /usr/src/git-*
sudo make prefix=/usr/local all
sudo make prefix=/usr/local install

The compilation may take some time depending on your system.

If your shell still resolves /usr/bin/git after installation, open a new terminal or verify your PATH and binary location with:

Terminal
which git
echo $PATH

Verify the installation:

Terminal
git --version
output
git version 2.53.0

To upgrade to a newer version later, repeat the same process with the new version number.

Configuring Git

After installing Git, configure your username and email address. Git associates your identity with every commit you make.

Set your global commit name and email:

Terminal
git config --global user.name "Your Name"
git config --global user.email "youremail@yourdomain.com"

Verify the configuration:

Terminal
git config --list
output
user.name=Your Name
user.email=youremail@yourdomain.com

The configuration is stored in ~/.gitconfig:

~/.gitconfigconf
[user]
name = Your Name
email = youremail@yourdomain.com

You can edit the configuration using the git config command or by editing ~/.gitconfig directly.

For a deeper walkthrough, see How to Configure Git Username and Email .

Troubleshooting

E: Unable to locate package git
Run sudo apt update first and verify you are on Debian 13 repositories. If sources were recently changed, refresh package metadata again.

git --version still shows an older version after source install
Your shell may still resolve /usr/bin/git before /usr/local/bin/git. Check with which git and adjust PATH order if needed.

Build fails with missing headers or libraries
One or more dependencies are missing. Re-run the dependency install command and then compile again.

make succeeds but git command is not found
Confirm install step ran successfully: sudo make prefix=/usr/local install. Then check /usr/local/bin/git exists.

FAQ

Should you use apt or source on Debian 13?
For most systems, use apt because updates are integrated with Debian security and package management. Build from source only when you need a newer Git release than the repository version.

Does compiling from source replace the apt package automatically?
No. Source builds under /usr/local and can coexist with the apt package in /usr/bin. Your PATH order determines which binary runs by default.

How can you remove a source-installed Git version?
If you built from the source tree, run sudo make prefix=/usr/local uninstall from that same source directory.

Conclusion

We covered two ways to install Git on Debian 13: using apt, which provides Git 2.47.3, or compiling from source for the latest version. The default repository version is sufficient for most use cases.

For more information, see the Pro Git book .

Cursor 的 5 种指令方法比较,你最喜欢哪一种?

我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点。

如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。 很多人第一次打开 OpenClaw,会下意识把它当成"接在微信或 Slack 上的聊天机器人"。这种理解只对了一半。从架构上看,OpenClaw 更像一个网关:它站在你和一堆能力之间,负责路由、鉴权、记忆和工具调用。真正决定你能做多少事的,不是对话框有多好看,而是背后接了多少"身体"——也就是 Skills。

在使用 Cursor 时,你会看到很多向 AI 发指令的方式:AGENTS.md.cursor/rules/.cursor/commands/.cursor/skills/.cursor/agents/(子代理)。每种方式适用场景不同,不少人会问:"该用哪一个?怎么区分?" 这篇文章把它们的用法和适用场景做个对比,并给出选择思路和组合建议,方便你按需搭配。

读完后你可以做到三件事:快速判断新需求该用哪种方式、避免把规则写错位置、用一套合理的目录结构起步。

为什么需要多种指令方式

当你希望 Cursor 用 Next.js、TypeScript 做 Web 应用时,往往会加上一堆约定,比如:

  • API 路由必须用 zod 做校验
  • 数据库访问用 Prisma 的类型安全写法
  • 错误统一成固定格式返回

这些规则在一次对话里 AI 还能记住,但新开一个会话又要重新说一遍。原因在于 LLM 本身的限制:

  • 无状态:不会跨会话记忆
  • Token 有限:上下文窗口有上限
  • 成本:上下文越长,调用成本越高

所以我们需要把"要说什么"沉淀成文件,并在合适的时机让 AI 读到。不同文件类型对应不同的触发时机和复用范围,这就是五种方式存在的理由。其中 Skills 专门针对"知识很多但不想一次全塞进上下文"的问题,用渐进式披露来平衡信息量和 Token。

渐进式披露(Progressive Disclosure)的意思是:需要的时候再把相关知识装进上下文,信息分阶段给出去,既够用又不占满上下文。Skills 的三级加载大致如下。

这部分原本用流程图展示三个级别之间的关系,下面是可以直接复制到文生图应用里的提示语。

20260305084854

  • 级别 1(元数据):所有已安装技能的 namedescription 会预先放进系统提示,让代理知道有哪些技能、大概干什么,从而判断和当前任务是否相关。
  • 级别 2(说明):一旦判定相关,再把该技能的 SKILL.md 全文载入,看清具体步骤和指示。
  • 级别 3(资源):若执行任务还需要更多信息,再按 SKILL.md 里的引用去打开其他文件、脚本或资料。

这样可以把大量信息收进技能里,又不会一次性占满 Token,这就是 Skills 的设计思路。

5 种方法概览

下面用几张表把五种方式的基本信息、适用时机和主要用途捋一遍。

基本信息

项目 AGENTS.md Rules Commands Skills Subagents
保存位置 项目根目录 .cursor/rules/ .cursor/commands/ .cursor/skills/ .cursor/agents/
文件格式 AGENTS.md .mdc.md .md 文件夹内 SKILL.md .md
运营重要性 推荐 强烈推荐 任意 推荐 任意
利用频率 较高 极高 中等 中等 较低

适用时机与上下文

项目 AGENTS.md Rules Commands Skills Subagents
适用时机 始终自动 条件、常时或手动 仅手动、通过命令名 AI 自动判断或手动、技能名 AI 自动判断或手动
上下文 与父共享 与父共享 与父共享 与父共享 独立上下文
并行执行 不可 不可 不可 不可 可(多子代理同时跑)

再利用性

项目 AGENTS.md Rules Commands Skills Subagents
再利用性 项目内(单体仓库可在子目录各配一份) 项目内 项目内 可跨项目(可全局配置) 可跨项目(可全局配置)

主要用途

项目 AGENTS.md Rules Commands Skills Subagents
主要目的 项目整体方针 对 AI 的持续指示 特定作业的一键启动 专业能力扩展 任务委托与并行
适合场景 简单、整体的指示 需要按条件生效的规则 人工触发的固定流程 通用领域知识 长时间调研、并行处理
示例 编码风格、架构原则 某目录下的规则 /code-review GraphQL 设计最佳实践 安全审查、验证作业

如何快速选:一张流程图

遇到新需求时,可以按一张决策流程图先判断用哪种方式,再去看对应章节的细节。下面是给文生图应用使用的提示语。

20260305084956

若一个需求同时符合多种方式(例如既有"始终生效的简略约定"又有"按目录生效的细则"),可以组合使用,例如根目录 AGENTS.md 写总纲,再用 Rules 针对 src/components/ 写组件规范。

AGENTS.md:项目的「README 式总纲」

AGENTS.md 是五种方式里最省事、门槛最低的一种,可以理解成「写给 Cursor 看的项目 README」。只要在项目根目录放一个 AGENTS.md,当你在这个项目里向 Cursor 提问时,它会自动把这里的内容当成项目的默认约定和背景信息。

在 Cursor 里的效果大致如下:

20260227142857

上面这张图对应的实际配置非常简单:我只是在项目根目录新建了一个 AGENTS.md,里面写清楚「开发或调试前要先跑哪几个命令」:

## 项目开发指令(AGENTS.md)

每次开发或调试前,请按顺序执行以下三个命令:

1. `pnpm dev`:启动开发服务器。
2. `pnpm build`:打包生产环境构建,确保构建无误。
3. `pnpm view react version`:查看当前 npm 注册表中的 React 最新版本号。

之后在这个项目里,只要我随手问一句「帮我优化一下这段代码」之类的问题,Cursor 在后台都会先读取这个 AGENTS.md,于是你在右侧看到的,就是「先按这三个步骤来」这种带项目语境的回答——相当于让 AGENTS.md 帮你把团队约定自动说了一遍。

特点:

  • 零配置:根目录放一个文件即可生效,不需要再写任何额外设置
  • 可读性好:纯 Markdown,适合写简洁、叙事化的项目方针和协作约定
  • 可分区:单体仓库可以在子目录各放一份(例如 frontend/AGENTS.mdbackend/AGENTS.md),分别约束前端、后端
  • 始终生效:对整个项目长期生效,适合放「总纲型」规则,而不是细碎的按场景说明
  • 易维护:内容有变更时,直接编辑本文件即可,无需动其他配置

下面是一个简化示例,用来说明"项目指令"长什么样、一般会写哪些内容:

# Project Instructions

## Code Style

- 使用 TypeScript
- 优先函数型组件

## Architecture

- 使用 Repository 模式
- 业务逻辑放在服务层而不是组件里

适合用 AGENTS.md 的情况:只需要一份项目级总纲,不强调按条件生效,也暂时不考虑跨项目复用,只想用最少配置写清楚项目整体约定和团队共识。一旦要"某些情况才生效"、"手动触发一套流程"或"多个项目共用同一套知识",就该考虑 Rules、Commands 或 Skills。

另外要注意:AGENTS.md 的内容会在每次相关对话里加载,写得太长会一直占用上下文。更细碎、按场景区分的规则,更适合拆到 Rules 或 Skills 里去。

规则(Rules):细一点、灵活一点的背景指令

当规则变多、需要按条件生效,或者想针对某些文件、目录单独约定时,就用 Rules。它可以按路径、glob 或"是否常驻"来选什么时候生效,还能在规则里用 @filename 引用具体文件。

特点:

  • 适用条件可配置:常时、特定文件、由 AI 判断或手动选
  • 支持在规则里引用代码,例如 @component-template.tsx
  • 多个规则文件放在 .cursor/rules/ 里,便于分类
  • 团队可在仪表板统一管理

Rules 的 frontmatter 里常用字段有:description(规则说明,供 AI 或人工识别)、globs(匹配哪些路径)、alwaysApply(是否常驻)。不写 globsalwaysApply: true 时,规则会在所有对话里生效。

例如给 React 组件单独定一套规则,只在编辑 src/components/ 下的 .tsx 时生效。在 .cursor/rules/react-components.mdc 里写 frontmatter 和正文。下面先给出 YAML 头,用于指定描述和匹配范围:

---
description: "React 组件开发的规则"
globs: ["src/components/**/*.tsx"]
alwaysApply: false
---

同一文件内,frontmatter 后面接 Markdown 正文,写具体规则内容并引用模板文件:

# React Component Rules

## 组件创建时

- 必须定义 Props 接口
- 避免默认 export
- 参考 @component-template.tsx

这样在编辑 src/components/ 下文件时,这条规则才会被应用。若希望某条规则在任意对话里都生效(例如全局编码风格),可设 alwaysApply: true 且不设 globs

命令(Commands):一键跑完的流程

Commands 用来把"一串固定操作"打包成一个命令,由你手动触发,比如跑测试、做 code review、建 PR。每个命令对应一个文件,通过 /命令名 调用。

特点:

  • 通过 / 即调即执行
  • 可以带参数,例如 /commit/pr for DX-523
  • 团队可在仪表板共享同一套命令
  • 把多步操作写在一个命令里,减少重复说明

例如 /code-review:在 .cursor/commands/code-review.md 里定义步骤,之后在聊天里输入 /code-review 就会按这些步骤执行。下面给出该文件的示例内容,用于说明"命令文件"如何写步骤:

# /code-review

## 步骤

1. 确认变更的文件
2. 检查安全问题
3. 确认是否符合编码规范
4. 列出 3 个改进提案

使用方式:在输入框输入 /code-review,必要时加一句说明,例如:"请审查这个 PR"。

Commands 适合:测试、代码审查、建 PR 这类需要人工点一下才跑的固定流程。不适合:希望自动生效、一直挂在上下文里、或由 AI 自己决定何时用的规则与知识,那些更适合 AGENTS.md、Rules 或 Skills。

和子代理的区别:Commands 是在当前对话里"插入一段预设步骤",上下文还是主对话;子代理是另开一个独立对话去执行,结果再回传,适合耗时长或需要并行的任务。

技能(Agent Skills):可复用的专业知识包

Agent Skills 把某一类专业知识打成一个个「能力模块」,用开放标准格式(含 SKILL.md 和 YAML 头)描述。AI 先只知道有哪些技能(name / description),真正需要时再把对应技能的全文与资源加载进来,这就是 Skills 的渐进式、模块化加载。

特点(和 Rules 的区别可以顺便看出来):

  • 按知识主题模块化:每个 Skill 代表一块专业能力(如组件命名规范、GraphQL 设计),可以在多个项目里复用
  • 按需加载:所有技能只先加载元数据,只有和当前任务相关的那几个才会把 SKILL.md 正文装进上下文,后续再按需加载引用资源
  • 规则 vs 技能:Rules 是按「当前在哪个文件 / 路径」选出要生效的规则,一旦命中就整条规则全文进上下文;Skills 则是先全局感知有哪些技能,再对当前任务只挑相关的那几块知识细化加载
  • 可组合脚本:Skill 里还可以挂脚本,把 LLM 不擅长的步骤交给代码执行

实现上,每个技能是一个文件夹,里面至少有一个 SKILL.md,并在文件开头用 YAML 定义 namedescription。Cursor 启动时会扫描技能目录(例如 .cursor/skills/),先把所有技能的元数据提供给代理,之后在具体任务里再决定要不要加载某个技能的正文。

下面直接用本文正在使用的真实示例来说明 Skill 的长相:我在这个项目里新建了 .cursor/skills/moment-component-prefix/SKILL.md,约定所有 React 组件的名字都以 Moment 开头(例如 MomentButtonMomentCard)。完整内容如下:

---
name: Moment 组件命名规范
description: 要求本项目中所有 React 组件名称都以 Moment 开头,例如 MomentButton、MomentCard。
---

# Moment 组件命名技能

## 何时使用

- 在创建新的 React 组件时
- 在重命名或抽取组件时

## 指示

- 所有导出的 React 组件名称必须以 `Moment` 开头,例如 `MomentHeader``MomentFooter`- 若根据文件名生成组件,组件名也应以 `Moment` 开头,例如文件 `header.tsx` 中的组件名为 `MomentHeader`- 执行重构时,如遇不符合该前缀的组件,应优先建议重命名为带 `Moment` 前缀的版本。

配置好这个 Skill 之后,当我在项目里请求「优化组件」「重构组件名」时,Cursor 就会自动参考这份规则,优先给出带 Moment 前缀的组件名称。下图展示的是这个 Skill 实际生效时的效果:

20260227144513

子代理(Subagents):只干一件事的副手

子代理可以理解成「由主代理派出的专职小助手」。每个子代理都有独立上下文,专注处理某一类任务,做完以后把结果打包交回主代理;主代理再根据这些结果继续和你对话或接着修改代码。

模块化的角度看,子代理不是按「路径」或「知识主题」来拆,而是按任务角色来拆:比如「验证助手」「安全审查助手」「UI 回归助手」等,让每个子代理只干一件事、长期保持同一种工作风格。

特点:

  • 上下文隔离:子代理有自己的对话历史,不会把长时间验证 / 调研的细节塞进你的主对话里
  • 可并行:可以同时开多个子代理,让它们各自跑不同任务,再统一收结果
  • 可定制:每个子代理可以配自己的提示词、工具组合和模型类型
  • 可复用:同一子代理配置可以在多个项目共享,比如统一的安全审查或验证流程

调度方式大致有两种:

模式 行为 适合场景
Foreground 主代理等子代理跑完再继续对话 需要按顺序拿到输出的任务(例如「先验证再继续开发」)
Background 立刻把控制权还给你,子代理在后台慢慢跑 耗时长或希望并行多任务时(例如「一边写代码,一边让子代理做全面安全审查」)

需要注意的是:子代理从「空上下文」开始,看不到主对话历史。所以主代理在派活时,必须把当前任务描述、涉及的文件、期望的检查点等一起打包进提示,否则子代理只好猜,很容易跑偏。

下面是本文当前项目里真实在用的一个「验证」子代理配置,文件路径是 .cursor/agents/verifier.md,主要负责在改动完成后帮忙做一次系统性的检查与建议:

---
name: verifier
description: 验证已完成的工作,检查实现是否按预期运作,并结合本项目约定给出测试与改进建议。
---

# Verifier 子代理

你是本项目的验证助手,专门在实现完成后进行检查与验证。你有独立上下文,不会看到主对话历史,所有必要信息会由主代理在派发任务时提供给你。

## 目标

- 确认实现是否满足用户需求与设计意图。
- 检查是否存在明显的类型错误、运行时错误或边界情况缺失。
- 结合本项目使用的工具链(pnpm、React、TypeScript)给出合理的测试与改进建议。

## 使用背景

- 本项目使用 pnpm 作为包管理工具。
- 运行开发与构建相关的典型命令包括但不限于:
  - `pnpm dev`
  - `pnpm build`
  - (如存在)`pnpm test``pnpm lint`

## 验证步骤

1. **理解需求与范围**
   - 阅读主代理提供的任务描述与变更说明。
   - 弄清楚本次改动的功能边界与非目标范围。

2. **审查代码与结构**
   - 聚焦主代理列出的关键文件和模块。
   - 检查是否遵循项目的技术栈约定(React + TypeScript 等)。
   - 粗略评估实现是否过于复杂,是否可以简化。

3. **测试与构建建议**
   - 若主代理提供了命令输出(如 `pnpm build``pnpm test`),分析其中的错误、警告与提示。
   - 若尚未执行相关命令,明确建议主代理或用户执行:
     - 至少运行 `pnpm build` 以确认生产构建是否通过。
     - 若项目配置了测试与 Lint,建议运行 `pnpm test``pnpm lint`4. **边界条件与错误处理检查**
   - 思考与本次改动相关的典型边界情况(空数据、错误响应、慢网路、异常输入等),检查代码中是否有所体现。
   - 指出潜在的未处理情况或容易出错的分支,并给出改进方向。

5. **输出结构化报告**
   - 列出:
     - ✅ 已验证通过的项目(功能点、测试或构建检查)。
     - ⚠️ 发现的问题(含严重程度说明)与改进建议。
     - ❓ 需要主代理或用户补充的信息(例如缺失的日志、命令输出、接口约定等)。
   - 报告尽量简洁、条理清晰,便于主代理直接据此继续修改或补充验证。

## 与主代理的协作约定

- 若信息不足以完成可靠验证,请明确说明缺口,并请求主代理补充必要上下文,而不是进行过度臆测。
- 在可能的情况下,优先给出可操作的、一步步的改进建议,而不是泛泛而谈。

实际使用时,你可以在主对话里完成一轮实现,然后让主代理把相关改动、运行命令和期望行为打包交给 verifier 子代理,让它在独立上下文里跑完整套验证,并把结果以结构化报告的形式带回主对话。这样既不污染主对话的上下文,又把「验证这件事」模块化成了一个可以在多个项目反复复用的助手。

实际使用示例:React 项目里的五种用法

同一个需求"在 React 项目里统一组件开发方式",可以分别用五种方式实现。下面按"从简到繁、从项目内到可复用"的顺序各给一个写法,便于对比同一诉求在不同方式下的形态。

方式 1:AGENTS.md(最简)

在项目根目录的 AGENTS.md 里写一段即可,适合小项目或只做简单约定时使用。

# Project Instructions

## React 组件开发

- 使用 TypeScript
- 优先函数型组件
- Props 必须进行类型定义

方式 2:Rules(按目录生效)

希望规则只在改 src/components/ 时生效,可以用 Rules。在 .cursor/rules/react-components.mdc 里写 frontmatter 和正文,例如引用模板文件:

---
description: "React 组件开发的规则"
globs: ["src/components/**/*.tsx"]
alwaysApply: false
---

# React 组件规则

## 组件创建时

- 必须定义 Props 接口
- 避免默认 export
- 参考 @component-template.tsx

方式 3:Commands(手动执行一套步骤)

把"创建组件"拆成固定步骤,做成命令 /create-component。在 .cursor/commands/create-component.md 里写:

# /create-component

# 步骤

1. 创建组件文件
2. 定义 Props 接口
3. 创建测试文件

使用:输入 /create-component Button,即可按步骤生成组件并带测试。

方式 4:Skills(可复用的最佳实践)

把 React 组件的最佳实践打成技能,AI 在写组件或做 code review 时会按需加载。例如 .cursor/skills/react-best-practices/SKILL.md

---
name: React Best Practices
description: React 组件开发的最佳实践。重视性能优化、重渲染防止、Hooks 的适当使用。
---

# React Best Practices 技能

## 何时使用

- 创建或修正 React 组件时
- 需要性能优化时
- 使用 Hooks 时
- 解决重渲染问题时

## 指示

### 组件设计

- 自定义 Hook 使用 `use` 前缀
- Props 接口必须进行类型定义
- 组件遵循单一责任原则

### 性能优化

- `useMemo``useCallback` 仅在必要时使用
- `useEffect` 的依赖数组必须明确指定
- 对于大型列表,考虑使用虚拟化

### 重渲染防止

- `memo` 仅在必要时使用(避免过度优化)
- Context 的值适当进行 Memoization
- 识别不必要重渲染的原因

适合:希望按"行业常见实践"自动参与编写和审查,且可能多个项目共用时。

方式 5:子代理(独立验证)

验证和测试单独交给子代理,不占用主对话上下文。例如 .cursor/agents/verifier-reviewer.md

---
name: verifier
description: 验证已完成的工作,确认实现是否正常运作,并执行测试
---

# Verifier 子代理

此子代理验证已完成的工作,确认实现是否正常运作,执行测试,并报告成功和未完成的部分。

## 验证步骤

1. 确认已实现的代码
2. 执行单元测试
3. 执行集成测试
4. 检查错误或警告
5. 报告结果

适合:做完一坨改动后,希望单独跑一轮验证或测试,而不把主对话拉得很长时。

小结:若只做"项目内、始终生效的简单约定"用方式 1;若希望"只在改组件目录时生效"用方式 2;若希望"人工点一下才按步骤生成组件"用方式 3;若希望"多项目共用、且 AI 写组件或审查时自动参考"用方式 4;若希望"验证和测试在独立上下文里跑"用方式 5。实际项目中往往组合使用,例如 1 + 2 + 4,或 1 + 3 + 5。

再举一个安全审查子代理的例子,放在 .cursor/agents/security-reviewer.md

---
name: security-reviewer
description: 检查代码中的注入、XSS、硬编码秘密等常见漏洞
---

# Security Reviewer 子代理

您是安全专家。执行代码的安全审查,识别潜在漏洞。

## 检查项目

1. SQL 注入
2. XSS(跨站脚本攻击)
3. 硬编码秘密
4. 认证和授权问题
5. 遵守安全的编码实践

从零开始的建议顺序

如果项目里还没用过这些方式,可以按这个顺序逐步加,避免一次堆太多导致维护成本高:

  1. 先写一个根目录的 AGENTS.md,把项目技术栈、编码风格、目录约定等总纲写清楚,控制在几十行以内。
  2. 再按目录或文件类型加 Rules,例如 src/components/ 用组件规则、src/api/ 用 API 规则,每条规则保持单一职责。
  3. 把经常重复的"多步操作"抽成 Commands,例如 /code-review/run-tests,方便团队统一流程。
  4. 若有跨项目共用的领域知识(如 GraphQL、无障碍、K8s),再做成 Skills,安装到 .cursor/skills/ 或全局技能目录。
  5. 子代理用在"需要独立上下文或并行"的场景即可,不必每个项目都配。

一个常用的项目配置结构

下面是一套常见的组合方式,按目录列出来,方便你直接套用或裁剪。如果希望用图来展示整个项目结构,可以使用下面这段提示语生成信息图。

手绘风格教育科普信息图海报,竖版 3:4 比例,白色或浅米色背景,彩色铅笔与素描线条质感,温暖柔和蓝黄粉绿橙配色,整体风格类似儿童编程科普图。顶部标题「一个典型 Cursor 项目的配置结构」,副标题「AGENTS、Rules、Commands、Skills、Subagents 分布一图看懂」。
画面中央是一棵抽象的项目目录「树」或卡片式结构:最上方是大文件夹「项目根目录」,内部画 `AGENTS.md` 文件卡片,旁边小标签「项目整体方针」。向下分出两条分支,分别是 `frontend/AGENTS.md` 和 `backend/AGENTS.md`,用不同颜色表示前端与后端,旁边写「前端指令」「后端指令」。
根目录下画 `.cursor` 大文件夹,内部再分为四个子文件夹:`rules`、`commands`、`skills`、`agents`,每个子文件夹用不同颜色和图标表示:
rules 区块下有 `api-design.mdc`、`database-schema.mdc`、`deployment-flow.mdc` 三个文件卡片,配注释「API 设计规则」「数据库设计规则」「部署流程」;
commands 区块下有 `code-review.md`、`create-pr.md`、`run-tests.md` 三个文件卡片,标注「代码审查命令」「创建 PR」「运行测试」;
skills 区块下有 `react-best-practices/` 文件夹和若干 `SKILL.md` 文件,如 `graphql-best-practices/SKILL.md`、`kubernetes-ops/SKILL.md`、`accessibility/SKILL.md`,配小图标代表前端、后端与运维知识,旁边写「可复用技能包」;
agents 区块下有 `verifier.md` 和 `security-reviewer.md` 两个文件卡片,配放大镜与盾牌图标,分别标注「验证子代理」「安全审查子代理」。
用细虚线或颜色分区轻轻框出「项目总纲(AGENTS)」「项目内规则(Rules)」「一键流程(Commands)」「领域技能(Skills)」「子代理(Agents)」五块区域,每块区域顶部有小标题和简短说明,整体布局紧凑清晰,方便一眼看懂各类文件放在哪里、负责什么。

常见误区和组合建议

  • 把本该"按条件生效"的细则写进 AGENTS.md,导致上下文总是很长。这类内容更适合放到 Rules(按路径)或 Skills(按任务类型)。
  • 把"希望 AI 自动用到"的领域知识只写在 Commands 里。Commands 只有你输入 /xxx 时才会执行,不会自动参与编写或审查,这类知识应放在 Rules(项目内)或 Skills(可复用)。
  • 同一件事既写 Rules 又写 Skills,内容重复且可能冲突。约定好边界:和本项目、本目录强相关的用 Rules,通用且要复用的用 Skills。
  • 子代理的提示里没带够上下文。子代理看不到主对话,主代理在派活时必须把"当前改了哪些文件、期望验证什么"等写进提示,否则子代理容易做无用功。

组合建议:多数项目用"根目录 AGENTS.md + 若干 Rules"就能覆盖大部分需求;Commands 按团队实际工作流补几条即可;Skills 和 Subagents 按是否有跨项目知识、是否有并行或独立验证需求再加。这样既不会漏掉该用的方式,也不会堆得难以维护。

总结一下选择思路:要"项目总纲"用 AGENTS.md;要"按文件或条件生效"用 Rules;要"人工一键跑流程"用 Commands;要"可复用的领域知识"用 Skills;要"独立上下文、并行或专门验证"用 Subagents。按需求组合这五样,就能把 Cursor 的指令体系用得比较顺手。

JitWord 2.3: 墨定,行远

今天,我们宣布推出 JitWord AI文档 2.3版本。

图片

在持续两年的研究和技术难点攻克下,我们取得了如下成果:

  • 实现了高效的Word在线协同编辑能力
  • 实现了高效的Excel在线协同编辑能力
  • 实现了业内领先的Docx/PDF高精度导入导出能力
  • 实现了Office办公套件的嵌入和预览(Word,PDF, PPT, Excel)
  • 实现了国产化环境兼容适配(本地部署安全可靠)
  • 支持多端适配和编辑(PC,移动, IPad等设备)
  • 兼容市面上所有主流AI模型,并研发设计了AI Native组件,全面打造智慧办公场景
  • 多模态能力(图文/音视频/思维导图/图表/电子签名等)
  • 实现了高性能文档渲染引擎(支持50W字超大文档渲染,目前还在持续优化)
  • 实现了复杂的数学公式渲染引擎(支持导出为word可编辑的公式)

 当然我们的目标是全面对齐 Office,并基于AI Native 的设计理念,打造国产化的 AI Office 解决方案。同时我们是开源了一个基础版的SDK(v1.0版本),供大家直接本地调用:

图片我们的目标是为全球的科研人员、企业和组织赋能,帮助他们利用我们的前沿解决方案和AI能力构建安全可靠,符合企业自身需求的创新协同AI办公解决方案。

github地址: github.com/MrXujiang/j…

体演示地址:jitword.com

接下来我就和大家分享一下 JitWord 2.3 版本带来的新功能。

一、电子签名功能:数字化时代的"最后一公里"

去年我们团队在做用户调研时,遇到一个令人意外的场景。

某建筑设计公司的项目经理李哥向我们吐槽:"我们用你们的 jitword 写方案、改图纸备注都很爽,但每到签合同环节,就得全部打印出来,手写签字,再扫描回传。一套流程下来,半天没了,纸摞得比字典还厚。"

这番话让我们愣住了。

在 All-in-Digital 的今天,我们实现了云端协作、AI辅助写作、多人实时编辑,却在最原始的"确认"环节卡了壳。

电子签名这个看似简单的功能,成了文档数字化流程中的"断点"。

更让我们震惊的是数据:据我们抽样调研,73%的企业用户仍在使用"打印-签字-扫描"的传统模式处理合同和确认文件,平均每周浪费4.6小时在这类机械操作上。

这不是技术问题,这是体验设计的失职。

那一刻我们决定:JitWord 2.3 必须解决这个"最后一公里"问题。而且,不能做成简单的图片贴入,要让它真正可用、好用、让人愿意用

于是我们研发并上线了电子签名组件:

图片

大家可以在 jitword 编辑器的插入分类下使用电子签名,插入到文档的效果如下:

图片

我们在用户体验和界面设计上做了大量的优化,保证用户能以最好的体验使用这个功能。

大家可以在文档的任何位置插入电子签名,并且能一键导出为PDF和Docx文件,直接用于合同等场景的打印流程:

图片

二、分栏布局:回归文档的"阅读本质"

图片

如果说电子签名解决的是"出口"问题,那么分栏功能解决的就是"呈现"问题。

2.1 为什么传统文档编辑器"不好看"?

长期以来,Web文档编辑器有个通病:它们更像是"网页"而不是"文档"。单栏通顶的布局适合屏幕阅读,但一旦需要打印成册、制作手册、设计简报,就显得笨拙不堪。

我们观察到一个趋势:越来越多的用户把jitword当作轻量级排版工具使用。市场部门做产品手册,教研组编试卷,律师团队整理证据目录...他们不需要InDesign的专业,但Word的分栏功能又总让他们在Web和桌面软件之间来回切换。

2.2 技术实现:浏览器里的"排版引擎"

分栏功能看似简单,在Web技术栈里却是个硬骨头。

浏览器的流式布局天生是单栏的,要实现像LaTeX那样的专业分栏,需要重写文本流算法。我们团队花了两个月时间,基于CSS Columns规范做了深度定制:

  • 智能断栏:避免标题孤行、段落割裂,确保阅读连贯性
  • 图文混排:图片跨栏、文字环绕的像素级精准控制
  • 动态平衡:根据内容长度自动调整栏高,告别"最后一栏空荡荡"
  • 打印还原:屏幕所见即打印所得,解决Web文档打印走样的顽疾

特别值得一提的是分栏协同编辑的难点。当两个用户同时编辑不同分栏的内容时,光标定位、选区计算、冲突合并的复杂度呈指数级上升。

图片

我们重构了底层的 CRDT 算法,确保分栏场景下的协同体验与单栏一样流畅。

2.3 应用场景:从"能用"到"好用"的跃迁

现在,jitword 的分栏功能已经成为一些用户的"秘密武器":

  • 教育行业:老师用双栏排版制作试卷,左栏题干右栏答题区,直接导出印刷
  • 法律行业:律师用三栏整理证据清单,证据编号、内容摘要、页码索引一目了然
  • 市场营销:运营用混栏设计制作产品白皮书,图文穿插,专业度不输设计公司

三、表格多人协同:复杂数据的"共舞"方案

图片

3.1 被低估的协同场景

表格,是文档中最复杂的数据结构,也是协同编辑的"雷区"。

传统方案要么采用"锁定整表"的保守策略(一个人改,其他人看),要么"自由混战"(最后保存的人覆盖一切)。前者效率低下,后者数据灾难。

在 jitword 2.3中,我们实现了单元格级细粒度协同——这是技术架构上的重大突破。

3.2 技术架构:从"文档"到"数据"的视角转换

要实现真正的表格协同,必须改变底层思维:把表格不再视为"文档的一部分",而是视为嵌入式数据库

我们的技术方案包含三个层次:

第一,结构层解耦。  表格的每个单元格都是独立的数据对象,拥有唯一的CRDT(无冲突复制数据类型)标识。这意味着A用户在改A1单元格,B用户在改B2单元格,两者完全隔离,互不阻塞。

第二,冲突层智能。  当两人同时修改同一单元格时,系统不是简单"后覆盖前",而是基于语义合并策略:如果是数值,做算术合并;如果是文本,做差异对比;如果是公式,重新计算依赖链。冲突解决过程可视化呈现,用户可选择接受哪个版本或手动合并。

第三,感知层细腻。  我们设计了"单元格 occupancy"机制:当某人正在编辑某单元格,该单元格边缘会显示其头像呼吸灯,其他人点击时会收到友好提示"某某正在编辑,是否加入协作?"。这种"软阻塞"既避免了冲突,又保留了灵活性。

3.3 真实场景:一场没有"等等我"的会议

想象一下这个场景:周五下午,财务、销售、运营三个部门负责人要赶在下班前确认Q3预算表。以前,他们需要:

  1. 各自填好Excel分表
  2. 发给财务汇总
  3. 发现数据对不上,群里@来@去
  4. 修改,再发,再核对...
  5. 三小时后,终于搞定

现在,大家在 jitword 中打开同一张表格,各自在自己负责的栏目实时填写,公式自动计算,批注即时可见,有疑问直接@相关人在单元格内讨论。20分钟,预算表确认完毕,直接签名定稿。

这不是未来场景,这是 jitword 2.3 用户的日常。

四、价值重构:我们到底在做什么?

写到这里,我想停下来回答一个根本问题:jitword 2.3的这三个功能,到底创造了什么价值?

效率价值:时间的复利

电子签名节省的"打印-签字-扫描"流程,按每次30分钟、每周3次计算,一年就是78小时,相当于10个工作日;

分栏功能节省的软件切换和格式调整时间;

表格协同节省的汇总核对时间...这些碎片时间积累起来,是组织效率的复利增长。

体验价值:心流的守护

更重要的是认知成本的降低。当工具不再打断你的工作流——不需要为了签个字打开另一个系统,不需要为了排个版导出到另一个软件,不需要为了合个表发无数封邮件——你就能保持专注,进入心流状态。这种"不卡顿"的体验,是数字化办公的稀缺品。

信任价值:数字的确定性

电子签名的法律效力、协同编辑的版本可追溯、分栏排版的所见即所得,共同构建了一种数字确定性

在远程办公常态化的今天,这种确定性是团队协作的基石。我们知道谁在什么时候做了什么修改,我们知道这份文件被谁确认过,我们知道打印出来和屏幕上看到的一样——这些"知道",就是信任。

写在最后

我们团队的一个共识:最好的技术,是让人感受不到技术的存在,却能感受到人的温度。

电子签名的笔迹,是承诺的温度;分栏排版的精致,是专业的温度;表格协同的流畅,是协作的温度。

JitWord 2.3 不是功能的堆砌,是我们对"文档应该是什么样"的持续思考。在这个AI重构一切的时代,我们选择先做好人与文档、人与人之间的连接

如果大家也厌倦了工具的割裂、流程的繁琐、协作的摩擦,欢迎体验 jitword 。我们相信,好的工具,会让你重新爱上工作本身。


关于JitWord

JitWord 是面向企业的下一代协同AI文档平台,致力于让文档创作更智能、协作更流畅、知识更有序。

2.3版本现已全面上线,访问官网即可体验电子签名、分栏排版、表格协同等全新功能。

pxcharts Ultra V2.3更新:多维表一键导出 PDF,渲染兼容性拉满!

最近粉丝咨询最多的问题莫过于 pxcharts 多维表是否能导出PDF的能力了。

图片

说实话,我回避了很久。浏览器打印引擎差异大,中文渲染、分页断行、复杂表格适配...每个都是坑。

直到上个月,一个做财务的朋友跟我吐槽:月底导报表,调格式调到凌晨2点。我决定,这功能必须上。

于是在1周的设计和研究下,终于实现了多维表导出PDF的功能。

演示如下:

图片

导出后的PDF文件预览效果:

图片

演示地址:pxcharts.com

开源版:github.com/MrXujiang/p…

接下来和大家分享一下详细的功能技术实现。

Pxcharts多维表导出PDF功能技术实现

支持将表格数据导出为 PDF 格式,便于用户打印、存档和分享,核心需求包括:

  • 保持表格结构和样式
  • 支持分页(避免行被截断)
  • 支持封面页(统计信息)
  • 状态标签着色
  • 横向/纵向布局可选

技术选型

为了实现这个方案,我们的核心依赖如下:

依赖 版本 用途
jspdf latest 生成 PDF 文件
html2canvas latest 将 HTML 渲染为 Canvas 图像

选型理由

为什么选择 html2canvas + jsPDF?原因如下:

  1. 纯前端实现无需后端服务,保护数据隐私
  2. 样式可控通过 CSS 精确控制 PDF 外观
  3. 兼容性好支持现代浏览器
  4. 生态成熟社区活跃,文档完善

为什么不直接用 jsPDF 的表格 API?

  • jsPDF 的 autoTable 插件对复杂样式支持有限
  • 自定义样式(状态标签着色、交替行背景)实现困难
  • html2canvas 可以复用现有的 HTML/CSS 样式

实现架构

整体流程我这里设计如下:

表格数据
    ↓
生成 HTML(按页)
    ↓
html2canvas 渲染为 Canvas
    ↓
Canvas 转 PNG 图像
    ↓
jsPDF 写入 PDF(每页一张图)
    ↓
下载 PDF 文件

分页策略

关键问题:如何避免表格行在分页时被截断?

我的解决方案:按行预分页

  1. 估算每行高度(约 36px)
  2. 计算每页可容纳行数:rowsPerPage = floor((pageHeight - headerHeight) / rowHeight)
  3. 按行数切分数据,每页独立渲染
  4. 每页都包含表头,方便阅读
const estimateRowHeight36// 每行大约 36px
const headerHeight60// 表头高度
const pageContentHeightPx = Math.round(contentHeight / scale)
const rowsPerPage = Math.floor((pageContentHeightPx - headerHeight) / estimateRowHeight)

// 分页
for (let i0; i < records.length; i += rowsPerPage) {
const pageRecords = records.slice(i, i + rowsPerPage)
  pages.push(renderDataPage(pageRecords, i))
}

核心代码解析

1. 动态导入(SSR 兼容):

const [{ default: jsPDF }, { default: html2canvas }] = awaitPromise.all([
import("jspdf"),
import("html2canvas"),
])

原因jspdf 和 html2canvas 依赖浏览器 API(如 documentwindow),在 Next.js SSR 阶段会报错。使用动态导入确保只在客户端执行。

2. 页面尺寸计算:

const pageDimensions = {
a4: { width: 595, height: 842 },  // pt 单位
a3: { width: 842, height: 1191 },
}

const pdfWidth = orientation === "landscape"
  ? pageDimensions[pageSize].height
  : pageDimensions[pageSize].width

注意:jsPDF 使用 pt(点)作为单位,1pt = 1/72 英寸。

3. HTML 生成

数据页结构这里我预设如下

<divstyle="width:1122px;padding:32px;box-sizing:border-box;background:#fff">
<tablestyle="width:100%;border-collapse:collapse">
<thead><!-- 表头 --></thead>
<tbody><!-- 数据行 --></tbody>
</table>
</div>

关键样式

  • width:1122px固定 canvas 宽度(A4 横向像素)
  • border-collapse:collapse合并表格边框
  • white-space:nowrap防止文本换行

4. Canvas 渲染

const canvasawaithtml2canvas(element, {
scale2,              // 2倍缩放,提高清晰度
useCORStrue,         // 允许跨域图片
allowTainttrue,      // 允许污染 canvas
backgroundColor"#ffffff",
loggingfalse,
})

参数说明

参数 说明
scale: 2 2倍分辨率,PDF 更清晰
useCORS 处理跨域图片(如附件预览图)
allowTaint 允许 canvas 被污染(某些图片需要)

5. PDF 写入

const imgData = canvas.toDataURL("image/png"1.0)
const imgWidth = contentWidth
const imgHeight = (canvas.height * imgWidth) / canvas.width

pdf.addImage(imgData, "PNG", margin, margin, imgWidth, imgHeight)

图像格式选择

  • PNG无损,清晰度高,适合文字
  • JPEG有损压缩,文件小,但不适合文字

样式处理技巧

状态标签着色这里我做了一层数据映射,方便精准还原样式:

constcolorMap: Record<stringstring> = {
"已完成""#dcfce7;color:#16a34a",
"进行中""#dbeafe;color:#2563eb",
"待开始""#fef3c7;color:#d97706",
"已停滞""#f3f4f6;color:#6b7280",
"重要紧急""#fee2e2;color:#dc2626",
}

交替行背景我采用的逻辑判断来动态渲染:

<tr style="background:${idx % 2 === 0 ? "#fff" : "#f8fafc"}">

如果文本出现截断换行,用canvas很难处理,这里我采用如下方案截断处理:

// 方案1:省略号截断(适合固定宽度列)
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:160px">

// 方案2:完全显示(适合自动宽度列)
<spanstyle="white-space:nowrap">

当然还有很多细节的处理,这里就不一一介绍了。我们可以基于这个方案,继续扩展出如下场景:

  1. 水印支持添加企业 Logo 或水印
  2. 页码在页脚添加 "第 X 页 / 共 Y 页"
  3. 图表嵌入将图表大屏的图表嵌入 PDF
  4. 批量导出支持同时导出多个表格

今天就分享到这,后续我们还会持续迭代和更新,打造最强大的多维表格和文档协同系统。

演示地址:pxcharts.com

开源版:github.com/MrXujiang/p…

async/await高级模式:async迭代器、错误边界与并发控制

掌握async/await的进阶技巧,让你的异步代码更加优雅、健壮、高效。本文深入探讨async生成器、取消机制、并发控制等高级特性,帮助架构师和中高级开发者写出生产级的异步代码。

目录


为什么需要高级异步模式

如果你已经熟练掌握了 async/await 的基本用法:

async function fetchUser(id) {
  try {
    const response = await fetch(`/api/users/${id}`);
    const user = await response.json();
    return user;
  } catch (error) {
    console.error('获取用户失败:', error);
    throw error;
  }
}

那么恭喜你,你已经能够处理80%的异步场景了。但在生产环境中,你会遇到更棘手的问题:

  • 分页数据流: 如何优雅地处理无限滚动的分页数据?
  • 请求取消: 用户离开页面时,如何取消正在进行的请求?
  • 并发控制: 100个并发请求会打爆服务器,如何控制到最多5个?
  • 错误隔离: 一个请求失败不应该拖垮整个应用,如何实现错误边界?
  • 资源清理: 如何确保文件句柄、数据库连接等资源被正确释放?

这些问题需要高级异步模式来解决。本文将带你深入这5个核心领域,让你写出像V8引擎源码一样优雅的异步代码。


Async Generator: 异步迭代器

什么是异步生成器

异步生成器是 ES2018 引入的特性,它结合了三个概念:

  1. 异步(async): 支持await操作
  2. 生成器(generator): 可以暂停执行,惰性求值
  3. 迭代器(iterator): 可以用for...of遍历

简单来说,异步生成器让你可以一边异步获取数据,一边逐个产出结果,而不是等所有数据加载完才返回。

基础语法

// 定义异步生成器函数
async function* asyncGenerator() {
  yield Promise.resolve(1);
  yield Promise.resolve(2);
  yield Promise.resolve(3);
}

// 消费异步生成器
async function consume() {
  for await (const value of asyncGenerator()) {
    console.log(value); // 1, 2, 3
  }
}

关键点:

  • 使用async function*定义
  • yield可以产出Promise或普通值
  • 必须用for await...of来消费
  • 每个yield都会等待前面的异步操作完成

实际应用场景

场景1: 分页API数据流

假设你有一个分页API,需要获取所有数据:

// 传统方式:一次性加载所有数据
async function fetchAllPages(url) {
  const allData = [];
  let page = 1;
  
  while (true) {
    const response = await fetch(`${url}?page=${page}`);
    const data = await response.json();
    
    if (data.items.length === 0) break;
    
    allData.push(...data.items);
    page++;
  }
  
  return allData; // 可能几千条数据,内存爆炸!
}

// 异步生成器:流式处理
async function* fetchPaginatedData(url) {
  let page = 1;
  
  while (true) {
    const response = await fetch(`${url}?page=${page}`);
    const data = await response.json();
    
    if (data.items.length === 0) break;
    
    yield data; // 每次只产出当前页数据
    page++;
  }
}

// 消费:内存友好,处理一页释放一页
async function processLargeDataset() {
  for await (const page of fetchPaginatedData('/api/users')) {
    await processPage(page.items); // 处理当前页
    // 处理完成后,当前页数据可被垃圾回收
  }
}

优势:

  • 内存占用恒定,不随数据量增长
  • 真正的流式处理,边加载边处理
  • 更早开始处理,不用等所有数据加载完

场景2: 文件逐行读取

async function* readLines(filepath) {
  const fileHandle = await fs.promises.open(filepath, 'r');
  const buffer = Buffer.alloc(1024);
  let leftover = '';
  
  try {
    while (true) {
      const { bytesRead } = await fileHandle.read(buffer, 0, 1024);
      
      if (bytesRead === 0) {
        if (leftover) yield leftover;
        break;
      }
      
      const chunk = leftover + buffer.toString('utf8', 0, bytesRead);
      const lines = chunk.split('\n');
      
      // 最后一行可能不完整,留到下次处理
      leftover = lines.pop() || '';
      
      for (const line of lines) {
        yield line;
      }
    }
  } finally {
    await fileHandle.close(); // 确保文件句柄被释放
  }
}

// 使用
async function processLogFile() {
  for await (const line of readLines('/var/log/app.log')) {
    if (line.includes('ERROR')) {
      console.error(line);
    }
  }
}

关键点: 使用try/finally确保资源清理,这是异步生成器的最佳实践。

场景3: 实时数据流

假设你在对接WebSocket实时数据流:

async function* streamMessages(websocketUrl) {
  const ws = new WebSocket(websocketUrl);
  const messageQueue = [];
  let resolveMessage = null;
  
  ws.onmessage = (event) => {
    if (resolveMessage) {
      resolveMessage(JSON.parse(event.data));
      resolveMessage = null;
    } else {
      messageQueue.push(JSON.parse(event.data));
    }
  };
  
  try {
    while (true) {
      if (messageQueue.length > 0) {
        yield messageQueue.shift();
      } else {
        yield await new Promise(resolve => {
          resolveMessage = resolve;
        });
      }
    }
  } finally {
    ws.close(); // 确保WebSocket连接关闭
  }
}

// 消费实时流
async function monitorRealtimeData() {
  const abortController = new AbortController();
  
  setTimeout(() => abortController.abort(), 60000); // 1分钟后停止
  
  for await (const message of streamMessages('wss://api.example.com/realtime')) {
    if (abortController.signal.aborted) break;
    await processMessage(message);
  }
}

高级技巧

1. yield* 委托

async function* userPosts(userId) {
  const posts = await fetchUserPosts(userId);
  yield* posts; // 委托给数组的同步迭代器
}

async function* allUsersPosts(userIds) {
  for (const userId of userIds) {
    yield* userPosts(userId); // 委托给另一个异步生成器
  }
}

// 平铺所有用户的文章
for await (const post of allUsersPosts([1, 2, 3])) {
  console.log(post.title);
}

2. 从异步生成器创建ReadableStream

async function* generateChunks() {
  yield 'chunk1';
  yield 'chunk2';
  yield 'chunk3';
}

const stream = new ReadableStream({
  async start(controller) {
    for await (const chunk of generateChunks()) {
      controller.enqueue(chunk);
    }
    controller.close();
  }
});

// 可以用于fetch的body
await fetch('/api/upload', {
  method: 'POST',
  body: stream
});

AbortController: 优雅的取消机制

为什么需要取消机制

想象一个场景:用户在搜索框输入,每次输入都会触发API请求。如果用户快速输入"JavaScript",会连续触发10个请求,但只有最后一个请求是有意义的,前9个都是浪费:

// ❌ 问题代码
let currentQuery = '';
async function handleSearch(query) {
  currentQuery = query;
  const results = await fetch(`/api/search?q=${query}`);
  if (query === currentQuery) { // 竞态条件检查
    displayResults(results);
  }
}

问题:

  • 前9个请求仍在执行,浪费带宽
  • 无法真正中断请求,只能忽略结果
  • 代码逻辑复杂,容易出现竞态条件

解决方案: AbortController

AbortController基础

const controller = new AbortController();
const signal = controller.signal;

// 发起可取消的请求
fetch('/api/data', { signal })
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => {
    if (error.name === 'AbortError') {
      console.log('请求被取消');
    } else {
      console.error('请求失败:', error);
    }
  });

// 取消请求
controller.abort();

核心API:

  • controller.abort(reason?): 取消所有关联的操作
  • controller.signal: 一个AbortSignal对象,可以传递给异步操作
  • signal.aborted: 布尔值,表示是否已取消
  • signal.reason: 取消的原因
  • signal.throwIfAborted(): 如果已取消,则抛出错误

实际应用场景

场景1: 搜索请求取消

let abortController = null;

async function search(query) {
  // 取消前一个请求
  if (abortController) {
    abortController.abort();
  }
  
  // 创建新的控制器
  abortController = new AbortController();
  
  try {
    const response = await fetch(`/api/search?q=${query}`, {
      signal: abortController.signal
    });
    const results = await response.json();
    return results;
  } catch (error) {
    if (error.name === 'AbortError') {
      console.log('搜索请求被取消');
      return [];
    }
    throw error;
  }
}

// 用户快速输入: J -> Ja -> Jav -> Java -> JavaS -> ...
// 只有最后一个请求会执行,前面的都被取消

场景2: React组件卸载清理

import { useEffect, useState } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    const abortController = new AbortController();
    
    async function fetchUser() {
      try {
        setLoading(true);
        const response = await fetch(`/api/users/${userId}`, {
          signal: abortController.signal
        });
        const data = await response.json();
        setUser(data);
      } catch (error) {
        if (error.name !== 'AbortError') {
          console.error('获取用户失败:', error);
        }
      } finally {
        setLoading(false);
      }
    }
    
    fetchUser();
    
    // 组件卸载时取消请求
    return () => abortController.abort();
  }, [userId]);
  
  if (loading) return <div>Loading...</div>;
  return <div>{user?.name}</div>;
}

关键点: useEffect的清理函数会在组件卸载时或userId变化时执行,确保旧请求被取消。

场景3: 超时控制

现代浏览器支持直接创建超时signal:

// 方式1: AbortSignal.timeout (现代浏览器)
async function fetchWithTimeout(url, timeout = 5000) {
  try {
    const response = await fetch(url, {
      signal: AbortSignal.timeout(timeout)
    });
    return response.json();
  } catch (error) {
    if (error.name === 'AbortError' || error.name === 'TimeoutError') {
      throw new Error(`请求超时 (${timeout}ms)`);
    }
    throw error;
  }
}

// 方式2: 手动超时控制 (兼容性更好)
async function fetchWithManualTimeout(url, timeout = 5000) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeout);
  
  try {
    const response = await fetch(url, {
      signal: controller.signal
    });
    return response.json();
  } finally {
    clearTimeout(timeoutId);
  }
}

场景4: 取消多个操作

AbortController可以同时取消多个操作:

async function fetchDashboardData(userId) {
  const controller = new AbortController();
  const signal = controller.signal;
  
  try {
    // 同时发起多个请求,共享同一个signal
    const [user, posts, comments] = await Promise.all([
      fetch(`/api/users/${userId}`, { signal }).then(r => r.json()),
      fetch(`/api/users/${userId}/posts`, { signal }).then(r => r.json()),
      fetch(`/api/users/${userId}/comments`, { signal }).then(r => r.json())
    ]);
    
    return { user, posts, comments };
  } catch (error) {
    if (error.name === 'AbortError') {
      console.log('Dashboard数据请求被取消');
      return null;
    }
    throw error;
  }
}

// 用户离开页面时取消所有请求
window.addEventListener('beforeunload', () => {
  controller.abort();
});

自定义可取消操作

AbortController不仅限于fetch,你可以为任何异步操作添加取消功能:

// 可取消的延时
function cancellableDelay(ms, signal) {
  return new Promise((resolve, reject) => {
    const timeoutId = setTimeout(resolve, ms);
    
    signal.addEventListener('abort', () => {
      clearTimeout(timeoutId);
      reject(new DOMException('Aborted', 'AbortError'));
    }, { once: true }); // once: true 确保只触发一次
  });
}

// 使用
async function countdown(seconds, signal) {
  for (let i = seconds; i > 0; i--) {
    console.log(i);
    await cancellableDelay(1000, signal);
  }
  console.log('完成!');
}

const controller = new AbortController();
countdown(10, controller.signal).catch(console.error);

// 3秒后取消
setTimeout(() => controller.abort(), 3000);
// 输出: 10, 9, 8, 然后抛出AbortError

并发控制: 从失控到可控

为什么需要并发控制

Promise.all()虽然方便,但没有并发限制:

// ❌ 危险代码:可能同时发起1000个请求!
const userIds = Array.from({ length: 1000 }, (_, i) => i + 1);

await Promise.all(
  userIds.map(id => fetch(`/api/users/${id}`))
);
// 服务器会被瞬间打爆,触发限流,甚至被拉黑

现实问题:

  • 浏览器对同一域名的并发请求有限制(通常6个)
  • 服务器有速率限制(rate limit)
  • 数据库连接池有限
  • 内存占用会飙升

并发控制方案对比

方案 特点 npm包 周下载量
Promise.all 无限制并发 原生 -
p-limit 简单限流 p-limit 2.04亿
p-map 映射处理 p-map 6780万
p-queue 队列管理 p-queue 1890万

方案1: p-limit (推荐)

最简单轻量的并发控制库:

npm install p-limit
import pLimit from 'p-limit';

// 创建并发限制器(最多同时执行3个任务)
const limit = pLimit(3);

async function fetchUsers() {
  const userIds = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
  
  // 将每个任务包装到limit()中
  const tasks = userIds.map(id => 
    limit(() => fetch(`/api/users/${id}`).then(r => r.json()))
  );
  
  // 执行所有任务,但最多同时执行3个
  const users = await Promise.all(tasks);
  console.log('获取用户:', users.length); // 10
  
  return users;
}

工作原理:

  1. pLimit(3)创建一个限制器
  2. limit(fn)将任务加入队列
  3. 如果当前执行数<3,立即执行
  4. 如果当前执行数>=3,等待前面的任务完成
  5. 任务完成后,从队列中取出下一个执行

方案2: p-map

专门用于映射处理的并发控制:

npm install p-map
import pMap from 'p-map';

async function processImages(imageUrls) {
  const results = await pMap(
    imageUrls,
    async (url) => {
      const response = await fetch(url);
      const blob = await response.blob();
      return processImage(blob);
    },
    {
      concurrency: 5, // 最多同时处理5张图片
      stopOnError: false // 即使有错误也继续处理其他图片
    }
  );
  
  return results;
}

高级选项:

  • concurrency: 并发数
  • stopOnError: 是否在遇到错误时停止(默认true)
  • signal: AbortSignal,支持取消

方案3: p-queue

功能最全面的队列管理:

npm install p-queue
import PQueue from 'p-queue';

// 创建队列
const queue = new PQueue({
  concurrency: 3,      // 最多同时执行3个任务
  interval: 1000,      // 每秒最多开始3个任务
  intervalCap: 3,      // 每个interval期间最多执行的任务数
});

// 监听事件
queue.on('active', () => {
  console.log(`正在执行: ${queue.pending}/${queue.concurrency}`);
});

queue.on('idle', () => {
  console.log('队列为空,所有任务完成');
});

queue.on('error', (error) => {
  console.error('任务执行出错:', error);
});

// 添加任务
async function fetchUserData() {
  for (let i = 1; i <= 100; i++) {
    queue.add(() => 
      fetch(`/api/users/${i}`)
        .then(r => r.json())
        .then(user => {
          console.log(`获取用户: ${user.name}`);
          return user;
        })
    );
  }
  
  // 等待所有任务完成
  await queue.onIdle();
  console.log('所有用户获取完成');
}

高级特性:

  • 优先级: queue.add(fn, { priority: 10 })
  • 暂停/恢复: queue.pause() / queue.start()
  • 清空队列: queue.clear()
  • 统计信息: queue.pending, queue.size

方案4: 手动实现并发控制

如果你不想引入依赖,可以手动实现:

async function limitConcurrency(tasks, concurrency) {
  const results = [];
  const executing = new Set();
  
  for (const [index, task] of tasks.entries()) {
    const promise = task().then(result => {
      executing.delete(promise);
      results[index] = result;
      return result;
    });
    
    executing.add(promise);
    
    if (executing.size >= concurrency) {
      await Promise.race(executing);
    }
  }
  
  return Promise.all(executing).then(() => results);
}

// 使用
const tasks = userIds.map(id => () => fetch(`/api/users/${id}`).then(r => r.json()));
const users = await limitConcurrency(tasks, 3);

核心算法:

  1. 维护一个执行中的Promise集合executing
  2. 每次添加新任务时,检查执行数是否达到上限
  3. 达到上限时,等待任意一个任务完成(Promise.race)
  4. 任务完成后自动从集合中移除,空出位置给下一个任务

并发控制最佳实践

1. 根据资源设置并发数

// 根据CPU核心数
import os from 'os';
const cpuCount = os.cpus().length;
const limit = pLimit(cpuCount);

// 根据数据库连接池
const dbPoolSize = 10;
const dbLimit = pLimit(dbPoolSize - 2); // 留2个备用

// 根据API速率限制
const rateLimitPerSecond = 50;
const apiLimit = pLimit(rateLimitPerSecond / 2); // 留buffer

2. 动态调整并发数

const queue = new PQueue({ concurrency: 5 });

// 根据响应时间动态调整
setInterval(() => {
  const avgResponseTime = calculateAverageResponseTime();
  
  if (avgResponseTime > 2000) {
    // 响应慢,减少并发
    queue.concurrency = Math.max(1, queue.concurrency - 1);
  } else if (avgResponseTime < 500) {
    // 响应快,增加并发
    queue.concurrency = Math.min(20, queue.concurrency + 1);
  }
}, 10000);

3. 错误处理

async function fetchWithErrorHandling(items) {
  const errors = [];
  
  const results = await pMap(
    items,
    async (item) => {
      try {
        return await processItem(item);
      } catch (error) {
        errors.push({ item, error });
        return null; // 返回null而不是抛出错误
      }
    },
    {
      concurrency: 5,
      stopOnError: false
    }
  );
  
  if (errors.length > 0) {
    console.error(`处理失败: ${errors.length}/${items.length}`);
    errors.forEach(({ item, error }) => {
      console.error(`项目${item.id}处理失败:`, error);
    });
  }
  
  return results.filter(r => r !== null);
}

高级错误处理模式

Try-Catch的局限性

传统的try-catch在复杂场景中不够用:

async function processOrder(orderId) {
  try {
    const order = await fetchOrder(orderId);
    const inventory = await checkInventory(order.items);
    const payment = await processPayment(order);
    const shipment = await createShipment(order);
    
    return { order, inventory, payment, shipment };
  } catch (error) {
    // 不知道是哪一步出错
    console.error('处理订单失败:', error);
    throw error;
  }
}

问题:

  1. 无法区分哪一步出错
  2. 嵌套try-catch导致代码膨胀
  3. 错误吞噬(catch后未处理)
  4. 无法针对不同错误类型采取不同策略

模式1: 错误分类

// 定义自定义错误类型
class NetworkError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.name = 'NetworkError';
    this.statusCode = statusCode;
  }
}

class ValidationError extends Error {
  constructor(message, fields) {
    super(message);
    this.name = 'ValidationError';
    this.fields = fields;
  }
}

class PaymentError extends Error {
  constructor(message, orderId) {
    super(message);
    this.name = 'PaymentError';
    this.orderId = orderId;
  }
}

// 使用
async function processOrder(orderId) {
  try {
    const order = await fetchOrder(orderId);
    
    // 验证错误
    if (!order.items || order.items.length === 0) {
      throw new ValidationError('订单没有商品', ['items']);
    }
    
    // 网络错误
    const inventory = await checkInventory(order.items);
    if (inventory.status === 503) {
      throw new NetworkError('库存服务不可用', 503);
    }
    
    // 支付错误
    const payment = await processPayment(order);
    if (payment.status === 'failed') {
      throw new PaymentError('支付失败', orderId);
    }
    
    return { order, inventory, payment };
  } catch (error) {
    // 根据错误类型采取不同策略
    if (error instanceof ValidationError) {
      return { success: false, reason: 'validation', fields: error.fields };
    } else if (error instanceof NetworkError) {
      // 重试逻辑
      return retryOperation(() => processOrder(orderId), 3);
    } else if (error instanceof PaymentError) {
      // 发送告警
      alertSupport(error.orderId, error.message);
      return { success: false, reason: 'payment' };
    } else {
      throw error; // 未知错误,向上抛出
    }
  }
}

模式2: 结果包装器

// Result类型 (类似Rust的Result)
class Result {
  static ok(value) {
    return { success: true, value };
  }
  
  static error(error) {
    return { success: false, error };
  }
}

async function safeAsync(promise) {
  try {
    const value = await promise;
    return Result.ok(value);
  } catch (error) {
    return Result.error(error);
  }
}

// 使用
async function processOrders(orderIds) {
  const results = await Promise.all(
    orderIds.map(id => safeAsync(processOrder(id)))
  );
  
  const successful = results.filter(r => r.success).map(r => r.value);
  const failed = results.filter(r => !r.success).map(r => r.error);
  
  console.log(`成功: ${successful.length}, 失败: ${failed.length}`);
  
  // 失败的错误可以记录日志、重试或告警
  failed.forEach(error => {
    console.error('订单处理失败:', error);
  });
  
  return { successful, failed };
}

模式3: 错误包装器

特别适用于Express.js等框架:

// Express错误包装器
function asyncHandler(fn) {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}

// 使用
app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await fetchUser(req.params.id);
  res.json(user);
}));

// 全局错误处理中间件
app.use((error, req, res, next) => {
  if (error instanceof ValidationError) {
    res.status(400).json({ error: 'validation', fields: error.fields });
  } else if (error instanceof NetworkError) {
    res.status(502).json({ error: 'network', message: error.message });
  } else {
    console.error('未处理的错误:', error);
    res.status(500).json({ error: 'internal' });
  }
});

模式4: Promise.allSettled并发错误处理

当多个并发任务且不想一个失败导致全部失败时:

async function fetchAllData(userId) {
  const results = await Promise.allSettled([
    fetch(`/api/users/${userId}`).then(r => r.json()),
    fetch(`/api/users/${userId}/posts`).then(r => r.json()),
    fetch(`/api/users/${userId}/comments`).then(r => r.json()),
    fetch(`/api/users/${userId}/followers`).then(r => r.json()),
  ]);
  
  const [user, posts, comments, followers] = results.map((result, index) => {
    if (result.status === 'fulfilled') {
      return result.value;
    } else {
      console.error(`数据${index}获取失败:`, result.reason);
      return null; // 返回null作为fallback
    }
  });
  
  return {
    user: user || {},
    posts: posts || [],
    comments: comments || [],
    followers: followers || [],
  };
}

模式5: 全局错误捕获

作为最后一道防线,捕获未处理的异常:

// Node.js
process.on('unhandledRejection', (reason, promise) => {
  console.error('未处理的Promise rejection:', reason);
  // 发送到错误追踪服务
  Sentry.captureException(reason);
  process.exit(1); // 可选:强制退出进程
});

process.on('uncaughtException', (error) => {
  console.error('未捕获的异常:', error);
  Sentry.captureException(error);
  process.exit(1);
});

// 浏览器
window.addEventListener('unhandledrejection', (event) => {
  console.error('未处理的Promise rejection:', event.reason);
  event.preventDefault(); // 阻止默认行为(控制台错误)
});

window.addEventListener('error', (event) => {
  console.error('全局错误:', event.error);
});

// React Error Boundary
import { ErrorBoundary } from 'react-error-boundary';

function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div role="alert">
      <p>出错了:</p>
      <pre>{error.message}</pre>
      <button onClick={resetErrorBoundary}>重试</button>
    </div>
  );
}

<ErrorBoundary FallbackComponent={ErrorFallback}>
  <App />
</ErrorBoundary>

实战案例:分页API完整实现

让我们把学到的知识整合起来,实现一个生产级的分页数据获取方案:

/**
 * 分页API客户端
 * 支持:
 * - 异步生成器流式处理
 * - AbortController取消
 * - 并发控制
 * - 错误处理
 */
class PaginatedAPIClient {
  constructor(baseUrl, options = {}) {
    this.baseUrl = baseUrl;
    this.concurrency = options.concurrency || 5;
    this.timeout = options.timeout || 30000;
    this.cache = new Map();
  }
  
  /**
   * 异步生成器:流式获取分页数据
   */
  async *fetchPaginated(endpoint, options = {}) {
    const signal = options.signal || new AbortController().signal;
    let page = options.startPage || 1;
    let hasMore = true;
    
    while (hasMore && !signal.aborted) {
      const url = `${this.baseUrl}${endpoint}?page=${page}&${new URLSearchParams(options.params || {})}`;
      
      try {
        const response = await this.fetchWithTimeout(url, signal);
        const data = await response.json();
        
        if (!data.items || data.items.length === 0) {
          hasMore = false;
          break;
        }
        
        yield data;
        
        hasMore = data.hasMore !== false;
        page++;
        
      } catch (error) {
        if (error.name === 'AbortError') {
          console.log('请求被取消');
          break;
        }
        throw error;
      }
    }
  }
  
  /**
   * 批量处理分页数据(支持并发)
   */
  async processPaginated(endpoint, processor, options = {}) {
    const results = [];
    const errors = [];
    const batchSize = options.batchSize || 10;
    const signal = options.signal || new AbortController().signal;
    
    let batch = [];
    
    for await (const page of this.fetchPaginated(endpoint, options)) {
      if (signal.aborted) break;
      
      batch.push(page);
      
      if (batch.length >= batchSize) {
        const batchResults = await this.processBatch(batch, processor, signal);
        results.push(...batchResults.successful);
        errors.push(...batchResults.errors);
        batch = [];
      }
    }
    
    // 处理剩余批次
    if (batch.length > 0) {
      const batchResults = await this.processBatch(batch, processor, signal);
      results.push(...batchResults.successful);
      errors.push(...batchResults.errors);
    }
    
    return { results, errors };
  }
  
  /**
   * 批量处理(内部方法,支持并发控制)
   */
  async processBatch(pages, processor, signal) {
    const results = await Promise.allSettled(
      pages.map(page => processor(page, signal))
    );
    
    return {
      successful: results
        .filter(r => r.status === 'fulfilled')
        .map(r => r.value),
      errors: results
        .filter(r => r.status === 'rejected')
        .map(r => r.reason)
    };
  }
  
  /**
   * 带超时的fetch
   */
  async fetchWithTimeout(url, signal) {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), this.timeout);
    
    // 合并外部signal和timeout signal
    if (signal) {
      signal.addEventListener('abort', () => controller.abort());
    }
    
    try {
      const response = await fetch(url, { signal: controller.signal });
      
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }
      
      return response;
    } finally {
      clearTimeout(timeoutId);
    }
  }
  
  /**
   * 带缓存的get请求
   */
  async get(endpoint, options = {}) {
    const cacheKey = `${endpoint}:${JSON.stringify(options.params)}`;
    
    if (options.useCache && this.cache.has(cacheKey)) {
      return this.cache.get(cacheKey);
    }
    
    const url = `${this.baseUrl}${endpoint}?${new URLSearchParams(options.params || {})}`;
    const response = await this.fetchWithTimeout(url, options.signal);
    const data = await response.json();
    
    if (options.useCache) {
      this.cache.set(cacheKey, data);
    }
    
    return data;
  }
  
  /**
   * 取消所有正在进行的请求
   */
  cancelAll() {
    this.activeRequests.forEach(controller => controller.abort());
    this.activeRequests.clear();
  }
}

// 使用示例
async function main() {
  const client = new PaginatedAPIClient('https://api.example.com', {
    concurrency: 5,
    timeout: 15000
  });
  
  // 示例1: 流式处理大数据集
  const abortController = new AbortController();
  
  setTimeout(() => {
    console.log('30秒后取消请求');
    abortController.abort();
  }, 30000);
  
  for await (const page of client.fetchPaginated('/users', {
    signal: abortController.signal,
    params: { status: 'active' }
  })) {
    console.log(`处理第${page.currentPage}页,共${page.items.length}条数据`);
    await processUserData(page.items);
  }
  
  // 示例2: 批量处理(带并发控制)
  const { results, errors } = await client.processPaginated(
    '/posts',
    async (page, signal) => {
      // 每页数据的处理逻辑
      const processedItems = [];
      
      for (const post of page.items) {
        if (signal.aborted) break;
        
        // 处理post
        const enriched = await enrichPost(post);
        processedItems.push(enriched);
      }
      
      return processedItems;
    },
    {
      batchSize: 10,
      startPage: 1,
      signal: abortController.signal
    }
  );
  
  console.log(`成功: ${results.length}, 失败: ${errors.length}`);
}

// 启动
main().catch(console.error);

这个实现整合了:

  1. 异步生成器: 流式处理分页数据
  2. AbortController: 支持取消请求
  3. 并发控制: 批量处理时限制并发
  4. 错误处理: Promise.allSettled捕获错误
  5. 超时控制: 防止请求hang住
  6. 缓存: 减少重复请求
  7. 类型安全: 可配合TypeScript使用

性能优化建议

1. 避免async/await性能陷阱

陷阱1: 不必要的await

// ❌ 低效:串行执行两个不相关的操作
async function getData() {
  const user = await fetchUser();
  const posts = await fetchPosts();
  return { user, posts };
}

// ✅ 高效:并行执行
async function getData() {
  const [user, posts] = await Promise.all([
    fetchUser(),
    fetchPosts()
  ]);
  return { user, posts };
}

陷阱2: 循环中的await

// ❌ 低效:串行执行
async function fetchUsers(userIds) {
  const users = [];
  for (const id of userIds) {
    const user = await fetch(`/api/users/${id}`).then(r => r.json());
    users.push(user);
  }
  return users;
}

// ✅ 高效:并发控制
async function fetchUsers(userIds) {
  const limit = pLimit(5); // 限制并发数为5
  const tasks = userIds.map(id => 
    limit(() => fetch(`/api/users/${id}`).then(r => r.json()))
  );
  return Promise.all(tasks);
}

2. 内存优化

// ❌ 内存占用高
async function fetchAllData() {
  const allData = [];
  
  for await (const page of fetchPages()) {
    allData.push(...page.items); // 数据越来越多,内存爆炸
  }
  
  return allData;
}

// ✅ 流式处理,内存友好
async function processAllData() {
  for await (const page of fetchPages()) {
    await processItems(page.items); // 处理完就释放
  }
}

3. 超时和重试策略

async function fetchWithRetry(url, options = {}) {
  const { retries = 3, timeout = 5000 } = options;
  
  for (let i = 0; i < retries; i++) {
    try {
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), timeout);
      
      const response = await fetch(url, {
        ...options,
        signal: controller.signal
      });
      
      clearTimeout(timeoutId);
      
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }
      
      return response.json();
      
    } catch (error) {
      if (error.name === 'AbortError') {
        console.log(`请求超时,第${i + 1}次重试`);
      } else {
        console.error(`请求失败(${error.message}),第${i + 1}次重试`);
      }
      
      if (i === retries - 1) {
        throw error;
      }
      
      // 指数退避
      await new Promise(resolve => 
        setTimeout(resolve, Math.pow(2, i) * 1000)
      );
    }
  }
}

4. 使用性能监控工具

// 测量异步函数执行时间
async function measurePerformance(name, fn) {
  const start = performance.now();
  const result = await fn();
  const duration = performance.now() - start;
  console.log(`${name}耗时: ${duration.toFixed(2)}ms`);
  return result;
}

// 使用
await measurePerformance('fetchUsers', () => fetchUsers([1, 2, 3]));

总结

本文深入探讨了async/await的5个高级使用模式:

核心要点回顾

  1. Async Generator

    • 流式处理大数据集
    • 惰性求值,内存友好
    • 使用for await...of消费
    • try/finally确保资源清理
  2. AbortController

    • 优雅取消异步操作
    • 组件卸载时清理请求
    • 避免竞态条件
    • 支持超时控制
  3. 并发控制

    • Promise.all无限并发,危险!
    • p-limit简单轻量
    • p-map适合映射处理
    • p-queue功能最全
  4. 错误处理

    • 区分错误类型
    • Promise.allSettled并发容错
    • 全局错误捕获作为防线
    • React Error Boundary
  5. 性能优化

    • 避免不必要的await
    • 使用并发控制代替串行
    • 流式处理减少内存占用
    • 添加超时和重试

知识检查清单

  • 能用async生成器处理分页API
  • 能用AbortController取消fetch请求
  • 能选择合适的并发控制库(p-limit/p-map/p-queue)
  • 能区分不同的错误处理场景并选择合适策略
  • 能实现生产级的分页API客户端

进阶学习路线

  1. 源码阅读: 阅读p-limit、p-queue的源码(都很小,易于理解)
  2. 实践项目: 用async生成器实现一个文件流处理器
  3. 深入原理: 了解V8引擎的async/await实现
  4. 性能对比: Benchmark不同并发控制方案的性能差异

参考链接

一个非常实用的Three.js3D模型爆破💥和切割开源插件

前言

给大家分享一个非常实用Three.js3D模型爆破切割插件,这个插件能够使前端可以直接在浏览器中,对 3D 模型进行实时且物理效果真实的 “爆破”“粉碎”“切片” 处理。

Mar-01-2026 18-55-28.gif

安装

安装也是非常的简单直接通过 npm 安装即可

但需要注意的是Three.js版本需要大于 0.158

npm install @dgreenheck/three-pinata

使用

使用也是非常的简单只要将插件提供的方法引入即可

DestructibleMesh 用于创建可切割或爆破的物体物体

FractureOptions 用于设置参数配置

import { DestructibleMesh, FractureOptions } from "@dgreenheck/three-pinata";

const outerMaterial = new THREE.MeshStandardMaterial({ color: 0x4a90e2 });
const innerMaterial = new THREE.MeshStandardMaterial({ color: 0xff6b6b });

const mesh = new DestructibleMesh(geometry, outerMaterial, innerMaterial);
scene.add(mesh);

const options = new FractureOptions({
  fractureMethod: "voronoi",
  fragmentCount: 16,
  voronoiOptions: {
    mode: "3D",
  },
});
const fragments = mesh.fracture(options);

fragments.forEach((fragment) => scene.add(fragment));
mesh.visible = false;

参数方法

该插件大概提供了7种不同的针对3D模型爆破和切割的场景方法,并且官网示例都可以直接查看演示效果

比如这个砸碎物体方法:

image.png

image.png

又或者说这个物体切片方法:

image.png

image.png

项目仓库

该项目插件是一个外国大佬开发,如果有使用Three.js开发一个小游戏的需求,或者说想丰富一下你的3D网站这个插件都会可以给你提供不错的帮助的

项目演示地址:three-pinata-demo.vercel.app/

Github: github.com/dgreenheck/…

JitWord Office预览引擎:如何用Vue3+Node.js打造丝滑的PDF/Excel/PPT嵌入方案

ps:老规矩,先上地址,github地址:jitword sdk

最近很多用户反馈了需要支持Office预览功能,于是我们加班加点,在Jitword 协同AI文档上支持了一键预览Office文件的功能:

image.png

目前 jitword 已全面支持如下文件类型的解析预览:

  • Markdown文件
  • Docx文件
  • PDF文件
  • Excel文件
  • PPT文件
  • JSON文件
  • HTML文件

接下来我会详细和大家分享一下功能和技术实现,给大家提供一个技术参考。

往期精彩:

拒绝重复造轮子?我们偏偏花365天,用Vue3写了款AI协同的Word编辑器

项目背景:为什么我们要造这个轮子?

image.png

作为一个协同文档项目,JitWord一直在探索轻量级的办公解决方案。最近社区反复提出"Office预览"需求,但是我们面临一个选择:

方案 优点 缺点
OnlyOffice/Collabora 功能完整,支持编辑 部署重(2GB+镜像),加载慢(3-5s),样式难定制
微软/谷歌预览API 接入简单 数据出境,自定义域名受限,免费额度有限
自研预览引擎 轻量、可控、体验统一 开发成本高,需持续维护

我们的决策:自研轻量级预览引擎,专注"预览+文档编排"场景。

下面分享一下我们的技术方案。

架构设计:三层解耦模型

┌─────────────────────────────────────────┐
│           协同层 (Collaboration)         │
│    批注Canvas + 用户体系 + 实时同步        │
├─────────────────────────────────────────┤
│           嵌入层 (Embedding)             │
│    Vue3组件 + 响应式布局 + 主题同步        │
├─────────────────────────────────────────┤
│           解析层 (Parsing)               │
│    PDF.js / SheetJS / PPTX解析器         │
└─────────────────────────────────────────┘

核心技术实现

PDF预览:PDF.js深度优化

问题:原版PDF.js加载大文件时卡顿,内存占用高。

优化方案

// pdf-loader.js
import * as pdfjsLib from 'pdfjs-dist';

class PDFPreviewEngine {
  constructor(container, options = {}) {
    this.container = container;
    this.pdfDoc = null;
    this.scale = options.scale || 1.5;
    this.chunkSize = options.chunkSize || 256 * 1024; // 256KB分片
  }

  async load(url) {
    // 分片加载:只加载可视区域附近的页面
    const loadingTask = pdfjsLib.getDocument({
      url,
      rangeChunkSize: this.chunkSize,
      disableAutoFetch: true, // 关键:禁用自动全量加载
    });

    this.pdfDoc = await loadingTask.promise;
    return this.renderVisiblePages();
  }

  async renderVisiblePages() {
    const viewportHeight = this.container.clientHeight;
    const pages = [];
    
    // 只渲染可视区域 + 上下各缓冲1页
    for (let i = 1; i <= this.pdfDoc.numPages; i++) {
      const page = await this.pdfDoc.getPage(i);
      const viewport = page.getViewport({ scale: this.scale });
      
      // 虚拟列表逻辑:计算页面是否在视口内
      if (this.isPageInViewport(i, viewport.height)) {
        pages.push(this.renderPage(page, viewport));
      }
    }
    
    return Promise.all(pages);
  }

  renderPage(page, viewport) {
    const canvas = document.createElement('canvas');
    const context = canvas.getContext('2d');
    canvas.height = viewport.height;
    canvas.width = viewport.width;

    return page.render({
      canvasContext: context,
      viewport: viewport
    }).promise.then(() => canvas);
  }
}

关键优化点

  1. disableAutoFetch: true:禁用PDF.js的自动全量加载
  2. rangeChunkSize:设置分片大小,配合HTTP Range请求
  3. 虚拟列表渲染:只渲染可视区域,100MB+PDF也能流畅滚动

Excel预览:SheetJS + 自研渲染器

问题:SheetJS解析后如何高效渲染?如何保留公式计算?

方案架构

Excel文件 (.xlsx)
    ↓
SheetJS解析 → Workbook对象
    ↓
数据转换层 (Data Transformer)
    ↓
Vue3表格组件 (Virtual Table)

核心代码

// excel-parser.js
import XLSX from 'xlsx';

class ExcelPreviewEngine {
  parse(buffer) {
    const workbook = XLSX.read(buffer, { 
      type: 'array',
      cellFormula: true,      // 保留公式
      cellNF: true,           // 保留数字格式
      cellStyles: true        // 保留样式
    });
    
    return this.transformWorkbook(workbook);
  }

  transformWorkbook(workbook) {
    return workbook.SheetNames.map(name => {
      const worksheet = workbook.Sheets[name];
      const data = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
      
      return {
        name,
        data,
        merges: this.parseMerges(worksheet['!merges']), // 合并单元格
        formulas: this.extractFormulas(worksheet),      // 公式映射
        colWidths: worksheet['!cols']?.map(c => c.wpx) || []
      };
    });
  }

  extractFormulas(worksheet) {
    const formulas = {};
    for (const [cell, value] of Object.entries(worksheet)) {
      if (value && value.f) { // value.f 是公式字符串
        formulas[cell] = value.f;
      }
    }
    return formulas;
  }
}

前端渲染组件(Vue3 + 虚拟滚动):

<!-- ExcelPreview.vue -->
<template>
  <div class="excel-preview" ref="container">
    <div class="sheet-tabs">
      <button 
        v-for="sheet in sheets" 
        :key="sheet.name"
        :class="{ active: currentSheet === sheet.name }"
        @click="switchSheet(sheet.name)"
      >
        {{ sheet.name }}
      </button>
    </div>
    
    <VirtualTable
      :data="currentData"
      :formulas="currentFormulas"
      :col-widths="currentColWidths"
      :row-height="28"
      @cell-click="handleCellClick"
    />
  </div>
</template>

<script setup>
import { ref, computed } from 'vue';
import VirtualTable from './VirtualTable.vue';
import { evaluateFormula } from './formula-engine'; // 自研公式计算引擎

const props = defineProps({
  workbook: Object
});

const currentSheet = ref(props.workbook[0]?.name);
const currentData = computed(() => {
  const sheet = props.workbook.find(s => s.name === currentSheet.value);
  return sheet?.data || [];
});

// 公式实时计算
const computedValues = computed(() => {
  const result = {};
  const formulas = props.workbook.find(s => s.name === currentSheet.value)?.formulas || {};
  
  for (const [cell, formula] of Object.entries(formulas)) {
    try {
      result[cell] = evaluateFormula(formula, currentData.value);
    } catch (e) {
      result[cell] = '#ERROR';
    }
  }
  
  return result;
});
</script>

公式计算引擎(简化版):

// formula-engine.js
export function evaluateFormula(formula, data) {
  // 移除开头的=
  const expr = formula.replace(/^=/, '');
  
  // 单元格引用解析:A1 → data[0][0]
  const cellRef = expr.match(/([A-Z]+)(\d+)/g);
  if (!cellRef) return evaluateExpression(expr);
  
  let evalExpr = expr;
  for (const ref of cellRef) {
    const { col, row } = parseCellRef(ref);
    const value = data[row - 1]?.[col] || 0;
    evalExpr = evalExpr.replace(ref, value);
  }
  
  return evaluateExpression(evalExpr);
}

// 支持常用函数
const FUNCTIONS = {
  SUM: (args) => args.reduce((a, b) => Number(a) + Number(b), 0),
  AVERAGE: (args) => FUNCTIONS.SUM(args) / args.length,
  MAX: (args) => Math.max(...args),
  MIN: (args) => Math.min(...args),
  // ... 200+函数实现
};

PPT预览:XML解析 + Vue3幻灯片组件

技术选型:不渲染为图片,而是解析为可交互的组件树

// pptx-parser.js
import JSZip from 'jszip';

class PPTXParser {
  async parse(arrayBuffer) {
    const zip = await JSZip.loadAsync(arrayBuffer);
    
    // 解析核心XML
    const [contentTypes, presentation, slideMasters] = await Promise.all([
      zip.file('[Content_Types].xml').async('string'),
      zip.file('ppt/presentation.xml').async('string'),
      zip.file('ppt/slideMasters/slideMaster1.xml').async('string')
    ]);

    const parser = new DOMParser();
    const presDoc = parser.parseFromString(presentation, 'application/xml');
    
    // 提取幻灯片列表
    const slideIds = Array.from(presDoc.querySelectorAll('sldId')).map(s => s.getAttribute('id'));
    
    // 并行解析所有幻灯片
    const slides = await Promise.all(
      slideIds.map((id, index) => this.parseSlide(zip, index + 1))
    );
    
    return { slides, slideCount: slides.length };
  }

  async parseSlide(zip, slideNum) {
    const slideXml = await zip.file(`ppt/slides/slide${slideNum}.xml`).async('string');
    const doc = new DOMParser().parseFromString(slideXml, 'application/xml');
    
    // 提取形状、文本、图片
    const shapes = Array.from(doc.querySelectorAll('sp')).map(sp => ({
      type: this.getShapeType(sp),
      x: this.emuToPx(sp.querySelector('off')?.getAttribute('x')),
      y: this.emuToPx(sp.querySelector('off')?.getAttribute('y')),
      width: this.emuToPx(sp.querySelector('ext')?.getAttribute('cx')),
      height: this.emuToPx(sp.querySelector('ext')?.getAttribute('cy')),
      text: this.extractText(sp),
      style: this.extractStyle(sp)
    }));

    // 提取动画时序
    const animations = this.parseAnimations(doc);
    
    return { shapes, animations, transition: this.parseTransition(doc) };
  }

  emuToPx(emu) {
    return Math.round(parseInt(emu) / 9525); // 1px = 9525 EMU
  }
}

Vue3幻灯片渲染组件

<!-- SlideViewer.vue -->
<template>
  <div class="slide-viewer" :style="slideStyle">
    <TransitionGroup name="slide">
      <div 
        v-for="(shape, index) in currentSlide.shapes" 
        :key="index"
        class="shape"
        :style="shapeStyle(shape)"
        v-show="isShapeVisible(index)"
      >
        <TextShape v-if="shape.type === 'text'" :content="shape.text" :style="shape.style" />
        <ImageShape v-else-if="shape.type === 'image'" :src="shape.src" />
        <TableShape v-else-if="shape.type === 'table'" :data="shape.data" />
      </div>
    </TransitionGroup>
    
    <!-- 动画控制 -->
    <div class="animation-controls">
      <button @click="prevAnimation" :disabled="currentStep === 0">上一步</button>
      <span>{{ currentStep + 1 }} / {{ totalSteps }}</span>
      <button @click="nextAnimation" :disabled="currentStep >= totalSteps - 1">下一步</button>
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue';
import TextShape from './shapes/TextShape.vue';
import ImageShape from './shapes/ImageShape.vue';
import TableShape from './shapes/TableShape.vue';

const props = defineProps({
  slide: Object
});

const currentStep = ref(0);

// 根据动画时序计算可见形状
const isShapeVisible = (shapeIndex) => {
  if (!props.slide.animations) return true;
  const triggerStep = props.slide.animations[shapeIndex]?.triggerStep || 0;
  return currentStep.value >= triggerStep;
};

const nextAnimation = () => {
  if (currentStep.value < totalSteps.value - 1) {
    currentStep.value++;
  }
};

const totalSteps = computed(() => {
  if (!props.slide.animations) return 1;
  return Math.max(...props.slide.animations.map(a => a.triggerStep)) + 1;
});
</script>

嵌入层:与文档流的完美融合

核心挑战:如何让Office预览组件像<img>标签一样自然嵌入文档?

解决方案contenteditable + Shadow DOM隔离

// embed-manager.js
class OfficeEmbedManager {
  constructor(editor) {
    this.editor = editor; // 富文本编辑器实例
    this.embeds = new Map();
  }

  insertEmbed(type, fileUrl, position) {
    // 生成唯一ID
    const embedId = `embed-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
    
    // 在编辑器中插入占位符
    const placeholder = document.createElement('div');
    placeholder.className = 'office-embed-placeholder';
    placeholder.dataset.embedId = embedId;
    placeholder.dataset.type = type;
    placeholder.contentEditable = false; // 关键:防止编辑器干扰
    
    // 使用Shadow DOM隔离样式
    const shadow = placeholder.attachShadow({ mode: 'open' });
    
    // 根据类型渲染对应组件
    const app = createApp(getPreviewComponent(type), {
      src: fileUrl,
      onReady: (api) => this.embeds.set(embedId, api)
    });
    
    app.mount(shadow);
    
    // 插入到编辑器指定位置
    this.editor.insertNodeAt(position, placeholder);
    
    return embedId;
  }

  // 协同批注:将坐标映射到Office内容
  addAnnotation(embedId, x, y, content) {
    const embed = this.embeds.get(embedId);
    if (!embed) return;
    
    // 将屏幕坐标转换为文档相对坐标
    const rect = embed.getBoundingClientRect();
    const relativeX = (x - rect.left) / rect.width;
    const relativeY = (y - rect.top) / rect.height;
    
    // 根据类型做语义化定位
    const location = embed.resolveLocation(relativeX, relativeY);
    
    return {
      embedId,
      location, // 如:{ type: 'cell', ref: 'B5' } 或 { type: 'page', num: 3 }
      content,
      timestamp: Date.now()
    };
  }
}

性能数据与优化技巧

加载性能对比

文件类型 文件大小 OnlyOffice 我们的方案 提升
PDF 50MB 4.2s 0.8s 5.2x
Excel 10MB (10万行) 3.8s 1.1s 3.5x
PPT 20MB (50页) 5.1s 1.5s 3.4x

关键优化技巧

1. Web Worker卸载解析

// excel-worker.js
self.onmessage = async (e) => {
  const { buffer, sheetName } = e.data;
  
  // 在Worker线程解析,不阻塞主线程
  const workbook = XLSX.read(buffer, { type: 'array' });
  const sheet = workbook.Sheets[sheetName];
  const data = XLSX.utils.sheet_to_json(sheet, { header: 1 });
  
  self.postMessage({ data, formulas: extractFormulas(sheet) });
};

2. 虚拟滚动(Excel大数据)

<VirtualList
  :items="flattenedData"
  :item-height="28"
  :buffer="5"
  v-slot="{ item, index }"
>
  <TableRow :cells="item" :row-index="index" />
</VirtualList>

3. 图片懒加载(PDF/PPT)

const imageObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src; // 真正加载
      imageObserver.unobserve(img);
    }
  });
});

我们提供了一个开源SDK版本,大家可以轻松集成到项目里使用:

github:github.com/MrXujiang/j…

总结与展望

这套方案的核心价值在于轻量与可控

  • 轻量:前端包体积<500KB,无需重型服务器
  • 可控:源码支持二次开发,模块化解耦设计
  • 协同:与文档系统深度集成,而非孤立的预览窗口

未来规划

  1. WebAssembly加速:将公式计算用Rust重写,编译为WASM
  2. Rag知识库:支持文档即知识的Rag动态知识库功能
  3. AI增强:PDF自动摘要、Excel智能分析

如果大家有好的方案,欢迎随时交流反馈~

往期精彩:

拒绝重复造轮子?我们偏偏花365天,用Vue3写了款AI协同的Word编辑器

iOS开发有什么好用的图片浏览器?

年更博主终于推出新版本,JXPhotoBrowser v4.0 全面重构焕新!

JXPhotoBrowser 是一个轻量级、可定制的 iOS 图片/视频浏览器,实现 iOS 系统相册的交互体验。支持缩放、拖拽关闭、自定义转场动画等特性,架构清晰,易于集成和扩展。同时支持 UIKitSwiftUI 两种调用方式(SwiftUI 通过桥接层集成,详见 Demo-SwiftUI 示例工程)。

首页列表 图片浏览 下拉关闭
homepage.png browsing.png pull_down.png

核心设计

  • 零数据模型依赖:框架不定义任何数据模型,业务方完全使用自己的数据结构,通过 delegate 配置 Cell 内容。
  • 图片加载完全开放:框架不内置图片加载逻辑,业务方可自由选择 Kingfisher、SDWebImage 或其他任意图片加载方案。
  • 极简 Cell 协议JXPhotoBrowserCellProtocol 仅包含 browsertransitionImageView 两个属性,将浏览器与具体 Cell 实现解耦,既可以直接使用内置的 JXZoomImageCell,也可以实现完全自定义的 Cell。
  • 协议驱动的数据与 UI 解耦JXPhotoBrowserDelegate 只关心数量、Cell 与转场,不强制统一的数据模型。

功能特性

  • 多模式浏览:支持水平(Horizontal)和垂直(Vertical)两个方向的滚动浏览。
  • 无限循环:支持无限循环滚动(Looping),无缝切换首尾图片。
  • 手势交互
    • 双击缩放:仿系统相册支持双击切换缩放模式。
    • 捏合缩放:支持双指捏合随意缩放(1.0x - 3.0x)。
    • 拖拽关闭:支持下滑手势(Pan)交互式关闭,伴随图片缩小和背景渐变效果。
  • 转场动画
    • Fade:经典的渐隐渐现效果。
    • Zoom:类似微信/系统相册的缩放转场效果,无缝衔接列表与大图。
    • None:无动画直接显示。
  • 浏览体验优化:基于 UICollectionView 复用机制,内存占用低,滑动流畅。
  • 自定义 Cell 支持:内置图片 JXZoomImageCell,也支持通过协议与注册机制接入完全自定义的 Cell(如视频播放 Cell)。
  • Overlay 组件机制:支持按需装载附加 UI 组件(如页码指示器、关闭按钮等),默认不装载任何组件,零开销。内置 JXPageIndicatorOverlay 页码指示器。

核心架构

  • JXPhotoBrowserViewController:核心控制器,继承自 UIViewController。内部维护一个 UICollectionView 用于展示图片页面,负责处理全局配置(如滚动方向、循环模式)和手势交互(如下滑关闭)。
  • JXZoomImageCell:可缩放图片展示单元,继承自 UICollectionViewCell 并实现 JXPhotoBrowserCellProtocol。内部使用 UIScrollView 实现缩放,负责单击、双击等交互。通过 imageView 属性供业务方设置图片。
  • JXImageCell:轻量级图片展示 Cell,不支持缩放手势,适用于 Banner 等嵌入式场景。内置可选的加载指示器(默认不启用),支持样式定制。
  • JXPhotoBrowserCellProtocol:极简 Cell 协议,仅需 browser(弱引用浏览器)和 transitionImageView(转场视图)两个属性即可接入浏览器,另提供 photoBrowserDismissInteractionDidChange 可选方法响应下拉关闭交互,不强制依赖特定基类。
  • JXPhotoBrowserDelegate:代理协议,负责提供总数、Cell 实例、生命周期回调(willDisplay/didEndDisplaying)以及转场动画所需的缩略图视图等,不强制要求统一的数据模型。
  • JXPhotoBrowserOverlay:附加视图组件协议,定义了 setupreloadDatadidChangedPageIndex 三个方法,用于页码指示器、关闭按钮等附加 UI 的统一接入。
  • JXPageIndicatorOverlay:内置页码指示器组件,基于 UIPageControl,支持自定义位置和样式,通过 addOverlay 按需装载。

依赖

  • 框架本身依赖:UIKit(核心),无任何第三方依赖
  • 图片加载:框架不内置图片加载逻辑,业务方可自由选择 Kingfisher、SDWebImage 或其他任意图片加载方案。
  • 示例工程:
    • Demo-UIKit:UIKit 示例,使用 CocoaPods 集成,依赖 Kingfisher 加载图片,演示完整功能(图片浏览、视频播放、Banner 轮播等)。
    • Demo-SwiftUI:SwiftUI 示例,使用 SPM 集成,演示如何通过桥接层在 SwiftUI 中使用 JXPhotoBrowser(媒体网格、设置面板、图片浏览)。
    • Demo-Carthage:UIKit 示例,使用 Carthage 集成。首次使用需在 Demo-Carthage 目录下执行 carthage update --use-xcframeworks --platform iOS 构建框架。

隐私清单(Privacy Manifest)

本框架已包含 PrivacyInfo.xcprivacy 隐私清单文件,符合 Apple 自 2024 年春季起对第三方 SDK 的隐私清单要求。

JXPhotoBrowser 不追踪用户、不收集任何数据、不使用任何 Required Reason API,隐私清单中所有字段均为空声明。通过 CocoaPods、SPM 或 Carthage 集成时,隐私清单会自动包含在框架中,无需额外配置。

系统要求

  • iOS 12.0+
  • Swift 5.4+

安装

CocoaPods

在你的 Podfile 中添加:

pod 'JXPhotoBrowser', '~> 4.0.1'

注意:Xcode 15 起默认开启了 User Script SandboxingENABLE_USER_SCRIPT_SANDBOXING=YES),该沙盒机制会阻止 CocoaPods 的 Run Script 阶段(如 [CP] Copy Pods Resources[CP] Embed Pods Frameworks 等)访问沙盒外的文件,导致编译失败。需要在编译 Target 的 Build Settings 中将 ENABLE_USER_SCRIPT_SANDBOXING 设置为 NO

Target → Build Settings → Build Options → User Script Sandboxing → No

Swift Package Manager

在 Xcode 中:

  1. 选择 File > Add Package Dependencies...
  2. 输入仓库地址:https://github.com/JiongXing/PhotoBrowser
  3. 选择版本规则后点击 Add Package

或在 Package.swift 中添加依赖:

dependencies: [
    .package(url: "https://github.com/JiongXing/PhotoBrowser", from: "4.0.1")
]

Carthage

在你的 Cartfile 中添加:

github "JiongXing/PhotoBrowser"

然后运行:

carthage update --use-xcframeworks --platform iOS

构建完成后,将 Carthage/Build/JXPhotoBrowser.xcframework 拖入 Xcode 工程的 Frameworks, Libraries, and Embedded Content 中,并设置为 Embed & Sign

手动安装

Sources 目录下的所有文件拖入你的工程中。

快速开始

基础用法

import JXPhotoBrowser

// 1. 创建浏览器实例
let browser = JXPhotoBrowserViewController()
browser.delegate = self
browser.initialIndex = indexPath.item // 设置初始索引

// 2. 配置选项(可选)
browser.scrollDirection = .horizontal // 滚动方向
browser.transitionType = .zoom        // 转场动画类型
browser.isLoopingEnabled = true       // 是否开启无限循环

// 3. 展示
browser.present(from: self)

实现 Delegate

遵守 JXPhotoBrowserDelegate 协议,提供数据和转场支持:

import Kingfisher // 示例使用 Kingfisher,可替换为任意图片加载库

extension ViewController: JXPhotoBrowserDelegate {
    // 1. 返回图片总数
    func numberOfItems(in browser: JXPhotoBrowserViewController) -> Int {
        return items.count
    }
    
    // 2. 提供用于展示的 Cell
    func photoBrowser(_ browser: JXPhotoBrowserViewController, cellForItemAt index: Int, at indexPath: IndexPath) -> JXPhotoBrowserAnyCell {
        let cell = browser.dequeueReusableCell(withReuseIdentifier: JXZoomImageCell.reuseIdentifier, for: indexPath) as! JXZoomImageCell
        return cell
    }
    
    // 3. 当 Cell 将要显示时加载图片
    func photoBrowser(_ browser: JXPhotoBrowserViewController, willDisplay cell: JXPhotoBrowserAnyCell, at index: Int) {
        guard let photoCell = cell as? JXZoomImageCell else { return }
        let item = items[index]
        
        // 使用 Kingfisher 加载图片(可替换为 SDWebImage 或其他库)
        let placeholder = ImageCache.default.retrieveImageInMemoryCache(forKey: item.thumbnailURL.absoluteString)
        photoCell.imageView.kf.setImage(with: item.originalURL, placeholder: placeholder) { [weak photoCell] _ in
            photoCell?.setNeedsLayout()
        }
    }
    
    // 4. (可选) Cell 结束显示时清理资源(如取消加载、停止播放等)
    func photoBrowser(_ browser: JXPhotoBrowserViewController, didEndDisplaying cell: JXPhotoBrowserAnyCell, at index: Int) {
        // 可用于取消图片加载、停止视频播放等
    }
    
    // 5. (可选) 支持 Zoom 转场:提供列表中的缩略图视图
    func photoBrowser(_ browser: JXPhotoBrowserViewController, thumbnailViewAt index: Int) -> UIView? {
        let indexPath = IndexPath(item: index, section: 0)
        guard let cell = collectionView.cellForItem(at: indexPath) as? MyCell else { return nil }
        return cell.imageView
    }
    
    // 6. (可选) 控制缩略图显隐,避免 Zoom 转场时视觉重叠
    func photoBrowser(_ browser: JXPhotoBrowserViewController, setThumbnailHidden hidden: Bool, at index: Int) {
        let indexPath = IndexPath(item: index, section: 0)
        if let cell = collectionView.cellForItem(at: indexPath) as? MyCell {
            cell.imageView.isHidden = hidden
        }
    }
    
    // 7. (可选) 自定义 Cell 尺寸,默认使用浏览器全屏尺寸
    func photoBrowser(_ browser: JXPhotoBrowserViewController, sizeForItemAt index: Int) -> CGSize? {
        return nil // 返回 nil 使用默认尺寸
    }
}

在 SwiftUI 中使用

JXPhotoBrowser 是基于 UIKit 的框架,在 SwiftUI 项目中可通过桥接方式集成。Demo-SwiftUI 示例工程演示了完整的集成方案。

核心思路

  1. 网格和设置面板使用纯 SwiftUI 实现(LazyVGridPickerAsyncImage 等)
  2. 全屏图片浏览器通过桥接层调用 JXPhotoBrowserViewController
  3. 创建一个 Presenter 类实现 JXPhotoBrowserDelegate,获取当前 UIViewController 后调用 browser.present(from:)

桥接层示例

import JXPhotoBrowser

/// 封装 JXPhotoBrowserViewController 的创建、配置和呈现
final class PhotoBrowserPresenter: JXPhotoBrowserDelegate {
    private let items: [MyMediaItem]

    func present(initialIndex: Int) {
        guard let viewController = topViewController() else { return }

        let browser = JXPhotoBrowserViewController()
        browser.delegate = self
        browser.initialIndex = initialIndex
        browser.transitionType = .fade
        browser.addOverlay(JXPageIndicatorOverlay())
        browser.present(from: viewController)
    }

    func numberOfItems(in browser: JXPhotoBrowserViewController) -> Int {
        items.count
    }

    func photoBrowser(_ browser: JXPhotoBrowserViewController, cellForItemAt index: Int, at indexPath: IndexPath) -> JXPhotoBrowserAnyCell {
        browser.dequeueReusableCell(withReuseIdentifier: JXZoomImageCell.reuseIdentifier, for: indexPath) as! JXZoomImageCell
    }

    func photoBrowser(_ browser: JXPhotoBrowserViewController, willDisplay cell: JXPhotoBrowserAnyCell, at index: Int) {
        guard let photoCell = cell as? JXZoomImageCell else { return }
        // 加载图片到 photoCell.imageView ...
    }
}

在 SwiftUI View 中调用

struct ContentView: View {
    // 持有 presenter(JXPhotoBrowserViewController.delegate 为 weak,需要外部强引用)
    @State private var presenter: PhotoBrowserPresenter?

    var body: some View {
        LazyVGrid(columns: columns) {
            ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
                AsyncImage(url: item.thumbnailURL)
                    .onTapGesture {
                        let p = PhotoBrowserPresenter(items: items)
                        presenter = p
                        p.present(initialIndex: index)
                    }
            }
        }
    }
}

注意JXPhotoBrowserViewControllerdelegateweak 引用,必须在 SwiftUI 侧用 @State 持有 Presenter 实例,否则它会在创建后立即被释放。

关于 Zoom 转场

Demo-SwiftUI 示例工程未演示 Zoom 转场动画,默认使用 Fade 转场。

原因:Zoom 转场依赖 thumbnailViewAt delegate 方法返回列表中缩略图的 UIView 引用,框架通过该引用计算动画起止位置并构建临时动画视图。而 SwiftUI 的 AsyncImage 等原生视图无法直接提供底层 UIView 引用。

如需自行实现:可将缩略图从 AsyncImage 替换为 UIViewRepresentable 包裹的 UIImageView,从而获取真实的 UIView 引用,再通过 thumbnailViewAtsetThumbnailHidden 两个 delegate 方法提供给框架即可。具体的 Zoom 转场接入方式可参考 Demo-UIKit 示例工程。

JXImageCell 加载指示器

JXImageCell 内置了一个 UIActivityIndicatorView 加载指示器,默认不启用。适用于 Banner 等嵌入式场景下展示图片加载状态。

启用加载指示器

let cell = browser.dequeueReusableCell(withReuseIdentifier: JXImageCell.reuseIdentifier, for: indexPath) as! JXImageCell

// 启用加载指示器
cell.isLoadingIndicatorEnabled = true
cell.startLoading()

// 图片加载完成后停止
cell.imageView.kf.setImage(with: imageURL) { [weak cell] _ in
    cell?.stopLoading()
}

自定义样式

通过 loadingIndicator 属性可直接定制指示器的外观:

cell.loadingIndicator.style = .large       // 指示器尺寸
cell.loadingIndicator.color = .systemBlue  // 指示器颜色

自定义 Cell

框架支持两种方式创建自定义 Cell:

方式一:继承 JXZoomImageCell(推荐)

继承 JXZoomImageCell 可自动获得缩放、转场、手势等功能。以 Demo 中的 VideoPlayerCell 为例,它继承 JXZoomImageCell 并添加了视频播放能力:

class VideoPlayerCell: JXZoomImageCell {
    static let videoReuseIdentifier = "VideoPlayerCell"
    
    private var player: AVPlayer?
    private var playerLayer: AVPlayerLayer?
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        // 自定义初始化:添加 loading 指示器等
    }
    
    /// 配置视频资源
    func configure(videoURL: URL, coverImage: UIImage? = nil) {
        imageView.image = coverImage
        // 创建播放器并开始播放...
    }
    
    /// 重写单击手势:暂停视频或关闭浏览器
    override func handleSingleTap(_ gesture: UITapGestureRecognizer) {
        if isPlaying {
            pauseVideo()
        } else {
            browser?.dismissSelf()
        }
    }
}

方式二:实现协议(完全自定义)

直接实现 JXPhotoBrowserCellProtocol 协议,获得完全的自由度:

class StandaloneCell: UICollectionViewCell, JXPhotoBrowserCellProtocol {
    static let reuseIdentifier = "StandaloneCell"
    
    // 必须实现:弱引用浏览器(避免循环引用)
    weak var browser: JXPhotoBrowserViewController?
    
    // 可选实现:用于 Zoom 转场动画,返回 nil 则使用 Fade 动画
    var transitionImageView: UIImageView? { imageView }
    
    let imageView = UIImageView()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        // 自定义初始化
    }
    
    // 可选实现:下拉关闭交互状态变化时调用
    // isInteracting 为 true 表示用户正在下拉(图片缩小跟随手指),false 表示交互结束(回弹恢复)
    // 适用于在拖拽关闭过程中暂停视频、隐藏附加 UI 等场景
    func photoBrowserDismissInteractionDidChange(isInteracting: Bool) {
        // 例如:下拉时暂停视频播放
    }
}

注册和使用自定义 Cell

let browser = JXPhotoBrowserViewController()

// 注册自定义 Cell(必须在设置 delegate 之前)
browser.register(VideoPlayerCell.self, forReuseIdentifier: VideoPlayerCell.videoReuseIdentifier)

browser.delegate = self
browser.present(from: self)

// 在 delegate 中使用
func photoBrowser(_ browser: JXPhotoBrowserViewController, cellForItemAt index: Int, at indexPath: IndexPath) -> JXPhotoBrowserAnyCell {
    let cell = browser.dequeueReusableCell(withReuseIdentifier: VideoPlayerCell.videoReuseIdentifier, for: indexPath) as! VideoPlayerCell
    cell.configure(videoURL: url, coverImage: thumbnail)
    return cell
}

Overlay 组件

框架提供了通用的 Overlay 组件机制,用于在浏览器上层叠加附加 UI(如页码指示器、关闭按钮、标题栏等)。默认不装载任何 Overlay,业务方按需装载

使用内置页码指示器

框架内置了 JXPageIndicatorOverlay(基于 UIPageControl),一行代码即可装载:

let browser = JXPhotoBrowserViewController()
browser.addOverlay(JXPageIndicatorOverlay())

支持自定义位置和样式:

let indicator = JXPageIndicatorOverlay()
indicator.position = .bottom(padding: 20)  // 位置:底部距离 20pt(也支持 .top)
indicator.hidesForSinglePage = true         // 仅一页时自动隐藏
indicator.pageControl.currentPageIndicatorTintColor = .white
indicator.pageControl.pageIndicatorTintColor = .lightGray
browser.addOverlay(indicator)

自定义 Overlay

实现 JXPhotoBrowserOverlay 协议即可创建自定义组件:

class CloseButtonOverlay: UIView, JXPhotoBrowserOverlay {
    
    func setup(with browser: JXPhotoBrowserViewController) {
        // 在此完成布局(如添加约束)
    }
    
    func reloadData(numberOfItems: Int, pageIndex: Int) {
        // 数据或布局变化时更新
    }
    
    func didChangedPageIndex(_ index: Int) {
        // 页码变化时更新
    }
}

// 装载
browser.addOverlay(CloseButtonOverlay())

多个 Overlay 可同时装载,互不干扰:

browser.addOverlay(JXPageIndicatorOverlay())
browser.addOverlay(CloseButtonOverlay())

保存图片/视频到相册

框架本身不内置保存功能,业务方可自行实现。Demo 中演示了通过长按手势弹出 ActionSheet 保存媒体到系统相册的完整流程。

前提:需要在 Info.plist 中配置 NSPhotoLibraryAddUsageDescription(写入相册权限描述)。

核心步骤

  1. 添加长按手势:在自定义 Cell 中添加 UILongPressGestureRecognizer
  2. 弹出 ActionSheet:通过 browser 属性获取浏览器控制器来 present。
  3. 请求权限并保存:使用 PHPhotoLibrary 请求权限,下载后写入相册。

示例:在自定义 Cell 中长按保存

以 Demo 中的 VideoPlayerCell 为例,继承 JXZoomImageCell 后添加长按保存能力:

import Photos

class VideoPlayerCell: JXZoomImageCell {
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        // 添加长按手势
        let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
        scrollView.addGestureRecognizer(longPress)
    }
    
    @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
        guard gesture.state == .began else { return }
        
        let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
        alert.addAction(UIAlertAction(title: "保存视频", style: .default) { [weak self] _ in
            self?.saveVideoToAlbum()
        })
        alert.addAction(UIAlertAction(title: "取消", style: .cancel))
        
        // 通过 browser 属性获取浏览器控制器来 present
        browser?.present(alert, animated: true)
    }
    
    private func saveVideoToAlbum() {
        guard let url = videoURL else { return }
        
        // 1. 请求相册写入权限
        PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
            guard status == .authorized || status == .limited else { return }
            
            // 2. 下载视频(远程 URL 需先下载到本地)
            URLSession.shared.downloadTask(with: url) { tempURL, _, _ in
                guard let tempURL else { return }
                
                // 3. 写入相册
                PHPhotoLibrary.shared().performChanges({
                    PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: tempURL)
                }) { success, error in
                    // 处理结果...
                }
            }.resume()
        }
    }
}

保存图片的流程类似,将下载部分替换为图片写入即可:

// 下载图片数据
URLSession.shared.dataTask(with: imageURL) { data, _, _ in
    guard let data, let image = UIImage(data: data) else { return }
    
    PHPhotoLibrary.shared().performChanges({
        PHAssetChangeRequest.creationRequestForAsset(from: image)
    }) { success, error in
        // 处理结果...
    }
}.resume()

常见问题 (FAQ)

Q: Zoom 转场动画时图片尺寸不对或有闪烁现象?

A: 这通常是因为打开浏览器时,目标 Cell 的 imageView 还没有设置图片,导致其 bounds 为 zero。

解决方案:在 willDisplay 代理方法中,确保同步设置占位图。例如使用 Kingfisher 时:

func photoBrowser(_ browser: JXPhotoBrowserViewController, willDisplay cell: JXPhotoBrowserAnyCell, at index: Int) {
    guard let photoCell = cell as? JXZoomImageCell else { return }
    
    // 同步从缓存取出缩略图作为占位图
    let placeholder = ImageCache.default.retrieveImageInMemoryCache(forKey: thumbnailURL.absoluteString)
    photoCell.imageView.kf.setImage(with: imageURL, placeholder: placeholder) { [weak photoCell] _ in
        photoCell?.setNeedsLayout()
    }
}

这样可以确保转场动画开始时,Cell 已经有正确尺寸的图片,动画效果更加流畅。

项目开源地址

github.com/JiongXing/P…

How to Revert a Commit in Git

The git revert command creates a new commit that undoes the changes introduced by a specified commit. Unlike git reset , which rewrites the commit history, git revert preserves the full history and is the safe way to undo changes that have already been pushed to a shared repository.

This guide explains how to use git revert to undo one or more commits with practical examples.

Quick Reference

Task Command
Revert the last commit git revert HEAD
Revert without opening editor git revert --no-edit HEAD
Revert a specific commit git revert COMMIT_HASH
Revert without committing git revert --no-commit HEAD
Revert a range of commits git revert --no-commit HEAD~3..HEAD
Revert a merge commit git revert -m 1 MERGE_COMMIT_HASH
Abort a revert in progress git revert --abort
Continue after resolving conflicts git revert --continue

Syntax

The general syntax for the git revert command is:

Terminal
git revert [OPTIONS] COMMIT
  • OPTIONS — Flags that modify the behavior of the command.
  • COMMIT — The commit hash or reference to revert.

git revert vs git reset

Before diving into examples, it is important to understand the difference between git revert and git reset, as they serve different purposes:

  • git revert — Creates a new commit that undoes the changes from a previous commit. The original commit remains in the history. This is safe to use on branches that have been pushed to a remote repository.
  • git reset — Moves the HEAD pointer backward, effectively removing commits from the history. This rewrites the commit history and should not be used on shared branches without coordinating with your team.

As a general rule, use git revert for commits that have already been pushed, and git reset for local commits that have not been shared.

Reverting the Last Commit

To revert the most recent commit, run git revert followed by HEAD:

Terminal
git revert HEAD

Git will open your default text editor so you can edit the revert commit message. The default message looks like this:

plain
Revert "Original commit message"

This reverts commit abc1234def5678...

Save and close the editor to complete the revert. Git will create a new commit that reverses the changes from the last commit.

To verify the revert, use git log to see the new revert commit in the history:

Terminal
git log --oneline -3
output
a1b2c3d (HEAD -> main) Revert "Add new feature"
f4e5d6c Add new feature
b7a8c9d Update configuration

The original commit (f4e5d6c) remains in the history, and the new revert commit (a1b2c3d) undoes its changes.

Reverting a Specific Commit

You do not have to revert the most recent commit. You can revert any commit in the history by specifying its commit hash.

First, find the commit hash using git log:

Terminal
git log --oneline
output
a1b2c3d (HEAD -> main) Update README
f4e5d6c Add login feature
b7a8c9d Fix navigation bug
e0f1a2b Add search functionality

To revert the “Fix navigation bug” commit, pass its hash to git revert:

Terminal
git revert b7a8c9d

Git will create a new commit that undoes only the changes introduced in commit b7a8c9d, leaving all other commits intact.

Reverting Without Opening an Editor

If you want to use the default revert commit message without opening an editor, use the --no-edit option:

Terminal
git revert --no-edit HEAD
output
[main d5e6f7a] Revert "Add new feature"
2 files changed, 0 insertions(+), 15 deletions(-)

This is useful when scripting or when you do not need a custom commit message.

Reverting Without Committing

By default, git revert automatically creates a new commit. If you want to stage the reverted changes without committing them, use the --no-commit (or -n) option:

Terminal
git revert --no-commit HEAD

The changes will be applied to the working directory and staging area, but no commit is created. You can then review the changes, make additional modifications, and commit manually:

Terminal
git status
git commit -m "Revert feature and clean up related code"

This is useful when you want to combine the revert with other changes in a single commit.

Reverting Multiple Commits

Reverting a Range of Commits

To revert a range of consecutive commits, specify the range using the .. notation:

Terminal
git revert --no-commit HEAD~3..HEAD

This reverts the last three commits. The --no-commit option stages all the reverted changes without creating individual revert commits, allowing you to commit them as a single revert:

Terminal
git commit -m "Revert last three commits"

Without --no-commit, Git will create a separate revert commit for each commit in the range.

Reverting Multiple Individual Commits

To revert multiple specific (non-consecutive) commits, list them one after another:

Terminal
git revert --no-commit abc1234 def5678 ghi9012
git commit -m "Revert selected commits"

Reverting a Merge Commit

Merge commits have two parent commits, so Git needs to know which parent to revert to. Use the -m option followed by the parent number (usually 1 for the branch you merged into):

Terminal
git revert -m 1 MERGE_COMMIT_HASH

In the following example, we revert a merge commit and keep the main branch as the base:

Terminal
git revert -m 1 a1b2c3d
  • -m 1 — Tells Git to use the first parent (the branch the merge was made into, typically main or master) as the base.
  • -m 2 — Would use the second parent (the branch that was merged in).

You can check the parents of a merge commit using:

Terminal
git log --oneline --graph

Handling Conflicts During Revert

If the code has changed since the original commit, Git may encounter conflicts when applying the revert. When this happens, Git will pause the revert and show conflicting files:

output
CONFLICT (content): Merge conflict in src/app.js
error: could not revert abc1234... Add new feature
hint: After resolving the conflicts, mark the corrected paths
hint: with 'git add <paths>' and run 'git revert --continue'.

To resolve the conflict:

  1. Open the conflicting files and resolve the merge conflicts manually.

  2. Stage the resolved files:

    Terminal
    git add src/app.js
  3. Continue the revert:

    Terminal
    git revert --continue

If you decide not to proceed with the revert, you can abort it:

Terminal
git revert --abort

This restores the repository to the state before you started the revert.

Pushing a Revert to a Remote Repository

Since git revert creates a new commit rather than rewriting history, you can safely push it to a shared repository using a regular push:

Terminal
git push origin main

There is no need for --force because the commit history is not rewritten.

Common Options

The git revert command accepts several options:

  • --no-edit — Use the default commit message without opening an editor.
  • --no-commit (-n) — Apply the revert to the working directory and index without creating a commit.
  • -m parent-number — Specify which parent to use when reverting a merge commit (usually 1).
  • --abort — Cancel the revert operation and return to the pre-revert state.
  • --continue — Continue the revert after resolving conflicts.
  • --skip — Skip the current commit and continue reverting the rest.

Troubleshooting

Revert is in progress
If you see a message that a revert is in progress, either continue with git revert --continue after resolving conflicts or cancel with git revert --abort.

Revert skips a commit
If Git reports that a commit was skipped because it was already applied, it means the changes are already present. You can proceed or use git revert --skip to continue.

FAQ

What is the difference between git revert and git reset?
git revert creates a new commit that undoes changes while preserving the full commit history. git reset moves the HEAD pointer backward and can remove commits from the history. Use git revert for shared branches and git reset for local, unpushed changes.

Can I revert a commit that has already been pushed?
Yes. This is exactly what git revert is designed for. Since it creates a new commit rather than rewriting history, it is safe to push to shared repositories without disrupting other collaborators.

How do I revert a merge commit?
Use the -m option to specify the parent number. For example, git revert -m 1 MERGE_HASH reverts the merge and keeps the first parent (usually the main branch) as the base.

Can I undo a git revert?
Yes. Since a revert is just a regular commit, you can revert the revert commit itself: git revert REVERT_COMMIT_HASH. This effectively re-applies the original changes.

What happens if there are conflicts during a revert?
Git will pause the revert and mark the conflicting files. You need to resolve the conflicts manually, stage the files with git add, and then run git revert --continue to complete the operation.

Conclusion

The git revert command is the safest way to undo changes in a Git repository, especially for commits that have already been pushed. It creates a new commit that reverses the specified changes while keeping the full commit history intact.

If you have any questions, feel free to leave a comment below.

❌