一.What?
A new way to deliver amazing user experiences on the web.
一种提升Web用户体验的方式。除了Web天生的(便捷)体验外,还有3个特点:Reliable, Fast, Engaging
可靠:在不确定的网络环境下,也能立即加载,而不会(因为断网而)瞬间回到远古时代
可靠指的是离线缓存,断网状态走缓存,保证离线场景仍然可用,service worker配合cache API建立缓存-代理机制
快速:迅速以丝滑的动画作为交互反馈,而不存在掉帧卡顿的滚动
快速,只是强调交互反馈“感觉快”,与推崇的Material Design有关,并没有真正的速度优势(至少首屏没有)
另外,得益于缓存-代理机制,再次访问时走本地缓存会相当快
类native:像设备原生App一样,具有沉浸式的用户体验(即全屏)
除了全屏外,还有主屏图标(让Web App在主屏幕有一席之地)和系统通知(“拉活”的能力),通过Web App Manifest配置来实现,依赖用户环境支持
P.S.Engaging这个抽象形容词真不好翻译,这里暂且取其实际意义,类native
所以,表面上看,PWA的亮点分2部分:
(离线)缓存-代理机制
全屏,主屏图标和系统通知等类native特性
缓存机制在Web App/SPA里一点不新鲜,抽离出数据层之后,缓存顺手就做了。但侧重点不同,PWA的缓存机制偏向于静态资源缓存,而Web App/SPA的缓存层多用来做动态内容缓存(上次的内容没过期的话,不再重新获取动态部分,而是直接做客户端渲染)
至于全屏,主屏图标以及系统通知等类native特性,算是渐进增强中的增强,在支持的用户环境是可用的(一些浏览器提供了支持,但更广泛的WebView环境在不久的将来可能还是不行)。但这表明Web正在以渐进增强的方式走出PC时代,向着移动化发展
二.试玩
依赖环境
- HTTPS
要求服务源必须是安全的,所以需要HTTPS环境。除了出于Web信息安全的考虑,想要推进HTTPS普及也是一个重要原因,HTTPS作为Web技术发展的必要基础设施,对于拍照,录音,push API等新特性,都需要获得用户许可,而HTTPS是权限工作流的关键部分,必不可少
P.S.在permission.site能够体验到HTTPS与HTTP环境在获取用户授权方面的差异
类native增强
通过引入Web App Manifest配置文件来实现类native增强,在支持PWA的浏览器生效(在不支持的环境最坏结果也就是多请求一个JSON文件):
<link rel="manifest" href="./manifest.json">
注意,有个比较相似的东西,叫Application Cache(HTML5特性,已过时),其manifest引入方式不同:
<html manifest="example.appcache">
...
</html>
因为二者引入方式不同,所以Web App Manifest与Application Cache是不相干的,没有历史包袱的后顾之忧
P.S.Application Cache对SPA支持较好,对多页应用则不适用,但存在很多问题,这里不多做介绍
主屏图标
Web App Manifest内容示例如下:
{
"short_name": "主屏显示的应用名称",
"name": "安装banner显示的应用名称",
"icons": [
{
"src": "launcher-icon-1x.png",
"type": "image/png",
"sizes": "48x48",
"density": "1.0"
},
{
"src": "launcher-icon-2x.png",
"type": "image/png",
"sizes": "96x96",
"density": "1.0"
},
{
"src": "launcher-icon-4x.png",
"type": "image/png",
"sizes": "192x192",
"density": "1.0"
}
],
"start_url": "index.html?launcher=true"
}
P.S.安装banner是指一个类似于获取权限的弹出面板,用户可以选择添加至主屏幕或取消,满足一定条件的话,Chrome会自动弹出安装banner,具体见Web App Install Banners
这样理想情况下我们就拥有了主屏图标,支持Web App Manifest的环境会选用最合适的(最接近48dp的)图标
注意:index.html
里的内容应该是首屏渲染需要的最小化内容,为了达到首屏立即加载的效果,可以把带loading和默认占位图的页面框架作为App Shell展示出来。另外,为了达到秒开可用的首屏性能,Web App首屏性能优化其它常规手段在PWA也是推荐使用的,比如数据直出。如开篇所说,PWA并没有天生的(首屏)性能优势,Web App适用的常规优化手段仍然是必要的
闪屏(Splash)
从主屏图标进入,可定制的启动过程显示内容包括:标题,背景色和图像。新配置项如下:
// 背景色
"background_color": "#2196F3",
// 主题色,包括工具栏
"theme_color": "#2196F3",
图像从icons
中选取最接近128dp的图像作为闪屏,不支持动图
另外,还可以指定显示模式和页面方向:
// 全屏(隐藏浏览器的UI)
"display": "standalone",
// 显示浏览器外壳,像打开书签一样
"display": "browser",
// 横屏
"orientation": "landscape"
P.S.关于闪屏的示例及更多信息请查看Adding a Splash Screen for Installed Web Apps in Chrome 47
特别注意:如果manifest.json文件有更新,这些改动不会自动生效,除非用户重新添加应用到主屏
系统通知
与Web App Manifest无关,依赖Push API。简单示例如下:
// service-worker.js
self.addEventListener("push", function (event) {
event.waitUntil(
self.self.registration.showNotification("发布新文章啦", {
body: "有新文章发布啦,点击查看。"
})
);
});
这里不多做介绍(目前(2017/12/15)几乎可以认为这个特性不存在),因为规范定义了API,但没规定统一个push协议,所以各家浏览器的push机制不同,比如Chrome的GCM在我们这片天空下就不可用
关于Push API的更多信息,请查看【Service Worker】消息推送功能“全军覆没”
缓存-代理
缓存分为几部分:
首屏静态资源缓存(预缓存)
已访问资源缓存(运行时缓存)
动态内容缓存(运行时缓存)
缓存是纯数据操作(包括持久化),而service worker能够在后台运行,尤其适合处理这种与页面及交互无关的事情,所以service worker与Cache API,Push API成了搭档。但service worker自身也应该看做“增强”项,在不支持service worker的环境应该跳过缓存机制保证基本的页面体验,简单的特征检测方案如下:
if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register('./service-worker.js')
.then(function() { console.log('Service Worker Registered'); });
}
service worker在install
事件处理器完成包括App Shell在内的首屏静态资源缓存:
// service-worker.js
var cacheName = 'weatherPWA-step-6-1';
var filesToCache = [
// 入口URL
'/',
'/index.html',
'/scripts/app.js',
'/styles/inline.css',
// App Shell需要的资源
'/images/ic_add_white_24px.svg',
'/images/ic_refresh_white_24px.svg',
// 内容展示可能用到的资源
'/images/clear.png',
'/images/cloudy-scattered-showers.png',
'/images/cloudy.png',
'/images/fog.png',
'/images/partly-cloudy.png',
'/images/rain.png',
'/images/scattered-showers.png',
'/images/sleet.png',
'/images/snow.png',
'/images/thunderstorm.png',
'/images/wind.png'
];
self.addEventListener('install', function(e) {
console.log('[ServiceWorker] Install');
e.waitUntil(
caches.open(cacheName).then(function(cache) {
console.log('[ServiceWorker] Caching app shell');
//! 只要有一个失败就不接着做下一个了
return cache.addAll(filesToCache);
})
);
});
当然,还需要对缓存做基本的版本控制:
// service-worker.js
self.addEventListener('activate', function(e) {
console.log('[ServiceWorker] Activate');
e.waitUntil(
caches.keys().then(function(keyList) {
return Promise.all(keyList.map(function(key) {
// 以为cacheName为cache key,如果存在旧的缓存,删除掉
if (key !== cacheName) {
console.log('[ServiceWorker] Removing old cache', key);
return caches.delete(key);
}
}));
})
);
// 要求立即激活service worker,避免边界case
return self.clients.claim();
});
P.S.边界case指的是某些情况下service worker无法立刻恢复激活态,导致不走缓存。为了屏蔽这些边界case,推荐使用GoogleChromeLabs/sw-precache帮助处理缓存控制问题(包括过期,更新策略等等)
缓存有了,接下来实现代理部分,拦截请求,并把缓存内容作为响应:
// service-worker.js
// 拦截请求
self.addEventListener('fetch', function(event) {
console.log('[ServiceWorker] Fetch', e.request.url);
// 自定义响应内容
e.respondWith(
// 查找缓存,没有才请求
caches.match(e.request).then(function(response) {
return response || fetch(e.request);
})
);
});
到这里基本的缓存-代理机制就准备好了,我们做了这些事情:
按资源列表预先缓存静态资源
拦截请求
把缓存内容作为响应给过去
有3个注意事项:
浏览器缓存可能会影响缓存更新,所以
install
事件处理器中的请求不会走缓存,而是直接进入网络注销service worker不会清掉缓存,cache key不变的话,之后还会拿到旧的缓存内容
默认新注册的service worker在页面重新载入之后才会生效,除非做特殊处理
另外,我们的简版实现还存在一些问题,例如:
缓存版本控制依赖一个静态的cache key,每次更新
service-worker.js
都要修改这个key一旦cache key有变化,会抹掉所有缓存,重新请求一遍,对于静态资源有些浪费
缺少运行时缓存,资源列表不够灵活,期望更强大的边访问边缓存
第1个问题没什么太好的办法,第2个问题可以通过细分资源类型来缓解,例如:
// Shorthand identifier mapped to specific versioned cache.
var CURRENT_CACHES = {
font: 'font-cache-v' + FONT_CACHE_VERSION,
css: 'css-cache-v' + CSS_CACHE_VERSION,
img: 'img-cache-v' + IMG_CACHE_VERSION
};
通过更细粒度的版本控制,能在一定程度上降低强制更新缓存的成本,当然,缓存层下面还有HTTP Cache兜底,缓存更新成本不是非常关键
至于运行时缓存,实际上只需要再做最后一小步就好了:
- 没命中缓存的话,请求资源*并缓存*
具体如下:
// 查找缓存,没有才请求
caches.match(e.request).then(function(response) {
return response || fetch(e.request).then(function(res) {
return caches.open(dataCacheName).then(function(cache) {
// 并缓存起来
cache.put(e.request.url, res.clone());
return res;
)
})
})
另外,还可以根据资源类型及场景要求,针对性的选用合适的缓存策略,例如:
// service-worker.js
self.addEventListener('fetch', function(e) {
console.log('[Service Worker] Fetch', e.request.url);
var dataUrl = 'https://cache.domain.com/fresh/';
// 策略1:有实时性要求的资源,请求优先,fetch then cache
if (e.request.url.indexOf(dataUrl) > -1) {
e.respondWith(
caches.open(dataCacheName).then(function(cache) {
return fetch(e.request).then(function(response){
cache.put(e.request.url, response.clone());
return response;
});
})
);
} else {
// 策略2:一般资源,缓存优先,cache falling back to fetch
}
});
P.S.更多缓存策略,见参考资料部分
三.Demo
官方Demo:Weather PWA,可能无法正常访问
搬运Demo(把官方Demo挪到github pages):https://ayqy.github.io/pwa/demo/weather-pwa/index.html
P.S.github pages非常适合用作试验田,稳定可靠的HTTPS,发布内容没有任何限制可以随便折腾,以后的博客Demo都会逐步迁移过去(之前一直放在自己的FTP,那可真蠢..)
不太乐观的消息:事实上,故意精心准备了用户环境(官方正版Chrome + 官方Demo),在小米4上没有自动弹出安装banner(可能是操作姿势等条件不满足,见上文),手动点击“添加至主屏幕”,toast添加成功,但主屏幕上啥也没有……这就是提不起兴趣手写Demo试玩的原因(当然,主要原因是懒;))
四.案例
饿了么:奇怪,为什么没有感受到Cache的作用呢
注意,隐身模式可能会导致阿里巴巴国际站的service worker抛如下错误:
Uncaught (in promise) DOMException: Quota exceeded.
正常环境可正常体验
P.S.更多案例,请查看Case Studies | Web | Google | Developers
五.应用场景
简言之,PWA算是Web App的升级版,主要亮点是类native支持。以渐进增强的方式,不需要太高成本就能完成Web App到PWA的“升级”,让部分用户(支持PWA的环境)获得更快(缓存)更便捷(主屏图标)的类native体验(全屏)
那么具体应用场景分以下几种:
缓存能带来明显收益的Web App
期望具有离线能力,或类native体验,或者单纯只是想要个主屏图标的Web应用
期望蹭个技术热点/协助推动其发展的Web应用或浏览器供应商
不管应用场景,话说回来,正如zxx某篇关于缓存(还是worker?)的文章所说,这么点儿成本就能让页面获得离线能力,真切看到缓存带来的收益,何乐而不为呢?
另外,Angular,React,Vue等主流框架都提供了PWA脚手架,具体请查看The Ultimate Guide to Progressive Web Applications