阅读视图

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

你应该了解的TCP滑窗

一、TCP 滑窗机制:从可靠传输到高效流控的演进

在基本的 TCP 传输概念中,滑动窗口机制通过序列号确认实现了可靠数据传输。然而,这仅仅是故事的开始。真正的 TCP 效率来自于对窗口机制的深度优化和策略控制,其中蕴含着精细的设计权衡。

二、累计确认:效率与可靠性的完美平衡

TCP 并没有采用简单的"一对一"确认机制,而是设计了精巧的累计 ACK 策略。这种机制的核心思想是:确认号表示该序号之前的所有字节都已被正确接收

已接收:1-1000, 2001-3000  // 序列号1001-2000形成空洞
期待接收:1001

在这种情况下,即使收到了 2001-3000 的数据,接收端仍然只能确认 1001。只有当 1001-2000 的数据到达后,才能一次性确认到 3001。

三、延迟确认的优化策略

TCP 实现中通常包含延迟确认计时器(通常 200ms,也许更短):

  • 当按序数据到达时,不立即回复 ACK,而是等待短暂时间
  • 如果在此期间有后续数据到达,可以合并确认
  • 如果期间有发送数据,可以捎带确认
  • 超时后仍会发送纯 ACK

这种策略在实际网络中可以将 ACK 数量减少 50% 以上,显著降低网络开销。

四、滑窗内部结构:字节级的精确控制

与概念性理解不同,实际的 TCP 滑窗是以字节为单位进行管理的,这种精细度带来了更精确的流量控制。

发送方窗口结构:

WeChatWorkScreenshot_4a6f5045-0724-4e93-8a2d-79f9a39d6e61.png

接收方窗口结构:

image.png

接收方多出的"已接收未提交"部分反映了应用层处理延迟。当应用读取速度跟不上接收速度时,这部分会增长,导致通告窗口缩小,这一“空间”的减小会导致接收方的通告窗口急剧下降,这时需要一个反馈机制,告诉发送方:减速->暂停。

五、流量控制:动态的速率协调机制

在第四点我们说到了滑窗结构,发送窗口与接收窗口,在一个完整的发送流程中我们会有探测 -> 启动 -> 连接 -> 发送 -> 接收 -> 回复 -> 确认 等一系列过程。

当然我们本文的重点在于滑窗,是这个体系知识的一小部分,那滑窗是如何动态控制的? 简单来说就是要保证整个通信链路能高效传输,既不能太慢也不能太快,它要在一个阀值附近横跳保证网络以最大可通信速率运行。

这个由谁决定?是拥塞窗口(cwnd)来进行控制,主要利用了慢启动、拥塞避免、快速重传和快速恢复这四个方案来处理

1:慢启动

其实说白了就是一开始不要把传输速率跑满而是从0加速,快速的接近一个半窗口阀值,因为一开始跑满速率这很可能会导致整个链路在开始就无比拥挤,才刚开始就堵车了这不是一个好消息,我们更希望的是先发一小部分内容用较低的速率发出,并且尽快达到半窗口阀值,而后逐步加速的过程。

2: 拥塞避免

接在慢启动之后拥塞避免方案会马上接手传输,在此基础上均匀加速到最大阀值是一个线性的过程

WeChatWorkScreenshot_00e4b9c9-439d-4c82-a21f-b219b8a452bf.png

以上半窗口阀值就是图中的ssthresh,慢启动就是达到ssthresh这段,而拥塞避免是ssthresh到max这段 这里我们也可以看到长短连接区别,长连接效率会高很多,短连接相当于每次都要有一个加速的过程,非常耗时。

3: 快速重传

没有快速重传时,TCP 发送方像个死脑筋:只有一个重传定时器。如果数据包丢了,它必须等到定时器超时才会重传。这通常需要几百毫秒到几秒,效率极低。

快速重传的核心思路就是:  别等定时器了!如果接收方反复在问同一个问题,那肯定是有包丢了!


想象你是个发送方,你发了 1, 2, 3, 4, 5, 6 号包。

  • 接收方顺利收到了包1,它回信说:“包1收到了,我下一个想要包2!”(ACK 2)
  • 这时,包2在网络中丢了,没收到。
  • 接收方紧接着收到了包3。它一看,包2还没到呢,包3就来了?但它最关心的还是包2。于是它再次回信催更:“包1收到了,我下一个还是想要包2!”(ACK 2)
  • 接着,它又收到了包4。它继续回信:“我想要包2!”(ACK 2)
  • 然后又收到了包5,它依然回信:“我想要包2!”(ACK 2)

对于发送方来说,它在短时间内连续收到了 3个重复的 ACK 2(加上第一个正常的,一共是4个 ACK 2)。

第二步:发送方触发“快速重传”

发送方内部有个小本本,专门记着 重复ACK计数器

  • 收到第一个 ACK 2:正常,不管。
  • 收到第二个 ACK 2(第一个重复ACK):计数器+1。心想:“有点奇怪,但再等等。”
  • 收到第三个 ACK 2(第二个重复ACK):计数器+1。心想:“情况不妙,可能真丢了。”
  • 收到第四个 ACK 2(第三个重复ACK) :计数器变成3了!发送方立刻判断:“实锤了!包2肯定丢了!别等重传定时器了,现在就重传包2!”

这个神奇的阈值 3(即收到总计4个相同的ACK)是经过大量实践验证的,能有效避免因为网络短暂乱序而误判丢包。

4: 快速恢复

重传完丢失的包2之后,故事还没完。如果直接回到原来的状态,可能会让网络瞬间再次拥塞。所以 TCP 会紧接着启动 快速恢复 算法:

  1. “假装”事情没那么糟:既然能收到这么多重复ACK,说明网络还能通,只是丢了一个包。所以不像超时重传那样把窗口直接打到1(慢启动),而是只砍半
  2. 保持数据流:在重传期间,发送方还可以继续发送新的数据(比如包7,包8),因为接收方的缓存里还存着包3,4,5,6,窗口并没有被完全占满。

当发送方收到针对包2(以及之后数据)的新ACK(比如 ACK 7),表明丢失的包已经被成功弥补,它就退出快速恢复状态,恢复正常传输。

WeChatWorkScreenshot_ea45b90a-a236-4116-b43c-c53880d890c4.png

上图可以看到,这是一个非常精巧的设计,确认丢包马上降速至新阀值,启动拥塞避免逐步恢复,有人会问为啥丢包就要降速?其实不降速是不行的,这会导致网络拥堵灾难,丢包是在告诉你路堵,已经导致路上的某些汽车已经冲出了道路找不到了。这个是一个非常危险的信号,出于公平使用网络tcp会自动降速,保证网络稳定通畅。


总结:TCP 滑窗的演进

TCP 滑窗机制从最初的基本可靠传输,演进为一套复杂的流量控制和效率优化系统。这种演进体现了网络协议设计的核心智慧:

  • 渐进优化:通过累计确认、延迟确认等机制在保持兼容性的前提下不断提升效率
  • 自适应控制:根据网络状况和应用需求动态调整传输策略

这些设计使得 TCP 在三十年后的今天,依然是互联网不可替代的传输基石。我们需要理解,每一种你觉得没必要的做法背后都是一次小型灾难,如今这些策略已经非常成熟,稳定的服务我们的每一次网络通信中。

注明:文中图片来自互联网

Monorepo 架构以及工具选型、搭建

Monorepo(Monolithic Repository,单体仓库)是一种代码管理策略,核心是将一个项目的所有相关代码(包括多个应用、库、工具链等)集中存储在单个代码仓库中,而非按模块拆分到多个独立仓库(Multirepo)。

📑 目录


快速参考

工具选型速查表

工具类型 推荐工具 适用场景 备选方案
包管理器 pnpm workspace 磁盘效率高、安装速度快 npm workspace、yarn workspace
任务调度 Turbo 增量构建、并行任务、缓存 Nx(企业级)、Rush(大型项目)
版本管理 Changeset monorepo 友好的版本管理 release-it(单包)、Lerna(传统)

快速开始

# 1. 初始化项目
mkdir your-project && cd your-project
pnpm init -y

# 2. 安装 Turbo
pnpm add turbo -D -w

# 3. 配置工作区
# 创建 pnpm-workspace.yaml

# 4. 配置 Turbo
# 创建 turbo.json

# 5. 创建子包
mkdir -p packages/core docs examples/basic

什么是 Monorepo

简单类比

  • Multirepo:像多个独立的文件夹,每个项目 / 库单独存放(比如 reactreact-domreact-router 各一个仓库)
  • Monorepo:像一个大文件夹,里面按功能分类存放所有相关项目(比如 Facebook 的 facebook/react 仓库,包含 React 核心、文档、示例、相关工具等所有代码)

常用结构

monorepo-root/
├── packages/          # 所有可复用包(库、工具)
│   ├── utils/         # 通用工具库
│   ├── components/     # UI 组件库
│   └── cli/           # 命令行工具
├── apps/              # 可部署应用
│   ├── web/           # 网页应用
│   └── admin/         # 管理后台
├── scripts/           # 全局构建/测试脚本
├── package.json       # 根项目配置(依赖、脚本)
└── pnpm-workspace.yaml # 工作区配置(pnpm 为例)

Monorepo vs Multirepo

对比维度 Multirepo(多仓库) Monorepo(单仓库)
依赖管理 重复安装,版本不一致,易冲突 共享依赖,版本统一,减少冗余
跨项目引用 需发布 npm / 用相对路径,同步修改繁琐 本地直接引用,修改实时生效,无需发布
工程化规范 各仓库独立配置,维护成本高 根目录统一配置,所有子项目继承
代码复用 复制粘贴或发布私有包,复用成本高 仓库内直接复用,抽离库更便捷
版本管理与发布 手动协调多包版本(如 A 依赖 B,B 升级后 A 需手动更新) 工具自动管理版本依赖(如 Changeset),批量发布
协作效率 跨仓库 PR 联动复杂,代码审查分散 所有代码在一个仓库,PR 集中,协作更高效

Monorepo 的优缺点

优点

  • 高效协作:所有代码集中管理,跨项目修改无需切换仓库,PR 集中审查
  • 规范统一:工程化配置(lint、测试、构建)全局统一,降低维护成本
  • 依赖优化:共享依赖减少安装体积,版本统一避免冲突
  • 代码复用:子包间直接引用,无需发布,迭代速度快

缺点

  • 仓库体积增大:随着项目增多,仓库体积会变大,但现代 Git(如 Git LFS)可缓解
  • 构建速度:大型 Monorepo 全量构建较慢,需借助 Turborepo 等工具实现增量构建和缓存
  • 权限控制:难以对单个子包进行精细化权限控制(如需控制,可结合 Git 子模块或企业级工具如 GitLab Enterprise)

经典案例

前端 Monorepo 经典案例:

  • Reactfacebook/react 仓库包含 React 核心、React DOM、React Server Components 等所有相关代码
  • Vuevuejs/core 仓库包含 Vue 3 核心、编译器、运行时等
  • Vitevitejs/vite 仓库包含 Vite 核心、官方插件(如 @vitejs/plugin-react)等
  • Tailwind CSStailwindlabs/tailwindcss 仓库包含核心库、CLI、插件等

何时使用 Monorepo

当你的项目满足以下 2 个及以上条件时,优先选择 Monorepo:

  • ✅ 需拆分独立模块(核心包 + 文档 + 示例是典型场景)
  • ✅ 模块间有依赖关系(如示例依赖核心包、文档引用核心包 API)
  • ✅ 需统一构建、测试、发布流程
  • ✅ 追求高效开发(增量构建、并行任务)

何时不使用 Monorepo

  • ❌ 单一核心包 + 简单 README 文档(单包架构足够)
  • ❌ 子包之间完全独立,无依赖关系
  • ❌ 团队规模小,维护成本高

工具选型

工作区管理工具

负责管理多包的依赖安装、路径映射、脚本执行,主流选择:

工具 磁盘效率 安装速度 monorepo 支持 适用场景
pnpm workspace ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ 推荐,生态最优
npm workspace ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐ npm 7+ 原生支持
yarn workspace ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ Yarn 1.x 传统方案
Lerna ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐ 早期方案,已过时

选型建议

  • 推荐:pnpm workspace(磁盘效率高、安装速度快、生态完善)
  • 备选:npm workspace(npm 7+ 原生支持,无需额外配置)

核心特性

  • pnpm workspace:轻量、快速,原生支持 Monorepo,通过 pnpm-workspace.yaml 配置子项目路径,自动处理包之间的软链接,安装依赖时复用缓存,效率极高
  • Yarn Workspaces:与 pnpm 功能类似,支持 workspace:* 语法声明内部依赖
  • Lerna:早期流行的 Monorepo 工具,可搭配 npm/yarn 使用,擅长版本管理和发布,但依赖安装效率不如 pnpm

任务调度工具

需支持「多包构建」「增量构建」「依赖顺序构建」,避免每次全量构建:

工具 增量构建 并行任务 缓存机制 配置复杂度 适用场景
Turbo ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ 中小型项目(推荐)
Nx ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐ 大型企业级项目
Rush ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ 超大型项目

选型建议

  • 中小型项目:Turbo(推荐,配置简单,性能优秀)
  • 大型企业级项目:Nx(功能强大,但配置复杂)
  • 超大型项目:Rush(微软开源,适合超大型 monorepo)

核心特性

  • Turbo:高性能构建系统,可缓存构建结果,并行执行任务(构建、测试、lint),大幅提升大型 Monorepo 的构建速度
  • tsup:支持多入口、增量构建,适配 TypeScript 项目,可快速构建多个子包
  • Rollup/Vite:适合构建库或应用,支持 Tree-shaking,Vite 还能提供开发时热更新

版本管理工具

解决多包版本联动、CHANGELOG 自动生成、npm 发布等问题:

工具 monorepo 支持 多包版本同步 自动化程度 配置复杂度 适用场景
Changeset ⭐⭐⭐⭐⭐ ✅ 自动同步 ⭐⭐⭐ ⭐⭐⭐ 多包项目(推荐)
release-it ⭐⭐⭐ ❌ 需手动 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ 单包项目、简单场景
Lerna ⭐⭐⭐ ✅ 支持 ⭐⭐⭐ ⭐⭐ 传统方案

选型建议

  • 多包项目、需要版本同步:Changeset(推荐)
  • 单包项目、追求自动化:release-it
  • 简单场景、快速发布:release-it

核心特性

  • Changeset:轻量、易用,支持按子包提交变更记录,自动计算版本号(语义化版本),生成 CHANGELOG,批量发布子包
  • release-it:可搭配 Changeset 使用,提供交互式发布流程,支持 GitHub 标签、发布说明等

代码质量工具

统一管理 lint、测试、格式化:

工具类型 推荐工具 核心功能
代码规范 ESLint + Prettier 根目录配置,所有子项目共享规则,可通过 eslint-config-xxx 抽离自定义规则
测试框架 Vitest / Jest 统一测试框架,支持跨包测试,可在根目录运行所有子项目的测试用例
Git Hooks Husky + lint-staged 提交代码前自动执行 lint 和测试,保障代码质量

实战搭建

以下是最简搭建流程,基于 pnpm(生态最优)+ Turbo + tsup(核心包打包)+ VitePress(文档)+ Vitest(测试)

1. 初始化基础环境

# 创建项目根目录
mkdir your-project && cd your-project

# 初始化根目录 package.json
pnpm init -y

# 安装 Turbo(任务调度)
pnpm add turbo -D -w  # -w 表示安装到根目录(workspace-root)

2. 配置 pnpm 工作区

创建 pnpm-workspace.yaml

# pnpm-workspace.yaml
packages:
  - 'packages/*' # 核心包(可发布)
  - 'docs' # 文档站点(不发布)
  - 'examples/*' # 示例项目(不发布)

3. 配置 Turbo

创建 turbo.json

{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["package.json", "turbo.json"],
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "dist/**", "build/**", ".vitepress/dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": ["coverage/**"]
    },
    "lint": {
      "outputs": []
    },
    "clean": {
      "cache": false
    }
  }
}

配置说明

  • dependsOn: ["^build"]:构建前先构建依赖的子包
  • outputs:指定构建输出目录,用于缓存判断
  • cache: false:开发模式不缓存,避免热更新问题
  • persistent: true:开发模式持续运行(如 watch 模式)

4. 根目录 package.json 配置

{
  "name": "your-project-monorepo",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev",
    "test": "turbo run test",
    "lint": "turbo run lint",
    "clean": "turbo run clean && rm -rf node_modules",
    "format": "prettier --write \"**/*.{ts,tsx,md,json}\""
  },
  "devDependencies": {
    "turbo": "^2.0.0",
    "prettier": "^3.0.0"
  },
  "packageManager": "pnpm@9.0.0",
  "engines": {
    "node": ">=18.0.0",
    "pnpm": ">=9.0.0"
  }
}

5. 搭建核心包

# 创建核心包目录并初始化
mkdir -p packages/core && cd packages/core
pnpm init -y

# 安装核心依赖(共享依赖安装到根目录)
pnpm add -D tsup typescript vitest @types/node -w

核心包 package.json

{
  "name": "@your-org/core",
  "version": "1.0.0",
  "description": "核心功能包",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    }
  },
  "files": ["dist"],
  "scripts": {
    "build": "tsup",
    "dev": "tsup --watch",
    "test": "vitest run",
    "test:watch": "vitest",
    "lint": "eslint src/**/*.ts",
    "clean": "rm -rf dist coverage"
  },
  "keywords": ["core", "utils"],
  "license": "MIT"
}

核心包 tsup.config.ts

import { defineConfig } from 'tsup';

export default defineConfig({
  entry: ['src/index.ts'],
  format: ['esm', 'cjs'],
  dts: true,
  clean: true,
  sourcemap: true,
  minify: true,
  splitting: false,
});

核心包 src/index.ts

export const greet = (name: string) => {
  return `Hello, ${name}!`;
};

export const add = (a: number, b: number) => {
  return a + b;
};

6. 搭建文档站点

# 创建文档目录并初始化
mkdir docs && cd docs
pnpm init -y

# 安装文档依赖
pnpm add -D vitepress @vitepress/theme-default -w

# 初始化 VitePress 文档
npx vitepress init

文档 package.json

{
  "name": "@your-org/docs",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "vitepress dev",
    "build": "vitepress build",
    "preview": "vitepress preview",
    "lint": "eslint . --ext .md,.ts",
    "clean": "rm -rf .vitepress/dist"
  },
  "dependencies": {
    "@your-org/core": "workspace:*"
  }
}

在 Markdown 中可直接导入核心包,用于示例演示:

# 快速使用

```ts
import { greet } from '@your-org/core';

console.log(greet('World')); // Hello, World!
```

7. 搭建示例项目

# 创建示例目录并初始化
mkdir -p examples/basic && cd examples/basic
pnpm init -y

# 安装依赖(使用工作区协议引用核心包)
pnpm add @your-org/core@workspace:* -w
pnpm add -D vite @vitejs/plugin-react -w

示例 package.json

{
  "name": "@your-org/example-basic",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "clean": "rm -rf dist"
  },
  "dependencies": {
    "@your-org/core": "workspace:*"
  }
}

8. 核心命令使用

命令 作用
pnpm run dev 同时启动核心包监听、文档热更新、示例热更新
pnpm run build 一键构建核心包(ESM/CJS + 类型)、文档静态资源、示例
pnpm run test 运行所有子包的测试(核心包单元测试、示例冒烟测试)
pnpm run lint 统一校验所有子包的代码规范
pnpm run clean 清理所有子包的构建产物和缓存

增量构建效果示例

  • 首次执行 pnpm run build:构建所有子包(core + docs + examples)
  • 修改核心包代码后再次执行 pnpm run build:仅重建 core 和依赖它的 examples,docs 未变更则直接复用缓存,构建速度提升 50%+

过滤特定子包执行任务

# 只构建核心包
pnpm run build --filter=@your-org/core

# 只构建文档和示例
pnpm run build --filter=@your-org/docs --filter=@your-org/example-basic

# 构建核心包及其依赖者
pnpm run build --filter=@your-org/core...

9. 版本管理与发布

使用 Changeset(推荐,适合多包版本同步)

Changeset 完全支持 Turbo monorepo,可仅发布核心包(packages/core),文档和示例不发布,并支持多包版本同步:

优势

  • ✅ 专为 monorepo 设计,支持多包版本同步
  • ✅ 自动更新依赖包的版本号
  • ✅ 变更记录清晰,便于追溯

劣势

  • ❌ 需要手动记录变更(npx changeset
  • ❌ 流程相对复杂

安装与配置

# 安装 Changeset 并初始化
pnpm add @changesets/cli -D -w
npx changeset init

修改 Changeset 配置(.changeset/config.json)

{
  "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
  "changelog": "@changesets/cli/changelog",
  "commit": false,
  "access": "public",
  "baseBranch": "main",
  "updateInternalDependencies": "patch",
  "ignore": ["@your-org/docs", "@your-org/example-basic"]
}

发布流程

# 1. 记录核心包变更(仅选择 @your-org/core)
npx changeset

# 2. 升级版本 + 生成 CHANGELOG
npx changeset version

# 3. 构建核心包
pnpm run build --filter=@your-org/core

# 4. 发布核心包
pnpm publish --filter=@your-org/core --access public

使用 release-it(适合单包发布或简单场景)

release-it 也可以用于 monorepo,但主要用于单包发布场景。

优势

  • ✅ 自动化程度高,一条命令完成发布
  • ✅ 配置灵活,支持自定义发布流程
  • ✅ 支持 GitHub Release、npm 发布等

劣势

  • ❌ 不支持多包版本同步(需要手动处理)
  • ❌ 需要为每个包单独配置或使用脚本

方式一:在子包中单独配置(推荐)

在需要发布的子包中配置 release-it:

# 在核心包目录下
cd packages/core
pnpm add release-it @release-it/conventional-changelog -D

核心包 release-it.config.js

module.exports = {
  git: {
    commitMessage: 'chore: release @your-org/core@${version}',
    tagName: '@your-org/core@${version}',
    requireCleanWorkingDir: false,
    requireBranch: 'main',
    requireCommits: true,
  },
  github: {
    release: true,
    releaseName: '@your-org/core@${version}',
  },
  npm: {
    publish: true,
    publishPath: './',
  },
  hooks: {
    'before:init': ['pnpm run test'],
    'after:bump': ['pnpm run build'],
    'after:release': 'echo "Release @your-org/core@${version} completed!"',
  },
  plugins: {
    '@release-it/conventional-changelog': {
      preset: 'angular',
      infile: 'CHANGELOG.md',
    },
  },
};

核心包 package.json scripts

{
  "scripts": {
    "release": "release-it",
    "release:patch": "release-it patch",
    "release:minor": "release-it minor",
    "release:major": "release-it major"
  }
}

发布流程

# 在核心包目录下
cd packages/core
pnpm run release

方式二:在根目录统一配置(适合单包发布)

在根目录配置,配合 pnpm filter 使用:

# 在根目录安装
pnpm add release-it @release-it/conventional-changelog -D -w

根目录 release-it.config.js

module.exports = {
  git: {
    commitMessage: 'chore: release v${version}',
    tagName: 'v${version}',
    requireCleanWorkingDir: false,
    requireBranch: 'main',
  },
  hooks: {
    'before:init': ['pnpm run test --filter=@your-org/core'],
    'after:bump': ['pnpm run build --filter=@your-org/core'],
  },
  plugins: {
    '@release-it/conventional-changelog': {
      preset: 'angular',
      infile: 'CHANGELOG.md',
    },
  },
};

根目录 package.json scripts

{
  "scripts": {
    "release": "release-it",
    "release:core": "cd packages/core && pnpm run release"
  }
}

Changeset vs release-it 对比

特性 Changeset release-it
monorepo 支持 ⭐⭐⭐⭐⭐(专为 monorepo 设计) ⭐⭐⭐(需手动配置)
多包版本同步 ✅ 自动同步 ❌ 需手动处理
自动化程度 ⭐⭐⭐(需手动记录变更) ⭐⭐⭐⭐⭐(一条命令)
配置复杂度 ⭐⭐⭐ ⭐⭐⭐⭐
适用场景 多包项目、版本同步需求 单包项目、简单场景

选型建议

  • 多包项目、需要版本同步:Changeset(推荐)
  • 单包项目、追求自动化:release-it
  • 简单场景、快速发布:release-it

高级配置

Turbo 缓存优化

1. 配置远程缓存(可选)

Turbo 支持远程缓存,团队共享构建缓存:

# 安装 Turbo 远程缓存客户端
pnpm add turbo -D -w

# 登录 Vercel(免费提供远程缓存)
npx turbo login

# 链接项目
npx turbo link

2. 优化缓存配置

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"],
      "env": ["NODE_ENV"], // 环境变量变化时重新构建
      "inputs": ["src/**/*.ts", "tsup.config.ts"] // 指定输入文件
    }
  }
}

CI/CD 集成

GitHub Actions 示例

创建 .github/workflows/ci.yml

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 18
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install

      - name: Build
        run: pnpm run build

      - name: Test
        run: pnpm run test

      - name: Lint
        run: pnpm run lint

依赖管理策略

1. 公共依赖提升到根目录

# 安装公共依赖到根目录
pnpm add -D typescript eslint prettier -w

# 子包无需重复安装,直接使用

2. 子包间依赖使用工作区协议

{
  "dependencies": {
    "@your-org/core": "workspace:*" // ✅ 正确
    // "@your-org/core": "1.0.0"     // ❌ 错误,无法实时同步
  }
}

3. 版本统一管理

使用 .npmrc 统一配置:

# .npmrc
shamefully-hoist=true
strict-peer-dependencies=false

最佳实践

  1. 子包命名规范:使用 scope(如 @your-org/core),避免命名冲突
  2. 依赖管理:公共依赖提升到根目录,子包间使用 workspace:* 协议
  3. 任务配置:在 turbo.json 中准确配置 outputs,确保缓存生效
  4. 版本管理:使用 Changeset 管理版本,仅发布需要发布的包
  5. 目录结构:按功能拆分(packages、docs、examples),保持清晰
  6. 避免过度拆分:子包数量控制在 5 个以内,过多会增加配置复杂度
  7. 开发体验:使用 pnpm run dev 同时启动所有子包的开发模式

常见问题

工作区相关问题

Q: 子包间依赖如何引用?

A: 使用工作区协议 workspace:*

{
  "dependencies": {
    "@your-org/core": "workspace:*"
  }
}

Q: 如何安装依赖到特定子包?

A:

# 安装到根目录
pnpm add -D typescript -w

# 安装到特定子包
pnpm add -D vite --filter @your-org/example-basic

# 安装到所有子包
pnpm add -D eslint --filter "./packages/*"

Q: 如何查看所有子包?

A:

pnpm list -r --depth=0

Turbo 相关问题

Q: Turbo 缓存不生效怎么办?

A:

  1. 检查 turbo.json 中的 outputs 配置是否正确
  2. 检查构建输出目录是否在 outputs 中声明
  3. 清理缓存:pnpm run clean && rm -rf .turbo

Q: 如何只构建变更的子包?

A: Turbo 默认就是增量构建,只需执行 pnpm run build,Turbo 会自动判断哪些子包需要重新构建。

Q: 如何跳过缓存强制构建?

A:

pnpm run build --force

Q: 如何查看构建依赖关系?

A:

# 查看任务依赖图
npx turbo run build --graph

版本管理相关问题

Q: Changeset 如何只发布特定包?

A: 在 .changeset/config.json 中配置 ignore 字段,或在执行 npx changeset 时只选择需要发布的包。

Q: 如何自动化发布流程?

A: 使用 GitHub Actions + Changeset,参考 Changeset 文档

Q: release-it 能否用于 monorepo?

A: 可以,但主要用于单包发布场景。如果项目只有一个包需要发布,release-it 更简单;如果需要多包版本同步,建议使用 Changeset。

Q: release-it 如何发布 monorepo 中的特定包?

A:

# 方式一:在子包目录下执行
cd packages/core
pnpm run release

# 方式二:使用 pnpm filter
pnpm --filter @your-org/core run release

性能优化相关问题

Q: 如何提升构建速度?

A:

  1. 配置 Turbo 远程缓存(团队共享)
  2. 优化 turbo.jsonoutputs 配置
  3. 使用 dependsOn 合理配置任务依赖
  4. 避免不必要的任务依赖

Q: 如何减少 node_modules 体积?

A:

  1. 使用 pnpm(默认使用符号链接,节省磁盘空间)
  2. 公共依赖提升到根目录
  3. 使用 .npmrc 配置 shamefully-hoist=false

参考资源


文档版本:v2.0
最后更新:2024 年

cursor如何安装vscode插件

前言

下面会分享 cursor 中如何安装 vscode插件

方案一

点击 插件市场 图标,通过 搜索框 搜索相关插件

image.png

方案二

有时候在 vscode 发布的插件并不一定能在 cursor 的插件市场搜到,我们可以选择手动安装,这里以 mac 电脑为例

首先,我们随意点击一个插件,找到 Size

image.png

点击这个 Size 能打开 cursor 插件 的安装目录

image.png

我们把要安装的 vscode 插件 放到 extensions 目录 中,再把 extensions.json 中的插件相关信息拷贝一下,以我的 文件名复制插件 Copy Filename Pro 为例,它的信息是这样的

{
    "identifier": {
      "id": "chouchouji.copy-filename-pro",
      "uuid": "30cb65df-4ab9-4842-b8ed-5daae96f8096"
    },
    "version": "0.3.0",
    "location": {
      "$mid": 1,
      "path": "/Users/xxx/.cursor/extensions/chouchouji.copy-filename-pro-0.3.0",
      "scheme": "file"
    },
    "relativeLocation": "chouchouji.copy-filename-pro-0.3.0",
    "metadata": {
      "installedTimestamp": 1744702279283,
      "pinned": false,
      "source": "gallery",
      "id": "30cb65df-4ab9-4842-b8ed-5daae96f8096",
      "publisherId": "ac995f6c-c315-46fc-b922-8ce3a7e5884f",
      "publisherDisplayName": "chouchouji",
      "targetPlatform": "undefined",
      "updated": false,
      "private": false,
      "isPreReleaseVersion": false,
      "hasPreReleaseVersion": false
    }
  }

将这个配置放到插件同层级的 extensions.json 中就大功告成了

image.png

深入理解 JavaScript Promise:原理、用法与实践

引言

在现代 JavaScript 开发中,异步编程是无法回避的核心话题。随着 Web 应用复杂度的提升,传统的回调函数(Callback)方式逐渐暴露出“回调地狱”(Callback Hell)等问题。为了解决这一难题,ES6 引入了 Promise 对象,提供了一种更加优雅、可读性更强的异步处理机制。

本文将结合提供的代码示例和文档说明,系统性地讲解 Promise 的基本概念、状态机制、核心方法(如 .then().catch())、链式调用、嵌套 Promise 的行为,并通过实际案例展示其在文件读取等场景中的应用。


一、Promise 是什么?

根据 readme.md 中的定义:

Promise 简单说是一个容器(对象),里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果。

Promise 有三种状态:

  • pending(进行中) :初始状态,既不是成功也不是失败。
  • fulfilled(已成功) :操作成功完成。
  • rejected(已失败) :操作失败。

关键特性:

  • 状态不可逆:一旦状态从 pending 变为 fulfilled 或 rejected,就不会再改变
  • 状态由内部决定:Promise 的状态变化由其内部的异步操作决定,不受外界影响

二、Promise 的基本用法

1. 创建 Promise

// 1.js 示例
const p = new Promise(function (resolve, reject) {
  setTimeout(function () {
    let err = '数据读取失败';
    reject(err);
  }, 1000);
});

p.then(
  function (value) {
    console.log(value); // 成功回调
  },
  function (reason) {
    console.log(reason); // 失败回调 → 输出 "数据读取失败"
  }
);

在这个例子中,我们创建了一个在 1 秒后调用 reject 的 Promise。.then() 方法接收两个参数:第一个是 resolve 的回调,第二个是 reject 的回调。

注意:虽然可以这样写,但更推荐使用 .catch() 来统一处理错误(见后文)。

2. Promise 立即执行


// 2.js 示例
let promise = new Promise(function (resolve, reject) {
  console.log('Promise'); // 立即执行
  resolve();
});

promise.then(function () {
  console.log('resolved');
});

console.log('Hi!');

// 输出顺序:
// Promise
// Hi!
// resolved

这说明:

  • Promise 构造函数是同步执行的,所以 'Promise' 最先输出。
  • .then() 中的回调是微任务(microtask) ,会在当前宏任务(script 执行)结束后、下一个宏任务开始前执行,因此 'resolved' 最后输出。

三、Promise 的链式调用与返回新 Promise

1. .then() 返回新 Promise

.then() 方法返回的是一个新的 Promise 实例(注意,不是原来那个 Promise 实例)。因此可以采用链式写法。

这意味着我们可以连续调用多个 .then(),每个 .then() 都可以处理上一个 Promise 的结果。

2. 在 .then() 中返回另一个 Promise

// 5.js 示例
getJSON("/post/1.json")
  .then(post => getJSON(post.commentURL)) // 返回新 Promise
  .then(
    comments => console.log("resolved: ", comments),
    err => console.log("rejected: ", err)
  );

这里的关键在于:第一个 .then() 返回的是 getJSON(...) 的结果,它本身就是一个 Promise。因此,第二个 .then() 会等待这个新 Promise 的状态变化。

  • 如果 post.commentURL 请求成功 → 调用第一个回调(打印 comments)
  • 如果任一环节失败 → 调用第二个回调(打印 error)

这种模式极大简化了多层异步依赖的处理。


四、错误处理:.catch() 的作用


// 6.js 示例
getJSON('/posts.json')
  .then(function (posts) {
    // ...
  })
  .catch(function (error) {
    console.log('发生错误!', error);
  });

根据 readme.md

.catch().then(null, rejection) 的别名,用于指定发生错误时的回调函数。

更重要的是:

  • .catch() 能捕获前面所有 .then() 中抛出的错误(包括同步错误和异步 reject)。
  • 它使得错误处理集中化,避免在每个 .then() 中都写错误回调。

例如:


Promise.resolve()
  .then(() => {
    throw new Error('出错了!');
  })
  .catch(err => {
    console.log(err.message); // "出错了!"
  });

五、嵌套 Promise 与状态传递

这是 Promise 中最容易被误解的部分之一。

// 3.js 示例(注释版)
const p1 = new Promise(function(resolve, reject){
  setTimeout(() => reject(new Error('fail')), 3000);
});

const p2 = new Promise(function(resolve, reject){
  setTimeout(() => resolve(p1), 1000); // resolve 传入的是 p1(另一个 Promise)
});

p2
  .then(result => console.log(result))
  .catch(err => console.log(err)); // 输出 Error: fail

关键点解析:

  • p2 在 1 秒后调用 resolve(p1),但 p1 本身是一个 Promise。
  • 当 resolve() 的参数是一个 Promise 实例时,当前 Promise(p2)的状态将由该 Promise(p1)决定
  • 因此,p2 的状态实际上“代理”了 p1 的状态。
  • 2 秒后(总耗时 3 秒),p1 被 reject,于是 p2 也变为 rejected,触发 .catch()

这一机制使得我们可以“转发”或“组合”多个异步操作,而无需手动监听每个 Promise。


六、实战:链式读取多个文件

// 7.js 示例(修正版)
const p = new Promise((resolve, reject) => {
  FileSystem.readFile('./1.txt', (err, data) => {
    if (err) reject(err);
    else resolve(data);
  });
});

p
  .then(value => {
    return new Promise((resolve, reject) => {
      FileSystem.readFile('./2.txt', (err, data) => {
        if (err) reject(err);
        else resolve([value, data]);
      });
    });
  })
  .then(value => {
    return new Promise((resolve, reject) => {
      FileSystem.readFile('./3.txt', (err, data) => {
        if (err) reject(err);
        else resolve([...value, data]);
      });
    });
  })
  .then(value => {
    console.log(value); // [data1, data2, data3]
  })
  .catch(err => {
    console.error('读取文件出错:', err);
  });

这个例子展示了:

  • 如何通过链式 .then() 依次读取多个文件。
  • 每一步都将之前的结果累积到数组中。
  • 使用 .catch() 统一处理任意一步的 I/O 错误。

虽然现代 Node.js 更推荐使用 fs.promisesasync/await,但此例清晰体现了 Promise 链如何管理依赖型异步流程。


七、最佳实践与注意事项

  1. 始终使用 .catch()
    不要只依赖 .then() 的第二个参数,因为 .then() 内部的同步错误无法被其自身捕获,但能被后续 .catch() 捕获。
  2. 避免“Promise 嵌套地狱”
    不要写 new Promise(resolve => { anotherPromise().then(...) }),应直接返回 Promise。
  3. 理解微任务队列
    Promise 回调属于微任务,执行时机早于 setTimeout 等宏任务。
  4. 不要忽略错误
    未处理的 rejected Promise 会导致“未捕获的异常”,在 Node.js 中可能使进程崩溃。
  5. 考虑使用 async/await
    虽然 Promise 很强大,但在复杂逻辑中,async/await 语法更接近同步代码,可读性更高。

结语

Promise 是 JavaScript 异步编程的基石。它通过状态机模型、链式调用和统一的错误处理机制,有效解决了回调地狱问题。通过本文分析,我们不仅掌握了 Promise 的基本用法,还深入理解了其内部状态传递、嵌套行为和实际应用场景。

掌握 Promise,是迈向现代前端与 Node.js 开发的关键一步。在此基础上,进一步学习 async/awaitPromise.all()Promise.race() 等高级特性,将使你能够构建更加健壮、可维护的异步程序。

正如那句老话:“理解了 Promise,你就理解了 JavaScript 的异步灵魂。

深入理解 JavaScript 词法作用域链:从代码到底层实现机制

一、引言:一个令人困惑的示例

先来看一段看似简单却容易出错的 JavaScript 代码:

// 全局环境
var myName = '极客时间';
let myAgent = 10;
let test = 1;

function bar(){
  console.log(myName);
}

function foo(){
  var myName = '极客邦';
  bar();
}

foo(); // 输出什么?

直觉上,很多人会认为输出应该是 '极客邦',因为 bar() 是在 foo() 内部调用的。但实际上,这段代码输出的是 '极客时间'

为什么会出现这样的结果?这就引出了 JavaScript 中一个核心概念——词法作用域链

二、什么是词法作用域?

词法作用域(Lexical Scope)指的是:变量的可见性由函数在源代码中的声明位置决定,而不是函数被调用的位置

换句话说,解析变量名的"查找路径"(即作用域链)在代码的编译/解析阶段就已经确定好了,与运行时调用栈的顺序无关。这就是为什么 bar() 函数始终访问的是全局的 myName,因为它在源码中就是在全局作用域声明的。

三、更复杂的示例:混合作用域类型

让我们看一个更复杂的例子,包含 varlet 和块级作用域:

function bar() {
  var myName = '极客世界';
  let test1 = 100;
  if (1) {
    let myName = 'Chrome';
    console.log(test); // 这里会输出什么?
  }
}

function foo() {
  var myName = '极客邦';
  let test = 2;
  {
    let test = 3;
    bar();
  }
}

var myName = '极客时间';
let test = 1;
foo();

这段代码展示了:

  • var 的函数级作用域
  • let 的块级作用域
  • 不同位置声明的变量如何相互影响

关键点在于:bar 在源码中声明的位置决定了它能访问的外层词法环境。即使 bar()foo 里的某个块中被调用,它也无法看到 foo 的局部变量(除非 bar 是在 foo 内部声明的)。

四、JavaScript 引擎的内部机制

要真正理解作用域链,我们需要深入到 JavaScript 引擎(如 V8)的实现层面。

执行上下文的组成

每个执行上下文(Execution Context)包含三个核心部分:

  1. Variable Environment(变量环境) - 存储 varfunction 声明
  2. Lexical Environment(词法环境) - 存储 let / const / class 声明
  3. ThisBinding(this 绑定) 及可执行代码

现代 JavaScript 引擎中,变量环境和词法环境是两套独立但协同工作的系统,它们各自维护环境记录(Environment Record),并共享相同的外层指针(outer),构成"并行的作用域链结构"。

编译阶段 vs 执行阶段

JavaScript 函数的执行分为两个关键阶段:

1. 编译阶段(Compilation)

在这个阶段,引擎会:

创建 Variable Environment:

  • 登记 var 声明(初始化为 undefined
  • 登记函数声明(初始化为对应函数对象)

创建 Lexical Environment:

  • 登记 let / const / class 声明,但保持在 TDZ(暂时性死区)
  • 为块级作用域创建独立的词法环境

建立 outer 链接:

  • 确定当前环境的外层环境引用
  • 这个链接基于代码的静态结构,而非运行时调用

2. 执行阶段(Execution)

代码真正开始执行时:

  1. 访问变量时,查找顺序为:

    • 先查 Lexical Environment(块级作用域 + let/const)
    • 找不到则查 Variable Environment(var/function)
    • 再沿着 outer 指针向外层环境查找,直到全局
  2. 环境记录中的值会被不断更新(赋值、初始化等)

执行上下文的内部结构

从实现角度看,执行上下文可以表示为:

Execution Context = {
  EnvironmentRecord: {
    Variable Environment,
    Lexical Environment,
    outer // 指向外层词法环境的引用
  },
  code  // 可执行代码
}

不同类型的声明有不同的处理策略:

  • var:在编译阶段被初始化为 undefined
  • function:在编译阶段被绑定为函数对象
  • let/const:在词法环境中登记,但直到执行到声明语句才正式初始化

五、回到示例:为什么是全局的 myName?

现在我们可以完整解释开头的例子了:

  1. bar 在全局作用域声明,因此 bar.[[Environment]] 指向全局词法环境
  2. bar 执行并访问 myName 时,查找路径是:
    • bar 的局部环境(没有找到)
    • 沿着 [[Environment]] 到全局环境
    • 找到 myName = '极客时间'
  3. barfoo 内部调用的事实不改变[[Environment]] 引用

这就是词法作用域(静态作用域)与动态作用域的核心区别。

六、闭包(closure)是如何“借用”词法作用域的

简单版结论:闭包是函数和其声明时关联的词法环境的组合

function foo(){
  var myName = '极客时间';
  let test1 = 1;
  const test2 = 2;
  var innerBar = {
    getName: function(){
      console.log(test1);
      return myName;
    },
    setName: function(newName){
      myName = newName;
    }
  }
  return innerBar;
}

var bar = foo();
bar.setName('极客邦');
console.log(bar.getName()); // '极客邦'

分析:

  • getName / setNamefoo 内声明,因此它们的 [[Environment]] 指向 foo 的词法环境。
  • foo 返回 innerBar 后,foo 的执行上下文弹出调用栈,但 foo 的词法环境并未被回收,因为 innerBar 中的函数仍然通过闭包引用该环境(环境是“可达”的)。这就是闭包保持自由变量存活的机制。

GC(垃圾回收)角度

  • 只有当 foo 的词法环境不再被任何可达对象(如返回的函数对象)引用时,才会被回收。
  • 因此 bar(上例返回的对象)持有对那块环境的引用,导致 myNametest1 等变量继续存活。

七、常见面试/调试陷阱

  1. 函数在哪里声明,在哪里决定它的外部环境:无论何时调用,外部环境由声明位置决定。
  2. 调用栈 vs 环境:调用栈控制运行顺序和执行上下文的创建/销毁;环境控制变量解析路径,二者不同步。环境包括变量环境和词法环境。
  3. varlet/const 的差别var 是函数级(或全局)绑定且会被提前初始化为 undefinedlet/const 是块级绑定且存在 TDZ。
  4. 闭包不等于内存泄漏:闭包让外层环境继续可达,因此不会被 GC;需要手动断开引用(如把返回对象设为 null)来释放内存。

八、实践建议(写更容易理解、调试的代码)

  • 尽量用 let/const 而不是 var,避免意外提升带来的迷惑。
  • 函数如果需要访问周围变量,尽量把它在恰当的词法位置声明,这样阅读代码时能直观得知依赖关系。
  • 对长期持有闭包引用的场景(如事件回调、定时器、长生命周期对象),显式释放引用或把需要缓存的数据放到显式的对象上,以便管理其生命周期。

九、小结(一句话回顾)

词法作用域链在编译阶段就决定了变量解析路径;闭包则是函数与其声明时词法环境的绑定,正是它使得某些局部变量在函数返回后仍然存活。

ehcarts 实现 饼图扇区间隙+透明外描边

image.png

以上是UI的效果图,大致实现思路可以参考echarts官网的实例 (饼图扇区间隙)实现类似的效果。

image.png

配置如下:

option = {
  tooltip: {
    trigger: 'item'
  },
  legend: {
    top: '5%',
    left: 'center'
  },
  series: [
    {
      name: 'Access From',
      type: 'pie',
      radius: ['35%', '50%'],
      avoidLabelOverlap: false,
      padAngle: 2,
      itemStyle: {
        borderRadius: 0
      },
      label: {
        show: false,
        position: 'center'
      },
      emphasis: {
        label: {
          show: true,
          fontSize: 20,
          fontWeight: 'bold'
        }
      },
      labelLine: {
        show: false
      },
      data: [
        { value: 1048, name: 'Search Engine' },
        { value: 735, name: 'Direct' },
        { value: 580, name: 'Email' },
        { value: 484, name: 'Union Ads' },
        { value: 300, name: 'Video Ads' }
      ]
    },
    {
      name: 'Access From',
      type: 'pie',
      radius: ['50%', '55%'],
      avoidLabelOverlap: false,
      padAngle: 2,
      itemStyle: {
        borderRadius: 0,
        opacity: 0.2
      },
      label: {
        show: false,
        position: 'center'
      },
      emphasis: {
        label: {
          show: false
        }
      },
      labelLine: {
        show: false
      },
      data: [
        { value: 1048, name: 'Search Engine' },
        { value: 735, name: 'Direct' },
        { value: 580, name: 'Email' },
        { value: 484, name: 'Union Ads' },
        { value: 300, name: 'Video Ads' }
      ]
    }
  ]
};

再调整一些参数,基本上能满足UI的效果,这里不详细赘述。

JavaScript 词法作用域与闭包:从底层原理到实战理解

JS运行机制

词法作用域

“词法”这个词听起来有点抽象,其实它的意思很简单: “词法” = “和你写代码的位置有关”

换句话说,JavaScript 中很多行为在你写代码的时候就已经确定了,而不是等到程序运行时才决定。这种特性也叫静态作用域(static scoping)。

你可以这样理解:

代码怎么写的,它就怎么执行——这非常符合我们的直觉。

比如,letconst 声明的变量之所以不能在声明前使用(会报“暂时性死区”错误),就是因为它们属于词法环境的一部分。而词法环境正是由你在源代码中的书写位置决定的。你把变量写在哪里,它就在哪里生效,不能“穿越”到还没写到的地方去用——这很合理,也很直观。

所以,“词法”本质上就是:看代码结构,而不是看运行过程

看一段关于词法作用域的代码

function bar() {
    console.log(myName);
}
function foo() {
    var myName = '极客邦'
    bar()// 运行时
}
var myName = '极客时间'
foo();

这里输出的是

极客时间 为什么输出的不是 "极客邦"

因为 bar 函数是在全局作用域中声明的,所以它的词法作用域链在定义时就已经固定为:自身作用域 → 全局作用域

JavaScript 查找变量时,遵循的是词法作用域规则——也就是说,它只关心函数在哪里被定义,而不关心函数在哪里被调用

bar 内部访问变量(比如 testmyName)时,引擎会先在 bar 自己的执行上下文中查找;如果找不到,就沿着词法作用域链向外层查找,也就是直接跳到全局作用域,而不会进入 foo 的作用域——尽管 bar 是在 foo 里面被调用的。

因此,bar 根本“看不见” foo 中的 myName = "极客邦",自然也就无法输出它。

image.png

总结:

JavaScript 使用 词法作用域(Lexical Scoping) ,也就是说,函数在定义时就决定了它能访问哪些变量,而不是在调用时

词法作用域链:变量查找的路径

当 JavaScript 引擎执行代码时,会为每一段可执行代码创建一个 执行上下文(Execution Context)
每个执行上下文都包含一个 词法环境(Lexical Environment) ,它不仅保存了当前作用域中声明的变量,还持有一个指向外层词法环境的引用。这些嵌套的词法环境连接起来,就形成了 作用域链(Scope Chain)

  • 全局执行上下文位于调用栈的底部,是程序启动时创建的。
  • 每当调用一个函数,就会创建一个新的函数执行上下文,并将其压入调用栈。
  • 当需要查找某个变量时,JavaScript 会从当前作用域开始,沿着作用域链由内向外逐层查找,直到找到该变量,或最终到达全局作用域为止。

这种机制确保了变量访问遵循词法作用域规则——即“在哪里定义,就看哪里的变量”,而不是“在哪里调用”。

看看这段关于作用域链和块级作用域的代码:

function bar () {
  var myName = "极客世界";
  let test1 = 100;
  if (1) {
    let myName = "Chrome 浏览器"  // 1.先在词法环境查找一下
    console.log(test)
  }
}
function foo() {
  var myName = "极客邦";
  let test = 2;
  {
    let test = 3;
    bar()
  }
}
var myName = "极客时间";
let myAge = 10;
let test = 1;
foo();

这段代码的执行结果不会报错,而是会正常输出:

1

原因正是基于 JavaScript 的 词法作用域(Lexical Scoping) 机制。

虽然 bar() 是在 foo() 内部被调用的,但它的声明位置在全局作用域。因此,当 bar 内部引用变量 test 时,JavaScript 引擎会从 bar 自身的作用域开始查找;找不到时,就沿着词法作用域链向外层查找——也就是直接跳到全局作用域,而不会进入 foo 的作用域。

由于全局作用域中存在 let test = 1;,所以 console.log(test) 最终输出的是 1

image.png

换句话说:变量查找看的是函数“在哪里定义”,而不是“在哪里调用” 。这条由内向外的查找路径,就是我们所说的 作用域链

在 JavaScript 的设计中,每个函数的执行上下文都包含一个内部指针(通常称为 [[Outer]] 或 “outer 引用”),它指向该函数定义时所在的作用域——也就是它的词法外层环境

当你在代码中嵌套定义多个函数时,每个函数都会通过这个 outer 指针,链接到它上一层的词法环境。这样一层套一层,就形成了一条静态的、由代码结构决定的链式结构,我们称之为 词法作用域链(Lexical Scope Chain)

正是这条链,决定了变量查找的路径:从当前作用域开始,沿着 outer 指针逐级向外搜索,直到全局作用域为止。

image.png

这种机制是 JavaScript 闭包、变量访问和作用域行为的核心基础。理解了 outer 指针如何连接各个词法环境,你就真正掌握了词法作用域链的本质。

闭包 ——前面内容的优雅升华

闭包(Closure)是 JavaScript 中一个基于词法作用域的核心机制。掌握它,不仅能写出更灵活、模块化的代码,还能轻松应对面试中的高频问题。下面用通俗易懂的方式,带你彻底搞懂闭包。


一、什么是闭包?

闭包 = 一个函数 + 它定义时所处的词法环境。

换句话说:
当一个函数即使在自己原始作用域之外被调用,仍然能够访问并操作其定义时所在作用域中的变量,这个函数就形成了闭包。

这并不是魔法,而是 JavaScript 词法作用域机制的自然结果


二、闭包形成的两个必要条件(缺一不可)

  1. 函数嵌套:内部函数引用了外部函数的变量;
  2. 内部函数被暴露到外部:比如通过 return 返回、赋值给全局变量、作为回调传递等,并在外部被调用。

只有同时满足这两点,闭包才会真正“生效”。


三、经典示例:直观感受闭包

function foo() {
  var myName = "极客时间";
  let test1 = 1;
  const test2 = 2; // 注意:test2 未被内部函数使用

  var innerBar = {
    getName: function () {
      console.log(test1); // 引用了外部变量 test1
      return myName;      // 引用了外部变量 myName
    },
    setName: function (newName) {
      myName = newName;   // 修改外部变量 myName
    }
  };

  return innerBar; // 将内部对象返回,使内部函数可在外部调用
}

// 执行 foo,获取返回的对象
var bar = foo(); // 此时 foo 已执行完毕,上下文出栈

// 在外部调用内部函数 —— 闭包开始工作!
bar.setName("极客邦");
bar.getName();               // 输出:1
console.log(bar.getName());  // 输出:1 和 "极客邦"

输出结果:

1
1
极客邦

四、关键问题:为什么 foo 的变量没被垃圾回收?

  • 通常情况下,函数执行结束后,其局部变量会被垃圾回收。

  • 但在闭包场景中,只要内部函数仍被外部引用,JavaScript 引擎就会保留该函数所依赖的外部变量

  • 在本例中:

    • getName 和 setName 引用了 myName 和 test1 → 这两个变量被“捕获”并保留在内存中;
    • test2 没有被任何函数使用 → 被正常回收。

📌 重点:闭包不会阻止整个函数上下文销毁,只保留“被引用”的变量
这既保证了功能,又避免了内存浪费。

image.png


五、闭包的本质与词法作用域的关系

1. 闭包的本质

闭包不是某种特殊语法,而是一种运行时行为

函数 + 它出生时的词法环境 = 闭包

你可以把它想象成:函数随身带了一个“背包”,里面装着它定义时能访问的所有外部变量。无论它走到哪里(哪怕在全局调用),都能从背包里取用或修改这些数据。

2. 与词法作用域的关联

  • 词法作用域:变量的作用域由代码书写位置决定(静态的、编译期确定)。
  • 闭包:正是词法作用域在函数被传递到外部后依然生效的体现。

✅ 所以说:闭包不是额外特性,而是词法作用域 + 函数作为一等公民 的必然产物。


💡 记住一句话
闭包不是“不让变量销毁”,而是“还有人用,所以不能销毁”。
它让 JavaScript 实现了私有状态、模块封装、回调记忆等强大能力。

理解闭包,你就真正迈入了 JavaScript 高阶编程的大门。

黑马喽大闹天宫与JavaScript的寻亲记:作用域与作用域链全解析

黑马喽大闹天宫与JavaScript的寻亲记:作用域与作用域链全解析

开场白:一个变量的"无法无天"与它的"寻亲之路"


📖 第一章:黑马喽的嚣张岁月

话说在前端江湖的ES5时代,有个叫var的黑马喽,这家伙简直无法无天!它想来就来,想走就走,完全不顾什么块级作用域的规矩。

// 你们看看这黑马喽的德行
for (var i = 0; i < 3; i++) {
    setTimeout(() => {
        console.log(i); // 你猜输出啥?3,3,3!
    }, 100);
}
// 循环结束了,i还在外面晃荡
console.log(i); // 3,瞧瞧,跑出来了吧!

但今天咱们不仅要扒一扒var的底裤,还要讲讲变量们是怎么"寻亲"的——这就是作用域作用域链的故事。


🔧 第二章:编译器的三把斧——代码的"梳妆打扮"

要说清楚作用域,得先从JavaScript的编译说起。别看JS是解释型语言,它在执行前也要经历一番"梳妆打扮"。

2.1 词法分析:拆解字符串的魔术

想象一下,编译器就像个认真的语文老师,把代码这个长句子拆成一个个有意义的词语:

var a = 1vara=1

注意:空格要不要拆开,得看它有没有用。就像读书时要不要停顿,得看语气!

2.2 语法分析:构建家谱树

拆完词之后,编译器开始理清关系——谁声明了谁,谁赋值给谁,最后生成一棵抽象语法树(AST)

这就像把一堆零散的家庭成员信息,整理成清晰的家谱。

2.3 代码生成:准备执行

最后,编译器把家谱树转换成机器能懂的指令,准备执行。

关键点:JS的编译发生在代码执行前的一瞬间,快到你几乎感觉不到!


💕 第三章:变量赋值的三角恋

var a = 1这么简单的一行代码,背后居然上演着一场"三角恋":

  • 🎯 编译器:干脏活累活的媒人,负责解析和牵线
  • JS引擎:执行具体动作的新郎
  • 🏠 作用域:管理宾客名单的管家

3.1 订婚仪式(编译阶段)

// 当看到 var a = 1;
编译器:管家,咱们这有叫a的变量吗?
作用域:回大人,还没有。
编译器:那就在当前场合声明一个a!

3.2 结婚典礼(执行阶段)

JS引擎:管家,我要找a这个人赋值!
作用域:大人请,a就在这儿。
JS引擎:好,把1赋给a!

这里涉及到两种查找方式:

LHS查询:找容器(找新娘)

var a = 1; // 找到a这个容器装1

RHS查询:找源头(找新娘的娘家)

console.log(a); // 找到a的值
foo();         // 找到foo函数本身

编译过程示意图


🐒 第四章:黑马喽的罪证展示

在ES5时代,var这家伙真是目中无人:

4.1 无视块级作用域

{
    var rogue = "我是黑马喽,我想去哪就去哪";
}
console.log(rogue); // 照样能访问!

4.2 变量提升的诡计

console.log(naughty); // undefined,而不是报错!
var naughty = "我提升了";

这货相当于:

var naughty;          // 声明提升到顶部
console.log(naughty); // undefined
naughty = "我提升了"; // 赋值留在原地

🙏 第五章:如来佛祖的五指山——let和const

ES6时代,如来佛祖(TC39委员会)看不下去了,派出了letconst两位大神:

5.1 块级作用域的紧箍咒

{
    let disciplined = "我在块里面很老实";
    const wellBehaved = "我也是好孩子";
}
console.log(disciplined); // ReferenceError!出不来咯

5.2 暂时性死区的降妖阵

console.log(rebel); // ReferenceError!此路不通
let rebel = "想提升?没门!";

真相let/const其实也会提升,但是被关进了"暂时性死区"这个五指山里,在声明前谁都别想访问!


🧩 第六章:黑马喽的迷惑行为——词法作用域的真相

6.1 一个让黑马喽困惑的例子

function bar(){
    console.log( myName);  // 黑马喽:这里该输出啥?
}

function foo(){
    var myName = "白吗喽";
    bar()
    console.log("1:", myName)   // 这个我懂,输出"白吗喽"
}

var myName = "黑吗喽";
foo()  // 输出:"黑吗喽","白吗喽"

黑马喽挠着头想:"不对啊!bar()foo()里面调用,不是应该找到foo()里的myName = "白吗喽"吗?怎么会是黑吗喽呢?"

6.2 outer指针:函数的"身份证"

原来,在编译阶段,每个函数就已经确定了自己的"娘家"(词法作用域):

// 编译阶段发生的事情:
// 1. bar函数出生,它的outer指向全局作用域(它声明在全局)
// 2. foo函数出生,它的outer也指向全局作用域(它声明在全局)
// 3. 变量myName声明提升:var myName = "黑吗喽"

// 执行阶段:
var myName = "黑吗喽";  // 全局myName赋值为"黑吗喽"
foo();                 // 调用foo函数

黑马喽的错误理解

bar() → foo() → 全局

实际的作用域查找(根据outer指针):

bar() → 全局

如图

C9AE3D8E-F1DA-4767-AE87-AF4B1AF8B94D.png

6.3 词法作用域 vs 动态作用域

词法作用域(JavaScript):看出生地

var hero = "全局英雄";

function createWarrior() {
    var hero = "部落勇士";
    
    function fight() {
        console.log(hero); // 永远输出"部落勇士"
    }
    
    return fight;
}

const warrior = createWarrior();
warrior(); // "部落勇士" - 记得出生时的环境

动态作用域:看调用地(JavaScript不是这样!)

// 假设JavaScript是动态作用域(实际上不是!)
var hero = "战场英雄";
const warrior = createWarrior();
warrior(); // 如果是动态作用域,会输出"战场英雄"

🗺️ 第七章:作用域链——变量的寻亲路线图

7.1 每个函数都带着"出生证明"

var grandma = "奶奶的糖果";

function mom() {
    var momCookie = "妈妈的饼干";
    
    function me() {
        var myCandy = "我的棒棒糖";
        console.log(myCandy);    // 自己口袋找
        console.log(momCookie);  // outer指向mom
        console.log(grandma);    // outer的outer指向全局
    }
    
    me();
}

mom();

7.2 作用域链的建造过程

// 全局作用域
var city = "北京";

function buildDistrict() {
    var district = "朝阳区";
    
    function buildStreet() {
        var street = "三里屯";
        console.log(street);     // 自己的
        console.log(district);   // outer指向buildDistrict
        console.log(city);       // outer的outer指向全局
    }
    
    return buildStreet;
}

// 编译阶段就确定的关系:
// buildStreet.outer = buildDistrict作用域
// buildDistrict.outer = 全局作用域

如图

9625B41F-066C-4BD2-AF8B-44B93C395CF9.png

⚔️ 第八章:作用域链的实战兵法

8.1 兵法一:模块化开发

function createCounter() {
    let count = 0; // 私有变量,外部无法直接访问
    
    return {
        increment: function() {
            count++; // 闭包:outer指向createCounter作用域
            return count;
        },
        getValue: function() {
            return count;
        }
    };
}

const counter = createCounter();
console.log(counter.increment()); // 1
// console.log(count); // 报错!count是私有的

8.2 兵法二:解决循环陷阱

黑马喽的坑
for (var i = 0; i < 3; i++) {
    setTimeout(function() {
        console.log(i); // 3, 3, 3 - 所有函数共享同一个i
    }, 100);
}
作用域链的救赎
// 方法1:使用let创建块级作用域
for (let i = 0; i < 3; i++) {
    setTimeout(function() {
        console.log(i); // 0, 1, 2 - 每个i都有自己的作用域
    }, 100);
}

// 方法2:IIFE创建新作用域
for (var i = 0; i < 3; i++) {
    (function(j) {
        setTimeout(function() {
            console.log(j); // 0, 1, 2 - j在IIFE作用域中
        }, 100);
    })(i);
}

8.3 兵法三:正确的函数嵌套

function foo(){
    var myName = "yang";
    
    function bar(){  // 现在bar的outer指向foo了!
        console.log("2:", myName);  // 找到foo的myName
    }
    
    bar()
    console.log("1:", myName)
}

var myName = "yang1";
foo()  // 输出:2: yang, 1: yang

🚀 第九章:现代JavaScript的作用域体系

9.1 块级作用域的精细化管理

function modernScope() {
    var functionScoped = "函数作用域";
    let blockScoped = "块级作用域";
    
    if (true) {
        let innerLet = "内部的let";
        var innerVar = "内部的var"; // 依然提升到函数顶部!
        
        console.log(blockScoped); // ✅ 可以访问外层的let
        console.log(functionScoped); // ✅ 可以访问外层的var
    }
    
    console.log(innerVar); // ✅ 可以访问
    // console.log(innerLet); // ❌ 报错!let是块级作用域
}

9.2 作用域链的新层级

// 全局作用域
const GLOBAL = "地球";

function country() {
    // 函数作用域
    let nationalLaw = "国家法律";
    
    {
        // 块级作用域1
        let provincialLaw = "省法规";
        
        if (true) {
            // 块级作用域2
            let cityRule = "市规定";
            
            console.log(cityRule);     // ✅ 本市有效
            console.log(provincialLaw); // ✅ 本省有效
            console.log(nationalLaw);   // ✅ 全国有效
            console.log(GLOBAL);        // ✅ 全球有效
        }
        
        // console.log(cityRule); // ❌ 跨市无效
    }
}

⚡ 第十章:作用域链的性能与优化

10.1 作用域查找的代价

var globalVar = "我在最外层";

function level3() {
    // 这个查找要经过:自己 → level2 → level1 → 全局
    console.log(globalVar);
}

function level2() {
    level3();
}

function level1() {
    level2();
}

10.2 优化心法

function optimized() {
    const localCopy = globalVar; // 局部缓存,减少查找深度
    
    function inner() {
        console.log(localCopy); // 直接访问,快速!
    }
    
    inner();
}

🏆 大结局:黑马喽的毕业总结

经过这番学习,黑马喽终于明白了作用域的真谛:

🎯 作用域的进化史

  1. ES5的混乱var无视块级作用域,到处捣乱
  2. ES6的秩序let/const引入块级作用域和暂时性死区
  3. outer指针机制:词法作用域在编译时确定,一辈子不变

🧠 作用域链的精髓

  1. outer指针:函数在编译时就确定了自己的"娘家"
  2. 词法作用域:看出生地,不是看调用地
  3. 就近原则:先找自己,再按outer指针找上级
  4. 闭包的力量:函数永远记得自己出生时的环境

💡 最佳实践心法

// 好的作用域设计就像好的家风
function createFamily() {
    // 外层:家族秘密,内部共享
    const familySecret = "传家宝";
    
    function teachChild() {
        // 中层:教育方法
        const education = "严格教育";
        
        return function child() {
            // 内层:个人成长
            const talent = "天赋异禀";
            console.log(`我有${talent},接受${education},知道${familySecret}`);
        };
    }
    
    return teachChild();
}

const familyMember = createFamily();
familyMember(); // 即使独立生活,依然记得家族传承

🌟 终极奥义

黑马喽感慨地总结道:

"原来JavaScript的作用域就像血缘关系:

  • 作用域是家规(在哪里能活动)
  • 作用域链是族谱(怎么找到祖先)
  • outer指针是出生证明(一辈子不变)
  • 词法作用域是家族传承(看出生地,不是看现住地)"

从此,黑马喽明白了:想要在前端江湖混得好,就要遵守作用域的家规,理解作用域链的族谱,尊重outer指针的出生证明!


🐒 黑马喽寄语:记住,函数的作用域是它的"娘家",编译时定亲,一辈子不变!理解了这套规则,你就能驯服任何JavaScript代码!

AI生成CAD图纸(云原生CAD+AI让设计像聊天一样简单)

项目概述

本章节探讨AI技术与在线CAD相结合,能否打造一个能让CAD"听懂人话"的智能助手。

核心价值:告别繁琐的手动绘图,用自然语言就能完成CAD设计。无论是建筑工程师、机械设计师,还是CAD开发者,都能通过AI大幅提升工作效率。

为什么选择MxCAD来做CAD智能系统?

1. 原子化API - AI时代的CAD开发利器

传统CAD软件的问题是:你只能用它给你的功能,比如"画直线"、"画圆"这样的整体功能。但MxCAD的API把所有功能都拆得特别细,就像乐高积木一样:

// 传统方式:只能调用drawCircle()
drawCircle(center, radius);
// MxCAD原子化API:AI可以精确控制每个细节
const center = new McGePoint3d(100, 100, 0);  // 精确控制圆心
const circle = new McDbCircle();              // 创建圆对象
circle.center = center;                       // 设置圆心
circle.radius = 50;                           // 设置半径
circle.trueColor = new McCmColor(255, 0, 0);  // 精确控制颜色
entitys.push(circle);                         // 添加到图纸

这对AI意味着什么?

- AI可以像人类工程师一样思考,理解每个几何元素的含义

- 可以精确控制颜色、图层、线型等所有属性

- 能处理复杂的空间变换和几何计算

- 生成的代码质量更高,更符合工程规范

2. 智能体策略 - 让AI像专业工程师一样思考

我们设计了三种AI智能体,各自负责不同的专业领域:

A.建模智能体(ModelingAgent)

专业领域 :CAD图形创建和迭代修改

工作流程

1. 接收自然语言指令(如"画一个带圆角的矩形,长100宽60,圆角半径5")

2. 分析需求,拆解为几何元素

3. 生成精确的MxCAD代码

4. 在沙箱中预览效果

5. 自动修复可能的错误

6. 最终插入到图纸中

技术亮点

- 支持代码迭代修改:"刚才那个矩形,把圆角改成10"

- 自动管理实体数组,避免重复和遗漏

- 智能错误修复:代码执行失败时自动分析错误并修复

- 最多重试3次,确保成功率  

B.通用智能体(DefaultAgent)

专业领域 :CAD图纸操作和查询

典型任务

- "选中所有长度大于100的直线"

- "把图层"标注"的颜色改成红色"

- "计算这个区域的面积"

- "导出选中的实体为DXF"  

技术亮点

- 理解CAD专业术语和概念

- 能操作图层、线型、标注等CAD特有功能

- 支持复杂的选择条件和过滤

C.意图识别智能体(IntentRecognitionAgent)

角色 :智能调度员

工作原理

1. 关键词匹配:快速识别用户意图(如包含"画"、"创建"等词 → 建模智能体)

2. LLM深度分析:复杂请求调用大语言模型分析

3. 智能路由:自动选择最合适的智能体处理

优势:用户无需手动选择模式,系统智能判断

3.安全沙箱-让AI代码安全运行

为什么需要沙箱?

AI生成的代码可能包含错误、无限循环或恶意代码,直接在主应用中执行可能导致崩溃或数据丢失。

我们的解决方案

用户输入 → AI生成代码 → 沙箱预览 → 错误检测 → 自动修复 → 用户确认 → 插入图纸

技术实现

- 在隔离的iframe中执行代码

- 限制访问DOM和本地存储

- 捕获所有错误信息

- 提供详细的错误堆栈分析

- 支持最多3次自动修复尝试  

用户体验

- 实时预览AI绘图效果

- 确认无误后再插入图纸

- 随时取消或重新生成

- 支持撤销操作

AI功能详解

自然语言到CAD代码转换

示例1:绘制复杂机械零件

用户:画一个法兰盘,外径200,内径1006个直径20的螺栓孔均匀分布
AI生成代码:
// 创建外圆
const outerCircle = new McDbCircle();
outerCircle.center = new McGePoint3d(0, 0, 0);
outerCircle.radius = 100;
entitys.push(outerCircle);
// 创建内圆
const innerCircle = new McDbCircle();
innerCircle.center = new McGePoint3d(0, 0, 0);
innerCircle.radius = 50;
entitys.push(innerCircle);
// 创建6个螺栓孔
for (let i = 0; i < 6; i++) {
  const angle = (i * 60) * Math.PI / 180;
  const hole = new McDbCircle();
  hole.center = new McGePoint3d(75 * Math.cos(angle), 75 * Math.sin(angle), 0);
  hole.radius = 10;
  entitys.push(hole);
}

示例2:建筑平面图

用户:画一个10m×8m的房间,墙厚240mm,门宽900mm在右侧墙中央
AI自动计算所有尺寸,生成符合建筑规范的墙体和门窗

智能代码优化

自动补全import语句

// AI生成的代码可能缺少import
const line = new McDbLine(); // 错误:McDbLine未定义
// 系统自动补全
import { McDbLine, McGePoint3d } from "mxcad";
const line = new McDbLine(); // 正确

管理实体数组

// AI可能忘记将实体添加到图纸
const circle = new McDbCircle();
// 缺少 entitys.push(circle);
// 系统自动检测并添加
const circle = new McDbCircle();
entitys.push(circle); // 自动添加

智能修复语法错误

// AI可能生成有语法错误的代码
const point = new McGePoint3d(0, 0, 0) // 缺少分号
// 系统自动修复
const point = new McGePoint3d(0, 0, 0); // 自动添加分号

多AI模型支持

支持的AI提供商

- OpenRouter:统一接口,支持DeepSeek、Llama、Gemini等100+模型

- OpenAI:GPT-4、GPT-3.5等官方模型

- iFlow:国产大模型,包括通义千问、Kimi、DeepSeek等

- 自定义:支持任何OpenAI兼容的API

模型选择策略

- 免费模型:适合测试和简单任务

- 付费模型:适合复杂任务和高质量要求

- 国产模型:适合数据安全要求高的场景

实际应用场景

场景一:建筑工程师 - 快速绘制标准户型

传统方式

1. 打开CAD软件

2. 选择画线工具

3. 输入起点坐标(0,0)

4. 输入终点坐标(10000,0)  // 10米墙

5. 重复步骤3-4,画4面墙

6. 选择偏移工具,偏移240mm生成内墙线

7. 选择修剪工具,修剪墙角

8. 插入门、窗图块

9. 添加尺寸标注

10. 整个过程约15-30分钟  

AI方式

输入:画一个10m×8m的房间,墙厚240mm,门宽900mm在右侧墙中央,窗宽1500mm在左侧墙中央
AI响应:✅ 已生成标准房间平面图
- 外墙:10m×8m,墙厚240mm
- 门:900mm宽,位于右侧墙中央
- 窗:1500mm宽,位于左侧墙中央
- 已添加尺寸标注
用时:10秒

场景二:机械设计师 - 参数化零件设计

传统方式

- 手动计算所有尺寸

- 逐个绘制每个特征

- 容易出错,修改困难

AI方式

输入:生成一个M10螺栓,长度50mm,头部六角对边16mm

AI响应:✅ 已生成M10螺栓模型

  • 螺纹公称直径:10mm
  • 螺栓长度:50mm
  • 六角头对边宽度:16mm
  • 符合GB/T 5782标准 用时:5秒

场景三:图纸修改-智能批量操作

传统方式

- 手动查找需要修改的元素

- 逐个修改,耗时且容易遗漏

AI方式

输入:把所有标注文字的字体改成仿宋,字高改为3.5mm

AI响应:✅ 已修改23个标注对象

  • 字体:仿宋
  • 字高:3.5mm
  • 修改对象:23个尺寸标注 用时:3秒  

技术架构深度解析

代码执行流程

代码执行流程.png

核心模块说明

1. agents/AgentStrategy.ts

- 智能体策略接口定义

- 智能体实例管理

- 智能体选择逻辑

2. agents/ModelingAgent.ts

- CAD建模专用智能体

- 代码生成与修改

- 错误自动修复

3. agents/IntentRecognitionAgent.ts

- 用户意图识别

- 智能体路由调度

- 对话状态管理

4. core/LLMClient.ts

- 多AI提供商支持

- 请求管理与取消

- 错误处理与重试

5. core/codeModificationUtils.ts

- 代码智能修改

- JSON指令解析

- 语法错误修复

6. sandbox.ts

- 沙箱环境初始化

- 代码安全执行

- 错误信息捕获

7. services/openRouterAPI.ts

- AI模型管理

- API配置管理

- 模型缓存机制  

快速体验AI智能体服务

首先打开demo2.mxdraw3d.com:3000/mxcad/, 如下图: 点击使用AI服务.png 打开AI服务会弹出一个胶囊输入框。我们点击设置按钮,如下图: 打开设置按钮.png 我们需要线配置AI的api接口。这里我们选择iflow AI服务 这是目前国内免费的最佳供应商,如下图: 设置apiKey的弹框.png

具有配置如下:

首先我们打开iflow.cn 登录账号,然后我们鼠标移入头像,找到api管理,如下图: iflow找到api管理设置.png

我们把api key填写到MxCAD AI服务中,如下图: iflow复制apikey.png

选择模型商: iFlow

填写API Key: 刚刚复制的key粘贴在这里, 模型选择: 支持很多模型,都可以,甚至稍微差一些的模型都可以,iFlow目前所有的模型都是免费使用。

然后我们点击“保存”按钮。就可以开始在胶囊输入框内输入你的需求了,比如:一个比较抽象的需求, "画一朵花" 然后按下回车键,如下图: 需求:花一朵花.png 等待一会儿, 就把代码生成出来给你看,并且还有预览效果,如果满意的话点击确认就可以把这朵花插入到图元中了。如果不满意,我们可以继续与AI对话进行修改,如下图: 需求:花一朵花的效果.png 比如现在我们觉得这个花不够精致。我们和AI说, “花不够精致”。然后按下回车键,如下图: 需求:花不够精致.png

需求:花不够精致的效果.png 我们可以不断的让AI修改代码,从而达到一个满意的效果。但要真正投入使用,还需要结合具体的需求调整提示词和整个智能体的流程,以上演示的是建模智能体的能力。而通用智能体的能力,目前主要是用于操作一些实体。

比如:"选中实体按照原本比例放大10倍,间距还是原本的间距" image.png 我们点击生成的代码点击运行,效果就出来了,如下图: image-1.png 还有很多操作,只要是代码可以完成的操作,都可以通过AI配合网页CAD完成。

Konvajs实现虚拟表格

这是一个专栏 从零实现多维表格,此专栏将带你一步步实现一个多维表格,缓慢更新中

本文涉及的代码

虚拟表格

虚拟表格(Virtual Table) 是一种优化技术,用于处理大量数据时的性能问题。它只渲染当前可见区域(视口)内的表格单元格,而不是渲染整个表格的所有数据。

实现原理

一个简单的虚拟表格实现主要包括以下两点(注:一个完善的虚拟表格需要关注的方面更多,这里只讨论核心实现,后续的优化项会在本专栏的后续文章中实现)

  1. 按需渲染:只创建和渲染用户当前能看到的数据行和列
  2. 滚动监听:监听容器滚动事件,动态计算新的可见范围

代码大纲

基于上述原理,我们可以写出如下代码:

import Konva from "konva";
import { Layer } from "konva/lib/Layer";
import { Stage } from "konva/lib/Stage";

export type Column = {
  title: string;
  width: number;
};

type VirtualTableConfig = {
  container: HTMLDivElement;
  columns: Column[];
  dataSource: Record<string, any>[];
};

type Range = { start: number; end: number };

class VirtualTable {
  // =========== 表格基础属性 ===========
  rows: number = 20;
  cols: number = 20;
  columns: Column[];
  stage: Stage;
  layer: Layer;
  dataSource: TableDataSource;

  // =========== 虚拟表格实现 ===========
  // 滚动相关属性
  scrollTop: number = 0;
  scrollLeft: number = 0;
  maxScrollTop: number = 0;
  maxScrollLeft: number = 0;
  visibleRowCount: number = 0;
  // 可见行列范围
  visibleRows: Range = { start: 0, end: 0 };
  visibleCols: Range = { start: 0, end: 0 };
  // 表格可见宽高
  visibleWidth: number;
  visibleHeight: number;

  constructor(config: VirtualTableConfig) {
    const { container, columns, dataSource } = config;
    this.columns = columns;
    this.dataSource = dataSource;
    this.visibleWidth = container.getBoundingClientRect().width;
    this.visibleHeight = container.getBoundingClientRect().height;
    this.visibleRowCount = Math.ceil(this.visibleHeight / ROW_HEIGHT);
    this.maxScrollTop = Math.max(
      0,
      (this.rows - this.visibleRowCount) * ROW_HEIGHT
    );

    // 计算总列宽
    const totalColWidth = this.columns.reduce((sum, col) => sum + col.width, 0);
    this.maxScrollLeft = Math.max(0, totalColWidth - this.visibleWidth);

    this.stage = new Konva.Stage({
      container,
      height: this.visibleHeight,
      width: this.visibleWidth,
    });
    this.layer = new Konva.Layer();
    this.stage.add(this.layer);

    // 监听滚动事件
    this.bindScrollEvent(container);
    // 初始化调用
    this.updateVisibleRange();
    this.renderCells();
  }

  // 监听滚动事件
  bindScrollEvent() {
    this.updateVisibleRange();
    this.renderCells();
  }

  // 计算可见行列范围
  updateVisibleRange() {}

  // 渲染可见范围内的 cell
  renderCells() {}
}

export default VirtualTable;

计算可见行列范围

updateVisibleRange() {
    // 计算可见行
    const startRow = Math.floor(this.scrollTop / ROW_HEIGHT);
    const endRow = Math.min(
      startRow + this.visibleRowCount,
      this.dataSource.length
    );
    this.visibleRows = { start: startRow, end: endRow };

    // 计算可见列
    let accumulatedWidth = 0;
    let startCol = 0;
    let endCol = 0;

    // 计算开始列
    for (let i = 0; i < this.columns.length; i++) {
      const col = this.columns[i];
      if (accumulatedWidth + col.width >= this.scrollLeft) {
        startCol = i;
        break;
      }
      accumulatedWidth += col.width;
    }

    // 计算结束列
    accumulatedWidth = 0;
    for (let i = startCol; i < this.columns.length; i++) {
      const col = this.columns[i];
      accumulatedWidth += col.width;
      if (accumulatedWidth > this.visibleWidth) {
        endCol = i + 1;
        break;
      }
    }

    this.visibleCols = {
      start: startCol,
      end: Math.min(endCol, this.columns.length),
    };
  }

滚动事件监听


  /**
   * 绑定滚动事件
   */
  bindScrollEvent(container: HTMLDivElement) {
    container.addEventListener("wheel", (e) => {
      e.preventDefault();
      this.handleScroll(e.deltaX, e.deltaY);
    });

    // 支持触摸滚动
    let lastTouchY = 0;
    let lastTouchX = 0;
    container.addEventListener("touchstart", (e: TouchEvent) => {
      const touch = e.touches?.[0];
      if (touch) {
        lastTouchY = touch.clientY;
        lastTouchX = touch.clientX;
      }
    });

    container.addEventListener("touchmove", (e: TouchEvent) => {
      const touch = e.touches?.[0];
      if (touch) {
        const deltaY = lastTouchY - touch.clientY;
        const deltaX = lastTouchX - touch.clientX;
        this.handleScroll(deltaX, deltaY);
        lastTouchY = touch.clientY;
        lastTouchX = touch.clientX;
      }
    });
  }

  /**
   * 处理滚动
   */
  handleScroll(deltaX: number, deltaY: number) {
    // 更新滚动位置
    this.scrollTop = Math.max(
      0,
      Math.min(this.scrollTop + deltaY, this.maxScrollTop)
    );
    this.scrollLeft = Math.max(
      0,
      Math.min(this.scrollLeft + deltaX, this.maxScrollLeft)
    );

    // 更新可见行列范围
    this.updateVisibleRange();

    // 更新单元格渲染
    this.renderCells();
  }

单元格渲染逻辑


  /**
   * 获取指定行的 Y 坐标
   * @param rowIndex - 行索引
   * @returns Y 坐标值
   */
  getRowY(rowIndex: number): number {
    return rowIndex * ROW_HEIGHT;
  }

  /**
   * 获取指定列的 X 坐标
   * @param colIndex - 列索引
   * @returns X 坐标值
   */
  getColX(colIndex: number): number {
    let x = 0;
    for (let i = 0; i < colIndex; i++) {
      const col = this.columns[i];
      if (col) {
        x += col.width;
      }
    }
    return x;
  }

  renderCell(rowIndex: number, colIndex: number) {
    const column = this.columns[colIndex];
    if (!column) return;
    // 计算坐标时考虑滚动偏移
    const x = this.getColX(colIndex) - this.scrollLeft;
    const y = this.getRowY(rowIndex) - this.scrollTop;
    // 创建单元格
    const group = new Konva.Group({
      x,
      y,
    });
    const rect = new Konva.Rect({
      x: 0,
      y: 0,
      width: column.width,
      height: ROW_HEIGHT,
      fill: "#FFF",
      stroke: "#ccc",
      strokeWidth: 1,
    });

    // 创建文本
    const text = new Konva.Text({
      x: 8,
      y: 8,
      width: column.width - 16,
      height: 16,
      text: this.dataSource[rowIndex][colIndex],
      fontSize: 14,
      fill: "#000",
      align: "left",
      verticalAlign: "middle",
      ellipsis: true,
    });
    group.add(rect);
    group.add(text);
    this.layer.add(group);
  }

  /**
   * 渲染可见范围内的所有单元格
   * 首先清除旧单元格,然后按行列重新渲染
   */
  renderCells() {
    this.layer.destroyChildren();
    // 渲染数据行
    for (
      let rowIndex = this.visibleRows.start;
      rowIndex <= this.visibleRows.end;
      rowIndex++
    ) {
      for (
        let colIndex = this.visibleCols.start;
        colIndex <= this.visibleCols.end;
        colIndex++
      ) {
        this.renderCell(rowIndex, colIndex);
      }
    }
  }

本文涉及的代码

Bipes项目二次开发/设置功能-1(五)

Bipes项目二次开发/设置功能-1(五)

设置功能,这一期改动有点多,可能后期也会继续出设置功能-n文章,这一期是编程模式,那做目的有两个: 1,代码设计 现在确定做的模式有三种,硬件编程,离线编程,海龟编程三种。每种模式所涉及的代码不同,所以得划分出来。

2,可配置性 后期可能会出一些定制开发,界面就可以通过配置,进行界面调整。

编程模式

html

页面初始内容

<div class="settings-preview">
    <div id="settings-modal">
      <h3>设置</h3>
      <div class="settings-group">
        <label>模式选择</label>
        <div class="radio-group">
          <div class="radio-option">
            <input type="radio" id="mode-hardware" name="programMode" value="hardware" checked>
            <span>硬件编程</span>
          </div>
          <div class="radio-option">
            <input type="radio" id="mode-offline" name="programMode" value="offline">
            <span>离线编程</span>
          </div>
          <div class="radio-option">
            <input type="radio" id="mode-turtle" name="programMode" value="turtle">
            <span>海龟编程</span>
          </div>
        </div>
      </div>
      <div class="modal-actions">
        <button id="cancel-settings" class="btn btn-secondary">取消</button>
        <button id="save-settings" class="btn btn-primary">保存</button>
      </div>
    </div>
  </div>

js

import Common from "./common";

export default class SettingPreview extends Common {
    constructor() {
        super()
        this.state = false // 设置弹窗是否显示
    }
    initEvent() {
        $('#settingsButton').on('click', () => {
            this.changeSetting(!this.state)
        })
        
        // 添加取消按钮事件监听
        $('#cancel-settings').on('click', () => {
            this.changeSetting(false)
        })
        
        // 添加确认按钮事件监听
        $('#save-settings').on('click', this.saveSettings.bind(this))
    }
    changeSetting(state) {
        let status = state ? 'on' : 'off'
        $('.settings-preview').css('visibility', (state ? 'visible' : 'hidden'))
        $('#settingsButton').css('background', `url(../media/new-icon/setting-${status}.png) center / cover`)
        
        if (state) {
            // 显示时可以加载已保存的设置
            this.loadSettings();
        }

        this.state = state
    }
    
    // 保存设置到本地缓存
    saveSettings() {
        // 获取选中的模式
        let selectedMode = 'hardware'; // 默认值
        const selectedRadio = $('input[name="programMode"]:checked');
        if (selectedRadio.length > 0) {
            selectedMode = selectedRadio.val();
        }
        
        // 创建设置对象并保存到本地缓存
        const settings = {
            mode: selectedMode
        };
        
        try {
            localStorage.setItem('settings', JSON.stringify(settings));
            console.log('设置已保存:', settings);
        } catch (error) {
            console.error('保存设置失败:', error);
        }

        this.changeSetting(false)
    }
    
    // 从本地缓存加载设置
    loadSettings() {
        try {
            const savedSettings = localStorage.getItem('settings');
            if (savedSettings) {
                const settings = JSON.parse(savedSettings);
                if (settings.mode) {
                    // 设置选中的单选按钮
                    $(`input[name="programMode"][value="${settings.mode}"]`).prop('checked', true);
                }
            }
        } catch (error) {
            console.error('加载设置失败:', error);
        }
    }
}

界面效果

在这里插入图片描述

总结

出这一期主要针对编程模式,不同模式下做不同功能。 硬件编程:保留原有功能,通过连接板子,与板子通信,在板子上运行编写好的代码,做出不同效果 离线编程:学习,了解编程 海龟编程:学习,了解编程,让编程变得不枯燥。

【Promise.withResolvers】发现这个api还挺有用

Jym好😘,我是珑墨。

在 es 的异步编程世界中,Promise 已经成为处理异步操作的标准方式。然而,在某些场景下,传统的 Promise 构造函数模式显得不够灵活。Promise.withResolvers 是 ES2024(ES14)中引入的一个静态方法,它提供了一种更优雅的方式来创建 Promise,并同时获得其 resolve 和 reject 函数的引用。

look:

什么是 Promise.withResolvers

Promise.withResolvers 是一个静态方法,它返回一个对象,包含三个属性:

  • promise: 一个 Promise 对象
  • resolve: 用于解决(fulfill)该 Promise 的函数
  • reject: 用于拒绝(reject)该 Promise 的函数

基本语法

const { promise, resolve, reject } = Promise.withResolvers();

这个方法的核心优势在于:你可以在 Promise 外部控制其状态,这在许多场景下非常有用。


为什么 Promise.withResolvers挺实用?

先看传统 Promise 的局限性

Promise.withResolvers 出现之前,如果我们想要在 Promise 外部控制其状态,通常需要这样做:

let resolvePromise;
let rejectPromise;

const myPromise = new Promise((resolve, reject) => {
  resolvePromise = resolve;
  rejectPromise = reject;
});

// 现在可以在外部使用 resolvePromise 和 rejectPromise
setTimeout(() => {
  resolvePromise('成功!');
}, 1000);

这种方法虽然可行,但存在以下问题:

  1. 代码冗余:每次都需要创建临时变量,会导致一坨地雷
  2. 作用域污染:需要在外部作用域声明变量
  3. 不够优雅:代码结构不够清晰
  4. 容易出错:如果忘记赋值,会导致运行时错误

Promise.withResolvers解决了啥?

Promise.withResolvers 解决了上述所有问题:

const { promise, resolve, reject } = Promise.withResolvers();

// 简洁、清晰、安全
setTimeout(() => {
  resolve('成功!');
}, 1000);

语法和用法

基本语法

const { promise, resolve, reject } = Promise.withResolvers();

返回值

Promise.withResolvers() 返回一个普通对象,包含:

  • promise: 一个处于 pending 状态的 Promise 对象
  • resolve: 一个函数,调用时会将 promise 变为 fulfilled 状态
  • reject: 一个函数,调用时会将 promise 变为 rejected 状态

基本示例

示例 1:简单的延迟解析

const { promise, resolve } = Promise.withResolvers();

// 1 秒后解析 Promise
setTimeout(() => {
  resolve('数据加载完成');
}, 1000);

promise.then(value => {
  console.log(value); // 1 秒后输出: "数据加载完成"
});

示例 2:处理错误

const { promise, resolve, reject } = Promise.withResolvers();

// 模拟异步操作
setTimeout(() => {
  const success = Math.random() > 0.5;
  if (success) {
    resolve('操作成功');
  } else {
    reject(new Error('操作失败'));
  }
}, 1000);

promise
  .then(value => console.log(value))
  .catch(error => console.error(error));

示例 3:多次调用 resolve/reject 的行为

const { promise, resolve, reject } = Promise.withResolvers();

resolve('第一次');
resolve('第二次'); // 无效,Promise 状态已确定
reject(new Error('错误')); // 无效,Promise 状态已确定

promise.then(value => {
  console.log(value); // 输出: "第一次"
});

重要提示:一旦 Promise 被 resolve 或 reject,其状态就确定了,后续的 resolve 或 reject 调用将被忽略。


与传统方法的对比

场景 1:事件监听器中的 Promise

传统方法

function waitForClick() {
  let resolveClick;
  let rejectClick;
  
  const promise = new Promise((resolve, reject) => {
    resolveClick = resolve;
    rejectClick = reject;
  });
  
  const button = document.getElementById('myButton');
  const timeout = setTimeout(() => {
    button.removeEventListener('click', onClick);
    rejectClick(new Error('超时'));
  }, 5000);
  
  function onClick(event) {
    clearTimeout(timeout);
    button.removeEventListener('click', onClick);
    resolveClick(event);
  }
  
  button.addEventListener('click', onClick);
  
  return promise;
}

使用 Promise.withResolvers

function waitForClick() {
  const { promise, resolve, reject } = Promise.withResolvers();
  
  const button = document.getElementById('myButton');
  const timeout = setTimeout(() => {
    button.removeEventListener('click', onClick);
    reject(new Error('超时'));
  }, 5000);
  
  function onClick(event) {
    clearTimeout(timeout);
    button.removeEventListener('click', onClick);
    resolve(event);
  }
  
  button.addEventListener('click', onClick);
  
  return promise;
}

优势

  • 代码更简洁
  • 不需要在外部作用域声明变量
  • 结构更清晰

场景 2:流式数据处理

传统方法

function createStreamProcessor() {
  let resolveStream;
  let rejectStream;
  
  const promise = new Promise((resolve, reject) => {
    resolveStream = resolve;
    rejectStream = reject;
  });
  
  // 模拟流式处理
  const chunks = [];
  let isComplete = false;
  
  function processChunk(chunk) {
    if (isComplete) return;
    chunks.push(chunk);
    
    if (chunk.isLast) {
      isComplete = true;
      resolveStream(chunks);
    }
  }
  
  function handleError(error) {
    if (isComplete) return;
    isComplete = true;
    rejectStream(error);
  }
  
  return { promise, processChunk, handleError };
}

使用 Promise.withResolvers

function createStreamProcessor() {
  const { promise, resolve, reject } = Promise.withResolvers();
  
  const chunks = [];
  let isComplete = false;
  
  function processChunk(chunk) {
    if (isComplete) return;
    chunks.push(chunk);
    
    if (chunk.isLast) {
      isComplete = true;
      resolve(chunks);
    }
  }
  
  function handleError(error) {
    if (isComplete) return;
    isComplete = true;
    reject(error);
  }
  
  return { promise, processChunk, handleError };
}

实际应用场景

场景 1:用户交互等待

// 等待用户确认操作
function waitForUserConfirmation(message) {
  const { promise, resolve, reject } = Promise.withResolvers();
  
  const modal = document.createElement('div');
  modal.className = 'confirmation-modal';
  modal.innerHTML = `
    <p>${message}</p>
    <button class="confirm">确认</button>
    <button class="cancel">取消</button>
  `;
  
  modal.querySelector('.confirm').addEventListener('click', () => {
    if (modal.parentNode) {
      document.body.removeChild(modal);
    }
    resolve(true);
  });
  
  modal.querySelector('.cancel').addEventListener('click', () => {
    if (modal.parentNode) {
      document.body.removeChild(modal);
    }
    reject(new Error('用户取消'));
  });
  
  document.body.appendChild(modal);
  
  return promise;
}

// 使用
waitForUserConfirmation('确定要删除这个文件吗?')
  .then(() => console.log('用户确认'))
  .catch(() => console.log('用户取消'));

场景 2:WebSocket 消息等待

class WebSocketManager {
  constructor(url) {
    this.ws = new WebSocket(url);
    this.pendingRequests = new Map();
    this.requestId = 0;
    
    this.ws.onmessage = (event) => {
      const data = JSON.parse(event.data);
      const { requestId, response, error } = data;
      
      const pending = this.pendingRequests.get(requestId);
      if (pending) {
        this.pendingRequests.delete(requestId);
        if (error) {
          pending.reject(new Error(error));
        } else {
          pending.resolve(response);
        }
      }
    };
  }
  
  sendRequest(message) {
    const { promise, resolve, reject } = Promise.withResolvers();
    const requestId = ++this.requestId;
    
    this.pendingRequests.set(requestId, { resolve, reject });
    
    this.ws.send(JSON.stringify({
      requestId,
      message
    }));
    
    // 设置超时
    setTimeout(() => {
      if (this.pendingRequests.has(requestId)) {
        this.pendingRequests.delete(requestId);
        reject(new Error('请求超时'));
      }
    }, 5000);
    
    return promise;
  }
}

// 使用
const wsManager = new WebSocketManager('ws://example.com');
wsManager.sendRequest('获取用户信息')
  .then(data => console.log('收到响应:', data))
  .catch(error => console.error('错误:', error));

场景 3:文件上传进度

function uploadFileWithProgress(file, url) {
  const { promise, resolve, reject } = Promise.withResolvers();
  
  const xhr = new XMLHttpRequest();
  const formData = new FormData();
  formData.append('file', file);
  
  xhr.upload.addEventListener('progress', (event) => {
    if (event.lengthComputable) {
      const percentComplete = (event.loaded / event.total) * 100;
      console.log(`上传进度: ${percentComplete.toFixed(2)}%`);
    }
  });
  
  xhr.addEventListener('load', () => {
    if (xhr.status === 200) {
      resolve(JSON.parse(xhr.responseText));
    } else {
      reject(new Error(`上传失败: ${xhr.status}`));
    }
  });
  
  xhr.addEventListener('error', () => {
    reject(new Error('网络错误'));
  });
  
  xhr.addEventListener('abort', () => {
    reject(new Error('上传已取消'));
  });
  
  xhr.open('POST', url);
  xhr.send(formData);
  
  // 返回 Promise 和取消函数
  return {
    promise,
    cancel: () => xhr.abort()
  };
}

// 使用
const { promise, cancel } = uploadFileWithProgress(file, '/api/upload');
promise
  .then(result => console.log('上传成功:', result))
  .catch(error => console.error('上传失败:', error));

场景 4:可取消的异步操作

function createCancellableOperation(operation) {
  const { promise, resolve, reject } = Promise.withResolvers();
  let cancelled = false;
  
  operation()
    .then(result => {
      if (!cancelled) {
        resolve(result);
      }
    })
    .catch(error => {
      if (!cancelled) {
        reject(error);
      }
    });
  
  return {
    promise,
    cancel: () => {
      cancelled = true;
      reject(new Error('操作已取消'));
    }
  };
}

// 使用
const { promise, cancel } = createCancellableOperation(
  () => fetch('/api/data').then(r => r.json())
);

// 3 秒后取消
setTimeout(() => cancel(), 3000);

promise
  .then(data => console.log('数据:', data))
  .catch(error => console.error('错误:', error));

场景 5:队列处理

class TaskQueue {
  constructor() {
    this.queue = [];
    this.processing = false;
  }
  
  add(task) {
    const { promise, resolve, reject } = Promise.withResolvers();
    
    this.queue.push({
      task,
      resolve,
      reject
    });
    
    this.process();
    
    return promise;
  }
  
  async process() {
    if (this.processing || this.queue.length === 0) {
      return;
    }
    
    this.processing = true;
    
    while (this.queue.length > 0) {
      const { task, resolve, reject } = this.queue.shift();
      
      try {
        const result = await task();
        resolve(result);
      } catch (error) {
        reject(error);
      }
    }
    
    this.processing = false;
  }
}

// 使用
const queue = new TaskQueue();

queue.add(() => fetch('/api/task1').then(r => r.json()))
  .then(result => console.log('任务1完成:', result));

queue.add(() => fetch('/api/task2').then(r => r.json()))
  .then(result => console.log('任务2完成:', result));

深入理解:工作原理

Promise.withResolvers 的实现原理

虽然 Promise.withResolvers 是原生 API,但我们可以通过理解其等价实现来加深理解:

// Promise.withResolvers 的等价实现
function withResolvers() {
  let resolve, reject;
  const promise = new Promise((res, rej) => {
    resolve = res;
    reject = rej;
  });
  return { promise, resolve, reject };
}

内存和性能考虑

Promise.withResolvers 的实现是高度优化的。它:

  1. 避免闭包开销:原生实现避免了额外的闭包创建
  2. 内存效率:直接返回引用,无需额外的变量存储
  3. 性能优化:浏览器引擎级别的优化

与 Promise 构造函数的关系

// 这两种方式是等价的(在功能上)
const { promise, resolve, reject } = Promise.withResolvers();

// 等价于
let resolve, reject;
const promise = new Promise((res, rej) => {
  resolve = res;
  reject = rej;
});

Promise.withResolvers 提供了:

  • 更简洁的语法
  • 更好的可读性
  • 标准化的 API

浏览器兼容性和 Polyfill

浏览器支持

Promise.withResolvers 是 ES2024 的特性,目前(2024年)的支持情况:

  • ✅ Chrome 119+
  • ✅ Firefox 121+
  • ✅ Safari 17.4+
  • ✅ Node.js 22.0.0+
  • ❌ 旧版本浏览器不支持

Polyfill 实现

如果需要在不支持的浏览器中使用,可以使用以下 polyfill:

if (!Promise.withResolvers) {
  Promise.withResolvers = function() {
    let resolve, reject;
    const promise = new Promise((res, rej) => {
      resolve = res;
      reject = rej;
    });
    return { promise, resolve, reject };
  };
}

使用 Polyfill 的完整示例

// 在项目入口文件添加
(function() {
  if (typeof Promise.withResolvers !== 'function') {
    Promise.withResolvers = function() {
      let resolve, reject;
      const promise = new Promise((res, rej) => {
        resolve = res;
        reject = rej;
      });
      return { promise, resolve, reject };
    };
  }
})();

// 现在可以在任何地方使用
const { promise, resolve, reject } = Promise.withResolvers();

使用 core-js

如果你使用 core-js,可以导入相应的 polyfill:

import 'core-js/actual/promise/with-resolvers';

最佳实践和注意

1. 避免重复调用 resolve/reject

const { promise, resolve, reject } = Promise.withResolvers();

resolve('第一次');
resolve('第二次'); // 无效,但不会报错

// 最佳实践:添加状态检查
let isResolved = false;
function safeResolve(value) {
  if (!isResolved) {
    isResolved = true;
    resolve(value);
  }
}

2. 处理错误情况

const { promise, resolve, reject } = Promise.withResolvers();

try {
  // 某些可能抛出错误的操作
  const result = riskyOperation();
  resolve(result);
} catch (error) {
  reject(error);
}

3. 清理资源

function createResourceManager() {
  const { promise, resolve: originalResolve, reject: originalReject } = Promise.withResolvers();
  const resources = [];
  
  function cleanup() {
    resources.forEach(resource => resource.cleanup());
  }
  
  // 创建包装函数,确保在 resolve 或 reject 时清理资源
  const resolve = (value) => {
    cleanup();
    originalResolve(value);
  };
  
  const reject = (error) => {
    cleanup();
    originalReject(error);
  };
  
  return { promise, resolve, reject };
}

4. 类型安全(TypeScript)

在 TypeScript 中,Promise.withResolvers 的类型定义:

interface PromiseWithResolvers<T> {
  promise: Promise<T>;
  resolve: (value: T | PromiseLike<T>) => void;
  reject: (reason?: any) => void;
}

// 使用
const { promise, resolve, reject }: PromiseWithResolvers<string> = 
  Promise.withResolvers<string>();

5. 避免内存泄漏

// 不好的做法:持有大量未完成的 Promise,没有清理机制
const pendingPromises = new Map();

function createRequest(id) {
  const { promise, resolve } = Promise.withResolvers();
  pendingPromises.set(id, { promise, resolve });
  return promise;
  // 问题:如果 Promise 永远不会 resolve,会一直占用内存
}

// 好的做法:设置超时和清理机制
const pendingPromises = new Map(); // 在实际应用中,这应该是类或模块级别的变量

function createRequestWithTimeout(id, timeout = 5000) {
  const { promise, resolve, reject } = Promise.withResolvers();
  
  const timeoutId = setTimeout(() => {
    if (pendingPromises.has(id)) {
      pendingPromises.delete(id);
      reject(new Error('请求超时'));
    }
  }, timeout);
  
  pendingPromises.set(id, {
    promise,
    resolve: (value) => {
      clearTimeout(timeoutId);
      pendingPromises.delete(id);
      resolve(value);
    },
    reject: (error) => {
      clearTimeout(timeoutId);
      pendingPromises.delete(id);
      reject(error);
    }
  });
  
  return promise;
}

6. 与 async/await 结合使用

async function processWithResolvers() {
  const { promise, resolve, reject } = Promise.withResolvers();
  
  // 在异步操作中控制 Promise
  setTimeout(() => {
    resolve('完成');
  }, 1000);
  
  try {
    const result = await promise;
    console.log('结果:', result);
  } catch (error) {
    console.error('错误:', error);
  }
}

总结下

Promise.withResolvers 是 es 异步编程的一个重要补充,它解决了在 Promise 外部控制其状态的需求。通过本文的详细讲解,我们了解到:

核心要点

  1. 简洁性:提供了更优雅的 API 来创建可外部控制的 Promise
  2. 实用性:在事件处理、流式处理、WebSocket 等场景中非常有用
  3. 标准化:作为 ES2024 标准的一部分,提供了统一的解决方案

适用场景

  • ✅ 需要在 Promise 外部控制其状态
  • ✅ 事件驱动的异步操作
  • ✅ 流式数据处理
  • ✅ 可取消的异步操作
  • ✅ 队列和任务管理

注意

  • ⚠️ 浏览器兼容性(需要 polyfill 或现代浏览器)
  • ⚠️ 尤其得避免重复调用 resolve/reject
  • ⚠️ 注意资源清理和内存管理

参考资料


原来Webpack在大厂中这样进行性能优化!

性能优化方案

优化分类:

  1. 优化打包后的结果(分包、减小包体积、CDN 服务器) ==> 更重要
  2. 优化打包速度(exclude、cache-loader)

代码分割(Code Splitting)

一、主要目的

  • 减少首屏加载体积:避免一次性加载全部代码
  • 利用浏览器缓存:第三方库(如 React、Lodash)变动少,可单独缓存
  • 按需加载/并行请求:路由、组件、功能模块只在需要时加载(按需加载或者并行加载文件,而不是一次性加载所有代码)

二、三种主要的代码分割方式

1. 入口起点(Entry Points)手动分割

通过配置多个 entry 实现。

// webpack.config.js
module.exports = {
  entry: {
    main: './src/main.js',
    vendor: './src/vendor.js', // 手动引入公共依赖
  },
  output: {
    filename: '[name].[contenthash].js',
    path: path.resolve(__dirname, 'dist'),
  },
};

缺点:

  • 无法自动提取公共依赖(比如 mainvendor 都用了 Lodash,会重复打包)
  • 维护成本高

上面写的是通用配置,但我们在公司一般会分别配置开发和生产环境的配置。大多数项目中,entry 在 dev 和 prod 基本一致,无需差异化配置。差异主要体现在 output 和其他插件/加载器行为上。

// webpack.config.prod.js
module.exports = {
  mode: 'production',
  entry: './src/index.js',
  output: {
    filename: 'js/[name].[contenthash:8].js', // 生产环境用 [contenthash](而非 [hash] 或 [chunkhash]),确保精准缓存
    chunkFilename: 'js/[name].[contenthash:8].js',
    path: path.resolve(__dirname, 'dist'), // 必须输出到磁盘用于部署
    publicPath: '/static/', // 用于 CDN 或静态资源服务器
    clean: true, // 清理旧文件
  },
};
// webpack.config.dev.js
module.exports = {
  mode: 'development',
  entry: './src/index.js',
  output: {
    filename: 'js/[name].js',  // 开发环境若加 hash,每次保存都会生成新文件,可能干扰热更新或者devtools混乱
    chunkFilename: 'js/[name].js',
    path: path.resolve(__dirname, 'dist'), // 通常仍写 dist,但实际不写入磁盘(webpack-dev-server 默认内存存储),节省IO,提高编译速度
    publicPath: '/', // 与 devServer 一致
    // clean: false (默认)
  },
};
2. SplitChunksPlugin(推荐!自动代码分割)

自动提取公共模块和第三方库。webpack 已默认安装相关插件。

默认行为(仅在 production 模式生效):

// webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'async', // 默认只分割异步模块
    },
  },
};

常用配置:

// webpack.config.prod.js
optimization: { 
  // 自动分割
  // https://twitter.com/wSokra/status/969633336732905474
  // https://medium.com/webpack/webpack-4-code-splitting-chunk-graph-and-the-splitchunks-optimization-be739a861366    
  splitChunks: {
    // chunks: async | initial(对通过的代码处理) | all(同步+异步都处理)
    chunks: 'initial',
    minSize: 20000, // 模块大于 20KB 才分割(Webpack 5 默认值)
    maxSize: 244000, // 单个 chunk 最大不超过 244KB(可选)
    cacheGroups: { // 拆分分组规则
      // 提取 node_modules 中的第三方库
      vendor: {
        test: /[\\/]node_modules[\\/]/, // 匹配符合规则的包
        name: 'vendors', // 拆分包的name 属性
        chunks: 'initial',
        priority: 10, // 优先级高于 default
        enforce: true,
      },
      // 提取多个 chunk 公共代码
      default: {
        minChunks: 2, // 至少被 2 个 chunk 引用
        priority: -20,
        reuseExistingChunk: true, // 复用已存在的 chunk
        maxInitialRequests: 5, // 默认限制太小,无法显示效果
        minSize: 0, // 这个示例太小,无法创建公共块
      },
    },
  },
  // runtime相关的代码是否抽取到一个单独的chunk中,比如import动态加载的代码就是通过runtime 代码完成的
  // 抽离出来利于浏览器缓存,比如修改了业务代码,那么runtime加载的chunk无需重新加载
  runtimeChunk: true,
}

在开发环境下 splitChunks: false, 即可。

生产环境:

  • 生成 vendors.xxxx.js(第三方库)
  • 生成 default.xxxx.js(项目公共代码)
  • 主 bundle 体积显著减小
3. 动态导入(Dynamic Imports)—— 按需加载

使用 import() 语法(符合 ES Module 规范),实现懒加载。

Webpack 会为每个 import() 创建一个独立的 chunk,并自动处理加载逻辑。

三、魔法注释(Magic Comments)—— 控制 chunk 名称等行为

// 自定义 chunk 名称(便于调试和长期缓存)
const module = await import(
  /* webpackChunkName: "my-module" */
  './my-module'
);

其他常见注释:

  • /* webpackPrefetch: true */:空闲时预加载(提升后续访问速度)
  • /* webpackPreload: true */:当前导航关键资源预加载(慎用)
// 预加载“下一个可能访问”的页面
import(
  /* webpackChunkName: "login-page" */
  /* webpackPrefetch: true */
  './LoginPage'
);

详细比较:

  • preload chunk 会在父 chunk 加载时,以并行方式开始加载。prefetch chunk 会在父 chunk 加载结束后开始加载。
  • preload chunk 具有中等优先级,并立即下载。prefetch chunk 在浏览器闲置时下载。

CND

内容分发网络(Content Delivery Network 或 Content Distribution Network)

它是指通过相互连接的网络系统,利用最靠近每个用户的服务器;更快、更可靠地将音乐、图片、视频、应用程序及其他文件发送给用户;提供高性能、可扩展性及低成本的网络内容传递。

工作中,我们使用 CDN 的主要方式有两种:

  1. 打包所有静态资源,放到 CDN 服务器,用户所有资源都是通过 CND 服务器加载的
    1. 通过 output.publicPath 改为自己的的 CDN 服务器,打包后就可以从上面获取资源
    2. 如果是自己的话,一般会从阿里、腾讯等买 CDN 服务器。
  2. 一些第三方资源放在 CDN 服务器上
    1. 一些库/框架会将打包后的源码放到一些免费的 CDN 上,比如 JSDeliver、bootcdn 等
    2. 这样的话,打包的时候就不需要对这些库进行打包,直接使用 CDN 服务器中的源码(通过 externals 配置排除某些包)

CSS 提取

将 css 提取到一个独立的 css 文件。

npm install mini-css-extract-plugin -D
// webpack.config.prod.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  mode: 'production',
  module: {
    rules: [
      // 生产环境:使用 MiniCssExtractPlugin.loader
      {
        test: /\.css$/i,
        use: [
          MiniCssExtractPlugin.loader, // 替换 style-loader
          'css-loader',
          'postcss-loader',
        ],
      },
      {
        test: /\.s[ac]ss$/i,
        use: [
          MiniCssExtractPlugin.loader,
          'css-loader',
          'postcss-loader',
          'sass-loader',
        ],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: 'css/[name].[contenthash:8].css',
      chunkFilename: 'css/[name].[contenthash:8].css',
    }),
  ],
};

Terser 代码压缩

Terser 可以帮助我们压缩、丑化(混淆)我们的代码,让我们的 bundle 变得更小。

Terser 是一个单独的工具,拥有非常多的配置,这里我们只讲工作中如何使用,以一个工程的角度学习这个工具。

真实开发中,我们不需要手动的通过 terser 来处理我们的代码。webpack 中 minimizer 属性,在 production 模式下,默认就是使用的 TerserPlugin 来处理我们代码的。我们也可以手动创建 TerserPlugin 实例覆盖默认配置。

// webpack.prod.js 
const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  mode: 'production',
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin({
        parallel: true, // 多核 CPU 并行压缩,默认为true,并发数默认为os.cpus().length-1
        terserOptions: {
          compress: { // 压缩配置
            drop_console: true,
            drop_debugger: true, // 删除debugger
            pure_funcs: ['console.info', 'console.debug'], // 只删除特定的函数调用
          },
          mangle: true, // 是否丑化代码(变量)
          toplevel: true, // 顶层变量是否进行转换
          keep_classnames: true, // 是否保留类的名称
          keep_fnames: true, // 是否保留函数的名称
          format: {
            comments: /@license|@preserve/i, // 保留含 license/preserve 的注释(某些开源库要求保留版权注释)
          },
        },
        extractComments: true, // 默认为true会将注释提取到一个单独的文件(这里用于保留版权注释),false表示不希望保留注释
        sourceMap: true,   // 需要 webpack 配置 devtool 生成 source map
      }),
    ],
  },
};

不要在开发环境启动 terser,因为:

  • 压缩会拖慢构建速度
  • 混淆后的代码无法调试
  • hmr 和 source-map 会失效

CSS 压缩

CSS 压缩通常是去除无用的空格等,因为很难去修改选择器、属性的名称、值等;我们一般使用插件 css-minimizer-webpack-plugin;他的底层是使用 cssnano 工具来优化、压缩 CSS(也可以单独使用)。

使用也是非常简单:

minimizer: [
  new CssMiniMizerPlugin()({
    parallel: true
  })
]

Tree Shaking 摇树

详情见之前文章:《简单聊聊 webpack 摇树的原理》

HTTP 压缩

HTTP 压缩(HTTP Compression)是一种 在服务器和客户端之间传输数据时减小响应体体积 的技术,通过压缩 HTML、CSS、JavaScript、JSON 等文本资源,显著提升网页加载速度、节省带宽。

一、主流压缩算法

算法 兼容性 压缩率 速度 说明
gzip ✅ 几乎所有浏览器(IE6+) 最广泛使用,Web 标准推荐
Brotli (br) ✅ 现代浏览器(Chrome 49+, Firefox 44+, Safari 11+) ⭐ 更高(比 gzip 高 15%~30%) 较慢(压缩),解压快 推荐用于静态资源
deflate ⚠️ 支持不一致(部分浏览器实现有问题) 已基本淘汰,不推荐使用

二、工作原理(协商压缩)

HTTP 压缩基于 请求头 ↔ 响应头协商机制:

  1. 客户端请求(表明支持的压缩格式)
GET /app.js HTTP/1.1
Host: example.com
Accept-Encoding: gzip, deflate, br // 客户端支持的压缩算法列表
  1. 服务端响应(返回压缩后的内容)
HTTP/1.1 200 OK
Content-Encoding: br  // 服务端使用的压缩算法
Content-Type: application/javascript
Content-Length: 102400  // 注意:这是压缩后的大小!

...(二进制压缩数据)...
  • 浏览器自动解压,开发者无感知

三、如何启用 HTTP 压缩?

我们一般会优先使用 Nginx 配置做压缩(生产环境最常用),这样就无需应用层处理。

除此之外,我们还会进行预压缩 + 静态文件服务,这主要就是 webpack 要做的工作。

在构建阶段(Webpack/Vite)就生成 .gz.br 文件,部署到 CDN 或静态服务器。

// webpack.config.js
const CompressionPlugin = require('compression-webpack-plugin');

module.exports = {
  plugins: [
    // 生成 .gz 文件
    new CompressionPlugin({
      algorithm: 'gzip',
      test: /\.(js|css|html|svg)$/,
      threshold: 8192, // 大于 8KB 才压缩
      minRatio: 0.8,  // 至少的压缩比例
    }),
    // 生成 .br 文件(需额外安装)
    new CompressionPlugin({
      algorithm: 'brotliCompress',
      test: /\.(js|css|html|svg)$/,
      compressionOptions: { level: 11 }, // 最高压缩率
    }),
  ],
};

Nginx 配合预压缩文件:

gzip_static on;    # 优先返回 .gz 文件
brotli_static on;  # 优先返回 .br 文件

打包分析

打包时间分析

我们需要借助一个插件 speed-measure-webpack-plugin,即可看到每个 loader、每个 plugin 消耗的打包时间。

// webpack.config.js
const SpeedMeasurePlugin = require('speed-measure-webpack-plugin');

const smp = new SpeedMeasurePlugin();

const config = {
  // 你的正常 Webpack 配置
  entry: './src/index.js',
  module: { /* ... */ },
  plugins: [ /* ... */ ],
};

// 仅当环境变量 ANALYZE_SPEED=1 时包裹配置
module.exports = process.env.ANALYZE_SPEED ? smp.wrap(config) : config;

打包文件分析

方法一、生成 stats.json 文件
"build:stats": "w--config ./config/webpack.common.js --env production --profile --json=stats.json",

运行 npm run build:stats,可以获取到一个 stats.json 文件,然后放到到 webpack.github.com/analyse 进行分析。

方法二、webpack-bundle-analyzer

更常用的方式是使用 webpack-bundle-analyzer 插件分析。

// webpack.prod.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  mode: 'production',
  plugins: [
    // 其他插件...
    new BundleAnalyzerPlugin({
      analyzerMode: 'static', // 生成静态 HTML 报告(默认)
      openAnalyzer: false,    // 不自动打开浏览器
      reportFilename: 'bundle-report.html',
      generateStatsFile: true, // 可选:同时生成 stats.json
      statsFilename: 'stats.json',
    }),
  ],
};

MCP理论和实战,然后做个MCP脚手架吧

引言: 本文介绍了目前MCP Server的开发方式和原理,包括streamable HTTP和STDIO两种。并提供了一个npm脚手架工具帮你创建项目,每个模板项目都是可运行的。

streamable HTTP

原理分析

抓包「握手」

MCP Client总共发了三次请求,MCP Server响应2次。实际的握手流程是4次握手,第5次请求是为了通知后续的信息(比如进度,日志等。 目前规范实现来看,第5次握手不影响正常功能)

使用wiresshark抓包结果如下:

image.png

image.png

从官网的「initialization」流程来看,也就是4次(第5次未来应该会被普遍实现)

image.png

第1次 Post请求,initialize 方法

{
  "jsonrpc": "2.0",
  "id": 0,
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-06-18",
    "capabilities": {
      "sampling": {},
      "elicitation": {},
      "roots": {
        "listChanged": true
      }
    },
    "clientInfo": {
      "name": "inspector-client",
      "version": "0.17.2"
    }
  }
}

第2次 :200 OK,响应体如下

{
  "result": {
    "protocolVersion": "2025-06-18",
    "capabilities": {
      "tools": {
        "listChanged": true
      }
    },
    "serverInfo": {
      "name": "weather",
      "version": "0.0.1"
    }
  },
  "jsonrpc": "2.0",
  "id": 0
}

第3次 :Post请求,notifications/initialized方法

{"jsonrpc":"2.0","method":"notifications/initialized"}

第4次 :202 Accepted,无响应体

第5次 :Get请求,此时要求服务端一定是SSE传输了-accept: text/event-stream

GET /mcp HTTP/1.1
accept: text/event-stream

总结「握手」流程

  1. POST /mcp (initialize)

    • 客户端:你好,我是 Inspector Client,我想初始化。
    • 服务器:收到,这是我的能力列表(200 OK)。
    • 状态:JSON-RPC 会话开始。
  2. POST /mcp (notifications/initialized)

    • 客户端:我已经收到你的能力了,初始化完成。
    • 服务器:收到 (202 Accepted)。
    • 状态:逻辑握手完成。
  3. GET /mcp (Header: accept: text/event-stream)

    • 目的:客户端现在试图建立长连接通道,以便在未来能收到服务器发来的通知(比如 notifications/message 或 roots/listChanged)。如果没有这个通道,服务器就变成了“哑巴”,无法主动联系客户端。

后续通信

tools/list (列出工具)

client->server 请求

请求头:

POST /mcp HTTP/1.1
accept: application/json, text/event-stream
accept-encoding: gzip, deflate, br
content-length: 85
content-type: application/json
mcp-protocol-version: 2025-06-18
user-agent: node-fetch
Host: localhost:3000
Connection: keep-alive

请求数据:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/list",
  "params": {
    "_meta": {
      "progressToken": 1
    }
  }
}

P.S. params中的progressToken是可以用于后续的进度通知的(通过SSE)

server->client 响应

响应头:

HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json
Date: Thu, 27 Nov 2025 11:52:31 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

响应体:

{
  "result": {
    "tools": [
      {
        "name": "get_weather_now",
        "title": "Get Weather Now",
        "description": "Get current weather for a location (city name)",
        "inputSchema": {
          "$schema": "http://json-schema.org/draft-07/schema#",
          "type": "object",
          "properties": {
            "location": {
              "description": "Location name or city (e.g. beijing, shanghai, new york, tokyo)",
              "type": "string"
            }
          },
          "required": [
            "location"
          ]
        }
      }
    ]
  },
  "jsonrpc": "2.0",
  "id": 1
}

这里列出一个工具:

  • get_weather_now,我们自己定义/注册的工具。我们可以拿到它的titledescriptioninputSchema,这些语义信息可以帮助LLM理解这个工具。
tools/call (调用tool)

这里通过 mcp inspector 工具调用了get_weather_now,请求体如下:

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "_meta": {
      "progressToken": 2
    },
    "name": "get_weather_now",
    "arguments": {
      "location": "北京"
    }
  }
}

响应体:

{
  "result": {
    "content": [
      {
        "type": "text",
        "text": "Weather for 北京, CN:\nCondition: 晴\nTemperature: 3°C\nLast Update: 2025-11-27T19:50:14+08:00"
      }
    ]
  },
  "jsonrpc": "2.0",
  "id": 2
}
方法小总结

上面我们列出了两种常见的方法

  • tools/list。MCP Client在向LLM发请求携带列出的tool,LLM会告诉客户端调用的tool name,然后由MCP client来触发tool调用。
  • tools/call。MCP Client告诉MCP Server 调用哪个tool。

可以结合官网的这张示意图,调用tool就是一次request/response。如果是长任务,可以通过_meta.progressToken作为关联,通过SSE持续通知进度(还记得「握手」流程的第5次握手吗)

image.png

代码实战 - 天气工具

准备天气API

这里我使用了心知天气的API,然后自己封装一个node API。 src/core/seniverse.ts

import * as crypto from 'node:crypto';
import * as querystring from 'node:querystring';
/**
 * 查询天气接口
 */
const API_URL = 'https://api.seniverse.com/v3/';
export class SeniverseApi {
    publicKey;
    secretKey;
    constructor(publicKey, secretKey) {
        this.publicKey = publicKey;
        this.secretKey = secretKey;
    }
    async getWeatherNow(location) {
        const params = {
            ts: Math.floor(Date.now() / 1000), // Current timestamp (seconds)
            ttl: 300, // Expiration time
            public_key: this.publicKey,
            location: location
        };
        // Step 2: Sort keys and construct the string for signature
        // "key=value" joined by "&", sorted by key
        const sortedKeys = Object.keys(params).sort();
        const str = sortedKeys.map(key => `${key}=${params[key]}`).join('&');
        // Step 3: HMAC-SHA1 signature
        const signature = crypto
            .createHmac('sha1', this.secretKey)
            .update(str)
            .digest('base64');
        // Step 4 & 5: Add sig to params and encode for URL
        // querystring.encode will handle URL encoding of the signature and other params
        params.sig = signature;
        const queryString = querystring.encode(params);
        const url = `${API_URL}weather/now.json?${queryString}`;
        try {
            const response = await fetch(url);
            if (!response.ok) {
                throw new Error(`HTTP error! status: ${response.status}`);
            }
            return await response.json();
        }
        catch (error) {
            console.error("Error making Seniverse request:", error);
            return null;
        }
    }
}

src/core/index.ts

import { SeniverseApi } from './seniverse.js';

export const seniverseApi = new SeniverseApi(
  process.env.SENIVERSE_PUBLIC_KEY || '',
  process.env.SENIVERSE_SECRET_KEY || '',
);

搭建streamable HTTP类型的MCP

1.使用express提供后端服务,然后设置/mcp endpoint(一般来说MCP client默认就是访问这个endpoint). 2.在MCP协议中,握手/工具调用等都是通过这个一个endpoint来完成的。

3.封装逻辑 封装了一个MyServer

  • run方法启动HTTP服务
  • init方法注册工具

4.核心是McpServerStreamableHTTPServerTransport两个API

  • McpServer: 负责注册tool.
  • StreamableHTTPServerTransport: 接管了/mcp endpoint的通信逻辑
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";
import { z } from "zod";
import "dotenv/config";
import { seniverseApi } from "./core/index.js";

export class MyServer {
  private mcpServer: McpServer;
  private app: express.Express
  constructor() {
    this.mcpServer = new McpServer({
      name: "weather",
      version: "0.0.1",
    });

    // Set up Express and HTTP transport
    this.app = express();
    this.app.use(express.json());

    this.app.use('/mcp', async (req: express.Request, res: express.Response) => {
        // Create a new transport for each request to prevent request ID collisions
        const transport = new StreamableHTTPServerTransport({
            sessionIdGenerator: undefined,
            enableJsonResponse: true
        });

        res.on('close', () => {
            transport.close();
        });

        await this.mcpServer.connect(transport);
        await transport.handleRequest(req, res, req.body);
    });

  }

  /**
   * 在端口运行Server, 通过HTTP stream传输数据
   */
  async run(): Promise<void> {
    const port = parseInt(process.env.PORT || '3000');
    this.app.listen(port, () => {
        console.log(`Demo MCP Server running on http://localhost:${port}/mcp`);
    }).on('error', error => {
        console.error('Server error:', error);
        process.exit(1);
    });
    
  }

  /**
   * 初始化,注册工具
   */
  async init(): Promise<void> {
    // Register weather tool
    this.mcpServer.registerTool(
      "get_weather_now",
      {
        title: "Get Weather Now",
        description: "Get current weather for a location (city name)",
        inputSchema: {
          location: z.string().describe("Location name or city (e.g. beijing, shanghai, new york, tokyo)")
        }
      },
      async ({ location }) => {
        
        const weatherData = await seniverseApi.getWeatherNow(location);
        if (!weatherData || !weatherData.results || weatherData.results.length === 0) {
          return {
            content: [
              {
                type: "text",
                text: `Failed to retrieve weather data for location: ${location}. Please check the location name and try again.`,
              },
            ],
          };
        }

        const result = weatherData.results[0];
        const weatherText = `Weather for ${result.location.name}, ${result.location.country}:\n` +
                            `Condition: ${result.now.text}\n` +
                            `Temperature: ${result.now.temperature}°C\n` +
                            `Last Update: ${result.last_update}`;
        return {
          content: [
            {
              type: "text",
              text: weatherText,
            },
          ],
        };
      },
    );
  }
}

效果如下:

image.png

注意左侧侧边栏:

  • Transport Type选择Streamable HTTP
  • URL 填写你的express 服务地址和endpoint。

stdio

原理分析

我在项目中,通过监听process.stdin,查看通信Message

// 监听 stdin 输入,可以在inspector面板的"notifications/message"中看到(作为debug用)
    process.stdin.on("data", async (data) => {
      const input = data.toString().trim();
      console.error(input);
    });

通过mcp-inspector工具就可以观察到通信信息了,往下看👁

tools/list

image.png

tools/call

image.png

结合官网的stdio通信原理图

image.png

可以总结如下:

  • 连接一个stdio MCP服务,不同于streamable HTTP MCP服务需要进行「握手」,只要开启一个子进程(subprocess),就表示连接成功。
  • 后续的通信的信息格式遵循json-rpc:2.0,通过读写process.stdinprocess.stdout完成通信。

代码实战 - 统计文件数

比较简单,可以参考我的这篇博客 Node写MCP入门教程,基于StdioServerTransport实现的统计目录下文件夹的MCP Server,并且介绍了mcp inspector的调试和Trae安装使用。

创建MCP项目的脚手架

每次写个新MCP Server都要搭建项目模板,这种重复的工作当然该做成工具辣! 我自己写了一个create-mcp脚手架 Githubcreate-mcp cli工具已经发布在npm上了,可以npm安装使用。

cli 原理

1.脚手架原理,首先准备两个模板项目

  • template-stdio 模板
  • template-streamable 模板

2.然后用Node写一个cli工具,使用了以下依赖,通过命令行交互的方式创建项目

pnpm i minimist prompts fs-extra chalk

3.根据你选择的项目名称和模板,帮你拷贝模板,修改为你的「项目名称」

觉得这个cli项目不错的话,给个免费的star吧~ 👉 Github

使用 cli

使用@caikengren-cli/create-mcp创建项目

npx @caikengren-cli/create-mcp

image.png

然后依次分别运行下面两个命令

# 编译ts/运行node
pnpm dev

# 打开 mcp-inspector工具调试
pnpm inspect

参考

mcp官网
mcp中文网
mcp: typescript-sdk

搭建简易版monorepo + turborepo

背景

  • 项目结构:pnpm Monorepo
    • packages/ui:React 组件库(使用 Vite + TS 打包)
    • apps/react-demo:React 应用,依赖 @my-org/ui
  • 目标:
    • ✅ 开发环境:修改 ui 源码 → 自动热更新到 react-demo
    • ✅ 生产构建:react-demo 能正确打包 @my-org/ui

遇到的问题 & 解决方案

❌ 问题 1:生产构建时报错 —— @my-org/ui 无法解析

// 错误信息
[vite]: Rolldown failed to resolve import "@my-org/ui" ...

🔎 根本原因:

  • react-demonode_modules/@my-org/ui/没有 ****dist/ ****目录
  • 导致 Vite 找不到 JS 入口文件(如 ui.js

✅ 解决步骤:

  1. 确认 ****packages/ui/package.json ****包含 ****"files": ["dist"]
    → 否则 pnpm 不会把 dist 链接到 consumer 的 node_modules
  2. 先构建 UI 库

pnpm --filter @my-org/ui build
  1. 确保 ****react-demo ****声明了依赖

pnpm add @my-org/ui@workspace:* --filter react-demo
  1. 强制刷新链接

pnpm install --force

💡 关键认知:pnpm workspace 链接 ≠ 实时目录映射,它只链接 package.json 中声明的文件(通过 files),且需在 dist 存在后执行 install

❌ 问题 2:package.json 入口文件名与实际输出不一致

  • 配置写的是:

"main": "./dist/index.js"
  • 但 Vite 默认输出:

dist/ui.js
dist/ui.mjs

🔎 后果:

  • 即使 dist 被链接,Vite 仍尝试加载不存在的 index.js → 模块解析失败

✅ 解决方案(二选一):

方案 操作
A(推荐) 修改 package.json指向真实文件: "main": "./dist/ui.js"
B 修改 vite.config.ts强制输出 index.jsfileName: (format) => index.${format === 'es' ? 'mjs' : 'js'}``

✅ 最终选择 方案 A,避免改构建配置,更简单直接。

❌ 问题 3:即使 files 正确,dist 仍不出现

运行 pnpm install --force 后,node_modules/@my-org/ui/dist 依然不存在。

🔎 深层原因:

  • react-demo/package.json 未声明对 ****@my-org/ui ****的依赖
  • pnpm 不会自动链接未声明的 workspace 包

✅ 解决:


pnpm add @my-org/ui@workspace:* --filter react-demo

→ 显式建立依赖关系,pnpm 才会创建 symlink 并包含 dist/

📌 这是 Monorepo 的核心规则: “未声明 = 不存在”

✅ 问题 4:开发环境如何实现热更新?

生产构建成功后,需支持开发时实时编辑 ui 组件。

✅ 解决方案:

  1. ****react-demo/vite.config.ts ****中添加 alias

resolve: {
  alias: {
    '@my-org/ui': path.resolve(__dirname, '../../packages/ui/src')
  }
}
  1. 启动开发服务器

pnpm --filter react-demo dev

💡 原理:Vite 直接编译 ui/src 源码(而非 dist),天然支持 HMR 和 TSX。


🧪 验证清单(最终状态)

检查项 命令 预期结果
UI 库已构建 ls packages/ui/dist ui.js, ui.mjs, *.d.ts
依赖已声明 grep "@my-org/ui" apps/react-demo/package.json "@my-org/ui": "workspace:*"
链接已同步 ls apps/react-demo/node_modules/@my-org/ui/dist 文件存在
生产构建成功 pnpm --filter react-demo build 无报错,生成 dist/
开发热更新 修改 ui/src/Button.tsx→ 浏览器自动刷新 ✅ 实时生效

📚 经验总结

场景 关键配置
生产构建 package.jsonmain/module必须匹配真实文件名 + "files": ["dist"]
依赖链接 Consumer 必须在 package.json中显式声明 workspace:*依赖
开发体验 通过 Vite alias指向 src,绕过 dist,实现 HMR
构建顺序 build ui→ 再 pnpm install→ 最后 build app

🔄记住这张图,脑子跟着浏览器的事件循环(Event Loop)转起来了

一、前言

下面按照我的理解,纯手工画了一张在浏览器执行JavaScript代码的Event Loop(事件循环) 流程图。

后文会演示几个例子,把示例代码放到这个流程图演示其执行流程。

当然,这只是简单的事件循环流程,不过,却能让我们快速掌握其原理。

Event Loop.png

二、概念

事件循环JavaScript为了处理单线程执行代码时,能异步地处理用户交互、网络请求等任务 (异步Web API),而设计的一套任务调度机制。它就像一个永不停止的循环,不断地检查(结合上图就是不断检查Task QueueMicrotask Queue这两个队列)并需要运行的代码。


三、为什么需要事件循环

JavaScript是单线程的,这意味着它只有一个主线程来执行代码。如果所有任务(比如一个耗时的计算、一个网络请求)都同步执行,那么浏览器就会被卡住,无法响应用户的点击、输入,直到这个任务完成。这会造成极差的用户体验。

事件循环就是为了解决这个问题而生的:它让耗时的操作(如网络请求、文件读取)在后台异步执行,等这些操作完成后,再通过回调的方式来执行相应的代码,从而不阻塞主线程

四、事件循环流程图用法演示

演示一:小菜一碟

先来一个都是同步代码的小菜,先了解一下前面画的流程图是怎样在调用栈当中执行JavaScript代码的。

console.log(1)

function funcOne() {
  console.log(2)
}

function funcTwo() {
  funcOne()
  console.log(3)
}

funcTwo()

console.log(4)

控制台输出:

1 2 3 4

下图为调用栈执行流程

演示01.png

每执行完一个同步任务会把该任务进行出栈。在这个例子当中每次在控制台输出一次,则进行一次出栈处理,直至全部代码执行完成。

演示二:小试牛刀

setTimeout+Promise组合拳,了解异步代码是如何进入任务队列等待执行的。

console.log(1)

setTimeout(() => {
  console.log('setTimeout', 2)
}, 0)

const promise = new Promise((resolve, reject) => {
  console.log('promise', 3)
  resolve(4)
})

setTimeout(() => {
  console.log('setTimeout', 5)
}, 10)

promise.then(res => {
  console.log('then', res)
})

console.log(6)

控制台输出:

1 promise 3 6 then 4 setTimeout 2 setTimeout 5

流程图执行-步骤一:

先执行同步代码,如遇到异步代码,则把异步回调事件放到后台监听对应的任务队列

image.png

  1. 执行console.log(1),控制台输出1

  2. 执行定时器,遇到异步代码,后台注册定时器回调事件,时间到了,把回调函数() => {console.log('setTimeout', 2)},放到宏任务队列等待。

  3. 执行创建Promise实例,并执行其中同步代码:执行console.log('promise', 3),控制台输出promise 3;执行resolve(4),此时Promise已经确定为完成fulfilled状态,把promise.then()的回调函数响应值设为4

  4. 执行定时器,遇到异步代码,后台注册定时器回调事件,时间未到,把回调函数() => { console.log('setTimeout', 5) }放到后台监听。

  5. 执行promise.then(res => { console.log('then', res) }),出栈走异步代码,把回调函数4 => { console.log('then', 4) }放入微任务队列等待。

流程图执行-步骤二:

上面已经把同步代码执行完成,并且把对应异步回调事件放到了指定任务队列,接下来开始事件循环

image.png

  1. 扫描微任务队列,执行4 => { console.log('then', 4) }回调函数,控制台输出then 4

  2. 微任务队列为空,扫描宏任务队列,执行() => {console.log('setTimeout', 2)}回调函数,控制台输出setTimeout 2

  3. 每执行完一个宏任务,需要再次扫描微任务队列是否存在可执行任务(假设此时后台定时到了,则会把() => { console.log('setTimeout', 5) }加入到了宏任务队列末尾)。

  4. 微任务队列为空,扫描宏任务队列,执行() => { console.log('setTimeout', 5) },控制台输出setTimeout 5

演示三:稍有难度

setTimeout+Promise组合拳+多层嵌套Promise

console.log(1)

setTimeout(() => {
  console.log('setTimeout', 10)
}, 0)

new Promise((resolve, reject) => {
  console.log(2)
  resolve(7)

  new Promise((resolve, reject) => {
    resolve(5)
  }).then(res => {
    console.log(res)

    new Promise((resolve, reject) => {
      resolve('嵌套第三层 Promise')
    }).then(res => {
      console.log(res)
    })
  })

  Promise.resolve(6).then(res => {
    console.log(res)
  })

}).then(res => {
  console.log(res)
})

new Promise((resolve, reject) => {
  console.log(3)

  Promise.resolve(8).then(res => {
    console.log(res)
  })

  resolve(9)
}).then(res => {
  console.log(res)
})

console.log(4)

上一个演示说明了流程图执行的详细步骤,下面就不多加赘叙了,直接看图!

talk is cheap, show me the chart

image.png

上图,调用栈同步代码执行完成,开始事件循环,先看微任务队列,发现不为空,按顺序执行微任务事件:

嵌套02.png

上图,已经把刚才排队的微任务队列全部清空了。但是在执行第一个微任务时,发现还有嵌套微任务,则把该任务放到微任务队列末尾,然后接着一起执行完所有新增任务

嵌套03.png

最后微任务清空后,接着执行宏任务。到此全部事件已执行完毕!

控制台完整输出顺序:

1 2 3 4 5 6 7 8 9 10

演示四:setTimeout伪定时

setTimeout并不是设置的定时到了就马上执行,而是把定时回调放在task queue任务队列当中进行等待,待主线程调用栈中的同步任务执行完成后空闲时才会执行。

const startTime = Date.now()
setTimeout(() => {
  const endTime = Date.now()
  console.log('setTimeout cost time', endTime - startTime)
  // setTimeout cost time 2314
}, 100)

for (let i = 0; i < 300000; i++) {
  // 模拟执行耗时同步任务
  console.log(i)
}

控制台输出:

1 2 3 ··· 300000 setTimeout cost time 2314

下图演示了其执行流程:

setTimeout假延时.png

演示五:fetch网络请求和setTimeout

获取网络数据,fetch回调函数属于微任务,优于setTimeout先执行。

setTimeout(() => {
  console.log('setTimeout', 2)
}, 510)

const startTime = Date.now()
fetch('http://localhost:3000/test').then(res => {
  const endTime = Date.now()
  console.log('fetch cost time', endTime - startTime)
  return res.json()
}).then(data => {
  console.log('data', data)
})

下图当前Call Stack执行栈执行完同步代码后,由于fetchsetTimeout都是宏任务,所以走宏任务Web API流程后注册这两个事件回调,等待定时到后了,由于定时回调是个普通的同步函数,所以放到宏任务队列;等待fetch拿到服务器响应数据后,由于fetch回调为一个Promise对象,所以放到微任务队列。

fetch.png

经过多番刷新网页测试,下图控制台打印展示了setTimeout延时为510msfetch请求响应同样是510ms的情况下,.then(data => { console.log('data', data) })先执行了,也是由于fetch基于Promise实现,所以其回调为微任务。

b475cbb38b0161d3e7f5f97b45824b31.png

五、结语

这可能只是简单的JavaScript代码执行事件循环流程,目的也是让大家更直观理解其中原理。实际执行过程可能还会读取堆内存获取引用类型数据、操作dom的方法,可能还会触发页面的重排、重绘等过程、异步文件读取和写入操作、fetch发起网络请求,与服务器建立连接获取网络数据等情况。

但是,它们异步执行的回调函数都会经过图中的这个事件循环过程,从而构成完整的浏览器事件循环。

Mac 端企业微信调试工具开启指南:解决页面兼容性问题必备

前言:

本文主要分享以下两点:

  1. 如何打开 Mac 版企业微信中的调试控制台:由于 Mac 版企微调试工具的开启方式和 Windows 不一样,网上教程零散,所以整理了详细步骤,帮前端同学快速上手~

  2. 我遇到个坑:页面在 Windows 端企微正常,Mac 端打开时字体却闪一下变大,需要用企微调试工具定位问题,附解决方法。

一、背景介绍

随着公司企业微信的全员推广,内部业务页面逐步迁移至企微环境运行,既提升了协作效率,也对页面兼容性提出了更高要求。近期开发的页面在 Windows 端企微中表现正常,但 Mac 端企微打开时出现字体闪烁变大的异常,需通过 企微内置调试工具 定位问题。由于 Mac 版企微调试工具的开启路径与 Windows 端存在差异,特此整理详细操作流程,为同类场景提供参考。

二、打开步骤

  1. 首先 打开 debug模式:

    方法:同时按下快捷键 command + shift + control + D,会有debug模式开启的提示。

image.png

再按一次就是关闭提示:

image.png

  1. 然后点击左上方的“调试”菜单,即【调试】——>【浏览器、webView相关】——>【开启webView元素审查】。具体见下面截图:

1fe54945-47c8-44ca-bdce-052ac089e475.png

image.png

结束这个步骤之后,再次打开调试查看时,【开启webView元素审查】会变成【关闭webView元素审查】,这样就说明开启成功,即:

11b04d5a-014d-402e-860d-f8edc98253b6.png

  1. 最后 关闭 应用 重新打开 即可。这一步非常重要!如果不重新打开的话,右键时,也不会出来“检查因素”,即下面第四步就不会生效。

  2. 右键,出现 “检查因素”,打开就是调试控制台了:

4b1fab6a-d2f3-42a0-a125-f5d542413232.png

image.png

三、Mac的企业微信的网页,会默认给body加一个zoom属性

这一隐形的设置可能会成为你项目中bug的原因。就比如我项目中的问题,在 Windows 中,页面的字体没有问题,在 Mac 的企微中打开,页面中的字体会缩放一下,就是由于这个默认属性导致的。我需要对此单独处理一下,就能完美解决问题,解决方案如下图:

image.png

四、总结

这就是 Mac 端企业微信调试工具的完整开启步骤啦~ 按照流程操作后,就能像在浏览器控制台一样,调试企微内的页面样式、接口请求等,轻松定位字体异常、布局错乱、交互失效等问题。

如果你的工作中也需要在企微生态开发页面,本文的调试控制台开启方法能直接参考。若有其他企微调试的小技巧,也欢迎在评论区留言交流,一起避坑提效!

以上,希望对你有帮助!

CLI 工具开发的常用包对比和介绍

04-CLI 工具开发

CLI 工具开发涉及命令行交互、终端美化、文件操作和模板生成等核心功能。

📑 目录


快速参考

工具选型速查表

工具类型 推荐工具 适用场景 备选方案
命令行解析 commander 功能丰富、API 友好 yargs(灵活)、meow(轻量)
交互式输入 inquirer 功能全面、生态丰富 prompts(轻量)、enquirer
终端美化 chalk 功能丰富、API 友好 picocolors(极轻量)、kleur
加载动画 ora 简单易用 -
进度条 cli-progress 文件上传/下载进度 -
文件操作 fs-extra Promise API、功能增强 -
文件匹配 glob 通配符匹配 -
模板引擎 handlebars 轻量、逻辑少 ejs、mustache

快速开始

# 1. 安装核心工具
pnpm add commander inquirer chalk ora fs-extra glob handlebars

# 2. 创建 CLI 入口文件
# src/cli.ts

# 3. 配置 package.json bin 字段

命令行交互

commander(推荐)

commander 是一款 Node.js 命令行解析工具,核心用途是解析命令行参数,让 CLI 工具的命令行交互更友好、专业。

优势

  • ✅ API 友好,链式调用
  • ✅ 功能丰富,支持子命令、选项、帮助信息
  • ✅ 生态完善,文档详细
  • ✅ 自动生成帮助信息

劣势

  • ❌ 体积较大(相比 meow)
安装
pnpm add commander
pnpm add @types/commander -D
基础用法
// src/cli.ts
import { program } from 'commander';

program
  .version('1.0.0', '-v, --version')
  .description('一个基于 commander + inquirer 的 CLI 工具示例');

// 定义无参数命令
program
  .command('init')
  .description('初始化项目')
  .action(() => {
    console.log('开始初始化项目...');
  });

// 定义带选项的命令
program
  .command('build')
  .description('打包项目')
  .option('-e, --env <env>', '打包环境', 'development')
  .option('-o, --outDir <dir>', '输出目录', 'dist')
  .action((options) => {
    console.log('开始打包...');
    console.log('打包环境:', options.env);
    console.log('输出目录:', options.outDir);
  });

program.parse(process.argv);
选项配置
选项格式 说明 示例
.option('-s, --single') 布尔型选项(无参数,存在即 true) your-cli --single{ single: true }
.option('-n, --name <name>') 必填参数选项 your-cli --name test{ name: 'test' }
.option('-a, --age [age]') 可选参数选项 your-cli --age 25{ age: 25 };不传则为 undefined
.option('--env <env>', '描述', 'dev') 带默认值的选项 不传 --env 时,默认 { env: 'dev' }
高级用法
// 子命令
program
  .command('create <name>')
  .description('创建新项目')
  .option('-t, --template <template>', '模板类型', 'default')
  .action((name, options) => {
    console.log(`创建项目 ${name},使用模板 ${options.template}`);
  });

// 必需选项
program.requiredOption('-c, --config <path>', '配置文件路径').parse();

// 自定义帮助信息
program.addHelpText('after', '\n示例:\n  $ my-cli init\n  $ my-cli build --env production');

yargs

yargs 是功能强大的命令行解析工具,支持位置参数、命令补全等高级功能。

优势

  • ✅ 功能强大,支持位置参数
  • ✅ 灵活的配置方式
  • ✅ 支持命令补全

劣势

  • ❌ API 相对复杂
  • ❌ 学习曲线较陡
安装
pnpm add yargs
pnpm add @types/yargs -D
基础用法
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';

const argv = yargs(hideBin(process.argv))
  .option('name', {
    alias: 'n',
    type: 'string',
    description: '项目名称',
    demandOption: true,
  })
  .option('template', {
    alias: 't',
    type: 'string',
    default: 'default',
    description: '模板类型',
  })
  .command('init <name>', '初始化项目', (yargs) => {
    return yargs.positional('name', {
      describe: '项目名称',
      type: 'string',
    });
  })
  .parseSync();

console.log(argv);

meow

meow 是轻量级的命令行解析工具,适合简单场景。

优势

  • ✅ 轻量级,体积小
  • ✅ 配置简单
  • ✅ 自动处理帮助信息

劣势

  • ❌ 功能相对简单
  • ❌ 不支持复杂命令结构
安装
pnpm add meow
基础用法
import meow from 'meow';

const cli = meow(
  `
  用法
    $ my-cli <input>

  选项
    --name, -n  项目名称
    --template, -t  模板类型

  示例
    $ my-cli init --name my-project
`,
  {
    importMeta: import.meta,
    flags: {
      name: {
        type: 'string',
        alias: 'n',
      },
      template: {
        type: 'string',
        alias: 't',
        default: 'default',
      },
    },
  },
);

console.log(cli.input[0]); // 命令参数
console.log(cli.flags); // 选项

命令行解析工具对比

工具 体积 配置复杂度 功能丰富度 适用场景
commander 较大 ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ 功能丰富的 CLI 工具
yargs 较大 ⭐⭐ ⭐⭐⭐⭐⭐ 需要位置参数的场景
meow 轻量级 ⭐⭐⭐⭐⭐ ⭐⭐⭐ 简单 CLI 工具

选型建议

  • 功能丰富的 CLI 工具:commander(推荐)
  • 需要位置参数:yargs
  • 简单工具:meow

交互式输入

inquirer(推荐)

当命令行参数无法满足需求(如让用户选择框架、输入密码、确认操作)时,inquirer 提供「交互式输入」。

优势

  • ✅ 功能全面,支持多种交互类型
  • ✅ 生态丰富,插件多
  • ✅ 文档完善

劣势

  • ❌ 体积较大
  • ❌ 配置相对复杂
安装
pnpm add inquirer
pnpm add @types/inquirer -D
基础用法
import { program } from 'commander';
import inquirer from 'inquirer';
import chalk from 'chalk';

program
  .command('init')
  .description('初始化项目')
  .action(async () => {
    console.log(chalk.blue('📦 开始初始化项目...'));

    const answers = await inquirer.prompt([
      {
        type: 'input',
        name: 'projectName',
        message: '请输入项目名称:',
        default: 'my-project',
        validate: (value) => {
          if (!value.trim()) return '项目名称不能为空!';
          return true;
        },
      },
      {
        type: 'list',
        name: 'framework',
        message: '请选择项目框架:',
        choices: [
          { name: 'React + TypeScript', value: 'react-ts' },
          { name: 'Vue + TypeScript', value: 'vue-ts' },
          { name: 'Vanilla JS', value: 'vanilla' },
        ],
        default: 'react-ts',
      },
      {
        type: 'checkbox',
        name: 'modules',
        message: '请选择需要的功能模块:',
        choices: ['路由', '状态管理', 'UI 组件库', 'ESLint/Prettier'],
        default: ['路由', 'ESLint/Prettier'],
      },
      {
        type: 'confirm',
        name: 'initGit',
        message: '是否初始化 Git 仓库?',
        default: true,
      },
    ]);

    console.log(chalk.green('\n✅ 项目配置如下:'));
    console.log('项目名称:', answers.projectName);
    console.log('框架:', answers.framework);
    console.log('功能模块:', answers.modules.join(', '));
    console.log('初始化 Git:', answers.initGit ? '是' : '否');
  });

program.parse(process.argv);
核心交互类型
类型 用途 关键配置
input 普通文本输入(如项目名称、邮箱) message、default、validate
password 密码输入(输入内容隐藏) 同 input,自动隐藏输入
list 单选(如框架选择、环境选择) choices(选项数组)、default
checkbox 多选(如功能模块、依赖选择) choices、default(默认选中项)
confirm 二选一确认(是 / 否) message、default(true/false)
rawlist 带编号的单选(按数字选择) 同 list,选项前显示编号
autocomplete 带自动补全的输入(如文件路径) 需配合 inquirer-autocomplete-prompt 插件

prompts

prompts 是轻量级的交互式输入工具,API 简洁。

优势

  • ✅ 轻量级,体积小
  • ✅ API 简洁
  • ✅ 支持取消操作(Ctrl+C)

劣势

  • ❌ 功能相对简单
  • ❌ 生态较小
安装
pnpm add prompts
基础用法
import prompts from 'prompts';

const response = await prompts([
  {
    type: 'text',
    name: 'projectName',
    message: '项目名称',
    initial: 'my-project',
    validate: (value) => (value.trim() ? true : '项目名称不能为空'),
  },
  {
    type: 'select',
    name: 'framework',
    message: '选择框架',
    choices: [
      { title: 'React', value: 'react' },
      { title: 'Vue', value: 'vue' },
    ],
  },
]);

console.log(response);

enquirer

enquirer 是现代化的交互式输入工具,支持自定义提示符。

优势

  • ✅ 现代化设计
  • ✅ 支持自定义提示符
  • ✅ API 灵活

劣势

  • ❌ 文档相对较少
  • ❌ 生态较小
安装
pnpm add enquirer
基础用法
import { prompt } from 'enquirer';

const response = await prompt({
  type: 'input',
  name: 'projectName',
  message: '项目名称',
  initial: 'my-project',
});

console.log(response);

交互式输入工具对比

工具 体积 配置复杂度 功能丰富度 生态 适用场景
inquirer 较大 ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ 功能全面的 CLI 工具
prompts 轻量级 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ 简单交互场景
enquirer 中等 ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ 需要自定义提示符

选型建议

  • 功能全面的 CLI 工具:inquirer(推荐)
  • 简单交互场景:prompts
  • 需要自定义提示符:enquirer

终端美化

chalk(推荐)

chalk 是 Node.js 终端日志彩色打印工具,核心用途是给终端输出的文字添加颜色、背景色、加粗 / 下划线等样式。

优势

  • ✅ 功能丰富,API 友好
  • ✅ 支持链式调用
  • ✅ 生态完善

劣势

  • ❌ 体积较大(相比 picocolors)
安装
pnpm add chalk
封装日志函数
// src/utils/logger.ts
import chalk from 'chalk';

export enum LogType {
  SUCCESS = 'success',
  ERROR = 'error',
  WARN = 'warn',
  INFO = 'info',
}

export const log = (message: string, type: LogType = LogType.INFO) => {
  const prefixMap = {
    [LogType.SUCCESS]: chalk.green('✅'),
    [LogType.ERROR]: chalk.bold.red('❌'),
    [LogType.WARN]: chalk.yellow('⚠️'),
    [LogType.INFO]: chalk.blue('ℹ️'),
  };

  const colorMap = {
    [LogType.SUCCESS]: chalk.green,
    [LogType.ERROR]: chalk.red,
    [LogType.WARN]: chalk.yellow,
    [LogType.INFO]: chalk.blue,
  };

  const prefix = prefixMap[type];
  const color = colorMap[type];
  console.log(`${prefix} ${color(message)}`);
};

export const logSuccess = (message: string) => log(message, LogType.SUCCESS);
export const logError = (message: string) => log(message, LogType.ERROR);
export const logWarn = (message: string) => log(message, LogType.WARN);
export const logInfo = (message: string) => log(message, LogType.INFO);
使用
import { logSuccess, logError, logWarn, logInfo } from './utils/logger';

logInfo('正在初始化项目...');
logWarn('当前 Node 版本低于推荐版本');
logSuccess('项目初始化完成!');
logError('配置文件缺失,请检查 config.json');

picocolors

picocolors 是极轻量的终端颜色库,体积仅 0.5KB。

优势

  • ✅ 极轻量(0.5KB)
  • ✅ API 简洁
  • ✅ 性能好

劣势

  • ❌ 功能相对简单
  • ❌ 不支持链式调用
安装
pnpm add picocolors
基础用法
import pc from 'picocolors';

console.log(pc.green('成功'));
console.log(pc.red('错误'));
console.log(pc.bold(pc.blue('加粗蓝色')));

kleur

kleur 是轻量级的终端颜色库,API 类似 chalk。

优势

  • ✅ 轻量级(体积小)
  • ✅ API 类似 chalk,迁移成本低
  • ✅ 支持链式调用

劣势

  • ❌ 功能相对简单
安装
pnpm add kleur
基础用法
import kleur from 'kleur';

console.log(kleur.green('成功'));
console.log(kleur.red('错误'));
console.log(kleur.bold().blue('加粗蓝色'));

ora(展示加载动画)

ora 是一款 Node.js 终端加载动画工具,核心用途是在耗时操作时显示「加载中」动画 + 提示文字。

安装
pnpm add ora
封装加载动画函数
// src/utils/loader.ts
import ora from 'ora';
import chalk from 'chalk';

export const withLoader = async <T>(message: string, asyncFn: () => Promise<T>): Promise<T> => {
  const spinner = ora(chalk.bold.blue(message)).start();
  try {
    const result = await asyncFn();
    spinner.succeed(chalk.green('✅ 操作完成!'));
    return result;
  } catch (error) {
    spinner.fail(chalk.bold.red(`❌ 操作失败:${(error as Error).message}`));
    throw error;
  }
};
使用示例
import { withLoader } from './utils/loader';

await withLoader('正在请求接口数据...', async () => {
  await new Promise((resolve) => setTimeout(resolve, 1500));
});
高级用法
import ora from 'ora';

const spinner = ora('加载中...').start();

// 更新文本
spinner.text = '处理中...';

// 成功
spinner.succeed('完成!');

// 失败
spinner.fail('失败!');

// 警告
spinner.warn('警告!');

// 信息
spinner.info('信息');

cli-progress(进度条)

cli-progress 用于显示文件上传/下载、批量处理等操作的进度。

安装
pnpm add cli-progress
基础用法
import cliProgress from 'cli-progress';

const bar = new cliProgress.SingleBar({
  format: '进度 |{bar}| {percentage}% | {value}/{total}',
  barCompleteChar: '\u2588',
  barIncompleteChar: '\u2591',
  hideCursor: true,
});

bar.start(100, 0);

// 模拟进度
for (let i = 0; i <= 100; i++) {
  await new Promise((resolve) => setTimeout(resolve, 50));
  bar.update(i);
}

bar.stop();

boxen(边框框)

boxen 用于在终端中创建带边框的文本框,适合显示重要信息。

安装
pnpm add boxen
基础用法
import boxen from 'boxen';
import chalk from 'chalk';

const message = boxen(chalk.green('✅ 项目初始化完成!'), {
  padding: 1,
  margin: 1,
  borderStyle: 'round',
  borderColor: 'green',
});

console.log(message);

cli-table3(表格展示)

cli-table3 用于在终端中展示表格数据,适合显示配置信息、对比数据等。

安装
pnpm add cli-table3
pnpm add @types/cli-table3 -D
基础用法
import Table from 'cli-table3';

const table = new Table({
  head: ['工具', '用途', '推荐度'],
  colWidths: [20, 30, 10],
});

table.push(
  ['commander', '命令行解析', '⭐⭐⭐⭐⭐'],
  ['inquirer', '交互式输入', '⭐⭐⭐⭐⭐'],
  ['chalk', '终端美化', '⭐⭐⭐⭐⭐'],
);

console.log(table.toString());

终端美化工具对比

工具 体积 功能 适用场景
chalk 较大 颜色、样式 功能丰富的 CLI 工具
picocolors 极轻量 基础颜色 对体积敏感的项目
kleur 轻量级 颜色、样式 轻量级 CLI 工具
ora 中等 加载动画 耗时操作提示
cli-progress 中等 进度条 文件处理进度
boxen 轻量级 边框框 重要信息展示
cli-table3 中等 表格 数据展示

选型建议

  • 功能丰富的 CLI 工具:chalk(推荐)
  • 对体积敏感:picocolors
  • 需要进度条:cli-progress
  • 需要表格展示:cli-table3

文件操作

fs-extra(操作文件系统)

fs-extra 在 Node.js 原生 fs 模块基础上做了增强,核心优势:

  • 完全兼容原生 fs 模块(可直接替换 fs 使用)
  • 所有 API 支持 Promise(无需手动封装 util.promisify)
  • 新增高频实用功能(递归创建目录、递归删除目录、复制文件 / 目录等)
安装
pnpm add fs-extra
pnpm add @types/fs-extra -D
核心用法
import fs from 'fs-extra';
import path from 'path';

// 递归创建目录
await fs.ensureDir(path.resolve(__dirname, 'a/b/c'));

// 递归删除目录
await fs.remove(path.resolve(__dirname, 'a'));

// 复制文件/目录
await fs.copy(src, dest);

// 写入 JSON 文件
await fs.writeJson(jsonPath, { name: 'test', version: '1.0.0' }, { spaces: 2 });

// 读取 JSON 文件
const config = await fs.readJson(jsonPath);

// 判断文件/目录是否存在
const exists = await fs.pathExists(path);
常用 API
API 名称 核心用途 优势对比(vs 原生 fs)
fs.ensureDir(path) 递归创建目录(不存在则创建,存在则忽略) 原生需手动递归,fs-extra 一键实现
fs.remove(path) 递归删除文件 / 目录(支持任意层级) 原生需先遍历目录,fs-extra 一键删除
fs.copy(src, dest) 复制文件 / 目录(自动创建目标目录) 原生需区分文件 / 目录,fs-extra 自动适配
fs.writeJson(path, data) 写入 JSON 文件(自动 stringify) 原生需手动 JSON.stringify,fs-extra 简化步骤
fs.readJson(path) 读取 JSON 文件(自动 parse) 原生需手动 JSON.parse,fs-extra 简化步骤
fs.pathExists(path) 判断文件 / 目录是否存在(返回 boolean) 原生需用 fs.access 捕获错误,fs-extra 直接返回

glob(匹配文件)

glob 解决「按规则批量查找文件」的需求,支持用通配符(如 ***?)匹配文件路径。

安装
pnpm add glob
pnpm add @types/glob -D
核心用法
import glob from 'glob';
import path from 'path';

// 同步匹配
const files = glob.sync('src/**/*.ts', {
  cwd: process.cwd(),
  ignore: ['src/test/**/*'],
});

// 异步匹配(推荐)
const files = await glob.promise('src/**/*.{ts,js}', {
  cwd: process.cwd(),
  dot: true,
});

// 流式匹配(适合大量文件)
const stream = glob.stream('src/**/*.ts');
stream.on('data', (filePath) => {
  console.log('匹配到文件:', filePath);
});
常见通配符规则
通配符 含义 示例 匹配结果
* 匹配当前目录下的任意字符(不含子目录) src/*.ts src/index.tssrc/utils.ts
** 匹配任意层级的目录(递归) src/**/*.ts src/index.tssrc/a/b/utils.ts
? 匹配单个字符 src/file?.ts src/file1.tssrc/file2.ts
[] 匹配括号内的任意一个字符 src/[ab].ts src/a.tssrc/b.ts
! 排除匹配的文件 src/**/*.ts + !src/test.ts 所有 .ts 文件,排除 src/test.ts

模板生成

handlebars(生成模板文件)

handlebars 是一款逻辑少、轻量型的模板引擎,核心用途是「将数据与模板结合,动态生成文本内容」。

安装
pnpm add handlebars
pnpm add @types/handlebars -D
基础用法
import handlebars from 'handlebars';

// 1. 定义模板
const template = `{
  "name": "{{ projectName }}",
  "version": "{{ version }}",
  "description": "{{ description }}"
}`;

// 2. 编译模板
const compiledTemplate = handlebars.compile(template);

// 3. 传入数据渲染
const data = {
  projectName: 'my-ts-pkg',
  version: '1.0.0',
  description: '基于 TS + 双模式的 npm 包',
};

const result = compiledTemplate(data);
console.log(result);
核心语法
  • 变量占位符{{ 变量名 }}(支持嵌套对象)
  • 条件判断{{#if 条件}}...{{else}}...{{/if}}
  • 循环遍历{{#each 数组}}...{{/each}}
  • 注释{{! 注释内容 }}
高级用法
// 注册辅助函数
handlebars.registerHelper('uppercase', (str) => {
  return str.toUpperCase();
});

// 使用辅助函数
const template = `{{ uppercase name }}`;

完整示例

结合所有工具,实现一个完整的 CLI 工具:

import { program } from 'commander';
import inquirer from 'inquirer';
import chalk from 'chalk';
import ora from 'ora';
import fs from 'fs-extra';
import glob from 'glob';
import handlebars from 'handlebars';
import path from 'path';
import boxen from 'boxen';

program.version('1.0.0', '-v, --version').description('CLI 工具示例');

program
  .command('init [projectName]')
  .description('初始化项目')
  .option('-t, --template <template>', '模板类型', 'default')
  .option('-y, --yes', '跳过交互式询问', false)
  .action(async (projectName, options) => {
    console.log(chalk.blue('📦 开始初始化项目...'));

    let answers: any = {};

    // 如果提供了项目名称且使用了 --yes,跳过交互
    if (projectName && options.yes) {
      answers = {
        projectName,
        framework: 'react-ts',
        modules: ['路由', 'ESLint/Prettier'],
        initGit: true,
      };
    } else {
      // 交互式询问
      answers = await inquirer.prompt([
        {
          type: 'input',
          name: 'projectName',
          message: '项目名称',
          default: projectName || 'my-project',
          validate: (value) => {
            if (!value.trim()) return '项目名称不能为空!';
            if (!/^[a-z0-9-]+$/.test(value)) {
              return '项目名称只能包含小写字母、数字和连字符!';
            }
            return true;
          },
        },
        {
          type: 'list',
          name: 'framework',
          message: '选择框架',
          choices: [
            { name: 'React + TypeScript', value: 'react-ts' },
            { name: 'Vue + TypeScript', value: 'vue-ts' },
            { name: 'Vanilla JS', value: 'vanilla' },
          ],
          default: 'react-ts',
        },
        {
          type: 'checkbox',
          name: 'modules',
          message: '选择功能模块',
          choices: ['路由', '状态管理', 'UI 组件库', 'ESLint/Prettier'],
          default: ['路由', 'ESLint/Prettier'],
        },
        {
          type: 'confirm',
          name: 'initGit',
          message: '是否初始化 Git 仓库?',
          default: true,
        },
      ]);
    }

    const spinner = ora(chalk.blue('正在生成项目文件...')).start();

    try {
      // 1. 检查目录是否存在
      const targetDir = path.resolve(process.cwd(), answers.projectName);
      if (await fs.pathExists(targetDir)) {
        spinner.fail(chalk.red(`目录 ${answers.projectName} 已存在!`));
        process.exit(1);
      }

      // 2. 创建项目目录
      await fs.ensureDir(targetDir);

      // 3. 读取模板文件
      const templateDir = path.resolve(__dirname, '../templates', options.template);
      const templateFiles = await glob.promise('**/*.hbs', {
        cwd: templateDir,
        dot: true,
      });

      // 4. 渲染模板并写入文件
      for (const templateFile of templateFiles) {
        const templatePath = path.resolve(templateDir, templateFile);
        const templateContent = await fs.readFile(templatePath, 'utf8');

        const compiled = handlebars.compile(templateContent);
        const renderedContent = compiled(answers);

        const targetFile = templateFile.replace(/\.hbs$/, '');
        const targetPath = path.resolve(targetDir, targetFile);

        await fs.ensureDir(path.dirname(targetPath));
        await fs.writeFile(targetPath, renderedContent, 'utf8');
      }

      // 5. 初始化 Git(如果选择)
      if (answers.initGit) {
        spinner.text = '正在初始化 Git 仓库...';
        // 这里可以调用 git 命令
      }

      spinner.succeed(chalk.green('项目初始化完成!'));

      // 6. 显示成功信息
      const successMessage = boxen(
        chalk.green(`✅ 项目 ${answers.projectName} 创建成功!\n\n`) +
          chalk.cyan(`cd ${answers.projectName}\n`) +
          chalk.cyan('npm install\n') +
          chalk.cyan('npm run dev'),
        {
          padding: 1,
          margin: 1,
          borderStyle: 'round',
          borderColor: 'green',
        },
      );

      console.log(successMessage);
    } catch (error) {
      spinner.fail(chalk.red(`初始化失败:${(error as Error).message}`));
      process.exit(1);
    }
  });

program
  .command('build')
  .description('构建项目')
  .option('-e, --env <env>', '构建环境', 'production')
  .option('-o, --outDir <dir>', '输出目录', 'dist')
  .action(async (options) => {
    const spinner = ora(chalk.blue('正在构建项目...')).start();

    try {
      // 模拟构建过程
      await new Promise((resolve) => setTimeout(resolve, 2000));

      spinner.succeed(chalk.green(`构建完成!输出目录:${options.outDir}`));
    } catch (error) {
      spinner.fail(chalk.red(`构建失败:${(error as Error).message}`));
      process.exit(1);
    }
  });

program.parse(process.argv);

最佳实践

  1. 错误处理:使用 try-catch 捕获错误,提供友好的错误提示
  2. 用户体验:使用 ora 显示加载状态,使用 chalk 美化输出
  3. 参数验证:在 inquirer 中使用 validate 验证用户输入
  4. 文件操作:使用 fs-extra 的 Promise API,避免回调地狱
  5. 模板管理:将模板文件放在独立目录,使用 glob 批量处理
  6. 命令结构:使用 commander 组织命令,保持清晰的命令层次
  7. 帮助信息:为每个命令添加清晰的描述和示例

常见问题

命令行解析相关问题

Q: commander 和 yargs 如何选择?

A:

  • commander:API 友好,适合大多数场景(推荐)
  • yargs:需要位置参数或复杂参数解析时使用

Q: 如何获取未定义的选项?

A:

// commander
program.parse();
const unknownOptions = program.opts();

// yargs
const argv = yargs.parse();
const unknown = argv._; // 未定义的参数

交互式输入相关问题

Q: inquirer 和 prompts 如何选择?

A:

  • inquirer:功能全面,生态丰富(推荐)
  • prompts:轻量级,简单场景使用

Q: 如何中断交互式输入?

A:

// inquirer 会自动处理 Ctrl+C
// prompts 需要手动处理
const response = await prompts({
  type: 'text',
  name: 'value',
  message: '输入值',
  onCancel: () => {
    console.log('已取消');
    process.exit(0);
  },
});

终端美化相关问题

Q: chalk 和 picocolors 如何选择?

A:

  • chalk:功能丰富,适合大多数场景(推荐)
  • picocolors:对体积敏感的项目使用

Q: 如何检测终端是否支持颜色?

A:

import chalk from 'chalk';

// chalk 会自动检测,不支持时自动禁用颜色
// 手动检测
const supportsColor = chalk.supportsColor;

文件操作相关问题

Q: fs-extra 和原生 fs 的区别?

A:

  • fs-extra:Promise API、递归操作、JSON 操作更便捷
  • 原生 fs:需要手动封装 Promise、手动递归

Q: glob 如何排除多个文件?

A:

const files = await glob.promise('src/**/*.ts', {
  ignore: ['src/test/**/*', 'src/**/*.test.ts'],
});

模板生成相关问题

Q: handlebars 和其他模板引擎的区别?

A:

  • handlebars:逻辑少、轻量级(推荐)
  • ejs:支持 JavaScript 代码,功能强大但体积大
  • mustache:无逻辑模板,但功能较少

Q: 如何在模板中使用辅助函数?

A:

handlebars.registerHelper('eq', (a, b) => a === b);

// 模板中使用
// {{#if (eq value "test")}}...{{/if}}

参考资源

一次从“卡顿地狱”到“丝般顺滑”的 React 搜索优化实战

author: 大布布将军

前言:万恶之源

本故事有虚构成分。但来源于现实开发场景。 事情是这样的。

某个周五下午 4 点,产品经理(PM)迈着六亲不认的步伐向我们前端走来。他满面春风地说:“哎,那个列表页,用户反馈说找不到想要的数据,加个实时搜索功能吧?要那种一边打字一边过滤的,丝般顺滑的感觉,懂我意思吧?”

前端心想:这还不简单?input 绑定 onChange,拿到 value 往列表里一 filter,完事儿。半小时搞定,还能赶上 6 点的地铁。

于是,前端写下了那段让我后来后悔不已的代码。


第一阶段:由于过度自信导致的翻车

为了还原案发现场,前端写了一个简化版的 Demo。假设我们有一个包含 10,000 条数据的列表(别问为什么前端要处理一万条数据,问就是后端甩锅)。

也就是这几行“天真”的代码:


// 假装这里有一万个商品
const generateProducts = () => {
  return Array.from({ length: 10000 }, (_, i) => `超级无敌好用的商品 #${i}`);
};

const dummyProducts = generateProducts();

export default function SearchList() {
  const [query, setQuery] = useState('');

  // 🔴 罪魁祸首在这里:每次 render 都要遍历一万次
  const filteredProducts = dummyProducts.filter(p => 
    p.toLowerCase().includes(query.toLowerCase())
  );

  const handleChange = (e) => {
    setQuery(e.target.value); // 这一步更新 state,触发重渲染
  };

  return (
    <div className="p-4">
      <input 
        type="text" 
        value={query} 
        onChange={handleChange} 
        placeholder="搜索..." 
        className="border p-2 w-full"
      />
      <ul className="mt-4">
        {filteredProducts.map((p, index) => (
          <li key={index}>{p}</li>
        ))}
      </ul>
    </div>
  );
}

结果如何?

在前端的 ThinkBook 上跑了一下,输入的时候感觉像是在 PPT 里打字。每一个字符敲下去,都要顿个几百毫秒才会显示在输入框里。

为什么? 这是 React 的基本原理:

  1. 用户输入 'a' -> 触发 setQuery
  2. React 甚至还没来得及把 'a' 更新到 input 框里,就被迫去执行组件的 render 函数。
  3. render 函数里有一个极其昂贵的 filter 操作(遍历 10k 次)。
  4. 即使 filter 完了,React 还要把生成的几千个 DOM 节点和之前的做 Diff,然后挂载到页面上。
  5. JS 线程被堵死,UI 渲染被阻塞,用户看到的就是:卡顿

第二阶段:万金油防抖 (Debounce) —— 治标不治本

作为老油条,第一反应当然是:“切,防抖一下不就行了?”

只要让用户打字的时候不触发计算,停下来再计算,不就完了?

import { debounce } from 'lodash';

// ... 省略部分代码

const handleChange = debounce((e) => {
    setQuery(e.target.value);
}, 300);

效果: 输入框确实不卡了,打字很流畅。但是,当你停止打字 300ms 后,页面会突然“冻结”一下,然后列表瞬间刷新。

痛点: 这种体验就像是便秘。虽然没有一直在用力,但最后那一下还是很痛苦。而且,UI 的响应滞后感很强,依然没有达到 PM 要求的“丝般顺滑”。

第三阶段:祭出神器 useDeferredValue

React 18 发布这么久了,是时候让它出来干点活了。

这时候作为前端的我们需要引入一个概念:并发模式 (Concurrent Features)

简单来说,就是把更新任务分为“大哥”和“小弟”。

  • 大哥(紧急更新) :用户的打字输入、点击反馈。这玩意儿必须马上响应,不然用户会以为死机了。
  • 小弟(非紧急更新) :列表的过滤渲染。晚个几百毫秒没人在意。

React 18 给了我们一个 Hook 叫 useDeferredValue,专门用来处理这种场景。

改造后的代码:


export default function OptimizedSearchList() {
  const [query, setQuery] = useState('');
  
  //  魔法在这里:创建一个“滞后”的副本
  // React 会在空闲的时候更新这个值
  const deferredQuery = useDeferredValue(query);

  const handleChange = (e) => {
    // 这里依然是紧急更新,保证 input 框打字流畅
    setQuery(e.target.value); 
  };

  // 只有当 deferredQuery 变了,才去跑这个昂贵的 filter
  // 注意:这里要配合 useMemo,不然也没用
  const filteredProducts = useMemo(() => {
    return dummyProducts.filter(p => 
      p.toLowerCase().includes(deferredQuery.toLowerCase())
    );
  }, [deferredQuery]);

  return (
    <div className="p-4">
      <input 
        type="text" 
        value={query} // 绑定实时的 query
        onChange={handleChange} 
        className="border p-2 w-full"
      />
      
      {/* 甚至可以加个 loading 状态,判断 query 和 deferredQuery 是否同步 */}
      <div style={{ opacity: query !== deferredQuery ? 0.5 : 1 }}>
        <ul className="mt-4">
          {filteredProducts.map((p, index) => (
            <li key={index}>{p}</li>
          ))}
        </ul>
      </div>
    </div>
  );
}

这一波操作到底发生了什么?

  1. 输入 'a' :React 此时收到了两个任务。

    • 任务 A(高优先级):更新 query,让 input 框显示 'a'。
    • 任务 B(低优先级):更新 deferredQuery,并重新计算列表。
  2. React 的调度

    • React 说:“任务 A 甚至关乎到我的尊严,马上执行!” -> Input 框瞬间变了。
    • React 接着说:“任务 B 嘛,我先切片执行一点点... 哎?用户又输入 'b' 了?那任务 B 先暂停,我去执行新的任务 A!”
  3. 结果

    • JS 线程没有被长列表渲染锁死。
    • 输入框始终保持 60fps 的响应速度。
    • 列表会在资源空闲时“不知不觉”地更新完成。

深度解析:React 原理是咋搞的?

为了不显得只是个 API 调用侠,这里必须装一波,讲讲原理。

1. 以前的 React (Stack Reconciler)

想象你在厨房切洋葱(渲染组件)。老板(浏览器)跟你说:“客人要加单!”。以前的 React 是个死脑筋,一旦开始切洋葱(比如那 10,000 条数据),天王老子来了它也得切完才肯抬头。这时候浏览器就卡死了,用户点击没反应。

2. 现在的 React (Fiber & Concurrency)

现在的 React 学聪明了,它把切洋葱分成了无数个小步骤(Time Slicing)。

  • 切两刀,抬头看看:“老板,有急事吗?”
  • 老板:“没事,你继续。” -> 继续切。
  • 切两刀,抬头:“老板?”
  • 老板:“有!客人要喝水(用户输入了)!”
  • React:“好嘞!”(放下菜刀,先去倒水,倒完水回来再继续切洋葱,或者如果洋葱不用切了直接扔掉)。

useDeferredValue 本质上就是告诉 React:“这个 state 的更新是切洋葱,可以往后稍稍,先去给客人倒水。”

总结 & 避坑指南

这波优化上线后,PM 拍了拍前端的肩膀说:“行啊,有点东西。”

但是,兄弟们,请注意以下几点(防杠声明):

  1. 不要滥用:这玩意儿是有 overhead(开销)的。如果你的列表只有 50 条数据,用 useDeferredValue 纯属脱裤子放屁,反而更慢。
  2. 配合 useMemo:就像代码里写的,过滤逻辑必须包裹在 useMemo 里。否则每次 Parent Render,列表过滤还是会执行,useDeferredValue 就白用了。
  3. 性能优化的尽头是虚拟列表:如果数据量真到了 10 万级,别折腾 Concurrent Mode 了,直接上 react-windowreact-virtualized 吧,DOM 节点的数量才是真正的瓶颈。

好了,今天的摸鱼时间结束,撤退 。 我是大布布将军,一个前端开发。


下一步建议:如果你的项目里也有这种“输入卡顿”或者“Tab 切换卡顿”的场景,别急着重构,先试着把那个导致卡顿的状态用 useDeferredValue 包一下,说不定有奇效。

❌