推送通知实战:从Web Push到用户留存的完整技术方案
优化前:卡得不行
上个月搞一个移动端项目,用户反馈“一进通知页手机就发烫”“点一下卡三秒”。我一开始还不信,自己打开 devtools 看了一眼——好家伙,首屏加载花了 5 秒多,滚动列表还掉帧到 10fps。页面就一个推送通知列表,最多也就几十条数据,怎么会这么慢?
后来发现,问题出在“通知渲染逻辑”上。每次收到新推送,前端都会直接把整张列表重新 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,更稳一点。
以上是我踩坑后的总结,希望对你有帮助。有更好的实现方式欢迎评论区交流!

暂无评论