最近有不少關於 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