Message消息组件在Vue3项目中的实战封装与常见问题解决方案

宝娥 Dev 组件 阅读 2,001
赞 34 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

上个月上线一个后台管理平台,里面用了自己封装的 Message 组件——就是那种调用一下 Message.success('操作成功') 就弹个提示框的东西。本来以为这种小玩意儿能有多大事?结果一上生产,运营同学反馈:“点完按钮要等两秒才看到提示,有时候点了没反应,再点一次直接弹俩”。我本地试了下,确实卡,尤其在低配 Mac 或 Win10 旧笔记本上,连发 3 条 message,页面直接卡顿 1.5 秒以上。更离谱的是,Chrome Performance 面板里看到每次触发 message,主线程里一堆 layout 强制同步、style recalc、paint 全来一遍……我寻思着:就一个 80px 高的小盒子,咋能干出这种事?

Message消息组件在Vue3项目中的实战封装与常见问题解决方案

找到瘼颈了!

先开 Chrome DevTools 的 Performance 面板,录了一段点击按钮 → 触发 3 次 Message.info → 等消息自动关闭的全过程。导出火焰图一看,最大头不是 JS 执行,而是 Layout 和 Paint 占了 60%+ 时间。再切到 Elements 面板,发现每条 message 实例都挂在一个固定容器(#message-container)里,而这个容器是直接写死在 body 下的——但问题是,我们每次新增 message 都会重新计算整个容器的 height、position,还要做 translateY 动画,再加上 message 进出场动画里用了 transform + opacity,但父容器没设 will-change: transform,浏览器就只能不断重排重绘。

另外查了下源码,发现当时为了“支持多实例堆叠”,每条 message 创建时都会遍历已有的所有 DOM 节点去算 top 偏移量,还用了 getBoundingClientRect() ——这玩意儿在动画中调用,就是性能杀手。还有个坑:消息组件内部用了 ref + useEffect 去监听 DOM 插入,然后马上执行动画逻辑,结果 React 还没 commit 完,DOM 就被强制读取尺寸……

优化后:流畅多了

试了几种方案:换用 requestAnimationFrame 包裹 layout 读取、改成 CSS-in-JS 动态注入样式、甚至想上 Web Worker 处理偏移计算(后来发现太重了放弃)。最后定下来三个核心改动,效果最直接:

  • 第一,砍掉实时 DOM 尺寸计算:不再遍历已有节点算 top,改用纯 JS 计数器维护 offset。每条 message 固定高度 48px,间距 8px,所以第 n 条就是 (48 + 8) * (n - 1),简单粗暴。而且只在 mount 时算一次,后续靠 CSS 动画位移,不 touch layout。
  • 第二,把容器从 body 直接挂载,改成 portal + 隔离容器:之前是全站共用一个 #message-container,导致每次增删都触发全局重排。现在每个 message 实例自己带一个轻量级 container(用 createPortal 渲染),并且加了 contain: layout paint style ——亲测有效,layout 耗时降了 70%。
  • 第三,动画全部交给 CSS,JS 只管 class 切换:原来用 useState 控制 visible,再用 useEffect 去 setStyle,现在全换成 className + @keyframes。进出场分离成 message-enter/message-leave,配合 animation-fill-mode: forwards,避免 JS 中间干预。

下面是关键代码对比(精简版,去掉 error boundary 和 type 定义):

// 优化前(问题集中区)
const Message = ({ content, type, duration = 3000 }) => {
  const [visible, setVisible] = useState(false)
  const ref = useRef(null)

  useEffect(() => {
    setVisible(true)
    const timer = setTimeout(() => {
      setVisible(false)
    }, duration)
    return () => clearTimeout(timer)
  }, [])

  useEffect(() => {
    if (ref.current && visible) {
      // ❌ 强制同步 layout:触发重排
      const container = document.getElementById('message-container')
      const allMessages = container.querySelectorAll('.message-item')
      const offsetTop = Array.from(allMessages).length * 56 // 48+8
      ref.current.style.top = ${offsetTop}px
      ref.current.style.opacity = '1'
      ref.current.style.transform = 'translateY(0)'
    }
  }, [visible])

  return (
    <div ref={ref} className="message-item">
      <span>{content}</span>
    </div>
  )
}
// 优化后(核心逻辑就这几行)
const Message = ({ content, type, duration = 3000 }) => {
  const [state, setState] = useState('enter') // 'enter' | 'idle' | 'leave'
  const id = useRef(Math.random().toString(36).substr(2, 9)).current

  useEffect(() => {
    const timer = setTimeout(() => {
      setState('leave')
      setTimeout(() => setState('idle'), 300) // 匹配 CSS 动画时长
    }, duration)
    return () => clearTimeout(timer)
  }, [])

  // ✅ 不读 DOM,不写内联样式,只切 class
  const className = message-item message-${type} message-${state}

  return createPortal(
    <div className={className} key={id}>
      <span>{content}</span>
    </div>,
    // ✅ 每个实例独占 container,加 contain 提升渲染隔离
    (() => {
      const el = document.createElement('div')
      el.style.contain = 'layout paint style'
      document.body.appendChild(el)
      return el
    })()
  )
}

css
/* 优化后的 CSS(关键部分) */
.message-item {
position: fixed;
top: 24px;
right: 24px;
width: 320px;
max-width: calc(100vw - 48px);
padding: 12px 16px;
border-radius: 6px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
z-index: 9999;
opacity: 0;
transform: translateX(100%);
transition: opacity 0.3s, transform 0.3s;
}

.message-enter {
opacity: 1;
transform: translateX(0);
}

.message-leave {
opacity: 0;
transform: translateX(100%);
}
`>

性能数据对比

还是用 Performance 面板录同一段操作(连续触发 5 条 message):

  • 优化前:平均总耗时 4.8s,其中 Layout 占 2.1s,Paint 占 1.4s,JS 执行 0.6s
  • 优化后:平均总耗时 780ms,Layout 降到 120ms(基本只剩首次渲染),Paint 降到 210ms,JS 执行 80ms

真实设备测试(MacBook Air M1 + Chrome 124):

  • 连续触发 10 条 message,页面无卡顿感,动画帧率稳定在 58–60fps
  • 低端安卓机(Redmi Note 9)上,从原来 2.3s 的响应延迟降到 620ms 左右,用户感知明显变快

还有一个意外收获:原来因为用 getBoundingClientRect() 导致在 iframe 场景下偶现报错(跨域限制),现在彻底没了。

踩坑提醒:这三点一定注意

第一,别信“只要用 React 就不会 layout thrashing” —— 你 ref.current.offsetHeight 一读,浏览器立刻 flush layout,不管你在哪个 hook 里调;

第二,createPortal 的 container 必须手动清理,不然内存泄漏。我在 unmount 里补了 container.remove(),但要注意有些老版本 React Portal 有 bug,得加个 if (container.parentNode) 判断;

第三,CSS 动画的 transition@keyframes 别混用,我一开始两个都写了,结果 leave 动画跳帧,最后只留 keyframes + animation-fill-mode: forwards 才稳。

最后说一句:这个方案不是最优解,比如真要支持从底部向上堆叠、动态宽度适配、或者主题切换时重算样式……那还得加 context + useLayoutEffect。但现在项目里够用,改完上线一周,没收到一条相关 bug 投诉,我觉得值了。

以上是我踩坑后的总结,希望对你有帮助。有更优的实现方式欢迎评论区交流。

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

暂无评论