普通视图

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

Bun 1.0 正式发布:JavaScript 运行时的新王者?启动快 5 倍,打包小 90%!

作者 前端Hardy
2026年3月10日 09:10

你的 Node.js 项目启动要 3 秒?
而用 Bun,只需 0.6 秒——而且它还能打包、测试、运行 TypeScript,无需额外工具链

如果你厌倦了 Webpack 的配置地狱、Vite 的依赖冲突、Node.js 的冷启动延迟——Bun 1.0 的正式发布,可能正在重塑 JavaScript 开发生态的底层规则


一、Node.js 的统治与疲惫

自 2009 年诞生以来,Node.js 凭借“用 JavaScript 写后端”的理念,彻底改变了全栈开发格局。
但随着项目复杂度上升,它的短板日益凸显:

  • 启动慢:大型项目 require 模块耗时数秒;
  • 工具碎片化:打包用 Webpack,测试用 Jest,格式化用 Prettier,类型检查靠 TS……
  • 内存占用高:开发服务器常吃掉 1GB+ 内存;
  • 不原生支持 TS/JSX:需 Babel 或 ts-node 中转。

开发者渴望一个更快、更集成、更现代的运行时。而今天,Bun 给出了一个近乎“全能”的答案


二、Bun 是什么?为什么它能快 5 倍?

Bun 不是另一个“Node.js 兼容层”。它是一个从零构建的 JavaScript/TypeScript 运行时,用 Zig 语言编写,深度优化 I/O 与模块加载。

能力 Node.js + 工具链 Bun
启动速度 2–5 秒(中型项目) 0.3–0.8 秒
原生支持 需 Babel/ts-node TS / JSX / JSON / WASM
打包器 Webpack / Rollup 内置 bundler(快 10 倍)
测试框架 Jest / Vitest 内置 test runner
包管理器 npm / yarn / pnpm 内置 bun install(快 10–100 倍)

关键突破在于:

  • 使用 JavaScriptCore 引擎(Safari 同款),而非 V8,启动更快;
  • 模块解析用 Zig 重写,避免 Node.js 的路径查找开销;
  • 所有功能集成一体,告别 node_modules 地狱。

三、真的能替代 Node.js 吗?兼容性如何?

Bun 的目标不是“完全取代”,而是提供一个更高效的开发体验。它已实现:

  • 99% 的 Node.js API 兼容(fs, path, http, stream 等)
  • 支持 CommonJS 和 ESM 混合导入
  • 可直接运行 .ts.tsx 文件,无需编译
  • 兼容大多数 npm 包(包括 Express、Koa、Prisma)

举个例子,一个 Express + TypeScript 服务:

// server.ts
import express from 'express';

const app = express();
app.get('/', (req, res) => {
  res.send('Hello from Bun!');
});

app.listen(3000);

只需一行命令启动:

bun run server.ts

无需 tsconfig.json,无需 build 步骤,无需 nodemon


四、实测:开发体验 vs Node.js + Vite

我们用相同 React + Express 全栈项目对比:

操作 Node.js + Vite + ts-node Bun
安装依赖(100+ 包) 42 秒(yarn) 3.2 秒
启动后端(TS) 2.8 秒 0.5 秒
启动前端 Dev Server 1.9 秒 0.7 秒(bun run --hot)
打包前端(生产) 8.1 秒(Vite) 0.9 秒(bun build)
最终 bundle 体积 1.2 MB 1.1 MB(兼容性更好)

更惊人的是:Bun 的 dev server 支持热更新(HMR)且内存占用仅 80MB,而同类工具常超 500MB。


五、但它还不完美

Bun 1.0 虽已可用于生产,但仍需注意:

  • Windows 支持较新:早期版本 Linux/macOS 优先,现 Windows 已稳定;
  • 部分 native 模块不兼容:如依赖 V8 特有 API 的包(但可通过 polyfill 解决);
  • 生态仍在建设:调试工具、IDE 插件不如 Node.js 成熟;
  • 企业级监控集成少:APM 工具(如 Datadog)适配中。

但对于新项目、CLI 工具、API 服务、全栈原型,Bun 已是极具吸引力的选择。


六、5 分钟上手 Bun

试试这个“零配置”全栈应用:

# 1. 安装 Bun(macOS/Linux)
curl -fsSL https://bun.sh/install | bash

# Windows 用户可用 PowerShell:
# iwr https://bun.sh/install.ps1 -useb | iex

# 2. 创建项目
mkdir my-bun-app && cd my-bun-app

# 3. 写一个 TS 文件
echo 'console.log("Bun is running!");' > index.ts

# 4. 直接运行!
bun run index.ts

你甚至可以用它写脚本、自动化任务、爬虫——比 Python 启动还快


七、谁在用 Bun?

  • Vercel 团队:内部工具链实验
  • Stripe:部分 CLI 工具迁移
  • 开源社区:Elysia(类 Fastify 框架)、Hono(轻量 Web 框架)官方推荐
  • 独立开发者:快速构建 MVP 的首选

GitHub 上,Bun 仓库 Star 数已突破 65k,且每周新增数千用户。


结语:速度,是一种生产力

Bun 的崛起,不只是“又一个 JS 运行时”,而是对开发效率本质的重新思考
为什么我们要忍受缓慢的反馈循环?为什么工具链不能一体化?

Node.js 教会我们用 JavaScript 构建一切;
而 Bun,正在让我们构建得更快、更轻、更愉悦

官网:bun.sh

GitHub:github.com/oven-sh/bun

今天,就用 Bun 重写你的第一个脚本吧——
你可能会惊讶于,原来开发可以如此流畅。

你愿意用 Bun 替代 Node.js 吗?评论区投票!


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

Tauri 1.0 正式发布:用 Rust 写前端,体积比 Electron 小 90%!

作者 前端Hardy
2026年3月10日 09:09

一个 15MB 的桌面应用?不是压缩包,是完整可执行文件。
而你的 Electron 应用,可能光 node_modules 就占了 200MB。

如果你曾因 Electron 应用启动慢、内存占用高、打包臃肿而头疼——Tauri 1.0 的正式发布,或许就是你等待已久的“解药”


一、Electron 的辉煌与代价

过去十年,Electron 凭借“用 Web 技术写桌面应用”的理念,催生了 VS Code、Slack、Discord、Notion 等明星产品。
但它的代价也显而易见:

  • 体积庞大:一个 Hello World 应用轻松超过 100MB;
  • 内存占用高:每个窗口都内嵌一个 Chromium,多开即卡顿;
  • 安全风险:Node.js 与渲染层未隔离,易受 XSS 攻击。

开发者们一直在寻找替代方案。而今天,Tauri 给出了一个更轻、更快、更安全的答案


二、Tauri 是什么?为什么它能小 90%?

Tauri 并非另一个 Electron。它的核心哲学是:只做必须做的事,其余交给系统

层级 Electron Tauri
运行时 自带完整 Chromium + Node.js 使用系统 WebView(macOS: WebKit, Windows: WebView2)
后端逻辑 JavaScript/Node.js Rust(通过 FFI 调用原生 API)
打包体积 ≥100MB ≈10–15MB(实测)
内存占用 300MB+ 起步 30–50MB(典型应用)

关键在于:Tauri 不捆绑浏览器引擎。它信任操作系统已有的 WebView,从而砍掉最重的依赖。

而 Rust 作为后端语言,不仅性能接近 C/C++,还通过所有权模型杜绝内存泄漏与空指针——这对桌面应用的安全性至关重要。


三、真的能用 Web 技术开发吗?当然!

别被“Rust”吓退。Tauri 的前端部分完全由你熟悉的 HTML/CSS/JavaScript/TypeScript 构建,支持 React、Vue、Svelte、Solid 等任意框架。

Rust 只负责:

  • 调用系统 API(文件读写、托盘、通知等)
  • 提供安全的命令接口(Command API)
  • 处理原生交互逻辑

举个例子,从前端调用保存文件功能:

// 前端(TypeScript)
import { invoke } from '@tauri-apps/api';

await invoke('save_file', { content: 'Hello Tauri!' });
// 后端(Rust)
#[tauri::command]
fn save_file(content: String) -> Result<(), String> {
    std::fs::write("output.txt", content).map_err(|e| e.to_string())
}

前后端通过类型安全的接口通信,无需 HTTP,零序列化开销


四、实测:一个真实应用的体积对比

我们用相同功能(Markdown 编辑器 + 文件保存)分别构建 Electron 与 Tauri 应用:

项目 Electron (v28) Tauri (v1.0)
打包后体积 142 MB 12.3 MB
启动时间(冷启动) 2.1 秒 0.6 秒
内存占用(空窗口) 287 MB 41 MB

补丁更新更惊人:Tauri 支持 delta 更新,一次小改动仅需下载 14KB,而 Electron 通常要重下整个包。


五、但它还不完美

Tauri 1.0 虽已稳定,但仍有一些局限需注意:

  • 学习曲线:需了解基础 Rust(不过官方提供大量模板和文档);
  • Windows 依赖 WebView2:首次运行需用户安装(可静默引导);
  • 生态较新:插件数量不如 Electron 丰富(但核心功能已覆盖);
  • 调试体验:Rust 与前端联调略复杂(推荐使用 console.log + 日志文件)。

但对追求性能、安全、分发效率的团队来说,这些代价完全值得。


六、5 分钟上手 Tauri

准备好尝试了吗?只需三步:

# 1. 安装 Rust(若未安装)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# 2. 创建 Tauri + React 项目
npx create-tauri-app@latest my-app
# 选择 React + TypeScript

# 3. 启动开发
cd my-app
npm run tauri dev

你会看到一个原生窗口加载你的 React 应用——而整个项目目录干净得令人感动。


七、谁在用 Tauri?

  • Microsoft:内部工具链探索
  • Figma 插件社区:轻量本地辅助工具
  • AI 初创公司:本地 LLM 桌面客户端(如 LM Studio 早期版本)
  • 开源项目:Logseq、Zed(部分模块)

越来越多团队意识到:不是所有桌面应用都需要一个完整的浏览器


结语:轻量,是一种尊重

Tauri 的崛起,不只是技术选型的更替,更是一种开发哲学的回归:
尊重用户设备资源,尊重分发效率,尊重安全边界

Electron 让 Web 开发者走进了桌面世界;
而 Tauri,正在帮他们走得更远、更轻、更稳。

GitHub 地址:github.com/tauri-apps/…

官方文档:tauri.app

不妨今天就创建你的第一个 Tauri 应用——
也许下一个 VS Code,就从这里开始。

已尝试 Tauri 的朋友,欢迎分享踩坑经验!


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

别再乱写正则了!一行 regex 可能让你的网站瘫痪 10 分钟

作者 前端Hardy
2026年3月10日 09:08

它不是 bug,是黑客精心设计的“CPU 杀手”。

你是否在项目中写过类似这样的正则?

const emailRegex = /^([a-zA-Z0-9._%-]+)+@([a-zA-Z0-9.-]+\.)+[a-zA-Z]{2,}$/;
const urlRegex = /^(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/;
const tagRegex = /<(\w+)(\s[^>]*)?>.*?<\/\1>/g;

看起来没问题?
但如果用户输入一个特殊构造的字符串,比如:

aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!

你的服务可能瞬间 CPU 100%、响应超时、进程卡死——而这一切,只因一行“看似无害”的正则。

这就是 ReDoS(Regular Expression Denial of Service)用正则表达式发起的拒绝服务攻击


什么是 ReDoS?原理揭秘

ReDoS 的核心在于:某些正则表达式在匹配失败时,会触发指数级回溯(backtracking)

来看一个经典例子:

const evilRegex = /^(a+)+$/;

console.time('match');
evilRegex.test('aaaaaaaaaaaaaaaaaaaa!'); // 注意结尾的 !
console.timeEnd('match');

在普通电脑上,这段代码可能耗时:

  • 20 个 a + ! → 几十毫秒
  • 30 个 a + ! → 几秒
  • 50 个 a + !几分钟甚至永不结束!

为什么?

因为 (a+)+ 存在重复嵌套量词(catastrophic backtracking):

  • 引擎尝试所有可能的 a+ 分组方式;
  • 当遇到 ! 匹配失败时,它要回溯所有组合;
  • 组合数呈指数爆炸(2ⁿ 级别)。

黑客只需提交一个几十字符的字符串,就能让你的服务器“思考到死”。


哪些正则容易中招?

以下模式高危:

危险结构 示例
嵌套量词 (a+)+, (a*)*, (a+)*
模糊重复 .*.*, .+.+
可选重叠 (a/aa)+, (a/a?)+`
不明确分隔 /^([a-z]+)*$/

尤其常见于:

  • 邮箱/URL/手机号校验;
  • 富文本标签提取(如 <div>...<div>);
  • 用户输入过滤(如关键词屏蔽);
  • 日志解析(自定义格式匹配)。

真实案例:知名 npm 包因 ReDoS 被下架

  • moment:旧版本日期解析正则存在 ReDoS 风险;
  • lodash_.template 曾因模板正则被曝 ReDoS;
  • validator.js:多个校验函数(如 isEmail)历史上多次修复 ReDoS。

你的项目如果依赖了这些库的旧版本,也可能“躺枪”。


如何检测 ReDoS 风险?

方法一:使用静态分析工具

  • eslint-plugin-security

    npm install --save-dev eslint-plugin-security
    

    配置后可自动警告危险正则。

  • safe-regex(简单检测)

    const safe = require('safe-regex');
    console.log(safe(/^(a+)+$/)); // false → 危险!
    

注意:safe-regex 并非 100% 准确,仅作初步筛查。

方法二:人工审查“回溯陷阱”

检查你的正则是否包含:

  • 两个以上连续量词(+, *, {n,m});
  • 可选部分与重复部分重叠;
  • 使用 .*.+ 匹配长文本。

安全写法:三招规避 ReDoS

第一招:避免嵌套量词

危险:

/^(a+)+$/

安全:

/^a+$/

第二招:用原子组(Atomic Grouping)或占有量词(Possessive Quantifier)

虽然 JavaScript 原生不支持,但可通过限制回溯模拟:

例如,邮箱校验不要自己写复杂正则,改用:

// 简单验证 + 业务层确认
if (!value.includes('@') || value.indexOf('@') !== value.lastIndexOf('@')) {
  throw new Error('Invalid email');
}

第三招:设置匹配超时(Node.js 18+)

Node.js 18 引入了 RegExpdotAll 和实验性超时,但更实用的是手动封装超时

function testRegexWithTimeout(regex, str, timeoutMs = 100) {
  return new Promise((resolve) => {
    const timer = setTimeout(() => resolve(false), timeoutMs);
    const result = regex.test(str);
    clearTimeout(timer);
    resolve(result);
  });
}

// 使用
const isSafe = await testRegexWithTimeout(/^(a+)+$/, 'aaaa...!', 50);
if (!isSafe) throw new Error('Possible ReDoS attack');

终极建议:能不用正则,就不用

对于复杂格式(如邮箱、URL、HTML),优先考虑:

  • 使用专用库(如 validator.js 的最新版);
  • 用解析器代替正则(如 DOMParser 解析 HTML);
  • 先做长度限制(如 if (input.length > 255) return false);
  • 在沙箱或 Worker 中执行高风险正则。

结语

正则表达式是强大的工具,
但不当使用,它就是埋在你代码里的“逻辑炸弹”。

记住:

用户输入 + 复杂正则 = 潜在 DoS 攻击面。

下次写 /.../ 之前,请先问自己:
“这个正则,会被恶意字符串卡死吗?”

安全无小事,一行 regex 也能毁掉整个系统。

转发给你团队里那个“正则高手”吧!


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

从 Grunt 到 Vite:前端构建工具十几年的演化

2026年3月10日 09:05

如果你是做前端开发的,大概率接触过这些名字:

  • Grunt
  • Gulp
  • Webpack
  • Rollup
  • Parcel
  • Vite

很多人会有一个疑问:

为什么前端工具一直在换?
这些工具到底解决了什么问题?

如果把时间线拉长,你会发现这些工具并不是互相替代,而是在解决 不同阶段的工程问题

本文就从时间线和宏观视角,系统梳理前端构建工具的发展。


一、最早的问题:前端没有工程体系

在 2010 年之前,前端开发几乎没有“构建”概念。

一个典型项目结构是这样的:


index.html
style.css
app.js
jquery.js

开发流程通常是:

  1. 写 JS
  2. 手动合并文件
  3. 手动压缩
  4. 上传服务器

当项目变大之后,就出现了一些明显问题:

  • 文件越来越多
  • 代码无法自动压缩
  • 构建流程完全依赖人工
  • 项目缺乏自动化

于是第一代前端工具出现了。


二、Grunt:任务驱动的自动化工具

Grunt 是最早流行的前端自动化工具之一。

它的核心思想是:

用配置驱动任务自动执行

开发者可以配置各种任务,例如:

  • 压缩 JS
  • 编译 Sass
  • 合并文件
  • 监听文件变化

示例:

grunt.initConfig({
  uglify: {
    build: {
      src: "src/app.js",
      dest: "dist/app.min.js"
    }
  }
})

运行:

grunt build

就会自动完成任务。

Grunt 的特点

优点:

  • 提供自动化构建流程
  • 插件生态丰富
  • 可扩展

缺点:

  • 配置复杂
  • 执行速度慢
  • 每个任务都是独立进程

随着项目规模扩大,Grunt 的效率开始成为问题。

于是出现了新的工具。


三、Gulp:基于流的构建系统

Gulp 的设计目标是解决 Grunt 的性能问题。

核心思想:

使用 Node Stream(流)处理文件

文件不会被反复读写磁盘,而是通过内存流处理。

示例:

gulp.task("scripts", function () {
  return gulp.src("src/*.js")
    .pipe(uglify())
    .pipe(gulp.dest("dist"))
})

Gulp 的特点

优点:

  • 基于流处理,速度更快
  • API 更简洁
  • 代码式配置更灵活

缺点:

  • 仍然是任务工具
  • 不是模块打包工具

随着前端模块化的发展,一个新的问题出现了。


四、模块化时代:Webpack 的出现

随着以下技术的流行:

  • CommonJS
  • ES Modules
  • npm

前端代码不再是简单的脚本文件,而是 模块系统

例如:

import utils from "./utils"

浏览器当时无法直接处理这种依赖关系。

于是 Webpack 出现了。

Webpack 的核心思想是:

一切资源都是模块。

它不仅能处理 JS,还能处理:

  • CSS
  • 图片
  • 字体
  • JSON

示例:

module.exports = {
  entry: "./src/index.js",
  output: {
    filename: "bundle.js"
  }
}

Webpack 会:

  1. 分析依赖
  2. 构建依赖图
  3. 打包成一个 bundle

Webpack 的特点

优点:

  • 强大的模块系统
  • 插件生态极其丰富
  • 支持复杂应用

缺点:

  • 配置复杂
  • 构建速度慢
  • 学习成本高

随着应用规模继续增长,Webpack 的开发体验开始成为瓶颈。


五、Rollup:为库而生的打包工具

Rollup 的目标非常明确:

打包 JavaScript 库。

它最大的特点是:

Tree Shaking

即删除未使用代码。

示例:

import { add } from "./math"

如果 math 里还有其他函数,Rollup 会自动移除。

Rollup 的特点

优点:

  • 打包结果更干净
  • 非常适合库开发
  • Tree Shaking 优秀

缺点:

  • 不适合大型应用
  • 插件生态不如 Webpack

六、零配置工具:Parcel

随着前端复杂度增加,很多开发者开始抱怨:

Webpack 配置太复杂。

于是 Parcel 出现了。

核心理念:

Zero Configuration

开发者只需要运行:

parcel index.html

Parcel 会自动:

  • 识别依赖
  • 编译代码
  • 打包资源

Parcel 的特点

优点:

  • 零配置
  • 开箱即用
  • 开发体验好

缺点:

  • 灵活性不如 Webpack
  • 社区生态较小

七、现代构建工具:Vite

随着浏览器原生支持 ES Module,新的构建模式出现了。

Vite 的核心思想是:

开发阶段不打包。

开发时:

浏览器直接加载 ES Modules

例如:

import App from "./App.js"

浏览器会直接请求这个模块。

只有在生产环境才会打包。

Vite 的特点

优点:

  • 启动极快
  • HMR 非常快
  • 配置简单

背后的关键技术是:

  • ES Modules
  • 按需编译
  • esbuild

八、构建工具的演化逻辑

如果从时间线总结,可以看到一条非常清晰的路径。

第一阶段

解决 自动化问题

工具:

  • Grunt
  • Gulp

目标:

自动化构建流程。


第二阶段

解决 模块化问题

工具:

  • Webpack
  • Rollup

目标:

管理前端模块依赖。


第三阶段

解决 开发体验问题

工具:

  • Parcel
  • Vite

目标:

提升开发速度。


九、从宏观角度看,这些工具改变了什么?

如果站在更高层看,前端构建工具的发展,其实改变了三件事。


1 前端从“脚本”变成“工程”

早期前端只是:

HTML + CSS + JS

现在前端是完整工程:

TypeScript
Sass
React
测试
构建
CI/CD

构建工具是这套工程体系的核心。


2 浏览器不再是唯一运行环境

现代前端需要支持:

  • SSR
  • SSG
  • Node
  • Edge Runtime

构建工具负责把代码转换成适合不同环境的版本。


3 开发效率革命

现代工具链带来的提升非常巨大:

  • 热更新
  • 自动打包
  • 按需加载
  • 代码拆分

大型项目开发效率因此大幅提升。


十、今天的前端构建生态

目前主流的工具格局大致是:

应用开发:

  • Vite
  • Webpack(仍然大量存在)

库开发:

  • Rollup
  • tsup
  • esbuild

新一代工具:

  • Turbopack
  • Rspack

这些工具的目标都很一致:

更快、更简单、更高效。


总结

前端构建工具的发展,大致经历了四个阶段:

1 自动化阶段(Grunt / Gulp) 2 模块化阶段(Webpack / Rollup) 3 工程化阶段(Webpack 生态) 4 极速开发阶段(Vite)

如果说一句总结的话:

前端工具的发展,本质上是在不断降低开发复杂度,同时提升工程能力。

而随着浏览器能力的增强,未来的构建工具可能会继续向 更快、更轻、更少配置 的方向发展。

OpenCode 进阶使用指南(第二章:Skills 系统)

2026年3月10日 08:57

本文档是《OpenCode 进阶使用指南》的第二章专注于 Skills 技能系统的深入讲解,预计阅读时间:30-40 分钟


目录

  1. 什么是 Skills 系统
  2. Skills 的核心概念
  3. 创建你的第一个 Skill
  4. SKILL.md 文件详解
  5. 高级技能编写技巧
  6. 技能调试与优化
  7. 团队协作与技能共享
  8. 实战案例集
  9. 底层原理剖析
  10. 常见问题与解决方案

什么是 Skills 系统

2.1.1 从重复劳动说起

想象一个场景:你刚入职一家新公司,发现团队的代码审查流程非常规范——每次提交都要检查代码风格、潜在 bug、性能问题、安全漏洞。这些检查点有十几条,每次审查都要一条条过。

刚开始你觉得这个流程很好,能保证代码质量。但几周后,你开始觉得烦了。每次审查都要重复同样的检查项,写同样的评论格式,提同样的修改建议。

你会想:"如果能把这个流程自动化就好了。"

Skills 系统就是来解决这个问题的。

2.1.2 Skills 的本质

Skills 是一套可复用的指令模板。

当你发现自己在重复做某类任务时,你可以:

  1. 把这类任务的步骤、标准、注意事项整理出来
  2. 写成 SKILL.md 文件
  3. 放到特定目录
  4. 以后让 AI 自动按照这个模板执行

简单来说,Skills 就是把"你的经验"变成"AI 的能力"。

2.1.3 Skills vs Agent 模式的区别

很多人问:"Skills 和 Agent 模式有什么区别?"

维度 Agent 模式 Skills 系统
定位 执行能力 知识封装
作用 让 AI 能动手 让 AI 懂规则
使用方式 临时指令 预定义模板
复用性 每次都要说 一次定义多次使用
适用场景 具体任务 标准化流程

举例说明

Agent 模式就像你让一个程序员"去重构代码"。他会自己思考怎么做。

Skills 系统就像你给程序员一本《代码重构手册》,告诉他:"以后重构都按这个手册来"。

两者可以结合使用:用 Skills 定义流程,用 Agent 模式执行流程。

2.1.4 Skills 能做什么

代码质量类

  • 代码审查检查清单
  • 重构标准流程
  • 性能优化指南
  • 安全审计规范

开发流程类

  • 新功能开发流程
  • Bug 修复标准流程
  • 测试用例生成规范
  • 文档编写模板

团队协作类

  • 代码提交规范
  • PR 审查标准
  • 发布流程检查
  • 新人入职指南

领域专业类

  • 前端组件开发规范
  • API 设计最佳实践
  • 数据库操作规范
  • 微服务拆分原则

Skills 的核心概念

2.2.1 技能发现机制

OpenCode 是如何找到 Skills 的?这涉及到一个"发现机制"。

搜索路径

当你启动 OpenCode 时,它会从当前目录开始,向上查找所有包含 .opencode/skills/.claude/skills/ 的目录。

搜索顺序:

当前工作目录/.opencode/skills/
当前工作目录/.claude/skills/
父目录/.opencode/skills/
父目录/.claude/skills/
...
直到 git 仓库根目录
~
~/.config/opencode/skills/    (全局)
~/.claude/skills/             (全局兼容)

为什么要向上查找?

考虑一个 monorepo 项目结构:

my-project/                    ← git 根目录
├── .opencode/skills/          ← 项目级 Skills
│   ├── code-review/
│   └── api-design/
├── packages/
│   ├── frontend/
│   │   └── .opencode/skills/  ← 前端专属 Skills
│   │       └── react-component/
│   └── backend/
│       └── .opencode/skills/  ← 后端专属 Skills
│           └── express-route/

如果你在 packages/frontend/ 目录下工作,OpenCode 会加载:

  1. packages/frontend/.opencode/skills/ - 前端专属
  2. my-project/.opencode/skills/ - 项目级
  3. ~/.config/opencode/skills/ - 全局

这样设计的好处:子项目可以覆盖或扩展父项目的 Skills。

2.2.2 技能加载过程

当一个 Skill 被发现后,OpenCode 会:

  1. 读取 SKILL.md:解析 YAML frontmatter 和 Markdown 内容
  2. 验证格式:检查必填字段、格式规范
  3. 解析依赖:如果有依赖其他 Skills,先加载依赖
  4. 注入上下文:将 Skill 信息注入到 AI 的上下文中
  5. 注册工具:如果 Skill 定义了自定义工具,注册到工具集

加载时机

  • 启动时:加载所有发现的 Skills
  • 运行时:可以动态加载新 Skills(通过 /skill reload
  • 按需加载:某些 Skills 可以设置 lazy loading

2.2.3 技能匹配机制

OpenCode 怎么知道什么时候该用哪个 Skill?

关键词匹配

Skill 的 description 字段包含关键词,AI 会根据用户输入匹配最合适的 Skill。

例如:

  • 用户说"帮我审查代码" → 匹配 code-review Skill
  • 用户说"生成 API 文档" → 匹配 api-doc Skill

显式调用

用户可以直接指定使用某个 Skill:

> /skill use code-review
> 开始审查 src/components/Button.tsx

自动推荐

OpenCode 会分析当前上下文,主动推荐可能相关的 Skills:

看起来你正在写 React 组件。
可用的 Skills:
1. react-component - React 组件开发规范
2. code-review - 代码审查检查清单
3. testing - 测试用例生成

要使用哪个?(输入编号或名称)

2.2.4 技能生命周期

一个 Skill 从创建到使用,经历以下阶段:

1. 开发阶段

  • 编写 SKILL.md
  • 本地测试
  • 迭代优化

2. 发布阶段

  • 提交到 Git
  • 版本标记
  • 文档更新

3. 分发阶段

  • 团队成员拉取
  • 全局安装(可选)
  • 内部 npm 包(可选)

4. 使用阶段

  • 自动匹配
  • 显式调用
  • 结果反馈

5. 维护阶段

  • 收集反馈
  • 更新优化
  • 版本迭代

创建你的第一个 Skill

2.3.1 准备工作

环境要求

  • OpenCode 0.1.40+
  • 一个 Git 仓库(推荐)
  • 基本的 Markdown 知识

选择技能类型

第一次创建 Skill,建议从简单的开始。比如:

  • 代码风格检查
  • 提交信息规范
  • 简单的代码生成

2.3.2 实战:创建一个代码提交规范 Skill

场景:团队要求所有提交信息都要遵循 Conventional Commits 规范,但新人总是记不住格式。

目标:创建一个 Skill,帮助生成符合规范的提交信息。

Step 1:创建目录结构

mkdir -p .opencode/skills/conventional-commit
cd .opencode/skills/conventional-commit

Step 2:编写 SKILL.md

创建文件 SKILL.md

---
name: conventional-commit
description: 帮助生成符合 Conventional Commits 规范的提交信息
version: 1.0.0
author: your-name
tags:
  - git
  - workflow
  - automation
---

# Conventional Commit 助手

## 什么时候用

当你需要写 Git 提交信息时,使用这个 Skill 来生成符合团队规范的提交信息。

## 规范说明

我们团队使用 Conventional Commits 规范,格式如下:

### Type 类型

- **feat**: 新功能
- **fix**: 修复 bug
- **docs**: 文档修改
- **style**: 代码格式修改(不影响功能)
- **refactor**: 重构
- **perf**: 性能优化
- **test**: 测试相关
- **chore**: 构建过程或辅助工具变动

### Scope 范围

根据修改的内容选择:

- **api**: API 相关
- **ui**: 界面相关
- **db**: 数据库相关
- **config**: 配置相关
- **deps**: 依赖相关

## 使用步骤

1. 询问用户本次修改的主要内容
2. 分析修改的文件,判断 type  scope
3. 生成多个提交信息选项
4. 让用户选择或修改
5. 输出最终的提交命令

## 示例

**示例 1:新功能**

用户:添加了用户登录功能

生成:

git commit -m "feat(auth): 添加用户登录功能

- 实现 JWT token 验证
- 添加登录页面 UI
- 集成后端登录接口

Closes #123"

**示例 2:Bug 修复**

用户:修复了登录按钮点击无响应的问题

生成:

git commit -m "fix(ui): 修复登录按钮点击无响应

问题原因:事件监听器未正确绑定
解决方案:使用 useEffect 确保 DOM 加载完成后再绑定

Fixes #456"

**示例 3:文档更新**

用户:更新了 API 文档

生成:

git commit -m "docs(api): 更新用户认证接口文档

- 添加 JWT 认证说明
- 补充错误码列表
- 添加请求示例"

## 注意事项

1. subject 首字母不要大写
2. subject 末尾不要加句号
3. body 每行不超过 72 个字符
4. 如果有关联的 issue,在 footer 中引用

Step 3:测试 Skill

在 OpenCode 中测试:

> /skill list
可用的 Skills:
- conventional-commit: 帮助生成符合 Conventional Commits 规范的提交信息

> 我要提交代码,帮我写提交信息
[AI 会使用 conventional-commit Skill]

AI:请描述一下本次修改的主要内容:

> 修复了用户列表页面分页不生效的问题

AI:分析修改内容...
      ↓
AI:为你生成了以下提交信息选项:

选项 1:
git commit -m "fix(ui): 修复用户列表分页不生效

- 修正分页参数传递错误
- 添加分页边界检查

Fixes #789"

选项 2:
git commit -m "fix(api): 修复用户列表接口分页参数处理

问题原因:page 参数未正确解析
解决方案:使用 parseInt 转换并添加默认值

Fixes #789"

请选择一个(输入 1 或 2),或告诉我修改建议:

> 1

AI:好的,使用选项 1。请复制以下命令执行:

git commit -m "fix(ui): 修复用户列表分页不生效

- 修正分页参数传递错误
- 添加分页边界检查

Fixes #789"

2.3.3 进阶:添加智能分析

上面的 Skill 还需要用户手动描述修改内容。我们可以让它更智能——自动分析修改的文件。

更新 SKILL.md

---
name: conventional-commit
description: 智能生成符合 Conventional Commits 规范的提交信息
version: 1.1.0
author: your-name
tags:
  - git
  - workflow
  - automation
  - smart-analysis
---

# Conventional Commit 助手(智能版)

## 新增功能

### 自动分析修改

使用以下命令自动分析工作区的修改:

git diff --name-only          # 查看修改了哪些文件
git diff                      # 查看具体修改内容
git status                    # 查看文件状态

### 文件类型映射

根据修改的文件自动推断 type  scope:

**文件路径  Type 推断**:

- `src/**/*.test.ts`  test
- `docs/**/*.md`  docs
- `package.json`, `package-lock.json`  chore
- 其他  需要人工判断(feat  fix)

**文件路径  Scope 推断**:

- `src/components/**/*.tsx`  ui
- `src/api/**/*.ts`  api
- `src/db/**/*.ts`  db
- `src/utils/**/*.ts`  utils
- `config/**/*.js`  config

## 使用步骤(智能版)

1. 执行 `git status`  `git diff --name-only`,了解修改了哪些文件
2. 执行 `git diff`,了解具体的修改内容
3. 根据文件路径和修改内容,自动推断 type  scope
4. 生成提交信息草稿
5. 询问用户确认或修改
6. 输出最终的提交命令

## 示例流程

用户:帮我生成提交信息

AI:[执行 git status]
    修改的文件:
    - src/components/UserList.tsx
    - src/api/users.ts
    - src/components/UserList.test.tsx

AI:[执行 git diff]
    UserList.tsx: 添加了分页功能
    users.ts: 添加了分页参数支持
    UserList.test.tsx: 添加了分页测试

AI:分析结果:
    - Type: feat (新功能)
    - Scope: ui (主要是 UI 组件)
    - 涉及 API 修改,需要在 body 中说明

生成提交信息:

git commit -m "feat(ui): 添加用户列表分页功能

- 实现前端分页组件和交互
- 后端 API 添加分页参数支持
- 添加单元测试覆盖

涉及文件:
- src/components/UserList.tsx
- src/api/users.ts
- src/components/UserList.test.tsx

Closes #234"

请确认或修改:

2.3.4 提交到团队仓库

创建好 Skill 后,记得提交到 Git:

git add .opencode/skills/conventional-commit/
git commit -m "feat(devtools): 添加 Conventional Commit 助手 Skill

- 自动生成符合规范的提交信息
- 支持智能分析修改内容
- 提供多种选项供选择"

git push

团队成员拉取代码后就能使用这个 Skill 了。


SKILL.md 文件详解

2.4.1 YAML Frontmatter 完整字段

SKILL.md 文件以 YAML frontmatter 开头,放在 --- 之间。

必需字段

字段 类型 说明 示例
name string 技能名称,必须唯一,只能用小写字母、数字和连字符 code-review
description string 技能描述,用于匹配和展示 自动进行代码审查

可选字段

字段 类型 说明 示例
version string 版本号,遵循 semver 1.2.3
author string 作者信息 张三 <zhangsan@example.com>
license string 许可证 MIT
tags array 标签,用于分类和搜索 ["testing", "automation"]
compatibility string 兼容性标识 opencode>=0.1.40
dependencies array 依赖的其他 Skills ["base-coding", "testing-utils"]
metadata object 自定义元数据 { "team": "frontend", "priority": "high" }

完整示例

---
name: react-component-review
description: 针对 React 组件的代码审查,检查 hooks 使用、性能优化、可访问性等
version: 2.1.0
author: 前端团队 <frontend@company.com>
license: MIT
tags:
  - react
  - review
  - performance
  - a11y
compatibility: opencode>=0.1.40
dependencies:
  - base-code-review
metadata:
  team: frontend
  priority: high
  review_level: strict
---

2.4.2 Markdown 内容结构

YAML frontmatter 之后是 Markdown 内容,用来详细说明技能的使用方法。

推荐结构

# 技能标题

## 什么时候用

描述使用场景和触发条件。

## 前置条件

列出使用这个技能前需要满足的条件。

## 输入参数

如果技能需要参数,说明参数格式和示例。

## 使用步骤

详细说明执行步骤,可以包含:

1. 第一步做什么
2. 第二步做什么
3. ...

## 检查清单

列出需要检查的项目,用复选框表示。

## 示例

提供具体的使用示例。

### 示例 1:场景 A

详细说明...

### 示例 2:场景 B

详细说明...

## 输出格式

说明执行结果的格式。

## 注意事项

列出常见问题和注意事项。

## 相关资源

- 链接 1
- 链接 2

2.4.3 特殊语法

变量替换

可以在 Skill 中使用变量,这些变量会在执行时被替换:

## 使用步骤

1. 读取文件 {{file_path}}
2. 检查 {{check_item}}
3. 输出结果到 {{output_path}}

使用时:

> /skill use code-review file_path=src/App.tsx check_item=performance

条件逻辑

可以使用条件注释:

<!-- if: framework == "react" -->

## React 特定检查

- 检查 hooks 规则
- 检查 JSX 语法
<!-- endif -->

<!-- if: framework == "vue" -->

## Vue 特定检查

- 检查模板语法
- 检查生命周期使用
<!-- endif -->

代码块

使用代码块展示示例代码:

// 示例代码
function example() {
  return "Hello";
}

表格

使用表格展示对照信息:

| 情况 | 处理方式   | 示例        |
| ---- | ---------- | ----------- |
| 正常 | 直接通过   | -           |
| 警告 | 提示但允许 | console.log |
| 错误 | 必须修复   | eval()      |

高级技能编写技巧

2.5.1 条件技能

根据不同的条件执行不同的逻辑。

示例:根据项目类型使用不同的检查规则

---
name: project-lint
description: 根据项目类型自动选择合适的代码检查规则
---

# 项目类型检测

## 检测项目类型

1. 检查 package.json 中的依赖:
   - 如果有 react  前端项目
   - 如果有 express  后端项目
   - 如果有 pytest  Python 项目

2. 根据类型加载对应的检查规则:

<!-- if: project_type == "frontend" -->
## 前端项目检查规则

### JavaScript/TypeScript
- ESLint 规则
- Prettier 格式
- TypeScript 类型检查

### React 特定
- Hooks 规则检查
- JSX 语法检查
- 组件命名规范
<!-- endif -->

<!-- if: project_type == "backend" -->
## 后端项目检查规则

### Node.js
- 异步处理检查
- 错误处理检查
- 安全问题检查

### API 设计
- RESTful 规范
- 参数校验
- 错误码规范
<!-- endif -->

2.5.2 组合技能

一个 Skill 可以调用其他 Skills。

示例:完整的代码提交流程

---
name: complete-code-submit
description: 完整的代码提交流程:检查  测试  生成提交信息  提交
dependencies:
  - code-review
  - conventional-commit
  - pre-commit-check
---

# 完整代码提交流程

## 流程步骤

1. **代码检查**(调用 code-review Skill)
   - 运行 linter
   - 类型检查
   - 安全检查

2. **测试验证**(调用 pre-commit-check Skill)
   - 运行单元测试
   - 运行集成测试
   - 检查测试覆盖率

3. **生成提交信息**(调用 conventional-commit Skill)
   - 分析修改内容
   - 生成符合规范的提交信息

4. **执行提交**
   - git add
   - git commit
   - (可选)git push

## 失败处理

如果某一步失败:
- 停止后续步骤
- 报告失败原因
- 提供修复建议

2.5.3 交互式技能

让 Skill 支持交互式输入。

示例:交互式项目初始化

---
name: interactive-init
description: 交互式项目初始化,根据用户选择生成项目结构
---

# 交互式项目初始化

## 步骤 1:选择项目类型

询问用户:
"你要创建什么类型的项目?"
选项:
1. React 前端应用
2. Node.js 后端 API
3. Python 脚本
4. 其他

根据选择进入不同分支。

## 步骤 2:收集项目信息

<!-- if: project_type == "react" -->
询问:
- 项目名称?
- 使用 TypeScript 吗?
- 使用哪个 UI 库?(Ant Design / Material-UI / Tailwind)
- 需要路由吗?
- 需要状态管理吗?
<!-- endif -->

## 步骤 3:确认配置

展示将要生成的配置:

项目:my-app 类型:React + TypeScript UI 库:Ant Design 路由:是状态管理:Redux Toolkit

确认生成吗?(y/n)

## 步骤 4:生成项目

根据确认的配置生成项目文件。

## 步骤 5:安装依赖并启动

cd {{project_name}}
npm install
npm run dev

2.5.4 模板技能

使用模板引擎生成代码。

示例:组件生成器

---
name: component-generator
description: 根据模板生成 React 组件
templates:
  - name: functional-component
    path: ./templates/FunctionalComponent.tsx
  - name: class-component
    path: ./templates/ClassComponent.tsx
---

# React 组件生成器

## 使用方法

> /skill use component-generator name=Button type=functional props="onClick: () => void; children: ReactNode"

## 模板变量

模板中可以使用以下变量:

- `{{component_name}}` - 组件名(大驼峰)
- `{{component_name_lower}}` - 组件名(小写)
- `{{props}}` - 属性定义
- `{{imports}}` - 导入语句
- `{{today}}` - 当前日期

## 生成的文件

- `src/components/{{component_name}}/index.tsx` - 组件主体
- `src/components/{{component_name}}/index.test.tsx` - 测试文件
- `src/components/{{component_name}}/index.stories.tsx` - Storybook 文档
- `src/components/{{component_name}}/styles.module.css` - 样式文件

模板文件示例templates/FunctionalComponent.tsx):

import React from 'react';
import styles from './styles.module.css';

export interface {{component_name}}Props {
  {{props}}
}

/**
 * {{component_name}} 组件
 *
 * @example
 * ```tsx
 * <{{component_name}} {{example_props}} />
 * ```
 */
export const {{component_name}}: React.FC<{{component_name}}Props> = (props) => {
  const { {{prop_names}} } = props;

  return (
    <div className={styles.container}>
      {/* 组件内容 */}
    </div>
  );
};

{{component_name}}.displayName = '{{component_name}}';

export default {{component_name}};

技能调试与优化

2.6.1 调试技巧

本地测试

在提交 Skill 前,先在本地测试:

# 创建测试目录
mkdir /tmp/skill-test
cd /tmp/skill-test

# 复制 Skill
cp -r /path/to/your/skill ./.opencode/skills/

# 启动 OpenCode 测试
opencode --agent

# 测试 Skill
> /skill list
> /skill use your-skill-name

日志输出

在 Skill 中添加调试信息:

## 调试模式

如果设置环境变量 `DEBUG_SKILL=true`,输出详细的执行日志。

DEBUG_SKILL=true opencode --agent

逐步执行

使用 step_by_step 模式查看每一步的执行:

> /agent config mode step_by_step
> /skill use your-skill

2.6.2 性能优化

减少上下文大小

  • 只加载必要的文件
  • 使用摘要而非完整内容
  • 压缩历史记录
---
name: optimized-skill
description: 优化过的技能,只加载必要信息
---

# 优化执行

## 文件读取优化

不要读取整个文件:
 cat src/large-file.ts

只读取相关部分:
 head -n 50 src/large-file.ts
 grep -n "function target" src/large-file.ts

## 上下文管理

- 使用 `max_lines_per_file: 100` 限制每文件读取行数
- 使用 `compression: true` 启用上下文压缩

缓存机制

对于重复使用的数据,可以缓存:

## 缓存配置

缓存以下数据以提高性能:

- 项目结构(5 分钟)
- 依赖列表(1 小时)
- 检查规则(24 小时)

2.6.3 错误处理

优雅降级

## 错误处理

如果某一步失败:

1. 尝试备用方案
2. 如果备用方案也失败,报告错误
3. 提供手动操作指南

### 示例:文件读取失败

错误:无法读取 src/config.ts

可能原因:

  1. 文件不存在
  2. 权限不足
  3. 文件被占用

解决方案:

  1. 检查文件是否存在:ls src/
  2. 检查权限:ls -la src/config.ts
  3. 手动创建文件(提供模板)

重试机制

## 网络操作重试

对于网络请求(如 API 调用):

- 失败时自动重试 3 次
- 每次重试间隔 1 秒
- 如果都失败,报错并退出

2.6.4 版本管理

语义化版本

遵循 semver 规范:

  • MAJOR(主版本):不兼容的修改
  • MINOR(次版本):新增功能,向后兼容
  • PATCH(修订):Bug 修复,向后兼容

示例

1.0.0 - 初始版本
1.1.0 - 添加自动分析功能
1.1.1 - 修复 scope 检测 bug
2.0.0 - 重构 API,不兼容 1.x

变更日志

维护 CHANGELOG.md:

# Changelog

## [2.0.0] - 2026-01-15

### 重大变更

- 重构 API,使用新的配置格式
- 不再支持 OpenCode < 0.1.40

### 新功能

- 支持 Vue 项目
- 添加自定义规则

### 修复

- 修复了 Windows 路径问题

团队协作与技能共享

2.7.1 团队 Skills 仓库

集中管理

创建一个专门的仓库管理团队 Skills:

company-opencode-skills/
├── README.md
├── skills/
│   ├── code-review/
│   │   └── SKILL.md
│   ├── commit-message/
│   │   └── SKILL.md
│   └── pr-template/
│       └── SKILL.md
├── templates/
│   └── component/
├── scripts/
│   └── install.sh
└── package.json

安装脚本scripts/install.sh):

#!/bin/bash
# 安装团队 Skills 到全局

SKILLS_DIR="$HOME/.config/opencode/skills"
REPO_URL="https://github.com/yourcompany/opencode-skills.git"

# 克隆或更新
if [ -d "$SKILLS_DIR/.git" ]; then
  cd "$SKILLS_DIR" && git pull
else
  git clone "$REPO_URL" "$SKILLS_DIR"
fi

echo "✓ 团队 Skills 已安装到 $SKILLS_DIR"
echo "可用的 Skills:"
ls "$SKILLS_DIR/skills/"

2.7.2 项目级 Skills

与项目代码一起管理

将项目特定的 Skills 放在项目仓库中:

my-project/
├── .opencode/
│   └── skills/
│       ├── domain-specific/     # 领域特定
│       ├── framework-specific/  # 框架特定
│       └── team-conventions/    # 团队约定
├── src/
├── package.json
└── README.md

Git 子模块(可选):

如果多个项目共享同一套 Skills,可以使用 Git 子模块:

# 添加 Skills 子模块
git submodule add https://github.com/yourcompany/opencode-skills.git .opencode/skills

# 克隆项目时递归克隆子模块
git clone --recursive https://github.com/yourcompany/my-project.git

2.7.3 Skills 市场(未来规划)

虽然目前 OpenCode 还没有官方的 Skills 市场,但可以预见未来的发展方向:

社区共享

  • 类似 npm 的 Skills 仓库
  • 版本管理和依赖解析
  • 评分和评论系统

企业内部市场

  • 私有 Skills 仓库
  • 权限管理
  • 审计日志

实战案例集

2.8.1 案例一:前端代码审查 Skill

---
name: frontend-code-review
description: 针对前端项目的全面代码审查,包括 React/Vue、性能、安全、可访问性
tags:
  - frontend
  - review
  - react
  - vue
---

# 前端代码审查

## 审查范围

### 1. 代码规范
- ESLint 规则合规性
- Prettier 格式
- 命名规范
- 文件组织

### 2. React 特定检查
- Hooks 使用规范
  - 不要在循环、条件中调用 Hooks
  - 依赖数组完整性
  - useEffect 清理函数
- 组件设计
  - 单一职责原则
  - Props 类型定义
  - 默认 Props
- 性能优化
  - useMemo/useCallback 使用
  - 不必要的重渲染
  - 代码分割

### 3. Vue 特定检查
- Composition API 使用
- Props/Emits 类型定义
- 生命周期使用
- 响应式数据

### 4. 样式检查
- CSS-in-JS 规范
- 样式隔离
- 响应式设计
- 浏览器兼容性

### 5. 可访问性 (a11y)
- 语义化 HTML
- ARIA 属性
- 键盘导航
- 屏幕阅读器支持

### 6. 安全检查
- XSS 防护
- 不安全的 innerHTML
- 敏感信息泄露

## 输出格式

```markdown
## 代码审查报告

### 概览
- 审查文件:{{file_count}} 
- 问题总数:{{issue_count}} 
  - 严重:{{critical_count}} 
  - 警告:{{warning_count}} 
  - 建议:{{suggestion_count}} 

### 详细问题

#### 1. [严重] src/components/UserForm.tsx:45
**问题**:useEffect 缺少依赖项
**代码**:

useEffect(() => {
  fetchUser(userId);
}, []); //  userId 未在依赖数组中

**修复**:

useEffect(() => {
  fetchUser(userId);
}, [userId]);

#### 2. [警告] src/utils/api.ts:12

**问题**:缺少错误处理 ...

### 建议改进

1. ...
2. ...

## 使用示例

> /skill use frontend-code-review path=src/components/

2.8.2 案例二:API 设计审查 Skill

---
name: api-design-review
description: RESTful API 设计审查,包括规范、安全性、性能
tags:
  - backend
  - api
  - rest
---

# API 设计审查

## 审查清单

### 1. RESTful 规范
- [ ] URL 设计是否符合资源导向
- [ ] HTTP 方法使用是否正确
- [ ] 状态码返回是否恰当
- [ ] 版本控制策略

### 2. 请求/响应设计
- [ ] 请求参数校验
- [ ] 响应格式统一
- [ ] 错误信息规范
- [ ] 分页设计

### 3. 安全性
- [ ] 认证机制
- [ ] 权限控制
- [ ] 输入验证
- [ ] 防注入
- [ ] 敏感数据保护

### 4. 性能
- [ ] 响应时间
- [ ] 缓存策略
- [ ] 并发处理
- [ ] 数据库查询优化

### 5. 文档
- [ ] OpenAPI/Swagger 文档
- [ ] 示例请求/响应
- [ ] 错误码说明

## 示例审查

**待审查 API**:

GET /api/getUserData?id=123

**问题**:
1. URL 不符合 RESTful 规范(动词 getUserData)
2. 使用 query 参数传递 ID(应该是路径参数)
3. 缺少版本号

**建议**:

GET /api/v1/users/123

2.8.3 案例三:数据库迁移 Skill

---
name: database-migration
description: 安全的数据库迁移流程,包括备份、迁移、验证、回滚
tags:
  - database
  - migration
  - devops
---

# 数据库迁移

## 流程

### 1. 迁移前检查
- [ ] 确认迁移脚本已测试
- [ ] 检查数据库连接
- [ ] 验证磁盘空间
- [ ] 通知相关人员

### 2. 备份

# 创建备份
mysqldump -u root -p database_name > backup_$(date +%Y%m%d_%H%M%S).sql

# 验证备份
mysql -u root -p -e "source backup_xxx.sql" test_db

### 3. 执行迁移


# 使用迁移工具
npx prisma migrate deploy
# 或
npm run migration:up

### 4. 验证

- [ ] 检查表结构
- [ ] 验证数据完整性
- [ ] 运行关键查询
- [ ] 检查应用日志

### 5. 回滚准备

如果失败,执行:

# 方式1:使用迁移工具回滚
npx prisma migrate rollback

# 方式2:从备份恢复
mysql -u root -p database_name < backup_xxx.sql

## 迁移脚本模板

-- 迁移:添加用户表
-- 作者:张三
-- 日期:2026-01-15

-- 开始事务
BEGIN;

-- 检查表是否存在
DROP TABLE IF EXISTS users;

-- 创建表
CREATE TABLE users (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    email VARCHAR(255) UNIQUE NOT NULL,
    name VARCHAR(100) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;

-- 创建索引
CREATE INDEX idx_users_email ON users(email);

-- 验证
SELECT COUNT(*) FROM users;

-- 提交事务
COMMIT;

底层原理剖析

2.9.1 Skills 加载机制

发现阶段

// 伪代码
function discoverSkills(startPath) {
  const skills = [];
  let currentPath = startPath;

  while (currentPath !== root) {
    // 检查项目级 Skills
    if (exists(join(currentPath, '.opencode/skills'))) {
      skills.push(...loadSkillsFromDir(join(currentPath, '.opencode/skills')));
    }

    // 检查兼容的 Claude Skills
    if (exists(join(currentPath, '.claude/skills'))) {
      skills.push(...loadSkillsFromDir(join(currentPath, '.claude/skills')));
    }

    currentPath = parent(currentPath);
  }

  // 加载全局 Skills
  skills.push(...loadSkillsFromDir('~/.config/opencode/skills'));

  return skills;
}

**解析阶段**:

```javascript
function parseSkill(skillPath) {
  const content = readFile(skillPath + '/SKILL.md');

  // 解析 YAML frontmatter
  const { frontmatter, body } = parseFrontmatter(content);

  // 验证必填字段
  validateRequiredFields(frontmatter, ['name', 'description']);

  // 解析 Markdown
  const ast = parseMarkdown(body);

  return {
    meta: frontmatter,
    content: body,
    ast: ast,
    path: skillPath,
  };
}

注入阶段

function injectSkillToContext(skill, context) {
  // 将 Skill 信息添加到 AI 上下文
  context.availableSkills.push({
    name: skill.meta.name,
    description: skill.meta.description,
  });

  // 如果 Skill 定义了工具,注册工具
  if (skill.tools) {
    for (const tool of skill.tools) {
      context.registerTool(tool);
    }
  }
}

2.9.2 技能匹配算法

关键词匹配

function matchSkill(userInput, skills) {
  const scores = skills.map((skill) => {
    let score = 0;

    // 名称匹配
    if (userInput.includes(skill.meta.name)) {
      score += 10;
    }

    // 描述匹配
    const descriptionWords = skill.meta.description.toLowerCase().split(' ');
    const inputWords = userInput.toLowerCase().split(' ');
    const commonWords = intersection(descriptionWords, inputWords);
    score += commonWords.length * 2;

    // 标签匹配
    if (skill.meta.tags) {
      for (const tag of skill.meta.tags) {
        if (userInput.includes(tag)) {
          score += 5;
        }
      }
    }

    return { skill, score };
  });

  // 按分数排序
  scores.sort((a, b) => b.score - a.score);

  // 返回分数最高的
  return scores[0]?.score > threshold ? scores[0].skill : null;
}

上下文感知

function contextualMatch(userInput, skills, context) {
  // 根据当前上下文调整匹配权重
  const currentFile = context.currentFile;
  const projectType = detectProjectType(context.projectRoot);

  return skills.map((skill) => {
    let score = baseMatchScore(userInput, skill);

    // 文件类型匹配
    if (skill.fileTypes && skill.fileTypes.includes(currentFile.extension)) {
      score += 3;
    }

    // 项目类型匹配
    if (skill.projectTypes && skill.projectTypes.includes(projectType)) {
      score += 5;
    }

    return { skill, score };
  });
}

2.9.3 技能执行流程

[用户输入][意图识别] ← 使用 LLM 分析用户意图
    ↓
[技能匹配] ← 根据意图匹配 Skills
    ↓
[技能加载] ← 读取 SKILL.md 内容
    ↓
[参数提取] ← 从输入中提取参数
    ↓
[提示词构建] ← 构建执行提示词
    ↓
[LLM 执行] ← AI 执行 Skill 指令
    ↓
[结果输出] ← 返回执行结果

2.9.4 提示词工程

系统提示词片段

你是一名专业的软件开发助手。你可以使用以下 Skills 来帮助用户:

<available_skills>
{{#each skills}}
<skill>
  <name>{{this.name}}</name>
  <description>{{this.description}}</description>
</skill>
{{/each}}
</available_skills>

当用户需要执行特定任务时,你可以选择最合适的 Skill 来帮助他们。

使用 Skill 的方式:
1. 分析用户需求
2. 匹配最合适的 Skill
3. 调用 skill({ name: "skill-name" }) 加载 Skill
4. 根据 Skill 的指引执行任务
5. 报告执行结果

约束:
- 只在用户需要时使用 Skill
- 不要主动推荐不相关的 Skill
- 执行过程中可以询问用户确认

任务提示词构建

function buildSkillPrompt(skill, userInput, context) {
  return `
# 当前任务

用户输入:${userInput}

当前上下文:
- 项目类型:${context.projectType}
- 当前文件:${context.currentFile}
- 相关文件:${context.relatedFiles.join(', ')}

# 使用的 Skill

名称:${skill.meta.name}
描述:${skill.meta.description}

## Skill 内容

${skill.content}

# 执行要求

请根据上述 Skill 的指引,帮助用户完成任务。
如果需要更多信息,可以向用户提问。
`;
}

常见问题与解决方案

2.10.1 Skill 不生效

问题:创建了 Skill,但 OpenCode 没发现。

检查清单

  1. 路径是否正确

    • 必须在 .opencode/skills/<name>/.claude/skills/<name>/
    • 文件名必须是 SKILL.md(全大写)
  2. YAML 格式是否正确

    • 必须用 --- 包裹
    • namedescription 必须存在
    • 缩进必须用空格,不能用 Tab
  3. 名字是否符合规范

    • 只能用小写字母、数字和连字符
    • 必须和文件夹名完全一致
  4. 是否重新加载

    • 重启 OpenCode
    • 或执行 /skill reload

2.10.2 Skill 匹配错误

问题:OpenCode 总是匹配到错误的 Skill。

解决方案

  1. 优化 description

    • 使用更具体、独特的描述
    • 包含关键词
  2. 使用显式调用

    • /skill use skill-name
  3. 调整标签

    • 添加更多标签帮助匹配
  4. 上下文感知

    • 在 Skill 中指定 fileTypesprojectTypes

2.10.3 Skill 执行失败

问题:Skill 匹配成功,但执行时报错。

排查步骤

  1. 检查 YAML 语法
  2. 检查 Markdown 格式
  3. 查看 OpenCode 日志
  4. 简化 Skill 内容,逐步测试
  5. 检查依赖的 Skills 是否存在

2.10.4 性能问题

问题:使用 Skill 后,响应变慢。

优化建议

  1. 减少上下文大小

    • 限制加载的文件数量
    • 使用文件摘要而非完整内容
  2. 使用缓存

    • 缓存不变的数据
    • 设置合理的缓存时间
  3. 延迟加载

    • 只在需要时加载大型资源
    • 使用 lazy: true 标记
  4. 优化提示词

    • 减少不必要的说明
    • 使用更简洁的语言

总结与下一步

本章要点

  1. Skills 是什么:可复用的指令模板,把经验变成工具
  2. 如何创建:编写 SKILL.md 文件,放到指定目录
  3. 高级技巧:条件技能、组合技能、交互式技能、模板技能
  4. 调试优化:本地测试、性能优化、错误处理
  5. 团队协作:共享 Skills、版本管理
  6. 底层原理:发现机制、匹配算法、执行流程

学习路径

初级

  • 理解 Skills 概念
  • 创建简单的 Skill
  • 在团队内共享

中级

  • 掌握高级技巧
  • 优化 Skill 性能
  • 处理复杂场景

高级

  • 开发 Skill 框架
  • 贡献开源社区
  • 设计 Skill 生态

下一步

学完 Skills 系统后,建议继续学习:

  • 第三章:MCP 集成 - 让 AI 能使用外部工具
  • 第五章:最佳实践 - 掌握使用技巧

文档信息

  • 字数:约 8,000 字
  • 适用版本:OpenCode 0.1.40+

相关资源

(第二章完)

浏览器到底在偷偷帮你做什么?——HTTP缓存与刷新机制

作者 牛奶
2026年3月10日 08:49

你的浏览器可能比你想象中更"勤俭持家"。这篇文章聊聊它是怎么帮你省流量、省时间的,以及什么时候该阻止它。


你有没有注意过这样一个现象?

第一次打开一个网页,要等好几秒。第二次打开同一个网页,基本秒开。

你是不是以为浏览器記住了这个页面?它到底是怎么做到的?

今天我们来聊聊浏览器缓存——这个你天天在用,却可能从来没认真了解过的机制。


原文地址

墨渊书肆/浏览器到底在偷偷帮你做什么?——HTTP缓存与刷新机制


缓存到底是个什么东西?

想象一下这个场景。

你每天早上都去楼下的早餐店买豆浆。第一次去的时候,老板娘要问你「要甜的還是甜的」「要不要加鸡蛋」「打包还是在这吃」——问半天,你回答半天。

但如果你连着去了一个星期,每天都点「原味豆浆,不加糖,带走」。

第七天的时候,你刚走到门口,老板娘已经把豆浆打包好了递给你,连话都不用说一句。

这就是缓存。

浏览器做的就是这件事。第一次访问一个网页,它要千里迢迢去服务器「要」资源。第二次访问,它就聪明了——直接从自己的「小仓库」里拿出来给你用,省时又省力。

但问题来了:浏览器怎么知道哪些东西可以直接用?哪些东西已经过期了?

这就涉及到HTTP缓存的两个核心概念:强缓存协商缓存


强缓存:先用了再说

强缓存就像是你跟浏览器达成的一个小协议。

你告诉它:「这个资源在未来一年内都不会变,你就放心大胆地用,别来问我。」

浏览器听了这话,就直接把资源扔给你,连服务器的大门都不带敲的。

那这个"一年"的承诺是怎么实现的呢?

Expires:看时间

最早的方式是用Expires这个响应头:

Expires: Wed, 21 Oct 2026 07:28:00 GMT

这就像在商品上贴了个过期日期:「2026年10月21日之前都有效。」

但这个方式有个bug:如果你的电脑时间和服务器时间不一致,比如你把电脑调快了一个月,那缓存就直接失效了——浏览器会以为已经过期了,实际上还能用。

Cache-Control:更聪明的做法

后来HTTP/1.1推出了Cache-Control,就像给缓存装了个更智能的计时器:

Cache-Control: max-age=3600, public

max-age=3600的意思是:缓存有效期是3600秒,也就是一个小时。

这个比Expires靠谱多了——它是相对时间,不受系统时间影响。

而且Cache-Control还支持一堆指令,让你能更精细地控制缓存行为:

指令 意思 什么时候用
public 缓存可以放在CDN、代理服务器上 静态资源
private 只准浏览器自己缓存 个性化内容
no-cache 每次用之前先问问我 经常变化的资源
no-store 别存了,每次都重新拿 敏感数据
immutable 这东西永远不会变 版本化后的静态资源

这里有个重点:no-cache不是"不缓存",而是"用之前先问一下"。


协商缓存:问一下再决定

强缓存虽然快,但有个问题:如果服务器上的资源已经变了,浏览器可不知道。

你跟浏览器说「这个文件缓存一年」,结果三个月后你更新了网站,浏览器还在用旧的——用户就看不到新内容了。

怎么办?

协商缓存就是这个问题的答案。

它的逻辑是这样的:浏览器还是要去问一下服务器:「我这里有个缓存,还能用吗?」

服务器说「能用」(资源没变),返回304 Not Modified,浏览器继续用缓存。

服务器说「不能用」(资源变了),返回200 OK + 新资源,浏览器更新缓存。

Last-Modified:看修改时间

最早的方式是用Last-Modified

# 服务器返回
Last-Modified: Wed, 21 Oct 2025 07:28:00 GMT

# 浏览器下次请求
If-Modified-Since: Wed, 21 Oct 2025 07:28:00 GMT

就像你问老板:「这个文件最后改过是什么时候?如果还是那天下午,我就继续用我的版本。」

但这个方式有个缺点:精度只到秒。如果一秒内文件被修改了两次,浏览器可看不出来。

ETag:看"指纹"

后来就有了ETag,它给每个文件生成一个"指纹":

# 服务器返回
ETag: "abc123"

# 浏览器下次请求
If-None-Match: "abc123"

这个指纹通常是文件的MD5哈希值,或者其他能唯一标识内容的东西。

服务器收到请求后,会比较一下ETag:如果指纹一样,说明内容没变, 返回304;如果不一样,返回200 + 新内容。

划重点:ETag的优先级高于Last-Modified。如果服务器同时给了这两个,浏览器会先用ETag。


缓存到底是怎么工作的?

让我给你串起来整个流程:

你访问一个页面
    ↓
浏览器先看强缓存(Cache-Control / Expires)
    ↓
[命中了] → 直接从本地拿 → 页面秒开 ✅
    ↓ [没命中]
浏览器带着缓存标识去问服务器
    ↓
服务器比较ETag / Last-Modified
    ↓
[资源没变] → 返回304 → 浏览器用缓存 → 依然很快 ⚡
    ↓ [资源变了]
服务器返回200 + 新资源 + 新标识
    ↓
浏览器更新缓存,展示新内容

这个流程你理解之后,很多奇怪的现象就能解释了:

  • 为什么更新了CSS但页面没变?——可能被强缓存了
  • 为什么F5刷新后还是旧内容?——正常,优先用强缓存
  • 为什么Ctrl+F5就变了?——强制刷新,绕过了所有缓存

刷新这件事,讲究这么多?

说到刷新,我发现很多人(包括以前的我)对浏览器的刷新按钮有误解。

你以为的刷新:「重新加载这个页面」

实际上的刷新:「emmm,让我先看看缓存有没有过期」

正常刷新(F5 / 点击链接)

这是最常用的方式。

浏览器会先看强缓存,过期了吗?没有就直接用。过期了就去协商,服务器说能用就用。

结果:可能快(304),也可能慢(200),取决于缓存状态。

强制刷新(Ctrl+Shift+R / Ctrl+F5)

这个才是真正的"重新加载"。

浏览器会跟服务器说:「少废话,把所有资源都给我拿新的,别跟我提缓存!」

请求头里会带上Cache-Control: no-cache,或者直接忽略所有的缓存标识。

结果:总是200,慢但保证最新。

开发者工具里的选项

Chrome DevTools里其实有更精细的控制:

  • 禁用缓存:只有DevTools打开时才生效,模拟no-cache
  • 硬性重新加载:等于强制刷新
  • 空缓存并硬性重新加载:先清空所有缓存,再强制刷新——相当于把浏览器的小仓库一把火烧了

说完了缓存,来聊聊安全

缓存虽好,但有时候也会带来安全问题。

比如你登录了一个网站,浏览器缓存了你的个人信息。然后别人用了你的电脑,打开同一个网站——诶,怎么直接就登录了?

这就是缓存的"副作用"。所以有些东西是不能缓存的。

no-store:敏感数据别存

Cache-Control: no-store

这个指令告诉浏览器:「看归看,但别存到硬盘上。」内存里用完就扔,下次重启就没了。

登录后的用户信息、token、支付相关的数据——这些都应该加上no-store

private:只存浏览器

Cache-Control: private

这个的意思是:「可以缓存,但只能存在用户自己的浏览器里,别给CDN、代理那些中间商。」

用户信息存private,静态资源存public——这是基本的安全常识。


聊完缓存,再聊聊HTTPS

既然说到安全,顺便提一下HTTPS。

你可能知道HTTPS是"加密的HTTP",但它跟缓存有什么关系呢?

其实没有直接关系。但HTTPS有一个相关的安全头:

Strict-Transport-Security: max-age=31536000; includeSubDomains

这叫HSTS,简单说就是告诉浏览器:「以后别用HTTP跟我玩了,直接给我用HTTPS。」

防止一种叫"SSL剥离"的攻击——黑客在中间把HTTPS降级成HTTP,偷看你的数据。

还有哪些安全头值得关注?

作用
X-Frame-Options: DENY 防止别人把你的页面嵌在iframe里(点击劫持)
X-Content-Type-Options: nosniff 告诉浏览器别乱猜内容类型
Content-Security-Policy 限制资源加载来源,防止XSS
Referrer-Policy 控制 Referrer 信息泄露

这些安全头配置起来不麻烦,但能挡住很多常见攻击。建议都配置上。


到底该怎么用?

说了一堆原理,最后来点实用的。

不同资源,不同策略

资源类型 推荐策略
JS / CSS / 图片 max-age=31536000, immutable(一年,不用改)
HTML页面 no-cache,每次都协商
API接口 no-cacheprivate
用户敏感数据 no-store

核心原则

  • 静态资源:狠命缓存,文件名带hash,变了就改文件名
  • HTML:别缓存,随时要最新
  • API:看场景,频繁变化的要协商,不变的可以强缓存
  • 敏感数据:有多远滚多远,别缓存

总结一下

这篇文章聊了浏览器缓存和刷新机制的核心概念:

  • 强缓存Cache-Control / Expires):直接用,不过问服务器
  • 协商缓存ETag / Last-Modified):问一下服务器能不能用
  • 刷新:F5看缓存,Ctrl+F5硬刷新
  • 安全no-store防敏感数据泄露,private防中间商缓存
  • HTTPS相关:HSTS、安全响应头

理解这些机制之后,你就能解释很多奇怪的现象,也能更好地优化你的Web应用性能。

用 AI,0 基础复刻网页顶级特效!😀

2026年3月10日 07:50

先看效果

螺旋效果.gif在线访问地址包含了移动端版本和 PC 版本。

这个效果其实是我模仿 Awwwards 顶级网站的 3D 特效复刻出来的。核心框架使用的是 Three.js。

更夸张的是:

我对于 Three.js 的知识储备几乎是 0。好吧,实际上花了 4 个小时了解基本概念和学习 Shader(点击文字,会跳转到学习的视频(B站),请关注一下,是我的账号,因为有 100 粉丝后 B 站才能解锁发合集的功能, 我会将更好的酷炫动效视频教程分享给你 )。

而实现这个效果的核心工具只有一个:AI + 正确的 Vibe Coding 方法

过去是:学习 -> 写代码 -> 调试 -> 再学习

现在是:理解效果 -> 拆解核心算法 -> 提示 AI -> 快速迭代

核心能力不再是手写代码。而是:如何和 AI 协作。接下来我用一个最关键的例子说明。

为什么很多人用 AI 写网页特效总是失败?

假设我们要实现这样一个效果:图片围绕形成一个 3D 圆柱画廊(如上面展示的动画效果一样,)

大多数人给 AI 的提示词是这样的:

使用 Three.js 做一个 3D 图片画廊。

要求:

1. 页面中展示多张图片卡片
2. 图片在 3D 空间中排列,看起来有层次感
3. 用户滚动页面时,图片会产生移动动画
4. 整体效果类似一个环绕的圆柱型的画廊
5. 动画需要流畅自然

请给出完整代码。

如果你这样问 AI。我可以很负责地说:90% 情况下生成不出来。即使你用的是:Claude, GPT 最强的模型。

原因很简单:提示词太模糊。,而模糊的本质是确实对这类效果知之甚少。

AI 编程的关键:给 AI “思考路径”

后来我换了一种提示方式。效果完全不一样。提示词如下:

# 角色(Role)

你是一名 Three.js 工程师。
实现一个 Cylindrical Image Gallery。

# 要求(Requirement)

1. 使用 Three.js
2. 图片使用 PlaneGeometry 提供的免费图片
3. 将图片均匀排列在一个圆柱表面

位置计算公式:

const angle = (index / total) * 2π
const x = cos(angle) * radius
const z = sin(angle) * radius

# 技术栈
html + css + javascript + threejs

# 输出
完整 HTML

你会发现一个关键区别:

我给了 AI 一个核心算法。

也就是这个:

const angle = (index / total) * 2π
const x = cos(angle) * radius
const z = sin(angle) * radius

只要 AI 有这个信息。生成效果成功率会暴涨。

真正的 Vibe Coding:不是“不会写代码”

很多人误解了 Vibe Coding。他们以为是:什么都不懂,让 AI 写代码,其实这是错的,起码目前为止做简单的效果没问题,复杂的效果还是远远不够的。

所以目前最佳的 Vibe Coding 实现酷炫的网页效果这一领域而言,需要提供一定的。核心算法素材

比如我自己平时就会收集一些常见特效算法,例如:

  • 圆柱螺旋布局算法
  • 无限滚动算法
  • 图片弯曲 Shader(如上面展示的案例,图片是弯曲环绕的)

例如这段代码j就是 圆柱螺旋布局算法

class RingLayoutEngine {
  calculatePosition(index, scrollProgress, config) {
    const angle = (index / config.perLevel) * Math.PI * 2 + scrollProgress * 0.03;

    const x = Math.cos(angle) * config.radius;
    const z = Math.sin(angle) * config.radius;

    let y = (baseY - scrollProgress * 0.8) % fullHeight;

    return { x, y, z, angle };
  }
}

注:文章末尾附录,会有整个网页的 ai 代码素材的链接。

复刻酷炫网页的公式

当我看到一个酷炫网站时。我只需要:

  1. 观察效果
  2. 找相似算法
  3. 给 AI 当案例
  4. 生成代码

这就是 AI 时代,当前 AI 发展阶段的酷炫网页开发流程。

对于我实现的网页特效,

如何获取更多素材

目前来说还是需要懂一定代码的人去提取,当然有兴趣的同学可以加入我的酷炫网页成长群(免费),一起沟通分享,我也会偶尔分享一些类似的代码片段。

当然也有付费的群,很便宜,目前是 199 终身(后续人多了一定会涨很多),然后至少每个月 4 个 awwwards(聚集了世界顶级网页特效的网站) 级别效果的网站源码。还有很多动效方面的学习资料。我的能力覆盖 ai 全栈开发,还有高级前端开发等等内容,完全可以作为福利分享到咋们的酷炫网页成长群。

如果你是完全编程基础很少,或者 0 基础,我也很愿意分享你很多教程,补一些基础的编程基础会更好的帮助你跟 ai 协作。

未来更多分享内容

未来我会持续分享 AI + 前端开发的内容,例如:

如果你对这些内容感兴趣,可以关注我的公众号,包括在小红书、知乎、x 等平台的账号,都叫:ai超级个人。

我是一个不断进步的人,并且会一直进步下去,还有一些隐藏技能还在提升中,例如英语的听说,坚持已经两年了,我有自己的一套方法论,如果能成,也会单独拿来分享。

最后,我希望做的事情很简单:在 AI 时代,帮助更多人掌握真正的技术杠杆。

我们一起成长。一起做出更酷的东西!加油!

附录

拒绝盲目 Git:VS Code 神级插件 GitLens 的 9 个进效杀手锏

2026年3月10日 07:31

前言:在团队开发中,你是否常发出“灵魂质问”:这段代码谁写的?为什么这么写?两周前它长什么样?

如果你的 VS Code 只装了 GitLens 却只看行末那行半透明的 blame,那真是暴殄天物。今天分享 9 个让开发效率翻倍的 GitLens 进阶技巧,带你开启“上帝视角”。


1. 瞬时定位:Current Line Blame(责权显示)

痛点: 看到一段诡异代码,想找人对齐逻辑却不知道找谁。

技巧: 默认开启。只需光标停留,行末即刻浮现作者、时间和提交信息。

  • 进阶操作: 点击行末提示,会直接弹出该 Commit 的详细变更看板。
  • 避坑配置: 觉得太吵?设置 gitlens.currentLine.enabled: false,改为悬停触发。

2. 深度溯源:File History(文件时光机)

痛点: 一个文件被改烂了,想看它在两周前的逻辑演变。

技巧: 在 GitLens 侧边栏找到 File History 视图。

这里按时间线列出了所有修改记录。点击任一记录,VS Code 会进入 Diff 模式,左侧是历史,右侧是当前,改动点一目了然。


3. 视觉破局:Commit Graph(可视化图谱)

痛点: 分支错综复杂,git log --graph 看着眼晕。

技巧: 点击状态栏或侧边栏的 Graph 图标。

它会生成一张极其漂亮的彩色分支树状图。不仅能看,你还可以直接在图上右键进行 Merge、Rebase、甚至 Cherry-pick,将复杂的命令行操作转化为直观的点选。


4. 侦探模式:Search Commits(全局搜索)

痛点: 记得写过某段逻辑,但记不清在哪个 Commit 里的,也不知道文件叫啥。

技巧: 使用 Search & Compare 面板。

支持按 Message(提交信息)、Author(作者)、甚至是 Changed Files(修改的文件名) 进行模糊搜索。找代码历史,不再靠翻页。


5. 极速对撞:Compare Commits(版本比对)

痛点: 准备发版前,想确认 main 分支比 dev 多了哪些内容。

技巧: 在 GitLens 侧边栏选中两个分支或两个 Commit,右键选择 Compare with...

它会把所有的差异文件打包列出。这是进行 Pre-code Review 的最佳手段。


6. 优雅停车:Visual Stashes(暂存管理)

痛点: git stash 存了太多东西,早忘了 stash@{2} 里写的是啥。

技巧: 展开 GitLens 的 Stashes 面板。

这里可以预览每一个 Stash 里的代码改动,支持一键 Apply(应用)或 Pop(弹出)。再也不用盲猜暂存区里的内容。


7. 链路打通:GitHub/GitLab 深度集成

痛点: 看到 Commit 里的 PR 编号,还要手动去浏览器搜?

技巧: 配置远程仓库权限后,GitLens 会在悬停框中直接展示 PR 标题、状态和链接

点击编号,直接在浏览器跳到 PR 页面。打通了代码与协作工具的“最后一公里”。


8. 避雷雷达:File Heatmap(代码热力图)

痛点: 新接手项目,不知道哪些文件逻辑最复杂、最不稳定。

技巧: 开启侧边栏的 Heatmap 模式。

编辑器左侧行号处会根据修改频率显示不同深浅的色块。颜色越红/深,代表该处改动越频繁。 相信我,这种地方通常就是 Bug 的老巢。


9. 局部回滚:Open Changes with Working File

痛点: 发现当前代码改错了,只想把其中一小段还原回 3 天前的样子。

技巧: 在文件历史中找到 3 天前那个版本,右键选择 Open Changes with Working File

在 Diff 窗口中,你可以精准地把左侧(旧代码)的某几行点击小箭头同步到右侧(当前代码),实现“手术刀式”的局部回滚。


💡 总结与建议

GitLens 是典型的“瑞士军刀”,功能多但没必要全开。

  • 极致性能党: 建议关闭所有“自动浮现”功能,仅通过侧边栏面板进行交互。
  • 配置建议: 搜索 gitlens.plus.enabled。GitLens 很多高级功能(如 Graph)现在属于 Pro 版,但基础版功能对个人开发者已完全够用。

Taro 支付宝小程序可观测最佳实践

2026年3月9日 21:34

为什么要做可观测?—— 解决“不可见”带来的业务风险

在 Taro 开发的支付宝小程序中,可观测性(Observability)的本质是通过数据化、实时化的监控手段,让小程序的运行状态、用户行为及系统交互“可见、可分析、可追溯”。其必要性源于小程序在实际运行中面临的三大核心挑战:

1、“黑盒化”运行环境:问题难以感知,故障发现滞后

小程序部署在支付宝客户端及云端环境中,开发者无法直接接触用户设备的真实运行状态。当出现以下问题时,若无可观测能力,往往只能依赖用户反馈或事后投诉。

典型案例:某电商小程序上线后,用户投诉“提交订单按钮点击无效”,但开发团队通过日志仅能看到“按钮渲染成功”,因缺乏用户操作路径和接口调用的关联数据,耗时 3 天才定位到是“异步数据未加载完成时按钮未禁用”的逻辑缺陷。若提前部署可观测能力,可通过用户行为流+接口状态实时发现该问题。

2、业务依赖复杂:跨端、跨服务的故障定位成本高

Taro 小程序通常依赖多方服务:前端通过 Taro 框架调用支付宝小程序 API(如支付、登录)、与后端服务交互(如订单查询、用户数据同步),甚至涉及第三方服务(如地图 SDK、营销活动平台)。任何一个环节异常都可能导致整体功能失效,但问题根源可能隐藏在任意一层,若无统一的观测数据(如接口调用链、用户操作路径、错误上下文),排查效率极低,可能导致用户流失或业务损失。

3、用户体验敏感:微小问题可能引发大规模负面反馈

支付宝小程序的用户对体验的容忍度极低——页面加载慢 1 秒可能导致 5% 的用户流失,支付流程卡顿可能直接引发投诉。但用户通常不会详细描述问题(如“首屏渲染慢是因为图片未压缩”),而是给出模糊反馈(“用不了”“太卡了”)。若无可观测能力,开发者只能通过“猜测+灰度测试”被动优化,无法精准识别影响用户体验的关键节点。

这些问题的解决依赖于对用户行为、性能指标的实时监控,而非事后统计。

把观测云作为 Taro 支付宝小程序可观测最佳实践的核心工具--落地路径与关键动作

  • 将观测云定位为小程序的“统一可观测平台”,以统一标签(如 service、env、version)贯穿指标、日志、链路、用户访问四类数据,形成端到端的观测视图。
  • 在 Taro 多端(含支付宝小程序)场景中,前端接入 RUM(用户访问监测),后端与基础设施接入 Logging/Tracing/Metrics,实现跨端、跨服务的统一观测与关联。
  • 通过“Dashboard + Explorer”构建业务与技术双视角的可观测体系,既看得到用户体验,也定位得到根。

前端 RUM 接入(Taro 支付宝小程序)

本最佳实践实验版本为 Taro v4.1.7

查看编译环境
➜  ~ taro --version 
? Taro v4.1.7
4.1.7
➜  ~ 

1、观测云后台->用户访问监测->新建应用,选择应用类型为小程序,输入应用名称、应用ID,系统会自动根据配置信息生成引入 SDK 的代码,可选择 NPM 接入或 CDN 文件接入(这里我们选择 CDN 文件方式接入)。

  • 应用名称:用于识别当前用户访问监测的应用名称。
  • 应用 ID :应用在当前工作空间唯一标识,对应字段:app_id 。

2、根据链接下载 CDN 文件,打开项目代码,在小程序项目中的 app.ts 入口文件引入观测云后台生成的配置代码。

注意项目中引入代码的位置,必须要在 App() 初始化之前,否则会出现数据无法正常采集上报,或者只能上报一小部分数据的情况。

3、本地运行项目或者打包发布测试后,通过观察接口调用发现 /v1/write/rum 接口成功调用,说明数据成功上报,可在对应应用的查看器查看上报数据。

4、js 会自动生成一个 trace_id,如果在 allowedTracingOrigins 配置了正则(本最佳实践中配置的是 *,意味着所有接口都会带 ),会在对应的请求头中加入一些参数,ddtrace 的是这几个参数。

实现效果

1、小程序部署在支付宝客户端及云端环境中,开发者直接接触用户设备的真实运行状态
进入「用户访问监测」页面—> 选择创建的对应微信小程序—> 分析看板,可以查看小程序运行中的部分重点信息。

进入「用户访问监测」页面—> 选择创建的对应微信小程序—> 查看器,可以查看小程序应用中用户访问的动作,请求资源以及项目中的报错信息等。

2、多方服务全链路打通

3、后端服务透明化,可以观测到更详细的代码级方法,开发可以快速定位

4、用户体验可视化,全局可观测

观测云为 Taro 支付宝小程序创建一份“数字神经系统”

在 Taro 开发的小程序中,可观测性不是“可选能力”,而是保障业务健康增长的必备基础设施。它通过将“不可见的运行状态”转化为“可见的数据洞察”,帮助开发者:

  • 从被动救火到主动预防(快速发现问题,降低故障损失);
  • 从经验驱动到数据驱动(精准优化体验,提升转化效率);
  • 从局部优化到全局可控(支撑规模化增长,保障业务稳定性);
  • 从单点协作到团队协同(提升研发效率,加速产品迭代)。
昨天 — 2026年3月9日掘金 前端

uni-app x 蒸汽模式 性能测试基准报告 Benchmark

作者 CHB
2026年3月9日 19:39

背景

uni-app x 蒸汽模式,是DCloud于2026年推出的跨平台开发框架新版本。

该产品的特点是:比原生更快

本基准测试的目标,即为了真实呈现主要性能指标,并确保开发者可自行重现本基准测试,并得出相近结论。

先简要介绍 uni-app x 及 蒸汽模式

  • uni-app x 使用vue语法,并在蒸汽模式中去除了虚拟DOM
  • 蒸汽模式中,模板和样式编译为c或c++代码(在Android也会编译出部分kotlin代码),script仍然为uts语言
  • uni-app x 基于原生渲染管线,可融合原生组件生态,并占用更小的内存
  • 蒸汽模式提供了大量自研高性能组件,如view、text、image、list、rich-text、swiper、slider、picker等

测试指标

UI系统的核心性能指标是:渲染速度和帧率

追求渲染速度更快、掉帧更少。

人工体感可以录像,但测试指标必须可精准度量,需要准确的度量方案。

环境声明

目前 uni-app x 蒸汽模式,仅在鸿蒙平台公开发布,本测试报告仅包含鸿蒙设备的测试。

本Benchmark使用了2台鸿蒙系统在售的最低端机型 nove 12,具体信息如下:

  • 设备型号:nova 12 (不是pro) 运行内存8G
  • OS版本:6.0.0.130(截止测试时间的最新版OS,patch02)
  • 全部使用release方式运行
  • 电量90%左右,未开启节能模式。该设备仅支持普通模式和节能模式
  • 屏幕的刷新率设置为高,即120hz
  • 测试前所有设备重启,并静置2分钟。除关于本机的界面外,杀掉所有其他App的进程

view和text渲染速度测试

view和text是渲染引擎的核心基础,大量组件基于这2个基础组件构建。这2个基础组件的渲染速度是一套渲染引擎最核心的性能指标。

验证一个view和text创建速度是否足够快,可靠的方式是在同一个屏幕内创建大量view和text组件,计算耗时。

测试方法

点击按钮后,在屏幕上创建2000个view,每个view有一个背景色,每个view中再套入一个text组件。

2000个view需在同一屏幕区显示,view不设宽高,text字体较小。view们被分为50行,每行40个view,同时每行外层再套一个view。

即,一共4050的元素,其中2050个view和2000个text。

对比使用 uni-app x 蒸汽模式arkUI原生,进行创建速度的测试。

首先看录屏对比。 左边为arkUI原生,右边为uni-app x 蒸汽模式

IMG_3827.GIF

高清视频,可到 B 站观看:www.bilibili.com/video/BV1Rp…

界面中弹出的toast显示了耗时,单位为ms。arkUI为804ms、uni-app x蒸汽模式为267ms。计时说明:

  • 开始时间为按钮的click事件触发时间
  • 结束时间为主线程渲染指令已全部送达OS渲染进程时间。此时主线程已经完成本次渲染所需的工作,处于空闲状态。

该结束时间并非肉眼所见的屏幕显示时间,实际上渲染进程和GPU仍需一定时间工作才能让屏幕显示图像,但后续时间段无法通过编程打点计时。

经过录屏和计时的粗略对比,发现arkUIuni-app x 蒸汽模式在渲染进程和GPU的耗时接近,都在1帧左右,故在后续精准比较中忽略这段时间,保留目前的结束时间定义。

该实验重复5次。每次均杀掉应用进程重新进入,精准计算的耗时如下:

arkUI uni-app x蒸汽模式
791 243
805 244
811 244
771 243
810 242

平均值:

arkUI uni-app x蒸汽模式
797.6 243.2

以上数据单位均为ms。

测试结论

结论:在4050 view和text同屏渲染测试中,uni-app x 蒸汽模式的渲染速度是 arkUI 3.3倍

复现工程源码和体验方式

上述2个示例,源码如下:

arkUI版本,需要自行编译原始工程。

uni-app x 蒸汽模式,可以在HBuilderX 5.0以上版本编译运行(注意选用release方式运行,或者发行为正式包安装)。

hello uni-app x示例应用已经在鸿蒙应用商店上架,可以搜索“DCloud开发者中心系统”,或使用鸿蒙手机扫描下方二维码: 

图片

安装 hello uni-app x 后,点击右下角模板 -> 顶部有 view和text性能测试。

uni-app x 作为通用引擎,未对该示例做任何定制优化,没有诸如预加载、预测量等影响实验结果的行为。

长列表掉帧测试

list组件的地位,在渲染引擎中仅次于view和text。

现代渲染引擎,都采用复用技术实现长列表,确保持续滑动长列表后,内存没有持续增长。

使用复用技术的长列表进入速度都很快,因为只加载了一部分数据,但在滚动过程中持续加载数据并复用已存在视图时,如果列表复杂,会发生滚动掉帧。

测试方法

设计一个非常复杂的“死亡长列表”:

  • 加载4000行数据,7.4M的JSON
  • 每行超过40+元素,包括文字、图片、视频、自定义vue组件
  • 每行嵌套10+层
  • 渲染2万个元素,占据普通手机1333屏左右
  • 列表中还有大量的阴影、圆角、边框等复杂渲染样式

在人工体验中,用户可以体验加载速度、快速滑动时的流畅度,但在严谨的Benchmark中,需要精准的对比数据。

首先需要制作一个fps组件,监听系统的帧回调,在120Hz高刷屏上,每8.33ms会触发一次帧回调。如果2个帧回调的代码响应时长超过了8.33ms,就意味着掉帧。

该fps组件需要使用同样的逻辑分别实现arkUI版本和uni-app x版本。源码见后续 复现工程 章节。

同时死亡长列表的代码,也需要在arkUI和uni-app x中使用相同逻辑实现。

arkUI中使用lazy foreach,uni-app x中使用list-view。

使用arkUI版本和uni-app x版本分别进入长列表,滚动到底部,加载完4000行数据,然后点击鸿蒙手机的顶部状态栏,此时会滚动回到列表顶部。

两个版本回滚速度一样,均为1秒,在这个回滚到顶部的过程中,计算帧率,验证掉帧情况。同时从录像视觉上进行直观感受。

首先看录屏对比。 左边为arkUI原生,右边为uni-app x蒸汽模式

deadlist.GIF

高清视频,可移步B站观看,uni-app x vs arkUI 原生 长列表掉帧对比视频

视觉体验中可看出,arkUI的fps组件数字在1秒的动画期间更低,在回滚过程中很多视频呈现黑块。

该实验重复5次,每次均杀掉应用重新进入,重新滚动到顶部。

5次的测试数据如下:

  • arkUI:
    • 回滚过程中的平均FPS: 19.67, 最高FPS: 49, 最低FPS: 13
    • 回滚过程中的平均FPS: 24.14, 最高FPS: 61, 最低FPS: 15
    • 回滚过程中的平均FPS: 19.50, 最高FPS: 43, 最低FPS: 13
    • 回滚过程中的平均FPS: 20.67, 最高FPS: 53, 最低FPS: 13
    • 回滚过程中的平均FPS: 21.67, 最高FPS: 56, 最低FPS: 13
  • uni-app-x 蒸汽模式:
    • 回滚过程中的平均FPS: 78.29, 最高FPS: 100, 最低FPS: 52
    • 回滚过程中的平均FPS: 112.30, 最高FPS: 120, 最低FPS: 90
    • 回滚过程中的平均FPS: 106.33, 最高FPS: 120, 最低FPS: 45
    • 回滚过程中的平均FPS: 88.43, 最高FPS: 120, 最低FPS: 29
    • 回滚过程中的平均FPS: 104.50, 最高FPS: 120, 最低FPS: 55

arkUI的5次平均fps为 21.13,最高fps为61,最低fps为13。

uni-app x蒸汽模式的5次平均fps为 97.97,最高fps为120,最低为29。

测试结论

结论:在长列表帧率测试中,uni-app x蒸汽模式的平均帧率是 arkUI 4.6倍

实测发现arkUI版本的长列表中的video,无法记忆video的播放进度,即播放A视频到5s时,滚动到其他地方,然后再滚回来显示A视频,A视频会重头播放。

uni-app x的版本记忆了播放进度。除了功能的不同外,此差异也需要考虑到帧率对比中,记忆播放进度本身也耗费时间,也就是如果uni-app x取消记忆播放进度,帧率还能再提升。

复现工程源码和体验方式

上述2个示例,源码如下:

arkUI版本,需要自行编译原始工程。

uni-app x蒸汽模式,可以在HBuilderX 5.0以上版本编译运行(注意选用release方式运行,或者发行为正式包安装)。

hello uni-app x示例应用已经在鸿蒙应用商店上架,可以搜索“DCloud开发者中心系统”,或使用鸿蒙手机扫描下方二维码: 

图片

安装hello uni-app x后,点击右下角模板 -> 顶部有 死亡长列表。

uni-app x作为通用引擎,未对该示例做任何定制优化,没有诸如预加载、预测量等影响实验结果的行为。

此示例中7M多的4000行数据并非静态数据存在本地,而是由代码生成的数据,生成数据的代码是预执行的,在arkUI版和uni-app x版均如此。

其他组件

一套渲染引擎,除了view、text、list外,还需要更多高性能的组件。

uni-app x中对各种组件都做了极限性能测试,但受限于精力,未对arkUI组件全面做性能测试。

开发者可以在 hello uni-app x 中体验各种组件的性能测试,几乎每个组件的示例中,都单独提供了 组件性能测试。

  • rich-text组件:App平台的rich-text过去一直没有太好的解决方案。鸿蒙自身的richText组件也是基于webview渲染的,存在加载慢、内存占用高、快滑白屏等问题。uni-app x 蒸汽模式 提供更快的rich-text组件。以下测试,加载5万字长文、59张插图。可以看到无等待进入页面。上下快滑不掉帧、除了联网图片加载外滑不出白屏。高清演示视频,可移步 B 站观看:uni-app x rich-text 演示

     注:鸿蒙手机录屏时帧率只能为60Hz,实际使用时是完整的120Hz。下同。

  • swiper组件:加载100个item。无等待进入页面。在上述5万字长文中点击图片,可以看到swiper中无等待呈现59张图片,左右切换图片无延迟

  • picker组件:加载省市区4000条数据,无等待弹出组件。高清视频,可移步 B 站观看:uni-app x picker 组件演示

  • slide组件:拖动100个slider,丝滑流畅。高清演示视频,可移步 B 站观看:uni-app x slide组件演示

  • loading组件:屏幕上同时旋转100个loading不掉帧(录屏后从120掉帧到60)。高清视频,可移步 B 站观看:uni-app x loading组件演示

  • canvas组件:屏幕上同时移动数百个小球不掉帧。高清演示视频,可移步 B 站观看:uni-app x canvas组件演示

  • 众多表单组件均有100或200个创建速度测试监控。hello uni-app x 模板中还提供了日历、竖滑视频、侧滑删除长列表、ai chat的流式打字机等性能考验示例。高清演示视频,可移步 B 站观看:uni-app x 侧滑删除长列表演示uni-ai x 流式打字演示

在ai时代,很多App都需要内嵌一个开源的AI对话聊天库,能流式解析markdown,解析过程不掉帧。为此DCloud推出开源的uni-ai x,详见ext.dcloud.net.cn/plugin?id=2…

没有用户喜欢等待、没有用户喜欢卡顿掉帧。

从2007年iPhone发布后,手机用户每天都要为每次页面转场等待300ms。uni-app x 将支撑这个时间大幅缩短。hello uni-app x的蒸汽模式中已默认改为150ms,这150ms更多是留给网络。

如果开发者使用h3等新兴网络技术,优化好服务器速度,还可以把等待时间缩的更短。

FAQ

uni-app x 的App平台到底是自渲染还是原生渲染?

是原生渲染。准确的讲,是在原生渲染管线上自己做几乎所有组件。

如果使用xComponent自渲染,会因为2条渲染管线并存额外消耗硬件资源。

并且鸿蒙有很多原生组件,比如权限按钮、map地图以及三方生态中大量arkUI原生组件,自渲染方案在与原生生态融合时问题较多。两条渲染管线的滚动同步、资源消耗均导致这一路线不是最佳方案。

站在宏观视角,在原生渲染管线中优化,提供更快的核心组件,兼容所有原生组件,比自立一套组件生态对产业更有意义。

为什么都是原生渲染,uni-app x的蒸汽模式比原生渲染更快?

这里面涉及数千项工程优化,举例一些:

  1. Android的compose ui也是基于原生渲染管线的,但没有使用Android自带的view、textview,而是实现了自己的组件系统。

    这条路可行,只不过compose ui没有成为一个好标杆,它实际渲染速度比view体系更慢。(在上述4050示例对比中,有原生view和compose ui的测试例,详见

    uni-app x 蒸汽模式,也几乎没有使用系统自带的组件,不管是textView、recycleView、viewPage...,或者是鸿蒙的arkUI相关组件,基本都没用。全新研发的组件做到了性能更高。

  2. vue里template和style里的代码,被直接编译为优化度非常高的C代码。它的运行速度远快于arkts、kotlin及k/n。

    当然它的副作用就是编译速度很慢,开发C的工程师应该知道编译大型C工程是一件耗时工作。

    后续DCloud也会提供开发期间的热刷新方案。

在uni-app x的示例中发现了拍平。如果不拍平的话,uni-app x蒸汽模式中渲染速度还会比原生快吗?

如果不拍平的话,同屏创建4050个view和text的示例的平均耗时为467ms。仍远快于arkUI的797.6ms。

k/n驱动c层渲染,是否也快过arkUI或uni-app x蒸汽模式?

截止到目前(2026年2月初),基于k/n的开源跨平台框架,在上述基准测试中的表现均比arkUI差很多。更无法与uni-app x蒸汽模式相比。

uni-app x蒸汽模式在Android和iOS是否也快过原生?

uni-app x蒸汽模式的iOS版和Android版的渲染引擎已开发完毕,但产品化还有一些工作要做。预计分别在2026年Q1和Q2发布。

不管在iOS还是Android,均比原生快2~3倍,均基于原生渲染管线。

已公开如下预览版对比测试例:

上述示例在华为mate30 Android版上,对比数据如下:

5次冷启动平均耗时(单位:ms)
原生view 436
原生compose ui 673.2
原生compose ui aot 544.2
uni-app x 蒸汽模式 224

即跨平台,又比原生性能更高,曾经被认为是天方夜谭。

在 uni-app x蒸汽模式 发布前,DCloud曾给行业内小范围演示,也被认为不可能。特将本文赠予哪些不相信这件事的人。

在中国,因为小程序和鸿蒙等多平台现状,一个优秀的跨平台框架对于产业有巨大的意义。

在中国,被封锁高端芯片技术的鸿蒙系统,更需要高性能的软件框架来支撑用户体验。

继续前行!

🚀 两年小程序开发,我把踩过的坑做成了开源 Skills

作者 whinc
2026年3月9日 18:58

还记得第一次用 miniprogram-automator 写自动化测试时的绝望吗?

  • querySelector() 死活选不到自定义组件里的元素
  • waitFor() 不知道该等什么,测试时好时坏
  • wx.request Mock 不生效,每次测试都打真实接口
  • 截图对比总是因为时间戳、动画而误报

还有 CI 发布时的噩梦:

  • 上传到微信后台经常超时,CI 流水线红成一片
  • pnpm 项目的 npm 包打不进去,shamefully-hoist 是什么鬼?
  • GitHub Actions 里 secrets 配置错了,排查半天才发现是 IP 白名单问题

我决定不再让其他人重复踩这些坑。


📦 wechat-miniprogram-skills

这是我开源的微信小程序 AI 编程技能包,基于 Skills 规范,支持 40+ AI 编程工具(Claude Code、Cursor、Windsurf、Continue、Copilot 等)。

包含两个核心 Skill:

1️⃣ miniprogram-automation - 自动化测试不再是玄学

核心能力:

  • ✅ 生成可复用的 Node.js 自动化脚本(不强制 Jest)
  • ✅ 处理自定义组件边界问题(.shadow()>>> 选择器)
  • ✅ 智能 waitFor 策略(等待元素、网络、页面栈)
  • ✅ wx.request Mock 方案(mockWxMethod / restoreWxMethod)
  • ✅ 截图 + 回归验证(console / exception 监听)
  • ✅ 基于官方 miniprogram-demo 实际验证

常见触发词: "小程序自动化"、"automator"、"E2E 测试"、"选不到元素"、"mock wx.request"

实际案例:

// 自动生成的脚本会处理这些细节:
const input = await page.$('.custom-input >>> input')
await input.input('测试内容')
await page.waitFor(500) // 智能等待策略

2️⃣ miniprogram-ci - 让 CI/CD 稳如老狗

核心能力:

  • ✅ 生成 pack-npm / preview / upload 独立脚本
  • ✅ 内置超时重试机制(3次重试,每次等待 5 秒)
  • ✅ 完整 GitHub Actions 模板(npm / pnpm 双版本)
  • ✅ pnpm 兼容性配置(shamefully-hoist / public-hoist-pattern)
  • ✅ secrets 管理 + IP 白名单检查
  • ✅ 自动创建 GitHub Release

常见触发词: "上传小程序"、"CI 部署"、"miniprogram-ci"、"预览二维码"、"自动化发布"

实际案例:

# 自动生成的 GitHub Actions 会帮你:
- name: Upload to WeChat
  run: node scripts/upload.js
  env:
    PRIVATE_KEY: ${{ secrets.WX_PRIVATE_KEY }}

🎯 为什么要做成 Skills?

1. AI 编程时代,让 AI 直接生成正确的代码

  • 不用再翻文档、查 Stack Overflow
  • AI 知道如何处理自定义组件、如何配置 CI

2. 知识可复用、可迭代

  • 把经验固化成 Skill,团队共享
  • 遇到新坑就更新 Skill,让 AI 帮后来者避坑

3. 支持 40+ AI 工具

  • 不绑定特定 IDE 或 AI 助手
  • Claude、Cursor、Copilot 都能用

📥 如何使用?

# 安装整个仓库
npx skills add whinc/wechat-miniprogram-skills

# 只安装自动化测试 Skill
npx skills add whinc/wechat-miniprogram-skills --skill miniprogram-automation

# 只安装 CI 发布 Skill
npx skills add whinc/wechat-miniprogram-skills --skill miniprogram-ci

安装后,直接在 AI 编程工具中说:

  • "帮我生成小程序自动化测试脚本"
  • "配置小程序 CI 自动上传"

AI 会基于这些 Skills 生成经过实战验证的代码


🤝 欢迎贡献

如果你也在小程序开发中踩过坑、总结过经验,欢迎提交 PR 补充新的 Skill!

比如:

  • 小程序性能监控
  • 分包加载优化
  • 云开发自动化部署
  • ...

让我们一起让小程序开发不再痛苦 💪


🔗 链接

ahooks useRequest 深度解析:一个 Hook 搞定所有请求

作者 兆子龙
2026年3月9日 18:26

二、核心功能详解

1. 自动管理请求状态

const { data, loading, error, run, refresh, cancel } = useRequest(
  fetchUserList,
  {
    manual: false,  // 自动执行
    defaultParams: [{ page: 1 }],  // 默认参数
  }
);

// run: 手动触发
// refresh: 使用上次参数重新请求
// cancel: 取消当前请求

2. 防抖与节流

// 搜索场景:防抖
const { data, loading } = useRequest(searchAPI, {
  debounceWait: 300,  // 300ms 防抖
  manual: true,
});

// 滚动加载:节流
const { run } = useRequest(loadMore, {
  throttleWait: 1000,  // 1s 节流
  manual: true,
});

3. 轮询

// 每 3 秒轮询一次
const { data } = useRequest(getStatus, {
  pollingInterval: 3000,
  pollingWhenHidden: false,  // 页面隐藏时停止轮询
});

// 条件轮询
const { data } = useRequest(getJobStatus, {
  pollingInterval: 2000,
  pollingErrorRetryCount: 3,  // 错误重试次数
  onSuccess: (result) => {
    if (result.status === 'completed') {
      // 完成后停止轮询
      return false;
    }
  }
});

4. 依赖刷新

const [userId, setUserId] = useState('1');

const { data } = useRequest(
  () => fetchUser(userId),
  {
    refreshDeps: [userId],  // userId 变化时自动重新请求
  }
);

5. 缓存机制

// SWR 模式:先返回缓存,后台更新
const { data, loading } = useRequest(fetchUser, {
  cacheKey: 'user-data',
  staleTime: 5000,  // 5s 内认为数据新鲜
  cacheTime: 300000,  // 缓存保留 5 分钟
});

// 清除缓存
import { clearCache } from 'ahooks';
clearCache('user-data');

6. 错误重试

const { data, error, retry } = useRequest(unstableAPI, {
  retryCount: 3,  // 失败后重试 3 次
  retryInterval: 1000,  // 重试间隔 1s
  onError: (error, params) => {
    console.log('请求失败', error);
  }
});

三、进阶场景

并行请求

const user = useRequest(fetchUser);
const posts = useRequest(fetchPosts);
const comments = useRequest(fetchComments);

const loading = user.loading || posts.loading || comments.loading;

串行请求

const { data: user } = useRequest(fetchUser);

const { data: posts } = useRequest(
  () => fetchUserPosts(user.id),
  {
    ready: !!user,  // user 存在时才执行
    refreshDeps: [user],
  }
);

分页加载

function UserList() {
  const { data, loading, loadMore, loadingMore, noMore } = useRequest(
    (d) => fetchList({ page: d?.nextPage || 1 }),
    {
      loadMore: true,
      isNoMore: (d) => !d?.hasMore,
    }
  );
  
  return (
    <>
      {data?.list.map(item => <Item key={item.id} {...item} />)}
      {!noMore && (
        <Button onClick={loadMore} loading={loadingMore}>
          加载更多
        </Button>
      )}
    </>
  );
}

乐观更新

const { run: deleteItem } = useRequest(deleteAPI, {
  manual: true,
  onBefore: (params) => {
    // 立即更新 UI
    setList(list => list.filter(item => item.id !== params[0]));
  },
  onError: (error, params) => {
    // 失败时回滚
    message.error('删除失败');
    refresh();
  }
});

四、与其他方案对比

特性 useRequest React Query SWR
学习成本
功能完整度 很高
包体积 较大
防抖节流 内置 需自己实现 需自己实现
轮询 内置 内置 需配置
TypeScript 良好 优秀 良好

五、最佳实践

  1. 合理使用缓存:列表、详情等读多写少的数据适合缓存
  2. 设置合适的防抖时间:搜索建议 300-500ms
  3. 避免过度轮询:根据业务需求设置合理的轮询间隔
  4. 善用 ready 参数:避免无效请求
  5. 统一错误处理:在全局配置中处理通用错误
// 全局配置
import { configResponsive } from 'ahooks';

configResponsive({
  onError: (error) => {
    if (error.code === 401) {
      // 统一处理未登录
      redirectToLogin();
    }
  }
});

六、源码解析(简化版)

useRequest 的核心实现思路:

function useRequest(service, options) {
  const [state, setState] = useState({
    data: undefined,
    loading: false,
    error: undefined,
  });
  
  const run = useCallback(async (...params) => {
    setState(s => ({ ...s, loading: true }));
    
    try {
      const data = await service(...params);
      setState({ data, loading: false, error: undefined });
    } catch (error) {
      setState(s => ({ ...s, loading: false, error }));
    }
  }, [service]);
  
  useEffect(() => {
    if (!options.manual) {
      run(...(options.defaultParams || []));
    }
  }, []);
  
  return { ...state, run };
}

实际实现还包括:

  • 防抖节流的 debounce/throttle 包装
  • 轮询的 setInterval 管理
  • 缓存的 Map 存储
  • 依赖追踪的 useEffect
  • 请求取消的 AbortController

总结

useRequest 是一个功能强大且易用的请求管理 Hook,它封装了日常开发中 90% 的请求场景。通过合理使用其提供的能力,可以大幅减少样板代码,提升开发效率。

推荐在中小型项目中直接使用 useRequest,大型项目可以考虑 React Query 获得更强的数据管理能力。

如果这篇文章对你有帮助,欢迎点赞收藏!

React Suspense 从入门到实战:让异步加载更优雅

作者 兆子龙
2026年3月9日 17:34

二、代码分割场景

这是 Suspense 最成熟的应用场景,配合 React.lazy 实现组件懒加载。

import { lazy, Suspense } from 'react';

const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));

function App() {
  return (
    <Suspense fallback={<PageLoader />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  );
}

多层 Suspense 边界:

<Suspense fallback={<AppShell />}>
  <Layout>
    <Suspense fallback={<SidebarSkeleton />}>
      <Sidebar />
    </Suspense>
    <Suspense fallback={<ContentSkeleton />}>
      <Content />
    </Suspense>
  </Layout>
</Suspense>

三、数据请求场景

React 18 开始,Suspense 可以配合支持的数据请求库使用。

使用 SWR:

import useSWR from 'swr';

function User({ id }) {
  const { data } = useSWR(`/api/user/${id}`, fetcher, {
    suspense: true  // 开启 Suspense 模式
  });
  
  return <div>{data.name}</div>;
}

<Suspense fallback={<UserSkeleton />}>
  <User id={123} />
</Suspense>

使用 React Query:

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

function Posts() {
  const { data } = useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    suspense: true
  });
  
  return data.map(post => <Post key={post.id} {...post} />);
}

四、与 Error Boundary 配合

Suspense 只处理"挂起"状态,错误需要 Error Boundary 捕获。

class ErrorBoundary extends React.Component {
  state = { hasError: false };
  
  static getDerivedStateFromError(error) {
    return { hasError: true };
  }
  
  render() {
    if (this.state.hasError) {
      return <ErrorFallback />;
    }
    return this.props.children;
  }
}

<ErrorBoundary>
  <Suspense fallback={<Loading />}>
    <DataComponent />
  </Suspense>
</ErrorBoundary>

五、与并发特性配合

配合 useTransition 避免不必要的 loading 状态:

function SearchResults() {
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();
  
  const handleSearch = (value) => {
    startTransition(() => {
      setQuery(value);  // 低优先级更新
    });
  };
  
  return (
    <>
      <input onChange={e => handleSearch(e.target.value)} />
      {isPending && <InlineSpinner />}
      <Suspense fallback={<ResultsSkeleton />}>
        <Results query={query} />
      </Suspense>
    </>
  );
}

六、最佳实践

  1. 合理划分边界:按路由或功能模块设置 Suspense,避免过细或过粗
  2. 提供有意义的 fallback:使用骨架屏而非简单的 loading 文字
  3. 避免瀑布流:并行发起请求,而非串行等待
  4. 配合预加载:在用户交互前提前触发数据请求
// 预加载示例
const resource = fetchUser(id);  // 提前发起

function Profile() {
  const user = use(resource);  // 直接使用
  return <div>{user.name}</div>;
}

七、注意事项

  • Suspense 在服务端渲染(SSR)中需要特殊处理,React 18 提供了流式 SSR 支持
  • 不是所有数据请求库都支持 Suspense,使用前查看文档
  • fallback 组件应该是轻量的,避免在其中执行副作用
  • 嵌套 Suspense 时,内层边界会优先生效

总结

Suspense 让异步操作的处理更加优雅和声明式。从代码分割到数据请求,它都能提供更好的开发体验和用户体验。配合 React 18 的并发特性,Suspense 将成为构建现代 React 应用的重要工具。

如果这篇文章对你有帮助,欢迎点赞收藏!

ahooks useRequest 深度解析:一个 Hook 搞定所有请求

作者 兆子龙
2026年3月9日 17:33

二、核心功能详解

1. 自动管理请求状态

const { data, loading, error, run, refresh, cancel } = useRequest(
  fetchUserList,
  {
    manual: false,  // 自动执行
    defaultParams: [{ page: 1 }],  // 默认参数
  }
);

// run: 手动触发
// refresh: 使用上次参数重新请求
// cancel: 取消当前请求

2. 防抖与节流

// 搜索场景:防抖
const { data, loading } = useRequest(searchAPI, {
  debounceWait: 300,  // 300ms 防抖
  manual: true,
});

// 滚动加载:节流
const { run } = useRequest(loadMore, {
  throttleWait: 1000,  // 1s 节流
  manual: true,
});

3. 轮询

// 每 3 秒轮询一次
const { data } = useRequest(getStatus, {
  pollingInterval: 3000,
  pollingWhenHidden: false,  // 页面隐藏时停止轮询
});

// 条件轮询
const { data } = useRequest(getJobStatus, {
  pollingInterval: 2000,
  pollingErrorRetryCount: 3,  // 错误重试次数
  onSuccess: (result) => {
    if (result.status === 'completed') {
      // 完成后停止轮询
      return false;
    }
  }
});

4. 依赖刷新

const [userId, setUserId] = useState('1');

const { data } = useRequest(
  () => fetchUser(userId),
  {
    refreshDeps: [userId],  // userId 变化时自动重新请求
  }
);

5. 缓存机制

// SWR 模式:先返回缓存,后台更新
const { data, loading } = useRequest(fetchUser, {
  cacheKey: 'user-data',
  staleTime: 5000,  // 5s 内认为数据新鲜
  cacheTime: 300000,  // 缓存保留 5 分钟
});

// 清除缓存
import { clearCache } from 'ahooks';
clearCache('user-data');

6. 错误重试

const { data, error, retry } = useRequest(unstableAPI, {
  retryCount: 3,  // 失败后重试 3 次
  retryInterval: 1000,  // 重试间隔 1s
  onError: (error, params) => {
    console.log('请求失败', error);
  }
});

三、进阶场景

并行请求

const user = useRequest(fetchUser);
const posts = useRequest(fetchPosts);
const comments = useRequest(fetchComments);

const loading = user.loading || posts.loading || comments.loading;

串行请求

const { data: user } = useRequest(fetchUser);

const { data: posts } = useRequest(
  () => fetchUserPosts(user.id),
  {
    ready: !!user,  // user 存在时才执行
    refreshDeps: [user],
  }
);

分页加载

function UserList() {
  const { data, loading, loadMore, loadingMore, noMore } = useRequest(
    (d) => fetchList({ page: d?.nextPage || 1 }),
    {
      loadMore: true,
      isNoMore: (d) => !d?.hasMore,
    }
  );
  
  return (
    <>
      {data?.list.map(item => <Item key={item.id} {...item} />)}
      {!noMore && (
        <Button onClick={loadMore} loading={loadingMore}>
          加载更多
        </Button>
      )}
    </>
  );
}

乐观更新

const { run: deleteItem } = useRequest(deleteAPI, {
  manual: true,
  onBefore: (params) => {
    // 立即更新 UI
    setList(list => list.filter(item => item.id !== params[0]));
  },
  onError: (error, params) => {
    // 失败时回滚
    message.error('删除失败');
    refresh();
  }
});

四、与其他方案对比

特性 useRequest React Query SWR
学习成本
功能完整度 很高
包体积 较大
防抖节流 内置 需自己实现 需自己实现
轮询 内置 内置 需配置
TypeScript 良好 优秀 良好

五、最佳实践

  1. 合理使用缓存:列表、详情等读多写少的数据适合缓存
  2. 设置合适的防抖时间:搜索建议 300-500ms
  3. 避免过度轮询:根据业务需求设置合理的轮询间隔
  4. 善用 ready 参数:避免无效请求
  5. 统一错误处理:在全局配置中处理通用错误
// 全局配置
import { configResponsive } from 'ahooks';

configResponsive({
  onError: (error) => {
    if (error.code === 401) {
      // 统一处理未登录
      redirectToLogin();
    }
  }
});

六、源码解析(简化版)

useRequest 的核心实现思路:

function useRequest(service, options) {
  const [state, setState] = useState({
    data: undefined,
    loading: false,
    error: undefined,
  });
  
  const run = useCallback(async (...params) => {
    setState(s => ({ ...s, loading: true }));
    
    try {
      const data = await service(...params);
      setState({ data, loading: false, error: undefined });
    } catch (error) {
      setState(s => ({ ...s, loading: false, error }));
    }
  }, [service]);
  
  useEffect(() => {
    if (!options.manual) {
      run(...(options.defaultParams || []));
    }
  }, []);
  
  return { ...state, run };
}

实际实现还包括:

  • 防抖节流的 debounce/throttle 包装
  • 轮询的 setInterval 管理
  • 缓存的 Map 存储
  • 依赖追踪的 useEffect
  • 请求取消的 AbortController

总结

useRequest 是一个功能强大且易用的请求管理 Hook,它封装了日常开发中 90% 的请求场景。通过合理使用其提供的能力,可以大幅减少样板代码,提升开发效率。

推荐在中小型项目中直接使用 useRequest,大型项目可以考虑 React Query 获得更强的数据管理能力。

如果这篇文章对你有帮助,欢迎点赞收藏!

React Native中创建自定义渐变色

作者 sure282
2026年3月9日 17:30

前言

所谓自定义渐变色就是允许用户按需选择颜色以及颜色的位置,一个基本的CSS渐变色配置是这样的: experimental_backgroundImage: 'linear-gradient(135deg, #e2d7c8 0%, #d1c3a6 25%, #bdac86 50%, #a79368 75%, #8e794d 100%)', 之前的文章提到过RN中实现渐变色的两种个方案,分别是RN的experimental_backgroundImage和使用expo-linear-gradient依赖库,以experimental_backgroundImage为例,如果我们想让用户实现自定义的渐变色方案应该允许用户配置 颜色颜色停留的位置渐变朝向角度。下面是界面样式:

微信图片_20260309095613_268_23.jpg微信图片_20260309095614_269_23.jpg

微信图片_20260309095615_270_23.jpg微信图片_20260309095615_271_23.jpg

微信图片_20260309095616_272_23.jpg微信图片_20260309095617_273_23.jpg

依赖库选择

因为我们需要一个调色板供用户选择颜色,因此我们需要一个依赖库来提供颜色选择功能:

reanimated-color-picker

它提供了多种颜色形式面板,比如圆形,条形,方形等选择界面UI组件,透明度控制和多种颜色格式获取,比如rgba格式和#ccc这种hex格式等,通常我们需要hex格式即可。该依赖库功能强大但是它没有详细文档,使用的最好方式是去它的项目中查看示例代码,非常详尽。由于我们想要创建渐变色效果应用于部分页面的渐变色背景,如果使用渐变色背景的页面是transparent_modal形式则不推荐使用透明度,将透明度始终设置为1,来避免显示底部页面内容。 以上我们创建了一个调色盘和颜色条,这二者便是以上依赖库为我们提供的样式UI:

import ColorPicker, { colorKit, HueSlider, Panel3, type ColorFormatsObject } from 'reanimated-color-picker';
    /**
     * ColorPick向外暴露onChange它是ui线程执行
     * onChangeJS和onCompleteJS都是在js线程执行的
     * ColorPick颜色选择行为会在点击调色盘和调整透明度时
     * 均会产生一个新颜色结果,而且渐变色的配置不适合调整透明度
     * 否则对于弹窗页面底部会显示出来
     * 它接收色彩对象,按需取制定格式即可
     */

                <ColorPicker
                    value={resultColor}
                    sliderThickness={16}
                    thumbSize={16}
                    thumbShape='circle'
                    onCompleteJS={onColorPick}
                    style={styles.picker}
                    boundedThumb
                >
                    <View
                        style={styles.panel}
                    >
                        <Panel3
                            style={styles.panelStyle}
                        />
                    </View>

                    <HueSlider
                        style={styles.sliderStyle}
                    />
                </ColorPicker>

它接收onChange事件函数以获取修改后的颜色,它内部使用react-native-reanimated依赖库因此动画控制以及值的变化有UI线程和JS线程之分,在这里我们使用js线程即可,因为我们要在值变化后修改组件接收的样式动态更新。

颜色位置

我们通常使用%百分比来控制颜色位置,这里我们使用一个slider,限定值的范围01或者0100,它的值在每次新增颜色后更新位置则与新的颜色配置绑定,因为每个颜色都应该有一个不同的位置,所以每一个渐变色存储对象中都应该有一个颜色和位置

渐变角度

渐变角度则是整个渐变色的配置,因此整个渐变色结果对象中只需要一个角度值。因此我们需要在创建渐变色时应该保存:渐变颜色n个,颜色位置n个,渐变角度1个:

单个颜色的基本配置字段,它同时也是底部已选择的颜色栏元素所需字段:

export interface ColorConfig {
    color: string,
    pos: number,
    id: string
};

当用户选择颜色超过1个时就可以创建渐变色,构造linear-gradient(135deg, #e2d7c8 0%, #d1c3a6 25%, #bdac86 50%, #a79368 75%, #8e794d 100%)

    const defaultColor = colorKit.randomRgbColor().hex();
    /** 记录当前选择的颜色项 */
    const [currentId, setCurrentId] = useState<string>('');
    /** 已选择的颜色数组 */
    const [colors, setColors] = useState<ColorConfig[]>([]);
    /** 删除模式还是新增模式 */
    const [mode, setMode] = useState<LinearMode>('select');
    /** 渐变色结果字符串,直接赋值给指定组件的样式 */
    const [experimental_backgroundImage, setExperimental_backgroundImage] = useState<string>('');

    const onColorPick = (color: ColorFormatsObject) => {
        setResultColor(color.hex);
        let obj = { color: color.hex, pos: sliderValue, id: generateId() };
        if (mode === 'select') {
            if (currentId) {
                setColors(prev => {
                    const newList = prev.map(el => {
                        if (el.id === currentId) {
                            return { ...el, color: color.hex };
                        };
                        return el;
                    });
                    if (newList.length > 1) {
                        const stopsStr = newList.slice().sort((a, b) => a.pos - b.pos).map(({ color, pos }) => `${color} ${(pos * 100).toFixed(0)}%`).join(', ');
                        const result = `linear-gradient(${angle}deg, ${stopsStr})`
                        setExperimental_backgroundImage(result);
                        if (viewItemsCount < newList.length) {
                            flatListRef.current?.scrollToOffset({ offset: newList.length * 55, animated: true });
                        };
                    };
                    return newList;
                });
            } else {
                setColors(prev => [...prev, obj]);
                setCurrentId(obj.id);
            };
        };
    };

以上有一个细节:在调色板组件中,底部颜色条,顶部圆盘以及颜色透明度的变化都视为颜色变化,触发onColorPick并生成一个新的颜色值结果,因此我们应该区分用户到底是在新增一个颜色还是在修改当前这个颜色,因此在颜色位置的Slider变化时应该有相同的逻辑处理:

    const handleSliderChange = (val: number) => {
        setSliderValue(Number(val.toFixed(1)));
        if (currentId) {
            setColors(prev => {
                const newList = prev.map(el => {
                    if (el.id === currentId) {
                        return { ...el, pos: val }
                    };
                    return el;
                });
                const stopsStr = newList.slice().sort((a, b) => a.pos - b.pos).map(({ color, pos }) => `${color} ${(pos * 100).toFixed(0)}%`).join(', ');
                const result = `linear-gradient(${angle}deg, ${stopsStr})`
                setExperimental_backgroundImage(result);
                return newList;
            });

        };
    };

颜色值和位置值的变化最终会影响渐变色,因此需要重新生成experimental_backgroundImage

角度值变化时也应该重新生成experimental_backgroundImage:

    /**
     * 角度值发生变化时执行
     * 添加节流处理,角度变化后
     * 会在已选颜色两种以上的情况下重新想修改
     * 渐变色
     */
    const handleAngelChange = (val: number) => {
        setAngle(Number(val.toFixed(0)));
        if (colors.length > 1) {
            const stopsStr = colors.slice().sort((a, b) => a.pos - b.pos).map(({ color, pos }) => `${color} ${(pos * 100).toFixed(0)}%`).join(', ');
            const result = `linear-gradient(${angle}deg, ${stopsStr})`
            setExperimental_backgroundImage(result);
        };
    };

完成以上三个控制后就是收获结果了将其转化为linear-gradient(135deg, #e2d7c8 0%, #d1c3a6 25%, #bdac86 50%, #a79368 75%, #8e794d 100%)格式保存,也没什么好说的,具体你想以哪种方式存储问题都不大,因为核心问题就是调色板,解决了它就是数据的修改保存,完整代码如下:

import { type FC, useState, useCallback, useRef } from 'react';
import PageTitleBar from '@/components/ui/PageTitleBar';
import StyledText from '@/components/ui/StyledText';
import { ThemedIonicons, ThemedView } from '@/components/theme/ThemedComponents';
import { View, StyleSheet, ScrollView, Platform, Pressable, TextInput, useWindowDimensions, KeyboardAvoidingView } from 'react-native';
import { useLocalSearchParams, router } from 'expo-router';
import type { ThemeType } from '@/types';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import CustomButton from '@/components/ui/CustomButton';
import ColorPicker, { colorKit, HueSlider, Panel3, type ColorFormatsObject } from 'reanimated-color-picker';
import Slider from '@react-native-community/slider';
import Switch from '@/components/ui/Switch';
import type { LinearMode } from '@/types/gradient';
import { useThemeConfig, useThemeNotification } from '@/hooks/useTheme';
import { generateId } from '@/utils';
import { insertGradient } from '@/libs/sqlite'
import { FlashList, type FlashListRef } from '@shopify/flash-list';
import ColorItem, { type ColorConfig } from '@/components/linearcolorselectpage/ColorItem';
import useProMusicStore from '@/store/proMusic';
const defaultColor = colorKit.randomRgbColor().hex();
const ColorPickerPage: FC = () => {
    const { type } = useLocalSearchParams<{ type: ThemeType }>();
    const { top, bottom } = useSafeAreaInsets();
    const [resultColor, setResultColor] = useState(defaultColor);
    const [colors, setColors] = useState<ColorConfig[]>([]);
    const flatListRef = useRef<FlashListRef<ColorConfig>>(null);
    const [sliderValue, setSliderValue] = useState<number>(0);
    const { text } = useThemeConfig();
    const { width } = useWindowDimensions()
    const showNotification = useThemeNotification();
    const [experimental_backgroundImage, setExperimental_backgroundImage] = useState<string>('');
    const [angle, setAngle] = useState<number>(0);
    const [mode, setMode] = useState<LinearMode>('select');
    const [title, setTitle] = useState<string>('')
    const [currentId, setCurrentId] = useState<string>('');
    const viewItemsCount = Math.floor((width - 40) / 55);
    /**
     * ColorPick向外暴露onChange它是ui线程执行
     * onChangeJS和onCompleteJS都是在js线程执行的
     * ColorPick颜色选择行为会在点击调色盘和调整透明度时
     * 均会产生一个新颜色结果,而且渐变色的配置不适合调整透明度
     * 否则对于弹窗页面底部会显示出来
     * 它接收色彩对象,按需取制定格式即可
     */
    const onColorPick = (color: ColorFormatsObject) => {
        setResultColor(color.hex);
        let obj = { color: color.hex, pos: sliderValue, id: generateId() };
        if (mode === 'select') {
            if (currentId) {
                setColors(prev => {
                    const newList = prev.map(el => {
                        if (el.id === currentId) {
                            return { ...el, color: color.hex };
                        };
                        return el;
                    });
                    if (newList.length > 1) {
                        const stopsStr = newList.slice().sort((a, b) => a.pos - b.pos).map(({ color, pos }) => `${color} ${(pos * 100).toFixed(0)}%`).join(', ');
                        const result = `linear-gradient(${angle}deg, ${stopsStr})`
                        setExperimental_backgroundImage(result);
                        if (viewItemsCount < newList.length) {
                            flatListRef.current?.scrollToOffset({ offset: newList.length * 55, animated: true });
                        };
                    };
                    return newList;
                });
            } else {
                setColors(prev => [...prev, obj]);
                setCurrentId(obj.id);
            };
        };
    };
    const handleBack = () => router.dismiss();
    const handleConfirm = async () => {
        if (colors.length < 2) {
            showNotification({ tip: '请至少选择两个颜色', type: 'warning' });
            return
        };
        if (!title) {
            showNotification({ tip: '请填写渐变色标题', type: 'warning' });
            return;
        };
        try {
            const configId = await insertGradient({
                id: generateId(),
                theme_type: type,
                title,
                is_active: 1,
                sort_order: 0,
                stops: colors.map(({ color, pos }, index) => ({
                    color,
                    position: pos,
                    id: generateId(),
                    sort_order: index,
                    config_id: '' // 会在 insertGradient 内部统一设置
                })),
                metadata: {
                    config_id: '', // 会在 insertGradient 内部统一设置
                    gradient_type: 'linear',
                    angle,
                },
            });
            if (configId) {
                showNotification({ tip: '保存成功', type: 'success' });
                const { setSignal } = useProMusicStore.getState();
                setSignal('RNLinearColorSelectPage');
                // 可以在这里执行保存后的操作,如返回上一页
                handleBack();
            } else {
                showNotification({ tip: '保存失败', type: 'error' })
            }
        } catch (error) {
            showNotification({ tip: '请稍后重试', type: 'warning' })
        };
    };
    const handleChangeMode = () => {
        setMode(prev => prev === 'select' ? 'delete' : 'select');
    };
    const handlePressColorItem = useCallback((item: ColorConfig, mode: LinearMode) => {
        if (mode === 'delete') {
            setColors(prev => prev.filter(({ id }) => id !== item.id));
        } else {
            const { id, color, pos } = item;
            setCurrentId(id);
            setResultColor(color);
            setSliderValue(pos);
        };
    }, []);
    const handleSliderChange = (val: number) => {
        setSliderValue(Number(val.toFixed(1)));
        if (currentId) {
            setColors(prev => {
                const newList = prev.map(el => {
                    if (el.id === currentId) {
                        return { ...el, pos: val }
                    };
                    return el;
                });
                const stopsStr = newList.slice().sort((a, b) => a.pos - b.pos).map(({ color, pos }) => `${color} ${(pos * 100).toFixed(0)}%`).join(', ');
                const result = `linear-gradient(${angle}deg, ${stopsStr})`
                setExperimental_backgroundImage(result);
                return newList;
            });

        };
    };
    /**
     * 角度值发生变化时执行
     * 添加节流处理,角度变化后
     * 会在已选颜色两种以上的情况下重新想修改
     * 渐变色
     */
    const handleAngelChange = (val: number) => {
        setAngle(Number(val.toFixed(0)));
        if (colors.length > 1) {
            const stopsStr = colors.slice().sort((a, b) => a.pos - b.pos).map(({ color, pos }) => `${color} ${(pos * 100).toFixed(0)}%`).join(', ');
            const result = `linear-gradient(${angle}deg, ${stopsStr})`
            setExperimental_backgroundImage(result);
        };
    };
    const handleTextChange = (val: string) => setTitle(val.trim());
    const themeType = type === 'light';
    const handleAddColor = useCallback(() => {
        const id = generateId();
        setCurrentId(id);
        setColors(prev => {
            const newItem = { ...prev[prev.length - 1], id };
            return ([...prev, newItem]);
        });
    }, []);
    return (<ThemedView
        style={[styles.container, { paddingBottom: bottom + 10, experimental_backgroundImage }]}
    >
        <PageTitleBar
            leftText='配置颜色'
            onPressLeft={handleBack}
        />
        <KeyboardAvoidingView
            behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
            style={[styles.container, { paddingTop: top + 50 }]}
        >
            <ScrollView
                showsVerticalScrollIndicator={false}
            >
                <View
                    style={styles.colorPicker}
                >
                    <ThemedIonicons
                        name='color-palette-outline'
                        size={15}
                    />
                    <StyledText
                        size='SM'
                        weight='BOLD'
                    >颜色选择器</StyledText>
                </View>
                <ColorPicker
                    value={resultColor}
                    sliderThickness={16}
                    thumbSize={16}
                    thumbShape='circle'
                    onCompleteJS={onColorPick}
                    style={styles.picker}
                    boundedThumb
                >
                    <View
                        style={styles.panel}
                    >
                        <Panel3
                            style={styles.panelStyle}
                        />
                    </View>

                    <HueSlider
                        style={styles.sliderStyle}
                    />
                </ColorPicker>
                <View
                    style={styles.titleBar}
                >
                    <View
                        style={styles.title}
                    >
                        <ThemedIonicons
                            name='pin-sharp'
                            size={14}
                        />
                        <StyledText
                            size='SM'
                            weight='BOLD'
                        >颜色位置</StyledText>
                    </View>
                    <Slider
                        style={styles.slider}
                        value={sliderValue}
                        step={.01}
                        minimumValue={0}
                        maximumValue={1}
                        onSlidingComplete={handleSliderChange}
                        maximumTrackTintColor='#ccc'
                    />
                    <StyledText
                        size='XXS'
                        textAlign='right'
                        weight='BOLD'
                        style={styles.sliderText}
                    >{`${Number(sliderValue.toFixed(2)) * 100}%`}</StyledText>
                </View>
                <View
                    style={styles.titleBar}
                >
                    <View
                        style={styles.title}
                    >
                        <ThemedIonicons
                            name='compass-outline'
                            size={15}
                        />
                        <StyledText
                            size='SM'
                            weight='BOLD'
                        >渐变角度</StyledText>
                    </View>
                    <Slider
                        style={styles.slider}
                        value={angle}
                        step={1}
                        minimumValue={0}
                        maximumValue={360}
                        maximumTrackTintColor='#ccc'
                        onSlidingComplete={handleAngelChange}
                    />
                    <StyledText
                        size='XXS'
                        textAlign='right'
                        weight='BOLD'
                        style={styles.sliderText}
                    >{angle.toFixed(0)}deg</StyledText>
                </View>
                <View
                    style={styles.titleBar}
                >
                    <View
                        style={styles.title}
                    >
                        <ThemedIonicons
                            name={themeType ? 'sunny-outline' : 'moon-outline'}
                            size={themeType ? 16 : 14}
                        />
                        <StyledText
                            weight='BOLD'
                            size='SM'
                        >{`已选颜色(${colors.length})`}</StyledText>
                    </View>
                    <View
                        style={styles.title}
                    >
                        <StyledText
                            size='SM'
                            onPress={handleChangeMode}
                            color={mode === 'select' ? text : '#e44444'}
                        >{mode === 'select' ? '选择模式' : '删除模式'}</StyledText>
                        <Switch
                            style={styles.switch}
                            active={mode === 'select'}
                            onChange={handleChangeMode}
                        />
                    </View>
                </View>
                <FlashList
                    ref={flatListRef}
                    style={styles.list}
                    data={colors}
                    keyExtractor={({ id }) => id}
                    horizontal
                    showsHorizontalScrollIndicator={false}
                    contentContainerStyle={styles.colorContent}
                    renderItem={({ item }) => <ColorItem
                        mode={mode}
                        item={item}
                        active={currentId === item.id}
                        onPress={handlePressColorItem}
                    />}
                    ListFooterComponent={<ColorItem
                        onPress={handleAddColor}
                        isEmpty
                    />}
                />
                <View
                    style={styles.colorPicker}
                >
                    <ThemedIonicons
                        name='create-outline'
                        size={16}
                    />
                    <StyledText
                        weight='BOLD'
                        size='SM'
                    >颜色名称:</StyledText>
                </View>
                <View
                    style={styles.inputArea}
                >
                    <TextInput
                        value={title}
                        onChangeText={handleTextChange}
                        placeholder='请输入颜色标题'
                        placeholderTextColor={text}
                        style={[styles.input, { color: text }]}
                        maxLength={15}
                    />
                    <Pressable
                        style={{ display: title ? 'flex' : 'none' }}
                        onPress={() => handleTextChange('')}
                    >
                        <ThemedIonicons
                            name='close-circle'
                            size={18}
                        />
                    </Pressable>
                </View>
            </ScrollView>
        </KeyboardAvoidingView>
        <View
            style={styles.buttonArea}
        >
            <CustomButton
                type='primary'
                text='取消'
                onPress={handleBack}
                style={styles.buttonStyle}
            />
            <CustomButton
                type='success'
                text='保存'
                onPress={handleConfirm}
                style={styles.buttonStyle}
            />
        </View>
    </ThemedView>);
};
const shadow = Platform.select({
    web: { boxShadow: 'rgba(0, 0, 0, 0.3) 0px 0px 2px' },
    default: {
        shadowColor: '#000',
        shadowOffset: {
            width: 0,
            height: 1,
        },
        shadowOpacity: 0.2,
        shadowRadius: 1.41,
        elevation: 2,
    },
});
const styles = StyleSheet.create({
    container: {
        flex: 1
    },
    input: {
        flex: 1,
        height: 30,
        paddingVertical: 2
    },
    slider: {
        flex: 1,
    },
    sliderText: {
        width: 40
    },
    list: {
        height: 65,
        width: '100%',
        paddingHorizontal: 20
    },
    titleBar: {
        width: '100%',
        paddingHorizontal: 20,
        flexDirection: 'row',
        justifyContent: 'space-between',
        alignItems: 'center',
        paddingVertical: 15
    },
    colorPicker: {
        paddingHorizontal: 20,
        flexDirection: 'row',
        gap: 4,
        alignItems: 'center',
        paddingVertical: 10
    },
    inputArea: {
        flex: 1,
        flexDirection: 'row',
        alignItems: 'center',
        gap: 2,
        borderBottomColor: '#ccc',
        borderBottomWidth: 1,
        marginHorizontal: 20,
    },
    title: {
        flexDirection: 'row',
        alignItems: 'center',
        gap: 4
    },
    previewContainer: {
        alignItems: 'center',
        paddingVertical: 10
    },
    colorContent: {
        gap: 5,
    },
    colorCard: {
        width: 40,
        height: 40,
        borderRadius: 10,
        borderWidth: 1
    },
    buttonArea: {
        flexDirection: 'row',
        justifyContent: 'space-evenly',
        alignContent: 'center',
        paddingTop: 20,
        width: '100%'
    },
    card: {
        borderRadius: 10,
        overflow: 'hidden'
    },
    tip: {
        position: 'absolute',
        right: 1,
        top: 1
    },
    switch: {
        width: 28,
        height: 14
    },
    buttonStyle: {
        width: 120
    },
    picker: {
        paddingHorizontal: 20,
        gap: 10,
        paddingBottom: 20
    },
    panel: {
        width: '100%',
        alignItems: 'center'
    },
    panelStyle: {
        width: 260,
        ...shadow,
    },
    sliderStyle: {
        borderRadius: 20,
        ...shadow,
    },
    sliderVerticalStyle: {
        borderRadius: 20,
        height: 300,
        ...shadow,
    },
    previewTxt: {
        color: '#707070',
        fontFamily: 'Quicksand',
    },
    content: {

        padding: 20,

    }

});
export default ColorPickerPage;

有任何疑问可以查看[项目代码](expo rn: expo创建的react native的音乐播放器应用,专注视频转歌和本地歌曲播放)

用 Three.js 和 D3 在 Vue 中打造 3D 苏州地图

作者 天下权
2026年3月9日 17:22

前言

地理信息可视化一直是前端领域的热门话题。传统的 2D 地图已经无法满足我们对于视觉效果和交互体验的追求,而 3D 地图则可以提供更直观、更震撼的空间认知。本文将带你从零开始,在 Vue 项目中结合 Three.js 和 D3.js 的地理投影模块,将一个普通的 GeoJSON 文件转化为可交互的 3D 挤出地图,并支持旋转、缩放等操作。最终效果是一个具有立体感和边缘高亮的苏州各区地图。

本文所有代码均基于 Vue 3 + Three.js + d3-geo 实现,你可以直接复制代码运行体验。

image.png

原理:从经纬度到 3D 几何体

要将平面地图“立”起来,我们需要解决两个核心问题:

  1. 坐标转换:地理坐标(经纬度)无法直接在 Three.js 的笛卡尔坐标系中使用。我们需要使用地图投影(如墨卡托投影)将经纬度转换为平面上的 x、y 坐标。这里我们选择 D3.js 提供的 geoMercator 投影,它可以精确地将球面坐标映射到平面,并且可以通过 center 和 scale 参数将地图定位到场景中心。
  2. 三维挤出:有了平面轮廓后,我们可以利用 Three.js 的 ExtrudeGeometry 将平面形状挤出厚度,从而形成立体感。挤出的几何体可以赋予半透明的材质,使其看起来像一块块漂浮的玻璃板。同时,为了增强轮廓的清晰度,我们还可以在边缘绘制线条,让每个区域的分界更加明显。

整个流程可以概括为:
加载 GeoJSON → 解析几何类型(Polygon/MultiPolygon)→ 投影坐标 → 创建 Shape → 挤出 Mesh → 添加边缘 Line。

技术教程

1. 环境准备

首先创建一个 Vue 3 项目(如果你还没有),然后安装必要的依赖:

bash

npm install three d3-geo

注意:d3-geo 是 D3 的地理投影模块,我们只需要它,无需安装整个 D3。

2. 基础场景搭建

在 Vue 组件中,我们先初始化 Three.js 的核心组件:场景、相机、渲染器、轨道控制器。相机使用透视相机,并设置一个较远的初始位置(比如 z=300),以便后续加载的地图能够完整显示。

为了让画面更清晰,我们关闭阴影,限制像素比,并设置深色背景以减少视觉闪烁。

javascript

import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a1a);

const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 1, 10000);
camera.position.set(0, 0, 300);
camera.lookAt(0, 0, 0);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = false; // 关闭阴影
document.body.appendChild(renderer.domElement);

const controls = new OrbitControls(camera, renderer.domElement);
controls.update();

3. 加载并解析 GeoJSON

GeoJSON 是一种常用的地理数据格式。我们准备了一份苏州市区的 GeoJSON 文件(可以在网上寻找或自行制作),其中包含了各区(姑苏区、虎丘区、吴中区等)的边界坐标。由于网络请求可能失败,我们添加了错误处理,并使用默认多边形作为备用。

javascript

async function loadGeoJSON() {
  try {
    const response = await fetch('/苏州市区.geojson');
    const geojson = await response.json();
    processGeoJSON(geojson);
  } catch (error) {
    console.error('加载失败,使用默认数据', error);
    const defaultGeoJSON = {
      type: "FeatureCollection",
      features: [{
        type: "Feature",
        properties: { name: "默认区域" },
        geometry: {
          type: "Polygon",
          coordinates: [[[-10, -10], [10, -10], [10, 10], [-10, 10], [-10, -10]]]
        }
      }]
    };
    processGeoJSON(defaultGeoJSON);
  }
}

4. 投影转换

在 processGeoJSON 中,我们需要遍历每一个 Feature,根据几何类型(Polygon 或 MultiPolygon)提取坐标环。使用 D3 的墨卡托投影将经纬度转换为平面坐标:

javascript

import { geoMercator } from 'd3-geo';

const projection = geoMercator()
  .center([120.41453, 31.342948]) // 苏州市中心经纬度
  .translate([0, 0])
  .scale(10000);

center 设置地图中心点,scale 控制缩放比例,translate 偏移设为 [0,0] 意味着投影后的坐标原点位于 (0,0),这样我们可以直接将坐标用于 Three.js。

5. 绘制挤出几何体

对于每一个坐标环(多边形轮廓),我们创建一个 THREE.Shape,然后通过 ExtrudeGeometry 挤出厚度。这里我们使用半透明的黄色材质,并开启一定的透明度,让内部结构隐约可见。

javascript

function drawExtrudeMesh(polygon, districtName) {
  const shape = new THREE.Shape();
  polygon.forEach((point, index) => {
    const [x, y] = projection(point);
    if (index === 0) shape.moveTo(x, y);
    else shape.lineTo(x, y);
  });

  const extrudeSettings = {
    depth: 10,
    bevelEnabled: false,
    steps: 1
  };

  const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings);
  const material = new THREE.MeshBasicMaterial({
    color: 'yellow',
    transparent: true,
    opacity: 0.5
  });
  return new THREE.Mesh(geometry, material);
}

注意:这里的 depth 控制挤出高度,可以根据视觉效果调整。

6. 添加边缘线条

为了区分不同区域并增强轮廓,我们在每个多边形边缘绘制一条线。线条的 z 坐标稍微抬高(比如设为 9),使其浮在挤出体的上方,避免被遮挡。

javascript

function lineEdge(polygon) {
  const points = polygon.map(point => {
    const [x, y] = projection(point);
    return new THREE.Vector3(x, y, 9);
  });
  const geometry = new THREE.BufferGeometry().setFromPoints(points);
  const material = new THREE.LineBasicMaterial({ color: 'yellow' });
  return new THREE.Line(geometry, material);
}

7. 处理 MultiPolygon

GeoJSON 中可能存在 MultiPolygon(多个多边形构成一个区域)。我们需要递归处理,将每个子多边形分别转为 Mesh 和 Line。

javascript

if (feature.geometry.type === 'MultiPolygon') {
  coordinates.forEach(coordinate => {
    coordinate.forEach(rows => {
      map.add(drawExtrudeMesh(rows, districtName));
      map.add(lineEdge(rows));
    });
  });
} else if (feature.geometry.type === 'Polygon') {
  coordinates.forEach(rows => {
    map.add(drawExtrudeMesh(rows, districtName));
    map.add(lineEdge(rows));
  });
}

将所有生成的物体添加到一个 THREE.Object3D(即 map)中,最后将这个组添加到场景。

8. 添加辅助和光照

为了让空间感更强,我们添加了坐标轴辅助线,并设置环境光(虽然 MeshBasicMaterial 不需要光照,但为了扩展性保留)。

javascript

const axes = new THREE.AxesHelper(700);
scene.add(axes);

const light = new THREE.AmbientLight(0xffffff);
scene.add(light);

9. 启动动画循环

最后,在数据加载完成后启动动画循环,不断渲染场景。

javascript

async function init() {
  await loadGeoJSON();
  animate();
}

function animate() {
  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}

init();

10. 完整代码整合

将上述所有片段整合到一个 Vue 组件的 <script setup> 中,即可得到一个完整的 3D 地图应用。记得将 GeoJSON 文件放置在 public 目录下。

效果预览与优化方向

运行项目后,你会看到一个悬浮在黑暗空间中的黄色半透明苏州地图,每个区都有清晰的边缘线条,你可以使用鼠标旋转、缩放查看各个角度。

下章优化点:

  • 为不同区域赋予不同颜色,提高辨识度。
  • 添加鼠标悬停效果,高亮当前区域并显示名称。
  • 加入底图或街道标签,丰富信息层次。
  • 使用 ShaderMaterial 实现发光边缘等特效。
  • 添加飞线效果

SSE(Server-Sent Events)流式传输原理和XStream实践

作者 KKKK
2026年3月9日 17:14

1. 背景:SSE 数据格式 {背景}

在理解代码之前,必须先理解 SSE 协议的原始数据格式。

1.1 服务端发送的原始字节流

服务端发送:
POST /api/ai/chat → 服务端开始流式返回

原始字节流(服务端连续发送):
┌─────────────────────────────────────────────┐
│ event: delta\n                              │
│ data: {"content":"你","done":false}\n       │
│ \n                                          │  ← 空行,表示一个事件结束
│ event: delta\n                              │
│ data: {"content":"好","done":false}\n       │
│ \n                                          │
│ event: delta\n                              │
│ data: {"content":"","done":true}\n          │
│ \n                                          │
└─────────────────────────────────────────────┘

1.2 SSE 协议规范

┌─────────────────────────────────────────────────┐
│ SSE 事件格式(RFC 规范):                         │
│                                                 │
│ [field]: [value]\n                              │
│ [field]: [value]\n                              │
│ \n                                              │ ← 空行 = 事件分隔符
│                                                 │
│ field 只有 4 种:data / event / id / retry      │
│                                                 │
│ 示例:                                          │
│   event: delta\n            ← event 字段        │
│   data: {"content":"你"}\n  ← data 字段         │
│   \n                        ← 事件结束           │
└─────────────────────────────────────────────────┘

1.3 浏览器接收到的是什么

HTTP 响应体(ReadableStream<Uint8Array>):

chunk 1: [101 118 101 110 116 ...]  ← Uint8Array 字节数组
chunk 2: [100 97 116 97 58 ...]
chunk 3: [123 34 99 111 110 116 ...] ← 可能被任意截断!

注意:TCP 数据包的边界与 SSE 事件边界完全无关

这就是为什么需要 手动——将乱序、任意截断的字节流,解析成业务可用的结构化数据

2. EventSource 的工作方式

EventSource 是浏览器内置的专用 API,专门用来消费 SSE。EventSource浏览器专为 SSE 封装的高级 API,能够自动解析data/event/id/retry、自动分行、自动解码,服务器可指定重连间隔,断网自动重试。

简单示例

const es = new EventSource('/time');
es.onmessage = (e) => console.log('收到时间:', e.data);
es.onerror = (e) => console.log('出错了');

你只需要关心业务逻辑,连接管理和协议解析都由浏览器搞定。

但是有一个关键的缺点,不支持传递自定义请求头,只能用 GET。因此,对于大型项目,我们只能使用更加原生流式请求fetch + ReadableStream的方式。fetch通用流式传输,不仅能做 SSE,还能处理文件下载、视频流、自定义二进制流。

3. fetch + ReadableStream 的工作方式

首先来一段代码,下面是使用了EventSource实现的流式对话的一个方法,我们直接监听onmessage方法取到数据,这个数据已经是格式化后的数据,将这个数据追加到message中就可以实现流式的效果。现在,我们需要将它改造为fetch + ReadableStream的方式。

export const connectSSE = (message: string, options: SSEOptions) => {
  let currentAttempt = 0;
  let delay = 1000;
  let timeoutId: NodeJS.Timeout | null = null;
  let isClosed = false;
  let eventSource: EventSource | null = null;

  const closeConnection = () => {
    isClosed = true;
    if (eventSource) {
      eventSource.close();
      eventSource = null;
    }
    if (timeoutId) {
      clearTimeout(timeoutId);
      timeoutId = null;
    }
  };

  const connect = () => {
    eventSource = new EventSource(
      `/api/ai/chat?message=${encodeURIComponent(message)}`,
    );

    eventSource.onmessage = (event) => {
      try {
        const data = JSON.parse(event.data);

        if (data.done) {
          eventSource?.close();
          options.onDone();
          closeConnection();
        } else {
          options.onChunk(data.content);
        }
      } catch (error) {
        options.onError(new Error("解析SSE数据失败"));
      }
    };

    eventSource.onerror = () => {
      if (isClosed) return;

      eventSource?.close();
      options.onError(new Error("SSE连接错误"));

      if (currentAttempt < DEFAULT_RETRY_CONFIG.maxRetries) {
        currentAttempt++;
        timeoutId = setTimeout(() => {
          connect();
        }, delay);
      } else {
        options.onError(new Error(`SSE连接失败,已重试${currentAttempt}次`));
        closeConnection();
      }
    };
  };

  connect();

  return closeConnection;
};

fetch 返回的 Response 对象中的 body 是一个 ReadableStream(可读流)。它只提供最原始的字节数据,不解析任何格式,你需要手动处理:

  • 发起请求:可以自定义 method、headers、body,不限于 GET。
  • 获取流response.body 是一个 ReadableStream<Uint8Array>
  • 读取数据:通过 getReader() 获得读取器,循环读取数据块(chunk)。
  • 解析协议:需要自己将二进制 chunk 解码为字符串,按行分割,检测 SSE 格式(data:\n\n 等)。
  • 处理重连:如果需要自动重连,必须自己实现逻辑。
  • 取消连接:可以调用 reader.cancel() 或 abortController.abort()

简单示例

const response = await fetch('/time', { method: 'GET' });
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  buffer += decoder.decode(value, { stream: true });
  // 解析 buffer 中的 SSE 消息...
  // 检测 data: ...\n\n,提取数据,然后清空已处理部分
}

上面的value就是最新返回的数据流,通过解码后就是我们的文本块了,应该怎么处理呢。

EventSource 方式:
  Server SSEEventSource 解析 → onmessage 回调
  
Fetch + Stream 方式:
  Server SSEReadableStreamTextDecoder(字节→文本)
              ↓
           行缓冲(处理分割)
              ↓
           SSE 行解析(data: 前缀)
              ↓
           JSON.parse
              ↓
           onChunk 回调

首先,解码后的值赋值给buffer,此时buffer是一个字符串,它可能含有多条语句,因此需要进行切割。buffer.split("\n")将buffer分割为含有多个语句的数组,需要注意的是数组的最后一个元素可能并不是以\n结束的因此先不要处理。

为了处理这种分块,我们需要维护一个累积的缓冲区buffer 变量),将每次新收到的 chunk 追加到缓冲区末尾,然后再一起处理。

export const connectSSEFetch = async (
  message: string,
  options: SSEOptions
) => {
  const abortController = new AbortController();

  try {
    const response = await fetch(
      `/api/ai/chat?message=${encodeURIComponent(message)}`,
      { signal: abortController.signal }
    );

    const reader = response.body!.getReader();
    const decoder = new TextDecoder();
    let buffer = "";

    while (true) {
      const { done, value } = await reader.read();

      if (done) {
        options.onDone();
        break;
      }

      buffer += decoder.decode(value, { stream: true });
      const lines = buffer.split("\n");
      buffer = lines.pop() || ""; // 保留最后一行

      for (const line of lines) {
        if (line.startsWith("data: ")) {
          const data = JSON.parse(line.slice(6));
          if (data.done) {
            options.onDone();
            return;
          }
          options.onChunk(data.content);
        }
      }
    }
  } catch (err) {
    options.onError(err as Error);
  }

  return () => abortController.abort();
};

举个例子- 例如,当前缓冲区内容为:

  data: Hello\n
  data: Wor

这里 "data: Wor" 没有换行符结尾,它只是单词 "World" 的一部分,下一个 chunk 可能会带来 "ld\n\n"split('\n') 会得到:

  ["data: Hello", "data: Wor"]

其中 "data: Wor" 是最后一项,它不是以换行符结尾的完整行,而是一个不完整的行。如果现在处理这一行,就会得到错误的数据。

所以,我们不能处理最后一行,必须把它留到下一次,等后续数据到来拼完整后再处理。

到现在为止,逻辑还是比较清晰的,对比之下,Fetch只是多了一些流数据处理的逻辑,是不是可以把这部分抽取出来呢。


// 高阶函数:创建 SSE 行处理器
  // buffer 和 decoder 在闭包中维持状态,跨 chunk 使用
  const createSSELineProcessor = () => {
    let buffer = "";
    const decoder = new TextDecoder();

    return {
      // 处理单个数据 chunk
      processChunk: (chunk: Uint8Array): boolean => {
        // if (dataEndReceived) return false;

        // 1. 转换字节为文本(stream: true 表示可能是多字节字符的中间部分)
        buffer += decoder.decode(chunk, { stream: true });

        // 2. 按换行符分割成行
        const lines = buffer.split("\n");

        // 3. 最后一行可能不完整,保留到下一个 chunk
        buffer = lines.pop() || "";

        // 4. 处理完整的行
        for (const line of lines) {
          // 空行是 SSE 中的消息分隔符,跳过
          if (!line.trim()) continue;

          const data = parseSSELine(line);
          if (!data) continue; // 不是 data 行,跳过
          if (data.done) {
            options.onDone();
            return false; // 返回 false 表示应该停止处理
          }

          // 处理数据块
          options.onChunk(data.content);
        }

        return true; // 返回 true 表示继续处理
      },

      // 处理流结束时的剩余数据
      flush: (): void => {
        // 最终解码(处理任何待处理的多字节字符)
        const finalText = decoder.decode();
        if (finalText.trim()) {
          const data = parseSSELine(finalText);
          if (data && !data.done) {
            options.onChunk(data.content);
          }
        }
      },
    };
  };

这里我们对流数据处理逻辑进行了抽取,buffer作为闭包保存,使用的时候只需要p=createSSELineProcessor,然后调用p.processChunk即可。依旧是每轮判断有没有done,有的话就退出。

 while (true) {
    const { done, value } = await reader.read();

    if (done) {
      break;
    }

    const shouldContinue = processor.processChunk(value);
    if (!shouldContinue) {
      break;
    }
  }
}

还有两个细节,原来的函数有flush,这个函数是干嘛的,什么时候调用呢。

TextDecoder 是浏览器原生 API,用于将 二进制字节流(Uint8Array 解码为文本字符串,它有两种解码模式,对应两种调用方式:

带参数 + stream: true(流式解码,核心用法)

decoder.decode(chunk, { stream: true })
  • 作用分片、连续解码二进制流(比如 SSE / 文件流,数据是一块块传输的);
  • 关键特性:如果遇到不完整的多字节字符(比如中文、 emoji,UTF-8 占 3 字节),解码器不会强行解码,而是把残留字节缓存在解码器内部,等待下一块数据拼接完整后再解码;
  • 不会出现乱码,专门处理流式分片数据。

不带参数(刷新 / 收尾解码,最终调用)

decoder.decode()
  • 官方定义结束流式解码,刷新解码器内部的所有残留字节
  • 作用:把之前流式解码时缓存的未完成字节,一次性全部解码成字符串;
  • 副作用:清空解码器的内部缓冲区,标志着整个解码流程结束。 SSE 流是分块传输的,当服务器关闭流、传输彻底结束时,会存在两个残留数据问题
  1. 解码器内部残留TextDecoderstream: true 缓存了不完整的多字节字符,没有新 chunk 了,必须手动调用 decode() 刷新;
  2. 缓冲区残留:闭包中的 buffer 可能还剩最后一行不完整文本,没有后续换行符触发处理。

如果不调用 flush(),这部分数据会直接丢失flush()流传输结束后的收尾方法,专门处理最后残留的、未被解码 / 未被处理的数据,防止数据丢失。

还有一个细节,reader最后注意释放, cancel主动终止整个可读流(ReadableStream)。调用后,流会被关闭,后续无法再从该流读取任何数据,并且任何挂起的读请求都会立即完成或失败。 releaseLock释放当前读取器对流的锁定,但不关闭流。调用后,该读取器不再关联任何流,流可以被其他读取器(或异步迭代器)再次锁定。

比如

// 假设已经用 reader 读了一部分
const firstChunk = await reader.read();
// 处理 firstChunk...

// 释放锁,然后才能用 for await 或 XStream
reader.releaseLock();

// 现在可以安全地使用 for await
for await (const chunk of response.body) {
  // ...
}

如果中间没有释放,机会报错,一个流被两个代码读取了。

再补充一点,mdn对于流的读取还提到了这样一个例子:

const response = await fetch("https://www.example.org");
let total = 0;

// Iterate response.body (a ReadableStream) asynchronously
for await (const chunk of response.body) {
  // Do something with each chunk
  // Here we just accumulate the size of the response.
  total += chunk.length;
}

// Do something with the total
console.log(total);

for await...of 本质上就是异步迭代的语法糖,它与手动 while 循环实现的是完全相同的流式处理效果。ReadableStream2024年开始逐步实现了异步可迭代协议。Chrome 124(2024年4月稳定版)正式增加了对ReadableStream的异步迭代支持。区别在于代码风格:for await...of 更简洁、更符合直觉,而手动循环则暴露了更多的底层控制细节。 手动 while 循环通常是这样写的:

const reader = stream.getReader();
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  // 处理 value
}

现在还可以采用更加优雅的方式。

if (stream[Symbol.asyncIterator]) {
  for await (const chunk of stream) {
    const shouldContinue = processor.processChunk(chunk);
    // 如果接收到 done 信号,继续消耗流直到结束
    if (!shouldContinue ) {
      break;
    }
  }
} else {
  throw new Error("浏览器不支持 for await...of");
}

4. XStream 整体架构与逻辑链路

根据官网,@ant-design/x-sdk 提供了一系列的工具API,旨在帮助开发人员开箱即用的管理AI对话应用数据流,其中的XStream用于转换可读数据流。先看看用法

import { XStream } from '@ant-design/x';

async function request() {
  const response = await fetch();
  // .....

  for await (const chunk of XStream({
    readableStream: response.body,
  })) {
    console.log(chunk);
  }
}

是不是跟上面的很像,其实就是做了进一步的底层封装和优化,逻辑都差不多,下方看看怎么实现的。 源代码

4.1 数据流转图

服务端 HTTP 响应
        │
        ▼
ReadableStream<Uint8Array>          ← 原始字节流(TCP 数据包)
        │
        │  .pipeThrough(decoderStream)
        ▼
ReadableStream<string>              ← UTF-8 文本流(可能在字符中间截断)
        │
        │  .pipeThrough(splitStream('\n\n'))
        ▼
ReadableStream<string>              ← SSE 事件字符串流(按 \n\n 分割)
        │
        │  .pipeThrough(splitPart('\n', ':'))
        ▼
ReadableStream<SSEOutput>           ← 结构化 SSE 对象流
        │
        │  stream[Symbol.asyncIterator]
        ▼
AsyncGenerator<SSEOutput>           ← 支持 for await...of 消费

4.2 实际数据转换示例

输入(Uint8Array 字节):
[101, 118, 101, 110, 116, 58, 32, 100, 101, 108, 116, 97, ...]

─── decoderStream ───→

输入(string,可能不完整):
"event: delta\ndata: {\"con"    chunk 1
"tent\":\"你好\"}\n\n"           chunk 2

─── splitStream('\n\n') ───→

输出(完整事件,按 \n\n 分割):
"event: delta\ndata: {\"content\":\"你好\"}"

─── splitPart('\n', ':') ───→

输出(SSEOutput 对象):
{
  event: "delta",
  data: "{\"content\":\"你好\"}"
}

5. 核心 API 讲解

5.1 ReadableStream

ReadableStream 是浏览器中表示"可读数据流"的原生 API。

// 创建一个自定义 ReadableStream
const stream = new ReadableStream<string>({
  start(controller) {
    // 流启动时调用
    controller.enqueue('Hello');   // 推入数据
    controller.enqueue(' World');
    controller.close();            // 关闭流
  },
  cancel(reason) {
    // 消费者取消时调用
    console.log('流被取消:', reason);
  }
});

// 消费方式1: getReader()
const reader = stream.getReader();
const { done, value } = await reader.read();  // { done: false, value: 'Hello' }
reader.releaseLock();

// 消费方式2: for await...of(需要 asyncIterator 支持)
for await (const chunk of stream) {
  console.log(chunk);  // 'Hello', ' World'
}

关键特性

  • 流只能被读取一次(single-consumer)
  • 一次只能有一个 reader
  • enqueue 将数据推入内部队列
  • close 发出 done 信号

5.2 TransformStream

TransformStream 是可读流和可写流的组合,专门用来转换数据

// 基本结构
const transform = new TransformStream<Input, Output>({
  transform(chunk, controller) {
    // 每次有 chunk 时调用
    // controller.enqueue() 将转换后的数据推入输出流
    // controller.error() 报错
  },
  flush(controller) {
    // 流结束时调用,处理剩余缓冲数据
    // 对应 TextDecoder 的 decode() 无参版本
  },
  start(controller) {
    // 初始化时调用(可选)
  }
});

// 使用示例:大写转换器
const upperCaseTransform = new TransformStream<string, string>({
  transform(chunk, controller) {
    controller.enqueue(chunk.toUpperCase());
  }
});

// 消费
const writer = upperCaseTransform.writable.getWriter();
const reader = upperCaseTransform.readable.getReader();

writer.write('hello');
const { value } = await reader.read();  // 'HELLO'

与 上文 中 createSSELineProcessor 的对比:

// sse.ts:手动管理状态的处理器(命令式)
const createSSELineProcessor = () => {
  let buffer = '';
  const decoder = new TextDecoder();

  return {
    processChunk(chunk: Uint8Array): boolean {
      buffer += decoder.decode(chunk, { stream: true });
      // ... 手动处理 buffer 和行分割
      return true;
    },
    flush(): void {
      // 手动处理剩余数据
    }
  };
};

// XStream:使用 TransformStream(声明式,职责更清晰)
new TransformStream<string, string>({
  transform(streamChunk, controller) {
    // 只关注转换逻辑,不关注如何读写流
    buffer += streamChunk;
    // ...
  },
  flush(controller) {
    // 框架自动调用,无需手动触发
  }
});

5.3 pipeThrough

pipeThrough 将一个 ReadableStream 接入 TransformStream,返回新的 ReadableStream。

// 基本用法
const outputStream = inputStream.pipeThrough(transformStream);

// 链式调用(XStream 的精华)
const processedStream = rawStream
  .pipeThrough(decoderStream)     // Uint8Array → string
  .pipeThrough(splitStream())     // string → SSE事件块
  .pipeThrough(splitPart());      // SSE事件块 → {event, data}

// 类比:Linux 管道
// cat file.txt | grep "error" | awk '{print $1}'
//       ↓              ↓              ↓
// ReadableStream  pipeThrough    pipeThrough

关键特性

  • 自动处理背压(backpressure):下游消费慢时,自动暂停上游写入
  • 惰性求值:不消费就不处理
  • 返回新的 ReadableStream,原始流不可再用
// pipeThrough 内部等价于:
readable
  .pipeTo(transform.writable)   // 原流连接到 transform 的可写端
  .then(/* 完成 */);
return transform.readable;      // 返回 transform 的可读端

5.4 TextDecoderStream

专门用于字节→字符串转换的原生 TransformStream。

// 原生 TextDecoderStream(现代浏览器支持)
const nativeDecoder = new TextDecoderStream('utf-8');
// 等价于:
const polyfillDecoder = new TransformStream({
  transform(chunk, controller) {
    controller.enqueue(decoder.decode(chunk, { stream: true }));
  },
  flush(controller) {
    controller.enqueue(decoder.decode());  // 处理剩余字节
  }
});

// 使用场景:正确处理被切断的多字节字符
const chineseChar = '你';  // UTF-8: [E4 BD A0](3字节)

// 不用 stream: true 的问题:
decode([0xE4])              // 错误:'?'(字节不完整)
decode([0xBD, 0xA0])        // 错误:'??'

// 用 stream: true 的正确处理:
decode([0xE4], { stream: true })        // ''(等待后续字节)
decode([0xBD, 0xA0], { stream: true })  // '你'(完整输出)

createDecoderStream 的兼容处理

function createDecoderStream() {
  // 优先使用原生 API(性能更好)
  if (typeof TextDecoderStream !== 'undefined') {
    return new TextDecoderStream();
  }
  // 降级到 polyfill(Safari 旧版等不支持的环境)
  const decoder = new TextDecoder('utf-8');
  return new TransformStream({ /* ... */ });
}

5.5 Symbol.asyncIterator

让对象支持 for await...of 语法。

// 基本用法
const asyncIterable = {
  [Symbol.asyncIterator]: async function*() {
    yield 1;
    yield 2;
    yield 3;
  }
};

for await (const value of asyncIterable) {
  console.log(value);  // 1, 2, 3
}

// XStream 的实现:给 ReadableStream 附加异步迭代器
stream[Symbol.asyncIterator] = async function*() {
  const reader = this.getReader();  // this = stream
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    if (!value) continue;
    yield value;  // 每次 yield 一个已转换好的 SSEOutput
  }
};

// 使用效果:
const xStream = XStream({ readableStream: response.body });
for await (const event of xStream) {
  console.log(event);  // { event: 'delta', data: '{"content":"你好"}' }
}

6. XStream 各函数详解

6.1 splitStream:SSE 事件块分割器

function splitStream(streamSeparator = '\n\n') {
  let buffer = '';  // 闭包维持跨 chunk 状态

  return new TransformStream<string, string>({
    transform(streamChunk, controller) {
      buffer += streamChunk;
      const parts = buffer.split(streamSeparator);  // 按 \n\n 分割
      
      parts.slice(0, -1).forEach(part => {
        if (isValidString(part)) controller.enqueue(part);  // 推出完整事件
      });
      
      buffer = parts[parts.length - 1];  // 保留最后不完整的部分
    },
    flush(controller) {
      if (isValidString(buffer)) controller.enqueue(buffer);  // EOF 时推出最后数据
    }
  });
}

执行过程追踪

输入 chunk 1: "event: delta\ndata: {\"con"
  buffer = "event: delta\ndata: {\"con"
  parts = ["event: delta\ndata: {\"con"]    只有1部分,无完整事件
  enqueue: 无
  buffer = "event: delta\ndata: {\"con"

输入 chunk 2: "tent\":\"\"}\n\nevent: de"
  buffer = "event: delta\ndata: {\"content\":\"\"}\n\nevent: de"
  parts = [
    "event: delta\ndata: {\"content\":\"\"}",   完整事件!
    "event: de"                                   不完整
  ]
  enqueue: "event: delta\ndata: {\"content\":\"\"}"  
  buffer = "event: de"

flush 时:
  buffer = "event: de"(如有剩余)
  enqueue: "event: de"

6.2 splitPart:键值对解析器 {#splitpart}

function splitPart(partSeparator = '\n', kvSeparator = ':') {
  return new TransformStream<string, SSEOutput>({
    transform(partChunk, controller) {
      // 输入: "event: delta\ndata: {\"content\":\"你好\"}"
      const lines = partChunk.split(partSeparator);

      const sseEvent = lines.reduce<SSEOutput>((acc, line) => {
        const separatorIndex = line.indexOf(kvSeparator);  // 找第一个 ':'
        if (separatorIndex === -1) return acc;

        const key = line.slice(0, separatorIndex).trim();
        if (!isValidString(key)) return acc;  // 跳过注释行(: 开头)

        const value = line.slice(separatorIndex + 1).trim();
        return { ...acc, [key]: value };
      }, {});

      if (Object.keys(sseEvent).length === 0) return;
      controller.enqueue(sseEvent);
    }
  });
}

细节:为什么用 indexOf 而不是 split(':')

// 假设 data 内容中包含冒号:
const line = 'data: {"url":"https://example.com"}';

// ❌ split(':') 会把 URL 中的冒号也切割
line.split(':')  // ['data', ' {"url"', '"https', '//example.com"}']

// ✅ indexOf 只找第一个冒号
const i = line.indexOf(':');  // 4
const key = line.slice(0, 4).trim();   // 'data'
const value = line.slice(5).trim();    // '{"url":"https://example.com"}'

执行过程追踪

输入: "event: delta\ndata: {\"content\":\"你好\"}"

lines = [
  "event: delta",
  "data: {\"content\":\"你好\"}"
]

reduce 过程:
  line "event: delta":
    separatorIndex = 5
    key = "event"
    value = "delta"
    acc = { event: "delta" }

  line "data: {\"content\":\"你好\"}":
    separatorIndex = 4
    key = "data"
    value = "{\"content\":\"你好\"}"
    acc = { event: "delta", data: "{\"content\":\"你好\"}" }

enqueue: { event: "delta", data: "{\"content\":\"你好\"}" }

6.3 createDecoderStream:兼容性解码器 {#createdecoderstream}

function createDecoderStream() {
  // 优先使用原生 API
  if (typeof TextDecoderStream !== 'undefined') {
    return new TextDecoderStream();
  }

  // 降级 polyfill:手动实现相同逻辑
  const decoder = new TextDecoder('utf-8');
  return new TransformStream({
    transform(chunk, controller) {
      controller.enqueue(decoder.decode(chunk, { stream: true }));
    },
    flush(controller) {
      controller.enqueue(decoder.decode());  // 确保最后字节被正确输出
    },
  });
}

兼容性说明:

  • TextDecoderStream:Chrome 71+, Firefox 105+, Safari 14.1+
  • polyfill:理论上覆盖所有支持 TransformStream 的浏览器

6.4 XStream 主函数 {#xstream-main}

function XStream<Output = SSEOutput>(options: XStreamOptions<Output>) {
  const { readableStream, transformStream, streamSeparator, partSeparator, kvSeparator } = options;

  const decoderStream = createDecoderStream();

  // 构建管道链(两种模式)
  const stream = (
    transformStream
      ? // 模式A:自定义 transformStream(用户完全自控解析逻辑)
        readableStream
          .pipeThrough(decoderStream)      // Uint8Array → string
          .pipeThrough(transformStream)    // string → Output(用户定义)
      : // 模式B:默认 SSE 解析(三段式管道)
        readableStream
          .pipeThrough(decoderStream)      // Uint8Array → string
          .pipeThrough(splitStream(...))   // string → SSE事件块
          .pipeThrough(splitPart(...))     // SSE事件块 → SSEOutput
  ) as XReadableStream<Output>;

  // 给流对象附加 AsyncIterator,让其支持 for await...of
  stream[Symbol.asyncIterator] = async function*() {
    const reader = this.getReader();
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      if (!value) continue;
      yield value;
    }
  };

  return stream;
}

自定义 TransformStream 的场景

// 场景:服务端返回 JSON Lines 格式而非标准 SSE
// {"content":"Hello","type":"text"}\n
// {"content":"World","type":"text"}\n

const jsonLinesTransform = new TransformStream<string, MyOutput>({
  transform(chunk, controller) {
    // 手动处理每行 JSON
    chunk.split('\n').filter(Boolean).forEach(line => {
      try {
        controller.enqueue(JSON.parse(line));
      } catch {}
    });
  }
});

const stream = XStream({
  readableStream: response.body,
  transformStream: jsonLinesTransform,  // 替换默认的 SSE 解析管道
});

7. 与 上文实现 的对比分析

7.1 架构对比

自己实现 (命令式)                    XStream (声明式/管道式)
─────────────────────────           ──────────────────────────
connectSSE()                        XStream()
  │                                   │
  ├─ EventSource (高层API)             ├─ ReadableStream (底层)
  │   └─ 自动处理SSE协议               │
  │                                   │  .pipeThrough(decoderStream)
  └─ connectSSEWithFetch()            │  .pipeThrough(splitStream)
      │                               │  .pipeThrough(splitPart)
      ├─ fetch()                      │
      ├─ createSSELineProcessor()     │  for await...of
      │   ├─ buffer (闭包)            │
      │   ├─ decoder (闭包)           └─ 返回流对象(延迟消费)
      │   ├─ processChunk()
      │   └─ flush()
      └─ readStream()

7.2 数据处理对比

自己实现(命令式,紧耦合业务逻辑):

// 解析和业务回调混在一起
const processChunk = (chunk: Uint8Array) => {
  buffer += decoder.decode(chunk, { stream: true });
  const lines = buffer.split('\n');
  buffer = lines.pop() || '';

  for (const line of lines) {
    if (!line.trim()) continue;
    const data = parseSSELine(line);
    if (!data) continue;

    if (data.done) {
      options.onDone();    // ← 业务逻辑耦合在解析中
      return false;
    }
    options.onChunk(data.content);  // ← 业务回调耦合在解析中
  }
};

XStream(声明式,关注点分离):

// splitStream 只关心按 \n\n 分割
new TransformStream({ transform(chunk) { /* 只分割 */ } })

// splitPart 只关心解析键值对
new TransformStream({ transform(chunk) { /* 只解析 */ } })

// 业务逻辑完全在消费侧
for await (const event of stream) {
  if (event.data) {
    const data = JSON.parse(event.data);
    if (data.done) onDone();
    else onChunk(data.content);
  }
}

7.3 buffer 处理对比

// sse.ts:手动管理 buffer(需要理解流式处理细节)
const createSSELineProcessor = () => {
  let buffer = '';                          // 跨 chunk 维持
  const decoder = new TextDecoder();        // 手动创建

  return {
    processChunk(chunk: Uint8Array) {
      buffer += decoder.decode(chunk, { stream: true });   // 手动解码
      const lines = buffer.split('\n');
      buffer = lines.pop() || '';           // 手动保留最后行
      // ...
    },
    flush() {
      decoder.decode();                     // 手动 flush
    }
  };
};

// XStream:TransformStream 框架自动处理 flush
new TransformStream({
  transform(chunk, controller) {
    buffer += chunk;
    // ...buffer 处理...
  },
  flush(controller) {
    // 框架在流结束时自动调用这个方法,不需要手动触发
    if (buffer) controller.enqueue(buffer);
  }
});

7.4 主要差异表

维度 自己实现 XStream
设计思想 命令式,手动控制流程 声明式,管道组合
关注点分离 解析与业务耦合 每层只做一件事
可组合性 低,硬编码解析逻辑 高,可替换任意管道环节
可测试性 需模拟整个连接 每个 TransformStream 可单独测试
背压处理 手动/无 pipeThrough 自动处理
重连逻辑 内置 不含(需外部处理)
格式灵活性 固定 SSE 格式 支持自定义 transformStream
学习曲线 低,容易理解 较高,需理解流 API
flush 触发 手动调用 框架自动调用

7.5 联系:相同的核心解题思路

两者都解决了同一个核心问题:TCP 数据包不按照 SSE 事件边界切割

同一 buffer 策略:

sse.ts:                          XStream:
─────────────────                ─────────────────────────────
buffer = ''                      let buffer = ''   (在 splitStream 中)
buffer += decode(chunk)          buffer += streamChunk
lines = buffer.split('\n')       parts = buffer.split('\n\n')
buffer = lines.pop()             buffer = parts[parts.length - 1]

7.6 结合使用建议

实际上,可以把 XStream 用于 sse.ts 中:

// 用 XStream 替代 createSSELineProcessor
export const connectSSEWithFetch = (message, options) => {
  // ... 重连逻辑保留 ...

  const connect = async () => {
    const response = await fetch(url, { signal: abortController.signal });

    // ✅ 用 XStream 处理解析,用 sse.ts 处理重连
    const stream = XStream({ readableStream: response.body });

    for await (const event of stream) {
      if (!event.data) continue;
      const data = JSON.parse(event.data);

      if (data.done) {
        options.onDone();
        closeConnection();
        break;
      }
      options.onChunk(data.content);
    }
  };
};

8. 总结

8.1 流 API 知识体系

浏览器 Streams API 体系:

ReadableStream              可读流(数据来源)
  ├─ .getReader()           获取 reader(锁定流)
  │   ├─ .read()            逐块读取
  │   └─ .releaseLock()     释放锁
  ├─ .pipeThrough(ts)       接入 TransformStream
  └─ .pipeTo(ws)            接入 WritableStream

TransformStream<I, O>       转换流(数据中转站)
  ├─ .readable              可读端(输出侧)
  ├─ .writable              可写端(输入侧)
  └─ new TransformStream({
       transform(chunk, ctrl) { ctrl.enqueue(data) }
       flush(ctrl)            { /* EOF 处理 */ }
     })

WritableStream               可写流(数据消费者)

管道链:
ReadableStream
  .pipeThrough(TransformStream1)   → ReadableStream
  .pipeThrough(TransformStream2)   → ReadableStream
  .pipeTo(WritableStream)          → Promise<void>

8.2 XStream 管道链的优雅之处

// 每一层只做一件事,清晰可维护

readableStream                         // Uint8Array(二进制)
  .pipeThrough(createDecoderStream())  // → string(解码)
  .pipeThrough(splitStream('\n\n'))    // → string(按事件分割)
  .pipeThrough(splitPart('\n', ':'))   // → SSEOutput(解析键值)

// 想换成 JSON Lines 格式?只替换最后两层
  .pipeThrough(jsonLinesTransform)

// 想加压缩?在最前面加一层
readableStream
  .pipeThrough(decompressionStream)    // → Uint8Array(解压)
  .pipeThrough(createDecoderStream())
  // ...

8.3 快速参考

// ① 创建最简单的 XStream 消费
const stream = XStream({ readableStream: response.body });
for await (const event of stream) {
  console.log(event);  // { event: 'delta', data: '...' }
}

// ② 自定义格式
const stream = XStream({
  readableStream: response.body,
  transformStream: myCustomTransform,
});

// ③ 自定义分隔符
const stream = XStream({
  readableStream: response.body,
  streamSeparator: '\r\n\r\n',  // Windows 换行
  kvSeparator: '=',             // key=value 格式
});

// ④ 使用 splitStream/splitPart 单独测试
const splitTransform = splitStream('\n\n');
const writer = splitTransform.writable.getWriter();
const reader = splitTransform.readable.getReader();

writer.write("event: test\n\n");
const { value } = await reader.read();  // "event: test"

9. 感想

其实一步步做下来会发现,sse看起来只是一个协议,但是涉及到的前端知识挺多的,从浏览器自己封装的EventSource接口再到自己基于fetch封装更加灵活的接口,再到xStream的源码实现,其中有许多流的相关概念,同时还涉及到了闭包、断连重试等JS基础,总而言之,好好看看还是有不少收获的。

React Native 物理按键扫码监听终极方案:从冲突到完美共存

2026年3月9日 16:46

React Native 物理按键扫码监听终极方案:从冲突到完美共存

写给所有被 PDA 扫码折磨的开发者,以及未来的自己。

如果你正在开发 PDA(手持终端)应用,并且遇到了“全局监听和页面监听打架”、“扫码结果在这个页面能收到,在那个页面就收不到”的问题,那么这篇文章就是为你准备的。

1. 遇到的问题

在开发仓库管理系统(WMS)或类似 PDA 应用时,物理扫码键是最常用的交互方式。我们通常会有两种需求:

  1. 全局监听:不管在哪个页面,我都要记录扫码历史,或者做一些全局的日志记录。
  2. 页面监听:在具体的业务页面(比如入库单、盘点单),我需要拿到扫码结果去请求接口、查询商品。

最初的痛点: 当我们使用原生的 DeviceEventEmitter 或者简单的封装时,往往会遇到“单播”的尴尬——一旦我在具体的业务页面开始监听扫码,全局的那个监听器就被“顶”掉了,失效了;或者反过来,全局监听器把事件拦截了,业务页面收不到。

2. 解决方案的核心思想:多播(Multicast)

要解决这个问题,我们需要一个“中间人”(Manager)。

  • 以前的模式(单播):原生事件 -> Manager -> 唯一的监听者(谁最后注册谁就赢)。
  • 现在的模式(多播):原生事件 -> Manager -> 监听者列表(Set) -> 分发给所有注册的人。

这样,无论是全局的 Context,还是具体的页面组件,只要向 Manager 注册了,大家都能收到通知,互不干扰!

3. 代码实现全解析

3.1 底层管理者:PhysicalKeyScanManager

这是最核心的部分。它负责跟原生模块打交道,并维护一个监听者列表。

关键点:

  • 使用 Set 来存储回调函数,自动去重。
  • startListening 不再覆盖旧的回调,而是 add 进列表。
  • stopListening 只移除指定的回调,而不是清空所有。
// src/utils/PhysicalKeyScanManager.js

class PhysicalKeyScanManager {
  constructor() {
    // ...
    this.listeners = new Set(); // 核心:存放所有监听者的集合
    // ...
  }

  // 收到原生事件后的处理
  _handleScanResult = (result) => {
    // ... 包装数据 ...
  
    // 核心:遍历列表,人人有份
    this.listeners.forEach(callback => {
        if (callback) callback(scanData);
    });
  };

  startListening(callback) {
    // 1. 把新来的监听者加入集合
    if (callback) this.listeners.add(callback);

    // 2. 如果是第一个监听者,才真正去建立原生连接(省资源)
    if (!this.scanSubscription) {
      this.scanSubscription = this.scanEventEmitter.addListener(
        'onScanResult',
        this._handleScanResult
      );
    }
  
    // 3. 返回一个取消函数,方便 useEffect 清理
    return () => this.stopListening(callback);
  }

  stopListening(callback) {
    // 1. 只移除这一个监听者
    if (callback) this.listeners.delete(callback);

    // 2. 如果人走茶凉(列表空了),就把原生连接也断了
    if (this.listeners.size === 0 && this.scanSubscription) {
      this.scanSubscription.remove();
      this.scanSubscription = null;
    }
  }
}

3.2 全局大管家:ScanContext

我们在 App 的最顶层(App.js)包裹这个 Provider。它的作用是从 App 启动那一刻起,就占一个坑位

它负责:

  • 初始化扫码服务(autoInit)。
  • 记录所有的扫码历史(history)。
  • 提供全局状态。
// src/context/ScanContext.js

useEffect(() => {
  physicalKeyScanManager.autoInit();

  // 注册全局监听,因为 Manager 支持多播,这里注册了也不会影响别的页面
  const unsubscribe = physicalKeyScanManager.startListening(result => {
    console.log('[全局记录] 收到扫码:', result.code);
    setHistory(prev => [result, ...prev]);
  });

  return () => unsubscribe();
}, []);

3.3 页面级的 Hook:usePhysicalKeyScan

这是给普通业务页面用的。它的特点是智能管理生命周期

  • 页面获得焦点时:自动开始监听。
  • 页面失去焦点时:自动停止监听。

这样能保证用户不在当前页面时,不会意外触发当前页面的逻辑。

注意:为了防止 React Hooks 的闭包陷阱导致监听器重复注册(出现收一次码打印两次日志的 Bug),我们在实现时使用了局部变量锁定的技巧,确保清理函数总是清理当前周期创建的那个监听器。

// src/hooks/usePhysicalKeyScan.js

useFocusEffect(
  useCallback(() => {
    let unsubscribe = null; // 局部变量,锁定当前周期的监听器

    if (autoStart) {
      // 页面来了,注册监听,并赋值给局部变量
      unsubscribe = physicalKeyScanManager.startListening(handleScanResult);
      // 同步到 ref 供外部(如卸载时)使用
      unsubscribeRef.current = unsubscribe;
      // ...
    }

    return () => {
      // 页面走了,使用局部变量进行清理,精准打击
      if (unsubscribe) {
        unsubscribe();
      }
      // ...
    };
  }, [autoStart])
);

3.4 进阶 Hook:useContextualPhysicalKeyScan

这是最强大的 Hook,专门解决**“我要把这个码扫给谁?”**的问题。

比如在一个物料列表中,点击某一行,然后扫码,把条码填入该行。

  • setContext(item):设置当前正在操作的对象(上下文)。
  • onScan(result, context):回调里会把当时的上下文带回来给你。
// src/hooks/useContextualPhysicalKeyScan.js

const setContext = useCallback((context) => {
  contextRef.current = context; // 存起来
  // 可以设置个超时,比如30秒后自动清除,防止误操作
}, []);

const handleScanResult = useCallback((result) => {
  // 触发回调时,把上下文也传出去
  onScan(result, contextRef.current);
}, []);

4. 如何使用?(小白看这里)

场景一:我就想在页面里拿扫码结果

直接用 usePhysicalKeyScan

import usePhysicalKeyScan from '@/hooks/usePhysicalKeyScan';

const MyPage = () => {
  usePhysicalKeyScan({
    onScan: (result) => {
      alert(`扫到了:${result.code}`);
      // 这里调用接口查询...
    }
  });

  return <View>...</View>;
};

场景二:我有好几个输入框/列表项,我要区分扫给谁

useContextualPhysicalKeyScan

import useContextualPhysicalKeyScan from '@/hooks/useContextualPhysicalKeyScan';

const ListPage = () => {
  const { setContext } = useContextualPhysicalKeyScan({
    onScan: (result, context) => {
      if (context) {
        console.log(`把条码 ${result.code} 赋值给商品 ${context.name}`);
        // 更新列表数据...
      } else {
        console.log('没选中商品,扫码无效或作为通用查询');
      }
    }
  });

  return (
    <View>
      {items.map(item => (
        <TouchableOpacity 
          key={item.id} 
          onPress={() => setContext(item)} // 点击选中,告诉 Hook “接下来扫码是给它的”
        >
          <Text>{item.name}</Text>
        </TouchableOpacity>
      ))}
    </View>
  );
};

5. 总结

通过改造 PhysicalKeyScanManager 为多播模式,我们完美实现了:

  1. 全局不掉线:ScanContext 里的历史记录永远在记录。
  2. 页面互不扰:A 页面监听扫码,不会影响 B 页面;离开 A 页面自动停止监听。
  3. 上下文可追踪:清楚地知道当前这一次扫码是为了哪个业务对象。

这就是 PDA 物理按键扫码的“终极解决方案”。🚀

主动取消的防抖

作者 Din
2026年3月9日 16:32

支持主动取消的防抖:两种 API 设计对比(写法一 vs 写法二)

本文对比两种「可取消防抖」的封装方式:Lodash 风格(单函数 + .cancel)与 双方法返回({ run, cancel }),并给出实现与选型建议。

一、为什么需要「可取消」的防抖?

防抖(debounce)大家都很熟:在连续触发时只执行最后一次。但有一种场景,仅「延迟执行」不够,还需要主动取消

  • 输入校验 + 异步请求:用户输入金额 → 400ms 防抖后请求「计算手续费」。若用户在这 400ms 内把金额删成 0 或改成非法值,我们希望在校验失败时取消这次待执行的请求,而不是等 400ms 后仍用旧值或 0 去请求接口。

此时就需要:在错误分支里主动取消防抖,避免无效请求。
VueUse 的 useDebounceFn 没有暴露 .cancel(),所以我们可以自己封装一个「支持取消」的防抖,并在设计 API 时面临两种风格:写法一(单函数 + .cancel)写法二(返回两个方法)


二、写法一:Lodash 风格 —— 一个函数 + .cancel

思路

返回一个函数,既可正常调用(触发防抖),又挂载 .cancel() 方法,用于取消当前等待中的执行。和 Lodash 的 debounce 返回的 API 一致。

实现

/**
 * 类 lodash 的防抖,支持主动取消防抖(.cancel())
 * @param fn 要防抖的函数
 * @param delay 延迟毫秒数
 * @returns 防抖后的函数,带 .cancel() 方法
 */
export function useDebounceWithCancel<T extends (...args: any[]) => any>(
  fn: T,
  delay: number,
): ((...args: Parameters<T>) => void) & { cancel: () => void } {
  let timer: ReturnType<typeof setTimeout> | null = null;

  function cancel() {
    if (timer !== null) {
      clearTimeout(timer);
      timer = null;
    }
  }

  function run(...args: Parameters<T>) {
    cancel();
    timer = setTimeout(() => {
      fn(...args);
      timer = null;
    }, delay);
  }

  run.cancel = cancel;
  return run;
}

使用方式

const debouncedCalculateFee = useDebounceWithCancel(calculateFee, 400);

// 触发防抖
debouncedCalculateFee();

// 在错误分支等场景下主动取消
debouncedCalculateFee.cancel();

特点

优点 缺点
只维护一个变量,心智负担小 类型要写交叉类型 Fn & { cancel: () => void }
与 Lodash / 社区常见 API 一致 有人不习惯「函数上挂方法」
便于传递:把「防抖函数」当整体传参时,对方也能 .cancel()

三、写法二:返回两个方法 —— { run, cancel }

思路

不返回「带属性的函数」,而是直接返回两个方法:一个负责触发防抖(run),一个负责取消(cancel)。职责分离,一眼能看出是两个能力。

实现

/**
 * 防抖,支持主动取消。返回两个方法:触发防抖 / 取消防抖
 */
export function useDebounceWithCancel<T extends (...args: any[]) => any>(
  fn: T,
  delay: number,
): { run: (...args: Parameters<T>) => void; cancel: () => void } {
  let timer: ReturnType<typeof setTimeout> | null = null;

  function cancel() {
    if (timer !== null) {
      clearTimeout(timer);
      timer = null;
    }
  }

  function run(...args: Parameters<T>) {
    cancel();
    timer = setTimeout(() => {
      fn(...args);
      timer = null;
    }, delay);
  }

  return { run, cancel };
}

使用方式

const { run: debouncedCalculateFee, cancel: cancelCalculateFee } =
  useDebounceWithCancel(calculateFee, 400);

// 触发防抖
debouncedCalculateFee();

// 取消防抖
cancelCalculateFee();

特点

优点 缺点
「触发」和「取消」职责分离,语义清晰 需要维护两个名字(或解构时起别名)
类型简单,就是普通对象 { run, cancel } 与 Lodash 等单函数 + .cancel 的形态不一致
解构时可自由命名(如 run → debouncedCalculateFee) 若要把防抖「整体」传给子组件,需要传 run + cancel 两个
闭包逻辑与写法一完全一致

四、闭包:两种写法是同一套逻辑

无论写法一还是写法二,防抖和取消能生效,靠的都是闭包

  • runcancel 都在 useDebounceWithCancel 内部定义,共享同一份 timer(以及 fndelay)。
  • 多次调用 run() 会先 cancel() 再设新的 setTimeout,所以「只执行最后一次」。
  • 在任意时机调用 cancel()(或 debouncedFn.cancel()),清掉的都是这一份 timer

所以:返回一个函数再挂 .cancel,还是返回 { run, cancel },闭包行为相同;差异只在 API 形态和调用方式。


五、对比与选型建议

维度 写法一(单函数 + .cancel) 写法二({ run, cancel })
变量数量 一个 两个(或解构出两个名字)
类型写法 交叉类型稍复杂 普通对象,简单
与 Lodash 一致性 一致 不一致
语义 「一个防抖函数,附带取消」 「两个独立能力」
传递/复用 传一个引用即可,对方可 .cancel() 需传 run + cancel

选型建议

  • 更看重和 Lodash / 社区习惯统一,或需要把「防抖」作为整体传递(如传给子组件、工具函数)→ 优先 写法一
  • 更看重职责分离、类型简单、命名灵活,且多在本组件内使用 → 写法二 也很合适。

没有绝对优劣,按团队习惯和具体场景选即可。


六、小结

  • 可取消防抖在「输入校验 + 延迟请求」场景里很实用,能避免无效请求。
  • 写法一:返回带 .cancel() 的单个函数,Lodash 风格,便于传递和统一心智。
  • 写法二:返回 { run, cancel },职责清晰,类型简单,闭包逻辑与写法一相同。
  • 两种写法都依赖同一套闭包(共享 timer),可按团队偏好和场景在两种 API 间选择。

如果你也在做表单防抖、搜索防抖或类似「延迟执行 + 需要取消」的逻辑,不妨试试自己封装一个「支持 cancel 的防抖」,并在这两种 API 风格里选一种落地到项目里。


❌
❌