ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vue3迁移指南:从事件总线到Composables的组合式重构

Vue3迁移指南:从事件总线到Composables的组合式重构 从 Vue 2 项目往 Vue 3 迁移的人大概率都见过这种报错TypeError: bus.$on is not a function或者更隐蔽一点代码没报错但事件发出去之后怎么都收不到 debug 半天发现this.$bus根本不存在。别笑我在好几个项目里都踩过。Vue 3 里$on、$off、$once被正式移除了不是 deprecated 后给你留几个版本过渡是直接从根上拿掉。这不是 Vue 团队拍脑袋做的决定背后是对组件通信机制的一次重新思考。而随之成为主流替代方案的正是今天标题里后半部分的主角——Composables 组合式函数。这篇文章我会先讲清楚移除的原因和迁移路径再用足够多的例子把 Composables 的用法、设计思路和团队落地经验讲透。1. 为什么$on/$off/$once会被移除被误用的通信方式结果1.1 四个方法原本的设计意图$on、$off、$once和$emit在 Vue 2 里是一套完整的事件系统。$emit是组件对外发布事件$on监听事件$off取消监听$once只监听一次。在 Vue 2 里每个 Vue 实例都实现了 EventEmitter 接口所以你可以对任何组件实例调用这些方法// Vue 2 中挂在 Vue.prototype 上的全局事件总线 // main.js Vue.prototype.$bus new Vue() // A 组件发送方 this.$bus.$emit(refresh-list, { id: 1 }) // B 组件接收方 this.$bus.$on(refresh-list, payload { console.log(payload) })你甚至不需要把事件总线挂在 prototype 上直接找一个现有的 Vue 实例当总线也行。这套机制设计之初是给组件内部用的$emit配合$on让父组件能监听到子组件的事件而$on其实主要用在生命周期管理上比如在mounted里监听某个事件然后在beforeDestroy里取消。但社区里很快就发展出了一种玩法把$on用在任意两个组件之间通过一个共享的 Vue 实例做事件转发——这就是大家熟悉的 EventBus 模式。1.2 事件总线为什么成为反模式事件总线在最早期确实方便但它有两个致命的毛病项目越大越明显。第一个问题是断链问题。$emit和$on之间通过一个字符串名字关联没有任何约束机制。你发一个refresh-list收的地方得靠约定知道有人会发。一旦组件多了改名、拼写错误、多个模块共用同一个事件名都是潜在的隐患。Vue 2 项目里常见这种场景一个新同事接手代码看到一个bus.$on根本不知道是哪个组件在发这个事件只能全局搜字符串。更麻烦的是如果你$emit(refresh-List)和$on(refresh-list)大小写不一致运行时不会有任何提示就是收不到。第二个问题是内存泄漏。事件监听这种东西注册了就得注销。问题是你在一个组件里$on了事件如果忘了在beforeDestroy里$off组件销毁了但监听函数还留在总线上。表现就是页面不断创建销毁内存里的监听器越积越多事件被重复触发 N 次。我见过一个生产事故就是因为某列表页组件 A 监听了总线的refresh事件但 A 每次进来都新建实例结果刷新页面时回调被执行了五次页面直接卡死。当时排查了两天最后发现监听器堆了 200 多个。Vue 3 移除$on、$off、$once本质上就是对这套机制说不。Vue 团队想传达的信息很明确组件之间的数据流应该是显式的、可追踪的而不是靠全局事件广播。1.3 移除后对类型系统和代码可维护性的影响从技术层面看Vue 3 用 TypeScript 重写而 EventBus 这种模式对类型系统极不友好。事件名是字符串载荷是任意类型两者之间没有任何静态约束。Vue 官方在 RFC 里也明确提到实例方法$on的移除跟类型推导有很大关系——你没有办法从$emit(foo, 1)推导出$on(foo, (n: number) {})的参数类型除非引入模板字面量类型等复杂方案强行做类型体操可最终收益也不大。把事件系统从框架核心中移除让全局通信方案交给第三方库或由应用层自己设计换来的是更干净的运行时、更轻量的实例以及让开发者重新思考数据流应该怎么走这件事。这个改动放在整个 Vue 3 的架构调整里和 Composition API 的推出是一脉相承的——从隐式的、凌乱的逻辑组织转向显式的、可组合的代码结构。2. 迁移实战没有事件总线的 Vue 3 项目怎么做跨组件通信2.1 轻量方案用 mitt 平替事件总线如果你就是需要一个全局事件广播机制而且不想大动干戈mitt是目前社区里最主流的替代方案。它 200 字节左右API 跟旧的 EventBus 几乎一样// mitt 基本用法 import mitt from mitt const emitter mitt() // 监听 emitter.on(refresh, callback) // 只监听一次 emitter.once(refresh, callback) // 取消监听 emitter.off(refresh, callback) // 触发 emitter.emit(refresh, payload)我自己的迁移路径是在src/utils/eventBus.js里导出一个单例然后把原先所有this.$bus.xxx改成eventBus.xxx。这种方案的好处是改动量极小几百个引用点都能机械替换。但说实话这只是换个引擎继续跑同一辆车事件总线本身的问题断链、内存泄漏、类型不友好在 mitt 里依然存在。只不过 mitt 强迫你手动管理off比 Vue 2 里new Vue()当总线更明确一些。做过渡可以长期不建议。2.2 官方推荐方案provide / inject 组合跨组件通信的官方首选是provide/inject。Vue 3 的provide/inject比 Vue 2 更好用因为传下去的值可以是响应式对象还能配合函数修改。一个典型场景是父组件提供一个刷新方法深层子组件调用它。!-- 父组件 -- script setup import { provide, ref } from vue const list ref([]) async function loadData() { const data await fetch(/api/list).then(r r.json()) list.value data } provide(loadData, loadData) /script!-- 深层的子组件 -- script setup import { inject } from vue const loadData inject(loadData) loadData() /scriptprovide/inject的好处在于它是有方向的。事件总线是谁都能发、谁都能收provide / inject 是只有祖先能提供只有后代能消费数据流方向天然清晰。配合readonly包一层还能防止子组件意外修改父组件状态。我爱用这个方案的一个场景是全局 UI 状态比如多级弹窗需要关闭最顶层抽屉。你不需要把状态放到 Pinia 里全局可见只需要在组件树顶层 provide 一个closeDrawer函数需要的地方 inject 即可。这样隔离性好也不会产生事件名冲突。2.3 props / $emit 的边界与约束除了 provide / inject常规的父子通信依然推荐props$emit。在script setup里是这么用的!-- Parent.vue -- script setup import { ref } from vue import Child from ./Child.vue const msg ref() function handleUpdate(value) { msg.value value } /script template Child :model-valuemsg update:model-valuehandleUpdate / /template!-- Child.vue -- script setup defineProps([modelValue]) const emit defineEmits([update:modelValue]) function change() { emit(update:modelValue, new value) } /script注意 Vue 3 里的defineEmits是编译时宏它不是运行时的 API但起到了声明这个组件会发出哪些事件的作用。相比 Vue 2 的隐式$emit这已经是一个进步。团队里可以约定所有事件名统一用update:xxx或change:xxx这样的规范让事件语义更明确。2.4 状态管理方案Pinia 才是真正的总线如果两个组件之间确实没有任何层级关系而且需要共享同一份状态再用事件总线搞广播就很绕了。正确做法是引入 Pinia。Pinia 不仅是一个 store它本身也是一个响应式事件系统——store 里的 state 改变了任何读取它的组件都会自动更新。这就是典型的不要告诉别人数据变了而是让别人自己知道数据变了。拿刚才的刷新列表场景举例用 Pinia 的做法// stores/listStore.js import { defineStore } from pinia export const useListStore defineStore(list, { state: () ({ list: [], reloadFlag: 0 }), actions: { async loadData() { const data await fetch(/api/list).then(r r.json()) this.list data this.reloadFlag } } })任何组件调loadData()所有用到list和reloadFlag的组件自动更新不需要$emit、不需要$on、不需要中间人。这才是真正的总线——单向数据流可追踪可调试。所以迁移 Vue 2 项目时我的建议顺序是场景原先的做法Vue 3 的推荐做法深层子组件触发祖先操作bus.$emit(xxx)provide/inject父子组件通信$emit/$onprops/$emit完全无关的组件共享状态bus.$emit(xxx)Pinia临时性的全局事件且场景很轻量bus.$emit(xxx)mitt并严格管理 off3. Composables 组合式函数详解从声明式选项到命令式组合3.1 概念与核心思想一个useXxx函数就是一个独立逻辑单元Composables在国内一般叫组合式函数。它不是 Vue 3 的新概念而是对在 Vue 组件中封装和复用有状态逻辑这件事的一种模式化落地。在 Vue 2 里逻辑复用靠的是 mixins、高阶组件、renderless components 这些方案它们各有各的毛病——命名冲突、数据来源不透明、层层嵌套导致调试困难。Composables 的思路很朴素把一段需要响应式能力的逻辑封装成一个普通函数函数内部可以用 Vue 的 ref、reactive、computed、watch 等 API函数返回给调用方需要的值或方法。任何组件都可以调用这个函数得到一份独立副本。函数是普通的 JavaScript 函数不依赖组件实例所以你在任何组件甚至任意模块里都可以使用它。一个最经典的例子是鼠标位置追踪// 你会发现这个函数不依赖组件它只是碰巧用了 Vue 的响应式 API // useMousePosition.js import { ref, onMounted, onUnmounted } from vue export function useMousePosition() { const x ref(0) const y ref(0) function update(event) { x.value event.clientX y.value event.clientY } onMounted(() { window.addEventListener(mousemove, update) }) onUnmounted(() { window.removeEventListener(mousemove, update) }) return { x, y } }然后用它就像用普通函数一样script setup import { useMousePosition } from /composables/useMousePosition const { x, y } useMousePosition() /script template div鼠标位置{{ x }}, {{ y }}/div /template你看组件的逻辑被压缩到只剩拿数据 渲染模板这两件事。这就是 Composition API 的初衷——按逻辑组织代码而不是按选项类型组织代码。3.2 最基础的例子把数据请求封装成 useAsyncData数据请求是前端项目里最高频的逻辑也是最值得封装成 Composables 的场景。一个基础的 useAsyncData 可以这样做// useAsyncData.js import { ref, watch } from vue export function useAsyncData(fetcher, options {}) { const data ref(null) const error ref(null) const loading ref(false) const { immediate true } options async function run(...args) { loading.value true error.value null try { data.value await fetcher(...args) } catch (e) { error.value e } finally { loading.value false } } if (immediate) { run() } return { data, error, loading, run } }组件里的用法script setup import { useAsyncData } from /composables/useAsyncData const { data: list, loading, error, run } useAsyncData( (page) fetch(/api/list?page page).then(r r.json()), { immediate: false } ) /script template div v-ifloading加载中.../div div v-else-iferror{{ error.message }}/div ul v-else li v-foritem in list :keyitem.id{{ item.name }}/li /ul button clickrun(2)加载第二页/button /template这个封装的价值在于所有组件里请求数据 管理 loading 处理错误这一套样板代码被收敛到了一处。后续如果需要加缓存、加超时、加请求取消只需要改一个文件所有用到的组件都跟着受益。我在团队里落地之后业务组件里几乎看不到fetch和loading的状态逻辑了全都在 composables 里。3.3 进阶用 useDebounce、useCounter 理解延时与状态管理的模式有了基础概念再看几个更有味道的进阶例子你就能体会到 Composables 的灵活性。第一个是防抖逻辑封装// useDebounce.js import { customRef } from vue export function useDebounce(value, delay 300) { let timer null return customRef((track, trigger) ({ get() { track() // 追踪依赖 return value }, set(newValue) { clearTimeout(timer) timer setTimeout(() { value newValue trigger() // 派发更新 }, delay) } })) }用法const searchText useDebounce(, 500) watch(searchText, (val) { // 用户停止输入 500ms 后才执行搜索 searchApi(val) })这个例子妙在customRef的使用它让你可以在set和get之间插入自定义逻辑而外部使用时完全感受不到延迟的存在——它就是响应式数据的一个变种。第二个是计数器/步进器逻辑// useCounter.js import { ref, computed } from vue export function useCounter(initialValue 0) { const count ref(initialValue) const double computed(() count.value * 2) function increment(step 1) { count.value step } function decrement(step 1) { count.value - step } function reset() { count.value initialValue } return { count, double, increment, decrement, reset } }两个组件各自调用useCounter(0)它们内部的count互不干扰——这是 Composables 最重要的特性之一状态独立。相比之下如果两个组件同时 import 同一个对象作为共享状态那它们就共享了。你需要什么由函数内部的实现决定。3.4 生命周期与作用域Composables 里的onMounted不能乱用Composables 看起来是普通函数但它之所以不普通是因为它内部可以使用 Vue 的生命周期钩子。这里有个关键约束onMounted、onUnmounted、watchEffect这类副作用 API只能在同步执行的过程中注册。什么叫同步执行就是你调用useMousePosition()的时候代码执行到onMounted(() {})这一行Vue 能确保当前存在一个正在被创建的组件实例于是把回调挂到那个实例上。如果你在setTimeout里调用或者在一个 async 函数里 await 之后再调用Vue 就找不到当前实例控制台会警告onMounted is called when there is no active component instance这一点是我在团队评审代码时反复强调的。你可以在 composables 内部自由使用生命周期但不要把整个 composable 的调用放在 setTimeout 或者 promise 回调里。如果确实需要异步初始化正确的做法是同步调用 composable 拿到方法和 ref再在onMounted里执行异步逻辑或者把异步逻辑封装成一个函数返回给调用方由调用方决定什么时候执行。3.5 命名规则与文件组织团队协作时的不成文规矩Composables 的约定俗成一个命名规范use开头。useMousePosition、useDebounce、useAsyncData。这个命名不是纯粹的审美问题它有一个实际用途IDE 插件和 eslint 规则可以根据use前缀识别哪些函数是 composables。Vue 官方推荐的 ESLint 规则集eslint-plugin-vue里有专门针对 composables 的规则会检查你写的 composable 是否同步调用了响应式 API。如果命名不规范这些检查可能失效。目录组织方面我的习惯是在src/composables/或src/hooks/下按业务域分文件夹src/composables/ /useAsyncData.js /useDebounce.js /useMousePosition.js /list/ /useListSearch.js /useListSelection.js单文件职责要单一。一个 composable 只做一件事不要搞一个useGlobalUtils把所有零碎逻辑塞进去。那样和以前把工具函数全塞进utils.js没有区别反而丢掉了逻辑复用的颗粒度。4. 设计你的可复用 Composables状态内聚与参数设计4.1 什么时候该抽成 Composables判断标准与反面案例不是所有代码都值得抽成 composable。我见过有人把两三行的 API 调用都封装一下最后项目里的 composables 比页面组件还多另一个极端是所有业务逻辑全塞在组件里composable 写了等于没写。我自己判断是否抽象成 composable主要看三个标准第一这段逻辑是否被多个组件使用如果只有一个组件用可以先不抽等出现第二个使用方再抽YAGNI 原则在这里同样适用。第二是否包含响应式状态或生命周期副作用如果只是纯函数计算抽成普通的工具函数就行用utils目录别用composables目录这样团队里的人看到路径就能区分文件的性质。第三是否和当前组件的模板渲染强耦合如果这段逻辑脱离组件模板就没法理解那它就适合留在组件内部硬抽出去反而增加了跳转成本。反面案例一个组件里有两个表单联动判断逻辑用了三个if。有人把这段联动逻辑抽成useFormLinkage然后在组件里用const { updateFieldA, updateFieldB } useFormLinkage(form)。但问题是这段逻辑只在当前表单场景出现复用性为零而且抽走之后模板里找不到数据的来源——这就是过度抽象。在重构时我们要时刻提醒自己可复用性是抽离的唯一理由不是代码变短了的理由。4.2 参数与返回值设计单对象返回与多值返回的取舍Composables 的返回值设计直接决定调用方的体验。我见过两种极端风格一种把所有状态塞进一个reactive对象返回调用方const state useCounter()然后到处都是state.count.value另一种把十几个 ref 全平铺返回调用方const { data, error, loading, run, clear, refresh, setPage, pageSize, total } useList()看着很爽但重命名成本很高。我的经验是核心状态优先用单一引用返回操作函数分组返回。以useList为例export function useList(fetcher) { const state reactive({ data: [], page: 1, pageSize: 10, total: 0, loading: false, error: null }) async function fetchList() { state.loading true state.error null try { const result await fetcher({ page: state.page, pageSize: state.pageSize }) state.data result.list state.total result.total } catch (e) { state.error e } finally { state.loading false } } function setPage(page) { state.page page fetchList() } return { state, // 响应式状态统一用一个 state fetchList, // 操作函数单独列出 setPage } }这样调用方看着清晰state里是全部数据状态函数里是全部操作方法。当然你也可以根据实际场景折中返回{ data, loading, error, pagination }这种分层结构核心思想不变——语义分组。4.3 响应式状态在组件外的生命周期差异使用 Composables 时要注意一个坑如果你在模块顶层直接调用 composable而不是在组件里调用那状态是全局共享的。// 错误示范模块顶层创建所有组件共享同一个 state const { state } useList() // 正确示范每个组件自己调一次各自独立 export function useListStore() { const { state } useList() return { state } }这个特性和 Vue 2 的 mixins 有本质区别mixins 是组件的复制粘贴Composables 是函数调用每次调用都重新创建内部状态。如果状态不是从函数外部传入而是函数内部创建的那每次调用都是全新的副本。这是个优点但也容易踩坑——你以为是独立的结果有人在模块顶层调了一次整个应用都共享了。所以在设计 composable 时最好明确状态是在函数内部创建还是外部传入。如果是从外部传入的ref那就意味着多个调用方会共享这个 state得在注释里写清楚。// 明确共享的实现 export function useCounter(sharedRef) { // sharedRef 由调用方传入大家共享 } // 明确独立的实现 export function useCounter(initialValue 0) { const count ref(initialValue) // 每次调用独立 }5. 从 Vue 2 迁移踩过的坑一次真实的$on到 Composables 重构记录5.1 排查过程从报错到定位根因最后分享一个我实际做过的迁移案例记录完整的排查和重构过程希望能给正在迁移的老项目一点启发。那个项目是一个后台管理系统中台Vue 2.6 Element UI。迁移到 Vue 3 用的是vue/compat兼容构建模式本来一切看起来挺正常的直到有一个页面开始鬼畜点击新增按钮弹窗要setTimeout才会出现打开 A 页面B 页面的列表会自动刷新更离谱的是关闭一个弹窗组件地址栏的 hash 变了。我用 Vue Devtools 的 Timeline 一看事件监听器列表密密麻麻全是重复项。定位到代码发现项目里大量使用了 EventBus// 旧代码全局事件总线 // 某列表页 bus.$on(refresh-list, this.loadData)这行代码写在created里没有对应的bus.$off。在 Vue 2 里组件实例销毁时 Vue 会自动清理组件自身在实例上的监听器实际并不会事件总线是一个独立的 Vue 实例它不会知道你销毁了监听器就一直残留。每次进入列表页都会重新$on一次监听器数量疯涨于是出现了事件被触发多次的诡异现象。迁移到 Vue 3 之后首先bus.$on不存在了报错直接打断流程反而是好事——问题暴露得彻底算是中了头彩。5.2 重构方案不同类型的旧 EventBus 用不同策略替换我没有用 mitt 平替所有事件总线而是分类处理第一类是列表页刷新这种——两个毫无关联的页面纯粹是 A 改了数据后通知 B 刷新。这种场景我直接接入了 Pinia// 重构后listStore.js import { defineStore } from pinia export const useListStore defineStore(list, { state: () ({ version: 0 }), actions: { async refresh() { // 重新请求列表 this.version // 自增版本号任何组件 watch version 就重新加载 } } })消费方// 列表组件 import { storeToRefs } from pinia const listStore useListStore() const { version } storeToRefs(listStore) watch(version, () loadData())第二类是某个弹窗组件里触发了父级抽屉的数据更新——层级比较深用 provide / inject 更合适// 父级抽屉组件 provide(refreshAfterEdit, () { activeTab.value list fetchData() }) // 弹窗组件 const refreshAfterEdit inject(refreshAfterEdit) function submit() { await api.update(...) refreshAfterEdit() }第三类是某个非组件模块里的工具函数需要发布事件——比如 axios 拦截器里请求失败后需要一个全局的用户未登录踢回登录页通知。这种用 mitt 是合理的因为它是模块级事件的典型场景跟 Vue 组件生命周期解耦。5.3 重构过程中的典型问题$forceUpdate、异步时序、模板里的$on重构时一定会遇到几个 Vue 2 老项目的老朋友。$forceUpdate就是其中之一。Vue 2 里视图没更新第一反应是this.$forceUpdate()这非常影响性能且说明设计有问题。迁移到 Vue 3 后$forceUpdate还在但我不建议用。我在重构中处理这个问题的方式是检查为什么数据变了视图没更新。绝大多数情况下是因为直接给对象添加了新属性Vue 2 的响应性缺陷或者修改了this.list[0].name这种下标赋值。Vue 3 用 Proxy 代理属性新增和下标修改都是响应式的了原来的$forceUpdate自然可以去掉。异步时序问题也值得单独说一句。在事件总线模式里$emit是同步的监听器立即执行。所以 Vue 2 里确保事件已经处理完再做下一步是写在$emit之后的。重构后用 Pinia异步 action 返回的是 Promise可以用await确保执行顺序// 旧emit 后 setTimeout 假装等待 bus.$emit(refresh-list) setTimeout(next, 0) // 新await 确保完成 await listStore.refresh() next()模板里也有用$on的Vue 2 里有人会在模板里写click$bus.$emit(logout)。拆掉 EventBus 后这种事件操作改为调用 inject 的函数或 store 的 action模板里依然很干净。5.4 回归测试与团队约定迁移后少了什么多了什么重构完成的标志不只是能编译、能跑还得做一轮监听器泄漏检查。我当时的做法是打开管控台的性能面板反复创建和销毁几个典型页面观察内存曲线和事件监听器数量。Vue 3 的getCurrentInstance()里有一个subTree结构但普通业务代码不需要碰它。更实用的方式是给 Vue Devtools 的 Timeline 拍快照查看监听器一列是否归零。另外可以用window.performance.memory粗略观察内存是否持续上涨。团队规范方面我列了三条硬性约定源代码中不允许再出现new mitt()全局单例除非在工具的eventBus.js里统一创建并导出任何模块不得自行 new。所有组件内事件监听必须写在onUnmounted中取消这个可以用 ESLint 插件辅助检查也可以靠 Code Review。组件传值扁平化props 不传对象深层引用防止子组件内部随意改父组件状态。5.5 小结回过头看移除$on/$off/$once对 Vue 3 的生态是一件好事。它把从框架层面杜绝了那种靠全局广播做通信的坏味道逼着你用更显式的数据流。Composables 的出现又极大地填补了逻辑复用的空白让组件代码可以按业务维度组织而不是被 data、methods、computed 这些选项切碎。迁移过程中最深刻的体会是不要试图用 mitt 假装事件总线没被移除而是借这次重构成的机会把事件驱动的模式改造成状态驱动的模式。如果你也正在做类似迁移我的建议是先不要急着全量替换。选一个耦合低的小模块用 Composables 重构一遍感受一下响应式状态在函数作用域内的流动方式再逐步扩大范围。Vue 2 和 Vue 3 最大的区别不是 API 变了多少而是你组织代码的方式彻底变了——想清楚这一点迁移过程中遇到的所有别扭都会迎刃而解。
返回列表