知

模块一 · 起步与基础

第 5 / 17 节16 分钟阅读型

组件边界:通信方式与状态该放在哪

组件拆分不是"越小越好",而是按职责与变更频率划边界。这一节给出七种通信方式的适用场景,以及状态放置的判断顺序。

读完这一节,你能做到

  1. 用职责、复用、变更频率三个维度判断组件边界。
  2. 在七种通信方式中选对一种,而不是全部用全局状态。
  3. 判断一个状态该放组件内、父级还是全局。
  4. 识别"过度拆分"和"巨型组件"两种极端。

一、边界的三个判断维度

维度问题结论
职责它是否只做一件事多个职责就拆
复用是否会在别处出现会复用就抽组件
变更频率是否与周围同时变化变化节奏不同就隔开

反例:为了"看起来组件化"把一行文字抽成组件,结果 props 比模板还长;正例:把"数据获取 + 加载态 + 错误态"收敛到一个组合式函数,视图只负责渲染。

二、七种通信方式

方式方向适用
props父 → 子明确的输入
emit子 → 父请求父级变更
v-model双向语法糖表单类组件
provide/inject跨层级向下主题、配置、上下文
slots父 → 子内容结构可变
attrs透传包装原生元素/第三方组件
全局状态任意跨页面共享、登录态、购物车

选择顺序:能用 props/emit 就不用 provide,能用 provide 就不用全局状态。越靠前的方案,依赖越清晰。

三、状态放在哪

1. 只在一个组件用 → 放组件内
2. 兄弟组件共享 → 提升到共同父级
3. 跨页面/跨层级 → 全局状态(Pinia)
4. 只读配置 → provide/inject

判断标准不是"方便",而是谁拥有这个状态的生命周期。登录态属于应用,放全局;弹窗开关属于页面,放页面;输入框内容属于表单,放表单。

四、v-model 的本质

<Child v-model="value" />
<!-- 等价于 -->
<Child :model-value="value" @update:model-value="value = $event" />

组件内用 defineModel()(Vue 3.4+)声明,既拿到值,也拿到更新事件;3.4 之前的项目仍要写 props + update:modelValue 事件。多个 v-model 用参数区分:v-model:title、v-model:visible。

五、受控与非受控

  • 受控:值由父组件决定,子组件只发事件。适合需要校验、联动、持久化的场景。
  • 非受控:子组件内部维护状态,父组件只在必要时读取。适合简单输入、性能敏感场景。

混合用法最容易出 bug:父组件传了初始值却不再更新,子组件以为自己是受控的。要么完全受控,要么明确"仅初始值"语义。

六、过度拆分与巨型组件

问题表现修法
过度拆分props 层层透传、文件跳转频繁合并同一职责的碎片
巨型组件500 行以上、多个不相关状态按职责拆,先抽逻辑再抽视图

拆分的收益是"可独立理解和测试",不是"文件数量"。如果一个组件拆出去后仍要读父组件的五个状态,说明边界划错了。

七、组合式函数:逻辑复用的正确姿势

组件复用视图,组合式函数复用逻辑。一个好的组合式函数应该:

  • 只依赖入参,不隐式读取全局状态。
  • 返回 ref 或方法,让调用方决定怎么用。
  • 自己清理副作用(定时器、监听、请求取消)。
export function useMouse() {
  const x = ref(0),
    y = ref(0);
  const onMove = (e) => {
    x.value = e.clientX;
    y.value = e.clientY;
  };
  onMounted(() => window.addEventListener("mousemove", onMove));
  onUnmounted(() => window.removeEventListener("mousemove", onMove));
  return { x, y };
}

如果组合式函数里出现“读取当前路由”“读取登录用户”这类隐式依赖,它就不再可复用,应该把依赖作为参数传进来。组合式函数尽量返回对象而不是数组,调用方按名取值;需要解构时用 toRefs,避免丢失响应式。

八、一个拆分案例

一个商品详情页原本有 600 行,包含:图片轮播、SKU 选择、价格计算、库存提示、加购、评价列表。按职责拆成:

部分职责状态归属
Gallery只渲染图片与切换当前索引(局部)
SkuPicker选择规格选中 SKU(页面)
PricePanel价格与库存派生自选中 SKU
AddToCart提交动作无状态
Reviews列表与分页请求状态(局部)

拆分后,价格计算变成纯派生逻辑,库存提示只依赖选中 SKU,评价列表的请求状态不再污染页面。判断拆分是否成功:每个组件能否在不看父组件的情况下说清自己的输入与输出。 另一个信号:如果拆出的组件要向外抛 5 个以上事件,说明它承载了过多职责,应该提升状态或改用组合式函数。

反模式问题替代
prop 传回调改父状态数据流反向、难追踪emit
provide 传可变对象子组件随意修改provide 方法
全局 store 放弹窗开关生命周期错位组件内状态

边界的本质是“变更的影响范围”:改一个需求时,需要同时改几个文件,就说明边界切错了。组件树的深度不是目标,可理解性才是;每次拆分都要问:它能否独立测试?边界清晰的项目,改一个需求只动一两个文件;边界模糊的项目,改一处要读十个文件。边界清楚,协作才顺。

九、常见坑

  1. 所有共享状态都塞进 Pinia,组件失去内聚性。
  2. 用 provide 传可变状态,却不提供修改方法,子组件直接改注入值。
  3. v-model 与 props 同时声明,出现两个数据源。
  4. 为了透传属性层层写 props,忘了 $attrs。
  5. 组件拆分后 props 超过 7 个,说明职责没有收敛。

十、动手任务:自写 → AI 写 → 对比 → 取舍

四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。

① 自己写

  1. 画出你项目里一个页面的组件树,标出每个状态放在哪。
  2. 找出一个 props 超过 7 个的组件,尝试合并或改用插槽。
  3. 把一个跨三层传值的 props 改成 provide/inject。

② 让 AI 写同一题

把第 2 题(跨层传值)交给 AI,补一句 先判断该用 props 还是 provide/inject,别默认上 Pinia。

③ 对比要盯的点

  • 两层就能解决的地方它上没上 provide/inject?
  • emit 的事件名它写成 camelCase 了吗?模板监听时是 kebab-case 才稳。
  • 它有没有把“共享状态”做成两个组件各存一份?这是最难查的一类 bug。

④ 一句话取舍

它给的方案,在组件层级再加两层之后还成立吗?

十一、自测

  1. 组件边界的三个维度是什么?
  2. 七种通信方式的优先级顺序是什么?
  3. 状态放置的判断标准是什么?
  4. v-model 编译后等价于什么?
  5. 受控与非受控混用会出什么问题?

参考答案要点:职责/复用/变更频率;props→emit→provide→全局;谁拥有生命周期;值与更新事件;数据源不唯一。

配套题目

★ 越多越该先背;点题目可看完整答案与深入版。

提示:读完可以顺手做几道对应题目,检验是否真的理解