[貝聊科技]PWA初探

HTML 5 曾被認爲是移動應用的明天,卻被原生App在性能和功能上輕易打敗,Web逐漸成爲App的附屬。然而,馬雲「爸爸」告訴咱們:「夢想仍是要有的,萬一實現了呢?」現在,咱們離夢想又近了一步。javascript

PWA,全稱「Progressive Web App」,是Google提出的爲Web提供App般使用體驗的一系列技術方案。它優點主要體如今:css

  • 可在離線網絡較差的環境下正常打開頁面。
  • 安全(HTTPS)。
  • 保持最新(及時更新)。
  • 支持安裝(添加到主屏幕)和消息推送
  • 向下兼容,在不支持相關技術的瀏覽器中仍可正常訪問。

本文將逐一講述PWA涉及的主要技術方案。html

CacheStorage

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

Service worker

隨着Web承載的任務變得愈來愈複雜,瀏覽器也爲JavaScript提供了多線程能力——Web worker。Web worker容許一段JavaScript程序運行在主線程以外的另一個線程中。可是基於線程安全的考慮:json

  • 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生命週期圖

性能對比

實現了增量緩存以後,至關於頁面只要打開過一次就能夠離線瀏覽了。下面對兩種緩存方案(Service worker + CacheStorage、HTTP緩存)作性能對比。首先是正常網速下的對比:

正常網速下的HTTP緩存

正常網速下的 Service worker + CacheStorage

能夠發現,沒有太大的區別。其實這也很好理解,被緩存的資源,不管是CacheStorage仍是HTTP緩存,本質上要麼存在磁盤、要麼已經被瀏覽器調入內存,既然來源是同樣的,讀取的速度天然也大體相同。

下面再看一下慢速3G網絡下的狀況:

慢速3G下的HTTP緩存

慢速3G下的 Service worker + CacheStorage

能夠發現,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橫幅」)。而點擊主屏幕圖標進入應用後,會先出現一個啓動屏(注意:配置了512x512以上尺寸的圖標纔會顯示到此),而後才進入到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瀏覽器兼容表

可見:

  • 對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…

相關文章
相關標籤/搜索