/

如何使基於CMS的網站離線工作

如何使基於CMS的網站離線工作

我如何在現代設備上瀏覽時逐步增強網站的能力

本案例研究說明了如何通過引入一組被稱為漸進式Web應用程序(特別是Service WorkersCache API)的技術來向基於Grav的網站(一款非常適合開發人員的PHP-based CMS)添加離線工作功能。

當我們完成時,我們將能夠在移動設備或桌面上使用我們的網站,即使離線,如下圖所示(注意網絡緩存設置中的“離線”選項)

漸進式Web應用程序

第一个方法:首先緩存

我首先采用了一种首先緩存的方法。簡單來說,當我們在服務工作器中攔截一個fetch請求時,我們首先檢查我們是否已經將其緩存。如果没有,则從網絡獲取。

這樣做的好處是,當加載已緩存的頁面時,網站變得非常快速,即使在線(尤其是在網絡緩慢和降低連接質量的情況下),但當我發送新內容時,也引入了一些複雜性。

這不會是我採用的最終解決方案,但是出於演示目的,值得一試。

我將過程分為幾個階段:

  1. 我引入了一個服務工作器並將其作為網站JS腳本的一部分加載。服務工作器正常工作
  2. 當安裝服務工作器時,我緩存網站的骨架
  3. 我攔截指向其他鏈接的請求,將其緩存

引入服務工作器

我在站點根目錄下的sw.js文件中添加了服務工作器。這使它可以在所有站點子文件夾和站點主目錄上運行。該服務工作器目前非常基本,只是記錄任何網絡請求:

1
2
3
self.addEventListener('fetch', (event) => {
console.log(event.request)
})

我需要註冊服務工作器,我從一個包含在每個頁面中的腳本中進行註冊:

1
2
3
4
5
6
7
8
9
10
11
12
13
window.addEventListener('load', () => {
if (!navigator.serviceWorker) {
return
}

navigator.serviceWorker.register('/sw.js', {
scope: '/'
}).then(() => {
//...ok
}).catch((err) => {
console.log('registration failed', err)
})
})

如果服務工作器可用,我們就註冊sw.js文件,並且在下一次刷新頁面時應該可以正常工作:

服務工作器正常工作

現在,我需要在網站上進行一些重活。首先,我需要找到一種方法來只提供應用殼:一組基本的HTML + CSS和JS,即使離線,它也將始終可用並顯示給用戶。

這基本上是網站的精簡版本,其中包含一個<div class="wrapper row" id="content-wrapper"></div>空元素,稍後我們將填充內容,這些內容在/shell路由下可用:

應用殼

所以當用戶首次加載網站時,將顯示正常版本(完整HTML版本),並安裝服務工作器。

現在單擊的任何其他頁面都會被我們的服務工作器攔截。每次加載頁面時,我們首先加載殼層,然後加載頁面的簡化版本,不包括殼層,僅包含內容。

如何實現呢?

我們聽取install事件,該事件在安裝或更新服務工作器時觸發。當這發生時,我們使用shell的內容初始化緩存:基本的HTML布局,加上一些CSS、JS和一些外部資源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const cacheName = 'writesoftware-v1'

self.addEventListener('install', (event) => {
event.waitUntil(caches.open(cacheName).then(cache => cache.addAll([
'/shell',
'user/themes/writesoftware/favicon.ico',
'user/themes/writesoftware/css/style.css',
'user/themes/writesoftware/js/script.js',
'https://fonts.googleapis.com/css?family=Press+Start+2P',
'https://fonts.googleapis.com/css?family=Inconsolata:400,700',
'https://cdnjs.cloudflare.com/ajax/libs/prism/1.6.0/themes/prism.min.css',
'https://cdnjs.cloudflare.com/ajax/libs/prism/1.6.0/prism.min.js',
'https://cdn.jsdelivr.net/prism/1.6.0/components/prism-jsx.min.js'
])))
})

然後,當我們執行一個fetch時,我們攔截發送到我們頁面的請求,並從緩存中獲取殼層,而不是從網絡上獲取。

使用歷史API修復URL、標題和返回按鈕

script.js文件中,我向創建每個頁面包含的腳本添加了一個重要功能:每當頁面上的鏈接被單擊時,我攔截它,然後向廣播頻道發送一條消息。

因為目前只有Chrome、Firefox和Opera支持Service Workers,所以我可以在這種情況下安全地依賴BroadcastChannel API

Service workers browsers support

首先,我連接到ws_navigation頻道並在上面附加onmessage事件處理程序。每當我收到一個事件,這是一個從服務工作器來與App Shell中的新內容通信,於是我僅查找具有idcontent-wrapper的元素,並將部分頁面內容置於其中,從而有效地更改用戶所看到的頁面。

一旦註冊了服務工作器,我就向此頻道發送一條消息,其中包含一個fetchPartial任務和要提取的部分頁面URL。這是初始頁面加載的內容。

由於應用程序殼層在服務工作器首次安裝時立即加載,因為它始終被緩存,所以很快它被實際的內容替換了,而內容也可能被緩存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
window.addEventListener('load', () => {
if (!navigator.serviceWorker) { return }
const channel = new BroadcastChannel('ws_navigation')

channel.onmessage = (event) => {
if (document.getElementById('content-wrapper')) {
document.getElementById('content-wrapper').innerHTML = event.data.content
}
}

navigator.serviceWorker.register('/sw.js', {
scope: '/'
}).then(() => {
channel.postMessage({
task: 'fetchPartial',
url: `${window.location.pathname}?partial=true`
})
}).catch((err) => {
console.log('SW registration failed', err)
})
})

缺少的部分是處理頁面上的點擊。當單擊鏈接時,我攔截該事件,停止它,然後向服務工作器發送一條消息,以提取帶有該URL的部分。

在提取部分時,我向其URL附加一個?partial=true的查詢,以告訴我的後端僅提供內容,而不是殼層。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
window.addEventListener('load', () => {

//...

window.onclick = (e) => {
let node = e.target
while (node !== undefined && node !== null && node.localName !== 'a') {
node = node.parentNode
}
if (node !== undefined && node !== null) {
channel.postMessage({
task: 'fetchPartial',
url: `${node.href}?partial=true`
})
return false
}
return true
}
})

現在,我們只需要處理此事件。在服務工作器端,我連接到ws_navigation頻道並聽取事件。我聽取fetchPartial消息任務名稱,雖然我可以避免進行此條件檢查,因為這是唯一一個在此處發送的事件(Broadcast Channel API中的消息不會被調度到發起它們的同一頁面,而只在頁面和Web Worker之間調度)。

我檢查該url是否已緩存。如果是,我只是把它作為一條回复消息發送到頻道上,然後返回。

如果它沒有被緩存,我會提取它,將其作為消息發送回頁面,然後將其緩存以供下次可能訪問它時使用。

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
const channel = new BroadcastChannel('ws_navigation')
channel.onmessage = (event) => {
if (event.data.task === 'fetchPartial') {
caches
.match(event.data.url)
.then((response) => {
if (response) {
response.text().then((body) => {
channel.postMessage({ url: event.data.url, content: body })
})
return
}

fetch(event.data.url).then((fetchResponse) => {
const fetchResponseClone = fetchResponse.clone()
fetchResponse.text().then((body) => {
channel.postMessage({ url: event.data.url, content: body })
})

caches.open(cacheName).then((cache) => {
cache.put(event.data.url, fetchResponseClone)
})
})
})
.catch((error) => {
console.error(error)
})
}
}

我們幾乎已經完成了。

現在,服務工作器在用戶訪問時安裝在網站上,並且後續的頁面加載通過Fetch API動態處理,不需要完整的頁面加載。在首次訪問後,頁面被緩存並且加載速度非常快,更重要的是,即使離線,它們還是加載!

而且-所有這一切都是一種漸進式增強。舊版本的瀏覽器以及不支持服務工作器的瀏覽器仍然可以正常工作。

現在,劫持瀏覽器導航給我們帶來了一些問題:

  1. 當顯示新頁面時, URL必須更改。返回按鈕應正常工作,瀏覽器歷史記錄也應該工作
  2. 頁面標題必須更改以反映新的頁面標題
  3. 我們需要通知Google Analytics API,已加載一個新頁面,以避免錯過重要指標,如每位訪問者的頁面訪問量。
  4. 加載新內容時,代碼片段不再高亮顯示

讓我們解決這些挑戰。

使用歷史API修復URL、標題和返回按鈕

script.js文件的消息處理程序中,除了注入部分HTML外,我還觸發History APIhistory.pushState()方法:

1
2
3
4
5
6
7
channel.onmessage = (event) => {
if (document.getElementById('content-wrapper')) {
document.getElementById('content-wrapper').innerHTML = event.data.content
const url = event.data.url.replace('?partial=true', '')
history.pushState(null, null, url)
}
}

這有效,但是頁面標題在瀏覽器UI中沒有更改。我們需要將其從頁面上某處提取出來。我決定在部分頁面中放入一個隱藏的span,保存頁面標題,以便我們可以使用DOM API從頁面中提取它並設置document.title屬性:

1
2
3
4
5
6
7
8
9
10
channel.onmessage = (event) => {
if (document.getElementById('content-wrapper')) {
document.getElementById('content-wrapper').innerHTML = event.data.content
const url = event.data.url.replace('?partial=true', '')
if (document.getElementById('browser-page-title')) {
document.title = document.getElementById('browser-page-title').innerHTML
}
history.pushState(null, null, url)
}
}

修復Google Analytics

Google Analytics在開箱即用時運作正常,但在動態加載頁面時就不能如何了。我們必須使用它提供的API來通知它新頁面已加載。由於我使用的是全局網站標籤(gtag.js)追踪,我需要調用:

1
gtag('config', 'UA-XXXXXX-XX', {'page\_path': '/the-url'})

在處理更改頁面的代碼中插入上面的代碼:

1
2
3
4
5
6
7
8
9
10
11
channel.onmessage = (event) => {
if (document.getElementById('content-wrapper')) {
document.getElementById('content-wrapper').innerHTML = event.data.content
const url = event.data.url.replace('?partial=true', '')
if (document.getElementById('browser-page-title')) {
document.title = document.getElementById('browser-page-title').innerHTML
}
history.pushState(null, null, url)
gtag('config', 'UA-1739509-49', {'page\_path': url})
}
}

我在頁面上要修復的最後一個問題是代碼片段的高亮顯示。我使用Prism語法高亮顯示器來完成這項任務,它非常簡單,我只需在我的onmessage處理程序中添加一個Prism.highlightAll()調用即可:

1
2
3
4
5
6
7
8
9
10
11
12
channel.onmessage = (event) => {
if (document.getElementById('content-wrapper')) {
document.getElementById('content-wrapper').innerHTML = event.data.content
const url = event.data.url.replace('?partial=true', '')
if (document.getElementById('browser-page-title')) {
document.title = document.getElementById('browser-page-title').innerHTML
}
history.pushState(null, null, url)
gtag('config', 'UA-XXXXXX-XX', {'page\_path': url})
Prism.highlightAll()
}
}

script.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', () => {
if (!navigator.serviceWorker) { return }
const channel = new BroadcastChannel('ws_navigation')

channel.onmessage = (event) => {
if (document.getElementById('content-wrapper')) {
document.getElementById('content-wrapper').innerHTML = event.data.content
const url = event.data.url.replace('?partial=true', '')
if (document.getElementById('browser-page-title')) {
document.title = document.getElementById('browser-page-title').innerHTML
}
history.pushState(null, null, url)
gtag('config', 'UA-XXXXXX-XX', {'page\_path': url})
Prism.highlightAll()
}
}

navigator.serviceWorker.register('/sw.js', {
scope: '/'
}).then(() => {
channel.postMessage({
task: 'fetchPartial',
url: `${window.location.pathname}?partial=true`
})
}).catch((err) => {
console.log('SW registration failed', err)
})

window.onclick = (e) => {
let node = e.target
while (node !== undefined && node !== null && node.localName !== 'a') {
node = node.parentNode
}
if (node !== undefined && node !== null) {
channel.postMessage({
task: 'fetchPartial',
url: `${node.href}?partial=true`
})
return false
}
return true
}
})

sw.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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
const cacheName = 'writesoftware-v1'

self.addEventListener('install', (event) => {
event.waitUntil(caches.open(cacheName).then(cache => cache.addAll([
'/shell',
'user/themes/writesoftware/favicon.ico',
'user/themes/writesoftware/css/style.css',
'user/themes/writesoftware/js/script.js',
'https://fonts.googleapis.com/css?family=Press+Start+2P',
'https://fonts.googleapis.com/css?family=Inconsolata:400,700',
'https://cdnjs.cloudflare.com/ajax/libs/prism/1.6.0/themes/prism.min.css',
'https://cdnjs.cloudflare.com/ajax/libs/prism/1.6.0/prism.min.js',
'https://cdn.jsdelivr.net/prism/1.6.0/components/prism-jsx.min.js'
])))
})

self.addEventListener('fetch', (event) => {
const requestUrl = new URL(event.request.url)

if (requestUrl.href.startsWith('https://www.googletagmanager.com') ||
requestUrl.href.startsWith('https://www.google-analytics.com') ||
requestUrl.href.startsWith('https://assets.convertkit.com')) {
// don't cache, and no cors
event.respondWith(fetch(event.request.url, { mode: 'no-cors' }))
return
}

event.respondWith(caches.match(event.request)
.then((response) => {
if (response) { return response }
if (requestUrl.origin === location.origin) {
if (requestUrl.pathname.endsWith('?partial=true')) {
return fetch(requestUrl.pathname)
} else {
return caches.match('/shell')
}

return fetch(`${event.request.url}?partial=true`)
}
return fetch(event.request.url)
})
.then(response => caches.open(cacheName).then((cache) => {
cache.put(event.request.url, response.clone())
return response
}))
.catch((error) => {
console.error(error)
}))
})

const channel = new BroadcastChannel('ws_navigation')
channel.onmessage = (event) => {
if (event.data.task === 'fetchPartial') {
caches
.match(event.data.url)
.then((response) => {
if (response) {
response.text().then((body) => {
channel.postMessage({ url: event.data.url, content: body })
})
return
}

fetch(event.data.url).then((fetchResponse) => {
const fetchResponseClone = fetchResponse.clone()
fetchResponse.text().then((body) => {
channel.postMessage({ url: event.data.url, content: body })
})

caches.open(cacheName).then((cache) => {
cache.put(event.data.url, fetchResponseClone)
})
})
})
.catch((error) => {
console.error(error)
})
}
}

第二個方法:以網路為先,丟棄應用殼

儘管第一種方法為我們提供了一個完全可工作的應用程序,但對於在客戶端上將某個頁面的副本緩存太長時間,我有些抱擔,因此,我決定採用以網路為先的方法:當用戶加載一個頁面時,首先從網絡獲取該頁面。

如果由於某種原因,網絡請求失敗,並且在緩存中找不到該頁面,則向用戶顯示GIF,如果完全離線,則顯示另一個GIF,如果頁面不存在(即使可以到達,但出現404錯誤)。

一旦獲得頁面,我們將其緩存在(不檢查先前是否已緩存過它,只是存儲最新版本)。

作為一個實驗,我還完全放棄了應用殼,因為在我的情況下,我沒有意圖創建一個可安裝的應用程序,因為我沒有一個最新的Android設備來進行測試,我寧願避免在沒有正確測試的情況下丟出東西。

為此,我只是從install服務工作器事件中刪除了應用殼,並且依靠服務工作器和緩存API僅提供站點的普通頁面,而無需管理部分更新。在加載完整頁面時,我也放棄了對/shell的fetch劫持,因此,在第一個頁面加載時無需等待,但我們稍後瀏覽到其他頁面時仍然加載部分。

我仍然使用script.jssw.js來托管代碼,其中script.js是初始化服務工作器的文件,並且還攔截客戶端的單擊事件。

以下是script.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
44
45
46
47
const OFFLINE_GIF = '/user/themes/writesoftware/img/offline.gif'

const fetchPartial = (url) => {
fetch(`${url}?partial=true`)
.then((response) => {
response.text().then((body) => {
if (document.getElementById('content-wrapper')) {
document.getElementById('content-wrapper').innerHTML = body
if (document.getElementById('browser-page-title')) {
document.title = document.getElementById('browser-page-title').innerHTML
}
history.pushState(null, null, url)
gtag('config', 'UA-XXXXXX-XX', { page_path: url })
Prism.highlightAll()
}
})
})
.catch(() => {
if (document.getElementById('content-wrapper')) {
document.getElementById('content-wrapper').innerHTML = `<center><h2>Offline</h2><img src="${OFFLINE_GIF}" /></center>`
}
})
}

window.addEventListener('load', () => {
if (!navigator.serviceWorker) { return }

navigator.serviceWorker.register('/sw.js', {
scope: '/'
}).then(() => {
fetchPartial(window.location.pathname)
}).catch((err) => {
console.log('SW registration failed', err)
})

window.onclick = (e) => {
let node = e.target
while (node !== undefined && node !== null && node.localName !== 'a') {
node = node.parentNode
}
if (node !== undefined && node !== null) {
fetchPartial(node.href)
return false
}
return true
}
})

以下是sw.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
44
45
46
47
48
49
50
51
52
53
const CACHE_NAME = 'writesoftware-v1'
const OFFLINE_GIF = '/user/themes/writesoftware/img/offline.gif'
const PAGENOTFOUND_GIF = '/user/themes/writesoftware/img/pagenotfound.gif'

self.addEventListener('install', (event) => {
event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.addAll([
'/user/themes/writesoftware/favicon.ico',
'/user/themes/writesoftware/css/style.css',
'/user/themes/writesoftware/js/script.js',
'/user/themes/writesoftware/img/offline.gif',
'/user/themes/writesoftware/img/pagenotfound.gif',
'https://fonts.googleapis.com/css?family=Press+Start+2P',
'https://fonts.googleapis.com/css?family=Inconsolata:400,700',
'https://cdnjs.cloudflare.com/ajax/libs/prism/1.6.0/themes/prism.min.css',
'https://cdnjs.cloudflare.com/ajax/libs/prism/1.6.0/prism.min.js',
'https://cdn.jsdelivr.net/prism/1.6.0/components/prism-jsx.min.js'
])))
})

self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return
if (event.request.headers.get('accept').indexOf('text/html') === -1) return

const requestUrl = new URL(event.request.url)
let options = {}

if (requestUrl.href.startsWith('https://www.googletagmanager.com') ||
requestUrl.href.startsWith('https://www.google-analytics.com') ||
requestUrl.href.startsWith('https://assets.convertkit.com')) {
// no cors
options = { mode: 'no-cors' }
}

event.respondWith(fetch(event.request, options)
.then((response) => {
if (response.status === 404) {
return fetch(PAGENOTFOUND_GIF)
}
const resClone = response.clone()
return caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request.url, response)
return resClone
})
})
.catch(() => caches.open(CACHE_NAME).then(cache => cache.match(event.request.url)
.then((response) => {
if (response) {
return response
}
return fetch(OFFLINE_GIF)
})
.catch(() => fetch(OFFLINE_GIF)))))
})

簡化:無部分加載

我放棄了提取部分的鏈接的點擊攔截器,並且依靠服務工作器和緩存API僅提供站點的普通頁面,而無需管理部分更新:

script.js

1
2
3
4
5
6
7
8
window.addEventListener('load', () => {
if (!navigator.serviceWorker) { return }
navigator.serviceWorker.register('/sw.js', {
scope: '/'
}).catch((err) => {
console.log('SW registration failed', err)
})
})

sw.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
44
45
46
47
48
49
50
const CACHE_NAME = 'writesoftware-v1'
const OFFLINE_GIF = '/user/themes/writesoftware/img/offline.gif'
const PAGENOTFOUND_GIF = '/user/themes/writesoftware/img/pagenotfound.gif'

self.addEventListener('install', (event) => {
event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.addAll([
'/user/themes/writesoftware/favicon.ico',
'/user/themes/writesoftware/css/style.css',
'/user/themes/writesoftware/js/script.js',
'/user/themes/writesoftware/img/offline.gif',
'/user/themes/writesoftware/img/pagenotfound.gif',
'https://fonts.googleapis.com/css?family=Press+Start+2P',
'https://fonts.googleapis.com/css?family=Inconsolata:400,700',
'https://cdnjs.cloudflare.com/ajax/libs/prism/1.6.0/themes/prism.min.css',
'https://cdnjs.cloudflare.com/ajax/libs/prism/1.6.0/prism.min.js',
'https://cdn.jsdelivr.net/prism/1.6.0/components/prism-jsx.min.js'
])))
})

self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return
if (event.request.headers.get('accept').indexOf('text/html') === -1) return

const requestUrl = new URL(event.request.url)
let options = {}

if (requestUrl.href.startsWith('https://www.googletagmanager.com') ||
requestUrl.href.startsWith('https://www.google-analytics.com') ||
requestUrl.href.startsWith('https://assets.convertkit.com')) {
// no cors
options = { mode: 'no-cors' }
}

event.respondWith(fetch(event.request, options)
.then((response) => {
if (response.status === 404) {
return fetch(PAGENOTFOUND_GIF)
}
const resClone = response.clone()
return caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request.url, response)
return resClone
})
})
.catch(() => caches.open(CACHE_NAME).then(cache => cache.match(event.request.url)
.then((response) => {
return response || fetch(OFFLINE_GIF)
})
.catch(() => fetch(OFFLINE_GIF)))))
})

我認為這是在網站上添加離線功能的最簡單示例,同時保持事情簡單。任何類型的網站都可以在不太複雜的情況下添加這種服務工作器。

最終對我來說,這種方法並不足以是可行的,我最終實現了fetch部分更新版本。