网页导致手机发热如何排查?
场景
用户反馈打开某个 H5 页面几分钟后手机明显发热、耗电加快,随后还可能出现掉帧、操作延迟。作为前端负责人,如何确认问题确实由网页引起,定位是哪类资源持续繁忙,并验证修复有效?
我会把“发热”当作持续高能耗的外部现象,而不是直接认定为 JavaScript 问题:
- 固定设备、版本、亮度、网络、初始温度和操作脚本,排除充电、后台应用、弱信号等干扰;
- 用空白页、正常页、问题页以及新旧版本做 A/B,先证明增量来自该网页;
- 在真机录制 Performance / Safari Timelines,关联 CPU、脚本、渲染、GPU、网络和内存活动;
- 按 JS、渲染与 GPU、网络、内存与 GC、媒体与设备能力分层提出假设;
- 用 Feature Flag 或二分禁用模块,每次只改变一个变量,找到“关闭后能耗下降”的充分证据;
- 修复持续轮询、无限动画、过度绘制、重试风暴以及未释放的摄像头、定位、Wake Lock 等资源;
- 在不连接调试器的真机上长时间复测,以系统能耗、热状态、CPU、请求数和业务体验共同验收。
普通网页通常不能可靠读取手机温度,因此线上监控使用的是能耗代理指标;真实热状态需要实验室设备或 App / WebView 原生侧协助。
一、先理解“发热”的因果链
手机发热通常来自硬件长时间处于高功耗状态。网页可能持续占用 CPU、GPU、无线网络、摄像头、定位或屏幕,功耗最终转化为热量;温度升高后,系统又可能降频,于是页面进一步变卡。
因此需要避免两个错误推断:
- 手机发热,不代表根因一定在主线程;视频解码、合成、WebGL、网络或定位也可能没有明显 Long Task;
- 页面卡顿,不一定是最初根因;卡顿可能只是温度升高、系统降频后的结果。
手机外壳温度受到室温、保护壳、握持位置和芯片布局影响。排查需要同时观察工具指标、系统热状态和可重复的 A/B 结果。
二、建立可重复的对照实验
1. 补齐问题上下文
先确认:
- 设备型号、系统版本、浏览器或 WebView 版本;
- 前端构建版本、路由、账号、数据规模和操作步骤;
- 打开后多久开始发热,是前台使用还是切到后台后仍发热;
- 是否播放视频、使用地图、相机、麦克风、定位或 3D 内容;
- 是否在充电、弱信号、高亮度、高刷新率或高室温环境;
- 是否只在某个版本、机型、网络或业务数据下出现。
interface ThermalCase {
deviceModel: string;
osVersion: string;
browserOrWebView: string;
release: string;
route: string;
scenario: string;
dataScale?: string;
network: 'wifi' | 'cellular' | 'offline';
brightnessPercent: number;
charging: boolean;
ambientTemperature?: number;
timeToNoticeHeat?: number;
}
2. 固定测试变量
| 变量 | 控制方式 | 原因 |
|---|---|---|
| 初始状态 | 每轮测试前让设备自然冷却到接近相同状态 | 热降频会污染后续结果 |
| 供电 | 尽量不充电,记录起止电量 | 充电本身会发热 |
| 屏幕 | 固定亮度、刷新率和自动锁屏策略 | 屏幕功耗可能掩盖网页差异 |
| 网络 | 固定 Wi-Fi 或蜂窝网络与信号强度 | 弱信号会提高无线电功耗 |
| 系统 | 关闭无关后台任务和自动更新 | 减少系统噪声 |
| 页面 | 使用同一账号、数据和操作脚本 | 保证工作量一致 |
| 时间 | 预热后测试相同的持续时长 | 短测试看不到稳态问题 |
不要用冰袋等方式强行降温,这会改变系统调度条件,也有冷凝风险。
3. 设计四组基线
至少比较:
- 浏览器空白页或轻量静态页;
- 产品内已知正常的页面;
- 用户反馈的问题页面;
- 问题页面的旧版本与当前版本。
如果只有问题页持续高负载,才能继续把网页视为高概率来源;如果空白页也发热,应优先检查充电、系统任务、浏览器版本和设备环境。
桌面端设备模拟只能帮助发现功能与性能线索,不能模拟移动芯片、无线电、散热和系统热管理。最终结论必须来自真实设备。
三、建立跨层证据链
1. 浏览器侧
Android Chrome:
- 使用
chrome://inspect连接真机; - Performance 面板录制完整复现路径,查看 CPU 总览、Main 火焰图、Frames、GPU、Raster 和 Network;
- Performance Monitor 持续观察 CPU、JS Heap、DOM Node、事件监听器和 Layout 次数;
- Memory 面板比较 Heap Snapshot,检查对象、DOM、Canvas 或 Blob 是否持续增长;
- Network 面板检查轮询、重试、WebSocket 消息和大文件传输是否持续发生。
iOS Safari:
- 使用 Safari Web Inspector 连接真机;
- Timelines 关联 CPU、JavaScript & Events、Layout & Rendering、Media & Animations、Network 和 Memory;
- 根据 CPU 活动、定时器或
requestAnimationFrame的入口回到具体源码; - 用 JavaScript Allocations 快照确认持续分配和泄漏。
2. 系统或原生容器侧
- Android 可使用 System Tracing / Perfetto 观察调度、CPU 频率、线程和系统事件;
- 受支持的 Pixel 设备可用 Android Studio Power Profiler 观察设备级 Power Rails;
- App 内 WebView 可由原生侧记录 Android thermal status / headroom;
- iOS 原生容器可记录
ProcessInfo.thermalState,并与网页路由和操作时间点对齐。
系统能耗通常包含整机或进程噪声。必须用时间线、前后台状态和功能开关把它与网页操作相关联,不能看到设备功耗上升就直接归因于某段前端代码。
3. 调试器会改变被测对象
远程调试、详细内存采样和高级 Paint Instrumentation 都有额外开销,USB 连接还可能让设备充电。因此:
- 调试模式用于定位活动来源;
- 最终对比用于验证真实能耗,应关闭重型采样并保持供电条件一致;
- 保存 Trace、版本号、操作时间点和实验条件,避免只留一张温度截图。
四、按资源类型定位根因
| 层级 | 常见根因 | 关键证据 | 最小验证 |
|---|---|---|---|
| JS / CPU | 死循环、密集计算、频繁定时器、第三方脚本 | CPU 长时间繁忙,火焰图热点稳定 | 禁用模块或把调用频率降为零 |
| 渲染 / GPU | 无限动画、频繁重绘、大 Canvas/WebGL、过多合成层 | Frames、GPU、Raster、Paint 持续活跃 | 暂停动画或隐藏绘制区域 |
| 网络 | 轮询过密、重试风暴、埋点过多、大文件传输 | 空闲时仍有密集请求或持续吞吐 | 阻断接口或关闭同步功能 |
| 内存 / GC | 每帧分配、对象泄漏、图片和 Blob 未释放 | Heap 上升、GC 频繁、长时间不回落 | 重复进入退出页面并比较快照 |
| 媒体 / 设备 | 视频解码、摄像头、定位、传感器、Wake Lock | 权限指示、媒体轨道或系统能力持续活动 | 停止 Track、定位监听或锁屏保持 |
1. JavaScript 与 CPU
高风险代码包括:
- 没有退出条件的循环或递归微任务;
- 高频
setInterval、短间隔轮询和重复注册的监听器; - 每次渲染都进行大数组排序、序列化、压缩、加密或图片处理;
- 页面隐藏后仍在同步数据、计算图表或执行第三方 SDK;
- 多个模块各自维护心跳、刷新和重试调度器。
应在火焰图中回答三个问题:哪个入口唤醒任务、每次做了多少工作、为什么会持续发生。仅看到某个函数“很慢”还不够。
把计算移到 Worker 可以释放主线程、改善交互,但计算总量不变时 CPU 仍可能持续高负载,甚至增加通信和拷贝成本。降低能耗的核心仍是减少工作量、频率或持续时间;可转移大数据时再使用 Transferable Object。
2. 渲染、合成与 GPU
重点检查:
- 无视觉变化时
requestAnimationFrame循环是否仍持续运行; - CSS 动画、轮播、骨架屏或 Loading 动画是否永不停止;
- 滚动时是否频繁触发 Style、Layout、Paint;
- 大面积模糊、滤镜、阴影、透明叠加和固定背景是否造成高栅格化成本;
- Canvas/WebGL 是否按设备像素比创建了过大的缓冲区;
- 地图、图表、粒子效果是否在离屏或静止时仍重绘;
will-change是否长期保留,制造过多合成层和显存占用。
transform 和 opacity 通常能避免 Layout / Paint,但持续合成仍然需要 GPU 工作,所以“走 GPU”不等于“不会发热”。
3. 网络与无线电
网络请求除了消耗 CPU,还会反复唤醒 Wi-Fi 或蜂窝无线电。检查:
- 多个组件是否重复请求同一数据;
- 短轮询是否能改为按需刷新、推送或更长间隔;
- 失败重试是否有指数退避、抖动、上限和熔断;
- WebSocket 心跳与服务端推送频率是否合理;
- 埋点是否逐条发送而不是批量上报;
- 图片、视频、地图瓦片是否正确缓存和按可见区域加载;
- 弱网下是否出现下载取消、重新开始和无限重试。
4. 内存分配与 GC
内存问题不只会导致崩溃。持续分配会触发频繁 GC,解码后的图片、Canvas 缓冲和合成层还可能占用 JS Heap 之外的内存。
排查时重复执行“进入页面 → 操作 → 离开页面”:
- 对象数量和 DOM Node 能否回到稳定平台;
- Detached DOM、事件监听器、定时器和闭包是否被保留;
URL.createObjectURL()是否对应调用URL.revokeObjectURL();- 图片、视频帧、ArrayBuffer 和 Canvas 尺寸是否有上限;
- Heap 的锯齿上沿是否持续抬高,GC 是否越来越密集。
更完整的方法见内存泄漏排查。
5. 媒体、定位和设备能力
常被遗漏的资源包括:
- 高清或高帧率视频持续解码,离屏后仍播放;
- 摄像头、麦克风的
MediaStreamTrack用完后没有stop(); watchPosition()长时间开启高精度定位且没有clearWatch();- Device Motion、Orientation 等传感器监听未移除;
- Web Audio 节点或
AudioContext没有暂停或关闭; - Screen Wake Lock 超出业务所需时长,屏幕无法熄灭。
权限只应在用户需要的阶段启用,并在完成、取消、路由离开和异常路径中对称释放。
五、用生命周期统一管理持续任务
持续任务散落在组件中,很容易出现“启动了但没有停止”。可以把前后台切换和资源释放收敛为统一抽象:
export type Disposer = () => void;
export interface ResourceScope {
add(disposer: Disposer): void;
}
class ManagedResourceScope implements ResourceScope {
private readonly disposers = new Set<Disposer>();
add(disposer: Disposer): void {
this.disposers.add(disposer);
}
dispose(): void {
const disposers = [...this.disposers];
this.disposers.clear();
for (const disposer of disposers) {
try {
disposer();
} catch (error) {
// 单个资源释放失败时,仍继续释放其余资源。
console.error('Failed to dispose page resource', error);
}
}
}
}
export class VisibilityAwareRuntime {
private scope: ManagedResourceScope | null = null;
constructor(private readonly start: (scope: ResourceScope) => void) {
document.addEventListener('visibilitychange', this.sync);
this.sync();
}
private readonly sync = (): void => {
this.scope?.dispose();
this.scope = null;
if (document.visibilityState === 'visible') {
this.scope = new ManagedResourceScope();
this.start(this.scope);
}
};
destroy(): void {
document.removeEventListener('visibilitychange', this.sync);
this.scope?.dispose();
this.scope = null;
}
}
业务模块注册资源时,同时注册清理动作:
import { VisibilityAwareRuntime } from './visibility-aware-runtime';
export function startDashboardRuntime(): () => void {
const runtime = new VisibilityAwareRuntime((scope) => {
const controller = new AbortController();
scope.add(() => controller.abort());
let animationFrame = 0;
const scheduleRender = (): void => {
// 数据没有变化时不创建下一帧,避免永久动画循环。
if (animationFrame !== 0) return;
animationFrame = requestAnimationFrame(() => {
animationFrame = 0;
drawDashboard();
});
};
scope.add(() => cancelAnimationFrame(animationFrame));
let pollTimer = 0;
const poll = async (): Promise<void> => {
try {
const response = await fetch('/api/dashboard', {
signal: controller.signal,
});
updateDashboard(await response.json());
scheduleRender();
} catch (error) {
if (!controller.signal.aborted) console.error(error);
}
if (!controller.signal.aborted) {
// 使用串行 setTimeout,避免请求时间超过间隔后任务堆积。
pollTimer = window.setTimeout(poll, 30_000);
}
};
void poll();
scope.add(() => window.clearTimeout(pollTimer));
});
// React Effect、Vue onUnmounted 或路由卸载时调用返回的清理函数。
return () => runtime.destroy();
}
示例强调的是“启动与释放成对”,不是让所有页面都同时轮询和动画。静止内容应采用事件驱动,只在状态变化时绘制。
六、线上监控只能采集代理指标
网页侧可以低开销聚合 Long Task 和 Long Animation Frame,辅助发现持续主线程压力:
interface RuntimePressure {
longTaskCount: number;
longTaskDuration: number;
longFrameCount: number;
longFrameDuration: number;
}
export function observeRuntimePressure(
report: (sample: RuntimePressure) => void,
): () => void {
const sample: RuntimePressure = {
longTaskCount: 0,
longTaskDuration: 0,
longFrameCount: 0,
longFrameDuration: 0,
};
const observers: PerformanceObserver[] = [];
const supported = new Set(PerformanceObserver.supportedEntryTypes ?? []);
const observe = (
type: 'longtask' | 'long-animation-frame',
onEntry: (entry: PerformanceEntry) => void,
): void => {
if (!supported.has(type)) return;
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach(onEntry);
});
observer.observe({ type, buffered: true });
observers.push(observer);
};
observe('longtask', (entry) => {
sample.longTaskCount += 1;
sample.longTaskDuration += entry.duration;
});
observe('long-animation-frame', (entry) => {
sample.longFrameCount += 1;
sample.longFrameDuration += entry.duration;
});
const flush = (): void => {
if (sample.longTaskCount === 0 && sample.longFrameCount === 0) return;
report({ ...sample });
sample.longTaskCount = 0;
sample.longTaskDuration = 0;
sample.longFrameCount = 0;
sample.longFrameDuration = 0;
};
// 只定期上传聚合结果,避免监控本身制造高频网络活动。
const timer = window.setInterval(flush, 30_000);
return () => {
window.clearInterval(timer);
observers.forEach((observer) => observer.disconnect());
flush();
};
}
这些 API 存在兼容性限制,也不能覆盖 GPU、视频解码、网络无线电和系统温度。它们只能作为信号,不能作为“页面没有发热问题”的证明。
Long Task 与 LoAF 可能描述同一段主线程压力,统计时应分别观察,不能把两者时长相加当作 CPU 时间。
推荐同时按路由、版本、设备档位和页面可见性聚合:
- Long Task / LoAF 数量与总时长;
- INP、掉帧和交互失败率;
- 空闲期请求数、传输字节和重试次数;
- DOM Node、监听器和 JS Heap 的增长趋势;
- 视频、相机、定位、Wake Lock 等功能的开启时长;
- App / WebView 场景下,由原生侧提供的热状态和低电量状态。
七、修复与验收
1. 常见修复策略
| 问题 | 修复策略 |
|---|---|
| 高频任务 | 事件驱动、合并调度、降低频率、结果缓存、提前退出 |
| 大计算 | 减少输入规模、增量计算、算法优化;Worker 用于隔离而非替代减负 |
| 无限动画 | 状态不变即停止,离屏/隐藏暂停,支持 prefers-reduced-motion |
| 过度绘制 | 降低分辨率和 DPR、缩小绘制区域、减少滤镜与合成层 |
| 网络风暴 | 请求去重、缓存、批量、指数退避、熔断和弱网降级 |
| 内存增长 | 对称解绑、对象池谨慎复用、释放 Blob / Canvas / 媒体资源 |
| 设备能力 | 最小权限时长,停止 Track、定位、音频上下文和 Wake Lock |
| 第三方脚本 | 延迟或按需加载、采样降频、超预算隔离或下线 |
2. 验收不要只看“摸起来凉了”
在相同环境下对比修复前后:
- 问题路径的 CPU / GPU / Network 活动是否显著下降;
- 页面静止或切到后台后是否接近空闲;
- 单位时间请求数、传输量、长任务和 GC 是否下降;
- 系统热状态、功耗或电量下降曲线是否改善;
- 温升后是否仍出现降频、掉帧和交互延迟;
- 核心业务功能和实时性是否满足要求。
至少覆盖低端 Android、主流 Android 和 iPhone,并重复多轮。不要制定一个适用于所有机型的绝对温度阈值,应以同机型、同场景相对基线和业务预算为主。
3. 建立长期防线
- 为重页面设置 CPU 时间、长任务、请求频率、内存和动画预算;
- 将“页面隐藏后是否安静”加入 E2E 与人工回归;
- 持续任务统一注册、可观测、可暂停、可销毁;
- 第三方 SDK 必须有负责人、采样策略和性能预算;
- 原生容器将热状态与路由版本对齐上报,严重时触发低功耗降级;
- 发布后按设备档位灰度,观察能耗代理指标与业务护栏。
常见面试问题
Q1: 为什么不能只用 Lighthouse 排查手机发热?
答案:Lighthouse 主要分析一次加载和用户体验指标,无法完整覆盖页面运行数分钟后的持续动画、轮询、视频解码、定位、WebSocket、后台活动和系统热状态。发热问题需要真机长时间 Trace 与受控 A/B。
Q2: 网页能直接读取手机温度吗?
答案:普通 Web 平台没有可靠、通用的温度 API。不要把电池 API、电量变化或某个私有接口当作温度。App / WebView 可由原生层读取系统热状态后,经过隐私和安全评审向网页提供受控能力。
Q3: 主线程没有 Long Task,为什么手机仍然发热?
答案:负载可能在 Worker、GPU、合成线程、视频解码、网络、定位或摄像头上;也可能由很多小于 50ms 的任务持续累积。继续检查 CPU 总览、LoAF、GPU/Raster、网络、媒体和系统 Trace。
Q4: 把计算全部移到 Web Worker 能解决发热吗?
答案:不一定。Worker 改善主线程响应,但不自动减少计算量,持续占满另一个核心仍会耗电发热。应先减少工作,再决定是否通过 Worker 隔离剩余计算。
Q5: 为什么页面切到后台后仍要主动停止任务?
答案:浏览器会节流后台定时器和暂停多数 requestAnimationFrame,但媒体、网络、Worker、权限能力或不同 WebView 的策略不完全相同。业务显式释放可以保证资源生命周期清晰,也便于测试和监控。
Q6: will-change 和 GPU 加速是否能降低发热?
答案:不能保证。合成可以减少 Layout 和 Paint,但持续合成仍消耗 GPU;滥用 will-change 还会增加合成层与显存。应通过 Trace 比较总工作量,而不是根据属性名称推断能耗。
Q7: 如何区分是 H5 还是 WebView 原生壳导致发热?
答案:在同设备上对比系统浏览器打开同 URL、WebView 打开轻量静态页、WebView 打开问题页,再分别关闭 JS 模块和原生能力。结合进程/线程 Trace、原生热状态和 JS 时间点确定归属。
Q8: 线上大面积反馈发热时,第一步是什么?
答案:先评估是否与刚发布版本或某个高耗能功能相关。如果影响明显,可通过 Feature Flag 关闭动画、地图、视频、定位或高频同步等可疑功能止血,同时保留对照组和诊断证据,再继续定位。
相关链接
- Chrome DevTools:分析运行时性能
- Chrome DevTools:Performance Monitor
- Chrome DevTools:远程调试 Android 设备
- WebKit Web Inspector:Timelines
- WebKit:CPU Timeline 与 Energy Impact
- Android:Power Profiler
- Android:在设备上采集 System Trace
- Android:PowerManager 热状态 API
- MDN:Page Visibility API
- MDN:Long Tasks API
- MDN:Long Animation Frames
- MDN:Screen Wake Lock API
- 运行时卡顿排查
- 移动端性能问题排查
- 第三方脚本性能治理