【源码阅读-ni】npm,yarn,pnpm各种包管理工具,如何自动管理?

先说一下,这篇文章的由来。

前几天看到若川的推文,介绍一个39行小工具 install-pkg,可以解决包管理工具的命令管理功能。

看完之后感觉很实用,故想把该功能集成到自己的脚手架里面去,因此特意去看了一下源码。(我看的是ni库,是同一个作者,核心代码也是一样的)

先看一下这个库解决了一个什么问题?

不同项目,可能用的包管理软件不同,直接粗暴的用npm可能会有问题,比如依赖的版本,关系不正确,比如产生多个lock文件。

这个库就是根据项目的lock文件,自动匹配对应的命令脚本。

那么这是如何实现的呢?(先理源码思路)

1.如何确认项目使用的管理工具

源码中有一个detect方法,就是用来确认管理工具的。

typescript 复制代码
export async function detect({
  autoInstall,
  programmatic,
  cwd,
}: DetectOptions = {}) {
  let agent: Agent | null = null;
  let version: string | null = null;

  const lockPath = await findUp(Object.keys(LOCKS), { cwd });
  let packageJsonPath: string | undefined;

  if (lockPath) packageJsonPath = path.resolve(lockPath, "../package.json");
  else packageJsonPath = await findUp("package.json", { cwd });

  // read `packageManager` field in package.json
  if (packageJsonPath && fs.existsSync(packageJsonPath)) {
    try {
      const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
      if (typeof pkg.packageManager === "string") {
        const [name, ver] = pkg.packageManager.split("@");
        version = ver;
        if (name === "yarn" && Number.parseInt(ver) > 1) agent = "yarn@berry";
        else if (name === "pnpm" && Number.parseInt(ver) < 7) agent = "pnpm@6";
        else if (name in AGENTS) agent = name;
        else if (!programmatic)
          console.warn("[ni] Unknown packageManager:", pkg.packageManager);
      }
    } catch {}
  }

  // detect based on lock
  if (!agent && lockPath) agent = LOCKS[path.basename(lockPath)];

  // auto install
  if (agent && !cmdExists(agent.split("@")[0]) && !programmatic) {
    if (!autoInstall) {
      console.warn(
        `[ni] Detected ${agent} but it doesn't seem to be installed.\n`
      );

      if (process.env.CI) process.exit(1);

      const link = terminalLink(agent, INSTALL_PAGE[agent]);
      const { tryInstall } = await prompts({
        name: "tryInstall",
        type: "confirm",
        message: `Would you like to globally install ${link}?`,
      });
      if (!tryInstall) process.exit(1);
    }

    await execaCommand(`npm i -g ${agent}${version ? `@${version}` : ""}`, {
      stdio: "inherit",
      cwd,
    });
  }

  return agent;
}

第一步,先匹配对应目录下是哪种lock文件。

ini 复制代码
  const lockPath = await findUp(Object.keys(LOCKS), { cwd });

ps(根据文件名查找路径用的是find-up库)

第二步,如果package.json中指定了packageManager,则使用packageManager中的配置,否则就是lock为准。

第三步,判断命令是否存在,如果不存在,则进行自动安装

ps(terminalLink方法是在命令行上创建一个可点击的链接。prompts创建一个交互界面,选择是否尝试安装。execaCommand为执行命令方法)

如何匹配命令

比如最常见的安装命令,我们需要根据不同的工具执行不同的命令。

核心的代码是getCommand函数。

ps(ni -v 获取到的args参数是["-v"])

typescript 复制代码
export function getCommand(
  agent: Agent,
  command: Command,
  args: string[] = []
) {
  if (!(agent in AGENTS)) throw new Error(`Unsupported agent "${agent}"`);

  const c = AGENTS[agent][command];

  if (typeof c === "function") return c(args);

  if (!c) throw new UnsupportedCommand({ agent, command });

  const quote = (arg: string) =>
    !arg.startsWith("--") && arg.includes(" ") ? JSON.stringify(arg) : arg;

  return c.replace("{0}", args.map(quote).join(" ")).trim();
}

在源码中,已经将各个工具能用到的命令都枚举出来了。(包括安装,移除,升级,全局,npx)

根据agent,以及command可以匹配到具体的命令,然后再通过替换占位符的方式,拼接出完整的命令。

如何开始源码调试

现在我们大致知道了这个工具实现的功能,以及如何实现的。

那我们再来详细的看看源码是怎么写的,也可以调试一下。

第1步: 克隆代码,安装依赖

第2步, 执行pnpm run build && pnpm run stub

打包用的是unbuild,stub类似监听。

第3步, 找入口文件开始调试。

ni的入口是bin下面的ni.mjs文件。

javascript 复制代码
#!/usr/bin/env node
'use strict'
import '../dist/ni.mjs'

引用的是dist里面的,那肯定是打包出来的文件,所以我们再找一下打包配置。

javascript 复制代码
import { basename } from 'node:path'
import { defineBuildConfig } from 'unbuild'
import fg from 'fast-glob'

export default defineBuildConfig({
  entries: [
    ...fg.sync('src/commands/*.ts').map(i => ({
      input: i.slice(0, -3),
      name: basename(i).slice(0, -3),
    })),
  ],
  clean: true,
  declaration: true,
  rollup: {
    emitCJS: true,
    inlineDependencies: true,
  },
})

这下总算是找到了,在src/commands/

javascript 复制代码
import { parseNi } from '../parse'
import { runCli } from '../runner'

runCli(parseNi)

源码中还实现了哪些功能?

源码其实就执行了一个方法,runCli-> run。

typescript 复制代码
export async function run(
  fn: Runner,
  args: string[],
  options: DetectOptions = {}
) {
  console.log(args);
  const debug = args.includes(DEBUG_SIGN);
  console.log("debug", debug, args);
  if (debug) remove(args, DEBUG_SIGN);

  let cwd = options.cwd ?? process.cwd();
  if (args[0] === "-C") {
    cwd = resolve(cwd, args[1]);
    args.splice(0, 2);
  }
  if (
    args.length === 1 &&
    (args[0]?.toLowerCase() === "-v" || args[0] === "--version")
  ) {
    const getCmd = (a: Agent) =>
      agents.includes(a) ? getCommand(a, "agent", ["-v"]) : `${a} -v`;
    const getV = (a: string, o?: ExecaOptions) => {
      return execaCommand(getCmd(a as Agent), o)
        .then((e) => e.stdout)
        .then((e) => (e.startsWith("v") ? e : `v${e}`));
    };

    const globalAgentPromise = getGlobalAgent();
    const globalAgentVersionPromise = globalAgentPromise.then(getV);
    const agentPromise = detect({ ...options, cwd }).then((a) => a || "");
    const agentVersionPromise = agentPromise.then((a) => a && getV(a, { cwd }));
    const nodeVersionPromise = getV("node", { cwd });

    console.log(`@antfu/ni  ${c.cyan(`v${version}`)}`);
    console.log(`node       ${c.green(await nodeVersionPromise)}`);
    const [agent, agentVersion] = await Promise.all([
      agentPromise,
      agentVersionPromise,
    ]);
    if (agent) console.log(`${agent.padEnd(10)} ${c.blue(agentVersion)}`);
    else console.log("agent      no lock file");
    const [globalAgent, globalAgentVersion] = await Promise.all([
      globalAgentPromise,
      globalAgentVersionPromise,
    ]);
    console.log(
      `${`${globalAgent} -g`.padEnd(10)} ${c.blue(globalAgentVersion)}`
    );
    return;
  }

  if (args.length === 1 && (args[0] === "--version" || args[0] === "-v")) {
    console.log(`@antfu/ni v${version}`);
    return;
  }

  if (args.length === 1 && ["-h", "--help"].includes(args[0])) {
    const dash = c.dim("-");
    console.log(
      c.green(c.bold("@antfu/ni")) +
        c.dim(` use the right package manager v${version}\n`)
    );
    console.log(`ni    ${dash}  install`);
    console.log(`nr    ${dash}  run`);
    console.log(`nlx   ${dash}  execute`);
    console.log(`nu    ${dash}  upgrade`);
    console.log(`nun   ${dash}  uninstall`);
    console.log(`nci   ${dash}  clean install`);
    console.log(`na    ${dash}  agent alias`);
    console.log(`ni -v ${dash}  show used agent`);
    console.log(
      c.yellow("\ncheck https://github.com/antfu/ni for more documentation.")
    );
    return;
  }

  let command = await getCliCommand(fn, args, options, cwd);
  console.log("command", command);
  if (!command) return;

  const voltaPrefix = getVoltaPrefix();
  if (voltaPrefix) command = voltaPrefix.concat(" ").concat(command);

  if (debug) {
    console.log(command);
    return;
  }

  await execaCommand(command, { stdio: "inherit", encoding: "utf-8", cwd });
}

ps(console是我本地调试加的)

1.判断是否是debug模式

ini 复制代码
const DEBUG_SIGN = "?";
const debug = args.includes(DEBUG_SIGN);

如果是debug模式则只输出命令,不执行。

lua 复制代码
if (debug) {
    console.log(command);
    return;
  }

ps(?是特殊字符,需要写成ni "?")

2.是否是-v,-h

脚手架基本都有的两个参数。

而且不仅仅只是输出工具版本,而是把node,npm,以及当前工具版本都输出出来了。

ps(查找某个工具是否安装,用的是which库)

3.生成完整命令并执行(重点)

ini 复制代码
  let command = await getCliCommand(fn, args, options, cwd);
  console.log("command", command);
  if (!command) return;

  const voltaPrefix = getVoltaPrefix();
  if (voltaPrefix) command = voltaPrefix.concat(" ").concat(command);

  if (debug) {
    console.log(command);
    return;
  }

  await execaCommand(command, { stdio: "inherit", encoding: "utf-8", cwd });

getCliCommand跟execaCommand我们之前已经介绍过了。

那这个fn是啥呢?

fn就是入口那里传入的parseNi。

javascript 复制代码
export const parseNi = <Runner>((agent, args, ctx) => {
  // bun use `-d` instead of `-D`, #90
  if (agent === "bun") args = args.map((i) => (i === "-D" ? "-d" : i));

  if (args.includes("-g"))
    return getCommand(agent, "global", exclude(args, "-g"));

  if (args.includes("--frozen-if-present")) {
    args = exclude(args, "--frozen-if-present");
    return getCommand(agent, ctx?.hasLock ? "frozen" : "install", args);
  }

  if (args.includes("--frozen"))
    return getCommand(agent, "frozen", exclude(args, "--frozen"));

  if (args.length === 0 || args.every((i) => i.startsWith("-")))
    return getCommand(agent, "install", args);

  return getCommand(agent, "add", args);
});

getCommand方法我们已经看过了。

parseNi方法就是对参数做一个处理,比如-g,要变成global,因为要到AGENTS里面做匹配。

总结一下

虽然是个小工具,实现的功能也比较简单。

但是里面的代码实现细节,处理方式还是能学到很多东西。

如果看完有收获,欢迎点赞、评论、分享支持。你的支持和肯定,是我写作的动力

相关推荐
腾讯TNTWeb前端团队6 小时前
helux v5 发布了,像pinia一样优雅地管理你的react状态吧
前端·javascript·react.js
范文杰9 小时前
AI 时代如何更高效开发前端组件?21st.dev 给了一种答案
前端·ai编程
拉不动的猪9 小时前
刷刷题50(常见的js数据通信与渲染问题)
前端·javascript·面试
拉不动的猪9 小时前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
FreeCultureBoy10 小时前
macOS 命令行 原生挂载 webdav 方法
前端
uhakadotcom11 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom11 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom11 小时前
React与Next.js:基础知识及应用场景
前端·面试·github
uhakadotcom11 小时前
Remix 框架:性能与易用性的完美结合
前端·javascript·面试
uhakadotcom11 小时前
Node.js 包管理器:npm vs pnpm
前端·javascript·面试