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

1. 前言

2. update-notifier

update-notifier是在给定的时间间隔内,检查package.json 中的 name 和 version 和当前 npm registry 上是否有新的更新可用,并在有更新时通知用户。它基于 ConfigStore 来保存和读取配置信息,使用了一些第三方库来获取最新版本的信息,并使用 boxen 来创建一个带有边框的通知框。

3. 源码调试

3.1 第三方库文件说明

  • process:Node.js 的 process 模块,提供了对进程的操作和控制。
  • spawnchild_process 模块的 spawn 方法,用于启动一个子进程并执行命令。
  • fileURLToPathurl 模块的 fileURLToPath 方法,用于将文件的 URL 转换为本地路径。
  • path:Node.js 的 path 模块,提供了处理文件路径的工具函数。
  • formatutil 模块的 format 方法,用于格式化字符串。
  • ConfigStore:一个用于保存配置信息的库。
  • chalk:一个用于在终端中添加颜色和样式的库。
  • semver:用于对语义化版本进行比较和操作的库。
  • semverDiff:用于计算两个版本之间的差异的库。
  • latestVersion:用于获取指定包名的最新版本号的库。
  • isNpmOrYarn:一个用于检测当前项目是使用npm还是yarn的库。
  • isInstalledGlobally:一个用于检测当前包是否全局安装的库。
  • boxen:用于在终端创建带有边框的框的库。
  • xdgConfig:一个用于获取 XDG 配置目录路径的库。
  • isInCi:用于检测当前是否在 CI 环境下运行的库。
  • pupa:用于填充模板字符串的库。

UpdateNotifier 类有一些公共属性和方法,用于检查和通知更新。其中重要的属性和方法包括:

  • config:一个 ConfigStore 实例,用于保存和读取更新通知的配置信息。
  • update:一个保存最新版本信息的对象,包括当前版本、最新版本和版本差异。
  • check():用于检查是否有新的更新可用。
  • fetchInfo():用于获取最新版本的信息。
  • notify():用于通知用户有新的更新可用。

3.2 函数说明

js 复制代码
class UpdateNotifier {
    // 初始化操作
	constructor(options = {}) {}
    // 检查
	check() {}
    // 获取版本信息
	fetchInfo() {}
    // 提示
	notify(options) {}
}

初始化

js 复制代码
	constructor(options = {}) {
		this.#options = options;
		options.pkg = options.pkg ?? {};
		options.distTag = options.distTag ?? "latest";

		// Reduce pkg to the essential keys. with fallback to deprecated options
		// TODO: Remove deprecated options at some point far into the future 兼容以前版本处理
		options.pkg = {
			name: options.pkg.name ?? options.packageName,
			version: options.pkg.version ?? options.packageVersion,
		};
		// 必须传项 pkg.name(需要校验的包), pkg.version(需要校验包的版本)
		if (!options.pkg.name || !options.pkg.version) {
			throw new Error("pkg.name and pkg.version required");
		}
		this._packageName = options.pkg.name;
		this.#packageVersion = options.pkg.version;
		// 默认检查间隔为 1 天 小于这个间隔时不再检查
		this.#updateCheckInterval =
			typeof options.updateCheckInterval === "number"
				? options.updateCheckInterval
				: ONE_DAY;
		// 禁用提示
		// 1. 在环境变量中禁用 NO_UPDATE_NOTIFIER
		// 2. 在环境变量中禁用 NODE_ENV 为 test
		// 3. 在命令行中禁用 node example.js --no-update-notifier
		// 4. 在CI中禁用
		this.#isDisabled =
			"NO_UPDATE_NOTIFIER" in process.env ||
			process.env.NODE_ENV === "test" ||
			process.argv.includes("--no-update-notifier") ||
			isInCi;
		// 是否在npm脚本中通知
		this._shouldNotifyInNpmScript = options.shouldNotifyInNpmScript;
		// 如果没有禁用提示 继续执行
		if (!this.#isDisabled) {
			try {
                            // 持久化存储数据 存储一些默认值如 {optOut: false, lastUpdateCheck: Date.now()} 存储位置 ~/.config/update-notifier
                            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" }));
                            });
			}
		}
	}

check

js 复制代码
	check() {
		// 判断是否禁用
		// 1. 是否存在config属性
		// 2. 是否有optOut字段
		// 3. 是否禁用提示
		if (!this.config || this.config.get("optOut") || this.#isDisabled) {
			return;
		}
		// 第一次是undefined
		this.update = this.config.get("update");
		// 如果存在update 更新当前的最新版本current
		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
		// 如果当前检测时间和上一次检测的时间间隔小于 updateCheckInterval, 则不进行校验, 防止校验次数过多, 降低用户体验
		if (
			Date.now() - this.config.get("lastUpdateCheck") <
			this.#updateCheckInterval
		) {
			return;
		}

		// Spawn a detached process, passing the options as an environment property
		// 启动一个独立的子进程中执行 check.js 文件,并将 this.options 对象作为参数传递给 check.js。子进程的标准输入、输出和错误流都会被忽略,子进程与父进程的关联也会被解除。
		// 目的是更新configStore中的update数据
		spawn(
			process.execPath,
			[path.join(__dirname, "check.js"), JSON.stringify(this.#options)],
			{
				detached: true,
				stdio: "ignore",
			},
		).unref();
	}

check.js

js 复制代码
/* eslint-disable unicorn/no-process-exit */
import process from "node:process";
import UpdateNotifier from "./update-notifier.js";
// 传入this.#options的配置
const updateNotifier = new UpdateNotifier(JSON.parse(process.argv[2]));

try {
	// Exit process when offline
	// 30s退出
	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

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

		return {
			latest,
			current: this.#packageVersion,
			type: semverDiff(this.#packageVersion, latest) ?? distTag,
			name: this._packageName,
		};
	}

notify

js 复制代码
	notify(options) {
		// 如果是npm或yarn且不在npm脚本中通知,则不通知
		const suppressForNpm = !this._shouldNotifyInNpmScript && isNpmOrYarn;
		// 是否提示
		// 1. 判断TTY是否可用
		// 2. 判断是不是在npm脚本中
		// 3. 判断是否有没有update信息
		// 4. 判断是否大于最新版本
		if (
			!process.stdout.isTTY ||
			suppressForNpm ||
			!this.update ||
			!semver.gt(this.update.latest, this.update.current)
		) {
			return this;
		}
		// 是否是全局安装
		options = {
			isGlobal: isInstalledGlobally,
			...options,
		};
		// 根据判断是否是全局安装 设置不同命令
		const installCommand = options.isGlobal
			? `npm i -g ${this._packageName}`
			: `npm i ${this._packageName}`;
		// 提示信息 chalk是一个命令行颜色库
		const defaultTemplate =
			"Update available " +
			chalk.dim("{currentVersion}") +
			chalk.reset(" → ") +
			chalk.green("{latestVersion}") +
			" \nRun " +
			chalk.cyan("{updateCommand}") +
			" to update";
		// 如果有传入message则使用传入的, 否则使用默认
		const template = options.message || defaultTemplate;
		// 如果没有传入boxenOptions则使用默认  这个库来构建各种不同的方框包裹的的提示信息
		options.boxenOptions ??= {
			padding: 1,
			margin: 1,
			textAlignment: "center",
			borderColor: "yellow",
			borderStyle: "round",
		};
		// 渲染提示信息 pupa将数据渲染到模板中的占位符
		const message = boxen(
			pupa(template, {
				packageName: this._packageName,
				currentVersion: this.update.current,
				latestVersion: this.update.latest,
				updateCommand: installCommand,
			}),
			options.boxenOptions,
		);
		// 如果options.defer属性的值为false,则在控制台输出错误信息。否则,注册一个"exit"事件监听器,该监听器在进程退出时输出错误信息;另外,还注册一个"SIGINT"事件监听器,该监听器在接收到SIGINT信号(通常由用户按下Ctrl+C触发)时输出错误信息,并退出进程。
		if (options.defer === false) {
			console.error(message);
		} else {
			process.on("exit", () => {
				console.error(message);
			});

			process.on("SIGINT", () => {
				console.error("");
				process.exit();
			});
		}

		return this;
	}

第一次初始化: 本地存储数据: 执行check: 执行后本地存储: 结果:

4. 总结

总结一下流程:

如有错误,请指正!O^O!

相关推荐
霍先生的虚拟宇宙网络10 分钟前
webp 网页如何录屏?
开发语言·前端·javascript
jessezappy30 分钟前
jQuery-Word-Export 使用记录及完整修正文件下载 jquery.wordexport.js
前端·word·jquery·filesaver·word-export
旧林8431 小时前
第八章 利用CSS制作导航菜单
前端·css
yngsqq1 小时前
c#使用高版本8.0步骤
java·前端·c#
Myli_ing2 小时前
考研倒计时-配色+1
前端·javascript·考研
余道各努力,千里自同风2 小时前
前端 vue 如何区分开发环境
前端·javascript·vue.js
软件小伟2 小时前
Vue3+element-plus 实现中英文切换(Vue-i18n组件的使用)
前端·javascript·vue.js
醉の虾2 小时前
Vue3 使用v-for 渲染列表数据后更新
前端·javascript·vue.js
张小小大智慧2 小时前
TypeScript 的发展与基本语法
前端·javascript·typescript
hummhumm3 小时前
第 22 章 - Go语言 测试与基准测试
java·大数据·开发语言·前端·python·golang·log4j