Message消息机制的实现原理与前端应用实践

A. 锦锦 组件 阅读 1,798
赞 15 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

上个月接了个后台管理系统重构的活,UI 组件库用的是 Ant Design,但客户提了个需求:全局消息提示(Message)要能自定义样式,还要支持“批量操作成功后合并提示”——比如“3 条数据删除成功”,而不是连续弹 3 个“删除成功”。一开始我直接用了 AntD 的 message.success(),结果发现它不支持动态合并,每次调用都新建一个实例,堆在页面上密密麻麻,用户反馈体验很差。

Message消息机制的实现原理与前端应用实践

纠结了一下,是魔改 AntD 还是自己写?考虑到后续可能还要加动画、位置控制、点击关闭等功能,干脆自己撸一个。反正 Message 逻辑不复杂:创建 DOM、显示、定时销毁。但真动手才发现,坑比想象中多。

核心代码就这几行(理想很丰满)

最开始的实现特别简单:搞个全局容器,每次调用 showMessage 就往里面塞个 div,加个 CSS 动画,3 秒后自动删掉。代码大概长这样:

// 简化版,实际项目有更多配置
let container = null;

function initContainer() {
  if (!container) {
    container = document.createElement('div');
    container.className = 'message-container';
    document.body.appendChild(container);
  }
}

function showMessage(text, type = 'info') {
  initContainer();
  const msg = document.createElement('div');
  msg.className = message message-${type};
  msg.textContent = text;
  container.appendChild(msg);

  // 淡入动画
  setTimeout(() => msg.classList.add('show'), 10);

  // 3秒后移除
  setTimeout(() => {
    msg.classList.remove('show');
    setTimeout(() => msg.remove(), 300); // 等动画结束再删
  }, 3000);
}
.message-container {
  position: fixed;
  top: 20px;
  right: 20px;
  z-index: 10000;
}

.message {
  padding: 8px 16px;
  margin-bottom: 8px;
  background: #fff;
  border-radius: 4px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.15);
  opacity: 0;
  transform: translateY(-10px);
  transition: all 0.3s;
}

.message.show {
  opacity: 1;
  transform: translateY(0);
}

跑起来没问题,但一压测就露馅了——快速点十几次按钮,DOM 节点疯狂堆积,内存蹭蹭涨。更糟的是,如果用户在消息消失前关掉页面,那些 setTimeout 还在后台跑,虽然不影响功能,但看着控制台一堆报错(试图操作已删除的 DOM),心里发毛。

最大的坑:性能问题和重复提示

第一个问题是性能。我原以为 Message 生命周期短,GC 会自动回收,但实测发现频繁创建/销毁 DOM 开销不小,尤其在低端安卓机上卡顿明显。后来改成**对象池**:预创建 5 个消息节点,用完放回池里复用,不用每次都 new DOM。关键代码:

const pool = [];
const MAX_POOL_SIZE = 5;

function getMessageFromPool() {
  return pool.length > 0 ? pool.pop() : document.createElement('div');
}

function returnMessageToPool(msg) {
  if (pool.length < MAX_POOL_SIZE) {
    msg.className = '';
    msg.textContent = '';
    pool.push(msg);
  } else {
    msg.remove(); // 池满了就真删
  }
}

// 在 showMessage 里用 getMessageFromPool() 代替 createElement
// 在销毁时用 returnMessageToPool(msg) 代替 msg.remove()

这招效果立竿见影,内存占用降了 60%,低端机也不卡了。

第二个坑是重复提示。比如用户连点“保存”,弹出 5 个“保存成功”。产品要求合并成“保存成功 x5”。我折腾了半天,最后用**防抖+计数器**搞定:相同内容的消息在 1 秒内只显示一个,后面来的只更新计数。难点在于怎么判断“相同内容”——不能只比字符串,因为“删除成功”和“删除成功”是一样的,但“删除 id=1 成功”和“删除 id=2 成功”就不一样。最后妥协:只对固定文案(如“操作成功”)做合并,带变量的不管。实现时用个 Map 缓存最近的消息:

const recentMessages = new Map(); // key: text, value: { count, timer }

function showMessage(text, type = 'info') {
  // 如果是可合并的文案(比如以"成功"结尾)
  if (isMergeable(text)) {
    if (recentMessages.has(text)) {
      const record = recentMessages.get(text);
      record.count++;
      // 更新显示文本
      record.element.textContent = ${text} x${record.count};
      // 重置倒计时
      clearTimeout(record.timer);
      record.timer = setTimeout(() => {
        destroyMessage(record.element, text);
      }, 3000);
      return;
    }
  }

  // ...创建新消息逻辑
  const element = createMessageElement(text, type);
  container.appendChild(element);

  let timer = setTimeout(() => {
    destroyMessage(element, text);
  }, 3000);

  if (isMergeable(text)) {
    recentMessages.set(text, { count: 1, element, timer });
  }
}

function destroyMessage(element, text) {
  element.classList.remove('show');
  setTimeout(() => {
    returnMessageToPool(element);
    if (isMergeable(text)) {
      recentMessages.delete(text);
    }
  }, 300);
}

这里注意我踩过好几次坑:忘记在销毁时清理 recentMessages,导致内存泄漏;还有合并时没考虑不同类型(比如 success 和 error 不该合并),后来加了 type 作为 key 的一部分才解决。

最终的解决方案

综合下来,现在的方案是:

  • 用对象池管理 DOM 节点,避免频繁创建
  • 对固定文案做 1 秒内的合并提示
  • 所有定时器在组件销毁时统一清理(虽然 Message 是全局的,但我在 SPA 路由切换时会手动清空容器)
  • 加了点击关闭功能:用户点消息就立刻消失,不用等 3 秒

点击关闭很简单,给每个消息加个 click 事件:

msg.addEventListener('click', () => {
  clearTimeout(timer); // 清掉倒计时
  destroyMessage(msg, text);
});

另外,为了适配不同场景,还加了配置项:持续时间、位置(top/right/bottom)、是否可关闭。调用时可以这么写:

showMessage('数据保存成功', {
  type: 'success',
  duration: 5000, // 5秒
  closable: true,
  position: 'top-center'
});

回顾与反思

整体效果还不错,用户没再抱怨消息刷屏,性能也达标。但有几个小问题没完全解决:

  • 合并提示的规则太死板,只能处理固定文案。如果产品哪天要合并“删除用户[张三]成功”这种,还得改逻辑。不过目前需求没到这一步,先放着。
  • 对象池大小写死为 5,极端情况(比如同时弹 10 个消息)还是会创建新节点。但实测 99% 场景够用,懒得动态扩容了。
  • 没做 SSR 兼容(因为是后台系统,全是 CSR),如果用在 Next.js 项目里得加 typeof window !== 'undefined' 判断。

其实 AntD 的 Message 也能通过 message.config 改位置、时长,但合并提示和对象池这种深度定制,还是自己写更灵活。这次折腾最大的收获是:**别小看简单组件,高频使用下性能细节决定体验**。以前总觉得“弹个消息而已,能有多大事”,现在看到 createElement 就手抖。

以上是我踩坑后的总结,希望对你有帮助。如果你们有更好的合并提示方案,或者遇到过类似性能问题,欢迎评论区交流!

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

暂无评论