1. 前言
-
本文参加了由 公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。
-
这是源码共读的第6期,链接:update-notifier 检测 npm 包是否更新
2. update-notifier
update-notifier是在给定的时间间隔内,检查package.json
中的 name 和 version 和当前 npm registry 上是否有新的更新可用,并在有更新时通知用户。它基于 ConfigStore
来保存和读取配置信息,使用了一些第三方库来获取最新版本的信息,并使用 boxen
来创建一个带有边框的通知框。
3. 源码调试
3.1 第三方库文件说明
process
:Node.js 的process
模块,提供了对进程的操作和控制。spawn
:child_process
模块的spawn
方法,用于启动一个子进程并执行命令。fileURLToPath
:url
模块的fileURLToPath
方法,用于将文件的 URL 转换为本地路径。path
:Node.js 的path
模块,提供了处理文件路径的工具函数。format
:util
模块的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!