【使你的页面飞起来】3-传输加载优化

313 阅读13分钟

浏览器打开页面过程: image.png

如上图所示,我们来看看每一部分分别都做了什么:

-- prompt for unload: 卸载旧页面,请求新页面
---- navigationStart: 开始记时,开始处理请求页面的起始时间
-- redirect: 定向url
-- unload: 继续卸载旧页面
-- Appcache: 检查是否有离线缓存

以上操作全是在本地完成的,下面一步才开始连接网络

-- DNS
---- domainLookupStart: 开始域查找
---- domainLookupEnd: IP地址翻译结束
-- TCP:开启TCP连接,在请求资源时,是复用一个tcp连接的,以进行js,css等的串行下载
---- responseEnd: 响应完成,开始处理数据,需要注意的是,在响应过程中是不处理数据的,响应完成后才开始处理数据
-- Processing: 浏览器开始工作
---- domLoading: 将dom载入到内存当中
---- domInteractive: 解析dom,创建dom树
---- domInteractive~domContentLoaded: 这个过程中继续拿取css、js、图片资源等,到domContentLoaded时dom内容全部处理完成
---- domContentLoaded~domComplete: 这个过程中做一些其它事情,如提取页面信息,设置缓存参数等,到domComplete时dom处理结束
-- onLoad: html标签中的onLoad,开始执行脚本,处理函数等

我们需要知道的是,从prompt for unload一直到Processing页面一直是白的。我们可以根据上图来考虑需要优化的几个点:

  1. 缓存
  2. DNS
  3. TCP连接时间,加大带宽,服务器端响应速度
  4. 请求,响应速度,数据量越小,响应越快
  5. Processing,优化dom结构

浏览器请求过程中潜在优化点

  1. 相同的静态资源是否可以缓存?
  2. dns是否可以通过缓存减少dns查询时间?
  3. 网络请求的过程走最近的网络环境, CDN?
  4. 能否减少http资源请求大小?
  5. 减少http请求次数
  6. 服务端渲染

推荐文章

从优化到面试装逼指南——网络系列

1. HTTP资源缓存

目的:提高重复访问时资源加载的速度

image.png

缓存会根据请求保存输出内容的副本,例如html页面,图片,文 件,当下一个请求来到的时候:如果是相同的URL,缓存直接使 用副本响应访问请求,而不是向源服务器再次发送请求。

我们先来看一下浏览器没有缓存,或者第一次请求时的情况,如下所示: image.png

接下来是有缓存的情况,如下所示: image.png

我们先来解释下上图中几个缓存的意思:

image.png

  1. last-modified / if-modified-since 这是一组请求/响应头

响应头: * last-modified: Wed, 16 May 2018 02:57:16 GMT 01
请求头: if-modified-since: Wed, 16 May 2018 05:55:38 GMT

last-modified标示这个响应资源的最后修改时间,服务器端返回资源时,如果头部带上了 last-modified,那么资源下次请求时就会把值加入到请求头 if-modified-since 中,服务器可以对比这个值,确定资源是否发生变化,如果没有发生变化,则返回 304。

当资源过期时(使用Cache-Control标识的max-age),发现资源具有Last-Modified声明,则再次向web服务器请求时带上头If- Modified-Since,表示请求时间。web服务器收到请求后发现有头If-Modified- Since 则与被请求资源的最后修改时间进行比对。若最后修改时间较新,说明资源又被改动过,则响应整片资源内容(写在响应消息包体内),HTTP 200;若最后修改时间较旧,说明资源无新修改,则响应HTTP 304 (无需包体,节省浏览),告知浏览器继续使用所保存的cache。

  1. etag / if-none-match 这也是一组请求/响应头

响应头: etag: "D5FC8B85A045FF720547BC36FC872550"
请求头: if-none-match: "D5FC8B85A045FF720547BC36FC872550"
etag是浏览器当前资源在服务器的唯一标识(生成规则由服务器决定),服务器端返回资源时,如果头部带上了 etag,那么资源下次请求时就会把值加入到请求头 if-none-match 中,服务器可以对⽐这个值,确定资源是否发生变化,如果没有发生变化,则返回 304。

当资源过期时(使用Cache-Control标识的max- age),发现资源具有Etage声明,则再次向web服务器请求时带上头If-None-Match (Etag的值)。web服务器收到请求后发现有头If-None-Match 则与被请求资源的相应校验串进行比对,决定返回200或304。

  1. expires * expires: Thu, 16 May 2019 03:05:59 GMT

在http头中设置一个过期时间,在这个过期时间之前,浏览器的请求都不会发出,而是自动从缓存中读取文件,除非缓存被清空,或者强制刷新。缺陷在于,服务器时间和⽤户端时间可能存在不⼀致,所以 HTTP/1.1 加⼊了 cache-control 头来改进这个问题。

  1. cache-control

设置过期的时间长度(秒),在这个时间范围内,浏览器请求都会直接读缓存。当 expires 和 cache-control 都存在时,cache-control 的优先级更高。

这几个缓存的优先级如下: cache-control > expires > etag > last-modified

HTML文件不缓存,缓存js、css、图片、视频文件。之前在webpack中对js、css设置里hash,所以只更新html即可,当资源文件变化时,html会去加载新的资源文件

// last-modified
// 缺陷:文件的时间戳改动了但内容不一定改动。时间戳只能精确到秒级别,更新频繁的内容将无法生效。
var handle = function (req, res) {
    fs.stat(filename, function (err, stat) {
        var lastModified = stat.mtime.toUTCString();
        if (lastModified === req.headers['if-modified-since']) {
            res.writeHead(304, "Not Modified");
            res.end();
        } else {
            fs.readFile(filename, function(err, file) {
                var lastModified = stat.mtime.toUTCString();
                res.setHeader("Last-Modified", lastModified);
                res.writeHead(200, "Ok");
                res.end(file);
            });
        }
    });
};
// etags
var getHash = function (str) {
    var shasum = crypto.createHash('sha1');
    return shasum.update(str).digest('base64');
};
var handle = function (req, res) {
    fs.readFile(filename, function(err, file) {
        var hash = getHash(file);
        var noneMatch = req.headers['if-none-match'];
        if (hash === noneMatch) {
            res.writeHead(304, "Not Modified");
            res.end();
        } else {
            res.setHeader("ETag", hash);
            res.writeHead(200, "Ok");
            res.end(file);
        }
    });
};
// expires
// 只要本地还存在这个文件,在过期时间之前,都不会再发起请求
// 缺陷:如果用户本地时间和服务器时间不一致,那么这个缓存机制就存在问题。
var handle = function (req, res) {
    fs.readFile(filename, function(err, file) {
        var expires = new Date();
        expires.setTime(expires.getTime() + 10 * 365 * 24 * 60 * 60 * 1000);
        res.setHeader("Expires", expires.toUTCString());
        res.writeHead(200, "Ok");
        res.end(file);
    });
};
// Cache-Control
var handle = function (req, res) {
    fs.readFile(filename, function(err, file) {
        res.setHeader("Cache-Control", "max-age=" + 10 * 365 * 24 * 60 * 60 * 1000);
        res.writeHead(200, "Ok");
        res.end(file);
    });
};

推荐文章

HTTP 缓存

2. DNS预加载

减少DNS解析时间

// DNS 预解析
<link rel="dns-prefetch" href="//yuchengkai.cn">
// 预加载
<link rel="preload" href="http://example.com">
// 预渲染
<link rel="prerender" href="http://example.com"> 
// 懒执行就是将某些逻辑延迟到使用时再计算。该技术可以用于首屏优化,对于某些耗时逻辑并不需要在首屏就使用的,就可以使用懒执行。懒执行需要唤醒,一般可以通过定时器或者事件的调用来唤醒。

推荐文章

DNS递归查询与迭代查询

3. CDN

网络请求的过程走最近的网络环境

4. 减少请求包大小

4.1 启用压缩Gzip

# 开启gzip
gzip on;
# 字节多大进行压缩
gzip_min_length 1k;
# 压缩级别1~9,级别越高cpu消耗越大,官网建议是6
gzip_comp_level 6;
# 压缩的文件类型
gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript application/x-httpd-php image/bmp application/x-bmp image/x-ms-bmp application/vnd.ms-fontobject font/ttf font/opentype font/x-woff;
# 对已经gzip的资源直接使用,不需要再压缩
gzip_static on;
# 是否在http header中添加Vary: Accept-Encoding,建议开启,告诉客户端是否启用了gzip
gzip_vary on;
# 设置压缩所需要的缓冲区大小
gzip_buffers 4 16k;
# 压缩使用的http版本
gzip_http_version 1.1;
# 禁用IE 6 gzip
gzip_disable "MSIE [1-6]\.";
# 反向代理启用
gzip_proxied expired no-cache no-store private auth;

推荐文章

nginx 完整配置

4.2 优化资源

合理的压缩合并资源,拆分代码,参考下面文章 【使你的页面飞起来】2-资源优化

5. 减少http请求次数

1. 启用Keep Alive

多次请求复用一个TCP连接

  • 一个持久的TCP连接,节省了连接创建时间
  • Nginx默认开启keep alive
# 超时时间,超过多长时间不用会关闭掉
keepalive_timeout 65;
# 利用TCP连接可以发送多少请求,超过后会关闭,重新开启一个TCP连接
keepalive_requests 100;

5.2 HTTP 2的性能提升

http2只能工作在https下使用:

image.png

优势:

  • 二进制传输
  • 请求响应多路复用
  • Server push(服务端推送)

开启http2: image.png

生成ssl证书:

openssl genrsa -des3 -passout pass:x -out server.pass.key 2048

openssl rsa -passin pass:x -in server.pass.key -out server.key 

openssl req -new -key server.key -out server.csr 

openssl x509 -req -sha256 -days 3650 -in server.csr -signkey server.key -out server.crt 

image.png

提前推送到客户端

image.png

推荐文章

TCP的三次握手与四次挥手(详解+动图)
HTTP1.0、HTTP1.1 和 HTTP2.0 的区别

6. Service workers

  • 加速重复访问
  • 离线支持

image.png

注意事项:

  • 延长了首屏时间,但页面总加载时间减少
  • 兼容性
  • 只能在localhost或https下使用

Service Worker 是一个脚本,浏览器独立于当前网页,将其在后台运行,为实现一些不依赖页面或者用户交互的特性打开了一扇大门。在未来这些特性将包括推送消息,背景后台同步, geofencing(地理围栏定位),但它将推出的第一个首要特性,就是拦截和处理网络请求的能力,包括以编程方式来管理被缓存的响应。

参见文档:segmentfault.com/a/119000001…

查看浏览器Service Worker使用情况 chrome://serviceworker-internals/ 已安装Service Worker的详细情况 chrome://inspect/#service-workers

Service Workers 就像介于服务器和网页之间的拦截器,能够拦截进出的HTTP 请求,从而完全控制你的网站。 最主要的特点:

  1. 在页面中注册并安装成功后,运行于浏览器后台,不受页面刷新的影响,可以监听和截拦作用域范围内所有页面的 HTTP 请求。
  2. 网站必须使用 HTTPS。除了使用本地开发环境调试时(如域名使用 localhost) 运行于浏览器后台,可以控制打开的作用域范围下所有的页面请求
  3. 单独的作用域范围,单独的运行环境和执行线程
  4. 不能操作页面 DOM。但可以通过事件机制来处理
  5. 事件驱动型服务线程

为什么要求网站必须是HTTPS的,大概是因为service worker权限太大能拦截所有页面的请求吧,如果http的网站安装service worker很容易被攻击

浏览器支持情况详见: caniuse.com/#feat=servi…

Service Worker生命周期如下图:

image.png

当用户首次导航至 URL 时,服务器会返回响应的网页。

  1. 当你调用 register() 函数时, Service Worker 开始下载。
  2. 在注册过程中,浏览器会下载、解析并执行 Service Worker ()。如果在此步骤中出现任何错误,register() 返回的 promise 都会执行 reject 操作,并且 Service Worker 会被废弃。
  3. 一旦 Service Worker 成功执行了,install 事件就会激活
  4. 安装完成,Service Worker 便会激活,并控制在其范围内的一切。如果生命周期中的所有事件都成功了,Service Worker 便已准备就绪,随时可以使用。

离线缓存 index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Hello Caching World!</title>
  </head>
  <body>
    <!-- Image -->
    <img src="/images/hello.png" />                 
    <!-- JavaScript -->
    <script async src="/js/script.js"></script>     
    <script>
      // 注册 service worker
      if ('serviceWorker' in navigator) {           
        navigator.serviceWorker.register('/service-worker.js', {scope: '/'}).then(function (registration) {
          // 注册成功
          console.log('ServiceWorker registration successful with scope: ', registration.scope);
        }).catch(function (err) {                   
          // 注册失败 :(
          console.log('ServiceWorker registration failed: ', err);
        });
      }
    </script>
  </body>
</html>

注:Service Worker 的注册路径决定了其 scope 默认作用页面的范围。 如果 service-worker.js 是在 /sw/ 页面路径下,这使得该 Service Worker 默认只会收到 页面/sw/ 路径下的 fetch 事件。 如果存放在网站的根路径下,则将会收到该网站的所有 fetch 事件。 如果希望改变它的作用域,可在第二个参数设置 scope 范围。示例中将其改为了根目录,即对整个站点生效。

service-worker.js

var cacheName = 'helloWorld';     // 缓存的名称  
// install 事件,它发生在浏览器安装并注册 Service Worker 时        
self.addEventListener('install', event => { 
/* event.waitUtil 用于在安装成功之前执行一些预装逻辑
 但是建议只做一些轻量级和非常重要资源的缓存,减少安装失败的概率
 安装成功后 ServiceWorker 状态会从 installing 变为 installed */
  event.waitUntil(
    caches.open(cacheName)                  
    .then(cache => cache.addAll([    // 如果所有的文件都成功缓存了,便会安装完成。如果任何文件下载失败了,那么安装过程也会随之失败。        
      '/js/script.js',
      '/images/hello.png'
    ]))
  );
});
  
/**
为 fetch 事件添加一个事件监听器。接下来,使用 caches.match() 函数来检查传入的请求 URL 是否匹配当前缓存中存在的任何内容。如果存在的话,返回缓存的资源。
如果资源并不存在于缓存当中,通过网络来获取资源,并将获取到的资源添加到缓存中。
*/
self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.match(event.request)                  
    .then(function (response) {
      if (response) {                            
        return response;                         
      }
      var requestToCache = event.request.clone();  //          
      return fetch(requestToCache).then(                   
        function (response) {
          if (!response || response.status !== 200) {      
            return response;
          }
          var responseToCache = response.clone();          
          caches.open(cacheName)                           
            .then(function (cache) {
              cache.put(requestToCache, responseToCache);  
            });
          return response;             
    })
  );
});

注:为什么用request.clone()和response.clone() 需要这么做是因为request和response是一个流,它只能消耗一次。因为我们已经通过缓存消耗了一次,然后发起 HTTP 请求还要再消耗一次,所以我们需要在此时克隆请求 Clone the request—a request is a stream and can only be consumed once.

serice worker实现消息推送

  1. 提示用户并获得他们的订阅详细信息
  2. 将这些详细信息保存在服务器上
  3. 在需要时发送任何消息

步骤一和步骤二 index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Progressive Times</title>
    <link rel="manifest" href="/manifest.json">                                      
  </head>
  <body>
    <script>
      var endpoint;
      var key;
      var authSecret;
      var vapidPublicKey = 'BAyb_WgaR0L0pODaR7wWkxJi__tWbM1MPBymyRDFEGjtDCWeRYS9EF7yGoCHLdHJi6hikYdg4MuYaK0XoD0qnoY';
      // 方法很复杂,但是可以不用具体看,知识用来转化vapidPublicKey用
      function urlBase64ToUint8Array(base64String) {                                  
        const padding = '='.repeat((4 - base64String.length % 4) % 4);
        const base64 = (base64String + padding)
          .replace(/\-/g, '+')
          .replace(/_/g, '/');
        const rawData = window.atob(base64);
        const outputArray = new Uint8Array(rawData.length);
        for (let i = 0; i < rawData.length; ++i) {
          outputArray[i] = rawData.charCodeAt(i);
        }
        return outputArray;
      }
      if ('serviceWorker' in navigator) {
        navigator.serviceWorker.register('sw.js').then(function (registration) {
          return registration.pushManager.getSubscription()                            
            .then(function (subscription) {
              if (subscription) {                                                      
                return;
              }
              return registration.pushManager.subscribe({                              
                  userVisibleOnly: true,
                  applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
                })
                .then(function (subscription) {
                  var rawKey = subscription.getKey ? subscription.getKey('p256dh') : '';
                  key = rawKey ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawKey))) : '';
                  var rawAuthSecret = subscription.getKey ? subscription.getKey('auth') : '';
                  authSecret = rawAuthSecret ?
                    btoa(String.fromCharCode.apply(null, new Uint8Array(rawAuthSecret))) : '';
                  endpoint = subscription.endpoint;
                  return fetch('./register', {                                         
                    method: 'post',
                    headers: new Headers({
                      'content-type': 'application/json'
                    }),
                    body: JSON.stringify({
                      endpoint: subscription.endpoint,
                      key: key,
                      authSecret: authSecret,
                    }),
                  });
                });
            });
        }).catch(function (err) {
          // 注册失败 :(
          console.log('ServiceWorker registration failed: ', err);
        });
      }
    </script>
  </body>
</html>

步骤三 服务器发送消息给service worker app.js

const webpush = require('web-push');                 
const express = require('express');
var bodyParser = require('body-parser');
const app = express();
webpush.setVapidDetails(                             
  'mailto:contact@deanhume.com',
  'BAyb_WgaR0L0pODaR7wWkxJi__tWbM1MPBymyRDFEGjtDCWeRYS9EF7yGoCHLdHJi6hikYdg4MuYaK0XoD0qnoY',
  'p6YVD7t8HkABoez1CvVJ5bl7BnEdKUu5bSyVjyxMBh0'
);
app.post('/register', function (req, res) {           
  var endpoint = req.body.endpoint;
  saveRegistrationDetails(endpoint, key, authSecret); 
  const pushSubscription = {                          
    endpoint: req.body.endpoint,
    keys: {
      auth: req.body.authSecret,
      p256dh: req.body.key
    }
  };
  var body = 'Thank you for registering';
  var iconUrl = 'https://example.com/images/homescreen.png';
  // 发送 Web 推送消息
  webpush.sendNotification(pushSubscription,          
      JSON.stringify({
        msg: body,
        url: 'http://localhost:3111/',
        icon: iconUrl
      }))
    .then(result => res.sendStatus(201))
    .catch(err => {
      console.log(err);
    });
});
app.listen(3111, function () {
  console.log('Web push app listening on port 3111!')
});

service worker监听push事件,将通知详情推送给用户 service-worker.js

self.addEventListener('push', function (event) {
 // 检查服务端是否发来了任何有效载荷数据
  var payload = event.data ? JSON.parse(event.data.text()) : 'no payload';
  var title = 'Progressive Times';
  event.waitUntil(
    // 使用提供的信息来显示 Web 推送通知
    self.registration.showNotification(title, {                           
      body: payload.msg,
      url: payload.url,
      icon: payload.icon
    })
  );
});

7. Processing,优化dom结构

7.1 预渲染页面

预渲染页面

react-snap

  • 大型单页应用的性能瓶颈: JS下载+解析+执行
  • SSR的主要问题:牺牲TTFB来补救First Paint; 实现复杂
  • Pre-rendering打包时提前渲染页面,没有服务端参与

7.2 使用骨架组件减少布局移动(layout Shift)

react-placeholder

7.3 使用Flexbox优化布局

优势:

  • 更高性能的实现方案
  • 容器有能力决定子元素的大小,顺序,对齐,间隔等
  • 双向布局

7.4 渐进式启动(Progressive Bootstrapping)

  • 可见不可交互VS最小可交互资源集(如开始只展示首屏)

image.png

7.5 渲染优化

参考:【使你的页面飞起来】1-渲染优化