如何將Web站點改造爲PWA?
作者|Craig Buckler
譯者|張衛濱
來源 | 前端之巔
最近圍繞漸進式 Web 應用(PWA)有很多的討論,很多人在懷疑它是不是代表了(移動)Web 的未來。我不會捲入原生應用與 PWA 之間的爭論,但有一點是毋庸置疑的:它們對改善移動和增強用戶體驗大有助益。
移動 Web 訪問將會將會超過其他設備的總和,面對這種趨勢,你能視若無睹嗎?
好消息是實現 PWA 並不困難。實際上,將現有的 Web 站點轉換爲 PWA 是非常具有可行性的。在本教程中,我們討論這一話題,在本文結束的時候,我們將會有一個行爲與原生 Web 應用一致的站點。它能夠離線運行並且具有自己的主頁屏幕圖標。
什麼是漸進式 Web 應用
漸進式 Web 應用(Progressive Web Apps,也被稱爲 PWAs)是 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(https://github.com/sitepoint-editors/pwa-retrofit) 獲取。
它提供了一個簡單的、四頁的 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 並導航至 。
你可以利用瀏覽器菜單中的“Add to Home screen”。多次訪問之後,瀏覽器會提示你進行“Install”。這兩種方式都能在你的主頁上創建一個新的圖標(icon)。訪問幾個頁面之後,關閉 Chrome 並斷開設備的連接。然後你可以啓動該 PWA Website 應用。此時,將會看到一個閃屏界面,儘管沒有連接服務器,依然能夠訪問之前閱讀過的頁面。
要將你的 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"
}
]
}
對該文件的引用要放到所有頁面的
主要的清單屬性是:
- 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 應用清單屬性的完整列表 (https://developer.mozilla.org/en-US/docs/Web/Manifest)。
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(
'',
{ 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(https://jakearchibald.com/2016/caching-best-practices/).
相關鏈接
如果你想要理解漸進式 Web 應用的更多知識的話,可以參考如下有用的資源:
1.PWA.rocks 樣例應用:
https://pwa.rocks/
2.Progressive Web Apps:
https://developers.google.com/web/progressive-web-apps/
3 . 第一個 PWA:
https://codelabs.developers.google.com/codelabs/your-first-pwapp/
4.Mozilla Service Worker Cookbook:
https://serviceworke.rs/
5.MDN Using Service Workers:
https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers
6 . 原文鏈接:
https://www.sitepoint.com/retrofit-your-website-as-a-progressive-web-app/