页面性能优化 - 全面解读!

2,490 阅读15分钟

一、常规优化手段

白屏 -> 首次渲染

- loading:
    - 在页面最前面加loading相关的html和css
    - 结合html-webpack-plugin插入loading(html+css)
    - prerender-spa-plugin(`暂记`)
    - 内联CSS(`不考虑缓存策略的话`)
- 骨架屏<br/>
    Q: css文件的加载阻塞骨架屏的渲染<BR/>
    A:使用preload,伪代码如下:
    ![](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/8/16/16c9923d9d085200~tplv-t2oaga2asx-image.image)

缓存策略

精简打包的代码

动态polyfill

  • polyfill.io:根据浏览器User-Agent头,判断其支持的特性,返回合适的polyfill。

webpack相关优化

  • Tree Shaking
    • 关闭babel的模块处理:modules: false; pachage.json配置sideEffects
    • 对于lodash、underscore这样的工具库来说尤其重要,开启了这个特性后:
      import { capitalize } from 'lodash-es';
      
      • 效果
      import {a} from xx 
      转换为 
      import {a} from 'xx/a'
      
      • 原理
        // a.js
        export function a() {}
        // b.js
        export function b(){}
        // package/index.js
        import a from './a'
        import b from './b'
        export { a, b }
        // app.js
        import {a} from 'package'
        console.log(a)
        
        结果treeShaking后:
        // a.js
        export function a() {}
        // b.js 不再导出 function b(){}
        function b() {}
        // package/index.js 不再导出 b 模块
        import a from './a'
        import b from './b'
        export { a }
        // app.js
        import {a} from 'package'
        console.log(a)
        
        配合 webpack 的 scope hoistinguglify 之后,b 模块的痕迹会被完全抹杀掉。
      • 副作用
        如果 b 模块中添加了一些副作用,比如一个简单的 log:
        // b.js
        export function b(v) { reutrn v }
        console.log(b(1))
        
        处理后 b 模块内容变成了:
        // b.js
        console.log(function (v){return v}(1))
        
        注意:b文件中保留了console的代码。
      • sideEffects 设置为false,即不管它是否真的有副作用,只要它没有被引用到,整个模块/包都会被完整的移除。(上面的b文件被移除)
      • 注意事项: shim或者polyfill慎用!
        不是导出使用的模块需声明:
        "sideEffects": [
            "*.css",
            "src/javascript/base/da.js"
          ]
        
  • splitChunks
  • import()

可交互 -> 内容加载完成

  • 懒加载
    • 监听scroll事件
    • Intersection Observer获取元素可见性
  • placeholder(提前占位)
    和骨架屏不同。其解决的问题:文本图片加载完前后,由于高度被撑开,导致闪屏的现象。三方组件:react-placeholderreact-hold

直出HTML(同构)优化 (缓存时注意拉取CGI接口的参数处理)

  • 方案
    • 接口动静分离 & Redis缓存(node层缓存html)
      • 静态数据在node层获取然后渲染;动态数据前端拉取并渲染
    • PWA直出优化(前端缓存html)
  • 同构直出的容灾策略
    • 前端渲染和直出页面的访问路径不同
    • 直出出错时,转发到前端渲染路径

PWA

  • 核心:Web App Manifest,Service Worker,Push API & Notification API,App Shell & App Skeleton

Service Worker

  • 基于HTTPS
  • 大部分API都是基于promise-based
  • 运行在独立的worker进程(webworker)
  • 离线缓存
  • 弱网快速访问
  • 使用lighthouse测试页面性能,根据评估结果,针对性优化。
  • 错误监控 / 数据统计

  • 一般监听load事件注册
  • 作用域
    //只会对topics/下面的路径进行优化
    navigator.serviceWorker.register('/topics/sw.js');
    

  • 生命周期

    • 注册

      指定serviceworkerJS文件的位置,加载解析执行;load事件中注册

    • 安装

      将指定的静态资源进行离线缓存

    • 激活

      对旧缓存做删除等处理;接管控制权

  • SW更新机制

    背景SW 没有自动更新的逻辑,它需要在页面加载(一次跳转)之后才会去请求sw.js
    解决:由于浏览器判断sw.js是否更新是通过字节方式,因此修改cacheName会重新触发install并缓存资源。此外,在activate事件中,我们需要检查cacheName是否变化,如果变化则表示有了新的缓存资源,原有缓存需要删除。

    • 新的SW.js文件下载,并触发install事件。
    • 此时,旧的SW还在工作,新的SW进入waiting状态(此时两个SW同时存在,旧的SW掌管当前页面)。
      • 等到下一次页面跳转(二次跳转)才能展示最新的页面。
      • install中缓存资源后,self.skipWaiting():
        //caches是全局变量
        self.addEventListener('install',e =>{
          e.waitUntil(
            caches.open(cacheStorageKey)
            .then(cache => cache.addAll(cacheList))
            .then(() => self.skipWaiting())
          )
        })
        
      • 【workbox另一种思路】因为新的SW会进入waiting状态,所以在waiting阶段,采用一定的策略来进行页面的刷新:如弹窗提示用户是否刷新,若刷新则调用wb.messageSW({type: 'SKIP_WAITING'});触发message事件。【webWorker】main.js中做相关逻辑处理,通过postMessage传递消息控制worker
        self.addEventListener('message', (event) => {
            if (event.data && event.data.type === 'SKIP_WAITING') {
                // the new v2 Service Worker will immediately kill the old v1 activated Service     Worker once the v2 Service Worker installs.
                self.skipWaiting();
            }
        });
        
    • 一旦新的SW接管,则会触发activate事件;可以在此处对旧缓存做删除等处理;不重刷,接管控制权:self.clients.claim()
      self.addEventListener('activate',function(e){
          e.waitUntil(
              //获取所有cache名称
              caches.keys().then(cacheNames => {
              return Promise.all(
                  // 获取所有不同于当前版本名称cache下的内容
                  cacheNames.filter(cacheNames => {
                      return cacheNames !== cacheStorageKey
                  }).map(cacheNames => {
                      return caches.delete(cacheNames)
                  })
              )
              }).then(() => {
                  //直接接管当前页面的权限
                  return self.clients.claim()
              })
          )
      })
      
  • 注意

    • 设置sw.jsmanifest.json静态资源的缓存策略:不缓存,必须校验
    • 冷启动,预加载
    • 只缓存重要的页面如主页,链接,最近的文章等
    • 不缓存图片,视频和大的文件
    • 定期清除旧的缓存文件
    • 提供一个”缓存到本地”的按钮,以便用户可以自行选择
    • 降级方案:增加降级开关
      if('serviceWorker' in navigator) {
          fetch('./cas').then(() => {
              if(降级) {
                  //注销掉所有sw
                  unregister();
              }else {
                  //注册
                  register();
              }
          })
      }
      
  • 首次启动优化
    背景:首次加载时没有资源,所以会走线上,等于没优化
    方案:构建时,把整个项目用到的资源输出到一个list,然后inline到sw.js。当sw install,就会把这个list的资源全部请求进行缓存。这样做的结果是,无论用户第一次进入我们站点的哪个页面,都会把整个站点所有的资源都加载回来并缓存。

Redux 与 IndexDB 结合


二、资源预加载(静态 & 动态)

涉及内容:
 - link相关(rel、media)
 - defer、async
 - 缓存(4种缓存、缓存策略、ServiceWork)
 - 优化网络(H2 PushPreload/Prefetch、域名拆分)
 - 推送JSON/json内联,加速首页渲染
 - 浏览器中各资源加载的优先级

前言

  • 背景

当我们需要某些网络资源时,加载和执行往往耦合在一起,下载完立即执行,而加载过程是阻塞式的,延长了onload时间。因此如何在资源执行前预加载资源,减少等待网络的开销便是我们要探讨的问题。

  • 常规做法

    附一张不同资源浏览器优先级的图示(来源):

    不同资源浏览器优先级

    1. async/defer: 无阻塞加载

      async defer

      • defer:DOMContentLoaded事件触发前执行
        在现实当中,延迟脚本并不一定会按照顺序执行,也不一定会在DOMContentLoaded事件触发前执行,因此最好只包含一个延迟脚本
      • async:加载完立即执行,无法控制执行时机和执行顺序。适用于无依赖的外部独立资源

      不足:仅限于脚本资源;执行时机不可控或存在执行顺序问题,用于非关键资源。

    2. 使用ajax加载资源:可以实现预加载。

      不足:优先级较低,无法对首屏资源提前加载。

    3. Webkit浏览器预测解析:chrome的预加载扫描器html-preload-scanner通过扫描节点中的 "src" , "link"等属性,找到外部连接资源后进行预加载,避免了资源加载的等待时间,同样实现了提前加载以及加载和执行分离。
      原始解析做法:


      采用预解析(扫描)器:
      不足:

      • 仅限解析HTML中收集到的外链资源,对JS异步加载的资源无法提前收集。
      • 未暴露类似于Preload的onload事件。
    4. Server Push  图片来源

      Link: <https://example.com/other/styles.css>; rel=preload; as=style;
      

      仅预加载,不推送:

      Link: <https://example.com/other/styles.css>; rel=preload; as=style;nopush
      

      目标:减少请求数量和提高页面加载速度。
      特点:多页面共享push cache(动态数据json除外
      适用场景:如果不推送这个资源,浏览器就会请求这个资源。
      需要注意:要确保没有发起不必要的推送,浪费流量。可以使用preload标签代替,或者在HTTP头中加nopush属性。
      【如果服务器或者浏览器不支持 HTTP/2,那么浏览器就会按照 preload 来处理这个头信息,预加载指定的资源文件。】

      不足:

      • Edge和Safari的支持不好,慎用
      • 如果浏览器不从push cache中获取资源,推送反而不利于网页性能。
      • 只能推送不带请求体的GET和HEAD请求
      • push cache中的资源只能使用一次
      • 不考虑客户端的缓存,始终推送。
        • 只对第一次访问的用户开启服务器推送;
        • 保守起见,推送原本内联的资源,这样即使多推,也比内联效果好点。
        • 将资源文件的缓存状态更新至客户端的Cookie
          • cookie空间有限,可以使用 Golomb-compressed sets算法生成指纹,编码为base64,然后存入Cookie
          • 需自行处理缓存策略
      • 仅能推送同源资源
      • push cache: 只在会话中存在,一旦会话结束就会被释放
      • 即使push的是最新的资源,如果http缓存中max-age没有过期,仍然使用http缓存中的资源。(【扩展】资源依次查找缓存的顺序:内存缓存、Service Worker缓存、Disk缓存、Push缓存
      • 无load/error事件

【扩展】

 问题1:不仅js渲染阻塞,同时js执行后可能获取一些数据(JSON),才能真正渲染完成,如何解决?

  1. 用于动态资源的提前推送,注意参数需固定,不带随机变量的
  2. 服务端渲染(直出同构)
  3. 内联JSON(目前有些工程使用此方法传递同步数据)
    另外两种较常见的渲染方式图片来源:

preload和prefetch

  • 概念

    • preload:声明式的 fetch,可以强制浏览器请求资源,同时不阻塞文档 onload 事件。当前页面使用,尽早下载,优先级较高;
    • prefetch:首次渲染时不需要,之后可能需要。优先级较低,在浏览器空闲时才会下载。使用场景:比如当前页可能跳转的页面,或者条件加载的资源。
  • 特点

    • preload的资源存储在内存缓存(没有设置资源的缓存策略时)中。
    • 下载但不执行
    • 异步加载,不影响当前页面的渲染
    • 提前加载资源,在真正使用时,直接从从缓存中读取。
    • 使用场景
      • 当分析当前页面用户高频点击的链接,分析提取跳转页上的资源,使用prefetch预加载。
      • font字体文件的预加载
        由于字体文件必须等到 CSSOM 构建完成并且作用到页面元素了才会开始加载,会导致页面字体样式闪动。而浏览器为了避免FOUT,会尽量等待字体加载完成后,再显示应用了该字体的内容,会导致加载完成前显示空白。
  • 检测prelaod和prefetch的支持情况

    let { relList } = document.createElement('link');
    return relList && relList.supports && relList.supports('preload');
    
  • 如何使用

    //link标签
    <link rel="preload"  as="style" herf="./a.css"/>
    <link rel="prefetch" as="script" href="./b.js"/>
    
    //动态创建
    let preLink = document.createElement('link');
    preLink.rel ='prefetch';  //感觉动态创建不适合preload
    prelink.as = 'script';
    preLink.href = './a.js';
    
  • as属性值

    不同值表明资源类型,对应的优先级不同:style, script, image, media, document, font。 问题: 官方说法:不带 “as”属性的 preload 的优先级将会等同于异步请求。 测试:没有发请求。

  • 注意事项

    • 造成二次下载
    • 没有使用preload资源,Chrome会在onload事件3s后做出警示,避免无效的优化,浪费流量。
    • 【扩展】浏览器对同一域名有并行加载数限制,因此考虑域名拆分等优化。
  • 实践

    twitter
    //head中
    <link rel="preload" href="https://abs.twimg.com/k/zh-cn/init.zh-cn.3b38ddbf651139df6007.js" as="script">
    //body底部
    <script src="https://abs.twimg.com/k/zh-cn/init.zh-cn.3b38ddbf651139df6007.js" async></script>
    
    
  • preload的polyfill

    • 背景知识
    //若支持preload,异步下载完不会立即执行
    <link rel="preload" >
    //下载完立即应用到DOM树
    <link rel="stylesheet" >
    //异步下载,只有打印的时候才会应用,不符合则不会应用,因此不会阻塞渲染
    <link rel="stylesheet" media='print' >
    
    
    • polyfill思路 (参见loadCSS,提供了css的preload的polyfill实现)
    // 1. 支持preload:
        //由于preload只是获取样式,不会立即应用,因此使用onload改变link的rel使其立即生效9)
        <link rel="preload" href="style.css" as="style" onload="this.onload = null;this.rel ='stylesheet'">
        注:设置onload=null主要是因为有些浏览器会在rel改变时再次出发load事件。
    // 2. 不支持preload
        // 1)获取全部link
            let links = document.getElementsByTagName("link");
        
        // 2)缓存每个link的media
            var finalMedia = link.media || "all";
        
        // 3)改变link的rel和media(异步下载但不会应用)
            link.rel = "stylesheet";
            link.media = "only x";
    	
        // 4)如果绑定onload事件(为了启用media)
            if( link.addEventListener ){
    	    	link.addEventListener( "load", enableStylesheet );
    	    } else if( link.attachEvent ){
    	    	link.attachEvent( "onload", enableStylesheet );
        	}
        // 5)为了应对旧的浏览器不支持link的onload事件
            setTimeout( enableStylesheet, 3000 );
    	
        // 6)enableStylesheet回调,将media恢复,样式立即应用
            link.setAttribute( "onload", null ); 
            link.media = finalMedia;
    

    适用于对于首页无关的样式:由于preload的资源,能够异步加载样式,因此可以避免在加载首页无关样式时阻塞初始渲染。

    对于首页初始渲染中重要的样式
      1)内联 (注意,会将静态资源的缓存策略与页面的缓存策略捆绑
      2)HTTP/2的serverPush

知道了preload和prefetch的用途,那如何结合项目实践呢?由于webpack目前基本是项目必备,所以首先介绍结合webpack的使用;然后对quiklink进行简单介绍。

结合webpack的实践

1. 插件:PreloadWebpackPlugin

常用的配置如下:

new PreloadWebpackPlugin({
   //preload or  prefetch方式
   rel: '',
   /*
    *即<link as='' />中的as,表明资源类型,不同的类型决定了不同的执行优先级
    *比如:script的优先级大于style
   */
   as: '',
  //排除的html页面集合,即只关联要配置的页面
   excludeHtmlNames: [],
   //所关联页面需要使用preload或prefetch的资源
   include: []
})

其中include的两种使用:

  1. 根据chunk类型进行处理:
    • asyncChunks:import()动态导入的模块。可以使用prefetch方式异步加载模块;
    • initial:初始化需要的模块;
    • allChunks:处理所有模块(asyncChunks & initial)。
  2. 对已知命名的chunk,可以更精确的使用数组的方式配置需要使用的chunk
 ```
  include: ['vendor', 'index']
 ```
  • 注意事项

    1. 需要结合HtmlWebpackPlugin插件使用
    2. 必须放到HtmlWebpackPlugin后面,因为PreloadWebpackPlugin需要使用其提供的hook钩子将构造的<link>插入html中:
    plugins: [
      new HtmlWebpackPlugin(),
      new PreloadWebpackPlugin()
    ]
    
  • 使用效果 对某个页面中include的资源,最终会在对应页面head中插入link标签:

    <link as="script" href="/common.js" rel="preload">
    <link as="script" href="/asyncChunk.js" rel="prefetch">
    

    当真正使用时,由于已经下载到本地,直接读取执行,性能得到较大的提升。

2. 结合import()

好处:拆分chunk,减少首屏js体积。
如果工程没有使用HtmlWebpackPlugin,可以对动态导入的资源做如下处理:

import(/* webpackPrefetch: true */)
import(/* webpackPreload: true */)

【版本限制】需webpack v4.6.0+ 才支持预取和预加载。本地测试后,发现prefetch可用,preload无效(有成功的烦请告知)。


quiklink

  • 工作原理 通过获取页面中a标签的href,试图更快的加载接下来可能要访问的页面。

    • IntersectionObserver(交叉观察器): 检测当前视口的links

      let target= document.getElementById('a');
      io = new IntersectionObserver(
          entries => {}, 
          {
            threshold: [1]  //交叉区域为1时会触发callback
          }
      );
      io.observe(target);
      

      【备注】常规的主要是通过getBoundingClientRect()获取元素在视口中的详细位置,来实现滚动加载以及吸附等功能。

    • requestIdleCallback:等到浏览器空闲时

      【备注】注意其和requestAnimationFrame的区别

    • 检查当前的网络环境:navigator.connection.effectiveType //4G、2G...

    • prefetch缓存的待下载的url

    小巧的js库,使用了如上4个特性,每一个都值得细细品味。

  • 工作流程

    • 浏览器空闲时,获取页面所有a标签的链接link
      • 使用IntersectionObserver监听link
        • 在视口区的link,使用prefetch下载
          • 判断当前网络状况,若使用的是2G或者开启了省流模式(data-saver),则不做处理

            data-saver: The user may enable such preference, if made available by the user agent, due to high data transfer costs, slow connection speeds, or other reasons.

            题外话:prefetch有点偷流量的意思,我想看什么才消耗对应资源产生的流量,而prefetch擅自为我做主,偷偷下载很多我可能并不需要的资源(在早前流量特贵的时候这么做,估计会被打死...)。

          • 三种下载资源的方式:fetchxhr<link rel=prefetch href="" />

  • 使用说明

    • 只支持prefetch
    • a标签获取links
    • 最佳实践是对后续可能访问的页面的提前下载,后续真正访问时,直接从本地获取执行

总结

综合来看,PreloadWebpackPlugin更适合对chunk而非html文件的处理;而quikLink更适合博客类的网站,或者服务端渲染的页面,这样才能实现"秒开"的预期效果。

欢迎关注公众号,不定时更新哦~

参考文献

【欢迎留言】本文是否对你有帮助,亦或有所遗漏笔误等,烦请告知。