JavaScript Web编程中的性能优化
2021-05-312.4k 阅读
减少 DOM 操作
在 JavaScript Web 编程中,DOM 操作是性能瓶颈的一个重要来源。因为 DOM 操作涉及到 JavaScript 与浏览器渲染引擎之间的交互,这个过程相对较慢。
- 合并多次 DOM 操作
- 原理:每次对 DOM 进行修改,浏览器都需要重新计算布局和重新绘制页面。如果频繁地进行 DOM 操作,会导致大量不必要的计算和绘制,从而降低性能。因此,将多次 DOM 操作合并为一次,可以显著减少浏览器的负担。
- 示例:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF - 8">
<title>合并 DOM 操作示例</title>
</head>
<body>
<div id="container"></div>
<script>
const container = document.getElementById('container');
// 不好的做法,多次 DOM 操作
const p1 = document.createElement('p');
p1.textContent = '这是第一个段落';
container.appendChild(p1);
const p2 = document.createElement('p');
p2.textContent = '这是第二个段落';
container.appendChild(p2);
// 好的做法,合并 DOM 操作
const fragment = document.createDocumentFragment();
const p3 = document.createElement('p');
p3.textContent = '这是第三个段落';
fragment.appendChild(p3);
const p4 = document.createElement('p');
p4.textContent = '这是第四个段落';
fragment.appendChild(p4);
container.appendChild(fragment);
</script>
</body>
</html>
- 在上述示例中,先展示了不好的做法,即每次创建一个
<p>
元素就立即添加到container
中,这会触发多次布局计算和重绘。而好的做法是先创建一个文档片段fragment
,将所有要添加的元素添加到片段中,最后再将片段添加到container
中,这样只触发一次布局计算和重绘。
- 批量修改样式
- 原理:直接修改元素的
style
属性会触发重排和重绘。如果要修改多个样式,最好一次性修改 CSS 类,而不是逐个修改style
属性。因为修改 CSS 类时,浏览器可以更高效地批量处理样式变化。 - 示例:
- 原理:直接修改元素的
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF - 8">
<title>批量修改样式示例</title>
<style>
.highlight {
background - color: yellow;
color: red;
}
</style>
</head>
<body>
<div id="text">这是一段文本</div>
<button onclick="highlightText()">高亮文本</button>
<script>
function highlightText() {
const textDiv = document.getElementById('text');
// 不好的做法,逐个修改 style 属性
// textDiv.style.backgroundColor = 'yellow';
// textDiv.style.color ='red';
// 好的做法,添加 CSS 类
textDiv.classList.add('highlight');
}
</script>
</body>
</html>
- 上述代码中,不好的做法是逐个修改
style
属性,这会触发两次重排和重绘。而好的做法是添加一个预定义的 CSS 类highlight
,这样浏览器可以更高效地处理样式变化,只触发一次重排和重绘。
- 避免频繁读取和修改 DOM 属性
- 原理:每次读取某些 DOM 属性(如
offsetTop
、clientWidth
等),浏览器需要计算最新的值,这可能会触发重排。如果在一个循环中频繁读取和修改 DOM 属性,会导致大量不必要的重排操作。 - 示例:
- 原理:每次读取某些 DOM 属性(如
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF - 8">
<title>避免频繁读取和修改 DOM 属性示例</title>
</head>
<body>
<div id="box" style="width:100px;height:100px;background - color:lightblue;"></div>
<script>
const box = document.getElementById('box');
// 不好的做法
for (let i = 0; i < 100; i++) {
box.style.width = (box.offsetWidth + 1) + 'px';
}
// 好的做法
let width = box.offsetWidth;
for (let i = 0; i < 100; i++) {
width++;
}
box.style.width = width + 'px';
</script>
</body>
</html>
- 在不好的做法中,每次循环都读取
offsetWidth
并修改width
,这会触发多次重排。而好的做法是先读取一次offsetWidth
,在循环中只对变量进行操作,最后再一次性修改width
,这样只触发一次重排。
优化内存使用
- 避免内存泄漏
- 原理:内存泄漏指的是程序中已分配的内存由于某种原因无法释放,导致内存占用不断增加,最终可能导致浏览器性能下降甚至崩溃。在 JavaScript 中,常见的内存泄漏原因包括意外的全局变量、闭包引用未释放、DOM 元素引用未释放等。
- 意外的全局变量:
- 示例:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF - 8">
<title>意外全局变量导致内存泄漏示例</title>
</head>
<body>
<script>
function badFunction() {
// 忘记使用 var、let 或 const 声明变量,导致成为全局变量
leakyVariable = '这是一个泄漏的变量';
}
badFunction();
// 即使 badFunction 执行完毕,leakyVariable 仍然存在于全局作用域中,不会被垃圾回收
</script>
</body>
</html>
- 在上述示例中,`leakyVariable` 没有使用 `var`、`let` 或 `const` 声明,因此成为了全局变量。即使 `badFunction` 执行完毕,该变量依然存在于全局作用域中,不会被垃圾回收机制回收,从而导致内存泄漏。
- 闭包引用未释放:
- 示例:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF - 8">
<title>闭包导致内存泄漏示例</title>
</head>
<body>
<script>
function outerFunction() {
const largeObject = {
data: new Array(1000000).fill('a')
};
return function innerFunction() {
// innerFunction 形成闭包,引用了 outerFunction 中的 largeObject
return largeObject.data.length;
};
}
const closure = outerFunction();
// 即使 outerFunction 执行完毕,由于闭包的存在,largeObject 不会被垃圾回收
</script>
</body>
</html>
- 在这个例子中,`outerFunction` 返回的 `innerFunction` 形成了闭包,它引用了 `outerFunction` 中的 `largeObject`。即使 `outerFunction` 执行完毕,`largeObject` 由于被闭包引用,不会被垃圾回收,从而可能导致内存泄漏。要解决这个问题,可以在适当的时候切断闭包对 `largeObject` 的引用,比如:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF - 8">
<title>解决闭包导致内存泄漏示例</title>
</head>
<body>
<script>
function outerFunction() {
const largeObject = {
data: new Array(1000000).fill('a')
};
return function innerFunction() {
const length = largeObject.data.length;
// 切断对 largeObject 的引用
largeObject = null;
return length;
};
}
const closure = outerFunction();
</script>
</body>
</html>
- DOM 元素引用未释放:
- 示例:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF - 8">
<title>DOM 元素引用未释放导致内存泄漏示例</title>
</head>
<body>
<div id="element">这是一个 DOM 元素</div>
<script>
const element = document.getElementById('element');
const globalReference = element;
// 移除 DOM 元素
document.body.removeChild(element);
// 但是由于 globalReference 仍然引用该元素,该元素不会被垃圾回收
</script>
</body>
</html>
- 在上述代码中,`globalReference` 引用了 `element`,即使从 DOM 中移除了 `element`,由于 `globalReference` 的存在,该元素不会被垃圾回收。解决方法是在移除 DOM 元素后,将引用设为 `null`:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF - 8">
<title>解决 DOM 元素引用未释放导致内存泄漏示例</title>
</head>
<body>
<div id="element">这是一个 DOM 元素</div>
<script>
const element = document.getElementById('element');
const globalReference = element;
// 移除 DOM 元素
document.body.removeChild(element);
// 将引用设为 null
globalReference = null;
</script>
</body>
</html>
- 合理使用对象池
- 原理:对象池是一种缓存对象的技术,通过复用已创建的对象,避免频繁创建和销毁对象,从而减少内存分配和垃圾回收的开销。在 JavaScript 中,对于一些创建开销较大的对象,如定时器、XMLHttpRequest 对象等,可以使用对象池来优化性能。
- 示例:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF - 8">
<title>对象池示例</title>
</head>
<body>
<script>
const timerPool = [];
function getTimer() {
return timerPool.length > 0? timerPool.pop() : setTimeout(() => {});
}
function releaseTimer(timer) {
clearTimeout(timer);
timerPool.push(timer);
}
// 使用对象池中的定时器
const timer1 = getTimer();
setTimeout(() => {
console.log('定时器 1 执行');
releaseTimer(timer1);
}, 1000);
const timer2 = getTimer();
setTimeout(() => {
console.log('定时器 2 执行');
releaseTimer(timer2);
}, 2000);
</script>
</body>
</html>
- 在上述示例中,创建了一个定时器对象池
timerPool
。getTimer
函数从对象池中获取定时器,如果对象池为空则创建新的定时器。releaseTimer
函数用于将定时器放回对象池,同时清除定时器的执行。通过这种方式,可以复用定时器对象,减少创建和销毁定时器的开销。
优化事件处理
- 事件委托
- 原理:事件委托是一种利用事件冒泡机制的技术,将多个子元素的事件处理委托给它们的共同父元素。这样可以减少事件处理程序的数量,提高性能。因为每个事件处理程序都会占用一定的内存和资源,减少事件处理程序的数量可以降低内存占用和事件处理的开销。
- 示例:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF - 8">
<title>事件委托示例</title>
</head>
<body>
<ul id="list">
<li>列表项 1</li>
<li>列表项 2</li>
<li>列表项 3</li>
</ul>
<script>
const list = document.getElementById('list');
list.addEventListener('click', function (event) {
if (event.target.tagName === 'LI') {
console.log('点击了列表项:', event.target.textContent);
}
});
</script>
</body>
</html>
- 在上述示例中,没有为每个
<li>
元素单独添加点击事件处理程序,而是将点击事件委托给了父元素<ul>
。当点击<li>
元素时,事件会冒泡到<ul>
元素,通过检查event.target
来确定是哪个<li>
元素被点击。这样,无论有多少个<li>
元素,都只需要一个事件处理程序,大大提高了性能。
- 防抖(Debounce)
- 原理:防抖是指在事件触发后,延迟一定时间执行回调函数,如果在延迟时间内再次触发事件,则重新计算延迟时间。这样可以避免短时间内频繁触发事件导致的性能问题,比如用户在搜索框中输入时,如果每次输入都触发搜索请求,会给服务器带来很大压力,并且可能导致界面卡顿。通过防抖,可以在用户停止输入一段时间后再执行搜索请求。
- 示例:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF - 8">
<title>防抖示例</title>
</head>
<body>
<input type="text" id="searchInput" placeholder="搜索">
<script>
function debounce(func, delay) {
let timer;
return function () {
const context = this;
const args = arguments;
clearTimeout(timer);
timer = setTimeout(() => {
func.apply(context, args);
}, delay);
};
}
const searchInput = document.getElementById('searchInput');
const debouncedSearch = debounce(() => {
console.log('执行搜索:', searchInput.value);
}, 500);
searchInput.addEventListener('input', debouncedSearch);
</script>
</body>
</html>
- 在上述代码中,
debounce
函数接受一个回调函数func
和延迟时间delay
。每次触发input
事件时,都会清除之前设置的定时器,并重新设置一个新的定时器,延迟delay
时间后执行func
。这样,在用户快速输入时,不会频繁执行搜索操作,只有在用户停止输入 500 毫秒后才会执行搜索。
- 节流(Throttle)
- 原理:节流是指在一定时间内,只允许事件处理函数执行一次。与防抖不同,节流保证了事件处理函数在一定时间间隔内至少执行一次,适用于一些需要持续触发但又不能过于频繁的场景,比如滚动事件。
- 示例:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF - 8">
<title>节流示例</title>
</head>
<body>
<div style="height:2000px;width:100%;background - color:lightgray;"></div>
<script>
function throttle(func, interval) {
let lastTime = 0;
return function () {
const now = new Date().getTime();
const context = this;
const args = arguments;
if (now - lastTime >= interval) {
func.apply(context, args);
lastTime = now;
}
};
}
window.addEventListener('scroll', throttle(() => {
console.log('滚动中');
}, 200));
</script>
</body>
</html>
- 在上述示例中,
throttle
函数接受一个回调函数func
和时间间隔interval
。每次触发scroll
事件时,会检查距离上次执行func
的时间是否超过了interval
,如果超过则执行func
并更新lastTime
。这样,在滚动过程中,func
每 200 毫秒最多执行一次,避免了频繁执行导致的性能问题。
优化代码结构和算法
- 使用高效的数据结构和算法
- 数组操作:
- 原理:在 JavaScript 中,数组是常用的数据结构。不同的数组操作方法性能有所差异。例如,
push
和pop
方法在数组末尾添加和删除元素的性能较好,而unshift
和shift
方法在数组开头添加和删除元素的性能相对较差,因为它们需要移动数组中的其他元素。 - 示例:
- 原理:在 JavaScript 中,数组是常用的数据结构。不同的数组操作方法性能有所差异。例如,
- 数组操作:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF - 8">
<title>数组操作性能示例</title>
</head>
<body>
<script>
const arr1 = [];
const start1 = new Date().getTime();
for (let i = 0; i < 10000; i++) {
arr1.push(i);
}
const end1 = new Date().getTime();
console.log('使用 push 添加元素耗时:', end1 - start1, '毫秒');
const arr2 = [];
const start2 = new Date().getTime();
for (let i = 0; i < 10000; i++) {
arr2.unshift(i);
}
const end2 = new Date().getTime();
console.log('使用 unshift 添加元素耗时:', end2 - start2, '毫秒');
</script>
</body>
</html>
- 在上述示例中,可以看到 `push` 方法添加元素的耗时明显比 `unshift` 方法少,因为 `unshift` 需要移动数组中的所有元素。
- 查找算法:
- 原理:对于简单的线性查找,
indexOf
方法可以满足需求,但对于大规模数据,其性能较差。二分查找算法(binarySearch
)在有序数组中查找元素的效率更高,时间复杂度为 O(log n),而线性查找的时间复杂度为 O(n)。 - 示例:
- 原理:对于简单的线性查找,
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF - 8">
<title>查找算法性能示例</title>
</head>
<body>
<script>
function binarySearch(arr, target) {
let left = 0;
let right = arr.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (arr[mid] === target) {
return mid;
} else if (arr[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1;
}
const sortedArr = new Array(1000000).fill(0).map((_, i) => i);
const target = 500000;
const start1 = new Date().getTime();
sortedArr.indexOf(target);
const end1 = new Date().getTime();
console.log('使用 indexOf 查找耗时:', end1 - start1, '毫秒');
const start2 = new Date().getTime();
binarySearch(sortedArr, target);
const end2 = new Date().getTime();
console.log('使用二分查找耗时:', end2 - start2, '毫秒');
</script>
</body>
</html>
- 在上述示例中,对于大规模的有序数组,二分查找的耗时远远小于 `indexOf` 方法的耗时,体现了二分查找算法在查找效率上的优势。
2. 避免不必要的函数嵌套和递归
- 函数嵌套:
- 原理:函数嵌套会增加作用域链的长度,每次访问变量时,JavaScript 引擎需要沿着作用域链查找变量,作用域链越长,查找变量的时间就越长,从而影响性能。
- 示例:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF - 8">
<title>函数嵌套示例</title>
</head>
<body>
<script>
function outerFunction() {
const outerVar = '外部变量';
function innerFunction() {
const innerVar = '内部变量';
console.log(outerVar);
console.log(innerVar);
}
innerFunction();
}
outerFunction();
</script>
</body>
</html>
- 在上述示例中,虽然代码逻辑简单,但 `innerFunction` 访问 `outerVar` 时需要沿着作用域链查找,增加了查找变量的开销。如果可以,尽量减少函数嵌套的深度。
- 递归:
- 原理:递归函数在执行过程中会不断调用自身,每次调用都会在调用栈中创建一个新的栈帧。如果递归深度过大,会导致调用栈溢出,并且递归过程中的函数调用和栈操作也会带来性能开销。
- 示例:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF - 8">
<title>递归示例</title>
</head>
<body>
<script>
function factorialRecursive(n) {
if (n === 0 || n === 1) {
return 1;
} else {
return n * factorialRecursive(n - 1);
}
}
function factorialIterative(n) {
let result = 1;
for (let i = 1; i <= n; i++) {
result = result * i;
}
return result;
}
const num = 1000;
const start1 = new Date().getTime();
factorialRecursive(num);
const end1 = new Date().getTime();
console.log('递归计算阶乘耗时:', end1 - start1, '毫秒');
const start2 = new Date().getTime();
factorialIterative(num);
const end2 = new Date().getTime();
console.log('迭代计算阶乘耗时:', end2 - start2, '毫秒');
</script>
</body>
</html>
- 在上述示例中,计算阶乘可以使用递归和迭代两种方式。对于较大的 `n`,递归方式不仅可能导致调用栈溢出,而且性能比迭代方式差,因为递归过程中有大量的函数调用和栈操作开销。
加载和执行优化
- 代码拆分与按需加载
- 原理:在大型 Web 应用中,将所有代码打包在一个文件中会导致文件过大,加载时间过长。代码拆分可以将代码分割成多个小块,按需加载。这样,在页面初始加载时,只加载必要的代码,提高页面的加载速度。当用户进行特定操作或导航到特定页面时,再加载相应的代码块。
- 示例:在现代 JavaScript 模块系统(如 ES6 模块)中,可以使用动态导入来实现按需加载。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF - 8">
<title>代码拆分与按需加载示例</title>
</head>
<body>
<button onclick="loadFeature()">加载功能模块</button>
<script type="module">
async function loadFeature() {
const { featureFunction } = await import('./feature.js');
featureFunction();
}
</script>
</body>
</html>
- 在上述示例中,
feature.js
模块在按钮点击时才通过import()
动态导入,而不是在页面加载时就加载。这样可以有效减少初始加载的代码量,提高页面加载性能。
- 优化加载顺序
- 原理:JavaScript 文件的加载顺序会影响页面的渲染和性能。阻塞渲染的脚本(如
<script>
标签没有async
或defer
属性)会阻止页面的渲染,直到脚本加载和执行完毕。因此,要合理安排脚本的加载顺序,将非关键脚本放在页面底部加载,或者使用async
或defer
属性来控制脚本的加载和执行时机。 - 示例:
- 原理:JavaScript 文件的加载顺序会影响页面的渲染和性能。阻塞渲染的脚本(如
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF - 8">
<title>优化加载顺序示例</title>
<!-- 样式表放在头部,优先加载 -->
<link rel="stylesheet" href="styles.css">
</head>
<body>
<!-- 内容先渲染 -->
<h1>这是一个页面</h1>
<p>页面内容</p>
<!-- 非关键脚本放在底部加载 -->
<script src="script.js"></script>
<!-- 带有 defer 属性的脚本,在 HTML 解析完成后执行 -->
<script defer src="deferScript.js"></script>
<!-- 带有 async 属性的脚本,加载完成后立即执行,不阻塞 HTML 解析 -->
<script async src="asyncScript.js"></script>
</body>
</html>
- 在上述示例中,样式表放在头部优先加载,因为样式表不会阻塞页面的首次渲染,但会影响页面的最终呈现样式。非关键脚本放在底部加载,避免阻塞页面渲染。带有
defer
属性的脚本在 HTML 解析完成后执行,适合那些需要在 DOM 加载完成后执行的脚本。带有async
属性的脚本加载完成后立即执行,不阻塞 HTML 解析,适合那些与页面渲染无关的独立脚本,如统计脚本等。
- 缓存策略
- 原理:合理设置缓存策略可以减少重复请求相同资源的开销,提高页面加载性能。浏览器可以根据缓存头信息来决定是否从缓存中加载资源,而不是每次都从服务器获取。
- 示例:在服务器端,可以通过设置
Cache - Control
头来控制缓存策略。例如,对于不经常变化的静态资源(如 CSS、JavaScript 文件、图片等),可以设置较长的缓存时间:
const http = require('http');
const fs = require('fs');
const path = require('path');
const server = http.createServer((req, res) => {
const filePath = path.join(__dirname, req.url === '/'? 'index.html' : req.url);
const extname = path.extname(filePath);
let contentType = 'text/html';
if (extname === '.css') {
contentType = 'text/css';
} else if (extname === '.js') {
contentType = 'application/javascript';
}
fs.readFile(filePath, (err, content) => {
if (err) {
if (err.code === 'ENOENT') {
res.writeHead(404, { 'Content - Type': 'text/html' });
res.end('<h1>404 Not Found</h1>');
} else {
res.writeHead(500, { 'Content - Type': 'text/html' });
res.end('<h1>Server Error</h1>');
}
} else {
if (extname === '.css' || extname === '.js') {
res.writeHead(200, {
'Content - Type': contentType,
'Cache - Control':'max - age = 31536000' // 缓存一年
});
} else {
res.writeHead(200, { 'Content - Type': contentType });
}
res.end(content, 'utf - 8');
}
});
});
const port = 3000;
server.listen(port, () => {
console.log(`Server running on port ${port}`);
});
- 在上述 Node.js 服务器示例中,对于 CSS 和 JavaScript 文件,设置了
Cache - Control: max - age = 31536000
,表示缓存一年。这样,浏览器在一年内再次请求相同的 CSS 或 JavaScript 文件时,会直接从缓存中加载,而不需要再次从服务器获取,大大提高了加载性能。
通过以上从 DOM 操作、内存使用、事件处理、代码结构和算法以及加载和执行等方面的优化,可以显著提升 JavaScript Web 编程的性能,为用户提供更流畅的 Web 体验。在实际开发中,需要根据具体的应用场景和需求,综合运用这些优化技巧,不断进行性能测试和调优。