likes
comments
collection
share

给你的npm包增加更新检测 - update-notifier源码学习

作者站长头像
站长
· 阅读数 12

本文参加了由公众号@若川视野 发起的每周源码共读活动,      点击了解详情一起参与。

这是源码共读的第6期 | update-notifier 检测 npm 包是否更新

前言

我们在开发一个轮子或者CLI工具时,大部分情况都不会去考虑后续用户使用时的版本检测操作,update-notifier非常精简的实现了版本的自动检测和更新提示,接下来会细细道来其中的实现原理~

使用

使用npm init -y 创建一个库,接着将库的名字改为public-ip用于测试。 然后将以下代码执行两次就可以看到回显

import updateNotifier from "update-notifier";
//import packageJson from './package.json' assert {type: 'json'};
//手动读取 packageJson
import fs from "node:fs";

const pkgFile = fs.readFileSync("./package.json", { encoding: "utf-8" });
const pkgJson = JSON.parse(pkgFile.toString());
new updateNotifier({ pkg: pkgJson, updateCheckInterval: 0 }).notify();

入口

老规矩,在package.json中 我们可以明确其执行路径为: "exports": "./index.js"

import UpdateNotifier from "./update-notifier.js";

export default function updateNotifier(options) {
	const updateNotifier = new UpdateNotifier(options);
	updateNotifier.check();
	return updateNotifier;
}

初始化

constructor(options = {}) {
    //省略若干初始化参数代码
    
    this.#updateCheckInterval =
    typeof options.updateCheckInterval === "number"
            ? options.updateCheckInterval
            : ONE_DAY;
if (!this.#isDisabled) {
        try {
                this.config = new ConfigStore(`update-notifier-${this._packageName}`, {
                        optOut: false,
                        // Init with the current time so the first check is only
                        // after the set interval, so not to bother users right away
                        lastUpdateCheck: Date.now(),
                });
        } catch {
                // Expecting error code EACCES or EPERM
                const message =
                        chalk.yellow(format(" %s update check failed ", options.pkg.name)) +
                        format("\n Try running with %s or get access ", chalk.cyan("sudo")) +
                        "\n to the local update config store via \n" +
                        chalk.cyan(
                                format(" sudo chown -R $USER:$(id -gn $USER) %s ", xdgConfig)
                        );

                process.on("exit", () => {
                        console.error(boxen(message, { textAlignment: "center" }));
                });
        }
}
}

这里可以看到熟悉的库ConfigStore,上一期也讲过这个库,其作用就是数据本地文件持久化。 这一段代码会将按照我们传递要检测的包作为文件名,然后将执行时的时间作为最后一次检测时间存储进去。

  1. 如果是第一次使用这个库执行检测更新,那么不会有任何结果,仅仅是创建了这个文件并存储
  2. 另外如果你没有指定updateCheckInterval参数,那么它默认只有在一天之后再会去做比较执行
  3. 所以你可以通过改变本地时间来达到提前检测的目的(嘿嘿)

接着回到入口处它会接着执行updateNotifier.check();

check

check() {
    if (!this.config || this.config.get("optOut") || this.#isDisabled) {
            return;
    }

    this.update = this.config.get("update");

    if (this.update) {
            // Use the real latest version instead of the cached one
            this.update.current = this.#packageVersion;

            // Clear cached information
            this.config.delete("update");
    }
    //如果现在时间减去上一次存储的时间 小于 检测间隔则啥也不干
    // Only check for updates on a set interval
    if (
            Date.now() - this.config.get("lastUpdateCheck") <
            this.#updateCheckInterval
    ) {
            return;
    }
    // spawn 子进程执行命令 process.execPath 得到node执行路径 即node命令 然后执行文件是 当前目录下的 check.js 执行参数
    // Spawn a detached process, passing the options as an environment property
    spawn(
            process.execPath,
            [path.join(__dirname, "check.js"), JSON.stringify(this.#options)],
            {
                    detached: true,
                    stdio: "ignore", //不输出 执行中的结果 抛出到控制台
            }
    ).unref(); //unref 父级的事件循环不将子级包括在其引用计数中
}
//取出被转为Json字符串的  对象参数 并转回对象作为参数使用
const updateNotifier = new UpdateNotifier(JSON.parse(process.argv[2]));

try {
	// Exit process when offline
	setTimeout(process.exit, 1000 * 30);

	const update = await updateNotifier.fetchInfo();

	// Only update the last update check time on success
	updateNotifier.config.set("lastUpdateCheck", Date.now());

	if (update.type && update.type !== "latest") {
		updateNotifier.config.set("update", update);
	}

	// Call process exit explicitly to terminate the child process,
	// otherwise the child process will run forever, according to the Node.js docs
	process.exit();
} catch (error) {
	console.error(error);
	process.exit(1);
}

跟着会调用fetchInfo方法,会去获取最新的一个版本号作比较并返回构造的对象信息

semverDiff、semver 库都是基于semver版本号规范的轮子,用于版本号的比较等

async fetchInfo() {
    const { distTag } = this.#options;
    const latest = await latestVersion(this._packageName, { version: distTag });

    return {
            latest,
            current: this.#packageVersion,
            //更新的版本类型 是 major、patch 还是什么默认的latest
            type: semverDiff(this.#packageVersion, latest) || distTag,
            name: this._packageName,
    };
}

当得到返回信息后,会将检测时间进行更新,接着根据返回的版本类型(latest即最新的)是不是最新的 而决定更新update这个字段

notify

而notify方法则非常简单,就是调用控制台输出的库,根据当前使用的包管理工具决定输出的信息,最终只看这个判断的执行然后就可以看到控制台的输出了

if (
    !process.stdout.isTTY ||
    suppressForNpm ||
    !this.update ||
    !semver.gt(this.update.latest, this.update.current)
) {
    return this;
}

check函数中已经更新了 this.update.latest 的值,而在初始化中更新了this.update.current的值,两者通过比较,以及其他条件的判断进行输出

一些其他补充

is-ci 默认的导出依赖了 ci-info这个包, 其原理就是通过预先定义好的各种CI 环境信息去做process.env的匹配,当前是否处于 CI服务器环境下

process.stdout.isTTY 用于判断命令执行是否在终端环境 suppressForNpm 是否为Npm Yarn process.execPath 执行node 的环境变量

devdocs.io/node~16_lts…

child_process.spawn(command[, args][, options]) 执行的命令 传递参数 命令参数

总结

梳理下最终可以得到这样一个流程:

  1. 先执行初始化,创建本地文件更新第一次存储的时间
  2. 每次check函数中 会先比较一次 本地时间和持久化文件中的时间,条件符合则更新一次存储时间,并请求最新的版本返回用于比较
  3. 根据前面得到的版本信息进行比较,再根据当前环境决定提示信息的拼装,最后控制台输出更新提示信息