Skip to content

01|局部状态优先 ​

第五章概览 / 01。目标:先确定唯一 owner,再选择容器;不要因为多个组件“拿得到”同一变量,就默认它们都应该拥有它。

从一条单向数据流开始 ​

一个组件已经是一套完整的状态系统:源状态驱动视图,用户事件调用动作,动作修改源状态。只服务当前实例的输入值、焦点、折叠状态和临时错误,最自然的 owner 就是当前组件。

text
用户输入 ──> 组件动作 ──> 局部源状态 ──> computed ──> 模板

computed 是从源状态得到的视图,不要再保存第二份。否则修改 draft 时还要记得同步 canSubmit,两个 owner 会制造不一致。

最小组件:草稿归编辑器所有 ​

vue
<!-- 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 的命令。若两个消费者都在同一布局下,提升到布局往往已经足够。

提升状态时保留写入口 ​

vue
<!-- 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。

要求:

  1. 为六份状态选择组件、URL 或远程缓存 owner,并说明生命周期。
  2. 写一个不使用 Pinia 的 <SearchBox>:输入为空时拒绝提交,成功时只 emit 去除首尾空格后的关键字。
  3. 变化题:页面新增跨路由保留的“比较清单”后,只把清单提升到 store;原有局部状态不得一起搬迁。

URL联动是读完第六章params/query后的回访部分;当前先完成owner判断和SearchBox的输入/事件实验,不要求提前猜Router API。

回访验收:刷新含 ?q=vue&sort=price 的 URL 能恢复已提交查询;打开/关闭菜单不产生历史记录;空字符串不会触发搜索;组件卸载后输入中内容消失。

参考答案:第 01 题。

本页结论 ​

容器由所有权和生命周期决定。先减少状态副本,再引入共享机制;store 解决的是跨边界共享与统一变更,不是“变量太多”。

用心记录每一步成长