Node 版本管理工具:nvm-desktop 进化啦!不依赖操作系统的功能和 shell,完美支持为项目切换不同的 Node 版本

前言

一开始在写这个项目的时候,并没有考虑要实现支持为不同项目设置和切换不同Node版本的功能,所以在之前的nvm-desktop版本中都是通过最简单的方式(Macos上通过shell脚本实现,Windows则只需要通过setx -m命令来更改系统环境变量)来实现的,只提供了最基础的Node全局设置和切换的功能。后面虽然在Macos上通过shell监听终端切换文件目录(cd命令)来实现了这个功能,但是是有缺陷的,比如Vscode中的Debug模式就无效了,于是之前只是发布了 Pre-release v1.3.0 版本临时充数。

但是其实为项目设置切换Node版本的功能在实际开发中还是挺需要的,当然也还是想把这个项目做好,更何况社区也已经有了类似挺成功的工具:Volta。再者说,如果不实现这个功能的话那这个项目其实做出来的意义也不大(应该也不会有人用了),所以最终还是痛定思痛,决心实现这个功能。

所以终于,nvm-desktop现在完美支持为不同项目单独设置并且切换不同Node版本的功能啦,而且底层实现上不依赖操作系统的任何特定功能以及shellMacosWindows都开箱即用,十分方便。

本文将主要介绍如何使用nvm-desktop为项目设置和切换不同Node版本的功能,以及底层是如何全新实现的。有关nvm-desktop的基础功能和使用教程可以查看上一篇文章:使用 nvm-desktop 轻松安装和管理多个 node 版本,这里就不再赘述。请下载安装最新的版本:Release v2.0.0 以获得最新、最完整的体验。

那么本文正式开始。

功能演示

为项目设置和切换不同的Node版本(上面的终端在Document目录下运行(全局),下面的终端在测试项目nvm-desktop目录下运行(项目)):

每个Node版本之间相互隔离,不会受到影响,这里以npm全局安装包命令为例:

功能演示的视频是在Macos平台上录制的,其实在Windows上的运行效果也是和这里一致的,这里就不再单独录制一份拿出来演示了。

新版本在体验上的改进

其实通过上面的演示Demo,大家应该已经能够明显感受出来跟之前版本在体验上的区别了:

  • 切换Node版本之后,不需要重启终端了,直接就能够生效
  • 支持为项目单独设置和切换Node版本
  • 每个Node版本之间完全相互隔离,不受彼此的影响

除此之外:

  • 完美支持VscodeDebug模式(调试项目的断点和日志输出等),不需要更改任何配置Vscode都能准确识别出正确的Node版本

(因为nvm-desktop不会更改Node的默认行为,只是让执行环境能够识别正确的Node的版本,下面会详细说明)

全新的底层实现

在自己经过一些调研和学习之后,如果想实现在双平台上为项目设置和切换Node的功能,那么按照之前的方案肯定是行不通的,再加上期间也去了解了一下 Volta 是如何实现的,所以最终有了这个想法(和Volta类似):实现一个代理Node引擎的可执行程序,Node引擎所有的命令(包括npmnpxcorepack)都会先走这个代理程序,在代理程序中会结合nvm-desktop客户端的设置识别出正确的Node版本及其安装路径,并将Node的安装路径注入到新建的一个子进程中,去执行其对应的命令。(大概就是这个意思🤔️...)

于是就衍生出了一个新的项目:nvmd-commandnvmd-command就是这个Node的代理程序,由Rust编写,无任何外部依赖,当然也不需要shell了。代码完全开源,如果有兴趣的话可以去看一下相关代码,这算是我用Rust写的第一个"东西"了,代码方面还有进步的空间,还望不吝指教。

主要依赖Rust的官方标准库:std::process::Command 实现。

nvmd-command 的工作原理

nvm-desktop客户端安装第一次启动的时候,在目录$HOME/.nvmd/bin下会安装上面截图中的文件,这个nvmd可执行文件就是由nvmd-command编译生成的,所以在你运行所有Node引擎相关的命令时,都会进入到nvmd可执行文件中。不过请放心,nvmd-command不会更改和影响Node引擎的任何默认行为,只是找到正确的Node版本然后去放开执行而已,具体代码可见:main.rs

rust 复制代码
let mut command = command::create_command(&exe);

// ENV_PATH 为新进程的环境变量PATH的值
// 会找到正确的 Node 版本的安装路径,然后将 Node 的路径拼接添加在原有父进程的环境变量 PATH 中

let child = command
    .env("PATH", ENV_PATH.clone())
    .args(&args)
    .spawn()
    .expect("command failed to start");

let output = child.wait_with_output().expect("failed to wait on child");

let code = match output.status.success() {
    true => 0,
    false => 1,
};

process::exit(code);

nvmd-command 识别正确Node版本的策略是:先从当前工作目录下查找.nvmdrc文件,如果有该文件就读取文件内容(Node的版本号)并作为当前的Node版本;如果文件不存在,那么就读取全局设置的默认的Node的版本;如果全局未设置默认的Node版本,那么此时行为就跟平时电脑未安装Node一样,终端会提示Node不是一个合法的命令

对应的代码:

rust 复制代码
// 获取正确的 Node 版本号
pub fn get_version() -> String {
    let mut nvmdrc = match env::current_dir() {
        Err(_) => PathBuf::from(""),
        Ok(dir) => dir,
    };
    nvmdrc.push(".nvmdrc");

    let project_version = match read_to_string(&nvmdrc) {
        Err(_) => String::from(""),
        Ok(v) => v,
    };

    if !project_version.is_empty() {
        return project_version;
    }

    let mut default_path = NVMD_PATH.clone();
    default_path.push("default");

    let default_version = match read_to_string(&default_path) {
        Err(_) => String::from(""),
        Ok(v) => v,
    };

    return default_version;
}

// 生成对应版本 Node 的安装目录下的可执行文件的路径
fn get_bin_path() -> OsString {
    let mut nvmd_path = NVMD_PATH.clone();
    nvmd_path.push("versions");
    nvmd_path.push(VERSION.clone());

    if cfg!(unix) {
        nvmd_path.push("bin");
    }

    let bin_path = nvmd_path.into_os_string();

    return bin_path;
}

// 拼接生成新的环境变量 PATH 的值
pub fn get_env_path() -> OsString {
    let bin_path = get_bin_path();

    let path = match env::var_os("PATH") {
        Some(path) => {
            let mut paths = env::split_paths(&path).collect::<Vec<_>>();

            paths.insert(0, PathBuf::from(bin_path));

            let env_path = match env::join_paths(paths) {
                Ok(p) => p,
                Err(_) => OsString::from(""),
            };

            return env_path;
        }
        None => bin_path,
    };

    return path;
}

那么至此,nvmd-command总能够快速识别到正确的Node的版本,然后去执行对应的命令,并且也不会影响其默认的行为。

对于npm全局包的管理需要额外处理

对于 npm 全局安装包的时候,则会在目录$HOME/.nvmd/bin下添加对应包的一个垫片(以 npm install @vue/cli typescript -g 为例):

那么包安装成功之后,在终端输入对应的命令(vue --version),也能够得到正确的识别和响应。

不过在全局包卸载的时候,就有问题了:如何判断移除这些包的垫片的时机?。因为我们的Node不止只有一个版本,不同版本的Nodenpm可能会安装同一个包,如果只是无脑在执行npm uninstall @vue/cli -g命令的时候去移除@vue/cli包的垫片的话,那么切换到其它版本的时候,那么@vue/cli就找不到无效了。于是,nvmd-command会去记录不同Node下通过npm install -g命令安装的所有全局包的包名、及其对应的Node版本的关系这些信息:

rust 复制代码
let code = match output.status.success() {
    true => 0,
    false => 1,
};

if code == 0 {
    // successed
    if (args.contains(&INSTALL) || args.contains(&UNINSTALL))
        && (args.contains(&SHORT_GLOBAL) || args.contains(&GLOBAL))
    {
        // npm install -g
        if args.contains(&INSTALL) {
            nvmd::install_packages(&args);
        }

        // npm uninstall -g
        if args.contains(&UNINSTALL) {
            nvmd::uninstall_packages();
        }
    }
}

nvmd::install_packagesnvmd::uninstall_packages 的代码就不贴出来了,里面的逻辑就是去找到对应包的 bin name,然后记录下来,作为添加移除这些包的垫片的依据(还是以 npm install @vue/cli typescript -g 为例):

json 复制代码
// "18.17.1", "20.5.1" 两个版本下都安装
// npm install @vue/cli typescript -g 时会记录如下信息
// 包对应的垫片不会被重复添加
{"tsc":["18.17.1", "20.5.1"],"tsserver":["18.17.1", "20.5.1"],"vue":["18.17.1", "20.5.1"]}

// 卸载 "20.5.1" 版本的
// npm uninstall @vue/cli typescript -g 则会变成这样
// 垫片不会被移除 因为还被 "18.17.1" 引用
{"tsc":["18.17.1"],"tsserver":["18.17.1"],"vue":["18.17.1"]}

// 继续卸载 "18.17.1" 版本的
// npm uninstall @vue/cli typescript -g 则会变成这样
// 垫片会被移除 因为不被任何版本引用
{"tsc":[],"tsserver":[],"vue":[]}

此时,如果对应垫片没有被任何版本的Node引用的话,该垫片才会被移除;同时,该垫片只有在没有被其他任何版本Node引用下才会被添加。

nvmd-commandnvm-desktop的关系

nvm-desktop是一个用Electron发开的一个桌面应用,这个应用提供了以可视化界面操作的形式让用户为自己的操作系统设置和切换Node版本的能力,而nvm-desktop想要具备这种能力就离不开nvmd-command,因为具体的功能是在nvmd-command中实现的。

nvmd-command则是一个单一、快速的本机可执行文件,没有外部依赖项,并且使用 Rust 构建,它依赖nvm-desktop的设置来识别出正确的Node版本。

两者相辅相成。

后话

其实在实现代理Node引擎的可执行文件的时候,一开始是用c++来进行编程的,期间在c++中做了很多测试和coding的工作,但是过程中遇到了很多问题,比如:新生成的环境变量PATH注入子进程之后并没有生效,程序进入死循环(调用自身);终端跑命令正常,但VscodeDebug无法启动命令等......最终是倒在了VscodeDebug模式下,调试程序打的断点无法命中的这个问题下(那会儿以为问题是出在多进程的通信这里,是不是这种方式导致通信出了问题,需要额外的处理,但是太麻烦了)🤷。

不过在后来研究了Volta的源码之后,发现Volta在进程通信这方面(代理执行Node引擎的命令这部分)并没有做出什么特殊的处理,只是单纯调用了std::process::Commandapi新建进程去执行而已,所以后来自己开始在本地使用Rust写了一些测试代码,最终发现在Rust中可行,喜极而泣😹。

目前还保留着c++中实现的代码,在nvmd-commandc++分支:github.com/1111mp/nvmd...

其中在c++尝试过如下api开启新进程执行命令的:

然后是 Windows 平台上的:

最后

如果你已经在电脑上安装了nvm-desktopv1.x.x版本的,那么请务必升级到最新的 Release v2.0.0 版本。不要忘记:

  • Macos 上,更改环境变量PATH

    shell 复制代码
    # from
    export NVMD_DIR="$HOME/.nvmd"
    [ -s "$NVMD_DIR/nvmd.sh" ] && . "$NVMD_DIR/nvmd.sh" # This loads nvmd
    
    # to
    export NVMD_DIR="$HOME/.nvmd" 
    export PATH="$NVMD_DIR/bin:$PATH"
  • Windows 上,只需清理计算机上以前版本留下的无用环境变量即可(如果不这样做,不会有任何区别):

    javascript 复制代码
    Remove the environment variable named `NVMD` and remove the reference to it from `PATH`.

如果您在使用过程中遇到问题,请检查操作系统中的环境变量是否有效。当然也十分欢迎你的 issues

Github地址:

下载地址:

如果好用的话请留下您的Star,还请点赞收藏这篇文章,以便让掘金的推荐算法把这篇文章推广给更多的同仁。十分感谢🙏。

相关推荐
甜兒.32 分钟前
鸿蒙小技巧
前端·华为·typescript·harmonyos
Jiaberrr4 小时前
前端实战:使用JS和Canvas实现运算图形验证码(uniapp、微信小程序同样可用)
前端·javascript·vue.js·微信小程序·uni-app
everyStudy4 小时前
JS中判断字符串中是否包含指定字符
开发语言·前端·javascript
城南云小白4 小时前
web基础+http协议+httpd详细配置
前端·网络协议·http
前端小趴菜、4 小时前
Web Worker 简单使用
前端
web_learning_3214 小时前
信息收集常用指令
前端·搜索引擎
tabzzz5 小时前
Webpack 概念速通:从入门到掌握构建工具的精髓
前端·webpack
200不是二百5 小时前
Vuex详解
前端·javascript·vue.js
滔滔不绝tao5 小时前
自动化测试常用函数
前端·css·html5
码爸5 小时前
flink doris批量sink
java·前端·flink