HTML 5 曾被認為是移動應用的明天,卻被原生App在效能和功能上輕易戰勝,Web逐漸成為App的附屬。然而,馬雲“爸爸”告訴我們:“夢想還是要有的,萬一實現了呢?”如今,我們離夢想又近了一步。
PWA,全稱「Progressive Web App」,是Google提出的為Web提供App般使用體驗的一系列技術方案。它優勢主要體現在:
- 可在離線或網路較差的環境下正常開啟頁面。
- 安全(HTTPS)。
- 保持最新(及時更新)。
- 支援安裝(新增到主螢幕)和訊息推送。
- 向下相容,在不支援相關技術的瀏覽器中仍可正常訪問。
本文將逐一講述PWA涉及的主要技術方案。
CacheStorage
CacheStorage是一種新的本地儲存,它的儲存結構是這樣的:
每個域有若干個儲存模組,每個模組內可以儲存若干個鍵值對。
它的鍵是網路請求(Request),值是請求對應的響應(Response)。
CacheStorage的介面集中在全域性變數「caches」中,且僅在HTTPS協議(或localhost:*域)下可用,呼叫前要檢查相容性。以下是一段實現載入資源並寫入快取的程式碼示例:
if (typeof `caches` !== `undefined`) {
// 要快取資源的URL
const URL = `https://s3.imgbeiliao.com/assets/imgs/icons/app/1.0.0/parent-192x192.png`;
// 儲存模組名
const CACHE_KEY = `v1`;
fetch(URL, {
mode: `no-cors`
}).then((response) => {
// 開啟儲存模組後往裡面新增快取
caches.open(CACHE_KEY).then((cache) => {
cache.put(url, response);
});
});
}
複製程式碼
其中用到了 Fetch API 去請求資源,這個API的目標是取代XMLHttpRequest。
除了寫入快取,自然還有匹配快取和刪除快取的介面:
// 在所有儲存模組中匹配資源
caches.match(URL).then((response) => {
console.log(response);
});
// 在單個儲存模組中匹配資源
caches.open(CACHE_KEY).then((cache) => {
cache.match(URL).then((response) => {
console.log(response);
});
});
複製程式碼
// 刪除整個儲存模組
caches.delete(CACHE_KEY).then((flag) => {
console.log(flag);
});
// 刪除儲存模組中的某個儲存項
caches.open(CACHE_KEY).then((cache) => {
if (cache) {
cache.delete(url).then((flag) => {
console.log(flag)
});
}
});
複製程式碼
雖然可以獨立呼叫,但 CacheStorage 一般會搭配下文所說的 Service worker 一起使用。
Service worker
隨著Web承載的任務變得越來越複雜,瀏覽器也為JavaScript提供了多執行緒能力——Web worker。Web worker允許一段JavaScript程式執行在主執行緒之外的另外一個執行緒中。但是基於執行緒安全的考慮:
- Worker執行緒不能操作主執行緒的某些物件(如DOM)。
- Worker執行緒與主執行緒不共享資料,只能通過訊息機制(postMessage)傳遞資料。
Service worker也是一種Web Worker,只是它的能力比一般的Web worker要強大得多,這主要體現在:
- 一旦被安裝,就永遠存在,除非登出;
- 用到的時候喚醒,閒置的時候睡眠;
- 可以作為代理攔截請求和響應;
- 離線狀態下也可用。
- 能力越大,責任也越大,所以 Service worker 僅在HTTPS協議(或localhost:*域)下可用。
註冊
一個新的 Service worker 要經過註冊、安裝、啟用這三個步驟,才可以對頁面生效。第一步是把指令碼檔案註冊為 Service worker :
function setupSW() {
var serviceWorker = window.navigator.serviceWorker;
if (!serviceWorker || typeof fetch !== `function`) {
return;
}
serviceWorker.register(`/sw.js`).then(function(reg) {
console.info(`[SW]: Registered at scope "` + reg.scope + `"`);
});
}
window.addEventListener(`load`, setupSW, false);
複製程式碼
註冊操作的實質是新開執行緒,有一定的開銷(從註冊到啟用,實測iOS Safari和Chrome耗時70~100ms,UC瀏覽器和QQ瀏覽器的耗時都在200ms以上,均為內網測試結果,實際環境中還要算上sw.js的網路開銷),所以最好是在頁面載入完之後執行。
註冊、安裝、啟用都完成之後, Service worker 就可以對作用域內的頁面生效。這裡說的作用域並不是變數的作用域,而是指 Service worker 指令碼所在的目錄。預設情況下, Service worker 可以作用於其指令碼所在目錄及其子目錄下的所有頁面。例如以「/a/sw.js」註冊的Service worker可以作用於「/a/page1.html」、「/a/b/page2.html」,但無法作用於「/index.html」。不過,也可以通過引數指定作用域,比如:
serviceWorker.register(`/a/sw.js`, {
scope: `/`
});
複製程式碼
然而,這段程式碼執行的時候會出現異常:
Failed to register a ServiceWorker: The path of the provided scope (`/`) is not under the max scope allowed (`/a/`). Adjust the scope, move the Service Worker script, or use the Service-Worker-Allowed HTTP header to allow the scope.
原因就是,預設情況下作用域只能降低而不能提升。如果非得提升,就要給指令碼檔案增加一個HTTP響應頭「Service-Worker-Allowed」。例如:
server {
location /a/sw.js {
add_header `Service-Worker-Allowed` `/`;
}
}
複製程式碼
此外, Service worker 指令碼還必須與頁面同域。為了避免作用域帶來的麻煩,建議把該指令碼檔案放置於頁面所在域的根目錄下。
順帶一提,在實際應用中,建議給 Service worker 增加開關。因為它畢竟屬於新特性,還不知道會不會有未知的坑,一旦出現大規模故障,需要有一種快速的方式讓其失效。示例程式碼如下:
fetch(`/sw-enable?` + Date.now()).then(
// 200狀態為開,其他狀態為關
function(res) { return res.status === 200 ? 1 : -1; },
// 請求失敗時不做任何操作
function() { return 0; }
).then(function(flag) {
if (flag === 1) {
serviceWorker.register(`/sw.js`);
} else if (flag === -1) {
serviceWorker.getRegistration(`/sw.js`).then(function(reg) {
if (reg) { reg.unregister(); }
});
}
});
複製程式碼
需要特別注意的是,如果處於關閉狀態,一定要登出 Service worker 。否則對於已註冊 Service worker 的客戶端而言,該worker還是存在的。
代理
Service worker 啟用後就會成為頁面跟瀏覽器之間的代理。它作用域內所有頁面的所有HTTP請求(除了它自身)都會觸發它的fetch事件。下面以WebP的相容處理為例,說明 Service worker 的代理作用。
WebP是Google釋出的圖片檔案格式。與JPG、PNG等格式相比,在質量相同的前提下,WebP格式的檔案往往會更小。然而,微軟和蘋果尚未在自家瀏覽器中支援這種格式,所以在實際應用中需要處理相容問題。
過往做相容處理的方式,主要是檢查相容性後動態輸出圖片路徑。但是這種方式需要在所有輸出圖片的地方做額外處理,並且對SEO不友好。而 Service worker 則可以通過攔截原圖片(PNG、JPG)的請求並將其“修改”為對應的WebP請求。
// sw.js
self.addEventListener(`fetch`, (e) => {
// accept: image/webp,image/apng,image/*,*/*;q=0.8
const headers = e.request.headers;
const supportsWebP = headers.has(`accept`) && headers.get(`accept`).includes(`webp`);
const url = new URL(e.request.url);
if (supportsWebP && url.host.includes(`qiniu`)) {
url.search = `?imageMogr2/format/webp`;
e.respondWith(
fetch(url.toString(), { mode: `no-cors` })
);
}
});
複製程式碼
以上程式碼通過監聽fetch事件:
- 檢測瀏覽器對WebP的支援(支援WebP的瀏覽器,在accept這個請求頭中,都會帶有「image/webp」);
- 倘若瀏覽器支援WebP,且圖片的儲存空間也支援WebP轉換,則生成對應的WebP請求的URL,並通過 Fetch API 進行請求;
- 通過事件物件的「respondWith」方法,使用 Fetch API 的響應作為本次請求的響應。
至此,劫持原請求定向到另一個請求的功能就完成了。
與CacheStorage互動
我們還可以在 Service worker 指令碼中與 CacheStorage 進行互動,實現資源的快取和提取。
第一種快取策略是預快取。它的原理是在 Service worker 的安裝事件中快取一部分資源,並且在這些資源快取成功之後再完成安裝。
// sw.js
const CACHE_KEY = `v1`;
const cacheList = [
`/js/jquery.js`,
`/style/reset.css`
];
self.addEventListener(`install`, (e) => {
e.waitUntil(
caches.open(CACHE_KEY).then((cache) => {
return cache.addAll(cacheList);
});
);
});
複製程式碼
這種策略的好處是:只要 Service worker 安裝成功,就可以確保快取可用(排除儲存空間不足等因素)。然而,它的缺點也不可忽視:只要有一個預快取的資源請求失敗,就會導致 Service worker 安裝失敗。因此,預快取的資源越少越好。
預快取成功後,就可以在fetch事件中匹配快取裡面的資源進行響應:
// sw.js
self.addEventListener(`fetch`, (e) => {
e.respondWith(
caches.match(e.request).then((response) => {
if (response != null) {
return response;
} else {
return fetch(e.request.url);
}
})
);
});
複製程式碼
第二種快取策略是增量快取,流程很簡單:如果在快取中匹配到請求的資源,則直接響應;否則傳送請求,並把資源快取下來後再響應。需要注意的是,不要去快取異常狀態(如HTTP狀態碼為404或500)的資源。程式碼實現如下:
// sw.js
self.addEventListener(`fetch`, (e) => {
e.respondWith(
caches.match(e.request).then((res) => {
if (res != null) {
return res;
} else {
return fetch(url).then((res) => {
if (res && (res.status === 200 || res.status === 304)) {
const resCache = res.clone();
caches.open(CACHE_KEY).then((cache) => {
cache.put(url, resCache);
});
}
return res;
});
}
});
);
});
複製程式碼
在實際應用的時候,還需要排除一些特殊請求:
- 瀏覽器允許在HTTPS協議的頁面中通過HTML標籤載入HTTP協議的圖片、視訊等資源。但是, Fetch API 不允許這麼做。所以,不要用 Fetch API 傳送HTTP協議的請求。
- 第三方資源的請求不應快取,如各種統計平臺的資源。
- 非GET請求不應快取,因為它們大部分涉及提交資料到後端並讓其執行某些操作。
- Service worker 的開關介面不應快取。
程式碼實現如下:
// sw.js
self.addEventListener(`fetch`, (e) => {
let url = new URL(e.request.url);
if (url.protocol === `http:` ||
(url.host !== location.host && url.host.includes(`.abc-cdn.com`)) ||
e.request.method !== `GET` ||
url.pathname.indexOf(`sw-enable`) !== -1
) {
return;
}
url = url.toString();
e.respondWith(
// ...
);
});
複製程式碼
更新
只要瀏覽器檢查到 Service worker 指令碼檔案的內容有變化,就會安裝新的 Service worker 。但是,在預設情況下,新的 Service worker 處於等待狀態,得關閉所有跟舊 Service worker 有關聯的頁面,再重新開啟,新的 Service worker 才會被啟用。如果想新的 Service worker 馬上生效,可以在安裝事件中呼叫「self.skipWaiting」:
// sw.js
self.addEventListener(`install`, (e) => {
e.waitUntil(
caches.open(CACHE_KEY).then((cache) => {
return cache.addAll(cacheList);
}).then(() => {
return self.skipWaiting();
})
);
});
複製程式碼
需要特別注意的是, Service worker 指令碼檔案要設定為永不快取(max-age: 0)。否則,即使它的內容有變化,瀏覽器也無法得知,也就無法更新了。事實上,瀏覽器也考慮到了快取的情況,為了避免不良指令碼長時間生效,Service worker指令碼每24小時一定會被下載一次。
講到這,其實只實現了 Service worker 自身的更新,但如何進一步更新 CacheStorage 中的資源快取呢?前文有提及, CacheStorage 是按模組儲存的,利用這個儲存結構,就可以實現每釋出一次程式碼就更換一個儲存模組。由於新的儲存模組內是空的,根據增量快取的機制,瀏覽器會通過網路或者HTTP快取獲取這個資源。程式碼如下:
// sw.js
const CACHE_KEY = `v2`; // 下次釋出時改成v3
caches.keys().then(function(keys) {
keys.forEach(function(key) {
if (key !== CACHE_KEY) {
caches.delete(key);
}
});
});
複製程式碼
生命週期
講到這,其實已經接觸到 Service worker 生命週期中的絕大部分環節,下面通過一張生命週期圖進行歸納:
效能對比
實現了增量快取之後,相當於頁面只要開啟過一次就可以離線瀏覽了。下面對兩種快取方案(Service worker + CacheStorage、HTTP快取)做效能對比。首先是正常網速下的對比:
可以發現,沒有太大的區別。其實這也很好理解,被快取的資源,無論是CacheStorage還是HTTP快取,本質上要麼存在磁碟、要麼已經被瀏覽器調入記憶體,既然來源是一樣的,讀取的速度自然也大致相同。
下面再看一下慢速3G網路下的情況:
可以發現,HTML文件的請求速度有較大差異。在 Service worker + CacheStorage 方案中,HTML文件已經被快取下來了;而在HTTP快取方案中,HTML文件的狀態碼為304,說明瀏覽器向伺服器發出了請求。而這一次HTTP請求在網路較慢的情況下耗時較長。
如果給HTML文件設定過期時間(max-age),讓瀏覽器將其快取起來,這個差異是否就不存在呢?實際情況沒有這麼簡單:
- 即使設定了過期時間,某些瀏覽器仍然會請求伺服器,例如PC和Android平臺的Chrome。
- 沒有好的辦法可以在程式碼變更時告知瀏覽器清除快取。
- 傳統後端渲染的應用中,HTML文件數量太多(例如網易的每篇新聞都是一個HTML文件),全部快取下來會佔用大量儲存空間。
所以,一般不會給HTML文件設定快取時間,或者只設一個很短的快取時間。然而,HTML文件作為頁面的入口,快取下來的意義是非常大的。自從了有了 Service worker ,可以做到:
攔截HTML文件的請求,檢查 CacheStorage 後再決定是否請求伺服器;
通過修改 Service worker 指令碼及時清理快取。
此外,前端渲染模式可以實現一個HTML文件對應多份同類內容;基於Vue.js、React、Angular等框架開發的單頁應用甚至只有一個HTML文件。
綜上所述,在前端渲染模式下通過 Service worker 和 CacheStorage 快取HTML文件,可以有效提高網路不穩定時頁面的載入速度。而因為靜態資源本身有HTTP快取,所以不必在 CacheStorage 中快取所有靜態資源(只快取關鍵的部分)。
小結
最後我們必須搞清楚一個問題: Service worker + CacheStorage 的快取機制與 HTTP快取 其實是比較相似的,為什麼需要兩種相似的快取?
- 其一,HTTP快取則是由伺服器(響應頭)控制的,且快取過期前,伺服器無法通知瀏覽器清理快取;
- 其二, Service worker 可以在瀏覽器端實現對快取的有效控制,包括快取策略與快取清理;
- 其三, Service worker 支援離線執行,在離線或網路不好的情況下可以快速響應,這一點對訊號不穩定的行動網路來說尤其重要。
順帶一提, HTML 5 中的 Application Cache (離線快取)因為實際應用的時候靈活性不足,已不再建議使用,該標準也已經被廢棄。
在Vue.js專案中接入Service worker
Service worker 所帶來的好處讓我迫不及待地想將其接入到專案中,下面以一個典型的Vue.js專案為例,講一下接入過程。
第一步是註冊 Service worker 指令碼,為了儘可能在頁面元件載入完後再執行這一步,可以把這片程式碼放到Vue.js根例項(main.js)的mounted鉤子中執行:
// main.js
new Vue({
mounted() {
// 本地開發時不啟用Service worker
if ([`test`, `pre`, `prod`].indexOf(env) === -1) { return; }
const serviceWorker = window.navigator.serviceWorker;
if (!serviceWorker || typeof fetch !== `function`) { return; }
fetch(`/sw-enable?` + Date.now()).then(
(res) => { return res.status === 200 ? 1 : -1; },
() => { return 0; }
).then((flag) => {
if (flag === 1) {
serviceWorker.register(`/sw.js`);
} else if (flag === -1) {
serviceWorker.getRegistration(`/sw.js`).then((reg) => {
if (reg) { reg.unregister(); }
});
}
});
});
});
複製程式碼
Service worker 指令碼的內容跟前文提及的大致上一樣(此處只做了預快取):
// 快取模組(版本號)
const CACHE_KEY = `v$REV`;
// 要預快取的資源列表
const cacheList = [
`/index.html`,
`https://abc-cdn.com/polyfill.min.js`
];
self.addEventListener(`install`, (e) => {
e.waitUntil(
caches.keys().then((keys) => {
// 清理舊快取
keys.forEach((key) => {
if (key !== CACHE_KEY) { caches.delete(key); }
});
}).then(() => {
// 預快取
return caches.open(CACHE_KEY)
.then((cache) => { return cache.addAll(cacheList); })
}).then(() => {
// 跳過等待
return self.skipWaiting();
});
);
});
self.addEventListener(`fetch`, (e) => {
const url = new URL(e.request.url);
if (url.protocol === `http:` ||
url.pathname.includes(`sw-enable`) ||
e.request.method !== `GET` ||
(url.host !== location.host && cacheList.indexOf(e.request.url) === -1)
) {
return;
}
// 判斷是否HTML文件的請求
const isHTMLDoc = e.request.headers.has(`accept`) &&
e.request.headers.get(`accept`).includes(`text/html`) &&
(url.pathname.endsWith(`.html`) || !/.w+$/.test(url.pathname));
// 基於Vue.js的單頁應用只有一個HTML文件,所有HTML文件的請求可以全部指向一個檔案
const request = isHTMLDoc ? new Request(`/index.html`) : e.request;
e.respondWith(
caches.match(request).then((res) => {
if (res != null) {
return res;
} else {
return fetch(url.toString());
}
})
);
});
複製程式碼
需要特別提一下的是:
- 「$REV」是個佔位符,要在Webpack構建流程中將其替換為具體的版本號;
預快取資源中第一項為HTML文件(單頁應用只有一個HTML文件,只快取這個就行了),第二項是關鍵的靜態資源(ES6的polyfill); - 當前域下所有HTML文件的請求其實都是指向同一個請求(index.html)。
最後,在Webpack構建流程中增加一個步驟,把 Service worker 指令碼的「$REV」替換成新版本號(時間戳),並拷貝到index.html所在路徑下(保證他們同域):
new CopyWebpackPlugin([
{
from: path.resolve(__dirname, `../src/sw.js`),
to: path.dirname(config.build.index), // index.html所在路徑
transform(content, path) {
return content.toString().replace(`$REV`, Date.now());
}
}
])
複製程式碼
Web App Manifest
這一節介紹的是一個簡單的JSON配置檔案,示例程式碼如下(manifest.json):
{
"name": "貝聊官網",
"short_name": "貝聊官網",
"start_url": "/",
"display": "standalone",
"background_color": "#fff",
"theme_color": "#fff",
"orientation": "portrait",
"description": "中國幼兒園家長工作平臺",
"icons": [{
"src": "https://s2.imgbeiliao.com/assets/imgs/icons/app/1.0.0/parent-192x192.png",
"type": "image/png",
"sizes": "192x192"
}, {
"src": "https://s2.imgbeiliao.com/assets/imgs/icons/app/1.0.0/parent-512x512.png",
"type": "image/png",
"sizes": "512x512"
}]
}
複製程式碼
比較關鍵的幾個配置項包括:
- name:應用的名字。
- short_name:應用簡稱,用於在空間不那麼充足的位置顯示,如桌面圖示。
- start_url:啟動頁路徑。
- display:顯示模式,一共有四種,分別是fullscreen(佔全屏)、standalone(佔狀態列以外的空間)、minimal-ui(有瀏覽器的導航選單)、browser(使用瀏覽器開啟)。
- icons:指定各種尺寸的圖示。
編寫好這樣一個配置檔案之後,還需要通過link標籤在HTML文件中引用它:
<link rel="manifest" href="/manifest.json" />
複製程式碼
在此基礎上,如果還符合以下條件:
- Manifest檔案配置了以下專案:
- short_name;
- name;
- start_url;
- 192×192的png圖示。
- 頁面使用HTTPS協議,且註冊了Service worker。
- 被訪問至少兩次,且兩次訪問至少間隔五分鐘。
使用Chrome瀏覽器開啟頁面後就會彈出「新增到主螢幕」的橫幅(下文簡稱為「A2HS橫幅」)。而點選主螢幕圖示進入應用後,會先出現一個啟動屏(注意:配置了512×512以上尺寸的圖示才會顯示到此),然後才進入到App的啟動頁。
支援A2HS橫幅的瀏覽器有Chrome、UC瀏覽器、小米瀏覽器,均在Android平臺下。對於其他瀏覽器而言,只能手動找到功能選單或按鈕,再新增到主螢幕。
最後再說一下Manifest檔案的一些問題:
- 修改Manifest檔案後,必須重新新增到主螢幕才能生效。
- iOS下的問題:
- 啟動屏為白屏;
- 丟失上下文,每次進入應用(包括重新啟動、回到主螢幕再進入)都會回到啟動頁,這是最嚴重的問題。
- 部分配置項無效,包括background_color、theme_color、orientation、icons。其中icons可以通過標籤配置:
<link rel="apple-touch-icon" sizes="192x192" href="..." /> 複製程式碼
現狀
PWA的現狀可以用這麼一句經典的話來概括:
前途是光明的,道路是曲折的
先看一張相容性方面的圖:
可見:
- 對PWA支援最為完美的只有Chrome,但它在國內的市場佔有率不高,而且部分服務不可用。
- Service Worker 和 CacheStorage 的可用度較高;
- 推送通知的可用度較低(故而本文沒有進行介紹);
- 國內廠商的瀏覽器都沒有「新增到桌面」的功能選單;如果A2HS橫幅被關閉,就無法通過其他方式把應用新增到桌面。
此外,iOS Safari從iOS 11.3起支援PWA大部分特性,但存在較嚴重的體驗問題——每次離開PWA都會丟失上下文。
綜上所述,目前對大部分企業來說,做一個完整的PWA應用並不是明智的選擇。然而,通過支援度較高的 Service worker 和 CacheStorage 改善使用者體驗,卻是很有意義的。另一方面,雖然Web跟原生App存在競爭關係,但更多情況下,它們是相互合作的——大部分App都內嵌了網頁去實現部分功能。所以,可以考慮在App的WebView中支援上述技術,為Web提供支援。
本文同時釋出於作者個人部落格: mrluo.life/article/det… 。