
最近在梳理自己的Vue知识体系翻到以前记的笔记发现零零散散存了不少东西。索性花点时间整理成系列文章既是对自己的一次查漏补缺也方便有需要的人直接参考。这篇是第一期主要围绕工程初始化、响应式原理、组件通信、路由配置、样式与调试工具这几块展开。内容不追求大而全但我会把实际操作中踩过的坑和验证过的结论一并写出来希望对正在入门或者刚接触Vue 3的朋友有点帮助。1. 工程初始化从零搭起一个能跑的Vue 3项目1.1 创建项目时的选型参考现在创建Vue项目官方推荐的方式依然是Vite。相比早期的Vue CLI基于WebpackVite在冷启动速度和热更新体验上完全是两个世代的产品。创建项目只需要一条命令npm create vuelatest这条命令会生成一个交互式脚手架它会问你一些配置选项包括是否使用TypeScript、是否需要Vue Router、Pinia、ESLint、Prettier、Vitest等。很多新手会纠结怎么选我的建议是TypeScript如果你不是团队强制要求纯学习阶段可以暂缓先聚焦Vue本身的语法和设计思路。但如果未来打算长期投入前端直接用TS起步其实更划算。Vue Router只要做多页面应用基本必选。Pinia涉及跨组件共享数据时强烈建议选上它是Vue官方推荐的下一代状态管理库比Vuex的API更简洁。ESLint Prettier强烈建议要。它们不是摆设而是帮你把代码风格规范化的最有效工具尤其是多人协作时能省去大量无谓的讨论。选完之后脚手架会生成一个标准目录结构。进入项目目录后安装依赖cd my-project npm install npm run dev启动后访问终端里给出的本地地址看到欢迎页面就说明你的第一个Vue项目已经跑起来了。1.2 目录结构和运行机制用Vite创建的Vue 3项目目录结构大致如下├── index.html ├── package.json ├── vite.config.js或 .ts ├── src/ │ ├── main.js或 main.ts │ ├── App.vue │ ├── router/ │ ├── stores/ │ ├── views/ │ └── components/ └── public/其中index.html是入口页面文件Vite会把它当作整站应用挂载的起点。src/main.js负责创建Vue实例并挂载到index.html里某个id对应的DIV节点上。App.vue是根组件所有其他组件最终都会被嵌套组合到它内部。有一个细节值得新手注意src目录下的.vue文件并不是浏览器能直接识别的格式Vite在开发阶段做的事情之一就是把它们编译成浏览器能运行的JavaScript。这也是为什么你在index.html里只看到idapp的DIV节点而没有直接看到div{{ msg }}/div这类内容。1.3 依赖安装时的常见问题国内开发者在运行npm install时经常会遇到网络问题换用国内镜像源是最常见的解决办法npm config set registry https://registry.npmmirror.com如果你在安装过程中遇到node_modules目录生成不完整或者安装报错ERR! code ELIFECYCLE优先尝试删除node_modules和package-lock.json然后重新执行npm install。这类问题九成以上是依赖树损坏或缓存冲突导致的重装能解决绝大部分情况。测一下安装速度和依赖完整性npm install --legacy-peer-deps如果项目里某个依赖包和你的Node版本不兼容用这个命令可以绕过旧版本npm的peerDependencies检查但不建议长期依赖它根上的解决手段还是升级Node版本或者找到依赖版本匹配的包。2. 响应式原理从使用到手写一个迷你的reactive、ref、effect、computed我一直认为Vue 3的响应式系统是必须啃下来的硬骨头。这个核心机制搞懂了后面看组件更新、状态管理、性能优化都会轻松很多。热搜词里有人提到脱离vue源码使用原生proxy手写一个包含reactive、ref、effect、computed这个思路非常好我当年也这么练过一遍今天分享我的简化版实现顺便把原理讲透。2.1 理解reactive和ref的本质区别Vue 3中响应式数据有两种声明方式import { reactive, ref } from vue const state reactive({ count: 0 }) const num ref(0)reactive只能接收对象类型数组、Map、Set等也算对象它返回一个深度响应式的代理对象。你可以直接通过state.count去访问和修改。ref更通用它可以接收任意类型的值包括基本类型number、string、boolean。但ref包装后在JavaScript中修改值必须用.valuenum.value 1 // 正确 num 1 // 错误为什么ref要用.value因为它需要把基本类型值包裹到一个对象里才能建立响应式依赖。这跟Vue 3的底层实现直接相关——Proxy只能代理对象无法直接代理基本类型。而在模板中Vue会对ref自动解包所以你直接写{{ num }}就能渲染出值不需要手动加.value这是模板编译器的便利设计。我的习惯是定义对象用reactive定义基本类型用ref。但如果你使用的是TypeScript或者需要给数据添加新属性、删除属性我更推荐一律用ref它能覆盖更多场景而且变量传递时也不用担心失去响应式。2.2 手写一个mini版reactiveVue 3面试中高频考点就是手写一个简单的响应式系统。核心包括依赖收集和触发更新两步。依赖收集是指在读取数据时记录当前正在执行的效果函数触发更新是指在数据变化时重新执行与该数据相关的效果函数。先实现一个用于管理依赖的存储结构const targetMap new WeakMap() function track(target, key) { let depsMap targetMap.get(target) if (!depsMap) { depsMap new Map() targetMap.set(target, depsMap) } let dep depsMap.get(key) if (!dep) { dep new Set() depsMap.set(key, dep) } if (activeEffect) { dep.add(activeEffect) } } function trigger(target, key) { const depsMap targetMap.get(target) if (!depsMap) return const dep depsMap.get(key) if (dep) { dep.forEach(effect effect.run()) } }targetMap是一个WeakMap用来存储每个响应式对象对应的依赖映射表。depsMap是一个Map存储该对象每个属性对应的依赖集合。dep是一个Set存放与该属性相关的所有effect函数。接下来用Proxy实现reactivelet activeEffect null function reactive(target) { return new Proxy(target, { get(obj, key, receiver) { track(obj, key) return Reflect.get(obj, key, receiver) }, set(obj, key, value, receiver) { const result Reflect.set(obj, key, value, receiver) trigger(obj, key) return result } }) }关键点在于track和trigger的配合。当你访问某个属性时当前正在运行的effect函数会被记录到该属性的依赖集合中。当你给该属性赋值时依赖集合里的所有effect函数都会被重新执行。2.3 手写effect、ref和computedeffect是Vue内部最核心的调度函数它接收一个函数并立即执行一次同时在执行过程中收集依赖function effect(fn) { const _effect { run() { activeEffect _effect fn() activeEffect null } } _effect.run() return _effect }之所以要把effect封装成对象而不是直接执行函数是为了后续能通过_effect控制执行逻辑比如停止监听、添加调度器等。有了reactive和effect实现ref就很简单了。ref的本质是把一个值包裹成{ value: ... }这样的对象然后交给reactive处理function ref(initialValue) { return reactive({ value: initialValue }) }computed的实现思路是基于effect缓存结果并在依赖变化时重新计算function computed(getter) { let value let dirty true const effectRef effect(() { value getter() dirty false }) return { get value() { if (dirty) { value getter() dirty false } return value } } }简化版没有处理调度和嵌套依赖边界但核心逻辑已经能跑通。如果你在一张纸上把track和trigger之间触发链条画一遍就会彻底理解Vue 3响应式的执行流程读取时收集、赋值时派发。2.4 为什么Proxy比Object.defineProperty更优Vue 2的响应式系统依赖Object.defineProperty这也是它最大的局限它必须预先知道要劫持哪些属性所以对新增属性、删除属性都无能为力只能用$set、$delete来补救。而Vue 3的Proxy可以直接监听整个对象包括属性的新增和删除。再加上Reflect的配合可以确保在代理中执行对象的默认操作时不会因为this指向问题而出现意外。这也是Vue 3中你可以放心直接给响应式对象添加新属性的原因const state reactive({ count: 0 }) state.name vue // 这是响应式的换成Vue 2的话直接这样赋值页面上是不会更新的。这是两者最关键的差异也是面试中几乎必问的点。3. 组件通信与生命周期变量怎么在组件间流动组件化开发里最绕不开的事情就是通信。Vue提供了很多通信方案但真正用好的前提是清楚每种方案的适用场景。3.1 父子组件通信props emit父子通信是最常见也最基础的形态。父组件通过props向子组件传递数据子组件通过emit向父组件发送事件。父组件template Child :titletitle updatehandleUpdate / /template script setup import { ref } from vue import Child from ./Child.vue const title ref(父组件的标题) function handleUpdate(newVal) { title.value newVal } /script子组件template div p{{ title }}/p button clickupdateTitle修改标题/button /div /template script setup const props defineProps({ title: { type: String, required: true } }) const emit defineEmits([update]) function updateTitle() { emit(update, 子组件发来的新标题) } /script有一个官方不推荐、但实际开发容易出现的写法直接在子组件里修改props。在Vue 3中直接改props的值不会报错但会产生不可预期的副作用而且在开发模式下控制台会给出警告。正确做法是把props当作只读数据需要修改时通过emit通知父组件去改。3.2 v-model与跨层通信Vue 3里v-model的底层实现其实就是propsemit的语法糖。定义一个支持v-model的组件template input :valuemodelValue input$emit(update:modelValue, $event.target.value) / /template script setup defineProps([modelValue]) defineEmits([update:modelValue]) /script使用时MyInput v-modelusername /等于MyInput :modelValueusername update:modelValueusername $event /如果你想跨多级组件传递数据比如从祖先组件直接给孙子组件传值可以借助provide和inject// 祖先组件 import { provide } from vue provide(theme, dark) // 孙组件 import { inject } from vue const theme inject(theme)provide/inject适合数据像配置一样从上到下流动的场景比如全局主题、语言环境、用户信息等。但注意用inject拿到的数据并不是响应式的除非你传入的是ref或reactive对象。实际项目中如果要让inject的数据响应式需要在provide端就用ref包一层provide(theme, ref(dark))跨层通信还有一个常用工具是Pinia。相比provide/injectPinia更适合大量共享状态的场景因为它提供了完整的store、getter、action机制而且开发工具支持友好。如果你发现组件树中多个不相关的组件都需要读取同一份数据那就是引入Pinia的信号了。3.3 组件ref与defineExpose除了数据传递有时父组件需要直接调用子组件内部的方法。这就是组件ref的场景。子组件script setup import { ref } from vue const count ref(0) function addCount() { count.value } defineExpose({ addCount }) /script父组件template Child refchildRef / /template script setup import { ref, onMounted } from vue import Child from ./Child.vue const childRef ref(null) onMounted(() { childRef.value.addCount() }) /script特别提醒在script setup语法下子组件内部的变量和方法默认是不对外开放的。必须通过defineExpose显式暴露后父组件才能访问到。这是Vue 3新语法和Vue 2选项式写法一个很不一样的地方很多人第一次从Vue 2转Vue 3时容易踩到这个坑。3.4 生命周期钩子时机比顺序更重要Vue 3的常用生命周期钩子和Vue 2有一个显著变化beforeDestroy改名为onBeforeUnmountdestroyed改名为onUnmounted。另外 Vue 3 还新增了与KeepAlive相关的onActivated和onDeactivated。实际业务中我最多用到的组合是onMounted发起接口请求、初始化第三方库、挂载DOM相关的操作。onBeforeUnmount清理定时器、取消订阅、销毁地图实例。onActivated配合KeepAlive缓存页面时每次进入页面想刷新数据放在这里而不是onMounted。一个典型的坑onMounted里创建了定时器但忘记在onBeforeUnmount里清理导致页面切走之后定时器依然在跑造成内存泄漏和多余请求。这个问题在纯前端页面里不容易立刻暴露但在单页应用里路由来回切换次数一多问题就很明显。3.5 父子组件加载顺序父子组件的生命周期执行顺序也是前端面试的高频题。初次渲染时父组件 setup 父组件 onBeforeMount 子组件 setup 子组件 onBeforeMount 子组件 onMounted 父组件 onMounted这个顺序说明子组件的挂载是先于父组件的。所以如果你在父组件的onMounted里去访问子组件实例是可以拿到的但如果你想在父组件onMounted里操作子组件内部的一个DOM节点最好确认该节点是稳定存在的。更新阶段父组件更新会触发子组件更新顺序是父组件的onBeforeUpdate然后是子组件的onBeforeUpdate和onUpdated最后才是父组件的onUpdated。4. 路由配置与实战面试里的route传参、拦截器与懒加载Vue Router是单页应用的核心基础设施。没有路由Vue项目只能做单页组件演示。但实际开发中路由又经常成为各种bug的来源。4.1 路由模式与基础配置Vue Router 4对应Vue 3创建项目时如果勾选了Router会生成类似这样的文件import { createRouter, createWebHistory } from vue-router import { useStore } from ../stores/counter const router createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { path: /, name: home, component: () import(../views/HomeView.vue) }, { path: /about, name: about, component: () import(../views/AboutView.vue) } ] })createWebHistory是HTML5 History模式URL中不会出现#。如果部署到Nginx需要配置try_files否则刷新子路由页面会出现404。这几乎每个做部署的人都会遇到一次。开发环境用createWebHistory没问题生产环境如果对Nginx配置没把握可以退一步用createWebHashHistory。它的地址里会出现#号但胜在部署简单任意静态文件服务都能跑。4.2 路由参数传递的三种方式第一种路径参数。定义路由时用:id占位{ path: /detail/:id, name: detail, component: () import(../views/DetailView.vue) }跳转方式router.push({ name: detail, params: { id: 123 } })在页面中通过route.params.id获取。第二种query参数router.push({ path: /detail, query: { id: 123 } })页面中通过route.query.id获取。query参数会显示在URL的?后面刷新后依然能保留。第三种通过路由的state字段或组件间的状态管理来传递复杂对象。这类数据不会出现在URL中安全性相对高一些但缺点是一刷新页面就没数据了。注意如果使用params方式传参数必须用name而不是path来跳转。使用path: /detail加params时Vue Router 会直接忽略params这也是一个容易踩的坑。4.3 路由守卫做登录拦截路由守卫是项目里最常见的需求场景某些页面需要登录后才能访问未登录用户直接跳转登录页。router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next({ name: login }) } else { next() } })如果你在维护一个需要区分用户角色的系统可以扩展to.meta.role字段在守卫里做角色校验。还有一个细节全局前置守卫里不要做太重的异步任务比如等待某个接口返回再放行。原因是这会导致页面切换有明显的延迟感体验很不好。如果确实需要用户信息建议在应用启动时提前加载或者把异步逻辑放到组件内部的onMounted中执行。4.4 路由懒加载与按需加载上面的代码里组件使用() import()这种写法就是路由懒加载。它的作用是只有访问到对应路由时浏览器才去下载对应的JS文件。这在大项目里的收益是立竿见影的。假设你的应用有20个页面如果不做懒加载首屏会把所有页面的JS都下载下来。做了懒加载后首屏只下载首页对应的JS其他页面等用户点击进入时才加载。首屏白屏时间能降低很多。如果你用的是Vite懒加载写法通常是动态导入component: () import(../views/HomeView.vue)Vite会按路由自动分包生产构建时会生成HomeView.[hash].js这样的独立文件。5. 样式控制与调试经验scoped、Deep选择器与DevToolsVue的样式控制是日常高频需求入门时看起来简单但深入后有不少容易被忽视的细节。5.1 scoped样式的作用原理在style标签上加上scoped属性后Vue会为该组件模板中的DOM节点添加一个类似>style scoped .parent :deep(.child-class) { color: red; } /style:deep()的写法本质上是在编译时去掉前缀限制让这个选择器能作用于子组件内部DOM。5.2 全局样式与局部样式的组织方式通常一个项目会有三种样式文件global.css全局基础样式比如reset、字体、公共按钮类。variables.cssCSS变量或Sass/Scss变量定义为设计规范中的基础色值、间距、圆角。每个组件的局部样式只在当前组件生效负责组件内部的布局和细节。如果你使用Sass推荐通过Vite配置css.preprocessorOptions来全局自动注入变量文件这样每个组件里直接用变量却不需要重复import能省下大量样板代码。5.3 Vue DevTools调试Vue应用的神器浏览器上装好Vue DevTools扩展后打开任何一个Vue应用你会发现它的能力远超普通查看器。我最常用的功能有三个组件树检查逐个查看组件实例的props、data、computed、setup状态定位数据异常时非常好用。Timeline的时间线记录能在页面操作时看到router导航、reactive副作用触发的整体脉络。Pinia状态跟踪直接查看每个store里的state和getter在排查跨组件状态问题时效率极高。如果DevTools显示Vue.js not detected先确认你运行的项目是不是Vite默认模式再确认浏览器扩展权限是否对该站点开启。有些浏览器隐私模式下会默认禁用扩展这一点常被忽略。5.4 VSCode常用插件与工作流开发Vue项目时VSCode里我基本会装这样几款插件Vue Language Features (Volar)Vue 3官方推荐的语法支持插件提供模板、脚本、样式的语言服务。TypeScript Vue Plugin配合Volar处理script setup langts场景的类型推导。ESLint实时提示代码规范问题。Prettier - Code formatter统一格式化风格。Auto Rename Tag修改HTML标签时同步更新配对标签写模板时很顺手。这里要专门提醒Vue 2时代主流的Vetur插件在Vue 3项目里反而可能造成冲突。如果你在VSCode里装了Vetur又装了Volar建议把Vetur禁用否则会出现模板语法提示错乱的问题。还有个调试技巧如果你在代码里写了console.log并在开发模式查看但刷新页面后找不到输出先确认Vite配置里的build.sourcemap是否开启。生产构建默认不开sourcemap调试时把配置打开能看到更完整的堆栈信息。// vite.config.js export default defineConfig({ build: { sourcemap: true } })6. 结语之外一些记了很久的经验这份笔记写到这里已经不算短了但对我来说最有价值的反而是那些从错误中修正出来的认知。比如手写响应式系统的练习让我不再把reactive和ref当成黑盒自己搭过一次路由拦截器才明白为什么官方文档反复强调next的调用时机被scoped样式坑过几次后才真正理解了>