最近有很多關於 Progressive Web Apps(PWAs)的訊息,很多人都在問這是不是(移動)web 的未來。我不想陷入native app 和 PWA 的紛爭,但是有一件事是確定的 --- PWA極大的提升了移動端表現,改善了使用者體驗。
好訊息是開發一個 PWA 並不難。事實上,我們可以將現存的網站進行改進,使之成為PWA。這也是我這篇文章要講的 -- 當你讀完這篇文章,你可以將你的網站改進,讓他看起來就像是一個 native web app。他可以離線工作並且擁有自己的主屏圖示。
Progressive Web Apps 是什麼?
Progressive Web Apps (下文以“PWAs”代指) 是一個令人興奮的前端技術的革新。PWAs綜合了一系列技術使你的 web app表現得就像是 native mobile app。相比於純 web 解決方案和純 native 解決方案,PWAs對於開發者和使用者有以下優點:
你只需要基於開放的 W3C 標準的 web 開發技術來開發一個app。不需要多客戶端開發。
使用者可以在安裝前就體驗你的 app。
不需要通過 AppStore 下載 app。app 會自動升級不需要使用者升級。
使用者會受到‘安裝’的提示,點選安裝會增加一個圖示到使用者首屏。
被開啟時,PWA 會展示一個有吸引力的閃屏。
chrome 提供了可選選項,可以使 PWA 得到全屏體驗。
必要的檔案會被本地快取,因此會比標準的web app 響應更快(也許也會比native app響應快)
安裝及其輕量 -- 或許會有幾百 kb 的快取資料。
網站的資料傳輸必須是 https 連線。
PWAs 可以離線工作,並且在網路恢復時可以同步最新資料。
現在還處在 PWA 的早期,但已經有 很多成功案例 。
PWA 技術目前被 Firefox,Chrome 和其他基於Blink核心的瀏覽器支援。微軟正在努力在Edge瀏覽器上實現。Apple沒有動作 although there are promising comments in the WebKit five-year plan。幸運的是,瀏覽器支援對於 PWA 似乎不太重要...
PWAs 是漸進增強的
你的app仍然可以執行在不支援 PWA 技術的瀏覽器裡。使用者不能離線訪問,不過其他功能都像原來一樣沒有影響。綜合利弊得失,沒有理由不把你的 app 改進為 PWA。
不只是 Apps
Google 引領了 PWA 的一系列動作,所以大多數教程都在說如何從零開始構建一個基於 Chrome,native-looking mobile app。然而並不是只有特殊的單頁應用可以PWA化,也不需要一定遵循 material interface design guidelines。大多數網站都可以在數小時內實現 PWA 化。這包括你的 WordPress站點或者靜態站點。
示例程式碼
示例程式碼可以在github.com/sitepoint-e…找到。
程式碼提供了一個簡單的四個頁面的網站。其中包含一些圖片,一個樣式表和一個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 總共有三個必要步驟:
第一步:開啟 HTTPS
由於一些顯而易見的原因,PWAs 需要 HTTPS 連線。
HTTPS 在示例程式碼中並不是必須的,因為 Chrome 允許使用 localhost 或者任何 127.x.x.x 的地址來測試。你也可以在 HTTP 連線下測試你的 PWA,你需要使用 Chrome ,並且輸入以下命令列引數:
--user-data-dir
--unsafety-treat-insecure-origin-as-secure
第二步:建立一個 Web App Manifest
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
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。
Install 事件
這個事件在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())
);
});複製程式碼
Activate 事件
當 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。
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
頁面。
開發者工具提供了檢視 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 的有用資訊。
PWA 陷阱
有幾點需要注意:
URL 隱藏
我們的示例程式碼隱藏了 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複製程式碼