ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Vue组件化开发实战:从粒度划分到性能优化

Vue组件化开发实战:从粒度划分到性能优化 我们团队半年前接手了一个中后台项目第一版急着上线组件基本是页面拆分法——一个路由对应一个.vue文件公共部分全靠复制粘贴。结果二期需求一来改动点散落在十几个文件里一个按钮的交互调整要翻三个组件改完A处炸了B处。痛定思痛之后我把整个项目的组件梳理重构了一遍这中间踩了不少坑也想明白了很多事。这篇文章不打算讲Vue的基础语法而是聚焦组件化开发这件事本身边界怎么划、数据怎么传、复用怎么做、性能怎么顾以及我实际项目里遇到的一些典型问题的完整排查过程。1. 组件粒度的划分逻辑别把拆文件当成组件化很多初学者理解的组件化就是把一个页面拆成好几个.vue文件这个理解不算错但远远不够。我在代码评审里见过一个非常典型的问题有人把一个表单页拆成了FormHeader.vue、FormBody.vue、FormFooter.vue看起来结构清晰但实际上三个组件之间用$emit和props传了几十个字段父组件里塞满了各种回调函数改一个字段校验规则要在三个文件里来回跳。这不是组件化这是把一个大文件拆成了三个互相依赖的小文件复杂度一点没降反而因为通信成本升高了。1.1 拆分的本质是封装变化不是切分页面组件拆分的真正目的是把会一起变化的逻辑和视图收拢在一起把不会一起变化的逻辑隔离开来。我倾向于用三个问题来判断一个组件是否需要拆分这段代码是否会在多个地方复用这段代码是否只跟某一块独立的业务强相关跟外层页面其他部分没有直接耦合这段代码是否有独立的状态管理需求如果三个问题的答案都是否那它就只是一段普通的模板片段强行拆成组件反而增加通信成本。比如页面顶部的标题栏如果整站只有一个页面用到而且只是展示文字那就没必要抽组件。反之如果项目里有十个页面都要用同一个用户选择器要处理远程搜索、多选、回显、禁用状态这种就必须拆——因为它有完整独立的交互逻辑和内部状态。1.2 我目前比较稳定的拆分习惯实际项目中我总结了一套自己的拆分节奏按照业务组件 通用组件 基础组件三层来做层级例子特点基础组件UI组件按钮、输入框、弹窗、日期选择器不感知业务纯展示基础交互通常用第三方库如Ant Design Vue通用业务组件用户选择器、部门树、文件上传、商品选择弹窗绑定特定业务实体内部有数据请求逻辑但跟具体页面无关页面级业务组件订单表单、商品详情卡片、审批列表跟页面强相关可以独立维护其他页面基本不会用这个分层的核心意义在于依赖方向基础组件不依赖业务组件通用业务组件不依赖页面级组件。一旦出现反向依赖比如通用组件里引了某个页面的接口复用性就废掉了。经验心得组件拆分的粒度没有绝对标准但有一个可以落地的检验方法——如果一个组件里同时存在v-for、v-if、多个props分支判断、超过三个$emit事件基本就可以考虑继续拆了。反之如果一个组件只有props展示没有内部状态也没必要为了看起来组件化而硬拆。2. 组件通信方式的选择不同场景要用的正确姿势Vue组件通信的方式非常多props、$emit、v-model、$refs、provide/inject、事件总线、Pinia、Vuex。很多人能背出API但到了真正的项目里不知道选哪个。我的经验是通信方式的选择本质上是在问一个问题数据之间的依赖关系是单向的还是双向的是父子的还是跨层级的2.1 props/$emit是默认选择但要注意数据流向父子通信最普通但也不是随便用的。我见过有人在props里传对象子组件里直接this.propsObj.xxx newVal改属性这在Vue 2里是能运行的但会破坏单向数据流。后续排查问题的时候你根本不知道这个对象的某个属性是被哪个组件改掉的。正确做法是子组件把修改意图$emit上去由父组件来改数据!-- 子组件 -- template el-input :model-valuemodelValue update:model-valueval $emit(update:modelValue, val) / /template script setup defineProps([modelValue]) defineEmits([update:modelValue]) /script用v-model语法糖来简化这种受控组件的写法模板看起来简洁很多而且父组件和子组件的数据流仍然保持数据在父、行为在子的单向逻辑。2.2 provide/inject跨层级传递的边界provide配inject解决的是跨层级透传问题。比如一个复杂页面里的根组件向下传项目上下文信息中间隔了三层组件每层都用props中转太痛苦provide/inject就非常合适。但这里有个非常常见的坑provide里的数据如果不是响应式的子组件接收后不会自动更新。我踩过一次在父组件里provide了一个普通对象接口返回后改了对象里的某个数组子组件里的inject拿到的还是旧值。排查了半天才发现问题出在响应性上。所以使用provide时要注意要么直接provide一个ref、reactive对象要么用computed包装一层。// 父组件 const projectInfo ref({ name: , owner: }) provide(projectInfo, projectInfo) // 子组件 const projectInfo inject(projectInfo)这么处理之后接口数据更新时子组件里用到的projectInfo也能自动刷新。我一般用provide/inject来传递上下文状态——比如当前登录用户、当前项目ID、权限配置这类全局性的、读取频率高且基本不怎么变的数据。2.3 事件总线为什么不推荐以及什么时候真的该用事件总线new Vue()或mitt在Vue 2时代很流行到了Vue 3虽然也可以实现但官方已经不太推荐了。原因很简单全局事件不好追踪。出问题的时候你不知道事件是谁发的也不知道谁在监听代码量一大就变成事件满天飞。而且组件销毁时如果忘记off解除监听还会造成内存泄漏。我之前在一个老项目里查过一个诡异的bugA页面提交表单成功触发了B页面的数据刷新当时就是靠一个全局事件总线跨页面通信最后发现B页面已经销毁了但监听还在反复提交后页面越来越卡。定位到原因后我把这个全局事件改成了Pinia里的状态管理。但这不代表事件总线完全不能碰。在极少数场景下比如多个组件需要共同响应一个外部系统事件比如WebSocket推送消息、全局快捷键用一个统一的mitt实例还是比在每个组件里各自管理生命周期要省事。这种情况下把事件名定义成常量集中管理并且组件卸载时记得off问题也不大。2.4 Pinia是跨组件共享数据的最终归属当数据需要在多个非父子组件之间共享或者多个组件要修改同一个数据源时我强烈建议直接上Pinia。相比VuexPinia的API更简洁去掉了mutations那一层直接在store里写函数改state配合setup风格的store写法心智负担低很多。在实际组件化项目里Pinia通常用来承载真正的全局状态用户信息、权限点、购物车、或者某个跨页面需要保持一致的数据。需要注意的是尽量不要把所有的数据都塞进store。有时候父子组件之间传递最简单的开关状态也用store反而会让组件失去独立性。我推荐的判断标准是如果这个组件的复用性要求很高它的内部数据就不要依赖store而应该通过props传入。这样以后放到任何页面都能直接用不用先建一堆store才能跑。3. 插槽设计让组件真正具备扩展能力如果你只把组件做成封装起来的一整块页面那它注定很难复用因为现实中的业务总有那么一点点不同同样的弹窗组件A页面要加一个输入框B页面要加一个表格C页面要改按钮文案。这时候如果每个差异都用props去控制组件会膨胀成一个巨大的if/else怪物。Vue里更好的解法是插槽slot。3.1 作用域插槽的正确用法插槽不只是往组件里塞一段模板这么简单作用域插槽允许子组件向插槽内容传递数据这样父组件就能在插槽里拿到子组件的状态来定制展示。举个例子我封装过一个通用的AsyncSelect组件它负责远程搜索、防抖、loading状态、下拉选项数据管理。但不同页面对选项渲染的需求不同有的地方要显示头像昵称有的地方只要显示部门名。如果我把选项渲染写死在组件里就没法复用了。于是我用作用域插槽把选项数据暴露出去AsyncSelect :fetch-apifetchUsers template #option{ item } div classuser-option el-avatar :srcitem.avatar sizesmall / span{{ item.name }}{{ item.dept }}/span /div /template /AsyncSelect这样AsyncSelect只负责数据获取和交互逻辑具体的展示样式由使用方决定。组件本身不关心你在选项里显示什么。3.2 具名插槽与预留扩展位的思路组件封装的另一个经验是从一开始就预留扩展位。哪怕当前只有一个插槽需求也建议把组件内部的关键位置用具名插槽暴露出来。比如一个Card组件默认有header和footer但内容区域到底放什么完全由外部决定。这样后续出现新的业务需求时你不需要去改被复用的老组件而是通过插槽内容做扩展安全得多。我在重构项目时把原来一个写死的OrderDetail组件改成了多个区域插槽顶部信息、商品明细、操作按钮、扩展信息区。结果后来新增了一个物流跟踪需求完全不需要动核心组件只在外部调用时往扩展信息区塞了一个LogisticsTimeline组件就搞定了。这就是插槽带来的解耦能力。4. 组件状态管理与生命周期最容易踩坑的地方很多人对组件化开发的理解停留在模板怎么写、组件怎么拆但真正到项目中跑起来生命周期和状态同步才是最容易出问题的环节。我整理了几个实战中反复遇到的坑以及最终的排查方案。4.1 组件复用导致的数据残留有一段场景一个列表页点击编辑按钮打开一个弹窗组件弹窗里是一个表单。第一版代码弹窗是用v-if控制的打开时创建组件关闭时销毁组件。后来为了优化体验改成用v-show控制显隐结果出现了一个很隐蔽的bugA记录编辑到一半关闭弹窗再打开编辑B记录表单里还是A记录的数据。排查过程先怀疑是表单初始化逻辑没触发检查created和mounted——发现组件在v-show下根本不会重新走生命周期钩子只有v-if才会重新创建组件。解决方案有三种方案一保留v-if但增加一个key来强制组件重建比如:keycurrentRecord.id。这样每次打开不同记录时Vue会认为这是一个新组件走完整的初始化流程。方案二用watch监听外部传入的visible或currentRecord在弹窗打开时手动重置表单数据。方案三通过$refs调用子组件暴露的resetForm方法。我落地时选择了方案一因为它的侵入最小而且key变化后组件内部所有状态都是全新的不会残留。4.2 为什么明明数据变了界面不更新另一类高频问题是修改了数据但视图不动。在Vue 3的reactive和ref设计中一般不会出现属性新增不响应这类Vue 2时代的坑但有一种情况很容易被忽略在computed里直接修改state。比如有人写了这样的代码const count computed({ get() { return store.count }, set(val) { store.count val // 直接改store } })这本身问题不大但如果count被多个组件引用而且set逻辑里还带副作用就会造成一个组件改了值其他组件也间接受影响但依赖链复杂到根本看不出是谁改的。排查这一类问题我建议用Vue Devtools的组件树和Pinia面板直接看当前组件的computed依赖关系能比较快地定位到被修改的数据源。4.3 组件销毁时的清理工作组件化开发中onUnmountedVue 3阶段是很多人会忽略的。如果组件里创建了setInterval、addEventListener、WebSocket连接或者订阅了mitt事件必须在销毁时清理否则页面切换多了就会卡顿甚至报错。我写过这样一个组件它内部用一个轮询去刷新数据当时的代码如下onMounted(() { this.timer setInterval(fetchData, 3000) })后来我在路由里反复进入退出这个页面发现接口请求次数越来越多页面也越来越卡。用DevTools的Performance面板一查发现每进入一次都多了一个setInterval因为旧组件的定时器没有清掉。加上onUnmounted里clearInterval之后就正常了。写组件时我现在的习惯是所有在onMounted里打开的资源都必须在onUnmounted里关闭。这个习惯比等出问题再去查高效得多。5. 组件的复用与扩展把单一职责贯彻到业务组件业务组件的复用比基础组件要难得多。因为基础组件通常只是展示交互业务组件却包含了数据请求、权限判断、业务状态等复杂逻辑。如果边界没划好很容易变成看起来复用实际上改都改不动。5.1 通用业务组件的封装要点以前面说的AsyncSelect为例一个可复用的业务组件应该具备这几个特征数据获取能力由外部传入通过props传fetchApi组件内部不写死任何接口内部状态完整搜索词、loading、选项列表、选中值、下拉开关全部由组件自己管理对外暴露最小必要接口modelValue双向绑定、placeholder、disabled等常规属性关键展示位用插槽暴露方便外部定制如果业务组件里写死了某个后端的请求地址或者内部调用了其他业务模块的状态那这个组件的复用面就非常窄了。封装时的核心思想是把变的部分留给使用者把不变的部分沉淀在组件内部。5.2 组合式函数Composables复用的是逻辑不是组件还有一些场景组件本身不适合复用但逻辑值得复用。比如列表页的搜索、分页、重置这套流程在很多页面里都一样但页面的模板结构完全不同。这时候硬抽公共组件反而不灵活更好的做法是用组合式函数useSearchList把逻辑抽出来。export function useSearchList(fetchApi, { defaultParams {} } {}) { const loading ref(false) const list ref([]) const total ref(0) const params reactive({ ...defaultParams }) async function getList() { loading.value true try { const res await fetchApi(params) list.value res.list total.value res.total } finally { loading.value false } } function reset() { Object.keys(params).forEach(k delete params[k]) Object.assign(params, defaultParams) getList() } return { loading, list, total, params, getList, reset } }这样每个页面只需要少量模板代码就能复用整套搜索列表逻辑。相比组件复用这种方式更轻量也更灵活。组件负责复用视觉交互Composable负责复用纯逻辑两种手段配合使用覆盖的场景才完整。5.3 先写代码再抽象不要过度设计我自己之前犯过一个错误就是做组件的时候想着以后可能会扩展加入了很多当时根本用不到的配置项和逻辑分支。结果代码复杂了测试覆盖也没跟上后来真正有扩展需求的时候才发现当初预设的分支跟实际需求完全对不上改动反而更大。现在我的做法是先让组件在至少两个真实业务场景里跑通然后再根据这两个场景的共性和差异做抽象。如果只有一个场景在用抽出来的通用性很可能是假的。真正可复用的组件是在反复迭代中沉淀出来的而不是靠一次性设计出来的。6. 组件渲染性能优化从能用到流畅组件化开发做到后期性能是一个绕不开的话题。尤其当页面里有很多嵌套组件、长列表、复杂表格的时候渲染性能直接决定了体验。6.1 用computed缓存派生状态避免重复计算很多人写模板里的过滤和计算时习惯直接在模板里写方法template div{{ formatTime(item.createTime) }}/div /template script setup function formatTime(time) { /* ... */ } /script这个写法的问题是只要组件重新渲染formatTime就会被重新执行。如果列表有几百条每条都调用一次性能就是几百次函数调用。更合理的做法是把计算结果变成computed或者直接在数据层面格式化好。尤其当计算逻辑依赖多个响应式属性时computed是有缓存机制的只有依赖变化时才重新计算性能提升非常明显。6.2 v-if与v-show的选择还有一个老生常谈但经常被用错的问题v-if和v-show。我见过有人把一个大段组件用v-show控制导致初始化时就渲染了所有子组件即使它根本不可见也有人把切换频繁的按钮用v-if导致每次点击都要重新创建销毁组件。我的选择标准很简单如果元素只在初始时渲染一次后续基本不变用v-if如果元素切换频繁且内部逻辑不复杂用v-show如果元素内部包含大量子组件和复杂状态尽量避免v-show因为它虽然隐藏了元素但并没有销毁组件组件内部的onMounted、定时器、数据请求都还在跑6.3 长列表的性能优化中后台项目里长表格、长列表非常常见。几千条数据一次性渲染页面会明显卡顿。我的优化思路分几步让后端做分页前端只展示当前页数据这是最简单有效的方式。如果必须一次性加载大量数据考虑用虚拟滚动——只渲染可视区域内的DOM滚动时动态替换。尽量避免在列表项组件里放太复杂的子组件列表项越轻滚动越流畅。虚拟滚动实现起来有一定成本但网上有不少成熟的方案如vue-virtual-scroller,如果项目确实需要可以直接引库。不过拿这个库之前一定要自己拉一下demo试一下因为有些虚拟滚动方案跟表格的固定列、多级表头组合时会有兼容问题。6.4 借助Vue Devtools定位性能瓶颈排查组件性能问题时我一般会打开Vue Devtools的性能标签页录制一段交互操作然后看每个组件的渲染耗时、依赖追踪情况。排查的优先级是先看是否有组件在数据不变时仍然频繁重渲染如果有检查它的props和computed是否稳定再看是否有大组件内部存在嵌套过深的子组件树考虑用v-memo、shallowRef或拆分异步组件最后看网络请求是否有组件mounted时同时发起了多个重复请求Vue 3.2提供的v-memo指令在某些场景下非常有用。比如一个列表里每一项组件都依赖一个选中状态而选中状态只在一个时刻改变一个值但模板里其他内容没有变化时用v-memo可以直接跳过不必要的diff。不过v-memo要求你非常清楚依赖是什么用错了反而会导致界面不更新所以我建议项目中有限度使用。7. 几个真实业务场景的完整复盘光讲理论容易飘。这里挑三个我在真实项目里对接过的组件化难点完整走一遍从问题出现到最终落地的过程。7.1 百度地图组件第一次打开正常第二次一片空白这是热搜词里出现的一个典型问题。我在一个项目中封装了地图选择器组件第一次进入页面地图正常显示切换路由再回来地图区域变成空白。排查过程中我先确认了组件确实走过了onMounted地图实例也创建了但容器DOM的宽高在初始化时是0。这个问题的根因是百度地图初始化的时机和DOM的渲染时机不同步。当组件被路由切换重建时地图容器虽然挂载了但布局尚未稳定宽高还没被计算出来地图初始化就会失败。我在排查时先验证了延迟初始化的思路在nextTick后再setTimeout(0)初始化地图但这个方案并不稳定。最终我采用了两个保险在onMounted里用nextTick确保DOM布局完成后再初始化地图。给地图容器设置一个固定的min-height避免宽高为0。同时用一个key绑定到组件上当地图容器重新创建时强制地图实例重新初始化。这个案例给我的经验是组件在二次打开时产生问题多半是生命周期和外部依赖如地图、富文本编辑器、图表的初始化时序没有对齐而且排查时不要一股脑去怀疑框架先确认DOM状态和外部库需要的条件是否满足。7.2 el-select远程搜索下拉滚动加载更多另一个热搜词是el-select可以远程搜索下拉框可以滚动请求更多数据。中后台里这是非常常见的需求。难点在于远程搜索需要防抖避免每输入一个字符就请求一次下拉滚动到底部时需要加载下一页数据并且去重已选中的选项即使不在当前搜索结果里也要回显我封装的方案是组件内部维护options数组、page和keyword状态搜索时重置page1并请求第一页数据滚动到底时page继续请求用Map去重选中后把选中的对象单独存放在一个selectedOption里让显示值可以正常回显。代码结构大概是el-select v-modelselectedValue filterable remote :remote-methoddebouncedSearch visible-changehandleVisibleChange el-option v-foropt in options :keyopt.id :labelopt.name :valueopt.id / /el-select这里有个容易踩的坑remote-method触发的时机比你想象中频繁如果没做防抖接口会被打爆。我习惯用lodash.debounce包一层或者自己写个简单的定时器版本。另一个坑是滚动加载的容器不是页面而是el-select的下拉弹层要监听的是那个弹层容器的scroll事件而不是页面的scroll。这个弹层在DOM里可能不在组件内部所以不能用scroll直接绑定需要在onMounted后拿到弹层的DOM元素添加监听并在onUnmounted时移除。7.3 飞书免登录跳转Vue项目时的登录态不同步这个场景更偏向于多系统集成中的组件边界设计。实际项目中遇到的需求大致是从飞书的工作台点击应用图标跳转到我们的Vue项目需要自动完成登录而不是让用户再输一次账号密码。这里的关键不是组件本身而是应用启动时的会话打通。我们的做法是在路由前置守卫里放一个全局的登录拦截如果本地没有token就尝试用飞书跳转参数里的临时凭证去换取服务端的登录态。换取成功后再继续路由跳转失败则跳转到登录页。从组件化的角度看这个登录拦截本质上是**整个应用的最高层组件或路由守卫**在统一处理而不是每个页面组件各自判断。如果每个页面都去处理是否已登录的逻辑代码会非常分散也很难维护。正确的做法是把这类与应用生命周期强相关的逻辑收敛到全局而不是散落在各个业务组件里。8. 复盘哪些组件化最佳实践其实是伪需求写了这么多我想最后聊一个反直觉的话题。网上很多文章都在强调组件要足够通用、足够解耦、配置要灵活但我的实际体会是过度追求这些往往比不拆分更糟糕。我接手过一套组件库里面每个组件都有十几个props、四五个插槽、还有一堆computed动态判断。看起来非常强大但真正用的时候大部分props从没被传入过大部分插槽也从没被使用过。反而因为代码分支太多任何一个小改动都要回归测试一大堆场景。一个组件真正被复用的前提是在真实场景中确实出现了复用的需求。与其在一开始就设计一个万能组件不如在第一个使用场景里把组件做到刚好够用在第二个使用场景出现时再根据差异抽象出扩展点。我在重构项目时发现一个很有意思的现象真正被复用的组件往往是在三个以上的页面中都长得很像的那部分而那些当初精心设计的万能组件反而因为没人敢用最终成了死代码。组件化的价值是让系统的复杂度可控不是让组件数量变多。每抽出一个组件都应该让代码逻辑更清晰、改动面更小、可读性更高。如果拆完之后发现改动一个功能要同时改三个组件那大概率是拆分方向出了问题而不是组件化本身没用。另一个体会跟组件命名有关。好的组件名应该让人一看就知道它是干嘛的而且要能看明白它的层级。项目里我见过大量index.vue文件点进去才知道是什么组件。虽然这是很多脚手架默认生成的命名但长期维护下来这种命名方式真的会增加定位成本。我现在的做法是通用组件名用名词名词如UserSelect、DeptTree页面组件名用页面名业务名如OrderDetailPanel尽量不用index.vue作为唯一命名。组件化开发这条路没有标准答案但有一些共性的原则边界要清晰、数据流要可追踪、复用要基于真实需求、性能要有意识地去关注。每次你拿到一个需求先不要急着写模板花几分钟想想这个功能跟其他页面的现有能力是什么关系哪些东西应该收敛到组件内部哪些能力应该以插槽或props的形式对外暴露想清楚了再动手后面的维护成本会低很多。
返回列表