理解状态的位置和组件边界仍然是现代前端开发中主要挑战之一,也是团队在应用规模增加时做出的最重要的决定之一,可能会加速开发,也可能成为最大的摩擦源。

如果做得好,构建、组合、重构和测试前端组件会变得轻而易举;如果做得不好,则会成为难以追踪的幽灵错误的无尽源泉,使代码库变得脆弱。

Vue 3.4 版本中从实验状态发布的 defineModel 宏,可能是关于组件间复杂状态交互的实现方式最具变革性的特性之一。

描述看似很无害:

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

表面上看,这个宏的实用性似乎微不足道,但它对团队如何处理状态和管理组件边界有深远影响。我们看看 defineModel 是做什么的,为什么它的添加在 Vue 3.4 中感觉像是一种范式转变——尽管它只是一个简单的宏。

状态的概念模型

通常,现代前端应用程序有三种状态范围(不包括全局窗口级别的状态)。

7b057333d0997a20ab4c6922b3e2d5d2.png

  1. 全局共享状态。这是整个层次结构中不同组件可访问的状态,例如登录用户的账户信息和路由间共享的信息。

  2. 分层组件状态。这是在层次结构的子树中不同组件可访问的状态,例如列表-详情编辑视图。

  3. 单个组件级别的状态。这是仅在层次结构中的单个组件中可访问的状态,状态交互不需要在树中上下传递。

在全局级别,有许多库和解决方案可以解决这个问题。例如,React 的 Zustand、Jotai、Recoil、Redux 等,Vue 的 Pinia 可以将状态从组件树中提取出来放入全局范围,以跨越树。它旨在保存真正的全局状态,如浅色/深色模式或租户 ID。

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

在这种情况下,团队的自然决定是将状态移动到全局存储中,或者进入第三个组件状态范围,仅仅是为了避免这种摩擦,而不断堆积到一个巨大的组件中——这会产生另一种痛苦。

如果能在保持 Vue 的双向绑定的同时,轻松地将状态分离开来,而不需要属性传递的摩擦,那该多好。这正是 defineModel 的作用所在,它大大减少了在树中移动状态的摩擦,同时保持 Vue 的双向绑定。

defineModel 是什么?

重要的是首先了解它是什么以及它的功能。对于那些不熟悉 Vue 的人来说,组件间上下移动状态的惯用模式一直是使用 propsemits

在 defineModel 之前 - props 和 emits

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

a858a818d5be5deb25c3802235db7eab.png

外部组件定义了 ref 并将其作为 prop 传递给子组件。更新通过子组件向父组件的 emit 事件来完成。

为了获得双向绑定,我们需要内部的 NameInput.vue 组件如下:

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

<script?setup?lang="ts">
const?props?=?defineProps<{
??modelValue:?string
}>()

const?emits?=?defineEmits<{
??'update:modelValue':?[string]
}>()

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>
????<NameInput?v-model="name"/>
??</LabeledContainer>
</template>

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

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

02558365b298dae16b7b8fd4a3687c8e.gif

我们的非常简单的组件具有父子组件之间的双向绑定。

可以很容易地看到,对于如此简单的事情,这种样板代码会变得多么繁琐!

在 defineModel 之后

随着 Vue 3.4 中 defineModel 的发布,我们看看它如何简化 NameInput.vue

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

<script?setup?lang="ts">
const?name?=?defineModel<string>({?required:?true?})
</script>

父组件保持不变,但大量的样板代码被删除了!这个小小的宏完全改变了管理状态的体验。

一个实际的例子

表面上看,这似乎是一个微不足道的变化。当然,获得了一些便利,但这对开发人员管理状态有多大影响?仅仅是一个简单的宏,声称会有这么大的影响岂不是荒谬?

事实上,开发人员往往会选择阻力最小的路径,如果阻力最小的路径是坏习惯,那么,开发人员将创建一个充满许多坏习惯的代码库——即“技术债务”。如果你见过 1000 多行的 React 或 Vue 组件(我们中谁没见过?),那么很可能的原因是将状态以可管理的方式分散出来的摩擦太大;随着组件的有机增长,将状态保持在一个巨大的组件中比分解出新组件更容易。

defineModel 的实现是,它创建了一条最小阻力路径,同时有助于改善团队对状态的思考方式。突然之间,管理分层组件状态变得微不足道,并消除了将状态移入全局范围或在大型组件中进行松散操作的诱惑(通常是 1000 多行组件的来源)。

使用 defineModel 简化分层状态

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

2087cdddaef07e61c95540730027a592.png

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

如果我们想在 Listing.vueDetails.vue 之间共享状态,它必须是全局状态或从公共父级 Example3.vue 开始的分层状态——否则,很容易看到将所有内容放入一个巨大的组件中的诱惑!

在这种情况下,这就是我们的分层状态的样子:

312e10adc4a89f3437eac68637b8ffab.png

状态通过 prop 从列表组件传递到联系人组件。

我们从外到内检查代码。

这是我们的父 Example3.vue 组件:

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

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

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

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

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

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

这是我们的状态所在根,并通过绑定将其传递给 ListingDetails 组件:

<!--?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">
??????<label>
????????Name
????????<input?v-model="name"/>
??????</label>

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

??????<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('')

watch?(selected,?(contact)?=>?{
??if?(!contact)?{
????return
??}

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

function?handleCancel()?{
??selected.value?=?undefined
}

function?handleDone()?{
??if?(!selected.value)?{
????return
??}

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

这个组件的目的是拥有一组状态副本,当选中的联系人更改时,组件将值复制到本地状态,以便在不影响原始状态的情况下(直到用户保存),更改名称和 handle。这也允许用户取消任何编辑。

对于更大的属性集,可以考虑创建对象的完整响应式副本并直接绑定到它。

在左侧,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>

我们看看这整个事情是如何结合在一起的:

73161106e5385dba2a22d0a2d6b999e1.gif

我们的组件在示例组件树的层次结构中共享状态。

如果没有 defineModel 来帮助简化这种交互,很容易看到本能是采取捷径或将状态移入全局状态,因为编写各种 emitscomputed 会产生相当大的摩擦,即使在这个小示例中也是如此!

正如 Billy Mays 可能会说:“但等一下!还有更多!”让我们看看如何通过使用可组合组件进一步简化代码。

使用 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
??}
}

很容易看出,如果我们想从 Details.vue 中移动更多的逻辑和状态,将 namehandle refs 以及 handleCancel()handleDone() 函数移动到另一个可组合组件中并共享它们是非常低摩擦的:

//?useDetailsEditor.ts

//????注意我们在这里接收响应式?selectedContact?
export?function?useDetailsEditor(
??selectedContact:?Ref<Contact|undefined>
)?{
??const?name?=?ref('')?

??const?handle?=?ref('')?

??//????我们在?selectedContact?上添加一个监听器
??//?以便更新封装的状态。
??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)
//????我们将选中的联系人传递给可组合组件以便监听
</script>

使用 Vue 3 可组合组件和 Vue 3.4 的 defineModel 宏,这种方式可以很好地将相关状态和逻辑干净地分离和封装。我希望清楚的是,通过简单地复制状态、函数、监听器和计算值并将它们粘贴到可组合组件中,可以轻松地重构代码。

这种模式可以使即使是大型子组件树也变得易于管理、重构和测试。

结论

Vue 3.4 中引入的 defineModel 实际上是一个深远的变化,它将帮助团队遵循最佳实践并构建更好、更易管理的组件。通过消除构建分层状态时的许多摩擦,它使得团队不太可能立即求助于全局状态或倒退到松散的做法。

通过将 defineModel 与 Vue 可组合组件结合使用,团队可以通过组织和封装相关状态和逻辑来创建更干净的组件,这些组件易于阅读和理解。

当尤雨溪首次提出 Vue 3 的 Composition API 时,社区中有很多反对声音,认为它应该保留 Options API 的简单性和易用性。事后看来,很明显,尤雨溪帮助 Vue 更好地扩展以应对构建大型项目的团队的路径是正确的选择。

随着 3.4 的发布,由于它使状态管理变得更加简洁和直观,这种愿景现在感觉更加完整。在某种程度上,它有助于澄清状态放置位置的复杂决策过程,使简单而明显的选择成为正确的选择。defineModel 就像是一颗投向海洋的小石子,其涟漪可能有一天会成为汹涌的波浪!

最后:

vue2与vue3技巧合集

VueUse源码解读

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐