阅读视图

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

每日一题-区间乘法查询后的异或 I🟡

给你一个长度为 n 的整数数组 nums 和一个大小为 q 的二维整数数组 queries,其中 queries[i] = [li, ri, ki, vi]

对于每个查询,按以下步骤执行操作:

  • 设定 idx = li
  • idx <= ri 时:
    • 更新:nums[idx] = (nums[idx] * vi) % (109 + 7)
    • idx += ki

在处理完所有查询后,返回数组 nums 中所有元素的 按位异或 结果。

 

示例 1:

输入: nums = [1,1,1], queries = [[0,2,1,4]]

输出: 4

解释:

  • 唯一的查询 [0, 2, 1, 4] 将下标 0 到下标 2 的每个元素乘以 4。
  • 数组从 [1, 1, 1] 变为 [4, 4, 4]
  • 所有元素的异或为 4 ^ 4 ^ 4 = 4

示例 2:

输入: nums = [2,3,1,5,4], queries = [[1,4,2,3],[0,2,1,2]]

输出: 31

解释:

  • 第一个查询 [1, 4, 2, 3] 将下标 1 和 3 的元素乘以 3,数组变为 [2, 9, 1, 15, 4]
  • 第二个查询 [0, 2, 1, 2] 将下标 0、1 和 2 的元素乘以 2,数组变为 [4, 18, 2, 15, 4]
  • 所有元素的异或为 4 ^ 18 ^ 2 ^ 15 ^ 4 = 31

 

提示:

  • 1 <= n == nums.length <= 103
  • 1 <= nums[i] <= 109
  • 1 <= q == queries.length <= 103
  • queries[i] = [li, ri, ki, vi]
  • 0 <= li <= ri < n
  • 1 <= ki <= n
  • 1 <= vi <= 105

我用 AI 撸了个开源"万能预览器":浏览器直接打开 Office、CAD 和 3D 模型

最近一直在深耕 AI Agent 与大模型应用,比如 JitKnow AI 知识库、JitWord协同AI文档、Pxcharts 超级表格,同时也持续在给大家分享 GitHub 上真正能落地、能解决实际问题的优质AI开源项目。

两周前发布了我们开源的文档预览SDK——jit-viewer。

图片

目前在npm上已有 2.1k 的下载量,我们也在持续更新迭代,满足更多开发者的需求。

github:github.com/jitOffice/j…

国内镜像:gitee.com/lowcode-chi…

在 AI Coding的帮助下,我加速了迭代频率,今天很高兴和大家分享Jit-Viewer最新版本 V1.3.0.

什么是 Jit-Viewer

图片

简单来说,它是一个纯前端的文件预览引擎。不需要后端转换服务,不需要安装任何插件,几行代码就能让浏览器具备"专业软件"的预览能力。

图片

过去我们 preview 文件,要么调用微软/Google 的在线接口(有隐私风险),要么自建转换服务(服务器成本高)。

jit-viewer 的思路很直接:把解析能力搬到浏览器端。下面我就和大家分享一下最新版本的更新内容。

1. 支持CAD文件预览功能

图片

事情的起因很简单:工程团队在处理设计稿交付时,总是要在微信里发"麻烦安装个 CAD 看图软件"或者"这个 3D 模型我截图给你"。

作为一位写过无数款文档编辑器、多维表格的开发者,我突然意识到——为什么我们不能在浏览器里直接预览这些文件?

没有安装包,没有兼容性问题,打开链接就能看。这不应该是 2026 年的标配吗?

于是借助 AI, 我在 Jit-viewer sdk中支持了CAD文件的预览。

目前线上已提供demo测试,大家也可以体验测试一下。

2. 支持3D文件预览功能

图片

3D模型预览我们开放了很多能力,比如自动旋转3D模型,对3D模型进行旋转,截图,环境渲染器配置等,基本上开箱即用,开发者不需要关注复杂的3D空间知识,只需要按照我们api文档提供的信息配置,即可实现专业的3D模型预览功能。比如你想在web系统中预览3D商品图,手动调整模型渲染方式,都是用轻松用Jit-Viewer 来实现。大家另一个比较关注的问题可能是性能问题,这里我也做了性能优化的方案:

  1. WebAssembly 承担重计算:CAD 的几何解析、3D 模型的三角化都在 WASM 中完成,避免阻塞主线程
  2. 流式加载:大模型支持 LOD(细节层次)加载,先展示低精度轮廓,再逐步细化
  3. Worker 多线程:解析和渲染分离,UI 永不卡顿

3. 视频预览支持完全可控的视频播放控件

图片

我基本上重写了视频播放器,隐藏了video原生的视频播放控件,利用js api,重写了一个完全可控的视频播放 API 接口。

大家可以通过编程式来控制视频的播放,同时还能配置式控制播放控件的显示逻辑:

图片

那么最近的迭代,有哪些应用场景呢?

jit-viewer 不只是用来"打开文件",在我们实际业务中,它解决了几个实际的痛点:

场景 1:设计评审系统

  • 设计师上传 CAD 图纸,产品经理和开发直接在浏览器标注尺寸,无需安装 AutoCAD
  • 支持测量工具(距离、角度、面积),数据实时同步到多维表格

场景 2:3D 电商展示

  • 用户上传 3D 模型,自动生成 360° 预览,替代传统的图片轮播
  • 支持爆炸图动画,展示产品内部结构

场景 3:BIM 轻量化查看

  • 建筑信息模型在浏览器端轻量化展示,现场工程师用手机就能查看管线碰撞

场景 4:制造业协同

  • 供应商和客户之间传递 3D 模型,不再担心"你用的 SolidWorks 版本和我不兼容"

优缺点分析(客观总结,方便大家参考评估)

✅ 优势:

  • 零服务端成本:纯前端方案,不需要维护昂贵的文件转换服务器
  • 隐私安全:文件不上云,本地解析,适合涉密图纸
  • 极致体验:打开即看,无需等待"转换中"的 loading
  • 插件化架构:按需加载,不用 CAD 功能就不加载 2MB 的 WASM 文件

❌ 局限:

  • 超大文件限制:超过 500MB 的 CAD 文件还是建议用桌面软件,浏览器内存有限
  • 复杂特性缺失:CAD 的图层编辑、3D 的复杂材质节点暂时不支持(仅预览)
  • 移动端性能:3D 模型在低端手机上帧率可能下降,建议开启简化模式

写在最后:独立开发者的 vibe coding 感悟

作为一个连续创业者,我越来越确信:AI 不是替代开发者,而是让独立开发者有了对抗大厂的武器。

jit-viewer 的 CAD 解析模块,如果让我手写 C++ 几何算法,可能需要半年。但在 AI 辅助下,我花了两周就把 OpenCascade 移植到了 WebAssembly。剩下的时间,我可以专注在产品设计和开发者体验上。

这也是我开源这个项目的初衷——降低技术门槛,让更多人能做出专业的工具

如果你在做 PLG(产品驱动增长)的 SaaS 工具,或者有文件预览的需求,欢迎试试 jit-viewer。

遇到问题直接提 Issue,我会亲自回复(没错,目前 issue 响应速度还在 2 小时内 ~)。

github:github.com/jitOffice/j…

国内镜像:gitee.com/lowcode-chi…

git commit

# Git Commit Message 规范

## 1. 基本格式

```aiignore
<type>(<scope>): <subject>

<body>

<footer>
```

## 2. Type 类型

| 类型 | 描述 |
| --- | --- |
| feat | 新功能 (feature) |
| fix | 修复 bug |
| docs | 文档变更 |
| style | 代码格式变更(不影响代码运行) |
| refactor | 重构(既不是新增功能,也不是修复 bug 的代码变动) |
| test | 增加测试 |
| chore | 构建过程或辅助工具的变动 |
| perf | 性能优化 |
| ci | CI/CD 相关变更 |
| build | 构建系统或外部依赖变更 |
| revert | 撤销之前的 commit |

## 3. Scope 范围(可选)

表示 commit 影响的范围,如:

- `api`
- `ui`
- `auth`
- `database`
- `config`

## 4. Subject 主题

- 使用祈使句、现在时态
- 首字母小写
- 结尾不加句号
- 不超过 50 个字符

## 5. Body 正文(可选)

- 解释“是什么”和“为什么”,而不是“怎么做”
- 每行不超过 72 个字符
-`subject` 用空行分隔

## 6. Footer 页脚(可选)

- 记录 breaking changes
- 关闭 issues

## 7. 语言

- 尽量使用中文

## 8. 示例

### 简单示例

```text
feat: 添加用户登录功能
fix(auth): 修复密码验证逻辑错误
docs: 更新 API 文档
style: 格式化代码缩进
refactor(api): 重构用户服务接口
```

### 完整示例

```text
feat(shopping cart): 添加商品到购物车功能

用户现在可以通过点击“添加到购物车”按钮将商品添加到购物车。

这个功能包括:
- 商品数量选择
- 库存验证
- 价格计算

Closes #123
```

env Command in Linux: Show and Set Environment Variables

When you need to run a program with a different set of variables, test a script in a clean environment, or write a shebang line that works across systems, the env command is the right tool. It prints the current environment, sets or removes variables for a single command, and can even start a process with no inherited variables at all.

This guide covers how to use the env command with practical examples for everyday tasks.

env Syntax

txt
env [OPTIONS] [NAME=VALUE]... [COMMAND [ARGS]]

When called without arguments, env prints every environment variable in the current session, one per line. When followed by NAME=VALUE pairs and a command, it runs that command with the specified variables added or changed without affecting the current shell.

Print All Environment Variables

The simplest use of env is printing the full environment:

Terminal
env
output
SHELL=/bin/bash
USER=john
HOME=/home/john
LANG=en_US.UTF-8
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
TERM=xterm-256color
XDG_SESSION_TYPE=tty
EDITOR=vim

Each line is a KEY=value pair. This is identical to what printenv shows with no arguments. The difference between the two commands becomes clear when you start passing options: printenv is designed for inspecting variables, while env is designed for modifying the environment before launching a program.

Print Variables with NUL Separator

By default, env separates each variable with a newline character. If variable values contain newlines themselves, the output becomes ambiguous. The -0 (or --null) option ends each entry with a NUL byte instead:

Terminal
env -0 | tr '\0' '\n' | head -5

This is useful when piping environment data into tools that expect NUL-delimited input, such as xargs with its -0 flag.

Run a Command with Modified Variables

The most practical feature of env is launching a command with variables changed for that command only. Place NAME=VALUE pairs before the command:

Terminal
env LANG=C sort unsorted.txt

This runs sort with LANG set to C, which forces byte-order sorting regardless of the system locale. The current shell’s LANG value stays unchanged after the command finishes.

You can set multiple variables at once:

Terminal
env DB_HOST=localhost DB_PORT=5432 python3 app.py

The application sees DB_HOST and DB_PORT in its environment, but those variables do not persist in your shell session after the process exits.

Tip
You can achieve the same result with the shell’s built-in VAR=value command syntax (for example, LANG=C sort unsorted.txt). The env command is useful when you need additional options like -i or -u, or when you want to be explicit about environment manipulation in scripts.

Run a Command in a Clean Environment

The -i (or --ignore-environment) option clears the entire inherited environment before running the command. Only the variables you explicitly set on the command line will be present:

Terminal
env -i bash -c 'env'
output
PWD=/home/john
SHLVL=1
_=/usr/bin/env

The output shows almost nothing. The shell itself sets a few internal variables (PWD, SHLVL, _), but everything the parent shell normally passes along (PATH, HOME, LANG, USER) is gone.

This is useful for testing whether a script depends on variables it does not set itself. You can combine -i with explicit variables to create a minimal, controlled environment:

Terminal
env -i HOME=/home/john PATH=/usr/bin:/bin bash -c 'echo $HOME; echo $PATH'
output
/home/john
/usr/bin:/bin

A bare - (hyphen) works as a shorthand for -i:

Terminal
env - PATH=/usr/bin bash -c 'echo $PATH'

Unset a Variable for a Command

The -u (or --unset) option removes a specific variable from the environment before running the command:

Terminal
env -u EDITOR vim

This launches vim without the EDITOR variable in its environment. Other variables remain untouched. You can unset multiple variables by repeating -u:

Terminal
env -u LANG -u LC_ALL python3 script.py

The difference from -i is that -u is surgical: it removes only the named variables and leaves everything else in place.

Change Directory Before Running a Command

The -C (or --chdir) option changes the working directory before executing the command:

Terminal
env -C /var/log cat syslog | head -3

This is equivalent to running cd /var/log && cat syslog, but without affecting the current shell’s working directory. It is a convenient way to run a command in another directory from within a script or one-liner.

Info
The -C option requires GNU coreutils 8.28 or later. Check your version with env --version.

Using env in Shebangs

One of the most common uses of env is in shebang lines at the top of scripts:

sh
#!/usr/bin/env bash
echo "Hello from Bash"

When the kernel encounters #!/usr/bin/env bash, it runs /usr/bin/env with bash as its argument. env then searches the PATH for the bash executable and runs it. This is more portable than hardcoding #!/bin/bash, because bash is not always located in /bin on every system (for example, on FreeBSD it is typically at /usr/local/bin/bash).

The same pattern works for other interpreters:

sh
#!/usr/bin/env python3
sh
#!/usr/bin/env node

On systems with GNU coreutils 8.30 or later, you can pass arguments to the interpreter using the -S (split string) option:

sh
#!/usr/bin/env -S python3 -u

Without -S, the kernel treats python3 -u as a single argument. The -S option tells env to split the string into separate arguments before executing.

env Options

  • -i, --ignore-environment - Start with an empty environment
  • -u NAME, --unset=NAME - Remove NAME from the environment
  • -C DIR, --chdir=DIR - Change working directory to DIR before running the command
  • -0, --null - End each output line with a NUL byte instead of a newline
  • -S STRING, --split-string=STRING - Split STRING into separate arguments (useful in shebangs)
  • -v, --debug - Print verbose information for each processing step
  • --block-signal=SIG - Block delivery of the specified signal to the command
  • --default-signal=SIG - Reset signal handling to the default for the command
  • --ignore-signal=SIG - Set signal handling to ignore for the command

Quick Reference

For a printable quick reference, see the env cheatsheet .

Command Description
env Print all environment variables
env -0 Print variables with NUL separator
env VAR=value command Run a command with a modified variable
env -i command Run a command in a clean environment
env -i VAR=value command Run a command with only the specified variables
env -u VAR command Run a command with a variable removed
env -C /path command Run a command in a different directory
#!/usr/bin/env bash Portable shebang line
#!/usr/bin/env -S python3 -u Shebang with interpreter arguments

FAQ

What is the difference between env and printenv?
Both commands print environment variables when called without arguments. The difference is in their purpose: printenv is a read-only inspection tool that can print individual variables by name (printenv HOME). env is designed to modify the environment and run commands. Use printenv when you need to check a value, and env when you need to change the environment for a process.

What is the difference between env and export?
export is a shell built-in that adds a variable to the current shell’s environment permanently (until the session ends or you unset it). env sets variables only for the duration of a single command and does not affect the current shell. If you need a variable to persist for all subsequent commands in your session, use export. If you need a variable set for one command only, use env or the shell’s VAR=value command syntax.

When should I use env -i?
Use env -i when you want to verify that a script or program works without relying on inherited environment variables. It is also useful in security-sensitive contexts where you want to prevent a child process from seeing variables like AWS_SECRET_ACCESS_KEY or database credentials that exist in the parent shell.

Why use #!/usr/bin/env bash instead of #!/bin/bash?
The env-based shebang is more portable. On most Linux distributions, bash lives at /bin/bash, but on other Unix systems (FreeBSD, macOS with Homebrew, NixOS) it may be installed elsewhere. Using #!/usr/bin/env bash searches the PATH for the interpreter, so the script works regardless of where bash is installed.

Conclusion

The env command gives you fine-grained control over the environment a process sees without touching your current shell session. For a broader look at how environment and shell variables work in Linux, including persistent configuration and the PATH variable, see the guide on how to set and list environment variables .

模拟

解法:模拟

数据范围较小,模拟即可。复杂度 $\mathcal{O}(nq)$。

参考代码(c++)

class Solution {
public:
    int xorAfterQueries(vector<int>& nums, vector<vector<int>>& queries) {
        int n = nums.size();
        long long A[n];
        for (int i = 0; i < n; i++) A[i] = nums[i];

        const int MOD = 1e9 + 7;
        for (auto &qry : queries) for (int i = qry[0]; i <= qry[1]; i += qry[2]) A[i] = A[i] * qry[3] % MOD;
        
        long long ans = 0;
        for (int i = 0; i < n; i++) ans ^= A[i];
        return ans;
    }
};

从一个截图函数到一个 npm 包——pdf-snapshot 的诞生记

一个 PDF 文档页面截图工具的渐进式演化之路

背景

事情要从一个内部知识库项目说起。

产品同学提了一个需求:知识库里存了大量 PDF 文档,在预览列表页希望能展示文档的缩略图,用户点击缩略图后再打开完整的 PDF 文件。听起来很简单对吧?但问题是——库里只有 PDF 文件,没有缩略图。

于是摆在我面前的问题就很清晰了:如何从 PDF 文件中生成缩略图

一番调研后发现,Node.js 生态里虽然有一些 PDF 相关的库,但要么功能太重(整个 PDF 编辑器级别)、要么只能跑在浏览器端、要么 API 设计不太友好。最后决定基于 pdf-parse 封装一个轻量级的截图工具。

本以为写个工具函数就完事了,没想到这个小需求最终演变成了一个完整的 npm 包。下面就来聊聊这个渐进式的演化过程。

渐进式方案演进

阶段一:一个 utils 函数

最初的需求很简单——给知识库用,能生成缩略图就行。

于是我在项目里写了个 utils/pdfSnapshot.ts,核心逻辑大概长这样:

import { PDFParse } from 'pdf-parse';

export async function snapshotPdf(filePath: string, pages: number[]) {
  const pdfBuffer = await readFile(filePath);
  const pdfParser = new PDFParse({ data: pdfBuffer });
  
  const result = await pdfParser.getScreenshot({
    partial: pages,
    scale: 1.5,
    imageBuffer: true,
  });
  
  return result.pages.map(page => ({
    page: page.pageNumber,
    data: Buffer.from(page.data),
  }));
}

嗯,几十行代码,需求搞定,下班!

阶段二:抽成独立模块

好景不长,没过多久,隔壁组的同事找过来了:

"嘿,听说你写了个 PDF 截图的工具?我们这边有个文档预处理服务也需要这个功能,能不能给我们用用?"

于是我把这个函数从业务项目里抽出来,放到了一个独立的内部模块里。

但抽离的过程中发现了一些问题:

  1. 内存泄漏风险pdfjs-distpdf-parse 的底层依赖)会在内存里缓存解析结果,大量 PDF 处理后内存蹭蹭往上涨
  2. 缺少取消机制:处理几百页的大文件时,用户等不及想取消,但没有中断的能力
  3. 输入格式单一:只支持文件路径,不支持 Buffer 和流式输入

既然要给其他模块用了,这些问题就得解决。于是开始了第一次重构:

  • 引入子进程隔离,PDF 渲染跑在独立进程里,进程退出后内存自动释放
  • 支持 AbortController 取消操作
  • 支持文件路径 / Buffer / ReadableStream 三种输入格式

阶段三:发布为 npm 包

又过了一段时间,其他团队的同事也找过来了:

"你们那个 PDF 截图工具挺好用的,我们想在另一个项目里用,能不能发个 npm 包?" "对了,我们有个批量处理的场景,能不能加个进度回调?" "还有,我们运维同学想在脚本里用,能不能支持命令行?"

好家伙,需求越来越多了。

既然要发 npm 包,那就得认真对待了。于是有了这次比较彻底的重构:

  • 完善的 TypeScript 类型定义
  • 进度回调机制(onProgress
  • CLI 工具支持,方便脚本调用和 AI Agent 集成
  • 多种输出格式:Buffer / Base64 / 文件路径
  • 超时控制,避免子进程卡死

最终,这个工具从一个几十行的函数,演变成了一个结构完整的 npm 包——@guangmingz/pdf-snapshot

设计思路与实现框架

聊完演化过程,来深入剖析一下 pdf-snapshot 的设计思路。

核心设计原则

在设计这个工具时,我遵循了几个核心原则:

  1. 主进程零污染:PDF 渲染是内存大户,不能污染主进程
  2. 输入输出灵活:支持多种输入格式和输出格式,适应不同场景
  3. 可控性强:支持取消、超时、进度回调
  4. API 简洁:一个函数搞定,不需要复杂的初始化流程

模块架构

整个项目的目录结构如下:

src/
├── core/
│   ├── snapshot.ts      # 核心截图函数(主进程)
│   ├── pdf-info.ts      # 获取 PDF 信息
│   └── worker.ts        # 子进程 Worker(实际渲染)
├── utils/
│   ├── input-normalizer.ts   # 输入归一化
│   ├── page-resolver.ts      # 页码解析
│   ├── output-formatter.ts   # 输出格式化
│   └── worker-manager.ts     # 子进程管理
├── cli/
│   └── index.ts         # 命令行入口
├── types.ts             # 类型定义
├── errors.ts            # 错误类
├── constants.ts         # 常量
└── index.ts             # 导出入口

可以看到,模块划分还是比较清晰的:

  • core:核心逻辑,包括主进程入口和子进程 Worker
  • utils:工具函数,处理输入输出和子进程管理
  • cli:命令行接口

子进程隔离:内存泄漏的终极解法

这是整个设计中最关键的一环。

为什么要用子进程?因为 pdfjs-dist 在解析 PDF 时会在 V8 堆上分配大量内存,即使调用了 destroy() 方法,也很难完全释放。如果在主进程里处理大量 PDF,内存会越积越多,最终 OOM。

解法很简单也很粗暴——用子进程。子进程退出后,操作系统会自动回收它占用的所有内存,干净利落。

整个流程如下:

┌─────────────────────────────────────────────────────────────────┐
│                        主进程 (Main Process)                     │
├─────────────────────────────────────────────────────────────────┤
│  1. 接收输入 (文件路径 / Buffer / Stream)                         │
│  2. 归一化为临时文件路径                                          │
│  3. 解析页码参数                                                  │
│  4. Fork 子进程,传递任务参数                                      │
│  5. 等待子进程完成,接收结果文件路径                                │
│  6. 根据 output 参数格式化输出                                    │
│  7. 清理临时文件                                                  │
└───────────────────────────┬─────────────────────────────────────┘
                            │ IPC 通信(传递路径,不传 Buffer)
                            ▼
┌─────────────────────────────────────────────────────────────────┐
│                        子进程 (Worker Process)                   │
├─────────────────────────────────────────────────────────────────┤
│  1. 读取 PDF 文件                                                │
│  2. 调用 pdf-parse 渲染指定页面                                   │
│  3. 将截图写入临时目录                                            │
│  4. 返回文件路径 + 元数据                                         │
│  5. 退出进程(内存自动释放)                                       │
└─────────────────────────────────────────────────────────────────┘

这里有个细节值得一提:IPC 通信只传文件路径,不传 Buffer

为什么?因为 IPC 传输大数据很慢,一张截图可能有几 MB,如果通过 IPC 传 Buffer,性能会很差。所以我们让子进程把截图写到临时目录,IPC 只传路径和元数据(宽高、大小),主进程再按需读取。

子进程的核心代码:

process.on('message', async (msg: WorkerRequest) => {
  const { pdfPath, pages, scale, outputDir } = msg;
  let pdfParser: PDFParse | null = null;

  try {
    const pdfBuffer = await readFile(pdfPath);
    pdfParser = new PDFParse({ data: pdfBuffer });

    // 一次性传入所有页码,避免重复解析 PDF
    const screenshotResult = await pdfParser.getScreenshot({
      partial: pages,
      scale,
      imageBuffer: true,
    });

    const results: PageInfo[] = [];
    for (const page of screenshotResult.pages) {
      const filePath = join(outputDir, `page-${page.pageNumber}.png`);
      await writeFile(filePath, Buffer.from(page.data));
      results.push({ pageNumber: page.pageNumber, filePath, width: page.width, height: page.height });
    }

    process.send!({ success: true, pages: results });
  } catch (error) {
    process.send!({ success: false, error: error.message });
  } finally {
    await pdfParser?.destroy();
    process.exit(0);  // 退出进程,内存自动释放
  }
});

输入归一化:统一处理多种输入格式

为了支持文件路径、Buffer、ReadableStream 三种输入格式,我设计了一个「输入归一化」层:

export async function normalizeInput(input: PdfInput): Promise<{ path: string; isTempFile: boolean }> {
  // 文件路径:直接使用
  if (typeof input === 'string') {
    return { path: input, isTempFile: false };
  }
  
  // Buffer / Stream:写入临时文件
  const tempPath = join(tmpdir(), `pdf-${randomUUID()}.pdf`);
  
  if (Buffer.isBuffer(input)) {
    await writeFile(tempPath, input);
  } else {
    // Stream
    const chunks: Buffer[] = [];
    for await (const chunk of input) {
      chunks.push(chunk);
    }
    await writeFile(tempPath, Buffer.concat(chunks));
  }
  
  return { path: tempPath, isTempFile: true };
}

不管用户传什么格式,最终都归一化为文件路径,后续逻辑只需要处理文件路径即可。这种「归一化」的设计模式在很多场景下都很实用。

取消与超时:让操作可控

处理大文件时,用户可能等不及想取消;或者子进程卡死了需要超时兜底。这两个能力是生产环境必备的。

取消能力基于标准的 AbortController

const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);  // 5 秒后取消

try {
  await snapshotPdf('./large.pdf', { signal: controller.signal });
} catch (error) {
  if (error instanceof SnapshotAbortedError) {
    console.log('操作被取消');
  }
}

超时控制在子进程管理器里实现:

const timer = setTimeout(() => {
  child.kill('SIGKILL');  // 强制杀死子进程
  reject(new SnapshotTimeoutError(timeout));
}, timeout);

进度回调:让等待不再焦虑

批量处理时,用户需要知道当前进度。虽然子进程是一次性处理所有页面的,但我们至少可以在「开始」和「完成」两个时机通知用户:

await snapshotPdf('./document.pdf', {
  pageRange: [1, 100],
  onProgress: (progress) => {
    // progress.stage: 'preparing' | 'completed'
    // progress.percent: 0 | 100
    console.log(`[${progress.stage}] ${progress.percent}%`);
  },
});

为什么不支持逐页进度?因为 pdf-parsegetScreenshot 是一次性处理所有页面的,中间没有回调钩子。如果要实现逐页进度,需要改成逐页调用,但这样会有性能问题(每次调用都要重新解析 PDF)。权衡之下,选择了「阶段进度」的方案。

CLI 工具:让 AI 也能用

最后聊聊 CLI 工具。

为什么要做 CLI?除了方便运维同学写脚本,还有一个重要原因——方便 AI Agent 调用

现在各种 AI 编程助手越来越流行,它们通常通过命令行来调用工具。如果你的工具只有 API 没有 CLI,AI 就很难直接使用。

pdf-snapshot 的 CLI 使用起来很简单:

# 截取第 1-10 页
pdf-snapshot -r 1-10 -o ./output document.pdf

# 截取指定页
pdf-snapshot -p 1,5,10 document.pdf

# 从标准输入读取(支持管道)
cat document.pdf | pdf-snapshot -o ./output -r 1-5 -

# 仅查看 PDF 信息
pdf-snapshot --info document.pdf

CLI 的实现基于 commander,核心是把命令行参数映射到 snapshotPdf 的 options:

program
  .argument('<input>', 'PDF 文件路径')
  .option('-o, --output <dir>', '输出目录', './pdf-screenshots')
  .option('-p, --pages <pages>', '离散页码')
  .option('-r, --range <range>', '页码范围')
  .option('-s, --scale <number>', '缩放比例', '1.5')
  .action(async (input, opts) => {
    const results = await snapshotPdf(input, {
      output: 'file',
      outputDir: opts.output,
      pageRange: parseRange(opts.range),
      pages: parsePages(opts.pages),
      scale: parseFloat(opts.scale),
    });
    console.log(`✅ 完成!已保存 ${results.length} 张截图`);
  });

还贴心地加了进度条:

⏳ 正在截图...
  [████████████████████████████████████████] 100% | 50/50 页

✅ 完成!已保存 50 张截图到 ./pdf-screenshots

总结

回顾 pdf-snapshot 的演化过程:

  1. 阶段一:一个 utils 函数,解决单点需求
  2. 阶段二:抽成独立模块,解决内存泄漏、支持取消和多种输入格式
  3. 阶段三:发布 npm 包,增加进度回调、CLI 工具、完善类型定义

这个过程其实挺有代表性的。很多时候我们写的工具函数,一开始只是为了解决眼前的问题,但随着需求的增加和使用场景的扩展,它会逐渐演化成一个更通用、更健壮的模块。

关键是要在演化过程中保持代码的可维护性可扩展性。子进程隔离、输入归一化、取消超时机制……这些设计不是一开始就有的,而是在实际使用中逐步发现问题、解决问题后沉淀下来的。

最后,如果你也有 PDF 截图的需求,欢迎试试 pdf-snapshot!

GitHub 地址:pdf-snapshot

有问题欢迎提 Issue,有改进想法欢迎 PR!

字节/腾讯内部流出!Claude Code 2026王炸玩法!效率暴涨10倍

还在把 Claude 当“高级代码抄写员”?
让它写个函数、改个 bug,一问一答像聊天?

大错特错! 2026 年的 Claude Code 早已进化成自主 AI 开发智能体——
它能自己读项目、自己规划、自己写代码、自己跑测试、自己修 bug,甚至直接操控你的电脑完成全流程开发!

真实案例
字节某团队用 Claude Code 的 Subagents(多智能体) 功能,30 分钟交付一个带用户认证的完整博客系统;
腾讯某工程师靠 Computer Use(电脑直控),让 AI 自动部署项目、复现并修复 UI Bug,全程无需动手。

今天这篇,把 Claude Code 2026 最强玩法、最新功能、隐藏技巧、实战避坑 一次性讲透,看完直接从新手变大神!


一、先看效果:以前累死,现在躺赢

场景 旧方式 Claude Code 新方式
开新对话 重复解释项目架构、规范 Kairos 长期记忆自动加载上下文
部署项目 手动敲命令、点 Vercel Computer Use 自动操作 GUI 完成
复杂开发 一人单干,耗时一天 Subagents 派出 AI 团队并行开发
关机后 任务中断 /schedule 云端继续跑

核心价值
从“人写代码” → “人定目标,AI 自主完成全流程”


二、2026 三大王炸功能(官方 3 月刚上线)

1. Computer Use:AI 直接操控你的 macOS 电脑

这是 AI 编程的革命性突破!

Claude 不再局限于代码文本,而是像人一样操作系统

  • ✅ 自动打开终端、执行 npm install
  • ✅ 截图识别报错弹窗、日志
  • ✅ 点击按钮、填写表单、操作 GUI 工具
  • ✅ 完整 Debug 循环:运行→报错→修改→再运行

实战场景
“帮我部署这个 React 项目到 Vercel”
→ Claude 自动登录 Vercel → 构建 → 部署 → 返回结果
全程你只需要看着!

注意:目前仅支持 macOS + Pro/Max 订阅,需授权安全目录。


2. Subagents:召唤你的 AI 开发团队

一个 Claude 不够用?直接派多个分身并行工作!

  • 前端组:开发页面、写样式
  • 后端组:设计 API、写逻辑
  • 测试组:编写用例、跑测试
  • 安全组:审查漏洞、提建议

效率提升:日常开发 3-5 倍,复杂项目 10 倍+


3. Kairos 长期记忆:AI 永远记住你的项目

解决“金鱼记忆”痛点——跨会话永久记忆 + 自动整理

启用方式
在项目根目录创建 CLAUDE.md,Claude 自动读取并永久记忆。

# 项目规范(示例)
- 技术栈:React 18 + TypeScript
- 代码规范:ESLint + Prettier
- 命名:小驼峰,组件名大写开头
- 禁止:直接修改 src/legacy 目录

下次打开,无需重复解释任何信息


三、硬核对比:为什么 Claude Code 是 2026 最强?

SWE-bench 权威数据(复杂任务通过率)

  • Claude Opus 4.680.8%(行业第一)
  • GPT-5.2:80.0%
  • Cursor(GPT-5 后端):61.3%

Token 效率:省 5.5 倍成本

同样复杂任务:

  • Claude Code:33,000 tokens,零错误
  • Cursor:188,000 tokens,多次报错

适用场景对比

工具 最佳场景 劣势
Claude Code 大型项目、全流程开发、跨文件重构 界面极简,学习曲线略陡
Cursor 前端快速开发、实时补全 复杂项目理解弱
Copilot 单行补全、IDE 集成 自主能力差

结论
做正经开发,选 Claude Code;简单业务,选 Cursor。


四、90% 人不知道的隐藏技巧

1. 7 个必学斜杠命令

/auto          # 全自动模式,AI 自主决策
/debug         # 查看会话状态、工具调用
/skill list    # 查看所有可用技能
/schedule      # 云端定时任务(关机后继续跑)
/context clear # 清理上下文,防“变笨”
/llm           # 切换模型(Sonnet/Opus)

2. 提示词黄金公式

角色+目标+规范+示例+约束

【角色】资深全栈,精通 React+TS
【目标】开发登录页,含表单验证
【规范】Tailwind CSS,小驼峰命名
【示例】参考注册页风格
【约束】响应式,支持移动端

3. Computer Use 安全玩法

  • 开启 Safe Mode:敏感操作需二次确认
  • /allow dir ./my-project 限定工作目录
  • 重要操作前手动审查 AI 计划

五、新手 3 步速成指南(闭眼操作)

第 1 步:安装+配置

npm install -g @anthropic/claude-code
claude login
claude config set model opus-4.6
claude config set computer_use true

第 2 步:必装 Skills 包

# 添加市场
claude market add official
claude market add https://github.com/affaan-m/everything-claude

# 安装神级包
claude install everything-claude   # 60+ 全能技能
claude install kairos-mem          # 长期记忆
claude install computer-use-pro    # 电脑直控增强

第 3 步:最佳工作流

  1. 项目根目录创建 CLAUDE.md
  2. 启动 Claude:claude
  3. 启用技能:/skill use everything-claude
  4. 下达目标:“帮我分析项目,规划开发计划”
  5. 确认后执行:/auto

六、避坑指南:7 个常见错误

错误 正确做法
把 Claude 当聊天机器人 给完整角色、目标、规范
不设权限,放任 AI 操作 严格限定目录,开安全模式
上下文爆炸不清理 定期 /context clear
所有任务用最贵模型 简单用 Sonnet,复杂用 Opus
忽略 Computer Use 安全 仅授权工作目录,手动审查

七、2026 选型指南:谁最适合用?

必选 Claude Code,如果你是:

  • 后端/全栈,做复杂业务系统
  • 架构师,负责大型项目重构
  • 技术团队,追求效率最大化
  • 独立开发者,想一个人顶一个团队

考虑其他工具,如果你是:

  • 纯前端,只做快速页面(选 Cursor)
  • 学生/新手,追求简单易用(选 Cursor)
  • 仅需单行补全(选 Copilot)

结语:AI 编程已进入 2.0 时代

2026 年,AI 编程不再是“辅助”,而是“主力”
Claude Code 代表的自主智能体开发模式,正在彻底重构软件开发流程。

今天学会 Claude Code,不是掌握一个工具,而是抢占 AI 时代的开发效率制高点

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

大厂都在偷偷用的 Cursor Rules 封装!告别重复 Prompt,AI 编程效率翻倍

还在每天教 AI “失忆实习生”?
每次开新对话都要重讲项目架构、代码规范、命名风格……
而用这套 Rules 配置,Cursor 直接变成“懂你心思的老搭档”——跨会话记忆、自动守规范、团队统一标准,字节/腾讯内部已全面落地

如果你受够了:

  • 写 200 行 Prompt 只为让 AI 记住项目
  • 团队成员各自为战,AI 输出风格五花八门
  • 代码不规范、漏 error、命名混乱,返工到崩溃

那么,这篇经过 GitHub 5w+ 星验证的 Rules 完全指南,就是为你写的——
不用背命令,装完就忘,自然语言写需求,AI 自动按规矩干活


一、先看效果:以前累死,现在躺赢

场景 旧方式 Rules 新方式
开新对话 粘贴 300 行项目背景 自动加载上下文
写 Go 代码 手动提醒“加注释、小驼峰” 自动遵守官方规范
团队协作 每人一套 Prompt,风格乱飞 共享规则包,输出统一
代码审查 人工查漏 自动 lint + 安全扫描

真实收益

  • 每天节省 1~2 小时重复解释
  • 代码返工率下降 70%
  • 新人上手 AI 编程速度提升 3 倍

二、Rules 是什么?

传统 Prompt = 便利贴
贴一次用一次,新开对话就丢。

Rules = 永久工作手册
写一次,全局生效,团队共享,Git 可管。

核心价值
让 Cursor 从“聪明但没规矩” → “专业且守纪律”


三、必装三大神级 Rules(附一键安装)

1. everything-cursor(闭眼装)

GitHub ⭐ 52k+,黑客松冠军,60+ 规则覆盖全开发流程

支持:Go / Java / Python / React / Rust / Next.js
能力:自动格式化、TDD、部署提示、安全检查
命令:/fmt /check /tdd /mem

/rule market add https://github.com/affaan-m/everything-cursor
/rule install everything-cursor@everything-cursor

2. cursor-mem(解决金鱼记忆)

GitHub ⭐ 23k+,跨会话永久记住你的项目

再也不用重复解释:

  • 业务逻辑
  • 技术架构
  • 特殊约束
/rule market add https://github.com/thedotmack/cursor-mem
/rule install cursor-mem@cursor-mem

3. super-rules(工程化最强)

GitHub ⭐ 28k+,让 AI 像资深工程师一样思考

TDD 测试驱动
结构化调试
代码审查分级(Minor/Normal/Critical)

/rule market add https://github.com/obra/cursor-super-rules.git
/rule install super-rules@cursor-super-rules

避坑:装了 everything-cursor 就别装 plan-with-code,指令冲突!


四、Rules 怎么工作?完全无感!

99% 的规则安装后自动生效,无需手动触发

Cursor 会智能判断:

  • 你打开的是 Go 文件 → 自动激活 go-standard
  • 你在写前端 → 自动加载 react-rules
  • 你问架构问题 → 启用 arch-rules

你只需要用自然语言描述需求,剩下的交给 Rules!


五、两种安装方式(推荐第一种)

方式一:市场导入(推荐)

# 添加市场(只需一次)
/rule market add https://github.com/getcursor/cursor-rules-official

# 安装规则包
/rule install go-pack@cursor-rules-official

方式二:本地手动(私有/离线场景)

# macOS/Linux
cp -r my-rule ~/.cursor/rules/

六、新手安装优先级(直接抄作业)

优先级 规则包 理由
第一 everything-cursor 全能王,闭眼装
第二 cursor-mem 解决记忆痛点
第三 super-rules 提升工程质量
4 code-fmt(官方) 自动格式化
5 rule-maker(官方) 自定义规则

七、大厂为什么都在用?

  • 字节:用 cursor-mem 统一 200+ 微服务上下文
  • 腾讯super-rules 接入 CI,代码审查自动化
  • 阿里云:基于 rule-maker 生成内部规范包

Rules 不是玩具,而是 AI 编程的“基础设施”


结语:AI 编程,进入“有纪律”时代

当你不再为重复解释焦头烂额,
当你团队的 AI 输出风格高度统一,
你就知道——Rules,是每个专业开发者必须掌握的生产力核弹

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

RainbowKit快速集成多链钱包连接:从“连不上”到丝滑切换的踩坑实录

背景

上个月,我接手了一个新的DeFi聚合器项目的前端重构。这个项目的老前端用的是web3modal + 自定义的链配置,代码已经有点“祖传”的味道了,每次加一条新链都得手动改好几个配置文件,测试起来也麻烦。产品经理提了新需求:要快速支持Arbitrum、Optimism、Polygon等七八条EVM链,并且用户切换链的体验要足够丝滑。

我评估了一下,自己从头用wagmi去搭一套连接组件,虽然灵活,但时间成本太高,光是设计UI和处理好各种边缘情况(比如用户钱包里没添加该链)就得花上好几天。这时候,我想到了RainbowKit——一个基于wagmi构建的、开箱即用的钱包连接套件,UI漂亮,文档说支持多链配置。心想,用它应该能快速搞定,把时间省下来去处理更复杂的业务逻辑。于是,我的“快速集成”之旅开始了,没想到,快是快了,坑也是一个没少踩。

问题分析

一开始,我的思路很简单:照着RainbowKit官方文档的“Getting Started”部分,安装依赖,用getDefaultConfig搞个配置,把RainbowKitProviderWagmiProvider一套,最后把ConnectButton一扔,不就完事了吗?我最初的核心配置代码是这样的:

import { getDefaultConfig, RainbowKitProvider } from '@rainbow-me/rainbowkit';
import { WagmiProvider } from 'wagmi';
import { mainnet, polygon, optimism, arbitrum } from 'wagmi/chains';

const config = getDefaultConfig({
  appName: 'My DeFi App',
  projectId: 'YOUR_PROJECT_ID', // 从WalletConnect Cloud拿的
  chains: [mainnet, polygon, optimism, arbitrum],
});

function App() {
  return (
    <WagmiProvider config={config}>
      <RainbowKitProvider>
        <ConnectButton />
      </RainbowKitProvider>
    </WagmiProvider>
  );
}

跑起来一看,连接MetaMask确实没问题,主网也能用。但当我尝试切换到Polygon时,问题来了。点击切换,钱包弹窗倒是出来了,但要么是提示“未添加网络”,要么是切换后前端的链ID显示还是1(以太坊主网)。控制台里时不时飘过一些关于RPC URL的警告。

我意识到,问题出在链的配置上。getDefaultConfig和从wagmi/chains导入的链定义,其RPC端点可能是公共的,有速率限制或不稳定。而且,对于用户钱包里没有的链,RainbowKit的默认行为可能和我想的不一样。我需要更精细地控制每条链的配置,特别是RPC,并且要处理好钱包添加网络的流程。这不是一个“五分钟集成”就能完事的问题,需要深入配置。

核心实现

第一步:自定义链配置,搞定稳定的RPC

公共RPC是第一个坑。尤其是在测试网或者Polygon这类链上,公共RPC经常不稳定,导致交易发送失败或者读取数据超时。我的解决方案是使用项目自己的Infura或Alchemy节点,如果没有,也可以选择一些更可靠的公共服务商如publicnode.com

这里有个关键点:RainbowKit(或者说底层的wagmi v2)的链配置对象,需要包含rpcUrls字段,并且要正确区分defaultpublic。我一开始没注意,直接覆盖错了,导致钱包连接内部调用还是走了不稳定的节点。

// chains/customChains.ts
import { Chain } from 'wagmi/chains';

// 自定义Polygon链配置
export const customPolygon: Chain = {
  id: 137,
  name: 'Polygon',
  network: 'matic',
  nativeCurrency: {
    name: 'MATIC',
    symbol: 'MATIC',
    decimals: 18,
  },
  rpcUrls: {
    // default 和 public 最好都配置,default用于钱包写操作,public用于前端读操作
    default: {
      http: ['https://polygon-mainnet.g.alchemy.com/v2/YOUR_API_KEY'], // 你的Alchemy或Infura URL
    },
    public: {
      http: ['https://polygon-rpc.com'], // 一个可靠的公共RPC
    },
  },
  blockExplorers: {
    default: { name: 'PolygonScan', url: 'https://polygonscan.com' },
  },
  contracts: {
    multicall3: {
      address: '0xca11bde05977b3631167028862be2a173976ca11',
      blockCreated: 25770160,
    },
  },
};

// 同理,配置其他链,比如Arbitrum
export const customArbitrum: Chain = {
  id: 42161,
  name: 'Arbitrum One',
  network: 'arbitrum',
  nativeCurrency: {
    name: 'Ether',
    symbol: 'ETH',
    decimals: 18,
  },
  rpcUrls: {
    default: {
      http: ['https://arb1.arbitrum.io/rpc'],
    },
    public: {
      http: ['https://arb1.arbitrum.io/rpc'],
    },
  },
  blockExplorers: {
    default: { name: 'Arbiscan', url: 'https://arbiscan.io' },
  },
  contracts: {
    multicall3: {
      address: '0xca11bde05977b3631167028862be2a173976ca11',
      blockCreated: 7654707,
    },
  },
};

第二步:配置RainbowKit与Wagmi

有了自定义的链配置,接下来就是正确创建wagmiconfig对象。这里我放弃了getDefaultConfig这个快捷方法,因为它对配置的控制不够细。我改用createConfig手动配置,这样可以明确指定传输层(transport)和连接器。

注意这个细节wagmicreateConfig需要为每条链单独创建transport。我在这里又踩了个坑,试图用一个transport给所有链用,结果只有主网能正常工作。

// config/wagmiConfig.ts
import { http, createConfig } from 'wagmi';
import { mainnet } from 'wagmi/chains';
import { customPolygon, customArbitrum, customOptimism } from '../chains/customChains';
import { getDefaultWallets } from '@rainbow-me/rainbowkit';

// 定义项目支持的链数组
const projectChains = [mainnet, customPolygon, customArbitrum, customOptimism] as const;

// 1. 设置钱包连接器 (RainbowKit提供)
const { connectors } = getDefaultWallets({
  appName: 'My DeFi Aggregator',
  projectId: 'YOUR_WALLETCONNECT_PROJECT_ID', // 必须去WalletConnect Cloud创建项目获取
  chains: projectChains,
});

// 2. 创建Wagmi配置
export const config = createConfig({
  chains: projectChains,
  transports: {
    // 为每条链分别创建transport,使用我们自定义的RPC
    [mainnet.id]: http(mainnet.rpcUrls.default.http[0]), // 也可以用你的主网节点
    [customPolygon.id]: http(customPolygon.rpcUrls.default.http[0]),
    [customArbitrum.id]: http(customArbitrum.rpcUrls.default.http[0]),
    [customOptimism.id]: http(customOptimism.rpcUrls.default.http[0]),
  },
  connectors, // 注入RainbowKit生成的连接器
  ssr: false, // 如果不是Next.js等SSR框架,可以设为false
});

第三步:集成到React应用中并实现链切换

配置完成后,在应用根组件中注入Provider就相对简单了。但为了让用户能方便地切换链,我不仅使用了ConnectButton(它自带切换网络的下拉菜单),还在应用内部关键位置(比如资产面板顶部)添加了一个手动的链切换器,使用useSwitchChain这个hook。

这里有个用户体验上的坑:如果用户的钱包里没有添加你指定的链,直接调用switchChain会失败。RainbowKit的ConnectButton下拉菜单会自动处理这个情况(触发钱包添加网络),但自己写的切换器需要手动处理。我的做法是捕获错误,然后调用addChain

// components/ChainSwitcher.tsx
import { useChainId, useSwitchChain, useChains } from 'wagmi';
import { useCallback } from 'react';

export function ChainSwitcher() {
  const currentChainId = useChainId();
  const { switchChain } = useSwitchChain();
  const supportedChains = useChains();

  const handleSwitch = useCallback(async (targetChainId: number) => {
    if (targetChainId === currentChainId) return;
    
    try {
      await switchChain({ chainId: targetChainId });
    } catch (error: any) {
      // 错误码 4902 是钱包(如MetaMask)提示用户添加网络的标准错误
      if (error?.code === 4902) {
        // 在实际项目中,这里应该弹出一个更友好的提示,引导用户去ConnectButton那里切换,或者手动触发addChain。
        // 因为addChain API需要完整的链信息,直接从supportedChains里找。
        const targetChain = supportedChains.find(c => c.id === targetChainId);
        if (targetChain) {
          console.warn(`请手动在钱包中添加 ${targetChain.name} 网络,或使用右上角的连接按钮进行切换。`);
          // 可以在这里调用 window.ethereum.request({ method: 'wallet_addEthereumChain', params: [targetChainInfo] })
        }
      }
      console.error('切换链失败:', error);
    }
  }, [currentChainId, switchChain, supportedChains]);

  return (
    <div className="chain-switcher">
      <span>当前网络: </span>
      <select 
        value={currentChainId} 
        onChange={(e) => handleSwitch(Number(e.target.value))}
      >
        {supportedChains.map((chain) => (
          <option key={chain.id} value={chain.id}>
            {chain.name}
          </option>
        ))}
      </select>
    </div>
  );
}

完整代码示例

下面是一个简化但可运行的应用根组件示例,整合了上述所有配置:

// App.tsx
import { WagmiProvider } from 'wagmi';
import { RainbowKitProvider, darkTheme, ConnectButton } from '@rainbow-me/rainbowkit';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { config } from './config/wagmiConfig';
import { ChainSwitcher } from './components/ChainSwitcher';
import '@rainbow-me/rainbowkit/styles.css'; // 不要忘记引入样式!

// 为Wagmi的缓存创建QueryClient
const queryClient = new QueryClient();

function App() {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider 
          theme={darkTheme()} // 可以自定义主题
          coolMode // 开启酷炫的按钮效果
          locale="en-US" // 设置语言
        >
          <div className="app">
            <header>
              <h1>我的DeFi聚合器</h1>
              <div className="wallet-section">
                <ConnectButton 
                  accountStatus="full" // 显示完整地址
                  chainStatus="icon" // 只显示链图标不显示名称
                  showBalance={false}
                />
              </div>
            </header>
            <main>
              <div className="network-panel">
                <ChainSwitcher />
              </div>
              {/* 你的其他业务组件 */}
              <div>业务内容区域...</div>
            </main>
          </div>
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}

export default App;

踩坑记录

  1. projectId无效或缺失导致的静默失败:最开始我没仔细看文档,随便写了个字符串当projectId。结果钱包连接(尤其是WalletConnect)时,移动端扫码后一直连接不上,前端也没明显报错。解决方法:必须去WalletConnect Cloud创建项目,获取真实的projectId

  2. 链切换后,前端状态不同步:点击切换链,钱包成功了,但应用里useChainId()返回的还是旧的链ID。排查发现:这是因为我在不同的地方用了不同的wagmi配置实例,或者Provider包裹层级有问题。解决方法:确保整个应用只用一个config,且WagmiProvider包裹了所有用到wagmi hook的组件。

  3. 自定义链的图标不显示:RainbowKit为一些主流链内置了图标,但自定义链或一些较新的链(比如Base)可能没有。解决方法:可以通过RainbowKitProviderchainImages属性来注入自定义链图标,是一个{ [chainId: number]: string }的映射,值为图片URL。

  4. SSR(Next.js)下的水合错误:在Next.js项目里,因为服务端和客户端初始状态可能不一致(比如连接的钱包信息),会导致水合错误。解决方法:RainbowKit提供了SSRProvider组件来配合Next.js的App Router使用。同时,将wagmi配置中的ssr设为true,并确保连接状态相关的UI在客户端渲染后再显示(用useEffectuseState控制)。

小结

这次集成让我体会到,RainbowKit确实能极大加速Web3应用钱包连接部分的开发,但它不是“无脑”配置就能应对所有生产环境需求的。核心收获是:多链支持的关键在于稳定且可控制的RPC配置,以及对“用户钱包可能未添加链”这一情况的妥善处理。 下一步,可以继续深挖RainbowKit的主题定制、与Zustand/Redux的状态集成,以及如何优雅地处理连接断开和重连的逻辑。

AI 全流程解析(LLM / Token / Context / RAG / Prompt / Tool / Skill / Agent)

前言

AI圈子里每天都在冒新名词:LLM、Token、Context、Prompt……这些词你可能都听说过。但是,你真的能准确说出其中每一个概念的确切含义吗?

这篇文章不整那些虚头巴脑的商业概念,我们从最底层的工程视角出发,一个一个把这些概念拆开、揉碎讲清楚。相信读完这篇文章,你对AI的理解绝对会上升一个台阶。

image.png


一、LLM(大语言模型)

1.1 什么是LLM?

LLM全称是Large Language Model,翻译成中文就是大语言模型,简称大模型

基本上现在所有的大模型都是基于Transformer这套架构训练出来的。这个架构最早由Google团队在2017年提出,对应的论文名是《Attention is All You Need》。

极具戏剧性的是,虽然Google发明了火种,但真正把它点燃并且引爆全世界的却是OpenAI。

1.2 大模型的发展历程

  • 2020年:GPT-3发布,已经具备初步实用价值
  • 2022年底:GPT-3.5横空出世,真正让大模型进入大众可用阶段
  • 2023年3月:GPT-4发布,把AI的能力天花板拉到了新高度
  • 至今:Claude等优秀后起之秀在各领域与OpenAI同台竞技

GPT系列是今天AI浪潮的绝对鼻祖,时至今日,GPT家族依然非常强大,GPT-4仍是业界标杆之一。

1.3 大模型的工作原理

大模型到底是怎么工作的?答案非常简单朴素:它本质上就是一个文字接龙游戏

虽然本质是"预测下一个Token",但通过大规模训练,这种能力涌现出了推理、总结、代码生成等复杂行为。

具体例子:

假设你向大模型提问:"这个产品怎么样?"

  1. 模型接收这句话后,经过内部运算,预测下一个概率最高的词:"非常"
  2. 模型把"非常"抓回来,追加到输入后面
  3. 继续预测下一个字:"好"
  4. 再把"好"塞回去,继续预测:"用"
  5. 最后输出结束标识符

完整回答:"非常好用"

这就是为什么大模型要一个词一个词地输出答案——因为它就是这么运作的。

一句话总结:大模型 = 一个极其复杂的"概率文字接龙机器"


二、Token(词元)

2.1 文字与数字的桥梁

大模型本质上是一个庞大的数学函数,里面跑的全是矩阵运算。它接收的是数字,输出的也是数字,压根儿就不认识人类写的文字。

在人类和大模型之间必须有一个中间人来做翻译,这个中间人就叫做Tokenizer(分词器),负责编码和解码两件事情:

  • 编码:把文字变成数字
  • 解码:把数字还原成文字

2.2 Token的生成过程

以用户提问"这个产品怎么样?"为例,这句话通常会被切分成若干个Token(具体数量取决于模型的分词策略,不同模型可能略有差异)。

第一步:切分

把用户的问题拆成一个一个最小的片段,这些片段就叫做Token

第二步:映射

把每个Token对应到一个数字上去,这个数字就叫做Token ID

2.3 Token vs 词:不是一对一关系

你可能会想:Token就是词,对吧?

不一定! Token和词并没有明确的一对一关系。

中文例子:

  • "工作坊" → 拆成"工作"+"坊"(2个Token)
  • "程序员" → 拆成"程序"+"员"(2个Token)

英文例子:

  • "hello" → 1个Token
  • "going" → 1个Token
  • "helpful" → 拆成"help"+"ful"(2个Token)

特殊字符:

某些情况下,一个字符会被切分成多个Token。比如"✓"(对勾)需要3个Token来表示。

2.4 Token的估算标准

平均来讲:

  • 1个Token ≈ 0.75个英文单词
  • 1个Token ≈ 1.5~2个汉字

举例:

  • 40万个Token ≈ 60~80万个汉字
  • 40万个Token ≈ 30万个英文单词

总结:Token是模型自己学会的一套文本切分规则,切出来的每一块就是它一次能够处理的最小单位。

一句话总结:Token是模型理解世界的"最小语言单位"


三、Context(上下文)

3.1 大模型的"记忆"之谜

我们平时和大模型聊天,它好像能记住之前说的话。比如你开头告诉他"我的名字是小明",他给你回复以后,你再问他"我叫什么名字",他还是能够回答得出来。

但问题是,大模型本质上只是一个数学函数,它并不像人一样真的有记忆。它是怎么记住之前的聊天内容的呢?

答案:每次给大模型发送消息时,并不只会发我们的问题,背后的程序会自动把之前的整段对话历史找出来,一起发过去。

3.2 什么是Context?

Context(上下文)代表大模型每次处理任务时所接触到的信息总和。

Context包含的内容:

  1. 用户问题
  2. 对话历史
  3. 大模型正在输出的每个Token
  4. 工具列表
  5. System Prompt(系统提示词)
  6. 其他信息

可以把Context看成是大模型的一个临时记忆体。

3.3 Context Window(上下文窗口)

Context Window代表Context能够容纳的最大Token数量。

主流模型的Context Window:

模型 Context Window
GPT-4 128K(约105万Token)
Claude 3.1 Pro 100万Token
Claude Opus 4 100万Token

100万个Token ≈ 150万个汉字,整个《哈利波特》全集的内容都能装下。

技术细节:为什么Context会影响推理能力?因为大模型进行复杂推理(如链式思考)需要足够长的上下文来"记住"推理过程。没有足够的Context,模型无法进行多步骤的逻辑推演。

一句话总结:Context不是记忆,是一次性打包输入


四、RAG(检索增强生成)

4.1 问题场景

假设你有一个上千页的公司产品手册,你希望大模型根据这个手册来回答用户的各种疑问,要怎么实现?

4.2 不好的方案

把手册的全部内容跟着用户问题一起扔给大模型?

问题

  • 产品手册太长
  • 即使模型的Context Window不被撑爆,成本也无法控制

4.3 RAG解决方案

RAG(Retrieval-Augmented Generation,检索增强生成)可以从产品手册中抽取与用户问题最为匹配的几个片段,然后只把这几个片段发给大模型。

优势

  • 不受Context Window大小限制
  • 成本大大降低
  • 大模型接收的不是一整本书,可能只是几段话

RAG vs Fine-tuning:RAG是检索时动态注入知识,适合知识频繁更新的场景;微调是将知识嵌入模型参数,适合特定任务的优化。两者可以结合使用。

一句话总结:RAG = 给大模型外挂一个可检索的知识库


五、Prompt(提示词)

5.1 什么是Prompt?

Prompt(提示词)是大模型接收的具体问题或指令。

比如你向大模型提需求:"帮我写一首诗。"这句话就是Prompt。

不要把Prompt想成特别复杂高端的东西,它只不过就是给大模型的一个问题或者是指令而已。

5.2 为什么Prompt很重要?

如果你只是简单地说"帮我写一首诗",大模型可能会:

  • 写古诗
  • 写现代诗
  • 写打油诗

原因:Prompt太模糊,它不知道你具体想要什么。

5.3 如何写好Prompt?

一个好的Prompt应该是:清晰的、具体的、明确的

好的例子

"请帮我写一首五言绝句,主题是秋天的落叶,风格要悲凉一点。"

这样一来,大模型就清楚多了,生成的内容也更符合你的预期。

5.4 Prompt Engineering(提示词工程)

Prompt Engineering本质上不是"黑科技",而是"把话说清楚"——这也是为什么它门槛并不高。

现状

  • 门槛较低,本质上就是把话说清楚
  • 大模型能力越来越强,即使提示词含糊不清,大模型也能大致猜出你的意图
  • 现在还在提它的人寥寥无几

一句话总结:Prompt = 你和大模型沟通的唯一接口


六、User Prompt vs System Prompt

6.1 两种不同的Prompt

有些时候我们不仅要告诉大模型它要处理的具体任务,还要告诉它人设和做事规则。

User Prompt(用户提示词)

  • 定义:说明具体任务的Prompt
  • 来源:用户自己在对话框输入
  • 示例:"3加5等于几?"

System Prompt(系统提示词)

  • 定义:说明人设和做事规则的Prompt
  • 来源:开发者在后台配置
  • 示例:"你是一个耐心的数学老师,当学生问你数学问题的时候,不要直接给出答案,而是要一步一步引导学生思考,帮助他们理解解题思路。"

6.2 具体例子

场景:做一个数学辅导机器人

System Prompt(后台设置,用户看不到):

"你是一个耐心的数学老师,当学生问你数学问题的时候,不要直接给出答案,而是要一步一步引导学生思考,帮助他们理解解题思路。"

User Prompt(学生输入):

"3加5等于几?"

大模型的回答

"我们可以这样想,你手里有三个苹果,然后又拿了5个,现在一共有多少个呢?你可以数一数看。"

对比:如果没有System Prompt,大模型可能直接说"8"了。


七、Tool(工具)

image.png

7.1 大模型的弱点

大模型有一个明显的弱点:无法感知外界环境

没有Tool的大模型,本质上是"闭着眼睛说话"。

例子:

假设你问大模型:"今天上海的天气怎么样?"

它可能会说:

"抱歉,我无法获取实时天气信息。我的知识库截止到某年某月,无法提供当前的天气数据。"

原因:大模型只是个文字接龙游戏,它的能力是根据训练数据来预测下一个词,但它真的没有办法去查天气预报网站拿到实时的天气数据。

7.2 什么是Tool?

Tool(工具)本质上就是一个函数,你给它输入,它就给你输出。

天气查询工具例子:

  • 输入:城市、日期(两个参数)
  • 内部操作:调用气象局的接口
  • 输出:天气信息

有了工具,大模型就可以回答天气相关的问题了。

7.3 工作流程

完整流程涉及的角色:

  1. 用户:提出问题
  2. 大模型:理解问题,决定是否需要调用工具
  3. 工具:执行具体任务,返回结果
  4. 大模型:根据工具返回的结果,生成最终回答

具体步骤:

  1. 用户问:"今天上海天气怎么样?"
  2. 大模型识别到需要天气信息
  3. 大模型调用天气查询工具,传入参数:城市="上海",日期="今天"
  4. 工具调用气象局API,返回天气数据
  5. 大模型根据天气数据生成自然语言回答
  6. 用户收到:"今天上海晴,气温25°C,适合出行。"

7.4 常见工具类型

工具类型 功能 示例
搜索工具 实时搜索互联网信息 Google搜索、Bing搜索
计算工具 执行数学计算 Python代码执行器
数据库工具 查询数据库 SQL查询工具
API工具 调用外部服务 天气API、股票API
文件工具 读写文件 文档处理工具

一句话总结:Tool = 让大模型睁开眼睛看世界的能力


八、Skill(技能)

我帮你写一版“风格统一的”👇(可以直接用)


什么是Skill?

Skill(技能)是针对特定任务的预配置能力包。它把大模型、Prompt、工具、记忆等组件打包在一起,形成一个可以直接使用的功能模块。

如果说:

  • Tool 是一个个“工具函数”
  • 那 Skill 就是把多个 Tool + Prompt + 执行流程 组合在一起,形成一个可复用的能力模块

Skill的组成

组件 说明 示例
大模型配置 选择哪个模型 GPT-4、Claude、Gemini
System Prompt 人设和任务规则 "你是一个Python代码专家"
工具列表 可调用的工具 代码执行器、Git操作
记忆配置 是否需要记忆 短期记忆、长期记忆
输出格式 结果的格式要求 JSON、Markdown、代码块

Skill vs Agent vs SubAgent

对比维度 Skill Agent SubAgent
定位 功能模块 任务执行者 辅助执行者
配置 预配置 动态配置 由Agent分配
复用性 高(可跨项目) 中(项目内) 低(任务内)
自主性 低(被动调用) 高(自主规划) 低(被动执行)

Skill的生态系统

开源Skill库

  • GitHub上的Skill仓库
  • 社区贡献的Skill包
  • 可直接下载使用

商业Skill市场

  • 官方Skill商店
  • 第三方Skill平台
  • 付费/免费Skill

自定义Skill

  • 根据业务需求定制
  • 企业内部Skill库
  • 持续迭代优化

九、Agent(智能体)

9.1 从大模型到Agent

前面我们学习了Tool,让大模型能够调用外部函数来获取信息。但这里有个问题:谁来决定什么时候调用工具?调用哪个工具?工具返回的结果怎么处理?

这就是Agent(智能体)登场的时候了。

Agent是大模型的进阶形态,它不仅能理解用户需求,还能自主规划和执行任务。

Agent不是更聪明,是更会做事。

9.2 Agent的核心能力

一个完整的Agent通常具备以下能力:

1. 感知能力

理解用户的意图和当前环境信息。

2. 规划能力

把复杂任务拆解成多个步骤,制定执行计划。

3. 执行能力

调用工具、访问外部API、操作文件等。

4. 反思能力

评估执行结果,调整策略,重新规划。

9.3 Agent vs 大模型的区别

对比维度 大模型 Agent
核心能力 文本生成 任务执行
主动性 被动响应 主动规划
工具使用 需要人工指定 自主决定调用
任务处理 单轮对话 多步骤任务流
记忆能力 有限(Context限制) 可扩展(外部存储)
错误处理 可重试、调整策略

9.4 Agent工作流程示例

场景:用户让Agent帮忙订一张去北京的机票

步骤1:理解意图

Agent分析用户需求:"订去北京的机票"

步骤2:规划任务

Agent把任务拆解:

  • 确定出发城市
  • 确定出发日期
  • 查询航班信息
  • 选择合适航班
  • 完成预订

步骤3:执行与交互

  1. Agent问:"请问您从哪个城市出发?"
  2. 用户答:"上海"
  3. Agent问:"请问您希望什么日期出发?"
  4. 用户答:"明天"
  5. Agent调用航班查询工具
  6. Agent展示查询结果
  7. Agent问:"您选择哪个航班?"
  8. 用户选择后,Agent调用预订工具

步骤4:反馈与确认

Agent返回:"已为您预订成功,订单号是..."

8.5 SubAgent(子智能体)

什么是SubAgent?

SubAgent是Agent的子智能体,用于处理Agent任务流程中的子任务。

Agent vs SubAgent的区别

对比维度 Agent SubAgent
定位 主控智能体 辅助智能体
任务范围 完整任务 子任务
调用关系 被用户调用 被Agent调用
生命周期 长期存在 任务完成后销毁
决策权 低(由Agent分配)

SubAgent使用场景

例子:用户让Agent写一个技术文档

  1. Agent接收到任务:"写一份API文档"
  2. Agent规划
    • 调用SubAgent1:分析代码,提取API接口信息
    • 调用SubAgent2:生成文档模板
    • 调用SubAgent3:填充具体内容
  3. SubAgent1完成代码分析,返回接口列表
  4. SubAgent2生成文档结构
  5. SubAgent3根据接口信息填充文档内容
  6. Agent整合所有结果,返回最终文档

一句话总结:Agent = 能自己决定怎么做事的大模型

8.6 多Agent协同模式

为什么需要多Agent协同?

单个Agent虽然强大,但面对复杂任务时,往往需要"术业有专攻"。通过多个Agent协同工作,可以实现:

  • 专业分工:每个Agent专注自己的领域
  • 质量提升:通过互相检查、辩论,减少错误
  • 效率优化:并行处理多个子任务
  • 能力互补:不同Agent拥有不同的工具和知识

动态规划示例

  • 代码审查Agent:挂载代码分析工具,使用Claude模型(擅长代码理解)
  • 数据分析Agent:挂载数据处理工具,使用GPT-4模型(擅长数学推理)
  • 文档写作Agent:挂载文档模板工具,使用Gemini模型(擅长长文本生成)

三种主流协同模式

image.png

一、上下级协同(Hierarchical)——类比公司组织架构

结构image.png

工作方式

  • 中控Agent负责拆解任务、分配工作、整合结果
  • 子Agent负责具体执行
  • 可以多层嵌套,形成树状结构

适用场景

  • 大型项目管理
  • 复杂系统的开发
  • 需要严格流程控制的任务

实际案例 - 飞猪行程规划

  • 中控Agent:接收用户"规划一次北京到上海的旅行"需求
  • SubAgent1:查询北京到上海的航班信息
  • SubAgent2:搜索上海的酒店和景点
  • SubAgent3:生成详细的行程安排
  • 中控Agent:整合所有信息,输出完整行程单

实际案例 - 机票预订

  • 中控Agent:接收"预订明天北京到上海的机票"需求
  • SubAgent1:查询航班信息(时间、价格、航空公司)
  • SubAgent2:对比不同航班的性价比
  • SubAgent3:执行预订操作
  • 中控Agent:确认预订结果,返回订单号
二、师生式协同(Master-Disciple)——本质是"带思路"

结构

image.png工作方式

  • 专家Agent提供策略、方法论、评价标准
  • 新手Agent按照专家的指导执行具体任务
  • 专家可以实时反馈和调整

关键要素

  • 策划思路:如何拆解问题、制定策略
  • 信息收集方法:从哪里获取信息、如何筛选有效信息
  • 表达格式规范:输出应该是什么格式、包含哪些要素
  • 评价标准:如何判断结果的好坏、如何改进

适用场景

  • 需要传承专业知识的任务
  • 质量要求高的内容创作
  • 需要标准化流程的工作

实际案例 - 单轮对话优化

  • 用户输入:"帮我写一个产品介绍"
  • 新手Agent:生成初步版本(可能不够专业)
  • 专家Agent:提供反馈:"需要突出产品的核心优势,增加数据支撑,使用更专业的术语"
  • 新手Agent:根据反馈优化输出
  • 专家Agent:继续指导:"结构可以调整为:问题背景 → 产品解决方案 → 核心优势 → 客户案例"
  • 新手Agent:按照新结构重新生成
  • 最终输出:高质量的产品介绍

本质区别:上下级协同是主智能体严格拆解任务并分配,师生式协同则是通过讨论和反馈优化输出,更具互动性。

三、竞争式协同(Competitive / Debate)——让模型"互相杠"

结构image.png工作方式

  • 多个Agent独立生成不同方案
  • 裁判Agent对比分析各方案优劣
  • 选择最优或融合多个方案

本质:多解 → 对比 → 选择最优

适用场景

  • 开放性问题(没有标准答案)
  • 需要高质量输出的任务
  • 容易"幻觉"的任务(需要互相验证)
  • 创意类工作(需要多个视角)

实际案例 - 营销文案创作

  • 任务:"为一款智能手表写营销文案"
  • Agent1:强调性价比(价格优势、功能对比)
  • Agent2:强调品质(材质、工艺、品牌背书)
  • Agent3:强调创新(独特功能、技术突破)
  • 裁判Agent:综合三个角度,生成最优文案
    • 开头用创新点吸引注意力
    • 中间用品质数据建立信任
    • 结尾用性价比促成转化

为什么竞争式协同有效?

  • 避免单一视角:不同Agent从不同角度思考,避免思维局限
  • 互相验证:多个方案可以互相检查,减少幻觉和错误
  • 激发创意:竞争机制激发Agent的创造力,产生更好的想法
  • 质量提升:通过对比筛选,最终输出质量更高

应用场景总结

多智能体协作适用于复杂任务(如工程开发、项目上线),通过分工减轻单一智能体负担。

应用场景 推荐模式 理由
工程开发 上下级协同 需要严格的任务拆解和流程控制
项目上线 上下级协同 涉及多个环节,需要统一调度
代码审查 师生式协同 需要专家指导,逐步优化代码质量
内容创作 师生式协同 需要反复打磨,提升内容质量
方案设计 竞争式协同 需要多角度思考,选择最优方案
创意生成 竞争式协同 需要激发创意,避免思维固化
数据分析 混合模式 用上下级协同拆解任务,用竞争式协同验证结果

如何选择协同模式?

任务特点 推荐模式
复杂、需要严格流程 上下级协同
需要专业知识传承 师生式协同
开放性、创意类 竞争式协同
需要多角度验证 竞争式协同
大型项目管理 上下级协同
需要迭代优化 师生式协同

一句话总结:多Agent协同 = 让多个专业AI各司其职,通过分工、合作、竞争,共同完成复杂任务


十、思考题

Q1:Token和字符有什么区别?为什么大模型不直接处理字符?

  • Token是大模型处理文本的最小单位,Token和字符不是一对一关系
  • 一个Token可能对应一个词、多个字符,也可能一个字符对应多个Token
  • 平均1个Token ≈ 1.5~2个汉字,或0.75个英文单词
  • 不直接处理字符的原因:字符粒度太细,模型需要学习更多模式;Token粒度更符合语言单元,训练效率更高

Q2:Context Window越大越好吗?

  • Context Window是大模型一次能处理的最大Token数量
  • 它决定了模型能"记住"多少信息
  • 影响模型的成本和性能
  • 不同模型的Context Window大小不同(GPT-4:128K,Claude 3.1 Pro:100万)
  • 不是越大越好:更大的Context意味着更高的计算成本,需要根据实际需求选择合适的模型

Q3:RAG和Fine-tuning有什么区别?什么时候用哪个?

  • RAG:检索时动态注入知识,适合知识频繁更新的场景
  • Fine-tuning:将知识嵌入模型参数,适合特定任务的优化
  • RAG优势:知识可实时更新,成本低,不受Context Window限制
  • Fine-tuning优势:模型对特定领域更熟悉,输出更稳定
  • 两者可以结合使用:用Fine-tuning学习领域风格,用RAG获取最新知识

Q4:Agent和普通的大模型有什么本质区别?

  • Agent是具备自主规划和执行能力的智能体
  • 区别:
    • 大模型:被动响应,只能生成文本
    • Agent:主动规划,可以执行多步骤任务
  • Agent的核心能力:感知、规划、执行、反思
  • Agent可以自主决定何时调用工具、调用哪个工具
  • Agent不是更聪明,是更会做事

Q5:Agent未来的发展方向是什么?

技术方向

  • 多模态能力:处理文本、图像、音频、视频
  • 更强的规划能力:处理更复杂的任务链
  • 自主学习:根据反馈优化自身行为
  • 协作能力:多个Agent协同工作

应用方向

  • 垂直领域Agent:医疗、法律、金融等
  • 个人化Agent:深度了解用户习惯和偏好
  • 企业级Agent:处理复杂的业务流程
  • 物理世界Agent:机器人、自动驾驶等

挑战

  • 安全性:防止Agent执行危险操作
  • 可控性:确保Agent行为符合预期
  • 成本:降低Agent运行成本
  • 普适性:让更多普通人能使用Agent

十一、总结

核心概念回顾

概念 英文 定义
大语言模型 LLM 基于Transformer架构训练的大规模语言模型
词元 Token 大模型处理文本的最小单位
上下文 Context 大模型每次处理任务时接收的信息总和
上下文窗口 Context Window Context能容纳的最大Token数量
提示词 Prompt 大模型接收的具体问题或指令
检索增强生成 RAG 从大量文档中检索相关片段发给大模型的技术
工具 Tool 让大模型能够执行具体任务的函数
智能体 Agent 具备自主规划和执行能力的AI系统
子智能体 SubAgent 被Agent调用的辅助智能体
技能 Skill 针对特定任务的预配置能力包
模型上下文协议 MCP 连接大模型和外部数据源的标准化协议

image.png


十二、结语

AI技术日新月异,但核心概念始终如一。从最底层的LLM、Token,到中层的Context、Prompt、RAG,再到上层的Tool、Agent、Skill、MCP,这些概念构成了现代AI应用的技术栈。

希望这篇文章能帮助你建立扎实的AI知识体系。如果觉得有用,欢迎分享给更多需要的朋友!

:本文内容基于当前主流AI技术整理,随着技术发展,部分概念可能会有更新。建议持续关注最新动态。

Vite:比Webpack快100倍的“闪电侠”,原理竟然这么简单?

听说Vite很快?快得像你点下保存,浏览器立马刷新。今天我们就来拆解这个“前端新宠”,看看它到底用了什么黑魔法。看完你会发现:哦,原来不是魔法,是“降维打击”!

前言

Webpack就像个勤劳的蚂蚁,把整个项目一点点搬完再给你看结果。Vite则像个聪明的快递员:你点什么,它送什么,绝不提前扛一堆货。

Vite(法语“快”)是尤雨溪推出的构建工具,开发服务器启动快到“秒开”,热更新快到“没感觉”。它的秘诀就是:利用浏览器原生ES模块(ESM),让浏览器帮你分担工作,自己只做最轻量的事。

一、Webpack为什么慢?因为它在“提前打包”

Webpack的工作方式是:启动开发服务器时,要从入口开始,把整个项目的所有模块都打包成一个(或几个)bundle。即使你用lazy loading,它也要先解析所有依赖关系。

这个过程随着项目变大而变慢。想象一下,你只是改了一行代码,Webpack却要重新打包一大坨东西——虽然做了缓存,但还是很重。

二、Vite的思路:让浏览器做打包

现代浏览器已经支持<script type="module">,可以直接在HTML里导入ES模块,浏览器会按需请求每个模块文件。

Vite利用这一点:开发时,它不打包,而是直接把源码转换成ES模块,让浏览器去请求。当浏览器请求/src/main.js时,Vite拦截请求,实时编译(比如把JSX转成JS,把TS转成JS),然后返回给浏览器。

启动速度对比

  • Webpack:启动 = 打包整个应用 → 慢。
  • Vite:启动 = 启动一个静态服务器 + 预构建第三方依赖 → 极快。

热更新(HMR)对比

  • Webpack:改一个模块,可能需要重新打包部分bundle。
  • Vite:利用ESM的精确依赖关系,只更新被改的模块,浏览器只需要重新请求那个模块,速度飞起。

三、Vite的核心原理:三大绝招

1. 基于原生ESM的开发服务器

Vite在开发环境下,把每个文件都当作独立的模块,通过koaconnect启动一个服务器。当浏览器请求/src/App.vue时,Vite会实时编译Vue组件,返回一个JS模块。

// 浏览器请求 main.js
import { createApp } from '/node_modules/.vite/deps/vue.js'
import App from '/src/App.vue'
createApp(App).mount('#app')

注意,import里的路径是相对于服务器的,Vite会拦截并处理。

2. 预构建:用esbuild把第三方库“打成一块”

虽然Vite不打包业务代码,但第三方库(比如vuereact)往往有很多内部模块。如果让浏览器一个个请求,请求数太多,性能差。

Vite在启动时会用esbuild(用Go写的,超快)把第三方库预构建成单个ESM模块,然后缓存起来。这样浏览器只需要请求一个文件,而不是几十个。

3. 按需编译 + 缓存

Vite只会编译浏览器实际请求的文件。你没访问到的页面组件,Vite根本不会编译。而且编译结果会缓存到node_modules/.vite,下次启动秒开。

四、生产环境:还是Rollup打包

有人问:Vite开发那么快,生产环境也用ESM不就行了?问题是,纯ESM在生产环境下有太多请求,性能不好。而且需要做tree shaking、代码分割、压缩等优化。

所以Vite在生产环境默认使用Rollup打包,打包出高度优化的静态文件。这样既享受了开发时的快,又保证了生产时的优。

五、手写一个迷你Vite

我们来模拟Vite的核心思想:一个按需编译的静态服务器。

// mini-vite.js
const express = require('express');
const fs = require('fs');
const path = require('path');
const { transform } = require('esbuild'); // 用esbuild转译

const app = express();

app.use((req, res, next) => {
  const url = req.url;
  if (url === '/') {
    // 返回index.html
    const html = fs.readFileSync('./index.html', 'utf-8');
    res.setHeader('Content-Type', 'text/html');
    return res.send(html);
  }
  // 处理JS文件
  if (url.endsWith('.js')) {
    const filePath = path.join(__dirname, url);
    const content = fs.readFileSync(filePath, 'utf-8');
    // 用esbuild转译JSX/TS等(简化版)
    transform(content, { loader: 'jsx' }).then(result => {
      res.setHeader('Content-Type', 'application/javascript');
      res.send(result.code);
    });
  } else {
    next();
  }
});

app.listen(3000, () => console.log('Mini Vite running at http://localhost:3000'));

这只是个玩具,真实Vite复杂得多(处理Vue单文件、HMR、预构建等),但核心思想一致:拦截请求、实时转换、返回ES模块

六、Vite的适用场景和坑点

适用场景

  • 新项目,尤其使用Vue3或React+TS
  • 需要极快开发体验
  • 对构建配置要求不高(默认配置够用)

坑点

  • 依赖CommonJS格式的库可能有问题(Vite会尝试转换,但少数不行)
  • 动态导入import()的路径必须静态可分析
  • 生产环境用Rollup,配置和开发环境可能不一致(但Vue官方推荐)

七、总结:Vite不是魔法,是“借力”

  • 开发时:利用浏览器ESM + esbuild预构建 + 按需编译 → 秒启、快更。
  • 生产时:Rollup打包 → 优化产物。
  • 核心思想:让浏览器做更多,服务器做更少

Webpack正在努力追赶(比如Webpack5的模块联邦和缓存),但Vite的“降维打击”思路确实带来了革命性的开发体验。如果你还没试过,去创建一个Vite项目体验一下,你会回来点赞的。

如果你觉得今天的“闪电侠”够形象,点个赞让更多人看到。明天我们将进入TypeScript基础,从类型注解到接口,让你写出更健壮的代码。我们明天见!

Connect 深度解析:Node.js 中间件框架的基石

在 Node.js 生态中,connect 是一个轻量级、可扩展的 HTTP 中间件框架。它虽然代码量不大(核心文件仅数百行),却奠定了 Express、Koa 等现代 Web 框架的中间件设计基础。理解 connect 的源码与设计思想,有助于掌握 Node.js HTTP 开发的底层模式。本文将从概念、使用方法、源码实现、中间件机制以及应用场景五个维度,对 connect 进行全面剖析。

Connect 是什么?

connect ,其定位是“Node.js 的中间件层”。它本身不是一个完整的 Web 框架,而是一个可插拔的 HTTP 请求处理管道。开发者可以将各种功能(日志、静态文件、路由、代理等)以中间件的形式插入到管道中,按顺序处理请求。

connect 的核心概念:

  • 中间件:一个接受 (req, res, next) 的函数,可以修改请求/响应、结束请求或调用下一个中间件。
  • 中间件栈:使用 use 方法注册中间件,形成一个数组(栈),请求到来时依次执行。
  • 错误处理:通过 (err, req, res, next) 形式的中间件捕获异常。

Connect 的核心是其维护的一个中间件队列(stack),通过use方法将中间件注册到队列中。每个中间件都是一个函数,它能够访问请求对象(req)、响应对象(res)以及控制权传递函数(next)。

中间件处理的核心在于next()函数:

  • 调用next() :表示当前中间件已完成处理,将控制权传递给队列中的下一个中间件。
  • 不调用next() :表示请求处理链终止,不再继续向下执行。

这种机制确保了每一个中间件只处理它负责的部分,实现了职责分离和灵活组合。

Connect 使用

1、初始化与基础设置

const connect = require("connect");
const http = require("http");

const app = connect();
const server = http.createServer(app);

server.listen(3000);
console.log("Server is running on port 3000");

2、不指定路径,中间件会对每个请求执行

app.use(function logger(req, res, next) {
  const start = Date.now();
  const originalUrl = req.url; // 保存原始 URL
  // 监听响应结束事件(因为 res.end 是异步的)
  res.on("finish", () => {
    const duration = Date.now() - start;
    console.log(`响应结束 ${req.method} ${originalUrl} - ${res.statusCode} - ${duration}ms`);
  });
  next();
});

3、路径匹配中间件

请求路径以 /user 开头时触发(如 /user/user/profile

app.use("/user", (req, res, next) => {
  console.log("用户中间件");
  res.setHeader("Content-Type", "text/plain");
  res.end("User area");
});

4、子应用挂载

const adminApp = connect();

adminApp.use((req, res, next) => {
  res.setHeader("Content-Type", "text/plain");
  res.end("Admin area");
});
app.use("/admin", adminApp);

5、错误触发中间件

访问 /error 时主动抛出错误,用于测试错误处理中间件。

app.use("/error", (req, res, next) => {
  throw new Error("Error");
});

5、404中间件

所有未匹配的请求都会返回 404 Not Found

app.use((req, res) => {
  res.statusCode = 404;
  res.end('Not Found');
});

7、错误处理中间件

app.use((err, req, res, next) => {
  console.error(err.stack);
  res.statusCode = 500;
  res.end("Internal Server Error");
});

源码 connect@3.7

/*!
 * connect
 * Copyright(c) 2010 Sencha Inc.
 * Copyright(c) 2011 TJ Holowaychuk
 * Copyright(c) 2015 Douglas Christopher Wilson
 * MIT Licensed
 */

'use strict';

/**
 * Module dependencies.
 * @private
 */

var debug = require('debug')('connect:dispatcher');
var EventEmitter = require('events').EventEmitter;
var finalhandler = require('finalhandler');
var http = require('http');
var merge = require('utils-merge');
var parseUrl = require('parseurl');

/**
 * Module exports.
 * @public
 */

module.exports = createServer;

/**
 * Module variables.
 * @private
 */

var env = process.env.NODE_ENV || 'development';
var proto = {};

/* istanbul ignore next */
var defer = typeof setImmediate === 'function'
  ? setImmediate
  : function(fn){ process.nextTick(fn.bind.apply(fn, arguments)) }

/**
 * Create a new connect server.
 * 用于创建一个新的 Connect 服务器实例。
 * 这个函数返回一个具有中间件处理能力的函数,该函数可以作为 HTTP 服务器的请求处理器
 *
 * @return {function}
 * @public
 */

function createServer() {
  // 创建 app 函数
  // 当 app 函数被调用时,它会调用自身的 handle 方法来处理请求
  function app(req, res, next){ app.handle(req, res, next); }
  // 将 proto 对象的属性合并到 app 函数上
  merge(app, proto);
  merge(app, EventEmitter.prototype);
  app.route = '/';
  app.stack = []; // 存储中间件函数
  return app;
}

/**
 * Utilize the given middleware `handle` to the given `route`,
 * defaulting to _/_. This "route" is the mount-point for the
 * middleware, when given a value other than _/_ the middleware
 * is only effective when that segment is present in the request's
 * pathname.
 *
 * For example if we were to mount a function at _/admin_, it would
 * be invoked on _/admin_, and _/admin/settings_, however it would
 * not be invoked for _/_, or _/posts_.
 *
 * @param {String|Function|Server} route, callback or server
 * @param {Function|Server} callback or server
 * @return {Server} for chaining
 * @public
 */
// 向中间件栈中添加一个新的中间件
proto.use = function use(route, fn) {
  var handle = fn;
  var path = route;

  // default route to '/'
  // 函数重载:如果 route 参数不是字符串,说明是中间件函数,直接赋值给 handle 变量
  // 不指定路径,中间件会对每个请求执行。
  if (typeof route !== 'string') {
    handle = route;
    path = '/';
  }

  // wrap sub-apps
  // 子应用包装(Sub-app)
  // 传入的 handle 是一个 connect 应用实例(具有 handle 方法),则将其包装成一个中间件函数
  if (typeof handle.handle === 'function') {
    var server = handle; // 子应用实例
    server.route = path;
    // 中间件函数
    handle = function (req, res, next) {
      server.handle(req, res, next);
    };
  }

  // wrap vanilla http.Servers
  // HTTP 服务器适配
  // 传入的 handle 是 Node.js 原生 http.Server 实例,则提取其 'request' 事件监听器(即第一个处理函数)作为中间件
  if (handle instanceof http.Server) {
    handle = handle.listeners('request')[0];
  }

  // strip trailing slash
  // 移除路径末尾的斜杠,确保路径格式为 /admin 而不是 /admin/
  if (path[path.length - 1] === '/') {
    path = path.slice(0, -1);
  }

  // add the middleware
  debug('use %s %s', path || '/', handle.name || 'anonymous');

  // 将中间件对象(包含 route 和 handle)推入 this.stack 数组
  this.stack.push({ route: path, handle: handle });

  return this;
};

/**
 * Handle server requests, punting them down
 * the middleware stack.
 * 遍历中间件栈(this.stack),根据请求路径匹配中间件,并依次执行
 *
 * @private
 */

proto.handle = function handle(req, res, out) {
  var index = 0; // 当前中间件在栈中的索引
  // 请求 URL 中的协议+主机部分(如 http://example.com)
  var protohost = getProtohost(req.url) || '';
  var removed = ''; // 记录已被匹配并“剥离”的路由前缀
  var slashAdded = false; // 标记是否因为路径变换而添加了前导斜杠
  var stack = this.stack;

  // final function handler
  // 最终处理函数(默认 finalhandler),当所有中间件执行完或出错时调用
  var done = out || finalhandler(req, res, {
    env: env,
    onerror: logerror
  });

  // store the original URL
  // 保存原始请求 URL
  req.originalUrl = req.originalUrl || req.url;

  function next(err) {
    // 1、恢复 URL 变换
    // 因为匹配路由而临时去掉了前导斜杠(slashAdded === true),则将其加回
    if (slashAdded) {
      req.url = req.url.substr(1);
      slashAdded = false;
    }

    // 之前剥离了路由前缀(removed 非空),则将其重新拼接到 req.url 前面
    if (removed.length !== 0) {
      req.url = protohost + removed + req.url.substr(protohost.length);
      removed = '';
    }

    // next callback
    // 2、取出当前中间件,索引自增
    var layer = stack[index++];

    // all done
    // 如果已无中间件,则调用 done(可能传递错误),结束处理
    if (!layer) {
      defer(done, err);
      return;
    }

    // 3、路径匹配检查
    // route data
    var path = parseUrl(req).pathname || '/';
    var route = layer.route;

    // skip this layer if the route doesn't match
    if (path.toLowerCase().substr(0, route.length) !== route.toLowerCase()) {
      return next(err);
    }

    // skip if route match does not border "/", ".", or end
    // 4、边界符检查
    var c = path.length > route.length && path[route.length];
    if (c && c !== '/' && c !== '.') {
      return next(err);
    }

    // 5、URL 变换(剥离路由前缀)
    // trim off the part of the url that matches the route
    if (route.length !== 0 && route !== '/') {
      removed = route;
      req.url = protohost + req.url.substr(protohost.length + removed.length);

      // ensure leading slash
      if (!protohost && req.url[0] !== '/') {
        req.url = '/' + req.url;
        slashAdded = true;
      }
    }

    // 6、调用中间件
    // call the layer handle
    call(layer.handle, route, err, req, res, next);
  }

  next();
};

/**
 * Listen for connections.
 *
 * This method takes the same arguments
 * as node's `http.Server#listen()`.
 *
 * HTTP and HTTPS:
 *
 * If you run your application both as HTTP
 * and HTTPS you may wrap them individually,
 * since your Connect "server" is really just
 * a JavaScript `Function`.
 *
 *      var connect = require('connect')
 *        , http = require('http')
 *        , https = require('https');
 *
 *      var app = connect();
 *
 *      http.createServer(app).listen(80);
 *      https.createServer(options, app).listen(443);
 *
 * @return {http.Server}
 * @api public
 */

proto.listen = function listen() {
  // 1、创建 HTTP 服务器实例
  // this 指向 connect 应用实例
  // http.createServer(this) 创建原生 HTTP 服务器,并将 app 作为请求监听器
  // 这意味着每当有 HTTP 请求到达时,就会调用 app(req, res),从而进入 Connect 的中间件处理管道
  var server = http.createServer(this);
  // 2、启动 HTTP 服务器
  return server.listen.apply(server, arguments);
};

/**
 * Invoke a route handle.
 * @private
 */

function call(handle, route, err, req, res, next) {
  var arity = handle.length;
  var error = err;
  var hasError = Boolean(err);

  debug('%s %s : %s', handle.name || '<anonymous>', route, req.originalUrl);

  try {
    if (hasError && arity === 4) {
      // error-handling middleware
      handle(err, req, res, next);
      return;
    } else if (!hasError && arity < 4) {
      // request-handling middleware
      handle(req, res, next);
      return;
    }
  } catch (e) {
    // replace the error
    error = e;
  }

  // continue
  next(error);
}

/**
 * Log error using console.error.
 *
 * @param {Error} err
 * @private
 */

function logerror(err) {
  if (env !== 'test') console.error(err.stack || err.toString());
}

/**
 * Get get protocol + host for a URL.
 * 从 URL 字符串中提取协议和主机部分(protocol + host)
 *
 * @param {string} url
 * @private
 */

function getProtohost(url) {
  // 检查 URL 是否为空字符串或以 '/' 开头
  // 这类 URL 通常是相对路径,不包含协议和主机信息
  if (url.length === 0 || url[0] === '/') {
    return undefined;
  }

  // 查找 URL 中的协议分隔符位置
  var fqdnIndex = url.indexOf('://')

  return fqdnIndex !== -1 && url.lastIndexOf('?', fqdnIndex) === -1
    ? url.substr(0, url.indexOf('/', 3 + fqdnIndex))
    : undefined;
}

AI 写代码总翻车?我用 Harness:developer 把它管成“右侧打工人”

先说一个真实场景。

你让 AI 改个功能,本来想要“10 分钟提效”,结果最后变成“2 小时排雷”:

  • 一会儿改了不该改的文件;
  • 一会儿说“已完成”但没有验证证据;
  • 你回头想复盘,发现没有计划、没有过程、没有日志;
  • 最终你和同事围着屏幕沉默三分钟,谁都不敢点合并。

我后来发现,问题不在“模型笨”,而在“流程野”。

于是我设计了一个任务编排skill:Harness:developer, 干的事很直接:

把 AI 开发变成一条有门禁的流水线。主会话只负责编排,右侧 pane 才能写业务代码。你可以理解成让 AI 去右边工位打卡上班,左边主管只盯流程,不准亲自抡键盘抢活。

这篇文章不讲玄学,直接讲它到底怎么工作。看完你就能按步骤跑,尤其适合刚上手 AI 协作开发的同学。

先记住一句总规则

从 Step 2 开始,到 Step 6 结束:主会话禁止业务编码。

是的,禁止。不是“尽量不要”,是“违规就 fail”。

为什么这么绝对?因为大多数事故都发生在“差不多就行”的灰色地带。主会话一旦开始手改业务代码,你后面就很难分清:到底是编排成功,还是救火成功。

整条流程长什么样

Harness:developer 固定是 9 个阶段:

  1. Step 0:Preflight:环境预检
  2. Step 1:需求理解与计划落盘:prd/在线原型/原型源码三路并行分析
  3. Step 2:编排锁建立:claudecode使用agent team分析需求并执行计划,codex使用subagents分析需求并执行计划
  4. Step 3:启动指令生成
  5. Step 4:启动右侧编码会话
  6. Step 5:任务下发 + ACK 校验
  7. Step 6:tmux+smux右侧创建pane,执行编码(主会话只监控,开发过程实时记录进度)
  8. Step 7:强制调用 /Harness:verify 验证编码结果
  9. Step 8:日志归档 + 规则合规报告

你可以把它看成“开发版过安检”。每一步都要验票,没票就别进下一站。

开始流程

image.png

终端执行tmux -> codex --yolo 或者 claude --dangerously-skip-permissions -> 输入提示词

# claudecode
/Harness:developer "完成 @docs/产品文档/v1.0/产品文档/交易中心_分账订单PRD.md" "codex" "http://localhost:3000/#/payment-orders/transfers"

# codex
$Harness:developer "完成 @docs/产品文档/v1.0/产品文档/交易中心_分账订单PRD.md" "codex" "http://localhost:3000/#/payment-orders/transfers"

Step 0:先查体检,再谈开工

image.png

image.png 这一阶段主要确认“你现在是不是在能跑流程的环境里”。

要检查的核心点:

  • tmux 是否可用,主 pane 能不能探测到
  • smux/tmux-bridge 是否可用
  • 当前会话识别为 codex 还是 claude
  • 关键 skill 是否具备:/prototype-reader/Harness:progress/Harness:verify

这一步最常见的翻车是:根本不在 tmux 里,或者 pane 信息都拿不到,还硬着头皮继续。后面自然是连锁崩。

一句话总结:Step 0 不是形式主义,它是“这趟车能不能发车”的决定点。

Step 1:三路并行分析,不再盲改

image.png

这里是我认为最值钱的一步。Harness:developer 要求并行跑 3 个分析单元:

  • prd-analyst:看需求
  • prototype-explorer:看原型
  • source-analyst:看现有代码

注意,原型分析必须给硬证据,不接受“我看过了”的口头保证。至少要有:

  • 页面开闭记录
  • 3 张及以上截图(列表页、详情/弹窗、配置页)
  • 浏览器已清理标记
  • 主会话逐项验证截图文件存在

这一步完成后,会把计划文档和进度文档落盘。也就是说,从这一刻开始,你不再是“脑内计划”,而是“可追踪计划”。

image.png

image.png

很多人跳过这一步,理由是“我急着写代码”。结果往往是后面改三轮,最后总耗时更长。

Step 2:上锁,正式进入“只编排模式”

image.png

Step 2 是整条链路的硬门禁。

这里要做几件关键事:

  • 校验 Step 1 产物存在(计划、进度、分析单元已回收)
  • 输出固定强提醒:Step2-6 主会话仅编排,违规即 fail
  • 识别并记录 MAIN_PANE_ID
  • 清理多余子 pane
  • 建立 ORCHESTRATION_LOCK=on 并写锁文件

这把锁的意义非常现实:防止流程运行中途“主会话忍不住下场改代码”。

你可以把它当作“防手痒机制”。听起来有点好笑,但真有用。

Step 3:生成启动命令,做一次防串改

这一步会生成 READY_TOKENSTART_CMD,并做可执行校验。

关键要求是:START_CMD 必须包含 ready:{READY_TOKEN}

如果只是裸 ready,后面不认。因为裸 ready 很容易误判来源,token 化回执才能明确“这是这次任务、这个 pane、这条链路的回执”。

此外还会记录 START_CMD_SHA1,防止命令被串改或发错版本。

Step 4:拉起右侧 pane,确认“打工人已就位”

动作看起来简单:

  • 新建右侧 pane
  • 拿到 TARGET_PANE_ID
  • 确认它不等于 MAIN_PANE_ID
  • 通过协议发送启动命令
  • 等待 ready:{READY_TOKEN}
  • 校验 pane 当前进程必须是 codex|claude

真正难的不是“会不会 split-window”,而是“会不会做来源校验”。

我见过最经典的事故是:ready 回执出现在主 pane,但大家没注意,还继续推进。最后你以为右侧在写代码,实际右侧压根没起来。

这就是为什么它要求 token、要求来源、要求进程在位校验。不是麻烦,是防事故。

Step 5:下发任务,必须拿到 ACK 才算派工成功

image.png

这一步的核心是“发送任务 + 验收回执”。

合规 ACK 形态主要是:task_received:{MODULE_NAME}

如果走 fallback,也不是随便说一句“我收到了”就行,还要补齐工作态证据,比如已经进入工作、已返回空闲等。

还有个细节很关键:ACK 必须来自目标 pane。主 pane 出现对应 ACK,直接判误发。

你可以理解成工单系统里的“签收回执”:没签收,不算派单成功。

Step 6:右侧执行编码,主会话只监控

image.png

这是最考验耐心的一段。

主会话此时允许做的事只有三类:

  • 监控目标 pane 输出
  • 定时记进度(通常每 3 分钟一次)
  • 必要时发“继续开发”

不允许做的事就一条,但非常重要:主会话不能接管业务编码。

并且完成回执有顺序要求:

  1. verify_done:{MODULE_NAME}
  2. done_ack:{MODULE_NAME}

顺序错了不行,缺一个也不行。

另外,流程把“等待态”和“故障态”分得很清楚:

  • timeouttask_blocked:*:属于等待态,继续等
  • 429/50x:才算故障态,可进入恢复策略

这条规则能显著减少“焦虑型误操作”:看着不动就 kill pane,结果把正常执行链路硬切断。

定时更新进度的效果图:

image.png

image.png

Step 7:必须走 /Harness:verify,不能口头毕业

image.png

到了这步,很多人会说“我自己跑了 lint/typecheck 就好了吧”。

不行。

Harness:developer 要求必须调用 /Harness:verify,并保留可核验回执。原因很简单:统一口径、统一证据、统一复盘入口。否则每个人都说“我测过了”,但没人说得清“你到底怎么测的”。

Step 8:归档日志,让这次开发可追溯

image.png

最后一步是很多团队最容易忽略的,但长期价值最大:

  • 写自检日志到固定目录
  • 记录计划执行、验证结果、规则检查、问题修复
  • 输出 Rule Compliance Report(改动文件 -> 对应规则)

这一步做得好,后面查问题就不是“靠记忆猜”,而是“按记录查”。

这套流程到底值不值得

如果你只看“第一次上手成本”,它确实比一句 prompt 重。

但如果你看“总交付成本”,它通常更省:

  • 需求理解更早收敛,少返工
  • 过程责任更清晰,少扯皮
  • 验证证据更统一,少争议
  • 归档更完整,少失忆

说白了,这是一套“把 AI 开发从表演赛变成联赛”的方法。

给小白的落地建议

如果你今天就想试,照这个顺序来:

  1. 先完整跑一遍,不要私自删 Step
  2. 严格执行 Step 2 的锁,不要觉得自己能自律
  3. 把 Step 1 的原型证据当硬指标,不要口头化
  4. Step 7 一定走 /Harness:verify,别手动替代
  5. Step 8 认真写日志,给未来的自己省时间

你会发现,流程不是束缚,而是“降低犯错自由度”。这在多人协作里,几乎总是好事。

结尾

Harness:developer 最厉害的地方,不是让 AI 写得更快,而是让你知道“它到底有没有按规范写”。

快不快是一时的,稳不稳是长期的。

把 AI 放到右侧 pane 去打工,把主会话留给编排和审计。你会明显感觉到:项目开始像项目了,不再像临场 improvisation。

为了搞懂 Promise 源码,我重写了 MiniPromise

前言

Promise 源码看了一百遍,不如自己写一遍。

相信很多前端同学都有过这样的经历:面试问手写 Promise,网上搜一搜 "Promise A+ 规范实现",然后对着代码 Copy 一遍,写完还是云里雾里 —— 那些 .thenPromise.resolvePromise.all 到底是怎么串起来的?

这篇文章不打算贴完整代码(GitHub 上已经够多了),而是换个方式:用一个最小、最简、最裸的 MiniPromise,带你从零理解 Promise 的设计思路


1. 先想清楚:Promise 解决什么问题?

在 Promise 出现之前,我们用回调函数来处理异步:

fetchData(function(result) {
  processResult(result, function(processed) {
    saveData(processed, function() {
      // ... 回调地狱
    });
  });
});

这叫 回调地狱(Callback Hell),问题不仅仅是嵌套难读,更重要的是 错误处理分散、状态不可控

Promise 的核心思路就两点:

  1. 状态机:pending → fulfilled / rejected,只能变一次
  2. 链式调用.then() 返回一个新的 Promise,实现"异步组合"

理解这两点,你就能自己动手写一个简化版 Promise。


2. MiniPromise 的核心结构

class MiniPromise {
  constructor(executor) {
    this.state = 'pending';
    this.value = undefined;
    this.onFulfilledCallbacks = [];
    this.onRejectedCallbacks = [];

    const resolve = (value) => this._resolve(value);
    const reject = (reason) => this._reject(reason);

    try {
      executor(resolve, reject);
    } catch (err) {
      reject(err);
    }
  }
}

就这?一个类,三个属性,两个数组?

对,就是这么裸。Promise 本质就是一个状态容器,所有的魔法都在 _resolve 方法里。


3. 核心逻辑:状态流转 + 异步执行

_resolve(value) {
  // 只能从 pending 变一次
  if (this.state !== 'pending') return;

  // 如果 value 是 Promise,需要"展开"
  if (value instanceof MiniPromise) {
    return value.then(this._resolve.bind(this), this._reject.bind(this));
  }

  this.state = 'fulfilled';
  this.value = value;

  // 异步执行回调 —— 这就是 then 可以链式调用的关键
  queueMicrotask(() => {
    this.onFulfilledCallbacks.forEach(cb => cb(this.value));
  });
}

等等,这里有个关键点:为什么要用 queueMicrotask?

因为 Promise 的设计原则是:then 的回调必须异步执行。这保证了执行顺序的可预测性,也是 Promise/A+ 规范的要求。


4. then 是怎么实现的?

then(onFulfilled, onRejected) {
  // 返回一个新的 Promise,这就是链式调用的秘密
  return new MiniPromise((resolve, reject) => {
    const handleCallback = (callback, value) => {
      try {
        // 如果没有传回调,直接透传 value
        const result = callback ? callback(value) : value;
        resolve(result); // 关键:resolve 的是回调的返回值
      } catch (err) {
        reject(err);
      }
    };

    if (this.state === 'fulfilled') {
      // 异步执行,保持一致性
      queueMicrotask(() => handleCallback(onFulfilled, this.value));
    } else if (this.state === 'rejected') {
      queueMicrotask(() => handleCallback(onRejected, this.value));
    } else {
      // pending 状态,先把回调存起来
      this.onFulfilledCallbacks.push(() => handleCallback(onFulfilled, this.value));
      this.onRejectedCallbacks.push(() => handleCallback(onRejected, this.value));
    }
  });
}

看到没?then 返回的是一个全新的 Promise,而不是直接返回结果。这个新 Promise 的 resolve 取决于回调函数的返回值——这,就是链式调用的本质。


5. 静态方法:Promise.resolve / Promise.reject

static resolve(value) {
  if (value instanceof MiniPromise) return value;
  return new MiniPromise(resolve => resolve(value));
}

static reject(reason) {
  return new MiniPromise((_, reject) => reject(reason));
}

简单到不用解释。


6. Promise.all 怎么写?

static all(promises) {
  return new MiniPromise((resolve, reject) => {
    const results = [];
    let completed = 0;

    if (promises.length === 0) return resolve([]);

    promises.forEach((p, i) => {
      MiniPromise.resolve(p).then(val => {
        results[i] = val;
        completed++;
        if (completed === promises.length) resolve(results);
      }, reject);
    });
  });
}

核心就一个:遍历 + 计数 + 全部成功才 resolve


7. 写完 MiniPromise,我学到了什么?

  1. Promise 不是什么魔法:就是一个有状态管理的异步容器,外加一套回调收集 + 异步调度机制

  2. 链式调用的本质:每个 .then() 返回一个新 Promise,上一个 then 的返回值成为下一个 then 的输入

  3. queueMicrotask 的作用:确保 then 的回调总是异步执行,这是 Promise 行为一致性的根基

  4. Promise.resolve 的"递归展开":这是 Promise 最难理解的部分——如果 resolve 的是一个 Promise,需要等它完成后再 fulfill 当前 Promise


结语

手写一遍之后,再看 Promise.allPromise.raceasync/await,你会发现它们都是建立在同一套机制上的延伸。

源码不是魔法,原理才是。


完整代码我已经整理到 GitHub,有兴趣的同学可以跑跑测试:

GitHub 地址(可替换为你的仓库)


记一次主题闪烁问题

为站点添加亮暗模式切换组件,却在黑暗模式下,遇到主题闪烁的问题,如图:

blinking.gif

主题初始化

添加切换组件之前,已经做好了亮暗模式的获取,即通过 window.matchMedia('(prefers-color-scheme: dark)') 获取信息,由于使用了 tailwindcss , 可控制 document 节点的 'dark' 类名切换页面亮暗模式。

在初始化站点亮暗模式之前,还注册了对 document 节点 class 变化的监听,根据有无 'dark' 类名,将亮暗模式信息持久化储存。

代码如下:

const getThemePreference = () => {
  if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
    return localStorage.getItem('theme');
  }
  return window.matchMedia('(prefers-color-scheme: dark)').matches
    ? 'dark'
    : 'light';
};
const isDark = getThemePreference() === 'dark';

if (typeof localStorage !== 'undefined') {
  const observer = new MutationObserver(() => {
    const isDark = document.documentElement.classList.contains('dark');
    localStorage.setItem('theme', isDark ? 'dark' : 'light');
  });
  observer.observe(document.documentElement, {
    attributes: true,
    attributeFilter: ['class'],
  });
}

document.documentElement.classList[isDark ? 'add' : 'remove']('dark');

主题闪烁

在浏览器暗黑模式下,进入页面,页面已经初始化为暗黑模式。但 ModeToggle 组件的渲染引发了主题闪烁。

组件代码如下:

import { Button } from '@/components/ui/button';
import { Sun, Moon } from 'lucide-react';
import { useState, useEffect } from 'react';

type Theme = 'light' | 'dark';

const ModeToggle = () => {
  const [theme, setTheme] = useState<Theme>('light');

  useEffect(() => {
    const isDark = document.documentElement.classList.contains('dark');
    setTheme(isDark ? 'dark' : 'light');
  }, []);

  useEffect(() => {
    const docClassList = document.documentElement.classList;
    if (theme === 'dark' && !docClassList.contains('dark')) {
      docClassList.add('dark');
    } else if (theme === 'light' && docClassList.contains('dark')) {
      docClassList.remove('dark');
    }
  }, [theme]);

  const handleClick = () => {
    setTheme(theme === 'light' ? 'dark' : 'light');
  };

  return (
    <Button size="icon" onClick={handleClick}>
      {theme === 'light' ? <Moon /> : <Sun />}
    </Button>
  );
};

export default ModeToggle;

分析一下执行流程。

组件将 theme 初始化为 'light'

初次渲染,依次执行组件的两个 useEffect

首先,是依赖项为空数组的 useEffect,此时,页面已经为暗黑模式,即 document 节点的 class 已经包含了 'dark' ,所以会执行 setTheme('dark')

接着依赖项为 themeuseEffect, 会执行 docClassList.remove('dark') , 将页面置为日间模式。

接着执行第二次渲染(由第一次渲染的 setTheme('dark') 触发),触发依赖项为 themeuseEffect

此时,theme'dark' , document 节点也没有了 'dark' 类,所以将执行 docClassList.add('dark') , 将之前变为日间模式的页面重置为暗黑模式。那个日间模式的持续时间非常短暂,所以就有了动图上看到的闪烁。

很明显,问题就在依赖项为 themeuseEffect 里面将页面置为日间模式的代码。

修复

于是我不再将 theme 初始化为 'light' ,而是给它一个 null 值,让依赖值为空的那个 useEffect 根据 document 的类名来决定设置 theme'light' 还是 'dark'

//...

type Theme = 'light' | 'dark' | null;

const ModeToggle = () => {
  const [theme, setTheme] = useState<Theme>(null);
  // ...
};

这样一来,主题闪烁消失了,暗黑模式下,组件的跳变也不见了,如图:

blinking-fix.gif

组件跳变问题

但是,又产生了新的问题,如下图,在日间模式下,刷新页面,右侧的 ModeToggle 组件会有一个跳变。

toggle-jump.gif

组件代码如下:

type Theme = 'light' | 'dark' | null;

const ModeToggle = () => {
  const [theme, setTheme] = useState<Theme>(null);

  useEffect(() => {
    const isDark = document.documentElement.classList.contains('dark');
    setTheme(isDark ? 'dark' : 'light');
  }, []);

  useEffect(() => {
    const docClassList = document.documentElement.classList;
    if (theme === 'dark' && !docClassList.contains('dark')) {
      docClassList.add('dark');
    } else if (theme === 'light' && docClassList.contains('dark')) {
      docClassList.remove('dark');
    }
  }, [theme]);

  const handleClick = () => {
    setTheme(theme === 'light' ? 'dark' : 'light');
  };

  return (
    <Button size="icon" onClick={handleClick}>
      {theme === 'light' ? <Moon /> : <Sun />}
    </Button>
  );
};

日间模式下的渲染流程如下:

第一次渲染, theme 初始值为 null

依次执行两个 useEffect 。依赖项为空数组的 useEffect 执行 setTheme('light') , 这将触发第二次渲染。由于 theme 值为 null ,依赖项为 themeuseEffect 不会对主题产生影响。

在组件返回的 JSX 部分,可看到 theme === 'light' ? <Moon /> : <Sun /> ,由于 themenull , 此时将渲染 Sun 图标,而不是预期的 Moon 图标。问题就在这里。

第二次渲染, theme 值为 'light'

执行依赖项为 themeuseEffect , document 节点并没有 'dark' 类名,页面保持日间主题状态。

在组件返回的 JSX 部分,此时渲染了正确的 Moon 图标。

两次渲染了不同的图标,所以会有跳变。

修复

那么再添加逻辑判断修复吗?可行是可行。不过既然基于 tailwindcss 的 'dark' 类名控制亮暗模式,何不也通过它来控制图标渲染?更准确来说,是通过 CSS 的变形,来确定如何渲染图标。代码如下:

const ModeToggle = () => {
  // ...

  return (
    <Button onClick={handleClick}>
      <Sun className="absolute rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
      <Moon className="rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
    </Button>
  );
};

可以看到,为两个图标添加了一些类,来控制它们的样式。

scale 相关:通过缩放,来控制图标的”显隐“。在日间模式下, Sun 图标缩小为 0%,不可见; Moon 图标大小为 100%,即初始大小。夜间模式同理。

absolute : 让 Sun 图标脱离文档流,由 Moon 撑起宽高,使得两个图标只占一个图标的空间。由于没有给 absoulute 元素设置位置偏移量,所以它的位置参照原本的 static 定位。假如不给 Sun 设置 absolute, 就会产生两个图标大小的空间,如图:

double-space.png

rotate 相关:在主题切换时,为图标提供旋转动画,优化体验。

修复效果如下:

日间模式:

light-fix.gif

夜间模式:

dark-fix.gif

执行上下文:变量提升、作用域与 this 底层机制

深入理解 JavaScript 执行上下文

1. 为什么需要执行上下文?

JavaScript 代码在执行前,引擎会先进行一次解析(Parsing)。这一步要完成:

  • 语法检查:有没有 SyntaxError
  • 变量/函数声明的收集:确定当前作用域中有哪些标识符。
  • 作用域规则的建立:决定变量从哪找、函数能否提前调用。

这些信息需要被存储在一个“环境盒子”里,以便在后续执行阶段使用。这个“环境盒子”就是执行上下文(Execution Context)

简单来说:执行上下文是 JS 引擎在代码执行前,为当前运行环境创建的执行环境结构, 用于记录变量、函数声明、作用域链以及 this 的绑定规则。

2. 执行上下文的类型

类型 说明 数量 何时销毁
全局执行上下文 (GEC) 最外层环境,浏览器中即 window 对象。 只有一个 页面关闭时
函数执行上下文 (FEC) 每次调用函数时创建。 每次调用创建一个 函数执行完毕后
eval 执行上下文 eval() 内的代码。 不常用

3. 执行上下文的生命周期

每个执行上下文都经历两个阶段:创建阶段执行阶段。如下图:

3.1 创建阶段(Creation Phase)

这是引擎“读懂代码”的阶段,主要做三件事:

  1. 创建变量对象(Variable Object, VO)
    • 收集当前作用域中所有 var 声明的变量 → 提升并初始化为 undefined
    • 收集所有函数声明 → 提升并完整保存函数体(可提前调用)。
    • 收集 letconst 声明的变量 → 提升但不初始化,存入词法环境并进入 暂时性死区(TDZ)

ES6 后,let/const 存储在独立的“词法环境”中,但理解上仍可认为“提升但不可访问”。

  1. 创建作用域链(Scope Chain)
    • 当前上下文的变量对象 + 所有父级上下文的变量对象。
    • 决定了变量查找的顺序:从当前开始,逐级向外,直到全局。
  2. 确定this的值
    • 全局上下文this创建阶段就永久绑定为全局对象(浏览器 window),执行阶段不会改变。
    • 函数上下文this创建阶段仅预留位置,不赋值,实际值在执行阶段(函数被调用时),由调用方式动态确定(普通调用、对象方法、call/apply/bind、构造函数、箭头函数等规则不同)。
    • 特殊:箭头函数无自身 this,继承外层词法作用域的 this

3.2 执行阶段(Execution Phase)

  • 代码逐行执行,变量被赋实际值,函数被调用,表达式求值。
  • 当执行到 let/const 声明行时,变量才完成初始化(离开 TDZ)。

4. 调用栈(Call Stack)

调用栈是 JS 引擎用来跟踪函数调用顺序的机制,遵循 后进先出(LIFO) 原则。如下图:

示例

function inner() { console.log('inner'); }
function outer() { inner(); }
outer();

栈变化过程

  1. 程序启动 → 压入 全局上下文
  2. 调用 outer() → 压入 outer 上下文
  3. outer 中调用 inner() → 压入 inner 上下文
  4. inner 执行完 → 弹出 inner 上下文
  5. outer 执行完 → 弹出 outer 上下文
  6. 页面关闭 → 弹出 全局上下文

5. 变量提升详解

5.1 var 的提升

console.log(a);  // undefined
var a = 10;

编译后等价于:

var a;           // 提升并初始化为 undefined
console.log(a);  // undefined
a = 10;

5.2 函数声明的提升(完整提升)

greet();         // 输出 "Hello"
function greet() {
  console.log("Hello");
}

函数声明连同函数体一起提升,所以可以在声明前使用。

5.3 letconst 的提升(暂时性死区)

console.log(b);  // ReferenceError: Cannot access 'b' before initialization
let b = 20;

let/const 也会提升,但从代码块开始到声明语句之间是 暂时性死区(TDZ),访问会报错。

5.4 函数表达式不提升

greet2();        // TypeError: greet2 is not a function
var greet2 = function() {
  console.log("Hi");
};

var greet2 提升为 undefined,调用时还不是函数。

5.5 函数声明与 var 声明的优先级

当同一作用域中同时存在函数声明var** 变量声明**(同名)时,函数声明的提升优先级更高

console.log(typeof foo);   // "function"
function foo() {}
var foo = 1;
console.log(typeof foo);   // "number"

编译阶段

  • 函数声明 function foo() {} 被提升,foo 指向函数。
  • var foo 声明被忽略(因为同名标识符已存在)。

执行阶段

  • 第一行输出 "function"
  • 执行到 var foo = 1 时,赋值覆盖为 1,第二行输出 "number"

规则:函数声明会覆盖同名的 var 变量声明(但不会覆盖后续赋值)。反过来,var 声明不会覆盖已存在的函数声明。

6. 变量环境 vs 词法环境(ES6+)

概念 存放内容 提升行为
变量环境 var 声明、函数声明 创建阶段初始化为 undefined 或函数引用
词法环境 letconst、块级作用域内的声明 提升但不初始化(TDZ)

查找变量时,先查词法环境,再查变量环境。

7. 执行上下文与闭包

闭包的本质:内部函数持有外部函数变量对象的引用,即使外部函数已执行完毕

function outer() {
  let word = 'Hello';
  function inner() {
    console.log(word);
  }
  return inner;
}
const fn = outer();
fn();  // 输出 'Hello'

原理

  • outer 执行时创建了变量对象(包含 word)。
  • inner 定义时,其内部属性 [[Scope]] 记录了当前作用域链(即 outer 的变量对象)。
  • outer 执行完毕弹出调用栈,但 inner 仍引用着 outer 的变量对象,所以 word 不会被回收。
  • 调用 fn() 时,inner 通过 [[Scope]] 找到 word,输出 'Hello'

8. 经典面试题

8.1 变量提升优先级(再次强调)

console.log(typeof foo);   // ?
function foo() {}
var foo = 1;
console.log(typeof foo);   // ?

答案"function""number"

8.2 暂时性死区陷阱

console.log(typeof x);   // ?
let x = 1;

答案ReferenceError(不是 "undefined")。
解释let x 的 TDZ 导致访问即报错,不会执行 typeof 运算。

8.3 循环中的 varlet

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// 输出:3 3 3

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// 输出:0 1 2

解释var 函数作用域,所有回调共享同一个 ilet 块级作用域,每次迭代创建新绑定。

8.4 执行上下文数量

function A() {
  function B() { }
  B();
}
A();

答案:3 个(全局 + A + B)。

9. 总结一句话

执行上下文是 JS 引擎在执行前为代码创建的环境盒子,用于存储变量、函数声明、作用域链和 this。它解释了变量提升、作用域、闭包等核心行为。var** 提升并初始化为 undefined,函数声明完整提升且优先级高于 varlet/const 提升但不初始化(TDZ)。调用栈以后进先出的方式管理函数执行顺序。

掌握执行上下文,你就掌握了 JS 作用域、闭包和 this 的底层原理。

前端DICOM Viewer开发避坑指南:从入门到实战(含切片、3D、标注全解析)

作为前端开发者,第一次接触医学影像DICOM开发时,我踩了无数坑:以为DICOM是普通图片、疑惑为什么CT只有灰度、不清楚3D标注怎么实现、纠结要不要自己做皮肤血管建模……

结合近期开发经验,整理了这篇实战指南,从基础原理到核心功能,再到避坑要点,帮你快速上手前端DICOM Viewer开发,避开我踩过的所有弯路,尤其适合前端新手入门医学影像领域。

一、先澄清一个核心认知:DICOM不是图片

刚开始开发时,我下意识以为.dcm文件是普通图片,直接用img标签加载,结果完全显示不了——这是第一个大坑。

DICOM(Digital Imaging and Communications in Medicine)是医学影像的国际标准,本质是二进制数据文件,不是图片。一个.dcm文件对应一张医学影像切片,一整套CT/MRI检查,其实是几百个.dcm文件的集合(相当于一整条面包,每片面包就是一个切片)。

我们平时去医院拿到的“一张胶片”,其实是医院把几百张切片缩成缩略图,拼成一张图打印出来的,并非原始DICOM数据——这也是很多人误以为“影像只有一张”的原因。

二、核心原理:前端怎么解析并显示DICOM?

前端解析DICOM的核心流程很简单,就3步:读文件→解析数据→渲染图像,全程依赖成熟库,不用自己从零造轮子。

1. 核心技术栈(前端首选)

  • 解析DICOM:dicom-parser(最成熟、轻量,负责把二进制DICOM转成前端能看懂的像素数据和元信息)
  • 2D/3D渲染:Cornerstone.js(2D切片)、Cornerstone3D(3D体积、MPR多平面重建)
  • 交互工具:cornerstone-tools(标注、测量、缩放平移,开箱即用)
  • 3D建模辅助(可选):vtk.js、three.js(用于渲染后端生成的3D模型)

2. 完整渲染流程(极简版)

// 1. 读取本地DICOM文件(input选择文件)
const file = document.getElementById('fileInput').files[0];
const reader = new FileReader();
reader.onload = (e) => {
  // 2. 解析DICOM二进制数据
  const byteArray = new Uint8Array(e.target.result);
  const dataSet = dicomParser.parseDicom(byteArray);
  
  // 3. 提取关键信息(患者信息、图像参数、像素数据)
  const patientName = dataSet.string('x00100010'); // 患者姓名
  const pixelData = new Uint16Array(
    byteArray.buffer,
    dataSet.elements.x7FE00010.dataOffset,
    dataSet.elements.x7FE00010.length / 2
  );
  
  // 4. 用Cornerstone渲染图像
  const element = document.getElementById('dicomImage');
  cornerstone.enable(element);
  const imageId = cornerstoneWADOImageLoader.wadouri.fileManager.add(file);
  cornerstone.loadImage(imageId).then(image => {
    cornerstone.displayImage(element, image);
  });
};
reader.readAsArrayBuffer(file);

三、高频疑问解答:这些坑我全踩过

开发过程中,很多疑问都是前端新手的共性问题,结合我自己的踩坑经历,逐一解答,帮你少走弯路。

疑问1:CT为什么只有灰度?能显示彩色吗?

CT原始数据是HU值(亨氏单位),范围是-1024~3000+,代表人体组织的密度,天生是灰度图——因为密度只有“高低”,没有“颜色”。

但可以显示彩色!彩色本质是“伪色彩映射(LUT)”,把灰度值(0~255)映射成彩虹、热力等颜色,比如PET影像常用的jet色卡。前端实现超简单,一行代码即可:

// Cornerstone切换彩色映射
cornerstone.setColorMap(element, 'jet'); // 医学常用彩色映射

注意:CT的彩色不是原始数据自带的,是前端渲染时添加的;而PET、超声等影像,部分原始数据本身就是彩色的。

疑问2:窗宽窗位到底是什么?为什么必须做?

这是前端DICOM开发的核心难点,也是我踩过的第二个大坑——刚开始没做窗宽窗位,渲染的CT图全黑或全白,根本看不清。

核心原因:CT原始HU值范围(-1024~3000+)有4000多个等级,而显示器只能显示256级灰度,无法全部显示,只能“截取一段”显示——这就是窗宽窗位的作用。

简单公式(前端可直接用):

// 窗位(WWL)= 要显示的中心值;窗宽(WW)= 要显示的范围
const lower = windowCenter - windowWidth / 2; // 显示下限
const upper = windowCenter + windowWidth / 2; // 显示上限
// 映射规则:低于下限→纯黑,高于上限→纯白,中间线性映射到0~255
function applyWindowLevel(pixelValue, windowCenter, windowWidth) {
  if (pixelValue < lower) return 0;
  if (pixelValue > upper) return 255;
  return ((pixelValue - lower) / (upper - lower)) * 255;
}

实际开发中,不用自己写这个算法,Cornerstone会自动应用窗宽窗位,我们只需要提供调节控件(滑块),让用户切换“看骨头”“看肺”“看软组织”即可。

疑问3:切片怎么实现上下翻页?

翻切片的原理超级简单,不是“切换图片”,而是“按顺序加载不同的.dcm文件”。

核心步骤:

  1. 让用户选择整个DICOM序列文件夹(或多选.dcm文件);
  2. 读取所有.dcm文件,按DICOM的ImagePositionPatient[2](Z轴坐标)排序(重点:不能按文件名排序,可能乱序);
  3. 用一个变量记录当前切片索引(currentIndex),上下按钮控制索引增减;
  4. 加载当前索引对应的.dcm文件,重新渲染即可。

用cornerstone-tools可以快速实现翻页,不用自己写复杂逻辑,几行代码就能搞定。

疑问4:标注怎么做?2D和3D标注有区别吗?

标注的核心是“在图像上画图形+存坐标”,前端只负责“画”和“存”,不用自己做复杂逻辑,cornerstone-tools内置了全套标注工具。

  • 2D标注(最常用):在单张切片上画矩形、线、点、文字,用于标记病灶、测量长度,直接激活工具即可;
  • 3D标注:在一整叠切片上标注,会贯穿多层切片,本质是“2D标注在Z轴上延伸+自动插值”,比如在第10层和第20层画轮廓,系统自动生成中间所有层的轮廓,形成3D立体标注。

标注数据可以用JSON格式保存,包含标注类型、坐标、切片索引等信息,传给后端即可,不用自己设计复杂格式。

疑问5:要显示皮肤、血管,需要前端建模吗?

这是最容易踩坑的点——很多新手会误以为,显示3D皮肤、血管,需要前端自己建模,其实完全不需要!

核心结论:建模是后端/算法团队的活,前端只负责“显示模型”。

流程:后端用ITK、VTK等算法,从DICOM序列中提取皮肤、骨骼、血管的表面,生成3D模型(如.obj、.stl格式),前端只需要用vtk.js或three.js加载模型,渲染、旋转、上色即可。

前端开发时,99%的业务场景都不需要自己做建模,除非是做AI科研、高端3D可视化项目(这种情况会有专门的算法团队配合)。

四、前端DICOM Viewer开发路线(从易到难,落地性强)

结合实际业务需求,推荐以下开发路线,不用追求一步到位,逐步迭代即可,符合企业实际开发流程:

  1. 基础版:单张DICOM显示 + 缩放、平移、重置视图;
  2. 进阶版:窗宽窗位调节 + 序列加载 + 上下翻切片;
  3. 实用版:2D标注(矩形、长度、点) + 标注保存/导出;
  4. 高级版:MPR多平面重建(冠状、矢状、轴位) + 简单3D体积预览;
  5. 终极版:加载后端3D模型 + 3D标注 + 体积测量。

重点:前3个版本是核心,满足90%的医学影像前端需求,先落地基础功能,再逐步迭代高级功能,避免一开始就陷入3D建模、AI识别等复杂需求。

五、常用测试资源(免费可用)

开发时需要测试DICOM文件,分享几个免费、匿名化、无版权的资源,直接下载就能用:

六、最后总结

前端DICOM Viewer开发,核心是“理解DICOM标准 + 用好成熟库 + 明确自身定位”:

  1. 不要把DICOM当普通图片,它是二进制数据,需要专门解析;

  2. 不用自己造轮子,dicom-parser、Cornerstone系列库足够覆盖所有需求;

  3. 前端的核心是“显示 + 交互 + 标注”,建模、AI识别等交给后端/算法团队;

  4. 从基础功能开始迭代,逐步落地高级功能,避免一开始就陷入复杂需求。

希望这篇指南能帮你避开前端DICOM开发的坑,快速上手实战。如果有具体的开发问题,也可以在评论区交流,一起探讨~

❌