electron版本更新之增量更新、无感更新

        上一篇介绍了electron版本更新之全量更新(vue)_Zoie_ting的博客-CSDN博客

        但是全量更新的包太大了,而且也不是每次都改动很多东西需要全量更的,有没有什么办法可以少量更新呢?本篇就在上一篇的基础上,讲述一下如何进行增量更新。 

        词不达意,以图示例:

一、入手

        打包项目,在dist中有个bundled,这个目录下就是我们经常修改的部分,包括css、js、图片、html等文件。本地安装一下,打开文件所在位置,会发现在resources目录下有个app.asar,其实这个文件是把所有bundled的内容压缩到一起了。

        或许读者会质疑,那我们来实操一下:

  • 首先安装asar:npm install -g asar
  • 切到app.asar所在目录,执行:asar extract app.asar ./app-bundled
  • 之后会在resources看到一个app-bundled文件夹,下面的内容几乎与dist中的bundled一样!

        其实,在主进程中的win.loadURL('app://./index.html') ,就是运行的这个文件中的index.html。

        因此,理论上,我们只需要把app.asar中的需要改的文件单独拎出,修改主进程的index.html加载路径就好了。

二、分包

        这里要特别说明一下,版本更新需要有触发机制、不论是接口获取还是websocket推送还是配置文件声明。这个应该很好理解,接口获取和websocket推送比较简单,获取数据跟用户本地不一样时触发更新即可,笔者这里用配置文件(hotVersion.json)的方式说明一下。

        在vue.config.js中配置打包时的分包文件,主要思想就是将平时经常修改的文件放到app.asar.unpacked中,将版本比对的hotVersion.json单独拎出,其余文件仍然放到app.asar中。

        hotVersion.json中的内容只是个版本号,用于比对:{"version": "2.5.7"}。

        这里用到asar和builderOptions中的files和extraResources配置项:

  • asar:是否使用Electron的存档格式将应用程序的源代码打包到存档中,一定要设置成false
  • file加入的文件就是新包中app.asar中的文件
  • extraResources中:
  1. from表示从打完的包中的哪个路径下,这里是dist/bundled
  2. to表示将这些包输出到哪个路径,app.asar.unpacked表示在resources文件夹下的app.asar.unpacked文件夹中,./表示在resources文件夹下
  3. filter匹配的是文件名,特别注意的是,打到app.asar.unpacked中的文件一定是从bundled中过滤出来的,且在app.asar.unpacked中的文件一定不能在file中,因为一旦app.asar中有该文件,那么程序会自动使用该文件中的而不是我们想要的app.asar.unpacked中的

        配置如下:

  pluginOptions: {
    electronBuilder: {
      //...
      asar: false,
      builderOptions: {
        productName: "test", //包名

        extraResources: [
          {
            from: "dist/bundled",
            to: "app.asar.unpacked",
            filter: [
              "!**/node_modules",
              "!**/background.js",
              "!**/background.js.LICENSE.txt",
              "!**/favicon.ico",
              "!**/package.json",
              "!**/hotVersion.json",
            ],
          },
          {
            from: "dist/bundled",
            to: "./",
            filter: [
              "**/hotVersion.json"
            ],
          },
        ],
        files: [
          "**/node_modules/**/*",
          "**/background.js",
          "**/background.js.LICENSE.txt",
          "**/favicon.ico",
          "**/package.json",
        ],

        win: {
          publish: [
            {
              provider: "generic",
              url: "https:xxx", //更新服务器地址,可为空
            },
          ],
          //...
        },
      },
    },
  },

        按照上面的配置,打包之后,安装到本地,在resources文件夹下会多出hotVersion.json和app.asar.unpacked文件夹,app.asar.unpacked下的内容就是我们配置的过滤出的经常修改的那些。执行asar extract app.asar ./app-bundled,可以看到多出的app-bundled文件夹下只有我们配置的files中的那些文件,再次强调一下,app.asar中不能存在与app.asar.unpacked相同的文件。

        至此,我们只是将安装后的文件分离出来,并没有打出分包。继续配置:

         Common Configuration - electron-builder在electron-builder中有几个钩子函数,这里我们需要用到afterPack,这里的函数将在打包之后运行,笔者需要在此打出分包,这里用到了adm-zip,请读者自行install。

        新建afterPack.js,写入以下代码:

  • targetPath表示安装到本地后的resources路径,也就是app.asar.unpacked所在路径,unpacked为app.asar.unpacked路径,也就是我们每次需要修改的文件的所在路径,利用adm-zip将该路径下的文件全部打包,将压缩包输出到当前打包路径下,即dist下,并命名为unpack.zip。
  • 对dist/hotVersion.json执行写操作(没有该文件则自动生成),写入的内容正是我们hotVersion.json的内容
const path = require("path");
const AdmZip = require("adm-zip");
const fs = require("fs");

exports.default = async function (context) {
  let targetPath;
  if (context.packager.platform.nodeName === "darwin") {
    targetPath = path.join(
      context.appOutDir,
      `${context.packager.appInfo.productName}.app/Contents/Resources`
    );
  } else {
    targetPath = path.join(context.appOutDir, "./resources");
  }
  const unpacked = path.join(targetPath, "./app.asar.unpacked");
  var zip = new AdmZip();
  zip.addLocalFolder(unpacked);
  zip.writeZip(path.join(context.outDir, "unpacked.zip"));

  fs.writeFile(
    path.join(context.outDir, "hotVersion.json"),
    JSON.stringify(
      {
        version: require("./hotVersion.json").version
      },
      null,
      2
    ),
    (err, data) => {}
  );
};

        此时vue.config.js配置中需加入afterPack:

//...
files:[
    //...
]
afterPack: "./afterPack.js",
//...

        到这里,打包后的dist文件夹中就会生成unpack.zip和hotVersion.json,其中unpack.zip就是我们每次更新的包,换言之,我们每次将用户本地的hotVersion.json和服务器上的hotVersion.json的版本号进行比对,如果不一致,则下载unpack.zip,解压到app.asar.unpacked中,刷新页面,即可完成更新。

        别忘了,electron默认运行的是app.asar中的index.html,在上面一顿操作之后,app.asar中已经没有index.html了,因此,打开后将会是白屏。不要慌,修改一下加载路径。

        原来的加载index.html的配置如下:

import { createProtocol } from 'vue-cli-plugin-electron-builder/lib'

//...
    createProtocol('app')
    win.loadURL('app://./index.html')

          不再引用vue-cli-plugin-electron-builder/lib的createProtocol,新建createProtocol.js:

import { protocol } from 'electron'
import * as path from 'path'
import { readFile } from 'fs'
import { URL } from 'url'

export const createProtocol = (scheme, customProtocol, serverPath = __dirname) => {
  (customProtocol || protocol).registerBufferProtocol(
    scheme,
    (request, respond) => {
      let pathName = new URL(request.url).pathname
      pathName = decodeURI(pathName) // Needed in case URL contains spaces

      readFile(path.join(serverPath, pathName), (error, data) => {
        if (error) {
          console.error(
            `Failed to read ${pathName} on ${scheme} protocol`,
            error
          )
        }
        const extension = path.extname(pathName).toLowerCase()
        let mimeType = ''

        if (extension === '.js') {
          mimeType = 'text/javascript'
        } else if (extension === '.html') {
          mimeType = 'text/html'
        } else if (extension === '.css') {
          mimeType = 'text/css'
        } else if (extension === '.svg' || extension === '.svgz') {
          mimeType = 'image/svg+xml'
        } else if (extension === '.json') {
          mimeType = 'application/json'
        } else if (extension === '.wasm') {
          mimeType = 'application/wasm'
        }

        respond({ mimeType, data })
      })
    }
  )
}

        修改加载路径,改为加载app.asar.unpacked中的index.html:

let createProtocol = require("./config/createProtocol.js").createProtocol;

//...
   createProtocol(
      "app",
      "",
      path.join(process.resourcesPath, "./app.asar.unpacked")
    );
    win.loadURL("app://./index.html");

        至此,打包安装后运行electron就不是白屏了。

        如果需要发布增量版本,只需要修改hotVersion.json的内容,打包后将hotVersion.json和unpack.zip放在服务器上,检测时如果hotVersion.json本地与服务器不一致,则更新。

三、更新

        注:这里接上一篇全量更新的逻辑,如果有不清楚的,可以看上一篇:electron版本更新之全量更新(vue)_Zoie_ting的博客-CSDN博客

        启动,检测是否存在全量更新:

  if (process.env.WEBPACK_DEV_SERVER_URL) {
    //...
  } else {
    createProtocol(
      "app",
      "",
      path.join(process.resourcesPath, "./app.asar.unpacked")
    );
    win.loadURL("app://./index.html");
    checkForUpdates();
  }
function checkForUpdates() {
  autoUpdater.checkForUpdates();
}

        如果存在更新包,将自动从autoUpdater.setFeedURL(url)配置的url下载,下载完成后在update-downloaded处理。其中defaultId是配置默认选择项,值是buttons的索引,例如这里的配置是0,表示“否”。cancelId是配置的也是0,表示如果不选择buttons的值而是直接关闭弹出的确认框时,按照选择“否”处理:

import {
    //...
    dialog
} from "electron";
autoUpdater.on("update-downloaded", () => {
    dialog.showMessageBox({
          type: "info",
          buttons: ["否", "是"],
          title: "应用更新",
          message: "更新包下载完成",
          detail: "请选择是否立即更新",
          defaultId: 0,
          cancelId: 0,
        }).then((res) => {
        if (res.response === 1) {
            autoUpdater.quitAndInstall();
        } else {
        }
    });
});

        如果不存在全量更新,则检测是否存在增量更新版本:

let currentIncrementUpdate = ""; //本地版本
autoUpdater.on("update-not-available", () => {
  // 读取本地hotVersion
  fs.readFile(
    path.join(process.resourcesPath, "./hotVersion.json"),
    "utf8",
    (err, data) => {
      if (err) {
        //...
      } else {
        //记录本地的版本号,因为我们需要比对本地版本号和线上是否相同再触发更新
        currentIncrementUpdate = JSON.parse(data).version;
        incrementUpdate();
      }
    }
  );
});

        检测是否存在增量更新,如果存在,则下载线上的版本包: 

let obsIncrementUpdate = ""; //服务器版本
let currentIncrementUpdate = ""; //本地版本

// 增量更新
async function incrementUpdate() {
  let oldPath = process.resourcesPath + "/app.asar.unpacked";
  let targetPath = process.resourcesPath + "/unpacked.zip";

  request(
    {
      method: "GET",
      uri: "https://xxx/hotVersion.json",
    },
    function (err, response, body) {
      if (response.statusCode == 200) {
        // 服务器版本
        obsIncrementUpdate = JSON.parse(body).version;
        //两个版本号不同,触发更新
        if (currentIncrementUpdate != obsIncrementUpdate) {
          let req = request({
            method: "GET",
            uri: "https://xxx/unpacked.zip", //增量更新包在服务器上的路径
          });

          try {
            let out = fs.createWriteStream(targetPath);
            let received_bytes = 0;
            let total_bytes = 0;
            req.pipe(out);

            req.on("response", function (data) {
              total_bytes = parseInt(data.headers["content-length"]);
            });

            req.on("data", function (chunk) {
              received_bytes += chunk.length;
            });

            req.on("end", function () {
              if (req.response.statusCode === 200) {
                if (received_bytes === total_bytes) {
                  updateAtOnce(oldPath, targetPath, obsIncrementUpdate);
                } else {
                  out.end();
                  //...省略错误处理
                }
              } else {
                //网络波动,下载文件不全
                out.end();
                //...省略错误处理
              }
            });
            req.on("error", (e) => {
              out.end();
              //网络波动,下载文件不全
              if (received_bytes !== total_bytes) {
               //...省略错误处理
              } else {
               //...省略错误处理
              }
            });
          } catch (err) {
            //...省略错误处理
          }
        } else {
        }
      } else {
        //读取线上的hotVersion错误
        //...省略错误处理
      }
    }
  );
}

        已经下载完毕线上的增量更新包,给出提示,是否立即更新:

async function updateAtOnce(oldPath, targetPath, obsIncrementUpdate) {
      dialog
        .showMessageBox({
          type: "info",
          buttons: ["否", "是"],
          title: "应用更新",
          message: "更新包下载完成",
          detail: "请立即完成更新",
          defaultId: 0,
          cancelId: 0,
        })
        .then((res) => {
          if (res.response === 1) {
            //立即更新
            handleIncreaseUpdate(oldPath, targetPath, obsIncrementUpdate);
          } else {
          }
        });
}

        处理立即更新:判断是否存在app.asar.unpacked.old,如果存在,则删除该文件夹和该文件夹下的所有文件,将app.asar.unpacked先备份为app.asar.unpacked.old,解压已下载的unpack.zip到app.asar.unpacked,在这个过程中一旦发生异常,将app.asar.unpacked.old恢复成app.asar.unpacked。

        之所以要单独写这个函数,是为了后面的优化,这个函数主要处理的就是下载完的安装。如果已经有安装包,可以进行强更,只需要执行这个函数即可。

//删除目标文件夹以及文件夹下的所有文件
function deleteOld(url) {
  var files = [];
  if (fs.existsSync(url)) {
    files = fs.readdirSync(url);
    files.forEach(function (file, index) {
      var curPath = path.join(url, file);
      if (fs.statSync(curPath).isDirectory()) {
        deleteOld(curPath);
      } else {
        fs.unlinkSync(curPath);
      }
    });
    fs.rmdirSync(url);
  }
}
async function handleIncreaseUpdate(
  oldPath,
  targetPath,
  obsIncrementUpdate,
  reload = true
) {
  //删除目标文件夹以及文件夹下的所有文件
  deleteOld(oldPath + ".old");

  // 建立.old备份
  fs.rename(oldPath, oldPath + ".old", (err) => {
    if (err) {
      //...省略错误处理
      return;
    }
    // 解压
    let zip = new AdmZip(targetPath);
    // 把整个压缩包完全解压到 app.asar.unpacked 目录中
    zip.extractAllToAsync(oldPath, true, (err) => {
      if (err) {
        //恢复
        fs.rename(oldPath + ".old", oldPath, (err) => {});
        return;
      }
      //解压完之后别忘了要修改本地hotVersion文件的版本号,否则会一直触发更新
      fs.writeFile(
        path.join(process.resourcesPath, "./hotVersion.json"),
        JSON.stringify(
          {
            version: obsIncrementUpdate,
          },
          null,
          2
        ),
        (err, data) => {
          if (err) {
            //...省略错误处理
          } else {
            currentIncrementUpdate = obsIncrementUpdate;
            if (reload) {
              //重启应用
              app.relaunch();
              app.exit(0);
            } else {
            }
          }
        }
      );
    });
  });
}

四、定时更新

        上文笔者已经介绍了正常情况下更新的流程已经处理,但是这是在用户启动的时候检测了一次,如果用户启动之后没有退出过软件,那按照上述的实际上是没办法更新的,所以需要加入定时更新。

        需要注意的是,如果已经安装过全量包了,就不要再让用户下载了,也就是说不再检测更新了。

        笔者这里假设是两小时检测一次(预留足够长的时间,确保在这个时间内可以下载完全量更新包(因为增量更新包比全量小)),修改如下:

let timeInterval = null; //检测更新
var updateDownloading = false; //正在下载全量更新包

function checkForUpdates() {
  /防止如果有其他的触发机制,每次先清除定时器,每次触发则重新计时
  clearInterval(timeInterval);
  timeInterval = null;
  // 已下载完成或尚未下载
  if (!updateDownloading) {
    autoUpdater.checkForUpdates();
    timeInterval = setInterval(() => {
      if (!updateDownloading) autoUpdater.checkForUpdates();
    }, 7200000);
  } else {
  }
}
autoUpdater.on("update-available", (info) => {
  updateDownloading = true; 
});
autoUpdater.on("update-downloaded", () => {
  updateDownloading = false; 
}

        修改incrementUpdate():

let obsIncrementUpdate = ""; //服务器版本
let currentIncrementUpdate = ""; //本地版本
let hasCheckWaitUpdate = false; //稍后更新
let downloadApplying = false;

// 增量更新
async function incrementUpdate() {
  let oldPath = process.resourcesPath + "/app.asar.unpacked";
  let targetPath = process.resourcesPath + "/unpacked.zip";
  if (hasCheckWaitUpdate) {
      dialog
        .showMessageBox({
          type: "info",
          buttons: ["否", "是"],
          title: "应用更新",
          message: "更新包下载完成",
          detail: "请立即完成更新",
          defaultId: 0,
          cancelId: 0,
        })
        .then((res) => {
          if (res.response === 1) {
            handleIncreaseUpdate(oldPath, targetPath, obsIncrementUpdate);
          }
        });
    }
    return;
  }

  if (downloadApplying) {
    dialog
      .showMessageBox({
        type: "info",
        buttons: ["我知道了"],
        title: "应用更新",
        message: "更新包正在下载",
        detail: "请耐心等待",
      })
      .then((res) => {});
    return;
  }
  request(
    {
      method: "GET",
      uri: "https://xxx/hotVersion.json",
    },
    function (err, response, body) {
      if (response.statusCode == 200) {
        // 服务器版本
        obsIncrementUpdate = JSON.parse(body).version;
        //两个版本号不同,触发更新
        if (currentIncrementUpdate != obsIncrementUpdate) {
          downloadApplying = true;
          let req = request({
            method: "GET",
            uri: "https://xxx/unpacked.zip", //增量更新包在服务器上的路径
          });

          try {
            let out = fs.createWriteStream(targetPath);
            let received_bytes = 0;
            let total_bytes = 0;
            req.pipe(out);

            req.on("response", function (data) {
              total_bytes = parseInt(data.headers["content-length"]);
            });

            req.on("data", function (chunk) {
              received_bytes += chunk.length;
            });

            req.on("end", function () {
              if (req.response.statusCode === 200) {
                if (received_bytes === total_bytes) {
                  updateAtOnce(oldPath, targetPath, obsIncrementUpdate);
                } else {
                  out.end();
                  //...省略错误处理
                  downloadApplying = false;
                }
              } else {
                //网络波动,下载文件不全
                out.end();
                //...省略错误处理
                downloadApplying = false;
              }
            });
            req.on("error", (e) => {
              out.end();
              //网络波动,下载文件不全
              if (received_bytes !== total_bytes) {
               //...省略错误处理
              } else {
               //...省略错误处理
              }
              downloadApplying = false;
            });
          } catch (err) {
            //...省略错误处理
            downloadApplying = false;
          }
        } else {
        }
      } else {
        //读取线上的hotVersion错误
        //...省略错误处理
      }
    }
  );
}

        修改updateAtOnce():

async function updateAtOnce(oldPath, targetPath, obsIncrementUpdate) {
    hasCheckWaitUpdate = true;
    //...
}

        修改handleIncreaseUpdate():

let decompressing = false; //正在解压
let hasCheckWaitUpdate = false; //稍后更新
let downloadApplying = false;
async function handleIncreaseUpdate(
  oldPath,
  targetPath,
  obsIncrementUpdate,
  reload = true
) {
  if (!fs.existsSync(targetPath)) {
    hasCheckWaitUpdate = false;
    downloadApplying = false;
    return;
  }
  // 不能重复处理文件
  if (decompressing) {
    return;
  }
  decompressing = true;

  //...

  fs.rename(oldPath, oldPath + ".old", (err) => {
    if (err) {
      //...省略错误处理
      hasCheckWaitUpdate = false;
      downloadApplying = false;
      decompressing = false;
      return;
    }
    // ...
    zip.extractAllToAsync(oldPath, true, (err) => {
      if (err) {
        //...
        downloadApplying = false;
        decompressing = false;
        return;
      }
      //解压完之后别忘了要修改本地hotVersion文件的版本号,否则会一直触发更新
      fs.writeFile(
        //...
        (err, data) => {
          if (err) {
            //...省略错误处理
          } else {
            currentIncrementUpdate = obsIncrementUpdate;
            hasCheckWaitUpdate = false;
            if (reload) {
              //刷新页面
              win.webContents.reloadIgnoringCache();
            } else {
            }
          }
          downloadApplying = false;
          decompressing = false;
        }
      );
    });
  });
}

五、优化与兜底

5.1 强制更新

        切换账号或者退出系统时,检测是否已经下载了安装包但还没有更新,强制更:

ipcMain.on("mustUpdate", (event, args) => {
  if (hasCheckWaitUpdate) {
    let oldPath = process.resourcesPath + "/app.asar.unpacked";
    let targetPath = process.resourcesPath + "/unpacked.zip";
    handleIncreaseUpdate(oldPath, targetPath, obsIncrementUpdate);
    setTimeout(() => {
      closeWinAll();
    }, 2000);
  } else {
    closeWinAll();
  }
});

5.2 弹窗减少

        上面我们是定时检测更新,每次都会给出立即更新的确认框,但是用户可以不选择也不关闭,这样就会导致弹框反复弹出,他每个弹框都可以点击,但是当他点击其中一个弹框的时候,我们已经更新了,所以后面的点击可能会报错,为了解决这个问题,我们可以限制弹框只出现一次:

let messageBox = null; //立即更新提示框
async function incrementUpdate() {
  //...
  if (hasCheckWaitUpdate) {
    if (!messageBox) {
      messageBox = dialog
        .showMessageBox({
          //...
        })
        .then((res) => {
          messageBox = null;
          //...
        });
    }
    return;
  }
  //...
}
async function updateAtOnce(oldPath, targetPath, obsIncrementUpdate) {
    //...
    if (!messageBox) {
      messageBox = dialog
        .showMessageBox({
          //...
        })
        .then((res) => {
          messageBox = null;
          //...
        });
    }
}

总结

        以上就是笔者对electron的增量更新及优化的主要介绍,如果有什么记录不全的,欢迎提问~

  • 11
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 16
    评论
JavaScript 表单验证是一个非常常见的需求,可以通过 JavaScript 来验证表单是否符合指定的格式要求。 以下是一个简单的表单验证示例: ```html <html> <head> <title>表单验证</title> <script type="text/javascript"> function validateForm() { var name = document.forms["myForm"]["name"].value; var email = document.forms["myForm"]["email"].value; if (name == "") { alert("请填写姓名"); return false; } if (email == "") { alert("请填写电子邮件地址"); return false; } if (email.indexOf("@") == -1) { alert("请输入有效的电子邮件地址"); return false; } return true; } </script> </head> <body> <form name="myForm" onsubmit="return validateForm()" method="post"> <label for="name">姓名:</label> <input type="text" name="name" id="name"><br> <label for="email">电子邮件地址:</label> <input type="email" name="email" id="email"><br> <input type="submit" value="提交"> </form> </body> </html> ``` 在这个例子中,我们使用了 `validateForm()` 函数来验证表单。首先,我们获取了表单中的姓名和电子邮件地址,然后逐一进行验证。如果某个字段未填写或者电子邮件地址不符合格式要求,就会弹出相应的提示信息并返回 `false`。最后,如果表单中的所有字段都符合要求,就会返回 `true`,表单就会被提交。 在实际项目中,表单验证可能会更加复杂。可以使用正则表达式等工具来进行更加精确的验证。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 16
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值