Vue 3.4 中的状态概念模型

146 阅读11分钟

摘要

随着现代 Web 应用程序的复杂性不断增加,在前端正确获取状态和组件化仍然是最大的挑战之一。Vue 3.4 中发布的 defineModel 宏提供了一种简化的思路,用于思考如何对复杂的组件间状态管理进行建模,同时仍能保留数据的强局部性和模块化。

完整的示例代码可以在此 GitHub 存储库中找到:github.com/CharlieDigi…

简介

了解状态和组件边界的放置仍然是现代前端 Web 开发中的一项重大挑战,通常也是团队可以做出的最重要的决策之一。这些决策要么可以在应用程序的规模增加时加速开发,要么成为最大的摩擦来源。

如果做得好,它可以轻松地构建、组合、重构和测试前端组件。如果做得不好,它可能是难以追踪的、无处不在的错误的源头,并使代码库感觉脆弱。

defineModel 宏(从 Vue 的 3.4 版本中脱离实验状态)可能是其中一项改变游戏规则的功能,因为它可以影响团队思考和实现不同组件之间复杂状态交互的方式。

这段描述似乎无伤大雅:

defineModel 是一个新的 <script setup> 宏,旨在简化对支持 v-model 的组件的实现。

从表面上看,此宏的实用性似乎很微妙,但它对团队如何思考状态和管理组件边界产生了深远的影响。让我们来看看 defineModel 的作用,以及为什么在 Vue 3.4 中添加它感觉像是一种范式转变,即使它只是一个简单的宏。

前端状态模型概览

一般来说,当我们考虑现代前端应用程序时,状态有 3 个范围(不包括窗口级别的真正全局状态):

  • 全局共享状态:这是整个层次结构中不同组件可以访问的状态。
  • 分层组件状态:这是层次结构的子树中不同组件可以访问的状态。
  • 单个组件级别状态:这是层次结构中单个组件内可以访问的状态。

在全局层面,有很多库和解决方案可以解决这个问题。例如,React 的 Zustand、Jotai、Recoil、Redux(以及其他)和 Vue 的 Pinia 有助于将状态从组件树中提取到全局范围内以跨越树。

状态的第二层是团队遇到 “prop drilling” 摩擦的地方 —— 无论是在 React、Vue 还是其他库或框架中。部分原因是管理在组件之间上下移动状态非常繁琐。

在这种情况下,团队做出的自然决策是将状态转移到全局存储中,或者进入状态的第三个组件范围,并简单地继续堆积到一个巨大的组件中,以避免这种摩擦 —— 而不是创造另一种痛苦。

如果在保持 Vue 的反应式双向绑定的同时,能够轻松地分离状态,而无需 prop 钻取的摩擦和痛苦,那不是很好吗?这正是 defineModel 发挥作用的地方,因为它在保持 Vue 的双向绑定的同时,极大地减少了树中组件之间移动状态的摩擦。

defineModel 是什么?

首先了解它是什么以及它做什么很重要。对于不熟悉 Vue 的人来说,在组件之间上下移动状态的习惯用法使用了 props 和 emits 这对。

在 defineModel 之前 - props 和 emits

例如,考虑这个父组件和子组件:

图片
外部组件定义了作为道具传到子组件中的 ref。更新通过从子组件到父组件的 emit 进行。

要实现双向绑定,我们需要内部的 NameInput.vue 组件看起来像这样:

 <!-- NameInput.vue -->
 <template>
   <LabeledContainer label="NameInput.vue">
     <!-- 👇 Bind it here to our input -->
     <input v-model="name"/>
   </LabeledContainer>
 </template>

 <script setup lang="ts">
 // 👇 Enters as a prop
 const props = defineProps<{
   modelValue: string
 }>()

 // 👇 Emit the update to the parent
 const emits = defineEmits<{
   'update:modelValue': [string]
 }>()

 // 👇 A writable computed to tie it together.
 const name = computed({
   get() {
     return props.modelValue
   },
   set(val) {
     emits('update:modelValue', val)
   }
 })
 </script>

以及外部 Example1.vue 组件:

 <!-- Example1.vue -->
 <template>
   <LabeledContainer label="Example1.vue">
     <h1>Example 1</h1>
     <p>Hello, {{ name.length === 0 ? "(enter your name below)" : name }}</p>
     <!-- 👇 Here is our component -->
     <NameInput v-model="name"/>
   </LabeledContainer>
 </template>

 <script setup lang="ts">
 const name = ref('')
 </script>

现在,当我们在文本框中输入一个值时,这会自动更新 prop 的值:

图片

很容易看出这个样板是如何变得繁琐的!

defineModel ✨ 之后

在 Vue 3.4 中发布 defineModel ✨ 之后,让我们看看它是如何简化 NameInput.vue 的:

 <!-- NameInput.vue -->
 <template>
   <LabeledContainer label="NameInput.vue">
     <input v-model="name"/>
   </LabeledContainer>
 </template>

 <script setup lang="ts">
   // 🎉 Just a single line!
 const name = defineModel<string>({ required: true })
 </script>

父组件保持不变,但已删除了大量样板代码!

一个实际的例子

从表面上看,这似乎是一个微不足道的改变。当然,它带来了一些便利,但这如何真正影响开发人员管理状态?当它只是一个简单的宏时,这么说是不是有点荒谬?

现实情况是,开发人员往往会选择阻力最小的路径,如果阻力最小的路径是糟糕的实践之一,那么,开发人员将创建一个包含许多、许多糟糕实践的代码库 —— 又名 “技术债务”。如果您见过 1000 多行的 React 或 Vue 组件(我们当中谁没有见过?),那么可能的原因是随着组件的有机增长,以可管理的方式分发状态存在太多的摩擦;在一个大型组件中保持共享相同的状态比打破一个新组件更容易。

defineModel 所实现的是创建了一条阻力最小的路径,同时也有助于改善团队对状态的思考方式。突然之间,管理分层组件状态的中间地带变得极其容易,并且消除了将状态移入全局范围的诱惑。

在我们深入探讨如何之前,我认为考虑一下关于状态、组件以及在何处创建边界的概念模型是有用的。

状态的概念模型

如果我们重新审视我们的状态范围列表,那么思考我们应当具体地将什么放入每个范围是有益的。通常,可以在组件层次结构中放置状态的范围有 3 个:

  • 全局状态:真正全局的状态,比如用户帐户信息;不同路由之间共享的信息。
  • 分层状态:组件子树内状态的分组;通常在单个路由内。比如摘要 - 详情编辑器视图。
  • 单个组件状态:与单个独立组件相关状态,其中状态交互不会从组件中冒出,也不需要进入子组件。

图片

虽然全局状态肯定有其用例 —— 例如登录用户的状态或亮 / 暗模式或当前选定的租户 —— 但很容易看出,当需要在多于一两层的层次结构中以层次化方式管理共享状态时,可以滥用它作为 “万能的”。

组件状态很简单:如果一个控件完全独立,不依赖于它的兄弟姐妹或祖先,那么就不需要考虑如何在不同的组件之间共享该状态;我们只是将所有状态和变异保留在组件内部。

但分层状态是最有趣的,因为它映射到常见的场景,在这些场景中,如果在保持 Vue 的响应式双向绑定的同时,可以轻松分离状态而没有 prop 钻取的摩擦和痛苦,那就太好了。这正是 defineModel 发挥作用的地方,因为它极大地减少了 prop drilling 的摩擦。

使用 defineModel 简化分层状态

考虑以下简单的联系人管理应用程序:

图片
注意组件的层次结构和两个清晰的子树。

请注意此示例中的层次结构。当用户从 Listing.vue 中选择联系人时,应用程序应在 Details.vue 中显示详细信息。当用户编辑详细信息并在 Details.vue 中保存更改时,应用程序应更新 Listing.vue 中的条目。

如果我们要在 Listing.vue 和 Details.vue 之间共享状态,则它必须是全局状态或从公共父级 Example3.vue 开始的分层状态。

在此示例中,这就是我们分层状态的外观:

图片
将状态分发到联系人组件是通过列表中的属性。

从外部检查代码。

以下是我们的父级 Example3.vue 组件:

 <template>
   <LabeledContainer label="Example3.vue">
     <h1>Example 3</h1>

     <p v-if="!!selectedContact">
       Selected: {{ selectedContact.name }} ({{ selectedContact.handle  }})
     </p>

     <div class="parent">
       <!-- 👈 Left branch -->
       <Listing
         v-model="contacts"
         v-model:selected="selectedContact"/>

       <!-- 👉 Right branch -->
       <Details v-model="selectedContact"/>
     </div>
   </LabeledContainer>
 </template>

 <script setup lang="ts">
 const selectedContact = ref<Contact>()

 const contacts = ref<Contact[]>([{
   name: 'Charles',
   handle: '@chrlschn'
 }])
 </script>

这是我们的状态存在根源,我们通过绑定将其传递到 Listing 和 Details 组件:

 <!-- Snippet from Example3.vue-->
 <Listing
   v-model="contacts"
   v-model:selected="selectedContact"/>

 <Details v-model="selectedContact"/>

让我们先看看 Details.vue:

 <!-- Details.vue, the right side form inputs -->
 <template>
   <LabeledContainer label="Details.vue">
     <div v-if="!!selected">
       <!-- Input for the user name -->
       <label>
         Name
         <input v-model="name"/>
       </label>

       <!-- Input for the user handle -->
       <label>
         Handle
         <input v-model="handle"/>
       </label>

       <!-- Action buttons -->
       <div>
         <button @click="handleCancel">Done</button>
         <button @click="handleDone">Save</button>
       </div>
     </div>
     <p v-else>
       Select a contact
     </p>
   </LabeledContainer>
 </template>

 <script setup lang="ts">
 const selected = defineModel<Contact|undefined>({
   required: true
 })

 const name = ref('')

 const handle = ref('')

 // When the selected value updates, we update our local copy.
 watch (selected, (contact) => {
   if (!contact) {
     return
   }

   name.value = contact.name,
   handle.value = contact.handle
 })

 // If changes are cancelled, we revert everything.
 function handleCancel() {
   selected.value = undefined
 }

 // If changes are saved, we update the selected object.
 function handleDone() {
   if (!selected.value) {
     return
   }

   selected.value.name = name.value;
   selected.value.handle = handle.value;
 }
 </script>

编写此组件的目的是有一组状态获取联系人详细信息的副本。当所选联系发生改变时,组件会将值复制到本地状态,这样它就可以在用户保存之前修改状态(姓名和句柄)而不影响原始状态。有了这个,用户还可以取消所有编辑。

(对于更大数量的属性,考虑对对象进行完全的响应式复制,并直接绑定到它。)

在左侧,Listing.vue 组件包含一个联系人列表,其中包含添加新联系人的选项。

 <!-- Listing.vue -->
 <template>
   <LabeledContainer label="Listing.vue">
     <div class="container">
       <ContactItem
         v-for="contact in contacts"
         :contact="contact"
         :selected="selected == contact"
         @click="selected = contact">
       </ContactItem>
     </div>

     <div>
       <button @click="handleAddContact"> Add contact </button>
     </div>
   </LabeledContainer>
 </template>

 <script setup lang="ts">
 const contacts = defineModel<Contact[]>({
   required: true
 })

 const selected = defineModel<Contact|undefined>('selected', {
   required: true
 })

 function handleAddContact() {
   contacts.value.push({
     name: 'Name',
     handle: 'Handle'
   })
 }
 </script>

然后在 ContactItem.vue 中,Listing.vue 使用常规 props 将显示值传递下去,因为这里没有变异(不需要双向绑定):

 <template>
   <LabeledContainer
     label="Contact.vue"
     class="contact"
     :class="{
       'selected': !!selected
     }">
     <p class="name">{{ contact.name }}</p>
     <p class="handle">{{ contact.handle }}</p>
   </LabeledContainer>
 </template>

 <script setup lang="ts">
 defineProps<{
   contact: Contact,
   selected?: boolean
 }>()
 </script>

让我们看看这一切是怎样组合在一起的:

图片
我们的组件正在活动中,在我们的示例组件树的层次结构中共享状态。

如果没有 defineModel 来帮助简化此交互,很容易看出本能将如何成为捷径或将其移动到全局状态,因为编写各种 emits 和 computed 甚至在这个小示例中也会产生相当大的摩擦!正如比利・梅斯可能会说的,“但等等!还有更多!”;让我们看看通过使用组合如何进一步简化此代码并使其更容易理解和管理。

使用带有组合的 defineModel

利用组合可以将其提升到一个新的水平,并通过从组件中提取我们的状态进一步简化我们的代码。当组件变得更大时,这样做可能特别有用。在 Vue 中,这很容易实现,并且可以轻松重构和重新组织复杂性。我们只需将我们的状态和函数从组件中提取到另一个函数中:

 // useContacts composable
 export function useContacts() {
   const selectedContact = ref<Contact>()

   const contacts = ref<Contact[]>([{
     name: 'Charles',
     handle: '@chrlschn'
   }])

   function addContact() {
     contacts.value.push({
       name: 'Name',
       handle: 'Handle'
     })
   }

   return {
     selectedContact,
     contacts,
     addContact
   }
 }

用可组合元素重构的 Example3.vue → Example4.vue:

 <!-- Example4.vue -->
 <template>
   <LabeledContainer label="Example4.vue">
     <h1>Example 4</h1>

     <p v-if="!!selectedContact">
       Selected: {{ selectedContact.name }} ({{ selectedContact.handle  }})
     </p>

     <div class="parent">
       <Listing
         v-model="contacts"
         v-model:selected="selectedContact"
         :add="addContact"/>

       <Details v-model="selectedContact"/>
     </div>
   </LabeledContainer>
 </template>

 <script setup lang="ts">
 // We get all of our key state from the composables instead.
 const {
   selectedContact,
   contacts,
   addContact
 } = useContacts()
 </script>

举例来说,如果我们想将更多的逻辑和状态从 Details.vue 中移出,就可以很容易地将 name 和 handle refs 以及 handleCancel () 和 handleDone () 函数移到另一个可组合中并共享:

 // useDetailsEditor.ts

 // 👇 Note how we receive the reactive selectedContact here so we can watch it.
 export function useDetailsEditor(selectedContact: Ref<Contact|undefined>) {
   const name = ref('')

   const handle = ref('')

   // 👇 We add a watcher here on selectedContact so we update the encapsulated state.
   watch (selectedContact, (contact) => {
     if (!contact) {
       return
     }

     name.value = contact.name,
     handle.value = contact.handle
   })

   function cancel() {
     selectedContact.value = undefined
   }

   function done() {
     if (!selectedContact.value) {
       return
     }

     selectedContact.value.name = name.value;
     selectedContact.value.handle = handle.value;
   }

   return {
     name,
     handle,
     cancel,
     done
   }
 }

然后更新 Details.vue:

 <template>
   <LabeledContainer label="Details.vue">
     <div v-if="!!selected">
       <div>
         <label>
           Name
           <input v-model="name"/>
         </label>
       </div>

       <div>
         <label>
           Handle
           <input v-model="handle"/>
         </label>
       </div>

       <div>
         <button @click="cancel">Done</button>
         <button @click="done">Save</button>
       </div>
     </div>
     <p v-else>
       Select a contact
     </p>
   </LabeledContainer>
 </template>

 <script setup lang="ts">
 const selected = defineModel<Contact|undefined>({
   required: true
 })

 const {
   name,
   handle,
   cancel,
   done
 } = useDetailsEditor(selected)
 // 👆 Here we pass in the selected contact so we can watch it in the composable
 </script>

通过使用 Vue 3 的可组合性和 Vue 3.4 的 defineModel 宏,可以很好地分离和封装相关的状态和逻辑。我希望大家能清楚地看到,只需将状态、函数、监视器和计算值整体复制并粘贴到可组合式中,就能轻松重构代码以适应这种模式。

这种模式甚至可以让大型组件子树变得易于管理、重构和测试。

结束语

Vue 3.4 中引入的 defineModel 实际上是一个意义深远的改变,它将帮助团队遵循最佳实践,构建更好、更易于管理的组件。它消除了构建分层状态过程中的大量摩擦,使团队不太可能立即采用全局状态或回到草率的做法。

通过将 defineModel 与 Vue 可组合元素相结合,团队可以创建更清晰的组件,通过组织和封装相关的状态和逻辑,使组件更易于阅读和理解。

当 Evan You 首次提出 Vue 3 的 Composition API 时,社区内一片哗然,大家都希望保留 Options API 的简洁性和易用性。

现在回过头来看,我们可以清楚地看到,Evan You 为帮助 Vue 更好地扩展团队构建的大型项目而设定的道路是正确的。在 3.4 中,由于它使状态管理变得更加精简和直接,所以现在感觉这一愿景似乎更加完整了。在某种程度上,它通过让简单、显而易见的选择成为正确的选择,让通常复杂的状态放置决策过程变得更加清晰。