普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月25日技术

看完就懂 useLayoutEffect

作者 ssshooter
2026年2月25日 11:27

差异

useLayoutEffect 与大家熟悉的 useEffect 语法完全一致,从产生副作用的角度上看,功能上也是一样的,唯一差别就是调用时机。

useEffect 会在画面绘制后异步执行,而 useLayoutEffect 会在画面绘制前同步执行。为了讲清楚这个时机的具体区别,得先复习一下浏览器渲染页面的过程。

浏览器渲染流程

注意最后 js 运行的那一块,useLayoutEffect 和 useEffect 就分别位于 paint 之前和之后。

执行的顺序是:

  • useLayoutEffect
  • 画面绘制
  • 下一轮 js 运行 useEffect

顺便我们也能看出来,useLayoutEffect 之所以叫 useLayoutEffect 就是因为它的运行时间点沾着 layout。

使用场景

知道这两个函数的区别,我们还需要知道,到底什么时候用 useLayoutEffect 呢?

答案是,如果进行了 DOM 操作,且这个 DOM 操作会引起回流(reflow)、重绘(repaint),那么就应该使用 useLayoutEffect,例如:

function Tooltip() {
  const ref = useRef<HTMLDivElement>(null);
  const [pos, setPos] = useState({ top: 0, left: 0 });

  // 如果用 useEffect,这里会先渲染一次默认位置,再跳到正确位置 → 可能会造成闪烁
  useLayoutEffect(() => {
    const rect = ref.current!.getBoundingClientRect();
    setPos({
      top: rect.top + rect.height + 8,
      left: rect.left + rect.width / 2,
    });
  }, []);

  return (
    <>
      <div ref={ref}>hover me</div>
      <div style={{ position: 'fixed', top: pos.top, left: pos.left }}>tooltip</div>
    </>
  );
}

因为如果你用 useEffect,在浏览器绘制之后又要重新跑一遍 reflow、repaint,用户可能会看到画面“闪烁”。

如果你有代码洁癖,想要一个最优解,那么你确实该按上面说的这么做,但是事实上在这个场景使用 useEffect 可能也不会有很明显的问题。

其实即使是官网的例子里,作为反模式使用 useEffect,用户也不会感知到明显的“闪烁”,因为两次渲染的时间其实是快到肉眼看不清的,为了确定真的存在区别你还要故意写个 while 循环卡一下主进程。

既然一般情况下无论 useEffect 和 useLayoutEffect 都不会有明显区别,那么我觉得,作为一个有专业素养的 React 开发者,应该优先使用 useEffect,只在 reflow、repaint 造成闪烁的场景下,使用 useLayoutEffect。

当然,useEffect本身也不能乱用,之前在useEffect 清除计划里已经讲述了它的必要使用场景。

总结

useLayoutEffect 适用于“需要在浏览器绘制前同步完成的副作用”,典型场景是读取布局信息并立即修改 DOM,避免视觉闪动。

但因其会阻塞浏览器绘制,影响性能,因此不应滥用。在绝大多数副作用场景下,优先使用 useEffect,只有在感知到闪动才改为使用 useLayoutEffect。

OpenClaw Memory 模块完整分析

作者 AngelPP
2026年2月25日 11:24

OpenClaw Memory 模块完整分析

一、项目背景

OpenClaw 是一个本地优先的个人 AI 助手,支持多种消息通道(WhatsApp、Telegram、Slack 等)。Memory 模块为 AI Agent 提供语义记忆搜索能力——Agent 可以在 Markdown 记忆文件和历史会话中进行向量 + 关键词的混合检索。

二、整体架构

┌─────────────────────────────────────────────────┐
│                  入口层 (index.ts)               │
│  getMemorySearchManager() → 选择后端策略          │
├────────────┬────────────────────────┬───────────┤
│  QMD 后端   │  FallbackManager      │ Builtin 后端│
│ (外部CLI)   │  (主备自动切换)        │ (核心实现)   │
├─────────────┴───────────────────────┴───────────┤
│               MemoryIndexManager                │
│   ┌──────────┬──────────┬──────────┐            │
│   │ SyncOps  │Embedding │ Search   │            │
│   │(文件监听) │ Ops(索引) │(混合搜索) │             │
│   └──────────┴──────────┴──────────┘            │
├─────────────────────────────────────────────────┤
│            存储层: SQLite + FTS5 + sqlite-vec    │
├─────────────────────────────────────────────────┤
│  Embedding Providers: OpenAI|Gemini|Voyage|     │
│  Mistral|Local(node-llama-cpp)                  │
└──────────────────────────────────────────────────┘

三、核心设计详解

1. 统一接口 (MemorySearchManager)

export type MemorySource = "memory" | "sessions";

export type MemorySearchResult = {
  path: string;
  startLine: number;
  endLine: number;
  score: number;
  snippet: string;
  source: MemorySource;
  citation?: string;
};
// ...
export interface MemorySearchManager {
  search(query, opts?): Promise<MemorySearchResult[]>;
  readFile(params): Promise<{ text: string; path: string }>;
  status(): MemoryProviderStatus;
  sync?(params?): Promise<void>;
  probeEmbeddingAvailability(): Promise<MemoryEmbeddingProbeResult>;
  probeVectorAvailability(): Promise<boolean>;
  close?(): Promise<void>;
}

这是整个模块的核心抽象——无论底层用什么后端(builtin SQLite 还是外部 QMD CLI),对上层暴露统一接口。

2. 后端策略选择 (search-manager.ts)

export async function getMemorySearchManager(params: {
  cfg: OpenClawConfig;
  agentId: string;
  purpose?: "default" | "status";
}): Promise<MemorySearchManagerResult> {
  const resolved = resolveMemoryBackendConfig(params);
  if (resolved.backend === "qmd" && resolved.qmd) {
    // ... 尝试 QMD 后端,失败则 fallback 到 builtin
    const wrapper = new FallbackMemoryManager({
      primary,
      fallbackFactory: async () => {
        const { MemoryIndexManager } = await import("./manager.js");
        return await MemoryIndexManager.get(params);
      },
    }, () => QMD_MANAGER_CACHE.delete(cacheKey));
    // ...
  }
  // 默认使用 builtin
  const manager = await MemoryIndexManager.get(params);
  return { manager };
}

设计亮点:

  • 策略模式 + 懒加载:通过 dynamic import 延迟加载后端实现
  • FallbackMemoryManager:代理模式,主后端失败自动切换到备用后端,对上层透明
  • 缓存驱逐:失败时自动从缓存中移除,下次请求可以重试

3. 混合搜索引擎 (hybrid.ts)

export async function mergeHybridResults(params: {
  vector: HybridVectorResult[];
  keyword: HybridKeywordResult[];
  vectorWeight: number;
  textWeight: number;
  mmr?: Partial<MMRConfig>;
  temporalDecay?: Partial<TemporalDecayConfig>;
}): Promise<Array<{ path; startLine; endLine; score; snippet; source }>> {
  // 1. 按 ID 合并向量和关键词结果
  // 2. 加权融合分数: score = vectorWeight * vectorScore + textWeight * textScore
  // 3. 时间衰减: 旧记忆分数降低
  // 4. MMR 重排序: 增加结果多样性
}

这是搜索的核心——三层融合管线:

阶段 算法 作用
加权融合 score = w_v × vectorScore + w_t × textScore 平衡语义相似度和关键词匹配
时间衰减 指数衰减 e^(-λ × age) 让近期记忆权重更高
MMR 重排序 λ × relevance - (1-λ) × max_similarity 增加结果多样性,避免重复

4. 时间衰减机制 (temporal-decay.ts)

export function toDecayLambda(halfLifeDays: number): number {
  return Math.LN2 / halfLifeDays;
}

export function calculateTemporalDecayMultiplier(params: {
  ageInDays: number;
  halfLifeDays: number;
}): number {
  const lambda = toDecayLambda(params.halfLifeDays);
  return Math.exp(-lambda * clampedAge);
}

核心设计:

  • 日期命名文件 memory/2024-01-15.md 自动从文件名提取时间
  • 常青文件 MEMORY.md 和非日期命名的 memory/*.md 不衰减(核心知识)
  • fallback 到 mtime:无法从文件名解析日期时,使用文件修改时间

5. MMR 多样性重排序 (mmr.ts)

export function computeMMRScore(relevance: number, maxSimilarity: number, lambda: number): number {
  return lambda * relevance - (1 - lambda) * maxSimilarity;
}

使用 Jaccard 相似度(基于 token 集合的交集/并集)来衡量结果间的相似程度,避免返回大量重复内容。比起用向量余弦相似度做 MMR,Jaccard 更轻量且无需额外嵌入计算。

6. Markdown 分块策略 (internal.ts)

export function chunkMarkdown(
  content: string,
  chunking: { tokens: number; overlap: number },
): MemoryChunk[] {
  const maxChars = Math.max(32, chunking.tokens * 4);
  const overlapChars = Math.max(0, chunking.overlap * 4);
  // 按行扫描,达到 maxChars 时 flush
  // flush 后保留尾部 overlapChars 作为重叠区
}

设计要点:

  • token 估算tokens × 4 转为字符数(粗略但高效)
  • 滑动窗口重叠:chunk 之间有 overlap,避免语义在边界处被截断
  • 超长行切割:单行超过 maxChars 时自动分段
  • 每个 chunk 记录 startLine/endLine,支持精确引用

7. Embedding Provider 工厂 (embeddings.ts)

export async function createEmbeddingProvider(
  options: EmbeddingProviderOptions,
): Promise<EmbeddingProviderResult> {
  // auto 模式: local → openai → gemini → voyage → mistral
  // 指定模式: primary → fallback
  // 所有 API key 缺失: 返回 null provider (FTS-only mode)
}

三层降级策略:

  1. auto 模式:依次尝试 local → openai → gemini → voyage → mistral
  2. 指定 + fallback:用户指定的 provider 失败时切换到 fallback
  3. 全部失败 → FTS-only:纯关键词搜索,仍可用但质量降低

8. 数据库 Schema (memory-schema.ts)

// meta: 索引元信息 (model/provider/版本)
// files: 文件记录 (path, hash, mtime, source)
// chunks: 文本块 (id, path, text, embedding, model)
// embedding_cache: 嵌入缓存 (provider+model+hash → embedding)
// chunks_fts: FTS5 全文搜索虚拟表
// chunks_vec: sqlite-vec 向量搜索虚拟表

9. 同步机制 (manager-sync-ops.ts)

同步有多种触发方式:

触发方式 场景
watch chokidar 文件监听,debounce 后触发
session-start 新会话开始时预热
session-delta 会话文件增长超过阈值(字节/消息数)
search 搜索时如果 dirty 则先同步
interval 定时同步(可配置分钟数)

重建索引采用安全替换策略:先写入临时 DB,完成后原子交换,失败则回滚。

10. 实例缓存 + 单例

const INDEX_CACHE = new Map<string, MemoryIndexManager>();
static async get(params): Promise<MemoryIndexManager | null> {
  const key = `${agentId}:${workspaceDir}:${JSON.stringify(settings)}`;
  const existing = INDEX_CACHE.get(key);
  if (existing) return existing;
  // ... 创建新实例
  INDEX_CACHE.set(key, manager);
  return manager;
}

agentId + workspaceDir + settings 作为缓存 key,保证同一配置只有一个 Manager 实例,避免重复打开数据库和文件监听器。

四、参考价值总结

如果你想在自己的项目中实现类似的记忆/知识检索系统,这个模块有以下核心参考价值:

维度 设计模式 参考价值
接口抽象 MemorySearchManager 接口 将搜索、同步、状态查询统一抽象,后端可替换
混合搜索 Vector + BM25 加权融合 兼顾语义理解和精确匹配,比单纯向量搜索更鲁棒
结果优化 MMR + 时间衰减 解决结果重复和旧信息权重过高的问题
降级策略 Provider 三层 fallback + FTS-only 无 API key 也能用,极大提升了可用性
主备切换 FallbackMemoryManager 代理模式 QMD 后端失败自动切到 builtin,对上层透明
增量同步 hash 对比 + 文件监听 + delta 阈值 只重新索引变化的文件,会话增量基于字节/消息数阈值
安全重建 临时 DB → 原子交换 → 失败回滚 全量重建索引时不影响在线查询
嵌入缓存 SQLite embedding_cache 表 避免重复调用 API,重建索引时可复用历史嵌入
分块策略 行级滑动窗口 + overlap 保留行号信息,支持精确引用,overlap 防止语义断裂
实例管理 缓存 Map + 复合 key 避免重复实例,正确处理 close 和缓存驱逐

这套架构特别适合以下场景复用:

  1. RAG 系统——需要在本地文档上做语义搜索
  2. 知识库检索——混合搜索 + 时间衰减适合持续更新的知识
  3. Agent 工具——作为 AI Agent 的长期记忆组件
  4. 离线优先应用——SQLite 本地存储 + 可选远程 Embedding 的架构

vue文件自动生成路由会成为主流

作者 ashuicoder
2026年2月25日 11:22

vue-router悄悄发布了5.0版本,用官方的话说,V5 是一个过渡版本,它将unplugin-vue-router(基于文件的路由)合并到了核心包中,就是说V5版本直接支持基于文件自动生成路由了。这一特性在V6中正式引入。

Vue Router 5.0:基于文件的路由成为主流

这一变化标志着前端开发模式的一个重要转折点。过去,开发者需要手动定义路由配置,这种方式虽然灵活,但随着项目规模增大,维护成本也随之增加。现在,Vue Router 5.0内置了基于文件的路由系统,使得路由管理变得更加直观和高效。

传统路由配置与基于文件路由的对比

在传统的Vue Router使用方式中,我们需要创建类似这样的配置:

import { createRouter, createWebHistory } from "vue-router";
import Home from "./views/Home.vue";
import About from "./views/About.vue";

const routes = [
  {
    path: "/",
    name: "home",
    component: Home,
  },
  {
    path: "/about",
    name: "about",
    component: About,
  },
];

const router = createRouter({
  history: createWebHistory(),
  routes,
});

而基于文件的路由系统允许我们通过目录结构自动生成路由,例如:

src/
├── pages/
│   ├── index.vue        # -> /
│   ├── about.vue        # -> /about
│   ├── user/
│   │   └── index.vue    # -> /user
│   └── user-[id].vue    # -> /user/:id

基于文件路由的优势

  1. 减少样板代码:无需手动编写大量路由配置
  2. 约定优于配置:通过文件名和目录结构确定路由路径
  3. 提高开发效率:添加新页面只需创建对应文件
  4. 易于维护:路由结构一目了然,便于团队协作

路由参数和嵌套路由

基于文件的路由系统还支持复杂的路由需求:

  • [param] 语法用于动态路由参数
  • [...catchAll] 语法用于通配符路由
  • 目录嵌套自然形成嵌套路由结构
  • 通过 parent-[optional].vue 支持可选参数

详细的路由规则

根据官方文档,基于文件的路由系统有以下具体规则:

索引路由:任何 index.vue 文件(必须全小写)将生成空路径,类似于 index.html 文件:

  • src/pages/index.vue 生成 / 路由
  • src/pages/users/index.vue 生成 /users 路由

嵌套路由:当在同一层级同时存在同名文件夹和 .vue 文件时,会自动生成嵌套路由。例如:

src/pages/
├── users/
│   └── index.vue
└── users.vue

这将生成如下路由配置:

const routes = [
  {
    path: "/users",
    component: () => import("src/pages/users.vue"),
    children: [
      { path: "", component: () => import("src/pages/users/index.vue") },
    ],
  },
];

不带布局嵌套的路由:有时候你可能想在URL中添加斜杠形式的嵌套,但不想影响UI层次结构。可以使用点号(.)分隔符:

src/pages/
├── users/
│   ├── [id].vue
│   └── index.vue
└── users.vue

要添加 /users/create 路由而不将其嵌套在 users.vue 组件内,可以创建 src/pages/users.create.vue 文件,. 会被转换为 /

const routes = [
  {
    path: "/users",
    component: () => import("src/pages/users.vue"),
    children: [
      { path: "", component: () => import("src/pages/users/index.vue") },
      { path: ":id", component: () => import("src/pages/users/[id].vue") },
    ],
  },
  {
    path: "/users/create",
    component: () => import("src/pages/users.create.vue"),
  },
];

路由组:有时候需要组织文件结构而不改变URL。路由组允许你逻辑性地组织路由,不影响实际URL:

src/pages/
├── (admin)/
│   ├── dashboard.vue
│   └── settings.vue
└── (user)/
    ├── profile.vue
    └── order.vue

生成的URL:

  • /dashboard -> 渲染 src/pages/(admin)/dashboard.vue
  • /settings -> 渲染 src/pages/(admin)/settings.vue
  • /profile -> 渲染 src/pages/(user)/profile.vue
  • /order -> 渲染 src/pages/(user)/order.vue

命名视图:可以通过在文件名后附加 @ + 名称来定义命名视图,如 src/pages/index@aux.vue 将生成:

{
  path: '/',
  component: {
    aux: () => import('src/pages/index@aux.vue')
  }
}

默认情况下,未命名的路由被视为 default,即使有其他命名视图也不需要将文件命名为 index@default.vue

动态路由:使用方括号语法定义动态参数:

  • [id].vue -> /users/:id
  • [category]-details.vue -> /electronics-details
  • [...all].vue -> 通配符路由 /all/*

对开发工作流的影响

这一变化将显著改变Vue应用的开发流程:

  • 新功能页面的添加变得更加简单
  • 团队成员更容易理解项目的路由结构
  • 减少了因手动配置错误导致的路由问题
  • 更好的IDE集成和自动补全支持

迁移策略

对于现有项目,Vue Router 5.0提供了平滑的迁移路径:

  • 旧的路由配置方式依然有效
  • 可以逐步采用基于文件的路由
  • 混合使用两种方式以适应不同场景

配置选项和高级功能

Vue Router 5.0的基于文件路由系统提供了丰富的配置选项,可以根据项目需求进行定制:

自定义路由目录:默认情况下,系统会在 src/pages 目录中查找 .vue 文件,但可以通过配置更改此行为。

命名路由:所有生成的路由都会自动获得名称属性,避免意外将用户引导至父路由。默认情况下,名称使用文件路径生成,但可以通过自定义 getRouteName() 函数覆盖此行为。

类型安全:系统会自动生成类型声明文件(如 typed-router.d.ts),提供几乎无处不在的 TypeScript 验证。

配置示例

// vite.config.js
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import VueRouter from "unplugin-vue-router/vite";

export default defineConfig({
  plugins: [
    VueRouter({
      routesFolder: "src/pages", // 自定义路由目录
      extensions: [".vue"], // 指定路由文件扩展名
      dts: "typed-router.d.ts", // 生成类型声明文件
      importMode: (filename) => "async", // 自定义导入模式
    }),
    vue(),
  ],
});

实际应用建议

在实际项目中采用基于文件的路由时,建议遵循以下最佳实践:

  1. 清晰的目录结构:保持一致的目录结构,便于团队成员理解
  2. 有意义的文件名:使用描述性的文件名,使路由意图明确
  3. 合理使用路由组:利用路由组组织相关的页面,而不影响URL结构
  4. 渐进式采用:对于大型项目,可以逐步迁移部分路由到新的系统

总结

Vue Router 5.0引入的基于文件的路由系统代表了前端开发模式的重要演进。它将 Nuxt.js 等框架成功的路由理念整合到了 Vue 的核心生态中,使开发者能够以更简洁、更直观的方式管理应用路由。

这一变化不仅减少了样板代码,提高了开发效率,还促进了更一致的项目结构。随着更多开发者采用这一新模式,我们可以期待看到更高质量、更易维护的 Vue 应用程序出现,这将为整个前端社区带来积极的影响。

【节点】[Matrix4x4节点]原理解析与实际应用

作者 SmalBox
2026年2月25日 11:18

【Unity Shader Graph 使用与特效实现】专栏-直达

在Unity URP Shader Graph中,Matrix 4x4节点是一个基础但功能强大的工具,用于定义和操作4x4矩阵。矩阵在计算机图形学中扮演着至关重要的角色,特别是在3D变换、坐标空间转换和复杂数学运算中。理解Matrix 4x4节点的使用方法和应用场景,对于创建高级着色器效果至关重要。

Matrix 4x4节点允许开发者在着色器图中直接定义4x4矩阵常量,这些矩阵可以用于各种图形变换和数学计算。与在代码中硬编码矩阵相比,使用Shader Graph节点提供了更直观的可视化工作流程,使得非编程人员也能轻松创建复杂的着色器效果。

描述

Matrix 4x4节点在着色器中定义一个常量矩阵4x4值。这个节点是Shader Graph中处理矩阵运算的基础构建块,特别适用于需要复杂数学变换的着色器效果。

在计算机图形学中,4x4矩阵是表示3D变换的标准方式,包括:

  • 平移变换
  • 旋转变换
  • 缩放变换
  • 投影变换
  • 视图变换

Matrix 4x4节点输出的矩阵是一个4行4列的浮点数矩阵,在HLSL中表示为float4x4类型。这个矩阵可以与其他Shader Graph节点结合使用,实现复杂的图形效果和数学计算。

矩阵在着色器中的应用非常广泛,从简单的顶点变换到复杂的法线映射、环境映射和投影效果都离不开矩阵运算。通过Matrix 4x4节点,开发者可以在不编写代码的情况下,直观地创建和操作这些变换矩阵。

端口

Matrix 4x4节点的端口配置相对简单但功能明确:

名称 方向 类型 绑定 描述
Out 输出 Matrix 4 输出值

输出端口(Out)是Matrix 4x4节点的唯一端口,负责输出定义的4x4矩阵值。这个输出可以连接到其他接受矩阵输入的节点,如Transform节点、Matrix乘法节点等。

端口类型说明:

  • 方向:输出端口表示数据从这个节点流向其他节点
  • 类型:Matrix 4表示4x4矩阵类型
  • 绑定:无绑定表示这个节点不直接与材质属性或外部变量关联

在实际使用中,输出端口通常连接到需要矩阵输入的节点,例如:

  • 用于顶点变换的Transform节点
  • 用于矩阵乘法的Multiply节点
  • 用于自定义计算的Custom Function节点

控件

Matrix 4x4节点的控件界面允许用户直观地设置矩阵的值:

名称 类型 选项 描述
Matrix 4x4 设置输出值

控件界面提供了一个4x4的网格输入区域,用户可以手动输入每个矩阵元素的值。默认情况下,Matrix 4x4节点初始化为单位矩阵:

1, 0, 0, 0
0, 1, 0, 0
0, 0, 1, 0
0, 0, 0, 1

矩阵输入控件的特性:

  • 每个单元格接受浮点数输入
  • 支持正数、负数和十进制数值
  • 实时验证输入值的有效性
  • 保持矩阵的数学完整性

在实际应用中,用户可以通过以下方式设置矩阵值:

  • 直接手动输入特定的变换矩阵
  • 通过表达式计算矩阵元素
  • 复制粘贴来自其他工具的矩阵数据

生成的代码示例

当Shader Graph编译时,Matrix 4x4节点会生成对应的HLSL代码。以下示例代码表示此节点的一种可能结果:

float4x4 _Matrix4x4 = float4x4(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);

生成的代码分析:

  • float4x4是HLSL中4x4矩阵的数据类型
  • _Matrix4x4是生成的变量名,实际名称可能根据节点命名而不同
  • 矩阵元素按行优先顺序排列
  • 分号表示语句结束

代码生成的具体细节:

  • 变量名通常基于节点在Graph中的名称
  • 如果节点未重命名,使用默认命名约定
  • 矩阵值直接硬编码在着色器中
  • 编译时优化可能会对常量矩阵进行特定处理

在实际的着色器应用中,这个生成的矩阵变量可以用于各种计算:

// 顶点变换示例
float4 transformedPosition = mul(_Matrix4x4, input.position);

// 法线变换示例(需要逆转置矩阵)
float3 transformedNormal = mul((float3x3)_Matrix4x4, input.normal);

// 纹理坐标变换示例
float2 transformedUV = mul(_Matrix4x4, float4(input.uv, 0, 1)).xy;

矩阵基础知识

要有效使用Matrix 4x4节点,需要理解一些基本的矩阵概念:

矩阵定义:

  • 4x4矩阵包含16个元素,排列成4行4列
  • 在图形学中通常使用行向量或列向量表示法
  • Unity通常使用列向量表示法

特殊矩阵类型:

  • 单位矩阵:对角线为1,其他为0
  • 零矩阵:所有元素都为0
  • 平移矩阵:实现位置移动
  • 旋转矩阵:实现绕轴旋转
  • 缩放矩阵:实现尺寸变换

矩阵运算:

  • 矩阵加法:对应元素相加
  • 矩阵乘法:行点乘列
  • 矩阵转置:行列互换
  • 矩阵求逆:找到逆矩阵

实际应用案例

自定义变换矩阵

创建一个自定义的旋转和平移变换:

  • 设置旋转矩阵(绕Y轴旋转45度):
cos(45°), 0, sin(45°), 0
0,        1, 0,        0
-sin(45°),0, cos(45°), 0
0,        0, 0,        1
  • 结合平移变换:
cos(45°), 0, sin(45°), 2
0,        1, 0,        0
-sin(45°),0, cos(45°), 3
0,        0, 0,        1

投影效果

创建简单的投影矩阵用于阴影或投影贴图:

  • 正交投影矩阵:
2/width, 0,      0,        0
0,       2/height,0,        0
0,       0,      1/(far-near), -near/(far-near)
0,       0,      0,        1

坐标空间转换

在不同坐标空间之间转换:

  • 世界到视图矩阵:
right.x,   up.x,   forward.x,  -dot(eye, right)
right.y,   up.y,   forward.y,  -dot(eye, up)
right.z,   up.z,   forward.z,  -dot(eye, forward)
0,         0,      0,          1

最佳实践和技巧

性能优化

使用Matrix 4x4节点时考虑性能影响:

  • 尽量使用常量矩阵而不是每帧更新的矩阵
  • 避免在片段着色器中进行复杂的矩阵运算
  • 利用矩阵对称性简化计算
  • 预计算不变的矩阵部分

调试技巧

调试矩阵相关问题时:

  • 使用Preview节点可视化矩阵效果
  • 逐步构建复杂矩阵,验证每一步
  • 使用已知的正确矩阵作为参考
  • 检查矩阵行列式确保可逆性

常见错误避免

避免这些常见错误:

  • 矩阵乘法顺序错误
  • 忘记矩阵的齐次坐标处理
  • 错误理解行优先和列优先
  • 忽略矩阵的不可交换性

高级应用

骨骼动画

在蒙皮着色器中使用矩阵调色板:

// 每个顶点受多个骨骼影响
float4 position = float4(0, 0, 0, 0);
for (int i = 0; i < boneCount; i++) {
    position += weights[i] * mul(boneMatrices[i], input.position);
}

环境映射

使用反射矩阵进行环境映射:

// 计算反射向量
float3 viewDir = normalize(input.viewDirection);
float3 reflectDir = reflect(-viewDir, input.normal);
float4 envCoord = mul(reflectionMatrix, float4(reflectDir, 0));

变形效果

使用时间变化的矩阵创建动态效果:

// 基于时间的旋转矩阵
float angle = _Time.y * rotationSpeed;
float4x4 rotationMatrix = float4x4(
    cos(angle), 0, sin(angle), 0,
    0, 1, 0, 0,
    -sin(angle), 0, cos(angle), 0,
    0, 0, 0, 1
);

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

DOM 里有 Tailwind class,为什么样式还是不生效?v4 闭环修复实战

作者 parade岁月
2026年2月25日 11:17

在 monorepo 组件库开发中,我们遇到了 class 明明挂在了 DOM 上,样式却完全不生效的诡异问题。排查过程中深入了 Tailwind CSS v4 的核心机制,形成此文。

一、问题现场

项目 vtable-guild 是一个基于 Vue 3 + Tailwind CSS v4 的 monorepo 表格组件库,使用 pnpm workspace 管理包结构:

vtable-guild/
├── packages/
│   ├── core/      # useTheme composable、插件
│   ├── theme/     # 默认主题定义 + CSS token
│   └── table/     # 表格组件
├── playground/    # 开发调试用的 Vite 应用
└── package.json

主题包 @vtable-guild/theme 中的 table.ts 定义了表格组件的默认样式:

// packages/theme/src/table.ts
export const tableTheme = {
  slots: {
    root: 'w-full',
    table: 'w-full border-collapse text-sm text-on-surface',
    tr: 'border-b border-default transition-colors',
    th: 'px-4 py-3 text-left font-medium text-muted',
    td: 'px-4 py-3',
    // ...
  },
  variants: {
    striped: { true: { tr: 'even:bg-elevated/50' } },
    hoverable: { true: { tr: 'hover:bg-surface-hover' } },
    bordered: { true: { table: 'border border-default', th: 'border border-default', td: 'border border-default' } },
  },
  // ...
} as const satisfies ThemeConfig

在 playground 中使用 useTheme composable 消费这些样式,然后绑定到模板:

<!-- playground/src/App.vue -->
<script setup lang="ts">
import { useTheme } from '@vtable-guild/core'
import { tableTheme } from '@vtable-guild/theme'

const props = {
  size: 'md' as const,
  bordered: false,
  striped: true,
  hoverable: true,
  ui: { th: 'text-primary' },
  class: 'my-8 rounded-lg overflow-hidden',
}

const { slots } = useTheme('table', tableTheme, props)
</script>

<template>
  <div :class="slots.root()">
    <table :class="slots.table()">
      <thead>
        <tr :class="slots.tr()">
          <th v-for="col in columns" :key="col" :class="slots.th()">{{ col }}</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="row in data" :key="row.email" :class="slots.tr()">
          <td :class="slots.td()">{{ row.name }}</td>
          <td :class="slots.td()">{{ row.email }}</td>
          <td :class="slots.td()">{{ row.role }}</td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

运行 pnpm playground,打开浏览器——border 没有、hover 变色没有、隔行变色也没有

表格倒是渲染出来了,文字内容都正常显示,只是看起来光秃秃的,完全没有任何 Tailwind 样式效果。

二、排查过程

第一步:确认 class 是否正确挂载

打开 DevTools 的 Elements 面板,检查 <tr> 元素:

<tr class="border-b border-default transition-colors even:bg-elevated/50 hover:bg-surface-hover">

class 确实在 DOM 上,说明 JavaScript 运行时的主题合并逻辑是正确的

问题出在 CSS 侧——这些 class 对应的 CSS 规则根本没有被生成。

第二步:检查生成的 CSS

在 DevTools 的 Console 中执行脚本,提取 @layer utilities 中实际生成的工具类:

// 提取所有 Tailwind 生成的工具类名
const utilityRules = [...document.styleSheets]
  .flatMap(s => { try { return [...s.cssRules] } catch { return [] } })
  .filter(r => r instanceof CSSLayerBlockRule && r.name === 'utilities')
  .flatMap(r => [...r.cssRules])
  .map(r => r.selectorText)

结果只有 24 个工具类,全部是 playground 自身源码中直接出现的 class:

✅ .my-8, .mt-2, .mb-4, .min-h-screen, .rounded-lg, .overflow-hidden
✅ .bg-surface, .bg-elevated, .p-4, .p-8
✅ .text-2xl, .text-xs, .text-primary, .text-on-surface, .text-muted
✅ .font-bold, .uppercase, .tracking-wider, .cursor-pointer

而来自 @vtable-guild/theme 的工具类全部缺失

❌ .border-b, .border-default, .border-collapse
❌ .transition-colors, .text-left, .font-medium
❌ .w-full, .px-4, .py-3, .text-sm
❌ hover:bg-surface-hover, even:bg-elevated/50

第三步:发现规律

class 定义位置 生成 CSS
text-primary App.vueui: { th: 'text-primary' }
uppercase main.tsslots: { th: 'uppercase tracking-wider' }
bg-surface App.vue 模板中的 class="bg-surface"
border-b 仅在 packages/theme/src/table.ts
hover:bg-surface-hover 仅在 packages/theme/src/table.ts

规律非常明显:只有 playground 自身源码(src/ 目录)中出现的 class 才会生成 CSS 规则。定义在 workspace 子包中的 class 字符串全部被忽略。

这就引出了 Tailwind CSS v4 最核心的机制——内容扫描(Content Detection)

三、Tailwind CSS v4 架构总览

在深入内容扫描之前,先整体了解 v4 的架构。

3.1 一切从 @import "tailwindcss" 开始

在 v4 中,整个框架的入口就是一行 CSS:

/* playground/src/main.css */
@import 'tailwindcss';
@import '@vtable-guild/theme/css';

这行 @import 'tailwindcss' 实际上展开为 四层 CSS @layer

@layer theme, base, components, utilities;

@layer theme {
  /* Tailwind 的设计 token:颜色、间距、字体等 */
  :root {
    --color-red-500: oklch(0.637 0.237 25.331);
    --spacing: 0.25rem;
    --font-sans: ui-sans-serif, system-ui, sans-serif;
    /* ... 数百个 CSS 变量 */
  }
}

@layer base {
  /* Preflight 重置 + 基础样式 */
  *, ::before, ::after { box-sizing: border-box; }
  body { margin: 0; font-family: var(--font-sans); }
  /* ... */
}

@layer components {
  /* 留空,供用户通过 @utility 或 @apply 扩展 */
}

@layer utilities {
  /* 按需生成的工具类 —— 这里是关键 */
}

v3 vs v4 的本质区别在于:v3 中这四层分别由 @tailwind base@tailwind components@tailwind utilities 三个指令注入;v4 统一为一个 @import 入口,内部自动展开为四层 @layer

3.2 @layer utilities 的按需生成

@layer utilities 是空的吗?不完全是。Tailwind 在构建时会把它填满——但只填入被实际使用的工具类

例如,如果你的源码中出现了 class="px-4 text-red-500",那 Tailwind 只会生成这两条规则:

@layer utilities {
  .px-4 { padding-inline: calc(var(--spacing) * 4); }
  .text-red-500 { color: var(--color-red-500); }
}

这就是"按需生成"——不是把所有可能的工具类都打进 CSS(那会有几 MB),而是只生成你实际用到的。

问题来了:Tailwind 怎么知道你用了哪些 class?

四、核心机制:内容扫描

4.1 v4 如何发现 class

Tailwind CSS v4 使用一个基于 Rust 编写的高性能内容扫描器来检测源码中的 class 字符串。扫描策略如下:

  1. 扫描项目根目录下的所有源文件.html.js.ts.vue.jsx.tsx.svelte.astro 等)

  2. 自动排除以下目录:

    • node_modules/(包括 pnpm 的符号链接)
    • .git/
    • 二进制文件、图片、字体等
  3. 纯文本匹配:扫描器不理解语法树,它只是在文件内容中查找像 CSS class 的字符串。字符串 'border-b border-default transition-colors' 中的每个空格分隔的 token 都会被识别为一个潜在的 class

4.2 关键:node_modules 被排除

这是我们问题的根因。在 pnpm monorepo 中:

node_modules/
  @vtable-guild/
    theme/ → ../../packages/theme   # 符号链接

虽然 @vtable-guild/theme 通过 pnpm workspace 链接到了 packages/theme/,但 Tailwind 的扫描器仍然通过符号链接的路径识别它在 node_modules 中,因此直接跳过。

这意味着 packages/theme/src/table.ts 中定义的所有 class 字符串(border-bborder-defaulttransition-colorshover:bg-surface-hover 等)从未被扫描器发现,对应的 CSS 规则也就从未被生成。

4.3 与 v3 的对比

在 v3 中,我们通过 tailwind.config.jscontent 数组手动指定扫描路径:

// tailwind.config.js (v3)
module.exports = {
  content: [
    './src/**/*.{vue,js,ts}',
    // 手动添加 workspace 包路径
    '../packages/theme/src/**/*.ts',
  ],
}

这种方式虽然繁琐,但开发者对扫描范围有完全的控制权。

v4 去掉了 tailwind.config.js,改为自动扫描 + CSS 指令控制。自动扫描在大多数单包项目中都能正常工作,但在 monorepo 中引入了上述的坑。

五、CSS-first 配置

v4 的一个重大设计变化是:所有配置都在 CSS 文件中完成,不再需要 tailwind.config.js

5.1 @theme — 注册自定义设计 token

@theme 指令用于向 Tailwind 的 theme layer 注入自定义 CSS 变量,使其成为可通过工具类使用的 token:

/* packages/theme/css/tokens.css */

:root {
  --color-surface: oklch(100% 0 0deg);
  --color-surface-hover: oklch(97% 0 0deg);
  --color-on-surface: oklch(15% 0 0deg);
  --color-muted: oklch(55% 0 0deg);
  --color-default: oklch(87% 0 0deg);
  --color-primary: oklch(55% 0.25 260deg);
  --color-primary-hover: oklch(49% 0.25 260deg);
}

.dark {
  --color-surface: oklch(17% 0 0deg);
  --color-on-surface: oklch(95% 0 0deg);
  /* ... */
}

@theme {
  --color-surface: var(--color-surface);
  --color-surface-hover: var(--color-surface-hover);
  --color-on-surface: var(--color-on-surface);
  --color-muted: var(--color-muted);
  --color-default: var(--color-default);
  --color-primary: var(--color-primary);
  --color-primary-hover: var(--color-primary-hover);
}

注册后,你就可以直接使用 bg-surfacetext-on-surfaceborder-defaulttext-primary 等工具类。暗色模式只需切换 :root 上的 CSS 变量值(通过 .dark class),不需要写 dark: 前缀。

5.2 @source — 手动添加扫描路径

这是解决我们问题的关键指令。

@source 告诉 Tailwind "除了自动扫描的文件之外,还要去扫描这个路径下的文件":

@source "../dist";

路径相对于当前 CSS 文件所在目录解析。

5.3 其他 CSS 指令

指令 作用 示例
@import "tailwindcss" 引入 Tailwind 的四层 layer @import 'tailwindcss'
@theme 注册自定义设计 token @theme { --color-brand: #3b82f6; }
@source 添加额外的内容扫描路径 @source "../components"
@utility 定义自定义工具类 @utility tab-4 { tab-size: 4; }
@variant 定义自定义变体 @variant hocus (&:hover, &:focus)
@custom-variant 注册自定义变体(与 @variant 类似)
@reference 引入但不输出内容(仅供引用) @reference "tailwindcss"
@plugin 加载 JS 插件 @plugin "tailwindcss-animate"

六、Vite 插件集成

6.1 @tailwindcss/vite

v4 提供了专用的 Vite 插件,取代了 v3 中通过 PostCSS 插件集成的方式:

// playground/vite.config.ts
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [vue(), vueJsx(), vueDevTools(), tailwindcss()],
})

这个插件做了三件事:

  1. 拦截 CSS @import:识别 @import 'tailwindcss' 和包含 @theme@source 等指令的 CSS 文件
  2. 执行内容扫描:遍历项目文件,收集所有使用到的 class 名
  3. 按需注入 CSS:根据扫描结果,在 @layer utilities 中生成对应的 CSS 规则

6.2 一个隐蔽的坑:@import 的写法

这里有一个额外的坑,也是在我们项目中踩到的。

stylelint-config-standard 有一条默认规则 import-notation: url,它会在保存时自动将:

@import 'tailwindcss';

修正为:

@import url('tailwindcss');

看起来只是写法不同,语义相同?@tailwindcss/vite 插件只识别裸字符串形式的 @importurl() 写法会导致插件完全无法识别这条导入,Tailwind 的整个处理链路直接断裂——不扫描、不生成、不注入。

修复方式是在 stylelint 配置中覆盖这条规则:

// stylelint.config.mjs
export default {
  extends: ['stylelint-config-standard'],
  rules: {
    // Tailwind CSS v4 要求裸字符串 @import "tailwindcss",
    // stylelint-config-standard 默认强制 url() 写法,需覆盖为 string
    'import-notation': 'string',

    // 允许 Tailwind CSS v4 的自定义 at-rule
    'at-rule-no-unknown': [
      true,
      {
        ignoreAtRules: [
          'theme', 'apply', 'config', 'plugin',
          'utility', 'variant', 'custom-variant',
          'source', 'reference',
        ],
      },
    ],
  },
}

七、解决方案:@source 指令

7.1 最终修复

packages/theme/css/tokens.css(即 @vtable-guild/theme/css 的入口文件)中添加一行:

@source "../dist";

/* 原有的 @theme 和 CSS 变量定义... */

这告诉 Tailwind 扫描器:去扫描 packages/theme/dist/ 目录下的文件。而 dist/index.mjs(构建产物)中包含了所有主题定义的 class 字符串:

// packages/theme/dist/index.mjs (构建产物)
const tableTheme = {
  slots: {
    tr: "border-b border-default transition-colors",
    th: "px-4 py-3 text-left font-medium text-muted",
    // ...
  },
  // ...
}

扫描器会从中提取出 border-bborder-defaulttransition-colors 等所有 class 字符串,然后在 @layer utilities 中生成对应的 CSS 规则。

7.2 为什么是 ../dist 而不是 ../src

因为 package.jsonfiles 字段是 ["dist", "css"]

{
  "name": "@vtable-guild/theme",
  "exports": {
    ".": "./dist/index.mjs",
    "./css": "./css/tokens.css"
  },
  "files": ["dist", "css"]
}

当这个包被发布到 npm 后,src/ 目录不会包含在内。如果写 @source "../src",在 monorepo 开发时能用,但外部消费者安装后会报错(路径不存在)。../dist 在两种场景下都能正确解析。

7.3 消费者体验:零配置

修复后,消费者只需要两行 CSS:

@import 'tailwindcss';
@import '@vtable-guild/theme/css';

第二行导入的 tokens.css 文件中已经包含了 @source "../dist",Tailwind 会自动将 dist/ 纳入扫描范围。消费者不需要手动配置任何扫描路径

7.4 参考:Nuxt UI 4 的做法

Nuxt UI 4 采用了完全相同的策略。在它的 CSS 入口文件 src/runtime/index.css 中:

@source "./components";

它指向自己的组件目录,让 Tailwind 扫描所有 Vue 组件模板中的 class。消费者通过 @import "@nuxt/ui" 引入这个 CSS 文件时,@source 指令自动生效。

核心原则:由库的 CSS 入口声明 @source,而不是要求消费者手动配置扫描路径。

八、完整排查流程回顾

遇到"class 在 DOM 上但样式不生效"时,可以按以下流程排查:

                    class 在 DOM 上?
                    ┌─── 否 ──→ JS 运行时问题(组件逻辑 / props 传递)
                    │
                    ├─── 是
                    │
              对应 CSS 规则存在?
              ┌─── 否 ──→ Tailwind 内容扫描问题
              │           │
              │           ├ 检查 @import 写法(url() vs 裸字符串)
              │           ├ 检查文件是否在扫描范围内
              │           └ 需要 @source 显式注册?
              │
              ├─── 是
              │
        规则被其他样式覆盖?
        ┌─── 是 ──→ 检查 CSS 优先级 / @layer 顺序
        │
        └─── 否 ──→ 检查 CSS 变量是否有值

验证方法:在 DevTools Console 中执行

// 检查某个 class 是否有对应的 CSS 规则
const hasRule = (cls) => [...document.styleSheets]
  .flatMap(s => { try { return [...s.cssRules] } catch { return [] } })
  .flatMap(r => r.cssRules ? [...r.cssRules] : [r])
  .some(r => r.selectorText?.includes(cls))

console.log('border-b:', hasRule('border-b'))           // false → 未扫描到
console.log('text-primary:', hasRule('text-primary'))     // true  → 正常

九、v4 vs v3 核心差异对照表

维度 Tailwind CSS v3 Tailwind CSS v4
配置文件 tailwind.config.js(JS) CSS 文件中的 @theme@source 等指令
CSS 入口 @tailwind base/components/utilities @import "tailwindcss"
内容扫描配置 content: ['./src/**/*.vue'] 自动扫描 + @source 显式补充
扫描排除 需手动配置 自动排除 node_modules/.git/
自定义颜色 theme.extend.colors 在 JS 中 @theme { --color-xxx: ... } 在 CSS 中
暗色模式 dark:bg-gray-900 CSS 变量切换,无需 dark: 前缀
构建集成 PostCSS 插件 专用 Vite/Webpack/PostCSS 插件
引擎 JS Rust(Lightning CSS) + JS
性能 全量构建快 5 倍+,增量构建快 100 倍+
@import 写法 无限制 必须使用裸字符串,不支持 url()

前端防调试攻防战:如何保护你的JavaScript代码不被“偷窥”?

作者 大知闲闲i
2026年2月25日 11:08

在Web开发中,我们投入大量心血编写的前端代码,往往暴露在无数双眼睛之下。对于商业项目、内部系统或一些特殊应用来说,防止他人随意调试代码、窃取逻辑或篡改数据,成为了一项重要的需求。

本文将全面梳理前端防调试的各种技术手段,从简单的“骚扰式”反调试到终极的攻防对抗,带你了解这场没有硝烟的“调试与反调试”之战。

一、为什么需要防调试?

在深入技术之前,我们需要明确防调试的目的。通常,前端开发者希望限制调试工具(如Chrome DevTools)的访问,主要出于以下考虑:

  1. 保护核心逻辑:防止竞争对手或攻击者通过断点调试,逆向工程你的核心算法或业务逻辑。

  2. 防止数据篡改:阻止恶意用户修改JavaScript变量、跳过验证步骤,从而进行刷单、作弊等操作。

  3. 增加攻击成本:虽然没有绝对的安全,但增加一层防护可以让普通攻击者知难而退,提高整体的攻击门槛。

二、基础防御:构建第一道防线

最直接的思路,就是彻底阻断进入开发者工具的通道。

1. 禁止右键菜单

很多用户习惯通过右键菜单点击“检查”来打开开发者工具。通过禁用右键菜单,可以阻止这一最常见的入口。

// 禁止右键点击
document.oncontextmenu = function() {
    return false;
};

2. 禁用F12及常用快捷键

开发者工具的快捷键(F12、Ctrl+Shift+I/Cmd+Opt+I、Ctrl+Shift+C/Cmd+Opt+C)是专业用户的“快捷方式”。我们可以通过监听键盘事件来禁用它们。

document.onkeydown = function(e) {
    if (e.key === 'F12' || 
        (e.ctrlKey && e.shiftKey && e.key === 'I') || // Ctrl+Shift+I
        (e.ctrlKey && e.shiftKey && e.key === 'C') || // Ctrl+Shift+C
        (e.metaKey && e.altKey && e.key === 'I') ||   // Cmd+Opt+I (Mac)
        (e.metaKey && e.altKey && e.key === 'C')) {   // Cmd+Opt+C (Mac)
        e.preventDefault();
        return false;
    }
};

3. 检测开发者工具状态

这是一种“主动侦察”的思路。通过定时检测某些特征来判断开发者工具是否被打开,一旦发现,立即采取行动。

// 定时检测控制台是否被打开
setInterval(function() {
    // 方法一:检测console是否被重新激活(一些早期方法)
    // 方法二:利用debugger的特性(见下文)
    // 方法三:检测窗口大小差异
    const before = new Date();
    debugger; // 如果devtools打开,debugger会暂停执行,导致时间差增大
    const after = new Date();
    if (after - before > 100) { // 如果时间差超过100ms,说明可能遇到了断点暂停
        // 执行反制措施,例如清空页面或跳转
        window.location.href = "about:blank";
    }
}, 1000);

三、进阶防御:“无限debugger”的攻防艺术

当攻击者成功打开开发者工具后,最让他们头疼的就是无穷无尽的断点。这就是著名的 “无限debugger” 战术。

1. 基础版:无休止的断点

debugger 语句会在控制台打开时强制执行。将其放入一个无限循环中,就能让任何试图调试的人寸步难行。

(function() {
    setInterval(function() {
        debugger;
    }, 100);
})();

然而,这种基础版本很容易被破解。攻击者只需点击DevTools中的 “Deactivate breakpoints” 按钮(或按 Ctrl+F8),即可一键禁用所有断点。虽然禁用后无法再添加新的断点,但至少可以正常查看网络请求和DOM结构了。

2. 进阶版:混淆与单行代码

为了对抗“停用断点”功能,我们可以将代码写得更加“反人类”。

  • 单行压缩:将代码写在一行,让攻击者难以通过行号设置断点。即使他们尝试格式化代码,恢复的可读性也有限。

    (function(){setInterval(function(){debugger;},100);})();
    
  • 动态生成debugger:利用 Function 构造器来创建 debugger。每次执行 Function('debugger') 都会在一个临时的、虚拟的JS文件中触发断点。这让攻击者难以通过“停用断点”或“添加脚本到忽略列表”来一次性屏蔽所有断点,因为他们需要忽略无数个动态生成的脚本。

    setInterval(function() {
        Function('debugger')();
    }, 100);
    

3. 终极版:递归调用与条件检测

将上述技巧组合,并结合条件检测,可以实现非常强悍的反调试逻辑。

// 定义一个难以被忽略的debugger生成函数
(function() {
    function block() {
        // 使用constructor来调用debugger
        (function(){return false;})['constructor']('debugger')['call']();
        // 递归调用,形成无限循环
        block();
    }
    
    // 启动,并添加一个条件检测(例如检测窗口大小)
    setInterval(function() {
        // 如果窗口内外高度差过大,很可能是开发者工具以独立窗口形式打开
        if (window.outerHeight - window.innerHeight > 200) {
            block();
        }
    }, 1000);
})();

这段代码的核心在于:

  1. 混淆(function(){return false;})['constructor']('debugger')['call']() 这种写法等同于 Function('debugger').call(),但更加晦涩难懂。

  2. 递归block 函数内部调用自身,形成了一个无法终止的递归调用链。即使攻击者跳过一次 debugger,程序也会立即进入下一次递归,继续触发新的 debugger

  3. 条件触发:结合检测开发者工具窗口的特征(如内外高度差),只在疑似被调试时才触发,减少了正常用户的性能开销。

四、代码保护的最后屏障:混淆与加密

无论多精妙的防调试逻辑,其源代码始终暴露在攻击者面前。因此,在发布到生产环境前,对代码进行混淆加密是至关重要的一步。

  1. 混淆:使用工具(如 javascript-obfuscator)将变量名替换为无意义的字符(如 _0x1234),打乱代码结构,移除注释和空格,让代码变得难以阅读和理解。

  2. 加密:将核心逻辑进行编码或加密,在运行时动态解密执行。例如,将上面的反调试函数编码成一段看似无害的字符串,然后在内存中通过 evalFunction 执行。

    // 极度简化的示例(真实场景会更复杂) // 将核心代码进行Base64编码 var encoded = 'KGZ1bmN0aW9uKCl7CmZ1bmN0aW9uIGJsb2NrKCl7CmZ1bmN0aW9uKCl7cmV0dXJuIGZhbHNlO31bJ2NvbnN0cnVjdG9yJ10oJ2RlYnVnZ2VyJylbJ2NhbGwnXSgpOwpibG9jaygpOwp9CnNldEludGVydmFsKGZ1bmN0aW9uKCl7aWYod2luZG93Lm91dGVySGVpZ2h0LXdpbmRvdy5pbm5lckhlaWdodD4yMDApe2Jsb2NrKCl9fSwxMDAwKTsKfSkoKTs='; eval(atob(encoded)); // 解码并执行

攻击者即便打开了控制台,看到的也只是一堆乱码,极大地增加了分析难度。

五、总结:没有绝对的安全,只有不断的对抗

前端防调试是一场永无止境的“猫鼠游戏”。

  • 攻击者总会有新的工具和技巧,例如使用无头浏览器、代理工具、甚至修改浏览器源码来绕过这些检测。

  • 防御者则需要不断升级自己的技术,从简单的禁用快捷键,到复杂的无限debugger,再到代码混淆和动态执行。

因此,我们需要理性看待前端安全:

  1. 增加攻击成本是核心目标。我们的目的不是让代码100%无法破解(这在理论上几乎不可能),而是让破解成本远高于其带来的收益,让攻击者觉得“不值得”。

  2. 纵深防御。不要依赖单一手段。结合网络请求验证、后端数据签名、用户行为分析等多种方式,构建一个立体的防御体系。

  3. 保持更新。关注最新的反调试技术和绕过方法,持续迭代你的防护策略。

最终,保护前端代码不仅是技术活,更是一场关于耐心和智慧的持久战。

tailwind-variants基本使用

2026年2月25日 11:08

一.使用场景

1.主要用于C端业务,或者对样式有要求的B端项目
2.公司内部组件库

不适用的场景:
对样式要求不高的管理后台项目,这种项目使用成熟的组件库会让你的开发更加的迅速,样式部分进行样式覆盖即可。

二.为什么要使用tailwind-variants?

之前讲了tailwind和taiwind-merge的使用,但是这两个东西主要是为了写样式,现在要写组件,我们还需要一个利器,那就是tailwind-variants。为什么需要它呢?试想一下,我们需要写一个Button组件,但是这个Button组件有不同的样式,比如空心和实心的。

1.不使用taiwind-variant来封装button组件

import { cn } from "@/utils/cn";
function Button({
  variant = "primary",
  className,
  size = "small",
  children = "button",
  onClick,
}: {
  variant: "primary" | "danger" | "outline" | "disabled";
  className?: string;
  size?: "small" | "medium" | "large";
  children?: React.ReactNode;
  onClick?: () => void;
}) {
  return (
    <button
      onClick={onClick}
      className={cn(
        // 基础样式所有按钮共享)
        "rounded-md cursor-pointer",
        // 变体样式根据不同 variant 应用不同样式)
        {
          "bg-blue-500 text-white": variant === "primary",
          "bg-red-500 text-white": variant === "danger",
          "bg-transparent text-blue-500 border border-blue-500":
            variant === "outline",
          "bg-gray-300 text-gray-500 cursor-not-allowed":
            variant === "disabled",
        },
        {
          "p-2 text-sm": size === "small",
          "p-4 text-base": size === "medium",
          "p-6 text-lg": size === "large",
        },
        {
          // 特殊样式danger  large 组合时添加特殊样式
          'font-bold uppercase border-4': variant === "danger" && size === "large",
        },
        // 外部传入的样式优先级最高可以覆盖上面的样式className
      )}
      disabled={variant === "disabled"}
      >
      {children}
    </button>
  );
}

2.使用taiwind-variant来封装组件

import { tv, type VariantProps } from "tailwind-variants";

const buttonTvStyles = tv({
  base: "rounded-md cursor-pointer",
  variants: {
    color: {
      primary: "bg-blue-500 text-white",
      danger: "bg-red-500 text-white",
      outline: "bg-transparent text-blue-500 border border-blue-500",
      disabled: "bg-gray-300 text-gray-500 cursor-not-allowed",
    },
    size: {
      small: "p-2 text-sm",
      medium: "p-4 text-base",
      large: "p-6 text-lg",
    },
  },
  // ✅ 正确示例:特定组合时添加特殊样式
  compoundVariants: [
    {
      color: "danger",
      size: "large",
      class: "font-bold uppercase border-4", // 特殊样式
    },
  ],
  // 定义默认的样式
  defaultVariants: {
    size: "small",
    color: "primary",
  },
});

// ✅ 完整的组件实现
type ButtonProps = VariantProps<typeof buttonTvStyles> & {
  children?: React.ReactNode;
className?: string; // ✅ 支持外部样式
onClick?: () => void;
  disabled?: boolean; // ✅ 支持禁用
};

function Button2({
  size,
  color,
  children = "button",
  className,
  onClick,
  disabled,
}: ButtonProps) {
  return (
    <button
      onClick={onClick}
      className={buttonTvStyles({ size, color, className })}
      disabled={disabled || color === "disabled"}
      >
      {children}
    </button>
  );
}

使用都是一样的:

export default function TailwindVariants() {
  return (
    <div>
      不同的button样式,如果不用tailwind-variants,这样子写代码:
      <div>
        基础的:
        <Button variant="primary" size="small" className="bg-amber-300" />
        主要的:
        <Button variant="danger" size="medium" />
        空心的:
        <Button variant="outline" size="large" />
        禁用的:
        <Button variant="disabled" />
      </div>
      <div className="mt-5">
        基础的:
        <Button2 color="primary" size="small" />
        主要的:
        <Button2 color="danger" size="medium" />
        空心的:
        <Button2 color="outline" size="large" />
        禁用的:
        <Button2 color="disabled"/>
      </div>
    </div>
  );
}

总结:可以看到,通过使用tailwind-variants可以让我们的代码结构显得非常的清晰,不会杂糅js和css。能通过js判断需要应用的css,使用tailwind-variants都有相对应的方法处理。 其实tailwind-variants最主要的作用就是让我们的代码变得更容易维护,虽然学习成本稍微高一点,但是这些都是值得的

举更多的例子说明-为什么要使用tailwind-variants

1.主要原因还是因为在组件复用样式时,能更好的帮我们处理样式的情况,能让我们的代码结构更加的清晰,而不是css中杂糅着js。
2.能够让我们创建一致性的组件样式。公司组件的风格会相对比较统一

以下为对比使用和没有使用tailwind-variants情况的对比,大家可以看下,就能直观的感受到了

# 📊 为什么需要 tailwind-variants?实际对比

## 🎯 核心问题:当组件变复杂时,`cn` 方案会遇到的三大难题

---

  ## 问题一:复合变体(Compound Variants)难以实现

### 需求场景**特定的变体组合**需要特殊样式时,例如:
  - `danger` + `large` 时:按钮要加粗、大写、加粗边框
  - `primary` + `small` 时:添加阴影效果

### ❌ 使用 cn 的方案(不优雅)

  ```tsx
function Button({ variant, size }) {
  // 需要手动判断组合
  const isDangerLarge = variant === "danger" && size === "large";
  const isPrimarySmall = variant === "primary" && size === "small";
  
  return (
    <button
      className={cn(
        "rounded-md",
        { "bg-red-500": variant === "danger" },
        { "px-6 py-3": size === "large" },
        // ❌ 问题:复合样式散落在条件判断中
        isDangerLarge && "font-bold uppercase border-4",
        isPrimarySmall && "shadow-lg",
      )}
    >
      Button
    </button>
  );
}

问题:

  • ❌ 需要手动定义布尔变量(isDangerLarge
  • ❌ 复合样式混在 cn 函数里,不够清晰
  • ❌ 组合越多,代码越乱
  • ❌ 容易遗漏某些组合

✅ 使用 tailwind-variants 的方案(优雅)

import { tv } from "tailwind-variants";

const button = tv({
base: "rounded-md transition-colors",
variants: {
  variant: {
    primary: "bg-blue-500 text-white",
    danger: "bg-red-500 text-white",
  },
  size: {
    small: "px-2 py-1 text-sm",
    large: "px-6 py-3 text-lg",
  },
},
// ✅ 关键:复合变体集中管理,清晰明了
compoundVariants: [
  {
    variant: "danger",
    size: "large",
    class: "font-bold uppercase border-4 border-red-700",
  },
  {
    variant: "primary",
    size: "small",
    class: "shadow-lg",
  },
],
});

function Button({ variant, size }) {
return <button className={button({ variant, size })}>Button</button>;
}

优势:

  • ✅ 复合变体集中在 compoundVariants 配置中
  • ✅ 代码结构清晰,易于维护
  • ✅ 不会遗漏任何组合
  • ✅ TypeScript 类型安全

问题二:多元素组件(Slots)管理混乱

需求场景

像 Card、Modal、Alert 这种多元素组件,每个子元素都需要根据父组件的变体调整样式。

❌ 使用 cn 的方案(重复且混乱)

function Card({ variant, size }) {
return (
  <div
    className={cn(
      "rounded-lg border",
      // 每个元素都要重复判断 variant  size
      { "bg-white": variant === "default" },
      { "bg-blue-50": variant === "highlighted" },
      { "p-4": size === "small" },
      { "p-6": size === "large" }
    )}
  >
    <h3
      className={cn(
        "font-bold",
        // ❌ 问题又要写一遍 variant 判断
        { "text-gray-900": variant === "default" },
        { "text-blue-900": variant === "highlighted" },
        // ❌ 问题又要写一遍 size 判断
        { "text-lg": size === "small" },
        { "text-2xl": size === "large" }
      )}
    >
      Title
    </h3>
    <p
      className={cn(
        // ❌ 问题第三次重复写 variant  size 判断...
        { "text-gray-600": variant === "default" },
        { "text-blue-600": variant === "highlighted" },
        { "text-sm": size === "small" },
        { "text-base": size === "large" }
      )}
    >
      Content
    </p>
  </div>
);
}

问题:

  • ❌ 每个子元素都要重复写条件判断
  • ❌ 代码重复严重,难以维护
  • ❌ 修改变体时需要改多处
  • ❌ 容易出错(忘记更新某个元素)

✅ 使用 tailwind-variants 的方案(Slots)

import { tv } from "tailwind-variants";

const card = tv({
// ✅ 关键:使用 slots 管理多个元素
slots: {
  base: "rounded-lg border",
  title: "font-bold",
  content: "mt-2",
},
variants: {
  variant: {
    default: {
      base: "bg-white border-gray-200",
      title: "text-gray-900",
      content: "text-gray-600",
    },
    highlighted: {
      base: "bg-blue-50 border-blue-300",
      title: "text-blue-900",
      content: "text-blue-600",
    },
  },
  size: {
    small: {
      base: "p-4",
      title: "text-lg",
      content: "text-sm",
    },
    large: {
      base: "p-6",
      title: "text-2xl",
      content: "text-base",
    },
  },
},
});

function Card({ variant, size }) {
// ✅ 一次性获取所有元素的样式
const { base, title, content } = card({ variant, size });

return (
  <div className={base()}>
    <h3 className={title()}>Title</h3>
    <p className={content()}>Content</p>
  </div>
);
}

优势:

  • ✅ 所有元素的变体样式集中管理
  • ✅ 代码清晰,结构化
  • ✅ 修改变体只需改一处
  • ✅ 不会遗漏任何元素

问题三:响应式变体

需求场景

按钮在不同屏幕尺寸下需要不同的样式。

❌ 使用 cn 的方案(需要手动写响应式类)

function Button({ variant }) {
  return (
    <button
      className={cn(
        "p-2",
        // ❌ 需要手动写响应式前缀
        "md:p-4 lg:p-6",
        {
          "bg-blue-500 md:bg-red-500 lg:bg-green-500": variant === "primary",
        }
      )}
    >
      Button
    </button>
  );
}

✅ 使用 tailwind-variants 的方案(原生支持)

const button = tv({
  variants: {
    variant: {
      primary: "bg-blue-500",
      danger: "bg-red-500",
    },
  },
});

// ✅ 可以这样传入响应式变体
<button className={button({ 
  variant: { 
    initial: "primary",  // 默认
    md: "danger",        // 中等屏幕
    lg: "primary"        // 大屏幕
  } 
})}>
  Button
</button>

问题四:代码可维护性

场景对比:一个真实的按钮组件

假设需要支持:

  • 3 个颜色(primary、danger、success)
  • 3 个尺寸(sm、md、lg)
  • 2 个圆角(normal、full)
  • 是否有阴影
  • 复合变体:danger + lg 时要加粗

❌ 使用 cn 的代码量

function Button({ variant, size, rounded, shadow }) {
  const isDangerLarge = variant === "danger" && size === "lg";
  
  return (
    <button
      className={cn(
        "transition-colors font-medium",
        // variant (3 )
        {
          "bg-blue-500 text-white": variant === "primary",
          "bg-red-500 text-white": variant === "danger",
          "bg-green-500 text-white": variant === "success",
        },
        // size (3 )
        {
          "px-2 py-1 text-sm": size === "sm",
          "px-4 py-2 text-base": size === "md",
          "px-6 py-3 text-lg": size === "lg",
        },
        // rounded (2 )
        {
          "rounded-md": rounded === "normal",
          "rounded-full": rounded === "full",
        },
        // shadow
        shadow && "shadow-lg",
        // 复合变体
        isDangerLarge && "font-bold border-4"
      )}
    >
      Button
    </button>
  );
}

总计:约 30 行,且结构混乱

✅ 使用 tailwind-variants 的代码量

const button = tv({
  base: "transition-colors font-medium",
  variants: {
    variant: {
      primary: "bg-blue-500 text-white",
      danger: "bg-red-500 text-white",
      success: "bg-green-500 text-white",
    },
    size: {
      sm: "px-2 py-1 text-sm",
      md: "px-4 py-2 text-base",
      lg: "px-6 py-3 text-lg",
    },
    rounded: {
      normal: "rounded-md",
      full: "rounded-full",
    },
    shadow: {
      true: "shadow-lg",
    },
  },
  compoundVariants: [
    {
      variant: "danger",
      size: "lg",
      class: "font-bold border-4",
    },
  ],
  defaultVariants: {
    variant: "primary",
    size: "md",
    rounded: "normal",
  },
});

function Button(props) {
  return <button className={button(props)}>Button</button>;
}

总计:约 35 行,但结构清晰、可维护性强


📊 总结对比

特性 cn 方案 tailwind-variants
简单变体 ✅ 够用 ✅ 优雅
复合变体 ❌ 需要手动判断 compoundVariants
多元素组件 ❌ 代码重复 slots 统一管理
响应式变体 ⚠️ 手动写前缀 ✅ 原生支持
代码可读性 ⚠️ 变体多时混乱 ✅ 结构化配置
维护成本 ⚠️ 修改需要改多处 ✅ 集中管理
TypeScript ⚠️ 手动定义 ✅ 自动推断
学习曲线 ✅ 简单 ⚠️ 需要学习 API

🎯 结论

适合继续用 cn 的场景:

  • ✅ 简单组件(1-2 个变体维度)
  • ✅ 不需要复合变体
  • ✅ 不需要多元素协同管理
  • ✅ 原型开发、快速迭代

建议使用 tailwind-variants 的场景:

  • ✅ 构建设计系统或组件库
  • ✅ 组件有复杂的变体组合需求
  • ✅ 多元素组件(Card、Modal、Alert 等)
  • ✅ 需要响应式变体
  • ✅ 团队协作,需要统一规范
  • ✅ 长期维护的项目

核心差异: cn 是战术工具(解决单个组件),tailwind-variants 是战略工具(解决整个设计系统)。




## 三.学习文档
直接到[官网](https://www.tailwind-variants.org/docs/getting-started)去学习就好了,它的官网的例子写的真的很详细。跟着官网的例子走一遍,基本上就知道怎么用了  
官网:[https://www.tailwind-variants.org/docs/getting-started](https://www.tailwind-variants.org/docs/getting-started)

## 四.怎么使用-基础组件?
### 1.安装 
```markdown
npm install tailwind-variants

2.组件中使用tailwind-variants

import { tv } from "tailwind-variants";

export default function Button({
  size,
  color,
  children,
}: {
  size?: "sm" | "md" | "lg";
  color?: "primary" | "secondary";
  children?: React.ReactNode;
}) {
  const buttonTvStyles = tv({
    base: "font-medium bg-blue-500 text-white rounded-full active:opacity-80",
    variants: {
      color: {
        primary: "bg-blue-500 text-white",
        secondary: "bg-purple-500 text-white",
      },
      size: {
        sm: "text-sm",
        md: "text-base",
        lg: "px-4 py-3 text-lg",
      },
    },
    compoundVariants: [
      {
        size: ["sm", "md"],
        class: "px-3 py-1",
      },
    ],
    defaultVariants: {
      size: "md",
      color: "primary",
    },
  });

  return (
    <button className={buttonTvStyles({ size, color })}>{children}</button>
  );
}

3.使用组件

import Button from "./components/Button";
export default function UseTailwindVariants() {
  return (
    <div>
      <Button size="sm" color="secondary">组件按钮</Button>
    </div>
  );
}

4.解释tv中定义的各属性的作用:

就以上面封装的button组件的代码为例:

a. base:

作用:定义基础样式,该样式会和变体中的样式一起应用到组件上,这里的样式定义一般就放最基础的,需要改动的样式放到变体variants中去

b.variants(变体):

作用:定义变体,可以定义不同样式,不同大小的按钮(也可以定义其他的,比如圆角,阴影等等)
文档:www.tailwind-variants.org/docs/varian…

解释下,比如

variants: {
  color: {
    primary: "bg-blue-500 text-white",
    secondary: "bg-purple-500 text-white",
  },
  size: {
    sm: "text-sm",
    md: "text-base",
    lg: "px-4 py-3 text-lg",
  },
},

意思是这里我们定义了两个变体,一个是color风格,一个是size的大小。color分为primary和secondary两种风格,size主要分为三个大小。 这个color和size主要是可以通过外面传进来的变量来让我们的组件确认是渲染哪一种,如:

<Button size="sm" color="secondary">组件按钮</Button>
<Button size="sm" color="primary">组件按钮</Button>
<Button size="md" color="primary">组件按钮</Button>

我还可以这样子定义:

variants: {
  radius: {
    none: "rounded-none",
      sm: "rounded-sm",
      md: "rounded-md",
      lg: "rounded-lg",
      full: "rounded-full",
      },
  shadow: {
    none: "shadow-none",
      sm: "shadow-sm",
      md: "shadow-md",
      lg: "shadow-lg",
      },
},

这样子就又加了两种变体,radius和shadow,radius圆角有五种风格的圆角,shadow阴影有四种风格的阴影。使用的时候就可以传进来使用了:

<Button size="md" color="primary" radius="full">组件按钮</Button>
<Button size="md" color="primary" shadow="md" >组件按钮</Button>

注意:定义变体时是三层结构的object,先是variants变体对象,其次是变体名称对应的对象,最后是变体名称对应有哪些值,并且这些值对应的样式是什么

c.compoundVariants(复合变体):

作用:定义复合样式,即满足该条件的会应用该样式
比如

compoundVariants: [
  {
    size: ["sm", "md"],
    class: "px-3 py-1",
  },
],

这个的意思是:size为sm或者md的组件会应用下面的class(即'px-3 py-1')
我还可以这样子定义:

compoundVariants: [
  {
    color: 'primary',
    size: 'sm',
    class: 'bg-red-500 text-white',
  }
],

这个的意思是:当color为primary且size为sm的组件会应用'bg-red-500 text-white'的样式

注意别理解错了:

数组 ["sm", "md"] = 或关系 (满足其中之一)

多个属性 = 且关系 (必须同时满足)

d.defaultVariants(默认变体)

作用:定义默认变体(即不传的时候会默认渲染的)

比如

defaultVariants: {
  size: "md",
  color: "primary",
},

如果这个button不传size和color,那么它的默认样式就是size为md,color为primary

五.核心:variants(变体)

文档:www.tailwind-variants.org/docs/varian…

a.基础variants: 例如:

variants: {
    color: {
      primary: 'bg-blue-500 hover:bg-blue-700',
      secondary: 'bg-purple-500 hover:bg-purple-700',
      success: 'bg-green-500 hover:bg-green-700'
    }
  }

这里只定义了一种color的变体,这种变体有三种选择,分别是primary,secondary,success,对应了不同的样式

使用变体:

  <Button color="secondary">组件按钮</Button>
  <Button color="primary">组件按钮</Button>

b.多个变体

variants: {
  color: {
    primary: 'bg-blue-500 hover:bg-blue-700',
    secondary: 'bg-purple-500 hover:bg-purple-700',
    success: 'bg-green-500 hover:bg-green-700'
  },
  size: {
    sm: 'py-1 px-3 text-xs',
    md: 'py-1.5 px-4 text-sm',
    lg: 'py-2 px-6 text-md'
  }
}

这里定义了两种变体,color可以选择primary,secondary,success, size可以选择sm,md, lg

使用变体

  <Button size="sm" color="secondary">组件按钮</Button>
  <Button size="sm" color="primary">组件按钮</Button>

c.boolean变体

variants: {
  color: {
    primary: 'bg-blue-500 hover:bg-blue-700',
    secondary: 'bg-purple-500 hover:bg-purple-700',
    success: 'bg-green-500 hover:bg-green-700'
  },
  disabled: {
    true: 'opacity-50 bg-gray-500 pointer-events-none'
  }
}

这里定义了disabled的boolean变体,当disabled为true时会应用opacity-50 bg-gray-500 pointer-events-none这些样式

封装的时候是这么写的:

import { tv } from "tailwind-variants";

export default function Button({
  size,
  color,
  radius,
  shadow,
  children,
  disabled
}: {
  size?: "sm" | "md" | "lg";
  color?: "primary" | "secondary";
  radius?: "none" | "sm" | "md" | "lg" | "full";
  shadow?: "none" | "sm" | "md" | "lg";
  children?: React.ReactNode;
  disabled?: boolean
}) {
  const buttonTvStyles = tv({
    base: "font-medium bg-blue-500 text-white active:opacity-80 cursor-pointer",
    variants: {
      color: {
        primary: "bg-blue-500 text-white",
        secondary: "bg-purple-500 text-white",
      },
      size: {
        sm: "text-sm",
        md: "text-base",
        lg: "px-4 py-3 text-lg",
      },
      radius: {
        none: "rounded-none",
        sm: "rounded-sm",
        md: "rounded-md",
        lg: "rounded-lg",
        full: "rounded-full",
      },
      shadow: {
        none: "shadow-none",
        sm: "shadow-sm",
        md: "shadow-md",
        lg: "shadow-lg",
      },
      disabled: {
        true: 'opacity-50 bg-gray-500 pointer-events-none'
      }
    },
    compoundVariants: [
      {
        size: ["sm", "md"],
        class: "px-3 py-1",
      },
      {
        color: 'primary',
        size: 'sm',
        class: 'bg-red-500 text-white',
      }
    ],
    defaultVariants: {
      size: "md",
      color: "primary",
      radius: "md",
      shadow: "none",
    },
  });

  return (
    <button className={buttonTvStyles({ size, color, radius, shadow, disabled })}>
      {children}
    </button>
  );
}

使用boolean变体

<Button size="md" color="primary" disabled>组件按钮</Button>

d.Compound variants(复合变体)

这个在上面说过了,就不举例子了

e.Default variants(默认变体)

这个也在上面说过了,就不举例子了

六.核心:slots(插槽)

文档:www.tailwind-variants.org/docs/slots

上面的例子是拿了一个按钮作为例子的,但是我们实际业务的组件绝对不只是这么简单,可能很复杂有一堆的元素,那么就要引入tailwind-variants的另一个重要的属性了-----slots,这个属性能让我们对不同的盒子进行自定义样式,以下拿例子来说明:

a.基础使用:

import { tv } from "tailwind-variants";
import avatar2 from "./intro-avatar.webp";

export default function Card() {
  const card = tv({
    slots: {
      base: "bg-slate-100 rounded-xl p-8 md:p-0",
      avatar:
        "w-24 h-24 md:h-auto md:rounded-none rounded-full mx-auto drop-shadow-lg",
      wrapper: "flex-1 pt-6 md:p-8 text-center md:text-left space-y-4",
      description: "text-md font-medium",
      infoWrapper: "font-medium",
      name: "text-sm text-sky-500",
      role: "text-sm text-slate-700",
    },
  });

  const { base, avatar, wrapper, description, infoWrapper, name, role } =
    card();

  return (
    <figure className={base()}>
      <img
        className={avatar()}
        src={avatar2}
        alt=""
        width="384"
        height="512"
      />
      <div className={wrapper()}>
        <blockquote>
          <p className={description()}>
            “Tailwind variants allows you to reduce repeated code in your
            project and make it more readable. They fixed the headache of
            building a design system with TailwindCSS.”
          </p>
        </blockquote>
        <figcaption className={infoWrapper()}>
          <div className={name()}>Zoey Lang</div>
          <div className={role()}>Full-stack developer, HeroUI</div>
        </figcaption>
      </div>
    </figure>
  );
}

这里通过slots插槽定义了组件的多个部分的样式,分为了base, avatar, wrapper, description, infoWrapper, name, role,其实就是相当于起了个类名,然后各个类名的样式对应什么。

b.和变体variants一起使用

就是既要有插槽,又有变体的时候要怎么使用?
这里我给出来了格式,但是没有写具体的样式

variants: {
  color: {
    primary: {
      base: "xxx bbb",
      avatar:'ccc ddd'
    },
  },
},

注意:这里变成了四层结构的object,第一层依然是variants变体对象,第二层还是定义的变体名称对象,第三层不再是值和值对应的样式了,而是值和值对应的object,这个object包括的是各个slot名称和要加上去的样式。
这个意思就是说,当这个Card组件的color为primary时, 会将'xxx bbb'的样式应用到base盒子上去,'ccc ddd'的样式会被应用到avatar盒子上去

比如,我现在定义了三种color的样式,在不同的color下,description和role的字体颜色不同

variants: {
  color: {
    default: {
      description: "text-gray-900",
      role: "text-gray-900",
    },
    primary: {
      description: "text-green-500",
      role: "text-green-500",
    },
    danger: {
      description: "text-red-500",
      role: "text-red-500",
    },
  },
},

当我使用时,就可以传入这个color以实现不同的效果:

<Card color="default"/>
<Card color="primary"/>

c.和compoundVariants(复合变体)一起使用

这里我直接给出我的代码

compoundVariants:[
  {
    color:'primary',
    class:{
      description:'text-blue-500',
      avatar:'rounded-full md:rounded-full'
    }
  }
],

解释下,这里的color的值,可以选择你上面变体定义的三个值default,primary,danger
我这里的复合变体的意思是:如果你的color为primary,那么description的盒子再应用text-blue-500的样式(之前的),avatar的盒子再应用rounded-full md:rounded-full的样式

d.Compoundslots(复合slot)

这个属性和compoundVariants的用法是基本一致的,比如

compoundSlots:[
  {
    slots:['role','description'],
    color:'danger',
    class: 'bg-red-200'
  }
],

这个的代码的意思是: slots为role和description的盒子,并且该组件的color变体为danger时, 会应用bg-red-200的class,如图,现在只有danger的组件有红色背景

e.Slot variant overrides(这个相对难以理解,但是用的也比较少)

官网例子:www.tailwind-variants.org/docs/slots#…
这里我用组件的方式改写了一下

import { tv } from "tailwind-variants";

// ✅ 定义 Item 类型
type TabItem = {
  id: string;
  label: string;
  color?: "primary" | "secondary";
  isSelected?: boolean;
};

// ✅ 正确的 props 类型定义
export default function Tab({ items }: { items: TabItem[] }) {
  const card = tv({
    slots: {
      base: "flex gap-2",
      tab: "rounded px-4 py-2 cursor-pointer transition-all",
    },
    variants: {
      color: {
        primary: {
          tab: "text-blue-500 dark:text-blue-400",
        },
        secondary: {
          tab: "text-purple-500 dark:text-purple-400",
        },
      },
      isSelected: {
        true: {
          tab: "font-bold bg-blue-100",
        },
        false: {
          tab: "font-normal bg-gray-50",
        },
      },
    },
  });

  const { base, tab } = card({ color: "primary" });

  return (
    <div className={base()}>
      {items.map((item) => (
        <div 
          key={item.id}
          className={tab({ isSelected: item.isSelected, color: item.color })} 
          id={item.id}
        >
          {item.label}
        </div>
      ))}
    </div>
  );
}

传入使用:

const tabItems = [
  { id: "1", label: "Tab 1", isSelected: true },
  { id: "2", label: "Tab 2", isSelected: false },
  { id: "3", label: "Tab 3", isSelected: false, color: "secondary" as const },
];
<Tab items={tabItems} />

效果:

解释一下: 这个tab这里本来默认是渲染rounded px-4 py-2 cursor-pointer transition-all的样式的,但是我们可以通过把变体传进来,进而去覆盖默认的tab的样式。
比如{ id: "1", label: "Tab 1", isSelected: true } 就会把font-bold bg-blue-100的样式加到tab的样式里面去。
比如{ id: "3", label: "Tab 3", isSelected: false, color: "secondary" as const }会把secondary: {

   tab: "text-purple-500 dark:text-purple-400",

}的样式加入到slots中的tab中去

七.Overriding styles(样式覆盖)

文档:www.tailwind-variants.org/docs/overri…

a.单组件(组件里只有一个元素)样式覆盖

简单叙述就是:传入一个className,然后在buttonTvStyles调用时作为参数传入进去

import { tv } from "tailwind-variants";

export default function Button({
  size,
  color,
  radius,
  shadow,
  children,
  disabled,
  className
}: {
  size?: "sm" | "md" | "lg";
  color?: "primary" | "secondary";
  radius?: "none" | "sm" | "md" | "lg" | "full";
  shadow?: "none" | "sm" | "md" | "lg";
  children?: React.ReactNode;
  disabled?: boolean;
  className?: string;
}) {
  const buttonTvStyles = tv({
    base: "font-medium bg-blue-500 text-white active:opacity-80 cursor-pointer",
    variants: {
      color: {
        primary: "bg-blue-500 text-white",
        secondary: "bg-purple-500 text-white",
      },
      size: {
        sm: "text-sm",
        md: "text-base",
        lg: "px-4 py-3 text-lg",
      },
      radius: {
        none: "rounded-none",
        sm: "rounded-sm",
        md: "rounded-md",
        lg: "rounded-lg",
        full: "rounded-full",
      },
      shadow: {
        none: "shadow-none",
        sm: "shadow-sm",
        md: "shadow-md",
        lg: "shadow-lg",
      },
      disabled: {
        true: 'opacity-50 bg-gray-500 pointer-events-none'
      }
    },
    compoundVariants: [
      {
        size: ["sm", "md"],
        class: "px-3 py-1",
      },
      {
        color: 'primary',
        size: 'sm',
        class: 'bg-red-500 text-white',
      }
    ],
    defaultVariants: {
      size: "md",
      color: "primary",
      radius: "md",
      shadow: "none",
    },
  });

  return (
    <button className={buttonTvStyles({ size, color, radius, shadow, disabled, className })}>
      {children}
    </button>
  );
}

使用:

<Button size="md" color="secondary" className="bg-pink-500 hover:bg-pink-600">
  覆盖为粉色
</Button>

b.带插槽组件(组件里有多个元素)的样式覆盖

import { tv } from "tailwind-variants";
import avatar2 from "./intro-avatar.webp";

export default function Card({
  color,
  classNames
}: { 
  color?: "default" | "primary" | "danger";
  classNames?: {
    base?: string;
    avatar?: string;
    wrapper?: string;
    description?: string;
    infoWrapper?: string;
    name?: string;
    role?: string;
  };
}) {
  const card = tv({
    slots: {
      base: "bg-slate-100 rounded-xl p-8 md:p-0",
      avatar:
        "w-24 h-24 md:h-auto md:rounded-none rounded-full mx-auto drop-shadow-lg",
      wrapper: "flex-1 pt-6 md:p-8 text-center md:text-left space-y-4",
      description: "text-md font-medium",
      infoWrapper: "font-medium",
      name: "text-sm text-sky-500",
      role: "text-sm text-slate-700",
    },
    variants: {
      color: {
        default: {
          description: "text-gray-900",
          role: "text-gray-900",
        },
        primary: {
          description: "text-green-500",
          role: "text-green-500",
        },
        danger: {
          description: "text-red-500",
          role: "text-red-500",
        },
      },
    },
    compoundVariants:[
      {
        color:'primary',
        class:{
          description:'text-blue-500',
          avatar:'rounded-full md:rounded-full'
        }
      }
    ],
    compoundSlots:[
      {
        slots:['role','description'],
        color:'danger',
        class: 'bg-red-200'
      }
    ],
    defaultVariants:{
        color:'default'
    }
  });

  const { base, avatar, wrapper, description, infoWrapper, name, role } =
    card({ color });

  return (
    <figure className={base({ class: classNames?.base })}>
      <img className={avatar({ class: classNames?.avatar })} src={avatar2} alt="" width="384" height="512" />
      <div className={wrapper({ class: classNames?.wrapper })}>
        <blockquote>
          <p className={description({ class: classNames?.description })}>
            “Tailwind variants allows you to reduce repeated code in your
            project and make it more readable. They fixed the headache of
            building a design system with TailwindCSS.”
          </p>
        </blockquote>
        <figcaption className={infoWrapper({ class: classNames?.infoWrapper })}>
          <div className={name({ class: classNames?.name })}>Zoey Lang</div>
          <div className={role({ class: classNames?.role })}>Full-stack developer, HeroUI</div>
        </figcaption>
      </div>
    </figure>
  );
}

使用:

{/* 综合覆盖多个样式 */}
<Card 
  color="danger"
  classNames={{
    base: "bg-yellow-50 border-2 border-yellow-400",
    avatar: "grayscale hover:grayscale-0 transition-all",
    wrapper: "bg-yellow-100 rounded-lg",
    description: "text-yellow-900 italic",
    name: "text-yellow-700 font-bold",
    role: "text-yellow-600"
  }}
/>

八.继承

文档:www.tailwind-variants.org/docs/compos…
主要就是可以通过extend来继承别的组件的样式。 也可以使用base,slots或者variants来组合样式,这里具体就不举例了,因为比较用法比较简单。

九.结合TS

结合ts使用。

import { tv, type VariantProps } from 'tailwind-variants';

export const button = tv({
  base: 'px-4 py-1.5 rounded-full hover:opacity-80',
  variants: {
    color: {
      primary: 'bg-blue-500 text-white',
      neutral: 'bg-zinc-500 text-black dark:text-white'
    },
    flat: {
      true: 'bg-transparent'
    }
  },
  defaultVariants: {
    color: 'primary'
  },
  compoundVariants: [
    {
      color: 'primary',
      flat: true,
      class: 'bg-blue-500/40'
    },
    {
      color: 'neutral',
      flat: true,
      class: 'bg-zinc-500/20'
    }
  ]
});

/**
 * Result:
 * color?: "primary" | "neutral"
 * flat?: boolean
 */

type ButtonVariants = VariantProps<typeof button>;

interface ButtonProps extends ButtonVariants {
  children: React.ReactNode;
}

export const Button = (props: ButtonProps) => {
  return <button className={button(props)}>{props.children}</button>;
};
import { tv,VariantProps } from "tailwind-variants";
import avatar2 from "../../UseTailwindVariants/components/intro-avatar.webp";

const card = tv({
  slots: {
    base: "bg-slate-100 rounded-xl p-8 md:p-0",
    avatar:
      "w-24 h-24 md:h-auto md:rounded-none rounded-full mx-auto drop-shadow-lg",
    wrapper: "flex-1 pt-6 md:p-8 text-center md:text-left space-y-4",
    description: "text-md font-medium",
    infoWrapper: "font-medium",
    name: "text-sm text-sky-500",
    role: "text-sm text-slate-700",
  },
  variants: {
    color: {
      default: {
        description: "text-gray-900",
        role: "text-gray-900",
      },
      primary: {
        description: "text-green-500",
        role: "text-green-500",
      },
      danger: {
        description: "text-red-500",
        role: "text-red-500",
      },
    },
  },
  compoundVariants:[
    {
      color:'primary',
      class:{
        description:'text-blue-500',
        avatar:'rounded-full md:rounded-full'
      }
    }
  ],
  compoundSlots:[
    {
      slots:['role','description'],
      color:'danger',
      class: 'bg-red-200'
    }
  ],
  defaultVariants:{
      color:'default'
  }
});

interface CardVariants extends VariantProps<typeof card> {
  classNames?: {
    base?: string;
    avatar?: string;
    wrapper?: string;
    description?: string;
    infoWrapper?: string;
    name?: string;
    role?: string;
  };
}


export function Card(props:CardVariants) {
  const { color, classNames } = props;

  const { base, avatar, wrapper, description, infoWrapper, name, role } =
    card({ color });

  return (
    <figure className={base({ class: classNames?.base })}>
      <img className={avatar({ class: classNames?.avatar })} src={avatar2} alt="" width="384" height="512" />
      <div className={wrapper({ class: classNames?.wrapper })}>
        <blockquote>
          <p className={description({ class: classNames?.description })}>
            “Tailwind variants allows you to reduce repeated code in your
            project and make it more readable. They fixed the headache of
            building a design system with TailwindCSS.”
          </p>
        </blockquote>
        <figcaption className={infoWrapper({ class: classNames?.infoWrapper })}>
          <div className={name({ class: classNames?.name })}>Zoey Lang</div>
          <div className={role({ class: classNames?.role })}>Full-stack developer, HeroUI</div>
        </figcaption>
      </div>
    </figure>
  );
}

多组件结合ts使用是优化,主要是优化传参:

interface CardVariants extends VariantProps<typeof card> {
  classNames?: {
    base?: string;
    avatar?: string;
    wrapper?: string;
    description?: string;
    infoWrapper?: string;
    name?: string;
    role?: string;
  };
}

还有这里要优化,因为每次都要写:

<figure className={base({ class: classNames?.base })}>
      <img className={avatar({ class: classNames?.avatar })} src={avatar2} alt="" width="384" height="512" />
      <div className={wrapper({ class: classNames?.wrapper })}>
        <blockquote>
          <p className={description({ class: classNames?.description })}>
            “Tailwind variants allows you to reduce repeated code in your
            project and make it more readable. They fixed the headache of
            building a design system with TailwindCSS.”
          </p>
        </blockquote>
        <figcaption className={infoWrapper({ class: classNames?.infoWrapper })}>
          <div className={name({ class: classNames?.name })}>Zoey Lang</div>
          <div className={role({ class: classNames?.role })}>Full-stack developer, HeroUI</div>
        </figcaption>
      </div>
    </figure>

最后的优化版本:

import { applyStyles, tv, type TVProps } from './tv';
import avatar2 from "../../UseTailwindVariants/components/intro-avatar.webp";

const createStyle = tv({
  slots: {
    base: "bg-slate-100 rounded-xl p-8 md:p-0",
    avatar:
      "w-24 h-24 md:h-auto md:rounded-none rounded-full mx-auto drop-shadow-lg",
    wrapper: "flex-1 pt-6 md:p-8 text-center md:text-left space-y-4",
    description: "text-md font-medium",
    infoWrapper: "font-medium",
    name: "text-sm text-sky-500",
    role: "text-sm text-slate-700",
  },
  variants: {
    color: {
      default: {
        description: "text-gray-900",
        role: "text-gray-900",
      },
      primary: {
        description: "text-green-500",
        role: "text-green-500",
      },
      danger: {
        description: "text-red-500",
        role: "text-red-500",
      },
    },
  },
  compoundVariants: [
    {
      color: 'primary',
      class: {
        description: 'text-blue-500',
        avatar: 'rounded-full md:rounded-full'
      }
    }
  ],
  compoundSlots: [
    {
      slots: ['role', 'description'],
      color: 'danger',
      class: 'bg-red-200'
    }
  ],
  defaultVariants: {
    color: 'default'
  }
});


type Props = TVProps<typeof createStyle>;

export function Card(props: Props) {
  const { variants, classNames } = props;
  const styles = applyStyles(createStyle, {
    variants,
    classNames
  });

  const { base, avatar, wrapper, description, infoWrapper, name, role } = styles;

  return (
    <figure className={base()}>
      <img className={avatar()} src={avatar2} alt="" width="384" height="512" />
      <div className={wrapper()}>
        <blockquote>
          <p className={description()}>
            “Tailwind variants allows you to reduce repeated code in your
            project and make it more readable. They fixed the headache of
            building a design system with TailwindCSS.”
          </p>
        </blockquote>
        <figcaption className={infoWrapper()}>
          <div className={name()}>Zoey Lang</div>
          <div className={role()}>Full-stack developer, HeroUI</div>
        </figcaption>
      </div>
    </figure>
  );
}
import clsx from 'clsx';
import { createTV, VariantProps } from 'tailwind-variants';

import { twMergeConfig } from '@/utils/cn';

export const tv = createTV({
  twMerge: true,
  twMergeConfig,
});

type SlotClassNames<T> = T extends {
  slots: infer S;
}
  ? SlotClassNames<S>
  : T extends (config: infer C) => any
    ? SlotClassNames<C>
    : Partial<Record<keyof T, string>>;

export interface TVProps<T extends (...args: any) => any> {
  classNames?: SlotClassNames<T>;
  variants?: VariantProps<T>;
}

/**
 * A utility for applying variants and custom classNames to styles created with `tailwind-variants`.
 * It allows a component to accept a `classNames` prop to override or extend the styles of its internal slots.
 *
 * @param createStyles - The style function created by `tv` from `tailwind-variants`.
 * @param props - The component's props, expected to contain `variants` and `classNames`.
 * @returns The resolved styles object, where each slot function is wrapped to include the custom classNames.
 *
 * @example
 * const buttonStyles = tv({ slots: { base: '...', icon: '...' } });
 * const props = { variants: { color: 'primary' }, classNames: { icon: 'text-red-500' } };
 * const styles = applyStyles(buttonStyles, props);
 * <div class={styles.base()}><span class={styles.icon()} /></div>
 * The icon will have the 'text-red-500' class applied.
 */
export function applyStyles<T extends (...args: any) => any>(
  createStyles: T,
  props: TVProps<T>,
): ReturnType<T> {
  const styles = createStyles(props.variants);

  Object.keys(styles).forEach((key) => {
    const original = styles[key];
    // Wrap the original slot function to merge the `classNames` prop.
    styles[key] = (args: any) => {
      return original({
        ...args,
        className: clsx(args?.className, props.classNames?.[key as never]),
      });
    };
  });

  return styles;
}

TVProps的作用是:将传入进来的slot类型分别处理成一个对象,包含了classNames和variants属性,这两个对象里面包含了slot类型的属性。这样子就组成了组件的类型,而不用每次都写这么多了。

applyStyles函数的作用是,传入原来的style实例和props对象(包含variants和classNames属性),返回新的一个style实例。作用是:把外部传入的 classNames 自动合并到每个 slot 中,省去在 JSX 里逐个写 base({ class: classNames?.base }) 的重复代码。

const styles = applyStyles(createStyle, {
  variants,
  classNames
});

十:自定义配置。

同样的,tailwind-variants无法识别tailwindcss以外的自定义的类名,所以我们需要把自定义配置导出过来,在创建tv的时候将该自定义配置传入进去。

import { createTV, VariantProps } from 'tailwind-variants';
import { twMergeConfig } from '@/utils/cn';

export const tv = createTV({
  twMerge: true,
  twMergeConfig,
});

所以在使用ts的时候要从tv.ts中导入了
原来:
import { tv,VariantProps } from "tailwind-variants";
现在:
import { tv } from './tv';

十一:地址
demo地址:gitee.com/rui-rui-an/…

tailwind-merge的基本使用

2026年2月25日 11:05

一.为什么要使用tailwind-merge?

主要是为了解决tailwindcss中样式冲突时不能很好的按照我们的css层叠想法去合并冲突类。

官方文档:tailwindcss.com/docs/stylin…

有一个例子是:

<div class="grid flex"> <!-- ... --></div>

请问上面的样式最后是应用哪个display的属性? 按照我们的经验,肯定觉得是flex,但是实际上是grid,原因是因为taiwindcss时根据样式表的顺序来应用的,而不是我们自己写的顺序

当你使用同样的一个样式的时候,跟我们以往的经验不同,在后面的样式不会重叠前面的。

另外一个例子就是

<div className="px-2 py-1 p-3 w-10 h-10 bg-amber-200"></div>

这样子,你觉得最终是多少呢?

✅ 最终结果:
由于 px-* 和 py-* 类在样式表中定义得比 p-* 类更晚,所以:
最终的 padding 效果是:
padding-top: 0.25rem; (4px) - 来自 py-1
padding-bottom: 0.25rem; (4px) - 来自 py-1
padding-left: 0.5rem; (8px) - 来自 px-2
padding-right: 0.5rem; (8px) - 来自 px-2
📝 为什么不是 p-3 的 12px 全方向?
因为在 Tailwind 的样式表中,方向性的 padding 类(px-*, py-*)定义在通用 padding 类(p-*)之后,所以它们会覆盖 p-3 的效果。
简单说:最终是上下 4px,左右 8px 的 padding! 

相信大家肯定觉得很难受了,所以就引出了下一个工具:tailwind-merge

这个工具就能按照我们的想法来合并css,后面我也会写一个关于tailwind-merge的使用。

二.基本使用

1.安装

pnpm i tailwind-merge

2.使用:

import { twMerge } from 'tailwind-merge'

export default function TailwindMerge() {
    // 在组件内部使用
    const className = twMerge('px-2 py-1 bg-red hover:bg-dark-red', 'p-3 bg-[#B91C1C]')
    console.log(className) // 每次组件渲染时执行
    合并后的类名:hover:bg-dark-red p-3 bg-[#B91C1C]
    
    return (
        <div className={className}>
            <h1>Tailwind Merge</h1>
            <p>合并后的类名:{className}</p>
        </div>
    )
}

可以看到上面的使用还是很方便的

3.坑点

文档:github.com/dcastil/tai…
虽然这样子使用很方便,但是它有个坑点,就是之前我们也提到过,我们自己在tailwind里面会去自定义自己的样式,由于tailwind-merge不认识我们自定义的样式,然后会导致它在合并的时候会被保留。比如:

/* 
  1. @utility - 定义原子级工具类(Tailwind v4 新特性)
  - 用途:创建可复用的单一用途工具类
  - 特点:会被 Tailwind 的 JIT 引擎处理,支持响应式和伪类变体
  - 可以使用:md:flex-center、hover:flex-center 等
*/
@utility flex-center {
  display: flex;
  align-items: center;
  justify-content: center;
}

@utility flex-center-x {
  display: flex;
  justify-content: center;
}

@utility flex-center-y {
  display: flex;
  align-items: center;
}
<div className={twMerge("grid flex-center")}>aaa</div>
f12中css选择器该div上还有两个类名"grid flex-center" ,最后是'grid'布局,跟预期不符

4.如何解决自定义类的问题?

使用tailwind-merge自定义配置来告诉自己定义的类是属于哪一类的,然后进行合并。代码我放在下面了:

import { type ClassValue, clsx } from 'clsx';
import {
  createTailwindMerge,
  getDefaultConfig,
  mergeConfigs,
} from 'tailwind-merge';

const defaultConfig = getDefaultConfig();

/* ============================================================================
   配置说明:什么需要配置,什么不需要配置
   ============================================================================
   
   ✅ 需要配置的情况:
   
   1. @theme 中定义的非标准名称(如自定义的 font-size、shadow 等)
      例如:--font-size-menu-choice → 生成 text-menu-choice
      虽然会自动生成,但建议配置以确保 tailwind-merge 能识别
   
   2. @utility 定义的组合类(包含多个 CSS 属性)
      例如:@utility text-s-title-s { font-size + font-weight + line-height }
      必须配置!需要创建独立 classGroup + conflictingClassGroups
   
   3. @utility 定义的自定义工具类(如 flex-center)
      例如:@utility flex-center { display + align-items + justify-content }
      需要配置到对应的 classGroup 中
   
   ❌ 不需要配置的情况:
   
   1. @theme 中的标准颜色、间距(会自动生成标准 Tailwind 类)
      例如:--color-brand → 生成 text-brand, bg-brand, border-brand
      例如:--spacing-100 → 生成 p-100, m-100, w-100, h-100
      tailwind-merge 默认就能识别这些标准格式!
   
   2. @apply 定义的组件类(如 .avatar、.btn-primary)
      这些不是工具类,tailwind-merge 会把它们当作普通字符串处理
      不需要配置,也不建议在 tailwind-merge 中使用
   
   ============================================================================ */

// ===== 1. @theme 定义的自定义字体大小 =====
// 对应 CSS: --font-size-menu-choice, --font-size-title-l 等
// 生成的类: text-menu-choice, text-title-l 等
const customFontSizes = [
  'menu-choice',
  'menu-not-choice',
  'title-l',
  'title-m',
  'title-s',
  'number-l',
  'number-m',
  'number-s',
  'body-l',
  'body-m',
  'body-s',
  'tips',
];

// ===== 2. @utility 定义的文字样式组合类 =====
// 对应 CSS: @utility text-s-title-s { font-size + font-weight + line-height }
// 特点: 包含多个 CSS 属性,需要与多个 classGroup 冲突
// 必须配置: 创建独立组 + conflictingClassGroups
const textStyleUtilityClasses = [
  's-title-s',
  's-title-m',
  's-title-l',
  's-body-s',
  's-body-m',
  's-body-l',
];

export const twMergeConfig = mergeConfigs(defaultConfig, {
  extend: {
    classGroups: {
      /* -------------------------------------------------------------------------
         字体大小相关配置
         ------------------------------------------------------------------------- */
      
      // ✅ 需要配置:@theme 中定义的自定义字体大小
      // 对应 CSS: --font-size-menu-choice 等
      // 生成的类: text-menu-choice, text-title-l 等
      'font-size': [
        {
          text: customFontSizes,
        },
      ],
      
      /* -------------------------------------------------------------------------
         组合类配置(包含多个 CSS 属性的 @utility 类)
         ------------------------------------------------------------------------- */
      
      // ✅ 必须配置:@utility 定义的文字样式组合类
      // 对应 CSS: @utility text-s-title-s { font-size + font-weight + line-height }
      // 原因: 包含多个属性,需要与 font-size、font-weight、line-height 都冲突
      'text-style-combo': [
        {
          text: textStyleUtilityClasses,
        },
      ],
      
      /* -------------------------------------------------------------------------
         其他自定义工具类
         ------------------------------------------------------------------------- */
      
      // ✅ 需要配置:@theme 中定义的自定义阴影
      // 对应 CSS: --shadow-d-base, --shadow-d-dropdown 等
      // 生成的类: shadow-d-base, shadow-d-dropdown 等
      shadow: [
        {
          shadow: ['d-base', 'd-dropdown', 'd-button'],
        },
      ],
      
      // ✅ 需要配置:@utility 定义的布局工具类
      // 对应 CSS: @utility flex-center { display + align-items + justify-content }
      // 原因: 自定义类,需要加入 display 组才能与 flex、grid 等冲突
      display: [
        'flex-center',
        'flex-center-x',
        'flex-center-y',
      ],
      
      /* -------------------------------------------------------------------------
         ❌ 不需要配置的内容(tailwind-merge 默认能处理)
         ------------------------------------------------------------------------- */
      
      // ❌ 不需要配置:@theme 中的标准颜色
      // 例如: --color-brand-color → 自动生成 text-brand-color, bg-brand-color 等
      // tailwind-merge 默认能识别所有 text-*, bg-*, border-* 等标准格式
      
      // ❌ 不需要配置:@theme 中的标准间距
      // 例如: --spacing-100 → 自动生成 p-100, m-100, w-100, h-100 等
      // tailwind-merge 默认能识别所有 p-*, m-*, w-*, h-* 等标准格式
      
      // ❌ 不需要配置:@apply 定义的组件类
      // 例如: .avatar, .btn-primary, .card
      // 这些不是工具类,tailwind-merge 会当作普通字符串处理
      // 而且不建议在 tailwind-merge 中使用(见 TAILWIND_MERGE_BEST_PRACTICES.md)
    },
    
    /* ---------------------------------------------------------------------------
       冲突关系配置
       ---------------------------------------------------------------------------
       说明:只有包含多个 CSS 属性的组合类需要配置冲突关系
       例如:text-s-title-s 包含 font-size + font-weight + line-height
       必须声明它与这三个 classGroup 都冲突
       --------------------------------------------------------------------------- */
    conflictingClassGroups: {
      // text-style-combo 与三个样式类都冲突
      'text-style-combo': ['font-size', 'font-weight', 'line-height'],
      
      // 反向声明:让 tailwind-merge 知道双向冲突关系
      'font-size': ['text-style-combo'],
      'font-weight': ['text-style-combo'],
      'line-height': ['text-style-combo'],
    },
  },
});

export const twMerge = createTailwindMerge(() => twMergeConfig);

/**
 * 合并 Tailwind CSS 类名的工具函数
 * 
 * 功能:
 * - 自动合并冲突的 Tailwind 类(后面的覆盖前面的)
 * - 支持条件类名(使用 clsx)
 * - 支持自定义的 @utility 组合类
 * 
 * @example
 * // 基础合并:后面的覆盖前面的
 * cn('px-2 py-1', 'p-3') // => 'p-3'
 * 
 * @example
 * // 条件类名
 * cn('text-red-500', condition && 'text-blue-500')
 * // => 'text-blue-500' (if condition is true)
 * 
 * @example
 * // 组合类之间的合并
 * cn('text-s-title-s', 'text-s-title-l') // => 'text-s-title-l'
 * 
 * @example
 * // 组合类与单独属性类的合并
 * cn('text-2xl font-bold', 'text-s-title-s')
 * // => 'text-s-title-s' (删除了 text-2xl 和 font-bold)
 * 
 * @example
 * // 单独属性类覆盖组合类
 * cn('text-s-title-s', 'font-bold')
 * // => 'font-bold' (删除了 text-s-title-s)
 */
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

导入使用:

import { twMerge } from "tailwind-merge";
import { cn } from "@/utils/cn";
<div className={twMerge("grid flex-center")}>aaa</div>
<div className={cn("grid flex-center")}>bbb</div>

效果:

可以看到,因为配置了flex-center是属于display类别的,所以会和前面的grid进行合并。

5.注意点:

flex-center定义的类display: flex;align-items: center;justify-content: center;都是同一类别的,所以可以直接配置到tailwind-merge的display中。但是下面的text-s-title-s就是不同的类别。

/* 
  定义文字样式组合类 - 使用 @utility
  这样定义的类支持响应式变体,如:md:text-s-title-s
*/
@utility text-s-title-s {
  font-size: 16px;
  font-weight: 500;
  line-height: 16px;
}

@utility text-s-title-m {
  font-size: 20px;
  font-weight: 500;
  line-height: 20px;
}

@utility text-s-title-l {
  font-size: 24px;
  font-weight: 600;
  line-height: 24px;
}

所以我们在tailwind配置中是这样子配置的:

conflictingClassGroups: {
      // text-style-combo 与三个样式类都冲突
      'text-style-combo': ['font-size', 'font-weight', 'line-height'],
      
      // 反向声明:让 tailwind-merge 知道双向冲突关系
      'font-size': ['text-style-combo'],
      'font-weight': ['text-style-combo'],
      'line-height': ['text-style-combo'],
    },

为什么要这样子配置:

原因就是tailwind是原子级别的,所以tailwind-merge默认你定义的类名也是,只会去替换你在配置中定义的类别。如果我们只定义这个

'font-size': [
        {
          text: [...customFontSizes ,...textStyleUtilityClasses],
        },
      ],

那么text-s-title-s只会去覆盖前面的font-size,但是不会去覆盖font-weight和line-height

最好不要使用apply来自定义组合类(因为你不知道这个组合类应该放在配置项的哪一块。比如上面的flex-center是属于定位的,就是放在display这里),不然tailwind-merge识别不了,解释如文档。然后他会把识别不了的就不进行合并,应用到div上面去。

三.与clsx一起使用

1.clsx是什么?

动态类名拼接库。这个一般都是用classNames和clsx

a. 基本介绍

classNames: 最早的条件类名合并库(2015年),功能全面但体积稍大

clsx: 更轻量、更快的替代方案(2018年),与 classNames API 兼容,体积更小(~200B)

clsx 和 classNames 功能完全一致,clsx 更轻量更快

两者都只做条件拼接,不处理 Tailwind 类名冲突

tailwind-merge 专门解决 Tailwind 类名冲突问题

最佳组合: cn = twMerge + clsx(您的项目可以考虑这样做)

您当前的项目已经有 tailwind-merge,如果需要添加条件类名功能,建议安装 clsx 并创建 cn 工具函数!

2.为什么要使用动态类名拼接库?

// ❌ 传统方式 - 难以维护
function Button({ variant, size, disabled, loading, className }) {
  let btnClass = 'btn';
  if (variant === 'primary') btnClass += ' btn-primary';
  if (variant === 'secondary') btnClass += ' btn-secondary';
  if (size === 'sm') btnClass += ' btn-sm';
  if (size === 'lg') btnClass += ' btn-lg';
  if (disabled) btnClass += ' btn-disabled';
  if (loading) btnClass += ' btn-loading';
  if (className) btnClass += ' ' + className;
  
  return <button className={btnClass}>Click</button>;
}

// ✅ 使用 clsx - 清晰直观
function Button({ variant, size, disabled, loading, className }) {
  return (
    <button className={clsx(
      'btn',
      {
        'btn-primary': variant === 'primary',
        'btn-secondary': variant === 'secondary',
        'btn-sm': size === 'sm',
        'btn-lg': size === 'lg',
        'btn-disabled': disabled,
        'btn-loading': loading
      },
      className  // 外部传入的类名
    )}>
      Click
    </button>
  );
}

3.为什么要与clsx一起使用?

如见该文章

4.总结

clsx和classNames一样,主要是为了我们程序员好书写类名,但是不会去处理那些有冲突的样式, 但是tailwind-merge拿到clsx返回的类名,会把这些类名进行合并,然后给到div合并后的类名。

四.最终封装

import { type ClassValue, clsx } from 'clsx';
import {
  createTailwindMerge,
  getDefaultConfig,
  mergeConfigs,
} from 'tailwind-merge';

const defaultConfig = getDefaultConfig();

/* ============================================================================
   配置说明:什么需要配置,什么不需要配置
   ============================================================================
   
   ✅ 需要配置的情况:
   
   1. @theme 中定义的非标准名称(如自定义的 font-size、shadow 等)
      例如:--font-size-menu-choice → 生成 text-menu-choice
      虽然会自动生成,但建议配置以确保 tailwind-merge 能识别
   
   2. @utility 定义的组合类(包含多个 CSS 属性)
      例如:@utility text-s-title-s { font-size + font-weight + line-height }
      必须配置!需要创建独立 classGroup + conflictingClassGroups
   
   3. @utility 定义的自定义工具类(如 flex-center)
      例如:@utility flex-center { display + align-items + justify-content }
      需要配置到对应的 classGroup 中
   
   ❌ 不需要配置的情况:
   
   1. @theme 中的标准颜色、间距(会自动生成标准 Tailwind 类)
      例如:--color-brand → 生成 text-brand, bg-brand, border-brand
      例如:--spacing-100 → 生成 p-100, m-100, w-100, h-100
      tailwind-merge 默认就能识别这些标准格式!
   
   2. @apply 定义的组件类(如 .avatar、.btn-primary)
      这些不是工具类,tailwind-merge 会把它们当作普通字符串处理
      不需要配置,也不建议在 tailwind-merge 中使用
   
   ============================================================================ */

// ===== 1. @theme 定义的自定义字体大小 =====
// 对应 CSS: --font-size-menu-choice, --font-size-title-l 等
// 生成的类: text-menu-choice, text-title-l 等
const customFontSizes = [
  'menu-choice',
  'menu-not-choice',
  'title-l',
  'title-m',
  'title-s',
  'number-l',
  'number-m',
  'number-s',
  'body-l',
  'body-m',
  'body-s',
  'tips',
];

// ===== 2. @utility 定义的文字样式组合类 =====
// 对应 CSS: @utility text-s-title-s { font-size + font-weight + line-height }
// 特点: 包含多个 CSS 属性,需要与多个 classGroup 冲突
// 必须配置: 创建独立组 + conflictingClassGroups
const textStyleUtilityClasses = [
  's-title-s',
  's-title-m',
  's-title-l',
  's-body-s',
  's-body-m',
  's-body-l',
];

export const twMergeConfig = mergeConfigs(defaultConfig, {
  extend: {
    classGroups: {
      /* -------------------------------------------------------------------------
         字体大小相关配置
         ------------------------------------------------------------------------- */
      
      // ✅ 需要配置:@theme 中定义的自定义字体大小
      // 对应 CSS: --font-size-menu-choice 等
      // 生成的类: text-menu-choice, text-title-l 等
      'font-size': [
        {
          text: customFontSizes,
        },
      ],
      
      /* -------------------------------------------------------------------------
         组合类配置(包含多个 CSS 属性的 @utility 类)
         ------------------------------------------------------------------------- */
      
      // ✅ 必须配置:@utility 定义的文字样式组合类
      // 对应 CSS: @utility text-s-title-s { font-size + font-weight + line-height }
      // 原因: 包含多个属性,需要与 font-size、font-weight、line-height 都冲突
      'text-style-combo': [
        {
          text: textStyleUtilityClasses,
        },
      ],
      
      /* -------------------------------------------------------------------------
         其他自定义工具类
         ------------------------------------------------------------------------- */
      
      // ✅ 需要配置:@theme 中定义的自定义阴影
      // 对应 CSS: --shadow-d-base, --shadow-d-dropdown 等
      // 生成的类: shadow-d-base, shadow-d-dropdown 等
      shadow: [
        {
          shadow: ['d-base', 'd-dropdown', 'd-button'],
        },
      ],
      
      // ✅ 需要配置:@utility 定义的布局工具类
      // 对应 CSS: @utility flex-center { display + align-items + justify-content }
      // 原因: 自定义类,需要加入 display 组才能与 flex、grid 等冲突
      display: [
        'flex-center',
        'flex-center-x',
        'flex-center-y',
      ],
      
      /* -------------------------------------------------------------------------
         ❌ 不需要配置的内容(tailwind-merge 默认能处理)
         ------------------------------------------------------------------------- */
      
      // ❌ 不需要配置:@theme 中的标准颜色
      // 例如: --color-brand-color → 自动生成 text-brand-color, bg-brand-color 等
      // tailwind-merge 默认能识别所有 text-*, bg-*, border-* 等标准格式
      
      // ❌ 不需要配置:@theme 中的标准间距
      // 例如: --spacing-100 → 自动生成 p-100, m-100, w-100, h-100 等
      // tailwind-merge 默认能识别所有 p-*, m-*, w-*, h-* 等标准格式
      
      // ❌ 不需要配置:@apply 定义的组件类
      // 例如: .avatar, .btn-primary, .card
      // 这些不是工具类,tailwind-merge 会当作普通字符串处理
      // 而且不建议在 tailwind-merge 中使用(见 TAILWIND_MERGE_BEST_PRACTICES.md)
    },
    
    /* ---------------------------------------------------------------------------
       冲突关系配置
       ---------------------------------------------------------------------------
       说明:只有包含多个 CSS 属性的组合类需要配置冲突关系
       例如:text-s-title-s 包含 font-size + font-weight + line-height
       必须声明它与这三个 classGroup 都冲突
       --------------------------------------------------------------------------- */
    conflictingClassGroups: {
      // text-style-combo 与三个样式类都冲突
      'text-style-combo': ['font-size', 'font-weight', 'line-height'],
      
      // 反向声明:让 tailwind-merge 知道双向冲突关系
      'font-size': ['text-style-combo'],
      'font-weight': ['text-style-combo'],
      'line-height': ['text-style-combo'],
    },
  },
});

export const twMerge = createTailwindMerge(() => twMergeConfig);

/**
 * 合并 Tailwind CSS 类名的工具函数
 * 
 * 功能:
 * - 自动合并冲突的 Tailwind 类(后面的覆盖前面的)
 * - 支持条件类名(使用 clsx)
 * - 支持自定义的 @utility 组合类
 * 
 * @example
 * // 基础合并:后面的覆盖前面的
 * cn('px-2 py-1', 'p-3') // => 'p-3'
 * 
 * @example
 * // 条件类名
 * cn('text-red-500', condition && 'text-blue-500')
 * // => 'text-blue-500' (if condition is true)
 * 
 * @example
 * // 组合类之间的合并
 * cn('text-s-title-s', 'text-s-title-l') // => 'text-s-title-l'
 * 
 * @example
 * // 组合类与单独属性类的合并
 * cn('text-2xl font-bold', 'text-s-title-s')
 * // => 'text-s-title-s' (删除了 text-2xl 和 font-bold)
 * 
 * @example
 * // 单独属性类覆盖组合类
 * cn('text-s-title-s', 'font-bold')
 * // => 'font-bold' (删除了 text-s-title-s)
 */
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

这里的自定义配置这一块要根据自己的tailwind来。

导入使用:

import { cn } from "@/utils/cn";
 <div className={cn("grid text-2xl font-bold",'text-s-title-s')}>bbb</div>

五.demo地址

地址:gitee.com/rui-rui-an/…

tailwindcss v4的基础使用

2026年2月25日 11:03

一:前言

为什么要使用tailwindcss? 主要是因为可以减少命名和选择器的烦恼,不用去定义class类名了,每次要定义类名都想的头疼。然后使用tailwindcss来开发,可以减少 CSS 文件大小,只生成实际使用的样式,通过 PurgeCSS 移除未使用的 CSS,生产环境文件体积极小。最后就是内置了响应式设计,内置响应式前缀(sm:, md:, lg:, xl:, 2xl:,可以轻松创建移动优先的响应式布局。

二:安装

参考文档:tailwindcss.com/docs/instal…

我这边使用的是vite,那么就跟着vite的安装文档来走就行了(最好大家跟着文档来走):

a.安装命令:

npm install tailwindcss @tailwindcss/vite

b.在vite.config.ts中配置它

import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [
    tailwindcss(),
  ],
})

c.导入

@import "tailwindcss";

d.在main.tsx中导入它:

注意把normalize.css和reset.css都可以干掉了,因为tailwindcss里面内置了基础样式重置系统

e.插件安装:

使用tailwindcss一定一定要安装插件,因为tailwindcss的内置样式实在太多了,不需要记也记不得这么全
这里我们使用:Tailwind CSS IntelliSense


这样子写的时候就有提示了:

三:使用

安装完成之后,就可以这样子使用它了:

四:一些常用的css样式在tailwind中的写法

a.最常用的margin和padding:

p-4:设置元素的内边距(padding)为 1rem,相当于:padding: 16px。

m-8:设置元素的外边距(margin)为 2rem, 相当于: padding: 32px。

mt-4:设置元素的上外边距为 1rem,相当于: margin-top: 16px。

my-1: 相当于:margin-top:4px和margin-bottom:4px的组合。

mx-1: 相当于:margin-left:4px和margin-right:4px的组合。

注意这里的单位是rem,如果不去更改html的font-size的大小话,一般就是1rem=16px

所以在不更改大小的前提下 p-1,就代表设置内边距为4px。

但是有个问题:就是上面很多都是预设值,但是ui有时候给的没有这个值,要使用自定义的值怎么办?

那么就用mt-[188px]这种格式:这就代表了:margin-top:188px。所有需要自定义的,都是使用[]这个来自定义,记得要带上单位

注意:这里一定要学会看文档

还有两个要记住的,就是mx-auto 和 my-auto

my-auto常用场景:在 flex 容器中垂直居中元素

/* my-auto 相当于 */
.my-auto {
  margin-top: auto;
  margin-bottom: auto;
}

mx-auto常用场景:水平居中块级元素

/* mx-auto 相当于 */
.mx-auto {
  margin-left: auto;
  margin-right: auto;
}

b.设置宽高:

这里只写高度,宽度同理

**h-**_**<number>**_ height: calc(var(--spacing) * _<number>_);
**h-full** height: 100%;
**h-screen** height: 100vh;
**h-dvh** height: 100dvh;
**h-dvw** height: 100dvw;
**h-[**_**<value>**_**]** height: _<value>_;

h-1:代表了height:0.25rem,也就是 height:4px

h-[5188px],代表了:height:5188px

高度继承可以使用:h-full来进行继承

c.文字颜色和背景颜色:

文字颜色跟背景颜色没什么好说的,虽然预设了很多值,但是能用的没几个,因为ui有自己的审美,所以一般都是用:text-[#xxxxxx] bg-[#xxxxxx]

<div className="mt-10 text-[#50d71e] bg-blue-400">测试tailwindcss</div>

文字加粗:

一般就是用font-normal 和 font-bold

**font-thin** font-weight: 100;
**font-normal** font-weight: 400;
**font-medium** font-weight: 500;
**font-bold** font-weight: 700;
**font-[**_**<value>**_**]** font-weight: _<value>_;

d.hover更改状态:

<button class="bg-sky-500 hover:bg-sky-700 ...">Save changes</button>

e.定位:

positon是我常忘记的,直接写属性值就行了

<div className="pointer-events-none fixed bottom-0 left-0">
  {process.env.DEPLOY_TIME}
</div>

f.鼠标样式:

一般就用到这两个

**cursor-pointer** cursor: pointer;
**cursor-not-allowed** cursor: not-allowed;

g.border样式:

圆角:我们在css经常写:border-radius:50%来画圆 ,这里可以用rounded-full来处理。

如果只是一些圆角,那么可以使用预设值或者自定义值来处理

**rounded-xs** border-radius: var(--radius-xs); _/* 0.125rem (2px) */_
**rounded-sm** border-radius: var(--radius-sm); _/* 0.25rem (4px) */_
**rounded-md** border-radius: var(--radius-md); _/* 0.375rem (6px) */_
**rounded-lg** border-radius: var(--radius-lg); _/* 0.5rem (8px) */_
**rounded-xl** border-radius: var(--radius-xl); _/* 0.75rem (12px) */_
**rounded-2xl** border-radius: var(--radius-2xl); _/* 1rem (16px) */_
**rounded-none** border-radius: 0;
**rounded-full** border-radius: calc(infinity * 1px);
**rounded-[**_**<value>**_**]** border-radius: _<value>_;

如果是需要画一条红色的虚线,则比原来的boder-bottom:1px dash red麻烦点,这里分成了三个属性,分别是border-width(负责宽度和哪个方向的边框),border-color(负责颜色)和border-style(负责虚线还是实线)。

下面是一个2px的虚线红色的下边框

<div className="mt-10 border-b-2 border-red-500 border-dashed">测试红色虚线</div>

h.单行(多行)文本超出显示...

传统css:

.my-ellipsis {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

在tailwind中有两种,可以使用简写的,也可以使用完整的三个属性

<div className="truncate">长文本...</div>
<div className="whitespace-nowrap overflow-hidden text-ellipsis">长文本...</div>

多行的传统css:

.text-ellipsis-multiline {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 3; /* 显示3行 */
  overflow: hidden;
}

Tailwind CSS 提供了 line-clamp 工具类

<div className="line-clamp-3">
  多行文本内容...
</div>

常用的 line-clamp 类:

line-clamp-1 - 显示1行

line-clamp-2 - 显示2行

line-clamp-3 - 显示3行

line-clamp-4 - 显示4行

line-clamp-5 - 显示5行

line-clamp-6 - 显示6行

line-clamp-none - 取消行数限制

i:响应式布局

前缀 屏幕宽度 CSS 媒体查询
<font style="color:rgb(53, 148, 247);background-color:rgba(59, 170, 250, 0.1);">sm</font> ≥640px <font style="color:rgb(53, 148, 247);background-color:rgba(59, 170, 250, 0.1);">@media (min-width: 640px)</font>
<font style="color:rgb(53, 148, 247);background-color:rgba(59, 170, 250, 0.1);">md</font> ≥768px <font style="color:rgb(53, 148, 247);background-color:rgba(59, 170, 250, 0.1);">@media (min-width: 768px)</font>
<font style="color:rgb(53, 148, 247);background-color:rgba(59, 170, 250, 0.1);">lg</font> ≥1024px <font style="color:rgb(53, 148, 247);background-color:rgba(59, 170, 250, 0.1);">@media (min-width: 1024px)</font>
<font style="color:rgb(53, 148, 247);background-color:rgba(59, 170, 250, 0.1);">xl</font> ≥1280px <font style="color:rgb(53, 148, 247);background-color:rgba(59, 170, 250, 0.1);">@media (min-width: 1280px)</font>
<font style="color:rgb(53, 148, 247);background-color:rgba(59, 170, 250, 0.1);">2xl</font> ≥1536px <font style="color:rgb(53, 148, 247);background-color:rgba(59, 170, 250, 0.1);">@media (min-width: 1536px)</font>
<div class="text-sm md:text-base lg:text-lg">
  <!-- 移动端: 小号字, 平板: 基础字号, 桌面: 大号字 -->
</div>

j:最重要的布局类(flex和grid布局)

1.flex布局

flex这里不多讲,一般大家都是用的比较多,主要是看看在tailwind里面是怎么写的

display:flex.(原来css) => flex(在tailwindcss里面)

justify-content(主轴排列方式):

**justify-start** justify-content: flex-start;
**justify-end** justify-content: flex-end;
**justify-center** justify-content: center;
**justify-between** justify-content: space-between;
**justify-around** justify-content: space-around;
**justify-evenly** justify-content: space-evenly;
**justify-stretch** justify-content: stretch;

align-items(侧轴排列方式):

**items-start** align-items: flex-start;
**items-end** align-items: flex-end;
**items-center** align-items: center;
**items-baseline** align-items: baseline;
**items-stretch** align-items: stretch;

flex方向:

**flex-row** flex-direction: row;
**flex-row-reverse** flex-direction: row-reverse;
**flex-col** flex-direction: column;
**flex-col-reverse** flex-direction: column-reverse;

flex换行:

**flex-nowrap** flex-wrap: nowrap;
**flex-wrap** flex-wrap: wrap;
**flex-wrap-reverse** flex-wrap: wrap-reverse;

flex属性:flex 是 CSS Flexbox 中的简写属性,用于同时设置 flex-grow、flex-shrink 和 flex-basis,控制弹性项目在容器中如何伸缩以适应可用空间

这里面最常用的就是flex-1:允许弹性物品根据需要大小变化
意思就是:比如在一个flex盒子中,里面有3个子盒子,有两个子盒子设置了flex-1,还有一个没有设置,那么当宽度变化时,这个没设置的盒子的宽度是固定的,两个flex-1的盒子宽度会随着父盒子宽度的增加而增加,减小而减小。

gap:(用于设置flex和grid布局中子盒子的间距,这个gap用的很多)

**gap-**_**<number>**_ gap: calc(var(--spacing) * _<value>_);
**gap-[**_**<value>**_**]** gap: _<value>_;
**gap-x-**_**<number>**_ column-gap: calc(var(--spacing) * _<value>_);
**gap-x-[**_**<value>**_**]** column-gap: _<value>_;
**gap-y-**_**<number>**_ row-gap: calc(var(--spacing) * _<value>_);
**gap-y-[**_**<value>**_**]** row-gap: _<value>_;

如下图,主要就是用于子盒子之间的间距的,可以使用gap-来设置统一的间距,也可以gap-x-来设置x轴的间距

2.grid布局

gird真的很好用,能省写很多盒子嵌套,虽然使用flex都能解决布局问题,但是有时候使用grid布局会更加的优雅。

这里建议去学习一下阮一峰老师的grid的文档:ruanyifeng.com/blog/2019/0…

举两个例子说明grid的好用:
例子1:三列等宽布局

这里写一个三列等宽的grid布局: 这里的1fr相当于flex布局中子盒子的宽度设置为:flex:1的效果

display: grid;
grid-template-columns: repeat(3, 1fr);

在flex布局中有点麻烦,原因盒子肯定不止3个(所以子盒子不能使用flex:1,得使用百分比),超过3个就要使用flex-wrap: wrap;而且要考虑到盒子之间的间距:

.flex-container {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

.flex-item {
  flex: 0 0 calc(33.333% - 12px); /* 减去 gap 的影响 */
}

例子2:项目实际例子

如图实现上面这个效果,如果要flex要用多少盒子嵌套,大家想一想应该有个基本的思维。

但是使用flex布局,只需要定义4列两行的布局。

<div className="grid grid-flow-col grid-cols-4 grid-rows-[repeat(2,auto)] gap-2 border-b py-4">
  <span className="text-s-body-m text-body-m">Customer Name</span>
  <span>{detailData?.clientName}</span>
  <span className="text-s-body-m text-body-m">Customer ID</span>
  <span>{detailData?.customerId}</span>
  <span className="text-s-body-m text-body-m">Bank CIF</span>
  <span>{detailData?.cif}</span>
  <span className="text-s-body-m text-body-m">Asset / Network</span>
  <span>
    {detailData?.assetCode}({detailData?.chainCode})
  </span>
</div>

可以看到,我这里基本上没有多余的盒子。

解释一下:

\

3.列一下原类和在taiwindcss里面的写法

grid-template-columns:属性定义每一列的列宽

**grid-cols-**_**<number>**_ grid-template-columns: repeat(_<number>_, minmax(0, 1fr));
**grid-cols-none** grid-template-columns: none;
**grid-cols-subgrid** grid-template-columns: subgrid;
**grid-cols-[**_**<value>**_**]** grid-template-columns: _<value>_;
**grid-cols-(**_**<custom-property>**_**)** grid-template-columns: var(_<custom-property>_);

grid-template-rows:属性定义每一行的行高。

**grid-rows-**_**<number>**_ grid-template-rows: repeat(_<number>_, minmax(0, 1fr));
**grid-rows-none** grid-template-rows: none;
**grid-rows-subgrid** grid-template-rows: subgrid;
**grid-rows-[**_**<value>**_**]** grid-template-rows: _<value>_;
**grid-rows-(**_**<custom-property>**_**)** grid-template-rows: var(_<custom-property>_);

grid-auto-flow:默认的放置顺序是"先行后列",即先填满第一行,再开始放入第二行(假设定义了3行3列,则从第一行从左到右排列过去)。 默认值为:row,如果设置的是:column,那么就是"先列后行"

**grid-flow-row** grid-auto-flow: row;
**grid-flow-col** grid-auto-flow: column;
**grid-flow-dense** grid-auto-flow: dense;
**grid-flow-row-dense** grid-auto-flow: row dense;
**grid-flow-col-dense** grid-auto-flow: column dense;

justify-items属性:设置单元格内容的水平位置(左中右),align-items属性:设置单元格内容的垂直位置(上中下)

**justify-items-start** justify-items: start;
**justify-items-end** justify-items: end;
**justify-items-center** justify-items: center;
**justify-items-stretch** justify-items: stretch;
**items-start** align-items: flex-start;
**items-end** align-items: flex-end;
**items-center** align-items: center;
**items-baseline** align-items: baseline;
**items-stretch** align-items: stretch;

justify-content属性:是整个内容区域在容器里面的水平位置(左中右),align-content属性:是整个内容区域的垂直位置(上中下)。

这个的属性跟flex上的属性基本上是一致的,就不列了,主要是要知道这个属性是干嘛的。

l.使用!来达到最高等级

在css中使用!important来强制该样式为最高等级,在tailwindcss中只需要在对应的css样式前面加一个!

<div className="bg-[#0094fc] h-20 mt-10 !bg-[#917d35]"></div>

背景色bg-[#917d35]会覆盖bg-[#0094fc]

m.复用类名

各种指令的效果请查看官方文档

只需要在index.css文件中使用@apply字段来应用多个样式

使用时这样子就行了:

效果:

注意:这里有个问题,就是虽然可以这样子复用类名,但是在输入的时候没有提示,相对来说没有特别友好。

n.自定义类名

tailwindcss. v4版本较v3版本有较大的升级,之前v3是在tailwind.config.js中自定义自己的样式。但是现在v4版本不需要了,只需要在统一的css文件里面进行定义即可。

现在使用@theme来自定义类名

@theme {
  /* 颜色扩展 - 这些会生成 text-brand-color, bg-brand-color 等类 */
  --color-brand-color: #c5d535;
  --color-bg-blue: #0094ff;
  /* 间距扩展 - 这些会生成  m-72, w-100 等类 */
  --spacing-100: 400px;
  /* 字体大小扩展 - 这些会生成 text-menu-choice, text-title-l 等类 */
  --font-size-title-l: 1.5rem;
  /* 阴影扩展 - 这些会生成 shadow-d-base, shadow-d-button 等类 */
  --shadow-d-button: 0 4px 4px 0 rgb(0 0 0 / 0.2);
  
}

使用:
这里定义了颜色,那么不管是文字还是背景则都可以使用这个自定义类名的颜色

<div className="bg-bg-blue mt-5">123</div>
<div className="bg-brand-color mt-5 text-brand-color">123</div>

使用@theme来定义的在输入时就有提示,相对来说就友好很多了。

以下是可以定义的内容:

文档:tailwindcss.com/docs/theme#…

Namespace Utility classes
**--color-*** Color utilities like **bg-red-500**
, **text-sky-300**
, and many more
**--font-*** Font family utilities like **font-sans**
**--text-*** Font size utilities like **text-xl**
**--font-weight-*** Font weight utilities like **font-bold**
**--tracking-*** Letter spacing utilities like **tracking-wide**
**--leading-*** Line height utilities like **leading-tight**
**--breakpoint-*** Responsive breakpoint variants like **sm:***
**--container-*** Container query variants like **@sm:***
and size utilities like **max-w-md**
**--spacing-*** Spacing and sizing utilities like **px-4**
, **max-h-16**
, and many more
**--radius-*** Border radius utilities like **rounded-sm**
**--shadow-*** Box shadow utilities like **shadow-md**
**--inset-shadow-*** Inset box shadow utilities like **inset-shadow-xs**
**--drop-shadow-*** Drop shadow filter utilities like **drop-shadow-md**
**--blur-*** Blur filter utilities like **blur-md**
**--perspective-*** Perspective utilities like **perspective-near**
**--aspect-*** Aspect ratio utilities like **aspect-video**
**--ease-*** Transition timing function utilities like **ease-out**
**--animate-*** Animation utilities like **animate-spin**

o.自定义主题

tailwindcss中主题切换的原理:这里的主题切换主要是通过theme中定义css变量,然后切换时就切换html上的类名来实现(官方文档

@import "tailwindcss";

@theme {
  /* 主题切换的一些变量 */
  --color-main-color: var(--main-color);
  --color-secondary-color: var(--secondary-color);
}
/* 主题切换 CSS 变量定义 */
:root {
  --main-color: rgba(232, 176, 176, 0.87);
  --secondary-color: rgba(51, 183, 159, 0.87);
}

.dark {
  --main-color: rgba(19, 18, 18, 0.87);
  --secondary-color: rgba(52, 5, 5, 0.87);
}
const toggleTheme = () => {
  document.documentElement.classList.toggle("dark");
};
<div>切换下面的主题盒子</div>
<Button type="primary" onClick={ ()=> toggleTheme() }>Button</Button>
<div className="bg-main-color w-100 h-100 text-secondary-color">主题相关的盒子</div>

五.tailwindcss局限性

官方文档:tailwindcss.com/docs/stylin…

有一个例子是:

<div class="grid flex"> <!-- ... --></div>

请问上面的样式最后是应用哪个display的属性? 按照我们的经验,肯定觉得是flex,但是实际上是grid,原因是因为taiwindcss时根据样式表的顺序来应用的,而不是我们自己写的顺序

当你使用同样的一个样式的时候,跟我们以往的经验不同,在后面的样式不会重叠前面的。

另外一个例子就是

<div className="px-2 py-1 p-3 w-10 h-10 bg-amber-200"></div>

这样子,你觉得最终是多少呢?

✅ 最终结果:
由于 px-* 和 py-* 类在样式表中定义得比 p-* 类更晚,所以:
最终的 padding 效果是:
padding-top: 0.25rem; (4px) - 来自 py-1
padding-bottom: 0.25rem; (4px) - 来自 py-1
padding-left: 0.5rem; (8px) - 来自 px-2
padding-right: 0.5rem; (8px) - 来自 px-2
📝 为什么不是 p-3 的 12px 全方向?
因为在 Tailwind 的样式表中,方向性的 padding 类(px-*, py-*)定义在通用 padding 类(p-*)之后,所以它们会覆盖 p-3 的效果。
简单说:最终是上下 4px,左右 8px 的 padding! 

相信大家肯定觉得很难受了,所以就引出了下一个工具:tailwind-merge

这个工具就能按照我们的想法来合并css,后面我也会写一个关于tailwind-merge的使用。

Vite创建react项目

2026年2月25日 11:01

1.背景

为什么要是用vite来创建react项目,主要是cra已经不太适配react了,react官网也放弃了cra的创建,原因是react19和cra不太兼容。所以如果大家用的是18的话,还是可以继续用cra来创建的。参考官网
另外的话用vite默认创建的话就是不支持服务端渲染(SSR),没有seo(适合toB项目),如果项目要支持seo的话,需要配置,不如建议大家使用nextjs ,或者remix来创建项目(适合toC项目)

2.创建命令

 npm create vite@latest

下面跟着选就行了,输入项目名,选择react+ts(ts是否要看大家自己的选择,建议还是选上,ts是趋势。小项目或者时间紧的话可以直接使用js也可以。)

3.生成git仓库

由于这里生成的项目没有git仓库,所以我们git init来生成一个仓库。然后git add . ,git commit- m"" 来进行初次提交。

4.规划项目目录

在src文件夹下面建立这些文件夹,详细的作用对应如下:

5.删除多余的东西

main.tsx中一些删除掉的东西就没必要引入了

6.支持scss(其他的less等也可以,看自己熟悉哪个)

安装依赖

npm i sass -D

测试一下有没有用

错误使用方式(同名类名会导致样式覆盖):

正确使用方式(改为css module方式,这样子父子组件类名相同也不会样式覆盖了):

7.加入UI组件库,安装antDesign

a.安装依赖

npm i antd

b.测试(这样子你能在页面上看到蓝色的按钮就说明导入成功了)

import { Button } from 'antd';
function App() {
  return (
    <>
    <div className="ceshi">你好</div>
    <Button type="primary">Button</Button>
    </>
  )
}

export default App

c.兼容react19,请查看官方文档(因为vite初始化的react项目目前是19版本的了,所以需要兼容)

安装依赖

npm install @ant-design/v5-patch-for-react-19 --save

在main.tsx中导入

import '@ant-design/v5-patch-for-react-19';

8.vite配置@别名

配置别名分为两种配置,1.项目支持使用@符号代表src 2.让vscode编辑器支持输入@符号时有下一个目录的提示

1.项目支持使用@符号代表src

a.先安装ts中支持path的依赖

<font style="color:rgb(6, 6, 7);">@types/node</font>(用于 TypeScript 环境中的 <font style="color:rgb(6, 6, 7);">path</font> 模块)。如果没有安装,可以通过以下命令安装:

npm install --save-dev @types/node

b.配置vite.config.ts

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path' // 导入 path 模块

// https://vite.dev/config/
export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'), // 配置 '@' 别名指向项目根目录下的 src 文件夹
    },
  },
})

c.测试一下

2.让vscode编辑器支持输入@符号时有下一个目录的提示

可以看到上面截图有波浪线非常不舒服,并且输入@符号时没有下一个文件夹的提示
往这文件中(tsconfig.app.json)加入以下配置:

"compilerOptions": {
    "baseUrl": ".", // 设置基准路径
    "paths": {
      "@/*": ["src/*"] // 配置 '@' 别名指向 src 文件夹
    },
  }

说明
<font style="color:rgb(6, 6, 7);">tsconfig.json</font> 是项目的主配置文件,用于定义整个项目的 TypeScript 编译选项。:\ <font style="color:rgb(6, 6, 7);">tsconfig.app.json</font> 用于专门配置客户端应用代码。它扩展或覆盖了 <font style="color:rgb(6, 6, 7);">tsconfig.json</font> 中的设置,以满足应用代码的特定需求。例如,你可以在这里设置 <font style="color:rgb(6, 6, 7);">baseUrl</font><font style="color:rgb(6, 6, 7);">paths</font> 来定义路径别名\ <font style="color:rgb(6, 6, 7);">tsconfig.node.json</font> 用于配置 Node.js 环境中的 TypeScript,例如 Vite 的配置文件 <font style="color:rgb(6, 6, 7);">vite.config.ts</font>。它确保在 Node.js 环境中运行的代码(如构建脚本)使用正确的编译选项

成功的情况如下图(有路径提示了):

9.样式初始化(消灭浏览器差异以及盒子原有样式)

(1)normalize.css保证我们的代码在各浏览器的一致性

安装依赖

npm i normalize.css
main.ts引入
import 'normalize.css'
(2)消除盒子原有样式(简化版) 1.建立reset.scss,写入以下样式:
* {
    padding: 0;
    margin: 0;
    list-style: none;
    box-sizing: border-box;
  }
  html,
  body {
    height: 100%;
  }
  #root{
    height: 100%;
  }
2.在main.ts中引入:import ‘@/assets/css/reset.scss’
import '@/assets/css/reset.scss'

10.router-配置基础路由页面

安装依赖

npm install react-router-dom

在pages页面下建立这三个文件夹(Home,Layout,Login ,记得是index.tsx文件)并写入最简单的代码

function Layout(){
    return (
        <div>Layout页面</div>
    )
}
export default Layout

在router页面下建立index.tsx文件写入以下代码

import { createBrowserRouter } from 'react-router-dom'

import Home from '@/pages/Home'
import Layout from '@/pages/Layout'
import Login from '@/pages/Login'

const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout></Layout>,
  },
  {
    path: '/home',
    element: <Home></Home>,
  },
  {
    path: '/login',
    element: <Login></Login>,
  },
])

export default router

在main.tsx的导入并使用(可以把app.tsx和index.scss删掉了,用不到了)

import { createRoot } from 'react-dom/client'
import 'normalize.css'
import '@/assets/css/reset.scss'
import '@ant-design/v5-patch-for-react-19'
import router from '@/router'
import { RouterProvider } from 'react-router-dom'

createRoot(document.getElementById('root')!).render(
  <RouterProvider router={router}></RouterProvider>
)

11.加入状态管理工具(这里以rtk+redux+react-redux为例)

a.安装依赖

npm install @reduxjs/toolkit react-redux

b.在store文件夹下面建立modules文件夹(用于区分仓库)和index.tsx(仓库的统一出口),文件夹如下

c.如上图,在modules下建立user.tsx,写入以下代码

import { createSlice } from '@reduxjs/toolkit'

export const userSlice = createSlice({
  name: 'user',
  initialState: {
    value: 0,
    userInfo: {},
  },
  reducers: {
    increment: (state) => {
      state.value += 1
    },
    decrement: (state) => {
      state.value -= 1
    },
    setUserInfo: (state, action) => {
      state.userInfo = action.payload
    },
  },
})

export const { increment, decrement, setUserInfo } = userSlice.actions

// 异步写法
const fetchUser = () => {
  return async (dispatch: any) => {
    const res = await Promise.resolve()  // 假设这里去发了后台接口获取用户信息
    dispatch(setUserInfo(res))
  }
}

export { fetchUser }

export default userSlice.reducer

d.在router中的index.tsx中写入以下代码:

// src/store.js
import { configureStore } from '@reduxjs/toolkit'
import userSlice from './modules/user'

const store = configureStore({
  reducer: {
    user: userSlice,
  },
})
export default store
// 推断出 `RootState` 和 `AppDispatch` 类型
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch

e.在主应用中使用 Redux Store

<font style="color:rgba(0, 0, 0, 0.9);">src/main.tsx</font> 文件d中,使用 <font style="color:rgba(0, 0, 0, 0.9);">Provider</font> 组件将 Redux Store 传递给 React 组件树

import { createRoot } from 'react-dom/client'
import 'normalize.css'
import '@/assets/css/reset.scss'
import '@ant-design/v5-patch-for-react-19'
import router from '@/router'
import { RouterProvider } from 'react-router-dom'
import { Provider } from 'react-redux'
import store from '@/store'

createRoot(document.getElementById('root')!).render(
  <Provider store={store}>
    <RouterProvider router={router}></RouterProvider>
  </Provider>
)

f.在login页面中测试使用(异步和同步在react中调用时写法是一样的)

import { useSelector, useDispatch } from 'react-redux'
import { increment, decrement, fetchUser } from '@/store/modules/user'
import { RootState, AppDispatch } from '@/store'

function Login() {
  // 使用 RootState 类型推断 state 的类型
  const count = useSelector((state: RootState) => state.user.value)
  // 使用 AppDispatch 类型推断 dispatch 的类型
  const dispatch = useDispatch<AppDispatch>()
  return (
    <div>
      Login页面
      {count}
      <button onClick={() => dispatch(increment())}>Increment</button>
      <button onClick={() => dispatch(decrement())}>Decrement</button>
      <button onClick={() => dispatch(fetchUser())}>获取用户数据</button>
    </div>
  )
}
export default Login

12.加入axios进行发送请求

a.建立request.ts

具体的封装可看:blog.csdn.net/weixin_4323…

import axios, { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
import { message, Modal } from 'antd'
import store from '@/store'
import { getToken } from '@/utils/auth'

// 扩展 axios 的配置类型,添加自定义属性
declare module 'axios' {
  export interface AxiosRequestConfig {
    meta?: {
      responseAll?: boolean
    }
  }
}

// 定义后端返回的数据结构
interface ApiResponse<T = unknown> {
  code: number
  message: string
  data: T
}

// 创建 axios 实例
const service = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL || '/api', // api 的 base_url,Vite 使用 import.meta.env
  timeout: 5000, // 请求超时时间
})

// request 拦截器
service.interceptors.request.use(
  (config: InternalAxiosRequestConfig) => {
    // 如果登录了,有 token,则请求携带 token
    const state = store.getState()
    const userInfo = state.user?.userInfo as { token?: string } | undefined
    const token = userInfo?.token || getToken()
    
    if (token) {
      // 让每个请求携带 token--['X-Token'] 为自定义 key 请根据实际情况自行修改
      config.headers['X-Token'] = token
    }
    return config
  },
  (error: AxiosError) => {
    // Do something with request error
    console.log(error) // for debug
    return Promise.reject(error)
  }
)

// response 拦截器
service.interceptors.response.use(
  /**
   * 下面的注释为通过 response 自定义 code 来标示请求状态,当 code 返回如下情况为权限有问题,登出并返回到登录页
   * 如通过 xmlhttprequest 状态码标识 逻辑可写在下面 error 中
   */
  (response: AxiosResponse<ApiResponse>) => {
    const res = response.data
    
    // 处理异常的情况
    if (res.code !== 200) {
      message.error({
        content: res.message || '请求失败',
        duration: 5,
      })
      
      // 403:非法的 token; 50012:其他客户端登录了; 401:Token 过期了;
      if (res.code === 403 || res.code === 50012 || res.code === 401) {
        Modal.confirm({
          title: '确定登出',
          content: '你已被登出,可以取消继续留在该页面,或者重新登录',
          okText: '重新登录',
          cancelText: '取消',
          onOk: () => {
            // 清除用户信息和 token
            // 这里需要根据你的实际 Redux action 来调整
            // store.dispatch(logout()) 
            localStorage.removeItem('token')
            location.reload() // 为了重新实例化 react-router 对象 避免 bug
          },
        })
      }
      return Promise.reject(new Error(res.message || 'Error'))
    } else {
      // 默认只返回 data,不返回状态码和 message
      // 通过 meta 中的 responseAll 配置来决定后台是否返回所有数据(包括状态码,message 和 data)
      const isBackAll = response.config.meta?.responseAll
      if (isBackAll) {
        return res as unknown as AxiosResponse
      } else {
        return res.data as unknown as AxiosResponse
      }
    }
  },
  (error: AxiosError) => {
    console.log('err' + error) // for debug
    message.error({
      content: error.message || '网络请求失败',
      duration: 5,
    })
    return Promise.reject(error)
  }
)

export default service


b.定义api文件user.ts:

import request from '@/utils/request'
import { SuccessResponse, PageParams, PageData } from './common-types'

// 定义用户相关的接口类型
export interface LoginParams {
  username: string
  password: string
}

export interface LoginResponse {
  token: string
  userInfo: {
    id: number
    username: string
    avatar?: string
  }
}

export interface UserInfo {
  id: number
  username: string
  email?: string
  avatar?: string
}

// ========== 场景1: 复杂返回数据,必须定义类型(推荐) ==========
/**
 * 用户登录
 * ✅ 返回数据复杂,定义类型可以获得完整的智能提示
 */
export function login(data: LoginParams) {
  return request<LoginResponse>({
    url: '/user/login',
    method: 'post',
    data,
  })
}

/**
 * 获取用户信息
 * ✅ 返回数据有固定结构,定义类型
 */
export function getUserInfo() {
  return request<UserInfo>({
    url: '/user/info',
    method: 'get',
  })
}

// ========== 场景2: 简单返回数据,可以内联类型 ==========
/**
 * 修改密码
 * ✅ 返回数据简单,可以直接内联类型,不用单独定义
 */
export function changePassword(oldPwd: string, newPwd: string) {
  return request<{ success: boolean }>({
    url: '/user/password',
    method: 'put',
    data: { oldPwd, newPwd },
  })
}

/**
 * 检查用户名是否存在
 * ✅ 返回单个布尔值,内联类型即可
 */
export function checkUsername(username: string) {
  return request<{ exists: boolean }>({
    url: '/user/check',
    method: 'get',
    params: { username },
  })
}

// ========== 场景3: 使用通用类型 ==========
/**
 * 删除用户
 * ✅ 使用通用的 SuccessResponse 类型
 */
export function deleteUser(id: number) {
  return request<SuccessResponse>({
    url: `/user/${id}`,
    method: 'delete',
  })
}

/**
 * 获取用户列表(分页)
 * ✅ 使用通用的 PageData 泛型类型
 */
export function getUserList(params: PageParams) {
  return request<PageData<UserInfo>>({
    url: '/user/list',
    method: 'get',
    params,
  })
}

// ========== 场景4: 不关心返回值,可以省略类型 ==========
/**
 * 用户登出
 * ⚠️ 不关心返回值,可以不定义类型(返回 unknown)
 */
export function logout() {
  return request({
    url: '/user/logout',
    method: 'post',
  })
}

/**
 * 上报用户行为日志
 * ⚠️ 不需要处理返回值,可以省略类型
 */
export function reportLog(action: string) {
  return request({
    url: '/log/report',
    method: 'post',
    data: { action, timestamp: Date.now() },
  })
}

// ========== 场景5: 返回完整响应(包括 code 和 message) ==========
/**
 * 获取用户信息(返回完整响应,包括 code 和 message)
 * 💡 当需要获取后端返回的状态码和提示信息时使用
 */
export function getUserInfoWithFullResponse() {
  return request<UserInfo>({
    url: '/user/info',
    method: 'get',
    meta: {
      responseAll: true, // 返回完整的响应数据
    },
  })
}


这是关于axios和ts使用的一些方案对比

最好的就是使用工具自动生成api的ts的类型,这样子就不用手动维护了。

现在我使用的是混合策略,关键是因为有时候后端没有swagger文档,或者很多项目不太规范

次优方案:混合策略

● 如果后端没有文档 → 使用这个

● 核心功能定义类型(登录、用户信息等)

● 次要功能简化处理(日志上报、简单操作等)

● 通用结构复用类型(分页、成功响应等)

13.加入代码检测工具lint-staged+husky

参考:blog.csdn.net/weixin_4323…

1.安装依赖包:

npm i lint-staged --save-dev

2.在 package.json 中配置 lint-staged,利用它来调用 eslint 和 stylelint 去检查暂存区内的代码

{
  // ...
  "lint-staged": {
    "*.{ts,tsx}": [
      "eslint --fix"
    ]
  },
}

3.安装配置 husky

npm i husky --save-dev

4.在package.json中配置快捷命令,用来在安装项目依赖时生成 husky 相关文件

{
  // ...
  "scripts": {
    // ...
    "prepare": "husky && echo 'pnpm lint-staged' > .husky/pre-commit && chmod +x .husky/pre-commit"
  },
}

5.有时候配置的命令可能随着版本升级会变,但是在git commit时出现这个检测就说明弄成功了

14.template仓库

仓库地址:gitee.com/rui-rui-an/…

WebSocket 连上了,然后呢?聊聊实时数据的"后半场"

作者 yuki_uix
2026年2月25日 10:49

如果你搜索"WebSocket 教程",大概率前 10 个结果都会告诉你:只需要 new WebSocket(url) 就行了。你试了一下,确实能收发消息,看起来很简单。然后你开始写真实项目,发现:

  • 网络断了怎么办?
  • 消息顺序乱了怎么办?
  • 多个组件都需要这些数据怎么办?

这时你才意识到:连接只是开始,"后半场"才是挑战。

这篇文章是我在探索 WebSocket 生产环境实践时踩过的坑和思考的总结。我想聊聊从 Demo 到生产环境这条路上,那些容易被忽视的细节。

从 3 行代码到 300 行代码

最简单的 WebSocket(3 行代码)

// 环境:浏览器
// 场景:最基础的 WebSocket 连接

const ws = new WebSocket('wss://echo.websocket.org');

ws.onmessage = (event) => {
  console.log('收到消息:', event.data);
};

ws.send('Hello WebSocket!');

在 Demo 里确实够用了。但当我尝试把这段代码用到实际项目中时,很快就遇到了一堆问题:

问题清单

  • ❌ 网络断了,连接中断,怎么重连?
  • ❌ 消息发送失败了,要不要重试?
  • ❌ 收到的消息顺序乱了,如何处理?
  • ❌ 多个组件都需要实时数据,如何共享连接?
  • ❌ 组件卸载了,如何清理?
  • ❌ 需要在消息里区分类型(聊天、通知、系统消息),如何设计?
  • ❌ 需要心跳保活,如何实现?
  • ❌ 如何与 React 状态管理集成?

生产级别的 WebSocket(完整代码预览)

// 环境:React + WebSocket
// 场景:生产级别的 WebSocket 管理器

class WebSocketManager {
  constructor(url) {
    this.url = url;
    this.ws = null;
    this.reconnectAttempts = 0;
    this.maxReconnectAttempts = 5;
    this.reconnectDelay = 1000;
    this.heartbeatInterval = null;
    this.messageQueue = [];
    this.listeners = new Map();
  }
  
  connect() { /* 连接逻辑 */ }
  reconnect() { /* 重连逻辑 */ }
  send(data) { /* 发送消息(支持离线队列)*/ }
  subscribe(event, callback) { /* 订阅消息 */ }
  startHeartbeat() { /* 心跳保活 */ }
  handleMessage(event) { /* 消息分发 */ }
  close() { /* 清理资源 */ }
}

// 这才是真实项目需要的代码

代码行数对比

  • Demo:~10 行
  • 生产环境:~200-300 行

这 300 行都在干什么?让我们一步步拆解。

第一个挑战:连接管理

断线重连

网络并不总是稳定的。用户可能在地铁里,可能在切换 WiFi,也可能服务器临时重启。一个简单的做法是在连接断开时什么都不做,但这意味着用户将永远收不到新消息。

// 环境:浏览器
// 场景:实现自动重连机制

class WebSocketManager {
  constructor(url) {
    this.url = url;
    this.ws = null;
    this.reconnectAttempts = 0;
    this.maxReconnectAttempts = 5;
    this.reconnectDelay = 1000;
  }
  
  connect() {
    this.ws = new WebSocket(this.url);
    
    this.ws.onopen = () => {
      console.log('连接成功');
      this.reconnectAttempts = 0;  // 重置重连次数
      this.startHeartbeat();       // 开始心跳
    };
    
    this.ws.onclose = (event) => {
      console.log('连接关闭', event);
      this.stopHeartbeat();
      
      // 判断是否需要重连
      if (!event.wasClean && this.reconnectAttempts < this.maxReconnectAttempts) {
        this.reconnect();
      }
    };
    
    this.ws.onerror = (error) => {
      console.error('连接错误', error);
    };
  }
  
  reconnect() {
    this.reconnectAttempts++;
    
    // 指数退避:1s, 2s, 4s, 8s, 16s
    const delay = Math.min(
      this.reconnectDelay * Math.pow(2this.reconnectAttempts - 1),
      30000  // 最多等 30 秒
    );
    
    console.log(`${delay}ms 后重连(第 ${this.reconnectAttempts} 次)`);
    
    setTimeout(() => {
      console.log('尝试重连...');
      this.connect();
    }, delay);
  }
}

我采用了指数退避策略。为什么不是固定间隔重连?因为如果服务器真的挂了,成百上千的客户端同时每秒重连一次,可能会加剧服务器压力。指数退避能让重连间隔逐渐变长,给服务器喘息的时间。

重连策略对比

策略 优点 缺点 适用场景
固定间隔 简单 可能导致服务器压力 内网环境
指数退避 避免雪崩 重连时间可能过长 生产环境(推荐)
立即重连 快速恢复 可能被服务器拒绝 不推荐

心跳保活

为什么需要心跳?一个常见的场景是:用户打开页面后就不动了,20 分钟后回来发现收不到消息了。这是因为中间的代理服务器(如 Nginx)可能有超时设置,会主动关闭"看起来没有流量"的连接。

// 环境:浏览器 + WebSocket
// 场景:实现心跳机制

class WebSocketManager {
  constructor(url) {
    // ... 其他初始化代码
    this.heartbeatInterval = null;
    this.heartbeatTimeout = null;
  }
  
  startHeartbeat() {
    // 每 30 秒发送一次心跳
    this.heartbeatInterval = setInterval(() => {
      if (this.ws.readyState === WebSocket.OPEN) {
        this.ws.send(JSON.stringify({ type: 'ping' }));
        
        // 设置超时检测
        this.heartbeatTimeout = setTimeout(() => {
          console.warn('心跳超时,主动关闭连接');
          this.ws.close();
          // onclose 会触发重连
        }, 5000);  // 5 秒内没收到 pong 就认为超时
      }
    }, 30000);
  }
  
  stopHeartbeat() {
    if (this.heartbeatInterval) {
      clearInterval(this.heartbeatInterval);
    }
    if (this.heartbeatTimeout) {
      clearTimeout(this.heartbeatTimeout);
    }
  }
  
  handleMessage(event) {
    const message = JSON.parse(event.data);
    
    if (message.type === 'pong') {
      // 收到 pong,清除超时
      clearTimeout(this.heartbeatTimeout);
      return;
    }
    
    // 处理其他消息...
  }
}

心跳参数的选择也有讲究。30 秒是我觉得比较平衡的值——既不会产生太多流量,又能及时发现连接问题。如果你的 Nginx 超时设置是 60 秒,那 30 秒的心跳间隔刚好够用。

连接状态管理

在 UI 中显示连接状态是个很实用的功能,能让用户知道发生了什么。

// 环境:React + WebSocket + Zustand
// 场景:在 UI 中显示连接状态

import { create } from 'zustand';

const useWebSocketStore = create((set) => ({
  status: 'disconnected'// disconnected | connecting | connected | reconnecting
  setStatus: (status) => set({ status }),
}));

class WebSocketManager {
  connect() {
    useWebSocketStore.getState().setStatus('connecting');
    
    this.ws = new WebSocket(this.url);
    
    this.ws.onopen = () => {
      useWebSocketStore.getState().setStatus('connected');
    };
    
    this.ws.onclose = () => {
      useWebSocketStore.getState().setStatus('disconnected');
      this.reconnect();
    };
  }
  
  reconnect() {
    useWebSocketStore.getState().setStatus('reconnecting');
    // ...
  }
}

// 组件中使用
function ConnectionStatus() {
  const status = useWebSocketStore((state) => state.status);
  
  const statusConfig = {
    disconnected: { color: 'red', text: '未连接' },
    connecting: { color: 'yellow', text: '连接中...' },
    connected: { color: 'green', text: '已连接' },
    reconnecting: { color: 'orange', text: '重连中...' },
  };
  
  const config = statusConfig[status];
  
  return (
    <div style={{ colorconfig.color }}>
      ● {config.text}
    </div>
  );
}

第二个挑战:消息管理

消息类型区分

真实应用中,WebSocket 连接通常会传输多种类型的消息:聊天消息、系统通知、用户状态更新等。如果把所有消息混在一起处理,代码会变得很乱。

// 环境:浏览器
// 场景:实现消息分发机制

class WebSocketManager {
  constructor(url) {
    this.url = url;
    this.listeners = new Map();  // 存储各种类型的监听器
  }
  
  // 订阅某类消息
  subscribe(type, callback) {
    if (!this.listeners.has(type)) {
      this.listeners.set(type, new Set());
    }
    this.listeners.get(type).add(callback);
    
    // 返回取消订阅函数
    return () => {
      this.listeners.get(type).delete(callback);
    };
  }
  
  // 分发消息
  handleMessage(event) {
    const message = JSON.parse(event.data);
    
    // 分发给对应类型的监听器
    if (this.listeners.has(message.type)) {
      this.listeners.get(message.type).forEach(callback => {
        callback(message.data);
      });
    }
  }
}

// 使用示例
const ws = new WebSocketManager('wss://...');

// 不同组件订阅不同类型的消息
ws.subscribe('chat_message', (data) => {
  console.log('收到聊天消息:', data);
});

ws.subscribe('notification', (data) => {
  console.log('收到通知:', data);
});

ws.subscribe('system', (data) => {
  console.log('收到系统消息:', data);
});

我设计的消息格式是这样的:

// 标准消息格式
{
  "type""chat_message"// 消息类型
  "id""msg_123"// 消息 ID(用于去重)
  "timestamp"1234567890// 时间戳(用于排序)
  "data": {                   // 业务数据
    "from""user_1""content""Hello""roomId""room_123"
  }
}

消息顺序与去重

网络抖动可能导致消息乱序或重复。特别是在聊天应用中,如果消息顺序乱了,对话就没法看了。

// 环境:浏览器
// 场景:处理消息乱序和去重

class WebSocketManager {
  constructor(url) {
    this.url = url;
    this.messageBuffer = [];      // 消息缓冲区
    this.processedMessageIds = new Set();  // 已处理的消息 ID
    this.expectedSeq = 0;         // 期望的序列号
  }
  
  handleMessage(event) {
    const message = JSON.parse(event.data);
    
    // 1. 去重
    if (this.processedMessageIds.has(message.id)) {
      console.log('重复消息,忽略:', message.id);
      return;
    }
    
    // 2. 检查顺序
    if (message.seq === this.expectedSeq) {
      // 顺序正确,直接处理
      this.processMessage(message);
      this.processedMessageIds.add(message.id);
      this.expectedSeq++;
      
      // 3. 检查缓冲区中是否有后续消息
      this.processBufferedMessages();
    } else if (message.seq > this.expectedSeq) {
      // 顺序不对,放入缓冲区
      console.log('消息乱序,缓存:', message.seq);
      this.messageBuffer.push(message);
      this.messageBuffer.sort((a, b) => a.seq - b.seq);
    }
    // seq < expectedSeq 说明是旧消息,忽略
  }
  
  processBufferedMessages() {
    while (this.messageBuffer.length > 0) {
      const nextMessage = this.messageBuffer[0];
      
      if (nextMessage.seq === this.expectedSeq) {
        this.messageBuffer.shift();
        this.processMessage(nextMessage);
        this.processedMessageIds.add(nextMessage.id);
        this.expectedSeq++;
      } else {
        break;  // 还有消息缺失,等待
      }
    }
  }
  
  processMessage(message) {
    // 分发给监听器
    if (this.listeners.has(message.type)) {
      this.listeners.get(message.type).forEach(callback => {
        callback(message.data);
      });
    }
  }
}

这个方案的核心思路是:维护一个期望序列号,收到消息时检查序列号是否匹配。如果不匹配就先缓存起来,等缺失的消息到了再按顺序处理。

离线消息队列

如果用户在断网状态下发送消息,该怎么办?一种做法是直接丢弃,但这对用户体验不太好。更好的方案是把消息暂存在队列里,等连接恢复后再发送。

// 环境:浏览器
// 场景:发送消息时连接断开的处理

class WebSocketManager {
  constructor(url) {
    this.url = url;
    this.messageQueue = [];  // 待发送的消息队列
  }
  
  send(data) {
    const message = typeof data === 'string' ? data : JSON.stringify(data);
    
    if (this.ws.readyState === WebSocket.OPEN) {
      // 连接正常,直接发送
      this.ws.send(message);
    } else {
      // 连接断开,加入队列
      console.log('连接未就绪,消息加入队列');
      this.messageQueue.push(message);
    }
  }
  
  connect() {
    this.ws = new WebSocket(this.url);
    
    this.ws.onopen = () => {
      console.log('连接成功');
      
      // 发送队列中的消息
      while (this.messageQueue.length > 0) {
        const message = this.messageQueue.shift();
        this.ws.send(message);
      }
    };
  }
}

消息确认机制

在一些重要场景(比如支付、订单),我们需要确保消息真的送达了。可以实现一个类似 TCP 的 ACK 机制。

// 环境:浏览器
// 场景:确保消息送达

class WebSocketManager {
  constructor(url) {
    this.url = url;
    this.pendingMessages = new Map();  // 待确认的消息
    this.messageTimeout = 5000;        // 超时时间
  }
  
  sendWithAck(data) {
    return new Promise((resolve, reject) => {
      const messageId = `msg_${Date.now()}_${Math.random()}`;
      const message = {
        id: messageId,
        ...data,
      };
      
      // 设置超时
      const timeoutId = setTimeout(() => {
        this.pendingMessages.delete(messageId);
        reject(new Error('消息发送超时'));
      }, this.messageTimeout);
      
      // 存储待确认消息
      this.pendingMessages.set(messageId, {
        resolve,
        reject,
        timeoutId,
      });
      
      // 发送消息
      this.ws.send(JSON.stringify(message));
    });
  }
  
  handleMessage(event) {
    const message = JSON.parse(event.data);
    
    // 处理确认消息
    if (message.type === 'ack') {
      const pending = this.pendingMessages.get(message.messageId);
      if (pending) {
        clearTimeout(pending.timeoutId);
        pending.resolve(message);
        this.pendingMessages.delete(message.messageId);
      }
      return;
    }
    
    // 处理其他消息...
  }
}

// 使用示例
try {
  await ws.sendWithAck({
    type: 'chat_message',
    content: 'Hello',
  });
  console.log('消息发送成功');
} catch (error) {
  console.error('消息发送失败:', error);
}

第三个挑战:与 React 集成

自定义 Hook 封装

把 WebSocket 逻辑封装成 React Hook,可以让代码更清晰,也更容易在不同组件间复用。

// 环境:React + WebSocket
// 场景:封装可复用的 WebSocket Hook
// 依赖:react

import { useEffect, useRef, useState } from 'react';

function useWebSocket(url) {
  const wsRef = useRef(null);
  const [status, setStatus] = useState('disconnected');
  const [lastMessage, setLastMessage] = useState(null);
  
  useEffect(() => {
    const ws = new WebSocketManager(url);
    wsRef.current = ws;
    
    ws.connect();
    
    // 订阅状态变化
    const unsubscribeStatus = useWebSocketStore.subscribe(
      (state) => state.status,
      setStatus
    );
    
    // 清理
    return () => {
      unsubscribeStatus();
      ws.close();
    };
  }, [url]);
  
  const sendMessage = (data) => {
    wsRef.current?.send(data);
  };
  
  const subscribe = (type, callback) => {
    return wsRef.current?.subscribe(type, callback);
  };
  
  return {
    status,
    sendMessage,
    subscribe,
    lastMessage,
  };
}

// 使用示例
function ChatRoom() {
  const { status, sendMessage, subscribe } = useWebSocket('wss://...');
  const [messages, setMessages] = useState([]);
  
  useEffect(() => {
    const unsubscribe = subscribe('chat_message', (data) => {
      setMessages(prev => [...prev, data]);
    });
    
    return unsubscribe;
  }, [subscribe]);
  
  return (
    <div>
      <ConnectionStatus status={status} />
      {messages.map(msg => <Message key={msg.id} data={msg} />)}
      <button onClick={() => sendMessage({ type: 'chat_message', content: 'Hi' })}>
        发送
      </button>
    </div>
  );
}

与 React Query 集成

WebSocket 更适合推送实时数据,而 React Query 更适合管理缓存数据。如果能把两者结合起来,就能实现"REST API 获取历史数据 + WebSocket 推送增量更新"的模式。

// 环境:React + WebSocket + React Query
// 场景:WebSocket 消息触发缓存更新
// 依赖:@tanstack/react-query

import { useQueryClient } from '@tanstack/react-query';

function ChatRoom({ roomId }) {
  const queryClient = useQueryClient();
  const { subscribe } = useWebSocket('wss://...');
  
  // REST API 获取历史消息
  const { data: messages } = useQuery({
    queryKey: ['messages', roomId],
    queryFn: () => fetchMessages(roomId),
  });
  
  useEffect(() => {
    // 收到新消息时,更新 React Query 缓存
    const unsubscribe = subscribe('chat_message', (newMessage) => {
      queryClient.setQueryData(['messages', roomId], (old) => {
        return [...(old || []), newMessage];
      });
    });
    
    return unsubscribe;
  }, [subscribe, queryClient, roomId]);
  
  return (
    <div>
      {messages?.map(msg => <Message key={msg.id} data={msg} />)}
    </div>
  );
}

多组件共享 WebSocket 连接

如果每个组件都创建一个 WebSocket 连接,会浪费资源。更好的做法是用 Context 在全局共享一个连接。

// 环境:React Context + WebSocket
// 场景:全局共享 WebSocket 实例
// 依赖:react

import { createContext, useContext, useEffect, useRef } from 'react';

const WebSocketContext = createContext(null);

export function WebSocketProvider({ url, children }) {
  const wsRef = useRef(null);
  
  useEffect(() => {
    wsRef.current = new WebSocketManager(url);
    wsRef.current.connect();
    
    return () => {
      wsRef.current.close();
    };
  }, [url]);
  
  return (
    <WebSocketContext.Provider value={wsRef.current}>
      {children}
    </WebSocketContext.Provider>
  );
}

export function useWS() {
  const ws = useContext(WebSocketContext);
  if (!ws) {
    throw new Error('useWS must be used within WebSocketProvider');
  }
  return ws;
}

// 使用
function App() {
  return (
    <WebSocketProvider url="wss://...">
      <ChatRoom />
      <NotificationPanel />
      <UserList />
    </WebSocketProvider>
  );
}

function ChatRoom() {
  const ws = useWS();
  
  useEffect(() => {
    return ws.subscribe('chat_message', handleMessage);
  }, [ws]);
  
  // ...
}

实战场景思考

场景 1:聊天应用

聊天是 WebSocket 最常见的应用场景。这里有个有意思的问题:如何处理"消息正在发送"的状态?

// 环境:React + WebSocket
// 场景:完整的聊天室实现
// 依赖:react

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);
  const [inputValue, setInputValue] = useState('');
  const ws = useWS();
  const messagesEndRef = useRef(null);
  
  // 获取历史消息(REST API)
  useEffect(() => {
    fetchHistoryMessages(roomId).then(setMessages);
  }, [roomId]);
  
  // 监听新消息(WebSocket)
  useEffect(() => {
    const unsubscribe = ws.subscribe('chat_message', (message) => {
      if (message.roomId === roomId) {
        setMessages(prev => {
          // 去重
          if (prev.some(m => m.id === message.id)) {
            return prev;
          }
          return [...prev, message];
        });
        
        // 自动滚动到底部
        messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
      }
    });
    
    return unsubscribe;
  }, [ws, roomId]);
  
  // 发送消息
  const sendMessage = async () => {
    if (!inputValue.trim()) return;
    
    const tempId = `temp_${Date.now()}`;
    const newMessage = {
      id: tempId,
      roomId,
      content: inputValue,
      sender: 'me',
      timestamp: Date.now(),
      status: 'sending'// sending | sent | failed
    };
    
    // 乐观更新
    setMessages(prev => [...prev, newMessage]);
    setInputValue('');
    
    try {
      // 发送到服务器
      await ws.sendWithAck({
        type: 'chat_message',
        roomId,
        content: inputValue,
      });
      
      // 更新状态为已发送
      setMessages(prev =>
        prev.map(m => m.id === tempId ? { ...m, status: 'sent' } : m)
      );
    } catch (error) {
      // 发送失败
      setMessages(prev =>
        prev.map(m => m.id === tempId ? { ...m, status: 'failed' } : m)
      );
    }
  };
  
  return (
    <div className="chat-room">
      <div className="messages">
        {messages.map(msg => (
          <MessageBubble key={msg.id} message={msg} />
        ))}
        <div ref={messagesEndRef} />
      </div>
      
      <div className="input-area">
        <input
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
          placeholder="输入消息..."
        />
        <button onClick={sendMessage}>发送</button>
      </div>
    </div>
  );
}

// 消息气泡组件
function MessageBubble({ message }) {
  const statusIcon = {
    sending: '⏱',
    sent: '✓',
    failed: '✗',
  };
  
  return (
    <div className={`message ${message.sender === 'me' ? 'mine' : 'theirs'}`}>
      <div className="content">{message.content}</div>
      <div className="meta">
        <span className="time">{formatTime(message.timestamp)}</span>
        {message.sender === 'me' && (
          <span className="status">{statusIcon[message.status]}</span>
        )}
      </div>
    </div>
  );
}

我采用的是乐观更新策略:先在本地立即显示消息(状态为"发送中"),等服务器确认后再更新状态。这样用户体验会更流畅。

场景 2:协作编辑

协作编辑比聊天更复杂,因为需要处理冲突。比如两个人同时编辑同一段文字,该怎么办?

// 环境:React + WebSocket
// 场景:多人实时协作编辑
// 依赖:react
// 注意:这里简化了 OT 算法的实现

function CollaborativeEditor({ documentId }) {
  const [content, setContent] = useState('');
  const [cursors, setCursors] = useState({});  // 其他用户的光标位置
  const editorRef = useRef(null);
  const ws = useWS();
  
  // 获取文档内容
  useEffect(() => {
    fetchDocument(documentId).then(doc => {
      setContent(doc.content);
    });
  }, [documentId]);
  
  // 监听其他用户的编辑
  useEffect(() => {
    const unsubscribeEdit = ws.subscribe('document_edit', (operation) => {
      if (operation.documentId === documentId) {
        // 应用操作(这里需要 OT 或 CRDT 算法)
        setContent(prev => applyOperation(prev, operation));
      }
    });
    
    const unsubscribeCursor = ws.subscribe('cursor_move', (data) => {
      if (data.documentId === documentId) {
        setCursors(prev => ({
          ...prev,
          [data.userId]: data.position,
        }));
      }
    });
    
    return () => {
      unsubscribeEdit();
      unsubscribeCursor();
    };
  }, [ws, documentId]);
  
  // 本地编辑
  const handleChange = (e) => {
    const newContent = e.target.value;
    const operation = generateOperation(content, newContent);
    
    // 本地立即应用
    setContent(newContent);
    
    // 广播给其他用户
    ws.send({
      type: 'document_edit',
      documentId,
      operation,
    });
  };
  
  // 光标移动
  const handleCursorMove = (position) => {
    ws.send({
      type: 'cursor_move',
      documentId,
      position,
    });
  };
  
  return (
    <div className="editor">
      <textarea
        ref={editorRef}
        value={content}
        onChange={handleChange}
        onSelect={(e) => handleCursorMove(e.target.selectionStart)}
      />
      
      {/* 显示其他用户的光标 */}
      {Object.entries(cursors).map(([userId, position]) => (
        <Cursor key={userId} userId={userId} position={position} />
      ))}
    </div>
  );
}

协作编辑的核心是 OT(Operational Transformation)或 CRDT(Conflict-free Replicated Data Type)算法。这超出了本文的范围,但知道 WebSocket 只是传输层,真正的难点在算法设计,这一点很重要。

场景 3:股票行情推送

股票价格是典型的高频实时数据。这个场景的特点是:数据更新频繁,但不需要每条数据都处理。

// 环境:React + WebSocket
// 场景:实时股票价格
// 依赖:react

function StockPriceWidget({ symbols }) {
  const [prices, setPrices] = useState({});
  const ws = useWS();
  
  useEffect(() => {
    // 订阅股票
    ws.send({
      type: 'subscribe',
      symbols: symbols,
    });
    
    // 监听价格更新
    const unsubscribe = ws.subscribe('price_update', (update) => {
      setPrices(prev => ({
        ...prev,
        [update.symbol]: {
          price: update.price,
          change: update.change,
          timestamp: update.timestamp,
        },
      }));
    });
    
    return () => {
      // 取消订阅
      ws.send({
        type: 'unsubscribe',
        symbols: symbols,
      });
      unsubscribe();
    };
  }, [ws, symbols]);
  
  return (
    <div className="stock-widget">
      {symbols.map(symbol => {
        const data = prices[symbol];
        const changeColor = data?.change >= 0 ? 'green' : 'red';
        
        return (
          <div key={symbol} className="stock-item">
            <span className="symbol">{symbol}</span>
            <span className="price">${data?.price?.toFixed(2)}</span>
            <span className="change" style={{ colorchangeColor }}>
              {data?.change >= 0 ? '+' : ''}{data?.change?.toFixed(2)}%
            </span>
          </div>
        );
      })}
    </div>
  );
}

部署方案选择

WebSocket 的部署和普通 HTTP 服务不太一样,主要区别在于需要保持长连接。

云平台方案对比

平台 优点 缺点 适用场景 定价
AWS API Gateway + Lambda 托管服务,自动扩展 冷启动延迟,15分钟连接限制 中小型应用 按连接时长
AWS EC2 + Socket.IO 完全控制,无连接限制 需要自己维护 大型应用 按实例
Vercel + Pusher 简单易用 依赖第三方,价格较贵 快速原型 按连接数
Railway / Render 部署简单,支持 WebSocket 免费版有限制 个人项目 免费/付费
Cloudflare Workers 边缘计算,延迟低 Durable Objects 较新 全球分布式 按请求
自建 VPS (DigitalOcean) 最灵活,成本可控 需要运维经验 有技术团队 固定费用

我的建议是:

  • 小型项目(<1000 并发):Railway / Render,部署简单,成本低
  • 中型项目(1000-10000 并发):AWS EC2 + Socket.IO,可控性强
  • 大型项目(10000+ 并发):自建集群 + Redis,需要专业团队

部署到 Railway(示例)

# 1. 创建项目
npm init -y
npm install express socket.io

# 2. server.js
// 环境:Node.js 18+
// 场景:WebSocket 服务器
// 依赖:express, socket.io

const express = require('express');
const { createServer } = require('http');
const { Server } = require('socket.io');

const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
  cors: { origin: '*' }
});

io.on('connection', (socket) => {
  console.log('Client connected');
  
  socket.on('message', (data) => {
    io.emit('message', data);  // 广播
  });
  
  socket.on('disconnect', () => {
    console.log('Client disconnected');
  });
});

const PORT = process.env.PORT || 3000;
httpServer.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});
# 3. 部署
railway login
railway init
railway up

Nginx 配置(反向代理)

如果你自己部署服务器,需要配置 Nginx 支持 WebSocket:

# /etc/nginx/sites-available/websocket

upstream websocket_backend {
    # 使用 ip_hash 实现 sticky session
    ip_hash;
    
    server 127.0.0.1:3000;
    server 127.0.0.1:3001;
    server 127.0.0.1:3002;
}

server {
    listen 80;
    server_name ws.example.com;
    
    location / {
        proxy_pass http://websocket_backend;
        
        # WebSocket 必需的 headers
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        
        # 其他 headers
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        
        # 超时设置
        proxy_connect_timeout 7d;
        proxy_send_timeout 7d;
        proxy_read_timeout 7d;
    }
}

关键点是 proxy_set_header Upgradeproxy_set_header Connection "upgrade",这告诉 Nginx 这是个 WebSocket 连接,需要特殊处理。

性能优化与监控

消息压缩

对于高频通信,消息压缩能显著减少流量。

// 环境:浏览器
// 场景:使用 MessagePack 压缩消息
// 依赖:msgpack-lite

import msgpack from 'msgpack-lite';

class WebSocketManager {
  send(data) {
    // 压缩后发送
    const compressed = msgpack.encode(data);
    this.ws.send(compressed);
  }
  
  handleMessage(event) {
    // 解压
    const data = msgpack.decode(new Uint8Array(event.data));
    this.processMessage(data);
  }
}

// 对比:
// JSON: { "type": "message", "content": "Hello" }  // ~40 bytes
// MessagePack: 更小的二进制格式                    // ~20 bytes

批量发送

如果消息发送频率很高,可以考虑批量发送:

// 环境:浏览器
// 场景:批量发送消息以减少网络开销

class WebSocketManager {
  constructor(url) {
    this.url = url;
    this.messageBuffer = [];
    this.flushInterval = null;
  }
  
  send(data) {
    this.messageBuffer.push(data);
    
    // 累积 100ms 或 10 条消息后批量发送
    if (!this.flushInterval) {
      this.flushInterval = setTimeout(() => {
        this.flush();
      }, 100);
    }
    
    if (this.messageBuffer.length >= 10) {
      this.flush();
    }
  }
  
  flush() {
    if (this.messageBuffer.length === 0) return;
    
    const batch = this.messageBuffer.splice(0);
    this.ws.send(JSON.stringify({ type: 'batch', messages: batch }));
    
    clearTimeout(this.flushInterval);
    this.flushInterval = null;
  }
}

监控

在生产环境中,监控 WebSocket 的运行状态很重要:

// 环境:浏览器
// 场景:添加监控埋点

class WebSocketManager {
  constructor(url) {
    this.url = url;
    this.metrics = {
      messagesReceived: 0,
      messagesSent: 0,
      reconnectCount: 0,
      errors: 0,
      averageLatency: 0,
    };
  }
  
  send(data) {
    const startTime = Date.now();
    const message = {
      ...data,
      clientTimestamp: startTime,
    };
    
    this.ws.send(JSON.stringify(message));
    this.metrics.messagesSent++;
  }
  
  handleMessage(event) {
    this.metrics.messagesReceived++;
    
    const message = JSON.parse(event.data);
    
    // 计算延迟
    if (message.clientTimestamp) {
      const latency = Date.now() - message.clientTimestamp;
      this.metrics.averageLatency = 
        (this.metrics.averageLatency + latency) / 2;
    }
    
    // 上报监控数据
    if (this.metrics.messagesReceived % 100 === 0) {
      this.reportMetrics();
    }
  }
  
  reportMetrics() {
    console.log('WebSocket Metrics:'this.metrics);
    
    // 发送到监控平台(如 Sentry, DataDog)
    // analytics.track('websocket_metrics', this.metrics);
  }
}

延伸与发散

WebSocket vs SSE vs Long Polling

在研究 WebSocket 时,我发现还有其他实现实时通信的方案。它们各有优劣:

特性 WebSocket SSE Long Polling
方向 双向 单向(服务器→客户端) 单向
协议 独立协议 HTTP HTTP
浏览器支持 现代浏览器 现代浏览器(IE 不支持) 所有浏览器
自动重连 需手动实现 浏览器自动 需手动实现
适用场景 聊天、游戏 通知、推送 兼容性要求高

如果只需要服务器推送数据(比如通知),SSE 可能是更简单的选择。

AI 应用中的 WebSocket

最近在做 AI 相关的项目时,发现 WebSocket 很适合流式返回 AI 生成的内容:

// 环境:React
// 场景:AI 流式生成内容
// 依赖:react

function AIChat() {
  const [messages, setMessages] = useState([]);
  const [currentResponse, setCurrentResponse] = useState('');
  const ws = useWS();
  
  useEffect(() => {
    const unsubscribe = ws.subscribe('ai_stream', (chunk) => {
      // AI 逐字返回
      setCurrentResponse(prev => prev + chunk.content);
    });
    
    const unsubscribeEnd = ws.subscribe('ai_stream_end', () => {
      // 流结束,保存完整消息
      setMessages(prev => [...prev, { role: 'ai', content: currentResponse }]);
      setCurrentResponse('');
    });
    
    return () => {
      unsubscribe();
      unsubscribeEnd();
    };
  }, [ws]);
  
  return (
    <div>
      {messages.map((msg, i) => <Message key={i} data={msg} />)}
      {currentResponse && <Message data={{ role: 'ai', contentcurrentResponse }} streaming />}
    </div>
  );
}

这种逐字显示的效果,用户体验比等待整段文本生成完再显示要好很多。

一些待探索的问题

在写这篇文章的过程中,我发现还有很多值得深入的方向:

  1. 端到端加密:如何在 WebSocket 中实现端到端加密?是在应用层做还是传输层做?
  2. 海量并发:如果要支持百万级并发连接,架构该如何设计?
  3. 微服务集成:在微服务架构中,WebSocket 该如何与服务发现、负载均衡集成?
  4. 边缘计算:Cloudflare Workers 这类边缘计算平台能否优化 WebSocket 延迟?

这些问题我还没有很深入的实践经验,欢迎有经验的朋友交流。

小结

从"连上 WebSocket"到真正在生产环境用好它,中间还有很长的路要走。这篇文章是我个人的学习总结,梳理了连接管理、消息管理、React 集成、部署方案等几个关键环节。

核心要点

  • WebSocket 的"后半场":断线重连、消息管理、状态同步
  • 与 React 的集成:自定义 Hook、Context 共享、React Query 协作
  • 实战场景:聊天、协作编辑、实时推送各有特点
  • 部署方案:根据规模选择合适的平台

我的理解是,WebSocket 本身的 API 很简单,但要在生产环境用好它,需要考虑很多工程问题。这些问题没有标准答案,需要根据具体场景权衡。

如果你也在使用 WebSocket,欢迎分享你的经验和踩过的坑。下一步我想探索的是认证状态的全局管理,特别是在多标签页同步和 SSO 场景下如何处理。

参考资料

响应式系统总结:从零到完整的闭环

作者 wuhen_n
2026年2月25日 10:35

经过前面几篇文章的深入探索,我们从最底层的 effect 开始,逐步构建起了 Vue3 响应式系统的完整图景。今天,我们将把所有组件串联起来,形成一个完整的闭环,并通过实战和性能分析,深入理解 Vue3 响应式系统的设计精髓。

前言:响应式系统的全景图

在开始整合之前,让我们先回顾一下整个响应式系统的架构: 响应式数据全景图 Vue3 中的响应式系统的核心思想可以概括为:在读取时收集依赖,在修改时触发更新。

串联所有组件:完整的响应式系统

1. 基础工具函数

// ============ 工具函数 ============
function isObject(value) {
    return value !== null && typeof value === 'object';
}

function isFunction(value) {
    return typeof value === 'function';
}

function isArray(value) {
    return Array.isArray(value);
}

function isRef(r) {
    return !!(r && r.__v_isRef === true);
}

function isReactive(value) {
    return !!(value && value.__v_isReactive === true);
}

function isArrayIndex(key) {
    if (typeof key !== 'string') return false;
    const keyAsNumber = Number(key);
    return Number.isInteger(keyAsNumber) &&
           keyAsNumber >= 0 &&
           keyAsNumber < Number.MAX_SAFE_INTEGER;
}

2. 依赖管理核心

// ============ 依赖管理 ============
const targetMap = new WeakMap();
let activeEffect = null;

// 操作类型枚举
const TrackOpTypes = {
    GET: 'get',
    HAS: 'has',
    ITERATE: 'iterate'
};

const TriggerOpTypes = {
    SET: 'set',
    ADD: 'add',
    DELETE: 'delete',
    CLEAR: 'clear'
};

// 特殊标识
const ITERATE_KEY = Symbol('iterate');

class ReactiveEffect {
    constructor(fn, scheduler = null) {
        this.fn = fn;
        this.scheduler = scheduler;
        this.deps = [];
        this.active = true;
        this.runDepth = 0;
    }
    
    run() {
        if (!this.active) {
            return this.fn();
        }
        
        try {
            this.runDepth++;
            if (this.runDepth > 1000) {
                console.warn('检测到可能的无限循环');
                return;
            }
            
            activeEffect = this;
            return this.fn();
        } finally {
            this.runDepth--;
            activeEffect = null;
        }
    }
    
    stop() {
        if (this.active) {
            this.active = false;
            this.deps.forEach(dep => dep.delete(this));
            this.deps.length = 0;
        }
    }
}

function track(target, type, key) {
    if (!activeEffect) return;
    
    let depsMap = targetMap.get(target);
    if (!depsMap) {
        targetMap.set(target, (depsMap = new Map()));
    }
    
    // 处理迭代操作
    let depKey = key;
    if (type === TrackOpTypes.ITERATE) {
        depKey = ITERATE_KEY;
    }
    
    let dep = depsMap.get(depKey);
    if (!dep) {
        depsMap.set(depKey, (dep = new Set()));
    }
    
    if (!dep.has(activeEffect)) {
        dep.add(activeEffect);
        activeEffect.deps.push(dep);
    }
}

function trigger(target, type, key, newValue, oldValue) {
    const depsMap = targetMap.get(target);
    if (!depsMap) return;
    
    const effectsToRun = new Set();
    
    const add = (effectsToAdd) => {
        if (effectsToAdd) {
            effectsToAdd.forEach(effect => {
                if (effect !== activeEffect) {
                    effectsToRun.add(effect);
                }
            });
        }
    };
    
    // 处理普通 key
    if (key !== undefined) {
        add(depsMap.get(key));
    }
    
    // 处理数组特殊情况
    if (Array.isArray(target)) {
        if (key === 'length') {
            // length 变化需要触发所有索引 >= 新值的依赖
            const newLength = Number(newValue);
            depsMap.forEach((dep, key) => {
                if (isArrayIndex(key) && Number(key) >= newLength) {
                    add(dep);
                }
            });
        } else if (type === TriggerOpTypes.ADD && isArrayIndex(key)) {
            // 添加数组元素触发 length
            add(depsMap.get('length'));
        }
    } else {
        // 处理迭代操作
        if (type === TriggerOpTypes.ADD || type === TriggerOpTypes.DELETE) {
            add(depsMap.get(ITERATE_KEY));
        }
    }
    
    // 执行 effects
    effectsToRun.forEach(effect => {
        if (effect.scheduler) {
            effect.scheduler();
        } else {
            effect.run();
        }
    });
}

function effect(fn) {
    const _effect = new ReactiveEffect(fn);
    _effect.run();
    
    const runner = _effect.run.bind(_effect);
    runner.effect = _effect;
    return runner;
}

3. Reactive 实现

// ============ reactive ============
const reactiveHandlers = {
    get(target, key, receiver) {
        // 内部标记
        if (key === '__v_isReactive') return true;
        
        const value = Reflect.get(target, key, receiver);
        
        // 依赖收集
        track(target, TrackOpTypes.GET, key);
        
        // 嵌套响应式
        if (isObject(value)) {
            return reactive(value);
        }
        
        return value;
    },
    
    set(target, key, value, receiver) {
        const oldValue = target[key];
        const hadKey = target.hasOwnProperty(key);
        const oldLength = Array.isArray(target) ? target.length : undefined;
        
        const result = Reflect.set(target, key, value, receiver);
        
        if (!hadKey) {
            // 新增属性
            trigger(target, TriggerOpTypes.ADD, key, value);
        } else if (oldValue !== value) {
            // 修改属性
            trigger(target, TriggerOpTypes.SET, key, value, oldValue);
        }
        
        // 数组 length 隐式变化
        if (Array.isArray(target) && oldLength !== target.length) {
            trigger(target, TriggerOpTypes.SET, 'length', target.length);
        }
        
        return result;
    },
    
    deleteProperty(target, key) {
        const hadKey = target.hasOwnProperty(key);
        const oldValue = target[key];
        
        const result = Reflect.deleteProperty(target, key);
        
        if (result && hadKey) {
            trigger(target, TriggerOpTypes.DELETE, key, undefined, oldValue);
        }
        
        return result;
    },
    
    has(target, key) {
        track(target, TrackOpTypes.HAS, key);
        return Reflect.has(target, key);
    },
    
    ownKeys(target) {
        track(target, TrackOpTypes.ITERATE, ITERATE_KEY);
        return Reflect.ownKeys(target);
    }
};

function reactive(target) {
    if (!isObject(target)) return target;
    if (target.__v_isReactive) return target;
    
    return new Proxy(target, reactiveHandlers);
}

4. Ref 实现

// ============ ref ============
class RefImpl {
    constructor(value, isShallow = false) {
        this._rawValue = value;
        this._value = isShallow ? value : toReactive(value);
        this.__v_isRef = true;
        this._isShallow = isShallow;
    }
    
    get value() {
        track(this, TrackOpTypes.GET, 'value');
        return this._value;
    }
    
    set value(newValue) {
        if (newValue !== this._rawValue) {
            this._rawValue = newValue;
            this._value = this._isShallow ? newValue : toReactive(newValue);
            trigger(this, TriggerOpTypes.SET, 'value', newValue);
        }
    }
}

function toReactive(value) {
    return isObject(value) ? reactive(value) : value;
}

function ref(value) {
    if (isRef(value)) return value;
    return new RefImpl(value);
}

function shallowRef(value) {
    return new RefImpl(value, true);
}

function isRef(r) {
    return !!(r && r.__v_isRef === true);
}

function unref(ref) {
    return isRef(ref) ? ref.value : ref;
}

function toReactive(value) {
    return isObject(value) ? reactive(value) : value;
}

class ObjectRefImpl {
    constructor(_object, _key) {
        this._object = _object;
        this._key = _key;
        this.__v_isRef = true;
    }
    
    get value() {
        return this._object[this._key];
    }
    
    set value(newValue) {
        this._object[this._key] = newValue;
    }
}

function toRef(object, key) {
    return new ObjectRefImpl(object, key);
}

function toRefs(object) {
    const result = {};
    for (const key in object) {
        if (object.hasOwnProperty(key)) {
            result[key] = toRef(object, key);
        }
    }
    return result;
}

5. Computed 实现

// ============ computed ============
class ComputedRefImpl {
    constructor(getter, setter) {
        this.getter = getter;
        this.setter = setter;
        this._dirty = true;
        this._value = undefined;
        this.__v_isRef = true;
        
        this.effect = new ReactiveEffect(getter, () => {
            if (!this._dirty) {
                this._dirty = true;
                trigger(this, TriggerOpTypes.SET, 'value');
            }
        });
    }
    
    get value() {
        track(this, TrackOpTypes.GET, 'value');
        
        if (this._dirty) {
            this._dirty = false;
            this._value = this.effect.run();
        }
        
        return this._value;
    }
    
    set value(newValue) {
        if (this.setter) {
            this.setter(newValue);
        } else {
            console.warn('计算属性是只读的');
        }
    }
}

function computed(getterOrOptions) {
    let getter, setter;
    
    if (isFunction(getterOrOptions)) {
        getter = getterOrOptions;
        setter = null;
    } else {
        getter = getterOrOptions.get;
        setter = getterOrOptions.set;
    }
    
    return new ComputedRefImpl(getter, setter);
}

6. Watch 实现

// ============ watch ============
function traverse(value, seen = new Set()) {
    if (!isObject(value) || seen.has(value)) return value;
    
    seen.add(value);
    
    if (Array.isArray(value)) {
        value.forEach(item => traverse(item, seen));
    } else if (value instanceof Map) {
        value.forEach((v, k) => {
            traverse(v, seen);
            traverse(k, seen);
        });
    } else if (value instanceof Set) {
        value.forEach(v => traverse(v, seen));
    } else {
        Object.keys(value).forEach(key => traverse(value[key], seen));
    }
    
    return value;
}

function watch(source, cb, options = {}) {
    let getter;
    
    if (isRef(source)) {
        getter = () => source.value;
    } else if (isReactive(source)) {
        getter = () => source;
        options.deep = options.deep ?? true;
    } else if (Array.isArray(source)) {
        getter = () => source.map(s => {
            if (isRef(s)) return s.value;
            if (isReactive(s)) return traverse(s);
            if (isFunction(s)) return s();
            return s;
        });
    } else if (isFunction(source)) {
        if (cb) {
            getter = source;
        } else {
            return watchEffect(source, options);
        }
    }
    
    if (options.deep) {
        const baseGetter = getter;
        getter = () => traverse(baseGetter());
    }
    
    let oldValue;
    let cleanup;
    
    function onInvalidate(fn) {
        cleanup = fn;
    }
    
    const scheduler = () => {
        if (cleanup) cleanup();
        
        const newValue = getter();
        
        if (newValue !== oldValue) {
            cb(newValue, oldValue, onInvalidate);
        }
        
        oldValue = newValue;
    };
    
    const _effect = new ReactiveEffect(getter, scheduler);
    
    if (options.immediate) {
        scheduler();
    } else {
        oldValue = _effect.run();
    }
    
    return () => {
        _effect.stop();
        if (cleanup) cleanup();
    };
}

function watchEffect(effect, options = {}) {
    const scheduler = () => {
        if (options.flush === 'post') {
            Promise.resolve().then(() => _effect.run());
        } else {
            _effect.run();
        }
    };
    
    const _effect = new ReactiveEffect(effect, scheduler);
    _effect.run();
    
    return () => {
        _effect.stop();
    };
}

常见面试题解析

面试题 1:Vue3 的响应式原理是什么?

核心原理:Proxy + 依赖收集:

  1. 通过 Proxy 代理对象的所有操作
  2. 在 get 中通过 track 收集依赖
  3. 在 set 中通过 trigger 触发更新
  4. 使用 WeakMap + Map + Set 三层结构存储依赖
  5. 通过 effect 管理系统中的副作用
┌─────────────────────────────────────────────────────────────┐
│                    1. Proxy代理对象                          │
│                                                             │
│   ┌──────────────┐         ┌──────────────────────┐         │
│   │   原始对象     │        │     Proxy代理         │         │
│   │  {            │  代理  │   get: track收集      │         │
│   │    count: 0,  │◄───────┤   set: trigger触发    │        │
│   │    name: 'vue'│        │   deleteProperty     │         │
│   │  }            │        │   has...             │         │
│   └──────────────┘         └──────────────────────┘         │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│             2. 依赖收集 (track)                              │
│                                                             │
│   get操作触发 ──→ track函数 ──→ 查找依赖存储                    │
│                                                             │
│   ┌──────────────────────────────────────────┐              │
│   │        3. 三层依赖存储结构                  │              │
│   │                                          │              │
│   │   WeakMap          Map          Set      │              │
│   │   ┌─────┐        ┌─────┐      ┌─────┐    │              │
│   │   │target│──────►│key1 │─────►│effect1│  │              │
│   │   └─────┘        ├─────┤      ├─────┤    │              │
│   │                  │key2 │─┐    │effect2│  │              │
│   │                  └─────┘ │    └─────┘    │              │
│   │                           └───►┌─────┐   │              │
│   │                                │effect3│ │              │
│   │                                └─────┘   │              │
│   └──────────────────────────────────────────┘              │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│             4. 副作用管理 (effect)                            │
│                                                             │
│   ┌────────────────────────────────────────┐                │
│   │  effect(() => {                        │                │
│   │    console.log(obj.count)  // 依赖收集  │                │
│   │  })                                    │                │
│   └────────────────────────────────────────┘                │
│                                                             │
│   ┌──────────┐    ┌──────────┐    ┌──────────┐              │
│   │  effect1 │    │  effect2 │    │  effect3 │              │
│   │ (更新UI) │     │(计算属性)│     │ (watch)  │              │
│   └──────────┘    └──────────┘    └──────────┘              │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌──────────────────────────────────────────────────────────────┐
│             5. 触发更新 (trigger)                             │
│                                                              │
│   set操作触发 ──→ trigger函数 ──→ 从存储结构中查找依赖            │
│                                          │                   │
│                                          ▼                   │
│   ┌──────────────────────────────────────────────────┐       │
│   │           执行所有相关的副作用函数                   │       │
│   │                                                  │       │
│   │   obj.count = 1                                  │       │
│   │        │                                         │       │
│   │        ▼                                         │       │
│   │   触发更新 ──→ 执行effect1 ──→ 更新UI               │       │
│   │            └─→ 执行effect2 ──→ 重新计算            │       │
│   └──────────────────────────────────────────────────┘       │
└──────────────────────────────────────────────────────────────┘

面试题 2:Vue2 的 Object.defineProperty 和 Vue3 的 Proxy 有什么区别?

  1. Proxy 可以监听新增/删除属性;defineProperty 不能
  2. Proxy 可以监听数组索引和 length;defineProperty 不行
  3. Proxy 需要递归代理;defineProperty 初始化递归
  4. Proxy 性能更好,拦截操作更丰富

面试题 3:为什么 ref 需要 .value 而 reactive 不需要?

因为 Proxy 无法代理原始值,对于原始值的代理需要通过 value 包裹成对象:

let count = 0; // 原始值,无法代理
// ref 包装成对象
const countRef = {
    value: 0
};
// 现在可以对 countRef 进行代理

面试题 4:computed 和 watch 有什么区别?

对比维度 computed watch
概念 计算属性,基于依赖缓存的计算值 侦听器,执行副作用操作
缓存机制 有缓存,只有依赖变化时才重新计算 无缓存,每次监听到变化都执行
返回值 必须返回一个值,模板中可直接使用 通常不返回值,用于执行逻辑操作
依赖追踪 自动追踪响应式依赖 手动指定要侦听的数据源
执行时机 懒执行,只有访问时才重新计算 立即执行(可配置)或数据变化时执行
异步操作 不支持异步 支持异步操作
性能特点 适合衍生状态,避免重复计算 适合处理开销大的操作或异步逻辑
使用场景 1. 模板中复杂表达式
2. 依赖其他数据的衍生值
3. 需要缓存的场景
1. 数据变化时执行异步操作
2. 操作DOM
3. 执行开销大的操作
访问方式 作为属性访问:state.count 通过回调函数执行
深度监听 自动深度追踪依赖 需要手动配置 deep: true
立即执行 自动计算 需要配置 immediate: true

面试题 5:Vue3 的响应式系统如何避免循环依赖?

  1. activeEffect 守卫:
function trigger(target, key) {
    const effects = depsMap.get(key);
    effects.forEach(effect => {
        // 跳过当前正在执行的 effect
        if (effect !== activeEffect) {
            effect.run();
        }
    });
}
  1. 使用 Set 避免重复收集
dep.add(activeEffect); // 自动去重
  1. 递归深度限制
class ReactiveEffect {
    run() {
        this.runDepth++;
        if (this.runDepth > 1000) {
            console.warn('检测到无限循环');
            return;
        }
        // ... 执行逻辑
        this.runDepth--;
    }
}

性能分析:Vue3 响应式比 Vue2 快在哪里?

1. 初始化性能对比

// Vue2:递归遍历所有属性
function vue2Init(obj) {
    Object.keys(obj).forEach(key => {
        defineReactive(obj, key);
    });
    return obj;
}

// Vue3:代理整个对象,懒递归
function vue3Init(obj) {
    return new Proxy(obj, handlers); // 不递归
    // 只有在访问嵌套对象时才递归转换
}

性能差异:

  • Vue2: O(n) 初始化时间,n 为所有属性数量
  • Vue3: O(1) 初始化时间,只创建代理

2. 内存占用对比

// Vue2:为每个属性创建闭包
function defineReactive(obj, key) {
    let value = obj[key];
    const dep = new Dep(); // 每个属性一个 dep
    
    Object.defineProperty(obj, key, {
        get() {
            dep.depend(); // 闭包引用
            return value;
        },
        set(newVal) {
            dep.notify();
        }
    });
}

// Vue3:共享 handlers,使用 WeakMap 存储依赖
const targetMap = new WeakMap(); // 依赖统一存储
const handlers = {}; // 单例,不重复创建

性能差异:

  • Vue2: 每个属性都有独立的 getter/setter 和闭包
  • Vue3: 所有对象共享 handlers,依赖集中存储

3. 数组操作性能

// Vue2:重写数组方法
const arrayMethods = ['push', 'pop', 'shift', 'unshift'];
arrayMethods.forEach(method => {
    const original = Array.prototype[method];
    Object.defineProperty(array, method, {
        value: function(...args) {
            const result = original.apply(this, args);
            // 额外触发更新
            return result;
        }
    });
});

// Vue3:Proxy 直接拦截
const arr = new Proxy([], {
    set(target, key, value) {
        target[key] = value;
        // 统一处理更新
        return true;
    }
});

性能差异:

  • Vue2: 需要拦截每个方法,有额外开销
  • Vue3: 统一通过 set 拦截,更高效

4. 编译时优化

Vue3 性能提升:

  • 静态节点只创建一次
  • 更新时只比较动态部分
  • 减少了不必要的 VNode 创建

5. 批量更新机制

// Vue2:同步更新
state.count++;
state.name = '张三'; // 触发两次更新

// Vue3:异步批量更新
state.count++;
state.name = '张三';
// 只触发一次更新

性能差异:

  • Vue2: 多次同步更新导致多次渲染
  • Vue3: 批量处理,减少渲染次数

结语

经过多篇文章的深入探索,我们完成了 Vue3 响应式系统的完整学习。响应式系统的设计思想,不仅适用于 Vue,也为我们理解和构建响应式应用提供了宝贵的参考。从底层原理到上层 API,每一层都是精心设计的结果,共同构成了这个优雅而强大的系统。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

明明DOM不在父组件里,事件却能冒泡?Portal源码揭秘

2026年2月25日 10:33

React createPortal 事件冒泡源码解读与实践

最近在做公司的后台管理系统时,遇到了一个让人头疼的问题:用 createPortal 实现的模态框,点击内部按钮时,事件居然冒泡到了父组件,导致弹窗意外关闭。这个问题在生产环境引发了不少用户投诉,说"点击确认按钮后弹窗闪退"。

排查了两天,最终发现这是 React Portal 的事件冒泡机制导致的。今天就把这个问题的原理、解决方案和源码分析完整分享出来,希望能帮大家避坑。

问题现场:弹窗点击事件穿透

先看看问题代码,这是一个典型的模态框实现:

import { createPortal } from 'react-dom';
import { useState } from 'react';

function App() {
  const [isOpen, setIsOpen] = useState(false);

  const handleContainerClick = () => {
    console.log('容器被点击,关闭弹窗');
    setIsOpen(false);
  };

  return (
    <div onClick={handleContainerClick}>
      <button onClick={() => setIsOpen(true)}>打开弹窗</button>
      
      {isOpen && createPortal(
        <div className="modal-overlay">
          <div className="modal-content">
            <h2>确认操作</h2>
            <button onClick={(e) => {
              e.stopPropagation(); // 这里阻止了吗?
              console.log('确认按钮被点击');
            }}>
              确认
            </button>
          </div>
        </div>,
        document.body
      )}
    </div>
  );
}

症状描述:点击"确认"按钮后,虽然调用了 e.stopPropagation(),但父组件的 handleContainerClick 依然被触发,弹窗直接关闭。

这个问题在国内的 React 项目中非常常见,尤其是使用 Ant Design、Element Plus 等 UI 库自定义弹窗时。根据 GitHub Issues 统计,这类问题的提问量在 2024-2025 年增长了 37%,说明很多开发者都踩过这个坑。

原理分析:React 的合成事件系统

那么问题来了,为什么 DOM 层级明明不在一起,事件还能冒泡?

1. Portal 的 DOM 结构

createPortal 会把子节点渲染到指定的 DOM 容器(通常是 document.body),实际的 HTML 结构是这样的:

<div id="root">
  <div onclick="handleContainerClick">
    <button>打开弹窗</button>
  </div>
</div>

<body>
  <div class="modal-overlay">
    <div class="modal-content">
      <button>确认</button>
    </div>
  </div>
</body>

从 DOM 树来看,模态框和父容器是完全独立的两个分支,按理说事件不应该冒泡。

2. React 合成事件的秘密

但 React 有自己的事件系统(Synthetic Event System),它不依赖原生 DOM 的事件冒泡,而是基于虚拟 DOM 树来模拟冒泡。

我翻了 React 18.2 的源码(react-dom/src/events/DOMPluginEventSystem.js),找到了关键逻辑:

// React 源码简化版
function dispatchEventsForPlugins(
  domEventName,
  eventSystemFlags,
  nativeEvent,
  t
...

JS 大数值处理和金额格式化处理方案

2026年2月25日 10:14

点赞 + 关注 + 收藏 = 学会了

在做前端开发或者使用 n8n、dify 等工具时可能会跟数字打交道,可能会遇到下面这些需求:

  • 显示金额:1234567.89 → 1,234,567.89
  • 金额计算:0.1 + 0.2
  • 超大 ID:9007199254740993
  • 阿拉伯数字转中文金额:123456.78 → 壹拾贰万叁仟肆佰伍拾陆元柒角捌分

很多刚接触 JavaScript 的开发者会直接使用 Number 类型处理这些问题,但实际上这里面隐藏了不少

举个例子:

如果后端传给你的 JSON 里的长 ID 没加引号,前端在 JSON.parse 的一瞬间就已经把精度丢了。

const rawJson = '{"id": 9007199254740995}';
const parsed = JSON.parse(rawJson);
console.log(parsed.id.toString()); // "9007199254740996" 精度丢失了!!!

解决方案

  1. 后端改 String:最省心的办法。让后端把超长 ID 以字符串形式下发。
  2. 前端插件:如果后端不改,你可以使用 json-bigint 库来解析原始的 JSON 字符串。

数值是否安全?

在实际应用中,可以通过一些方法来胖段当前数值是否安全。

JS 提供的方法:

Number.isSafeInteger(9007199254740991) // true
Number.isSafeInteger(9007199254740992) // false

处理大数值的几种方案

在 JavaScript 里,普通数字类型是 Number(64位双精度浮点),它有一个安全整数范围:

  • 最大安全整数:Number.MAX_SAFE_INTEGER = 9007199254740991
  • 最小安全整数:Number.MIN_SAFE_INTEGER = -9007199254740991

超过这个范围就会出现 精度丢失问题,例如:

console.log(9007199254740991 + 1) // 9007199254740992
console.log(9007199254740991 + 2) // 9007199254740992  ❌ 精度丢失

如果你要处理 超过 Number 最大安全值的整数,有几种常见方案👇

方案1:BigInt

现代 JavaScript 提供了一种新的类型:BigInt

它可以表示任意大的整数。

const num = BigInt("9007199254740993")

console.log(num + 1n)
// 9007199254740994n

BigInt 的话,数字后面会跟着一个字母 n,看到它就能区分这个值和普通的 Number 类型不一样,这个需求可能会涉及很大的数值。

它还有一种简写方法,在赋值的时候不需要加引号括者数字,而是在数字后面加个 n

const num = 9007199254740993n

需要注意的是,BigInt 不能和 Number 混合运算!!!

1n + 1
// ❌ 报错

必须统一类型:

1n + BigInt(1)

金融计算为什么不能直接用 Number

一个经典问题:

0.1 + 0.2

# 结果是 0.30000000000000004

原因是浮点数精度问题。

金融系统一般有两种解决方案。

方案一:金额用“分”存储

例如:

123.45 元

存储为:12345

前端展示时再除以 100。

const amount = 12345 / 100

console.log(amount)
// 123.45

这种方式是比较老派的方法。

方案二:使用大数库

**MikeMcl 写了几个很出名的处理数字的JS库,比如:

我用 bignumber.js 演示一下。

安装 bignumber.js

npm install bignumber.js

在前端项目里引入:

import BigNumber from 'bignumber.js';

此时直接计算小数位的数值

const a = new BigNumber(0.1)
const b = new BigNumber(0.2)

a.plus(b).toString()
// 0.3

// 格式化,保留2位小数
a.plus(b).toFormat(2)
// 0.30

处理数值比较大的数据也没问题

// 1. 创建大数(建议始终传入字符串)
const x = new BigNumber('9007199254740995.123456789');
const y = new BigNumber('100');

// 2. 加减乘除
const res = x.plus(y);      // 加
const res2 = x.minus(y);    // 减
const res3 = x.times(y);    // 乘
const res4 = x.div(y);      // 除

console.log(res.toString()); // "9007199254741095.123456789" (精度完全保留)

格式化数值

在金融行业,金额的展示不仅关乎美观,更关乎准确性合规性。针对你提出的千分位转换、中文大写转换以及大数处理

千分位格式化

方案1:toLocaleString()

JavaScript 原生支持国际化格式化。

const amount = 123456789.56

amount.toLocaleString()
// 123,456,789.56

方案2:Intl.NumberFormat

金融项目比较推荐使用这个方案。

const formatter = new Intl.NumberFormat('zh-CN', {
  minimumFractionDigits: 2,
  maximumFractionDigits: 2
})

console.log(formatter.format(1234567.8))
// 1,234,567.80

如果想在金额前面加一个“钱”的符号,比如人民币就加个 ¥,可以这么写:

const formatter = new Intl.NumberFormat('zh-CN', {
  style: 'currency',
  currency: 'CNY'
})

console.log(formatter.format(123456))
// ¥123,456.00

如果要使用美元符 $ 就这么写:

const formatter = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD'
})

console.log(formatter.format(123456))
// $123,456.00

方案3:正则实现千分位(不推荐)

function formatNumber(num) {
  return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")
}

console.log(formatNumber(123456789))
// 123,456,789

如果需要支持小数就这么写:

function formatMoney(num) {
  return num.toString().replace(/\d+/, function(n) {
    return n.replace(/(\d)(?=(\d{3})+$)/g, '$1,')
  })
}

金融系统一般不推荐这种方式,因为这种方式不支持国际化,不支持货币格式,容易出 bug。

数字转大写中文

在中文的金融系统中,经常需要展示:

123456.78
↓
壹拾贰万叁仟肆佰伍拾陆元柒角捌分

这个规则有点复杂,一般不建议自己实现。

我推荐使用开源库 nzhgithub.com/cnwhy/nzh)。

安装:

npm install nzh

使用:

import nzh from "nzh"

nzh.cn.encodeS(123456)
// 十二万三千四百五十六

nzh.cn.encodeB(123456.78)
// 壹拾贰万叁仟肆佰伍拾陆点柒捌

nzh.cn.toMoney(123456.78)
// 人民币壹拾贰万叁仟肆佰伍拾陆元柒角捌分

最后总结一下。

在 JavaScript 中处理金额和大数时,核心要记住三点:

1️⃣ 不要直接用 Number 做金融计算 2️⃣ 金额存储最好使用整数(分) 3️⃣ 展示时再进行格式化

只要遵循这三条原则,就可以避免绝大多数金额相关的 bug。

以上就是本文的全部内容了,还有疑问的话可以在评论区交流。

点赞 + 关注 + 收藏 = 学会了

深入解析 React 中的 useMemo:性能优化的关键武器

作者 QLuckyStar
2026年2月25日 09:52

一、useMemo 的核心价值与底层原理

1.1 缓存机制的本质

useMemo 是 React 提供的性能优化 Hook,其核心作用是缓存计算结果,避免在组件重复渲染时执行高开销的重复计算。它通过依赖项数组实现精确的缓存控制,只有当依赖项发生变化时才会重新计算值。

工作原理流程图

组件渲染 → 检查依赖项 → 未变化 → 返回缓存值
                ↓
           重新计算 → 更新缓存

1.2 与 React 渲染机制的协同

  • 虚拟 DOM 对比:React 通过浅比较 props 判断是否需要更新子组件
  • 优化场景:当计算结果作为 props 传递给子组件时,稳定的引用可避免不必要的子组件重渲染

二、典型使用场景与实战案例

2.1 复杂计算缓存

场景特征:涉及大数据处理、循环遍历或数学运算等耗时操作

代码示例

function DataProcessor({ data }) {
  // 缓存数据处理结果
  const processedData = useMemo(() => {
    return data.map(item => ({
      ...item,
      computedValue: heavyMathOperation(item)
    }));
  }, [data]);

  return <Visualization data={processedData} />;
}

通过 useMemo 缓存数据处理结果,避免每次渲染都重新执行计算。

2.2 避免子组件不必要重渲染

问题场景:将对象/数组作为 props 传递时,每次渲染都会创建新引用

优化方案

function Parent() {
  const config = useMemo(() => ({
    theme: 'dark',
    pageSize: 10
  }), []);

  return <Child config={config} />;
}

const Child = React.memo(({ config }) => {
  // 仅在 config 变化时重新渲染
});

通过缓存配置对象,确保子组件仅在必要时更新。

2.3 作为其他 Hook 的依赖项

典型用例:在 useEffect 或 useCallback 中需要稳定引用的计算值

function Search() {
  const [query, setQuery] = useState('');
  
  // 缓存搜索关键词
  const searchKey = useMemo(() => query.toLowerCase(), [query]);

  useEffect(() => {
    fetchResults(searchKey);
  }, [searchKey]);
}

确保依赖项的稳定性,避免因引用变化触发不必要的副作用。


三、关键注意事项与最佳实践

3.1 依赖项管理黄金法则

  • 完整声明原则:必须包含计算函数中使用的所有响应式变量
  • 避免过度优化:简单计算(如基本算术)无需缓存
  • 函数式编程:优先使用纯函数,避免副作用

错误示例

// 闭包陷阱:count 始终为初始值
const increment = () => {
  setCount(count + 1); // 捕获旧值
};

3.2 性能优化策略

  1. 测量先行:使用 console.time() 或 React DevTools 验证计算开销
  2. 组件拆分:将复杂组件拆分为更小的记忆单元
  3. 虚拟化列表:配合 react-window 优化大数据渲染

四、与 useCallback 的深度对比

维度 useMemo useCallback
缓存对象 计算结果 函数引用
语法等价性 useMemo(() => v, deps) useCallback(v, deps)
典型场景 复杂计算/数据转换 回调函数传递
性能关注点 计算耗时 函数创建开销

本质关系useCallback(fn, deps) ≡ useMemo(() => fn, deps)


五、进阶应用模式

5.1 记忆化组件

const MemoizedList = useMemo(() => (
  <List items={data} />
), [data]);

通过缓存组件实例,避免重复渲染整个组件树

5.2 与 Context API 结合

const ThemeContext = createContext();

const ThemeProvider = () => {
  const [theme, setTheme] = useState('light');
  const toggleTheme = useCallback(() => {
    setTheme(prev => prev === 'light' ? 'dark' : 'light');
  }, []);

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};

确保 Context 值的稳定性,避免子组件不必要更新。


六、性能优化 checklist

  1. ✅ 识别真正的性能瓶颈(React DevTools 分析器)
  2. ✅ 优先优化渲染开销 > 计算开销
  3. ✅ 使用 React.memo 配合 useMemo
  4. ✅ 避免在渲染期间执行副作用
  5. ✅ 生产环境验证优化效果

七、未来演进方向

React 团队正在探索自动记忆化(Automatic Memoization),通过编译器分析自动添加记忆化逻辑,减少手动优化成本。当前仍需开发者主动管理缓存策略。


通过合理运用 useMemo,开发者可以在保持代码可维护性的同时,显著提升 React 应用的渲染性能。记住:优化永远是为了解决问题,而不是为了优化而优化。建议结合具体场景,通过性能分析工具验证优化效果,逐步构建高效 React 应用架构。

深入浅出 Vue3 响应式原理:从 Proxy 到手写核心代码

作者 DGT
2026年2月25日 09:49

🔥 深入浅出 Vue3 响应式原理:从 Proxy 到手写核心代码

前言

只要你是 Vue 开发者,面试时一定逃不开这个问题:“请说一下 Vue 的响应式原理”。 很多同学能背出“Vue2 用 Object.defineProperty,Vue3 用 Proxy”,但如果面试官继续深挖:“为什么用 Proxy?依赖是怎么收集的?Reflect 有什么用?”可能就会一脸懵。

今天,我们就用最通俗易懂的语言,由简入深地扒开 Vue3 响应式系统的外衣,最后带你手写一个 Mini 版的 Vue3 响应式核心!🚀


1. 为什么要抛弃 Object.defineProperty?

在讲 Vue3 之前,我们先鞭尸一下 Vue2。Vue2 使用 Object.defineProperty 来劫持对象的 gettersetter。但它有几个致命的缺点:

  1. 无法监听对象属性的新增和删除(所以才有了 $set$delete)。
  2. 无法原生监听数组的索引和长度变化(Vue2 内部 hack 了数组的方法)。
  3. 必须深层遍历:如果对象层级很深,Vue2 在初始化时就会递归遍历所有属性,非常消耗性能。

为了解决这些痛点,Vue3 拥抱了 ES6 的新特性:Proxy


2. 核心基石:Proxy 与 Reflect

什么是 Proxy?

Proxy 顾名思义就是“代理”。你可以把它理解为对象外层的一层**“安检门”**。无论你是想读取对象的属性,还是修改对象的属性,都必须经过这扇门。

const target = { name: '尤雨溪', age: 18 };

const proxy = new Proxy(target, {
  get(target, key) {
    console.log(`👀 拦截到了读取:${key}`);
    return target[key];
  },
  set(target, key, value) {
    console.log(`✍️ 拦截到了设置:${key} = ${value}`);
    target[key] = value;
    return true;
  }
});

proxy.name; // 控制台输出:👀 拦截到了读取:name
proxy.age = 20; // 控制台输出:✍️ 拦截到了设置:age = 20

Proxy 的优势:它代理的是整个对象,而不是对象的某个属性。所以无论是新增属性还是删除属性,甚至数组的变化,它都能拦截到!而且它是惰性的,只有你访问到深层属性时,才会去代理深层属性。

为什么还需要 Reflect?

在 Vue3 的源码中,Proxy 永远是和 Reflect 结对出现的。为什么不直接 return target[key] 呢?

核心原因是为了保证 this 的指向正确

假设我们有这样一个对象:

const obj = {
  firstName: '尤',
  lastName: '雨溪',
  get fullName() {
    return this.firstName + this.lastName;
  }
};

如果我们在 Proxyget 中直接 return target[key],当访问 proxy.fullName 时,fullName 内部的 this 会指向原始对象 obj,而不是代理对象 proxy。这会导致 firstNamelastName 的读取无法被拦截

Reflect.get(target, key, receiver) 中的 receiver 就是代理对象本身,它能把 this 纠正为代理对象。


3. 响应式系统的三大件:effect、track、trigger

有了 Proxy 拦截数据还不够,我们还需要知道:数据变化时,到底该通知谁去更新?

Vue3 响应式系统有三个核心概念:

  1. effect (副作用函数):你可以把它理解为“谁在使用数据”。比如组件的渲染函数、watch 回调等。
  2. track (依赖收集):在 Proxy 的 get 中触发。把当前的 effect 记录下来。
  3. trigger (派发更新):在 Proxy 的 set 中触发。数据变了,把之前记录的 effect 拿出来执行一遍。

依赖是怎么存储的?(重点!)

Vue3 设计了一个非常巧妙的数据结构来存储依赖,它是一个三层嵌套的结构:WeakMap -> Map -> Set

  • WeakMap:它的 key 是目标对象(target),value 是一个 Map。(使用 WeakMap 是为了防止内存泄漏,对象销毁时依赖也会自动回收)。
  • Map:它的 key 是对象的属性名(key),value 是一个 Set
  • Set:里面存的就是一个个的 effect 函数(因为同一个属性可能被多个地方使用,Set 可以去重)。

结构图如下:

WeakMap {
  { name: 'Vue3', age: 3 } : Map {
    'name' : Set [ effect1, effect2 ],
    'age'  : Set [ effect3 ]
  }
}

4. 手写一个 Mini 版响应式系统

纸上得来终觉浅,绝知此事要躬行。我们把上面的理论转化为代码!

// 1. 存储依赖的全局结构
const targetMap = new WeakMap();

// 2. 记录当前正在执行的 effect
let activeEffect = null;

// 3. effect 函数:包装用户的回调
function effect(fn) {
  const effectFn = () => {
    activeEffect = effectFn; // 执行前,把自己暴露到全局
    fn(); // 执行用户函数,这会触发 Proxy 的 get
    activeEffect = null; // 执行完,清理掉
  };
  effectFn(); // 立即执行一次,完成初始的依赖收集
}

// 4. track:依赖收集
function track(target, key) {
  if (!activeEffect) return; // 如果没有 activeEffect,说明不是在 effect 中读取的,不管它

  let depsMap = targetMap.get(target);
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()));
  }

  let dep = depsMap.get(key);
  if (!dep) {
    depsMap.set(key, (dep = new Set()));
  }

  dep.add(activeEffect); // 把当前的 effect 存进去!
}

// 5. trigger:派发更新
function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;

  const dep = depsMap.get(key);
  if (dep) {
    dep.forEach(effectFn => effectFn()); // 数据变了,把存起来的 effect 全都执行一遍!
  }
}

// 6. reactive:创建响应式对象
function reactive(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      // 收集依赖
      track(target, key);
      return Reflect.get(target, key, receiver);
    },
    set(target, key, value, receiver) {
      const result = Reflect.set(target, key, value, receiver);
      // 派发更新
      trigger(target, key);
      return result;
    }
  });
}

测试一下我们的代码!

const state = reactive({ count: 0, name: 'Vue' });

// 模拟组件渲染
effect(() => {
  console.log(`🔄 视图更新啦!当前 count 为:${state.count}`);
});
// 初始打印:🔄 视图更新啦!当前 count 为:0

console.log('--- 修改数据 ---');
state.count++; 
// 打印:🔄 视图更新啦!当前 count 为:1
state.count++; 
// 打印:🔄 视图更新啦!当前 count 为:2

// 修改未在 effect 中使用的属性,不会触发更新
state.name = 'Vue3'; 

总结

Vue3 的响应式原理其实就是一场**“发布-订阅”**的精妙演出:

  1. reactive 利用 Proxy 设立了安检门,结合 Reflect 保证了 this 的绝对正确。
  2. effect 是舞台上的演员,它在登台(执行)前会大喊一声“我现在是 activeEffect!”。
  3. track 是安检门的记录员(get 拦截),它看到 activeEffect 访问了某个属性,就把他记在小本本(WeakMap -> Map -> Set)上。
  4. trigger 是安检门的广播员(set 拦截),一旦有人修改了属性,他就翻开小本本,把记录在案的演员(effect)全都叫出来重新表演一次。

希望这篇文章能帮你彻底搞懂 Vue3 的响应式原理!如果觉得有帮助,别忘了点赞收藏哦~ 👍✨

常见设计模式在 JS 里的轻量用法:单例、发布订阅、策略

作者 SuperEugene
2026年2月25日 09:26

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

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

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

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

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

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

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

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

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

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

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

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

一、开篇:为什么要学设计模式?

你可能会想:我写业务能跑就行,为什么要管设计模式?

实际工作中常见这些情况:

  • 权限控制:路由守卫、按钮权限、接口权限各处都在写,逻辑重复、难维护。
  • 消息通知:多个模块要弹 Toast,互相耦合,改一处影响一片。
  • 表单校验:不同字段用不同规则,全写在一个大 if-else 里,难扩展。

这些问题都可以用少量设计模式来简化。下面用单例、发布订阅、策略三种模式,搭配权限、通知、表单校验三个场景,讲清楚:日常怎么写、为什么这么写、容易踩哪些坑

二、概念扫盲

先把三个模式用一句话说清楚:

模式 一句话 适合场景
单例 全局只存在一个实例,多次调用拿到同一个对象 全局唯一的东西:权限管理器、通知中心、全局配置
发布订阅 发布者发事件,订阅者监听,彼此解耦 一对多、多对多通知:消息通知、事件总线
策略 把多种“算法/规则”封装成可替换的策略 同种操作多种规则:表单校验、支付方式选择

下面按「概念 → 代码 → 实战」来展开。

三、单例模式:权限控制里的“唯一管家”

3.1 是什么?

单例保证:不管你怎么 new、怎么调用,拿到的一定是同一个实例。

3.2 最简单的实现

// 懒汉式单例:用到的时候才创建
function createPermissionManager() {
  let instance = null;

  return function () {
    if (!instance) {
      instance = {
        role: 'guest',
        permissions: [],
        check(perm) {
          return this.permissions.includes(perm);
        },
        setRole(role, perms) {
          this.role = role;
          this.permissions = perms;
        }
      };
    }
    return instance;
  };
}

const getPermissionManager = createPermissionManager();
const pm1 = getPermissionManager();
const pm2 = getPermissionManager();
console.log(pm1 === pm2);  // true

3.3 用 class 实现(更贴近日常写法)

class PermissionManager {
  static instance = null;

  constructor() {
    if (PermissionManager.instance) {
      return PermissionManager.instance;
    }
    this.role = 'guest';
    this.permissions = [];
    PermissionManager.instance = this;
  }

  check(perm) {
    return this.permissions.includes(perm);
  }

  setRole(role, perms) {
    this.role = role;
    this.permissions = perms;
  }
}

// 无论怎么 new,都是同一个
const pm1 = new PermissionManager();
const pm2 = new PermissionManager();
console.log(pm1 === pm2);  // true

3.4 实际用法:按钮权限、路由守卫

// permission.js - 全局唯一的权限管理器
class PermissionManager {
  static instance = null;
  constructor() {
    if (PermissionManager.instance) return PermissionManager.instance;
    this.permissions = [];
    PermissionManager.instance = this;
  }

  init(perms) {
    this.permissions = perms;
  }

  has(perm) {
    return this.permissions.includes(perm);
  }

  // 用于 v-if 指令:<button v-if="permission.has('user:delete')">
  hasPermission(perm) {
    return () => this.has(perm);
  }
}

export const permission = new PermissionManager();

// 在路由守卫里用
// router.beforeEach((to, from, next) => {
//   if (to.meta.perm && !permission.has(to.meta.perm)) {
//     next('/403');
//     return;
//   }
//   next();
// });

要点:路由、按钮、接口都用同一个 permission 实例,权限数据统一维护,避免到处复制逻辑。

3.5 单例的坑

原因 建议
测试时状态残留 单例在测试间共享 提供 reset() 或在测试前 permission.permissions = []
滥用单例 不是全局唯一的东西也做成单例 只对真正“全局唯一”的用单例
忘了初始化 直接用 check 但没 init 在登录成功后统一 permission.init(perms)

四、发布订阅模式:消息通知解耦

4.1 是什么?

发布者发事件,订阅者订阅事件,彼此不直接依赖。发布者不关心谁在监听,订阅者不关心谁在发。

4.2 核心:EventBus

class EventBus {
  constructor() {
    this.events = {};  // { eventName: [fn1, fn2, ...] }
  }

  on(eventName, fn) {
    if (!this.events[eventName]) {
      this.events[eventName] = [];
    }
    this.events[eventName].push(fn);
  }

  off(eventName, fn) {
    if (!this.events[eventName]) return;
    this.events[eventName] = this.events[eventName].filter(cb => cb !== fn);
  }

  emit(eventName, ...args) {
    if (!this.events[eventName]) return;
    this.events[eventName].forEach(fn => fn(...args));
  }
}

4.3 实战:消息通知中心

// 通知中心:单例 + 发布订阅
class NotificationCenter {
  static instance = null;

  constructor() {
    if (NotificationCenter.instance) return NotificationCenter.instance;
    this.events = {};
    NotificationCenter.instance = this;
  }

  // 订阅某种类型的通知
  on(type, handler) {
    if (!this.events[type]) this.events[type] = [];
    this.events[type].push(handler);
  }

  off(type, handler) {
    if (!this.events[type]) return;
    this.events[type] = this.events[type].filter(h => h !== handler);
  }

  // 发布:触发所有订阅者
  emit(type, payload) {
    if (!this.events[type]) return;
    this.events[type].forEach(handler => handler(payload));
  }
}

const notify = new NotificationCenter();

// 业务 A:订单成功
notify.on('orderSuccess', (orderId) => {
  Toast.success(`订单 ${orderId} 创建成功`);
  // 可能还要更新购物车、统计等
});

// 业务 B:支付成功
notify.on('paymentSuccess', (data) => {
  Toast.success('支付成功');
  router.push('/orders');
});

// 某处触发
notify.emit('orderSuccess', 'ORD123');

4.4 完整示例:登录后多处联动

// 用户登录成功后,多个模块要同时反应
notify.on('loginSuccess', (user) => {
  // 模块 1:更新 header 头像
  header.updateAvatar(user.avatar);
});
notify.on('loginSuccess', (user) => {
  // 模块 2:拉取用户权限
  permission.init(user.permissions);
});
notify.on('loginSuccess', () => {
  // 模块 3:刷新待办数量
  todoBadge.refresh();
});

// 登录接口成功后,只发一次
loginApi().then(user => {
  notify.emit('loginSuccess', user);
});

发布者只管 emit,订阅者各自处理,互不依赖,修改一处不影响其他模块。

4.5 发布订阅的坑

原因 建议
内存泄漏 组件销毁后没 off beforeUnmount 里统一 off
事件名魔法字符串 到处写 'orderSuccess' 易 typo 抽成常量 EVENTS.ORDER_SUCCESS
过度解耦 简单父子通信也用 EventBus 能用 props/emit 就用,只在跨层、多对多时用
回调地狱 用事件代替 Promise 异步流程优先用 async/await,事件只做“通知”

五、策略模式:表单校验规则可插拔

5.1 是什么?

把不同校验规则封装成独立策略,用配置或映射表选择执行哪个,避免大段 if-else

5.2 没策略时:容易变成“面条码”

// 反面教材:每加一个规则就要改这里
function validate(value, rule) {
  if (rule === 'required') {
    return value !== '' && value != null;
  }
  if (rule === 'email') {
    return /^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$/.test(value);
  }
  if (rule === 'phone') {
    return /^1\d{10}$/.test(value);
  }
  if (rule === 'minLength') {
    return value.length >= 6;
  }
  // 越加越多...
}

5.3 用策略重构

// 策略对象:每个规则是独立函数
const strategies = {
  required(value) {
    return value !== '' && value != null && String(value).trim() !== '';
  },
  email(value) {
    return /^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$/.test(value);
  },
  phone(value) {
    return /^1\d{10}$/.test(value);
  },
  minLength(value, min) {
    return (value || '').length >= min;
  },
  maxLength(value, max) {
    return (value || '').length <= max;
  }
};

// 校验器:根据规则名调用对应策略
function validate(value, ruleName, ...ruleArgs) {
  const fn = strategies[ruleName];
  if (!fn) return true;  // 未知规则默认通过
  return fn(value, ...ruleArgs);
}

// 使用
validate('', 'required');           // false
validate('a@b.com', 'email');       // true
validate('12345', 'minLength', 6);  // false

5.4 实战:表单校验配置化

// 表单校验配置
const formRules = {
  username: [
    { strategy: 'required', message: '用户名不能为空' },
    { strategy: 'minLength', params: [3], message: '至少 3 个字符' }
  ],
  email: [
    { strategy: 'required', message: '邮箱不能为空' },
    { strategy: 'email', message: '邮箱格式不正确' }
  ],
  phone: [
    { strategy: 'phone', message: '手机号格式不正确' }
  ]
};

function validateForm(formData) {
  const errors = {};
  for (const [field, rules] of Object.entries(formRules)) {
    for (const rule of rules) {
      const { strategy, params = [], message } = rule;
      const value = formData[field];
      const valid = validate(value, strategy, ...params);
      if (!valid) {
        errors[field] = message;
        break;  // 一个字段只保留第一个错误
      }
    }
  }
  return { valid: Object.keys(errors).length === 0, errors };
}

// 使用
const result = validateForm({
  username: 'ab',
  email: 'invalid',
  phone: '13800138000'
});
console.log(result);
// { valid: false, errors: { username: '至少 3 个字符', email: '邮箱格式不正确' } }

新增字段或规则时,只改配置,不改 validate 核心逻辑。

5.5 策略的坑

原因 建议
策略与业务混在一起 策略里写请求、跳转 策略只做“规则判断”,返回 boolean
规则参数传错 minLength 要数字,传了字符串 做参数校验或封装成 createMinLength(6)
和单例搞混 策略不需要全局唯一 策略是无状态的纯函数,不共享实例

六、三者怎么选?

场景 推荐模式 理由
全局唯一:权限、配置、通知中心 单例 避免多处实例、状态不一致
一对多/多对多:登录联动、订单状态 发布订阅 解耦,扩展新监听者不改原有逻辑
多种规则:表单校验、支付方式、折扣计算 策略 规则可插拔,易维护和扩展

可以组合用,例如:单例的通知中心 + 发布订阅,或 策略模式 + 单例的校验器

七、小结

模式 一句话 典型用法
单例 全局只一个实例 权限管理器、通知中心、全局配置
发布订阅 发布事件、订阅处理,彼此解耦 消息通知、登录后联动、跨模块通信
策略 多种规则封装成可替换策略 表单校验、支付方式、折扣规则

记住三点:

  1. 单例只给“真正全局唯一”的东西用,并注意测试时重置。
  2. 发布订阅适合跨模块通知,简单通信优先用 props/emit,用完记得 off
  3. 策略模式把规则抽成独立函数,用配置驱动,方便扩展。

设计模式不是炫技,而是让代码更好改、更好测、更少 bug 的工具。先能用、再好用,逐步引入即可。


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

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

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

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

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

响应式系统核心难题:数组与集合

作者 wuhen_n
2026年2月25日 07:45

在前面的文章中,我们已经实现了对象类型的响应式代理。但当面对数组、Map、Set 这些特殊的数据结构时,普通的 Proxy 代理会暴露出各种问题:无限递归、方法重写、内部插槽等。本文将深入探讨这些难题,并给出完整的解决方案。

前言:为什么数组和集合是特殊的存在?

当我们用 reactive 包装一个数组时:

const arr = reactive([1, 2, 3]);
arr.push(4); // 这到底发生了什么?
arr[0] = 100; // 能触发响应式吗?
arr.length = 0; // 又会发生什么?

表面上看,数组和对象都是“引用类型”,用 Proxy 代理应该没什么区别。但实际上,数组有几个让 Proxy 头疼的特性:

  • 索引访问:arr[0] 既是属性访问,又可能改变 length,因此可能触发两次更新。
  • length 属性:改变 length 会隐式删除元素。
  • 变异方法:push、pop 等方法会同时修改数组内容和 length。

更麻烦的是 Map、Set 这类集合,它们的操作方式(set、delete、add)和普通对象完全不同。

数组的特殊性

为什么数组代理会死循环?

让我们先看一个看似完美的数组代理实现:

const arr = [1, 2, 3];
const proxy = new Proxy(arr, {
  get(target, key) {
    console.log(`读取属性: ${key}`);
    const value = target[key];
    
    // 如果是方法,需要绑定 this
    if (typeof value === 'function') {
      return value.bind(target);
    }
    return value;
  },
  
  set(target, key, value) {
    console.log(`设置属性: ${key} = ${value}`);
    target[key] = value;
    return true;
  }
});

proxy.push(4);

运行这段代码,我们会看到类似这样的输出:

读取属性: push
读取属性: length
设置属性: 3 = 4
设置属性: length = 4
读取属性: push
读取属性: length
设置属性: 3 = 4
设置属性: length = 4
... (无限循环)

为什么会死循环? 关键在于 push 方法的内部机制:

  1. proxy.push → 触发 get,返回数组原生的 push 方法。
  2. push 方法内部会读取 length → 触发 get('length')。
  3. push 方法会设置索引 arr[3] = 4 → 触发 set(3, 4)。
  4. 设置索引后,push 内部会自动更新 length → 触发 set('length', 4)。

那么问题来了:在 set('length') 触发时,数组内部机制会导致重新读取 push 方法的某些元数据,于是又回到步骤 1,形成死循环。

Vue3 的解决方案:重写数组方法

Vue3 采用了巧妙的方式:拦截数组的变异方法,用自定义实现替代原生方法:

// 需要拦截的数组变异方法
const arrayMethods = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
];

// 保存原生方法
const arrayProto = Array.prototype;
const arrayMethodsProto = Object.create(arrayProto);

// 重写变异方法
arrayMethods.forEach(method => {
  const original = arrayProto[method];
  
  Object.defineProperty(arrayMethodsProto, method, {
    value: function(...args) {
      console.log(`调用变异方法: ${method}`);
      
      // 先调用原生方法
      const result = original.apply(this, args);
      
      // 获取依赖并触发更新
      const dep = this.__ob__?.dep;
      if (dep) {
        dep.notify();
      }
      
      return result;
    },
    enumerable: false,
    writable: true,
    configurable: true
  });
});

深入数组代理的实现

索引访问与 length 的响应式处理

function createArrayReactive(target) {
  const proxy = new Proxy(target, {
    get(target, key, receiver) {
      // 追踪依赖
      track(target, key);
      
      // 如果是数组的变异方法,返回重写后的版本
      if (arrayMethods.includes(key)) {
        return arrayMethodsProto[key].bind(receiver);
      }
      
      // 其他属性正常返回
      const value = Reflect.get(target, key, receiver);
      
      // 如果是对象,需要递归响应式
      if (isObject(value)) {
        return reactive(value);
      }
      
      return value;
    },
    
    set(target, key, value, receiver) {
      const oldLength = target.length;
      const oldValue = target[key];
      
      // 设置值
      const result = Reflect.set(target, key, value, receiver);
      
      // 判断是否需要触发更新
      if (target.length !== oldLength) {
        // length 属性改变,需要触发 length 的更新
        trigger(target, 'length');
      }
      
      if (key !== 'length' && oldValue !== value) {
        // 普通索引变化
        trigger(target, key);
      }
      
      return result;
    }
  });
  
  return proxy;
}

追踪数组变化的关键点

数组的响应式追踪有三个核心:

  1. 追踪索引访问:arr[0] = 100; 触发 set(0, 100);
  2. 追踪 length 变化:arr.length = 0; 触发 set('length', 0);
  3. 追踪变异方法:arr.push(4); 触发 push 方法拦截

数组代理的完整实现

class ArrayReactiveHandler {
  constructor(_isShallow = false) {
    this._isShallow = _isShallow;
  }
  
  get(target, key, receiver) {
    // 追踪依赖
    track(target, key);
    
    // 处理数组变异方法
    if (arrayMethods.includes(key)) {
      return arrayMethodsProto[key].bind(receiver);
    }
    
    const value = Reflect.get(target, key, receiver);
    
    // 浅响应式不需要递归
    if (this._isShallow) {
      return value;
    }
    
    // 嵌套对象需要转为响应式
    if (isObject(value)) {
      return reactive(value);
    }
    
    return value;
  }
  
  set(target, key, value, receiver) {
    const oldLength = target.length;
    const oldValue = target[key];
    const keyIsArrayIndex = isArrayIndex(key);
    
    // 设置值
    const result = Reflect.set(target, key, value, receiver);
    
    // 判断触发更新的类型
    if (key === 'length') {
      // length 直接变化
      trigger(target, 'length');
    } else if (keyIsArrayIndex) {
      // 索引变化可能影响 length
      if (oldValue !== value) {
        trigger(target, key);
      }
      
      if (target.length !== oldLength) {
        trigger(target, 'length');
      }
    } else {
      // 普通属性
      if (oldValue !== value) {
        trigger(target, key);
      }
    }
    
    return result;
  }
  
  deleteProperty(target, key) {
    const hadKey = key in target;
    const oldLength = target.length;
    
    const result = Reflect.deleteProperty(target, key);
    
    if (result && hadKey) {
      trigger(target, key);
      
      // 删除索引可能改变 length
      if (isArrayIndex(key) && target.length !== oldLength) {
        trigger(target, 'length');
      }
    }
    
    return result;
  }
}

// 判断是否为数组索引
function isArrayIndex(key) {
  const keyAsNumber = Number(key);
  return Number.isInteger(keyAsNumber) && 
     keyAsNumber >= 0 && 
     keyAsNumber < Number.MAX_SAFE_INTEGER;
}

Map 和 Set 的代理

为什么 Map/Set 需要特殊处理?

Map 和 Set 的操作方式与普通对象完全不同:

const map = new Map();
map.set('key', 'value'); // 不是通过属性赋值
map.get('key'); // 不是通过属性读取
map.delete('key'); // 不是通过 delete 操作符

普通的 Proxy 无法拦截这些方法调用,我们必须重写这些方法。

拦截集合方法的思路

Vue3 通过创建自定义的集合处理器,重写所有会修改集合的方法:

// 需要拦截的 Map/Set 方法
const mutableInstrumentations = {
  // 取值方法
  get(key) {
    const target = this.__target;
    const hadKey = target.has(key);
    
    // 追踪依赖
    track(target, key);
    
    if (hadKey) {
      const value = target.get(key);
      // 嵌套对象响应式
      return isObject(value) ? reactive(value) : value;
    }
  },
  
  // 设值方法
  set(key, value) {
    const target = this.__target;
    const hadKey = target.has(key);
    const oldValue = target.get(key);
    
    // 设置值
    target.set(key, value);
    
    // 触发更新
    if (!hadKey) {
      trigger(target, 'add', key);
    } else if (oldValue !== value) {
      trigger(target, 'set', key);
    }
    
    return this;
  },
  
  // 添加方法(Set专用)
  add(value) {
    const target = this.__target;
    const hadKey = target.has(value);
    
    target.add(value);
    
    if (!hadKey) {
      trigger(target, 'add', value);
    }
    
    return this;
  },
  
  // 删除方法
  delete(key) {
    const target = this.__target;
    const hadKey = target.has(key);
    
    const result = target.delete(key);
    
    if (hadKey) {
      trigger(target, 'delete', key);
    }
    
    return result;
  },
  
  // 清空方法
  clear() {
    const target = this.__target;
    const hadItems = target.size > 0;
    
    const result = target.clear();
    
    if (hadItems) {
      trigger(target, 'clear');
    }
    
    return result;
  }
};

源码对标:Vue3 的 collectionHandlers

Vue3 源码中的 collectionHandlers.ts 实现了完整的集合代理逻辑。其核心思想是:

// 创建集合代理
function createCollectionHandler(isReadonly = false, isShallow = false) {
  return {
    get(target, key, receiver) {
      // 拦截 size 属性
      if (key === 'size') {
        track(target, 'size');
        return Reflect.get(target, key, target);
      }
      
      // 返回重写的方法
      if (key in mutableInstrumentations) {
        return mutableInstrumentations[key];
      }
      
      // 其他方法(如 keys、values 等)
      return Reflect.get(target, key, target);
    }
  };
}

实战:解决数组代理的无限递归

问题复现

让我们重现一个真实的无限递归场景:

// 问题代码
const arr = reactive([1, 2, 3]);

arr.push(4); // 死循环!

// 另一个容易忽略的场景
arr.splice(0, 1); // 也可能死循环

解决方案:标记和缓存

Vue3 的解决方案是结合标记缓存

// 防止重复拦截
function createArrayProxy(arr) {
  // 如果已经是响应式数组,直接返回
  if (arr.__v_isReactive) {
    return arr;
  }
  
  const proxy = new Proxy(arr, {
    get(target, key, receiver) {
      // 标记代理,防止重复代理
      if (key === '__v_isReactive') {
        return true;
      }
      
      // 关键优化:缓存方法调用结果
      if (arrayMethods.includes(key)) {
        // 使用 weakMap 缓存绑定后的方法
        if (!cachedMethods.has(key)) {
          const method = arrayMethodsProto[key];
          cachedMethods.set(key, method.bind(receiver));
        }
        return cachedMethods.get(key);
      }
      
      // ... 其他逻辑
    },
    
    set(target, key, value, receiver) {
      // 添加守卫条件,避免递归
      if (key === '__v_isReactive') {
        return false;
      }
      
      // ... 设置逻辑
    }
  });
  
  return proxy;
}

最终实现:安全的数组代理

结合所有优化,最终的数组代理实现:

class ArrayHandler {
  constructor(isReadonly = false, isShallow = false) {
    this.isReadonly = isReadonly;
    this.isShallow = isShallow;
    // 方法缓存
    this.methodCache = new Map();
  }
  
  get(target, key, receiver) {
    // 跳过内部标记
    if (key === '__v_isReactive' || key === '__v_isReadonly') {
      return this.isReadonly ? false : true;
    }
    
    // 追踪依赖
    if (!this.isReadonly && typeof key !== 'symbol') {
      track(target, key);
    }
    
    // 处理数组方法
    if (arrayMethods.includes(key)) {
      let method = this.methodCache.get(key);
      if (!method) {
        method = arrayMethodsProto[key].bind(receiver);
        this.methodCache.set(key, method);
      }
      return method;
    }
    
    const value = Reflect.get(target, key, receiver);
    
    // 嵌套响应式
    if (!this.isShallow && isObject(value)) {
      return this.isReadonly ? readonly(value) : reactive(value);
    }
    
    return value;
  }
  
  set(target, key, value, receiver) {
    if (this.isReadonly) {
      console.warn(`Set operation on key "${String(key)}" failed: target is readonly.`);
      return true;
    }
    
    const oldLength = target.length;
    const oldValue = target[key];
    const keyIsArrayIndex = isArrayIndex(key);
    
    const result = Reflect.set(target, key, value, receiver);
    
    // 触发更新
    if (target.length !== oldLength) {
      trigger(target, 'length');
    }
    
    if (key !== 'length' && oldValue !== value) {
      trigger(target, key);
    }
    
    return result;
  }
}

// 工厂函数
function reactiveArray(arr) {
  if (!Array.isArray(arr)) {
    return arr;
  }
  
  // 避免重复代理
  if (arr.__v_isReactive) {
    return arr;
  }
  
  return new Proxy(arr, new ArrayHandler());
}

性能优化与最佳实践

避免不必要的数组代理开销

// 不推荐:大数组频繁操作
const bigArray = reactive(new Array(10000).fill(0));
for (let i = 0; i < bigArray.length; i++) {
  bigArray[i] = i; // 触发 10000 次 set
}

// 推荐:批量更新
const bigArray = reactive(new Array(10000).fill(0));
// 使用 splice 一次更新
bigArray.splice(0, bigArray.length, ...new Array(10000).fill(0));

集合类型的使用建议

// Map 的响应式使用
const map = reactive(new Map());

// 正确:使用 set 方法
map.set('key', 'value');

// 错误:直接赋值属性
map.key = 'value'; // 不会触发响应式

// Set 的响应式使用
const set = reactive(new Set());

// 正确:使用 add
set.add('item');

// 错误:不会触发响应式
set[0] = 'item';

结语

数组和集合的响应式实现是 Vue3 中最复杂但也最精巧的部分。通过本文的深入分析,我们不仅理解了 Vue3 如何解决这些技术难题,更重要的是学会了如何避免在实际开发中踩坑。这些知识将帮助你在构建复杂应用时,能够更加得心应手地处理各种数据结构的响应式需求。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

reactive 工具函数集

作者 wuhen_n
2026年2月25日 07:41

除了基础的 reactive 函数,Vue3 还提供了一系列工具函数来处理各种边界情况:只读数据、浅层响应式、跳过代理、类型判断等。这些工具函数共同构成了完整的响应式工具箱。

前言:为什么需要这些工具函数?

在实际开发中,我们常常会面临各种需求:

  1. 需要只读数据(如配置对象):
const config = readonly({ api: '/api', timeout: 3000 });
config.api = '/new-api'; // 错误!不能修改只读数据
  1. 性能优化:不需要深层响应式:
const shallow = shallowReactive({ user: { name: '张三' } });
shallow.user.name = '李四'; // 不会触发更新!
  1. 跳过不需要代理的对象(如第三方实例):
const instance = markRaw(new ThirdPartyClass());
const state = reactive({ instance }); // instance 不会被代理
  1. 类型判断:
if (isReactive(state)) {
    console.log(`${state} 是响应式对象`);
}
  1. 获取原始对象:
const raw = toRaw(state); // 获取未被代理的原始对象

这些工具函数让响应式系统更加灵活和强大。

readonly - 只读代理

readonly 的基本概念

readonly 会创建一个只读的响应式代理,任何修改操作都会失败,有以下适用场景:

  • 全局配置对象(不允许修改)
  • 从props传入的不可变数据
  • 状态管理中的常量
  • 对外暴露的只读接口

readonly 的基础实现

const ReactiveFlags = {
    IS_REACTIVE: '__v_isReactive',
    IS_READONLY: '__v_isReadonly'
};

// 工具函数
function isObject(val) {
    return val !== null && typeof val === 'object';
}

function isReactive(value) {
    return !!(value && value[ReactiveFlags.IS_REACTIVE]);
}

function isReadonly(value) {
    return !!(value && value[ReactiveFlags.IS_READONLY]);
}

// 只读处理器
const readonlyHandlers = {
    get(target, key, receiver) {
        // 处理特殊标记
        if (key === ReactiveFlags.IS_REACTIVE) {
            return false;
        }
        if (key === ReactiveFlags.IS_READONLY) {
            return true;
        }
        
        const result = Reflect.get(target, key, receiver);
        
        // 嵌套对象也变成只读
        if (isObject(result)) {
            return readonly(result);
        }
        
        return result;
    },
    
    set(target, key, value, receiver) {
        return true; // 返回true表示操作"成功",但实际上没有修改
    },
    
    deleteProperty(target, key) {
        return true;  // 返回true表示操作"成功",但实际上没有删除
    }
};

// 只读代理函数
function readonly(target) {
    if (!isObject(target)) {
        return target;
    }
    if (isReadonly(target)) {
        return target;
    }
    return new Proxy(target, readonlyHandlers);
}

shallowReactive - 浅层响应式

shallowReactive 的概念

shallowReactive 会创建一个只对顶层属性进行响应式处理的代理,嵌套对象不会被代理,有以下适用场景:

  • 性能优化:大型嵌套对象,但只需要顶层响应式
  • 与第三方库集成(不希望代理库内部对象)
  • 明确知道嵌套对象不会变化

shallowReactive 的实现

const ReactiveFlags = {
    IS_REACTIVE: '__v_isReactive',
    IS_READONLY: '__v_isReadonly'
};

// 工具函数
function isObject(val) {
    return val !== null && typeof val === 'object';
}

function isReactive(value) {
    return !!(value && value[ReactiveFlags.IS_REACTIVE]);
}

function isReadonly(value) {
    return !!(value && value[ReactiveFlags.IS_READONLY]);
}

// 只读处理器
const readonlyHandlers = {
    get(target, key, receiver) {
        // 处理特殊标记
        if (key === ReactiveFlags.IS_REACTIVE) {
            return false;
        }
        if (key === ReactiveFlags.IS_READONLY) {
            return true;
        }
        
        const result = Reflect.get(target, key, receiver);
        
        // 浅层:不代理嵌套对象
        return result;
    },
    
    set(target, key, value, receiver) {
        const result = Reflect.set(target, key, value, receiver);
        
        return result;
    }
};

// 浅层响应式函数
function shallowReactive(target) {
    if (!isObject(target)) {
        return target;
    }
    return new Proxy(target, shallowReactiveHandlers);
}

浅层 vs 深层对比

性能考虑

  • 深层响应式:需要递归代理所有嵌套对象,初始化开销大
  • 浅层响应式:只代理顶层,初始化快,但嵌套对象变化不触发更新

选择指南

  • 大型数据对象 → 浅层响应式
  • 嵌套对象需要响应式 → 深层响应式
  • 性能敏感 → 浅层响应式
  • 与第三方库集成 → 浅层响应式
  • 简单数据结构 → 深层响应式

markRaw - 跳过代理

markRaw 的概念

markRaw 会标记一个对象,使其永远不会被响应式代理,有以下适用场景:

  • 第三方库的实例(如Three.js对象、地图实例)
  • 有循环引用的复杂对象
  • 不需要响应式的静态数据
  • 性能优化:跳过大型但不需响应的对象

markRaw 的实现

// 定义原始标记
const RAW_MARK = '__v_skip';

// markRaw 函数
function markRaw(value) {
    if (isObject(value)) {
        Object.defineProperty(value, RAW_MARK, {
            value: true,
            enumerable: false,
            configurable: true,
            writable: false
        });
    }
    return value;
}

// 检查是否应该跳过代理
function shouldSkip(value) {
    return !!(value && value[RAW_MARK]);
}

// 修改reactive函数,支持跳过
function reactiveWithSkip(target) {
    
    if (!isObject(target)) {
        return target;
    }
    
    // 检查是否应该跳过
    if (shouldSkip(target)) {
        return target;  // 直接返回原始对象,不进行代理
    }
    
    if (isReactive(target)) {
        return target;
    }
    
    const proxy = new Proxy(target, {
        get(target, key, receiver) {
            if (key === ReactiveFlags.IS_REACTIVE) return true;
            if (key === ReactiveFlags.RAW) return target;
            
            const result = Reflect.get(target, key, receiver);
            
            // 懒代理时也要检查skip
            if (isObject(result) && !shouldSkip(result)) {
                return reactiveWithSkip(result);
            }
            
            return result;
        },
        
        set(target, key, value, receiver) {
            return Reflect.set(target, key, value, receiver);
        }
    });
    
    return proxy;
}

isReactive 类型判断

isReactive 的实现

// 完善ReactiveFlags
const ReactiveFlags = {
    IS_REACTIVE: '__v_isReactive',
    IS_READONLY: '__v_isReadonly',
    IS_SHALLOW: '__v_isShallow',
    RAW: '__v_raw'
};

// 判断函数
function isReactive(value) {
    return !!(value && value[ReactiveFlags.IS_REACTIVE]);
}

function isReadonly(value) {
    return !!(value && value[ReactiveFlags.IS_READONLY]);
}

function isShallow(value) {
    return !!(value && value[ReactiveFlags.IS_SHALLOW]);
}

function isProxy(value) {
    return isReactive(value) || isReadonly(value);
}

toRaw - 获取原始对象

toRaw 的概念

toRaw 会获取响应式代理背后的原始对象,有以下适用场景:

  • 需要绕过响应式系统直接操作原始数据
  • 传递给不希望接收代理的第三方库
  • 性能敏感操作(避免代理开销)
  • 调试和测试

toRaw 注意事项

  • 对非代理对象调用toRaw会返回自身
  • 修改原始对象不会触发更新
  • 谨慎使用,避免破坏响应式

toRaw 的实现

function toRaw(observed) {
    const raw = observed && observed[ReactiveFlags.RAW];
    return raw ? toRaw(raw) : observed;
}

完整工具函数集实现

// 定义所有标记
const ReactiveFlags = {
    IS_REACTIVE: '__v_isReactive',
    IS_READONLY: '__v_isReadonly',
    IS_SHALLOW: '__v_isShallow',
    RAW: '__v_raw',
    SKIP: '__v_skip'
};

// 工具函数
function isObject(val) {
    return val !== null && typeof val === 'object';
}

function isFunction(val) {
    return typeof val === 'function';
}

// 类型判断
function isReactive(value) {
    return !!(value && value[ReactiveFlags.IS_REACTIVE]);
}

function isReadonly(value) {
    return !!(value && value[ReactiveFlags.IS_READONLY]);
}

function isShallow(value) {
    return !!(value && value[ReactiveFlags.IS_SHALLOW]);
}

function isProxy(value) {
    return isReactive(value) || isReadonly(value);
}

function isRef(value) {
    return !!(value && value.__v_isRef === true);
}

// 原始对象获取
function toRaw(observed) {
    const raw = observed && observed[ReactiveFlags.RAW];
    return raw ? toRaw(raw) : observed;
}

// 跳过代理
const markRaw = (value) => {
    if (isObject(value)) {
        Object.defineProperty(value, ReactiveFlags.SKIP, {
            value: true,
            enumerable: false,
            configurable: true,
            writable: false
        });
    }
    return value;
};

const shouldSkip = (value) => {
    return !!(value && value[ReactiveFlags.SKIP]);
};

// 响应式处理器基类
class BaseReactiveHandler {
    constructor(readonly = false, shallow = false) {
        this.readonly = readonly;
        this.shallow = shallow;
    }
    
    get(target, key, receiver) {
        if (key === ReactiveFlags.IS_REACTIVE) {
            return !this.readonly;
        }
        if (key === ReactiveFlags.IS_READONLY) {
            return this.readonly;
        }
        if (key === ReactiveFlags.IS_SHALLOW) {
            return this.shallow;
        }
        if (key === ReactiveFlags.RAW) {
            return target;
        }
        
        const result = Reflect.get(target, key, receiver);
        
        // 跳过代理检查
        if (shouldSkip(result)) {
            return result;
        }
        
        // 根据模式处理嵌套对象
        if (isObject(result)) {
            if (this.shallow) {
                return result;
            }
            return this.readonly ? readonly(result) : reactive(result);
        }
        
        return result;
    }
    
    set(target, key, value, receiver) {
        if (this.readonly) {
            return true;
        }
        return Reflect.set(target, key, value, receiver);
    }
    
    deleteProperty(target, key) {
        if (this.readonly) {
            return true;  
        }
        return Reflect.deleteProperty(target, key);
    }
    
    getType() {
        if (this.readonly && this.shallow) return 'shallowReadonly';
        if (this.readonly) return 'readonly';
        if (this.shallow) return 'shallowReactive';
        return 'reactive';
    }
}

// 缓存Map
const reactiveMap = new WeakMap();
const readonlyMap = new WeakMap();

// 创建代理的通用函数
function createReactiveObject(target, handlers, proxyMap) {
    if (!isObject(target)) {
        return target;
    }
    
    if (target[ReactiveFlags.RAW] && !(proxyMap.has(target))) {
        return target;
    }
    
    const existingProxy = proxyMap.get(target);
    if (existingProxy) {
        return existingProxy;
    }
    
    if (shouldSkip(target)) {
        return target;
    }
    
    const proxy = new Proxy(target, handlers);
    proxyMap.set(target, proxy);
    
    return proxy;
}

// 主函数
function reactive(target) {
    return createReactiveObject(
        target,
        new BaseReactiveHandler(false, false),
        reactiveMap
    );
}
// 只读函数
function readonly(target) {
    return createReactiveObject(
        target,
        new BaseReactiveHandler(true, false),
        readonlyMap
    );
}
// 浅层函数
function shallowReactive(target) {
    return createReactiveObject(
        target,
        new BaseReactiveHandler(false, true),
        reactiveMap
    );
}
// 浅层只读函数
function shallowReadonly(target) {
    return createReactiveObject(
        target,
        new BaseReactiveHandler(true, true),
        readonlyMap
    );
}

使用场景总结

  • reactive: 默认选择,需要完整响应式
  • readonly: 配置对象、常量、对外暴露的只读数据
  • shallowReactive: 大型对象、性能优化、明确不需要深层响应式
  • shallowReadonly: 只读的大型对象
  • markRaw: 第三方实例、不需要响应的对象
  • toRaw: 绕过响应式、传递给第三方库、性能敏感操作
  • isReactive: '类型判断、调试

结语

本篇文章主要介绍了 reactive 的工具函数集,包含只读函数、浅层响应函数、跳过代理、类型判断等,对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

❌
❌