普通视图

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

刷 LeetCode 看不懂题解?我做了一个能"播放"算法的开源可视化平台

2026年3月11日 22:49

凌晨一点,屏幕上还亮着那道动态规划。题解翻来覆去看了三遍——每个字都认识,连起来就是不知道它在干什么。状态怎么转移的?这个 dp[i][j] 到底是从哪来的?脑子里全是问号。

说实话,我也是这么过来的。 不是那种一眼就能看穿算法本质的大佬,每次刷题都卡在"题解看懂了字,却没真正看懂思路"这一步。后来我尝试用 AI 帮自己拆解推理过程,发现有帮助,但光靠文字还是不够——我需要亲眼看到每一步到底发生了什么。

于是就有了 Bare Algo(朴素算法)。这是一个开源的交互式算法可视化平台,把算法的每一步推演变成可以播放、暂停、步进、拖拽的逐帧动画。DP 状态怎么填表、BFS 怎么一层层扩散、回溯在哪里剪枝——不用脑补了,按下播放键,看着它一帧帧跑给你看。


🚀 立即体验(欢迎 Star ⭐)

👉 在线访问barealgo.com
GitHub 仓库Harvey-Andrew/bare-algorithm


为什么它值得关注?

Bare Algo 覆盖了 23 个算法分类——数组、链表、二叉树、图论、动态规划、位运算、单调栈、线段树、并查集……几乎满足从入门到进阶的全部刷题需求。更重要的是,它不仅教你"怎么做",还通过真实业务 Demo(如账号合并检测、课程依赖分析)展示算法在实际场景中的应用,帮助你真正将算法思维内化为工程能力。

✨ 三大核心亮点

  • 🎬 逐帧可视化播放器 — 支持多倍速(0.5x–4x)、进度拖拽、代码同步高亮,播放到哪一帧,对应代码行自动亮起,帧消息实时更新,彻底告别"不知道执行到哪了"。

  • 🔀 多解法一键对比 — 同一道题(如岛屿数量)支持 DFS / BFS / 并查集等多种模式一键切换,直观对比不同算法的思路差异与时空复杂度表现。

  • 📱 全平台响应式体验 — 移动端竖屏悬浮条 + 抽屉、桌面端 7:5 双栏布局,配合自动缩放容器 ScaleToFit,无论手机还是大屏都能流畅使用。

🗺️ 项目架构一览

graph LR
    A["📝 AlgorithmConfig<br/>算法配置"] --> B["⚙️ generateFrames()<br/>帧生成函数"]
    B --> C["🎞️ Frame[]<br/>快照序列"]
    C --> D["🖥️ GenericVisualizer<br/>通用渲染引擎"]
    D --> E["▶️ 交互式播放器<br/>播放/暂停/步进/拖拽"]

    style A fill:#4f46e5,color:#fff,stroke:none
    style B fill:#7c3aed,color:#fff,stroke:none
    style C fill:#a855f7,color:#fff,stroke:none
    style D fill:#c084fc,color:#fff,stroke:none
    style E fill:#e879f9,color:#fff,stroke:none

每个算法都是一个自包含模块,只需定义配置、帧生成逻辑和渲染组件,即可无缝接入平台——这也意味着任何人都可以轻松贡献新题目。

如果你也认同"看动画学算法"这件事,欢迎提 PR 贡献新题目,或在讨论区分享你的解题思路。 你的每一次参与,都在帮助更多人跨过算法这道门槛。

"算法不该只是面试的门槛,它应该被所有人看见和理解。"

面试官:大模型是怎么调用工具的呢 ?

作者 Cobyte
2026年3月11日 21:49

前言

首先我们开发所谓 Agent,其实就写一堆工具给大模型调用,所以要学习 Agent 开发就得知道大模型是怎么调用工具的。

大模型本质上是一个文本生成模型,它是无法直接访问外部数据和操作外部系统的,如获取实时信息、读取文件等。但如今,主流的代码智能体(如 Claude Code)皆已实现对文件系统的直接操作,使其能够自主完成文件读写、项目构建等复杂任务。

它们是怎么做到的呢?

为了解决大模型无法访问外部数据这个局限,OpenAI 提出了 Function Calling(工具调用)机制,后来 Anthropic、Google 等也推出了类似的功能,统称为 Tool Use

其核心思想是:大模型根据用户的输入判断如果要调用工具时,就让大模型在生成回答时,能够输出一个结构化的请求,要求外部系统执行某个函数,然后将函数执行的结果返回给模型,大模型再基于结果生成最终回答。

本质上就是 Tools + LLM,也就是工具加大模型。

同理地所谓 AI Agent 开发可以简单理解为编写各种工具函数让大模型调用。当然 AI Agent 除了需要编写工具,还需要规划机制 + 记忆系统 + 安全控制 + ……

下面我们详细讲解这个机制的实现原理和流程。

注意:本文是使用 Python 语言,所以想了解 Python 入门以及大模型的基础操作知识,可以回看我的上一篇文章:《AI全栈实战:使用 Python+LangChain+Vue3 构建一个 LLM 聊天应用》,Python 是一门非常容易上手的语言,建议大家在 AI 全栈时代掌握它。可以主要看看 LLM 和 OpenAI 接口快速入门 章节部分。

我是 Cobyte,欢迎添加 v: icobyte,学习 AI 全栈。

LLM 和 OpenAI 接口快速入门

首先我们快手回顾一下上一篇文章中的基础知识点,LLM 和 OpenAI 接口快速入门如下:

import os
from openai import OpenAI
from dotenv import load_dotenv
# 初始化客户端
client = OpenAI(
    api_key=os.getenv("DEEPSEEK_API_KEY"), # 身份验证凭证,确保你有权访问 API
    base_url="https://api.deepseek.com" # 将请求重定向到 DeepSeek 的服务器(而非 OpenAI)
)
# 构建聊天请求
response = client.chat.completions.create(
  model="deepseek-chat", # 指定模型版本
  temperature=0.5,
  messages=[   # 对话消息数组
      {"role": "user", "content": "你是谁?"}
  ]
)
# 打印结果 
print(response.choices[0].message.content.strip())

其中比较重要的知识点是消息类型,主要有:system (系统角色)、user (用户角色)、assistant (助手角色)。除了上述的角色外有一种高级角色,也就是 function/tool (函数/工具角色)。

定义工具

根据上文我们知道大模型本身不具备执行代码的能力,但它可以通过结构化输出“请求”调用外部工具,其实就是返回的数据中带有一个 tool_calls 的参数。我们开发者需要做的事情就是提前定义好工具的描述(函数名、参数、功能)给到大模型,让大模型知道有哪些工具可以调用。

工具定义必须符合 OpenAI 的规范(其他厂商基本兼容),每个工具包含以下要素:

  • type:目前主要是 "function"

  • function:函数的具体描述:

    • name:函数名称(在代码中唯一标识)。
    • description:函数功能说明,帮助模型理解何时调用。
    • parameters:参数定义,采用 JSON Schema 格式,包括参数类型、是否必需、枚举值等。
# 工具定义
tools = [
    {
        "type": "function",
        "function": {
            "name": "read_file",
            "description": "读取文本文件内容。",
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {
                        "type": "string",
                        "description": "要读取的文件路径"
                    },
                    "encoding": {
                        "type": "string",
                        "enum": ["utf-8", "gbk"],
                        "description": "文件编码格式"
                    }
                },
                "required": ["path"]
            }
        }
    }
]

上述代码就定义了一个读取文本内容的工具,然后大模型就会根据用户的输入进行判断是否需要调用读取文本内容的工具。注意上面只是定义,真正读取文本内容的工具需要我们自己实现。

发起请求,附带工具描述

定义了工具之后在调用模型 API 时,通过 tools 参数传入上述工具定义,并设置 tool_choice="auto" 让大模型自主决定是否调用。

response = client.chat.completions.create(
    model="deepseek-chat",
    temperature=0.5,
    messages=messages,
    tools=tools, # 通过 `tools` 参数传入上述工具定义
    tool_choice="auto"  # "auto":大模型自主决定是否调用工具(默认)、"none":禁止调用工具
)

一般执行的结果数据结构如下:

image.png

我们看到在 message 下有一个 tool_calls 的参数中返回了数据,这代表大模型需要调用外部工具。

解析模型响应,提取工具调用请求

通过前面我们知道大模型返回的 message 中若包含 tool_calls 字段,则表示需要调用工具。tool_calls 是一个列表,每个元素包含:

  • id:工具调用唯一标识。
  • function.name:要调用的函数名。
  • function.arguments:JSON 格式的参数。

接下来,我们就需要根据 tool_calls 字段去判断需要执行哪些工具:

msg = response.choices[0].message
if msg.tool_calls:
    for tool_call in msg.tool_calls:
        if tool_call.function.name == "read_file":
            args = json.loads(tool_call.function.arguments)
            result = file_tool.execute(**args)   # 执行本地函数

这个 read_file 只是一个判断标记,代表需要执行读取文件,这个标记是在定义工具的时候我们自己设置的。file_tool 则是一个本地函数,需要我们编写的。

读取文件的函数如下:

from pathlib import Path

class ReadFileTool:
    """读取文件内容"""
    def execute(self, path: str, encoding: str = "utf-8") -> str:
        try:
            file_path = Path(path).expanduser()
            if not file_path.exists():
                return f"❌ 文件不存在: {path}"
            return file_path.read_text(encoding="utf-8")
        except Exception as e:
            return f"❌ 读取失败: {str(e)}"
        
# 初始化工具实例
file_tool = ReadFileTool()

执行本地函数,并返回结果

我们根据函数名调用对应的本地函数,获得结果后,构造一条 tool 角色的消息,包含 tool_call_id 和 content(上述执行本地读取文件函数的结果 result),追加到对话历史中,重新发给大模型。

messages.append({
    "role": "tool",
    "tool_call_id": tool_call.id,
    "content": result
})

二次请求:让模型基于工具结果生成最终回答

将更新后的消息历史再次发送给大模型,不再需要传入我们的定义的工具(tools),大模型会结合工具执行结果,生成连贯的自然语言回答。

second_response = client.chat.completions.create(
    model="deepseek-chat",
    messages=messages
)
final_answer = second_response.choices[0].message.content

完整代码示例:文件读取助手

以下是一个完整的可运行示例,展示了从工具定义、调用、执行到生成最终回答的全过程。

import os
import json
from pathlib import Path
from dotenv import load_dotenv
from openai import OpenAI

# 加载环境变量(如 DEEPSEEK_API_KEY)
load_dotenv()

# ---------- 工具定义 ----------
tools = [
    {
        "type": "function",
        "function": {
            "name": "read_file",
            "description": "读取文本文件内容。",
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {"type": "string", "description": "要读取的文件路径"},
                    "encoding": {"type": "string", "enum": ["utf-8", "gbk"], "description": "文件编码格式"}
                },
                "required": ["path"]
            }
        }
    }
]

# ---------- 工具实现 ----------
class ReadFileTool:
    def execute(self, path: str, encoding: str = "utf-8") -> str:
        try:
            file_path = Path(path).expanduser()
            if not file_path.exists():
                return f"❌ 文件不存在: {path}"
            return file_path.read_text(encoding=encoding)
        except Exception as e:
            return f"❌ 读取失败: {str(e)}"

file_tool = ReadFileTool()

# ---------- 初始化客户端 ----------
client = OpenAI(
    api_key=os.getenv("DEEPSEEK_API_KEY"),
    base_url="https://api.deepseek.com"
)

# ---------- 构建对话 ----------
messages = [
    {"role": "system", "content": "你是一个文件读取助手,必要时可以调用工具帮助用户读取文件内容。"}
]
user_input = "帮我读一下 file.txt"
messages.append({"role": "user", "content": user_input})
print(f"👤 用户: {user_input}\n")

# 第一步:请求模型判断是否调用工具
response = client.chat.completions.create(
    model="deepseek-chat",
    messages=messages,
    tools=tools,
    tool_choice="auto"
)
msg = response.choices[0].message
messages.append(msg.model_dump())

# 第二步:处理工具调用
if msg.tool_calls:
    for tool_call in msg.tool_calls:
        if tool_call.function.name == "read_file":
            args = json.loads(tool_call.function.arguments)
            result = file_tool.execute(**args)
            print(f"🔧 调用工具: {tool_call.function.name}, 参数: {args}")
            print(f"✅ 工具执行结果:\n{result}\n")
            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "content": result
            })
    
    # 第三步:二次请求,生成最终回答
    second_response = client.chat.completions.create(
        model="deepseek-chat",
        messages=messages
    )
    final_msg = second_response.choices[0].message
    print(f"💬 助手: {final_msg.content}")
else:
    print(f"💬 助手: {msg.content}")

最后执行的内容如下:

image.png

至此我们可以总结一下大模型调用工具的原理是什么了。大模型调用工具的工作流程如下:

  • 定义工具(用 JSON Schema 描述函数名称、功能、参数)。
  • 将工具描述附加到模型请求中。
  • 模型根据用户输入判断是否需要调用工具,以及调用哪个工具和参数。
  • 解析模型的响应(tool_calls)。
  • 执行本地函数,获得结果。
  • 将结果作为新的消息(tool role)发送给模型。
  • 模型结合工具结果生成最终回答。

总结

大模型本身不具备执行代码的能力,但它可以通过结构化输出“请求”调用外部工具。开发者提前定义好工具的描述(函数名、参数、功能)给到大模型,这样大模型在生成回复时,就会判断如果需要工具辅助,就会返回一个特殊的 tool_calls 字段,指明要调用的函数和参数。随后开发者也根据大模型返回的 tool_calls 字段判断需要执行哪些本地函数,并将执行结果以 tool 角色的消息重新发给模型,模型再整合生成最终答案。

这种机制极大地扩展了大模型的能力边界,使其能够与外部系统交互,完成实时查询、文件操作、数据库访问等任务,真正成为智能应用的“大脑”。

我是 Cobyte,欢迎添加 v: icobyte,学习 AI 全栈。

【自荐】精美小巧的现代化终端文件管理器

2026年3月11日 21:43

FileMan

精美小巧的现代化终端文件管理器

基于 Go + Bubble Tea 构建, 可用于Linux服务器快速管理文件(只需要通过SSH-命令行)。

fileman-intro.gif


✨ 功能特性

  • 双面板界面 — 上下双面板设计,Tab 键快速切换焦点
  • 实时预览 — 文本文件内容预览,自动识别文件类型
  • 文件操作 — 利用双面板可将当前文件快速复制/移动到另一面板上、还支持删除、重命名、新建文件/目录
  • 快速搜索 — 实时过滤当前目录文件
  • 内置编辑 — 文本文件内置编辑器
  • 鼠标支持 — 点击选择、滚动浏览
  • 自适应布局 — 自动适配终端窗口大小

📦 安装

一键安装(推荐)

curl -fsSL https://raw.githubusercontent.com/Joehaivo/fileman/main/install.sh | bash

🚀 使用

fm

查看版本:

fm --version

⌨️ 快捷键

导航

按键 功能
/ 光标上下移动
PgUp / PgDn 翻页
Home / End 跳转顶部/底部
返回上一级目录
/ Enter 进入目录或编辑文件
Tab 切换上下面板

文件操作

按键 功能
F1 重命名
F2 复制到另一面板
F3 移动到另一面板
F4 新建目录
F5 新建文件
F6 外部编辑器打开
F7 显示/隐藏文件
F8 设置
F9 退出
Del 删除
/ 搜索
Esc 取消搜索/弹窗

编辑模式

按键 功能
移动光标
F1 保存
F2 退出编辑
Home / End 行首/行尾
PgUp / PgDn 翻页

源码编译

git clone https://github.com/Joehaivo/fileman.git
cd fileman
go build -ldflags "-s -w -X main.version=$(git describe --tags --always)" -o fm .

🛠️ 技术栈

📄 许可证

MIT

GitHub地址

昨天 — 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小技巧?评论区分享一下~


❌
❌