Vue React和小程序中组件通信的实战方案与避坑指南

技术司翰 框架 阅读 1,464
赞 30 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

上周上线一个数据看板页,嵌了七八个子组件——筛选器、时间轴、图表容器、指标卡片、导出按钮……每个都用 Vue 3 的 defineProps + defineEmits 做父子通信,外加几个全局 mitt 事件做跨层级通知。结果一打开页面,Chrome DevTools 直接报 “Long Task: 420ms”,滚动时掉帧严重,切个日期范围,UI 卡顿半秒才响应。

Vue React和小程序中组件通信的实战方案与避坑指南

最离谱的是:点一次「重置筛选」,控制台刷出 17 次 watch 触发,其中 12 次是同一个 ref 在不同组件里被重复监听;computed 里套了个 filter().map().sort() 处理原始数据,每次父组件 emit 一个更新,这堆计算就全重跑一遍——哪怕只改了个颜色配置项。

不是夸张,真测过:从点击筛选项到图表重新渲染完成,平均耗时 5.2s(本地 dev 模式,M2 Mac)。用户反馈说“像在等编译”,我信了。

找到瘼颈了!

先开 Performance 面板录一段操作:选日期 → 点刷新 → 看火焰图。一眼看到三块红区:

  • 大量 triggerEffects 调用堆在 reactivity 底层,说明响应式依赖太密
  • patchElement 占比奇高,DOM diff 次数远超预期
  • 有个 updateChart 函数在主线程跑了 890ms,里面调了三次 chartInstance.setOption()

再配合 Vue Devtools 的 Components 面板点开「Reactivity»,发现 filters 这个对象被 9 个组件同时作为 reactive 对象解构监听,而且每个都用了 watch(() => props.filters, ...) —— 没做防抖、没加 immediate: false、更没限制触发深度。改一个字段,9 个 watch 全 fire。

还顺手开了 console.time('render'),定位到问题根因:父组件把整个 dataList(2000+ 条)直接传给子组件,而子组件内部又用 v-for 渲染 + @click 绑定事件,导致 Vue 必须为每条数据建立独立的响应式代理和事件监听器。

试了几种方案,最后这个效果最好

一开始想用 shallowRefmarkRaw 包一层数据,但子组件里要过滤排序,得靠响应式能力,直接 markRaw 就废了。后来试了 computed 缓存 + toRefs 拆解,但父组件一改源数据,computed 还是全量重算。

最终落地的方案就两条铁律:

  1. 通信只传必要字段,不传整块数据
  2. 所有跨组件状态变更,统一走事件 + 状态快照,不靠响应式穿透

具体干了三件事:

第一:砍掉无意义的响应式穿透

原来父组件这样传:

<!-- Parent.vue -->
<ChildComponent :filters="filters" :dataList="dataList" />

子组件里:

// ChildComponent.vue
const props = defineProps(['filters', 'dataList'])
watch(() => props.filters, () => update(), { deep: true })
watch(() => props.dataList, () => renderTable(), { deep: true })

改成只传关键字段,且用 computed 做最小化派发:

<!-- Parent.vue -->
<ChildComponent 
  :date-range="filters.dateRange"
  :status="filters.status"
  :item-count="dataList.length"
  @filter-change="handleFilterChange"
/>

子组件不再监听整个对象,只收扁平字段,watch 从 9 个压到 2 个,且去掉 deep: true

第二:用事件总线 + payload 快照替代高频 emit

原来时间轴组件每次拖动都 emit('date-change', newDate),图表组件立马 fetch 并重绘。拖动过程中 emit 了 30+ 次,后端 API 被狂刷。

现在改成:

// Timeline.vue
const debouncedEmit = useDebounceFn(() => {
  // 只在拖动结束或静止 300ms 后发一次
  emit('date-snapshot', { start: startDate.value, end: endDate.value })
}, 300)

// 图表组件监听 date-snapshot,而不是 date-change
onMounted(() => {
  const unwatch = watch(
    () => props.dateSnapshot,
    (snap) => {
      if (snap) fetchAndRender(snap)
    }
  )
})

这里注意我踩过好几次坑:debounce 不能直接包 emit,得包整个逻辑块;否则 Vue 的 emit 是同步的,debounce 失效。另外快照对象必须是新引用(用 {...snap}),不然 watch 不触发。

第三:大数据列表用虚拟滚动 + 非响应式渲染

那个 2000+ 行的表格,彻底放弃 v-for 响应式渲染。引入 vue-virtual-scroller,把数据转成普通数组传入:

<!-- Parent.vue -->
<VirtualScroller
  :items="rawDataList" <!-- 注意:这里传的是 toRaw(dataList) -->
  item-key="id"
  height="500"
>
  <template #default="{ item }">
    <div class="row">{{ item.name }} | {{ item.value }}</div>
  </template>
</VirtualScroller>

子组件里不再用 refreactive 接收数据,只做纯展示。点击行内操作?不绑定 @click,改用事件委托 + dataset

// 子组件 mounted 里
el.addEventListener('click', (e) => {
  const id = e.target.closest('[data-id]')?.dataset.id
  if (id) emit('row-click', id)
})

优化后:流畅多了

改完重新测:同操作路径下,首屏可交互时间从 5.2s → 820ms,Long Task 从 420ms → 最大 32ms,FPS 稳在 58+。最爽的是滚动完全不卡,切日期范围几乎零延迟。

DevTools 里再看 reactivity 面板:filters 监听数从 9 降到 2;dataList 的响应式代理直接没了(因为传的是 toRaw);patchElement 耗时下降 76%。

当然也有妥协:比如某些需要实时联动的编辑态组件,还是保留了少量 deep watch,但加了 flush: 'post'immediate: false 控制时机。不是完美,但够用。

性能数据对比

环境:Chrome 125 / M2 MacBook Air / 开发模式(Vite dev server)

指标 优化前 优化后 提升
首屏可交互时间 5230ms 820ms ↓ 84%
平均 Long Task 时长 398ms 28ms ↓ 93%
v-for 渲染耗时(2000 条) 1650ms 120ms ↓ 93%
date-range 切换响应延迟 1120ms 45ms ↓ 96%

生产环境压缩后效果更好,但 dev 模式能压到这个程度,我已经愿意请自己喝杯冰美式了。

以上是我个人对组件通信性能优化的完整实践,有更优的实现方式欢迎评论区交流

这个技巧的拓展用法还有很多,比如怎么在 Pinia store 里复用这套「快照+事件」思路、怎么给第三方图表库做轻量 bridge 层,后续会继续分享这类博客。

以上是我踩坑后的总结,希望对你有帮助

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论