张鑫旭-鑫空间-鑫生活
it's my whole life!备份内容浏览
将网站改进为增强型网页应用-PWA
译者 | 京东金融-移动研发部-前端工程师 郑莉
最近有一些关于增强型网页应用(PWA)的议论,很多人都质疑PWA是否代表了未来的(移动)Web。我并无意参与到整个的原生app与PWA的争论中,但是有一件事是确定的– 在提高移动端和改善用户体验方面还有很长的路要走。到2018,移动端web访问量注定会超过其他设备的总和,你能忽视这一趋势吗?
好消息是制作一个PWA并不困难。实际上,很多可能可以将一个现有的网站转换成PWA。这也是本文将要介绍的内容– 看完本文,你将拥有一个行为类似于原生web app,能够离线运行,拥有自己的桌面图标。
什么是PWA?
增强型网页应用(Progressive Web Apps,PWAs),是web技术里一个令人激动的革新。PWAs集合了一系列技术,可以使web app有和原生移动app相同的功能。这有利于开发者和用户实现那些仅限于web和native的解决方案:
你只需要一个由开源的、标准的W3C网页技术开发的应用。无需单独开发一个原生代码库。
用户可以发现并在下载前尝试你的应用。
没有必要使用需要支付费用的应用商店,应用程序会自动更新,而用户无感知。
下载前有提示,下载后会在主屏幕上添加图标。
启动时,PWA会展示一个聚睛的启动画面。
chrome浏览器选项可以设置全屏显示。
基本文件会被缓存到本地,因此PWAs比标准的网页app响应地更快(甚至比原生app还要快)。
安装包非常轻量– 可能只有几百KB的缓存数据。
所有的数据交换只能通过安全的HTTPS连接。
PWAs的功能是离线的,并能在连接响应后同步数据。
现在还是早期,但对于案例的研究还是积极的。Flipkart,印度最大的电商网站,在弃用原生app改用PWA后,销售转换率增长了70%,用户的停留时间变成了三倍。阿里巴巴,世界最大的商业交易平台,转换率也差不多增长了76%。
Firefox、Chrome和其他基于Blink的浏览器都是支持PWA技术的。微软正致力于在Edge中实现。Apple保持了沉默,尽管在WebKit的五年计划中有一些有希望的评论。幸运地是,这与浏览器端的支持性几乎不相干。
PWA是渐进增强的
你的app仍然可以在不支持PWA的浏览器中的运行,只是用户无法享受到离线功能,但一切都会如常运行。在给出效益成本比回报的情况下,没有理由不往你的系统里添加PWA技术。
不仅仅是App
谷歌在推广PWA时给的大部分教程都是描述如何从头建立一个基于Chrome而似native的移动app。然而,我们不需要多加一个单页面app或非得去跟从实际的界面设计指南。大多数的网站可以在几个小时内改造成PWA,包括WordPress可静态网站。Smashing Magazine宣布他们也在使用PWA。
演示代码
演示代码可参考https://github.com/sitepoint-editors/pwa-retrofit,这是一个简单的4页网站,有一些图片、一个样式表和一个主js文件。这个网站兼容所有现在浏览器(IE10+)。如果浏览器支持PWA,那么用户可以在离线环境下阅读曾经浏览过的网页。
运行代码时,请确保已经安装了Node.js,之后可以在终端运行下面的代码启动web server:
node./server.js [port]
[port]是配的,默认值8888。打开Chrome或其他基于Blink的浏览器(比如Opera或Vivaldi),然后打开 http://localhost:8888/(端口可改成你所指定的值)。你也可以打开开发者工具(F12或Cmd/Ctrl + Shift + I)查看控制台信息。
浏览一下主页,和其他页面,然后通过以下两种方式,切换成离线状态。
使用Cmd/Ctrl + C停止web server,
在控制台的Network或Application– Service Workers 选项卡,选中离线选项。
重新访问之前浏览过的页面,发现它们仍能加载。如果访问一个从未浏览过的页面,将会展示一个‘you’re offline’页面,并包含一个可浏览页面的列表:
连接设备
你也可以在Android机上浏览演示代码,手机需要通过USB连接到PC或Mac上。打开开发者工具左上角三点处的More tools菜单里的Remote devices面板。
选择左侧的Settings,点击Add Rule将端口8888映射到localhost:8888。现在你可以打开手机上的浏览器并访问http://localhost:8888/
有两种方式向桌面添加图标,你可以使用浏览器菜单中的“Add to Home screen”,也可以浏览一些页面后,浏览器会提示你下载。浏览完页面后,关闭Chrome并断开设备连接。然后再次启动PWA Website应用 – 你会看到一个启动画面,也能够在不连接服务器的情况下,浏览之前阅读过的页面。
下面介绍如何三步将你的网站改造成一个PWA。
Step 1:开启HTTPS
显然,PWAs需要HTTPS连接。虽然成本会增加,但这些成本和代价是值得的,毕竟谷歌搜索会将安全的网站排名更高。
HTTPS对于上边的演示代码是非必需的,因为Chrome允许使用localhost或任何127.x.x.x地址进行测试。如果想在HTTP网站上测试PWA技术,在启动Chrome时可以使用下面的命令行标记:
·--user-data-dir
·--unsafety-treat-insecure-origin-as-secure
Step 2:创建Web App Manifest
Webapp manifest包括应用程序信息,比如名称、描述和操作系统桌面图标、启动画面和视口的图片。事实上,manifest用一个单独文件替代了页面中已存在的大量的特定图标和主题meta标签。
Manifest是JSON文件,位于app根目录下。它的Content-Type需要设置为application/manifest+json 或 application/json。这个文件的名称可随意更改,但在该演示代码中被命名为/manifest.json。
{
"name" :"PWAWebsite",
"short_name" :"PWA",
"description" :"Anexample PWA website",
"start_url" :"/",
"display" :"standalone",
"orientation" :"any",
"background_color" :"#ACE",
"theme_color" :"#ACE",
"icons":[
{
"src" :"/images/logo/logo072.png",
"sizes" :"72x72",
"type" :"image/png"
},
{
"src" :"/images/logo/logo152.png",
"sizes" :"152x152",
"type" :"image/png"
},
{
"src" :"/images/logo/logo192.png",
"sizes" :"192x192",
"type" :"image/png"
},
{
"src" :"/images/logo/logo256.png",
"sizes" :"256x256",
"type" :"image/png"
},
{
"src" :"/images/logo/logo512.png",
"sizes" :"512x512",
"type" :"image/png"
}
]
}
需要在所有页面中添加如下链接:
<link rel="manifest"href="/manifest.json">
Manifest中的主要属性有:
name – 展示给用户的应用程序的全称
short_name –全称无法完全显示时使用的短名称
description– 对于应用的详细描述
start_url –启动程序对应的URL(一般是‘/’)
scope – 导航范围。例如‘/app/’指限制应用只访问该文件夹
background-color– 启动界面和chrome浏览器(如果需要的话)的背景颜色
theme_color– 应用程序的颜色,通常与背景色相同,它将影响app的显示效果
orientation– 设置优先的显示方向:any, natural, landscape, landscape-primary, landscape-secondary,portrait, portrait-primary,和 portrait-secondary
display – 设置优先的显示模式:fullscreen (非chrome), standalone (看上去像一个原生app), minimal-ui (包含一小部分UI控件) and browser (一个常规的浏览器标签页)
icons – 一个图片对象列表,定义了src(地址)、sizes(宽高)和type(类型)。有一系统的图标应该被定义。
MDN提供了完整的属性列表(https://developer.mozilla.org/en-US/docs/Web/Manifest)
Chrome开发者工具Application面板中的Manifest选项,显示了应用的manifest JSON,还提供了“Add to homescreen”链接,可以在桌面设备上运行:
Step 3:创建Service Worker
SeviceWorker 是可编程的代理,能够拦截并响应网络请求。它是位于应用根目录下的一个JavaScript文件。
页面js(在该演示代码中是/js/main.js)中可以检查是否支持serviceworker并注册该文件:
if('serviceWorker'in navigator){
//register service worker
navigator.serviceWorker.register('/service-worker.js');
}
如果你不需要离线功能,那么只需要创建一个空白的/service-worker.js文件 – 用户将被提示安装当前应用。
serviceworker会有些难懂,但你应该去改写演示代码以满足你的需要。这是标准的web worker脚本,浏览器会下载(如果支持的话)该脚本并运行在独立的线程中。它不能访问DOM和其他页面的API,但会拦截各种网络请求,包括由页面改变、资源下载和Ajax调用所触发的。
这是网站需要使用HTTPS的首要原因。试想一下,如果一个第三方脚本从另外的域名注入了其自己的sevice worker,将会造成怎样的状况。它将可以检测并修改客户端和服务器之间的所有数据交换!
serviceworker会对3种主要事件作出反应:install、activate和fetch。
Install事件
该事件发生在应用安装时。通常是使用Cache API来缓存基本文件。
首先,我们将定义一些配置项:
缓存名称(CACHE)和版本号(version)。应用可以有多个缓存区,但我们只需要一个。版本号可以保证,当我们大幅修改应用后,本地能够使用新的缓存并忽视原有的缓存文件。
一个离线页面URL(offlineURL)。当离线用户尝试加载一个从未访问过的页面时,将显示该离线页面。
一个必要文件数组,包含了网站离线运行必要的文件(installFilesEssential)。数组里还应包括资源文件,如CSS和JavaScript;我还会加上主页(/)和logo。如果某些URL可以通过多种路径定位到,例如‘/’和‘/index.html’,那么也应全部添加进去。注意:offlineURL也要加到这个数组中。
可选项,一个描述文件数组(installFilesDesirable)。这些文件也会被下载,但如果下载失败不会导致安装中止。
// configuration
const
version ='1.0.0',
CACHE= version +'::PWAsite',
offlineURL ='/offline/',
installFilesEssential =[
'/',
'/manifest.json',
'/css/styles.css',
'/js/main.js',
'/js/offlinepage.js',
'/images/logo/logo152.png'
].concat(offlineURL),
installFilesDesirable =[
'/favicon.ico',
'/images/logo/logo016.png',
'/images/hero/power-pv.jpg',
'/images/hero/power-lo.jpg',
'/images/hero/power-hi.jpg'
];
installStaticFiles()函数使用基于promise的Cache API将文件添加到缓存中。只有当缓存了必要文件时才会返回数值:
// install static assets
functioninstallStaticFiles(){
return
caches
.open(CACHE
)
.then(
cache
=>{
// cache desirable files
cache
.addAll(installFilesDesirable
);
// cache essential files
return
cache
.addAll(installFilesEssential
);
});
}
最后,我们添加一个install事件监听器。waitUntil方法确保serviceworker在所有封闭代码都已运行后才被安装,方法中运行installStaticFiles() 后运行 self
.skipWaiting() ,激活service worker:
// application installation
self
.addEventListener('install',event
=>{
console
.log('service worker: install');
// cache core files
event
.waitUntil(
installStaticFiles()
.then(()=>
self
.skipWaiting())
);
});
Activate事件
该事件发生在serviceworker被激活时,不论是安装后还是返回时。你可以用不到这一句柄,但演示代码中使用了该事件,用于删除现有的旧缓存:
// clear old caches
functionclearOldCaches(){
return
caches
.keys()
.then(
keylist
=>{
return
Promise
.all(
keylist
.filter(
key
=>key
!==CACHE
)
.map(
key
=>caches
.delete(key
))
);
});
}
// application activated
self
.addEventListener('activate',event
=>{
console
.log('service worker: activate');
// delete old caches
event
.waitUntil(
clearOldCaches()
.then(()=>
self
.clients
.claim())
);
});
Fetch事件
该事件发生在网络请求产生时。它会调用respondWith()方法来劫持GET请求并:
从缓存中获取资源,
如果#1失败,使用Fetch API(与fetch事件无关)从网络上加载资源,并将该资源加进缓存,
如果#1和#2都失败了,返回一个适当的结果。
// application fetch network data
self
.addEventListener('fetch',event
=>{
// abandon non-GET requests
if(
event
.request
.method
!=='GET')return;
let
url
=event
.request
.url
;
event
.respondWith(
caches
.open(CACHE
)
.then(
cache
=>{
return
cache
.match(event
.request
)
.then(
response
=>{
if(
response
){
// return cached file
console
.log('cache fetch: '+url
);
return
response
;
}
// make network request
returnfetch(
event
.request
)
.then(
newreq
=>{
console
.log('network fetch: '+url
);
if(
newreq
.ok
)cache
.put(event
.request
,newreq
.clone());
return
newreq
;
})
// app is offline
.catch(()=>offlineAsset(
url
));
});
})
);
});
最后调用的offlineAsset(url
)方法返回了一个适当的结果,提供了一些帮助功能:
// is image URL?
letiExt
=['png','jpg','jpeg','gif','webp','bmp'].map(f
=>'.'+f
);
functionisImage(url
){
return
iExt
.reduce((ret
,ext
)=>ret
||url
.endsWith(ext
),false);
}
// return offline asset
functionofflineAsset(url
){
if(isImage(
url
)){
// return image
returnnewResponse(
'<svg role="img" viewBox="0 0 400 300" xmlns="http://www.w3.org/2000/svg"><title>offline</title><path d="M0 0h400v300H0z" fill="#eee" /><text x="200" y="150" text-anchor="middle" dominant-baseline="middle" font-family="sans-serif" font-size="50" fill="#ccc">offline</text></svg>',
{
headers
:{
'Content-Type':'image/svg+xml',
'Cache-Control':'no-store'
}}
);
}
else{
// return page
return
caches
.match(offlineURL
);
}
}
offlineAsset(url
)函数会检查该request是否请求的是一张图片,是的话返回一个包含“offline”文字的SVG,不是的话,返回offlineURL页面。
Chrome开发者工具Application面板中的ServiceWorker选项,提供了有关worker的信息,
在开发者工具的 Cache Storage 选项列出了所有当前域内的缓存和所包含的静态文件。当缓存更新的时候,你可以点击左下角的刷新按钮来更新缓存:
不出意料, Clear storage 选项可以删除你的 serviceworker 和缓存:
再来一步 – Step 4:创建一个可用的离线页面
离线页面可以是一个静态页面,来说明当前用户请求不可用。然而,我们也可以在这个页面上列出可以访问的页面链接。
在main.js
中我们可以使用 Cache API 。然而API 使用promises,在不支持的浏览器中会引起所有javascript运行阻塞。为了避免这种情况,我们在加载另一个 /js/offlinepage.js
文件之前必须检查离线文件列表和是否支持 Cache API 。
// load script to populate offline page list
if(
document.getElementById(
'cachedpagelist') &&
'caches'inwindow) {
var
scr =
document.createElement(
'script');
scr.src =
'/js/offlinepage.js';
scr.async =
1;
document
.head.appendChild(scr);
}
/js/offlinepage.js
locates the most recent cache by version name, 取到所有 URL的key的列表,移除所有无用 URL,排序所有的列表并且把他们加到 ID 为cachedpagelist
的 DOM 节点中:
ent(
'li'),
// cache name
const
CACHE =
'::PWAsite',
offlineURL =
'/offline/',
list =
document.getElementById(
'cachedpagelist');
// fetch all caches
window.caches.keys()
.then(cacheList => {
// find caches by and order by most recent
cacheList = cacheList
.filter(cName => cName.includes(CACHE))
.sort((a, b) => a - b);
// open first cache
caches.open(cacheList[
0])
.then(cache => {
// fetch cached pages
cache.keys()
.then(reqList => {
let
frag =
document.createDocumentFragment();
reqList
.map(req => req.url)
.filter(req => (req.endsWith(
'/') || req.endsWith(
'.html')) && !req.endsWith(offlineURL))
.sort()
.forEach(req => {
let
li =
document.createElem a = li.appendChild(
document.createElement(
'a'));
a.setAttribute(
'href', req);
a.textContent = a.pathname;
frag.appendChild(li);
});
if
(list) list.appendChild(frag);
});
})
});
开发工具
如果你觉得 javascript调试困难,那么 service worker 也不会很好。Chrome的开发者工具的Application 提供了一系列调试工具。
你应该打开 隐身窗口 来测试你的 app,这样在你关闭这个窗口之后缓存文件就不会保存下来。
最后,Lighthouse extension for Chrome 提供了很多改进 PWA 的有用信息。
PWA 陷阱
有几点需要注意:
URL 隐藏
我们的示例代码隐藏了 URL栏,我不推荐这种做法,除非你有一个单 url 应用,比如一个游戏。对于多数网站,manifest 选项 display: minimal-ui
或者 display: browser
是最好的选择。
缓存太多
你可以缓存你网站的所有页面和所有静态文件。这对于一个小网站是可行的,但这对于上千个页面的大型网站实际吗?没有人会对你网站的所有内容都感兴趣,而设备的内存容量将是一个限制。即使你像示例代码一样只缓存访问过的页面和文件,缓存大小也会增长的很快。
也许你需要注意:
·只缓存重要的页面,类似主页,和最近的文章。
·不要缓存图片,视频和其他大型文件
·经常删除旧的缓存文件
·提供一个缓存按钮给用户,让用户决定是否缓存
缓存刷新
在示例代码中,用户在请求网络前先检查该文件是否缓存。如果缓存,就使用缓存文件。这在离线情况下很棒,但也意味着在联网情况下,用户得到的可能不是最新数据。
静态文件,类似于图片和视频等,不会经常改变的资源,做长时间缓存没有很大的问题。你可以在HTTP 头里设置 Cache-Control
来缓存文件使其缓存时间为一年(31,536,000 seconds):
Cache-Control:
max-age=31536000
页面,CSS和 script 文件会经常变化,所以你应该改设置一个很短的缓存时间比如 24 小时,并在联网时与服务端文件进行验证:
Cache-Control:
must-revalidate, max-age=86400
PS: 备份内容仅显示纯文字。