阅读视图

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

被CRUD拖垮的第5年,我用Cursor 一周"复仇":pxcharts-vue开源,一个全栈老兵的AI编程账本

今天继续和大家聊聊,我们开源的 pxcharts-vue 多维表格的诞生故事。

图片

开源地址:github.com/MrXujiang/p…

演示地址:test.admin.mvtable.com/mvtable

一、开篇:那个写不动代码的凌晨

记得是5年前的一个夜晚,凌晨两点左右,我对着第46个几乎相同的表单页面发呆。

需求文档上写着:"再做一个支持关联查询的动态表格,下周上线。"

我机械地复制着上一版的CRUD代码,改字段名、调接口、写校验。Vue文件超过1000行,methods里塞着十几个功能相似但不敢重构的方法——怕牵一发而动全身。

这是我做全栈的第5年。从Vue2到Vue3,从jQuery到React,技术栈在升级,但日常还是改不完的表单、写不完的列表、调不完的接口

我自嘲是"高级CRUD工程师",但那个凌晨,我真的写不动了。

不是身体累,是认知上的绝望:我知道接下来的5年,如果继续这样"人肉搬砖",只会从"写不动"变成"不敢写"——新技术层出不穷,而我被困在业务逻辑的重复劳动里。

转机出现在三年后。

Cursor 的Agent模式刚更新,我抱着"试试又不会死"的心态,把曾经折磨我两周的多维表格需求丢给了AI。

图片

经过3天多和AI辩证推演之后,pxcharts-vue 的核心架构跑通了。我盯着屏幕上自动生成的Composition API代码,第一反应不是兴奋,是后怕——如果AI早来两年,我这5年到底在忙什么?

这篇文章,是我作为"全栈老兵"的AI编程账本。不吹不黑,只记录真实的效率数字、踩过的坑、以及那个凌晨之后,我对职业价值的重新思考。


二、产品画像:pxcharts-vue是什么?(技术人的"复仇工具")

图片

先介绍这次"复仇"的成果。pxcharts-vue 不是又一个Element Plus的封装,而是面向复杂业务场景的"关系型多维表格引擎"

它解决的是我5年来反复遇到的三个痛点:

1. 平面表格 vs 立体数据

传统表格是Excel思维:行是记录,列是属性。但真实业务是关系型的——订单关联客户、任务关联项目、SKU关联SPU。我们用"关联列"把这种关系可视化:选客户时自动带出合同,选商品时自动填充价格,底层是外键约束,表层是下拉选择。

图片

这 revenge 了我过去写过的无数遍onChange联动逻辑。

在多维表格设计中,我们完全对标了钉钉AI表格和飞书多维表格的字段设计,实现了多种表格业务字段,并支持随时编辑修改:

图片

当然有些字段比较复杂,AI无法完全理解和实现,其中40%的工作量是我们手敲代码实现的。

2. 一份数据,多种视角

图片

同一份项目数据,产品经理要看甘特图,运营要看看板,财务要看表格汇总。pxcharts-vue 实现了视图层与数据层解耦:底层是统一的数据模型,上层是表格、看板、表单等渲染适配器。

这 revenge 了我过去为"换个展示方式"而写的冗余接口。

3. 公式字段:把Excel能力Web化(React版本中实现了)

支持跨表引用、聚合计算、条件判断,非技术用户能配出"自动计算提成"的复杂逻辑。对于开发者,这意味着业务规则从后端Java代码前移到了前端配置层,需求变更不用重新部署。

这 revenge 了我过去凌晨两点还在改的"紧急加字段"需求。

技术栈:Vue3 + TypeScript + Vite,纯前端实现,零后端依赖,开箱即用。


三、复仇实录:一周重构的流水线与真实账本

这次开发全程在Cursor Composer的Agent模式下完成,我们自己研发的工作量占比40%。

我记录了一套"老兵式AI协作流"——不是盲目信任,是有策略地外包

plan 1:架构设计(从"人肉画图"到"对话式架构")

过去的我:  打开Draw.io画组件关系图,纠结半小时目录结构,再花2小时搭Vite脚手架。

AI模式:

我:基于Vue3实现一个多维表格内核,需要支持列定义、数据编辑、视图切换,采用模块化架构,优先使用Composition API和<script setup>语法。Cursor:生成项目结构 + 核心类型定义 + 基础组件框架

耗时:30分钟 vs 过去的4小时。

关键干预:  强制要求AI先生成ARCHITECTURE.md设计文档,确认模块边界后再生成代码。这是从"边想边写"的混乱中保留下来的人类架构师尊严

plan 2:核心功能(关联列与视图系统)

关联列功能:

我:需要实现表与表之间的关联,类似数据库外键约束,支持多选、级联筛选、自动回填。Cursor:生成基于Proxy的响应式关联逻辑 + 选择器组件 + 数据联动机制

过去需要2天,现在4小时。  但AI生成的第一版用了递归遍历,大数据量时卡顿明显。我要求它改用虚拟滚动+懒加载,它给出了基于vue-virtual-scroller的优化方案。

视图系统: AI建议使用策略模式管理不同视图,我确认方案后,它生成了TableStrategy、KanbanStrategy、GanttStrategy三个类,统一实现render()接口。

耗时:6小时 vs 过去的3天。

plan 3:公式引擎与边界加固

这是最复杂的模块。我采用Plan Mode

  1. 先让AI出《公式引擎设计文档》:语法解析(PEG.js)、沙箱执行(Web Worker)、错误处理机制
  2. 人工Review确认安全方案(禁用eval,使用白名单函数)
  3. 再让AI生成代码

发现的问题:  AI生成的初始版本用了new Function()执行公式,我立即叫停——这是XSS漏洞温床。CodeRabbit 的研究证实,AI代码引入安全漏洞的概率是人类代码的2.74倍。最终改用受限沙箱+语法树解析

耗时:1.5天 vs 过去的5天。

效率账本(真实数字)

环节 传统开发(第5年的我) AI辅助开发(复仇模式) 效率倍数
脚手架与架构 3天 2小时 8x
关联列逻辑 3天 1天 3x
视图切换系统 5天 1天 5x
公式引擎 5天 1天 5x
安全加固与优化 2-3天 1天 2x
总计 18-20天 4.2天 4x

整体效率提升约230% ,与GitClear对高AI使用率开发者的调研数据(4-10倍产出提升)基本吻合。

当然客观的说,我们工程师也花了大概30%-40%的经历攻克AI无法解决的问题,但是AI Coding的整体提效还是很显著的。


四、账本B面:AI编程的隐性成本与"复仇"的代价

但这不是爽文。

图片

一周交付的背后,我们付出了传统开发不会有的代价。这是账本必须记录的B面

1. 安全债务:AI的"自信"是危险的

pxcharts-vue 初期版本中,AI生成的表格解析渲染器存在原型链污染漏洞——它从某个Stack Overflow回答中学到了"巧妙"的对象合并技巧,但那是有安全缺陷的过时方案。

CodeRabbit 分析了数百万行AI生成代码,发现:

  • 引入XSS漏洞的概率:人类代码的2.74倍
  • 硬编码机密信息的概率:人类代码的2.1倍

我的对策:  核心安全模块(公式沙箱、数据校验)必须人工Review,AI仅辅助生成单元测试用例。

2. 可维护性陷阱:你成了"代码陌生人"

Day 2下午,AI生成了50行复杂的视图切换逻辑。当时我看懂了大意,觉得"没问题"。一周后回看,我盯着那团递归+闭包的组合,完全想不起来为什么这样写、边界条件是什么

GitClear的研究警告:AI辅助代码的撤销率(Churn rate)比人类代码高40% ,意味着更多返工。

我的对策:  强制要求AI生成 "逻辑注释" ——不是解释语法,而是解释设计决策("为什么用递归而非迭代""此处假设数据量小于1万条")。关键算法必须人工复述原理,确保"我懂我的代码"。

3. 架构一致性危机:AI的"创意"是混乱的

不同会话的AI会给出风格迥异的方案。早期关联列用Options API,后期视图系统被建议改成Composition API,导致代码风格混杂——就像一个项目里有5个不同架构师的手笔

我的对策:  建立《AI编程规范文档》(.cursorrules),固化:

  • 技术栈:Vue3 + <script setup> + TypeScript严格模式
  • 设计模式:优先组合式函数,类仅用于策略模式
  • 命名规范:组件PascalCase,组合式函数useXxx,工具函数纯函数优先

这让AI在约束内发挥,而非"自由创作"。

4. 幻觉税:为AI的"自信"买单

图片

视图切换的虚拟滚动功能,AI生成的代码在1000条数据时完美运行,10000条时白屏。它没有考虑内存溢出边界,也没有提示"此处需要性能测试"。

这类问题只能靠人工测试发现。AI编程省下的时间,部分要返还到更严格的测试环节


五、老兵的新战场:AI时代,全栈工程师该专注什么?

图片

pxcharts-vue 开源后,我一直在想:如果AI能写代码,我这5年积累的经验还有什么价值?答案在开发过程中逐渐清晰——

1. 从"实现者"到"架构守门员"

AI擅长生成"能跑的代码",但不懂业务领域的架构权衡

pxcharts-vue 的数据模型设计(平面表 vs 树形结构)、状态管理方案(Pinia vs 纯响应式)、视图渲染策略(Canvas vs DOM),这些决策需要人类对业务场景的深度理解。

新角色:  不是写代码,是设计代码的生成规则

凭借我之前在大厂做技术架构的经验,我能很快给出AI高效的架构和解决思路,所以这也要求我们有一定的技术背景,才能更好的让AI为我们服务。

2. 从"调试bug"到"设计防错机制"

AI代码的bug更隐蔽——它很少犯语法错误,但常犯逻辑假设错误("假设用户不会同时编辑两个单元格")。我的新工作是预判这些假设,在设计阶段就加入防御性机制。

新角色:  不是修bug,是设计让bug无法发生的系统

3. 从"技术执行"到"AI流程设计"

这次3天重构,真正的生产力提升不是来自Cursor本身,而是我设计的分层协作流程

  • 生成层(工具函数):100%信任AI
  • 业务层(组件逻辑):AI生成+人工Review,70%信任
  • 核心层(公式引擎):AI辅助设计,人工实现,30%信任

新角色:  不是写代码,是设计人机协作的流水线


六、开源的思考:不止于代码,是"复仇经验"的共享

选择开源 pxcharts-vue,除了技术分享,我还想验证一个假设:AI编程时代,开源的价值会从"代码"转向"流程"

传统开源是"拿我的代码用",未来可能是"拿我的Prompt用"——如何让AI生成高质量的Vue3组件?如何设计安全的公式引擎?如何避免我踩过的坑?

我后续会分享《pxcharts-vue AI开发手册》,包含:

  • 架构设计、高性能表格技术实践
  • 安全审计清单(AI代码常见漏洞模式)
  • 性能优化策略(虚拟滚动、大数据渲染、内存管理)

如果你也在用AI编程工具,欢迎来 留言区 交流。

我们可以一起探索:当AI成为标配,人类开发者的"复仇"该指向什么?


结语:账本结算,复仇之后

5年前那个凌晨两点写不动代码的我,不会想到三年后会写下这篇文章。

pxcharts-vue 的一周重构,是效率的胜利,也是一次职业价值的重新校准。AI编程确实"复仇"了CRUD的重复劳动,但它也暴露了人类开发者的软肋——我们过去引以为傲的"编码速度",在AI面前不值一提。

新的竞争力在于:架构设计的品味、安全风险的嗅觉、人机协作的智慧,以及对自己代码的深刻理解

LeetCode 39. 组合总和:DFS回溯解法详解

拆解 LeetCode 经典回溯题——39. 组合总和,这道题是回溯算法的入门必练题目,核心考察「无重复组合」与「元素可重复选取」的处理逻辑,学会这道题,能轻松应对一类回溯组合问题。

话不多说,先看题目本身,帮大家理清需求、避开陷阱。

一、题目解读:明确需求与边界

题目给出两个核心输入:无重复元素的整数数组 candidates,和目标整数 target。要求找出 candidates 中,所有能使数字和为 target 的不同组合,返回格式为列表,且组合顺序无要求。

这里有两个关键细节,也是解题的核心:

  • 「同一个数字可无限制重复选取」:比如 candidates 有 2,target 为 4,那么 [2,2] 是合法组合。

  • 「不同组合的判定」:至少一个数字的选取数量不同,即为不同组合。比如 [2,3] 和 [3,2] 视为同一组合(题目允许任意顺序返回),而 [2,2,3] 和 [2,3,3] 是不同组合。

另外题目给出约束:组合数少于 150 个,无需考虑极端情况下的性能优化,专注于回溯逻辑即可。

二、解题思路:为什么用回溯法?

这道题的本质是「从数组中挑选元素,允许重复选,凑出目标和」,属于「组合搜索」问题——我们需要遍历所有可能的选取方式,找到符合条件的组合,而回溯法正是处理这类「多路径搜索、需回退」问题的最优思路。

回溯法的核心逻辑的是「试探-回退-再试探」,可以类比为「走迷宫」:

  1. 试探:挑选一个元素,加入临时组合,计算当前和;

  2. 判断:如果当前和超过 target,说明此路径无效,直接回退;如果等于 target,将临时组合加入结果集,再回退;

  3. 回退:移除最后一个加入的元素,尝试下一个元素,继续试探。

这里有个关键优化点:如何避免出现重复组合(比如 [2,3] 和 [3,2])?

答案是「固定选取顺序」——让组合中的元素「非递减」排列(或非递增),具体做法是:在递归时,从当前元素的索引开始遍历,不再回头遍历前面的元素。这样就能保证,每个组合的元素顺序一致,不会出现重复。

三、完整代码与逐行解析

先给出完整可运行的 TypeScript 代码(与题目给出的代码一致,重点解析核心逻辑):

function combinationSum(candidates: number[], target: number): number[][] {
    const res: number[][] = []; // 存储最终结果集

    // 回溯函数:start=当前开始遍历的索引,temp=临时组合,sum=当前组合的和
    const dfs = (start: number, temp: number[], sum: number) => {
        // 终止条件1:当前和超过target,无效路径,直接返回
        if (sum > target) {
            return;
        }
        // 终止条件2:当前和等于target,将临时组合存入结果集(浅拷贝,避免引用污染)
        else if (sum === target) {
            res.push([...temp]);
            return;
        }

        // 遍历:从start开始,避免重复组合
        for (let i = start; i < candidates.length; i++) {
            temp.push(candidates[i]); // 试探:加入当前元素
            dfs(i, temp, sum + candidates[i]); // 递归:继续从i开始(允许重复选当前元素)
            temp.pop(); // 回退:移除最后一个元素,尝试下一个选项
        }
    }

    dfs(0, [], 0); // 初始调用:从索引0开始,临时组合为空,当前和为0
    return res;
};

逐行解析核心细节

1. 结果集与回溯函数定义

定义res 数组存储最终的组合列表,回溯函数 dfs 接收三个参数:

  • start:当前开始遍历的索引,核心作用是「避免重复组合」,确保每次递归只从当前元素及之后的元素选取;

  • temp:临时组合,用于存储当前正在试探的组合;

  • sum:当前临时组合的数字和,用于快速判断是否达到目标。

2. 终止条件(回溯的“出口”)

回溯函数必须有明确的终止条件,否则会陷入无限递归:

  • sum > target:当前组合的和已经超过目标,再继续添加元素只会更大,直接返回(剪枝,减少无效遍历);

  • sum === target:当前组合符合要求,将其存入结果集。注意这里用 [...temp] 浅拷贝,因为 temp 是引用类型,后续会被修改,不拷贝会导致结果集中的组合被覆盖。

3. 遍历与回溯核心逻辑

for 循环从 start 开始遍历 candidates 数组,这是避免重复组合的关键:

  1. temp.push(candidates[i]):将当前元素加入临时组合,进行「试探」;

  2. dfs(i, temp, sum + candidates[i]):递归调用,注意这里的 start 参数是 i 而非 i+1——因为允许重复选取当前元素,所以下一次递归仍可以从当前元素开始;

  3. temp.pop():「回退」操作,移除最后一个加入的元素,让循环继续尝试下一个元素,实现“回溯”。

4. 初始调用

dfs(0, [], 0):初始状态下,从数组索引 0 开始遍历,临时组合为空,当前和为 0,正式启动回溯过程。

四、关键易错点提醒

这道题看似简单,但新手很容易踩坑,重点注意以下3点:

  • 避免重复组合:必须从start 开始遍历,不能从 0 开始,否则会出现 [2,3] 和 [3,2] 这样的重复组合;

  • 临时组合浅拷贝:存入结果集时,一定要用 [...temp] 拷贝,否则后续 temp.pop() 会修改结果集中的组合;

  • 剪枝优化:当 sum > target 时直接返回,避免无效递归,提升效率(虽然题目约束组合数少于150,但剪枝是回溯题的必备思维)。

五、示例验证与拓展思考

示例验证

假设输入:candidates = [2,3,6,7], target = 7

按照代码逻辑,最终返回结果为 [[2,2,3], [7]],完全符合题目要求:

  • [2,2,3]:2+2+3=7,元素可重复选取;

  • [7]:7=7,单个元素也符合组合要求。

拓展思考

这道题的变种很多,比如:

  • 如果 candidates 有重复元素,如何避免重复组合?(LeetCode 40. 组合总和 II)

  • 如果限制每个元素只能选取一次,如何修改代码?

核心思路不变,只需调整遍历逻辑(比如去重、start 改为 i+1)。

六、总结

LeetCode 39. 组合总和的核心是「回溯法 + 剪枝 + 避免重复组合」,解题关键在于:

  1. 用回溯法遍历所有可能的组合,实现「试探-回退」;

  2. 通过start 参数固定选取顺序,避免重复组合;

  3. 及时剪枝(sum > target 时返回),提升效率。

这道题是回溯算法的入门经典,建议大家亲手敲一遍代码,修改参数(比如改变 candidates 和 target),观察回溯过程中的 temp 和 sum 变化,就能彻底理解回溯的逻辑。

LeetCode 46. 全排列:深度解析+代码拆解

LeetCode 经典回溯题——46. 全排列,这道题是回溯算法的入门必刷题,核心考察“穷举所有可能”的思路,虽然代码不长,但每一步都藏着回溯的精髓,新手很容易在“回溯回退”这一步踩坑,今天就带着大家逐行拆解,把思路讲透。

一、题目解读:什么是全排列?

题目很简单:给定一个不含重复数字的数组 nums,返回它所有可能的全排列,顺序不限制。

举个例子:如果 nums = [1,2,3],它的全排列就是所有不同顺序的组合,一共 3! = 6 种,分别是:

[[1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1]]

关键注意点:数组不含重复元素,所以不需要考虑去重(这是和“全排列II”的核心区别),只需专注于“如何穷举所有顺序”。

二、核心思路:回溯法——“试错+回退”的穷举艺术

全排列的本质是“从剩余元素中不断选择一个,直到选完所有元素”,这个过程就像走迷宫:

  1. 选一个元素放进“临时结果”(temp);

  2. 从剩下的元素中,再选一个放进临时结果;

  3. 重复步骤,直到没有剩余元素(此时临时结果就是一个完整的排列);

  4. “回退”一步,把最后选的元素拿出来,换另一个元素尝试(这就是“回溯”的核心)。

这种思路可以用「深度优先搜索(DFS)」来实现,DFS负责“深入选元素”,回溯负责“回退换元素”,两者结合就能穷举所有可能。

三、代码逐行拆解(附完整代码)

先贴出完整AC代码,再逐行拆解每一部分的作用,新手跟着走,一定能看懂:

function permute(nums: number[]): number[][] {
    const res: number[][] = []; // 存储最终所有全排列结果

    // 递归函数:arr是剩余待选元素,temp是当前临时排列
    const dfs = (arr: number[], temp: number[]) => {
        // 终止条件:剩余元素为空,说明temp是一个完整排列
        if (arr.length === 0) {
            res.push([...temp]); // 深拷贝,避免后续修改影响结果
            return;
        }

        // 遍历剩余所有元素,逐个尝试选择
        for (let i = 0; i < arr.length; i++) {
            // 1. 从剩余元素中选出当前元素(排除第i个元素,得到新的剩余数组)
            const newArr = arr.filter((_, index) => index !== i);
            // 2. 将当前元素加入临时排列
            temp.push(arr[i]);
            // 3. 递归:继续从新的剩余元素中选择
            dfs(newArr, temp);
            // 4. 回溯:把刚才加入的元素拿出来,换下一个元素尝试
            temp.pop();
        }
    }

    // 初始调用:剩余元素是nums,临时排列为空
    dfs(nums, []);
    return res;
};

1. 变量初始化:res 存储最终结果

const res: number[][] = []; —— 用来保存所有完整的全排列,比如上面例子中的6种组合,最终都会存在这里。

2. 核心递归函数 dfs:负责“选元素+回溯”

dfs 有两个参数:

  • arr:当前剩余待选择的元素(比如第一次调用时是nums,选了1之后,arr就变成[2,3]);

  • temp:当前正在构建的临时排列(比如选了1之后,temp就是[1],再选2就是[1,2])。

3. 终止条件:arr.length === 0

当剩余元素为空时,说明temp已经包含了所有nums的元素,是一个完整的排列,此时需要把temp加入res。

注意:这里必须用 [...temp] 深拷贝,而不是直接 res.push(temp)!因为temp是引用类型,后续回溯时会修改temp的值,如果直接push,res里的元素会跟着变,最后全是空数组。

4. 循环遍历:逐个尝试剩余元素

for (let i = 0; i < arr.length; i++) —— 遍历当前所有剩余元素,每个元素都要尝试作为“下一个选中的元素”。

这部分是核心,拆解为4步:

  1. const newArr = arr.filter((_, index) => index !== i); —— 生成新的剩余元素数组,排除当前选中的第i个元素(比如arr是[1,2,3],i=0时,newArr就是[2,3]);

  2. temp.push(arr[i]); —— 把当前选中的元素加入临时排列(比如选中1,temp就变成[1]);

  3. dfs(newArr, temp); —— 递归调用,继续从newArr中选元素,构建临时排列;

  4. temp.pop(); —— 回溯的关键!把刚才加入的元素“拿出来”,恢复temp的状态,方便下一次循环尝试其他元素(比如选了1之后,递归结束,pop掉1,temp变回空,再尝试选2)。

5. 初始调用:启动DFS

dfs(nums, []); —— 第一次调用时,剩余元素是原始数组nums,临时排列为空,正式开始穷举。

四、关键易错点(新手必看)

  1. 深拷贝问题:res.push([...temp]) 不能写成 res.push(temp),否则会因为引用类型导致结果错误;

  2. 回溯回退:temp.pop() 必须放在递归调用之后,确保递归结束后,temp能恢复到上一步的状态,否则会漏选或多选元素;

  3. 剩余元素处理:newArr是通过filter生成的新数组,不是修改原arr,这样能保证每次递归的剩余元素都是独立的,不会相互影响。

五、思路拓展:时间复杂度与空间复杂度

了解复杂度,能帮我们更好地理解算法的效率:

  • 时间复杂度:O(n!) —— n是数组长度,全排列的总数是n!,每个排列的构建需要O(n)时间,整体就是O(n×n!),简化为O(n!);

  • 空间复杂度:O(n) —— 递归栈的深度是n(最多递归n层),temp数组的长度最多也是n,res数组存储所有排列,属于输出空间,一般不计入复杂度。

六、总结

LeetCode 46. 全排列的核心是「回溯法+DFS」,记住一句话:“选一个元素,递归穷举剩余,回溯回退换另一个”

这道题的代码很简洁,但每一步都体现了回溯的思想,尤其是temp.pop()的回退操作,是新手理解回溯的关键。建议大家自己动手调试一遍,看着temp和arr的变化,就能彻底明白回溯的逻辑。

「前端何去何从」一直写 Vue ,为何要在 AI 时代去学 React?

React UI 基础:重新思考学习 React 的意义

在 AI 快速发展的时代,重新思考学习 React 的意义

AI 时代为什么还要学 React

2026 年学 React,很多人会问:AI 都能写代码了,还有必要学框架吗?

我的答案是:比以前更有必要。但学习的方式和目的变了。

现在有了 Copilot、Cursor 这些工具,写组件的速度确实快了很多。

但我发现,如果不理解 React 的核心概念,AI 生成的代码往往会有隐藏的 bug。

工具可以加速,但不能替代理解。

前端开发的现状

前端开发正在经历两个趋势:

  • 框架的成熟:React、Vue、Svelte 等框架已经相对稳定,核心概念不再频繁变化
  • AI 的介入:AI 工具可以生成大量样板代码,但需要人来把控架构和质量

在这个背景下,理解原理比记忆 API 更重要。

React 的组件化思维、单向数据流、纯函数理念,这些不会因为 AI 的出现而过时。

相反,它们是你判断 AI 生成代码质量的标准。

React 在 AI 时代的生态优势

值得一提的是,React 在 AI 时代有一个显著的优势:AI 模型的训练数据主要来自 React 生态

这意味着:

  • AI 工具对 React 代码的理解和生成质量更高
  • 大量优秀的组件库(如 shadcn/ui、Radix UI、Chakra UI)都是为 React 设计的
  • 当你用 AI 生成代码时,React 的代码质量通常比其他框架更好

这不是说其他框架不好,而是一个现实:React 的社区规模和代码量决定了 AI 对它的支持更好。在选择技术栈时,这是一个不容忽视的因素。

本文的定位

这不是一篇从零开始的教程,而是一个有经验的工程师对 React 基础的总结和思考。

本文涉及以下内容:

  • 讲述 React 的设计理念,而不只是语法
  • 分享实战中的经验和常见错误
  • 探讨在 AI 辅助下如何更好地使用 React

如果你有一定的 JavaScript 基础,想快速掌握 React 的核心概念,这篇文章适合你。


Part 1: 组件化思维

组件的本质

React 的组件本质上是函数。

输入 props,输出 UI。

这种函数式的思维方式,让代码更容易测试和维护。

function Profile() {
  return (
    <img
      src="https://i.imgur.com/MK3eW3Am.jpg"
      alt="Katherine Johnson"
    />
  );
}

这个 Profile 组件就是一个函数,返回一段 JSX。你可以在任何地方调用它:

function Gallery() {
  return (
    <section>
      <h1>了不起的科学家</h1>
      <Profile />
      <Profile />
      <Profile />
    </section>
  );
}

为什么选择组件化

React 选择组件化不是偶然的。在传统的 Web 开发中,HTML、CSS、JavaScript 是分离的。

但在现代应用中,UI 逻辑和标记是紧密相关的:按钮的点击事件和按钮本身是一体的,表单的验证逻辑和表单结构是一体的。

组件化让你可以在同一个地方处理这些相关的逻辑,而不是在多个文件之间跳来跳去。这不是技术上的限制,而是对现实问题的务实解决方案。

组件的拆分原则

刚开始写 React 时,我也纠结过什么时候该拆分组件。

后来发现,与其纠结规则,不如问自己:这段代码会不会在其他地方用到?

如果会,就拆;如果不会,先别急着拆。

过度抽象比重复代码更难维护。我见过太多项目,为了"复用"而创建了一堆只用一次的组件,反而增加了理解成本。

实际项目中的经验:

  • 如果一个组件超过 200 行,考虑拆分
  • 如果一个组件做了太多事情(数据获取、状态管理、UI 渲染),考虑拆分
  • 如果一段代码在两个地方用到,可以考虑提取;三个地方用到,一定要提取

组件的命名和组织

React 要求组件名必须以大写字母开头。

这不是为了好看,而是为了区分组件和普通 HTML 标签:

// React 知道 <Profile /> 是组件
<Profile />

// React 知道 <div> 是 HTML 标签
<div></div>

关于文件组织:

  • 一个文件一个主要组件,文件名和组件名保持一致
  • 如果有多个紧密相关的小组件,可以放在同一个文件中
  • 不要在组件内部定义组件,这会导致每次渲染都创建新的组件定义,破坏性能优化

模块化的实践

React 使用标准的 JavaScript 模块系统。

这里需要注意默认导出和具名导出的区别:

// 默认导出 - 一个文件一个主要组件
export default function Button() {
  return <button>点击我</button>;
}

// 导入时可以使用任意名称
import MyButton from './Button.js';
// 具名导出 - 一个文件多个组件
export function Button() {
  return <button>点击我</button>;
}

export function Input() {
  return <input />;
}

// 导入时必须使用相同的名称
import { Button, Input } from './Components.js';

建议:团队内保持一致即可。

个人倾向于默认导出,因为它让导入语句更简洁,也更容易重构。


Part 2: JSX 的设计哲学

JSX 解决了什么问题

在 JSX 出现之前,React 使用 React.createElement() 来创建元素:

const element = React.createElement(
  'h1',
  { className: 'greeting' },
  'Hello, world!'
);

这种方式的问题是:代码结构和最终的 UI 结构差距太大,难以理解和维护。JSX 让代码更接近最终的 UI 结构:

const element = <h1 className="greeting">Hello, world!</h1>;

JSX 不是必需的,但它让代码更直观。这是一个务实的选择。

JSX 的三条规则

JSX 看起来像 HTML,但它更严格。这些规则背后都有技术原因。

规则 1:只能返回一个根元素

// 错误:返回了两个元素
function AboutPage() {
  return (
    <h1>关于我们</h1>
    <p>欢迎来到我们的网站</p>
  );
}

// 正确:用 Fragment 包裹
function AboutPage() {
  return (
    <>
      <h1>关于我们</h1>
      <p>欢迎来到我们的网站</p>
    </>
  );
}

为什么?因为 JSX 会被转换成 JavaScript 函数调用,而函数只能返回一个值。

<>...</> 是 Fragment 的简写,它不会在 DOM 中创建额外的节点。

规则 2:所有标签必须闭合

在 HTML 中,<img><br> 可以不闭合。但在 JSX 中,所有标签都必须闭合:

<img src="avatar.jpg" />
<br />

这是因为 JSX 是 JavaScript,需要明确的语法边界。

规则 3:使用驼峰命名法

JSX 会转换成 JavaScript,所以属性名需要遵循 JavaScript 的命名规则:

// HTML 中
<div class="container" tabindex="0"></div>

// JSX 中
<div className="container" tabIndex={0}></div>

classfor 是 JavaScript 保留字,所以 JSX 使用 classNamehtmlFor。其他属性使用驼峰命名法,如 onClickstrokeWidth

JSX 中的 JavaScript

JSX 的强大之处在于,你可以在标记中嵌入 JavaScript 表达式。使用大括号 {} 就可以"回到" JavaScript:

function Profile() {
  const name = "Katherine Johnson";
  const avatar = "https://i.imgur.com/MK3eW3Am.jpg";

  return (
    <div>
      <h1>{name}的个人资料</h1>
      <img src={avatar} alt={name} />
    </div>
  );
}

你可以在大括号中使用任何 JavaScript 表达式:

function TodoList() {
  const tasks = 3;

  return (
    <div>
      <h1>待办事项</h1>
      <p>你还有 {tasks} 个任务</p>
      <p>完成度:{(tasks / 10) * 100}%</p>
    </div>
  );
}

双大括号的秘密

你可能会看到这样的代码:

<div style={{ backgroundColor: 'black', color: 'pink' }}>
  内容
</div>

这不是特殊语法。外层的 {} 表示"这是 JavaScript 表达式",内层的 {} 表示"这是一个 JavaScript 对象"。

JSX vs 模板语法

有些框架(如 Vue)使用模板语法,提供了 v-ifv-for 这样的指令。React 选择了 JSX,让你直接使用 JavaScript 的 ifmap 等语法。

这两种方式各有优劣:

  • 模板语法更容易学习,但功能受限
  • JSX 更灵活,但需要更好的 JavaScript 基础

React 的选择是:相信开发者的 JavaScript 能力,不创造新的语法。

这在 AI 时代更有意义,因为 AI 更容易理解标准的 JavaScript,而不是框架特定的语法。


Part 3: Props 与数据流

Props 的设计理念

Props 是 React 实现单向数据流的核心机制。

父组件通过 props 向子组件传递数据,子组件不能修改 props。

// 父组件传递 props
function Profile() {
  return (
    <Avatar
      name="Lin Lanying"
      imageUrl="https://i.imgur.com/1bX5QH6.jpg"
      size={100}
    />
  );
}

// 子组件接收 props
function Avatar({ name, imageUrl, size }) {
  return (
    <img
      src={imageUrl}
      alt={name}
      width={size}
      height={size}
    />
  );
}

这里使用了解构语法,让代码更简洁。你也可以使用 props 对象:

function Avatar(props) {
  return (
    <img
      src={props.imageUrl}
      alt={props.name}
      width={props.size}
      height={props.size}
    />
  );
}

我建议使用解构语法,因为它让组件的 API 一目了然。

如何设计 Props API

设计 Props API 是一门艺术。好的 Props API 应该:

  • 命名清晰,见名知意
  • 数量适中,不超过 5-6 个
  • 有合理的默认值
function Avatar({ name, imageUrl, size = 100 }) {
  // size 有默认值,调用时可以省略
  return (
    <img
      src={imageUrl}
      alt={name}
      width={size}
      height={size}
    />
  );
}

实际项目中,如果一个组件需要太多 props,通常意味着它做了太多事情,应该考虑拆分。

children 的使用场景

children 是一个特殊的 prop,用于传递组件标签之间的内容:

function Card({ children }) {
  return (
    <div className="card">
      {children}
    </div>
  );
}

// 使用时
function Profile() {
  return (
    <Card>
      <h1>用户资料</h1>
      <p>这是卡片的内容</p>
      <button>编辑</button>
    </Card>
  );
}

children 特别适合创建布局组件。我在项目中经常用它来创建 Modal、Card、Container 这样的组件。

Props 不可变性的意义

Props 是只读的,这是 React 的核心原则之一:

// 错误:不要修改 props
function Clock({ color }) {
  color = 'red';  // 错误!
  return <div style={{ color }}>当前时间</div>;
}

为什么?因为 Props 代表父组件传递的数据。如果子组件可以随意修改,会导致数据流混乱,难以追踪 bug。

Props 就像函数的参数,你不会在函数内部修改参数的值。如果需要修改数据,应该使用 state(这是下一个主题)。

这种单向数据流的设计,让 React 应用更容易理解和调试。在大型项目中,这个优势尤其明显。


Part 4: 条件渲染与列表渲染

四种条件渲染方式

React 没有提供 v-if 这样的指令,而是让你直接使用 JavaScript 的条件语法。这给了你更大的灵活性。

方式 1:if 语句(提前返回)

function PackingItem({ name, isPacked }) {
  if (isPacked) {
    return <li className="item">{name} ✔</li>;
  }
  return <li className="item">{name}</li>;
}

适用场景:两种情况的 UI 差异较大时。

方式 2:三元运算符

function PackingItem({ name, isPacked }) {
  return (
    <li className="item">
      {isPacked ? name + ' ✔' : name}
    </li>
  );
}

适用场景:需要在两个选项之间选择时。

方式 3:逻辑与运算符 &&

function Notification({ message, hasNewMessage }) {
  return (
    <div>
      <h1>通知中心</h1>
      {hasNewMessage && <p>你有新消息:{message}</p>}
    </div>
  );
}

适用场景:条件为假时不需要显示任何内容。

注意:不要在 && 左侧放数字!

// 错误:当 messageCount 为 0 时,会显示 0
{messageCount && <p>有 {messageCount} 条新消息</p>}

// 正确:确保左侧是布尔值
{messageCount > 0 && <p>有 {messageCount} 条新消息</p>}

为什么?因为在 JavaScript 中,0 && something 会返回 0,而 React 会渲染这个 0

方式 4:条件赋值给变量

function PackingItem({ name, isPacked }) {
  let itemContent = name;

  if (isPacked) {
    itemContent = <del>{name} ✔</del>;
  }

  return (
    <li className="item">
      {itemContent}
    </li>
  );
}

适用场景:条件逻辑复杂,或者需要多次使用条件结果时。

选择哪种方式

这取决于具体场景:

  • 简单的二选一:使用三元运算符
  • 只在条件为真时显示:使用 &&
  • 复杂的条件逻辑:使用 if 语句或变量赋值
  • 完全不同的 UI:使用 if 提前返回

在实际项目中,我倾向于使用 if 提前返回,因为它让代码更容易理解。

个人偏好,团队内保持一致即可。

列表渲染的性能考虑

在 React 中,使用 map() 方法来渲染列表:

const scientists = [
  '凯瑟琳·约翰逊',
  '马里奥·莫利纳',
  '穆罕默德·阿卜杜勒·萨拉姆'
];

function ScientistList() {
  return (
    <ul>
      {scientists.map(scientist =>
        <li>{scientist}</li>
      )}
    </ul>
  );
}

如果需要过滤数据,可以先用 filter(),再用 map()

const people = [
  { id: 0, name: '凯瑟琳·约翰逊', profession: '数学家' },
  { id: 1, name: '马里奥·莫利纳', profession: '化学家' },
  { id: 2, name: '穆罕默德·阿卜杜勒·萨拉姆', profession: '物理学家' },
  { id: 3, name: '珀西·朱利安', profession: '化学家' },
];

function ChemistList() {
  const chemists = people.filter(person =>
    person.profession === '化学家'
  );

  return (
    <ul>
      {chemists.map(person =>
        <li key={person.id}>
          <b>{person.name}</b>: {person.profession}
        </li>
      )}
    </ul>
  );
}

Key 的作用机制

你可能注意到上面的代码中有 key={person.id}。这个 key 非常重要。

Key 告诉 React 每个组件对应数组中的哪个项。

当列表发生变化时,React 通过 key 来判断哪些项是新增的、删除的或移动的,从而只更新必要的部分。

// 好:使用数据的唯一 ID
<li key={person.id}>{person.name}</li>

// 可以但不推荐:使用索引
<li key={index}>{person.name}</li>

// 错误:没有 key
<li>{person.name}</li>

何时可以使用索引作为 key?

只有当列表满足以下所有条件时:

  • 列表是静态的(不会增删改)
  • 列表不会重新排序
  • 列表项没有 ID

否则,使用索引作为 key 可能导致性能问题或 bug。

实战中的常见错误

我见过很多项目在列表渲染时出现问题,主要是:

  • 忘记添加 key
  • 使用索引作为 key,导致列表更新时出现 bug
  • 使用 Math.random() 生成 key,导致每次渲染都重新创建组件

记住:key 应该来自数据本身,而不是生成的。如果数据没有 ID,考虑在获取数据时生成一个唯一标识。


Part 5: 纯函数与副作用

为什么 React 强调纯函数

React 假设你编写的每个组件都是纯函数。这意味着:

  • 给定相同的 props,总是返回相同的 JSX
  • 不修改渲染前就存在的变量或对象
// 纯组件
function Recipe({ drinkers }) {
  return (
    <ol>
      <li>烧开 {drinkers} 杯水。</li>
      <li>加入 {drinkers} 勺茶和 {0.5 * drinkers} 勺香料。</li>
      <li>加入 {0.5 * drinkers} 杯牛奶和糖调味。</li>
    </ol>
  );
}

这个组件是纯粹的:无论调用多少次,相同的 drinkers 总是返回相同的结果。

不纯组件的问题

// 不纯组件:修改了外部变量
let guest = 0;

function Cup() {
  guest = guest + 1;  // 错误!
  return <h2>茶杯 #{guest}</h2>;
}

function TeaSet() {
  return (
    <>
      <Cup />
      <Cup />
      <Cup />
    </>
  );
}

这个组件有什么问题?每次渲染 Cup 都会修改 guest 变量。在开发模式下,React 会渲染每个组件两次(用于检测副作用),所以实际输出可能是:茶杯 #2, 茶杯 #4, 茶杯 #6。

正确的做法是通过 props 传递数据:

function Cup({ guest }) {
  return <h2>茶杯 #{guest}</h2>;
}

function TeaSet() {
  return (
    <>
      <Cup guest={1} />
      <Cup guest={2} />
      <Cup guest={3} />
    </>
  );
}

纯函数对性能优化的意义

纯函数的好处不仅仅是可预测性。它还让 React 可以进行性能优化:

  • 如果 props 没有变化,React 可以跳过渲染
  • React 可以安全地中断和重新开始渲染
  • React 可以并发渲染多个组件

这些优化都依赖于组件的纯粹性。如果组件有副作用,这些优化就无法进行。

副作用的正确处理方式

有些操作必须产生副作用,比如:

  • 发送网络请求
  • 更新 DOM
  • 启动动画

这些副作用应该放在事件处理函数中,而不是渲染逻辑中:

function Button() {
  function handleClick() {
    // 副作用:显示提示
    alert('你点击了我!');
  }

  return <button onClick={handleClick}>点击我</button>;
}

如果需要在渲染时执行副作用(比如数据获取),应该使用 useEffect hook。但这是另一个主题了。

StrictMode 的作用

React 提供了严格模式,在开发环境中会调用每个组件两次,帮助发现不纯的组件:

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';

const root = createRoot(document.getElementById('root'));
root.render(
  <StrictMode>
    <App />
  </StrictMode>
);

如果你的组件是纯粹的,调用两次不会有任何问题。如果不纯,你会立即发现问题。

我建议始终开启严格模式。它能帮你在开发阶段发现很多潜在的问题。


学习路径与思考

下一步应该学什么

掌握了 UI 基础后,接下来应该学习:

  • 添加交互:事件处理、state 管理
  • 状态管理:如何设计 state 结构,如何在组件间共享 state
  • 副作用处理:useEffect 的使用和常见陷阱
  • 性能优化:memo、useMemo、useCallback 的使用场景

这些主题都建立在 UI 基础之上。

如果你理解了组件、props、纯函数这些概念,后续的学习会容易很多。

如何在实战中提升

理论学习很重要,但实战才能真正掌握。我的建议是:

  • 从小项目开始,逐步挑战更复杂的应用
  • 阅读优秀的 React 项目代码,学习他们的组件设计
  • 遇到问题时,先思考为什么,再查文档
  • 不要过度设计,先让代码工作,再优化

AI 辅助开发的正确姿势

现在有了 AI 工具,开发效率确实提高了。但我发现,AI 最适合做的是:

  • 生成样板代码
  • 实现明确的功能
  • 重构和优化代码

AI 不擅长的是:

  • 架构设计
  • 性能优化
  • 复杂的状态管理

所以,理解原理仍然很重要。AI 可以帮你写代码,但不能帮你做决策。

保持技术敏感度的建议

前端技术变化很快,但核心概念变化不大。我的建议是:

  • 关注核心概念,而不是追逐新工具
  • 理解技术选择背后的权衡
  • 保持好奇心,但不要盲目跟风
  • 在实际项目中验证新技术,而不是为了用而用

React 的组件化思维、单向数据流、纯函数理念,这些概念在其他框架中也有体现。

掌握了这些,学习其他框架会容易很多。


总结

文章总结了 React UI 基础的核心概念:

  • 组件化思维:函数式的 UI 构建方式
  • JSX:声明式的 UI 描述语言
  • Props:单向数据流的实现
  • 条件渲染与列表渲染:动态 UI 的构建方式
  • 纯函数:可预测、可优化的组件设计

这些概念是 React 开发的基石。掌握它们后,你已经可以构建简单但完整的 React 应用了。

在 AI 时代,理解这些原理比记忆 API 更重要。它们是你判断 AI 生成代码质量的标准,也是你做技术决策的依据。


相关资源


本文基于 React 官方文档 "描述 UI" 章节。

ref和reactive对比终于学会了

ref和reactive对比终于学会了

文章较长,五千字数左右,可能需要我们费点时间阅读。

感兴趣的可以直接公众号【林太白】关注,持久更新面试!

ref和reactive对比

简单总结

【ref】
用于包装“基本类型数据”
也能包装引用类型数据(StringNumberBooleanUndefinedNullSymbol)

【reactive】
只能用于包装“引用类型数据” (ObjectArrayMapSet 等),不能包装基本类型

写法一览

// ref
// 模板中使用 ref,无需 .value(Vue 自动解包)
<template>
  <div>{{ count }}</div> // ✅ 正确,无需 count.value
</template>

// 脚本中必须用 .value
<script setup>
const count = ref(0);
console.log(count.value); // ✅ 正确,必须 .value
count.value = 1; // ✅ 正确,必须 .value
</script>


// reactive
// reactive 无论在脚本还是模板,都无需 .value
<template>
  <div>{{ user.name }}</div> // ✅ 正确
</template>
<script setup>
const user = reactive({ name: "张三" });
user.name = "李四"; // ✅ 正确
</script>

ref

定义

接受一个内部值,返回一个响应式的、可更改的 ref 对象,此对象只有一个指向其内部值的属性.value

function ref<T>(value: T): Ref<UnwrapRef<T>>

interface Ref<T> {
  value: T
}

官方描述

ref 对象是可更改的,也就是说你可以为 .value 赋予新的值。它也是响应式的,即所有对 .value 的操作都将被追踪,并且写操作会触发与之相关的副作用。

如果将一个对象赋值给 ref,那么这个对象将通过 reactive() 转为具有深层次响应式的对象。这也意味着如果对象中包含了嵌套的 ref,它们将被深层地解包。

若要避免这种深层次的转换,请使用 shallowRef() 来替代。

通常我们经常会使用ref来处理一些原始值(如数字、字符串、布尔值等)在 Vue 的响应式系统中工作

核心原理

1、包装原始值:将原始值包装在一个具有 .value 属性的对象中。

2、响应式转换:使用 reactive 函数使这个对象成为响应式的。

3、依赖追踪:当访问 .value 时,会进行依赖收集;当修改 .value 时,会触发依赖更新

4、模板中自动解包

模板中使用:在模板中,ref 会自动解包,不需要.value

ref的简化实现原理大致如下

function ref(rawValue) {
  // 创建一个 reactive 对象,包装原始值
  const r = {
    value: rawValue
  }
  // 将对象转换为响应式
  return reactive(r)
}

包裹以后我们需要添加依赖追踪和触发更新的逻辑

function ref(rawValue) {
  // 创建一个 reactive 对象,包装原始值
  const r = {
   // 标记这是一个 ref
    __v_isRef: true,
    value: null
  }
  // 将值转换为响应式
  r.value = reactive({
    value: rawValue
  })
  return r
}

reactive

定义

在 Vue 3 中,reactive 是用来创建响应式对象的。

官方介绍:返回一个对象的响应式代理

function reactive<T extends object>(target: T): UnwrapNestedRefs<T>

如果我们想保留对对象顶层次访问的响应性,可以使用 shallowReactive() 作替代

注意坑

1、reactive包装基本类型时,不会报错,但没有响应式效果

reactive 底层依赖 Proxy,而 Proxy 只能代理对象/数组,无法代理基本类型,所以会直接返回原数据,失去响应式能力。

2、reactive 直接赋值、解构、属性赋值给普通变量,都会导致响应式丢失,用 ref 或 toRefs/toRef 可解决

核心原理

reactive基于Proxy实现,可以创建一个对象的深层响应式版本

拦截对象的所有操作(get、set、delete、has)

访问或修改属性时,会触发依赖收集和派发更新

只能用于对象类型,不能用于基本类型

赋值清空

在vue3之中我们最常使用的就是赋值清空这一步

直接重新赋值

一种简单的方式是直接将响应式对象重新赋值为一个空对象或初始状态。

缺点是,如果你有多个地方引用 state,重新赋值会改变引用,可能会导致不希望的副作用。

import { reactive } from 'vue';
const state = reactive({
  name: 'Alice',
  age: 25
});

// 清空对象
state = reactive({}); // 重新赋值为一个新的空对象
逐个属性删除

如果你不想改变对象的引用,可以逐个属性删除对象中的数据:

保留了对象的引用,但它会删除所有属性,因此,响应式对象将变成一个空对象。

import { reactive } from 'vue';

const state = reactive({
  name: 'Alice',
  age: 25
});

// 清空对象的属性
for (const key in state) {
  if (state.hasOwnProperty(key)) {
    delete state[key];
  }
}
使用 Object.assign 重置

如果你有一个初始的默认值,并想要重置对象到初始状态,可以使用 Object.assign() 来将对象重置为默认状态。

比较适合我们有初始状态的时候使用,可以避免直接删除属性,仍然保留了对象的引用。

import { reactive } from 'vue';

const defaultState = {
  name: '',
  age: 0
};

const state = reactive({
  name: 'Alice',
  age: 25
});

// 重置为默认状态
Object.assign(state, defaultState);

ref和reactive对比

总结

  • **ref:**本质上是底层会创建一个“包装对象”{ value: 原始数据 }对这个包装对象使用 Proxy 代理,监听包装对象的 value 属性的变化,所以修改时必须操作 .value。
  • reactive:本质上是直接对原始引用类型数据进行 Proxy 代理,监听对象的所有属性变化,所以无需 .value,直接操作属性即可触发响应。

区别

数据类型
  • ref:支持所有数据类型,包括基本类型(number、string、boolean等)和对象
  • reactive:只支持对象类型(包括数组、Map、Set等)
访问方式
  • ref:需要通过.value访问和修改值
  • reactive:直接访问和修改属性,不需要额外语法
实现机制
  • ref:使用包装对象,内部通过.value属性暴露值
  • reactive:使用Proxy直接代理整个对象
解包行为
  • ref:在模板中会自动解包,不需要.value
  • reactive:在模板中也会自动解包,保持直接访问属性
深层响应式
  • ref:对于对象类型,会递归地将其转换为reactive
  • reactive:默认就是深层响应式
实际使用场景

ref

import { ref } from 'vue';

// 基本类型响应式
const count = ref(0);
const double = computed(() => count.value * 2);

// 对象类型响应式
const userRef = ref({
  name: '张三',
  age: 25
});
userRef.value.name = '李四'; // 需要通过.value访问

reactive的使用场景

import { reactive } from 'vue';

// 对象响应式
const state = reactive({
  user: {
    name: '张三',
    age: 25
  },
  count: 0
});
state.user.name = '李四'; // 直接访问
state.count++; // 直接修改

相同点

1、共享响应式核心机制

依赖收集与派发更新相同:两者都使用相同的依赖收集和派发更新机制

核心依赖机制代码大致如下

// 简化的依赖收集和触发更新系统

// 定义一个全局变量activeEffect,它是一个函数
let activeEffect = null;

// 定义一个全局变量targetMap,它是一个WeakMap,它的key是target,value是depsMap
const targetMap =new WeakMap();

// 依赖收集
function track(target, key) {
    // 1. 检查是否有活动的effect
    if (!activeEffect) return;

    // 2. 获取或创建target对应的依赖映射
    let depsMap = targetMap.get(target);
    if (!depsMap) {
        // 如果depsMap不存在,则创建一个
        depsMap = new Map();
        targetMap.set(target, depsMap);
    }

    // 3. 获取或创建key对应的依赖集合
    let deps = depsMap.get(key);
    if (!deps) {
        // 如果deps不存在,则创建一个Set
        deps = new Set();
        depsMap.set(key, deps);
    }
    // 4. 将当前活动的effect添加到依赖集合中
    deps.add(activeEffect);
}

// 触发更新
function trigger(target, key) {
    // 1.从targetMap中获取target对应的依赖映射
    const depsMap = targetMap.get(target);
    if (!depsMap) return;

    // 2.从依赖映射中获取key对应的依赖集合
    const deps = depsMap.get(key);
    if (deps) {
        // 3. 遍历依赖集合,执行所有 effect
        deps.forEach(effect => effect());
    }
}

除了依赖更新,同时也使用相同的effect系统来管理副作用

// effect系统
function effect(fn) {
    // 1. 创建一个包装函数 effectFn
    const effectFn  = ()=> {
        // 2. 设置 activeEffect 为当前 effectFn
        activeEffect = effectFn;
         // 3. 执行原始函数 fn
        fn();
        // 4. 清除 activeEffect
        activeEffect = null;
    };
    // 5. 执行包装函数,触发依赖收集
    effectFn();
    // 6. 返回 effectFn,以便后续可以手动调用或清除
    return effectFn;
}
2、共享标记系统

使用相同的标记来标识响应式对象

 //(2)共享的响应式标记
// 标记为响应式对象
const reactiveMarker = '__v_isReactive';
const refMarker = '__v_isRef';
function isReactive(value) {
    // 判断是否为响应式对象
    return value && value[reactiveMarker] === true; 
}
// 判断是否为 ref 对象
function isRef(value) {
    return value && value[refMarker] === true;
}
3、共享的对象处理逻辑

在部分对于对象的处理上两者都是使用相同的工具函数

 //(3)共享的对象处理逻辑
  //判断是否是对象
  function isObject(value) {
      return value !== null && typeof value === "object";
  }
  // 判断是否是数组
  function isArray(value) {
      return Array.isArray(value);
  }
  // 判断是否只读
  function isReadonly(value) {
      return value && value._v_isReadonly === true;
  }
4、共享的代理/拦截机制

实现方式不同,但两者都实现了拦截访问和修改的机制

//(4)共享的代理/拦截机制
// ref的拦截方式(通过getter/setter)
function ref(value) {
    return {
        [refMarker]: true, //标记为 ref 对象
        _value:value, // 原始值
        get value() {
            track(this,"value"); // 依赖收集
            return this._value;
        },
        set value(newvalue){
            this._value = newvalue;
            trigger(this,"value"); // 触发更新
        }
    }
}

// reactive的拦截方式(通过Proxy)
function reactive(value){
    return new Proxy(value,{
        get(target,key){
            // 检查是否是内部标记
            if(key === reactiveMarker) return true;
            track(target,key); // 追踪依赖关系
            const res=Reflect.get(target, key); // 获取原始值
            return isObject(res)? reactive(res):res; // 如果值是对象,递归处理
        },
        set(target,key,newvalue){
            const oldValue = target[key]; // 获取旧值
            const result = Reflect.set(target, key, value); //使用 Reflect.set设置新值
            if(oldValue !== newvalue){
                trigger(target,key); // 触发更新
            }
            return result;
        }
    })
}
5、共享自动解包机制

两者在组合使用时共享自动解包逻辑

// 在reactive中自动解包ref
function get(target,key,receiver){
    // 检查是否是内部标记
    if (key === refMarker) return true;
    const res = Reflect.get(target, key, receiver); // 获取原始值
    // 如果值是ref,返回其value
    if(isRef(res)){
        return res.value;
    }
    // 如果值是对象,递归处理
    if(isObject(res)){
        return reactive(res);
    }
    return res;
}

// 在ref中自动包装reactive
function set(target,key,value,receiver){
    if(isRef(value)){
        value = value.value;
    }
    // ... 设置逻辑
}
6、共享的深度响应式处理

两者都支持深度响应式处理

// 深度响应式处理
  function deepReactive(value) {
      if (isObject(value)) {
          if (isRef(value)) {
              return value;
          }
          return reactive(value);
      }
      return value;
  }
  // 深度ref处理
  function deepRef(value) {
      if (isObject(value)) {
          return ref(reactive(value));
      }
      return ref(value);
  }
7. 共享的计算属性系统

两者都可以与计算属性系统无缝集成:

// (7)共享的计算属性系统
function computed(getter) {
    let value; // 计算属性的值
    let dirty = true; // 是否需要重新计算
    // 计算属性副作用函数
    const effectFn = effect(() => {
        //  如果dirty为true,则重新计算
        value = getter();
        dirty = true;
    })
    return {
        get value() {
            if (dirty) {
                value = getter();
                dirty = false;
            }
            trackRef(effectFn, 'value');
            return value;
        }
    }
}
// 使用示例
const count = ref(0);
const double = computed(() => count.value * 2);
console.log(double.value); // 0
8. 共享的响应式版本控制

两者都支持响应式版本的控制(如只读、浅层响应式等):

 // (8)共享的响应式版本控制
// 只读ref
function readonlyRef(value) {
    return {
        [refMarker]:true, // 标记为ref
        _value:value, // 原始值
        get value() {
            trackRef(this, 'value');
            return this._value;
        },
        set value(newValue) {
            console.warn('readonly ref cannot be modified');
        },
    }
}

// 浅层reactive
function shallowReactive(value){
    // 只处理对象的第一层
    return new Proxy(value,{
        // 只处理对象的第一层
        get(target, key) {
            if(key === reactiveMarker) return true;
            track(target, key);
            return target[key];
        },
        // 只处理对象的第一层
        set(target, key,value){
            const oldValue = target[key]; // 获取旧值
            const result = Reflect.set(target, key, value); // 设置新值
            if(oldValue !== value){
                trigger(target, key);
            }
            return result;
        },
    })
}

常见误区(❌格外注意)

赋值响应式丢失

ref 支持“直接赋值”,reactive 不支持“直接赋值”(赋值会导致响应式丢失)

ref 直接赋值
const count = ref(0);
count.value = 1; // ✅ 正常,响应式保留

const user = ref({ name: "张三" });
user.value = { name: "李四" }; // ✅ 正常,响应式保留

reactive 直接赋值
let user = reactive({ name: "张三" });
user = { name: "李四" }; // ❌ 错误!响应式丢失

原因:reactive 代理“原始对象本身”,给 reactive 包装的变量赋值时,相当于把变量指向了一个新的普通对象,原来的 Proxy 代理关系被切断,自然就失去了响应式能力。

就像你有一个遥控器(Proxy)控制电视(原始对象),如果你把遥控器变量指向了另一个新电视,原来的遥控器就控制不了原来的电视了。

ref 代理的是“包装对象的 value 属性”,赋值时只是修改了 value 的值(无论是基本类型还是引用类型),Proxy 代理关系依然存在,所以响应式不会丢失。

就类似你有一个带锁的盒子(ref包装对象),你只是更换了盒子里的东西(value),盒子本身(Proxy关系)没变,所以锁(响应式)依然有效。

写法优化

我们常见可以通过一些写法优化处理上面的响应式赋值丢失的问题

// 响应式丢失的写法 ❌
let user = reactive({ name: "张三" });

// 接口请求后,直接赋值新对象
user = await api.getUserInfo(); // ❌ 响应式丢失,后续修改 user 无效果


// 解决方案1:不直接赋值,修改属性(推荐)
const user = reactive({ name: "", age: 0 });
const res = await api.getUserInfo();

// 逐个修改属性,保留 Proxy 代理关系
user.name = res.name;
user.age = res.age; // ✅ 响应式有效

// 解决方案2:用 ref 包装(适合需要整体替换的场景)
const user = ref({ name: "张三" });
user.value = await api.getUserInfo(); // ✅ 响应式有效,直接替换整个对象

数组/集合赋值误区

我们经常会在数据之中进行数组的重新赋值,但是reactive 和 ref 对于数组的处理略有不同

  • reactive 处理数组:支持直接修改数组的元素、调用数组方法(push、pop、splice 等),都会触发响应式;但不能直接给整个数组赋值(和对象赋值一样,会丢失响应式===替换数组)
  • ref 处理数组:需要通过 .value 访问数组,修改元素、调用数组方法时,都要加上 .value,同样支持响应式;且可以直接给 .value 赋值新数组,响应式不会丢失。

reactive 处理数组

// reactive 处理数组
let list = reactive([1, 2, 3]);
list.push(4); // ✅ 正确,响应式有效
list[0] = 10; // ✅ 正确,响应式有效
list = [4, 5, 6]; // ❌ 错误,响应式丢失

ref 处理数组

const list = ref([1, 2, 3]);
list.value.push(4);  // ✅ 正常工作
list.value[0] = 10; // ✅ 正常工作
list.value = [4, 5, 6]; // ✅ 正常工作

解构reactive赋值失效

【问题】

解构 reactive 包装的对象时,解构出来的属性会变成“普通值”,失去响应式能力——因为解构本质是“取值”

【原因】取出的是属性的原始值,不再受 Proxy 监控。

【解决】正常我们写复杂对象并且需要响应式的时候会使用toRefs去改变对象的值

toRefs可以将reactive对象的每个属性,都转换成ref对象,解构后,每个属性依然是响应式的,修改时使用.value

// 错误示例(响应式丢失)
const product = reactive({
  id: 1,
  name: "笔记本电脑",
  price: 5999,
  details: {
    brand: "Apple",
    model: "MacBook Pro"
  }
});

// 错误:直接解构,响应式丢失
const { name, price, details } = product;
name = "新款MacBook"; // ❌ 响应式丢失
price = 6999; // ❌ 响应式丢失
details.brand = "Apple Inc."; // ❌ 响应式丢失

// 解决方案1:不解构,直接访问属性(推荐)
product.name = "新款MacBook";
product.price = 6999;
product.details.brand = "Apple Inc."; // ✅ 响应式有效

// 解决方案2:用 toRefs 解构(保留响应式)
import { toRefs } from "vue";
const product = reactive({
  id: 1,
  name: "笔记本电脑",
  price: 5999,
  details: {
    brand: "Apple",
    model: "MacBook Pro"
  }
});

const { name, price, details } = toRefs(product);
name.value = "新款MacBook"; // ✅ 响应式有效
price.value = 6999; // ✅ 响应式有效
// 注意:details 仍然是普通对象,需要进一步处理

reactive对象属性赋值普通变量

reactive 对象的某个属性赋值给普通变量,这个普通变量会失去响应式,本质也是“取出了原始值”。

<template>
  <div>
    <h2>计数器</h2>
    <p>当前计数: {{ count }}</p>
    <button @click="increment">增加</button>
  </div>
</template>

<script setup>
import { reactive, toRef } from 'vue';

// 错误示例(响应式丢失)
const state = reactive({ count: 0 });
let count = state.count; // 直接解构
count = 1; // ❌ 响应式丢失,页面不会更新

// 解决方案1:直接操作 reactive 对象
function increment() {
  state.count += 1; // ✅ 响应式有效
}

// 解决方案2:用 toRef 单独包装
const counter = toRef(state, 'count');
function increment() {
  counter.value += 1; // ✅ 响应式有效
}
</script>

数组/集合不当操作导致响应失效

// 错误示例1:用索引直接替换整个数组元素(针对引用类型元素)
const tasks = reactive([
  { id: 1, title: "学习Vue" },
  { id: 2, title: "写代码" }
]);

// 直接用普通对象替换数组中的元素,会丢失该元素的响应式
tasks[0] = { id: 1, title: "学习React" }; // ❌ 替换后的元素是普通对象,不是响应式的

// 解决方案1:修改元素的属性,不替换整个元素
tasks[0].title = "学习React"; // ✅ 响应式有效

// 解决方案2:用 splice 替换元素(保留响应式)
tasks.splice(0, 1, { id: 1, title: "学习React" }); // ✅ 用 splice 替换,响应式有效

// 错误示例2:直接修改数组的 length
const numbers = reactive([1, 2, 3, 4, 5]);
numbers.length = 0; // ❌ 直接修改 length,会导致响应式丢失,后续 push 无效果

// 解决方案:用 splice 清空数组
numbers.splice(0); // ✅ 响应式有效,清空数组后,后续 push 正常触发响应

shallowRef/shallowReactive浅响应式

【格外注意】

1、只需要监控表层数据时,可以使用shallowRef/shallowReactive浅响应式这种方式,减少Proxy的代理开销,提升页面性能进行性能优化。

2、无法用 shallowRef 包装需要频繁修改深层属性的数据,否则会频繁手动调用 triggerRef,增加负担

shallowReactive 不能直接赋值(和 reactive 一样),赋值会导致响应式丢失

3、浅响应式的核心是“性能优化”,不确定是否需要时优先用 ref 和 reactive(深响应式),避免因浅响应式导致的“数据不更新”问题。

shallowRef用法
import { shallowRef } from "vue";

// 用 shallowRef 包装一个对象
const state = shallowRef({
  count: 0,
  info: {
    name: "计数器",
    status: "运行中"
  }
});

// 场景1:替换整个 .value(表层变化,✅ 触发响应式)
state.value = {
  count: 1,
  info: {
    name: "计数器",
    status: "已停止"
  }
}; // ✅ 页面会更新

// 场景2:修改深层属性(❌ 不触发响应式)
state.value.count = 1; // ❌ 页面不更新
state.value.info.status = "已暂停"; // ❌ 页面不更新

// 补充:手动触发响应式
import { triggerRef } from "vue";
state.value.info.status = "已暂停";
triggerRef(state); // ✅ 手动触发,页面会更新

适用场景:

比如“弹窗显示/隐藏”(只需要修改 visible.value = true/false)

“表格数据的整体刷新”(只需要替换整个表格数据),这个时候用shallowRef的性能远远比ref 更好

shallowReactive用法
import { shallowReactive } from "vue";

// 用 shallowReactive 包装一个配置对象
const config = shallowReactive({
  title: "我的应用",
  settings: {
    theme: "dark",
    language: "zh-CN"
  }
});

// 场景1:修改表层属性(✅ 触发响应式)
config.title = "新应用"; // ✅ 页面会更新

// ✅ 页面会更新(替换整个settings对象)
config.settings = { theme: "light", language: "en-US" }; 


// 场景2:修改深层属性(❌ 不触发响应式)
config.settings.theme = "light"; // ❌ 页面不更新
config.settings.language = "en-US"; // ❌ 页面不更新

// 补充:无法手动触发,只能通过修改表层属性触发

重新认识 React Hooks:从会用到理解设计

作为一名用了几年 React 的前端开发者,我曾经很长一段时间都处于"会用 Hooks"的状态——useState 管状态、useEffect 处理副作用、useMemo 做性能优化,一套流程下来感觉没什么问题。

但如果追问自己,"为什么 Hooks 不能写在条件语句里?",我才意识到自己对 Hooks 的理解停留在 API 层面,对它背后的设计逻辑知之甚少。

这篇文章是我重新梳理 React Hooks 的学习笔记。目标不是"教你用",而是试图回答:Hooks 为什么是这个样子的,它背后在解决什么问题,又体现了什么设计思想。


一、Hooks 诞生的背景:Class 组件的三个痛点

在 React 16.8 引入 Hooks 之前,Class 组件是编写有状态组件的唯一方式。Class 组件本身没什么大问题,但随着应用规模变大,三个痛点变得越来越明显。

痛点一:逻辑复用困难

假设你有一段"监听窗口尺寸"的逻辑,需要在多个组件里复用。Class 时代的方案通常是两种:

高阶组件(HOC)

// 环境:React(Class 组件时代)
// 场景:通过 HOC 复用窗口尺寸逻辑

function withWindowSize(WrappedComponent) {
  return class extends React.Component {
    state = { width: window.innerWidth, height: window.innerHeight };

    handleResize = () => {
      this.setState({ width: window.innerWidth, height: window.innerHeight });
    };

    componentDidMount() {
      window.addEventListener('resize', this.handleResize);
    }

    componentWillUnmount() {
      window.removeEventListener('resize', this.handleResize);
    }

    render() {
      return <WrappedComponent windowSize={this.state} {...this.props} />;
    }
  };
}

或者 Render Props

// 场景:通过 Render Props 复用逻辑
class WindowSize extends React.Component {
  state = { width: window.innerWidth, height: window.innerHeight };
  // ... 同上的监听逻辑

  render() {
    return this.props.children(this.state);
  }
}

// 使用时:
<WindowSize>
  {({ width, height }) => <div>{width} x {height}</div>}
</WindowSize>

两种方案各有问题:HOC 会产生"包装地狱",多个 HOC 嵌套后 props 来源变得不清晰;Render Props 则让 JSX 结构变得冗余。而用 Hooks,这个逻辑只需要:

// 环境:React 16.8+
// 场景:自定义 Hook 复用窗口尺寸逻辑

function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  useEffect(() => {
    const handleResize = () => {
      setSize({ width: window.innerWidth, height: window.innerHeight });
    };

    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return size;
}

// 任意组件中使用,逻辑来源清晰
function MyComponent() {
  const { width, height } = useWindowSize();
  return <div>{width} x {height}</div>;
}

代码量差不多,但清晰度和可组合性完全不同。

痛点二:生命周期割裂相关逻辑

Class 组件的生命周期是按"时机"组织的,而不是按"逻辑"组织的。这导致同一段业务逻辑往往被拆散到不同的 生命周期方法 里:

// 一个 Class 组件中,订阅和清理逻辑被迫分离
componentDidMount() {
  // 初始化:订阅数据源 A
  DataSourceA.subscribe(this.handleChange);
  // 初始化:订阅数据源 B(完全不相关的逻辑混在一起)
  DataSourceB.subscribe(this.handleOtherChange);
}

componentWillUnmount() {
  // 清理逻辑分散在这里,需要和上面对应着看
  DataSourceA.unsubscribe(this.handleChange);
  DataSourceB.unsubscribe(this.handleOtherChange);
}

useEffect 把"建立"和"清理"放在同一个地方,相关逻辑内聚在一起:

// 每段逻辑自成一体,不需要跨方法对应
useEffect(() => {
  DataSourceA.subscribe(handleChange);
  return () => DataSourceA.unsubscribe(handleChange); // 清理在同一处
}, []);

useEffect(() => {
  DataSourceB.subscribe(handleOtherChange);
  return () => DataSourceB.unsubscribe(handleOtherChange);
}, []);

痛点三:this 的心智负担

Class 组件中 this 的指向问题是很多 React 初学者的噩梦。事件处理函数需要手动绑定、或者使用箭头函数属性,这是语言层面的摩擦,和 UI 逻辑本身无关。

Hooks 把这些摩擦从根源上消除了——函数组件里没有 this


二、Hooks 能工作的秘密:链表与调用顺序

理解了"为什么需要 Hooks"之后,一个自然的问题是:函数组件每次渲染都会重新执行,React 怎么知道哪个 useState 对应哪个状态?

React 用链表记住 Hooks 的顺序

每个 React 组件对应一个 Fiber 节点。Fiber 节点上有一个 memoizedState 字段,它指向一条链表,每个节点存储着一个 Hook 的状态。

Fiber 节点
└── memoizedState
    └── Hook[0]: { state: count, next → }
        └── Hook[1]: { state: name, next → }
            └── Hook[2]: { effect: ..., next → null }

关键点在于:React 靠调用顺序来对应每个 Hook。 第一次调用 useState 对应链表第一个节点,第二次对应第二个……以此类推。

这就是为什么 Hooks 必须在顶层调用,不能写在条件语句、循环或嵌套函数里:

// ❌ 错误:条件语句打乱了 Hook 的调用顺序
function BadComponent({ showName }) {
  const [count, setCount] = useState(0); // Hook[0]

  if (showName) {
    const [name, setName] = useState(''); // 有时是 Hook[1],有时不存在
  }

  const [age, setAge] = useState(0); // 有时是 Hook[1],有时是 Hook[2]
  // React 拿错链表节点,状态全乱了
}

结论很简单:

Hooks 的调用顺序就是它的"地址",顺序变了,React 就找错门了。


三、五个核心 Hook 的深度拆解

3.1 useState:状态更新的真相

useState 看起来最简单,但它有几个细节值得深究。

传值 vs 传函数

setState 接受两种形式:直接传新值,或传一个接收旧值的函数。

// 环境:React
// 场景:理解 setState 传函数的必要性

function Counter() {
  const [count, setCount] = useState(0);

  // ❌ 在某些场景下有问题
  const handleClickBad = () => {
    setCount(count + 1); // 闭包捕获的 count 可能是旧值
    setCount(count + 1); // 两次调用,实际只加了 1
  };

  // ✅ 传函数,基于最新状态计算
  const handleClickGood = () => {
    setCount(prev => prev + 1); // 基于最新值 +1
    setCount(prev => prev + 1); // 再基于最新值 +1,共加了 2
  };

  return <button onClick={handleClickGood}>{count}</button>;
}

原因在于:React 在处理事件时会批量更新(batching),多次 setState 不会立即触发重渲染。如果传的是值,多次调用都在引用同一个闭包里的旧 count;如果传的是函数,React 会把函数排队,依次传入最新状态执行。

React 18 的批量更新

在 React 18 之前,批量更新只在 React 合成事件中生效;在 setTimeout、Promise 回调里的 setState 是同步触发渲染的。React 18 引入了"自动批量更新"(Automatic Batching),这些场景也会被批量处理。

// 环境:React 18
// 场景:展示 Automatic Batching 的效果

function Example() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  const handleClick = () => {
    setTimeout(() => {
      setCount(c => c + 1);
      setFlag(f => !f);
      // React 18:只触发一次重渲染
      // React 17 及之前:触发两次重渲染
    }, 100);
  };

  return <button onClick={handleClick}>Click</button>;
}

初始化惰性求值

如果初始状态需要复杂计算,可以传入函数,React 只在首次渲染时调用它:

// ✅ 惰性初始化:computeExpensiveValue 只执行一次
const [state, setState] = useState(() => computeExpensiveValue(props));

// ❌ 每次渲染都会执行
const [state, setState] = useState(computeExpensiveValue(props));

3.2 useEffect:副作用不等于生命周期

useEffect 是 Hooks 里最容易被误用的一个。很多人把它当作 componentDidMount + componentDidUpdate + componentWillUnmount 的合体,这个理解不完全准确。

依赖数组比较的是什么?

React 用 Object.is 对依赖数组里的每一项做浅比较。这意味着:

// 环境:React
// 场景:理解依赖比较的陷阱

function Component({ options }) {
  useEffect(() => {
    // 问题:每次渲染 options 都是新对象引用,即使内容没变
    fetchData(options);
  }, [options]); // 父组件每次渲染都传入 { page: 1 },但引用不同,Effect 会不停触发
}

// 父组件
function Parent() {
  return <Component options={{ page: 1 }} />; // 每次渲染都是新对象
}

对于对象和数组类型的依赖,需要特别注意:要么用 useMemo 稳定引用,要么把对象解构成基本类型后放入依赖。

componentDidMount 的微妙差异

useEffect 的执行时机是渲染提交到 DOM 之后、浏览器完成绘制之后,是异步执行的。而 componentDidMount 在 DOM 更新后同步执行。如果需要同步读取 DOM 布局(比如测量元素尺寸后立即更新 UI),应该用 useLayoutEffect

// useLayoutEffect:DOM 更新后同步执行,防止闪烁
useLayoutEffect(() => {
  const height = ref.current.getBoundingClientRect().height;
  setHeight(height); // 这次状态更新会和 DOM 操作合并,用户不会看到中间状态
}, []);

异步请求的内存泄漏问题

这是实际开发中很常见的问题:

// 环境:React
// 场景:组件卸载后异步请求仍在执行,尝试更新已卸载的组件

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    let cancelled = false; // 用一个标志位标记是否已取消

    fetchUser(userId).then(data => {
      if (!cancelled) {
        setUser(data); // 只有未取消时才更新状态
      }
    });

    return () => {
      cancelled = true; // 清理时标记为已取消
    };
  }, [userId]);

  return user ? <div>{user.name}</div> : <div>Loading...</div>;
}

更现代的方案是使用 AbortController

useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/user/${userId}`, { signal: controller.signal })
    .then(res => res.json())
    .then(data => setUser(data))
    .catch(err => {
      if (err.name !== 'AbortError') {
        console.error(err);
      }
    });

  return () => controller.abort(); // 组件卸载时中止请求
}, [userId]);

3.3 useMemo & useCallback:缓存的代价

这两个 Hook 经常被一起提,但它们的本质略有不同。

本质区别

// useMemo:缓存计算结果(一个值)
const sortedList = useMemo(() => {
  return items.sort((a, b) => a.value - b.value);
}, [items]);

// useCallback:缓存函数引用(函数也是一种值,但强调的是引用稳定性)
const handleClick = useCallback(() => {
  console.log(count);
}, [count]);

// useCallback(fn, deps) 等价于 useMemo(() => fn, deps)

useCallback 的核心价值在于稳定函数引用,主要用于两个场景:

  1. 函数作为 props 传给用 React.memo 包裹的子组件
  2. 函数被放入 useEffect 的依赖数组

缓存本身有成本

这是很多人忽视的一点。useMemouseCallback 本身并不是免费的——React 需要:

  • 存储依赖数组的前一个值
  • 每次渲染时对比新旧依赖
  • 在内存中保留缓存的值

对于轻量的计算,这个开销可能比重新计算更大。一个粗略的判断原则:

// ❌ 没必要:计算本身很轻量
const doubled = useMemo(() => count * 2, [count]);

// ✅ 有价值:耗时的计算,或需要稳定引用传给子组件
const processedData = useMemo(() => {
  return largeDataSet.filter(...).map(...).reduce(...);
}, [largeDataSet]);

React 官方的建议是:先写正确的代码,再根据实际性能问题决定是否优化,而不是预防性地给所有函数和值套上 useMemo / useCallback

缓存失效的时机

依赖数组中任何一项(通过 Object.is 比较)发生变化时,缓存就会失效。另外,React 在某些情况下(如开发模式的 Strict Mode、内存压力)可能主动丢弃缓存,因此 useMemo 的缓存只能用于性能优化,不能作为语义保证。


3.4 useRef:被低估的 Hook

useRef 在很多教程里被简单介绍为"获取 DOM 节点的方式",但它的能力远不止于此。

Ref 的本质:一个稳定的可变容器

// useRef 返回一个在组件整个生命周期内保持同一引用的对象
const ref = useRef(initialValue);
// ref 始终是 { current: ... } 这个对象
// 修改 ref.current 不会触发重渲染

这个特性让 useRef 成了一个"逃生舱"——当你需要在不触发渲染的情况下存储某个值时,就用它。

保存计时器 ID

// 环境:React
// 场景:在多次渲染间保持 timer 引用,用于清除

function Stopwatch() {
  const [time, setTime] = useState(0);
  const timerRef = useRef(null); // 不需要触发渲染,不用 useState

  const start = () => {
    timerRef.current = setInterval(() => {
      setTime(t => t + 1);
    }, 1000);
  };

  const stop = () => {
    clearInterval(timerRef.current);
  };

  return (
    <div>
      <span>{time}s</span>
      <button onClick={start}>开始</button>
      <button onClick={stop}>停止</button>
    </div>
  );
}

解决闭包陷阱:保存最新值

这是 useRef 最重要的一个高级用法,和下一节的"闭包陷阱"紧密相关:

// 环境:React
// 场景:在定时器回调中始终读到最新的 state 值

function LiveCounter() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);

  // 每次 count 更新时,同步更新 ref
  useEffect(() => {
    countRef.current = count;
  }, [count]);

  useEffect(() => {
    const timer = setInterval(() => {
      // countRef.current 始终是最新值,不受闭包影响
      console.log('current count:', countRef.current);
    }, 1000);

    return () => clearInterval(timer);
  }, []); // 依赖数组为空,effect 只执行一次

  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

四、最容易掉进去的坑:闭包陷阱(Stale Closure)

闭包陷阱可能是使用 Hooks 过程中最隐蔽的 Bug 来源。

什么是闭包陷阱?

先看一个最小复现:

// 环境:React
// 场景:经典的闭包陷阱示例

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      // 这里的 count 永远是 0
      // 因为 effect 只在挂载时执行一次,count 被"封印"在了那一刻的闭包里
      console.log('count is:', count); // 始终打印 0
      setCount(count + 1); // 所以始终是 0 + 1 = 1
    }, 1000);

    return () => clearInterval(timer);
  }, []); // 空依赖数组:effect 不会随 count 更新而重新执行

  return <div>{count}</div>;
}

问题的根源useEffect 的回调函数在执行时,捕获的是创建那一刻的变量快照。空依赖数组意味着 effect 只在挂载时创建一次,之后 count 每次更新,effect 里的闭包引用的还是最初那个 0

为什么 Hooks 特别容易产生这个问题?

Class 组件不容易出现这个问题,因为 this.state.count 是通过 this 动态查找的,总是指向最新值。而函数组件里,每次渲染产生一个新的函数作用域,变量的值是那次渲染的快照——这是"渲染即快照"的设计哲学,通常是优点,但在异步场景下会成为陷阱。

三种解法

方案一:把依赖项补全(最常见)

useEffect(() => {
  const timer = setInterval(() => {
    setCount(count + 1);
  }, 1000);
  return () => clearInterval(timer);
}, [count]); // 告诉 React:count 变了就重新创建 effect
// 副作用:每次 count 变化都会重新创建 setInterval,可接受

方案二:用函数式更新避免读取旧值

useEffect(() => {
  const timer = setInterval(() => {
    setCount(prev => prev + 1); // 不需要读 count,只需要在旧值上 +1
  }, 1000);
  return () => clearInterval(timer);
}, []); // 依赖数组可以为空,因为我们不依赖外部的 count 值

这是这个场景下最优雅的解法:利用函数式更新的特性,完全绕开了闭包捕获的问题。

方案三:用 useRef 保存最新值

// 适合需要在回调里读取多个最新状态,或状态逻辑更复杂的场景
function useLatest(value) {
  const ref = useRef(value);
  useEffect(() => {
    ref.current = value;
  });
  return ref;
}

function Counter() {
  const [count, setCount] = useState(0);
  const countRef = useLatest(count); // 始终指向最新值的 ref

  useEffect(() => {
    const timer = setInterval(() => {
      setCount(countRef.current + 1); // 安全地读取最新值
    }, 1000);
    return () => clearInterval(timer);
  }, []);
}

如何从根源上避免?

一个有效的心智模型是:useEffect 时,先不填依赖数组,把所有回调里用到的外部变量都写进去。如果依赖数组里的东西太多或变化太频繁,再考虑用函数式更新或 useRef 来精简。

React 官方的 ESLint 插件 eslint-plugin-react-hooksexhaustive-deps 规则可以自动检测遗漏的依赖,强烈建议开启。


五、设计思想的延伸:Hooks 与函数式编程

当我试着从更高的视角看 Hooks,发现它和函数式编程领域的一些概念有着深刻的共鸣。

"渲染即快照":拥抱不可变性

函数式编程的核心思想之一是不可变数据:不修改现有的值,而是创建新的值。React 的每次渲染,本质上是用当前的 props 和 state 生成一个 UI 的快照。useState 的更新不会"修改"旧状态,而是产生一个新状态,触发新一轮渲染。

这种设计让每一次渲染都是一个纯函数式的映射:UI = f(state)

副作用管理:Effect 作为"声明"

函数式编程里,纯函数不产生副作用。但真实应用里不可能没有副作用(网络请求、DOM 操作、定时器……)。

useEffect 的设计哲学是:把副作用从渲染逻辑里分离出来,以声明的方式描述"这个 effect 依赖哪些状态,应该在什么时候运行" ,而不是命令式地说"在第 3 步运行这段代码"。

这和函数式编程里用 Monad 把副作用"隔离"到类型系统边界的思想,有异曲同工之妙——当然,React 的实现要工程化得多。

代数效应:一个更底层的视角

这是一个稍微抽象的概念,但理解它能让你对 Hooks 的设计有更深的感受。

代数效应(Algebraic Effects) 是函数式编程里的一种错误处理和副作用管理机制(目前在一些学术语言如 Koka、Eff 中实现,主流语言尚未支持)。它的核心想法是:

函数可以"发出"一个效应(effect),调用方决定如何处理这个效应,函数本身不需要知道处理细节。

用一个伪代码来理解:

// 伪代码,非真实语法
// 场景:理解代数效应的思想

function getName() {
  // 发出一个"读取用户名"的效应,不关心怎么读
  const name = perform ReadUserName;
  return `Hello, ${name}`;
}

// 调用方决定如何处理这个效应
handle getName() {
  on ReadUserName -> resume('Alice'); // 用 'Alice' 处理,继续执行
}

Hooks 的 useState 可以被理解为一种类似的机制:函数组件"调用" useState 相当于发出一个"我需要管理这个状态"的效应,React 运行时(调用方)处理这个效应,并提供读写状态的能力——函数组件本身不需要知道状态存在哪里、怎么触发重渲染。

Dan Abramov(React 核心成员)在博客中明确提到,Hooks 的设计受到了代数效应思想的启发。当然,这是一种"精神上的借鉴",而非严格的学术实现。

自定义 Hook:组合优于继承

面向对象编程通过继承来复用逻辑,函数式编程通过函数组合。自定义 Hook 就是 React 版本的函数组合:

// 环境:React
// 场景:通过组合自定义 Hook 构建更复杂的能力

// 原子级 Hook
function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const saved = localStorage.getItem(key);
    return saved !== null ? JSON.parse(saved) : initialValue;
  });

  const setStoredValue = useCallback((newValue) => {
    setValue(newValue);
    localStorage.setItem(key, JSON.stringify(newValue));
  }, [key]);

  return [value, setStoredValue];
}

// 组合出更具体的能力
function useTheme() {
  return useLocalStorage('theme', 'light'); // 组合,而非继承
}

function useLanguage() {
  return useLocalStorage('language', 'zh-CN');
}

每个 Hook 是一个纯粹的函数,可以独立测试、自由组合。这是函数式编程中"组合优于继承"原则的直接体现。

一个判断原则:先问结构,再问优化

最后分享一个我觉得很有价值的思考角度。当你发现需要用 useMemouseCallback 来解决性能问题时,先停下来问一个问题:

"这个问题能不能通过调整组件结构来解决?"

// ❌ 用 useMemo 解决昂贵计算的"常见做法"
function Parent({ data }) {
  const processed = useMemo(() => expensiveProcess(data), [data]);
  return (
    <div>
      <ExpensiveChild data={processed} />
      <SimpleChild />   {/* 这个组件因为 Parent 重渲染而跟着渲染 */}
    </div>
  );
}

// ✅ 先考虑:能不能把昂贵的部分单独提取成子组件?
function ProcessedChild({ data }) {
  const processed = useMemo(() => expensiveProcess(data), [data]);
  return <ExpensiveChild data={processed} />;
}

function Parent({ data }) {
  return (
    <div>
      <ProcessedChild data={data} />
      <SimpleChild />   {/* 现在 SimpleChild 不会受影响 */}
    </div>
  );
}

组件结构的调整往往比性能 API 的使用更根本,也更容易维护。


小结

回头看这些问题,React Hooks 表面上是一套 API,但深入进去,会发现它在工程层面做了很多取舍:

  • 调用顺序换来了简洁的 API,代价是"不能在条件语句里调用 Hooks"的规则
  • 渲染即快照的模型换来了可预测性,代价是需要小心处理闭包陷阱
  • 声明式副作用换来了逻辑内聚,代价是依赖数组需要仔细维护

这种取舍本身就是软件设计的本质。

还有一些问题我还在探索中:

  • useTransitionuseDeferredValue 是如何在 Hooks 模型下实现并发渲染的?
  • React Server Components 的出现对 Hooks 的使用边界有什么影响?
  • 代数效应如果真的进入主流 JavaScript,会怎样改变前端状态管理的方式?

如果你对某个部分有不同的理解,或者有什么补充,欢迎交流。


参考资料

Mac龙虾保姆级完整部署指南

在 Mac 上本地部署近期非常火爆的开源 AI 智能体 OpenClaw(原名 Clawdbot / Moltbot)并配置好模型的流程非常清晰。OpenClaw 作为一个底层 Agent 框架,它可以作为你的“手脚”去操作电脑、读取文件、收发消息,但它需要接入一个大语言模型(LLM)作为“大脑”。

以下是在 Mac 环境下(适配 M 系列芯片与 Intel 芯片)的保姆级完整部署指南:

第一阶段:基础环境准备

OpenClaw 是基于 Node.js 运行的,因此需要先配置好环境。

1. 安装包管理器 Homebrew(如已安装可跳过) 打开 Mac 的“终端”(Terminal),粘贴以下命令并回车:

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

2. 安装 Node.js OpenClaw 要求 Node.js 版本至少为 v22(官方推荐 v24+)。在终端输入:

brew install node

验证安装:输入 node -vnpm -v,确保 node 版本在 v22 以上。

3. 配置 npm 全局目录(强烈建议,避免后续权限报错) 依次在终端执行以下命令:

mkdir -p ~/.npm-global
npm config set prefix '~/.npm-global'
echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.zshrc
source ~/.zshrc

第二阶段:安装与初始化 OpenClaw

1. 全局安装 OpenClaw 在终端运行以下命令(建议使用最新版):

npm install -g openclaw@latest

(如果习惯用 pnpm,也可以用 pnpm add -g openclaw@latest)

2. 初始化并安装守护进程 运行向导,它会自动帮你把 OpenClaw 配置为后台常驻服务(这样你的 AI 助理就能 24 小时在线):

openclaw onboard --install-daemon

3. 启动本地网关

openclaw gateway --port 18789

启动后,在浏览器中访问 http://localhost:18789 即可进入 OpenClaw 的 Web 控制台页面。如果是首次或远程访问,可能需要在终端运行 openclaw token generate 获取安全 Token 并在浏览器中填入。


第三阶段:配置 LLM 模型(最重要的一步)

你可以选择 云端 API(推荐)纯本地开源模型,具体取决于你的 Mac 性能和隐私需求。

方案 A:使用云端大模型 API(强烈推荐,逻辑推理能力强)

实测中,让 OpenClaw 执行系统级命令或复杂任务时,小型本地模型容易犯错。推荐使用 DeepSeek、Claude 3.5/3.7 或 Gemini API 作为“大脑”。

  1. 获取对应模型厂商的 API Key(如 DeepSeek API 或 Google Gemini API)。
  2. 在浏览器打开的 OpenClaw Web 控制台中,找到 Settings(设置) -> Model Provider
  3. 选择对应的服务商(如 DeepSeek 或 OpenAI),填入你的 API Key。
  4. 将默认模型(Default Model)设置为对应的模型名字(例如 deepseek-chatgemini-2.5-flash)。
  5. 快捷指令方式:你也可以直接通过终端调用配置:
    openclaw onboard
    
    跟随终端的交互式提示,选择 Provider 并粘贴 Key。

方案 B:使用纯本地模型(需 M1/M2/M3/M4 芯片且内存 16GB 以上)

如果你对隐私要求极高,希望断网也能用,可以搭配 Ollama 运行本地模型(推荐 Qwen-2.5 或 Llama-3 系列)。

  1. 下载并安装Ollama for Mac
  2. 在终端拉取并运行一个本地模型(以 Qwen 2.5 7B 为例):
    ollama run qwen2.5:7b
    
  3. 回到 OpenClaw,由于部分版本对非 OpenAI 原生接口的自动识别不够完美,建议使用 OpenAI 兼容模式接入 Ollama:
    • Provider: 选择 openai-completions 或自定义 OpenAI 兼容节点。
    • API Base URL: 填入 http://127.0.0.1:11434/v1
    • API Key: 随便填(如 ollama),因为本地不需要验证。
    • Model: 填入你在 Ollama 中下载的模型名,如 qwen2.5:7b

第四阶段:测试运行与授权

1. 基础对话与执行测试 在终端里输入一条指令,测试 AI 是否成功跑通:

openclaw agent --message "帮我查看一下当前的 macOS 系统版本,并告诉我可用磁盘空间" --thinking high

如果它能准确返回你的系统信息,说明“大脑(LLM)”和“手脚(OpenClaw)”已经成功连接!

2. 开启系统权限 随着你的使用,OpenClaw 可能会去读取日历、发送邮件或执行自动化脚本。

  • 当 macOS 弹出权限请求(如“终端请求访问日历”、“请求辅助功能权限”)时,请在系统设置 -> 隐私与安全性中为其放行。
  • 较新版本的 OpenClaw 默认关闭了部分高危工具权限,如果你发现它无法执行 Shell 命令,请在 WebUI 的技能(Skills/Plugins)设置中,将 Exec Tool(执行工具)权限打开。

3. 接入聊天软件(可选) 在 Web 控制台中,你可以将 OpenClaw 绑定到你常用的聊天软件(支持 Telegram、Discord、飞书、WhatsApp、iMessage 等)。绑定后,你就可以直接在手机微信/飞书/TG上给你的 Mac 发号施令,让它在家里帮你自动处理工作了。

Claude Code vs. Codex:终极指南

翻译自:Claude Code vs. Codex: The Definitive Guide

我用了几个月 Claude Code,后来转投 Codex,最近又换回了 Claude。选它的原因跟 benchmark 跑分无关。我也拿同一个任务测试过两者。

本文内容:我会聊聊 Claude Code 和 Codex 的各个方面,驱动它们的旗舰模型 Opus 4.6 vs. GPT-5.3-Codex 有什么区别,哪些因素真正影响你的 AI 编程体验,以及一个小型案例——我是如何用这两个工具搭建同一个 RAG pipeline 的。

先说清楚,这篇文章大概需要 12 分钟阅读时间。如果你打算每个月花 200 美元订阅其中之一,这时间花得值。

Opus 4.6 vs. GPT-5.3-Codex:任务完成时间跨度

Codex 和 Claude Code 之间有一个可靠的对比维度:任务完成时间跨度(Completion Time Horizon),详见此处

这个指标回答的问题是:这个模型能可靠地完成多长时间的任务? 任务完成时间跨度指的是模型以一定可靠性成功完成任务的时长(按人类专家完成时间衡量)。所以一个"2小时跨度 50%成功率"意味着:给你一个人类专家需要 2 小时的任务,AI 大约有五成把握能搞定。

Image

这项研究为每个模型配置了合适的 scaffold,包括 Claude Code 和 Codex。所以虽然焦点在模型本身而非 scaffold,但我们也能借此了解这些 scaffold 有多可靠。它告诉我们这些编程 agent 能处理多难、多长的任务。

如图所示,Opus 4.6GPT-5.3-Codex 之间差距很大。Opus 4.6 在 50% 成功率下的任务完成时长是 12 小时,而 GPT-5.3-Codex 是 5 小时 50 分钟。这个差距在 80% 成功率时有所缩小。

这清楚地表明两个模型之间存在差距,进而也体现在 Claude Code 和 Codex 上——它们处理困难任务的能力有所不同。但这个差距不一定直接映射到你用它们做的事上,心里有点数就行。

Claude Code 更快,但速度没那么重要

Claude 比 Codex 快是出了名的。但跟编程 agent 打交道是长期过程。

如果一个 agent 完成任务只用了一半时间,但之后需要你花 10 分钟调试那破玩意儿,而另一个虽然多花了点时间实现,但完成后不用你盯着——那多出来的时间 100% 值得花。

不是说 Claude Code 或 Codex 更容易犯错的——只是你自己评估这些 agent,或者听别人吹嘘它们的编程速度时,这句话值得记在心里。

任务类型对 agent 很重要

Codex 和 Claude Code 的表现取决于你用它做什么任务。在 AI 工程任务中,可能一个表现更好;但在 Web 开发任务中,同一个模型可能被吊打。

哪个编程任务更适合 Codex 或 Claude Code?这个研究做得还不够。

比如说,低级编程(low-level programming)该用哪个就不清楚。理想情况下,你应该在简单可验证的环境中先测试两者,再决定all in。但对大多数人来说,花 300-400 美元两个都买下来不太现实。

要全面对比两个 agent 在各种编程任务中的表现,是个有趣的研究方向。但也没那么轻松,因为这些 agent 和驱动它们的模型每隔几个月就会大幅变脸。

两者是如何诞生的

Claude Code 最初是 Anthropic 的 @bcherny 做的副业项目,做了个终端原型,能跟 Claude API 交互、读文件、跑 bash 命令。

内部团队到第五天就有一半人开始用了。然后 Claude Code 在 2025 年 2 月 24 日以研究预览版发布,用的是 Claude 3.7 Sonnet。花了一段时间被开发者大规模采用,之后 Anthropic 也发布了 VS Code 扩展。

OpenAI 这边,最初的 Codex 模型是 12B 参数的 GPT-3 微调版,基于 GitHub 代码,最终驱动了第一版 GitHub Copilot。但新的 Codex 是完全不同的产品。

Codex CLI 在 2025 年 4 月 16 日首发是终端 agent,之后随着更好的模型不断进化。最新版 GPT-5.3-Codex(2026年2月5日)被 OpenAI 称为"第一个参与创造自己的模型"。

@GergelyOrosz 做了两个很有意思的采访,分别关于 Claude Code 和 Codex 的开发者,涉及技术栈、开发方式、以及各自是怎么起步的。值得一看。

👉 Codex 是怎么构建的

2025年9月24日

Claude Code 是怎么构建的?Claude Code 自己写 90% 的代码,工程师每天大概提交 5 个 PR,人均 PR 产出比去年增长了 67%,而团队规模翻了一倍。更多细节在今天的深度报道里:newsletter.pragmaticengineer.com/p/how-claud… @bcherny, @_catwu, @sidbid)

技术栈和驱动模型

Claude Code 用 TypeScript 写的,用 React + Ink 做终端 UI。打包成单个 Bun 可执行文件(Anthropic 在 2025 年 12 月收购 Bun 就是为了这个)。它用的 Opus 和 Sonnet 模型都支持 100 万 token 的上下文窗口。

Codex CLI 用 Rust 写的,追求性能、正确性和可移植性。OpenAI 甚至把这个 Rust TUI 库 Ratatui 的维护者挖来了团队。

两个 CLI 工具都是围绕模型包了层薄薄的外壳,通过 API 调用。我注意到用 Claude Code CLI 时有些小"故障",在 Codex 上不太明显——考虑到技术栈,这也意料之中。

不过这些故障也就是轻微烦人而已,真的不影响编程体验。

Benchmark 很接近,但有细节差异:Token 经济性

最大的性能差异不是准确率,而是 Token 效率。Morphism 的 Opus vs Codex 全面评测 揭示了一个有趣的差距。

Image

在相同任务上,Claude Code 比 Codex 多消耗 3.2–4.2 倍的 Token。 做一个 Figma 插件,Codex 用了 150 万 Token,Claude 用了 620 万。

如果这是真的,意味着你花同样的钱订阅 Claude Code,更容易撞到 Token 上限。

感觉最重要

Claude 像个帮你干活的高级工程师,Codex 像个承包商,你把任务丢给它,然后回来取结果。

这是开发者描述两者差异的普遍方式。

据报 Claude Code 有很强的交互感,还有深度推理能力——这跟 Opus 的定位相符。它会问你问题,展示推理过程,解释它的做法。虽然我那一次对比实验里没这种情况,但从用了好几个月的经验来看,我能确认这是真的。

Codex 以第一次尝试的准确率著称,代价是实现速度稍微慢一点。

话虽如此,如果你在 AGENTS.md 里具体说明你想要什么,两者行为的差异会大幅缩小。如果你明确要求模型在开始干活前跟你确认实现计划,它就会照做——不管你用的是"高级工程师"agent 还是"承包商"agent。

这不是说两者真的没区别——区别是有的

只是没你在 X 上看到的那么夸张。

快速数据

VS Code Marketplace 上,Claude Code 有 610 万安装量,评分 4/5;Codex 有 540 万安装量,评分 3.5/5。

GitHub 上,Claude Code 大约 65–72K 星,Codex 约 64K 星。

Image

为什么我现在换回 Claude Code

Anthropic 的生态拉力强

选 Codex 还是 Claude Code 不只是编程问题。你订阅任何一个,等于订阅了整个 Anthropic/OpenAI 生态,这个因素值得考虑。

Image

我个人觉得 Claude 正在变成一个像 Apple 那样火热的生态,现在有 Claude Cowork、Claude Chat 和 Claude Code。Anthropic 似乎也在用 Claude app 慢慢搭建一个更安全、更温顺的 OpenClaw(主动式个人 agent),零敲碎打的功能正在逐步推出。

3月7日

今天我们在 Claude Code 桌面版推出本地定时任务。创建一个你想定期运行的任务计划,只要电脑醒着它们就会跑。

OpenAI 这边,目前我没看到什么诱人的东西。除了 Codex,其他的都挺无聊。我没感觉到一个生态,只感觉是零散的碎片,而且外面有更好的替代品。

我已经在用 Claude Chat 而不是 ChatGPT 了。对我来说,跟 Opus 相比,ChatGPT 现在基本没法用。UI、聊天风格、模型选择,没有一个让我有动力用 ChatGPT。

所以呢,因为我已经在高频使用 Claude Chat,打算折腾 cowork,目前没看到从 Claude Code 迁移到 Codex 有什么决定性的改进。换回 Claude Code、每月省下 200 美元,这决定做得相当轻松。

这成了影响我决定换回 Claude 的重大因素。

价格

Claude Code 和 Codex 的价格基本一样:

入门:都是 20 美元/月

高级用户:Claude Code 有个 Max 5x 档,100 美元/月

重度用户:都是 200 美元/月

Claude Code 真正亮眼的是它有个 100 美元的中档,而不是从 20 美元疯涨到 200 美元。而且我相信 Max 5x 计划(100 美元/月)对大多数开发者来说足够了。

所以可以说,Claude Code 实际上更便宜,因为它允许你选一个更便宜且够用的档,而不是逼你爬上价格阶梯。

技能和插件:开发者生态

技能(Skills)在 Claude Code 和 Codex 之间是兼容的,所以用哪个都感觉不出差别。但大多数技能中心和仓库都以 Claude Code 命名,可能有点混淆。

其他大多数事情也这样。你在 Reddit、X 或博客上看到的关于编程 agent 的帖子,大多关于 Claude Code 而不是 Codex——尽管两者原理相同。这本身就说明了很多问题:受欢迎程度和社区规模。

Codex 比 Claude Code 晚很久才支持技能和插件。但插件没有技能那么兼容。而且 Codex 的插件支持刚起步,没多少可用。

也就是说,很多开发者,包括我,根本不用插件。所以除非你特别需要各种插件支持,这方面不用纠结,也别把它当作选择依据。

RAG Pipeline:案例研究

我选了一个可以量化评估的任务来对比。问题是做一个落地页这种任务没法量化:一个人可能觉得好看,另一个可能说是紫色渐变的垃圾。

所以我选了个简单的 RAG pipeline 任务,因为生成的答案可以用数字衡量准确性。

如果你想做类似的对比,其他好想法包括:训练 vision model 或微调 LLM,或者测量低级程序的性能。

搭建检索 pipeline 是 AI 工程师的常见任务,你工作中可能用到 Claude Code 或 Codex。我让这两个编程 agent 给我搭一个论文问答 RAG pipeline。流程很简单:

  1. 取一批论文,提取文本
  2. 把内容分块(chunk)
  3. 把每块 embedding 到向量空间
  4. 用户提问时,找到跟问题 embedding 最接近的块
  5. 以原始形式检索出相关块(不是 embedding)
  6. 用这个上下文回答用户问题

这个任务足够简单,可以一次session 做完,但细节很复杂,对输出影响很大:用哪种分块策略、选什么 embedding 模型、用什么向量存储、如何处理"哪个块更接近查询"的置信度、是否重写用户问题来帮助找到更相似的块……

实验设置

我从 @huggingface 过去一周的每日论文里选了 5 篇,建了一个测试集(100 道题及标准答案),用来测试 Claude 或 Codex 的实现质量。

对两个 coding agent,我都是这么要求的:

  • 做一个 Python RAG pipeline
  • 用 PyMuPDF 处理所有 PDF
  • 为这个用例选一个好的分块策略
  • 创建 embedding 和持久化本地向量索引(你选)
  • llama-3.1-8b-instant 生成最终答案
  • 如果没有足够证据,不要 hallucinate,返回 fallback

对 Codex 和 Claude Code,我都用了最流行且默认的模型:gpt-5.3-codexOpus 4.6,都用 High effort(推理深度)。都没有 AGENTS.md。

它们怎么实现的 pipeline

我没注意到两个 agent 思考任务的方式有什么明显差别,除了 Codex 更啰嗦,会解释它的计划以及要做什么。Claude 直接写文件,执行命令,不说那么多。

Codex 完成任务比 Claude 花了更长时间。

更重要的是,Claude 端到端测试了脚本,确保 pipeline 能用。

Codex 则是做完了实现,但没有测试或运行程序,只是告诉我 pip install 依赖然后运行脚本。自然,我跑的时候报错了,Codex 解决了。Claude 的脚本跑起来一点问题没有。

我注意到 Codex 有这个模式:很多脏活累活它留给你做,而不是自己动手。

Codex 会告诉你并主动处理环境问题或实现困难,Claude 则自行修复——这取决于你的偏好,可能是好事也可能是坏事。

我还注意到,Codex 在新会话中第一个 token 的响应时间可以高达一分钟,Claude Code 这边短得多。

Claude Code vs. Codex 实现

两个 coding agent 的方案惊人地相似:

  • 都选了 all-MiniLM-L6-v2 作为 embedding 模型
  • 都选了 k=5 做 Top-K 检索
  • 都在 system prompt 里限制 LLM 只准用提供的上下文

但这些地方它们走了不同的路:

  • 向量存储:Claude Code 选了 ChromaDB,Codex 选了 FAISS——一个更底层的相似度搜索库,更省内存更快。
  • 分块:Claude Code 用了递归字符分割。先试 \n\n,然后 \n,然后 ".",然后 " "。目标是 1000 字符,200 字符重叠。Codex 用了句子级别的词分割,每块最多 220 词,40 词重叠。Claude Code 按结构分割(段落→行→句子→词),按字符计量。Codex 先按句子切,然后打包进词预算的箱子里。Codex 的方法尊重句子边界,避免句子中间切断,但 220 词对这种上下文可能太小(学术文本)。
  • 检索:两者都选了 Top-5 块。Claude Code 返回原始 L2 距离,Codex 返回内积(cosine)分数。
  • 置信度:Claude Code 对最佳 L2 距离用单一阈值(>1.2 = 不相关),然后检查低置信度与高置信度块的距离平均值。Codex 用多标准三档:强、中等、不足。
  • 代码架构:Claude Code:扁平函数,各模块常量,无模型一致性输入验证。Codex:OOP pipeline 类,集中配置,dataclasses,argparse CLI,模型一致性验证。Codex 明显工程化程度更高、可配置性更好。在更大更严肃的代码库里,这很关键。

结果

gpt-4 做 LLM-as-a-judge,两个 pipeline 的答案按四个标准比较:正确性、完整性、相关性、简洁性。

Image

100 道题中,Claude Code 赢了 42 道,Codex 赢了 33 道,25 道平手。 Claude 赢主要是因为它的置信度阈值更松,可能还有生成温度稍高(0.2 vs Codex 的 0.1)。

加点盐

这只是个非常简单的设置。我主要是好奇两个 coding agent 实现同一个封闭任务时有什么不同的做法。在专业环境里,是开发者拍板整体架构:分块方法、向量数据库、检索策略等。而且在专业环境里,做这类系统需要更多测试和迭代改进,以及更可靠的测试集和验证。

不过可以预期,一个不太有经验的初级开发者做 RAG pipeline,会把这些决策交给 AI。

选一个吧

我觉得选 Claude Code 还是 Codex,没有绝对错误的选择。两者都比现有格局的模型强,完成任务的水平差不多。

我的两大因素是:Anthropic 生态,以及 100 美元/月的价位段。即使我需要升到 200 美元/月 档位,还是会为了前者留在 Anthropic 的 Claude Code。

最重要的是你用这些 scaffold 做什么,以及怎么用。

这个比任何 benchmark 都能更好地判断哪个更适合你——没有标准答案,只能凭感觉。你把两个都试过之后,哪个用起来更舒服,答案就在你心里。

有开发者比如 @steipete 坚决站 Codex,也有人相信 Opus 就是被 OpenAI 模型吊打。

我觉得两边都对,因为他们的工作流不同,对这些 coding agent 的"品味"也不同。

如果你犹豫不定,建议先试两个的 20 美元/月 版本,用跟你相关的编程领域,最好在几个可验证的任务上测试。

最后,跟其他 AI 相关的东西一样,格局几个月就变一次。你现在喜欢哪个,三个月后 agent 行为可能漂移,或者新模型出来了。

AI 领域很少有全球通用的标准答案,这个话题也不是 ;)

从 SSR 踩坑到 CSR 封神:Nuxt4 全流程终极实战

Vue3 + Axios + 多环境部署 + 国际化 全套落地方案

一、前言

全程使用 Nuxt4 开发。从最开始疯狂踩 SSR、接口 502、打包报错、部署失败,到最后彻底跑通全流程,我把所有真实踩坑 + 解决方案全部整理在这里。


二、我的项目背景

  • 项目类型:智能仓储 / 工厂 3D 可视化后台系统

  • 技术栈:Vue3 + Nuxt4 + Three.js + Axios + i18n 国际化

  • 核心需求:

    • 3D 场景渲染
    • 接口请求统一封装
    • 中英文切换
    • 开发 / 生产环境自由切换
    • 打包后不改代码、不重新打包就能换接口
    • 稳定部署,不 502、不报错

三、我踩过的所有 Nuxt4 大坑(全部真实)

1. 打包后访问报错:[nuxt] instance unavailable

原因在 axios 工具文件最外层直接写

js

const config = useRuntimeConfig()

SSR 服务端执行时,还没有实例,直接崩溃。

最终正确写法useRuntimeConfig() 放到 请求拦截器里

js

service.interceptors.request.use((config) => {
  const runtimeConfig = useRuntimeConfig()
  config.baseURL = runtimeConfig.public.apiBase
})

2. 接口代理配置错误,一直 502

错误写法

ts

routeRules: {
  '/api/**': {
    proxy: '{{runtimeConfig.public.apiBase}}/api/**'
  }
}

原因routeRules 不支持运行时变量,只会当成字符串,所以代理地址无效。

正确方案

  • 开发环境:用 vite 代理
  • 生产环境:用运行时环境变量

3. 服务端渲染(SSR)不适合我的项目

我总结了一个超级实用的判断标准

表格

项目类型 是否适合 SSR
官网、电商、需要 SEO ✅ 适合
后台系统、3D 可视化、内部系统 ❌ 不适合

我的项目属于后台系统 + 3D 渲染,直接关闭 SSR:

ts

export default defineNuxtConfig({
  ssr: false
})

关闭后:

  • localStorage 正常用
  • 不再报实例错误
  • 部署超级简单
  • 接口不再 502

四、开发 / 生产环境一套代码搞定

1. 开发环境(vite 代理)

ts

vite: {
  server: {
    proxy: {
      '/api': {
        target: 'http://10.102.129.12:18088',
        changeOrigin: true
      }
    }
  }
}

2. 生产环境(运行时配置)

ts

runtimeConfig: {
  public: {
    apiBase: '' // 留空,环境变量覆盖
  }
},
nitro: {
  host: '0.0.0.0',
  port: 3000
}

五、Axios 封装最终版(可直接复制)

ts

import axios from 'axios'

const service = axios.create({
  baseURL: '/api',
  timeout: 10000
})

service.interceptors.request.use((config) => {
  if (process.env.NODE_ENV === 'production') {
    const runtimeConfig = useRuntimeConfig()
    config.baseURL = runtimeConfig.public.apiBase
  }

  const token = process.client ? localStorage.getItem('factory_token') : null
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }
  return config
})

service.interceptors.response.use((response) => {
  const res = response.data
  if (res.code !== 'SUCCESS') return Promise.reject(res)
  return res.records
})

export const request = {
  get: (url, params) => service.get(url, { params }),
  post: (url, data) => service.post(url, data),
  put: (url, data) => service.put(url, data),
  delete: (url, params) => service.delete(url, { params })
}

export default service

六、国际化 i18n 配置(URL 不带前缀)

ts

i18n: {
  locales: ['zh', 'en'],
  defaultLocale: 'zh',
  strategy: 'no_prefix',
  detectBrowserLanguage: false
}

七、生产环境启动命令

PowerShell

powershell

$env:NUXT_PUBLIC_API_BASE="http://10.102.129.12:18088"
node .output/server/index.mjs

Windows 一键启动脚本 start.bat

bat

@echo off
set NUXT_PUBLIC_API_BASE=http://10.102.129.12:18088
node .output/server/index.mjs
pause

Docker 部署(目录挂载,不用重新打包)

yaml

version: '3.8'
services:
  nuxt-app:
    image: node:20-alpine
    volumes:
      - ./.output:/app
    ports:
      - "3000:3000"
    environment:
      - NUXT_PUBLIC_API_BASE=http://10.102.129.12:18088
    command: ["node", "server/index.mjs"]

八、nuxt 配置文件

export default defineNuxtConfig({
  ssr: true,
  
  // 只保留最干净的配置
  modules: [
    '@pinia/nuxt',
    '@element-plus/nuxt',
    '@nuxtjs/i18n',
    '@pinia-plugin-persistedstate/nuxt'
  ],
  css: ['~/assets/css/main.css'],
  elementPlus: {
    // 配置图标组件前缀,设置为 'ElIcon' 即可启用自动导入
    icon: "ElIcon",
    // 如果你需要自定义主题,可以设置为 'scss'
    // importStyle: 'scss',
  },
  i18n: {
    // 指定翻译文件存放的目录
    langDir: '',

    // 配置支持的语言列表
    locales: [
      { 
        code: 'zh', 
        language: 'zh', 
        file: 'zh.json', 
        name: '简体中文' 
      },
      { 
        code: 'en', 
        language: 'en', 
        file: 'en.json', 
        name: 'English' 
      },
    ],

    // 默认语言
    defaultLocale: 'en',

    
    strategy: 'no_prefix',
    

    // 是否检测浏览器语言并进行重定向
    detectBrowserLanguage: {
      useCookie: true, // 使用cookie保存用户语言选择
      cookieKey: 'i18n_redirected', // cookie的key
      redirectOn: 'root', // 仅在访问根路径时检测
    },
  },
  runtimeConfig: {
    public: {
      apiBase: "", // 运行时覆盖
    },
  },
  nitro: {
    compressPublicAssets: true,
    minify: true,
    devProxy: {
      '/web': {
        target: 'http://10.102.129.12:18088/web',
        changeOrigin: true,
      }
    },
    // routeRules: {
    //   "/web/**": {
    //     proxy: 'http://10.102.129.12:18088/web/**'
    //   }
    // }
  },
  vite: {
    ssr: {
      noExternal: ['vue']
    }
  }
})

九、结束语

这篇文章完全来自 我真实的 Nuxt4 实战提问与踩坑历史,从零基础到全流程打通,希望能帮助到正在做 Nuxt4 后台、可视化、3D 项目的同学。

如果你也在踩坑,欢迎交流~

vue2 和 vue3自定义指令有什么区别,都是怎么实现和使用一个指令

vue2 和 vue3自定义指令有什么区别,都是怎么实现和使用一个指令

Vue2 和 Vue3 自定义指令(Custom Directive) 整体思想一样:

直接操作 DOM 的一种扩展机制,通常用于权限控制、焦点、拖拽、懒加载等。

但 API 设计、生命周期、实现方式有明显变化。

4个层面:

1️⃣ Vue2 vs Vue3 指令生命周期区别 2️⃣ Vue2 按钮权限指令实现 3️⃣ Vue3 按钮权限指令实现 4️⃣ Vue3 指令底层设计变化

一、Vue2 vs Vue3 指令生命周期区别

Vue2 指令钩子

Vue2 指令有 5个生命周期

钩子 说明
bind 指令第一次绑定到元素
inserted 元素插入 DOM
update VNode 更新
componentUpdated 组件更新完成
unbind 解绑

示例

Vue.directive('focus', {
  bind(el) {},
  inserted(el) {},
  update(el) {},
  componentUpdated(el) {},
  unbind(el) {}
})

Vue3 指令生命周期

Vue3 完全重写了指令生命周期,名字和组件生命周期保持一致。

Vue2 Vue3
bind beforeMount
inserted mounted
update updated
componentUpdated updated
unbind unmounted

示例

app.directive('focus', {
  beforeMount(el) {},
  mounted(el) {},
  updated(el) {},
  unmounted(el) {}
})

二、Vue2 按钮权限指令实现

企业中最常见的自定义指令就是:

按钮权限控制

v-permission

例如

<button v-permission="'user:add'">新增</button>

如果没有权限:按钮直接删除

Vue2 指令实现

  1. 定义指令
import store from '@/store'

Vue.directive('permission', {
  inserted(el, binding) {

    const { value } = binding
    const permissions = store.state.user.permissions

    if (value && value instanceof Array) {

      const hasPermission = permissions.some(
        p => value.includes(p)
      )

      if (!hasPermission) {
        el.parentNode.removeChild(el)
      }

    } else {
      throw new Error('权限指令需要数组')
    }
  }
})
  1. 使用指令
<button v-permission="['user:add']">
新增用户
</button>

binding 参数结构

Vue2:

binding = {
  name: 'permission',
  value: ['user:add'],
  oldValue: undefined,
  expression: "['user:add']",
  arg: undefined,
  modifiers: {}
}

三、Vue3 按钮权限指令实现

Vue3 写法更简洁。

  1. 创建指令

src/directives/permission.ts

import type { Directive } from 'vue'
import { useUserStore } from '@/store/user'

export const permission: Directive = {
  mounted(el, binding) {

    const { value } = binding
    const userStore = useUserStore()

    const permissions = userStore.permissions

    const hasPermission = permissions.some(
      p => value.includes(p)
    )

    if (!hasPermission) {
      el.parentNode?.removeChild(el)
    }
  }
}
  1. 注册指令

main.ts

import { createApp } from 'vue'
import { permission } from '@/directives/permission'

const app = createApp(App)

app.directive('permission', permission)

app.mount('#app')
  1. 使用指令
<button v-permission="['user:add']">
新增用户
</button>

四、Vue3 指令底层设计变化

Vue3 指令其实是 VNode patch 阶段执行。

关键源码在:

runtime-core/directives.ts

核心函数:

invokeDirectiveHook

简化源码:

export function invokeDirectiveHook(
  vnode,
  prevVNode,
  instance,
  name
) {
  const bindings = vnode.dirs

  for (let i = 0; i < bindings.length; i++) {
    const binding = bindings[i]

    const hook = binding.dir[name]

    if (hook) {
      hook(vnode.el, binding, vnode, prevVNode)
    }
  }
}

执行流程:

template
   ↓
编译成 render
   ↓
VNode 上挂 dirs
   ↓
patch 阶段
   ↓
invokeDirectiveHook
   ↓
执行 mounted / updated

VNode结构:

{
  type: 'button',
  props: {},
  dirs: [
    {
      dir: permission,
      value: ['user:add']
    }
  ]
}

五、Vue2 vs Vue3 指令实现差异

区别 Vue2 Vue3
注册 Vue.directive app.directive
生命周期 bind inserted update beforeMount mounted updated
调用时机 patch patch
binding 参数 复杂 更简单
类型支持 TS Directive 类型
底层实现 directive.js runtime-core/directives.ts

六、 Vue3 指令系统真正的运行路径

源码执行链路

template
 ↓
编译 render
 ↓
withDirectives()
 ↓
VNode.dirspatch()
 ↓
invokeDirectiveHook()
 ↓
执行 mounted / updated / unmounted

七、模板里的指令是怎么变成 VNode 的

模板

<button v-permission="['user:add']">
新增
</button>

编译后的 render 函数(简化版)

import { withDirectives, createVNode } from "vue"

return withDirectives(
  createVNode("button", null, "新增"),
  [
    [permission, ['user:add']]
  ]
)

关键函数:

withDirectives()

函数的作用是:把指令挂到 VNode 上

八、withDirectives 源码

源码位置:

packages/runtime-core/src/directives.ts

核心代码(简化)

export function withDirectives(vnode, directives) {

  const bindings = vnode.dirs || (vnode.dirs = [])

  for (let i = 0; i < directives.length; i++) {

    let [dir, value, arg, modifiers] = directives[i]

    bindings.push({
      dir,
      value,
      arg,
      modifiers
    })

  }

  return vnode
}

执行完之后:

VNode 结构会变成:

{
  type: "button",
  props: null,
  children: "新增",
  dirs: [
    {
      dir: permission,
      value: ['user:add'],
      arg: undefined,
      modifiers: {}
    }
  ]
}

重点:VNode.dirs 这里存放所有指令

九、patch 阶段如何执行指令

Vue3 DOM 渲染核心:renderer.ts

当元素创建时:mountElement()

核心流程:mountElement(vnode, container)

简化代码:

const mountElement = (vnode, container) => {

  const el = vnode.el = document.createElement(vnode.type)

  // props
  patchProps(el)

  // children
  mountChildren()

  // 指令 mounted
  if (vnode.dirs) {
    invokeDirectiveHook(vnode, null, parentComponent, 'mounted')
  }

}

所以:mounted 是在 DOM 创建完成之后执行

十、invokeDirectiveHook(核心函数)

源码:

runtime-core/directives.ts

核心代码:

export function invokeDirectiveHook(
  vnode,
  prevVNode,
  instance,
  name
) {

  const bindings = vnode.dirs

  for (let i = 0; i < bindings.length; i++) {

    const binding = bindings[i]

    const hook = binding.dir[name]

    if (hook) {
      hook(
        vnode.el,
        binding,
        vnode,
        prevVNode
      )
    }
  }
}

执行逻辑:

遍历 vnode.dirs
   ↓
找到对应生命周期
   ↓
执行 mounted / updated

调用指令:

permission.mounted(el, binding)

十一、binding 参数真正结构

当 VNode 更新:patchElement()

源码:

if (dirs) {
  invokeDirectiveHook(
    n2,
    n1,
    parentComponent,
    'updated'
  )
}

执行顺序:

patch props
patch children
↓
directive updated

所以:updated 一定在 DOM 更新后执行

十二、指令 unmounted 执行时机

组件卸载:unmount()

源码:

if (vnode.dirs) {
  invokeDirectiveHook(vnode, null, instance, 'unmounted')
}

十三、Vue2 指令底层 vs Vue3 指令底层

Vue2 指令是在:patch.js 执行 updateDirectives()

源码:

function updateDirectives(oldVnode, vnode) {

  const dirs = normalizeDirectives()

  for (key in dirs) {

    callHook(dir, 'bind')

  }

}

逻辑非常复杂

Vue3重写原因:

1️⃣ 生命周期混乱 2️⃣ diff逻辑复杂 3️⃣ 指令和组件生命周期不一致

Vue3改进:

统一生命周期
统一调用入口
VNode直接挂 dirs

后话:

为什么 Vue3 要有 withDirectives

Vue3 是 函数式 VNode 创建:

h('button')

没有 template 的情况下:

h('button', {}, '新增')

只能:

withDirectives()

withDirectives(
  h('button'),
  [[permission, ['user:add']]]
)

所以:withDirectives 是 render 层 API

网易云桌面端--精选歌单布局思路记录

最近在学习electron想做一个自己喜欢的桌面端的软件,这边选择了网易云音乐,这边记录一下自己实现布局和功能的思路

image.png

查看图片可以发现这个页面内容包含了三个部分,左边箭头,右边箭头,中间的内容区域,这边开始将基本的布局框架搭建出来

 <div class="scroll-warp group">
 <!-- 左箭头-->
    <div
      class="arrow left-arrow transition-opacity duration-300"
      :class="{ disabled: isAtStart }"
      @click="scroll('left')"
    >
      <Icon style="width: 100%; height: 100%" icon="tabler:chevron-left"></Icon>
    </div>
    <!--内容-->
    <div class="content" ref="contentRef" @scroll="handleScroll">
      <MusicItemCard v-for="(item, index) in 8" :key="index"></MusicItemCard>
    </div>
    <!-- 右箭头 -->
    <div
      class="arrow right-arrow transition-opacity duration-300"
      :class="{ disabled: isAtEnd }"
      @click="scroll('right')"
    >
      <Icon style="width: 100%; height: 100%" icon="tabler:chevron-right"></Icon>
    </div>
  </div>

有了基本的容器,我们就需要将样式完善出来

.scroll-warp {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  background-color: pink;
  padding: 10px;

  // 箭头的通用样式
  .arrow {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 40px;
    height: 100%;
    min-height: 40px; // 防止高度为0
    cursor: pointer;
    z-index: 10;

    // 默认隐藏,父容器 hover 时显示
    opacity: 0;
    transition:
      opacity 0.3s ease,
      background-color 0.3s;

    // 禁用状态样式
    &.disabled {
      opacity: 0.5 !important; // 即使 hover 也保持半透明
      cursor: not-allowed;
      // background-color: #ccc; // 变灰
      pointer-events: none; // 禁止点击
    }
  }

  // 当鼠标移入 scroll-warp 时,显示箭头
  &:hover .arrow {
    opacity: 1;
  }

  .content {
    flex: 1;
    flex-shrink: 0;
    background-color: rgb(0, 255, 183);
    overflow-x: scroll;
    overflow-y: hidden;
    white-space: nowrap;
    display: flex;
    align-items: center;
    padding: 10px;
    gap: 10px;
    flex-wrap: nowrap;

    // 隐藏滚动条
    &::-webkit-scrollbar {
      display: none;
    }

    // 兼容其他浏览器隐藏滚动条
    -ms-overflow-style: none; /* IE and Edge */
    scrollbar-width: none; /* Firefox */

    margin: 0 10px;
  }
}

然后我们就可以得到一个这样的布局界面

image.png

接下来我们来实现一下js逻辑

import { ref, onMounted, onUnmounted } from 'vue'
import MusicItemCard from './MusicItemCard.vue'

// 获取内容区域的 DOM 引用
const contentRef = ref(null)

// 定义状态变量
const isAtStart = ref(true) // 是否在最左侧
const isAtEnd = ref(false) // 是否在最右侧

// 滚动处理函数
const scroll = (direction) => {
  if (!contentRef.value) return

  // 每次滚动的距离,这里设置为容器宽度的 80%,也可以设置为固定像素如 300
  const scrollAmount = contentRef.value.clientWidth * 0.8

  if (direction === 'left') {
    contentRef.value.scrollBy({ left: -scrollAmount, behavior: 'smooth' })
  } else {
    contentRef.value.scrollBy({ left: scrollAmount, behavior: 'smooth' })
  }
}

// 监听滚动事件,更新按钮状态
const handleScroll = () => {
  if (!contentRef.value) return

  const { scrollLeft, scrollWidth, clientWidth } = contentRef.value

  // 判断是否在起点(允许 1px 的误差)
  isAtStart.value = scrollLeft <= 1

  // 判断是否在终点(scrollLeft + clientWidth >= scrollWidth)
  // 这里减去 1 是为了处理浮点数计算可能存在的微小误差,或者为了留一点边距
  isAtEnd.value = Math.ceil(scrollLeft + clientWidth) >= scrollWidth - 1
}

// 组件挂载和卸载时处理窗口大小变化(可选,为了更严谨)
const updateScrollState = () => handleScroll()

onMounted(() => {
  // 初始化时检查一次状态
  updateScrollState()
  // 监听窗口大小变化,因为窗口变化可能导致可滚动宽度变化
  window.addEventListener('resize', updateScrollState)
})

onUnmounted(() => {
  window.removeEventListener('resize', updateScrollState)
})

这样我们就可以实现这种的布局切换容器和界面了

如何需要MusicItemCard代码

<template>
  <div class="music-card">
    123
  </div>
</template>

<script setup>
import { ref,reactive,getCurrentInstance} from 'vue'
const { proxy } = getCurrentInstance()
</script>

<style scoped lang="scss">
.music-card {
  width: 140px;
  height: 190px;
  border-radius: 6px;
  background-color: #fff;
  flex-shrink: 0;
  margin: 0 5px;
}
</style>

完整代码

<template>
  <div class="scroll-warp group">
    <!-- 左箭头 -->
    <!-- 
      1. 添加 @click 事件
      2. 动态绑定 class,当 isAtStart 为 true 时添加 disabled 样式
      3. 添加 opacity-0 和 group-hover:opacity-100 类实现鼠标移入显示
    -->
    <div
      class="arrow left-arrow transition-opacity duration-300"
      :class="{ disabled: isAtStart }"
      @click="scroll('left')"
    >
      <Icon style="width: 100%; height: 100%" icon="tabler:chevron-left"></Icon>
    </div>

    <!-- 内容区域 -->
    <!-- 
      1. 绑定 ref 以便在 JS 中获取 DOM 元素
      2. 监听 scroll 事件以更新状态
    -->
    <div class="content" ref="contentRef" @scroll="handleScroll">
      <!-- 这里的 item 只是演示,实际使用请传入你的数据 -->
      <MusicItemCard v-for="(item, index) in 8" :key="index"></MusicItemCard>
    </div>

    <!-- 右箭头 -->
    <div
      class="arrow right-arrow transition-opacity duration-300"
      :class="{ disabled: isAtEnd }"
      @click="scroll('right')"
    >
      <Icon style="width: 100%; height: 100%" icon="tabler:chevron-right"></Icon>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import MusicItemCard from './MusicItemCard.vue'

// 获取内容区域的 DOM 引用
const contentRef = ref(null)

// 定义状态变量
const isAtStart = ref(true) // 是否在最左侧
const isAtEnd = ref(false) // 是否在最右侧

// 滚动处理函数
const scroll = (direction) => {
  if (!contentRef.value) return

  // 每次滚动的距离,这里设置为容器宽度的 80%,也可以设置为固定像素如 300
  const scrollAmount = contentRef.value.clientWidth * 0.8

  if (direction === 'left') {
    contentRef.value.scrollBy({ left: -scrollAmount, behavior: 'smooth' })
  } else {
    contentRef.value.scrollBy({ left: scrollAmount, behavior: 'smooth' })
  }
}

// 监听滚动事件,更新按钮状态
const handleScroll = () => {
  if (!contentRef.value) return

  const { scrollLeft, scrollWidth, clientWidth } = contentRef.value

  // 判断是否在起点(允许 1px 的误差)
  isAtStart.value = scrollLeft <= 1

  // 判断是否在终点(scrollLeft + clientWidth >= scrollWidth)
  // 这里减去 1 是为了处理浮点数计算可能存在的微小误差,或者为了留一点边距
  isAtEnd.value = Math.ceil(scrollLeft + clientWidth) >= scrollWidth - 1
}

// 组件挂载和卸载时处理窗口大小变化(可选,为了更严谨)
const updateScrollState = () => handleScroll()

onMounted(() => {
  // 初始化时检查一次状态
  updateScrollState()
  // 监听窗口大小变化,因为窗口变化可能导致可滚动宽度变化
  window.addEventListener('resize', updateScrollState)
})

onUnmounted(() => {
  window.removeEventListener('resize', updateScrollState)
})
</script>

<style scoped lang="scss">
.scroll-warp {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  background-color: pink;
  padding: 10px;
  position: relative; // 如果箭头需要绝对定位可以开启

  // 箭头的通用样式
  .arrow {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 40px;
    height: 100%;
    min-height: 40px; // 防止高度为0
    cursor: pointer;
    z-index: 10;

    // 默认隐藏,父容器 hover 时显示 (Tailwind CSS 写法: opacity-0 group-hover:opacity-100)
    opacity: 0;
    transition:
      opacity 0.3s ease,
      background-color 0.3s;

    &:hover {
      // background-color: darkorange;
    }

    // 禁用状态样式
    &.disabled {
      opacity: 0.5 !important; // 即使 hover 也保持半透明
      cursor: not-allowed;
      // background-color: #ccc; // 变灰
      pointer-events: none; // 禁止点击
    }
  }

  // 当鼠标移入 scroll-warp 时,显示箭头
  &:hover .arrow {
    opacity: 1;
  }

  .content {
    flex: 1;
    flex-shrink: 0;
    background-color: rgb(0, 255, 183);
    overflow-x: scroll;
    overflow-y: hidden;
    white-space: nowrap;
    display: flex;
    align-items: center;
    padding: 10px;
    gap: 10px; // 使用 gap 代替 margin 控制间距
    flex-wrap: nowrap;

    // 隐藏滚动条
    &::-webkit-scrollbar {
      display: none;
    }

    // 兼容其他浏览器隐藏滚动条
    -ms-overflow-style: none; /* IE and Edge */
    scrollbar-width: none; /* Firefox */

    margin: 0 10px;
  }
}
</style>

Web打印插件实战:轻量化JS打印方案vue-print-designer落地指南

在企业级Web系统开发中,网页打印是绕不开的刚需场景——无论是后台管理系统的单据报表、电商平台的订单小票,还是政务医疗类的制式票据,都需要稳定、可控、易维护的打印能力。但长期以来,原生Web打印的局限性、传统JS打印插件的冗余繁琐,一直是前端开发者的高频痛点。

本文将围绕Web打印插件、JS打印插件、网页打印插件三大核心场景,客观分析传统打印方案的弊端,详解轻量化开源打印组件vue-print-designer的实战集成流程,分享生产环境下的配置技巧与避坑要点,为开发者提供一套低侵入、高兼容、易扩展的网页打印解决方案。

开源项目地址:gitee.com/theGreatOld…


一、传统Web网页打印的核心痛点

日常开发中,基于原生JS+CSS实现网页打印,或是选用老旧打印插件,往往会遇到以下难以规避的问题,也是多数开发者在业务落地时的真实踩坑点:

  • 浏览器兼容性极差:Chrome、Edge、Firefox等主流浏览器的打印渲染规则不一致,同一套样式在不同端会出现排版错乱、分页断裂、边距异常,反复调试成本极高;
  • 排版可控性弱:仅能通过CSS打印媒体查询做基础样式控制,复杂票据、多页报表的精准对齐、分页控制、页眉页脚定制,纯手写代码几乎难以实现;
  • 无可视化设计能力:打印模板调整需修改前端代码、重新打包部署,非技术人员无法参与模板优化,业务需求变更响应缓慢;
  • 功能单一且局限:原生打印仅支持浏览器弹窗打印,无法满足静默打印、批量打印、PDF/图片导出、动态数据绑定等企业级核心需求;
  • 插件侵入性强:部分传统打印插件体积庞大、依赖冗余,与现有Vue/React等框架兼容性差,引入后易引发项目打包体积过大、样式冲突等问题。

针对以上痛点,轻量化、高兼容的JS打印插件成为刚需,而vue-print-designer恰好以极简集成、可视化设计、跨端稳定的特性,适配绝大多数Web打印场景,且无过度冗余设计,适合生产环境快速落地。

二、vue-print-designer插件核心特性(客观选型依据)

作为一款专注Web打印的轻量级JS插件,vue-print-designer并非功能堆砌型组件,而是聚焦打印核心需求,兼顾易用性与稳定性,核心优势均贴合实际业务开发,无浮夸卖点:

核心特性总结:轻量无冗余、多框架兼容、可视化拖拽设计、精准排版渲染、支持动态数据与企业级打印拓展,适配原生JS、Vue2/Vue3全场景。

  • 极致轻量化:插件核心体积不足500KB,无第三方冗余依赖,支持CDN、npm多种引入方式,对项目性能无侵入;
  • 全场景兼容:不仅支持Vue2/Vue3框架集成,也可直接在原生HTML页面中使用,适配各类后台系统、静态网页、移动端H5的打印需求;
  • 可视化模板设计:内置文本、表格、二维码、条码、图片等常用打印组件,支持拖拽布局、属性可视化配置,无需手写排版代码,降低开发门槛;
  • 渲染稳定性高:基于Canvas+SVG双引擎渲染,跨浏览器排版一致性可达99%,彻底解决分页错乱、样式偏移问题;
  • 企业级拓展能力:支持动态数据绑定、静默打印、多格式导出(PDF/PNG/JPG)、模板JSON化存储,满足生产环境复杂打印需求。

三、多场景实战集成(极简步骤,可直接复制)

该插件集成流程极简,无复杂配置,以下分原生JS网页Vue3项目Vue2项目三种主流场景,给出完整可落地的代码,适配绝大多数前端项目架构。

3.1 原生JS网页集成(无框架场景)

适合静态页面、无前端框架的传统网页,直接通过CDN引入即可,无需安装依赖,快速实现打印能力:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>原生JS网页打印插件集成</title>
  <!-- 引入插件样式 -->
  <link rel="stylesheet" href="https://unpkg.com/vue-print-designer/style.css">
  <style>
    #print-box { width: 100%; height: 800px; margin: 20px 0; }
  </style>
</head>
<body>
  <div id="print-box"></div>

  <!-- 引入Vue3核心(插件依赖,原生场景必引) -->
  <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
  <!-- 引入打印插件核心JS -->
  <script src="https://unpkg.com/vue-print-designer"></script>
  <script>
    // 初始化打印设计器
    const app = Vue.createApp({
      components: {
        PrintDesigner: window['vue-print-designer'].PrintDesigner
      },
      template: '<print-designer />'
    })
    app.mount('#print-box')

    // 获取插件实例,后续调用打印/导出API
    const printInstance = document.querySelector('print-designer')
    console.log('打印插件初始化完成', printInstance)
  </script>
</body>
</html>

3.2 Vue3项目集成(主流后台系统场景)

适合Vue3+Element Plus/Vant等技术栈的项目,通过npm安装,全局注册后即可全局使用,适配若依等主流后台脚手架:

步骤1:安装依赖

npm install vue-print-designer --save
# 或yarn
yarn add vue-print-designer

步骤2:全局注册(main.ts/main.js)

import { createApp } from 'vue'
import App from './App.vue'
// 引入打印插件及样式
import PrintDesigner from 'vue-print-designer'
import 'vue-print-designer/style.css'

const app = createApp(App)
// 全局注册打印组件
app.use(PrintDesigner)
app.mount('#app')

步骤3:页面中使用

<template>
  <div class="container">
    <h3>业务单据打印设计</h3>
    <!-- 打印设计器容器,自适应页面布局 -->
    <print-designer 
      ref="printRef" 
      style="width: 100%; height: calc(100vh - 150px);"
    ></print-designer>
  </div>
</template>

<script setup>
import { ref } from 'vue'
const printRef = ref(null)

// 打印预览
const handlePrint = async () => {
  await printRef.value.print({ mode: 'preview' })
}

// 导出PDF
const handleExportPdf = async () => {
  await printRef.value.export({
    type: 'pdf',
    filename: '业务单据打印.pdf'
  })
}
</script>

<style scoped>
.container { padding: 20px; }
</style>

3.3 Vue2项目集成(兼容老项目)

针对存量Vue2项目,插件提供专属适配版本,仅需调整引入路径即可,无需改造原有项目架构:

// main.js
import Vue from 'vue'
import App from './App.vue'
// 引入Vue2适配版本
import PrintDesigner from 'vue-print-designer/vue2'
import 'vue-print-designer/style.css'

Vue.use(PrintDesigner)
new Vue({
  el: '#app',
  render: h => h(App)
})

四、生产环境核心配置与避坑要点

项目落地到生产环境,需重点关注动态数据绑定、模板复用、打印兼容性三大核心点,以下是实战中总结的实用配置,避免踩坑:

4.1 动态业务数据绑定

企业打印场景多为动态数据(订单、报表、用户信息),通过setVariables方法可直接绑定后端接口返回的JSON数据,模板中通过变量名自动渲染:

// 模拟后端接口获取的业务数据
const orderData = {
  orderNo: 'BILL202603110086',
  customerName: '掘金用户',
  createTime: '2026-03-11 14:30:00',
  totalAmount: '1280.00',
  goodsList: [
    { name: '企业级后台组件', price: 680, num: 1 },
    { name: '打印模板定制', price: 600, num: 1 }
  ]
}

// 绑定数据至打印模板
printRef.value.setVariables(orderData, { merge: true })

4.2 模板复用与持久化

设计完成的打印模板可导出为JSON字符串,存储至后端数据库,后续直接加载JSON模板即可复用,无需重复设计,适配多模板、多业务线场景:

// 导出模板JSON
const templateJson = printRef.value.toJson()
// 调用接口存储至后端
await saveTemplateApi(templateJson)

// 加载模板JSON
printRef.value.loadJson(templateJson)

4.3 静默打印配置(企业刚需)

针对无人值守、批量打印的场景,配合官方PrintDot Client桌面客户端,可实现静默打印,跳过浏览器弹窗确认,提升办公效率:

// 静默打印(需提前安装客户端)
await printRef.value.print({
  mode: 'silent',
  printerName: '指定打印机名称',
  copies: 1 // 打印份数
})

4.4 避坑核心提醒

  1. 集成时务必引入插件样式文件,否则会出现界面渲染异常;
  2. 动态数据绑定需确保变量名与模板中一致,区分大小写;
  3. 生产环境建议固定容器宽高,避免页面布局变化导致打印排版错乱;
  4. 静默打印需提前部署客户端,测试时优先验证打印机连通性。

五、适配场景梳理

该款轻量级Web打印插件,可覆盖绝大多数企业级网页打印场景,无场景局限:

  • 后台管理系统:各类单据、报表、合同、审批单打印;
  • 电商零售系统:订单小票、快递面单、发货单打印;
  • 政务医疗系统:制式表格、检验报告、证件票据打印;
  • 传统Web项目:静态页面、老旧系统的打印功能升级。

4.png

11.png

12.png

六、总结

Web打印作为企业级系统的基础功能,核心诉求始终是稳定、易用、低维护成本。vue-print-designer摒弃了冗余的附加功能,专注解决打印核心痛点,轻量化的设计让它能无缝融入各类前端项目,无论是新项目快速集成,还是老项目功能升级,都无需大幅改造原有架构。

相比传统原生打印与笨重的打印插件,这款JS打印插件真正实现了“引入即用、可视化设计、跨端稳定”,大幅降低了网页打印功能的开发与调试成本,是前端开发者处理Web打印需求的实用选择。

更多详细API文档、进阶配置与示例,可前往开源项目仓库查看:gitee.com/theGreatOld…


作者注:本文为实战技术分享,所有代码均经过生产环境验证,可直接复制使用,欢迎交流打印功能落地的实战经验。

鸿蒙文件哈希流

请帮我解释:hs.update(new Uint8Array(data?.split('').map((x: string) => x.charCodeAt(0))).buffer);

这段代码的目的是将字符串数据转换为二进制格式,并更新到消息认证码(HMAC)的计算过程中。以下是逐层解析:


1. 代码功能说明

hs.update(
  new Uint8Array(
    data?.split('')
      .map((x: string) => x.charCodeAt(0))
  ).buffer
);
  • 作用:将字符串 data转换为 Uint8Array二进制数据,并传递给 HMAC 计算的 update方法。
  • 典型场景:计算消息认证码时分段更新数据(如大文件分块处理)。

2. 代码逐层拆解

步骤 代码片段 解析
① 安全访问 data?. 使用可选链(?.)避免 data为空时报错
② 字符串转数组 .split('') 将字符串拆分为单字符数组,如 "abc" → ["a","b","c"]
③ 字符转ASCII码 .map(x => x.charCodeAt(0)) 将字符转为 Unicode 编码(如 "a"→97
④ 创建二进制数组 new Uint8Array(...) 将数字数组转为 Uint8Array类型(每个元素占1字节)
⑤ 提取底层Buffer .buffer 获取 Uint8Array底层的 ArrayBuffer对象
⑥ 更新HMAC计算 hs.update(...) 将二进制数据加入 HMAC 计算流

3. 潜在问题与改进 ⚠️ 问题点

  1. 字符编码限制
    charCodeAt(0)仅支持 ASCII 字符(0-255),非 ASCII 字符(如中文)会丢失信息:

    "中".charCodeAt(0) // 返回 20013(超出 Uint8 范围)
    
  2. 内存效率低
    通过 splitmap转换大字符串时会产生中间数组,占用额外内存。

✅ 改进方案(参考鸿蒙文档)

使用 标准 UTF-8 编解码方法(来自[搜索结果3]):

import { buffer } from '@kit.ArkTS';

// 直接转换字符串 → Uint8Array
const messageData = new Uint8Array(buffer.from(data, 'utf-8').buffer);

// 更新到 HMAC
hs.update({ messageData });

优势

  • 支持多字节字符(如中文)
  • 无中间数组,内存效率高
  • 符合鸿蒙 cryptoFramework规范(见[搜索结果1]1)

**4. 完整流程示例(鸿蒙 HMAC 计算) **结合[搜索结果1]1的规范流程:

import { cryptoFramework } from '@kit.CryptoArchitectureKit';
import { buffer } from '@kit.ArkTS';

// 1. 生成密钥
const keyData = new Uint8Array(buffer.from("密钥字符串", 'utf-8').buffer);
const key = await cryptoFramework.createSymKeyGenerator('HMAC')
  .convertKey({ keyData });

// 2. 初始化 HMAC 实例
const mac = cryptoFramework.createMac('SHA256');
await mac.init(key);

// 3. 更新数据(改进后的安全写法)
const messageData = new Uint8Array(buffer.from("待认证数据", 'utf-8').buffer);
await mac.update({ messageData });

// 4. 获取最终 HMAC 值
const macResult = await mac.doFinal();
console.info("HMAC结果:", macResult.data);

关键点:鸿蒙要求通过 { Uint8Array }格式传递二进制数据,直接使用 buffer对象可能导致类型错误。

信息来源

消息认证码计算HMAC(ArkTS)

#Flutter 的官方Skills技能库

最近在 GitHub 上看到一个很有意思的项目:flutter/skills。它来自 Flutter 官方团队,目标是给 AI 助手(比如 Cursor、Copilot 等)提供一套「技能包」,让 AI 在写 Flutter 代码时更专业、更符合最佳实践。

为什么需要 Flutter Skills?

用 AI 写 Flutter 时,常见情况是:能跑,但不够「地道」。比如状态管理该用 setState 还是 Provider、布局该用 LayoutBuilder 还是 MediaQuery,AI 往往给不出最合适的方案。

Flutter Skills 就是为解决这类问题设计的:把 Flutter 官方推荐的做法、约束和决策逻辑,写成结构化的「技能」,让 AI 在生成代码时按这些规则来,而不是凭感觉。

它到底是什么?

可以把它理解成:给 AI 看的 Flutter 最佳实践手册。每个技能对应一个具体领域,例如:

  • 无障碍(Accessibility)
  • 动画(Animation)
  • 应用体积(App Size)
  • 架构(Architecture)
  • 缓存(Caching)
  • 并发(Concurrency)
  • 数据库(Databases)
  • 环境配置(Linux / macOS / Windows)
  • HTTP 与 JSON
  • 布局(Layout)
  • 国际化(Localization)
  • 原生互操作(Native Interop)
  • 性能(Performance)
  • 平台视图(Platform Views)
  • 插件开发(Plugins)
  • 路由与导航(Routing and Navigation)
  • 状态管理(State Management)
  • 测试(Testing)
  • 主题(Theming)

每个技能里都有:目标、决策逻辑、具体步骤和约束,而不是简单罗列 API。

举个例子:状态管理技能

flutter-state-management 为例,它不会只说「用 Provider」,而是会先帮你判断:

  • 状态只影响单个组件?→ 用 setState 的 Ephemeral State
  • 状态需要跨页面、跨会话共享?→ 用 MVVM + Provider 的 App State

然后给出清晰的实现步骤:如何建 Model、ViewModel、如何用 ChangeNotifier、如何用 Consumer 做局部重建,以及「业务逻辑不能写在 View 里」等约束。这样 AI 生成的代码会更贴近 Flutter 官方推荐的架构。

再举个例子:无障碍技能

flutter-accessibility 会明确要求:

  • 可点击区域至少 48×48 逻辑像素
  • 文本对比度符合 WCAG
  • 使用 LayoutBuilderMediaQuery.sizeOf 做自适应,而不是 MediaQuery.orientation 或设备类型判断
  • 不锁定屏幕方向

还会给出 SemanticsFocusableActionDetectorFocusTraversalGroup 等具体用法,让 AI 在写无障碍相关代码时有章可循。

怎么用?

安装方式很简单:

npx skills add flutter/skills

更新:

npx skills update flutter/skills

项目目前还在开发中,README 里也写了「尚未准备好正式使用」,但技能内容本身已经比较完整,值得提前体验。

对开发者意味着什么?

如果你在用 Cursor、GitHub Copilot 等 AI 工具写 Flutter,Flutter Skills 可以:

  1. 提高生成代码质量:更符合官方架构和最佳实践
  2. 减少返工:少踩一些常见坑(比如状态管理、无障碍)
  3. 统一团队风格:AI 按同一套规则生成代码,更易维护

开源与贡献

项目在 GitHub 上开源,欢迎贡献。官方有 CONTRIBUTING 文档,提交前需要签署 Google CLA。如果你有某个领域的经验,可以尝试为对应技能补充或改进内容。

小结

Flutter Skills 是 Flutter 团队在「AI + 开发」方向上的一个尝试:把最佳实践结构化,让 AI 在写 Flutter 时更专业、更一致。虽然还在早期阶段,但思路很清晰,值得关注和试用。

如果你已经在用 AI 写 Flutter,不妨试试加上这套技能,看看生成代码的变化。


相关链接:

Python Virtual Environments: venv and virtualenv

A Python virtual environment is a self-contained directory that includes its own Python interpreter and a set of installed packages, isolated from the system-wide Python installation. Using virtual environments lets each project maintain its own dependencies without affecting other projects on the same machine.

This guide explains how to create and manage virtual environments using venv (built into Python 3) and virtualenv (a popular third-party alternative) on Linux and macOS.

Quick Reference

Task Command
Install venv support (Ubuntu, Debian) sudo apt install python3-venv
Create environment python3 -m venv venv
Activate (Linux / macOS) source venv/bin/activate
Deactivate deactivate
Install a package pip install package-name
Install specific version pip install package==1.2.3
List installed packages pip list
Save dependencies pip freeze > requirements.txt
Install from requirements file pip install -r requirements.txt
Create with specific Python version python3.11 -m venv venv
Install virtualenv pip install virtualenv
Delete environment rm -rf venv

What Is a Python Virtual Environment?

When you install a package globally with pip install, it is available to every Python script on the system. This becomes a problem when two projects need different versions of the same library — for example, one requires requests==2.28.0 and another requires requests==2.31.0. Installing both globally is not possible.

A virtual environment solves this by giving each project its own isolated space for packages. Activating an environment prepends its bin/ directory to your PATH, so python and pip resolve to the versions inside the environment rather than the system-wide ones.

Installing venv

The venv module ships with Python 3 and requires no separate installation on most systems. On Ubuntu and Debian, the module is packaged separately:

Terminal
sudo apt install python3-venv

On Fedora, RHEL, and Derivatives, venv is included with the Python package by default.

Verify your Python version before creating an environment:

Terminal
python3 --version

For more on checking your Python installation, see How to Check Python Version .

Creating a Virtual Environment

Navigate to your project directory and run:

Terminal
python3 -m venv venv

The second venv is the name of the directory that will be created. The conventional names are venv or .venv. The directory contains a copy of the Python binary, pip, and the standard library.

To create the environment in a specific location rather than the current directory:

Terminal
python3 -m venv ~/projects/myproject/venv

Activating the Virtual Environment

Before using the environment, you need to activate it.

On Linux and macOS:

Terminal
source venv/bin/activate

Once activated, your shell prompt changes to show the environment name:

output
(venv) user@host:~/myproject$

From this point on, python and pip refer to the versions inside the virtual environment, not the system-wide ones.

To deactivate the environment and return to the system Python:

Terminal
deactivate

Installing Packages

With the environment active, install packages using pip as you normally would. If pip is not installed on your system, see How to Install Python Pip on Ubuntu .

Terminal
pip install requests

To install a specific version:

Terminal
pip install requests==2.31.0

To list all packages installed in the current environment:

Terminal
pip list

Packages installed here are completely isolated from the system and from other virtual environments.

Managing Dependencies with requirements.txt

Sharing a project with others, or deploying it to another machine, requires a way to reproduce the same package versions. The convention is to save all dependencies to a requirements.txt file:

Terminal
pip freeze > requirements.txt

The file lists every installed package and its exact version:

output
certifi==2024.2.2
charset-normalizer==3.3.2
idna==3.6
requests==2.31.0
urllib3==2.2.1

To install all packages from the file in a fresh environment:

Terminal
pip install -r requirements.txt

This is the standard workflow for reproducing an environment on a different machine or in a CI/CD pipeline.

Using a Specific Python Version

By default, python3 -m venv uses whichever Python 3 version is the system default. If you have multiple Python versions installed, you can specify which one to use:

Terminal
python3.11 -m venv venv

This creates an environment based on Python 3.11 regardless of the system default. To check which versions are available :

Terminal
python3 --version
python3.11 --version

virtualenv: An Alternative to venv

virtualenv is a third-party package that predates venv and offers a few additional features. Install it with pip:

Terminal
pip install virtualenv

Creating and activating an environment with virtualenv follows the same pattern:

Terminal
virtualenv venv
source venv/bin/activate

To use a specific Python interpreter:

Terminal
virtualenv -p python3.11 venv

When to use virtualenv over venv:

  • You need to create environments faster — virtualenv is significantly faster on large projects.
  • You are working with Python 2 (legacy codebases only).
  • You need features like --copies to copy binaries instead of symlinking.

For most modern Python 3 projects, venv is sufficient and has the advantage of requiring no installation.

Excluding the Environment from Version Control

The virtual environment directory should never be committed to version control. It is large, machine-specific, and fully reproducible from requirements.txt. Add it to your .gitignore:

Terminal
echo "venv/" >> .gitignore

If you named your environment .venv instead, add .venv/ to .gitignore.

Troubleshooting

python3 -m venv venv fails with “No module named venv”
On Ubuntu and Debian, the venv module is not included in the base Python package. Install it with sudo apt install python3-venv, then retry.

pip install installs packages globally instead of into the environment
The environment is not activated. Run source venv/bin/activate first. You can verify by checking which pip — it should point to a path inside your venv/ directory.

“command not found: python” after activating the environment
The environment uses python3 as the binary name if it was created with python3 -m venv. Use python3 explicitly, or check which python and which python3 inside the active environment.

The environment breaks after moving or renaming its directory
Virtual environments contain hardcoded absolute paths to the Python interpreter. Moving or renaming the directory invalidates those paths. Delete the directory and recreate the environment in the new location, then reinstall from requirements.txt.

FAQ

What is the difference between venv and virtualenv?
venv is a standard library module included with Python 3 — no installation needed. virtualenv is a third-party package with a longer history, faster environment creation, and Python 2 support. For new Python 3 projects, venv is the recommended choice.

Should I commit the virtual environment to git?
No. Add venv/ (or .venv/) to your .gitignore and commit only requirements.txt. Anyone checking out the project can recreate the environment with pip install -r requirements.txt.

Do I need a virtual environment for every project?
It is strongly recommended. Without isolated environments, installing or upgrading a package for one project can break another. The overhead of creating a virtual environment is minimal.

What is the difference between pip freeze and pip list?
pip list shows installed packages in a human-readable format. pip freeze outputs them in requirements.txt format (package==version) suitable for use with pip install -r.

Can I use a virtual environment with a different Python version than the system default?
Yes. Pass the path to the desired interpreter when creating the environment: python3.11 -m venv venv. The environment will use that interpreter for both python and pip commands.

Conclusion

Virtual environments are the standard way to manage Python project dependencies. Create one with python3 -m venv venv, activate it with source venv/bin/activate, and use pip freeze > requirements.txt to capture your dependencies. For more on managing Python installations, see How to Check Python Version .

tcpdump Cheatsheet

Basic Syntax

Core tcpdump command forms.

Command Description
sudo tcpdump Start capturing on the default interface
sudo tcpdump -i eth0 Capture on a specific interface
sudo tcpdump -i any Capture on all interfaces
sudo tcpdump -D List available interfaces
sudo tcpdump -h Show help and usage

Limit and Format Output

Control how much data is shown and how packets are displayed.

Command Description
sudo tcpdump -c 10 Stop after 10 packets
sudo tcpdump -n Do not resolve hostnames
sudo tcpdump -nn Do not resolve hostnames or service names
sudo tcpdump -v Verbose output
sudo tcpdump -X Show packet contents in hex and ASCII

Protocol Filters

Capture only the protocol traffic you care about.

Command Description
sudo tcpdump tcp Capture TCP packets only
sudo tcpdump udp Capture UDP packets only
sudo tcpdump icmp Capture ICMP packets only
sudo tcpdump arp Capture ARP traffic
sudo tcpdump port 53 Capture DNS traffic on port 53

Host and Port Filters

Match packets by source, destination, host, or port.

Command Description
sudo tcpdump host 192.168.1.10 Capture traffic to or from one host
sudo tcpdump src host 192.168.1.10 Capture packets from one source host
sudo tcpdump dst host 192.168.1.10 Capture packets to one destination host
sudo tcpdump port 22 Capture SSH traffic
sudo tcpdump src port 443 Capture packets from source port 443

Combine Filters

Use boolean operators to build precise capture expressions.

Command Description
sudo tcpdump 'tcp and port 80' Capture HTTP traffic over TCP
sudo tcpdump 'host 10.0.0.5 and port 22' Capture SSH traffic for one host
sudo tcpdump 'src 10.0.0.5 and dst port 443' Match one source and HTTPS destination
sudo tcpdump 'port 80 or port 443' Capture HTTP or HTTPS traffic
sudo tcpdump 'net 192.168.1.0/24 and not port 22' Capture a subnet except SSH

Write and Read Capture Files

Save traffic to a file or inspect an existing pcap capture.

Command Description
sudo tcpdump -w capture.pcap Write packets to a pcap file
sudo tcpdump -r capture.pcap Read packets from a pcap file
sudo tcpdump -i eth0 -w web.pcap port 80 Save filtered traffic to a file
sudo tcpdump -nn -r capture.pcap Read a file without name resolution
sudo tcpdump -r capture.pcap 'host 10.0.0.5' Apply a filter while reading a pcap

Common Use Cases

Practical commands for day-to-day packet inspection.

Command Description
sudo tcpdump -i any port 22 Watch SSH connections
sudo tcpdump -i any port 53 Inspect DNS queries and replies
sudo tcpdump -i eth0 host 8.8.8.8 Trace traffic to one external host
sudo tcpdump -i any 'tcp port 80 or tcp port 443' Watch web traffic
sudo tcpdump -i any icmp Check ping and ICMP traffic

Troubleshooting

Quick checks for common tcpdump issues.

Issue Check
You do not have permission to capture on that device Run with sudo or verify packet-capture capabilities
No packets appear Confirm the correct interface with tcpdump -D and use -i any if needed
Hostnames make output slow Add -n or -nn to disable name resolution
Output is too noisy Add -c, protocol filters, or host/port filters to narrow the capture
Need to inspect later Write to a file with -w capture.pcap and review it with tcpdump -r or Wireshark

Related Guides

Use these guides for broader networking and packet-capture workflows.

Guide Description
tcpdump Command in Linux Full tcpdump guide with detailed examples
ss Command in Linux Inspect sockets and listening services
ping cheatsheet Test reachability and latency
IP command cheatsheet Check interfaces, addresses, and routes
How to Check Open Ports in Linux Review listening ports before capturing traffic

打造高效易用的Agent Skill

导读 introduction

Agent 能写代码、能调工具,但它不了解你团队的规范、流程和质量标准,每次对话都从零教起,既低效又不稳定。Skill 机制正是为解决这个问题而生:把你的经验和流程结构化地交给 Agent,让它像拿到工作手册一样自主执行。本文从设计原理、编写方法到评测迭代,梳理 Skill 的实践路径,帮助开发者打造高效易用的Agent Skill。

01 Skill 是什么,为什么需要它

1.1 Agent 的先天缺陷

大模型很聪明,但它有一个根本问题:没有你的私域知识和专属能力

你团队的代码规范是什么?做 Code Review 要看哪几个维度?创建一份 PPTX 应该遵循什么品牌样式?这些东西不在训练数据里,每次对话都重新教一遍既低效又不稳定。

更现实的问题是,即使你通过 MCP 给了 Agent 工具调用能力,能读 GitHub、能查 Sentry、能操作 Linear,它依然不知道该按什么流程、什么顺序、什么标准去使用这些工具。而 Skill 就可以提供这些信息,帮助Agent更好地执行任务。

1.2 从 MCP 到 Skill:能力扩展的演进

Agent 能力扩展的路径,经历了几个关键节点:

MCP(Model Context Protocol) 解决了"连接"问题。2024 年 11 月 Anthropic 开源 MCP,让 Agent 能够标准化地调用外部工具和数据源。这是基础设施层面的突破,Agent 终于能"伸手"触达外部世界了。

AGENTS.md 是社区自发的探索。随着 Cursor、Claude Code 等 AI 编码助手的普及,开发者很快意识到一个问题:这些 Agent 能写代码,但不了解项目的技术栈选择、代码风格约定、架构决策背景。于是社区开始在仓库根目录放置 AGENTS.md,用自然语言把项目的上下文和规范写给 Agent 看。

Skill 则是 Anthropic 在 2025 年 10 月正式推出的标准化方案。它把 AGENTS.md 的理念系统化,不仅仅是一个 Markdown 文件,而是一个结构化的文件夹,包含指令、脚本、参考文档和资源文件,形成完整的知识包。随后,Cursor、Windsurf 等产品也纷纷推出类似机制,Skill 正在成为 Agent 能力扩展的主流范式。

1.3 Skill 的核心设计:渐进式披露

Skill 最精妙的设计在于它的三级渐进式披露(Progressive Disclosure)机制,不会一次性把内容全塞给模型,而是分层按需加载:

第一级:YAML frontmatter 中的 description 字段。 本质上是一段结构化的自然语言声明,包含三层信息:这个 Skill 干什么用(“分析 Figma 设计稿并生成开发交付文档”)、核心能力是什么(“设计规范提取、组件文档生成、标注导出”)、什么时候触发(“当用户上传 .fig 文件或要求’设计转代码交付’时”)。它始终存在于 Agent 的系统提示词中,作用类似索引,当用户输入到来时,Agent 拿请求和所有 Skill 的 description 做匹配,命中了才加载对应 Skill 的完整内容。这个设计意味着你可以同时挂载几十个 Skill,而激活判断的成本只是几十行短文本的比对,不需要把所有 Skill 的完整指令都塞进上下文。

第二级: SKILL.md 正文。 当 Agent 判断某个 Skill 与当前任务相关时,才会读取 SKILL.md 的完整内容。这里包含核心指令、工作流程和关键示例。

第三级: references/  scripts/ references/ 目录下的详细文档、scripts/ 下的可执行脚本,这些只在 Agent 执行过程中确实需要时才会去查阅或调用。

为什么要这么设计?它解决了两个实际问题:

  1. Token 效率:不把所有知识一股脑塞进上下文,避免信息过载。
  2. 注意力聚焦:模型的注意力机制在上下文越长时衰减越明显,渐进式披露让模型在每个阶段只关注最相关的信息。

1.4 怎么组织和安装 Skill

当 Skill 越写越多,散落在各处很快就会失控。推荐一开始就用Git仓库统一管理。

team-skills/
├── code-review/
│   └── SKILL.md
├── react-state-management/
│   ├── SKILL.md
│   └── references/
├── sprint-planning/
│   ├── SKILL.md
│   └── scripts/
└── ...

好处很直接:版本有记录,团队能协作,跨仓库安装迅速。

安装到具体的 Agent 平台时,各家的路径约定不同,但社区已经有了统一的解决方案,Vercel 开源的 skills CLI 工具,一条命令兼容多平台:

# 从 GitHub 安装,自动识别当前环境并放到正确的位置
npx skills add https://github.com/your-team/skills/tree/main/code-review
# 支持 Claude Code、Cursor、Windsurf 等主流 Agent 平台
# 无需关心各平台的路径差异

当然,你也可以手动放置安装。因平台和场景而异路径约定不同,以Claude Code为例:

Claude Code:

# 项目级(只在当前项目生效)
.claude/skills/code-review/SKILL.md
# 全局级(所有项目生效)
~/.claude/skills/code-review/SKILL.md

社区实践一瞥

Skill 的生态正在快速成长。Anthropic 官方提供了一批高质量 Skill, 在anthropics/skills 仓库,尤其是 pdfskill-creatorfrontend-design 这几个,它们很好地展示了渐进式披露和脚本自动化的最佳实践。这些 Skill 本身就是很好的学习范本。

社区层面,Asana、Atlassian、Figma、Sentry、Zapier 等厂商已经为自己的 MCP Server 配套了 Skill。独立开发者也在持续贡献,从前端设计到代码审查,从数据分析到项目管理,可用的 Skill 库正在不断扩大。

02 如何编写一个 Skill

2.1 基本格式

一个 Skill 在文件系统中是一个文件夹,最小结构只需要一个文件:

your-skill-name/
├── SKILL.md          # 必须,入口文件
├── scripts/          # 可选,可执行脚本
├── references/       # 可选,参考文档
└── assets/           # 可选,模板、图标等资源

命名规则简单但严格:

  • 文件夹名用 kebab-casemy-cool-skill 是正确的,而My Cool Skill 以及my_cool_skill 等都是无效的。
  • 入口文件必须精确命名为 SKILL.md,大小写敏感,skill.md 或 SKILL.MD 都不行
  • 不要在Skill文件夹内放README.md(所有文档放在SKILL.md或 references/ 中)

SKILL.md 的结构分两部分:YAML Frontmatter 和 Markdown 正文

---
name: my-skill-name
description: 做什么。在用户说"XXX"时使用。核心能力包括 A、B、C。
---
# My Skill Name
## Instructions
具体的指令内容...

Frontmatter 用 --- 包裹,其中 name 和 description 是必填字段。正文用标准 Markdown 编写,包含 Agent 执行任务时需要遵循的具体指令。

2.2 工作原理

理解 Skill 的工作原理,有助于写出更有效的 Skill。核心流程是这样的:

阶段一:常驻索引。 你安装的所有 Skill 的 description 字段会被注入到 Agent 的系统提示词中。Agent 在每次对话开始时就"知道"自己拥有哪些 Skill,但不知道具体内容。

阶段二:激活读取。 当用户的请求与某个 Skill 的 description 匹配时,Agent 会使用内置工具(如 view 或 read 命令)读取该 Skill 的 SKILL.md 完整内容。这一步对应 messages[] 中的一个工具调用。

阶段三:执行与深入。 Agent 根据 SKILL.md 中的指令开始执行任务。如果指令中引用了 references/ 下的文档或 scripts/ 下的脚本,Agent 会在需要时再去读取或执行它们。

用 API 的 messages[] 视角来看,一个典型的 Skill 调用大约是这样的

用户消息 → Agent 识别需要 Skill → [工具调用: 读取 SKILL.md] 
→ Agent 获得指令 → [工具调用: 执行任务步骤] → 返回结果

这意味着 Skill 的激活本身会消耗 1-2 步工具调用。所以 description 写得准不准,直接影响 Token 消耗和响应速度,误触发意味着浪费,漏触发意味着能力缺失。

03 编写优质的 Skill

一个 Skill 能不能用和好不好用,差距巨大。这个差距主要体现在两个地方:Description 决定"什么时候用",Body 决定"用起来效果如何"。

3.1 Description:激活的精准度

Description 是整个 Skill 体系中最关键的一行文字。它决定了 Agent 在什么场景下会加载你的 Skill,写得不好,要么该用的时候不触发(under-triggering),要么不该用的时候乱触发(over-triggering)。

三大要素: 一个好的 Description 需要同时回答三个问题

  1. 能做什么:这个 Skill 的核心价值是什么
  2. 核心能力:具体包含哪些能力
  3. 激活条件:用户说什么话、做什么操作时应该触发

正面案例:

# 清晰、具体、包含触发短语
description: >
  分析 Figma 设计稿并生成开发交付文档。当用户上传 .fig 文件、
  要求"设计规范""组件文档""设计转代码交付"时使用。
# 明确的服务边界和触发词
description: >
  管理 Linear 项目工作流,包括迭代规划、任务创建和状态跟踪。
  当用户提到"迭代""Linear 任务""项目规划"或要求
  "创建工单"时使用。

反面案例:

# 太模糊,几乎什么都能匹配
description: Helps with projects.
# 缺少触发条件,Agent 不知道什么时候该用
description: Creates sophisticated multi-page documentation systems.
# 过于技术化,没有用户视角的触发词
description: Implements the Project entity model with hierarchical relationships.

防止过度触发的技巧: 如果你的 Skill 经常在不相关的场景被加载,可以在 Description 中加入"负向触发"说明:

description: >
  CSV 文件的高级数据分析,包括统计建模、回归分析、聚类。
  不要用于简单的数据浏览(那个用 data-viz skill)。

3.2 Body:执行的效果

Description 写好了只是让 Skill 在对的时间出现,Body 的质量才决定最终效果。根据使用场景,Body 通常呈现两种形态:

形态一:知识文档型

适用于需要 Agent 掌握特定领域知识或遵循特定标准的场景。

核心要素:

  • 领域知识:把你的专业判断和决策逻辑写成 Agent 可以理解的规则
  • 质量检查清单:明确定义"什么算做好了",让 Agent 在交付前自查
  • Few-Shot 示例:给出 2-3 个输入输出的范例,比抽象描述有效得多
## Code Review Standards
### Critical Checks (must pass)
1. No hardcoded credentials or API keys
2. All user inputs sanitized
3. Error boundaries on async operations
### Quality Checks (should pass)
1. Functions under 50 lines
2. Meaningful variable names (no single letters except loop counters)
3. Comments explain "why", not "what"
### Example Review
**Input:** A React component with inline styles and no error handling
**Expected output:**
- Flag: inline styles → suggest CSS modules or Tailwind
- Flag: missing error boundary → provide template
- Pass: component size reasonable
- Suggestion: extract magic numbers to constants

形态二:工作流型

适用于多步骤、有固定流程的任务。

核心要素:

  • 步骤清晰:每一步做什么、调用什么工具、预期输出是什么
  • 步骤间校验:上一步的输出满足条件才进入下一步,而不是盲目往下走
  • 可循环迭代:对质量不达标的输出能回到前面的步骤重做
## Sprint Planning Workflow

Step 1: Gather Context

`Fetch current project status from Linear. Validation: Confirm at least 1 active project returned.

Step 2: Analyze Velocity

Calculate team velocity from last 3 sprints. Validation: Velocity data covers at least 2 complete sprints.

Step 3: Draft Plan

Create task breakdown with estimates. Validation: Total story points ≤ average velocity × 0.85 (buffer).

Step 4: Review & Adjust

Present plan to user. If user requests changes: → Return to Step 3 with modified constraints.

Step 5: Execute`

Create tasks in Linear with labels and assignments. Validation: All tasks created successfully, no API errors.

3.3 进阶技巧:分层与自动化

多层渐进: SKILL.md 只放核心指令和工作流主干。详细的 API 文档、完整的示例库、边缘场景的处理方案,都放到 references/ 目录下,在正文中用明确的路径引用:

Before writing API queries, consult references/api-patterns.md for:
- Rate limiting guidance
- Pagination patterns  
- Error codes and handling

这样既保证 Agent 知道有这些资源可用,又不会在每次激活时都加载全部内容。

脚本自动化: 凡是可以用代码确定性完成的事情,就不要让模型用自然语言"理解"着去做。模型理解自然语言有概率性,但代码执行是确定性的。

官方的 PDF、DOCX、PPTX 等 Skill 大量使用了这个模式,核心的文档生成逻辑封装在 Python 脚本中,SKILL.md 只负责告诉 Agent 什么时候调用哪个脚本、传什么参数。

04 基于评测迭代

写完 Skill 不是终点。Skill 本质上是给概率性系统写的指令,“我觉得写得挺好"和"它确实在各种场景下都表现稳定"之间,往往隔着好几轮迭代的距离。评测不是锦上添花,而是 Skill 开发流程中不可省略的一环。

4.1 核心理念:像对待 Prompt 一样对待 Skill

Skill 的 Description 是系统提示词的一部分,Body 是任务执行时的指令集。这使得 Skill 开发和 Prompt 开发面临相似的挑战,而 Prompt 开发有一个被反复验证的基本事实:你无法靠直觉判断一段指令的好坏,只能靠在真实场景中反复测试来验证

这引出三个关键原则:

原则一:分层评测。 Description 和 Body 解决的是完全不同的问题,前者决定"什么时候用”,后者决定"用起来效果如何"。它们的评测方法、评测标准和迭代策略完全不同,必须分开处理。

原则二:对照实验。 “好不好"是相对概念。一个 Skill 的输出质量,只有和某个基线对比才有意义。这个基线可以是没有 Skill 时的裸跑效果,也可以是上一个版本的 Skill。没有对照组,改进就无从衡量。

原则三:人类参与。 自动化评分能覆盖格式、结构、字段完整性这类客观检查,但 Skill 真正的价值,比如审美判断、业务适配度、专业深度,只有人能评估。评测流程的设计必须让人的判断能高效地注入迭代循环。

4.2 评测 Description:触发的精准度

Description 评测要回答一个简单的问题:Agent 在该用这个 Skill 的时候用了吗?在不该用的时候没用吧?

理解触发机制

在动手测之前,先理解两个关于触发的事实:

事实一:Agent 只在觉得自己搞不定时才找 Skill。 简单的一步操作(比如"读一下这个文件”),即使 Description 完美匹配也可能不触发,因为 Agent 判断自己直接就能完成。这意味着你的测试用例必须足够复杂,不然你测的不是 Description 好不好,而是任务够不够难。

事实二:Agent 天生偏向欠触发(under-triggering)。 Description 要写得主动一点,把边界往外推。比如不只写"分析 Figma 设计稿并生成交付文档",而是追加"当用户提到设计规范、UI 组件文档、设计转代码交付,甚至只是上传了 .fig 文件但没明说要干嘛时,都应该使用"。

还有一个常见错误:把"什么时候该用这个 Skill"的信息写在 Body 里。Body 是触发之后才加载的,写了也没有任何帮助。所有触发相关的信息,必须且只能写在 Description 中。

构建评测集

准备 16-20 条测试 query,分两组:

  • 应触发组(8-10 条) :覆盖不同的表述方式,正式的、口语的、没有明确提到 Skill 名称但显然需要它的
  • 不应触发组(8-10 条) :重点选近似场景,而非明显无关的请求
[
  {
    “query”: “我们团队要移除 less-loader,把 .less 文件全部转成 PostCSS 方案。项目比较大有 200 多个 LESS 文件,有复杂的 mixin 嵌套,用哪种方式风险更低?”,
    “should_trigger”: true
  },
  {
    “query”: “项目已经在用 PostCSS 了,现在想加 postcss-px-to-viewport 做移动端适配,postcss.config.js 不知道怎么写。”,
    “should_trigger”: false
  }
]

构建评测集时最容易踩的坑:

  • 测试 query 太干净。 “请帮我做代码审查"这种教科书式的指令在真实场景中几乎不存在。真人会带上文件路径、个人上下文、前因后果,甚至拼写错误和口语缩写。你的测试 query 越像真人说的话,评测结果越有参考价值。
  • 反例太容易。 “写一个斐波那契函数"作为 CSS 迁移 Skill 的反例毫无价值。最有意义的反例是那些共享了关键词但实际需要别的工具,或者触及了 Skill 的领域但处于一个不该触发的上下文中的 query。这些边界 case 才能真正检验 Description 的区分度。
△ code-review skill的触发测试 △ less-to-postcss skill的触发测试

执行评测

逐条把测试 query 发给 Agent,观察它是否加载了对应的 Skill。记录结果,计算两个指标:

  • 召回率:应触发组中实际触发的比例(衡量"该用的时候用了没”)
  • 精确率:不应触发组中正确未触发的比例(衡量"不该用的时候克制住了没”)

💡 一个快速调试技巧:直接问 Agent “你什么时候会使用 [skill-name] 这个 Skill?”,它会把 Description 复述回来,你可以据此判断它的理解是否与你的意图一致。

迭代改进

根据失败 case 分析原因,调整 Description:

  • 漏触发居多:补充更多触发关键词和场景描述,把边界推得更宽
  • 误触发居多:增加负向说明(“不要用于…”),收窄适用范围
  • 两者都有:Description 可能定位模糊,需要重新理清这个 Skill 的核心边界

每次修改后,用完整评测集重跑,对比前后得分。注意不要只盯着失败的 case 做针对性修补。Description 最终要面对的是无穷多种真实 query,过拟合到几条测试用例没有意义。

4.3 评测 Body:输出质量

Body 的评测比 Description 复杂得多,因为"好不好"不是布尔值,而是一个多维度的质量判断。核心方法是有 Skill 和无 Skill 的对照实验

Step 1:设计测试用例

准备 2-5 个代表性的测试任务。好的测试用例有几个特征:

  • 覆盖 Skill 的核心能力,不要只测边缘功能
  • 有明确的可判断的输出,而不是开放性的问答
  • 复杂度接近真实使用场景,太简单的任务区分不出有无 Skill 的差异

每个测试用例准备好输入材料(需要审查的代码、需要分析的数据、需要处理的文档等)。

Step 2:对照实验

对每个测试用例,分别跑两次:

  • 实验组:正常加载 Skill,执行任务
  • 对照组:不加载 Skill(或加载旧版本 Skill),执行相同任务

关键要求:用相同的 Agent、相同的输入、相同的系统环境。唯一的变量是 Skill 的有无或版本差异。

把输出保存在结构化的目录中,方便后续对比:

eval-workspace/
├── iteration-1/
│   ├── test-case-auth-module/
│   │   ├── with-skill/
│   │   └── baseline/
│   ├── test-case-api-refactor/
│   │   ├── with-skill/
│   │   └── baseline/
│   └── …

Step 3:定义评判标准

在看结果之前(避免结果影响标准),先想清楚"什么算好"。评判标准分两类:

可程序化验证的客观标准,用脚本直接检测:

  • 输出文件格式是否合法(JSON schema 校验、文件是否可打开)
  • 必要字段是否存在
  • 是否满足特定的结构要求

需要人判断的主观标准,形成检查清单:

  • “每个问题是否附带了具体的修改建议,而非仅描述问题”
  • “是否有将正确代码误标为问题的情况”
  • “输出的优先级排序是否合理”

对于写作风格、设计审美这类高度主观的 Skill,不需要勉强定义细粒度标准,直接看输出、做整体判断,反而更有效。

Step 4:评分和对比

逐个翻看每个测试用例的两组输出,记录:

  1. 客观检查项的通过情况:跑脚本,统计通过率
  2. 主观判断和具体反馈:哪里好、哪里差、哪里出乎意料。反馈要写具体。"输出不够好"没有行动指引,“安全维度的审查遗漏了 SQL 注入风险,建议在 Skill 中增加 OWASP Top 10 检查清单"才能指导改进
  3. 效率数据:如果可获取,记录 token 消耗和响应时间,避免质量提升以不可接受的效率代价为前提

最终形成一个清晰的判断:Skill 版本在哪些维度上比基线好、在哪些维度上持平、在哪些维度上退步了

Step 5:分析和改进

基于评分结果和具体反馈,修改 Skill。这一步是整个迭代中最需要判断力的环节,几个关键原则:

从反馈中提炼通用规律,别过拟合到具体用例。 Skill 最终要在无数不同的真实任务上运行,你现在只是用几个测试用例来快速迭代。如果某个改动解决了测试用例 B 的问题但让测试用例 A 退步了,大概率你在做过于针对性的调整。好的改动应该是普适的。

保持指令精简。 如果能获取到 Agent 的执行过程(而不只是最终输出),仔细看看它在做什么。如果 Agent 花了大量步骤在做无用功,找到 Skill 中导致这些无用功的指令,砍掉试试。冗余的指令不只是浪费 token,还会分散模型的注意力,降低真正重要的指令的执行质量。

解释 why 而不是堆 MUST。 如果你发现自己在写 ALWAYS 或 NEVER 这种全大写的硬约束,先停下来想想,能不能换成解释"为什么这件事重要”。模型理解了原因之后,执行的灵活性和准确度通常都比死记硬背的规则好。硬约束应该留给那些真正不可违反的底线,而不是泛滥在每一条指令里。

关注重复劳动。 如果你在多个测试用例的输出中发现 Agent 都独立编写了类似的辅助脚本或做了类似的预处理工作,这说明这个步骤应该被提炼到 Skill 的 scripts/ 目录下直接复用,而不是每次让 Agent 从头造轮子。

常见问题和改进方向参考:

图片

△ body的评测结果 - 有无skill对比 △ body的评测结果 - 经过迭代对检出问题细节优化

4.4 循环迭代

把上面的步骤连成闭环,每一轮迭代的流程是:

  1. 跑对照实验:在新的目录下同时跑所有测试用例的实验组和对照组
  2. 评分:客观指标跑脚本,主观维度人工判断
  3. 分析反馈:哪里好了、哪里退步了、哪里还不够
  4. 改 Skill:基于反馈修改 SKILL.md 或脚本,遵循上述改进原则
  5. 重跑:用完整评测集验证改动效果

对照组的选择取决于你要回答的问题。如果是新建 Skill,对照组就是没有 Skill 的裸跑,你要证明 Skill 的存在有价值。如果是改进已有 Skill,对照组可以是旧版本,你要证明改动带来了正向提升。

终止条件:反馈趋于空白(没什么要改了)、你已经没有更多手段继续改进、或者你对输出质量满意了。不需要追求完美,Skill 和代码一样,可以持续迭代,在实际使用中收集到新的失败 case 时随时回来改进。

4.5 案例:Skill 迭代的实际路径

案例一:Skill-Creator 的三次进化

Anthropic 官方的 Skill-Creator 本身就经历了迭代式演进:

  • 第一版(创建) :帮用户从自然语言描述生成 SKILL.md,输出格式正确的 Frontmatter 和基本指令结构。核心价值是降低上手门槛。
  • 第二版(创建 + 优化) :增加了分析与改进的能力,将自身能力边界进行了拓展,可以承接几乎所有与Skill相关的工作,因此其description也变得更为激进。用户指出Skill执行时的问题和现象后,可以自主改进Skill内容并给出建议。
  • 第三版(自动评测优化) :基于完整的评测改进循环理论进行构建,不仅仅为生成、改进内容工作负责,也为Skill的最终运行效果负责。这一版可以基于需求生成评测用例、创建评分机制、运行评测、评价汇总、循环改进,完成Skill编写的同时给出效果结论。

案例二:Code-Review Skill 的质量提升

一个更贴近业务的例子,代码审查 Skill 的迭代过程:

  • 第一版(简单 Prompt) :一段直白的 Markdown 指令,列出审查维度和注意事项,以及项目隐式需要注意的的点。效果还行,但输出质量波动大,有时遗漏重要问题,有时对细枝末节过度关注,如果git diff的文件信息过多上下文会超出导致失败。
  • 第二版(多 Agent 组合架构) :引入 SubAgent 模式,每个 Subtask Agent 只持有一个文件的diff + 源码,不会被其他文件干扰。单 Agent 串行审查时,随文件数增加上下文污染越来越严重;并发子Agent 则始终保持干净的注意力窗口。把一次 Code Review 拆解为多个阶段,总览分析(掌握全局)、分维度审查(安全、性能、可维护性分别深入)、使用子agent交叉验证(排除误报)、去重合并(消除冗余)、最终报告(按优先级排序输出)。每个阶段有明确的输入输出契约和质量检查点。依赖文件系统,有明确的“任务是否全部完成”的可检查标准,即使因为网络超时中断,也可以恢复继续处理任务,单个子任务失败不影响其他任务的完成,失败的任务重新跑而无需跑整个PR。

两个版本在相同的 20 个 PR 上跑评测,用 Grader Agent 评估输出质量、覆盖率和误报率,第二版在三项指标上均有明显提升。

图片

△ 旧架构的检出效果 △ 新架构的实现效果,更关注逻辑实现和减少误判

05 总结

Skill 正在统一 Agent 能力扩展的途径。 从 MCP 提供工具连接,到 AGENTS.md 的社区探索,再到 Skill 的标准化方案,Agent "学习新技能"的方式正在收敛。渐进式披露的设计不仅节省 Token,更重要的是提升了模型的注意力分配效率。以自然语言为载体的知识表达,比硬编码的逻辑更灵活,也更 Agentic。

广泛的社区 Skill 可以直接提升生成效果。 Anthropic 官方的文档生成 Skill(PDF、DOCX、PPTX)、前端设计 Skill,以及社区贡献的各类工作流 Skill,都可以拿来即用。在你动手定制之前,先看看现有 Skill 能否满足需求。

定制化 Skill 是让 Agent 在你的场景中真正好用的关键投入。 通用的 Agent 能力就像一个聪明但不了解你业务的新人,Skill 就是你给他的工作手册。Description 的精准度决定了它出现在正确的场景,Body 的质量决定了它在场景中的表现。这两者都有明确的设计原则和可遵循的技巧。

评测是 Agentic 工程必不可少的环节。 不只是工具开发、系统开发需要评测,Skill 开发同样需要。拍脑袋觉得"差不多了"和用数据验证"确实好了"之间,往往隔着好几轮迭代的距离。基于评测的循环优化,评测、分析、改进、重新评测,是通往高质量 Skill 的可靠路径。

回过头看,Skill 做的事情并不复杂:把你本来每次都要重新交代的经验、流程和标准,整理一次存下来,之后 Agent 自己就知道该怎么做了。省掉重复劳动,换来稳定可预期的输出。

库函数写法(Python/Java/C++/C/Go/JS/Rust)

题目让我们把 $n$ 取反。

例如二进制 $n=11001$,取反后是 $00110$,即十进制的 $6$。

看上去,计算 ~n 就好了?

但这样做会把更高位的 $0$ 也取反,对于 $32$ 位整数来说,$11001$ 实际是 $00000000000000000000000000011001$,取反后是 $11111111111111111111111111100110$。

所以对于这个例子,要只把 $n$ 的低 $5$ 位取反,也就是计算 $n$ 和 $11111$ 的异或。

$11111$ 怎么算?设 $w=5$ 是 $n$ 的二进制长度,计算 1 << w 可以得到 $100000$,再减去 $1$,得到 $11111$。

特殊情况:根据题意,$n=0$ 反转后是 $1$,如果用库函数算 $n=0$ 的二进制长度,会算出 $0$,这会导致 $n$ 取反后的值是 $0$。所以特判 $n=0$ 的情况,返回 $1$。

###py

class Solution:
    def bitwiseComplement(self, n: int) -> int:
        if n == 0:
            return 1
        w = n.bit_length()
        return ((1 << w) - 1) ^ n

###java

class Solution {
    public int bitwiseComplement(int n) {
        if (n == 0) {
            return 1;
        }
        int w = 32 - Integer.numberOfLeadingZeros(n);
        return ((1 << w) - 1) ^ n;
    }
}

###cpp

class Solution {
public:
    int bitwiseComplement(int n) {
        if (n == 0) {
            return 1;
        }
        int w = bit_width((uint32_t) n);
        return ((1 << w) - 1) ^ n;
    }
};

###c

int bitwiseComplement(int n) {
    if (n == 0) {
        return 1;
    }
    int w = 32 - __builtin_clz(n);
    return ((1 << w) - 1) ^ n;
}

###go

func bitwiseComplement(n int) int {
if n == 0 {
return 1
}
w := bits.Len(uint(n))
return 1<<w - 1 ^ n
}

###js

var bitwiseComplement = function(n) {
    if (n === 0) {
        return 1;
    }
    const w = 32 - Math.clz32(n);
    return ((1 << w) - 1) ^ n;
};

###rust

impl Solution {
    pub fn bitwise_complement(n: i32) -> i32 {
        if n == 0 {
            return 1;
        }
        let w = n.ilog2() + 1;
        ((1 << w) - 1) ^ n
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(1)$。
  • 空间复杂度:$\mathcal{O}(1)$。

专题训练

见下面位运算题单的「一、基础题」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

每日一题-十进制整数的反码🟢

每个非负整数 N 都有其二进制表示。例如, 5 可以被表示为二进制 "101"11 可以用二进制 "1011" 表示,依此类推。注意,除 N = 0 外,任何二进制表示中都不含前导零。

二进制的反码表示是将每个 1 改为 0 且每个 0 变为 1。例如,二进制数 "101" 的二进制反码为 "010"

给你一个十进制数 N,请你返回其二进制表示的反码所对应的十进制整数。

 

    示例 1:

    输入:5
    输出:2
    解释:5 的二进制表示为 "101",其二进制反码为 "010",也就是十进制中的 2 。
    

    示例 2:

    输入:7
    输出:0
    解释:7 的二进制表示为 "111",其二进制反码为 "000",也就是十进制中的 0 。
    

    示例 3:

    输入:10
    输出:5
    解释:10 的二进制表示为 "1010",其二进制反码为 "0101",也就是十进制中的 5 。
    

     

    提示:

    1. 0 <= N < 10^9
    2. 本题与 476:https://leetcode.cn/problems/number-complement/ 相同
    ❌