BACK
Featured image of post 將網頁變成Progressive Web Application(PWA),漸進式的網頁應用程式

將網頁變成Progressive Web Application(PWA),漸進式的網頁應用程式

漸進式網路應用程式(英語:Progressive Web Apps,簡稱:PWA)是一種普通網頁或網站架構起來的網路應用程式,但它可以以傳統應用程式或原生行動應用程式形式展示給使用者。這種應用程式形態視圖將目前最為現代化的瀏覽器提供的功能與行動裝置的體驗優勢相結合。

參考網站
參考網站
參考網站

前幾天與大家分享,當開啟Chrome內建的PWA功能,就可將PWA的網頁,一鍵變成一個應用程式,雖然說PWA只是在應用程式裡,包了Chrome的瀏覽器,但開啟時就像應用程式一般,讓網頁就像應用程式一般,其實簡單的來說,這就有點像PhoneGap的感覺,但比PhoneGap要來的簡單許多。

因此只要將網頁加入一些簡單的宣告與設定,立即就可將一般的網頁變成PWA的網頁應用程式,真心覺得PWA是個好物,因此只要你會寫網頁,就可將你所設計的網頁變成一隻應用程式!


將網頁變成Progressive Web Application(PWA)

Step1:下載 Chrome 擴充套件

點我前往 「Web Server」 擴充套件

首先,你可先下載Chrome的「Web Server」擴充程式,再點「CHOSOSE FOLDER」鈕,選擇已製作好的網頁目錄。


Step2:檢查網頁

點一下連結,並檢查網頁是否能正常呈現。


Step3:建立 manifest.json

更多 manifest.json 介紹,點我前往

接著建立一個新的文件檔,並依的輸入應用程式名稱、圖示、與應用程式的URL,再將它儲存為「manifest.json」檔。

manifest.json
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
{
  "short_name": "Wayne's blog",
  "name": "Wayne's blog",
  "description": "偉恩的部落格,用於紀錄coding life查找的資料、筆記與文章,統整起來除了自己使用,也希望對各位有幫助。",
  "icons": [
    {
      "src":"./apple-touch-icon-57x57.webp",
      "sizes": "57x57",
      "type": "image/webp"
    },
    {
      "src":"./apple-touch-icon-114x114.webp",
      "sizes": "114x114",
      "type": "image/webp"
    },
    {
      "src":"./apple-touch-icon-120x120.webp",
      "sizes": "120x120",
      "type": "image/webp"
    },
    {
      "src":"./apple-touch-icon-180x180.webp",
      "sizes": "180x180",
      "type": "image/webp"
    },
    {
      "src":"./apple-touch-icon-192x192.webp",
      "sizes": "192x192",
      "type": "image/webp"
    },
    {
      "src":"./apple-touch-icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    },
    {
      "src":"./maskable_icon.webp",
      "sizes": "512x512",
      "type": "image/webp",
      "purpose": "any maskable"
    }
  ],
  "lang": "zh-Hant-TW",
  "start_url": "./?utm_source=web_app_manifest",
  "background_color": "#f5f5fa",
  "theme_color": "#57BCB9",
  "display": "standalone",
  "orientation": "landscape",
  "prefer_related_applications": false
}

Step4:html 加入 manifest.json

在head標籤中加入 manifest.json

1
2
3
<!-- ... -->
<link rel="manifest" href="./manifest.json" />
<!-- ... -->

由於 manifest 在 IOS 上還不支援,因此如果 IOS 上也想要自訂 icon,就要改用 HTML 裡的 meta 來設定,如下:

1
2
3
4
5
6
7
8
9
<!-- ... -->
<link rel="manifest" href="./manifest.json" />
<link rel="apple-touch-icon" sizes="57x57" href="./apple-touch-icon-57x57.webp" />
<link rel="apple-touch-icon" sizes="114x114" href="./apple-touch-icon-114x114.webp" />
<link rel="apple-touch-icon" sizes="120x120" href="./apple-touch-icon-120x120.webp" />
<link rel="apple-touch-icon" sizes="180x180" href="./apple-touch-icon-180x180.webp" />
<link rel="apple-touch-icon" sizes="192x192" href="./apple-touch-icon-192x192.webp" />
<link rel="apple-touch-icon" sizes="512x512" href="./apple-touch-icon-512x512.png" />
<!-- ... -->

Step5:打開開發人員工具檢查 manifest.json 的設定


Step6:加入 serviceWorker 語法

更多 Service Worker 介紹,點我前往

在網頁前加入下方的語法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<script>
  if ("serviceWorker" in navigator) {
    console.log("Will service worker register?");
    navigator.serviceWorker.register("./service-worker.js").then(function(reg) {
      console.log("Yes it did.");
    }).catch(function(err) {
      console.log("No, it didn't. This happened: ", err);
    });
  }
</script>

Step7:建立 service-worker.js 檔案

接著再建立一個文件,並貼上下方的語法,儲存為 service-worker.js,這時可看到在原來的網頁中,分別會多了 manifest.jsonservice-worker.js 和圖片資料夾。

service-worker.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
self.addEventListener('install', function(event) {
  self.skipWaiting();
  
  var offlinePage = new Request('offline.html');
  event.waitUntil(
  fetch(offlinePage).then(function(response) {
    return caches.open('offline2').then(function(cache) {
      return cache.put(offlinePage, response);
    });
  }));
});
self.addEventListener('fetch', function(event) {
  event.respondWith(
    fetch(event.request).catch(function(error) {
        return caches.open('offline2').then(function(cache) {
          return cache.match('offline.html');
      });
    }));
});
self.addEventListener('refreshOffline', function(response) {
  return caches.open('offline2').then(function(cache) {
    return cache.put(offlinePage, response);
  });
});
self.addEventListener('push', function (event) {
  var data = event.data.json();   var opts = {
    body: data.body,
    icon: data.icon,
    data: {
      url: data.url
    }
  };
  event.waitUntil(self.registration.showNotification(data.title, opts));
});
self.addEventListener('notificationclick', function(event) {
  var data = event.notification.data;   event.notification.close();   event.waitUntil(
    clients.openWindow(data.url)
  );
});

Step8:確認 Service Workers 是否正常

接著再回到網頁的「開發人員工具」的 Service Workers 時,就會看到綠色燈號。


Step9:安裝「頁面」

都完成後,再點 Chrome 右上的選單圖示,就可看到「安裝XXX」。


Step10:安裝

點一下,就會出現安裝畫面,再點「安裝」。

當安裝完畢後,就會開啟剛所安裝的應用程式,哈!是不是畫面乾淨許多,就像在使用一般的應用程式一樣,有了PWA後可實現透過網頁就能打造專屬的應用程式的夢想,且完全不用再學新語言,真是超方便的。


manifest.json 介紹

以下來說明一下 manifest.json

manifest.json 是在學 PWA 這塊時最簡單的部份,主要是建一支 JSON 檔就可以,相關的參數 MDN上 或是 Google 一下就會看到,不用花太多的時間去記。

manifest.json是做什麼用的?

根據 MDN 上的解釋,manifest.json 是這樣子的:

它提供了應用程式相關的資訊(像是名稱、作者、圖示、描述)。 manifest 的功用是將 Web 應用程式安裝到設備的主畫面,為使用者提供更快速的訪問和更豐富的體驗。

簡單的說明就是,PWA 主要就是讓網頁可以模擬成一個手機的 App 來使用,要當成 App,就要有 App 的樣子,就要能在手機的桌面上有一個 icon 可以按,按下去後有一個幾秒鐘的啟動畫面讓使用者知道開啟的 App 是什麼,manifest 就是在處理這段。

另外,一般我們在手機上開 Chrome 或 Safari 看網頁時,最頂部會有一條網址列,但一般 App 是不會有的,而 manifest.json 也可以設定開啟頁面時不顯示網址列。

PWA 是把頁面模擬成 App 的一個方法,如果公司本身也有製作 App 呢?manifest.json 上也可以設定 App 在 Google Play、App Store 上的連結,並呈現一個推薦通知讓使用者可以進入並下載。

最後補充一點,不論是 PWA 的 Cache 或是推播功能,都要求網站要是 https,manifest.json 這點也不意外,如果網站不是 https,即便引用了 manifest.json,也不會被 Chrome 主動詢問是否要將網站加入到主畫面。

關於 manifest 的功能,Google 有一隻影片介紹:

Web App Manifest: Totally Tooling Tips


manifest.json 成員

成員列表整理如下:

欄位說明
theme_color應用程式的主要顏色
background_color啟動畫面(splash screen)的背景色
icons應用程式的圖示
name應用程式的名稱
short_name應用程式的簡寫
lang主要語言
description應用程式的描述
dir文字書寫方向
display應用程式的顯示模式
orientation預設顯示的方向是直的或橫的
prefer_related_applications是否要推薦一個原生的 App
related_applications推薦原生 App 的連結
start_url開啟應用程式時的預設網址
scope應用程式的使用範圍

theme_color

應用程式的主要顏色,主要可以改變網址列那一條的顏色。

1
"theme_color": "#57BCB9"

background_color

啟動畫面(splash screen)的背景色,App 啟動時,會有一個 Splash Screen,翻譯成快閃頁、過場頁,本篇稱為啟動畫面。

Splash Screen 需要3個成員:background_color、icons、name。

1
"background_color": "red"

補充一點,Splash Screen,在 IOS 上是不支援的,但可以直接讀取一張圖檔來當作啟動頁面,在 head 裡加入以下就行:

1
2
3
4
5
6
7
<link rel="apple-touch-startup-image" href="images/splash-640x1136.png" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="images/splash-750x1294.png" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="images/splash-1125x2436.png" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="images/splash-1242x2148.png" media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="images/splash-1536x2048.png" media="(min-device-width: 768px) and (max-device-width: 1024px) and (-webkit-min-device-pixel-ratio: 2) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="images/splash-1668x2224.png" media="(min-device-width: 834px) and (max-device-width: 834px) and (-webkit-min-device-pixel-ratio: 2) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="images/splash-2048x2732.png" media="(min-device-width: 1024px) and (max-device-width: 1024px) and (-webkit-min-device-pixel-ratio: 2) and (orientation: portrait)">

icons

應用程式的圖示,icons 裡放陣列,放不同尺寸下的主要圖示,如果 Splash Screen 裡要用的圖示,大小必須包含 192px512px

icons 裡,陣列裡的每一個物件有 3 個成員:

  • sizes 圖片尺寸,可用在多個尺寸上的話用空白鍵區隔。
  • src 圖片的路徑,如果是相對路徑,是以 manifest 所在的位置為基準。
  • type 圖檔類型,這項排必填,主要是告知裝置類型,讓不支援的裝置可以快速略過。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
"icons": [
  {
    "src": "icon/lowres.webp",
    "sizes": "48x48",
    "type": "image/webp"
  },
  {
    "src": "icon/lowres",
    "sizes": "48x48"
  },
  {
    "src": "icon/hd_hi.ico",
    "sizes": "72x72 96x96 128x128 256x256"
  },
  {
    "src": "icon/hd_hi.svg",
    "sizes": "72x72"
  }
]

由於 manifest 在 IOS 上還不支援,因此如果 IOS 上也想要自訂 icon,就要改用 HTML 裡的 meta 來設定,如下:

1
2
3
<link rel="apple-touch-icon" sizes="57x57" href="apple-icon-57x57.png" />
<link rel="apple-touch-icon" sizes="72x72" href="apple-icon-72x72.png" />
<link rel="apple-touch-icon" sizes="114x114" href="apple-icon-114x114.png" />

name

應用程式的完整名稱,會顯示在 Splash Screen上,以及主畫面上的名稱。

1
"name": "Wayne's blog"

short_name

應用程式的簡寫,當 name 太長而無法顯示時,會改成顯示簡寫。

1
"short_name": "Wayne's blog"

lang

主要語言,針對 name、short_name 這兩個使用的。

語言列表可以在這找:https://www.iana.org

1
"lang": "zh-Hant-TW"

description

應用程式的描述,描述這個應用程式是做什麼的。

1
"description": "偉恩的部落格,用於紀錄coding life查找的資料、筆記與文章,統整起來除了自己使用,也希望對各位有幫助。"

dir

文字書寫方向,值有 3 個:ltr(左至右)、rtl(右至左)、auto(讓瀏覽器自己決定),不填的話預設值是 auto

1
"dir": "ltr"

display

應用程式的顯示模式,顯示模式指的是從主畫面點開 App 後,要顯示的樣子,有 4 個值可用:

  • fullscreen: 全螢幕,就像在用原生 App 一樣,會隱藏所有的瀏覽器 UI。
  • standalone: 會隱藏標準瀏覽器 UI 元素,如 URL 欄等。
  • browser: 預設值,就像一般用瀏覽器一樣。
  • minimal: ui 會有導覽列最小的 UI,這個值 Chrome 不支援。

如果要 Chrome 主動提示加入主畫面的話,設定值必須要是 standalone

1
"display": "standalone"

orientation

預設顯示的方向是直的或橫的,可以強制讓使用者必須裝置拿直的(landscape)或拿橫的(portrait)看。這點要設要仔細思考過才行,因為使用都大部份都喜歡自己決定是直看或横看。

如果是遊戲類型的,可以設定用橫的。

1
"orientation": "landscape"

是否要推薦一個原生的 App,PWA 是模擬頁面為 App,如果網站本身有出 App,這項就可以設成 true,從主畫面點開頁面時就會出現可以下載 App 的提示。

如果這項為 true,則下面的 related_applications 就要填入值;這項的預設值為 false

1
"prefer_related_applications": false

推薦原生 App 的連結,值是陣列,裡面放 App 資訊的物件,每項物件有 2 個成員要填,是 Google Play 上的話則有 3 個成員。

  • platform: 應用程式的平台,可填 play、itunes。
  • url: 應用程式的網址。
  • id: Google Play上要填的 ID。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
"related_applications": [
  {
    "platform": "play",
    "url": "https://play.google.com/store/apps/details?id=com.example.app1",
    "id": "com.example.app1"
  },
  {
    "platform": "itunes",
    "url": "https://itunes.apple.com/app/example-app1/id123456789"
  }
]

start_url

開啟應用程式時的預設網址,如果有設定的話,開啟應用程式時就會進到設定的網址。沒設定的話就是使用者按加入主畫面時的那個網址。
如果填寫的是相對路徑,是以 manifest 所在的位置為基準。
建議可以在網址上加入 Google Analyticsutm 參數,這樣在 GA 上就可以看見多少來源是來自於 PWA,可以檢測成效。

如果要 Chrome 主動提示加入主畫面的話,這項必須填寫。

1
"start_url": "./?utm_source=web_app_manifest"

scope

應用程式的使用範圍,這項如果有填,那應用程式的作用域就會限在指定的目錄裡,超過指定目錄,就會當成一般的網頁瀏覽。

1
"scope": "/myapp/"

manifest.json 基本檔案內容

manifest 裡的成員不是每項都一定要填寫的,以下附上 manifest.json 的基本檔內容:

manifest.json
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
{
  "short_name": "Wayne's blog",
  "name": "Wayne's blog",
  "description": "偉恩的部落格,用於紀錄coding life查找的資料、筆記與文章,統整起來除了自己使用,也希望對各位有幫助。",
  "icons": [
    {
      "src":"./apple-touch-icon-57x57.webp",
      "sizes": "57x57",
      "type": "image/webp"
    },
    {
      "src":"./apple-touch-icon-114x114.webp",
      "sizes": "114x114",
      "type": "image/webp"
    },
    {
      "src":"./apple-touch-icon-120x120.webp",
      "sizes": "120x120",
      "type": "image/webp"
    },
    {
      "src":"./apple-touch-icon-180x180.webp",
      "sizes": "180x180",
      "type": "image/webp"
    },
    {
      "src":"./apple-touch-icon-192x192.webp",
      "sizes": "192x192",
      "type": "image/webp"
    },
    {
      "src":"./apple-touch-icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    },
    {
      "src":"./maskable_icon.webp",
      "sizes": "512x512",
      "type": "image/webp",
      "purpose": "any maskable"
    }
  ],
  "lang": "zh-Hant-TW",
  "start_url": "./?utm_source=web_app_manifest",
  "background_color": "#f5f5fa",
  "theme_color": "#57BCB9",
  "display": "standalone",
  "orientation": "landscape",
  "prefer_related_applications": false
}

關於「Chrome 主動提示加入主畫面」

PWA 身為 Google 的親身兒子,在 Android 手機是很有吃香的點。
頁面引用了 manifest.json 後,使用者在手機上點選加入主畫面,頁面就可以像 App 一樣顯示在手機的主畫面上。

但很多使用者其實不知道有這功能,或是知道了也不常會去按。
貼心的 Google 針對這點做了一項措施,就是主動詢問使用者要不要將頁面加入主畫面。

根據 Google 的說明文件:Add to Home Screen,觸發 Chrome 主動詢問是否加入主畫面的條件如下:

  1. 使用者還沒加入主畫面
  2. 在有 manifes.json 的網域下,互動了至少 30
  3. manifset.json 裡有幾項成員一定要有:nameshort_nameiconsstart_urldisplay
  4. 頁面要是 https
  5. 頁面有裝 service-worker(sw.js),裡面有監聽並處理 beforeinstallprompt 事件

第 5 點,是否要有寫 beforeinstallprompt,好像不是必填項目,即便沒填,在測試時也會在底部出現一條詢問框。

在參考了這篇以後:PWA 實戰經驗分享
發現別人是用在 UX 上更優化的地方,就是自己選擇要出現詢問框時用的。

manifest 觀摩

我們來看看幾個知名的 PWA 都是怎麼寫他們的 manifest.json

第一個是 PWA 界中很有名的 flipkart

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
{
  "name": "Flipkart Lite",
  "short_name": "Flipkart Lite",
  "icons": [
    {
      "src": "https:/https://static.coderbridge.com/img/techbridge/images1a.flixcart.com/www/linchpin/batman-returns/logo_lite-cbb3574d.png",
      "sizes": "192x192",
      "type": "image/png"
    }
  ],
  "gcm_sender_id": "656085505957",
  "gcm_user_visible_only": true,
  "start_url": "/?start_url=homescreenicon",
  "permissions": [
    "gcm"
  ],
  "orientation": "portrait",
  "display": "standalone",
  "theme_color": "#2874f0",
  "background_color": "#2874f0"
}

再來是鼎鼎大名的 twitter

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
{
  "background_color": "#ffffff",
  "description": "It's what's happening. From breaking news and entertainment, sports and politics, to big events and everyday interests.",
  "display": "standalone",
  "gcm_sender_id": "49625052041",
  "gcm_user_visible_only": true,
  "icons": [
    {
      "src": "https://abs.twimg.com/responsive-web/web/ltr/icon-default.604e2486a34a2f6e.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "https://abs.twimg.com/responsive-web/web/ltr/icon-default.604e2486a34a2f6e.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
  "name": "Twitter",
  "share_target": {
    "action": "compose/tweet",
    "params": {
      "title": "title",
      "text": "text",
      "url": "url"
    }
  },
  "short_name": "Twitter",
  "start_url": "/",
  "theme_color": "#ffffff",
  "scope": "/"
}

最後則是 Google I/O 2018

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
{
  "name": "Google I/O 2018",
  "short_name": "I/O 2018",
  "start_url": "./?utm_source=web_app_manifest",
  "display": "standalone",
  "theme_color": "#6284F3",
  "background_color": "#6284F3",
  "icons": [{
    "src": "static/images/homescreen/homescreen57.png",
    "sizes": "57x57",
    "type": "image/png"
  }, {
    "src": "static/images/homescreen/homescreen114.png",
    "sizes": "114x114",
    "type": "image/png"
  }, {
    "src": "static/images/homescreen/homescreen128.png",
    "sizes": "128x128",
    "type": "image/png"
  }, {
    "src": "static/images/homescreen/homescreen144.png",
    "sizes": "144x144",
    "type": "image/png"
  }, {
    "src": "static/images/homescreen/homescreen192.png",
    "sizes": "192x192",
    "type": "image/png"
  }, {
    "src": "static/images/homescreen/homescreen512.png",
    "sizes": "512x512",
    "type": "image/png"
  }],
  "prefer_related_applications": false,
  "related_applications": [{
    "platform": "play",
    "id": "com.google.samples.apps.iosched"
  }],
  "gcm_sender_id": "103953800507"
}

我滿喜歡觀察別人家的這些東西,因為你會發現很多你查資料時遺漏或是根本找不到的資訊,而且這些看久了你也會有個概念,知道哪些屬性特別常用,除了 manifest.json 以外,也可以參考 html 裡面的 tag,一樣能學習到很多。


Service Worker 介紹

加入 Service Worker 的目的就只有一個,那就是快取。透過 Service Worker(以下簡稱 SW),可以幫助我們在發送 request 之前就先攔截到並且做處理,而離線運行的原理也是這樣的,我們先在第一次開啟時註冊 SW,並且利用 SW 下載靜態檔案並快取住,之後若使用者離線,我們再用已經快取住的檔案來回覆,就不會發送真的 request,自然也不會發生無法連線的情況。

而 Google 有提供了一個方便的工具:Workbox 來幫助我們自動產生出 SW 以及利用更方便的語法來攔截 request。

舉例來說,我自己用的是 Webpack 的 plugin:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
new workboxPlugin.InjectManifest({
    swSrc: path.join(__dirname, '..', SRC_DIR, 'sw.js'),
    swDest: path.join(__dirname, '..', DIST_DIR, 'sw.js'),
    globDirectory: path.join(__dirname, '..', DIST_DIR),
    globPatterns: ['**/*.{js,css}']
}),

//sw.js
let precacheList = self.__precacheManifest || []
workbox.precaching.precacheAndRoute(precacheList)

只要這樣一寫,就會自動去找符合規則的檔案並且加入快取清單裡面,你只要一註冊 SW 的時候就會把那些檔案給快取起來。

除此之外呢,Workbox 也可以針對 URL 來監聽:

sw.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// sw.js
workbox.routing.registerRoute(/(https?:\/\/)(.*)\/api\/(.*)/, args =>
  workbox.strategies
    .networkFirst({
      cacheName: 'data-cache',
      plugins: [
        new workbox.expiration.Plugin({
          maxEntries: 100,
          maxAgeSeconds: 2592000
        })
      ]
    })
    .handle(args)
    .then(response => {
      return response
    })
    .catch(err => {
      console.log('err:', err)
    })
)

像上面的程式碼就是針對路徑中含有 api 的 request 做快取,這樣在離線時也可以利用以前快取住的 API response。

Workbox 針對這種動態的快取提供幾種策略,分別是:staleWhileRevalidatecacheFirstnetworkFirstnetworkOnlycacheOnly,其實看名字就可以大概理解策略是什麼了,想知道詳細的內容可以參考官方文件:Workbox Strategies

總之自從有了 Workbox 之後,基本上就不用自己手寫 SW 了,都靠著它提供的 API 以及功能就行了,就可以自動產生出符合需求的 SW。

Add to home screen banner

最後要來談的是「安裝 PWA」這一塊,在 iOS Safari 上面別無他法,就只能自己叫出選單然後選取「Add to home screen」,可是在 Android Chrome 上面,如果你符合一定的條件(有設置 mainfest.json 以及有註冊 Service Worker),就會自動幫你跳出一個可愛的 Install banner。

(圖片來自:Changes to Add to Home Screen Behavior

根據 Chrome 版本的不同,行為也有所不同。

在 Chrome 67(含)以前的版本,如果你在 beforeinstallprompt 事件裡面沒有特別用 preventDefault(),或是顯式的呼叫了 prompt(),就會出現最左邊那個頗大的 A2HS banner。

然後在 Chrome 68(含)之後的版本,無論你做了什麼,系統都會自動出現那個 Mini-infobar,但如果使用者關掉的話,要隔三個月才會再出現一次,實在是有夠久。

接著呢,上面這兩個 A2HS banner 跟 Mini-infobar,使用者點擊之後都會出現最右邊的 A2HS Dialog,提示使用者要不要安裝 PWA。

但是在 Chrome 68 以後,你也可以利用程式去呼叫 beforeinstallprompt 裡面拿到的 event.prompt() 把這個 dialog 顯示出來。

聽起來有點複雜對吧?

先來介紹 beforeinstallprompt 這個 event 好了,這個 event 在一切都準備就緒,確認你滿足條件可以顯示 prompt 的時候會被觸發,會傳來一個 event,你可以阻止顯示 prompt,把這個 event 存起來:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 此範例來自上面的官方文件
let installPromptEvent;

window.addEventListener('beforeinstallprompt', (event) => {
  // Prevent Chrome <= 67 from automatically showing the prompt
  event.preventDefault();
  // Stash the event so it can be triggered later.
  installPromptEvent = event;
  // Update the install UI to notify the user app can be installed
  document.querySelector('#install-button').disabled = false;
});

為什麼要存起來呢?因為使用者可能不想一打開網站就看到這個彈窗,或者他可能正在結帳結果你跳這個東西來干擾他,所以先把它存起來,等適當的時機再呼叫 installPromptEvent.prompt() 來跳出 Dialog。

但要注意的事情是你直接呼叫 installPromptEvent.prompt() 是沒用的,你必須要 within a user gesture,意思就是你要放在按鈕的 click 事件(或其他由使用者觸發的事件)裡才有效,直接呼叫是沒有用的,而且會看到 console 跳出錯誤訊息。

我之前一度很好奇它是怎麼做判斷的,後來發現原來有 event.isTrusted 可以用,可以判斷一個事件是不是被使用者主動觸發的,參考資料:MDN - Event.isTrusted

總之呢,因為在不同版本上的 Chrome 有不同行為,所以最後我們決定用下面的程式碼針對不同版本有不同的反應:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 把 event 存起來
var installPromptEvent

// 要顯示 prompt 的延遲
var showTime = 30 * 1000

window.addEventListener('beforeinstallprompt', function (e) {
  e.preventDefault()
  installPromptEvent = e
  var data = navigator.userAgent.match(/Chrom(e|ium)\\/([0-9]+)\\./)
  var version = (data && data.length >= 2) ? parseInt(data[2], 10) : null
  if (version && installPromptEvent.prompt) {

    // 延遲一段時間才顯示 prompt
    setTimeout(function() {
      // 如果 Chrome 版本是 67(含)以下,可以直接呼叫
      if (version <= 67) {
        installPromptEvent.prompt()
        return
      }

      // 否則的話必須透過 user action 主動觸發
      // 這邊幫 #root 加上 event listener,代表點擊螢幕任何一處都會顯示 prompt
      document.querySelector('#root').addEventListener('click', addToHomeScreen)    
    }, showTime)
  }
});

function addToHomeScreen(e) {
  if (installPromptEvent) {
    installPromptEvent.prompt()
    installPromptEvent = null
    document.querySelector('#root').removeEventListener('click', addToHomeScreen) 
  }
}

如果是 67 以下,直接呼叫就可以顯示 prompt,否則的話還要再一步,要加個 event listener 才行,而我們也選擇延遲 30 秒才顯示。

出乎意料地,這樣一個小改動帶來驚人的成長,原本一天大概才 20、30 個人安裝 PWA,經過這樣調整之後瞬間變成八到十倍,看到 GA 的那個統計圖我也嚇了一跳,沒想到效果這麼好。

與其一直積極地要別人快點安裝 PWA,還不如只要求真的對你產品有興趣(停留超過 30 秒鐘)的人。


用 Firebase 做 Web Push

用到的資源

Web Push 推播功能,這陣子很常看到的一個功能,如果一進入網站,看到網站要求顯示通知的權限,就代表這個網站有用這功能:

那通常,很多網站 UX 設計不良,在使用者根本就還搞不清楚這網站是幹麻的情況下就跳通知,所以大部份都直接按封鎖了吧 XD?

總之,在踩了幾個坑以後,終於完整的寫出了 Web Push 的功能。

完整是指會在桌機、安卓手機發出推播通知,並且點了會進到指定的頁面,同時在 Firebase 儲存發送訊息的記錄。

用到的資源如下:

Web Push 是 PWA 一個很重要的功能!

取得 FCM 金鑰

本篇是用 Firebase 的 Cloud Messaging功能(以下稱 FCM),去實作 Web Push。所以第一步是要先在 Firebase 上開一個專案。

以下截圖所用到的 Firebase 專案之後會刪掉,各種金鑰最後都不會存在,純示範用。

開完專案後,要取得推播金鑰,有了金鑰才能始用 FCM 的功能。

首先進到 Firebase 後台,點選齒輪後,再點選專案設定:

點擊 Cloud Messaging:

頁面往下拉,會看到一個「網路設定」的區塊,有一個「產生金鑰組」的按金,按下去:

就會產生一組 Web Push 用的金鑰,先存下來,Firebase Config 裡會用到:

新增 manifest.json、Firebase Config

取得金鑰後,第二步就是新增一個 manifest.json 的檔案,檔案內容大概如下:

manifest.json
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{
  "short_name": "XXX",
  "name": "XXXXXX",
  "icons": [
    {
      "src": "./logo/logo192.png",
      "type": "image/png",
      "sizes": "192x192"
    },
    {
      "src": "./logo/logo512.png",
      "type": "image/png",
      "sizes": "512x512"
    },
    {
      "src":"./logo/maskable_icon.webp",
      "sizes": "512x512",
      "type": "image/webp",
      "purpose": "any maskable"
    }
  ],
  "start_url": "/",
  "background_color": "#ffffff",
  "display": "standalone",
  "scope": "/",
  "theme_color": "#000000",
  "gcm_sender_id": "103953800507"
}

最重要的是這一行:

1
"gcm_sender_id": "103953800507"

這行一定要有,key、value 是固定的,copy 貼上就行。

Firebase Config

建立一個首頁的檔案 index.html,在頁尾的部份引用 Firebase Config。
Firebase Config 一樣是後台有提供,點小齒輪,再點專案設定後,接著點代表 Web 的那個按鈕:

就會出現 Config 了:

copy 以後貼到 index.html,另外也一併貼上 FCM 的引用,整合後如下:

fcm-web-push-firebase-config.html
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<script src="https://www.gstatic.com/firebasejs/5.9.1/firebase.js"></script>
<script>
  var config = {
    apiKey: "XXXXXXXXXX",
    authDomain: "XXXXXXXXXX.firebaseapp.com",
    databaseURL: "https://XXXXXXXXXX.firebaseio.com",
    projectId: "XXXXXXXXXX",
    storageBucket: "XXXXXXXXXX.appspot.com",
    messagingSenderId: "XXXXXXXXXX"
  };
  firebase.initializeApp(config);
  var database = firebase.database();
  var messaging = firebase.messaging();
  messaging.usePublicVapidKey('XXXXXXXXXXXXXXXXXXXX');
</script>

最後一行的:

1
messaging.usePublicVapidKey('XXXXXXXXXXXXXXXXXXXX');

XXX 就是要替換成第一步拿到的 FCM 金鑰

取得使用者的 token

簡單來說,Web Push 推播功能的流程如下:

  • 註冊 service-worker → 向使用者要求允許通知權限 →
  • FCM 產生這個裝置的 token → token 寫進資料庫 →
  • 從後台使用 Web Push → Server 發 Web Push 到裝置上 →
  • 裝置上的 service worker 接收 → service worker 執行 notification

token 是每一個裝置會有的,之所以會以裝置為單位,而不是以使用者為單位,是因為每一個裝置都可以註冊service worker,而且每次存的 token 都會不同,所以一個使用者在桌機的 Chrome、Fireox,或是安卓手機的Chrome、Firefox,都有不同的 token。

假設王小明在桌機的 Chrome、Firefox,都按下了允許通知,在安卓手機的 Chrome、Firefox 也按了允許通知,那當我們按下發送推播後,王小明就會在 4 個地方收到相同的通知。因為是用裝置來區分的。

IOS 目前還不支援 Web Push。
2020.04.10更新:
今天看到 iZooto 的 一篇文章 上寫,在比對了一下 Can I Use 中的 service workers 支援情況,可以看到 IOS11.3 開始支援 Service Workers 了,但 Web Push 的功能只支援在 MAC,尚未支援到 iPhone 上。

新增 sw.js

Workbox 新增一個 sw.js 檔案,把 Firebase 的 Config 放進去,程式碼如下:

fcm-web-push-sw1.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
importScripts("https://storage.googleapis.com/workbox-cdn/releases/3.6.3/workbox-sw.js");
importScripts('https://www.gstatic.com/firebasejs/5.7.0/firebase-app.js');
importScripts('https://www.gstatic.com/firebasejs/5.7.0/firebase-messaging.js');

workbox.clientsClaim();
workbox.skipWaiting();

workbox.precaching.precacheAndRoute([
  // 要快取的檔案
]);

// firebase config
var config = {
  apiKey: "XXXXXXXXXX",
  authDomain: "XXXXXXXXXX.firebaseapp.com",
  databaseURL: "https://XXXXXXXXXX.firebaseio.com",
  projectId: "XXXXXXXXXX",
  storageBucket: "XXXXXXXXXX.appspot.com",
  messagingSenderId: "XXXXXXXXXX"
};
firebase.initializeApp(config);

var messaging = firebase.messaging();

如果推播裡要放公司的 Logo,那 Logo 的圖檔就要寫進快取檔案的清單裡,到時推播才有圖檔可以顯示。

註冊 sw.js、存 Cookies、存 Firebase

如果拿到的 token 不存在 Cookies 裡,用 Cookies 判斷是否拿過 token,那使用者就會存到 2 組以上的 token,在發推播時,就會收到相同的訊息多次。

當然,如果使用者清除了快取,就會有別種情況發生,最好的方式還是讓使用者登入會員後,再取得 token,把 token 跟會員綁一起,就不會有奇奇怪怪的情形出現。

以下為 JS code:

fcm-web-push-get-token.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
window.addEventListener('load', function() {
  if('serviceWorker' in navigator) {
    navigator.serviceWorker.register('/sw.js')
      .then(function(reg) {
        // firebase methods,用同一支sw.js
        messaging.useServiceWorker(reg);
      })
      // 註冊失敗
      .catch(function(err) {
        console.log('error: ', err);
      });
  }

  messaging.requestPermission().then(function() {

    // 先判斷cookies有沒有token,沒有再取token
    var ckv = document.cookie.replace(/(?:(?:^|.*;\\s*)augustusWsPush\s*\=\s*([^;]*).*$)|^.*$/, "$1") || null;

    // cookies不存在,跟使用者要求通知權限
    if(ckv === null) {
      // 拿到token,firebase-messaging-sw.js 就會存 Service Workers 裡
      messaging.getToken().then(function(currentToken) {

        // token存至firebase
        var id = currentToken.split(':')[0];
        firebase.database().ref('pushUsers/' + id).set({'token': currentToken});

        // token存至cookies
        document.cookie = "augustusWsPush=" + currentToken;

      });
    }
    // cookies 已存在,從 cookies 取出後傳至 firebase
    else {
      var id = ckv.split(':')[0];
      firebase.database().ref('pushUsers/' + id).set({'token': ckv});
    }

  }).catch(function(err) {
    console.log('使用者未允許通知', err);
  });

});

裡面有一行很重要

1
messaging.useServiceWorker(xxx);

這是這次踩到的

如果沒寫這行,就會發現網站存了 2 個 service worker,分別是 Firebase 的,以及我們自己的。這行可以讓 FCM 只存我們註冊的 sw.js,才可以在用 FCM 推播時,順利的執行推播及點擊後開啟指定的頁面。

成功的話,開啟 index.html,在開發人員工具的 Application → Service Workers,就會看見註冊了 sw.js 檔案:

註冊 sw.js 只能在 https 下,因此網域必須有S SL,不然會註冊失敗。

1
messaging.requestPermission()

這行會跟使用者要允許通知,使用者按下允許後

1
messaging.requestPermission()

這行就會產生 FCM 的 token,之後就是存進 Firebase 跟 Cookies。
Firebase 上會看到以下:

之後只要跑個迴圈,就可以一個個去發送 Web Push。

發送 Web Push

這邊新增一個 admin.html 來當做發送 Web Push 的後台介面,直接用 Vue.js 抓每個 input 的值。

Web Push 如果要判斷成效,直接用 Google Analytics 的 utm 參數就可以了,所以後台欄位有給 utm 用的 3 個主要參數。
不含樣式的原始碼文末會附上 GitHub 網址。
在發送 Web Push 時,還要填入伺服器的金鑰,這也是從 Firebase 後台可以拿到。點小齒輪 → 專案設定 → Cloud Messaging,第一個「伺服器金鑰」的值就是了:

發送 Web Push 的 JS 如下,寫在 admin.html 裡:

fcm-web-push-trigger.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
var notification = {
  'title': 'web push標題',
  'body': 'web push內文',
  'badge': 'logo圖檔路徑',
  'icon': 'logo圖檔路徑',
  'click_action': 'https://www.domain.com.tw',
  'data': {
    'url': 'https://www.domain.com.tw'
  }
};

fetch('https://fcm.googleapis.com/fcm/send', {
  'method': 'POST',
  'headers': {
    'Authorization': 'key=從firebase上取得伺服器金鑰',
    'Content-Type': 'application/json'
  },
  'body': JSON.stringify({
    'data': notification,
    'to': '使用者的token',
  })
}).then(function(response) {
  console.log(response);
}).catch(function(error) {
  alert(error);
});

接收 Web Push

接受 Web Push 有 2 種情況:

  1. 使用者正開啟官網頁面
  2. 使用者沒有開啟官網頁面

第一種情況,是在 index.js 下寫一個 messaging.onMessage 的 function 處理。
第二種情況,是在 sw.js 下寫一個 messaging.setBackgroundMessageHandler 的 function 處理。

使用者正開啟官網頁面

官網正開啟的狀況下,屬於 JS 的 notification 功能,function 範例如下:

fcm-web-push-notification.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
messaging.onMessage(function(payload) {
  var msgTitle = payload.data.title;
  var url = payload.data.click_action;
  var notification = new Notification(msgTitle, payload.data);

  // 點擊推播後要連去哪
  notification.addEventListener('click', function() {
    e.preventDefault();
    location.href = url;
  });
});

payload 就是傳來的值,點擊後要導到的頁面就用 location.href 來處理。

使用者沒有開啟官網頁面

使用者在上網,但沒有開啟官網的頁面,就是由之前註冊的 sw.js 處理,在 sw.js 加入以下:

fcm-web-push-sw2.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
var click_action;

// 監聽notifiction點擊事件
self.addEventListener('notificationclick', function(event) {
  var url = click_action;
  event.notification.close();
  event.waitUntil(
    clients.matchAll({
      type: 'window'
    }).then(windowClients => {
      // 如果tab是開著的,就 focus 這個tab
      for (var i = 0; i < windowClients.length; i++) {
        var client = windowClients[i];
        if(client.url === url && 'focus' in client) {
          return client.focus();
        }
      }
      // 如果沒有,就新增tab
      if(clients.openWindow) {
        return clients.openWindow(click_action);
      }
    })
  );
});

// FCM
messaging.setBackgroundMessageHandler(function(payload) {
  var data = payload.data;
  var title = data.title;
  var options = {
    body: data.body,
    icon: '/logo/logo192.png',
    badge: '/logo/logo192.png'
  };
  click_action = data.click_action;

  return self.registration.showNotification(title, options);
});

click_action 是點擊後要開啟的頁面網址,但要另外寫一個 notificationclick 來處理。

這邊踩了一個坑,如果按照一般順序來寫,會先寫了 messaging.setBackgroundMessageHandler 後,再寫 notificationclick。然後就會發現,Chrome 上可以正常運作,但在 Firefox 上怎麼點就是不會開啟頁面。

Google 了很久,才看到這篇:service worker notificationclick event doesn’t focus or open my website in tab

click_action 是 Chrome 用的,如果 messaging.setBackgroundMessageHandler 先寫了,那 FCM 就會全部接收 Web Push 的功能,就不會讓原生的 notificationclick 事件被運作,因此 notificationclick 得寫在前,讓原生事件運作後,再執行 FCM 的事件。

補充資源

有了發送、接收,基本上 Web Push 就可以順利運作了。
要注意的是 Web Push 只接受在 https 以下運行。
最後附上學習時參考的教學文,以及本篇的 GitHub 原始碼。

從建 Firebase 就開始教學的(英文):Tutorial: Web Push notification using Firebase

建立 Service Worker Web Push Notification — (Firebase Push Notification實作紀錄)

Push Notification之成為訂閱用戶(Firebase實作)

Google 官方教學的原始碼:GitHub
Google 官方教學的功能示範:Notification Examples


comments powered by Disqus