很多人写Vue笔记,记着记着就变成了一张张API抄录卡片,过两个月回看只想删掉。我自己的笔记经历过好几轮重构,从最早的“官方文档摘抄”,到后来的“痛点场景 + 解决模板”,才算真正变成能复用的东西。这篇笔记不是教程的搬运,而是我实际做项目时沉淀下来的一些关键思路,包括选项式与组合式怎么选、组件通信的取舍、路由与状态管理怎么配合、性能优化从哪下手,以及一份能直接照着排查的报错速查表。不管是刚接触Vue、正在用Vue写业务,还是准备梳理自己知识体系的开发者,应该都能从中找到一些有用的东西。
1. 从需求到框架:先搞懂Vue到底帮你解决了什么问题
1.1 不是框架需要你,是你需要一套状态同步方案
很多人学Vue上来就背生命周期、背指令,但心里其实没想明白一个问题:为什么页面复杂到一定程度,原生JavaScript写起来会那么痛苦?
核心在于“状态与视图的同步”。用原生JS操作DOM时,你要手动维护一堆变量,还要在每次数据变化后手动调用更新函数。数据一变,视图就得跟着变;视图一交互,数据也得同步回来。这个双向同步的逻辑一旦多起来,代码很快就变成一团乱麻。
Vue的答案是用数据驱动视图:你只需要维护数据(状态),框架负责在数据变化时把视图更新到正确状态。这个思路解决的不是“写代码”的问题,而是“状态一多,怎么保证视图不错乱”的问题。
想通这一点后,再看Vue的响应式系统、计算属性、侦听器,就都是在回答一个核心问题:数据变了,哪些东西需要跟着变,以及以什么顺序、什么粒度去变。
1.2 一个笔记体系该有的结构
我看过很多人的Vue笔记,最常见的问题是没有分层。今天记一个指令用法,明天记一个API参数,全部平铺在一个页面里,想查的时候根本找不到。
我的笔记结构一般分四层:
- 第一层是核心思想:响应式、组件化、虚拟DOM,这些是理解一切的根基。
- 第二层是API速查:指令、选项、组合式函数,按功能分类,只记参数和边界情况。
- 第三层是模式沉淀:组件通信、状态管理、复用逻辑,这些是从项目里提炼出来的通用方案。
- 第四层是踩坑记录:报错信息、原因分析、解决步骤,这是最有个人价值的部分。
没有这个分层,笔记就只是搬运;有了这个分层,笔记才是一个可以持续生长的知识体系。
2. 选项式还是组合式:不是选边站,是看场景
2.1 选项式API的优势区间
选项式API(Options API)把代码按照data、methods、computed、watch这些选项强制分区。这种结构的好处是约定大于配置,上手极快,尤其适合以下场景:
- 组件规模小,逻辑简单,数据、方法、计算属性都很少。
- 团队里有新手,需要一种足够直观、约束力强的写法来保证代码风格统一。
- 维护老项目,现有代码全是选项式,没必要为了“新”而重写。
但它的短板也很明显:当一个组件的逻辑复杂到一定程度,同一个功能相关的代码会被拆散到不同选项中。比如你要追踪一个表单的联动逻辑,data里有字段定义,methods里有处理函数,watch里有监听逻辑,computed里有派生状态,散落在各个区块里,阅读代码时需要在文件里来回跳转。
2.2 组合式API的实战价值
组合式API(Composition API)的核心不是“去掉选项”,而是提供了一种按逻辑关注点组织代码的能力。用一个setup函数(或者<script setup>语法块)把相关代码聚在一起,逻辑内聚性明显更好。
举个例子,做一个搜索列表页,需要处理的关键词、防抖、请求、加载状态、错误处理。选项式写法会把它们分开,组合式则可以全部放一个useSearch函数里,组件里只留调用代码。
<script setup> import { ref } from 'vue' import { useSearch } from './useSearch' const keyword = ref('') const { list, loading, error, search } = useSearch(keyword) </script>注意:组合式API不是为了消灭选项式API,而是为了在逻辑复杂时提供一个更好的组织工具。简单组件用选项式完全没问题;“能用组合式就不许用选项式”这种非黑即白的团队规范,往往忽略了代码的可读性和团队实际情况。
2.3 我的选型建议
如果是新项目,默认用组合式API +<script setup>是合理选择,它更接近未来生态的主流写法。但团队里如果有大量没有组合式经验的成员,建议先小范围尝试,等大家熟悉了响应式API的底层逻辑再全面铺开。选技术方案不能只看趋势,还要看团队当前的交付能力和维护成本。
3. 组件通信的取舍:八种方式,到底该用哪种
3.1 从Props和emit说起
组件通信是Vue开发里绕不开的环节。父传子用props,子传父用emit,这是最基础、也是最推荐的通信方式。它显式、单向、可追踪,数据流清晰,适合绝大多数父子组件的场景。
<!-- 父组件 --> <Child :title="pageTitle" @update="handleUpdate" /> <!-- 子组件 --> <script setup> const props = defineProps({ title: String }) const emit = defineEmits(['update']) </script>要注意的是,props只应该被当作入参读取,不要直接在子组件里修改。Vue会给出警告,而且这种写法破坏了单向数据流,排查问题时会很痛苦。如果确实需要修改,应该通过emit把事件抛给父组件去改,或者用computed做一个可写的中间层。
3.2 依赖注入:跨越层级不要层层传递
深层嵌套的组件树里,如果每一层都用props转发数据,代码会非常冗余。Vue提供了provide和inject,让祖先组件直接向后代组件注入数据,跳过中间层。
// 祖先组件 import { provide, ref } from 'vue' provide('theme', ref('dark')) // 后代组件 import { inject } from 'vue' const theme = inject('theme')使用provide/inject要注意它带来的隐性耦合:后代组件不再通过props声明自己依赖了什么,排查数据来源时就要多绕一层。我的经验是,跨三层以上才考虑用provide/inject,两层以内直接用props更清晰。
3.3 全局状态:什么时候才需要Pinia
很多项目一上来就装Pinia,把所有数据都塞进store,这种做法其实是在给简单问题增加复杂度。Pinia的定位是跨组件、跨路由的共享状态,比如用户信息、购物车、全局主题配置。判断标准很简单:如果这个数据只在一个组件子树里使用,那就用props/emit或者provide/inject;只有当数据在多个不相关的组件里都要用、且需要统一修改时,才上Pinia。
import { defineStore } from 'pinia' export const useUserStore = defineStore('user', { state: () => ({ name: '', roles: [] }), actions: { setUser(user) { this.name = user.name; this.roles = user.roles } } })组件通信这块,我整理过一个决策顺序:父子之间用props/emit;跨层级但作用域明确用provide/inject;跨页面、跨模块共享且需要响应式联动用Pinia;完全不相关的组件之间需要通信,多半是设计出了问题,先想想是不是状态放错了位置,而不是急着引进新的通信方式。
4. 路由与状态管理笔记:把“页面导航”和“数据流转”当成两件事
4.1 路由配置的细节
Vue Router的配置看起来简单,但有几个点容易出问题。
动态路由参数/user/:id,参数通过route.params读取,但组件复用时,参数变化不会触发组件重新创建,所以不能依赖created钩子去加载数据。正确做法是watch路由参数,或者在组件内通过onBeforeRouteUpdate守卫来处理。
import { watch } from 'vue' import { useRoute } from 'vue-router' const route = useRoute() watch(() => route.params.id, (newId) => { fetchData(newId) })路由懒加载是首屏优化的重要一环。组件只有在路由匹配到时才加载对应的JS文件,能明显减少首屏资源体积。写法上要注意,import()里用动态字符串可能导致Webpack/Vite无法正确打包,尽量使用静态路径:
{ path: '/user', name: 'User', component: () => import('@/views/UserInfo.vue') }4.2 路由守卫里面别写业务逻辑
我见过不少项目把登录校验、权限判断、埋点上报全扔在全局前置守卫里,最后这个文件变成几百行的“垃圾场”。我的原则是:守卫只做两件事,一是“判断能不能进”,二是“重定向到哪”,至于这个用户有哪些权限、菜单怎么生成,那是状态管理层的职责。
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next({ name: 'login' }) } else { next() } })4.3 状态管理与路由的边界
简单理解两者的分工:路由管“你在哪”,Store管“你的数据是什么”。不要在路由跳转时把一整坨业务数据塞进query或state里,这会让URL变得不可读,刷新页面后数据还会丢。正确的做法是,URL里只保留定位信息(比如id、页码、筛选条件),完整的业务数据在页面加载时通过接口获取后存入Store。
比如一个商品详情页,路由只保留/product/123,页面组件加载后调用接口获取商品详情。这样直接刷新页面、分享链接、新开标签页,都能恢复到正确状态。反过来,如果你把整个商品对象塞进了路由state,刷新后state清空,页面就废了。
5. 响应式原理笔记:理解ref和reactive比背API重要
5.1 为什么需要响应式
Vue的响应式系统解决的核心问题是:数据变化时,自动更新依赖它的视图。这背后的机制变化经历了从Vue 2的Object.defineProperty到Vue 3的Proxy的升级。Proxy能拦截对象属性的读取和写入,还能拦截新增属性、删除属性、数组索引变化等,这正是Vue 3响应式更完整的原因。
这段代码演示了Vue 3响应式的基本形态(简化版):
const state = reactive({ count: 0 }) // 读取时收集依赖 // 写入时触发更新 state.count++ // 视图自动更新理解这个机制后,很多“奇怪”现象就能解释了:解构出来的count为什么会丢失响应性?因为reactive返回的是Proxy代理对象,当你const { count } = state时,拿到的只是原始数值,不是代理对象,自然没有拦截能力。要想保持响应性,需要借助toRefs:
import { reactive, toRefs } from 'vue' const state = reactive({ count: 0, name: 'vue' }) const { count, name } = toRefs(state) // 这样解构出来的count和name都是ref对象,保持响应性5.2 ref为什么比reactive更常用
日常开发中,ref往往比reactive更顺手。原因很简单:ref可以封装原始类型,传参、解构、组合都方便;reactive只能作用于对象,而且解构后容易丢失响应性。在实践中,我会默认用ref,只有需要一个对象整体做响应式(比如表单数据)时才用reactive。
import { ref } from 'vue' const count = ref(0) const user = ref({ name: '', age: 0 }) // 修改 count.value = 1 user.value.name = '张三'注意,ref创建的对象在模板中使用时不需要写.value,Vue会自动解包。所以模板里写{{ count }},脚本里写count.value,不要搞混。
5.3 计算属性和侦听器:各自该干什么
计算属性(computed)用于根据已有状态派生出新状态,并且有缓存,只有依赖变化时才重新求值。侦听器(watch)用于在状态变化时执行副作用,比如发送请求、操作DOM、写入存储。
import { computed, watch } from 'vue' const firstName = ref('张') const lastName = ref('三') const fullName = computed(() => firstName.value + lastName.value) watch(fullName, (newVal) => { console.log('姓名变化:', newVal) })我的经验是,能用computed表达的就不要用watch手动赋值,后者容易写出重复代码,还会在初始化时多执行一次(除非设置immediate和deep参数)。
6. 组件复用与逻辑抽离笔记:不只是“封装一个组件”
6.1 复用什么:结构、样式还是逻辑
组件化的价值在于复用,但很多初学者以为复用就是“把长得像的部分抠出来做成一个组件”。其实组件复用的维度不止一种:
- 结构性复用:模板片段相似,用插槽(
slot)来定制差异部分。 - 样式性复用:外观相似,通过CSS变量或
props控制风格。 - 逻辑性复用:与视图无关的行为,比如请求、格式化、权限判断,适合抽成组合式函数。
判断该用哪种方式,核心看“变化点在哪里”。如果只是某一块文案和图片不同,用插槽就能解决;如果只是主题色不同,用CSS变量更合适;如果是复杂的交互逻辑要复用,就要考虑组合式函数。
6.2 组合式函数的写法
组合式函数(Composable)是Vue 3中逻辑复用的主要方式。把一组相关的响应式状态和操作封装在一个普通函数里,返回需要暴露的内容。
// useCounter.js import { ref, computed } from 'vue' export function useCounter(initial = 0) { const count = ref(initial) const double = computed(() => count.value * 2) const increment = () => count.value++ const decrement = () => count.value-- return { count, double, increment, decrement } }组件里直接调用:
<script setup> import { useCounter } from './useCounter' const { count, double, increment, decrement } = useCounter(10) </script>这样写的好处是逻辑可以被多个组件复用,测试时也可以脱离组件单独测试。我自己踩过的坑是,组合式函数里的ref如果返回给多个组件共享,实际上就是全局状态了,不再是组件私有状态。如果只想“复用逻辑”而不想“共享状态”,每次调用函数时都要确保创建新的ref,而不是在函数外部定义全局ref。
6.3 封装组件的边界
组件封装的粒度很难拿捏。封装太粗,组件变成“什么都干”的巨无霸,props十几个,内部逻辑分支一堆;封装太细,组件遍地都是,页面代码碎成一地,组合起来反而麻烦。
我给组件封装定的几条规则:
- 一个组件只做一件事,如果它需要十来个props才能满足所有场景,大概率拆错了。
- 通过插槽而不是一堆布尔props来扩展差异部分。
- 组件内部不要直接依赖全局状态,外部状态通过params传入,便于复用和测试。
7. 生命周期与DOM操作笔记:钩子函数到底怎么用
7.1 选项式与组合式的生命周期对照
Vue 3里,组合式API的生命周期钩子函数和选项式是一一对应的。onMounted对应mounted,onUpdated对应updated,onUnmounted对应unmounted,等等。最常见的一个问题是:在setup阶段能不能访问DOM?
答案是不能,因为此时组件还没有挂载到页面上。如果你在setup里直接document.getElementById,大概率拿到null。正确的时机是onMounted里。
<script setup> import { onMounted, ref } from 'vue' const el = ref(null) onMounted(() => { console.log(el.value) // 此时才有真实的DOM节点 }) </script> <template> <div ref="el">hello</div> </template>7.2 异步操作的清理
在组件里发起了异步请求,组件卸载后又触发了数据更新,就会报“警告:组件已卸载,但仍有异步更新”。常见于列表页快速切换路由时,前一个页面的请求还在进行中,组件已经销毁了。
解决方案是在onUnmounted里取消请求或忽略结果。使用Axios时可以通过AbortController取消请求,或者用一个布尔标识判断组件是否已卸载:
import { onMounted, onUnmounted } from 'vue' let isUnmounted = false onMounted(async () => { const data = await fetchData() if (!isUnmounted) { // 安全更新状态 } }) onUnmounted(() => { isUnmounted = true })7.3nextTick的使用场景
修改了响应式数据后,DOM不会立即更新,Vue的更新是异步批量执行的。如果修改数据后立刻读取DOM,读到的是旧值。此时用nextTick:
import { ref, nextTick } from 'vue' const list = ref([]) list.value.push('new item') await nextTick() // 此时DOM已更新,可以访问新渲染的节点常见场景是动态列表渲染后需要滚动到底部,或者需要根据更新后的DOM尺寸做计算。
8. 性能优化笔记:先测量再优化
8.1 最容易见效的几个方向
- 路由懒加载:前面说过了,首屏体积减少,收益非常明显。
v-ifvsv-show:v-if是条件渲染,切换时创建/销毁组件,适合切换频率低的场景;v-show是CSS切换显示,组件始终存在,适合高频切换。- 列表加
key:稳定且唯一,不要用index,否则列表项复用会出状态错乱。 - 大数据列表用虚拟滚动:几千上万条数据渲染会导致DOM节点过多,页面卡顿,虚拟滚动只渲染可视区域,是这类场景的标准解法。
8.2 计算属性缓存的价值
计算属性会缓存结果,只有依赖的响应式数据变化时才重新计算。模板里的方法调用则没有缓存,只要组件重新渲染,方法就会被重新执行一次。对于一个开销较大的计算,应该用computed而不是方法。
import { computed } from 'vue' const expensiveResult = computed(() => { return bigArray.value.filter(item => item.visible) })8.3 避免过深监听
watch默认是浅层监听,如果你需要监听一个嵌套对象的所有属性变化,需要设置deep: true:
watch( () => user.value, (newVal) => { console.log(newVal) }, { deep: true } )deep监听会对对象做递归遍历,开销不小,在大对象上要谨慎使用。更好的方式是尽量监听具体的属性路径,或者用computed返回需要关注的分片后监听。
8.4 性能优化不要靠猜
没有Profile就优化,等于蒙着眼睛修车。我的习惯是先用Vue Devtools的Performance面板和浏览器自带的Performance录制,找出真正的性能瓶颈,再针对性地优化。很多时候卡顿的根源不在Vue本身,而在大量DOM重排、特大JSON解析、或者无节制的watch执行。
9. 工程化与工具链笔记:Vite、ESLint、调试配置
9.1 Vite带来的变化
Vite开发时的极速冷启动让它基本成了新项目的默认选择。它的核心思路是利用浏览器原生ES Module支持,免去了开发时的打包步骤,按需编译,改动保存后热更新几乎是即时的。
npm create vite@latest my-app -- --template vue生产构建时会用Rollup做打包和代码拆分,配置也不需要过多干预。但要注意的是,Vite构建出的代码目标浏览器需要支持ES Modules,老浏览器要加@vitejs/plugin-legacy做降级。
9.2 环境变量与模式
Vite用import.meta.env来暴露环境变量,以VITE_前缀开头的变量会自动暴露给客户端代码:
# .env.development VITE_API_BASE=/api-dev # .env.production VITE_API_BASE=/apiconst apiBase = import.meta.env.VITE_API_BASE不要把接口地址、密钥硬编码在业务代码里,环境变量是管理不同环境配置的标准方式。
9.3 ESLint和Prettier的作用
团队协作中,代码规范比个人风格重要。ESLint负责检查代码质量和潜在问题,Prettier负责格式化。两者配合,可以让代码风格自动统一,减少代码评审时的无谓争论。
我的建议是,新项目直接使用官方推荐的eslint-plugin-vue,严格模式起步,后面觉得太严再逐步放宽,比一开始松散、后面收紧要容易得多。
npm install eslint eslint-plugin-vue --save-dev9.4 调试工具
Vue Devtools浏览器插件是必须装的,它能直观地查看组件树、props、事件、Pinia状态和时间线性能记录。我排查组件数据问题时,第一件事就是打开Devtools看当前组件实例的props和state,往往能快速定位是数据没传进来、还是事件没触发、还是更新被覆盖。
Sourcemap的配置也很关键,Vue的报错信息默认指向编译后的代码,开启sourcemap后就能定位到源代码的具体行。
10. 常见报错排查速查表
下面是实际开发里见的比较多的报错和对应处理方式。
| 报错信息 | 常见原因 | 解决思路 |
|---|---|---|
Cannot read properties of undefined (reading 'xxx') | 数据还未加载完成就渲染,访问了不存在的属性 | 用v-if控制渲染时机,或者给初始数据提供默认值 |
[Vue warn]: Property "xxx" was accessed during render but is not defined | 模板里使用了未定义的变量 | 检查是否在setup/data里漏定义了该变量 |
[Vue warn]: Avoid mutating a prop directly | 子组件直接修改了props | 改为通过emit通知父组件修改 |
Failed to resolve component: xxx | 组件未正确导入或注册 | 检查导入路径、是否有命名大小写问题 |
Hydration completed but contains mismatches | SSR场景下客户端与服务器渲染不一致 | 检查模板中是否使用了依赖客户端环境的API |
Maximum recursive updates exceeded | 无限循环更新,通常是watch自身触发修改依赖项 | 检查watch副作用里是否修改了正在被监听的数据 |
Cannot find module 'vue' | 项目缺少vue依赖或解析配置出错 | 检查package.json依赖、npm install是否完整 |
Unhandled error during execution of native event handler | 事件处理器中抛出了异常 | 在事件处理方法内加try/catch,或检查是否存在未定义的函数 |
TypeError: Cannot read properties of null (reading 'value') | 在setup阶段访问了尚未挂载的DOM ref | 访问ref对象请在onMounted之后 |
11. 给初学者的指引:从第一个Demo到可维护项目
如果你刚开始学Vue,我的建议是先跑通一个完整的小项目,而不只是看文档。选一个你熟悉的小场景,比如Todo列表,从create-vite创建项目开始,自己动手写一个组件、加一次路由跳转、加一个Pinia store、调一次接口、部署到服务器。整个流程走一遍,比记住所有API都对框架有更深的把握。
在写这个Todo项目的过程中,你会自然遇到“组件之间怎么传值”“列表的key该用谁”“调用接口后loading怎么处理”这些问题。每一个问题都是切入点,带着问题查文档、看社区讨论,理解会更牢固。
学习顺序可以这么安排:先掌握模板语法、指令、组件、props/emit,再学响应式原理与组合式API,然后是路由和状态管理,最后再碰工程化和性能优化。不要一上来就研究Proxy源码或者Vite内部原理,先会用,再探为什么,路径会顺畅得多。
从个人经验来说,前端框架的学习从来不是一条直线。今天觉得懂了的东西,过一个月用新视角回看,往往会有更深的体会。我自己的Vue笔记更新了很多版,近几次回看时最常感叹的就是“当时居然不知道还有这种写法”。这也是写笔记的一个价值所在:它记录了你的认知变化,让你在回看时清楚地看到自己走了多远。希望这个笔记能对你有所启发,也欢迎你在此基础上继续补充自己的思考。