Web開發多年來有了顯著的發展。它允許開發人員部署網站或Web應用程式並在數分鐘內為全球數百萬人服務。只需一個瀏覽器,使用者可以輸入URL就可以訪問Web應用程式了。隨著 Progressive Web Apps的到來,開發人員可以使用現代Web技術向使用者提供很好體驗的應用程式。在這篇文章中,你會學習到如何構建一個離線的漸進式 web 應用程式(Progressive Web Apps),下面就叫 PWA 啦。
首先介紹一下什麼是 PWA
雖然很多文章已經說過了,已經理解的童鞋請跳過這個步驟。PWA基本上是使用現代Web技術構建的網站,但是體驗上卻像一個移動 app,在2015年,谷歌工程師Alex Russell和Frances Berriman創造了“ Progressive Web Apps”。此後谷歌就一直致力於讓 PWA能給使用者像原生 app一般的體驗。一個典型的PWA的應該是這樣的:
1、開始在Web瀏覽器的位址列中訪問
2、有顯示新增到裝置的主螢幕選項
3、逐步開始展示諸如離線使用、推送通知和後臺同步等應用程式屬性。
到目前為止,移動APP可以做很多Web App不能真正做的事情。PWA,一個web app嘗試去做移動app已經很長時間了。它結合最好的 web技術的和最好的app技術,可以在慢速網路連線上快速載入,離線瀏覽,推送訊息,並在Web螢幕上載入Web應用程式入口。
到現在,安卓上最心版本的Chrome瀏覽器,支援在桌面上快速開啟你的 web app 了,這一切都感謝 PWA,如下圖
WPA 的特性
這類新的Web應用程式具有定義它們存在的特性。沒有很難的知識點,下面這些都是 PWA具有的一些特性:
-
Responsive(響應式):Ui可以適配多個終端,桌面,手機,平板等等
-
App-like(像app):當與一個PWA互動時,它應該感覺像一個原生的應用程式。
-
Connectivity Independent(連線獨立): 它能離線瀏覽(通過 Service Workers) 或者在低網速上也能瀏覽
-
Re-engageable(重新連線):通過推送通知等功能,使用者應該能夠持續地參與和重用應用程式。
-
Installable(安裝):使用者可以新增在主螢幕並且從那裡啟動它他們就可以重新應用程式了。
-
Discoverable(可發現的):使用者通過搜尋應被識別發現的
-
Fresh(最新資料):當使用者連線到網路時,應該能夠在應用程式中提供新的內容。
-
Safe(安全):該通過HTTPS提供服務,防止內容篡改和中間人攻擊。
-
Progressive(漸進式):不管瀏覽器的選擇如何,它應該對每個使用者都有效。
-
Linkable(可連結):通過URL分享給別人。
PWA的一些生產用例
Flipkart Lite: FlipKart 是印度最大的電商之一。如下圖
AliExpress:AliExpress 是個非常受歡迎的全球線上零售市場,通過實踐 PWA之後,訪問量和瀏覽數都成倍增加這裡不做詳細講解。如下圖
Service Workers
Service Workers是可程式設計代理的一個script指令碼執行在你瀏覽器的後臺,它具有攔截、處理HTTP請求的能力,也能以各種方式對他們作出響應。它有響應網路請求、推送通知、連線更改等等的功能。Service Workers不能訪問DOM,但它可以利用獲取和快取API。您可以Service Workers快取所有靜態資源,這將自動減少網路請求並提高效能。 Service worker 可以顯示一個 app應用殼,通知使用者,他們與網際網路斷開了並且提供一個頁面供使用者在離線時進行互動、瀏覽。
一個Service worker檔案,例如sw.js
需要像這樣放置在根目錄中:
在你的PWA中開始service workers,如果你的應用程式的JS檔案是app.js
,你需要去註冊service workers在你的app.js
檔案,下面的程式碼就是註冊你的service workers。
if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register('./sw.js')
.then(function() { console.log('Service Worker Registered'); });
}
複製程式碼
上面的程式碼檢查瀏覽器是否支援service workers。如果支援,就開始註冊service workers,一旦service workers註冊了,我們就開始體驗使用者第一次訪問頁面時service workers的生命週期。
service workers的生命週期
- Install:在使用者第一次訪問頁面時觸發安裝事件。在這個階段中,service workers被安裝在瀏覽器中。在安裝過程中,您可以將Web app的所有靜態資產快取下來。如下面程式碼所示:
// Install Service Worker
self.addEventListener('install', function(event) {
console.log('Service Worker: Installing....');
event.waitUntil(
// Open the Cache
caches.open(cacheName).then(function(cache) {
console.log('Service Worker: Caching App Shell at the moment......');
// Add Files to the Cache
return cache.addAll(filesToCache);
})
);
});
複製程式碼
filesToCache
變數代表的所有檔案要快取陣列
cachename
指給快取儲存的名稱
- Activate:當service worker啟動時,此事件將被觸發。
// Fired when the Service Worker starts up
self.addEventListener('activate', function(event) {
console.log('Service Worker: Activating....');
event.waitUntil(
caches.keys().then(function(cacheNames) {
return Promise.all(cacheNames.map(function(key) {
if( key !== cacheName) {
console.log('Service Worker: Removing Old Cache', key);
return caches.delete(key);
}
}));
})
);
return self.clients.claim();
});
複製程式碼
在這裡, service worker每當應用殼(app shell)檔案更改時都更新其快取。
- Fetch:此事件作用與於從伺服器端的資料快取到 app殼中。
caches.match()
解析了觸發Web請求的事件,並檢查它是否在快取中獲得資料。然後,它要麼響應於快取版本的資料,要麼用fetch
從網路中獲取資料。用e.respondWith()
方法來響應返回到Web頁面。
self.addEventListener('fetch', function(event) {
console.log('Service Worker: Fetch', event.request.url);
console.log("Url", event.request.url);
event.respondWith(
caches.match(event.request).then(function(response) {
return response || fetch(event.request);
})
);
});
複製程式碼
在寫程式碼的時候。我們需要注意一下Chrome, Opera、Firefox是支援service workers 的,但是Safari 和 Edge 還沒有相容到service workers
Service Worker Specification 和 primer 都是關於Service Workers的一下非常有用的學習資料。
Application Shell(應用殼)
在文章的前面,我曾多次提到過應用殼app shell
。應用程式殼是用最小的HTML,CSS和JavaScript驅動應用程式的使用者介面。一個PWA確保應用殼被快取,以對應app
多次快速訪問和快速載入。
下面我們將逐步寫一個 PWA例子
我們將構建一個簡單的PWA。這個app只跟蹤來自特定開源專案的最新提交。作為一個 PWA,他應該具具有:
- 離線應用,使用者應該能夠在沒有Internet連線的情況下檢視最新提交。
- 應用程式應立即載入重複訪問
- 開啟按鈕通知按鈕後,使用者將獲得對最新提交到開放原始碼專案的通知。
- 可安裝(新增到主螢幕)
- 有一個Web應用程式清單
光說不做瞎扯淡,開始吧!
建立index.html
和latest.html
檔案在你的程式碼資料夾裡面。
index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Commits PWA</title>
<link rel="stylesheet" type="text/css" href="css/style.css">
</head>
<body>
<div class="app app__layout">
<header>
<span class="header__icon">
<svg class="menu__icon no--select" width="24px" height="24px" viewBox="0 0 48 48" fill="#fff">
<path d="M6 36h36v-4H6v4zm0-10h36v-4H6v4zm0-14v4h36v-4H6z"></path>
</svg>
</span>
<span class="header__title no--select">PWA - Home</span>
</header>
<div class="menu">
<div class="menu__header"></div>
<ul class="menu__list">
<li><a href="index.html">Home</a></li>
<li><a href="latest.html">Latest</a></li>
</div>
<div class="menu__overlay"></div>
<div class="app__content">
<section class="section">
<h3> Stay Up to Date with R-I-L </h3>
<img class="profile-pic" src="./images/books.png" alt="Hello, World!">
<p class="home-note">Latest Commits on Resources I like!</a></p>
</section>
<div class="fab fab__push">
<div class="fab__ripple"></div>
<img class="fab__image" src="./images/push-off.png" alt="Push Notification" />
</div>
<!-- Toast msg's -->
<div class="toast__container"></div>
</div>
</div>
<script src="./js/app.js"></script>
<script src="./js/toast.js"></script>
<script src="./js/offline.js"></script>
<script src="./js/menu.js"></script>
</body>
</html>
複製程式碼
latest.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Commits PWA</title>
<link rel="stylesheet" type="text/css" href="css/style.css">
</head>
<body>
<div class="app app__layout">
<header>
<span class="header__icon">
<svg class="menu__icon no--select" width="24px" height="24px" viewBox="0 0 48 48" fill="#fff">
<path d="M6 36h36v-4H6v4zm0-10h36v-4H6v4zm0-14v4h36v-4H6z"></path>
</svg>
</span>
<span class="header__title no--select">PWA - Commits</span>
</header>
<div class="menu">
<div class="menu__header"></div>
<ul class="menu__list">
<li><a href="index.html">Home</a></li>
<li><a href="latest.html">Latest</a></li>
</ul>
</div>
<div class="menu__overlay"></div>
<section class="card_container">
<h2 style="margin-top:70px;" align="center">Latest Commits!</h2>
<div class="container">
<section class="card first">
</section>
<section class="card second">
</section>
<section class="card third">
</section>
<section class="card fourth">
</section>
<section class="card fifth">
</section>
</div>
</section>
<div class="loader">
<svg viewBox="0 0 32 32" width="32" height="32">
<circle id="spinner" cx="16" cy="16" r="14" fill="none"></circle>
</svg>
</div>
<!-- Toast msg's -->
<div class="toast__container"></div>
</div>
<script src="./js/app.js"></script>
<script src="./js/latest.js"></script>
<script src="./js/toast.js"></script>
<script src="./js/offline.js"></script>
<script src="./js/menu.js"></script>
</body>
</html>
複製程式碼
建立一個 css
資料夾,並且在這個檔案下載建立一個style.css
檔案(可以點選這裡檢視),建立一個js
資料夾,並在這個檔案下建立app.js
, menu.js
, offline.js
, latest.js
,toast.js
。
js/offline.js
(function () {
'use strict';
var header = document.querySelector('header');
var menuHeader = document.querySelector('.menu__header');
//After DOM Loaded
document.addEventListener('DOMContentLoaded', function(event) {
//On initial load to check connectivity
if (!navigator.onLine) {
updateNetworkStatus();
}
window.addEventListener('online', updateNetworkStatus, false);
window.addEventListener('offline', updateNetworkStatus, false);
});
//To update network status
function updateNetworkStatus() {
if (navigator.onLine) {
header.classList.remove('app__offline');
menuHeader.style.background = '#1E88E5';
}
else {
toast('You are now offline..');
header.classList.add('app__offline');
menuHeader.style.background = '#9E9E9E';
}
}
})();
複製程式碼
上面的程式碼幫助使用者在 ui視覺上區分離線和線上狀態。
js/menu.js
(function () {
'use strict';
var menuIconElement = document.querySelector('.header__icon');
var menuElement = document.querySelector('.menu');
var menuOverlayElement = document.querySelector('.menu__overlay');
//Menu click event
menuIconElement.addEventListener('click', showMenu, false);
menuOverlayElement.addEventListener('click', hideMenu, false);
menuElement.addEventListener('transitionend', onTransitionEnd, false);
//To show menu
function showMenu() {
menuElement.style.transform = "translateX(0)";
menuElement.classList.add('menu--show');
menuOverlayElement.classList.add('menu__overlay--show');
}
//To hide menu
function hideMenu() {
menuElement.style.transform = "translateX(-110%)";
menuElement.classList.remove('menu--show');
menuOverlayElement.classList.remove('menu__overlay--show');
menuElement.addEventListener('transitionend', onTransitionEnd, false);
}
var touchStartPoint, touchMovePoint;
/*Swipe from edge to open menu*/
//`TouchStart` event to find where user start the touch
document.body.addEventListener('touchstart', function(event) {
touchStartPoint = event.changedTouches[0].pageX;
touchMovePoint = touchStartPoint;
}, false);
//`TouchMove` event to determine user touch movement
document.body.addEventListener('touchmove', function(event) {
touchMovePoint = event.touches[0].pageX;
if (touchStartPoint < 10 && touchMovePoint > 30) {
menuElement.style.transform = "translateX(0)";
}
}, false);
function onTransitionEnd() {
if (touchStartPoint < 10) {
menuElement.style.transform = "translateX(0)";
menuOverlayElement.classList.add('menu__overlay--show');
menuElement.removeEventListener('transitionend', onTransitionEnd, false);
}
}
})();
複製程式碼
上面的程式碼作用於選單省略號按鈕的動畫。
js/toast.js
(function (exports) {
'use strict';
var toastContainer = document.querySelector('.toast__container');
//To show notification
function toast(msg, options) {
if (!msg) return;
options = options || 3000;
var toastMsg = document.createElement('div');
toastMsg.className = 'toast__msg';
toastMsg.textContent = msg;
toastContainer.appendChild(toastMsg);
//Show toast for 3secs and hide it
setTimeout(function () {
toastMsg.classList.add('toast__msg--hide');
}, options);
//Remove the element after hiding
toastMsg.addEventListener('transitionend', function (event) {
event.target.parentNode.removeChild(event.target);
});
}
exports.toast = toast; //Make this method available in global
})(typeof window === 'undefined' ? module.exports : window);
複製程式碼
上面的程式碼是是一個 tost
的提示資訊框
latest.js
和 app.js
現在還是空的。
現在,使用本地伺服器啟動你的應用程式,例如 http-server模組可以幫組你啟動本地服務,您的Web應用程式應該如下所示:
Side menu
Index Page
Latest Page
Application Shell
您的應用殼也在上面突出顯示。現在尚未實現載入動態內容,下一步,我們需要從 Github's API獲取最新的提交。
獲取動態內容
開啟js/latest.js
增加下面的程式碼
(function() {
'use strict';
var app = {
spinner: document.querySelector('.loader')
};
var container = document.querySelector('.container');
// Get Commit Data from Github API
function fetchCommits() {
var url = 'https://api.github.com/repos/unicodeveloper/resources-i-like/commits';
fetch(url)
.then(function(fetchResponse){
return fetchResponse.json();
})
.then(function(response) {
var commitData = {
'first': {
message: response[0].commit.message,
author: response[0].commit.author.name,
time: response[0].commit.author.date,
link: response[0].html_url
},
'second': {
message: response[1].commit.message,
author: response[1].commit.author.name,
time: response[1].commit.author.date,
link: response[1].html_url
},
'third': {
message: response[2].commit.message,
author: response[2].commit.author.name,
time: response[2].commit.author.date,
link: response[2].html_url
},
'fourth': {
message: response[3].commit.message,
author: response[3].commit.author.name,
time: response[3].commit.author.date,
link: response[3].html_url
},
'fifth': {
message: response[4].commit.message,
author: response[4].commit.author.name,
time: response[4].commit.author.date,
link: response[4].html_url
}
};
container.querySelector('.first').innerHTML =
"<h4> Message: " + response[0].commit.message + "</h4>" +
"<h4> Author: " + response[0].commit.author.name + "</h4>" +
"<h4> Time committed: " + (new Date(response[0].commit.author.date)).toUTCString() + "</h4>" +
"<h4>" + "<a href='" + response[0].html_url + "'>Click me to see more!</a>" + "</h4>";
container.querySelector('.second').innerHTML =
"<h4> Message: " + response[1].commit.message + "</h4>" +
"<h4> Author: " + response[1].commit.author.name + "</h4>" +
"<h4> Time committed: " + (new Date(response[1].commit.author.date)).toUTCString() + "</h4>" +
"<h4>" + "<a href='" + response[1].html_url + "'>Click me to see more!</a>" + "</h4>";
container.querySelector('.third').innerHTML =
"<h4> Message: " + response[2].commit.message + "</h4>" +
"<h4> Author: " + response[2].commit.author.name + "</h4>" +
"<h4> Time committed: " + (new Date(response[2].commit.author.date)).toUTCString() + "</h4>" +
"<h4>" + "<a href='" + response[2].html_url + "'>Click me to see more!</a>" + "</h4>";
container.querySelector('.fourth').innerHTML =
"<h4> Message: " + response[3].commit.message + "</h4>" +
"<h4> Author: " + response[3].commit.author.name + "</h4>" +
"<h4> Time committed: " + (new Date(response[3].commit.author.date)).toUTCString() + "</h4>" +
"<h4>" + "<a href='" + response[3].html_url + "'>Click me to see more!</a>" + "</h4>";
container.querySelector('.fifth').innerHTML =
"<h4> Message: " + response[4].commit.message + "</h4>" +
"<h4> Author: " + response[4].commit.author.name + "</h4>" +
"<h4> Time committed: " + (new Date(response[4].commit.author.date)).toUTCString() + "</h4>" +
"<h4>" + "<a href='" + response[4].html_url + "'>Click me to see more!</a>" + "</h4>";
app.spinner.setAttribute('hidden', true); //hide spinner
})
.catch(function (error) {
console.error(error);
});
};
fetchCommits();
})();
複製程式碼
此外在你的latest.html
引入latest.js
<script src="./js/latest.js"></script>
複製程式碼
增加 loading 在你的latest.html
....
<div class="loader">
<svg viewBox="0 0 32 32" width="32" height="32">
<circle id="spinner" cx="16" cy="16" r="14" fill="none"></circle>
</svg>
</div>
<div class="toast__container"></div>
複製程式碼
在latest.js
你可以觀察到,我們從GitHub的API獲取到資料並將其附加到DOM中來,現在獲取後的頁面長這樣子了。
Latest.html 頁面
通過Service Workers預載入 app shell
為了確保我們的app快速載入和離線工作,我們需要快取app shell
通過service worker
- 首先,在根目錄中建立一個 service worker檔案。它的名字sw.js
- 第二,開啟app.js檔案和新增這段程式碼來實現service worker註冊使用
app.js
if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register('./sw.js')
.then(function() { console.log('Service Worker Registered'); });
}
複製程式碼
- 開啟
sw.js
檔案並新增這段程式碼
sw.js
var cacheName = 'pwa-commits-v3';
var filesToCache = [
'./',
'./css/style.css',
'./images/books.png',
'./images/Home.svg',
'./images/ic_refresh_white_24px.svg',
'./images/profile.png',
'./images/push-off.png',
'./images/push-on.png',
'./js/app.js',
'./js/menu.js',
'./js/offline.js',
'./js/toast.js'
];
// Install Service Worker
self.addEventListener('install', function(event) {
console.log('Service Worker: Installing....');
event.waitUntil(
// Open the Cache
caches.open(cacheName).then(function(cache) {
console.log('Service Worker: Caching App Shell at the moment......');
// Add Files to the Cache
return cache.addAll(filesToCache);
})
);
});
// Fired when the Service Worker starts up
self.addEventListener('activate', function(event) {
console.log('Service Worker: Activating....');
event.waitUntil(
caches.keys().then(function(cacheNames) {
return Promise.all(cacheNames.map(function(key) {
if( key !== cacheName) {
console.log('Service Worker: Removing Old Cache', key);
return caches.delete(key);
}
}));
})
);
return self.clients.claim();
});
self.addEventListener('fetch', function(event) {
console.log('Service Worker: Fetch', event.request.url);
console.log("Url", event.request.url);
event.respondWith(
caches.match(event.request).then(function(response) {
return response || fetch(event.request);
})
);
});
複製程式碼
就像我在這篇文章的前面部分所解釋的,我們所有的靜態資源都放到filesToCache
陣列裡面,當service worker被安裝時,它在瀏覽器中開啟快取並且陣列裡面所有檔案都被快取到pwa-commits-v3
這個快取裡面。一旦 service worker已經安裝,install
事件就會觸發。此階段確保您的service worker在任何應用殼檔案更改時更新其快取。fetch
事件階段應用殼從快取中獲取資料。
注意:為了更容易和更好的方式預先快取記憶體你的資源。檢查谷歌瀏覽器的sw-toolbox
和 sw-precachelibraries
現在過載你的 web app 並且開啟 DevTools
,到Application
選項去檢視Service Worker皮膚,確保Update on reload
這個選項是勾選的。如下圖
現在,重新載入Web頁面並檢查它。有離線瀏覽麼?
Index Page Offline
Yaaay!!! 首頁終於離線也是可以瀏覽了,那麼latest
頁面是不是顯示最新的提交呢?
Latest Page Offline
Yaaay!!!latest
已是離線服務。但是等一下!資料在哪裡?提交在哪裡?哎呀!我們的 app試圖請求Github API當使用者與Internet斷開連線時,它失敗了。
Data Fetch Failure, Chrome DevTools
我們該怎麼辦?處理這個場景有不同的方法。其中一個選項是告訴service worker提供離線頁面。另一種選擇是在第一次載入時快取提交資料,在後續請求中載入本地儲存的資料,然後在使用者連線時獲取最新的資料。提交的資料可以儲存在IndexedDB
或local Storage
。
好了,我們現在就此結束!
附上:
原文地址: https://auth0.com/blog/introduction-to-progressive-apps-part-one/
專案程式碼地址:https://github.com/unicodeveloper/pwa-commits
部落格文章:https://blog.naice.me/articles/5a31d20a78c3ad318b837f59
如果有那個地方翻譯出錯或者失誤,請各位大神不吝賜教,小弟感激不盡
期待下一篇: 介紹一下漸進式 Web App(即時載入) - Part 2