likes
comments
collection
share

特别好用的包自动选择工具ni,解读源码实现

作者站长头像
站长
· 阅读数 18

本文参加了由公众号@若川视野 发起的每周源码共读活动,      点击了解详情一起参与。

这是源码共读的第12期 | 尤雨溪推荐神器 ni ,能替代 npm/yarn/pnpm ?简单好用!源码揭秘!

前言

忙碌和玩了一段时间, 该收收心继续学习, 而且今年听说前端招聘还特别严峻, 底层非科班小前端瑟瑟发抖~

本篇学习笔记将:

  1. 学习并使用 ni 到工作中
  2. 弄清楚 ni 的实现原理

原理

本质上就是通过对命令执行的目录 检测 lock(yarn.lock/package-lock.json/pnpm-lock-yaml) 文件,并将各种包管理工具的命令差异抹平进行执行,如:

ni -> npm instal / yarn install / pnpm install

并且 ni 会在 没有 lock文件的情况下, 读取用户目录下的./nirc文件,决定使用哪种命名执行, 默认使用npm

//config.ts 下 读取用户目录
const home = process.platform === 'win32'
  ? process.env.USERPROFILE
  : process.env.HOME

const rcPath = path.join(home || '~/', '.nirc')
//默认配置
const defaultConfig: Config = {
  defaultAgent: 'prompt',
  globalAgent: 'npm',
}

Npm readme文档 : @antfu/ni

准备

克隆官方仓库:git clone https://github.com/antfu/ni.git 推荐川哥文章对应的克隆仓库: git clone github.com/lxchuan12/n…

源码

package.json

"bin": {
    "ni": "bin/ni.js",
    "nci": "bin/nci.js",
    "nr": "bin/nr.js",
    "nu": "bin/nu.js",
    "nx": "bin/nx.js",
    "nrm": "bin/nrm.js"
  }
 "scripts": {
    "prepublishOnly": "rimraf dist && npm run build",
    "watch": "npm run build -- --watch",
    "?ni": "npm install",
    "ni": "esno src/ni.ts",
    "nci": "esno src/nci.ts",
    "nr": "esno src/nr.ts",
    "nu": "esno src/nu.ts",
    "nx": "esno src/nx.ts",
    "nrm": "esno src/nrm.ts",
    "dev": "esno src/ni.ts",
    "build": "rimraf dist && tsup src/ni.ts src/nci.ts src/nr.ts src/nu.ts src/nx.ts src/nrm.ts src/index.ts --format cjs,esm --dts",
    "release": "npx bumpp --commit --push --tag",
    "lint": "eslint \"**/*.ts\"",
    "lint:fix": "npm run lint -- --fix",
    "test": "c8 ava"
  }

之前的文章也有提到过,可执行命令需要通过 bin字段的声明, 所以目录中还有一个 bin文件夹,就是处理这个事情,它们会对应的引入 build后的文件

//bin/ni.js
#!/usr/bin/env node
'use strict'
require('../dist/ni')

ni执行入口

//ni.ts
import { parseNi } from './commands'
import { runCli } from './runner'

runCli(parseNi)
//runner.ts
export async function runCli(fn: Runner, options: DetectOptions = {}) {
  const args = process.argv.slice(2).filter(Boolean)
  try {
    await run(fn, args, options)
  }
  catch (error) {
    process.exit(1)
  }
}

通过slice 截取后面的有效参数, node命令行执行 默认下标0 和 1 的内容是 node 以及执行文件。 最后会去执行 run函数

参数处理

export async function run(fn: Runner, args: string[], options: DetectOptions = {}) {
  // 判断用户输入的参数有没有 ?
  // 有的话会移除这个?参数 并得到新的 args 同时debug true
  const debug = args.includes(DEBUG_SIGN)
  if (debug)
    remove(args, DEBUG_SIGN)

  let cwd = process.cwd()
  let command
  // 判断第一个参数是不是 -C,是的话将cwd解析为 cwd+args[1]
  // 也就是得到新的命令执行目录,然后移除掉args中的 -C 和后面一位参数
  // 所以-C 用来执行命令执行目录
  if (args[0] === '-C') {
    cwd = resolve(cwd, args[1])
    args.splice(0, 2)
  }
  // ...code
}

这里是对 参数的一些处理,指定目录执行以及 是否debug模式, debug模式下 不会去实际执行命令

//run
const isGlobal = args.includes('-g')
  if (isGlobal) {
    command = await fn(getGlobalAgent(), args)
}
//config.ts
export function getConfig() {
  if (!config) {
    if (!fs.existsSync(rcPath))
      config = defaultConfig
    else
      config = Object.assign({}, defaultConfig, ini.parse(fs.readFileSync(rcPath, 'utf-8')))
  }
  return config
}
export function getGlobalAgent() {
  return getConfig().globalAgent
}

判断是否全局执行命令, 如果是会去执行 parseNi函数 , getGlobalAgent 函数则会去读取 全局下的配置文件,如果不存在则 会直接使用默认配置

//config.ts
const defaultConfig: Config = {
  defaultAgent: 'prompt',
  globalAgent: 'npm',
}

抹平命令差异 parseNi

在这里先看 getCommand 函数的作用, 函数中使用了 AGENTS 这个变量,用于读取和判断命令有效性

//agents.ts
export const AGENTS = {
  npm: {
    'run': npmRun('npm'),
    'install': 'npm i',
    'frozen': 'npm ci',
    'global': 'npm i -g {0}',
    'add': 'npm i {0}',
    'upgrade': 'npm update {0}',
    'upgrade-interactive': null,
    'execute': 'npx {0}',
    'uninstall': 'npm uninstall {0}',
    'global_uninstall': 'npm uninstall -g {0}',
  },
 // ...还有 yarn pnpm 部分不做展开
}
export function getCommand(
  agent: Agent,
  command: Command,
  args: string[] = [],
) {
  // 如果传进的包标识 不存在定义的字典中直接异常结束
  if (!(agent in AGENTS)) throw new Error(`Unsupported agent "${agent}"`)

  // c = AGENTS['npm']['install'] 这里就是得到对应执行语句
  const c = AGENTS[agent][command]
  // 如果是函数则执行得到返回结束 对应的是 npmRun函数
  // npmRun函数内部会把传递的args 拆分组合成完整的命令执行
  if (typeof c === 'function') return c(args)
  // 不存在的命令 也异常结束
  if (!c) {
    throw new Error(
      `Command "${command}" is not support by agent "${agent}"`,
    )
  }
  // 最后将 {0} 替换为 args数组转化内容
  return c.replace('{0}', args.join(' ')).trim()
}

getCommand 的作用就是通过对应 agent和 args 得到 完整的要执行的命令, 然后在看 parseNi 函数

export const parseNi = <Runner>((agent, args, ctx) => {
  // 如果参数只有-v 则直接输入当前版本号并结束退出
  if (args.length === 1 && args[0] === '-v') {
    // eslint-disable-next-line no-console
    console.log(`@antfu/ni v${version}`)
    process.exit(0)
  }
  // 没有指定参数则执行 install
  if (args.length === 0) return getCommand(agent, 'install')
  // 存在 -g 则执行 全局命令,并把参数中的 -g移除
  if (args.includes('-g'))
    return getCommand(agent, 'global', exclude(args, '-g'))
  // 只有-f 时执行 install
  if (args.length === 1 && args[0] === '-f')
    return getCommand(agent, 'install', args)
  // --frozen-if-present 这个不知道干啥的,最后用来判断是执行install还是frozen
  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'))
  // 都没有符合条件的 最后返回组装好的 add 命令
  return getCommand(agent, 'add', args)
})

这里就清楚了parseNi 函数就是在解析 install 命令 以及 参数的传递,最后组装成完整的命令返回。

并且 如果是全局安装的情况下,ni 会去 读取使用全局配置下的文件~./nirc,如果没有配置的情况下就会默认使用 npm来进行全局安装

在回到 run函数中 看非全局情况下的执行

非全局执行

else {
    let agent = await detect({ ...options, cwd }) || getDefaultAgent()
    if (agent === 'prompt') {
      agent = (await prompts({
        name: 'agent',
        type: 'select',
        message: 'Choose the agent',
        choices: agents.map(value => ({ title: value, value })),
      })).agent
      if (!agent)
        return
    }
    command = await fn(agent as Agent, args, {
      hasLock: Boolean(agent),
      cwd,
    })
  }

这里就涉及到一个关键函数detect ,也就是确定使用哪个包管理工具的关键函数,来看它的实现

//agent.ts
export const LOCKS: Record<string, Agent> = {
  'pnpm-lock.yaml': 'pnpm',
  'yarn.lock': 'yarn',
  'package-lock.json': 'npm',
}
export async function detect({ autoInstall, cwd }: DetectOptions) {
  // 通过findUp 查找LOCKS中的文件是否存在
  const result = await findUp(Object.keys(LOCKS), { cwd })
  // 如果找到的话会返回文件路径 然后通过basename截取文件名
  const agent = (result ? LOCKS[path.basename(result)] : null)
  // 检测包管理工具是否有全局安装
  if (agent && !cmdExists(agent)) {
    // 如果参数没有传递autoInstall 则提示用户手动安装
    if (!autoInstall) {
      console.warn(`Detected ${agent} but it doesn't seem to be installed.\n`)
      // 谷歌科普下用来判断当下是不是在CI环境中
      if (process.env.CI)
        process.exit(1)

      // terminalLink 包可以得到命令行上可以点击的超链接
      // 通过prompts 让用户选择
      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)
    }
    // 通过execa 执行命令  全局安装这个包
    await execa.command(`npm i -g ${agent}`, { stdio: 'inherit', cwd })
  }
  // 返回 对应的包
  return agent
}

考虑到了用户可能没安装对应包的情况,去提供对应的安装和提示,非常nice。 最后detect 会返回 需要用的的哪个包管理工具字符串npm/pnpm/yarn

最后再看 非全局安装这一部分代码就很清晰了

export async function run(fn: Runner, args: string[], options: DetectOptions = {}) {
  // ...
  else {
    // 如果detect 返回null 则会去取 getDefaultAgent 结果就是prompt
    let agent = await detect({ ...options, cwd }) || getDefaultAgent()
    // 如果没判断到对应的包管理工具 让用户手动选择
    if (agent === 'prompt') {
      agent = (await prompts({
        name: 'agent',
        type: 'select',
        message: 'Choose the agent',
        choices: agents.map(value => ({ title: value, value })),
      })).agent
      if (!agent)
        return
    }
    // 执行ni fn这里就是parseNi,将对应的包 和参数传入 得到完整的执行命令
    command = await fn(agent as Agent, args, {
      hasLock: Boolean(agent),
      cwd,
    })
  }

  if (!command)
    return
  // debug 模式仅 输出 完整命令
  if (debug) {
    // eslint-disable-next-line no-console
    console.log(command)
    return
  }
  // 最后执行 对应命令
  await execa.command(command, { stdio: 'inherit', encoding: 'utf-8', cwd })
}

梳理

至此,基本的命令执行流程就清楚了

  1. 不同的命令会去执行 不同的可执行文件,如 ni 最终执行的是 run(parseNi)
  2. 通过detect 函数得到用户使用的包管理工具
  3. 将组装好的完整命令 执行
  4. 其他命令大体相同,最终执行的也是run(parseNr)

总计

快速读了一遍小册的 <TypeScript 全面进阶指南> , 对于Ts类型 有了一些基础,后面还要再多读几遍和记笔记,当然也是因为 ni源码中 也没有什么类型体操,阅读起来挺友好(泪奔) ,并且将之前的 事件中心库harexs-emitter用ts稍微改造了下。 Keep Study!

转载自:https://juejin.cn/post/7211426020967940152
评论
请登录