Vue3,那个从选项式到组合式的蜕变
我们组有个前端老哥,用了五年 Vue2。有天他升级到 Vue3,盯着代码看了半小时,然后说了句:"这不是 Vue 了,这是 JavaScript。"
他说得没错。Vue3 就像一个人从被管家安排得井井有条的生活,突然有了自己选择的自由。
那个管家叫"选项式"
Vue2 的时候,咱们写组件就像填表格:
export default {
data() {
return { count: 0, user: null }
},
methods: {
increment() { this.count++ },
fetchUser() { /* 请求逻辑 */ }
},
computed: {
doubled() { return this.count * 2 }
},
watch: {
count(newVal) { console.log(newVal) }
}
}
data 放数据,methods 放方法,computed 放计算属性,watch 放监听。每个东西都有自己的格子,管家(Vue)帮你整理得妥妥帖帖。
问题是,当逻辑复杂了,这些格子就散开了。比如一个"用户登录"的功能,验证逻辑在 methods 里,状态在 data 里,错误提示在 computed 里,监听在 watch 里。你得在五个地方跳来跳去,就像在一个迷宫里找东西。
更糟的是,如果两个组件都需要"计数器"逻辑,你得复制粘贴整个 data、methods、computed。代码重复,维护困难。
后来来了个叫"组合式"的东西
Vue3 引入了 Composition API,让你可以把相关逻辑写在一起。比如提取一个 useCounter 的函数:
import { ref, computed, watch } from 'vue'
function useCounter(initialValue = 0) {
const count = ref(initialValue)
const doubled = computed(() => count.value * 2)
const increment = () => count.value++
const decrement = () => count.value--
watch(count, (newVal) => {
console.log(`计数变化: ${newVal}`)
})
return { count, doubled, increment, decrement }
}
然后在任何组件里复用它:
import { useCounter } from './useCounter'
export default {
setup() {
const { count, doubled, increment, decrement } = useCounter(10)
return { count, doubled, increment, decrement }
}
}
这就像把"计数逻辑"打包成一个独立的工具,需要的时候拿出来用。两个组件都需要计数?各自调用一次 useCounter,互不影响。
响应式系统,从"魔法"变成了"透明"
Vue2 用 Object.defineProperty 做响应式,有些坑:
// Vue2 的坑
data() {
return { user: { name: 'Tom' } }
},
methods: {
addField() {
this.user.age = 30 // 不会触发更新!
this.$set(this.user, 'age', 30) // 得这样才行
}
}
Vue3 用 Proxy,直接了当。有两种方式:
用 reactive 处理对象:
import { reactive } from 'vue'
const user = reactive({ name: 'Tom' })
user.age = 30 // 直接就能用,没坑
user.email = 'tom@example.com' // 新增字段也行
用 ref 包装任何类型:
import { ref } from 'vue'
const count = ref(0)
const user = ref({ name: 'Tom' })
const list = ref([1, 2, 3])
count.value++ // 改 value 属性
user.value.age = 30 // 对象也得改 value
list.value.push(4) // 数组也一样
窗外下着雨,我问那个老哥为什么要改成 value。他说:"这样你一眼就知道这是响应式的,不用猜。而且 ref 可以包装任何东西,基础类型、对象、数组都行。"
监听响应式数据,从"watch"到"watchEffect"
Vue2 的 watch 得手动指定监听的属性:
watch: {
user: {
handler(newVal) {
console.log('用户变化:', newVal)
},
deep: true // 深度监听,性能差
}
}
Vue3 的 watch 更灵活:
import { watch, ref } from 'vue'
const user = ref({ name: 'Tom', age: 30 })
// 监听整个对象
watch(user, (newVal) => {
console.log('用户变化:', newVal)
}, { deep: true })
// 监听对象的某个属性
watch(() => user.value.name, (newVal) => {
console.log('名字变化:', newVal)
})
// 监听多个数据源
const count = ref(0)
watch([user, count], ([newUser, newCount]) => {
console.log('用户或计数变化')
})
还有个更方便的 watchEffect,自动追踪依赖:
import { watchEffect, ref } from 'vue'
const user = ref({ name: 'Tom' })
const count = ref(0)
watchEffect(() => {
// 这里用到了 user 和 count,watchEffect 会自动监听它们
console.log(`${user.value.name} 的计数: ${count.value}`)
})
// 改任何一个,都会触发
user.value.name = 'Jerry' // 触发
count.value++ // 也触发
组件通信,从"props/emit"到"provide/inject"
Vue2 的组件通信,层级深了就得一层层传:
// 爷爷组件
<GrandParent :user="user" @update="handleUpdate" />
// 父组件
<Parent :user="user" @update="handleUpdate" />
// 孙子组件
<Child :user="user" @update="handleUpdate" />
Vue3 用 provide/inject 可以跨越多层:
// 爷爷组件
import { provide, ref } from 'vue'
export default {
setup() {
const user = ref({ name: 'Tom' })
const updateUser = (newUser) => {
user.value = newUser
}
// 提供给所有后代组件
provide('user', user)
provide('updateUser', updateUser)
}
}
// 孙子组件,直接注入,不用经过父组件
import { inject } from 'vue'
export default {
setup() {
const user = inject('user')
const updateUser = inject('updateUser')
return { user, updateUser }
}
}
就像在公司里,爷爷直接给孙子发通知,不用经过父亲转达。
性能优化,从"全量更新"到"精准打击"
Vue2 的虚拟 DOM 更新,有时候会更新一些不必要的节点。Vue3 做了几个优化:
静态提升:不变的东西,编译时就提出来,不用每次都重新创建。
<template>
<div>
<p>{{ msg }}</p>
<p>不变的文本</p>
<button @click="increment">+1</button>
</div>
</template>
编译后,"不变的文本"这个节点只创建一次,不会在每次更新时重新创建。
事件缓存:事件处理函数也缓存起来。
<template>
<!-- 这个 @click 处理函数会被缓存 -->
<button @click="increment">+1</button>
</template>
Diff 算法优化:标记了哪些节点是动态的,哪些是静态的,更新时直接跳过静态部分。
结果就是,Vue3 的性能比 Vue2 快了 1.3 到 2 倍。
那个 <script setup> 语法糖
后来 Vue 又出了个更简洁的写法,叫 <script setup>:
<script setup>
import { ref, computed, watch } from 'vue'
const count = ref(0)
const doubled = computed(() => count.value * 2)
const increment = () => count.value++
watch(count, (newVal) => {
console.log('计数变化:', newVal)
})
</script>
<template>
<div>
<p>{{ count }}</p>
<p>{{ doubled }}</p>
<button @click="increment">+1</button>
</div>
</template>
不用写 setup() 函数,不用 return,直接定义的变量和函数就能在模板里用。就像你在自己家里,东西放哪儿就用哪儿,不用特意收拾。
组件插槽和动态组件
Vue3 的插槽更灵活。比如一个弹窗组件:
<!-- Modal.vue -->
<script setup>
import { ref } from 'vue'
const isOpen = ref(false)
</script>
<template>
<div v-if="isOpen" class="modal">
<div class="modal-header">
<slot name="header">默认标题</slot>
</div>
<div class="modal-body">
<slot>默认内容</slot>
</div>
<div class="modal-footer">
<slot name="footer">
<button @click="isOpen = false">关闭</button>
</slot>
</div>
</div>
</template>
使用时:
<Modal>
<template #header>
<h2>用户信息</h2>
</template>
<p>这是弹窗内容</p>
<template #footer>
<button @click="save">保存</button>
<button @click="cancel">取消</button>
</template>
</Modal>
Teleport 和 Fragment
有时候你需要把组件渲染到 DOM 的其他地方。比如弹窗应该渲染到 body 下,而不是嵌套在父组件里:
<script setup>
import { ref } from 'vue'
const showModal = ref(false)
</script>
<template>
<div>
<button @click="showModal = true">打开弹窗</button>
<!-- 这个弹窗会被渲染到 body 下 -->
<Teleport to="body">
<div v-if="showModal" class="modal-overlay">
<div class="modal">
<p>这是弹窗</p>
<button @click="showModal = false">关闭</button>
</div>
</div>
</Teleport>
</div>
</template>
Fragment 让你可以返回多个根元素,不用包裹在一个 div 里:
<template>
<header>头部</header>
<main>主体</main>
<footer>底部</footer>
</template>
Vue2 会报错,Vue3 直接支持。
生命周期钩子的变化
Vue2 的生命周期钩子:
export default {
created() { console.log('创建') },
mounted() { console.log('挂载') },
updated() { console.log('更新') },
unmounted() { console.log('卸载') }
}
Vue3 的 Composition API 里,用函数形式:
import { onMounted, onUpdated, onUnmounted } from 'vue'
export default {
setup() {
onMounted(() => console.log('挂载'))
onUpdated(() => console.log('更新'))
onUnmounted(() => console.log('卸载'))
}
}
或者在 <script setup> 里直接用:
<script setup>
import { onMounted, onUpdated, onUnmounted } from 'vue'
onMounted(() => console.log('挂载'))
onUpdated(() => console.log('更新'))
onUnmounted(() => console.log('卸载'))
</script>
自定义 Hook,把复杂逻辑打包成工具
真正的威力在于自定义 Hook。比如一个"获取用户列表"的逻辑,涉及加载状态、错误处理、分页等:
import { ref, computed } from 'vue'
function useUserList(pageSize = 10) {
const users = ref([])
const loading = ref(false)
const error = ref(null)
const currentPage = ref(1)
const totalPages = computed(() =>
Math.ceil(users.value.length / pageSize)
)
const fetchUsers = async (page = 1) => {
loading.value = true
error.value = null
try {
const res = await fetch(`/api/users?page=${page}`)
users.value = await res.json()
currentPage.value = page
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
const nextPage = () => {
if (currentPage.value < totalPages.value) {
fetchUsers(currentPage.value + 1)
}
}
return { users, loading, error, currentPage, totalPages, fetchUsers, nextPage }
}
然后在组件里用:
<script setup>
import { useUserList } from './useUserList'
const { users, loading, error, nextPage } = useUserList()
// 组件挂载时自动获取
onMounted(() => fetchUsers())
</script>
<template>
<div>
<div v-if="loading">加载中...</div>
<div v-if="error" class="error">{{ error }}</div>
<ul v-else>
<li v-for="user in users" :key="user.id">{{ user.name }}</li>
</ul>
<button @click="nextPage">下一页</button>
</div>
</template>
这就像把"用户列表"的整个逻辑打包成一个工具,需要的时候拿出来用。
自定义指令,给 DOM 加魔法
Vue3 的自定义指令更简洁。比如一个"自动聚焦"的指令:
// 全局注册
app.directive('focus', {
mounted(el) {
el.focus()
}
})
// 或者局部注册
const vFocus = {
mounted(el) {
el.focus()
}
}
使用时:
<template>
<input v-focus />
</template>
再比如一个"点击外部关闭"的指令:
const vClickOutside = {
mounted(el, binding) {
el.clickOutsideEvent = (event) => {
if (!(el === event.target || el.contains(event.target))) {
binding.value(event)
}
}
document.addEventListener('click', el.clickOutsideEvent)
},
unmounted(el) {
document.removeEventListener('click', el.clickOutsideEvent)
}
}
使用时:
<script setup>
import { ref } from 'vue'
const showMenu = ref(false)
const closeMenu = () => { showMenu.value = false }
</script>
<template>
<div v-click-outside="closeMenu">
<button @click="showMenu = !showMenu">菜单</button>
<ul v-if="showMenu">
<li>选项1</li>
<li>选项2</li>
</ul>
</div>
</template>
TypeScript 支持,从"猜测"到"确定"
Vue3 对 TypeScript 的支持比 Vue2 好得多。首先,定义类型:
interface User {
id: number
name: string
email: string
}
interface ApiResponse<T> {
code: number
data: T
message: string
}
然后在 Hook 里用:
import { ref, computed, Ref } from 'vue'
function useUserList(pageSize: number = 10): {
users: Ref<User[]>
loading: Ref<boolean>
error: Ref<string | null>
fetchUsers: (page: number) => Promise<void>
} {
const users = ref<User[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const fetchUsers = async (page: number) => {
loading.value = true
try {
const res = await fetch(`/api/users?page=${page}`)
const data: ApiResponse<User[]> = await res.json()
users.value = data.data
} catch (e) {
error.value = e instanceof Error ? e.message : '未知错误'
} finally {
loading.value = false
}
}
return { users, loading, error, fetchUsers }
}
在组件里,TypeScript 会自动推断类型:
<script setup lang="ts">
import { useUserList } from './useUserList'
const { users, loading, error, fetchUsers } = useUserList()
// TypeScript 知道 users 是 User[],会自动提示属性
users.value.forEach(user => {
console.log(user.name) // 有代码补全
})
</script>
泛型 Hook,让代码更通用
有时候你需要一个通用的"获取数据"Hook,可以用泛型:
function useFetch<T>(url: string) {
const data = ref<T | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const fetch = async () => {
loading.value = true
try {
const res = await fetch(url)
data.value = await res.json()
} catch (e) {
error.value = e instanceof Error ? e.message : '未知错误'
} finally {
loading.value = false
}
}
return { data, loading, error, fetch }
}
使用时,类型会自动推断:
// 获取用户列表
const { data: users, fetch: fetchUsers } = useFetch<User[]>('/api/users')
// 获取单个用户
const { data: user, fetch: fetchUser } = useFetch<User>('/api/user/1')
// TypeScript 知道 users 是 User[] | null,user 是 User | null
响应式计算属性的高级用法
有时候你需要一个计算属性依赖多个响应式数据,并且有缓存:
import { ref, computed, watch } from 'vue'
function useSearch(query: Ref<string>, items: Ref<Item[]>) {
const results = computed(() => {
return items.value.filter(item =>
item.name.toLowerCase().includes(query.value.toLowerCase())
)
})
const hasResults = computed(() => results.value.length > 0)
// 监听搜索结果变化
watch(results, (newResults) => {
console.log(`找到 ${newResults.length} 个结果`)
})
return { results, hasResults }
}
组合多个 Hook
真正的威力在于组合。比如一个"用户搜索"的功能,需要搜索和分页:
function useUserSearch() {
const query = ref('')
const { users, loading: listLoading, fetchUsers } = useUserList()
const { results, hasResults } = useSearch(query, users)
const search = async (q: string) => {
query.value = q
await fetchUsers(1)
}
return {
query,
results,
hasResults,
loading: listLoading,
search
}
}
一个 Hook 组合了两个 Hook,逻辑清晰,易于维护。
自定义指令的 TypeScript 类型
定义一个类型安全的指令:
import { DirectiveBinding, VNode } from 'vue'
interface ClickOutsideBinding extends DirectiveBinding {
value: (event: MouseEvent) => void
}
const vClickOutside = {
mounted(el: HTMLElement, binding: ClickOutsideBinding) {
el.clickOutsideEvent = (event: MouseEvent) => {
if (!(el === event.target || el.contains(event.target as Node))) {
binding.value(event)
}
}
document.addEventListener('click', el.clickOutsideEvent as EventListener)
},
unmounted(el: HTMLElement) {
document.removeEventListener('click', el.clickOutsideEvent as EventListener)
}
}
那个老哥现在怎么样
他现在用 Vue3 + TypeScript 写项目,说代码更安全了。有个新来的实习生问他:"Vue3 难吗?"
他想了想说:"不难,就是自由度大了。以前是管家告诉你怎么做,现在是你自己决定。"
有天他写了个 useUserSearch Hook,一个 Hook 里组合了三个其他的 Hook。我问他这样会不会太复杂。
他摇摇头:"不复杂,就是把小工具组合成大工具。就像乐高积木,一块一块拼起来。"
凌晨两点,他还在改 Bug。我问他 Vue3 有没有坑。
他笑了笑:"有啊,但都是自己挖的。现在有 TypeScript,至少能提前发现一些坑。"
#前端 #Vue3 #TypeScript #技术生活 #编程感悟 #工程师日常