阅读视图

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

把一份前端 checklist 变成 AI 的 Skill:让 CR 不再靠记忆

引子:一个吃灰三年的项目被重新盘活

写这篇博客的由头有点特别。

我有一个叫 front-end-checklist 的老项目(网页:wsafight.github.io/front-end-c…),2023 年初在公司做 Code Review 的时候顺手整理出来的。那会儿评审新同学的代码,总是在重复同样的话:"这里没做 XSS 转义"、"useEffect 里有竞态"、"label 没和 input 关联"。后来干脆把这些反复出现的问题写成清单,用 Jekyll 挂在 GitHub Pages 上。

然后它就一直在那儿积灰。2024 只有一次提交,2025 只有一次,到了 2026 年 5 月也还没动过。

2026-05-08 晚上,我本来只想顺手改一下清单里过时的条目,结果一头扎进 Claude Code,两小时做了 22 次提交,把这个静态页面彻底改造了一遍。

真正让我想写这篇文章的,不是"AI 让我写代码变快"。快是肯定快,但这次改造最后落到了一件我之前没想过的事情上——一份静态文档,换了个交付形式,才真正开始被用起来。

清单本身:三年沉淀下来的 160 条检查项

先交代清楚这个清单是什么。

它不是 ESLint 能查的那种风格规则,而是 Code Review 时需要结合业务和上下文判断的问题。目前一共 25 个分组、160 条检查项:

命名规范      数据与类型    函数设计      状态管理      控制流
异步处理      数据请求      UI 与渲染     样式与响应式  路由与权限
性能          安全与健壮性  表单与交互    错误处理      测试
无障碍访问    用户体验      代码质量      工程化        国际化
日志与监控    依赖管理      浏览器兼容    文档与协作    PR 自检

随便挑几条看看:

  • 区分"缺失"、"为空"、"为 0"、"为空数组"、"为空字符串"的业务含义
  • 处理并发请求的竞态问题,避免旧响应覆盖新状态
  • 同一表单/输入控件不要在受控与非受控之间切换
  • useEffect / watch 的依赖项必须完整,避免闭包捕获过期值
  • 定时器和事件监听记得清除,否则可能引发内存泄漏
  • 表单错误应定位到具体字段,而不是只给出笼统提示

这些不是靠格式化、类型推导或者简单 AST 规则就能稳定抓出来的问题。它们往往来自真实线上事故、返工、误解和交接成本。以前这些经验只能靠人去记,记不住就会在别的项目里重新踩一次。

痛点:清单挂在网上,但不会进入工作流

清单做完这三年,我一直有个遗憾:没人真的会去翻

我自己都不翻。做一个 PR 的时候,我知道清单里有条"处理并发请求的竞态",但我会不会每次都老老实实打开页面对一遍?不会。同事更不会。新人入职时我会把链接丢给他们,他们收藏,然后就再也不打开了。

这是很多静态文档共同的问题:信息在那里,但你必须主动去触达它。而在 CR 场景里,主动触达的前提是你已经意识到自己可能漏了什么。可真正漏掉的时候,人往往意识不到。

我试过一些办法:

  • 写成 Markdown 放 README:没人会在写代码时切到 README 里逐条对照。
  • 做成可搜索的网页:搜索的前提是你已经知道关键词,可 XSS、竞态、状态错位这类问题,经常就是因为你没意识到该搜什么。
  • 加上勾选和进度追踪:这次改造时我反而把它们删了,因为它把"查阅文档"变成了"填表",使用意愿更低。

根本问题不是文档形式不够花哨,而是查阅式文档很难主动进入人的工作流。

转折:把清单变成 AI 的上下文

Claude Code 的 Skill 机制改变了这件事。

简单说,Skill 是一段给 AI 读的说明书,加上它执行任务时需要参考的资料。当你在对话里说到特定触发词时,AI 会加载这个 Skill,并按说明书走流程。

我的 frontend-checklist Skill 里写的是这样的逻辑:

  1. 只在用户明确请求时触发:比如 /frontend-checklist、"按前端清单 review"、"按 checklist 检查这段代码"。用户没提,它不会自作主张去扫代码。
  2. 确认检查范围:如果用户指定了文件,就只看指定文件;如果说 "review PR",就用 git diff 定位变更;如果都没有,就先确认范围,不盲目扫整个仓库。
  3. 按语言选择清单:中文提问读中文版清单,英文提问读英文版清单。
  4. 只报命中的问题:通过和不适用的条目一律不输出。
  5. 按严重度排序:安全、数据丢失、竞态、内存泄漏这类硬问题优先,命名风格靠后。
  6. 每个问题都标出文件路径和行号:让人能直接跳到具体位置。

关键的翻转在这里:

以前是"你去翻清单"。现在是"清单来找你"。

写 PR 的时候,你不需要记得清单里每一条是什么,直接在 IDE 里说一句"按前端清单 review 我这个 PR",AI 会把 160 条和当前 diff 放在一起看,只告诉你命中的那些,并给出文件路径和行号,必要时附 1-3 行示例代码。心智负担从"记住 160 条"降到"知道有这么一个入口"。

一个真实跑出来的 review

空讲没意思,看一段 showcase 里的实际输出。

下面这段代码是我写的一个有意设计的"坏例子"(showcase/cases/01-xss/bad.tsx),一个评论列表组件:

export function CommentList(props: any) {
  const [list, setList] = useState([] as any);
  const [keyword, setKeyword] = useState('');

  useEffect(() => {
    fetch('/api/comments?topic=' + props.topic)
      .then((r) => r.json())
      .then((d) => {
        setList(d.data);
      });
  }, [props.topic]);

  const highlight = (text, kw) => {
    if (!kw) return text;
    return text.replace(new RegExp(kw, 'g'), '<mark>' + kw + '</mark>');
  };

  return (
    <div>
      <input type="text" onChange={onSearch} />
      <div id="tip" dangerouslySetInnerHTML={{ __html: props.tip }} />
      {list.map((c: Comment, i: number) => (
        <div key={i} className="comment">
          <img src={c.avatar} />
          <a href={'javascript:void(0)'} onClick={() => eval(c.author.onClick)}>
            {c.author.name}
          </a>
          <div dangerouslySetInnerHTML={{ __html: highlight(c.body, keyword) }} />
        </div>
      ))}
    </div>
  );
}

乍看能跑,TypeScript 不一定报错,ESLint 也不一定能拦住关键问题。但 Skill 跑完吐出来 11 条命中,挑几条看(行号对应 showcase/cases/01-xss/bad.tsx 原文件,不是上面代码块里的相对行号):

安全与健壮性(最严重的一批)

  • line 35dangerouslySetInnerHTML={{ __html: props.tip }} 直接吃外部 tip,存在 XSS 风险。建议默认文本渲染,真要富文本时先用 DOMPurify 消毒。
  • line 44highlight 用字符串拼接 HTML 再 dangerouslySetInnerHTMLbodykeyword 都没转义。建议改成 String.split 分段渲染,把命中段包进 <mark>,不要注入 HTML。
  • line 39eval(c.author.onClick) 把接口返回的字符串当代码执行,等于把任意脚本执行权交给接口数据。建议彻底删除,交互改成前端静态映射。

数据请求

  • line 14-20props.topic 变化时发起新请求但没取消旧请求,旧响应可能覆盖新数据。建议用 AbortControllerignore 标志在 cleanup 里关掉。

UI 与渲染

  • line 37key={i} 用数组下标,列表增删重排时可能导致状态错位。建议用稳定的业务 id,比如 c.id

无障碍

  • line 34, 38:搜索 input 没有 aria-label 或关联 label<img> 没有 alt

泛泛地让 AI 做 review,它很容易给出"结构清晰、建议补充错误处理"这类通用意见。扔给 ESLint,也大概率只会在 any、未定义变量、hook 依赖这类规则上发声。Skill 的价值在于把评审标准显式化:AI 不是凭感觉聊几句,而是按一把真实的尺子在量代码。

我还拿其他 showcase 跑了一遍:用户资料页命中 12 条(竞态、未清理副作用、无空值守卫),注册表单命中 17 条(a11y、字段级错误、密码明文 input、防重复提交)。这些都是一眼看过去"差不多能用",但上线后很容易变成坑的代码。

怎么用

Skill 装起来一条命令的事:

curl -fsSL https://github.com/wsafight/front-end-checklist/releases/latest/download/install.sh \
  | sh -s -- claude

装完在对话里说 /frontend-checklist 或"按前端清单 review 我这个 PR",就会触发它。也支持 Kiro / Cursor / Codex,把命令结尾换一下就行。

清单是中英双语的,AI 会根据你提问的语言自动选对应版本。


两小时改造一个老项目听起来像标题党,但实际发生的事情比这更有意思:一份躺了三年没人翻的清单,换了个交付形式,突然就活过来了。内容还是那 160 条,变的只是"它怎么到达读者"。

如果你手里也有这种"明明有价值但没人用"的老文档,值得花个晚上,把它接进 AI 看看。

— 2026-05-09 夜

你还在手动敲命令部署?GitHub Actions 让你 push 即上线,摸鱼时间翻倍

你改完代码,打开终端,输入 npm run build,然后 FTP 上传,或者登录服务器 git pull。这一套操作每天重复 N 次,不累吗?今天我们来把“部署”这件事自动化——用 GitHub Actions,只要你 git push,代码自动测试、自动打包、自动发到服务器。以后你只管写代码,上线交给机器人。

前言

我见过太多团队还停留在“手工部署”时代:上线先发个群消息“我要部署了,大家别动”,然后手动打包、上传、解压、重启。万一忘了执行某个步骤,线上就挂了。

GitHub Actions 就是你的免费 DevOps 机器人。它能监听 GitHub 上的事件(push、pull request、issue),然后执行你写好的自动化脚本。我们只需要写一个 YAML 文件,放在 .github/workflows 目录下,剩下的全部自动。

今天我们就来写一个完整的工作流:当推送到 main 分支时,自动运行测试、构建、并部署到服务器(或 Vercel / 阿里云 OSS)。全程保姆级,复制粘贴就能用。

一、准备工作:你需要什么?

  • GitHub 仓库(私有或公开都可以)。
  • 一台服务器(或云存储,如阿里云 OSS、Vercel)。
  • 如果部署到自己的服务器,需要服务器的 SSH 密钥(免密登录)。

如果你没有服务器,可以用 Vercel(个人项目免费,连 GitHub 自动部署也是免费的,甚至不需要写 Actions——但为了教学,我们还是会演示自定义部署到服务器的流程)。

二、基础工作流:跑测试 + 打包

在项目根目录创建 .github/workflows/deploy.yml

name: CI/CD Pipeline

on:
  push:
    branches: [ main ]   # 当推送到 main 分支时触发
  pull_request:
    branches: [ main ]   # PR 时也跑测试,但不部署

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: 检出代码
        uses: actions/checkout@v4

      - name: 安装 Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 18

      - name: 安装依赖
        run: npm ci

      - name: 运行测试
        run: npm test

  build:
    runs-on: ubuntu-latest
    needs: test   # 测试通过后才构建
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 18
      - run: npm ci
      - run: npm run build
      - name: 上传构建产物(给后续部署用)
        uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

提交这个文件后,每次 git push main,GitHub 就会自动跑测试和构建。你可以在仓库的 Actions 标签页看到实时日志。

三、部署到自己的服务器(通过 SSH)

deploy.yml 中增加一个 job,依赖 build,然后通过 SSH 把构建产物上传到服务器。

首先在 GitHub 仓库 Settings → Secrets and variables → Actions 中添加几个密钥:

  • SERVER_HOST:你的服务器 IP
  • SERVER_USERNAME:登录用户名(如 root、ubuntu)
  • SSH_PRIVATE_KEY:服务器的私钥内容(复制 ~/.ssh/id_rsa 整个内容)

然后在 deploy.yml 中添加:

  deploy:
    runs-on: ubuntu-latest
    needs: build
    if: github.event_name == 'push'   # 只有 push 时部署,PR 不部署
    steps:
      - name: 下载构建产物
        uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist

      - name: 通过 SSH 部署
        uses: easingthemes/ssh-deploy@main
        env:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          ARGS: "-rlgoDzvc -i --delete"
          SOURCE: "dist/"
          REMOTE_HOST: ${{ secrets.SERVER_HOST }}
          REMOTE_USER: ${{ secrets.SERVER_USERNAME }}
          TARGET: "/var/www/myapp/"   # 服务器上的目标目录

这样,每次 git push main,代码会自动出现在 /var/www/myapp 中。如果服务器上跑着 Nginx,刷新页面就是新版。

如果想要重启 PM2 进程,可以在部署步骤后加一个 exec 命令:

      - name: 重启 PM2 服务(如果后端是 Node)
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USERNAME }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /var/www/myapp
            pm2 restart myapp

四、部署到 Vercel(更简单)

如果你的项目是前端静态站点,Vercel 本身就是和 GitHub 集成的。但你也可以手动写 Actions 来调用 Vercel CLI。不过更推荐直接在 Vercel 网站导入 GitHub 仓库,它会自动监听 main 分支并部署,连 YAML 都不用写。

如果你坚持要用 Actions 调用 Vercel:

      - name: 部署到 Vercel
        uses: amondnet/vercel-action@v20
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.ORG_ID}}
          vercel-project-id: ${{ secrets.PROJECT_ID}}
          vercel-args: '--prod'

五、部署到阿里云 OSS(静态网站托管)

阿里云 OSS 支持静态网站。我们可以用 aliyun-cli 同步文件:

      - name: 安装阿里云 CLI
        run: npm install -g @alicloud/oss

      - name: 同步到 OSS
        run: |
          oss cp dist/ oss://my-bucket/ -r --force --access-key-id ${{ secrets.OSS_KEY_ID }} --access-key-secret ${{ secrets.OSS_KEY_SECRET }} --endpoint oss-cn-hangzhou.aliyuncs.com

六、进阶:分环境部署(dev/staging/prod)

你可以通过分支名来区分环境:

  • main 分支 → 生产环境
  • develop 分支 → 测试环境

on.push.branches 里可以写多个分支,然后在 job 里根据 github.ref_name 做判断,选择不同的服务器目录或环境变量。

七、常见坑点

  • 密钥泄露:永远不要把 SSH 私钥、密码明文写在代码里,要用 GitHub Secrets。
  • 构建产物太大upload-artifact 可能较慢,对于几 MB 的项目还好,几百 MB 建议直接推送到服务器或云存储。
  • 权限问题:确保服务器上目标目录有写入权限。
  • 缓存依赖:可以加 actions/cache 来缓存 node_modules,每次 build 快很多。

八、总结:让机器人替你干活

  • 写好 .github/workflows/deploy.yml,push 即触发。
  • 用 Secrets 存放敏感信息。
  • 可以串联测试、构建、部署,还能加个钉钉/飞书通知。

从此你只需要 git add . && git commit -m "fix: xxx" && git push,然后去倒杯水。回来群里就会收到:“生产环境部署成功,版本 v1.2.3”。不用记命令、不用等上传、不怕忘步骤。这才是现代前端该有的体验。

如果你觉得今天的“自动化”够解放双手,点个赞让更多人看到。评论区聊聊:你经历过哪些手工部署的惨案?

Pinia 状态管理

引言

在 Vue 3 项目中,状态管理是不可或缺的一部分。随着 Vuex 逐渐被 Pinia 取代,Pinia 凭借其更简洁的 API、更好的 TypeScript 支持和更轻量的体积,成为了 Vue 生态中的首选状态管理方案。本文将深入讲解 Pinia 的核心概念和实战技巧。

什么是 Pinia?

Pinia 是 Vue 官方推荐的状态管理库,可以看作是 Vuex 的继任者。它具有以下特点:

  • 轻量级:仅 1KB 大小(压缩后)
  • TypeScript 友好:完整的类型推断
  • 简洁 API:基于 Composition API 的设计
  • 模块化:天然支持代码分割
  • DevTools 支持:集成 Vue DevTools

核心概念

Store

Store 是 Pinia 的核心,用于存储和管理应用状态。每个 Store 都是独立的,可以单独使用。

State

State 是响应式的数据源,类似于 Vue 组件中的 data

Getters

Getters 用于计算派生状态,类似于 Vue 的 computed

Actions

Actions 用于处理业务逻辑,可以包含异步操作,类似于 Vue 组件中的 methods

快速上手

安装

npm install pinia

创建 Store

使用 defineStore 创建 Store:

// stores/counter.js
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  // State
  state: () => ({
    count: 0,
    name: 'Pinia'
  }),
  
  // Getters
  getters: {
    doubleCount: (state) => state.count * 2,
    fullName: (state) => `Hello, ${state.name}!`
  },
  
  // Actions
  actions: {
    increment() {
      this.count++
    },
    decrement() {
      this.count--
    },
    setCount(value) {
      this.count = value
    },
    async fetchCount() {
      const response = await fetch('/api/count')
      const data = await response.json()
      this.count = data.count
    }
  }
})

在组件中使用

<template>
  <div>
    <h1>{{ counterStore.fullName }}</h1>
    <p>Count: {{ counterStore.count }}</p>
    <p>Double: {{ counterStore.doubleCount }}</p>
    <button @click="counterStore.increment">+</button>
    <button @click="counterStore.decrement">-</button>
  </div>
</template>

<script setup>
import { useCounterStore } from '@/stores/counter'

const counterStore = useCounterStore()
</script>

高级用法

Store 持久化

使用 pinia-plugin-persistedstate 实现状态持久化:

npm install pinia-plugin-persistedstate
// main.js
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

app.use(pinia)
// stores/user.js
export const useUserStore = defineStore('user', {
  state: () => ({
    token: '',
    userInfo: null
  }),
  actions: {
    login(token, userInfo) {
      this.token = token
      this.userInfo = userInfo
    }
  },
  persist: true // 自动持久化到 localStorage
})

多个 Store 协作

// stores/cart.js
import { defineStore } from 'pinia'
import { useProductStore } from './product'

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: []
  }),
  actions: {
    addToProduct(productId, quantity) {
      const productStore = useProductStore()
      const product = productStore.getProductById(productId)
      
      const existingItem = this.items.find(item => item.id === productId)
      if (existingItem) {
        existingItem.quantity += quantity
      } else {
        this.items.push({
          id: productId,
          name: product.name,
          price: product.price,
          quantity
        })
      }
    },
    
    get totalPrice() {
      return this.items.reduce((sum, item) => {
        return sum + item.price * item.quantity
      }, 0)
    }
  }
})

组合 Store

// stores/composed.js
import { defineStore } from 'pinia'
import { useUserStore } from './user'
import { useCartStore } from './cart'

export const useCheckoutStore = defineStore('checkout', () => {
  const userStore = useUserStore()
  const cartStore = useCartStore()
  
  const canCheckout = computed(() => {
    return userStore.isLoggedIn && cartStore.items.length > 0
  })
  
  const checkout = async () => {
    if (!canCheckout.value) {
      throw new Error('Cannot checkout')
    }
    
    // 处理结账逻辑
    const result = await fetch('/api/checkout', {
      method: 'POST',
      body: JSON.stringify({
        userId: userStore.userInfo.id,
        items: cartStore.items
      })
    })
    
    return result.json()
  }
  
  return { canCheckout, checkout }
})

最佳实践

1. 合理的 Store 划分

按业务模块划分 Store,而不是按功能类型:

stores/
├── user.js      # 用户相关
├── product.js   # 商品相关
├── cart.js      # 购物车
└── order.js     # 订单

2. 使用 TypeScript

interface User {
  id: number
  name: string
  email: string
}

export const useUserStore = defineStore('user', {
  state: (): { user: User | null } => ({
    user: null
  }),
  actions: {
    setUser(user: User) {
      this.user = user
    }
  }
})

3. 避免直接修改 State

始终通过 Actions 修改状态:

// ❌ 不推荐
store.count = 100

// ✅ 推荐
store.setCount(100)

4. 使用 Getters 进行派生计算

// ✅ 推荐
getters: {
  activeUsers: (state) => state.users.filter(user => user.isActive)
}

// ❌ 不推荐
computed: {
  activeUsers() {
    return this.users.filter(user => user.isActive)
  }
}

总结

Pinia 以其简洁的 API、优秀的 TypeScript 支持和轻量的体积,成为了 Vue 3 项目状态管理的首选。通过合理的 Store 划分、类型安全和最佳实践,可以构建出可维护、可扩展的状态管理方案。

关键要点:

  • 使用 defineStore 创建 Store
  • State、Getters、Actions 三大核心
  • 支持持久化和多 Store 协作
  • 推荐按业务模块划分 Store
  • 充分利用 TypeScript 类型推断

Pinia 让 Vue 应用的状态管理变得更加简单和优雅!

Claude Code + Amazon Bedrock 使用指南

Claude Code + Amazon Bedrock 使用指南

一、Claude Code 是什么?

Claude Code 是 Anthropic 官方推出的 AI 编程 CLI 工具,可以:

  • 直接在终端中与 AI 对话,完成代码编写、调试、重构
  • 自动读取项目代码上下文,理解整个代码库
  • 执行 shell 命令、编辑文件、运行测试
  • 支持 VS Code / Cursor 等 IDE 集成

通过 Amazon Bedrock 接入,我们无需使用个人 Anthropic API Key,统一走公司 AWS 账号,费用由公司统一结算。

二、前置条件

在开始之前,请确认以下环境已准备就绪:

条件 要求 检查方式
Node.js >= 18.0 node --version
AWS CLI v2 aws --version
AWS 凭证 已配置 aws sts get-caller-identity
操作系统 macOS / Linux / Windows (WSL) -

2.1 安装 Node.js

如果尚未安装 Node.js,推荐使用 nvm

# macOS / Linux
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.zshrc  # 或 source ~/.bashrc
nvm install 22
nvm use 22

2.2 安装并配置 AWS CLI

# macOS
brew install awscli

# 验证安装
aws --version

2.3 配置 AWS 凭证

请联系 DevOps 团队获取你的 AWS Access Key,然后执行:

aws configure

按提示输入:

AWS Access Key ID: <你的 Access Key ID>
AWS Secret Access Key: <你的 Secret Access Key>
Default region name: us-east-1
Default output format: json

验证凭证是否生效:

aws sts get-caller-identity

如果返回了你的 Account ID 和 ARN,说明配置成功。

三、安装 Claude Code

# 全局安装
npm install -g @anthropic-ai/claude-code

# 验证安装
claude --version

如遇权限问题(macOS),可尝试:

sudo npm install -g @anthropic-ai/claude-code --unsafe-perm

四、配置 Bedrock 连接

4.1 创建配置文件

编辑(或创建)Claude Code 的配置文件 ~/.claude/settings.json

{
  "env": {
    "CLAUDE_CODE_USE_BEDROCK": "1",
    "ANTHROPIC_MODEL": "us.anthropic.claude-sonnet-4-6-v1",
    "AWS_REGION": "us-east-1",
    "AWS_PROFILE": "default",
    "DISABLE_AUTOUPDATE": "1"
  },
  "permissions": {
    "allow": [],
    "deny": []
  }
}

配置文件路径:

操作系统 路径
macOS / Linux ~/.claude/settings.json
Windows %USERPROFILE%.claude\settings.json

注意:如果文件已存在其他配置,请将 env 字段合并进去,不要覆盖已有内容。

4.2 跳过登录引导流程

Claude Code 首次启动会进入 Anthropic 的登录引导(Onboarding)流程。使用 Bedrock 时需要跳过此步骤。

创建(或编辑)文件 ~/.claude.json(注意:是用户主目录下的 .claude.json,不是 .claude/ 目录里的):

{
  "hasCompletedOnboarding": true
}

说明:设置 hasCompletedOnboardingtrue 后,Claude Code 启动时会跳过默认的 Anthropic OAuth 登录流程,直接使用 settings.json 中配置的 Bedrock 连接。

4.3 验证配置

修改完成后重新打开终端,然后执行:

claude --version

如果没有弹出登录提示,说明配置成功。

五、可用模型

公司 Bedrock 账号已开通以下 Claude 模型:

模型 Bedrock Model ID 适用场景 相对成本
Claude Sonnet 4.6 us.anthropic.claude-sonnet-4-6-v1 日常编码、代码审查、调试(推荐默认)
Claude Haiku 4.5 us.anthropic.claude-haiku-4-5-v1 快速问答、简单任务、节省成本 ★(最低)
Claude Opus 4.6 us.anthropic.claude-opus-4-6-v1 复杂架构设计、深度推理 ★★★(最高)

切换模型

# 方式一:启动时指定
claude --model us.anthropic.claude-opus-4-6-v1

# 方式二:对话中切换
# 输入 /model 命令选择模型

成本提醒:Opus 模型费用约为 Sonnet 的 5 倍,请根据任务复杂度合理选择。日常开发建议使用 Sonnet,简单查询使用 Haiku。

六、快速上手

6.1 启动 Claude Code

# 在项目根目录启动
cd your-project
claude

首次启动会显示欢迎信息,确认模型连接成功。

6.2 基本用法

# 让 AI 解释代码
> 解释一下 src/main/java/com/example/UserService.java 的主要逻辑

# 让 AI 写代码
> 帮我写一个用户注册的 REST API,使用 Spring Boot

# 让 AI 修 Bug
> 这个 NullPointerException 是什么原因?帮我修复

# 让 AI 重构
> 把这个方法拆分成更小的函数,遵循单一职责原则

# 运行命令
> 运行项目的单元测试并分析失败原因

6.3 IDE 集成

Claude Code 同时支持在 IDE 中使用:

VS Code:

  1. 安装扩展:搜索 "Claude Code" 并安装
  2. 打开命令面板(Cmd+Shift+P),输入 "Claude"
  3. 快捷键 Cmd+Esc 打开 Claude 面板

JetBrains(IntelliJ IDEA 等):

  1. Settings -> Plugins -> 搜索 "Claude Code" 并安装
  2. 重启 IDE
  3. 右侧工具栏会出现 Claude 图标

IDE 集成会自动继承终端中配置的环境变量(Bedrock 配置),无需额外设置。

七、常用技巧

7.1 让 AI 理解你的项目

在项目根目录创建 CLAUDE.md 文件,写入项目背景信息:

# 项目说明

- 这是一个 Spring Boot 微服务项目
- 使用 MyBatis 作为 ORM
- 数据库:MySQL 8.0
- 构建工具:Maven
- Java 版本:17

## 代码规范

- 遵循阿里巴巴 Java 开发手册
- Controller 层不写业务逻辑
- Service 层通过接口定义

Claude Code 每次启动时会自动读取该文件,确保 AI 理解项目上下文。

7.2 实用快捷键

快捷键 功能
Esc(连按两次) 退出 Claude Code
Cmd+C 中断当前 AI 响应
/help 查看所有可用命令
/clear 清空对话历史
/compact 压缩上下文(对话太长时使用)
Tab 自动补全文件路径

7.3 权限模式

Claude Code 在执行文件操作和 shell 命令时会请你确认:

  • 输入 y:允许本次操作
  • 输入 n:拒绝本次操作
  • 输入 !:本次会话中始终允许该类型操作

八、常见问题

Q1:报错 "Could not connect to Bedrock"

排查步骤:

# 1. 检查 AWS 凭证是否有效
aws sts get-caller-identity

# 2. 检查区域配置
echo $AWS_REGION

# 3. 检查 Bedrock 访问权限
aws bedrock list-foundation-models --region us-east-1 --query "modelSummaries[?contains(modelId, 'claude')]"

如果第 3 步报权限不足,请联系 DevOps 团队申请 Bedrock 模型访问权限。

Q2:报错 "Model not found" 或 "Access denied"

可能原因:

  • Bedrock 未开通对应模型 -> 联系 DevOps 开通
  • Region 不匹配 -> 确认 AWS_REGION 设置为 us-east-1
  • AWS 凭证过期 -> 重新执行 aws sso login

Q3:响应速度很慢

  • 切换到 Haiku 模型(更快、更便宜)
  • 检查网络连接,确保能访问 AWS
  • 使用 /compact 压缩过长的对话上下文

Q4:JSON 配置不生效

  • 确保 JSON 格式合法(可以用 jsonlint.com 校验)
  • 修改配置后必须重新打开终端
  • 检查配置文件路径是否正确

Q5:macOS 安装权限错误

# 方案一:使用 sudo
sudo npm install -g @anthropic-ai/claude-code --unsafe-perm

# 方案二:修改 npm 全局目录(推荐)
mkdir -p ~/.npm-global
npm config set prefix ~/.npm-global
echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.zshrc
source ~/.zshrc
npm install -g @anthropic-ai/claude-code

九、安全须知

  1. 不要在 Claude Code 对话中粘贴密码、Token 等敏感信息,AI 会将其作为上下文处理
  2. 不要让 AI 直接操作生产环境,所有生产操作请走正规发布流程
  3. 代码审查仍然必要,AI 生成的代码需要经过 Code Review 后才能合并
  4. 注意成本控制,避免无意义的长对话消耗 Token

十、获取帮助

场景 联系方式
AWS 凭证 / 权限问题 DevOps 团队

前端周刊:axios 疑遭朝鲜黑客“钓鱼“;CSS 新函数上线;npm 上线深色主题;Oxlint 兼容表;ESLint 支持 Temporal......

🌐 今日要闻

打破信息壁垒,走近全球前端。Hello World 大家好,我是林语冰。

欢迎阅读《Web 周刊》,上周 Web 开发圈的主要情报包括:

  • 🐞 Axios 主席疑遭朝鲜黑客“钓鱼“,自爆了社会工程的诈骗过程
  • 🌗 npm 可信发布支持 CircleCI,npm 官网新增深色主题
  • ✅ ESLint 10.2 支持 JS 最新的 Temporal API
  • ✅ Axios 1.15 支持 Deno / Bun,源码重构了 url.parse()

PS:本文附带甜妹解说的动画视频,粉丝请搜索哔哩哔哩@Web情报局

🎉 每周热搜

🔗 Axios 主席疑遭朝鲜黑客“钓鱼“

Axios 是 GitHub 第一请求库,周下载量过亿。

不幸的是,愚人节前夕,Axios 突然发布了 2 个中毒版本,它们只坚挺了 3 小时就被封杀了,但至少波及几十万用户,这是近一年内最大规模的 npm 供应链攻击。

随后 Axios 团队主席爆料了攻击事件的完整事后分析,首先是它遭到了社会工程“钓鱼“,通过伪造的在线会议安装了有毒软件,导致 npm 账户被盗用。

axios-post.png

Axios 源码本身没有 bug,黑客只添加了一个幽灵依赖 plain-crypto-js,其中包含了一个 postinstall 脚本,随后使用传统 npm token 发布中毒版本。

用户使用 npm install axios 之后,postinstall 脚本会自动执行,请求其他恶意软件,盗用系统资料。

post-install.png

之后,这个脚本会自尽,删除 postinstall 脚本,替换为正常 package.json,用户对这种“完美犯罪“浑然不知。

谷歌和微软深入调查了本次赛博攻击,部分证据表明攻击来自朝鲜的黑客组织,但可能很难像川普打伊朗那样直接证明。

korea-npm.png

总之,供应链攻击是一种系统性原罪。我们应该遵循 npm 发包的最佳实践,防止黑客轻易绕过了现代化的可信发布流程。

🛜 官方情报

🔗 Node bug 悬赏项目破产

2016 年,Node 加盟了 HackerOne 的 IBB(互联网 bug 悬赏)项目,通过众筹为 fix bug 的志愿者提供奖金。

Node 团队会继续接收 bug,但由于资金链中断,该悬赏项目现已暂停。

node-bug.png

特别鸣谢一直以来为 Node 安全贡献的开发者和赞助商!Node 是 Web 开发的重要基建,如果你愿意提供赞助,请随时联系 OpenJS 基金会。

🔗 npm 可信发布支持 CircleCI

GitHub 官宣,npm 可信发布支持 CircleCI 作为 OIDC(OpenID 连接)供应商。

circle-ci.png

CircleCI 现在和 GitHub Actions 与 GitLab CI / CD 一样,维护者能从部署流程鉴权发包,无需长期 token。

此外,npm 官网上线深色主题了。

🔗 Oxlint / Oxfmt 兼容表

Oxc 官网新增了 Oxlint / Oxfmt 兼容表,可以直观地查看它们支持哪些 JS 框架和文件类型,从夯到拉分为四大梯队:

  1. 完整支持,比如 Oxlint + Oxfmt 完整支持 React 的代码质检和格式化
  2. 部分支持,比如 Oxlint 暂不支持 Vue 模板的代码质检
  3. 不支持,比如 Svelte 没有提供 Prettier 插件,Oxfmt 也不支持
  4. 越界功能,比如 Oxlint 不支持 CSS 代码质检

oxc.png

🚦 版本更新

🔗 ESLint 10.2

ESLint 是 GitHub 第一 JS Linter(代码质检工具),最近更新了 10.2 次版本。

首先,ESLint 新增了 meta.languages 属性,作者可以指定规则适用的语言,比如 JS 专属规则或 Markdown 专属规则等。

eslint.png

此外,ESLint 还支持 JS 最新的 Temporal 全局变量,no-undef 规则能识别 Temporal 而不会报警,no-obj-calls 规则会在直接调用 Temporal 时报警。

temporal.png

🔗 Axios 1.15

Axios 发布了 1.15 次版本,现在能支持 Bun / Deno。

Axios 还修复了代理处理和头部注入等安全漏洞,CI(持续集成)采用 OIDC 来守卫 npm 发布。

源码使用了原生 URL API,url.parse() 重构为 new URL()

url.png

💡 前端信息差

🔗 CSS 新函数 contrast-color()

最近 npm 上线了深色主题,但还有一些无障碍 bug,比如切换深色主题后文字对比度不够,看不清楚。

npm-a11y.png

Google 专家 Una 之前就提出了一个新的 CSS 函数 contrast-color(),它可以接受任意颜色,然后根据输入的颜色去计算对比度,最终返回 blackwhite

contrast.png

contrast-color() 可以自动生成对比度更好的文字,完美解决 npm 的无障碍文字 bug。

CSS contrast-color() 函数目前已经达到 Baseline Newly Available(全新可用基线),所有新版的主流浏览器都支持这个功能喔。

baseline.png

🛠️ 工具推荐

为了对抗供应链攻击,本期我们主要分享一些 npm 防御式指南

🔗 npm 可信发布

首先是 npm 官方文档出品的 trusted publishing(可信发布),它可以让 npm 和 GitHub CI(持续集成)完美搭配。

npm-trust.png

🔗 npmx 网站

再来是比 npm 更现代化的网站 npmx,npmx 会显示盾牌来说明这个 axios 版本是通过可信发布持续部署的。

你会发现 axios 的某些版本没有盾牌,说明 axios 没有严格使用可信发布,黑客就会从这里寻找机会。

npmx-oidc.png

npmx 不仅会警告你这个版本可信度降低,还会爆料模块的性能问题或安全漏洞,我知道你很急,但是你先别升级。

npmx-axios.png

🔗 pnpm 供应链攻击

还有就是比 npm 更现代化的包管理器 pnpm,pnpm v10 的官网提供了缓解供应链攻击的完整指南。

pnpm-trust.png

这些配置在最新版的 pnpm 11 中会默认启用,所以提前了解也方便你之后升级到 pnpm 11。

🔗 npm 安全最佳实践

最后,Node 安全专家 Liran 也在 GitHub 上分享了一份《npm 安全最佳实践》,建议收藏。

npm-best.png

🙏 特别鸣谢

以上就是本期《Web 周刊》的全部内容了,希望对你有所帮助。

👍 感谢大家按赞跟转发分享本文,你的手动支持是我坚持创作的不竭动力喔。

😘 已经关注我的粉丝们,我们下期再见啦,掰掰~~

cat-thank.gif

一个简单的套壳方案,就能让你的 Agent 少做重复初始化

前言

随着 harness 的完善,Agent 的启动过程正在从“输入任务后直接执行”,逐渐变成“先完成一组初始化动作,再进入任务”。

常见初始化内容包括:

  • 个人记忆、协作偏好、输出风格
  • 项目背景、目录结构、技术栈、历史约定
  • 团队规范、工作流、提交和评审规则
  • 工具说明、MCP 配置、常用命令
  • 特定任务类型依赖的 skills / agents

这些内容大多不是一次性信息。它们变化不频繁,却会在每个新会话里反复出现。

问题也就集中在这里:

  • 重复:相同初始化内容在多个会话中反复执行。
  • 耗时:每次启动都要等待加载和确认。
  • 不稳定:步骤越多,越容易漏读、乱序或加载不完整。

当 Agent 使用越来越依赖记忆、项目上下文和工具体系后,初始化本身就变成了一个需要工程化处理的问题。

解决思路:复用稳定初始化

一次 Agent 任务可以拆成两部分:

  • 稳定初始化:记忆、规则、项目背景、工具、skills。
  • 具体任务:本次真正要做的分析、开发、写作或排查。

基座工程关注的是第一部分。

如果某些内容满足下面三个条件,就不应该每次都重新初始化:

  • 相对稳定
  • 高频使用
  • 启动时必需

更合理的方式是:先让 Agent 完成一次稳定初始化,把这个状态保存成基座;之后每个新任务都从这个基座派生。

可以把它理解成下面这条链路:

记忆 / 规则 / 项目上下文 / skills
        │
        ▼
   生成基座会话
        │
        ▼
  每次新对话,都 fork 基座会话
        │
        ▼
    执行具体任务

需要注意:基座复用优化的是初始化过程,不是让上下文内容本身消失。初始化内容仍然会成为会话状态的一部分。

实现方式:给 Codex 包一层启动器

要把这个思路落地,关键不是直接改 Codex 本身,而是在 Codex CLI 外面包一层启动器。

原因很简单:如果用户每次都直接执行 codex,启动前后没有地方插入自己的逻辑。我们无法在启动前判断基座是否过期,也无法自动决定应该生成基座、复用基座,还是从某个指定基座 fork。

所以需要一个自己的入口,负责在调用 Codex 之前先做一层调度。

在我的本地实现里,这个入口叫 cx。它不是新的 Agent,也不是 Codex 的替代品,只是 Codex CLI 外面的一层启动器:

用户输入 cx
   │
   ▼
cx 启动器:检查基座 registry、必要时刷新、解析别名
   │
   ▼
基于基座会话,派生新的工作会话,以免污染基座
   │
   ▼
进入真正的 Codex 任务会话

使用案例

下面举个具体的个人使用的例子。我的 harness 工程首轮对话会加载许多记忆、技能等,每次 codex 耗时约 30s,使用该方案就可以跳过这部分,从而直接聚焦于具体问题。

默认情况下,直接执行 cx,会从 memory 记忆基座派生新会话:

如果记忆相关的文件更新的情况下,则会通过文件哈希校验自动更新基座:

除此之外,我还添加了一套基座的管理命令,主要用于管理各个开发项目的上下文基座。

cx -add <session-id> <别名>     # 把某个已有 Codex 会话登记为基座
cx -list                       # 查看当前登记的基座
cx -load <别名|session-id>      # 从指定基座 fork 新会话
cx -remove <别名>               # 删除手动登记的基座

默认基座:memory

当前实现里,memory 是默认基座。用户不指定 -load 时,都会走它。

memory 基座是托管基座:

  • 没有本机状态时,首次执行 cx 会自动生成。
  • 关键初始化资产变化时,会自动刷新。
  • 它不能通过 -remove memory 删除,避免默认启动链路被误删。

机器 B 第一次使用时不需要从机器 A 复制 state。只要 repo 里的基座定义还在,执行 cx 时就会在机器 B 上重新生成自己的 memory 基座。

手动基座:通过别名管理

除了默认 memory,还可以把某次已有 Codex 对话登记成一个手动基座。

这类基座适合临时复用某个已经加载过上下文的会话,比如某个项目、某类排查现场或某次专项分析。

不过它和 memory 不一样:手动基座记录的是本机 Codex session id,本质上是本机状态,换机器后不能自动恢复。

所以可以把基座分成两类:

类型 生成方式 是否可跨机器自动恢复 适合场景
memory 默认基座 根据 repo 里的记忆和规则自动生成 可以重新生成 长期稳定初始化
手动基座 cx -add <session-id> <别名> 不可以,需要重新登记 临时项目现场、专项上下文

资产指纹:判断 memory 是否过期

对于默认 memory 基座,启动器会跟踪一组关键初始化资产。

const KEY_FILES = [
  "src/brain/claude.md",
  "src/brain/knowledge/claude.md",
  "src/config/skills/claude.md",
  "src/config/agents/claude.md",
  "src/brain/knowledge/reinforced-rules.md",
  "src/brain/knowledge/workflow-superpowers.md",
];

这些文件决定了 Agent 启动后要加载哪些记忆、遵守哪些规则、知道哪些 skills / agents。

启动器会对它们计算 hash,并合成一个整体指纹:

function computeFingerprint(root) {
  const lines = KEY_FILES.map(file => {
    const content = fs.readFileSync(path.join(root, file));
    return sha256(content) + "  " + file;
  });

  return sha256(lines.join("\n"));
}

指纹没变,说明 memory 基座仍然可用。

指纹变了,说明记忆、规则或 skill 索引发生变化,需要重新生成 memory 基座。

哪些内容适合被基座化

适合进入基座的内容,通常满足三个条件:

  • 稳定:变化频率低。
  • 高频:多个任务都会用到。
  • 必要:缺失后会明显影响执行质量。

常见基座类型:

类型 适合放入的内容
个人基座 长期记忆、协作偏好、输出风格、常用工作习惯
项目基座 项目背景、目录结构、技术栈、常用命令、开发约定

不适合进入基座的内容也要明确:

  • 单次任务里的临时信息
  • 尚未确认的推测
  • 变化频繁的业务细节
  • 只对当前会话有价值的过程记录

简单判断标准是:如果一段内容下次大概率还会用到,并且启动时就需要知道,它就有机会被基座化。

给 AI Agent 装上"长期记忆":Karpathy 的 LLM Wiki 思想,我做成了工具

你的 AI 每次对话都在重新推导知识。而一个由 Agent 自己维护、会复利增长的 Wiki,让它越用越聪明。

这篇文章不是教你怎么敲 CLI 命令。memex 的入口在 agent 对话里——你只需要说 /memex:capture/memex:ingest/memex:query,Agent 自己知道怎么做。


一、Karpathy 在 2026 年 4 月提出了一个思想

HE9kEdZaMAADLIU.jpg

Andrej Karpathy 是 OpenAI 创始团队成员、前 Tesla AI 总监。2026 年 4 月 4 日,他在 GitHub Gist 上发布了一篇 LLM Wiki Pattern,系统阐述了一个思想:

为什么人类用 Wiki 积累知识,而 AI 每次对话都在从零推导?

他的主张很直接:给 LLM 一个结构化 Markdown Wiki,让它自己维护。人类只负责往 raw/ 里扔源材料,LLM 负责把知识编译进 wiki/——更新概念页、建立交叉引用、标注矛盾、写综合页。每轮对话不是"检索",是"阅读一本已经写好的书"。

他打了个比方,传得很广:

"Obsidian is the IDE, the LLM is the programmer, the wiki is the codebase."

翻译过来就是:"Obsidian 是 IDE,LLM 是程序员,Wiki 是代码库。"

什么意思?你写代码时——IDE 是你的界面,程序员是写代码的人,代码库是持续构建的产物。类比到这里——Obsidian(或任意 Markdown 浏览器)只是你看知识的界面,LLM 才是真正写知识的人,Wiki 就是 LLM 持续构建和维护的知识产物。你不写 Wiki,你看 Wiki;LLM 不读 Wiki,LLM 写 Wiki。

Karpathy 的核心洞见其实用一句话就能说清——他把知识库当代码仓库管理:

软件工程 知识库工程
src/ raw/(原始资料,不可变)
build/ wiki/(编译产物,LLM 自动生成)
编译器 LLM(把 raw 编译成结构化 wiki)
IDE Obsidian / 任意 Markdown 浏览器
Lint / CI 健康检查(断链、矛盾、过期页)
增量编译 每次只 ingest 新增的 raw,不改旧文件

我是开发出身,第一眼看到这张表就懂了。这不就是 CI/CD 的知识库版本吗?

软件工程 → 知识库工程 映射

而 Karpathy 用了一个词来概括这一切——编译(Compile)。把原始资料编译成结构化知识。raw 是源码,wiki 是编译产物。你不会把 .class.java 混在一起,笔记也一样。

核心区别在于:RAG 每次重推,Wiki 持续复利。

这句话拆开看——

RAG LLM Wiki
知识形态 文档切片,无关联 结构化页面,交叉引用
更新方式 重新索引 Agent 直接编辑 Markdown
查询 向量相似度拼凑 读已组织好的页面
累积性 没有复利 每次 ingest 在旧知识上修改、关联
所有权 在厂商的向量库里 在本地 Git 仓库里

Karpathy 给的是思想。我把它做成了工程:memex


二、memex 怎么用?在 agent 对话里说话就行

最重要的概念先摆出来——

你不是在终端敲 memex distillmemex ingest。你是在 agent 对话框里说 /memex:capture/memex:ingest/memex:query。CLI 只在 Agent 脚下跑,你感觉不到它。

memex 提供了 6 个 slash command,覆盖完整的知识生命周期:

Slash Command 你做什么 Agent 做什么
/memex:capture 给 Agent 一个 URL、一段文字、一个文件 Agent 保存到 raw/,记录出处,不变形
/memex:ingest "把这些新东西消化进知识库" Agent 读 raw 源材料,更新 concept/entity/source 页面,写交叉引用,更新 index
/memex:query "关于 X,我们知道哪些?" Agent 搜 wiki,综合答案,带引用
/memex:distill "这次对话有不少好结论,存下来" Agent 把会话要点蒸馏成结构化 raw 笔记
/memex:lint "检查一下知识库健不健康" Agent 跑机械检查 + 语义扫描,报问题,修问题
/memex:status "看看知识库现在什么状态" Agent 报告页面数、最近变化、待处理项

你不需要记住命令参数。你只需要用自然语言告诉 Agent 你想干什么,Agent 自己调对应的 slash command。

别上来就搞 RAG

一提"AI + 笔记",很多人的第一反应是搭 RAG:选 Embedding 模型、搭向量数据库、调切片策略。整套架构搞了一个月,笔记库里还是只有 20 篇文章。

Karpathy 的思路反过来:先跑通流程,再优化基础设施。 知识库规模不大的时候(几百篇文章以内),维护几个索引文件就够了。LLM 先读 index.md 定位,再直接阅读相关内容。简单、可靠、零额外成本。等你的笔记真的过了一万条,搜东西开始找不到、找不全了,再考虑 RAG 不迟。

这道理写代码的人都懂,但轮到自己搭知识库的时候就忘了。

每次问答也能存回知识库

还有一个 Karpathy 特别强调的设计:好的问答结果应该存回 wiki,而不是消失在聊天记录里。 你问了一个复杂问题,Agent 查 wiki、综合答案、带引用——这个答案本身就是一份有价值的知识产物。把它存成新页面。下次类似问题,Agent 直接读已有的分析,不用重新推导。

你每跟 AI 聊一次,知识库就增加一层。这就是复利。

知识编译管线:capture → ingest → query → lint


三、五个场景:memex 到底能带来什么价值

下面这五个场景,是我自己用了三个月的真实感受。

场景 1:长期研究 —— 让知识库自己长起来

痛点:你在研究"Agent Memory vs RAG"这个话题,今天看一篇论文,明天读一个开源项目,后天和 AI 讨论两个小时。三周后你想写篇总结文章——发现所有讨论散落在十几个聊天窗口里,找不到线索。

怎么做

你:/memex:capture https://arxiv.org/abs/xxxx --scene research
你:读到新的论文或讨论出新想法时,继续 capture 进去
你:积累几份材料后——
你:/memex:ingest 把这些新研究材料消化进 wiki
你:/memex:query "agent memory 和 RAG 的设计取舍,我们目前知道哪些?"

你始终在 agent 对话里。Agent 负责:

  • 把每篇论文、每次讨论存成 raw/research/ 下的源文件
  • ingest 时把新知识合并进 concepts/agent-memory.md、更新对比页 summaries/agent-memory-vs-rag.md
  • query 时综合 wiki 里的所有内容,带引用回答

价值:三周后,你拥有的不是十几个聊天窗口,而是一个结构化的知识地图——概念定义、方案对比、源材料索引、开放问题清单。写文章时,直接 /memex:query "agent memory 技术路线对比"

场景1:长期研究 — 知识库随时间生长

场景 2:长期项目 —— 让项目记忆可继承

痛点:你的项目已经迭代了三个月。今天用 Claude Code,明天用 Codex,后天用 Cursor。每个新 Agent 都要重新理解架构、踩过的坑、命名的原因、测试的边界。

怎么做

你:帮我连接这个项目到 memex 知识库
Agent:安装项目级别的 context 文件,记录相关的 scene

你:读当前代码和文档,然后起草这个项目的 architecturecommand-designknown-pitfalls 页面
Agent:读源码,写带有文件路径引用的 code-reading 笔记到 raw/

你:/memex:ingest 把这次 code-reading 结论写进项目 wiki
Agent:更新架构决策页、命令设计页、已知坑页、测试契约页

每次新会话开始:

你:/memex:query "继续 ai-memex-cli 网站和文档工作"
Agent:从 wiki 拉出最近的 handoff 笔记、未完成的任务、需要遵守的测试契约
你:从上次中断的地方继续

价值:项目知识不再是散落在聊天里的只言片语。新 Agent 开局就能回答"为什么这么设计"、"哪些地方容易踩坑"、"上次改到哪了"。代码仓库本身就是 source of truth,wiki 存的是 Agent 从代码、文档、issue、反馈中提炼出来的可继承理解

场景2:长期项目 — 三个 Agent 共用一个 wiki

场景 3:跨会话继承 —— 多次会话之间携带上下文

痛点:今天 Claude Code 做了一半,明天 Codex 继续,后天出差回来用 Cursor 检查。每个新会话都是一个黑洞——上下文全丢。

怎么做

你:/memex:distill 这次 Codex 会话,写清楚做到了哪、下一步做什么、有没有阻塞
Agent:找到当前 agent 的会话数据,蒸馏成 raw/sessions/ 下的结构化笔记

你:/memex:ingest 把这次 handoff 合并进项目记忆
Agent:更新项目 wiki 中的进度页和 log.md

——第二天,换了一个 agent——

你:/memex:query "上次中断的工作,下一步是什么"
Agent:从 wiki 里拉出 handoff 笔记和未完成项

跨 Agent 完全无感——Claude Code 写的,Codex 能读;Codex 补充的,Cursor 继续改。它们不共享一个聊天窗口,它们共享 raw/wiki/index.mdlog.md

价值:连续性不再绑定任何一个厂商。你可以换 Agent、换模型、等一周再回来,任务状态还在同一个 wiki 里等你。

场景3:跨会话继承 — 有无 memex 的对比

场景 4:对话沉淀 —— 把聊天里的好结论留下

痛点:一场深入对话里,你们讨论了产品定位、架构边界、某个 bug 根因、三个被否决的方案。聊完很爽,一周后只记得大概——细节全丢了。

怎么做

你:/memex:distill 这次对话,我们聊清楚了产品定位和几个关键的取舍
Agent:把对话蒸馏成 source 页,保留上下文、决策、未解问题

你:/memex:ingest 只要这次确定的稳定结论,合并到已有的 positioning 页面里
Agent:读蒸馏产物,提取可复用的结论,增量更新已有页面,不复制已有内容

什么样的结论值得沉淀?

  • 产品定位:怎么描述产品、避免用什么说法
  • 架构边界:为什么 CLI 不做语义层、为什么 raw 不可变
  • Bug 根因:排查路径、实际原因、回归测试要点
  • 被否决的方案:为什么没选、当时的前提是什么

价值:聊天不再是消耗品。重要推理先变成可追溯的 source,再变成结构化的 wiki 知识。下次 query 时,能同时看到结论和它为什么成立。如果前提变了,wiki 也能记录"老判断基于什么、新判断基于什么"。

场景4:对话沉淀 — 从聊天到 wiki 的蒸馏流

场景 5:结构化维护 —— 让 Agent 持续维护知识,而不是只回答一次

痛点:大部分人用 AI 的模式是"问一次答一次"。知识在回答完后原地消失。没人去更新、去合并重复页、去修断链、去标记过期内容。

怎么做

你:/memex:status
Agent:报告 vault 整体健康状况——页面数、最近更新的 source、哪些页面过时了、哪些维护任务待处理

你:/memex:lint 检查断链、孤儿页、过期页、缺失的 frontmatter
Agent:跑机械 lint(路径、链接、frontmatter 正确性)+ 语义扫描(矛盾、重复、过时论断)
你:机械问题直接修,语义问题先给我看方案
你:把 Karpathy 的 LLM Wiki gist 加入知识库
Agent:capture 源文件 → 创建 concept 页 → 更新相关页面交叉引用 → 写 log
你:告诉我改了什么,还有什么需要 review

价值:Wiki 不是一堆文件的堆积。它是一个被持续维护的结构化系统。每次 Agent 用它,也能同时改善它。重复页被合并或标注、孤儿页被找到、断链被修复、index.md 是真正的导航入口而非文件列表。

场景5:结构化维护 — lint 健康检查的四个维度


四、Agent 和 CLI 的分工边界

这里有一个设计决策需要讲清楚——CLI 永远只做机械正确性的事,不做语义判断。

谁负责 做什么
Agent Claude Code / Codex / Cursor 判断哪些页面要更新、哪些概念要链接、哪些矛盾要保留、哪些总结要重写
Slash Command /memex:capture 等 6 个 把用户的自然语言意图翻译成底层 CLI 调用
CLI memex 命令行工具 文件读写、frontmatter 校验、链接检查、关键词搜索、会话解析——纯机械,不调 LLM API

这意味着:

  • 你的知识不绑定任何厂商——Agent 可以换,wiki 不变
  • 你的知识是 Git 化的 Markdown——可以 diff、可以 blame、可以回退
  • CLI 永远不帮你做语义决策——"这两个页面是不是该合并"这种问题,Agent 自己判断但会问你

memex 三层架构:raw(不可变)→ wiki(编译)→ 输出

CLI 的补充能力

上面 6 个 slash command 覆盖日常 90% 的交互。CLI 底层还提供几个高阶能力,但不建议作为日常入口:

CLI 命令 用途 说明
memex watch 自愈守护进程 监听 raw/ 变化,自动触发 ingest → lint 循环。适合长期跑
memex inject 上下文注入 会话开始前,按任务描述从 wiki 拉最相关页面注入当前上下文
memex install-hooks 安装 Agent hooks 把 SessionStart / SessionEnd hook 写入 Agent 配置,自动 distill 和 inject
memex search 命令行搜索 全文搜索 wiki,适合脚本化场景

但这些不是入口。日常入口是 agent 对话框,是说 /memex:query 而不是敲 memex search


五、两周跑通最小闭环

如果你想试,不需要什么额外工具。装好 memex,在你的 Agent 里说话就行。

第一周:搭 raw → wiki 的最小循环。 装好 memex,运行 memex onboard。然后开始往知识库喂东西——看到好文章、好推文、好想法,直接对 Agent 说 /memex:capture。攒够 5 到 10 条后,说 /memex:ingest 把这些新素材消化进知识库。Agent 会生成摘要、提取概念、更新索引。

第二周:让问答开始积累,跑第一次健康检查。 每次对知识库做复杂提问,结果让 Agent 存回 wiki。然后说 /memex:lint 给知识库做一次全面体检。Agent 会扫出断链、矛盾、过期页、孤儿页——先让它修机械问题,语义问题你看一下再决定。

两周之后你有一个能持续运转的小系统。规模不重要,流程跑通了就行。后面就是往 raw/ 里不断喂素材,让 Agent 持续编译。


六、知识库的"GitHub 时刻"

回到 Karpathy。他那篇 Gist 的最后一句话是:

这套东西目前仍然像一堆 hacky scripts,但有空间做成 incredible new product。

我想到 2006 年前的版本控制。那时候也是 svn、cvs、git 命令行,只有程序员在用。然后有人把它做成了 GitHub,整个协作方式都变了。

个人知识库可能正在类似的节点。今天它是 Obsidian + LLM + 手搓脚本的组合,看起来还很粗糙。但底层范式已经有了:把知识当代码管理。 有输入,有编译,有产物,有测试。

如果你是程序员,好消息是你不需要学任何新东西。代码仓库怎么管,知识库就怎么管。你积累了这么多年的工程直觉,终于可以用在自己的笔记上了。

Karpathy 原文里还有一段话:

人类放弃 Wiki 是因为维护负担的增长速度永远超过它带来的价值。你得亲手写每个页面、手动保持一致性、记住所有交叉引用。

但 LLM 不会无聊。它可以一次触碰 10-15 个页面,把新知识合并进去,更新索引,同时保持系统自洽。

人的工作:策展、取舍、提问、思考。LLM 的工作:剩下的全部。

memex 做的,就是把这句话变成可以跑的东西。

别让你的笔记腐烂。让它们被编译。


快速开始:

npm install -g ai-memex-cli
memex onboard

然后在你的 Claude Code / Codex / Cursor 里说第一句话:

你:/memex:capture https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f --scene research
你:/memex:ingest Karpathy 的 LLM Wiki 思想,作为 research 场景的第一份材料
你:/memex:query "Karpathy 的 LLM Wiki 核心思想是什么?"

给 AI 一份会生长的记忆。


项目地址: github.com/zelixag/ai-…

理念来源: Karpathy's LLM Wiki Pattern

大模型和function calling分别是如何工作的

以一个表处理为例:

github地址:github.com/YueJingGe/p…

用大模型处理表格

描述:有一个本周饮品销量小表,希望大模型输出按饮品汇总销量的列表,以及销量最高的饮品是什么

代码示例+运行结果

image.png

核心思想

当你把表格 用 df.to_string() 转成文本,然后放进 prompt 里时,大模型(如通义千问、GPT-4)会做这几步:

  • 1、阅读理解:识别出这是一个三列表格,理解列名含义(“饮品”“销量_杯”“门店”)。
  • 2、信息提取:逐行读取数据,记住每行对应关系。
  • 3、简单计算:将“美式”的两行销量 30 和 12 相加,得到 42;将“拿铁”的 18 单独作为总和;将“橙汁”的 12 作为总和。
  • 4、格式组织:按要求的格式输出“美式:42,拿铁:18,橙汁:12”。   这些能力完全来自大模型自身的训练——它在海量数据中学到了如何理解文本表格、如何进行基本的聚合计算(求和、计数等)、如何比较大小。你不需要额外实现任何逻辑,只要把表格文本喂给模型,它就能尝试回答。  

技术架构图

image.png

局限性

虽然模型能处理简单的表格问答,但有以下问题:

  • 1、计算错误:当表格行数多、数字复杂(如带小数、需加权平均),或者需要多步运算(先分组再排序再筛选),模型容易算错或遗漏。
  • 2、上下文长度:表格太大(比如1000行),放进 prompt 可能超过模型的 token 限制,或导致注意力分散、遗漏关键信息。
  • 3、不确定性:同一个表格问同一个问题,模型可能给出略有差异的答案(因为生成有随机性)。
  • 4、无法执行复杂逻辑:比如“找出销量最高的门店,再列出该门店销量前两名的饮品”,这类需要多步条件筛选和排序的任务,模型往往做不好。  

Function Calling + 大模型处理表

首先,Function calling(也称工具调用)不是让模型直接分析表格,而是让模型决定去调用一个外部工具(比如写好的 Python 函数) 来精确处理表格,再把工具返回的结果整理成自然语言回答。

描述:有一个本周饮品销量小表,希望大模型输出按饮品汇总销量的列表,以及销量最高的饮品是什么,以及哪个门店销量最高

典型流程:

用户问:“按饮品汇总销量。”
模型看到可用的函数列表,比如 compute_groupby_sum(column, group_by)。
模型不自己计算,而是输出一个函数调用指令:compute_groupby_sum(column="销量_杯", group_by="饮品")。
你的程序拦截这个指令,让 Python 后端实际执行 df.groupby("饮品")["销量_杯"].sum(),拿到精确结果 {"美式":42, "拿铁":18, "橙汁":12}。
你的程序把这个结果送回模型,模型再组织成一句人话:“美式共卖出42杯,拿铁18杯,橙汁12杯。”

代码示例

import os
import json
import pandas as pd
from openai import OpenAI
from dotenv import load_dotenv

load_dotenv()
client = OpenAI(
    api_key=os.getenv('DASHSCOPE_API_KEY'),
    base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)

# ---------- 定义真实的数据查询函数(后端执行) ----------
# 使用 pandas 创建数据表
df = pd.DataFrame({
    "饮品": ["美式", "拿铁", "橙汁", "美式"],
    "销量_杯": [30, 18, 12, 12],
    "门店": ["A店", "A店", "B店", "B店"],
})

def get_sales_summary(by: str = "饮品"):
    """
    获取销量汇总数据。
    by: 分组依据,目前支持 "饮品" 或 "门店"
    """
    if by == "饮品":
        grouped = df.groupby("饮品")["销量_杯"].sum().reset_index()
        grouped = grouped.sort_values("销量_杯", ascending=False)
        # 转为字典列表,方便模型阅读
        result = grouped.to_dict(orient="records")
    elif by == "门店":
        grouped = df.groupby("门店")["销量_杯"].sum().reset_index()
        grouped = grouped.sort_values("销量_杯", ascending=False)
        result = grouped.to_dict(orient="records")
    else:
        result = {"error": f"不支持的聚合方式: {by}"}
    return json.dumps(result, ensure_ascii=False)

def get_top_sales(by: str = "饮品"):
    """获取销量冠军"""
    if by == "饮品":
        grouped = df.groupby("饮品")["销量_杯"].sum()
        top_item = grouped.idxmax()
        top_value = int(grouped.max())
        return json.dumps({"冠军": top_item, "销量": top_value}, ensure_ascii=False)
    elif by == "门店":
        grouped = df.groupby("门店")["销量_杯"].sum()
        top_item = grouped.idxmax()
        top_value = int(grouped.max())
        return json.dumps({"冠军": top_item, "销量": top_value}, ensure_ascii=False)
    else:
        return json.dumps({"error": f"不支持的冠军查询: {by}"})

# ---------- 定义工具描述(给大模型看的说明书) ----------
tools = [
    {
        "type": "function",
        "function": {
            "name": "get_sales_summary",
            "description": "按饮品或门店获取销量汇总,返回排序后的列表",
            "parameters": {
                "type": "object",
                "properties": {
                    "by": {
                        "type": "string",
                        "enum": ["饮品", "门店"],
                        "description": "分组维度,例如'饮品'或'门店'"
                    }
                },
                "required": ["by"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "get_top_sales",
            "description": "获取销量冠军(销量最高的饮品或门店)",
            "parameters": {
                "type": "object",
                "properties": {
                    "by": {
                        "type": "string",
                        "enum": ["饮品", "门店"],
                        "description": "冠军类型,例如'饮品'或'门店'"
                    }
                },
                "required": ["by"]
            }
        }
    }
]

# ---------- 主对话循环 ----------
def ask_with_function_calling(user_question):
    messages = [{"role": "user", "content": user_question}]
    
    # 第一次调用:让模型判断是否需要调用函数
    first_response = client.chat.completions.create(
        model="qwen-plus",  # 或 qwen-turbo
        messages=messages,
        tools=tools,
        tool_choice="auto",
    )
    response_message = first_response.choices[0].message
    messages.append(response_message)
    
    # 模型要求调用函数吗?
    if response_message.tool_calls:
        for tool_call in response_message.tool_calls:
            func_name = tool_call.function.name
            func_args = json.loads(tool_call.function.arguments)
            print(f"🔧 模型调用: {func_name}, 参数: {func_args}")
            
            # 执行真实的 Python 函数
            if func_name == "get_sales_summary":
                result = get_sales_summary(**func_args)
            elif func_name == "get_top_sales":
                result = get_top_sales(**func_args)
            else:
                result = json.dumps({"error": "未知函数"})
            
            # 将工具结果加入对话
            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "content": result,
            })
        
        # 第二次调用:模型根据工具结果生成最终回答
        second_response = client.chat.completions.create(
            model="qwen-plus",
            messages=messages,
        )
        final_answer = second_response.choices[0].message.content
        return final_answer
    else:
        # 没有工具调用,直接返回模型回答
        return response_message.content

# ---------- 运行示例 ----------
if __name__ == "__main__":
    # 问题1:按饮品汇总销量
    q1 = "按饮品汇总销量,并告诉我销量最高的饮品是什么"
    print(f"🙋 用户: {q1}")
    ans1 = ask_with_function_calling(q1)
    print(f"🤖 AI: {ans1}\n")
    
    # 问题2:销量冠军门店
    q2 = "哪个门店的销量最高?"
    print(f"🙋 用户: {q2}")
    ans2 = ask_with_function_calling(q2)
    print(f"🤖 AI: {ans2}\n")

运行结果:

image.png

核心思想

核心思想就是:

1、你给模型预定义一组函数,告诉模型这些函数能做什么。

2、用户问自然语言问题,比如“按饮品汇总销量,然后自然地说出来”。

3、大模型自己决定该不该调用函数、调用哪个函数、传什么参数。

4、模型返回的不是最终答案,而是一个函数调用指令(例如 get_sales_summary(by='drink'))。

5、你的代码收到这个指令后,实际去执行 pandas 计算(或查数据库、调API等),然后把精确的计算结果再发回给模型。

6、模型根据结果生成最终的自然语言回答。

整个过程中,模型在主动决策“我需要调用工具来帮忙”,而不仅仅是接收现成数据。

技术架构图

image.png

总结

不用 Function Calling 的时候主要是依赖大模型本身的语言理解和推理能力,适合你完全清楚要算什么的场景,代码简单高效。

用 Function Calling是作为一种“增强手段”来提升准确性和能力上限,适合搭建对话式数据分析助手,用户可以随意提问,模型自动选择合适的函数去执行。

深入学习

Function Calling 官方资料:

阿里云百炼平台:搜索“阿里云百炼 Function Call”,参考官方文档。

社区博客:搜索“超实用!用 FunctionCall 实现快递 AI 助手”,了解用它构建真实 AI 助手的详细步骤。

CSDN 博客:搜索“通义千问的 Function Call”,更直观地理解整个实现流程。

用ethers.js连接MetaMask实现Web3钱包登录:从踩坑到稳定运行的完整记录

前言:一个看似简单的需求,让我折腾了两天

事情是这样的:上个月我在做一个DeFi看板项目,核心功能是让用户连接MetaMask钱包后,能看到自己的资产和交易记录。老板说"钱包登录很简单,就是调个接口嘛",我当时也觉得——不就是调个window.ethereum吗?结果真正动手时,踩坑踩到怀疑人生。

从连接失败、签名验证错误,到用户拒绝连接后状态混乱,再到不同链之间切换导致数据错乱……每一个问题都让我抓狂。但最终,我总结出了一套稳定可用的方案。这篇文章就是把我这两天的踩坑经历完整记录下来,希望对你有帮助。


背景:一个DeFi看板项目的前端需求

当时我正在开发一个叫"DeFiDash"的项目,本质上就是一个聚合看板,用户连接钱包后能查看自己在多个链上的资产、持仓和交易记录。前端用的是React + TypeScript,后端是Node.js。

核心需求其实就三个:

  1. 用户点击"连接钱包"按钮,弹出MetaMask授权窗口
  2. 用户签名一条消息,后端验证签名后返回JWT token
  3. 页面根据用户地址展示对应的链上数据

看起来很简单对吧?但真正实现时,我遇到了第一个大坑——连接过程的状态管理。


问题分析:为什么"一行代码"搞不定?

我最初的思路是直接写一个connectWallet函数:

// 第一版代码,天真到不行
async function connectWallet() {
  const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
  setAddress(accounts[0]);
}

然后我发现了三个问题:

问题1:用户拒绝连接时,代码会直接崩溃。 如果用户点击了MetaMask弹窗的"取消"按钮,request会抛出一个错误,但我的代码没有捕获它,导致页面白屏。

问题2:连接后没有验证链ID。 用户可能连接的是以太坊主网,也可能连接的是Goerli测试网,但我根本没有检查。后来用户反馈说"连接后看不到资产",排查了半天才发现是链ID不匹配。

问题3:页面刷新后连接状态丢失。 用户连接成功后,刷新页面就需要重新连接。这体验太差了,而且每次刷新都弹MetaMask窗口,用户会疯的。

这三个问题让我意识到,钱包登录远不止"调一个接口"那么简单。我需要一个完整的连接流程,包括状态管理、错误处理、链ID验证和持久化。


核心实现:一步步搭建稳定的钱包登录

第一步:初始化Provider和检测MetaMask

在React中,我习惯把所有Web3相关的逻辑封装在一个自定义Hook里。首先,我需要一个provider——这是与区块链交互的底层对象。

// hooks/useWallet.ts
import { BrowserProvider, JsonRpcSigner } from 'ethers';
import { useState, useEffect, useCallback } from 'react';

export function useWallet() {
  const [provider, setProvider] = useState<BrowserProvider | null>(null);
  const [signer, setSigner] = useState<JsonRpcSigner | null>(null);
  const [address, setAddress] = useState<string>('');
  const [chainId, setChainId] = useState<number>(0);
  const [error, setError] = useState<string>('');

  // 初始化:检测MetaMask是否安装
  useEffect(() => {
    if (typeof window.ethereum === 'undefined') {
      setError('请安装MetaMask浏览器插件');
      return;
    }
    // 注意:这里不要自动请求连接,只在用户点击按钮时才触发
    const ethersProvider = new BrowserProvider(window.ethereum);
    setProvider(ethersProvider);
  }, []);
}

这里有个坑: 不要一加载页面就调用eth_requestAccounts。有些用户不想连接钱包,只是浏览页面,自动弹窗会吓到他们。正确的做法是只检测MetaMask是否存在,连接操作交给用户点击按钮触发。

第二步:实现连接逻辑,处理所有异常

连接钱包的核心是调用eth_requestAccounts,但必须做好错误处理。我当时用了一个笨办法——直接在catch里打印错误信息,后来发现不同错误类型需要不同处理方式。

// hooks/useWallet.ts(续)
const connect = useCallback(async () => {
  if (!provider) {
    setError('Provider未初始化');
    return;
  }

  try {
    // 关键:先请求账户,再获取签名者
    const accounts = await provider.send('eth_requestAccounts', []);
    if (accounts.length === 0) {
      throw new Error('没有获取到账户');
    }

    const userAddress = accounts[0];
    const userSigner = await provider.getSigner();
    const network = await provider.getNetwork();
    const userChainId = Number(network.chainId);

    // 验证链ID是否在支持的范围内
    const SUPPORTED_CHAIN_IDS = [1, 5, 137]; // 以太坊主网、Goerli、Polygon
    if (!SUPPORTED_CHAIN_IDS.includes(userChainId)) {
      // 这里可以提示用户切换网络,但先保存状态
      console.warn(`当前链ID ${userChainId} 不在支持列表中`);
    }

    setAddress(userAddress);
    setSigner(userSigner);
    setChainId(userChainId);
    setError('');

    // 持久化:把地址存到localStorage,下次刷新时自动恢复
    localStorage.setItem('walletAddress', userAddress);
    localStorage.setItem('walletChainId', userChainId.toString());

  } catch (err: any) {
    // 处理不同类型的错误
    if (err.code === 4001) {
      // 用户拒绝了连接请求
      setError('用户拒绝了连接请求');
    } else if (err.code === -32002) {
      // MetaMask正在处理另一个请求
      setError('请先处理MetaMask中的其他请求');
    } else {
      setError(err.message || '连接钱包失败');
    }
  }
}, [provider]);

注意这个细节: 错误码4001是用户拒绝,-32002是重复请求。这两个错误码我查了MetaMask文档才搞清楚,之前一直用err.message判断,结果发现不同版本的MetaMask返回的消息格式不一样。

第三步:消息签名与后端验证

登录不只是连接钱包,还需要让后端验证用户身份。最常用的方式是"消息签名"——前端让用户签名一条包含随机数(nonce)的消息,后端用公钥验证签名。

// hooks/useWallet.ts(续)
const signMessage = useCallback(async (message: string): Promise<string> => {
  if (!signer) {
    throw new Error('请先连接钱包');
  }

  try {
    // 注意:message应该包含一个nonce,防止重放攻击
    const signature = await signer.signMessage(message);
    return signature;
  } catch (err: any) {
    if (err.code === 4001) {
      throw new Error('用户取消了签名');
    }
    throw new Error('签名失败: ' + err.message);
  }
}, [signer]);

// 实际登录流程
const login = useCallback(async () => {
  if (!address) {
    setError('请先连接钱包');
    return;
  }

  try {
    // 1. 从后端获取nonce
    const nonceResponse = await fetch('/api/auth/nonce?address=' + address);
    const { nonce } = await nonceResponse.json();

    // 2. 让用户签名nonce
    const message = `欢迎登录DeFiDash,本次登录的随机码为:${nonce}`;
    const signature = await signMessage(message);

    // 3. 发送地址和签名到后端验证
    const loginResponse = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ address, signature, message }),
    });

    const { token } = await loginResponse.json();
    // 存储token,后续API请求带上
    localStorage.setItem('authToken', token);
    setError('');

  } catch (err: any) {
    setError(err.message || '登录失败');
  }
}, [address, signMessage]);

这里有个坑: 签名消息的格式很重要。有些项目直接签address,这太不安全了,因为任何网站都可以伪造。一定要包含nonce,并且最好加上一些上下文信息(比如"欢迎登录XXX"),让用户在MetaMask里能看到清晰的内容。

第四步:页面刷新后自动恢复连接状态

用户连接成功后刷新页面,如果直接显示"未连接",体验很差。我通过localStorage保存地址,在页面加载时尝试恢复。

// hooks/useWallet.ts(续)
// 页面加载时恢复连接
useEffect(() => {
  const savedAddress = localStorage.getItem('walletAddress');
  const savedChainId = localStorage.getItem('walletChainId');

  if (savedAddress && provider) {
    // 恢复时只设置地址,不主动请求连接
    setAddress(savedAddress);
    setChainId(Number(savedChainId));
    // 注意:这里不设置signer,因为signer需要用户授权
    // 实际使用时,如果用户需要签名,再调用connect获取signer
  }
}, [provider]);

// 监听账户变化和链变化
useEffect(() => {
  if (!window.ethereum) return;

  const handleAccountsChanged = (accounts: string[]) => {
    if (accounts.length === 0) {
      // 用户断开了连接
      disconnect();
    } else {
      setAddress(accounts[0]);
      localStorage.setItem('walletAddress', accounts[0]);
    }
  };

  const handleChainChanged = (chainIdHex: string) => {
    const newChainId = parseInt(chainIdHex, 16);
    setChainId(newChainId);
    localStorage.setItem('walletChainId', newChainId.toString());
    // 链变化后,signer需要重新获取
    if (provider) {
      provider.getSigner().then(setSigner);
    }
  };

  window.ethereum.on('accountsChanged', handleAccountsChanged);
  window.ethereum.on('chainChanged', handleChainChanged);

  return () => {
    window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
    window.ethereum.removeListener('chainChanged', handleChainChanged);
  };
}, [provider]);

const disconnect = useCallback(() => {
  setAddress('');
  setSigner(null);
  setChainId(0);
  localStorage.removeItem('walletAddress');
  localStorage.removeItem('walletChainId');
  localStorage.removeItem('authToken');
}, []);

注意这个细节: chainChanged事件返回的是十六进制字符串(比如"0x5"),需要转成十进制。我当时直接用了parseInt,但忘记加基数参数,导致"0x5"被解析成0。排查了半天才发现。


完整代码:可直接运行的React Hook

我把上面所有代码整合成一个完整的useWallet Hook,你可以直接复制到项目中使用。

// hooks/useWallet.ts
import { BrowserProvider, JsonRpcSigner } from 'ethers';
import { useState, useEffect, useCallback } from 'react';

interface WalletState {
  provider: BrowserProvider | null;
  signer: JsonRpcSigner | null;
  address: string;
  chainId: number;
  error: string;
  isConnecting: boolean;
}

export function useWallet() {
  const [state, setState] = useState<WalletState>({
    provider: null,
    signer: null,
    address: '',
    chainId: 0,
    error: '',
    isConnecting: false,
  });

  // 初始化:检测MetaMask
  useEffect(() => {
    if (typeof window.ethereum === 'undefined') {
      setState(prev => ({ ...prev, error: '请安装MetaMask插件' }));
      return;
    }
    const ethersProvider = new BrowserProvider(window.ethereum);
    setState(prev => ({ ...prev, provider: ethersProvider }));
  }, []);

  // 恢复上次连接
  useEffect(() => {
    const savedAddress = localStorage.getItem('walletAddress');
    if (savedAddress && state.provider) {
      setState(prev => ({ ...prev, address: savedAddress }));
    }
  }, [state.provider]);

  // 连接钱包
  const connect = useCallback(async () => {
    if (!state.provider) {
      setState(prev => ({ ...prev, error: 'Provider未初始化' }));
      return;
    }

    setState(prev => ({ ...prev, isConnecting: true, error: '' }));

    try {
      const accounts = await state.provider.send('eth_requestAccounts', []);
      if (accounts.length === 0) {
        throw new Error('没有获取到账户');
      }

      const userAddress = accounts[0];
      const userSigner = await state.provider.getSigner();
      const network = await state.provider.getNetwork();
      const userChainId = Number(network.chainId);

      localStorage.setItem('walletAddress', userAddress);
      localStorage.setItem('walletChainId', userChainId.toString());

      setState(prev => ({
        ...prev,
        address: userAddress,
        signer: userSigner,
        chainId: userChainId,
        isConnecting: false,
      }));
    } catch (err: any) {
      let errorMsg = '连接钱包失败';
      if (err.code === 4001) errorMsg = '用户拒绝了连接请求';
      else if (err.code === -32002) errorMsg = '请先处理MetaMask中的其他请求';
      else if (err.message) errorMsg = err.message;

      setState(prev => ({ ...prev, error: errorMsg, isConnecting: false }));
    }
  }, [state.provider]);

  // 断开连接
  const disconnect = useCallback(() => {
    localStorage.removeItem('walletAddress');
    localStorage.removeItem('walletChainId');
    localStorage.removeItem('authToken');
    setState(prev => ({
      ...prev,
      address: '',
      signer: null,
      chainId: 0,
      error: '',
    }));
  }, []);

  // 监听事件
  useEffect(() => {
    if (!window.ethereum) return;

    const handleAccountsChanged = (accounts: string[]) => {
      if (accounts.length === 0) {
        disconnect();
      } else {
        setState(prev => ({ ...prev, address: accounts[0] }));
        localStorage.setItem('walletAddress', accounts[0]);
      }
    };

    const handleChainChanged = (chainIdHex: string) => {
      const newChainId = parseInt(chainIdHex, 16);
      setState(prev => ({ ...prev, chainId: newChainId }));
      localStorage.setItem('walletChainId', newChainId.toString());
      // 重新获取signer
      if (state.provider) {
        state.provider.getSigner().then(signer => {
          setState(prev => ({ ...prev, signer }));
        });
      }
    };

    window.ethereum.on('accountsChanged', handleAccountsChanged);
    window.ethereum.on('chainChanged', handleChainChanged);

    return () => {
      window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
      window.ethereum.removeListener('chainChanged', handleChainChanged);
    };
  }, [state.provider, disconnect]);

  // 签名消息
  const signMessage = useCallback(async (message: string): Promise<string> => {
    if (!state.signer) {
      throw new Error('请先连接钱包');
    }
    try {
      return await state.signer.signMessage(message);
    } catch (err: any) {
      if (err.code === 4001) throw new Error('用户取消了签名');
      throw new Error('签名失败: ' + err.message);
    }
  }, [state.signer]);

  return {
    ...state,
    connect,
    disconnect,
    signMessage,
  };
}

使用示例:

// App.tsx
import { useWallet } from './hooks/useWallet';

function App() {
  const { address, chainId, error, isConnecting, connect, disconnect, signMessage } = useWallet();

  const handleLogin = async () => {
    try {
      // 假设后端返回nonce
      const signature = await signMessage('登录nonce: 123456');
      // 发送signature到后端验证
      console.log('签名结果:', signature);
    } catch (err: any) {
      console.error(err.message);
    }
  };

  return (
    <div>
      {address ? (
        <div>
          <p>已连接: {address.slice(0, 6)}...{address.slice(-4)}</p>
          <p>链ID: {chainId}</p>
          <button onClick={handleLogin}>签名登录</button>
          <button onClick={disconnect}>断开连接</button>
        </div>
      ) : (
        <button onClick={connect} disabled={isConnecting}>
          {isConnecting ? '连接中...' : '连接钱包'}
        </button>
      )}
      {error && <p style={{ color: 'red' }}>{error}</p>}
    </div>
  );
}

踩坑记录:我实际遇到的4个问题

1. ethers.getDefaultProvider() 在浏览器端报错

  • 报错信息:Error: network error: The method eth_getBlockByNumber does not exist/is not available
  • 原因:getDefaultProvider() 会尝试连接以太坊节点,但在浏览器端,应该使用MetaMask注入的provider。
  • 解决:统一使用 new BrowserProvider(window.ethereum)

2. 用户拒绝连接后,再次点击连接按钮无反应

  • 现象:用户第一次点击"连接钱包"时取消了MetaMask弹窗,再次点击按钮,弹窗不出现了。
  • 原因:MetaMask检测到已有挂起的请求,返回错误码-32002
  • 解决:在catch中处理这个错误,提示用户"请先处理MetaMask中的其他请求",并建议用户刷新页面。

3. 签名时MetaMask弹窗不显示消息内容

  • 现象:调用signer.signMessage()时,MetaMask弹窗只显示"签名请求",没有显示具体的消息内容。
  • 原因:消息是纯字符串,没有格式化为可读的EIP-712类型数据。
  • 解决:对于简单登录,可以使用personal_sign方法,或者确保消息中包含可读的上下文(如"登录XXX")。

4. 链切换后,signer对象失效

  • 现象:用户在MetaMask中切换了网络,但调用signer.getAddress()时返回了旧地址。
  • 原因:signer是在连接时创建的,链变化后需要重新获取signer。
  • 解决:在chainChanged事件监听中,重新调用provider.getSigner()

小结

连接MetaMask实现钱包登录,核心就三点:正确处理用户授权、严格管理状态变化、做好异常处理。不要被"一行代码"的错觉迷惑,真正的坑都在细节里——比如错误码、链ID格式、事件监听的生命周期。

如果你继续深入,可以研究一下:

  • 使用wagmiRainbowKit等库简化钱包连接
  • 实现多链支持,让用户在不同链之间切换
  • 集成EIP-712类型数据签名,提升用户体验

希望这篇文章能帮你少踩一些坑。如果你在实现过程中遇到了其他问题,欢迎在评论区交流。

如何用 Recast 实现静态配置文件源码级读写

当你使用 Node.js 修改代码时,用正则直接操作字符串极其危险且难以维护。通过 AST 就可以像操作 JSON 一样精确地增删改查代码。

  • Babel (@babel/parser):负责执行将代码拆成 Token、将 Token 组装成树,把字符串拆解成一个个节点,如变量名、数值、函数。但是在修改并回写代码时,会丢失原有的缩进、空格和注释,导致代码格式全乱。
  • Recast:在解析时会记录每个节点的原始位置和格式,在回写阶段会进行自动对比,只更新你修改过的部分。

Recast 基础用法

  1. recast.parse:把代码字符串解析成一颗 AST 树。
  2. recast.visit:在树上找节点,比如找到名为 config 的变量。
  3. recast.types.builders (简称 b ) :假如你想把数字 1 改成 2,你需要用 builder 造出一个“数字 2”的节点来替换。

使用 recast.visit 时,可以从 path.node 拿到需要的数据。

path.node 常用属性

type:节点的类型(如 Identifier, Literal),这是判断“它是什么”的第一步。

loc:包含 startend 的行号列号,Recast 靠它实现精准的局部替换。

comments:存放该节点的注释信息,可以通过 b.commentLine 往里推入新注释。

变量声明 (VariableDeclarator)

id:左手边的变量名节点,通常 node.id.name 就能拿到 "port"

init:右手边的初始值节点,它是你要读取或替换的核心。

对象属性 (ObjectProperty)

key:键名,注意:如果是 { 'a-b': 1 },key 是 StringLiteral;如果是 { a: 1 },key 是 Identifier

value:键值,可以是任何表达式(数字、函数、另一个对象)。

computed:布尔值,如果为 true,说明是 { [prop]: 1 } 这种计算属性。

成员表达式 (MemberExpression)

object:点号左边的部分,如 process.env.PORT 中的 process.env

property:点号右边的部分,如 PORT

函数调用 (CallExpression)

arguments:一个数组,存放所有传入的参数节点,修改它就能增删函数参数。

字面量 (Literal 系列)

value:存放在 JS 里的实际值(如数字 8080,字符串 "localhost")。

raw:原始文本,比如源码写的是 0x10value16,而 raw 就是 "0x10"

1. 定义解析规则

const recast = require('recast');
const parser = require('@babel/parser');
const b = recast.types.builders; // 用来创建新的代码节点

const options = {
  // 解析器配置
  parser: {
    parse: source => parser.parse(source, {
      sourceType: 'module', // es模块化
      plugins: ['typescript'] // 开启 TS 插件
    })
  }
};

2. 从源码提取数据

比如把 const port = 8080 变成 JS 对象 { port: 8080 }

function getConfig(code) {
  const ast = recast.parse(code, options); // 先解析为 AST
  const result = {};

  recast.visit(ast, {
    // 遍历所有的变量定义
    visitVariableDeclarator(path) {
      const node = path.node;
      // node.id.name 是变量名,node.init.value 是变量的值
      result[node.id.name] = node.init.value; 
      return false; // 找到后停止向下搜寻
    }
  });
  return result;
}

3. 回写源码内容

假设把源码里的 port 改为 9090,并加上注释。

function updateConfig(oldCode, newValues) {
  const ast = recast.parse(oldCode, options); // 先解析为 AST

  recast.visit(ast, {
    visitVariableDeclarator(path) {
      const varName = path.node.id.name;
      if (newValues[varName]) {
        // 用 builder 创建一个新的 number 节点
        const newValueNode = b.numericLiteral(newValues[varName]);
        
        // 替换旧的初始值
        path.get('init').replace(newValueNode);

        // 添加一行注释
        path.parentPath.node.comments = [b.commentLine(' 自动生成的配置')];
      }
      return false;
    }
  });

  // 输出转换结果
  return recast.print(ast, { quote: 'single' }).code;
}

常用的遍历节点类型:

visitVariableDeclarator:匹配变量定义,如 const a = 1 中的 a = 1 部分。

visitObjectProperty:匹配对象属性,用于读写 { key: value } 中的键值对。

visitArrayExpression:匹配数组配置,常用于增删 [item1, item2] 中的元素。

visitImportDeclaration:匹配导入语句,用于分析或修改 import 的路径与成员。

visitExportNamedDeclaration:匹配导出语句,用于处理 export const config = {}

visitCallExpression:匹配函数调用,用于修改 init({ port: 80 }) 等执行语句的参数。

visitAssignmentExpression:匹配赋值操作,如修改 module.exports = {} 或变量重赋值。

visitMemberExpression:匹配成员访问,用于处理 process.env.NODE_ENV 这种点语法。

visitIdentifier:匹配所有标识符,即代码中出现的变量名、函数名或属性名。

visitStringLiteral / NumericLiteral:匹配字符串或数字字面量,用于直接改写基础值。

visitExpressionStatement:匹配独立的表达式语句,常用于在文件顶层插入新代码行。

visitIfStatement:匹配条件判断,用于自动化修改 if (isDev) 等逻辑分支。

visitArrowFunctionExpression:匹配箭头函数,用于重构或分析回调函数内容。

visitClassDeclaration:匹配类定义,用于提取类名、继承关系或修改装饰器。


4. 引用变量的提取与回写

处理节点时,引用变量是一个比较麻烦的地方。比如代码中不仅仅只是简单的 port: 8080,而是 port: DEFAULT_PORTpath: process.env.URL。直接读取 node.init.value 会得到 undefined,因为这些值不是字面量(Literal) ,而是标识符(Identifier)或成员表达式(MemberExpression) 。

提取逻辑示例

遇到如 IdentifierMemberExpression 等非字面量节点时,递归提取其完整路径,并用特殊标记(如 __isRef: true)包装,防止丢失引用关系。

// 递归提取引用路径
_getMemberPath(node) {
    if (node.type === 'Identifier') return node.name;
    if (node.type === 'MemberExpression') {
        // 递归向上拼接
        return `${this._getMemberPath(node.object)}.${node.property.name}`;
    }
    return '';
}

// 如果是引用则返回包装对象
if (node.type === 'Identifier' || node.type === 'MemberExpression') {
    return { __isRef: true, __refName: this._getMemberPath(node) };
}

回写逻辑示例

回写时识别标记,通过 split('.') 将路径切开,利用 reduce 配合 b.memberExpression 进行还原。

// 将字符串还原为 AST 
if (val && val.__isRef) {
    const parts = val.__refName.split('.');
    return parts.reduce((sum, cur) => {
        if (!sum) return b.identifier(cur); // 第一个基础标识符
        return b.memberExpression(sum, b.identifier(cur)); // 向下递归拼接
    }, null);
}

Recast 是如何进行自动对比的?

在执行 recast.parse 时,Recast 会为 AST 的每个节点打上一个隐藏的标签,记录该节点在原始字符串中的起始位置 loc.start 和结束位置 loc.end ,以及它周围的所有空格、换行、分号。

当你使用 replace 修改了某个节点或者它的属性时,Recast 会将该节点标记为脏节点 。

在执行 recast.print 时,Recast 的渲染器会遍历整棵树:

  • 如果是干净节点,则直接从原始字符串中,根据记录的 loc 坐标切出那一段文本。
  • 如果是脏节点,则递归调用生成器,根据 Babel 规则重新生成这一小段代码,并尝试参考父节点的缩进风格进行对齐。

耗时一个月,我把 Nuxt 首屏性能排障经验做成了一个 AI Skill

从「首屏白屏 30 秒」到「一句对话定位瓶颈」——把排障方法论编码为 AI Agent 可复用的技能包。

blog-cover.webp

背景:首屏慢得离谱,却无从下手

我们的项目是一个基于 Nuxt 3 的 SSR 应用,某天收到反馈:弱网环境下首屏白屏接近 30 秒,根组件的 onMountedwindow.load 之后很久才触发

第一反应当然是打开 Chrome DevTools 的 Performance 面板看火焰图。但问题来了:

  • Dev 模式下 Vite 的 transform 和 HMR runtime 会严重放大首屏体感,和线上差距巨大
  • preview 模式更接近生产,但每次要 build + preview 才能复测,迭代极慢
  • DevTools 的 Network 面板看单个请求还行,但要从几百个 chunk 和子资源里找出「谁拖慢了 onMounted」,完全靠肉眼排查
  • 每次排查都要手动数 TTFB、DCL、onMounted 之间的间隔,然后对照 Resource Timing 去猜瓶颈在哪一段

更痛苦的是,这些问题不是我一个人的问题 —— 团队里每个人遇到性能问题时都要重新走一遍这个排查流程。

核心痛点拆解

反复排查几轮后,我意识到问题其实可以拆成几个独立的维度:

1. 不知道瓶颈在服务端还是客户端

Nuxt SSR 的首屏链路分两段:

  • SSR 端(服务端):Nitro 中间件 → server 插件 → app.vue 服务端 setup → 页面 setup → 输出 HTML
  • CSR 端(浏览器):HTML 解析 → 客户端插件 → chunk 拉取与执行 → 路由 setup → 根 onMounted

TTFB 很短但整体慢?瓶颈在 CSR。TTFB 本身就长?瓶颈在 SSR。看似简单,但很多人第一步就判断错了方向。

2. 缺少采样边界

window.loadapp:mounted 看着差不多,但实际上「用户感觉页面可用了」的时机是 app.vueonMounted 全部执行完,而不是某个框架生命周期。没打 mark 的话,你甚至不知道这个「真正就绪」的时间点。

3. dev 和真实环境的巨大差异

dev 模式下 Vite 的 transform、HMR、sourcemap 带来巨大的额外开销。在 dev 下优化半天以为快了 2 秒,上 preview 发现只快了 200ms —— 因为 dev 的 2 秒瓶颈是 Vite 自身。

4. 排障知识无法传递

每次有新成员加入或开新项目,性能排障的方法论都要口头传授一遍。检查清单、脚本、经验全在脑子里,无法复用。

方案选型:为什么是 Skill 而不是文档或 CLI 工具?

既然要把排障流程固化下来,有几种选择:

方案 优点 缺点
写一篇 wiki 简单 没人看;每次要手动对着 wiki 操作
做一个 CLI 工具 可执行 只解决了采集,检查清单、阶段判断、结果解读还是靠人
做成 AI Agent Skill AI 引导执行全套流程 依赖 Agent 环境(Cursor/Claude Code)

最终选择了 Skill,理由:

  1. 排障不是一个纯自动化任务 — 它需要根据项目结构(monorepo?哪个子包?跑 dev 还是 preview?)做判断,这正是 AI Agent 擅长的
  2. Skill 包含「触发后检查清单」 — 不是跑一个命令就完了,而是严格保证每一步前提条件
  3. 输出格式标准化 — 每次 profile 结果以同样的 Markdown 结构呈现,方便团队对照历史数据
  4. 可分发、可安装 — 通过 npx skills add 一键安装,不依赖特定项目配置

Skill 的实现原理

整体架构

skills/nuxt-boot-timing/
├── SKILL.md                 # 技能入口:触发条件、执行流程、输出格式
├── references/
│   ├── boot-phases.md       # 冷启动阶段模型(SSR/CSR 各阶段拆解)
│   └── trigger-checklist.md # 触发后必做检查清单
└── scripts/
    ├── verify-env.mjs       # 环境校验(Node/Nuxt/Playwright 三重检查)
    ├── startup-resource-profile.mjs  # 核心:Playwright + CDP 采集脚本
    └── profile-cloudflare-startup.mjs # 一条龙:启动服务 + profile

阶段模型(SKILL.md 核心)

Skill 的核心是一张 SSR → CSR 阶段表,把 Nuxt 首屏链路拆成可独立度量的步骤:

SSR 端:

  1. Nitro / 服务端中间件
  2. Nuxt *.server 插件
  3. 根组件 app.vue 服务端 setup(含可能阻塞的顶层 await
  4. 页面 setup、@nuxt/content
  5. 输出 HTML

CSR 端:

  • 文档与入口脚本
  • 客户端插件链
  • 路由与壳、app:beforeMount
  • 根组件客户端 setup
  • 布局与首屏子树 chunk
  • 路由页 setup、根 onMounted、Suspense

有了这张表,每次排查就不再从零开始猜,而是对着阶段去定位。

采样机制:在根组件打入 mark

脚本要求必须在 app.vue 中插入一段代码:

// app.vue
onMounted(() => {
  // 放在最后一个 onMounted 里
  window.__TEENPATTI_APP_ROOT_MOUNTED__ = true
  performance.mark('teenpatti-app-root-mounted')
})

这个 mark 是整个 profile 的采样边界

  • 脚本启动 Playwright,打开页面
  • 轮询等待 window.__TEENPATTI_APP_ROOT_MOUNTED__ === true
  • page.evaluate 中采集 Navigation Timing + 所有 startTime ≤ mark.startTime 的 Resource Timing

这样做的好处:不等价于 window.load。在弱网 + 顶层 await 的场景下,onMounted 可能远晚于 load 事件。

环境校验脚本:三道防线

verify-env.mjs 在跑任何 profile 前做三层检查:

  1. Node 版本 — 必须 ≥ 18
  2. Nuxt 项目 — 从 cwd 向上遍历查找声明了 nuxtpackage.json(兼容 monorepo)
  3. Playwright 状态 — 从项目依赖、用户全局安装、本机可执行能力三个维度汇报

任何一项不满足,给出明确的修复指引,而不是让用户面对一个神秘的报错。

如何使用

安装

# 全局安装(推荐)
npx skills add vghub-official/nuxt-boot-perf-skills --skill nuxt-boot-timing -g -y

# 或在 Cursor 中手动复制
mkdir -p ~/.cursor/skills
cp -R skills/nuxt-boot-timing ~/.cursor/skills/nuxt-boot-timing

在 AI Agent 中使用(核心体验)

安装 Skill 后,你不需要手动做任何接入操作。在 Cursor 或 Claude Code 中直接对 Agent 说一句:

"首屏加载很慢,帮我排查一下"

Agent 会自动触发 nuxt-boot-timing 技能,按检查清单自动执行以下操作:

skill-workflow.webp

整个过程用户不需要手动操作脚本路径、不需要自己改 package.json,只需要在 Agent 引导下在终端执行 profile 命令。

报告格式长这样:

### 环境与前提
- 应用位置:<repo-root>/apps/web
- 运行方式:pnpm build && pnpm preview
- 约束:禁缓存 + DevTools Slow 4G

### 阶段判定
TTFB 仅 120ms,但 DCL → onMounted 间隔达 21s,瓶颈在 CSR 侧 chunk 拉取与执行。

### 证据摘要
| waitingMs | type | xfer    | url |
|-----------|------|---------|-----|
| 18500     | script | 2.3 MiB | /_nuxt/app.vue-abc123.js |
| 12400     | script | 1.1 MiB | /_nuxt/GameModal-def456.js |

### 建议下一步
1. 检查 app.vue 顶层 await 是否可改为 lazy
2. GameModal 改为动态导入,不阻塞首屏
3. preview 模式下复测确认

可配置的环境变量

这个 Skill 的脚本暴露了丰富的环境变量,覆盖各种排障场景:

变量 作用 默认值
STARTUP_PROFILE_URL 目标页面 URL http://localhost:3000/
STARTUP_PROFILE_USE_CACHE 是否使用 HTTP 缓存 禁用
STARTUP_PROFILE_NO_THROTTLE 不限速(本机带宽) 限速
STARTUP_PROFILE_THROTTLE_PRESET 限速预设 devtools-slow-4g
STARTUP_PROFILE_JSON 输出 JSON 便于 CI/jq 关闭
STARTUP_PROFILE_TOP 各排行榜条数 10
STARTUP_PROFILE_SETTLE_MS mounted 后额外等待 0

设计决策:为什么这样设计?

1. 脚本必须复制,不能现场生成

SKILL.md 明确规定:profile 脚本必须从技能目录直接复制,禁止 Agent 临时手写。这是为了防止 Agent「即兴发挥」写出不规范的脚本,导致采集口径漂移。

2. verify-env 不复制到业务仓库

校验脚本在技能目录保留单一副本,只在需要时通过绝对路径调用。因为它是排障工具的一部分,不是业务代码。

3. 不自动安装 Playwright 浏览器二进制

SKILL.md 要求 Agent 提示用户自行安装,而不是代劳。因为 npx playwright install chromium 会下载 ~150MB 的浏览器二进制,不应该在用户不知情的情况下执行。

4. Monorepo 兼容

脚本从 cwd 向上遍历找 package.json 中声明 nuxt 的目录,而不是写死路径。这在 monorepo(如 apps/webpackages/frontend)场景下至关重要。

实际效果

接入这个 Skill 后,团队的性能排障流程从:

"我看看 DevTools...这个 chunk 好像很大...TTFB 多少来着...等下我跑个 lighthouse..."

变成了:

Agent: "首屏瓶颈在 CSR 侧,根 onMounted 在 DCL 之后 21 秒。Top 3 拖慢项:app.vue chunk (2.3MiB, waiting 18.5s)、GameModal chunk (1.1MiB, waiting 12.4s)、@nuxt/content 文档数据 (waiting 8.2s)。建议:顶层 await 改 lazy、GameModal 异步加载。"

每次排查的时间从 30 分钟降到 3 分钟,而且复盘时有结构化的数据可对照。

开源地址

Skill 源码已开源在 GitHub:

github.com/vghub-offic…

安装只需一行:

npx skills add vghub-official/nuxt-boot-perf-skills --skill nuxt-boot-timing -g -y

如果你也在被 Nuxt 首屏性能问题困扰,欢迎试试,也欢迎提 PR 一起完善。


标签:Nuxt、性能优化、AI Agent、Skill、SSR、Playwright

从零开始,学习所有指令!

哈喽大家好,我是心连欣。在 Vue.js 的世界里,我们不再直接操作 DOM(比如 document.getElementById),而是通过操作 数据(Data)  来驱动 视图(View)  的变化。今天,我们就通过四个具体的场景,来看看 Vue 是如何做到这一点的。

1. 视觉控制:v-show (条件渲染)

核心思想:  用数据控制元素的“显”与“隐”。

还记得小时候玩的“躲猫猫”吗?v-show 就像是一个开关,控制着元素是否在页面上显示。

  • 原理:  根据表达式的真假(true/false),切换元素的 CSS display 属性。
  • 适用场景:  需要频繁切换显示状态的元素。

案例解析:控制图片显隐
在这个案例中,我们通过一个布尔值 isshow 来控制图片。

  • 初始状态 isshow: false,图片隐藏。
  • 点击按钮时,执行 changeisshow 方法,利用 ! 取反操作符,让 true 变 false,图片状态随之切换。

image.png 效果如下:

image.png

2. 批量处理:v-for (列表渲染)

核心思想:  用数据控制元素的“生”与“灭”。

当数据不再是单一的字符串,而是一组数据(数组或对象)时,v-for 就派上用场了。它能帮我们循环生成 DOM 结构,比如列表、表格等。

案例解析:旅游清单与美食菜单
在这个案例中,我们展示了两种用法:

  1. 遍历数组:  arr 存储了省份信息,v-for="(it, index) in arr" 拿到了每一项和索引。
  2. 遍历对象数组:  vegetables 是一个对象数组。点击“添加”按钮时,push 方法将新数据追加到数组末尾,Vue 会自动帮我们生成新的 <li> 标签。

image.png 效果如下:

image.png

3. 交互响应:v-on (事件监听)

核心思想:  监听用户的操作,执行对应的逻辑。

光有界面是不够的,网页必须能响应用户的点击、双击、鼠标移动等行为。v-on 指令(简写为 @)就是连接用户操作与 JavaScript 代码的桥梁。

案例解析:按钮与文本更新
这里我们监听了三种事件:

  • @click:单击按钮,弹出警告框。
  • @dblclick:双击按钮,也触发同一个函数。
  • @mouseenter:鼠标移入时触发。

同时,我们还演示了如何修改数据来更新视图:点击标题时,changefood 方法修改了 food 字符串,页面上的文字立刻就变了。

image.png 效果如下:

image.png 这几个指令分别代表了 Vue 的四种核心能力:条件渲染列表渲染事件监听 和 双向数据绑定

为了帮你梳理知识体系,我将你提供的四个案例整合成了一篇结构化的学习笔记。这篇文章不仅展示了代码,还解析了它们背后的逻辑,希望能帮你把零散的知识点串联起来。


🚀 Vue 2 核心指令实战:从数据到界面的魔法

在 Vue.js 的世界里,我们不再直接操作 DOM(比如 document.getElementById),而是通过操作 数据(Data)  来驱动 视图(View)  的变化。今天,我们就通过四个具体的场景,来看看 Vue 是如何做到这一点的。

1. 视觉控制:v-show (条件渲染)

核心思想:  用数据控制元素的“显”与“隐”。

还记得小时候玩的“躲猫猫”吗?v-show 就像是一个开关,控制着元素是否在页面上显示。

  • 原理:  根据表达式的真假(true/false),切换元素的 CSS display 属性。
  • 适用场景:  需要频繁切换显示状态的元素。

案例解析:控制图片显隐
在这个案例中,我们通过一个布尔值 isshow 来控制图片。

  • 初始状态 isshow: false,图片隐藏。
  • 点击按钮时,执行 changeisshow 方法,利用 ! 取反操作符,让 true 变 false,图片状态随之切换。

html

预览

<!-- 模板部分 -->
<div id="app">
    <!-- v-show 绑定 data 中的 isshow -->
    <img v-show="isshow" src="/曾婉之宝宝/image/2.jpg" alt="">
    <!-- @click 是 v-on:click 的缩写 -->
    <input type="button" value="点击切换显示状态" @click="changeisshow">
</div>

<script>
var app = new Vue({
    el: '#app',
    data: {
        isshow: false // 数据驱动视图
    },
    methods: {
        changeisshow: function(){
            // 修改数据,视图自动更新
            this.isshow = !this.isshow;
        }
    }
})
</script>

2. 批量处理:v-for (列表渲染)

核心思想:  用数据控制元素的“生”与“灭”。

当数据不再是单一的字符串,而是一组数据(数组或对象)时,v-for 就派上用场了。它能帮我们循环生成 DOM 结构,比如列表、表格等。

案例解析:旅游清单与美食菜单
在这个案例中,我们展示了两种用法:

  1. 遍历数组:  arr 存储了省份信息,v-for="(it, index) in arr" 拿到了每一项和索引。
  2. 遍历对象数组:  vegetables 是一个对象数组。点击“添加”按钮时,push 方法将新数据追加到数组末尾,Vue 会自动帮我们生成新的 <li> 标签。

html

预览

<!-- 模板部分 -->
<div id="app">
    <!-- 遍历数组 -->
    <ul>
        <li v-for="(it, index) in arr">
            {{ index+1 }}我最喜欢的地方:{{ it }}
        </li>
    </ul>
    
    <!-- 遍历对象数组,并动态绑定title属性 -->
    <h2 v-for="item in vegetables" v-bind:title="item.name">
        {{ item.name }}
    </h2>
    
    <!-- 按钮触发方法 -->
    <input type="button" value="添加" @click="add">
    <input type="button" value="删除" @click="remove">
</div>

<script>
var app = new Vue({
    el: '#app',
    data: {
        arr: ["重庆", "四川", "云南", "贵州"],
        vegetables: [
            { name: "番茄炒鸡蛋" },
            { name: "鸡蛋炒番茄" }
        ]
    },
    methods: {
        add() {
            // 修改数组,视图自动更新列表
            this.vegetables.push({ name: "土豆炒马铃薯" });
        },
        remove() {
            this.vegetables.shift();
        }
    }
})
</script>

3. 交互响应:v-on (事件监听)

核心思想:  监听用户的操作,执行对应的逻辑。

光有界面是不够的,网页必须能响应用户的点击、双击、鼠标移动等行为。v-on 指令(简写为 @)就是连接用户操作与 JavaScript 代码的桥梁。

案例解析:按钮与文本更新
这里我们监听了三种事件:

  • @click:单击按钮,弹出警告框。
  • @dblclick:双击按钮,也触发同一个函数。
  • @mouseenter:鼠标移入时触发。

同时,我们还演示了如何修改数据来更新视图:点击标题时,changefood 方法修改了 food 字符串,页面上的文字立刻就变了。

html

预览

<!-- 模板部分 -->
<div id="app">
    <!-- 监听 click 事件 -->
    <input type="button" value="单击" @click="doIt">
    <!-- 监听 dblclick 事件 -->
    <input type="button" value="双击" @dblclick="doIt">
    <!-- 监听 mouseenter 事件 -->
    <input type="button" value="鼠标" @mouseenter="doIt">
    
    <!-- 点击修改数据 -->
    <h2 @click="changefood">{{ food }}</h2>
</div>

<script>
var app = new Vue({
    el: '#app',
    data: {
        food: '番茄炒鸡蛋'
    },
    methods: {
        doIt() {
            alert('学习It'); // 执行业务逻辑
        },
        changefood() {
            // 修改数据
            this.food += '好吃!';
        }
    }
})
</script>

4. 数据同步:v-model (双向数据绑定)

核心思想:  数据变了,界面变;界面变了,数据也变。

这是 Vue 最神奇的特性之一。在传统的开发中,我们要想获取输入框的内容,必须手动去 DOM 里取。而在 Vue 中,v-model 像一根双向的管道,把输入框的值和数据对象紧紧绑定在一起。

案例解析:文本输入与回车事件
在这个例子中:

  1. 输入框绑定了 msg 数据。当你在输入框打字时,msg 的值在内存中实时改变。
  2. 页面上的 <h1>{{ msg }}</h1> 实时显示这个值。
  3. 修饰符实战:  我们用了 @keyup.enter,意思是只有当用户按下“回车键”时,才触发 getM 方法弹出警告。这展示了 Vue 如何优雅地处理键盘事件。

image.png 效果如下:

image.png

核心指令对比总结

指令 作用 核心逻辑 典型场景
v-show 条件渲染 display: none / block 开关、选项卡
v-for 列表渲染 循环生成 DOM 商品列表、表格数据
v-on 事件监听 监听用户行为 按钮点击、表单提交
v-model 双向绑定 数据 ↔ 视图 表单输入、搜索框
这些是基础的,简单的实例,让我们继续努力学习吧!😊😊😊

Next.js 实现在线工具平台:从路由设计到文件处理的完整实践

本文记录基于 Next.js 13 实现的多功能在线工具平台的前端架构设计与文件处理实践。覆盖 40+ 工具场景,采用 Next.js + Spring Boot 3.2 全栈方案。文章将深入讲解 Pages Router 的双轨路由设计、前端优先的文件处理策略,以及 Java 调用 Python 的桥接实现。


一、项目背景

最近在做一个整合 PDF 处理、图片编辑、格式转换、开发辅助等高频工具的在线平台。目标是每个工具都有真实的处理引擎、完整的用户体系,不是那种挂几个外链的导航站。

image.png

工具派 gjupai.com 技术架构.png

技术栈选型

层级 技术 版本 选型理由
前台 Next.js 13 (Pages Router) SSR 利于 SEO,动态路由适合工具化场景
前台 TailwindCSS 3.3 原子化 CSS,快速迭代 UI
后台 Vite + React 4.5 + 18 管理后台轻量启动,Ant Design 5 组件丰富
后端 Spring Boot 3.2.5 Java 生态成熟,适合复杂业务
数据库 MySQL + Redis 8.0 / 7.0 业务数据 + Token 缓存双剑合璧
转换引擎 Python 3.9 pdf2docx、PyMuPDF、Ghostscript 等库成熟稳定

二、Next.js 路由设计:双轨制架构

42 个工具怎么组织路由?每个工具一个页面文件会导致 42 个 .tsx 文件,维护成本爆炸。但如果全走动态路由,纯前端工具(如二维码生成器)又没必要统一到一个 2000 行的页面里。

我的解法是双轨路由

Next.js Pages Router 双轨路由设计.png

轨道 A:动态路由 [code].tsx

负责所有需要后端服务器处理文件的工具:

  • PDF 处理(转换、压缩、合并拆分)
  • Office 转换(Word/Excel/PPT 互转)
  • 图片处理(压缩、格式转换、批量处理)
  • 音视频(压缩、格式转换、提取音频)

核心路由守卫逻辑:

// src/pages/tools/[code].tsx
const PURE_FRONTEND_TOOLS = [  'batch_rename', 'qrcode_generator', 'file_encryptor',  'data_cleaner', 'regex_tester', 'chart_generator',  // ... 共 28 个纯前端工具];

export default function ToolDetailPage() {
  const router = useRouter();
  const { code } = router.query;

  useEffect(() => {
    if (!code) return;
    const backendCode = Array.isArray(code) ? code[0] : code;
    
    // 白名单命中 → 跳转独立页面
    if (PURE_FRONTEND_TOOLS.includes(backendCode)) {
      router.replace(`/tools/${backendCode}`);
      return;
    }
    
    // 否则留在动态路由页面,走后端处理流程
    loadToolDetail(backendCode);
  }, [code]);
  
  // ... 2000+ 行的工具详情页渲染
}

轨道 B:独立页面 {code}.tsx

每个纯前端工具拥有独立的页面文件,不依赖后端文件转换,直接在浏览器中完成处理:

// src/pages/tools/batch_rename.tsx
// src/pages/tools/qrcode_generator.tsx
// src/pages/tools/file_encryptor.tsx
// ...

这些页面的共同特点是:

  • 从后端只获取工具元数据和额度信息
  • 文件处理完全在浏览器内完成(pdf-libcrypto.subtleJSZip 等)
  • 处理结果通过 URL.createObjectURL(blob) 生成本地下载链接

为什么不用 App Router?

项目启动时 Next.js 13 的 App Router 还不够稳定,且 Pages Router 的动态路由语法 [param] 对于工具型站点更直观。另外,项目大量使用了 getServerSideProps 来做 SEO 数据注入,迁移成本较高。如果今天重新选型,我可能会评估 App Router 的 Server Components 对首屏性能的提升。


三、文件处理体系:前端优先,后端兜底

在线工具平台的核心是文件处理能力。我的设计哲学是:

能前端处理的绝不走服务器。减少带宽消耗、降低服务器压力、保护用户隐私。

文件处理双模式-前端优先 vs后端兜底.png

image.png

模式一:浏览器端处理(纯前端)

1. PDF 合并/拆分(pdf-lib)

对于小于 100MB 的 PDF 文件,直接在浏览器内完成合并,免去上传等待:

// src/utils/pdfUtils.ts
import { PDFDocument } from 'pdf-lib';

const FRONTEND_SIZE_LIMIT = 100 * 1024 * 1024; // 100MB
const FRONTEND_FILE_COUNT_LIMIT = 10;

export function canProcessInFrontend(files: File[]): boolean {
  if (files.length > FRONTEND_FILE_COUNT_LIMIT) return false;
  const totalSize = files.reduce((sum, f) => sum + f.size, 0);
  return totalSize <= FRONTEND_SIZE_LIMIT;
}

export async function mergePdfsInFrontend(
  files: File[], 
  outputFileName: string
) {
  const mergedDoc = await PDFDocument.create();
  let totalPages = 0;

  for (const file of files) {
    const arrayBuffer = await file.arrayBuffer();
    const pdf = await PDFDocument.load(arrayBuffer);
    const pages = await mergedDoc.copyPages(pdf, pdf.getPageIndices());
    pages.forEach((page) => mergedDoc.addPage(page));
    totalPages += pages.length;
  }

  const mergedBytes = await mergedDoc.save({
    useObjectStreams: true,
    addDefaultPage: false,
  });

  const blob = new Blob([mergedBytes], { type: 'application/pdf' });
  return {
    url: URL.createObjectURL(blob),
    fileName: outputFileName,
    size: blob.size,
    pageCount: totalPages,
  };
}

2. 文件 AES-GCM 加密(Web Crypto API)

文件加密这种敏感操作,走前端意味着用户的密码和文件不会离开浏览器

const SALT_LEN = 16;
const IV_LEN = 12;
const ITERATIONS = 100000;

async function getKey(password: string, salt: Uint8Array): Promise<CryptoKey> {
  const enc = new TextEncoder();
  const keyMaterial = await crypto.subtle.importKey(
    'raw', enc.encode(password), { name: 'PBKDF2' },
    false, ['deriveKey']
  );
  return crypto.subtle.deriveKey(
    { name: 'PBKDF2', salt, iterations: ITERATIONS, hash: 'SHA-256' },
    keyMaterial,
    { name: 'AES-GCM', length: 256 },
    false, ['encrypt', 'decrypt']
  );
}

async function encryptFile(file: File, password: string): Promise<Blob> {
  const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
  const iv = crypto.getRandomValues(new Uint8Array(IV_LEN));
  const key = await getKey(password, salt);
  const data = new Uint8Array(await file.arrayBuffer());
  const encrypted = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv }, key, data
  );

  // salt(16) + iv(12) + encrypted
  const result = new Uint8Array(SALT_LEN + IV_LEN + encrypted.byteLength);
  result.set(salt, 0);
  result.set(iv, SALT_LEN);
  result.set(new Uint8Array(encrypted), SALT_LEN + IV_LEN);

  return new Blob([result], { type: 'application/octet-stream' });
}

3. CSV/Excel 数据清洗(PapaParse + XLSX)

import Papa from 'papaparse';
import * as XLSX from 'xlsx';

function handleFile(f: File) {
  const ext = f.name.split('.').pop()?.toLowerCase();
  
  if (ext === 'csv') {
    Papa.parse(f, {
      complete: (results) => {
        const data = results.data as string[][];
        setHeaders(data[0]);
        setRows(data.slice(1).filter(r => r.some(c => c !== '')));
      },
      skipEmptyLines: true,
    });
  } else if (ext === 'xlsx' || ext === 'xls') {
    const reader = new FileReader();
    reader.onload = (e) => {
      const data = new Uint8Array(e.target?.result as ArrayBuffer);
      const workbook = XLSX.read(data, { type: 'array' });
      const sheet = workbook.Sheets[workbook.SheetNames[0]];
      const json = XLSX.utils.sheet_to_json(sheet, { header: 1 }) as any[][];
      // ... 数据清洗逻辑
    };
    reader.readAsArrayBuffer(f);
  }
}

模式二:服务端处理(Java + Python 桥接)

大文件或复杂格式转换(如 Word 转 PDF、视频压缩)必须走后端。核心流程:

前端上传:

const handleUpload = async () => {
  // 1. 前端 PDF 优先处理(小文件免上传)
  if (isPdfMergeSplitTool && operationType === 'merge') {
    if (canProcessInFrontend(files)) {
      const result = await mergePdfsInFrontend(files, 'merged.pdf');
      setResultUrl(result.url);
      return;
    }
  }

  // 2. 启动模拟进度条
  let simulatedProgress = 0;
  const progressInterval = setInterval(() => {
    simulatedProgress += Math.random() * 4 + 2;
    if (simulatedProgress >= 90) {
      simulatedProgress = 90;
      clearInterval(progressInterval);
    }
    setProgress(Math.min(simulatedProgress, 90));
  }, 300);

  // 3. 构建 FormData
  const formData = new FormData();
  if (isPdfMergeSplitTool && operationType === 'merge') {
    files.forEach((f) => formData.append('files', f));
    formData.append('toolCode', 'pdf_merge_split');
    formData.append('mode', 'merge');
  } else {
    formData.append('file', file!);
  }

  // 附加工具参数(JSON 序列化)
  if (isPdfToTool) {
    const params = { format: targetFormat, pageRanges: pdfPageRanges, dpi: pdfDpi };
    formData.set('targetFormat', JSON.stringify(params));
  }

  // 4. 上传
  const uploadRes = await fetch(`/api/tool/upload/${actualToolCode}`, {
    method: 'POST',
    headers: token ? { Authorization: `Bearer ${token}` } : {},
    body: formData,
  });

  const uploadData = await uploadRes.json();
  if (uploadData.code === 200) {
    clearInterval(progressInterval);
    setProgress(100);
    setResultUrl(uploadData.data.resultUrl);
  }
};

后端桥接(Java 调用 Python):

// ProcessExecutor.java
@Component
public class ProcessExecutor {
    public ProcessResult execute(String pythonPath, String scriptPath, 
                                  int timeoutSeconds, String... args) {
        // Windows 兼容处理
        boolean isWindows = pythonPath.contains("\") || pythonPath.contains(":");
        List<String> commandList = new ArrayList<>();
        
        if (isWindows) {
            commandList.add("cmd.exe");
            commandList.add("/c");
            commandList.add("chcp 65001 >nul && " + pythonPath + " " 
                + scriptPath + " " + String.join(" ", args));
        } else {
            commandList.add(pythonPath);
            commandList.add(scriptPath);
            for (String arg : args) commandList.add(arg);
        }

        ProcessBuilder builder = new ProcessBuilder(commandList);
        builder.environment().put("PYTHONIOENCODING", "utf-8");
        builder.redirectErrorStream(true);
        
        Process process = builder.start();
        boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS);
        
        if (!finished) {
            process.destroyForcibly();
            return new ProcessResult(false, "处理超时", null, elapsedTime);
        }
        
        // 读取 Python 输出的 JSON 结果
        String output = IOUtils.toString(process.getInputStream(), StandardCharsets.UTF_8);
        return new ProcessResult(true, null, output, elapsedTime);
    }
}

Python 转换引擎(PDF 压缩示例):

# pdf_compress.py
def compress_with_ghostscript(input_path, output_path, level, 
                               target_size_kb=None, image_quality=None):
    gs_cmd = find_ghostscript()  # gswin64c / gswin32c / gs
    
    settings_map = {
        'low': '/printer',     # 300 DPI
        'medium': '/ebook',    # 150 DPI
        'high': '/screen',     # 72 DPI
    }
    
    cmd = [
        gs_cmd, '-sDEVICE=pdfwrite',
        '-dCompatibilityLevel=1.4',
        f'-dPDFSETTINGS={settings_map[level]}',
        '-dDownsampleColorImages=true',
        '-dColorImageResolution=' + str(target_dpi),
        '-dCompressFonts=true',
        '-dSubsetFonts=true',
        f'-sOutputFile={output_path}',
        input_path
    ]
    
    result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
    return { 'success': result.returncode == 0, 'output': output_path }

四、性能与体验优化

1. 文件大小分级策略

场景 阈值 处理方式
PDF 合并/拆分 ≤ 100MB,≤ 10 个文件 浏览器端 pdf-lib 处理
PDF 合并/拆分 > 100MB 或 > 10 个文件 上传后端 Python 处理
图片压缩 不限(前端 Canvas) 浏览器端 Canvas 压缩
视频/GIF 单文件 ≤ 50MB 上传后端 FFmpeg 处理
Word/PPT 转 PDF 不限 后端 LibreOffice 转换

2. 进度条设计

后端处理类工具需要进度反馈。我的方案是前端模拟 + 后端确认

// 前端启动模拟进度(每 300ms 增加 2~6%)
const progressInterval = setInterval(() => {
  simulatedProgress += Math.random() * 4 + 2;
  if (simulatedProgress >= 90) {
    simulatedProgress = 90;
    clearInterval(progressInterval);
  }
  setProgress(Math.min(simulatedProgress, 90));
}, 300);

// 后端返回后冲到 100%
if (uploadData.code === 200) {
  clearInterval(progressInterval);
  setProgress(100);
}

这种方案避免了 WebSocket 的复杂度,用户感知上足够流畅。

image.png


五、踩坑记录

  1. Next.js Pages Router 刷新 404

    部署后发现直接刷新 /tools/pdf_to_word 会 404。解决:Nginx 配置 try_files 回退到 index.html,由 Next.js 客户端路由接管。

  2. Python 进程假死

    早期未设置超时,大 PDF 转换时 Python 进程挂死导致 Java 线程阻塞。解决:ProcessExecutor 增加 waitFor(timeout) 机制,超时强制 destroyForcibly()

  3. Windows 本地开发 vs Linux 生产环境

    Python 路径、LibreOffice 路径、Ghostscript 路径在不同系统完全不同。解决:配置文件中按系统类型分别指定路径,启动时做环境检查。

  4. 文件类型白名单绕过

    早期只检查后缀名,有被上传 .pdf.exe 的风险。解决:后缀名 + MIME 类型双重校验,后端存储时重命名为 UUID。


六、总结

这个项目让我对"全栈独立开发"有了更深的理解:

  • 路由设计:双轨制不是过度设计,而是 42 个工具在可维护性和用户体验之间的最优解
  • 文件处理:前端优先策略让平台在 2核4G 的服务器上也能流畅运行,同时保护了用户隐私
  • 跨语言桥接:Java + Python 不是最优解,但在现有团队技能栈和开源生态下是最务实的选择

如果你也在做类似的工具平台,欢迎交流。项目已开源核心思路,具体代码涉及商业逻辑不便公开,但本文的架构设计和核心片段应该足够参考。


  • 项目已上线,地址请查看我的主页

如果这篇文章对你有帮助,欢迎点赞收藏。有问题可以在评论区讨论,我会尽量回复。

超越 Vibe Coding —— AI 辅助编程指南

你好,我是冴羽

用 AI 写代码,70% 的功能 5 分钟就能搞定,但剩下的 30% 能让你崩溃一整天。

我专门研究了 Google 工程师 Addy Osmani 写的《Beyond Vibe Coding》。

他用 25 年的开发经验告诉你:

  • Vibe Coding(氛围编程): 70% 进度 5 分钟,剩下 30% 要 3 天

  • AI-Assisted Engineering (AI 辅助工程):从原型到生产环境,全流程可控

两种方式的差距,就是“能跑的 Demo”和“能上线的产品”的差距。

1. 什么是 Vibe Coding?

1.1. 定义

Vibe Coding 是一种“随性”的开发方式:

你给 AI 一个模糊的需求,它给你一堆代码,你看都不看直接运行,关注的是整体“感觉”而不是实现细节。

特斯拉前 AI 总监 Andrej Karpathy 描述过这种未来:“我只是看看东西、说说话、跑跑代码、复制粘贴,然后它就能工作了。”

听起来很美好对吧?

1.2. 70% 问题

但现实是:Vibe Coding 能让你快速达到 70%,剩下 30% 会让你怀疑人生。

具体表现:

  • 两步前进,三步后退:修一个 Bug,冒出来三个新 Bug

  • 隐藏成本:没有工程知识,代码根本没法维护

  • 边际递减:AI 工具对有经验的开发者帮助更大,新手反而更容易踩坑

  • 安全漏洞:“Vibe Coding 很爽,直到你开始泄露数据库密码”

**但这并不是说:**Vibe Coding == 低质量代码。

它只是一种特定的开发方式,对于生产系统,你需要考虑的远不止“能跑”。

2. AI 编程 4 大坑

2.1. 坑 1:上来就让 AI 写代码

❌ 错误示范:“帮我做一个 Todo 应用”

✅ 正确示范:“给我几个 Todo 应用的架构方案,从最简单的开始。
先别写代码,只列出思路,让我选一个方向。”

🚀 最佳实践:写一个 mini PRD —— 定义问题、用户旅程、预期结果

这是因为 AI 十有八九会提出一个过于复杂的方案。你要先让它规划,再让它实现。

目前很多 AI 编程工具都支持“Plan Mode(规划模式)”:

  • Cline:先生成计划,再执行

  • Bolt:支持“Enhance Prompt”,把粗糙的想法变成结构化的需求

2.2. 坑 2:不提供文档就让 AI 写代码

  1. 检查知识门槛

很多模型只知道 Tailwind v3,但 v4 其实在 2025 年就发布了。

  1. 附上相关文件

当你用特定的 API 或框架时,把官方文档喂给 AI。

  1. 设置全局规则:
始终遵循这些原则:

1. 先定义数据模型,再写代码
2. 用 Mock 数据,别一上来就搞数据库
3. 创建组件库,把代码拆分到多个文件
4. 集中管理状态
5. 分批实现,别一次写太多
6. 改代码前确认改的是正确的文件
7. 需求不清楚就问

对应英文版:

// Example system prompt
Always follow these guidelines:

1. Define the data model before writing code
2. Start with mock data instead of a database
3. Create a component library and split code into multiple files
4. Centralize state management
5. Batch implementation into smaller chunks
6. Double-check you're changing the correct files
7. Ask follow-up questions if requirements are unclear

2.3. 坑 3:纯文字描述 UI

一张图胜过千言万语。 当你让 AI 实现设计或修 Bug 时,直接截图。

现在的 AI 编程工具都支持:

  • 从 Figma 导入设计:无缝集成设计和代码

  • 把图片添加到提示词:让 AI 理解视觉上下文

  • 引入实时浏览器截图:实时抓取页面状态

2.4. 坑 4:懒得测试

不管你多么小心,AI 总会在某个时刻破坏你的应用。

所以:

  • 每次更新后都在 localhost 测试

  • 打开浏览器控制台检查错误

  • 小步测试才能避免噩梦般的 Debug 过程

3. Prompt 工程 5 条原则

3.1. 提供足够的上下文

永远假设 AI 对你的项目一无所知。

❌ 错误:"为什么我的代码不工作?"

✅ 正确:"这个 React hooks 函数应该在表单提交时更新用户资料,
但现在报错'Cannot read property name of undefined'。
代码如下:

const updateProfile = (userData) => {
setUser(userData.name);
};

错误发生在第 2 行。使用 React 18.2.0。"

3.2. 明确你的目标

模糊的问题会得到模糊的答案。要具体说明:

  • 预期行为是什么

  • 当前(错误的)行为是什么

  • 相关的约束或要求

  • 期望的输出格式

3.3. 拆解复杂任务

把大问题分成小块,逐步推进。

举个例子:构建用户认证系统

  1. 首先:“设计用户认证的数据库 schema”

  2. 然后:“创建用户注册接口”

  3. 接着:“实现密码哈希和验证”

  4. 最后:“添加 JWT token 生成和验证”

3.4. 提供输入输出示例

用具体例子来减少歧义。

创建一个格式化货币的函数。

示例:

- formatCurrency(2.5) 应该返回 "$2.50"
- formatCurrency(1000) 应该返回 "$1,000.00"
- formatCurrency(0.99) 应该返回 "$0.99"

3.5. 使用角色和人设

让 AI “扮演”特定角色能改变回答的风格和深度。

有效的人设:

  • 资深 React 开发者:“作为一个资深 React 开发者,review 我的代码找潜在 Bug”

  • 性能专家:“你是 JavaScript 性能专家,优化下面这个函数”

  • 代码审查员:“以安全专家的角度 review 这段代码”

4. 生产代码 4 条原则

4.1. 始终 Review AI 生成的代码

把 AI 生成的代码当作初级开发者写的代码。 需要仔细 review 和测试才能提交。

列出检查清单:

  • ✅ 安全漏洞

  • ✅ 错误处理

  • ✅ 性能影响

  • ✅ 可维护性标准

4.2. 有完整的测试策略

AI 可以帮你生成测试,但你必须验证覆盖率和质量。

"为这个用户认证函数生成完整的单元测试。包括:

- 有效凭证
- 无效凭证
- 网络错误
- 畸形输入
- 边界情况如空字符串
- 安全场景如 SQL 注入尝试

使用 Jest,遵循我们在 /tests/auth/ 的现有测试模式"

4.3. 安全优先

AI 可能引入安全漏洞。始终验证安全实践。

累出安全验证清单:

  • ✅ 输入验证和清理

  • ✅ 认证和授权

  • ✅ SQL 注入防护

  • ✅ XSS 防护

  • ✅ 敏感数据处理

  • ✅ API key 和凭证管理

  • ✅ HTTPS 和安全通信

4.4. 性能和可扩展性

AI 可能生成能用但低效的代码。始终考虑性能。

"优化这个数据库查询,表有 100 万+记录。考虑:

- 合适的索引策略
- 查询执行计划
- 内存使用
- 连接池
- 缓存机会

解释你的优化选择,包括优化前后的性能对比。"

5. 未来 AI 辅助开发的完整工作流

想象一下这样的开发体验:

🎯 意图定义 → 用自然语言描述你想构建什么,AI 理解上下文、需求和约束

📋 智能规划 → AI 生成详细的技术方案,考虑架构决策,建议最优实现策略

🏗️ 自主实现 → AI agent 跨多个文件实现功能,处理集成,生成完整测试

🔍 智能 Review → AI 提供详细的代码审查、安全分析和性能优化建议

🚀 自动部署 → AI 管理部署流程,监控性能,提供优化建议

这其实都不算是未来了,而是现在已经在跑的东西。

我是冴羽,10 年笔耕不辍,专注前端领域,更新了 10+ 系列、300+ 篇原创技术文章,翻译过 Svelte、Solid.js、TypeScript 文档,著有小册《Next.js 开发指南》、《Svelte 开发指南》、《Astro 实战指南》。

欢迎围观我的“网页版朋友圈”,关注我的公众号:冴羽(或搜索 yayujs),每天分享前端知识、AI 干货。

React 首页秒开优化:用 KeepAlive 实现丝滑的页面缓存

React 首页秒开优化:用 KeepAlive 实现丝滑的页面缓存

你是否经历过这样的场景:用户辛辛苦苦滚动了好几屏内容,点进一篇文章看完返回,首页又从头加载,滚动位置全丢了。这种体验对用户来说就像刚到手的冰淇淋掉在了地上——瞬间兴致全无。

本文将带你一步步实现 React 首页 KeepAlive 缓存,让用户在页面间来回切换时保持组件状态、滚动位置,体验接近原生 App。


为什么需要 KeepAlive?

React 的路由切换本质上是卸载旧组件、挂载新组件。这意味着:

问题 表现
状态丢失 useStateuseReducer 全部重置
数据重载 useEffect 再次执行,重复请求 API
滚动丢失 页面回到顶部,用户需要重新翻找
加载白屏 大组件重新渲染,出现短暂 loading

以一个典型的内容流首页为例:用户滚动了 5 页无限滚动内容、浏览了 30+ 篇文章卡片,然后点进去看了一篇详情。返回后,以上全部白费。

KeepAlive 的核心思想:将组件的 DOM 节点和内部状态缓存起来,路由切走时不销毁,切回来时直接复用。


技术选型:react-activation

社区中有几个 KeepAlive 方案,本项目选择 react-activationv0.13.4),原因如下:

  • API 设计友好:对标 Vue 的 <keep-alive>,学习成本极低
  • 滚动位置恢复:内置 saveScrollPosition 属性,开箱即用
  • React 18/19 兼容:基于 Portals 实现,生命周期管理完善
  • 轻量无侵入:包裹现有组件即可,不需要重构路由结构
pnpm add react-activation

实现步骤

第一步:在路由根部挂载 AliveScope

AliveScope 是 KeepAlive 的全局上下文容器,维护一个 DOM 缓存池。它必须包裹在路由组件的最外层:

// src/router/index.tsx
import ReactActivation from 'react-activation'
const { AliveScope } = ReactActivation as any

export default function RouterConfig() {
    return (
        <Router>
            <AliveScope>                    {/* 缓存容器 */}
                <Suspense fallback={<Loading />}>
                    <Routes>
                        <Route path='/' element={<MainLayout />}>
                            <Route index element={<Home />} />
                            <Route path='order' element={<Order />} />
                            <Route path='chat' element={<Chat />} />
                            <Route path='mine' element={<Mine />} />
                        </Route>
                        <Route path='/login' element={<Login />} />
                        <Route path='/post/:id' element={<PostDetail />} />
                    </Routes>
                </Suspense>
            </AliveScope>
        </Router>
    )
}

注意react-activation 是 CommonJS 模块,在 Vite 的 ESM 环境下,需要默认导入后解构取组件。详见文末"踩坑记录"。

AliveScope 的原理是在内存中维护一个 DOM 缓存池(一个隐藏的 <div>)。当被 KeepAlive 包裹的组件"卸载"时,其真实 DOM 被移入缓存池而非销毁;"重新激活"时,DOM 从缓存池移回原位。

第二步:用 KeepAlive 包裹需要缓存的组件

创建一个 KeepAliveHome 组件,将首页包裹起来:

// src/components/KeepAliveHome.tsx
import ReactActivation from 'react-activation'
import Home from '@/pages/Home'

const { KeepAlive } = ReactActivation as any

const KeepAliveHome = () => {
    return (
        <KeepAlive name='home' saveScrollPosition='screen'>
            <Home />
        </KeepAlive>
    )
}

export default KeepAliveHome

这里两个属性是关键:

  • name='home':给缓存起一个唯一名称。同一个 name 的缓存实例会被复用,不同 name 的缓存互不干扰。如果你有多个页面需要缓存(如首页和订单页),给不同的 name 即可。

  • saveScrollPosition='screen':自动保存和恢复滚动位置。'screen' 表示按屏幕视口维度记忆,你也可以传 true 使用默认行为。

第三步:懒加载 + 路由配置

结合 React.lazy 实现代码分割,让首页的 KeepAlive 逻辑按需加载:

// src/router/index.tsx
import { lazy } from 'react'

const Home = lazy(() => import('@/components/KeepAliveHome'))

在路由中使用时,Home 就是包裹了 KeepAlive 的首页组件:

<Route index element={<Home />} />

当用户从首页切到 /post/:id 详情页时:

  1. Home 组件的真实 DOM 被 AliveScope 移入缓存池(不销毁)
  2. 组件内部的 useState、Zustand store、useRef 全部保持原样
  3. 滚动位置被记录

当用户从详情页返回时:

  1. DOM 从缓存池移回页面原位
  2. 组件状态原封不动地恢复
  3. 滚动位置瞬间还原到离开时的位置

整个过程没有 loading 闪烁,没有重复的网络请求。


无限滚动 + KeepAlive 的协同效应

首页使用了 IntersectionObserver 实现的无限滚动(InfiniteScroll 组件):

用户滚动 → 哨兵元素进入视口 → onLoadMore 触发 → fetchPosts(page) → posts 追加到 Zustand
场景 无 KeepAlive 有 KeepAlive
用户滚到第 3 页 20 条帖子已渲染 20 条帖子已渲染
点进详情页 组件卸载,posts 重置为空数组 组件缓存,posts 保持 20 条
返回首页 重新加载第 1 页,用户要重新滚 直接展示 20 条,停留在第 3 页

KeepAlive 缓存了整个组件树,Zustand store 的状态也一并保留——posts 数组、page 计数、hasMore 标记全部完好。用户返回时,连 useEffect 都不会重新执行(因为组件没有重新挂载)。

这里有一个值得注意的细节:InfiniteScrolluseEffect cleanup 函数在组件卸载时会调用 observer.unobserve(),但在 KeepAlive 模式下组件并没有真正卸载——react-activation 通过 HOC 机制让生命周期钩子(useActivate / useUnactivate)来区分"缓存隐藏"和"真正卸载"。


数据流全景

                  ┌──────────────────────────┐
                         AliveScope         
                     (DOM 缓存池容器)        
                                            
  Route: /  ───▶    ┌────────────────────┐  
                      KeepAlive(name='home')│
                      ┌────────────────┐   
                           Home          
                        - Header         
                        - SlideShow      
                        - InfiniteScroll  
                        - PostItem[]     
                      └────────────────┘   
                    └────────────────────┘  
                                            
  Route: /post/:id│   (Home DOM 移入缓存池)   
                  └──────────────────────────┘
                         
                    Zustand Store
                  ┌─────────────────┐
                   posts: Post[]      数据不丢失
                   page: 3            分页状态保留
                   hasMore: true   
                   loading: false  
                  └─────────────────┘

你可能遇到的坑与解法

1. Vite + CJS 模块:导入为 undefined

react-activation 是 CommonJS 模块,在 Vite 的纯 ESM 环境下,命名导入 import { KeepAlive } 会得到 undefined,默认导入 import KeepAlive from 会得到整个 module.exports 对象。

解法:默认导入后解构:

import ReactActivation from 'react-activation'
const { KeepAlive } = ReactActivation as any

as any 是为了绕过 TypeScript 对 CJS 导入类型的限制。

2. 缓存命名冲突

如果多个页面的 KeepAlive 使用了相同的 name,它们会互相覆盖。确保每个需要缓存的页面有唯一的 name

<KeepAlive name='home'>    <Home />    </KeepAlive>
<KeepAlive name='order'>   <Order />   </KeepAlive>
<KeepAlive name='chat'>    <Chat />    </KeepAlive>

3. 不需要缓存的页面不要包裹

像登录页、纯静态页这类不需要缓存的页面,直接用原始组件,不要用 KeepAlive 包裹。过度缓存反而占用内存。

4. 内存考量

被缓存的组件 DOM 一直存在于内存中。对于首页这种核心流量入口,缓存是值得的;但如果你的页面包含大量图片或视频,建议配合虚拟列表或图片懒加载来平衡内存占用。


总结

KeepAlive 不需要改变任何业务代码,只用在路由层做两件事:

  1. 外层套 AliveScope — 提供缓存能力
  2. 目标组件套 KeepAlive — 启用缓存

成本极低,收益显著:页面秒切、状态不丢、请求不重发、滚动位置精准还原。对于内容流、列表页这类"浏览 → 点进 → 返回"的典型场景,KeepAlive 是投入产出比最高的优化手段之一。


实现环境:React 19 + Vite 8 + react-activation 0.13.4 + Zustand 5 + react-router-dom 7

Node.js 从零开发 MCP 服务:30 分钟上手,对接 Claude/Cursor 全流程

大家好,我是一名 Node.js 开发者。最近 MCP(Model Context Protocol)协议爆火,作为 Anthropic 推出的模型上下文协议,它能标准化大模型与外部工具、数据的连接,只要开发一个 MCP 服务,就能让 Claude、Cursor 等主流 AI 客户端拥有自定义能力——比如调用本地接口、操作数据库、解析文件等,相当于给 AI 装了个“自定义插件”。

之前很多小伙伴问我,Node.js 怎么开发 MCP 服务,网上相关教程要么零散,要么偏向 Python 版本,今天就给大家带来一篇从零到上线、可直接复制运行 的 Node.js 版 MCP 开发全指南,全程 30 分钟,新手也能轻松上手,最后还会讲解客户端对接和生产环境部署,干货拉满!

一、先搞懂:MCP 服务到底是什么?

在动手之前,先简单理清核心概念,避免盲目开发。MCP 采用客户端-服务端架构,核心是“让 AI 能调用我们自定义的功能”,关键角色分为 3 个:

  • MCP 服务端(我们要开发的):暴露工具(可执行函数)、资源(只读数据)、提示词(预定义模板)给 AI 客户端调用;
  • MCP 客户端:Claude Desktop、Cursor、Cline 等支持 MCP 协议的 AI 应用;
  • 传输层:负责两者通信,常用两种方式——本地开发用 STDIO(进程间直接通信,无网络开销),远程部署用 HTTP + SSE(支持公网访问)。

简单说:我们开发的 Node.js MCP 服务,就是 AI 客户端的“能力扩展插件”,你想让 AI 做什么,就开发对应的工具/资源,比如让 AI 帮你读取本地文件、调用公司接口,都能通过 MCP 实现。

二、前置准备:3 分钟搭好开发环境

开发 Node.js 版 MCP 服务,不需要复杂框架,只需要两个基础环境,新手也能快速搞定:

1. 环境要求

  • Node.js:≥ 20(推荐 LTS 版本,避免兼容性问题);
  • 包管理器:npm、yarn、pnpm 任意一个(本文用 npm 演示)。

检查环境是否合格,终端输入以下命令:

node -v  # 输出 ≥ v20.0.0 即可
npm -v   # 输出任意版本均可

2. 核心依赖(必装)

MCP 官方提供了 Node.js SDK,还有一个参数校验工具(官方推荐,避免参数异常导致服务崩溃),终端执行以下命令安装:

# 核心 MCP SDK(官方包,处理协议通信)
npm install @modelcontextprotocol/sdk

# 参数校验工具(Zod,官方推荐,必装)
npm install zod

这里不需要额外安装 Express、Koa 等 Web 框架,纯原生 Node.js 就能跑通,极大降低开发成本。

三、核心开发:30 分钟写出可运行的 MCP 服务

我们采用“最小可用原则”,先开发一个包含「工具 + 资源」的基础 MCP 服务,支持本地 STDIO 通信,能直接对接 Claude 客户端,后续再扩展远程部署和更多功能。

1. 项目结构(极简,不冗余)

不用复杂的目录结构,一个入口文件 + 配置文件就够了,新手也能快速理清逻辑:

node-mcp-server/          # 项目根目录
├── index.js              # 服务入口(核心代码)
└── package.json          # 依赖和配置

2. package.json 配置(关键,避免运行报错)

新建 package.json 文件,复制以下内容,重点注意 type: "module"(必须加,支持 ES 模块导入):

{
  "name": "node-mcp-server",
  "version": "1.0.0",
  "type": "module",  // 关键:支持 ES 模块,否则 import 会报错
  "main": "index.js",
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.0.0",
    "zod": "^3.22.4"
  },
  "scripts": {
    "start": "node index.js",  // 启动命令
    "dev": "node --watch index.js"  // 开发热更新(Node.js 18+ 支持)
  }
}

3. 完整核心代码(可直接复制运行)

新建 index.js 文件,这是整个 MCP 服务的核心,包含服务初始化、工具注册、资源注册、服务启动四个步骤,每一行都加了注释,新手也能看懂:

// 导入核心依赖
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';

// 1. 初始化 MCP 服务实例
// name:服务名称(自定义),version:版本号(自定义)
const mcpServer = new McpServer({
  name: 'node-mcp-demo',
  version: '1.0.0',
});

// 2. 注册工具(核心功能:让 AI 能调用的可执行函数)
// 示例1:两数相加工具(简单演示,可替换成你的业务逻辑)
mcpServer.tool(
  'addNumbers',  // 工具唯一标识(AI 调用时会用到)
  // 参数校验规则(用 Zod 定义,避免传入非数字导致报错)
  {
    a: z.number().describe('需要相加的数字 A'),
    b: z.number().describe('需要相加的数字 B'),
  },
  // 工具执行逻辑(async 函数,支持异步操作,比如调用接口、查数据库)
  async ({ a, b }) => {
    // 返回结果给 AI 客户端,格式必须是 { content: [{ type: 'text', text: '结果' }] }
    return {
      content: [{ type: 'text', text: `两数相加结果:${a + b}` }],
    };
  }
);

// 示例2:获取本地应用配置(模拟业务场景,可替换成读取本地文件、数据库)
mcpServer.tool(
  'getAppConfig',
  {},  // 该工具无需参数,传空对象即可
  async () => {
    // 模拟从数据库/配置文件获取数据
    const config = {
      appName: 'Node.js MCP 服务',
      version: '1.0.0',
      author: '掘金开发者',
      enable: true
    };
    return {
      content: [{ type: 'text', text: `应用配置:\n${JSON.stringify(config, null, 2)}` }],
    };
  }
);

// 3. 注册资源(只读数据,给 AI 提供上下文,比如配置、静态数据)
mcpServer.resource(
  'config://appInfo',  // 资源唯一 URI(AI 获取时会用到)
  '应用基础配置信息(只读)',  // 资源描述
  async () => {
    return {
      contents: [
        {
          uri: 'config://appInfo',  // 与上面的资源 URI 一致
          text: JSON.stringify({
            version: '1.0.0',
            updateTime: '2026-05-08',
            desc: 'Node.js 开发的 MCP 服务示例,用于对接 Claude 客户端'
          }, null, 2)
        }
      ]
    };
  }
);

// 4. 启动 MCP 服务(本地模式:STDIO 传输,适合开发调试)
async function startServer() {
  // 初始化 STDIO 传输(本地进程间通信)
  const transport = new StdioServerTransport();
  // 连接传输层并启动服务
  await mcpServer.connect(transport);
  // 注意:MCP 服务只能用 console.error 打印日志(避免占用 STDIO 通信通道)
  console.error('✅ Node.js MCP 服务启动成功(STDIO 模式)');
  console.error('📌 可对接 Claude/Cursor 客户端进行测试');
}

// 启动服务并捕获错误
startServer().catch(error => {
  console.error('❌ MCP 服务启动失败:', error.message);
  process.exit(1);
});

4. 本地运行测试(1 分钟验证)

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

npm start

如果终端输出以下内容,说明服务启动成功(STDIO 模式会保持进程运行,等待 AI 客户端连接):

✅ Node.js MCP 服务启动成功(STDIO 模式)
📌 可对接 Claude/Cursor 客户端进行测试

注意:不要关闭终端,关闭终端会终止 MCP 服务。

四、关键步骤:对接 Claude 客户端(实测可用)

服务启动后,我们需要配置 Claude 客户端,让它能连接到我们的 MCP 服务,这里以 Claude Desktop(Mac/Windows)为例,步骤超简单:

1. 找到 Claude 配置文件

  • Mac 路径:~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows 路径:%APPDATA%\Claude\claude_desktop_config.json

如果找不到文件,可先启动一次 Claude 客户端,会自动生成配置文件。

2. 配置 MCP 服务路径

打开配置文件,添加 mcpServers 字段,替换成你的项目 index.js 路径(绝对路径):

{
  "mcpServers": {
    "node-mcp-demo": {  // 服务名称(自定义,便于识别)
      "command": "node",
      "args": ["/Users/xxx/node-mcp-server/index.js"]  // 替换成你的 index.js 绝对路径
    }
  }
}

3. 测试 AI 调用 MCP 工具

重启 Claude 客户端,在对话框中输入以下内容,测试工具调用:

调用 addNumbers 工具,a=10,b=20;再调用 getAppConfig 工具,获取应用配置。

如果 Claude 能正确返回相加结果和应用配置,说明对接成功!至此,你的 Node.js MCP 服务已经能正常工作了。

五、进阶:远程部署(公网可访问)

本地 STDIO 模式只能在自己的电脑上使用,如果想让其他人也能通过 AI 客户端调用你的 MCP 服务,需要改成 HTTP + SSE 传输模式,部署到服务器上。

1. 修改启动代码(替换传输层)

修改 index.js 中的启动逻辑,替换成 SSE 传输,添加 HTTP 服务:

// 导入 SSE 传输和 http 模块
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import http from 'http';

// 替换原来的 startServer 函数
async function startServer() {
  // 1. 创建 HTTP 服务器
  const httpServer = http.createServer();
  // 2. 初始化 SSE 传输(指定接口路径 /sse)
  const transport = new SSEServerTransport('/sse', httpServer);
  // 3. 绑定请求处理器
  httpServer.on('request', transport.handler);
  // 4. 启动 HTTP 服务(监听 3000 端口,可自定义)
  httpServer.listen(3000, () => {
    console.error('✅ Node.js MCP 远程服务启动成功');
    console.error('📌 服务地址:http://localhost:3000/sse');
  });
  // 5. 连接 MCP 服务和传输层
  await mcpServer.connect(transport);
}

2. 部署到服务器(简单演示)

  1. 将项目上传到服务器(推荐用 Git 或 FTP);
  2. 服务器安装 Node.js ≥ 20,执行 npm install 安装依赖;
  3. 用 pm2 守护进程(避免服务崩溃): npm install -g pm2 `` pm2 start index.js # 启动服务 ``pm2 list # 查看服务状态
  4. 开放服务器 3000 端口(防火墙放行);
  5. 客户端配置修改为远程地址: { `` "mcpServers": { `` "node-mcp-remote": { `` "url": "http://你的服务器IP:3000/sse" `` } `` } ``}

六、生产环境必备:避坑指南 + 优化建议

如果要将 MCP 服务用于生产环境,以下 4 点一定要注意,避免踩坑:

1. 日志规范

MCP 服务的 STDIO 通道用于与客户端通信,绝对不能用 console.log 打印日志,必须用 console.error,否则会占用通信通道,导致客户端连接失败。

2. 错误捕获

所有工具和资源的执行逻辑中,必须添加 try/catch 捕获异常,避免单个工具报错导致整个 MCP 服务崩溃,示例:

mcpServer.tool(
  'getAppConfig',
  {},
  async () => {
    try {
      const config = await fetchConfigFromDB(); // 模拟从数据库获取配置
      return {
        content: [{ type: 'text', text: JSON.stringify(config, null, 2) }]
      };
    } catch (error) {
      // 捕获异常,返回错误信息给 AI 客户端
      return {
        content: [{ type: 'text', text: `获取配置失败:${error.message}` }]
      };
    }
  }
);

3. 鉴权(远程服务必加)

远程服务暴露在公网后,必须添加鉴权(比如 API Key),防止被恶意调用,可通过拦截 HTTP 请求实现:

// 在启动 HTTP 服务时,添加鉴权拦截
httpServer.on('request', (req, res) => {
  // 从请求头获取 API Key
  const apiKey = req.headers['x-api-key'];
  // 验证 API Key(替换成你的密钥)
  if (req.url === '/sse' && apiKey !== 'your-secret-api-key') {
    res.writeHead(401, { 'Content-Type': 'text/plain' });
    res.end('Unauthorized: Invalid API Key');
    return;
  }
  // 鉴权通过,交给 transport 处理
  transport.handler(req, res);
});

4. 进程守护

生产环境不要用 node index.js 直接启动,必须用 pm2 等进程守护工具,确保服务崩溃后能自动重启,避免服务中断。

七、总结:Node.js 开发 MCP 服务的核心要点

其实开发 Node.js 版 MCP 服务一点都不复杂,核心就 4 个关键点,记住就能快速上手:

  1. 环境:Node.js ≥ 20 + 两个核心依赖(官方 SDK + Zod);
  2. 核心:McpServer 实例 + 工具/资源注册;
  3. 传输:本地用 STDIO(开发调试),远程用 HTTP + SSE(部署上线);
  4. 对接:修改 AI 客户端配置,即可调用自定义工具/资源。

MCP 协议的核心价值,就是让我们能轻松扩展 AI 的能力,不用依赖 AI 厂商的接口限制,自己开发服务,让 AI 适配我们的业务场景——比如对接公司内部系统、操作本地文件、调用第三方 API 等。

本文的代码可以直接复制运行,如果你有具体的业务场景(比如让 AI 调用数据库、解析 PDF、操作本地文件),可以在评论区留言,后续会补充对应的实现教程。

最后,如果你觉得这篇文章对你有帮助,麻烦点个赞、收藏一下,关注我,后续会分享更多 Node.js 和 AI 相关的实战教程~

附:常见问题排查

  • 服务启动报错:检查 Node.js 版本是否 ≥ 20,package.json 是否添加 type: "module"
  • Claude 无法调用工具:检查配置文件中的 index.js 路径是否正确,服务是否正常运行;
  • 远程服务无法访问:检查服务器端口是否开放,鉴权配置是否正确。

TRAE SOLO 移动端正式上线:手机也是随身工位,随时随地进入「Vibe Working」

不再被电脑束缚,一个 Agent 在三端自由流转——你的灵感和任务都会自然接力

01 一个熟悉的场景

周五下班前,你在公司电脑上跑了一个需要几小时才能完成的训练脚本。人离开办公室,电脑留在工位。路上手机突然弹出警报:程序崩了。或者一切正常,但你无法确认进度,也无法干预……

这就是当下很多 AI 开发者的真实焦虑:明明 Agent 已经能帮我们做很多事,我们却仍然离不开那台「住着 AI」的电脑。

OpenClaw 的出现曾让很多人看到希望——通过 IM 软件远程遥控电脑上的 Claude Code。但实际用起来,依然无法实时查看进程、对 Agent 的下一步操作没有预期,甚至想打断都找不到 Ctrl+C。久而久之,人走到哪,电脑还是得背到哪。

如果,手机、电脑、云端的工作流和 Agent 能彻底打通呢?

几天前,TRAE SOLO 移动端正式上线,实现了 iOS / Android + Mac / Windows + 网页 三端全量打通。无需邀请码,下载即用。

02 手机 ≠ 缩水版桌面端

TRAE SOLO 最核心的设计理念是:换设备,不断流

手机端、桌面端、网页端共享:

  • 同一个 Agent

  • 同一套文件系统

  • 同一段对话上下文

你在地铁上用语音说了一个产品 idea,手机端 Agent 可以立刻把它整理成 PRD 草稿;到公司打开电脑,刚才的文档已经躺在工作区,继续精细化编辑。

开发者也一样:路上想到一个 bug 的解法,直接对手机说,Agent 会将其拆解成修改计划并同步到 PC 端。你坐下后,代码已经等在那里。

TRAE SOLO 做的是:把「想到」到「做到」之间的搬运成本压缩到最短。

03 实战:用手机做一个小项目

一位日本开发者做了一个浏览器插件:定时在屏幕上显示一只大肥猫,提醒你休息。

我看到后很想自己也做一个。但没有人愿意在手机上敲大段格式化提示词。我要让 Agent 解决所有问题。

TRAE SOLO 移动端包含完整的 MTC(Model-Think-Code)Code 功能。从产品规划、提示词设计到代码落地,全在手机上完成。

我先是让 MTC 理解这个项目并输出完善的提示词(Markdown 文件),保存到手机后,切换到 Code 模式直接开始构建。整个过程,手机揣兜里,等通知就行。

最后到工位时,猫猫插件已经在 Chrome 里跑起来了。

一个完整的产品概念,从灵感闪现到可运行的产物,全程只靠一部手机。

04 三个值得关注的新能力

实时语音交互讨论

你可以直接用手机和 Agent 讨论一个产品想法、运营方案或代码问题。过去手机只是「记下来,回去再说」;现在可以直接开展讨论,Agent 会生成会议纪要、沉淀思路,并进一步下发任务。

实测体验:Agent 反应快、建议质量高,甚至会在每次发言后主动引导你深化思考。持续 5 分钟的讨论结束后,它自动输出了完整的讨论总结——就像专业会议软件的会后纪要。

唯一遗憾:语音讨论时暂时无法联网搜索或执行实际任务。如果未来补齐这一点,TRAE SOLO 在语音交互赛道将几乎没有对手。

飞书 CLI 接入

对产品、运营、市场、管理者非常实用。把飞书文档链接交给 SOLO,它可以:

  • 理解文档内容

  • 基于上下文生成方案、报告或任务拆解

  • 将修改后的文档以卡片形式沉淀,方便后续查看和编辑

实际测试中,我们把 SOLO 接入了自己的飞书工作流。开放权限后,它像一位新入职的编辑,快速整理出五一期间遗漏的重要 AI 资讯,并按日期、类别、重要性标记清楚。甚至能从混乱的选题文档中为每个事件提取出相当准确的标题。

定时任务

提前设定 Prompt 和触发频率(如每天上午),SOLO 会自动执行并产出结果。例如:

  • 每天自动整理 Cursor、Claude Code 等产品的最新动态

  • 生成日报发送给自己

这让 Agent 真正成为一个长期主动在线的助手,持续完成信息收集、整理和汇报。

05 从「闪念胶囊」到「闪念完成体」

很多好想法不是坐在电脑前想出来的。走路、洗澡、坐车、睡前,灵感突然「啪」地冒出来。罗永浩的「闪念胶囊」是一个伟大的尝试——让灵感在第一秒被稳稳接住。

但问题在于:它只做到了断点保存,却跟不住工作流。

TRAE SOLO 就是那个「闪念胶囊完全体」。

过去,胶囊负责保存念头;现在,Agent 接过下一棒——把念头补全、整理、拆解,并直接推向真正的工作流。

06 智能体的主场到底在哪里?

过去两年,AI 编码领域的竞争主线很清晰:谁能更懂代码、更快补全、更准修 bug,谁就能抢下开发者的桌面。Cursor、GitHub Copilot、TRAE SOLO 桌面版都是这一阶段的产物。

但它们本质上仍是桌面软件时代的延伸——提升了效率,却没有改变「人的心流」。

历史是个圈:

  • 互联网 1.0:信息上网,人坐在电脑前浏览

  • 互联网 2.0:人上网,开始生产内容和互动

  • 移动互联网:服务跟着人走,场景无处不在

今天,TRAE SOLO Mobile 正在推动智能体进入下一个阶段:工作流跟着人走,在不同设备间自然流转,在不同场景里接力完成任务。

同样的,移动端大大降低了智能体的使用门槛。当 AI 编程工具只存在于 IDE 里,它天然是开发者的专属工具。但当它可以通过手机 + 语音 + 文档 + 业务流程操作时,产品经理、运营、管理者、测试、设计,都能把它纳入自己的工作流。

所以,智能体的主场到底在哪里?

答案或许不再是 IDE,不再是浏览器,不再是任何一个载体。

真正的主场,是人的工作流本身。

TRAE SOLO 移动端已全量开放:iOS、Android、Mac、Windows 均可下载,立即体验「随时随地的 Vibe Working」。

你的代码仓库变成“毛线团”了?Monorepo 用 Turborepo 拆成“乐高积木”

你维护着五六个项目,每个都单独开一个 Git 仓库。改一个公共组件,要挨个进每个项目,复制粘贴,提交,发布。一上午就没了。今天我们来学 Monorepo——用 Turborepo 把多个项目放进同一个仓库,共享代码、统一构建、一键发布。让你的“多仓库噩梦”变成“搭积木游戏”。

前言

Polyrepo(多仓库)刚开始很爽:每个项目独立,互不干扰。但公共代码一多,就成了复制粘贴地狱。你修了一个 bug,五个项目都要同步,漏一个线上就崩。

Monorepo(单仓库)不是把代码随便堆在一起,而是用工具(Turborepo、Nx、Lerna)把多个项目“有序地”放在同一个 Git 仓库里,让它们能共享依赖、共享配置、共享构建缓存。今天我们用 Turborepo(Vercel 出品,Next.js 同款团队)搭一个 Monorepo,里面有 React 应用、Node API、一个共享的 UI 组件库。全程实战,告别“复制粘贴工程师”。

一、Monorepo 解决了什么?

  • 代码共享:公共组件放在 packages/shared,所有应用直接 import
  • 统一依赖:根目录一个 package.json,用 pnpmyarn workspaces 管理依赖,避免重复安装。
  • 原子提交:一次 commit 修改多个项目,版本同步。
  • 任务缓存:Turborepo 会记住每个任务的输入输出,第二次构建直接取缓存,秒完成。

二、准备工作:安装 pnpm 和 Turborepo

我们选择 pnpm 作为包管理器(比 npm/yarn 快,节省磁盘空间)。如果你没装 pnpm:

npm install -g pnpm

创建项目目录:

mkdir my-monorepo
cd my-monorepo
pnpm init

三、配置 pnpm workspace

在根目录创建 pnpm-workspace.yaml

packages:
  - "apps/*"
  - "packages/*"

这样 apps/ 下的每个子目录是一个应用(比如 React 前端、Node 后端),packages/ 下的子目录是共享包(比如 UI 组件库、工具函数)。

四、安装 Turborepo

pnpm add -g turbo
# 或者在项目中安装
pnpm add -D turbo

创建 turbo.json

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

pipeline 定义了任务依赖关系。^build 表示执行某个包的 build 之前,先构建它的依赖包。

五、创建共享组件库

mkdir -p packages/ui
cd packages/ui
pnpm init

packages/ui/package.json 中,给包起个名字(重要):

{
  "name": "@myrepo/ui",
  "version": "0.0.1",
  "main": "./src/index.tsx",
  "types": "./src/index.tsx",
  "scripts": {
    "build": "tsc"
  }
}

安装 React 和 TypeScript 依赖(在根目录执行):

pnpm add -D react react-dom typescript @types/react -w

-w 表示安装在根 workspace。

写一个简单的 Button 组件:packages/ui/src/Button.tsx

import React from 'react';

export const Button: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  return <button style={{ padding: '8px 16px', background: 'blue', color: 'white' }}>{children}</button>;
};

packages/ui/src/index.tsx

export { Button } from './Button';

配置 TypeScript:packages/ui/tsconfig.json

{
  "compilerOptions": {
    "jsx": "react-jsx",
    "module": "ESNext",
    "target": "ES2020",
    "declaration": true,
    "outDir": "dist",
    "strict": true
  },
  "include": ["src"]
}

六、创建 React 应用

我们用 Vite 创建一个 React 应用放在 apps/web

cd apps
pnpm create vite web --template react-ts
cd web

修改 apps/web/package.json,添加对共享包的依赖:

"dependencies": {
  "@myrepo/ui": "workspace:*",
  ...
}

workspace:* 表示使用当前 workspace 中的对应包。

apps/web/src/App.tsx 中引入共享按钮:

import { Button } from '@myrepo/ui';

function App() {
  return (
    <div>
      <h1>Monorepo Demo</h1>
      <Button>来自共享组件库的按钮</Button>
    </div>
  );
}
export default App;

现在在根目录运行 pnpm install,它会自动链接本地包。

七、配置 Turborepo 任务

修改根 turbo.json,让 build 任务在 React 应用里产生输出:

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", "build/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

然后在根 package.json 添加脚本:

"scripts": {
  "dev": "turbo dev",
  "build": "turbo build",
  "lint": "turbo lint"
}

运行 pnpm dev,Turborepo 会同时启动两个应用的开发服务器(如果你还有 Node 后端的话)。第一次启动正常速度,第二次因为缓存,秒开。

八、共享配置与依赖提升

想在根目录统一管理 TypeScript、ESLint、Prettier 配置?在根目录创建 tsconfig.base.json,然后每个子项目的 tsconfig.json 继承它:

// apps/web/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist"
  }
}

ESLint 同理,根目录装 eslint,每个子项目通过根配置运行。

九、生产构建与部署

运行 pnpm build,Turborepo 会按照依赖顺序构建:先构建 @myrepo/ui,再构建 apps/web。并且第二次构建时会复用缓存,毫秒级完成。

构建产物可以分别部署:apps/web/dist 部署到 Vercel/Netlify,Node 应用部署到服务器。因为它们在一个仓库里,但部署是独立的。

十、总结:Monorepo 不是银弹,但能救你于复制粘贴

  • 适合场景:多个项目共享代码、团队规模中等、希望统一 CI/CD。
  • 不适合:项目之间几乎没有依赖、团队权限隔离要求极高(可加 CODEOWNERS 缓解)。
  • 工具选择:Turborepo 速度快、配置简单;Nx 功能更强(但复杂);Lerna 已过时(现在用 Nx 或 Turborepo)。

下次你又在不同项目间同步代码时,想一想:能不能把它们放进同一个 Monorepo,用 Turborepo 一键构建?省下的时间,正好可以摸会儿鱼。

面试手写 KeepAlive:React 组件缓存的实现原理

面试手写 KeepAlive:React 组件缓存的实现原理

面试官:"用过 Vue 的 <keep-alive> 吗?如果让你在 React 中手写一个,你会怎么实现?"

这看似是一道框架 API 题,实际上考察的是你对 React 组件渲染机制DOM 复用策略 的理解深度。本文将带你从零手写一个 KeepAlive 组件,把每一步的设计决策讲透彻。


先搞懂本质:KeepAlive 解决什么问题?

看一个具体场景。我们的 App 有两个 Tab:

// App.jsx
const App = () => {
    const [activeTab, setActiveTab] = useState('A')

    return (
        <div>
            <button onClick={() => setActiveTab('A')}>显示A组件</button>
            <button onClick={() => setActiveTab('B')}>显示B组件</button>

            <KeepAlive activeId={activeTab}>
                {activeTab === 'A' ? <Counter name="A" /> : <OtherCounter name="B" />}
            </KeepAlive>
        </div>
    )
}

Counter 组件内部有一个 count 状态:

const Counter = ({ name }) => {
    const [count, setCount] = useState(0)
    // 挂载/卸载的生命周期日志
    useEffect(() => {
        console.log('挂载', name)
        return () => console.log('卸载', name)
    }, [])

    return (
        <div>
            <h3>{name}视图</h3>
            <p>当前计数:{count}</p>
            <button onClick={() => setCount(count + 1)}>加1</button>
        </div>
    )
}

没有 KeepAlive 时,用户在 A 组件把 count 点到 5,切换到 B,再切回 A:

切换 BA 组件卸载(state 销毁,count 归零,DOM 移除)
切回 AA 组件重新挂载(count 重新从 0 开始,useEffect 再次执行)

用户体验:辛辛苦苦点的数全白费了。


核心思路:把 JSX 元素存进一个对象里

React 的组件渲染本质上就是把 JSX 转成 Virtual DOM,再映射到真实 DOM。那如果我们不销毁这个 JSX 对应的 DOM,而是把它"藏起来"呢?

关键认知:JSX 本质上就是一个 JavaScript 对象引用。 只要引用不被 GC 回收,React 内部维护的 Fiber 节点和对应的真实 DOM 就不会被销毁。

设计数据结构:

// cache 对象的结构
{
    'A': <Counter name="A" />,     // JSX 对象引用
    'B': <OtherCounter name="B" />,
}
  • key:用 activeId 作为缓存键,唯一标识每个需要缓存的视图
  • value:存储该视图对应的 JSX 元素(注意:是首次渲染时的那个 JSX 对象,不是每次都创建新的)

一步步写出来

第一版:能跑就行的朴素实现

import { useState, useEffect } from 'react'

const KeepAlive = ({ activeId, children }) => {
    const [cache, setCache] = useState({})

    useEffect(() => {
        if (!cache[activeId]) {
            setCache(prev => ({
                ...prev,
                [activeId]: children
            }))
        }
    }, [activeId, children, cache])

    return (
        <>
            {Object.entries(cache).map(([id, component]) => (
                <div
                    key={id}
                    style={{ display: id === activeId ? 'block' : 'none' }}
                >
                    {component}
                </div>
            ))
            }
        </>
    )
}

export default KeepAlive

逐行解析

1. 缓存状态:const [cache, setCache] = useState({})

用一个对象存储所有被缓存过的视图。为什么用 useState 而不是 useRef?因为我们需要在状态更新时触发重新渲染——新的 children 被存入缓存后,必须让 React 重新执行 render 才能把新 DOM 渲染出来。

2. 缓存时机:if (!cache[activeId])
useEffect(() => {
    if (!cache[activeId]) {
        setCache(prev => ({
            ...prev,
            [activeId]: children
        }))
    }
}, [activeId, children, cache])

这是整个组件的灵魂。判断逻辑是:

场景 cache[activeId] 是否存在 行为
首次切换到某个 Tab 不存在 保存 children 到缓存
再次切换回已缓存的 Tab 已存在 什么都不做,复用旧缓存

注意:这里保存的是第一次渲染时的 children 引用。一旦保存,后续即使 children 变化(其他 Tab 的 JSX),已缓存的引用不会被覆盖。这就是状态得以保留的根源——React 始终渲染的是最初那个 Fiber 节点。

3. 显示策略:display: block / none
{Object.entries(cache).map(([id, component]) => (
    <div key={id} style={{ display: id === activeId ? 'block' : 'none' }}>
        {component}
    </div>
))}

所有被缓存过的组件全部渲染在 DOM 树中,但只把当前激活的那个设为可见:

  • 激活的 Tab:display: block(正常显示)
  • 隐藏的 Tab:display: none(DOM 存在但不可见)

这是整个方案最巧妙的地方:React 看到 {component} 引用没变,不会重新执行函数组件,不会触发 Hooks 重新计算,不会触发 useEffect。Fiber 节点一直挂在树上,状态完好无损。

当你从 B 切回 A 时,控制台不会打印"挂载 A",因为 A 组件的 Fiber 从未被卸载过。这就是 KeepAlive 的本质——DOM 存在但不显示,而非销毁后重建。


运行效果:对比控制台日志

// 初始加载
挂载 A              ← useEffect 触发

// 切换到 B
挂载 BB 首次进入缓存,执行挂载
// 注意:没有 "卸载 A"!

// 切回 A
// 没有 "挂载 A"!    ← A 从未卸载,缓存命中

// 再次切到 B
// 没有 "挂载 B"!    ← B 也从未卸载

A 组件切走时,控制台没有打印"卸载 A",因为 display: none 只是隐藏,React 的 cleanup 函数不会执行。切回来时也没有"挂载 A",计数仍然保持离开时的数字。


面试进阶:面试官可能会追问什么

Q1:为什么用 children 而不是让 KeepAlive 自己去渲染?

// ❌ 不好的设计:KeepAlive 内部 import 组件
<KeepAlive activeId={activeTab} components={{ A: Counter, B: OtherCounter }} />

// ✅ 好的设计:通过 children 让父组件控制渲染
<KeepAlive activeId={activeTab}>
    {activeTab === 'A' ? <Counter name="A" /> : <OtherCounter name="B" />}
</KeepAlive>

原因children 模式遵循 React 的组合优于继承原则。父组件完全控制子组件的 props、条件渲染逻辑,KeepAlive 只负责缓存,职责单一。

Q2:所有缓存组件都在 DOM 中,性能会不会有问题?

会有。每个隐藏的组件虽然不可见,但它的 DOM 节点和 Fiber 节点全部真实存在于内存中。如果你的 Tab 内容包含 1000 个列表项,那缓存 10 个 Tab 就是 10000 个 DOM 节点——对内存和首屏渲染性能都是负担。

生产级方案(如 react-activation)会做更精细的优化:通过 React Portal 把隐藏组件的 DOM 移到一个独立的、脱离文档流的容器中挂起。

Q3:useEffect 的依赖数组里有 cache,会不会导致无限循环?

cache[activeId] 不存在时才调用 setCache,更新后的 cacheactiveId 已存在,下次 useEffect 执行时 if (!cache[activeId])false,不会再调用 setCache。所以不会无限循环。

但这里有一个可优化的点:依赖 cache 对象意味着每次缓存更新后 useEffect 都会对整个 cache 重新求值。更好的写法是用函数式 setState + 单独的 useEffect 监听:

useEffect(() => {
    setCache(prev => {
        if (prev[activeId]) return prev  // 已缓存,不更新
        return { ...prev, [activeId]: children }
    })
}, [activeId, children])

这样去掉了对 cache 的依赖,效果一样但更简洁。

Q4:display: none 和条件渲染有什么区别?

display: none 条件渲染 {visible && <Comp />}
DOM 存在 ✅ 存在 ❌ 移除
state 保留 ✅ 保留 ❌ 销毁
useEffect cleanup ❌ 不触发 ✅ 触发
组件函数是否重新执行 ❌ 不执行 ✅ 重新执行

条件渲染的本质是移除 DOM → 销毁 Fiber → 清除 state → 执行 cleanup。display: none 的本质是 DOM 还在 → Fiber 还在 → state 还在 → cleanup 不执行。前者是"删了重建",后者是"藏起来再拿出来"。


从面试代码到生产级方案

这个 25 行的实现抓住了 KeepAlive 的核心思想,但它缺少几个关键能力:

缺失能力 生产级方案(react-activation)
滚动位置恢复 内置 saveScrollPosition 属性
缓存淘汰策略 支持 LRU,限制最大缓存数量
多实例管理 AliveScope 全局缓存池统一调度
生命周期钩子 useActivate / useUnactivate 替代 useEffect
SSR 兼容 提供 SSRKeepAlive 降级方案
动画过渡 切换时可配合 CSS Transition

但面试官要看的不是你会不会用库——而是你是否理解状态保留的本质是保留 JSX 引用,保留引用的本质是不让 Fiber 卸载,不让 Fiber 卸载的本质是 DOM 不离树。


总结

手写 KeepAlive 是一个优质的面试题,它串起了 React 的多个核心概念:

JSX 对象引用 → useState 缓存 → display:none 保活
         ↘        Fiber 持久化       ↙
              状态与 DOM 永不销毁

记住这一条线,你就能在任何面试中把 KeepAlive 的原理讲得明明白白。

一句话版本:KeepAlive = useState 存 JSX 引用 + display: none 隐藏非激活 DOM,让 React 的 Fiber 节点不被卸载,从而保住所有组件内部状态。

❌