普通视图

发现新文章,点击刷新页面。
今天 — 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. 我提出的这两点关于原型链的总结,只是我个人当前的理解。如果有我没有覆盖到的地方,也欢迎大家积极评论,帮我找出错误,我们一起进步。

Solana开发(1)- 核心概念扫盲篇&&扫雷篇

作者 Amos_Web
2026年4月7日 13:35

本篇文章针对的人群是有一些Rust基础,同时想要了解Solana开发的同学。如果您对这些没有任何了解的话,建议您直接点赞、收藏、评论即可,无需往下阅读~ Solana中文开发教程 Solana Rust相关教程

1. 前言

最近在学习Solana开发(别问为什么,问就是前端已死...),以为自己有一些Rust的基础学起来会比较简单,结果没想到阅读上面的教程看的云里雾里,看完了之后就看完了,完全串不起来,各种概念,账户,token、私钥、公钥、合约傻傻分不清楚。

我自己的学习思路是学一个大的概念,也就是把对这个东西的大的框架搭建起来,然后在尝试一点点的去填空。所以这篇内容的话也算是自己理解的整个Solana大框架内容,细节的话感觉上面的教程已经说的很清楚了,推荐如果想学的话先把这篇内容消化一下 然后再看上面的教程会好理解很多。

我把这几天的“填坑日记”整理出来,尽可能的使用大白话的方式来给大家讲清楚Solana的相关基础知识以及其背后的设计理念,尽可能避免一些专有名词的干扰,让你看完了能大概明白是怎么回事。

建议点赞、收藏、评论之后慢慢细品🤣🤣😄😄~~~

2. 核心概念

2.1 账户(Account)

2.1.1 是什么

在 Solana 中,一切皆账户。无论是你的钱包余额、你的代码、还是你发行的 Token 数据,甚至是你创建的智能合约,以及官方代币程序Token Program,全都是保存在一个个“账户”里的。 账户是Solana用于存储状态的基本数据单元。网络将所以状态存储在一个键值对数据库中,每个键是一个32字节的地址,每个值就是一个账户。 image.png

2.1.2 关键信息

  1. 结构:每个账户都包含相同的五个字段:lamports、data、owner、executable、rent_epoch。
  2. 地址:每个账户由唯一的 32 字节地址标识(可以是 Ed25519 公钥或 PDA)。
  3. 所有权:只有账户的 owner 程序可以修改其数据或扣除 lamports。任何程序都可以向任何可写账户充值 lamports。
  4. rent:每个账户都必须持有与其数据大小成比例的最低 lamport 余额,才能保持链上状态。

2.1.3 账户分类

  1. 可执行账户 (Executable Accounts/Program Accounts): 也就是我们常说的 Program (程序/合约)。这类账户里存放的是编译后的 BPF 字节码。它就像一个“只读可执行文件”,只能被调用

    • Program Account用于存储可执行的代码。每个Program Account都由Loader Program拥有。当Program部署时,运行时会创建一个Program Account来存放其字节码。
      image.png
  2. 非可执行账户 (Non-Executable Accounts): 也就是 数据账户 (Data Account)。它是专门用来存状态的(比如余额、游戏积分)。它通过一个 Owner 字段指明哪个程序有权操作它。

    • 程序状态账户:程序会将其状态存储在数据账户中,创建程序状态账户需要两个步骤:

      • 调用 System Program 创建账户。System Program 会将账户所有权转移给指定的程序。
      • 拥有该账户的程序根据其 instructions 初始化账户的 data 字段。 image.png
    • 系统状态账户:创建后仍由 System Program 拥有的账户称为 system account。首次向新地址发送 SOL 时,会在该地址创建一个由 System Program 拥有的新账户。

      • 所有的钱包账户都是System Account。交易的手续费支付者必须是System Account。因为只有System Program拥有的账户才能支付交易手续费 image.png

2.1.4 关键点:程序(Program)是无状态的!

你的 Rust 代码上链后,它只是个“只读的函数库”。它不存你的分数、不存你的余额。数据存哪?存在专门开辟的 Account(账户) 里。 说人话就是每个账户只能是可执行账户、或者是非可执行账户,不可以又包含数据,又包含状态,这就是Solana的设计哲学:数据和状态分离代码和可变状态的分离意味着程序只需部署一次,即可管理任意数量的数据账户。

2.2 指令

指令是对Solana程序执行特定功能的请求。指令是链上操作的基本构建块。 每条指令只指定一个要调用的程序、所需的账户、以及由程序解析的字节数组数据。 每条指令的执行逻辑都存储在程序中,每个程序定义了自己的指令集。要与Solana网络交互,需要将一条或多条指令添加到交易中,并发送到网络进行处理。

image.png

2.2.1 关键点

  1. **单一程序:**每条指令只针对一个程序,通过program_id
  2. 账户元数据: accounts 数组为指令读取或写入的每个账户提供账户元数据。
  3. 不透明数据: data字段是一个字节数组,其格式由目标程序定义。

2.2.2 指令结构

  1. program_id: 被调用的程序的ID 指令的program_id是包含该指令执行逻辑的程序的公钥地址。运行时会使用此字段将指令路由到正确的程序进行处理。
  2. accounts: 一个账户元数据数组 指令的accounts数组是一个有序的Account Meta 结构体列表。每个指令交互的账户都必须提供元数据。validator会利用这些元数据判断哪些交易可以并行运行。写入不同账户的交易可以并行执行。
    image.png
  3. data: 包含额外 数据 的字节数组,供指令使用。 指令的 data 字段是一个字节数组。用于告知程序要调用哪个函数,并为该函数提供参数。数据通常以一个判别符或索引字节开头,用于标识目标函数,后面跟着序列化的参数。 常见的编码约定:
    • 核心程序(System、Stake、Vote): 使用Bincode序列化的枚举变体索引,后跟序列化参数。
    • Anchor程序:使用8字节判别符,后跟Borsh序列化参数。

2.3 交易

按照上面的图片中的标识,一笔交易实际就是多个指令的集合。任何指令失败,整个交易失败,所有的状态更改都会被回滚。

2.3.1 如何界定交易包含多少指令

  • 原子性绑定: 如果指令A 失败,指令B就不能做,那就打包到一起;
  • 容量限制:单个交易包不能超过1232字节(约等于能塞下十几个账户地址);
  • 计算限制:逻辑太复杂会爆CU(计算单元),需要拆分或申请提高预算;

2.4 SPL(Solana Program Library)代币

代币是代表不同类别资产所有权的数字资产。代币化使财产权利的数字化成为可能。

2.4.1 是什么:

Solana 上的所有代币本质上都是由 Token Program 拥有的 数据账户。 基于数据账户这个说明,其实代币有两个核心”概念性“的账户:

  1. 铸币账户 (Mint Account) —— 代表“币”的本体 代表这种 Token 的本质。比如你今天发了一个 "USDT",这个 USDT 的总量、精度(Decimals)、管理员是谁,存在 Mint 账户里
  2. 代币账户 (Token Account) —— 代表“某人的口袋” 用来装这种 Token 的“口袋”。你的主钱包地址(Wallet)不能直接装 Token,必须名下挂一个专属的 "口袋(Token Account)" 才能装 USDT。 代币中的账户
    • Mint: 该token account持有的代币
    • Owner: 有权从该token account转移代币的账户
    • Amount: 该token account当前持有的代币数量

2.5 私钥、公钥、签名

2.5.1 是什么

代表着你在区块链世界的身份凭证和密码

2.5.2 关联

  • 公钥 (PublicKey) :就是我们口头说的“钱包地址”或“账户地址”,它是向外界展示的门牌号。
  • 私钥 (Secret Key) :能对这扇门发号施令的唯一钥匙,常以 id.json 文件的形式存在。
  • 签名 (Signature) :每次转账或修改账户数据时,用你的私钥“盖的章”。节点一验章,就知道“这确实是他本人操作的”,也就是授权。

2.6 RPC (远程过程调用节点)

2.6.1 是什么

区块链外围的服务亭(比如你刚试过的 https://api.devnet.solana.com 或 Helius 的节点)。

2.6.2 作用

不论你是要在网页上查询你的 SOL 余额(Query),还是通过 Web3 SDK 把签名好的交易(Transaction)丢上链,统统得通过 HTTP(甚至是 WebSocket 实时订阅)发给 RPC 节点。节点负责将这些包裹进一步分发到区块链主干网上的那些验证器进行执行打包。

2.7 DApp (去中心化应用前端)

2.7.1 是什么

为了不让用户对着命令行或者代码界面发呆而写的图形化网页系统。

2.7.2 典型框架

像目前 Solana 官方推崇的 @solana/wallet-adapter 组件。它能在网页上悬浮一个类似于 Phantom、Solflare 的插件图标,让用户在可视化的界面里点击一键登录,并对后端的转账交易弹出授权框。

3. 避坑第一条:别跟“代理”较劲!

刚开始跑 solana airdrop 就给我吃了一记闭门羹:connection closed via error

真相大白: Solana CLI 底层用的 Rust reqwest 库,在处理 macOS 代理时简直是“洁癖”。curl 都能通,它就是不通。咱也不知道为啥,但是查了很多资料之后直接放弃死磕到底,走捷径还是很轻松的。

  • 基础知识点: Solana 节点通信走的是 JSON-RPC 协议
  • 代码补充: 只要 RPC 节点配置对,其实一行代码就能查余额。
    // Web3.js 基础:连接节点并查询
    const connection = new Connection("https://api.devnet.solana.com");
    const balance = await connection.getBalance(myPublicKey);
    
  • 解决方案: 别死磕官方 RPC 了。换个 QuickNodeHelius 的免费专用节点,关掉代理直连,瞬间丝滑。

4. SPL Token:为什么我发个币要建一堆“口袋”?

在 Solana 上发币,全网只有一个主程序(Token Program)。你发币其实只是在“填表格”:

  1. Mint Account(铸币账户):这就是币的“身份证”,定义了谁能印钱。
  2. Token Account(代币账户):你的主钱包不能直接装币!想接某种币,必须在名下挂一个专属的“口袋账户”。
    • 核心逻辑: 所有的转账,本质上是 Token Program 这个“大管家”在帮你修改 A 口袋 and B 口袋里的数字。

5. 钱包、签名、公钥:三位一体的“印章”逻辑

  • 钱包(Phantom/CLI):它是你的印章保管员。私钥永远不出钱包,只负责在交易包上盖个戳(签名)。
  • 公钥(Public Key):你的收款码/身份证照,全网公开。
  • 签名验证(Ed25519):节点不需要你的私钥,它通过你的“签名结果”+“公钥照片”+“交易内容”进行数学推导,一眼就能看出这戳是不是印章盖的。

6. 终极奥义:Solana 凭什么这么快?

这绝对是面试必考题。Solana 的“多核并发”不是吹的。

1. 强制“提前报备”(Sealevel 引擎)

在以太坊,你不知道交易会动谁。而在 Solana,每个交易必须明确声明:我要读写哪些账户

  • 专业解释: 调度器一旦发现两笔交易操作的账户互不干扰,就会把它们分给不同的 CPU 核心并行处理

2. Rust 的天生优势

Solana 选 Rust 是有原因的。Rust 的**所有权(Ownership)**和内存安全机制,让它在多核高并发读写内存时,依然能保证不发生数据竞争(Data Race)。

3. PoH(历史证明)

给全网加了个“精准节拍器”。节点之间不用再在那儿磨叽“现在几点了”,大家看时间戳就能排好队,CPU 闷头猛算就行。

总结:Solana 的工程哲学

如果你习惯了 Web2 或者以太坊,初见 Solana 可能会觉得它设计得太繁琐。但一旦你接受了 “数据与代码分离”、“声明式读写” 这种设定,你会发现这是一个极致追求性能的分布式操作系统

AI 时代的管理后台框架,应该是什么样子?

作者 Hooray
2026年4月7日 13:24

这些年我一直在做 Fantastic-admin 这套管理后台框架。也一直在关注这个圈子的发展,虽然“技术栈在升级”、“UI 风格也在变化”,但管理后台框架核心一直在不断解决同一个问题:

如何把那些反复出现、又特别容易失控的工程问题,提前收敛成一套系统能力。

早期,这个问题的答案是“给我一个能跑起来的脚手架”;后来变成“帮我把常见页面骨架搭好”;再后来,变成“不要让我被框架反过来绑架”;而到了今天,在 AI 和 Agent 已经真的进入开发现场之后,我觉得问题已经变成了:

一个管理后台框架,能不能同时服务开发者和 Agent ?

这也是我写这篇文章的原因。在我看来,AI 当下的管理后台,已经不能只是一个后台模板,它必须是一套面向长期协作的工程系统。

再聊之前,不妨先回顾下管理后台框架的发展史。这里以 Vue 生态下的管理后台为主。

第一阶段:脚手架时代,解决了“从 0 到 1”

这个阶段最核心的诉求非常朴素:

  • 不要让我从空目录开始搭项目
  • 不要让我自己接 Vue、路由、状态管理、权限、登录、Mock、构建配置,哪怕其中有些我不用,但也最好有

在这个阶段,vue-element-admin 是绕不过去的一款产品,它除了解决了开发者的基本诉求外,还提供了一套非常前卫的设计:用路由驱动导航菜单

今天看这件事很自然,但在当时,这其实是很关键的一步:

  • 导航菜单不再需要额外维护一份数据
  • 路由结构和导航菜单结构天然一致
  • 标题、图标、权限这类信息可以集中管理

为什么这一步重要?因为后台和普通内容网站不一样,导航本身就是产品的信息架构。导航一乱,整个后台的认知成本就会上去。

所以在我看来,第一个阶段最重要的历史贡献就是这个路由即导航的设计,影响了几乎所有后来诞生的后台框架。

第二阶段:模板繁荣时代,开始出现“虚假的强大”

随着 Vue 3 发布,以及 vue-element-admin 作者的停更,大量新的管理后台框架开始出现。

这一阶段有一个非常明显的现象:与其说是框架,更像是“模板展厅”。因为你会看到:

  • 第三方插件集成示例越来越多
  • 图表、地图、编辑器、拖拽控件、可视化页面一应俱全

很容易让人觉得“这个框架很强”,但真的是这样么?

我们不可能在一个项目中把这些所有插件都用上,即便会用到其中几个,提供的这些示例页面也未必能满足实际的需求。而绝大多数真实业务团队,日常最高频的需求反而是:

  • 列表页怎么高效搭建
  • 搜索区、分页区、操作区怎么统一
  • 新增、编辑、详情页怎么组织
  • 菜单、路由、权限、缓存怎么协同

也就是说,这个阶段很多后台框架在解决的是“看起来像个成熟后台”的问题,而不是“怎样真正高效地服务开发者”的问题。

这是我做 Fantastic-admin 时非常警惕的一件事:

不要把框架做成一个演示效果很强、真正落地时却帮不上太多忙的样子货。

第三阶段:后台框架开始回到“系统能力”本身

如果说第二阶段有不少东西是在做“展示能力”,那么从第三阶段开始,我觉得后台框架终于慢慢回到了更本质的问题上:

它到底能不能成为一套真正服务业务的系统。

在我看来,这一阶段出现了两条很清晰的路线。

一条路线是向内走:把框架本身做得更完整

这条路线的核心是尽量扩充框架自身的系统能力,也是我开发 Fantastic-admin 时侧重的一条路。因为我发现,真正影响一个后台项目长期体验的,往往不是那些最显眼的东西,而是:

  • 导航布局够不够灵活
  • 页面布局能不能适配不同产品形态
  • 路由元信息够不够细
  • 标签栏、工具栏、偏好设置是不是成体系
  • 页面保活是不是只停留在“开/关”两档
  • 有没有合理的扩展位,而不是逼着开发者去改框架源码
  • 等等

这些能力平时开发使用未必会注意到,但它们决定了一个项目在需求扩张的时候,能否让开发者放心,不用担心框架没有提供这个能力的问题。

比如页面保活这件事,我一直觉得很多框架做得太粗了,通常都只是提供一个 keepAlive: true 的开关,虽然能解决一部分问题,但真实后台项目的诉求往往更复杂:

  • 从列表进详情,希望列表保活
  • 从列表跳其他模块,希望列表不保活
  • 标签页合并(Fantastic-admin专有功能)后,有些页面要保活,有些页面返回时必须释放保活

基于这些场景,我更想做的是一套可控的保活策略,而不是一个粗糙的开关,因为这才是业务开发者真正会长期依赖的能力。

另一条路线是向外走:继续靠近业务开发本身

另一条路线也很重要,因为一个事实是:后台大量业务页面,本质上高度重复。

  • 结构重复
  • 交互重复
  • 列表重复
  • 表单重复
  • 弹窗抽屉重复

总的来说就是大量 CRUD 模块高度重复,既然重复,那就不应该每次都从基础组件重新拼。

所以有框架开始探索更高层的业务抽象,比如 vben 就提供了更成熟的 CRUD 能力、更高集成度的表格表单组件,这些方向我都认为目前还是对的。

岔开聊一句,为什么说目前还是对的,因为高集成度的封装和抽象,本质上是减轻人类开发者的工作,假设我们面对一个5000-6000行的代码文件,想要理解它是很痛苦的,所以工程化、组件化的理念才如此重要。但这种大文件却刚好很契合 AI ,毕竟如果文件拆分太多,AI 频繁需要跨文件引入,上下文变得碎片化,必然会出现链路过长,信息丢失的情况,反而不适合 AI 优先的开发模式。

但不管怎么说,从这一步开始,后台框架的竞争终于不再停留在“模板多不多”,而是进入了更实在的层面:

谁能真正把业务开发里的重复劳动继续向上抽象。

补充一点:框架开始和 UI 组件库解耦

第三阶段继续往前走,我自己又越来越强烈地感受到另一个问题:

几乎所有后台框架和某个 UI 组件库绑定死了。

这会直接带来几个问题:

  • 开发者认同你的工程设计,但不认同你的 UI 风格
  • 框架代码和某个 UI 库深度绑定,更换 UI 库成本巨大
  • 一旦 UI 组件库停止维护或维护不积极时,整套系统都会受到牵连

发现这个问题后,我就知道不能把 Fantastic-admin 绑死在某个 UI 库上。

shadcn/ui 以及后来社区出现的 shadcn-vue ,对我来说是一个非常关键的信号。

它带来的最重要启发,不是某个按钮或者弹窗组件本身,而是它在强调一件事:

  • 组件代码应该是开放的
  • 组件应该是可读、可改、可延展的
  • 设计系统应该掌握在项目自己手里
  • 组件不是黑盒消费品,而是工程资产

shadcn/ui 官方甚至直接强调自己 不是传统组件库,而是一种构建组件的方式

当侧边导航、弹窗、抽屉、消息通知等等这些基础组件和 UI 组件库解耦后,Fantastic-admin 彻底变成了一套独立的,不再是某个 UI 组件库生态下的管理后台框架。

第四阶段:Agent 爆发之后,后台框架应该被重新定义

到了今天,AI 和 Agent 的爆发,不是在给后台框架“增加一个新卖点”,而是在逼着整个领域重新回答一个问题:

如果 AI 已经能读代码、改代码、理解目录、执行任务,那么管理后台框架应该如何被重新设计?

我自己简单分析了一下,在 AI 时代,一个管理后台框架至少应该具备下面 5 个特征:

1. 必须能让 AI 看懂项目全貌

这里就绕不开 monorepo 的架构了,过去我们说 monorepo 很多时候是在说工程治理、依赖复用、多应用扩展。

但今天我越来越觉得 monorepo 还有一个非常现实、而且会越来越重要的价值:

它天然更适合让 AI 建立完整上下文,能让 AI 拥有完整信息版图。

当应用代码、公共组件、主题、框架设置、文档、各种CI/CD脚本、技能定义都放在同一个结构清晰的仓库里时,AI 更容易快速理解:

  • 哪些是业务层
  • 哪些是公共能力
  • 哪些是配置边界
  • 哪些是复用资产
  • 哪些是项目约定,哪些只是偶然写法

Google 在那篇著名的 monorepo 文章里,把 monorepo 的价值概括为“common source of truth”。

我不想机械照搬这句话,但在 AI 协作语境下,它确实给了我很强的启发:

统一的代码真相源,也意味着统一的 Agent 理解入口。

这当然不是说用了 monorepo 架构,AI 就自动变聪明了。但至少它更容易看到全貌,减少 AI 幻觉的产生。

2. 必须有一套 AI 能稳定读取的项目协议

只有代码结构还不够,要想让 AI 想稳定工作,还必须有一层项目级协议,也就是 AGENTS.md ,或者 CLAUDE.md

它们本质上都在解决同一件事:

AI 协作不能只靠一次次聊天,而是需要项目内置的长期说明。

这意味着一个现代项目,未来不只是有给人看的 README,也应该有给 Agent 看的 README。

3. 应该把高频任务产品化为 Skills

Prompt 适合解决临时问题,但不适合承载高频、稳定、可复用的项目流程。

后台项目最常见的动作其实非常固定:

  • 生成 CRUD 模块
  • 新增表单页
  • 增加路由
  • 配置国际化
  • 修改框架设置
  • 生成 store
  • 定制主题
  • 优化/美化页面

如果这些事情每次都靠人重新组织一段 Prompt,AI 的表现一定会飘忽不定。这也让我决定要把这些高频动作沉淀成 Skills,把目录约定、实现策略、文件位置、限制条件、注意事项全部前置进去。这样做的好处非常直接:

  • AI 不再靠猜
  • 生成结果更接近项目现有风格
  • 不同 Agent 工具之间更容易复用同一套知识
  • 项目经验不再只存在聊天记录里,而会沉淀成长期资产

在我看来,这一步很重要,因为它意味着我们开始从“会用 AI”走向“把 AI 纳入工程系统”。

4. 必须把“可修改”放在“可调用”前面

在 AI 时代,我越来越觉得一个被黑盒包裹得太深的组件体系,长期价值其实会下降。

因为 Agent 最擅长的,不只是调用 API,而是:

  • 阅读现有代码
  • 理解现有代码
  • 修改现有代码
  • 基于现有代码继续延展

如果组件只是一个外部依赖包里的抽象壳,AI 的可操作空间是受限的;但如果组件体系是开放的、分层清晰的、仓库内可读的,AI 的工作质量通常会高很多。

相信这也是 shadcn/ui 爆火的原因之一。

这里说一个暴论,目前国内比较火的 UI 库,我一直都没有看到官方有提供 skills ,在一个既没有 skill ,AI 又无法直接阅读 UI 库的源码,这在当前环境下,很有可能会被逐渐弃用。

未来的软件系统,不只是给人维护的,也会越来越多地交给 AI 一起维护。

所以我理解的现代管理后台,不是“我有一堆组件”就够了,而应该是:

  • 有可读的组件实现
  • 有统一的组件约定
  • 有能沉淀后台业务场景的内建组件层
  • 有可替换的底层 UI 能力

5. 最终服务的是“长期协作”,而不只是“快速生成”

很多人一谈 AI,就会把重点放在“生成更快”上。但我做后台项目这些年越来越觉得:快,从来不是唯一问题,甚至很多时候都不是核心问题。

真正重要的是:

  • 生成出来以后,能不能做 code review
  • 多个页面之间风格能不能保持一致(UI风格、代码风格)
  • 多应用、多主题、多品牌场景下会不会慢慢失控
  • 人和 Agent 或多 Agents 混合协作时,项目是否仍然稳定

所以在我看来,AI 时代最好的后台框架,不一定是第一次生成最惊艳的那个,而应该是:

最适合持续迭代、持续扩展、持续被 AI 正确理解的那个。

最后聊一聊 Fantastic-admin 即将发布的 6.0 版本

v6-is-coming.png

如果把前面这几个阶段串起来看,其实就很容易理解,为什么 Fantastic-admin 要在这个阶段发布一个大版本更新。

因为对我来说,它已经不只是“一个 Vue 3 管理后台框架”,而是在尝试回答一个更具体的问题:

如果管理后台框架要面向下一个阶段,它应该提前长成什么样子?

1. 一套可长期演进的工程底座

Fantastic-admin v6 采用了 pnpm monorepo 架构,仓库里把应用、公共包、文档、脚本、技能清晰拆开:

fantastic-admin/
├── apps/              # 应用目录
│   ├── core           # 应用源码
│   └── example        # 示例应用
├── packages/          # 公共包目录
├── docs/              # 文档站点
├── scripts/           # 脚本工具
├── skills/            # AI 技能
└── package.json       # 根目录 package.json

这么做当然也有工程治理层面的考虑,但更重要的是,我希望“代码、文档、约定、技能”能够在同一个仓库里形成闭环。对于人来说,这是更清楚的工程边界;对于 Agent 来说,这是一张更完整的信息地图。

2. 把项目协议写进了仓库

仓库根目录有 AGENTS.md 文件,里面明确说明了:

  • 项目技术栈
  • 目录结构
  • 开发命令
  • 开发规范
  • 注意事项
  • 对技能使用的补充约束

这么做的原因很简单:我不希望 AI 每次都靠对话去猜这个项目是什么样子。

3. 把高频动作沉淀成了一套 Skills

目前已经有的 Skills ,包括但不限于:

  • CRUD 模块生成
  • 表单页生成
  • 路由生成
  • 国际化管理
  • 框架设置管理
  • 页面优化
  • 预留插槽创建
  • Store 生成
  • 主题定制

Skill 一方面是可以节省 token ,另一方面是将我的能力和我对框架的理解,形成了一套任何人都可以直接复用的标准,这是一份给 AI 的指导方针,让 AI 不再是猜测你的需求,或者可以说相当于你“雇用”了作者本人帮你完成需求😁。

4. 一套更加完善的系统设计

Fantastic-admin 一直以来的重点,都不是去堆砌多少示例页面,而是把后台真正核心的问题做成一套可配置化的系统:

这些能力拆开看都不算噱头,但组合在一起,我认为它们构成的不是一个“模板展示项目”,而是一套真正的后台基础设施。


image.png

至此,Fantastic-admin 即将发布的 6.0 全新版本就是我对 AI 时代管理后台框架的全部理解

如果你对 Fantastic-admin 开始感兴趣了,现在已经发布了 6.0 beta 版,欢迎来尝试体验,我将在4月中旬左右发布正式版本。

重新思考模板语言与 TypeScript 的结合:一条可落地的新路径

作者 梁高强
2026年4月7日 12:58

前端框架语法大致可以分为两类:模板语言框架(如 Vue、Svelte、Qingkuai)和 JSX/TSX 框架(如 React、Solid)。

在模板语言中,开发者通常可以在嵌入脚本块里获得接近原生 JS/TS 的编写体验,同时借助更简洁的模板语法完成常见渲染逻辑;代价是组件文件的灵活性会受到一定约束。JSX/TSX 则几乎让你在整份文件里都处在 JS/TS 的表达体系中,灵活性更高,但也会让 HTML 标签、CSS 样式与 JavaScript 代码深度交织,语法边界相对模糊。

以上只是对两类语法核心差异的简化描述,具体体验因人而异。本文聚焦一个长期存在的痛点:模板语言如何更好地支持 TypeScript

一、组件中的类型声明

在日常使用模板语言时,我一直有一个明显感受:主流框架对 TypeScript 的支持虽然已经很强,但在关键场景仍有不小门槛。最典型的就是几乎所有组件化框架都会遇到的 props 类型声明。

在这件事上,VueSvelte 采用了相近思路:通过编译标记(不同框架术语略有区别,例如 Vue 常称为编译器宏)声明类型。对于简单 props 这套方案基本够用;但进入泛型场景后,通常需要在 <script> 标签上额外声明泛型作用域,例如:

<script generics="T extends { id: number; name: string }"></script>

这在一定程度上背离了模板语言的核心优势:在嵌入脚本里提供一个纯净的 JS/TS 编程环境,让开发者专注业务逻辑,而不是额外语法细节。

更现实的问题是隐性成本。比如在 generics 属性中,是否可以访问嵌入脚本块内声明的类型?经过测试,Vue 与 Svelte 的表现一致但并不理想:

对于导入的外部类型,generics 可以访问;对于脚本块内部声明的类型,则无法访问。

导入类型.png

内部类型.png

我推测这与泛型组件的导出形态有关:语言服务可能需要将组件默认导出处理为函数,而 import 声明只能位于模块顶层,因此需要提升到函数外部,进而产生这种可见性差异。无论具体实现原因如何,这都会增加开发成本,并削弱模板语言应有的流畅体验。

这也是我在 Qingkuai 中做的一个核心取舍:保留 Props 作为组件全局类型声明。只要声明了 Props,就等于声明了 props 类型。这样一来,嵌入脚本块的编写体验和普通 JS/TS 基本一致。

props类型声明.png

这个设计还有一个额外收益:在非 TypeScript 项目中,仍可通过 JSDoc 注释声明 Props 类型,从而获得类型检查与补全能力。

jsdoc定义组件类型.png

二、泛型实参的传递

除了 props 声明之外,另一个高频痛点是:无法为组件泛型参数传递实参

在 Vue 与 Svelte 中,目前都缺少一套明确机制来向组件泛型传入实参。这会导致调用方即使具备明确的业务上下文,也无法通过显式传入泛型实参来收窄并主动约束组件类型。

组件泛型实参.png

三、插槽上下文类型推导

在插槽上下文类型推导上,模板语言相较 JSX/TSX 其实有天然优势:多数模板语言通过 slot 标签声明插槽出口,并可在标签上直接绑定要传递给插槽的数据。这为自动推导提供了明确入口,不必强迫开发者在组件内部增加额外类型标注。

反过来看 JSX/TSX(如 React),其并没有原生插槽概念,通常只能通过 children 模拟类似能力。这样一来,类型推导会明显更难,往往需要开发者手工声明函数类型来描述 children 的参数与返回值。

遗憾的是,当前主流模板语言仍未实现插槽上下文自动推导。Vue 支持手动标注插槽上下文类型;Svelte v4 使用 slot 定义插槽但不能标注其类型,v5 虽引入 Snippet 机制,仍需要开发者手动标注片段上下文类型,二者都不支持自动推导。

但从可行性看,这件事并不遥远。通过编译期静态分析、IR 标记与 TypeScript 语言服务提取类型的组合,模板语言完全可以实现插槽上下文自动推导。例如下面两个组件中,组件内部没有额外类型标注,调用方仍可获得完整推导与补全,甚至在纯 JavaScript 项目中也能自动推导插槽上下文类型:

插槽上下文类型推导.png

插槽上下文自动推导的价值不只在于减少类型声明成本,更在于 IDE 交互质量。借助 查找定义查找引用,开发者可以直接跳转到上下文定义源头,而不是落在类型定义中转层。

qingkuai插槽跳转.gif

vue插槽跳转.gif

在复杂组件里,这个差异非常直观。没有自动推导时,你往往需要先定位 <slot>,再分析绑定字段,最后回溯字段定义;有自动推导时,只需在插槽内容处执行一次 查找定义,即可直达源头,开发效率和可维护性都会明显提升。

四、组件类型导出

目前几乎所有模板语言都不要求手工定义组件导出类型,语言服务会根据组件内部声明自动推导默认组件类型。这本身是合理且高效的设计。

但另一个问题是:推导出的导出类型是否足够可读。Vue 可能因兼容历史语法而导致类型展示偏冗长;Svelte 虽然更简洁一些,但仍会暴露部分内部类型细节,容易增加理解成本。

vue组件导出类型.png

svelte组件导出类型.png

通过更清晰的导出类型结构设计,这个问题是可以优化的:

qingkuai组件导出类型.png

另外,很多开发者在写组件时都会习惯把鼠标悬停在组件标签上查看类型,但 Vue 与 Svelte 对这一体验的支持仍不理想:

svelte组件标签查看类型.png

vue组件标签查看类型.png

如果通过 TypeScript 语言服务的 TypeChecker 提取组件导出类型,并在标签悬停中返回该类型,落地并不复杂:

qingkuai组件标签查看类型.png

五、总结

本文从四个问题展开:组件内类型声明、泛型实参传递、插槽上下文类型推导,以及组件导出类型可读性。它们看似分散,本质上都指向同一个目标:让模板语言中的 TypeScript 体验尽可能接近“普通 TypeScript 文件”的直觉与效率。

从工程实践看,真正决定体验的往往不是语法表层,而是类型流是否连续。只要类型信息在“组件定义 -> 编译产物 -> 语言服务 -> IDE 交互”链路上断裂,开发者就会被迫用额外声明、注释和心智记忆去补洞。

围绕这一点,Qingkuai 采取了两项关键策略:

  1. 减少模板内额外语法负担:通过内置 Props / Refs 约定,将组件属性类型声明收敛到标准 TS 类型定义。
  2. 增强语言服务侧类型恢复能力:在编译期保留足够结构化标记,再由 TypeScript 语言服务提取并回填类型,用于补全、跳转与错误检查。

以插槽上下文为例,采用“编译期 IR 标记 + LSP TypeChecker 提取”路径后,类型推导不再依赖开发者逐处手工维护,IDE 也能把定义关系直接连接回真实源头。这不仅降低了类型维护成本,也显著改善了代码阅读与重构体验。

最终结论可以归纳为三点:

  1. 模板语言并不天然弱于 TS 体验:关键在于是否将类型系统纳入语言与工具链的一体化设计。
  2. 编译器与语言服务应协同设计:编译器负责可追踪标记,语言服务负责语义恢复与交互反馈。
  3. 高质量类型体验可以工程化落地:只要类型链路闭环,补全、跳转、诊断与可维护性就能同步提升。

这也意味着,这套思路并不局限于 Qingkuai。本质上,它为其他模板语言也提供了一条可行路线:在尽量保持模板语法简洁的前提下,通过编译器与语言服务协同设计,持续提升 TypeScript 体验。Qingkuai 的后续工作也可以沿着这条路径推进:补齐更多边界场景(复杂泛型、条件类型、跨文件符号映射),并以真实项目数据验证这套机制在大型代码库中的稳定性与性能表现。若你想进一步了解实现细节或直接上手验证,可以参考qingkuai文档在线体验qingkuai

❌
❌