【PWA】PWA入門到進階

最近在使用某款運動APP,使用過程中我發現一個很便捷的功能,就是你在跑步頁,App會提示你添加“便捷”功能至桌面,添加後桌面會有一個APP圖標,點進去它其實是一個web,然後通過web調用APP方法能直接進入APP跑步。我想了想,這難不成是PWA。由此原因,我們來看看PWA是怎麼操作的 ·>_·>

1、Service Worker 引入

PWA的核心就是Service Worker。所以,我們不得不先對它進行介紹!

谷歌Jeff曾經這麼描述它:“如果將你的網絡請求想象成飛機起飛,那麼Service Worker就是路由請求的空中交通管制員,它可以通過網絡加載,甚至通過緩存加載。”

作爲空管員,Service Worker 能讓你全權控制網站發起的每個請求,這爲許多不同的使用場景開闢了可能性。例如,空管員可以將飛機重定向到另一個機場,甚至命令飛機延遲降落。而Service Worker也能如此。

Service Worker 有幾個特點:

  • 運行在它自己的全局腳本上下文
  • 不綁定到具體的網頁
  • 無法修改網頁中的元素,因爲它無法訪問DOM
  • 只能使用HTTPS

我們用一個圖解釋Service Worker是如何工作的:

在這裏插入圖片描述
Service Worker 運行在Worker上下文中,這意味這它無法訪問DOM,它運行在Worker線程中,是完全異步,因此不會被阻塞。但是,你也因此無法使用XHR、localStorage之類的功能。

Service Worker生命週期

我們從用戶訪問頁面時,發生的解析過程闡述SW的生命週期。如下圖:
在這裏插入圖片描述
首先,用戶首次訪問URL時,服務器會返回響應的網頁。當調用register()函數時,SW開始下載。在註冊過程中,瀏覽器會下載、解析並執行SW。如果在此步驟出現錯誤,register()返回的Promise會執行reject操作,並且SW會被廢棄。

一旦SW成功執行,安裝事件就會激活。SW是基於事件驅動的,這意味着你可以進入這些事件中的任意一個。你可以通過進入不同事件來監聽任何網絡請求。

一旦完成安裝,SW就會激活,並控制在其範圍內的一切。如果生命週期中的所有事件都成功,SW就隨時可供使用。

  • 簡單理解SW生命週期——交通信號燈

你可能會覺得SW的生命週期不好記?
這樣,你可以把SW生命週期當作一組交通信號燈。在註冊過程中,SW處於紅燈狀態,因爲它還需要下載和解析。接下來,它處於黃燈狀態,因爲它正在執行,還沒有完全準備好。如果紅燈、黃燈都執行了,SW就進入到綠燈狀態,隨時可以使用

當第一次加載頁面時,SW還沒有激活,所以它不會處理任何請求。只有當它安裝和激活後,才能控制其範圍內的一切。所以,只有刷新頁面或導航到另一個頁面,SW內的邏輯纔會啓動。

SW示例

準備工作

爲了安全考慮,SW可能用於惡意用途。例如,如果有人在你的網頁上註冊一個惡意的SW,它能劫持連接並將其重定向到惡意端點。爲了避免這種情況,你只能通過HTTPS提供服務的網頁上註冊SW,確保網頁在通過網絡傳輸的過程中沒有被纂改。

但是,作爲開發者,你可以在本地localhost中測試SW並調試。

如果你要將PWA發佈到網上,你也可以先使用一些免費的HTTPS服務,例如 https://letsencrypt.org ,或者使用GitHub Pages來測試你的PWA。

下面,我們通過一個簡單的例子,帶你瞭解SW。

//index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Lato">
    //注意,在頭部添加“清單文件”。這個文件提供Web應用信息,如名稱、作者、圖標和描述。它使得瀏覽器能將web應用安裝到設備主屏幕上,以便爲用戶提供更快捷的訪問。
    <link rel="manifest"  href="/manifest.json">
</head>
<body>
    <img src="./images/hello.jpeg" alt="" width="100%">
    <script src="/js/script.js"></script>
    <script>
        if('serviceWorker' in navigator) { //檢查當前瀏覽器是否支持SW
            //註冊
            navigator.serviceWorker.register('/sw.js') //支持,則註冊一個名爲sw.js的SW文件
            .then(function(registration){ //Promise語法,Promise表示一個操作還未完成,但是期待它將來會完成
                //註冊成功
                console.log('sucess:',registration.scope);
            }).catch(err=>{
                //註冊失敗
                console.log('Error:',err);
            })
        }
    </script>
</body>
</html>

前面提到,SW是基於事件驅動的,所以我們可以通過監聽事件,執行SW操作。例如fetch事件。當一個資源發起fetch事件時,你可以決定如何繼續進行。可以將發出的HTTP請求或接收到的HTTP響應更改成任何內容。

//sw.js
self.addEventListener('fetch',function(event){ //爲fetch事件添加事件監聽器
	if(/\.jpg$/.test(event.request.url)){//檢查傳入的HTTP請求URL是否是以.jpg結尾的文件
		event.responseWith(fetch('/images/logo.jpg')); //嘗試獲取logo.jpg圖片,並用它作爲替代圖片來響應請求
	}
})

2、PWA引入

PWA並不需要我們從頭開始把項目再重新做一遍。例如,每當你發現某個新功能對用戶有益並且能提升他們體驗時,就可以試試添加這個功能。

這裏有一個工具:Lighthouse,你也可以在Chrome擴展插件上安裝它。
它能提供與Web應用相關的有用的性能分析和審覈信息。

曾有一段時間,網絡上討論PWA與原生應用之間的矛盾,形成一種對立。我認爲這應該根據用戶的需要來選擇。作爲開發者,我們應該不斷探索提升用戶體驗的方法,不應該把心思放在不休止的爭論上。

PWA架構方式

PWA其實是一個外殼。想象一下,你第一次啓動某個下載的App時,在內容加載前,你會看到一個空的UI外殼,包括了頭部和導航。

依照這樣的方式,PWA藉助SW正可以實現這樣的體驗。例如使用SW緩存應用的UI外殼。

這裏簡單對UI外殼做個解釋即用戶界面所必需的最小化HTML、CSS、Javascript。它可能會是類似網站頭部、底部和導航這樣沒有任何動態內容的部分。如果可以加載UI外殼並對其進行緩存,則可以稍後講動態內容加載到頁面中。

例如,當你第一次或重新刷新訪問GMail時,首先會顯示網站的UI外殼,這讓用戶及時獲取到反饋,讓用戶認爲網站的速度很快,即使是在感官上。一旦外殼加載完成,網站就會使用javascript來獲取並加載動態內容。

即,當用戶首次訪問網站時,SW會開始下載並自行安裝。在安裝階段可以進入這個事件並緩存UI外殼所需要的所有資源,即基礎的HTML和CSS、javascript資源文件。當這些資源文件都已添加到SW緩存中,這些資源的HTTP請求再也不需要發送給服務器。一旦用戶導航到另一個頁面,用戶將立即看到UI外殼。

在這裏插入圖片描述
由此,我們總結PWA一般具備下列幾點特徵:

  • 響應式——適應不同尺寸屏幕
  • 連接無關——SW緩存,可以離線工作
  • 應用式交互——使用UI外殼架構進行構建
  • 始終保持最新——由於SW的更新,它會不斷更新
  • 安全——基於HTTPS
  • 可搜索——搜索引擎可爬取到它
  • 可安裝——使用清單文件mainfest.json 可以進行安裝
  • 可鏈接——通過URL輕鬆分享、訪問

SW緩存技術

回想一下,當你做火車進入一個山洞隧道,是不是會出現手機信號衰減或無信號狀態,導致你正在網上瀏覽時加載出現問題。而,SW緩存能處理這個問題。

我們知道,Web服務器可以使用Expires響應頭來通知web客戶端去使用未過期的資源副本,指定指定的“過期時間”。反過來,瀏覽器可以緩存此資源,並且只有在有效期滿後纔會再次檢查新版本。如下圖所示:當瀏覽器發起一個資源的HTTP請求時,服務器會發送一個包含該資源相關有用信息的HTTP響應頭。

在這裏插入圖片描述
但是,使用HTTP緩存存在一個缺陷,即客戶端要依賴於服務器來告知何時緩存資源以及資源何時過期。如果內容具有相關性,任何更新都可能導致服務器發送的到期日期變得很容易不同步,以至於影響網站。

SW緩存

SW緩存,不同於HTTP緩存,SW無須由服務器告知瀏覽器資源要緩存多久。而是,SW能自己控制資源如何緩存。所以,SW緩存是對HTTP緩存的增強,並可以與之配合使用。

現在我們先看個例子:

<body>
    <img src="./images/hello.jpeg" alt="" width="100%">
    <script src="/js/script.js"></script>
    <script>
        if('serviceWorker' in navigator) {
            //註冊
            navigator.serviceWorker.register('/sw.js')
            .then(function(registration){
                //註冊成功
                console.log('sucess:',registration.scope);
            }).catch(err=>{
                //註冊失敗
                console.log('Error:',err);
            })
        }
    </script>
</body>
  • 重點:
//創建緩存資源
var cacheName = 'hello'; //緩存名稱
self.addEventListener('install',event=>{//進入SW的安裝事件
    event.waitUntil(
        caches.open(cacheName)//使用指定的緩存名稱來打開緩存
        .then(cache=>cache.addAll([//把JS和hello.png添加到緩存中
            '/js/script.js'
            '/images/hello.jpeg'
        ]))
    );
});

  • 解析:

  • install事件,它發生在瀏覽器安裝並註冊SW時,它是把後面階段可能會用到的資源添加到緩存中的絕佳時間。例如,我們緩存了script.js這個文件,那麼,另一個引用了此文件的網頁,在後面的階段就可以輕鬆地從緩存中獲取它。

  • cacheName:字符串,用於設置緩存的名稱。可以爲每個緩存取不同的名稱,甚至可以擁有一個緩存的多個不同的副本,每個新的字符串對應唯一的緩存。

  • event.waitUntil() 使用Promise來知曉安裝所需的時間以及是否安裝成功。

  • 需要知道的一點,如果所有的文件都成功緩存,那麼SW便是安裝成功。但是,如果其中之一的文件緩存失敗,那麼安裝過程也會隨之失敗。所以,一個很長的緩存列表,會增加緩存失敗的概率,多一個文件便多一份風險,從而導致SW無法安裝。

OK,前面我們把緩存準備好了。現在我們要讀取緩存。

//利用fetch事件,讀取緩存。fetch事件會監聽URL請求,
//如果在SW緩存中,就從SW中取;如果不在,就通過網絡從服務器中取。
self.addEventListener('fetch',function(event){
	event.respondWith(
		caches.match(event.request)//檢查傳入的請求URL是否匹配當前緩存中存在的任何內容
			.then(function(response){
				if(response){return response;}//SW有,則返回
				return fetch(event.request);//SW沒有,通過網絡從服務器中取
		});
	);
});

現在,交給你一個任務:打開devTool->Network,刷新頁面,看看資源的獲取方式是否真的發生變化。同時,打開Applicaion-》Cache Storage看看SW緩存了哪些文件。

攔截並緩存

前面介紹到的SW緩存,是在install階段進行的,通常稱作“預緩存”。

But,當你的資源是動態的時,該怎麼進行緩存呢?

Don’t Worry. SW能夠攔截HTTP請求,所以這是發起請求然後將響應存儲在緩存中的絕佳機會。那麼,這樣以後將首先請求資源,然後立即將其緩存起來。這對於同樣資源發起下一次HTTP請求時,就可以立即將其從Service Worker緩存中取出。

現在我們再通過示例說明:

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <!--字體引用-->
    <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Lato">
</head>
<body>
    <script src="/js/script.js"></script>
    <script>
        if('serviceWorker' in navigator) {
            //註冊
            navigator.serviceWorker.register('/sw.js')
            .then(function(registration){
                //註冊成功
                console.log('sucess:',registration.scope);
            }).catch(err=>{
                //註冊失敗
                console.log('Error:',err);
            })
        }
    </script>
</body>

//注意到,我們添加了字體引用,即我們需要對該字體資源在請求時進行SW緩存

var cacheName = 'hello';
self.addEventListener('fetch',function(event){
	event.responseWith(
		caches.match(event.request)
			.then(function(response){
					return response;
			})
			var resquestToCache = event.request.clone();//複製請求。請求是一個流,只能使用一次
			return fetch(requestToCache).then(//按照預期發起原始的HTTP請求
                    function(response){
                        if(!response||response.status!==200){//請求失敗或服務器錯誤,返回錯誤消息
                            return response;
                        }
                        var responseToCache = response.clone();//再次複製響應,因爲你需要將其添加到緩存中,而且它還將用於最終返回響應
                        caches.open(cacheName)//打開名稱爲hello的緩存
                        .then(function(cache){
                            cache.put(resquestToCache,responseToCache);//將響應添加到緩存中
                        });
                        return response;
                    }
            )
	)
})
  • 解析:
  1. event.request.clone() :複製請求。請求是一個流,它只能使用一次。如果你已經通過緩存使用了一次請求,接下來發起HTTP請求還要再使用一次,所以你需要在此時複製請求。
  2. response.clone():響應式一個流,它只能使用一次。因爲你想要瀏覽器和緩存都能夠使用響應,所以你需要複製它,這樣就有了兩個流。
  3. cache.put(resquestToCache,responseToCache) :使用響應,並將其添加到緩存中,以便下一次再次使用它。如果用戶刷新頁面或訪問網站上另一個請求了這些資源的頁面,它會立即從緩存中獲取資源,而不再是通過網絡獲取。

應用:試想一下,我們可以通過預緩存技術來確保Web應用的重複訪問時能夠立即加載。還可以假定用戶會單擊鏈接並閱讀新聞的完整內容。如果在安裝SW後緩存這些內容,對於用戶而言,他們就能快速看到下個頁面的內容。

我們可以使用WebPagetest這個工具來測試Web應用在使用了SW緩存後的性能變化。

進一步使用SW緩存

  • 版本控制/緩存破壞
    SW的優點在於,每當對SW文件本身做出更改時,都會自動觸發SW的更新流程。當用戶導航至你的網站時,瀏覽器會嘗試在後臺重新下載SW。即使下載的SW文件與當前的相比只有一個字節的差別,瀏覽器也會認爲它是新的

這個特點,使得我們有幸使用新文件更新緩存。在更新緩存時,可以使用兩種方式。

  1. 可以更新用來存儲緩存的名稱。例如將cacheName的值由’hello’更改爲’hello-2’,就會自動創建一個新緩存並開始從這個緩存中提供文件。之前的緩存將被孤立並不再使用。
  2. 這種方式可能更實用:對文件進行版本控制。這種技術稱爲“緩存破壞”。當緩存靜態文件時,它可以存儲很長一段時間,然後纔到期。如果期間你對網站進行更新,這可能會造成困擾,因爲文件的緩存版本存儲在訪問者的瀏覽器中,它們可能無法看到所做的更改。緩存破壞解決了這個問題:它通過使用一個唯一的文件版本標識符來告知瀏覽器該文件有新版本可用。例如:
<script src="/script-xsbase123.js"></script> //通過在文件名末尾附加一個散列字符串

緩存破壞的原理就是每次更改文件時創建一個全新的文件名,以確保瀏覽器可以獲取最新的內容。

  • 處理額外查詢參數

如果對文件發起的HTTP請求附帶了任意查詢字符串,並且查詢字符串會更改,這可能會導致一些問題。例如,如果你對一個先前匹配的URL發起的請求,則可能會發現由於查詢字符串略有不同而導致該URL找不到。那麼,SW中有這樣的配置:當檢查緩存時,可以忽略查詢字符串,使用ignoreSearch屬性並設置爲true。示例:

self.addEventListener('fetch',function(event){
	event.respondWith(
		caches.match(event.request,{ignoreSearch:true})
		.then(function(response){
				return response || fetch(event.request)
		}
	)
})

除此之外,還有ignoreMethod選項,它會忽略請求參數的方法,POST請求可以匹配緩存中的GET項。ignoreVary選項會忽略已緩存響應中的vary響應頭。

  • SW緩存容量

SW的內存空間,取決於你設備的存儲情況。

  • Workbox
    Workbox 是一個SW輔助庫,能幫助你快速創建SW。

fetch 事件

從前面的講述中,我們SW能攔截瀏覽器發出的任何HTTP請求。因此,屬於此SW作用域內的每個HTTP作用域內的每個HTTP請求都將觸發fetch事件。
但是,通常情況下,只有當用戶刷新頁面時,SW纔會被激活並開始攔截請求。這樣的話,並不是很理想。我想,我們更希望SW能立即開始工作,而不是等待用戶跳轉至網站的其他頁面或刷新頁面。

還好,SW中提供了一個小技巧,能立即激活SW:

self.addEventListener('install',function(event){
	event.waitUntil(self.skipWaiting());
})

skipWaiting()函數,最終會觸發activate事件,並告知SW立即開始工作,而無須等待用戶跳轉或刷新頁面。因爲skipWaiting()強制等待中的SW成爲激活的SW。除此之外,skipWaiting()還可以和self.clients.claim()一起使用,確保底層SW的更新立即生效。例如:

self.addEventListener('activate',function(event){
	event.waitUntil(self.clients.claim());
})

上述兩段代碼同時使用,能立即激活SW。

進一步瞭解fetch事件

  • 處理webp圖片格式兼容性問題

這裏,我們用一個示例說明fetch的應用。通常情況下,加載大體積圖片會導致下載緩慢,導致頁面加載慢。在網絡環境差的情況下,更是令人灰心。

WebP圖片格式橫空出世,它的體積相比PNG和JPEG分別減少了26%和25%~34%,更重要的是圖片質量不會受到影響。支持WebP格式的瀏覽器會在每個HTTP請求中添加accept:image/webp請求頭來告知服務器它支持webp格式。

但是,截止目前(2019.5.1),瀏覽器對webp的支持還不完全。如圖:

在這裏插入圖片描述
但是,別擔心,有了SW,你就能處理這類問題:SW它可以攔截請求,根據瀏覽器對webp的支持情況,將webp格式的圖片返回給能夠渲染給它們的瀏覽器。

示例:

<body>
    <img src="./images/hello.jpeg" alt="" width="100%">
    <script>
        if('serviceWorker' in navigator) {
            //註冊
            navigator.serviceWorker.register('/sw.js')
            .then(function(registration){
                //註冊成功
                console.log('sucess:',registration.scope);
            }).catch(err=>{
                //註冊失敗
                console.log('Error:',err);
            })
        }
    </script>
</body>

// 爲支持WebP圖片的瀏覽器返回此格式的圖片

self.addEventListener('fetch',function(event){
	//爲支持webp格式的瀏覽器返回webp格式的圖片
    if(/\.jpg$|.png$/.test(event.request.url)){
        var supportWebp = false;
        if(event.request.headers.has('accept')){
            //檢測accept請求頭是否支持webp
            supportWeb = event.request.headers
            .get('accept')
            .includes('webp');
        }
        if(supportWebp) {
            var req = event.request.clone();
            var returnUrl = req.url.substr(0,req.url.lastIndexOf("."))+".webp";//創建返回url
            event.responseWith(
                fetch(returnUrl,{
                    mode:'no-cors'
                })
            )
        }
    }
})

如果你請求的是圖片,就可以根據傳遞的HTTP請求頭來返回最合適的內容。上述代碼中,我們通過檢查每個請求頭並尋找image/webp的mime類型,一旦知道請求頭的值,就能判斷出瀏覽器是否支持WebP圖片並返回相應的WebP圖片。如果瀏覽器不支持Webp圖片,它不會再HTTP請求頭中聲明支持,SW會忽略該請求並繼續正常工作。

  • save-data請求頭

通常情況下,在移動端瀏覽器的設置裏有一個選項:“開啓節省流量\智能無圖”等類似的功能按鈕。一旦設置啓用後,每個發送到服務器的HTTP請求都會包含Sava-Data請求頭。使用開發者工具,在Request Headers中,你看到的是Sava-Data: on。開啓節省流量,就可以使用幾種不同的技術來將數據返回給用戶。因爲每個HTTP請求都會發送到服務器,所以可以直接根據服務器端代碼中的Sava-Data請求頭提供不同的內容。

self.addEventListener('fetch',function(event){
	    //檢查save-data HTTP請求頭
    if(event.request.headers.get('save-data')){
        //開啓節省流量功能,限制fonts.googleapis.com
        if(event.request.url.includes('fonts.googleapis.com')){
            //不返回任何內容,417狀態碼錶示服務器無法滿足Expect請求頭字段的要求
            event.responseWith(new Response('',{status:417,statusText:'Ignore fonts to save data.'}));
        }
    }
})

417狀態碼:表示服務器無法滿足Expect請求頭字段的要求

是不是很不錯,這項技術能減少頁面的整體下載量,確保用戶節省了任何不必要的數據。

PWA的增強

前面,我們大都說的是PWA的功能,但是要創建吸引人的應用,我們還需要專注PWA的視覺能力。下面,我們開始介紹怎麼讓PWA表現的更加豐富。

回到開始的那刻,我提到在使用跑步APP時,有一項添加到屏幕的快捷功能,能讓我馬上開啓APP的跑步功能,截圖爲證:
在這裏插入圖片描述
現在我們就來嘗試這個功能。

添加到主屏幕

首先,我們需要知道web應用清單這個東西。它是一個JSON文件,名爲manifest.json,它能讓用戶將Web應用安裝到設備的主屏幕上,並允許開發者自定義啓動頁面、模板顏色、打開的URL等。

示例:

//manifest.json
{
    "name":"Progressive web app",
    "short_name": "Progressive App",
    "start_url": "/index.html",
    "display":"standalone",
    "background_color": "#FFDF00",
    "icons": [{
        "src": "images/homescreen-128.png",
        "type": "image/png",
        "sizes": "128x128"
      }, {
        "src": "images/homescreen-152.png",
        "type": "image/png",
        "sizes": "152x152"
      }, {
        "src": "images/homescreen-144.png",
        "type": "image/png",
        "sizes": "144x144"
      }, {
        "src": "images/homescreen-192.png",
        "type": "image/png",
        "sizes": "192x192"
      }]
 }
//然後再頁面中引入,如果你想在每個頁面中使用,每個頁面都要引入
<link rel="manifest"  href="/manifest.json">
  • 解釋
  1. name:提示用戶安裝應用時出現的文本
  2. short_name:用作當應用安裝後出現在用戶主屏幕上的文本
  3. start_url:決定了當用戶從設備的主屏幕上開啓Web應用時出現的第一個頁面,基礎路徑是mainfest.json所在的路徑
  4. display:表示開發者希望他們的應用如何向用戶展示。有幾種方式:
    • fullscreen:打開web應用並佔用整個可用的顯示區域
    • standalone: URL地址欄等瀏覽器UI元素將被排除
    • minimal-ui:爲用戶提供可訪問的最小UI元素集合,如後退按鈕、前進按鈕等
    • browser:使用OS內置的標準瀏覽器來開發web應用(默認值
  5. theme_color:對瀏覽器的地址欄進行着色。
  6. icons:當把web應用添加到設備主屏幕上時所顯示的圖標。

添加到主屏幕,也稱爲Web應用安裝操作欄。這是一種允許用戶在瀏覽器中快速無縫將Web應用添加到主屏幕上的絕佳方法。通常,我們會通過在頁面上增加提示,提示用戶添加到主屏幕。一般需要滿足幾個條件:

  1. 需要manifest.json文件
  2. manifest.json文件中需要設置啓動URL字段:start_url
  3. 需要144x144像素的PNG圖標
  4. 網站必須使用通過HTTPS運行的SW
  5. 用戶需要至少訪問過網站2次,並且2次訪問間隔大於5min(重要)

你可能會問,爲什麼要2次訪問間隔大於5min(重要)提示纔會出現?這樣做的原因是確保這項功能不會讓人反感。同時,這個約束是瀏覽器內置的,因此開發者無法進行控制。

  • 但是,你可能不希望顯示添加到主屏幕操作欄
window.addEventListener('beforeinstallprompt',event=>{
	event.preventDefault();//阻止添加到主屏幕的操作欄出現
    return false;
});
  • 或者,你希望看看用戶是否接受添加到主屏幕操作欄的情況:
window.addEventListener('beforeinstallprompt',event=>{
    event.userChoice.then(function(result){//判斷用戶選擇,使用userChoice對象
        console.log(result.outcome);
        if(result.outcome==='dismissed'){//不接受
            //發送數據進行分析
            event.preventDefault();//阻止添加到主屏幕的操作欄出現
            return false;
        }else {//接受
            //發送數據進行分析
        }
    })
    
})
  • 調試manifest.json

manifest.json只是簡單的JSON文本,容易錯誤,不容易直觀看出設置效果。

Don’t Worry! Chrome開發者工具提供了調試:在Application->Manifest
在這裏插入圖片描述
同時,你還可以在manifest-validator.appspot.com中進行驗證(需科學上網)。

添加推送

大部分現代Web應用都需要具備定期更新和與用戶溝通的能力。溝通渠道如郵件、應用通知等,但它們並不總是能引起用戶的注意,尤其是當他們離開網站時。
推送通知最大的優點是即使用戶沒有瀏覽你的網站也能收到通知,這種體驗類似原生應用,而且即使瀏覽器沒有運行也可以工作。例如天氣應用。

當然,一旦用戶接收或屏蔽推送通知提示,提示就不會再次出現。重要的是要注意:只有當站點通過HTTPS運行時,同時有一個註冊過的SW,並且已經爲其編好了代碼,纔會出現提示。

這裏的推送,我們是基於Web推送標準push-api目前的支持情況(2019.5.1)如圖:

在這裏插入圖片描述

發送推送通知需要三個步驟:

  1. 向服務器發送訂閱服務
  2. 保存訂閱細節
  3. 在需要時發送推送通知

首先,瀏覽器會顯示一個提示以詢問用戶是否願意接受通知。如果接受,可以將用戶的訂閱詳細信息保存在服務器上,稍後會使用它來發送通知。因爲這些訂閱細節對於每個用戶、設備和瀏覽器來說是唯一的,所以如果一個用戶使用多個設備登錄你的網站,那麼每臺設備都會提醒該用戶是否接受通知。

//先引入清單文件
<link rel="manifest"  href="/manifest.json">

訂閱通知

let endpoint,
		key,
		authSecret;
		let vapidPublicKey = 'BAyb_WagR0L0poDaR7wWkxJi__tWbM1MPBymyRDFEGjtDCWeRYS9EF7yGoCHLdHJi6hikYdg4MuYaK0XoD0qnoY';//客戶端和服務端之間通信的公鑰,確保消息是加密過的

		//將公鑰從base64字符串轉換成Unit8數組,因爲這是VAPID協議規範要求的
		function urlBase64ToUnit8Array(base64String) {
			const padding = '='.repeat((4-base64String.length % 4) % 4);
			const base64 = (base64String + padding)
				.replace(/\-g/g,'+')
				.replace(/_/g,'/');
			const rawData = window.atob(base64);
			const outputArray = new Unit8Array(rawData.length);

			for(let i = 0;i < rawData.length; ++i) {
				outputArray[i] = rawData.charCodeAt(i);
			}
			return outputArray;
		}
		if('serviceWorker' in navigator) {
            //註冊
            navigator.serviceWorker.register('/sw.js')
            .then(function(registration){
                //註冊成功
                return registration.pushManager.getSubscription()//獲取任何已存在的訂閱
                	.then(function(subscription){
                		if(subscription) {//如果訂閱過,則無需再註冊
                			return;
                		}
                		return registration.pushManager.subscribe({
                			userVisibleOnly: true,
                			applicationServerKey: urlBase64ToUnit8Array(vapidPublicKey)
                		})
                		.then(function(subscription){
                			let rawKey = subscription.getKey ?
                			subscription.getKey('p256dh'): '';//從訂閱對象中獲取密鑰和authSecret
                			key = rawKey ? btoa(String.fromCharCode.apply(null,new Unit8Array(rawKey))) : '';
                			let rawAuthSecret = subscription.getKey ?
                			subscription.getKey('auth'): '';
                			authSecret = rawAuthSecret ?
                			btoa(String.fromCharCode.apply(null,new Unit8Array(rawAuthSecret))): '';
                			endpoint = subscription.endpoint;

                			return fetch('./register',{//將詳細信息發送給服務器已註冊用戶
                				method:'post',
                				headers: new Headers({
                					'content-type': 'application/json'
                				}),
                				body: JSON.stringify({
                					endpoint: subscription.endpoint,
                					key: key,
                					authSecret: authSecret
                				}),
                			});
                		});
                	});
            }).catch(err=>{
                //註冊失敗
                console.log('Error:',err);
            })
        }
  • 從上面的代碼,我們知道,要發送通知,需要使用VAPID協議:是自主應用服務器表示的簡稱。它是一個規範,本質上定義了應用服務器和推送服務器之間的握手,並允許推送服務器確認哪個站點正在發送消息。這點是重要的,因爲這意味着應用服務器能夠包含其自身相關的附加信息,這些信息可用於聯繫應用服務器的操作人員。

  • 其次,你要知道,每個訂閱對象包含一個訂閱ID,對於每臺機器,它是唯一的。這對於保護用戶隱私很有幫助,因爲你不會了解用戶的任何信息,而只是一個唯一的ID。

  • 用戶還沒有訂閱前,使用pushManager.subscribe()函數來提示用戶訂閱,該函數使用VAPID公鑰識別自己。在提示用戶之前,需要包含VAPID公鑰並確保已將其轉換爲UInt8Array。將它轉換成UInt8Array發送是因爲規範只接受此類型。如果用戶接受瀏覽器給出的Web推送提示,那麼subscribe函數便返回包含訂閱對象的Promise,可以從這個對象中提取所需要的密鑰authSecret,以便在訂閱時將其發送給服務器。

OK,現在我們需要寫nodejs服務器發送通知的代碼。

發送通知

const webpush = require('web-push');
const express = require('express');
let bodyParser = require('body-parser');
const app = express();

//設置VAPID詳情
webpush.setVapidDetails(
	'mailto:[email protected]',
	'BAyb_WagR0L0poDaR7wWkxJi__tWbM1MPBymyRDFEGjtDCWeRYS9EF7yGoCHLdHJi6hikYdg4MuYaK0XoD0qnoY',//公鑰與前端保持一致
	'p6YVD7t8HkABoez1CVJ5b17BnEdKUu5bSyVjyxMBh0'
);
//監聽指向'/register'的POST請求
app.post('/register',function(req,res){
	let endPoint = req.body.endPoint;
	saveRegistrationDetails(endPoint, key, authSecret);//保存用戶註冊詳情,這樣可以在稍後階段向他們發送消息
	//構建pushSubscription對象
	const pushSubscription = {
		endPoint: req.body.endPoint,
		keys: {
			auth: req.body.authSecret,
			p256dh: req.body.key
		}
	};
	let body = 'Thank you for registering';
	let iconUrl = './images/homescreen-144.png';
	//發送Web推送消息
	webpush.sendNotification(pushSubscription,
		JSON.stringify({
			msg: body,
			url: 'http://localhost:8081',
			icon: iconUrl
		}))
		.then(result => res.sendStatus(201))
		.catch(err=>{
			console.log(err);
		});
});
app.listen(8081,function(){
	console.log('web push app listening on port 8081');
});

接收通知

//sw.js
self.addEventListener('push',function(event){
    //檢查服務器端是否發送了任何有效載荷數據
    let payload = event.data ? JSON.parse(event.data.text()) : 'no payload';
    let title = 'Progressive';
    //使用提供的信息來顯示Web推送通知
    event.waitUntil(
        self.registration.showNotification(title, {
            body: payload.msg,
            url: payload.url,
            icon: payload.icon
        })
    )
})
  • 監聽push事件並讀取來自服務器端的有效載荷數據。有個有效載荷數據,就可以使用showNotification來顯示通知。

處理用戶與推送通知的交互

self.addEventListener('notificationclick',function(event){
    event.notification.close();//一旦單擊了通知提示,便會關閉
    //檢查當前窗口是否已經打開,如果打開,則切換至當前窗口
    event.waitUntil(
        clients.matchAll({
            type: "window"
        })
        .then(function(clientList){
            for(let i=0;i<clientList.length;i++){
                let client = clientList[i];
                if(client.url == '/' && 'focus' in client)
                    return client.focus();
            }
            if(clients.openWindow) {
                //單擊後開發URL
                return clients.openWindow('http://localhost:8081');
            }
        })
    )
})

能夠推送通知是Web應用的一大進步,但是基本的推送只允許用戶單擊消息或完全關閉消息。爲了將推送提升一個等級,可以使用通知操作來真正與用戶互動。使用通知操作,可以定義用戶可以調用並與之交互的情景操作。

爲通知添加振動模式

例如,爲通知添加振動模式振動模式,可以是數字數組,也可以是單個數字,但它會被看做單個數字的數組,數組中的值表示以毫秒爲單位的時間:

  • 索引爲偶數的數字表示振動的時間;
  • 索引爲奇數的數字表示在下一次振動之前暫停多久。
self.addEventListener('push',function(event){
    //檢查服務器端是否發送了任何有效載荷數據
    let payload = event.data ? JSON.parse(event.data.text()) : 'no payload';
    let title = 'Progressive';
    //使用提供的信息來顯示Web推送通知
    event.waitUntil(
        self.registration.showNotification(title, {
            body: payload.msg,
            url: payload.url,
            icon: payload.icon,
            actions: [//會出現在通知中的操作
                {action:'voteup',title:'Vote up'},
                {action:'votedown',title:'Vote down'}
            ],
            vibrate: [300,100,400] //振動300ms,暫停100ms,再暫停400ms
        })
    )
})

//處理通知的單擊動作 event.action

self.addEventListener('notificationclick',function(event){
    event.notification.close();
    if(event.action === 'voteup') {//確定用戶選擇了哪個操作
        clients.openWindow('http://localhost:/voteup');
    }else {
        clients.openWindow('http://localhost:/votedown');
    }
},false);

取消訂閱

用戶可能想取消訂閱,我們可以爲用戶提供一個按鈕。

...
<link rel="manifest"  href="/manifest.json">
...
<button type="button" id="unsubscribe">Unsubscribe</button>
<script>
        function unsubscribe() {
            if('serviceWorker' in navigator) {
                navigator.serviceWorker.ready
                .then((serviceWorkerRegistration)=>{
                    serviceWorkerRegistration.pushManager.getSubscription()//是否訂閱
                        .then((subscription)=>{
                            if(!subscription) {
                                console.log("No subscribed")
                            }
                            subscription.unsubscribe()//如果用戶已經訂閱,就取消訂閱
                                .then(function(){
                                    console.log("Successfully unsubscribe");
                                })
                                .catch((e)=>{
                                    logger.error("Error:",e);
                                })
                        })
                })
            }
        }
        document.getElementById('unsubscribe').addEventListener('click',unsubscribe);

    </script>
    ...
  • 第三方推送通知服務
  1. OneSignal
  2. Roost
  3. Aimtell

構建離線應用

SW可以檢查任何失敗的請求,然後返回用戶要查看的頁面的緩存版本。實際上,一切都在SW的掌握中,你可以返回返回緩存中存在的任何內容。基於此,你可以構建Web離線應用。

開始

假設當用戶處在離線時,我們提供一個離線頁面:

//sw.js
const cacheName = 'offline-cache';//離線緩存的名稱
const offlineUrl = 'offline-page.html';//要存儲在離線緩存中的離線網頁的URL
self.addEventListener('install',event=>{
    event.waitUntil(
        caches.open(cacheName).then(function(cache){
            return cache.addAll([
                offlineUrl
            ])
        })
    )
})
//當用戶沒有連接時提供離線頁面
self.addEventListener('fetch',event=>{
    if(event.request.method==='GET' &&
        //檢查是否是GET請求並且請求的資源類型是'text/html'
        event.request.headers.get('accept').includes('text/html')) {
        event.respondWith(
            fetch(event.request.url).catch(error=>{
                return caches.match(offlineUrl);//返回離線頁面
            })
        )
    }else {
        event.respondWith(fetch(event.request));//返回正常響應
    }
})

  • 我們可以通過Chrome開發者工具提供的Offline來測試。
    在這裏插入圖片描述
  • 通常情況,我們可以把離線頁面放在需要換成的資源的最後。如果緩存中沒有用戶需要的資源,則會降級成默認的“離線”頁面。如:
const cacheName = 'offline-cache';//離線緩存的名稱
const offlineUrl = 'offline-page.html';//要存儲在離線緩存中的離線網頁的URL
self.addEventListener('install',event=>{
    event.waitUntil(
        caches.open(cacheName).then(function(cache){
            return cache.addAll([
            	... //資源1
            	...//資源2
            	...
                offlineUrl
            ])
        })
    )
});
self.addEventListener('fetch',function(event){
	event.respondWith(
        caches.match(event.request,{ignoreSearch:true})
            .then(function(response){
                if(response){
                    return response;
                }
                var requestToCache = event.request.clone();//複製請求。請求是一個流,只能使用一次
                return fetch(requestToCache).then(//按照預期發起原始的HTTP請求
                    function(response){
                        if(!response||response.status!==200){//請求失敗或服務器錯誤,返回錯誤消息
                            return response;
                        }
                        var responseToCache = response.clone();//再次複製響應,因爲你需要將其添加到緩存中,而且它還將用於最終返回響應
                        caches.open(cacheName)//打開名稱爲hello的緩存
                        .then(function(cache){
                            cache.put(event.request,responseToCache);//將響應添加到緩存中
                        });
                        return response;
                    }).catch(error=>{
							if(event.request.method==='GET' &&
						        //檢查是否是GET請求並且請求的資源類型是'text/html'
						        event.request.headers.get('accept').includes('text/html')) {
						                return caches.match(offlineUrl);//返回離線頁面
						        )
						    }
					})
            })
    );
})

雖然說PWA能讓你構建離線應用,但是通常情況下,如果整個網站還不到500KB,那麼緩存全部內容或許是有意義的。如果超過10MB,那就沒什麼意義了。同時,當提供離線功能時,請考慮用戶需求及用戶量。用戶是如何訪問你的網站?他們是否需要一次下載整個網站,或者當他們訪問每個新頁面時再去獲取?
這些問題,有助於你制定緩存策略。

額外功能

根據用戶的連接情況顯示UI通知

  • 監聽online&offline
let offlineNotification = document.getElementById('offline');
function showNotification() {
    offlineNotification.innerHTML = '當前離線';
    offlineNotification.className = 'showOfflineNotification';
}
function hideNotification() {
    offlineNotification.className = 'hideOfflineNotification';
}
window.addEventListener('online',hideNotification);
window.addEventListener('offline',showNotification);

跟蹤離線使用情況

對於用戶是否處在離線情況,我們常常需要知道,然後制定策略。**俗話說,如果你無法衡量它,就不能改善它。**因爲如果用戶處在離線狀態,你無法使用傳統的Web分析方法來跟蹤它們。在沒有網絡連接的情況下,分析請求將無法發出,用戶的操作行爲將會丟失。

有一個SW Helper輔助庫,可以幫助你。其中有一個Offline Google Analytics可以幫助你分析用戶的離線行爲。在用戶離線時,這個庫會將所有分析請求放入隊列中等候,一旦用戶重新連接網絡,它會將隊列中的請求發送到分析服務器。

構建彈性應用

即使你構建的網站再漂亮、運行再快,它也始終需要通過網絡來獲取資源,而獲取過程本身就有可能失敗。這就是構建快速、吸引人的、富有彈性的網站如此重要的原因。

下面,討論web開發時網絡方面存在2個挑戰:lie-fi單點故障

lie-fi和單點故障

  • lie-fi:手機信號滿格,但無法下載任何東西。會導致用戶體驗非常差,因爲瀏覽器一直嘗試下載,而不是在放棄的時候選擇放棄並使用備用方案。這比離線更糟糕。
  • 單點故障(SPOF):SPOF是”如果系統中一點失效“將導致停止整個系統的工作。例如,如果第三方腳本沒有正確實施和部署,那麼對於託管它們的網站將構成重大的風險。(你可以使用WebPagetest.org進行測試,它有提供SPOF選項卡,進行模擬測試。)

使用SW構建備用方案

  • 如果第三方服務器宕機或長時間沒有響應,就取消請求。
//在網速慢時,返回408響應的SW
//超時函數
function timeout(delay) {
    return new Promise(function(resolve,reject)=>{
        setTimeout(function(){
            resolve(new Response('',{
                status:408,
                statusText: 'Request timed out.'
            }));
        },delay);
    })
}
self.addEventListener('fetch',function(event){
    if(/googleapis/.test(event.request.url)) {
        //使用Promise.race作爲條件來同時觸發timeout和fetch函數
        event.respondWith(Promise.race([timeout(3000),fetch(event.request.url)]));
    }else {
        event.respondWith(fetch(event.request))
    }
})

上面的代碼,構建了一個自定義的HTTP響應,它返回HTTP狀態碼408和自定義消息。如果請求太久沒有響應,將觸發這個HTTP響應。

根據研究顯示,10s是保持哦用戶注意力的臨界值。應該將超時限制控制在10秒內。

同樣,你可以使用Chrome開發者工具,測試網站在低網速下的狀況。
在這裏插入圖片描述

使用Workbox處理網絡超時

importScripts('workbox-sw.prod.v1.1.0.js');

const workboxSW = new self.WorkboxSW();
workboxSW.router.registerRoute('https://fonts.googleapis.com/(.*',//選擇緩存的資源
    workboxSW.strategies.cacheFirst({//使用緩存優先策略來緩存資源
        cacheName:'googleapis',
        newWorkTimeoutSeconds: 4//如果網絡請求4s還沒有響應,降級至緩存版本
}))

數據同步

  • 後臺同步(即將發佈)
  • 定期同步

本節有一節新的API要介紹:BackgroundSync(後臺同步)。它允許用戶在離線工作時對需要發送到服務器的數據進行排隊,一旦用戶再次上線,它會將排隊中的數據發送到服務器。例如,聯想一下,你在用某個桌面級筆記應用,你能在離線的時候寫筆記,當再次連上網絡的時候,應用會進行線上同步。

後臺同步(即將發佈)

後臺同步,可以使你推遲操作,直到用戶具備穩定的連接,這使得它非常適合用來確保無論用戶想發送什麼都能在恢復連接時發送出去。例如,電子郵箱的客戶端。郵件在發件箱中排隊,一旦有連接,就將它們一一發送出去。

//註冊後臺同步

<!DOCTYPE html>
<html>
<head>
	<title></title>
</head>
<body>
	<input type="text" id="name"/>
	<input type="text" id="email"/>
	<input type="text" id="message"/>
	<button id="submit">submit</button>
	<!-- 引入idb-keyval庫,它是一個基於Promise並且使用IndexedDB實現的存儲 -->
	<!-- 雖然SW無法修改DOM,但能訪問IndexedDB和緩存 -->
	<script src="./js/idb-keyval.js"></script>
	<script>
	//檢測是否支持SW和SyncManager功能
		if('serviceWorker' in navigator && 'SyncManager' in window) {
            //註冊
            navigator.serviceWorker.register('/sw.js')
            .then(function(registration){
                document.getElementById('submit').addEventListener('click',()=>{
                	//註冊同步並使用contact-email作爲標籤名
                	registration.sync.register('contact-email').then(()=>{
                		let payload = {
                			name: document.getElementById('name').value,
                			email: document.getElementById('email').value,
                			message: document.getElementById('message').value,
                		};
                		idbKeyval.set('sendMessage',payload);//從頁面中取得payload數據並將其保存到IndexedDB中
                	})
                })
            }).catch(err=>{
                //註冊失敗
                console.log('Error:',err);
            })
        }else {
        	document.getElementById('submit').addEventListener('click',()=>{
            	//註冊同步並使用contact-email作爲標籤名
        		let payload = {
        			name: document.getElementById('name').value,
        			email: document.getElementById('email').value,
        			message: document.getElementById('message').value,
        		};
        		fetch('/sendMessage',{//不支持SW和SyncManager,則使用fetch發送數據至服務器
        			method: 'POST',
        			headers: new Headers({
        				'content-type': 'application/json'
        			}),
        			body: JSON.stringify(payload)
        		});

            });
        }
	</script>
</body>
</html>

  • idb-keyval庫
  • IndexedDB:用戶客戶端存儲大量結構化數據的底層API,包括文件或二進制大對象(Blob)。適用在小數據量的存儲上。
  • contact-email:作爲標籤名註冊同步。它只是一個自定義字符串,用於識別事件。可以把這些同步標籤當做不同操作的標籤,想要多少就有多少。
  • 使用registration對象並提供一個用來識別的標籤註冊同步。每個同步都必須有唯一的標籤名,因爲如果使用的標籤名與等待中的同步同名,它們就會進行合併。例如,如果使用相同的標籤名,用戶在離線期間發送了5條消息,當重新連接時,它們指揮觸發一個同步。如果你想觸發5次,那麼需要使用5個唯一的標籤名

響應同步事件

上一節,我們將數據保存在IndexedDB中了,現在,在SW中監聽同步事件。

importScripts('./js/idb-keyval.js');
self.addEventListener('sync',(event)=>{
    if(event.tag === 'contact-email') {//檢查標籤名
        event.waitUntil(
            idbKeyval.get('sendMessage').then(value=>{//從IndexedDB中獲取有效載荷值
                fetch('/sendMessage/',{//發起POST請求
                    method: 'POST',
                    headers: new Headers({'content-type': 'application/json'}),
                    body: JSON.stringify(value)//將從IndexedDB中獲取的有效載荷值作爲參數傳遞給服務器
                }).then(response=>{
                    if(response.status >=200 && response.status < 300) {
                        idbKeyval.delete('sendMessage');//從IndexedDB中移除有效載荷值
                    }
                })
            })
        )
    }
})
  • sync事件:只會在瀏覽器認爲用戶連接到網絡時觸發。

下圖,解釋了後臺同步的邏輯流程
在這裏插入圖片描述

  • 測試:先把wifi連接禁用,然後提交數據,然後再重啓連接WiFi(你可以在開發者工具中看到排隊的請求發送到了服務器)。

更進一步

雖然,我們將用戶離線編輯的數據在用戶上線時發送了,但是用戶其實並不知道發生了什麼。所以,我們有必要向用戶提供反饋,讓他們知道消息已放入等待隊列,當他們再次上線時這些消息會發送出去。

所以,在前面的代碼中,我們添加這麼一個功能。

//顯示通知和提示用戶消息狀態
function displayMessageNotification(notificationText) {
	let messageNotification = document.getElementById('message');
	messageNotification.innerHTML = notificationText;
	messageNotification.className = 'showMessageNotification';
}
...
...
idbKeyval.set('sendMessage',payload);//從頁面中取得payload數據並將其保存到IndexedDB中
displayMessageNotification('Message queued'); //顯示消息已經加入隊列,等待發送

定期同步

當打開應用時,無論是否離線,最新的消息都會呈現。這個功能稱爲”定期同步“。它允許你在預定的時間內安排同步。

navigator.serviceWorker.ready.then(function(registration){
    registration.periodicSync.register({
        tag: 'get-latest-news', //同步事件的標籤名
        minPeriod: 12*60*60*1000, //兩次成功同步事件之間的最小時間間隔,單位毫秒。
        powerState: 'avoid-draining'//確定同步的電池需求,['auto','avoid-draining']
        networkState: 'avoid-cellular'//確定同步的網絡需求,['online(默認)','avoid-cellular','any']
    }).then(function(periodicSyncReg){
        //成功
    },function(){
        //失敗
    })
})
  • minPeriod:單位毫秒,設置爲0,瀏覽器可以按照自己意願頻繁觸發事件

**注意:**定期同步並不意味它是一個精確的計時器。儘管該API接受毫秒爲單位的屬性,但這並不代表會準時進行同步。很多因素都會影響它,如網絡環境、電池狀況、當前設備的設置等。由於定期同步需要設備的支持,因此需要得到用戶的授權。

WebStream

web stream 允許你以流的方式向用戶發送數據。例如,假設你要在網頁上顯示一張圖片。如果不使用流,瀏覽器需要進行下列步驟:

  1. 通過網絡獲取圖片資源
  2. 處理數據並將其解壓爲原始像素數據
  3. 將結果數據渲染到頁面中

這是顯示一張圖片的關鍵步驟,但爲什麼要等整個圖片下載完才能開始這些步驟呢?如果可以一塊塊地處理數據,無須等待整個圖片下載完成呢?如果不使用流,你需要等待下載完全部內容,才能進行響應。但是,使用流,就可以一塊塊地返回下載結果並進行處理,這使得渲染更快。你可以並行獲取及處理數據。

同時,使用流還有一些好處:如能夠減少大型資源所佔用的內存空間。例如:如果需要下載、處理一個大文件,並將其保存在內存中,這可能會導致問題。如果使用流,就可以減少大型資源佔用的內存空間,因爲數據時一塊塊處理的,這個特性稱爲”流量控制“

使用流量控制,可以使用解碼器來檢測你是否正在以比讀取速度更快的速度生成解碼幀,這使得我們可以減少網絡流量和下載頻率。線上視頻就是很好的應用例子。

除此之外,WebStream還能:

  • 知道流的開始與結束
  • 緩存尚未讀取的值
  • 用管道將流組合成一個異步序列
  • 發生的任何錯誤都將沿着管道傳播
  • 可以取消流並將其傳回管道中

可讀流 (ReadableStream)

可讀流,是WS的核心概念。表示可以衝中讀取數據的數據源,可讀流只允許數據留出,不允許流入。

使用的兩種數據源的類型:

  • 推送源(push):將數據推送給你,不管你是否請求他們的數據,提供了暫停和恢復數據流的機制
  • 拉取源(pull):需要你請求或手動拉取數據。例如文件句柄:它允許你讀取指定數量的數據或尋找文件中的特定位置。

可讀流,是一種將推送源和拉取源同時包裝在一個易於理解的接口中的簡單方法。

let stream = new ReadableStream({
	start(controller) {},
	pull(controller) {},
	cancel(reason) {}
},queuingStrategy);
  • start(controller) : 立即調用它並用它來設置任何基礎數據源,如推送源或拉取源。只有當成功後纔會調用pull(controller)
  • pull(controller):當流的緩存區未滿時,會調用該方法,而且會重複調用,直到緩衝區滿爲止。只有當前這個pull成功,纔會調用下一個pull。
  • cancel(reason):取消任何基礎數據源
  • queuingStrategy:一個對象。決定了流如何根據內部隊列的狀態來發出過載信號。

示例

2016:The Year of web Stream“這篇文章,深入介紹了WS及其應用。
下面我們通過示例介紹一個”可讀流並故意減慢數據流向瀏覽器的速度,從而使頁面逐漸渲染“:

self.addEventListener('fetch',event=>{
    event.respondWith(htmlStream());
})
function htmlStream() {
    const html = 'html goes here...'
    const stream = new ReadableStream({//創建一個可讀流
        start: controller => {
            const encoder = new TextEncoder();//使用TextEncoder將文本轉換成字節
            let pos = 0;
            let chunkSize = 1;
            //將結果推送到WS中
            function push() {
                if(pos >= html.length) {//檢查是否超出HTML的長度,超出則關閉controller
                    controller.close();
                    return;
                }
                //將下一個HTML塊編碼並放入隊列
                controller.enqueue(
                    encoder.encode(html.slice(pos,pos+chunkSize))
                );
                pos += chunkSize;
                setTimeout(push,50);//延遲50ms,降低渲染速度
            }
            push();//開始推送流

        }
    });
    //返回流的結果作爲新的Response對象
    return new Response(stream,{
        headers: {
            'Content-Type': 'text/html'
        }
    });
}

現在,我們可以把Service Worker 和 WebStream結合起來,提升Web性能。例如,我們可以使用SW流獲取頁面不同部分,然後將它們組合成一個流。

  • 添加資源到緩存
const cacheName = 'lastestNews-v1';
self.addEventListener('install',event=>{
    self.skipWaiting();
    event.waitUntil(
        .then(cache=>cache.addAll([
            './js/main.js',
            './images/newspaper.svg',
            './css/app.css',
            './header.html',
            './footer.html',
            'offline-page.html'
        ]))
    )
})
self.addEventListener('activate',event=>{
    self.clients.claim();
})
  • 在web stream中拼裝HTML
//從查詢字符串中獲取指定字段的值
function getQueryString(field,url=window.location.href) {
    const reg = new RegExp( '[?&]' + field + '=([^&#]*)', 'i');
}
self.addEventListener('fetch',event=>{
    const url = new URL(event.request.url);
    //是否是請求article的路由
    if(url.pathname.endsWith('/article.html')) {
        //獲取id
        const articleId = getQueryString('id');
        //建立URL
        const articleUrl = 'data-${articleId}';
        //使用流結果進行響應
        event.respondWith(streamArticle(articleUrl));
    }
})
  • 最後一步:在web stream響應中拼裝HTML
function streamArticle(url) {
    try {
        new ReadableStream({});//檢查當前瀏覽器是否支持web stream api
    }catch(e) {
        return new Response('Streams not supported');
    }
    const stream = new ReadableStream({
        start(controller) {
            const startFetch = caches.match('header.html');//從緩存中去header.html
            const bodyData = fetch('data/${url}.html')//獲取頁面主體部分
            .catch(()=>new Response('Body fetch failed'));
            const endFetch = caches.match('footer.html');//從緩存中去footer.html

            function pushStream(stream) {
                const reader = stream.getReader();
                function read() {
                    return reader.read().then(result=>{
                        if(result.done) return;
                        controller.enqueue(result.value);
                        return read();
                    });
                }
                return read();
            }
        startFetch
            .then(response=>pushStream(response.body))//將數據推送到流中
            .then(()=>bodyData)
            .then(response=>pushStream(response.body))
            .then(()=>endFetch)
            .then(response=>pushStream(response.body))
            .then(()=>controller.close());
        }
    });
    return new Response(stream,{
        headers: {'Content-Type':'text/html'}
    });
}

流最棒的一點是它們可以像管道一樣傳輸數據。可讀流可以直接傳輸到可寫流,也可以先傳輸一個或多個轉換流。以這種方式連接在一起的一組流被稱爲”管道鏈“。在管道鏈中,原始來源是鏈中第一個可讀流的基礎來源,最終流向鏈中最後一個寫入流的結果。

一般地,我們可以將流形容爲”管道“和”水槽“,因爲這表現出數據像水一樣,從一個或多個流傳輸到下一個流。

PWA常見問題

用戶清除了Chrome緩存,PWA的緩存也會被清除

因爲PWA是由Chrome等瀏覽器提供支持的,所以目前存儲是共享的。用戶清楚了緩存,PWA的緩存也會被清除。

SW中添加了緩存資源,更改資源後,緩存沒有更新,刷新後也是舊版本?

  • 辦法1:如果你需要確保在更改時始終更新文件,那麼可能需要考慮對文件進行版本控制並進行重命名。例如:main-v2.js
    每次文件更新後,都更新版本,這樣就會重新下載。
  • 辦法2:在SW更新後的激活階段刪除當前的緩存項。通過SW的activate事件,清除緩存。

查看PWA使用存儲的情況

navigator.storage.estimate("temporary").then(info=>{
	console.log(info.quota);//總的存儲空間,單位-字節
	console.log(info.usage);//到目前使用了多少數據,單位-字節
})

參考學習資料

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章