Vue 3 实战:组合式函数、类型化契约与比例状态管理
Vue 3的组合式API、带类型的props与emits功能,以及Pinia和渐进式采用策略,使得应用仅在其确实需要变得复杂时才会如此,同时也避免了在Vue并不适合的场景下使用它。
来源: Vue.js Is the Frontend Framework More Teams Should Be Using
当一个团队选择 React 时,没人会要求解释原因;而当他们选择 Vue 时,几乎总有人会追问理由。这种反应更多反映的是习惯而非框架本身的优劣,因为 Vue 3 已经是相当成熟的选项,拥有官方的路由和状态管理功能、一流的 TypeScript 支持,以及通过 Nuxt 实现的全栈开发能力。本文将通过实际代码来介绍 Vue 3:如何读取状态和派生值、如何用组合式函数封装行为、如何通过类型化的属性和事件暴露组件契约、Pinia 如何与之配合使用,以及哪些场景下 Vue 并非最佳选择。
贯穿全文的主题是适度性。Vue 能为复杂应用提供足够的结构支撑,同时又不会让每个小功能都变成架构层面的决策,而这一优势在真实的组件中比在功能对比中更易体现。
一个实用的首个组件:过滤搜索功能
待办事项应用对框架的体现甚少。一个更现实的测试是产品搜索功能:输入内容、产品列表以及与查询匹配的子集。
下面的逻辑使用了两个基本组件。ref用于创建响应式状态,此处即搜索文本和产品数组。computed则根据这些状态计算出值并将其缓存,直到其依赖项发生变化。需要注意的是,过滤器会先对查询内容进行截取和转小写处理,当查询为空时会返回完整列表,之后才进行过滤操作。
import { computed, ref } from 'vue'
interface Product {
id: number
name: string
price: number
}
const search = ref('')
const products = ref<Product[]>([
{ id: 1, name: 'Mechanical Keyboard', price: 120 },
{ id: 2, name: '4K Monitor', price: 450 },
{ id: 3, name: 'Wireless Mouse', price: 60 }
])
const filteredProducts = computed(() => {
const query = search.value.toLowerCase().trim()
if (!query) {
return products.value
}
return products.value.filter(product =>
product.name.toLowerCase().includes(query)
)
})
模板直接绑定到这些值。v-model使输入内容和search状态双向同步,v-for会根据过滤后的产品逐个渲染项目,而:key则为每一行提供稳定的标识,以便Vue高效地更新列表。
<input
v-model="search"
type="search"
placeholder="Search products..."
/>
<ul>
<li
v-for="product in filteredProducts"
:key="product.id"
>
{{ product.name }} - ${{ product.price }}
</li>
</ul>
几乎没有繁琐的流程。状态是可见的,派生值也是可见的,使用这些值的标记代码同样清晰可见。无需先学习框架特有的机制术语,就能从上到下阅读代码。这听起来可能只是个小优点,但随着代码库的扩大,一个人能多快理解一个陌生的文件,便成为框架所能带来的最重要影响之一。
有一个值得了解的细节:在模板中,引用会自动解包,因此只需写search而非search.value。而在脚本代码中仍需使用.value,这就是为什么计算属性函数会读取search.value和products.value的原因。
组合 API:重用行为而非组件
旧版 Vue 与 Vue 3 之间最大的区别在于组合式 API。选项式 API 按框架类别(数据、方法、计算属性、监听器)对代码进行分组,导致同一功能分散在多个部分中。而组合式 API 允许你根据功能的用途来组织代码。
以一个需要获取数据并跟踪加载状态和错误状态的产品页面为例,这类功能可以被封装到一个组合函数中——即一个通常以 use 开头的普通函数,它返回响应式状态以及用于修改该状态的函数:
import { ref } from 'vue'
export interface Product {
id: number
name: string
price: number
}
export function useProducts() {
const products = ref<Product[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
async function loadProducts(): Promise<void> {
loading.value = true
error.value = null
try {
const response = await fetch('/api/products')
if (!response.ok) {
throw new Error(`Request failed with ${response.status}`)
}
products.value = (await response.json()) as Product[]
} catch (err) {
error.value =
err instanceof Error
? err.message
: 'Failed to load products.'
} finally {
loading.value = false
}
}
return {
products,
loading,
error,
loadProducts
}
}
这里有一些刻意的设计。在finally块中会重置loading状态,这样无论请求成功还是失败它都会被清零。每次尝试之前都会清除error,以避免旧的错误信息持续存在。非2xx响应会被转换为异常,因为fetch仅在网络故障时才会拒绝请求,而不会因HTTP错误状态码而拒绝。此外,在读取message之前,catch块会通过instanceof Error来限定err的类型,这是必要的,因为TypeScript会将捕获的值视为unknown类型。
关键的区别在于究竟什么会被重用。你分享的并非组件及其标记语法,而是其行为逻辑。组件专注于用户所看到的内容,而可组合元素则侧重于功能的实现方式,两者都不必为了让两个界面共享某些逻辑就变成全局抽象。需要注意的一点是:每次调用 useProducts() 都会创建独立的状态。这通常是人们希望看到的,但如果两个组件需要查看相同的产品列表,则就需要共享状态,这时存储机制便派上用场了。
为每个功能找到合适的归宿
框架的强大之处在于它能提供诸多功能,或者在于它能够缩小你的设计意图与实际编码之间的差距。Vue 更倾向于后者。
想象这样一个看似简单的需求:当过滤器发生变化时,需在URL中体现这一变化,重新获取数据,保持分页功能,并显示加载指示器。在合理的Vue架构中,这涉及多个组成部分,每个部分都有明确的负责人:
- 组件负责渲染界面
- 组合式函数实现该功能的具体逻辑
- 路由器管理URL状态
- API客户端负责与后端通信
- 仅当数据确实需要在整个应用中共享时,才由状态管理器保存
没有哪个部分需要知晓所有细节。这种分离并非Vue所独有,任何现代框架都能实现。值得关注的是,Vue的底层机制能自然而然地引导开发者采用这种架构。
单文件组件:一个UI单元对应一个文件
Vue的单文件组件将模板、脚本和样式整合在同一个.vue文件中。有时批评者会认为这是一种风格上的特例,但其核心理念并非“将所有内容放在一个文件中”,而是要求每个UI单元都应存在于唯一的位置。这里重要的是组件模型,而非文件格式。
一个简单的展开/收起切换功能仅需一个布尔值以及用于切换该状态的函数:
import { ref } from 'vue'
const isOpen = ref(false)
function toggle(): void {
isOpen.value = !isOpen.value
}
标记语的用法同样直观。@click将按钮与toggle函数关联,而v-if则根据isOpen的值来显示或隐藏面板:
<button type="button" @click="toggle">
Account Settings
</button>
<div v-if="isOpen">
Manage your account preferences.
</div>
状态与用户界面之间的距离极小,并且会一直保持如此。即使数月后返回该功能进行调试,标志位与屏幕上显示内容之间的关联依然清晰可见。需要注意的是,v-if会将该元素完全从DOM中移除;如果面板切换频率很高或包含耗资源的内容,仅通过CSS隐藏元素的v-show可能是更好的选择。
优秀的组件架构并非在于减少文件数量,而在于让相关内容易于查找。
契约定义在 TypeScript 中
前端应用中充满了各种契约:组件接受的属性、它发出的事件、组合式函数返回的值、API客户端生成的模型,以及存储层暴露的状态和动作。当这些契约是隐含的时,重构大型代码库就意味着要依赖人们记住这些假设,而非直接查看它们。
Vue 3让这些契约与组件逻辑紧邻在一起。在<script setup>中,defineProps和defineEmits是编译器宏,仅接受类型声明:
interface User {
id: number
name: string
email: string
}
const props = defineProps<{
user: User
}>()
const emit = defineEmits<{
updated: [user: User]
}>()
function save(): void {
emit('updated', props.user)
}
属性类型表明该组件需要一个类型为 User 的 user。而事件发射类型采用元组形式的签名,这意味着 updated 事件恰好只包含一个参数,即一个 User 对象。如果使用错误的事件名称或数据内容调用 emit,就会引发类型错误。任何打开该文件的人都能一目了然地看到组件的公共接口。
这正是 TypeScript 在大型前端项目中最具价值的地方。自动补全和编辑器提示固然便利,但明确的契约才能让代码变更更易于理解,也让重构的风险更低。
尽可能将状态保持在本地
让前端变得复杂的一个可靠方法是将所有内容都放入全局状态中:为模态框创建一个存储,为表单字段创建一个存储,为选中的表格行创建一个存储,为临时的搜索查询创建一个存储。不久之后,应用程序的一半功能都存在于这个庞大的状态层中,任何代码都能修改其中的内容。
Vue 提供了一种更为渐进的方式:
- 将本地状态保留在拥有它的组件内部
- 将可复用的功能放入组合式函数中
- 真正需要共享的状态才放入存储中
在最后这一步中,Pinia 是 Vue 的官方状态管理库。下面的示例是一个用于购物车的选项式状态管理实例。state用于返回初始数据,total getter 根据商品和数量计算购物车总价,而各种动作则用于修改列表。addItem函数在商品已存在于购物车时增加其数量,否则会添加该商品的副本,以避免状态管理库与调用方共享同一个对象引用。
import { defineStore } from 'pinia'
export interface CartItem {
id: number
name: string
price: number
quantity: number
}
interface CartState {
items: CartItem[]
}
export const useCartStore = defineStore('cart', {
state: (): CartState => ({
items: []
}),
getters: {
total: (state): number =>
state.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
)
},
actions: {
addItem(item: CartItem): void {
const existing = this.items.find(
current => current.id === item.id
)
if (existing) {
existing.quantity += item.quantity
} else {
this.items.push({ ...item })
}
},
removeItem(itemId: number): void {
this.items = this.items.filter(
item => item.id !== itemId
)
}
}
})
商店本身并非关键所在,真正重要的是创建它的决策。文本输入几乎从不需要全局状态,而头部、产品页面和结账页面都会使用的购物车则很可能需要。Vue允许开发者自行判断是否需要全局状态,而非将所有状态都放入同一个容器中。Pinia还支持直接使用ref和computed的设置式语法,一些团队更喜欢这种方式,因为它与组合式的编写方式更为一致。
逐步发展而非一次性投入
Vue 并不要求你在第一天就确定整个架构。你可以从 Vue 本身开始,需要路由功能时再加入 Vue Router,需要全局状态管理时就添加 Pinia,而当需要服务端渲染、服务端路由以及更完整的全栈架构时再转向 Nuxt。小型应用可以保持简洁,大型应用则可以根据需求逐步增加结构。
这对于现有产品尤为重要。Vue 可以被集成到用 Django、Laravel、Rails 或普通 PHP 构建的已服务端渲染的应用中,而无需重新构建整个前端。也许只有分析仪表板需要强大的交互功能,或者某个配置界面已经超出了服务端模板的能力范围,又或者编辑器需要复杂的客户端行为——Vue 可以专门处理这些部分,其余部分则保持不变。
并非所有前端都必须是单页应用
在某些情况下,生态系统会将“前端应用”与“单页应用”视为同义词,但实际上并非如此。许多产品更适合采用服务器端渲染的页面,并在其中加入少量精心设计的交互元素,Vue非常契合这种模式。
对于维护成熟应用的团队而言,这完全改变了迁移的思路。问题不再是从“如何用Vue重写产品?”转变为“Vue能在哪些地方减少最大程度的阻力?”。后一个问题能带来更小规模、更安全的步骤,并更快实现价值。
性能源自架构,而非基准测试
Vue的性能表现不错,但基准测试数据并不能成为选择某个框架的充分理由。生产环境中的应用之所以变慢,很少是因为其框架在微基准测试中落后了几毫秒;真正的原因是它们需要渲染大量数据、传输过大的代码包、重复发送网络请求、保留过多的状态,或是在不恰当的地方执行耗时操作。
Vue的模型设计有助于避免这些问题:
- 组件可以保持简洁且专注
- 状态可以限制在局部,避免触发整个应用的更新
- 功能与路由可以实现懒加载
- 可复用的逻辑可以封装在组合式函数中
- 全局状态仅包含真正需要存储的内容
这些措施并不能保证性能自动提升,但能让良好的性能更易于实现,这比在基准测试中稍占优势要有价值得多。
成熟的生态系统
当初选择 Vue 就意味着要接受相对较小的生态系统。但现在这一论点已大为削弱。Vue 拥有官方路由组件、用于管理应用状态的 Pinia、成熟的开发者工具、众多 UI 组件库,还有专为需要完整应用框架的团队准备的 Nuxt。
这些工具加在一起能够满足各种需求:从单个交互式组件,到大型单页应用,再到服务器端渲染的网站,甚至是全栈产品。在编写第一行代码之前,你无需先确定自己要构建哪种类型的应用,而这种灵活性本身便是一种优势。
较难量化的论点:决策基于需求
最难量化的益处在于日常决策带来的体验。你打开一个组件并理解其功能。由于需要可复用的行为,你就编写了组合式函数。状态属于某个组件,因此会保留在该组件中。当确实需要共享某些内容时,就会将其放入状态管理库中。应用需要路由功能,于是你就添加了路由器。当应用规模超出单客户端应用的范畴时,你就会采用 Nuxt。
每一步决策都是为了解决实际存在的复杂性。你做出架构上的选择是因为产品需求如此,而非框架预先规定的要求。在项目的整个生命周期中,这种差异会不断累积。
何时 Vue 不是合适的选择
Vue 并不总是最佳选择。那些拥有庞大 React 代码库、自建的 React 组件库、深厚的内部 React 开发经验,且招聘方向也以 React 工程师为主的组织,若进行迁移,很可能损失大于收益。框架的选择从来都不是孤立进行的;团队技能、现有基础设施、招聘成本以及迁移费用,这些因素的重要性至少与编程模型本身相当。
Vue 也无法避免糟糕的工程实践。在 Vue 中同样可能构建出庞大的组件、臃肿的全局状态管理机制、多余的抽象层以及加载缓慢的页面,这与其他任何框架并无不同。它仅提供便捷的默认设置,并不能替代人的判断力。
比例作为设计原则
Vue 的突出优势在于其恰到好处的设计理念。小问题就有简单的解决方案,可复用的功能会被封装为组合式函数,而共享状态则仅在必要时才会使用存储机制。导航功能由路由器处理,规模较大的应用可以选择 Nuxt,TypeScript 负责界定代码边界而不需接管整个代码库。
这些都不是什么颠覆性的创新,而这正是其意义所在。框架无需每隔几个月就重新发明前端开发方式,只需让现有问题更易于解决即可。
核心要点
- Vue 3 的
ref和computed功能能让状态和计算值更加清晰,从而在组件规模扩大时仍保持可读性。 - 组合式函数用于共享功能而非标记,每次调用都会创建独立的状态,因此只有在确实需要共享数据时才应使用存储机制。
defineProps 和 defineEmits 仅用于定义类型,这使得组件的公共接口清晰可见,并能通过编译器进行验证。