Vue 中的受控与非受控组件
Vue 中的受控与非受控组件
熟悉 React 的开发者应该对“受控组件”的概念并不陌生,实际上对于任何组件化开发框架而言,都可以实现所谓的受控与非受控,Vue 当然也不例外。并且理解受控与非受控对应的需求场景,可以让我们在设计一些基础组件时思路更加清晰,暴露出来的组件 API 也更加合理、统一。
需求
许多 UI 组件都是有状态(stateful)的,而这个状态是由组件外部控制还是组件内部维护,也就对应了受控与非受控两种模式。
例如 Tabs 组件是很常见的一种 UI 组件,它的核心状态就是记录当前 active 的 Tab,并且允许用户切换。
很多时候我们只希望 Tabs 可以正确的展示 active 的内容、并在用户操作时正常切换,不需要进行任何干预,那么就希望 只需要传入所有的 Tab 内容,不需要再做额外的配置。
但有的时候我们又希望对 Tabs 的状态有很强的控制能力,例如多个关联的 Tabs,子级 Tabs 的内容需要根据父级 Tabs 的 active Tab 动态切换,这时候就会希望 Tabs 组件可以暴露足够充分的 API,来实现业务的需求。
因此我们可以用一种通用的模式,来让任意组件的任意状态同时兼容受控与非受控两种模式,让不同需求场景下都可以使用最合理的 API。
简化示例
我们用一个简单的 Tabs 实现来演示这种通用的组件 API 设计模式,简化的部分包括:
- 用 index 来作为 Tab 的唯一标识
- Tab content 只支持字符串
可以打开 online DEMO 配合阅读
API 设计
对于 Vue 组件而言,API 设计主要指的是内部的 data, computed, methods 以及对外的 props, events。在这个示例中,我们会用 activeIdx
作为核心状态,所有的 API 也都会围绕这个状态命名。
非受控模式
如上文所说,非受控模式指的是使用者不需要关心控制组件的状体,完全交由组件内部维护。
因此我们的 API 会包括:
{ props: { defaultActiveIdx: { type: Number, default: 0 } }, data() { return { localActiveIdx: this.defaultActiveIdx } }, methods: { handleActiveIdxChange(idx) { this.localActiveIdx = idx; this.$emit("active-idx-change", idx); } } }
localActiveIdx
是我们用来存放 active index 的组件内 data,对于非受控模式而言,虽然不希望在外部维护状态,但是仍有可能希望在外部决定初始状态,所以我们用 defaultActiveIdx
这个 props 决定 localActiveIdx
的初始值。
之后当我们用 v-for="(tab, idx) in tabs"
指令生成所有的 Tab 时,就可以通过 idx === localActiveIdx
的方式判断当前 Tab 是否 active,再通过 @click="handleActiveIdxChange(idx)"
就可以实现对 localActiveIdx
的更新。
同样的,我们也可以通过 {{ tabs[localActiveIdx].content }}
展示 active Tab 的内容。
需要注意的是在 handleActiveIdxChange
的事件处理中,我们也 emit 了 active-idx-change
这一事件,这样可以方便外部在不需要管理组件状态的同时也可以与组件状态保持同步。例如我们希望将 active Tab 反映在 URL 中,就可以在外部监听 active-idx-change
这一事件,并将当前 index 同步到路由中,在将路由中获取到的 index 作为 defaultActiveIdx
传入,就可以实现 URL 和 Tabs 的同步。
受控模式
对于受控模式来说,我们可以理解为 active index 是外部传入的 props,由外部自行维护其状态。
因此我们只需要添加如下 props:
props: { activeIdx: Number }
由于我们已经有对外 emit 的事件 active-idx-change
,所以外部用以下方式就可以用一个 data 属性 externalActiveIdx
维护对应状态:
<tabs :tabs="tabs" :activeIdx="externalActiveIdx" @active-idx-change="this.externalActiveIdx = $event" />
当然由于在这种模式下外部对状态有完全的控制权,所以在 active-idx-change
的事件处理中也可以做更为复杂的判断,例如是否允许激活目标 Tab 之类的校验。
而在 Tabs 组件内部,我们还需要做一些小的修改。在受控模式中,我们所有状态相关的处理都是直接使用 localActiveIdx
,而现在我们的逻辑应该变为“如果存在 activeIdx
props,则使用,否则使用 localActiveIdx
”。
为了保证以上逻辑不会让我们的组件内部实现变得复杂、易错,我们引入一个 computed 属性:
computed: { _activeIdx() { return this.activeIdx || this.localActiveIdx; } }
这样我们就可以把状态相关的判断改为通过 idx === _activeIdx
判断一个 Tab 是否为激活状态,也通过 {{ tabs[_activeIdx].content }}
展示 active Tab 的内容。
同样,我们在 handleActiveIdxChange
的方法内部也可以增加一个判断,如果存在 props aciveIdx
则不更新 localActiveIdx
:
handleActiveIdxChange(idx) { if (this.activeIdx === undefined) { this.localActiveIdx = idx; } this.$emit("active-idx-change", idx); }
在一些更复杂的组件中,可能会频繁判断是否为受控模式并做不同的处理,这时候通过 this.activeIdx
这样的核心状态 props 是否传入来判断是否为受控模式是一个不错的实践。
总结
最终我们为 active index 设计的完整 API 如下:
{ props: { activeIdx: Number, defaultActiveIdx: { type: Number, default: 0 } }, data() { return { localActiveIdx: this.defaultActiveIdx }; }, computed: { _activeIdx() { return this.activeIdx || this.localActiveIdx; } }, methods: { handleActiveIdxChange(idx) { if (this.activeIdx === undefined) { this.localActiveIdx = idx; } this.$emit("active-idx-change", idx); } } }
通过这种 API 设计方式,可以让我们设计的基础组件使用方式更一致,拓展性更强,不论是开发还是使用时思路也会更加简洁清晰。