最近有不少关于 Progressive Web Apps(PWAs)的消息,不少人都在问这是否是(移动)web 的将来。我不想陷入native app 和 PWA 的纷争,可是有一件事是肯定的 --- PWA极大的提高了移动端表现,改善了用户体验。javascript
好消息是开发一个 PWA 并不难。事实上,咱们能够将现存的网站进行改进,使之成为PWA。这也是我这篇文章要讲的 -- 当你读完这篇文章,你能够将你的网站改进,让他看起来就像是一个 native web app。他能够离线工做而且拥有本身的主屏图标。css
Progressive Web Apps (下文以“PWAs”代指) 是一个使人兴奋的前端技术的革新。PWAs综合了一系列技术使你的 web app表现得就像是 native mobile app。相比于纯 web 解决方案和纯 native 解决方案,PWAs对于开发者和用户有如下优势:html
你只须要基于开放的 W3C 标准的 web 开发技术来开发一个app。不须要多客户端开发。前端
用户能够在安装前就体验你的 app。java
不须要经过 AppStore 下载 app。app 会自动升级不须要用户升级。node
用户会受到‘安装’的提示,点击安装会增长一个图标到用户首屏。git
被打开时,PWA 会展现一个有吸引力的闪屏。github
chrome 提供了可选选项,可使 PWA 获得全屏体验。web
必要的文件会被本地缓存,所以会比标准的web app 响应更快(也许也会比native app响应快)ajax
安装及其轻量 -- 或许会有几百 kb 的缓存数据。
网站的数据传输必须是 https 链接。
PWAs 能够离线工做,而且在网络恢复时能够同步最新数据。
如今还处在 PWA 的早期,但已经有 不少成功案例 。
PWA 技术目前被 Firefox,Chrome 和其余基于Blink内核的浏览器支持。微软正在努力在Edge浏览器上实现。Apple没有动做 although there are promising comments in the WebKit five-year plan。幸运的是,浏览器支持对于 PWA 彷佛不过重要...
你的app仍然能够运行在不支持 PWA 技术的浏览器里。用户不能离线访问,不过其余功能都像原来同样没有影响。综合利弊得失,没有理由不把你的 app 改进为 PWA。
Google 引领了 PWA 的一系列动做,因此大多数教程都在说如何从零开始构建一个基于 Chrome,native-looking mobile app。然而并非只有特殊的单页应用能够PWA化,也不须要必定遵循 material interface design guidelines。大多数网站均可以在数小时内实现 PWA 化。这包括你的 WordPress站点或者静态站点。
示例代码能够在https://github.com/sitepoint-editors/pwa-retrofit找到。
代码提供了一个简单的四个页面的网站。其中包含一些图片,一个样式表和一个main javascript 文件。这个网站能够运行在全部现代浏览器上(IE10+)。若是浏览器支持 PWA 技术,当离线时用户能够浏览他们以前看过的页面。
运行代码前,确保 Node.js 已经安装,而后再命令行里启动服务:
node ./server.js [port]
[port]
是可配置的,默认为 8888。打开 Chrome 或者其余基于Blink内核的浏览器,好比 Opera 或者 Vivaldi,而后输入连接 http://localhost:8888/(或者你指定的某个端口)。你也能够打开开发者工具看一下各个console信息。
浏览主页,或者其余页面,而后用如下任一方法使页面离线:
按下 Cmd/Ctrl + C ,中止 node 服务器,或者
在开发者工具的 Network 或者 Application - Service Workers 栏里点击 offline 选项。
从新浏览任意以前浏览过的页面,它们仍然能够浏览到。浏览一个以前没有看过的页面,你会看到一个专门的离线页面,标识“you’re offline”,还有一个你能够浏览的页面列表:
你也能够经过 USB 链接你的安卓手机来预览示例网页。在开发者工具中打开 Remote devices 菜单。
在左边选择 Settings ,点击 Add Rule 输入 8888 端口。你能够在你的手机上打开Chrome,打开 http://localhost:8888/。
你能够点击浏览器菜单里的 “Add to Home screen”。浏览几个页面,浏览器会提醒你去安装。这两种方式均可以建立一个新的图标在你的主屏上。浏览几个页面后关掉Chrome,断开设备链接。你依然能够打开 PWA Website app -- 你会看到一个启动页,而且能够离线访问以前你访问过的页面。
将你的网站改进为一个 Progressive Web App 总共有三个必要步骤:
因为一些显而易见的缘由,PWAs 须要 HTTPS 链接。
HTTPS 在示例代码中并非必须的,由于 Chrome 容许使用 localhost 或者任何 127.x.x.x 的地址来测试。你也能够在 HTTP 链接下测试你的 PWA,你须要使用 Chrome ,而且输入如下命令行参数:
--user-data-dir
--unsafety-treat-insecure-origin-as-secure
manifest 文件提供了一些咱们网站的信息,例如 name,description 和须要在主屏使用的图标的图片,启动屏的图片等。
manifest文件是一个 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" } ] }
在页面的<head>
中引入:
<link rel="manifest" href="/manifest.json">
manifest 中主要属性有:
name —— 网页显示给用户的完整名称
short_name —— 当空间不足以显示全名时的网站缩写名称
description —— 关于网站的详细描述
start_url —— 网页的初始 相对 URL(好比 /
)
scope —— 导航范围。好比,/app/
的scope就限制 app 在这个文件夹里。
background-color —— 启动屏和浏览器的背景颜色
theme_color —— 网站的主题颜色,通常都与背景颜色相同,它能够影响网站的显示
orientation —— 首选的显示方向:any
, natural
, landscape
, landscape-primary
, landscape-secondary
, portrait
, portrait-primary
, 和 portrait-secondary
。
display —— 首选的显示方式:fullscreen
, standalone
(看起来像是native app),minimal-ui
(有简化的浏览器控制选项) 和 browser
(常规的浏览器 tab)
icons —— 定义了 src
URL, sizes
和type
的图片对象数组。
MDN提供了完整的manifest属性列表:Web App Manifest properties
在开发者工具中的 Application tab 左边有 Manifest 选项,你能够验证你的 manifest JSON 文件,并提供了 “Add to homescreen”。
Service Worker 是拦截和响应你的网络请求的编程接口。这是一个位于你根目录的一个单独的 javascript 文件。
你的 js 文件(在示例代码中是 /js/main.js
)能够检查是否支持 Service Worker,而且注册:
if ('serviceWorker' in navigator) { // register service worker navigator.serviceWorker.register('/service-worker.js'); }
若是你不须要离线功能,能够简单的建立一个空的 /service-worker.js
文件 —— 用户会被提示安装你的 app。
Service Worker 很复杂,你能够修改示例代码来达到本身的目的。这是一个标准的 web worker,浏览器用一个单独的线程来下载和执行它。它没有调用 DOM 和其余页面 api 的能力,但他能够拦截网络请求,包括页面切换,静态资源下载,ajax请求所引发的网络请求。
这就是须要 HTTPS 的最主要的缘由。想象一下第三方代码能够拦截来自其余网站的 service worker, 将是一个灾难。
service worker 主要有三个事件: install,activate 和 fetch。
这个事件在app被安装时触发。它常常用来缓存必要的文件。缓存经过 Cache API来实现。
首先,咱们来构造几个变量:
缓存名称(CACHE
)和版本号(version
)。你的应用能够有多个缓存可是只能引用一个。咱们设置了版本号,这样当咱们有重大更新时,咱们能够更新缓存,而忽略旧的缓存。
一个离线页面的URL(offlineURL
)。当离线时用户试图访问以前未缓存的页面时,这个页面会呈现给用户。
一个拥有离线功能的页面必要文件的数组(installFilesEssential
)。这个数组应该包含静态资源,好比 CSS 和 JavaScript 文件,但我也把主页面(/
)和图标文件写进去了。若是主页面能够多个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 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 才会执行 install。执行 installStaticFiles()
方法,而后执行 self.skipWaiting()
方法使service worker进入 active状态。
// application installation self.addEventListener('install', event => { console.log('service worker: install'); // cache core files event.waitUntil( installStaticFiles() .then(() => self.skipWaiting()) ); });
当 install完成后, service worker 进入active状态,这个事件马上执行。你可能不须要实现这个事件监听,可是示例代码在这里删除老旧的无用缓存文件:
// 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()
方法设置自己为active的service worker。
当有网络请求时这个事件被触发。它调用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
页面。
开发者工具提供了查看 Service Worker 相关信息的选项:
在开发者工具的 Cache Storage 选项列出了全部当前域内的缓存和所包含的静态文件。当缓存更新的时候,你能够点击左下角的刷新按钮来更新缓存:
不出意料, Clear storage 选项能够删除你的 service worker 和缓存:
离线页面能够是一个静态页面,来讲明当前用户请求不可用。然而,咱们也能够在这个页面上列出能够访问的页面连接。
在main.js
中咱们可使用 Cache API 。然而API 使用promises,在不支持的浏览器中会引发全部javascript运行阻塞。为了不这种状况,咱们在加载另外一个 /js/offlinepage.js
文件以前必须检查离线文件列表和是否支持 Cache 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
locates the most recent cache by version name, 取到全部 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 提供了一系列调试工具。
你应该打开 隐身窗口 来测试你的 app,这样在你关闭这个窗口以后缓存文件就不会保存下来。
最后,Lighthouse extension for Chrome 提供了不少改进 PWA 的有用信息。
有几点须要注意:
咱们的示例代码隐藏了 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