[译] 将你的网站改造为PWA

本文翻译自:Retrofit Your Website as a Progressive Web App
如果原文打不开请移步:这里

最近大家都在讨论渐进增强web应用(Progressive Web Apps 简称PWA),许多人质疑PWA是否能代表移动web的未来。这里我不会介入navtive app 和 PWA的争论之中,但是有件事是可以确定的-PWA在很大的长度上增强移动端提高了用户的体验。到2018年移动设备的web接入的数量注定要超过其他所有设备总量和,这种趋势是不可忽视的。

好消息是制作PWA程序并不是很困难。实际上,将现有的网站转换成PWA是很有可能的。在本文中,我将会介绍如何将一个网站改造成像native app一样。它可以离线工作,并且拥有自己的应用图标。

什么是渐进增强web应用?

Progressiv Web App (简称PWA)是web上一个令人兴奋的创新。PWA由一系列技术组成,用来是web应用程序更像native应用。对于开发人员和用户来说,它可以突破web和native上面的限制因素:

  1. 你只需要开发一个符合W3c标准的web程序,不必开发单独的native版本
  2. 用户可以在安装前就使用你的程序
  3. 我们不在需要使用 AppStore,忍受那些难懂的规则或付费。应用程序可以自动更新,不再需要用户手动操作
  4. 网页可以提醒用户是否在主屏幕上添加应用图标
  5. 当程序启动时,PWA的具有吸引力的展示效果
  6. 如果有必要,可以修改浏览器设置达到全屏浏览的效果
  7. 基本的文件被缓存在本地因此PWA具有比普通web应用更快的响应速度(他们设置比native app还要快
  8. 安装更加的轻量,也许只是几百KB的缓存数据
  9. PWA处于离线状态可以在连接返回后同步数据

现在PWA技术还有些不成熟,但是已经有了积极的案例。印度最大的商务网站Flipkart 通过将native app转换成PWA使得销售增长了70%。全球最大的交易平台Alibaba转换率也达到了70%。
PWA技术已经被支持与Firefox,Chrome和其他基于Blink内核的浏览器中。微软的Edge也在努力的实现。尽管Apple在webkit五年计划上发表了很多积极的言论,但是依然没有支持PWA。

“渐进增强” web 应用

你的网站有可能依然运行在不支持PWA的浏览器中。这样做仅仅是让用户不能使用离线等功能,网站依然可以像网站一样正常运行。鉴于利益的汇报,我们没有理由不去将PWA技术添加到我们的网站之中。

这不仅仅是应用程序

Google 引领了PWA运动,以至于许多教程都在讲述如何在基于Chrome的基础上从头开始构建PWA应用。然而,你并不一定需要一个单页应用或者遵循界面设计指南。大多数网站都可以在几个小时能升级到PWA。这包括你的WordPress或者静态页面。在编写本文的时候,Smashing 杂志宣布,他们正在支持PWA的建设。

示例代码

你可以在这里找到示例代码:https://github.com/sitepoint-editors/pwa-retrofit
demo 提供了一个带有一些图片,一个样式表和一些主要的javascrit文件的四页网站。该网站可以运行在所有的现在浏览器中(IE10+)。如果您的浏览器支持PWA,那么你可以在离线时浏览整个网站。
运行代码请确保Node.js环境,在终端中执行下面的命令启动服务器:

node ./server.js [port]

打开Chrome或者基于Blik内核的浏览器,访问 http://localhost:8888/ 来查看页面。你可以打开 Developer Tools来查看控制台输出信息。

1.png

下面的方案可以帮你离线查看: 关闭 web 服务器,或者在Developer Tools的 Network或Application中点击offline复选框。 重新访问你之前的页面,他们依然会加载。访问你没有看到过的网站,则会显示 您处于离线状态的页面,其中包含了网页列表信息。
2.png

连接移动设备

你也可以通过USB线将移动设备连接到PC/MAC上,来查看页面。打开浏览器 More tools选中的Remote devices面板。

3.png

选择左侧的设置,点击 Add rule 添加 localhost:8888。现在你可以在移动设备上打开Chrome浏览器访问 http://localhost:8888。

你可以使用浏览器菜单中的Add to Home screen。几次访问后,浏览器也会提醒你是否安装。上述两种方法都可以将应用程序添加到你的主屏幕。浏览几个页面后关闭Chrome断开设备链接。之后打开你的 PWA 网站,你可以看见启动屏幕并且依然能够查看之前的页面,尽管这时候你处于离线状态。

如何使用PWA技术

将你的网站转换成PWA仅需要一下三个基本步骤:

步骤1:设置HTTPS

PWA需要HTTPS的支持,原因不言而喻。设置HTTPS在不同的主机上有不同的流程,但是HTTPS网站会在 Google 获得更高的 Rank 这使得一切都是值得的。
Chrome 允许使用 localhost 或者任何 127.x.x.x 来进行HTTPS测试。你也可以在Chrome启动中添加下面的命令,来使得PWA运行在HTTP中:

– user-data-dir
– unsafety-treat-insecure-origin-as-secure

步骤2:创建 manifest 文件

提供一个web应用程序相关的信息,包括名称,描述和配置到主屏幕的图标,启动图片和viewport。实际上,manifest 文件是提供上述信息的文件。
manifest 是一个JSON格式的文件。我们必须提供含有 Content-Type: application/manifest+json 或者 Content-Type: applicaiont/json 的HTTP请求头来时使用它。该文件可以随意起名,这里我们叫做 manifest.json:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
{
"name" : "PWA Website",
"short_name" : "PWA",
"description" : "An example 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"
}
]
}

<head>标签中需要加入下面的内容:

1
<link rel="manifest" href="/manifest.json">

这些字段的含义如下:

  • name – 应用名称
  • short_name – 应用的缩略名
  • description – 应用描述
  • start_url – 应用启动的相对路径 (通常为 /)
  • scope - 导航范围。例如:/app/会将应用限制在app文件夹下
  • background-color - 启动界面的背景颜色
  • display - 展示方式:fullscreen 全屏,standalong 类似native app,minimal-ui 一个简单的UI空间,browser 传统浏览器界面
  • icons - 定义图片信息的数组,包括图片URL,大小和类型。应该定义一些列图标信息。

MDN 定义了完整的列表Web App Manifest properties
Chrome 浏览器会监听验证你的manifest文件,并且提供add to homescreen 链接:

4.png

步骤3:创建Service Worker

Service Workers 是一个用来拦截网络请求的Javascript脚本,放在应用程序根目录下即可。
下面这段代码(/js/main.js)用来检测当网站支持 Service Worker 后注册service-worker.js脚本:

1
2
3
4
if ('serviceWorker' in navigator) {
// register service worker
navigator.serviceWorker.register('/service-worker.js');
}

Service workers 可能会让感到迷惑,你可以根据自己的目的来调整demo代码。示例代码是标准的浏览器下载脚本,它单独运行在一个线程上。该脚本无权访问DOM或其他页面的API,但是它可以在网页发生变化的时候拦截网络请求,资源下载和Ajax请求。
Service workers也是网站需要采用HTTPS的根本原因。试想一下,如果第三方脚本可有轻易注入你的Service worker中。它就有可能窃取和篡改客户端和服务端之间的通讯信息。
Service workers 需要响应下面三个基本事件:install, activate 和 fetch。

Install Event

这个事件发生在Service worker注册阶段。它通常用于使用Cache API来创建缓存,放置应用离线时所需要的资源。
首先,我们定义一些基础的配置:

  1. 缓存名称(CACHE)和版本号(version) 你的应用可以有多个缓存,但我们只使用一个。因此应用版本号就很有用,当网站有重大的改变后,将使用心得缓存忽略掉之前的缓存。
  2. 离线页面URL(offlineURL) 当用户离线后视图访问之前没有访问过的页面,该页面将被显示。
  3. 一系列提供离线功能的基本文件(installFilesEssential) 这里应该提供一个包含CSS和Javascript的数组,我还向数组中加入了主页(/)和logo。你也应该加入其他的需要文件例如:/ 或者 /index.html。注意offlineURL也要加入这个数组。
  4. 一些可选的文件(installFilesDesirable) 如果有可能,这些内容将会被下载,但不会使安装中断
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 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() 函数利用异步的Cache API 来将这些文件添加到缓存。当缓存生成后返回Promise对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
// install static assets
function installStaticFiles() {
return caches.open(CACHE)
.then(cache => {
// cache desirable files
cache.addAll(installFilesDesirable);
// cache essential files
return cache.addAll(installFilesEssential);
});
}

最后,我们注册一个 install 监听器。我们必须提供一个waitUntil方法(参数为promise对象),当oninstall或者onactivate触发时被调用,来执行我们的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// application installation
self.addEventListener('install', event => {
console.log('service worker: install');
// cache core files
event.waitUntil(
installStaticFiles()
.then(() => self.skipWaiting())
);
});
```
#### Activate Event
当Service worker安装成功后会被立刻激活该事件。你可能并不总是需要这个处理程序。下面这段代码可以用来删除旧的缓存:
```javascript
// clear old caches
function clearOldCaches() {
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())
);
});

注意,最后的 self.clients.claim() 将当前Service worker对象作为所有客户端的活动对象。

Fetch Event

当网络产生请求后触发该事件,它通过调用respondWith() 方法来拦截GET请求并返回:

  1. 缓存中的内容。
  2. 如果#1没有缓存,则使用Fetch API从网络加载资源(与Service worker的fetch event无关),然后将资源添加到缓存中。
  3. 如果#1,#2都失败了,则返回一个适当的结果。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 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
return fetch(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) 方法返回一个适当的响应:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// is image URL?
let iExt = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'].map(f => '.' + f);
function isImage(url) {
return iExt.reduce((ret, ext) => ret || url.endsWith(ext), false);
}
// return offline asset
function offlineAsset(url) {
if (isImage(url)) {
// return image
return new Response(
'<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()函数用来检测请求是否是一个图片并且返回一个包含”offline”字段的SVG。其他请求则返回离线页面。
Chrome开发工具提供了查看Service Worker信息的选项,包含了错误消息,重新加载工具和页面离线等内容:

5.png

Cache Storage 列出了当前环境下所有的缓存内容,如果缓存有更新你需要点击刷新按钮来看到他们。

6.png

同样Clear storage选项可以删除你的Service worker和缓存。

步骤4:创建一个实用的离线页面

离线页面可以是一个静态页面来提醒用户他们访问的页面不能离线查看。同时,我们还需要提供一个可访问页面的URL列表供用户查看。
我们可以再main.js中来使用Cache API。但是,API的异步请求失败的时候会导致浏览器停止运行。为了防止这种情况发生,我们将检测离线元素和Caches API是否可用的代码放在其他的文件中 /js/offlinepage.js(必须放在 installFiledEssential数组前):

1
2
3
4
5
6
7
// load script to populate offline page list
if (document.getElementById('cachedpagelist') && 'caches' in window) {
var scr = document.createElement('script');
scr.src = '/js/offlinepage.js';
scr.async = 1;
document.head.appendChild(scr);
}

/js/offlinepage 通过版本名称来查找最近的缓存,URL列表,删除没有页面的URLs,对列表进行排序并添加到id为cachedpagelist的DOM节点上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 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.createElement('li'),
a = li.appendChild(document.createElement('a'));
a.setAttribute('href', req);
a.textContent = a.pathname;
frag.appendChild(li);
});
if (list) list.appendChild(frag);
});
})
});

调试工具

如果你认为调试Serice Workers有困难,Chrome Development Tools中的 Application选项提供了一些列日志消息在控制台中。
你应该在隐身模式下开发你的应用程序,这样可以避免文件缓存带来的影响。
Firefox也提供了来自Service Workers的JavaScript调试信息,未来也会更加的完善。
最后Chrome 扩展程序 Lighthouse 可以提供更加丰富的PWA信息。

PWA 的问题

对于渐进增强web应用的支持需要谨慎,如果改造它花了很长时间那就得不偿失了。毕竟不支持 PWA 也不会造成很大的负面影响。
这里我们还有几个问题需要考虑:

URL 隐藏

如何隐藏地址栏我并没有演示,除非你开发了一个单页的游戏这才有必要。manifest的diplay:minimal-ui 或者 display: browser 才是适合大多数网站的。

缓存重载

你可以缓存站点的每一页。这对于小型网站是有好处的,但是对于那些拥有很多页面的网站就不那么可取了。没有人会对你所有的内容感兴趣并且设备缓存是有限度的。及时你只缓存访问过的页面,缓存依旧增长的很明显。
你也许该考虑以下建议:

  • 只缓存重要的页面如主页,链接,最近的文章等
  • 不缓存图片,视频和大的文件
  • 定期清除旧的缓存文件
  • 提供一个”缓存到本地”的按钮,以便用户可以自行选择

缓存更新

缓存同样需要定期更新,不然用户只能看见旧的页面。
对于图片和视频这种不经常变化的内容,你可以设置一个持续一年的缓存时间:

Cache-Control: max-age=31536000

页面,css和脚本文件有可能频繁更新,你需要设置较短的更新时间例如24小时,确保在线状态服务器对版本的验证:

Cache-Control: must-revalidate, max-age=86400

你也可以考虑使用缓存清除技术来确定哪些不适用的旧文件,例如:命名你的CSS文件 styles-abc124.css,每个版本都修改哈希值。
缓存是很复杂的这里我建议你阅读 Jake Archibold 的文章 Caching best practices & max-age gotchas

更多内容

如果你想了解更多,可以参考一下内容: