普通视图

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

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

作者 徐小夕
2026年3月11日 20:52

今天继续和大家聊聊,我们开源的 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回溯解法详解

作者 Wect
2026年3月11日 19:17

拆解 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. 全排列:深度解析+代码拆解

作者 Wect
2026年3月11日 19:08

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?

作者 从文处安
2026年3月11日 18:17

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对比终于学会了

作者 林太白
2026年3月11日 17:33

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:从会用到理解设计

作者 yuki_uix
2026年3月11日 17:33

作为一名用了几年 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龙虾保姆级完整部署指南

2026年3月11日 17:31

在 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:终极指南

作者 jerrywus
2026年3月11日 17:06

翻译自: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 全流程终极实战

作者 孙凯亮
2026年3月11日 16:50

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自定义指令有什么区别,都是怎么实现和使用一个指令

2026年3月11日 16:50

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

网易云桌面端--精选歌单布局思路记录

2026年3月11日 16:34

最近在学习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落地指南

2026年3月11日 16:31

在企业级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…


作者注:本文为实战技术分享,所有代码均经过生产环境验证,可直接复制使用,欢迎交流打印功能落地的实战经验。

鸿蒙文件哈希流

作者 城西往事
2026年3月11日 16:27

请帮我解释: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技能库

作者 明君87997
2026年3月10日 10:29

最近在 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,不妨试试加上这套技能,看看生成代码的变化。


相关链接:

使用GSAP轻松实现元素做曲线运动

作者 花满楼zxc
2026年3月11日 11:16

前言

在前端开发中,我们经常需要实现一些比简单的直线平移更具动感的动画效果。虽然 CSS 的 transitionanimation 能够处理大部分基础动画,但一旦涉及到复杂的贝塞尔曲线运动路径跟随动画,原生 CSS 实现起来就不太容易。

今天我们要介绍的主角是 GSAP (GreenSock Animation Platform) 。它是业界公认的动画标杆,不仅性能卓越,更重要的是,它提供了一套极其优雅的 API,让我们只需几行代码就能实现复杂的曲线运动。

为什么选择 GSAP?

GSAP 的强大之处在于其全能性:

  • 极高性能: 经过高度优化的内核,甚至在处理数千个 SVG 元素时也能保持 60fps。
  • 零兼容性烦恼: 自动处理不同浏览器的前缀和属性差异。
  • 强大的插件系统: 比如我们今天要重点讨论的 MotionPathPlugin,专门为路径动画而生。
  • 时间轴管理: 通过 Timeline 可以非常直观地编排一连串复杂的动画。

在项目中引入 GSAP

  1. 使用 NPM/Yarn(推荐)
npm install gsap
# 或者 
yarn add gsap
  1. 在项目中使用
import { gsap } from "gsap";
import { MotionPathPlugin } from "gsap/MotionPathPlugin";

// 使用插件前需要手动注册
gsap.registerPlugin(MotionPathPlugin);
  1. 使用 CDN 引入
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/MotionPathPlugin.min.js"></script>

实现元素做曲线运动

要让元素沿着曲线运动,GSAP 提供了 MotionPathPlugin 插件。它支持两种主要的路径定义方式:SVG 路径坐标点数组

方案一:让元素跟随现有的 SVG 路径

这是最常用的场景,可以直接在设计稿中画好一段 SVG 路径(Path),然后让 HTML 元素“贴”上去运行。 HTML:

<svg width="400" height="200" viewBox="0 0 400 200">
  <path id="route" d="M10,100 Q100,0 200,100 T390,100" stroke="lightgray" fill="transparent" />
</svg>
<div class="ball"></div>

JS:

gsap.to(".ball", {
  duration: 5,
  repeat: -1, 
  ease: "power1.inOut",
  motionPath: {
    path: "#route", // 引用 SVG 路径的选择器
    align: "#route", // 将元素对齐到路径上
    autoRotate: true, // 元素随路径方向自动旋转
    alignOrigin: [0.5, 0.5] // 以元素的中心点进行对齐
  }
});
方案二:通过坐标数组自定义曲线

如果你没有 SVG,也可以直接传入一组坐标点,GSAP 会自动为你计算出平滑的贝塞尔曲线。

gsap.to(".ball", {
  duration: 4,
  motionPath: {
    path: [
      {x: 100, y: -50}, 
      {x: 250, y: 100}, 
      {x: 400, y: 50},
      {x: 600, y: 200}
    ],
    curviness: 1.5 // 数值越大曲线越圆润
  }
});
关键参数深度解析

motionPath 对象中,有几个参数非常实用:

  1. align (对齐): 如果不设置 align,元素会基于自己当前的初始位置进行相对运动。设置后,它会“吸附”到目标路径上。
  2. autoRotate (自动旋转): 对于像“小车”或“纸飞机”这样的元素,设置为 true 或指定旋转偏移量,可以让物体的头部始终指向运动方向。
  3. start & end: 你可以指定动画从路径的 10% 处开始,到 90% 处结束(取值 0 到 1)。
    • start: 0.1, end: 0.9

进阶:结合 ScrollTrigger 实现滚动曲线动画

GSAP 最强悍的组合就是将 MotionPathScrollTrigger 结合。你可以实现当用户向下滚动页面时,一个元素沿着预设的曲线路径飞过。

import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);

gsap.to(".ball", {
  scrollTrigger: {
    trigger: ".container",
    start: "top center",
    end: "bottom center",
    scrub: 1, // 动画随滚动条进度平滑移动
  },
  motionPath: {
    path: "#route"
  }
});

2026前端CSS黑科技技巧

2026年3月11日 10:53

日常开发中那些「一招解决」的CSS小技巧,收藏这篇就够了!

前言

前端开发中,我们经常遇到「伪元素使用」「移动端样式兼容」「滚动条定制」等高频场景,每次都要翻笔记、查文档?这篇整理了15类最常用的CSS/Scss实战技巧,覆盖布局、兼容、样式定制、交互优化等场景,代码可直接复制使用,帮你节省80%的样式调试时间!

一、基础样式黑科技

1. 伪元素(::before/::after)通用写法

核心场景:生成装饰元素、清除浮动、模拟边框等
通用模板(必加属性,避免踩坑):

/* 伪元素基础写法(适配所有场景) */
.box::after, .box::before {
  content: ""; /* 伪元素必须加,空内容也不能省略 */
  position: absolute; /* 脱离文档流,方便定位 */
  /* 可选:根据场景补充 */
  /* display: block; */
  /* width: 100%; height: 100%; */
}

避坑点

  • 忘记写content会导致伪元素不显示;
  • 未加position: absolute时,伪元素会占用文档流空间,影响布局。

2. 文字排版高频技巧

需求场景 代码实现 适用场景
文字平稳换行(避免溢出) word-break: break-all; 长文本、中英文混合内容
文字强制不换行 white-space: nowrap; 按钮文字、单行标题
文字两端对齐 text-align-last: justify; text-align: justify; text-justify: distribute-all-lines; 导航栏、表单标签
/* 文字两端对齐(兼容IE/Chrome/Firefox) */
.text-justify {
  text-align-last: justify;
  text-align: justify;
  text-justify: distribute-all-lines; /* IE专属兼容 */
}

3. 尺寸100%适配技巧

/* 页面/容器占满视口(无需嵌套父元素设置height:100%) */
.full-screen {
  height: 100vh; /* 高度占满视口 */
  width: 100vw; /* 宽度占满视口(慎用:可能出现横向滚动条,建议用100%) */
  /* 替代方案(更安全) */
  width: 100%;
  min-height: 100vh;
}

/* 高度随内容撑开(继承父元素高度规则) */
.height-inherit {
  height: inherit;
}

二、移动端兼容必看

1. 去除a标签点击高亮背景

/* 适配iOS/Android */
a, button, input {
  -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
  tap-highlight-color: rgba(0, 0, 0, 0); /* 标准写法 */
}

2. 解决移动端圆角渲染异常

场景:input/button等原生控件在移动端圆角显示不一致

.input-fix {
  -webkit-appearance: none; /* 清除移动端原生样式 */
  appearance: none; /* 标准写法 */
  border-radius: 4px; /* 自定义圆角 */
}

3. 解决移动端/PC端文字大小不一致

/* 修复移动端文字缩放问题(经典偏方,实测有效) */
body {
  max-height: 999999px;
  -webkit-text-size-adjust: 100%; /* 禁止iOS文字自动缩放 */
  text-size-adjust: 100%;
}

三、视觉美化技巧

1. 元素发光效果

/* 高亮发光(按钮/卡片hover效果) */
.glow-effect {
  box-shadow: 0px 0px 20px #5D5C61; /* 灰色发光 */
  /* 彩色发光示例(蓝色) */
  /* box-shadow: 0px 0px 15px rgba(22, 93, 255, 0.6); */
}

2. 背景图片固定(视差效果)

/* 背景图片固定,滚动页面时背景不移动 */
.bg-fixed {
  background: url("bg.jpg") no-repeat center center;
  background-size: cover;
  background-attachment: fixed; /* 核心属性 */
  height: 500px; /* 必须设置高度,否则效果不生效 */
}

避坑点background-attachment: fixed在移动端部分浏览器(如iOS Safari)不兼容,可通过JS模拟视差效果替代。

3. 自定义滚动条(美化必备)

/* Scss写法(Vue/React组件内) */
.scroll-custom {
  width: 100%;
  height: 40rem;
  overflow-y: auto; /* 仅纵向滚动 */

  /* 滚动条宽度/高度 */
  &::-webkit-scrollbar {
    width: 0.2rem;
    height: 1px;
  }

  /* 滚动条滑块 */
  &::-webkit-scrollbar-thumb {
    border-radius: 10px;
    -webkit-box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
    background: #BDA065; /* 滑块颜色 */
  }

  /* 滚动条轨道 */
  &::-webkit-scrollbar-track {
    -webkit-box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
    border-radius: 10px;
    background: #ffffff; /* 轨道颜色 */
  }
}

兼容说明:仅支持Webkit内核浏览器(Chrome/Safari/Edge),Firefox需用scrollbar-color/scrollbar-width,IE需用原生样式。

四、媒体/视频样式优化

1. 图片保持比例裁剪适配

/* 图片裁剪填充(不拉伸,保持比例) */
.img-cover {
  width: 100%;
  height: 200px;
  object-fit: cover; /* 核心属性:裁剪多余部分 */
  /* 可选值:contain(完整显示)/ fill(拉伸)/ none(原图尺寸) */
}

2. 视频全屏/封面设置

/* 视频铺满容器 */
video {
  width: 100%;
  height: 100%;
  object-fit: fill; /* 拉伸铺满(也可根据需求用cover) */
}
<!-- 视频添加封面 + 控制栏 -->
<video 
  src="video/53709159-1-6.mp4" 
  poster="images/video.png" <!-- 封面图片 -->
  controls="true" <!-- 显示原生控制栏 -->
  width="100%"
>
  您的浏览器不支持HTML5视频播放
</video>

五、特殊场景技巧

1. 全站变灰色(哀悼/特殊节日)

/* 全局置灰(兼容所有浏览器) */
html {
  -webkit-filter: grayscale(100%);
  -moz-filter: grayscale(100%);
  -ms-filter: grayscale(100%);
  -o-filter: grayscale(100%);
  filter: progid:DXImageTransform.Microsoft.BasicImage(grayscale=1); /* IE */
  filter: grayscale(100%); /* 标准写法 */
  _filter: none; /* IE6/7兼容 */
}

2. 全局加边界(调试布局)

/* 开发调试用:给所有元素加红色边框,定位布局问题 */
* {
  outline: solid #f00 1px !important;
}

六、Scss组件内样式穿透(Vue/React)

场景:组件库样式覆盖(如Element Plus/Element UI/Ant Design)

写法 适用场景 注意事项
::v-deep Vue3 + Scss/Less 官方推荐
/deep/ Vue2 + Scss/Less 部分编译器需配置
>>> Vue2 + 原生CSS 不支持Scss/Less嵌套
/* Vue3组件内覆盖Element Plus样式 */
.el-button {
  ::v-deep .el-button__text {
    color: #165DFF;
    font-size: 14px;
  }
}

/* Vue2兼容写法 */
.el-input {
  /deep/ .el-input__inner {
    border-radius: 4px;
  }
}

总结

  1. 核心价值:这些技巧覆盖前端开发80%的样式场景,代码可直接CV,解决伪元素、移动端兼容、视觉美化等高频问题;
  2. 避坑重点:伪元素必须加contentbackground-attachment: fixed移动端兼容、滚动条样式仅支持Webkit内核;
  3. 使用建议:将高频技巧封装成CSS工具类(如.text-justify/.img-cover),统一维护,提升团队开发效率。

你还遇到过哪些「一招解决」的CSS小技巧?评论区分享一下~


Vite 实战教程:alias/env/proxy 配置 + 打包优化避坑|Vue 工程化必备

作者 SuperEugene
2026年3月11日 10:50

【Vite】前端工程化实操:从路径别名到打包优化,彻底搞懂Vite核心配置,避开高频踩坑!

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱「面向搜索引擎写代码」的尴尬。

📑 文章目录

一、alias:让 import 更清晰

1.1 为什么需要 alias?

没有 alias 时,你会经常看到这样的写法:


import Button from '../../../components/Button.vue'
import { getUserInfo } from '../../../../api/user'

问题主要有两点:

  1. ../ 太多,路径难维护,容易写错

  2. 重构时移动文件,相对路径要全改一遍

用 alias 把常用目录映射成简短路径后,可以改成:


import Button from '@/components/Button.vue'
import { getUserInfo } from '@/api/user'

⬆ 返回目录

1.2 怎么配置?

vite.config.js(或 vite.config.ts)里配置:


// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      // 方式一:映射到 src 目录
      '@': fileURLToPath(new URL('./src', import.meta.url)),
      // 方式二:可以配多个
      '@components': fileURLToPath(new URL('./src/components', import.meta.url)),
      '@api': fileURLToPath(new URL('./src/api', import.meta.url)),
    },
  },
})

要点:

  • fileURLToPath + new URL():在 Node 的 ESM 环境下拿到正确的绝对路径

  • import.meta.url:当前配置文件所在目录

  • ./src:相对于配置文件所在目录的路径

⬆ 返回目录

1.3 常见踩坑

坑 1:忘记在 IDE 里配置路径提示

Vite 能正确解析,但 IDE 可能不认识 @,需要加 jsconfig.jsontsconfig.json


// jsconfig.json(用 JS 的项目)
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@components/*": ["src/components/*"],
      "@api/*": ["src/api/*"]
    }
  },
  "include": ["src/**/*"]
}

// tsconfig.json(用 TS 的项目)
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@components/*": ["src/components/*"]
    }
  }
}

坑 2:alias 和 Vite 配置不一致

jsconfig/tsconfigpaths 要跟 vite.configalias 保持一致,否则可能出现:开发时没问题,打包后路径错误或 IDE 报错。

⬆ 返回目录

二、env:环境变量怎么用

2.1 为什么需要 env?

不同环境需要不同配置,例如:

  • 开发环境:本地 API 地址、调试开关

  • 生产环境:线上 API 地址、关闭调试

如果写死在代码里,每次发版都要手动改,容易出错。用 env 可以按环境自动切换。

⬆ 返回目录

2.2 基本规则

Vite 的环境变量规则:

  • 文件名必须是 .env.env.local.env.[mode].env.[mode].local 这类

  • 只有以 VITE_ 开头的变量会暴露给客户端

  • mode 默认是 development(dev)和 production(build)

⬆ 返回目录

2.3 典型文件结构


项目根目录/
├── .env                 # 所有环境都加载
├── .env.local           # 本地覆盖,一般加在 .gitignore
├── .env.development     # 开发环境
├── .env.production      # 生产环境
└── .env.staging         # 可选:预发环境

⬆ 返回目录

2.4 示例配置

.env (公共变量)


# API 基础路径(会被 .env.development / .env.production 覆盖)
VITE_APP_TITLE=我的项目

.env.development (开发)


VITE_API_BASE_URL=http://localhost:3000/api
VITE_USE_MOCK=true

.env.production (生产)


VITE_API_BASE_URL=https://api.yoursite.com
VITE_USE_MOCK=false

.env.local (本地覆盖,不提交)


# 比如你本机端口不同
VITE_API_BASE_URL=http://localhost:8080/api

⬆ 返回目录

2.5 在代码里怎么用


// 直接通过 import.meta.env 访问
console.log(import.meta.env.VITE_API_BASE_URL)
console.log(import.meta.env.VITE_USE_MOCK)
console.log(import.meta.env.MODE)  // 'development' | 'production'

如果要集中管理,可以再包一层:


// src/config/env.js
export const config = {
  apiBaseUrl: import.meta.env.VITE_API_BASE_URL,
  useMock: import.meta.env.VITE_USE_MOCK === 'true',
  isDev: import.meta.env.DEV,
  isProd: import.meta.env.PROD,
}

⬆ 返回目录

2.6 常见踩坑

坑 1:没用 VITE_ 前缀


API_URL=xxx   # ❌ 客户端拿不到
VITE_API_URL=xxx  # ✅ 正确

坑 2:把 env 当布尔用


// env 读出来都是字符串
if (import.meta.env.VITE_USE_MOCK) { }  // 'true' 和 'false' 都是 truthy!
// 正确写法
if (import.meta.env.VITE_USE_MOCK === 'true') { }

坑 3:.env.local 被提交

.env.local 里常放本地密钥、端口等,要加到 .gitignore,不要提交。

⬆ 返回目录

三、proxy:解决开发环境跨域

3.1 为什么需要 proxy?

前端开发时往往是 localhost:5173,接口在 api.yoursite.com,浏览器会因同源策略限制产生跨域。

后端配 CORS 是一种方式,但有时后端不方便改,或者你想在本地连不同环境的接口,这时用 Vite 的 proxy 最方便:浏览器只请求同源的 dev 服务器,由 dev 服务器转发到真实接口。

⬆ 返回目录

3.2 基本配置


// vite.config.js
export default defineConfig({
  server: {
    port: 5173,
    proxy: {
      // 简单写法:/api 开头的请求转发到目标服务器
      '/api': {
        target: 'https://api.yoursite.com',
        changeOrigin: true,
      },
    },
  },
})

这样访问 http://localhost:5173/api/user/info 时,会被转发到 https://api.yoursite.com/api/user/info

⬆ 返回目录

3.3 更完整的配置示例


// vite.config.js
export default defineConfig({
  server: {
    port: 5173,
    open: true,
    proxy: {
      '/api': {
        target: 'https://api.yoursite.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ''), // 转发时去掉 /api 前缀
        secure: false,
        configure: (proxy, options) => {
          proxy.on('proxyReq', (proxyReq, req, res) => {
            // 可选:加 token 等请求头
            // proxyReq.setHeader('Authorization', 'Bearer xxx')
          })
          proxy.on('proxyRes', (proxyRes, req, res) => {
            // 可选:处理响应
          })
        },
      },
      // 多个接口可以配多个代理
      '/upload': {
        target: 'https://upload.yoursite.com',
        changeOrigin: true,
      },
    },
  },
})

常用选项说明:

选项 作用
target 真实后端地址
changeOrigin 改请求头 Host,避免目标服务器校验失败
rewrite 重写请求路径,例如去掉 /api 前缀
secure 目标为 https 且证书有问题时,可设 false
⬆ 返回目录

3.4 和 env 配合

开发环境用 proxy,生产用完整 URL,可以这样配合 env:

.env.development


VITE_API_BASE_URL=/api

.env.production


VITE_API_BASE_URL=https://api.yoursite.com

src/api/request.js


const baseURL = import.meta.env.VITE_API_BASE_URL

export function request(url, options = {}) {
  return fetch(`${baseURL}${url}`, options)
}

开发时请求 /api/xxx,会被 proxy 转发;生产时直接请求完整域名。

⬆ 返回目录

3.5 常见踩坑

坑 1:忘记 changeOrigin

目标为域名时,建议设 changeOrigin: true,否则可能被后端拒绝。

坑 2:rewrite 把路径改错了

要清楚 rewrite 前后路径的对应关系,比如:


// 前端请求:/api/user/info
// 未 rewrite:https://api.xxx.com/api/user/info
// rewrite 去掉 /api:https://api.xxx.com/user/info
rewrite: (path) => path.replace(/^\/api/, ''),

要看后端实际路径再决定是否 rewrite。

坑 3:proxy 只在开发环境生效

server.proxy 只在 vite 开发服务器下生效,生产构建不会用到,生产环境依赖你配置的 VITE_API_BASE_URL 等。

⬆ 返回目录

四、打包优化

4.1 为什么需要打包优化?

不做优化时常见问题:

  • 单个 JS 过大,首屏加载慢

  • 第三方库和业务代码混在一起,缓存利用差

  • 未压缩的包体积大

Vite 默认已经做了不少优化,我们再针对常见场景补充一些配置。

⬆ 返回目录

4.2 代码分割(手动分包)


// vite.config.js
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // Vue 全家桶单独打包
          'vue-vendor': ['vue', 'vue-router', 'pinia'],
          // 体积较大的 UI 库单独打包
          'element-plus': ['element-plus'],
        },
      },
    },
  },
})

这样可以把 Vue、路由、状态管理和 UI 库拆成独立 chunk,利于缓存。

⬆ 返回目录

4.3 分包策略示例(按路由/模块)


// vite.config.js
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules')) {
            // node_modules 里的包
            if (id.includes('vue') || id.includes('pinia') || id.includes('vue-router')) {
              return 'vue-vendor'
            }
            if (id.includes('element-plus')) {
              return 'element-plus'
            }
            return 'vendor'
          }
        },
        chunkFileNames: 'js/[name]-[hash].js',
        entryFileNames: 'js/[name]-[hash].js',
        assetFileNames: '[ext]/[name]-[hash].[ext]',
      },
    },
    chunkSizeWarningLimit: 1000, // 单 chunk 超过 1000kb 时警告
  },
})

⬆ 返回目录

4.4 CDN 外链(可选)

把 Vue、Element Plus 等用 CDN 引入,减小打包体积:


// vite.config.js
import { defineConfig } from 'vite'

export default defineConfig({
  build: {
    rollupOptions: {
      external: ['vue', 'vue-router', 'pinia', 'element-plus'],
      output: {
        globals: {
          vue: 'Vue',
          'vue-router': 'VueRouter',
          pinia: 'Pinia',
          'element-plus': 'ElementPlus',
        },
      },
    },
  },
})

index.html 中用 <script> 引入对应 CDN,并确保全局变量名和 globals 一致。

注意:一般 SPA 不推荐全部 external,可以只 external 少数大库,其余照常打包。

⬆ 返回目录

4.5 压缩与产物清理


// vite.config.js
export default defineConfig({
  build: {
    minify: 'terser',
    terserOptions: {
      compress: {
        drop_console: true,  // 生产环境去掉 console
        drop_debugger: true,
      },
    },
    cssCodeSplit: true,
    sourcemap: false,
  },
})

⬆ 返回目录

4.6 常见踩坑

坑 1:manualChunks 拆得太碎

拆出太多小 chunk 会多很多请求,反而影响性能,一般把体积大的依赖拆几块即可。

坑 2:忘记配 chunkSizeWarningLimit

默认 500kb 会报警,可按项目实际情况调大,例如 1000 或 1500。

坑 3:生产 sourcemap

生产环境建议关掉 sourcemap,否则包体积会明显增大。

⬆ 返回目录

五、完整配置示例

下面是一份整合了 alias、env、proxy 和打包优化的 vite.config.js 示例:


// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'

export default defineConfig({
  plugins: [vue()],

  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },

  server: {
    port: 5173,
    open: true,
    proxy: {
      '/api': {
        target: 'https://api.yoursite.com',
        changeOrigin: true,
      },
    },
  },

  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'vue-vendor': ['vue', 'vue-router', 'pinia'],
        },
        chunkFileNames: 'js/[name]-[hash].js',
        assetFileNames: '[ext]/[name]-[hash].[ext]',
      },
    },
    chunkSizeWarningLimit: 1000,
    sourcemap: false,
  },
})

⬆ 返回目录

六、小结

配置项 作用 重点
alias 简化 import 路径 和 jsconfig/tsconfig 保持一致
env 按环境切换配置 必须 VITE_ 前缀,注意值是字符串
proxy 开发环境解决跨域 changeOrigin,和 env 配合使用
打包优化 减小体积、提升加载 合理分包,控制 chunk 数量和大小

建议在实际项目里按需启用和调整这些配置,有问题可以在评论区补充你的项目结构和错误信息,便于一起排查。

⬆ 返回目录


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战的方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~

React Native 完全入门:从原理到实战

作者 兆子龙
2026年3月11日 10:49

一、React Native 是什么

React Native(简称 RN)是 Facebook 开源的跨平台移动应用开发框架,让你用 JavaScript 和 React 语法编写原生 iOS 和 Android 应用。

核心特点:

- 真原生渲染:不是 WebView,而是调用原生 UI 组件 - 热更新:无需重新打包,线上修复 bug - 跨平台:一套代码,iOS 和 Android 共用 80%+ 逻辑 - React 生态:复用 React 的组件化、状态管理等能力

与其他方案对比:

方案 渲染方式 性能 开发体验
原生开发 原生 最优 需学 Swift/Kotlin
React Native 原生 接近原生 JavaScript + React
Flutter 自绘引擎 接近原生 Dart 语言
Hybrid(Cordova) WebView 较差 Web 技术栈

二、React Native 的工作原理

2.1 整体架构

┌─────────────────────────────────────┐
│      JavaScript 层(业务逻辑)       │
│      (React 组件、状态管理)          │
└──────────────┬──────────────────────┘
               │ Bridge(消息通信)
┌──────────────┴──────────────────────┐
│      Native 层(原生模块)           │
│      (UI 渲染、网络、存储等)         │
└─────────────────────────────────────┘

三层结构:

1. JavaScript 层:运行在 JavaScriptCore(iOS)或 Hermes(Android)引擎中,执行 React 代码 2. Bridge:JS 和 Native 之间的消息通道,传递 JSON 数据 3. Native 层:iOS 用 Objective-C/Swift,Android 用 Java/Kotlin,负责实际渲染和系统调用

2.2 渲染流程

// 1. 你写的 JSX
<View style={{ flex: 1 }}>
  <Text>Hello RN</Text>
</View>

// 2. React 转成虚拟 DOM
{
  type: 'View',
  props: { style: { flex: 1 } },
  children: [
    { type: 'Text', props: {}, children: ['Hello RN'] }
  ]
}

// 3. Bridge 传给 Native
{
  "type": "createView",
  "viewId": 1,
  "viewType": "RCTView",
  "props": { "flex": 1 }
}

// 4. Native 创建真实 UI
UIView *view = [[UIView alloc] init];  // iOS
// 或
View view = new View(context);         // Android

2.3 Bridge 通信

JS 调用 Native:

// JS 端
import { NativeModules } from 'react-native';
const { ToastModule } = NativeModules;

ToastModule.show('Hello', ToastModule.SHORT);

Native 实现(iOS):

// ToastModule.m
#import <React/RCTBridgeModule.h>

@interface ToastModule : NSObject <RCTBridgeModule>
@end

@implementation ToastModule

RCT_EXPORT_MODULE();

RCT_EXPORT_METHOD(show:(NSString *)message duration:(NSInteger)duration) {
  dispatch_async(dispatch_get_main_queue(), ^{
    // 显示 Toast
  });
}

@end

Native 调用 JS:

// Native 端
[self.bridge enqueueJSCall:@"RCTDeviceEventEmitter"
                    method:@"emit"
                      args:@[@"onNetworkChange", @{@"type": @"wifi"}]
                completion:NULL];
// JS 端
import { NativeEventEmitter, NativeModules } from 'react-native';

const eventEmitter = new NativeEventEmitter(NativeModules.ToastModule);
eventEmitter.addListener('onNetworkChange', (event) => {
  console.log('网络变化:', event.type);
});

三、从零搭建 React Native 项目

3.1 环境准备

安装依赖:

# macOS(开发 iOS 需要)
brew install node watchman
sudo gem install cocoapods

# 安装 React Native CLI
npm install -g react-native-cli

安装 Xcode(iOS)或 Android Studio(Android)。

3.2 创建项目

npx react-native init MyApp
cd MyApp

目录结构:

MyApp/
├── android/          # Android 原生代码
├── ios/              # iOS 原生代码
├── node_modules/
├── App.tsx           # 入口组件
├── index.js          # 注册入口
├── package.json
└── metro.config.js   # 打包配置

3.3 运行项目

# iOS
npx react-native run-ios

# Android(需先启动模拟器或连接真机)
npx react-native run-android

四、核心组件

4.1 View 和 Text

import { View, Text, StyleSheet } from 'react-native';

function App() {
  return (
    <View style={styles.container}>
      <Text style={styles.title}>Hello React Native</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#f5f5f5'
  },
  title: {
    fontSize: 24,
    fontWeight: 'bold',
    color: '#333'
  }
});

4.2 Image

<Image
  source={{ uri: 'https://example.com/image.png' }}
  style={{ width: 200, height: 200 }}
  resizeMode="cover"
/>

// 本地图片
<Image source={require('./assets/logo.png')} />

4.3 ScrollView 和 FlatList

// ScrollView:适合少量数据
<ScrollView>
  {data.map(item => <Text key={item.id}>{item.name}</Text>)}
</ScrollView>

// FlatList:适合长列表,支持虚拟滚动
<FlatList
  data={data}
  keyExtractor={item => item.id}
  renderItem={({ item }) => <Text>{item.name}</Text>}
  onEndReached={loadMore}
  onEndReachedThreshold={0.5}
/>

4.4 TouchableOpacity

<TouchableOpacity
  onPress={() => console.log('点击')}
  activeOpacity={0.7}
>
  <Text>点我</Text>
</TouchableOpacity>

五、样式与布局

5.1 Flexbox 布局

RN 默认使用 Flexbox,但有些差异:

// 默认 flexDirection 是 column(Web 是 row)
<View style={{ flexDirection: 'row' }}>
  <View style={{ flex: 1, backgroundColor: 'red' }} />
  <View style={{ flex: 2, backgroundColor: 'blue' }} />
</View>

5.2 尺寸单位

RN 没有 pxrem,只有无单位数字(对应设备独立像素 dp/pt):

<View style={{ width: 100, height: 50 }} />

5.3 响应式布局

import { Dimensions } from 'react-native';

const { width, height } = Dimensions.get('window');

<View style={{ width: width * 0.8 }} />

六、手写简化版 React Native

6.1 核心思路

1. 解析 JSX 生成虚拟 DOM 2. 遍历虚拟 DOM,生成 Native 指令 3. 通过 Bridge 发送给 Native 4. Native 创建真实 UI

6.2 虚拟 DOM 转指令

function renderToNative(vdom, parentId = 0) {
  const viewId = generateId();
  const instructions = [];

  // 创建视图指令
  instructions.push({
    type: 'createView',
    viewId,
    viewType: vdom.type,  // 'View', 'Text' 等
    parentId,
    props: vdom.props
  });

  // 递归处理子节点
  if (vdom.children) {
    vdom.children.forEach(child => {
      if (typeof child === 'string') {
        // 文本节点
        instructions.push({
          type: 'updateText',
          viewId,
          text: child
        });
      } else {
        instructions.push(...renderToNative(child, viewId));
      }
    });
  }

  return instructions;
}

6.3 Bridge 实现

class Bridge {
  constructor() {
    this.queue = [];
  }

  // JS 调用 Native
  callNative(module, method, args) {
    this.queue.push({ module, method, args });
    this.flush();
  }

  // 批量发送
  flush() {
    if (this.queue.length === 0) return;

    const batch = this.queue.splice(0);
    // 实际会调用 Native 的 C++ 接口
    window.__nativeBridge.processBatch(JSON.stringify(batch));
  }

  // Native 调用 JS
  invokeCallback(callbackId, args) {
    const callback = this.callbacks[callbackId];
    if (callback) callback(...args);
  }
}

6.4 Native 端处理(伪代码)

// iOS 端
class NativeBridge {
  func processBatch(_ json: String) {
    let batch = JSON.parse(json)
    
    for instruction in batch {
      switch instruction.type {
      case "createView":
        let view = createView(instruction.viewType)
        view.tag = instruction.viewId
        applyProps(view, instruction.props)
        parentView.addSubview(view)
        
      case "updateText":
        let label = viewRegistry[instruction.viewId] as! UILabel
        label.text = instruction.text
      }
    }
  }
}

6.5 完整示例

// 1. JSX
const App = () => (
  <View style={{ flex: 1 }}>
    <Text>Hello</Text>
  </View>
);

// 2. 转虚拟 DOM
const vdom = {
  type: 'View',
  props: { style: { flex: 1 } },
  children: [
    { type: 'Text', props: {}, children: ['Hello'] }
  ]
};

// 3. 生成指令
const instructions = renderToNative(vdom);
// [
//   { type: 'createView', viewId: 1, viewType: 'View', props: {...} },
//   { type: 'createView', viewId: 2, viewType: 'Text', parentId: 1 },
//   { type: 'updateText', viewId: 2, text: 'Hello' }
// ]

// 4. 发送给 Native
bridge.callNative('UIManager', 'createView', instructions);

七、常用库与生态

7.1 导航

npm install @react-navigation/native @react-navigation/stack
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';

const Stack = createStackNavigator();

function App() {
  return (
    <NavigationContainer>
      <Stack.Navigator>
        <Stack.Screen name="Home" component={HomeScreen} />
        <Stack.Screen name="Detail" component={DetailScreen} />
      </Stack.Navigator>
    </NavigationContainer>
  );
}

7.2 状态管理

npm install zustand
import create from 'zustand';

const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 }))
}));

function Counter() {
  const { count, increment } = useStore();
  return <Text onPress={increment}>{count}</Text>;
}

7.3 网络请求

fetch('https://api.example.com/data')
  .then(res => res.json())
  .then(data => console.log(data));

// 或使用 axios
import axios from 'axios';
const { data } = await axios.get('/api/data');

八、性能优化

8.1 避免不必要的渲染

import { memo } from 'react';

const ListItem = memo(({ item }) => (
  <Text>{item.name}</Text>
));

8.2 使用 FlatList 而非 ScrollView

// ❌ 差
<ScrollView>
  {data.map(item => <Item key={item.id} />)}
</ScrollView>

// ✅ 好
<FlatList
  data={data}
  renderItem={({ item }) => <Item item={item} />}
/>

8.3 图片优化

<Image
  source={{ uri: url }}
  style={{ width: 200, height: 200 }}
  resizeMode="cover"
  // 启用缓存
  cache="force-cache"
/>

九、调试技巧

9.1 开发者菜单

模拟器中按 Cmd + D(iOS)或 Cmd + M(Android)打开菜单,可以:

- Reload:重新加载 - Debug:打开 Chrome DevTools - Show Inspector:查看元素

9.2 日志

console.log('普通日志');
console.warn('警告');
console.error('错误');

9.3 Flipper

Facebook 官方调试工具,支持网络、布局、日志等:

brew install flipper

十、打包发布

10.1 iOS

# 1. 打开 Xcode
open ios/MyApp.xcworkspace

# 2. 选择 Generic iOS Device
# 3. Product -> Archive
# 4. 上传到 App Store Connect

10.2 Android

# 1. 生成签名密钥
keytool -genkey -v -keystore my-release-key.keystore -alias my-key-alias -keyalg RSA -keysize 2048 -validity 10000

# 2. 配置 android/gradle.properties
MYAPP_RELEASE_STORE_FILE=my-release-key.keystore
MYAPP_RELEASE_KEY_ALIAS=my-key-alias
MYAPP_RELEASE_STORE_PASSWORD=***
MYAPP_RELEASE_KEY_PASSWORD=***

# 3. 打包
cd android
./gradlew assembleRelease

# 4. APK 在 android/app/build/outputs/apk/release/

总结

React Native 让你用 JavaScript 写原生应用,核心原理:

- JS 层运行 React 代码,生成虚拟 DOM - Bridge 传递 JSON 指令给 Native - Native 层创建真实 UI 组件

关键要点:

- 使用 View、Text、Image 等基础组件 - Flexbox 布局,默认 flexDirection: column - FlatList 处理长列表 - React Navigation 做路由 - 通过 NativeModules 调用原生能力

适合快速开发跨平台应用,性能接近原生。

vue3 watch解析

作者 哇哇哇哇
2026年3月11日 10:45

要理解 watch 的底层逻辑,我们需要从 Vue3 的响应式系统watch 核心实现源码入手。以下解析基于 Vue3 源码(packages/runtime-core/src/apiWatch.ts),聚焦核心逻辑,剔除边缘分支。

一、先理清核心依赖

watch 的实现完全依赖 Vue3 的响应式核心:

  • Effect 系统watch 本质是一个带调度器的副作用函数(ReactiveEffect)
  • 依赖收集:通过 track 收集监听目标的依赖,数据变化时通过 trigger 触发 Effect
  • 调度器(scheduler) :控制 Effect 执行时机(如 flush: pre/post/sync)、防抖(默认只执行最后一次)

二、watch 核心入口函数

Vue3 暴露的 watch 函数是一个封装后的入口,核心定义在 apiWatch.ts 中,简化后的核心逻辑如下:

// 核心入口:用户调用的 watch 函数
export function watch<T = any>(
  source: WatchSource<T> | WatchSource<T>[], // 监听目标(ref/reactive/函数等)
  cb: WatchCallback<T>, // 用户传入的回调函数
  options?: WatchOptions // 配置项(immediate/deep/flush 等)
): WatchStopHandle { // 返回停止监听的函数
  // 标准化配置项(设置默认值:flush: 'pre'、deep: false、immediate: false)
  const resolvedOptions = resolveWatchOptions(options)
  // 核心:创建 watcher 实例
  const instance = getCurrentInstance() // 获取当前组件实例
  const effect = doWatch( // 真正实现 watch 逻辑的核心函数
    source,
    cb,
    resolvedOptions,
    instance
  )
  // 返回停止监听的函数(本质是停止 effect)
  return () => {
    effect.stop()
  }
}

核心结论:watch 函数只是一层封装,真正的逻辑在 doWatch 中,最终返回的 “停止函数” 本质是停止内部的 ReactiveEffect

三、doWatch:watch 的核心实现

doWatchwatch 的灵魂函数,负责:

  1. 标准化监听目标(统一处理单个 / 多个、ref/reactive/ 函数等)
  2. 创建 ReactiveEffect 并收集依赖
  3. 处理调度逻辑(时机、防抖、immediate)
  4. 处理 deep 深度监听

1. 第一步:标准化监听目标

首先把用户传入的各种监听目标(ref、reactive、数组、函数)统一为获取值的函数(getter) ,简化后的核心代码:

function doWatch(
  source: WatchSource | WatchSource[] | WatchCallback,
  cb: WatchCallback | null,
  options: WatchOptions,
  instance: ComponentInternalInstance | null
) {
  // 1. 标准化监听目标为 getter 函数(核心:统一不同类型的 source)
  let getter: () => any
  const isMultiSource = isArray(source) // 是否监听多个目标

  if (isMultiSource) {
    // 监听多个目标:getter 返回所有目标的值组成的数组
    getter = () => source.map(s => normalizeWatchSource(s))
  } else if (isRef(source)) {
    // 监听 ref:getter 返回 ref.value
    getter = () => source.value
  } else if (isReactive(source)) {
    // 监听 reactive:开启深度监听 + getter 返回自身
    getter = () => source
    options.deep = true // reactive 强制开启 deep(用户传 false 也无效)
  } else if (isFunction(source)) {
    // 监听函数(如 () => user.age):getter 直接用这个函数
    getter = () => source.call(instance && instance.proxy, instance)
  } else {
    // 无效目标:getter 为空,不监听
    getter = NOOP
    warn(`无效的 watch 监听目标:${source}`)
  }

  // 2. 处理 deep 深度监听:重写 getter,递归遍历对象收集所有依赖
  if (options.deep) {
    const baseGetter = getter
    // 重写 getter:调用 traverse 递归遍历值,触发所有深层属性的依赖收集
    getter = () => traverse(baseGetter())
  }

  // ... 后续逻辑见下文
}

// 辅助函数:标准化单个监听源
function normalizeWatchSource(source: WatchSource): any {
  if (isRef(source)) {
    return source.value
  } else if (isReactive(source)) {
    return source
  } else if (isFunction(source)) {
    return source()
  } else {
    return NOOP
  }
}

// 核心:深度遍历对象,触发所有属性的 track(依赖收集)
function traverse(value: unknown, seen = new Set()) {
  if (!isObject(value) || seen.has(value)) {
    return value
  }
  seen.add(value)
  // 遍历对象/数组的所有属性,递归触发访问(收集依赖)
  if (isArray(value)) {
    for (let i = 0; i < value.length; i++) {
      traverse(value[i], seen)
    }
  } else if (isPlainObject(value)) {
    for (const key in value) {
      traverse((value as any)[key], seen)
    }
  }
  return value
}

关键解释

  • 无论用户传入什么类型的监听目标,最终都会被转为一个 getter 函数,watch 内部只需要执行这个函数就能拿到监听值;
  • deep: true 的本质是调用 traverse 递归遍历对象的所有属性,触发每个属性的 track(依赖收集),这样哪怕是深层属性变化,也能触发 watch;
  • 监听 reactive 对象时,Vue 会强制开启 deep(因为 reactive 本身是深层响应式的)。

2. 第二步:创建 ReactiveEffect 并处理调度

这是 doWatch 的核心,创建副作用函数并关联调度器,简化后的代码:

function doWatch(
  source: WatchSource | WatchSource[] | WatchCallback,
  cb: WatchCallback | null,
  options: WatchOptions,
  instance: ComponentInternalInstance | null
) {
  // ... 省略第一步:标准化 getter

  // 2. 定义副作用函数的调度器(控制回调执行时机/防抖)
  let scheduler: EffectScheduler
  const { flush } = options

  if (flush === 'sync') {
    // 同步执行:数据变化立即触发回调
    scheduler = () => run(cb)
  } else if (flush === 'post') {
    // 组件更新后执行:加入 post 队列(比如 watch 中访问更新后的 DOM)
    scheduler = () => queuePostEffect(run, instance && instance.suspense)
  } else {
    // 默认 flush: 'pre':组件更新前执行
    scheduler = () => {
      if (!instance || instance.isMounted) {
        queuePreEffect(run, instance)
      } else {
        // 组件未挂载时直接执行
        run()
      }
    }
  }

  // 3. 创建 ReactiveEffect(核心:依赖收集 + 触发执行)
  // effect 执行时会调用 getter,从而收集依赖
  const effect = new ReactiveEffect(getter, scheduler)

  // 4. 处理 immediate:立即执行一次回调
  if (options.immediate) {
    // 立即执行回调(此时 oldValue 为 undefined)
    run()
  } else {
    // 非 immediate:先执行一次 effect(仅收集依赖,不执行回调)
    effect.run()
  }

  // 5. 定义真正执行回调的 run 函数
  let oldValue = isMultiSource ? [] : INITIAL_WATCHER_VALUE
  function run() {
    if (effect.active) {
      // 执行 getter 获取新值(触发依赖收集)
      const newValue = effect.run()
      // 对比新旧值,变化则执行回调
      if (
        deep ||
        isMultiSource
          ? newValue.some((v, i) => hasChanged(v, oldValue[i]))
          : hasChanged(newValue, oldValue)
      ) {
        // 执行用户传入的回调:cb(newVal, oldVal)
        cb(newValue, oldValue === INITIAL_WATCHER_VALUE ? undefined : oldValue)
        // 更新旧值
        oldValue = newValue
      }
    }
  }

  // 6. 返回停止监听的函数(停止 effect,清空依赖)
  return effect
}

核心逻辑拆解(新手友好版)

  1. 创建 EffectReactiveEffect(getter, scheduler) 是核心,getter 负责获取监听值并收集依赖,scheduler 负责控制回调执行时机;
  2. 依赖收集:首次执行 effect.run() 时,会调用 getter,访问监听目标的响应式数据,触发 track 收集依赖(把当前 Effect 关联到响应式数据上);
  3. 触发执行:当监听的响应式数据变化时,会调用 trigger,找到关联的 Effect,执行其 scheduler,最终调用 run 函数;
  4. 回调执行run 函数中对比新旧值,只有值变化时才执行用户传入的 cb(回调),并更新旧值。

四、关键细节补充

1. 为什么 reactive 监听拿不到 oldVal?

源码中,oldValue 是通过 getter 获取的,而 reactive 对象是引用类型oldValuenewValue 指向同一个对象,所以无法拿到真正的旧值:

// 监听 reactive 时,getter 返回的是对象本身(引用)
getter = () => source // source 是 reactive 对象
// 所以 oldValue = newValue = 同一个对象引用

解决方案:如果需要旧值,要手动深拷贝,或监听具体属性(() => user.age)。

2. watch 的防抖逻辑(默认行为)

Vue3 的 watch 默认是防抖的:如果短时间内数据多次变化,只会执行最后一次回调。核心原因:scheduler 会把 run 函数加入队列,队列会做去重 + 防抖,确保同一 watch 只执行最后一次。

3. flush 执行时机的底层逻辑

  • pre(默认):组件更新前执行 → 加入 preFlushQueue,在组件 patch 前执行;
  • post:组件更新后执行 → 加入 postFlushQueue,在组件 patch 后执行(可访问更新后的 DOM);
  • sync:同步执行 → 不加入队列,数据变化立即执行(性能开销大,慎用)。

总结

  1. watch 本质是带调度器的 ReactiveEffect,依赖 Vue3 的响应式系统(track/trigger)实现监听;
  2. 核心流程:标准化监听目标 → 创建 Effect 收集依赖 → 数据变化触发 scheduler → 对比新旧值执行回调;
  3. 关键特性:deeptraverse 深度遍历收集依赖,immediate 靠首次执行 runflush 靠队列控制执行时机,reactive 监听无 oldVal 是因为引用类型指向同一对象。
❌
❌