快取&PWA實踐

袋鼠雲數棧UED發表於2022-05-27

快取&PWA 實踐

一、背景

從上一篇《前端動畫實現與原理分析》,我們從 Performance 進行動畫的效能分析,並根據 Performance 分析來優化動畫。但,前端不僅僅是實現流暢的動畫。ToB 專案會經常與資料的儲存、渲染打交道。例如開發中,為了提高使用者體驗,遇到了一些場景,其實就是在利用某些手段,來進行效能優化。

  • Select 下拉:做前端分頁展示 → 避免一次性渲染資料造成瀏覽器的假死狀態;
  • indexedDB:儲存資料 → 使用者下一次進入時,儲存上一次編輯的狀態 ……

那不免引發思考,我們從快取與資料儲存來思考,該如何優化?

二、 HTTP 快取

是什麼?

Http 快取其實就是瀏覽器儲存通過 HTTP 獲取的所有資源,
是瀏覽器將網路資源儲存在本地的一種行為。

請求的資源在哪裡?
  1. 6.8kB + 200 狀態碼: 沒有命中快取,實際的請求,從伺服器上獲取資源;
  2. memory cache: 資源快取在記憶體中,不會請求伺服器,一般已經載入過該資源且快取在記憶體中,當關閉頁面時,此資源就被記憶體釋放掉了;
  3. disk cache: 資源快取在磁碟中,不會請求伺服器,但是該資源也不會隨著關閉頁面就釋放掉;
  4. 304 狀態碼:請求伺服器,發現資源沒有被更改,返回 304 後,資源從本地取出;
  5. service worker: 應用級別的儲存手段;

HTTP 快取分類

1. 強快取
  1. 瀏覽器載入資源時,會先根據本地快取資源的 header 中的資訊判斷是否命中強快取。如果命中,則不會像伺服器傳送請求,而是直接從快取中讀取資源。
  2. 強快取可以通過設定 HTTP Header 來實現:

http1.0 → Expires: 響應頭包含日期/時間, 即在此時候之後,響應過期。
http1.1 → Cache-Control:max-age=: 設定快取儲存的最大週期,超過這個時間快取被認為過期(單位秒)。與Expires相反,時間是相對於請求的時間

? Cache-control

  • cache-control: max-age=3600 :表示相對時間
  • cache-control:no-cache → 可以儲存在本地快取的,只是在原始伺服器進行新鮮度在驗證之前,快取不能將其提供給客戶端使用
  • cache-control: no-store → 禁止快取對響應進行復制,也就是真正的不快取資料在本地;
  • catch-control:public → 可以被所有使用者快取(多使用者共享),包括終端、CDN 等
  • cache-control: private → 私有快取

    2. 協商快取
    1. 當瀏覽器對某個資源的請求沒有命中強快取,就會發一個請求到伺服器,驗證協商快取是否命中,如果協商快取命中,請求返回的 http 狀態 304,並且會顯示 Not Modified 的字串;
    2. 協商快取通過【last-Modified,if-Modified-Since】與【ETag, if-None-Match】來管理的。

  • 「Last-Modified、If-Modified-Since」

last-Modified : 表示本地檔案最後修改的日期,瀏覽器會在請求頭中帶上 if-Modified-since(也是上次返回的 Last-Modified 的值),伺服器會將這個值與資源修改的時間進行匹配,如果時間不一致,伺服器會返回新的資源,並且將 Last-modified 值更新,並作為響應頭返回給瀏覽器。如果時間一致,表示資源沒有更新,伺服器會返回 304 狀態,瀏覽器拿到響應狀態碼後從本地快取中讀取資源。

  • ETag、If-None-Match」

http 1.1 中, 伺服器通過 Etag 來設定響應頭快取標示。Etag 是由伺服器來生成的。

第一次請求時,伺服器會將資源和 ETag 一併返回瀏覽器,瀏覽器將兩者快取到本地快取中。

第二次請求時,瀏覽器會將 ETag 的值放到 If-None-Match 請求頭去訪問伺服器,伺服器接收請求後,會將伺服器中的檔案標識與瀏覽器發來的標識進行比對,如果不同, 伺服器會返回更新的資源和新的 Etag,如果相同,伺服器會返回 304 狀態碼,瀏覽器讀取快取。

? 思考為什麼有了 Last-Modified 這一對兒,還需要 Etag 來標識是否過期進行命中協商快取

  1. 檔案的週期性更改,但是檔案的內容沒有改變,僅僅改變了修改時間,這個時候,並不希望客戶端認為該檔案被修改了,而重新獲取。
  2. 如果檔案檔案頻繁修改,比如 1s 改了 N 次,If-Modified-Since 無法判斷修改的,會導致檔案已經修改但是獲取的資源還是舊的,會存在問題。
  3. 某些伺服器不能精確得到檔案的最後修改時間,導致資源獲取的問題。

⚠️  如果 Etag 與 Last-Modified 同時存在,伺服器會先檢查 ETag,然後在檢查 Last-Modified, 最終確定是返回 304 或 200。

HTTP 快取實踐

測試環境: 利用 Koa,搭建一個 node 服務,用來測試如何命中強快取還是協商快取

當 index.js 沒有配置任何關於快取的配置時, 無論怎麼重新整理 chrome,都沒有快取機制的;

  • 注意⚠️:在開始實驗之前要把 network 皮膚的 Disable cache 勾選去掉,這個選項表示禁用瀏覽器快取,瀏覽器請求會帶上 Cache-Control: no-cache 和 Pragma: no-cache 頭部資訊。
1. 命中強快取
app.use(async (ctx) => {
    // ctx.body = 'hello Koa'
    const url = ctx.request.url;
    if(url === '/'){
        // 訪問跟路徑返回 index.html
        ctx.set('Content-type', 'text/html');
        ctx.body = await parseStatic('./index.html')
    }else {
        ctx.set('Content-Type', parseMime(url))
        ctx.set('Expires', new Date(Date.now() + 10000).toUTCString()) // 實驗1
        ctx.set('Cache-Control','max-age=20') // 實驗2
        ctx.body = await parseStatic(path.relative('/', url))
    }
})

app.listen(3000, () => {
    console.log('starting at port 3000')
})
2. 命中協商快取
         /**
         * 命中協商快取
         * 設定 Last-Modified, If-Modified-Since
         */
         ctx.set('cache-control', 'no-cache'); // 關閉強快取
         const isModifiedSince = ctx.request.header['if-modified-since'];
         const fileStat = await getFileStat(filePath);
         if(isModifiedSince === fileStat.mtime.toGMTString()){
             ctx.status = 304
         }else {
             ctx.set('Last-Modified', fileStat.mtime.toGMTString())
         }  
         ctx.body = await parseStatic(path.relative('/', url))

        /**
         * 命中協商快取
         * 設定 Etag, If-None-Match
         */
         ctx.set('cache-control', 'no-cache');
         const ifNoneMatch = ctx.request.headers['if-none-match'];
         const fileBuffer = await parseStatic(filePath);
         const hash = crypto.createHash('md5');
         hash.update(fileBuffer);
         const etag = `"${hash.digest('hex')}"`
         if (ifNoneMatch === etag) {
            ctx.status = 304
          } else {
            ctx.set('Etag', etag)
            ctx.body = fileBuffer
          }
    }

三、 瀏覽器快取

1.Cookies

  • MDN 定義: 是伺服器傳送到使用者瀏覽器並報訊在本地的一小塊資料,他會在瀏覽器下次想統一伺服器再次傳送請求時被攜帶併傳送到伺服器上。
  • 應用場景:

    • 會話狀態管理【使用者登陸狀態,購物車,遊戲分數或其他需要記錄的資訊】
    • 個性化設定(如使用者自定義設定、主題等)
    • 瀏覽器行為跟蹤(如跟蹤分析使用者行為等)
  • cookie 的讀取與寫入:
class Cookie {
    getCookie: (name) => {
        const reg = new RegExp('(^| )'+name+'=([^;]*)')
        let match = document.cookie.match(reg); //  [全量,空格,value,‘;’]
        if(match) {return decodeURI(match[2])}
    }
    setCookie:(key,value,days,domain) => {
        // username=John Smith; expires=Thu, 18 Dec 2043 12:00:00 GMT; path=/
      let d = new Date();
    d.setTime(d.getTime()+(days*24*60*60*1000));
    let expires = "; expires="+d.toGMTString();
        let domain = domain ? '; domain='+domain : '';
        document.cookie = name + '=' + value + expires + domain + '; path=/'
        
    }
    deleteCookie: (name: string, domain?: string, path?: string)=> {
        // 刪除cookie,只需要將時間設定為過期時間,而無需刪除cookie的value
        const d = new Date(0);
        domain = domain ? `; domain=${domain}` : '';
        path = path || '/';
        document.cookie =
            name + '=; expires=' + d.toUTCString() + domain + '; path=' + path;
    },
}
  • 存在的問題: 由於通過 Cookie 儲存的資料,每次請求都會攜帶在請求頭。對與一些資料是無需交給提交後端的,這個不免會帶來額外的開銷。

2.WebStorage API

瀏覽器能以一種比使用 Cookie 更直觀的方式儲存鍵值對

localStorage

為每一個給定的源(given origin)維持一個獨立的儲存區域,瀏覽器關閉,然後重新開啟後資料仍然存在。

sessionStorage

為每一個給定的源(given origin)維持一個獨立的儲存區域,該儲存區域在頁面會話期間可用(即只要瀏覽器處於開啟狀態,包括頁面重新載入和恢復)。

3.indexedDB 與 webSQL

webSQL

基本操作與實際資料庫操作基本一致。
最終的資料去向,一般只是做臨時儲存和大型網站的業務執行儲存快取的作用,頁面重新整理後該庫就不存在了。而其本身與關聯式資料庫的概念比較相似。

indexedDB

隨著瀏覽器的功能不斷增強,越來越多的網站開始考慮,將大量資料儲存在客戶端,這樣可以減少從伺服器獲取資料,直接從本地獲取資料。現有的瀏覽器資料儲存方案,都不適合儲存大量資料;

IndexedDB 是瀏覽器提供的本地資料庫, 允許儲存大量資料,提供查詢介面,還能建立索引。這些都是 LocalStorage 所不具備的。就資料庫型別而言,IndexedDB 不屬於關係型資料庫(不支援 SQL 查詢語句),更接近 NoSQL 資料庫。


四、應用程式快取

Service Worker

在提及 Service Worker 之前,需要對 web Worker 有一些瞭解;

webWorker : Web Worker 是瀏覽器內建的執行緒所以可以被用來執行非阻塞事件迴圈的 JavaScript 程式碼。 js 是單執行緒,一次只能完成一件事,如果出現一個複雜的任務,執行緒就會被阻塞,嚴重影響使用者體驗, Web Worker 的作用就是允許主執行緒建立 worker 執行緒,與主執行緒同時進行。worker 執行緒只需負責複雜的計算,然後把結果返回給主執行緒就可以了。簡單的理解就是,worker 執行緒執行復雜計算並且頁面(主執行緒)ui 很流暢,不會被阻塞。


Service Worker 是瀏覽器和網路之間的虛擬代理。其解決了如何正確快取往後網站資源並使其在離線時可用的問題。

Service Worker 執行在一個與頁面 js 主執行緒獨立的執行緒上,並且無權訪問 DOM 結構。他的 API 是非阻塞的,並且可以在不同的上下文之間傳送和接收訊息。

他不僅僅提供離線功能,還可以做包括處理通知、在單獨的執行緒上執行繁重的計算等事務。Service Workers 非常強大,因為他們可以控制網路請求,修改網路請求,返回快取的自定義響應或者合成響應。

2.PWA

? PWA,全稱 Progressive web apps,即漸進式 Web 應用。PWA 技術主要作用為構建跨平臺的 Web 應用程式,並使其具有與原生應用程式相同的使用者體驗。
? PWA 的核心: 最根本、最基本的,就是 Service Worker 以及在其內部使用的 Cache API。只要通過 Service Worker 與 Cache API,實現了對網站頁面的快取、對頁面請求的攔截、對頁面快取的操縱 。

為什麼使用 PWA:

傳統的 Web 存在的問題:

  1. 缺乏直接入口,需要記住他的域名,或者是儲存在收藏夾,尋找起來不夠方便;
  2. 依賴於網路。只要客戶端處於斷網的狀態,整個 web 就處於癱瘓狀態,客戶端無法使用;
  3. 無法像 Native APP 推送訊息。

傳統 Native APP 存在的問題:

  1. 需要安裝與下載。哪怕只是使用 APP 的某個功能,也是需要全盤下載的;
  2. 開發成本高,一般需要相容安卓與 IOS 系統;
  3. 釋出需要稽核;
  4. 更新成本高……

PWA 的存在,就是為了解決以上問題所帶來的麻煩:
優勢:

  1. 桌面入口,開啟便捷;
  2. 離線可用,不用過度依賴網路;
  3. 安裝方便;
  4. 一次性開發,無需稽核,所有平臺可用;
  5. 能夠進行訊息推送
  6. Web App Manifest Web App Manifest(Web 應用程式清單)概括地說是一個以 JSON 形式集中書寫頁面相關資訊和配置的檔案。
{
  "short_name": "User Mgmt",
  "name": "User Management",
  "icons": [
    {
      "src": "favicon.ico",
      "sizes": "64x64 32x32 24x24 16x16",
      "type": "image/x-icon"
    },
    {
      "src": "logo192.png",
      "type": "image/png",
      "sizes": "192x192"
    },
    {
      "src": "logo512.png",
      "type": "image/png",
      "sizes": "512x512"
    }
  ],
  "start_url": ".", // 調整網站的起始連結
  "display": "standalone", // 設定網站提示模式 : standalone 表示隱藏瀏覽器的UI
  "theme_color": "#000000", // 設定網站每個頁面的主題顏色
  "background_color": "#ffffff" // 設定背景顏色
}
  • ServiceWorker.js 程式碼
/* eslint-disable no-restricted-globals */

// 確定哪些資源需要快取
const CACHE_VERSION = 0;
const CACHE_NAME = 'cache_v' + CACHE_VERSION;
const CACHE_URL = [
  '/',
  'index.html',
  'favicon.ico',
  'serviceWorker.js',
  'static/js/bundle.js',
  'manifest.json',
  'users'
]
const preCache = () => {
  return caches
    .open(CACHE_NAME)
    .then((cache) => {
      return cache.addAll(CACHE_URL)
    })
}
const clearCache = () => {
  return caches.keys().then(keys => {
    keys.forEach(key => {
      if (key !== CACHE_NAME) {
        caches.delete(key)
      }
    })
  })
}
// 進行快取
self.addEventListener('install', (event) => {
  event.waitUntil(
    preCache()
  )
})

// 刪除舊的快取
self.addEventListener('activated', (event) => {
  event.waitUntil(
    clearCache()
  )
})

console.log('hello, service wold');

self.addEventListener('fetch', (event) => {
  console.log('request:', event.request.url)
  event.respondWith(
    fetch(event.request).catch(() => { // 優先網路請求,如果失敗,則從快取中拿資源
      return caches.match(event.request)
    })
  )
})
  • PWA 除錯

當離線的時候依然拿到快取的資源,並且正常展示,可以看出資源被 serviceWorker 快取。

藉助開發者工具:
chrome devtools : chrome://inspect/#service-workers ,可以展示當前裝置上啟用和儲存的 service worker

五、總結與思考

參考優秀網站:

  1. 語雀: https://www.yuque.com/dashboard
  2. PWA 例子: https://mdn.github.io/pwa-examples/js13kpwa/

相關文章