普通视图

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

Rust 错误处理实战:anyhow + thiserror 的黄金搭档

作者 土豆1250
2026年4月8日 21:03

适合人群:已经写过几百行 Rust 但看到 Box<dyn Error> 就头皮发麻的同学。读完本文,你将掌握一套工程级错误处理范式,彻底告别"我也不知道这里会报什么错"的状态。


为什么你需要了解它?(Why)

刚学 Rust 时,很多人的第一反应是:错误处理好麻烦。

// 新手三件套:能用就行,别来烦我
fn read_config() -> Result<Config, Box<dyn std::error::Error>> {
    let content = std::fs::read_to_string("config.toml")?;
    let config: Config = toml::from_str(&content)?;
    Ok(config)
}

Box<dyn Error> 就像一个万能收纳袋——什么错误都能塞进去,但等你要取出来细看的时候,里面一团乱麻,调用方根本不知道该如何处理。

更大的痛点出现在项目规模增长后:

  • 库的调用者:你的错误类型应该精确,让我能 match 不同情况
  • 应用层开发者:我要整合来自文件、网络、数据库的各路错误,手动写 From 实现快把人搞崩
  • 所有人:出了问题,错误信息得有上下文,不然排查问题像大海捞针

这正是 thiserroranyhow 诞生的原因,它们来自同一位作者(David Tolnay),天生一对,分工明确:

版本 定位
thiserror 2.0.18 为**库(library)**定义精确的错误类型,减少样板代码
anyhow 1.0.102 为**应用(application)**方便地传播和追踪错误链

它到底是什么?(What)

thiserror:错误类型的自动打工仔

手动实现一个错误类型,你需要写 std::fmt::Displaystd::error::Error,有时还要写一堆 From<SomeOtherError>thiserror 用派生宏帮你全干了。

use thiserror::Error;

#[derive(Debug, Error)]
pub enum MyError {
    #[error("文件读取失败: {0}")]
    Io(#[from] std::io::Error),
    
    #[error("配置字段缺失: {field}")]
    MissingField { field: String },
}

三行属性宏,DisplayErrorFrom<io::Error> 全部到位。调用方可以精准地 match MyError::MissingField { .. } 处理不同情况,这是库设计的基本礼貌。

anyhow:错误传播的瑞士军刀

anyhow 提供一个 anyhow::Error 类型,它能包裹任何实现了 std::error::Error 的错误,还能附加上下文信息。

use anyhow::{Context, Result};

fn load_config(path: &str) -> Result<Config> {
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("无法读取配置文件: {path}"))?;
    
    let config: Config = toml::from_str(&content)
        .context("配置文件格式错误")?;
    
    Ok(config)
}

出错时,你会看到完整的错误链:

Error: 无法读取配置文件: config.toml

Caused by:
    No such file or directory (os error 2)

工作原理图

应用层 (main.rs / bin/)          库层 (lib.rs)
┌─────────────────────────┐     ┌─────────────────────────┐
│                         │     │  #[derive(Error)]        │
│  anyhow::Result<T>      │◄────┤  pub enum DbError { .. } │
│  .context("做某事时")   │     │  pub enum ParseError {..}│
│  ? 自动转换              │     │                         │
│                         │     │  精确、可匹配、有文档    │
└─────────────────────────┘     └─────────────────────────┘
         ↓ 错误链自动串联
    Error: 保存用户时出错
    Caused by: 数据库写入失败
    Caused by: connection refused

怎么用?(How)

快速上手:环境准备

Cargo.toml 中添加依赖(截至 2026 年 4 月最新稳定版):

[dependencies]
anyhow = "1.0"
thiserror = "2.0"

场景一:为库定义精确错误类型

假设你在写一个用户认证库:

// src/lib.rs
use thiserror::Error;

#[derive(Debug, Error)]
pub enum AuthError {
    /// 数据库操作失败,透传底层原因
    #[error("数据库错误")]
    Database(#[from] sqlx::Error),

    /// 密码不匹配,不暴露更多细节(安全考虑)
    #[error("用户名或密码错误")]
    InvalidCredentials,

    /// Token 过期,附带过期时间信息
    #[error("Token 已过期,过期时间: {expired_at}")]
    TokenExpired { expired_at: String },

    /// 包裹其他任意错误(透明转发,直接展示内部错误的 Display)
    #[error(transparent)]
    Other(#[from] anyhow::Error),
}

pub fn login(username: &str, password: &str) -> Result<String, AuthError> {
    if password.is_empty() {
        return Err(AuthError::InvalidCredentials);
    }
    Ok(format!("token_for_{username}"))
}

调用方可以 match 精确处理每种情况:

match login("alice", "") {
    Ok(token) => println!("登录成功: {token}"),
    Err(AuthError::InvalidCredentials) => println!("账号或密码有误,请重试"),
    Err(AuthError::TokenExpired { expired_at }) => {
        println!("Token 于 {expired_at} 过期,请重新登录")
    }
    Err(e) => println!("系统错误: {e}"),
}

场景二:应用层用 anyhow 串联多源错误

应用层常常需要整合来自不同库的错误,这时 anyhow 大显身手:

// src/main.rs
use anyhow::{Context, Result, bail, ensure};

fn setup_app() -> Result<()> {
    // context: 静态字符串,直接传入,成本极低
    let config = std::fs::read_to_string("config.toml")
        .context("读取配置文件失败")?;

    // with_context: 闭包,只在出错时执行,适合有格式化开销的消息
    let port: u16 = config.trim().parse()
        .with_context(|| format!("配置内容 '{config}' 无法解析为端口号"))?;

    // ensure!: 断言式错误,条件为 false 时直接返回 Err
    ensure!(port > 1024, "端口号 {port} 需大于 1024,当前不支持特权端口");

    // bail!: 无条件构造并返回一个 anyhow::Error
    if port == 8080 {
        bail!("端口 8080 已被其他服务占用,请换一个");
    }

    println!("服务启动在端口 {port}");
    Ok(())
}

fn main() {
    if let Err(e) = setup_app() {
        eprintln!("启动失败: {e:?}"); // {:?} 会打印完整错误链
        std::process::exit(1);
    }
}

场景三:库与应用的完整协作示例

这是最接近真实项目的组合拳——库用 thiserror 定义错误,应用用 anyhow 包裹并附加上下文:

// === 库侧:精确定义 ===
use thiserror::Error;

#[derive(Debug, Error)]
pub enum DbError {
    #[error("连接失败: {addr}")]
    ConnectionFailed { addr: String },

    #[error("记录不存在: id={id}")]
    NotFound { id: u64 },

    #[error("IO 错误")]
    Io(#[from] std::io::Error),
}

pub fn find_user(id: u64) -> Result<String, DbError> {
    if id == 0 {
        return Err(DbError::NotFound { id });
    }
    Ok(format!("user_{id}"))
}

// === 应用侧:灵活传播 ===
use anyhow::{Context, Result};

fn handle_request(user_id: u64) -> Result<()> {
    let user = find_user(user_id)
        .with_context(|| format!("处理请求时无法找到用户 {user_id}"))?;

    println!("处理用户: {user}");
    Ok(())
}

错误发生时的输出:

处理请求时无法找到用户 0

Caused by:
    记录不存在: id=0

场景四:axum Web 服务的错误处理

axum 的 handler 函数要求返回值中的错误类型必须实现 IntoResponse trait,这意味着你不能直接在 handler 里用 anyhow::Result——因为 anyhow::Error 不知道怎么变成一个 HTTP 响应。

标准解法是:用 thiserror 定义业务错误枚举,再为它实现 IntoResponse,让错误自动映射到对应的 HTTP 状态码。

依赖配置:

[dependencies]
anyhow = "1.0"
thiserror = "2.0"
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde_json = "1"
http = "1"

错误类型定义(src/errors.rs):

use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde_json::json;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum AppError {
    /// 资源未找到(→ 404)
    #[error("资源未找到: {0}")]
    NotFound(String),

    /// 请求参数非法(→ 400)
    #[error("请求参数错误: {0}")]
    BadRequest(String),

    /// 未授权(→ 401)
    #[error("未授权,请先登录")]
    Unauthorized,

    /// 数据库错误(→ 500,不向客户端暴露细节)
    #[error("数据库内部错误")]
    Database(#[from] sqlx::Error),

    /// 兜底:其他所有错误(→ 500)
    #[error("内部服务器错误")]
    Internal(#[from] anyhow::Error),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match &self {
            AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
            AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
            AppError::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()),
            // 数据库错误和内部错误:记录日志,但不向外暴露实现细节
            AppError::Database(e) => {
                tracing::error!("数据库错误: {e}");
                (StatusCode::INTERNAL_SERVER_ERROR, "内部服务器错误".to_string())
            }
            AppError::Internal(e) => {
                tracing::error!("内部错误: {e:?}");
                (StatusCode::INTERNAL_SERVER_ERROR, "内部服务器错误".to_string())
            }
        };

        // 统一返回 JSON 格式的错误响应
        (status, Json(json!({ "error": message }))).into_response()
    }
}

// 为 anyhow::Error 提供便捷的 From 转换,让 ? 在 handler 里可以直接用
impl From<anyhow::Error> for AppError {
    fn from(e: anyhow::Error) -> Self {
        AppError::Internal(e)
    }
}

在 handler 中使用(src/handlers.rs):

use axum::{extract::{Path, State}, Json};
use sqlx::PgPool;
use crate::errors::AppError;

// handler 返回 Result<Json<T>, AppError>,出错时 AppError 自动转成 HTTP 响应
pub async fn get_user(
    State(pool): State<PgPool>,
    Path(user_id): Path<i64>,
) -> Result<Json<User>, AppError> {
    if user_id <= 0 {
        return Err(AppError::BadRequest(format!("user_id 必须为正整数,收到: {user_id}")));
    }

    let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
        .bind(user_id)
        .fetch_one(&pool)
        .await
        // sqlx::Error::RowNotFound → 手动转换为 404,其他 sqlx 错误 → 500
        .map_err(|e| match e {
            sqlx::Error::RowNotFound => AppError::NotFound(format!("用户 {user_id} 不存在")),
            other => AppError::Database(other),
        })?;

    Ok(Json(user))
}

pub async fn create_user(
    State(pool): State<PgPool>,
    Json(payload): Json<CreateUserRequest>,
) -> Result<Json<User>, AppError> {
    // 业务逻辑中可以用 anyhow::Context 添加追踪信息,最终被 Internal 变体包裹
    let user = do_create_user(&pool, payload)
        .await
        .map_err(AppError::Internal)?;

    Ok(Json(user))
}

客户端收到的响应示例:

// GET /users/999 → 404
{ "error": "用户 999 不存在" }

// GET /users/-1 → 400
{ "error": "user_id 必须为正整数,收到: -1" }

// 数据库宕机 → 500(不暴露内部细节)
{ "error": "内部服务器错误" }

设计要点AppError 是应用层的"错误路由器",决定哪类错误给客户端什么反馈。数据库错误和内部错误只写 tracing 日志,对外只说"内部服务器错误"——这是生产级 API 的标准安全实践。


场景五:sqlx 数据库操作的错误集成

sqlx 的错误类型 sqlx::Error 是一个枚举,包含了连接失败、行不存在、约束冲突等多种情况。在实际项目中,最常见的需求是:RowNotFound 映射为业务层的"未找到"错误,其他错误透传。

依赖配置:

[dependencies]
anyhow = "1.0"
thiserror = "2.0"
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio", "macros"] }
tokio = { version = "1", features = ["full"] }

定义数据访问层的错误类型(src/db/error.rs):

use thiserror::Error;

#[derive(Debug, Error)]
pub enum RepoError {
    /// 记录不存在(从 RowNotFound 显式转换而来)
    #[error("{entity} 未找到 (id={id})")]
    NotFound { entity: &'static str, id: i64 },

    /// 唯一约束冲突(如重复邮箱)
    #[error("{field} 已被占用: {value}")]
    UniqueViolation { field: String, value: String },

    /// 其他所有数据库错误,透传原始信息
    #[error("数据库错误: {0}")]
    Sqlx(#[from] sqlx::Error),
}

/// 将 sqlx::Error 转换为更具语义的 RepoError
/// 使用辅助函数而非 From,因为需要额外的上下文参数
pub fn map_sqlx_err(e: sqlx::Error, entity: &'static str, id: i64) -> RepoError {
    match e {
        sqlx::Error::RowNotFound => RepoError::NotFound { entity, id },
        sqlx::Error::Database(db_err) => {
            // PostgreSQL 唯一约束违反错误码为 "23505"
            if db_err.code().as_deref() == Some("23505") {
                let constraint = db_err.constraint().unwrap_or("unknown");
                return RepoError::UniqueViolation {
                    field: constraint.to_string(),
                    value: String::new(), // 实际项目中可从 payload 中提取
                };
            }
            RepoError::Sqlx(sqlx::Error::Database(db_err))
        }
        other => RepoError::Sqlx(other),
    }
}

数据访问层实现(src/db/user_repo.rs):

use sqlx::PgPool;
use anyhow::Context;
use crate::db::error::{RepoError, map_sqlx_err};

pub struct User {
    pub id: i64,
    pub name: String,
    pub email: String,
}

pub async fn find_by_id(pool: &PgPool, id: i64) -> Result<User, RepoError> {
    sqlx::query_as!(User, "SELECT id, name, email FROM users WHERE id = $1", id)
        .fetch_one(pool)
        .await
        // 用辅助函数做精确的错误映射
        .map_err(|e| map_sqlx_err(e, "User", id))
}

pub async fn find_by_email(pool: &PgPool, email: &str) -> Result<Option<User>, RepoError> {
    // fetch_optional 不会产生 RowNotFound,直接用 #[from] 转换即可
    let user = sqlx::query_as!(
        User,
        "SELECT id, name, email FROM users WHERE email = $1",
        email
    )
    .fetch_optional(pool)
    .await?; // sqlx::Error 经 #[from] 自动转为 RepoError::Sqlx

    Ok(user)
}

pub async fn create(pool: &PgPool, name: &str, email: &str) -> Result<User, RepoError> {
    sqlx::query_as!(
        User,
        "INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id, name, email",
        name,
        email
    )
    .fetch_one(pool)
    .await
    .map_err(|e| map_sqlx_err(e, "User", 0))
}

在 Service 层用 anyhow 聚合错误并添加业务上下文:

use anyhow::{Context, Result};
use crate::db::{user_repo, error::RepoError};

pub async fn register_user(pool: &PgPool, name: &str, email: &str) -> Result<User> {
    // RepoError 实现了 std::error::Error,可自动转换为 anyhow::Error
    let existing = user_repo::find_by_email(pool, email)
        .await
        .with_context(|| format!("注册时检查邮箱 {email} 是否已存在失败"))?;

    if existing.is_some() {
        // 业务层直接用 anyhow::bail! 抛出业务错误
        anyhow::bail!("邮箱 {email} 已被注册");
    }

    let user = user_repo::create(pool, name, email)
        .await
        .with_context(|| format!("创建用户 {name} 失败"))?;

    Ok(user)
}

pub async fn get_user_profile(pool: &PgPool, user_id: i64) -> Result<User, RepoError> {
    // Service 层也可以直接返回 RepoError,让上层(如 axum handler)做 HTTP 映射
    user_repo::find_by_id(pool, user_id).await
}

整体数据流示意:

HTTP 请求
    │
    ▼
axum Handler
    │  返回 Result<Json<T>, AppError>
    │
    ▼
Service 层
    │  返回 anyhow::Result<T> 或 Result<T, RepoError>
    │  用 .with_context() 附加业务上下文
    │
    ▼
Repository 层 (sqlx)
    │  返回 Result<T, RepoError>
    │  RowNotFound → RepoError::NotFound
    │  23505 → RepoError::UniqueViolation
    │  其他 → RepoError::Sqlx
    │
    ▼
PostgreSQL

小技巧sqlx::query_as! 宏在编译期会检查 SQL 语句和返回类型是否匹配(需要设置 DATABASE_URL 环境变量),相当于把一部分运行时错误提前到了编译期消灭——和 Rust 的错误处理哲学一脉相承。


最佳实践

  • 库用 thiserror,应用用 anyhow:这是社区最广泛认可的分界线。库的调用者需要精确匹配错误,应用层只需要传播并展示。

  • with_context 代替 context 处理含格式化的消息context("msg") 无论是否出错都会立即执行参数表达式;with_context(|| ...) 是懒求值,只在真正出错时才执行闭包,性能更好。

  • axum handler 统一返回 Result<T, AppError>,由 AppError 决定 HTTP 状态码:业务逻辑与 HTTP 协议解耦,handler 代码干净清晰。

  • 数据库错误不要直接透传给 HTTP 客户端sqlx::Error 可能包含表结构、SQL 语句等敏感信息,在 IntoResponse 实现里要过滤,只记日志不对外暴露。

  • RowNotFound 要主动映射为 404,不要让它变成 500sqlx::Error::RowNotFound 是正常的业务情况,不是系统故障。用 map_err 显式转换。

  • 不要在库的公共 API 里用 anyhow::Error:调用方无法 match,等于告诉别人"出了错你自己看着办",是库设计的大忌。

  • 不要滥用 unwrap()expect():生产代码中,expect("这里绝对不会出错") 是程序在挑衅墨菲定律。

  • 不要在热路径上用 context() 拼接昂贵字符串:改用 with_context(|| ...) 让字符串构造只在出错时发生。


常见误区与避坑指南

误区 正确理解 解决方案
"我的 crate 既是库也是二进制,用哪个?" 分层处理:lib.rsthiserror 定义类型,main.rsanyhow 收集 Cargo.toml 里同时声明两者,按层使用
"升级到 thiserror 2.0 后编译报错" 2.0 是重大版本,有 breaking change:{r#type} 格式需改为 {type},且使用方必须直接依赖 thiserror 检查格式字符串中的原始标识符,确保 Cargo.toml 里有直接依赖
"axum handler 里能直接用 anyhow::Result 吗?" 不能。anyhow::Error 没有实现 IntoResponse,axum 不知道怎么把它变成 HTTP 响应 定义 AppError 包裹 anyhow::Error 并实现 IntoResponse
"sqlx::Error::RowNotFound 导致用户收到 500" RowNotFound 是业务错误,不应产生 500 map_err 里手动匹配 RowNotFound,转为 AppError::NotFound
"anyhow::Error 能不能反向提取原始错误?" 可以,用 err.downcast_ref::<MyError>() anyhow 内部保留了类型信息,可以 downcast 回具体类型
"错误信息怎么显示完整调用链?" println!("{:?}", err) 会打印 Debug 含调用链;println!("{}", err) 只打印顶层 调试时用 {:#?}{:?},用户展示时用 {}
"no_std 环境能用吗?" thiserror 2.0 支持 no_std thiserror = { version = "2", default-features = false }

进阶资源


小结

一句话总结这套搭档的哲学:给别人用的代码,精确定义错误;给自己用的代码,方便传播即可。

  • 写库/Repository 层 → thiserror:让调用者能精准匹配,让错误信息足够描述问题
  • 写 Service/应用层 → anyhow:用 ? + .context() 打通全部错误通道,出问题有完整链路
  • 写 axum handler → AppError + IntoResponse:错误自动映射 HTTP 状态码,业务与协议解耦
  • 处理 sqlx → 显式匹配 RowNotFound,用辅助函数将数据库错误转为语义化业务错误

Rust 的错误处理确实比其他语言啰嗦,但这些"啰嗦"迫使你在写代码时想清楚:这里会出什么错?调用方应该如何处理?这恰好是很多 bug 在编译期就被消灭的原因。用好 anyhow + thiserror,你会发现 Rust 的错误处理不是负担,而是护城河。

Tauri 入门与实践:用 Rust 构建你的下一个桌面应用

作者 土豆1250
2026年4月8日 21:01

"我只是想做个桌面 App,为什么要下载半个 Chrome?"
——每一个被 Electron 支配过的前端开发者


一、为什么是 Tauri?(WHY)

Electron 的困境

前端开发者历史上一直有一个执念:用 Web 技术写桌面应用。Electron 把这个梦想变成了现实,同时也带来了一个副作用——你的"记事本"应用可能比 Visual Studio Code 还大。

Electron 的核心问题在于:它在每个应用里都打包了一个完整的 Chromium 浏览器引擎。这就像你搬家的时候,不是带走自己的家具,而是把整栋楼都拆了打包带走。结果就是:

  • 一个"Hello World" 应用动辄 100MB+
  • 内存占用堪比多开十几个标签页
  • 打包速度……去泡杯茶再回来吧

Tauri 的出现

2020 年,Tauri 横空出世,带来了一个截然不同的哲学:

复用系统已有的 WebView,而不是打包一个浏览器。

这听起来很简单,但效果是革命性的:

  • macOS 上用 WKWebView(Safari 内核)
  • Windows 上用 WebView2(Edge 内核)
  • Linux 上用 WebKitGTK

用户的操作系统里本来就有这些东西,我们何必再带一份?

于是,一个最小化的 Tauri 应用,体积可以 小于 600KB。对,你没看错,是 KB。

Tauri vs Electron 一眼看懂

对比维度 Electron Tauri
最小包体积 ~50-100 MB < 600 KB
内存占用 较高(独立 Chromium) 较低(系统 WebView)
后端语言 Node.js (JavaScript) Rust
安全性 需要手动配置 默认安全,权限最小化
跨平台 桌面(Win/Mac/Linux) 桌面 + 移动端(iOS/Android)
前端兼容性 任意 Web 框架 任意 Web 框架
首次编译速度 慢(Rust 编译)

一句话总结:如果你想要一个体积小、性能好、安全性强的跨平台应用,Tauri 是当下最值得关注的选择。如果你对 Rust 望而却步,没关系——入门阶段你几乎不需要写多少 Rust。


二、Tauri 是什么?(WHAT)

核心架构

理解 Tauri 的关键,在于理解它的双进程架构

Tauri 双进程架构图

前端进程:运行在 WebView 中,负责 UI 渲染,可以是你熟悉的任何 Web 框架。

核心进程(Rust Core):负责与操作系统交互,处理文件、网络、系统托盘等"重活"。

IPC(进程间通信):两个进程之间的桥梁,也是整个安全模型的核心。前端通过 invoke 调用 Rust 函数,Rust 也可以向前端发送事件。

核心概念速览

1. Commands(命令)—— 前端调用 Rust

这是 Tauri 中最常用的通信方式。你在 Rust 里写一个函数,加上 #[tauri::command] 标记,前端就可以像调用普通 JavaScript 函数一样调用它。

2. Events(事件)—— 双向消息传递

Commands 是"请求-响应"模式,Events 则更像广播。适合那些不需要立即响应、但需要持续通知的场景,比如下载进度、后台任务状态更新等。

3. Plugins(插件)—— 开箱即用的功能

Tauri 官方维护了一套丰富的插件生态,覆盖了文件系统、Shell、通知、剪贴板、全局快捷键、系统托盘等常见需求,大多数情况下你不需要自己写底层代码。

4. Capabilities & Permissions(权限系统)

Tauri 2.0 引入了细粒度的权限控制系统。每个功能(Command)默认都是禁用的,你必须在配置中显式开启。这是 Tauri 安全设计哲学的集中体现:最小权限原则


三、如何上手?(HOW)

3.1 环境准备

磨刀不误砍柴工。Tauri 的环境配置比 Electron 稍微麻烦一点(Rust 的锅),但一旦搭好就永远受益。

第一步:安装系统依赖

Windows 用户需要:

  1. Microsoft C++ Build Tools:下载 Visual C++ Build Tools,安装时勾选 "Desktop development with C++"
  2. WebView2:访问 Microsoft WebView2 下载页,下载 "Evergreen Bootstrapper" 并安装(Win 11 默认已内置)

macOS 用户:

xcode-select --install

Linux(以 Debian/Ubuntu 为例):

sudo apt update
sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file \
  libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev

第二步:安装 Rust

Rust 是 Tauri 的灵魂,必须安装。

# Linux/macOS
curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh

# Windows:直接去 https://rustup.rs 下载安装程序

安装完成后,重启终端,然后验证:

rustc --version
# rustc 1.78.0 (之类的版本号)
cargo --version
# cargo 1.78.0

小贴士:Rust 的首次编译非常慢,这是正常现象。换句话说,第一次 tauri dev 时,去泡杯茶,甚至可以考虑冲个咖啡。之后由于缓存,重新编译会快很多。

第三步:安装 Node.js(如果使用前端框架)

前往 Node.js 官网 下载 LTS 版本安装即可。


3.2 创建第一个 Tauri 项目

Tauri 提供了 create-tauri-app 脚手架工具,让你快速上手:

# 使用 npm
npm create tauri-app@latest

# 或 pnpm
pnpm create tauri-app

# 或 yarn
yarn create tauri-app

按照提示选择:

  • 项目名称
  • 前端框架(React、Vue、Svelte、Vanilla JS……随你喜欢)
  • 语言(TypeScript / JavaScript)

以 React + TypeScript 为例,最终你会得到这样的目录结构:

my-tauri-app/
├── src/                    # 前端代码(React)
│   ├── App.tsx
│   ├── main.tsx
│   └── styles.css
├── src-tauri/              # Rust 后端代码(核心所在)
│   ├── src/
│   │   ├── lib.rs          # 应用逻辑入口
│   │   └── main.rs         # 程序入口
│   ├── Cargo.toml          # Rust 依赖配置(相当于 package.json)
│   ├── Cargo.lock          # Rust 依赖锁文件(务必提交到 Git!)
│   ├── tauri.conf.json     # Tauri 核心配置
│   └── capabilities/       # 权限配置目录
│       └── default.json
├── package.json
└── index.html

3.3 启动开发模式

npm run tauri dev

第一次运行:Cargo 会下载并编译所有 Rust 依赖,耗时 5~10 分钟很正常。
后续运行:只编译变更部分,通常几秒钟。

启动后,一个原生窗口会弹出,显示你的前端页面。修改前端代码,页面热更新;修改 Rust 代码,应用自动重启

打开开发者工具:在窗口内右键 → "Inspect",或使用快捷键 Ctrl+Shift+I(Windows)。


3.4 核心开发:前端与 Rust 的通信

这是 Tauri 开发最核心的部分,掌握了通信机制,就掌握了 Tauri 的精髓。

场景一:前端调用 Rust 函数(Commands)

Rust 端src-tauri/src/lib.rs):

// 1. 定义命令:加上 #[tauri::command] 标记
#[tauri::command]
fn greet(name: &str) -> String {
    format!("你好,{}!来自 Rust 的问候。", name)
}

// 2. 注册命令:在 Builder 中声明
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![greet])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

前端端src/App.tsx):

import { invoke } from '@tauri-apps/api/core';

async function handleGreet() {
  // 调用 Rust 命令,参数使用 camelCase 命名
  const message = await invoke<string>('greet', { name: '世界' });
  console.log(message); // "你好,世界!来自 Rust 的问候。"
}

命名规则要注意:Rust 函数名用 snake_case(如 my_command),调用时仍用 snake_case;但参数名在前端传递时要用 camelCase(如 invokeMessage 对应 Rust 的 invoke_message)。

场景二:异步命令(推荐方式)

涉及 I/O 操作时,务必使用 async,否则会阻塞 UI:

use std::time::Duration;
use tokio::time::sleep;

#[tauri::command]
async fn fetch_data(url: String) -> Result<String, String> {
    // 模拟异步网络请求
    sleep(Duration::from_millis(100)).await;
    Ok(format!("从 {} 获取的数据", url))
}
invoke<string>('fetch_data', { url: 'https://example.com' })
  .then(data => console.log(data))
  .catch(err => console.error('出错了:', err));

场景三:错误处理

不要用 unwrap(),要优雅地返回错误:

// 推荐方式:使用 thiserror 定义语义化错误类型
use thiserror::Error;

#[derive(Debug, Error)]
enum AppError {
    #[error("文件未找到: {0}")]
    FileNotFound(String),
    #[error("IO 错误: {0}")]
    Io(#[from] std::io::Error),
}

// 手动实现 Serialize(Tauri 要求错误类型可序列化)
impl serde::Serialize for AppError {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        serializer.serialize_str(self.to_string().as_ref())
    }
}

#[tauri::command]
async fn read_config() -> Result<String, AppError> {
    let content = std::fs::read_to_string("config.toml")?;
    Ok(content)
}
invoke<string>('read_config')
  .then(config => { /* 处理成功 */ })
  .catch((err: string) => {
    // err 就是 AppError 序列化后的字符串
    console.error('配置读取失败:', err);
  });

场景四:事件系统(后台任务通知)

当 Rust 后台需要主动推送消息给前端时(比如下载进度),用 Events:

Rust 端(使用 Channel 推送流式数据):

use tauri::ipc::Channel;

#[tauri::command]
async fn download_file(url: String, on_progress: Channel<u32>) {
    // 模拟下载进度
    for progress in [10, 30, 50, 80, 100] {
        tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
        on_progress.send(progress).unwrap();
    }
}

前端端

import { invoke } from '@tauri-apps/api/core';
import { Channel } from '@tauri-apps/api/core';

const onProgress = new Channel<number>();
onProgress.onmessage = (progress) => {
  console.log(`下载进度: ${progress}%`);
};

await invoke('download_file', {
  url: 'https://example.com/file.zip',
  onProgress,
});

也可以用更传统的事件监听方式:

import { listen } from '@tauri-apps/api/event';

// 监听事件(记得在组件卸载时取消监听!)
const unlisten = await listen<string>('task-status', (event) => {
  console.log('任务状态更新:', event.payload);
});

// 组件卸载时清理
onUnmounted(() => unlisten());

3.5 权限配置:必须踩的一关

Tauri 2.0 的权限系统是新手最容易卡住的地方。

默认情况下,所有能力都是关闭的。当你使用官方插件(如文件系统、Shell 等)时,必须在 capabilities/default.json 中明确授权:

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "默认权限配置",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "fs:read-all",
    "fs:write-all",
    "shell:open"
  ]
}

别被吓到:大多数情况下,跟着插件文档走,把需要的权限加上就好。这个机制的存在是为了防止恶意前端代码偷偷访问系统资源。


3.6 打包发布

npm run tauri build

这会在 src-tauri/target/release/bundle/ 下生成对应平台的安装包:

  • Windows:.msi.exe(NSIS 安装程序)
  • macOS:.dmg.app
  • Linux:.deb.rpm.AppImage

首次打包也很慢(Rust 全量编译),后续增量编译会快很多。


四、最佳实践

✅ 实践 1:Rust 做"重活",前端做 UI

Tauri 的设计哲学很清晰:前端负责视图,Rust 负责系统交互

不要尝试在前端用 fetch 访问本地文件,而是写一个 Rust Command 来读取;不要在前端做加密,而是用 Rust 来处理。

✅ 实践 2:异步命令是默认选择

凡是涉及 I/O(文件、网络、数据库)的操作,一律写成 async fn。同步命令会阻塞 Tauri 的主线程,导致界面卡顿。

✅ 实践 3:用 thiserror 处理错误

不要用 unwrap(),不要用 expect()(生产代码中),使用 thiserror 定义语义化的错误类型,让前端能收到有意义的错误信息。

✅ 实践 4:一定要提交 Cargo.lock

与 Node.js 项目的 package-lock.json 类似,src-tauri/Cargo.lock 确保了在不同机器上构建结果的一致性。务必将其提交到 Git,而不要加入 .gitignore

与此同时,src-tauri/target/ 目录应该被 .gitignore 忽略(它是编译产物,体积巨大)。

✅ 实践 5:善用官方插件,不要重复造轮子

Tauri 官方维护了大量高质量插件,覆盖了绝大多数常见场景:

插件 功能
@tauri-apps/plugin-fs 文件系统读写
@tauri-apps/plugin-shell 执行 Shell 命令
@tauri-apps/plugin-notification 系统通知
@tauri-apps/plugin-clipboard-manager 剪贴板操作
@tauri-apps/plugin-global-shortcut 全局快捷键
@tauri-apps/plugin-store 持久化键值存储
@tauri-apps/plugin-sql SQLite 数据库
@tauri-apps/plugin-http HTTP 请求(绕过 CSP)
@tauri-apps/plugin-updater 应用自动更新

✅ 实践 6:充分利用 CSP 保护应用安全

tauri.conf.json 中配置内容安全策略(CSP),可以有效防止 XSS 攻击:

{
  "app": {
    "security": {
      "csp": "default-src 'self'; script-src 'self'"
    }
  }
}

五、常见误区与坑

❌ 误区 1:以为不需要学 Rust

入门阶段确实可以少写 Rust,但随着应用复杂度提升,你不可避免地需要编写 Rust 代码。提前学习 Rust 基础知识(所有权、借用、Result/Option 类型)是明智之举。

推荐资源:The Rust Book(官方书,有中文版)

❌ 误区 2:忘记注册 Command

写好了 Rust Command,但忘记在 invoke_handler 中注册,然后前端一直报"command not found"。这是最高频的新手错误。

// ❌ 错误:写了函数但没注册
#[tauri::command]
fn my_command() {}

// ✅ 正确:必须在这里声明
.invoke_handler(tauri::generate_handler![my_command])

❌ 误区 3:在 Rust 中用 unwrap() 处处"爽"

开发阶段 unwrap() 可以快速跑通流程,但生产代码中一个 unwrap() 就可能让整个应用 panic 崩溃。养成用 ? 操作符 + Result 类型处理错误的习惯。

❌ 误区 4:参数命名不匹配导致的"神秘"错误

Rust 函数参数是 snake_case,前端传参是 camelCase,Tauri 会自动转换。但如果你的 Rust 参数是 user_name,前端就必须传 userName,写错了会得到"missing required argument"之类令人困惑的报错。

❌ 误区 5:用系统 WebView 导致样式兼容问题

Tauri 使用系统 WebView,这意味着在不同操作系统上,CSS/JS 的支持程度可能略有不同。

  • Windows 上是 Edge(Chromium 内核)✅
  • macOS 上是 Safari(WebKit 内核)⚠️ 部分 CSS 特性需要前缀
  • Linux 上是 WebKitGTK ⚠️ 版本可能较旧

解决办法:开发时在多个平台测试;使用 PostCSS/Autoprefixer 自动处理 CSS 前缀。

❌ 误区 6:混淆前端路由和窗口管理

在多窗口场景下,Tauri 的"窗口(Window)"和前端路由(如 React Router)是两个不同的概念。如果你只是要做页面跳转,用前端路由就好;如果需要独立的原生窗口(比如设置页面单独弹出),才需要用 Tauri 的窗口 API。


六、总结

回顾一下我们走过的路:

  • WHY:Tauri 用"复用系统 WebView"这一优雅设计,解决了 Electron 包体积臃肿、内存占用高的痛点,同时还默认提供了更好的安全性。

  • WHAT:Tauri 的核心是前端 WebView + Rust Core 的双进程架构,通过 IPC(Commands 和 Events)进行通信,并有细粒度的权限系统保障安全。

  • HOW:环境搭建 → 创建项目 → 理解目录结构 → 掌握 Command/Event 通信 → 配置权限 → 打包发布,这是完整的开发路径。

适合用 Tauri 的场景

  • 需要跨平台(Windows/macOS/Linux,甚至移动端)的桌面应用
  • 安装包体积内存占用有要求的场景
  • 需要访问系统 API(文件、通知、快捷键、系统托盘等)
  • 团队已有 Web 开发能力,希望低成本拓展到桌面端

不太适合的场景

  • 需要像素级跨平台 UI 一致性(系统 WebView 差异可能导致渲染略有不同)
  • 完全不愿意碰 Rust 的团队
  • 需要极致图形性能的游戏类应用(这种场景考虑 native 方案)

延伸阅读


Tauri 是一个相当年轻的框架,生态还在快速成长。你在探索过程中遇到的问题,很可能就是这个社区正在解决的问题。加入进来,说不定你就是下一个贡献者。

——本文基于 Tauri 2.x 版本编写,持续更新中。

cc Agent Teams 没用 AutoGen,怎么做的?

作者 ZzT
2026年4月8日 20:46

CC 源码系列 #3|CC 没用 AutoGen,自己写了 8000 行调度代码

上一篇《CC 记忆凭啥不用向量数据库》发出后,有读者留言问:Agent Teams 怎么实现的?有没有用 AutoGen 或者 CrewAI?这篇带你扒开 Claude Code 多智能体团队系统的源码,看看它底层到底是怎么调度的。


目录


一行框架代码都没用

我本来以为 Claude Code 的多智能体系统会包一层 AutoGen 或 CrewAI——毕竟现成的轮子摆在那里,不用白不用。

翻完源码之后,发现完全不是这回事。

CC 的 Agent Teams 实现在 packages/cli/src/services/teams/ 下,加上相关类型定义和工具层,合计接近 8000 行代码,一行现成框架都没有。

整个系统基于文件系统构建,没有数据库,没有消息队列,没有 Redis,没有 RPC。

读完的第一感受是:很多框架党想多了。


目录结构:就是一堆 JSON 文件

目录结构

CC Teams 的运行时目录长这样:

~/.claude/teams/<team-name>/
  members.json          # 团队成员注册表
  tasks.json            # 任务列表(全局共享)
  inboxes/
    team-lead.json      # team-lead 的收件箱
    agent-1.json        # agent-1 的收件箱
    agent-2.json        # agent-2 的收件箱
    ...
  state/
    <agent-name>.json   # 各成员运行时状态

这就是整个多智能体系统的"基础设施"——一个目录,几个 JSON 文件。

没有消息队列服务要启动,没有数据库要初始化,没有网络端口要监听。文件系统即协议。


核心机制:文件 inbox

inbox 机制

每个团队成员的"收件箱"是一个独立的 JSON 文件,路径为 inboxes/<name>.json

消息格式如下:

[
  {
    "id": "msg_abc123",
    "from": "team-lead",
    "to": "agent-1",
    "text": "开始处理任务 #3",
    "timestamp": "2026-04-07T10:00:00Z",
    "read": false
  },
  {
    "id": "msg_def456",
    "from": "agent-1",
    "to": "team-lead",
    "text": "{\"type\": \"idle_notification\", \"agent\": \"agent-1\"}",
    "timestamp": "2026-04-07T10:05:00Z",
    "read": true
  }
]

SendMessage = append 一条消息到目标 inbox 文件。

收消息 = 读文件,筛选 read: false 的条目。

协议消息(shutdown_request / idle_notification / plan_approval_request)也走同一个 inbox,靠 text 字段里的 JSON type 字段区分。没有单独的控制通道,没有优先级队列。

这个设计意味着:如果某个 Agent 卡住了,你可以直接 cat inboxes/agent-name.json 看消息有没有送到、有没有被 read。调试体验极其直接。


Agent 生命周期:Idle ≠ Dead

Idle 机制

这是整个系统最反直觉的一点。

处理完一个 turn 之后,Agent 进程不会退出。

它会:

  1. 向 inbox 发一条 idle_notification
  2. 进入轮询状态,持续读取自己的 inbox
  3. 检测到新消息后立即唤醒,开始处理下一个 turn

轮询间隔很短,唤醒是毫秒级的。

为什么不重启?重启的成本是重新加载所有上下文。 进程保活意味着 context window 里的内容还在,下一个任务可以直接衔接。这对于有状态的长期任务尤其重要。

所以当你看到一个 Agent 长时间没有输出时,不要以为它挂了——它可能只是在 idle,等消息。

正确的排查方式:直接 cat inboxes/<name>.json,看最后几条消息的 read 状态。


任务系统:无锁竞争设计

任务系统

任务存储在 tasks.json 里,格式大致如下:

[
  {
    "id": "task_001",
    "subject": "修复登录页面 Bug",
    "description": "用户反馈登录后跳转失败,复现路径:...",
    "status": "in_progress",
    "owner": "agent-1",
    "blockedBy": [],
    "blocks": ["task_002"]
  },
  {
    "id": "task_002",
    "subject": "更新登录相关文档",
    "status": "pending",
    "owner": null,
    "blockedBy": ["task_001"],
    "blocks": []
  }
]

没有分布式锁。

CC 的任务竞争靠的是应用层规范:team-lead 主动派活,不依赖 Agent 自主抢占。这规避了多 Agent 同时 claim 同一个任务的并发问题——因为 team-lead 是单点分派的。

blocker 依赖(blockedBy / blocks)由 Agent 手动维护。清理不及时会导致任务链卡住。依赖链越长,出问题的概率越高。这是这套设计里明确的 trade-off。


这套设计的 7 条代价

设计代价

这套方案听起来很美("就是几个 JSON 文件"),但不是没有代价:

1. 并发写冲突

多个 Agent 同时向同一个 inbox 文件 append 消息,存在并发写丢消息的风险。CC 没有加锁,靠的是写入频率较低(每次写入有 LLM 处理间隔)来规避。

2. 文件只增不减

inbox 文件会持续增长,没有自动 GC。长时间运行后需要手动清理,否则读取成本上升。

3. 崩溃恢复弱

进程崩溃后,read: false 的消息会被重新处理(at-least-once 语义)。没有持久化的消费位点,幂等性由上层业务保证。

4. 无横向扩展

整个系统假设单机运行。不支持跨机器的 Agent,不支持动态扩容。这是设计目标决定的,不是遗漏。

5. 依赖链手动维护

blockedBy / blocks 需要 Agent 自己更新。如果 Agent 忘记清理,或者清理时机不对,任务链会卡死。

6. 消息无优先级

所有消息 FIFO 处理,没有优先级队列。紧急任务没有插队机制,只能靠 team-lead 提前发送。

7. 状态可观测性差

没有统一的 dashboard,观察整个团队状态需要手动读多个 JSON 文件。这在调试复杂场景时比较费力。

CC 在源码注释里对这些问题都有所承认。这是典型的 "worse is better":牺牲严格正确性换简洁,对目标场景(单机、小团队、可审计)来说是合理的取舍。


与主流框架对比

框架对比

维度 CC Teams AutoGen CrewAI
依赖 零(文件系统) Python 生态 Python 生态
消息机制 文件 inbox 内存 / Redis 内存
持久化 自动(文件即状态) 需要配置 需要配置
可观测 cat JSON 即可 需要日志系统 需要日志系统
横向扩展 不支持 支持 支持
学习成本 极低 中等 中等
目标场景 单机、小团队、可审计 通用、分布式 通用、角色化

CC Teams 明显是为单机、小团队、可审计优化的。横向扩展不是它的目标,也不应该是你对它的期待。


从源码反推的 5 条使用技巧

使用技巧

1. 卡住直接 cat inbox

cat ~/.claude/teams/<team-name>/inboxes/<agent-name>.json | jq '.[] | select(.read == false)'

这一条比任何日志都直接。消息在不在、有没有被读,一目了然。

2. 别频繁重启 Agent

重启意味着 context window 清空。除非你确认当前上下文已经失效,否则 idle 状态的 Agent 不需要重启。

3. shutdown 也是消息

要关闭某个 Agent,发一条 shutdown_request 消息到它的 inbox,不要直接 kill 进程。优雅关闭能保证当前 turn 处理完成后再退出。

{
  "type": "shutdown_request",
  "request_id": "req_xxx"
}

4. team-lead 主动派活,不要等 Agent 自主抢占

任务分配收敛更快,也避免并发 claim 冲突。

5. 依赖链扁平化

blockedBy 链越长,出问题越多。能并行的任务尽量不加 blocker,单链深度不超过 3 层是个经验值。


我的实际运行场景

我自己跑的是一个 commander 团队:1 个 lead + 7 个成员。

成员分工:

  • xhs-ops:每天巡查小红书笔记评论,识别用户需求,上报 commander
  • juejin-ops:同步发布技术文章到掘金(就是正在做这件事)
  • github-ops:巡查开源项目的 Issue,自动派发给开发成员
  • cc-stats-dev:负责 cc-statistics 的功能开发和 Bug 修复
  • stock-dev:AI 投研分析服务,接收用户的股票分析请求
  • album-dev:KMP 相册应用开发

典型流程:

用户在小红书评论"能不能支持 CSV 导出?"
    → xhs-ops 识别 → 上报 commander
    → commander 评估 P2(体验改善)
    → 派发给 cc-stats-dev
    → cc-stats-dev 进 ~/Claude/cc-statistics 仓库实现
    → 完成后通知 commander
    → commander 通知 xhs-ops
    → xhs-ops 回复用户 + 发布更新帖

整套流程全靠 inbox + task list 驱动,没有额外的协调层。

有一次团队成员 idle 半小时,我以为挂了,直接 cat inboxes/xxx.json 一看——消息根本没送到,是 SendMessage 时写错了目标名。这种调试体验在看日志的框架里是复现不了的。


一句话总结

CC Teams 用文件 inbox 替代消息队列,用应用层规范替代分布式锁,用进程保活替代冷启动,用 JSON 文件替代状态数据库。Idle ≠ Dead,cat inbox 即调试,worse is better 是它的设计哲学。

如果你之前因为 AutoGen / CrewAI 太复杂没敢入坑,CC Teams 是个很好的起点——理解成本几乎为零,跑起来就是几个 JSON 文件。


延伸:用 cc-statistics 观察多 Agent 的 token 消耗

如果你在跑 CC Teams,cc-statistics 可以帮你统计各 Agent 的 token 消耗分布。

多 Agent 并行时,token 消耗会比单 Agent 高出好几倍。cc-statistics 支持按 session 拆分,能看到每个 Agent 的 input/output token 占比,方便评估成本。


下一篇预告

CC 源码系列 #4:Tool 系统——为什么一个 LLM 工具调用框架要写 1 万多行?

从 Bash、Read、Write、Edit、Glob 这些基础工具,到权限沙箱、超时控制、hook 系统,下一篇扒开看。

CC 源码系列 #3|CC 没用 AutoGen,自己写了 8000 行调度代码

作者 ZzT
2026年4月8日 20:44

CC 源码系列 #3|CC 没用 AutoGen,自己写了 8000 行调度代码

上一篇《CC 记忆凭啥不用向量数据库》发出后,有读者留言问:Agent Teams 怎么实现的?有没有用 AutoGen 或者 CrewAI?这篇带你扒开 Claude Code 多智能体团队系统的源码,看看它底层到底是怎么调度的。


目录


一行框架代码都没用

我本来以为 Claude Code 的多智能体系统会包一层 AutoGen 或 CrewAI——毕竟现成的轮子摆在那里,不用白不用。

翻完源码之后,发现完全不是这回事。

CC 的 Agent Teams 实现在 packages/cli/src/services/teams/ 下,加上相关类型定义和工具层,合计接近 8000 行代码,一行现成框架都没有。

整个系统基于文件系统构建,没有数据库,没有消息队列,没有 Redis,没有 RPC。

读完的第一感受是:很多框架党想多了。


目录结构:就是一堆 JSON 文件

目录结构

CC Teams 的运行时目录长这样:

~/.claude/teams/<team-name>/
  members.json          # 团队成员注册表
  tasks.json            # 任务列表(全局共享)
  inboxes/
    team-lead.json      # team-lead 的收件箱
    agent-1.json        # agent-1 的收件箱
    agent-2.json        # agent-2 的收件箱
    ...
  state/
    <agent-name>.json   # 各成员运行时状态

这就是整个多智能体系统的"基础设施"——一个目录,几个 JSON 文件。

没有消息队列服务要启动,没有数据库要初始化,没有网络端口要监听。文件系统即协议。


核心机制:文件 inbox

inbox 机制

每个团队成员的"收件箱"是一个独立的 JSON 文件,路径为 inboxes/<name>.json

消息格式如下:

[
  {
    "id": "msg_abc123",
    "from": "team-lead",
    "to": "agent-1",
    "text": "开始处理任务 #3",
    "timestamp": "2026-04-07T10:00:00Z",
    "read": false
  },
  {
    "id": "msg_def456",
    "from": "agent-1",
    "to": "team-lead",
    "text": "{\"type\": \"idle_notification\", \"agent\": \"agent-1\"}",
    "timestamp": "2026-04-07T10:05:00Z",
    "read": true
  }
]

SendMessage = append 一条消息到目标 inbox 文件。

收消息 = 读文件,筛选 read: false 的条目。

协议消息(shutdown_request / idle_notification / plan_approval_request)也走同一个 inbox,靠 text 字段里的 JSON type 字段区分。没有单独的控制通道,没有优先级队列。

这个设计意味着:如果某个 Agent 卡住了,你可以直接 cat inboxes/agent-name.json 看消息有没有送到、有没有被 read。调试体验极其直接。


Agent 生命周期:Idle ≠ Dead

Idle 机制

这是整个系统最反直觉的一点。

处理完一个 turn 之后,Agent 进程不会退出。

它会:

  1. 向 inbox 发一条 idle_notification
  2. 进入轮询状态,持续读取自己的 inbox
  3. 检测到新消息后立即唤醒,开始处理下一个 turn

轮询间隔很短,唤醒是毫秒级的。

为什么不重启?重启的成本是重新加载所有上下文。 进程保活意味着 context window 里的内容还在,下一个任务可以直接衔接。这对于有状态的长期任务尤其重要。

所以当你看到一个 Agent 长时间没有输出时,不要以为它挂了——它可能只是在 idle,等消息。

正确的排查方式:直接 cat inboxes/<name>.json,看最后几条消息的 read 状态。


任务系统:无锁竞争设计

任务系统

任务存储在 tasks.json 里,格式大致如下:

[
  {
    "id": "task_001",
    "subject": "修复登录页面 Bug",
    "description": "用户反馈登录后跳转失败,复现路径:...",
    "status": "in_progress",
    "owner": "agent-1",
    "blockedBy": [],
    "blocks": ["task_002"]
  },
  {
    "id": "task_002",
    "subject": "更新登录相关文档",
    "status": "pending",
    "owner": null,
    "blockedBy": ["task_001"],
    "blocks": []
  }
]

没有分布式锁。

CC 的任务竞争靠的是应用层规范:team-lead 主动派活,不依赖 Agent 自主抢占。这规避了多 Agent 同时 claim 同一个任务的并发问题——因为 team-lead 是单点分派的。

blocker 依赖(blockedBy / blocks)由 Agent 手动维护。清理不及时会导致任务链卡住。依赖链越长,出问题的概率越高。这是这套设计里明确的 trade-off。


这套设计的 7 条代价

设计代价

这套方案听起来很美("就是几个 JSON 文件"),但不是没有代价:

1. 并发写冲突

多个 Agent 同时向同一个 inbox 文件 append 消息,存在并发写丢消息的风险。CC 没有加锁,靠的是写入频率较低(每次写入有 LLM 处理间隔)来规避。

2. 文件只增不减

inbox 文件会持续增长,没有自动 GC。长时间运行后需要手动清理,否则读取成本上升。

3. 崩溃恢复弱

进程崩溃后,read: false 的消息会被重新处理(at-least-once 语义)。没有持久化的消费位点,幂等性由上层业务保证。

4. 无横向扩展

整个系统假设单机运行。不支持跨机器的 Agent,不支持动态扩容。这是设计目标决定的,不是遗漏。

5. 依赖链手动维护

blockedBy / blocks 需要 Agent 自己更新。如果 Agent 忘记清理,或者清理时机不对,任务链会卡死。

6. 消息无优先级

所有消息 FIFO 处理,没有优先级队列。紧急任务没有插队机制,只能靠 team-lead 提前发送。

7. 状态可观测性差

没有统一的 dashboard,观察整个团队状态需要手动读多个 JSON 文件。这在调试复杂场景时比较费力。

CC 在源码注释里对这些问题都有所承认。这是典型的 "worse is better":牺牲严格正确性换简洁,对目标场景(单机、小团队、可审计)来说是合理的取舍。


与主流框架对比

框架对比

维度 CC Teams AutoGen CrewAI
依赖 零(文件系统) Python 生态 Python 生态
消息机制 文件 inbox 内存 / Redis 内存
持久化 自动(文件即状态) 需要配置 需要配置
可观测 cat JSON 即可 需要日志系统 需要日志系统
横向扩展 不支持 支持 支持
学习成本 极低 中等 中等
目标场景 单机、小团队、可审计 通用、分布式 通用、角色化

CC Teams 明显是为单机、小团队、可审计优化的。横向扩展不是它的目标,也不应该是你对它的期待。


从源码反推的 5 条使用技巧

使用技巧

1. 卡住直接 cat inbox

cat ~/.claude/teams/<team-name>/inboxes/<agent-name>.json | jq '.[] | select(.read == false)'

这一条比任何日志都直接。消息在不在、有没有被读,一目了然。

2. 别频繁重启 Agent

重启意味着 context window 清空。除非你确认当前上下文已经失效,否则 idle 状态的 Agent 不需要重启。

3. shutdown 也是消息

要关闭某个 Agent,发一条 shutdown_request 消息到它的 inbox,不要直接 kill 进程。优雅关闭能保证当前 turn 处理完成后再退出。

{
  "type": "shutdown_request",
  "request_id": "req_xxx"
}

4. team-lead 主动派活,不要等 Agent 自主抢占

任务分配收敛更快,也避免并发 claim 冲突。

5. 依赖链扁平化

blockedBy 链越长,出问题越多。能并行的任务尽量不加 blocker,单链深度不超过 3 层是个经验值。


我的实际运行场景

我自己跑的是一个 commander 团队:1 个 lead + 7 个成员。

成员分工:

  • xhs-ops:每天巡查小红书笔记评论,识别用户需求,上报 commander
  • juejin-ops:同步发布技术文章到掘金(就是正在做这件事)
  • github-ops:巡查开源项目的 Issue,自动派发给开发成员
  • cc-stats-dev:负责 cc-statistics 的功能开发和 Bug 修复
  • stock-dev:AI 投研分析服务,接收用户的股票分析请求
  • album-dev:KMP 相册应用开发

典型流程:

用户在小红书评论"能不能支持 CSV 导出?"
    → xhs-ops 识别 → 上报 commander
    → commander 评估 P2(体验改善)
    → 派发给 cc-stats-dev
    → cc-stats-dev 进 ~/Claude/cc-statistics 仓库实现
    → 完成后通知 commander
    → commander 通知 xhs-ops
    → xhs-ops 回复用户 + 发布更新帖

整套流程全靠 inbox + task list 驱动,没有额外的协调层。

有一次团队成员 idle 半小时,我以为挂了,直接 cat inboxes/xxx.json 一看——消息根本没送到,是 SendMessage 时写错了目标名。这种调试体验在看日志的框架里是复现不了的。


一句话总结

CC Teams 用文件 inbox 替代消息队列,用应用层规范替代分布式锁,用进程保活替代冷启动,用 JSON 文件替代状态数据库。Idle ≠ Dead,cat inbox 即调试,worse is better 是它的设计哲学。

如果你之前因为 AutoGen / CrewAI 太复杂没敢入坑,CC Teams 是个很好的起点——理解成本几乎为零,跑起来就是几个 JSON 文件。


延伸:用 cc-statistics 观察多 Agent 的 token 消耗

如果你在跑 CC Teams,cc-statistics 可以帮你统计各 Agent 的 token 消耗分布。

多 Agent 并行时,token 消耗会比单 Agent 高出好几倍。cc-statistics 支持按 session 拆分,能看到每个 Agent 的 input/output token 占比,方便评估成本。


下一篇预告

CC 源码系列 #4:Tool 系统——为什么一个 LLM 工具调用框架要写 1 万多行?

从 Bash、Read、Write、Edit、Glob 这些基础工具,到权限沙箱、超时控制、hook 系统,下一篇扒开看。

前端必看!前端路由守卫这么写,再也不担心权限混乱(Vue/React通用)

作者 前端Hardy
2026年4月8日 18:38

所有前端必看!路由守卫看似简单,却藏着很多坑——未登录能直接访问个人中心、管理员页面普通人能进、跳转时数据未加载就渲染。全程实操干货+完整封装,Vue2/Vue3、React 都能用,复制就能实现权限管控

先搞懂:路由守卫到底用来做什么?

不管是 Vue Router 还是 React Router,路由守卫的核心作用只有一个:控制路由的访问权限和跳转逻辑,解决以下高频问题:

  • 未登录用户,禁止访问个人中心、订单页等需要权限的页面;
  • 不同角色(普通用户/管理员),展示不同的路由页面;
  • 页面跳转前,校验数据、确认操作(比如未保存的表单,提示用户);
  • 页面加载前,获取必要数据(比如用户信息),避免页面空白。

重点:路由守卫是前端权限管控的核心。Vue 和 React 用法略有差异,但逻辑一致。本文分别给出完整示例,复制就能适配自己的项目,不用再从零编写。


核心干货:Vue2/Vue3 路由守卫完整封装(直接复制)

Vue 项目用 Vue Router,路由守卫分为 3 类:全局守卫、路由独享守卫、组件内守卫。重点掌握全局守卫,就能解决 80% 的权限问题。

1. Vue3 + Vue Router 4(最常用,推荐)

新建 router/index.js,全局守卫 + 路由配置,一步到位:

// router/index.js(Vue3)
import { createRouter, createWebHistory } from 'vue-router';
import { getStorage } from '@/utils/storage'; // 复用之前封装的 LocalStorage 工具

// 1. 定义路由(区分公开路由和需要权限的路由)
const routes = [
  // 公开路由(无需登录就能访问)
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/Login.vue'),
    meta: { requiresAuth: false } // 标记:无需权限
  },
  {
    path: '/',
    name: 'Home',
    component: () => import('@/views/Home.vue'),
    meta: { requiresAuth: false }
  },
  // 需要权限的路由(必须登录才能访问)
  {
    path: '/user',
    name: 'UserCenter',
    component: () => import('@/views/UserCenter.vue'),
    meta: {
      requiresAuth: true, // 标记:需要权限
      role: 'user' // 角色限制:普通用户即可访问
    }
  },
  // 管理员路由(只有管理员能访问)
  {
    path: '/admin',
    name: 'Admin',
    component: () => import('@/views/Admin.vue'),
    meta: {
      requiresAuth: true,
      role: 'admin' // 角色限制:仅管理员
    }
  },
  // 404页面
  {
    path: '/:pathMatch(.*)*',
    name: '404',
    component: () => import('@/views/404.vue')
  }
];

// 2. 创建路由实例
const router = createRouter({
  history: createWebHistory(import.meta.env.VITE_BASE_URL),
  routes
});

// 3. 全局前置守卫(跳转前校验,核心)
router.beforeEach((to, from, next) => {
  // 1. 获取 token(从 LocalStorage 中取)
  const token = getStorage('token');
  // 2. 获取当前用户角色(登录后存储的用户信息)
  const userRole = getStorage('userInfo')?.role || '';

  // 3. 校验逻辑
  if (to.meta.requiresAuth) {
    // 3.1 需要权限的路由:判断是否登录
    if (!token) {
      // 未登录,跳转到登录页,登录后返回当前页面
      return next({ name: 'Login', query: { redirect: to.fullPath } });
    } else {
      // 已登录,判断角色是否匹配
      if (to.meta.role && to.meta.role !== userRole) {
        // 角色不匹配,跳转到首页(或 403 页面)
        return next({ name: 'Home' });
      }
      // 登录且角色匹配,允许跳转
      next();
    }
  } else {
    // 3.2 公开路由:直接跳转
    next();
  }
});

// 4. 全局后置守卫(跳转后执行,比如修改页面标题)
router.afterEach((to) => {
  // 设置页面标题
  document.title = to.meta.title || '前端路由守卫示例';
});

export default router;

页面中使用(Vue3):

<!-- 登录页面,登录成功后跳转回之前的页面 -->
<script setup>
import { useRouter, useRoute } from 'vue-router';
import { setStorage } from '@/utils/storage';

const router = useRouter();
const route = useRoute();

const login = async () => {
  const res = await loginApi(); // 登录接口
  // 存储 token 和用户信息
  setStorage('token', res.data.token, 86400);
  setStorage('userInfo', res.data.user, 86400);

  // 跳转回之前的页面(如果有),否则跳首页
  const redirect = route.query.redirect || '/';
  router.push(redirect);
};
</script>

2. Vue2 + Vue Router 3(兼容旧项目)

逻辑和 Vue3 一致,仅语法略有差异,直接复制:

// router/index.js(Vue2)
import Vue from 'vue';
import Router from 'vue-router';
import { getStorage } from '@/utils/storage';

Vue.use(Router);

const routes = [
  // 路由配置和 Vue3 一致
  { path: '/login', name: 'Login', component: () => import('@/views/Login'), meta: { requiresAuth: false } },
  { path: '/user', name: 'UserCenter', component: () => import('@/views/UserCenter'), meta: { requiresAuth: true, role: 'user' } },
  { path: '/admin', name: 'Admin', component: () => import('@/views/Admin'), meta: { requiresAuth: true, role: 'admin' } },
];

const router = new Router({
  mode: 'history',
  base: process.env.BASE_URL,
  routes
});

// 全局前置守卫
router.beforeEach((to, from, next) => {
  const token = getStorage('token');
  const userRole = getStorage('userInfo')?.role || '';

  if (to.meta.requiresAuth) {
    if (!token) {
      next({ name: 'Login', query: { redirect: to.fullPath } });
    } else {
      if (to.meta.role && to.meta.role !== userRole) {
        next({ name: 'Home' });
      } else {
        next();
      }
    }
  } else {
    next();
  }
});

export default router;

核心干货:React + React Router 6 路由守卫封装(直接复制)

React Router 6 取消了传统的路由守卫 API,改用「组件封装」的方式实现权限控制,更灵活,适配 React 函数式组件,直接复制就能用。

1. 封装权限守卫组件(utils/PrivateRoute.js

import { Navigate, Outlet } from 'react-router-dom';
import { getStorage } from '@/utils/storage';

/**
 * 权限守卫组件
 * @param {Object} props - 传入的角色限制
 * @param {string} props.role - 允许访问的角色(可选)
 */
export const PrivateRoute = ({ role }) => {
  // 获取 token 和用户角色
  const token = getStorage('token');
  const userRole = getStorage('userInfo')?.role || '';

  // 未登录,跳转到登录页
  if (!token) {
    return <Navigate to="/login" replace />;
  }

  // 有角色限制,且当前角色不匹配,跳转到首页
  if (role && role !== userRole) {
    return <Navigate to="/" replace />;
  }

  // 权限通过,渲染子路由(Outlet 对应 Vue 的 router-view)
  return <Outlet />;
};

2. 路由配置(router/index.jsx

import { createBrowserRouter } from 'react-router-dom';
import PrivateRoute from '@/utils/PrivateRoute';
// 引入页面组件
import Login from '@/views/Login';
import Home from '@/views/Home';
import UserCenter from '@/views/UserCenter';
import Admin from '@/views/Admin';
import NotFound from '@/views/404';

// 创建路由
const router = createBrowserRouter([
  {
    path: '/',
    element: <Home />
  },
  {
    path: '/login',
    element: <Login />
  },
  // 需要权限的路由:用 PrivateRoute 包裹
  {
    path: '/user',
    element: <PrivateRoute role="user" />, // 普通用户可访问
    children: [
      { path: '', element: <UserCenter /> } // 子路由,对应 Outlet
    ]
  },
  // 管理员路由:限制角色为 admin
  {
    path: '/admin',
    element: <PrivateRoute role="admin" />,
    children: [
      { path: '', element: <Admin /> }
    ]
  },
  // 404 页面
  {
    path: '*',
    element: <NotFound />
  }
]);

export default router;

3. 入口文件中使用(main.jsx

import React from 'react';
import ReactDOM from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import router from './router';

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>
);

4.React 登录页面使用:

import { useNavigate, useLocation } from 'react-router-dom';
import { setStorage } from '@/utils/storage';

function Login() {
  const navigate = useNavigate();
  const location = useLocation();
  // 获取跳转前的页面地址
  const redirect = new URLSearchParams(location.search).get('redirect') || '/';

  const login = async () => {
    const res = await loginApi();
    setStorage('token', res.data.token, 86400);
    setStorage('userInfo', res.data.user, 86400);
    // 跳转回之前的页面
    navigate(redirect, { replace: true });
  };

  return (
    <button onClick={login}>登录</button>
  );
}

export default Login;

实战避坑:4 个高频坑,新手必避

坑 1:Vue 路由守卫中,忘记调用 next(),导致页面卡死

错误示例:在 beforeEach 中只做了判断,没调用 next(),路由无法跳转,页面卡死。
正确做法:所有分支都必须调用 next(),允许跳转用 next(),重定向用 next({ name: 'Login' })

坑 2:React Router 6 中,用旧版本语法写守卫,导致失效

React Router 6 取消了 beforeEachafterEach 等 API,不要再用旧版本的写法。
正确做法:用「PrivateRoute 组件 + Outlet」的方式实现权限控制,本文示例直接可用。

坑 3:未处理“登录后跳转回原页面”,体验变差
用户未登录访问需要权限的页面,登录后应该跳转回之前的页面,而不是默认首页。
正确做法:跳转登录页时,携带当前页面地址(query 参数),登录成功后跳转回去。

坑 4:角色权限判断不严谨,导致越权访问

只判断是否登录,不判断角色,导致普通用户能访问管理员页面。
正确做法:在路由 meta(Vue)或 PrivateRoute 组件(React)中添加角色限制,登录后校验角色。


进阶技巧:路由守卫高级用法

1. 表单未保存,禁止跳转(组件内守卫 / Vue 专属)

<script setup>
import { onBeforeRouteLeave } from 'vue-router';

// 组件内守卫:离开当前页面时触发
onBeforeRouteLeave((to, from, next) => {
  // 判断表单是否未保存
  if (formIsDirty.value) {
    if (confirm('表单未保存,确定要离开吗?')) {
      next(); // 确认离开
    } else {
      next(false); // 取消离开
    }
  } else {
    next(); // 表单已保存,允许离开
  }
});
</script>

2. 路由跳转时,加载 loading 状态(全局守卫)

// Vue3 全局守卫中添加 loading
import { ref } from 'vue';
export const isLoading = ref(false);

router.beforeEach((to, from, next) => {
  isLoading.value = true; // 跳转前显示 loading
  // 原有校验逻辑...
  next();
});

router.afterEach(() => {
  setTimeout(() => {
    isLoading.value = false; // 跳转后隐藏 loading
  }, 300);
});

结尾:干货总结

路由守卫是前端权限管控的核心。Vue 和 React 用法虽有差异,但逻辑一致——判断登录状态、校验角色、控制跳转。一套封装就能覆盖所有场景,避开 4 个高频坑,复制就能实现权限管控。


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

前端必看!LocalStorage这么用,再也不踩坑(多框架通用,直接复制)

作者 前端Hardy
2026年4月8日 18:37

所有前端必看!LocalStorage看似简单,却有90%的人用错——存对象报错、存数组失效、数据污染、内存溢出,甚至导致页面卡顿。全程实操干货+通用封装,Vue/React/Uniapp/小程序/Node都能用,复制就能避免所有坑

先搞懂:LocalStorage核心痛点,你一定踩过

做前端开发,谁没用 LocalStorage 存过 token、用户信息?但大多数人都是裸写 localStorage.setItemlocalStorage.getItem,看似简单,实则全是坑:

  • 只能存字符串:存对象/数组直接报错,或取出来变成 [object Object]
  • 没有过期时间:存的 token、临时数据一直占用内存,导致数据污染;
  • 没有容错处理:取不到数据直接报错,影响页面渲染;
  • 键名混乱:多个页面/组件存数据,容易覆盖、冲突。

重点:LocalStorage 是前端本地存储的基础,Vue、React、Uniapp、小程序、Node(前端渲染)都能用。一套通用封装,彻底解决所有痛点,不用重复写冗余代码。

核心干货:LocalStorage通用封装(直接复制,多框架通用)

新建 utils/storage.js,一次封装,全局使用。支持存字符串、对象、数组,带过期时间、容错处理、键名统一,复制到任何前端项目都能直接用!

/**
 * LocalStorage通用封装(Vue/React/Uniapp/小程序通用)
 * 支持:存字符串、对象、数组 + 过期时间 + 容错处理 + 键名统一
 */
const STORAGE_KEY_PREFIX = 'frontend_'; // 键名前缀,避免冲突

// 1. 存数据(支持过期时间,单位:秒)
export const setStorage = (key, value, expire = 0) => {
  try {
    // 处理对象/数组,转为JSON字符串(LocalStorage只能存字符串)
    const data = {
      value: typeof value === 'object' ? JSON.stringify(value) : value,
      expire: expire > 0 ? Date.now() + expire * 1000 : 0 // 0表示永久有效
    };
    // 键名加前缀,避免和其他项目/插件冲突
    localStorage.setItem(`${STORAGE_KEY_PREFIX}${key}`, JSON.stringify(data));
  } catch (error) {
    console.error('LocalStorage存储失败:', error);
    // 兼容低版本浏览器/隐私模式(LocalStorage不可用)
    alert('浏览器存储不可用,请开启正常模式后重试');
  }
};

// 2. 取数据(自动处理JSON解析,判断过期)
export const getStorage = (key) => {
  try {
    const storageKey = `${STORAGE_KEY_PREFIX}${key}`;
    const dataStr = localStorage.getItem(storageKey);
    if (!dataStr) return null;

    const data = JSON.parse(dataStr);
    // 判断是否过期(expire=0表示永久有效)
    if (data.expire > 0 && Date.now() > data.expire) {
      // 过期后自动删除,避免无效数据占用内存
      localStorage.removeItem(storageKey);
      return null;
    }

    // 自动解析JSON(如果存的是对象/数组)
    try {
      return JSON.parse(data.value);
    } catch (e) {
      // 不是JSON格式,直接返回原始值(字符串)
      return data.value;
    }
  } catch (error) {
    console.error('LocalStorage获取失败:', error);
    return null;
  }
};

// 3. 删除单个数据
export const removeStorage = (key) => {
  try {
    localStorage.removeItem(`${STORAGE_KEY_PREFIX}${key}`);
  } catch (error) {
    console.error('LocalStorage删除失败:', error);
  }
};

// 4. 清空所有数据(只清空当前项目的,不影响其他项目)
export const clearStorage = () => {
  try {
    // 只删除带前缀的键,避免清空其他项目的存储
    Object.keys(localStorage).forEach(key => {
      if (key.startsWith(STORAGE_KEY_PREFIX)) {
        localStorage.removeItem(key);
      }
    });
  } catch (error) {
    console.error('LocalStorage清空失败:', error);
  }
};

// 5. 批量存数据
export const setStorageBatch = (obj, expire = 0) => {
  try {
    Object.entries(obj).forEach(([key, value]) => {
      setStorage(key, value, expire);
    });
  } catch (error) {
    console.error('LocalStorage批量存储失败:', error);
  }
};

实战用法:多框架示例,直接复制

不管是 Vue、React、Uniapp,用法完全一致,只需引入封装好的方法,无需额外适配。以下示例覆盖 80% 的使用场景。

1. 基础用法:存/取字符串、对象、数组

// 引入封装的方法(所有框架通用)
import { setStorage, getStorage, removeStorage } from '@/utils/storage';

// 1. 存字符串(比如token)
setStorage('token', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', 86400); // 过期时间1天(86400秒)

// 2. 存对象(比如用户信息)
const userInfo = { id: 1, name: '张三', age: 25 };
setStorage('userInfo', userInfo, 86400);

// 3. 存数组(比如历史记录)
const historyList = ['Vue', 'React', 'JS'];
setStorage('historyList', historyList); // 不设过期时间,永久有效

// 4. 取数据(自动解析对象/数组,无需手动JSON.parse)
const token = getStorage('token');
const user = getStorage('userInfo'); // 直接拿到对象,无需解析
const history = getStorage('historyList'); // 直接拿到数组

// 5. 删除数据
removeStorage('token'); // 删除单个
// clearStorage(); // 清空当前项目所有存储

2. Vue3/Uniapp页面中使用

<script setup>
import { ref, onMounted } from 'vue';
import { setStorage, getStorage } from '@/utils/storage';

const userInfo = ref({});

// 页面加载时,从LocalStorage取用户信息
onMounted(() => {
  const user = getStorage('userInfo');
  if (user) {
    userInfo.value = user;
  }
});

// 登录成功后,存用户信息和token
const login = async () => {
  const res = await loginApi(); // 登录接口
  setStorage('token', res.data.token, 86400);
  setStorage('userInfo', res.data.user, 86400);
  userInfo.value = res.data.user;
};
</script>

3. React页面中使用

import { useState, useEffect } from 'react';
import { setStorage, getStorage, removeStorage } from '@/utils/storage';

function UserPage() {
  const [user, setUser] = useState({});

  useEffect(() => {
    // 组件挂载时取数据
    const userInfo = getStorage('userInfo');
    if (userInfo) {
      setUser(userInfo);
    }
  }, []);

  // 退出登录,删除存储
  const logout = () => {
    removeStorage('token');
    removeStorage('userInfo');
    setUser({});
  };

  return (
    <div>
      {user.name}
      <button onClick={logout}>退出登录</button>
    </div>
  );
}

4. 小程序/Uniapp适配(特殊处理)

小程序不支持 window.localStorage,需替换为 wx.setStorageSync 等原生 API,修改封装方法即可,核心逻辑不变:

// 小程序版本封装(utils/storage.js)
const STORAGE_KEY_PREFIX = 'frontend_';

// 存数据
export const setStorage = (key, value, expire = 0) => {
  try {
    const data = {
      value: typeof value === 'object' ? JSON.stringify(value) : value,
      expire: expire > 0 ? Date.now() + expire * 1000 : 0
    };
    wx.setStorageSync(`${STORAGE_KEY_PREFIX}${key}`, data);
  } catch (error) {
    console.error('存储失败:', error);
    wx.showToast({ title: '存储不可用', icon: 'none' });
  }
};

// 取数据(其他方法同理,替换为wx.getStorageSync、wx.removeStorageSync)
export const getStorage = (key) => {
  try {
    const storageKey = `${STORAGE_KEY_PREFIX}${key}`;
    const data = wx.getStorageSync(storageKey);
    if (!data) return null;
    if (data.expire > 0 && Date.now() > data.expire) {
      wx.removeStorageSync(storageKey);
      return null;
    }
    try {
      return JSON.parse(data.value);
    } catch (e) {
      return data.value;
    }
  } catch (error) {
    console.error('获取失败:', error);
    return null;
  }
};

实战避坑:5个高频坑,新手必避

坑1:直接存对象/数组,导致报错或解析失败

错误示例localStorage.setItem('user', {name: '张三'}),直接存对象会报错。
正确做法:用封装的 setStorage,自动将对象/数组转为 JSON 字符串,取的时候自动解析。

坑2:不设过期时间,导致数据污染

存 token、临时数据时,不设过期时间,用户退出后数据依然存在,再次登录会出现异常。
正确做法:给敏感数据、临时数据设置过期时间(比如 token 设 1 天)。

坑3:键名不统一,导致覆盖冲突

多个组件/页面存数据,键名都是 “user”“data”,容易互相覆盖。
正确做法:用前缀统一键名(封装中已自带 frontend_ 前缀),避免冲突。

坑4:忽略浏览器兼容性,导致报错

部分低版本浏览器、隐私模式下,LocalStorage 不可用,裸写会报错。
正确做法:封装中添加容错处理,捕获异常并提示用户。

坑5:清空所有存储,影响其他项目

错误示例:直接用 localStorage.clear(),会清空浏览器中所有项目的 LocalStorage。
正确做法:用封装的 clearStorage,只清空当前项目带前缀的存储。

进阶技巧:LocalStorage进阶用法

1. 监听 LocalStorage 变化(跨页面通信)

// 页面A监听存储变化
window.addEventListener('storage', (e) => {
  // 只监听当前项目的存储变化(带前缀)
  if (e.key?.startsWith(STORAGE_KEY_PREFIX)) {
    console.log('存储变化:', e.key, e.newValue);
    // 比如监听token变化,实现跨页面登录状态同步
    if (e.key === `${STORAGE_KEY_PREFIX}token`) {
      // 处理登录状态更新
    }
  }
});

// 页面B修改存储,页面A会触发监听
setStorage('token', 'newToken');

2. 限制存储大小,避免内存溢出

LocalStorage 默认存储上限约 5MB,存大量数据会导致内存溢出。可在封装中添加存储大小校验:

// 新增:校验存储大小
const checkStorageSize = (value) => {
  const valueStr = typeof value === 'object' ? JSON.stringify(value) : value;
  const size = new Blob([valueStr]).size;
  // 限制单条数据不超过1MB
  if (size > 1 * 1024 * 1024) {
    alert('存储数据过大,建议拆分存储');
    return false;
  }
  return true;
};

// 在setStorage中添加校验
export const setStorage = (key, value, expire = 0) => {
  if (!checkStorageSize(value)) return;
  // 原有逻辑...
};

结尾:干货总结

LocalStorage 是前端必备的本地存储工具。一套通用封装,解决存对象、过期时间、冲突、容错等所有痛点,适配所有前端框架,复制就能用,避开 5 个高频坑,再也不用为存储问题头疼。


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

MCP TypeScript SDK的使用

作者 wusfe
2026年4月8日 18:18

MCP TypeScript SDK 架构

概述

MCP (Model Context Protocol) TypeScript SDK 实现了模型上下文协议,用于在 LLM 和外部数据源/工具之间建立标准化的通信。


架构图

┌─────────────────────────────────────────────────────────────────────────────┐
│                              MCP Architecture                                │
└─────────────────────────────────────────────────────────────────────────────┘

                              ┌─────────────────┐
                              │   LLM (AI)      │
                              └────────┬────────┘
                                       │
                          ┌────────────▼────────────┐
                          │      MCP Client         │
                          │  @modelcontextprotocol/ │
                          │      sdk/client         │
                          └────────────┬────────────┘
                                       │
                    ┌──────────────────┼──────────────────┐
                    │                  │                  │
          ┌─────────▼─────────┐ ┌──────▼──────┐ ┌───────▼──────┐
          │    Tools API      │ │ Resources   │ │   Prompts    │
          │  listTools()      │ │ listResources│ │ listPrompts()│
          │  callTool()       │ │ readResource │ │  getPrompt()  │
          └─────────┬─────────┘ └──────┬──────┘ └───────┬──────┘
                    │                   │                │
          ┌─────────▼───────────────────▼────────────────▼─────────┐
          │                    Transport Layer                     │
          │  ┌──────────────┐  ┌──────────────────┐  ┌──────────┐ │
          │  │    Stdio     │  │ Streamable HTTP  │  │   SSE    │ │
          │  │  (Local)     │  │   (Remote)       │  │(Legacy)  │ │
          │  └──────────────┘  └──────────────────┘  └──────────┘ │
          └──────────────────────────┬──────────────────────────────┘
                                    │
                    ┌───────────────┴───────────────┐
                    │                               │
          ┌─────────▼─────────┐         ┌─────────▼─────────┐
          │   MCP Server      │         │   MCP Client      │
          │  (Provider)       │◄───────►│  (Consumer)       │
          └─────────┬─────────┘   RPC   └───────────────────┘
                    │
          ┌─────────▼─────────────────────────────────────────┐
          │                  Capability Layer                   │
          │  ┌─────────┐  ┌──────────┐  ┌────────┐  ┌───────┐ │
          │  │ Tools   │  │ Resources │  │Prompts │  │Sampling│ │
          │  │         │  │          │  │        │  │Elicit │ │
          │  └─────────┘  └──────────┘  └────────┘  └───────┘ │
          └────────────────────────────────────────────────────┘

组件说明

组件 作用 职责说明
LLM 消费方 大语言模型,通过 MCP Client 发现和使用外部工具、资源。解析用户意图,决定调用哪些工具,处理返回结果
MCP Client 连接管理 SDK 客户端库,负责:① 与服务器建立连接 ② 发现并列出可用工具/资源/提示器 ③ 调用工具并传递参数 ④ 读取资源内容 ⑤ 处理服务器通知
MCP Server 能力提供 SDK 服务器端,负责:① 注册和暴露 Tools(可执行函数)② 注册和暴露 Resources(可读数据)③ 注册和暴露 Prompts(提示模板)④ 处理客户端请求并返回结果
Transport 通信通道 传输层,负责客户端与服务器之间的消息传递。支持多种传输方式:① Stdio - 本地进程间通信 ② HTTP Streamable - 远程 HTTP 通信,支持双向流 ③ SSE - 服务器单向推送事件
Tools 执行能力 服务器暴露的可调用函数,用于执行具体操作(如计算、查询、创建等)。带输入参数,返回执行结果
Resources 数据暴露 服务器暴露的只读数据,支持客户端读取和订阅变更。可以是配置文件、数据库记录、文件内容等
Prompts 模板复用 预定义的提示模板,支持参数化。客户端可以获取填充参数后的完整消息,用于标准化常见任务的提示
Sampling LLM 代理 允许服务器向客户端请求 LLM 推理能力。服务器可以请客户端的 LLM 生成文本、总结内容等
Elicitation 交互代理 允许服务器向客户端请求用户输入。用于需要用户确认、填写表单或做出选择的场景

SDK 模块结构

本项目使用 v1 版本的 @modelcontextprotocol/sdk 统一包。

模块 导入路径 用途
Client @modelcontextprotocol/sdk/client/index.js MCP 客户端实现,连接服务器、调用工具、读取资源
Server @modelcontextprotocol/sdk/server/mcp.js MCP 服务器实现,暴露工具、资源、提示器

协议层设计

Transport 层

Transport 层负责客户端与服务器之间的底层通信。

传输类型 适用场景
Stdio StdioServerTransport / StdioClientTransport 本地进程通信,同一系统内 spawn 子进程
HTTP Streamable StreamableHTTPClientTransport 远程服务器,支持 SSE 事件流(推荐)
SSE SSEClientTransport Server-Sent Events 单向事件流
Stdio 传输

适合本地场景,服务器作为子进程启动:

// 服务器
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
const transport = new StdioServerTransport();
await server.connect(transport);

// 客户端
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
const transport = new StdioClientTransport({ command: 'node', args: ['server.js'] });
HTTP Streamable 传输

适合远程场景,服务器独立运行:

import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'));
await client.connect(transport);

Capability 层

MCP 协议定义了四种主要能力。

Tools(工具)

工具是服务器暴露的可调用函数,LLM 可以通过工具执行实际操作。

服务器注册

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import * as z from 'zod';

server.registerTool(
  'calculate-bmi',
  {
    title: 'BMI Calculator',
    description: 'Calculate Body Mass Index',
    inputSchema: z.object({
      weightKg: z.number().describe('Weight in kilograms'),
      heightM: z.number().describe('Height in meters')
    }),
    outputSchema: z.object({ bmi: z.number() })
  },
  async ({ weightKg, heightM }) => {
    const bmi = weightKg / (heightM * heightM);
    return {
      content: [{ type: 'text', text: `BMI: ${bmi.toFixed(2)}` }],
      structuredContent: { bmi }
    };
  }
);

客户端调用

const result = await client.callTool({
  name: 'calculate-bmi',
  arguments: { weightKg: 70, heightM: 1.75 }
});

Resources(资源)

资源是服务器暴露的二进制或文本数据,客户端可以读取和订阅。

服务器注册

server.registerResource(
  'config://app',
  {
    title: 'App Config',
    description: 'Application configuration',
    mimeType: 'application/json'
  },
  async () => ({
    contents: [{
      uri: 'config://app',
      text: JSON.stringify({ theme: 'dark', language: 'en' })
    }]
  })
);

客户端操作

// 列出资源
const { resources } = await client.listResources();

// 读取资源
const { contents } = await client.readResource({ uri: 'config://app' });

// 订阅变更
await client.subscribeResource({ uri: 'config://app' });
client.setNotificationHandler('notifications/resources/updated', handler);

Prompts(提示器)

提示器是预定义的提示模板,可以携带参数生成消息。

服务器注册

server.registerPrompt(
  'review-code',
  {
    title: 'Code Review',
    description: 'Review code for best practices',
    argsSchema: z.object({ code: z.string() })
  },
  ({ code }) => ({
    messages: [{
      role: 'user',
      content: { type: 'text', text: `Please review:\n\n${code}` }
    }]
  })
);

客户端调用

const { messages } = await client.getPrompt({ name: 'review-code', arguments: { code: '...' } });

Sampling(采样)

Sampling 允许服务器向客户端请求 LLM 推理。

服务器端请求

server.registerTool(
  'summarize',
  { inputSchema: z.object({ text: z.string() }) },
  async ({ text }, ctx) => {
    const response = await ctx.mcpReq.requestSampling({
      messages: [{ role: 'user', content: { type: 'text', text: `Summarize: ${text}` } }],
      maxTokens: 500
    });
    return { content: [{ type: 'text', text: JSON.stringify(response.content) }] };
  }
);

客户端处理

client.setRequestHandler('sampling/createMessage', async request => {
  const lastMessage = request.params.messages.at(-1);
  // 转发给 LLM 并返回
  return { model: 'claude-3-5-sonnet', role: 'assistant', content: { type: 'text', text: '...' } };
});

Elicitation(请求用户输入)

Elicitation 允许服务器向客户端请求用户交互式输入。

服务器端请求

const result = await ctx.mcpReq.elicitInput({
  mode: 'form',
  message: 'Please rate this:',
  requestedSchema: {
    type: 'object',
    properties: { rating: { type: 'integer', minimum: 1, maximum: 5 } },
    required: ['rating']
  }
});

客户端处理

client.setRequestHandler('elicitation/create', async request => ({
  action: 'accept',
  content: { rating: 5 }
}));

Auth 层

SDK 支持 OAuth 2.0 认证。

Provider 用途
ClientCredentialsProvider 客户端凭证流,适用于服务间认证
PrivateKeyJwtProvider 私钥 JWT,适用于更安全的场景
import { ClientCredentialsProvider } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

const authProvider = new ClientCredentialsProvider({
  clientId: 'my-service',
  clientSecret: 'my-secret'
});

const transport = new StreamableHTTPClientTransport(
  new URL('http://localhost:3000/mcp'),
  { authProvider }
);

错误处理

SDK 提供 ProtocolErrorSdkError

import { ProtocolError } from '@modelcontextprotocol/sdk/client/index.js';

if (error instanceof ProtocolError) {
  console.log(error.code); // 错误码
  console.log(error.message); // 错误消息
}

从ethers.js迁移到Viem:我在重构DeFi前端时踩过的那些坑

作者 竹林818
2026年4月8日 18:02

背景

我负责维护一个已经运行两年的DeFi项目前端,技术栈是React + TypeScript + ethers.js 5.7。最近在做性能优化时发现,打包后的bundle size比同类项目大了近30%,经过分析,ethers.js占了相当大的比重。同时,项目中的一些复杂类型定义在ethers.js下显得很冗长,类型提示也不够友好。

团队讨论后决定尝试迁移到Viem。Viem是较新的以太坊JavaScript库,以类型安全、模块化、轻量化为特点。但迁移一个生产环境项目不是简单的替换import语句,我需要在保证现有功能完全正常的前提下完成迁移。

问题分析

最初我以为迁移就是换个库,把ethers.providers.Web3Provider换成viem/createWalletClient就行了。但实际一开始就遇到了问题:

  1. 类型系统完全不同:ethers.js使用自己的BigNumber类型,而Viem直接使用原生bigint
  2. 事件监听机制差异:ethers.js的合约事件监听和Viem的watchContractEvent参数结构完全不同
  3. 多链支持方式不同:我们项目支持Ethereum、Polygon、Arbitrum三条链,ethers.js通过Network对象管理,Viem有自己的一套链定义

最头疼的是,项目中有上百处以太坊交互代码,分布在组件、hooks、工具函数中,不可能一次性全部重写。我需要一个渐进式的迁移方案。

核心实现

第一步:搭建双库共存环境

我决定先让两个库共存,逐步迁移模块。首先安装必要的Viem包:

npm install viem wagmi

然后创建了一个lib/viem-client.ts文件,初始化基础客户端:

import { createPublicClient, http } from 'viem'
import { mainnet, polygon, arbitrum } from 'viem/chains'

// 根据链ID获取对应的Viem链配置
export function getChainConfig(chainId: number) {
  switch (chainId) {
    case 1: return mainnet
    case 137: return polygon
    case 42161: return arbitrum
    default: return mainnet
  }
}

// 创建公共客户端(用于读取数据)
export function createViemPublicClient(chainId: number) {
  const chain = getChainConfig(chainId)
  const transport = http(process.env.NEXT_PUBLIC_RPC_URL)
  
  return createPublicClient({
    chain,
    transport,
  })
}

// 这里有个坑:Viem的链配置需要和你的项目实际使用的RPC节点匹配
// 如果RPC节点不支持某些方法,需要在transport中配置

同时,我保留了现有的ethers.js代码,只是在新写的功能中使用Viem。

第二步:处理BigNumber类型转换

这是迁移中最频繁遇到的问题。我们的项目中有大量的金额计算、余额显示逻辑,原来都使用ethers.js的BigNumber。

我创建了一个转换工具函数:

import { BigNumber } from 'ethers'
import { formatUnits, parseUnits } from 'viem'

/**
 * 将ethers.js的BigNumber转换为Viem兼容的bigint
 * 注意:这里要处理undefined和null的情况
 */
export function bigNumberToBigInt(value?: BigNumber): bigint {
  if (!value) return 0n
  return BigInt(value.toString())
}

/**
 * 将Viem的bigint转换回ethers.js的BigNumber(用于过渡期)
 */
export function bigIntToBigNumber(value: bigint): BigNumber {
  return BigNumber.from(value.toString())
}

/**
 * 统一格式化显示金额
 * 原来用ethers.utils.formatUnits,现在用viem的formatUnits
 * 注意:viem的formatUnits返回string,而ethers返回string
 */
export function formatTokenAmount(
  amount: bigint | BigNumber,
  decimals: number
): string {
  const amountBigInt = amount instanceof BigNumber 
    ? bigNumberToBigInt(amount)
    : amount
  
  return formatUnits(amountBigInt, decimals)
}

第三步:重写合约交互层

我们项目中有几十个合约交互的hooks,这是迁移的重点。我选择从最常用的ERC20代币合约开始。

原来的ethers.js版本:

// 旧的ERC20 Hook (ethers.js)
import { Contract } from 'ethers'
import ERC20_ABI from '../abis/ERC20.json'

export function useERC20(contractAddress: string, signer: any) {
  const contract = new Contract(contractAddress, ERC20_ABI, signer)
  
  const getBalance = async (account: string) => {
    return await contract.balanceOf(account)
  }
  
  const transfer = async (to: string, amount: BigNumber) => {
    const tx = await contract.transfer(to, amount)
    return await tx.wait()
  }
  
  return { getBalance, transfer }
}

迁移到Viem的版本:

// 新的ERC20 Hook (Viem)
import { createPublicClient, createWalletClient, custom, http } from 'viem'
import { mainnet } from 'viem/chains'
import { useAccount, useWalletClient } from 'wagmi'

// 注意:Viem需要更精确的ABI类型,不能直接用JSON ABI
import { erc20Abi } from 'viem'
import { usePublicClient } from 'wagmi'

export function useViemERC20(contractAddress: `0x${string}`) {
  const { address } = useAccount()
  const publicClient = usePublicClient()
  const { data: walletClient } = useWalletClient()
  
  const getBalance = async (account?: `0x${string}`) => {
    if (!publicClient) throw new Error('No public client')
    
    const balance = await publicClient.readContract({
      address: contractAddress,
      abi: erc20Abi,
      functionName: 'balanceOf',
      args: [account || address!],
    })
    
    return balance as bigint
  }
  
  const transfer = async (to: `0x${string}`, amount: bigint) => {
    if (!walletClient || !address) throw new Error('Wallet not connected')
    
    const hash = await walletClient.writeContract({
      address: contractAddress,
      abi: erc20Abi,
      functionName: 'transfer',
      args: [to, amount],
      account: address,
    })
    
    // 等待交易确认
    const receipt = await publicClient.waitForTransactionReceipt({ hash })
    return receipt
  }
  
  return { getBalance, transfer }
}

这里有个重要的坑:Viem要求地址必须是0x${string}类型,而不是普通的string。这意味着所有合约地址、用户地址都需要进行类型转换。我创建了一个类型守卫函数:

export function isValidAddress(address: string): address is `0x${string}` {
  return /^0x[a-fA-F0-9]{40}$/.test(address)
}

export function toViemAddress(address: string): `0x${string}` {
  if (!isValidAddress(address)) {
    throw new Error(`Invalid address format: ${address}`)
  }
  return address as `0x${string}`
}

第四步:处理事件监听

我们项目中有很多实时数据更新依赖于合约事件。ethers.js的事件监听和Viem完全不同。

原来的事件监听:

// ethers.js 事件监听
contract.on('Transfer', (from, to, amount, event) => {
  console.log('Transfer event:', { from, to, amount })
  updateUI()
})

迁移到Viem的事件监听:

// Viem 事件监听
import { watchContractEvent } from 'viem'

const unwatch = watchContractEvent({
  address: contractAddress,
  abi: erc20Abi,
  eventName: 'Transfer',
  onLogs: (logs) => {
    logs.forEach((log) => {
      const { args } = log
      console.log('Transfer event:', {
        from: args.from,
        to: args.to,
        amount: args.value
      })
      updateUI()
    })
  },
})

// 注意:Viem的watchContractEvent返回一个取消监听的函数
// 在React组件中需要在useEffect中清理
useEffect(() => {
  const unwatch = watchContractEvent({ ... })
  
  return () => {
    unwatch()
  }
}, [])

这里踩了个坑:Viem的事件参数args可能是undefined,需要做安全处理:

onLogs: (logs) => {
  logs.forEach((log) => {
    if (!log.args) return
    
    const { from, to, value } = log.args
    // 现在from, to, value都是可选的,需要类型断言
    if (from && to && value) {
      // 处理事件
    }
  })
}

第五步:集成Wagmi管理状态

为了更好的React集成,我引入了Wagmi。Wagmi是基于Viem的React Hooks库,类似于ethers.js的useDapp或web3-react。

配置Wagmi:

// lib/wagmi-config.ts
import { createConfig, configureChains } from 'wagmi'
import { publicProvider } from 'wagmi/providers/public'
import { mainnet, polygon, arbitrum } from 'wagmi/chains'
import { InjectedConnector } from 'wagmi/connectors/injected'
import { WalletConnectConnector } from 'wagmi/connectors/walletConnect'

const { chains, publicClient, webSocketPublicClient } = configureChains(
  [mainnet, polygon, arbitrum],
  [publicProvider()]
)

export const config = createConfig({
  autoConnect: true,
  connectors: [
    new InjectedConnector({ chains }),
    new WalletConnectConnector({
      chains,
      options: {
        projectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID!,
      },
    }),
  ],
  publicClient,
  webSocketPublicClient,
})

然后在App中包裹WagmiProvider:

import { WagmiConfig } from 'wagmi'
import { config } from '../lib/wagmi-config'

function App() {
  return (
    <WagmiConfig config={config}>
      <YourApp />
    </WagmiConfig>
  )
}

完整代码示例

下面是一个完整的、可运行的ERC20余额查询和转账组件,展示了Viem + Wagmi的实际使用:

import React, { useState, useEffect } from 'react'
import { useAccount, usePublicClient, useWalletClient } from 'wagmi'
import { erc20Abi } from 'viem'
import { formatUnits, parseUnits } from 'viem'
import { isValidAddress, toViemAddress } from '../lib/address-utils'

// 假设的USDC合约地址
const USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'

function ERC20Transfer() {
  const { address, isConnected } = useAccount()
  const publicClient = usePublicClient()
  const { data: walletClient } = useWalletClient()
  
  const [balance, setBalance] = useState<bigint>(0n)
  const [recipient, setRecipient] = useState('')
  const [amount, setAmount] = useState('')
  const [loading, setLoading] = useState(false)
  
  // 获取余额
  const fetchBalance = async () => {
    if (!publicClient || !address) return
    
    try {
      const balance = await publicClient.readContract({
        address: toViemAddress(USDC_ADDRESS),
        abi: erc20Abi,
        functionName: 'balanceOf',
        args: [address],
      })
      
      setBalance(balance as bigint)
    } catch (error) {
      console.error('Failed to fetch balance:', error)
    }
  }
  
  // 转账
  const handleTransfer = async () => {
    if (!walletClient || !address || !recipient || !amount) return
    if (!isValidAddress(recipient)) {
      alert('Invalid recipient address')
      return
    }
    
    setLoading(true)
    try {
      // USDC有6位小数
      const amountInWei = parseUnits(amount, 6)
      
      const hash = await walletClient.writeContract({
        address: toViemAddress(USDC_ADDRESS),
        abi: erc20Abi,
        functionName: 'transfer',
        args: [toViemAddress(recipient), amountInWei],
        account: address,
      })
      
      console.log('Transaction hash:', hash)
      
      // 等待交易确认
      const receipt = await publicClient.waitForTransactionReceipt({ hash })
      console.log('Transaction confirmed:', receipt)
      
      // 更新余额
      await fetchBalance()
      setAmount('')
      setRecipient('')
      
      alert('Transfer successful!')
    } catch (error: any) {
      console.error('Transfer failed:', error)
      alert(`Transfer failed: ${error.shortMessage || error.message}`)
    } finally {
      setLoading(false)
    }
  }
  
  // 监听地址变化,重新获取余额
  useEffect(() => {
    if (address) {
      fetchBalance()
    }
  }, [address, publicClient])
  
  if (!isConnected) {
    return <div>Please connect your wallet</div>
  }
  
  return (
    <div>
      <h2>USDC Balance: {formatUnits(balance, 6)}</h2>
      
      <div>
        <input
          type="text"
          placeholder="Recipient address"
          value={recipient}
          onChange={(e) => setRecipient(e.target.value)}
        />
        <input
          type="text"
          placeholder="Amount"
          value={amount}
          onChange={(e) => setAmount(e.target.value)}
        />
        <button 
          onClick={handleTransfer} 
          disabled={loading || !recipient || !amount}
        >
          {loading ? 'Processing...' : 'Transfer'}
        </button>
      </div>
      
      <button onClick={fetchBalance} style={{ marginTop: '20px' }}>
        Refresh Balance
      </button>
    </div>
  )
}

export default ERC20Transfer

踩坑记录

在实际迁移过程中,我遇到了不少预料之外的问题:

  1. 类型错误:Argument of type 'string' is not assignable to parameter of type 'Hex'

    • 问题:Viem严格要求地址类型为0x${string}(Hex类型)
    • 解决:创建了toViemAddress类型转换函数和isValidAddress类型守卫
  2. 事件监听内存泄漏

    • 问题:Viem的watchContractEvent不会自动清理,在React组件卸载后仍在监听
    • 解决:必须在useEffect的清理函数中调用返回的unwatch函数
  3. BigInt序列化问题

    • 问题:将包含bigint的对象直接存入Redux或传递给API会报错
    • 解决:在存储前转换为string,使用时再转回bigint,或者使用支持bigint的序列化库
  4. RPC方法不支持

    • 问题:某些自定义RPC节点不支持Viem默认调用的方法
    • 解决:在创建transport时指定支持的RPC方法,或使用Alchemy、Infura等标准节点
  5. ABI类型不匹配

    • 问题:直接从原有项目复制的JSON ABI在Viem中类型推断失败
    • 解决:使用Viem提供的标准ABI(如erc20Abi),或使用as const断言自定义ABI

小结

从ethers.js迁移到Viem确实需要投入不少精力,但带来的类型安全、包体积减小和更现代的API设计是值得的。最关键的是采用渐进式迁移,先让两个库共存,逐步替换模块。对于新开始的Web3项目,我会直接选择Viem + Wagmi的组合。

从零构建现代化登录界面:React + Tailwind CSS 前端工程实践

作者 Lee川
2026年4月8日 17:53

从零构建现代化登录界面:React + Tailwind CSS 前端工程实践

引言:前端开发的黄金时代

在当今 Web 开发领域,用户体验已成为衡量一个应用成功与否的关键指标。登录界面作为用户与应用程序建立连接的第一道门户,其设计质量和交互体验直接影响着用户对产品的第一印象。本文将深入剖析一个基于 React 和 Tailwind CSS 构建的现代化登录页面项目,带你领略前端工程化的魅力。

这个项目虽然代码量不大,但却凝聚了现代前端开发的核心精髓:组件化思维、工具类优先的样式方案、受控组件的状态管理,以及以用户为中心的交互设计。让我们一同揭开这个精致登录页面背后的技术奥秘。


第一章:技术栈选型——站在巨人的肩膀上

1.1 Vite:下一代前端构建工具

项目采用 Vite 作为构建工具,这是一个明智的选择。相比传统的 Webpack,Vite 基于原生 ES 模块(ESM)的开发服务器实现了秒级热更新(HMR)。在开发过程中,当你修改代码保存的瞬间,浏览器几乎立即反映出变化,这种流畅的开发体验极大地提升了开发效率。

Vite 的核心优势在于:

  • 开箱即用:无需繁琐的配置,默认支持 TypeScript、JSX、CSS 预处理器等
  • 按需编译:只编译当前访问路径所需的代码,启动速度飞快
  • 生产优化:基于 Rollup 的打包机制,生成高度优化的静态资源

1.2 Tailwind CSS:实用优先的 CSS 框架

Tailwind CSS 代表了一种全新的样式编写范式。它不提供任何预设的组件,而是提供一套原子化的工具类,让开发者通过组合这些基础类来构建任意设计。

在登录页面中,我们看到了大量 Tailwind 类的应用:

className='min-h-screen bg-slate-50 flex items-center justify-center p-4'

这行代码实现了:

  • min-h-screen:最小高度为视口高度(100vh),确保背景填满整个屏幕
  • bg-slate-50:使用 Slate 色系的第 50 级作为背景色,一种非常浅的灰蓝色
  • flex items-center justify-center:Flexbox 布局,内容垂直水平居中
  • p-4:内边距为 1rem(16px)

这种"所见即所得"的类名设计,让样式变得可预测、可维护、可复用

1.3 Lucide React:优雅的图标解决方案

项目选用了 lucide-react 图标库,这是一个基于 SVG 的开源图标集合。相比传统的图标字体(如 Font Awesome),Lucide 具有以下优势:

  • Tree-shaking 友好:按需引入,只打包实际使用的图标
  • 可定制性强:通过 sizecolorstrokeWidth 等 props 灵活控制
  • 无障碍支持:内置适当的 ARIA 属性
import {Lock, Mail, EyeOff, Eye} from 'lucide-react';

这四个图标分别承担了不同的语义:

  • Lock:品牌标识和密码输入框的视觉提示
  • Mail:邮箱输入框的视觉提示
  • EyeOff/Eye:密码显示/隐藏切换按钮

第二章:组件架构——受控组件的精妙设计

2.1 状态管理的艺术

React 的核心哲学是UI 是状态的函数。在这个登录表单中,我们看到了典型的状态驱动设计:

const [formData, setFormData] = useState({
  email: '',
  password: '',
  rememberMe: false
});

这里定义了一个包含三个字段的表单状态对象。使用单个状态对象而非多个独立的 useState,有以下好处:

  1. 逻辑聚合:相关的表单数据组织在一起,结构清晰
  2. 易于扩展:添加新字段只需在对象中增加属性
  3. 便于提交formData 可直接作为 API 请求的 payload

2.2 抽象的事件处理函数

代码中最具技术含量的部分之一是 handleChange 函数:

const handleChange = (e) => {
  const { name, value, type, checked } = e.target;
  setFormData((prev) => ({
    ...prev,
    [name]: type === 'checkbox' ? checked : value
  }))
}

这个函数展现了高超的抽象能力:

参数解构的智慧 从事件对象中提取 namevaluetypechecked,这四个属性足以处理所有表单元素的变化。

动态属性名的技巧 [name] 使用 ES6 的计算属性名语法,根据输入框的 name 属性动态更新对应的状态字段。这意味着:

  • email 输入框变化时,formData.email 被更新
  • password 输入框变化时,formData.password 被更新
  • rememberMe 复选框变化时,formData.rememberMe 被更新

类型判断的处理 type === 'checkbox' ? checked : value 这一三元表达式巧妙地处理了不同输入类型的差异:

  • 文本输入框(type="text|email|password")使用 value
  • 复选框(type="checkbox")使用 checked

函数式更新的必要性 使用 setFormData((prev) => ...) 而非 setFormData({...}) 是最佳实践。因为在异步操作或批量更新场景中,直接访问 formData 可能拿到过时的值,而函数式更新保证基于最新状态进行计算。

2.3 受控组件的完整闭环

每个输入框都遵循受控组件模式:

<input 
  type="email" 
  name="email" 
  required
  value={formData.email}
  onChange={handleChange}
  placeholder='name@company.com'
  className="..."
/>

受控组件的核心特征

  • value 绑定到 React 状态
  • onChange 触发状态更新
  • UI 完全由状态驱动,形成"状态 → UI → 事件 → 新状态"的闭环

这种模式虽然比非受控组件(使用 ref)多写几行代码,但带来了巨大的优势:

  • 即时验证:可在用户输入时实时校验
  • 动态禁用:可根据条件禁用提交按钮
  • 自动格式化:可自动格式化输入内容(如电话号码、信用卡号)
  • 测试友好:状态可预测,易于单元测试

第三章:交互设计——细节决定用户体验

3.1 密码显示/隐藏功能

密码输入框的显示/隐藏切换是一个看似简单却极具实用价值的设计。让我们分析其实现:

const [showPassword, setShowPassword] = useState(false);

// JSX 部分
<input
  type={showPassword ? 'text' : 'password'}
  // ...其他属性
/>
<button 
  type="button"
  onClick={() => setShowPassword(!showPassword)}
  className="absolute inset-y-0 right-0 pr-4 flex items-center text-slate-400 hover:text-slate-600 transition-colors"
>
  {showPassword ? <EyeOff size={18}/> : <Eye size={18} />}
</button>

技术要点解析

  1. 状态驱动类型切换

    • showPasswordfalse 时,type="password",字符显示为圆点
    • showPasswordtrue 时,type="text",字符明文显示
  2. 按钮的语义化设计

    • type="button" 防止按钮默认提交表单
    • onClick 切换状态,实现显示/隐藏的 toggle 效果
  3. 图标的动态渲染

    • 显示密码时用 EyeOff(眼睛带斜杠),暗示"关闭可见性"
    • 隐藏密码时用 Eye(眼睛),暗示"开启可见性"
    • 这种反向提示符合用户的心理模型
  4. 绝对定位的布局

    • absolute inset-y-0 right-0 将按钮固定在输入框右侧
    • inset-y-0 等同于 top: 0; bottom: 0,让按钮垂直拉伸
    • pr-4 提供右侧内边距,避免图标贴边
  5. 视觉反馈

    • text-slate-400 默认灰色
    • hover:text-slate-600 悬停时变深
    • transition-colors 颜色平滑过渡

这个功能的价值在于:

  • 减少输入错误:用户可确认密码是否正确
  • 提升可访问性:对视力障碍用户更友好
  • 增强掌控感:用户可自主选择是否显示密码

3.2 视觉焦点的引导

仔细观察输入框的样式,会发现精妙的焦点状态设计:

className="... focus:outline-none focus:ring-2 focus:ring-indigo-600/20 focus:border-indigo-600"

焦点状态的三层效果

  1. focus:outline-none:移除浏览器默认的蓝色轮廓
  2. focus:ring-2:添加 2px 宽的外环
  3. focus:ring-indigo-600/20:外环颜色为靛蓝色,透明度 20%
  4. focus:border-indigo-600:边框变为靛蓝色

同时,左侧图标也会响应焦点:

className="... text-slate-400 group-focus-within:text-indigo-600 transition-colors"

group-focus-within 是一个强大的选择器,当容器内任意元素获得焦点时,样式生效。这让图标从灰色变为靛蓝色,与输入框的焦点状态形成视觉呼应。

这种多层次的焦点反馈让用户清晰地知道当前操作的元素,提升了界面的可访问性和专业感。

3.3 响应式设计的考量

虽然这是一个简单的登录页面,但响应式设计的细节无处不在:

className='... p-8 md:p-10'
  • 移动端(默认):内边距为 p-8(2rem, 32px)
  • 中等屏幕及以上(md: 断点,768px):内边距为 p-10(2.5rem, 40px)

这体现了 Mobile First(移动优先) 的设计原则:

  • 默认样式针对小屏幕
  • 通过媒体查询逐步增强到大屏幕

另一个例子是容器宽度:

className='w-full max-w-md'
  • w-full:宽度 100%,在小屏幕上充分利用空间
  • max-w-md:最大宽度 28rem(448px),在大屏幕上不会过度拉伸

这种设计确保了从 iPhone SE(375px 宽)到 27 寸显示器(2560px 宽)的各种设备上,登录框都能呈现最佳的视觉效果。


第四章:Tailwind CSS 的深度实践

4.1 色彩系统的科学

项目使用了 Tailwind 的 Slate 色系,这是一种偏冷色调的灰色,比纯灰色更具现代感。Slate 色系从 50 到 950 共定义了 15 个色阶:

bg-slate-50        // 最浅,用于背景
text-slate-900     // 最深,用于主标题
text-slate-700     // 中等深度,用于标签
text-slate-500     // 较浅,用于辅助文字
border-slate-200   // 浅色边框
shadow-slate-200   // 浅色阴影

这种系统化的色彩使用带来了:

  • 视觉层次:通过深浅区分信息优先级
  • 和谐统一:同一色系保证色彩协调
  • 易于调整:更换色系只需全局替换类名前缀

4.2 阴影的艺术

登录卡片的阴影设计值得细细品味:

className='... shadow-xl shadow-slate-200/60'

这里使用了双层阴影

  1. shadow-xl:Tailwind 预设的大阴影(0 20px 25px -5px rgb(0 0 0 / 0.1)
  2. shadow-slate-200/60:带颜色的阴影,透明度 60%

这种组合产生了柔和的立体感,让卡片仿佛轻轻浮在背景之上。相比纯黑色阴影,带颜色的阴影更加精致,与整体设计语言更协调。

4.3 间距的一致性

Tailwind 的间距系统基于 4px 网格:

space-y-6    // 子元素垂直间距 1.5rem (24px)
mb-10        // 下外边距 2.5rem (40px)
mt-2         // 上外边距 0.5rem (8px)

space-y-6 是一个特别优雅的工具类,它自动为容器的所有子元素(除第一个外)添加 margin-top: 1.5rem。这比手动给每个元素添加 mt-6 更简洁,也避免了第一个元素不需要上边距的特殊处理。

4.4 圆角的温度

项目中大量使用了圆角设计:

rounded-3xl      // 大圆角,1.5rem (24px)
rounded-xl       // 中等圆角,0.75rem (12px)

圆角的选择传递了不同的情感:

  • 大圆角rounded-3xl):友好、现代、亲和
  • 中等圆角rounded-xl):精致、专业、不失活泼

相比直角或小的圆角,大圆角更符合当代设计趋势,给人一种轻松愉悦的视觉感受。


第五章:工程化思维——可扩展性与维护性

5.1 代码组织的最佳实践

虽然当前代码量不大,但已经展现了良好的工程化思维:

清晰的注释系统

// esm React 代表默认引入
// useState hooks 引入 部分引入
// 数据业务
// 抽象的事件处理函数
// 密码显示隐藏
// 登录 api 等待状态

这些注释不是简单的重复代码,而是解释了设计意图业务逻辑,帮助后续维护者快速理解代码结构。

逻辑分组 代码按照功能模块自然分组:

  1. 导入语句
  2. 状态定义
  3. 事件处理函数
  4. JSX 渲染

这种组织方式让代码具有良好的可读性。

5.2 待扩展的功能点

README 中提到了 isLoading 状态,虽然当前代码中还未完全实现,但已经预留了状态:

const [isLoading, setIsLoading] = useState(false);

这个状态可用于:

  • 提交时显示加载动画
  • 禁用提交按钮防止重复提交
  • 显示"登录中..."的文字提示

完整的实现可能如下:

const handleSubmit = async (e) => { 
  e.preventDefault();
  setIsLoading(true);
  try {
    await loginApi(formData);
    // 登录成功处理
  } catch (error) {
    // 错误处理
  } finally {
    setIsLoading(false);
  }
}

// JSX
<button 
  type="submit"
  disabled={isLoading}
  className="... disabled:opacity-50"
>
  {isLoading ? '登录中...' : '登录'}
</button>

5.3 表单验证的扩展空间

当前使用了 HTML5 的原生验证(requiredtype="email"),但可以扩展到更复杂的验证场景:

const [errors, setErrors] = useState({});

const validate = () => {
  const newErrors = {};
  if (!formData.email) {
    newErrors.email = '邮箱不能为空';
  } else if (!/\S+@\S+\.\S+/.test(formData.email)) {
    newErrors.email = '邮箱格式不正确';
  }
  if (formData.password.length < 8) {
    newErrors.password = '密码至少需要 8 个字符';
  }
  setErrors(newErrors);
  return Object.keys(newErrors).length === 0;
}

配合 Tailwind 的样式,可以显示错误提示:

{errors.email && (
  <p className="text-red-500 text-sm mt-1">{errors.email}</p>
)}
<input 
  className={`... ${errors.email ? 'border-red-500' : 'border-slate-200'}`}
/>

第六章:用户体验的深层思考

6.1 认知负荷的降低

好的设计应该是"透明"的,用户无需思考就能自然操作。这个登录页面在降低认知负荷方面做了很多努力:

图标的语义化

  • 邮箱输入框旁的信封图标,直观地告诉用户"这里输入邮箱"
  • 密码输入框旁的锁图标,暗示"这是安全输入区域"
  • 眼睛图标,无需文字就能理解其功能

占位符的引导

placeholder='name@company.com'  // 邮箱格式示例
placeholder='********'          // 密码格式暗示

占位符不是简单的装饰,而是格式模板,帮助用户理解应该输入什么内容。

6.2 微交互的力量

微交互是指那些细微但能提升用户体验的动画和反馈:

过渡动画

transition-colors    // 颜色变化平滑过渡
transition-all       // 所有属性变化平滑过渡

这些过渡让状态变化不再突兀,而是如流水般自然。例如,当用户聚焦输入框时,图标颜色从灰色变为靛蓝色,这个变化如果有 200-300ms 的过渡,会比瞬间变化更加优雅。

悬停反馈

hover:text-indigo-500  // 链接悬停时颜色变浅
hover:text-slate-600   // 按钮悬停时颜色变深

悬停状态给用户提供了可点击的暗示,这是桌面端网页的重要交互线索。

6.3 无障碍性的考量

虽然代码中没有显式的 ARIA 属性,但已经体现了一些无障碍设计的思想:

语义化的 HTML

  • 使用 <label> 关联输入框,屏幕阅读器可以正确朗读
  • 使用 type="email",移动设备会自动显示带@的键盘
  • 使用 required,浏览器会提供原生的验证提示

可改进的无障碍特性 可以进一步增强:

<label htmlFor="email">Email:</label>
<input id="email" type="email" aria-describedby="email-help" />
<span id="email-help" className="sr-only">请输入您的注册邮箱</span>

sr-only 是一个常用的辅助类,让内容只对屏幕阅读器可见:

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  border: 0;
}

第七章:从登录页面看前端开发的未来

7.1 工具类优先的范式转移

Tailwind CSS 代表的是一种思维方式的转变。传统的 CSS 编写方式是:

/* 思考样式命名 */
.login-container {
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 100vh;
}

而 Tailwind 的方式是:

className="min-h-screen flex items-center justify-center"

这种转变的核心价值在于:

  • 减少命名负担:不再需要为每个样式想一个语义化的类名
  • 提高开发速度:直接组合工具类,无需在 CSS 文件和组件之间切换
  • 减小 CSS 体积:未使用的工具类会被自动移除(PurgeCSS)
  • 提升一致性:使用预定义的间距、颜色、字体等系统

当然,这种范式也有争议。批评者认为它让 JSX 变得冗长,但支持者认为这换来了开发效率和可维护性的提升。

7.2 组件库 vs 工具类

一个有趣的问题是:为什么不直接使用现成的 UI 组件库(如 Material-UI、Ant Design)?

这个问题的答案取决于项目需求:

使用组件库的场景

  • 需要快速搭建原型
  • 团队没有专业设计师
  • 需要丰富的组件类型(表格、弹窗、日期选择器等)

使用 Tailwind 的场景

  • 需要高度定制的设计
  • 追求更小的打包体积
  • 设计师提供了精确的设计稿

这个项目选择了 Tailwind,说明它追求的是精致的定制化设计,而非快速搭建。从结果来看,这个登录页面的视觉效果确实超越了大多数组件库的默认样式。

7.3 前端工程师的核心竞争力

通过这个小小的登录页面,我们可以看到现代前端工程师需要具备的能力:

  1. 框架理解:深入理解 React 的状态管理、生命周期、组件通信
  2. CSS 功底:理解 Flexbox、定位、过渡等核心概念
  3. 工具掌握:熟练使用 Tailwind、Vite 等现代工具
  4. 用户体验:站在用户角度思考交互细节
  5. 工程思维:考虑代码的可维护性、可扩展性
  6. 审美能力:对色彩、间距、比例有敏锐的感知

这些能力不是孤立的,而是相互交织,共同构成了一个优秀前端工程师的核心竞争力。


结语:小项目,大智慧

这个看似简单的登录页面,实际上凝聚了现代前端开发的诸多精华。从技术选型到代码组织,从交互设计到用户体验,每一个细节都值得细细品味。

技术的本质是解决问题,而不是堆砌复杂度。这个项目告诉我们,即使是再小的功能,也值得用心打磨。当你在一个输入框的焦点状态上花费心思时,当你在一个图标的颜色选择上反复斟酌时,你不仅仅是在写代码,更是在塑造用户与数字世界交互的方式

希望这篇文章能让你对前端开发有更深的理解,也能在你的下一个项目中,带来一些启发和灵感。记住,伟大的产品不是偶然产生的,而是由无数个精心设计的细节累积而成的。


(全文约 4200 字)

我把 Gemma4:26b 装进 M1 Pro 后,才看清 AI 编程最贵的不是模型费,而是工作流

2026年4月8日 17:52

下午两点多,我盯着终端发呆。

pulling ... 100%,然后断线。 重试。又断。 再重试。还是断。

到第三次的时候,我已经不是在下载模型了,我是在跟自己的耐心较劲。

最后看到 writing manifestsuccess 那一刻,我脑子里只剩一句话:

现在做 AI 编程,最贵的不是模型费,是你被流程反复打断、反复重来的时间。

image.png


269e2c5b-0586-473b-84c4-b8d3b72abce6.png

01|装完模型我才意识到:性能不是第一道坎,协作才是

我这台机器是 M1 Pro 32G。 gemma4:26b 跑纯文字问答,体感其实挺快,日常对话、方案讨论都很顺。

但一旦任务变成“长链路”,比如:

  • 跨多个文件修改
  • 连续工具调用
  • 长上下文推理

就会明显感受到:真正拉开差距的,不是单次回答速度,而是整套流程能不能稳定跑完(ps: 单纯的说本地模型哈,付费API的能力还是非常🐮🍺的)。

以前我总想找一个“全能模型”,把所有任务都塞进去。 现在看,这个思路本身就容易卡死。

不是模型不够强,是分工不清。


02|我把模式改成“主脑 + 助手”后,效率开始稳定

我现在用的是一个很朴素的工作流:

混合模式: 付费API + 本地模型 (可以抱着玩的心态去搞

家大业大助理太多.png

  • GPT 做主脑:拆任务、定策略、做最终审校
  • Gemma4:26b 做助手:跑初稿、做重复劳动、吃本地隐私任务
  • 人做拍板:关键风险操作必须人工确认

这套分工解决了三个高频痛点:

  1. 大模型能力强,但不该拿来干所有重复活
  2. 本地模型成本低,但不适合所有高复杂链路
  3. 全自动看起来很爽,但最怕一次跑偏后难回滚

一句话总结:

把重复交给助手,把判断留给主脑。


03|我现在更相信“半自动可回滚”,而不是“一键全自动”

很多人追求的是:一句话需求 -> 自动改完 -> 自动提交。

我实测下来,真正能长期落地的,反而是这条:

先计划,再改动,再确认。

我的执行顺序是:

  • 先出 plan(不直接改)
  • 再出 diff(只看变更)
  • 最后执行(高风险命令二次确认)

这套流程的好处非常现实:

就算模型偶尔跑偏,也只是“返工一次”,不会“炸穿一次”。

团队里真正稳定高产的人,往往不是最会写 prompt 的人, 而是最会设计“出错后怎么回来”的人。


04|给一人团队的最低配模板(今天就能上手)

如果你也是一人开发,不要一上来就搭巨复杂系统。 先把这 4 条跑起来:

  • 任务分级:小改动 / 中改动 / 高风险改动
  • 模型路由:本地默认,复杂任务升级
  • 执行闸门:删除、批量改、线上命令必须确认
  • 交付标准:每次都要有 plan、diff、回滚点

先把“稳定完成”做出来,再谈“极限效率”。


装完 gemma4:26b 这一天,我最大的变化不是“多会用一个模型”, 而是感觉 ------ 在充点(“钞能力”)你会更强,我的M1 Pro是“老家伙”了,只能跑26b,跑32的话估计就宕机了。

2026 年最值钱的能力,也许不是会写多少代码, 而是你能不能把一套 AI 工作流跑到稳定复用。

你现在是“一个人在写代码”, 还是“一个人在带一支 AI 小团队”?

⚡精通 Claude 第 1 课:掌握 Slash Commands

2026年4月8日 17:45

Slash Commands 是 Claude Code 中的快捷方式,通过 / 触发。55+ 内置命令、Skills、自定义命令、MCP 提示词都通过这种机制工作。本文覆盖常用命令、自定义 Skills 创建、以及实战技巧。


slash-command.png

什么是 Slash Commands

Slash Commands 是 Claude Code 中的核心交互机制。在对话中输入 / 开头的指令,Claude 会直接执行对应操作,而不是继续对话。

/help        → 显示帮助
/clear       → 清空对话
/plan        → 进入计划模式
/compact     → 压缩上下文

image.png 这不是对话的延续,而是命令执行。这是 Claude Code 与普通 AI 对话的本质区别。


内置命令速查

Claude Code 提供了 55+ 内置命令,覆盖日常开发全流程:

高频必备

命令 用途
/help 显示所有可用命令
/clear 清空当前对话(别名:/reset, /new
/plan 进入计划模式,让 Claude 先分析再执行
/compact 压缩上下文,保留关键信息
/diff 查看未提交的文件变更
/model 切换 AI 模型

image.png

Git 工作流

命令 用途
/pr-comments <PR号> 获取 GitHub PR 评论
/branch [name] 创建分支(别名:/fork
/resume [session] 恢复历史对话

image.png

系统状态

命令 用途
/status 版本、模型、账户信息
/cost Token 消耗统计
/stats 每日使用可视化
/context 可视化上下文占用

image.png

image.png

image.png 最近一个月用了27天,真的用了CC就回不去古法编程了😂

image.png

Claude Code 配置

命令 用途
/config 打开设置界面
/hooks 查看钩子配置
/mcp 管理 MCP 服务器
/plugin 管理插件
/theme 切换颜色主题
/permissions 调整工具权限

image.png

核心配置项(已显示)

  • Auto-compact:自动压缩冗余对话历史,防止上下文溢出
  • Show tips:显示使用提示与快捷键,辅助上手
  • Reduce motion:关闭界面动画,提升响应速度
  • Thinking mode:开启深度推理,保障复杂任务准确性
  • Fast mode:降低推理深度,快速响应(仅 Opus 4.6)
  • Rewind code:创建代码修改检查点,支持一键回退
  • Verbose output:输出详细调试日志,用于排错
  • Terminal progress bar:显示任务进度,直观查看状态
  • Show turn duration:标注单次交互的耗时,评估性能
  • Default permission mode:控制文件 / 命令操作的默认权限(手动确认 / 允许 / 拒绝)

隐藏配置项(12 more below)

  • Show line numbers:生成代码时自动显示行号
  • Show timestamps:对话历史中显示时间戳
  • Model selection:设置默认使用的模型(Sonnet/Opus/Haiku)
  • Context window limit:手动设置上下文窗口的最大 Token 数
  • Sandbox mode:开启沙箱隔离,限制危险操作
  • Custom hooks:配置自定义脚本(执行前 / 后触发)
  • Keyboard shortcuts:自定义操作快捷键,提升效率
  • Output format:设置响应内容的输出格式(文本 / Markdown/JSON)
  • Allowed tools:指定 Claude 可使用的工具列表
  • Disallowed tools:禁用 Claude 的指定工具
  • Prompt caching:开启提示词缓存,加速重复请求
  • Max thinking tokens:限制思考模式可使用的最大 Token 数
  • Status line customization:自定义终端状态栏的显示内容

image.png/hooks 就是给 Claude Code 加自定义规则、自动脚本、触发动作的地方。

你可以把它理解成:给 AI 助手装插件、设规矩、让它自动帮你干活。

举几个最容易懂的例子:

  1. 每次 Claude 改代码前,自动备份文件
  2. 代码保存后,自动跑 lint 检查格式
  3. 禁止 Claude 访问某些敏感文件
  4. 每次生成代码后,自动格式化
  5. 让 Claude 每次启动都加载你的项目规则

本质是什么?

hooks = 你给 AI 定的自动化小规则。不用你手动点、不用你重复输命令,AI 会按你的规矩自动运行。

image.png

image.png

image.png

image.png

Bundled Skills:内置技能包

Skills 是增强版的 Commands,可以打包脚本、模板和参考文件:

Skill 用途
/batch <instruction> 使用 worktree 并行执行大规模修改
/claude-api 加载项目语言的 Claude API 参考
/debug [description] 开启调试日志
/loop [interval] <prompt> 定时重复执行提示词
/simplify [focus] 检查代码质量

/batch 是 Claude Code 的批量任务执行工具,让你一次性给 AI 发一长串任务清单,它会按顺序自动跑完所有任务,不用你一次次手动发指令、等回复。 你可以把 /batch 理解成:给 Claude Code 开了个「自动流水线」

平时用 Claude Code 是「一问一答」:你发一个需求,AI 做完一个,你再发下一个,全程要手动跟进。而 /batch 就是把你所有要做的事,一次性打包成一个「任务清单」喂给 AI,它会自动按顺序、不中断地把所有任务全部执行完,中间完全不用你手动干预。

比如你要给一个前端项目做这些事:

  1. 把所有组件的 console.log 都删掉
  2. 给所有接口请求加统一的错误捕获
  3. 给所有按钮加 loading 状态防重复点击
  4. 跑一遍 eslint 修复格式问题
  5. 生成一份修改说明文档

如果不用 /batch:你要分 5 次发指令,每次等 AI 做完,再手动发下一个,全程要盯着。用了 /batch:你把这 5 个任务一次性写进 /batch,然后去喝杯咖啡,回来 Claude 已经把所有任务全做完了,直接给你最终结果。


/debug 是 Claude Code 的问题排查工具,专门用来显示后台详细日志,帮你快速找到 AI 为什么出错、卡壳、不干活。

image.png 平时用 Claude Code,你只能看到 AI 给你的最终结果:代码、回答、提示。但 AI 内部到底干了什么、调用了什么工具、读了哪些文件、哪里卡住了,你是看不见的。

/debug 就是把这些 “后台秘密” 全部亮出来给你看。

开启后,Claude Code 会显示:

  • AI 正在调用什么命令
  • 读取 / 修改了哪些文件
  • 为什么拒绝执行某个操作
  • 哪里报错、哪里超时、哪里卡住
  • 模型思考过程、工具执行结果

/loop 是 Claude Code 的自动循环重试工具,让 AI 自己反复检查、修改、运行代码,直到满足要求为止,不用你反复手动提醒。

平时你让 AI 写代码、改 Bug,经常会出现这种情况:AI 改一次 → 运行报错 → 你告诉它错了 → 它再改一次 → 又报错 → 你又得提醒……

来回折腾特别麻烦,效率很低。

/loop 就是解决这个问题的:你开启循环模式,AI 会自己 “闭环干活”

它会自动做这几件事:

  1. 改代码
  2. 运行测试 / 检查报错
  3. 自己发现哪里错了
  4. 自动再次修改
  5. 直到运行成功、没有错误,才停下来告诉你完成

简单说:你只需要说需求,剩下的反复调试让 AI 自己循环搞定,不用你插手。


/simplify 命令可以用来优化代码

image.png


自定义 Skills:打造你的命令体系

Skills 是 .claude/skills/<name>/SKILL.md 文件:

image.png

mkdir -p .claude/skills/optimize

文件:.claude/skills/optimize/SKILL.md

---
name: optimize
description: 分析代码性能问题、内存泄漏和优化机会
argument-hint: <代码片段>
allowed-tools: Bash, Read, Grep
---

# 代码优化分析

分析以下代码的性能问题:

$ARGUMENTS

检查:
1. 是否有不必要的重复计算
2. 是否存在内存泄漏风险
3. 算法复杂度是否有优化空间
4. 是否有冗余的 DOM 操作或 API 调用

变量替换

全部参数:

---
name: fix-issue
description: 修复 GitHub Issue
---

修复 Issue #$ARGUMENTS

单个参数:

---
name: review-pr
description: 评审 PR
---

评审 PR #$0,优先级 $1

动态上下文(Shell 命令):

---
name: commit
description: 创建带上下文的 Git 提交
allowed-tools: Bash(git *)
---

## 当前状态

- Git 状态:!`git status`
- 文件变更:!`git diff HEAD`
- 当前分支:!`git branch --show-current`

## 任务

基于以上变更,创建提交信息。

文件引用

请审查以下实现:
@src/utils/helpers.js
对比 @src/old-version.js 和 @src/new-version.js

Frontmatter 完整参考

---
name: my-command           # 命令名称(成为 /my-command)
description: 用途描述      # 帮助 Claude 判断何时使用
argument-hint: <参数>      # 自动补全提示
allowed-tools: Bash, Read  # 无需审批即可使用的工具
model: opus                # 指定使用的模型
disable-model-invocation: true  # 仅用户可调用
user-invocable: false      # 从 / 菜单隐藏
context: fork              # 在隔离子代理中运行
agent: general-purpose     # 子代理类型
hooks:                     # 技能级钩子
  PreToolUse: []
  PostToolUse: []
  Stop: []
---

命令执行流程

sequenceDiagram
    participant User
    participant Claude
    participant FS
    participant Shell

    User->>Claude: /optimize
    Claude->>FS: 搜索 .claude/skills/ 和 .claude/commands/
    FS-->>Claude: 返回 optimize/SKILL.md
    Claude->>Claude: 解析 Frontmatter
    Claude->>Shell: 执行 !`command` 替换
    Shell-->>Claude: 返回命令输出
    Claude->>Claude: 替换 $ARGUMENTS
    Claude->>User: 处理提示词
    Claude->>User: 返回结果

实战技巧

1. 用 /plan 避免浪费

在执行大改动前,输入:

/plan 重构用户认证模块

Claude 会先分析代码、制定计划,你确认后再执行。避免做到一半发现方向错了。

2. 用 /compact 保持专注

长对话变慢时,/compact 压缩上下文,保留核心信息,速度恢复。

3. 自定义快捷命令

把常用的复杂操作封装为 Skill:

---
name: deploy
description: 部署到生产环境(仅用户可调用)
disable-model-invocation: true
allowed-tools: Bash(npm *), Bash(git *)
---

1. 运行测试:npm test
2. 构建:npm run build
3. 推送到部署目标
4. 验证部署状态

4. MCP 提示词作为命令

MCP 服务器暴露的提示词可以直接调用:

/mcp__github__list_prs
/mcp__github__pr_review 456
/mcp__jira__create_issue "Bug 标题" high

Skill vs Legacy Command

特性 Skills(推荐) Legacy Commands
位置 .claude/skills/<name>/SKILL.md .claude/commands/<name>.md
目录结构 支持打包文件 单文件
自动触发 支持 不支持
子代理执行 context: fork 不支持
优先级 更高 较低

同名时,Skill 优先。


常见问题

命令不生效?

  • 确认文件在 .claude/skills/<name>/SKILL.md.claude/commands/<name>.md
  • 检查 frontmatter 的 name 字段
  • 重启 Claude Code 会话

Skill 和 Command 冲突?

  • 删除其中一个,或重命名
  • 同名时 Skill 总是优先

总结

Slash Commands 是 Claude Code 的核心交互范式:

  • 内置命令:55+ 覆盖开发全流程
  • Bundled Skills:开箱即用的增强能力
  • 自定义 Skills:打造个人命令体系
  • MCP 提示词:扩展到外部工具

下一课我们将深入 Memory 系统,学习如何让 Claude 记住项目上下文。


延伸阅读

连载04-CLAUDE.md ---一起吃透 Claude Code,告别 AI coding 迷茫

2026年4月8日 17:24

CLAUDE.md 完整指南——让 Claude 真正理解你的项目

AI Coding 系列第 04 篇 · CLAUDE.md 到底是什么:不是文档,而是 Claude 的规则层


CLAUDE.md 被严重误解

很多人对 CLAUDE.md 的理解有偏差。有人把它当项目文档来写,两百行的架构介绍、API 清单、数据库设计,然后疑惑为什么 Claude 经常无视其中的规则。有人复制了一个通用模板,放在那里从来不改。还有人干脆不知道它到底是干什么的。

这类误解有一个共同点:
CLAUDE.md 当成了“给 AI 看的项目说明书”。

CLAUDE.md 的本质不是文档,而是规则层

它不是用来完整介绍项目的,而是用来告诉 Claude:

  • 这个项目里哪些边界不能碰
  • 哪些行为默认是错的
  • 哪些约定会反复影响决策
  • 哪些高风险区域必须更保守

如果把它写成“项目背景”,Claude 最多只是“看过了”;
如果把它写成“行为规则”,Claude 的默认工作方式才会真正改变。

所以更准确的定义是:

CLAUDE.md 不是项目文档,而是把稳定偏好、高风险边界和重复纠正,提前变成 Claude 默认上下文的规则层。


一、CLAUDE.md 到底解决什么问题

一个好用的 CLAUDE.md,主要解决四类问题。

1. 把反复提醒的内容沉淀下来

如果你总是在 prompt 里反复说这些话:

  • 这个项目不要改 .github/workflows
  • 错误统一用 AppError
  • 不要默认新增依赖
  • 数据库变更前先讲回滚方案

那这些内容就不该每次重新说,而应该进入 CLAUDE.md

2. 给 Claude 的默认积极性加边界

Claude 默认会尽量帮你完成任务,但很多项目里真正危险的,不是它不做事,而是它做得太多。

比如:

  • 看见旧代码就想顺手重构
  • 看见没测试就想补一整套基础设施
  • 看见当前实现笨重就建议换栈

这些行为在通用场景里未必错,但在具体项目里可能是噪音,甚至是风险。
CLAUDE.md 的一个重要作用,就是给这种默认积极性划边界。

3. 把“代码里看不出来”的规则显式化

很多项目真正重要的约束,并不直接写在代码里。

例如:

  • 某个目录是历史包袱区,轻易别碰
  • 某些 migration 一旦上线后绝不能回写修改
  • 某个模块表面简单,背后连着外部系统
  • 某类接口一改就会影响前端联调和埋点

这些东西人类同事待久了会知道,但 AI 初来乍到不会知道。
CLAUDE.md 的价值,就在于把这些隐性知识提前说透。

4. 降低上下文成本

技术栈、关键路径、错误处理方式、依赖策略、部署边界,这些稳定规则本来就适合长期存在。把它们放进 CLAUDE.md,每次 prompt 就能专注当前任务,而不是重复灌输基础背景。


二、它不只是纠错层,也是预防层

前面说 CLAUDE.md 是纠偏器,这个说法是对的,而且很重要。因为它能一下子把很多人从“项目文档思维”拉回来。

但如果只停在“纠偏器”这一层,对它的理解还是不完整。

更准确地说,CLAUDE.md 既是纠错层,也是预防层

1. 纠错层:把重复犯的错写成规则

比如:

  • 你已经说过两次不要直接 throw new Error()
  • 你已经纠正过几次不要改 .github/workflows
  • 你已经反复提醒过不要随便 npm install

这些都属于典型的“纠错”。

2. 预防层:提前声明高代价边界

真正好用的 CLAUDE.md,并不只是在事后补锅。它还有一个同样重要的作用:提前声明那些一旦做错,代价就很高的边界。

比如:

  • 支付模块改动前先确认幂等逻辑
  • migration 文件上线后只能新增,不能回写修改
  • 生成目录不要手改,因为下次生成会覆盖
  • 新增重大依赖前先说明必要性和替代方案

这些规则不一定是 Claude 已经犯过的错,也可能是你提前告诉它:

“这里不是不能动,而是这里的错误成本很高,你默认要更保守。”

所以从完整定位上说,CLAUDE.md 的作用不是单纯“记录反复犯的错”,而是:

把稳定偏好、风险边界和高代价约束,提前变成 Claude 的默认工作上下文。

CLAUDE.md 的真实定位

图:CLAUDE.md 不是项目文档,而是纠偏层 + 预防层 + 长期约束层。


三、文档式写法 vs 纠偏式写法

说一百遍不如直接对比。

❌ 文档式写法(Claude 读了,但行为不变)

本项目是一个电商平台,使用 Node.js + Express + TypeScript 开发,
数据库采用 PostgreSQL,通过 Prisma 进行 ORM 管理。
项目包含用户模块、订单模块、支付模块和通知模块,
遵循 RESTful API 设计规范……
✅ 纠偏式写法(Claude 读了,行为立刻改变)

- 禁止 throw new Error(),统一用 AppError 类
- API 响应必须含 success / data / timestamp 三个字段,不能自己发明格式
- 禁止在 controller 层直接写 SQL,必须通过 service 层
- 所有异步函数必须有 try-catch,不靠外层中间件兜底
- 新增依赖前必须问我,不要自行 npm install

文档式写法让 Claude “知道”了,但知道不等于行动。
纠偏式写法告诉 Claude:“在这个项目里,你的默认行为哪里不对。” 这才是它真正听进去的语言。

判断一条规则是不是纠偏式,只用问一个问题:

这条规则是在纠正 Claude 的某个具体行为,还是在描述项目背景?

能对应到一个具体行为变化的,是纠偏。
其他的,是文档。


四、它和 Prompt、文档、Memory、Skill 的边界

很多人用不好 CLAUDE.md,不是不会写规则,而是把它和别的东西混在一起了。

最容易混淆的有四个对象:Prompt、项目文档、Memory、Skill。

CLAUDE.md 和其他机制的边界

图:Prompt 管当前任务,文档管背景,Memory 管自动沉淀,Skill 管重复流程,CLAUDE.md 管稳定规则。

源码里的分工也很明确

如果去看 Claude Code 的源码,CLAUDE.mdMemory 的边界其实分得很清楚。相关实现可以看 src/utils/claudemd.ts。在这部分实现里,CLAUDE.md 被归在一套明确的 instruction loading 顺序里:

  1. Managed memory:全局托管规则
  2. User memory:~/.claude/CLAUDE.md
  3. Project memory:仓库里的 CLAUDE.md.claude/CLAUDE.md.claude/rules/*.md
  4. Local memory:CLAUDE.local.md

这套机制本质上是在加载指令文件

而同一个文件里又能看到另一套独立机制:当 auto memory 打开时,系统会额外读取 getAutoMemEntrypoint() 返回的 MEMORY.md,其类型是 AutoMem,团队记忆则是 TeamMem。源码里甚至专门写了注释:

AutoMem/TeamMem are intentionally excluded — they're a separate memory system, not "instructions" in the CLAUDE.md/rules sense.

这句话非常关键。它说明:

  • CLAUDE.md 这一层,本质上是 instructions / rules
  • MEMORY.md 这一层,本质上是 auto memory / persistent memory

它们最后都会进入上下文,但在架构里并不是同一个东西。

所以如果从源码上更严格地说,CLAUDE.md 不是 MEMORY.md 的别名,更不是 auto-memory 的索引。
真正扮演“索引 + 主题文件”角色的,是后面的 MEMORY.md 系统。

1. Prompt 负责当前任务

Prompt 解决的是这一次你到底要 Claude 做什么。

比如:

  • 这次只修 bug,不要顺手重构
  • 这次只分析原因,先不要改代码
  • 这次只改前端,不动后端

这些都是单次任务边界,适合写在 prompt 里,不适合沉淀进 CLAUDE.md

2. 项目文档负责完整背景

README、设计文档、接口文档、架构说明,负责回答的是:

  • 这个项目是什么
  • 系统怎么设计
  • 模块如何划分
  • 业务流程怎么走

这些内容通常信息量大、细节多、更新频繁,它们的职责是“说明项目”,不是“约束 Claude 的默认行为”。

3. CLAUDE.md 负责稳定规则

CLAUDE.md 解决的是那些跨多次任务都成立、而且会持续影响 Claude 决策的东西。

比如:

  • 高风险文件和目录
  • 错误处理规范
  • 依赖策略
  • migration 边界
  • 哪些行为必须先确认

它不负责讲完整背景,只负责把真正影响行为的规则提炼出来。

4. Memory 负责自动沉淀

它更像 Claude 在长期协作里逐步学到的东西,是补充,不是替代。

你可以把它理解成“模型慢慢记住了你们项目里的某些偏好和事实”,但这类记忆不适合代替明确规则。因为对于关键边界来说,你明确写下来的东西,永远比它自己学到的更稳。

结合源码看,这个分工会更清楚:

  • CLAUDE.md 通过 instruction loader 进入系统 prompt
  • MEMORY.md 则是 auto memory 的入口文件
  • 相关 topic files 会在需要时被检索和召回,而不是把所有细节都塞进一个大文件

因此,更准确的理解是把它们视为“两套协作机制”,而不是“一份文件的两种叫法”。

5. Skill 负责重复流程

CLAUDE.md 管的是“长期规则”,Skill 更适合管“这类任务应该怎么做”。

比如:

  • 需求分析怎么展开
  • Code Review 按什么顺序做
  • 排查线上 bug 用什么流程
  • 新功能开发先看哪些文件、再做哪些验证

这类内容本质上是“做事模板”,更像流程,不像规则。

可以概括成一句话:

  • 当前任务Prompt
  • 稳定规则CLAUDE.md
  • 完整背景进项目文档
  • 自动沉淀交给 Memory
  • 重复流程沉淀成 Skill

一旦边界分清楚了,很多人最头疼的那个问题就会自动消失:

为什么我明明写了很多东西,但 Claude 还是不按我想的来?

因为你很可能把应该放在不同位置的信息,全塞进了 CLAUDE.md


五、三层分层架构

CLAUDE.md 不是一个单一文件,而是一个分层的规则系统。

三层分层架构

图:先按稳定性分层,再决定规则应该写到用户级、项目级还是公司级。

用户级~/.claude/CLAUDE.md
你电脑上所有项目都生效,写个人偏好。

项目级:仓库根目录的 CLAUDE.md
只在这个项目生效,写项目特有约定,提交进 Git。

公司级:企业统一管理的配置位置
整个组织生效,写合规要求和架构标准。大型企业才更常用,普通团队通常不需要。

判断一条规则放哪层,只用一个标准:

换个项目还成立吗?

成立放用户级。
比如:“我的变量命名用驼峰。” 换到任何项目都一样。

不成立放项目级。
比如:“这个项目用 Prisma,禁止用 Sequelize。” 换到 MongoDB 项目就不适用了。

这个区分看起来简单,但它直接决定后面的维护成本。


六、用户级:写你的默认行为偏好

用户级规则要少而精,不超过 50 行。这里写的是覆盖 Claude 默认值的个人偏好。

# 我的个人偏好

## 代码风格
- 缩进:2 个空格
- 变量命名:camelCase,类名 PascalCase
- 单行不超过 100 字符

## 我固定用的库(不要建议替代品)
- 日期处理:date-fns,不用 moment.js
- HTTP 请求:axios,不用 node-fetch
- 测试:Jest,不用 Vitest 或 Mocha

## 从不做的事
- 不要在我没要求时修改测试文件
- 不要建议我换版本控制工具
- 不要在随意讨论时提出架构大改动

## Git 提交格式
feat(模块名): 简短描述

- 改动说明 1
- 改动说明 2

注意措辞:写的是“我的偏好”,不是“你必须”。前者 Claude 当作信息接收,后者听起来像命令,反而可能在某些场景被跳过。

用户级不该写什么

  • 一次性的任务背景
  • 大段项目文档
  • 经常变动的技术现状
  • 只在某个仓库成立的规则

比如:“我现在在做一个电商系统。” 这不是偏好,是当前任务。应该放在 prompt 里。


七、项目级:记录这个项目特有的边界

项目级可以稍长,100 行左右。核心是三类内容:

1. 关键文件保护

## 禁止修改的文件
- src/config/env.ts — 改了会影响生产环境变量加载
- .github/workflows/* — CI/CD 流水线,改动需要 DevOps 审核
- 数据库 migration 文件一旦执行,不得修改,只能新增

2. 编码规范,必须具体到代码动作

## 错误处理
统一使用 AppError 类,禁止 throw new Error():
throw new AppError('用户不存在', 404, 'USER_NOT_FOUND')

## API 响应格式
所有响应必须符合:
{ "success": true, "data": {}, "timestamp": "ISO字符串" }
错误响应:
{ "success": false, "error": "ERROR_CODE", "message": "描述" }

3. 高风险路径标注

## 高风险区域(修改前必须告知我)
- src/modules/auth/* — 认证核心,任何改动都需要 review
- src/handlers/payment/* — 对接支付商,出错直接影响收入
- src/database/migrations/* — 不可逆操作,要有回滚方案

项目级真正决定效果的,不是“把整个项目介绍一遍”,而是:

把这个仓库里最容易做错、最不能做错的东西写出来。


八、一条好规则到底该怎么写

很多人不是不会列规则,而是写出来之后没有约束力。

比如:

  • 代码要整洁
  • 数据库迁移要小心
  • 不要随便改配置

这些话人类看得懂,但模型不一定知道“到底怎样做才算遵守”。

一条好规则,尽量包含这几个元素:

  • 触发场景
  • 期望动作
  • 禁止动作
  • 原因
  • 示例

一条好规则怎么写

图:好规则最少要把场景、动作、边界和原因交代清楚。

看一个例子就很清楚。

❌ 只有规则
- 使用 Prisma 生成迁移,不要写原生 SQL
✅ 规则 + 原因 + 行为边界
- 涉及 schema 变更时,优先走现有 migration 工作流,不要临时手写 SQL 直接改结构。
  原因:团队的审查、回滚和环境同步流程都围绕当前 migration 体系建立。
  如果必须做破坏性变更,先说明影响范围和回滚方案。

再比如:

❌ 太抽象
- 注意统一错误处理
✅ 可执行
- 所有业务异常统一使用 AppError,禁止直接 throw new Error()。
  原因:前端依赖统一错误码和 message 做提示与埋点归类。

关键就在这里:

CLAUDE.md 不是写原则,而是写可执行规则。


九、为什么有时有效,有时又像没生效

这也是很多人真正困惑的地方。

不是所有写进 CLAUDE.md 的内容,效果都一样。有些规则一写进去,Claude 的行为马上变化;有些规则写了之后,几乎没感觉。

通常不是因为它“没读”,而是因为规则本身写得不够能执行。

第一,规则写成了背景介绍

例如:

本项目采用分层架构,强调可维护性和扩展性。

这句话是背景,不是约束。Claude 即使看到了,也很难从里面推导出具体行动。

第二,规则太抽象

例如:

- 代码要整洁
- 注意性能
- 数据库修改要谨慎

这些话人类看得懂,但模型不知道“怎样才算遵守”。

第三,规则太多,信噪比下降

不是说长文一定不好,而是低价值内容一多,真正重要的规则就会被埋掉。

如果一份 CLAUDE.md 里面既有项目概述、又有接口说明、又有架构文档、又有零碎提醒,那 Claude 真正应该优先遵守的那些边界,反而不够突出。

第四,规则之间互相冲突

比如你在用户级里写了“我习惯四空格缩进”,项目级里又写“这个项目统一两空格”,但没有说明项目级覆盖团队标准。
这种情况下,Claude 不是一定做错,而是判断空间会变大。

第五,单次任务 prompt 和长期规则打架

如果你在 CLAUDE.md 里长期写“默认不要大改”,但当前 prompt 又说“请你重构这一块并统一风格”,那单次任务会临时改变优先级。

这不是 CLAUDE.md 失效,而是上下文优先级在变化。

真正决定它能不能稳定生效的,是三件事:

规则足够具体,边界足够清楚,信噪比足够高。只有这三件事同时成立,CLAUDE.md 才会真正改变行为。


十、它很重要,但不是万能控制器

把这一点想清楚,对 CLAUDE.md 的期待反而会更稳。

CLAUDE.md 很强,但它不是万能控制器。它做不到下面这些事:

  • 它不能替代清晰的任务描述
  • 它不能替代 README 和设计文档
  • 它不能替代你对复杂任务的即时判断
  • 它不能保证 Claude 在任何场景下 100% 机械执行

它真正擅长的是:

  • 让默认行为更接近你的项目习惯
  • 让高风险边界更早暴露
  • 让重复提醒沉淀成长期规则
  • 让每次 prompt 更聚焦当前任务

所以最好的理解方式不是:

“只要我把 CLAUDE.md 写好了,后面什么都不用管了。”

而是:

“我用 CLAUDE.md 把稳定规则立住,再用 prompt 管当前任务,用文档承载背景,用 Skill 沉淀流程。”

只有在这套分工里,CLAUDE.md 的作用才会既强,又稳定。


十一、连接第 03 篇:为什么它能解决“纠正回退”

第 03 篇讲过一个现象:你在对话里纠正了 Claude,它承认了,但过几轮又犯同样的错。这不是 Claude 不配合,而是对话历史会随时间衰减,纠正效果也会随之消退。

更稳定的纠正方式,就是写进 CLAUDE.md

写进 CLAUDE.md 的规则,每次对话开始时都会被系统自动注入,不受对话长度影响,也不会像临时纠正那样快速衰减。

判断标准很简单:

同一件事你纠正了两次以上,就应该写进 CLAUDE.md,不要再在对话里重复说。

# 这条规则在对话里说了三次,该进 CLAUDE.md 了:
- 日志统一用 logger.info/warn/error,禁止 console.log

十二、Claude 会主动学习,但它补充不了规则层

CLAUDE.md 不是单向的。你往里写规则,Claude 也会在长期协作中逐步积累知识。

每轮对话结束后,Claude Code 会在后台启动一个独立的子 Agent,分析对话里有没有值得保留的项目知识,自动写入 Memory 文件,下次会话时注入:

对话结束 → 后台子 Agent 分析 → 提取项目偏好和技术决策
→ 写入 ~/.claude/projects/[项目]/memory/ → 下次会话自动读取

你在某次对话里说了“我们禁止用 moment.js,改用 date-fns”,下次打开 Claude Code,它已经记得了。

几个要知道的细节:

它补充 CLAUDE.md,不取代它。
自动记忆是“Claude 学到的”,CLAUDE.md 是“你明确要求的”,关键约束还是应该写在 CLAUDE.md 里。

明确说出来的比隐含的更容易被记住。
在对话里直接说“我们统一用 date-fns”,提取率更高;只是悄悄在代码里换了库,Claude 可能记不到。

你可以检查它记了什么。
/memory 命令可以查看当前记忆内容,发现记错了直接改,它本质上还是普通文本文件。

实际效果是:Claude Code 越用越懂你的项目。头几天需要反复解释背景,用了几周后,很多背景已经自动沉淀,你的 prompt 可以越写越短。

Memory 的索引 + 主题文件结构

图:从源码看,Memory 更像索引入口 + topic files,而不是一个无限膨胀的大文件。

从源码看,Memory 本质上是一套“索引 + 主题文件”的结构

从实现上看,auto memory 不是把内容都堆在一个文件里。相关实现可以看 src/memdir/memdir.ts。在这部分实现里,入口常量就是:

export const ENTRYPOINT_NAME = 'MEMORY.md'
export const MAX_ENTRYPOINT_LINES = 200
export const MAX_ENTRYPOINT_BYTES = 25_000

这三行信息已经说明了很多问题:

第一,真正被当作 memory 入口文件的,是 MEMORY.md,不是 CLAUDE.md
第二,系统从设计上就不希望这个入口文件无限膨胀。
第三,memory 架构默认就不是“把所有内容堆在一个大文件里”。

同一个文件里,源码把保存流程直接写成了两步:

  1. 先把记忆写入独立主题文件
  2. 再在 MEMORY.md 里增加一个索引指针

源码注释原话基本就是这个意思:

  • Step 1:write the memory to its own file
  • Step 2:add a pointer to that file in MEMORY.md

而且它还专门强调:

MEMORY.md is an index, not a memory

从实现上看,Claude Code 的 auto memory 更像:

  • MEMORY.md:目录页 / 索引页
  • topic files:按主题拆开的详细内容

这也解释了一个很多人会问的问题:

如果记忆越积越多,MEMORY.md 不会越来越大吗?

答案是:源码层面已经考虑了这个问题。

truncateEntrypointContent() 会对 MEMORY.md 做双重限制:

  • 超过 200 行会截断
  • 超过 25KB 也会截断

截断后甚至还会追加警告,提醒把细节移到 topic files,只把一行短索引留在 MEMORY.md

换句话说,这套设计本身就在强制你保持:

  • 索引足够短
  • 细节分散到主题文件
  • 入口文件永远尽量装得进上下文

这和 CLAUDE.md 的定位,是什么关系

最容易混在一起的,恰恰是规则系统和记忆系统。

如果站在源码架构的角度看:

  • CLAUDE.md 更像 instruction layer
  • MEMORY.md 更像 memory index layer
  • topic files 更像 memory payload layer

这三层不是互相替代,而是互相配合。

所以把 CLAUDE.md 定义成“规则层”是成立的,而且和源码是对齐的。

放到 Claude Code 的完整架构里看,CLAUDE.md 负责规则,MEMORY.md 负责记忆索引,topic files 负责详细内容。

这样去理解,规则、索引和记忆详情各自负责什么,就不会再混成一团。

从源码看“自愈”和写入一致性

把这套机制类比成一种带“自愈”倾向的写入纪律,可以作为理解辅助,但不宜把类比直接当成源码结论。

从目前能看到的实现和解析文档来看,至少可以确定三件事:

  • memory 保存采用“先写主题文件,再更新 MEMORY.md 指针”的两步方式
  • 这种顺序天然更有利于一致性,因为索引最终指向的是已经成功落盘的内容
  • 它的思路更接近“先落数据,再更新索引”,和很多数据库 / 存储系统的一致性设计取向相似

更稳妥的理解是,把它当作一种 可以类比理解 的一致性思路,而不是直接把它等同于“源码明确实现了 WAL 逆向”。

因为源码里我能确认的是:

  • 两步保存存在
  • MEMORY.md 是索引存在
  • 入口大小控制存在
  • 按需检索 topic files 存在

这些都是可以直接从源码和解析文档里站得住的。


十三、两个最常见的陷阱

陷阱一:写得太多,关键规则被淹没

CLAUDE.md 写得太长时,Claude 往往只会抓住其中最显眼、最强约束的那部分,其他内容会逐渐退化成背景噪音。规则越多,真正稳定生效的比例通常越低。

解决方法:

  • 定期删掉已经不再是问题的规则
  • 删掉太细节、没有行为约束力的规则
  • 删掉重复表达

CLAUDE.md 应该是个活跃的 hotlist,不是越来越臃肿的文档。

陷阱二:规则放错层级

用户级放了项目特有规则,Claude 在其他项目里也按这个来。
项目级放了所有项目通用规则,十几个项目各自维护一份重复内容,改一条要改十几个地方。

解决方法还是那一句:

换个项目还成立吗?

成立放用户级,不成立放项目级,一次定好就别再改。


十四、维护节奏

CLAUDE.md 写好之后不是扔着不管,需要定期维护。

第一个月:初始化

/init 生成草稿,花半小时补充:

  • 关键文件保护
  • 错误处理规范
  • API 格式约定
  • 高风险路径说明

这是最重要的一次,做好了后面会省很多事。

每两周:维护

回顾最近 Claude 犯过什么错。

  • 同一个错出现两次以上,加进 CLAUDE.md
  • 已经不构成问题的规则,删掉
  • 写得太空的规则,改具体一点

每季度:清理

把整个文件读一遍:

  • 删冗余
  • 合并重复
  • 简化过细规则

目标是让文件保持高信噪比,而不是越写越长。


十五、检查清单

提交项目级 CLAUDE.md 前过一遍:

  • 规则是纠偏式的,不是文档式的
  • 每条规则能对应到 Claude 的一个具体行为变化
  • 关键文件有明确的保护声明
  • 高风险路径有标注和警告
  • 重要规则附上了“为什么”
  • 用户级和项目级没有混放
  • 文件总长度不超过 200 行
  • 对话里纠正过两次以上的规则已经写进来了

本篇实践任务

任务一: 打开你现有的 CLAUDE.md,把里面每条规则过一遍:它是纠偏式,还是文档式?把文档式的删掉或者改成纠偏式。

任务二: 回想最近一周,你在对话里纠正过 Claude 几次同一个问题?把这些问题整理成具体规则,写进 CLAUDE.md,下次对话观察效果。

任务三: 运行 /memory,看看 Claude 已经自动记住了什么。和你的 CLAUDE.md 对比,有没有重复的内容?有没有记错的内容需要修正?


下篇预告

第 05 篇:Skill 提炼——把重复任务沉淀成可复用模板

CLAUDE.md 管的是全局规则,Skill 管的是任务模板。当同一类任务反复出现,把“怎么做这类任务”浓缩成一个 Skill,下次直接触发。下一篇会讲什么时候沉淀 Skill、怎么写一个真正有效的 Skill,以及 Skill 和自定义命令的边界在哪。


AI Coding 系列持续更新。CLAUDE.md 是规则层,不是项目文档。写法不同,效果天壤之别。

UVM组件故事版 · driver:那个把"指令"翻译成"电信号"的人

作者 天河山统
2026年4月8日 16:57

UVM组件故事版 · driver:那个把"指令"翻译成"电信号"的人


想象一下打仗。

将军坐在指挥部里,写了一份作战命令:"明天凌晨三点,从A点向B点发起进攻,炮火掩护从C方向过来。"

这份命令写得很清楚,但问题来了——士兵在战壕里,拿到的是一张写满字的纸。他们听不懂"炮火掩护从C方向"是什么意思,他们只知道:什么时候该扣扳机,往哪个方向开枪,火力往哪打。

中间需要一个人,把将军的命令翻译成士兵能执行的具体动作。

这个翻译官,就是driver。


driver到底在干什么

在UVM的世界里,这个场景是这样的:

sequence(将军)→ 发送一个transaction:"我想要你发一个读命令,读地址0x1000"
    ↓
sequencer(传令兵)→ 把transaction传递给driver
    ↓
driver(翻译官)→ 把transaction转换成具体的时序信号,送到DUT的接口上
    ↓
DUT(士兵)→ 收到电信号,执行实际操作

driver做的事情本质上是两件:

1. 接收上层传来的数据对象(transaction)

2. 把这个对象"翻译"成DUT能看懂的时序信号

比如DUT是一个UART模块,它的接口信号长这样:

uart_tx    → 发送数据的信号
baud_rate  → 波特率信号

driver从sequence那里拿到一条"发数据0x55"的消息,driver要做的事情是:

1. 把0x55转成二进制:01010101
2. 按照UART协议加上起始位、校验位、停止位
3. 在对应的时钟沿上,把这些bit一个个送到uart_tx信号上

这中间涉及到很精确的时序控制——早了不行,晚了不行,数据宽度不对不行。driver就是那个必须分毫不差的人。


为什么会有人把driver写错

见过两种典型错误:

第一种:把所有事情都塞给driver

有人觉得driver是唯一能接触DUT的人,所以恨不得把所有的判断逻辑都写在driver里:

driver里写:if (data == 0x55) { 发送A } else if (data == 0xAA) { 发送B }

这是新手常干的事情。driver不是用来判断"做什么"的地方,那是sequence和test的工作。driver的职责是:我收到了一个任务,把它正确地执行下去。

第二种:不懂得和sequencer配合

driver和sequencer之间有一个握手机制:

driver发送req给sequencer:"我准备好了,给我下一个任务"
sequencer把下一个transaction传给driver
driver执行,然后再次请求

这个握手叫seq_item_port,是UVM里最常见的组件间通信方式。很多人写driver的时候跳过了这个理解,导致driver只跑一次就卡住了,或者跑起来之后和sequence的节奏完全对不上。


driver的正确打开方式

一个正确的driver,通常长这样:

class uart_driver extends uvm_driver #(uart_transaction);
  virtual uart_if vif;  // 硬件接口的虚接口
  
  function void build_phase(uvm_phase phase);
    super.build_phase(phase);
    if (!uvm_config_db #(virtual uart_if)::get(this, "", "vif", vif))
      `uvm_fatal("NOVIF", "virtual interface must be set");
  endfunction
  
  // driver的核心:不断从sequencer拿任务,执行它
  task run_phase(uvm_phase phase);
    forever begin
      seq_item_port.get_next_item(req);  // 从sequencer拿任务(阻塞等待)
      drive_transaction(req);             // 执行任务
      seq_item_port.item_done();          // 告诉sequencer任务完成了
    end
  endtask
  
  // 把transaction翻译成时序信号
  virtual protected task drive_transaction(uart_transaction tr);
    vif.rst_n <= 0;  // 假设要reset一下
    #10;
    vif.rst_n <= 1;
    
    // 发送起始位
    vif.uart_tx <= 1'b0;
    #(BIT_PERIOD);
    
    // 发送8位数据
    for (int i = 0; i < 8; i++) begin
      vif.uart_tx <= tr.data[i];
      #(BIT_PERIOD);
    end
    
    // 停止位
    vif.uart_tx <= 1'b1;
    #(BIT_PERIOD);
  endtask
endclass

核心循环就三步:

get_next_item() → drive_transaction() → item_done()

理解了这三步,driver就不难了。


打个比方收尾

sequence是将军,写作战计划。sequencer是传令官,把计划传递给翻译官。driver是翻译官,把将军的命令翻译成士兵能执行的具体动作——几时几分,从哪个方向,开什么枪。

翻译官不需要知道为什么要打这一仗,那是将军的事。翻译官只需要把命令准确无误地执行到位。

driver在UVM验证环境里的角色,就是这样:精确执行,不多不少。


下篇预告:monitor——那个躲在角落里,把一切看见的都记下来的人。




UVM军事系列 · 第一篇:driver——那个把命令翻译成战场语言的人


特种作战行动开始前,指挥部传来一份任务书:

"凌晨0300,A组从东侧突入,B组掩护,C组接应。炮火支援在C组进入位置后30秒启动。"

这份任务书写得清清楚楚,但问题是——前线的士兵拿到的是无线电里嘈杂的指令,他们听不懂"炮火支援在C组进入位置后30秒启动"这种协调语言,他们只知道:什么时候冲,往哪开枪,枪口抬多高。

中间需要一个人,把指挥部的命令翻译成前线士兵能执行的具体动作。

这个人叫通讯兵,也叫传译官

在UVM的世界里,这个角色,叫driver


driver到底在干什么

特种部队的通讯兵,是指挥部和前线之间唯一的翻译通道。

他的工作看起来很简单:收到命令,执行命令。但细看下去,每一步都藏着细节:

mission_order(任务书)→ 作战参谋排序 → 通讯兵接收任务 → 翻译成战场信号 → 士兵执行
    指挥部                      调度官                通讯兵              物理动作

driver做的事情本质上只有两件:

第一,接收上层传来的指令对象(mission order)。

第二,把这个指令翻译成DUT能看懂的时序信号。

比如,DUT是一个通信电台模块,它的物理接口信号长这样:

tx_data[7:0]8位数据线
tx_valid        → 数据有效信号(高电平表示data有效)
tx_ready        → 电台准备好了(握手信号)
clk             → 时钟信号

driver从sequencer那里拿到一条"发送数据0xA5"的消息,driver要做的事情是:

第一步:在tx_valid上拉高一个时钟周期,同时把0xA5放到tx_data上
第二步:等待tx_ready握手信号(告诉士兵"我收到命令了")
第三步:清空tx_valid,一个命令执行完毕

这中间涉及精确的时序控制——早了,电台还没准备好,数据丢了;晚了,士兵的火力窗口错过了。driver就是那个必须分毫不差的人。


为什么会有人把通讯兵派错岗位

见过两种典型的错误:

第一种:让通讯兵去决定打不打。

有人觉得通讯兵是唯一能接触前线的人,所以把"战术判断"也塞给他:

driver里写:if (urgent_mission) { 立即执行 } else { 排队等 }

这是新手常干的事。通讯兵不是战术决策者,那是作战参谋的活。通讯兵的职责是:我收到命令了,准确地翻译和执行。 翻译官不需要知道为什么要打这一仗,那是将军的事。

第二种:通讯兵不听参谋的调度,擅自行动。

driver和sequencer之间有一个握手机制:

通讯兵(driver):"我执行完了,下一个任务是什么?"
作战参谋(sequencer):"收到,给你新的任务。"
通讯兵执行新任务,再问:"执行完了,下一个呢?"
……循环往复

这个握手在UVM里叫seq_item_port,是UVM中最常见的通讯方式。多数人写driver的时候跳过了这个理解,导致通讯兵只跑一轮就停在那里,或者自顾自地一直发,完全不听参谋的节奏。


driver的正确打开方式

一个正确的通讯兵(driver),是这样的:

class comm_driver extends uvm_driver #(mission_item);
  virtual radio_if vif;  // 电台物理接口
  
  function void build_phase(uvm_phase phase);
    super.build_phase(phase);
    // 从配置池里拿到电台接口
    if (!uvm_config_db #(virtual radio_if)::get(this, "", "vif", vif))
      `uvm_fatal("NORADIO", "电台接口未配置,通讯兵没有电台怎么打仗?")
  endfunction
  
  // 通讯兵的日常:等命令 → 执行 → 报告完成 → 等下一个
  task run_phase(uvm_phase phase);
    forever begin
      // 从作战参谋那里拿到任务(阻塞等待,没有任务就待命)
      seq_item_port.get_next_item(req);  
      transmit_mission(req);              // 执行任务
      seq_item_port.item_done();           // 报告参谋:任务完成
    end
  endtask
  
  // 具体的翻译工作:把任务对象变成电台信号
  virtual protected task transmit_mission(mission_item tr);
    // 第一步:把数据放到数据线上,同时拉高有效信号
    @(posedge vif.clk);
    vif.tx_valid <= 1'b1;
    vif.tx_data  <= tr.payload;
    
    // 第二步:等待电台握手确认
    do begin
      @(posedge vif.clk);
    end while (vif.tx_ready == 1'b0);  // 电台没ready就一直等
    
    // 第三步:任务完成,清除有效信号
    @(posedge vif.clk);
    vif.tx_valid <= 1'b0;
  endtask
endclass

核心循环就三步:

get_next_item()  →  transmit_mission()  →  item_done()
  从参谋拿任务       执行翻译              报告完成

理解这三步,通讯兵就不难当了。


打个比方收尾

sequence是将军,写作战命令。sequencer是作战参谋,把命令排序整理。driver是通讯兵,把参谋整理好的命令翻译成前线士兵能执行的具体动作——几点几分,从哪个方向,打什么目标,打几发。

通讯兵不需要知道为什么要打这一仗,那是将军的事。通讯兵只需要把命令准确无误地传达下去。

迟一秒不行,早一秒也不行,信号错了更不行。

这就是driver在UVM验证环境里的角色:精确执行,不多不少。


下篇预告:monitor——那个躲在暗处,把战场上发生的一切都记录下来的人。侦察兵不上前线,但战场上没有人比他更清楚发生了什么。

通过 npm 下载node_modules 某个依赖 ;例如 下载 @rollup/rollup-linux-arm64-gnu

作者 持续前行
2026年4月8日 16:04

方法1:通过 npm 下载

从官方 npm registry 下载

# 1. 下载 .tgz 包
npm pack @rollup/rollup-linux-arm64-gnu

# 2. 或者直接安装
npm install @rollup/rollup-linux-arm64-gnu

# 3. 指定版本
npm pack @rollup/rollup-linux-arm64-gnu@latest
npm pack @rollup/rollup-linux-arm64-gnu@4.9.5

从淘宝镜像下载(更快)

# 设置淘宝镜像
npm config set registry https://registry.npmmirror.com

# 下载
npm pack @rollup/rollup-linux-arm64-gnu
# 或
curl -L -O https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.5.tgz

方法2:从 GitHub Releases 下载

# Rollup 官方 GitHub Releases
# https://github.com/rollup/rollup/releases

# 下载特定版本
curl -L -O https://github.com/rollup/rollup/releases/download/v4.9.5/rollup-linux-arm64-gnu.tgz

# 或者查看所有 Assets
# 在 Releases 页面找: rollup-linux-arm64-gnu.tgz

方法3:通过 npm 查看可用版本

# 查看所有版本
npm view @rollup/rollup-linux-arm64-gnu versions

# 查看最新版本
npm view @rollup/rollup-linux-arm64-gnu version

# 查看包信息
npm view @rollup/rollup-linux-arm64-gnu

方法4:下载脚本

#!/bin/bash
# download-rollup-arm64.sh

echo "下载 Rollup ARM64 二进制包..."

# 方法1: 从 npm 下载
echo "方法1: 从 npm 下载..."
npm pack @rollup/rollup-linux-arm64-gnu@latest
ls -lh *.tgz

# 方法2: 从淘宝镜像
echo "方法2: 从淘宝镜像下载..."
curl -L -O https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.5.tgz

# 验证下载
echo "验证文件..."
tar -tzf rollup-linux-arm64-gnu-*.tgz 2>/dev/null | head -10

方法5:如果无法下载,安装可选依赖

在你的 package.json中添加:

{
  "optionalDependencies": {
    "@rollup/rollup-linux-arm64-gnu": "^4.9.5"
  }
}

然后运行:

npm install
# 这会尝试安装 ARM64 的二进制包

下载链接(直接访问)

在浏览器中直接访问:

npm 官方

https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.5.tgz

淘宝镜像

https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.5.tgz

GitHub

https://github.com/rollup/rollup/releases/download/v4.9.5/rollup-linux-arm64-gnu.tgz

OpenClaw 跟病毒的区别是什么?

作者 ErpanOmer
2026年4月7日 16:10

节日期间在家办公,我坐在书房的电脑前,盯着满屏飘红的终端😖

webpack_error_terminal_style_match.png

我没有中勒索病毒,也没有被黑客攻击。我只是在之前,极其手欠地给跑在后台的 OpenClaw 下达了一句简单的语音指令:帮我把这个老项目里的无用 npm 依赖清理一下,顺便跑通本地编译。

openclaw_feishu_chat_conversation.png

然后我就去客厅看电视了。

等我两个小时后回来,发现风扇狂转。打开终端一看,这玩意儿不仅把我的 package-lock.json 给删了,还因为有个老旧的 Sass 模块死活装不上,它自己去网上搜了个不知道谁写的 Python 脚本跑了一遍,顺手把我的全局 Node 环境降级到了两年前的版本,最后还在根目录下给我留了几十个不知名的临时编译文件🤬🤬。

看着这片惨状,我脑子里突然冒出一个极其荒诞的问题: 一个拥有系统最高执行权限的 OpenClaw,跟一个木马病毒的区别到底是什么?

如果仔细推敲,你会发现这两者的行为轨迹惊人地相似,甚至可以说,前者带来的工程灾难往往更具欺骗性。


在搞破坏?

以前我们在电脑上跑个脚本,报错了就停在那,等你来排查,过程相对可控的。

但现在的 OpenClaw 是个拥有极高自主性的 Agent。它最大的卖点是遇到问题会自动尝试解决。这在写写单纯的文本时是个优点,但在复杂的现代前端工程里,这就是个彻头彻尾的灾难🤔。

当一个病毒遇到权限阻断时,它会疯狂尝试提权、扫描端口、注入进程。 那 OpenClaw 遇到前端编译报错时会干嘛?

它会像一个极其鲁莽的瞎子:

  • 它发现 pnpm install 报错了,它不会去思考是不是内网镜像源挂了,而是自作主张把它换成 npm,瞬间摧毁你精心维护的 Monorepo 幽灵依赖机制(symlink)。
  • 它发现有个类型找不到,它不会去查 .d.ts 声明,而是极其粗暴地去改你 node_modules 里的源码,或者给你全剧加上 @ts-ignore
  • 如果遇到文件死锁,它甚至敢在终端里直接替你敲下 rm -rf

病毒搞破坏是为了勒索你,而 OpenClaw 把你的系统搞崩溃,仅仅是因为它想完成你那句帮我跑通编译。

后台静默执行

做了 9 年研发,我看过无数次因为一行配置写错导致的线上 P0 级事故。所以越是资深的工程师,越在乎执行边界。

我们为什么需要 Git?为什么需要 Code Review?为什么 CI/CD 要分发不同的环境权限? 因为我们要清楚地知道,谁在什么时候,动了哪行代码,引发了什么后果。

但 OpenClaw 打破了这个铁律。它是一个跑在你电脑或者服务器上的巨大黑盒。

当你让它接管你的工作流时,你根本不知道它为了完成目标,在后台下载了多少个包含潜在风险的三方库。你也不知道它在调用工具链的时候,有没有把你的环境变量、甚至包含数据库密码的 .env 文件当成上下文,明文发送给了大模型的 API 服务器。

传统病毒窃取你的隐私,至少还会偷偷摸摸加个壳。而 OpenClaw 是你亲手把服务器的 Root 权限双手奉上🙌。


一段真实的案例

为了让大家更直观地感受这种病毒式操作。我复盘了一下昨天它为了修复一个构建错误,所做出的决策链路。

这是一个典型的高级前端遇到问题时的处理逻辑:

1. 发现 Webpack 构建报 chunk error 错误

screenshot-20260407-151125.png

2. 检查报错日志,发现是内存溢出 (OOM)

screenshot-20260407-151038.png

3.修改启动脚本,增加 Node 内存上限,完事🤷‍♂️

export NODE_OPTIONS="--max-old-space-size=4096"
npm run build

而 OpenClaw 在面对同一个报错时,它的终端执行记录:

# 1. 发现构建报错
npm run build 

# 2. 它看不懂 OOM,以为是依赖问题,执行了极其致命的操作
rm -rf node_modules
rm package-lock.json
npm install --legacy-peer-deps

# 3. 依赖彻底乱套,报了更多编译错误。它决定更换打包工具
npm install -g vite
# 强行在原本的 Webpack 项目里塞入一段残缺的 vite.config.js

# 4. 依然跑不通,它甚至去改了操作系统的环境😖😖😖
sudo chmod -R 777 ./ 

看到最后那个 chmod 777 的时候,我后背都在冒冷汗😢。 它为了解决一个权限微小的编译告警,直接把你整个项目的安全底裤都给扒了。这不是病毒是什么?


我想泼一盆冷水🫡

现在全网都在狂欢,各大社区都在比拼谁的 Agent 更聪明,谁能让电脑完全自己写代码、自己部署。

很多人沉迷于这种看着终端自己跳动代码的爽感中,觉得这就是未来。

但我不得不泼一盆冷水。在工程领域,不可控的自动化,比纯手工还要危险一万倍😒。

不管是 OpenClaw 还是未来更牛的智能体,只要它不具备真实世界的工程常识和后果承担能力(到目前为止都不可能为你背锅!!!),它就是一个披着 AI 外衣的高危病毒。

咱们在敲下回车之前,脑子里想的是:这会影响线上吗?会引发竞态问题吗?接手的同事能看懂吗? 而 Agent 脑子里只有计算概率:根据统计学,下达这个指令,满足用户当前 prompt 的概率哪个最大?它不在乎你的硬盘会不会被占满,不在乎你的生产环境会不会被污染。


所以,咱们这些在一线干活的兄弟们,清醒一点。

工具终究是工具,它可以帮你查 API,可以帮你写正则,可以帮你生成模版代码。但千万别把系统的控制权和架构的决策权,交给一个随时可能暴雷的 AI Agent

把危险关在沙盒里,让执行处于监控下。如果你做不到这一点,那你电脑里跑着的那个每天对你嘘寒问暖的 OpenClaw,真的比熊猫烧香还要可怕的。🤔

对此大家怎么看?

Suggestion.gif

移动端卡片边框怎么做高级?我用 CSS 实现了设计师的刁钻要求

2026年4月7日 14:49

移动端卡片边框怎么做高级?我用 CSS 实现了设计师的刁钻要求

一个让产品经理和设计师都满意的卡片边框方案

📖 前言

上周设计突然甩过来一张图,问我能不能不切图做出这种效果?

image.png

我蒙了一下第一反应感觉可以,无非就是常规的伪类+渐变。但尝试了一下发现两个致命问题:

1、border-image支持渐变但不支持每条边自定义设置;

2、使用伪类可以解决线的问题但是不能解决圆角问题;

忙乎半天又问了问ai感觉还是实现起来不容易,但随后产品过来又是那老一套。拿着别人家的产品看人家这个如何好看,如何优雅,巴拉巴拉。大有一种:

别人能做,你做不了。

这是不能接受的,于是又潜心研究了下,有了最后的效果。

🎯 需求拆解

先梳理一下具体需求:

需求 描述
位置 卡片左下角 L 形(左边 + 底边)
渐变 左下角颜色最深,向两端渐淡
粗细 视觉上 1px
长度 左边和底边长度大致相等
圆角 适配卡片 20px 圆角
性能 纯 CSS,无图片,无 SVG

看起来简单,做起来全是坑。

🧪 方案探索

方案一:两个伪元素分别画线

最直观的想法:用 ::before 画底部线,::after 画左边线。

scss

.card {
  &::before {
    // 底部线
    background: linear-gradient(90deg, gold, transparent);
  }
  &::after {
    // 左边线
    background: linear-gradient(0deg, gold, transparent);
  }
}

问题:两条线在圆角处有接缝,怎么都对不齐。调整了半天,还是能看到明显的拼接痕迹。

结论:放弃,圆角处无法完美衔接。


方案二:SVG 路径描边

SVG 可以精确控制路径和圆角,效果确实完美。

问题

  • 需要额外 HTML 结构
  • 移动端多一个网络请求或内联代码
  • 响应式适配需要额外处理

结论:能用,但不够优雅,性能也不够极致。


方案三:border-image + 渐变

scss

border-image: radial-gradient(circle at bottom left, gold, transparent) 1;

问题border-image 会覆盖四边,无法只控制左下角。

结论:放弃。


方案四:radial-gradient + mask(最终方案)

经过多次尝试,我发现径向渐变的圆心在左下角时,渐变会自然地向左和向上扩散,形成完美的 L 形。

配合 mask 组合,可以精确控制只显示边框区域,而不是整个渐变圆。

完美解决所有问题!

💻 最终代码

以下是基于vue2的一个组件CornerGradientCard,开箱即用。

但注意基于他的点击事件要使用click.native!!!

<template>
   <div
        :class="`gradient-wrapper ${type} `"
        :style="wrapperStyle"
   >
        <div
            class="gradient-wrapper__content"
            :style="{ borderRadius: radiusRem }"
        >
            <slot></slot>
        </div>
    </div>
  </template>

  <script>
  /** 与 postcss.config.js 中非 vant 资源的 rootValue(75) 一致,设计稿 px → rem */
  const POSTCSS_ROOT_VALUE = 75
  function pxToRem(px) {
    const n = Number(px)
    if (Number.isNaN(n)) return '0rem'
    return `${parseFloat((n / POSTCSS_ROOT_VALUE).toFixed(10))}rem`
  }

  export default {
    props: {
      type: {
        default: '',
        type: String
      },
      radius: {
        default: 12,
        type: Number
      },
      marginBottom: {
        default: 14,
        type: Number
      }
    },
    computed: {
      radiusRem() {
        return pxToRem(this.radius)
      },
      wrapperStyle() {
        const r = this.radiusRem
        return {
          borderRadius: r,
          marginBottom: pxToRem(this.marginBottom),
          '--corner-radius': r
        }
      }
    }
  }
  </script>

  <style lang="scss" scoped>
    $gradient-first-percent: 4%;    // 第一个实色节点百分比
    $gradient-second-percent: 10%;  // 第二个半透明节点百分比
    $gradient-transparent-percent: 30%; // 透明节点百分比

    .gradient-wrapper {
        width: 100%;
        position: relative;
        padding: 0 0 1px 1px;
        box-sizing: border-box;
        background-color: #fff;
        overflow: hidden;
        -webkit-backface-visibility: hidden;
        backface-visibility: hidden;

        &__content {
            width: 100%;
            position: relative;
            z-index: 3;
            margin: 0 0 1px 1px;
            box-sizing: border-box;
            overflow: hidden;
            background-color: transparent;
        }

        // 渐变边框线(核心)
        &::after {
            content: '';
            position: absolute;
            z-index: 2;
            bottom: 0;
            left: 0;
            width: 100%;
            height: 100%;
            border-radius: var(--corner-radius);
            pointer-events: none;
            mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
            mask-composite: exclude;
            -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
            -webkit-mask-composite: xor;
            padding: 1px;
        }

        // 渐变底色(光晕效果)
        &::before {
            content: '';
            position: absolute;
            z-index: 1;
            bottom: 1px;
            left: 1px;
            width: 143px;
            height: 73px;
            border-radius: var(--corner-radius);
            filter: blur(10px);
            pointer-events: none;
        }

        &.WX {
            &::after {
                background: radial-gradient(
                circle at bottom left,
                #B6E2C8  $gradient-first-percent,
                #DFF7EA $gradient-second-percent,
                transparent $gradient-transparent-percent
                );
            }
            &::before {
                background: radial-gradient( 83% 83% at 31% 52%, #F0FBF5 0%, rgba(239,255,246,0) 100%);
            }
        }
}
  </style>

 <CornerGradientCard
    v-for="(item, index) in infoData"
    :key="item.id"
    :id="item.id"
    :type="item.type"
    @click.native="clickItem(item)"
>
    <!-- 卡片内容 -->
</CornerGradientCard>
    

🎨 参数调节指南

参数 位置 作用 移动端建议
padding: 1px .wrapper 边框粗细 保持 1px
4% / 10% / 30% 径向渐变 边框长度 根据卡片大小调整
blur(10px) 光晕 柔和度 移动端 8-12px 较佳
border-radius 全局 圆角 与设计稿保持一致

当然,基于此样式还可以可发出各种变种,例如将渐变等放到常规的右上角,替代常规的卡片标签展示样式。

如果这篇文章对你有帮助,烦请动动发财的小手点个赞~

这个 GitHub 项目很有意思啊,解了死磕30 年的前端难题。

作者 逛逛GitHub
2026年4月8日 14:05

最近前端圈被一个叫 Pretext 的开源项目刷屏了。

它的作者是前 React 核心开发者,之前做过 react-motion 那个 21.9K Star 的项目。

这次他搞了个新东西,发布 3 天 Star 数就超过了 react-motion。

推文 1600 万浏览、6.4 万赞,X 上相关讨论帖子超 6.8 万条。

现在热度开始蔓延到国内了。

我花时间研究了一下,确实挺顶的。

01、开源项目简介

Pretext 是一个纯 JavaScript/TypeScript 的多行文本测量与布局库。

说直白一点,它解决的问题是:

在不碰 DOM 的情况下,精确算出一段文字在给定宽度下会有多高。

先来看看效果,下面这个视频是我使用和这个库做的网页效果。

只能说,非常丝滑。

开源地址:https://github.com/chenglou/pretext

02、牛在哪里

多行文本灵活布局库,听起来好像不是什么大事。

但这个问题在 Web 开发里已经存在 30 年了。

之前想测文字高度,只能靠 getBoundingClientRect 或者 offsetHeight。

但这些操作会强制浏览器重新计算整个页面布局,代价非常大。

在虚拟滚动列表、聊天界面、瀑布流这种需要频繁测量的场景里,性能直接拉胯。

Pretext 目前已经斩获 3.4 万+ Star,体积才 15KB。

这个项目能这么火不是没原因的,几个核心设计确实有意思。

两阶段架构,性能炸裂

Pretext 把文本测量拆成了两步:

第一步是 prepare(text, font), 负责分词、处理双向文本、用 Canvas measureText 测量每个片段的宽度并缓存。这一步相对重一些,500 段文本大概 19ms。

第二步是 layout(prepared, maxWidth, lineHeight), 基于缓存的宽度做纯算术运算,算换行后的总高度。这一步极轻,500 段文本才 0.09ms。

重点来了:窗口大小变化的时候,只需要重新跑第二步就行。

prepare 的缓存还在,直接用算术就能算出新高度。

比传统 DOM 测量快了 200 倍以上。

全语言支持,准确率 100%

这个库不是只测英文字母的。中文、日文、韩文啥的全都能处理。

而且在 Chrome、Safari、Firefox 三个浏览器上的准确率都是 7680/7680,也就是 100%。

做全语言文本渲染的人都知道这有多难。

所以一经发布,Star 数量就蹭蹭的涨。

03、两种使用场景

一个是预测文本高度。

不需要碰 DOM 就能知道文字多高,虚拟滚动列表、瀑布流布局、聊天气泡 shrink-wrap、防止布局偏移,这些场景都能用上。

二是手动逐行布局。

可以拿到每行文字的精确坐标和内容,然后渲染到 Canvas、SVG、WebGL 上,甚至可以实现文字绕障碍物流动这种高级效果。

你最近刷到的流体烟雾 ASCII 艺术、摄像头追踪人脸做文字避让、物理球碰撞改变文字排列、Mario 游戏 ASCII 文字版,各种花活都有。

这是一个 AI 辅助开发的标杆案例。

开发者 Cheng Lou 在开发过程中大量使用了 Claude Code 和 Codex,让 AI 在几十种容器宽度下对比浏览器的真实渲染结果,然后自动修正差异。

Hacker News 上很多人专门聊这一点,说这是 AI 编程的完美范例。

Simon Willison 也专门写博文推荐,特别称赞了它的测试方法,用整本了不起的盖茨比做跨浏览器对比。

04、部署与使用

安装很简单:

npm install @chenglou/pretext

或者用 bun:

bun add @chenglou/pretext

基础用法:预测文本高度

import { prepare, layout } from '@chenglou/pretext'

const prepared = prepare('AGI 春天到了.             🚀', '16px Inter')
const { height, lineCount } = layout(prepared, textWidth, 20)
// height 就是精确的像素高度,全程没碰 DOM

高级用法:手动逐行布局

import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext'

const prepared = prepareWithSegments('一些文本', '18px "Helvetica Neue"')
const { lines } = layoutWithLines(prepared, 320, 26)
for (let i = 0; i < lines.length; i++) {
  ctx.fillText(lines[i].text, 0, i * 26)
}

本地跑 Demo

git clone https://github.com/chenglou/pretext.git
cd pretext
bun install
bun start

然后浏览器打开 http://127.0.0.1:3000/demos 就能看到了。

这个不碰 DOM 就能预测文本高度的能力。

虚拟滚动、瀑布流、聊天 UI,这些天天在用的东西,以前要么牺牲性能去量,要么用估算值凑合。

现在有个 15KB、零依赖、100% 准确率的方案摆在这里,拿来就能用。

你的JS代码总在半夜崩溃?TypeScript来“上保险”了

作者 kyriewen
2026年4月8日 13:50

你有没有经历过:凌晨三点,线上报“Cannot read property 'name' of undefined”,你爬起来一看,原来是后端返回的数据少了一层。如果JS有“类型检查”,这种悲剧根本不会发生。今天我们就来认识TypeScript——给JavaScript买了一份“意外险”。

前言

JavaScript就像个自由散漫的天才:你给它一个字符串,它当数字用;你忘记传参数,它给你个undefined;你访问对象不存在的属性,它笑眯眯地说“没事,我给你undefined”。这种灵活在小型项目里很爽,但项目一大,就成了噩梦。

TypeScript(简称TS)就是来解决这个问题的。它给JS加上了类型系统,在代码运行之前就帮你检查类型错误。就像给代码装了安检门,不规范的写法根本过不去。

一、TypeScript是啥?JS的“严格模式”Pro Max

TypeScript是微软开发的开源语言,它是JavaScript的超集。意思是:所有合法的JS代码,在TS里也合法。TS只是给JS加了类型注解和一些新特性,然后编译成干净的JS。

// JS写法
function greet(name) {
  return 'Hello, ' + name;
}

// TS写法(加了类型)
function greet(name: string): string {
  return 'Hello, ' + name;
}

greet(123); // ❌ 报错:参数不能是数字

你看,TS在编译阶段就抓住了错误,不用等到运行时。

二、为什么要用TS?三个字:稳、爽、香

  • :类型错误在写代码时就暴露,而不是在用户手里炸。
  • :编辑器智能提示飞起,不用记方法名、参数顺序。
  • :代码即文档,看函数签名就知道怎么用。

据统计,使用TS的项目,早期Bug能减少15%~25%。对于中大型项目,TS几乎是标配。

三、基础类型:TS的“基本词汇”

TS支持JS的所有类型,还加了一些新的。

1. 原始类型

let name: string = '张三';
let age: number = 18;
let isStudent: boolean = true;
let nothing: null = null;
let notDefined: undefined = undefined;
let big: bigint = 100n;
let sym: symbol = Symbol('id');

2. 数组

let list1: number[] = [1, 2, 3];
let list2: Array<string> = ['a', 'b'];  // 泛型写法

3. 元组(固定长度和类型的数组)

let person: [string, number] = ['张三', 18];
person[0] = '李四';  // OK
person[1] = '20';   // ❌ 报错,第二个元素必须是数字

4. 枚举(给一组数字起名字)

enum Color { Red, Green, Blue }
let c: Color = Color.Red;
console.log(c); // 0(默认从0开始)

// 自定义值
enum Status { Success = 200, NotFound = 404 }

5. Any(万能类型,慎用)

let notSure: any = 4;
notSure = '字符串';  // OK
notSure = true;      // OK

any会关闭类型检查,相当于回到JS。尽量少用,除非你确定这个值无法预知类型。

6. Unknown(安全的Any)

let value: unknown = 'hello';
value = 123;  // OK
// console.log(value.toUpperCase()); // ❌ 报错,unknown不能直接调用方法
if (typeof value === 'string') {
  console.log(value.toUpperCase()); // 类型收窄后可用
}

unknownany安全,因为使用前必须先判断类型。

7. Void(没有返回值)

function warnUser(): void {
  console.log('警告');
}
// 变量声明为void类型只能赋值为null或undefined(strict模式下只能undefined)

8. Never(永远不会发生的类型)

function error(message: string): never {
  throw new Error(message);
}

function infiniteLoop(): never {
  while (true) {}
}

四、类型注解:给变量贴标签

TS的核心就是类型注解:在变量、函数参数、返回值后面加上: 类型

let myName: string = '张三';
function add(a: number, b: number): number {
  return a + b;
}

但TS很智能,很多时候可以类型推断,不用显式写:

let age = 18; // TS自动推断为number
age = '18';   // ❌ 报错

五、接口(Interface):定义对象的形状

接口是TS里最常用的功能,用来描述对象的结构。

interface Person {
  name: string;
  age: number;
  email?: string;  // 可选属性
  readonly id: number; // 只读属性
}

const zhangsan: Person = {
  name: '张三',
  age: 18,
  id: 1
};
zhangsan.id = 2; // ❌ 报错,只读属性不能改

接口还可以描述函数类型:

interface AddFunc {
  (a: number, b: number): number;
}
const add: AddFunc = (x, y) => x + y;

六、类型别名(Type):给类型起外号

类型别名和接口很像,但能表示联合类型、元组等更复杂的类型。

type ID = string | number;  // 联合类型
type Point = [number, number]; // 元组
type Callback = (data: string) => void;

let userId: ID = 123;
userId = 'abc';

接口 vs 类型别名

  • 接口可以扩展(extends),类型别名用交叉(&)。
  • 接口可以重复定义自动合并,类型别名不能重复。
  • 推荐优先用接口描述对象,用类型别名描述联合、元组等。

七、实战:用TS写一个简单的函数

// 需求:格式化用户信息
interface User {
  name: string;
  age: number;
  address?: string;
}

function formatUser(user: User, withAddress: boolean = false): string {
  let base = `${user.name}, ${user.age}岁`;
  if (withAddress && user.address) {
    base += `, 地址:${user.address}`;
  }
  return base;
}

const u: User = { name: '李四', age: 20, address: '北京' };
console.log(formatUser(u, true)); // "李四, 20岁, 地址:北京"

如果你在编辑器里打formatUser(,它会提示参数类型和返回值类型,爽不爽?

八、常见坑点与建议

  1. 不要滥用any:any越多,TS的价值越低。实在不知道类型,先写unknown
  2. 严格模式:开启strict: true(tsconfig.json),让TS更严格地检查。
  3. 第三方库:大多数库都有@types/xxx类型定义,安装后就能获得智能提示。
  4. 编译后的JS:TS只负责编译时检查,运行时还是JS,类型信息会被擦除。

九、总结:TS不是敌人,是保镖

  • 给JS加上类型,提前发现错误。
  • 基础类型、接口、类型别名是核心工具。
  • 用好类型推断,少写冗余注解。
  • 逐步迁移老项目,从.js改成.ts,开启allowJs: true

学TS并不难,你只需要把“写JS时的心理预期”明确写出来。明天我们继续深入TypeScript,聊聊高级类型——泛型、联合类型、交叉类型、类型保护,让你写出更灵活更安全的代码。

如果你觉得今天的“保险课”够实在,点个赞让更多人看到。我们明天见!

还原设计稿生成前端代码

作者 WebGirl
2026年4月8日 13:29

VScode插件【GitHub Copilot】+ Figma MCP还原设计稿生成前端代码

Cursor+Figma MCP的教程已经很多了,由于我用的vscode中的GitHub Copilot ,研究了一下直接在vscode里利用GitHub Copilot接入Figma MCP进行设计稿还原代码,大获成功,记录~

step1.方式1:让AI给你配置MCP

在vscode中打开项目,呼出github copilot 对话框,模式选择Agent,模型我用的是GPT-5.4,输入对话内容:

https://github.com/GLips/Figma-Context-MCP 如何配置能让你在vscode里使用这个mcp

之后跟着提示狂点下一步即可完成配置,如果有什么需要装的vscode插件它会自动帮你装,甚至自动生成了配置说明文档。

Figma-Context-MCP是一个为AI编程工具(如 Cursor, Windsurf)和大型语言模型(LLM)搭建的“桥梁”或“通用适配器”。它能自动读取Figma设计文件里的布局、样式和层级信息,转换成AI容易理解的结构化数据,让AI在写代码时能真正“看懂”设计稿,从而大幅提升从设计到代码的转换效率和还原度

mcp.json

{
"servers": {
"framelinkFigma": {
"type": "stdio",
"command": "npx",
"args": [
"-y",
"figma-developer-mcp",
"--stdio"
],
"env": {
"FIGMA_API_KEY": "${input:figma-token}"
}
},
"com.figma.mcp/mcp": {
"type": "http",
"url": "https://mcp.figma.com/mcp",
"gallery": "https://api.mcp.github.com",
"version": "1.0.3"
}
},
"inputs": [
{
"type": "promptString",
"id": "figma-token",
"description": "Figma Personal Access Token",
"password": true
}
]
}

step1.方式2:当然也可以手动安装插件完成配置

  • 在VS Code Copilot中设置对应的MCP配置

首先确保MCP发现的功能是开着的,在VS Code中打开设置(Ctrl+,或者Cmd+,), 输入chat.mcp确认Discovery是Enabled.

image.png

  • 在extentions中输入@mcp figma可以找到官方对于figma的访问支持

image.png

注意其中的Install是安装到VSCode 的整体目录下, Install in Workspace是安装到当前项目下,可以根据您的情况选择,建议选择Install in Workspace

选择Install in Workspace之后可以在当前项目.vscode/mcp.json下看到具体的Figma配置(也可以忽略上述的步骤,自己直接新建mcp.json文件然后输入详细的配置)

// 默认生成的,当前不可用
"com.figma.mcp/mcp": {
    "type": "http",
    "url": "https://mcp.figma.com/mcp",
    "gallery": "https://api.mcp.github.com",
    "version": "1.0.3"
}

注意这个是默认生成的配置,截止笔者发稿时,这个配置不可用,会报错。将协议和url改为sse依然不可用。需要改为如下的stdio的配置

//改为stdio格式,当前可用
"figma": {
    "command": "npx",
    "args": [
        "-y",
        "figma-developer-mcp",
        "--stdio"
    ],
    env": {
        "FIGMA_API_KEY": "您的Figma API Token"
    },
    "type": "stdio"
},

配置之后就可以在工具中具体看到Figma了

step2:替换自己的figma密钥

打开Figma的网页点击左上角自己的头像 -> settings -> Security -> Generate new token 设置路径可能会有变化,自己到处点点找到Generate new token就对了 找到点击之后会出现下面这个弹窗,随便起个名字比如mcp,然后把下面的权限列表一个个打开选择读或写,要不然默认是全部No access的。 注意默认是30天过期,30天后需要建一个新的才能继续用。

image.png

都选完之后点右下角的generate token之后会生成一个密钥,这是你唯一一次复制它的机会,没复制好就关掉窗口了就只能重新建了。把这个密钥复制到mcp.json文件中–figma-api-key=后面。

保存后,VS Code 一般会提示你启动或信任这个 MCP 服务。

如果没有自动启动,用命令面板执行(command + shift + p):

  • MCP: List Servers
  • 选中 framelinkFigma
  • Start 或 Restart
  • 如果提示 Trust,确认信任

第一次启动这个MCP服务,会提示你输入token,将刚保存好的figma的token复制到相应位置即可。

配置成功后,你在 Copilot Chat 里直接发:

  • 实现这个 Figma 链接对应的页面
  • 后面附上 Figma 的 frame 或 node 链接 如果 MCP 正常,我就能调用它读取设计数据,而不是只能看截图。

step3: 如何使用

在Figma设计图上选中你要的部分图层,右键后点击Copy link to selection

image.png

之后就可以把链接贴到对话框了,先来测试一下配置是否成功了,确保模式是Agent,提问:

https://www.figma.com/design/GJZhGih0VsGbpevJGkJQ9Z/E-commerce-UI—Figma-Ecommerce-UI-Kit–Demo-Version—Community-?node-id=2804-7985&m=dev 现在你能读到这个设计图了吗

image.png

出现这样的弹窗说明Agent在尝试链接MCP server了,点继续(也可以点击右边的箭头在当前会话中允许操作就不用每次都手动点了),过一会儿可以看到它的描述,说明设计图被读到了,我们的配置生效了。

读取成功后,可以让他写代码了:

请根据这个设计图在我的微信小程序里生成商品卡片组件的代码,注意微信小程序中2rpx=1px,要完全还原设计图的UI,再建一个测试页面展示这个组件的调用效果,可以参考微信小程序官方文档https://developers.weixin.qq.com/miniprogram/dev/api/

对比设计图,指出哪里还原度不够,让它进一步优化,客客气气的。

看上去有一些UI细节不够还原,比如卡片的内边距,还有按钮的布局,请你再仔细检查一下。
商品图片上的三个icon按钮应该是水平居中的,learn more按钮应该是水平居左的。另外你能不能直接下载设计图里的icon为svg来使用,这样更还原。
❌
❌