跳到主要内容

调试能力

如果用户说 web 应用感觉很反应慢或者卡顿,该如何排查?

如果用户觉得 web 应用反应卡顿, 主要从哪几个方面来排查?

  • 加载慢
  • 资源下载慢
  • 首屏并发请求资源过多
  • 首屏接口慢
  • 首屏对应的 JS 执行慢
  • 首屏渲染慢
  • 首屏加载静态资源过大
  • .......
  • 执行过程慢
  • 接口慢
  • long tasks 太多, 阻塞 JS 执行
  • 内存泄漏
  • 重绘重排 过多
  • 关键节点没有加 节流防抖
  • .......

主要排查手段有哪些

  • 通过建立性能监控指标: 通过真实用户数据反馈, 来判断用户是否卡顿, 包含网络监控、运行时性能监控

  • Chrome devtools: NetWork 主要排查网络问题

image
  • Chrome devtools: Performance 主要细查性能运行时性能,包含了 long tasks、render 次数、重排重绘、执行时间线、阻塞场景
image
  • Chrome devtools: Performance monitor 主要监控用户运行时性能,看看是否有内存泄露
image
  • React Developer Tools: 可以用于追踪 react 应用性能、渲染次数、重排重绘 image

  • Lighthouse: 全面分析网页性能的一个工具、支持浏览器插件 image

  • webpack-bundle-analyzer: 进行产物依赖分析、包大小分析

  • 抓包: 通过抓包的方式, 看看线上请求分析、请求模拟、网络劫持之后仅仅看 JS 执行时间

  • E2E测试: 通过 E2E 进行性能预检, 每次上线前进行一系列系统操作, 看看时间耗时和线上耗时波动

主要解决办法和思路

首屏加载慢的方向

  • 资源加载方向

  • 使用 tree shaking 减少包体积

  • 代码压缩和混淆

  • 对于高版本浏览器, 直接使用 ES6 语法,低版本浏览器再使用 ES5(es6 语法代码量会比编译成 es5 代码量小很多, 且执行速度也快)

  • 使用 split chunks 进行大包拆分、小包复用

  • 使用 gzip

  • 使用 图片压缩

  • 使用 雪碧图

  • 图标使用 iconfont 加载

  • 懒加载, 仅加载首屏必要资源

  • 使用 tailwindcss 等技术, 复用 css

  • 使用 微前端 技术,首屏仅加载当前子应用页面,可以做到只加载整站很少的一部分代码

  • 首屏非必要依赖尽量延后到 FMP 或者 TTI 之后再加载

  • 组件微前端化

  • 渲染方向

  • 尽量减少重排重绘

  • 减少重复渲染(useMemo、useCallback、memo 等)

  • 减少 setState 次数(多次 setState 可以合并为一次)

  • 尽量减少 dom 节点深度

  • 网络方向

  • 使用流式服务端渲染, 可以查看文档:资料

  • 使用服务端渲染, 减少首屏请求

  • 使用 SSG 静态站点生成

  • 首屏必要数据, 不作客户端请求, 用后端模板注入

  • 使用 BFF 进行请求聚合

  • 使用 CDN 进行网络请求分发

  • DNS Prefetch

  • 资源预加载(在闲暇时间加载后续页面所需要的资源和接口,例如:link rel preload)

  • 启用 HTTP2 多路复用

  • 在业务逻辑上, 首屏必要接口提前(例如在 html 加载的那一瞬间,利用一个非常小的 js 文件将首屏需要的请求发送出去, 然后缓存下来, 到业务使用的时候直接就使用即可)

  • 使用缓存技术缓存资源与请求:强缓存、协商缓存、离线缓存、Service Worker 缓存、后端业务缓存

运行时卡顿方向

  • 查看是否存在有有 long tasks, 有计划的拆解 long tasks
  • 解决项目中复杂度问题: 资料
  • 排查项目是否有内存泄露
  • 排查特定业务流程是否有慢接口
  • 高复杂计算逻辑放在 service worker 处理

参考文档

如何定位一个偶现的问题?

第一时间看看有没有较好的现场日志和数据能帮助定位问题 给到相应的设备信息和时间,能否根据描述的现象找到相应的日志定位,反向 push 自己尝试定位复现,找到合适的日志,如果多次操作仍不能马上复现,评估是否要挂起,看问题的出现概率和严重程度 如果是体验相关问题,最终判断概率极低,那么灰度期间,持续观察数据,线上进行监控

如何禁止别人调试自己的前端页面代码?

  • 前端页面防止调试的方法主要是通过不断 debugger 来疯狂输出断点,因为 debugger 在控制台被打开的时候就会执行
  • 由于程序被 debugger 阻止,所以无法进行断点调试,所以网页的请求也是看不到的
  • 基础代码如下:
/**
* 基础禁止调试代码
*/
(() => {
function ban () {
setInterval(() => {
// eslint-disable-next-line
debugger
}, 50)
}
try {
ban()
} catch (err) { }
})()

无限 debugger 的对策

  • 如果仅仅是加上面那么简单的代码,对于一些技术人员而言作用不大
  • 可以通过控制台中的 Deactivate breakpoints 按钮或者使用快捷键 Ctrl + F8 关闭无限 debugger
  • 这种方式虽然能去掉碍眼的 debugger,但是无法通过左侧的行号添加 breakpoint

禁止断点的对策

  • 如果将 setInterval 中的代码写在一行,就能禁止用户断点,即使添加 logpointfalse 也无用
  • 当然即使有些人想到用左下角的格式化代码,将其变成多行也是没用的
(() => {
function ban () {
setInterval(() => {
// debugger
}, 50)
}
try {
ban()
} catch (err) { }
})()

忽略执行的代码

  • 通过添加 add script ignore list 需要忽略执行代码行或文件
  • 也可以达到禁止无限 debugger

忽略执行代码的对策

  • 那如何针对上面操作的恶意用户呢
  • 可以通过将 debugger改写成 Function("debugger")(); 的形式来应对
  • Function 构造器生成的 debugger 会在每一次执行时开启一个临时 js 文件
  • 当然使用的时候,为了更加的安全,最好使用加密后的脚本
// 加密前
(() => {
function ban () {
setInterval(() => {
// eslint-disable-next-line
Function('debugger')()
}, 50)
}
try {
ban()
} catch (err) { }
})()

// 加密后
// eslint-disable-next-line
eval(function (c, g, a, b, d, e) { d = String; if (!''.replace(/^/, String)) { for (;a--;)e[a] = b[a] || a; b = [function (f) { return e[f] }]; d = function () { return '\w+' }; a = 1 } for (;a--;)b[a] && (c = c.replace(new RegExp('\b' + d(a) + '\b', 'g'), b[a])); return c }('(()=>{1 0(){2(()=>{3("4")()},5)}6{0()}7(8){}})();', 9, 9, 'block function setInterval Function debugger 50 try catch err'.split(' '), 0, {}))

终极增强防调试代码

  • 为了让自己写出来的代码更加的晦涩难懂,需要对上面的代码再优化一下
  • Function('debugger').call()改成 (function(){return false;})['constructor']('debugger')['call']();
  • 并且添加条件,当窗口外部宽高和内部宽高的差值大于一定的值 ,我把 body 里的内容换成指定内容
  • 当然使用的时候,为了更加的安全,最好加密后再使用
(() => {
function block () {
if (window.outerHeight - window.innerHeight > 200 || window.outerWidth - window.innerWidth > 200) {
document.body.innerHTML = '检测到非法调试,请关闭后刷新重试!'
}
setInterval(() => {
(function () {
return false
}
.constructor('debugger')
.call())
}, 50)
}
try {
block()
} catch (err) { }
})()

参考文档

用户访问页面白屏了, 原因是啥, 如何排查?

用户访问页面白屏可能由多种原因引起,以下是一些可能的原因和排查步骤:

  1. 网络问题:用户的网络连接可能存在问题,无法正确加载页面内容。可以要求用户检查网络连接,或者自己尝试在不同网络环境下测试页面的加载情况。

  2. 服务端问题:服务器未正确响应用户请求,导致页面无法加载。可以检查服务器的状态、日志和错误信息,查看是否有任何异常。同时,可以确认服务器上的相关服务是否正常运行。

  3. 前端代码问题:页面的前端代码可能存在错误或异常,导致页面无法正常渲染。可以检查浏览器的开发者工具,查看是否有任何错误信息或警告。同时,可以尝试将页面的JavaScript、CSS和HTML代码分离出来进行单独测试,以确定具体的问题所在。

  4. 浏览器兼容性问题:不同浏览器对于某些代码的支持可能不一致,导致页面在某些浏览器中无法正常加载。可以尝试在不同浏览器中测试页面的加载情况,同时使用浏览器的开发者工具检查是否有任何错误或警告。

  5. 第三方资源加载问题:页面可能依赖于某些第三方资源(如外部脚本、样式表等),如果这些资源无法加载,可能导致页面白屏。可以检查网络请求是否正常,是否有任何资源加载失败的情况。

  6. 缓存问题:浏览器可能在缓存中保存了旧版本的页面或资源,导致新版本无法加载。可以尝试清除浏览器缓存,或者通过添加随机参数或修改文件名的方式强制浏览器重新加载页面和资源。

  7. 其他可能原因:页面白屏问题还可能由于安全策略(如CSP、CORS等)限制、跨域问题、DNS解析问题等引起。可以使用浏览器的开发者工具检查网络请求和错误信息,查找可能的问题。

在排查问题时,可以根据具体情况逐步进行排查,并结合浏览器的开发者工具、服务器日志等工具来辅助定位问题所在,并且可以与用户进行进一步沟通以获取更多信息。如果问题无法解决,可以寻求专业的技术支持或咨询。

jank

排查网页卡顿问题时,可以按照以下步骤进行处理:

  1. 验证用户反馈:首先,确认用户反馈的卡顿问题是否普遍存在,还是个别用户的特殊情况。可以与其他用户进行沟通或观察其他设备上的表现。

  2. 检查网络连接:检查用户的网络连接是否稳定。卡顿问题可能由于网络延迟或不稳定导致。可以要求用户进行网络速度测试,或者与用户确认网络连接是否正常。

  3. 检查服务器性能:确认服务器是否能够处理用户的请求。可以检查服务器的负载情况、处理请求的时间以及资源使用情况。如果服务器负载过高,可能导致网页卡顿。

  4. 检查前端代码:检查网页的前端代码是否存在问题。主要看是否有内存泄露、long task 等情况; 关于内存泄露和 long task 可以看下面两个文档:

  1. 优化网页性能:对于前端代码存在性能问题的情况,可以尝试优化网页性能。例如,压缩和合并CSS和JavaScript文件、减少网络请求次数、使用缓存等方法来提高页面加载速度。 可以参考下面文档文档:
  1. 做好性能监控:持续监测网页的性能,并定期更新网页的代码和设计,以提升用户体验。

source map

Source Map(源映射)作用

Source Map(源映射)是一种文件,用于将压缩、混淆或编译后的代码映射回原始的源代码,以便在调试过程中能够直接查看和调试源代码。它提供了压缩文件和源文件之间的映射关系,包括每个压缩文件中的代码位置、原始文件的路径和行号等信息。

Source Map的主要作用如下:

  1. 调试:在开发过程中,源代码经常会被压缩、合并或转换为其他形式的代码,这使得在调试时直接查看和调试源代码变得困难。Source Map提供了一种方式,通过将压缩代码映射回源代码,开发者可以在调试器中直接查看和调试原始的、易于理解的源代码。

  2. 错误追踪:当发生错误或异常时,浏览器或运行环境会提供错误信息,其中包含了压缩后的代码行号和列号。Source Map可以将这些行号和列号映射回源代码的行号和列号,帮助开发者定位和追踪错误。

  3. 性能分析:Source Map可以提供压缩文件中每个代码片段对应的原始文件位置信息,这对于性能分析工具来说非常有用。性能分析工具可以使用Source Map来将性能数据映射回源代码,以便更准确地分析和优化代码性能。

Source Map的原理是通过在压缩文件中添加特定的注释或者生成独立的.map文件来存储映射关系。在调试过程中,浏览器或调试器会读取Source Map,并根据其中的映射关系将压缩代码中的行号、列号等信息映射回源代码的对应位置。

Source Map(源映射)实现原理

Source Map 的实现原理可以简单描述如下:

  1. 生成 Source Map:在代码的压缩、混淆或编译过程中,生成器会创建一个 Source Map 对象,并收集相关的映射信息。这些信息包括原始文件路径、行号、列号以及对应的压缩文件中的位置信息。

  2. 生成编码字符串:将收集到的映射信息使用 VLQ(Variable Length Quantity)编码进行压缩,将数字转换为可变长度的 Base64 编码字符串。VLQ 编码能够通过特定的规则将数字转换为可变长度的字符串,以减小 Source Map 的体积。

  3. 关联 Source Map:在生成的压缩文件中,通过注释或独立的 .map 文件将 Source Map 关联到压缩文件。注释方式可以通过特定的注释语法将编码字符串直接嵌入到压缩文件中,而独立的 .map 文件则将编码字符串保存在一个独立的文件中。

  4. 调试时使用 Source Map:在调试过程中,当开发者需要查看或调试源代码时,浏览器或调试工具会加载关联的 Source Map 文件,根据映射关系将压缩文件中的位置信息映射回源代码的对应位置。

通过这种方式,Source Map 实现了将压缩后的代码映射回原始源代码的功能,使得在调试、错误追踪和性能分析时能够更方便地操作和理解源代码。实际上,Source Map 的实现会有更多的细节和规范,但以上是其基本的实现原理概述。

内促泄漏定位

该问题转载自:资料

内存泄漏指由于疏忽或错误造成程序未能释放已经不再使用的内存。内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,导致在释放该段内存之前就失去了对该段内存的控制,从而造成了内存的浪费。内存泄漏通常情况下只能由获得程序源代码的程序员才能分析出来。然而,有不少人习惯于把任何不需要的内存使用的增加描述为内存泄漏,即使严格意义上来说这是不准确的。 ————wikipedia

⚠️ 注:下文中标注的 CG 是 Chrome 浏览器中 Devtools 的【Collect garbage】按钮缩写,表示回收垃圾操作。 image

外的全局变量

JavaScript 对未声明变量的处理方式:在全局对象上创建该变量的引用(即全局对象上的属性,不是变量,因为它能通过delete删除)。如果在浏览器中,全局对象就是window对象。

如果未声明的变量缓存大量的数据,会导致这些数据只有在窗口关闭或重新刷新页面时才能被释放。这样会造成意外的内存泄漏。

function foo (arg) {
bar = 'this is a hidden global variable with a large of data'
}

等同于:

function foo (arg) {
window.bar = 'this is an explicit global variable with a large of data'
}

另外,通过this创建意外的全局变量:

function foo () {
this.variable = 'potential accidental global'
}

// 当在全局作用域中调用foo函数,此时this指向的是全局对象(window),而不是'undefined'
foo()

解决方法

在 JavaScript 文件中添加'use strict',开启严格模式,可以有效地避免上述问题。

function foo (arg) {
'use strict' // 在foo函数作用域内开启严格模式
bar = 'this is an explicit global variable with a large of data' // 报错:因为bar还没有被声明
}

如果需要在一个函数中使用全局变量,可以像如下代码所示,在window上明确声明:

function foo (arg) {
window.bar = 'this is a explicit global variable with a large of data'
}

这样不仅可读性高,而且后期维护也方便

谈到全局变量,需要注意那些用来临时存储大量数据的全局变量,确保在处理完这些数据后将其设置为 null 或重新赋值。全局变量也常用来做 cache,一般 cache 都是为了性能优化才用到的,为了性能,最好对 cache 的大小做个上限限制。因为 cache 是不能被回收的,越高 cache 会导致越高的内存消耗。

onsole.log

console.log:向 web 开发控制台打印一条消息,常用来在开发时调试分析。有时在开发时,需要打印一些对象信息,但发布时却忘记去掉console.log语句,这可能造成内存泄露。

在传递给console.log的对象是不能被垃圾回收 ♻️,因为在代码运行之后需要在开发工具能查看对象信息。所以最好不要在生产环境中console.log任何对象。

实例

<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Leaker</title>
</head>

<body>
<input type="button" value="click">
<script>
!function () {
function Leaker() {
this.init();
};
Leaker.prototype = {
init: function () {
this.name = (Array(100000)).join('*');
console.log("Leaking an object %o: %o", (new Date()), this);// this对象不能被回收
},

destroy: function () {
// do something....
}
};
document.querySelector('input').addEventListener('click', function () {
new Leaker();
}, false);
}()
</script>
</body>

</html>

这里结合 Chrome 的 Devtools–>Performance 做一些分析,操作步骤如下:

⚠️注:最好在隐藏窗口中进行分析工作,避免浏览器插件影响分析结果
  1. 开启【Performance】项的记录
  2. 执行一次 CG,创建基准参考线
  3. 连续单击【click】按钮三次,新建三个 Leaker 对象
  4. 执行一次 CG
  5. 停止记录

image

可以看出【JS Heap】线最后没有降回到基准参考线的位置,显然存在没有被回收的内存。如果将代码修改为:

!(function () {
function Leaker () {
this.init()
}
Leaker.prototype = {
init: function () {
this.name = Array(100000).join('*')
},

destroy: function () {
// do something....
}
}
document.querySelector('input').addEventListener(
'click',
function () {
new Leaker()
},
false
)
})()

去掉console.log("Leaking an object %o: %o", (new Date()), this);语句。重复上述的操作步骤,分析结果如下:

image

从对比分析结果可知,console.log打印的对象是不会被垃圾回收器回收的。因此最好不要在页面中console.log任何大对象,这样可能会影响页面的整体性能,特别在生产环境中。除了console.log外,另外还有console.dirconsole.errorconsole.warn等都存在类似的问题,这些细节需要特别的关注。

losures(闭包)

当一个函数 A 返回一个内联函数 B,即使函数 A 执行完,函数 B 也能访问函数 A 作用域内的变量,这就是一个闭包——————本质上闭包是将函数内部和外部连接起来的一座桥梁。

function foo (message) {
function closure () {
console.log(message)
}
return closure
}

// 使用
const bar = foo('hello closure!')
bar() // 返回 'hello closure!'

在函数 foo 内创建的函数 closure 对象是不能被回收掉的,因为它被全局变量 bar 引用,处于一直可访问状态。通过执行bar()可以打印出hello closure!。如果想释放掉可以将bar = null即可。

由于闭包会携带包含它的函数的作用域,因此会比其他函数占用更多的内存。过度使用闭包可能会导致内存占用过多。

实例

<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Closure</title>
</head>

<body>
<p>不断单击【click】按钮</p>
<button id="click_button">Click</button>
<script>
function f() {
var str = Array(10000).join('#');
var foo = {
name: 'foo'
}
function unused() {
var message = 'it is only a test message';
str = 'unused: ' + str;
}
function getData() {
return 'data';
}
return getData;
}

var list = [];

document.querySelector('#click_button').addEventListener('click', function () {
list.push(f());
}, false);
</script>
</body>

</html>

这里结合 Chrome 的 Devtools->Memory 工具进行分析,操作步骤如下:

⚠️注:最好在隐藏窗口中进行分析工作,避免浏览器插件影响分析结果
  1. 选中【Record allocation timeline】选项
  2. 执行一次 CG
  3. 单击【start】按钮开始记录堆分析
  4. 连续单击【click】按钮十多次
  5. 停止记录堆分析

image

上图中蓝色柱形条表示随着时间新分配的内存。选中其中某条蓝色柱形条,过滤出对应新分配的对象:

image

查看对象的详细信息:

image

从图可知,在返回的闭包作用链(Scopes)中携带有它所在函数的作用域,作用域中还包含一个 str 字段。而 str 字段并没有在返回 getData()中使用过。为什么会存在在作用域中,按理应该被 GC 回收掉, why

原因是在相同作用域内创建的多个内部函数对象是共享同一个变量对象(variable object)。如果创建的内部函数没有被其他对象引用,不管内部函数是否引用外部函数的变量和函数,在外部函数执行完,对应变量对象便会被销毁。反之,如果内部函数中存在有对外部函数变量或函数的访问(可以不是被引用的内部函数),并且存在某个或多个内部函数被其他对象引用,那么就会形成闭包,外部函数的变量对象就会存在于闭包函数的作用域链中。这样确保了闭包函数有权访问外部函数的所有变量和函数。了解了问题产生的原因,便可以对症下药了。对代码做如下修改:

function f () {
const str = Array(10000).join('#')
const foo = {
name: 'foo'
}
function unused () {
const message = 'it is only a test message'
// str = 'unused: ' + str; //删除该条语句
}
function getData () {
return 'data'
}
return getData
}

const list = []

document.querySelector('#click_button').addEventListener(
'click',
function () {
list.push(f())
},
false
)

getData()和 unused()内部函数共享 f 函数对应的变量对象,因为 unused()内部函数访问了 f 作用域内 str 变量,所以 str 字段存在于 f 变量对象中。加上 getData()内部函数被返回,被其他对象引用,形成了闭包,因此对应的 f 变量对象存在于闭包函数的作用域链中。这里只要将函数 unused 中str = 'unused: ' + str;语句删除便可解决问题。

image

查看一下闭包信息:

image

OM 泄露

在 JavaScript 中,DOM 操作是非常耗时的。因为 JavaScript/ECMAScript 引擎独立于渲染引擎,而 DOM 是位于渲染引擎,相互访问需要消耗一定的资源。如 Chrome 浏览器中 DOM 位于 WebCore,而 JavaScript/ECMAScript 位于 V8 中。假如将 JavaScript/ECMAScript、DOM 分别想象成两座孤岛,两岛之间通过一座收费桥连接,过桥需要交纳一定“过桥费”。JavaScript/ECMAScript 每次访问 DOM 时,都需要交纳“过桥费”。因此访问 DOM 次数越多,费用越高,页面性能就会受到很大影响。了解更多ℹ️

为了减少 DOM 访问次数,一般情况下,当需要多次访问同一个 DOM 方法或属性时,会将 DOM 引用缓存到一个局部变量中。但如果在执行某些删除、更新操作后,可能会忘记释放掉代码中对应的 DOM 引用,这样会造成 DOM 内存泄露。

实例------>

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Dom-Leakage</title>
</head>
<body>
<input type="button" value="remove" class="remove" style="display:none;">
<input type="button" value="add" class="add">

<div class="container">
<pre class="wrapper"></pre>
</div>
<script>
// 因为要多次用到pre.wrapper、div.container、input.remove、input.add节点,将其缓存到本地变量中,
var wrapper = document.querySelector('.wrapper');
var container = document.querySelector('.container');
var removeBtn = document.querySelector('.remove');
var addBtn = document.querySelector('.add');
var counter = 0;
var once = true;
// 方法
var hide = function(target){
target.style.display = 'none';
}
var show = function(target){
target.style.display = 'inline-block';
}
// 回调函数
var removeCallback = function(){
removeBtn.removeEventListener('click', removeCallback, false);
addBtn.removeEventListener('click', addCallback, false);
hide(addBtn);
hide(removeBtn);
container.removeChild(wrapper);
}
var addCallback = function(){
wrapper.appendChild(document.createTextNode('\t' + ++counter + ':a new line text\n'));
// 显示删除操作按钮
if(once){
show(removeBtn);
once = false;
}
}
// 绑定事件
removeBtn.addEventListener('click', removeCallback, false);
addBtn.addEventListener('click', addCallback, false);
</script>
</body>
</html>

这里结合 Chrome 浏览器的 Devtools–>Performance 做一些分析,操作步骤如下:

⚠️注:最好在隐藏窗口中进行分析工作,避免浏览器插件影响分析结果
  1. 开启【Performance】项的记录
  2. 执行一次 CG,创建基准参考线
  3. 连续单击【add】按钮 6 次,增加 6 个文本节点到 pre 元素中
  4. 单击【remove】按钮,删除刚增加 6 个文本节点和 pre 元元素
  5. 执行一次 CG
  6. 停止记录堆分析

image

从分析结果图可知,虽然 6 次 add 操作增加 6 个 Node,但是 remove 操作并没有让 Nodes 节点数下降,即 remove 操作失败。尽管还主动执行了一次 CG 操作,Nodes 曲线也没有下降。因此可以断定内存泄露了!那问题来了,如何去查找问题的原因呢?这里可以通过 Chrome 浏览器的 Devtools–>Memory 进行诊断分析,执行如下操作步骤:

⚠️注:最好在隐藏窗口中进行分析工作,避免浏览器插件影响分析结果
  1. 选中【Take heap snapshot】选项
  2. 连续单击【add】按钮 6 次,增加 6 个文本节点到 pre 元素中
  3. 单击【Take snapshot】按钮,执行一次堆快照
  4. 单击【remove】按钮,删除刚增加 6 个文本节点和 pre 元元素
  5. 单击【Take snapshot】按钮,执行一次堆快照
  6. 选中生成的第二个快照报告,并将视图由"Summary"切换到"Comparison"对比模式,在[class filter]过滤输入框中输入关键字:Detached

image

从分析结果图可知,导致整个 pre 元素和 6 个文本节点无法别回收的原因是:代码中存在全局变量wrapper对 pre 元素的引用。知道了产生的问题原因,便可对症下药了。对代码做如下就修改:

// 因为要多次用到pre.wrapper、div.container、input.remove、input.add节点,将其缓存到本地变量中,
let wrapper = document.querySelector('.wrapper')
const container = document.querySelector('.container')
const removeBtn = document.querySelector('.remove')
const addBtn = document.querySelector('.add')
let counter = 0
let once = true
// 方法
const hide = function (target) {
target.style.display = 'none'
}
const show = function (target) {
target.style.display = 'inline-block'
}
// 回调函数
const removeCallback = function () {
removeBtn.removeEventListener('click', removeCallback, false)
addBtn.removeEventListener('click', addCallback, false)
hide(addBtn)
hide(removeBtn)
container.removeChild(wrapper)

wrapper = null // 在执行删除操作时,将wrapper对pre节点的引用释放掉
}
var addCallback = function () {
wrapper.appendChild(
document.createTextNode('\t' + ++counter + ':a new line text\n')
)
// 显示删除操作按钮
if (once) {
show(removeBtn)
once = false
}
}
// 绑定事件
removeBtn.addEventListener('click', removeCallback, false)
addBtn.addEventListener('click', addCallback, false)

在执行删除操作时,将 wrapper 对 pre 节点的引用释放掉,即在删除逻辑中增加wrapper = null;语句。再次在 Devtools–>Performance 中重复上述操作:

image

小试牛刀

再来看看网上的一个实例,代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Practice</title>
</head>
<body>
<div id="refA"><ul><li><a href="#"></a></li><li><a href="#"></a></li><li><a href="#" id="refB"></a></li></ul></div>
<div></div>
<div></div>

<script>
var refA = document.getElementById('refA');
var refB = document.getElementById('refB');
document.body.removeChild(refA);

// #refA不能GC回收,因为存在变量refA对它的引用。将其对#refA引用释放,但还是无法回收#refA。
refA = null;

// 还存在变量refB对#refA的间接引用(refB引用了#refB,而#refB属于#refA)。将变量refB对#refB的引用释放,#refA就可以被GC回收。
refB = null;
</script>
</body>
</html>

整个过程如下图所演示:

image

有兴趣的同学可以使用 Chrome 的 Devtools 工具,验证一下分析结果,实践很重要~~~🔆

imers

在 JavaScript 常用setInterval()来实现一些动画效果。当然也可以使用链式setTimeout()调用模式来实现:

setTimeout(function () {
// do something. . . .
setTimeout(arguments.callee, interval)
}, interval)

如果在不需要setInterval()时,没有通过clearInterval()方法移除,那么setInterval()会不停地调用函数,直到调用clearInterval()或窗口关闭。如果链式setTimeout()调用模式没有给出终止逻辑,也会一直运行下去。因此再不需要重复定时器时,确保对定时器进行清除,避免占用系统资源。另外,在使用setInterval()setTimeout()来实现动画时,无法确保定时器按照指定的时间间隔来执行动画。为了能在 JavaScript 中创建出平滑流畅的动画,浏览器为 JavaScript 动画添加了一个新 API-requestAnimationFrame()。关于 setInterval、setTimeout 与 requestAnimationFrame 实现动画上的区别 ➹ 猛击 😊

实例

如下通过setInterval()实现一个 clock 的小实例,不过代码存在问题的,有兴趣的同学可以先尝试找一下问题的所在~~~~~😎 操作:

  • 单击【start】按钮开始 clock,同时 web 开发控制台会打印实时信息
  • 单击【stop】按钮停止 clock,同时 web 开发控制台会输出停止信息
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>setInterval</title>
</head>
<body>
<input type="button" value="start" class="start">
<input type="button" value="stop" class="stop">

<script>
var counter = 0;
var clock = {
start: function () {
setInterval(this.step.bind(null, ++counter), 1000);
},
step: function (flag) {
var date = new Date();
var h = date.getHours();
var m = date.getMinutes();
var s = date.getSeconds();
console.log("%d-----> %d:%d:%d", flag, h, m, s);
}
}
document.querySelector('.start').addEventListener('click', clock.start.bind(clock), false);
document.querySelector('.stop').addEventListener('click', function () {
console.log('----> stop <----');
clock = null;
}, false);
</script>
</body>
</html>

上述代码存在两个问题:

  1. 如果不断的单击【start】按钮,会断生成新的 clock。

  2. 单击【stop】按钮不能停止 clock。

输出结果:

image

针对暴露出的问题,对代码做如下修改:

let counter = 0
const clock = {
timer: null,
start: function () {
// 解决第一个问题
if (this.timer) {
clearInterval(this.timer)
}
this.timer = setInterval(this.step.bind(null, ++counter), 1000)
},
step: function (flag) {
const date = new Date()
const h = date.getHours()
const m = date.getMinutes()
const s = date.getSeconds()
console.log('%d-----> %d:%d:%d', flag, h, m, s)
},
// 解决第二个问题
destroy: function () {
console.log('----> stop <----')
clearInterval(this.timer)
node = null
counter = void 0
}
}
document
.querySelector('.start')
.addEventListener('click', clock.start.bind(clock), false)
document
.querySelector('.stop')
.addEventListener('click', clock.destroy.bind(clock), false)

ventListener

做移动开发时,需要对不同设备尺寸做适配。如在开发组件时,有时需要考虑处理横竖屏适配问题。一般做法,在横竖屏发生变化时,需要将组件销毁后再重新生成。而在组件中会对其进行相关事件绑定,如果在销毁组件时,没有将组件的事件解绑,在横竖屏发生变化时,就会不断地对组件进行事件绑定。这样会导致一些异常,甚至可能会导致页面崩掉。

实例

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>callbacks</title>
</head>
<body>
<div class="container"></div>
<script>
var container = document.querySelector('.container');
var counter = 0;
var createHtml = function (n, counter) {
var template = `${(new Array(n)).join(`<div>${counter}: this is a new data <input type="button" value="remove"></div>`)}`
container.innerHTML = template;
}

var resizeCallback = function (init) {
createHtml(10, ++counter);
// 事件委托
container.addEventListener('click', function (event){
var target = event.target;
if(target.tagName === 'INPUT'){
container.removeChild(target.parentElement)
}
}, false);
}
window.addEventListener('resize', resizeCallback, false);
resizeCallback(true);
</script>
</body>
</html>

页面是存在问题的,这里结合 Devtools–>Performance 分析一下问题所在,操作步骤如下:

⚠️注:最好在隐藏窗口中进行分析工作,避免浏览器插件影响分析结果
  1. 开启 Performance 项的记录
  2. 执行一次 CG,创建基准参考线
  3. 对窗口大小进行调整
  4. 执行一次 CG
  5. 停止记录

image

如分析结果所示,在窗口大小变化时,会不断地对container添加代理事件。

同一个元素节点注册了多个相同的 EventListener,那么重复的实例会被抛弃。这么做不会让得 EventListener 被重复调用,也不需要用 removeEventListener 手动清除多余的 EventListener,因为重复的都被自动抛弃了。而这条规则只是针对于命名函数。对于匿名函数,浏览器会将其看做不同的 EventListener,所以只要将匿名的 EventListener,命名一下就可以解决问题:

const container = document.querySelector('.container')
let counter = 0
const createHtml = function (n, counter) {
const template = `${new Array(n).join(
`<div>${counter}: this is a new data <input type="button" value="remove"></div>`
)}`
container.innerHTML = template
}
//
const clickCallback = function (event) {
const target = event.target
if (target.tagName === 'INPUT') {
container.removeChild(target.parentElement)
}
}
const resizeCallback = function (init) {
createHtml(10, ++counter)
// 事件委托
container.addEventListener('click', clickCallback, false)
}
window.addEventListener('resize', resizeCallback, false)
resizeCallback(true)

在 Devtools–>Performance 中再重复上述操作,分析结果如下: image

在开发中,开发者很少关注事件解绑,因为浏览器已经为我们处理得很好了。不过在使用第三方库时,需要特别注意,因为一般第三方库都实现了自己的事件绑定,如果在使用过程中,在需要销毁事件绑定时,没有调用所解绑方法,就可能造成事件绑定数量的不断增加。如下链接是我在项目中使用 jquery,遇见到类似问题:jQuery 中忘记解绑注册的事件,造成内存泄露 ➹ 猛击 😊

本文主要介绍了几种常见的内存泄露。在开发过程,需要我们特别留意一下本文所涉及到的几种内存泄露问题。因为这些随时可能发生在我们日常开发中,如果我们对它们不了解是很难发现它们的存在。可能在它们将问题影响程度放大时,才会引起我们的关注。不过那时可能就晚了,因为产品可能已经上线,接着就会严重影响产品的质量和用户体验,甚至可能让我们承受大量用户流失的损失。作为开发的我们必须把好这个关,让我们开发的产品带给用户最好的体验。

考文章

内存泄漏主要是指的是内存持续升高,但是如果是正常的内存增长的话,不应该被当作内存泄漏来排查。排查内存泄漏,我们可以借助Chrome DevToolsPerformanceMemory选项。举个栗子:

我们新建一个memory.html的文件,完整代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
body {
text-align: center;
}
</style>
</head>
<body>
<p>检测内存变化</p>
<button id="btn">开始</button>
<script>
const arr = [];
// 数组中添加100万个数据
for (let i = 0; i < 10010000; i++) {
arr.push(i)
}
function bind() {
const obj = {
str: JSON.stringify(arr) // 浅拷贝的方式创建一个比较大的字符串
}
// 每次调用bind函数,都在全局绑定一个onclick监听事件,不一定非要执行
// 使用绑定事件,主要是为了保持obj被全局标记
window.addEventListener('click', () => {
// 引用对象obj
console.log(obj);
})
}
let n = 0;
function start() {
setTimeout(() => {
bind(); // 调用bind函数
n++; // 循环次数增加
if (n < 50) {
start(); // 循环执行50次,注意这里并没有使用setInterval定时器
} else {
alert('done');
}
}, 200);
}
document.getElementById('btn').addEventListener('click', () => {
start();
})
</script>
</body>
</html>

页面上有一个按钮用来开始函数调用,方便我们控制。点击按钮,每个200毫秒执行一次bind函数,即在全局监听click事件,循环次数为50次。

在无法确定是否发生内存泄漏时,我们可以先使用Performance来录制一段页面加载的性能变化,先判断是否有内存泄漏发生。

Performance

本次案例仅以Chrome浏览器展开描述,其他浏览器可能会有些许差异。首先我们鼠标右键选择检查或者直接F12进入DevTools页面,面板上选择Performance,选择后应该是如下页面:

在开始之前,我们先点击一下Collect garbageclear来保证内存干净,没有其他遗留内存的干扰。然后我们点击Record来开始录制,并且同时我们也要点击页面上的开始按钮,让我们的代码跑起来。等到代码结束后,我们再点击Record按钮以停止录制,录制的时间跟代码执行的时间相比会有出入,只要保证代码是完全执行完毕的即可。停止录制后,我们会得到如下的结果:

Performance的内容很多,我们只需要关注内存的变化,由此图可见,内存这块区域的曲线是在一直升高的并且到达顶点后并没有回落,这就有可能发生了内存泄漏。因为正常的内存变化曲线应该是类似于“锯齿”,也就是有上有下,正常增长后会有一定的回落,但不一定回落到和初始值一样。而且我们还可以隐约看到程序运行结束后,内存从初始的6.2MB增加到了差不多351MB,这个数量级的增加还是挺明显的。我们只是执行了50次循环,如果执行的次数更多,将会耗尽浏览器的内存空间,导致页面卡死。

虽然是有内存泄漏,但是如果我们想进一步看内存泄漏发生的地方,那么Performance就不够用了,这个时候我们就需要使用Memory面板。

Memory

DevTools的Memory选项主要是用来录制堆内存的快照,为的是进一步分析内存泄漏的详细信息。有人可能会说,为啥不一开始就直接使用Memory呢,反而是先使用Performance。因为我们刚开始就说了,内存增长不表示就一定出现了内存泄漏,有可能是正常的增长,直接使用Memory来分析可能得不到正确的结果。

我们先来看一下怎么使用Memory

首先选择Memory选项,然后清除缓存,在配置选项中选择堆内存快照。内存快照每次点击录制按钮都会记录当前的内存使用情况,我们可以在程序开始前点击一下记录初始的内存使用,代码结束后再点一下记录最终的内存使用,中间可以点击也可以不点击。最后在快照列表中至少可以得到两个内存记录:

初始内存我们暂时不深究,我们选择列表的最后一条记录,然后在筛选下拉框选择最后一个,即第一个快照和第二个快照的差异。

这里我们重点说一下Shallow SizeRetained Size的区别:

  • Shallow Size:对象自身占用的内存大小,一般来说字符串、数组的Shallow Size都会比较大
  • Retained Size:这个是对象自身占用的内存加上无法被GC释放的内存的大小,如果Retained Size和Shallow Size相差不大,基本上可以判定没有发生内存泄漏,但是如果相差很大,例如上图的Object,这就表明发生了内存泄漏。

我们再来细看一下Object,任意展开一个对象,可以在树结构中发现每一个对象都有一个全局事件绑定,并且占用了较大的内存空间。解决本案例涉及的内存泄漏也比较简单,就是及时释放绑定的全局事件。

关于PerformanceMemory的详细使用可以参考:手把手教你排查Javascript内存泄漏

大多数情况下,垃圾回收器会帮我们及时释放内存,一般不会发生内存泄漏。但是有些场景是内存泄漏的高发区,我们在使用的时候一定要注意:

  • 我们在开发的时候经常会使用console在控制台打印信息,但这也会带来一个问题:被console使用的对象是不能被垃圾回收的,这就可能会导致内存泄漏。因此在生产环境中不建议使用console.log()的理由就又可以加上一条避免内存泄漏了。

  • 被全局变量、全局函数引用的对象,在Vue组件销毁时未清除,可能会导致内存泄漏

// Vue3
<script setup>
import {onMounted, onBeforeUnmount, reactive} from 'vue'
const arr = reactive([1,2,3]);
onMounted(() => {
window.arr = arr; // 被全局变量引用
window.arrFunc = () => {
console.log(arr); // 被全局函数引用
}
})
// 正确的方式
onBeforeUnmount(() => {
window.arr = null;
window.arrFunc = null;
})
</script>

  • 定时器未及时在Vue组件销毁时清除,可能会导致内存泄漏
// Vue3
<script setup>
import {onMounted, onBeforeUnmount, reactive} from 'vue'
const arr = reactive([1,2,3]);
const timer = reactive(null);
onMounted(() => {
setInterval(() => {
console.log(arr); // arr被定时器占用,无法被垃圾回收
}, 200);
// 正确的方式
timer = setInterval(() => {
console.log(arr);
}, 200);
})
// 正确的方式
onBeforeUnmount(() => {
if (timer) {
clearInterval(timer);
timer = null;
}
})
</script>

setTimeoutsetInterval两个定时器在使用时都应该注意是否需要清理定时器,特别是setInterval,一定要注意清除。

  • 绑定的事件未及时在Vue组件销毁时清除,可能会导致内存泄漏

绑定事件在实际开发中经常遇到,我们一般使用addEventListener来创建。

// Vue3
<script setup>
import {onMounted, onBeforeUnmount, reactive} from 'vue'
const arr = reactive([1,2,3]);
const printArr = () => {
console.log(arr)
}
onMounted(() => {
// 监听事件绑定的函数为匿名函数,将无法被清除
window.addEventListener('click', () => {
console.log(arr); // 全局绑定的click事件,arr被引用,将无法被垃圾回收
})
// 正确的方式
window.addEventListener('click', printArr);
})
// 正确的方式
onBeforeUnmount(() => {
// 注意清除绑定事件需要前后是同一个函数,如果函数不同将不会清除
window.removeEventListener('click', printArr);
})
</script>

  • 被自定义事件引用,在Vue组件销毁时未清除,可能会导致内存泄漏

自定义事件通过emit/on来发起和监听,清除自定义事件和绑定事件差不多,不同的是需要调用off方法

// Vue3
<script setup>
import {onMounted, onBeforeUnmount, reactive} from 'vue'
import event from './event.js'; // 自定义事件
const arr = reactive([1,2,3]);
const printArr = () => {
console.log(arr)
}
onMounted(() => {
// 使用匿名函数,会导致自定义事件无法被清除
event.on('printArr', () => {
console.log(arr)
})
// 正确的方式
event.on('printArr', printArr)
})
// 正确的方式
onBeforeUnmount(() => {
// 注意清除自定义事件需要前后是同一个函数,如果函数不同将不会清除
event.off('printArr', printArr)
})
</script>

除了及时清除监听器、事件等,对于全局变量的引用,我们可以选择WeakMapWeakSet等弱引用数据类型。这样的话,即使我们引用的对象数据要被垃圾回收,弱引用的全局变量并不会阻止GC。

监控前端页面内存持续增长可以帮助我们及时发现内存泄漏和其他内存问题,从而优化前端页面的性能和稳定性。以下是一些监控前端页面内存持续增长的方法:

  1. 使用浏览器开发工具:现代浏览器的开发工具提供了内存监控功能。您可以使用 Chrome 开发者工具、Firefox 开发者工具等浏览器工具来监控内存的使用情况,并在内存使用超过阈值时进行警报。

  2. 手动检查页面代码:您可以手动检查页面的代码,特别是 JavaScript 代码和其他 DOM 操作,以查找可能导致内存泄漏的问题。例如,可能存在未清理的定时器、事件监听器、未释放的 DOM 元素等。

  3. 使用性能监测工具:性能监测工具,例如 New Relic、AppDynamics 等,可以监测前端页面的性能,并提供关于内存使用的警报和报告。

  4. 使用内存检测工具:内存检测工具,例如 memoryjs、heapdump.js 等,可以帮助检测内存泄漏和内存问题。这些工具可以生成内存快照,分析内存使用情况,以及识别潜在的内存泄漏问题。

通过以上方法的一些组合使用,可以帮助您监控前端页面内存持续增长的情况,及时发现和解决内存问题,提升用户体验和网站可靠性。

crash

对于 web 页面线上监控,如果页面崩溃了,通常会出现 500 或 404 状态码,或者页面停止响应或显示白屏等情况。

以下是一些监控崩溃的方法:

  1. 使用网站性能监测工具:这些工具可以检测页面的状态码和响应时间,如果页面崩溃了,就会发出警报。一些流行的性能监测工具包括 New Relic, Pingdom, 和 UptimeRobot 等。

  2. 设置异常检测:异常检测可以监测页面异常的行为,例如页面响应时间超过特定时间限制,或者页面元素加载失败等。通过设置这些异常检测,可以在页面崩溃时自动触发警报。

  3. 实时用户行为监测:实时监测用户行为可以帮助识别用户在页面上的行为,例如页面停留时间,点击按钮的位置等,以便检测页面异常行为。这些监测可以使用 Google Analytics, Mixpanel 等网站分析工具实现。

  4. 前端代码错误监测:使用前端监测工具,例如 Sentry, Raygun, 和 Bugsnag 等,可以监测前端代码错误,包括 JavaScript 和 CSS 错误,以便快速识别和解决问题。

通过以上方法的一些组合使用,可以帮助您监控 web 页面的崩溃,及时发现和解决问题,提升用户体验和网站可靠性。

如果是页面运行时页面崩溃, 如何监控?

如果在运行时发生页面崩溃,可以使用以下方法进行监控:

  1. 实时监控日志:可以设置日志监控,将日志实时发送到日志收集工具,例如 ELK Stack、Splunk 等。这些工具可以分析和提取有关页面崩溃的信息,例如错误消息、堆栈跟踪等,以便快速识别和解决问题。

  2. 页面截图:当页面崩溃时,可以使用截图工具进行截屏,以捕获页面的当前状态。这些截图可以用于快速检查页面崩溃的根本原因。

  3. 人工检测:可以雇用专业的质量测试人员或专业服务公司进行页面质量测试,以便在页面崩溃时进行手动检测和识别。

  4. 实时异常检测:实时监测页面异常的行为,例如页面响应时间超过特定时间限制,或者页面元素加载失败等。通过设置这些异常检测,可以在页面崩溃时自动触发警报。

通过以上方法的一些组合使用,可以帮助您在运行时监控 web 页面的崩溃,及时发现和解决问题,提升用户体验和网站可靠性。

55%