Webpack實戰-構建離線應用

浩麟發表於2017-12-23

認識離線應用

你的網頁效能優化的再好,如果網路不好那也會導致網頁的體驗差。 離線應用是指通過離線快取技術,讓資源在第一次被載入後快取在本地,下次訪問它時就直接返回本地的檔案,就算沒有網路連線。

離線應用有以下優點:

  • 在沒有網路的情況下也能開啟網頁。
  • 由於部分被快取的資源直接從本地載入,對使用者來說可以加速網頁載入速度,對網站運營者來說可以減少伺服器壓力以及傳輸流量費用。

離線應用的核心是離線快取技術,歷史上曾先後出現2種離線離線快取技術,它們分別是:

  1. AppCache 又叫 Application Cache,目前已經從 Web 標準中刪除,請儘量不要使用它。
  2. Service Workers 是目前最新的離線快取技術,是 Web Worker 的一部分。 它通過攔截網路請求實現離線快取,比 AppCache 更加靈活。它也是構建 PWA 應用的關鍵技術之一。

Service Workers 相比於 AppCache 來說更加靈活,因為它可以通過 JavaScript 程式碼去控制快取的邏輯。 由於第1種技術已經廢棄,本節只專注於講解如何用 Webpack 構建使用了 Service Workers 的網頁。

認識 Service Workers

Service Workers 是一個在瀏覽器後臺執行的指令碼,它生命週期完全獨立於網頁。它無法直接訪問 DOM,但可以通過 postMessage 介面傳送訊息來和 UI 程式通訊。 攔截網路請求是 Service Workers 的一個重要功能,通過它能完成離線快取、編輯響應、過濾響應等功能。

想更深入的瞭解 Service Workers,推薦閱讀文章服務工作執行緒:簡介

Service Workers 相容性

目前 Chrome、Firefox、Opera 都已經全面支援 Service Workers,但對於移動端瀏覽器就不太樂觀了,只有高版本的 Android 支援。 由於 Service Workers 無法通過注入 polyfill 去實現相容,所以在你打算使用它前請先調查清楚你的網頁的執行場景。

判斷瀏覽器是否支援 Service Workers 的最簡單的方法是通過以下程式碼:

// 如果 navigator 物件上存在 serviceWorker 物件,就表示支援
if (navigator.serviceWorker) {
  // 通過 navigator.serviceWorker 使用
}
複製程式碼

註冊 Service Workers

要給網頁接入 Service Workers,需要在網頁載入後註冊一個描述 Service Workers 邏輯的指令碼。 程式碼如下:

if (navigator.serviceWorker) {
  window.addEventListener('DOMContentLoaded',function() {
    // 呼叫 serviceWorker.register 註冊,引數 /sw.js 為指令碼檔案所在的 URL 路徑
      navigator.serviceWorker.register('/sw.js');
  });
}
複製程式碼

一旦這個指令碼檔案被載入,Service Workers 的安裝就開始了。這個指令碼被安裝到瀏覽器中後,就算使用者關閉了當前網頁,它仍會存在。 也就是說第一次開啟該網頁時 Service Workers 的邏輯不會生效,因為指令碼還沒有被載入和註冊,但是以後再次開啟該網頁時指令碼里的邏輯將會生效。

在 Chrome 中可以通過開啟網址 chrome://inspect/#service-workers 來檢視當前瀏覽器中所有註冊了的 Service Workers。

使用 Service Workers 實現離線快取

Service Workers 在註冊成功後會在其生命週期中派發出一些事件,通過監聽對應的事件在特點的時間節點上做一些事情。

在 Service Workers 指令碼中,引入了新的關鍵字 self 代表當前的 Service Workers 例項。

在 Service Workers 安裝成功後會派發出 install 事件,需要在這個事件中執行快取資源的邏輯,實現程式碼如下:

// 當前快取版本的唯一識別符號,用當前時間代替
var cacheKey = new Date().toISOString();

// 需要被快取的檔案的 URL 列表
var cacheFileList = [
  '/index.html',
  '/app.js',
  '/app.css'
];

// 監聽 install 事件
self.addEventListener('install', function (event) {
  // 等待所有資源快取完成時,才可以進行下一步
  event.waitUntil(
    caches.open(cacheKey).then(function (cache) {
      // 要快取的檔案 URL 列表
      return cache.addAll(cacheFileList);
    })
  );
});
複製程式碼

接下來需要監聽網路請求事件去攔截請求,複用快取,程式碼如下:

self.addEventListener('fetch', function(event) {
  event.respondWith(
    // 去快取中查詢對應的請求
    caches.match(event.request).then(function(response) {
        // 如果命中本地快取,就直接返回本地的資源
        if (response) {
          return response;
        }
        // 否則就去用 fetch 下載資源
        return fetch(event.request);
      }
    )
  );
});
複製程式碼

以上就實現了離線快取。

更新快取

線上的程式碼有時需要更新和重新發布,如果這個檔案被離線快取了,那就需要 Service Workers 指令碼中有對應的邏輯去更新快取。 這可以通過更新 Service Workers 指令碼檔案做到。

瀏覽器針對 Service Workers 有如下機制:

  1. 每次開啟接入了 Service Workers 的網頁時,瀏覽器都會去重新下載 Service Workers 指令碼檔案(所以要注意該指令碼檔案不能太大),如果發現和當前已經註冊過的檔案存在位元組差異,就將其視為“新服務工作執行緒”。
  2. 新 Service Workers 執行緒將會啟動,且將會觸發其 install 事件。
  3. 當網站上當前開啟的頁面關閉時,舊 Service Workers 執行緒將會被終止,新 Service Workers 執行緒將會取得控制權。
  4. 新 Service Workers 執行緒取得控制權後,將會觸發其 activate 事件。

新 Service Workers 執行緒中的 activate 事件就是最佳的清理舊快取的時間點,程式碼如下:

// 當前快取白名單,在新指令碼的 install 事件裡將使用白名單裡的 key 
var cacheWhitelist = [cacheKey];

self.addEventListener('activate', function(event) {
  event.waitUntil(
    caches.keys().then(function(cacheNames) {
      return Promise.all(
        cacheNames.map(function(cacheName) {
          // 不在白名單的快取全部清理掉
          if (cacheWhitelist.indexOf(cacheName) === -1) {
            // 刪除快取
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});
複製程式碼

最終完整的程式碼 Service Workers 指令碼程式碼如下:

// 當前快取版本的唯一識別符號,用當前時間代替
var cacheKey = new Date().toISOString();

// 當前快取白名單,在新指令碼的 install 事件裡將使用白名單裡的 key
var cacheWhitelist = [cacheKey];

// 需要被快取的檔案的 URL 列表
var cacheFileList = [
  '/index.html',
  'app.js',
  'app.css'
];

// 監聽 install 事件
self.addEventListener('install', function (event) {
  // 等待所有資源快取完成時,才可以進行下一步
  event.waitUntil(
    caches.open(cacheKey).then(function (cache) {
      // 要快取的檔案 URL 列表
      return cache.addAll(cacheFileList);
    })
  );
});

// 攔截網路請求
self.addEventListener('fetch', function (event) {
  event.respondWith(
    // 去快取中查詢對應的請求
    caches.match(event.request).then(function (response) {
        // 如果命中本地快取,就直接返回本地的資源
        if (response) {
          return response;
        }
        // 否則就去用 fetch 下載資源
        return fetch(event.request);
      }
    )
  );
});

// 新 Service Workers 執行緒取得控制權後,將會觸發其 activate 事件
self.addEventListener('activate', function (event) {
  event.waitUntil(
    caches.keys().then(function (cacheNames) {
      return Promise.all(
        cacheNames.map(function (cacheName) {
          // 不在白名單的快取全部清理掉
          if (cacheWhitelist.indexOf(cacheName) === -1) {
            // 刪除快取
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});
複製程式碼

接入 Webpack

用 Webpack 構建接入 Service Workers 的離線應用要解決的關鍵問題在於如何生成上面提到的 sw.js 檔案, 並且sw.js檔案中的 cacheFileList 變數,代表需要被快取檔案的 URL 列表,需要根據輸出檔案列表所對應的 URL 來決定,而不是像上面那樣寫成靜態值。

假如構建輸出的檔案目錄結構為:

├── app_4c3e186f.js
├── app_7cc98ad0.css
└── index.html
複製程式碼

那麼 sw.js 檔案中 cacheFileList 的值應該是:

var cacheFileList = [
  '/index.html',
  'app_4c3e186f.js',
  'app_7cc98ad0.css'
];
複製程式碼

Webpack 沒有原生功能能完成以上要求,幸好龐大的社群中已經有人為我們做好了一個外掛 serviceworker-webpack-plugin 可以方便的解決以上問題。 使用該外掛後的 Webpack 配置如下:

const path = require('path');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const { WebPlugin } = require('web-webpack-plugin');
const ServiceWorkerWebpackPlugin = require('serviceworker-webpack-plugin');

module.exports = {
  entry: {
    app: './main.js'// Chunk app 的 JS 執行入口檔案
  },
  output: {
    filename: '[name].js',
    publicPath: '',
  },
  module: {
    rules: [
      {
        test: /\.css/,// 增加對 CSS 檔案的支援
        // 提取出 Chunk 中的 CSS 程式碼到單獨的檔案中
        use: ExtractTextPlugin.extract({
          use: ['css-loader'] // 壓縮 CSS 程式碼
        }),
      },
    ]
  },
  plugins: [
    // 一個 WebPlugin 對應一個 HTML 檔案
    new WebPlugin({
      template: './template.html', // HTML 模版檔案所在的檔案路徑
      filename: 'index.html' // 輸出的 HTML 的檔名稱
    }),
    new ExtractTextPlugin({
      filename: `[name].css`,// 給輸出的 CSS 檔名稱加上 Hash 值
    }),
    new ServiceWorkerWebpackPlugin({
      // 自定義的 sw.js 檔案所在路徑
      // ServiceWorkerWebpackPlugin 會把檔案列表注入到生成的 sw.js 中
      entry: path.join(__dirname, 'sw.js'),
    }),
  ],
  devServer: {
    // Service Workers 依賴 HTTPS,使用 DevServer 提供的 HTTPS 功能。
    https: true,
  }
};
複製程式碼

以上配置有2點需要注意:

  • 由於 Service Workers 必須在 HTTPS 環境下才能攔截網路請求實現離線快取,使用在 2-6 DevServer https 中提到的方式去實現 HTTPS 服務。
  • serviceworker-webpack-plugin 外掛為了保證靈活性,允許使用者自定義 sw.js,構建輸出的 sw.js 檔案中會在頭部注入一個變數 serviceWorkerOption.assets 到全域性,裡面存放著所有需要被快取的檔案的 URL 列表。

需要修改上面的 sw.js 檔案中寫成了靜態值的 cacheFileList 為如下:

// 需要被快取的檔案的 URL 列表
var cacheFileList = global.serviceWorkerOption.assets;
複製程式碼

以上已經完成所有檔案的修改,在重新構建前,先安裝新引入的依賴:

npm i -D serviceworker-webpack-plugin webpack-dev-server
複製程式碼

安裝成功後,在專案根目錄下執行 webpack-dev-server 命令後,DevServer 將以 HTTPS 模式啟動,並輸出如下日誌:

> webpack-dev-server

Project is running at https://localhost:8080/
webpack output is served from /
Hash: 402ee6ce5bffb16dffe2
Version: webpack 3.5.5
Time: 619ms
     Asset       Size  Chunks                    Chunk Names
    app.js     325 kB       0  [emitted]  [big]  app
   app.css   21 bytes       0  [emitted]         app
index.html  235 bytes          [emitted]         
     sw.js    4.86 kB          [emitted]         
複製程式碼

用 Chrome 瀏覽器開啟網址 https://localhost:8080/index.html 後,就能訪問接入了 Service Workers 離線快取的頁面了。

驗證結果

為了驗證 Service Workers 和快取生效了,需要通過 Chrome 的開發者工具來檢視。

通過開啟開發者工具的 Application-Service Workers 一欄,就能看到當前頁面註冊的 Service Workers,正常的效果如圖:

圖3.12.1 檢視當前頁面註冊的 Service Workers

通過開啟開發者工具的 Application-Cache-Cache Storage 一欄,能看到當前頁面快取的資源列表,正常的效果如圖:

圖3.12.2 檢視當前頁面的 Cache Storage

為了驗證網頁在離線時能訪問的能力,需要在開發者工具中的 Network 一欄中通過 Offline 選項禁用掉網路,再重新整理頁面能正常訪問,並且網路請求的響應都來自 Service Workers,正常的效果如圖:

圖3.12.3 離線情況下訪問頁面

本例項提供專案完整程式碼

Webpack實戰-構建離線應用

《深入淺出Webpack》全書線上閱讀連結

閱讀原文

相關文章