特别好用的包自动选择工具ni,解读源码实现
本文参加了由公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。
这是源码共读的第12期 | 尤雨溪推荐神器 ni ,能替代 npm/yarn/pnpm ?简单好用!源码揭秘!
前言
忙碌和玩了一段时间, 该收收心继续学习, 而且今年听说前端招聘还特别严峻, 底层非科班小前端瑟瑟发抖~
本篇学习笔记将:
- 学习并使用 ni 到工作中
- 弄清楚 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 })
}
梳理
至此,基本的命令执行流程就清楚了
- 不同的命令会去执行 不同的可执行文件,如 ni 最终执行的是
run(parseNi)
- 通过
detect
函数得到用户使用的包管理工具 - 将组装好的完整命令 执行
- 其他命令大体相同,最终执行的也是
run(parseNr)
总计
快速读了一遍小册的 <TypeScript 全面进阶指南> , 对于Ts类型 有了一些基础,后面还要再多读几遍和记笔记,当然也是因为 ni源码中 也没有什么类型体操,阅读起来挺友好(泪奔) ,并且将之前的 事件中心库harexs-emitter用ts稍微改造了下。 Keep Study!
转载自:https://juejin.cn/post/7211426020967940152