[貝聊科技]PWA初探

貝聊科技發表於2019-02-28

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

效能對比

實現了增量快取之後,相當於頁面只要開啟過一次就可以離線瀏覽了。下面對兩種快取方案(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橫幅」)。而點選主螢幕圖示進入應用後,會先出現一個啟動屏(注意:配置了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瀏覽器相容表

可見:

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

相關文章