普通视图

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

用 3100 个数字造一台计算机

作者 jump_jump
2026年4月8日 01:01

你有没有想过,一台计算机最少需要什么?

不是说你桌上那台——那个有几十亿个晶体管、跑着操作系统和浏览器的庞然大物。我说的是最本质的那个东西:能算数、能画画、能放音乐、能响应你的键盘和鼠标。

答案可能会让你意外:一个数组就够了。

Little Virtual Computer 是一台用 TypeScript 写的虚拟计算机,原作者是 jsdf。我在他的基础上做了不少重构和优化——把代码拆分成了清晰的模块结构,加了音频系统、断点调试、内存追踪、中英文切换等功能。3100 个内存槽位,23 条指令,你可以在上面写汇编程序,画像素,甚至播放一首 Chocolate Rain。打开链接就能玩,不用装任何东西。

接下来聊聊拆解和重构这台计算机的过程中,那些让我觉得"原来如此"的时刻。

"硬件"就是一行代码

这台计算机的内存,就是这行:

static ram: number[] = new Array(3100).fill(0)

3100 个数字,所有东西都住在里面——变量、程序、输入设备、屏幕、声卡:

地址 用途
0 - 999 工作内存(变量)
1000 - 1999 程序代码
2000 - 2051 键盘、鼠标、随机数、时钟
2100 - 2999 屏幕(30x30 像素)
3000 - 3008 声卡(3 个通道)

这就是"内存映射 I/O"。真实计算机里,显卡有自己的显存,声卡有自己的缓冲区,键盘通过中断传递信号。但在这里,一切都是内存地址。想在屏幕左上角画一个红色像素?往地址 2100 写个 2。想让扬声器发出正弦波?往地址 3001 写频率,地址 3000 写 3

第一次把重构后的代码跑起来,盯着屏幕上亮起的那个像素,我突然理解了一件事:CPU 不需要"知道"什么是屏幕。它只是往一个地址写了个数字,恰好有人在监听那个地址。 输入输出不需要特殊的指令,读写内存就是一切。

CPU 其实在做一件很无聊的事

读原作者的 CPU 代码时,我以为会很复杂。结果核心逻辑是这样的:

static step(trace: boolean = true) {
  if (trace) Memory.beginTrace();       // 需要调试时才追踪
  const opcode = this.advanceProgramCounter();  // 从内存读一个数
  const instructionName = this.opcodesToInstructions.get(opcode); // 数字变指令名
  const operands = instruction.operands.map(() => this.advanceProgramCounter()); // 再读几个数当参数
  instruction.execute.apply(null, operands);  // 执行
  if (trace) this.lastStepTrace = Memory.endTrace();
}

程序计数器从地址 1000 开始。读一个数,往前走一步。读到 9010?那是 add,再读三个数当参数,加一下,写回去。然后继续读下一个。没有流水线,没有分支预测,没有乱序执行。一个 while 循环,一直读数字、执行、读数字、执行。

这就是冯·诺依曼架构的全部:程序和数据住在同一片内存里,CPU 按顺序取指令执行。 你桌上那台电脑的 CPU,不管它有多少核、多少级缓存,本质上也在做同样的事——只是快了几十亿倍。

23 条指令够写一个游戏吗

一开始觉得不够。23 条指令,连函数调用都没有,能干什么?

结果发现,不只是够了,还能写出让人意外的东西。这 23 条指令分成五类:

搬运数据(5 条)—— 把值从一个地址复制到另一个,或者写入一个常量。还有两条指针操作,让你可以"地址 A 里存着地址 B,去 B 里取值"——间接寻址,这是实现数组遍历的关键。

算术(10 条)—— 加减乘除取模,每种都有两个版本:两个地址相加,或者一个地址加一个常量。add_constant counter 1 counter 就是 counter++

比较(2 条)—— 比较两个值,结果是 -1、0 或 1。没有布尔值,没有大于小于等于,就一个三态数字。刚开始觉得别扭,后来发现这样反而更灵活。

跳转(5 条)—— jump_to 无条件跳转,branch_if_equal 条件跳转。没有 for 循环?跳回去就是循环。没有 if-else?跳过去就是 else。

系统(3 条)—— data 嵌入原始数据,break 暂停调试,halt 终止。

用这些东西,能写出画板程序、弹球、乒乓球游戏,甚至音乐播放器。

从文本到数字

手动往内存里填操作码太痛苦了,所以需要一个汇编器。你写这样的文本:

define counter 0
define limit 10
copy_to_from_constant counter 0
Loop:
  add_constant counter 1 counter
  branch_if_not_equal_constant counter limit Loop
halt

汇编器把它变成内存里的一串数字:9001 0 0 9011 0 1 0 9104 0 10 1003 9999

过程本身很有启发性。define 给地址起名字,Loop: 标记跳转目标。汇编器用经典的两遍扫描:第一遍收集所有标签的地址(这样你可以先 jump_to SomeLabel,后面再定义 SomeLabel:),第二遍把指令名替换成操作码,把标签和变量名替换成数字,逐个写入程序内存。

所谓"编译",最原始的形态就是这样——把人能读的东西翻译成机器能读的数字。

900 个像素的屏幕

30x30,900 个像素。听起来少得可怜。

但当你亲手用汇编一个像素一个像素地画出一个弹跳的小球时,你会对"像素"这个词产生全新的理解。每个像素就是一个内存地址,颜色就是 0 到 15 的一个数字。像素地址 = 2100 + y * 30 + x。16 种颜色:黑、白、红、绿、蓝、黄、青、品红、银、灰、栗、橄榄、深绿、紫、蓝绿、海军蓝。

渲染做了分场景优化:慢放模式下追踪"脏像素",只更新被写过的像素,被写入的像素还会短暂闪白,让你看到程序正在画什么——慢放下看着像素一个一个亮起来,有种看延时摄影的感觉。全速模式则跳过逐像素追踪,直接全量重绘,因为每帧都有大量像素变化,追踪反而是浪费。

用内存地址弹钢琴

音频部分是我最喜欢的设计。三个独立的振荡器通道,每个通道就是三个连续的内存地址:波形、频率、音量。

地址 3000: 波形 (0=方波, 1=锯齿波, 2=三角波, 3=正弦波)
地址 3001: 频率 (值 / 1000 = Hz)
地址 3002: 音量 (0-100)

往这几个地址写数字,声音就出来了。改个数字,音调就变了。

内置的 ChocolateRain 程序用两个通道演奏了一首完整的曲子。音乐数据全部用 data 指令嵌入在程序里——本质上就是一个大数组,记录着"第几拍、哪个通道、什么频率、多大音量"。程序读取当前时间,算出现在是第几拍,然后去数组里找对应的音符,写入音频内存。

一首歌,就是一个按时间索引的数组。

调试器:这才是重点

说实话,这台虚拟计算机最有价值的部分不是 CPU,不是显示器,不是音频——是调试器。

点"单步",程序计数器往前走一步。你能看到它读了哪个地址(蓝色高亮),写了哪个地址(橙色高亮)。设个断点,程序跑到那里自动停下来。把速度拉到慢放,看着弹球程序一帧一帧地擦掉旧位置、算出新位置、画上新像素。

我见过很多人学编程时卡在"不知道程序在干什么"。代码写完,跑起来,结果不对,然后就懵了。这台计算机的调试器让一切都暴露在外面:每一步读了什么、写了什么、程序计数器在哪里。没有黑箱,没有抽象层,你看到的就是全部。

六个程序,六种"原来如此"

内置的六个示例程序,每个都在教一件事:

Add —— 4 + 4 = 8。三行代码,结果存在地址 2。这是"指令怎么工作"的最小演示。

RandomPixels —— 用一个指针从地址 2100 扫到 2999,每个位置写一个随机颜色,然后从头再来。满屏闪烁的彩色像素,其实只是一个循环在往内存里写数字。

Paint —— 屏幕顶部一行是 16 色调色板,点击选色,然后在画布上画。鼠标位置就是一个内存地址里的数字,点击就是另一个地址从 0 变成 1。

BouncingBall —— 白色小球弹来弹去。用 Date.now() 控制帧率,每 60ms 更新一次位置,碰到边界就反转方向。这是"游戏循环"的最小实现。

MiniPong —— 乒乓球。两个挡板,一个球,碰到挡板反弹,错过就重置。这是最复杂的示例,用到了几乎所有指令。读完它的代码,你会对"游戏不过是一堆条件判断"有切身体会。

ChocolateRain —— 用汇编写的音乐播放器。理解这个程序怎么工作,就理解了数据驱动编程的本质。

重构与实现细节

原作者 jsdf 的实现是一个完整的单体,功能齐全但耦合度较高。我把它拆成了独立模块——CPU、内存、显示器、音频、输入、汇编器——通过内存这个"总线"连接,加了 TypeScript 类型系统。

拆的过程本身就是一次学习。当你必须决定"这个职责属于 CPU 还是属于 Memory"的时候,你对计算机架构的理解会变得非常具体。

架构

项目分成两个独立的 bundle:

src/index.ts    → dist/computer.module.js   (核心计算机)
src/simulator.ts → dist/simulator.module.js  (模拟器 UI)

index.ts 初始化所有硬件组件,返回一个 Computer 接口对象——这是两层之间唯一的契约。模拟器只通过这个接口操作计算机,不直接碰内部类。换掉整个计算机实现,只要接口不变,模拟器照常工作。

几个有意思的实现决策

内存布局用 const enum——MemoryPosition 定义所有地址常量,编译后直接内联为数字,零运行时开销。改一个数字,整台计算机的内存布局就变了。这就是"硬件规格"。

指令是数据驱动的——每条指令是一个对象,包含名称、操作码、操作数描述和执行函数。operands 数组不只是文档——汇编器用它验证操作数数量,调试器用它显示操作数含义。一份数据,三个用途。

流程控制指令直接改程序计数器——jump_to 的 execute 就是 CPU.programCounter = labelAddress。这形成了循环依赖(CPU → instructions → CPU),更"干净"的做法是把 CPU 状态作为参数传入,但在这个规模的项目里,简单直接比架构纯洁更重要。

性能:在不同场景下做不同的事

性能优化的核心思路不是"让代码更快",而是"在不同场景下做不同的事"——和真实系统的优化思路一样。

全速模式用帧预算策略:用 performance.now() 在每帧 14ms 的预算内尽量多跑 CPU 周期(留 2ms 给浏览器渲染和 GC),用 requestAnimationFrame 和屏幕刷新率同步。同时跳过内存追踪和调试面板更新,显示器切换到全量重绘。

慢放模式每次只执行一条指令,开启内存读写追踪,更新所有调试面板,显示器用脏像素增量重绘。

音频也做了状态缓存——用 state 对象记录上一次的参数值,只在值真正变化时才调用 Web Audio API,避免每帧 9 次无意义的 API 调用。CPU 停止时只需静音所有通道然后立即返回。

其他细节:内存重置用 Array.fill(0) 替代 for 循环;endTrace() 复用同一个对象避免每周期分配新数组;显示器用预计算的 Uint8Array 颜色查找表,位移 << 2 代替乘法索引;程序内存视图用虚拟滚动,只渲染可见区域 ± 10 行。

最后

折腾这台计算机的过程中,我反复体会到一件事:我们日常使用的那些抽象——变量、循环、函数、屏幕、声音——在最底层都是同一个东西:往一个地址读一个数字,或者写一个数字。

3100 个数字,23 条规则。这就是一台计算机的全部。

不信的话,打开试试:wsafight.github.io/little-virt…

点"单步",看看你的程序在做什么。


原项目:github.com/jsdf/little…

重构版源码:github.com/wsafight/li…

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

作者 徐小夕
2026年4月7日 23:07

最近一直在深耕 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

作者 nanfeiyan
2026年4月7日 22:16
# 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
```
昨天 — 2026年4月7日掘金 前端

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

作者 码云之上
2026年4月7日 19:35

一个 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倍

作者 前端Hardy
2026年4月7日 18:12

还在把 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 编程效率翻倍

作者 前端Hardy
2026年4月7日 18:05

还在每天教 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快速集成多链钱包连接:从“连不上”到丝滑切换的踩坑实录

作者 竹林818
2026年4月7日 18:01

背景

上个月,我接手了一个新的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)

作者 糟糕好吃
2026年4月7日 17:58

前言

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倍的“闪电侠”,原理竟然这么简单?

作者 kyriewen
2026年4月7日 17:31

听说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 中间件框架的基石

作者 米丘
2026年4月7日 16:33

在 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 把它管成“右侧打工人”

作者 jerrywus
2026年4月7日 16:16

先说一个真实场景。

你让 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

2026年4月7日 16:15

前言

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 地址(可替换为你的仓库)


记一次主题闪烁问题

2026年4月7日 15:11

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

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 底层机制

2026年4月7日 14:55

深入理解 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、标注全解析)

作者 孙凯亮
2026年4月7日 14:54

作为前端开发者,第一次接触医学影像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开发的坑,快速上手实战。如果有具体的开发问题,也可以在评论区交流,一起探讨~

NestJS + TypeScript 全栈项目骨架实战

2026年4月7日 14:41

对前端转全栈来说,NestJS + TypeScript 是「零语言切换成本、快速落地」的最优解。这一章,我们不聊理论,只做「手把手实操」—— 从环境准备到项目骨架搭建,再到第一个接口开发,全程带代码、带命令,让你 1 小时内跑通全栈项目基础架构。

核心目标:搭建一个「前端可调用、支持 AI API 对接、带数据库连接」的全栈后端骨架,为后续 AI 功能落地打基础。

一、前置环境准备(必做,5 分钟搞定)

NestJS 基于 Node.js,所以先确保你的环境满足要求,按以下步骤操作:

1. 安装 Node.js(核心依赖)

  • 要求:Node.js 版本 ≥ 18.x(推荐 18.17.0 或 20.x,LTS 版本更稳定)
  • 下载地址:Node.js官网(选对应系统的 LTS 版本)
  • 验证:安装完成后,打开终端输入以下命令,能显示版本号即成功:
node -v # 输出 v18.17.0 之类的版本号
npm -v  # 输出 9.x 或 10.x 版本号

2. 安装 Nest CLI(项目脚手架,必装)

Nest CLI 能快速创建项目、生成模块 / 控制器 / 服务,前端同学可以理解为「Nest 版的 Vue CLI/Create React App」。

终端执行以下命令全局安装:

npm install -g @nestjs/cli

验证:输入 nest -v,显示版本号即成功(如 10.3.0)。

3. 可选工具(提升开发效率)

  • 代码编辑器:推荐 VS Code,安装以下插件:
    • ESLint(代码校验)
    • Prettier(代码格式化)
    • NestJS Snippets(Nest 语法提示)
    • Prisma(若选 Prisma 数据库,提前安装)
    • TypeORM(若选 TypeORM 数据库,提前安装)
  • 终端:Windows 推荐 PowerShell/Windows Terminal,Mac/Linux 用自带终端即可。
  • API 调试工具:Postman 或 Apifox(后续测试接口用)。

二、创建 NestJS 项目(10 分钟搞定)

1. 初始化项目

终端进入你想存放项目的文件夹(如 ~/projects),执行以下命令创建项目:

# nest new 项目名(推荐用英文,比如 ai-fullstack-demo)
nest new ai-fullstack-demo

执行后会出现选项:

  • 选择包管理器:推荐选 npm(最通用,避免后续依赖问题)
  • 等待安装依赖(约 1-3 分钟,取决于网络)

2. 项目目录结构解析(前端视角看懂核心目录)

安装完成后,用 VS Code 打开项目,核心目录结构如下(不用记,先有个印象):

ai-fullstack-demo/
├── src/                  # 核心代码目录(所有业务逻辑写这里)
│   ├── app.controller.ts # 控制器(处理路由、接收请求)→ 类似前端的路由配置
│   ├── app.service.ts    # 服务(处理业务逻辑)→ 类似前端的工具函数/API 封装
│   ├── app.module.ts     # 根模块(项目入口,整合所有功能模块)→ 类似前端的入口文件
│   └── main.ts           # 项目启动文件(配置端口、中间件等)
├── package.json          # 依赖配置(和前端一样)
├── tsconfig.json         # TypeScript 配置(前端同学熟悉的配置文件)
└── nest-cli.json         # Nest CLI 配置(无需修改,默认即可)

对前端同学的通俗解释:

  • 控制器(Controller):负责「接收请求」—— 比如前端调用 /api/ai/generate,就由对应的控制器处理路由;
  • 服务(Service):负责「处理逻辑」—— 比如调用 OpenAI API、操作数据库,都写在 Service 里;
  • 模块(Module):负责「整合功能」—— 比如把 AI 相关的控制器、服务、数据库模型打包成一个 AiModule,结构清晰。

3. 启动项目,验证环境

终端进入项目根目录,执行启动命令:

cd ai-fullstack-demo
npm run start:dev # 开发模式启动(热更新,改代码不用重启服务)

启动成功后,终端会显示:

[Nest] 12345  - 2026/04/07 10:00:00     LOG [NestFactory] Starting Nest application...
[Nest] 12345  - 2026/04/07 10:00:01     LOG [InstanceLoader] AppModule dependencies initialized +100ms
[Nest] 12345  - 2026/04/07 10:00:01     LOG [NestApplication] Nest application successfully started +50ms

打开浏览器访问 http://localhost:3000,能看到 Hello World! 即说明项目启动成功!

三、搭建核心模块(全栈骨架核心,30 分钟搞定)

我们要搭建「用户模块 + AI 模块 + 数据库连接」的基础骨架,后续所有功能(如 AI 生成代码、用户登录)都基于这个结构扩展。

1. 用 Nest CLI 快速生成模块(高效不手写)

Nest CLI 支持自动生成模块、控制器、服务,避免手动创建文件和配置,终端执行以下命令:

# 生成用户模块(处理用户登录、注册等)
nest generate module modules/user
nest generate controller modules/user # 生成用户控制器
nest generate service modules/user   # 生成用户服务
# 生成 AI 模块(处理 AI API 调用、生成功能等)
nest generate module modules/ai
nest generate controller modules/ai
nest generate service modules/ai

执行后,项目会新增 src/modules 目录,自动创建 user 和 ai 两个模块,且会自动在根模块 app.module.ts 中导入(不用手动配置,太香了!)。

2. 配置 TypeScript(前端友好,统一类型规范)

打开 tsconfig.json,确保以下配置(默认已配置,重点看这几项):

{
  "compilerOptions": {
    "module": "commonjs",
    "declaration": true,
    "removeComments": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true, // 启用装饰器(Nest 核心特性)
    "target": "ES2021", // 目标 ES 版本,兼容 Node.js 18+
    "sourceMap": true,
    "outDir": "./dist",
    "baseUrl": "./",
    "incremental": true,
    "skipLibCheck": true,
    "strictNullChecks": true, // 严格 null 检查(避免 undefined 报错,前端熟悉)
    "noImplicitAny": true, // 禁止隐式 any 类型(强制写类型,更规范)
    "strictBindCallApply": true,
    "forceConsistentCasingInFileNames": true
  }
}

这些配置和前端项目的 TS 配置基本一致,前端同学不用额外学习。

3. 数据库选型与连接(TypeORM vs Prisma 二选一,前端友好)

全栈项目离不开数据库,NestJS 最常用的两种 ORM 工具是 TypeORMPrisma。两者各有优势,前端同学可根据自身情况选择,以下先对比核心差异,再分别给出实操步骤:

3.1 TypeORM vs Prisma 核心对比(前端视角)
对比维度 TypeORM Prisma 前端转全栈适配度
核心定位 传统 ORM(对象关系映射) 下一代 ORM(类型安全查询构建器) 两者均高,Prisma 更易上手
类型安全 依赖 TypeScript 装饰器,需手动定义类型 自动生成类型,零手动维护 Prisma 更优(前端熟悉的 “自动类型” 逻辑)
模型定义 用「实体类 + 装饰器」映射数据库表 用「Prisma Schema DSL」定义模型 TypeORM 更贴近前端 “类 + 装饰器” 思维;Prisma 更简洁
学习成本 中(需学装饰器、Repository、查询构建器) 低(语法简洁,类似写 interface) Prisma 更低(前端无额外认知负担)
开发效率 中等(查询需拼接 Repository 方法) 高(链式查询 + 自动补全,少写冗余代码) Prisma 更优
生态适配 NestJS 官方推荐,支持所有数据库 NestJS 无缝集成,支持主流数据库 持平
迁移体验 命令行生成迁移文件,需手动调整 SQL 声明式迁移,自动生成 SQL,支持回滚 Prisma 更友好(前端不用懂复杂 SQL)
调试体验 需打印 SQL 调试,类型错误运行时才暴露 编译时类型校验,Prisma Studio 可视化调试 Prisma 更优

选型建议

  • 若你 已用 TypeORM 或熟悉类 + 装饰器语法(比如 React 装饰器),选 TypeORM,无缝衔接前端思维;
  • 若你 刚起步、怕麻烦、想少写代码,选 Prisma,自动类型提示 + 可视化工具,开发效率拉满。
3.2 方案一:TypeORM + SQLite(适合已有 TypeORM 经验的同学)

SQLite 是文件型数据库(不用安装服务,零配置,适合开发阶段),TypeORM 是 NestJS 官方推荐 ORM,以下是完整实操:

步骤 1:安装 TypeORM 依赖
npm install @nestjs/typeorm typeorm sqlite3
步骤 2:定义 TypeORM 实体(数据库表结构)

新建 src/modules/user/entities/user.entity.ts:

import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
import { AiRecord } from '../../ai/entities/ai-record.entity';
@Entity('users') // 映射数据库表 users
export class User {
  @PrimaryGeneratedColumn('uuid') // 主键,UUID 类型
  id: string;
  @Column({ unique: true, length: 64 }) // 唯一字段,字符串类型
  username: string;
  @Column({ unique: true, length: 128 })
  email: string;
  @Column({ length: 255 })
  password: string;
  @CreateDateColumn({ name: 'created_at' }) // 自动维护创建时间
  createdAt: Date;
  @UpdateDateColumn({ name: 'updated_at' }) // 自动维护更新时间
  updatedAt: Date;
  @OneToMany(() => AiRecord, (aiRecord) => aiRecord.user) // 一对多关联(关联 AI 生成记录)
  aiRecords: AiRecord[];
}

新建 src/modules/ai/entities/ai-record.entity.ts:

import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
import { User } from '../../user/entities/user.entity';
@Entity('ai_records') // 映射数据库表 ai_records
export class AiRecord {
  @PrimaryGeneratedColumn('uuid')
  id: string;
  @Column('text') // 长文本字段(存储 AI 生成内容)
  content: string;
  @Column({ length: 32 }) // 生成类型(如 "code"、"text")
  type: string;
  @Column({ length: 32, default: 'success' }) // 状态(success/error)
  status: string;
  @Column({ name: 'user_id' }) // 外键字段(关联用户表)
  userId: string;
  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;
  @UpdateDateColumn({ name: 'updated_at' })
  updatedAt: Date;
  @ManyToOne(() => User, (user) => user.aiRecords) // 多对一关联
  @JoinColumn({ name: 'user_id' }) // 显式指定外键列名,避免歧义
  user: User;
}
步骤 3:配置 TypeORM 数据源

新建 src/config/typeorm.config.ts:

import { DataSource } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import { User } from '../modules/user/entities/user.entity';
import { AiRecord } from '../modules/ai/entities/ai-record.entity';
export const getTypeOrmConfig = (configService: ConfigService) => ({
  type: 'sqlite', // 数据库类型:SQLite
  database: configService.get('DATABASE_URL') || 'dev.db', // 数据库文件(自动生成)
  entities: [User, AiRecord], // 注册实体(数据库表映射)
  synchronize: false, // 生产环境禁用!用迁移管理表结构
  migrations: ['dist/src/migrations/*.js'], // 迁移文件路径
  migrationsTableName: 'migrations', // 迁移记录表名
});

在根模块 src/app.module.ts 中导入 TypeORM 配置:

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UserModule } from './modules/user/user.module';
import { AiModule } from './modules/ai/ai.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { getTypeOrmConfig } from './config/typeorm.config';
@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }), // 加载环境变量(.env 文件)
    TypeOrmModule.forRootAsync({
      useFactory: getTypeOrmConfig,
      inject: [ConfigService], // 注入配置服务
    }),
    UserModule,
    AiModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
步骤 4:生成数据库表(迁移命令)
# 1. 生成迁移文件(基于实体类变更)
npx typeorm-ts-node-commonjs migration:generate src/migrations/init-tables --dataSource src/config/typeorm.config.ts
# 2. 执行迁移(创建数据库表)
npx typeorm-ts-node-commonjs migration:run --dataSource src/config/typeorm.config.ts

执行成功后,项目根目录会生成 dev.db 数据库文件,表结构与实体类一致。

3.3 方案二:Prisma + SQLite(适合新手、追求高效的同学)

Prisma 是 TypeScript 友好的 ORM 工具,自动生成类型,不用懂 SQL,以下是完整实操:

步骤 1:安装 Prisma 依赖
npm install prisma --save-dev
npm install @prisma/client
步骤 2:初始化 Prisma
npx prisma init

执行后会生成:

  • prisma/schema.prisma:数据库模型配置文件(定义表结构);
  • .env:环境变量文件(默认生成 DATABASE_URL,配置数据库连接地址)。
步骤 3:配置数据库连接(SQLite)

打开 .env 文件,修改 DATABASE_URL 为 SQLite 连接地址:

# 原配置(PostgreSQL)注释掉,替换为以下内容
DATABASE_URL="file:./dev.db" # SQLite 数据库文件(会自动生成在 prisma 目录下)
步骤 4:定义 Prisma 模型(数据库表结构)

打开 prisma/schema.prisma,替换为以下代码:

generator client {
  provider = "prisma-client-js"
}
datasource db {
  provider = "sqlite" // 数据库类型:SQLite
  url      = env("DATABASE_URL") // 连接地址(从 .env 读取)
}
// 用户表(存储用户信息,后续登录用)
model User {
  id        String      @id @default(uuid()) // 主键,自动生成 UUID
  username  String      @unique // 用户名(唯一)
  email     String      @unique // 邮箱(唯一)
  password  String      // 密码(后续会加密)
  createdAt DateTime    @default(now()) // 创建时间
  updatedAt DateTime    @updatedAt // 更新时间
  aiRecords AiRecord[]  // 关联 AI 生成记录(一对多)
}
// AI 生成记录表(存储 AI 生成的内容,如代码、文案)
model AiRecord {
  id        String   @id @default(uuid())
  content   String   // 生成的内容(如代码字符串)
  type      String   // 生成类型(如 "code"、"text")
  status    String   @default("success") // 状态(success/error)
  userId    String   // 关联的用户 ID
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  user      User     @relation(fields: [userId], references: [id]) // 关联用户表
}
步骤 5:生成数据库和 Prisma 客户端
npx prisma migrate dev --name init
  • --name init:给这次数据库迁移起个名字(初始化);
  • 执行成功后,会生成 prisma/dev.db 数据库文件,且自动生成 TypeScript 客户端(用于操作数据库)。
步骤 6:封装 Prisma 全局服务
nest generate service prisma

打开 src/prisma/prisma.service.ts,替换为以下代码:

import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  // 模块初始化时连接数据库
  async onModuleInit() {
    await this.$connect();
  }
}

打开 src/prisma/prisma.module.ts,修改为全局模块:

import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global() // 标记为全局模块,所有模块无需导入即可使用
@Module({
  providers: [PrismaService],
  exports: [PrismaService], // 导出服务,供其他模块使用
})
export class PrismaModule {}

在根模块 src/app.module.ts 中导入 PrismaModule:

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UserModule } from './modules/user/user.module';
import { AiModule } from './modules/ai/ai.module';
import { PrismaModule } from './prisma/prisma.module'; // 导入 Prisma 模块
@Module({
  imports: [PrismaModule, UserModule, AiModule], // 加入全局模块
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

四、开发第一个接口(验证骨架可用性,二选一)

根据你选择的数据库方案,以下分别给出 TypeORM 和 Prisma 版本的 AI 生成接口,验证模块、数据库、路由是否正常工作:

方案一:TypeORM 版本接口

步骤 1:定义请求参数 DTO

新建 src/modules/ai/dto/generate-text.dto.ts:

// 定义 AI 生成请求的参数类型(前端可复用)
export class GenerateTextDto {
  prompt: string; // 提示词(如 "写一段前端学习文案")
  type: string; // 生成类型(如 "text")
  userId: string; // 关联的用户 ID(测试用,后续替换为登录用户)
}
步骤 2:编写 AI 服务逻辑

打开 src/modules/ai/ai.service.ts,替换为以下代码:

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AiRecord } from './entities/ai-record.entity';
import { GenerateTextDto } from './dto/generate-text.dto';
@Injectable()
export class AiService {
  // 注入 AiRecord 实体的 Repository(操作数据库)
  constructor(@InjectRepository(AiRecord) private aiRecordRepo: Repository<AiRecord>) {}
  // 模拟 AI 生成文本(后续替换为真实 LLM API 调用)
  async generateText(dto: GenerateTextDto) {
    // 1. 模拟 AI 生成结果(实际项目中替换为 OpenAI/通义千问 API 调用)
    const generatedContent = `AI 生成结果(基于提示词:${dto.prompt}):前端转全栈,用 NestJS + TypeORM 真的太香了!`;
    // 2. 把生成结果存入数据库
    const aiRecord = this.aiRecordRepo.create({
      content: generatedContent,
      type: dto.type,
      userId: dto.userId,
    });
    await this.aiRecordRepo.save(aiRecord);
    // 3. 返回结果(包含数据库记录 ID)
    return {
      success: true,
      data: {
        recordId: aiRecord.id,
        content: generatedContent,
      },
    };
  }
}
步骤 3:定义接口路由

打开 src/modules/ai/ai.controller.ts,替换为以下代码:

import { Controller, Post, Body } from '@nestjs/common';
import { AiService } from './ai.service';
import { GenerateTextDto } from './dto/generate-text.dto';
@Controller('api/ai') // 路由前缀:所有接口都以 /api/ai 开头
export class AiController {
  constructor(private readonly aiService: AiService) {}
  // 定义 POST 接口:/api/ai/generate-text
  @Post('generate-text')
  async generateText(@Body() dto: GenerateTextDto) {
    return this.aiService.generateText(dto);
  }
}

方案二:Prisma 版本接口

步骤 1:定义请求参数 DTO

新建 src/modules/ai/dto/generate-text.dto.ts:

// 定义 AI 生成请求的参数类型(前端可复用)
export class GenerateTextDto {
  prompt: string; // 提示词(如 "写一段前端学习文案")
  type: string; // 生成类型(如 "text")
  userId: string; // 关联的用户 ID(测试用,后续替换为登录用户)
}
步骤 2:编写 AI 服务逻辑

打开 src/modules/ai/ai.service.ts,替换为以下代码:

import { Injectable } from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import { GenerateTextDto } from './dto/generate-text.dto';
@Injectable()
export class AiService {
  constructor(private prisma: PrismaService) {} // 注入 Prisma 全局服务
  // 模拟 AI 生成文本(后续替换为真实 LLM API 调用)
  async generateText(dto: GenerateTextDto) {
    // 1. 模拟 AI 生成结果(实际项目中替换为 OpenAI/通义千问 API 调用)
    const generatedContent = `AI 生成结果(基于提示词:${dto.prompt}):前端转全栈,用 NestJS + Prisma 开发效率翻倍!`;
    // 2. 把生成结果存入数据库
    const aiRecord = await this.prisma.aiRecord.create({
      data: {
        content: generatedContent,
        type: dto.type,
        userId: dto.userId,
      },
    });
    // 3. 返回结果(包含数据库记录 ID)
    return {
      success: true,
      data: {
        recordId: aiRecord.id,
        content: generatedContent,
      },
    };
  }
}
步骤 3:定义接口路由

打开 src/modules/ai/ai.controller.ts,替换为以下代码:

import { Controller, Post, Body } from '@nestjs/common';
import { AiService } from './ai.service';
import { GenerateTextDto } from './dto/generate-text.dto';
@Controller('api/ai') // 路由前缀:所有接口都以 /api/ai 开头
export class AiController {
  constructor(private readonly aiService: AiService) {}
  // 定义 POST 接口:/api/ai/generate-text
  @Post('generate-text')
  async generateText(@Body() dto: GenerateTextDto) {
    return this.aiService.generateText(dto);
  }
}

步骤 4:测试接口(用 Postman/Apifox)

  1. 确保项目处于运行状态(npm run start:dev);
  2. 打开 Postman,创建一个 POST 请求,地址:http://localhost:3000/api/ai/generate-text
  3. 请求体(Body)选择 raw → JSON,输入以下参数:
{
  "prompt": "写一段前端学习文案",
  "type": "text",
  "userId": "test-user-123" // 测试用用户 ID,后续替换为真实用户
}
  1. 发送请求,成功返回以下结果即说明接口正常工作:
{
  "success": true,
  "data": {
    "recordId": "xxx-xxx-xxx-xxx", // 自动生成的记录 ID
    "content": "AI 生成结果(基于提示词:写一段前端学习文案):前端转全栈,用 NestJS + XXX 真的太香了!"
  }
}

同时,数据库中会新增一条 AI 生成记录,验证数据库连接成功。

五、项目骨架总结与后续扩展

到这里,我们的 NestJS + TypeScript 全栈项目骨架已经搭建完成,包含:

✅ 基础环境配置(Node.js + Nest CLI + TypeScript);

✅ 核心模块结构(用户模块 + AI 模块 + 数据库模块);

✅ 数据库连接(TypeORM/Prisma 二选一,支持类型安全);

✅ 测试接口(AI 生成文本,包含数据库存储);

✅ 前端友好的类型定义(前后端可复用 interface/dto)。

这个骨架的优势:

  1. 前后端 TypeScript 类型互通 —— 前端可直接复用后端的 GenerateTextDto 类型,避免字段不一致;
  2. 模块化清晰 —— 后续新增功能(如用户登录、AI 生成代码),只需新增对应模块;
  3. 可扩展性强 —— 后续替换为真实 LLM API、切换数据库(如 MySQL/PostgreSQL)、添加权限校验,都能基于这个骨架快速扩展。

下一章预告

下一章,我们将介绍AI本地化基础,了解 AI 本地化部署的项目整体思路。

使用 devServer Proxy 本地开发 POST 请求跨域报错问题及解决方案

作者 UndefinedLuo
2026年4月7日 14:22

在本地开发中,我遇到一个比较奇怪的问题:通过 devServer 的 proxy 转发接口请求,理论上浏览器看到的是同源请求,不应该触发跨域限制,但实际情况如下:

  • GET 请求:正常返回
  • POST 请求:失败,服务端基于 Origin 校验返回错误

问题分析

虽然浏览器同源访问没有跨域限制,但服务端对 Origin 做了安全校验:

  • GET 请求:浏览器通常不携带 Origin 头 → 服务端允许
  • POST 请求:浏览器会自动携带 Origin 头 → 服务端检查失败 → 报错

换句话说,这不是浏览器跨域机制导致的错误,而是服务端基于 Origin 的安全策略导致的。

解决方案

通过 devServer 的 proxy 修改请求头即可:

onProxyReq: (proxyReq) => {
  proxyReq.setHeader('Origin', 'xxx'); // 修改为服务端允许的 Origin
}

这样浏览器请求仍然是同源,服务端也能通过安全校验,问题解决。

相关知识点梳理

1. 跨域本质

  • 浏览器的 同源策略限制的是 读取响应,不是发送请求
  • 也就是说:请求一定可以发出去,但如果不符合跨域策略,浏览器会阻止 JS 访问响应数据

2. 请求分类

  • 简单请求 → 直接发 → 再根据响应头决定是否允许 JS 读取
  • 复杂请求 → 先发预检 → 检查允许的 Methods/Headers → 再发实际请求

3. CORS 是“放行机制”

  • 服务端通过 CORS 响应头告诉浏览器:“可以让前端访问我的资源”
  • 核心响应头:
作用 注意事项
Access-Control-Allow-Origin 指定允许访问的前端源 若带凭证,不能是 *
Access-Control-Allow-Credentials 是否允许前端携带 cookie 必须与前端 credentials 配合
Access-Control-Expose-Headers 允许前端访问的自定义响应头 默认只能访问安全头
Access-Control-Allow-Methods 预检允许的方法列表 复杂请求必需
Access-Control-Allow-Headers 预检允许的自定义请求头 复杂请求必需

4. devServer proxy 是“绕过机制”

  • 核心流程:
    • 页面加载: 浏览器 → localhost:3000/home
    • 接口请求: 浏览器 → localhost:3000/api/home/list(同源) → devServer(代理转发) → api.xxx.com/home/list
      • 浏览器请求 localhost:3000 ✔ 同源 → 不跨域 → 不会触发预检(OPTIONS)
      • devServer 转发到后端 ✔ 这是服务器发起的请求(不受同源策略限制) ✔ 浏览器完全感知不到真实后端地址

5. 总结

请求类型 流程特点 浏览器是否检查 CORS 注意点
同源请求 浏览器直接发请求 → 返回响应 ❌ 不检查 浏览器不校验 CORS,即使服务端返回 Access-Control-Allow-Origin 为其他源,也能成功。失败通常是服务端逻辑或 Origin 校验导致
跨域简单请求 直接发请求 → 检查响应头 ✅ 检查 浏览器根据 CORS 响应头决定是否允许 JS 读取响应
跨域复杂请求 先发 OPTIONS 预检 → 决定是否发送实际请求 ✅ 检查 预检失败 → 不发送实际请求,浏览器阻止 JS 访问响应

nvm for windows之死:别再被这个“过时工具”耽误开发

2026年4月7日 14:12

如果你是Windows平台的Node.js开发者,至今还在依赖nvm for windows管理Node版本,那这篇文章请你务必读完——不是危言耸听,而是这个陪伴了无数开发者近十年的工具,早已进入“死亡倒计时”,继续使用,只会让你在开发中频频踩坑、浪费时间。

打开PowerShell,输入nvm upgrade,那句冰冷的提示NVM FOR WINDOWS WILL EVENTUALLY BE SUCCEEDED BY AUTHOR/RUNTIME,不是警告,是宣判。它直白地告诉你:这个工具即将被取代,它的生命,已经走到了尽头。

一、nvm for windows的“死亡真相”:不是突然崩塌,是温水煮青蛙

很多开发者还在疑惑,为什么突然就用不了了?为什么安装Node v25.9.0会提示“未发布”?其实,nvm for windows的“死亡”,早有预兆,本质是“主动放弃+技术落后”的双重必然。

它的开发者Corey Butler,早在2019年就开始规划重写,而nvm for windows,只是一个过渡性的产物。截至2025年1月,它的最后一个稳定版本v1.2.2发布后,就彻底停止了新功能迭代,仅保留最基础的安全修复——而这所谓的“修复”,也几乎形同虚设。

要知道,这款工具自2014年诞生以来,曾收获过超过1200万次下载,是Windows开发者管理Node版本的首选工具,也曾迭代过10多个版本,不断完善功能。但时代在进步,它却停在了原地,最终被自己的开发者和行业淘汰,成了技术迭代的“牺牲品”。

1. 开发者主动弃坑:精力全转向下一代工具

nvm for windows的“停更”,不是被动放弃,而是作者的主动战略选择。Corey Butler在更新日志中明确表示,nvm for windows最终会被“Author/Runtime”(简称rt)取代——这是一款他耗时数年开发的跨平台环境管理器,不仅能管理Node,还能兼容Bun、Deno等多种 runtime,支持Windows、macOS、Linux全平台统一体验,解决了nvm for windows的所有痛点。

对于开发者而言,维护一个老旧架构的工具,远不如重写一套更现代、更全面的系统有价值。尤其是nvm for windows基于Go语言开发,依赖符号链接(symlink)和PATH劫持实现版本切换,在Windows系统中天生存在权限兼容问题,维护成本极高,与其缝缝补补,不如推倒重来。

2. 技术架构过时:跟不上Node迭代,满是坑点

nvm for windows的致命缺陷,在于它的技术架构早已跟不上时代。作为一款为旧版Windows和Node设计的工具,它无法适配Node的新特性(如Corepack、Node 20+以上版本),甚至连最基础的版本列表都无法及时更新——这就是为什么你安装Node v25.9.0会提示“未发布”,不是官方没发布,而是nvm for windows的缓存列表,早已停留在几个月前。

更让人崩溃的是它的固有坑点:Windows对符号链接的权限限制,导致切换版本时频繁弹出UAC弹窗;路径容易错乱,经常出现“nvm use生效但终端无法识别Node”的问题;不支持.nvmrc自动切换,每次切换项目都要手动输入命令;跨终端兼容性差,在PowerShell、CMD、Git Bash中经常出现不同步的情况。

这些问题,在nvm for windows停更后,再也不会有修复的可能——它就像一辆刹车失灵的旧车,继续开,只会随时抛锚。

3. 社区替代者崛起:它的位置,早已被取代

nvm for windows的“死亡”,还有一个重要原因:社区已经出现了更优秀的替代品,它的存在,变得毫无必要。

比如用Rust开发的fnm,切换速度比nvm for windows快数十倍,支持全平台,能自动识别.nvmrc文件实现版本切换,操作更轻量、更流畅;再比如Volta,由LinkedIn开发,专为团队协作设计,能自动匹配项目所需的Node版本,无需手动切换,稳定性和兼容性拉满,更是被微软推荐为Windows平台的首选Node管理工具。

这些工具,解决了nvm for windows的所有痛点,而且还在持续迭代更新,适配最新的Node版本和Windows系统特性。当更好的选择出现,nvm for windows的淘汰,只是时间问题。

二、别再硬撑!继续用nvm for windows,你会踩这些致命坑

很多开发者习惯了nvm for windows,觉得“能用就凑活”,但你不知道的是,这种“凑活”,正在浪费你的时间、消耗你的精力,甚至可能导致项目线上故障。

结合无数开发者的踩坑经历,这些问题,你大概率会遇到:

  • 无法安装最新Node版本:Node迭代速度极快,每年会发布3个大版本,而nvm for windows的版本列表无法更新,导致你无法使用Node 25+等新版本的特性,只能被困在旧版本中,无法适配新项目的需求。

  • 版本切换频繁失败:经常出现“nvm use 版本号”提示成功,但输入“node -v”依然显示旧版本,排查半天发现是路径错乱或权限问题,浪费大量时间。

  • 权限报错层出不穷:安装全局包时频繁出现权限不足,必须以管理员身份运行终端;切换版本时被UAC弹窗骚扰,甚至出现符号链接创建失败的问题,导致Node无法正常使用。

  • 项目环境不一致:不支持自动切换版本,团队协作时,容易出现“本地能跑、线上报错”的情况,排查后发现是Node版本不匹配——而这一切,本可以通过更现代的工具避免。

更可怕的是,nvm for windows已经被官方放弃,所有的bug和问题,都不会再被修复。今天你遇到的“小坑”,明天可能就会变成“致命故障”,耽误你的开发进度,甚至影响项目交付。

三、nvm for windows“死后”:Windows开发者该用什么?

nvm for windows的淘汰,不是结束,而是Windows Node开发环境的“升级”。与其抱着一个过时的工具硬撑,不如尽快切换到更高效、更稳定的替代方案——以下3种,是2026年最推荐的选择,按需挑选即可。

1. 首选:Volta(最稳定,适合团队协作)

Volta是LinkedIn开发的工具,被微软官方推荐,也是目前Windows平台最稳定、最易用的Node版本管理器。它的优势的是“自动适配、零手动操作”,进入项目目录后,会自动识别项目所需的Node版本,无需输入任何切换命令,完美解决团队协作时的环境一致性问题。

安装命令(PowerShell中直接复制):winget install Volta.Volta

优点:全平台兼容、自动版本切换、无权限坑、稳定流畅,支持Node、npm、yarn等全套工具链管理;缺点:功能相对精简,无过多拓展特性,但完全满足日常开发需求。

2. 备选:fnm(最快,适合高频切换版本)

fnm(Fast Node Manager)正如其名,核心卖点是“快”——用Rust开发,切换版本的速度比nvm for windows快数十倍,同时兼容.nvmrc文件,无需额外适配旧项目,操作也非常简洁。

安装命令(PowerShell中直接复制):winget install Schniz.fnm

优点:轻量、快速、跨平台,支持并行安装多个Node版本,适合频繁切换项目、对速度有要求的开发者;缺点:社区规模略小于Volta,部分高级功能缺失。

3. 兜底:官方直接安装(最简单,适合新手)

如果你的需求很简单,不需要频繁切换Node版本,只是单纯需要一个稳定的运行环境,那么直接从Node官方下载安装包,是最省心的选择——无需配置任何环境,双击安装即可,还能随时更新到最新版本。

安装命令(PowerShell中直接复制,一键安装Node v25.9.0):winget install OpenJS.NodeJS --version 25.9.0

优点:操作最简单、无需任何配置、绝对稳定;缺点:无法切换多个版本,适合单一项目开发。

四、最后:和nvm for windows体面告别

nvm for windows曾是Windows Node开发者的“救星”,它解决了早期Node版本管理的痛点,陪伴无数开发者度过了一段段开发时光。但技术的迭代,从来不会因为情怀而停下脚步——它的“死亡”,是时代发展的必然,也是行业进步的体现。

与其抱着过时的工具,在无数坑点中挣扎,不如尽快切换到更现代、更高效的替代方案。毕竟,作为开发者,我们的时间应该花在代码上,而不是浪费在解决工具的bug上。

现在,打开你的终端,卸载nvm for windows,安装一款适合自己的替代工具——这不是告别,而是拥抱更高效的开发体验。

愿每一位Windows Node开发者,都能摆脱工具的束缚,专注于真正有价值的开发工作。

一文读懂 JS 原型链

作者 臧玉波
2026年4月7日 14:11

开篇先放大招:

总结起来 JS 的原型链就只有两条规则:

  1. 构造对象 的原型指向 构造函数 的 prototype 属性。
  2. Object.prototype 的原型不可更改且指向 null(原型链的尽头)。

这有点类似于数学归纳法:先有一个初始条件,再有一套递归规则。接下来,我会用几个例子来证明这个观点。

JS 中最重要的三个数据结构就是对象、函数和数组。下面我将分别捋清这 3 类对象的原型链。

相信你一定看过这张图来源:www.cnblogs.com/dreamcc/p/1…

这张图虽然画得不错,但对于初学者来说,更像是一堆零碎知识点的堆叠。其实,关于原型链,核心规则就是我上面说的那两条。接下来我们来验证。

对象

一个普通对象 {} 是由 Object 这个构造函数构造出来的。根据第一条规则,构造对象的原型会指向构造函数的 prototype 属性,所以:

console.log(Object.getPrototypeOf({}) === Object.prototype); // true

Object 本身又是一个函数。函数都是通过 Function 构造出来的,所以:

console.log(Object.getPrototypeOf(Object) === Function.prototype); // true

Object.prototype 的值是一个对象。按理来说,所有对象都应该通过 Object 构造出来,但这里为了避免循环,规范对它做了特殊处理,让它的原型直接指向 null

console.log(Object.getPrototypeOf(Object.prototype) === null); // true

PS:这里补充一个知识点。null 可以理解为空对象,undefined 可以理解为空值。这里会涉及 JS 中的原始值,不懂的同学可以去 MDN 上看一看。

数组

一个普通数组是由 Array 这个函数构造出来的,所以:

console.log(Object.getPrototypeOf([]) === Array.prototype); // true

和上面的 Object 一样,Array 本身也是一个函数,所以:

console.log(Object.getPrototypeOf(Array) === Function.prototype); // true

Array.prototype 是一个对象,而对象都是由 Object 构造的。这里和 Object.prototype 不一样,需要注意:

console.log(Object.getPrototypeOf(Array.prototype) === Object.prototype); // true

函数

无论是普通函数还是箭头函数,它们默认都是由 Function 这个函数构造出来的,所以:

function fnc() {
  return 0;
}

const arrFnc = () => {
  return 0;
};

console.log(Object.getPrototypeOf(fnc) === Function.prototype); // true
console.log(Object.getPrototypeOf(arrFnc) === Function.prototype); // true

Function.prototype 是一个对象,所以:

console.log(Object.getPrototypeOf(Function.prototype) === Object.prototype); // true

constructor

constructor 这部分的规则也比较简单,首先我们要知道,设置 constructor 属性的目的是什么。

假设我现在有一个对象,想知道它是如何构造出来的,那我应该怎么找?

假设有这样一种情况:

class Person {
  constructor(name: string) {
    this.name = name;
  }

  name: string = '';
}

const person = new Person('bo');

console.log(Object.getPrototypeOf(person) === Object.prototype); // false

难道我要把项目里所有的 class 都遍历一遍,才能找到它是谁构造出来的吗?显然不现实。constructor 的意义就在这里,它提供了一条可以快速回溯到构造函数的路径。

class Person {
  constructor(name: string) {
    this.name = name;
  }

  name: string = '';
}

const person = new Person('bo');

console.log(Object.getPrototypeOf(person).constructor === Person); // true

const copyPerson = Object.getPrototypeOf(person).constructor;
const child = new copyPerson('child');

console.log(child.name); // child
console.log(Object.getPrototypeOf(child) === Object.getPrototypeOf(person)); // true

总结一下,constructor 的作用就是让你可以通过原型链快速找到对应的构造函数,所以这里形成了一个回环结构。

flowchart LR
    instance["instance"]
    proto["Class.prototype"]
    cls["Class"]

    instance -->|"__proto__"| proto

    proto -->|"constructor"| cls
    cls -->|"prototype"| proto
    cls -->|"create"| instance

了解了这四个部分之后,就能和前面那种大图一一对上了。总结来说,原型链的知识其实并不复杂。它看起来乱,是因为相关概念比较分散,但底层规则并不复杂。学习编程和学习数学一样,关键是把握规律;如果只是死记硬背,思路就会越学越乱。

补充

为了方便讲解,文章里有些地方采用了不那么严谨的说法。这里先列出一部分,如果有遗漏,也欢迎各位同学指出:

  1. 不是所有对象的原型都指向 Object.prototype,只有默认创建出来的对象通常是这样。比如直接字面量创建,或者仅仅调用 Object()。你也可以使用 Object.create 在创建对象时指定原型,还可以在创建之后使用 Object.setPrototypeOf 更改对象原型,但这些都不违背我上面总结的那两条规则。
  2. 普通函数和箭头函数的区别,我会在以后的文章里再写;但在原型这一节里,你可以先把它们看作没有本质区别。
  3. 我提出的这两点关于原型链的总结,只是我个人当前的理解。如果有我没有覆盖到的地方,也欢迎大家积极评论,帮我找出错误,我们一起进步。
❌
❌