web性能优化实践

2,895 阅读42分钟

前端性能优化的意义

互联网发展十分迅速,现在做的网站内容越来越多,功能越来越强大,页面也越做越漂亮。随之而来的问题是页面内容越多页面速度就会受到影响。作为用户当然希望访问的页面速度越快越好。所以对于前端工程师来说挑战越来越大。我们只有持续对我们的网站进行性能优化,才能跟的上用户的体验需求。

性能是web网站和应用的支柱。如果网站性能不好,用户会放弃访问你的网站。所以性能和我们网站的利益是相关的。网站最重要的是用户,有用户才会有业务。比如做一个电商网站,用户多了,浏览商品的人和下单的才会多。所谓的下单就是转换率。有多少人下单,业务才有多少收益。同时也希望有足够多的人来围观你的网站,这样可以产生足够多的广告收益。或者和其他平台合作进行带货。还有一点就是现在的搜素引擎会对网站的性能进行评估,高性能的网站会出现在它结果排名靠前的位置。所以如果网站性能不好,在搜索排名中都会落后。因此网站的性能是否好直接关系到你网站的用户体验、流量、搜索、转换率。基于以上问题进行网站性能优化是每个前端工程师都要关注的点。

1.png

重要的性能指标和优化目标

性能指标是我们去进行网站优化时可以参考的标准。这个是业界以及前人总结出来的作为指导性的东西。我们要做的就是站在巨人的肩膀上,参照这些指标对网站进行优化。我们可以通过访问淘宝的页面来看看都有哪些重要的性能指标。访问页面打开控制台,点击Lighthouse选项,生成性能测试报告如下:

2.png

可以看到这次测试得分是48分。每次测试会受网络的影响,因此也会出现每次测试的结果可能不一样。所以建议每次测试网页性能的时候可以多测试几下,取一个平均值。现在来分析一下测试结果。我们比较关心的指标有First Contentful Paint、Speed Index和Time to interactive。First Contentful Paint表示第一个有内容(文字或者图片)的绘制时间,即不再是白屏的时间。这里耗时1.6s,同时是绿色的,表示在这个方面做的非常好。这样给用户的体验非常好。Speed Index叫速度指数,现在Speed Index的标准是4s。淘宝页面Speed Index是2.8s,说明网页的速度是非常快的。Time to interactive表示用户可以交互的时间,点开network查看每个请求的情况。

3.png 这里我比较关心TTFB这个指标,用来衡量请求发出去到响应返回来一共花费了多少时间。以上这些都是网络加载的性能,对于性能我们还要关心网站加载完之后,用户真正开始使用过程中交互的体验。要做到交互响应足够快、画面足够流畅(帧率足够高)、异步请求足够快(1s内数据返回,返回不了加loading动画提高用户体验)。

RAIL测量模型

RAIL测量模型是google提出的一个可以量化网站性能的测量标准,通过这个模型可以用来指导我们进行性能优化的目标。每个字母都有特定的意义。

R -- Response响应(网站给用户响应的体验)

A -- Animation动画(动画是否流畅 1s 60帧)

I -- Idle 空闲(浏览器有足够的空闲时间,与Response是相呼应的)

L -- Load 加载(页面加载时间)

RAIL的目标 ---- 让良好的用户体验成为性能优化的目标

RAIL 评估标准:

响应:处理事件应在50ms以内完成。

动画:每10ms产生一帧。

空闲:尽可能增加空闲时间。

加载:在5s内完成内容加载并可以交互。

几个性能测量工具:Chrome DevTools开发调试、性能评测,Lighthouse网站整体质量评估,WebPageTest多测试地点、全面性能报告。

常用的性能测量APIs

Web标准APIs

上面提到的一些指标TTFB、Time to Interactive(可交互时间)等关键时间节点都是浏览器按照标准实现的特定API,那么我们也可以直接通过这些API获取我们想要的关键时间节点数据以及其他与性能相关的数据。

关键时间节点

浏览器有一个重要的对象performance,很多的和时间节点有关的数据都可以从这个对象上去获取。其中有一个方法getEntriesByType,和网络加载有关的重要时间节点都在navigation上。以Time to Interacrtive为例:

 let timing = performance.getEntriesByType('navigation')[0];
 let tti = timing.domInteractive - timing.fetchStart;
     

其他重要时间节点计算公式:

DNS 解析耗时: domainLookupEnd - domainLookupStart

TCP 连接耗时: connectEnd - connectStart

SSL 安全连接耗时: connectEnd - secureConnectionStart

网络请求耗时 (TTFB): responseStart - requestStart

数据传输耗时: responseEnd - responseStart

DOM 解析耗时: domInteractive - responseEnd

资源加载耗时: loadEventStart - domContentLoadedEventEnd

First Byte时间: responseStart - domainLookupStart

白屏时间: responseEnd - fetchStart

首次可交互时间: domInteractive - fetchStart

DOM Ready 时间: domContentLoadEventEnd - fetchStart

页面完全加载时间: loadEventStart - fetchStart

http 头部大小: transferSize - encodedBodySize

重定向次数:performance.navigation.redirectCount

重定向耗时: redirectEnd - redirectStart

网络状态(Network APIs)

浏览器提供了一个判断网络状态的API,那么我们就可以根据用户当前的网络的状态进行资源的加载,比如当网络状态好的时候我们加载高清的图片,当网络状态不好的情况下,我们加载不那么清晰但是体积更小的图片,让用户有更好的体验。

// 监听用户网络状态
let connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
let type = connection.effectiveType;
function updateConnectionStatus() {
       console.log("connection type of change from" + type + "to" + connection.effectiveType);
  }
 connection.addEventListener('change',updateConnectionStatus,false);

网页显示状态(UI APIs)

举一个比较实用的例子:监测用户是否离开页面。

//监听用户离开当前页面
let vEvent = 'visibilitychange';
if (document.webkitHidden != undefined) {
    vEvent = 'webkitvisibilitychange'
}
function visibilityChanged() {
     // 页面不可见
     if (document.hidden || document.webkitHidden) {
         console.log("web page is hidden");
    } else { // 页面可见
         console.log('web page is visibile');
     }
 }
document.addEventListener(vEvent,visibilityChanged,false);

上面列举了一些常用的API,有兴趣的小伙伴可以点击 developer.mozilla.org/zh-CN/docs/… 查看更多的API。接下来正式进入主题,如何进行性能优化。我分为好几个模块进行讨论:渲染优化、代码优化、资源优化、构建优化、传输加载优化。

渲染优化

这个板块我们要讨论浏览器是怎么把页面渲染出来的。网页渲染过程分很多的环节,这里我要说的是关键渲染路径。只有理解了渲染经历了几个步骤,才能有针对性的去进行相关的优化。我们知道网络资源被加载过来之后,要对脚本、css进行解析,解析完成后浏览器要对它们进行理解,然后将这些东西画到页面上。这就是渲染过程。具体做了什么,可以通过性能分析来看一下。

5.png

从上图可以看到主线程Main忙于做的任务都会列举出来,经常能看到一些重复的任务,比如计算样式(Recalculate Style)、布局(Layout)、绘制(Paint)。这几个任务就是渲染过程中的重要阶段。其实浏览器渲染页面会经历关键的5个步骤,这5个步骤我称之为关键渲染路径。无论是首次加载还是后续页面样式发生了变化,都要经历这5个步骤把页面最终呈现给用户。通过javascript来实现一些页面视觉上的变化,比如添加一个dom元素,浏览器会重新对样式进行计算,根据选择器进行重新匹配计算出哪些元素的css发生了变化。然后就是布局,把元素按照样式上规定的大小、位置绘制到页面上。接下来就是绘制,把元素画到页面上。最后一步是合成,浏览器为了提高效率,并不是把所有元素都画在同一层,最终再合成在一起形成页面。

截图.png

当浏览器拿到服务端返回的资源后都做了什么?无论是html、css还是js实质上都是文本,计算机理解不了文本。所以第一步要做的是通过解释器把这些文本翻译成能理解的数据结构。先来看下html是怎样被转换的。先把html文本转换成单个的字符。html里面有多标签,通过‘<>'标记出来的,那么这个‘<>'就可以被用做于识别,因此我们可以把字符串理解为有含义的标记,这些标记最终被换成节点对象放到一个链形数据结构里,这个链形结构就是html dom树。如下图,这样几可以把我们html中描述的嵌套关系很好的表达出来。

6.png

再来看看css部分,其实是一样的道理,当解释器遇到你可能引用了外部的css表。先把资源下载过来,对资源文本进行处理。把标记全部识别出来,看一看这个样式是描述的哪一个节点的样式,然后也会用一个树形结构把这些关系存储下来。如下图:

7.png

因此浏览器拿到资源后第一步就是构建两个对象模型,即构建DOM对象和CSSOM对象。

8.png

9.png

第二步:两棵树进行合并,构建渲染树。让浏览器理解最终要把什么画在页面上。

10.png

合并的结果如下:display:none的元素最终会被去掉,只会留下最终显示在页面上的节点。

11.png

有了这棵树之后,浏览器就知道每个节点是什么尺寸,要把它画在什么位置。之后再具体讲浏览器的布局、绘制过程。

布局和绘制

布局和绘制是关键渲染路径中最重要的两个步骤,也是开销最高的两个步骤。首先理解一下布局和绘制分别做了什么,然后再来看下如何减少布局和绘制的发生,甚至可以避免布局和绘制。布局其实就是根据"盒模型"计算每个节点精确的位置和大小。绘制是像素化每个节点的过程。在页面初次访问的过程中关键渲染路径一定会被触发。在之后整个页面使用过程中有可能还会做动画,用户的某些交互有可能还会改变页面视觉效果,都会触发这个流程。那我们可以思考一下,在某些特定的情况下是否可以不要去触发布局和绘制?答案是肯定的。因为某些样式的变化实际上不一定会触发布局和绘制。如果样式不是改变元素的height、offset这样的大小、位置信息的话就不会触发布局,比如修改背景颜色或者阴影大小。再比如有一些动画是可以利用GPU去加速的,这些动画其实可以直接走复合的过程,既不需要布局也不需要进行重绘。影响回流(布局)的操作:

  • 添加/删除元素

  • 操作styles

  • display:none;

  • offsetLeft,scrollTop,clientWith

  • 移动元素位置

  • 修改浏览器大小、字体大小 举个例子,改变图片大小,看看发生了什么。

    let cards = document.getElementsByClassName('MuiCardMedia-root'); const update = () => { cards[0].style.width = '800'px; } window.addEventListener('load',(e) => { update(); }); 打开调试工具,选中performance选项卡,进行性能分析。可以看到在load事件之后发生了重新计算样式和布局。

12.png

这只是简单的修改了元素的高度,并没有影响其他元素的变化,这个影响其实还比较小。如果说某个回流操作不仅影响本身还会导致其他元素的位置发生变化。试想一下它所触发的级连反应导致的消耗将是非常的高。甚至会导致页面出现卡顿的现象。因此应该尽量避免使用会导致回流的操作,提高页面性能。

但是有时候回流是无法避免的。那么在不可避免的情况下,还有可能会导致layout thrashing(布局抖动)。通过一个实例看一下什么是布局抖动。修改页面上所有图片的宽度,并无限循环。

window.addEventListener('load',(e) => {
    let cards = document.getElementsByClassName('MuiCardMedia-root');
    const update = (timetamp) => {
    for(let i =0; i < cards.length; i++) {
        cards[i].style.width = ((Math.sin(cards[i].offsetTop+timetamp / 1000) + 1)*500) + 'px';
     }
     window.requestAnimationFrame(update);
   }
window.addEventListener('load',(e) => {
   update();
})

会发现页面上所有的图片都进行了动画的变化(自行脑补),但是这些动画都不流畅。非常的卡顿,现在来看下性能分析会告诉我们一些什么信息。可以看到发生了非常非常非常多的Layout。并且提示发生了强制的回流(Layout Forced reflow)。

13.png

其实问题就出在for循环中,浏览器为了帮助我们提高布局的性能会尽量的把修改布局相关属性的操作推迟,但是有些情况它是无法推迟的,那就是当你要去获得布局相关的一些属性,比如offsetTop。浏览器不得不去进行最新的计算以保证你能拿到最新的结果。因此在width赋值前浏览器就被强制去进行了offsetTop计算,然后再对width进行写的操作。在循环中就会有连续的读写,每次读的操作就会强制布局立即进行重新计算,就会导致连续不断强制回流的发生,就会导致页面布局的抖动变得非常卡顿。如何避免这种问题:(1)避免回流;(2)读写分离,批量进行读的工作,再批量进行写的操作。

防止布局抖动的利器 ---- 插件 FastDom 把读的操作放在measure中,写的操作放在mutate中。地址 github.com/wilsonpage/…

const update = (timetamp) => {
    for(let i =0; i < cards.length; i++) {
         // 读取top值
         fastdom.measure(() => {
             let top = cards[i].offsetTop;
             fastdom.mutate(() => {
                  cards[i].style.width = cards[i].style.width = ((Math.sin(cards[i].top+timetamp / 1000) + 1)*500) + 'px';
              })
          })
       }
       window.requestAnimationFrame(update);
     }
 window.addEventListener('load',(e) => {
       update();
 })

13.png 使用fastdom后页面动画不再卡顿,性能分析也没有出现强制回流的警告。

复合线程与图层

复合是关键渲染路径中最后一个环节,但它其实和绘制是密切相关的。为了提高绘制的效率浏览器才有了复合。主要的作用就是把页面拆解成不同的图层,当页面在视觉上发生变化的时候,这些变化其实只影响某一个图层的变化。而其他图层不会受到影响,从而绘制就会更高效地完成。复合线程的工作就是将页面拆分图层进行绘制再进行复合。那么我们的网页是怎样被拆分成不同的图层呢?拆分的规则是什么?默认情况下是由浏览器来决定的,浏览器主要分析元素之间是否相互影响,如果某些元素对其他元素造成的影响非常大,就会被浏览器提取成一个单独的图层。这样的好处是,如果这个元素发生变化,只需要对它的这个图层进行重绘,而不去影响其他的布局。除了浏览器默认的拆分规则外,我们也可以主动的把某些元素提取到单独的图层。可以利用DevTools了解网页的图层拆分情况。

15.png

选中layers可以看到这个页面被拆分成了两个图层。鼠标放到某个图层上,页面会显示出该图层的区域。 以下几个属性只会触发复合而不去触发重绘。

transform: translate(npx,npx);
transform: scale(n);
transform: rotate(ndeg);
opacity: 0...1;

当我们使用以上属性的时候,如果可以把它们所涉及到的元素提取到一个图层,那么这些元素的发生视觉上变化的时候就只会触发复合,而不会触发布局和重绘。

16.png 考拉海购首页把上面的卡片拆为单独的图层,是因为这些卡片在鼠标悬浮的时候有动画的效果,希望这些动画效果不去触发布局和重绘,是用transform来实现的scale变换动画。

17.png

可以看到鼠标悬浮上去只触发了复合(Composite Layers)。这样就大大提高了效率。可见拆分图层可以提高网页性能,但是也不是拆分越多越好,因为图层越多开销也越高,从而适得其反影响网页的性能。因此只把特定的,能达到效果的元素提到一个图层中。

减少重绘(repaint)

上面也提到了某些属性不会触发重绘,这里就以transform为例子演示一下。打击卡片的时候让图片进行旋转。

  spin = () => {
       this.setState({spinning: true});
   }
  
  render() {
    /*根据Spinning进行判断决定是否旋转*/
    let cardClass = this.state.spinning ? this.props.classes.cardSpinning : this.props.classes.card;
    return (
        <div className={this.props.classes.root}>
            {/*添加点击事件,触发动画*/}
            <MaterialUICard className={cardClass} onClick={this.spin}>
                <CardMedia
                    component='img'
                    className={this.props.media}
                    image={this.props.image}
                    title={this.props.title}
                    height='200'
                />
            </MaterialUICard>
        </div>
    );
}
const styles = theme => ({
    root: {
        margin: theme.spacing(1),
    },
    card: {
        width: 300
    },
    cardSpinning: {
        width: 300,
        animation: '3s linear 1s infinite running rotate'
    },
    media: {
        height: 200,
        width: 300,
        objectFit: 'cover',
    }
});

css
@keyframes rotate {
    0% {
        transform: rotate(0deg);
        /* opacity: 0.1; */
        /* width: 300px; */
        /*transform: scaleX(1);*/
    }
    /* 50% {
        opacity: 0.5;
    } */
    100% {
        transform: rotate(360deg);
        /* opacity: 0.1; */
        /* width: 600px; */
        /*transform: scaleX(2);*/
    }
}
 

效果:点击图片开始旋转。

18.png 开始进行性能分析,点击图片前后对比。

18.png 点击图片前,没有什么运行的任务。主线程基本是空闲的。

19.png 点击图片后,就触发了动画Animation,主线程就变得繁忙了。首先会重新计算样式,然后对图层树进行更新,紧接着进行复合。没有发生任务布局和重绘的操作,达到了预期的效果。现在把动画调整为直接修改宽度属性,此时页面肯定会发生布局和重绘。

css
    @keyframes rotate {
    0% {
        /* transform: rotate(0deg); */
        /* opacity: 0.1; */
        width: 300px;
        /*transform: scaleX(1);*/
    }
    /* 50% {
        opacity: 0.5;
    } */
    100% {
        /* transform: rotate(360deg); */
        /* opacity: 0.1; */
        width: 600px;
        /*transform: scaleX(2);*/
    }
}

20.png

性能分析选中Rendering,勾选Paint flashing。发生重绘的元素会被标记为绿色,如上图所示。元素之间相互布局相互影响,导致关键路径重新进行渲染所需要的开销变大。通过以上例子就是为了告诉大家,尽量使用transform而不要直接去修改影响布局和绘制的属性。

把使用transform或者opacity的属性提取到单独的图层里,在root里设置willChange属性值为'transform',这样浏览器就知道这个元素应该被提取到一个单独的图层里。

root: {
        margin: theme.spacing(1),
        willChange: 'transform'
    },
    

高频事件防抖

某些事件触发频率非常高,甚至会超过帧的刷新速率。比如滚动事件、touch事件等,这类事件在一帧里会触发多次。导致浏览器在一帧里对这类事件进行多次响应,如果它们对应的事件处理函数消耗比较高,那么这一帧里任务就很繁重。实际上一帧里并不需要处理多次,比如滚动,我们只关心最后滚动到哪里。而整个过程中的其他滚动会导致任务量过重,就不能保证一帧能在16ms内完成,就会导致页面卡顿(抖动)。解决页面卡顿大利器requestAnimationFrame。为什么requestAnimationFrame可以解决页面卡顿,首先看一下帧的生命周期。

20.png 事件触发js触发视觉上的变化,此时一帧就要开始了,在layout和paint之前会调用rAF。也就是说requestAnimationFrame是在布局和绘制之前做的事情。因此可以利用requestAnimationFrame先把要做的处理做完之后再去进行布局和绘制,极大提高效率。此外,requestAnimationFrame是由js进行调度的,会尽量保证每一次绘制之前去触发rAF以满足达到60fps的效果。利用ticking变量控制触发频率,这样即使事件的触发频率很高,也能不按照它自己的触发频率执行,而是按照requestAnimationFrame的调度频率进行触发。

// 去抖动(页面卡顿)
function changeWidth(rand) {
    let cards = document.getElementsByClassName('MuiCardMedia-root');
     for(let i =0; i < cards.length; i++) {
         cards[i].style.width = ((Math.sin(rand / 1000) + 1)*500) + 'px';
        }
    }
    let ticking = false
    window.addEventListener('pointermove',(e) => {
        let pos = e.clientX;
        // 防止一帧内多次触发事件
        if(ticking) return;
        ticking = true;
        // 把要触发的任务changeWidth包在requestAnimationFrame里面
        window.requestAnimationFrame(() => {
            changeWidth(pos);
            ticking = false;
        });
    })
    

代码优化

这个模块是研究在代码层面能做哪些事情提高页面的性能。我门的代码有javascript、html、css、图片、文字等。其中javascript的开销是最大的。除了加载过程中的开销,js还会经历解析和编译过程,然后再执行,都是耗时的过程。

21.png

从图中可以看到,假设js和图片都是170kb左右。理论上通过网络加载时间是相同的。但是js之后要经过编译、解析、执行。编译耗时2s,执行耗时1.5s。图片解码耗时才0.064s。图片绘制过程才0.028s。因此可以看到js的开销较大。js加载过程还会造成页面的阻塞,影响用户交互。虽然js的编译、解析是跟浏览器处理引擎有关,但是如果在代码层面进行配合,实际上可以优化这个过程。有两种解决方案:代码拆分,按需加载和tree shaking(代码减重)。另外从解析和执行来看,应该减少主线程的工作量,避免长任务,避免超过1kb的行间脚本,使用rAF进行时间调度。

配合v8优化代码

v8是chrome浏览器的js引擎,它的编译原理如下:

22.png

拿到js脚本后进行解析工作,翻译成抽象语法树。然后解释器Interpreter去理解写的东西是什么意义,在把代码变成机器码去运行之前编译器会进行一些优化工作。但是不是所有优化都是合适的,当编译器发现它做的优化不合适的时候,会进行逆优化,即把之前做的优化去掉。这种情况实际会降低运行效率。因此我们在代码层面做的优化是尽量去满足编译器优化的条件。编译器怎么做的优化,我们就按照它希望的去写代码。同时还要回避造成逆优化的代码。下面看一个例子:

const {performance,PerformanceObserver} = require('perf_hooks');
const add = (a,b) => a + b;
const num1 = 1;
const num2 = 2;
performance.mark('start');
for(let i = 0; i < 1000000; i++) {
    add(num1,num2);
}

add(num1,'str');

for(let i = 0; i < 1000000; i++) {
    add(num1,num2);
}
performance.mark('end');

const observer = new PerformanceObserver((list) => {
    console.log(list.getEntries()[0]);
})

observer.observe({entryTypes:['measure']});

performance.measure('测量1','start','end');

测试结果:运行时间48.203922ms。

23.png

把add(num1,'str');这行代码去掉,再测试。运行时间是27.781225ms。有明显的速度提升。之前的运算都是两个数字相加,而且参数非常稳定。即使调用了10000000次,在编译过程中会对add函数进行优化。但是当执行add函数的时候第二个参数类型发生了变化,之前是数字现在变成了字符串。那么就不能再利用已经做过优化的逻辑了。所以就要把之前做的优化撤销掉。这样就会带来一定的延迟。

24.png

函数优化

v8引擎会对函数进行懒解析,即当函数真正进行调用的时候才会去解析函数体。不解析的话就不用为其创建语法树,那么在堆的内存空间里不用对这个函数进行内存的分配。对性能是一个极大的提升。函数的解析方式有lazy parsing懒解析和eager parsing饥饿解析。其中懒解析是默认的解析方式。不可否认懒解析能提高性能,但是现实中有时候还是需要函数被立即执行。那么问题就来了,如果一个函数是立即执行的,在刚开始声明的时候进行默认的懒解析。但是发现它又要立即执行,于是又进行了快速的饥饿解析,导致对同一个函数先进行懒解析再进行饥饿解析。反而降低了效率。因此需要一种方法告诉解析器该函数需要立即被执行,让解析器对它进行eager parsing。那么在代码里如何告诉解析器进行eager parsing。其实很简单,只需要加一对括号就可以告诉解析器。

const add =((a,b) => a + b);
const num1 = 1;
const num2 = 2;
add(num1,num2);

但是在项目中会对js进行压缩,又会把这对括号给去掉。导致没有办法通知到解析器。可以利用Optimize.js插件 (github.com/nolanlawson…) 又帮我们把括号加回去。

对象优化

  • 以相同顺序初始化对象成员,避免隐藏类的调整。

    class RectArea { // HC0
          constructor(l,w) {
              this.l = l;// HC1
              this.w = w;// HC2
          }
      }
      const rect1 = new RectArea(3,4);
      const rect2 = new RectArea(5,6);
    

    javascript是弱类型的语言,我们在写代码的时候不会去声明变量的类型。但是对于编译器而言,它最终还是需要确定变量的类型,因此在解析过程中,编译器根据自己的理解会给变量赋一个具体的类型。有多达21种类型,称为隐藏类型(hidden class)。之后编译器所做的优化都是根据hidden class进行的。Hidden Class 隐藏类型是V8为提高查询速度而生的。替代动态查询,在一个对象创建/改变/删除时,都会造成对应的HC发生变化。V8运行时为会每个对象创建HC,主要是为了记录一个对象的结构。当声明了RectArea类后,就会创建第一个隐藏类型HC0,当给this.l进行赋值的时候又会创建一个隐藏类型HC1,同理给this.w赋值的时候也会创建一个隐藏类型HC2。创建一个对象就创建了三个hidden class,编译器会进行优化,当再创建对象的时候,如果还是按照这样的顺序去做,编译器就会复用这三个hidden class。再举一个不能复用隐藏类型的例子说明以相同顺序初始化对象的重要性。

    const car1 = {color: 'red'}; // HC0
    car1.seats = 4; // HC1
    const car2 = {seats: 2}; //HC2
    car2.color = 'blue'; // 又会创建HC3
    

    创建car1的时候会创建第一个隐藏类型HC0,给car1添加seats属性的时候会创建第二个隐藏类型HC1。接下来创建car2对象,有一个seats属性。此时就不能复用HC0,HC0里属性是color属性,对car2进行创建的时候,声明的是seats的属性,所以没有办法去复用。只能再去声明一个新的隐藏类型HC2。那为什么不能复用HC1呢,实际上HC1不只包含seats这个属性,还包含了color属性。而且还会强调属性的顺序。就是说隐藏类型底层会有一个描述的数组会去强调属性声明的顺序或者说是索引的位置。所以HC1包含了对car1这个整体的描述。给car2添加color属性的时候又会创建HC3,可以看到在创建car2的时候完全没法去复用创建car1时底层去生成的隐藏类型,那么效率就不会高。

  • 实例化后避免添加新属性

     const car1 = {color: 'red'};
     car1.seats = 4; 。尽量不要用这种方式追加属性。
    

    color属性是创建car1对象时自带的属性,称为In-object属性。后面追加的属性seats叫做Normal/Fast属性,被存储在property store的地方。解析器是通过一个描述数组间接去查找该属性。因此属性查询的过程就没有查找对象本身就有的属性快。所以尽量不要用这种方式追加属性。建议尽量使用ES新的规范,新的规范在设计的时候会考虑到以上问题,帮助大家写出更加规范的代码迎合解析器去更好进行优化的代码。

  • 尽量使用Array代替array-like对象

    array-like对象即类数组对象,比如包含函数参数变量信息的arguments对象。它本身是一个对象,但是可以通过索引去访问属性。还有一个length属性,就像一数组一样,但实际上又不具备数组的方法,比如不能进行forEach遍历,因此称之为类数组。V8引擎会对真正的数组进行极大的性能的优化。但不会对类数组做相同的优化。虽然通过间接的方法Array.prototype.forEach.call可以去遍历类数组的属性,但是效率没有在真实数组上高。推荐先把array-like对象转换为真实的数组,然后再进行遍历。转成真实数组其实也是有开销的,但是V8做过实验,结论是即使把类数组转换成真正的数组再去遍历,也比不去转换直接遍历效率要高。因此我们最好遵循它的要求。

    const arr = Array.prototype.slice.call(arrObj,0);
    arr.forEach((val,index) => {
      console.log(val);
    })
    
  • 避免读取超过数组的长度 --越界问题

     function foo(array) {
         for(let i = 0; i <= array.length; i++) { // 越界比较
             if (array[i] > 1000) {
                 console.log(array[i]);
             }
         }
     }
    
     foo([3,4,5]);
     
    

    存在的问题:

    1. 数组的length是3,i会自增到3,但是array的索引没有3,那么就会出现array[3]为undefined,无效数据。
    2. 造成undefined和1000比较。
    3. 在对象上找不到属性的时候会沿着原型链去查找。造成额外的开销。
  • 避免元素类型的转换

      const array = [1,2,3];// 数组元素都是整型,编译器可以判断出来,会进行一个优化。规定了这个数组接收的类型是整型。分配一个PACKED_SMI_ELEMENTS类型。
      array.push(1.1); // 之前所做的优化就全都无效了。会变成PACKED_DOUBLE_ELEMTNTS。会对编译器造成额外的开销。
      
    

    数组元素都是整型,编译器可以判断出来,规定这个数组接收的类型是整型并分配一个PACKED_SMI_ELEMENTS类型,同时会对PACKED_SMI_ELEMENTS类型进行一系列优化。但是之后push元素1.1后,编译器之前所做的优化就全都无效了,又会分配一个类型PACKED_DOUBLE_ELEMTNTS,会对编译器造成额外的开销。

html优化

html优化的空间比较小,注意以下几点。

  • 减少iframes使用,或者延迟iframes加载,不会影响父文档加载。
  • 压缩空白符。
  • 避免节点深层级嵌套,避免生成dom树时占用太对内存。
  • 避免table布局,table开销非常大。
  • 删除无效内容,减小资源大小。
  • css、js尽量外链。
  • 删除元素默认属性。

css优化

  • 降低css对渲染的阻塞

    (1)尽量早的完成css的下载,尽早进行解析。

    (2)降低css的大小,首次只加载对首屏或当前页面有用的css。暂时用不到的css可以进行推迟加载。

  • 利用GPU进行完成动画。

  • 使用contain属性。

    contain是开发者可以跟浏览器进行沟通的属性。contain:layout告诉浏览器盒子里所有的子元素跟盒子外元素没有任何布局上的关系,即盒子里面如何变化都不会影响外面的元素。外面元素变化也不会影响盒子里的元素。可以减少回流或者重新布局时的计算。可以参考这个例子: codepen.io/rachelandre…

资源优化

压缩与合并

压缩和合并可以减少http请求数量、减少请求资源的大小。资源越小加载越快,就越快呈现给用户。

html压缩

  • 使用在线工具进行压缩
  • 使用html-minifier等npm工具

css压缩

  • 使用在线工具进行压缩
  • 使用clean-css等npm工具

js压缩

  • 使用在线工具进行压缩
  • 使用webpack对js在构建时压缩

css&js合并

图片优化

用下图概括图片优化的方案 2.png

  • 选择合适的图片格式(Choose the right format)

    不同格式的图片优缺点不同,在不同场景中使用特定的图片有更大的优势。

    图片格式比较

    1. JPEG/JPG

      优点:JPG是一种有所压缩的图片,这种图片进行了压缩以减小图片本身的体积。经过压缩后的图片色彩感还非常好。即压缩比高且色彩保持的好。当压缩比为50%的时候,画质还能保持在60%的水平。

      缺点:图片压缩后边缘会有锯齿感。如果图片比较强调纹理、边缘(设计logo图标)的时候JPG就不合适,会使logo边缘特别的粗糙。

      适用场景:当需要展示比较大的图片同时要比较好的画质效果时优先选择JPG格式的图片。比如首屏轮播图。

    2. PNG

      优点:弥补JPG的缺点,纹理做的非常好。

      缺点:保留了更多细节的东西,图片体积相较要大些。

      适用场景:做小的图标或者logo。

    3. Webp

      优点:跟PNG有同样的质量,但是压缩比例比PNG高。

      缺点:不是标准,是google提出来的,某些浏览器不一定支持。

  • 合适的图片大小(Size appropriately)

    不要传过大的图片到客户端,过大的图片在网络上时一种浪费。需要多大的图片就传多大的图片。

  • 适配不同屏幕(Adapt intelligently)

    设计不同尺寸的图片适配不同屏幕尺寸。

  • 压缩

  • 图片资源优先级(Prioritize critical images)

    重要的图片(如首屏图片)先进行加载。

  • 懒加载(Lazy-load the rest)

    先加载部分图片,随着用户滚动页面继续加载图片。

  • 利用自动化工具(Take care with tools)

图片加载优化

lazy load
  1. 原生的图片懒加载方案

    在img标签上加上loading="lazy”即可。

    3.png 需要浏览器进行支持,而且自定义和可扩展性并不好。所以通常我们会使用第三方插件实现懒加载效果。

  2. 第三方图片懒加载方案

    相关插件有:verlok/lazyload,yall.js,Blazy

使用渐进式图片

3.png

图片会经历一个从模糊到清晰的过程。从低像素到高像素的过程。这样从一开始就可以上让用户看到完整的图片,体验会比较好。开发时可以找UI设计生成这种渐进式的图片。

使用响应式图片

在不同屏幕尺寸的设备上都能有一张合适的图片让用户达到最佳的视觉体验。在img标签上使用srcset属性,设置一系列不同尺寸的图片,对应不同的屏幕尺寸。如下所示:

5.png

浏览器根据屏幕的大小去下载其中一张最合适的图片展示给用户。浏览器是根据sizes属性去计算的,sizes设置100vw,图片在横向的占比100%。比如屏幕尺寸是1440,超过1400但是小于1800,所以浏览器会选择1800w的图片。

字体优化

字体最常见的问题是字体未下载完成时,浏览器隐藏或者自动降级,导致字体闪烁。分为两种情况:

情况一:Flash Of Invisible Text --文字从隐藏到看到的闪烁变化过程。

情况二:Flash Of Unstyled Text -- 文字开始是一种默认样式,经过样式渲染过后又是另外一种样式,也会造成闪烁的过程。

这两种问题是不可避免的,因为字体下载需要时间。在css下载完成之前浏览器必须作出选择,要么等待下载完再显示文字,要么给你一个默认的文字,等字体下载完成后再重新渲染我们想要的文字样式。我们更倾向于后一种,即使有闪动也能第一时间让用户看到内容。可以通过一个属性font-display来控制浏览器的行为。虽然这个属性比较新,但基本所有浏览器都支持了。

1.png

font-display一共有5个值:auto/block/swap/fallback/optional

2.png 上面这个图非常清楚的描述了block/swap/fallback/optional几个值的行为。

block一开始不让文字进行显示,3s后如果字体下载完成了就用该字体显示,如果3s后还没下载完就选择用默认字体代替。

swap-开始就用默认字体显示,直到需要的字体下载完成进行替换。

fallback是对block的优化,100ms之后根据字体是否下载完成选择展示默认字体或者需要的字体。

optional 100ms后如果字体还没下载完成用默认字体展示,即使字体下载完成也不更换了。

2.png

构建优化

webpack的优化配置

Tree-shaking

Tree-shaking是一种减少资源体积的办法。有很多的代码其实在最终生产的包里是用不到的,在打包前可以进行处理,把用不到的代码去掉。Tree-shaking的使用条件代码必须是模块化的。基于ES6 import和export形式编写的代码。webpack4提供了我们两种模式development和production。production模式会默认给我们开启引入TerserPlugin,实际上Tree-shaking就是由TerserPlugin来处理的。其简单的工作原理是找到入口文件,入口相当于树的根节点,去看入口文件引用了什么东西。又会进一步去分析这些应用的包或者模块里面又引用了什么模块。不断地进行分析之后把所有需要的东西保留下来,把那些引入了但是没有用到的shaking下去,最后打包生成的bundle只包含运行时真正需要的代码。但是Tree-shaking也有局限性,它需要基于ES6的模块化语法,但是有时候会涉及到在全局作用域上添加或者修改属性,export是体现不出来的,就会被shaking掉,代码就会出问题,我们可以告诉webpack哪些东西在Tree-shaking过程中不要去掉。在package.json中添加sideEffect,把所有不需要被shaking掉的文件放在sideEffect数组里。比如css不是用模块化方式去写的,有可能会被shaking掉,加入到sideEffect后webpack就不会把css代码去掉。

3.png

最后还要注意babel配置的影响,我们通常会使用preset,preset就是把常用的babel插件做了一个集合,我们只用调用这个集合就可以使用这些插件。最常用的就是preset-env。在preset有一个问题,做转码的时候会把ES6模块化的语法转成其他模块化的语法,我们肯定希望保留ES6的模块化语法,所以我们可以加一个modules的配置并设置为false,表示不需要转换成其他的模块化语法。这样Tree-shaking才能起到作用。

4.png

webpack依赖优化

对依赖进行优化可以提高webpack的打包速度,有两种方式可以提速webpack打包过程,第一种是利用noParse参数提高构建速度。noParse的意思就是不解析,直接通知webpack忽略较大的库。如引用的第三方工具库,本身体积比较大,而且没有使用模块化的方式进行编写,本身也不会有外部的依赖,比较独立。那么就不对这样的库进行解析。

4.png

在module中配置noParse参数,设置loadsh,就告诉webpack不需要对loadsh进行递归解析。第二种方式通过DllPlugin插件,避免打包时对不变的库重复构建提高构建速度。比如项目中引入的react,react-dom,如果可以把它提取出来变成类似于动态链接哭的引用,每一次构建不需要重复构建,只需要引用之前已经构建过的固定的东西就可以了。创建一个webpack.dll.config.js文件。entry包含了需要创建成动态链接库的类,通过webpack.DllPlugin生成动态链接文件的描述文件。

4.png 运行webpack.dll.config.js,生成如下动态链接文件react.dll.js和描述文件react.manifest.json。有了这个两个文件就可以对构建过程进行优化了。

5.png 接下来在webpack.config.js中添加DllReferencePlugin插件引用上面生成的描述文件,这个描述文件会告诉webpack在构建过程中怎么找到动态链接文件。

5.png 运行webpack.config.js,对比优化前后可以看到优化前打包时间是8159ms,优化后打包时间是5428ms。

5.png

6.png

webpack代码拆分

对于大型的web应用,如果我们把所有的东西都打包到一起是十分低效和不可接受的。需要把bundle拆分成若干个小的bundles/chunks。把大的文件拆分成小的文件分散进行加载,先加载更重要的文件以缩短首屏加载时间,可提升用户体验。第一种方式在entry中添加其他入口,这样工程就会被拆分成多个不同的bundle。这种方式比较直接,但是需要手工去添加和维护。第二种方式通过splitChunks插件提取公有代码,拆分业务代码与第三方库。

5.png 提取第三方库,拆分成独立的bundle。匹配node_modules中我们引入的库。构建后实际业务逻辑app.bundle.js体积明显减小了。优化前app.bundle.js体积是101KiB,优化后体积是2.33KiB。同时会在build目录下生成vendor.bundle.js。这样就把所有的依赖给拆分出来了。

5.png

6.png

7.png

提取公共代码,匹配src下的文件。

8.png 在入口文件index.jsx和index1.jsx中我都引入了a.js文件中的方法a并执行。打包后会在build目录下生成common.bundle.js。即把两个入口文件中的公共代码提取出来了。

9.png

10.png

webpack资源压缩

(1)TerserPlugin压缩js。

(2)mini-css-extract-plugin提取css,optimize-css-assets-webpack-plugin压缩css。

12.png

(3)HtmlWebpackPlugin - minify压缩html。在生成模式下会默认启用minify。

webpack持久缓存

缓存可以提高用户再次访问网站的体验,加快网络加载的速度。管理好缓存对我们来说至关重要。采用持久化缓存方案来管理缓存,即每个打包的资源文件有唯一的hash值。在每个静态资源的后面加一个hash值,这个hash可以通过文件的内容计算出来。因为hash有一个特点,它是一个离散唯一的值,如果文件的内容不变那么其对应的hash值也是不变且唯一的。一旦文件内容发生改变只有受影响的文件hash变化。根据hash这个特点,就可以做一个增量式的更新。这样我们就可以充分利用浏览器缓存,在保证用户体验的情况下,还能使网站进行平稳的更新过渡。

webpack很早就支持持久化缓存解决方案了,在生成打包文件的过程中会去计算文件的hash值,在为文件命名的时候把这个hash值拼接到文件名上就可以了。如提取css时filename的命名方式。

6.png 打包后文件都会带上hash值。

13.png

传输加载优化

启用压缩Gzip

Gzip是用来进行网络资源压缩,减少资源文件在网络传输大小的技术,压缩比可高达90%。在网络传输过程中对资源进行实时的动态的压缩,Gzip是唯一可以选择的技术。以nginx举例说明如何配置Gzip压缩。需要把webpack打包完成后的资源部署到nginx上去。把打包后的文件copy一份放到/Users/lanxiang/dereck文件夹下面,进入ngnix.conf配置文件并修改root路径。测试页面能够正常访问。

20.png

22.png

关注vendor和app两个主要的js文件,大小分别是233kB和8.7kB。

再修改配置文件开启Gzip。在http对象中添加以下内容。

23.png (1)gzip on开启gzip;

(2)gzip_min_length 1k 对至少1k的文件才进行压缩;

(3)gzip_comp_level 6压缩级别,1~9。压缩比例越高对cpu的消耗就越高。取6是一个比较合适的值。

(4)gzip_types 压缩文件的类型,对文本类文件着重进行压缩,效果会比较好。

(5)gzip_static on直接利用已经压缩过的静态资源。

(6)gzip_vary on 在响应的头部添加一个vary的属性,告诉客户端是否启用了gzip压缩。

(7)gzip_buffers 4 16k优化压缩过程。

(8)gzip_http_version 1.1使用http版本1.1.

24.png

开启gzip压缩后vendor和app两个主要的js文件,大小分别是72.9kB和3.7kB

启用Keep Alive

http Keep Alive可以对TCP连接进行复用。当我们和服务器建立TCP连接之后,接下来的请求就不需要重复的去建立TCP连接了。对于请求量比较高的网站而言可以大大节约在网络加载时候的开销。http 1.1开始这个参数就是默认开启的。网站资源加载过程中请求消耗的时间不仅仅是文件内容加载、下载的时间,还有很多前置时间。比如Initial connection,其实就是建立TCP连接的时间。为什么会叫Initial connection?是因为默认情况下会开启Keep Alive,跟服务器通信正常情况下只需建立一次连接。

25.png

第二次请求就没有Initial connection了。是因为会对第一次请求的TCP连接进行复用。

26.png

可以在响应头中看到是否启用了Keep Alive。

27.png 跟Keep Alive相关的还有两个参数,通常根据网站实际的请求量和用户量进行相关的配置。下面举例说明基于ngnix如何进行配置。

29.png

keepalive_timeout代表超时的时间,当客户端和服务端进行TCP连接后,服务端会尽量保持住这个连接,但是如果客户端超时一直不使用的话服务端会把这个连接关掉。我们设置超时时间为65s,即过了65s客户端都没有使用这个TCP连接服务端就关闭掉。keepalive_timeout设置0代表不启用Keep Alive。第二个参数重要的keepalive_requests表示客户端利用这个TCP连接可以发送多少个请求。我们设置100表示发送的请求达到100后服务端会关闭掉TCP连接。第101个请求就必须要重新建立TCP连接。

HTTP资源缓存

使用HTTP缓存主要是为了提高重复访问时资源加载的速度,提高用户体验。

30.png 缓存方案:

  1. Cache-Control/Expires
  2. Last-Modified + If-Modified-Since
  3. Etag+If-None-Match 在ngnix配置文件中修改location部分添加缓存。

31.png html是不希望被缓存的,配置Cache-Control "no-cache"不需要进行缓存。添加后面两个是为了兼容老版本http。因为http 1.0没有实现Cache-Control。js和css需要缓存,设置过期时间expires为7d。7天之内访问都从浏览器缓存中取。

服务端如何知道客户端请求的资源是否发生变化?利用的是Etag,Etag相当于文件资源的唯一标识,这个是在服务端生成的。在第一次请求的时候会告诉客户端这个文件资源的标识是什么。

截图 (1).png

再次请求的时候会询问服务端ETag是否匹配。If-None-Match,如果不匹配了就要从服务器端拿最新的资源。如果还匹配,服务端返回304,客户端就从缓存里去拿文件。 ngnix默认开启了Etag技术。

截图 (3).png

Last-Modified + If-Modified-Since与Etag+If-None-Match是类似的,但是它跟时间相关,会受到时间精准性的影响。客户端和服务端时间可能不同步。因此不建议使用Last-Modified + If-Modified-Since进行缓存。

Service workers

截图 (7).png Service worker是在客户端和服务端建立一个中间层,对资源进行存储,离开服务端客户端也能正常访问到资源。

使用Service workers可以加速重复访问,并支持离线访问页面。可以让页面像原生应用一样在离线状态也能进行访问。这里介绍在webpack中如何配置Service workers。我们需要生成一个asset-manifest.json文件,使用webpack-manifest-plugin生成,这个文件定义了哪些资源需要缓存。precache-manifest.***.js包含了缓存文件版本信息。

32.png

截图 (4).png

44.png

再使用workbox-webpack-plugin插件生成service-worker.js

33.png

workbox.precaching.precacheAndRoute表示注册成功后要立即缓存的资源列表。 36.png

35.png swUrl就是用workbox-webpack-plugin插件生成service-worker.js。调用Worker.register()就可以启动功能了。

55.png