[关闭]
@levinzhang 2018-07-21T13:32:20.000000Z 字数 11083 阅读 611

将Web站点改造为渐进式Web应用

摘要

最近围绕渐进式Web应用(PWA)有很多的讨论,很多人在怀疑它是不是代表了(移动)Web的未来。在本文中,作者以实际的样例阐述了如何将一个已有的Web站点改造为PWA,改造后的站点在行为上与原生Web应用相同,它能够离线运行并且具有自己的主页屏幕图标。


本文最初发表于SitePoint站点,经原作者Craig Buckler授权由InfoQ中文站翻译并分享。

最近围绕渐进式Web应用(PWA)有很多的讨论,很多人在怀疑它是不是代表了(移动)Web的未来。我不会卷入原生应用与PWA之间的争论,但有一点是毋庸置疑的:它们对改善移动和增强用户体验大有助益。

移动Web访问将会将会超过其他设备的总和,面对这种趋势,你能视若无睹吗?

好消息是实现PWA并不困难。实际上,将现有的Web站点转换为PWA是非常具有可行性的。在本教程中,我们会讨论这一话题,在本文结束的时候,我们将会有一个行为与原生Web应用一致的站点。它能够离线运行并且具有自己的主页屏幕图标。

什么是渐进式Web应用

渐进式Web应用(Progressive Web Apps,也被称为PWA)是Web技术方面一项令人兴奋的创新。PWA混合了多项技术,能够让Web应用的功能类似于原生移动应用。它为开发人员和用户带来的收益能够突破纯Web解决方案和纯原生解决方案的限制:

  1. 你只需要一个按照开放、标准W3C Web技术开发的应用,不需要开发单独的原生代码库;
  2. 用户在安装之前就能发现并尝试你的应用;
  3. 没有必要使用AppStore,无需遵循复杂的规则或支付费用。应用程序会自动更新,无需用户交互;
  4. 用户会被提示“安装”,这样会添加一个图标到主屏幕上;
  5. 当启动的时候,PWA会展现一个有吸引力的启动闪屏画面;
  6. 如果需要的话,浏览器的chrome选项可以进行修改,以便于提供全屏的体验;
  7. 基本文件会在本地缓存,所以PWA要比标准Web应用反应更快(它们甚至能够比原生应用更快);
  8. 安装是轻量级的,可能只需几KB的缓存数据;
  9. 所有的数据交换必须要通过安全的HTTPS连接来执行;
  10. 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)来查看各种控制台信息。

查看主页和其他页面,你也可以按照如下的方式切换至离线状态:

  1. 通过Cmd/Ctrl + C停掉Web服务器,或者
  2. 在开发者工具中的NetworkApplication(在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:

第二步:创建Web应用清单

Web应用清单(manifest)提供了关于应用的信息,比如名称、描述和图片,OS会使用它们来显示主页屏幕的图标、闪屏页面和视区(viewport)。本质上来讲,清单就是用一个文件来替换你可能已经在页面上定义的多个厂商相关的图标以及主题元标记。

清单是一个JSON文本文件,位于应用的根目录下。该文件必须要以Content-Type: application/manifest+jsonContent-Type: application/json HTTP头来进行响应。这个文件可以是任意的名称,不过在示例代码中,它被称为/manifest.json

  1. {
  2. "name" : "PWA Website",
  3. "short_name" : "PWA",
  4. "description" : "An example PWA website",
  5. "start_url" : "/",
  6. "display" : "standalone",
  7. "orientation" : "any",
  8. "background_color" : "#ACE",
  9. "theme_color" : "#ACE",
  10. "icons": [
  11. {
  12. "src" : "/images/logo/logo072.png",
  13. "sizes" : "72x72",
  14. "type" : "image/png"
  15. },
  16. {
  17. "src" : "/images/logo/logo152.png",
  18. "sizes" : "152x152",
  19. "type" : "image/png"
  20. },
  21. {
  22. "src" : "/images/logo/logo192.png",
  23. "sizes" : "192x192",
  24. "type" : "image/png"
  25. },
  26. {
  27. "src" : "/images/logo/logo256.png",
  28. "sizes" : "256x256",
  29. "type" : "image/png"
  30. },
  31. {
  32. "src" : "/images/logo/logo512.png",
  33. "sizes" : "512x512",
  34. "type" : "image/png"
  35. }
  36. ]
  37. }

对该文件的引用要放到所有页面的<header>之中:

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

主要的清单属性是:

MDN提供了Web应用清单属性的完整列表

Chrome开发工具的Application标签下Manifest区域会校验清单JSON并提供一个“Add to homescreen”连接,会将功能放到设备的桌面上:

第三步:创建Service Worker

Service Worker是一个可编程的代理,它可以拦截和响应网络请求。它们是位于应用根目录下的一个JavaScript文件。

你的页面JavaScript(示例代码中的/js/main.js)能够检查对service worker的支持并注册该文件:

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

如果你不需要离线功能的话,只需创建一个空的/service-worker.js。用户将会提示安装你的应用。

Service Worker可能会让人觉得有些困惑,但是你可以根据自己的意图调整示例代码。浏览器会下载一个标准的Web Worker脚本,并在单独的线程中运行。它没有访问DOM和其他的页面API,但是能够拦截页面变化、资产下载以及Ajax调用所触发的网络调用。

这就是采用HTTPS的主要原因。设想一下,如果一个来自其他域的第三方脚本能够注入自己的service worker,那将带来多大的混乱。 这个脚本就能探测并修改客户端和服务器之间的所有数据交换。

Service Worker会响应三个主要的事件:installactivatefetch.

Install事件

当应用安装的时候,将会触发该事件。它一般用来借助Cache API缓存必要的文件。

首先,我们定义一些配置变量:

  1. 缓存名(CACHE)和版本(version)。你的应用可以有多个缓存存储,但是我们这里只需要一个。我们还会使用一个版本号,所以如果做一些重要的变更的话,将会使用一个新的缓存,所有之前缓存过的文件将会被忽略。

  2. 离线页面URL(offlineURL)。这是一个页面,当用户处于离线状态并且想要加载之前没有访问过的页面时,将会展现该页面。

  3. 要安装的必要文件所组成的数组,它们能够确保站点的离线功能(installFilesEssential)。这应该包括像CSS和JavaScript这样的资产,但是我还将主页(/)和logo包含了进来。如果URL能够通过多种方式进行处理的话,还应该包含变种形式,比如//index.html。需要注意,offlineURL要添加到该数组中。

  4. 另外,还有一个建议文件的数组(installFilesDesirable)。如果可以下载的话,这些文件会进行下载,但是如果下载失败的话,也不会让安装过程中断。
  1. // configuration
  2. const
  3. version = '1.0.0',
  4. CACHE = version + '::PWAsite',
  5. offlineURL = '/offline/',
  6. installFilesEssential = [
  7. '/',
  8. '/manifest.json',
  9. '/css/styles.css',
  10. '/js/main.js',
  11. '/js/offlinepage.js',
  12. '/images/logo/logo152.png'
  13. ].concat(offlineURL),
  14. installFilesDesirable = [
  15. 'https://dab1nmslvvntp.cloudfront.net/favicon.ico',
  16. '/images/logo/logo016.png',
  17. '/images/hero/power-pv.jpg',
  18. '/images/hero/power-lo.jpg',
  19. '/images/hero/power-hi.jpg'
  20. ];

installStaticFiles()函数会使用基于Promise的Cache API将文件添加到缓存中。只有当必要的文件都缓存成功的时候,才会生成一个返回值。

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

最后,我们添加一个install事件监听器。waitUntil方法会确保service worker直到所有闭包方法均执行完之后再进行安装。它运行installStaticFiles()self.skipWaiting()让service worker处于激活状态:

  1. // application installation
  2. self.addEventListener('install', event => {
  3. console.log('service worker: install');
  4. // cache core files
  5. event.waitUntil(
  6. installStaticFiles()
  7. .then(() => self.skipWaiting())
  8. );
  9. });

Activate事件

当service worker激活的时候,将会触发该事件,要么是安装之后,要么是在返回的时候。你可能并不需要这个处理器,但是在示例代码中会使用它来删除旧的缓存(如果存在的话):

  1. // clear old caches
  2. function clearOldCaches() {
  3. return caches.keys()
  4. .then(keylist => {
  5. return Promise.all(
  6. keylist
  7. .filter(key => key !== CACHE)
  8. .map(key => caches.delete(key))
  9. );
  10. });
  11. }
  12. // application activated
  13. self.addEventListener('activate', event => {
  14. console.log('service worker: activate');
  15. // delete old caches
  16. event.waitUntil(
  17. clearOldCaches()
  18. .then(() => self.clients.claim())
  19. );
  20. });

注意,最后的self.clients.claim()会将该service worker设置为站点的一个活跃worker。

Fetch事件

当进行网络请求的时候,将会触发该事件。在这里调用respondWith()方法来拦截GET请求和返回值:

  1. 来自缓存的资产;
  2. 如果#1失败的话,该资产将会使用Fetch API通过网络进行加载(与service worker的fetch事件无关),随后这个资产将会添加到缓存中;
  3. 如果#1和#2都失败的话,将会返回一个恰当的响应。
  1. // application fetch network data
  2. self.addEventListener('fetch', event => {
  3. // abandon non-GET requests
  4. if (event.request.method !== 'GET') return;
  5. let url = event.request.url;
  6. event.respondWith(
  7. caches.open(CACHE)
  8. .then(cache => {
  9. return cache.match(event.request)
  10. .then(response => {
  11. if (response) {
  12. // return cached file
  13. console.log('cache fetch: ' + url);
  14. return response;
  15. }
  16. // make network request
  17. return fetch(event.request)
  18. .then(newreq => {
  19. console.log('network fetch: ' + url);
  20. if (newreq.ok) cache.put(event.request, newreq.clone());
  21. return newreq;
  22. })
  23. // app is offline
  24. .catch(() => offlineAsset(url));
  25. });
  26. })
  27. );
  28. });

最后对offlineAsset(url)的调用会返回一个恰当的响应,这里会使用几个辅助函数:

  1. // is image URL?
  2. let iExt = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'].map(f => '.' + f);
  3. function isImage(url) {
  4. return iExt.reduce((ret, ext) => ret || url.endsWith(ext), false);
  5. }
  6. // return offline asset
  7. function offlineAsset(url) {
  8. if (isImage(url)) {
  9. // return image
  10. return new Response(
  11. '<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>',
  12. { headers: {
  13. 'Content-Type': 'image/svg+xml',
  14. 'Cache-Control': 'no-store'
  15. }}
  16. );
  17. }
  18. else {
  19. // return page
  20. return caches.match(offlineURL);
  21. }
  22. }

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是否可用:

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

/js/offlinepage.js会根据版本号定位最近的缓存,获取所有URL的key的列表,移除非页面的URL,对列表进行排序并根据元素ID cachedpagelist将它们附加到DOM节点上:

  1. // cache name
  2. const
  3. CACHE = '::PWAsite',
  4. offlineURL = '/offline/',
  5. list = document.getElementById('cachedpagelist');
  6. // fetch all caches
  7. window.caches.keys()
  8. .then(cacheList => {
  9. // find caches by and order by most recent
  10. cacheList = cacheList
  11. .filter(cName => cName.includes(CACHE))
  12. .sort((a, b) => a - b);
  13. // open first cache
  14. caches.open(cacheList[0])
  15. .then(cache => {
  16. // fetch cached pages
  17. cache.keys()
  18. .then(reqList => {
  19. let frag = document.createDocumentFragment();
  20. reqList
  21. .map(req => req.url)
  22. .filter(req => (req.endsWith('/') || req.endsWith('.html')) && !req.endsWith(offlineURL))
  23. .sort()
  24. .forEach(req => {
  25. let
  26. li = document.createElement('li'),
  27. a = li.appendChild(document.createElement('a'));
  28. a.setAttribute('href', req);
  29. a.textContent = a.pathname;
  30. frag.appendChild(li);
  31. });
  32. if (list) list.appendChild(frag);
  33. });
  34. })
  35. });

开发工具

如果你认为JavaScript调试很困难的话,service worker也有趣不到哪里去。Chrome开发者工具的Application提供了一些有用的特性,日志输出也会打印在控制台上。

在开发阶段,你应该考虑以Incognito window方式运行应用,因为这样的话,在关闭标签页的时候,缓存文件将不会保留。

Firefox在工具按钮上提供了一个Service Workers选项,用来进行JavaScript调试器的访问。

最后,Chrome的Lighthouse扩展也提供了关于PWA实现的有用信息。

PWA陷阱

渐进式Web应用需要新的技术,所以有一些建议的注意点。也就是说,它们是对已有Web站点的增强,它的改造不应该超过数个小时,并且对不支持的浏览器不应造成负面的影响。

开发人员的意见差别很大,但是有以下几点需要考虑。

URL隐藏

示例站点隐藏了URL栏,除非你是单页应用,如游戏,否则我不推荐这样做。对于大多数站点来说,清单选项display: minimal-uidisplay: 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应用的更多知识的话,可以参考如下有用的资源:

除此之外,还有很多在线的文章,它们影响了我构建示例代码的方式。

关于作者:

Craig是英国一位自由职业的Web咨询师,他1995年就为IE 2.0构建了第一个页面。从那时起,他就倡议采用标准的、可访问性的以及符合最佳实践的HTML 5技术。他为SitePoint撰写了超过1000篇文章,你可以通过他的推特@craigbuckler联系到他。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注