阅读视图

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

“啪啪啪”三下键盘,极速拉起你的 uni-app 项目!

说实话,我也不想造轮子。但试了一圈之后,我发现了一个让我忍不了的问题:选了不要某个功能,生成的代码里居然还有它的 import 和空壳文件。 与其花半小时手动删代码,不如用 hy-uni —— 三下键盘,1 秒钟搞定!


🚫 那些年,我们新建项目后手动删过的代码

如果你经常用社区的高分脚手架创建项目,一定会遇到这个进退两难的死胡同:

  • 官方模板太"毛坯":API 拦截器、状态管理全要自己从 0 开始配。新手直接劝退。

  • 社区模板太"精装":不仅送你一堆组件,还送你几个业务全景页。新建项目第一件事,就是花半小时去删那些不需要的页面和 npm 包。最痛苦的是,删的时候还得提心吊胆,生怕漏删了某个 import 导致整个项目一跑就白屏报错。

第 21 次从头搭项目时,我终于受不了了。于是,我过年时花了点时间写了 hy-uni


🎯 先说结论:三下键盘,极速拉起项目

一条命令,三下键盘,1 秒钟,带给你一个干干净净的、随时可进入业务开发的工业级 uni-app 项目:

# ⚡ 极速拉起纯净骨架(1 秒钟)
npx hy-uni my-app --pure
# 或者 📋 交互式精装配置(30 秒内完成)
npx hy-uni my-app

核心理念:你不要的功能,连一行代码、一段注释、一个 npm 依赖,都不该出现在最终的产物中。


⚡ 速度对比(为什么说"极速"?)

方案 时间 特点
hy-uni --pure ⚡ 1 秒 三下键盘极速拉起纯净骨架
hy-uni (交互) 📋 30 秒 选择功能后自动生成完整项目
官方脚手架 5 分钟+ 毛坯房,需要自己配置工程化
社区全量模板 10 分钟+ 功能全但冗余,需要手动删代码

关键对比:hy-uni 不仅快,而且不用删代码 —— 你不选的功能从代码到依赖全部消失。


💻 极客最爱的"双轨"构建体验

很多老手开发者拥有"代码洁癖",喜欢毫无业务代码的"极净空壳";也有很多开发者希望项目能"满级出生",自带网络请求和主题切换方案。

在这款 CLI 中,我们将选择权完全交还给你。

路线 A:极速构建"极致纯净"空壳(老手狂喜)

对于只想要**"帮我把工程化基建搭好,其他的我自己来"**的极客,你只需在命令后敲入一个 --pure 参数:


npx hy-uni my-app --pure

啪啪啪三下键盘,敲下回车,1秒钟静默生成。 没有任何繁琐的交互问答选项,你将直接获得一个强迫症狂喜的极净项目:

  • 只有基础工程化体系:Vue 3 + TypeScript + Vite + UnoCSS + Pinia 开箱即用。

  • 没有任何网络请求、主题切换、业务示例等多余代码。

  • 目录结构极其纯粹,没有多余的文件夹。

路线 B:交互式精装配置(开箱即用)

如果不加 --pure,CLI 则会提供完全可定制的丝滑交互面板:


┌ 🚀 火叶 - 快速创建高性能 uni-app 项目
│
● 模板来源: 缓存 (~/.huoye/templates/) [2天前更新]
│
◇ 请输入项目名称:
│ my-app
│
◇ 请选择创建路径:
│ ./demo
│
◇ 是否需要网络请求层?
│ ○ Yes / ● No
│
◆ 是否需要业务示例页面?
│ ○ Yes / ● No
│
◆ 是否需要主题管理?
│ ○ Yes / ● No
│
◆ 确认创建项目?
│ ● Yes / ○ No
│
◇ 🎉 恭喜!您的项目已准备就绪。
│
◇ Getting Started  ─────────╮
│                           │
│ $ cd demo/my-app          │
│ $ pnpm install            │
│ $ pnpm dev:h5             │
│                           │
├───────────────────────────╯

此时,选择全选 Yes 的你,将获得一个"满级配置"项目:

  • 封装极佳的 Http 客户端、请求拦截器体系及全局错误分类处理机制。

  • 完善的亮暗色主题无缝切换落地方案及 CSS 变量体系。

最硬核的是:无论你是走纯净路线还是全选路线,生成的项目 App.vuemain.ts 以及 package.json 中的所有代码,都会像你自己手写的一般融洽,没有任何一点"被暴力注销掉"的痕迹。

💡 温馨提示:三个功能之间有依赖关系。"业务示例页面"依赖"网络请求层"——因为示例必须有 API 封装才能跑起来。所以如果你不选"网络请求层",CLI 就不会问你要不要"业务示例"。这样设计是为了保证生成的项目永远可以直接运行,没有任何破碎的依赖关系。


💡 三种使用场景速查

我想要 命令 适合谁
极速纯净空壳 npx hy-uni my-app --pure 有代码洁癖的老手,想自己搭业务
交互式精装配置 npx hy-uni my-app 想要完整方案,但不想要冗余代码
本地开发版本 npx hy-uni my-app --local 项目贡献者,想用最新开发模板

📂 看看生成出来的项目差异

路线 A 生成结果(--pure)

my-app/
├── src/
│ ├── pages/
│ │ ├── index/index.vue
│ │ └── about/about.vue
│ ├── layouts/default.vue
│ ├── store/index.ts
│ ├── utils/
│ │ ├── platform.ts
│ │ ├── system.ts
│ │ ├── data.ts
│ │ └── time.ts
│ ├── style/
│ └── static/
├── vite.config.ts
├── tsconfig.json
└── package.json ← 只有基础依赖

路线 B 生成结果(全选)


my-app/
├── src/
│ ├── pages/
│ │ ├── index/index.vue
│ │ ├── about/about.vue
│ │ ├── theme/ ← 新增
│ │ └── examples/ ← 新增
│ │ ├── api-demo.vue
│ │ ├── form-demo.vue
│ │ └── list-demo.vue
│ ├── api/ ← 新增
│ │ ├── client.ts
│ │ ├── interceptors.ts
│ │ ├── errors.ts
│ │ └── modules/
│ ├── composables/
│ │ └── useTheme.ts ← 新增
│ ├── config/
│ │ └── theme.ts ← 新增
│ ├── components/
│ │ └── ThemeToggle.vue ← 新增
│ ├── store/
│ │ ├── theme.ts ← 新增
│ │ ├── index.ts
│ │ └── modules/
│ │ ├── app.ts
│ │ └── counter.ts ← 新增
│ ├── layouts/default.vue
│ ├── utils/
│ ├── style/
│ └── static/
├── vite.config.ts
├── tsconfig.json
└── package.json ← 完整的依赖列表

对比一目了然 —— 不选就是真的没有,不是"注释掉"。


🛠️ 不只是干净:开箱即用的重型工程底座

不管你怎么选裁剪,hy-uni 都为你提供了工业级的开发体验,包含了 7 个 Vite 核心插件的自动装配:

插件 作用
vite-plugin-uni-pages 页面自动路由生成
vite-plugin-uni-layouts 布局系统搭建
vite-plugin-uni-manifest manifest 编程化配置
vite-plugin-uni-components 组件按需自动导入
unplugin-auto-import Vue / uni-app API 自动导入
UnoCSS 原子化极速 CSS 构建
mp-selector-transform 小程序选择器兼容隔离转换

这意味着,创建完项目后:

  • 你不需要手动导入 refonMounted

  • 你不需要手动去繁琐的 pages.json 注册页面和组件。

  • 路径别名 @/src/ 已全部打通。

  • 开发体验直接拉满。


✨ 你到底能得到什么?

基础工程化(所有项目都有)

  • Vue 3 + TypeScript —— 类型安全,开发爽

  • Vite 5 —— 毫秒级热更新,极速开发

  • 7 个 Vite 插件 —— 页面自动路由、组件自动导入、manifest 编程化配置等,全配好

  • UnoCSS —— 按需生成原子化 CSS,再也不用手写 class

  • Pinia 状态管理 —— 开箱即用的持久化存储(适配小程序)

  • ESLint + TypeScript 类型检查 —— 代码规范自动化

可选功能 1:网络请求层

选了它,项目会多出完整的 src/api/ 目录:

import { get, post } from "@/api"
// GET 请求,自动拼接 params
const users = await get("/users", { page: 1, limit: 10 })
// POST 请求
const result = await post("/users", { name: "张三", age: 25 })

你获得了什么:

  • HTTP 客户端(基于 uni.request,支持 GET/POST/PUT/DELETE/PATCH)

  • 请求/响应/错误拦截器(自动注入 Token、处理超时等)

  • 7 种自定义错误分类(网络、超时、鉴权、权限等)

  • 跨平台兼容(H5 / 小程序 / App 无缝切换)

  • 完整的 API 模块化示例

不选它? src/api/ 目录根本不存在,package.json 里也没任何相关依赖。干干净净。

可选功能 2:主题管理

选了它,你就能这样用:

<script setup>
import { useTheme } from "@/composables/useTheme"
const { isDark, themeStore } = useTheme()
</script>
<template>
<button @click="themeStore.toggleTheme()">
{{ isDark ? "切换到亮色" : "切换到暗色" }}
</button>
</template>

你获得了什么:

  • 亮色/暗色/跟随系统 三种主题模式

  • 8 种预设主色调,可自定义

  • 20+ CSS 变量自动注入

  • 多端适配(H5 用 CSS 变量、小程序用全局事件、App 用状态栏同步)

  • 主题切换组件 + 完整的设置页面

不选它? 上面所有文件全部消失。布局组件里的主题代码也会被移除,替换成一个固定的 background-color: #f8f8f8 —— 不是留空,而是提供正确的 fallback。

可选功能 3:业务示例页面

选了它(需要先选网络请求层),你会得到 3 个完整的业务演示:

  • API 调用演示 —— 列表获取、详情查看、数据创建的完整流程

  • 表单演示 —— 输入、选择、复选、日期选择器,带表单验证

  • 列表演示 —— 上拉加载、下拉刷新、搜索过滤的完整实现

这不是 "Hello World",每个页面都是可以直接拿来改改就用的业务代码

不选它? 这些示例页面全部消失,首页上的导航入口也会一起消失(不会留下死链接)。


⚙️ 底层揭秘:如何做到代码级无痕裁剪?

一般的脚手架提供的是"多套模板分支组合"。而 hy-uni 创新性地引入了 "特征标记系统 (Feature Markers)",实现了一份源码,2^N 种自由组合引擎

我们在架构底层源码中,巧妙地隐藏了特定的注释标记:

1. 单行精确抹除

如果在 CLI 里没选 examples 示例功能,下面带有 // 【examples】 标记的代码行,会从物理层面直接消失:

export * from "./modules/app"
export { useCounterStore } from "./modules/counter" // 【examples】

2. 块级区域剥离(支持多语言环境)

如果没选 theme 主题功能,被包裹的代码块整块剥离(支持 TS、SCSS、Vue 甚至 HTML 注释):

<!-- 【theme:start】 -->
<view class="nav-link" @click="goToPage('/pages/theme')">
    <text>主题设置</text>
</view>
<!-- 【theme:end】 -->

3. 独门绝技:反向兜底(Fallback)裁剪

这是市面上其他脚手架极难做到的技术细节。针对"如果不选某个高阶模块,我仍然需要保留一套写死的基础兜底代码"的场景,我们设计了 ! 反向保留标记:


.layout {
    // 【!theme:start】 (如果没选动态主题,就保留这段写死的极简灰色背景)
    background-color: #f8f8f8;
    // 【!theme:end】

    // 【theme:start】 (如果选了主题,才保留动态的 CSS 变量注入机制)
    background-color: var(--bg-color-primary);
    transition: background-color 0.3s;
    // 【theme:end】
}

正是这套底层切割引擎,加上我们对 npm 依赖 dependencies 的按树剥离,以及支持功能间的链式感知(不支持底层功能时不展示进阶询问逻辑),才铸就了极致纯净的代码产物质量。


🔧 进阶:把它变成你们团队的专属黑科技

"这套裁剪逻辑不错,但我司有祖传架构,我单纯想白嫖这套神级裁剪引擎怎么办?"

完全没问题。整个脚手架能力是靠底层模板根目录的 .templaterc.json 驱动的:

{
"features": {
    "auth": {
           "name": "权限管理",
           "files": ["src/store/user.ts"],
           "dependencies": ["jwt-decode"]
        }
    }
}

结合在你的祖传代码里打上好 // 【auth】 标记,你就可以把 hy-uni 当作你们内部团队私有化的高阶脚手架来直接复用!

(剧透:在这个大版本之后,我们将正式支持 hy-uni template add 命令,允许你直接接管并挂载任意外部 Git 仓库,搭建你的私有定制生态!)


🚀 立即体验(极速拉起只需 3 个命令)

别再对着一堆乱糟糟的精装房一筹莫展了:

# 极速纯净版
npx hy-uni my-app --pure

创建后的常用命令

cd my-app
pnpm install

# 开发命令
pnpm dev:h5 # H5 本地开发(localhost:3000)
pnpm dev:mp # 微信小程序开发
pnpm dev:app # App 开发

# 构建命令
pnpm build:h5 # H5 生产构建
pnpm build:mp # 小程序构建

# 检查命令
pnpm lint # ESLint 检查 + 自动修复
pnpm type-check # TypeScript 类型检查


📊 跟现有方案对比

官方模板 社区全量模板 hy-uni
创建后能直接开发 ❌ 需要自己搭 ✅ 能,但要先删一堆 ✅ 开箱即用
功能选择 ❌ 无 ❌ 无 / 模板分支 ✅ 交互式按需选择
不要的功能 N/A ⚠️ 自己删(怕误删) ✅ 从代码到依赖全清理
生成代码质量 空壳 ⚠️ 可能有残留 ✅ 零残留,像手写的
模板维护成本 ⚠️ 高(N 个分支) ✅ 低(1 份模板)
极速纯净模式 --pure 1秒钟

🔗 获取地址(直达阵地)

核心源码不到 500 行,没有任何冗余包装。如果你也是代码洁癖患者,恰好懂我对极致整洁的坚持,欢迎来给我点一个宝贵的 Star!使用中发现任何 Bug,随时 Issue 见!


📌 总结

hy-uni

  • 我只想要骨架--pure 1秒钟搞定,零冗余

  • 我想要完整方案 → 交互式选择,按需组合

  • 我想要纯净但有示例 → 选 API + 示例,不选主题

  • 我想用自己的模板 → 即将支持,用我们的引擎

核心理念:你不要的功能,连一行代码都不该出现。


🚀 现在就试试


npx hy-uni my-app

让我们一起告别"删文件夹"的时代。

Electron 无边框窗口拖拽实现

Electron 无边框窗口拖拽实现详解:从问题到完美解决方案

技术栈: Electron 40+, Vue 3.5+, TypeScript 5.9+

🎯 问题背景

在开发 Electron 无边框应用时,遇到一个经典问题:如何让用户能够拖拽移动整个窗口?

传统的 Web 应用有浏览器标题栏可以拖拽,但 Electron 的无边框窗口 (frame: false) 完全去除了系统原生的标题栏,这就需要我们自己实现拖拽功能。

挑战

  1. 右键误触发: 用户右键点击时窗口也会跟着移动
  2. 定时器泄漏: 鼠标抬起后窗口仍在跟随鼠标
  3. 事件覆盖不全: 忘记处理鼠标离开窗口等边界情况
  4. 性能问题: 频繁的位置计算导致卡顿
  5. 安全考虑: 如何在保持功能的同时确保 IPC 通信安全

本文将从零开始,实现一个 Electron 窗口拖拽解决方案。

🛠️ 技术方案概览

我们采用 渲染进程 + IPC + 主进程 的三层架构:

[Vue 组件][IPC 安全通道][Electron 主进程][窗口控制]

核心优势:

  • ✅ 精确区分鼠标左/右键
  • ✅ 完整的事件生命周期管理
  • ✅ 内存安全(无定时器泄漏)
  • ✅ 安全的 IPC 通信
  • ✅ 流畅的用户体验(60fps)

🔧 详细实现步骤

第一步:主进程窗口配置

首先确保你的 Electron 窗口正确配置为无边框模式:

// electron/main/index.ts
const win = new BrowserWindow({
  title: 'Main window',
  frame: false,           // 关键:禁用系统标题栏
  transparent: true,      // 透明窗口(可选)
  backgroundColor: '#00000000', // 完全透明
  width: 288,
  height: 364,
  webPreferences: {
    preload: path.join(__dirname, '../preload/index.mjs'),
  }
})

第二步:预加载脚本 - 安全的 IPC 桥梁

使用 contextBridge 安全地暴露 API 给渲染进程:

// electron/preload/index.ts
import { ipcRenderer, contextBridge } from 'electron'

contextBridge.exposeInMainWorld('ipcRenderer', {
  // ... 其他 IPC 方法
  
  // 暴露窗口拖拽控制方法
  windowMove(canMoving: boolean) {
    ipcRenderer.invoke('windowMove', canMoving)
  }
})

为什么这样做?

  • 避免直接暴露完整的 ipcRenderer
  • 限制可调用的方法范围
  • 符合 Electron 安全最佳实践

第三步:主进程拖拽逻辑

创建专门的拖拽工具函数:

// electron/main/utils/windowMove.ts
import { screen } from "electron";

// 全局定时器引用 - 关键!
let movingInterval: NodeJS.Timeout | null = null;

export default function windowMove(
  win: Electron.BrowserWindow | null, 
  canMoving: boolean
) {
  let winStartPosition = { x: 0, y: 0 };
  let mouseStartPosition = { x: 0, y: 0 };

  if (canMoving && win) {
    // === 启动拖拽 ===
    console.log("main start moving");
    
    // 记录起始位置
    const winPosition = win.getPosition();
    winStartPosition = { x: winPosition[0], y: winPosition[1] };
    mouseStartPosition = screen.getCursorScreenPoint();
    
    // 清理已存在的定时器 - 防止重复
    if (movingInterval) {
      clearInterval(movingInterval);
      movingInterval = null;
    }
    
    // 启动位置更新定时器 (20ms ≈ 50fps)
    movingInterval = setInterval(() => {
      const cursorPosition = screen.getCursorScreenPoint();
      
      // 相对位移算法
      const x = winStartPosition.x + cursorPosition.x - mouseStartPosition.x;
      const y = winStartPosition.y + cursorPosition.y - mouseStartPosition.y;
      
      // 更新窗口位置
      win.setResizable(false); // 拖拽时禁止调整大小
      win.setBounds({ x, y, width: 288, height: 364 }); // 使用setBounds 同时设置位置和宽高防止拖动过程窗口变大,宽高可动态获取
    }, 20);
    
  } else {
    // === 停止拖拽 ===
    console.log("main stop moving");
    
    // 清理定时器
    if (movingInterval) {
      clearInterval(movingInterval);
      movingInterval = null;
    }
    
    // 恢复窗口状态
    if (win) {
      win.setResizable(true);
    }
  }
}

关键设计点:

  1. 全局定时器: movingInterval 声明在模块级别,确保能被正确清理
  2. 相对位移算法: 基于起始位置的相对移动,避免累积误差
  3. 防重复机制: 每次启动前清理已有定时器
  4. 窗口状态管理: 拖拽时禁用调整大小,结束后恢复

第四步:渲染进程事件处理

在 Vue 组件中精确处理鼠标事件:

<!-- src/App.vue -->
<script setup lang="ts">
import Camera from './components/Camera.vue'

// 调用主进程拖拽方法
const windowMove = (canMoving: boolean): void => {
  window?.ipcRenderer?.windowMove(canMoving);
}

// 只有左键按下时才开始移动
const handleMouseDown = (e: MouseEvent) => {
  if (e.button === 0) { // 0 = 左键, 1 = 中键, 2 = 右键
    windowMove(true);
  }
}

// 鼠标抬起时停止移动(任何按键)
const handleMouseUp = () => {
  windowMove(false);
}

// 鼠标离开容器时停止移动
const handleMouseLeave = () => {
  windowMove(false);
}

// 右键菜单处理 - 关键!
const handleContextMenu = (e: MouseEvent) => {
  e.preventDefault(); // 阻止默认右键菜单
  windowMove(false);  // 确保停止拖拽
}
</script>

<template>
  <div class="app-container" 
       @mousedown="handleMouseDown" 
       @mouseleave="handleMouseLeave"
       @mouseup="handleMouseUp" 
       @contextmenu="handleContextMenu">
    <Camera />
  </div>
</template>

鼠标按键值参考:

  • e.button === 0: 左键 (Left click)
  • e.button === 1: 中键 (Middle click)
  • e.button === 2: 右键 (Right click)

第五步:主进程 IPC 处理器

注册 IPC 处理器并集成拖拽逻辑:

// electron/main/index.ts
import windowMove from './utils/windowMove'

// ... 其他代码 ...

// 注册 IPC 处理器
ipcMain.handle("windowMove", (_, canMoving) => {
  console.log('ipcMain.handle windowMove', canMoving)
  windowMove(win, canMoving)
})

🔒 安全最佳实践

1. IPC 方法限制

// 好的做法:只暴露必要方法
contextBridge.exposeInMainWorld('ipcRenderer', {
  windowMove: (canMoving) => ipcRenderer.invoke('windowMove', canMoving)
})

// 避免:暴露完整 ipcRenderer
// contextBridge.exposeInMainWorld('ipcRenderer', ipcRenderer)

2. 输入验证

// 在主进程中验证输入
if (typeof canMoving !== 'boolean') {
  throw new Error('Invalid parameter');
}

3. 窗口引用安全

// 始终检查窗口是否存在
if (!win || win.isDestroyed()) {
  return;
}

🔄 替代方案对比

方案 A: CSS -webkit-app-region: drag (推荐用于简单场景)

.drag-area {
  -webkit-app-region: drag;
}

优点: 零 JavaScript,硬件加速,无 IPC 开销
缺点: 无法区分鼠标按键,会阻止所有鼠标事件

方案 B: 完整的自定义拖拽 (本文方案)

优点: 完全可控,支持复杂交互,可区分按键
缺点: 需要 IPC 通信,代码量较大

选择建议

  • 简单应用: 使用 CSS 方案
  • 复杂交互: 使用本文的自定义方案
  • 混合方案: 在非交互区域使用 CSS,在需要精确控制的区域使用自定义方案

💡 扩展功能思路

1. 拖拽区域限制

// 限制窗口不能拖出屏幕
const bounds = screen.getDisplayNearestPoint(cursorPosition).bounds;
const newX = Math.max(bounds.x, Math.min(x, bounds.x + bounds.width - windowWidth));
const newY = Math.max(bounds.y, Math.min(y, bounds.y + bounds.height - windowHeight));

2. 拖拽动画效果

// 拖拽开始时添加阴影
win.webContents.executeJavaScript(`
  document.body.style.boxShadow = '0 4px 12px rgba(0,0,0,0.3)';
`);

// 拖拽结束时移除
win.webContents.executeJavaScript(`
  document.body.style.boxShadow = 'none';
`);

3. 多显示器支持

// 获取所有显示器信息
const displays = screen.getAllDisplays();
// 根据当前显示器调整拖拽行为

📚 完整项目结构

electron-camera/
├── electron/
│   ├── main/
│   │   ├── utils/windowMove.ts    # 拖拽核心逻辑
│   │   └── index.ts               # 主进程入口
│   └── preload/index.ts           # IPC 安全桥梁
└── src/
    └── App.vue                    # 渲染进程事件处理

🤝 总结

通过本文的完整实现,你将获得一个:

  • 功能完整 的窗口拖拽解决方案
  • 安全可靠 的 IPC 通信架构
  • 性能优秀 的用户体验
  • 易于维护 的代码结构

这个方案已经在实际项目中经过充分测试,可以直接用于你的 Electron 应用开发。

在实现过程中发现还有一个好的库,github.com/Wargraphs/e… 有空可以试试。

如果你觉得这篇文章对你有帮助,请点赞、收藏或分享给其他开发者!

有任何问题或改进建议,欢迎在评论区讨论! 🚀

仅仅一行 CSS,竟让 2000 个节点的页面在弹框时卡成 PPT?

【哲风壁纸】可爱玩偶-地面落叶.png

前言

在最近的一个会议室排期系统(类似甘特图)的性能优化中,我遇到了一个诡异的现象:页面初始化非常流畅,但在点击“详情”打开 el-dialog 弹框时,遮罩层的渐入动画极其卡顿,掉帧感严重。

我原本以为是 DOM 节点过多(约 2000 个)导致 Vue 响应式数据更新太慢。但在排查过程中,我发现罪魁祸首竟然是一行看似为了“设计感”而存在的 CSS 属性:mix-blend-mode: multiply;

1. 现象描述:消失的帧率

我们的系统在横轴(时间)和纵轴(会议室)的交叉网格中渲染了大量的“档期卡片”。为了让卡片的背景色能和底部的网格线、文字有更好的融合感,代码中使用了 CSS 混合模式:

.card-bg {
  position: absolute;
  /* 混合模式:正片叠底 */
  mix-blend-mode: multiply; 
  background-color: #e6f7ff;
}

当页面只有几十个节点时,一切正常。但当展示 6 周数据,节点数达到 2000+  时,每当点击打开 el-dialog,浏览器就像陷入了泥潭。


2. 核心原因:混合模式背后的渲染逻辑

为什么 mix-blend-mode 会成为性能杀手?这要从浏览器的渲染机制说起。

A. 像素级重算(Pixel-by-Pixel Calculation)

常规的 background-color 渲染非常简单:浏览器只需要知道这个像素点的 RGB 值,直接涂色即可。

但 mix-blend-mode 不同。它要求浏览器执行 CSS Compositing(层叠组合)  规范。以 multiply(正片叠底)为例,浏览器渲染每一个像素点时,必须执行以下公式:

C=Cs×Cb255C = \frac{C_s \times C_b}{255}
  • CsC_s:当前图层颜色值(source)
  • CbC_b:底层背景颜色值(background)
  • CC:混合后的颜色值

这意味着,浏览器在绘制这 2000 个节点时,不能简单地“涂色”,而是必须先读取底层网格、文字、背景的颜色,再进行数学计算,最后输出结果。

B. 强制创建堆叠上下文(Stacking Context)

一旦元素应用了 mix-blend-mode(且值不为 normal),浏览器会强制该元素及其子元素创建一个新的堆叠上下文

在 2000 个节点上同时开启混合模式,会创建 2000 个 stacking context,并可能触发额外的合成层管理和 GPU 参与。这极大地消耗了显存和合成器的性能。

C. “弹框卡顿”的终极诱因:图层合成爆炸

这是最关键的一点。当你打开 el-dialog 时:

  1. el-dialog 会带有一个全屏的半透明遮罩层(Overlay)。
  2. 遮罩层在做淡入淡出动画(Opacity Animation)。
  3. 连锁反应:因为下方 2000 个节点都具有混合属性,它们对“背景”及其敏感。当上方的遮罩层颜色或透明度发生变化时,浏览器认为下方所有节点的“最终成色”都可能受到影响,从而被迫在动画的每一帧中,对这 2000 个节点进行全量的混合重计算和重绘。

渲染引擎在每一秒内要进行几十万次的像素乘法运算,GPU 瞬间满载,动画自然就变成了 PPT。


3. 解决方案:返璞归真

解决办法出奇地简单:移除混合模式,改用传统的透明色。

/* 优化前 */
.card-bg {
  mix-blend-mode: multiply;
  background-color: #e6f7ff;
}

/* 优化后 */
.card-bg {
  /* 移除混合模式 */
  /* 使用带透明度的 rgba 或者直接指定固定色值 */
  background-color: rgba(230, 247, 255, 0.8);
}

通过这一行代码的改动,浏览器不再需要读取背景像素进行乘法运算,节点被归类为普通渲染任务。再次打开 el-dialog,遮罩层的动画恢复到了丝滑的 60 FPS。


4. 经验总结:避开 CSS 的渲染陷阱

在构建高密度数据看板或复杂网格系统时,我们需要警惕以下这些“昂贵”的 CSS 属性:

  1. mix-blend-mode:在大量节点上使用是性能灾难。
  2. filter (如 blur(), drop-shadow()) :同样涉及复杂的卷积运算和像素偏移计算。
  3. box-shadow:特别是带有扩散半径的大面积阴影,会显著增加重绘成本。

视觉设计固然重要,但在节点过千的 B 端系统中,性能优先。  很多时候,通过预先计算好颜色值(如将混合后的颜色直接写死为 HEX),不仅能达到 90% 的视觉相似度,更能换来 100% 的交互流畅度。


如何在Vue3中优化生命周期钩子性能并规避常见陷阱?

一、Vue3 生命周期钩子基础回顾

1.1 生命周期钩子的核心作用

Vue3 组件从创建到销毁会经历一系列标准化阶段,生命周期钩子就是在这些阶段触发的回调函数,让开发者能在特定时机注入自定义逻辑。比如:

  • onMounted:组件首次渲染完成、DOM 节点创建后执行,适合初始化第三方库、获取DOM元素或发起初始数据请求。
  • onUpdated:组件响应式数据更新导致DOM重新渲染后执行,可用于处理更新后的DOM操作。
  • onUnmounted:组件从DOM中卸载前执行,用于清理资源(如定时器、事件监听)防止内存泄漏。

所有钩子的this上下文默认指向当前组件实例,但需注意不能使用箭头函数声明钩子,否则会丢失this指向。

1.2 正确的钩子注册方式

<script setup>中注册钩子的标准写法:

<script setup>
import { onMounted, onUnmounted } from 'vue'

// 同步注册钩子(必须在setup执行栈内同步调用)
onMounted(() => {
  console.log('组件已挂载,可操作DOM')
})

onUnmounted(() => {
  console.log('组件即将卸载,清理资源')
})
</script>

⚠️ 错误示例:异步注册钩子会失效

// 错误:setTimeout异步调用导致钩子无法关联当前组件实例
setTimeout(() => {
  onMounted(() => { /* 此回调不会执行 */ })
}, 100)

二、性能优化策略:让生命周期钩子更高效

2.1 onMounted:聚焦初始化必要操作

onMounted是组件初始化的关键节点,但需避免在此执行冗余逻辑:

  • 优化点1:合并重复DOM操作,避免频繁重排重绘
  • 优化点2:延迟非关键初始化(如非首屏必需的第三方库)到用户交互后
  • 优化点3:批量发起数据请求,减少网络开销

示例:按需加载第三方图表库

<script setup>
import { onMounted, ref } from 'vue'
const chartRef = ref(null)

onMounted(async () => {
  // 首屏优先渲染,延迟加载非关键库
  const { Chart } = await import('chart.js')
  new Chart(chartRef.value, { /* 配置项 */ })
})
</script>
<template>
  <canvas ref="chartRef"></canvas>
</template>

2.2 onUpdated:避免不必要的重复执行

onUpdated会在每次数据更新后触发,若处理不当极易引发性能问题:

  • 优化点1:用watch替代onUpdated监听特定数据变化,避免全局更新触发冗余逻辑
  • 优化点2:添加条件判断,仅在目标数据变化时执行操作
  • 优化点3:避免在onUpdated中修改响应式数据(会触发无限循环更新)
往期文章归档
免费好用的热门在线工具

示例:用watch替代onUpdated实现精准监听

<script setup>
import { ref, watch } from 'vue'
const tableData = ref([])

// 仅在tableData变化时执行表格重绘,而非每次组件更新都执行
watch(tableData, (newData) => {
  console.log('表格数据更新,执行重绘逻辑')
  // 调用表格重绘方法
}, { deep: true })
</script>

2.3 onUnmounted:及时清理资源防止泄漏

组件卸载时必须清理所有外部资源,否则会导致内存泄漏:

  • 清理定时器/间隔器
  • 移除DOM事件监听
  • 取消数据订阅(如WebSocket、RxJS流)
  • 销毁第三方库实例

示例:完整的资源清理流程

<script setup>
import { onMounted, onUnmounted } from 'vue'
let timer = null
let resizeHandler = null

onMounted(() => {
  timer = setInterval(() => {
    console.log('定时任务执行中...')
  }, 1000)

  resizeHandler = () => {
    console.log('窗口大小变化')
  }
  window.addEventListener('resize', resizeHandler)
})

onUnmounted(() => {
  // 清理定时器
  clearInterval(timer)
  // 移除事件监听
  window.removeEventListener('resize', resizeHandler)
})
</script>

2.4 合理选择钩子:用组合式API替代传统钩子

Vue3的组合式API允许将相关逻辑聚合,减少钩子中的碎片化代码。比如:

  • watchEffect替代onMounted + onUnmounted组合,自动处理依赖清理
  • computed替代onUpdated中的重复计算

示例:watchEffect自动清理资源

<script setup>
import { watchEffect } from 'vue'

watchEffect((onInvalidate) => {
  const timer = setInterval(() => {
    console.log('定时任务')
  }, 1000)

  // 组件卸载或依赖变化时自动执行清理
  onInvalidate(() => {
    clearInterval(timer)
  })
})
</script>

三、常见陷阱与规避方案

3.1 箭头函数导致的this指向错误

陷阱:用箭头函数声明钩子,导致this无法指向组件实例

// 错误示例
onMounted(() => {
  console.log(this) // undefined,箭头函数继承外部this
})

规避方案:始终使用普通函数声明钩子,或在<script setup>中直接使用组合式API(无需依赖this

3.2 onUpdated中的无限循环陷阱

陷阱:在onUpdated中修改响应式数据,触发新一轮更新导致无限循环

// 错误示例:会导致无限循环
onUpdated(() => {
  this.count++ // 修改响应式数据,再次触发onUpdated
})

规避方案

  1. watch监听特定数据变化,仅在目标数据更新时执行逻辑
  2. 添加条件判断,确保数据修改仅在必要时执行

3.3 未清理的全局事件监听

陷阱:在组件中添加全局事件监听(如window.resize),但未在onUnmounted中移除,导致组件卸载后监听仍存在 规避方案:在onUnmounted中严格匹配移除事件,或使用watchEffectonInvalidate自动清理

3.4 依赖第三方库的资源泄漏

陷阱:在onMounted中初始化第三方库实例(如地图、图表),但未在onUnmounted中销毁,导致DOM节点已卸载但实例仍占用内存 规避方案:查阅第三方库文档,调用实例的销毁方法(如map.destroy()

四、课后Quiz:巩固你的理解

问题1:如何避免在onUpdated中触发无限循环?

答案解析

  • 方案1:使用watch替代onUpdated,仅监听特定响应式数据变化,而非全局更新
  • 方案2:在onUpdated中添加条件判断,仅当目标数据发生预期变化时才执行逻辑
  • 方案3:避免在onUpdated中直接修改响应式数据,若必须修改需添加防抖/节流控制

问题2:组件卸载时必须清理哪些类型的资源?

答案解析

  1. 定时器/间隔器(setTimeout/setInterval
  2. 全局事件监听(window.addEventListener绑定的事件)
  3. 第三方库实例(如地图、图表、WebSocket连接)
  4. 自定义的订阅/发布事件(如Vuex的subscribe、EventBus)

问题3:为什么不能用箭头函数声明生命周期钩子?

答案解析: 箭头函数没有自己的this上下文,会继承外层作用域的this。在Vue钩子中,默认this指向组件实例,使用箭头函数会导致this丢失,无法访问组件的响应式数据和方法。

五、常见报错与解决方案

5.1 报错:Cannot read property 'xxx' of undefined

场景:在钩子中使用this.xxx时出现 原因:使用箭头函数声明钩子导致this指向错误 解决办法:将箭头函数改为普通函数,或在<script setup>中直接使用组合式API(无需this

5.2 报错:onMounted中获取DOM元素为null

场景:在onMounted中通过document.querySelector获取组件内DOM元素返回null 原因:组件的DOM结构可能使用了v-if条件渲染,导致元素在onMounted时未被创建 解决办法

  1. 使用Vue的模板引用(ref)替代原生DOM查询
  2. 若必须使用原生查询,可包裹在nextTick中确保DOM更新完成
<script setup>
import { onMounted, nextTick } from 'vue'

onMounted(async () => {
  await nextTick()
  const element = document.querySelector('.target') // 此时DOM已完全渲染
})
</script>

5.3 内存泄漏:组件卸载后定时器仍在运行

场景:组件卸载后控制台仍打印定时任务日志 原因:未在onUnmounted中清理定时器 解决办法:在onUnmounted中调用clearInterval/clearTimeout清理定时器,或使用watchEffect自动清理

参考链接

vuejs.org/guide/essen…

高性能直播弹幕系统实现:从 Canvas 2D 到 WebGPU

高性能直播弹幕系统实现:从 Canvas 2D 到 WebGPU

前言

在现代直播应用中,弹幕是提升用户互动体验的重要功能。本文将深入介绍如何实现一个支持大规模并发、高性能渲染的弹幕系统,该系统支持 Canvas 2DWebGPU 两种渲染方式,能够在不同设备环境下自适应选择最佳渲染方案。

技术选型与架构设计

整体架构

我们的弹幕系统采用了以下架构设计:

┌─────────────────────┐
│  DanmakuCanvas.vue  │  ← Vue组件层(UI交互)
└──────────┬──────────┘
           │
┌──────────▼──────────┐
│  DanmakuManager.ts  │  ← 管理层(协调通信)
└──────────┬──────────┘
           │
┌──────────▼──────────┐
│     worker.js       │  ← Worker层(核心渲染逻辑)
└─────────────────────┘

核心特性:

  • 🚀 使用 Web Worker 实现离屏渲染,避免阻塞主线程
  • 🎨 支持 Canvas 2D 和 WebGPU 双渲染引擎
  • 📊 智能轨道分配算法,防止弹幕碰撞
  • 🎯 支持富文本渲染(文字 + 表情)
  • 📈 性能监控与数据上报
  • 🔄 响应式画布尺寸适配

技术栈

  • Vue 3: 组件层框架
  • TypeScript: 类型安全
  • OffscreenCanvas: 离屏渲染
  • Web Worker: 多线程
  • WebGPU: GPU加速渲染(可选)

核心实现详解

一、Vue 组件层实现

DanmakuCanvas.vue 作为用户界面层,主要负责:

<template>
  <div class="xhs-danmaku-container">
    <canvas ref="canvasRef" class="xhs-danmaku-container-canvas" />
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import DanmakuManager from './danmakuManager'

const props = defineProps({
  position: { type: String, default: 'top' },
  emojis: null,
  showDanmaku: { type: Boolean, default: true },
  config: { type: Object, default: null },
})

const canvasRef = ref<HTMLCanvasElement>()
const danmakuManager = ref<DanmakuManager>()

// 初始化弹幕管理器
function init() {
  if (!canvasRef.value) return
  
  danmakuManager.value = new DanmakuManager(
    handleError, 
    handleErrorReport, 
    updateHeartDim, 
    logger
  )
  danmakuManager.value.init(canvasRef.value, props.emojis, props.config)
}

// 添加弹幕的公共方法
function addDanmaku(message: string, options: any = { type: 'scroll' }) {
  if (!danmakuManager.value || !message.trim()) return
  danmakuManager.value?.addDanmaku(message, options)
}

// 响应式尺寸适配
function updateCanvasSize() {
  if (!danmakuManager.value || !canvasRef.value) return
  
  const rect = canvasRef.value.getBoundingClientRect()
  const newConfig = { 
    canvasWidth: rect.width, 
    canvasHeight: rect.height 
  }
  danmakuManager.value.updateConfig(newConfig)
}

onMounted(() => {
  init()
  if (props.showDanmaku) {
    danmakuManager.value?.start()
  }
  
  // 监听窗口变化
  window.addEventListener('resize', handleResize)
  window.addEventListener('fullscreenchange', handleResize)
})

onUnmounted(() => {
  danmakuManager.value?.destroy()
  window.removeEventListener('resize', handleResize)
  window.removeEventListener('fullscreenchange', handleResize)
})

// 暴露方法给父组件
defineExpose({
  addDanmaku,
  openDanmaku,
  closeDanmaku,
  playDanmaku,
  pauseDanmaku,
})
</script>

关键点:

  1. 使用 ref 获取 canvas DOM 元素
  2. 生命周期管理:初始化 → 运行 → 销毁
  3. 监听窗口 resize 和全屏事件,实时调整画布尺寸
  4. 通过 defineExpose 暴露控制接口

二、管理层实现

danmakuManager.ts 负责主线程与 Worker 线程的通信:

export default class DanmakuManager {
  private worker: Worker | null = null
  private onError: (err: any) => void
  private onErrorReport: (data: any) => void
  private updateHeartDim: (key: string, value: any) => void

  constructor(
    onError: (err: any) => void,
    onErrorReport: (data: any) => void,
    updateHeartDim: (key: string, value: any) => void,
    logger?: any,
  ) {
    this.onError = onError
    this.onErrorReport = onErrorReport
    this.updateHeartDim = updateHeartDim
    
    try {
      // 创建 Web Worker
      this.worker = work(require.resolve('./worker.js'))
      this.worker.onerror = this.handleError.bind(this)
      this.worker.onmessage = this.handleMessage
    } catch (error) {
      this.logger.warn('创建弹幕 Worker 失败:', error)
      this.onError(error)
    }
  }

  // 初始化离屏Canvas
  init = (canvas: HTMLCanvasElement, mojiData: any, config: any) => {
    try {
      // 转移 Canvas 控制权到 Worker
      const offScreenCanvas = canvas.transferControlToOffscreen()
      
      const emojis = this.serializeMojiData(mojiData)
      const rect = canvas.getBoundingClientRect()
      
      // 向 Worker 发送初始化消息
      this.worker?.postMessage({
        type: 'INIT',
        data: {
          config: {
            canvasWidth: rect.width,
            canvasHeight: rect.height,
            pixelRatio: window.devicePixelRatio || 1,
            emojis,
            ...config,
          },
          danmuRenderType: localStorage.getItem('danmuRenderType'),
          offScreenCanvas,
        },
      }, [offScreenCanvas]) // 转移对象所有权
    } catch (error) {
      this.onError(error)
    }
  }

  // 添加弹幕
  addDanmaku(message: string, options: any) {
    this.worker?.postMessage({ 
      type: 'ADD_DANMAKU', 
      data: { message, options } 
    })
  }

  // 更新弹幕配置(用于响应式调整)
  updateConfig(newConfig: any) {
    this.worker?.postMessage({ 
      type: 'UPDATE_CONFIG', 
      data: { newConfig } 
    })
  }

  // 销毁 Worker
  destroy() {
    this.worker?.terminate()
  }
}

核心技术点:

  1. OffscreenCanvas 转移:通过 transferControlToOffscreen() 将 Canvas 控制权转移到 Worker 线程
  2. 结构化克隆:使用 postMessage 的第二个参数传递可转移对象
  3. ImageBitmap 序列化:将表情图片转换为可传输的 ImageBitmap 对象

三、Worker 核心渲染逻辑

worker.js 是整个系统的核心,包含以下关键模块:

3.1 弹幕数据结构
class Danmaku {
  constructor(message, options, config, ctx) {
    const type = options?.type || 'scroll'
    const parts = this.parseRichText(message)
    const width = this.computeDanmakuWidth(parts, options, config, ctx)
    const boxWidth = options.showBorder ? width + PADDING_LEFT * 2 : width
    const speed = options.speed || config.speed

    this.id = this.getDanmakuId()
    this.text = message
    this.type = type
    this.speed = type === 'scroll' ? speed : 0
    this.parts = parts           // 富文本片段
    this.width = width
    this.boxWidth = boxWidth
    this.x = this.getDanmakuX(boxWidth, type, config)
    this.timestamp = Date.now()
    this.color = options.color || config.color
    this.fontSize = options.fontSize || config.fontSize
    this.priority = options.priority || 0
    this.showBorder = options.showBorder || false
  }

  // 解析富文本(文字+表情)
  parseRichText(message) {
    const parts = []
    let lastIndex = 0
    const matches = [...message.matchAll(/\[([^\]]+)\]/g)]
    
    if (matches.length === 0) return []
    
    for (const match of matches) {
      // 添加普通文本
      if (match.index > lastIndex) {
        parts.push({
          type: 'text',
          content: message.slice(lastIndex, match.index),
        })
      }
      // 添加表情
      parts.push({
        type: 'emoji',
        content: match[0],
      })
      lastIndex = match.index + match[0].length
    }
    
    // 添加剩余文本
    if (lastIndex < message.length) {
      parts.push({
        type: 'text',
        content: message.slice(lastIndex),
      })
    }
    return parts
  }
}

设计亮点:

  • 富文本解析:支持 [表情名] 格式的表情符号
  • 动态宽度计算:精确计算文字+表情的混合宽度
  • 优先级系统:支持 VIP 弹幕等优先展示场景
3.2 渲染器实现
class DanmakuRenderer {
  constructor(config, ctx) {
    this.config = config
    this.ctx = ctx
  }

  render(danmakuList) {
    danmakuList.forEach((danmaku) => {
      if (danmaku.showBorder) {
        this.drawDanmakuWithBorder(danmaku)
      } else {
        this.renderRichDanmaku(danmaku)
      }
    })
  }

  // 富文本弹幕渲染
  renderRichDanmaku(danmaku) {
    this.setupCanvasContext(danmaku)
    
    const startX = danmaku.x
    const yPosition = danmaku.y
    
    if (!danmaku.parts || danmaku.parts.length === 0) {
      this.renderSimpleDanmaku(danmaku.text, startX, yPosition)
      return
    }
    
    this.renderParts(danmaku, startX, yPosition)
  }

  // 渲染富文本各部分
  renderParts(danmaku, startX, yPosition) {
    let currentX = startX
    
    for (const part of danmaku.parts) {
      const { content, type } = part || {}
      if (!content) continue

      if (type === 'emoji') {
        currentX = this.renderEmoji(content, danmaku, currentX, yPosition)
      } else {
        currentX = this.renderText(content, danmaku, currentX, yPosition)
      }
    }
  }

  // 渲染文本
  renderText(content, danmaku, x, y) {
    this.ctx.strokeText(content, x, y)
    this.ctx.fillText(content, x, y)
    return x + this.measureTextWidth(content, danmaku).width
  }

  // 渲染表情
  renderEmoji(content, danmaku, x, y) {
    try {
      const emojiBitmap = this.config.emojis[content]?.bitmap
      
      if (!emojiBitmap) {
        // 回退到文本渲染
        return this.renderText(content, danmaku, x, y)
      }

      const emojiActualSize = danmaku.fontSize
      const emojiY = y - emojiActualSize / 2

      this.ctx.drawImage(
        emojiBitmap,
        x,
        emojiY,
        emojiActualSize,
        emojiActualSize,
      )

      return x + danmaku.fontSize
    } catch (error) {
      return this.renderText(content, danmaku, x, y)
    }
  }
}

渲染优化:

  1. 文字描边:使用 strokeText + fillText 提升可读性
  2. 混排处理:文字和表情按顺序依次渲染
  3. 容错机制:表情加载失败时回退到文本显示
3.3 智能轨道分配算法
class DanmakuWorker {
  constructor() {
    this.danmakuList = []      // 屏幕上的弹幕
    this.penddingList = []     // 等待队列
    this.usedTrackIds = new Set()  // 已占用轨道
    this.config = defaultConfig
  }

  // 创建轨道列表
  createTrackList() {
    const { trackCount, trackHeight, trackGap } = this.config
    return Array.from({ length: trackCount }, (_, i) => ({
      id: `${i}-track`,
      height: trackHeight * (i + 1) + trackGap / 2,
    }))
  }

  // 为新弹幕分配轨道
  assignTrack(newDanmaku) {
    const trackList = this.config.trackList
    
    // 优先分配未使用的轨道
    if (this.usedTrackIds.size < trackList.length) {
      return trackList.find(track => !this.usedTrackIds.has(track.id))
    }

    // 检查每个轨道是否有足够空间
    for (const track of trackList) {
      if (this.isTrackAvailable(track, newDanmaku)) {
        return track
      }
    }
    
    return null  // 无可用轨道
  }

  // 检查轨道是否可用
  isTrackAvailable(track, newDanmaku) {
    if (newDanmaku.type !== 'scroll') {
      // 固定弹幕:确保轨道上没有其他固定弹幕
      const sameTrackDanmakus = this.danmakuList.filter(
        d => d.type !== 'scroll' && d.trackId === track.id,
      )
      return sameTrackDanmakus.length === 0
    }

    // 滚动弹幕:检查是否有足够空间
    const sameTrackDanmakus = this.danmakuList.filter(
      d => d.type === 'scroll' && d.trackId === track.id,
    )
    
    if (sameTrackDanmakus.length === 0) return true

    // 检查最后一个弹幕是否已留出足够空间
    const lastDanmaku = sameTrackDanmakus[sameTrackDanmakus.length - 1]
    const lastDanmakuPosition = lastDanmaku.x + lastDanmaku.boxWidth
    const availableSpace = this.config.canvasWidth - lastDanmakuPosition
    
    return availableSpace >= SAFE_AREA  // 36px 安全距离
  }
}

算法特点:

  • 空间优先:优先使用完全空闲的轨道
  • 碰撞检测:计算前一条弹幕是否留出足够安全距离
  • 队列机制:无可用轨道时加入等待队列
3.4 WebGPU 渲染实现
async initWebGpu() {
  if (!navigator.gpu) {
    return false
  }

  // 获取 GPU 适配器和设备
  const adapter = await navigator.gpu.requestAdapter()
  const device = await adapter.requestDevice()
  const context = this.offScreenCanvas.getContext('webgpu')

  // 创建辅助 Canvas 用于 2D 绘制
  const webgpuCanvas = new OffscreenCanvas(
    this.offScreenCanvas.width, 
    this.offScreenCanvas.height
  )
  const webgpuCtx = webgpuCanvas.getContext('2d')
  
  this.webgpuCanvas = webgpuCanvas
  this.ctx = webgpuCtx  // 使用 2D 上下文绘制,再由 GPU 渲染

  // 配置 Canvas 格式
  const canvasFormat = navigator.gpu.getPreferredCanvasFormat()
  context.configure({
    device,
    format: canvasFormat,
    alphaMode: 'premultiplied',
  })

  // 创建着色器
  const vertexShaderCode = `
    struct VertexOutput {
      @builtin(position) position: vec4f,
      @location(0) uv: vec2f,
    };

    @vertex
    fn main(@location(0) position: vec2f, @location(1) uv: vec2f) -> VertexOutput {
      var output: VertexOutput;
      output.position = vec4f(position, 0.0, 1.0);
      output.uv = uv;
      return output;
    }
  `

  const fragShaderCode = `
    @group(0) @binding(0) var textureSampler: sampler;
    @group(0) @binding(1) var texture: texture_2d<f32>;

    @fragment
    fn main(@location(0) uv: vec2f) -> @location(0) vec4f {
      let flippedUV = vec2<f32>(uv.x, 1.0 - uv.y);
      return textureSample(texture, textureSampler, flippedUV);
    }
  `

  // 创建渲染管线
  const pipeline = device.createRenderPipeline({
    layout: 'auto',
    vertex: {
      module: device.createShaderModule({ code: vertexShaderCode }),
      entryPoint: 'main',
      buffers: [/* ... */],
    },
    fragment: {
      module: device.createShaderModule({ code: fragShaderCode }),
      entryPoint: 'main',
      targets: [{ format: canvasFormat }],
    },
    primitive: { topology: 'triangle-strip' },
  })

  this.pipeline = pipeline
  this.renderType = 'WEBGPU'
  return true
}

async renderWebgpu() {
  // 1. 在 2D Canvas 上绘制弹幕
  this.renderer.render(this.danmakuList)

  // 2. 将 2D Canvas 内容复制到 GPU 纹理
  this.device.queue.copyExternalImageToTexture(
    { source: this.webgpuCanvas },
    { texture: this.texture },
    { width: this.webgpuCanvas.width, height: this.webgpuCanvas.height },
  )

  // 3. 使用 GPU 渲染到屏幕
  const encoder = this.device.createCommandEncoder()
  const pass = encoder.beginRenderPass({
    colorAttachments: [{
      view: this.context.getCurrentTexture().createView(),
      loadOp: 'clear',
      clearValue: [0, 0, 0, 0],
      storeOp: 'store',
    }],
  })

  pass.setPipeline(this.pipeline)
  pass.setVertexBuffer(0, this.vertexBuffer)
  pass.setBindGroup(0, this.bindGroup)
  pass.draw(4)
  pass.end()

  this.device.queue.submit([encoder.finish()])
}

WebGPU 优势:

  • GPU 加速合成,降低 CPU 负载
  • 更高的渲染性能,支持更大弹幕量
  • 适合高端设备,提供极致体验
3.5 动画循环与性能优化
update = (currentTime) => {
  if (this.state !== 'playing') return

  const elapsed = currentTime - this.lastUpdateTime
  this.lastUpdateTime = currentTime

  // 防止时间跳变(如标签页切换回来)
  if (elapsed <= 0) {
    this.animationId = requestAnimationFrame(this.update)
    return
  }

  // 更新弹幕位置
  this.updateDanmakuX(elapsed)

  // 渲染
  if (this.renderType === 'WEBGPU') {
    this.renderWebgpu()
  } else {
    this.render2D()
  }

  // 尝试从队列中添加弹幕
  this.tryAddPendingDanmaku()

  this.animationId = requestAnimationFrame(this.update)
}

// 更新弹幕位置
updateDanmakuX = (deltaTime) => {
  this.danmakuList = this.danmakuList.filter((danmaku) => {
    // 限制 deltaTime 防止时间跳变导致位置突变
    let _deltaTime = deltaTime
    if (_deltaTime >= 20) {
      _deltaTime = 20
    }
    if (deltaTime < 20 && deltaTime > 15) {
      _deltaTime = 16
    }

    // 滚动弹幕位置更新
    if (danmaku.type === 'scroll' && danmaku.trackId) {
      danmaku.x -= danmaku.speed * (_deltaTime / 1000)
    }

    const isVisible = this.isDanmakuVisible(danmaku)
    if (!isVisible) {
      this.clearCanvas()
    }
    return isVisible
  })
}

// 检查弹幕可见性
isDanmakuVisible(danmaku) {
  if (danmaku.type === 'scroll') {
    // 滚动弹幕:完全离开屏幕左侧才移除
    return danmaku.x + danmaku.boxWidth + SAFE_AREA > 0
  } else {
    // 固定弹幕:根据持续时间判断
    return Date.now() - danmaku.timestamp < danmaku.duration
  }
}

性能优化点:

  1. 时间平滑处理:限制 deltaTime 范围,避免标签页切换导致的位置跳变
  2. 自动清理:及时移除不可见弹幕,减少渲染负担
  3. 按需渲染:只在有弹幕时执行渲染逻辑

四、响应式尺寸适配

updateConfig = ({ newConfig }) => {
  const oldWidth = this.config?.canvasWidth
  const newWidth = newConfig.canvasWidth
  
  // 合并新配置
  this.config = { ...this.config, ...newConfig }

  const newWidthPx = newConfig.canvasWidth * this.config.pixelRatio
  const newHeightPx = newConfig.canvasHeight * this.config.pixelRatio
  
  // 更新画布尺寸
  if (this.offScreenCanvas) {
    this.offScreenCanvas.width = newWidthPx
    this.offScreenCanvas.height = newHeightPx
  }

  // 调整现有弹幕位置
  this.adjustDanmakuX(oldWidth, newWidth, this.danmakuList)
  this.adjustDanmakuX(oldWidth, newWidth, this.penddingList)
}

adjustDanmakuX = (oldWidth, newWidth, danmakuList) => {
  danmakuList.forEach((danmaku) => {
    if (danmaku.type === 'scroll') {
      // 滚动弹幕:保持相对位置
      danmaku.x += (newWidth - oldWidth)
    } else {
      // 固定弹幕:重新居中
      danmaku.x = this.config.canvasWidth / 2 - (danmaku.boxWidth / 2)
    }
  })
}

适配特点:

  • 无缝调整:窗口变化时保持弹幕连续性
  • 位置修正:滚动弹幕保持相对位置,固定弹幕重新居中
  • 双向同步:同时调整屏幕上的弹幕和等待队列

性能对比

指标 Canvas 2D WebGPU
CPU 占用 中等
GPU 占用 中等
最大弹幕量 ~300/s ~800/s
兼容性 99%+ ~70%
适用场景 通用 高端设备

使用示例

<template>
  <DanmakuCanvas
    ref="danmakuRef"
    :show-danmaku="true"
    :emojis="emojiData"
    :config="danmakuConfig"
    @on-error="handleError"
  />
</template>

<script setup>
import { ref } from 'vue'
import DanmakuCanvas from './components/CanvasBarrage/DanmakuCanvas.vue'

const danmakuRef = ref()

const danmakuConfig = {
  fontSize: 20,
  fontFamily: 'PingFang SC',
  color: '#fff',
  duration: 8000,
  trackHeight: 52,
  trackGap: 16,
  trackCount: 3,
  speed: 140,
}

// 发送弹幕
function sendDanmaku(message) {
  danmakuRef.value?.addDanmaku(message, {
    type: 'scroll',      // scroll | fixed
    priority: 0,         // 优先级
    showBorder: false,   // 是否显示边框
  })
}

// 发送 VIP 弹幕
function sendVipDanmaku(message) {
  danmakuRef.value?.addDanmaku(message, {
    type: 'scroll',
    priority: 10,        // 高优先级
    showBorder: true,    // 带边框
    color: '#FFD700',    // 金色
  })
}
</script>

最佳实践

1. 性能监控

// 在 Worker 中上报性能指标
globalThis.postMessage({ 
  type: 'updateHeartDim', 
  data: { 
    key: 'onScreenDanmuCount', 
    value: this.danmakuList.length 
  } 
})

2. 渲染模式选择

// 根据设备能力选择渲染方式
const danmuRenderType = localStorage.getItem('danmuRenderType') || 'webgpu'

// 浏览器支持检测
if (!navigator.gpu) {
  localStorage.setItem('danmuRenderType', 'canvas2d')
  window.location.reload()
}

3. 表情图片预处理

// 使用 ImageBitmap 提升渲染性能
async function loadEmojis(emojiUrls) {
  const emojis = {}
  for (const [key, url] of Object.entries(emojiUrls)) {
    const response = await fetch(url)
    const blob = await response.blob()
    emojis[key] = await createImageBitmap(blob)
  }
  return emojis
}

4. 内存管理

// 限制等待队列长度
const MAX_PENDDING_LIST_LEN = 100

if (this.penddingList.length >= MAX_PENDDING_LIST_LEN) {
  // 丢弃最早的弹幕
  this.penddingList.shift()
  // 上报丢弃数据
  globalThis.postMessage({ 
    type: 'updateHeartDim', 
    data: { key: 'discardDanmuCount', value: 1 } 
  })
}

总结

本文介绍的弹幕系统具备以下特点:

高性能:Web Worker + OffscreenCanvas,不阻塞主线程
可扩展:双渲染引擎,支持渐进增强
智能调度:轨道分配算法 + 优先级队列
功能丰富:富文本、边框、多种弹幕类型
响应式:自适应屏幕尺寸变化
可监控:完善的性能指标上报

这套方案已在生产环境稳定运行,能够支撑高并发直播场景下的大规模弹幕渲染需求。

参考资料

如果这篇文章对你有帮助,欢迎点赞收藏!有任何问题欢迎在评论区讨论~ 🎉

uniapp实现图片压缩并上传

最近在使用uniapp开发时,有个功能既要支持H5和小程序双平台,又要实现图片自动压缩,还要处理好接口响应的各种异常情况。最终封装了这个 useUploadMethod 自定义上传方法,今天分享给大家。

痛点分析

先看看我们平时会遇到哪些问题:

// 痛点1:图片太大,上传慢
uni.uploadFile({
  filePath: 'big-image.jpg'  // 5MB的图片直接上传
  // 用户等得花儿都谢了
})

// 痛点2:登录态过期
uni.uploadFile({
  success: (res) => {
    // {"code":405,"msg":"未登录"}
    // 啥也没发生,用户继续操作,然后报错
  }
})

// 痛点3:H5和小程序API不统一
// H5用 File/Blob
// 小程序用 tempFilePath
// 代码里到处都是 #ifdef
技术方案
1. 整体架构

整个上传方法分为三个核心层:

  • 预处理层:图片压缩、参数组装
  • 上传层:跨平台上传、进度监听
  • 响应层:状态码处理、登录态管理
2. 图片压缩模块

跨平台压缩策略

async function compressImage(file: UploadFileItem, options: any): Promise<File | string> {
  // 未启用压缩,直接返回
  if (!options?.enabled) return file.url

  // H5平台:使用 compressorjs
  // #ifdef H5
  return compressImageH5(file, options)
  // #endif

  // 小程序平台:使用 uni.compressImage
  // #ifndef H5
  return new Promise((resolve) => {
    uni.compressImage({
      src: file.url,
      quality: options.quality || 80,
      width: options.maxWidth,
      height: options.maxHeight,
      success: (res) => resolve(res.tempFilePath),
      fail: () => resolve(file.url) // 压缩失败回退原图
    })
  })
  // #endif
}

设计亮点

  • 条件编译处理平台差异
  • 压缩失败自动降级使用原图
  • 统一返回类型,上层无感知

H5平台深度优化(compressorjs)

async function compressImageH5(file: UploadFileItem, options?: CompressOptions): Promise<File | string> {
  let { name: fileName, url: filePath } = file
  
  return new Promise((resolve) => {
    // 从blob URL获取文件
    fetch(filePath)
      .then(res => res.blob())
      .then((blob) => {
        // compressorjs压缩配置
        new Compressor(blob, {
          quality: (options?.quality || 80) / 100, // 转换为0-1范围
          maxWidth: options?.maxWidth,
          maxHeight: options?.maxHeight,
          mimeType: blob.type,
          success: (compressedBlob) => {
            // 生成标准File对象
            const fileName = `file-${Date.now()}.${blob.type.split('/')[1]}`
            const file = new File([compressedBlob], fileName, { type: blob.type })
            resolve(file)
          },
          error: () => resolve(filePath) // 压缩失败回退
        })
      })
      .catch(() => resolve(filePath))
  })
}

关键点

  • fetch + blob() 获取原始文件数据
  • compressorjs 提供高质量的图片压缩
  • 返回 File 对象,H5上传更标准
3. 核心上传方法
export function useUploadMethod(httpOptions: HttpOptions) {
  const { url, name, formData: data, header, timeout, onStart, onFinish, onSuccess, compress } = httpOptions

  const uploadMethod: UploadMethod = async (file, formData, options) => {
    // 1. 上传开始钩子
    onStart?.()

    // 2. 图片压缩(如果启用)
    let filePath = file.url
    try {
      filePath = await compressImage(file, compress)
    } catch {
      filePath = file.url // 异常降级
    }

    // 3. 创建上传任务
    const uploadTask = uni.uploadFile({
      url: options.action || url,
      header: { ...header, ...options.header },
      name: options.name || name,
      formData: { ...data, ...formData },
      timeout: timeout || 60000,
      
      // 4. 跨平台文件参数处理
      ...(typeof File !== 'undefined' && filePath instanceof File 
          ? { file: filePath }   // H5: File对象
          : { filePath }),       // 小程序: 路径字符串

      // 5. 响应处理
      success: (res) => handleSuccess(res, file, options),
      fail: (err) => handleError(err, file, options)
    })

    // 6. 进度监听
    uploadTask.onProgressUpdate((res) => {
      options.onProgress(res, file)
    })
  }

  return { uploadMethod }
}
4. 智能响应处理器
// 上传成功处理
function handleSuccess(res: any, file: UploadFileItem, options: any) {
  try {
    // 解析响应数据
    const resData = JSON.parse(res.data) as ResData<any>
    
    // 状态码检查
    if (res.statusCode >= 200 && res.statusCode < 300) {
      const { code, msg: errMsg = '上传失败' } = resData
      
      if (+code === 200) {
        // 上传成功
        options.onSuccess(res, file, resData)
        onSuccess?.(res, file, resData)
        return
      }
      
      // 登录态过期处理
      if (+code === 405 || errMsg.includes('未登录')) {
        toast.show(errMsg || '登录态失效')
        logout()
        login() // 自动跳转登录页
        return
      }
      
      // 其他业务错误
      toast.show(errMsg)
      options.onError({ ...res, errMsg }, file, resData)
      return
    }
    
    // HTTP 401处理
    if (res.statusCode === 401) {
      toast.show('登录态失效')
      logout()
      login()
      return
    }
    
    // 其他HTTP错误
    toast.show(resData.msg || `服务出错:${res.statusCode}`)
    options.onError({ ...res, errMsg: '服务开小差了' }, file)
    
  } finally {
    onFinish?.() // 无论成功失败都调用
  }
}

// 上传失败处理
function handleError(err: any, file: UploadFileItem, options: any) {
  try {
    toast.show('网络错误,请稍后再试')
    // 设置上传失败
    options.onError(err, file, formData)
  } finally {
    // 文件上传完成时调用
    onFinish?.()
  }
} as any)
基础用法
<template>
  <wd-upload
    :upload-method="uploadMethod"
    v-model:file-list="fileList"
    @change="handleChange"
  />
</template>

<script setup>
import { useUploadMethod } from './upload-method'

// 配置上传方法
const { uploadMethod } = useUploadMethod({
  url: '/api/upload',
  name: 'file',
  header: {
    'Authorization': 'Bearer ' + getToken()
  },
  // 图片压缩配置
  compress: {
    enabled: true,
    quality: 80,
    maxWidth: 1920,
    maxHeight: 1080
  },
  // 钩子函数
  onStart: () => console.log('开始上传'),
  onSuccess: (res, file) => console.log('上传成功', file),
  onFinish: () => console.log('上传完成')
})
</script>

通往“全干”之路一:前端部署

年底入职了一家创业小公司,感觉还是很幸运的。由于前端就我1个人而且没有运维,很自然前端项目部署的工作就落在我的肩上。

第一周我搭建起了公司的后台管理系统框架,按需求开发了两个页面,主要是文件上传相关的。然后那周剩余的时间,我就想先部署上去。

一、常见的前端部署
部署环境:JumpServer开源堡垒机

部署所需配置文件就是nginx.conf

部署步骤:  

1、账号密码登录堡垒机 

2、安装nginx 

3、让豆包提供一份标准nginx.conf 

4、上传dist文件 

5、解压dist.zip到nginx目录/usr/share/nginx/html/ 

6、启动nginx

后续项目更新只需要上传,并解压文件到指定目录,前端页面刷新后即可看到更新。 这种部署方式比较常见,也比较简单,半天不到即可搞定。在这里不得不提一下AI编程工具对开发效率的提升,特别是新项目来说。

 
二、亚马逊容器云部署

然后是第二周在另一个前端项目里开发了用户侧的显示界面,也需要部署上去。听面试我的后端大佬说,后端服务是在亚马逊上,采用docker集群部署。还好之前的工作也接触的docker,所以也不是很慌。

部署环境:亚马逊堡垒机
部署所需配置文件:
1、nginx.conf:配置静态资源和前端api请求代理,此文件放前端项目里,然后打包进docker镜像。

2、front-model.yaml:此文件放服务器上,主要配置nginx服务的端口、内存占用,以及镜像地址等。可让AI生成一份,然后修改对应的名称即可。

3、xxx-ingress:服务器上路由文件,主要配置前端路由转到nginx服务。

 配置好以上文件后,即可按下面步骤完成部署:
1、打包构建

npm run build:test

2、打镜像

docker build -t front-model:v1.0.1 .

3、amazonaws镜像重命名

docker tag front-model:v1.0.1 628639829879.dkr.ecr.us-east-1.amazonaws.com/front-model:v1.0.1

4、amazonaws登录(先安装aws client)

aws ecr get-login-password --region us-east-1 | docker login --username xxx --password-stdin xxx.dkr.ecr.us-east-1.amazonaws.com

5、推送镜像到amazonaws仓库 

docker push 628639829879.dkr.ecr.us-east-1.amazonaws.com/front-model:v1.0.1

6、修改front-model.yaml镜像tag

sudo vim front-model.yaml

7、应用yaml 

kubectl apply -f front-model.yaml

8、重启pod服务

kubectl rollout restart deployment/front-model

9、查看指定pod状态

kubectl get pods | grep front-model

遇到的问题:
1、docker客户端提示缺少win包,然后下载进度卡住拉不下来,原因是docker的下载终端在鼠标点击后默认暂停了。
2、前端资源的mime类型不对,需修改nginx.conf。
3、api请求没有经过nginx,原因是ingress的path不支持正则表达式的写法,需要拆开单独写。

大家也发现了上面的部署方式都是纯手工,比较繁琐。后面会考虑做成脚本自动执行,或者接入CICD。

Vue3 组件生命周期详解

在上一篇文章中,我们深入探讨了组件从VNode到DOM的渲染过程。本篇文章将聚焦于组件的生命周期——这个贯穿组件从创建到销毁整个过程的时间轴。理解生命周期,不仅能帮助我们写出更可靠的代码,还能在合适的时机做合适的事情。

前言:为什么需要生命周期?

想象一下,我们正在搭建一座房子,一般需要经过以下几个阶段:

  1. 创建阶段:设计图纸、准备材料
  2. 挂载阶段:打地基、砌墙、安装门窗
  3. 更新阶段:翻新墙面、更换家具
  4. 卸载阶段:拆除房屋、清理场地

组件也是如此。在它的整个生命周期中,我们需要在不同的时间点执行不同的操作:

const Component = {
  // 创建时:初始化数据
  created() {
    this.fetchData();
  },
  
  // 挂载后:操作DOM
  mounted() {
    this.$el.focus();
  },

  // 更新时:页面刷新
  updated() {
    this.$el.scrollTop = this.$el.scrollHeight; 
  }
  
  // 卸载前:清理资源
  beforeUnmount() {
    clearInterval(this.timer);
  }
};

生命周期的完整图谱

生命周期全景图

下面这张图展示了 Vue3 组件的完整生命周期流程: 组件生命周期

各个阶段的核心任务

阶段 钩子 核心任务 注意事项
创建阶段 setup / beforeCreate / created 初始化数据、设置响应式 无法访问DOM
挂载阶段 beforeMount / mounted 渲染DOM、操作DOM 可以访问DOM
更新阶段 beforeUpdate / updated 响应数据变化 避免在更新钩子中修改数据
卸载阶段 beforeUnmount / unmounted 清理资源 清除定时器、取消订阅
缓存阶段 activated / deactivated 配合keep-alive 缓存组件的激活/失活

创建阶段:组件诞生

创建阶段的三个钩子

在 Vue3 中,创建阶段实际上由三个关键步骤组成:

  1. 最先执行:setup
  2. 然后执行:beforeCreate
  3. 最后执行:created
export default {
  // 1. 最先执行:setup
  setup() {
    console.log('1. setup 执行');
    
    const count = ref(0);
    
    // setup 中不能使用 this
    // console.log(this); // undefined
    
    return { count };
  },
  
  // 2. 然后执行:beforeCreate
  beforeCreate() {
    console.log('2. beforeCreate 执行');
    console.log('数据尚未初始化:', this.count); // undefined
    console.log('DOM 尚未创建:', this.$el);     // undefined
  },
  
  // 3. 最后执行:created
  created() {
    console.log('3. created 执行');
    console.log('数据已初始化:', this.count);    // 0
    console.log('DOM 尚未创建:', this.$el);      // undefined
  }
};

各钩子的数据访问能力

为了更好地理解每个阶段能做什么,我们用一个表格来展示:

钩子 访问 data 访问 props 访问 computed 访问 methods 访问 DOM 访问 $el
setup ❌ (尚未创建) ❌ (尚未创建)
beforeCreate
created

为什么需要三个创建钩子?

你可能有这样的疑问:为什么有了 setup 还要保留 beforeCreatecreated

这其实是为了兼容性和渐进迁移。在 Vue3 中,setup 实际上是 beforeCreatecreated 的替代品;但是 Vue3 为了向下兼容 Vue2 ,仍然保留了 beforeCreatecreated

Vue2 风格的创建钩子:

export default {
  beforeCreate() {
    // 初始化非响应式数据
    this.nonReactive = {};
  },
  created() {
    // 发起API请求
    this.fetchData();
  }
};

Vue3 组合式风格:

export default {
  setup() {
    // 初始化非响应式数据
    const nonReactive = {};
    
    // 发起API请求
    fetchData();
    
    return { nonReactive };
  }
};

挂载阶段:组件展现

挂载过程的内部机制

挂载阶段是组件第一次将虚拟 DOM 渲染为真实 DOM 的过程:

export default {
  beforeMount() {
    console.log('1. beforeMount 执行');
    console.log('此时已有编译好的模板,但尚未挂载到DOM');
    console.log('DOM 尚不存在:', this.$el);  // undefined
  },
  
  // render 函数在 beforeMount 之后、mounted 之前执行
  render() {
    console.log('2. render 执行');
    return h('div', 'Hello World');
  },
  
  mounted() {
    console.log('3. mounted 执行');
    console.log('DOM 已创建并挂载:', this.$el);     // <div>Hello World</div>
    console.log('可以安全地操作DOM了');
    
    // 可以访问DOM元素
    this.$el.querySelector('input')?.focus();
    
    // 可以集成第三方库
    new Chart(this.$el.querySelector('#chart'), {...});
  }
};

挂载阶段的时序图

挂载阶段的执行时序图

挂载阶段的典型应用场景

1. 操作DOM

this.$el.scrollTop = 100;

2. 获取元素尺寸

const width = this.$refs.box.offsetWidth;

3. 集成第三方库(需要DOM存在)

this.chart = new Chart(this.$refs.canvas, {
  type: 'line',
  data: this.chartData
});

4. 添加全局事件监听

window.addEventListener('resize', this.handleResize);

5. 启动定时器

this.timer = setInterval(this.refreshData, 5000);

6. 卸载前清理操作

window.removeEventListener('resize', this.handleResize);
clearInterval(this.timer);
this.chart?.destroy();

更新阶段:组件响应

更新阶段的时序图

更新阶段的时序图

更新阶段的注意事项

export default {
  beforeUpdate() {
    // ✅ 可以在DOM更新前访问旧状态
    const oldHeight = this.$refs.box.offsetHeight;
    console.log('旧高度:', oldHeight);
    
    // ❌ 不要在更新钩子中修改数据(可能造成死循环)
    // this.count++; // 会触发无限循环
    
    // ✅ 可以在这里手动操作DOM(不推荐)
    // 但要注意这些操作可能会被后续的更新覆盖
  },
  
  updated() {
    // ✅ 可以获取更新后的DOM信息
    const newHeight = this.$refs.box.offsetHeight;
    console.log('新高度:', newHeight);
    
    // ✅ 可以根据新状态调整其他非响应式内容
    if (newHeight > 500) {
      this.$refs.box.classList.add('overflow');
    }
    
    // ❌ 同样避免在这里修改数据
    // ❌ 避免直接操作DOM来"修复"样式,应该通过数据驱动
  }
};

卸载阶段:组件消亡

卸载的完整过程

export default {
  beforeUnmount() {
    console.log('1. beforeUnmount 执行');
    console.log('组件即将被卸载,但依然可以访问');
    console.log('DOM 仍然存在:', this.$el);
    
    // 清理工作
    this.cleanup();
  },
  
  unmounted() {
    console.log('2. unmounted 执行');
    console.log('组件已被卸载');
    console.log('DOM 已移除:', this.$el); // 被移除或置空
    
    // 最终清理
    this.finalCleanup();
  }
};

需要清理的典型资源

  1. 定时器:clearInterval(timer);
  2. 事件监听:window.removeEventListener('resize', handleResize);
  3. 观察者:observer.disconnect();
  4. 网络请求:controller.abort();
  5. 第三方库实例:chart.destroy();
  6. 手动订阅:subscription.unsubscribe();

缓存阶段:KeepAlive 的特殊生命周期

为什么需要缓存阶段?

当组件被 <KeepAlive> 包裹时,它的生命周期会发生变化: KeepAlive生命周期.png

activated 和 deactivated 的使用

const CacheComponent = {
  setup() {
    console.log('setup 执行'); // 只执行一次
    
    const count = ref(0);
    
    // 这些钩子会在组件被缓存时特殊处理
    onMounted(() => {
      console.log('mounted 执行'); // 只执行一次
    });
    
    onActivated(() => {
      console.log('activated 执行'); // 每次进入视图时执行
      
      // 恢复一些状态
      startAnimation();
      startPolling();
    });
    
    onDeactivated(() => {
      console.log('deactivated 执行'); // 每次离开视图时执行
      
      // 暂停一些操作,但不销毁
      stopAnimation();
      stopPolling();
    });
    
    onUnmounted(() => {
      console.log('unmounted 执行'); // 最终销毁时执行
    });
    
    return { count };
  }
};

父子组件的生命周期顺序

挂载阶段的执行顺序

当父子组件嵌套时,生命周期的执行顺序非常关键:

const Parent = {
  setup() { console.log('Parent setup'); },
  beforeCreate() { console.log('Parent beforeCreate'); },
  created() { console.log('Parent created'); },
  beforeMount() { console.log('Parent beforeMount'); },
  mounted() { console.log('Parent mounted'); },
  
  render() {
    return h('div', [
      h(Child)
    ]);
  }
};

const Child = {
  setup() { console.log('Child setup'); },
  beforeCreate() { console.log('Child beforeCreate'); },
  created() { console.log('Child created'); },
  beforeMount() { console.log('Child beforeMount'); },
  mounted() { console.log('Child mounted'); }
};

// 渲染输出顺序:
// 1. Parent setup
// 2. Parent beforeCreate
// 3. Parent created
// 4. Parent beforeMount
// 5. Child setup
// 6. Child beforeCreate
// 7. Child created
// 8. Child beforeMount
// 9. Child mounted
// 10. Parent mounted

更新阶段的执行顺序

当 Parent 的数据变化时:

  1. Parent beforeUpdate
  2. Child beforeUpdate
  3. Child updated
  4. Parent updated

卸载阶段的执行顺序

当父组件被移除时:

  1. Parent beforeUnmount
  2. Child beforeUnmount
  3. Child unmounted
  4. Parent unmounted

执行顺序的规律总结

阶段 执行顺序 原因
创建 父 → 子 父组件先创建,才能传递props给子组件
挂载 子 → 父 父组件需要等待所有子组件挂载完成
更新 父 → 子 父组件数据变化,传递给子组件
卸载 子 → 父 先拆除内部,再拆除外部

Vue3 中两种写法的生命周期对比

Vue3 同时支持两种写法:选项式 API组合式 API

选项式 API 生命周期

  • beforeCreate / created
  • beforeMount / mounted
  • beforeUpdate / updated
  • beforeUnmount / unmounted
  • activated / deactivated
  • errorCaptured
  • renderTracked / renderTriggered:新增的调试钩子

组合式 API 生命周期

  • setup
  • onBeforeMount / onMounted
  • onBeforeUpdate / onUpdated
  • onBeforeUnmount / onUnmounted
  • onActivated / onDeactivated
  • onErrorCaptured
  • onRenderTracked / onRenderTriggered

两种写法的对应关系表

选项式 API 组合式 API 执行时机
beforeCreate/created 直接在 setup 中编写代码 组件初始化前/组件初始化后
beforeMount/mounted onBeforeMount/onMounted DOM 挂载前/DOM 挂载后
beforeUpdate/updated onBeforeUpdate/onUpdated 数据更新、DOM 更新前/DOM 更新后
beforeUnmount/unmounted onBeforeUnmount/onUnmounted 组件卸载前/组件卸载后
activated/deactivated onActivated/onDeactivated keep-alive 组件激活/keep-alive 组件失活
errorCaptured onErrorCaptured 捕获后代组件错误

核心差异:setup 中的生命周期

setup 函数是最早的生命周期钩子,本身执行在 beforeCreatecreated 之前,属于 beforeCreatecreated 的替代品,因此在 setup 中编写的代码相当于在这两个钩子中执行:

export default {
  setup() {
    // 这些代码相当于在 beforeCreate 和 created 中执行
    
    console.log('相当于 beforeCreate/created');
    
    const count = ref(0);
    
    // 可以在这里执行初始化操作
    fetchData();
    
    // 注册生命周期钩子
    onMounted(() => {
      console.log('mounted');
    });
    
    return { count };
  }
};

<script setup> 的特殊性

<script setup> 的本质

<script setup> 是组合式 API 的语法糖,它在编译时会被转换为普通的 setup() 函数:

<!-- 源码写法 -->
<script setup>
import { ref, onMounted } from 'vue';

const count = ref(0);

function increment() {
  count.value++;
}

onMounted(() => {
  console.log('组件已挂载');
});
</script>
// 编译后
export default {
  setup() {
    const count = ref(0);
    
    function increment() {
      count.value++;
    }
    
    onMounted(() => {
      console.log('组件已挂载');
    });
    
    return { count, increment };
  }
};

<script setup> 中的生命周期变化

<script setup> 中,生命周期钩子的使用变得更加简洁:

  • onBeforeMount / onMounted
  • onBeforeUpdate / onUpdated
  • onBeforeUnmount / onUnmounted
  • onActivated / onDeactivated

<script setup> 的特殊特性

<script setup>
// 1. 自动返回顶层变量
const count = ref(0);           // 自动暴露给模板
function increment() {}          // 自动暴露给模板

// 2. 支持顶层 await
const data = await fetchData();  // 组件会等待异步操作完成

// 3. 使用 defineProps 和 defineEmits
const props = defineProps({
  title: String
});

const emit = defineEmits(['update']);

// 4. 使用 defineExpose 暴露方法
defineExpose({
  resetCount: () => count.value = 0
});

// 5. 生命周期钩子可以直接使用
onMounted(() => {
  console.log('mounted');
});
</script>

生命周期的最佳实践

各阶段适合做什么

阶段 适合的操作 不适合的操作
setup/created 初始化数据、设置响应式、发起API请求 操作DOM、访问$el
beforeMount 最后一次数据修改机会 操作DOM
mounted 操作DOM、集成第三方库、添加事件监听 同步修改数据(可能触发额外更新)
beforeUpdate 访问更新前的DOM 修改数据(可能死循环)
updated 执行依赖更新后DOM的操作 修改数据(可能死循环)
beforeUnmount 清理资源、移除事件监听 异步操作
unmounted 最终清理 访问已销毁的实例

生命周期调试技巧

使用生命周期追踪

<script setup>
import { onMounted, onUpdated, onRenderTracked, onRenderTriggered } from 'vue';

// 追踪渲染依赖
onRenderTracked((event) => {
  console.log('渲染依赖追踪:', event);
  // {
  //   key: 'count',      // 依赖的属性名
  //   target: {},        // 依赖的目标对象
  //   type: 'get',       // 操作类型
  // }
});

// 追踪渲染触发原因
onRenderTriggered((event) => {
  console.log('渲染触发原因:', event);
  // {
  //   key: 'count',
  //   target: {},
  //   type: 'set',
  //   oldValue: 0,
  //   newValue: 1
  // }
});

// 记录完整生命周期
onBeforeMount(() => console.log('🔄 beforeMount'));
onMounted(() => console.log('✅ mounted'));
onBeforeUpdate(() => console.log('🔄 beforeUpdate'));
onUpdated(() => console.log('✅ updated'));
onBeforeUnmount(() => console.log('🔄 beforeUnmount'));
onUnmounted(() => console.log('✅ unmounted'));
</script>

使用钩子组合

// 创建可复用的生命周期逻辑
function useLogger(componentName) {
  onBeforeMount(() => {
    console.log(`${componentName} 准备挂载`);
  });
  
  onMounted(() => {
    console.log(`${componentName} 已挂载`);
  });
  
  onBeforeUnmount(() => {
    console.log(`${componentName} 准备卸载`);
  });
  
  onUnmounted(() => {
    console.log(`${componentName} 已卸载`);
  });
}

// 在组件中使用
<script setup>
const props = defineProps({ name: String });
useLogger(props.name);
</script>

结语

理解生命周期,就像是掌握了组件从生到死的完整剧本。知道在每个阶段该做什么、不该做什么,才能写出既高效又可靠的Vue应用。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

组件渲染:从组件到DOM

在前面的文章中,我们深入探讨了虚拟DOM的创建和原生元素的挂载过程。但 Vue 真正的威力在于组件系统——它让我们能够将界面拆分成独立的、可复用的模块。本文将揭示 Vue3 如何将我们编写的组件,一步步渲染成真实的 DOM 节点。

前言:组件的魔法

当我们编写这样的Vue组件时:

<template>
  <div class="user-card">
    <h2>{{ user.name }}</h2>
    <p>{{ user.email }}</p>
    <button @click="sayHello">打招呼</button>
  </div>
</template>

<script>
export default {
  props: ['user'],
  setup(props) {
    const sayHello = () => {
      alert(`你好,我是${props.user.name}`);
    };
    
    return { sayHello };
  }
}
</script>

Vue内部经历了一系列复杂而有序的过程: 组件渲染流程 本文将带你一步步拆解这个过程,理解组件从定义到 DOM 的完整旅程。

组件的VNode结构

组件VNode的特殊性

与原生元素不同,组件的 VNode 有其独特的结构:

const componentVNode = {
  type: UserCard,                  // 对象/函数:表示组件定义
  props: { user: { name: '张三' } }, // 传递给组件的props
  children: {                       // 插槽内容
    default: () => h('span', '默认插槽'),
    header: () => h('h1', '头部')
  },
  shapeFlag: ShapeFlags.STATEFUL_COMPONENT, // 标记为组件
  
  // 组件特有属性
  key: null,
  ref: null,
  component: null,                  // 组件实例(挂载后填充)
  suspense: null,
  scopeId: null,
  slotScopeIds: null
};

组件类型的多样性

Vue3中的组件类型更加丰富:

1. 有状态组件(最常用)

const StatefulComponent = {
  data() { return { count: 0 } },
  template: `<div>{{ count }}</div>`
};

2. 函数式组件(无状态)

const FunctionalComponent = (props) => {
  return h('div', props.message);
};

3. 异步组件

const AsyncComponent = defineAsyncComponent(() => 
  import('./MyComponent.vue')
);

4. 内置组件

const KeepAliveComponent = {
  type: KeepAlive,
  props: { include: 'a,b' }
};

shapeFlag 标志

const enum ShapeFlags {
  ELEMENT = 1,
  FUNCTIONAL_COMPONENT = 1 << 1,  // 2
  STATEFUL_COMPONENT = 1 << 2,     // 4
  COMPONENT = ShapeFlags.FUNCTIONAL_COMPONENT | ShapeFlags.STATEFUL_COMPONENT // 6
}

组件VNode的创建过程

import UserCard from './UserCard.vue';

// 这行代码背后
const vnode = h(UserCard, { user: userInfo }, {
  default: () => h('span', 'children')
});

// 实际执行的是
function createComponentVNode(component, props, children) {
  // 规范化props
  props = normalizeProps(props);
  
  // 提取key和ref
  const { key, ref } = props || {};
  
  // 处理插槽
  let slots = null;
  if (children) {
    slots = normalizeSlots(children);
  }
  
  // 创建VNode
  const vnode = {
    type: component,
    props: props || {},
    children: slots,
    key,
    ref,
    shapeFlag: isFunction(component) 
      ? ShapeFlags.FUNCTIONAL_COMPONENT 
      : ShapeFlags.STATEFUL_COMPONENT,
    
    // 组件实例(稍后填充)
    component: null,
    
    // 其他内部属性
    el: null,
    anchor: null,
    appContext: null
  };
  
  return vnode;
}

组件实例的设计

为什么需要组件实例?

组件实例是组件的"活"的体现,它包含了组件的所有状态和功能: 组件状态与功能

组件实例的结构

一个完整的组件实例包含以下核心部分:

class ComponentInstance {
  // 基础标识
  uid = ++uidCounter;           // 唯一ID
  type = null;                  // 组件定义对象
  parent = null;                 // 父组件实例
  appContext = null;             // 应用上下文
  
  // 状态相关
  props = null;                  // 解析后的props
  attrs = null;                  // 非prop属性
  slots = null;                  // 插槽
  emit = null;                   // 事件发射器
  
  // 响应式系统
  setupState = null;             // setup返回的状态
  data = null;                   // data选项
  computed = null;               // 计算属性
  refs = null;                   // 模板refs
  
  // 生命周期
  isMounted = false;              // 是否已挂载
  isUnmounted = false;            // 是否已卸载
  isDeactivated = false;          // 是否被keep-alive缓存
  
  // 渲染相关
  subTree = null;                // 渲染子树
  render = null;                  // 渲染函数
  proxy = null;                   // 渲染代理
  withProxy = null;               // 带with语句的代理
  
  // 依赖收集
  effects = null;                 // 组件级effects
  provides = null;                // 依赖注入
  components = null;              // 局部注册组件
  directives = null;              // 局部注册指令
  
  constructor(public vnode, parent) {
    this.type = vnode.type;
    this.parent = parent;
    this.appContext = parent ? parent.appContext : vnode.appContext;
    
    // 初始化空容器
    this.props = {};
    this.attrs = {};
    this.slots = {};
    this.setupState = {};
    
    // 创建代理
    this.proxy = new Proxy(this, PublicInstanceProxyHandlers);
  }
}

为什么需要代理?

组件实例的代理(proxy)是为了提供一个统一的访问接口:

// 实例代理处理函数
const PublicInstanceProxyHandlers = {
  get({ _: instance }, key) {
    const { setupState, props, data } = instance;
    
    // 优先从setupState获取
    if (key in setupState) {
      return setupState[key];
    }
    
    // 然后从props获取
    else if (key in props) {
      return props[key];
    }
    
    // 然后从data获取
    else if (data && key in data) {
      return data[key];
    }
    
    // 最后是内置属性
    else if (key === '$el') {
      return instance.subTree?.el;
    }
    // ... 其他内置属性
  },
  
  set({ _: instance }, key, value) {
    const { setupState, props, data } = instance;
    
    // 按照优先级设置
    if (key in setupState) {
      setupState[key] = value;
    } else if (key in props) {
      // props 是只读的
      console.warn(`Attempting to mutate prop "${key}"`);
      return false;
    } else if (data && key in data) {
      data[key] = value;
    }
    
    return true;
  }
};

这个代理让我们可以在模板中直接使用 count,而不需要写 $data.countsetupState.count

setup 函数的执行时机

setup 的执行时机图

setup的执行时机图

setup 参数解析

setup函数接收两个参数:

setup(props, context) {
  // props: 响应式的props对象
  console.log(props.title);  // 自动解包,无需.value
  
  // context: 一个对象,包含有用的方法
  const { 
    attrs,    // 非prop属性
    slots,    // 插槽
    emit,     // 事件发射
    expose    // 暴露公共方法
  } = context;
  
  // 返回对象,暴露给模板
  return {
    count: ref(0),
    increment() {
      this.count.value++;
    }
  };
}

setup 的内部实现

function setupComponent(instance) {
  const { type, props, children } = instance.vnode;
  const { setup } = type;
  
  if (setup) {
    // 创建setup上下文
    const setupContext = createSetupContext(instance);
    
    // 设置当前实例(用于getCurrentInstance)
    setCurrentInstance(instance);
    
    try {
      // 执行setup
      const setupResult = setup(
        props,           // 只读的props
        setupContext     // 上下文
      );
      
      // 处理返回值
      handleSetupResult(instance, setupResult);
    } finally {
      // 清理
      setCurrentInstance(null);
    }
  }
  
  // 完成组件初始化
  finishComponentSetup(instance);
}

function createSetupContext(instance) {
  return {
    // 非prop属性
    get attrs() {
      return instance.attrs;
    },
    
    // 插槽
    get slots() {
      return instance.slots;
    },
    
    // 事件发射
    emit: instance.emit,
    
    // 暴露公共方法
    expose: (exposed) => {
      instance.exposed = exposed;
    }
  };
}

function handleSetupResult(instance, setupResult) {
  if (setupResult && typeof setupResult === 'object') {
    // 返回对象:作为模板上下文
    instance.setupState = proxyRefs(setupResult);
  } else if (typeof setupResult === 'function') {
    // 返回函数:作为渲染函数
    instance.render = setupResult;
  }
}

render 函数的调用

从 setup 到 render

从setup到render到挂载流程图

render 函数的创建

Vue3 中,render 函数可以通过多种方式获得:

function finishComponentSetup(instance) {
  const Component = instance.type;
  
  // 1. 优先使用setup返回的render函数
  if (!instance.render) {
    if (Component.render) {
      // 2. 使用组件选项中的render
      instance.render = Component.render;
    } else if (Component.template) {
      // 3. 编译模板为render函数
      instance.render = compile(Component.template);
    }
  }
  
  // 对函数式组件的处理
  if (!Component.render && !Component.template) {
    // 如果组件本身是函数,当作render函数
    if (typeof Component === 'function') {
      instance.render = Component;
    }
  }
}

渲染代理的工作机制

渲染代理让模板可以轻松访问各种状态:

const PublicInstanceProxyHandlers = {
  get(target, key) {
    const instance = target._;
    const { setupState, props, data } = instance;
    
    // 1. 特殊处理以$开头的内置属性
    if (key[0] === '$') {
      switch (key) {
        case '$el': return instance.subTree?.el;
        case '$props': return props;
        case '$slots': return instance.slots;
        case '$parent': return instance.parent?.proxy;
        case '$root': return instance.root?.proxy;
        case '$emit': return instance.emit;
        case '$refs': return instance.refs;
      }
    }
    
    // 2. 普通状态查找
    if (setupState && key in setupState) {
      return setupState[key];
    }
    if (props && key in props) {
      return props[key];
    }
    if (data && key in data) {
      return data[key];
    }
    
    // 3. 没找到返回undefined
    return undefined;
  }
};

手写实现:mountComponent

mountComponent的整体流程

  1. 创建组件实例:const instance = createComponentInstance(vnode);
  2. 初始化并执行组件: setupComponent(instance);
  3. 设置渲染effect:setupRenderEffect(instance, container, anchor);
  4. 返回组件实例:return instance;

创建组件实例

let uidCounter = 0;

function createComponentInstance(vnode, parent = null) {
  const instance = {
    // 基础信息
    uid: ++uidCounter,
    vnode,
    type: vnode.type,
    parent,
    
    // 状态
    props: {},
    attrs: {},
    slots: {},
    setupState: {},
    
    // 渲染相关
    render: null,
    subTree: null,
    isMounted: false,
    
    // 生命周期
    isUnmounted: false,
    
    // 代理
    proxy: null,
    
    // emit函数
    emit: () => {},
    
    // 上下文
    appContext: parent ? parent.appContext : vnode.appContext,
    provides: parent ? Object.create(parent.provides) : {}
  };
  
  // 创建代理
  instance.proxy = new Proxy(instance, PublicInstanceProxyHandlers);
  
  // 绑定emit
  instance.emit = createEmit(instance);
  
  return instance;
}

设置渲染 effect

function setupRenderEffect(instance, container, anchor) {
  // 创建组件更新函数
  const componentUpdateFn = () => {
    if (!instance.isMounted) {
      // 首次挂载
      
      // 1. 执行render函数,生成子树VNode
      const subTree = instance.render.call(
        instance.proxy,    // this指向代理
        instance.proxy     // 第一个参数
      );
      
      // 2. 保存子树
      instance.subTree = subTree;
      
      // 3. 挂载子树
      patch(null, subTree, container, anchor);
      
      // 4. 保存根元素引用
      instance.vnode.el = subTree.el;
      
      // 5. 标记已挂载
      instance.isMounted = true;
      
      // 6. 触发mounted钩子
      invokeLifecycle(instance, 'mounted');
    } else {
      // 更新阶段
      
      // 1. 获取新子树
      const nextTree = instance.render.call(
        instance.proxy,
        instance.proxy
      );
      
      // 2. 保存旧子树
      const prevTree = instance.subTree;
      instance.subTree = nextTree;
      
      // 3. 执行更新
      patch(prevTree, nextTree, container, anchor);
      
      // 4. 更新元素引用
      instance.vnode.el = nextTree.el;
      
      // 5. 触发updated钩子
      invokeLifecycle(instance, 'updated');
    }
  };
  
  // 创建ReactiveEffect
  const effect = new ReactiveEffect(
    componentUpdateFn,
    // 调度器:异步更新
    () => queueJob(instance.update)
  );
  
  // 保存更新函数
  instance.update = effect.run.bind(effect);
  
  // 立即执行首次渲染
  instance.update();
}

完整的mountComponent实现

function mountComponent(vnode, container, anchor) {
  // 1. 创建组件实例
  const instance = createComponentInstance(vnode);
  
  // 2. 初始化 props 和 slots(如果有props 和 slots)
  initProps(instance, vnode.props);
  initSlots(instance, vnode.children);
  
  // 3. 初始化并执行组件
  setupComponent(instance);
  
  // 4. 创建渲染effect
  setupRenderEffect(instance, container, anchor);
  
  // 5. 返回组件实例
  return instance;
}

// 初始化props
function initProps(instance, rawProps) {
  const props = {};
  const attrs = {};
  
  const options = instance.type.props || {};
  
  // 根据组件定义的props进行过滤
  if (rawProps) {
    for (const key in rawProps) {
      if (options[key] !== undefined) {
        // 是定义的prop
        props[key] = rawProps[key];
      } else {
        // 是普通属性
        attrs[key] = rawProps[key];
      }
    }
  }
  
  instance.props = shallowReactive(props);
  instance.attrs = shallowReactive(attrs);
}

// 初始化slots
function initSlots(instance, children) {
  if (children) {
    instance.slots = normalizeSlots(children);
  }
}

// 规范化插槽
function normalizeSlots(children) {
  if (typeof children === 'function') {
    // 单个函数:默认插槽
    return { default: children };
  } else if (Array.isArray(children)) {
    // 数组:也是默认插槽
    return { default: () => children };
  } else if (typeof children === 'object') {
    // 对象:多个插槽
    const slots = {};
    for (const key in children) {
      const slot = children[key];
      slots[key] = (props) => normalizeSlot(slot, props);
    }
    return slots;
  }
  return {};
}

组件渲染的生命周期

完整的组件生命周期流程图

完整的组件生命周期流程图

生命周期钩子的触发时机

// 生命周期钩子的内部实现
const LifecycleHooks = {
  BEFORE_CREATE: 'bc',
  CREATED: 'c',
  BEFORE_MOUNT: 'bm',
  MOUNTED: 'm',
  BEFORE_UPDATE: 'bu',
  UPDATED: 'u',
  BEFORE_UNMOUNT: 'bum',
  UNMOUNTED: 'um'
};

function invokeLifecycle(instance, hook) {
  const handlers = instance.type[hook];
  if (handlers) {
    // 设置当前实例
    setCurrentInstance(instance);
    
    // 执行钩子函数
    if (Array.isArray(handlers)) {
      handlers.forEach(handler => handler.call(instance.proxy));
    } else {
      handlers.call(instance.proxy);
    }
    
    // 清理
    setCurrentInstance(null);
  }
}

一个完整示例的渲染过程

// 示例:父子组件
const Child = {
  props: ['message'],
  setup(props) {
    console.log('Child setup');
    return {};
  },
  render() {
    console.log('Child render');
    return h('div', '子组件: ' + this.message);
  }
};

const Parent = {
  setup() {
    console.log('Parent setup');
    const msg = ref('Hello');
    
    setTimeout(() => {
      msg.value = 'World';
    }, 1000);
    
    return { msg };
  },
  render() {
    console.log('Parent render');
    return h('div', [
      h('h1', '父组件'),
      h(Child, { message: this.msg })
    ]);
  }
};

// 挂载
const vnode = h(Parent);
render(vnode, document.getElementById('app'));

// 控制台输出顺序:
// Parent setup
// Child setup
// Parent render
// Child render
// (1秒后)
// Parent render
// Child render

结语

本文深入剖析了Vue3组件渲染的完整过程,对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

渲染器核心:mount挂载过程

在上一篇文章中,我们深入探讨了虚拟 DOM 的设计与创建。现在,我们有了描述界面的 VNode,接下来要做的就是将它们渲染到真实的页面上。这个过程就是渲染器的职责。本文将深入剖析 Vue3 渲染器的挂载(mount)过程,看看虚拟 DOM 如何一步步变成真实 DOM。

前言:从虚拟 DOM 到真实 DOM

当我们编写这样的 Vue 组件时:

const App = {
  render() {
    return h('div', { class: 'container' }, [
      h('h1', 'Hello Vue3'),
      h('p', '这是渲染器的工作')
    ]);
  }
};

// 创建渲染器并挂载
createApp(App).mount('#app');

在这背后发生了一系列复杂而有序的操作: 渲染流图

本文将聚焦于首次渲染(mount)的过程。

渲染器的设计思想

为什么需要渲染器?

在深入了解代码之前,我们先思考一个问题:为什么 Vue 不直接将模板编译成 DOM 操作指令,而是要引入虚拟 DOM 和渲染器这一层?答案是:解耦跨平台

// 如果直接编译成 DOM 操作
function render() {
  const div = document.createElement('div');
  div.className = 'container';
  // ... 只能运行在浏览器
}

// 通过渲染器抽象
function render(vnode, container) {
  // 具体的创建操作由渲染器实现
  // 浏览器渲染器:document.createElement
  // 小程序渲染器:wx.createView
  // Native 渲染器:createNativeView
}

渲染器的三层架构

Vue3 的渲染器采用了清晰的分层设计: 渲染器三层架构 这种分层设计带来了极大的灵活性:

  • 渲染核心:实现 diff 算法、生命周期等通用逻辑
  • 平台操作层:提供统一的接口,由各平台实现
  • 目标平台:浏览器、小程序、Weex 等

渲染器的创建过程

创建渲染器工厂

渲染器本身是一个工厂函数,它接收平台操作作为参数,返回一个渲染器对象:

/**
 * 创建渲染器
 * @param {Object} options - 平台操作选项
 * @returns {Object} 渲染器对象
 */
function createRenderer(options) {
  // 解构平台操作
  const {
    createElement,  // 创建元素
    createText,     // 创建文本节点
    createComment,  // 创建注释节点
    insert,         // 插入节点
    setText,        // 设置文本内容
    setElementText, // 设置元素文本
    patchProp       // 更新属性
  } = options;

  // ... 渲染核心逻辑

  return {
    render,        // 渲染函数
    createApp      // 创建应用
  };
}

这种设计模式称为依赖注入,它将平台相关的操作从核心逻辑中抽离出来,使得渲染核心可以跨平台复用。

浏览器平台的实现

对于浏览器平台,Vue 提供了对应的 DOM 操作:

// 浏览器平台操作
const nodeOps = {
  // 创建元素:直接调用 document.createElement
  createElement(tag) {
    return document.createElement(tag);
  },
  
  // 创建文本节点
  createText(text) {
    return document.createTextNode(text);
  },
  
  // 创建注释节点
  createComment(text) {
    return document.createComment(text);
  },
  
  // 插入节点:使用 insertBefore 实现通用插入
  insert(child, parent, anchor = null) {
    parent.insertBefore(child, anchor);
  },
  
  // 设置元素文本内容
  setElementText(el, text) {
    el.textContent = text;
  },
  
  // 设置文本节点内容
  setText(node, text) {
    node.nodeValue = text;
  }
};

创建应用 API

渲染器还负责提供 createApp API,这是 Vue 应用的入口:

function createAppAPI(render) {
  return function createApp(rootComponent) {
    const app = {
      // 挂载方法
      mount(rootContainer) {
        // 1. 创建根 VNode
        const vnode = createVNode(rootComponent);
        
        // 2. 调用渲染器
        render(vnode, rootContainer);
        
        // 3. 返回组件实例
        return vnode.component;
      }
    };
    return app;
  };
}

首次渲染的完整流程

从 render 到 patch

当调用 app.mount('#app') 时,渲染器开始工作:

function render(vnode, container) {
  if (vnode) {
    // 存在新 VNode,进行 patch
    // container._vnode 存储上一次的 VNode,首次为 null
    patch(container._vnode || null, vnode, container);
  } else {
    // 没有新 VNode,卸载旧节点
    if (container._vnode) {
      unmount(container._vnode);
    }
  }
  // 保存当前 VNode
  container._vnode = vnode;
}

patch 的分发逻辑

patch 是整个渲染器的核心函数,它根据节点类型分发到不同的处理函数:

function patch(oldVNode, newVNode, container, anchor = null) {
  // 首次渲染,oldVNode 为 null
  if (oldVNode == null) {
    // 根据类型选择挂载方式
    const { type, shapeFlag } = newVNode;
    
    switch (type) {
      case Text:      // 文本节点
        mountText(newVNode, container, anchor);
        break;
      case Comment:   // 注释节点
        mountComment(newVNode, container, anchor);
        break;
      case Fragment:  // 片段
        mountFragment(newVNode, container, anchor);
        break;
      default:
        if (shapeFlag & ShapeFlags.ELEMENT) {
          // 原生元素
          mountElement(newVNode, container, anchor);
        } else if (shapeFlag & ShapeFlags.COMPONENT) {
          // 组件
          mountComponent(newVNode, container, anchor);
        }
    }
  }
}

下图展示了 patch 的分发流程: pacth 分发流程

为什么需要这么多类型?

不同类型的节点在 DOM 中的表现完全不同:

节点类型 真实 DOM 表示 特点
元素节点 HTMLElement 有标签名、属性、子节点
文本节点 TextNode 只有文本内容
注释节点 Comment 用于注释,不影响渲染
Fragment 无对应节点 多个根节点的容器

原生元素的挂载详解

mountElement 的四个步骤

挂载一个原生元素需要四个核心步骤:

  1. 创建 DOM 元素
  2. 保存 DOM 元素引用
  3. 处理子节点和属性
  4. 插入到容器
function mountElement(vnode, container, anchor) {
  const { type, props, shapeFlag } = vnode;
  
  // 步骤1:创建 DOM 元素
  const el = hostCreateElement(type);
  
  // 步骤2:保存 DOM 元素引用
  vnode.el = el;
  
  // 步骤3:处理子节点和属性
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    // 情况A:文本子节点
    hostSetElementText(el, vnode.children);
  } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    // 情况B:数组子节点
    mountChildren(vnode.children, el);
  }
  
  if (props) {
    for (const key in props) {
      hostPatchProp(el, key, null, props[key]);
    }
  }
  
  // 步骤4:插入到容器
  hostInsert(el, container, anchor);
}

子节点的递归挂载

数组子节点的挂载是一个递归过程:

function mountChildren(children, container) {
  for (let i = 0; i < children.length; i++) {
    const child = children[i];
    // 递归调用 patch 挂载每个子节点
    // 注意:这里传入的 oldVNode 为 null
    patch(null, child, container);
  }
}

一个完整的挂载示例

让我们通过一个具体例子,观察挂载的全过程:

// 示例 VNode
const vnode = {
  type: 'div',
  props: {
    class: 'card',
    id: 'card-1',
    'data-index': 0
  },
  children: [
    {
      type: 'h2',
      props: { class: 'title' },
      children: '标题',
      shapeFlag: ShapeFlags.ELEMENT | ShapeFlags.TEXT_CHILDREN
    },
    {
      type: 'p',
      props: { class: 'content' },
      children: '内容',
      shapeFlag: ShapeFlags.ELEMENT | ShapeFlags.TEXT_CHILDREN
    }
  ],
  shapeFlag: ShapeFlags.ELEMENT | ShapeFlags.ARRAY_CHILDREN
};

// 执行挂载
mountElement(vnode, document.getElementById('app'));

// 生成的真实 DOM:
// <div class="card" id="card-1" data-index="0">
//   <h2 class="title">标题</h2>
//   <p class="content">内容</p>
// </div>

属性的处理

属性的分类

在 Web 开发中,元素的属性分为以下几类:

  1. 普通属性:<div id="app" title="标题"></div>
  2. 类名:<div class="container active"></div>
  3. 样式:<div style="color: red; font-size: 16px"></div>
  4. 事件:<div onclick="handleClick"></div>
  5. DOM 属性:<div hidden disabled></div>

属性的设置方式

不同类型的属性,设置方式也不同:

类型 设置方式 示例
普通属性 setAttribute el.setAttribute('id', 'app')
类名 className el.className = 'container'
样式 style 对象 el.style.color = 'red'
事件 addEventListener el.addEventListener('click', handler)
DOM 属性 直接赋值 el.hidden = true

patchProp 的分发逻辑

Vue3 的 patchProp 函数需要处理以下这些情况:

  1. 处理事件:patchEvent(el, key, prevValue, nextValue);
  2. 处理 class:patchClass(el, nextValue);
  3. 处理 style:patchStyle(el, prevValue, nextValue);
  4. 处理 DOM 属性:patchDOMProp(el, key, nextValue);
  5. 处理普通属性:patchAttr(el, key, nextValue);

事件处理的优化

事件处理有一个重要的优化点:避免频繁添加/移除事件监听

不好的做法:每次更新都移除再添加

function patchEventBad(el, key, prevValue, nextValue) {
  const eventName = key.slice(2).toLowerCase();
  
  if (prevValue) {
    el.removeEventListener(eventName, prevValue);
  }
  if (nextValue) {
    el.addEventListener(eventName, nextValue);
  }
}

Vue3 的做法:使用 invoker 缓存

function patchEvent(el, rawKey, prevValue, nextValue) {
  const eventName = rawKey.slice(2).toLowerCase();
  
  // 使用 el._vei 存储事件调用器
  const invokers = el._vei || (el._vei = {});
  let invoker = invokers[eventName];
  
  if (nextValue && invoker) {
    // 有旧调用器:只更新值
    invoker.value = nextValue;
  } else if (nextValue && !invoker) {
    // 无旧调用器:创建新调用器
    invoker = createInvoker(nextValue);
    invokers[eventName] = invoker;
    el.addEventListener(eventName, invoker);
  } else if (!nextValue && invoker) {
    // 没有新值:移除监听
    el.removeEventListener(eventName, invoker);
    invokers[eventName] = null;
  }
}

function createInvoker(initialValue) {
  const invoker = (e) => {
    invoker.value(e);
  };
  invoker.value = initialValue;
  return invoker;
}

这种设计的优势在于:事件监听只添加一次,后续更新只改变回调函数: invoker 缓存

样式的合并处理

patchStyle 需要处理三种情况:

  1. 没有新样式:el.removeAttribute('style');
  2. 新样式是字符串:style.cssText = next;
  3. 新样式是对象:
// 设置新样式
for (const key in next) {
  setStyle(style, key, next[key]);
}

// 移除旧样式中不存在于新样式的属性
if (prev && typeof prev !== 'string') {
  for (const key in prev) {
    if (next[key] == null) {
      setStyle(style, key, '');
    }
  }
}

文本节点和注释节点

文本节点的处理

文本节点是最简单的节点类型:

// 文本节点的类型标识(Symbol 保证唯一性)
const Text = Symbol('Text');
function mountText(vnode, container, anchor) {
  // 1. 创建文本节点
  const textNode = document.createTextNode(vnode.children);
  
  // 2. 保存真实节点引用
  vnode.el = textNode;
  
  // 3. 插入到容器
  container.insertBefore(textNode, anchor);
}

文本节点在 DOM 中的表现:

<!-- 文本节点没有标签,只有内容 -->
Hello World

注释节点的处理

注释节点用于调试和特殊场景:

const Comment = Symbol('Comment');

function mountComment(vnode, container, anchor) {
  // 创建注释节点
  const commentNode = document.createComment(vnode.children);
  vnode.el = commentNode;
  container.insertBefore(commentNode, anchor);
}

注释节点在 DOM 中的表现:

<!-- 这是一个注释节点,不会显示在页面上 -->

Fragment 的处理

Fragment 是 Vue3 新增的特性,允许组件返回多个根节点:

const Fragment = Symbol('Fragment');

function mountFragment(vnode, container, anchor) {
  const { children, shapeFlag } = vnode;
  
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    // 文本子节点:挂载为文本节点
    mountText(createTextVNode(children), container, anchor);
  } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    // 数组子节点:挂载所有子节点
    mountChildren(children, container);
  }
  
  // Fragment 本身没有真实 DOM
  // el 指向第一个子节点的 el
  vnode.el = children[0]?.el;
  // anchor 指向最后一个子节点的 el
  vnode.anchor = children[children.length - 1]?.el;
}

Fragment 的 DOM 表现:

<!-- 没有外层包裹元素 -->
<h1>标题</h1>
<p>段落1</p>
<p>段落2</p>

完整的渲染器实现

让我们将上述所有概念整合,实现一个可工作的简化版渲染器:

class Renderer {
  constructor(options) {
    // 注入平台操作
    this.createElement = options.createElement;
    this.createText = options.createText;
    this.createComment = options.createComment;
    this.insert = options.insert;
    this.setElementText = options.setElementText;
    this.patchProp = options.patchProp;
  }

  render(vnode, container) {
    if (vnode) {
      this.patch(null, vnode, container);
      container._vnode = vnode;
    } else if (container._vnode) {
      this.unmount(container._vnode);
    }
  }

  patch(oldVNode, newVNode, container, anchor = null) {
    if (oldVNode === newVNode) return;
    
    const { type, shapeFlag } = newVNode;
    
    // 根据类型分发
    if (type === Text) {
      this.processText(oldVNode, newVNode, container, anchor);
    } else if (type === Comment) {
      this.processComment(oldVNode, newVNode, container, anchor);
    } else if (type === Fragment) {
      this.processFragment(oldVNode, newVNode, container, anchor);
    } else if (shapeFlag & ShapeFlags.ELEMENT) {
      this.processElement(oldVNode, newVNode, container, anchor);
    } else if (shapeFlag & ShapeFlags.COMPONENT) {
      this.processComponent(oldVNode, newVNode, container, anchor);
    }
  }

  processElement(oldVNode, newVNode, container, anchor) {
    if (oldVNode == null) {
      this.mountElement(newVNode, container, anchor);
    } else {
      this.patchElement(oldVNode, newVNode);
    }
  }

  mountElement(vnode, container, anchor) {
    // 1. 创建元素
    const el = this.createElement(vnode.type);
    vnode.el = el;
    
    // 2. 处理属性
    if (vnode.props) {
      for (const key in vnode.props) {
        this.patchProp(el, key, null, vnode.props[key]);
      }
    }
    
    // 3. 处理子节点
    const { shapeFlag, children } = vnode;
    if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
      this.setElementText(el, children);
    } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      this.mountChildren(children, el);
    }
    
    // 4. 插入容器
    this.insert(el, container, anchor);
  }

  mountChildren(children, container) {
    for (let i = 0; i < children.length; i++) {
      this.patch(null, children[i], container);
    }
  }

  processText(oldVNode, newVNode, container, anchor) {
    if (oldVNode == null) {
      const textNode = this.createText(newVNode.children);
      newVNode.el = textNode;
      this.insert(textNode, container, anchor);
    } else {
      const el = (newVNode.el = oldVNode.el);
      if (newVNode.children !== oldVNode.children) {
        el.nodeValue = newVNode.children;
      }
    }
  }

  processComment(oldVNode, newVNode, container, anchor) {
    if (oldVNode == null) {
      const commentNode = this.createComment(newVNode.children);
      newVNode.el = commentNode;
      this.insert(commentNode, container, anchor);
    } else {
      newVNode.el = oldVNode.el;
    }
  }

  processFragment(oldVNode, newVNode, container, anchor) {
    if (oldVNode == null) {
      const { shapeFlag, children } = newVNode;
      if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
        this.patch(null, {
          type: Text,
          children
        }, container, anchor);
      } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        this.mountChildren(children, container);
      }
    } else {
      this.patchChildren(oldVNode, newVNode, container);
    }
  }

  unmount(vnode) {
    const parent = vnode.el.parentNode;
    if (parent) {
      parent.removeChild(vnode.el);
    }
  }
}

性能优化与最佳实践

避免不必要的挂载

在实际开发中,需要注意避免频繁的挂载和卸载:

// 不推荐:频繁切换导致反复挂载/卸载
function BadExample() {
  return show.value 
    ? h(HeavyComponent) 
    : null;
}

// 推荐:使用 keep-alive 缓存组件
function GoodExample() {
  return h(KeepAlive, null, [
    show.value ? h(HeavyComponent) : null
  ]);
}

合理使用 key

key 在 diff 算法中起着关键作用:

// 不推荐:使用索引作为 key
items.map((item, index) => 
  h('div', { key: index }, item.text)
);

// 推荐:使用唯一标识
items.map(item => 
  h('div', { key: item.id }, item.text)
);

为什么不推荐使用索引作为 key: 不推荐使用索引key

静态内容提升

对于不会变化的静态内容,应该避免重复创建 VNode:

// 编译器会自动优化
// 源码:
// <div>
//   <span>静态文本</span>
//   <span>{{ dynamic }}</span>
// </div>

// 编译后:
const _hoisted_1 = h('span', '静态文本');

function render(ctx) {
  return h('div', [
    _hoisted_1,  // 直接复用
    h('span', ctx.dynamic)
  ]);
}

事件委托优化

对于大量相似元素的交互,使用事件委托:

// 不推荐:每个元素独立事件
list.value.map(item => 
  h('button', {
    onClick: () => handleItem(item)
  }, item.name)
);

// 推荐:使用事件委托
function handleListClick(e) {
  const target = e.target;
  if (target.tagName === 'BUTTON') {
    const index = target.dataset.index;
    handleItem(list.value[index]);
  }
}

h('div', { onClick: handleListClick },
  list.value.map((item, index) => 
    h('button', { 
      'data-index': index 
    }, item.name)
  )
);

完整挂载流程图

下面是完整的挂载流程图: 完整挂载流程图

结语

本文主要介绍了 Vue3 渲染器的挂载全过程,对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

虚拟DOM:VNode的设计与创建

经过前几篇文章的深入探索,我们完整地构建了 Vue3 的响应式系统。但响应式数据最终要渲染到页面上,这中间的桥梁就是虚拟DOM。今天,我们将深入 Vue3 虚拟 DOM 的设计与实现,看看它如何为高效的页面更新奠定基础。

前言:为什么需要虚拟DOM?

在传统的 jQuery 时代,我们直接操作真实 DOM:

$('#app').html('<div>Hello World</div>');

这种方式虽然直观,但有几个致命问题:

  • 性能开销大:DOM 操作是浏览器中最昂贵的操作之一,频繁的 DOM 操作会严重影响系统性能
  • 难以追踪:复杂应用的状态变化难以管理
  • 手动操作:开发者需要手动维护 DOM 与状态的一致性

虚拟 DOM 的出现解决了这些问题:

// 虚拟 DOM 描述
const vnode = {
  type: 'div',
  props: { class: 'container' },
  children: 'Hello World'
};

// 渲染器将虚拟 DOM 转换为真实 DOM
render(vnode, document.getElementById('app'));

虚拟 DOM 的本质:用 JavaScript 对象来描述真实 DOM 结构,通过比较新旧虚拟 DOM 的差异(diff),最小化地更新真实 DOM。

注:虚拟 DOM 相比真实 DOM 的优势在于:频繁操作 DOM 时,虚拟 DOM 可以先将操作收集,再一次性转成真实 DOM,渲染到页面上;而不需要每次操作都修改真实 DOM。

注:虚拟 DOM 不一定比真实 DOM 快,毕竟没有什么操作的性能能比 document.createElement('div') 更优了!

虚拟 DOM 的结构变化

Vue2 的 VNode 结构

// Vue2 的 VNode 结构(简化)
interface VNode {
  tag?: string;           // 标签名
  data?: VNodeData;       // 属性、事件等
  children?: VNode[];      // 子节点
  text?: string;          // 文本内容
  elm?: Node;             // 对应的真实 DOM
  key?: string | number;  // 唯一标识
  // ... 其他属性
}

Vue3 的 VNode 结构

// Vue3 的 VNode 结构(简化)
interface VNode {
  __v_isVNode: true;      // 标记为 VNode
  type: any;              // 类型:元素标签、组件、Fragment等
  props: any;             // 属性
  children: any;          // 子节点
  shapeFlag: number;      // 节点类型标志(位掩码)
  patchFlag: number;      // 优化标志(位掩码)
  dynamicProps: string[] | null;  // 动态属性列表
  staticCount: number;    // 静态节点计数
  
  key: any;               // 唯一标识
  ref: any;               // 引用
  el: HostNode | null;    // 真实 DOM 节点
  anchor: HostNode | null; // 锚点(Fragment 使用)
  
  // 组件相关
  component: any;         // 组件实例
  suspense: any;          // Suspense 相关
  ssContent: any;         // SSR 内容
  ssFallback: any;        // SSR 回退
  
  // 优化相关
  scopeId: string | null; // 作用域 ID
  slotScopeIds: string[] | null; // 插槽作用域 ID
}

Vue3 VNode 结构的主要变化

1. 更明确的类型标识

type: 'div' | 'span' | MyComponent | Fragment | Text | Comment | Static;

2. 使用 shapeFlag 位掩码标记类型

const enum ShapeFlags {
  ELEMENT = 1,              // 元素节点
  FUNCTIONAL_COMPONENT = 1 << 1, // 函数式组件
  STATEFUL_COMPONENT = 1 << 2,    // 状态组件
  TEXT_CHILDREN = 1 << 3,   // 文本子节点
  ARRAY_CHILDREN = 1 << 4,  // 数组子节点
  SLOTS_CHILDREN = 1 << 5,  // 插槽子节点
  TELEPORT = 1 << 6,        // Teleport
  SUSPENSE = 1 << 7,        // Suspense
  COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8,
  COMPONENT_KEPT_ALIVE = 1 << 9
}

3. 使用 patchFlag 标记动态内容

export const enum PatchFlags {
  TEXT = 1,                 // 动态文本内容
  CLASS = 1 << 1,           // 动态 class
  STYLE = 1 << 2,           // 动态 style
  PROPS = 1 << 3,           // 动态属性
  FULL_PROPS = 1 << 4,      // 全量比较
  HYDRATE_EVENTS = 1 << 5,  // 事件监听
  STABLE_FRAGMENT = 1 << 6, // 稳定 Fragment
  KEYED_FRAGMENT = 1 << 7,  // 带 key 的 Fragment
  UNKEYED_FRAGMENT = 1 << 8, // 无 key 的 Fragment
  NEED_PATCH = 1 << 9,      // 需要非 props 比较
  DYNAMIC_SLOTS = 1 << 10,  // 动态插槽
  DEV_ROOT_FRAGMENT = 1 << 11, // 开发环境根 Fragment
  
  // 特殊标志
  HOISTED = -1,             // 静态提升节点
  BAIL = -2                 // 退出优化
}

VNode 的核心属性

1. type:节点类型

元素节点
const elementVNode = {
  type: 'div',
  props: { class: 'box' },
  children: 'Hello'
};
组件节点
const MyComponent = {
  setup() {
    return () => h('div', '组件内容');
  }
};

const componentVNode = {
  type: MyComponent,
  props: { title: '标题' }
};
文本节点
const textVNode = {
  type: Text,
  props: null,
  children: '文本内容'
};

Fragment(片段)
const fragmentVNode = {
  type: Fragment,
  children: [
    h('div', '子节点1'),
    h('div', '子节点2')
  ]
};
静态节点
const staticVNode = {
  type: 'div',
  props: { class: 'static' },
  children: '静态内容',
  patchFlag: PatchFlags.HOISTED // 标记为提升
};

2. props:属性

function createVNode(type, props, children) {
  const vnode = {
    type,
    props: props || {},
    children,
    // 提取关键属性
    key: props && props.key,
    ref: props && props.ref,
    // 清理 props 中的特殊属性
    ...normalizeProps(props)
  };
  
  return vnode;
}

function normalizeProps(props) {
  if (!props) return {};
  
  // 分离特殊属性
  const { key, ref, ...pureProps } = props;
  
  return {
    props: pureProps,
    key,
    ref
  };
}

3. children:子节点

文本子节点
const vnode1 = {
  type: 'div',
  children: '纯文本',
  shapeFlag: ShapeFlags.ELEMENT | ShapeFlags.TEXT_CHILDREN
};
数组子节点
const vnode2 = {
  type: 'div',
  children: [
    h('span', '子节点1'),
    h('span', '子节点2')
  ],
  shapeFlag: ShapeFlags.ELEMENT | ShapeFlags.ARRAY_CHILDREN
};
插槽子节点
const vnode3 = {
  type: MyComponent,
  children: {
    default: () => h('div', '默认插槽'),
    header: () => h('div', '头部插槽')
  },
  shapeFlag: ShapeFlags.COMPONENT | ShapeFlags.SLOTS_CHILDREN
};
空子节点
const vnode4 = {
  type: 'div',
  children: null,
  shapeFlag: ShapeFlags.ELEMENT
};

多种 VNode 类型

元素节点

function createElementVNode(tag, props, children) {
  const vnode = {
    type: tag,
    props,
    children,
    shapeFlag: ShapeFlags.ELEMENT
  };
  
  // 设置子节点类型标志
  if (typeof children === 'string') {
    vnode.shapeFlag |= ShapeFlags.TEXT_CHILDREN;
  } else if (Array.isArray(children)) {
    vnode.shapeFlag |= ShapeFlags.ARRAY_CHILDREN;
  }
  
  return vnode;
}

组件节点

function createComponentVNode(component, props, children) {
  const vnode = {
    type: component,
    props,
    children,
    shapeFlag: ShapeFlags.STATEFUL_COMPONENT
  };
  
  // 处理插槽
  if (typeof children === 'object') {
    vnode.shapeFlag |= ShapeFlags.SLOTS_CHILDREN;
  }
  
  // 组件实例(稍后填充)
  vnode.component = null;
  
  return vnode;
}

文本节点

const Text = Symbol('Text');

function createTextVNode(text) {
  return {
    type: Text,
    props: null,
    children: String(text),
    shapeFlag: ShapeFlags.TEXT_CHILDREN
  };
}

Fragment 节点

const Fragment = Symbol('Fragment');

function createFragmentVNode(children) {
  return {
    type: Fragment,
    props: null,
    children,
    shapeFlag: Array.isArray(children) 
      ? ShapeFlags.ARRAY_CHILDREN 
      : ShapeFlags.TEXT_CHILDREN
  };
}

静态节点

function createStaticVNode(content, count) {
  return {
    type: 'div',
    props: null,
    children: content,
    shapeFlag: ShapeFlags.ELEMENT | ShapeFlags.TEXT_CHILDREN,
    patchFlag: PatchFlags.HOISTED,
    staticCount: count
  };
}

静态提升(Static Hoisting)

静态提升的原理

我们先来看一段模版代码:

<div>
  <span>静态文本</span>
  <span>{{ dynamic }}</span>
</div>

没有静态提升下的渲染函数:

function render(ctx) {
  return h('div', [
    h('span', '静态文本'), // 每次渲染都创建
    h('span', ctx.dynamic)
  ]);
}

没有静态提升下,对于 <span>静态文本</span> 这段代码,每次渲染时都会创建。

静态提升下的渲染函数:

const _hoisted_1 = h('span', '静态文本'); // 提升到函数外

function render(ctx) {
  return h('div', [
    _hoisted_1, // 直接复用
    h('span', ctx.dynamic)
  ]);
}

静态提升下,对于 <span>静态文本</span> 这段代码,会将静态文本的 VNode 提升到函数外,在需要的时候直接复用即可!

实现静态提升

// 编译器生成的代码示例
import { createVNode as _createVNode, openBlock as _openBlock, createBlock as _createBlock } from "vue"

// 静态节点提升
const _hoisted_1 = _createVNode("span", null, "静态文本", PatchFlags.HOISTED)
const _hoisted_2 = _createVNode("div", { class: "static-class" }, [
  _hoisted_1,
  _createVNode("span", null, "另一个静态节点", PatchFlags.HOISTED)
], PatchFlags.HOISTED)

export function render(_ctx, _cache) {
  return (_openBlock(), _createBlock("div", null, [
    _hoisted_2,  // 直接使用提升的节点
    _createVNode("span", null, _ctx.dynamic, PatchFlags.TEXT)
  ]))
}

Patch Flags 的作用

为什么要用 Patch Flags?

无 Patch Flags:需要全量比较:

function patch(oldVNode, newVNode) {
  // 比较所有属性
  if (oldVNode.props.class !== newVNode.props.class) {
    updateClass();
  }
  if (oldVNode.props.style !== newVNode.props.style) {
    updateStyle();
  }
  if (oldVNode.props.id !== newVNode.props.id) {
    updateId();
  }
  // ... 比较所有可能的属性
}

有 Patch Flags:只比较动态部分:

function patch(oldVNode, newVNode) {
  if (newVNode.patchFlag & PatchFlags.CLASS) {
    // 只有 class 是动态的
    updateClass();
  }
  if (newVNode.patchFlag & PatchFlags.STYLE) {
    // 只有 style 是动态的
    updateStyle();
  }
  // 只比较标记为动态的属性
}

Patch Flags 的实现

// 动态节点标记
function createVNodeWithFlags(type, props, children, flag) {
  const vnode = createVNode(type, props, children);
  vnode.patchFlag = flag;
  
  // 记录动态属性名
  if (flag & PatchFlags.PROPS) {
    vnode.dynamicProps = Object.keys(props).filter(
      key => !isStaticProperty(key)
    );
  }
  
  return vnode;
}

// 使用示例
const dynamicClassVNode = createVNodeWithFlags(
  'div',
  { class: dynamicClass }, // class 动态
  '内容',
  PatchFlags.CLASS
);

const dynamicTextVNode = createVNodeWithFlags(
  'span',
  null,
  dynamicText,
  PatchFlags.TEXT
);

const multipleDynamicsVNode = createVNodeWithFlags(
  'div',
  {
    class: dynamicClass,
    style: dynamicStyle,
    id: 'static-id' // 静态属性
  },
  '内容',
  PatchFlags.CLASS | PatchFlags.STYLE
);
// dynamicProps: ['class', 'style']

h 函数的实现

h 函数的基本实现

/**
 * h 函数:创建 VNode 的辅助函数
 * @param {string|object} type - 节点类型
 * @param {object} props - 属性
 * @param {array|string} children - 子节点
 * @returns {object} VNode
 */
function h(type, props, children) {
  // 处理参数重载
  const args = normalizeArgs(type, props, children);
  
  return createVNode(args.type, args.props, args.children);
}

function normalizeArgs(type, props, children) {
  // 如果没有 props
  if (arguments.length === 2) {
    if (isObject(props) && !isArray(props)) {
      // h('div', { class: 'box' })
      return { type, props, children: null };
    } else {
      // h('div', '文本内容')
      return { type, props: null, children: props };
    }
  }
  
  // 完整参数
  return { type, props, children };
}

function isObject(val) {
  return val !== null && typeof val === 'object';
}

function isArray(val) {
  return Array.isArray(val);
}

完整的 createVNode 实现

/**
 * 创建 VNode
 * @param {any} type - 节点类型
 * @param {object} props - 属性
 * @param {any} children - 子节点
 * @param {number} patchFlag - 优化标志
 * @param {object} dynamicProps - 动态属性列表
 * @returns {object} VNode
 */
function createVNode(type, props, children, patchFlag, dynamicProps) {
  // 处理 props
  props = normalizeProps(props);
  
  // 提取 key 和 ref
  const { key, ref } = props || {};
  
  // 计算 shapeFlag
  const shapeFlag = getShapeFlag(type, children);
  
  // 创建基础 VNode
  const vnode = {
    __v_isVNode: true,
    type,
    props: props || null,
    children,
    shapeFlag,
    
    // 优化相关
    patchFlag: patchFlag || 0,
    dynamicProps: dynamicProps || null,
    
    // 核心属性
    key,
    ref,
    
    // 运行时相关
    el: null,          // 真实 DOM
    anchor: null,      // 锚点(Fragment)
    component: null,   // 组件实例
    parent: null,      // 父 VNode
    
    // 其他
    scopeId: null,
    slotScopeIds: null
  };
  
  // 处理子节点
  normalizeChildren(vnode, children);
  
  // 如果有动态 children,记录
  if (shouldTrackDynamicChildren(vnode)) {
    vnode.dynamicChildren = [];
  }
  
  return vnode;
}

function normalizeProps(props) {
  if (!props) return null;
  
  // 移除 Vue 内部使用的特殊属性
  const { class: klass, style, ...rest } = props;
  
  // 合并 class
  if (klass) {
    rest.class = normalizeClass(klass);
  }
  
  // 合并 style
  if (style) {
    rest.style = normalizeStyle(style);
  }
  
  return rest;
}

function getShapeFlag(type, children) {
  let shapeFlag = 0;
  
  // 判断类型
  if (typeof type === 'string') {
    shapeFlag = ShapeFlags.ELEMENT;
  } else if (type === Text) {
    shapeFlag = ShapeFlags.TEXT_CHILDREN;
  } else if (type === Fragment) {
    shapeFlag = ShapeFlags.FRAGMENT;
  } else {
    shapeFlag = ShapeFlags.STATEFUL_COMPONENT;
  }
  
  // 判断子节点类型
  if (children) {
    if (typeof children === 'string') {
      shapeFlag |= ShapeFlags.TEXT_CHILDREN;
    } else if (Array.isArray(children)) {
      shapeFlag |= ShapeFlags.ARRAY_CHILDREN;
    } else if (isObject(children)) {
      shapeFlag |= ShapeFlags.SLOTS_CHILDREN;
    }
  }
  
  return shapeFlag;
}

function normalizeChildren(vnode, children) {
  if (!children) return;
  
  // 标准化文本子节点
  if (typeof children === 'string' || typeof children === 'number') {
    vnode.children = String(children);
  }
  
  // 标准化数组子节点
  if (Array.isArray(children)) {
    vnode.children = children.map(child => {
      if (typeof child === 'string') {
        return createTextVNode(child);
      }
      return child;
    });
  }
}

function shouldTrackDynamicChildren(vnode) {
  return vnode.patchFlag > 0 || 
         vnode.patchFlag === PatchFlags.HOISTED ||
         vnode.shapeFlag & ShapeFlags.COMPONENT;
}

// 工具函数:规范化 class
function normalizeClass(value) {
  if (typeof value === 'string') return value;
  if (Array.isArray(value)) {
    return value.map(normalizeClass).filter(Boolean).join(' ');
  }
  if (isObject(value)) {
    return Object.keys(value)
      .filter(key => value[key])
      .join(' ');
  }
  return '';
}

// 工具函数:规范化 style
function normalizeStyle(value) {
  if (typeof value === 'string') return value;
  if (Array.isArray(value)) {
    return Object.assign({}, ...value.map(normalizeStyle));
  }
  if (isObject(value)) return value;
  return {};
}

h 函数的完整版本

/**
 * 完整的 h 函数实现
 * 支持多种调用方式:
 * h('div')
 * h('div', { class: 'box' })
 * h('div', '文本')
 * h('div', {}, ['子节点1', '子节点2'])
 * h(Component, { props })
 */
function h(type, propsOrChildren, children) {
  const args = arguments.length;
  
  // h('div')
  if (args === 1) {
    return createVNode(type, null, null);
  }
  
  // h('div', {})
  if (args === 2) {
    if (isObject(propsOrChildren) && !isArray(propsOrChildren)) {
      // 第二个参数是 props
      return createVNode(type, propsOrChildren, null);
    } else {
      // 第二个参数是 children
      return createVNode(type, null, propsOrChildren);
    }
  }
  
  // h('div', {}, '文本')
  // h('div', {}, [])
  // h('div', {}, h('span'))
  if (args === 3) {
    if (isObject(propsOrChildren) && !isArray(propsOrChildren)) {
      // 有 props
      return createVNode(type, propsOrChildren, children);
    } else {
      // 无 props
      return createVNode(type, null, propsOrChildren);
    }
  }
  
  // 更多参数(不常见)
  const props = propsOrChildren;
  const _children = Array.from(arguments).slice(2);
  return createVNode(type, props, _children);
}

实战:使用 h 函数创建组件

// 定义组件
const MyComponent = {
  setup(props) {
    const count = ref(0);
    
    return () => h('div', { class: 'counter' }, [
      h('h3', props.title),
      h('p', `计数: ${count.value}`),
      h('button', {
        onClick: () => count.value++
      }, '增加')
    ]);
  }
};

// 创建 VNode
const vnode = h(MyComponent, {
  title: '我的计数器'
});

// 模拟渲染
function render(vnode, container) {
  if (typeof vnode.type === 'object') {
    // 组件
    const component = vnode.type;
    const subTree = component.setup(vnode.props);
    render(subTree, container);
  } else if (typeof vnode.type === 'string') {
    // 元素
    const el = document.createElement(vnode.type);
    
    // 设置属性
    if (vnode.props) {
      Object.entries(vnode.props).forEach(([key, value]) => {
        if (key.startsWith('on')) {
          el.addEventListener(key.slice(2).toLowerCase(), value);
        } else {
          el.setAttribute(key, value);
        }
      });
    }
    
    // 处理子节点
    if (typeof vnode.children === 'string') {
      el.textContent = vnode.children;
    } else if (Array.isArray(vnode.children)) {
      vnode.children.forEach(child => render(child, el));
    }
    
    container.appendChild(el);
    vnode.el = el;
  }
}

// 挂载
render(vnode, document.getElementById('app'));

结语

Vue3 的虚拟 DOM 在设计上进行了大量的优化,理解虚拟 DOM 的设计与实现,不仅帮助我们写出更高效的 Vue 应用,也为后续学习 diff 算法和渲染器打下坚实基础。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

vue3 Pinia 全解析:从入门到实战。

在 Vue3 生态中,Pinia 已经成为官方推荐的状态管理库,彻底替代了 Vue2 时代的 Vuex。相比于 Vuex,Pinia 移除了 mutations、modules 等繁琐概念,简化了语法,同时完美适配 Vue3 组合式 API,支持 TypeScript 类型推断,上手成本极低,成为中小型项目乃至大型项目的首选状态管理方案。

本文将从新手视角出发,用「通俗讲解 + 可直接复制的实战代码」,覆盖 Pinia 从环境搭建、基础使用(定义Store、存取状态、修改状态),到进阶技巧(Getters、Actions、持久化),再到实战场景的全流程,同时兼容非TS和TS两种写法,新手看完就能上手,老手也能查漏补缺,彻底掌握 Pinia 的核心用法。

一、为什么选择 Pinia?(对比Vuex的优势)

在学习 Pinia 之前,先搞清楚一个问题:为什么 Vue3 官方推荐用 Pinia 替代 Vuex?核心优势总结为 5 点,看完你就明白它有多香:

  1. 语法更简洁:移除了 Vuex 中繁琐的 mutations(提交修改)、modules(模块)嵌套,直接用 Actions 修改状态,代码量减少 30%+;
  2. 完美适配组合式 API:和 Vue3
  3. 原生支持 TypeScript:自带类型推断,无需手动编写大量类型声明,TS 开发体验拉满(非TS项目也能正常使用);
  4. 无需手动注册:创建 Store 后可直接在组件中使用,无需像 Vuex 那样在 main.js 中注册 Store 实例;
  5. 轻量无依赖:体积极小(仅 1KB 左右),无需引入额外依赖,不增加项目负担。

一句话总结:Pinia 就是 Vue3 时代「更简单、更高效、更友好」的 Vuex 替代方案,无论你是新手还是老手,都能快速上手。

二、环境搭建(Vue3 + Pinia,非TS/TS通用)

首先我们完成 Pinia 的环境搭建,分为「新项目初始化」和「已有项目集成」两种场景,步骤简单,全程复制命令即可。

1. 前提条件

确保你的项目是 Vue3 项目(Vue2 不支持 Pinia),如果是 Vue2 项目,需先升级到 Vue3,或继续使用 Vuex。

2. 安装 Pinia

打开终端,进入项目根目录,执行以下命令(npm 和 yarn 二选一):

# npm 安装(推荐)
npm install pinia

# yarn 安装
yarn add pinia

3. 初始化 Pinia(main.js 配置)

安装完成后,需要在 main.js 中创建 Pinia 实例,并挂载到 Vue 应用上,这一步是全局唯一的。

非TS版(Vue3)

// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia' // 引入 createPinia
import App from './App.vue'

const app = createApp(App)
const pinia = createPinia() // 创建 Pinia 实例

app.use(pinia) // 挂载 Pinia 到 Vue 应用
app.mount('#app')

TS版(Vue3 + TS )

// main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)
app.use(createPinia()) // 可简化写法,无需单独定义 pinia 变量
app.mount('#app')

至此,Pinia 环境搭建完成,接下来就可以创建 Store,开始状态管理了。

三、Pinia 核心基础:定义Store(最关键一步)

在 Pinia 中,Store 是核心,可以理解为「一个全局的响应式数据容器」,用于存储和管理全局共享的状态(比如用户信息、购物车数据、主题配置等)。

每个 Store 都是独立的,互不干扰,我们可以根据业务需求,创建多个 Store(比如用户 Store、购物车 Store、设置 Store),实现状态的模块化管理(无需像 Vuex 那样嵌套 modules)。

1. Store 创建规范

推荐在项目根目录下创建 stores 文件夹(注意是复数),用于存放所有 Store 文件,每个 Store 单独创建一个 js/ts 文件,命名规范:xxxStore.js/ts(比如 userStore.js、cartStore.ts)。

2. 基础 Store 定义(非TS版,新手首选)

以「用户 Store」为例,创建 stores/userStore.js,代码如下(核心包含 3 部分:state、getters、actions):

// stores/userStore.js
import { defineStore } from 'pinia' // 引入 Pinia 内置的 defineStore

// 1. 定义并导出 Store(参数1:Store唯一标识,参数2:Store配置对象)
// 唯一标识(id):整个应用中唯一,不能重复,建议和文件名对应
export const useUserStore = defineStore('user', {
  // 2. state:存储全局状态(类似组件中的 data),返回一个对象
  state: () => ({
    username: '游客', // 初始用户名
    token: '', // 用户token
    isLogin: false, // 登录状态
    userInfo: {} // 用户详细信息(对象类型)
  }),

  // 3. getters:处理状态(类似组件中的 computed),用于对 state 进行计算或过滤
  getters: {
    // 示例1:简单计算(获取用户名的大写形式)
    upperUsername: (state) => {
      return state.username.toUpperCase()
    },
    // 示例2:依赖其他 getters(判断是否是管理员,假设 userInfo 中有 role 字段)
    isAdmin: (state, getters) => {
      // getters 可以访问当前 Store 中的其他 getters
      return state.userInfo.role === 'admin' && getters.upperUsername
    }
  },

  // 4. actions:修改状态(类似组件中的 methods),支持同步和异步
  actions: {
    // 示例1:同步修改状态(登录,修改用户信息)
    login(data) {
      // 直接修改 state 中的数据(无需像 Vuex 那样提交 mutation)
      this.username = data.username
      this.token = data.token
      this.isLogin = true
      this.userInfo = data.userInfo
    },

    // 示例2:异步修改状态(退出登录,模拟接口请求)
    async logout() {
      // 模拟接口请求(比如调用后端退出接口)
      await new Promise((resolve) => {
        setTimeout(() => {
          resolve()
        }, 500)
      })

      // 接口请求成功后,重置状态
      this.username = '游客'
      this.token = ''
      this.isLogin = false
      this.userInfo = {}
    },

    // 示例3:修改单个状态(单独修改用户名)
    updateUsername(name) {
      this.username = name
    }
  }
})

3. 基础 Store 定义(TS版,类型安全)

TS版会对 state、getters、actions 的参数和返回值进行类型约束,避免数据类型错误,代码如下:

// stores/userStore.ts
import { defineStore } from 'pinia'

// 定义 UserInfo 类型接口(约束 userInfo 的结构)
interface UserInfo {
  id?: number
  name?: string
  role?: 'admin' | 'user' // 角色只能是 admin 或 user
}

// 定义 State 类型接口(约束 state 的结构)
interface UserState {
  username: string
  token: string
  isLogin: boolean
  userInfo: UserInfo
}

// 定义并导出 Store
export const useUserStore = defineStore('user', {
  // 对 state 进行类型约束
  state: (): UserState => ({
    username: '游客',
    token: '',
    isLogin: false,
    userInfo: {}
  }),

  getters: {
    upperUsername: (state: UserState): string => {
      return state.username.toUpperCase()
    },
    isAdmin: (state: UserState, getters): boolean => {
      return state.userInfo.role === 'admin' && getters.upperUsername
    }
  },

  actions: {
    // 对参数 data 进行类型约束
    login(data: { username: string; token: string; userInfo: UserInfo }): void {
      this.username = data.username
      this.token = data.token
      this.isLogin = true
      this.userInfo = data.userInfo
    },

    // 异步 action,返回 Promise
    async logout(): Promise<void> {
      await new Promise((resolve) => {
        setTimeout(() => {
          resolve()
        }, 500)
      })

      this.username = '游客'
      this.token = ''
      this.isLogin = false
      this.userInfo = {}
    },

    updateUsername(name: string): void {
      this.username = name
    }
  }
})

核心细节说明(新手必看)

  • defineStore 是 Pinia 内置宏,无需导入额外依赖,第一个参数(id)必须全局唯一,否则会导致状态混乱;
  • state 必须是一个「函数」,返回一个对象,目的是避免多个组件复用 Store 时,出现状态污染;
  • getters 支持两种写法:箭头函数(推荐,简洁)和普通函数,箭头函数的第一个参数是 state,第二个参数是 getters(可访问当前 Store 的其他 getters);
  • actions 中可以直接通过 this 访问和修改 state 中的数据,支持同步和异步(async/await),无需像 Vuex 那样区分 mutations(同步)和 actions(异步);
  • Store 命名规范:函数名以 useXXXStore 开头(比如 useUserStore),符合 Vue3 组合式 API 的命名习惯。

四、Pinia 基础使用:组件中存取/修改状态

创建好 Store 后,就可以在任意组件中使用它了——核心步骤:引入 Store → 创建 Store 实例 → 存取/修改状态,步骤简单,无需注册,直接使用。

1. 组件中使用 Store(非TS版)

<template>
  <div class="demo">
    <h3>Pinia 组件使用示例(非TS版)</h3>
    <!-- 直接使用 state 中的数据 -->
    <p>当前用户:{{ userStore.username }}</p>
    <p>用户名大写:{{ userStore.upperUsername }}</p>
    <p>登录状态:{{ userStore.isLogin ? '已登录' : '未登录' }}</p>

    <!-- 调用 actions 中的方法,修改状态 -->
    <button @click="handleLogin">模拟登录</button>
    <button @click="handleLogout" style="margin-left: 10px;">模拟退出</button>
    <button @click="handleUpdateName" style="margin-left: 10px;">修改用户名</button>
  </div>
</template>

<script setup>
// 1. 引入创建好的 Store
import { useUserStore } from '@/stores/userStore'

// 2. 创建 Store 实例(必须调用 useUserStore(),不能直接赋值)
const userStore = useUserStore()

// 3. 调用 actions 中的方法,修改状态
const handleLogin = () => {
  // 模拟登录数据
  const loginData = {
    username: '掘金用户',
    token: '1234567890abcdef',
    userInfo: {
      id: 1001,
      name: '掘金用户',
      role: 'user'
    }
  }
  // 调用同步 action
  userStore.login(loginData)
}

const handleLogout = () => {
  // 调用异步 action(注意:异步方法需要加 await)
  userStore.logout()
}

const handleUpdateName = () => {
  // 调用 action 修改单个状态
  userStore.updateUsername('新的用户名')
}
</script>

2. 组件中使用 Store(TS版)

TS版和非TS版写法基本一致,唯一区别是 TS 会自动进行类型推断,无需手动声明类型,代码更安全:

<template>
  <div class="demo">
    <h3>Pinia 组件使用示例(TS版)</h3>
    <p>当前用户:{{ userStore.username }}</p>
    <p>用户名大写:{{ userStore.upperUsername }}</p>
    <button @click="handleLogin">模拟登录</button>
  </div>
</template>

<script setup lang="ts">
// 引入 Store(TS 会自动推断类型)
import { useUserStore } from '@/stores/userStore'

const userStore = useUserStore()

// 调用 action 时,TS 会自动校验参数类型
const handleLogin = () => {
  const loginData = {
    username: 'TS用户',
    token: 'ts123456',
    userInfo: {
      id: 1002,
      name: 'TS用户',
      role: 'admin' // 符合 UserInfo 接口的 role 类型
    }
  }
  userStore.login(loginData)
}
</script>

3. 状态修改的3种方式(实战常用)

Pinia 提供了 3 种修改 state 状态的方式,根据场景灵活选择,推荐优先使用前两种(符合规范):

方式1:调用 actions 中的方法(推荐,最规范)

这是最推荐的方式,将状态修改逻辑封装在 actions 中,便于维护和复用,尤其是复杂状态修改和异步操作:

// 组件中
const userStore = useUserStore()
// 调用 actions 方法修改状态
userStore.login(loginData)
userStore.updateUsername('新用户名')

方式2:直接修改 state 中的数据(简单场景可用)

如果是简单的单个状态修改,可直接通过 Store 实例修改,无需封装 actions(简化代码):

const userStore = useUserStore()
// 直接修改单个状态
userStore.username = '直接修改用户名'
userStore.isLogin = true
// 直接修改对象中的属性
userStore.userInfo.name = '修改用户姓名'

方式3:使用 $patch 批量修改状态(批量修改可用)

如果需要同时修改多个状态,使用$patch 方法,批量修改,代码更简洁:

const userStore = useUserStore()
// 批量修改状态(对象写法)
userStore.$patch({
  username: '批量修改用户名',
  isLogin: true,
  token: 'patch123456'
})

// 批量修改状态(函数写法,适合复杂逻辑)
userStore.$patch((state) => {
  state.username = '批量修改(函数写法)'
  state.userInfo.role = 'admin'
  state.isLogin = true
})

五、Pinia 进阶技巧:实战必备功能

掌握基础用法后,再学习几个进阶技巧,覆盖实际开发中的高频需求,让 Pinia 用起来更高效。

1. Getters 进阶:缓存与依赖

Pinia 的 getters 具有「缓存特性」——只要依赖的 state 数据不变化,getters 的计算结果就会被缓存,多次访问不会重复计算,提升性能。

// stores/userStore.js
getters: {
  // 缓存示例:只有 state.username 变化时,才会重新计算
  upperUsername: (state) => {
    console.log('getters 计算执行了') // 仅在 username 变化时打印
    return state.username.toUpperCase()
  },

  // 依赖其他 Store 的 getters(跨 Store 访问)
  // 假设还有一个 settingStore,用于存储主题配置
  themeAndUser: (state, getters) => {
    const settingStore = useSettingStore() // 引入其他 Store
    return `${getters.upperUsername} - ${settingStore.theme}`
  }
}

2. Actions 进阶:异步操作与跨 Store 调用

Actions 支持 async/await 异步操作(比如调用后端接口),同时也能调用其他 Store 的 actions 或修改其他 Store 的状态,实现跨 Store 交互。

// stores/userStore.js
import { useCartStore } from './cartStore' // 引入其他 Store

actions: {
  // 异步 action:调用后端登录接口(实战常用)
  async loginByApi(formData) {
    try {
      // 调用后端登录接口(模拟)
      const res = await fetch('/api/login', {
        method: 'POST',
        body: JSON.stringify(formData)
      })
      const data = await res.json()

      // 登录成功后,修改当前 Store 状态
      this.username = data.username
      this.token = data.token
      this.isLogin = true

      // 跨 Store 调用:调用 cartStore 的 actions(比如同步购物车数据)
      const cartStore = useCartStore()
      cartStore.syncCart(data.token) // 传递 token,同步购物车

      return data // 可返回数据,供组件使用
    } catch (err) {
      console.error('登录失败:', err)
      throw err // 抛出错误,供组件捕获处理
    }
  }
}

3. 状态持久化(实战必备)

Pinia 的状态默认存储在内存中,页面刷新后会丢失(比如登录状态、购物车数据),因此需要做「状态持久化」——将状态存储到 localStorage/sessionStorage 中,页面刷新后自动恢复。

推荐使用第三方插件 pinia-plugin-persistedstate,配置简单,一键实现持久化。

步骤1:安装插件

npm install pinia-plugin-persistedstate

# 或 yarn 安装
yarn add pinia-plugin-persistedstate

步骤2:main.js 中配置插件

// main.js(非TS版)
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' // 引入插件
import App from './App.vue'

const app = createApp(App)
const pinia = createPinia()

pinia.use(piniaPluginPersistedstate) // 挂载持久化插件
app.use(pinia)
app.mount('#app')

步骤3:给指定 Store 开启持久化

在 defineStore 中添加 persist: true,即可开启当前 Store 的持久化(默认存储到 localStorage):

// stores/userStore.js
export const useUserStore = defineStore('user', {
  state: () => ({ /* ... */ }),
  getters: { /* ... */ },
  actions: { /* ... */ },
  persist: true // 开启持久化,页面刷新后状态不丢失
})

进阶配置:自定义持久化规则

可自定义持久化方式(localStorage/sessionStorage)、存储的键名、需要持久化的状态字段:

persist: {
  key: 'userStore', // 存储到本地的键名(默认是 Store 的 id)
  storage: sessionStorage, // 存储方式:sessionStorage(页面关闭后丢失)
  paths: ['username', 'token', 'isLogin'] // 只持久化这3个字段,其他字段不持久化
}

4. 解构 Store 数据(避免响应式丢失)

如果直接解构 Store 实例中的 state 数据,会导致数据丢失响应式(修改数据后,页面不更新),解决方案:使用 Pinia 内置的storeToRefs 方法。

// 错误写法:直接解构,丢失响应式
const { username, isLogin } = useUserStore()
username = '新用户名' // 页面不更新

// 正确写法:使用 storeToRefs 解构,保留响应式
import { storeToRefs } from 'pinia' // 引入 storeToRefs

const userStore = useUserStore()
const { username, isLogin } = storeToRefs(userStore)

// 此时修改数据(需通过 Store 实例,解构后的变量是只读的)
userStore.username = '新用户名' // 页面正常更新

说明:storeToRefs 只会解构 state 中的数据,并不会解构 getters 和 actions,getters 和 actions 仍需通过 Store 实例访问。

六、实战场景:Pinia 实现购物车功能

结合实际开发中的「购物车」场景,完整演示 Pinia 的使用流程(非TS版),代码可直接复制到项目中使用。

1. 创建购物车 Store(stores/cartStore.js)

// stores/cartStore.js
import { defineStore } from 'pinia'

export const useCartStore = defineStore('cart', {
  state: () => ({
    cartList: [] // 购物车列表,每个项包含 id、name、price、count、checked
  }),
  getters: {
    // 计算购物车总数量
    cartTotalCount: (state) => {
      return state.cartList.reduce((total, item) => total + item.count, 0)
    },
    // 计算购物车总价(只算选中的商品)
    cartTotalPrice: (state) => {
      return state.cartList
        .filter(item => item.checked)
        .reduce((total, item) => total + item.price * item.count, 0)
    },
    // 判断是否全选
    isAllChecked: (state) => {
      return state.cartList.length > 0 && state.cartList.every(item => item.checked)
    }
  },
  actions: {
    // 1. 添加商品到购物车(重复商品数量+1)
    addToCart(goods) {
      const existGoods = state.cartList.find(item => item.id === goods.id)
      if (existGoods) {
        existGoods.count += goods.count
      } else {
        state.cartList.push({ ...goods, checked: true }) // 默认选中
      }
    },

    // 2. 减少商品数量(数量为1时删除商品)
    reduceCartCount(id) {
      const existGoods = state.cartList.find(item => item.id === id)
      if (existGoods) {
        if (existGoods.count === 1) {
          this.removeFromCart(id) // 调用当前 Store 的其他 action
        } else {
          existGoods.count--
        }
      }
    },

    // 3. 从购物车删除商品
    removeFromCart(id) {
      state.cartList = state.cartList.filter(item => item.id !== id)
    },

    // 4. 切换商品选中状态
    toggleChecked(id) {
      const existGoods = state.cartList.find(item => item.id === id)
      if (existGoods) {
        existGoods.checked = !existGoods.checked
      }
    },

    // 5. 全选/取消全选
    toggleAllChecked(checked) {
      state.cartList.forEach(item => {
        item.checked = checked
      })
    },

    // 6. 清空购物车
    clearCart() {
      state.cartList = []
    }
  },
  // 开启持久化,避免页面刷新后购物车数据丢失
  persist: {
    key: 'cartStore',
    paths: ['cartList']
  }
})

2. 组件中使用购物车 Store

<template>
  <div class="cart-page">
    <h3>购物车页面</h3>
    <div class="cart-header">
      <input 
        type="checkbox" 
        v-model="isAllChecked"
        @change="handleToggleAllChecked"
      />
      <span>全选</span>
      <span class="total">总数量:{{ cartStore.cartTotalCount }}</span>
      <span class="total-price">总价:¥{{ cartStore.cartTotalPrice.toFixed(2) }}</span>
      <button @click="cartStore.clearCart" class="clear-btn">清空购物车</button>
    </div>

    <div class="cart-list">
      <div class="cart-item" v-for="item in cartStore.cartList" :key="item.id">
        <input 
          type="checkbox" 
          v-model="item.checked"
          @change="cartStore.toggleChecked(item.id)"
        />
        <span class="goods-name">{{ item.name }}</span>
        <span class="goods-price">¥{{ item.price.toFixed(2) }}</span>
        <div class="count-btn">
          <button @click="cartStore.reduceCartCount(item.id)">-</button>
          <span>{{ item.count }}</span>
          <button @click="cartStore.addToCart({ ...item, count: 1 })">+</button>
        </div>
        <button @click="cartStore.removeFromCart(item.id)" class="delete-btn">删除</button>
      </div>
    </div>

    <div class="add-goods">
      <button @click="handleAddGoods">添加商品到购物车</button>
    </div>
  </div>
</template>

<script setup>
import { useCartStore } from '@/stores/cartStore'
import { storeToRefs } from 'pinia'

const cartStore = useCartStore()
// 解构 getters,保留响应式
const { isAllChecked } = storeToRefs(cartStore)

// 全选/取消全选
const handleToggleAllChecked = () => {
  cartStore.toggleAllChecked(isAllChecked.value)
}

// 模拟添加商品
const handleAddGoods = () => {
  const goods = {
    id: Math.floor(Math.random() * 1000),
    name: `商品${Math.floor(Math.random() * 100)}`,
    price: Math.floor(Math.random() * 100) + 10,
    count: 1
  }
  cartStore.addToCart(goods)
}
</script>

七、常见坑点避坑指南(新手必看)

很多新手在使用 Pinia 时,会遇到「响应式丢失」「状态刷新丢失」「Store 调用错误」等问题,以下是最常见的 5 个坑点,帮你快速避坑。

坑点1:直接解构 Store 实例,丢失响应式

错误写法:const { username } = useUserStore()

正确写法:使用 storeToRefs 解构,const { username } = storeToRefs(useUserStore())

坑点2:忘记开启状态持久化,页面刷新后状态丢失

解决方案:安装 pinia-plugin-persistedstate 插件,在 Store 中添加 persist: true

坑点3:创建 Store 实例时,未调用 useXXXStore()

错误写法:const userStore = useUserStore(未加括号,赋值的是函数本身,不是实例);

正确写法:const userStore = useUserStore()(必须加括号,调用函数创建实例)。

坑点4:actions 中使用 async/await 时,未加 await 调用

错误写法:userStore.logout()(异步方法未加 await,可能导致后续操作执行顺序错误);

正确写法:await userStore.logout()(在 async 函数中调用,等待异步操作完成)。

坑点5:多个 Store 的 id 重复,导致状态混乱

解决方案:Store 的 id 必须全局唯一,推荐和文件名对应(比如 userStore 的 id 为 'user',cartStore 的 id 为 'cart')。

八、总结:Pinia 核心要点回顾

Pinia 作为 Vue3 官方推荐的状态管理库,核心优势是「简单、高效、友好」,掌握以下核心要点,就能应对所有项目场景:

  1. 核心结构:每个 Store 包含 state(存储状态)、getters(计算状态)、actions(修改状态),无需 modules 和 mutations;
  2. 基础流程:安装 Pinia → 初始化挂载 → 创建 Store → 组件中引入并使用;
  3. 状态修改:优先调用 actions 方法,简单场景可直接修改,批量修改用 $patch;
  4. 进阶技巧:getters 有缓存特性,actions 支持异步和跨 Store 调用,状态持久化用 pinia-plugin-persistedstate;
  5. 避坑关键:用 storeToRefs 解构保留响应式,开启持久化避免刷新丢失,Store id 全局唯一。

相比于 Vuex,Pinia 的上手成本极低,即使是新手,也能在1-2小时内掌握核心用法,并且完美适配 Vue3 组合式 API 和 TypeScript,是当前 Vue3 项目状态管理的最优解。

从 Vite 到 Vize:Vue 开发体验的下一次飞跃

从 2019 年开始,前端的底层工具链就陆续使用 Rust 重写,这一次轮到 Vue 了。

Vize 横空出世!

一个基于 Rust 编写的、为 Vue.js 量身定制的一体化开发工具;通过原生级的速度,将 Vue 原本碎片化的编译、校验、格式化、类型检查、开发预览合并为一个极速、零配置的工具。

Vize 什么来头?

2026 年 2 月 22 号发布,首发就获得了尤雨溪的关注。

作者 ubugeeeiVue.jsVue.js JP 的成员。

Vize 能做什么:

  1. 极速编译 .vue 文件

比传统 JS 编译器快很多,大型项目秒开。

  1. 统一全流程 AST

编译、Lint、格式化、类型检查共用一套解析结果,不重复干活。

  1. 代码检查 + 格式化

替代 ESLint、Prettier,速度更快、规则更统一。

  1. 组件库可视化管理

预览、调试、文档、测试一体化,替代 Storybook。

  1. 对接 AI 助手

通过 MCP 让 AI 直接读取组件信息,写代码更准。

  1. 深度集成 Vite

作为高性能编译器插件,直接提升整个项目速度。

Vize、Vite、Vite+

兄弟而非对手,Vize 不是要取代 Vite。

两者的区别在于:

  • Vite:前端通用构建工具
  • Vize:Vue 编译器工具链

它们分工明确:

  • Vite:管整个项目怎么跑、怎么构建
  • Vize:管 .vue 文件怎么编译得更快

事实上,Vize 的许多功能都是建立在 Oxc 等基础技术之上的。

在 Vite 配置中,通过 @vizejs/vite-plugin-vize 替换 @vitejs/plugin-vue,让 Vite 的编译速度大幅提升。

Vize 和 Oxc

从技术底层看,两者功能确实有重叠,但 Vize 的核心价值在于针对 Vue SFC(单文件组件)的深度集成语义理解

oxlint 和 oxfmt 是通用的工具:

  • 处理纯 .js 文件
  • 处理纯 .ts 文件
  • 支持 React、Vue、Svelte 等

而 Vize:

  • 处理 .vue 文件中的 <script>
  • 处理 .vue 文件中的 <template>
  • 处理 .vue 文件中的 <style>
  • 专为模版指令(v-if,v-for)研发的编译器和检查器

Vize 内部使用了 Oxc 的能力来处理 JS 和 TS。

oxlint / oxfmt 目标是:取代 ESLint 和 Prettier

Vize 目标是:取代 vite-plugin-vue + vue-tsc + eslint-plugin-vue

你在项目中安装了 Vize,可以不用再单独安装 oxlint 和 oxfmt。

Vize 实际上是把 Oxc 的散装零件(oxlint, oxfmt, resolver)组装成了一台专门跑 Vue 的“超级跑车”,只为 Vue 开发者服务,提供更深度的优化

从而实现了工具链的‘归一化’,让前端开发者得以从插件地狱中解脱出来。

快速使用

安装

npm install -g vize

配置

import { defineConfig } from 'vite'
import vize from '@vizejs/vite-plugin';

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

支持 Nuxt

Vize 同样支持 Nuxt, Nuxt 在 Vue 生态中的重要性不言而喻,大多数首发的项目都会支持 Nuxt。

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@vizejs/nuxt'],
  vize: {
    compiler: true,
    musea: {
      include: ['**/*.art.vue'],
    },
  },
});

核心功能

# vue 组件编译
vize build

# 代码校验
vize lint
  
# 代码格式化
vize fmt

# 类型检查
vize check
  
# 组件预览
vize musea

# 编辑器集成
vize lsp

架构设计

Vize 采用的是典型的 Rust crate 层架构设计:

这是 Vize 最基础的流水线,负责将原始.vue文件转化为可执行的 JS 代码:

源码输入 → 分词解析 → AST中间表示 → 语义分析 → 多目标编译 → 工具层 → 上层应用

这种一体化的设计让 Vize 可以统一语义引擎驱动所有工具

一个 Vize 就能代替 compiler-sfceslintprettiervue-tscStorybook

性能怪兽

官方一次性测试,编译 15000SFC 文件(36.9MB):

Vue 文件编译

  • vue 官方编译:10.52s
  • vize 单线程耗时:3.82s
  • vize 多线程耗时:0.38s

⭐️ 单线程提速 2.8 倍

⭐️ 多线程提速 9.8 倍

⭐️ vize 多线程对原生单线程,高达 27.7 倍的极致提速

代码检查

  • eslint: 65.3s
  • vize: 5.45s

⭐️ 提速 12 倍

代码格式化

  • prettier: 82.69s
  • vize 单线程:0.036s
  • vize 多线程:0.023s

⭐️ 单线程提速 2303 倍

⭐️ 多线程提速 872 倍

类型校验

  • vue-tsc: 35.69s
  • vize: 0.369s

⭐️ 提速 97.7 倍

Vite 构建

  • vite 官方: 16.98s
  • vize: 6.9s

⭐️ 提速 2.5 倍

如此快的速度得益于:

  • 没有垃圾回收机制(GC)
  • 没有即时编译(JIT)预热
  • Rayon 原生多线程
  • 高缓存命中率
  • 文件级增量编译

这令人震撼的速度不亚于当初 Vite 对 Webpack 的碾压.

Vize 的编译器直接将代码编译为原生代码,生成单文件静态链接可执行程序,相比于 JS 工具链,这是 Rust 带来的质的飞跃

组件库管理

Musea 是 Viza 置的组件库管理工具,是项目内组件的视化管理、预览、调试与文档工具」

不管是你自己开发的项目内业务组件,还是专门的公共组件库,都可以用 Musea 来做后续的管理:

  • 组件多变体预览、交互式 Props 调试
  • 生成组件使用文档、无障碍 / 视觉回归测试
  • 组件分类、搜索、管理,方便团队查阅和使用
  • AI 助手,辅助生成使用代码。

组件库一览

组件详情

组件属性

设计

也就是说,你在 components 目录中开发的组件,可以通过 Musea 进行可视化管理,一站式调试和编写文档,进行团队共享。

AI 能力

npm install @vizejs/musea-mcp-server

Vize 可以集成 MCP,让 AI 对组件的理解与开发者一致:

  • 组件发现:快速查找项目内指定类型组件及对应变体并获取使用建议;

  • 代码生成:按项目真实组件的 props、类型生成准确代码,无属性幻觉;

  • API 参考:查询组件真实的 API 定义,而非通用猜测;

  • 文档辅助:基于组件实际元数据生成精准的组件文档。

  • 设计Token:查看、解析颜色、排版等各类设计令牌的列表、分类及原始值。

总结

在 Vue 的生态中,Vite 完成了构建的使命,而 Vize 完成了工具链的升级。

尤雨溪在 Vize 发布时就赞助了其作者,足见他对 Vize 的喜爱。

如果你在用 Vue 开发项目,那 Vize 值得你去关注和了解。

👍作品推荐

Haotab 新标签页,一个优雅的新标签页

chrome 商店 | edge 商店 | 在线版

❤️静待你的体验

Vue3 + Pinia 状态管理,从入门到模块化

前言

Pinia 是 Vue3 标配状态管理。简单、轻量、易用。

一、创建 Store

// store/modules/user.js
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({
    token: '',
    userInfo: null
  }),

  actions: {
    setToken(token) {
      this.token = token
    },
    setUserInfo(info) {
      this.userInfo = info
    }
  }
})

二、在组件中使用

<script setup>
import { useUserStore } from '@/store/modules/user'
const userStore = useUserStore()

// 获取数据
console.log(userStore.token)

// 修改数据
userStore.setToken('xxxx')
</script>

三、Getters 计算

getters: {
  isLogin: (state) => !!state.token
}

四、数据持久化(常用)

安装插件:

npm install pinia-plugin-persistedstate

在 store 中启用:

export const useUserStore = defineStore('user', {
  // ...
  persist: true
})

数据自动存在 localStorage。

五、模块化最佳实践

  • store/modules/xxx.js 按业务拆分
  • 命名:useXxxStore
  • 页面直接引入使用

Vue3 组合式 API(setup + script setup)实战

前言

Vue3 的 <script setup> 是官方推荐写法,代码更简洁、逻辑更聚合。本文带你真正用好组合式 API。

一、script setup 基本写法

<script setup>
// 直接写逻辑,无需 export default
import { ref, reactive, computed } from 'vue'

const msg = ref('Hello Vue3')
</script>

<template>
  <div>{{ msg }}</div>
</template>

二、响应式数据

  • ref:基础类型(string/number/boolean)

  • reactive:对象 / 数组

    const num = ref(0) const user = reactive({ name: '张三', age: 20 })

三、计算属性 computed

import { computed } from 'vue'

const doubleNum = computed(() => num.value * 2)

四、方法与事件

<button @click="add">+1</button>

<script setup>
const add = () => {
  num.value++
}
</script>

五、生命周期

import { onMounted, onUpdated, onUnmounted } from 'vue'

onMounted(() => {
  console.log('组件挂载')
})

六、父传子 props

// 子组件
<script setup>
import { defineProps } from 'vue'
const props = defineProps({
  title: String
})
</script>

七、子传父 emit

// 子组件
const emit = defineEmits(['change'])
const handleChange = () => {
  emit('change', '新数据')
}

八、获取 DOM:ref

<div ref="box"></div>

<script setup>
import { ref } from 'vue'
const box = ref(null)

onMounted(() => {
  console.log(box.value)
})
</script>

总结

<script setup> 优点:

  • 代码更少
  • 无需 return
  • 更好的 TS 支持
  • 逻辑更清晰

Vue3 + Vite 从零搭建项目,超详细入门指南

前言

Vue3 搭配 Vite 构建工具,开发速度飞起。这篇文章带你从 0 到 1 搭建一个标准 Vue3 工程化项目。

一、环境准备

确保你已安装:

  • Node.js 16+
  • npm / yarn / pnpm

二、使用 Vite 创建项目

npm create vite@latest

步骤:

  1. 输入项目名
  2. 选择 Vue
  3. 选择 JavaScriptTypeScript

进入项目:

cd 项目名
npm install
npm run dev

打开浏览器即可看到 Vue3 欢迎页面。

三、项目结构说明

  • main.js:入口文件
  • App.vue:根组件
  • components/:公共组件
  • views/:页面组件
  • router/:路由
  • store/:状态管理
  • assets/:静态资源

四、配置 Vue Router

安装:

npm install vue-router@4

新建 router/index.js

import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  }
]

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

export default router

main.js 挂载:

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

createApp(App).use(router).mount('#app')

五、配置 Pinia 状态管理

安装:

npm install pinia

新建 store/index.js

import { createPinia } from 'pinia'
const pinia = createPinia()
export default pinia

main.js 挂载:

import store from './store'
createApp(App).use(router).use(store).mount('#app')

总结

本文完成了:

  • Vite + Vue3 项目创建
  • Vue Router 4 路由配置
  • Pinia 状态管理配置

下一篇我们讲 Vue3 组合式 API 最佳实践。

别再写换皮 Options 了!Vue3 Setup 真正的用法的是这3步升级

很多人迁移 Vue3 后,写的 Setup 只是 Options API 的换皮——看似整洁,实则依然无序膨胀。其实 Composition API 的核心不是 Setup 语法,而是按功能组织代码!今天从一个详情页出发,带你3步升级,真正吃透它。

从简单的详情页开始

假设我们有一个页面:

  • 展示 业务详情
  • 展示 用户信息
  • 有一个 确认操作(confirm)

在 Vue2 里我们会这样写:

  • mounted 里拉数据
  • methods 里写 confirm
  • data 里存 loading

迁移到 Vue.js 3 后,很多人会写成这样:

setup() {
  const route = useRoute()

  const detail = ref(null)
  const queryDetailLoading = ref(false)
  const user = ref(null)
  const confirmLoading = ref(false)

  async function queryDetail(id: string) {
    queryDetailLoading.value = true
    detail.value = await api.getDetail(id)
    queryDetailLoading.value = false
  }

  async function queryUser(id: string) {
    user.value = await api.getUser(id)
  }

  async function confirm(id: string) {
    confirmLoading.value = true
    await api.confirm(id)
    confirmLoading.value = false
  }

  onMounted(() => {
    queryDetail(route.params.id)
    queryUser(route.params.id)
  })

  return {
    detail,
    queryDetailLoading,
    user,
    confirmLoading,
    confirm
  }
}

看起来没问题,甚至代码还很整洁:

  • 定义外部变量
  • 定义内部变量
  • 定义内部方法
  • 在生命周期中触发初始化操作

但是,代码的组织度好像没有变化?上面的代码好像还是很容易无序膨胀,出现几千行的 .vue 文件?应该怎么抽象?

Options 到 Composition 的优势在哪?

Vue 官网有张对比图:

image.png

很多人以为它表达的是:

代码更清晰了。

但它真正表达的是:

代码组织维度发生了变化。

Options API 是:

  • data
  • computed
  • methods
  • watch

按类型组织。

而 Composition API,可以按照:

  • 详情模块
  • 用户模块
  • 操作模块

功能模块组织代码。

升级1:按功能模块组织代码

先把同一职责的代码写在一起。

setup() {
  const route = useRoute()

  // ===== 详情模块 =====
  const detail = ref(null)
  const queryDetailLoading = ref(false)

  async function queryDetail(id: string) {
    queryDetailLoading.value = true
    detail.value = await api.getDetail(id)
    queryDetailLoading.value = false
  }

  onMounted(() => {
    queryDetail(route.params.id)
  })

  // ===== 用户模块 =====
  const user = ref(null)

  async function queryUser(id: string) {
    user.value = await api.getUser(id)
  }

  onMounted(() => {
    queryUser(route.params.id)
  })

  // ===== confirm 模块 =====
  const confirmLoading = ref(false)

  async function confirm(id: string) {
    confirmLoading.value = true
    await api.confirm(id)
    confirmLoading.value = false
  }

  return {
    detail,
    queryDetailLoading,
    user,
    confirmLoading,
    confirm
  }
}

这里有个 Vue 2 很容易忽略的思维定式:

在 Vue 3 中,可以有多个生命周期钩子,可以写多个 onMounted

这一刻,setup 不再是一个“大仓库”,而是一个“功能组合器”。

生命周期不再是“一个入口”,它可以属于不同功能块。

这样的代码组织,才是官网对比图中的样子。

升级2:拆分 Setup,useXxx 的诞生

在 setup 中拆分功能块后,可以很自然地将各个功能块拆分出 setup。

比如对于详情模块,输入是 route.params.id,输出是 detailqueryDetailLoading

  // ===== 详情模块 =====
  const detail = ref(null)
  const queryDetailLoading = ref(false)

  async function queryDetail(id: string) {
    queryDetailLoading.value = true
    detail.value = await api.getDetail(id)
    queryDetailLoading.value = false
  }

  onMounted(() => {
    queryDetail(route.params.id)
  })

重构为

function useDetail(id: string) {  
  const detail = ref(null)
  const queryDetailLoading = ref(false)
  
  async function queryDetail(id: string) {
    queryDetailLoading.value = true
    detail.value = await api.getDetail(id)
    queryDetailLoading.value = false
  }

  onMounted(() => queryDetail(id))
  
  return {  
    detail,  
    queryDetailLoading  
  }
}

注意不要将 const route = useRoute() 抽到 useDetail 中,保持 useDetail 依据 id 获取数据的单一职责。 Composition 的抽象边界应该围绕“数据输入输出”,而不是围绕“框架能力”。

于是 setup 成为

setup() {
  const route = useRoute()

  // ===== 详情模块 =====
  const { detail, queryDetailLoading } = useDetail(route.params.id)
  
  // ===== 用户模块 =====
  const { user } = useUser(route.params.id)

  // ===== confirm 模块 =====
  const { confirm, confirmLoading } = useConfirm()

  return {
    detail,
    queryDetailLoading,
    user,
    confirmLoading,
    confirm
  }
}

通过这种方式,各个 composition 有自己的职责,setup 负责视图层数据的聚合,你再不会写出流水账式的代码。

升级3:从生命周期驱动到数据驱动

到这里,其实我们已经完成了“功能拆分”。

但还有一个更重要的转变:setup 不应该围绕生命周期组织,而应该围绕数据变化组织。

比如经常遇到的问题:如果路由参数变化要怎么做呢?

实际这里的逻辑是,当 id 变化时,重新获取 detail

function useDetail(id: MaybeRefOrGetter<string>) {  
  // ...

  watch(() => toValue(id), () => queryDetail(toValue(id)), { immediate: true })
  
  return {  
    detail,  
    queryDetailLoading  
  }
}
setup() {
  const route = useRoute()

  // ===== 详情模块 =====
  const { detail, queryDetailLoading } = useDetail(() => route.params.id)
  
  // ...

  return {
    detail,
    queryDetailLoading,
    // ...
  }
}

如果使用了 将 props 传递给路由组件,还可以将 route 统一到组件标准的 props 操作:

setup(props) {
  // ===== 详情模块 =====
  const { detail, queryDetailLoading } = useDetail(() => props.id)
  
  // ...

  return {
    detail,
    queryDetailLoading,
    // ...
  }
}

回顾:Composition 的升级到底在哪?

升级路径其实很自然:

  1. 在 setup 内按功能组织
  2. 将功能抽离 setup 方便共享
  3. 通过 watch 将生命周期驱动转向数据驱动

Vue3 没让代码变乱。它提供了按功能组织代码的能力。

它的真正价值,不在于 setup,而在于它允许我们按“业务模型”组织代码,而不是按“框架结构”组织代码。

这可以让我们写出更内聚,更单一职责的代码。

在 Vue 2 中想达成这种能力需要通过 mixin 的方式,但 mixin 是通过在 this 上动态添加属性的方式进行的,这导致 mixin 在类型推导上极其困难,极度依赖对实现细节的了解。

升级加餐:不拆 setup,也能简单

如果你已经接受“按功能组织 + 数据驱动副作用”这个思路,那么其实可以再进一步,把这些模式固化下来。

在很多场景下,setup 中的内容没有复用的必要,单独抽到其它文件中有点大材小用。

有没有不拆分,还能保证各功能块高度内聚的写法?我写了 vue-asyncx 用于解决这个问题。

setup(props) {
  // ===== 详情模块 =====
  const detail = ref(null)
  const queryDetailLoading = ref(false)

  async function queryDetail(id: string) {
    queryDetailLoading.value = true
    detail.value = await api.getDetail(id)
    queryDetailLoading.value = false
  }

  watch(() => props.id, () => queryDetail(props.id), { immediate: true })

  // ===== 用户模块 =====
  const user = ref(null)

  async function queryUser(id: string) {
    user.value = await api.getUser(id)
  }

  watch(() => props.id, () => queryUser(props.id), { immediate: true })

  // ===== confirm 模块 =====
  const confirmLoading = ref(false)

  async function confirm(id: string) {
    confirmLoading.value = true
    await api.confirm(id)
    confirmLoading.value = false
  }

  return {
    detail,
    queryDetailLoading,
    user,
    confirmLoading,
    confirm
  }
}

可以重构为

import { useAsyncData, useAsync } from 'vue-asyncx'

setup(props) {
  // ===== 详情模块 =====
  const { 
    detail, 
    queryDetailLoading 
  } = useAsyncData('detail', () => api.getDetail(props.id), {
    watch: () => props.id, immediate: true
  })

  // ===== 用户模块 =====
  const { user } = useAsyncData('user', () => api.getUser(props.id), {
    watch: () => props.id, immediate: true
  })

  // ===== confirm 模块 =====
  const { confirm, confirmLoading } = useAsync('confirm', (id: string) => api.confirm(id))

  return {
    detail,
    queryDetailLoading,
    user,
    confirmLoading,
    confirm
  }
}

核心代码从19行减少到10行,代码量减少接近 50%,而语义化、组织性不丢失。

更多详细用法,见:早点下班:在 Vue3 中少写 40%+ 的异步代码

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

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

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

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

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

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

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

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

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

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

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

基于文件路由的优势

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

路由参数和嵌套路由

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

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

详细的路由规则

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

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

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

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

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

这将生成如下路由配置:

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

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

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

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

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

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

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

生成的URL:

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

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

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

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

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

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

对开发工作流的影响

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

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

迁移策略

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

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

配置选项和高级功能

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

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

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

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

配置示例

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

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

实际应用建议

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

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

总结

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

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

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

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

一、问题现场

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

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

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

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

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

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

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

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

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

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

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

二、排查过程

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

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

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

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

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

第二步:检查生成的 CSS

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

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

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

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

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

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

第三步:发现规律

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

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

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

三、Tailwind CSS v4 架构总览

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

3.1 一切从 @import "tailwindcss" 开始

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

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

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

@layer theme, base, components, utilities;

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

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

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

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

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

3.2 @layer utilities 的按需生成

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

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

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

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

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

四、核心机制:内容扫描

4.1 v4 如何发现 class

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

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

  2. 自动排除以下目录:

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

4.2 关键:node_modules 被排除

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

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

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

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

4.3 与 v3 的对比

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

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

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

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

五、CSS-first 配置

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

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

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

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

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

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

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

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

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

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

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

@source "../dist";

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

5.3 其他 CSS 指令

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

六、Vite 插件集成

6.1 @tailwindcss/vite

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

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

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

这个插件做了三件事:

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

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

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

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

@import 'tailwindcss';

修正为:

@import url('tailwindcss');

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

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

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

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

七、解决方案:@source 指令

7.1 最终修复

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

@source "../dist";

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

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

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

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

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

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

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

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

7.3 消费者体验:零配置

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

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

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

7.4 参考:Nuxt UI 4 的做法

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

@source "./components";

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

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

八、完整排查流程回顾

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

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

验证方法:在 DevTools Console 中执行

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

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

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

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