浏览器打开页面过程:
如上图所示,我们来看看每一部分分别都做了什么:
-- 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页面一直是白的。我们可以根据上图来考虑需要优化的几个点:
- 缓存
- DNS
- TCP连接时间,加大带宽,服务器端响应速度
- 请求,响应速度,数据量越小,响应越快
- Processing,优化dom结构
浏览器请求过程中潜在优化点
- 相同的静态资源是否可以缓存?
- dns是否可以通过缓存减少dns查询时间?
- 网络请求的过程走最近的网络环境, CDN?
- 能否减少http资源请求大小?
- 减少http请求次数
- 服务端渲染
推荐文章
1. HTTP资源缓存
目的:提高重复访问时资源加载的速度
缓存会根据请求保存输出内容的副本,例如html页面,图片,文 件,当下一个请求来到的时候:如果是相同的URL,缓存直接使 用副本响应访问请求,而不是向源服务器再次发送请求。
我们先来看一下浏览器没有缓存,或者第一次请求时的情况,如下所示:
接下来是有缓存的情况,如下所示:
我们先来解释下上图中几个缓存的意思:
- 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。
- 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。
- expires
* expires: Thu, 16 May 2019 03:05:59 GMT
在http头中设置一个过期时间,在这个过期时间之前,浏览器的请求都不会发出,而是自动从缓存中读取文件,除非缓存被清空,或者强制刷新。缺陷在于,服务器时间和⽤户端时间可能存在不⼀致,所以 HTTP/1.1 加⼊了 cache-control 头来改进这个问题。
- 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);
});
};
推荐文章
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">
// 懒执行就是将某些逻辑延迟到使用时再计算。该技术可以用于首屏优化,对于某些耗时逻辑并不需要在首屏就使用的,就可以使用懒执行。懒执行需要唤醒,一般可以通过定时器或者事件的调用来唤醒。
推荐文章
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;
推荐文章
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下使用:
优势:
- 二进制传输
- 请求响应多路复用
- Server push(服务端推送)
开启http2:
生成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
提前推送到客户端
推荐文章
TCP的三次握手与四次挥手(详解+动图)
HTTP1.0、HTTP1.1 和 HTTP2.0 的区别
6. Service workers
- 加速重复访问
- 离线支持
注意事项:
- 延长了首屏时间,但页面总加载时间减少
- 兼容性
- 只能在localhost或https下使用
Service Worker 是一个脚本,浏览器独立于当前网页,将其在后台运行,为实现一些不依赖页面或者用户交互的特性打开了一扇大门。在未来这些特性将包括推送消息,背景后台同步, geofencing(地理围栏定位),但它将推出的第一个首要特性,就是拦截和处理网络请求的能力,包括以编程方式来管理被缓存的响应。
参见文档:segmentfault.com/a/119000001…
查看浏览器Service Worker使用情况 chrome://serviceworker-internals/ 已安装Service Worker的详细情况 chrome://inspect/#service-workers
Service Workers 就像介于服务器和网页之间的拦截器,能够拦截进出的HTTP 请求,从而完全控制你的网站。 最主要的特点:
- 在页面中注册并安装成功后,运行于浏览器后台,不受页面刷新的影响,可以监听和截拦作用域范围内所有页面的 HTTP 请求。
- 网站必须使用 HTTPS。除了使用本地开发环境调试时(如域名使用 localhost) 运行于浏览器后台,可以控制打开的作用域范围下所有的页面请求
- 单独的作用域范围,单独的运行环境和执行线程
- 不能操作页面 DOM。但可以通过事件机制来处理
- 事件驱动型服务线程
为什么要求网站必须是HTTPS的,大概是因为service worker权限太大能拦截所有页面的请求吧,如果http的网站安装service worker很容易被攻击
浏览器支持情况详见: caniuse.com/#feat=servi…
Service Worker生命周期如下图:
当用户首次导航至 URL 时,服务器会返回响应的网页。
- 当你调用 register() 函数时, Service Worker 开始下载。
- 在注册过程中,浏览器会下载、解析并执行 Service Worker ()。如果在此步骤中出现任何错误,register() 返回的 promise 都会执行 reject 操作,并且 Service Worker 会被废弃。
- 一旦 Service Worker 成功执行了,install 事件就会激活
- 安装完成,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实现消息推送
- 提示用户并获得他们的订阅详细信息
- 将这些详细信息保存在服务器上
- 在需要时发送任何消息
步骤一和步骤二 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最小可交互资源集(如开始只展示首屏)