前端性能优化(四):传输优化

Front-End Performance Checklist 2021[1]
https://www.smashingmagazine....
前端性能优化(一):准备工作[2]
前端性能优化(二):资源优化[3]
前端性能优化(三):构建优化[4]

一、使用defer异步加载关键Java Script

defer:异步加载,当HTML解析完毕后才执行。
async:异步加载,脚本下载完成后立即执行(脚本准本好,且之前的所有同步工作也执行完的时候执行)。如果脚本下载较快,比如直接从缓存获取,会阻塞HTML解析。同时,多个async脚本执行顺序不可预料。推荐使用defer。

不推荐同时使用defer和async,async的优先级高于defer。
前端性能优化(四):传输优化_第1张图片

二、使用 IntersectionObserver 和优先级提示(priority hints)懒加载耗性能的组件

Native lazy loading(only Chromium)已经对images和iframes可用,在DOM上添加loading属性即可。当元素距离可视窗口一定距离时才加载。该阈值取决于几件事,从正在获取的图像资源的类型到网络连接类型。使用Android上的Chrome浏览器进行的实验表明,在4G上,延迟可见的97.5%的折叠后图像在可见后的10ms内已完全加载。即使在速度较慢的2G网络上,在10毫秒内仍可以完全加载92.6%的折叠图像。截至2020年7月,Chrome进行了重大改进,以对齐图像延迟加载的视口距离阈值,以更好地满足开发人员的期望。在网络情况比较好的情况下(如:4g),distance-from-viewport thresholds为1250px,在网络情况比较差的情况下(如:3g),距离阀值设为2500px。

实现lazy loading最好的方式就是使用Intersection Observer API。它提供异步检测元素是否在祖先元素或根元素(通常是滚动的父元素)可见,我们可异步控制操作。

为兼容所有浏览器,我们使用Hybrid Lazy Loading[5]With IntersectionObserver[6]。

Lazy image

想了解更多关于懒加载的可以阅读Google的Fast load times[7]。

另外,我们可以在DOM节点上使用important属性[8],重置资源的优先级。它可以用

值得注意的是,动态样式也可能很昂贵,但通常仅在您依赖于数百个同时渲染的合成组件的情况下。因此,如果使用的是CSS-in-JS,请确保你的CSS-in-JS库在CSS不依赖主题或props并且不过度组合样式化组件的情况下优化执行。有兴趣的可以阅读 Aggelos Arvanitakis的The unseen performance costs of modern CSS-in-JS libraries in React apps[14]。

八、考虑让组件具有可连接性

如果你的网站允许用户以Save-Data的模式访问,当用户开启时,以请求头的形式传递给服务端,服务端传输较少的内容回来。虽然它本身不做任何事情,但是服务提供商或网站所有者可以根据该头信息进行相应的处理:

● Google Chrome浏览器可能会强制执行干预措施,例如推迟外部脚本以及延迟加载iframe和图像
● Google Chrome可以代理请求,以提高选择加入“精简版”且网络连接状况不佳的用户的性能
● 网站所有者可以提供其应用程序的较轻版本,例如 通过降低图像质量,交付服务器端呈现的页面或减少第三方内容的数量
● ISP可以转换HTTP图像以减小最终图像的大小

当然,除了用户主动开启,作为开发,我们也可以根据用户当前的网络状态去判断是否给用户返回“精减版”内容。使用Network Information API即可获得,取值有:slow-2g, 2g, 3g, or 4g。

navigator.connection.effectiveType

为了更方便控制,我们还可以借助service worker拦截。

"use strict";
self.addEventListener('fetch', function (event) {
    // Check if the current request is 2G or slow 2G
    if (/\slow-2g|2g/.test(navigator.connection.effectiveType)) {
        // Check if the request is for an image
        if (/\.jpg$|.png$|.gif$|.webp$/.test(event.request.url)) {
            // Return no images
            event.respondWith(
                fetch('placeholder.svg', {
                    mode: 'no-cors'
                })
            );
        }
    }
});

九、考虑让你的组件对设备内存敏感

除了网络状态,设备的内存情况我们也应该考虑到。使用Device Memory API,即navigator.deviceMemory,可以得到设备拥有多少RAM(以GB为单位),四舍五入到最接近2的幂。

十、预热连接以加速传输

有几个资源提示你需要了解:

● dns-prefetch:在后台执行DNS查找
● preconnect:要求浏览器在后台启动连接握手(DNS,TCP,TLS)
● prefetch:要求浏览器请求资源
● preload:在不执行资源的情况下预取资源
● prerender:提示浏览器在后台为下一个导航构建整个页面的资源(已被弃用:从巨大的内存占用和带宽使用到多个注册的分析点击率和广告曝光量等,都很有挑战。)
● NoState Prefetch:像 prerender 一样,NoState Prefetch 会提前获取资源;但不同的是,它不执行 JavaScript,也不提前渲染页面的任何部分

这里主要介绍preload和prefetch, vs ,更多关于preload和prefetch,可阅读Loading Priorities in Chrome(还详细介绍了什么情况下可能会导致请求两次等等)

● preload是一种声明性提取,可强制浏览器对资源进行请求,而不会阻止document的onload事件;prefetch是向浏览器提示可能需要资源的提示,浏览器决定是否以及何时加载该资源。
● preload通常是你具有高度自信预加载的资源将在当前页面中使用;prefetch通常是可能用于跨多个导航边界的未来导航的资源。
● preload是对浏览器早期的获取指令,用于请求页面所需的资源(关键脚本,Web字体,hero图像);prefetch使用情况略有不同-用户将来的导航(例如,在视图或页面之间),其中所获取的资源和请求需要在导航之间保持不变。如果页面A发起了对页面B所需的关键资源的预取请求,则可以并行完成关键资源和导航请求。如果我们在此用例中使用preload,则会在页面A的卸载后立即取消。
● 浏览器有4种缓存:HTTP cache, memory cache, Service Worker cache & Push cache。preload和prefetch都是存在HTTP cache中。

有兴趣的同学还可以看一下Early Hints、Priority Hints。

十一、使用 service worker 做性能优化

前面我们也看到了很多地方都有使用service worker,这里我们详细介绍一下。

Service Worker 是浏览器在后台独立于网页运行的脚本,核心功能是拦截和处理网络请求,包括通过程序来管理缓存中的响应。它可以支持离线体验。

(一)注意事项

● 它是一种 JavaScript Worker,无法直接访问 DOM、LocalStorage、window。Service  Worker 通过响应 postMessage 接口发送的消息来与其控制的页面通信,页面可在必要时对 DOM 执行操作。
● Service Worker 是一种可编程网络代理,让你能够控制页面所发送网络请求的处理方式。
● Service Worker 在不用时会被中止,并在下次有需要时重启,因此,你不能依赖 Service Worker onfetch 和 onmessage 处理程序中的全局状态。如果存在你需要持续保存并在重启后加以重用的信息,Service Worker 可以访问 IndexedDB API。
● Service Worker 广泛地利用了 promise。

(二)生命周期

Service Worker 的生命周期完全独立于网页,如下:

● 注册。
● 安装:可缓存某些静态资产,如果所有文件均已成功缓存,那么 Service Worker 就安装完毕。如果任何文件下载失败或缓存失败,那么安装步骤将会失败,Service Worker 就无法激活(也就是说, 不会安装)。
● 激活:是管理旧缓存的绝佳机会。
● 控制:Service Worker 将会对其作用域内的所有页面实施控制,不过,首次注册该 Service Worker 的页面需要再次加载才会受其控制,线程实施控制后,它将处于以下两种状态之一: 服务工作线程终止以节省内存,或处理获取和消息事件,从页面发出网络请求或消息后将会出现后一种状态。

前端性能优化(四):传输优化_第3张图片

(三)先决条件

● Service Worker 受 Chrome、Firefox 和 Opera 支持。
● 在开发过程中,可以通过 localhost 使用 Service Worker,但如果要在网站上部署 Service Worker,则需要在服务器上设置 HTTPS。

(四)使用

// 1、注册(注册w完成后,你可以通过转至 chrome://inspect/#service-workers 并寻找你的网站来检查 Service Worker 是否已启用。)
if ('serviceWorker' in navigator) {
  window.addEventListener('load', function() {
    // 这里/sw.js位于根网域。这意味着服务工作线程的作用域将是整个来源。
    // 换句话说,Service Worker 将接收此网域上所有事项的 fetch 事件。
    // 如果我们在 /example/sw.js 处注册 Service Worker 文件,则 Service Worker 将只能看到网址以 /example/ 开头(即 /example/page1/、/example/page2/)的页面的 fetch 事件。
    navigator.serviceWorker.register('/sw.js').then(function(registration) {
      // Registration was successful
      console.log('ServiceWorker registration successful with scope: ', registration.scope);
    }, function(err) {
      // registration failed :(
      console.log('ServiceWorker registration failed: ', err);
    });
  });
}
// 2、安装(sw.js)
self.addEventListener('install', function(event) {
  // 1、打开缓存
  // 2、缓存文件
  // 3、确认所有需要的资产是否已缓存
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(function(cache) {
        // urlsToCache:文件数组
        return cache.addAll(urlsToCache);
      })
      .then(() => {
        // `skipWaiting()` forces the waiting ServiceWorker to become the
        // active ServiceWorker, triggering the `onactivate` event.
        // Together with `Clients.claim()` this allows a worker to take effect
        // immediately in the client(s).
        self.skipWaiting();
      })
  );
});
// 3、缓存与返回请求(sw.js)
self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request)
      .then(function(response) {
        // Cache hit - return response
        if (response) {
          return response;
        }
        // IMPORTANT:Clone the request. A request is a stream and
        // can only be consumed once. Since we are consuming this
        // once by cache and once by the browser for fetch, we need
        // to clone the response.
        var fetchRequest = event.request.clone();

        return fetch(fetchRequest).then(
          function(response) {
            // Check if we received a valid response
            // 确保响应类型为 basic,亦即由自身发起的请求。 这意味着,对第三方资产的请求也不会添加到缓存。
            if(!response || response.status !== 200 || response.type !== 'basic') {
              return response;
            }

            // IMPORTANT:Clone the response. A response is a stream
            // and because we want the browser to consume the response
            // as well as the cache consuming the response, we need
            // to clone it so we have two streams.
            // 克隆响应:这样做的原因在于,该响应是数据流, 因此主体只能使用一次。
            var responseToCache = response.clone();

            caches.open(CACHE_NAME)
              .then(function(cache) {
                cache.put(event.request, responseToCache);
              });

            return response;
          }
        );
      }
    )
  );
});

注意:从无痕式窗口创建的任何注册和缓存在该窗口关闭后均将被清除。

(五)更新Service Worker

● 更新Service Worker, 需遵循以下步骤:
● 更新sw.js文件。用户访问你的网站时时,浏览器会尝试在后台重新下载定义 Service Worker 的脚本文件。如果 Service Worker 文件与其当前所用文件存在字节差异,则将其视为新 Service Worker。
● 新 Service Worker 将会启动,且将会触发 install 事件。
● 此时,旧 Service Worker 仍控制着当前页面,因此新 Service Worker 将进入 waiting 状态。
● 当网站上当前打开的页面关闭时,旧 Service Worker 将会被终止,新 Service Worker 将会取得控制权。
● 新 Service Worker 取得控制权后,将会触发其 activate 事件(activate 回调中常见任务是缓存管理。之所以需要缓存管理,是因为如果你在安装步骤中清除了任何旧缓存,则继续控制所有当前页面的任何旧 Service Worker 将突然无法从缓存中提供文件)。

// 遍历 Service Worker 中的所有缓存,并删除未在缓存白名单中定义的任何缓存(旧缓存)。
self.addEventListener('activate', function(event) {
  var cacheAllowlist = ['pages-cache-v1', 'blog-posts-cache-v1'];
  event.waitUntil(
    caches.keys().then(function(cacheNames) {
      return Promise.all(
        cacheNames.map(function(cacheName) {
          if (cacheAllowlist.indexOf(cacheName) === -1) {
            return caches.delete(cacheName);
          }
        })
      ).then(() => {
        // `claim()` sets this worker as the active worker for all clients that
        // match the workers scope and triggers an `oncontrollerchange` event for
        // the clients.
        return self.clients.claim();
      });
    })
  );
});

(六)可以做什么优化?

1、较小的HTML有效负载

将html打包成2个文件,首次访问的是有完整HTML的文件,访问完成后将文件的头和尾存储在缓存中,再次访问拦截请求,将其转发到只有内容的HTML文件,收到内容后将文件与之前存在在缓存的头和尾拼接(可以以流的形式)返回给浏览器。

// 这里使用workbox,若不想使用,具体可阅读(使用stream的形式返回html):https://livebook.manning.com/book/progressive-web-apps/chapter-10/55
// 另外,还需考虑页面的标题问题,这里就不叙述了,有兴趣的同学可以网上f翻阅资料
import {cacheNames} from 'workbox-core';
import {getCacheKeyForURL} from 'workbox-precaching';
import {registerRoute} from 'workbox-routing';
import {CacheFirst, StaleWhileRevalidate} from 'workbox-strategies';
import {strategy as composeStrategies} from 'workbox-streams';

const shellStrategy = new CacheFirst({cacheName: cacheNames.precache});
const contentStrategy = new StaleWhileRevalidate({cacheName: 'content'});

const navigationHandler = composeStrategies([
  () => shellStrategy.handle({
    request: new Request(getCacheKeyForURL('/shell-start.html')),
  }),
  ({url}) => contentStrategy.handle({
    request: new Request(url.pathname + 'index.content.html'),
  }),
  () => shellStrategy.handle({
    request: new Request(getCacheKeyForURL('/shell-end.html')),
  }),
]);

registerRoute(({request}) => request.mode === 'navigate', navigationHandler);

2、离线缓存

本身就支持的功能

3、拦截替换资源

如拦截图像请求,如果请求失败,返回默认的失败图片

function isImage(fetchRequest) {
    return fetchRequest.method === "GET" 
           && fetchRequest.destination === "image";
}
self.addEventListener('fetch', (e) => {
    e.respondWith(
        fetch(e.request)
            .then((response) => {
                if (response.ok) return response;
                // User is online, but response was not ok
                if (isImage(e.request)) {
                    // Get broken image placeholder from cache
                    return caches.match("/broken.png");
                }
            })
            .catch((err) => {
                // User is probably offline
                if (isImage(e.request)) {
                    // Get broken image placeholder from cache
                    return caches.match("/broken.png");
                }
            })
    )
});

4、不同类型的资源使用不同的缓存策略

比如Network Only(live data)、Cache Only(Web font)、Network Falling Back to Cache(HTML, CSS, JavaScript, image)。

比如在支持webP格式图片的手机上返回格式为webP格式的图片等。

可以使用Request.destination区分不同的请求类型:请求相关的destination取值有:"audio", "audioworklet", "document", "embed", "font", "image", "manifest", "object", "paintworklet", "report", "script", "serviceworker", "sharedworker", "style", "track", "video", "worker", 或者 "xslt"。如果没有特别说明,则为空字符串。

5、还可以在CDN/Edge上使用

具体这里就不叙述了。

(七)当7 KB等于7 MB

DOMException: Quota exceeded.

如果你正在构建一个渐进式Web应用程序,并且当Service Worker缓存从CDN提供的静态资产时遇到过大的缓存存储,请确保为跨域资源设置正确的CORS响应标头,并且不要无意间与Service Worker缓存不透明的响应(opaque responses) ,你可以通过将crossorigin属性添加到标签来将跨域图像资源选择为进入CORS模式。

(八)safari's range request

Safari发送初始请求以获取视频,并将Range标头设置为bytes = 0-1。你可以看到,Safari需要提供视频和音频的HTTP服务器来支持这样的Range请求。而Service Worker是有问题的。要解决此问题,可以对Service Worker进行如下设置:

// https://philna.sh/blog/2018/10/23/service-workers-beware-safaris-range-request/
self.addEventListener('fetch', function(event) {
  var url = new URL(event.request.url);
  if (url.pathname.match(/^\/((assets|images)\/|manifest.json$)/)) {
    if (event.request.headers.get('range')) {
      event.respondWith(returnRangeRequest(event.request, staticCacheName));
    } else {
      event.respondWith(returnFromCacheOrFetch(event.request, staticCacheName));
    }
  }
  // other strategies
});
// Range Header:Range: bytes=200-1000
function returnRangeRequest(request, cacheName) {
  return caches
    .open(cacheName)
    .then(function(cache) {
      return cache.match(request.url);
    })
    .then(function(res) {
      if (!res) {
        return fetch(request)
          .then(res => {
            const clonedRes = res.clone();
            return caches
              .open(cacheName)
              .then(cache => cache.put(request, clonedRes))
              .then(() => res);
          })
          .then(res => {
            return res.arrayBuffer();
          });
      }
      return res.arrayBuffer();
    })
    .then(arrayBuffer => {
      // 拿到array Buffer,处理Range Header
      const bytes = /^bytes\=(\d+)\-(\d+)?$/g.exec(
        request.headers.get('range')
      );
      if (bytes) {
        const start = Number(bytes[1]);
        const end = Number(bytes[2]) || arrayBuffer.byteLength - 1;
        return new Response(arrayBuffer.slice(start, end + 1), {
          status: 206,
          statusText: 'Partial Content',
          headers: [
            ['Content-Range', `bytes ${start}-${end}/${arrayBuffer.byteLength}`]
          ]
        });
      } else {
        return new Response(null, {
          status: 416,
          statusText: 'Range Not Satisfiable',
          headers: [['Content-Range', `*/${arrayBuffer.byteLength}`]]
        });
      }
    });
}

十二、优化渲染性能

确保在滚动页面或元素展示动画效果时没有延迟,能始终达到每秒 60 帧;如果达不到,至少也要使每秒帧数在 60 到 15 的混合范围内。你可以使用css will-change通知浏览器哪些元素和属性将会改变。

在不改变DOM和它的样式的前提下,会触发重绘的有:GIF、canvas绘制、animation。为避免重绘,我们需尽可能的使用opacity、transform,除非在有一些特殊情况,如为SVG路径设置动画时才会触发重绘。我们可以检测没必要的重绘DevTools → More tools → Rendering → Paint Flashing

除了Paint Flashing,还有多个比较有趣的工具选项,比如:

● Layer borders:用于显示由浏览器渲染的图层边框,以便可以轻松识别大小的任何变换或更改。
● FPS Meter:实时显示浏览器当前帧数。
● Paint flashing:用于突出显示浏览器被迫重绘的网页区域。Umar Hansa关于Understanding Paint Performance with Chrome DevTools[15]的视频值得看一看。

文章How to Analyze Runtime Performance[16]详细介绍了如何分析运行时的性能,非常有用,对Chrome DevTools的Performance还不太会用的同学,推荐阅读。

(一)如何测量样式和布局计算花费的时间?

requestAnimationFrame可以作为我们的工具,但它有一个问题,什么时候执行回调函数不同的浏览器表现不一样:Chrome、FF、Edge >= 18在样式和布局计算之前触发,Safari、IE、Edge < 18在样式和布局计算之后绘制之前触发。

如果在requestAnimationFrame的回调中调用setTimeout,在符合规范的浏览器中(如Chrome),setTimeout的回调函数将在绘制之后调用;在不符合规范的浏览器中(如Edge 17),requestAnimationFrame和setTimeout几乎同时出发,均在样式和布局计算完成之后触发。

如果在requestAnimationFrame的回调中调用microtask,如Promise.resolve,这完全没用,JavaScript执行完成后立即运行,因此,它根本不会等待样式和布局。

如果在requestAnimationFrame的回调中调用requestIdleCallback,它会在绘制完成之后触发。但是,这个很能会触发得太晚。它启动速度相当快,但如果主线程忙于执行其他工作,则requestIdleCallback可能会延迟很长时间,以等待浏览器确定运行某些“空闲”工作是安全的。这肯定比setTimeout少得多。

如果在requestAnimationFrame的回调中调用requestAnimationFrame,它会在绘制完成之后触发,与setTimeout相比,它可能会捕获更多的等待时间。在60Hz屏幕上大约需要16.7毫秒,而setTimeout的标准时间是4毫秒–因此稍微不准确。

总的来说,requestAnimationFrame + setTimeout尽管有缺陷,但可能仍然比requestAnimationFrame + requestAnimationFrame更好

(二)关于瀑布流布局你了解吗?

仅使用CSS grid[17]马上就可以支持。有兴趣的可以多了解一下,这里就不叙述了。‍

.container {
  display: grid;
  // 创建一个四列布局
  grid-template-columns: repeat(4, 1fr);
  grid-template-rows: masonry;
}

(三)CSS动画

当我们写CSS动画时,我们通常被告知如果实现动画,使用transform实现。比如修改元素的位置,为什么不直接用我们常用的left、top呢?因为浏览器需要不断计算元素的位置,这会触发回流;又比如修改图片在页面的显示状态,会触发重绘。重绘的代价通常都是非常高的。

如果想要动画看起来流畅,需要注意三点:

● 不要影响文档的流程
● 不依赖于文档的流程
● 不会导致重绘

符合以上条件的有:

● 3D transforms: translate3d, translateZ等等;
●