普通视图

发现新文章,点击刷新页面。
昨天 — 2026年3月19日首页

Vue-Vue2与Vue3核心差异与进化

2026年3月19日 21:05

前言

从 Vue 2 到 Vue 3,不仅仅是版本的跳跃,更是底层思想的革新。从 Object.definePropertyProxy,从 Options API 到 Composition API,Vue 3 在性能和开发体验上都实现了质的飞跃。本文将带你系统梳理两者的核心区别。

一、 响应式原理:从“属性拦截”到“对象代理”

响应式系统的升级是 Vue 3 性能提升的关键。

1. Vue 2:Object.defineProperty

  • 原理:初始化时通过递归遍历 data,为每个属性设置 gettersetter

  • 局限性

    • 无法检测到对象属性的新增删除
    • 无法直接监听数组索引的变化和 length 属性。
    • 必须使用 this.$set 等特有 API 来弥补。
    • 递归过程在处理大数据量时存在性能瓶颈。

2. Vue 3:ES6 Proxy

  • 原理:直接监听整个代理对象,拦截所有操作(如 get, set, deleteProperty, has 等)。

  • 优势

    • 原生支持:自动支持动态增删属性、数组下标修改。
    • 懒代理(Lazy Tracking) :只有当访问到深层属性时,才会动态将其转为响应式,大大提升了初始化速度。
    • 性能更好:省去了初始化时繁琐的递归遍历。

二、 编写模式:从“碎片化”到“模块化”

代码组织方式的改变直接影响了大型项目的维护成本。

1. Vue 2:选项式 API (Options API)

  • 痛点:逻辑被强行拆分在 datamethodscomputed 等固定选项中。当一个组件功能复杂时,同一个功能的代码会散落在各处,导致开发者反复上下滚动查找,难以维护。

2. Vue 3:组合式 API (Composition API)

  • 优势:通过 <script setup>,开发者可以按照功能逻辑将代码组织在一起。

  • 逻辑复用:可以轻松地将逻辑抽离成独立的 useHooks 函数,解决了 Vue 2 中 mixin 命名冲突和来源不明的问题。


三、 Vue 3 核心新特性与语法糖

1. 响应式新成员:ref vs reactive

  • ref:万能型。支持基本类型和引用类型,通过 .value 访问(模板中自动解包)。
  • reactive:对象型。仅支持引用类型,直接操作属性,无需 .value

2. defineModel:双向绑定的“减法”

在 Vue 3.4+ 中引入的 defineModel 极大地简化了父子组件通信:

  • Vue 2 做法:需要 props 接收值 + this.$emit('update:xxx') 触发更新。
  • Vue 3 新语法:子组件直接使用 const model = defineModel(),修改 model 的值会自动同步到父组件,代码量骤减。

3. 多根节点模板

  • Vue 2:模板内必须有一个唯一的根节点(通常是 <div>),否则报错。
  • Vue 3:原生支持多个根节点,减少了不必要的 DOM 层级,使 HTML 结构更简洁。

4. 异步处理神器:<Suspense>

  • 新增内置组件,专门用于处理异步组件的加载状态。它提供了 defaultfallback 两个插槽,可以优雅地展示“加载中”和“加载完成”的 UI 切换。

四、 总结:为什么要升 Vue 3?

类别 Vue2 Vue3
响应式原理 Object.defineProperty 逐个属性劫持 Proxy 代理整个对象,懒加载
编写模式 选项式API(Options API) 组合式API(Composition API +
模板规范 仅支持单个根节点 支持多个根节点
数据监听 无法监听对象增删、数组索引 原生支持对象增删、数组下标修改
组件双向绑定 props + emit 手动实现 defineModel 语法糖简化
异步加载 手动处理加载状态 内置 Suspense 组件

Vue2:数组/对象操作避坑大全

2026年3月19日 20:49

前言

在 Vue 2 开发中,你是否遇到过“明明数据变了,视图却没动”的诡异情况?这通常不是代码逻辑问题,而是由于 Vue 2 基于 Object.defineProperty 的响应式原理存在天然的局限性。本文将带你攻克这些响应式盲区。

一、 响应式的“硬伤”:为什么会失效?

Vue 2 在初始化阶段,会遍历 data 中的属性并使用 Object.defineProperty 将其转为 getter/setter

它的核心问题在于:

  1. 无法检测对象属性的添加或删除(因为它只在初始化时进行监听)。
  2. 无法检测数组索引的直接修改和长度变化

二、 对象操作:打破“属性新增”的僵局

1. 新增/删除属性

如果你直接通过 this.obj.newKey = value 赋值,Vue 是无法感知的。

  • 新增属性:使用 this.$set (或全局 Vue.set)。

    • 语法:this.$set(target, key, value)
    • 示例:this.$set(this.user, 'age', 18)
  • 删除属性:使用 this.$delete (或全局 Vue.delete)。

2. 批量修改属性

如果你需要一次性增加多个属性,不要写一堆 $setVue2 可以监听对象引用变化,最高效的方法是替换整个对象引用

// 这种方式 Vue 能够通过监听对象的引用变化来触发更新
this.user = Object.assign({}, this.user, {
  age: 18,
  gender: 'male'
});

// 批量更新user对象属性
this.user = {
  ...this.user,
  age: 20,
  gender: '男',
  address: '北京'
}

三、 数组操作:被“重写”的 7 个方法

在 Vue 2 中,直接执行 this.items[0] = 'new' 是不会触发更新的。解决方案同样是使用 this.$set,以及使用vue重写的相关数组方法。

1. 自动触发更新的方法

只要调用以下方法,Vue 就会自动检测到变化并更新视图:

  • push() / pop():队尾操作
  • unshift() / shift():队头操作
  • splice()最万能,可实现增、删、改。
  • sort():排序。
  • reverse():翻转。

2. 数组的特殊场景

  • 根据索引修改值

    • ❌ 错误:this.items[index] = newValue
    • ✅ 正确:this.$set(this.items, index, newValue)this.items.splice(index, 1, newValue)
  • 修改数组长度

    • ❌ 错误:this.items.length = 0 (清空数组失效)
    • ✅ 正确:this.items.splice(0)this.items = []

四、 进阶补充:Vue 3 是如何解决的?

  • Vue 3 使用了 ES6 Proxy:Proxy 代理的是整个对象而不是属性。

  • 优势:Proxy 可以原生监听到属性的动态添加、删除,以及数组索引的变化,因此在 Vue 3 中,你不再需要使用 $set 了!


五、 总结

  1. vue2对象新增属性:首选 this.$set,批量新增选 Object.assign

  2. vue2数组修改:养成使用 splicepush 等 7 个变异方法的习惯。

  3. 调试技巧:如果视图没更新,先用 console.log 确认数据是否变了,再检查是否触碰了上述响应式盲区。

Vue3:ref 与 reactive 超全对比

2026年3月19日 20:34

前言

在 Vue 3 的 Composition API 中,refreactive 是定义响应式数据的两大基石。很多初学者常纠结于“什么时候该用哪个”。本文将从底层原理到实战场景,带你彻底理清两者的区别。

一、 核心概念对比

1. ref:全能型选手

  • 定义:主要用于定义基本类型(String, Number, Boolean 等),也可以定义引用类型。

  • 本质:通过对原始值进行包装,生成一个具有 .value 属性的对象。对于引用类型,ref 内部会自动调用 reactive 来处理。

  • 访问控制

    • 在 JS 中必须通过 .value 访问;
    • <template> 模板中,Vue 会自动解包,直接写变量名即可,无需加 .value。

2. reactive:对象专家

  • 定义:专门用于定义引用类型(Object, Array, Map, Set)。

  • 本质:基于 ES6 Proxy 实现,直接代理整个对象。

  • 访问控制:像操作普通原生对象一样直接访问属性,无需 .value

    注意: 传入基本类型会触发 Vue 警告且丢失响应式。


二、 深度差异对比

特性 ref reactive
支持类型 基本类型 + 引用类型 仅限 引用类型
JS 访问方式 .value 直接访问属性
模板访问 自动解包,无需 .value 直接访问
底层实现 包装基本类型,内部调用 reactive 处理引用类型 基于 Proxy 深度代理整个对象
替换整个对象 支持 (ref.value = 新对象/新数组) 不支持(直接赋值会丢失代理,失去响应式)
解构支持 直接解构丢失响应式(需 toRefs 直接解构丢失响应式(需 toRefs

三、 使用场景:我该怎么选?

推荐使用 ref 的场景:

  1. 基本类型数据:计数器、开关状态、输入框的值。

  2. 需要重置的数据:例如从后端获取列表后,直接 list.value = res.data

  3. 简单组件逻辑:代码更清晰,.value 提醒这是一个响应式变量。

推荐使用 reactive 的场景:

  1. 复杂业务模型:包含多个相互关联属性的大对象(如用户信息、表单整组数据)。

  2. 追求原生感:不希望在逻辑代码中到处看到 .value

  3. 聚合数据:将一类变量聚合在一个对象中管理,减少变量声明。


四、 高频易错点

1. reactive 直接赋值整个对象会丢失响应式

let state = reactive({ count: 0 });
// ❌ 错误操作:这会导致 state 失去响应式,因为它变成了一个普通的普通对象
state = { count: 1 }; 

// ✅ 正确方案 A (ref):
const state = ref({ count: 0 });
state.value = { count: 1 };

// ✅ 正确方案 B (Object.assign):
Object.assign(state, { count: 1 });

2. 解构 reactive 数据丢失响应式

当你需要从一个响应式对象中提取属性并保持响应式时,必须使用 toRefs,否则会丢失响应式

const props = reactive({ title: 'Vue3', author: 'Gemini' });
// 直接解构:const { title } = props; -> title 只是一个普通的字符串
const { title } = toRefs(props); // -> title 变成了一个 ref,保持响应式

3. Watch 监听的差异

  • 监听 ref:默认只监听 .value 的变化,如果 ref 包裹的是对象,深度监听需要开启 { deep: true }

  • 监听 reactive:默认强制开启深度监听,且无法关闭。


📝 总结

  • ref 是万金油,虽然多了个 .value,但胜在灵活且不易出错。
  • reactive 适合组织复杂的对象数据,但要注意赋值和解构的陷阱。

借助AI,1周,0后端成本,我开源了一款Office预览SDK

作者 徐小夕
2026年3月19日 17:12

今天给大家分享一款我借助AI, 开发的一款开源 Office 文件预览SDK。

图片

我把它起名叫 jit-Viewer,刚好和我们做的 JitWord 协同AI文档相呼应。它主要的功能就是可以在浏览器中一键预览 Docx,PDF,Excel,PPT,Markdown,Txt等各种格式的文件。

图片

同时只需要3行代码,就可以轻松集成到Vue,React,Angular,Html项目中。话不多说,先上开源地址。

github:github.com/jitOffice/j…

国内镜像:gitee.com/lowcode-chi…

演示地址:jitword.com/jit-viewer.…

核心功能亮点

图片

jit-viewer 不依赖后端转换,而是直接在浏览器端解析 Office Open XML 格式(.docx/.xlsx/.pptx 的底层结构),最后通过 jit-viewer 封装的渲染器渲染成可视化组件。PDF 预览则是基于 PDF.js 做深度优化。

所以我们完全不依赖后端,同时目前支持的文件预览方式有:

  • 本地上传文件
  • 通过url地址直接预览文件

这2种方式基本上是用户需求最多的方式。

总结几个核心亮点,方便大家参考评估:

  1. 零后端依赖,纯前端渲染:无需配置任何后端服务,静态站点也能用
  2. 框架无绑定:一个SDK同时支持Vue/React/Angular,团队技术栈切换无成本
  3. 隐私安全:文件解析在浏览器本地完成,不上传服务器
  4. 性能优异:虚拟滚动 + Web Worker,大文件不卡主线程
  5. 扩展性强:插件化架构,可自定义渲染器、添加水印、集成审批流

这里我在补充一条,目前预览访问不仅能在PC端预览,还能直接在移动端预览:

图片

充分满足大家移动办公的需求。

同时为了提供开发人员对预览的样式控制,我还支持了预览控件:

  • 下载文件
  • 缩放重置
  • 旋转功能
  • 全屏
  • 主题切换
  • 获取文档信息

后续会持续优化一些更可控的功能供大家使用。

如何本地使用

我在 jit-viewer 文档中写了详细的本地使用教程,接下来给大家分享一下。

第一步,引入SDK:

<!-- 引入样式文件 -->
<link rel="stylesheet" href="jit-viewer.min.css">

<!-- 引入 JitViewer SDK -->
<script src="jit-viewer.min.js"></script>

第二步,创建预览容器:

<div id="viewer" style="width: 100%; height: 600px;"></div>

第三步,初始化实例:

// 创建预览器实例
const viewer = JitViewer.createViewer({
  file: 'document.pdf',      // 文件 URL
  filename: 'document.pdf',  // 文件名(可选)
  toolbar: true,             // 显示工具栏
  theme: 'light',            // 主题
  width: '100%',
  height: '600px',
  onReady: () => console.log('准备就绪'),
  onLoad: () => console.log('加载完成'),
  onError: (err) => console.error('错误:', err)
});

// 挂载到 DOM
viewer.mount('#viewer');

是不是非常简单?只需要3步,就能快速集成到你的系统中实现 Office 文件预览功能。

我在文档中也写了详细的API介绍,大家想定制SDK样式和交互,也可以参考文档:

图片

文档地址:jitword.com/jit-viewer.…

小小总结一下

图片

之所以要做这个项目,完全来自于之前的客户的一个需求,为了给我们的客户赋能,我们便做了这个开源SDK。

后续会继续迭代优化,实现更多文件类型的预览功能,大家有好的建议也欢迎留言区交流反馈~

对于AI,其实并不是全程参与开发(尤其是复杂的业务需求),大家短期内还是不用太神话AI的能力。

对于SDK的工程化方案(脚手架),我是完全交给AI来实现的,同时从文档的编写,网站demo的设计,也都是交给AI做的,到这里,AI基本能代替40%左右的工作了。

我提供的是SDK的产品需求,设计思路,技术选型,和设计风格,同时兼顾测试工程师的角色,这块其实是目前AI人机协作的常态。

所以技术能力还是需要,用好AI可能更好的为我们的工作和产品研发提效。

新的竞争力在于:架构设计的品味、安全风险的嗅觉、人机协作的智慧,以及对自己代码的深刻理解

github:github.com/jitOffice/j…

国内镜像:gitee.com/lowcode-chi…

前端空值处理规范:Vue 实战避坑,可选链、?? 兜底写法|项目规范篇

作者 SuperEugene
2026年3月19日 14:45

帮助同学们学会在前端真实业务项目里,到底该怎么写空值处理(?.、??、||、if判断、兜底逻辑),以及为什么这么选、会踩哪些高频坑,顺便帮你拉直JS/TS空值、真值假值的基础概念,助力写出规范可维护的团队级代码。

在这里插入图片描述

同学们好,我是 Eugene(尤金),一名多年中后台前端开发工程师。

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

很多前端开发者都会遇到一个瓶颈:

代码能跑,但不够规范;功能能实现,但维护起来特别痛苦;一个人写没问题,一到团队协作就各种混乱、踩坑、返工。

想写出干净、优雅、可维护的专业代码,靠的不是天赋,而是体系化的规范 + 真实实战经验

这一系列《前端规范实战》,我会用大白话 + 真实业务场景,不讲玄学、不堆理论,只分享能直接落地的规范、标准与避坑指南。

帮你从「会写代码」真正升级为「会写优质、可维护、团队级别的代码」。

引子:为什么要专门聊“空值处理规范”?

一句话定位这篇文章:

教你在真实项目里,到底该怎么写空值处理( ?. ?? || if ** 判断、兜底逻辑),以及为什么这么选、会踩哪些坑**,顺便帮你把 JS/TS 的一些基础概念拉直。

适用人群:

  • 已经会写 JS / Vue,但概念有点混== null||?.?? 到底差在哪?

  • 刚入门前端的小伙伴:想从一开始就养成靠谱的代码习惯

  • 像我这样工作多年想回炉重造的工程师:系统校准一下“老习惯”是不是已经过时了 本文不会讲太多过度底层的规范条文,而是:

  • 围绕真实业务代码的写法

  • 配合 完整示例 + 场景解释

  • 重点放在:怎么选写法、为什么这么选、常见坑在哪里

一、先把“空值家族”讲清楚:null、undefined、空字符串、0、false…

日常开发中经常混在一起的几个值:


null           // 明确的“空值”,一般表示“这里有个位置,但现在没有值”
undefined      // 未定义,通常是“压根没传”、“没赋值”
''             // 空字符串
0              // 数字 0
false          // 布尔 false
NaN            // 不是一个合法数字

1.1 “真值/假值”概念(很关键)

在 JS 里,if (xxx) 判断的是“真值/假值(truthy / falsy)”,而不是严格意义上的 true/false。下面这些都是 falsy(假)

  • false
  • 0
  • -0
  • ''(空字符串)
  • null
  • undefined
  • NaN

其他的基本都被当成 truthy(真)

为什么要先讲这个?

因为 ||&& 这些逻辑运算符,走的就是“真值/假值”逻辑。

比如:


const value = 0;
const result = value || 100;
console.log(result); // 100,而不是 0

0 在 JS 里是假值,所以 value || 100 会拿到 100

这也是我们后面会反复提的一个大坑:“用 ** || ** 做默认值会把合法值 0/''/false 当成没传”

二、可选链 ?.:安全访问深层属性的标准写法

场景:从后端拿到一个复杂对象,但某一层可能是 null / undefined,直接访问就会炸:


// 假设 user 可能是 null
const city = user.profile.address.city; 
// TypeError: Cannot read properties of null (reading 'profile')

2.1 传统写法 VS 可选链

传统写法(防御式编程):


const city =
  user &&
  user.profile &&
  user.profile.address &&
  user.profile.address.city;

  • 可读性差
  • 很啰嗦
  • 稍微一改结构就容易漏一个判断

可选链写法:


const city = user?.profile?.address?.city;

  • 短很多
  • 语义清晰:如果中间任何一层是 null/undefined,就直接返回 undefined,而不是抛异常

2.2 在 Vue 模板里的使用

Vue 2 + Babel 环境Vue 3 默认 Vite 脚手架 一般都支持可选链。

在模板里:


<template>
  <div>
    <p>用户名:{{ user?.profile?.name || '未设置' }}</p>
    <p>城市:{{ user?.profile?.address?.city || '未知城市' }}</p>
  </div>
</template>

<script setup>
const user = ref(null);
// 后端请求完成后,再赋值
</script>

注意:模板表达式里也可以用 ?.||??,和 JS 里一样。

2.3 规范建议:何时必须用可选链?

我在项目里通常建议:

  • 从接口拿来的数据 + 多层嵌套对象默认用可选链
  • SDK / 第三方库返回的结构:尽量用可选链保护
  • 对于我们自己完全可控、结构固定的内部数据,可以不用(比如本地写死的配置)

统一规则示例:

  • 接口 Model 层(TypeScript 类型 + 接口封装):尽量把可选属性处理掉,往下传固定结构
  • 页面 / 组件层
    • 对于“接口原始数据”:用 ?. + 兜底字符串 / 兜底组件
    • 对于“内部状态”:减少可选,用默认值初始化

三、空值合并运算符 ??:给“真空”兜底,而不是给所有假值兜底

回顾刚才的例子:


const value = 0;
const result = value || 100;
console.log(result); // 100

如果 0 在业务里是合法值(比如“价格 0 元”、“数量 0 个”),那上面这行其实是错的。

我们想要的是:“只有在值为 null 或 undefined 的时候才给默认值”。

这就是 ?? 的作用。

3.1 || vs ?? 对比示例


console.log(0 || 100);       // 100
console.log(0 ?? 100);       // 0

console.log('' || '默认');   // '默认'
console.log('' ?? '默认');   // ''

console.log(null || '默认'); // '默认'
console.log(null ?? '默认'); // '默认'

console.log(undefined || '默认'); // '默认'
console.log(undefined ?? '默认'); // '默认'

总结一句话:

  • ||:只要左边是假值(包括 0 / '' / false / NaN / null / undefined),就用右边
  • ??:只有左边是 nullundefined 时,才用右边

3.2 在真实业务中的推荐用法

典型错误写法(很常见):


// 单价和数量来自接口
const price = item.price || 0;
const count = item.count || 1;
const total = price * count;

在这些场景会出错:

  • 价格为 0 元:price 会变成 0 || 0 → 0(这里还好)
  • 数量为 0:count 会变成 1(业务错了)
  • 用户输入了空字符串 '' 需要区分,但被直接当成没填

推荐写法:


const price = item.price ?? 0;  // 价格缺失才用 0
const count = item.count ?? 1;  // 只有未传 count 才默认 1

再比如配置项对象


function createDialog(options = {}) {
  const width = options.width ?? 400;         // 未传 width 才采用默认 400
  const closable = options.closable ?? true;  // 未传 closable 才用 true
}

3.3 在 Vue 模板中用 ??


<template>
  <div>
    <!-- 后端没给 nickName 时显示 '游客',但如果是空字符串就保持空 -->
    <p>昵称:{{ user.nickName ?? '游客' }}</p>
  </div>
</template>

规范建议:

  • 只要你的兜底逻辑只想针对 null/undefined,统一用 ??,不要用 ||
  • 保留 || 用于“逻辑或”场景,而不是“兜底默认值”。

四、兜底逻辑:不仅是运算符,还有“业务上的安全网”

可选链和空值合并属于“语法层面的防御”。

真实项目里,还需要“业务层面的兜底”,比如:

  • 数据为 null 时显示一个“空态组件”
  • 钱包余额为 null 时,不显示数字而是展示“--”
  • 列表为空时展示“暂无数据”

4.1 文本兜底:别让页面渲染出 undefined / null

错误示例:


<template>
  <div>
    <!-- 假设 user.name 可能 undefined -->
    <p>用户名:{{ user.name }}</p>
  </div>
</template>

页面可能出现:


<p>用户名:undefined</p>

推荐写法:


<template>
  <div>
    <p>用户名:{{ user?.name ?? '未设置' }}</p>
  </div>
</template>

如果你更谨慎一点,还可以抽成一个小工具函数或指令:


function displayText(value, fallback = '--') {
  if (value === null || value === undefined) return fallback;
  return String(value);
}

模板中:


<p>用户名:{{ displayText(user?.name, '未设置') }}</p>

4.2 数字兜底:0、null、undefined 要区分

常见场景:金额 / 数量 / 积分


<template>
  <div>
    <!-- 如果 amount 为 0,要显示 0 元,而不是 “--” -->
    <p>金额:{{ formatAmount(order?.amount) }}</p>
  </div>
</template>

<script setup>
function formatAmount(value) {
  if (value === null || value === undefined) return '--'; // 真空
  const num = Number(value);
  if (Number.isNaN(num)) return '--';                     // 非法数字
  return num.toFixed(2) + ' 元';
}
</script>

这里的思路是:

  • 对于“真空”(null/undefined)和“非法值”(NaN),直接兜底成 --
  • 对于合法的 0、10.5 等,按正常格式化逻辑展示

4.3 列表兜底:空数组 vs null/undefined

错误写法:


<template>
  <ul>
    <li v-for="item in list" :key="item.id">{{ item.name }}</li>
  </ul>
</template>

<script setup>
const list = ref(null);
</script>

list 为 null 时,Vue 其实不会崩溃,但可读性很差,而且 TypeScript 下会疯狂报错。

推荐规范:

  • 列表类型的数据,初始化为 [],不要初始化为 null

  • 接口响应里如果是 null在数据层统一转成 [],不要把“既可以是数组又可以是 null”的结构传到视图层


// 假设后端可能返回 { list: null }
interface ApiResponse<T> {
  list: T[] | null;
}

async function fetchUsers(): Promise<User[]> {
  const res: ApiResponse<User> = await request('/api/users');
  return res.list ?? [];
}

Vue 组件里直接:


const users = ref<User[]>([]);

onMounted(async () => {
  users.value = await fetchUsers(); // 一定是数组
});

好处:

  • 模板里 v-for="user in users" 不用可选判断
  • 业务逻辑中也不用 if (!users) 乱判
  • 类型更干净,TS 也容易推断

五、可读性 vs 防御性:别让“防空代码”毁了代码结构

经常看到这样的代码:


if (user && user.profile && user.profile.address && user.profile.address.city) {
  showCity(user.profile.address.city);
} else {
  showDefaultCity();
}

可读性非常差。我们可以结合 ?. 和业务逻辑重写:

5.1 利用中间变量提高可读性


const city = user?.profile?.address?.city;

if (city) {
  showCity(city);
} else {
  showDefaultCity();
}

如果业务含义更复杂,比如:

  • city 为空字符串也视为没填

可以:


const rawCity = user?.profile?.address?.city;
const city = rawCity?.trim(); // string 或 undefined

if (!city) {
  showDefaultCity();
} else {
  showCity(city);
}

规范建议:

  • 不要在 if (...) 里面写一大串可选链,可以先提取出来
  • 对于复杂逻辑(例如 if (a && b && c && d)),考虑拆成几个语义明确的变量

六、项目中推荐的“空值处理规范(示例版)”

以下是一份可直接落地到团队规范里的示例,你可以根据团队实际情况调整。

6.1 基础规则

  • 规则 1:接口层统一做“空值归一化”
    • 列表字段:null / undefined 统一转成 []
    • 数字字段:null / undefined 转成约定好的业务默认(如 0),或者保持 null,但要有清晰设计文档
    • 字符串字段:如果是必展示项,可以转 '',或保留 null,但组件层要有兜底文案
  • 规则 2:组件 / 页面层永远不要直接信任后端
    • 访问深层属性一律用 ?.
    • 模板输出中不要让 null / undefined 直接裸露
  • 规则 3:兜底默认值尽量用 ??,而不是 ||
    • 只有当你有意要把 0 / '' / false 也视为“空”时,才可以用 ||

6.2 风格对比示例(推荐 vs 不推荐)

不推荐:


// 1. 访问深层属性不做保护
const city = user.profile.address.city;

// 2. 用 || 做默认值
const price = item.price || 0;
const count = item.count || 1;

// 3. 列表用 null 表示“还没加载”
const list = ref(null);

推荐:


// 1. 使用可选链保护
const city = user?.profile?.address?.city;

// 2. 用 ?? 严格处理 null/undefined
const price = item.price ?? 0;
const count = item.count ?? 1;

// 3. 列表统一用 [] 作为初始值
const list = ref([]);

在 Vue 模板中的统一写法示例:


<template>
  <div>
    <p>用户名:{{ user?.name ?? '未设置' }}</p>
    <p>年龄:{{ user?.age ?? '--' }}</p>

    <p>余额:{{ formatAmount(account?.balance) }}</p>

    <ul v-if="orders.length">
      <li v-for="order in orders" :key="order.id">
        订单号:{{ order.id }},金额:{{ formatAmount(order.amount) }}
      </li>
    </ul>
    <p v-else>暂无订单</p>
  </div>
</template>

<script setup>
const user = ref(null);
const account = ref(null);
const orders = ref([]); // 一定是数组

function formatAmount(value) {
  if (value === null || value === undefined) return '--';
  const num = Number(value);
  if (Number.isNaN(num)) return '--';
  return num.toFixed(2) + ' 元';
}
</script>

七、常见踩坑案例拆解

7.1 “把 0 当成没填”——报表类页面的大坑

需求:展示一个指标的环比增长率,后端字段 growthRate,可能是:

  • 0:说明没涨没跌
  • 正数:增长
  • 负数:下降
  • null:没有数据

错误写法:


<p>环比:{{ growthRate || '--' }}%</p>

growthRate = 0 时,会显示 --%,业务含义严重错误。

正确写法:


<p>环比:{{ growthRate ?? '--' }}{{ growthRate === null || growthRate === undefined ? '' : '%' }}</p>

或者包装一下:


function displayPercent(value) {
  if (value === null || value === undefined) return '--';
  return `${value}%`;
}

模板:


<p>环比:{{ displayPercent(growthRate) }}</p>

7.2 “深层属性访问炸页面”——常见于接口变更

场景:后端有一天把 user.profile 改成 user.info,但你代码里到处是:


user.profile.address.city

迁移时推荐策略:

  1. 先统一加可选链防御(短期止血):

const city = user?.profile?.address?.city;

  1. 在“数据适配层”做映射,避免在视图层直接跟后端结构硬绑定:

interface UserViewModel {
  city?: string;
  // ...
}

function mapUserDtoToViewModel(dto: any): UserViewModel {
  const profile = dto.profile || dto.info || {};
  return {
    city: profile.address?.city,
    // ...
  };
}
  1. 视图层只用 viewModel.city,再配合兜底:

<p>城市:{{ user.city ?? '未知城市' }}</p>

这样即使后端再改结构,你只需要改映射函数,不会到处是 ?. 打补丁。

八、结合 TypeScript:从“到处防空”升级为“类型上减少空值”

如果你的项目已经用 TypeScript,可以进一步 把“空值问题”提前到类型设计阶段解决

8.1 接口类型:把“可选”缩到最小

错误示例(很多后端生成工具会这样):


interface UserDto {
  id?: number;
  name?: string;
  age?: number | null;
  address?: {
    city?: string;
  } | null;
}

视图层到处是:


user?.address?.city ?? '未知城市'

更好的做法是:

  • 在“接口模型”层承认这些都是可选

  • 但在往页面传的时候,通过构造 ViewModel 把这些变成“非可选 + 有默认值”


interface UserViewModel {
  id: number;
  name: string;
  age: number | null;   // 业务上允许为 null
  city: string;         // 至少有兜底
}

function toUserViewModel(dto: UserDto): UserViewModel {
  return {
    id: dto.id ?? 0,                         // 或抛错,看业务
    name: dto.name ?? '未命名用户',
    age: dto.age ?? null,
    city: dto.address?.city ?? '未知城市',
  };
}

组件里就可以大胆用:


<p>用户名:{{ user.name }}</p>
<p>城市:{{ user.city }}</p>

而不是到处防空。

九、落地建议:如何在现有项目里逐步推行这套规范?

9.1 从“新代码”开始做对

  • 自己写的新组件、新方法,从一开始就用 ?.??
  • 审 PR 的时候,对用 || 做默认值的地方特别敏感,看清楚是否需要保留 0/''/false

9.2 为高风险页面补一层“空值巡检”

优先排查:

  • 面向 C 端用户的关键页面(订单、支付、结算)
  • 报表、数据面板类页面(数字特别多)

从这些点切入:

  • 所有深层属性访问,加上可选链或前置的空值判断
  • 所有数值展示,考虑是否需要 formatXXX 方法来统一兜底逻辑
  • 所有默认值逻辑,检查 || 能否替换为 ??

9.3 写到团队规范 / README / Contributing 里

可以直接摘抄下面一段到你们项目的规范文档里:

空值处理规范(摘要)

  1. 从接口拿到的原始数据,访问深层属性一律使用可选链 ?.
  2. 兜底默认值优先使用空值合并运算符 ??,只有在需要把 0 / '' / false 也当成“空”的场景才使用 ||
  3. 列表数据初始化为 [],不要用 null 表示“尚未加载”。接口返回 null 时在数据适配层统一转为 []
  4. 数字和金额展示需通过统一的格式化方法处理,避免页面出现 NaNundefined
  5. 模板中禁止直接输出可能为 null / undefined 的字段,必须有兜底显示(如 '--''未设置' 等)。

十、总结:把“空值处理”当成一个硬规范,而不是临时脑补

  • 可选链 ?.:用来安全访问深层属性,防止“Cannot read properties of undefined” 直接把页面干崩。
  • 空值合并 ??:只在 null / undefined 时兜底,避免误伤合法的 0 / '' / false
  • 兜底逻辑:不仅是语法问题,更是业务体验和数据安全网的问题,最好沉淀为项目级规范,而不是随手一写。

技术成长,从来不是比谁写得快,而是比谁写得稳、规范、可维护

哪怕每次只吃透一条规范,长期下来,差距会非常明显。

后续我会持续更新前端规范、工程化、可维护代码相关实战干货,帮你告别面条代码、维护噩梦,在开发与面试中更有底气。

觉得有用欢迎 点赞 + 收藏 + 关注,不错过每一篇实战内容。

我是 Eugene,与你一起写规范、写优质代码,我们下篇干货见~

前端代码注释规范:Vue 实战避坑,让 3 年后的自己还能看懂代码|项目规范篇

作者 SuperEugene
2026年3月19日 14:41

一套真正能落地的前端代码注释规范,从 Vue 项目实战出发,告诉你注释该写什么、不该写什么,避开常见坑点,写出让 3 年后的自己还能看懂的可维护代码。

在这里插入图片描述

同学们好,我是 Eugene(尤金),一名多年中后台前端开发工程师。

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

很多前端开发者都会遇到一个瓶颈:

代码能跑,但不够规范;功能能实现,但维护起来特别痛苦;一个人写没问题,一到团队协作就各种混乱、踩坑、返工。

想写出干净、优雅、可维护的专业代码,靠的不是天赋,而是体系化的规范 + 真实实战经验

这一系列《前端规范实战》,我会用大白话 + 真实业务场景,不讲玄学、不堆理论,只分享能直接落地的规范、标准与避坑指南。

帮你从「会写代码」真正升级为「会写优质、可维护、团队级别的代码」。

前言:为什么要认真对待“写注释”这件小事?

你有没有遇到过这些场景:

  • 半年前自己写的业务,今天改个小需求,打开文件之后第一反应:“这谁写的垃圾代码?”,再一看作者:是自己。

  • 接手别人老项目,逻辑绕来绕去,偶尔看到一行注释:// TODO// 这里有点问题,先这么写……然后就没有然后了。

  • 为了“规范”,团队强行要求每个函数、每个变量都加注释,结果:注释和代码一起过期,甚至误导后来的人。

这篇文章就想解决一个现实问题:

日常写代码时,注释到底该怎么写?为什么这么写?坑会踩在哪?

目标是:让 3 年后的自己和队友,打开代码就能快速搞懂上下文,而不是骂人。

本文不是讲晦涩的底层原理,而是站在一线开发、项目规范的视角,用 Vue / 前端开发场景来聊聊“代码注释规范”。

一、第一原则:好代码胜过好注释,但没有注释也不一定是好代码

1.1 一句话核心原则

能用清晰的命名和结构表达含义,就不要用注释补课。注释只做代码无法表达的“额外信息”。

很多团队会陷入两个极端:

  • 极端 1:注释洁癖“好的代码不需要注释”,结果写一堆晦涩难懂的缩写变量,没人看得懂。

  • 极端 2:注释狂魔几乎每一行都要注释:

    
    // 声明一个变量 a
    let a = 1;
    // a 加 1
    a++;
    
    

    这种注释只会浪费时间、增加维护成本。

正确姿势:

  • 优先改代码,让代码本身更清晰(变量名、函数名、拆分方法、抽象组件……)

  • 其次用注释补充“代码表达不到的信息”,例如:

    • 为什么要这么写(业务背景 / 历史原因 / 兼容性)
    • 注意事项(性能、边界条件、已知坑)
    • 和其他模块的约定(接口协议、调用顺序)

二、注释的四大黄金场景:该写什么?

下面是我在项目里常用、非常推荐的四类注释场景。

2.1 解释“为什么这么写”(Why),而不是“代码在干嘛”(What)

What 代码自己能看出来,Why 只能靠你写出来。

❌ 错误示例:只是重复代码


// 获取用户列表
const users = await fetchUsers();

  • 这行注释几乎就是在重复变量名,没有信息增量

✅ 推荐示例:解释设计/业务原因


// 这里不能直接用缓存的用户列表:
// 1. 用户状态(在线/离线)是实时的
// 2. 后端会根据当前登录态过滤可见用户
// 所以每次都强制请求最新数据
const users = await fetchUsers({ forceRefresh: true });

这里的注释说明了为什么不能优化成缓存,以后有人想“优化性能”时,看到注释就会收手,避免踩坑。

2.2 标记“约定”和“前置条件”:别人需要遵守什么?

在 Vue 组件、工具函数、API 调用中,最容易出问题的往往不是“实现细节”,而是使用前提

  • 参数有没有默认值?
  • 有哪些边界情况?
  • 调用顺序有没有依赖?

✅ Vue 组件示例:在 props / emits 上写注释


// UserForm.vue <script setup lang="ts">
interface Props {
  /**
   * 表单模式:
   * - 'create':新建用户,所有字段可编辑
   * - 'edit':编辑用户,用户名不可修改
   * - 'readonly':只读模式,所有字段禁用
   */
  mode: 'create' | 'edit' | 'readonly';

  /**
   * 编辑/只读模式下必传:
   * 后端返回的完整用户信息。
   * create 模式下可以不传(内部会使用默认值)
   */
  user?: User;
}

const props = defineProps<Props>();

/**
 * 表单提交事件:
 * - create: 提交的 user.id 由后端生成
 * - edit: 必须包含原有的 user.id
 */
const emit = defineEmits<{
  (e: 'submit', payload: User): void;
}>();

这里注释的作用非常明确:

  • 告诉你 mode 不同模式的差别
  • 告诉你 user 在什么模式下是必传的
  • 告诉你 submit 的 payload 长什么样

重点:这类注释是“契约”的一部分,写在类型(interface / props / emits)附近最合适。

2.3 记录“历史遗留”和“坑点说明”:这块代码为什么这么丑?

有些代码你也知道写得不优雅,但短期内又不能重构,比如:

  • 老接口的奇怪字段命名
  • 历史版本遗留的时间格式
  • 奇怪的兼容写法(低版本浏览器 / 特定设备)

与其未来被队友(或自己)怒喷:

“这谁写的?怎么这么鬼畜?”

不如提前写清楚原因。

✅ 示例:兼容老接口


/**
 * 注意:后端这个接口是老系统保留的,字段命名非常诡异。
 * - 'usr_nm' 对应用户姓名
 * - 'crt_tm' 是创建时间字符串,格式为 'YYYY/MM/DD HH:mm:ss'
 * 暂时不能动这个接口,只在这里统一做一次映射。
 */
function normalizeLegacyUser(raw: any): User {
  return {
    id: raw.id,
    name: raw.usr_nm,
    createdAt: dayjs(raw.crt_tm, 'YYYY/MM/DD HH:mm:ss').toDate(),
  };
}

以后谁要改这个接口时,看到注释就会明白:

  • 这是历史债务,不是你写代码水。
  • 如果要改,要 连后端 / 老系统一并考虑

2.4 对复杂算法 / 业务流程做“概览说明”:给后人一张思维导图

有些模块就算代码写得再优雅,逻辑本身就是复杂的

  • 多步骤审批流
  • 复杂的优惠券 / 价格计算规则
  • 权限控制(菜单 + 按钮 + 数据权限)

这种时候,不要指望“代码自解释”,加一段流程性注释是对所有人的救赎。

✅ 示例:订单价格计算(假设你在 calculateOrderPrice.ts 里)


/**
 * 订单价格计算规则(简化版):
 *
 * 1. 基础金额 = 所有商品单价 * 数量 之和
 * 2. 商品级优惠:
 *    - 满减券:优先按商品分类应用,不能跨分类凑单
 *    - 折扣券:在满减之后应用,最多 2 张
 * 3. 订单级优惠:
 *    - 平台券:在所有商品级优惠之后应用
 *    - 封顶逻辑:总优惠金额不能超过基础金额的 30%
 * 4. 运费:
 *    - 满 99 元包邮
 *    - 其他情况按地区和重量计算
 *
 * 注意:
 * - 所有金额都用「分」为单位在内部计算,避免浮点误差
 * - 对外展示时再转换为「元」
 */
export function calculateOrderPrice(order: Order): OrderPriceDetail {
  // 具体实现略
}

这里注释的价值在于:

  • 给出了整体流程(按步骤)
  • 标明了关键约束(封顶 30%、单位是“分”)
  • 以后别人改逻辑时,有一个可以“对齐口径”的地方

三、哪些注释是坚决不要写的?

知道“该写什么”之后,更重要的是:哪些注释写了只会拖团队后腿?

3.1 重复代码的注释:浪费时间 + 增加维护成本

❌ 示例 1:重复变量名


// 用户名称
const userName = getUserName();

❌ 示例 2:重复函数名 / 类型名


/**
 * 获取用户列表
 */
function getUserList() { ... }

这些注释的问题:

  • 没有额外信息
  • 只要一改函数名/变量名,注释就有可能不一致
  • 时间久了变成“看着像对的,其实是错的”

解决办法:

  • 优先把命名改清晰:getListgetUserListdatauserList / formState
  • 确实没啥要补充的,就不要写注释,空着反而更安全。

3.2 “心情日志”注释:TODO / FIXME 不写清楚内容

❌ 典型反面教材:


// TODO: 后续优化
// FIXME: 有 bug

半年后你自己也不知道:

  • 要优化什么?
  • 有什么 bug?复现步骤是什么?
  • 是否已经修了?是否还有影响?

✅ 推荐写法:


// TODO(v2.1): 表格数据量>1w时,滚动卡顿,需要引入虚拟列表
// 影响范围:订单列表、用户列表


// FIXME(2025-03-18 by 张三):
// 后端偶发返回重复的 orderId,导致 set 里丢数据
// 临时方案:前端用 (orderId + createdAt) 拼接作为 key,等后端修复后移除

规范建议:

  • TODO / FIXME 注释建议包含:

    • 触发条件 / 复现方式
    • 影响范围
    • (可选)目标版本/时间 & 责任人缩写
  • 团队可以规定:重要 TODO / FIXME 必须对应 Jira/禅道/飞书任务号,比如:


// TODO(JIRA-1234 v2.2): 支持多语言,先写死为中文

3.3 和真实逻辑不一致的注释:比没有注释更可怕

注释一旦和代码不一致,就会变成误导信息

❌ 示例:注释没更新


/**
 * 返回 true 表示用户未登录
 */
function isLoggedIn() {
  return !!localStorage.getItem('token');
}

显然逻辑是“有 token 才是登录”,但注释写反了。

如果后来别人只看注释不看实现,很容易写出一堆反逻辑的代码。

经验结论:

写过时注释 = 欺骗未来的同事。

写了就要维护,维护不了就少写。

所以在团队规范里可以明确:

  • 改动逻辑时,必须同步检查相关注释是否仍然正确
  • Code Review 时,把**“注释是否仍然成立”**当成一个检查点

3.4 写在实现细节里的“小说故事”:越写越乱

有同学特别喜欢在函数内部“边写边感想”,比如:


function fetchData() {
  // 这里先判断一下是不是有缓存
  // 如果有缓存的话就不用请求接口了
  // 但是这里我们又觉得可能缓存会不准
  // 所以又加了一个时间戳的判断
  // 总之就是很复杂,先这么写吧……
}

这种注释的问题:

  • 没有结构,像碎碎念日记
  • 讲了一堆感受,没有讲清楚最终规则
  • 以后别人看的时候,只会更迷惑

更好的做法:

  • 把真正关键的规则整理成条目
  • 其他的犹豫、不确定、吐槽,写到需求文档 / 评审记录里,而不是代码里

✅ 重写示例:


/**
 * 缓存策略说明:
 * 1. 默认命中缓存,避免重复请求
 * 2. 如果缓存时间超过 5 分钟,则强制请求最新数据
 * 3. 切换用户时,必须清空缓存(用户隔离)
 */
function fetchData() {
  // 实现略
}

四、不同层级怎么写?以 Vue 项目为例的一套落地规范

下面从 Vue 项目常见几层结构出发,给一套可直接落地到项目里的注释建议

4.1 组件层(Vue SFC):注释重点放在哪里?

4.1.1 props / emits / expose 是最值得写注释的地方

因为它们构成了组件的“对外接口”。

✅ 示例:表单组件


<script setup lang="ts">
interface Props {
  /**
   * 表单初始值:
   * - 不传则使用内部默认值
   * - 传入时会完全覆盖默认值(不要只传部分字段)
   */
  modelValue?: UserFormModel;

  /**
   * 是否立即在 mounted 后拉取远程选项数据
   * 默认 true;如果父组件要控制时机,可以传 false 后手动调用 `reloadOptions`
   */
  autoLoadOptions?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  autoLoadOptions: true,
});

const emit = defineEmits<{
  /**
   * 表单提交成功时触发
   * payload 包含表单内的所有字段
   */
  (e: 'submit', payload: UserFormModel): void;

  /**
   * 任意字段变化时触发(用于实时保存草稿)
   */
  (e: 'update:modelValue', value: UserFormModel): void;
}>();

defineExpose({
  /**
   * 重新拉取远程下拉选项
   */
  reloadOptions,
});
</script>

这里的注释能让你在不看实现的情况下,就知道怎么用这个组件,这就是高价值注释。

4.1.2 复杂模板逻辑,优先拆组件,其次写块级注释

当模板里出现大量条件判断 / 嵌套 v-if / v-for 时:

  1. 优先选择“拆小组件 / 抽函数”
  2. 仍然复杂时,可以在逻辑块上方加一段块级注释,说明大体意图

✅ 示例:


<template>
  <!-- 展示可见的菜单项:
       1. 已被后端标记为启用
       2. 当前用户有权限
       3. 如果是移动端,只显示前 5 个
  -->
  <MenuItem
    v-for="item in visibleMenuItems"
    :key="item.id"
    :item="item"
  />
</template>

这里注释的作用:

  • 总结了 visibleMenuItems过滤规则
  • 方便别人查找时快速定位逻辑(比如“为什么这个菜单在移动端消失了?”)

4.2 业务逻辑层(hooks / composables / services)

很多 Vue 3 项目会把复杂逻辑拆到:

  • useXXX.ts(逻辑复用)
  • xxxService.ts(调用后端接口 + 业务规则)

这部分逻辑往往最需要注释,但注释也最容易乱写。

4.2.1 统一写在函数/方法签名上方,说明职责和返回值

✅ 示例:组合式函数


/**
 * 订单列表的分页 + 筛选逻辑:
 * - 对外暴露响应式数据:list、loading、pagination
 * - 支持关键字搜索、状态筛选
 * - 初始化时自动加载一次数据
 */
export function useOrderList() {
  const list = ref<Order[]>([]);
  const loading = ref(false);
  const pagination = reactive({
    page: 1,
    pageSize: 20,
    total: 0,
  });

  // ...

  return {
    list,
    loading,
    pagination,
    reload,
    resetFilters,
  };
}

4.2.2 和后端接口交互的地方,注释协议差异/约束

✅ 示例:Service 层


/**
 * 获取订单详情:
 * - 后端只在 status='PAID' 时返回 payInfo 字段
 * - 如果订单已退款,refoundInfo 字段存在但可能为空对象
 * - 接口有 500ms 左右的延迟,注意不要在输入框输入时频繁调用
 */
export async function fetchOrderDetail(orderId: string): Promise<OrderDetail> {
  const { data } = await request.get(`/api/orders/${orderId}`);
  return normalizeOrderDetail(data);
}

这些信息如果不写在这里,很难在代码中第一时间发现,却又对上层调用逻辑影响极大。

4.3 工具层(utils / helpers):何时需要注释?

  • 通用的小工具函数,命名清晰时可以不用注释:

    
    export function formatPrice(amountInCent: number): string { ... }
    
    
  • 如果函数有一些隐含约束或性能特征,就应该注释说明:

✅ 示例:


/**
 * 深拷贝对象(仅用于小对象):
 * - 基于 JSON 序列化,不支持函数 / Date / Map / Set
 * - 遇到循环引用会抛错
 * 适合用于「接口 mock 数据」等简单场景,不要在核心路径频繁使用。
 */
export function simpleClone<T>(obj: T): T {
  return JSON.parse(JSON.stringify(obj));
}

五、团队层面的“注释规范建议”:可以直接抄到你们 RULE.md 里

下面给一份可以直接落地的团队规范草稿,你可以根据实际情况微调。

5.1 总体原则

  • P1:注释是代码的一部分,写了就要维护。
  • P2:注释说明“为什么 / 有什么坑 / 有什么约定”,不要“翻译代码”。
  • P3:宁可少写,也不要写错;宁可写在“合适位置”,也不要乱丢。

5.2 “必须注释”的场景

  • 对外接口:
    • 组件的 props / emits / expose
    • 公共工具函数 / Service 层函数的入参、返回值说明(特别是有约束时)
  • 复杂业务逻辑 / 算法:
    • 在函数 / 模块顶部写整体流程说明或规则列表
  • 历史遗留 / 兼容代码:
    • 必须说明历史背景 / 兼容对象 / 计划替换方案
  • TODO / FIXME:
    • 必须写明触发条件 / 影响范围 / 预期目标
    • 建议关联任务号(如:TODO(JIRA-1234)

5.3 “禁止/不鼓励”的注释

  • 重复代码内容的注释(变量名 / 函数名已经表达清楚)
  • 空泛的 TODO / FIXME(未说明问题和上下文)
  • 纯吐槽 / 情绪化注释
  • 长篇大论但没有结构的“感想式注释”

六、一个完整的小案例:从“糟糕注释”到“可维护代码”

下面用一个实际例子,演示如何从“混乱风格”改到“规范易读”。

6.1 初版(很多人项目里真实存在的写法)


<!-- OrderList.vue -->
<script setup lang="ts">
// 订单列表组件

const data = ref([]);
const loading = ref(false);
const page = ref(1);
const pageSize = ref(10);
const total = ref(0);

// 获取列表
async function getList() {
  loading.value = true;
  // 调接口
  const res = await request.get('/api/list', {
    params: {
      p: page.value,
      ps: pageSize.value,
    },
  });
  // 处理数据
  data.value = res.data.list;
  total.value = res.data.total;
  loading.value = false;
}

// TODO: 后面要加筛选
</script>

<template>
  <!-- 列表 -->
  <Table :data="data" />
</template>

问题:

  • 命名不清晰(data / getList / /api/list
  • 注释几乎都是废话,没有说明任何约束
  • TODO 没有说明到底怎么“要加筛选”

6.2 改进版:结合命名 + 注释一起升级


<!-- OrderList.vue -->
<script setup lang="ts">
/**
 * 订单列表页:
 * - 支持分页
 * - 计划后续增加:状态筛选、关键字搜索(见 TODO)
 */
import { fetchOrderList } from '@/services/order';

const orders = ref<Order[]>([]);
const loading = ref(false);
const pagination = reactive({
  page: 1,
  pageSize: 10,
  total: 0,
});

/**
 * 拉取订单列表:
 * - 后端的页码从 1 开始(不要传 0)
 * - pageSize 最大不超过 100,否则后端会报错
 */
async function loadOrders() {
  loading.value = true;
  const res = await fetchOrderList({
    page: pagination.page,
    pageSize: pagination.pageSize,
  });
  orders.value = res.list;
  pagination.total = res.total;
  loading.value = false;
}

// TODO(v2.1): 增加筛选条件(状态 / 下单时间区间)
// - 与后端对齐接口 GET /api/orders:新增 status / startAt / endAt 参数
// - UI 上用折叠面板隐藏高级筛选
</script>

<template>
  <OrderTable
    :data="orders"
    :loading="loading"
    :pagination="pagination"
    @change="loadOrders"
  />
</template>

这里我们做了几件事:

  • 改变量名:dataordersgetListloadOrders
  • 提取 Service 层:fetchOrderList(便于复用与测试)
  • 用注释补充约束和未来计划,而不是重复代码

这就是一个**“代码 + 注释配合良好”的例子**。

七、如何把“注释规范”写成一篇能发 CSDN 的文章?

你可以按本文结构,稍作润色,就能产出一篇完整的博客。建议大致结构如下:

  1. 引子(痛点故事)
    • 自嘲+团队真实场景,引出“注释到底该不该写”的问题
  2. 第一原则:好代码优先,注释补充 Why & 限制
  3. 四大高价值注释场景
    • Why / 约定 / 历史坑点 / 复杂流程概览
  4. 四类反面注释示例
    • 重复代码、空 TODO/FIXME、过期注释、碎碎念
  5. 结合 Vue 项目结构的一套实践
    • 组件层、业务层、工具层分别给建议和示例
  6. 前后对比小案例
    • “糟糕版” vs “改进版”
  7. 总结 + 个人习惯分享
    • 比如:写完函数先写注释再实现、Review 时检查注释等

你可以直接把上文复制到 CSDN,稍微调整标题 / 小节顺序,并补充你自己项目中的真实故事和代码片段,会更有代入感和说服力。

八、结语:写给 3 年后的自己

注释不是给现在的你看的,是给“未来的你”和“曾经不认识你的同事”看的。

  • 多写一点“为什么这么写”,少写一点“这行在干嘛”
  • 多写一点“有什么坑 / 有什么约束”,少写一点“将来再说”
  • 写得少,但每一行都值钱,比写一堆废话强太多

技术成长,从来不是比谁写得快,而是比谁写得稳、规范、可维护

哪怕每次只吃透一条规范,长期下来,差距会非常明显。

后续我会持续更新前端规范、工程化、可维护代码相关实战干货,帮你告别面条代码、维护噩梦,在开发与面试中更有底气。

觉得有用欢迎 点赞 + 收藏 + 关注,不错过每一篇实战内容。

我是 Eugene,与你一起写规范、写优质代码,我们下篇干货见~

Vue3 JSX 语法速查:v-model、事件、插槽一网打尽

作者 cmd
2026年3月19日 11:07

Vue3 JSX 以 JS 原生逻辑替代模板语法,核心转换规则如下:

  • 条件渲染v-if&&/ 三元表达式;v-show 直接保留
  • 循环渲染v-for → 数组 map 遍历
  • 事件处理:事件名驼峰化,修饰符用 withModifiers 包裹
  • 双向绑定v-model 支持基础、自定义名、修饰符及多 model 场景
  • 插槽:支持默认 / 具名 / 作用域插槽,渲染与传递写法清晰
import { ref } from 'vue'
export default function add(props, ctx) {
    let visible = props.visible
    let form = props.form
    let plus = props.isPlus
    let addFormRef = ref()
    async function handleOk() {
        const error = await addFormRef.value.validate()
        if (error) return
        let url = plus ? 'insert' : 'update'
        let msg = plus ? '新增成功' : '更新成功'
        proxy.post(`/api/mapbus/pm/project/${url}`, form).then((res) => {
            proxy.$message.success(msg)
            ctx.emit('success')
        })
    }
    return (
        <a-drawer visible={visible} title={plus ? '新建' : '编辑'} width="30%" onOk={handleOk}>
            <a-form ref={addFormRef} model={props.form} auto-label-width>
                <a-form-item field="projectName" label="项目名称" validate-trigger="blur" rules={{required: true, message: '项目名称必填'}}>
                    <a-input v-model={form.projectName}></a-input>
                </a-form-item>
                <a-form-item field="remark" label="说明">
                    <a-input v-model={form.remark}></a-input>
                </a-form-item>
            </a-form>
        </a-drawer>
    )
}

一、常规逻辑

  • v-if: 转换成 js逻辑,三元表达式也可;
  • v-show: 支持;可直接写成v-show;
  • v-for:转换成js逻辑,forEach,map...等数组循环方式;
  • 事件:依驼峰命名方式写,onClickonMouseOver...等等
  • js: 用花括号包起来;
  • 对象:用两个花括号,外围的括号是js的括号,里面括号才是对象的括号;

二、事件

  1. 事件以驼峰命名方式定义;

  2. 事件要是有修饰符的话:

  • 以常规驼峰命名写;
<input
  onClickCapture={() => {}}
  onKeyupOnce={() => {}}
  onMouseoverOnceCapture={() => {}}
  />
  • 可以使用 withModifiers 函数
<div onClick={withModifiers(() => {}, ['self'])} />

三、v-model

Vue3 jsx新特性,支持v-model使用

(一)、modelValue

如果组件的v-modelmodelValue的话,那使用很简单;

renderDropdown(h){
const value = "value"
return <custom-component v-mode={value}>
code...
</custom-component>
}

自定义value

比如v-model:visible=show写法如下:

renderDropdown(h){
  const show = "true"
  return <el-popover v-model={[show, 'visible']}>
    code...
  </el-popover>
}

修饰符

  1. v-model后面跟着,使用(_)代替(.);vModel_trim = {value}
  2. withModifiers
// template<input v-model="val" />
<input v-model:name="val">
<input v-model.trim="val">
<input v-model:name.trim="val">

// tsx
<input v-model={val} />
<input v-model={[val, 'name']} />
<input v-model={[val, ['trim']]} />
<input v-model={[val, 'name', ['trim']]} />

多个model

// template
<A v-model="foo" v-model:bar="bar" />

// tsx
<A v-models={[[foo], [bar, "bar"]]} />

四、插槽

(一·)、渲染插槽

  1. js的方式
// 默认插槽
<div>{slots.default()}</div>

// 具名插槽
<div>{slots.footer({ text: props.message })}</div>
  1. dom的形式
export default function common(props, ctx) {
    const children = ctx.slots.default()[0]
    function handleBack() {
        ctx.emit('back', 12)
    }
    return (
        <div className={commonCss.panelContainer}>
            <div className={commonCss.header}>
                <MyIcon name="return" size={16} style={{cursor: 'pointer'}} onClick={handleBack}></MyIcon>
            </div>
            <children></children>
        </div>
    )
}

(二)、传递插槽

// 默认插槽
<MyComponent>{() => 'hello'}</MyComponent>

// 具名插槽
<MyComponent>{{
  default: () => 'default slot',
  foo: () => <div>foo</div>,
  bar: () => [<span>one</span>, <span>two</span>]
}}</MyComponent>

也可以如下:

// 具名插槽
<MyComponent v-slots={{
  default: () => 'default slot',
  foo: () => <div>foo</div>,
  bar: () => [<span>one</span>, <span>two</span>]
}}></MyComponent>

(四)、作用域插槽

<MyComponent>{{
  default: ({ text }) => <p>{ text }</p>  
}}</MyComponent>

五、总结

模板语法(Template) JSX/TSX 语法 说明
v-if="show" {show && <div>内容</div>} 三元表达式也可:{show ? <div>显示</div> : <div>隐藏</div>}
v-show="show" <div v-show={show}>内容</div> 直接支持 v-show
v-for="item in list" {list.map(item => <div>{item}</div>)} 需加 key
@click.stop="handleClick" onClick={withModifiers(handleClick, ['stop'])} 修饰符用 withModifiers 包裹
v-model:visible="show" <el-popover v-model={[show, 'visible']}>

感谢您抽出宝贵的时间观看本文;本文是 Vue3 核心 API 系列的第 2 篇,后续会持续更新 computed、ref/reactive、生命周期等实战内容,同时正在整理「Vue3 完整项目实战小册」(包含从 0 到 1 开发小程序 / 管理系统的全流程),欢迎关注~

Vue3 状态管理库 Pinia 完整教程

作者 学以智用
2026年3月19日 09:50

你想系统学习 Vue3 官方推荐的状态管理库 Pinia,我会从核心概念、基础使用、模块化、异步操作到实战技巧,用最简单易懂的方式教你完全掌握。

一、核心概念

Pinia 是 Vue 官方新一代状态管理库,替代 Vuex,专为 Vue3 设计,同时兼容 Vue2,核心优势:

  • 语法简洁,无需 mutation(只有 state、getters、actions)
  • 天然支持 TypeScript
  • 模块化设计,无需嵌套模块
  • 体积更小,性能更高
  • 支持热更新、插件扩展

二、快速上手(步骤)

1. 安装 Pinia

# npm
npm install pinia

# yarn
yarn add pinia

# pnpm
pnpm add pinia

2. 在 main.js 全局注册

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

const app = createApp(App)
// 挂载 Pinia
app.use(createPinia())
app.mount('#app')

三、定义 Store(核心)

Store 是存储状态和业务逻辑的容器,推荐按功能模块化拆分(如 user、cart、setting)。

1. 创建 Store 示例

src/stores/ 目录下新建文件(如 user.js):

// src/stores/user.js
import { defineStore } from 'pinia'

// 第一个参数:store 唯一 ID(必须唯一)
// 第二个参数:配置对象
export const useUserStore = defineStore('user', {
  // 1. 状态:存储数据(类似 data)
  state: () => ({
    name: '张三',
    age: 20,
    token: ''
  }),

  // 2. 计算属性:派生状态(类似 computed,有缓存)
  getters: {
    // 自动接收 state 作为参数
    doubleAge: (state) => state.age * 2,
    // 也可以使用 this 访问整个 store
    getName: function() {
      return `我的名字:${this.name}`
    }
  },

  // 3. 方法:修改状态、异步请求(类似 methods)
  actions: {
    // 同步修改
    updateName(newName) {
      this.name = newName
    },
    // 异步修改(支持 async/await)
    async login(account, pwd) {
      // 模拟接口请求
      const res = await new Promise(resolve => {
        setTimeout(() => resolve({ token: 'abcd-1234' }), 1000)
      })
      this.token = res.token
      this.name = account
    }
  }
})

四、组件中使用 Store

1. 基础使用(读取/修改状态)

<template>
  <div>
    <p>姓名:{{ userStore.name }}</p>
    <p>年龄:{{ userStore.age }}</p>
    <p>双倍年龄:{{ userStore.doubleAge }}</p>
    <button @click="userStore.updateName('李四')">改名</button>
    <button @click="userStore.login('admin', '123456')">登录</button>
  </div>
</template>

<script setup>
// 导入定义好的 store
import { useUserStore } from './stores/user'

// 实例化 store
const userStore = useUserStore()
</script>

2. 解构 state(保持响应式)

直接解构会丢失响应式,必须用 storeToRefs

import { storeToRefs } from 'pinia'

// 正确写法:响应式解构
const { name, age, doubleAge } = storeToRefs(userStore)

// 注意:actions 不需要解构,直接用
const { updateName, login } = userStore

3. 批量修改 state

// 方式1:单个修改
userStore.name = '王五'

// 方式2:批量修改(推荐)
userStore.$patch({
  name: '赵六',
  age: 25
})

// 方式3:函数式批量修改(适合复杂逻辑)
userStore.$patch(state => {
  state.name = '孙七'
  state.age += 1
})

4. 重置 state 到初始值

userStore.$reset()

五、模块化与 Store 相互调用

Pinia 无需配置模块,直接导入其他 Store 即可使用

示例:cart 购物车 store 调用 user store

// src/stores/cart.js
import { defineStore } from 'pinia'
import { useUserStore } from './user'

export const useCartStore = defineStore('cart', {
  state: () => ({
    list: []
  }),
  actions: {
    addCart(goods) {
      const userStore = useUserStore()
      // 判断用户是否登录
      if (!userStore.token) {
        alert('请先登录')
        return
      }
      this.list.push(goods)
    }
  }
})

六、数据持久化(常用插件)

页面刷新后 Pinia 数据会丢失,使用 pinia-plugin-persistedstate 插件实现本地持久化。

1. 安装

pnpm add pinia-plugin-persistedstate

2. 全局注册

// main.js
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

3. 开启持久化

// 在 store 中添加 persist: true
export const useUserStore = defineStore('user', {
  state: () => ({ ... }),
  persist: true // 开启持久化(默认 localStorage)
})

七、完整实战总结

  1. 安装 + 全局注册 Pinia
  2. 按功能拆分 Store(user/cart 等)
  3. state 存数据、getters 做计算、actions 做修改/异步
  4. 组件中导入使用,storeToRefs 解构保持响应式
  5. 跨模块调用直接导入其他 Store
  6. 用持久化插件保存数据不丢失

总结

  1. Pinia 是 Vue3 首选状态库,无 mutation、语法极简
  2. 核心三部分:state(数据)、getters(计算)、actions(方法)
  3. 响应式解构必须用 storeToRefs
  4. 模块化天然支持,持久化插件一键配置
昨天以前首页

Vue-Vue Router核心原理+实战用法全解析

2026年3月18日 17:41

前言

无论是单页面应用(SPA)还是复杂的后台管理系统,路由(Router)都是其灵魂。它通过 URL 映射组件,实现了无刷新的页面切换。本文将从底层原生 API 出发,带你彻底弄懂 Vue Router 的运行机制。

一、 路由的本质:Hash vs History

前端路由的核心是:改变 URL,页面不刷新,但渲染不同的组件。 Vue Router 本质上是基于浏览器原生的 window.location.hashhistory API 实现的,通过监听 URL 变化,动态匹配路由规则并渲染对应组件,无需后端参与页面切换。

1. Hash 模式 (window.location.hash)

  • URL 特征:路径中携带# 符号,例如 http://xxx.com/#/homehttp://xxx.com/#/about
  • 底层依赖window.location.hash
  • 核心特性:URL 中 # 后的内容属于锚点定位,不会发送到服务器端,所有前端路由请求最终都会指向 域名/index.html,服务器只需返回首页文件即可。
  • 优势:无需额外配置服务器,刷新页面、直接访问子路由都不会出现 404 错误,兼容性极强。

2. History 模式 (window.history)

  • URL 特征:路径中无 # 符号,形态更简洁,例如 http://xxx.com/homehttp://xxx.com/about
  • 底层依赖:浏览器原生 history API
  • 核心坑点:当用户刷新页面、直接访问子路由时,浏览器会向服务器发送对应路径的 GET 请求(如请求 /home),如果服务器未配置路由指向,会直接返回 404 错误。
  • 解决方案:必须在 Nginx 等服务器中配置规则,将所有路由请求都指向项目入口 index.html,由前端路由接管匹配逻辑。
location / {
  root   /usr/share/nginx/html;
  index  index.html index.htm;
  # 关键:找不到资源时返回 index.html
  try_files $uri $uri/ /index.html; 
}

二、 底层原理实现

1. Hash 模式实现链路

  • 监听变化:基于 windowhashchange 事件,监听 URL 中 hash 值的变化。
  • 设置值:修改 location.hash手动修改路由路径。
  • 跳转:使用 location.assign()实现路由跳转。
  • 获取当前路径:通过 location.hreflocation.hash 解析。

2. History 模式实现链路

  • 监听变化:基于浏览器原生 popstate 事件,仅监听浏览器前进/后退操作触发的路由变化。

    ⚠️ 避坑点:调用 history.pushStatereplaceState 改变 URL 时,并不会触发 popstate。Vue Router 内部通过劫持这些方法手动触发了更新。

  • 操作记录

    • pushState(stateObj, title, url):添加历史记录。
    • replaceState(stateObj, title, url):替换当前记录。
  • 获取路径:基于 window.location.pathname获取纯路径部分。

  • 状态存储:通过 history.state 获取传给 pushState 的自定义对象。


三、 Vue 路由跳转实战

方法一:声明式导航 <router-link>

这是日常开发中最常用的方式,本质是对 <a> 标签的封装,默认无刷新跳转,语法简洁且支持路由参数传递。核心参数如下:

  • to(必传) :目标路由路径,支持字符串格式和对象格式

    • 字符串格式:<router-link to="/home">首页</router-link>
    • 对象格式:可搭配 name、query、params 实现精细化跳转
  • name:通过路由名称跳转(推荐,避免路径硬编码),示例::to="{ name: 'About' }"

  • query:传递查询参数,参数会拼接在 URL 中(刷新不丢失),示例::to="{ name: 'About', query: { name: 'test' } }",最终 URL:/about?name=test

  • params:传递动态路由参数,参数不会拼接在 URL(刷新会丢失),必须配合 name 使用,示例::to="{ name: 'About', params: { id: 123 } }"

    注意:若路由规则中未定义动态参数(如 :id),仅通过 name + params 传参,刷新页面后 params 会丢失;

    解决办法:在路由规则中添加 :id(必传)或 :id?(可选),例如 path: '/about/:id?'

方法二:编程式导航 useRouter

通过 useRouter 获取路由实例,用代码控制路由跳转,适合非点击触发的场景(如接口请求成功后跳转、条件判断跳转、定时器跳转等)

<script setup>
import { useRouter } from 'vue-router'
// 获取路由实例
const router = useRouter()

// 编程式跳转
const goToPage = () => {
  // 1. push 跳转(新增历史记录,可返回)
  router.push('/home')
  // 对象格式跳转
  router.push({ name: 'About', query: { name: 'test' } })

  // 2. replace 跳转(替换历史记录,不可返回)
  router.replace('/about')

  // 3. 路由前进/后退
  router.go(-1) // 后退一页
  router.back() // 后退一页(等价 go(-1))
  router.forward() // 前进一页(等价 go(1))
}
</script>

四、 Vue 路由监听三大方法

Vue 监听路由变化,本质是监听 route 对象(包含 path/params/query 等属性)的变化,触发自定义回调函数,常用于路由切换时更新数据、重置状态等场景.

1. 使用 watch + useRoute

通过 useRoute 获取当前路由对象,搭配 watch 监听器实现路由变化监听,支持立即执行、深度监听,适用性最广。

const route = useRoute();

watch(
  () => route.query,
  (newQuery) => {
    console.log('搜索参数变了:', newQuery);
  },
  { immediate: true, deep: true } // immediate 确保初始化时执行
);

2. 路由守卫 onBeforeRouteUpdate

Vue Router 提供的导航守卫,仅在组件复用时触发(例如 /detail/123/detail/456),路由跳转到其他组件时不会触发,适合列表页跳转详情页等场景。

  • 优点:不需要 watch 那么大的开销,专门针对参数更新。
  • 局限:离开该组件或首次进入时不触发。
<script setup>
import { onBeforeRouteUpdate } from 'vue-router'

// 组件复用时触发
onBeforeRouteUpdate((to, from) => {
  console.log('即将跳转至:', to.path)
  console.log('从:', from.path, '跳转而来')
  // 可在此处更新组件数据
})
</script>

3. 原生监听(底层方案)

直接监听浏览器原生路由事件,脱离 Vue Router API 实现监听,适合特殊定制场景,需注意事件解绑避免内存泄漏。

window.addEventListener('popstate', callback)


vue-router v5.x createRouter 是创建路由实例?

作者 米丘
2026年3月18日 13:05

vue-router 初始化方法 createRouter。

image.png

createRouter

1、做了什么?

  • createRouterMatcher 初始化路由匹配系统。
  • 初始化 URL 处理,设置 URL 查询参数的解析和序列化函数。
  • 初始化历史管理器,初始化路由历史管理(Hash/History 模式)。
  • 初始化导航守卫系统,创建全局导航守卫的回调队列,beforeGuards,beforeResolveGuardsafterGuards
  • 初始化路由状态,创建响应式的当前路由状态currentRoute和待处理路由pendingLocation
  • 浏览器寒假配置滚动行为。
  • 定义核心的路由管理、路由导航方法。

2、函数返回?

返回 Router 实例。

/**
 * Creates a Router instance that can be used by a Vue app.
 * 负责组装路由的所有核心能力(路由匹配、导航守卫、历史记录管理、滚动行为、URL 解析 / 生成等),
 * 最终返回一个可安装到 Vue 应用的 Router 实例
 * @param options - {@link RouterOptions}
 */
export function createRouter(options: RouterOptions): Router {
  // 创建路由匹配器:解析 routes 配置,生成匹配规则(核心)
  const matcher = createRouterMatcher(options.routes, options)

  // 初始化 URL 查询参数解析/序列化函数(默认/自定义)
  const parseQuery = options.parseQuery || originalParseQuery
  const stringifyQuery = options.stringifyQuery || originalStringifyQuery

  // 初始化历史管理器(Hash/History 模式),开发环境校验必传
  const routerHistory = options.history

  if (__DEV__ && !routerHistory)
    throw new Error(
      'Provide the "history" option when calling "createRouter()":' +
        ' https://router.vuejs.org/api/interfaces/RouterOptions.html#history'
    )

  // 初始化导航守卫队列(全局前置/解析后/后置守卫)
  const beforeGuards = useCallbacks<NavigationGuardWithThis<undefined>>()
  const beforeResolveGuards = useCallbacks<NavigationGuardWithThis<undefined>>()
  const afterGuards = useCallbacks<NavigationHookAfter>()

  // 初始化当前路由(响应式)和待处理路由
  const currentRoute = shallowRef<RouteLocationNormalizedLoaded>(
    START_LOCATION_NORMALIZED
  )
  // 待处理路由(当前导航目标),初始值为起始路由
  let pendingLocation: RouteLocation = START_LOCATION_NORMALIZED

  // 滚动行为初始化:有自定义 scrollBehavior 时,禁用浏览器默认滚动恢复
  // leave the scrollRestoration if no scrollBehavior is provided
  if (isBrowser && options.scrollBehavior && 'scrollRestoration' in history) {
    history.scrollRestoration = 'manual'
  }

  const normalizeParams = applyToParams.bind(
    null,
    paramValue => '' + paramValue
  )
  // 遍历路由参数对象的所有值,对每个值应用指定的处理函数,并返回新的参数对象
  const encodeParams = applyToParams.bind(null, encodeParam)
  const decodeParams: (params: RouteParams | undefined) => RouteParams =
    // @ts-expect-error: intentionally avoid the type check
    applyToParams.bind(null, decode)

  let removeHistoryListener: undefined | null | (() => void)
  
  let readyHandlers = useCallbacks<_OnReadyCallback>()
  let errorListeners = useCallbacks<_ErrorListener>()
  let ready: boolean
  
  const go = (delta: number) => routerHistory.go(delta)

  let started: boolean | undefined
  const installedApps = new Set<App>()
  
    // NOTE: we need to cast router as Router because the experimental
  // data-loaders add many properties that aren't available here. We might want
  // to add them later on instead of having declare module in experimental
  const router = {
    currentRoute,
    listening: true, // 监听路由

    addRoute,
    removeRoute,
    clearRoutes: matcher.clearRoutes,
    hasRoute,
    getRoutes,
    resolve,
    options,

    push,
    replace,
    go,
    back: () => go(-1),
    forward: () => go(1),

    beforeEach: beforeGuards.add,
    beforeResolve: beforeResolveGuards.add,
    afterEach: afterGuards.add,

    onError: errorListeners.add,
    isReady,

    /**
     * Vue 应用集成(install 方法)
     * @param app
     */
    install(app: App) {
      // 注册全局组件 RouterLink 和 RouterView
      app.component('RouterLink', RouterLink)
      app.component('RouterView', RouterView)

      // 暴露 $router/$route 到全局
      app.config.globalProperties.$router = router as Router
      Object.defineProperty(app.config.globalProperties, '$route', {
        enumerable: true,
        get: () => unref(currentRoute),
      })

      // this initial navigation is only necessary on client, on server it doesn't
      // make sense because it will create an extra unnecessary navigation and could
      // lead to problems
      // 初始化首次导航(客户端)
      if (
        isBrowser &&
        // used for the initial navigation client side to avoid pushing
        // multiple times when the router is used in multiple apps
        !started &&
        currentRoute.value === START_LOCATION_NORMALIZED
      ) {
        // see above
        started = true
        push(routerHistory.location).catch(err => {
          if (__DEV__) warn('Unexpected error when starting the router:', err)
        })
      }

      const reactiveRoute = {} as RouteLocationNormalizedLoaded
      for (const key in START_LOCATION_NORMALIZED) {
        Object.defineProperty(reactiveRoute, key, {
          get: () => currentRoute.value[key as keyof RouteLocationNormalized],
          enumerable: true,
        })
      }

      // 提供路由注入(useRouter/useRoute)
      app.provide(routerKey, router as Router)
      app.provide(routeLocationKey, shallowReactive(reactiveRoute))
      app.provide(routerViewLocationKey, currentRoute)

      const unmountApp = app.unmount
      installedApps.add(app)

      // 应用卸载时清理
      app.unmount = function () {
        installedApps.delete(app)
        // the router is not attached to an app anymore
        if (installedApps.size < 1) {
          // invalidate the current navigation
          pendingLocation = START_LOCATION_NORMALIZED
          removeHistoryListener && removeHistoryListener()
          removeHistoryListener = null
          currentRoute.value = START_LOCATION_NORMALIZED
          started = false
          ready = false
        }
        unmountApp()
      }

      // TODO: this probably needs to be updated so it can be used by vue-termui
      if (
        (__DEV__ || __FEATURE_PROD_DEVTOOLS__) &&
        isBrowser &&
        !__STRIP_DEVTOOLS__
      ) {
        addDevtools(app, router as Router, matcher)
      }
    },
  } satisfies Pick<Router, Extract<keyof Router, string>>
  
  return router as Router
}

参数 options 有哪些属性?

/**
 * Options to initialize a {@link Router} instance.
 */
export interface RouterOptions extends EXPERIMENTAL_RouterOptions_Base {
  /**
   * Initial list of routes that should be added to the router.
   */
  routes: Readonly<RouteRecordRaw[]>
}
/**
 * Options to initialize a {@link Router} instance.
 */
export interface EXPERIMENTAL_RouterOptions_Base extends PathParserOptions {
  /**
   * History implementation used by the router. Most web applications should use
   * `createWebHistory` but it requires the server to be properly configured.
   * You can also use a _hash_ based history with `createWebHashHistory` that
   * does not require any configuration on the server but isn't handled at all
   * by search engines and does poorly on SEO.
   *
   * @example
   * ```js
   * createRouter({
   *   history: createWebHistory(),
   *   // other options...
   * })
   * ```
   */
  history: RouterHistory // 指定路由使用的「历史记录管理器」,决定路由模式(Hash/History)

  /**
   * Function to control scrolling when navigating between pages. Can return a
   * Promise to delay scrolling.
   *
   * @see {@link RouterScrollBehavior}.
   *
   * @example
   * ```js
   * function scrollBehavior(to, from, savedPosition) {
   *   // `to` and `from` are both route locations
   *   // `savedPosition` can be null if there isn't one
   * }
   * ```
   */
  scrollBehavior?: RouterScrollBehavior // 自定义路由切换时的页面滚动行为(如返回顶部、恢复滚动位置)

  /**
   * Custom implementation to parse a query. See its counterpart,
   * {@link EXPERIMENTAL_RouterOptions_Base.stringifyQuery}.
   *
   * @example
   * Let's say you want to use the [qs package](https://github.com/ljharb/qs)
   * to parse queries, you can provide both `parseQuery` and `stringifyQuery`:
   * ```js
   * import qs from 'qs'
   *
   * createRouter({
   *   // other options...
   *   parseQuery: qs.parse,
   *   stringifyQuery: qs.stringify,
   * })
   * ```
   */
  parseQuery?: typeof originalParseQuery // 将 URL 中的查询参数字符串(如 a=1&b=2)解析为对象({ a: '1', b: '2' })

  /**
   * Custom implementation to stringify a query object. Should not prepend a leading `?`.
   * {@link parseQuery} counterpart to handle query parsing.
   * 将查询参数对象({ a: '1', b: '2' })序列化为字符串(a=1&b=2),无需手动加 ?
   */

  stringifyQuery?: typeof originalStringifyQuery

  /**
   * Default class applied to active {@link RouterLink}. If none is provided,
   * `router-link-active` will be applied.
   * 设置 <RouterLink> 「部分匹配激活」时的默认类名(如 /home 匹配 /home/child)
   */
  linkActiveClass?: string

  /**
   * Default class applied to exact active {@link RouterLink}. If none is provided,
   * `router-link-exact-active` will be applied.
   * 设置 <RouterLink> 「精确匹配激活」时的默认类名(仅 /home 匹配 /home)
   */
  linkExactActiveClass?: string

  /**
   * Default class applied to non-active {@link RouterLink}. If none is provided,
   * `router-link-inactive` will be applied.
   * 预留配置,用于设置 <RouterLink> 「非激活状态」的默认类名,当前版本未启用
   */
  // linkInactiveClass?: string
}
/**
 * @internal
 */
export interface _PathParserOptions {
  /**
   * Makes the RegExp case-sensitive.
   * 控制路由路径匹配时是否区分大小写(影响生成的正则表达式是否添加 i 标志)
   * @defaultValue `false` false(不区分大小写,如 /Home 和 /home 视为同一路由)
   */
  sensitive?: boolean

  /**
   * Whether to disallow a trailing slash or not.
   * 控制是否严格匹配路径末尾的斜杠(/)
   * @defaultValue `false` false(允许末尾斜杠,如 /home 和 /home/ 视为同一路由)
   */
  strict?: boolean

  /**
   * Should the RegExp match from the beginning by prepending a `^` to it.
   * @internal
   * 控制生成的路径匹配正则是否添加 ^ 前缀(即是否从字符串开头开始匹配)
   * @defaultValue `true` true(必须从路径开头匹配,符合路由匹配的基本逻辑)
   */
  start?: boolean

  /**
   * Should the RegExp match until the end by appending a `$` to it.
   * 控制生成的路径匹配正则是否添加 $ 后缀(即是否完整匹配路径末尾)
   * @deprecated this option will alsways be `true` in the future. Open a discussion in vuejs/router if you need this to be `false`
   * 已废弃
   * @defaultValue `true`
   */
  end?: boolean
}
export type PathParserOptions = Pick<
  _PathParserOptions,
  'end' | 'sensitive' | 'strict'
>

routes 配置

export type RouteRecordRaw =
  | RouteRecordSingleView // 最基础的路由配置,对应「一个路径匹配一个组件」的场景,无嵌套子路由
  // 基础单视图路由 + 嵌套子路由(对应 <RouterView> 嵌套渲染)
  | RouteRecordSingleViewWithChildren
  // 一个路径匹配多个组件,对应 <RouterView name="xxx"> 命名视图
  | RouteRecordMultipleViews
  // 多视图路由 + 嵌套子路由,是 RouteRecordMultipleViews 的扩展
  | RouteRecordMultipleViewsWithChildren
  // 仅用于路由重定向,无组件 / 视图配置,匹配路径后跳转到目标路由
  | RouteRecordRedirect

1、RouteRecordSingleView

基础单视图,一个路径匹配一个组件。禁止components、children、redirect。

/**
 * Route Record defining one single component with the `component` option.
 */
export interface RouteRecordSingleView extends _RouteRecordBase {
  /**
   * Component to display when the URL matches this route.
   * 指定路由匹配时要渲染的单个组件,是单视图路由的核心标识
   */
  component: RawRouteComponent
  // 明确禁止在单视图路由中使用 components 字段(多视图路由的核心字段)
  components?: never
  // 明确禁止在单视图路由中使用 children 字段(嵌套路由的核心字段)
  children?: never
  // 明确禁止在单视图路由中使用 redirect 字段(重定向路由的核心字段)
  redirect?: never

  /**
   * Allow passing down params as props to the component rendered by `router-view`.
   * 控制是否将路由参数(params/query)作为 props 传递给路由组件,避免组件直接依赖 $route
   */
  props?: _RouteRecordProps
}
// TODO: could this be moved to matcher? YES, it's on the way
/**
 * Internal type for common properties among all kind of {@link RouteRecordRaw}.
 */
export interface _RouteRecordBase extends PathParserOptions {
  /**
   * Path of the record. Should start with `/` unless the record is the child of
   * another record.
   * 路由路径
   * @example `/users/:id` matches `/users/1` as well as `/users/posva`.
   */
  path: string

  /**
   * Where to redirect if the route is directly matched. The redirection happens
   * before any navigation guard and triggers a new navigation with the new
   * target location.
   * 路由重定向选项,用于定义路由跳转目标
   */
  redirect?: RouteRecordRedirectOption

  /**
   * Aliases for the record. Allows defining extra paths that will behave like a
   * copy of the record. Allows having paths shorthands like `/users/:id` and
   * `/u/:id`. All `alias` and `path` values must share the same params.
   * 路由别名数组,用于定义额外的路径
   */
  alias?: string | string[]

  /**
   * Name for the route record. Must be unique.
   * 路由名称,必须唯一
   */
  name?: RouteRecordNameGeneric

  /**
   * Before Enter guard specific to this record. Note `beforeEnter` has no
   * effect if the record has a `redirect` property.
   */
  beforeEnter?:
    | NavigationGuardWithThis<undefined>
    | NavigationGuardWithThis<undefined>[]

  /**
   * Arbitrary data attached to the record.
   * 路由元数据,用于存储自定义信息,如权限、标题等
   */
  meta?: RouteMeta

  /**
   * Array of nested routes.
   * 子路由数组,用于定义嵌套路由结构
   */
  children?: RouteRecordRaw[]

  /**
   * Allow passing down params as props to the component rendered by `router-view`.
   */
  props?: _RouteRecordProps | Record<string, _RouteRecordProps>
}

2、RouteRecordSingleViewWithChildren

单视图嵌套子路由。禁止配置components。

/**
 * Route Record defining one single component with a nested view. Differently
 * from {@link RouteRecordSingleView}, this record has children and allows a
 * `redirect` option.
 */
export interface RouteRecordSingleViewWithChildren extends _RouteRecordBase {
  /**
   * Component to display when the URL matches this route.
   * 指定父路由匹配时渲染的布局组件(需包含 <RouterView> 用于渲染子路由)
   */
  component?: RawRouteComponent | null | undefined
  // 与 RouteRecordSingleView 一致,禁止使用 components(多视图字段),保证父路由为「单视图布局」
  components?: never

  // 定义父路由下的嵌套子路由,是该接口的核心标识(区别于 RouteRecordSingleView)
  children: RouteRecordRaw[]

  /**
   * Allow passing down params as props to the component rendered by `router-view`.
   * 控制是否将父路由的参数传递给父布局组件(而非子路由组件)
   */
  props?: _RouteRecordProps
}

3、RouteRecordMultipleViews

多视图。禁止配置component、children、redirect。

/**
 * Route Record defining multiple named components with the `components` option.
 */
export interface RouteRecordMultipleViews extends _RouteRecordBase {
  /**
   * Components to display when the URL matches this route. Allow using named views.
   * 指定路由匹配时要渲染的多个命名组件,键为「视图名称」,值为「组件」,是多视图路由的核心标识
   * 示例  components: {
            default: () => import('./DashboardMain.vue'), // 对应 <RouterView>(默认视图)
            header: () => import('./DashboardHeader.vue'), // 对应 <RouterView name="header">
            sidebar: () => import('./DashboardSidebar.vue'), // 对应 <RouterView name="sidebar">
          },
   */
  components: Record<string, RawRouteComponent>
  component?: never // 明确禁止使用 component 字段(单视图路由的核心字段)
  // 禁止使用 children 字段,多视图 + 嵌套子路由需使用 RouteRecordMultipleViewsWithChildren 类型
  children?: never
  // 禁止使用 redirect 字段,重定向路由需使用 RouteRecordRedirect 类型
  redirect?: never

  /**
   * Allow passing down params as props to the component rendered by
   * `router-view`. Should be an object with the same keys as `components` or a
   * boolean to be applied to every component.
   * 控制是否将路由参数传递给每个命名视图组件,是单视图 props 字段的多视图扩展
   */
  props?: Record<string, _RouteRecordProps> | boolean
}

4、RouteRecordMultipleViewsWithChildren

多视图嵌套子路由。禁止配置component。

/**
 * Route Record defining multiple named components with the `components` option and children.
 */
export interface RouteRecordMultipleViewsWithChildren extends _RouteRecordBase {
  /**
   * Components to display when the URL matches this route. Allow using named views.
   * 指定父路由匹配时渲染的多命名视图布局组件(需包含多个 <RouterView name="xxx"> 用于渲染子路由);
   * 1、有布局组件:父路由渲染多视图布局(如 header + sidebar + main),子路由可覆盖 / 扩展父视图;
   * 2、无布局组件:父路由仅用于路径分组(如 /admin/* 下的多视图子路由,无可视化布局);
   */
  components?: Record<string, RawRouteComponent> | null | undefined
  // 与 RouteRecordMultipleViews 一致,禁止使用 component 字段(单视图路由的核心字段)
  component?: never

  // 定义父多视图路由下的嵌套子路由,是该接口的核心标识(区别于 RouteRecordMultipleViews)
  children: RouteRecordRaw[]

  /**
   * Allow passing down params as props to the component rendered by
   * `router-view`. Should be an object with the same keys as `components` or a
   * boolean to be applied to every component.
   * 控制是否将父路由的参数传递给父多视图组件(而非子路由组件)
   */
  props?: Record<string, _RouteRecordProps> | boolean
}

路由独享守卫 beforeEnter

beforeEnter 守卫 只在进入路由时触发,不会在 paramsquery 或 hash 改变时触发。

image.png

{
  path: '/dashboard',
  name: 'dashboard',
  component: () => import('@/views/dashboard/DashBoard.vue'),
  meta: {
    title: '看板',
    icon: 'dashboard',
    roles: ['admin', 'user']
  },
  // beforeEnter: (to, from) => {
  //   console.log('beforeEnter-to', to)
  //   console.log('beforeEnter-from', from)
  //   return true
  // },
  beforeEnter: [(to, from) => {
    console.log('beforeEnter-111to', to)
    console.log('beforeEnter-f111rom', from)
    return true
  }, (to, from) => {
    console.log('beforeEnter-222to', to)
    console.log('beforeEnter-222from', from)
    return true
  }]

},

Router 实例有哪些属性?

/**
 * Router instance.
 * 路由实例
 */
export interface Router extends EXPERIMENTAL_Router_Base<RouteRecordNormalized> {
  /**
   * Original options object passed to create the Router
   * 存储创建路由实例时传入的原始配置项
   */
  readonly options: RouterOptions

  /**
   * Add a new {@link RouteRecordRaw | route record} as the child of an existing route.
   * 动态路由方法
   * 重载 1:添加嵌套路由
   * 返回值:一个「移除该动态路由的函数」,调用后可删除本次添加的路由
   * @param parentName - Parent Route Record where `route` should be appended at
   * @param route - Route Record to add
   */
  addRoute(
    // NOTE: it could be `keyof RouteMap` but the point of dynamic routes is not knowing the routes at build
    parentName: NonNullable<RouteRecordNameGeneric>,
    route: RouteRecordRaw
  ): () => void
  /**
   * Add a new {@link RouteRecordRaw | route record} to the router.
   *
   * @param route - Route Record to add
   * 重载 2:添加顶级路由
   * 返回值:一个「移除该动态路由的函数」,调用后可删除本次添加的路由
   */
  addRoute(route: RouteRecordRaw): () => void

  /**
   * Remove an existing route by its name.
   *
   * @param name - Name of the route to remove 路由名称(非空),注意只能通过名称删除,不能通过路径
   * 根据路由名称删除已存在的路由(包括静态路由和动态添加的路由)
   * 
   */
  removeRoute(name: NonNullable<RouteRecordNameGeneric>): void

  /**
   * Delete all routes from the router.
   * 清空路由表中所有路由(包括静态路由和动态添加的路由)
   * 注意:清空后路由表为空,需重新调用 addRoute 添加路由,否则导航会失效
   */
  clearRoutes(): void
}
/**
 * Router base instance.
 *
 * @experimental This version is not stable, it's meant to replace {@link Router} in the future.
 */
export interface EXPERIMENTAL_Router_Base<TRecord> {
  // NOTE: for dynamic routing we need this
  // <TRouteRecordRaw, TRouteRecord>
  /**
   * Current {@link RouteLocationNormalized}
   * 存储当前激活的标准化路由信息(响应式)
   */
  readonly currentRoute: ShallowRef<RouteLocationNormalizedLoaded>

  /**
   * Allows turning off the listening of history events. This is a low level api for micro-frontend.
   * 控制是否监听浏览器历史事件,专为「微前端」场景设计
   */
  listening: boolean

  // TODO: deprecate in favor of getRoute(name) and add it
  /**
   * Checks if a route with a given name exists
   * 根据路由名称判断路由是否存在(静态 / 动态添加的路由均可检测)
   * @param name - Name of the route to check
   */
  hasRoute(name: NonNullable<RouteRecordNameGeneric>): boolean

  /**
   * Get a full list of all the {@link RouteRecord | route records}.
   * 返回路由表中所有标准化路由记录
   */
  getRoutes(): TRecord[]

  /**
   * Returns the {@link RouteLocation | normalized version} of a
   * {@link RouteLocationRaw | route location}. Also includes an `href` property
   * that includes any existing `base`. By default, the `currentLocation` used is
   * `router.currentRoute` and should only be overridden in advanced use cases.
   * 将原始路由地址(如字符串、对象)解析为标准化的路由对象(包含 href、fullPath 等)
   * @param to - Raw route location to resolve
   * @param currentLocation - Optional current location to resolve against
   */
  resolve<Name extends keyof RouteMap = keyof RouteMap>(
    to: RouteLocationAsRelativeTyped<RouteMap, Name>,
    // NOTE: This version doesn't work probably because it infers the type too early
    // | RouteLocationAsRelative<Name>
    currentLocation?: RouteLocationNormalizedLoaded
  ): RouteLocationResolved<Name>
  resolve(
    // not having the overload produces errors in RouterLink calls to router.resolve()
    to: RouteLocationAsString | RouteLocationAsRelative | RouteLocationAsPath,
    currentLocation?: RouteLocationNormalizedLoaded
  ): RouteLocationResolved

  /**
   * Programmatically navigate to a new URL by pushing an entry in the history
   * stack.
   * 通过「新增历史记录」实现无刷新导
   *
   * @param to - Route location to navigate to
   */
  push(to: RouteLocationRaw): Promise<NavigationFailure | void | undefined>

  /**
   * Programmatically navigate to a new URL by replacing the current entry in
   * the history stack.
   * 通过「替换当前历史记录」实现导航(对应 history.replaceState),无历史记录回溯
   *
   * @param to - Route location to navigate to
   */
  replace(to: RouteLocationRaw): Promise<NavigationFailure | void | undefined>

  /**
   * Go back in history if possible by calling `history.back()`. Equivalent to
   * `router.go(-1)`.
   * 历史记录回溯
   */
  back(): void

  /**
   * Go forward in history if possible by calling `history.forward()`.
   * Equivalent to `router.go(1)`.
   * 历史记录回溯
   */
  forward(): void

  /**
   * Allows you to move forward or backward through the history. Calls
   * `history.go()`.
   *
   * @param delta - The position in the history to which you want to move,
   * relative to the current page
   * 历史记录回溯
   */
  go(delta: number): void

  /**
   * Add a navigation guard that executes before any navigation. Returns a
   * function that removes the registered guard.
   *
   * 注册全局前置守卫,导航触发时最先执行(可拦截、重定向导航)
   * @param guard - navigation guard to add
   */
  beforeEach(guard: NavigationGuardWithThis<undefined>): () => void

  /**
   * Add a navigation guard that executes before navigation is about to be
   * resolved. At this state all component have been fetched and other
   * navigation guards have been successful. Returns a function that removes the
   * registered guard.
   *
   * @param guard - navigation guard to add
   * @returns a function that removes the registered guard
   * 在所有组件内守卫、异步路由组件解析完成后,导航确认前执行
   * @example
   * ```js
   * router.beforeResolve(to => {
   *   if (to.meta.requiresAuth && !isAuthenticated) return false
   * })
   * ```
   *
   */
  beforeResolve(guard: _NavigationGuardResolved): () => void

  /**
   * Add a navigation hook that is executed after every navigation. Returns a
   * function that removes the registered hook.
   *
   * 导航完成后(成功 / 失败均执行),无法拦截导航
   *
   * @param guard - navigation hook to add
   * @returns a function that removes the registered hook
   *
   * @example
   * ```js
   * router.afterEach((to, from, failure) => {
   *   if (isNavigationFailure(failure)) {
   *     console.log('failed navigation', failure)
   *   }
   * })
   * ```
   */
  afterEach(guard: NavigationHookAfter): () => void

  /**
   * Adds an error handler that is called every time a non caught error happens
   * during navigation. This includes errors thrown synchronously and
   * asynchronously, errors returned or passed to `next` in any navigation
   * guard, and errors occurred when trying to resolve an async component that
   * is required to render a route.
   * 注册导航错误监听器,捕获导航过程中的所有未处理错误
   *
   * @param handler - error handler to register
   */
  onError(handler: _ErrorListener): () => void

  /**
   * Returns a Promise that resolves when the router has completed the initial
   * navigation, which means it has resolved all async enter hooks and async
   * components that are associated with the initial route. If the initial
   * navigation already happened, the promise resolves immediately.
   *
   * This is useful in server-side rendering to ensure consistent output on both
   * the server and the client. Note that on server side, you need to manually
   * push the initial location while on client side, the router automatically
   * picks it up from the URL.
   */
  isReady(): Promise<void> // 等待初始导航完成

  /**
   * Called automatically by `app.use(router)`. Should not be called manually by
   * the user. This will trigger the initial navigation when on client side.
   * 安装路由到 Vue 应用
   * 由 app.use(router) 自动调用,完成路由的初始化(注册全局组件、注入路由实例、触发初始导航)
   * @internal
   * @param app - Application that uses the router
   */
  install(app: App): void
}

实例方法 router.replace

  function replace(to: RouteLocationRaw) {
    return push(assign(locationAsObject(to), { replace: true }))
  }

实例方法 router.push

  function push(to: RouteLocationRaw) {
    return pushWithRedirect(to)
  }

pushWithRedirect

  /**
   * 负责处理「路由跳转 + 重定向 + 守卫执行 + 历史记录更新 + 错误处理」的全流程
   * @param to 目标路由位置(可以是字符串路径、命名路由对象或路径对象)
   * @param redirectedFrom 重定向来源路由位置(可选)
   * @returns 导航失败原因、成功时无返回值或 undefined
   */
  function pushWithRedirect(
    to: RouteLocationRaw | RouteLocation,
    redirectedFrom?: RouteLocation
  ): Promise<NavigationFailure | void | undefined> {

    // 解析目标路由为标准化 RouteLocation 对象
    const targetLocation: RouteLocation = (pendingLocation = resolve(to))
    const from = currentRoute.value // 获取当前路由(响应式的 currentRoute)

    // 获取历史记录状态(state)
    const data: HistoryState | undefined = (to as RouteLocationOptions).state
    // 获取强制跳转标志(force)
    const force: boolean | undefined = (to as RouteLocationOptions).force
    // to could be a string where `replace` is a function
    // 获取替换标志(replace)
    const replace = (to as RouteLocationOptions).replace === true

    // 检查目标路由是否配置了 redirect,返回重定向后的路由
    const shouldRedirect = handleRedirectRecord(targetLocation, from)

    // 若存在重定向,递归调用 pushWithRedirect 处理重定向后的路由
    if (shouldRedirect)
      return pushWithRedirect(
        // 合并重定向路由与原配置
        assign(locationAsObject(shouldRedirect), {
          state:
            typeof shouldRedirect === 'object'
              ? assign({}, data, shouldRedirect.state)
              : data,
          force,
          replace,
        }),
        // keep original redirectedFrom if it exists
        redirectedFrom || targetLocation
      )

    // if it was a redirect we already called `pushWithRedirect` above
    const toLocation = targetLocation as RouteLocationNormalized // 标准化目标路由

    toLocation.redirectedFrom = redirectedFrom // 标记重定向来源

    let failure: NavigationFailure | void | undefined // 声明导航失败变量

    // 非强制跳转 + 路由完全相同 → 生成重复跳转错误
    if (!force && isSameRouteLocation(stringifyQuery, from, targetLocation)) {
      failure = createRouterError<NavigationFailure>(
        ErrorTypes.NAVIGATION_DUPLICATED,
        {
          to: toLocation,
          from,
        }
      )
      // trigger scroll to allow scrolling to the same anchor
      // 即使重复跳转,仍处理滚动(如锚点 #top)
      handleScroll(
        from,
        from,
        // this is a push, the only way for it to be triggered from a
        // history.listen is with a redirect, which makes it become a push
        true, // push导航 
        // This cannot be the first navigation because the initial location
        // cannot be manually navigated to
        false // 非首次导航,初始路由不能手动跳转
      )
    }

    // 有失败则返回 resolved 的 failure,否则调用 navigate 执行真正的导航
    return (failure ? Promise.resolve(failure) : navigate(toLocation, from))
      .catch((error: NavigationFailure | NavigationRedirectError) =>
        isNavigationFailure(error)
          ? // navigation redirects still mark the router as ready
          // 导航守卫重定向 → 仅返回错误,不标记 ready
            isNavigationFailure(error, ErrorTypes.NAVIGATION_GUARD_REDIRECT)
            ? error
            // 其他导航失败 → 标记 router 为 ready 并返回错误
            : markAsReady(error) // also returns the error
          : // reject any unknown error
          // 未知错误 → 触发全局错误并抛出
            triggerError(error, toLocation, from)
      )
      .then((failure: NavigationFailure | NavigationRedirectError | void) => {
        if (failure) {
          if (
            isNavigationFailure(failure, ErrorTypes.NAVIGATION_GUARD_REDIRECT)
          ) {
            if (
              __DEV__ &&
              // we are redirecting to the same location we were already at
              // 开发环境:检测无限重定向(超过30次)并报警
              isSameRouteLocation(
                stringifyQuery,
                resolve(failure.to),
                toLocation
              ) &&
              // and we have done it a couple of times
              redirectedFrom &&
              // @ts-expect-error: added only in dev
              (redirectedFrom._count = redirectedFrom._count
                ? // @ts-expect-error
                  redirectedFrom._count + 1
                : 1) > 30
            ) {
              warn(
                `Detected a possibly infinite redirection in a navigation guard when going from "${from.fullPath}" to "${toLocation.fullPath}". Aborting to avoid a Stack Overflow.\n Are you always returning a new location within a navigation guard? That would lead to this error. Only return when redirecting or aborting, that should fix this. This might break in production if not fixed.`
              )
              return Promise.reject(
                new Error('Infinite redirect in navigation guard')
              )
            }

            return pushWithRedirect(
              // keep options
              assign(
                {
                  // preserve an existing replacement but allow the redirect to override it
                  replace,
                },
                locationAsObject(failure.to),
                {
                  state:
                    typeof failure.to === 'object'
                      ? assign({}, data, failure.to.state)
                      : data,
                  force,
                }
              ),
              // preserve the original redirectedFrom if any
              redirectedFrom || toLocation
            )
          }
        } else {
          // if we fail we don't finalize the navigation
          // 导航成功 → 最终化导航(更新历史记录/滚动/路由状态)
          failure = finalizeNavigation(
            toLocation as RouteLocationNormalizedLoaded,
            from,
            true,
            replace,
            data
          )
        }
        // 触发 afterEach 后置钩子
        triggerAfterEach(
          toLocation as RouteLocationNormalizedLoaded,
          from,
          failure
        )
        return failure
      })
  }

当待处理路由 与 当前路由完全一致,会出现以下问题

image.png

image.png

image.png

handleRedirectRecord

  /**
   * 「解析目标路由匹配记录中最后一条的 redirect 配置
   *  →标准化重定向目标格式→校验重定向合法性→合并原路由的 query/hash 等参数→返回最终的重定向目标」
   * @param to 目标路由对象
   * @param from 来源路由对象
   * @returns 
   */
  function handleRedirectRecord(
    to: RouteLocation,
    from: RouteLocationNormalizedLoaded
  ): RouteLocationRaw | void {

    const lastMatched = to.matched[to.matched.length - 1] // 获取最后一条匹配记录
    
    if (lastMatched && lastMatched.redirect) {
      const { redirect } = lastMatched // 获取 redirect 配置

      // 解析 redirect,目标重定向位置
      let newTargetLocation =
        typeof redirect === 'function' ? redirect(to, from) : redirect

      // 标准化字符串格式的 redirect → 对象格式
      if (typeof newTargetLocation === 'string') {
        newTargetLocation =
          // 字符串含 ?/# → 解析为完整对象(包含 query/hash)
          newTargetLocation.includes('?') || newTargetLocation.includes('#')
            ? (newTargetLocation = locationAsObject(newTargetLocation))
            : // force empty params
              { path: newTargetLocation }

        // @ts-expect-error: force empty params when a string is passed to let
        // the router parse them again
        // 强制清空 params,避免原路由 params 污染重定向目标
        newTargetLocation.params = {}
      }

      if (
        __DEV__ &&
        newTargetLocation.path == null &&
        !('name' in newTargetLocation)
      ) {
        warn(
          `Invalid redirect found:\n${JSON.stringify(
            newTargetLocation,
            null,
            2
          )}\n when navigating to "${
            to.fullPath
          }". A redirect must contain a name or path. This will break in production.`
        )
        throw new Error('Invalid redirect')
      }

      return assign(
        {
          query: to.query, // 继承原路由的 query 参数
          hash: to.hash, // 继承原路由的 hash 锚点
          // avoid transferring params if the redirect has a path
          // 重定向目标有 path → 清空 params;无 path(用 name 跳转)→ 继承原 params
          params: newTargetLocation.path != null ? {} : to.params,
        },
        newTargetLocation
      )
    }
  }

image.png

handleScroll

  // Scroll behavior
  function handleScroll(
    to: RouteLocationNormalizedLoaded, // 目标路由
    from: RouteLocationNormalizedLoaded, // 来源路由
    isPush: boolean, // 是否为 push 导航
    isFirstNavigation: boolean // 是否是应用首次导航(如页面初始化时的路由)
  ): // the return is not meant to be used
  Promise<unknown> {
  
    const { scrollBehavior } = options
    // 非浏览器环境(如SSR) 或 未配置 scrollBehavior → 直接返回成功 Promise
    if (!isBrowser || !scrollBehavior) return Promise.resolve()

    // 计算初始滚动位置(scrollPosition)
    const scrollPosition: _ScrollPositionNormalized | null =
      // 非 push 跳转(replace/后退)→ 读取保存的滚动位置
      (!isPush && getSavedScrollPosition(getScrollKey(to.fullPath, 0))) ||
      // 首次导航 或 非 push 跳转 → 读取 history.state 中的滚动位置
      ((isFirstNavigation || !isPush) &&
        (history.state as HistoryState) &&
        history.state.scroll) ||
      null // 其他情况 → 无滚动位置

    // 等待 DOM 更新完成后再执行滚动(路由跳转后组件渲染需要时间,避免滚动到未渲染的元素)
    return nextTick()
      // 调用用户配置的 scrollBehavior,获取目标滚动位置
      .then(() => scrollBehavior(to, from, scrollPosition))
      // 若返回了滚动位置,执行实际的滚动操作
      .then(position => position && scrollToPosition(position))
      // 捕获滚动过程中的错误,触发全局错误处理
      .catch(err => triggerError(err, to, from))
  }

scrollToPosition

最终调用原生 API window.scrollTo 实现。

export function scrollToPosition(position: ScrollPosition): void {
  let scrollToOptions: ScrollPositionCoordinates

  // 元素锚点型(包含 el 字段)
  if ('el' in position) {
    const positionEl = position.el
    const isIdSelector =
      typeof positionEl === 'string' && positionEl.startsWith('#')
    /**
     * `id`s can accept pretty much any characters, including CSS combinators
     * like `>` or `~`. It's still possible to retrieve elements using
     * `document.getElementById('~')` but it needs to be escaped when using
     * `document.querySelector('#\\~')` for it to be valid. The only
     * requirements for `id`s are them to be unique on the page and to not be
     * empty (`id=""`). Because of that, when passing an id selector, it should
     * be properly escaped for it to work with `querySelector`. We could check
     * for the id selector to be simple (no CSS combinators `+ >~`) but that
     * would make things inconsistent since they are valid characters for an
     * `id` but would need to be escaped when using `querySelector`, breaking
     * their usage and ending up in no selector returned. Selectors need to be
     * escaped:
     *
     * - `#1-thing` becomes `#\31 -thing`
     * - `#with~symbols` becomes `#with\\~symbols`
     *
     * - More information about  the topic can be found at
     *   https://mathiasbynens.be/notes/html5-id-class.
     * - Practical example: https://mathiasbynens.be/demo/html5-id
     */
    if (__DEV__ && typeof position.el === 'string') {
      // 场景1:是 ID 选择器但对应元素不存在,或不是 ID 选择器
      if (!isIdSelector || !document.getElementById(position.el.slice(1))) {
        try {
          const foundEl = document.querySelector(position.el)
          // 场景1.1:是 ID 选择器但通过 querySelector 找到了元素 → 警告(建议用 getElementById)
          if (isIdSelector && foundEl) {
            warn(
              `The selector "${position.el}" should be passed as "el: document.querySelector('${position.el}')" because it starts with "#".`
            )
            // return to avoid other warnings
            return
          }
        } catch (err) {
           // 场景1.2:选择器语法错误 → 警告(提示转义字符)
          warn(
            `The selector "${position.el}" is invalid. If you are using an id selector, make sure to escape it. You can find more information about escaping characters in selectors at https://mathiasbynens.be/notes/css-escapes or use CSS.escape (https://developer.mozilla.org/en-US/docs/Web/API/CSS/escape).`
          )
          // return to avoid other warnings
          return
        }
      }
    }

    // 查找目标 DOM 元素
    const el =
      typeof positionEl === 'string'
        ? isIdSelector
          ? document.getElementById(positionEl.slice(1)) // ID 选择器:直接用 getElementById
          : document.querySelector(positionEl)  // 其他选择器:用 querySelector
        : positionEl // 非字符串:直接使用传入的 HTMLElement

    // 元素不存在 → 开发环境警告并返回
    if (!el) {
      __DEV__ &&
        warn(
          `Couldn't find element using selector "${position.el}" returned by scrollBehavior.`
        )
      return
    }
    // 计算元素的滚动坐标
    scrollToOptions = getElementPosition(el, position)

    // 坐标型(直接使用)
  } else {
    scrollToOptions = position
  }

  // 浏览器支持平滑滚动(scrollBehavior API)
  // 判断浏览器是否支持 window.scrollTo 的配置项(如 { behavior: 'smooth' })
  if ('scrollBehavior' in document.documentElement.style)
    window.scrollTo(scrollToOptions)

  // 不支持平滑滚动 → 降级使用基础 scrollTo
  else {
    window.scrollTo(
      scrollToOptions.left != null ? scrollToOptions.left : window.scrollX,
      scrollToOptions.top != null ? scrollToOptions.top : window.scrollY
    )
  }
}

finalizeNavigation

  /**
   * - Cleans up any navigation guards
   * - Changes the url if necessary
   * - Calls the scrollBehavior
   */
  /**
   * 导航最终化
   * @param toLocation 目标路由
   * @param from 当前路由
   * @param isPush 是否为 push 导航
   * @param replace 是否为 replace 导航
   * @param data 导航状态数据
   * @returns
   */
  function finalizeNavigation(
    toLocation: RouteLocationNormalizedLoaded,
    from: RouteLocationNormalizedLoaded,
    isPush: boolean,
    replace?: boolean,
    data?: HistoryState
  ): NavigationFailure | void {
    // a more recent navigation took place
    // 校验导航是否被取消(并发导航冲突)
    const error = checkCanceledNavigation(toLocation, from)
    if (error) return error

    // only consider as push if it's not the first navigation
    // 判断是否为首次导航
    const isFirstNavigation = from === START_LOCATION_NORMALIZED
    const state: Partial<HistoryState> | null = !isBrowser ? {} : history.state

    // change URL only if the user did a push/replace and if it's not the initial navigation because
    // it's just reflecting the url
    // 仅在「主动 push 跳转」时更新 URL
    if (isPush) {
      // on the initial navigation, we want to reuse the scroll position from
      // history state if it exists
      // replace 模式 或 首次导航 → 使用 replaceState 更新 URL
      if (replace || isFirstNavigation)
        routerHistory.replace(
          toLocation.fullPath,
          assign(
            {
              scroll: isFirstNavigation && state && state.scroll,
            },
            data
          )
        )
        // 普通 push 跳转 → 使用 pushState 新增历史记录
      else routerHistory.push(toLocation.fullPath, data)
    }

    // accept current navigation
    // 更新响应式的当前路由 → 触发组件重新渲染
    currentRoute.value = toLocation 
    handleScroll(toLocation, from, isPush, isFirstNavigation) // 触发滚动

    markAsReady() // 标记就绪
  }

实例方法 router.resolve

router.resolve 是 Vue Router 提供的路由地址解析 API,用于将任意格式的路由地址(字符串 / 对象 / 命名路由)解析为标准化的 RouteLocationResolved 对象。

  /**
   * 路由地址解析器
   * @param rawLocation 原始路由地址(字符串或对象)
   * @param currentLocation 当前路由状态(可选)
   * @returns 解析后的路由地址对象
   */
  function resolve(
    rawLocation: RouteLocationRaw,
    currentLocation?: RouteLocationNormalizedLoaded
  ): RouteLocationResolved {
    // const resolve: Router['resolve'] = (rawLocation: RouteLocationRaw, currentLocation) => {
    // const objectLocation = routerLocationAsObject(rawLocation)
    // we create a copy to modify it later
    currentLocation = assign({}, currentLocation || currentRoute.value)

    // 解析字符串路由地址(包含 query/hash)
    if (typeof rawLocation === 'string') {
      const locationNormalized = parseURL(
        parseQuery,
        rawLocation,
        currentLocation.path
      )
      const matchedRoute = matcher.resolve(
        { path: locationNormalized.path },
        currentLocation
      )

      const href = routerHistory.createHref(locationNormalized.fullPath)
      if (__DEV__) {
        if (href.startsWith('//'))
          warn(
            `Location "${rawLocation}" resolved to "${href}". A resolved location cannot start with multiple slashes.`
          )
        else if (!matchedRoute.matched.length) {
          warn(`No match found for location with path "${rawLocation}"`)
        }
      }

      // locationNormalized is always a new object
      return assign(locationNormalized, matchedRoute, {
        params: decodeParams(matchedRoute.params),
        hash: decode(locationNormalized.hash),
        redirectedFrom: undefined,
        href,
      })
    }

    // 校验 rawLocation 是否为合法的路由对象(包含 path/name 至少其一)
    if (__DEV__ && !isRouteLocation(rawLocation)) {
      warn(
        `router.resolve() was passed an invalid location. This will fail in production.\n- Location:`,
        rawLocation
      )
      return resolve({})
    }

    let matcherLocation: MatcherLocationRaw

    // path could be relative in object as well
    // 解析对象路由地址(包含 path/params/query/hash)
    // 含 path 的对象路由
    if (rawLocation.path != null) {
      // 开发环境警告:path 与 params 混用(params 会被忽略)
      // path 与 params 不兼容:通过 path 跳转时,params 会被忽略(因 path 已包含参数,如 /user/1)
      if (
        __DEV__ &&
        'params' in rawLocation &&
        !('name' in rawLocation) &&
        // @ts-expect-error: the type is never
        Object.keys(rawLocation.params).length
      ) {
        warn(
          `Path "${rawLocation.path}" was passed with params but they will be ignored. Use a named route alongside params instead.`
        )
      }
      matcherLocation = assign({}, rawLocation, {
        path: parseURL(parseQuery, rawLocation.path, currentLocation.path).path,
      })

      // 解析命名路由地址(包含 name/params)
    } else {
      // remove any nullish param
      const targetParams = assign({}, rawLocation.params)
      for (const key in targetParams) {
         // 移除 null/undefined 的 params(避免匹配错误)
        if (targetParams[key] == null) {
          delete targetParams[key]
        }
      }
      // pass encoded values to the matcher, so it can produce encoded path and fullPath
      matcherLocation = assign({}, rawLocation, {
        params: encodeParams(targetParams),
      })
      // current location params are decoded, we need to encode them in case the
      // matcher merges the params
      currentLocation.params = encodeParams(currentLocation.params)
    }

    const matchedRoute = matcher.resolve(matcherLocation, currentLocation)
    const hash = rawLocation.hash || ''

    // 开发环境警告:hash 未以 # 开头
    if (__DEV__ && hash && !hash.startsWith('#')) {
      warn(
        `A \`hash\` should always start with the character "#". Replace "${hash}" with "#${hash}".`
      )
    }

    // the matcher might have merged current location params, so
    // we need to run the decoding again
    matchedRoute.params = normalizeParams(decodeParams(matchedRoute.params))

    // 生成 fullPath(合并 path/query/hash)
    const fullPath = stringifyURL(
      stringifyQuery,
      assign({}, rawLocation, {
        hash: encodeHash(hash),
        path: matchedRoute.path,
      })
    )

    const href = routerHistory.createHref(fullPath)
    if (__DEV__) {
      if (href.startsWith('//')) {
        warn(
          `Location "${rawLocation}" resolved to "${href}". A resolved location cannot start with multiple slashes.`
        )
      } else if (!matchedRoute.matched.length) {
        warn(
          `No match found for location with path "${
            rawLocation.path != null ? rawLocation.path : rawLocation
          }"`
        )
      }
    }

    return assign(
      {
        fullPath,
        // keep the hash encoded so fullPath is effectively path + encodedQuery +
        // hash
        hash,
        query:
          // if the user is using a custom query lib like qs, we might have
          // nested objects, so we keep the query as is, meaning it can contain
          // numbers at `$route.query`, but at the point, the user will have to
          // use their own type anyway.
          // https://github.com/vuejs/router/issues/328#issuecomment-649481567
          stringifyQuery === originalStringifyQuery
            ? normalizeQuery(rawLocation.query)
            : ((rawLocation.query || {}) as LocationQuery),
      },
      matchedRoute,
      {
        redirectedFrom: undefined,
        href,
      }
    )
  }
{
  path: '/dashboard',
  name: 'dashboard',
  component: () => import('@/views/dashboard/DashBoard.vue'),
  meta: {
    title: '看板',
    icon: 'dashboard',
    roles: ['admin', 'user']
  }
}

router.resolve 支持哪些输入格式?

  • 字符串格式(含绝对 / 相对路径、query/hash)。
  • 对象格式(path 模式),path 模式下传入 params 会被忽略(开发环境会警告)。
  • 对象格式(命名路由模式)。
// 解析 path 模式
console.log('router.resolve', router.resolve({
  path: '/dashboard'
}))

// 解析命名路由
console.log('router.resolve', router.resolve({
  name: 'dashboard'
}))

// 解析路径
console.log('router.resolve', router.resolve('/dashboard'))

image.png

实例方法 addRoute

  /**
   * 新增路由(支持嵌套)
   * 格式 1:addRoute(父路由名称, 子路由配置)
   * 格式 2:addRoute(路由配置)
   * @param parentOrRoute 父路由记录名或路由记录对象
   * @param route 子路由记录(可选)
   * @returns 移除路由的函数
   */
  function addRoute(
    parentOrRoute: NonNullable<RouteRecordNameGeneric> | RouteRecordRaw,
    route?: RouteRecordRaw
  ) {
    let parent: Parameters<(typeof matcher)['addRoute']>[1] | undefined
    let record: RouteRecordRaw

    // 判断第一个参数是否为「路由名称」(而非路由配置对象)
    if (isRouteName(parentOrRoute)) {
      // 根据路由名称从底层匹配器中获取对应的「路由记录匹配器」
      parent = matcher.getRecordMatcher(parentOrRoute)
      if (__DEV__ && !parent) {
        warn(
          `Parent route "${String(parentOrRoute)}" not found when adding child route`,
          route
        )
      }
      record = route!
    } else {
      record = parentOrRoute
    }

    return matcher.addRoute(record, parent)
  }

实例方法 removeRoute

  /**
   * 删除路由(根据路由记录名)
   * @param name 路由记录名称
   */
  function removeRoute(name: NonNullable<RouteRecordNameGeneric>) {
    const recordMatcher = matcher.getRecordMatcher(name)
    if (recordMatcher) {
      matcher.removeRoute(recordMatcher)
    } else if (__DEV__) {
      warn(`Cannot remove non-existent route "${String(name)}"`)
    }
  }

实例方法 getRoutes

  /**
   * 获取所有路由记录
   * @returns
   */
  function getRoutes() {
    return matcher.getRoutes().map(routeMatcher => routeMatcher.record)
  }

实例方法 hasRoute

  /**
   * 判断路由是否存在
   * @param name
   * @returns
   */
  function hasRoute(name: NonNullable<RouteRecordNameGeneric>): boolean {
    return !!matcher.getRecordMatcher(name)
  }

vue-router 是如何安装的?

router 实例的 install 是一个函数,vue 利用 vue 实例 app app.use(router) 引入 vue-router 。

image.png

vue-router 全局路由守卫有哪些?

image.png

beforeEach(guard: NavigationGuardWithThis<undefined>): () => void
beforeResolve(guard: _NavigationGuardResolved): () => void
afterEach(guard: NavigationHookAfter): () => void

v5 版本,已废弃 next() 写法,建议使用 return 返回替代。

// 已废弃写法
// 全局前置守卫
router.beforeEach((to, from, next) => {
  console.log('router.beforeEach-to', to)
  console.log('router.beforeEach-from', from)
  next()
})

// 全局解析守卫
router.beforeResolve((to, from, next) => {
  console.log('router.beforeResolve-to', to)
  console.log('router.beforeResolve-from', from)
  next()
})

image.png

// 建议写法
// 全局前置守卫
router.beforeEach((to, from) => {
  console.log('router.beforeEach-to', to)
  console.log('router.beforeEach-from', from)
  return true
})

// 全局解析守卫
router.beforeResolve((to, from) => {
  console.log('router.beforeResolve-to', to)
  console.log('router.beforeResolve-from', from)
  return true
})
export interface NavigationGuardWithThis<T> {
  (
    this: T,
    to: RouteLocationNormalized, // 目标路由对象
    from: RouteLocationNormalizedLoaded, // 来源路由对象
    /**
     * @deprecated Return a value from the guard instead of calling `next(value)`.
     * The callback will be removed in a future version of Vue Router.
     * 未来版本将移除对 `next(value)` 的调用,建议直接返回值。
     */
    next: NavigationGuardNext // 导航守卫回调函数
  ): _Awaitable<NavigationGuardReturn>
}

export interface _NavigationGuardResolved {
  (
    this: undefined,
    to: RouteLocationNormalizedLoaded,
    from: RouteLocationNormalizedLoaded,
    /**
     * @deprecated Return a value from the guard instead of calling `next(value)`.
     * The callback will be removed in a future version of Vue Router.
     */
    next: NavigationGuardNext
  ): _Awaitable<NavigationGuardReturn>
}

export interface NavigationHookAfter {
  (
    to: RouteLocationNormalizedLoaded,
    from: RouteLocationNormalizedLoaded,
    failure?: NavigationFailure | void
  ): unknown
}

router.push 接收参数的 3 种方式

/**
 * Route location that can be passed to `router.push()` and other user-facing APIs.
 */
export type RouteLocationRaw<Name extends keyof RouteMap = keyof RouteMap> =
  RouteMapGeneric extends RouteMap
    ?
        | RouteLocationAsString // 字符串路径(如 "/home")
        | RouteLocationAsRelativeGeneric // 命名路由泛型对象(如 { name: 'Home' })
        | RouteLocationAsPathGeneric  // 路径对象泛型(如 { path: '/home' })
    : // 强类型约束(开启 TS 强校验)
        | _LiteralUnion<RouteLocationAsStringTypedList<RouteMap>[Name], string>
        | RouteLocationAsRelativeTypedList<RouteMap>[Name]
        | RouteLocationAsPathTypedList<RouteMap>[Name]
const handleClick = () => {
  // 命名路由
  router.push({
    name: "user-list",
  });
};

const handleClick2 = () => {
  // 对象路由(path模式)
  router.push({
    path: "/user/123",
  });
};

const handleClick3 = () => {
  // 字符路由
  router.push("/data-view");
};

最后

  1. 源码阅读:github.com/hannah-lin-…

《Vue3 watch详情:deep/immediate/flush/once 全用法 + 踩坑总结》

作者 cmd
2026年3月18日 12:13

本文全面解析 Vue3 watch 所有用法,包含监听基础类型、引用类型、多个数据源、停止监听、深度监听、新旧值获取、与 watchEffect 区别,适合前端开发日常使用与面试准备。

《Vue3 watch详情:deep/immediate/flush/once 全用法 + 踩坑总结》

1. API介绍

watch(WatcherSource, Callback, [WatchOptions])

type WatcherSource<T> = Ref<T> | (() => T) 

interface WatchOptions extends WatchEffectOptions {
    deep?: boolean // 默认:false 
    immediate?: boolean // 默认:false 
    flush?: string // 默认:'pre'
}

参数说明:

WatcherSource: 用于指定要侦听的响应式数据源。侦听器数据源可以是返回值的 getter 函数,可以直接 是 ref reactive

callback : 执行的回调函数,可依次接受 newValue , oldValue 作为参数。

watchOptions: deep immediate flush once(3.4新增) 可选

  • 当需要对响应式对象进行深度监听时,设置 deep: true

  • 默认情况下watch是惰性的,当我们设置 immediate: true 时,watch会在初始化时立即执行回调函数

  • flush 选项可以更好地控制回调的时间。它可设置为 pre、post 或 sync

    • 默认值是 pre,指定的回调应该在DOM渲染前被调用。
    • post 值是可以用来将回调推迟到DOM渲染之后的。如果回调需要通过 $refs 访问更新的 DOM 或子组件,那么则使用该值。
    • 如果 flush 被设置为 sync,一旦值发生了变化,回调将被同步调用(少用,影响性能)。
  • once: true : 一次性侦听器;只生效一次(3.4新增参数)

WatchSource必须是引用对象;因此它的写法有两种;

  • 如果是响应式的引用对象,如ref,reactive; 直接写变量名即可;
  • 如果是基础数据,需要使用getter函数;

getter函数的使用除了上面的情况还有一个就是获取引用对象新旧值的时候会用到;

2. 侦听单个数据源及停止侦听

<script setup>
  import { watch, ref, reactive } from 'vue'
  // 侦听一个 getter
  const person = reactive({name: '小松菜奈'})
  watch(
    () => person.name,
    (value, oldValue) => {
      console.log(value, oldValue)
    }, {immediate:true}
  )
  person.name = '有村架纯'

  // 直接侦听ref  停止侦听
  const ageRef = ref(16)
  const stopAgeWatcher = watch(ageRef, (value, oldValue) => {
    console.log(value, oldValue)
    if (value > 18) {
      stopAgeWatcher() // 当ageRef大于18,停止侦听
    }
  })

  const changeAge = () => {
    ageRef.value += 1
  }
</script>

现象

配置了immediate:truewatch,在初始化时触发了一次watch的回调。我们连续点击增加年龄,当年龄 的当前值大于18时,watch停止了侦听。

结论

侦听器数据源可以是返回值的 getter 函数,也可以直接是 refwatch函数是有返回值的,返回值是停止器,然后通 过执行停止器() 函数来停止侦听。

3. 监听多个数据源

<script setup>
  import {ref, watch, nextTick} from 'vue'

  const name = ref('小松菜奈')
  const age = ref(25)

  watch([name, age], ([name, age], [prevName, prevAge]) => {
    console.log('newName', name, 'oldName', prevName)
    console.log('newAge', age, 'oldAge', prevAge)
  })

  // 如果你在同一个函数里同时改变这些被侦听的来源,侦听器只会执行一次
  const change1 = () => {
    name.value = '有村架纯'
    age.value += 2
  }

  // 用 nextTick 等待侦听器在下一步改变之前运行,侦听器执行了两次
  const change2 = async () => {
    name.value = '新垣结衣'
    await nextTick()
    age.value += 2
  }
</script>

现象

以上,当我们在同一个函数里同时改变nameage两个侦听源,watch的回调函数只触发了一次;当我们 在nameage的改变之间增加了一个nextTickwatch回调函数触发了两次。

结论

我们可以通过watch侦听多个数据源的变化。如果在同一个函数里同时改变这些被侦听的来源,侦听器只会 执行一次。若要使侦听器执行多次,我们可以利用 nextTick ,等待侦听器在下一步改变之前运行。

4. 侦听引用对象

<template>
  <div>
    <div>ref定义数组:{{arrayRef}}</div>
    <div>reactive定义数组:{{arrayReactive}}</div>
  </div>
  <div>
    <button @click="changeArrayRef">改变ref定义数组第一项</button>
    <button @click="changeArrayReactive">改变reactive定义数组第一项</button>
  </div>
</template>

<script setup>
  import {ref, reactive, watch} from 'vue'

  const arrayRef = ref([1, 2, 3, 4])
  const arrayReactive = reactive([1, 2, 3, 4])

  // ref not deep, 不能深度侦听
  const arrayRefWatch = watch(arrayRef, (newValue, oldValue) => {
    console.log('newArrayRefWatch', newValue, 'oldArrayRefWatch', oldValue)
  })

  // ref deep, 深度侦听,新旧值一样
  const arrayRefDeepWatch = watch(arrayRef, (newValue, oldValue) => {
    console.log('newArrayRefDeepWatch', newValue, 'oldArrayRefDeepWatch', oldValue)
  }, {deep: true})

  // ref deep, getter形式 , 新旧值不一样
  const arrayRefDeepGetterWatch = watch(() => [...arrayRef.value], (newValue, oldValue) => {
    console.log('newArrayRefDeepGetterWatch', newValue, 'oldArrayRefDeepGetterWatch', oldValue)
  })

  // reactive,默认深度监听,可以不设置deep:true, 新旧值一样
  const arrayReactiveWatch = watch(arrayReactive, (newValue, oldValue) => {
    console.log('newArrayReactiveWatch', newValue, 'oldArrayReactiveWatch', oldValue)
  })

  // reactive,getter形式 , 新旧值不一样
  const arrayReactiveGetterWatch = watch(() => [...arrayReactive], (newValue, oldValue) => {
    console.log('newArrayReactiveFuncWatch', newValue, 'oldArrayReactiveFuncWatch', oldValue)
  })

  const changeArrayRef = () => {
    arrayRef.value[0] = 3
  }
  const changeArrayReactive = () => {
    arrayReactive[0] = 6
  }
</script>

现象

  • 当将引用对象采用ref形式定义时,如果不加上deep:true watch侦听不到值的变化的;而加 deep:truewatch可以侦听到数据的变化,但是当前值和先前值一样,即不能获取旧值。
  • 当将引用对象采用 reactive形式定义时,不作任何处理,watch可以侦听到数据的变化,但是当前值和旧值一样。
  • 两种定义下,把watch的数据源写成getter函数的形式并进行深拷贝返回,可以在watch回调中同时获得当前值和旧值。
    const objReactive = reactive({user: {name: 'aa', age: '18'}, brand: 'Channel'});
    
    /** 对象深度监听的最佳实践- reactive且源采用函数式返回,返回深拷贝后的数据 */
    watch(() => _.cloneDeep(objReactive), (newVal, oldVal) => {
      console.log('newVal', newVal);
      console.log('oldVal', oldVal);
    })

结论: 当我们使用watch侦听引用对象时

  • 若使用ref定义的引用对象:
    • 只要获取当前值,watch第一个参数直接写成数据源,另外需要加上deep:true选项
    • 若要获取当前值和旧值,需要把数据源写成getter函数的形式,并且需对数据源进行深拷贝
  • 若使用 reactive定义的引用对象:
    • 只要获取当前值,watch第一个参数直接写成数据源,可以不加deep:true选项
    • 若要获取当前值和旧值,需要把数据源写成getter函数的形式,并且需对数据源进行深拷贝

5. watchEffect

watchEffect(callback, options): 只有两个参数,第一个是回调函数,第二个是配置项,配置项参数与watch一样;

watchEffect会立即执行,不像watch是惰性的;当然也可以通过watch配置项加{immediate: true}实现;

const number = reactive({ count: 0 });
const countAdd = () => {
  number.count++;
};
watchEffect(()=>{
  console.log("新的值:", number.count);
})

TIP

watchEffect 仅会在其同步执行期间,才追踪依赖。在使用异步回调时,只有在第一个 await 正常工作前访问到的属性才会被追踪。

以便dom更新之后运行watchEffect, 有个简单写法

watchPostEffect(() => {
  /* 在 Vue 更新后执行 */
})

6. watch, watchEffect的区别

  1. watchwatchEffect 都能监听响应式数据的变化,不同的是它们监听数据变化的方式不同。
  2. watch 会明确监听某一个响应数据,而 watchEffect则是隐式的监听回调函数中响应数据。
  3. watch 在响应数据初始化时是不会执行回调函数的,watchEffect 在响应数据初始化时就会立即执行回调函数。

7. FAQ

通常来说,我们的一个组件被销毁或者卸载后,监听器也会跟着被停止,并不需要我们手动去关闭监听器。但是总是有一些特殊情况,即使组件卸载了,但是监听器依然存在,这个时候其实式需要我们手动关闭它的,否则容易造成内存泄漏。

比如下面这中写法,我们就需要手动停止监听器:

<script setup>
import { watchEffect } from 'vue'
// 它会自动停止
watchEffect(() => {})
// ...这个则不会!
setTimeout(() => {
  watchEffect(() => {})
}, 100)
</script>

上段代码中我们采用异步的方式创建了一个监听器,这个时候监听器没有与当前组件绑定,所以即使组件销毁了,监听器依然存在。

关闭方法很简单,代码如下:

const unwatch = watchEffect(() => {})
// ...当该侦听器不再需要时
unwatch()

感谢您抽出宝贵的时间观看本文;本文是 Vue3 核心 API 系列的第 1 篇,后续会持续更新 computed、ref/reactive、生命周期等实战内容,同时正在整理「Vue3 完整项目实战小册」(包含从 0 到 1 开发小程序 / 管理系统的全流程),欢迎关注~

Vue 组件设计优化:别把控制显隐的 v-if 藏在子组件里

2026年3月18日 09:38

前言

在大型 Vue 项目的业务迭代中,我们经常会遇到这样的场景:一个详情页底部挂载着各种功能按钮(如:拉黑、不感兴趣、举报、反馈)。这些按钮的显示与否,往往取决于详情接口返回的各种权限位。

很多开发者的第一反应是: “把逻辑封装进子组件,传一个权限标识进去不就行了?”

看起来很优雅,但实际上,这种做法隐藏着巨大的性能浪费和维护风险。今天,我们通过一次真实的业务重构,聊聊 Vue 组件设计中的 “逻辑层级对齐”“单事实来源(SSOT)” 原则。

一、 滥用 Prop 传值的“多此一举”

假设我们有一个“拉黑权限” blackFlag,有些同学的组件化是这样的:

父组件:

<!-- 父组件拿到了权限,却把结论传给子组件,让子组件自己去藏起来 -->
<Child :black-flag="blackFlag" />

子组件:

<template>
  <div v-if="blackFlag">
    <!-- 业务代码 -->
  </div>
</template>
<script>
export default {
  props: { blackFlag: Boolean }
}
</script>

这种写法的三个“槽点”:

  1. 冗余的状态同步:关于“是否展示”这个事实,被父子组件同时观察,如果子组件内部不小心修改了对这个 Prop 的理解,或者在 computed 里又包了一层逻辑,就会出现“父组件想关,子组件关不掉”的情况。
  2. 多余的生命周期:哪怕 blackFlag 是 false,子组件也会被实例化,执行 data 初始化和生命周期钩子,白白占用内存。
  3. 脆弱的健壮性:如果子组件在 created 里注册了全局滚动监听或定时器,即便 UI 隐藏了,后台逻辑依然会“僵尸式”运行,极易引发内存泄漏或不可控的 Bug。

二、 健壮性原则:逻辑在哪里,控制就在哪里

重构的核心思路:让父组件决定子组件的“生死”,而不是“显隐”。

既然权限逻辑(接口请求过程)是在父组件完成的,那么父组件就应该掌握子组件的挂载权。

重构后的父组件:

<!-- 权限不满足,子组件压根不会生效 -->
<Child v-if="blackFlag" />

重构后的子组件:

<template>
  <div>
    <!-- 只需要关注业务:既然我被创建了,我就一定要展示 -->
  </div>
</template>
<script>
export default {
  // 删掉冗余的 blackFlag Prop
}
</script>

这样做带来的“绝对健壮性”:

由于 v-if 的 惰性(Lazy) 特质,当条件为假时,Vue 会确保子组件内部的任何 JS 逻辑、观察者(Watchers)、事件监听、甚至子组件的子组件都不会运行。这从物理层面上切断了任何潜在副作用产生的可能。

三、 进阶思考:代码加载了吗?

有些同学会问: “如果 v-if 把组件隐藏了,子组件的 JS 代码还会被浏览器加载吗?”

这需要分情况讨论:

情况 A:静态引入 (Static Import)

import Child from './Child.vue'; 
  • 加载情况:代码会被打包进主 Chunk。浏览器打开页面时代码已下载,但在内存中处于“静默”状态,不执行、不实例化

情况 B:动态引入 (Lazy Loading)

components: { 
  Child: () => import('./Child.vue') 
}
  • 加载情况:高能预警!如果 v-if 条件一开始为 false,浏览器连这个 JS 文件都不会去下载。只有条件变为 true 的瞬间,才会触发网络请求拉取 Chunk。

结论:结合 v-if 和动态引入,你可以实现极致的性能优化——不仅节省内存和执行开销,连用户的网络带宽都省了。

四、 颗粒度权衡:如何选择组件策略?

并不是所有 v-if 都要往上提。我们可以根据 “逻辑归属权” 来快速站队:

  1. 外部决策型逻辑:如“当前用户是否有权限”、“是否从特定入口进入”。
    • 策略:逻辑留父组件,v-if 挂在标签上。
  1. 自我决策型逻辑:如“该组件需要实时轮询一个接口来决定自己变不变红”。
    • 策略:逻辑搬进子组件,获取逻辑与 v-if 共同留在子组件根节点。

五、结语

一个好的工程师,不应该只关注组件长什么样,更应该关注 “组件的生命周期从哪里开始,到哪里结束”

遵循 “谁管理状态,谁控制显隐” 的原则,能让你的代码告别脆弱的 Prop 同步,走向真正的健壮与高性能。

最新版vue3+TypeScript开发入门到实战教程之watch详解

作者 angerdream
2026年3月17日 16:43

1、watch概述

watch本意是监视、观察。它的功能就是监视数据的变化。数据一旦变化,就会产生两种数据:新数据、旧数据。 如业务场景中,当订单量大多某个数时,就发放优惠卷。watch非常重要,掌握好响应式数据、computed、watch,vue写功能不会有太大问题。 在vue官网明确表达,watch可以监视以下四种数据:

  • ref定义的数据
  • reactive定义的数据
  • 函数返回一个值(getter函数
  • 包含上诉三种值的数组

2、监视ref定义的基本类型数据

  • 创建组件Fish
  • 引入ref、watch
  • 创建响应式数据name、price
  • watch函数监视price变化
  • 当price超过10,watch停止监视price变化
<template>
  <h2>鱼类:{{ name }}</h2>
  <h2>价格:{{ price }}</h2>
  <button @click="addPrice()">增加价格</button>
</template>
<script setup>
import { ref, watch } from 'vue'
let name = ref('鲫鱼');
let price = ref(5);
function addPrice() {
  price.value += 1;
}
let stopWatchPrice = watch(price, (newValue, oldValue) => {
  console.log(newValue, oldValue);
  if (newValue > 10) {
    console.log(stopWatchPrice);
    stopWatchPrice.stop();
  }
})
</script>

运行效果事例: 在这里插入图片描述 注意watch函数,监视的是price,而不是price.value。当点击按钮,price超过10,虽然数据在增加,但不再监视price。watch函数返回对象,有stop函数,调用此函数,即可解除监视。控制台打印,其结构如下:

() => {
    effect2.stop();
    if (scope && scope.active) {
      remove(scope.effects, effect2);
    }
  }

3、监视ref定义的对象类型数据

监视对象类型的数据,与基础类型的数据不同。当对象中的数据变化时,是无法监视到,但当整个数据改变时,是可以监视的。特点如下:

  • 创建组件Fish,引入ref、watch
  • 创建响应式对象fish,let fish = ref({ name: '鲫鱼', price: 5 });
  • 当改变fish.name值时,无法监视fish的变化
  • 当改变fish.price值时,无法监视fish的变化
  • 当改变整条鱼时,能够监视fish变化
<template>
  <h2>鱼类:{{ fish.name }}</h2>
  <h2>价格:{{ fish.price }}</h2>
  <button @click="changeName()">修改鱼类</button>
  <button @click="changePrice()">修改鱼价</button>
  <button @click="changeFish()">更换真个鱼</button>
</template>
<script setup>
import { ref, watch } from 'vue'
let fish = ref({ name: '鲫鱼', price: 5 });

function changeName() {
  fish.value.name += '~';
}
function changePrice() {
  fish.value.price += 1;
}
function changeFish() {
  fish.value = { name: '鲤鱼', price: 10 };
}
watch(fish, (newValue, oldValue) => {
  console.log(newValue, oldValue);
})

运行效果如下: 在这里插入图片描述 当修改响应式对象成员变量时,不会引起fish watch函数运行。原因在于watch监视的不是fish.name而是fish。那么如何才能监视fish.namefish.price数据变化呢? watch函数,它有三个参数

  • 一是监视对象
  • 二是监视回调函数,
  • 三是配置对象参数,如deep等等 只有在配置对象开启deep即可。
<template>
  <h2>鱼类:{{ fish.name }}</h2>
  <h2>价格:{{ fish.price }}</h2>
  <button @click="changeName()">修改鱼类</button>
  <button @click="changePrice()">修改鱼价</button>
  <button @click="changeFish()">更换真个鱼</button>
</template>
<script setup>
import { ref, watch } from 'vue'
let fish = ref({ name: '鲫鱼', price: 5 });

function changeName() {
  fish.value.name += '~';
}
function changePrice() {
  fish.value.price += 1;
}
function changeFish() {
  fish.value = { name: '鲤鱼', price: 10 };
}
watch(fish, (newValue, oldValue) => {
  console.log(newValue, oldValue);
}, { deep: true })
</script>

运行效果,仔细观看控制台打印的新数据、旧数据。

  • fish.name改变时,新旧数据一样
  • fish.price改变时,新旧数据一样
  • 当fish整个改变时,新旧数据不一样 效果如图: 在这里插入图片描述 注意fish.namefish.price,新旧数据是一样的。因为watch是从对象地址取到的数据。

4、watch监视函数返回一个值(getter函数)

它的功能是wath监视响应式对象中一个属性,如监视fish.name,是不允许直接监视,需要写成一个函数的形式。

  • 创建组件Fish,引入reactive, watch
  • 创建响应式对象fish,鱼的名字,鱼的体型:长度、重量
  • 分别监听鱼的名字与体型
  • 点击按钮修改鱼类,鱼的长度、鱼的重量、鱼的体型
<template>
  <h2>鱼类:{{ fish.name }}</h2>
  <h2>鱼长度:{{ fish.body.long }}</h2>
  <h2>鱼重量:{{ fish.body.weight }}</h2>
  <button @click="changeName()">修改鱼类</button>
  <button @click="changeFishLong()">修改鱼的长度</button>
  <button @click="changeFishWeight()">修改鱼的重量</button>
  <button @click="changeFishbody()">修改鱼的体型</button>
</template>
<script setup>
import { reactive, watch } from 'vue'
let fish = reactive({ name: '鲫鱼', body: { long: 1, weight: 24 } });

function changeName() {
  fish.name += '~';
}
function changeFishLong() {
  fish.body.long += 1;
}
function changeFishWeight() {
  fish.body.weight += 1;
}
function changeFishbody() {
  fish.body = { long: 100, weight: 300 };
}
watch(() => { return fish.name }, (newValue, oldValue) => {
  console.log('监听fish.name', newValue, oldValue);
})
watch(() => { return fish.body }, (newValue, oldValue) => {
  console.log('监听fish.body', newValue, oldValue);
})
</script>

监听响应式对象中的参数,需要写成一个箭头函数,并返回监听参数即可。具体操作,看下图: 在这里插入图片描述 当点击按钮,发现只有修改鱼类、修改鱼的体型,才能监听到变化。这是因为watch监听的地址。若想要能够监听到鱼的长度、鱼的重量,需要再watch加入deep参数即可。

watch(() => { return fish.body }, (newValue, oldValue) => {
  console.log('监听fish.body', newValue, oldValue);
}, { deep: true })

在这里插入图片描述 注意点击按钮修改鱼的长度、修改鱼的重量,新旧数据是一致的。

5、watch监视含有响应式对象数组的数据

watch监视的对象是一个数组,数组内可以是ref定义基本类型数据,也可是对象,可以是函数。

watch([() => { return fish.name },() => { return fish.body }], (newValue, oldValue) => {
  console.log('监听素组', newValue, oldValue);
}, { deep: true })

由于使用配置参数deep,操作效果如下: 在这里插入图片描述

具体代码

<template>
  <h2>鱼类:{{ fish.name }}</h2>
  <h2>鱼长度:{{ fish.body.long }}</h2>
  <h2>鱼重量:{{ fish.body.weight }}</h2>
  <button @click="changeName()">修改鱼类</button>
  <button @click="changeFishLong()">修改鱼的长度</button>
  <button @click="changeFishWeight()">修改鱼的重量</button>
  <button @click="changeFishbody()">修改鱼的体型</button>
</template>
<script setup>
import { reactive, watch } from 'vue'
let fish = reactive({ name: '鲫鱼', body: { long: 1, weight: 24 } });

function changeName() {
  fish.name += '~';
}
function changeFishLong() {
  fish.body.long += 1;
}
function changeFishWeight() {
  fish.body.weight += 1;
}
function changeFishbody() {
  fish.body = { long: 100, weight: 300 };
}
watch([() => { return fish.name },() => { return fish.body }], (newValue, oldValue) => {
  console.log('监听素组', newValue, oldValue);
}, { deep: true })
</script>

6、总结 watch可以监视四种数据,再加上配置函数,内容多且难记。在项目中多练习几次就能熟记。

  • ref定义的数据
  • reactive定义的数据
  • 函数返回一个值(getter函数
  • 包含上诉三种值的数组 在现实开发中,第一种和第三种情况最常用。尤其第三种情况,加函数,加配置参数deep。属于重中之重。
watch(() => { return fish.body }, (newValue, oldValue) => {
  console.log('监听fish.body', newValue, oldValue);
}, { deep: true })

Vue2 vs Vue3 全面对比(含代码示例+迁移指南)

作者 远山枫谷
2026年3月17日 14:16

Vue2 vs Vue3 全面对比(含代码示例+迁移指南)

作为前端开发者,Vue框架的升级迭代一直是我们关注的重点。从2019年Vue3发布beta版,到如今Vue3成为新项目的首选,两者之间的差异不仅体现在底层实现,更贯穿了开发流程的方方面面。今天我们就来全面拆解Vue2与Vue3的核心区别,结合代码示例帮你快速吃透差异,轻松应对项目迁移与开发选型。

本文将从「核心架构」「响应式原理」「语法特性」「性能优化」「生态工具」「迁移实践」6大维度展开,覆盖日常开发中90%以上会遇到的差异点,新手可快速入门,老开发者可查漏补缺。

一、核心架构:Options API vs Composition API

这是Vue2与Vue3最本质的区别,核心在于「代码组织方式」的不同——Vue2采用Options API(选项式API),Vue3引入Composition API(组合式API),同时兼容Options API,兼顾老项目迁移与新项目开发。

1. Vue2:Options API

Options API通过「选项」划分代码逻辑,将组件的逻辑拆分为data、methods、computed、watch、生命周期钩子等选项,结构固定,入门门槛低,但在复杂组件中会出现「逻辑分散」的问题。

比如一个包含数据请求、表单校验、状态管理的复杂组件,相关逻辑会分散在data、methods、mounted等不同选项中,后期维护时需要在多个选项间来回切换,可读性和可复用性较差。

<script>
// Vue2 Options API 示例
export default {
  // 数据
  data() {
    return {
      userInfo: null,
      loading: false,
      error: ''
    }
  },
  // 计算属性
  computed: {
    isUserLoaded() {
      return !!this.userInfo
    }
  },
  // 生命周期钩子
  mounted() {
    this.getUserInfo()
  },
  // 方法
  methods: {
    async getUserInfo() {
      this.loading = true
      try {
        const res = await fetch('/api/user')
        this.userInfo = await res.json()
      } catch (err) {
        this.error = err.message
      } finally {
        this.loading = false
      }
    }
  }
}
</script>

2. Vue3:Composition API

Composition API以「功能」为核心,通过组合函数(Composable)将相关逻辑聚合在一起,打破了Options API的选项限制,让代码组织更灵活,尤其适合复杂组件和逻辑复用。

Vue3中可以通过setup函数(或

<script setup>
// Vue3 Composition API 示例(<script setup>语法糖,推荐)
import { ref, computed, onMounted } from 'vue'

// 1. 定义响应式数据(替代data)
const userInfo = ref(null)
const loading = ref(false)
const error = ref('')

// 2. 计算属性(替代computed)
const isUserLoaded = computed(() => !!userInfo.value)

// 3. 逻辑抽离(可单独抽离为组合函数,供其他组件复用)
const getUserInfo = async () => {
  loading.value = true
  try {
    const res = await fetch('/api/user')
    userInfo.value = await res.json()
  } catch (err) {
    error.value = err.message
  } finally {
    loading.value = false
  }
}

// 4. 生命周期钩子(替代mounted)
onMounted(() => {
  getUserInfo()
})
</script>

核心差异总结

维度 Vue2(Options API) Vue3(Composition API)
代码组织 按选项划分(data、methods等) 按功能聚合(组合函数)
逻辑复用 依赖mixins,易出现命名冲突 组合函数,无命名冲突,复用性更强
复杂组件 逻辑分散,维护困难 逻辑聚合,可读性高
入门难度 低,结构固定 稍高,需理解组合逻辑

二、响应式原理:Object.defineProperty vs Proxy

响应式是Vue的核心特性,Vue2和Vue3的响应式实现方式完全不同,这也是Vue3性能提升的关键原因之一。两者的核心差异在于「数据劫持的方式」,Vue2基于Object.defineProperty,Vue3基于Proxy+Reflect,后者从根本上解决了前者的诸多局限性。

1. Vue2:Object.defineProperty

Vue2通过Object.defineProperty劫持对象的getter和setter方法,实现对数据变化的监听。但这种方式存在3个明显的局限性,也是开发中常遇到的痛点:

  • 无法监听对象新增/删除的属性(需通过Vue.set、Vue.delete手动触发响应);
  • 无法监听数组的索引变化和长度变化(需重写数组方法,如push、splice等);
  • 只能劫持对象的属性,无法直接劫持整个对象,初始化时需递归遍历对象所有属性,性能开销较大。
// Vue2 响应式核心实现(简化版)
function defineReactive(obj, key, value) {
  // 递归监听嵌套对象
  if (typeof value === 'object' && value !== null) {
    observe(value)
  }
  Object.defineProperty(obj, key, {
    get() {
      // 依赖收集
      track(obj, key)
      return value
    },
    set(newValue) {
      if (newValue === value) return
      value = newValue
      // 触发更新
      trigger(obj, key)
    }
  })
}

// 监听对象
function observe(obj) {
  if (typeof obj !== 'object' || obj === null) return
  Object.keys(obj).forEach(key => {
    defineReactive(obj, key, obj[key])
  })
}

// 痛点示例:新增属性无法监听
const obj = { name: 'Vue2' }
observe(obj)
obj.age = 3 // 新增属性,无法触发响应式更新
Vue.set(obj, 'age', 3) // 需手动调用Vue.set

2. Vue3:Proxy + Reflect

Vue3放弃了Object.defineProperty,转而使用ES6新增的Proxy(代理)和Reflect(反射),从根本上解决了Vue2的局限性。Proxy可以直接代理整个对象,而非单个属性,同时支持监听对象的所有操作(新增、删除、数组变化等),且无需递归遍历,性能更优。

Reflect则与Proxy相辅相成,提供了一套用于操作对象的方法集合,能更优雅地处理代理过程中的对象操作,比如自动传递this上下文、统一返回操作结果等,让代码更健壮。

// Vue3 响应式核心实现(简化版)
function reactive(obj) {
  return new Proxy(obj, {
    // 读取属性时触发
    get(target, key, receiver) {
      const result = Reflect.get(target, key, receiver)
      // 依赖收集
      track(target, key)
      // 懒代理:嵌套对象访问时才创建代理,减少初始化性能开销
      if (typeof result === 'object' && result !== null) {
        return reactive(result)
      }
      return result
    },
    // 设置属性时触发
    set(target, key, value, receiver) {
      const oldValue = Reflect.get(target, key, receiver)
      const result = Reflect.set(target, key, value, receiver)
      if (oldValue !== value) {
        // 触发更新
        trigger(target, key)
      }
      return result
    },
    // 删除属性时触发
    deleteProperty(target, key) {
      const result = Reflect.deleteProperty(target, key)
      // 触发更新
      trigger(target, key)
      return result
    }
  })
}

// 优势示例:自动监听新增/删除属性、数组变化
const obj = reactive({ name: 'Vue3' })
obj.age = 1 // 新增属性,自动触发响应
delete obj.name // 删除属性,自动触发响应

const arr = reactive([1, 2, 3])
arr.push(4) // 数组操作,自动触发响应
arr[0] = 0 // 数组索引修改,自动触发响应

响应式差异总结

特性 Vue2(Object.defineProperty) Vue3(Proxy+Reflect)
对象新增属性 不支持,需手动调用Vue.set 支持,自动监听
对象删除属性 不支持,需手动调用Vue.delete 支持,自动监听
数组索引/长度变化 不支持,需使用重写方法 支持,自动监听
嵌套对象监听 初始化时递归遍历,性能差 懒代理,访问时才监听,性能优
数据类型支持 仅支持对象/数组 支持对象、数组、Map、Set等

三、生命周期钩子:命名调整与使用方式变化

Vue3的生命周期钩子基本沿用了Vue2的逻辑,但进行了部分命名调整,同时适配Composition API的使用方式,新增了setup钩子(Composition API的入口),废弃了部分钩子。

1. 生命周期钩子对应关系

Vue2(Options API) Vue3(Options API) Vue3(Composition API,需导入)
beforeCreate beforeCreate(兼容) setup(替代,执行时机更早)
created created(兼容) setup(替代)
beforeMount beforeMount(兼容) onBeforeMount
mounted mounted(兼容) onMounted
beforeUpdate beforeUpdate(兼容) onBeforeUpdate
updated updated(兼容) onUpdated
beforeDestroy beforeUnmount(重命名) onBeforeUnmount
destroyed unmounted(重命名) onUnmounted
activated activated(兼容) onActivated
deactivated deactivated(兼容) onDeactivated

2. 核心变化说明

  • setup钩子:替代beforeCreate和created,是Composition API的入口,执行时机在beforeCreate之前,此时组件实例尚未创建,无法访问this(Vue3中Composition API不依赖this);
  • 钩子重命名:beforeDestroy → beforeUnmount,destroyed → unmounted,更贴合语义(组件卸载而非销毁);
  • Composition API中使用钩子:需从vue中导入对应的钩子函数,然后在setup中调用,支持多次调用(按调用顺序执行)。
<script setup>
// Vue3 Composition API 生命周期使用示例
import { onMounted, onBeforeUnmount, onUpdated } from 'vue'

// 组件挂载后执行
onMounted(() => {
  console.log('组件挂载完成')
})

// 组件更新前执行
onUpdated(() => {
  console.log('组件更新完成')
})

// 组件卸载前执行
onBeforeUnmount(() => {
  console.log('组件即将卸载')
})
</script>

四、模板语法:增强与简化

Vue3的模板语法基本兼容Vue2,但新增了部分实用特性,同时简化了部分语法,提升开发效率,减少冗余代码。

1. 新增特性

(1)多根节点(Fragments)

Vue2中组件模板只能有一个根节点(需用div等标签包裹),否则会报错;Vue3支持多根节点,无需额外包裹,减少DOM层级冗余,优化渲染性能。

// Vue2(错误示例:多根节点)
<template>
  <h1>Vue2</h1>
  <p>只能有一个根节点</p>
</template>

// Vue2(正确示例:需包裹div)
<template>
  <div>
    <h1>Vue2</h1>
    <p>只能有一个根节点</p>
  </div>
</template>

// Vue3(正确示例:多根节点)
<template>
  <h1>Vue3</h1>
  <p>支持多根节点,无需包裹</p>
</template>
(2)v-model 语法简化与增强

Vue2中v-model本质是:value + @input的语法糖,且只能绑定一个值;Vue3简化了v-model的使用,同时支持多值绑定、自定义修饰符,统一了组件通信的语法。

// Vue2 v-model 使用(单一绑定)
<template>
  <input v-model="value">
  // 等价于
  <input :value="value" @input="value = $event.target.value">
</template>

// Vue3 v-model 使用(多值绑定+自定义修饰符)
<template>
  // 1. 单一绑定(简化,无需手动处理$event)
  <input v-model="value">
  
  // 2. 多值绑定(绑定多个属性)
  <ChildComponent 
    v-model:name="name" 
    v-model:age="age"
  />
  
  // 3. 自定义修饰符(如v-model.trim)
  <input v-model.trim="value">
</template>
(3)动态指令参数

Vue3支持动态绑定指令参数,让指令使用更灵活,可根据数据动态切换指令的目标(如动态绑定v-bind、v-on的参数)。

<script setup>
import { ref } from 'vue'
const propName = ref('title')
const eventName = ref('click')
</script>

<template>
  // 动态绑定v-bind参数
  <div v-bind:[propName]="'Vue3动态指令'"></div>
  
  // 动态绑定v-on参数
  <button v-on:[eventName]="handleClick">点击</button>
</template>
(4)Teleport(瞬移组件)

Vue3新增Teleport组件,可将组件内容“瞬移”到页面的任意DOM节点中(如body),解决了弹窗、模态框等组件的层级问题,无需担心父组件的样式隔离影响。

<template>
  <teleport to="body">
    <div class="modal">
      这是一个弹窗,将被渲染到body中
    </div>
  </teleport>
</template>
(5)Suspense(异步组件占位)

Vue3新增Suspense组件,用于异步组件的加载占位,可在异步组件加载完成前显示loading状态,加载失败时显示错误提示,简化异步组件的处理逻辑。

<template>
  <suspense>
    <template #default>
      // 异步组件(需动态导入)
      <AsyncComponent />
    </template>
    <template #fallback>
      // 加载中占位
      <div>加载中...</div>
    </template>
  </suspense>
</template>

<script setup>
// 动态导入异步组件
const AsyncComponent = defineAsyncComponent(() => import('./AsyncComponent.vue'))
</script>

2. 废弃特性

  • v-on.native修饰符:Vue3中组件的原生事件无需使用.native修饰符,可直接绑定,若需区分组件自定义事件和原生事件,可通过emits选项声明自定义事件;
  • 过滤器(filter):Vue3废弃了过滤器,推荐使用计算属性或全局方法替代(过滤器的功能可完全通过计算属性实现,且更灵活);
  • v-bind.sync修饰符:Vue3中可用v-model:xxx替代,统一了双向绑定的语法。

五、性能优化:全方位提升

Vue3在性能上做了大量优化,相比Vue2,渲染速度提升30%+,打包体积减少约50%,主要优化点集中在编译优化、响应式优化、体积优化三个方面。

1. 编译优化

Vue3重写了模板编译逻辑,引入「静态标记(Patch Flag)」和「Block Tree」机制,实现按需更新,减少不必要的DOM操作:

  • 静态标记:编译模板时,对静态节点(不随数据变化的节点)添加标记,更新时跳过静态节点,只处理动态节点;
  • Block Tree:将模板拆分为多个Block(代码块),每个Block对应一个动态节点集合,更新时只遍历对应Block的动态节点,而非整个虚拟DOM树。

2. 响应式优化

如前文所述,Vue3使用Proxy+Reflect替代Object.defineProperty,实现以下优化:

  • 懒代理:嵌套对象只有在访问时才会创建代理,减少初始化时的性能开销;
  • 精准监听:只监听变化的属性,无需遍历整个对象,更新更高效;
  • 支持更多数据类型:Map、Set等集合类型也能实现响应式,满足更多开发场景。

3. 体积优化

Vue3支持Tree-shaking(树摇),只打包项目中用到的API,未使用的功能(如过滤器、v-on.native等)不会被打包,核心包体积从Vue2的约20KB缩减到最小10KB左右,大幅提升项目加载速度。

六、TypeScript支持:从兼容到原生

Vue2对TypeScript的支持较差,需要通过vue-class-component、vue-property-decorator等第三方库实现TS支持,且类型推导不精准,开发体验不佳;Vue3则是基于TypeScript原生开发的,天生支持TS,类型推导更精准,开发体验大幅提升。

核心差异

  • Vue2:需额外配置第三方库,类型定义不完整,组件内this指向不明确,类型推导困难;
  • Vue3:原生支持TS,Composition API的函数式写法更易推导类型,defineProps、defineEmits等宏支持泛型定义,模板中表达式的类型错误可在编译时被捕获,且核心代码的类型定义更完善。
<script setup lang="ts">
// Vue3 + TS 示例
import { ref, computed } from 'vue'

// 1. 基础类型响应式数据
const count = ref<number>(0)

// 2. 复杂类型响应式数据
interface User {
  name: string
  age: number
}
const user = ref<User | null>(null)

// 3. 计算属性类型推导
const doubleCount = computed(() => count.value * 2) // 自动推导为number类型

// 4. 组件props类型定义(defineProps宏)
const props = defineProps<{
  title: string
  count?: number // 可选属性
}>()

// 5. 组件事件类型定义(defineEmits宏)
const emit = defineEmits<{
  (e: 'change', value: number): void
}>()
</script>

七、生态与工具链:全面升级

Vue3的生态系统也同步升级,核心工具和第三方库均已适配Vue3,同时新增了更高效的开发工具,提升开发体验。

1. 核心工具

工具 Vue2 Vue3
构建工具 Vue CLI(基于Webpack) Vite(推荐,基于ESBuild,冷启动更快)、Vue CLI(兼容)
路由 vue-router@3.x vue-router@4.x(适配Composition API,支持TS)
状态管理 Vuex@3.x Pinia(推荐,更轻量、支持TS、API更简洁)、Vuex@4.x(兼容)
UI组件库 Element UI、Vuetify@2.x Element Plus、Vuetify@3.x、Ant Design Vue@3.x

2. 全局API变化

Vue3对全局API进行了重构,从“全局挂载”改为“实例化挂载”,支持多实例隔离,避免全局污染,同时简化了部分API的使用。

// Vue2 全局API使用
import Vue from 'vue'
import App from './App.vue'

// 全局注册组件
Vue.component('MyComponent', MyComponent)

// 全局注册指令
Vue.directive('my-directive', {})

// 全局配置
Vue.config.productionTip = false

// 创建实例
new Vue({
  render: h => h(App)
}).$mount('#app')

// Vue3 全局API使用(实例化方式)
import { createApp } from 'vue'
import App from './App.vue'

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

// 实例注册组件
app.component('MyComponent', MyComponent)

// 实例注册指令
app.directive('my-directive', {})

// 实例配置
app.config.productionTip = false

// 挂载实例
app.mount('#app')

八、实战迁移指南:从Vue2到Vue3

对于现有Vue2项目,无需一次性全部迁移,可采用“渐进式迁移”策略,逐步替换组件和逻辑,降低迁移成本。以下是具体迁移步骤和注意事项:

1. 迁移前准备

  • 检查依赖兼容性:升级核心依赖(Vue、vue-router、Vuex/Pinia),确保第三方组件库和插件支持Vue3(如Element UI替换为Element Plus);
  • 检查语法兼容性:移除Vue2中废弃的特性(过滤器、v-on.native、v-bind.sync等),替换为Vue3的替代方案;
  • 创建迁移分支:建议在Git中创建专门的迁移分支,避免影响主分支的正常开发。

2. 核心依赖升级命令

# 卸载Vue2相关依赖
npm remove vue vue-router vuex

# 安装Vue3相关依赖
npm install vue@3.2.x vue-router@4.x pinia@2.x

# 安装Vue3编译器(若使用Vue CLI)
npm install @vue/compiler-sfc@3.2.x -D

3. 逐步迁移组件

  • 优先迁移简单组件(如公共组件、基础组件),再迁移复杂组件;
  • 将Options API组件逐步改为Composition API(使用
  • 替换响应式数据写法:将data中的数据替换为ref/reactive,methods中的方法改为普通函数,computed/watch替换为对应的Composition API。

4. 常见迁移问题解决

  • this指向问题:Vue3的Composition API中无this,需通过ref/reactive的.value访问响应式数据;
  • 组件通信问题:将emit替换为defineEmitsemit替换为defineEmits,props替换为defineProps,parent/parent/children替换为provide/inject;
  • 事件总线问题:Vue3废弃了on/on/off/$once,可使用mitt等第三方库实现事件总线功能。

九、总结:该选择Vue2还是Vue3?

经过全面对比,Vue3在核心架构、响应式原理、性能、TS支持等方面均优于Vue2,且完全兼容Vue2的Options API,是未来的主流方向。结合实际开发场景,给出以下选型建议:

  • 新项目:优先选择Vue3 + Vite + Pinia + TS,享受更高效的开发体验和更优的性能;
  • 现有Vue2项目:若项目稳定,无需强制迁移;若需要新增复杂功能或优化性能,可采用渐进式迁移策略,逐步升级;
  • 新手学习:直接学习Vue3,Composition API的思想更贴合现代前端开发,且未来就业需求更高。

Vue3的升级不仅是技术的迭代,更是开发理念的升级——从“按选项组织代码”到“按功能组合代码”,让开发更灵活、更高效、更易维护。希望本文能帮助你全面掌握Vue2与Vue3的区别,顺利完成项目迁移和技术升级!

最后,如果你在迁移过程中遇到问题,或者有其他Vue相关的疑问,欢迎在评论区留言交流~

vue表格vxe-table实现表头合并,分组表头自定义合并

作者 卤蛋fg6
2026年3月17日 11:53

在开发后台管理系统时,经常会遇到需要展示复杂表格的场景,其中表头合并(多级表头、不规则合并)是一项常见需求。vxe-table 是一款功能强大的 Vue 表格组件,它不仅支持树形分组表头,还提供了自定义列头合并的功能,允许开发者灵活地将任意单元格进行合并,满足各种复杂的表头设计。

形分组表头 vs 自定义合并表头

vxe-table 默认支持树形分组表头,只需在列配置中定义 children 即可实现多级表头。例如:

columns: [
  { field: 'name', title: '姓名' },
  {
    title: '基本信息',
    children: [
      { field: 'sex', title: '性别' },
      { field: 'age', title: '年龄' }
    ]
  }
]

这种方式的优点是简单直观,但只能按层级自动生成表头,无法实现跨层级的任意合并(例如合并第一列的“姓名”和“性别”)。

开启自定义表头合并

定义合并表头则允许我们完全控制表头的每个单元格,通过 mergeHeaderCells 配置将任意位置的单元格合并,实现更灵活的布局。

  • 要使用自定义合并,需要在表格组件上设置两个关键属性:
  • show-custom-header:开启自定义表头渲染模式。
  • merge-header-cells:定义合并规则的数组。

mergeHeaderCells 配置详解

  • 行/列索引规则:
  • 行索引从上到下递增,0 表示最顶层的表头行。
  • 列索引从左到右递增,0 表示第一列(通常是序号列或第一列数据列)。 如果存在多级表头,最终渲染的表头行数由列配置的层级深度决定。

例如,合并第一列和第二列(两行高度)的规则为:

mergeHeaderCells: [
  { row: 0, col: 0, rowspan: 2, colspan: 1 }, // 合并第一列的两行
  { row: 0, col: 1, rowspan: 2, colspan: 1 }  // 合并第二列的两行
]

代码

image

<template>
  <div>
    <vxe-button @click="setMerge1">设置合并1</vxe-button>
    <vxe-button @click="setMerge2">设置合并2</vxe-button>
    <vxe-button status="success" @click="saveMergeData">获取合并规则</vxe-button>

    <vxe-grid  ref="gridRef" v-bind="gridOptions"></vxe-grid>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'

const gridRef = ref()

const gridOptions = reactive({
  border: true,
  showCustomHeader: true,
  height: 400,
  mergeHeaderCells: [
    { row: 0, col: 0, rowspan: 2, colspan: 1 },
    { row: 0, col: 1, rowspan: 2, colspan: 1 },
    { row: 0, col: 2, rowspan: 1, colspan: 2 },
    { row: 0, col: 4, rowspan: 1, colspan: 2 },
    { row: 1, col: 6, rowspan: 1, colspan: 2 },
    { row: 0, col: 8, rowspan: 2, colspan: 1 }
  ],
  columns: [
    { type: 'seq', width: 70 },
    { field: 'name', title: 'Name' },
    {
      title: 'Group1',
      field: 'group1',
      headerAlign: 'center',
      children: [
        { field: 'sex', title: 'Sex' },
        { field: 'age', title: 'Age' }
      ]
    },
    {
      field: 'group3',
      title: 'Group3',
      headerAlign: 'center',
      children: [
        { field: 'attr5', title: 'Attr5' },
        { field: 'attr6', title: 'Attr6' }
      ]
    },
    {
      field: 'group6',
      title: 'Attr3',
      children: [
        { field: 'attr3', title: 'Group8', headerAlign: 'center' }
      ]
    },
    {
      field: 'group8',
      title: 'Attr4',
      children: [
        { field: 'attr4', title: 'Attr4' }
      ]
    },
    { field: 'address', title: 'Address' }
  ],
  data: [
    { id: 10001, name: 'Test1', role: 'Develop', sex: 'Man', age: 46, attr3: 22, attr4: 100, attr5: 66, attr6: 86, address: 'Guangzhou' },
    { id: 10002, name: 'Test2', role: 'Test', sex: 'Women', age: 0, attr3: 0, attr4: 0, attr5: 0, attr6: 0, address: 'Shenzheng' },
    { id: 10003, name: 'Test3', role: 'PM', sex: 'Man', age: 0, attr3: 22, attr4: 0, attr5: 0, attr6: 0, address: 'Shanghai' },
    { id: 10004, name: 'Test4', role: 'Designer', sex: 'Women', age: 0, attr3: 0, attr4: 0, attr5: 0, attr6: 0, address: 'Guangzhou' },
    { id: 10005, name: 'Test5', role: 'Test', sex: 'Women', age: 0, attr3: 0, attr4: 0, attr5: 0, attr6: 0, address: 'Shenzheng' },
    { id: 10006, name: 'Test6', role: 'Develop', sex: 'Man', age: 0, attr3: 0, attr4: 0, attr5: 0, attr6: 0, address: 'Guangzhou' },
    { id: 10007, name: 'Test7', role: 'Designer', sex: 'Women', age: 0, attr3: 0, attr4: 0, attr5: 0, attr6: 0, address: 'Guangzhou' },
    { id: 10008, name: 'Test8', role: 'Test', sex: 'Man', age: 0, attr3: 0, attr4: 0, attr5: 0, attr6: 0, address: 'Guangzhou' }
  ]
})

const setMerge1 = () => {
  gridOptions.mergeHeaderCells = [
    { row: 0, col: 0, rowspan: 2, colspan: 1 },
    { row: 0, col: 1, rowspan: 2, colspan: 1 },
    { row: 0, col: 2, rowspan: 1, colspan: 2 },
    { row: 0, col: 4, rowspan: 1, colspan: 2 },
    { row: 1, col: 6, rowspan: 1, colspan: 2 },
    { row: 0, col: 8, rowspan: 2, colspan: 1 }
  ]
}

const setMerge2 = () => {
  gridOptions.mergeHeaderCells = [
    { row: 0, col: 0, rowspan: 2, colspan: 1 },
    { row: 0, col: 1, rowspan: 2, colspan: 1 },
    { row: 0, col: 2, rowspan: 1, colspan: 4 },
    { row: 1, col: 6, rowspan: 1, colspan: 3 }
  ]
}

const saveMergeData = () => {
  const $grid = gridRef.value
  if ($grid) {
    const mergeList = $grid.getMergeHeaderCells()
    console.log(mergeList)
  }
}
</script>

当使用自定义表头合并后,被合并的列将不支持通过拖拽调整列宽。这是因为合并后的单元格在结构上已经不是独立的列,拖拽行为难以精确定义。如果需要调整列宽,建议在合并前规划好列宽,或通过固定宽度配置。

vxetable.cn

事件监听器销毁完全指南:如何避免内存泄漏?

作者 wuhen_n
2026年3月17日 09:24

前言

我们在实际开发中可能遇到过这样的情况:打开一个网页,一开始很流畅,但后面越用越卡;尤其是切换页面后,感觉浏览器变慢了;长时间不刷新,页面最终崩溃了。

这很可能就是 内存泄漏 在作祟。

想象一下:我们有个垃圾桶,每天都在往里面扔垃圾,但从来不倒。一开始没什么问题,但一个月后,垃圾堆满了屋子,我们连站的地方都没有了。

事件监听器导致的内存泄漏,就是这样——垃圾不倒,导致越积越多。

本文将从最基础的概念讲起,用最通俗的语言,配合完整的代码示例,深入探讨事件监听器导致内存泄漏的成因、检测方法、预防措施,以及 TypeScript 如何帮助我们构建类型安全的清理策略。

为什么事件监听器会成为内存杀手?

从一个简单的例子开始

App.vue

<template>
  <div>
    <button @click="show = !show">切换组件</button>
    <ChildComponent v-if="show" />
  </div>
</template>

<script setup>
import { ref } from 'vue'
const show = ref(true)
</script>

ChildComponent.vue

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

onMounted(() => {
  // 每次组件挂载时,都添加一个滚动监听
  window.addEventListener('scroll', () => {
    console.log('滚动位置:', window.scrollY)
  })
})
</script>

这看起来没什么问题,但实际上发生了什么呢? 每次切换组件,都会增加一个新的监听器! 当成百上千次切换后,就有上千个监听器在工作...

为什么没有自动清理?

很多人都以为只要组件销毁了,它里面的东西会自动清理。但事实是:

  • Vue 可以自动清理:组件的数据、事件、计算属性等
  • Vue 不能自动清理:window/document 上的事件、定时器、WebSocket 等

内存泄漏的危害有多大?

指标 正常状态 泄漏状态 影响
内存占用 50MB 500MB+ 页面卡顿,甚至崩溃
事件响应 即时 延迟1-2秒 用户体验差
CPU使用率 10% 60%+ 电脑发烫,风扇狂转
电池消耗 正常 快3倍 移动端灾难

三种事件注册方式及其清理

三种注册方式对比

注册方式 优点 缺点 清理方法
内联事件 简单直接 无法移除多个,污染HTML 赋值为null
属性赋值 可移除 只能绑定一个 赋值为null
addEventListener 可绑定多个,灵活 需要对应 remove removeEventListener

内联事件的清理

// 移除内联事件
const button = document.querySelector('button')
button.onclick = null

// 或者移除整个元素
button.remove()

// 更彻底:清空父元素内容
parent.innerHTML = ''  // 会移除所有子元素的事件

注:实际 Vue 开发中,不推荐直接使用内联事件,推荐使用 Vue 的事件绑定 @click 等。

属性赋值的清理

// 注册
window.onresize = handleResize
document.onkeydown = handleKeyDown
button.onclick = handleClick

// 清理
window.onresize = null
document.onkeydown = null
button.onclick = null

注:属性赋值只能有一个监听器 window.onresize = fn1 window.onresize = fn2
此时 fn2 会覆盖 fn1

addEventListener 的正确清理

function handleResize() {
  console.log('resize')
}
window.addEventListener('resize', handleResize)
window.removeEventListener('resize', handleResize)

为什么 removeEventListener 有时候不工作?

场景一:匿名函数无法移除

window.addEventListener('click', () => {})
window.removeEventListener('click', () => {})  // ❌ 错误:匿名函数无法移除

因为匿名函数每次创建时都是新的,会重复创建,因此无法移除。

场景二:capture 参数不同,无法移除

window.addEventListener('click', handleClick, true)
window.removeEventListener('click', handleClick, false)  //   ❌ 错误::capture 不同,无法移除

场景三:options 对象不同,无法移除

const options1 = { passive: true }
const options2 = { passive: true }
element.addEventListener('click', handleClick, options1)
element.removeEventListener('click', handleClick, options2)  //  ❌ 错误:不同对象,无法移除

一句话总结:removeEventListener 的参数必须和 addEventListener 完全一致才能移除。

Vue 组件中的事件清理

最基本的清理模式

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

const scrollTop = ref(0)

// 1. 使用具名函数
function handleScroll() {
  scrollTop.value = window.scrollY
}

onMounted(() => {
  // 2. 注册事件
  window.addEventListener('scroll', handleScroll)
})

onUnmounted(() => {
  // 3. 组件卸载时移除事件
  window.removeEventListener('scroll', handleScroll)
})
</script>

<template>
  <div>滚动位置: {{ scrollTop }}</div>
</template>

封装可复用的组合式函数

// composables/useEventListener.js
import { onMounted, onUnmounted } from 'vue'

export function useEventListener(target, event, handler) {
  // 确保 target 存在
  if (!target?.addEventListener) return
  
  // 注册
  onMounted(() => {
    target.addEventListener(event, handler)
  })
  
  // 自动清理
  onUnmounted(() => {
    target.removeEventListener(event, handler)
  })
}

使用示例:

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

useEventListener(document, 'visibilitychange', () => {
  console.log('页面可见性变化')
})

useEventListener(document, 'keydown', (e) => {
  if (e.key === 'Escape') {
    console.log('按下 ESC 键')
  }
})

支持多个事件的组合式函数

// composables/useWindowEvents.js
import { onMounted, onUnmounted } from 'vue'

export function useWindowEvents(handlers) {
  const entries = Object.entries(handlers)
  
  onMounted(() => {
    entries.forEach(([event, handler]) => {
      window.addEventListener(event, handler)
    })
  })
  
  onUnmounted(() => {
    entries.forEach(([event, handler]) => {
      window.removeEventListener(event, handler)
    })
  })
}

使用示例:

useWindowEvents({
  resize: () => console.log('resize'),
  scroll: () => console.log('scroll'),
  click: (e) => console.log('click at', e.clientX, e.clientY)
})

返回清理函数的 Hook 模式

// composables/useResizeObserver.js
import { ref, onUnmounted } from 'vue'

export function useResizeObserver(target) {
  const width = ref(0)
  const height = ref(0)
  
  // 创建观察者
  const observer = new ResizeObserver((entries) => {
    const entry = entries[0]
    if (entry) {
      width.value = entry.contentRect.width
      height.value = entry.contentRect.height
    }
  })
  
  // 开始观察
  const el = unref(target)
  if (el) {
    observer.observe(el)
  }
  
  // 返回清理函数
  const cleanup = () => {
    observer.disconnect()
  }
  
  // 组件卸载时自动清理
  onUnmounted(cleanup)
  
  return {
    width,
    height,
    cleanup  // 也可以手动调用
  }
}

使用示例:

const container = ref()
const { width, height } = useResizeObserver(container)

内存泄漏的检测与诊断

Chrome DevTools 内存面板使用

// 步骤1:录制内存分配时间线
// Performance 面板 → Memory 勾选 → 开始录制
// 执行可能导致泄漏的操作 → 停止录制
// 查看内存曲线:正常应该波动后回落,泄漏会持续增长

// 步骤2:拍摄堆快照
// Memory 面板 → Take heap snapshot

// 步骤3:对比快照
// 操作前后各拍一次 → 选择 Comparison 视图
// 重点查看:
// - Detached 元素(已从 DOM 移除但未被回收)
// - 增加的 EventListener 数量
// - 新增的闭包引用

// 步骤4:使用 Allocation instrumentation on timeline
// 实时记录内存分配,定位泄漏的具体代码

Performance Monitor 实时监控

// 在 DevTools 中打开 Performance Monitor(Ctrl+Shift+P 搜索)
// 关注指标:
// - JS Heap size:堆内存大小,正常应该稳定在某个范围
// - DOM Nodes:DOM 节点数量,动态内容应有增有减
// - Event Listeners:事件监听器数量,不应无限增长
// - Documents:文档数量,通常为1

// 正常情况:操作前后指标应该基本持平
// 泄漏情况:指标持续增长,不会下降

手动检测代码

// 在开发环境添加监控工具
if (import.meta.env.DEV) {
  // 每5秒输出一次内存状态
  setInterval(() => {
    console.table({
      '时间': new Date().toLocaleTimeString(),
      'JS Heap': formatBytes((performance as any).memory?.usedJSHeapSize),
      'DOM Nodes': document.querySelectorAll('*').length,
      'Event Listeners': countEventListeners(),
      'Detached Nodes': countDetachedNodes()
    })
  }, 5000)
}

function countEventListeners(): number {
  // 遍历所有 DOM 元素,统计监听器(仅限 Chrome)
  let count = 0
  const allElements = document.querySelectorAll('*')
  
  allElements.forEach(el => {
    const listeners = (el as any).getEventListeners?.()
    if (listeners) {
      count += Object.values(listeners).flat().length
    }
  })
  
  return count
}

function countDetachedNodes(): number {
  // 统计已从 DOM 移除但未被回收的元素
  const heapSnapshot = (window as any).heapSnapshot
  if (!heapSnapshot) return 0
  
  let count = 0
  // 遍历堆快照统计 detached 元素
  // 具体实现依赖 DevTools 协议
  return count
}

function formatBytes(bytes: number): string {
  if (bytes < 1024) return bytes + ' B'
  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'
  return (bytes / (1024 * 1024)).toFixed(2) + ' MB'
}

常见陷阱与解决方案

陷阱一:在循环中注册事件

// ❌ 错误:每秒增加一个监听器
setInterval(() => {
  window.addEventListener('resize', () => {
    console.log('resize')
  })
}, 1000)

// ✅ 正确:只注册一次
window.addEventListener('resize', () => {
  console.log('resize')
})

setInterval(() => {
  // 做其他事
}, 1000)

陷阱二:watch 中注册事件

// ❌ 错误:每次 ID 变化都增加监听器
watch(() => route.params.id, () => {
  window.addEventListener('scroll', handleScroll)
})

// ✅ 正确:只注册一次
onMounted(() => {
  window.addEventListener('scroll', handleScroll)
})

function handleScroll() {
  // 根据当前 ID 做不同处理
  if (route.params.id) {
    console.log('当前ID:', route.params.id)
  }
}

onUnmounted(() => {
  window.removeEventListener('scroll', handleScroll)
})

陷阱三:箭头函数的 this 问题

class Component {
  data = 'test'
  
  // ❌ 错误:每次调用都创建新函数
  render() {
    button.addEventListener('click', () => {
      console.log(this.data)  // 无法移除
    })
  }
  
  // ✅ 正确:使用类属性方法
  handleClick = () => {
    console.log(this.data)
  }
  
  render() {
    button.addEventListener('click', this.handleClick)
    // 可以移除
    button.removeEventListener('click', this.handleClick)
  }
}

陷阱四:第三方库不销毁

import Swiper from 'swiper'
import * as echarts from 'echarts'

let swiper = null
let chart = null

onMounted(() => {
  // ❌ 只创建不销毁
  swiper = new Swiper('.swiper', {})
  chart = echarts.init(document.getElementById('chart'))
})

onUnmounted(() => {
  // ✅ 必须调用销毁方法
  if (swiper) {
    swiper.destroy(true, true)
    swiper = null
  }
  
  if (chart) {
    chart.dispose()
    chart = null
  }
})

最佳实践清单

开发时 Checklist

  • 每个 addEventListener 都有对应的 removeEventListener
  • 清理函数是否在 onUnmounted 中调用?
  • 匿名函数是否改成了具名函数或变量引用?
  • 节流/防抖的定时器是否清理了?
  • IntersectionObserver/ResizeObserver 是否调用了 disconnect
  • 第三方库实例是否调用了 destroydispose 方法?
  • 动态添加的元素,事件是否在移除元素时清理?

代码审查 Checklist

  • 是否有在循环或高频操作中注册事件?
  • 事件回调中是否持有大量数据的引用?(可能导致内存泄漏)
  • 多个组件共享的全局事件,是否考虑了竞态条件?
  • 组件销毁时,是否清理了所有自定义事件?
  • 使用 once 选项的事件是否确实只需要执行一次?

性能监控 Checklist

  • 是否定期检查 DevTools 的 Event Listeners 数量?
  • 是否有内存泄漏的自动化测试?
  • 生产环境是否有内存监控告警?
  • 是否建立了性能基准,跟踪内存趋势?
  • 是否在关键操作前后进行了内存快照对比?

注册清理对应表

注册 清理
addEventListener removeEventListener
setInterval clearInterval
setTimeout clearTimeout
new Observer observer.disconnect()
new WebSocket websocket.close()
new Swiper swiper.destroy()
echarts.init chart.dispose()

结语

好的代码不仅要能运行,还要能优雅地停止。学会正确地清理事件监听器,是每个前端开发者从入门到进阶的必修课。

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

vue一次解决监听H5软键盘弹出和收起的兼容问题

2026年3月16日 17:47

H5软键盘弹出和收起在安卓和ios以及不同浏览器之间存在不同的表现形式,网上也找不到更全面的解决方案,为此自己研究出能兼容主流浏览器的解决方案。

Screenshot_2026-03-13-11-38-23-641_com.android.chrome.jpg

问题

在做手机评论功能的交互时,必须要通过监听软键盘弹出和收起来实现,比如实现“点击回复评论弹出键盘,收起键盘就取消回复操作,输入框清空输入值”。

在研究过程中发现有以下几个问题:

  1. 安卓非谷歌浏览器高度会变化,横屏时不会收起键盘
  2. 安卓最新谷歌浏览器高度不会变化(页面上推),横屏时会收起键盘,但会触发resize事件
  3. 安卓收起键盘后input可能并未失焦
  4. 有些浏览器可能会多次触发resize事件
  5. ios收起键盘页面会上滚

完整的代码放在最下面。

使用方式

当页面只有一个输入框的情况下使用

<script setup>
import keyboard from "./keyboard";
const vKeyboard = keyboard;

const keyboardFn = val => {
  console.log(val ? "弹出键盘" : "收起键盘")
};
</script>

<template>
    <input v-keyboard="keyboardFn" />
</template>

完整代码

const isIOS = /iphone|ipad|ipod/.test(navigator.userAgent.toLocaleLowerCase());
const originHeight =
  document.documentElement.clientHeight || document.body.clientHeight;
let scrollTop = 0;

const keyBoard = {
  mounted(el, binding, vnode) {
    const isFocus = ref(false);
    const isResiz = ref(0);
    const isChange = ref(false);
    const isHeight = ref(false);

    el.resizeFn = () => {
      const resizeHeight =
        document.documentElement.clientHeight || document.body.clientHeight;
      if (resizeHeight < originHeight) {
        isChange.value = true;
        isHeight.value = true;
      } else {
        isHeight.value = false;
      }
      if (isFocus.value) isResiz.value++;
    };
    
    if (!isIOS) {
      window.addEventListener("resize", el.resizeFn);
      // 第1种情况处理方式
      watch(isHeight, () => {
        if (isChange.value && isFocus.value && !isHeight.value) {
          binding.value(false);
        }
      });
      // 第2种情况处理方式
      watch(isResiz, () => {
        if (!isChange.value && isFocus.value && isResiz.value > 1) {
          binding.value(false);
        }
      });
    }

    el.handlerFocusin = () => {
      if (!isIOS) {
        isFocus.value = true;
        binding.value(true);
      } else {
        binding.value(true);
        scrollTop = document.documentElement.scrollTop;
      }
    };
    
    el.handlerFocusout = () => {
      if (!isIOS) {
        // 先失焦后后收起键盘的情况处理
        if (isFocus.value) {
          binding.value(false);
        }
        isFocus.value = false;
        isChange.value = false;
        isHeight.value = false;
        isResiz.value = 0;
      } else {
        binding.value(false);
        // 处理 iOS 收起软键盘页面会上滚问题
        setTimeout(() => window.scrollTo({ top: scrollTop }), 50);
      }
    };
    
    el.addEventListener("focusin", el.handlerFocusin);
    el.addEventListener("focusout", el.handlerFocusout);
  },
  
  unmounted(el) {
    window.removeEventListener("resize", el.resizeFn);
    el.removeEventListener("focusin", el.handlerFocusin);
    el.removeEventListener("focusout", el.handlerFocusout);
  }
};

export default keyBoard;

Pinia 状态管理实战 | 从 0 到 1 搭建 Vue3 项目状态层(附模块化 / 持久化)

作者 代码煮茶
2026年3月16日 17:02

Pinia 状态管理实战 | 从 0 到 1 搭建 Vue3 项目状态层(附模块化 / 持久化)

一、为什么是 Pinia?

还记得 Vuex 吗?那个陪伴我们多年的状态管理库,有着严格的 mutations、actions 分工,写起来像在写 Java——虽然严谨,但也繁琐。

// Vuex 时代的痛
mutations: {
  SET_USER(state, user) {
    state.user = user
  }
},
actions: {
  async fetchUser({ commit }) {
    const user = await api.getUser()
    commit('SET_USER', user) // 绕了一大圈
  }
}

而 Pinia 来了,它说:「简单点,写代码的方式简单点」

// Pinia 的快乐
export const useUserStore = defineStore('user', {
  state: () => ({ user: null }),
  actions: {
    async fetchUser() {
      this.user = await api.getUser() // 直接赋值,爽!
    }
  }
})

1.1 Pinia 的核心优势

特性 Vuex Pinia
mutations ✅ 必须写 ❌ 没了
TypeScript 支持 😖 痛苦 😎 原生支持
代码量 少 30%
学习曲线 陡峭 平缓
DevTools ✅ 更好

二、项目初始化:从 0 开始搭建状态层

承接上一节的 Vite 项目,我们来深度拆解状态管理。

2.1 安装 Pinia

npm install pinia
npm install pinia-plugin-persistedstate # 持久化插件(后面会讲)

2.2 在 main.ts 中注册

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

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

const app = createApp(App)

// 注册插件(顺序很重要:先 Pinia,后路由)
app.use(pinia)
app.use(router)

app.mount('#app')

三、Store 的两种写法:你pick哪一种?

Pinia 支持两种 Store 定义方式,就像 Vue 有 Options API 和 Composition API 一样。

3.1 Options Store(类似 Vuex 风格)

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

export const useCounterStore = defineStore('counter', {
  // state:数据源
  state: () => ({
    count: 0,
    name: '计数器'
  }),
  
  // getters:计算属性
  getters: {
    doubleCount: (state) => state.count * 2,
    // 使用 this 访问其他 getter
    displayText(): string {
      return `${this.name}: ${this.count} (翻倍后: ${this.doubleCount})`
    }
  },
  
  // actions:方法(支持同步异步)
  actions: {
    increment(amount = 1) {
      this.count += amount
    },
    async fetchAndSetCount() {
      // 模拟异步请求
      const res = await fetch('/api/count')
      const data = await res.json()
      this.count = data.count
    }
  }
})

3.2 Setup Store(Composition API 风格)⭐推荐

// stores/counter.ts (Setup Store)
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  // state:用 ref/reactive
  const count = ref(0)
  const name = ref('计数器')
  
  // getters:用 computed
  const doubleCount = computed(() => count.value * 2)
  const displayText = computed(() => {
    return `${name.value}: ${count.value} (翻倍后: ${doubleCount.value})`
  })
  
  // actions:普通函数
  function increment(amount = 1) {
    count.value += amount
  }
  
  async function fetchAndSetCount() {
    const res = await fetch('/api/count')
    const data = await res.json()
    count.value = data.count
  }
  
  // 必须返回所有暴露的内容
  return {
    count,
    name,
    doubleCount,
    displayText,
    increment,
    fetchAndSetCount
  }
})

为什么推荐 Setup Store?

  • 更灵活,可以组合复用逻辑
  • TypeScript 类型推导更好
  • 符合 Vue3 Composition API 的心智模型

四、模块化设计:把大象装进冰箱分几步?

企业级项目最忌讳「一个大 Store 管所有」。正确的姿势是:按业务模块拆分

4.1 推荐的项目结构

src/stores/
├── index.ts              # 统一导出
├── modules/
│   ├── user.ts           # 用户模块
│   ├── cart.ts           # 购物车模块
│   ├── product.ts        # 商品模块
│   └── app.ts            # 应用配置(主题/语言等)
├── composables/          # 可复用的组合逻辑
│   ├── useAuth.ts
│   └── useCache.ts
└── plugins/              # Pinia 插件
    └── logger.ts

4.2 用户模块(完整示例)

// stores/modules/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { UserInfo, LoginParams } from '@/types/user'
import { loginApi, getUserInfoApi } from '@/api/user'
import { ElMessage } from 'element-plus'

export const useUserStore = defineStore('user', () => {
  // --- State ---
  const token = ref<string | null>(localStorage.getItem('token'))
  const userInfo = ref<UserInfo | null>(null)
  const permissions = ref<string[]>([])
  
  // --- Getters ---
  const isLoggedIn = computed(() => !!token.value)
  const userName = computed(() => userInfo.value?.name || '游客')
  const userRole = computed(() => userInfo.value?.role || 'guest')
  const hasPermission = computed(() => (perm: string) => {
    return permissions.value.includes(perm) || userRole.value === 'admin'
  })
  
  // --- Actions ---
  // 登录
  async function login(params: LoginParams) {
    try {
      const res = await loginApi(params)
      token.value = res.token
      userInfo.value = res.userInfo
      permissions.value = res.permissions || []
      
      // 同步到 localStorage
      localStorage.setItem('token', res.token)
      
      ElMessage.success('登录成功')
      return true
    } catch (error) {
      ElMessage.error('登录失败:' + (error as Error).message)
      return false
    }
  }
  
  // 登出
  function logout() {
    token.value = null
    userInfo.value = null
    permissions.value = []
    localStorage.removeItem('token')
    ElMessage.success('已退出登录')
  }
  
  // 获取用户信息
  async function fetchUserInfo() {
    if (!token.value) return
    
    try {
      const res = await getUserInfoApi()
      userInfo.value = res.userInfo
      permissions.value = res.permissions
    } catch (error) {
      console.error('获取用户信息失败:', error)
      // token 无效,自动登出
      if ((error as any).response?.status === 401) {
        logout()
      }
    }
  }
  
  // 更新用户信息
  function updateUserInfo(data: Partial<UserInfo>) {
    if (userInfo.value) {
      userInfo.value = { ...userInfo.value, ...data }
    }
  }
  
  return {
    // state
    token,
    userInfo,
    permissions,
    // getters
    isLoggedIn,
    userName,
    userRole,
    hasPermission,
    // actions
    login,
    logout,
    fetchUserInfo,
    updateUserInfo
  }
})

4.3 应用配置模块(主题/语言)

// stores/modules/app.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

type Theme = 'light' | 'dark'
type Language = 'zh' | 'en'

export const useAppStore = defineStore('app', () => {
  // 从 localStorage 读取初始值
  const getInitialTheme = (): Theme => {
    const saved = localStorage.getItem('theme') as Theme
    return saved || 'light'
  }
  
  const getInitialLanguage = (): Language => {
    const saved = localStorage.getItem('language') as Language
    return saved || 'zh'
  }
  
  // State
  const theme = ref<Theme>(getInitialTheme())
  const language = ref<Language>(getInitialLanguage())
  const sidebarCollapsed = ref(false)
  
  // Getters
  const isDark = computed(() => theme.value === 'dark')
  const currentLanguage = computed(() => language.value)
  
  // Actions
  function setTheme(newTheme: Theme) {
    theme.value = newTheme
    localStorage.setItem('theme', newTheme)
    
    // 更新 HTML 的 data-theme 属性(用于 CSS 变量)
    document.documentElement.setAttribute('data-theme', newTheme)
  }
  
  function toggleTheme() {
    setTheme(theme.value === 'light' ? 'dark' : 'light')
  }
  
  function setLanguage(lang: Language) {
    language.value = lang
    localStorage.setItem('language', lang)
  }
  
  function toggleSidebar() {
    sidebarCollapsed.value = !sidebarCollapsed.value
  }
  
  return {
    theme,
    language,
    sidebarCollapsed,
    isDark,
    currentLanguage,
    setTheme,
    toggleTheme,
    setLanguage,
    toggleSidebar
  }
})

4.4 统一导出(方便使用)

// stores/index.ts
export { useUserStore } from './modules/user'
export { useAppStore } from './modules/app'
export { useCartStore } from './modules/cart'
export { useProductStore } from './modules/product'

// 如果需要,可以创建一个组合多个 store 的 hook
import { useUserStore } from './modules/user'
import { useAppStore } from './modules/app'

export const useStore = () => ({
  user: useUserStore(),
  app: useAppStore()
})

五、持久化:让状态「记住」自己

5.1 问题场景

用户登录后刷新页面,状态丢了——这是初学者最常见的困惑。

// 刷新后,token 没了,又要重新登录
// 用户体验:???

5.2 解决方案:pinia-plugin-persistedstate

npm install pinia-plugin-persistedstate
// src/main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate) // 注册插件

5.3 基本用法

// stores/modules/user.ts
export const useUserStore = defineStore('user', {
  state: () => ({
    token: null,
    userInfo: null
  }),
  persist: true // 一键开启持久化
})

就这么简单!默认会:

  • 使用 localStorage
  • key 为 store名(这里是 'user')
  • 自动同步整个 state

5.4 高级配置:按需持久化

有时候我们不想存所有东西(比如敏感信息、临时数据):

// stores/modules/user.ts
export const useUserStore = defineStore('user', {
  state: () => ({
    token: null,
    userInfo: null,
    tempSearchKeyword: '', // 这个不想持久化
    loginTime: null
  }),
  persist: {
    key: 'user-storage', // 自定义存储 key
    storage: localStorage, // 可选 sessionStorage
    paths: ['token', 'userInfo'], // 只持久化这两个字段
    beforeRestore: (context) => {
      console.log('即将恢复状态', context)
    },
    afterRestore: (context) => {
      console.log('状态恢复完成', context)
    }
  }
})

5.5 Setup Store 的持久化写法

// stores/modules/app.ts
export const useAppStore = defineStore('app', () => {
  const theme = ref('light')
  const language = ref('zh')
  
  // ... 其他逻辑
  
  return {
    theme,
    language
  }
}, {
  persist: {
    key: 'app-settings',
    paths: ['theme', 'language'] // 只持久化主题和语言
  }
})

5.6 多标签页同步

如果你想让多个标签页的状态保持同步,可以这样配置:

// stores/modules/user.ts
export const useUserStore = defineStore('user', {
  state: () => ({
    token: null
  }),
  persist: {
    storage: localStorage,
    // 监听 storage 事件,实现多标签页同步
    beforeRestore: (context) => {
      window.addEventListener('storage', (e) => {
        if (e.key === 'user-storage') {
          // 重新恢复状态
          context.store.$hydrate()
        }
      })
    }
  }
})

六、Store 组合与复用(类似 Composables)

这是 Pinia 最强大的特性之一:Store 可以像组合式函数一样复用-5

6.1 场景:多个模块需要认证逻辑

假设你的应用有多个模块都需要用到用户认证状态,不想在每个 Store 里重复写一遍登录/登出逻辑。

// stores/composables/useAuth.ts
import { ref, computed } from 'vue'

export function useAuth() {
  const isLoggedIn = ref(false)
  const username = ref('')
  
  function login(name: string) {
    isLoggedIn.value = true
    username.value = name
  }
  
  function logout() {
    isLoggedIn.value = false
    username.value = ''
  }
  
  return {
    isLoggedIn,
    username,
    login,
    logout
  }
}

6.2 在 Store 中复用

// stores/modules/user.ts
import { defineStore } from 'pinia'
import { useAuth } from '../composables/useAuth'

export const useUserStore = defineStore('user', () => {
  // 复用认证逻辑
  const { isLoggedIn, username, login, logout } = useAuth()
  
  // 扩展用户专属状态
  const userId = ref<number | null>(null)
  const avatar = ref('')
  
  // 扩展登录方法
  const loginWithId = (name: string, id: number) => {
    login(name) // 调用复用的 login
    userId.value = id
  }
  
  return {
    isLoggedIn,
    username,
    userId,
    avatar,
    login: loginWithId,
    logout
  }
})

// stores/modules/admin.ts
import { defineStore } from 'pinia'
import { useAuth } from '../composables/useAuth'

export const useAdminStore = defineStore('admin', () => {
  // 同样复用认证逻辑
  const { isLoggedIn, username, login, logout } = useAuth()
  
  // 管理员特有的状态
  const adminLevel = ref(1)
  
  return {
    isLoggedIn,
    username,
    adminLevel,
    login,
    logout
  }
})

6.3 场景:数据缓存逻辑复用

多个模块都需要缓存数据(比如商品列表、订单列表),可以封装一个通用的缓存逻辑-5

// stores/composables/useCache.ts
import { ref } from 'vue'

export function useCache<T>(key: string, fetchFn: () => Promise<T>, expireTime = 5 * 60 * 1000) {
  const cachedData = ref<T | null>(null)
  const lastFetchTime = ref<number | null>(null)
  
  const getData = async () => {
    const now = Date.now()
    
    // 如果有缓存且未过期,直接返回缓存
    if (cachedData.value && lastFetchTime.value && (now - lastFetchTime.value) < expireTime) {
      console.log(`[缓存命中] ${key}`)
      return cachedData.value
    }
    
    // 否则重新获取
    console.log(`[缓存失效] ${key},重新获取`)
    const freshData = await fetchFn()
    cachedData.value = freshData
    lastFetchTime.value = now
    return freshData
  }
  
  const clearCache = () => {
    cachedData.value = null
    lastFetchTime.value = null
  }
  
  return {
    getData,
    clearCache,
    cachedData
  }
}
// stores/modules/product.ts
import { defineStore } from 'pinia'
import { useCache } from '../composables/useCache'
import { fetchProductList } from '@/api/product'

export const useProductStore = defineStore('product', () => {
  const { getData, clearCache, cachedData } = useCache(
    'products',
    fetchProductList,
    10 * 60 * 1000 // 10分钟缓存
  )
  
  const loadProducts = async () => {
    return await getData()
  }
  
  return {
    products: cachedData,
    loadProducts,
    clearCache
  }
})

七、在组件中使用:三种姿势

7.1 基础用法(最常用)

<!-- views/Profile.vue -->
<template>
  <div class="profile">
    <h2>个人中心</h2>
    
    <div v-if="userStore.isLoggedIn">
      <el-avatar :src="userStore.userInfo?.avatar" />
      <p>用户名:{{ userStore.userName }}</p>
      <p>角色:{{ userStore.userRole }}</p>
      
      <el-button @click="handleLogout">退出登录</el-button>
    </div>
    
    <div v-else>
      <p>请先登录</p>
      <el-button @click="goToLogin">去登录</el-button>
    </div>
    
    <!-- 测试权限指令 -->
    <button v-if="userStore.hasPermission('product:edit')">
      编辑商品
    </button>
  </div>
</template>

<script setup lang="ts">
import { useUserStore } from '@/stores/modules/user'
import { useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'

const userStore = useUserStore()
const router = useRouter()

const handleLogout = () => {
  ElMessageBox.confirm('确认退出登录吗?', '提示', {
    type: 'info'
  }).then(() => {
    userStore.logout()
    router.push('/login')
  })
}

const goToLogin = () => {
  router.push('/login')
}
</script>

7.2 解构赋值(小心丢失响应性)

<script setup>
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia' // 重要!

const userStore = useUserStore()

// ❌ 错误:直接解构会丢失响应性
const { userName, isLoggedIn } = userStore

// ✅ 正确:使用 storeToRefs
const { userName, isLoggedIn, userInfo } = storeToRefs(userStore)

// actions 可以直接解构(不会丢失)
const { login, logout } = userStore
</script>

7.3 在路由守卫中使用

// src/router/index.ts
import { useUserStore } from '@/stores/modules/user'

router.beforeEach((to, from, next) => {
  // 需要手动获取 store 实例
  const userStore = useUserStore()
  
  if (to.meta.requiresAuth && !userStore.isLoggedIn) {
    next({ path: '/login', query: { redirect: to.fullPath } })
  } else {
    next()
  }
})

7.4 在 axios 拦截器中使用

// src/utils/request.ts
import { useUserStore } from '@/stores/modules/user'

request.interceptors.request.use((config) => {
  const userStore = useUserStore()
  
  if (userStore.token) {
    config.headers.Authorization = `Bearer ${userStore.token}`
  }
  
  return config
})

request.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      const userStore = useUserStore()
      userStore.logout() // 自动清除状态
      router.push('/login')
    }
    return Promise.reject(error)
  }
)

八、Pinia 插件开发:定制你的专属功能

8.1 日志插件:记录所有状态变化

// stores/plugins/logger.ts
import type { PiniaPluginContext } from 'pinia'

export function loggerPlugin({ store, options }: PiniaPluginContext) {
  // 订阅 state 变化
  store.$subscribe((mutation, state) => {
    console.group(`📝 [${store.$id}] 状态变化`)
    console.log('类型:', mutation.type)
    console.log('载荷:', mutation.payload)
    console.log('新状态:', state)
    console.groupEnd()
  })
  
  // 订阅 action 调用
  store.$onAction(({
    name,       // action 名称
    store,      // store 实例
    args,       // 参数
    after,      // 成功后回调
    onError     // 失败后回调
  }) => {
    console.log(`🚀 [${store.$id}] 调用 action: ${name}`, args)
    
    after(result => {
      console.log(`✅ [${store.$id}] action 成功: ${name}`, result)
    })
    
    onError(error => {
      console.error(`❌ [${store.$id}] action 失败: ${name}`, error)
    })
  })
}

8.2 注册插件

// src/main.ts
import { loggerPlugin } from './stores/plugins/logger'

const pinia = createPinia()
pinia.use(loggerPlugin) // 全局生效

8.3 自定义持久化插件

// stores/plugins/customPersist.ts
export function customPersist({ store }: PiniaPluginContext) {
  // 从 localStorage 恢复状态
  const savedState = localStorage.getItem(`pinia:${store.$id}`)
  if (savedState) {
    store.$patch(JSON.parse(savedState))
  }
  
  // 订阅变化并保存
  store.$subscribe((mutation, state) => {
    localStorage.setItem(`pinia:${store.$id}`, JSON.stringify(state))
  })
}

九、性能优化与最佳实践

9.1 避免在 getter 中返回新对象

// ❌ 错误:每次访问都返回新对象,破坏缓存
getters: {
  filteredList: (state) => {
    return state.list.filter(item => item.active) // 每次都是新数组
  }
}

// ✅ 正确:getter 本身会缓存计算结果
getters: {
  activeCount: (state) => state.list.filter(item => item.active).length
}

9.2 按需加载 Store

// 在组件中动态导入(适用于大型应用)
const useUserStore = () => import('@/stores/user').then(m => m.useUserStore)

// 或者在路由懒加载时使用
const UserModule = () => import('@/views/User.vue')

9.3 使用 shallowRef 优化大对象

import { shallowRef } from 'vue'

// 对于大型对象,不需要深度响应式
const bigData = shallowRef(null)

// 只有整体替换时才触发更新
bigData.value = await fetchLargeDataset()

9.4 重置 Store 状态

// 添加重置方法
export const useUserStore = defineStore('user', () => {
  const initialState = {
    token: null,
    userInfo: null,
    permissions: []
  }
  
  const token = ref(initialState.token)
  const userInfo = ref(initialState.userInfo)
  const permissions = ref(initialState.permissions)
  
  function $reset() {
    token.value = initialState.token
    userInfo.value = initialState.userInfo
    permissions.value = initialState.permissions
    localStorage.removeItem('token')
  }
  
  return {
    token,
    userInfo,
    permissions,
    $reset,
    // ... 其他 actions
  }
})

十、TypeScript 类型增强

10.1 为 store 添加类型

// stores/modules/user.ts
import type { UserInfo } from '@/types/user'

export interface UserState {
  token: string | null
  userInfo: UserInfo | null
  permissions: string[]
}

export const useUserStore = defineStore('user', {
  state: (): UserState => ({
    token: null,
    userInfo: null,
    permissions: []
  })
})

10.2 扩展 Pinia 类型(为所有 store 添加通用方法)

// types/pinia.d.ts
import 'pinia'

declare module 'pinia' {
  export interface PiniaCustomProperties {
    // 给所有 store 添加 $reset 方法
    $reset(): void
    
    // 添加自定义属性
    readonly $id: string
  }
  
  export interface PiniaCustomStateProperties<S> {
    // 给所有 state 添加 toJSON 方法
    toJSON(): S
  }
}

10.3 为插件添加类型

// stores/plugins/logger.ts
import type { PiniaPluginContext } from 'pinia'

export interface LoggerPluginOptions {
  enabled?: boolean
  filter?: (storeId: string) => boolean
}

export function loggerPlugin(options: LoggerPluginOptions = {}) {
  return (context: PiniaPluginContext) => {
    // 插件逻辑
  }
}

十一、实战演练:完整的购物车模块

让我们把学到的知识串起来,实现一个完整的购物车模块。

11.1 购物车 Store

// stores/modules/cart.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { CartItem, Product } from '@/types'
import { ElMessage } from 'element-plus'

export const useCartStore = defineStore('cart', () => {
  // --- State ---
  const items = ref<CartItem[]>([])
  const loading = ref(false)
  const lastUpdated = ref<Date | null>(null)
  
  // --- Getters ---
  const totalCount = computed(() => {
    return items.value.reduce((sum, item) => sum + item.quantity, 0)
  })
  
  const totalPrice = computed(() => {
    return items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
  })
  
  const isEmpty = computed(() => items.value.length === 0)
  
  const formattedTotal = computed(() => {
    return ${totalPrice.value.toFixed(2)}`
  })
  
  // --- Actions ---
  function addItem(product: Product, quantity = 1) {
    const existing = items.value.find(item => item.id === product.id)
    
    if (existing) {
      existing.quantity += quantity
    } else {
      items.value.push({
        id: product.id,
        name: product.name,
        price: product.price,
        image: product.image,
        quantity
      })
    }
    
    lastUpdated.value = new Date()
    ElMessage.success(`已添加 ${product.name} 到购物车`)
  }
  
  function removeItem(productId: number) {
    const index = items.value.findIndex(item => item.id === productId)
    if (index > -1) {
      const removed = items.value[index]
      items.value.splice(index, 1)
      ElMessage.success(`已移除 ${removed.name}`)
    }
  }
  
  function updateQuantity(productId: number, quantity: number) {
    const item = items.value.find(item => item.id === productId)
    if (item) {
      if (quantity <= 0) {
        removeItem(productId)
      } else {
        item.quantity = quantity
      }
    }
  }
  
  function clearCart() {
    items.value = []
    ElMessage.success('购物车已清空')
  }
  
  async function checkout() {
    if (isEmpty.value) {
      ElMessage.warning('购物车是空的')
      return false
    }
    
    loading.value = true
    try {
      // 模拟提交订单
      await new Promise(resolve => setTimeout(resolve, 1500))
      
      // 提交成功后清空购物车
      clearCart()
      ElMessage.success('下单成功!')
      return true
    } catch (error) {
      ElMessage.error('下单失败,请重试')
      return false
    } finally {
      loading.value = false
    }
  }
  
  return {
    // state
    items,
    loading,
    lastUpdated,
    // getters
    totalCount,
    totalPrice,
    isEmpty,
    formattedTotal,
    // actions
    addItem,
    removeItem,
    updateQuantity,
    clearCart,
    checkout
  }
}, {
  persist: {
    key: 'shopping-cart',
    paths: ['items'], // 只持久化商品列表
    storage: localStorage
  }
})

11.2 在组件中使用

<!-- components/CartIcon.vue -->
<template>
  <el-badge :value="cartStore.totalCount" :hidden="cartStore.isEmpty">
    <el-button :icon="ShoppingCart" @click="showCartDrawer = true">
      购物车
    </el-button>
  </el-badge>
  
  <el-drawer v-model="showCartDrawer" title="购物车" size="400px">
    <div v-loading="cartStore.loading" class="cart-content">
      <template v-if="!cartStore.isEmpty">
        <div v-for="item in cartStore.items" :key="item.id" class="cart-item">
          <img :src="item.image" :alt="item.name" class="item-image">
          <div class="item-info">
            <h4>{{ item.name }}</h4>
            <p class="item-price">¥{{ item.price }}</p>
          </div>
          <div class="item-actions">
            <el-input-number
              v-model="item.quantity"
              :min="1"
              :max="99"
              size="small"
              @change="handleQuantityChange(item.id, $event)"
            />
            <el-button
              type="danger"
              :icon="Delete"
              link
              @click="cartStore.removeItem(item.id)"
            />
          </div>
        </div>
        
        <div class="cart-footer">
          <div class="total">
            <span>总计:</span>
            <span class="total-price">{{ cartStore.formattedTotal }}</span>
          </div>
          <el-button
            type="primary"
            :loading="cartStore.loading"
            @click="handleCheckout"
          >
            结算
          </el-button>
        </div>
      </template>
      
      <el-empty v-else description="购物车空空如也" />
    </div>
  </el-drawer>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { ShoppingCart, Delete } from '@element-plus/icons-vue'
import { useCartStore } from '@/stores/modules/cart'
import { ElMessageBox } from 'element-plus'

const cartStore = useCartStore()
const showCartDrawer = ref(false)

const handleQuantityChange = (productId: number, quantity: number) => {
  cartStore.updateQuantity(productId, quantity)
}

const handleCheckout = async () => {
  ElMessageBox.confirm('确认提交订单吗?', '提示', {
    type: 'info'
  }).then(async () => {
    const success = await cartStore.checkout()
    if (success) {
      showCartDrawer.value = false
    }
  })
}
</script>

<style scoped lang="scss">
.cart-content {
  padding: 20px;
  height: 100%;
  display: flex;
  flex-direction: column;
}

.cart-item {
  display: flex;
  align-items: center;
  padding: 12px 0;
  border-bottom: 1px solid #eee;
  
  .item-image {
    width: 60px;
    height: 60px;
    object-fit: cover;
    border-radius: 4px;
    margin-right: 12px;
  }
  
  .item-info {
    flex: 1;
    
    h4 {
      margin: 0 0 4px;
      font-size: 14px;
    }
    
    .item-price {
      margin: 0;
      color: #f56c6c;
      font-weight: bold;
    }
  }
  
  .item-actions {
    display: flex;
    align-items: center;
    gap: 8px;
  }
}

.cart-footer {
  margin-top: auto;
  padding-top: 20px;
  border-top: 2px solid #eee;
  
  .total {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 16px;
    font-size: 16px;
    
    .total-price {
      color: #f56c6c;
      font-size: 20px;
      font-weight: bold;
    }
  }
}
</style>

十二、总结与进阶

12.1 Pinia 核心要点回顾

概念 作用 类比
State 存储数据 组件的 data
Getter 计算派生状态 组件的 computed
Action 修改状态的方法 组件的 methods
Plugin 扩展功能 全局混入
Store 上述内容的容器 一个模块

12.2 什么时候用 Pinia?

  • ✅ 多个组件共享同一份数据
  • ✅ 数据需要跨路由持久化
  • ✅ 有复杂的业务逻辑需要复用
  • ✅ 需要 DevTools 调试状态变化
  • ❌ 简单的父子组件通信(用 props/emit 就够了)

12.3 下一步学习方向

  1. Pinia + Vue Query:服务端状态管理
  2. Pinia + WebSocket:实时数据同步
  3. Pinia 源码阅读:理解响应式原理
  4. 自定义插件开发:根据项目需求定制

12.4 写在最后

从 Vuex 到 Pinia,不仅仅是 API 的简化,更是对「状态管理应该简单」这一理念的回归。就像 Evan You 说的:

"Pinia 成功地在保持清晰的设计分离的同时,提供了简单、小巧且易于上手的 API。"

掌握 Pinia,不是为了炫技,而是为了让代码更清晰、维护更简单。现在,去重构你项目里的状态管理吧!🚀

v-once和v-memo完全指南:告别不必要的渲染,让应用飞起来

作者 wuhen_n
2026年3月16日 09:10

前言

在日常开发中,我们可能遇到过这样的情况:写了一个 Vue 应用,数据量稍微大一点,页面就开始卡顿;用户只是点击了一个按钮,整个页面都要重新渲染;明明大部分内容都没变,却感觉应用像“老了十岁”一样慢。这是为什么呢?

Vue 的响应式系统很智能,但它也有“过度反应”的时候。就像我们只是拍了拍桌子,整个办公室的人都站起来看看发生了什么——这显然是一种浪费。

v-oncev-memo 就是来解决这个问题的。它们像两个聪明的“保安”,告诉 Vue:“这部分内容不用每次都检查,它没变” 和 “这部分内容只有在特定条件变化时才需要检查”。

本文将从最基础的概念讲起,用最通俗的语言,配合完整的代码示例,帮助我们彻底掌握这两个性能优化神器。

为什么要关注不必要的渲染

从一个简单的例子开始

我们先来看一个简单的例子:

<template>
  <div>
    <!-- 动态内容:会变化 -->
    <h2>当前计数:{{ count }}</h2>
    <button @click="count++">点我增加</button>
    
    <!-- 静态内容:永远不会变 -->
    <footer>
      <p>© 2026 我的公司. 版权所有</p>
      <p>联系方式:contact@example.com</p>
      <p>地址:xxx</p>
    </footer>
  </div>
</template>

<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>

这段代码看起来没什么,但实际上会发生了什么呢?

每次点击按钮是,count 都会变化,整个组件都会重新渲染。包括那个 永远不会变 的页脚。

虽然 Vue 的虚拟 DOM 会最终发现页脚没变,不会更新真实的 DOM,但这个过程仍然需要:

  • 执行渲染函数
  • 创建新的虚拟 DOM
  • 和旧的虚拟 DOM 进行对比
  • 确认没有变化,跳过更新

这就像我们每天早上去公司,尽管保安每天都会看到我们,但他们仍然每天都要重新核对我们的身份信息,这是一种不必要的浪费。

Vue 的默认更新机制

响应式数据变化
    ↓
组件重新渲染函数执行
    ↓
生成新的虚拟 DOM 树
    ↓
与旧虚拟 DOM 进行 diff 比较
    ↓
计算出需要更新的真实 DOM
    ↓
执行 DOM 更新

不必要的渲染有多"贵"?

我们先看一段数据:

组件规模 一次不必要的渲染耗时 每天10万次操作 额外开销
小型组件(50个节点) 0.5ms 50,000ms 50秒
中型组件(200个节点) 2ms 200,000ms 3.3分钟
大型组件(1000个节点) 10ms 1,000,000ms 16.7分钟

想象一下,用户每天要多等十几分钟,就因为应用在“瞎忙活”。

什么是不必要的渲染?

简单来说就是:渲染的结果和上一次 完全一样,但过程却重复执行了。

// 这是一个"不必要的渲染"的典型案例
const App = {
  template: `
    <div>
      <!-- 这部分每次都会重新计算,但结果永远一样 -->
      <div>{{ getStaticData() }}</div>
      
      <!-- 这部分确实需要更新 -->
      <div>{{ dynamicData }}</div>
    </div>
  `,
  
  methods: {
    getStaticData() {
      console.log('我被调用了!') // 其实只需要调用一次
      return '永远不变的内容'
    }
  }
}

问题:即使大部分内容没变,渲染函数仍会执行,虚拟 DOM 树仍会创建,diff 算法仍需遍历。

v-once:一次渲染,终身躺平

v-once 是什么?

v-once 是 Vue 提供的一个指令,它的作用就像它的名字一样:只渲染一次。之后无论数据怎么变化,这部分内容都不会再更新。

用生活化的比喻理解v-once

想象一下,我们正在装修房子:

  • 普通渲染:每天都要重新粉刷一遍墙壁,尽管颜色没变
  • v-once 渲染:装修一次,以后再也不动它

v-once 的基本用法

<template>
  <div>
    <!-- 普通内容:每次count变化都会更新 -->
    <p>当前计数:{{ count }}</p>
    
    <!-- v-once内容:只渲染一次,之后永远不变 -->
    <p v-once>初始计数:{{ count }}</p>
    
    <button @click="count++">增加计数</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>

运行效果

  • 首次加载:两个都显示“0”
  • 点击按钮:上面变成“1”,下面还是“0”
  • 继续点击:上面一直变,下面永远是“0”

v-once的工作原理

让我们用流程图来理解:

首次渲染
    ↓
遇到 v-once 指令
    ↓
正常渲染内容
    ↓
将生成的虚拟DOM缓存起来
    ↓
打上"静态标记"
    ↓
─────────────────
    ↓
后续更新时
    ↓
遇到 v-once 标记
    ↓
直接返回缓存的虚拟DOM
    ↓
跳过所有更新逻辑

v-once 的实现机制

// 简化版的 v-once 实现原理
function processOnceNode(vnode) {
  if (vnode.shapeFlag & ShapeFlags.COMPONENT_ONCE) {
    // 如果是组件,标记为静态组件
    vnode.isStatic = true
    return vnode
  }
  
  // 如果是元素,创建静态节点
  const staticNode = createStaticVNode(
    vnode.children,
    vnode.props
  )
  
  // 后续更新直接返回缓存的静态节点
  return staticNode
}

v-once 的适用场景

场景一:页脚版权信息等纯静态内容

<!-- 页脚版权信息,永远不变 -->
<footer v-once>
  <p>© 2026 我的公司. All rights reserved.</p>
  <p>ICP备案号:xxxxx</p>
  <div class="contact">
    <p>邮箱:contact@example.com</p>
    <p>电话:400-123-4567</p>
  </div>
</footer>

场景二:一次性初始数据

<template>
  <div class="user-profile">
    <!-- 用户 ID 只在创建时显示,后续不变 -->
    <div v-once class="user-meta">
      <span>用户ID:{{ userId }}</span>
      <span>注册时间:{{ registerDate }}</span>
      <span>会员等级:{{ initialLevel }}</span>
    </div>
    
    <!-- 动态更新的内容 -->
    <div class="user-points">
      当前积分:{{ points }}
      <button @click="points++">签到</button>
    </div>
  </div>
</template>

场景三:复杂的静态组件

<template>
  <div class="dashboard">
    <!-- 左侧:帮助文档组件,完全静态,只需加载一次 -->
    <HelpDocumentation v-once class="sidebar" />
    
    <!-- 右侧:动态更新的内容 -->
    <div class="main-content">
      <DashboardCharts :data="liveData" />
      <RealTimeLogs :logs="systemLogs" />
    </div>
  </div>
</template>

场景四:与 v-for 配合优化列表

<template>
  <div class="data-table">
    <!-- 表格头部完全静态 -->
    <div v-once class="table-header">
      <div class="col">姓名</div>
      <div class="col">年龄</div>
      <div class="col">部门</div>
      <div class="col">操作</div>
    </div>
    
    <!-- 动态列表项 -->
    <div v-for="item in list" :key="item.id" class="table-row">
      <div class="col">{{ item.name }}</div>
      <div class="col">{{ item.age }}</div>
      <div class="col">{{ item.department }}</div>
      <div class="col">
        <button @click="edit(item.id)">编辑</button>
      </div>
    </div>
  </div>
</template>

v-once 的使用注意事项

注意事项 说明 示例
失去响应性 v-once 内的所有数据绑定都变成静态,不再响应更新 <div v-once>{{ count }}</div> 永远不会更新
子树全静态 v-once 作用于元素时,其所有子元素也变为静态 整个组件树都会静态化
避免滥用 只在真正不需要更新的地方使用,否则会导致数据和视图不一致 动态内容不能用 v-once
组件中使用 组件上加 v-once,整个组件只会渲染一次 <ComplexChart v-once />

v-once 性能收益实测

测试环境

  • 页面包含 200 个静态节点
  • 每秒触发 10 次更新
  • 运行 60 秒
指标 未优化 使用 v-once 提升
渲染函数调用次数 60,000 次 600 次 99%
虚拟 DOM 创建 60,000 次 600 次 99%
内存分配 850MB 85MB 90%
CPU 使用率 65% 8% 88%
平均帧率 45fps 60fps 33%

v-memo:有条件地记忆渲染

为什么要 v-memo?

v-once 虽然好,但它的缺点也很明显:要么永远更新,要么永远不更新。现实开发中,我们经常遇到这样的情况:

  • 列表项的大部分内容稳定,但少数字段会变
  • 组件的大部分数据不变,但需要响应某些特定变化

这时候就需要 v-memo 了。

v-memo 是什么?

v-memo 是 Vue 3.2+ 引入的新指令,它可以接受一个依赖数组,只有当数组中的值变化时,才会重新渲染。

用生活化的比喻理解 v-memo

想象一下,我们在公司里:

  • 普通员工:领导一喊,所有人都站起来(不管是不是叫自己)
  • v-memo 员工:只有听到自己名字才站起来

v-memo的基本用法

<template>
  <div 
    v-for="item in items" 
    :key="item.id"
    v-memo="[item.id, item.price, item.stock]"
  >
    <!-- 只有当 item.id、item.price 或 item.stock 变化时才重新渲染 -->
    <h3>{{ item.name }}</h3>
    <p>价格:{{ item.price }}</p>
    <p>库存:{{ item.stock }}</p>
    <button @click="toggleFavorite(item.id)">
      {{ item.isFavorite ? '取消收藏' : '收藏' }}
    </button>
  </div>
</template>

v-memo的工作原理

让我们用流程图来理解:

首次渲染
    ↓
计算依赖数组的值
    ↓
缓存这些值和生成的虚拟DOM
    ↓
─────────────────
    ↓
后续更新触发
    ↓
重新计算依赖数组的新值
    ↓
和缓存的值比较
    ↓
有变化?→ 是 → 重新渲染,更新缓存
    ↓       
    否
    ↓
直接返回缓存的虚拟DOM
    ↓
跳过所有更新逻辑

v-memo 工作机制的三阶段

1. 依赖收集阶段

  • 编译时解析依赖数组
  • 建立响应式依赖图谱
  • 为每个节点创建 memo 缓存

2. 缓存对比阶段

  • 重新渲染前计算依赖数组的新值
  • 与缓存的上次值进行浅比较
  • 若未变化 → 直接复用缓存的 VNode 树
  • 若已变化 → 重新生成 VNode 并更新缓存

3. 虚拟 DOM 跳过

  • 完全跳过该节点的 diff 计算
  • 不触发子树的渲染函数
  • 直接复用真实 DOM

v-memo的实战场景

场景一:超大规模商品列表

想象一个电商网站的商品列表,有1万件商品:

<template>
  <div class="product-list">
    <div 
      v-for="product in products" 
      :key="product.id"
      v-memo="[
        product.id, 
        product.price, 
        product.stock, 
        product.isFavorite
      ]"
      class="product-item"
    >
      <img :src="product.image" :alt="product.name" />
      <h3>{{ product.name }}</h3>
      <p class="price">¥{{ product.price }}</p>
      <p class="stock">库存: {{ product.stock }}件</p>
      <p class="sales">销量: {{ product.sales }}件</p>
      <p class="rating">评分: {{ product.rating }}分</p>
      <button 
        @click="toggleFavorite(product.id)"
        :class="{ active: product.isFavorite }"
      >
        {{ product.isFavorite ? '已收藏' : '收藏' }}
      </button>
    </div>
  </div>
</template>

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

// 生成1万件商品
const products = ref(
  Array.from({ length: 10000 }, (_, i) => ({
    id: i,
    name: `商品 ${i}`,
    price: Math.floor(Math.random() * 1000),
    stock: Math.floor(Math.random() * 100),
    sales: Math.floor(Math.random() * 1000),
    rating: (Math.random() * 5).toFixed(1),
    image: `https://picsum.photos/200/150?random=${i}`,
    isFavorite: false
  }))
)

function toggleFavorite(id) {
  const product = products.value.find(p => p.id === id)
  product.isFavorite = !product.isFavorite
  // ✅ 只有被点击的那一项会重新渲染
}
</script>

优化效果:

  • 用户点击收藏时,只有被点击的商品重新渲染
  • 后台更新价格时,只有价格变化的商品重新渲染
  • 其他 9999 件商品完全不动

场景二:复杂计算缓存

<template>
  <div class="dashboard">
    <!-- 只有当原始数据或用户设置变化时才重新计算 -->
    <div 
      class="dashboard-content"
      v-memo="[rawData.version, userSettings.theme]"
    >
      <DashboardHeader />
      
      <!-- 这里的数据需要复杂计算 -->
      <DataVisualization :data="processedData" />
      <StatsCards :stats="computedStats" />
      <ActivityChart :chart-data="chartData" />
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'

const rawData = ref(fetchData()) // 10MB的原始数据
const userSettings = ref({ theme: 'light', language: 'zh' })

// 复杂计算:处理10MB数据
const processedData = computed(() => {
  console.log('正在处理数据...') // 我们希望这个不要频繁执行
  return rawData.value.map(item => ({
    ...item,
    processed: heavyComputation(item)
  }))
})

// 当用户切换主题时,不应该重新计算processedData
// 但上面的v-memo确保了这一点:只有rawData.version或userSettings.theme变化时才重新渲染
</script>

场景三:聊天消息列表

<template>
  <div class="chat-messages">
    <div 
      v-for="msg in messages" 
      :key="msg.id"
      v-memo="[msg.id, msg.content, msg.timestamp, msg.isRead]"
      class="message"
      :class="{ 'message-self': msg.senderId === currentUserId }"
    >
      <img :src="msg.avatar" class="avatar" />
      <div class="content">
        <div class="sender">{{ msg.senderName }}</div>
        <div class="text">{{ msg.content }}</div>
        <div class="time">{{ formatTime(msg.timestamp) }}</div>
      </div>
      <div class="status">
        <span v-if="msg.isRead">已读</span>
        <span v-else-if="msg.isSending">发送中...</span>
        <span v-else-if="msg.isFailed">发送失败</span>
      </div>
    </div>
  </div>
</template>

<script setup>
const messages = ref([])

// 新消息到来时,只有新消息会渲染
// 已读状态变化时,只有那条消息会更新
// 其他消息完全不动
</script>

场景四:选中状态高亮

<template>
  <div class="image-gallery">
    <div 
      v-for="image in images" 
      :key="image.id"
      v-memo="[selectedId === image.id]"
      class="image-item"
      :class="{ selected: selectedId === image.id }"
      @click="selectedId = image.id"
    >
      <img :src="image.thumbnail" :alt="image.title" />
      <div class="overlay">
        <h4>{{ image.title }}</h4>
        <button @click.stop="download(image.id)">下载</button>
      </div>
    </div>
  </div>
</template>

<script setup>
const selectedId = ref(null)

// 点击时,只有之前选中的和当前选中的两个图片会重新渲染
// 其他9998张图片完全不动
</script>

v-memo 依赖项选择的黄金法则

  • 精准包含:只放那些真正会影响渲染的字段
  • 避免冗余:不要把整个对象放进去
  • 稳定依赖:不要用 Date.now() 这种每次都变的值
  • 版本控制:复杂对象可以用版本号

选择决策树

graph TD
    Start[遇到一个组件/元素] --> Question1{内容永远不变吗?}
    Question1 -->|是| A[用 v-once]
    Question1 -->|否| Question2{是长列表?<br>(>500项)}
    
    Question2 -->|否| B[暂时不需要优化]
    Question2 -->|是| Question3{更新频率高吗?}
    
    Question3 -->|低| C[保持现状]
    Question3 -->|高| Question4{能否精确控制更新?}
    
    Question4 -->|否| D[考虑虚拟滚动]
    Question4 -->|是| E[用 v-memo 精确优化]

v-once vs v-memo,如何选择?

特性对比表

对比维度 v-once v-memo
适用版本 Vue 2+ Vue 3.2+
更新策略 永不更新 条件更新
依赖声明 显式数组
学习难度 ⭐⭐⭐
适用场景 纯静态内容 大部分稳定的动态内容
代码侵入性

组合使用示例

<template>
  <div class="app">
    <!-- 1. 完全静态的头部 -->
    <header v-once>
      <AppLogo />
      <AppTitle />
      <NavigationMenu />
    </header>
    
    <!-- 2. 动态列表,但有条件更新 -->
    <div class="content">
      <div 
        v-for="item in items" 
        :key="item.id"
        v-memo="[item.id, item.updatedAt]"
      >
        <!-- 2.1 每个列表项内部的静态部分 -->
        <div v-once class="item-static">
          <img :src="item.avatar" />
          <span>ID: {{ item.id }}</span>
        </div>
        
        <!-- 2.2 每个列表项内部的动态部分 -->
        <div class="item-dynamic">
          <h3>{{ item.title }}</h3>
          <p>{{ item.content }}</p>
          <span>点赞: {{ item.likes }}</span>
        </div>
      </div>
    </div>
    
    <!-- 3. 完全静态的页脚 -->
    <footer v-once>
      <Copyright />
      <ContactInfo />
    </footer>
  </div>
</template>

性能收益对比

场景 优化前 v-once v-memo
静态页脚 每次更新都渲染 0次更新 不适用
收藏按钮点击 整个列表重绘 不适用 只更新单个项
价格批量更新 整个列表重绘 不适用 只更新价格变化项
列表项1000条 120ms 不适用 35ms

常见陷阱与解决方案

v-memo 依赖遗漏

<!-- ❌ 错误:遗漏了关键依赖 -->
<div 
  v-for="item in items"
  v-memo="[item.id]"
>
  {{ item.name }}  <!-- 当name变化时,这里不会更新! -->
  <span :class="{ active: item.isActive }">
    {{ item.status }}
  </span>
</div>

<!-- ✅ 正确:包含所有依赖 -->
<div 
  v-for="item in items"
  v-memo="[item.id, item.name, item.isActive, item.status]"
>
  {{ item.name }}
  <span :class="{ active: item.isActive }">
    {{ item.status }}
  </span>
</div>

在错误的位置使用 v-memo

<!-- ❌ 错误:在父容器上使用v-memo -->
<ul v-memo="[items.length]">
  <li v-for="item in items" :key="item.id">
    {{ item.name }}
  </li>
</ul>
<!-- 结果:items.length不变时,整个列表都不更新 -->
<!-- 但item.name变化时也不会更新! -->

<!-- ✅ 正确:在v-for的项上使用 -->
<ul>
  <li 
    v-for="item in items" 
    :key="item.id"
    v-memo="[item.id, item.name]"
  >
    {{ item.name }}
  </li>
</ul>

滥用v-once导致bug

<!-- ❌ 错误:动态内容用了v-once -->
<div v-once>
  <h3>当前用户:{{ username }}</h3>  <!-- 永远不会更新! -->
  <button @click="logout">退出登录</button>
</div>

<!-- ✅ 正确:只静态化真正静态的部分 -->
<div>
  <h3>当前用户:{{ username }}</h3>  <!-- 动态 -->
  <div v-once>操作面板</div>  <!-- 静态 -->
  <button @click="logout">退出登录</button>  <!-- 动态 -->
</div>

最佳实践清单

什么时候用 v-once?

  • 版权信息、页脚
  • 表格表头
  • 静态导航菜单
  • 一次性初始数据
  • 复杂的静态组件(帮助文档、使用说明)

什么时候用 v-memo?

  • 超长列表(>500项)
  • 高频更新的区域隔离
  • 选中状态切换
  • 复杂计算的缓存
  • 聊天消息列表

优化检查清单

  • v-memo 的依赖数组包含了所有影响渲染的字段
  • 避免在 v-memo 中使用 Date.now()Math.random()
  • v-memo 正确放在 v-for 的项上,而不是父容器
  • v-once 只用于真正静态的内容
  • 组合使用时逻辑清晰
  • 用性能工具验证了优化效果

性能优化的哲学

  1. 优化不是炫技:用数据和用户体感说话
  2. 适度原则:不是所有地方都需要优化
  3. 持续演进:性能优化是过程,不是终点
  4. 量化的力量:没有数据的优化是盲目的

结语

v-oncev-memo 是 Vue 提供的两个强大的优化工具,但它们不是银弹。真正的性能优化,是在理解业务场景的基础上,选择合适的技术,验证优化效果,持续改进的过程。让该更新的更新,该躺平的躺平,这才是 Vue 性能优化的真谛!

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

❌
❌