推送通知实战:从Web Push到用户留存的完整技术方案

东方一泽 移动 阅读 1,903
赞 7 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

上个月搞一个移动端项目,用户反馈“一进通知页手机就发烫”“点一下卡三秒”。我一开始还不信,自己打开 devtools 看了一眼——好家伙,首屏加载花了 5 秒多,滚动列表还掉帧到 10fps。页面就一个推送通知列表,最多也就几十条数据,怎么会这么慢?

推送通知实战:从Web Push到用户留存的完整技术方案

后来发现,问题出在“通知渲染逻辑”上。每次收到新推送,前端都会直接把整张列表重新 render 一遍。更离谱的是,有些老代码里还在用 innerHTML 拼字符串,而且每条通知都绑了独立的事件监听器。用户刷个十几条,DOM 节点轻松破百,内存直接飙到 300MB+。

找到瓶颈了!

我先用 Chrome DevTools 的 Performance 面板录了一次加载过程。结果很明显:主线程被大量 DOM 操作占满,GC(垃圾回收)频繁触发,JS 执行时间占比高达 70%。再看 Memory 面板,heap size 持续增长,明显是内存泄漏的节奏。

接着用 React Profiler(项目用的是 React)跑了一下,发现每次更新都触发了整个通知容器的 re-render,哪怕只新增一条通知。翻代码一看,果然:状态管理写得太糙,通知列表存在顶层 context 里,但没做任何 memoization。

另外,网络请求也有问题。页面一进来就调 fetch('https://jztheme.com/api/notifications') 拉全量数据,而且不分页、不缓存。弱网下光等 API 就要 2 秒,再加上渲染卡顿,用户体验直接崩盘。

核心优化:从“全量重绘”到“增量更新”

折腾了半天,我决定从三个层面动手:

  • 减少 DOM 操作:用虚拟滚动 + 复用节点
  • 避免无效 re-render:拆分组件 + useMemo/useCallback
  • 优化数据获取:分页 + 缓存 + 骨架屏

其中最见效的是第一条。我试过直接用 react-window,但项目不是纯 React(混合了部分原生 JS),最后自己撸了个轻量级虚拟滚动,核心就几十行。

先看优化前的代码(简化版):

// 优化前:暴力重绘
function NotificationList({ notifications }) {
  return (
    <div className="list">
      {notifications.map(notif => (
        <div key={notif.id} onClick={() => handleRead(notif.id)}>
          <h3>{notif.title}</h3>
          <p>{notif.content}</p>
        </div>
      ))}
    </div>
  );
}

问题很明显:notifications 一变,整个列表全删全建。每条通知都新建 DOM,事件监听器也重复绑定。

优化后改成这样:

// 优化后:虚拟滚动 + memoized item
import { useMemo } from 'react';

const NotificationItem = React.memo(({ notif, onRead }) => {
  return (
    <div onClick={() => onRead(notif.id)}>
      <h3>{notif.title}</h3>
      <p>{notif.content}</p>
    </div>
  );
});

function NotificationList({ notifications }) {
  // 只计算可视区域内的索引
  const visibleRange = useVisibleRange(); // 自定义 hook,根据 scrollY 计算

  const visibleItems = useMemo(() => {
    return notifications.slice(visibleRange.start, visibleRange.end);
  }, [notifications, visibleRange]);

  return (
    <div className="list" style={{ height: ${notifications.length * ITEM_HEIGHT}px }}>
      <div style={{ transform: translateY(${visibleRange.start * ITEM_HEIGHT}px) }}>
        {visibleItems.map(notif => (
          <NotificationItem key={notif.id} notif={notif} onRead={handleRead} />
        ))}
      </div>
    </div>
  );
}

这里注意我踩过好几次坑:NotificationItem 必须用 React.memo 包裹,否则父组件 re-render 时子组件还是会全刷;另外 onRead 回调要用 useCallback 包住,不然引用变化也会导致子组件失效。

对于非 React 部分,我用原生 JS 实现了类似的复用逻辑:维护一个节点池,滚动时只更新内容,不重建元素。关键代码如下:

// 原生 JS 虚拟滚动片段
class VirtualList {
  constructor(container, items, renderItem) {
    this.container = container;
    this.items = items;
    this.renderItem = renderItem;
    this.visibleStart = 0;
    this.visibleCount = Math.ceil(container.clientHeight / ITEM_HEIGHT);
    
    this.pool = Array(this.visibleCount).fill(null).map(() => {
      const el = document.createElement('div');
      el.className = 'notification-item';
      container.appendChild(el);
      return el;
    });
    
    this.update();
  }

  update() {
    const scrollTop = this.container.scrollTop;
    const start = Math.floor(scrollTop / ITEM_HEIGHT);
    const end = Math.min(start + this.visibleCount, this.items.length);

    for (let i = 0; i < this.visibleCount; i++) {
      const idx = start + i;
      const itemEl = this.pool[i];
      if (idx < this.items.length) {
        itemEl.style.display = 'block';
        this.renderItem(itemEl, this.items[idx]); // 只更新内容
      } else {
        itemEl.style.display = 'none';
      }
    }
  }
}

其他小优化(带过)

除了核心的渲染逻辑,我还顺手改了几处:

  • API 分页:改成每次拉 20 条,用 IntersectionObserver 监听底部自动加载
  • 本地缓存:用 localStorage 存最近 50 条,进入页面先展示缓存内容,再后台刷新
  • 防抖上报:用户点击“已读”不再立即发请求,攒够 5 条或 2 秒后批量上报

这些改动成本低,但对弱网体验提升很明显。

性能数据对比

优化前后实测数据(中端安卓机,4G 网络):

  • 首屏加载时间:5.2s → 800ms(缓存+骨架屏)
  • 滚动帧率:平均 12fps → 稳定 58fps
  • 内存占用:峰值 320MB → 稳定在 90MB 左右
  • JS 主线程占用:70% → 25%

用户反馈也变了:“现在滑得飞快”“再也不卡了”。虽然还有个别低端机偶尔掉帧,但整体已经达标。

最后说两句

这次优化让我深刻体会到:移动端推送通知这种高频交互场景,**不能图省事直接 map 渲染**。哪怕数据量不大,也要考虑滚动性能和内存增长。虚拟滚动听起来高大上,其实核心思想很简单——只渲染看得见的,复用看不见的。

当然,我的方案也不是最优的。比如现在用的自研虚拟滚动没处理动态高度,如果通知内容长短不一就会错位。不过目前业务里通知都是固定两行,暂时够用。后续打算换成 react-virtual 或者 tanstack/virtual,更稳一点。

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

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

暂无评论