☰
Vue3入门实战:Composition API、组合式函数与工程落地
2026/9/28 18:46:00 网站建设 项目流程

Vue3入门这件事,网上教程一大把,但很多都是照着官方文档念一遍,看完还是不知道怎么落地到真实项目。我这两年从Vue2迁到Vue3,又带过几个新人上手,踩了不少坑,也总结了一些经验。这篇东西不打算复述文档,而是把Vue3入门最核心的几条路给你捋顺:环境怎么搭、Composition API到底解决什么问题、组合式函数怎么写才不烂尾、从Vue2过来的人最容易被什么绊倒,最后是刷面试题时常见的一些坑。看完你应该能直接开写,而不是停留在“看懂了但不会用”。

1. Vue3带来了什么,以及为什么值得花时间学

1.1 不只是换个写法,是思维模式的变化

很多人觉得Vue3就是把Vue2的options搬进setup里,换个语法而已。真不是。Vue3最大的变化是:把“按选项组织代码”变成了“按逻辑关注点组织代码”。

Vue2时代,一个组件里同步修改同一份数据的方法散落在data、computed、watch、methods四个选项里。读代码的时候,要在整个文件里来回跳。比如一个购物车组件,里面有商品列表、数量加减、总价计算、结算状态,这些逻辑分别躺在四个选项里,你需要自己在脑子里把它们“圈”出来,才知道这个组件到底在干什么。

Vue3的setup函数允许你把同一个业务关注点的状态、计算属性、事件处理、监听器写在一起。一个购物车模块,几十行代码聚在一块儿,读起来就像在读一个独立的函数——输入、处理、输出,一目了然。这才是Composition API真正的价值,不是炫技,是把代码的可维护性拉高一个量级。

再说响应式系统。Vue2用Object.defineProperty逐个劫持属性,新增和删除属性检测不到,数组索引和length变化也检测不到,所以才有Vue.set和Vue.delete这种补丁式API。Vue3换成Proxy代理整个对象,属性增删、数组下标修改全都原生响应式。这个改动是底层的,直接影响你写业务代码时的自由度。

1.2 入门Vue3之前建议先补的知识点

如果你是从零开始,直接上手Vue3也不是不行,但有些前置知识建议先备好,否则学习中会卡壳:

  • ES6模块语法:import、export得会,这是所有框架的基础。
  • 解构赋值和展开运算符:组合式函数里返回对象、解构使用,用到频率很高。尤其reactive对象被解构后会丢失响应性,这个坑几乎所有Vue3新手都会踩一次。
  • 箭头函数和 this 的差异:Composition API里基本不用this了,但理解箭头函数能让你写watch和computed的回调时更顺畅。
  • TypeScript基础(可选但强烈推荐):Vue3的源码和类型推导都是为TS设计的,defineProps、ref 、computed 这些泛型标注能让编辑器提示强到离谱。哪怕你暂时不写TS,也要知道这些泛型是干什么的。

如果你是从Vue2迁移过来的,重点不是学新语法,而是忘掉this思维。Vue3的setup里,this不再是组件实例,你直接声明变量、直接定义函数、直接return出去给模板用。刚从前端那种到处this.xxx的习惯切换过来,头两天确实有点别扭,但撑过一周就好。

2. 环境搭建与第一个Vue3项目

2.1 最稳的起步方式:create-vue

现在官方推荐的是create-vue脚手架,不是vue-cli。两者的区别:create-vue是Vite驱动,秒级冷启动,热更新快到飞起;vue-cli是Webpack驱动,慢,配置也重。新项目一律用create-vue,别再开vue-cli的坑。

注意:之前网上大量教程还在用npm install -g @vue/cli然后vue create xxx,这条路径虽然还能跑,但官方已经不再主推。2023年以后创建新项目,请使用 npm create vue@latest。

安装命令就一条:

npm create vue@latest

执行后会问你要不要装TypeScript、JSX、Router、Pinia、Vitest、ESLint、Prettier。新手建议全部选No,先把最小骨架跑起来,后边按需再加。交互式选项看着多,其实每个人都可以按自己的实际场景勾选,比如做中后台管理系统你大概率需要Router和Pinia,做基础组件库可能连Router都用不上。

装完依赖、起服务:

cd your-project-name npm install npm run dev

浏览器打开http://localhost:5173,看到Vue的欢迎页就算成了。

2.2 手动搭一个最精简的Vue3项目

如果不想用脚手架,或者想完全掌握底层的依赖关系,手动搭一个也就三分钟的事。这种方式对理解Vite + Vue3的运行机制特别有帮助,建议入门阶段做一次。

初始化目录,装三个核心包:

npm init -y npm install vue npm install -D vite @vitejs/plugin-vue

新建index.html:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Vue3 最小项目</title> </head> <body> <div id="app"></div> <script type="module" src="/src/main.js"></script> </body> </html>

新建vite.config.js:

import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()] })

新建src/main.js:

import { createApp } from 'vue' import App from './App.vue' createApp(App).mount('#app')

新建src/App.vue,写一个最简单的响应式计数器:

<script setup> import { ref } from 'vue' const count = ref(0) function add() { count.value++ } </script> <template> <button @click="add">当前数量: {{ count }}</button> </template>

跑起来npx vite,项目就通了。这套流程走一遍,你会很清楚index.html是入口、main.js负责创建App、App.vue是根组件、Vite负责编译转换,整个链路没有黑盒。

2.3 关于Vite的几个坑

  • Node版本不能太低:Vite 5要求Node 18+,版本不够直接报错。装Node推荐用nvm,别直接从官网下安装包,后面切换版本会让你感谢这个决定。
  • 局域网访问空白:开发时手机要调试,启动命令加上npm run dev -- --host,否则默认只绑定了localhost,外部设备访问不到页面。
  • 首次启动慢:第三方的依赖预构建需要一点时间,会先显示几秒较短的等待,别以为卡死了。node_modules里如果是新装的,第一次启动可能需要个十几秒到几十秒,之后冷启动基本秒开。
  • scss安装:Vite原生支持CSS预处理语言,但不内置依赖。要写scss得手动装npm install -D sass,装上就能直接用,不用额外配置。注意别装成node-sass,那个早就停止维护了。

2.4 环境搭建常见问题速查表

症状可能原因解决办法
vite不是内部或外部命令依赖未安装npm install
ERR_OSSL_EVP_UNSUPPORTEDNode版本过新/过老切换Node 18 LTS
端口被占用5173有别的进程npm run dev -- --port 5174
页面白屏控制台报[plugin:vite:vue]vue文件语法错误看报错具体行号,通常是template或script的括号不闭合
CSS不生效忘记引入样式文件main.js里import './assets/main.css'

3. Composition API核心操作拆解

3.1 setup语法糖:你现在99%的场景都用它

Vue3.2之前,setup得写成一个函数在script里导出,模板用到的变量还得手动return,写起来特别繁琐。3.2之后有了<script setup>语法糖,所有在script顶层声明的东西,模板里直接用,不需要return了。这个改动极大降低了上手成本。

<script setup> import { ref, computed } from 'vue' const price = ref(100) const quantity = ref(2) const total = computed(() => price.value * quantity.value) </script> <template> <p>单价:{{ price }}</p> <p>数量:{{ quantity }}</p> <p>总价:{{ total }}</p> </template>

注意事项:

  • <script setup>是不支持默认导出的,一个.vue文件就是自包含的组件。
  • 组件引入后直接用,不用注册:
<script setup> import ChildComponent from './ChildComponent.vue' </script> <template> <ChildComponent /> </template>
  • 你也可以在 script setup 中直接使用await,组件会被自动包裹为异步组件,这种“顶层await”在部分后台管理项目的路由懒加载场景中用得上。

3.2 ref和reactive怎么选:别纠结,记住一条原则

这两个API是Vue3响应式数据的基础,新手最容易纠结的就是它们选谁。我的原则很简单:

  • 基础类型用ref,也就是String、Number、Boolean这些。
  • 对象类型常规用reactive,数组、对象用reactive直观。
  • 如果对象的值会被替换、解构或者从函数返回,就统一用ref。

ref其实也可以包裹对象,内部会自动帮你转成reactive。唯一的区别是访问方式:ref要.value,reactive直接访问。但注意,reactive对象一旦被解构,属性就会失去响应性,这是非常大的坑。

import { reactive } from 'vue' const state = reactive({ count: 0 }) // 解构后就失去响应性了 const { count } = state count++ // 页面不会更新 // 解法一:转成ref const total = ref(state.count) // 解法二:使用toRefs import { toRefs } from 'vue' const { count } = toRefs(state) count.value++

为了少踩解构的坑,我现在写业务代码习惯统一用ref。对象就写const user = ref({ name: '张三', age: 18 }),修改时user.value.name = '李四'。多写一个.value,但换来的是心智负担小,模板里自动解包,完全不用管“谁的响应性丢了”。

3.3 computed和watch:常见的性能与正确性陷阱

computed是带缓存的,只有依赖项变化才重新计算。不适合有副作用的场景,比如往localStorage写东西就不应该放computed里,副作用用watch来做。

watch是惰性监听,有多个触发场景需要区分——watch一个ref就直接监听;watch一个reactive对象时,需要开启deep才能监听内部的深层属性变化;监听多个数据源时用数组形式。

import { watch, ref } from 'vue' const keyword = ref('') const user = reactive({ name: '张三', age: 18 }) // 单数据源 watch(keyword, (newVal, oldVal) => { console.log('关键词变化:', newVal, oldVal) }) // 多数据源 watch([keyword, () => user.name], ([newKeyword, newName], [oldKeyword, oldName]) => { console.log('多个源变化了') }) // 开启deep,监听reactive内部所有属性的变化 watch(user, (newVal, oldVal) => { console.log('user任意属性变化都会触发') }, { deep: true }) // 立即执行一次的回调,类似Vue2的immediate watch(keyword, () => { // do something }, { immediate: true })

两个高频踩坑点:

  • 监听reactive对象的某个属性,要传getter函数() => user.name,不能直接传user.name,否则得到的是一个固定值,永远监听不到变化。
  • deep监听的是对象内部变化,如果对象的引用整个被替换了,要监听的对象也得换成新引用。比如你把user.value = { name: '李四' },原来建立的watch还盯着旧对象,需要手动重新监听。

3.4 defineProps和defineEmits:子组件通信的核心

子组件里声明接收什么数据,触发什么事件,都用编译宏来完成,不需要手动import。

<!-- ChildComponent.vue --> <script setup> const props = defineProps({ title: { type: String, default: '默认标题' }, count: { type: Number, required: true } }) const emit = defineEmits(['update:count', 'delete']) function handleClick() { emit('update:count', props.count + 1) } </script> <template> <div> <h3>{{ title }}</h3> <button @click="handleClick">增加</button> </div> </template>

父组件那边:

<script setup> import { ref } from 'vue' import ChildComponent from './ChildComponent.vue' const count = ref(0) </script> <template> <ChildComponent title="子组件标题" :count="count" @update:count="count = $event" /> </template>

这里有个Vue2转Vue3特别需要注意的差异:Vue2里子组件修改props会被警告,需要commit事件再派发。Vue3里依然不推荐直接修改props,但通过update:count这种约定事件写法,配合v-model:count可以在父组件侧自动接住:

<ChildComponent v-model:count="count" />

子组件改成emit('update:count', props.count + 1),这时候父子之间的数据同步就特别顺畅。

3.5 生命周期钩子:setup里怎么用

Vue2的生命周期钩子在Vue3的setup里全是函数形式:

Vue2写法Vue3 setup写法触发时机
beforeCreate / created不需要,直接在setup里写组件初始化
beforeMountonBeforeMount挂载前
mountedonMounted挂载完成
beforeUpdateonBeforeUpdate数据变化,DOM更新前
updatedonUpdated数据变化,DOM更新后
beforeUnmountonBeforeUnmount卸载前
unmountedonUnmounted卸载完成

一个实际的use场景,比如获取数据,你可能会在onMounted里发请求。但注意,如果你在setup顶层写异步请求,和onMounted里写没有本质区别,因为setup本身就是同步执行完才挂载。更推荐的做法是直接在setup顶层发请求,逻辑上代码更紧凑,也方便组合式函数复用。

import { onMounted, onUnmounted } from 'vue' function useMousePosition() { const x = ref(0) const y = ref(0) function update(e) { x.value = e.clientX y.value = e.clientY } onMounted(() => { window.addEventListener('mousemove', update) }) onUnmounted(() => { window.removeEventListener('mousemove', update) }) return { x, y } }

4. 从Vue2到Vue3的迁移,最容易被绊倒的5处

4.1 v-model的变化:不再是单向的value + input

Vue2中 v-model 默认绑定value属性和input事件。Vue3中改成了modelValue和update:modelValue。自定义组件上需要在子组件里显式声明:

<!-- 子组件 --> <script setup> const props = defineProps({ modelValue: { type: [String, Number], default: '' } }) const emit = defineEmits(['update:modelValue']) function onInput(e) { emit('update:modelValue', e.target.value) } </script> <template> <input :value="modelValue" @input="onInput" /> </template>

另外Vue3支持v-model:xxx这种多v-model绑定,一个组件可以同时绑定多个值,这个在封装表单类组件时优势很大。比如搜索组件可以同时暴露v-model:keyword和v-model:category。

4.2 全局API不再挂在构造函数上

Vue2里你写Vue.use()、Vue.component()、Vue.prototype.$xxx。Vue3里这些全没了,变成了从vue显式导入,或者在app实例上调用:

import { createApp } from 'vue' import MyComponent from './MyComponent.vue' const app = createApp(App) app.component('my-component', MyComponent) // 全局组件 app.use(SomePlugin) // 插件 app.config.globalProperties.$fetch = fetch // 替代 Vue.prototype.$fetch app.mount('#app')

这个变化对项目影响最大的场景:原来很多第三方库要求你Vue.use(Xxx),Vue3里统一走app.use(Xxx),没适配的就只能等升级或换方案。我在迁移项目时遇到一个老打印插件,源码里有Vue.use,直接迁移到Vue3白屏,最后换成了封装过的新库。

4.3 过滤器filter被移除了

Vue2里可以定义filter做文本格式化,Vue3彻底移除了。官方推荐用计算属性和方法替换。如果你之前项目里用了大量filter,迁移成本不小,要一个个改成computed或函数调用。

比如Vue2里:

Vue.filter('formatMoney', (value) => { return '¥' + Number(value).toFixed(2) })

Vue3里改成:

import { computed } from 'vue' const formattedPrice = computed(() => `¥${Number(props.price).toFixed(2)}`)

模板里直接{{ formattedPrice }},比filter更直观,也是响应式的。

4.4 v-for和v-if的优先级变了

Vue2里,v-for的优先级高于v-if,同一元素上两者同时出现时,v-for先循环再逐个v-if判断,导致很多用户用这个特性配合做“当列表不为空时显示列表”。Vue3改成v-if优先,同一元素上同时出现时v-if连循环的元素都拿不到,直接报错。

正确的写法是:需要过滤列表时,用计算属性新生成过滤后的数组,再v-for。不要在模板里同时挂v-for和v-if。这既是性能优化,也是代码规范。

const visibleItems = computed(() => items.value.filter(item => item.visible)) // 模板里 // <li v-for="item in visibleItems" :key="item.id">{{ item.name }}</li>

4.5 事件API:$on、$off被移除

Vue2里this.$on、this.$off可以作为事件总线用。Vue3不再支持组件实例的事件总线。要跨组件通信,现在更推荐:

  • 小项目直接父子props + emit。
  • 跨多级组件用provide/inject。
  • 全局状态用Pinia。
  • 极少数广播场景,可以自己实现一个轻量ublish/subscribe工具,但不要用第三方库的全局事件寄生在组件实例上。

5. 组合式函数:让逻辑复用不再靠mixin

5.1 为什么mixin不是好方案

Vue2里想要复用逻辑,最常见的做法就是mixin。但mixin有几个严重问题:

  • 命名冲突不可控:两个mixin都定义了同名的方法或数据,合并时后一个覆盖前一个,你根本不知道是谁覆盖了谁。
  • 数据来源隐式:模板里看到一个变量,你没法确定它在哪个mixin里,嵌套多层mixin后代码像毛线团。
  • 传参困难:mixin之间不能直接传递参数,全靠命名约定。

Vue3用组合式函数取代了mixin的位置。一个组合式函数就是一个普通的函数,内部用ref、computed、watch等组合出逻辑,最后返回状态和方法。组件里调用它,拿到返回值直接使用。逻辑清晰、类型清晰、依赖清晰。

5.2 写一个真实可用的组合式函数示例

以一个常见场景为例——封装一个useTable组合式函数,管理表格加载、分页、搜索和刷新:

// src/composables/useTable.js import { ref, reactive, onMounted } from 'vue' export function useTable(fetcher, initialParams = {}) { const loading = ref(false) const list = ref([]) const total = ref(0) const queryParams = reactive({ page: 1, pageSize: 20, ...initialParams }) async function loadData() { loading.value = true try { const result = await fetcher(queryParams) list.value = result.list total.value = result.total } catch (e) { console.error('表格数据加载失败:', e) } finally { loading.value = false } } function search() { queryParams.page = 1 loadData() } function reset() { Object.assign(queryParams, { page: 1, pageSize: 20, ...initialParams }) loadData() } onMounted(loadData) return { loading, list, total, queryParams, loadData, search, reset } }

组件里用起来,逻辑和模板紧密在一起:

<script setup> import { useTable } from '@/composables/useTable' import { fetchUsers } from '@/api/user' const { loading, list, total, queryParams, search, reset } = useTable(fetchUsers, { status: 1 }) </script> <template> <el-table :data="list" v-loading="loading"> <!-- 列定义 --> </el-table> <el-pagination v-model:current-page="queryParams.page" :page-size="queryParams.pageSize" :total="total" @current-change="search" /> </template>

这个组合式函数最大的优势:任何组件需要同样的表格能力,一行typed调用就拿到了所有状态和方法,不用复制粘贴一堆data和methods下来。

5.3 组合式函数的设计原则

几个我总结的实践经验:

  • 函数命名用use开头,便于一眼识别。
  • 参数尽量传纯函数和纯数据,不要在内部业务化,保持通用。
  • 返回的reactive对象建议转成ref,或者使用toRefs,防止解构丢响应性。如果你直接返回reactive,拿回来也只能整个对象操作。
  • 内部生命周期的使用要小心:组合式函数里可以用onMounted、onUnmounted,但只能在组件的setup阶段同步调用。如果你在异步回调里调用onMounted,Vue会警告。

6. 常见报错和项目实战场景排查

6.1 defineProps或defineEmits未定义

很多人写defineProps(['title'])的时候,IDE提示未定义。这是因为defineProps是一个编译宏,只在<script setup>中生效,不需要import。但IDE如果不识别,往往是eslint或volar插件的版本不对。新版Volar(Vue Language Features)已经能原生识别,老版本需要把Vetur禁用掉,否则两个插件互相打架。

6.2 reactive对象被赋值后失去响应性

这个错误几乎每个Vue3入门者都遇到:

const user = reactive({ name: '张三' }) // 接口返回数据 user = await fetchUserData() // 报错,不能重新给reactive赋值

正确做法是直接用ref,然后整体赋值:

const user = ref({ name: '张三' }) user.value = await fetchUserData() // 可以,页面自动更新

这个区别背后的原理: reactive的代理对象在创建时就固定了,如果要替换整个对象,你实际是让代理对象指向新地址,这是不可能的。ref则内部用了一个包裹对象,替换时直接改ref内部的值,所以可以。这也是我个人更偏ref的原因。

6.3 v-for中的key使用index导致列表更新错乱

Vue3对key的要求和Vue2一样,但很多新手为了省事直接用index。这在纯展示列表时没问题,一旦涉及列表项的增删、排序或者本地状态,index作key就会出现张冠李戴。

比如一个待办列表,勾选完成后删除这一项,用index做key会导致后续所有项的勾选状态错乱。正确的key应该用业务唯一ID,哪怕后端没有,也可以在前端生成一个自增的id。

// 不要 <li v-for="(item, index) in list" :key="index"> // 推荐 <li v-for="item in list" :key="item.id">

6.4 v-model更新不触发computed重算

这个一般不是框架问题,是你用了非响应式的方式修改数据。比如:

// 错误:直接修改深层属性,Vue3的ref深层自动响应还好, // 如果是reactive对象,很多人会在异步回调里直接赋值却忘了用.value

典型场景:el-input绑定searchForm.value.keyword,用户输入后computed没变。排查思路是打印一下searchForm.value,看看输入的内容到底有没有写进去。通常不是响应式的问题,而是绑定到了错误的字段名上。

6.5 异步组件加载失败/白屏

用defineAsyncComponent或者路由懒加载时,如果组件内部报错,Vue2会白屏或者弹错误框,Vue3默认也是白屏难debug。建议开发阶段把自己的App.vue包一层错误处理组件:

<script setup> import { onErrorCaptured } from 'vue' const error = ref('') onErrorCaptured((e) => { error.value = e.message return false // 阻止错误继续传播 }) </script> <template> <div v-if="error" class="error-full-screen"> 组件渲染出错:{{ error }} </div> <slot v-else /> </template>

这样出错时你能看到错误信息而不是空白页。

6.6 uniapp中使用echarts图片空白

uniapp的app端canvas运行环境和浏览器有差异,直接引入echarts后图表不渲染是常见问题。常规做法是改用renderjs配合echarts——renderjs运行在视图层,可以直接操作canvas,而且跟逻辑层通信是异步的。在vue3 + uniapp的项目里,还要注意renderjs只能在非编译小程序端使用,小程序端得用ec-canvas等适配方案。

我实测下来的配置要点:canvas的id必须固定,宽高必须显式指定,不能在style里写百分比;配置项变化后,要通过renderjs的script暴露的接收方法重新渲染,不能直接改echarts实例。如果图片不更新,检查是否在正确的生命周期里调用了setOption。

6.7 Edge浏览器右上角关闭按钮失效

有个热搜词专门提到Vue3项目在Edge浏览器中偶尔无法关闭右上角的最小化按钮。这听起来奇怪,其实大概率不是Vue3本身的问题,而是项目中开启了全局的键盘事件监听或window尺寸变化监听,导致Edge的原生标题栏响应被吞掉,或者某个第三方组件在关闭时阻止了默认行为。

我在一个后台管理项目里遇到过相似现象,最后定位到是某个全局拖拽组件监听的mousemove没有在组件卸载时清理,事件监听器堆积,Edge的窗口按钮响应变得极其迟钝。排查思路:

  • 全局搜索addEventListener和@mousemove,确认所有监听都有对应的移除。
  • 用Edge的开发者工具,在console里执行getEventListeners(window)检测是否有异常数量的监听器。
  • 在beforeUnmount时清理定时器(setInterval、setTimeout)。
  • 如果你用了cefsharp这类内嵌壳,还要检查壳层对Native窗口事件的中断情况。Vue3侧基本无解,多半是宿主环境在事件循环上做了干扰。

6.8 Vite + Vue3项目本地正常,局域网打开空白

这个问题也上榜热搜了。本地localhost能跑,换局域网IP访问白屏,通常有三个原因:

  • Vite默认只绑定了127.0.0.1,要设置server.host为true或具体IP。
  • 路由history模式在非首页刷新时,后端服务器没有做history fallback,返回404。Vite开发模式下自身会处理,但如果你把打包产物放到了静态服务器上而没配rewrite规则,非根路径刷新就白屏。
  • 打包时base配置写成了相对路径/绝对路径不匹配,资源加载404,也会白屏。生产环境有时要设base: './'让资源相对当前路径生效。

7. 面试经典6连问,背后的真正考点

热搜里的“vue3面试最经典6个问题”,虽然每年都在变,但核心高价值问题一直围绕这些方向,这里挑几个重点讲透,不只是给答案,更重要的是理解为什么考官会问这个。

7.1 ref和reactive的区别,以及各自的底层实现

考点在响应式原理。ref内部调用的是reactive的包装对象,返回的是个RefImpl实例,有value属性,模板里自动解包。reactive是直接对目标对象做Proxy代理。

面试时会追着问:为什么reactive不能解构?因为解构出来的值是基本类型,脱离了Proxy代理的get/set拦截,自然失去响应性。想保住响应性就得用toRefs,把每个属性转成ref。

还有一层:reactive的代理是懒代理,嵌套对象只有真正访问时才继续代理,好处是性能好。Vue2时代的defineProperty是在初始化时递归遍历所有属性,多层嵌套对象一上来就全代理,性能开销大,这也是为什么Vue2里深层响应式对象初始化慢。

7.2 Composition API和Options API对比

面试官想听的是“什么时候用哪个”,不是无脑夸new好。

我的观点:基础组件和模板简单的小组件,Options API更直观;中大型业务组件、跨组件复用逻辑,Composition API优势明显。Vue3官方并没有说完全抛弃Options API,而是给你选择。但作为面试,你要能说清楚Composition API的三个核心优势:

  • 逻辑复用能力:组合式函数替代mixin,干净无污染。
  • 代码组织方式:按关注点聚合,而不是按选项分散。
  • 类型推导友好:TypeScript推断比Options API更顺,特别是props和emits的类型标注。

再补充一点:Composition API的setup只执行一次,所以你不需要担心重复执行的问题,但也不要写在路由守卫里用options才有的api这种混搭玩法。

7.3 Vue3的diff算法优化在哪

Vue3的diff相比Vue2有以下几个优化点:

  • 静态标记(PatchFlags):编译时对节点打标记,动态绑定的部分才走diff,纯静态节点直接跳过。
  • 静态提升:不会变化的节点只创建一次,重复render时直接复用。
  • 事件缓存:对绑定的事件缓存,避免每次render重新创建函数。
  • 最长递增子序列算法:移动DOM时,找到最少的移动次数,不像Vue2的diff都从头到尾对比。

解答问题时建议直接拿一个具体场景举例:一个列表里只有一个文本框是动态变化的,Vue3编译时会给这个文本框打上动态标记,其他节点全部跳过。Vue2则是每次渲染都从头对比整个树结构。

7.4 v-model v-model:xxx .sync的关系

Vue2中 .sync 就是 v-model 的变体,用于自定义组件同步多个prop。Vue3里 .sync 被彻底移除了,统一合并为v-model:propName语法。所以现在你看到的就是:

<ChildComponent v-model:title="pageTitle" v-model:content="pageContent" />

子组件定义:

const props = defineProps({ title: String, content: String }) const emit = defineEmits(['update:title', 'update:content'])

面试时这个问题考察的是你是否有vUe2迁移经验,而不是背API。

7.5 keep-alive和动态组件

keep-alive在Vue3中多了几个新特性。它可以按include和exclude过滤缓存哪些组件,还可以传入max限制最多缓存几个实例。Vue3里keep-alive的缓存机制改用Map存储,支持动态的组件名,这点比Vue2更强。

企业中后台项目经常会用到keep-aliva按页面缓存:列表页切换详情再返回,列表数据、滚动位置要保持不刷新,用include指定组件的name即可。注意,在Vue3的setup语法下,组件name的写法变成了额外一个<script>块定义name,Vue3.3系列及以上支持了defineOptions,更方便:

<script> export default { name: 'UserListPage' } </script> <script setup> // ... </script>

或者Vue 3.3+:

<script setup> defineOptions({ name: 'UserListPage' }) </script>

7.6 父组件访问子组件的实例

Vue2里this.$refs.child.xxx。Vue3里,子组件如果用<script setup>,默认是关闭的,父组件拿不到内部任何数据,除非子组件主动通过defineExpose暴露出去:

<!-- 子组件 --> <script setup> const count = ref(0) const reset = () => { count.value = 0 } defineExpose({ count, reset }) </script> <!-- 父组件 --> <script setup> import { ref, onMounted } from 'vue' import Child from './Child.vue' const childRef = ref(null) onMounted(() => { childRef.value.reset() }) </script>

忘记defineExpose,父组件拿到的是个空对象,这是新手特容易撞上的“明明ref了为什么拿不到”问题。

8. 后台管理系统常遇到的组件场景:表单校验和tabs样式

8.1 日期校验和白名单逻辑

热搜里有“vue3 rules 日期检验”,说明很多人在封装搜索表单时,对范围日期选择器 + rules的校验规则写法有疑问。使用Element Plus时,留意日期选择器的绑定值本身是数组或Date对象,rules校验时要对null、空数组分别处理:

const rules = { dateRange: [ { validator: (rule, value, callback) => { if (!value || !Array.isArray(value) || value.length !== 2) { callback(new Error('请选择开始和结束日期')) } else if (value[0] && value[1] && value[0] > value[1]) { callback(new Error('开始日期不能晚于结束日期')) } else { callback() } }, trigger: 'change' } ] }

注意Element Plus的el-date-picker在清空时会传null而不是空数组,所以校验里!value || value.length !== 2要同时覆盖。很多人的bug就是只在没有value时拦截,清空数组却漏了。

8.2 修改tabs标签页的样式

热搜里“vue3修改tabs标签页样式”,其实核心问题是如何对Element Plus的深层样式做定制。Element Plus的tab条、划线、内容区都有自己的类名,直接写在scoped样式中是穿透不了的。

以前Vue2的推荐做法是::v-deep或者/deep/,Vue3有了新写法:

<style scoped> /* 这种写法Vue3.2+推荐 */ :deep(.el-tabs__nav) { background: #f5f7fa; } :deep(.el-tabs__active-bar) { height: 3px; background: linear-gradient(90deg, #409eff, #67c23a); } :deep(.el-tabs__item) { color: #666; } :deep(.el-tabs__item.is-active) { color: #409eff; font-weight: 600; } </style>

有几个坑要提醒:

  • 不要在全局样式中写这些覆盖,否则影响全站所有tabs。
  • 如果改了active-bar却看不到变化,多半是样式被更深的选择器覆盖,检查Element Plus本身有没有!important。
  • 暗黑模式下,深色背景的active-bar颜色需要额外写一套黑暗主题的样式。

8.3 动态添加/删除表单行

中后台配置页必有的场景:用户点“加一行”就新增一条,点删除就移除,同时校验规则要跟着新行生效。用v-for+ 动态数组实现就行:

const formRows = ref([ { id: 1, name: '', price: null } ]) const addRow = () => { formRows.value.push({ id: Date.now(), name: '', price: null }) } const removeRow = (index) => { if (formRows.value.length === 1) { return // 至少保留一行 } formRows.value.splice(index, 1) }

模板里要注意每一项的prop动态拼接:

<el-form-item v-for="(row, index) in formRows" :key="row.id" :label="'项目' + (index + 1)" :prop="'rows.' + index + '.name'" :rules="rowRules.name" > <el-input v-model="row.name" /> </el-form-item>

rules的注脚写法'rows.' + index + '.name'是在el-form上绑定:model="form"时生效的关键。很多人就是漏了这步,导致规则永远校验不上新增的行。

9. 完整可落地的Vue3入门学习路径(建议收藏)

如果你真要从零开始,别东一榔头西一棒子。我建议按这个顺序来,每一步都有明确目标:

  1. 理解核心概念:读完Vue3官方文档的“基础”章节,从创建应用开始,到模板语法、组件、props、事件、插槽,尽量不看Composition API,先把模板这部分吃透。目标是能做出静态页面和简单组件。
  2. 上手Composition API:把官方文档的响应式核心、Composition API这两章精读,动手改写一个你之前用Options写的小组件。目标是理解setup里ref、reactive、computed、watch的配合。
  3. 做三个小项目:
    • 待办清单(增加、删除、筛选、本地存储)
    • 搜索+列表页(防抖、分页、loading态)
    • 表单校验组件(validate、reset、动态添删行)
  4. 学习Vue Router和Pinia:做一个小型后台登录+首页+用户列表,把路由守卫、动态路由权限、状态管理都过一遍。目标是理解SPA应用的项目结构。
  5. 读源码(可选):当你能不看文档写出一个复杂组件时,再去看Vue3源码中ref、effect、computed的实现。那时你会发现很多面试题背后的原理不攻自破。

这个路径大概需要2到4周,取决于你每天能投入的时间。重点是第三、四步一定要动手敲,不能只看。

我自己带新人的体会是,学Vue3最大的门槛不是语法,是对响应式模型的理解。只要想明白“数据变了视图自动更新”底层是Proxy + 依赖收集 + 触发更新这一条链路,后面所有API都是围绕这条链路的工具。不是你用了工具才聪明,是理解了模型才会用对工具。

最后分享一个小技巧:入门阶段不要追求“优雅”,不要一上来就封装组合式函数、抽象通用组件。先老老实实把业务代码写出来,跑通一版再看哪里重复多。等你在两三个页面感受到了“这段逻辑我复制了两遍”,自然就知道该抽什么、怎么抽了。抽象过早,反而是新手最容易憋出来的一堆没人看得懂的“高级代码”的来源。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询