首页 / 文章

Vue 3 实战:组合式函数、类型化契约与比例状态管理

Vue 3的组合式API、带类型的props与emits功能,以及Pinia和渐进式采用策略,使得应用仅在其确实需要变得复杂时才会如此,同时也避免了在Vue并不适合的场景下使用它。

发布者:REACTAPP.TOP阅读约 12 分钟2722 词

来源: 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 仅用于定义类型,这使得组件的公共接口清晰可见,并能通过编译器进行验证。
  • 有意识地分层管理状态:先从本地状态开始,再使用组合式函数,仅将 Pinia 用于真正全局的数据。
  • 可以在现有的服务器渲染应用中逐个添加交互功能来引入 Vue,因此对于成熟产品而言这是一种风险较低的方案。
  • 团队的专业能力、现有代码以及招聘条件往往比框架的易用性更重要,因此应结合这些因素来评估 Vue,而非仅依据其流行度或测试结果。