【PWA學習與實踐】(9)生產環境中PWA實踐的問題與解決方案

AlienZHOU發表於2019-03-03

《PWA學習與實踐》系列文章已整理至gitbook - PWA學習手冊,文字內容已同步至learning-pwa-ebook。轉載請註明作者與出處。

本文是《PWA學習與實踐》系列的第九篇文章。

PWA作為時下最火熱的技術概念之一,對提升Web應用的安全、效能和體驗有著很大的意義,非常值得我們去了解與學習。對PWA感興趣的朋友歡迎關注《PWA學習與實踐》系列文章。

引言

在前八篇文章中,我已經介紹了一些PWA中的常見技術與使用方式。雖然我們已經學習了很多相關知識,但是,還是有很多問題在實踐時才會暴露出來。這篇文章是一篇TroubleShooting,總結了我近期在PWA實踐過程中遇到了一些問題,以及這些問題的解決方案。希望能幫助一些遇到類似問題的朋友。

1. Service Worker Scope

注意Service Worker註冊時的作用範圍(scope)

1.1. 遇到的問題

我在頁面/home下注冊了Service Worker:

navigator.serviceWorker.register('/static/home/js/sw.js')
複製程式碼

通過在.then()中呼叫console.log()可以發現Service Worker其實註冊成功了,但是在頁面中卻不生效。這是為什麼呢?

1.2. 產生的原因

我在前幾篇介紹Service Worker的文章中沒有過多強調Scope的概念:

scope: A USVString representing a URL that defines a service worker's registration scope; what range of URLs a service worker can control. This is usually a relative URL. The default value is the URL you'd get if you resolved './' using the service worker script's location as the base.

Scope規定了Service Worker的作用(URL)範圍。例如,一個註冊在https://www.sample.com/list路徑下的Service Worker,其作用的範圍只能是它本身與它的子路徑:

  • https://www.sample.com/list
  • https://www.sample.com/list/book
  • https://www.sample.com/list/book/comic

而在https://www.sample.comhttps://www.sample.com/book這些路徑下則是無效的。

同時,scope的預設值為./(注意,這裡所有的相對路徑不是相對於頁面,而是相對於sw.js指令碼的)。因此,navigator.serviceWorker.register('/static/home/js/sw.js')程式碼中的scope實際上是/static/home/js,Service Worker也就註冊在了/static/home/js路徑下,顯然無法在/home下生效。

這種情況非常常見:我們會把sw.js這樣的檔案放置在專案的靜態目錄下(例如文中的/static/home/js),而並非頁面路徑下。顯然,要解決這個問題需要設定相應的scope。

然而,另一個問題出現了。如果你直接將scope設定為/home

navigator.serviceWorker.register('/static/home/js/sw.js', {scope: '/home'})
複製程式碼

在chrome控制檯會看到如下的錯誤提示:

Uncaught (in promise) DOMException: Failed to register a ServiceWorker: The path of the provided scope ('/home') is not under the max scope allowed ('/static/home/js/'). 
Adjust the scope, move the Service Worker script, or use the Service-Worker-Allowed HTTP header to allow the scope.
複製程式碼

StackOverflow上對此的解釋是:

Service workers can only intercept requests originating in the scope of the current directory that the service worker script is located in and its subdirectories.

簡單來說,Service Worker只允許註冊在Service Worker指令碼所處的路徑及其子路徑下。顯然,我上面的程式碼觸碰到了這個規則。那怎麼辦呢?

1.3. 解決方案

解決這個問題的方式主要有兩種。

方法一:修改路由,讓sw.js的訪問路徑處於合適的位置

router.get('/sw.js', function (req, res) {
    res.sendFile(path.join(__dirname, '../../static/kspay-home/static/js/sw/', 'sw.js'));
});
複製程式碼

以上是一個express中簡單的路由。通過路由設定,我們將Service Worker指令碼路徑置於根目錄下,這樣就可以設定scope為/home而不會違反其規則了:

navigator.serviceWorker
    .register('/static/home/js/sw.js', {
        scope: '/home'
    })
複製程式碼

方法二:新增Service-Worker-Allowed響應頭

scope的規範有時候過於嚴格了。因此,瀏覽器也提供了一種方式來使我們可以越過這種限制。方法就是設定Service-Worker-Allowed響應頭。

以express中的靜態服務中介軟體serve-static為例,進行相應配置

options: {
    maxAge: 0,
    setHeaders: function (res, path, stat) {
        // 新增Service-Worker-Allowed,擴充套件service worker的scope
        if (/\/sw\/.+\.js/.test(path)) {
            res.set({
                'Content-Type': 'application/javascript',
                'Service-Worker-Allowed': '/home'
            });
        }
    }
}
複製程式碼

2. CORS

跨域資源的快取報錯

2.1. 遇到的問題

《【PWA學習與實踐】(3) 讓你的WebApp離線可用》中我介紹瞭如何用Service Worker進行快取以實現離線功能。其中,為了提高體驗,我們會在Service Worker安裝時快取靜態檔案,實現這一功能的部分程式碼如下:

// 監聽install事件,安裝完成後,進行檔案快取
self.addEventListener('install', e => {
    var cacheOpenPromise = caches.open(cacheName).then(function (cache) {
        return cache.addAll(cacheFiles);
    });
    e.waitUntil(cacheOpenPromise);
});
複製程式碼

cacheFiles就是需要快取的靜態檔案列表。然而Service Worker執行後,在application tab中發現cacheFiles的靜態資源並未被快取下來。

【PWA學習與實踐】(9)生產環境中PWA實踐的問題與解決方案

2.2. 產生的原因

切換到Console可以看到類似如下的報錯資訊:

【PWA學習與實踐】(9)生產環境中PWA實踐的問題與解決方案

前端同學對這個問題非常熟悉:跨域問題

為了使我們的頁面能夠順利載入CDN等外站資源,瀏覽器在scriptlinkimg等標籤上放鬆了跨域限制。這使得我們在頁面中通過script標籤來載入javascript指令碼是不會導致跨域問題的(經典的jsonp就是以此為基礎實現的)。

然而在Service Worker中使用cache.addAll()則會通過類似fetch請求的方式來獲取資源(類似在頁面中使用XHR請求外站指令碼),是會受到跨域資源策略限制而無法快取到本地的。

在實際生產環境中,為了縮短請求的響應時間與、減輕伺服器壓力,通常我們都會將javascript、css、image這些靜態資源通過CDN進行分發,或者將其放置在一些獨立的靜態服務叢集中。所以線上的靜態資源基本都是“跨站資源”。

2.3. 解決方案

該問題其實不算是Service Worker中的特定問題,解決方式和處理一般的跨域問題類似,可以設定Access-Control-Allow-Origin響應頭來解決。

  • 如果使用CDN,可以在CDN服務中進行配置。一般的CDN服務是會支援配置HTTP響應頭的;
  • 如果使用自己搭建的靜態伺服器叢集,可以對伺服器進行相應配置。這裡有一個倉庫包含ngix、apache、iis等常用伺服器的配置,可以參考。

3. iOS standalone 模式

iOS standalone模式下的特殊處理

3.1. 遇到的問題

今年年初Apple宣佈在iOS safari 11.3中支援Service Worker,這對PWA的推廣起到了重要的作用,讓我們可以“跨平臺”來實現PWA技術。

雖然,iOS safari不支援manifest配置來實現新增到桌面,但是我在《【PWA學習與實踐】(2) 使用Manifest,讓你的WebApp更“Native”》中介紹瞭如何用safari自有的meta標籤來實現standalone模式。

不過,問題就出在了standalone模式上。拋開iOS safari standalone模式現有的一些其他小bug(包括狀態列的顯示、白屏、重複新增等),iOS safari standalone模式有一個無法迴避的重大問題。其源於iOS與android的一個重要區別:

iOS沒有後退鍵,而一般android機都有。

在iOS上使用standalone模式新增的應用,由於沒有瀏覽器的工具欄,所以無法進行後退。例如我開啟首頁,然後點選首頁課程列表中的一門課程後,瀏覽器跳轉到課程頁,由於iOS沒有後退鍵,所以你無法再回到首頁,除非殺死“應用”重新啟動。

3.2. 產生的原因

正如上面所提到的,由於iOS沒有後退鍵,而standalone模式會隱藏瀏覽器工具條和導航條,因此,在iOS中使用儲存到桌面的WebApp,就像是一次不能回頭的旅行……

3.3. 解決方案

顯然,這種體驗是無法接受的。目前我採用的解決方案非常簡單,在開啟頁面時進行判斷,如果是iOS中的standalone模式,則在頁面右上角顯示一個“返回”小圖示。點選圖示返回上一個頁面。

iOS中有一個專門的屬性來判斷是否為standalone模式:

if ('standalone' in window.navigator && window.navigator.standalone) {
    // standalone模式進行特殊處理,例如展示返回按鈕
    backBtn.show();
}
複製程式碼

使用history API即可實現按鈕的後退功能:

backBtn.addEventListener('click', function () {
    window.history.back();
});
複製程式碼

4. 圖片策略

解決PWA離線資源中非快取圖片資源的展示

4.1. 遇到的問題

在實際使用中,為了滿足一定的離線功能,我快取了一些變化頻率極小的API資料,例如個人中心裡的列表資訊。而列表中包含了較多的圖片。為了節省了使用者的儲存空間,對於圖片資源我並未選擇快取。

這導致了一個問題:離線情況下,雖然使用者能正常看到列表資訊,但是其中的圖片部分都是類似下面這種“圖裂了”的情況,體驗不太好。

【PWA學習與實踐】(9)生產環境中PWA實踐的問題與解決方案

4.2. 產生的原因

原因上面已經解釋了,離線狀態下無法請求到圖片資源,所以在一些瀏覽器中就會表現出這種“圖掛了”的狀態。

4.3. 解決方案

解決這個體驗問題的大致思路如下:

  1. 首先,需要在本地快取佔點陣圖資源
  2. 其次,在獲取圖片時判斷是否出現錯誤
  3. 最後,在錯誤時使用佔點陣圖進行替換

由於只是快取佔點陣圖,而佔點陣圖一般較為固定,只會有有限的幾種尺寸樣式,因此不會產生太多快取空間的佔用。佔點陣圖的快取完全可以在快取靜態資源時一起進行。

而圖片獲取出錯(可能是網路原因,也可能是URL錯誤)時,進行佔點陣圖的替換有兩種簡單的方式:

方法一:在fetch事件中監聽圖片資源,出錯時使用佔點陣圖

self.addEventListener('fetch', e => {
    if (/\.png|jpeg|jpg|gif/i.test(e.request.url)) {
        e.respondWith(
            fetch(e.request).then(response => {
                return response;
            }).catch(err => {
                // 請求錯誤時使用佔點陣圖
                return caches.match(placeholderPic).then(cache => cache);
            })
        );
        return;
    }
複製程式碼

方法二:通過img標籤的onerror屬性來請求佔點陣圖

先將img標籤改為

<img class="list-cover"
    src="//your.sample.com/1234.png"
    alt="{{ item.desc }}"
    onerror="javascript:this.src='https://your.sample.com/placeholder.png'"/>   
複製程式碼

onerror屬性中指定的方法會在圖片載入錯誤時替換src;同時我們將Service Worker中的程式碼進行調整:

self.addEventListener('fetch', e => {
    if (/\.png|jpeg|jpg|gif/i.test(e.request.url)) {
        e.respondWith(
            fetch(e.request).then(response => {
                return response;
            // 觸發onerror後,img會再次請求圖片placeholder.png
            // 由於無網路連線,此fetch依然會出錯
            }).catch(err => {
                // 由於我們事先快取了placeholder.png,這裡會返回快取結果
                return caches.match(e.request).then(cache => cache);
            })
        );
        return;
    }
複製程式碼

5. 寫在最後

本文總結了一些我在進行PWA升級實踐中遇到的問題,希望對遇到類似問題的朋友能夠有一些啟發或幫助。

在下一篇文章中,我會回到PWA相關技術,介紹Resource Hint,以及如何使用Resource Hint來提高頁面的載入效能,提升使用者體驗。

《PWA學習與實踐》系列

參考資料

Service Worker Scope

CORS

iOS standalone

相關文章