普通视图

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

从 Recoil 的兴衰看前端状态管理的技术选型

作者 evle
2026年2月24日 16:13

从 Recoil 的兴衰看前端状态管理的技术选型

2023 年底,Meta 官方宣布 Recoil 进入“维护模式”,从它的兴衰历程中,我们看到了什么?

Recoil 的发展历程

2020 年:Recoil 横空出世

2020 年的一个下午,我正在 Twitter 上刷着动态,突然刷到一条让我想打开电脑蠢蠢欲动的动态 "Facebook 发布了 Recoil,一个实验性的状态管理库",状态管理在 React 生态里面一直是个工程负担,无论是团队角度从复杂工程的状态管理意识培养还是从开发者体验(DX)角度来看,React 状态管理一直像在等一个 “救世主”。

"Recoil 是一个实验性的状态管理库,为 React 提供了更好的状态管理体验。"

上面这是 Recoil 的宣传语,如果是2020年在写前端的同学肯定很熟悉当时的背景

当时的背景

  • Redux 虽然强大,但样板代码太多
  • Context API 性能问题明显,所有消费者都会重渲染
  • 社区渴望一个更简单、更现代的状态管理方案

Recoil 的核心优势

  1. 解决了 Context 的性能问题
    我只是想切换主题,为什么用户信息组件也要重渲染?应用越来越慢,每次状态变化都要重渲染几十个组件让我不得不拆分出非常多的 Context,有没有一种更简单的方式只订阅需要的状态。

  2. 比 Redux 更简单,减少了样板代码
    我只是想写一个计数器,为什么要写三个文件?action、reducer、dispatch...这些概念为什么这么抽象?这将层层传递(漏传) Props 的痛苦转变为一种新的痛苦。 有没有简单的状态管理方案?

  3. 原子化的状态管理理念
    工作台应用的复杂度完全取决于状态管理的复杂度,实际的业务逻辑并没有什么复杂的,反而我们在“技术”上花费大量时间建立开发者信心,这种 ROI 是经不起推敲的。 比如更新一个用户信息,我们 dispatch 一个 UPDATE_USER 类型的参数, 这时候心智负担是 A组件会重新渲染吗? B组件不应该渲染,但是渲染了。 C组件我也不知道是否会重新渲染。

  4. 官方背书
    既然是官方出的,那开发者生态自然会觉得这个方案非常可靠, 并且 TypeScript 支持优秀,我们下意识里面觉得是时候改变我们项目了。

2021-2022 年:快速成长期

Recoil 被广泛采用,成为 React 状态管理的热门选择之一。当时的成功案例和教程大量的涌现在互联网上 “Meta 内部项目开始使用”,"某知名开源项目也集成了 Recoil",生态的发展可是如火如荼,出现了 Recoil DevTools,与 React Router、React Query 等库集成良好。

在当时我们团队内部,我也开始推广 AtomSelector API 的使用,并对负责的项目进行状态管理改造。当时我比喻 Atom 就是一个保险柜:

  • 任何需要用钱的人都可以打开它
  • 任何人都可以往里面放钱或取钱
  • 保险箱里的钱变了,所有知道这个保险箱的人都会收到通知

假设我存了 100块到保险柜里

import { atom } from 'recoil';

// 创建一个 Atom,就像创建一个保险箱
const moneyState = atom({
  key: 'moneyState',      // 保险箱的名字(必须唯一)
  default: 100,           // 保险箱里的初始钱数
});

那么其他组件想交互我这个保险柜的状态像喝水一样简单

import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';

// 在组件中使用
function Wallet() {
  // 1. 读取和写入(既能看钱,也能存钱取钱)
  const [money, setMoney] = useRecoilState(moneyState);
  
  // 2. 只读取(只能看钱,不能改)
  const money = useRecoilValue(moneyState);
  
  // 3. 只写入(只能改钱,不能看)
  const setMoney = useSetRecoilState(moneyState);
  
  return (
    <div>
      <p>我的钱:{money}元</p>
      <button onClick={() => setMoney(money + 10)}>存10元</button>
      <button onClick={() => setMoney(money - 10)}>取10元</button>
    </div>
  );
}

当然我们也可以使用与保险柜配套的智能计算器 selector 来与保险柜交互,他会自动管理依赖关系,举个例子,今天大A跌了,我们取出来的钱要 x0.8, 我们如果使用普通函数实现的话,组件渲染每次都会新建这个函数,每次都会重新计算,这会损耗性能。如果使用原生useCallback API 的话引入了手动管理依赖的心智负担。

import { selector } from 'recoil';

const doubleMoneyState = selector({
  key: 'newMoneyState',  // 计算器的名字
  get: ({ get }) => {          // 计算逻辑
    const money = get(moneyState);  // 从保险箱里读取钱数
    return money * 0.8;               // 跌了!
  },
});

那重复计算场景,根据多个状态计算场景,过滤或者排序状态场景都可以轻松通过 selector 解决了。但在真实的实际使用过程中,发现 Recoil 的学习曲线并不是那么平滑,虽然 atom 和 selector 可以让新人2天内上手,但是 atomFamily、selectorFamily 的使用负担,selector越写越复杂导致的性能问题,以及没有彻底改造异步状态为 waitForAll,而是通过 Promise.all 的 JS API 来组合管理异步状态, 都让我感觉这次改造不如“预期” 。

Recoil 一直标记为 “试验性” ,刚开始我可能只是觉得 新项目嘛,API 可能随时变更,高速迭代,一直朝着最好的方向发展,但是渐渐的发现。企业级应用很难陪跑试验性的项目,没有人愿意为技术的升级买单。

2023 年:宣布进入维护模式

Recoil 团队宣布不再积极开发新功能,进入维护模式。

官方声明

"Recoil 已经进入维护模式,我们将继续修复关键 bug,但不会开发新功能。我们建议用户考虑其他状态管理方案。"

从官方公开资料与社区的状态来看,我觉得 Recoil 的衰落有3个原因:

  1. 资源有限

    • Meta 团队资源有限,无法同时维护多个状态管理方案
    • 优先级调整,资源分配到其他更重要项目(AI时代的趋势)
    • 维护一个"实验性"库的成本效益比不高
  2. 竞争激烈

    • Zustand、Jotai(Recoil原作者) 等新方案更轻量、更简单
    • 这些方案提供了类似的功能,但学习曲线更平缓
    • 社区开始转向更活跃的方案
  3. 需求变化

    • 前端技术栈快速演进,Recoil 的设计可能不再是最优解
    • 新的范式(如 Signals)开始兴起
    • 社区对状态管理的需求发生了变化

我从开始的震惊,转变为理解,也在社区开始讨论迁移方案,也在翻阅大量迁移指南和对比实践。

这次技术选型的经验

不要盲目追求"官方"方案

其实很多项目选择 Recoil 的一个重要原因是:它是 Meta 官方方案

  • "Recoil 是 Facebook 官方的,肯定可靠"
  • "Meta 的技术团队很厉害,他们的方案一定最好"
  • "官方方案 = 最佳方案"

但是回过头我们才发现

  • 官方 ≠ 最适合我们的项目
  • 官方方案也可能被放弃
  • 官方方案的学习曲线可能更陡峭
  • 官方方案的更新频率可能不如社区方案

Recoil 在后期面临的一个重要问题是:社区反馈响应不够及时。 提交了 Issue,但几周都没有回复,新功能的 Roadmap 始终没有看到,可持续性不够。

虽然切换到 Recoil 产生的技术收益不如预期,并且完全掉到另一个坑里(停止维护),但是团队通过这次迁移,强化和实践了 Recoil 可复制的理念。 比如每个状态都有自己的 R&R, 避免不必要的渲染。即使在后面迁移到 Zustand 我们的状态关系依然没有大的变化,状态之间的依赖关系清晰,并且可以独立测试每个状态。以及将一些老代码的 Context 也做了原子化拆分。

状态管理的技术趋势

3.1 简约主义:少即是多

当前最明显的趋势是追求简约。Zustand 的下载量超过了 Recoil 和 Jotai 的总和,大小却只有几KB,这正是与社区开发者共情 “这么简单的东西,我要写那么复杂吗?”,2018 年 统治者地位的 Redux 写个计数器demo 4个文件几十行代码,现如今。Zustand 3行代码,1个文件,开发1天内就能上手 API,更少的代码意味着更少的出错机会,团队反馈在 PeerReveiw 时的信心也增加了许多。Zustand 它让 80% 的场景变得简单,同时让 20% 的复杂场景仍然可行。

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

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

3.2 按需选择

社区不再追求"大一统"解决方案,就像工具箱里有锤子、螺丝刀、扳手,我们不会用锤子去拧螺丝。而是根据场景选择:

场景 推荐方案 理由
小型应用 React Context + Hooks 无需额外依赖
中型应用 Zustand 简单高效
大型企业应用 Redux Toolkit 生态成熟,工具完善
复杂状态依赖 Jotai 原子化设计
响应式需求 MobX / Valtio 自动追踪依赖

3.3 渐进式平滑迁移

在实际开发中,我听过很多“XXX技术好,我们要拥抱新技术!”,除去迁移风险,开发交付的核心是业务价值,重构过去稳定的模块在业务上也许完全没有收益, 就算以前写的很垃圾,经过这么多涂涂改改它也很稳定。 那我们的技术洁癖应该是渐进式迁移, 我们引入新方案与旧方案并存,新的业务享受着新技术的便利性,老的方案享受着无变更的稳定性。

那渐进式迁移的前提是什么? 迁移复杂度评估。目前主流的前端状态管理库似乎都意识到了这些工程难题:

  • 概念差异越大,迁移成本越高。
  • 代码结构差异越大,重构工作量越大。
  • 依赖的中间件、工具越多,迁移越复杂。

从而像充电器一样在做类似的“标准”。我们从 Recoil 迁移到 Zustand 的复杂度很低,因为两者的概念类似。

后话

技术会过时,但理念是:永恒的。

Vue 3 从基础到组合式 API 全解析

作者 赵_叶紫
2026年2月24日 14:40

目录


1.1 基础概念

MVVM 模式

MVVM 由三部分组成:Model(模型)、View(视图)、ViewModel(视图模型)。

graph LR
    subgraph View_Layer["🖥️ View 层"]
        direction TB
        V1["📄 HTML 模板 + CSS 样式"]
        V3["👆 用户交互事件<br/>click / input / submit"]
    end

    subgraph ViewModel_Layer["⚙️ ViewModel 层"]
        direction TB
        VM1["📦 响应式数据<br/>data / ref / reactive"]
        VM2["🔄 计算属性<br/>computed"]
        VM3["👁️ 侦听器<br/>watch"]
        VM4["🔗 生命周期钩子<br/>mounted / updated"]
        VM5["🛠️ 方法<br/>methods"]
    end

    subgraph Model_Layer["🗄️ Model 层"]
        direction TB
        M1["🌐 API 请求<br/>axios / fetch"]
        M3["💡 业务逻辑<br/>数据处理 / 校验 / 数据模型"]
        M4["🏪 状态管理<br/>Vuex / Pinia"]
    end

    %% View → ViewModel
    V3 -- "① 用户操作触发" --> VM5
    V1 -- "② v-model 双向绑定" --> VM1

    %% ViewModel 内部依赖
    VM1 -- "③-a 依赖数据变化<br/>触发重新计算" --> VM2
    VM1 -- "③-b 依赖数据变化<br/>触发侦听器" --> VM3
    VM4 -- "③-c 生命周期触发<br/>初始化加载等" --> VM5

    %% ViewModel → View
    VM1 -- "④ 数据驱动视图更新" --> V1
    VM2 -- "⑤ 计算结果渲染到模板" --> V1

    %% ViewModel → Model
    VM5 -- "⑥ 调用 API" --> M1
    VM3 -- "⑦ 监听变化触发业务逻辑" --> M3

    %% Model → ViewModel
    M1 -- "⑧ 返回数据 → 写入响应式变量" --> VM1
    M4 -- "⑨ 状态变更通知 → 写入响应式变量" --> VM1

    %% 自动触发渲染
    M1 -. "⑧→④ 自动触发视图渲染 🔄" .-> V1
    M4 -. "⑨→④ 自动触发视图渲染 🔄" .-> V1

    style View_Layer fill:#E3F2FD,stroke:#1565C0,stroke-width:3px
    style ViewModel_Layer fill:#FFF3E0,stroke:#E65100,stroke-width:3px
    style Model_Layer fill:#E8F5E9,stroke:#2E7D32,stroke-width:3px

    style V1 fill:#BBDEFB,stroke:#1565C0,stroke-width:2px
    style V3 fill:#BBDEFB,stroke:#1565C0,stroke-width:2px
    style VM1 fill:#FFE0B2,stroke:#E65100,stroke-width:2px
    style VM2 fill:#FFE0B2,stroke:#E65100,stroke-width:2px
    style VM3 fill:#FFE0B2,stroke:#E65100,stroke-width:2px
    style VM4 fill:#FFE0B2,stroke:#E65100,stroke-width:2px
    style VM5 fill:#FFE0B2,stroke:#E65100,stroke-width:2px
    style M1 fill:#C8E6C9,stroke:#2E7D32,stroke-width:2px
    style M3 fill:#C8E6C9,stroke:#2E7D32,stroke-width:2px
    style M4 fill:#C8E6C9,stroke:#2E7D32,stroke-width:2px

    linkStyle 0 stroke:#D32F2F,stroke-width:2.5px
    linkStyle 1 stroke:#D32F2F,stroke-width:2.5px
    linkStyle 2 stroke:#FF6F00,stroke-width:2.5px
    linkStyle 3 stroke:#FF6F00,stroke-width:2.5px
    linkStyle 4 stroke:#FF6F00,stroke-width:2.5px
    linkStyle 5 stroke:#1565C0,stroke-width:2.5px
    linkStyle 6 stroke:#1565C0,stroke-width:2.5px
    linkStyle 7 stroke:#FF6F00,stroke-width:2.5px
    linkStyle 8 stroke:#FF6F00,stroke-width:2.5px
    linkStyle 9 stroke:#2E7D32,stroke-width:2.5px
    linkStyle 10 stroke:#2E7D32,stroke-width:2.5px
    linkStyle 11 stroke:#9C27B0,stroke-width:2px,stroke-dasharray:5
    linkStyle 12 stroke:#9C27B0,stroke-width:2px,stroke-dasharray:5

三层职责与 Vue 中的映射:

缩写 全称 职责 Vue 中的对应
M Model(模型) 应用数据与业务逻辑。不关心数据如何展示,只负责存储和处理 reactive()/ref() 声明的响应式状态、API 请求返回的数据、Pinia store
V View(视图) 用户看到的界面。不包含业务逻辑,只负责声明式地描述 UI 结构 <template> 中的 HTML 模板、最终渲染出的真实 DOM
VM ViewModel(视图模型) M 与 V 之间的桥梁。监听 Model 变化自动更新 View,捕获 View 上的用户交互反向更新 Model Vue 组件实例本身——编译器将模板转为渲染函数,响应式系统追踪依赖并触发更新

流程总结:

整个 MVVM 的运转可以概括为一个闭环:

  1. 用户交互驱动(V → VM): 用户在 View 层触发事件(click、input 等),通过 v-model 或事件绑定将操作传递给 ViewModel 层的方法或响应式变量。
  2. ViewModel 内部联动(VM 内部): 响应式数据变化后,computed 自动重新计算派生值,watch 触发副作用逻辑,生命周期钩子在适当时机执行。
  3. 业务处理(VM → M): ViewModel 调用 Model 层的 API 请求或业务逻辑函数,完成数据的增删改查。
  4. 数据回写(M → VM): Model 层返回结果写入响应式变量,或 Pinia/Vuex 状态变更通知 ViewModel。
  5. 视图自动更新(VM → V): 响应式系统检测到数据变化,自动触发虚拟 DOM diff,最小化更新真实 DOM,用户看到最新界面。

核心价值: 开发者只需关注数据(M)模板(V),中间的同步、diff、DOM 操作全部由 Vue 的 ViewModel 层自动完成。v-model:modelValue + @update:modelValue 的编译时语法糖,本质仍由 VM 层协调,Vue 3 整体是单向数据流 + 双向绑定语法糖的设计。


1.2 项目创建(Vite)

基于原生 ES Module,毫秒级冷启动,HMR 不随项目规模变慢。

# 推荐使用 create-vue(Vue 官方脚手架,底层基于 Vite)
npm create vue@latest

典型项目结构:

my-project/
├── public/                  # 静态资源(不经过构建)
├── src/
│   ├── assets/              # 需构建处理的资源(图片、样式)
│   ├── components/          # 通用组件
│   ├── composables/         # 组合式函数
│   ├── router/              # 路由配置
│   ├── stores/              # Pinia 状态管理
│   ├── views/               # 页面级组件
│   ├── App.vue
│   └── main.ts
├── index.html               # 入口 HTML(Vite 以此为入口)
├── vite.config.ts
├── tsconfig.json
└── package.json

1.3 模板语法

插值与绑定

<template>
  <!-- 文本插值 -->
  <span>{{ message }}</span>

  <!-- 属性绑定(v-bind 简写 :) -->
  <img :src="imgUrl" :alt="title" />

  <!-- 动态绑定多个属性 -->
  <div v-bind="attrs"></div>
  <!-- 等价于 <div :id="attrs.id" :class="attrs.class" /> -->

  <!-- 事件绑定(v-on 简写 @) -->
  <button @click="submit">提交</button>
  <input @keyup.enter="search" />         <!-- 按键修饰符 -->
  <form @submit.prevent="save" />          <!-- 阻止默认行为 -->
</template>

条件渲染

<template>
  <!-- v-if:条件为 false 时 DOM 不存在(适合不频繁切换) -->
  <div v-if="status === 'loading'">加载中</div>
  <div v-else-if="status === 'error'">出错了</div>
  <div v-else>{{ data }}</div>

  <!-- v-show:始终渲染 DOM,通过 display 切换(适合频繁切换) -->
  <div v-show="visible">我一直在 DOM 中</div>
</template>
指令 DOM 行为 初始开销 切换开销 适用场景
v-if 销毁/重建 低(不渲染) 条件很少变化
v-show display: none 高(始终渲染) 频繁切换显示

列表渲染

<template>
  <!-- 数组遍历 -->
  <li v-for="(item, index) in list" :key="item.id">
    {{ index }}. {{ item.name }}
  </li>

  <!-- 对象遍历 -->
  <div v-for="(value, key) in obj" :key="key">
    {{ key }}: {{ value }}
  </div>

  <!-- v-for + v-if 不能同级使用,需用 <template> 包裹 -->
  <template v-for="item in list" :key="item.id">
    <li v-if="item.active">{{ item.name }}</li>
  </template>
</template>

key 的作用: 帮助 Vue 的 diff 算法识别节点身份,复用和重排已有元素而非重新创建。务必使用唯一业务 ID,避免用 index(排序/删除时会导致错误复用)。


2. 组件开发

组件是 Vue 的核心抽象单元——将 UI 拆分为独立、可复用的模块,每个组件封装自己的模板、逻辑和样式,通过明确的接口(props/emits)进行通信。

2.1 组件基础

组件定义与注册

Vue 3 推荐使用单文件组件(SFC) + <script setup> 语法,编译器自动处理注册,无需手动声明。

<!-- MyButton.vue — 单文件组件 -->
<template>
  <button :class="type" @click="emit('click', $event)">
    <slot />
  </button>
</template>

<script setup lang="ts">
defineProps<{ type?: 'primary' | 'default' }>()
const emit = defineEmits<{ click: [e: MouseEvent] }>()
</script>

<style scoped>
.primary { background: #409eff; color: #fff; }
</style>

使用方式:<script setup> 中导入即可直接在模板使用,无需注册。

<template>
  <MyButton type="primary" @click="save">保存</MyButton>
</template>

<script setup lang="ts">
import MyButton from './MyButton.vue'
</script>

SFC 的价值: 一个 .vue 文件 = 模板 + 逻辑 + 样式,scoped 实现样式隔离,<script setup> 减少样板代码,编译器自动优化。


2.2 组件通信

Vue 组件间通信方式按场景选择,核心原则:props 向下,events 向上,跨层用 provide/inject

Props(父 → 子)

父组件通过属性向子组件传递数据,子组件只读不可修改。

<!-- Child.vue -->
<template>
  <h2>{{ title }} ({{ count }})</h2>
</template>

<script setup lang="ts">
const props = withDefaults(defineProps<{
  title: string
  count?: number
}>(), {
  count: 0   // 类型声明的 props 通过 withDefaults 设置默认值
})
</script>
<!-- Parent.vue -->
<Child title="订单" :count="orderCount" />

Emits(子 → 父)

子组件通过事件通知父组件,保持单向数据流。

<!-- Child.vue -->
<template>
  <button @click="remove(item.id)">删除</button>
</template>

<script setup lang="ts">
const emit = defineEmits<{
  update: [value: string]
  delete: [id: number]
}>()

function remove(id: number) {
  emit('delete', id)   // 触发事件,父组件通过 @delete 监听
}
</script>
<!-- Parent.vue -->
<Child @update="handleUpdate" @delete="handleDelete" />

v-model(双向绑定语法糖)

v-model 本质是 :modelValue + @update:modelValue 的简写,支持多个 v-model。

<!-- SearchInput.vue -->
<template>
  <input v-model="keyword" />
  <select v-model="status">
    <option value="all">全部</option>
    <option value="active">启用</option>
  </select>
</template>

<script setup lang="ts">
const keyword = defineModel<string>()           // 默认 v-model
const status = defineModel<string>('status')    // v-model:status
</script>
<!-- Parent.vue — 语法糖写法 -->
<SearchInput v-model="keyword" v-model:status="currentStatus" />

上面的 v-model:status 等价于展开写法:

<!-- Parent.vue — 展开写法(与上方完全等价) -->
<SearchInput
  v-model="keyword"
  :status="currentStatus"
  @update:status="currentStatus = $event"
/>

v-model:status 编译后就是 :status + @update:statusdefineModel('status') 内部帮你处理了 props 接收和 emit 触发。

Provide / Inject(跨层级)

祖先组件提供数据,任意后代组件注入,避免 props 逐层透传。

<!-- 祖先组件 -->
<script setup lang="ts">
import { provide, ref } from 'vue'

const theme = ref<'light' | 'dark'>('light')
provide('theme', theme)       // key-value 形式提供
</script>
<!-- 任意深度的后代组件 -->
<template>
  <div :class="theme">当前主题:{{ theme }}</div>
</template>

<script setup lang="ts">
import { inject } from 'vue'
import type { Ref } from 'vue'

const theme = inject<Ref<'light' | 'dark'>>('theme')  // 注入
</script>

使用 InjectionKey 实现类型安全(推荐):

字符串 key 容易拼错且无类型推导,推荐抽取 InjectionKey 常量:

// keys.ts — 统一管理 injection key
import type { InjectionKey, Ref } from 'vue'

export const ThemeKey: InjectionKey<Ref<'light' | 'dark'>> = Symbol('theme')
// 祖先组件中 provide
import { ThemeKey } from './keys'
provide(ThemeKey, theme)      // TS 自动校验 value 类型

// 后代组件中 inject
import { ThemeKey } from './keys'
const theme = inject(ThemeKey) // 自动推导为 Ref<'light' | 'dark'> | undefined

适用场景: 主题、国际化、全局配置等需要跨多层传递但不适合放 Pinia 的数据。

Ref(父访问子实例)

通过模板 ref 获取子组件实例,直接调用子组件暴露的方法或属性。

defineExpose 的作用:<script setup> 中,组件内部的变量和方法默认对外不可见(与 Options API 不同)。必须通过 defineExpose 显式声明哪些内容允许父组件通过 ref 访问。未暴露的内容,父组件拿到 ref 后也无法调用。

<!-- Child.vue -->
<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)
const secret = ref('隐藏数据')     // 未暴露,父组件无法访问
function reset() { count.value = 0 }

// 只有 count 和 reset 对外可见,secret 外部不可访问
defineExpose({ count, reset })
</script>
<!-- Parent.vue -->
<template>
  <Child ref="childRef" />
  <button @click="resetChild">重置子组件</button>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import Child from './Child.vue'

const childRef = ref<InstanceType<typeof Child>>()

function resetChild() {
  childRef.value?.reset()   // 调用子组件通过 defineExpose 暴露的 reset 方法
}
</script>

注意: 模板 ref 访问破坏了组件封装性,仅在表单校验、弹窗控制等必要场景使用。


2.3 插槽

插槽让父组件向子组件内部注入模板片段,实现布局和内容的解耦。

默认插槽

子组件用 <slot /> 占位,父组件传入内容替换。

<!-- Card.vue -->
<template>
  <div class="card">
    <slot />   <!-- 父组件传入的内容渲染在这里 -->
  </div>
</template>
<Card>
  <p>这段内容会替换 slot 占位</p>
</Card>

具名插槽

多个插槽通过 name 区分,父组件用 v-slot:name(简写 #name)指定。

<!-- Layout.vue -->
<template>
  <header><slot name="header" /></header>
  <main><slot /></main>
  <footer><slot name="footer" /></footer>
</template>
<Layout>
  <template #header>
    <h1>页面标题</h1>
  </template>

  <p>默认插槽内容(main 区域)</p>

  <template #footer>
    <span>© 2026</span>
  </template>
</Layout>

作用域插槽

子组件通过 slot 向父组件回传数据,父组件拿到数据后自定义渲染。

<!-- DataList.vue -->
<script setup lang="ts">
defineProps<{ items: { id: number; name: string }[] }>()
</script>

<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      <slot :item="item" :index="item.id" />  <!-- 回传数据 -->
    </li>
  </ul>
</template>
<!-- 父组件自定义每一行的渲染方式 -->
<DataList :items="list">
  <template #default="{ item, index }">
    <span>{{ index }}. {{ item.name }}</span>
    <button @click="remove(item.id)">删除</button>
  </template>
</DataList>

作用域插槽的价值: 子组件负责数据遍历和逻辑,父组件负责 UI 呈现,实现逻辑与视图的分离。常见于表格列自定义、列表项渲染等场景。


2.4 动态组件

<component :is>

根据变量动态切换渲染的组件,适用于 Tab 切换、多表单步骤等场景。

<template>
  <button v-for="tab in tabs" :key="tab.label" @click="current = tab.comp">
    {{ tab.label }}
  </button>
  <component :is="current" />
</template>

<script setup lang="ts">
import { shallowRef } from 'vue'
import TabA from './TabA.vue'
import TabB from './TabB.vue'
import TabC from './TabC.vue'

const tabs = [
  { label: '基本信息', comp: TabA },
  { label: '详细配置', comp: TabB },
  { label: '操作日志', comp: TabC },
]
const current = shallowRef(tabs[0].comp)  // shallowRef 避免深度代理组件对象
</script>

<keep-alive>

缓存被切走的组件实例,切回时保留状态(表单输入、滚动位置等),避免重新创建和销毁。

<keep-alive :include="['TabA', 'TabB']" :max="5">
  <component :is="current" />
</keep-alive>
属性 说明
include 只缓存匹配的组件(名称或正则)
exclude 排除不缓存的组件
max 最大缓存实例数,超出时销毁最久未使用的(LRU)

keep-alive 缓存的组件可使用两个专属生命周期:

<script setup lang="ts">
import { onActivated, onDeactivated } from 'vue'

onActivated(() => {
  // 组件从缓存中被激活(切回)时触发,可用于刷新数据
})

onDeactivated(() => {
  // 组件被缓存(切走)时触发,可用于清理定时器
})
</script>

典型场景: 后台管理的多 Tab 页面切换——用户在 Tab A 填了一半表单,切到 Tab B 再切回来,数据不丢失。

defineAsyncComponent(异步组件)

将组件的 JS 代码从主包中分离,用到时才加载,减少首屏体积。Vite 构建时会自动将其拆为独立 chunk。

import { defineAsyncComponent } from 'vue'

// 基本用法:传入返回 import() 的工厂函数
const HeavyChart = defineAsyncComponent(() => import('./HeavyChart.vue'))

// 完整配置:加载状态、超时、错误处理
const HeavyChart = defineAsyncComponent({
  loader: () => import('./HeavyChart.vue'),
  loadingComponent: LoadingSpinner,   // 加载中显示的组件
  errorComponent: ErrorBlock,         // 加载失败显示的组件
  delay: 200,                         // 延迟 200ms 后才显示 loading(避免闪烁)
  timeout: 10000,                     // 超过 10s 视为超时,显示 errorComponent
})
<!-- 在模板中像普通组件一样使用,Vue 自动处理懒加载 -->
<template>
  <HeavyChart v-if="showChart" :data="chartData" />
</template>

适用场景: 大型图表、富文本编辑器、PDF 预览等体积较大且非首屏必需的组件。与路由懒加载(() => import('./views/xxx.vue'))原理相同,区别在于异步组件是组件级别的按需加载。


2.5 Teleport

将组件模板的一部分渲染到DOM 树的其他位置(如 body),解决弹窗/浮层被父组件 overflow: hiddenz-index 影响的问题。

<template>
  <button @click="visible = true">打开弹窗</button>

  <!-- 内容渲染到 body 下,而非当前组件 DOM 内 -->
  <Teleport to="body">
    <div v-if="visible" class="modal-overlay">
      <div class="modal">
        <p>弹窗内容</p>
        <button @click="visible = false">关闭</button>
      </div>
    </div>
  </Teleport>
</template>

<script setup lang="ts">
import { ref } from 'vue'
const visible = ref(false)
</script>
属性 说明
to CSS 选择器或 DOM 元素,指定渲染目标(如 "body""#modal-root"
disabled true 时禁用传送,内容回到组件原位

逻辑上仍属于当前组件(props/emits/provide 照常工作),只是 DOM 位置变了。


2.6 自定义指令

封装对 DOM 的底层操作为可复用指令,命名 v-xxx

// directives/vFocus.ts
import type { Directive } from 'vue'

export const vFocus: Directive = {
  mounted(el: HTMLElement) {
    el.focus()   // 元素挂载后自动聚焦
  }
}
<template>
  <input v-focus />
</template>

<script setup lang="ts">
import { vFocus } from '@/directives/vFocus'
</script>

指令生命周期钩子:

钩子 触发时机
created 元素属性/事件绑定前
beforeMount 插入 DOM 前
mounted 插入 DOM 后 ✅ 最常用
beforeUpdate 组件更新前
updated 组件更新后
beforeUnmount 卸载前
unmounted 卸载后

带参数的实际示例(权限指令):

// directives/vPermission.ts
import type { Directive } from 'vue'

export const vPermission: Directive<HTMLElement, string> = {
  mounted(el, binding) {
    // binding.value 就是 v-permission="'admin'" 中的 'admin'
    const userRole = getUserRole()
    if (userRole !== binding.value) {
      el.parentNode?.removeChild(el)  // 无权限则移除元素
    }
  }
}
<button v-permission="'admin'">仅管理员可见</button>

3. Composition API

Composition API 是 Vue 3 的核心编程范式,以函数为基本组织单位,替代 Options API 的 data/methods/computed/watch 分散写法,使相关逻辑聚合在一起,便于复用和维护。

3.1 响应式 API

ref

包装任意类型为响应式数据。JS/TS 中通过 .value 读写,模板中自动解包。包装对象时内部调用 reactive 实现深层响应

import { ref } from 'vue'

const count = ref(0)                    // 基本类型
const user = ref<User | null>(null)     // 对象类型,支持泛型

count.value++                           // JS 中需要 .value

// 嵌套对象同样响应式(内部自动 reactive)
const config = ref({ theme: { color: 'blue' } })
config.value.theme.color = 'red'        // ✅ 视图更新
config.value = { theme: { color: 'green' } }  // ✅ 整体替换也响应式

// 数组同样深层响应式
const list = ref([{ id: 1, name: '张三' }, { id: 2, name: '李四' }])
list.value.push({ id: 3, name: '王五' })       // ✅ 新增元素,视图更新
list.value[0].name = '赵六'                     // ✅ 修改元素属性,视图更新
list.value = list.value.filter(i => i.id !== 2) // ✅ 整体替换,视图更新

自动解包规则 & 注意事项:

场景 需要 .value 说明
模板 {{ count }} 自动解包
JS/TS 代码 count.value++
嵌入 reactive 对象 reactive({ count }).count++
放入数组 / Map reactive([ref(1)])[0].value
解构 .value 丢失响应性,需 toRefs() 转换

reactive

将对象转为深层响应式代理,访问属性无需 .value不能用于基本类型,且不能整体替换(会丢失响应性)。

import { reactive } from 'vue'

const form = reactive({
  name: '',
  age: 0,
  address: { city: '', zip: '' }  // 嵌套对象也是响应式
})

form.name = '张三'             // 直接赋值,无需 .value
form.address.city = '深圳'     // 深层属性也是响应式

ref vs reactive 选择:

场景 推荐 原因
基本类型(string / number / boolean) ref reactive 不支持基本类型
可能被整体替换的对象 ref reactive 重新赋值会丢失响应性
表单等字段固定的复杂对象 reactive 无需 .value,代码更简洁
composable 函数返回值 ref 解构时不丢失响应性

computed

基于响应式依赖自动缓存的派生值,依赖不变则不重新计算。

import { ref, computed } from 'vue'

const price = ref(100)
const quantity = ref(3)

// 只读计算属性
const total = computed(() => price.value * quantity.value)

// 可写计算属性(少用)
const fullName = computed({
  get: () => `${firstName.value} ${lastName.value}`,
  set: (val: string) => {
    const [first, last] = val.split(' ')
    firstName.value = first
    lastName.value = last ?? ''
  }
})

与方法的区别: computed 有缓存,多次访问只在依赖变化时重新计算;方法每次调用都重新执行。

watch

监听特定响应式数据,变化时执行回调。适合需要旧值对比有条件执行的场景。

import { ref, watch } from 'vue'

const keyword = ref('')

// 监听单个 ref
watch(keyword, (newVal, oldVal) => {
  console.log(`搜索词从 "${oldVal}" 变为 "${newVal}"`)
})

// 监听多个源
watch([keyword, page], ([newKeyword, newPage], [oldKeyword, oldPage]) => {
  fetchList(newKeyword, newPage)
})

// 监听 reactive 对象的某个属性(需用 getter 函数)
const form = reactive({ name: '', age: 0 })
watch(
  () => form.name,
  (newName) => { validate(newName) }
)

// 常用选项
watch(keyword, handler, {
  immediate: true,  // 创建时立即执行一次
  deep: true,       // 深层监听(reactive 对象默认深层,ref 对象需手动开启)
  flush: 'post',    // 在 DOM 更新后执行回调(默认 'pre')
})

watchEffect

自动追踪回调中用到的所有响应式依赖,不需要指定监听源。适合"用了什么就监听什么"的场景。

import { ref, watchEffect } from 'vue'

const keyword = ref('')
const page = ref(1)

// 回调中访问了 keyword 和 page,两者变化都会重新执行
const stop = watchEffect(() => {
  fetchList(keyword.value, page.value)
})

stop()  // 手动停止监听(组件卸载时自动停止)

watch vs watchEffect 对比:

维度 watch watchEffect
监听源 需显式指定 自动追踪回调中的依赖
旧值访问 (newVal, oldVal) ❌ 无旧值
首次执行 默认不执行(immediate: true 开启) 默认立即执行
适用场景 需要旧值对比、条件触发 依赖多且不需要旧值

nextTick

Vue 的 DOM 更新是异步批量的,修改数据后 DOM 不会立即更新。nextTick 等待 DOM 更新完成后执行回调。

import { ref, nextTick } from 'vue'

const show = ref(false)

async function expand() {
  show.value = true
  // 此时 DOM 尚未更新,拿不到新元素
  await nextTick()
  // DOM 已更新,可安全操作
  document.querySelector('.detail')?.scrollIntoView()
}

响应式工具函数

函数 作用 典型场景
toRef(obj, key) 将 reactive 对象的单个属性转为 ref 传递单个属性给 composable
toRefs(obj) 将 reactive 对象的所有属性转为 ref 解构 reactive 不丢失响应性
toRaw(proxy) 返回代理的原始对象 传给第三方库(避免代理副作用)
shallowRef(val) 只有 .value 替换时触发更新,深层属性变化不触发 大型对象 / 组件引用
shallowReactive(obj) 只有顶层属性变化触发更新 扁平配置对象
markRaw(obj) 标记对象永不被代理 第三方类实例(echarts、地图等)
import { reactive, toRefs, toRaw, shallowRef, markRaw } from 'vue'

// toRefs:解构不丢失响应性
const state = reactive({ name: '张三', age: 20 })
const { name, age } = toRefs(state)   // name、age 都是 Ref
name.value = '李四'                    // state.name 同步变化

// shallowRef:大型对象只在整体替换时触发更新
const tableData = shallowRef<Row[]>([])
tableData.value[0].name = '新名字'     // ❌ 不触发更新
tableData.value = [...tableData.value] // ✅ 整体替换才触发

// markRaw:排除不需要响应式的对象
const chart = markRaw(echarts.init(el))

3.2 依赖注入(provide / inject)

已在 2.2 组件通信 — Provide / Inject 中详细介绍,包含基本用法和 InjectionKey 类型安全写法。

核心要点回顾:

  • provide(key, value) 在祖先组件提供数据
  • inject(key) 在任意后代组件注入
  • 推荐使用 InjectionKey<T> 常量管理 key,实现自动类型推导
  • 适用于主题、国际化等跨层级共享数据的场景

3.3 生命周期钩子

Composition API 中通过 onXxx 函数注册生命周期回调,对应组件从创建到销毁的各个阶段。

graph TD
    A["setup()"] --> B["onBeforeMount"]
    B --> C["onMounted<br/>DOM 已挂载,可访问 DOM / 发请求"]
    C --> D["onBeforeUpdate"]
    D --> E["onUpdated<br/>DOM 已更新"]
    E --> D
    C --> F["onBeforeUnmount"]
    F --> G["onUnmounted<br/>组件已销毁,清理副作用"]
import {
  onBeforeMount,
  onMounted,
  onBeforeUpdate,
  onUpdated,
  onBeforeUnmount,
  onUnmounted
} from 'vue'

// setup 本身等价于 beforeCreate + created,无需对应钩子

onMounted(() => {
  // DOM 已渲染,适合:获取 DOM 引用、发起初始请求、初始化第三方库
  initChart()
  fetchData()
})

onUpdated(() => {
  // 响应式数据变化导致 DOM 更新后触发
  // 注意:避免在此修改响应式数据,可能导致无限循环
})

onBeforeUnmount(() => {
  // 组件即将销毁,适合:清除定时器、取消订阅、销毁第三方库实例
  clearInterval(timer)
  chart?.dispose()
})

Options API 与 Composition API 钩子映射:

Options API Composition API 说明
beforeCreate setup() 本身 setup 在所有 Options API 钩子之前执行
created setup() 本身 响应式数据已就绪,但 DOM 未挂载
beforeMount onBeforeMount DOM 挂载前
mounted onMounted DOM 已挂载 ✅
beforeUpdate onBeforeUpdate 数据变化,DOM 更新前
updated onUpdated DOM 已更新
beforeUnmount onBeforeUnmount 组件销毁前
unmounted onUnmounted 组件已销毁

常用原则: 初始化请求放 onMounted(而非 setup),清理工作放 onBeforeUnmount。同一钩子可多次调用,按注册顺序执行。


3.4 组合式函数(Composables)

相关联的响应式状态 + 逻辑提取为独立函数,实现跨组件复用。命名约定以 use 开头。

// composables/useFetch.ts
import { ref, type Ref } from 'vue'

interface UseFetchReturn<T> {
  data: Ref<T | null>
  error: Ref<string>
  isFetching: Ref<boolean>
  execute: () => Promise<void>
}

export function useFetch<T>(url: string | Ref<string>): UseFetchReturn<T> {
  const data = ref<T | null>(null) as Ref<T | null>
  const error = ref('')
  const isFetching = ref(false)

  async function execute() {
    isFetching.value = true
    error.value = ''
    try {
      const resolvedUrl = typeof url === 'string' ? url : url.value
      const res = await fetch(resolvedUrl)
      data.value = await res.json()
    } catch (e: any) {
      error.value = e.message
    } finally {
      isFetching.value = false
    }
  }

  execute()   // 创建时自动执行一次

  return { data, error, isFetching, execute }
}
<!-- 在组件中使用 -->
<template>
  <div v-if="isFetching">加载中...</div>
  <div v-else-if="error">{{ error }}</div>
  <ul v-else>
    <li v-for="item in data" :key="item.id">{{ item.name }}</li>
  </ul>
</template>

<script setup lang="ts">
import { useFetch } from '@/composables/useFetch'

const { data, error, isFetching } = useFetch<Item[]>('/api/items')
</script>

Composable 设计原则:

原则 说明
单一职责 一个 composable 只解决一类问题(如请求、分页、表单校验)
返回 ref 返回值使用 ref 而非 reactive,调用方解构时不丢失响应性
命名 useXxx 约定以 use 开头,表明这是一个组合式函数
可接收 ref 参数 参数支持 `string Ref`,提高灵活性

与 Mixin 的对比: Options API 时代用 Mixin 复用逻辑,但存在命名冲突、来源不明、隐式依赖等问题。Composable 通过显式导入和返回值,完全解决了这些问题。


3.5 <script setup> 语法糖

<script setup> 是 Composition API 在 SFC 中的编译时语法糖,编译器自动处理导出、注册、类型推导,减少样板代码。

核心编译宏

编译宏无需导入,编译器自动识别:

作用 示例
defineProps 声明 props defineProps<{ title: string }>()
defineEmits 声明 emits defineEmits<{ change: [value: string] }>()
defineExpose 暴露实例属性/方法 defineExpose({ reset })
defineModel 声明 v-model 双向绑定 defineModel<string>('status')
withDefaults 为类型声明的 props 设置默认值 withDefaults(defineProps<P>(), { count: 0 })
defineOptions 声明组件选项(如 name / inheritAttrs) defineOptions({ name: 'MyComp' })

<script setup> vs 普通 <script>

<!-- ❌ 普通 script:需要手动 return、注册组件 -->
<script lang="ts">
import { ref, defineComponent } from 'vue'
import MyButton from './MyButton.vue'

export default defineComponent({
  components: { MyButton },
  setup() {
    const count = ref(0)
    function increment() { count.value++ }
    return { count, increment }  // 必须手动 return
  }
})
</script>

<!-- ✅ script setup:顶层变量/导入组件自动暴露给模板 -->
<script setup lang="ts">
import { ref } from 'vue'
import MyButton from './MyButton.vue'   // 自动注册,模板中直接用

const count = ref(0)
function increment() { count.value++ }
// 无需 return,顶层声明自动可在模板中使用
</script>

defineOptions 与属性透传(inheritAttrs)

默认情况下,父组件传给子组件的未声明为 props 的属性(如 classstyleiddata-*)会自动透传到子组件的根元素上。通过 defineOptions({ inheritAttrs: false }) 可关闭自动透传,改用 useAttrs() 手动控制。

<!-- BaseInput.vue -->
<template>
  <!-- 关闭自动透传后,attrs 不会自动加到根 div 上 -->
  <div class="input-wrapper">
    <!-- 手动绑定到指定元素 -->
    <input v-bind="attrs" />
  </div>
</template>

<script setup lang="ts">
import { useAttrs } from 'vue'

defineOptions({
  name: 'BaseInput',        // 组件名(keep-alive include 匹配用)
  inheritAttrs: false        // 关闭自动透传
})

const attrs = useAttrs()     // 获取所有透传属性
</script>
<!-- 父组件使用 -->
<template>
  <!-- class、placeholder、@focus 都会透传到 BaseInput 内部的 <input> 上 -->
  <BaseInput class="custom" placeholder="请输入" @focus="onFocus" />
</template>
场景 inheritAttrs 效果
单根元素组件(默认) true attrs 自动添加到根元素
需要将 attrs 绑定到非根元素 false + v-bind="attrs" 手动控制透传目标
多根元素组件 Vue 警告,必须手动 v-bind="$attrs" 指定

Speckit、OpenSpec、Superpowers 和 everything-claude-code AI辅助编程工具对比分析

作者 王小酱
2026年2月24日 13:35

1. 概述

随着AI编码能力(如 Claude Code、Cursor 等)的普及,软件开发领域正从“Vibe Coding”(随心灵感编码)向更工程化的方向演进。为了应对AI生成代码的不确定性、上下文丢失以及协作一致性等问题,社区涌现了多种规范驱动开发(Spec-Driven Development, SDD)框架和工作流方法论

选取了目前最具代表性的四个项目进行对比:

  • Speckit:GitHub官方出品的结构化规范驱动工具包

  • OpenSpec:专注于增量变更和棕地项目的轻量级框架

  • Superpowers:强调强制流程和TDD的代理技能框架

  • everything-claude-code:黑客松冠军开源的综合性 Claude Code 方法论

2. 核心属性速览

维度 Speckit OpenSpec Superpowers everything-claude-code
核心理念 规范即代码,通过严格的结构化流程实现工业级控制 增量即真相,通过Delta机制管理现有项目的演进 强制方法论,通过不可跳过的“技能”约束AI行为 CLI+Skills替代MCP,通过编排与并行化榨干模型性能
目标场景 绿地项目 (0→1) ,复杂且需要严格文档的团队协作 棕地项目 (1→n) ,在已有代码库上进行频繁修改和功能迭代 从0到1的需求探索,对代码质量、测试覆盖有极高要求的项目 重度Claude Code用户,追求极致Token效率和复杂任务并行处理的场景
工作流程 五阶段线性流程:Constitution → Specify → Plan → Tasks → Implement 四阶段循环流程:Draft Proposal → Review → Apply → Archive 三阶段严格流程:Brainstorm → Write Plan → Execute Plan (含TDD) 五阶段Agent编排:Research → Plan → Implement → Review → Verify
关键机制 9条不可变架构原则、7层LLM输出约束、ADR决策记录 Specs/与Changes/隔离、Delta增量存储、Fail-Fast冲突检测 强制技能调用、Red-Green-Refactor TDD、子代理审查 多Agent编排、并行化(Git Worktrees)、动态系统提示、记忆钩子

3. 详细维度对比

3.1 核心理念与哲学

  • Speckit:秉持“规范优先”的哲学。它假设需求在编码前可以被完全定义,通过类似于法律的“宪章”来约束AI的每一次产出。它将开发视为一个严谨的、逐层分解的工程过程,试图通过结构的确定性来对抗AI的随机性

  • OpenSpec:秉持“演进优先”的哲学。它承认需求是动态变化的,特别是在维护老项目时。其核心理念是将“当前稳定状态”(Specs)与“提议变更”(Changes)分离,每次只处理增量,最终将验证后的增量合并回主干,实现系统的平滑演进

  • Superpowers:秉持“流程即法律”的哲学。它不信任AI的自由发挥,通过一套不可跳过的“技能”(Skills)来强制AI遵循人类软件工程的最佳实践(如必须先写测试)。它像一名“教官”,强制AI按照TDD、Code Review等严谨流程行动

  • everything-claude-code:秉持“效率与编排”的哲学。它将AI视为一个可编排的智能体集群,通过精细化的工程手段(如用CLI替代MCP省Token、多实例并行)来最大化模型性能,降低成本,实现复杂的、多步骤的研发任务

3.2 目标用户与适用项目

  • Speckit:适合中大型团队,特别是需要严格合规、文档齐全的企业级项目。它明确了产品经理、技术负责人、开发者之间的交接点(如Spec、Plan),适合角色分工明确的团队

  • OpenSpec:适合全栈开发者或小型团队,尤其是在维护复杂老系统的团队。对于经常需要跨多个服务或模块进行小范围修改的场景,它的轻量级和高效增量特性极具吸引力

  • Superpowers:适合任何追求代码质量的开发者,尤其是从0到1启动项目时。它的头脑风暴模式对需求不明确的项目非常友好,强制TDD则确保了代码的健壮性

  • everything-claude-code:适合Claude Code的重度用户和技术极客。适合需要处理复杂、多步骤、多文件任务的场景,或是希望在API调用成本上精打细算的开发者

3.3 工作流与落地实践

  • Speckit:流程线性且严格

    • Constitution:定义开发原则和不可变规则

    • Specify:详细描述需求(What & Why)

    • Plan:基于技术栈制定架构方案

    • Tasks:分解为可执行的任务列表

    • Implement:逐一执行任务

      实践发现,若前期需求或设计有误,后期返工成本极高。在动态变化的企业环境中,这种线性流程常面临“理想很丰满,现实很骨感”的挑战

  • OpenSpec:流程循环且隔离

    • Proposal:在 changes/ 目录下创建变更提案、任务和Spec增量

    • Review:人与AI审查、对齐提案,可利用 openspec validate 进行冲突检测

    • Apply:AI严格根据 tasks.md 和增量Spec实施编码

    • Archive:将验证通过的变更合并(归档)到 specs/ 目录,更新“真相源”

      这种模式确保了主分支的Spec始终反映最新状态,且归档动作实现了知识的持续沉淀

  • Superpowers:流程强制且循环

    • Brainstorm:AI通过多轮问答帮助用户精炼需求,探索方案,输出设计文档

    • Write Plan:将设计拆解为极小的任务(2-5分钟),每个任务包含精确的文件路径、代码片段和测试命令

    • Execute Plan:通过子代理或批量模式执行计划,强制遵循 TDD(Red-Green-Refactor) ,并进行代码审查

      任何跳过步骤(如先写代码)的行为都会被AI视为违规

  • everything-claude-code:流程编排且并行

    • 编排:通过 Research AgentPlanner AgentTDD-guide AgentReviewer AgentResolver Agent 的有序协作完成任务。每个Agent输入输出清晰,中间用 /clear 清理上下文

    • 并行:利用 Git Worktrees 创建多个工作目录,同时运行多个Claude实例处理不同任务,互不干扰。采用 Two-Instance Kickoff(一个搭骨架,一个做调研)启动新项目

3.4 优缺点分析

工具/方法论 优点 缺点/挑战
Speckit 结构最严谨,文档最完善,适合大型项目治理;通过ADR记录决策,可追溯性强 。 太重、太理想化。对动态需求适应性差,流程僵化;生成文档冗长(相较OpenSpec多出数倍),上下文窗口易爆,返工成本高 。
OpenSpec 轻量、Token效率高。增量模式对老项目友好;archive机制能反向构建知识库;学习曲线平缓 。 对命名敏感,Delta机制依赖稳定的命名进行匹配;冲突解决依赖人工介入,对认知负担有一定挑战 ;不适合需要顶层宏观设计的0→1项目 。
Superpowers 质量保障最强。通过强制TDD和Code Review,产出代码可靠性高;头脑风暴功能极佳,能深度挖掘模糊需求 。 流程强制带来的“笨重感” 。即使是修个小Bug,也可能触发全套流程(TDD),对于追求快速验证的场景可能显得繁琐 。
everything-claude-code 极致的技术效率。Token优化策略显著降本;并行化和Agent编排极大提升复杂任务吞吐量;开源且模块化,扩展性强 。 上手门槛较高。需要理解其Agent编排、Hooks、Worktrees等整套哲学,对新手不够友好;部分技巧(如记忆钩子)需要手动配置 。

4. 选型建议

根据不同的团队类型和项目阶段,可以遵循以下建议进行选型

  • 如果你是维护复杂老系统的“单人开发者”或“小型团队”

    • 首选 OpenSpec。它的增量哲学能让你在不扰乱现有架构的前提下,安全地嵌入新功能。归档机制能帮你逐步梳理出混乱系统的“隐形文档”
  • 如果你正在启动一个全新的、复杂度较高的项目,且有明确的架构要求

    • 如果你是严谨派,追求工业级的代码质量:可以尝试 Speckit,但要做好前期投入大量时间编写规范的准备

    • 如果你是敏捷派,需求尚在演变中:强烈推荐 Superpowers。先利用其 Brainstorm 功能理清思路,再利用强制TDD构建稳固的核心,体验会非常流畅

  • 如果你是重度AI用户(特别是 Claude Code),追求极致的开发效率和成本控制

    • everything-claude-code 是你的不二之选。学习并采用它的CLI+Skills思想、Agent编排和并行化策略,你将能驾驭AI完成以往需要一个小团队才能完成的复杂任务
  • 如果你在大型团队中协作,需要明确的产品与开发交接流程

    • Speckit 的结构化文档(Constitution, Spec, Plan)可以作为团队协作的契约,明确各方职责,减少沟通误差。但需确保项目需求相对稳定,以避免因变更导致的巨大返工成本

5. 总结

AI编程正从“无序的 vibe coding”走向“有序的工程化”。这四种工具代表了不同的工程化路径

  • Speckit 走的是“计划经济的道路”,通过周密的计划来控制生产

  • OpenSpec 走的是“改革的道路”,在保持系统稳定的前提下,通过小步快跑实现演进

  • Superpowers 走的是“素质教育的道路”,通过严格的训练(流程)让AI养成良好的编码习惯

  • everything-claude-code 走的是“科技强军的道路”,通过先进的装备(编排、并行)和战术配合来发挥AI的最大战斗力

最终的选择没有绝对的对错,关键在于你的项目痛点、团队文化以及对AI协作的期望。希望这份报告能帮助你在这个快速发展的领域中找到最适合自己的方向

Vue3 工程构建

作者 hypnos_xy
2026年2月24日 12:11

Vue3 工程构建

概述

Vue项目在搭建初期应当设定好目标、规范以及结构,以便后期扩展,避免结构混乱,代码难读、难以修改。

文档以vue3和ant-design-vue组件库为例,从零搭建项目,项目依赖如下:

生产依赖

{
    "dependencies": {
        "@ant-design/icons-vue": "7.0.1",
        "ant-design-vue": "4.2.2",
        "autoprefixer": "10.4.20",
        "axios": "1.7.7",
        "less": "4.2.0",
        "mockjs": "1.1.0",
        "pinia": "2.3.1",
        "postcss": "8.5.3",
        "tailwindcss": "3.4.17",
        "vue": "3.5.27",
        "vue-router": "4.4.5"
    },
}

开发依赖

{
    "devDependencies": {
        "@commitlint/config-conventional": "19.7.1",
        "@eslint/js": "^9.39.2",
        "@types/node": "22.12.0",
        "@typescript-eslint/eslint-plugin": "8.26.1",
        "@typescript-eslint/parser": "8.26.1",
        "@vitejs/plugin-vue": "5.0.4",
        "@vue/test-utils": "2.4.6",
        "@vue/tsconfig": "^0.8.1",
        "commitlint": "19.7.1",
        "cssnano": "^7.1.2",
        "eslint": "9.17.0",
        "eslint-plugin-vue": "9.28.0",
        "globals": "^17.3.0",
        "happy-dom": "20.5.0",
        "husky": "9.1.7",
        "lint-staged": "15.2.10",
        "prettier": "3.5.3",
        "stylelint": "16.12.0",
        "stylelint-order": "^7.0.1",
        "typescript": "5.9.3",
        "unplugin-auto-import": "^21.0.0",
        "unplugin-vue-components": "^31.0.0",
        "vite": "5.4.21",
        "vitest": "2.1.8",
        "vue-eslint-parser": "^10.2.0",
        "vue-tsc": "2.2.8"
    }
}

pnpm安装依赖

首先项目建议使用pnpm进行包管理和安装,

  1. pnpm 的 node_modules 布局使用符号链接来创建依赖项的嵌套结构。node_modules 中每个包的每个文件都是来自内容可寻址存储的硬链接。(避免重复安装
  2. pnpm 是默认支持 monorepo 多项目管理(多项目管理
  3. pnpm 使用链接仅将项目的直接依赖项添加到模块目录的根目录中(幽灵依赖
npm i -g pnpm

项目生产依赖

在项目生产环境中的依赖,主要考虑项目性质以及UI设计:(由于vue3只能在现代浏览器下运行。所以应该从兼容现代浏览器的版本开始,不需要兼容ie版本)

  1. 项目使用vue3应该配套使用状态管理库pinia以及路由管理vue-router
  2. 项目组件库为ant-design-vue,需要安装icon图标**@ant-design/icons-vue**,以及统一使用less作为css预处理器
  3. css工程化统一使用tailwindcsspostcssautoprefixer自动补齐css前缀。
  4. 使用axios请求接口,mockjs可以模拟接口数据。方便前期没有接口条件下开发。

项目开发环境vite以及vite相关配置

项目直接使用vite构建vue3+typescript项目

// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import AutoImport from 'unplugin-auto-import/vite';
import Components from 'unplugin-vue-components/vite';
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers';
import { resolve, extname } from 'path';
// https://vitejs.dev/config/
export default defineConfig({
    plugins: [
        vue(),
        AutoImport({
            // 自动导入常用 API(无需手写 import)
            imports: ['vue', 'vue-router', 'pinia'],
            resolvers: [
                AntDesignVueResolver({
                    importStyle: 'less',
                }),
            ],
            dts: 'src/auto-imports.d.ts', // 生成 `auto-imports.d.ts` 全局 API 类型声明,支持 IDE 代码提示
        }),
        Components({
            resolvers: [
                AntDesignVueResolver({
                    importStyle: 'less', // 这里设置为 'less',以便在使用组件时自动引入对应的 Less 样式文件
                }),
            ],
            dts: 'src/components.d.ts', // 生成 `components.d.ts` 全局组件类型声明,支持 IDE 代码提示
        }),
    ],
    resolve: {
        alias: {
            '@': resolve(__dirname, 'src'), // 设置 '@' 代表 'src' 目录,方便在项目中使用绝对路径导入模块
        },
    },
    css: {
        preprocessorOptions: {
            // 配置 Less 预处理器选项
            less: {
                javascriptEnabled: true, // 允许在 Less 文件中使用 JavaScript 表达式,这对于 Ant Design Vue 的样式定制非常重要
            },
        },
    },
    // 开发服务器配置
    server: {
        port: 3000,
        open: true,
        cors: true,
    },
    build: {
        target: 'es2020', // 设置构建目标为 ES2020,利用现代浏览器的特性提升性能
        outDir: 'dist',
        sourcemap: false,
        rollupOptions: {
            output: {
                // 按类型分类输出文件
                entryFileNames: 'assets/js/[name]-[hash].js',
                chunkFileNames: 'assets/js/[name]-[hash].js',
                assetFileNames: function (assetInfo) {
                    var _a;
                    var ext = extname((_a = assetInfo.name) !== null && _a !== void 0 ? _a : '');
                    if (ext === '.css') {
                        return 'assets/css/[name]-[hash][extname]';
                    }
                    return 'assets/[name]-[hash][extname]';
                },
                // 将核心依赖单独拆分成独立 chunk,方便 CDN 长效缓存
                manualChunks: {
                    vue: ['vue', 'vue-router', 'pinia'],
                    antd: ['ant-design-vue', '@ant-design/icons-vue'],
                    vendor: ['axios'],
                },
            },
        },
    },
});

代码规范eslint

ESLint 用于统一代码风格和查找潜在问题。本项目使用 JS/TS/Vue 分块配置,并根据不同文件类型启用对应规则。

//eslint.config.js
import js from '@eslint/js';
import globals from 'globals';
import vuePlugin from 'eslint-plugin-vue';
import vueParser from 'vue-eslint-parser';
import tseslint from '@typescript-eslint/eslint-plugin';
import tsparser from '@typescript-eslint/parser';

const isProd = process.env.NODE_ENV === 'production';

export default [
    // ==================== 基础配置 ====================
    {
        // 全局忽略的文件和目录
        ignores: [
            '**/node_modules/**',
            '**/dist/**',
            '**/build/**',
            '**/coverage/**',
            '**/public/**',
            '**/*.min.js',
            '**/*.d.ts',
            '**/package-lock.json',
            '**/pnpm-lock.yaml',
            '**/yarn.lock',
            '**/vite.config.d.ts',
            '**/vitest.config.d.ts',
        ],
    },

    // ==================== JavaScript 通用配置 ====================
    {
        files: ['**/*.{js,mjs,cjs}'],
        languageOptions: {
            ecmaVersion: 'latest',
            sourceType: 'module',
            globals: {
                ...globals.browser,
                ...globals.node,
                ...globals.es2020,
            },
        },
        rules: {
            ...js.configs.recommended.rules,

            // 自定义规则
            'no-console': isProd ? ['warn', { allow: ['warn', 'error'] }] : 'off',
            'no-debugger': isProd ? 'warn' : 'off',
            'no-alert': 'warn',
            'no-unused-vars': 'off', // 由 TypeScript 处理
            'prefer-const': 'error',
            eqeqeq: ['error', 'always'],
            curly: ['error', 'all'],
        },
    },

    // ==================== TypeScript 配置 ====================
    {
        files: ['**/*.{ts,tsx}'],
        languageOptions: {
            parser: tsparser,
            parserOptions: {
                ecmaVersion: 'latest',
                sourceType: 'module',
                // 使用应用和 Node 两个 tsconfig,提升类型检查覆盖面
                project: ['./tsconfig.app.json', './tsconfig.node.json'],
            },
            globals: {
                ...globals.browser,
                ...globals.node,
                ...globals.es2020,
            },
        },
        plugins: {
            '@typescript-eslint': tseslint,
        },
        rules: {
            ...tseslint.configs.recommended.rules,

            // TypeScript 特定规则
            '@typescript-eslint/no-explicit-any': 'warn',
            '@typescript-eslint/no-unused-vars': [
                'error',
                {
                    argsIgnorePattern: '^_',
                    varsIgnorePattern: '^_',
                    caughtErrorsIgnorePattern: '^_',
                },
            ],
            '@typescript-eslint/ban-ts-comment': 'warn',
            '@typescript-eslint/no-empty-function': 'warn',
            '@typescript-eslint/no-non-null-assertion': 'warn',
            '@typescript-eslint/explicit-function-return-type': 'off',
            '@typescript-eslint/explicit-module-boundary-types': 'off',
            '@typescript-eslint/no-inferrable-types': 'warn',
            '@typescript-eslint/consistent-type-imports': [
                'warn',
                {
                    prefer: 'type-imports',
                    disallowTypeAnnotations: false,
                },
            ],
        },
    },

    // ==================== Vue 3 文件配置 ====================
    {
        files: ['**/*.vue'],
        plugins: {
            vue: vuePlugin,
        },
        languageOptions: {
            // 使用官方 Vue 解析器对象,支持 <template> + <script setup>
            parser: vueParser,
            parserOptions: {
                parser: tsparser,
                ecmaVersion: 'latest',
                sourceType: 'module',
                extraFileExtensions: ['.vue'],
                project: ['./tsconfig.app.json', './tsconfig.node.json'],
            },
            globals: {
                ...globals.browser,
            },
        },
        rules: {
            // 继承 Vue 3 推荐规则
            ...vuePlugin.configs['vue3-recommended'].rules,

            // ========== Vue 3 自定义规则 ==========
            // 1. 组件命名规则(针对 Vue 3 单文件组件)
            'vue/multi-word-component-names': [
                'error',
                {
                    ignores: [
                        'index', // index.vue
                        'App', // App.vue
                        '404', // 404.vue
                        '[id]', // 动态路由组件
                        '[...all]', // 动态路由组件
                        'Layout', // Layout.vue
                        'Default', // Default.vue
                        'Main', // Main.vue
                    ],
                },
            ],

            // 2. 组件属性换行规则(针对 Ant Design Vue 属性多的特点)
            'vue/max-attributes-per-line': [
                'error',
                {
                    singleline: 5, // Ant Design 组件通常属性较多,放宽到5个
                    multiline: {
                        max: 1,
                    },
                },
            ],

            // 3. Ant Design Vue 组件名特殊处理(关键配置)
            'vue/component-name-in-template-casing': [
                'error',
                'PascalCase',
                {
                    registeredComponentsOnly: false,
                    ignores: [
                        // Ant Design Vue 组件前缀 (a-)
                        '/^a-/', // a-button, a-input, a-modal
                        '/^A[A-Z]/', // AButton, AInput
                        // Vue 内置组件
                        'router-view',
                        'router-link',
                        'transition',
                        'transition-group',
                        'keep-alive',
                        'component',
                        'slot',
                        'template',
                        // 常见第三方组件
                        'icon',
                        'icons',
                    ],
                },
            ],

            // 4. 其他 Vue 规则调整
            'vue/require-default-prop': 'off', // 不要求必须默认值
            'vue/no-v-html': 'warn', // 警告使用 v-html
            'vue/prop-name-casing': ['error', 'camelCase'], // props 使用驼峰
            'vue/attribute-hyphenation': ['error', 'always'], // 属性使用连字符

            // 5. 模板内容换行
            'vue/html-closing-bracket-newline': [
                'error',
                {
                    singleline: 'never',
                    multiline: 'always',
                },
            ],

            // 7. 顺序规则(可选,使代码更整洁)
            'vue/attributes-order': [
                'error',
                {
                    order: [
                        'DEFINITION', // is, v-is
                        'LIST_RENDERING', // v-for
                        'CONDITIONALS', // v-if, v-else-if, v-else, v-show, v-cloak
                        'RENDER_MODIFIERS', // v-once, v-pre
                        'GLOBAL', // id
                        'UNIQUE', // ref, key, v-slot, v-model
                        'SLOT', // v-slot
                        'TWO_WAY_BINDING', // v-model
                        'OTHER_DIRECTIVES', // v-custom-directive
                        'OTHER_ATTR', // 其他属性
                        'EVENTS', // v-on
                        'CONTENT', // v-text, v-html
                    ],
                },
            ],
        },
    },

    // ==================== 测试文件特殊配置 ====================
    {
        files: ['**/__tests__/**/*.{js,ts,vue}', '**/*.test.{js,ts,vue}', '**/*.spec.{js,ts,vue}'],
        plugins: {
            '@typescript-eslint': tseslint,
        },
        languageOptions: {
            parser: tsparser,
            parserOptions: {
                ecmaVersion: 'latest',
                sourceType: 'module',
            },
            globals: {
                // 使用 Vitest 全局变量
                ...globals.vitest,
            },
        },
        rules: {
            'no-console': 'off',
            'no-debugger': 'off',
            '@typescript-eslint/no-explicit-any': 'off',
            '@typescript-eslint/no-non-null-assertion': 'off',
        },
    },

    // ==================== 配置文件特殊处理 ====================
    {
        files: ['**/vite.config.{js,ts}', '**/vitest.config.{js,ts}', '**/eslint.config.{js,mjs}', '**/*.config.{js,ts}'],
        plugins: {
            '@typescript-eslint': tseslint,
        },
        languageOptions: {
            parser: tsparser,
            parserOptions: {
                ecmaVersion: 'latest',
                sourceType: 'module',
            },
            globals: {
                ...globals.node,
            },
        },
        rules: {
            'no-console': 'off',
            '@typescript-eslint/no-unused-vars': 'warn',
        },
    },
];

全局忽略配置

  • ignores:全局忽略的目录和文件模式,如:
    • **/node_modules/****/dist/****/build/**:依赖与构建输出目录。
    • **/coverage/**:测试覆盖率报告。
    • **/public/**:静态资源目录。
    • **/*.min.js**/*.d.ts:压缩 JS 与类型声明文件。
    • 各类锁文件和 *.config.d.ts 类型声明文件等。

JavaScript 通用配置

  • files:**/*.{js,mjs,cjs}
    • 对所有 JS 文件启用该配置。
  • languageOptions:
    • ecmaVersion:latest,使用最新 ECMAScript 语法。
    • sourceType:module,按 ES Module 解析。
    • globals:合并 browsernodees2020 全局变量,避免误报。
  • rules:
    • ...js.configs.recommended.rules:继承官方 ESLint 推荐规则。
    • no-console:生产环境下仅允许 console.warn / console.error,开发环境关闭。
    • no-debugger:生产环境警告,开发环境关闭。
    • no-alert:使用 alert 时给出警告。
    • no-unused-vars:关闭,由 TypeScript 规则接管。
    • prefer-const:推荐使用 const。
    • eqeqeq:强制使用 === / !==
    • curly:要求所有控制语句使用大括号。

TypeScript 配置

  • files:**/*.{ts,tsx}
  • languageOptions:
    • parser:@typescript-eslint/parser,支持 TS 语法。
    • parserOptions.project:./tsconfig.app.json, ./tsconfig.node.json,启用基于项目的类型信息检查。
    • globals:同样合并 browser/node/es2020 全局。
  • plugins:
    • @typescript-eslint:启用 TS 专用规则。
  • rules(节选):
    • 基于 @typescript-eslint 官方 recommended 规则。
    • @typescript-eslint/no-explicit-any:对 any 给出警告。
    • @typescript-eslint/no-unused-vars:检查未使用变量,可通过 _ 前缀忽略。
    • @typescript-eslint/ban-ts-comment:限制 // @ts-ignore 等用法。
    • @typescript-eslint/no-non-null-assertion:对 ! 非空断言警告。
    • @typescript-eslint/explicit-function-return-type:关闭强制显式返回类型。
    • @typescript-eslint/consistent-type-imports:推荐使用 type 导入形式。

Vue 3 文件配置

  • files:**/*.vue
  • plugins:
    • vue:Vue 官方 ESLint 插件。
  • languageOptions:
    • parser:vue-eslint-parser,支持 <template> + <script setup>
    • parserOptions.parser:内部再使用 TS 解析器,支持 TypeScript。
    • parserOptions.project:同样引用 tsconfig.app.jsontsconfig.node.json
  • rules(节选):
    • ...vuePlugin.configs['vue3-recommended'].rules:继承 Vue3 推荐规则集。
    • vue/multi-word-component-names:强制组件名多词,忽略特定名称(如 App、Layout、index 等)。
    • vue/max-attributes-per-line:单行最多 5 个属性,多行时每行 1 个,方便 Ant Design Vue 组件阅读。
    • vue/component-name-in-template-casing:模板中组件名强制 PascalCase,但对 a-button 等 Ant Design 组件和部分内置组件放宽。
    • vue/require-default-prop:关闭 props 强制默认值。
    • vue/no-v-html:对 v-html 给出警告。
    • vue/prop-name-casing:props 必须使用 camelCase。
    • vue/attribute-hyphenation:模板属性使用连字符形式。
    • vue/html-closing-bracket-newline:多行标签关闭时必须换行。
    • vue/attributes-order:规范属性书写顺序(如定义、条件、事件等)。

测试文件特殊配置

  • files:
    • **/__tests__/**/*.{js,ts,vue}
    • **/*.test.{js,ts,vue}
    • **/*.spec.{js,ts,vue}
  • languageOptions.globals:
    • 使用 globals.vitest,注入 Vitest 的全局(如 describeitexpect 等)。
  • rules:
    • no-console / no-debugger:在测试中关闭限制。
    • 放宽 TypeScript 关于 any 和非空断言的限制,方便编写测试用例。

配置文件自身的特殊处理

  • files:vite.config.{js,ts}vitest.config.{js,ts}eslint.config.{js,mjs} 以及 *.config.{js,ts}
  • languageOptions:仅使用 Node 环境的全局变量。
  • rules:
    • no-console:关闭,允许在配置文件中打印调试信息。
    • @typescript-eslint/no-unused-vars:降级为 warn,避免轻微未使用变量导致出错。

.prettierrc代码自动格式化

本项目使用 Prettier 统一代码格式,配置文件为 .prettierrc,并通过 npm script 与 lint-staged 集成,在保存/提交时代码会被自动格式化。

//.prettierrc
{
    "printWidth": 150,
    "tabWidth": 4,
    "useTabs": false,
    "semi": true,
    "singleQuote": true,
    "trailingComma": "es5",
    "bracketSpacing": true,
    "arrowParens": "avoid",
    "vueIndentScriptAndStyle": true,
    "htmlWhitespaceSensitivity": "css",
    "endOfLine": "lf"
}

通过vscode插件Prettier - Code formatter对代码进行自动格式化。能够更专注于代码逻辑书写。

.prettierrc 关键选项

  • printWidth:150
    • 每行最大字符数,超过会自动换行。
  • tabWidth:4
    • 一个缩进级别使用 4 个空格。
  • useTabs:false
    • 使用空格而不是制表符进行缩进。
  • semi:true
    • 语句末尾总是添加分号。
  • singleQuote:true
    • 使用单引号代替双引号。
  • trailingComma:"es5"
    • 在 ES5 允许的地方(对象、数组等)尽量保留尾随逗号。
  • bracketSpacing:true
    • 对象字面量的大括号两侧保留空格,例如 { foo: bar }
  • arrowParens:"avoid"
    • 能省略箭头函数参数括号时就省略,例如 x => x + 1
  • vueIndentScriptAndStyle:true
    • 在 .vue 文件中对 <script><style> 内容进行缩进。
  • htmlWhitespaceSensitivity:"css"
    • 按 CSS 的规则处理 HTML 空白字符,避免过度压缩影响布局。
  • endOfLine:"lf"
    • 统一使用 LF 换行符,有利于跨平台一致性。

其他常用配置(可选)

  • singleAttributePerLine
    • 默认:false
    • 作用:在 HTML / Vue / JSX 标签中,每个属性是否独占一行。属性较多的组件,设为 true 可读性更好,但文件会更长。
  • jsxSingleQuote
    • 默认:false
    • 作用:控制 JSX/TSX 内是否也使用单引号。若项目希望“所有地方都统一用单引号”,可以设为 true
  • quoteProps
    • 默认:"as-needed"
    • 常用值:"as-needed"(默认,仅在需要时加引号)、"consistent"(同一对象内保持一致)、"preserve"(保留原样)。
    • 作用:控制对象属性名(key)是否加引号,可根据团队对 JSON/对象风格的偏好统一约定。
  • bracketSameLine
    • 默认:false
    • 作用:控制多行 JSX/HTML 标签的闭合 > 是否与最后一行内容在同一行。不同团队习惯不同,可按团队偏好统一。
  • proseWrap
    • 默认:"preserve"
    • 常用值:"always""never""preserve"
    • 作用:控制 Markdown 文本是否在 printWidth 处自动换行。文档较多的项目,若希望 diff 更细致、行宽统一,可考虑设为 "always"
  • embeddedLanguageFormatting
    • 默认:"auto"
    • 常用值:"auto""off"
    • 作用:是否格式化字符串模板或文件中嵌入的代码块(如 Markdown 里的代码块、内联脚本等)。若不希望被自动改动,可设为 "off"
  • requirePragma
    • 默认:false
    • 作用:只有在文件头部包含特定注释(例如 @format)时才会被 Prettier 格式化。通常用于大型旧项目的“渐进式接入”。
  • insertPragma
    • 默认:false
    • 作用:在被 Prettier 格式化过的文件头部自动插入 @format 注释,常配合 requirePragma 使用。
  • rangeStart / rangeEnd
    • 默认:0 / Infinity
    • 作用:仅格式化文件的某一段范围,一般通过 CLI 或编辑器集成设置,适用于“只格式化选中区域”的场景。
  • overrides
    • 类型:数组
    • 作用:按文件匹配规则(files / excludeFiles)为不同类型文件指定不同的 Prettier 配置,例如:
      • *.md 使用不同的 printWidthproseWrap
      • *.json 关闭某些影响可读性的规则等。
  • plugins
    • 类型:数组
    • 作用:引入第三方 Prettier 插件,例如:
      • 排序 import、属性或 Tailwind 类名;
      • 支持额外的语法/语言。
    • 仅在确有需求时再引入,避免增加不必要的依赖和格式化开销。

与脚本命令的关系

  • format 脚本:prettier --write .
    • 手动运行时,会对整个项目文件执行一次格式化。

与 ESLint / Stylelint / lint-staged 的协同

  • .lintstagedrc 中:
    • *.{js,ts,vue} 文件:先用 eslint --cache --fix --max-warnings=0 再用 prettier --write,先修复语法/风格问题,再统一格式。
    • *.{css,less,scss}:先用 stylelint --cache --fix 检查样式规范,再用 Prettier 格式化。
    • *.{json,md,html,yml,yaml}:直接使用 prettier --write 进行格式化。
  • 这样可以保证:
    • ESLint/Stylelint 负责“代码/样式是否合理、有没有问题”;
    • Prettier 负责“长什么样、缩进和空格如何对齐”。

typescript 配置

本项目使用多层 tsconfig 管理不同环境和用途的 TypeScript 配置:

  • tsconfig.json:顶层工程引用文件。
  • tsconfig.app.json:应用源码相关配置。
  • tsconfig.node.json:Node 环境下的 TS 配置(主要用于 vite.config.ts、vitest.config.ts 等)。

tsconfig.json

{
    "files": [],
    "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }],
}
  • files:空数组
    • 顶层不直接编译任何文件,仅作为引用入口。
  • references:
    • 引用 tsconfig.app.jsontsconfig.node.json,组成 TS 的多工程(project references)结构,便于增量构建和工具支持。

tsconfig.app.json

{
    "extends": "@vue/tsconfig/tsconfig.dom.json",
    "compilerOptions": {
        "target": "ES2020",
        "useDefineForClassFields": true,
        "lib": ["ES2020", "DOM", "DOM.Iterable"],
        "module": "ESNext",
        "skipLibCheck": true,
        "moduleResolution": "bundler",
        "allowImportingTsExtensions": true,
        "resolveJsonModule": true,
        "isolatedModules": true,
        "noEmit": true,
        "jsx": "preserve",
        "strict": true,
        "noUnusedLocals": true,
        "noUnusedParameters": true,
        "noFallthroughCasesInSwitch": true,
        "baseUrl": ".",
        "paths": {
            "@/*": ["src/*"]
        }
    },
    "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
    "references": [
        {
            "path": "./tsconfig.node.json"
        }
    ]
}
extends
  • "@vue/tsconfig/tsconfig.dom.json"
    • 基于 Vue 官方推荐的 DOM 环境配置,自动包含适合 Vue 3 Web 应用的编译选项和 lib 设置。
compilerOptions(节选)
  • target:"ES2020"
    • 输出目标为 ES2020,支持较新的 JS 特性。
  • useDefineForClassFields:true
    • 使用符合 TC39 标准的类字段语义。
  • lib:["ES2020", "DOM", "DOM.Iterable"]
    • 包含 ES2020 和 DOM 相关的类型声明。
  • module:"ESNext"
    • 使用 ESNext 模块系统,交给打包工具处理。
  • skipLibCheck:true
    • 跳过库声明文件的类型检查,提高编译速度。
  • moduleResolution:"bundler"
    • 使用适合打包工具(如 Vite)的模块解析策略。
  • allowImportingTsExtensions:true
    • 允许显式导入 .ts 扩展名文件。
  • resolveJsonModule:true
    • 允许导入 JSON 文件,并生成对应的类型。
  • isolatedModules:true
    • 强制每个文件都可单独编译,有利于配合 Babel/Vite 使用。
  • noEmit:true
    • 不输出编译结果文件,仅做类型检查。
  • jsx:"preserve"
    • 保留 JSX,交给后续工具处理(如果使用 JSX/TSX)。
  • strict:true
    • 开启严格模式,包含多项严格类型检查选项。
  • noUnusedLocals / noUnusedParameters:true
    • 禁止未使用的本地变量和参数。
  • noFallthroughCasesInSwitch:true
    • 阻止 switch 语句的 case 贯穿错误。
  • baseUrl:"."
    • 以项目根目录为基础路径。
  • paths:
    • "@/*": ["src/*"]
    • 对应 Vite 中的 @ 别名,使 TS 能理解 @/xxx 导入路径。
include
  • "src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"
    • 指明应用代码中需要被 TS 类型系统分析的文件范围。
references
  • 引用 ./tsconfig.node.json
    • 让应用配置依赖 Node 配置,便于统一工程结构和增量编译。

tsconfig.node.json

{
    "compilerOptions": {
        "composite": true,
        "skipLibCheck": true,
        "module": "ESNext",
        "moduleResolution": "bundler",
        "allowSyntheticDefaultImports": true,
        "strict": true
    },
    "include": ["vite.config.ts", "vitest.config.ts"]
}
compilerOptions
  • composite:true
    • 表明该配置参与 TS 的工程引用(project references),允许生成增量信息。
  • skipLibCheck:true
    • 跳过声明文件检查,加快编译。
  • module:"ESNext"
    • 使用 ESNext 模块系统。
  • moduleResolution:"bundler"
    • 适配 Vite 等打包工具的解析方式。
  • allowSyntheticDefaultImports:true
    • 允许对仅有 export = 的模块使用默认导入,兼容 CommonJS 包。
  • strict:true
    • 同样开启严格类型检查。
include
  • ["vite.config.ts", "vitest.config.ts"]
    • 仅对这两个 Node 环境运行的配置文件进行类型检查,保证其类型安全和智能提示。

TypeScript 工具链与脚本(补充)

vue-tsc

- 专门针对 Vue 3 项目(包括 `.vue` 文件)进行类型检查的工具。
- 在 package.json 中通过脚本:
    - `type-check`: `vue-tsc -b`
    - `build`: `vue-tsc -b && vite build`
- 先基于 tsconfig 工程配置做一遍完整类型检查,再进入 Vite 构建流程,避免类型错误进入打包阶段。

ESLint + @typescript-eslint/*

- ESLint 使用 `@typescript-eslint/parser``@typescript-eslint/eslint-plugin` 读取 tsconfig 中的编译选项和类型信息,对 TS/TSX 代码做更精细的规则检查。
- 通过 parserOptions.project 指向 `tsconfig.app.json` / `tsconfig.node.json`,确保类型感知规则(如 no-unused-vars、no-explicit-any 等)能够发挥作用。

Vitest 与 TS: - vitest.config.ts 通过 mergeConfig 复用 Vite 配置,使测试文件也能使用同样的别名与 TS 配置。 - 测试代码本身的类型检查依托于上述 tsconfig 和 vue-tsc/TypeScript 工具链。

Git Hooks & 提交规范配置说明

本项目通过 Husky、lint-staged 和 Commitlint 组成一套 Git 提交前检查与提交消息规范校验流程。

Husky 目录结构

  • .husky/pre-commit
    • 提交前(pre-commit hook)执行。
  • .husky/commit-msg
    • 输入提交信息后、真正写入提交之前执行。

package.json 中:

  • "prepare": "husky"
    • 安装依赖后会自动初始化 Husky(创建 .husky 目录),确保 Git Hooks 生效。

pre-commit 钩子:代码质量检查

文件:.husky/pre-commit

内容:

# 严格模式:提交前必须通过 lint-staged 检查
pnpm lint-staged

含义:

  • 在执行 git commit 时,只对暂存区(staged)的文件运行 lint-staged。
  • 如果 lint-staged 中配置的命令有失败,则本次提交会被中断,强制开发者先修复问题。

lint-staged 配置:只检查改动文件

文件:.lintstagedrc

核心规则:

{
    "*.{js,ts}": ["eslint --cache --fix --max-warnings=0", "prettier --write"],
    "*.vue": ["eslint --cache --fix --max-warnings=0", "prettier --write"],
    "*.{css,less,scss}": ["stylelint --cache --fix", "prettier --write"],
    "*.{json,md,html,yml,yaml}": "prettier --write",
    "src/**/components/**/*.{vue,js,ts}": ["eslint --cache --fix --max-warnings=5"],
    "src/**/antd/**/*.{vue,js,ts}": ["eslint --cache --fix --max-warnings=5"],
}

说明:

  • *.{js,ts} / *.vue
    • 先使用 ESLint(带缓存、自动修复、并将允许的 warning 数量控制为 0)。
    • 再使用 Prettier 统一代码格式。
  • *.{css,less,scss}
    • 使用 Stylelint 进行样式规范检查并自动修复,然后交给 Prettier 统一格式。
  • *.{json,md,html,yml,yaml}
    • 仅用 Prettier 进行格式化,保证缩进与风格统一。
  • src/**/components/**/*.{vue,js,ts}src/**/antd/**/*.{vue,js,ts}
    • 对关键目录(组件、Ant Design 相关目录)额外运行一次 ESLint,并允许少量 warning(max-warnings=5),强调这里的代码质量。

commit-msg 钩子:提交信息规范

文件:.husky/commit-msg

内容:

# 严格模式:提交信息必须符合规范,否则中断提交
pnpm commitlint --edit "$1"

含义:

  • 在编写完 commit message 后,使用 Commitlint 对提交信息进行校验。
  • 如果不符合规范(例如类型不合法、描述太短等),提交会被中止,需要修改提交说明后重试。

Commitlint 规则

文件:.commitlintrc.cjs

核心规则:

// .commitlintrc.cjs
module.exports = {
    extends: ['@commitlint/config-conventional'],

    rules: {
        // 1. 提交类型(必需)
        'type-enum': [
            2,
            'always',
            [
                'feat', // 新功能
                'fix', // Bug修复
                'docs', // 文档更新
                'style', // 代码格式(空格、分号等,不影响功能)
                'refactor', // 重构(既不是新功能也不是bug修复)
                'test', // 测试相关
                'chore', // 构建过程或辅助工具变动
                'perf', // 性能优化
                'build', // 构建系统或外部依赖变更
                'ci', // CI配置变更
                'revert', // 回滚提交
                'other', // 其他类型
            ],
        ],

        // 2. 类型必须小写
        'type-case': [2, 'always', 'lower-case'],

        // 3. 类型不能为空
        'type-empty': [2, 'never'],

        // 4. 主题(描述)不能为空
        'subject-empty': [2, 'never'],

        // 5. 主题不以句号结尾
        'subject-full-stop': [2, 'never', '.'],

        // 6. 主题最少3个字符
        'subject-min-length': [2, 'always', 3],

        // 7. 主题最多100个字符(建议一行能显示完整)
        'subject-max-length': [2, 'always', 100],

        // 8. 作用域(可选)
        'scope-enum': [
            2,
            'always',
            [
                'component', // 组件
                'page', // 页面
                'layout', // 布局
                'router', // 路由
                'store', // 状态管理(Pinia)
                'api', // API接口
                'utils', // 工具函数
                'styles', // 样式
                'types', // TypeScript类型
                'config', // 配置
                'deps', // 依赖更新
                'other', // 其他(不属于以上分类的提交)
            ],
        ],
    },
};

关键点:

  • extends:['@commitlint/config-conventional']

    • 基于社区常用的 Conventional Commits 规范。
  • type-enum

    • 限制可用的提交类型,例如:featfixdocsstylerefactortestchoreperfbuildcirevertother 等。
  • type-case / type-empty

    • 类型必须小写,且不能为空。
  • subject-empty / subject-min-length / subject-max-length

    • 提交描述必填,长度在 3~100 字符之间,且不允许以句号结尾(subject-full-stop 规则)。
  • scope-enum

    • 可选的作用域列表,如 componentpagelayoutrouterstoreapiutilsstylestypesconfigdepsother 等,帮助约束“这个提交主要改了哪一类东西”。

日常使用建议

  • 开发过程中:
    • 经常本地执行 pnpm lintpnpm format 保持代码整洁。
  • 提交代码时:
    • 按照约定的格式书写提交信息,例如:
      • feat(component): 新增用户列表组件
      • fix(api): 修复登录接口返回值解析错误
    • pre-commit 和 commit-msg 钩子会自动帮你做最后一层把关。

postcss.config.cjs 配置说明

PostCSS 用于在构建过程中对 CSS 进行各种自动化处理。本配置主要启用了 Tailwind CSS、Autoprefixer 以及在生产环境使用的 cssnano 压缩。

plugins 插件配置

// postcss.config.cjs
module.exports = {
    plugins: {
        // Tailwind CSS 插件
        tailwindcss: {
            config: './tailwind.config.js', // 指定 Tailwind 配置文件路径
        },

        // Autoprefixer 自动添加浏览器前缀
        autoprefixer: {
            overrideBrowserslist: [
                'last 2 versions', // 支持最近2个版本的浏览器
                '> 1%', // 全球使用率 > 1% 的浏览器
                'ios >= 8', // iOS 8+
                'android >= 4.4', // Android 4.4+
                'not ie <= 11', // 不支持 IE 11 及以下
                'not dead', // 不包含已死亡的浏览器
            ],
            grid: true, // 为 IE 启用 CSS Grid 前缀
            flexbox: true, // 为旧版浏览器添加 Flexbox 前缀
            remove: false, // 不删除过时的前缀
        },

        // 可选:CSS 压缩(生产环境)
        ...(process.env.NODE_ENV === 'production'
            ? {
                  cssnano: {
                      preset: [
                          'default',
                          {
                              discardComments: { removeAll: true }, // 删除所有注释
                              normalizeWhitespace: false, // 不压缩空格(由构建工具处理)
                          },
                      ],
                  },
              }
            : {}),
    },
};
tailwindcss
  • 作用:启用 Tailwind CSS,按需生成原子化工具类样式。
  • config:'./tailwind.config.js'
    • 指定 Tailwind 的配置文件路径,统一管理扫描范围、主题颜色等。
autoprefixer
  • 作用:自动为 CSS 添加浏览器前缀,提升兼容性。
  • overrideBrowserslist:浏览器兼容策略列表:
    • last 2 versions:支持最近 2 个版本的各主流浏览器。
    • > 1%:全球使用率大于 1% 的浏览器。
    • ios >= 8:支持 iOS 8 及以上版本。
    • android >= 4.4:支持 Android 4.4 及以上版本。
    • not ie <= 11:排除 IE11 及以下版本。
    • not dead:排除已经停止维护的“死亡”浏览器。
  • grid:true
    • 为 IE 等浏览器添加 CSS Grid 前缀。
  • flexbox:true
    • 为旧版浏览器添加 Flexbox 前缀。
  • remove:false
    • 不删除已有的旧前缀,避免影响兼容性。
cssnano(仅生产环境)
  • 作用:在生产环境中对 CSS 进行压缩和优化,减小包体积。
  • 条件启用:process.env.NODE_ENV === 'production' 时才添加该插件。
  • preset:
    • 'default':使用 cssnano 默认优化策略。
    • 配置对象:
      • discardComments.removeAll:true,删除所有 CSS 注释。
      • normalizeWhitespace:false,不在此处压缩空白字符(交由构建工具处理)。

总结

  • 开发环境:Tailwind + Autoprefixer,便于快速开发与兼容性处理。
  • 生产环境:在上述基础上额外启用 cssnano,进一步压缩 CSS 提高加载性能。

tailwind.config.js 配置说明

该文件定义了 Tailwind CSS 的扫描范围、主题扩展以及与 Ant Design Vue 的配合策略。

// tailwind.config.js(精简版)
module.exports = {
    content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],

    theme: {
        extend: {
            // Ant Design 主题颜色
            colors: {
                primary: '#1890ff',
                success: '#52c41a',
                warning: '#faad14',
                error: '#f5222d',
                info: '#13c2c2',
            },

            // 字体
            fontFamily: {
                sans: ['-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', 'sans-serif'],
            },

            // 圆角
            borderRadius: {
                ant: '6px',
            },

            // 阴影
            boxShadow: {
                ant: '0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 9px 28px 8px rgba(0, 0, 0, 0.05)',
            },
        },

        // 响应式断点
        screens: {
            xs: '480px',
            sm: '640px',
            md: '768px',
            lg: '1024px',
            xl: '1280px',
            '2xl': '1536px',
        },
    },

    // 关键:禁用 preflight 避免与 Ant Design 冲突
    corePlugins: {
        preflight: false,
    },

    plugins: [],
};

content 扫描范围

  • ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}']
    • Tailwind 会在这些文件中扫描类名,只生成实际用到的工具类,减少最终 CSS 体积。

theme 主题配置

extend 扩展主题
  • colors:

    • primary:#1890ff
    • success:#52c41a
    • warning:#faad14
    • error:#f5222d
    • info:#13c2c2
    • 这些颜色与 Ant Design 的主题色保持一致,方便统一 UI 风格。
  • fontFamily.sans:

    • ['-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', 'sans-serif']
    • 定义无衬线字体的优先级列表,提升跨平台字体一致性。
  • borderRadius.ant:

    • 6px,用于配合 Ant Design 的默认圆角风格,可在项目中通过自定义类统一使用。
  • boxShadow.ant:

    • 一组与 Ant Design 近似的阴影配置,使自定义元素与 AntD 组件视觉统一。
screens 断点定义
  • xs:480px
  • sm:640px
  • md:768px
  • lg:1024px
  • xl:1280px
  • 2xl:1536px

这些断点用于响应式布局,如 md:w-1/2 表示在 md 及以上宽度时占一半宽度。

corePlugins 内置插件控制

  • preflight:false
    • 关闭 Tailwind 的预设 CSS 重置(preflight),以避免与 Ant Design 自身的样式重置产生冲突。
    • 通过此设置,可以让 Ant Design 的默认样式在项目中保持预期行为。

plugins 自定义插件

  • 当前为 [](空数组)。
    • 需要时可以在此添加社区或自定义的 Tailwind 插件,例如表单、美化滚动条等。

vitest.config.ts 配置说明

Vitest 是与 Vite 深度集成的测试框架。本配置基于 Vite 配置进行扩展,使测试环境与实际构建环境保持一致。

import { mergeConfig } from 'vitest/config';
import viteConfig from './vite.config';

export default mergeConfig(viteConfig, {
    test: {
        globals: true,
        environment: 'happy-dom',
        include: ['src/**/*.{test,spec}.{js,ts,vue}'],
    },
});

mergeConfig 与基础配置

  • import { mergeConfig } from 'vitest/config'
    • 用于在 Vitest 配置中复用并扩展 Vite 的配置。
  • import viteConfig from './vite.config'
    • 引入项目的 Vite 配置,保证测试环境的别名、插件等与开发/构建保持一致。
  • export default mergeConfig(viteConfig, { ... })
    • 使用 mergeConfig 将 Vitest 相关配置与 Vite 基础配置合并。

test 选项

  • globals:true
    • 启用全局测试 API,如 describeitexpect 等,无需手动 import。
  • environment:'happy-dom'
    • 使用 happy-dom 提供类似浏览器的 DOM 环境,适合测试 Vue 组件与涉及 DOM 操作的逻辑。
  • include:['src/**/*.{test,spec}.{js,ts,vue}']
    • 指定测试文件匹配模式:
      • 位于 src 目录及其子目录中。
      • 文件名包含 .test..spec.
      • 支持 js/ts/vue 等扩展名。

与 ESLint 中测试配置的关系

  • ESLint 的测试规则块会为这些测试文件注入 Vitest 的全局变量,防止 describe 等被报未定义。
  • Vitest 配置中的 include 对测试执行范围负责,两者配合保证测试既能正确运行又能通过 lint 检查。

项目目录结构说明

本文档说明本项目主要目录的作用和推荐使用方式,便于团队成员快速理解和扩展。

目录结构总览

├─ src/
│  ├─ assets/
│  │  ├─ images/
│  │  └─ styles/
│  ├─ components/
│  │  ├─ common/
│  │  └─ business/
│  ├─ layouts/
│  ├─ router/
│  │  └─ modules/
│  ├─ services/
│  │  └─ modules/
│  ├─ stores/
│  │  └─ modules/
│  ├─ hooks/
│  ├─ utils/
│  ├─ types/
│  ├─ constants/
│  ├─ tests/
│  │  ├─ unit/
│  │  └─ components/
│  ├─ App.vue
│  └─ main.ts
├─ public/
├─ doc/
│  ├─ project-structure.md
│  └─ ...(其他配置/规范文档)
├─ package.json
├─ vite.config.ts / vite.config.js
├─ vitest.config.ts / vitest.config.js
├─ tsconfig*.json
├─ eslint.config.js
├─ tailwind.config.js
└─ .prettierrc

根目录

  • src/
    • 前端应用的主要源码目录。
  • public/
    • 静态公共资源目录,打包时会原样拷贝到构建结果中。
  • doc/
    • 项目文档目录(工程规范、配置说明、目录结构等)。

src 目录

  • src/main.ts
    • 应用入口文件,创建 Vue 应用实例,注册路由、状态管理、全局组件等。
  • src/App.vue
    • 根组件,一般只负责基本布局容器和路由出口等。
  • src/assets/
    • 静态资源:图片、图标、全局样式等。
    • 建议按类型或业务拆分子目录,例如:images/styles/ 等。
  • src/components/
    • 可复用的通用组件。
    • 可再划分:common/(基础通用)、business/(跨页面业务组件)等。
  • src/views/
    • 页面级组件(通常与路由一一对应)。
    • 每个页面一个目录,内部放该页面的子组件,例如:views/user/List.vueviews/user/components/UserTable.vue
  • src/layouts/
    • 布局组件:如后台管理系统的主框架(侧边栏 + 顶栏 + 内容区)、登录页布局等。
    • 常见约定:DefaultLayout.vueAuthLayout.vue 等。
  • src/router/
    • 路由相关配置。
    • 一般包含:index.ts(创建 router 实例)、按模块拆分的路由配置文件(如 modules/user.ts)。
  • src/stores/
    • 状态管理(例如 Pinia)相关的 store 定义。
    • 每个业务领域一个 store 文件,如:userStore.tsappStore.ts 等。
  • src/services/
    • 与后端交互的服务层代码(API 请求封装)。
    • 推荐:
      • request.ts:封装 Axios/Fetch,统一处理请求、响应、错误。
      • 按业务模块拆分 API 文件,如:user.tsauth.tssystem.ts
  • src/utils/
    • 工具函数库,纯函数、与业务相对无关的通用逻辑。
    • 可按功能再拆分:date.tsformat.tsvalidator.ts 等。
  • src/hooks/
    • 组合式函数(Composition API Hooks),抽离可复用的状态逻辑。
    • 命名建议以 use 开头,例如:useRequestusePaginationuseDialog 等。
  • src/types/
    • TypeScript 类型定义,接口类型、全局类型、枚举等。
    • 可以按模块管理:user.d.tsauth.d.tsapi.d.ts 等。
  • src/constants/
    • 项目中用到的常量定义,如枚举值、字典、配置项、路由名称常量等。
    • 例如:route-names.tsstorage-keys.tsbusiness.ts
  • src/tests/
    • 单元测试与组件测试目录,使用 Vitest 运行。
    • 推荐按类型再分:tests/unit/(工具函数、逻辑单元)和 tests/components/(组件相关测试)。
    • 测试文件命名建议:*.test.ts*.spec.ts,Vitest 已在 vitest.config.ts 中配置 include: ['src/**/*.{test,spec}.{js,ts,vue}'],会自动匹配。

常见子目录约定

以下为项目中推荐使用的一些二级子目录,实际可根据业务扩展或精简:

  • src/assets/images/
    • 存放图片、图标等静态资源,可按业务或功能再拆子目录。
  • src/assets/styles/
    • 全局样式、Tailwind 扩展、主题相关样式等。
  • src/components/common/
    • 基础通用组件,如按钮封装、表格封装、对话框封装等,可在多个业务模块中复用。
  • src/components/business/
    • 跨页面的业务组件,例如“用户选择器”、“部门树选择”等。
  • src/views/dashboard/
    • 仪表盘 / 概览页相关的页面组件。
  • src/router/modules/
    • 路由模块配置文件,按业务模块拆分,例如:system.tsuser.tsdashboard.ts
  • src/stores/modules/
    • 按业务模块拆分的 store 文件,例如:system.tsuser.tsapp.ts 等。
  • src/services/modules/
    • 按业务模块拆分的 API 服务文件,例如:system.tsuser.tsauth.ts 等。

约定与建议

  • 页面(views)和业务组件的目录结构,尽量与路由、业务模块保持一致,便于查找和重构。
  • 通用组件、hooks、utils、services 等尽量保持“可复用、低耦合”,避免将具体页面逻辑写进去。
  • 新增目录或模块时,优先考虑是否属于现有模块,尽量保持目录层级清晰、简洁。

别再从 0 造后台了:`antdv-next-admin`,开箱即用的 Vue 3 中后台脚手架

作者 叶落阁主
2026年2月24日 11:56

image.png

做中后台项目,最容易“耗时间但不出成果”的,不是业务功能,而是重复的基础建设:登录鉴权、权限控制、菜单路由、表格表单、主题切换、国际化、Mock 联调。

antdv-next-admin 就是为了解决这个问题而做的一个现代化脚手架:让你少花时间搭地基,把精力集中到真正有价值的业务实现上。

这个脚手架到底能帮你省什么时间?

1. 一套成熟的技术栈,开箱即用

  • Vue 3.4 + TypeScript 5 + Vite 5
  • Pinia + Vue Router 4
  • antdv-next 组件体系
  • Axios、vue-i18n、ECharts 等常用能力全接好

2. 权限体系不是“样子货”

  • RBAC 权限模型
  • 动态路由与菜单控制
  • 按钮级权限和指令权限
  • 角色/用户/权限等系统管理页面示例

3. 中后台常见体验,基本都内置了

  • 多标签页(KeepAlive 缓存)
  • 全局搜索(Ctrl/Cmd + K
  • 亮色/暗色/跟随系统
  • 垂直/水平布局切换
  • 中英文国际化切换

4. 不只是“壳子”,还有可复用的业务能力

  • ProTable(查询、分页、列配置、值类型渲染)
  • ProForm(配置化表单、验证、布局)
  • ProModal(拖拽、全屏、表单集成)
  • 富文本编辑器、验证码组件、图标选择器、水印组件等

5. 联调效率很高

  • 开发环境支持完整 Mock
  • 常见 CRUD 接口都能直接跑
  • 前后端可并行开发,减少等待

5 分钟上手

git clone https://github.com/yelog/antdv-next-admin.git
cd antdv-next-admin
npm install
npm run dev

打开 http://localhost:3000 即可体验。

常用命令:

npm run type-check
npm run build
npm run build:check
npm run preview

适合哪些团队和项目?

  • 想快速启动中后台项目的个人开发者
  • 需要统一工程规范的中小团队
  • 希望沉淀“可复用管理端底座”的业务线
  • 需要权限、主题、多语言、Mock 一次性配齐的项目

为什么我会推荐它?

我更看重脚手架的两个指标:

  • 能不能快速进入业务开发,而不是陷入“搭架子”
  • 能不能在后续迭代中保持可维护、可扩展

antdv-next-admin 在这两点上都做得比较扎实:基础能力全,目录和职责清晰,示例场景覆盖也比较完整,适合作为长期演进的中后台基座。

最后

如果你正在做 Vue 3 中后台,这个项目值得试一下:

如果这个项目帮你省下了时间,欢迎点一个 Star
你的每一颗 Star,都是项目持续迭代最直接的动力。谢谢支持。

状态驱动渲染和事件驱动模型

作者 yiranlater
2026年2月24日 11:42

状态驱动渲染 vs 事件驱动模型:前端架构的两套范式

在前端开发中,有两种截然不同的思维模型:

  • 状态驱动渲染(State-Driven Rendering) :React / Vue 的核心范式
  • 事件驱动模型(Event-Driven Model) :传统 DOM、Node.js、消息系统的核心范式

它们不是对立的,但混用会出问题。理解两者的本质差异,是写出可维护前端架构的关键。


一、状态驱动渲染:UI 是状态的函数

核心公式

UI = f(state)

状态变了,UI 自动更新。开发者只需要关心:

"现在的状态是什么?"

而不是:

"我需要手动更新哪个 DOM?"

特征

// 状态
const [frame, setFrame] = useState(10);

// UI 自动跟随状态
return <Slider value={frame} />;
  • 状态有当前值:任何时刻都可以读取
  • 状态可持久:刷新、回放、时间旅行都合理
  • UI 是派生的:状态是唯一事实来源(Single Source of Truth)

适合管理什么?

// ✅ 这些都是"事实",适合放状态
{
  activeShardId: 0,       // 现在在哪个分片
  frameRange: [10, 20],   // 现在显示哪些帧
  selectedInstance: 'A',  // 现在选中了谁
  loadingStatus: 'ready', // 现在加载状态
  roiRange: {...},        // 现在的空间范围
}

二、事件驱动模型:响应发生的事情

核心公式

handler = f(event)

事件发生了,触发对应的处理函数。开发者关心的是:

"发生了什么事?"

而不是:

"现在的状态是什么?"

特征

bus.on('shard:didChange', ({ shardId }) => {
  doSomething(shardId);
});

bus.emit('shard:didChange', { shardId: 1 });
  • 事件没有当前值:发生的瞬间有意义,之后就没了
  • 事件是一次性的:错过了就是错过了
  • 消费者解耦:发布者不关心谁在监听

适合处理什么?

// ✅ 这些都是"发生的事",适合走事件
bus.emit('shard:willChange', ...)   // 分片即将切换
bus.emit('shard:didChange', ...)    // 分片切换完成
bus.emit('toast:show', ...)         // 弹出提示
bus.emit('camera:flyTo', ...)       // 相机飞行
bus.emit('frame:scrubEnd', ...)     // 时间轴拖动结束

三、两者的本质差异

用一个问题来判断

"如果我现在问系统,这个东西有没有当前值?"

有  → 状态(Zustand / useState)
没有 → 事件(Event Bus)

对比表

维度 状态驱动 事件驱动
核心问题 现在是什么 发生了什么
时间性 持久存在 瞬时发生
错过处理 不会错过(随时可读) 会错过(没订阅就没了)
典型载体 Zustand / useState / Pinia EventBus / CustomEvent
UI 关系 直接驱动渲染 触发副作用
调试方式 快照(Redux DevTools) 日志追踪

四、最经典的混用错误

把事件存成状态

// ❌ 用状态广播事件
store.setState({
  lastEvent: 'shardChanged',
  shardId: 1,
  eventId: id++
});

// ❌ 用 useEffect 消费"伪事件状态"
useEffect(() => {
  if (lastEvent === 'shardChanged' && eventId !== consumed.current) {
    consumed.current = eventId;
    doSomething();
  }
}, [lastEvent, eventId]);

为什么会失控?

问题一:新组件挂载重复消费

// 组件 A 正常消费
// 组件 B 5 秒后挂载,读到旧的 lastEvent,再次触发
// React StrictMode 双重挂载,触发两次

事件已经是过去式,但状态还在,新来的组件照样消费。

问题二:开始写 eventId++ 打补丁

// 你在用状态手搓一个残缺的消息队列
{
  lastEvent: 'shardChanged',
  eventId: 42,           // 区分新旧事件
  consumedEventId: 41,   // 记录消费位置
}

没有消费确认、没有顺序保证、没有错误处理。

问题三:store 开始堆垃圾字段

{
  // 真正的状态
  activeShardId: 0,
  frameRange: [10, 20],

  // 伪装成状态的事件(越来越多)
  lastEvent: 'shardChanged',
  lastToastMessage: '加载失败',
  lastCameraTarget: { x: 0, y: 0, z: 0 },
  lastExportResult: 'success',
}

没人能说清楚哪些是"当前值",哪些是"历史通知"。


五、另一个方向的混用:把状态当事件用

// ❌ 用事件传递"应该是状态"的东西
bus.emit('frameRangeUpdated', { start: 10, end: 20 });

// 然后每个组件都要订阅,才能知道当前帧范围
bus.on('frameRangeUpdated', ({ start, end }) => {
  setLocalFrame(start, end); // 各自维护本地副本
});

问题:

  • 新挂载的组件不知道当前帧范围(错过了事件)
  • 多个组件各自维护副本,状态不一致
  • 本质上是在用事件模拟状态,同样是范式错位

六、正确的分工(以点云编辑器为例)

Zustand 管状态(事实)

// 任何时刻问都有意义
{
  activeShardId: 0,
  frameRange: [10, 20],
  roiRange: { x: [-30,30], y: [-20,20], z: [-1,3] },
  selectedInstance: 'A',
  loadingStatus: 'ready',
}

Bus 发通知(事件)

// 只在发生的瞬间有意义
bus.emit('shard:willChange', { fromId: 0, toId: 1 }); // 做清理
bus.emit('shard:didChange', { shardId: 1 });           // 各模块响应
bus.emit('toast:show', { message: '加载完成' });        // UI 副作用
bus.emit('camera:flyTo', { x: 0, y: 0, z: 0 });       // 一次性动作

两者配合的典型流程

async function switchShard(newShardId: number) {
  const oldId = store.getState().activeShardId;

  // 1. 发事件:通知各模块准备
  bus.emit('shard:willChange', { fromId: oldId, toId: newShardId });

  // 2. 执行异步操作
  await loadShardPcd(newShardId);

  // 3. 更新状态:记录事实
  store.setState({ activeShardId: newShardId });

  // 4. 发事件:通知各模块响应
  bus.emit('shard:didChange', { shardId: newShardId });
}

各模块各自监听,互不依赖:

// rangeLayer 监听切换完成
bus.on('shard:didChange', () => rangeLayer.updateRange());

// cacheService 监听即将切换,提前预取
bus.on('shard:willChange', ({ toId }) => prefetchShard(toId));

// labelingData 监听即将切换,做清理
bus.on('shard:willChange', () => clearCachedLabelingData());

七、中间地带:Zustand subscribe

有一种情况可以不用 Bus,直接用 Zustand 的 subscribe

// 状态变了,顺带做副作用
store.subscribe(
  state => state.frameRange,
  (frameRange) => {
    updateFrameStats(frameRange);
  }
);

适合:

  • 副作用和状态强绑定(状态变了一定要做)
  • 逻辑简单、不需要跨模块广播

不适合:

  • 副作用很多、很复杂
  • 需要"即将发生"的时机(willChange
  • 需要携带额外上下文(比如 fromId

八、判断口诀(直接用)

问自己三个问题:

1. 这个东西有"当前值"吗?
   有 → Zustand

2. 这件事只在"发生瞬间"有意义吗?
   是 → Bus

3. 副作用和某个状态强绑定吗?
   是 → Zustand subscribe

九、总结

状态驱动和事件驱动是两套不同的范式,各自解决不同的问题:

  • 状态驱动:让 UI 和数据保持一致,解决"现在是什么"
  • 事件驱动:让模块之间低耦合协作,解决"发生了什么"

混用的根本原因是:把"发生了什么"存成了"现在是什么" ,或者反过来。

两者分工清晰之后,你会发现:

  • store 里只有干净的状态
  • 模块之间只通过事件通信
  • 没有神秘的 useEffect 副作用链
  • 系统行为可预测、可追踪、可测试

状态是系统的记忆,事件是系统的神经。两者各司其职,架构才会稳。

用 useState 管理服务端数据?不如试试 React Query 来“避坑”

作者 yuki_uix
2026年2月24日 11:41

在写前端应用的时候,我发现自己经常重复着同样的模式:useEffect + fetch + useState。刚开始觉得这很自然,不就是请求数据、设置状态嘛。但随着项目变大,我越来越感觉不对劲:为什么到处都是重复的 loading、error 处理?为什么同一个用户信息要请求好几次?为什么数据更新后其他组件不同步?

直到接触了 React Query,我才恍然大悟:原来服务端数据和普通状态根本不是一回事。这篇文章是我重新理解这个问题的过程,也希望能帮到同样困惑的你。

一个完整的对比:用户资料页

让我们从一个实际场景开始。假设要做一个用户资料页,需要:

  • 显示用户信息(名字、头像等)
  • 支持编辑昵称
  • 处理加载状态和错误
  • 多个组件共享用户数据(Header 和 Profile 都需要)

听起来不复杂对吧?我们先看看用传统方式怎么写。

方案 1:用 useState(传统方式)

// 环境:React
// 场景:用传统方式管理服务端数据

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  
  // 获取数据
  useEffect(() => {
    setLoading(true);
    setError(null);
    
    fetch(`/api/users/${userId}`)
      .then(res => {
        if (!res.ok) throw new Error('Failed to fetch');
        return res.json();
      })
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, [userId]);
  
  // 更新昵称
  const updateName = async (newName) => {
    try {
      const res = await fetch(`/api/users/${userId}`, {
        method: 'PUT',
        headers: { 'Content-Type''application/json' },
        body: JSON.stringify({ name: newName }),
      });
      
      if (!res.ok) throw new Error('Update failed');
      
      const updatedUser = await res.json();
      setUser(updatedUser);  // 更新本地状态
      
    } catch (err) {
      alert('fail to update');
    }
  };
  
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!user) return null;
  
  return (
    <div>
      <h1>{user.name}</h1>
      <button onClick={() => updateName('new name')}>
        Update Name
      </button>
    </div>
  );
}

看起来没问题?但等等,我们还需要在 Header 组件里显示用户名:

// Header 也需要用户信息,又要写一遍
function Header({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    // 又是一模一样的逻辑...
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      });
  }, [userId]);
  
  return <div>{user?.name}</div>;
}

这时候问题就来了:

❌ 问题 1:代码重复 Profile 和 Header 各自管理状态,同样的请求逻辑写了两遍。更糟的是,如果它们同时渲染,会发起两次相同的网络请求!

❌ 问题 2:缓存缺失 用户从 Profile 页跳到其他页,再跳回来,组件重新渲染 → useEffect 触发 → 又请求一遍。即使数据根本没变,用户也要看着 loading 转圈。

❌ 问题 3:数据同步困难 在 Profile 页改了昵称,更新了本地的 user 状态。但 Header 组件里的昵称怎么更新?它们是两个独立的 useState,互不影响!

❌ 问题 4:缺少错误重试 网络请求失败了,用户只能刷新页面重试。我们得手动实现一个"重试"按钮,还要管理重试次数、延迟等逻辑。

❌ 问题 5:缺少乐观更新 点击更新按钮 → 等待请求完成 → 界面才变化。用户会觉得卡顿,体验很差。

方案 2:用 React Query

现在让我们看看 React Query 如何解决这些问题:

// 环境:React + React Query
// 场景:用专业工具管理服务端数据
// 依赖:npm install @tanstack/react-query

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

// 封装获取用户的函数
const fetchUser = async (userId) => {
  const res = await fetch(`/api/users/${userId}`);
  if (!res.ok) throw new Error('Failed to fetch');
  return res.json();
};

// 封装更新用户的函数
const updateUser = async ({ userId, data }) => {
  const res = await fetch(`/api/users/${userId}`, {
    method: 'PUT',
    headers: { 'Content-Type''application/json' },
    body: JSON.stringify(data),
  });
  if (!res.ok) throw new Error('Update failed');
  return res.json();
};

function UserProfile({ userId }) {
  const queryClient = useQueryClient();
  
  // 获取数据(自动处理 loading、error、缓存)
  const { data: user, isLoading, error, refetch } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    staleTime: 5 * 60 * 1000// 5分钟内认为数据是新鲜的
    retry: 3// 失败自动重试 3 次
  });
  
  // 更新数据(支持乐观更新、自动同步)
  const updateMutation = useMutation({
    mutationFn: updateUser,
    
    // 乐观更新:立即显示效果
    onMutate: async (newData) => {
      await queryClient.cancelQueries({ queryKey: ['user', userId] });
      const previousUser = queryClient.getQueryData(['user', userId]);
      
      queryClient.setQueryData(['user', userId], (old) => ({
        ...old,
        ...newData.data,
      }));
      
      return { previousUser };
    },
    
    // 失败时回滚
    onError: (err, newData, context) => {
      queryClient.setQueryData(['user', userId], context.previousUser);
      alert('fail to update');
    },
    
    // 成功后让缓存失效(确保数据最新)
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['user', userId] });
    },
  });
  
  const handleUpdateName = () => {
    updateMutation.mutate({
      userId,
      data: { name: 'new name' },
    });
  };
  
  if (isLoading) return <div>Loading...</div>;
  if (error) return (
    <div>
      Error: {error.message}
      <button onClick={() => refetch()}>retry</button>
    </div>
  );
  
  return (
    <div>
      <h1>{user.name}</h1>
      <button 
        onClick={handleUpdateName}
        disabled={updateMutation.isPending}
      >
        {updateMutation.isPending ? 'Updating...' : 'Update Name'}
      </button>
    </div>
  );
}

// Header 组件(复用相同的数据)
function Header({ userId }) {
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });
  
  return <div>Welcome, {user?.name}</div>;
}

// Profile 和 Header 同时渲染:
// ✅ 只发一次请求(自动去重)
// ✅ Profile 更新后,Header 自动同步
// ✅ 数据缓存,不会重复请求

对比总结

功能 useState 方案 React Query 方案
代码量 ~40 行 ~30 行
loading/error 手动管理 自动处理 ✅
缓存 自动缓存 ✅
去重请求 否(发多次) 自动去重 ✅
数据同步 手动(很难) 自动同步 ✅
错误重试 自动重试 ✅
乐观更新 需要手写 内置支持 ✅
缓存失效 无概念 自动管理 ✅

核心区别是什么呢?我的理解是:

  • useState:把服务端数据当"普通状态",所有问题都要自己处理
  • React Query:把服务端数据当"远程缓存",这些问题工具已经解决了

这让我开始思考:服务端数据和客户端状态到底有什么本质区别?

理解核心概念:什么是服务端状态?

客户端状态 vs 服务端状态

我觉得理解这个区别很重要。先看客户端状态:

// 客户端状态:数据的"真相源"在客户端

// Modal 开关
const [isOpen, setIsOpen] = useState(false);
setIsOpen(true);  // 立即生效,没有"失败"的可能

// 主题设置
const [theme, setTheme] = useState('dark');
setTheme('light');  // 改了就是改了

// 表单输入
const [name, setName] = useState('');
setName('new name');  // 完全在客户端控制

这些状态的特点是:你说是什么就是什么。没有网络请求,没有失败的可能,刷新页面就丢失。

但服务端状态完全不同:

// 服务端状态:数据的"真相源"在服务器

// 用户信息
const [user, setUser] = useState(null);
fetch('/api/user').then(setUser);

// 问题:
// - 可能请求失败(需要重试)
// - 数据可能过期(服务器上改了)
// - 多处使用时(需要缓存)
// - 刷新页面就丢了(需要重新获取)

关键区别在哪?我整理了这个对比:

维度 客户端状态 服务端状态
真相源 客户端 服务器
持久性 刷新即丢失 服务器持久化
同步问题 无(单一真相) 有(客户端是副本)
失效判断 不需要 需要(数据会过期)
网络请求 有(可能失败)
缓存需求 有(避免重复请求)
适用工具 useState/Zustand React Query/SWR

一个生动的比喻

我想到了一个比喻帮助理解:

客户端状态 = 你的笔记本

  • 你写什么就是什么
  • 想改就改,没有"失败"
  • 丢了就丢了(刷新页面)

服务端状态 = 图书馆的书

  • 你只是借阅,真正的书在图书馆
  • 借之前要去图书馆拿(fetch)
  • 可能借不到(404)
  • 别人可能改了内容(需要同步)
  • 需要判断是否过期(stale)
  • 同一本书可以借给多人(缓存共享)

用 useState 管理"图书馆的书",就像你自己抄了一份。但书的内容变了,你的副本怎么办?这就是为什么需要 React Query。

服务端状态的特殊生命周期

服务端状态有个复杂的生命周期,我试着用流程图表示:

请求数据
   ↓
Loading (加载中)
   ↓
 成功? ←────────┐
   ↓           │
  是   否       │
   ↓    ↓      │
Fresh Error    │
(新鲜) (失败)   │
   ↓    ↓      │
使用  重试? ────┘
数据   ↓
   ↓   否
 过期?  ↓
   ↓  显示错误
  是
   ↓
Stale (过期)
   ↓
后台重新获取
   ↓
Fresh (新鲜)

这个流程里有很多 useState 无法处理的问题:

  • ❌ 失败重试(Error → 重试)
  • ❌ 数据过期判断(Fresh → Stale)
  • ❌ 后台自动刷新(Stale → 重新获取)
  • ❌ 缓存共享(多个组件用同一份数据)

React Query 核心原理

理解了问题,我们再看 React Query 是如何解决的。

Query Key:缓存的唯一标识

React Query 用 Query Key 来标识不同的缓存:

// 环境:React + React Query
// 场景:Query Key 的使用

// 不同的 key = 不同的缓存
useQuery({
  queryKey: ['user'123],  // 用户 123 的数据
  queryFn: () => fetchUser(123),
});

useQuery({
  queryKey: ['user'456],  // 用户 456 的数据
  queryFn: () => fetchUser(456),
});

// 相同的 key = 共享缓存
// 组件 A
useQuery({
  queryKey: ['user'123],
  queryFn: () => fetchUser(123),
});

// 组件 B(使用相同的缓存,不会重复请求)
useQuery({
  queryKey: ['user'123],
  queryFn: () => fetchUser(123),
});

Query Key 的设计很重要。我总结了一些原则:

// ❌ 不好:key 太简单
useQuery({
  queryKey: ['user'],  // 所有用户共享一个缓存?
  queryFn: () => fetchUser(userId),
});

// ✅ 好:key 包含所有影响数据的参数
useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
});

// ✅ 更好:包含查询条件
useQuery({
  queryKey: ['users', { status: 'active', page: 1 }],
  queryFn: () => fetchUsers({ status: 'active', page: 1 }),
});

// 规则:key 变化 = 重新请求

缓存的生命周期

React Query 的缓存有几个状态:

// 环境:React + React Query
// 场景:缓存生命周期配置

const { data } = useQuery({
  queryKey: ['user', userId],
  queryFn: fetchUser,
  
  // 缓存配置
  staleTime: 5 * 60 * 1000// 5分钟内认为数据"新鲜"
  gcTime: 10 * 60 * 1000// 缓存保留 10 分钟
});

// 数据状态流转:
// 1. fresh(新鲜):刚获取,不会重新请求
// 2. stale(过期):超过 staleTime,可能会重新请求
// 3. inactive(无人使用):所有组件都卸载了
// 4. deleted(删除):超过 gcTime,从内存中删除

我举个实际例子帮助理解:

// t=0s: 用户打开 Profile 页
useQuery(['user'123], fetchUser);
// → 发起请求
// → 数据返回,状态:fresh

// t=10s: 用户跳到其他页,又跳回 Profile
useQuery(['user'123], fetchUser);
// → 缓存是 fresh(未超过 5 分钟)
// → 直接用缓存,不发请求 ✅

// t=6min: 用户再次打开 Profile
useQuery(['user'123], fetchUser);
// → 缓存是 stale(超过 5 分钟)
// → 先显示缓存数据(不用等待)
// → 后台重新请求更新 ✅

// t=15min: 缓存已删除
useQuery(['user'123], fetchUser);
// → 重新请求

这种机制让用户体验特别好:大部分时候直接显示缓存,不用等待,同时又能保证数据最新。

自动的后台刷新

React Query 还支持自动刷新策略:

// 环境:React + React Query
// 场景:配置自动刷新

const { data } = useQuery({
  queryKey: ['user', userId],
  queryFn: fetchUser,
  
  // 自动刷新策略
  refetchOnWindowFocus: true// 窗口获得焦点时刷新
  refetchOnReconnect: true// 网络恢复时刷新
  refetchInterval: 30000// 每 30 秒刷新一次
});

// 用户体验:
// 1. 用户切换到别的标签页
// 2. 5分钟后切回来
// 3. React Query 自动刷新数据
// 4. 用户看到的永远是最新数据 ✅

Mutation:修改数据

查询数据之外,我们还需要修改数据。React Query 用 useMutation 处理:

// 环境:React + React Query
// 场景:修改数据并同步缓存

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

function EditProfile({ userId }) {
  const queryClient = useQueryClient();
  
  const mutation = useMutation({
    mutationFn: (newData) => updateUser(userId, newData),
    
    // 成功后的操作
    onSuccess: (updatedUser) => {
      // 方案 1:直接更新缓存(不需要重新请求)
      queryClient.setQueryData(['user', userId], updatedUser);
      
      // 方案 2:让缓存失效,触发重新获取
      queryClient.invalidateQueries({ queryKey: ['user', userId] });
      
      // 方案 3:如果有列表页,也让列表失效
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });
  
  return (
    <button 
      onClick={() => mutation.mutate({ name: '新名字' })}
      disabled={mutation.isPending}
    >
      {mutation.isPending ? '保存中...' : '保存'}
    </button>
  );
}

乐观更新(Optimistic Updates)

乐观更新是我觉得特别酷的功能。用户点击按钮,UI 立即变化,不用等待请求完成:

// 环境:React + React Query
// 场景:点赞功能的乐观更新

const likeMutation = useMutation({
  mutationFn: likePost,
  
  // 请求发出前:立即更新 UI
  onMutate: async (postId) => {
    await queryClient.cancelQueries({ queryKey: ['post', postId] });
    
    const previousPost = queryClient.getQueryData(['post', postId]);
    
    // 乐观更新:立即显示"已点赞"
    queryClient.setQueryData(['post', postId], (old) => ({
      ...old,
      liked: true,
      likeCount: old.likeCount + 1,
    }));
    
    return { previousPost };
  },
  
  // 请求失败:回滚
  onError: (err, postId, context) => {
    queryClient.setQueryData(['post', postId], context.previousPost);
    alert('点赞失败');
  },
  
  // 请求成功:确保数据最新
  onSettled: (data, error, postId) => {
    queryClient.invalidateQueries({ queryKey: ['post', postId] });
  },
});

// 用户体验:
// 1. 点击点赞 → UI 立即变化(乐观更新) ✅
// 2. 后台发送请求
// 3a. 成功 → 保持已点赞状态
// 3b. 失败 → 回滚到未点赞,提示错误

对比 useState 的做法:

// useState:必须等请求成功才更新
const [liked, setLiked] = useState(false);

const handleLike = async () => {
  try {
    await likePost(postId);
    setLiked(true);  // 等请求成功才变化
  } catch (err) {
    alert('失败');
  }
};

// 问题:用户点击 → 等待 → 才看到效果(体验差)

请求去重

React Query 还会自动去重请求:

// 环境:React + React Query
// 场景:多个组件同时需要相同数据

function Header() {
  const { data: user } = useQuery({
    queryKey: ['user'123],
    queryFn: () => fetchUser(123),
  });
  return <div>{user?.name}</div>;
}

function Sidebar() {
  const { data: user } = useQuery({
    queryKey: ['user'123],
    queryFn: () => fetchUser(123),
  });
  return <div>{user?.avatar}</div>;
}

// React Query 的行为:
// 1. Header 组件渲染 → 触发请求
// 2. Sidebar 组件渲染(几乎同时)
// 3. React Query 检测到:同样的 queryKey
// 4. 复用第一个请求的结果
// 5. 只发一次网络请求 ✅

// useState 的行为:
// 1. Header 和 Sidebar 各自独立
// 2. 发两次请求 ❌

SWR:更轻量的选择

说到服务端状态管理,还有一个不得不提的工具:SWR。

SWR 的核心理念

SWR 的名字来自 HTTP 缓存策略:Stale-While-Revalidate(过期时重新验证)。它的 API 非常简洁:

// 环境:React + SWR
// 场景:最简洁的服务端状态管理
// 依赖:npm install swr

import useSWR from 'swr';

const fetcher = (url) => fetch(url).then(r => r.json());

function UserProfile({ userId }) {
  const { data, error, isLoading, mutate } = useSWR(
    `/api/users/${userId}`// key(同时也是 URL)
    fetcher
  );
  
  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error</div>;
  
  return <div>{data.name}</div>;
}

注意到了吗?SWR 的用法更简单,key 直接就是 URL。

与 React Query 对比

// React Query:更明确的配置
const { data } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
  staleTime: 60000,
});

// SWR:更简洁,约定优于配置
const { data } = useSWR(`/api/users/${userId}`, fetcher);

SWR 的主要特性:

// 环境:React + SWR
// 场景:自动重新验证配置

// 自动重新验证
const { data } = useSWR('/api/user', fetcher, {
  refreshInterval: 3000// 每 3 秒刷新
  revalidateOnFocus: true// 窗口焦点时刷新
  revalidateOnReconnect: true// 网络恢复时刷新
});

// Mutation(更新数据)
import { mutate } from 'swr';

const updateUser = async (newData) => {
  // 乐观更新
  mutate('/api/user', newData, false);
  
  // 发送请求
  await fetch('/api/user', {
    method: 'PUT',
    body: JSON.stringify(newData),
  });
  
  // 重新验证
  mutate('/api/user');
};

选择建议

我整理了这个对比表:

特性 React Query SWR
API 复杂度 稍复杂 非常简洁 ✅
功能完整度 更强大 ✅ 够用
文件大小 ~40KB ~12KB ✅
DevTools 强大 ✅ 基础
无限滚动 内置 ✅ 需手动实现
学习曲线 平缓 ✅
适用场景 复杂应用 简单应用 ✅

我的建议是:

选 SWR 的情况:

  • ✅ 项目简单,主要是 GET 请求
  • ✅ 追求代码简洁
  • ✅ 使用 Next.js(同一作者 Vercel)

选 React Query 的情况:

  • ✅ 复杂的数据交互
  • ✅ 需要强大的 DevTools
  • ✅ 需要高级功能(无限滚动、依赖查询)

如果让 AI 帮你写代码,SWR 的简单 API 可能更容易生成正确的代码。但如果你的应用比较复杂,React Query 的灵活性会更有价值。

实战场景

理论说了这么多,我们看几个实际场景。

场景 1:用户中心(数据共享)

问题:用户信息在多处使用,如何避免重复请求?

// 环境:React + React Query
// 场景:多个组件共享用户数据

// Header
function Header() {
  const { data: user } = useQuery({
    queryKey: ['currentUser'],
    queryFn: fetchCurrentUser,
  });
  return <div>Hi, {user?.name}</div>;
}

// Sidebar
function Sidebar() {
  const { data: user } = useQuery({
    queryKey: ['currentUser'],
    queryFn: fetchCurrentUser,
  });
  return <UserAvatar src={user?.avatar} />;
}

// Profile
function Profile() {
  const { data: user } = useQuery({
    queryKey: ['currentUser'],
    queryFn: fetchCurrentUser,
  });
  return <UserDetails user={user} />;
}

// 结果:三个组件,只发一次请求 ✅
// useState 做不到这一点

场景 2:列表 + 详情(数据同步)

问题:详情页修改后,列表页如何同步?

// 环境:React + React Query
// 场景:列表和详情的数据同步

// 列表页
function PostList() {
  const { data: posts } = useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
  });
  
  return posts?.map(post => <PostCard key={post.id} post={post} />);
}

// 详情页
function PostDetail({ postId }) {
  const queryClient = useQueryClient();
  
  const updateMutation = useMutation({
    mutationFn: updatePost,
    onSuccess: () => {
      // 让列表页缓存失效
      queryClient.invalidateQueries({ queryKey: ['posts'] });
      // 列表页自动刷新 ✅
    },
  });
  
  return <button onClick={() => updateMutation.mutate(newData)}>Update</button>;
}

场景 3:搜索(防抖 + 缓存)

问题:搜索框实时搜索,如何优化?

// 环境:React + React Query
// 场景:搜索功能的优化
// 依赖:npm install use-debounce

import { useDebounce } from 'use-debounce';

function SearchBox() {
  const [keyword, setKeyword] = useState('');
  const [debouncedKeyword] = useDebounce(keyword, 500);
  
  const { data: results } = useQuery({
    queryKey: ['search', debouncedKeyword],
    queryFn: () => searchAPI(debouncedKeyword),
    enabled: debouncedKeyword.length > 0,
    staleTime: 5 * 60 * 1000// 缓存 5 分钟
  });
  
  return (
    <div>
      <input
        value={keyword}
        onChange={(e) => setKeyword(e.target.value)}
      />
      {results?.map(item => <SearchResult key={item.id} item={item} />)}
    </div>
  );
}

// 优势:
// - 输入 "react" → 等 500ms → 搜索
// - 再次搜索 "react" → 直接用缓存 ✅
// - 不需要手动管理缓存逻辑

场景 4:无限滚动

React Query 内置了无限滚动的支持:

// 环境:React + React Query
// 场景:无限滚动列表

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

function InfinitePostList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam),
    getNextPageParam: (lastPage) => lastPage.nextPage,
    initialPageParam: 1,
  });
  
  return (
    <div>
      {data?.pages.map(page =>
        page.posts.map(post => <PostCard key={post.id} post={post} />)
      )}
      {hasNextPage && (
        <button 
          onClick={() => fetchNextPage()}
          disabled={isFetchingNextPage}
        >
          {isFetchingNextPage ? 'Loading...' : '加载更多'}
        </button>
      )}
    </div>
  );
}

最佳实践与常见陷阱

在使用 React Query 的过程中,我总结了一些经验。

Query Key 设计原则

// ❌ 不好:key 不够具体
useQuery({
  queryKey: ['user'],
  queryFn: fetchUser,
});

// ✅ 好:包含所有影响数据的参数
useQuery({
  queryKey: ['user', userId, { includeProfile: true }],
  queryFn: () => fetchUser(userId, { includeProfile: true }),
});

避免过度失效缓存

// ❌ 不好:让所有缓存失效
queryClient.invalidateQueries();

// ✅ 好:精确失效
queryClient.invalidateQueries({ queryKey: ['user', userId] });

// ✅ 更好:直接更新缓存(避免重新请求)
queryClient.setQueryData(['user', userId], newData);

常见陷阱

陷阱 1:忘记处理 undefined

// ❌ 错误
const { data } = useQuery({ queryKey: ['user'], queryFn: fetchUser });
return <div>{data.name}</div>;  // 报错!data 初始是 undefined

// ✅ 正确
return <div>{data?.name}</div>;

陷阱 2:缓存时间设置不当

// ❌ 不好:股票价格缓存 1 小时
const { data } = useQuery({
  queryKey: ['stock'],
  queryFn: fetchStock,
  staleTime: 60 * 60 * 1000// 数据会过期的!
});

// ✅ 好:根据数据特性设置
const { data } = useQuery({
  queryKey: ['stock'],
  queryFn: fetchStock,
  staleTime: 1000// 1秒后过期
  refetchInterval: 5000// 每5秒刷新
});

陷阱 3:在循环中使用 useQuery

// ❌ 错误:Hooks 不能在循环中
userIds.map(id => {
  const { data } = useQuery({ queryKey: ['user', id], ... });
});

// ✅ 正确:使用 useQueries
const results = useQueries({
  queries: userIds.map(id => ({
    queryKey: ['user', id],
    queryFn: () => fetchUser(id),
  })),
});

延伸与发散

写到这里,我又产生了一些新的思考:

与 Zustand 的配合

React Query 和 Zustand 各司其职:

// React Query:管理服务端数据
const { data: user } = useQuery({
  queryKey: ['user'],
  queryFn: fetchUser,
});

// Zustand:管理客户端状态
const useUIStore = create((set) => ({
  sidebarOpen: false,
  toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
}));

// 各司其职,不要混用

SSR 中的使用

在 Next.js 等 SSR 框架中,React Query 也能很好地工作:

// 环境:Next.js + React Query
// 场景:服务端预取数据

export async function getServerSideProps({ params }) {
  const queryClient = new QueryClient();
  
  // 服务端预取数据
  await queryClient.prefetchQuery({
    queryKey: ['user', params.id],
    queryFn: () => fetchUser(params.id),
  });
  
  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  };
}

// 客户端直接使用服务端数据
function UserPage({ id }) {
  const { data: user } = useQuery({
    queryKey: ['user', id],
    queryFn: () => fetchUser(id),
  });
  
  return <div>{user.name}</div>;  // 首屏直接显示,无 loading
}

未来的思考

  • React Server Components 会如何改变服务端状态管理?
  • AI 流式输出适合用 React Query 吗?
  • 大量缓存会导致内存问题吗?
  • Suspense 模式的优缺点是什么?

这些问题我还在探索中,如果你有想法,欢迎交流。

小结

这篇文章从一个完整的对比开始,试图帮助你理解"为什么需要专门工具管理服务端数据"。

我的核心收获是:

  • 服务端数据 ≠ 普通状态——它本质上是远程缓存
  • useState 无法处理的问题:缓存、同步、失效、重试
  • React Query/SWR 已经解决了这些问题,不需要重复造轮子

实用建议:

  • 服务端数据 → React Query/SWR
  • 客户端状态 → useState/Zustand
  • 不要混用,分工明确

但我也想保持开放的态度。这些工具是否是"最佳实践"?在不同的项目规模、团队背景下,答案可能不同。重要的是理解背后的原理,而不是盲目跟风。

你之前是怎么管理服务端数据的?踩过哪些坑?准备尝试 React Query 吗?

参考资料

Promise:驾驭 JavaScript 异步编程的艺术

作者 Lee川
2026年2月24日 11:33

引言:从单线程的困境说起

JavaScript 是一门迷人的单线程语言。正如文档1(1.html)中所言:“js 不等, 单线程脚本语言”,它的简单性使得初学者易于上手,但也带来了一个核心挑战:如何在不阻塞主线程的前提下处理耗时任务?

想象一个场景:你在一个繁忙的咖啡馆点单,如果柜台后面只有一位服务员,他必须等待咖啡机慢慢煮好每一杯咖啡才能服务下一位顾客,队伍将会排得无穷无尽。JavaScript的早期正是如此——同步执行的代码就像那位固执的服务员,必须等待当前任务完全完成才能处理下一个。

异步的黎明:回调函数的时代

为了解决这个问题,JavaScript引入了异步编程模式。文档1展示了最基础的异步操作——setTimeout

console.log(1);
setTimeout(function(){
    console.log(2);
},3000)
console.log(3);

执行顺序将是1、3、2。这就是异步的本质:耗时任务被放入"事件循环"(event loop)中,主线程继续执行后续代码,等到适当时候再回来处理异步结果。文件I/O操作也是如此,如文档3(3.js)所示,fs.readFile读取文件时不会阻塞后续的console.log(2)

然而,回调函数带来了新的问题——"回调地狱"。多个异步操作嵌套时,代码会变得难以阅读和维护:

fs.readFile('./a.txt', function(err, data1) {
    if (err) return;
    fs.readFile('./b.txt', function(err, data2) {
        if (err) return;
        fs.readFile('./c.txt', function(err, data3) {
            if (err) return;
            // 三层嵌套后的处理逻辑
        });
    });
});

Promise的诞生:异步任务同步化

ES6引入的Promise正是为了解决这个问题,正如文档2(2.html)标题所言:"异步,变同步"。Promise不是一个具体的异步操作,而是一个管理异步操作的高级工具类

文档2展示了Promise的基本用法:

const p = new Promise((resolve) => {
    setTimeout(function(){
        console.log(2);
        resolve();
    },5000)
})

p.then(() => {
    console.log(3);
})
console.log(4);

这里的执行顺序是1、4、2、3。关键点在于:Promise的executor函数((resolve) => {...})是同步立即执行的,但内部的异步操作(如setTimeout)仍然是异步的。

Promise的核心机制:状态与承诺

Promise的核心思想可以用一个生活比喻来理解:它就像你在餐厅点餐后拿到的一个取餐号码。餐厅(JavaScript引擎)承诺(Promise)会在餐点准备好时通知你,而你不需要在柜台前干等。

Promise有三种状态:

  • pending(等待中):异步操作尚未完成
  • fulfilled(已完成):异步操作成功完成,调用resolve()
  • rejected(已拒绝):异步操作失败,调用reject()

文档3(3.js)展示了完整的Promise错误处理模式:

const p = new Promise((resolve, reject) => {
    console.log(3);
    fs.readFile('./a.txt', function(err, data){
        if(err){
            reject(err);  // 失败时调用reject
            return;
        }
        resolve(data.toString());  // 成功时调用resolve
    })
})

p.then((data) => {
    console.log(data,'////////');
}).catch((err) => {
    console.log(err,'读取文件失败');
})

Promise的威力:链式调用与组合

Promise的真正强大之处在于其链式调用能力。文档4(4.html)展示了如何使用Promise处理网络请求:

fetch('https://api.github.com/orgs/lemoncode/members')
    .then(data => data.json())
    .then(res => {
        document.getElementById('members').innerHTML = 
            res.map(item => `<li>${item.login}</li>`).join('');
    })

这里的fetch返回一个Promise,.then(data => data.json())处理响应体,再下一个.then处理解析后的JSON数据。每个.then返回的值会成为下一个.then的参数,或者可以返回一个新的Promise。

Promise在现代开发中的应用

文档6(readme.md)总结了Promise的核心价值:"es6 提供的异步变同步的高级工具类"。它让异步代码拥有了类似同步代码的清晰结构:

  1. 错误处理集中化:通过.catch()统一处理所有错误,告别每个回调都判断if (err)的时代
  2. 代码可读性提升:链式调用让异步流程一目了然
  3. 组合能力强大Promise.all()可以并行执行多个异步操作,Promise.race()可以竞速获取最快结果

从Promise到async/await:异步编程的进化

值得一提的是,Promise为更现代的async/await语法奠定了基础。async/await让异步代码看起来和同步代码几乎一样,进一步降低了异步编程的心智负担:

async function getMembers() {
    try {
        const response = await fetch('https://api.github.com/orgs/lemoncode/members');
        const members = await response.json();
        document.getElementById('members').innerHTML = 
            members.map(item => `<li>${item.login}</li>`).join('');
    } catch (error) {
        console.error('获取成员失败:', error);
    }
}

结语:掌握异步,驾驭现代Web开发

Promise不仅仅是ES6的一个新特性,它代表了JavaScript异步编程范式的根本转变。从文档中的基础示例到现实世界中的复杂应用,Promise让开发者能够以更优雅、更健壮的方式处理异步操作。

正如文档6所言,JavaScript需要"负责事件、页面更新",在单线程的限制下,Promise提供了一种机制,让耗时任务不再阻塞用户界面,同时保持代码的清晰和可维护性。掌握Promise,就是掌握了现代JavaScript异步编程的核心技艺。

无论是处理文件I/O(如文档3)、定时任务(如文档2)还是网络请求(如文档4),Promise都提供了一个统一、强大的抽象层。在异步无处不在的现代Web开发中,Promise已成为不可或缺的工具,是每个JavaScript开发者必须掌握的核心概念。

echarts图表联动

2026年2月24日 11:27

插件版本

"vue": "^2.6.11",
"vue-echarts": "^5.0.0-beta.0",

vue组件

mounted() {
  this.initChart1()
  this.initChart2()

  // 调用图表联动初始化方法
  initChartLinkage.call(this)
},

beforeDestroy() {
  clearLinkEvent.call(this)
},

实现代码

// 初始化图表联动功能
export function initChartLinkage() {
  const self = this
  // 等待图表渲染完成
  this.$nextTick(() => {
    const chart1 = this.$refs.chart1 && this.$refs.chart1.chart
    const chart2 = this.$refs.chart2 && this.$refs.chart2.chart

    if (!chart1 || !chart2) return

    // 为chart1添加鼠标事件监听
    chart1.getZr().on('mousemove', function(event) {
      let myIdx = self.getDataIdx(chart1, event);
      let myText = self.xData[myIdx];
      let otherIdx = self.toggleXAxis(self.chart2Option, myText);
      chart2.dispatchAction({
        type: 'showTip',
        seriesIndex: self.seriesIdx.serIdx2,
        dataIndex: otherIdx
      })
    })
    chart2.getZr().on('mousemove', function(event) {
      let myIdx = self.getDataIdx(chart2, event);
      let myText = self.xData[myIdx]
      let otherIdx = self.toggleXAxis(self.chart1Option, myText);
      chart1.dispatchAction({
        type: 'showTip',
        seriesIndex: self.seriesIdx.serIdx1,
        dataIndex: otherIdx
      })
    })

    // 当鼠标离开图表时,隐藏两个图表的tooltip
    chart1.getZr().on('mouseout', () => {
      chart2.dispatchAction({ type: 'hideTip' })
    })
    chart2.getZr().on('mouseout', () => {
      chart1.dispatchAction({ type: 'hideTip' })
    })

    // 存储图表实例引用,以便在组件销毁时移除事件监听
    this.linkedCharts = [chart1.getZr(), chart2.getZr()]

    // 监听dataZoom联动
    function bindDataZoomSync(chartA, chartB) {
      chartA.on('datazoom', function(params) {
        // 防止事件循环触发
        if (this.isUpdating) return

        const dataZoom = params.batch ? params.batch[0] : params
        const start = dataZoom.start
        const end = dataZoom.end

        // 更新图表B
        chartB.setOption({
          dataZoom: [{
            start: start,
            end: end
          }]
        }, false, false) // 不触发事件

        // 标记图表B正在更新
        chartB.isUpdating = true
        setTimeout(() => {
          chartB.isUpdating = false
        }, 10)
      })

      // 双向绑定
      chartB.on('datazoom', function(params) {
        if (this.isUpdating) return
        const dataZoom = params.batch ? params.batch[0] : params
        const start = dataZoom.start
        const end = dataZoom.end

        chartA.setOption({
          dataZoom: [{
            start: start,
            end: end
          }]
        }, false, false)

        chartA.isUpdating = true
        setTimeout(() => {
          chartA.isUpdating = false
        }, 10)
      })
    }
    // 调用联动函数
    bindDataZoomSync(chart1, chart2)
  })
}

// 清理图表事件监听
export function clearLinkEvent() {
  if (this.linkedCharts && this.linkedCharts.length) {
    const [chart1, chart2] = this.linkedCharts

    if (chart1) {
      chart1.off('mouseover')
      chart1.off('mouseout')
    }

    if (chart2) {
      chart2.off('mouseover')
      chart2.off('mouseout')
    }

    this.linkedCharts = null
  }
}

2026 年 把网页交互的主控权拿回前端手中 🚀

作者 webkubor
2026年2月24日 11:18

别怕被 AI 爬虫洗稿!详解 navigator.modelContext:把网页交互的主控权拿回前端手中 🚀

一、 现状:Agent 乱撞,开发者遭殃

兄弟们,现在的 AI Agent(比如基于 Playwright 的各种自动化代理)访问网页时简直就是“盲人摸象”。它们靠解析 DOM、猜按钮文字、强行 OCR 来完成任务。 结果是什么?

  • 效率极低:Agent 在你的页面上疯狂试错,消耗大量 Token。
  • 稳定性差:你改个 CSS 类名,Agent 的脚本就挂了。
  • 安全隐患:Agent 可能会误触一些高危操作(比如在不该下单的时候点了支付)。

WebMCP 的出现,就是为了给网页装上一套“标准的神经接口”,而 navigator.modelContext 就是前端开发者手中的那把“钥匙”。

二、 WebMCP:是“赋能”而非“强加”

很多同学担心浏览器引入 WebMCP 会导致网站隐私泄露或被 AI 强行接管。 错!大错特错!

WebMCP 的核心哲学是:完全的开发者主权。浏览器和标准本身不会强制任何网站启用这项能力。是否支持 navigator.modelContext,完全由你(网站开发者)说了算。

三、 前端开发者的三项核心自主权

1. 工具注册的主动权(Registration Control) 只有当你主动编写代码注册工具时,AI 代理才能通过 WebMCP 与你的页面交互。如果你不做任何配置,你的网页在 AI 眼里依然是那个传统的、只能靠 DOM 解析的黑盒。

一句话总结:你不给,AI 绝对拿不走。

2. 权限颗粒度的极致控制(Granular Permissions) 你可以决定哪些业务逻辑开放给 Agent。

  • 允许:“查询商品库存”、“获取技术文档摘要”。
  • 禁止:“支付下单”、“修改用户敏感信息”。 你甚至可以设置调用门槛,比如“必须由用户手动确认”或“限制单位时间调用频率”。

3. 优雅降级的兼容性(Graceful Degradation) 作为前端老鸟,我们最关心的就是兼容。WebMCP 是一套可选工具,通过简单的判断即可实现完美兼容:

if ('modelContext' in navigator) {
  // 欢迎来到 Agent 友好型网页时代
  // registerModelTools(...);
} else {
  // 依然是那个稳健的传统网页
}

这种设计确保了即便在不支持该 API 的老版本浏览器里,网页功能依然丝滑,绝无副作用。

四、 实战:如何定义你的“Agent 适配层”?

作为 Node.js/TypeScript 专家,我们只需几行声明式代码,就能让 Agent 瞬间读懂网页的深度逻辑:

// 示例:为一个低代码编辑器注册“AI 操作指令”
if (navigator.modelContext) {
  navigator.modelContext.registerTool({
    name: "add_component",
    description: "在当前画布中添加一个 UI 组件",
    inputSchema: {
      type: "object",
      properties: {
        type: { type: "string", enum: ["button", "input", "card"] },
        label: { type: "string" }
      }
    },
    // 执行逻辑由你完全控制
    handler: async (args) => {
      const success = await myAppCanvas.insert(args.type, args.label);
      return success ? "添加成功" : "位置重叠,请换个地方";
    }
  });
}
五、 结语:前端工程化的下一个 10 年

以前我们的工作是“为人写 UI”,未来我们的工作将增加一项:“为 Agent 写接口”。

navigator.modelContext 不是浏览器的越权,而是前端开发者在 AI 时代的定海神针。它让我们能够以最安全、最高效、最优雅的方式,定义网页与人工智能的边界。

Agent 的浪潮已经拍到岸边了,你是打算被它淹没,还是主动拿起 navigator.modelContext 去驾驭它?


#WebMCP #navigator.modelContext #前端开发 #TypeScript #AI_Agent #浏览器标准 #掘金技术

深入解析 React 中的 useCallback:原理、场景与最佳实践

作者 QLuckyStar
2026年2月24日 11:04

一、useCallback 的核心价值

useCallback 是 React 提供的性能优化 Hook,其核心作用是缓存函数引用,避免因函数重新创建导致的不必要子组件重渲染或重复订阅。在 React 的函数组件模型中,每次渲染都会重新执行组件函数体,导致内部定义的函数生成新引用。若将这类函数作为 prop 传递给子组件(尤其是使用 React.memo 优化的子组件),即使子组件逻辑未变,也会因 prop 引用变化触发重渲染。

关键特性

  • 引用稳定性:依赖项未变化时返回相同函数引用
  • 依赖追踪:通过依赖数组控制缓存失效条件
  • 等价语法useCallback(fn, deps) ≡ useMemo(() => fn, deps)

二、底层原理与实现逻辑

1. 闭包与依赖管理

useCallback 基于闭包机制存储函数实例和依赖数组。每次渲染时:

  1. 比较新旧依赖数组的深度相等性
  2. 若依赖变化则创建新函数并更新缓存
  3. 否则返回缓存的旧函数

伪代码实现:

function useCallback(callback, deps) {
  const hook = currentHook();
  if (!depsEqual(hook.deps, deps)) {
    hook.memoizedCallback = callback;
    hook.memoizedDeps = deps;
  }
  return hook.memoizedCallback;
}

2. 与 React 渲染机制的协同

  • 虚拟 DOM 对比:React 通过浅比较 props 判断是否需要更新子组件
  • 优化场景:当子组件使用 React.memo 时,useCallback 可避免因父组件渲染导致的子组件无效更新

三、典型使用场景

1. 跨组件传递回调函数

问题场景:父组件频繁渲染时,内联函数导致子组件重复渲染

// 未优化版本
const Parent = () => {
  const [count, setCount] = useState(0);
  return <Child onClick={() => console.log(count)} />;
};

// 优化后版本
const Parent = () => {
  const [count, setCount] = useState(0);
  const handleClick = useCallback(() => {
    console.log(count);
  }, [count]);
  return <Child onClick={handleClick} />;
};

通过 useCallback 缓存 handleClick,确保子组件仅在 count 变化时重新渲染。

2. 作为 Hook 的依赖项

在 useEffectuseMemo 等需要函数引用的场景中保持稳定性:

const fetchData = useCallback(async () => {
  const res = await fetch(url);
  return res.json();
}, [url]);

useEffect(() => {
  fetchData();
}, [fetchData]); // 依赖项稳定避免无限循环

3. 高阶函数与回调链

处理需要稳定引用的复杂函数:

const handleSave = useCallback(
  (data) => api.save(data).then(onSuccess),
  [onSuccess] // 确保 onSuccess 引用稳定
);

四、关键注意事项

1. 依赖数组管理

  • 必须完整声明:遗漏依赖会导致闭包陷阱(旧值捕获)
  • 避免过度优化:简单函数或非渲染相关函数无需缓存
  • 函数参数不影响缓存:参数变化不会触发 useCallback 重新创建

2. 性能考量

  • 创建开销:依赖项比较和缓存存储带来轻微性能成本
  • 适用场景:仅在函数传递导致子组件重渲染时使用
  • 替代方案:小组件直接重渲染可能更高效

五、常见误区与反模式

误区描述 正确做法
"所有函数都应包裹" 仅对需要稳定引用的函数使用
"空依赖数组安全" 必须包含函数体内所有响应式值
"优化所有渲染" 先通过 Profiler 确认性能瓶颈

错误示例

// 闭包陷阱:count 始终为初始值
const increment = useCallback(() => {
  setCount(count + 1); // 捕获初始 count 值
}, []);

六、与 useMemo 的对比

维度 useCallback useMemo
缓存对象 函数引用 计算结果
语法等价性 useCallback(fn, deps) useMemo(() => fn, deps)
典型场景 回调函数传递 复杂计算结果缓存
性能关注点 函数创建开销 计算耗时

七、进阶应用模式

1. 自定义 Hook 中的稳定回调

function useFetch(url) {
  const fetchData = useCallback(async () => {
    const res = await fetch(url);
    return res.json();
  }, [url]);

  useEffect(() => {
    fetchData();
  }, [fetchData]);
}

2. 与 Context API 结合

避免 Context 值变化导致子组件不必要更新:

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>
  );
};

八、总结与最佳实践

核心原则

  1. 必要性原则:仅在需要稳定引用时使用
  2. 最小化依赖:精确控制依赖数组范围
  3. 性能验证:通过 React DevTools 分析渲染开销

应用场景优先级

  1. 传递回调给 React.memo 子组件
  2. 作为 useEffect/useLayoutEffect 依赖
  3. 需要稳定引用的自定义 Hook

性能优化黄金法则:先确保代码正确性,再通过性能分析工具定位瓶颈,最后针对性优化。

通过合理运用 useCallback,开发者可以在保持代码可维护性的同时,显著提升 React 应用的渲染性能。记住:优化永远是为了解决问题,而不是为了优化而优化。

疯狂点赞效果

作者 大时光
2026年2月24日 10:47
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>直播间点赞动画无拖影版</title>
    <style>
        body {
            margin: 0;
            padding: 0;
            background: linear-gradient(135deg, #1e3c72, #2a5298);
            font-family: Arial, sans-serif;
            overflow: hidden;
            height: 100vh;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
        }
        
        .container {
            position: relative;
            width: 100%;
            height: 100vh;
            overflow: hidden;
        }
        
        #canvas {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: transparent;
        }
        
        .controls {
            position: absolute;
            top: 20px;
            left: 50%;
            transform: translateX(-50%);
            z-index: 100;
        }
        
        .like-button {
            background: linear-gradient(to right, #ff416c, #ff4b2b);
            color: white;
            border: none;
            padding: 15px 30px;
            font-size: 18px;
            border-radius: 50px;
            cursor: pointer;
            box-shadow: 0 4px 15px rgba(0,0,0,0.2);
            transition: all 0.3s ease;
        }
        
        .like-button:hover {
            transform: scale(1.05);
            box-shadow: 0 6px 20px rgba(0,0,0,0.3);
        }
        
        .like-button:active {
            transform: scale(0.95);
        }
        
        .counter {
            position: absolute;
            top: 20px;
            right: 20px;
            background: rgba(0,0,0,0.6);
            color: white;
            padding: 10px 15px;
            border-radius: 20px;
            font-size: 16px;
            z-index: 100;
        }
        
        .instructions {
            position: absolute;
            bottom: 20px;
            color: white;
            text-align: center;
            width: 100%;
            font-size: 14px;
            opacity: 0.7;
        }
    </style>
</head>
<body>
    <div class="container">
        <canvas id="canvas"></canvas>
        
        <div class="controls">
            <button class="like-button" id="likeButton">👍 点赞</button>
        </div>
        
        <div class="counter">
            点赞数: <span id="likeCount">0</span>
        </div>
        
        <div class="instructions">
            点击按钮生成点赞动画 | 表情从底部升起,逐渐消失
        </div>
    </div>

    <script>
        class LikeAnimation {
            constructor() {
                this.canvas = document.getElementById('canvas');
                this.ctx = this.canvas.getContext('2d');
                this.likes = [];
                this.likeCount = 0;
                this.emojis = ['👍', '❤️', '🔥', '✨', '🎉', '💯', '😍', '🤩', '🥰', '👏'];
                this.maxLikes = 50; // 限制最大点赞数量
                
                this.init();
                this.setupEventListeners();
                this.animate();
            }
            
            init() {
                this.resizeCanvas();
                window.addEventListener('resize', () => this.resizeCanvas());
            }
            
            resizeCanvas() {
                this.canvas.width = window.innerWidth;
                this.canvas.height = window.innerHeight;
            }
            
            setupEventListeners() {
                const button = document.getElementById('likeButton');
                
                // 使用节流,防止快速点击
                let isThrottled = false;
                button.addEventListener('click', () => {
                    if (!isThrottled) {
                        this.createLike();
                        isThrottled = true;
                        setTimeout(() => {
                            isThrottled = false;
                        }, 100); // 100ms节流时间
                    }
                });
            }
            
            createLike() {
                // 每次点击中心点偏移 5-10 像素
                const baseCenterX = this.canvas.width / 2;
                const offsetX = (Math.random() - 0.5) * 20; // -10 到 10 像素偏移
                const centerX = baseCenterX + offsetX;
                
                const size = Math.random() * 20 + 30; // 30-50px
                
                // 如果超过最大数量,移除最老的一个
                if (this.likes.length >= this.maxLikes) {
                    this.likes.shift();
                }
                
                const like = {
                    x: centerX,
                    y: this.canvas.height, // 从底部开始
                    baseX: centerX, // 基础X位置用于摇摆计算
                    size: 0, // 初始大小为0
                    targetSize: size,
                    opacity: 1,
                    emoji: this.emojis[Math.floor(Math.random() * this.emojis.length)],
                    speed: 2, // 固定速度
                    sway: Math.random() * 0.5 + 0.2, // 摇摆幅度
                    swaySpeed: Math.random() * 0.05 + 0.02, // 摇摆速度
                    swayOffset: Math.random() * Math.PI * 2, // 摇摆偏移
                    startTime: Date.now()
                };
                
                this.likes.push(like);
                this.likeCount++;
                document.getElementById('likeCount').textContent = this.likeCount;
            }
            
            updateLikes() {
                const now = Date.now();
                
                for (let i = this.likes.length - 1; i >= 0; i--) {
                    const like = this.likes[i];
                    const elapsed = (now - like.startTime) / 1000; // 秒为单位
                    
                    // 更新垂直位置
                    like.y = this.canvas.height - (elapsed * like.speed * 100);
                    
                    // 计算进度 (0-1)
                    const progress = Math.max(0, Math.min(1, 1 - (like.y / this.canvas.height)));
                    
                    // 计算摇摆效果(使用sin函数,基于基础X位置)
                    const swayValue = Math.sin(elapsed * like.swaySpeed * 100 + like.swayOffset) * like.sway * 100;
                    like.x = like.baseX + swayValue;
                    
                    // 根据进度调整大小和透明度
                    if (progress <= 0.2) {
                        // 0-20%阶段:大小从0变到1,透明度保持1
                        like.size = like.targetSize * (progress / 0.2);
                        like.opacity = 1;
                    } else {
                        like.size = like.targetSize;
                        
                        // 20-100%阶段:透明度从1变到0
                        if (progress > 0.2) {
                            like.opacity = 1 - ((progress - 0.2) / 0.8);
                        } else {
                            like.opacity = 1;
                        }
                    }
                    
                    // 移除超出屏幕的元素
                    if (like.y < -50 || progress >= 1) {
                        this.likes.splice(i, 1);
                    }
                }
            }
            
            renderLikes() {
                this.likes.forEach(like => {
                    if (like.opacity > 0 && like.size > 0) {
                        this.ctx.save();
                        this.ctx.globalAlpha = like.opacity;
                        
                        // 设置字体大小
                        this.ctx.font = `${like.size}px Arial`;
                        this.ctx.textAlign = 'center';
                        this.ctx.textBaseline = 'middle';
                        
                        // 绘制emoji
                        this.ctx.fillText(like.emoji, like.x, like.y);
                        
                        this.ctx.restore();
                    }
                });
            }
            
            animate() {
                // 完全清空画布
                this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
                
                // 更新和渲染点赞动画
                this.updateLikes();
                this.renderLikes();
                
                // 继续动画循环
                requestAnimationFrame(() => this.animate());
            }
        }
        
        // 初始化点赞动画
        window.addEventListener('DOMContentLoaded', () => {
            new LikeAnimation();
        });
    </script>
</body>
</html>

代码逻辑解析

1. 整体架构

这个点赞动画使用了面向对象编程的方式,创建了一个 LikeAnimation 类来管理所有动画相关的功能。

2. 核心思路

代码的核心是游戏循环模式,分为三个主要部分:

  • 创建:点击按钮时生成新的点赞表情
  • 更新:每一帧更新所有表情的位置、大小、透明度
  • 渲染:在Canvas上绘制所有表情

3. 详细实现逻辑

A. 初始化阶段

constructor() {
  this.canvas = document.getElementById('canvas');
  this.ctx = this.canvas.getContext('2d');
  this.likes = []; // 存储所有正在动画的表情
  this.likeCount = 0; // 点赞计数
  this.emojis = ['👍', '❤️', '🔥', '✨', '🎉', '💯', '😍', '🤩', '🥰', '👏'];
}
  • 获取Canvas画布和绘图上下文
  • 创建数组存储所有正在运动的表情
  • 定义可用的表情符号

B. 点赞创建逻辑

createLike() {
  // 1. 计算随机偏移的起始位置
  const baseCenterX = this.canvas.width / 2;
  const offsetX = (Math.random() - 0.5) * 20; // -10到10像素的随机偏移
  const centerX = baseCenterX + offsetX;
  
  // 2. 创建表情对象
  const like = {
    x: centerX,           // 水平位置
    y: this.canvas.height, // 垂直位置(从底部开始)
    baseX: centerX,       // 基础位置用于摇摆计算
    size: 0,              // 初始大小为0
    targetSize: size,     // 目标大小
    opacity: 1,           // 初始透明度为1
    // ...其他属性
  };
  
  // 3. 添加到数组中
  this.likes.push(like);
}

C. 动画更新逻辑

这是最核心的部分,每次调用都会更新所有表情的状态:

updateLikes() {
  const now = Date.now(); // 获取当前时间
  
  for (let i = this.likes.length - 1; i >= 0; i--) {
    const like = this.likes[i];
    const elapsed = (now - like.startTime) / 1000; // 计算经过的秒数
    
    // 1. 更新垂直位置(向上移动)
    like.y = this.canvas.height - (elapsed * like.speed * 100);
    
    // 2. 计算移动进度(0到1之间)
    const progress = Math.max(0, Math.min(1, 1 - (like.y / this.canvas.height)));
    
    // 3. 计算摇摆效果(使用sin函数实现左右摆动)
    const swayValue = Math.sin(elapsed * like.swaySpeed * 100 + like.swayOffset) * like.sway * 100;
    like.x = like.baseX + swayValue;
    
    // 4. 根据进度调整大小和透明度
    if (progress <= 0.2) {
      // 0-20%阶段:大小从0变到目标大小,透明度保持1
      like.size = like.targetSize * (progress / 0.2);
      like.opacity = 1;
    } else {
      like.size = like.targetSize;
      // 20-100%阶段:透明度从1变到0
      like.opacity = 1 - ((progress - 0.2) / 0.8);
    }
    
    // 5. 移除超出屏幕的表情
    if (like.y < -50 || progress >= 1) {
      this.likes.splice(i, 1);
    }
  }
}

D. 渲染逻辑

renderLikes() {
  this.likes.forEach(like => {
    if (like.opacity > 0 && like.size > 0) {
      // 保存当前绘图状态
      this.ctx.save();
      this.ctx.globalAlpha = like.opacity; // 设置透明度
      
      // 设置字体大小
      this.ctx.font = `${like.size}px Arial`;
      this.ctx.textAlign = 'center';
      this.ctx.textBaseline = 'middle';
      
      // 绘制表情
      this.ctx.fillText(like.emoji, like.x, like.y);
      
      // 恢复绘图状态
      this.ctx.restore();
    }
  });
}

4. 动画效果实现

摇摆效果

使用数学中的正弦函数实现左右摆动:

const swayValue = Math.sin(elapsed * frequency + offset) * amplitude;
like.x = baseX + swayValue;
  • Math.sin() 产生 -1 到 1 之间的周期性变化
  • 乘以幅度值实现左右摆动
  • 随时间推移产生连续摆动效果

透明度变化

// 0-20%阶段:透明度保持1
if (progress <= 0.2) {
  like.opacity = 1;
}
// 20-100%阶段:透明度从1变到0
else {
  like.opacity = 1 - ((progress - 0.2) / 0.8);
}

大小变化

// 0-20%阶段:大小从0放大到目标大小
like.size = like.targetSize * (progress / 0.2);

5. 性能优化措施

  1. 节流机制:防止用户快速点击导致性能问题
  2. 数量限制:最多只保留50个表情,超出时移除最老的
  3. 及时清理:表情移出屏幕后立即从数组中删除
  4. 条件渲染:只绘制可见的表情(opacity > 0)

6. 游戏循环

animate() {
  // 1. 清空画布
  this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
  
  // 2. 更新所有表情状态
  this.updateLikes();
  
  // 3. 渲染所有表情
  this.renderLikes();
  
  // 4. 请求下一帧
  requestAnimationFrame(() => this.animate());
}

这个循环每秒执行约60次,创造出流畅的动画效果。

总的来说,这个实现将复杂的动画分解为简单的步骤:创建 → 更新 → 渲染 → 循环,通过数学计算精确控制每个表情的运动轨迹和视觉效果。

在这里插入图片描述

Nuxt 4.2 + Tauri 2 接入指南把 Vue 元框架“静态化”后装进桌面/移动端

作者 HelloReader
2026年2月24日 10:44

1、Checklist(为什么要这么做)

  • 启用 SSG / SPA:ssr: false
    Tauri 不支持依赖服务端的方案,因此 Nuxt 需要禁用 SSR。(Tauri)
  • frontendDist 使用默认 ../dist
    Nuxt 静态产物放到 dist,Tauri 打包时把它当作前端资源目录。(Tauri)
  • 构建走静态生成:generate(对应 nuxt generate / nuxi generate
    generate 会预渲染路由并输出可直接静态部署的文件。(Tauri)
  • (可选)关闭 Nuxt telemetry
    可用 telemetry: false 或环境变量等方式关闭。(Tauri)

2、Tauri 侧配置:src-tauri/tauri.conf.json

这段配置的意义是:

  • tauri dev 前先启动 Nuxt dev server,并加载 devUrl
  • tauri build 前先执行 generate 产出 dist/,再把 frontendDist 打包进应用
{
  "build": {
    "beforeDevCommand": "npm run dev",
    "beforeBuildCommand": "npm run generate",
    "devUrl": "http://localhost:3000",
    "frontendDist": "../dist"
  }
}

(Tauri)

3、Nuxt 侧配置:nuxt.config.ts(重点是移动端与稳定性)

官方推荐的 Nuxt 配置大致如下(你可以直接拷贝再按项目改动):

export default defineNuxtConfig({
  compatibilityDate: '2025-05-15',

  // (optional) Enable the Nuxt devtools
  devtools: { enabled: true },

  // Enable SSG / SPA
  ssr: false,

  // Enables the dev server to be discoverable by other devices (useful for iOS physical devices)
  devServer: { host: '0' },

  vite: {
    // Better support for Tauri CLI output
    clearScreen: false,

    // Enable environment variables (include TAURI_*)
    envPrefix: ['VITE_', 'TAURI_'],

    server: {
      // Tauri requires a consistent port
      strictPort: true,
    },
  },

  // Avoids EMFILE: too many open files (watch)
  ignore: ['**/src-tauri/**'],
})

这些字段各自解决的问题:

  • ssr: false:让 Nuxt 走纯客户端渲染/静态路线,匹配 Tauri 的“静态宿主”模型。(Tauri)
  • devServer.host: '0':让 dev server 可被同一局域网设备发现,真机调试(尤其 iOS 物理机)更顺畅。(Tauri)
  • vite.server.strictPort: true:端口固定,避免 Tauri 以为是 3000 但 Nuxt 自动换端口导致加载失败。(Tauri)
  • ignore: ['**/src-tauri/**']:减少 watch 造成的文件句柄过多(EMFILE)问题。(Tauri)
  • envPrefix:把 TAURI_ 前缀变量也注入到前端环境变量体系里,方便移动端/工具链联动。(Tauri)

4、package.json scripts(确保 Tauri 能调用到)

至少保证有 devgenerate(你的命令也可以用 pnpm/yarn/deno 版本,和 tauri.conf.json 对齐即可):

{
  "scripts": {
    "dev": "nuxt dev",
    "generate": "nuxt generate",
    "build": "nuxt build"
  }
}

nuxt generate 会把路由预渲染并输出静态文件,适合让 Tauri 打包。(Nuxt)

5、一键启动与构建(你最终只需要两条命令)

  • 开发运行:
cargo tauri dev
  • 打包构建:
cargo tauri build

Tauri CLI 会在构建时使用 build.beforeBuildCommand 先生成前端产物,再读取 build.frontendDist 将其打包进应用。(Tauri)

6、常见问题快速定位

  • 运行时窗口白屏:优先确认 Nuxt 端口是否仍是 3000(strictPort),以及 tauri.conf.jsondevUrl/frontendDist 路径是否正确。(Tauri)
  • Nuxt HMR/WebSocket 异常:通常和 dev server 绑定地址、网络环境有关,移动端尤其明显;确保 devServer.host 配好,并尽量避免端口漂移。(Tauri)

前端真神器!RD280U 让我写码效率暴涨!

2026年2月24日 10:41

做前端开发很多年了,发现显示器是影响工作效率最大的因数。

相对于其他程序员,前端面对的开发场景要更复杂:

  • IDE、浏览器、开发者工具、UI设计稿、API 接口,五个面板同时打开的痛苦谁懂
  • 动辄上百行的 HTML
  • 密密麻麻的 CSS 样式
  • 还要处理浅色、深色模式
  • 来来回回不停的切换面板
  • 频繁的滚动查看

这一顿操作下来,眼累,手累,心更累,一块好的屏幕对前端开发来说真的太重要了。

早期我把显示器从 2K 升级到 4K,以为开发体验能大大改善,但其实改变是只是显示的细腻,整个工作的状态还是以前的方式。

直到新年,我把显示器换成了明基专业编程显示器RD280U,开发体验得到了质的飞跃。

相对于普通显示器,它的优势非常明显:

  • 编程模式
  • 夜间护眼
  • 智慧蓝光
  • 3:2
  • 1200:1 高对比度
  • 4K + 抗反射面板

3:2 黄金屏比,垂直视野多出一半

平时我们写代码,最烦人的就是频繁滚动鼠标找逻辑,而前端面对的是。

  • vue 开发中混合的 html、js、css 代码
  • 超长的 saas/less 文件
  • 复杂逻辑下的多层引用的模块
  • 开发者工具中的 DOM 树、CSS 样式
  • vue devtools、react devtools 中多个面板的信息流

前端对横向信息的要求要更高。

明基RD280U3:2 比例屏幕就是解决这个痛点的,用更少的滚动,查看更多的代码

对比传统的 16:9 的屏幕:

  • 单个屏幕显示的代码行数更多
  • 一个 .vue 文件能同时看到 html/js/css 代码
  • 查看函数之间的调用更方便
  • 保持思维和操作的连贯性
  • 减少鼠标滚动次数,省时省力

阅读源码、看文档都轻松了不少。

首创“编码专用色彩模式”,语法高亮更清晰

普通显示器的色彩是为视频或游戏设计的,而明基RD280U的核心之一就是行业内首创了编程专用色彩模式

这不是简单的切换主题,只是调整颜色色值而已,而是通过硬件级的智能调节色彩参数,加强语法高亮效果,让代码更容易识别。

亮色编程模式

除了在 IDE 中编写代码,我们还需要:

  • 查看文档,对接后端接口
  • 检索资料,寻找技术方案
  • 对照UI,一比一还原设计稿
  • 熟悉项目,回看产品文档

那些普通的显示器:

  • 白色背景下,强光刺激
  • 色彩普通
  • 代码关键字权重一样,不直观

这些场景下,亮色编程模式会做到:

  • 缓解“眩光感”
  • 模拟纸质书读感
  • 色彩区分度增强

非常适合在采光良好的办公室,查看白底文档时使用。

深色编程模式

写代码时,程序员一般都喜欢深色背景,容易进入心流状态,专注于代码本身。

对于普通的显示器:

  • 背景往往不是纯黑,发灰
  • 文字、字符边缘发散
  • 暗部细节不明显,观感差

深色编程模式能做到:

  • 提升色彩纯度
  • 消除黑色背景的“灰感”
  • 防止暗部细节丢失

最终呈现的是:专门锁定的高频高亮的语法色彩、通过硬件算法锐化边缘并提升饱和度。这样用起来会感觉,代码逻辑块之间的边界感更强,不需要费力辨认,扫一眼就能根据颜色抓到逻辑。

切换主题

硬件级的支持已经有了,但切换工作场景的时候,总不能频繁设置显示器的参数把,而且一般显示器修改设置的方式都很繁琐,好几个按钮放在显示器下边框位置,操作起来真是费劲。

明基RD280U最让我喜欢的一个小细节就是它在 logo 下方的面板上加了一个专用编程触键一触就能进入编程模式,相当方便

MoonHalo 智慧光环,深夜沉浸式氛围编程

很多前端人都是“夜猫子”,夜深人静,把房间的灯关上,打开电脑写代码,

RD280U 自带了 MoonHalo 智慧背光设计 + 夜间保护模式,很适合这种时候:

  • 背光环补充环境光,减少眩光
  • 屏幕显示更柔和,观感更舒适
  • 自动调光,轻松省心
  • 夜间护眼模式,减少视觉伤害
  • 智慧蓝光,自动开启

这种氛围下,很容易进入“心流”状态。

强大的软硬件协同与接口

RD280U 的接口丰富:

  • HDMI、DP1.4、Type-B
  • 90W 全功能 Type-C
  • 3 个 USB-A 接口
  • 1 个耳机插孔

安装 Display Pilot 2软件,和硬件结合功能更是强大:

  • 桌面分区
  • 快捷键控制
  • 集成搜索引擎快速检索
  • 一套键鼠控制两个系统
  • ...

通过设置键盘快捷键,也可以一键切换到编程模式,键盘快捷键+触控键两种方式,多种方便。

要想多台显示器组合的话,不想在桌面上看到杂乱无章的线缆,通过 MST 简化多显示器串联,减少桌面线缆。

总结

RD280U,一台超级生产力显示器

  • 3:2 黄金屏比
  • 4K+ 超清分辨率
  • 抗反射面板
  • 编码专用色彩模式
  • MoonHalo 智慧光环
  • 智慧蓝光
  • 夜间保护功能
  • 90W 全功能 Type-C
  • 多种快捷键

不管你是前端开发,还是后端开发,RD280U都能带来全新的编程体验,让人一键进入心流模式,显著提高开发效率和工作舒适度,大家新年想升级设备的真的可以冲!

手把手写几种常用工具函数:深拷贝、去重、扁平化

作者 SuperEugene
2026年2月24日 10:41

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

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

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

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

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

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

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

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

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

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

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

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

1. 开篇:有库可用,为什么还要自己写?

lodashramda 等库已经提供这些工具函数,但在面试、基础补强、和「读懂库源码」的场景里,手写一遍很有价值:

  • 搞清概念:什么算「深拷贝」、什么算「去重」
  • 踩一遍坑:循环引用、NaNDateRegExpSymbol
  • 形成习惯:知道什么时候用浅拷贝、什么时候必须深拷贝

下面按「深拷贝 → 去重 → 扁平化」的顺序,每种都给出可直接用的实现和说明。

2. 深拷贝

2.1 浅拷贝 vs 深拷贝,怎么选?

场景 推荐方式 原因
只改最外层、不改嵌套对象 浅拷贝({...obj}Object.assign 实现简单、性能好
需要改嵌套对象且不想影响原数据 深拷贝 避免引用共享
对象里有 DateRegExp、函数等 深拷贝时需特殊处理 否则会丢失类型或行为

一句话:只要会改到「嵌套对象/数组」,就考虑深拷贝。

2.2 常见坑

  1. 循环引用obj.a = obj,递归会栈溢出
  2. 特殊类型DateRegExpMapSetSymbol 不能只靠遍历属性复制
  3. Symbol 做 keyObject.keys 不会包含,需用 Reflect.ownKeysObject.getOwnPropertySymbols

2.3 实现示例(含循环引用与特殊类型处理)

function deepClone(obj, cache = new WeakMap()) {
  // 1. 基本类型、null、函数 直接返回
  if (obj === null || typeof obj !== 'object') {
    return obj;
  }

  // 2. 循环引用:用 WeakMap 缓存已拷贝对象
  if (cache.has(obj)) {
    return cache.get(obj);
  }

  // 3. 特殊对象类型
  if (obj instanceof Date) return new Date(obj.getTime());
  if (obj instanceof RegExp) return new RegExp(obj.source, obj.flags);
  if (obj instanceof Map) {
    const mapCopy = new Map();
    cache.set(obj, mapCopy);
    obj.forEach((v, k) => mapCopy.set(deepClone(k, cache), deepClone(v, cache)));
    return mapCopy;
  }
  if (obj instanceof Set) {
    const setCopy = new Set();
    cache.set(obj, setCopy);
    obj.forEach(v => setCopy.add(deepClone(v, cache)));
    return setCopy;
  }

  // 4. 普通对象 / 数组
  const clone = Array.isArray(obj) ? [] : {};
  cache.set(obj, clone);

  // 包含 Symbol 作为 key
  const keys = [...Object.keys(obj), ...Object.getOwnPropertySymbols(obj)];
  keys.forEach(key => {
    clone[key] = deepClone(obj[key], cache);
  });

  return clone;
}

// 使用示例
const original = { a: 1, b: { c: 2 }, d: [3, 4] };
original.self = original; // 循环引用
const cloned = deepClone(original);
cloned.b.c = 999;
console.log(original.b.c); // 2,原对象未被修改

要点:WeakMap 解决循环引用,Date/RegExp/Map/Set 单独分支,Object.getOwnPropertySymbols 保证 Symbol key 不丢失。

3. 去重

3.1 场景与选型

场景 方法 说明
基本类型数组(数字、字符串) Set 写法简单、性能好
需要兼容 NaN 自己写遍历逻辑 NaN !== NaNSet 能去重 NaN,但逻辑要显式写清楚
对象数组、按某字段去重 Mapfilter 用唯一字段做 key

3.2 几种实现

1)简单数组去重(含 NaN)

// 方式一:Set(ES6 最常用)
function uniqueBySet(arr) {
  return [...new Set(arr)];
}

// 方式二:filter + indexOf(兼容性更好,但 NaN 会出问题)
function uniqueByFilter(arr) {
  return arr.filter((item, index) => arr.indexOf(item) === index);
}

// 方式三:兼容 NaN 的版本
function unique(arr) {
  const result = [];
  const seenNaN = false; // 用 flag 标记是否已经加入过 NaN
  for (const item of arr) {
    if (item !== item) { // NaN !== NaN
      if (!seenNaN) {
        result.push(item);
        seenNaN = true; // 这里需要闭包,下面用修正版
      }
    } else if (!result.includes(item)) {
      result.push(item);
    }
  }
  return result;
}

// 修正:用变量
function uniqueWithNaN(arr) {
  const result = [];
  let hasNaN = false;
  for (const item of arr) {
    if (Number.isNaN(item)) {
      if (!hasNaN) {
        result.push(NaN);
        hasNaN = true;
      }
    } else if (!result.includes(item)) {
      result.push(item);
    }
  }
  return result;
}

注意:Set 本身对 NaN 是去重的(ES2015 规范),所以 [...new Set([1, NaN, 2, NaN])] 结果正确。需要兼容 NaN 的,多是旧环境或面试题场景。

2)对象数组按某字段去重

function uniqueByKey(arr, key) {
  const seen = new Map();
  return arr.filter(item => {
    const k = item[key];
    if (seen.has(k)) return false;
    seen.set(k, true);
    return true;
  });
}

// 使用
const users = [
  { id: 1, name: '张三' },
  { id: 2, name: '李四' },
  { id: 1, name: '张三2' }
];
console.log(uniqueByKey(users, 'id'));
// [{ id: 1, name: '张三' }, { id: 2, name: '李四' }]

4. 扁平化

4.1 场景

  • [1, [2, [3, 4]]] 变成 [1, 2, 3, 4]
  • 有时候需要「只扁平一层」或「扁平到指定层数」

4.2 实现

1)递归全扁平

function flatten(arr) {
  const result = [];
  for (const item of arr) {
    if (Array.isArray(item)) {
      result.push(...flatten(item));
    } else {
      result.push(item);
    }
  }
  return result;
}

console.log(flatten([1, [2, [3, 4], 5]])); // [1, 2, 3, 4, 5]

2)指定深度扁平(如 Array.prototype.flat)

function flattenDepth(arr, depth = 1) {
  if (depth <= 0) return arr;

  const result = [];
  for (const item of arr) {
    if (Array.isArray(item) && depth > 0) {
      result.push(...flattenDepth(item, depth - 1));
    } else {
      result.push(item);
    }
  }
  return result;
}

console.log(flattenDepth([1, [2, [3, 4]]], 1)); // [1, 2, [3, 4]]
console.log(flattenDepth([1, [2, [3, 4]]], 2)); // [1, 2, 3, 4]

3)用 reduce 递归写法(另一种常见写法)

function flattenByReduce(arr) {
  return arr.reduce((acc, cur) => {
    return acc.concat(Array.isArray(cur) ? flattenByReduce(cur) : cur);
  }, []);
}

5. 小结:日常怎么选

函数 生产环境 面试 / 巩固基础
深拷贝 优先用 structuredClone(支持循环引用)或 lodash cloneDeep 自己实现,要处理循环引用和特殊类型
去重 基本类型用 [...new Set(arr)],对象用 Map 按 key 去重 要能解释 NaNindexOf 等细节
扁平化 用原生 arr.flat(Infinity) 手写递归或 reduce 版本

自己写一遍的价值在于:搞清楚边界情况、循环引用、特殊类型,以后选库或读源码时心里有数。


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

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

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

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

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

极简版前端版本检测方案

2026年2月24日 10:39

前端版本检测实现方案

功能概述

实现前端发版后的版本检测功能,通过自动生成版本文件并定期检查,确保用户始终使用最新版本。

实现步骤

1. 配置 Vite 插件自动生成 version.json

vite.config.ts 中添加自定义插件,打包时自动生成版本文件:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { writeFileSync } from 'fs'
import { resolve } from 'path'

//  封装版本生成函数
const versionPlugin = () => ({
  name: 'version-plugin',
  writeBundle() {
    const versionData = { version: Date.now() }
    writeFileSync(
      resolve(__dirname, 'dist/version.json'),
      JSON.stringify(versionData)
    )
  }
})

export default defineConfig({
  plugins: [
             vue(), 
     //  使用插件
             versionPlugin()       
  ],
})

2. 版本检测工具函数

创建 src/utils/versionCheck.ts

// 检查版本是否一致
const checkVersion = async () => {
  try {
    const res = await fetch('/version.json?timestamp=' + Date.now())
    const { version: remoteVersion } = await res.json()
    const localVersion = localStorage.getItem('app_version')
    
    if (localVersion && localVersion !== String(remoteVersion)) {
      localStorage.setItem('app_version', String(remoteVersion))
      alert('发现新版本,请刷新页面')
      location.reload()
    }
    
    localStorage.setItem('app_version', String(remoteVersion))
  } catch (e) {
    console.error('版本检测失败', e)
  }
}

export const initVersionCheck = () => {
  checkVersion()
  setInterval(checkVersion, 5 * 60 * 1000)
}

3. 在应用入口初始化版本检测

src/main.ts 中引入:

import { createApp } from 'vue'
import App from './App.vue'
/**   引入工具函数   **/
import { initVersionCheck } from './utils/versionCheck'

const app = createApp(App)
app.mount('#app')
/** 初始化版本检测 **/
initVersionCheck()

工作原理

  1. 打包阶段:Vite 插件在 writeBundle 钩子中生成 version.json,包含当前时间戳作为版本号
  2. 运行阶段:应用启动时和每隔 5 分钟检查一次版本
  3. 版本对比:对比本地存储的版本号和远程版本号,不一致时提示刷新

优势

  • 自动化生成版本文件,无需手动维护
  • 定时检测,及时提醒用户更新
  • 无需额外样式和复杂逻辑

粒子形成文字

作者 大时光
2026年2月24日 10:21
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <!-- 页面基本设置 -->
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>静态粒子组成文字效果</title>
    <!-- 页面样式设置 -->
    <style>
        /* 页面基本样式 */
        body {
            margin: 0; /* 页面边距为0 */
            padding: 0; /* 页面内边距为0 */
            background: #000; /* 背景颜色为黑色 */
            overflow: hidden; /* 隐藏滚动条 */
            display: flex; /* 使用弹性布局 */
            justify-content: center; /* 水平居中 */
            align-items: center; /* 垂直居中 */
            height: 100vh; /* 高度为视窗高度 */
            font-family: Arial, sans-serif; /* 字体设置 */
        }
        /* 画布样式 */
        canvas {
            display: block; /* 画布显示为块级元素 */
            background: #000; /* 画布背景为黑色 */
        }
        /* 控制面板样式 */
        .controls {
            position: absolute; /* 绝对定位 */
            top: 20px; /* 距离顶部20像素 */
            left: 20px; /* 距离左边20像素 */
            color: white; /* 文字颜色为白色 */
            z-index: 100; /* 层级设置 */
        }
        /* 输入框和按钮样式 */
        input, button {
            margin: 5px; /* 外边距5像素 */
            padding: 5px; /* 内边距5像素 */
        }
    </style>
</head>
<body>
    <!-- 控制面板 -->
    <div class="controls">
        <!-- 文字输入框 -->
        <input type="text" id="textInput" value="123" maxlength="10">
        <!-- 更新文字按钮 -->
        <button onclick="updateText()">更新文字</button>
        <!-- 粒子数量设置 -->
        <div>
            <label>粒子数量: <input type="number" id="particleCount" value="2000" min="500" max="5000" step="500"></label>
        </div>
        <!-- 粒子大小设置 -->
        <div>
            <label>粒子大小: <input type="number" id="particleSize" value="2" min="1" max="5" step="0.5"></label>
        </div>
        <!-- 粒子颜色设置 -->
        <div>
            <label>颜色: <input type="color" id="particleColor" value="#00ffff"></label>
        </div>
    </div>
    <!-- 画布元素 -->
    <canvas id="canvas"></canvas>

    <script>
        // 获取画布元素
        const canvas = document.getElementById('canvas');
        // 获取2D绘图上下文
        const ctx = canvas.getContext('2d');
        
        // 设置画布大小函数
        function resizeCanvas() {
            canvas.width = window.innerWidth;  // 画布宽度设置为窗口宽度
            canvas.height = window.innerHeight; // 画布高度设置为窗口高度
        }
        
        // 监听窗口大小变化事件,自动调整画布大小
        window.addEventListener('resize', resizeCanvas);
        // 初始设置画布大小
        resizeCanvas();
        
        // 粒子类定义
        class Particle {
            // 构造函数,创建粒子时调用
            constructor(x, y, size, color) {
                this.x = x;        // 粒子的X坐标
                this.y = y;        // 粒子的Y坐标
                this.size = size;  // 粒子大小
                this.color = color; // 粒子颜色
            }
            
            // 绘制粒子方法
            draw() {
                ctx.fillStyle = this.color; // 设置填充颜色
                ctx.beginPath(); // 开始绘制路径
                ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); // 绘制圆形
                ctx.fill(); // 填充圆形
            }
        }
        
        // 存储粒子的数组
        let particles = [];
        
        // 创建文字粒子函数
        function createTextParticles() {
            // 获取用户输入的文字
            const text = document.getElementById('textInput').value;
            // 如果没有文字则返回
            if (!text) return;
            
            // 清空画布并绘制文字
            ctx.clearRect(0, 0, canvas.width, canvas.height); // 清空画布
            ctx.font = 'bold 120px Arial'; // 设置字体样式
            ctx.textAlign = 'center'; // 文字水平居中对齐
            ctx.textBaseline = 'middle'; // 文字垂直居中对齐
            ctx.fillStyle = 'white'; // 设置文字颜色为白色
            ctx.fillText(text, canvas.width / 2, canvas.height / 2); // 在画布中心绘制文字
            
            // 获取文字像素数据
            const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); // 获取画布上的像素数据
            const data = imageData.data; // 获取像素数据数组
            
            // 清空粒子数组
            particles = [];
            
            // 获取粒子数量和大小
            const particleCount = parseInt(document.getElementById('particleCount').value); // 获取粒子数量
            const particleSize = parseFloat(document.getElementById('particleSize').value); // 获取粒子大小
            const particleColor = document.getElementById('particleColor').value; // 获取粒子颜色
            
            // 获取文字中的所有点
            const textPoints = []; // 存储文字像素点的数组
            // 遍历画布上的每个像素点
            for (let y = 0; y < canvas.height; y += 2) { // Y轴,每隔2像素检查一次
                for (let x = 0; x < canvas.width; x += 2) { // X轴,每隔2像素检查一次
                    const index = (y * canvas.width + x) * 4; // 计算像素在数组中的索引
                    // 检查该像素点是否属于文字(透明度大于128)
                    if (data[index + 3] > 128) { // 检查透明度
                        textPoints.push({x, y}); // 将坐标添加到数组中
                    }
                }
            }
            
            // 随机选择一部分像素点作为粒子位置
            const selectedPoints = []; // 存储选中的点
            // 从所有文字点中随机选择指定数量的点
            for (let i = 0; i < Math.min(particleCount, textPoints.length); i++) {
                const randomIndex = Math.floor(Math.random() * textPoints.length); // 随机索引
                selectedPoints.push(textPoints[randomIndex]); // 添加到选中的点数组
            }
            
            // 创建粒子
            for (let i = 0; i < selectedPoints.length; i++) {
                const point = selectedPoints[i]; // 获取选中的点
                // 创建粒子对象并添加到粒子数组
                particles.push(new Particle(point.x, point.y, particleSize, particleColor));
            }
            
            // 清空画布,准备绘制粒子
            ctx.clearRect(0, 0, canvas.width, canvas.height);
        }
        
        // 更新文字函数
        function updateText() {
            createTextParticles(); // 创建文字粒子
            drawParticles(); // 绘制粒子
        }
        
        // 绘制粒子函数
        function drawParticles() {
            // 清空画布,设置背景为黑色
            ctx.fillStyle = 'rgba(0, 0, 0, 1)';
            ctx.fillRect(0, 0, canvas.width, canvas.height);
            
            // 绘制所有粒子
            for (let i = 0; i < particles.length; i++) {
                particles[i].draw(); // 调用粒子的绘制方法
            }
        }
        
        // 初始化
        createTextParticles(); // 创建文字粒子
        drawParticles(); // 绘制粒子
        
        // 鼠标移动事件监听器,实现互动效果
        canvas.addEventListener('mousemove', (e) => {
            // 获取鼠标相对于画布的坐标
            const rect = canvas.getBoundingClientRect(); // 获取画布位置信息
            const mouseX = e.clientX - rect.left; // 鼠标X坐标
            const mouseY = e.clientY - rect.top; // 鼠标Y坐标
            
            // 清空画布
            ctx.fillStyle = 'rgba(0, 0, 0, 1)';
            ctx.fillRect(0, 0, canvas.width, canvas.height);
            
            // 遍历所有粒子
            for (let i = 0; i < particles.length; i++) {
                // 计算粒子与鼠标之间的距离
                const dx = particles[i].x - mouseX; // X方向距离
                const dy = particles[i].y - mouseY; // Y方向距离
                const distance = Math.sqrt(dx * dx + dy * dy); // 计算实际距离
                
                // 如果粒子距离鼠标小于100像素
                if (distance < 100) {
                    // 让粒子远离鼠标
                    const angle = Math.atan2(dy, dx); // 计算角度
                    const force = (100 - distance) / 50; // 计算推力
                    const newX = particles[i].x + Math.cos(angle) * force; // 新的X坐标
                    const newY = particles[i].y + Math.sin(angle) * force; // 新的Y坐标
                    
                    // 绘制粒子在新位置
                    ctx.fillStyle = particles[i].color; // 设置颜色
                    ctx.beginPath(); // 开始绘制路径
                    ctx.arc(newX, newY, particles[i].size, 0, Math.PI * 2); // 绘制圆形
                    ctx.fill(); // 填充圆形
                } else {
                    // 绘制粒子在原始位置
                    particles[i].draw(); // 调用粒子的绘制方法
                }
            }
        });
        
        // 鼠标离开画布时的事件处理
        canvas.addEventListener('mouseleave', () => {
            drawParticles(); // 重新绘制静态粒子
        });
    </script>
</body>
</html>




让我用通俗易懂的话来解释这个粒子组成文字的实现思路:

实现思路

1. 基本原理

想象一下,我们要用很多小点(粒子)来"拼"出"123"这三个字。这就像用很多小星星拼出图案一样。

2. 实现步骤

第一步:画出文字轮廓

  • 我们先在画布上用白色画出"俞超群"这三个字
  • 这时文字是实心的,就像一个模板

第二步:提取文字位置

  • 我们检查画布上的每一个像素点
  • 如果这个点是白色(文字部分),就记住它的坐标
  • 这样我们就得到了所有属于文字的坐标点

第三步:创建粒子

  • 从所有属于文字的坐标点中,随机选择一部分(比如2000个点)
  • 在这些坐标位置上,放上我们想要的粒子(小圆点)
  • 每个粒子都有自己的颜色、大小

第四步:显示粒子

  • 把所有粒子按照它们在文字中的位置画出来
  • 这样就形成了用粒子组成的文字

3. 代码实现的关键点

粒子类(Particle)

class Particle {
    constructor(x, y, size, color) {
        this.x = x;        // 粒子的X坐标
        this.y = y;        // 粒子的Y坐标
        this.size = size;  // 粒子大小
        this.color = color; // 粒子颜色
    }
}

获取文字像素

// 先画出文字
ctx.fillText(text, canvas.width / 2, canvas.height / 2);

// 获取所有像素信息
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);

// 检查每个像素是否属于文字
if (data[index + 3] > 128) { // 透明度大于128的就是文字部分
    // 记录这个点的坐标
}

让我详细解释这段代码的工作原理:

  for (let y = 0; y < canvas.height; y += 2) { // Y轴,每隔2像素检查一次
                for (let x = 0; x < canvas.width; x += 2) { // X轴,每隔2像素检查一次
                    const index = (y * canvas.width + x) * 4; // 计算像素在数组中的索引
                    // 检查该像素点是否属于文字(透明度大于128)
                    if (data[index + 3] > 128) { // 检查透明度
                        textPoints.push({x, y}); // 将坐标添加到数组中
                    }
                }
            }

画布像素数据的存储方式

当你获取画布的像素数据时,Canvas API会返回一个一维数组,但实际上是按二维网格存储的。每个像素由4个值表示:

  • data[index + 0] - 红色值 (Red)
  • data[index + 1] - 绿色值 (Green)
  • data[index + 2] - 蓝色值 (Blue)
  • data[index + 3] - 透明度值 (Alpha)

为什么用这个公式计算索引

const index = (y * canvas.width + x) * 4;

让我用一个具体例子来说明:

假设画布是 5x5 像素的(实际很小,便于理解):

坐标: (x, y) -> 索引位置
(0,0) -> 索引 0
(1,0) -> 索引 4  
(2,0) -> 索引 8
(0,1) -> 索引 20 (5*4)
(1,1) -> 索引 24 (5*4+4)

计算过程:

  • (y * canvas.width + x) 计算的是第几个像素
  • 乘以4是因为每个像素占4个数组位置

举例说明:

  • 如果在第2行第3列 (x=3, y=2),画布宽度为100
  • 第2行第3列是第 (2 * 100 + 3) = 203 个像素
  • 在数组中的起始位置是 203 * 4 = 812
  • 所以这个像素的颜色数据在数组的:
    • data[812] - 红色值
    • data[813] - 绿色值
    • data[814] - 蓝色值
    • data[815] - 透明度值

为什么要加3

if (data[index + 3] > 128) // 检查透明度
  • index 是当前像素的起始位置
  • index + 3 就是透明度值的位置
  • 透明度值范围是 0-255:
    • 0 = 完全透明
    • 255 = 完全不透明
  • 128 是一个中间值,大于128就认为是文字部分

为什么每隔2像素检查

y += 2 和 x += 2

这是为了:

  1. 提高性能 - 不需要检查每个像素点
  2. 减少粒子数量 - 避免粒子过于密集
  3. 保持文字形状 - 即使跳过一些像素也能保持文字轮廓

图解说明

实际像素位置:    数组中的存储方式:
[0,0][0,1][0,2]   [R,G,B,A,R,G,B,A,R,G,B,A,...]
[1,0][1,1][1,2]   
[2,0][2,1][2,2]   

所以 (y * width + x) * 4 就是找到指定坐标的像素在数组中的起始位置。

4. 互动功能

  • 当鼠标移动时,靠近鼠标的一些粒子会暂时"躲开"
  • 鼠标离开后,所有粒子回到原来的位置
  • 这样就实现了简单的互动效果

5. 参数控制

  • 可以调整粒子数量(影响文字的清晰度)
  • 可以改变粒子大小(影响视觉效果)
  • 可以选择粒子颜色(改变整体风格)

总的来说,这个效果的核心就是:先用像素画出文字,再用粒子替换这些像素点。这样就实现了用粒子组成文字的效果。

在这里插入图片描述

通用管理后台组件库-6-头部导航组件

作者 没想好d
2026年2月24日 10:20

头部组件

说明:包含主题设置、中英文转换、黑暗和明亮模式、全屏、账号信息头像。

1.实现效果

image.png

image.png

2.主题设置

src/components/Themes/ThemeSettings.vue

<template>
  <div>
    <Icon icon="ri:brush-2-line" @click="drawer = true" class="text-2xl cursor-pointer" />
    <el-drawer v-model="drawer" title="主题设置" @close="handleClose" class="min-w-[330px] lt-sm:w-full!">
      <el-form v-model="form">
        <el-form-item label="主题颜色">
          <el-color-picker v-model="form.theme" />
        </el-form-item>
        <el-form-item label="暗黑模式">
          <el-switch v-model="form.darkMode" />
        </el-form-item>
        <el-form-item label="导航模式" class="flex-col nav">
          <div class="flex justify-between w-full">
            <el-tooltip content="左侧菜单">
              <div
                :class="['item', { active: form.mode === 'siderbar' }]"
                @click="selectedMode('siderbar')"
              >
                <div class="w-1/4 h-full bg-dark left-0 top-0 absolute z-30"></div>
                <div class="w-full h-1/4 bg-white left-0 top-0 absolute z-10"></div>
              </div>
            </el-tooltip>
            <el-tooltip content="顶部左侧菜单混合">
              <div :class="['item', { active: form.mode === 'mix' }]" @click="selectedMode('mix')">
                <div class="w-1/4 h-full bg-white left-0 top-0 absolute z-10"></div>
                <div class="w-full h-1/4 bg-dark left-0 top-0 absolute z-30"></div>
              </div>
            </el-tooltip>
            <el-tooltip content="顶部菜单">
              <div :class="['item', { active: form.mode === 'top' }]" @click="selectedMode('top')">
                <div class="w-full h-1/4 bg-dark left-0 top-0 absolute"></div>
              </div>
            </el-tooltip>
            <el-tooltip content="左侧菜单混合">
              <div
                :class="['item', { active: form.mode === 'mixbar' }]"
                @click="selectedMode('mixbar')"
              >
                <div class="w-1/6 h-full bg-dark left-0 top-0 absolute z-30"></div>
                <div class="w-1/6 h-full bg-white left-1/6 top-0 absolute z-10"></div>
                <div class="w-full h-1/4 bg-white left-0 top-0 absolute z-20 border-b"></div>
              </div>
            </el-tooltip>
          </div>
        </el-form-item>
        <el-form-item label="菜单背景">
          <el-color-picker v-model="form.backgroundColor" />
        </el-form-item>
        <el-form-item label="菜单宽度">
          <el-slider
            class="ml-3"
            :max="600"
            :min="220"
            v-model="form.menuWidth"
            show-input
            input-size="small"
          />
        </el-form-item>
        <el-form-item label="显示 Logo">
          <el-switch v-model="form.showLogo" />
        </el-form-item>
        <el-form-item label="切换动画"></el-form-item>
        <el-form-item label="标签页">
          <el-switch v-model="form.showTabs" />
        </el-form-item>
        <el-form-item label="头部固定">
          <el-switch v-model="form.fixedHead" />
        </el-form-item>
        <el-form-item label="显示面包屑">
          <el-switch v-model="form.showBeadcrumb" />
        </el-form-item>
      </el-form>
    </el-drawer>
  </div>
</template>

<script setup lang="ts">
import { Icon } from '@iconify/vue'
import type { ModeNav, ThemeSettingProps } from './type'

const drawer = ref(false)

const props = withDefaults(defineProps<ThemeSettingProps>(), {
  theme: '#409EFF',
  darkMode: false,
  menuWidth: 240,
  showLogo: false,
  showTabs: true,
  fixedHead: false,
  showBeadcrumb: true,
  mode: 'siderbar',
  backgroundColor: '#001529'
})

const form = reactive<ThemeSettingProps>({ ...props })
const selectedMode = (mode: ModeNav) => {
  form.mode = mode
}

const emit = defineEmits<{
  change: [settings: ThemeSettingProps]
}>()

onMounted(() => {
  // 解决抽屉动态设置时,页面内容不更新的问题
  emit('change', form)
})
// 关闭抽屉
const handleClose = () => {
  emit('change', form)
}
</script>

<style scoped lang="scss">
:deep(.el-form-item__content) {
  justify-content: flex-end;
}
:deep(.nav .el-form-item__label) {
  justify-content: flex-start;
}
:deep(.nav .el-form-item__content) {
  justify-content: space-between;
  @apply ml-17;
}
.item {
  @apply bg-gray-100 rounded w-15 h-10 relative overflow-hidden shadow border border-gray-100 cursor-pointer;
  &.active {
    @apply border-sky-800 border-2;
  }
}
</style>

类型文件:types.d.ts

import type { IconifyIcon } from '@iconify/vue'

export interface LocaleItem {
  // 选项名,中文、英文
  text: string
  icon?: string | IconifyIcon
  // locale文件夹下的文件名,如en、zh-CN
  name: string
}

// 菜单模式
export type ModeNav = 'siderbar' | 'mix' | 'top' | 'mixbar'

// 主题设置属性接口
export interface ThemeSettingProps {
  theme: string
  darkMode: boolean
  menuWidth?: number
  showLogo: boolean
  showTabs: boolean
  fixedHead: boolean
  showBeadcrumb: boolean
  // 导航模式
  mode: ModeNav
  backgroundColor: string
}

3.账号信息头像组件

Avatar.vue

<template>
  <el-dropdown v-bind="props" @command="handleCommand" :size="menuSize">
    <div class="el-dropdown-link flex items-center">
      <!-- src没有时自动显示用户名第一个大写字符 -->
      <el-avatar
        :size="size"
        :src="src"
        :icon="icon"
        :alt="alt"
        :shape="shape"
        :fit="fit"
        :src-set="srcSet"
        >{{ username ? username[0].toUpperCase() : '' }}</el-avatar
      >
      <span class="ml-2" v-if="username">{{ username }}</span>
    </div>
    <template #dropdown>
      <el-dropdown-menu>
        <!-- 遍历传递过来的下拉选项 -->
        <template v-for="(menu, index) in data" :key="index">
          <el-dropdown-item
            v-if="(typeof menu === 'object' && menu?.key ? menu.key : menu) !== 'divider'"
            :command="typeof menu === 'object' && menu?.key ? menu.key : menu"
            >{{ typeof menu === 'object' && menu?.value ? menu.value : menu }}</el-dropdown-item
          >
          <el-divider class="my-0!" v-else></el-divider>
        </template>
      </el-dropdown-menu>
    </template>
  </el-dropdown>
</template>

<script setup lang="ts">
import type { AvatarMenuProps } from './types'

const props = withDefaults(defineProps<Partial<AvatarMenuProps>>(), {
  trigger: 'click',
  size: 25,
  // src: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png',
  username: ''
})
// 将选中的值传递出去
const emit = defineEmits<{
  command: [arg: string | number | object]
}>()
const handleCommand = (command: string | number | object) => {
  emit('command', command)
}
</script>

<style scoped></style>

4.头部导航组件Header.vue

<template>
  <el-row class="flex items-center mx-2 flex-nowrap! h-[50px]">
    <!-- 折叠图标 -->
    <Icon
      :icon="collapseModel ? 'ep:expand' : 'ep:fold'"
      @click="collapseModel = !collapseModel"
      class="cursor-pointer text-2xl"
      v-if="setting?.mode !== 'top'"
    />
    <div class="flex-grow relative overflow-x-hidden">
      <slot name="menu"></slot>
    </div>
    <el-row class="flex items-center flex-nowrap!">
      <!-- 设置主题 -->
      <ThemeSettings class="mr-3" @change="handleChange" v-bind="setting"></ThemeSettings>
      <!-- 暗黑模式 -->
      <DarkModeTaggle
        class="mr-3"
        :dark="setting?.darkMode"
        @change="handleChangeDarkMode"
      ></DarkModeTaggle>
      <!-- 国际化 -->
      <ChangeLocale :locales="locales" class="mr-2" @change="changeLocale"></ChangeLocale>
      <!-- 全屏 -->
      <FullScreen class="mr-2"></FullScreen>
      <el-divider direction="vertical"></el-divider>
      <!-- 用户信息 -->
      <Avatar
        v-if="username || src"
        v-bind="avatarProps"
        @command="handleCommand"
        class="ml-2"
      ></Avatar>
    </el-row>
  </el-row>
</template>

<script setup lang="ts">
import { Icon } from '@iconify/vue'
import type { ThemeSettingProps } from '../Themes/type'
import type { HeaderProps } from './types'
import { loadLocaleMessages } from '@/modules/i18n'

const props = withDefaults(defineProps<HeaderProps>(), {
  collapse: false
})

// 使用v-model指令父子组件双向绑定,实现抽屉的展开和收起
const collapseModel = defineModel('collapse', {
  type: Boolean,
  default: false
})

// 获取头部导航栏数据,实现暗黑模式和主题设置中的数据转换
const localProps = reactive({ ...props })

// 过滤出头像数据
const avatarProps = computed(() => {
  const { collapse, locales, ...restProps } = props
  return restProps
})

// 回传数据
const emits = defineEmits<{
  menuChange: [arg: string | number | object]
  settingChange: [settings: ThemeSettingProps]
}>()

// 监听主题设置中的变化
watch(
  () => localProps.setting,
  () => {
    emits('settingChange', localProps.setting!)
  },
  { deep: true }
)

const handleCommand = (command: string | number | object) => {
  emits('menuChange', command)
}
// 主题设置
const handleChange = (settings: ThemeSettingProps) => {
  localProps.setting = settings
}
// 暗黑模式切换
const handleChangeDarkMode = (darkMode: boolean) => {
  localProps.setting!.darkMode = darkMode
}
// 切换中英文
const changeLocale = (locale: string) => {
  loadLocaleMessages(locale)
}
</script>

<style scoped></style>

类型文件types.d.ts

import type { AvatarMenuProps } from '../Avatar/types'
import type { LocaleItem, ThemeSettingProps } from '../Themes/type'

export interface HeaderProps extends Partial<AvatarMenuProps> {
  // 是否折叠
  collapse: boolean
  // 语言数组
  locales: LocaleItem[]
  // 主题设置
  setting?: ThemeSettingProps
}

5.默认布局default.vue中的导航组件引用

<template>
  <div class="w-full h-screen overflow-hidden flex">
    <!-- 左右布局 -->
    <!-- sidebar -->
    <div
      :style="{
        width: mixbarMenuWidth,
        backgroundColor: setting?.backgroundColor
      }"
      class="h-full transition-width shrink-0"
      v-if="setting?.mode !== 'top'"
    >
      <el-row class="h-full">
        <el-scrollbar
          v-if="setting?.mode !== 'mix'"
          :class="[setting?.mode !== 'mixbar' ? 'flex-1' : 'w-[64px] py-4']"
          :style="{
            backgroundColor:
              setting?.mode !== 'mixbar' ? 'auto' : darkenColor(setting?.backgroundColor, 10)
          }"
        >
          <!-- 左侧菜单和左侧菜单混合模式的布局-->
          <Menu
            :class="[{ mixbar: setting?.mode === 'mixbar' }]"
            v-if="setting?.mode === 'siderbar' || setting?.mode === 'mixbar'"
            mode="vertical"
            :data="mixbarMenus"
            :collapse="setting?.mode !== 'mixbar' && localSettings.collapse"
            text-color="#b8b8b8"
            :background-color="
              setting?.mode !== 'mixbar' ? setting?.backgroundColor : 'transparent'
            "
            @select="handleMenuSelect"
          ></Menu>
        </el-scrollbar>
        <el-scrollbar v-if="setting?.mode === 'mix' || setting?.mode === 'mixbar'" class="flex-1">
          <!-- 左侧菜单混合和顶部左侧菜单混合模式的二级menu -->
          <Menu
            mode="vertical"
            :data="getSubMenus(menus)"
            :collapse="localSettings.collapse"
            text-color="#b8b8b8"
            :background-color="setting?.backgroundColor"
            @select="handleMenuSelect"
          ></Menu
        ></el-scrollbar>
      </el-row>
    </div>
    <!-- content -->
    <div class="w-full h-full">
      <!-- header -->
      <Header
        :locales="locales"
        :username="username"
        :src="avatar"
        :data="avatarMenu"
        :setting="setting"
        v-model:collapse="localSettings.collapse"
        @setting-change="handleSettingChange"
        @select="handleMenuSelect"
      >
        <template #menu>
          <!-- 顶部菜单和混合模式布局 -->
          <Menu
            v-if="setting?.mode === 'top' || setting?.mode === 'mix'"
            mode="horizontal"
            :data="setting?.mode === 'mix' ? getTopMenus(menus) : menus"
            :collapse="false"
            @select="handleMenuSelect"
          ></Menu>
        </template>
      </Header>
      <!-- main -->
      <router-view></router-view>
    </div>
    <!-- 移动端菜单抽屉 -->
    <el-drawer
      direction="ltr"
      class="w-full!"
      :style="{ backgroundColor: setting?.backgroundColor }"
      v-if="isMobile"
      :model-value="!localSettings.collapse"
      @close="localSettings.collapse = true"
    >
      <Menu
        text-color="#b8b8b8"
        :data="menus"
        :background-color="setting?.backgroundColor"
        @select="handleMenuSelect"
      ></Menu>
    </el-drawer>
  </div>
</template>

<script setup lang="ts">
import type { DropMenuItem } from '@/components/Avatar/types'
import type { HeaderProps } from '@/components/Layouts/types'
import type { ThemeSettingProps } from '@/components/Themes/type'
import type { AppRouteMenuItem } from '@/components/menu/type'
import { useMenu } from '@/components/menu/useMenu'
import { darkenColor } from '@/utils'
import type { RouteRecordRaw } from 'vue-router/auto'
import { routes } from 'vue-router/auto-routes'

interface ThemeSettingsOptions extends HeaderProps {
  username: string
  avatar: string
  avatarMenu: DropMenuItem[]
}
const router = useRouter()

// 设置配置默认数据
const localSettings = reactive<ThemeSettingsOptions>({
  username: 'admin',
  locales: [
    {
      name: 'zh-CN',
      text: '中文',
      icon: 'uil:letter-chinese-a'
    },
    {
      text: '英文',
      name: 'en',
      icon: 'ri:english-input'
    }
  ],
  avatarMenu: [
    {
      key: '1',
      value: '个人中心'
    },
    {
      key: '2',
      value: '修改密码'
    },
    {
      key: 'divider',
      value: ''
    },
    {
      key: '4',
      value: '退出登录'
    }
  ],
  avatar: '',
  collapse: false,
  setting: { menuWidth: 280 } as ThemeSettingProps
})
const { locales, avatarMenu, username, avatar } = toRefs(localSettings)

// 菜单和路由配置类型不相同,转换一下
const genrateMenuData = (routes: RouteRecordRaw[]): AppRouteMenuItem[] => {
  const menuData: AppRouteMenuItem[] = []
  routes.forEach((route) => {
    if (route.meta?.hideMenu) return
    let menuItem: AppRouteMenuItem = {
      name: route.name,
      path: route.path,
      meta: route.meta,
      alias: typeof route.redirect === 'string' ? route.redirect : undefined,
      component: route.component
    }
    // 判断是否有子路由,递归转换
    if (route.children && Array.isArray(route.children) && route.children.length > 0) {
      menuItem.children = genrateMenuData(route.children)
    }
    menuData.push(menuItem)
  })
  return menuData
}
// 路由类型数据转换为菜单类型数据
const menus = computed(() => genrateMenuData(routes))
const isMobile = ref(false)
// 设置主题
const handleSettingChange = (themeSettings: ThemeSettingProps) => {
  localSettings.setting = themeSettings
}
// 获取菜单宽度
const menuWidth = computed(() => (localSettings.setting ? localSettings.setting.menuWidth : 240))
// 获取设置菜单
const setting = computed(() => localSettings.setting)

// 获取mixbar和mix模式下的一二级菜单
const { getTopMenus, getSubMenus } = useMenu()

onMounted(() => {
  console.log(getTopMenus(menus.value))
  console.log(getSubMenus(menus.value))
})

// 混合mixbar模式下的菜单
const mixbarMenus = computed(() =>
  setting.value?.mode === 'mixbar' ? getTopMenus(menus.value) : menus.value
)
// 混合mixbar模式下的二级菜单是否都设置了icon,判断收起的显示情况
const isFullIcons = computed(() => {
  return getSubMenus(menus.value).every(
    (item) => typeof item.meta?.icon !== 'undefined' && item.meta?.icon
  )
})
// 混合mixbar模式下的菜单宽度
const mixbarMenuWidth = computed(() => {
  if (isMobile.value) return 0
  if (setting.value?.mode === 'mixbar' && isFullIcons.value) {
    return localSettings.collapse ? 'auto' : menuWidth.value + 'px'
  } else {
    return localSettings.collapse ? '64px' : menuWidth.value + 'px'
  }
})
// 选择menu事件
const handleMenuSelect = (menuItem: AppRouteMenuItem) => {
  if (menuItem && menuItem.name) {
    router.push(menuItem.name as string)
    if (isMobile.value) {
     localSettings.collapse = true
    }
  }
}

// 菜单抽屉展开折叠,屏幕宽度适配
const tmpWidth = ref(0)
const changeWidthFlag = ref(false)
useResizeObserver(document.body, (entries) => {
  // 获取浏览器宽度
  const { width } = entries[0].contentRect
  if (tmpWidth.value === 0) {
    // 记录初始宽度
    tmpWidth.value = width
  }
  if (width > tmpWidth.value) {
    // 扩大屏幕
    changeWidthFlag.value = width < 640
  } else {
    // 缩小屏幕
    changeWidthFlag.value = width > 1200
  }
  if (width < 640 && !changeWidthFlag.value) {
    localSettings.collapse = true
  }
  if (width > 1200 && !changeWidthFlag.value) {
    localSettings.collapse = false
  }
  // 是否是移动端屏幕宽度
  isMobile.value = width < 440
  tmpWidth.value = width
})
onBeforeMount(() => {
  // 是否是移动端屏幕
  if (
    navigator.userAgent.match(
      /(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i
    )
  ) {
    isMobile.value = true
    localSettings.collapse = true
  }
})
</script>

<style lang="scss" scoped>
.mixbar {
  :deep(.el-menu-item) {
    height: auto;
    line-height: unset !important;
    flex-direction: column;
    margin-bottom: 15px;
    padding: 4px 0 !important;
    svg {
      margin-right: 0;
      margin-bottom: 10px;
    }
  }
}
</style>

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

作者 SmalBox
2026年2月24日 10:16

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

在Unity URP Shader Graph中,Matrix 3x3节点是一个基础但功能强大的工具,用于在着色器中定义和操作3x3矩阵。3x3矩阵在计算机图形学和着色器编程中扮演着至关重要的角色,特别是在处理2D变换、法线变换和特定类型的坐标变换时。与4x4矩阵相比,3x3矩阵更加轻量级,适用于不需要平移操作或处理三维齐次坐标的场景。

理解Matrix 3x3节点的使用方法和应用场景,对于创建高效、性能优化的着色器至关重要。本指南将全面介绍Matrix 3x3节点的各个方面,包括其数学基础、在Shader Graph中的使用方法、常见应用场景以及性能优化建议。

描述

Matrix 3x3节点允许着色器开发者在Shader Graph中直接定义和使用3x3矩阵常量。这个节点创建了一个3行3列的矩阵数据结构,可以用于各种数学变换和图形操作。

矩阵数学基础

在深入探讨Matrix 3x3节点的具体用法之前,有必要了解一些基本的矩阵数学概念。一个3x3矩阵由9个元素组成,排列成3行3列:

[ m00, m01, m02 ]
[ m10, m11, m12 ]
[ m20, m21, m22 ]

在着色器编程中,矩阵通常以列主序存储,这意味着在内存中,矩阵的元素按列顺序排列。这种存储方式影响了如何初始化矩阵以及如何访问其元素。

在Shader Graph中的重要性

Matrix 3x3节点在Shader Graph中具有多种重要用途:

  • 提供了一种直观的方式在可视化编程环境中定义矩阵
  • 简化了复杂数学变换的实现
  • 允许与其他Shader Graph节点无缝集成
  • 支持实时编辑和预览矩阵变换效果

默认矩阵值

当在Shader Graph中创建Matrix 3x3节点时,默认情况下它被初始化为单位矩阵:

[ 1, 0, 0 ]
[ 0, 1, 0 ]
[ 0, 0, 1 ]

单位矩阵是矩阵乘法中的"中性元素",任何向量或矩阵与单位矩阵相乘都不会改变。这一特性使得单位矩阵成为许多变换操作的理想起点。

端口

Matrix 3x3节点的端口配置相对简单,但理解其工作原理对于有效使用该节点至关重要。

输出端口

Matrix 3x3节点只有一个输出端口,标记为"Out",其类型为Matrix 3。这个端口输出节点中定义的3x3矩阵值,可以连接到任何接受矩阵输入的Shader Graph节点。

输出端口的关键特性包括:

  • 数据类型:Matrix 3(3x3矩阵)
  • 方向:输出
  • 绑定:无(不直接绑定到材质属性或其他外部资源)
  • 用途:提供矩阵数据给其他节点进行进一步处理

端口连接与数据流

在Shader Graph中,Matrix 3x3节点的输出端口可以连接到多种其他节点的输入端口,包括:

  • 矩阵乘法节点(Multiply节点)
  • 自定义函数节点
  • 其他需要矩阵输入的数学运算节点

当连接Matrix 3x3节点到其他节点时,Shader Graph会自动处理数据类型匹配和转换,确保数据流的正确性。

矩阵数据类型

在Shader Graph和底层HLSL/GLSL代码中,Matrix 3类型表示一个3x3的浮点数矩阵。这种数据类型在内存中占用9个浮点数的空间,通常以列主序排列。

理解矩阵数据类型的重要性在于:

  • 确保正确的内存对齐和访问模式
  • 优化着色器性能
  • 避免类型不匹配导致的编译错误

控件

Matrix 3x3节点的控件界面提供了直观的方式来定义和编辑3x3矩阵的值。通过这个控件,用户可以精确设置矩阵的每个元素,从而创建所需的变换矩阵。

矩阵编辑器

Matrix 3x3节点的核心控件是一个3x3的矩阵编辑器,允许用户直接输入每个元素的值。编辑器通常以表格形式呈现,包含9个输入字段,对应矩阵的9个元素。

矩阵编辑器的布局通常如下:

  • 第一行:m00, m01, m02
  • 第二行:m10, m11, m12
  • 第三行:m20, m21, m22

这种布局符合标准的矩阵表示法,使得用户可以直观地理解和编辑矩阵结构。

控件属性

Matrix 3x3控件的属性包括:

  • 类型:Matrix 3x3(固定类型,不可更改)
  • 选项:无特殊选项
  • 描述:设置输出值

虽然控件本身没有复杂的配置选项,但通过精心设置矩阵元素的值,可以实现各种复杂的变换效果。

交互方式

用户可以通过多种方式与Matrix 3x3控件交互:

  • 直接在每个输入字段中输入数值
  • 使用上下箭头微调数值
  • 通过表达式或数学公式设置值
  • 复制粘贴矩阵值

这些交互方式提供了灵活性和精确性,使用户能够快速创建和修改矩阵。

实时预览

Shader Graph的一个强大功能是实时预览,这也适用于Matrix 3x3节点。当用户修改矩阵值时,可以立即在预览窗口中看到变换效果,这大大加快了着色器开发迭代过程。

实时预览的特性包括:

  • 即时反馈矩阵变换效果
  • 支持多种预览模式(表面、顶点等)
  • 可调整的预览分辨率和质量

生成的代码示例

当Shader Graph编译为实际着色器代码时,Matrix 3x3节点会生成相应的HLSL代码。理解生成的代码有助于深入理解节点的工作原理和优化着色器性能。

基本代码结构

Matrix 3x3节点生成的基本代码结构如下:

HLSL

float3x3 _Matrix3x3 = float3x3(1, 0, 0, 0, 1, 0, 0, 0, 1);

这行代码声明了一个名为_Matrix3x3的3x3矩阵变量,并将其初始化为单位矩阵。变量名可能会根据节点在Shader Graph中的名称而有所不同。

矩阵初始化语法

在HLSL中,3x3矩阵可以使用多种方式初始化:

  • 逐元素初始化:float3x3(m00, m01, m02, m10, m11, m12, m20, m21, m22)
  • 行向量初始化:float3x3(row0, row1, row2),其中每个row是一个float3
  • 列向量初始化:float3x3(col0, col1, col2),其中每个col是一个float3

Shader Graph通常使用逐元素初始化方式,因为它提供了对每个矩阵元素的直接控制。

实际应用示例

以下是一个更复杂的示例,展示Matrix 3x3节点在完整着色器中的使用:

HLSL

// 由Shader Graph生成的代码片段
float3x3 _RotationMatrix = float3x3(
    cos(_Angle), -sin(_Angle), 0,
    sin(_Angle), cos(_Angle), 0,
    0, 0, 1
);

void surf(Input IN, inout SurfaceOutput o)
{
    // 应用旋转变换到UV坐标
    float2 rotatedUV = mul(_RotationMatrix, float3(IN.uv, 1)).xy;

    // 使用变换后的UV采样纹理
    fixed4 texColor = tex2D(_MainTex, rotatedUV);
    o.Albedo = texColor.rgb;
}

这个示例展示了如何使用Matrix 3x3节点创建一个旋转矩阵,并将其应用于UV坐标变换。

变量命名和作用域

Shader Graph会自动为Matrix 3x3节点生成的变量分配合适的名称和作用域:

  • 变量名通常基于节点名称,确保唯一性
  • 变量作用域取决于节点在Shader Graph中的位置和连接方式
  • 通过暴露为材质属性,可以使矩阵参数在材质检视器中可调

理解这些命名和作用域规则有助于调试复杂的着色器图和理解生成的代码结构。

应用场景

Matrix 3x3节点在Shader Graph中有多种应用场景,从简单的坐标变换到复杂的数学运算。了解这些应用场景有助于在实际项目中有效使用该节点。

2D变换操作

3x3矩阵非常适合处理2D变换,因为2D变换(旋转、缩放、斜切)可以用3x3矩阵完美表示。常见的2D变换应用包括:

  • UV动画:通过矩阵变换创建动态纹理效果
  • 精灵变换:在2D游戏中处理精灵的旋转和缩放
  • 界面元素:为UI元素创建复杂的变换效果

以下是一个创建2D旋转矩阵的示例:

// 旋转45度的矩阵
[ cos(45°), -sin(45°), 0 ]
[ sin(45°),  cos(45°), 0 ]
[    0,         0,     1 ]

在Shader Graph中,可以通过连接数学节点到Matrix 3x3节点的各个输入来动态创建这样的矩阵。

法线变换

在3D图形中,法线向量需要特殊的变换处理。当模型发生变换时,法线不能简单地使用与顶点相同的变换矩阵,否则可能无法保持与表面的垂直关系。

正确的法线变换需要使用模型变换矩阵的逆转置矩阵。对于只包含旋转和统一缩放的情况,可以使用3x3矩阵表示法线变换:

HLSL

// 法线变换矩阵(假设只包含旋转)
float3x3 normalMatrix = transpose(inverse((float3x3)unity_ObjectToWorld));
float3 worldNormal = mul(normalMatrix, objectNormal);

在Shader Graph中,可以通过组合多个节点来构建法线变换矩阵,确保法线在变换后保持正确方向。

颜色空间变换

3x3矩阵可以用于颜色空间之间的转换,例如从RGB到YUV或其它颜色模型的转换:

HLSL

// RGB到YUV转换矩阵
float3x3 rgbToYuv = float3x3(
    0.299, 0.587, 0.114,
    -0.14713, -0.28886, 0.436,
    0.615, -0.51499, -0.10001
);

float3 yuvColor = mul(rgbToYuv, rgbColor);

这种颜色空间转换在图像处理和后处理效果中非常有用。

自定义坐标系统

Matrix 3x3节点可用于创建自定义坐标系统,例如切线空间、对象空间或世界空间之间的转换:

  • 切线空间变换:将法线贴图中的向量从切线空间转换到世界空间
  • 对象空间变换:在对象局部坐标系中应用自定义变换
  • 投影变换:创建自定义投影效果

这些坐标系统变换对于实现高级渲染效果如法线映射、视差映射等至关重要。

性能优化

正确使用Matrix 3x3节点不仅影响着色器的功能,还直接影响渲染性能。以下是一些性能优化的建议和最佳实践。

矩阵运算优化

矩阵运算,特别是矩阵乘法,在着色器中可能是计算密集型的操作。优化矩阵运算的方法包括:

  • 尽可能使用更小的矩阵:如果2D变换足够,使用3x3矩阵而不是4x4矩阵
  • 利用矩阵的特殊结构:例如,如果矩阵是单位矩阵或对角矩阵,可以简化计算
  • 预计算常量矩阵:如果矩阵在渲染过程中不会改变,在CPU端预计算并作为常量传递

在Shader Graph中,可以通过精心设计节点网络来最小化不必要的矩阵运算。

内存访问模式

矩阵在内存中的布局影响访问效率。在HLSL中,矩阵默认以列主序存储,这意味着连续内存访问通常按列进行:

  • 优化矩阵向量乘法:确保向量与矩阵的乘法操作利用缓存局部性
  • 避免不必要的转置操作:转置矩阵可能导致低效的内存访问模式
  • 使用合适的矩阵初始化方法:选择最适合访问模式的初始化方式

理解内存访问模式有助于编写更高效的着色器代码。

精度控制

在Shader Graph中,可以通过精度修饰符控制矩阵计算的精度,从而平衡性能和质量:

  • 高精度:使用float类型,提供最高精度,适用于关键计算
  • 中等精度:使用half类型,在移动设备上提供更好的性能
  • 低精度:使用fixed类型,适用于颜色计算等对精度要求不高的场景

选择合适的精度可以显著提高着色器性能,特别是在移动设备上。

分支优化

在矩阵计算中避免复杂的分支逻辑,因为着色器中的分支可能导致性能下降:

  • 使用数学技巧替代条件语句:例如,使用lerpstep函数替代if语句
  • 将条件判断移出循环:如果可能,在矩阵运算外部处理条件逻辑
  • 使用常量折叠:让着色器编译器优化常量表达式

通过优化分支逻辑,可以提高着色器在GPU上的执行效率。

高级技巧

除了基本用法外,Matrix 3x3节点还可以用于实现一些高级技巧和复杂效果。

矩阵分解

有时需要将复杂的变换矩阵分解为基本变换(旋转、缩放、斜切)的组合。矩阵分解可以用于:

  • 动画系统:将复杂变换分解为可动画的参数
  • 效果控制:独立控制变换的各个方面
  • 数据压缩:存储变换的分解形式而非完整矩阵

在Shader Graph中,可以通过数学节点网络实现简单的矩阵分解操作。

矩阵插值

在两个或多个矩阵之间进行插值是实现平滑动画和过渡的重要技术:

  • 线性插值:使用lerp函数在两个矩阵之间插值
  • 球面线性插值:对于旋转矩阵,使用四元数插值获得更平滑的结果
  • 分层插值:对矩阵的不同组成部分使用不同的插值方法

矩阵插值在角色动画、相机过渡和动态效果中非常有用。

自定义矩阵函数

通过Shader Graph的自定义函数节点,可以创建复杂的矩阵操作函数:

  • 矩阵求逆:实现3x3矩阵的求逆算法
  • 特征值分解:用于高级数学分析和效果
  • 特定领域变换:为特定应用定制矩阵变换

这些自定义函数扩展了Matrix 3x3节点的功能,使其适用于更专业的应用场景。

与其它节点的组合

Matrix 3x3节点可以与Shader Graph中的其他节点组合,创建复杂的效果:

  • 与Sample Texture 2D节点组合:实现动态纹理变换
  • 与Position节点组合:创建基于位置的变换效果
  • 与Time节点组合:实现随时间变化的动画效果
  • 与Custom Function节点组合:实现自定义矩阵算法

通过节点组合,可以充分发挥Matrix 3x3节点的潜力,创建各种视觉上引人注目的效果。


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

❌
❌