JavaScript 差量更新的实现

4,285 阅读8分钟

为什么要做差量更新

传统的JavaScript 资源加载,一般是通过CDN 存放或者伺服在本地服务器,通过设置maxage、Last-Modified、 etag 等来让浏览器访问后缓存,减少重复请求,但在产品的更新很多时候往往都是修改少量内容,hash 改变之后,用户就需要全量下载整个Javascript 文件,普遍的增量更新思路都是以分包为主,当分包有更新的时候,用户依然需要下载一个全新分包,问题还是存在的。

此时,如果我们学Android 的增量更新机制,通过差分描述数据在本地与缓存文件进行merge,然后更新执行,我们就可以让用户几乎无感知无等待升级,也可以减少资源请求量了。

实践思路

我们在每次更新文件时,需要生成前几个版本(不同用户可能缓存了不同版本)与当前版本的Diff信息的描述数据。

而在浏览器端,先检查本地是否存在缓存文件,如果不存在缓存(或不支持LocalStorage),则下载全量文件并缓存到本地,等待下次使用;如果存在,则获取Diff 数据,然后判断Diff 数据中有无此版本的更新信息。若无,则说明本地缓存太旧,更新差距大,需要重新下载全量文件并缓存;若有,则将diff 信息与本地文件合并,生成全新版本,经由eval 执行js,然后缓存到本地,并更新hash。

如何生成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 信息如下:

在这个数组中,正数代表无需修改的文字字数,负数则代表删除的字数,字符串代表新增的文字。

浏览器的工作

到这里我们已经生成了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 差量更新的实现的一个思路,写得有点粗糙,还是希望能给大家带来一个新思路,谢谢🙏