JavaScript 差量更新的實現

爲何要作差量更新

傳統的JavaScript 資源加載,通常是經過CDN 存放或者伺服在本地服務器,經過設置maxage、Last-Modified、 etag 等來讓瀏覽器訪問後緩存,減小重複請求,但在產品的更新不少時候每每都是修改少許內容,hash 改變以後,用戶就須要全量下載整個Javascript 文件,廣泛的增量更新思路都是以分包爲主,當分包有更新的時候,用戶依然須要下載一個全新分包,問題仍是存在的。javascript

此時,若是咱們學Android 的增量更新機制,經過差分描述數據在本地與緩存文件進行merge,而後更新執行,咱們就可讓用戶幾乎無感知無等待升級,也能夠減小資源請求量了。html

實踐思路

咱們在每次更新文件時,須要生成前幾個版本(不一樣用戶可能緩存了不一樣版本)與當前版本的Diff信息的描述數據。前端

而在瀏覽器端,先檢查本地是否存在緩存文件,若是不存在緩存(或不支持LocalStorage),則下載全量文件並緩存到本地,等待下次使用;若是存在,則獲取Diff 數據,而後判斷Diff 數據中有無此版本的更新信息。若無,則說明本地緩存太舊,更新差距大,須要從新下載全量文件並緩存;如有,則將diff 信息與本地文件合併,生成全新版本,經由eval 執行js,而後緩存到本地,並更新hash。 java

如何生成diff 信息?

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

  1. 在請求資源時,服務端計算並緩存diff信息,但缺點是須要服務端配合,且消耗必定計算資源。
  2. 經過工程化工具生成diff信息。這一方案對前端來講最實際,那麼咱們就從這個方案着手,最經常使用的工具就是Webpack,那咱們以Webpack 爲例子來擼一個插件吧。最近寫了個diffupdate-webpack-plugin,還在原型階段,比較草略,可是基本思路都是符合的,如下的代碼均可以在其中參考。

1. 插件編寫

關於Webpack 插件的編寫,這裏不展開贅敘,能夠參考這篇文件 瞭解,基本思路就是實現一個帶有apply 方法的類,並使用compiler.plugin 監聽webpack 的生命週期事件並在回調函數中執行操做。git

2. 緩存文件

首先要說的是,webpack 提供了compilation對象,它表明了一次單一的版本構建和生成資源,咱們須要經過compilation.chunks獲取到即將輸出的文件信息,而後將其緩存以便後續比對。github

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 庫的對比仍是不那麼準確,這個能夠根據實際狀況進行選擇。web

拓展上面的代碼:ajax

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信息。npm

我本身實現的方法生成diff 信息以下:

在這個數組中,正數表明無需修改的文字字數,負數則表明刪除的字數,字符串表明新增的文字。

瀏覽器的工做

到這裏咱們已經生成了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提供瞭如下事件

這裏用了一個笨方法(由於忙其餘的,還沒找怎麼劫持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;
        }
    }
});
複製代碼

效果

第一次加載時,沒有本地緩存,讀取全量文件

第二次加載時,由於有緩存,無需讀取文件,直接從本地中拿到緩存
至此,成功!


爲何不用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 差量更新的實現的一個思路,寫得有點粗糙,仍是但願能給你們帶來一個新思路,謝謝🙏

相關文章
相關標籤/搜索