性能优化及调试技巧|青训营

213 阅读8分钟

JavaScript是一门动态、弱类型、解释型的脚本语言,与C、C++等先编译后执行的语言不同,JS实在程序运行过程中进行逐行解释,它的灵活性同时也给带来一些问题,比如代码的可读性、可维护性、可测试性以及性能的降低。而通过减少重绘和重排、使用节流和防抖技术、使用性能分析工具等方式可以优化JavaScript代码来提高性能。这里进行一些介绍和实践示例展示。

减少重绘和重排

首先,先理解页面生成的过程:

1.HTML 被 HTML 解析器解析成 DOM 树;

2.CSS 被 CSS 解析器解析成 CSSOM 树;

3.结合 DOM 树和 CSSOM 树,生成一棵渲染树(Render Tree),这一过程称为 Attachment;

4.生成布局(flow),浏览器在屏幕上“画”出渲染树中的所有节点;

5.将布局绘制(paint)在屏幕上,显示出整个页面。

第4,5步是最耗时的部分,这两步合起来,就是我们通常所说的渲染

渲染:在页面的生命周期中,网页生成的时候,至少会渲染一次。在用户访问的过程中,还会不断触发重排(reflow)和重绘(repaint)

不管页面发生了重绘还是重排,都会影响性能,最可怕的是重排,会使我们付出高额的性能代价,所以我们应尽量避免。

重排(reflow)

当DOM的变化影响了元素的几何信息(元素的的位置和尺寸大小),浏览器需要重新计算元素的几何属性,将其安放在界面中的正确位置,这个过程叫做重排。

重排也叫回流,简单的说就是重新生成布局,重新排列元素。

会发生重排的情况:

  • 页面初始渲染,这是开销最大的一次重排 添加/删除可见的DOM元素 改变元素位置

  • 改变元素尺寸,比如边距、填充、边框、宽度和高度等

  • 改变元素内容,比如文字数量,图片大小等 改变元素字体大小

  • 改变浏览器窗口尺寸,比如resize事件发生时

  • 激活CSS伪类(例如::hover)

  • 设置 style 属性的值,因为通过设置style属性改变结点样式的话,每一次设置都会触发一次

  • 查询某些属性或调用某些计算方法:offsetWidth、offsetHeight等,除此之外,当我们调用 getComputedStyle方法,或者IE里的 currentStyle 时,也会触发重排,原理是一样的,都为求一个“即时性”和“准确性”。

会引起重排的常见属性: width、height、margin、padding、display、border-width、border、position、overflow、font-size、vertical-align、min-height、clientWidth、clientHeight、clientTop、clientLeft、offsetWidth、offsetHeight、offsetTop、offsetLeft、scrollWidth、scrollHeight、scrollTop、scrollLeft

会引起重排的常见方法: scrollIntoView()、scrollTo()、getComputedStyle()、getBoundingClientRect()、scrollIntoViewIfNeeded()

重排影响的范围:

由于浏览器渲染界面是基于流式布局模型的,所以触发重排时会对周围DOM重新排列,影响的范围有两种:

  • 全局范围:从根节点html开始对整个渲染树进行重新布局。

  • 局部范围:对渲染树的某部分或某一个渲染对象进行重新布局。

重绘(repaints)

当一个元素的外观发生改变,但没有改变布局,重新把元素外观绘制出来的过程,叫做重绘。

会引起重绘的常见属性: color、border-style、visibility、background、text-decoration、background-image、background-position、background-repeat、outline-color、outline、outline-style、border-radius、outline-width、box-shadow、background-size、

优化

减少重排范围

我们应该尽量以局部布局的形式组织html结构,尽可能小的影响重排的范围。

  • 尽可能在低层级的DOM节点上,而不是像上述全局范围的示例代码一样,如果你要改变p的样式,class就不要加在div上,通过父元素去影响子元素不好。

  • 不要使用 table 布局,可能很小的一个小改动会造成整个 table 的重新布局。那么在不得已使用table的场合,可以设置table-layout:auto;或者是table-layout:fixed这样可以让table一行一行的渲染,这种做法也是为了限制reflow的影响范围。

减少重排次数

  • 样式集中变更,批量更新DOM

    频繁改变元素样式会引起重排。尽量使用 CSS 类一次性应用样式变更。对于一个静态页面来说,明智且可维护的做法是更改类名而不是修改样式,对于动态改变的样式来说,相较每次微小修改都直接触及元素,更好的办法是统一在 cssText 变量中编辑。也可以将这些修改封装在一个函数中,一次性进行更新。

    示例:

    // ×
    var left = 10;
    var top = 10;
    el.style.left = left + "px";
    el.style.top = top + "px";
    
    // √
    el.style.cssText += "; left: " + left + "px; top: " + top + "px;";
    // top和left的值是动态计算而成
    
    // √
    el.className += " className";
    
    // 添加样式类
    element.classList.add('my-style');
    
    // 移除样式类
    element.classList.remove('my-style');
    
    function updateStyles() {
    element1.style.color = 'red';
    element2.style.backgroundColor = 'blue';
    // ...
    elementN.style.display = 'none';
    }
    
  • 分离读写操作,避免强制同步布局

    DOM 的多个读操作(或多个写操作),应该放在一起。不要两个读操作之间,加入一个写操作。在获取某些属性(如offsetWidth、offsetHeight等)时,会触发强制同步布局,导致重绘和重排。尽量避免频繁获取这些属性,或者使用缓存来减少获取次数。

    示例:

    // × 强制刷新 触发四次重排+重绘
    div.style.left = div.offsetLeft + 1 + 'px';
    div.style.top = div.offsetTop + 1 + 'px';
    div.style.right = div.offsetRight + 1 + 'px';
    div.style.bottom = div.offsetBottom + 1 + 'px';
    
    
    // √ 缓存布局信息 相当于读写分离 触发一次重排+重绘
    var curLeft = div.offsetLeft;
    var curTop = div.offsetTop;
    var curRight = div.offsetRight;
    var curBottom = div.offsetBottom;
    
    div.style.left = curLeft + 1 + 'px';
    div.style.top = curTop + 1 + 'px';
    div.style.right = curRight + 1 + 'px';
    div.style.bottom = curBottom + 1 + 'px';
    
    
  • 使用requestAnimationFrame

    让浏览器在下一次重绘之前执行指定的函数。这样可以将多个DOM操作合并到一次重绘中,提高性能。

    示例:

    function updateDOM() {
    requestAnimationFrame(() => {
      element.style.width = '100px';
      element.style.height = '100px';
      element.style.opacity = '0.5';
      // ...
      });
    }
    

使用节流和防抖技术

节流(throttle)

指连续触发事件但是在 n 秒中只执行一次函数。节流会稀释函数的执行频率。

image-5.png

<script>
    // 1.封装节流函数
    function throttle(fn, time) {
        //3. 通过闭包保存一个 "节流阀" 默认为false
        let temp = false;
        return function () {
            //8.触发事件被调用 判断"节流阀" 是否为true  如果为true就直接trurn出去不做任何操作
            if (temp) {
                return;
            } else {
                //4. 如果节流阀为false  立即将节流阀设置为true
                temp = true; //节流阀设置为true
                //5.  开启定时器
                setTimeout(() => {
                    //6. 将外部传入的函数的执行放在setTimeout中
                    fn.apply(this, arguments);
                    //7. 最后在setTimeout执行完毕后再把标记'节流阀'为false(关键)  表示可以执行下一次循环了。当定时器没有执行的时候标记永远是true,在开头被return掉
                        temp = false;
                    }, time);
                }
            };
        }
    function sayHi(e) {
        // 打印当前 document 的宽高
            console.log(e.target.innerWidth, e.target.innerHeight);
    }
    // 2.绑定事件,绑定时就调用节流函数  
    // 绑定是就要调用一下封装的节流函数 触发事件是触发封装函数内部的函数
    window.addEventListener('resize', throttle(sayHi, 2000));
</script>  

防抖(debounce)

指触发事件后 n 秒后才执行函数,如果在 n 秒内又触发了事件,则会重新计算函数执行时间。

简单版本示例:

function debounce(func, wait) {
    let timeout;

    return function () {
        let context = this; // 保存this指向
        let args = arguments; // 拿到event对象

        clearTimeout(timeout)
        timeout = setTimeout(function(){
            func.apply(context, args)
        }, wait);
    }
}

防抖如果需要立即执行,可加入第三个参数用于判断,实现如下:

function debounce(func, wait, immediate) {

    let timeout;

    return function () {
        let context = this;
        let args = arguments;

        if (timeout) clearTimeout(timeout); // timeout 不为null
        if (immediate) {
            let callNow = !timeout; // 第一次会立即执行,以后只有事件执行后才会再次触发
            timeout = setTimeout(function () {
                timeout = null;
            }, wait)
            if (callNow) {
                func.apply(context, args)
            }
        }
        else {
            timeout = setTimeout(function () {
                func.apply(context, args)
            }, wait);
        }
    }
}

应用场景

防抖(debounce)

1.search搜索时,用户在不断输入值时,用防抖来节约请求资源。

2.手机号、邮箱等验证输入检测

3.窗口大小resize。调整完窗口后,计算窗口大小,防止重复渲染。

节流(throttle)

1.鼠标不断点击触发,mousedown(单位时间内只触发一次)

2.监听滚动事件,比如是否滑到底部自动加载更多,用throttle来判断

区别

相同点:

都可以通过使用 setTimeout 实现 目的都是,降低回调执行频率。节省计算资源

不同点:

  • 函数防抖,在一段连续操作结束后,处理回调,利用clearTimeout 和 setTimeout实现。函数节流,在一段连续操作中,每一段时间只执行一次,频率较高的事件中使用来提高性能

  • 函数防抖关注一定时间连续触发的事件,只在最后执行一次,而函数节流一段时间内只执行一次

例如,都设置时间频率为 500ms,在 2 秒时间内,频繁触发函数,节流,每隔 500ms 就执行一次。防抖,则不管调动多少次方法,在 2s 后,只会执行一次

使用性能分析工具

  • 浏览器的开发者工具: 浏览器提供了内置的开发者工具,可以查看网页的性能数据、内存使用情况、网络请求等。

  • Lighthouse: 是一款开源工具,可以分析网页的性能、可访问性、最佳实践等方面,并给出改进建议。

  • Chrome DevTools Performance 面板: 可以记录和分析页面的性能数据,包括 CPU 使用、内存占用、事件处理等。

  • Webpack Bundle Analyzer: 如果你使用 Webpack 进行打包,这个工具可以帮助你可视化分析打包后的 bundle 大小和模块依赖关系。

  • Chrome Trace: 生成详细的时间线跟踪,用于分析 JavaScript 执行、布局、绘制等性能问题。

懒加载和预加载

  • 懒加载(Lazy Loading): 将页面资源(如图片、视频)的加载延迟到它们进入用户视野之前,从而加快初始页面加载速度。

    例如路由的预加载(基于react和react-router使用的懒加载):

    const NewsRouter: React.FC = () => {
        const element = useRoutes([
        {
            path: "/home",
            element: LazyLoad("home/Home"),
        },
        {
            path: "/",
            element: <Navigate to="/home" />,
        },
        {
            path: "/user-manage/list",
            element: LazyLoad("user-manage/UserList"),
        },
        ...
        ]);
        return <div>{element}</div>
    };
    
    //路由懒加载的封装(按需加载)
    export const LazyLoad = (path: string) => {
    const Comp = React.lazy(() => import(`./${path}`));
    //导入下载依赖的模块
    return (
        <React.Suspense fallback={<p>加载中...</p>}>
        <Comp />
        </React.Suspense>
    );
    };
    
  • 预加载(Preloading): 在页面加载后,提前加载未来可能需要的资源,例如,当用户点击链接时,相关页面的资源已经在后台加载好。

代码优化和缓存

  • 代码优化: 通过删除不必要的代码、减少循环嵌套、避免过多的全局变量等方式,优化 JavaScript 代码的执行效率。

  • 使用缓存: 合理使用浏览器缓存、CDN 缓存等,减少不必要的网络请求。

综上,在大项目中,通过优化js代码来提高性能是很必要的任务,通过本文的讨论,我们可以应对大部分性能问题,以提升页面性能和用户体验。