MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

JavaScript Web编程中的性能优化

2021-05-312.4k 阅读

减少 DOM 操作

在 JavaScript Web 编程中,DOM 操作是性能瓶颈的一个重要来源。因为 DOM 操作涉及到 JavaScript 与浏览器渲染引擎之间的交互,这个过程相对较慢。

  1. 合并多次 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 中,这样只触发一次布局计算和重绘。
  1. 批量修改样式
    • 原理:直接修改元素的 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,这样浏览器可以更高效地处理样式变化,只触发一次重排和重绘。
  1. 避免频繁读取和修改 DOM 属性
    • 原理:每次读取某些 DOM 属性(如 offsetTopclientWidth 等),浏览器需要计算最新的值,这可能会触发重排。如果在一个循环中频繁读取和修改 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,这样只触发一次重排。

优化内存使用

  1. 避免内存泄漏
    • 原理:内存泄漏指的是程序中已分配的内存由于某种原因无法释放,导致内存占用不断增加,最终可能导致浏览器性能下降甚至崩溃。在 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>
  1. 合理使用对象池
    • 原理:对象池是一种缓存对象的技术,通过复用已创建的对象,避免频繁创建和销毁对象,从而减少内存分配和垃圾回收的开销。在 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>
  • 在上述示例中,创建了一个定时器对象池 timerPoolgetTimer 函数从对象池中获取定时器,如果对象池为空则创建新的定时器。releaseTimer 函数用于将定时器放回对象池,同时清除定时器的执行。通过这种方式,可以复用定时器对象,减少创建和销毁定时器的开销。

优化事件处理

  1. 事件委托
    • 原理:事件委托是一种利用事件冒泡机制的技术,将多个子元素的事件处理委托给它们的共同父元素。这样可以减少事件处理程序的数量,提高性能。因为每个事件处理程序都会占用一定的内存和资源,减少事件处理程序的数量可以降低内存占用和事件处理的开销。
    • 示例
<!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> 元素,都只需要一个事件处理程序,大大提高了性能。
  1. 防抖(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 毫秒后才会执行搜索。
  1. 节流(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 毫秒最多执行一次,避免了频繁执行导致的性能问题。

优化代码结构和算法

  1. 使用高效的数据结构和算法
    • 数组操作
      • 原理:在 JavaScript 中,数组是常用的数据结构。不同的数组操作方法性能有所差异。例如,pushpop 方法在数组末尾添加和删除元素的性能较好,而 unshiftshift 方法在数组开头添加和删除元素的性能相对较差,因为它们需要移动数组中的其他元素。
      • 示例
<!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`,递归方式不仅可能导致调用栈溢出,而且性能比迭代方式差,因为递归过程中有大量的函数调用和栈操作开销。

加载和执行优化

  1. 代码拆分与按需加载
    • 原理:在大型 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() 动态导入,而不是在页面加载时就加载。这样可以有效减少初始加载的代码量,提高页面加载性能。
  1. 优化加载顺序
    • 原理:JavaScript 文件的加载顺序会影响页面的渲染和性能。阻塞渲染的脚本(如 <script> 标签没有 asyncdefer 属性)会阻止页面的渲染,直到脚本加载和执行完毕。因此,要合理安排脚本的加载顺序,将非关键脚本放在页面底部加载,或者使用 asyncdefer 属性来控制脚本的加载和执行时机。
    • 示例
<!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 解析,适合那些与页面渲染无关的独立脚本,如统计脚本等。
  1. 缓存策略
    • 原理:合理设置缓存策略可以减少重复请求相同资源的开销,提高页面加载性能。浏览器可以根据缓存头信息来决定是否从缓存中加载资源,而不是每次都从服务器获取。
    • 示例:在服务器端,可以通过设置 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 体验。在实际开发中,需要根据具体的应用场景和需求,综合运用这些优化技巧,不断进行性能测试和调优。