HTML 5 曾被认为是移动应用的明天,却被原生App在性能和功能上轻易打败,Web逐渐成为App的附属。然而,马云“爸爸”告诉咱们:“梦想仍是要有的,万一实现了呢?”现在,咱们离梦想又近了一步。javascript
PWA,全称「Progressive Web App」,是Google提出的为Web提供App般使用体验的一系列技术方案。它优点主要体如今:css
本文将逐一讲述PWA涉及的主要技术方案。html
CacheStorage是一种新的本地存储,它的存储结构是这样的:前端
每一个域有若干个存储模块,每一个模块内能够存储若干个键值对。 它的键是网络请求(Request),值是请求对应的响应(Response)。 CacheStorage的接口集中在全局变量「caches」中,且仅在HTTPS协议(或localhost:*域)下可用,调用前要检查兼容性。如下是一段实现加载资源并写入缓存的代码示例:java
if (typeof 'caches' !== 'undefined') {
// 要缓存资源的URL
const URL = 'https://s3.imgbeiliao.com/assets/imgs/icons/app/1.0.0/parent-192x192.png';
// 存储模块名
const CACHE_KEY = 'v1';
fetch(URL, {
mode: 'no-cors'
}).then((response) => {
// 打开存储模块后往里面添加缓存
caches.open(CACHE_KEY).then((cache) => {
cache.put(url, response);
});
});
}
复制代码
其中用到了 Fetch API 去请求资源,这个API的目标是取代XMLHttpRequest。jquery
除了写入缓存,天然还有匹配缓存和删除缓存的接口:nginx
// 在全部存储模块中匹配资源
caches.match(URL).then((response) => {
console.log(response);
});
// 在单个存储模块中匹配资源
caches.open(CACHE_KEY).then((cache) => {
cache.match(URL).then((response) => {
console.log(response);
});
});
复制代码
// 删除整个存储模块
caches.delete(CACHE_KEY).then((flag) => {
console.log(flag);
});
// 删除存储模块中的某个存储项
caches.open(CACHE_KEY).then((cache) => {
if (cache) {
cache.delete(url).then((flag) => {
console.log(flag)
});
}
});
复制代码
虽然能够独立调用,但 CacheStorage 通常会搭配下文所说的 Service worker 一块儿使用。web
随着Web承载的任务变得愈来愈复杂,浏览器也为JavaScript提供了多线程能力——Web worker。Web worker容许一段JavaScript程序运行在主线程以外的另一个线程中。可是基于线程安全的考虑:json
Service worker也是一种Web Worker,只是它的能力比通常的Web worker要强大得多,这主要体如今:后端
一个新的 Service worker 要通过注册、安装、激活这三个步骤,才能够对页面生效。第一步是把脚本文件注册为 Service worker :
function setupSW() {
var serviceWorker = window.navigator.serviceWorker;
if (!serviceWorker || typeof fetch !== 'function') {
return;
}
serviceWorker.register('/sw.js').then(function(reg) {
console.info('[SW]: Registered at scope "' + reg.scope + '"');
});
}
window.addEventListener('load', setupSW, false);
复制代码
注册操做的实质是新开线程,有必定的开销(从注册到激活,实测iOS Safari和Chrome耗时70~100ms,UC浏览器和QQ浏览器的耗时都在200ms以上,均为内网测试结果,实际环境中还要算上sw.js的网络开销),因此最好是在页面加载完以后执行。
注册、安装、激活都完成以后, Service worker 就能够对做用域内的页面生效。这里说的做用域并非变量的做用域,而是指 Service worker 脚本所在的目录。默认状况下, Service worker 能够做用于其脚本所在目录及其子目录下的全部页面。例如以「/a/sw.js」注册的Service worker能够做用于「/a/page1.html」、「/a/b/page2.html」,但没法做用于「/index.html」。不过,也能够经过参数指定做用域,好比:
serviceWorker.register('/a/sw.js', {
scope: '/'
});
复制代码
然而,这段代码运行的时候会出现异常:
Failed to register a ServiceWorker: The path of the provided scope ('/') is not under the max scope allowed ('/a/'). Adjust the scope, move the Service Worker script, or use the Service-Worker-Allowed HTTP header to allow the scope.
缘由就是,默认状况下做用域只能下降而不能提高。若是非得提高,就要给脚本文件增长一个HTTP响应头「Service-Worker-Allowed」。例如:
server {
location /a/sw.js {
add_header 'Service-Worker-Allowed' '/';
}
}
复制代码
此外, Service worker 脚本还必须与页面同域。为了不做用域带来的麻烦,建议把该脚本文件放置于页面所在域的根目录下。
顺带一提,在实际应用中,建议给 Service worker 增长开关。由于它毕竟属于新特性,还不知道会不会有未知的坑,一旦出现大规模故障,须要有一种快速的方式让其失效。示例代码以下:
fetch('/sw-enable?' + Date.now()).then(
// 200状态为开,其余状态为关
function(res) { return res.status === 200 ? 1 : -1; },
// 请求失败时不作任何操做
function() { return 0; }
).then(function(flag) {
if (flag === 1) {
serviceWorker.register('/sw.js');
} else if (flag === -1) {
serviceWorker.getRegistration('/sw.js').then(function(reg) {
if (reg) { reg.unregister(); }
});
}
});
复制代码
须要特别注意的是,若是处于关闭状态,必定要注销 Service worker 。不然对于已注册 Service worker 的客户端而言,该worker仍是存在的。
Service worker 激活后就会成为页面跟浏览器之间的代理。它做用域内全部页面的全部HTTP请求(除了它自身)都会触发它的fetch事件。下面以WebP的兼容处理为例,说明 Service worker 的代理做用。
WebP是Google发布的图片文件格式。与JPG、PNG等格式相比,在质量相同的前提下,WebP格式的文件每每会更小。然而,微软和苹果还没有在自家浏览器中支持这种格式,因此在实际应用中须要处理兼容问题。
过往作兼容处理的方式,主要是检查兼容性后动态输出图片路径。可是这种方式须要在全部输出图片的地方作额外处理,而且对SEO不友好。而 Service worker 则能够经过拦截原图片(PNG、JPG)的请求并将其“修改”为对应的WebP请求。
// sw.js
self.addEventListener('fetch', (e) => {
// accept: image/webp,image/apng,image/*,*/*;q=0.8
const headers = e.request.headers;
const supportsWebP = headers.has('accept') && headers.get('accept').includes('webp');
const url = new URL(e.request.url);
if (supportsWebP && url.host.includes('qiniu')) {
url.search = '?imageMogr2/format/webp';
e.respondWith(
fetch(url.toString(), { mode: 'no-cors' })
);
}
});
复制代码
以上代码经过监听fetch事件:
至此,劫持原请求定向到另外一个请求的功能就完成了。
咱们还能够在 Service worker 脚本中与 CacheStorage 进行交互,实现资源的缓存和提取。
第一种缓存策略是预缓存。它的原理是在 Service worker 的安装事件中缓存一部分资源,而且在这些资源缓存成功以后再完成安装。
// sw.js
const CACHE_KEY = 'v1';
const cacheList = [
'/js/jquery.js',
'/style/reset.css'
];
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(CACHE_KEY).then((cache) => {
return cache.addAll(cacheList);
});
);
});
复制代码
这种策略的好处是:只要 Service worker 安装成功,就能够确保缓存可用(排除存储空间不足等因素)。然而,它的缺点也不可忽视:只要有一个预缓存的资源请求失败,就会致使 Service worker 安装失败。所以,预缓存的资源越少越好。
预缓存成功后,就能够在fetch事件中匹配缓存里面的资源进行响应:
// sw.js
self.addEventListener('fetch', (e) => {
e.respondWith(
caches.match(e.request).then((response) => {
if (response != null) {
return response;
} else {
return fetch(e.request.url);
}
})
);
});
复制代码
第二种缓存策略是增量缓存,流程很简单:若是在缓存中匹配到请求的资源,则直接响应;不然发送请求,并把资源缓存下来后再响应。须要注意的是,不要去缓存异常状态(如HTTP状态码为404或500)的资源。代码实现以下:
// sw.js
self.addEventListener('fetch', (e) => {
e.respondWith(
caches.match(e.request).then((res) => {
if (res != null) {
return res;
} else {
return fetch(url).then((res) => {
if (res && (res.status === 200 || res.status === 304)) {
const resCache = res.clone();
caches.open(CACHE_KEY).then((cache) => {
cache.put(url, resCache);
});
}
return res;
});
}
});
);
});
复制代码
在实际应用的时候,还须要排除一些特殊请求:
代码实现以下:
// sw.js
self.addEventListener('fetch', (e) => {
let url = new URL(e.request.url);
if (url.protocol === 'http:' ||
(url.host !== location.host && url.host.includes('.abc-cdn.com')) ||
e.request.method !== 'GET' ||
url.pathname.indexOf('sw-enable') !== -1
) {
return;
}
url = url.toString();
e.respondWith(
// ...
);
});
复制代码
只要浏览器检查到 Service worker 脚本文件的内容有变化,就会安装新的 Service worker 。可是,在默认状况下,新的 Service worker 处于等待状态,得关闭全部跟旧 Service worker 有关联的页面,再从新打开,新的 Service worker 才会被激活。若是想新的 Service worker 立刻生效,能够在安装事件中调用「self.skipWaiting」:
// sw.js
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(CACHE_KEY).then((cache) => {
return cache.addAll(cacheList);
}).then(() => {
return self.skipWaiting();
})
);
});
复制代码
须要特别注意的是, Service worker 脚本文件要设置为永不缓存(max-age: 0)。不然,即便它的内容有变化,浏览器也没法得知,也就没法更新了。事实上,浏览器也考虑到了缓存的状况,为了不不良脚本长时间生效,Service worker脚本每24小时必定会被下载一次。
讲到这,其实只实现了 Service worker 自身的更新,但如何进一步更新 CacheStorage 中的资源缓存呢?前文有说起, CacheStorage 是按模块存储的,利用这个存储结构,就能够实现每发布一次代码就更换一个存储模块。因为新的存储模块内是空的,根据增量缓存的机制,浏览器会经过网络或者HTTP缓存获取这个资源。代码以下:
// sw.js
const CACHE_KEY = 'v2'; // 下次发布时改为v3
caches.keys().then(function(keys) {
keys.forEach(function(key) {
if (key !== CACHE_KEY) {
caches.delete(key);
}
});
});
复制代码
讲到这,其实已经接触到 Service worker 生命周期中的绝大部分环节,下面经过一张生命周期图进行概括:
实现了增量缓存以后,至关于页面只要打开过一次就能够离线浏览了。下面对两种缓存方案(Service worker + CacheStorage、HTTP缓存)作性能对比。首先是正常网速下的对比:
能够发现,没有太大的区别。其实这也很好理解,被缓存的资源,不管是CacheStorage仍是HTTP缓存,本质上要么存在磁盘、要么已经被浏览器调入内存,既然来源是同样的,读取的速度天然也大体相同。
下面再看一下慢速3G网络下的状况:
能够发现,HTML文档的请求速度有较大差别。在 Service worker + CacheStorage 方案中,HTML文档已经被缓存下来了;而在HTTP缓存方案中,HTML文档的状态码为304,说明浏览器向服务器发出了请求。而这一次HTTP请求在网络较慢的状况下耗时较长。
若是给HTML文档设置过时时间(max-age),让浏览器将其缓存起来,这个差别是否就不存在呢?实际状况没有这么简单:
因此,通常不会给HTML文档设置缓存时间,或者只设一个很短的缓存时间。然而,HTML文档做为页面的入口,缓存下来的意义是很是大的。自从了有了 Service worker ,能够作到:
拦截HTML文档的请求,检查 CacheStorage 后再决定是否请求服务器; 经过修改 Service worker 脚本及时清理缓存。 此外,前端渲染模式能够实现一个HTML文档对应多份同类内容;基于Vue.js、React、Angular等框架开发的单页应用甚至只有一个HTML文档。
综上所述,在前端渲染模式下经过 Service worker 和 CacheStorage 缓存HTML文档,能够有效提升网络不稳定时页面的加载速度。而由于静态资源自己有HTTP缓存,因此没必要在 CacheStorage 中缓存全部静态资源(只缓存关键的部分)。
最后咱们必须搞清楚一个问题: Service worker + CacheStorage 的缓存机制与 HTTP缓存 实际上是比较类似的,为何须要两种类似的缓存?
顺带一提, HTML 5 中的 Application Cache (离线缓存)由于实际应用的时候灵活性不足,已再也不建议使用,该标准也已经被废弃。
Service worker 所带来的好处让我火烧眉毛地想将其接入到项目中,下面以一个典型的Vue.js项目为例,讲一下接入过程。
第一步是注册 Service worker 脚本,为了尽量在页面组件加载完后再执行这一步,能够把这片代码放到Vue.js根实例(main.js)的mounted钩子中执行:
// main.js
new Vue({
mounted() {
// 本地开发时不启用Service worker
if (['test', 'pre', 'prod'].indexOf(env) === -1) { return; }
const serviceWorker = window.navigator.serviceWorker;
if (!serviceWorker || typeof fetch !== 'function') { return; }
fetch('/sw-enable?' + Date.now()).then(
(res) => { return res.status === 200 ? 1 : -1; },
() => { return 0; }
).then((flag) => {
if (flag === 1) {
serviceWorker.register('/sw.js');
} else if (flag === -1) {
serviceWorker.getRegistration('/sw.js').then((reg) => {
if (reg) { reg.unregister(); }
});
}
});
});
});
复制代码
Service worker 脚本的内容跟前文说起的大体上同样(此处只作了预缓存):
// 缓存模块(版本号)
const CACHE_KEY = 'v$REV';
// 要预缓存的资源列表
const cacheList = [
'/index.html',
'https://abc-cdn.com/polyfill.min.js'
];
self.addEventListener('install', (e) => {
e.waitUntil(
caches.keys().then((keys) => {
// 清理旧缓存
keys.forEach((key) => {
if (key !== CACHE_KEY) { caches.delete(key); }
});
}).then(() => {
// 预缓存
return caches.open(CACHE_KEY)
.then((cache) => { return cache.addAll(cacheList); })
}).then(() => {
// 跳过等待
return self.skipWaiting();
});
);
});
self.addEventListener('fetch', (e) => {
const url = new URL(e.request.url);
if (url.protocol === 'http:' ||
url.pathname.includes('sw-enable') ||
e.request.method !== 'GET' ||
(url.host !== location.host && cacheList.indexOf(e.request.url) === -1)
) {
return;
}
// 判断是否HTML文档的请求
const isHTMLDoc = e.request.headers.has('accept') &&
e.request.headers.get('accept').includes('text/html') &&
(url.pathname.endsWith('.html') || !/\.\w+$/.test(url.pathname));
// 基于Vue.js的单页应用只有一个HTML文档,全部HTML文档的请求能够所有指向一个文件
const request = isHTMLDoc ? new Request('/index.html') : e.request;
e.respondWith(
caches.match(request).then((res) => {
if (res != null) {
return res;
} else {
return fetch(url.toString());
}
})
);
});
复制代码
须要特别提一下的是:
最后,在Webpack构建流程中增长一个步骤,把 Service worker 脚本的「$REV」替换成新版本号(时间戳),并拷贝到index.html所在路径下(保证他们同域):
new CopyWebpackPlugin([
{
from: path.resolve(__dirname, '../src/sw.js'),
to: path.dirname(config.build.index), // index.html所在路径
transform(content, path) {
return content.toString().replace('$REV', Date.now());
}
}
])
复制代码
这一节介绍的是一个简单的JSON配置文件,示例代码以下(manifest.json):
{
"name": "贝聊官网",
"short_name": "贝聊官网",
"start_url": "/",
"display": "standalone",
"background_color": "#fff",
"theme_color": "#fff",
"orientation": "portrait",
"description": "中国幼儿园家长工做平台",
"icons": [{
"src": "https://s2.imgbeiliao.com/assets/imgs/icons/app/1.0.0/parent-192x192.png",
"type": "image/png",
"sizes": "192x192"
}, {
"src": "https://s2.imgbeiliao.com/assets/imgs/icons/app/1.0.0/parent-512x512.png",
"type": "image/png",
"sizes": "512x512"
}]
}
复制代码
比较关键的几个配置项包括:
编写好这样一个配置文件以后,还须要经过link标签在HTML文档中引用它:
<link rel="manifest" href="/manifest.json" />
复制代码
在此基础上,若是还符合如下条件:
使用Chrome浏览器打开页面后就会弹出「添加到主屏幕」的横幅(下文简称为「A2HS横幅」)。而点击主屏幕图标进入应用后,会先出现一个启动屏(注意:配置了512x512以上尺寸的图标才会显示到此),而后才进入到App的启动页。
支持A2HS横幅的浏览器有Chrome、UC浏览器、小米浏览器,均在Android平台下。对于其余浏览器而言,只能手动找到功能菜单或按钮,再添加到主屏幕。
最后再说一下Manifest文件的一些问题:
<link rel="apple-touch-icon" sizes="192x192" href="..." />
复制代码
PWA的现状能够用这么一句经典的话来归纳:
前途是光明的,道路是曲折的
先看一张兼容性方面的图:
可见:
此外,iOS Safari从iOS 11.3起支持PWA大部分特性,但存在较严重的体验问题——每次离开PWA都会丢失上下文。
综上所述,目前对大部分企业来讲,作一个完整的PWA应用并非明智的选择。然而,经过支持度较高的 Service worker 和 CacheStorage 改善用户体验,倒是颇有意义的。另外一方面,虽然Web跟原生App存在竞争关系,但更多状况下,它们是相互合做的——大部分App都内嵌了网页去实现部分功能。因此,能够考虑在App的WebView中支持上述技术,为Web提供支援。
本文同时发布于做者我的博客: mrluo.life/article/det… 。