模块一 · 起步与基础
组件边界:通信方式与状态该放在哪
组件拆分不是"越小越好",而是按职责与变更频率划边界。这一节给出七种通信方式的适用场景,以及状态放置的判断顺序。
读完这一节,你能做到
- 用职责、复用、变更频率三个维度判断组件边界。
- 在七种通信方式中选对一种,而不是全部用全局状态。
- 判断一个状态该放组件内、父级还是全局。
- 识别"过度拆分"和"巨型组件"两种极端。
一、边界的三个判断维度
| 维度 | 问题 | 结论 |
|---|---|---|
| 职责 | 它是否只做一件事 | 多个职责就拆 |
| 复用 | 是否会在别处出现 | 会复用就抽组件 |
| 变更频率 | 是否与周围同时变化 | 变化节奏不同就隔开 |
反例:为了"看起来组件化"把一行文字抽成组件,结果 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 放弹窗开关 | 生命周期错位 | 组件内状态 |
边界的本质是“变更的影响范围”:改一个需求时,需要同时改几个文件,就说明边界切错了。组件树的深度不是目标,可理解性才是;每次拆分都要问:它能否独立测试?边界清晰的项目,改一个需求只动一两个文件;边界模糊的项目,改一处要读十个文件。边界清楚,协作才顺。
九、常见坑
- 所有共享状态都塞进 Pinia,组件失去内聚性。
- 用 provide 传可变状态,却不提供修改方法,子组件直接改注入值。
- v-model 与 props 同时声明,出现两个数据源。
- 为了透传属性层层写 props,忘了
$attrs。 - 组件拆分后 props 超过 7 个,说明职责没有收敛。
十、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 画出你项目里一个页面的组件树,标出每个状态放在哪。
- 找出一个 props 超过 7 个的组件,尝试合并或改用插槽。
- 把一个跨三层传值的 props 改成 provide/inject。
② 让 AI 写同一题
把第 2 题(跨层传值)交给 AI,补一句 先判断该用 props 还是 provide/inject,别默认上 Pinia。
③ 对比要盯的点
- 两层就能解决的地方它上没上 provide/inject?
- emit 的事件名它写成 camelCase 了吗?模板监听时是 kebab-case 才稳。
- 它有没有把“共享状态”做成两个组件各存一份?这是最难查的一类 bug。
④ 一句话取舍
它给的方案,在组件层级再加两层之后还成立吗?
十一、自测
- 组件边界的三个维度是什么?
- 七种通信方式的优先级顺序是什么?
- 状态放置的判断标准是什么?
v-model编译后等价于什么?- 受控与非受控混用会出什么问题?
参考答案要点:职责/复用/变更频率;props→emit→provide→全局;谁拥有生命周期;值与更新事件;数据源不唯一。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
提示:读完可以顺手做几道对应题目,检验是否真的理解