如何使基於CMS的網站離線工作 我如何在現代設備上瀏覽時逐步增強網站的能力
本案例研究說明了如何通過引入一組被稱為漸進式Web應用程序(特別是Service Workers 和Cache API )的技術來向基於Grav的網站(一款非常適合開發人員的PHP-based CMS)添加離線工作功能。
當我們完成時,我們將能夠在移動設備或桌面上使用我們的網站,即使離線,如下圖所示(注意網絡緩存設置中的“離線”選項)
第一个方法:首先緩存 我首先采用了一种首先緩存的方法。簡單來說,當我們在服務工作器中攔截一個fetch 請求時,我們首先檢查我們是否已經將其緩存。如果没有,则從網絡獲取。
這樣做的好處是,當加載已緩存的頁面時,網站變得非常快速,即使在線(尤其是在網絡緩慢和降低連接質量 的情況下),但當我發送新內容時,也引入了一些複雜性。
這不會是我採用的最終解決方案,但是出於演示目的,值得一試。
我將過程分為幾個階段:
我引入了一個服務工作器並將其作為網站JS腳本的一部分加載。
當安裝服務工作器時,我緩存網站的骨架
我攔截指向其他鏈接的請求,將其緩存
引入服務工作器 我在站點根目錄下的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 。
首先,我連接到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動態處理,不需要完整的頁面加載。在首次訪問後,頁面被緩存並且加載速度非常快,更重要的是,即使離線,它們還是加載!
而且-所有這一切都是一種漸進式增強。舊版本的瀏覽器以及不支持服務工作器的瀏覽器仍然可以正常工作。
現在,劫持瀏覽器導航給我們帶來了一些問題:
當顯示新頁面時, URL必須更改。返回按鈕應正常工作,瀏覽器歷史記錄也應該工作
頁面標題必須更改以反映新的頁面標題
我們需要通知Google Analytics API,已加載一個新頁面,以避免錯過重要指標,如每位訪問者的頁面訪問量。
加載新內容時,代碼片段不再高亮顯示
讓我們解決這些挑戰。
使用歷史API修復URL、標題和返回按鈕 在script.js
文件的消息處理程序中,除了注入部分HTML外,我還觸發History API 的history.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.js
和sw.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部分更新版本。