【源码学习】 第16期 | 如何及时检查npm包是否更新?

156 阅读4分钟

前言

    日常开发中如何及时检查项目依赖的npm包是否有更新?有没有工具能提醒我什么包该更新了?答案肯定是有的,今天就来看看检测npm包更新的update-notifier的源码实现!

收获清单

    听说带着目标去学习效率更高喔!

  • update-notifier源码调试
  • update-notifier应用场景

代码准备

git clone https://github.com/yeoman/update-notifier.git
cd update-notifier
pnpm install / npm install / yarn

代码调试

    虽然代码调试的步骤都大同小异,但是每次学习都记录下来是希望能够凑字数在阅读时跟着思路一起实践一遍~
    下载好代码后,不熟悉使用的可以先看README.md,接着看package.json的脚本命令、依赖等,一般装包时装不成功就要看一下自己当前的node环境是否符合engines标签的node环境~
    点击悬浮 update-notifier 的package.json出现的 调试脚本 开启调试

打断点

    开启调试前很关键的一步就是找到入口文件打断点,不然你调试脚本只是干跑了一次脚本而已,update-notifier的脚本命令是test,一般test命令所在测试文件是test.js或者test文件夹,如下图位置,然后在引用update-notifier的地方打断点再开启调试就可以跳到update-notifier,当然package.json也有写包的入口文件【可以在看不懂的地方打断点】,这也是为什么调试源码前都要看package.json的原因~ 图片.png

调试截图

    也可以在script"example": "node ./example.js"开启调试 图片.png

源码分析

引入依赖

    遇到不会的包强推 npmgithub 大法

//进程
import process from 'node:process';
// 子进程
import {spawn} from 'node:child_process';
// url模块
import {fileURLToPath} from 'node:url';
// path模块
import path from 'node:path';
// node的格式化函数
import {format} from 'node:util';
// 存储配置
import ConfigStore from 'configstore';
// 终端颜色
import chalk from 'chalk';
// 语义化版本管理
import semver from 'semver';
// 两个版本号之间的差异类型
import semverDiff from 'semver-diff';
// 获取npm包的最新版本
import latestVersion from 'latest-version';
// 判断是以npm还是yarn运行包
import {isNpmOrYarn} from 'is-npm';
// 判断是否全局安装包
import isInstalledGlobally from 'is-installed-globally';
// 检查是否通过yarn全局安装
import isYarnGlobal from 'is-yarn-global';
// 检测包是否使用yarn
import hasYarn from 'has-yarn';
// 在终端中创建方框
import boxen from 'boxen';
// 获取XDG基础目录路径
import {xdgConfig} from 'xdg-basedir';
// 判断当前环境是否持续集成服务器
import isCi from 'is-ci';
// 微模板
import pupa from 'pupa';
// 当前模块的url路径
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// 一天的时间戳,自动检测包更新用
const ONE_DAY = 1000 * 60 * 60 * 24;

UpdateNotifier类

变量及构造器
// Public

    config;

    update;
    // Semi-private (used for tests)

    _packageName;

    _shouldNotifyInNpmScript;

    #options;

    #packageVersion;

    #updateCheckInterval;

    #isDisabled;

    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,

        };

        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;
        // 检测时间
        this.#updateCheckInterval = typeof options.updateCheckInterval === 'number' ? options.updateCheckInterval : ONE_DAY;
       // 不能检测更新的情况
        this.#isDisabled = 'NO_UPDATE_NOTIFIER' in process.env

            || process.env.NODE_ENV === 'test'

            || process.argv.includes('--no-update-notifier')

            || isCi;

        this._shouldNotifyInNpmScript = options.shouldNotifyInNpmScript;

        // 可以检测包更新的情况保存配置,否则报错并退出进程
        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'}));

                });

            }

        }

    }
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 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();

    }

fetchInfo

// 获取最新版本、当前版本、版本差异、包名等信息
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

notify(options) {

        const suppressForNpm = !this._shouldNotifyInNpmScript && isNpmOrYarn;
        // process.stdout.isTTY 用来判断 Node.js 是否运行在一个 TTY 环境中
        // 返回 UpdateNotifier
        if (!process.stdout.isTTY || suppressForNpm || !this.update || !semver.gt(this.update.latest, this.update.current)) {

            return this;

        }

        options = {

            isGlobal: isInstalledGlobally,

            isYarnGlobal: isYarnGlobal(),

            ...options,

        };

        let installCommand;

        if (options.isYarnGlobal) {

            installCommand = `yarn global add ${this._packageName}`;

        } else if (options.isGlobal) {

            installCommand = `npm i -g ${this._packageName}`;

        } else if (hasYarn()) {

            installCommand = `yarn add ${this._packageName}`;

        } else {

            installCommand = `npm i ${this._packageName}`;

        }

       // 终端输出默认模板
        const defaultTemplate = 'Update available '

            + chalk.dim('{currentVersion}')

            + chalk.reset(' → ')

            + chalk.green('{latestVersion}')

            + ' \nRun ' + chalk.cyan('{updateCommand}') + ' to update';

        const template = options.message || defaultTemplate;
        
      // 终端方框样式
        options.boxenOptions = options.boxenOptions || {

            padding: 1,

            margin: 1,

            textAlignment: 'center',

            borderColor: 'yellow',

            borderStyle: 'round',

        };
       // 终端方框输出模板
        const message = boxen(

            pupa(template, {

                packageName: this._packageName,

                currentVersion: this.update.current,

                latestVersion: this.update.latest,

                updateCommand: installCommand,

            }),

            options.boxenOptions,

        );

        if (options.defer === false) {

            console.error(message);

        } else {

            process.on('exit', () => {

                console.error(message);

            });
            process.on('SIGINT', () => {

                console.error('');

                process.exit();

            });

        }

        return this;

    }

运行结果截图

图片.png

总结

    今天调试分析了update-notifier的源码,主要源码差不多两百行,关键就是通过semver-diff来对比项目应用包的当前版本与该包的最新版本来达到检测包是否更新的目的,日常工作中可以利用update-notifier这个小工具来及时地更新相应依赖~