HTML 5 曾被認爲是移動應用的明天,卻被原生App在性能和功能上輕易打敗,Web逐漸成爲App的附屬。然而,馬雲「爸爸」告訴咱們:「夢想仍是要有的,萬一實現了呢?」現在,咱們離夢想又近了一步。javascript
PWA,全稱「Progressive Web App」,是Google提出的爲Web提供App般使用體驗的一系列技術方案。它優點主要體如今:css
本文將逐一講述PWA涉及的主要技術方案。html
CacheStorage是一種新的本地存儲,它的存儲結構是這樣的:前端
每一個域有若干個存儲模塊,每一個模塊內能夠存儲若干個鍵值對。 它的鍵是網絡請求(Request),值是請求對應的響應(Response)。 CacheStorage的接口集中在全局變量「caches」中,且僅在HTTPS協議(或localhost:*域)下可用,調用前要檢查兼容性。如下是一段實現加載資源並寫入緩存的代碼示例:java
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。jquery
除了寫入緩存,天然還有匹配緩存和刪除緩存的接口:nginx
// 在全部存儲模塊中匹配資源
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 一塊兒使用。web
隨着Web承載的任務變得愈來愈複雜,瀏覽器也爲JavaScript提供了多線程能力——Web worker。Web worker容許一段JavaScript程序運行在主線程以外的另一個線程中。可是基於線程安全的考慮:json
Service worker也是一種Web Worker,只是它的能力比通常的Web worker要強大得多,這主要體如今:後端
一個新的 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事件:
至此,劫持原請求定向到另外一個請求的功能就完成了。
咱們還能夠在 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;
});
}
});
);
});
複製代碼
在實際應用的時候,還須要排除一些特殊請求:
代碼實現以下:
// 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),讓瀏覽器將其緩存起來,這個差別是否就不存在呢?實際狀況沒有這麼簡單:
因此,通常不會給HTML文檔設置緩存時間,或者只設一個很短的緩存時間。然而,HTML文檔做爲頁面的入口,緩存下來的意義是很是大的。自從了有了 Service worker ,能夠作到:
攔截HTML文檔的請求,檢查 CacheStorage 後再決定是否請求服務器; 經過修改 Service worker 腳本及時清理緩存。 此外,前端渲染模式能夠實現一個HTML文檔對應多份同類內容;基於Vue.js、React、Angular等框架開發的單頁應用甚至只有一個HTML文檔。
綜上所述,在前端渲染模式下經過 Service worker 和 CacheStorage 緩存HTML文檔,能夠有效提升網絡不穩定時頁面的加載速度。而由於靜態資源自己有HTTP緩存,因此沒必要在 CacheStorage 中緩存全部靜態資源(只緩存關鍵的部分)。
最後咱們必須搞清楚一個問題: Service worker + CacheStorage 的緩存機制與 HTTP緩存 實際上是比較類似的,爲何須要兩種類似的緩存?
順帶一提, HTML 5 中的 Application Cache (離線緩存)由於實際應用的時候靈活性不足,已再也不建議使用,該標準也已經被廢棄。
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());
}
})
);
});
複製代碼
須要特別提一下的是:
最後,在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());
}
}
])
複製代碼
這一節介紹的是一個簡單的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"
}]
}
複製代碼
比較關鍵的幾個配置項包括:
編寫好這樣一個配置文件以後,還須要經過link標籤在HTML文檔中引用它:
<link rel="manifest" href="/manifest.json" />
複製代碼
在此基礎上,若是還符合如下條件:
使用Chrome瀏覽器打開頁面後就會彈出「添加到主屏幕」的橫幅(下文簡稱爲「A2HS橫幅」)。而點擊主屏幕圖標進入應用後,會先出現一個啓動屏(注意:配置了512x512以上尺寸的圖標纔會顯示到此),而後才進入到App的啓動頁。
支持A2HS橫幅的瀏覽器有Chrome、UC瀏覽器、小米瀏覽器,均在Android平臺下。對於其餘瀏覽器而言,只能手動找到功能菜單或按鈕,再添加到主屏幕。
最後再說一下Manifest文件的一些問題:
<link rel="apple-touch-icon" sizes="192x192" href="..." />
複製代碼
PWA的現狀能夠用這麼一句經典的話來歸納:
前途是光明的,道路是曲折的
先看一張兼容性方面的圖:
可見:
此外,iOS Safari從iOS 11.3起支持PWA大部分特性,但存在較嚴重的體驗問題——每次離開PWA都會丟失上下文。
綜上所述,目前對大部分企業來講,作一個完整的PWA應用並非明智的選擇。然而,經過支持度較高的 Service worker 和 CacheStorage 改善用戶體驗,倒是頗有意義的。另外一方面,雖然Web跟原生App存在競爭關係,但更多狀況下,它們是相互合做的——大部分App都內嵌了網頁去實現部分功能。因此,能夠考慮在App的WebView中支持上述技術,爲Web提供支援。
本文同時發佈於做者我的博客: mrluo.life/article/det… 。