Vue 单文件组件的职责边界:什么时候该拆、请求该放哪、样式怎么划
一个 .vue 文件该拆成几个组件,没有一个字数或行数的硬指标能回答。判断的标准不是"它长不长",而是"改一处会不会带出你没预料到的副作用"。这是我这半年反复回到的一个问题——团队里那个用户资料页组件,从几十行的雏形长到七百多行,中间每一次改动单独看都合理,但整体的可维护性是一路下滑的。
资料页最初只是渲染一段用户信息加一个编辑按钮。后来加了头像上传弹窗,加了实名认证的表单校验,加了地址管理的列表和增删改,加了权限判断(哪些字段普通用户能改、哪些字段只有客服后台能改),还有一大段专门用来覆盖 Element UI 表单组件默认样式的 CSS。单独拿出每一次改动,都是"页面多了个需求,顺手加进去",没有哪一步是明显的错误决策。但七百行堆在一起之后,改一个按钮的文案都要战战兢兢——你不知道这个按钮的 v-if 条件是不是被另一段权限逻辑复用了,也不知道这段 CSS 改了会不会影响到页面里另一个用了同一个组件库控件的地方。
会不会写 .vue 文件不是差距所在,需求持续增长之后组件还能不能保持"改一处只影响一处"的状态,才是。
判断该不该拆的标准
.vue 文件本身只是个容器,语法层面允许你把任何东西塞进去,所以文件后缀不能替你做决策。判断要落到组件承担了几类职责上。资料页这个组件在变大之前,同时在做数据请求、状态处理(loading / 权限 / 表单校验状态)、页面布局、表单逻辑、弹窗控制这五件事——只要一个组件里这五类职责同时存在且互相牵扯,无论文件多长,它都会变得难改。
拆分的时机不是等到行数超过某个数字才动手,而是当你发现某个职责已经能独立描述、独立测试的时候。资料页里"头像上传"这块逻辑,输入是一个文件对象,输出是上传后的 URL 或者错误信息,跟页面其他部分几乎没有数据耦合——这种职责一旦能用一两句话讲清楚输入输出,就该独立成组件,不用等它膨胀到不可收拾。反过来,如果某段逻辑还在跟别的状态频繁互相读写,勉强拆出去只会多一层 props 和事件的搬运工作,暂时留在原地也没问题。
行数只是一个滞后的信号,不是判断依据本身。团队里有过一次争论,一位资深前端主张给组件设一条硬性红线——超过 300 行就必须拆,我起初觉得这条规则简单粗暴但至少可执行,真按这个标准去量资料页早期的版本才发现不对:一个 280 行、只做了复杂表单校验展示的组件,职责单一,改动只影响自身,没必要拆;反倒是资料页 200 行出头的时候,请求、权限判断、弹窗控制已经绞在一起,行数还没到红线,可维护性已经在下滑。后来我们把这条规则改成了"职责清单"式的检查——写组件的时候顺手列一下这个文件对外承担了几件事,列表超过三项,无论行数多少都该考虑拆分。这个检查比数行数麻烦一点,但准确得多。
还有一种常见的误判是把"拆得细"当成"拆得好"。资料页刚开始治理的时候,有同事提议把 ProfileHeader 里的头像图片和昵称文字进一步拆成 Avatar.vue 和 Nickname.vue 两个组件,理由是"职责要单一到极致"。这个方向看起来没错,实际做下去发现两个新组件除了传几个 props、渲染几个标签之外没有任何独立存在的理由——它们既不会被复用,也没有独立的状态和逻辑,纯粹是把同一段模板机械地剁碎,多出来的是组件树深度和 props 透传的成本,可读性反而下降。拆分要服务于"独立复用"或者"独立测试"这两个目的中至少一个,单纯为了让每个文件更短而拆,是本末倒置。
按职责分层,页面组件不背所有细节
组件按职责大致能分四层。页面组件负责拉数据、组织布局、协调子组件,它更像一个编排者,不是所有逻辑的容器;业务组件负责一个明确的业务块,比如资料页里的"用户信息卡片""地址列表";基础组件负责稳定的 UI 能力,按钮、输入框、弹窗这一类,不该掺杂任何业务判断;状态组件专门处理 loading、empty、error 这种通用展示,跟具体业务无关。
资料页拆完之后,页面组件的模板能保持这样的干净程度:
1<template> 2 <section class="profile-page"> 3 <StateView :loading="loading" :error="errorMessage" :empty="!profile"> 4 <ProfileHeader :profile="profile" /> 5 <ProfileForm 6 :value="form" 7 :errors="errors" 8 @submit="submitProfile" 9 /> 10 <AddressList :addresses="profile.addresses" /> 11 </StateView> 12 </section> 13</template>
读这段模板的时候,第一眼就能看懂页面由哪几块组成,不需要先扫一遍几十个字段和 v-if 分支才能建立起结构感。原来那个七百行版本最大的问题就在这里——ProfileHeader、地址管理、权限判断全部摊平在同一层模板里,想看清页面结构得先在脑子里把无关代码过滤掉。
四层划分不是绝对的教条,模糊地带需要具体判断。ProfileHeader 算业务组件还是基础组件,团队内部就有过分歧——它渲染头像、昵称、认证状态图标,看起来像纯展示,但认证状态图标的显隐逻辑跟用户的实名认证业务状态强绑定,一旦脱离"用户资料"这个业务语境就没有意义,所以它是业务组件,不该往基础组件库里放。判断标准落在一点上:这个组件的 props 和展示逻辑,脱离当前这个业务领域还能不能被理解。StateView 只关心 loading、error、empty 三个布尔或字符串状态,不关心这些状态从哪来、代表什么业务含义,脱离资料页放到订单页、放到任何页面都能直接用,这才是真正的状态组件。
props 的校验在这一层治理里起的作用比想象中大。资料页早期 ProfileForm 的 value prop 没有写类型校验,有一次页面组件那边把 form 初始化成了 undefined(异步请求还没返回时的默认值),ProfileForm 内部直接解构 value.nickname 直接报错,页面在加载状态下白屏。补上校验之后这类问题在开发阶段就会被 Vue 的控制台警告拦下来:
1props: { 2 value: { 3 type: Object, 4 required: true, 5 default: () => ({ nickname: '', avatarUrl: '', addresses: [] }), 6 }, 7 errors: { 8 type: Object, 9 default: () => ({}), 10 }, 11 maxAddressCount: { 12 type: Number, 13 default: 5, 14 validator(value) { 15 return value > 0 && value <= 20; 16 }, 17 }, 18},
validator 这个选项当时团队里用得还不多,那位资深前端一开始觉得多此一举——"传错了运行时自然会报错,何必多写一层"。但资料页地址列表这块正好踩过一次坑:后端返一次性把地址上限从 5 改成了 50,前端页面组件那边给 maxAddressCount 传了个从配置读出来、没做范围检查的值,如果不是子组件里的 validator 在开发环境把这个异常值挡在控制台警告里,这个问题只有等到测试环境点开地址页才会被发现。type、required、default、validator 四项放在一起用,相当于把组件的输入契约写在了代码里,而不是写在文档或者靠口头约定,对大组件治理来说,这层契约越明确,日后改动出错的概率越低。
目录结构要表达业务归属,不是收纳箱
目录结构如果只按组件类型分(所有 .vue 丢进一个 components 文件夹),文件多了以后完全找不到东西属于哪个页面。比较稳的做法是按功能域组织:
1src/ 2 views/ 3 profile/ 4 ProfilePage.vue 5 components/ 6 ProfileHeader.vue 7 ProfileForm.vue 8 AddressList.vue 9 services/ 10 profileService.js 11 utils/ 12 normalizeProfile.js
资料页专属的子组件放在 profile 目录下的 components 里,只有真正跨页面复用的组件——比如一个通用的地址选择器,其他页面(订单详情、收货地址管理)也在用——才上移到全局的 components/ 目录。这个顺序不能反过来:不要在组件第一次写出来的时候就往全局目录里放,先让它待在业务附近,等真的出现第二个调用方、复用范围稳定下来,再考虑上移。过早全局化的后果是,改一个只在资料页用得上的细节,还要担心会不会影响到别的页面,等于人为制造了本不存在的耦合。
utils/normalizeProfile.js 这个文件专门放数据格式转换的纯函数,比如把后端返回的 is_verified: 0/1 转换成组件内部用的布尔值 isVerified,把 addr_list 这种后端字段名转换成前端约定的驼峰命名。这类转换逻辑一开始是直接写在 fetchProfile 里的,后来发现地址列表页也需要同样的字段转换,才把它单独抽成 utils 下的纯函数——纯函数不依赖任何组件状态,测试起来只需要给定输入断言输出,是这套目录结构里最容易达成"独立测试"标准的一层。
有个容易忽略的细节是 services 和 utils 该怎么分工。profileService.js 里的函数天然带副作用(发请求、读缓存),normalizeProfile.js 里的函数必须是纯的,不做任何 I/O。早期有一版把字段转换逻辑写在了 profileService.js 内部,跟请求代码搅在一起,直接的后果是想单独测试字段转换规则,也得连带 mock 一遍 fetch。把两类逻辑分开之后,normalizeProfile 的单元测试不需要任何 mock,profileService 的测试只需要 mock 网络层,两边互不拖累。
请求逻辑下沉到服务层
资料页最初的请求是直接写在组件的 created 钩子里的,fetch 调用、错误处理、字段转换全部堆在一起。这样写的问题不是"不能跑",是这段逻辑没法脱离这个组件被复用或者被单独测试。请求应该拆到独立的服务层文件:
1// services/profileService.js 2export async function fetchProfile() { 3 try { 4 const response = await fetch('/api/profile'); 5 6 if (!response.ok) { 7 return { ok: false, message: '用户资料加载失败,请稍后重试' }; 8 } 9 10 return { ok: true, data: await response.json() }; 11 } catch (error) { 12 return { ok: false, message: '网络异常,请检查连接' }; 13 } 14}
组件里只处理结果,不关心请求细节:
1import { fetchProfile } from './services/profileService'; 2 3export default { 4 data() { 5 return { 6 loading: false, 7 profile: null, 8 errorMessage: '', 9 }; 10 }, 11 async created() { 12 this.loading = true; 13 const result = await fetchProfile(); 14 this.loading = false; 15 16 if (!result.ok) { 17 this.errorMessage = result.message; 18 return; 19 } 20 21 this.profile = result.data; 22 }, 23};
统一的返回结构({ ok, data } 或者 { ok, message })能避免每个页面自己写一套 try...catch 和错误文案,组件只需要判断 ok 是真是假,专注于状态呈现本身。这一层拆出去之后还有个附带的好处:fetchProfile 可以脱离 Vue 组件单独写单元测试,不需要挂载整个组件、模拟 DOM,只测函数的输入输出就够了。这也是判断拆分粒度的一个实际标准——一段逻辑如果拆出去之后能被单独测试,通常就该拆;如果拆出去还是得依赖组件的生命周期或者其他状态才能验证,说明这一刀切得不对。
服务层这套东西刚提出来的时候,也有人觉得是多此一举——"不就是把 fetch 挪个位置"。挪位置确实简单,但服务层真正解决的是另一个问题:同一份数据在多个地方需要用同一套获取和处理逻辑时,不下沉就必然重复。资料页上线不久,个人中心的侧边栏也要展示用户昵称和头像,如果继续把请求写在组件里,侧边栏组件要么整个复制一遍 fetchProfile 的逻辑,要么绕个圈子去 import 资料页的组件再抠出数据——两种做法都别扭。有了 profileService.js,侧边栏直接 import { fetchProfile } from '@/views/profile/services/profileService' 就能拿到同样格式的数据,不需要关心资料页组件内部长什么样。
服务层里还有一类容易被忽视的职责是"请求去重和串联"。资料页有个真实的坑:编辑弹窗打开时会同时触发一次资料请求和一次地址列表请求,两个请求原本各自独立发起,网络慢的时候地址列表偶尔会先于用户基本信息返回,导致页面短暂展示"地址已加载、用户名还是空"的错位状态。这类时序问题放在组件里很难看清楚,挪到服务层用一个组合函数处理就直观得多:
1// services/profileService.js 2export async function fetchProfileWithAddresses() { 3 const [profileResult, addressResult] = await Promise.all([ 4 fetchProfile(), 5 fetchAddresses(), 6 ]); 7 8 if (!profileResult.ok) { 9 return profileResult; 10 } 11 12 return { 13 ok: true, 14 data: { 15 ...profileResult.data, 16 addresses: addressResult.ok ? addressResult.data : [], 17 }, 18 }; 19}
组件只调用一个 fetchProfileWithAddresses,不需要在 created 钩子里手写 Promise.all 和两份结果的合并逻辑,这类编排放在服务层还有个好处——地址请求失败不影响主资料的展示,这条降级规则写在服务层比写在组件里更容易被测试覆盖到,因为测试只需要 mock 两个底层请求各自的返回值,断言合并结果,不需要关心组件的渲染细节。
模板不承担业务推导
模板负责展示结构,不适合堆复杂表达式。资料页里最初有一处用户昵称的展示逻辑写在模板里:
1<p> 2 {{ user.nickname || user.name || user.email || '未命名用户' }} 3</p>
这类多层兜底判断放在模板里,读的时候没法一眼看出它在做什么决策,稍微改一下顺序还容易改错。挪到 computed 里会清楚很多:
1computed: { 2 displayName() { 3 const { nickname, name, email } = this.user; 4 5 return nickname || name || email || '未命名用户'; 6 }, 7},
模板越干净,组件的结构就越容易被看懂。复杂条件、格式化、派生状态都应该从模板里挪出去,模板里保留的应该只有"渲染什么",不该出现"怎么算出要渲染的东西"。
这条规则有个看起来对、实际有坑的反例,是把复杂判断从模板挪进了 methods 而不是 computed。资料页的地址列表有一段"根据地址数量判断是否显示新增按钮"的逻辑,有个版本这样写:
1<template> 2 <button v-if="shouldShowAddButton()">新增地址</button> 3</template> 4 5<script> 6export default { 7 methods: { 8 shouldShowAddButton() { 9 return this.profile.addresses.length < this.maxAddressCount; 10 }, 11 }, 12}; 13</script>
单看这段代码,判断逻辑确实挪出了模板,读起来也没毛病。问题在于 methods 里的函数每次组件重新渲染都会重新执行一遍,而 Vue 的响应式系统对 computed 有缓存——只有依赖的响应式数据变化时才重新求值。地址列表这个页面还挂了一个跟地址无关的定时器每隔几秒刷新在线状态,一旦这类跟 shouldShowAddButton 毫不相关的数据变化触发组件重新渲染,methods 里的这个判断也会被无谓地重新执行一次。数据量小的时候看不出差别,但如果类似的判断散落在模板的好几个地方、又都用 methods 而不是 computed,页面在数据频繁更新的场景下会有一些不必要的重复计算。挪对位置的版本是这样:
1computed: { 2 canAddAddress() { 3 return this.profile.addresses.length < this.maxAddressCount; 4 }, 5},
判断一段逻辑该放 computed 还是 methods,标准很直接——只依赖响应式数据、没有副作用、返回值用于渲染的,放 computed;需要接收参数、或者本身会触发副作用(比如提交表单、发请求)的,放 methods。这两者常被简单地归为"都是挪出模板就行",但对渲染性能和代码语义来说并不等价。
样式该归到哪一层
<style scoped> 能降低样式串扰,但它解决的是"样式会不会泄漏到别的组件",不解决"这段样式该写在哪一层"的问题。资料页那段专门覆盖 Element UI 表单默认样式的 CSS,问题不在于用没用 scoped,而在于它越过了组件本该管的那一层——用深度选择器改第三方组件内部结构,这类样式一旦散落在多个页面组件里,任何一次 Element UI 升级都可能让好几处样式同时失效,而且没人能一眼看出这些覆盖分散在哪里。
比较稳的原则是:组件根节点有稳定的类名,子元素类名表达结构而不是表达样式意图,页面布局相关的样式留在页面组件层,基础组件只处理自身的尺寸和状态,不轻易用深度选择器改第三方组件内部结构。
1.profile-page {} 2.profile-page__header {} 3.profile-page__content {}
当某段样式开始需要跨越好几层组件去影响内部元素的时候,通常说明组件的职责范围或者设计系统的分工该重新看一遍了——要么这个第三方组件库本身缺一个可配置的插槽或 prop,要么团队该整理一套统一的表单样式规范,而不是每个页面各自维护一份覆盖样式。
深度选择器本身在 Vue 2.6 的 scoped 样式里也有两种写法混用的问题。资料页早期同时出现过 >>> 和 /deep/ 两种深度选择器,>>> 在部分 CSS 预处理器(当时团队用的是 Sass)里编译不通过,只能退而求其次全用 /deep/,后来 Vue 官方在文档里推荐统一用 ::v-deep,团队才把这几处混用的写法收敛成一种。三种写法效果等价,但混用会导致全局搜索"这里改了 Element UI 的哪个内部样式"变得困难——搜 /deep/ 搜不出用 ::v-deep 写的那几处。统一写法这件事本身工作量不大,但如果不是治理大组件顺带做了一次全仓库搜索,这类遗留写法很容易被忽略很久。
样式的管辖范围还有一层容易被漏掉的东西是 scoped 对动态生成的类名不生效。资料页有个地址标签是用 render 函数动态拼接 DOM 结构生成的(当时是为了兼容一段老代码),这部分内容不会被 scoped 自动加上组件的属性选择器,样式写在 <style scoped> 里对这部分完全不起作用,只能老老实实用一个全局类名去命中,这也是为什么"组件根节点有稳定类名"这条原则里,稳定的类名不能只依赖 scoped 机制本身,遇到动态生成的结构还是要手动维护类名的唯一性。
大组件的治理顺序
已经变大的 .vue 文件不建议一次性重写,风险太集中。资料页从七百行拆回去用的是这样一个顺序:先把请求和格式化逻辑移到独立文件,这一步不涉及模板和交互,风险最低;再把模板里明显独立的区域拆成子组件,比如"地址列表"这块跟其他区域几乎没有数据交叉,拆出去基本不会影响别处;然后处理弹窗、表单这类交互块,这一步开始涉及事件和状态的传递,需要多留意 props 和事件命名是不是清楚;最后再收敛样式和状态命名,这时候各部分该归哪已经清楚了,样式归位也就顺理成章。每一步都要保持行为不变,一边重构一边改业务需求是治理大组件最容易失控的地方,两件事混在一起做,出问题了都分不清是重构带来的还是新需求带来的。
这四步顺序背后其实是一条更通用的原则——先拆没有副作用、容易验证对错的部分,再拆有副作用、需要人工判断对错的部分。请求和格式化逻辑拆出去之后,只要给定同样的输入能得到同样的输出,就能确定这一步没有破坏行为,适合写单元测试兜底;表单和弹窗这类交互涉及用户操作路径,没法完全靠自动化测试覆盖,只能靠手动跑一遍关键流程(打开弹窗、填写、提交、取消)来确认没有回归,风险天然更高,所以放在后面,这样出问题时排查范围更小。
治理过程中还有一个具体的操作习惯值得记录:每完成一步就提交一次代码,commit message 只写这一步做了什么("提取 profileService,行为不变"),不要把四步合并成一次大的提交。资料页拆到第三步(弹窗表单)的时候确实出过一次回归——地址编辑弹窗的取消按钮在拆分后没有正确重置表单的校验状态,因为提交记录是分步的,很快定位到是哪一次改动引入的问题,如果是一次性重写再整体测试,出错范围会大得多,排查成本也高得多。
治理顺序里还有一个容易被忽略的环节,是给拆出去的每个子组件补一份最基础的单元测试,而不是等治理全部完成再统一补。地址列表这个子组件拆出来之后,团队用 Vue Test Utils 给它加了几个断言:传入空数组时渲染"暂无地址"提示,传入超过 maxAddressCount 的数据时新增按钮应该被禁用。这几条测试成本不高,但在后续继续给地址列表加分页功能的时候,起到了兜底作用——分页逻辑改动不小心把"超过上限禁用新增按钮"这条判断覆盖掉了,测试当场就红了。
和 mixin 的取舍
Vue 2 项目里另一个常见的复用手段是 mixin,资料页早期也短暂用过一个 formValidationMixin 来复用几个页面共享的校验逻辑。mixin 能省事,但它把逻辑混入组件的方式是隐式的——打开资料页组件的 data 和 methods,看不出哪些字段和方法来自 mixin,哪些是组件自己的,如果两个 mixin 之间还有同名字段冲突,覆盖关系也不直观。服务层加职责拆分的方案换了个思路:显式 import,数据流从函数参数和返回值走,不依赖 this 的隐式合并。这不是说 mixin 该被完全放弃,跨组件共享一些跟状态无关的纯行为(比如统一的埋点上报)用 mixin 依然方便,只是涉及数据请求和业务状态的逻辑,服务层这套显式的组织方式更容易追踪。
formValidationMixin 当时的具体写法是这样的:
1// mixins/formValidationMixin.js 2export default { 3 data() { 4 return { 5 errors: {}, 6 }; 7 }, 8 methods: { 9 validate(rules) { 10 const errors = {}; 11 12 Object.keys(rules).forEach((field) => { 13 const rule = rules[field]; 14 const value = this[field]; 15 16 if (rule.required && !value) { 17 errors[field] = `${rule.label}不能为空`; 18 } 19 }); 20 21 this.errors = errors; 22 return Object.keys(errors).length === 0; 23 }, 24 }, 25};
资料页和另一个"实名认证"表单页都混入了这个 mixin,两边各自还有自己的 errors 相关命名,认证表单那边原本已经有一个自己的 data 字段叫 errors 用来存服务端返回的字段级错误,混入 mixin 之后两个 errors 悄悄合并成了一个,服务端错误信息在某次校验触发后被前端校验的结果覆盖掉,页面上该显示"身份证号格式错误"变成了空白。这类问题在组件内部找起来格外费劲,因为报错的那一行代码看起来完全没问题,根源在于两处不同来源的同名字段合并方式是隐式的,不打开 mixin 的定义根本想不到。
这个坑之后,团队对 mixin 的使用定了一条具体约束:mixin 里出现的 data 字段名要加前缀(比如这里改成 validationErrors),降低和宿主组件命名冲突的概率;涉及请求、业务状态判断这类跟具体页面强相关的逻辑,一律走服务层加显式函数调用的方式,不再用 mixin。Vue 2.6 版本里官方也没有更好的替代方案——@vue/composition-api 这个官方兼容插件上个月才发布,团队还在评估阶段没有铺开用,provide/inject 又只适合另一类问题,所以 mixin 目前还留着,只是把它的适用范围收窄到跟业务状态无关的纯行为复用上。
跨层级通信:provide / inject 能解决什么,不能解决什么
$emit 加 props 这套机制在父子、兄弟组件之间很够用,但遇到跨好几层的祖先-后代关系,比如资料页的编辑弹窗内部有三层嵌套的表单区块,最内层的一个输入框想要知道最外层弹窗的"只读模式"开关,如果继续用 props 一层层往下传,中间两层组件本身根本不需要这个开关,却要在自己的 props 声明里加一行、在模板里多写一次透传,纯粹是为了把值送到更深的地方。
Vue 2.6 里的 provide/inject 是为这种场景准备的:
1// EditProfileDialog.vue(祖先组件) 2export default { 3 provide() { 4 return { 5 readonly: this.readonly, 6 }; 7 }, 8 props: { 9 readonly: { 10 type: Boolean, 11 default: false, 12 }, 13 }, 14};
1// 深层的某个输入框组件 2export default { 3 inject: ['readonly'], 4};
这样写省掉了中间组件的透传代码,但它带来的问题也很具体:provide 里直接返回 this.readonly 传的是一个值的快照,如果 readonly 后续在弹窗组件里发生了变化(比如弹窗支持了"编辑模式和只读模式来回切换"这个新需求),深层的 inject 拿到的还是打开弹窗那一刻的值,不会跟着响应式更新。资料页真的加了这个切换功能之后就踩了这个坑——切换到编辑模式,最深层的输入框仍然显示只读样式。要让它保持响应式,得把 provide 的值包成一个 computed 或者传一个响应式对象的引用:
1provide() { 2 return { 3 profileEditContext: this.editContext, // this.editContext 是 data 里的一个响应式对象 4 }; 5},
深层组件通过 this.profileEditContext.readonly 读取,因为对象引用不变、内部属性是响应式的,这样才能跟着源头变化。这也是 provide/inject 在 Vue 2.6 阶段的一个实际局限——它更适合传递那些创建后基本不变的"上下文"(主题配置、只读模式、当前语言),不适合用来传递需要频繁双向流动的业务数据,真遇到后者,要么老实走 props 加事件多传几层,要么该考虑引入一个真正的状态管理方案。团队目前的界定是:跨两层以内用 props,跨三层以上但值基本不变用 provide/inject,跨层级且值频繁变化,才去动事件总线或者 store。
通信方式对组织方式的影响
组件怎么拆,很大程度上取决于组件之间打算怎么通信。资料页的 ProfileForm 和 AddressList 是兄弟组件,中间没有直接通信需求,各自通过 props 接收数据、通过事件上抛变更,页面组件当协调者——这种父子加事件的方式在层级浅的时候很清楚。但如果地址管理这块需要被页面里三四层之外的另一个组件感知到(比如全局的收货地址数量要在页面顶部的导航栏实时更新),继续一层层 $emit 往上传就会让中间每一层都要写一遍转发代码,这时候引入一个简单的事件总线,或者把这部分状态提到一个专门的 store 模块里管理,会比死守"只用 props 和事件"更合适。判断标准还是回到组件树的形状:通信路径短、层级浅,props 加事件足够;一旦出现跨层级、跨页面的状态共享需求,组织方式就要跟着调整,不能为了"保持纯粹"而硬套一种通信方式。
父子之间最常见的双向绑定场景,团队里习惯直接用 v-model,但 v-model 在自定义组件上到底传递了什么,很多人只记住了"跟表单元素类似",没细想过它背后其实是 value prop 加 input 事件的语法糖。ProfileForm 早期是这样对外暴露的:
1<ProfileForm v-model="form" :errors="errors" @submit="submitProfile" />
1// ProfileForm.vue 2export default { 3 props: { 4 value: { 5 type: Object, 6 required: true, 7 }, 8 }, 9 methods: { 10 updateField(field, val) { 11 this.$emit('input', { ...this.value, [field]: val }); 12 }, 13 }, 14};
这套写法能跑,但它把整个 form 对象作为一个整体通过 input 事件抛出去,任何一个字段变化都要重新构造一份完整的对象。资料页后来遇到的具体问题是:ProfileForm 内部有一个头像上传的异步操作,上传过程中用户可能还在改昵称,两次 updateField 调用间隔很短,父组件那边基于 this.value 展开合并的操作如果不是严格按事件触发顺序处理,容易把其中一次改动覆盖掉——尤其是当父组件自己也在监听 input 事件做一些额外处理、内部再次访问旧的 this.form 时更容易出这类错序问题。
Vue 2.6 之后支持了自定义 v-model 的 model 选项,可以把默认绑定的 prop 和事件名换掉,这本身解决不了上面这个数据整体打包传递的问题,但可以用来避免另一类命名混用——如果 ProfileForm 同时还需要一个跟 v-model 无关的 value-like 展示属性,默认约定的 value/input 就会造成语义混淆:
1export default { 2 model: { 3 prop: 'formData', 4 event: 'change', 5 }, 6 props: { 7 formData: { 8 type: Object, 9 required: true, 10 }, 11 }, 12};
这样 v-model 实际绑定的是 formData 和 change 事件,value 这个名字就能空出来给别的用途。资料页最终没有走这条路,而是放弃了给整个表单对象套 v-model,改成给每个字段单独维护绑定和事件,颗粒度更细,出问题时也更容易定位是哪个字段的更新顺序出了问题。这里的教训是:v-model 用在自定义组件上很方便,但它掩盖了一个事实——这本质上还是 props 加事件,一旦对象整体传递带来的更新时序问题开始出现,还是得回到"精确到字段级别的通信"这条更笨但更可控的路上,不能因为语法糖好用就无视它背后数据流的粒度。
组件通信方式的选择,最终还是要回到组件树的形状和数据更新的频率上判断,没有一种方式能通吃所有场景——props 加事件够用的地方硬上 store 是过度设计,反过来该用 store 集中管理的状态硬靠事件层层转发,是给自己找麻烦。
判断组件是否稳定的几个问题
判断一个 SFC 组织得好不好,可以看它能不能快速回答几个问题:这个组件负责什么、数据从哪里来、状态在哪里变、样式影响到哪里、哪些部分可以独立测试和复用。资料页现在拆完之后,ProfilePage.vue 只剩下编排逻辑,profileService.js 单独跑单元测试不需要挂载任何组件,样式按页面和组件分层之后也不再需要满仓库搜索"这段 CSS 是谁写的"。
判断组件是否稳定,还可以加一条实际的检验方式:给这个组件写一份 Vue Test Utils 的测试用例,看写起来顺不顺手。如果一个组件的测试需要 mock 掉五六个内部依赖、还要模拟好几层生命周期钩子才能跑起来,通常说明它该管的事没有理清楚;如果 shallowMount 传几个 props、stub 掉子组件就能验证核心逻辑,说明这个组件的输入输出足够清楚。资料页的 ProfileHeader 现在的测试只需要传一个 profile 对象断言渲染结果,不需要涉及网络请求、不需要挂载子组件树,这种"测试写起来省事"本身就是这个组件职责划得对的一个信号。
组件写久了以后,格式规范写得多细反而不是关键,关键是每次需求增长时,有没有把新逻辑放进了它该在的那一层。