本文最初发表于 SitePoint 站点,经原作者 Craig Buckler 授权由 InfoQ 中文站翻译并分享。
最近围绕渐进式 Web 应用(PWA)有很多的讨论,很多人在怀疑它是不是代表了(移动)Web 的未来。我不会卷入原生应用与 PWA 之间的争论,但有一点是毋庸置疑的:它们对改善移动和增强用户体验大有助益。
移动 Web 访问将会将会超过其他设备的总和,面对这种趋势,你能视若无睹吗?
好消息是实现 PWA 并不困难。实际上,将现有的 Web 站点转换为 PWA 是非常具有可行性的。在本教程中,我们会讨论这一话题,在本文结束的时候,我们将会有一个行为与原生 Web 应用一致的站点。它能够离线运行并且具有自己的主页屏幕图标。
什么是渐进式 Web 应用
渐进式 Web 应用(Progressive Web Apps,也被称为 PWA)是 Web 技术方面一项令人兴奋的创新。PWA 混合了多项技术,能够让 Web 应用的功能类似于原生移动应用。它为开发人员和用户带来的收益能够突破纯 Web 解决方案和纯原生解决方案的限制:
- 你只需要一个按照开放、标准 W3C Web 技术开发的应用,不需要开发单独的原生代码库;
- 用户在安装之前就能发现并尝试你的应用;
- 没有必要使用 AppStore,无需遵循复杂的规则或支付费用。应用程序会自动更新,无需用户交互;
- 用户会被提示“安装”,这样会添加一个图标到主屏幕上;
- 当启动的时候,PWA 会展现一个有吸引力的启动闪屏画面;
- 如果需要的话,浏览器的 chrome 选项可以进行修改,以便于提供全屏的体验;
- 基本文件会在本地缓存,所以 PWA 要比标准 Web 应用反应更快(它们甚至能够比原生应用更快);
- 安装是轻量级的,可能只需几 KB 的缓存数据;
- 所有的数据交换必须要通过安全的 HTTPS 连接来执行;
- PWA 支持离线功能,当网络恢复后,数据会进行同步。
虽然还言之过早,但是一些案例研究都是正面的。Flipkart 是印度最大的电子商务网站,在他们放弃原生应用并转向PWA 之后,销售转化率提高了70% 并且用户的在线时长增加了三倍。阿里巴巴是世界最大的商务交易平台,转化率同样经历了76% 的增长。
Firefox、Chrome 和其他基于 Blink 的浏览器都能很好地支持 PWA 技术。微软正在致力于 Edge 的实现。苹果依然保持沉默,但是在 WebKit 的五年计划中有一些值得期待的评论(iOS 11.3 中已经添加了对 PWA 的支持,但是有一定的局限性,参见 InfoQ 之前的报道。——编辑注)。但是,浏览器的支持其实没有太大的影响……
渐进式 Web 应用是渐进式的增强
你的应用依然能够在不支持 PWA 技术的浏览器中运行。只是用户无法体验离线功能的好处,但其他的功能都能像以前一样运行。考虑到成本 - 效益的回报,没有理由不将 PWA 技术应用到你的系统中。
它不仅仅是 App
谷歌引领了 PWA 运动,所以大多数的教程都描述了如何从头开始构建一个基于 Chrome 的、外观看上去类似于原生的移动应用。但是,我们并不一定需要一个特殊的单页应用,或者要遵循 material 界面的设计指南。大多数的 Web 站点都可以在几个小时内转换为 PWA,其中包括 WordPress 或静态站点。
示例代码
示例代码可以通过 GitHub 获取。
它提供了一个简单的、四页的 Web 站点,包含了一些图片、一个样式表和一个 JavaScript 文件。这个站点能够在所有现代浏览器(IE10+)上运行。如果浏览器支持 PWA 技术的话,用户还可以在离线的情况下阅读之前看过的页面。
要运行该代码,确保已经安装了 Node.js ,在终端中运行所提供的 Web 服务器:
node ./server.js [port]
在上面的代码中,[port]
是可选的,默认是 8888。打开 Chrome 或者其他基于 Blink 的浏览器如 Opera 或 Vivaldi,然后导航至 http://localhost:8888/ (或者你所指定的端口)。你也可以打开开发者工具(F12 或Cmd/Ctrl + Shift + I
)来查看各种控制台信息。
查看主页和其他页面,你也可以按照如下的方式切换至离线状态:
- 通过
Cmd/Ctrl + C
停掉 Web 服务器,或者 - 在开发者工具中的Network或Application(在Service Workers标签页下)中选中Offline复选框。
重新访问你之前访问过的页面,它们依然能够加载。如果访问之前没有看过的页面,将会展现一个“你现在处于离线状态”的页面,还会列出可访问的页面:
连接设备
你还可以通过 Android 智能手机查看示例页面,这些手机需要通过 USB 连接到 PC/MAC 上。打开左侧三个点的菜单,打开Remote devices面板:
选择左侧的Settings,然后点击Add Rule,将 8888 转发到 localhost:8888,现在你就可以在智能手机中打开 Chrome 并导航至 http://localhost:8888/ 。
你可以利用浏览器菜单中的“Add to Home screen”。多次访问之后,浏览器会提示你进行“Install”。这两种方式都能在你的主页上创建一个新的图标(icon)。访问几个页面之后,关闭 Chrome 并断开设备的连接。然后你可以启动该PWA Web应用。此时,将会看到一个闪屏界面,尽管没有连接服务器,依然能够访问之前阅读过的页面。
要将你的 Web 站点转换为渐进式 Web 应用,主要可以分为如下的三步。
第一步:启用 HTTPS
PWA 需要 HTTPS 连接,这样做的原因很快就能体现出来。不同主机的成本和流程会有所差异,但是付出的成本和努力都是值得的,而且 Google 搜索对安全站点的排名更高。
对于上面的阐述来说,HTTPS 并不是必需的,因为 Chrome 允许使用 localhost 和任意的 127.x.x.x 地址进行测试。如果你使用如下的命令行标记启动 Chrome 的话,还可以在 HTTP 站点上测试 PWA:
--user-data-dir
--unsafety-treat-insecure-origin-as-secure
第二步:创建 Web 应用清单
Web 应用清单(manifest)提供了关于应用的信息,比如名称、描述和图片,OS 会使用它们来显示主页屏幕的图标、闪屏页面和视区(viewport)。本质上来讲,清单就是用一个文件来替换你可能已经在页面上定义的多个厂商相关的图标以及主题元标记。
清单是一个 JSON 文本文件,位于应用的根目录下。该文件必须要以Content-Type: application/manifest+json
或Content-Type: application/json
HTTP 头来进行响应。这个文件可以是任意的名称,不过在示例代码中,它被称为/manifest.json
:
{ "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" } ] }
对该文件的引用要放到所有页面的<header>
之中:
<link rel="manifest" href="/manifest.json">
主要的清单属性是:
- name:要展现给用户的应用全名;
- short_name:缩写的名称,如果没有足够的空间显示全名的话,将会显示缩写的名称;
- description:应用的长描述;
- start_url:启动应用的相对 URL(一般为
/
); - scope:导航作用域,比如
/app/
的作用域将会限制应用在该文件夹中; - background_color:用于闪屏和浏览器 chrome(如果需要的话)的背景颜色;
- theme_color:应用的颜色,一般会与背景颜色相同,这会影响到应用如何展现;
- orientation:推荐的屏幕方向:
any、natural、landscape、landscape-primary、landscape-secondary、portrait、portrait-primary 和 portrait-secondary
; - display:推荐的视图展现:
fullscreen
(非 chrome)、standalone
(看起来像原生应用)、minimal-ui
(UI 控件的一个小的集合) 以及browser
(便利的浏览器标签); - icons:图片对象的数组,定义了
src
URL、sizes
和type
(应该定义一系列的图标)。
MDN 提供了 Web 应用清单属性的完整列表。
Chrome 开发工具的Application标签下Manifest区域会校验清单 JSON 并提供一个“Add to homescreen”连接,会将功能放到设备的桌面上:
第三步:创建 Service Worker
Service Worker 是一个可编程的代理,它可以拦截和响应网络请求。它们是位于应用根目录下的一个 JavaScript 文件。
你的页面 JavaScript(示例代码中的/js/main.js
)能够检查对 service worker 的支持并注册该文件:
if ('serviceWorker' in navigator) { // register service worker navigator.serviceWorker.register('/service-worker.js'); }
如果你不需要离线功能的话,只需创建一个空的/service-worker.js
。用户将会提示安装你的应用。
Service Worker 可能会让人觉得有些困惑,但是你可以根据自己的意图调整示例代码。浏览器会下载一个标准的 Web Worker 脚本,并在单独的线程中运行。它没有访问 DOM 和其他的页面 API,但是能够拦截页面变化、资产下载以及 Ajax 调用所触发的网络调用。
这就是采用 HTTPS 的主要原因。设想一下,如果一个来自其他域的第三方脚本能够注入自己的 service worker,那将带来多大的混乱。 这个脚本就能探测并修改客户端和服务器之间的所有数据交换。
Service Worker 会响应三个主要的事件: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 = [ 'https://dab1nmslvvntp.cloudfront.net/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 function installStaticFiles() { return caches.open(CACHE) .then(cache => { // cache desirable files cache.addAll(installFilesDesirable); // cache essential files return cache.addAll(installFilesEssential); }); }
最后,我们添加一个install
事件监听器。waitUntil
方法会确保 service worker 直到所有闭包方法均执行完之后再进行安装。它运行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 事件
当 service worker 激活的时候,将会触发该事件,要么是安装之后,要么是在返回的时候。你可能并不需要这个处理器,但是在示例代码中会使用它来删除旧的缓存(如果存在的话):
// 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 设置为站点的一个活跃 worker。
Fetch 事件
当进行网络请求的时候,将会触发该事件。在这里调用respondWith()
方法来拦截 GET 请求和返回值:
- 来自缓存的资产;
- 如果#1 失败的话,该资产将会使用 Fetch API 通过网络进行加载(与 service worker 的 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 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)
的调用会返回一个恰当的响应,这里会使用几个辅助函数:
// 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。所有其他的请求返回offlineURL
页面。
在 Chrome 开发者工具的Application标签页中,Service Worker区提供了关于 worker 的信息,其中包含了强制加载和让浏览器处于离线状态的设施:
Cache Storage区列出了当前作用域下所有的缓存以及它们所包含的缓存资产。当缓存更新的时候,你可能需要点击一下刷新按钮:
Clear storage区可以删除 service worker 和缓存:
额外的步骤 4:创建有用的离线页面
离线页面可以是静态的 HTML,只是提醒用户他们所请求的页面在离线状态下不可用。但是,我们还可以提供一个可阅读的页面 URL 的列表。
在我们main.js
脚本中可以访问 Cache API ,但是该 API 使用了 Promise,在不支持的浏览器中会发生失败,这将会导致所有的 JavaScript 停止执行。为了避免这种情况,在加载另一个/js/offlinepage.js
JavaScript 文件(它必须位于前面所述的installFilesEssential
数组中)之前,我们需要添加代码检查离线列表元素和 Caches API 是否可用:
// 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.js
会根据版本号定位最近的缓存,获取所有 URL 的 key 的列表,移除非页面的 URL,对列表进行排序并根据元素 ID cachedpagelist
将它们附加到 DOM 节点上:
// 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); }); }) });
开发工具
如果你认为 JavaScript 调试很困难的话,service worker 也有趣不到哪里去。Chrome 开发者工具的Application
提供了一些有用的特性,日志输出也会打印在控制台上。
在开发阶段,你应该考虑以Incognito window方式运行应用,因为这样的话,在关闭标签页的时候,缓存文件将不会保留。
Firefox 在工具按钮上提供了一个 Service Workers 选项,用来进行 JavaScript 调试器的访问。
最后, Chrome 的 Lighthouse 扩展也提供了关于 PWA 实现的有用信息。
PWA 陷阱
渐进式 Web 应用需要新的技术,所以有一些建议的注意点。也就是说,它们是对已有 Web 站点的增强,它的改造不应该超过数个小时,并且对不支持的浏览器不应造成负面的影响。
开发人员的意见差别很大,但是有以下几点需要考虑。
URL 隐藏
示例站点隐藏了 URL 栏,除非你是单页应用,如游戏,否则我不推荐这样做。对于大多数站点来说,清单选项display: minimal-ui
或display: browser
可能是最好的。
缓存过载
你可以将站点的每个页面和资产缓存下来。对于小型的站点来说,这是可行的,对于具备上千个页面的应用来说,这样现实吗?没有人会关心你的所有内容,这可能也会超出设备存储的限制。即便你像上面的样例这样只缓存访问过的页面和资产,缓存空间可能也会有很大的增长。
我们可能会考虑采用下面的策略:
* 只缓存重要的页面,如主页、联系信息页以及最近的文章;
* 不缓存图片、视频和其他的大文件;
* 定期清理较旧的缓存文件;
* 提供一个“缓存本页供离线阅读”的按钮,这样用户就可以选择缓存哪些内容了。
缓存刷新
示例代码在从网络加载资产之前,会首先在缓存中查找。当用户处理离线状态的时候,这是很棒的,但是这也意味着用户处于在线状态时,可能也会看到旧的页面。
资产(如图片和视频)的 URL 应该是永远不会变的,所以长期缓存一般不会导致什么问题。我们可以通过Cache-Control
HTTP 头设置它们至少缓存一年的时间(1,536,000 秒):
Cache-Control: max-age=31536000
页面、CSS 和脚本可能会频繁变化,所以你可以设置一个较短的时间,如 24 小时,并确保处于在线状态时校验服务器端的版本:
Cache-Control: must-revalidate, max-age=86400
我们还可以采用 cache-busting 技术,确保不会使用较旧的资产,例如,将 CSS 文件命名为tyles-abc123.css
并在每次释放的时候变更 hash 值。
缓存可能会非常复杂,所以我推荐你阅读一下 Jake Archibold 的文章 Caching best practices & max-age gotchas 。
有用的链接
如果你想要了解渐进式 Web 应用的更多知识的话,可以参考如下有用的资源:
- PWA.rocks 样例应用
- Progressive Web Apps
- 第一个PWA
- Mozilla Service Worker Cookbook
- MDN Using Service Workers
除此之外,还有很多在线的文章,它们影响了我构建示例代码的方式。
关于作者:
Craig 是英国一位自由职业的 Web 咨询师,他 1995 年就为 IE 2.0 构建了第一个页面。从那时起,他就倡议采用标准的、可访问性的以及符合最佳实践的 HTML 5 技术。他为 SitePoint 撰写了超过 1000 篇文章,你可以通过他的推特 @craigbuckler 联系到他。
评论 1 条评论