01|局部状态优先
第五章概览 / 01。目标:先确定唯一 owner,再选择容器;不要因为多个组件“拿得到”同一变量,就默认它们都应该拥有它。
从一条单向数据流开始
一个组件已经是一套完整的状态系统:源状态驱动视图,用户事件调用动作,动作修改源状态。只服务当前实例的输入值、焦点、折叠状态和临时错误,最自然的 owner 就是当前组件。
用户输入 ──> 组件动作 ──> 局部源状态 ──> computed ──> 模板computed 是从源状态得到的视图,不要再保存第二份。否则修改 draft 时还要记得同步 canSubmit,两个 owner 会制造不一致。
最小组件:草稿归编辑器所有
<!-- DraftEditor.vue -->
<script setup lang="ts">
import { computed, ref } from 'vue'
const props = defineProps<{ initialText?: string }>()
const emit = defineEmits<{ saved: [text: string] }>()
const draft = ref(props.initialText ?? '')
const error = ref('')
const canSave = computed(() => draft.value.trim().length > 0)
function save() {
const text = draft.value.trim()
if (!text) {
error.value = '内容不能为空'
return
}
error.value = ''
emit('saved', text)
}
</script>
<template>
<label>草稿 <textarea v-model="draft" /></label>
<button :disabled="!canSave" @click="save">保存</button>
<p v-if="error" role="alert">{{ error }}</p>
</template>这里的 owner 分工很清楚:父组件只提供初值并接收已确认结果,编辑器拥有未提交草稿,canSave 只是派生值。组件卸载后草稿随实例释放,不需要全局 reset()。
initialText 的含义是“创建时的初值”,不是持续双向同步。如果父组件后续切换文档,应通过 :key="documentId" 创建新编辑器,或把“当前文档草稿”提升给共同 owner;不要加两个 watch 来回复制。
状态应该放哪一层
| 现象 | 合适的 owner | 原因 |
|---|---|---|
| 单个组件的 hover、焦点、输入中草稿 | 组件内 ref | 生命周期与组件一致 |
| 兄弟组件共同编辑同一值 | 最近共同祖先 | Props 向下、事件向上,仍有唯一源 |
| 一棵子树共享主题或表单上下文 | provide/inject 的 provider | 依赖范围跟组件树一致;写操作留在 provider |
| 多个页面共享登录用户、购物车 | 领域 store | 需要跨路由存活、统一动作与调试时间线 |
| 筛选、分页、选中资源身份需刷新/分享 | URL params/query | 浏览器历史才是这类状态的 owner |
| 列表、详情、保存结果来自 API | 服务端 + 客户端缓存 | 服务端是权威源,需要失效、重试和取消 |
“第二个消费者出现”是重新判断 owner 的信号,不是自动上 store 的命令。若两个消费者都在同一布局下,提升到布局往往已经足够。
提升状态时保留写入口
<!-- ParentView.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import DraftEditor from './DraftEditor.vue'
import DraftPreview from './DraftPreview.vue'
const savedText = ref('')
</script>
<template>
<DraftEditor :initial-text="savedText" @saved="savedText = $event" />
<DraftPreview :text="savedText" />
</template>现在父组件拥有“已保存文本”,编辑器仍拥有“输入中草稿”。这不是重复状态,因为两者代表不同业务阶段。如果产品要求预览每一次按键,再把 draft 提升给父级并改成受控组件;需求变化应对应 owner 变化。
常见失败路径
- 把派生值存进 store:源值变化后副本过期。改为 getter/
computed。 - 模块级
ref冒充局部状态:每个组件实例实际共享同一值,SSR 还可能跨请求污染。每次setup内创建局部状态。 - 为了传三层 Props 就建全局 store:状态被迫活得比页面更久。先考虑共同祖先或有边界的
provide/inject。 - 组件进入
<KeepAlive>后以为已经卸载:停用不会销毁实例;若需要停用时暂停资源,使用onDeactivated,不要依赖“局部状态自然释放”。 - 用 watch 双向同步两份状态:任一方向遗漏或调度顺序变化都会循环/覆盖。重新指定唯一 owner。
练习:给搜索页划分 owner
有一个商品搜索页,包含 inputText、已提交的 keyword、sort、下拉框是否展开 menuOpen、服务端 products 和 requestError。
要求:
- 为六份状态选择组件、URL 或远程缓存 owner,并说明生命周期。
- 写一个不使用 Pinia 的
<SearchBox>:输入为空时拒绝提交,成功时只 emit 去除首尾空格后的关键字。 - 变化题:页面新增跨路由保留的“比较清单”后,只把清单提升到 store;原有局部状态不得一起搬迁。
URL联动是读完第六章params/query后的回访部分;当前先完成owner判断和SearchBox的输入/事件实验,不要求提前猜Router API。
回访验收:刷新含 ?q=vue&sort=price 的 URL 能恢复已提交查询;打开/关闭菜单不产生历史记录;空字符串不会触发搜索;组件卸载后输入中内容消失。
参考答案:第 01 题。
本页结论
容器由所有权和生命周期决定。先减少状态副本,再引入共享机制;store 解决的是跨边界共享与统一变更,不是“变量太多”。