JavaScript 差量更新的實現

EvontNg發表於2019-03-21

為什麼要做差量更新

傳統的JavaScript 資源載入,一般是通過CDN 存放或者伺服在本地伺服器,通過設定maxage、Last-Modified、 etag 等來讓瀏覽器訪問後快取,減少重複請求,但在產品的更新很多時候往往都是修改少量內容,hash 改變之後,使用者就需要全量下載整個Javascript 檔案,普遍的增量更新思路都是以分包為主,當分包有更新的時候,使用者依然需要下載一個全新分包,問題還是存在的。

此時,如果我們學Android 的增量更新機制,通過差分描述資料在本地與快取檔案進行merge,然後更新執行,我們就可以讓使用者幾乎無感知無等待升級,也可以減少資源請求量了。

實踐思路

我們在每次更新檔案時,需要生成前幾個版本(不同使用者可能快取了不同版本)與當前版本的Diff資訊的描述資料。

而在瀏覽器端,先檢查本地是否存在快取檔案,如果不存在快取(或不支援LocalStorage),則下載全量檔案並快取到本地,等待下次使用;如果存在,則獲取Diff 資料,然後判斷Diff 資料中有無此版本的更新資訊。若無,則說明本地快取太舊,更新差距大,需要重新下載全量檔案並快取;若有,則將diff 資訊與本地檔案合併,生成全新版本,經由eval 執行js,然後快取到本地,並更新hash。

JavaScript 差量更新的實現

如何生成diff 資訊?

這一步想到的有幾個實現方案:

  1. 在請求資源時,服務端計算並快取diff資訊,但缺點是需要服務端配合,且消耗一定計算資源。
  2. 通過工程化工具生成diff資訊。這一方案對前端來說最實際,那麼我們就從這個方案著手,最常用的工具就是Webpack,那我們以Webpack 為例子來擼一個外掛吧。最近寫了個diffupdate-webpack-plugin,還在原型階段,比較草略,但是基本思路都是符合的,以下的程式碼都可以在其中參考。

1. 外掛編寫

關於Webpack 外掛的編寫,這裡不展開贅敘,可以參考這篇檔案 瞭解,基本思路就是實現一個帶有apply 方法的類,並使用compiler.plugin 監聽webpack 的生命週期事件並在回撥函式中執行操作。

2. 快取檔案

首先要說的是,webpack 提供了compilation物件,它代表了一次單一的版本構建和生成資源,我們需要通過compilation.chunks獲取到即將輸出的檔案資訊,然後將其快取以便後續比對。

compilation.chunks.forEach(chunk => {
    const { hash } = chunk;
    chunk.files.forEach(filename => {
        if (filename.indexOf('.js') !== -1) {
        // 從assets 中拿到對應檔案,使用source獲取到內容
        fileCache[filename] = fileCache[filename] || [];
        // ...
    });
});
//...
// webpack 可以通過compilation.assets[檔名] = 一個包含source 和size(兩個函式缺一不可)的物件的方式來生成資原始檔
compilation.assets['filecache.json'] = {
    source() { return JSON.stringify(fileCache); },
    size() { return JSON.stringify(fileCache).length;}
}        
複製程式碼

3. 檔案對比

這裡我們可以使用fast-diff,也可以使用diff,但我個人覺得diff 庫的對比還是不那麼準確,這個可以根據實際情況進行選擇。

擴充上面的程式碼:

const fastDiff = require('fast-diff');
// ...
const diffJson = {}; // 用於描述每個檔案每個版本的diff資訊
// ...
const newFile = compilation.assets[filename].source(); // 編譯完成之後的新檔案
diffJson[filename].forEach((ele, index) => {
    const item = fileCache[filename][index]; // 歷史檔案
    const diff = this.minimizeDiffInfo(fastDiff(item.source, newFile)); // 精簡diff資訊,減少不必要的干擾
    ele.diff = diff;
});
複製程式碼

當第一次構建時,我們沒有歷史版本,此時我們不會獲取到任何diff資訊。在後續的構建時,我們就會從快取中拿到前幾個版本的檔案內容並逐一與最新檔案進行比對並生成diff資訊,再覆蓋到上次生成的diff檔案中,這樣只要使用者在限定版本差距中,都可以得到與最新版本相對應的diff資訊。

我自己實現的方法生成diff 資訊如下:

JavaScript 差量更新的實現
在這個陣列中,正數代表無需修改的文字字數,負數則代表刪除的字數,字串代表新增的文字。

瀏覽器的工作

到這裡我們已經生成了diff資訊了,我們還需要讓瀏覽器獲取到diff資訊、載入快取js和合並diff。

獲取js

我們先寫一個loadScript 方法,傳入需要載入的js 檔名,先判斷LocalStorage 中有沒有對應的快取(這裡還要判斷支不支援LocalStorage),如果沒有,請求該資源並存入LocalStorage中。如果有,我們就根據diff 資訊進行合併,執行之後更新快取存入本地。

function mergeDiff(str, diffInfo) {
    var p = 0;
    for (var i = 0; i < diffInfo.length; i++) {
      var info = diffInfo[i];
      if (typeof(info) == 'string') {
        info = info.replace(/\\"/g, '"').replace(/\\'/g, "'");
        str = str.slice(0, p) + info + str.slice(p);
        p += info.length;
      }
      if (typeof(info) == 'number') {
        if (info < 0) {
          str = str.slice(0, p) + str.slice(p + Math.abs(info));
        } else {
          p += info;
        }
        continue;
      }
    }
    return str;
 }
 function loadFullSource(item) {
    ajaxLoad(item, function(result) {
      window.eval(result);
      localStorage.setItem(item, JSON.stringify({
        hash: window.__fileHash,
        source: result,
      }));
    });
 }
function loadScript(scripts) {
    for (var i = 0, len = scripts.length; i < len; i ++) {
      var item = scripts[i];
      if (localStorage.getItem(item)) {
        var itemCache = JSON.parse(localStorage.getItem(item));
        var _hash = itemCache.hash;
        var diff;
        // 獲取diff資訊
        if (diff) {
          var newScript = mergeDiff(itemCache.source || '', diff);
          window.eval(newScript);
          localStorage.setItem(item, JSON.stringify({
            hash: window.__fileHash,
            source: newScript,
          }));
        } else {
          loadFullSource(item);
        }
      } else {
        loadFullSource(item);
      }
    }
  }
複製程式碼

獲取diff資訊

我們可以通過請求上一步生成的diff.json 的方式獲取到diff資訊,但是這樣做有個弊端,那就是,所有使用到的js 的diff資訊都會獲取到,我們可以將diff資訊排除,只留下所需的js對應的diff資訊,此時我們就不能通過請求資源的方式,另外,在上面說到的,我們需要傳入所需的js ,而在大多數情況下,我們都是通過html-webpack-plugin 將生成的js 檔案通過script 標籤的方式注入到模板中,但這樣一來我們就達不到目的,那麼我們就需要修改輸出的資訊,所幸的是,html-webpack-plugin 允許我們修改它的輸出

修改html-webpack-plugin

html-webpack-plugin提供了以下事件

JavaScript 差量更新的實現

這裡用了一個笨方法(因為忙其他的,還沒找怎麼劫持script 修改的方法),首先在html-webpack-plugin-before-html-processing 事件時快取模板,然後html-webpack-plugin-after-html-processing 事件中對比生成檔案與模板內容的區別,替換diff 資訊,並把操作快取對比等邏輯js壓縮並插入到html中,這樣一來,客戶端讀取html 時,就會拿到最新的diff資訊,也無需手動填寫對應的js。Bingo!

 let oriHtml = '';
 // 在模板修改前快取模板
compilation.plugin('html-webpack-plugin-before-html-processing', (data) => {
    oriHtml = data.html;
});
// 對比更新,替換掉生成的script 標籤,將diff 資訊插入,同時將引入的js 列表填入到loadScript方法中
compilation.plugin('html-webpack-plugin-after-html-processing', (data) => {
    const htmlDiff = diff.diffLines(oriHtml, data.html);
    const result = UglifyJS.minify(insertScript);
    // ...
    for (let i = 0, len = htmlDiff.length; i < len; i += 1) {
        const item = htmlDiff[i];
        const { added, value } = item;
        if (added && /<script type="text\/javascript" src=".*?"><\/script>/.test(value)) {
              let { value } = item;
              const jsList = value.match(/(?<=src=")(.*?\.js)/g);
              value = value.replace(/<script type="text\/javascript" src=".*?"><\/script>/g, '');
              const insertJson = deepCopy(diffJson);
              for (const i in insertJson) {
                if (jsList.indexOf(i) === -1) delete insertJson[i]
              }
              newHtml += `<script>${result.code}</script>\n<script>window.__fileDiff__='${JSON.stringify(insertJson)}';</script><script>loadScript(${JSON.stringify(jsList)});</script>\n${value}`;
        } else if (item.removed) {
  
        } else {
              newHtml += value;
        }
    }
});
複製程式碼

效果

第一次載入時,沒有本地快取,讀取全量檔案

JavaScript 差量更新的實現
第二次載入時,因為有快取,無需讀取檔案,直接從本地中拿到快取
JavaScript 差量更新的實現
JavaScript 差量更新的實現
至此,成功!
JavaScript 差量更新的實現


為什麼不用PWA?

  1. 快取機制限制

    如果我們在新版本中更新了ServiceWorker子執行緒程式碼,當訪問網站頁面時瀏覽器獲取了新的檔案,對比發現不同時它會安裝新的檔案並觸發 install 。但此時已經處於啟用狀態的舊 Service Worker 還在執行,新 Service Worker 完成安裝後會進入 waiting 狀態。直到所有已開啟的頁面都關閉,舊的 Service Worker 自動停止,新的 Service Worker 才會在接下來重新開啟的頁面裡生效。如果想要立即更新需要在新的程式碼中做一些處理。首先在install事件中呼叫self.skipWaiting()方法,然後在active事件中呼叫self.clients.claim()方法通知各個客戶端。

    注意這裡說的是瀏覽器獲取了新版本的ServiceWorker程式碼,如果瀏覽器本身對sw.js進行快取的話,也不會得到最新程式碼,而且實際應用中,index.html也會快取,而在我們的fetch事件中,如果快取命中那麼直接從快取中取,這就會導致即使我們的index頁面有更新,瀏覽器獲取到的永遠也是都是之前的ServiceWorker快取的index頁面,所以有些ServiceWorker框架支援我們配置資源更新策略,比如我們可以對主頁這種做策略,首先使用網路請求獲取資源,如果獲取到資源就使用新資源,同時更新快取,如果沒有獲取到則使用快取中的資源

  2. 相容問題

    Service Worker的支援率並不高,IE也暫時不支援,但LocalStorage 則更佳。


寫在最後

以上就是一個Javascript 差量更新的實現的一個思路,寫得有點粗糙,還是希望能給大家帶來一個新思路,謝謝?

相關文章