npm之npx源码阅读
npm
是nodejs
的模块管理器,功能及其强大。甚至随着前端模块化的兴起,在平时的工作中,也必不可少的会接触npm
。在npm 5.2.0
以及后面的版本, 新增了npx
命令,该条命令主要有以下几个特点:
-
临时安装可执行依赖包,无需全局安装,不用担心长期的污染。(比如我们某个项目里用到
nodemon
这个nodejs
启动器, 但是全局的node_modules
中又没安装这个模块,然后通过npx nodemon xxx
来安装并且执行。再比如我们项目里依赖的webpack 4.x
,而系统全局安装的确是webkack 1.x
,又不想把全局的替换掉,这时候我们可以通过npx webpack@4.x xxx
来用我们项目里的版本编译相关代码) -
可以执行依赖包中的命令,安装完成自动运行。
-
自动加载node_modules中依赖包,如果没找到,再去找全局,如果依然没找到,则会先安装,再执行。
-
可以指定
node
版本、包的版本,解决了不同项目使用不同版本的包的问题。
了解了这些特点,我们来分析下npx
的具体实现。首先找到入口文件bin/index.js
#!/usr/bin/env node
const path = require('path')
const npx = require('libnpx')
const NPM_PATH = path.join(__dirname, 'node_modules', 'npm', 'bin', 'npm-cli.js')
// 解析参数
const parsed = npx.parseArgs(process.argv, NPM_PATH)
parsed.npxPkg = path.join(__dirname, 'package.json')
npx(parsed)
入口文件中最后执行的是libnpx
这个包,所以我们跟到libnpx/index.js
这个文件,先看下刚才入口文件里面调用的npx.parseArgs
:
npx.parseArgs
// parse-args.js
'use strict'
let npa
const path = require('path')
module.exports = parseArgs
/**
*
* @param {String[]} argv 由[node路径, npx路径, npx xxx后面的xxx, .etc]组成的数组
* @param {String} defaultNpm 默认的npm路径, 从当前项目的node_modules中找
*/
function parseArgs (argv, defaultNpm) {
argv = argv || process.argv
// npx xxx而不是npx -xxx这种
// 比如npx webpack
if (argv.length > 2 && argv[2][0] !== '-') {
return fastPathArgs(argv, defaultNpm)
}
// npx -xxx yyy这种
npa = require('npm-package-arg')
const parser = yargsParser(argv, defaultNpm)
const opts = parser.getOptions()
const bools = new Set(opts.boolean)
// 存储cmd所在的下面, 便于后面解析这条cmd时直接通过下标获取
let cmdIndex
let hasDashDash
for (let i = 2; i < argv.length; i++) {
const opt = argv[i]
// 遇到'--', 标记hashDashDash为true, 跳出循环
if (opt === '--') {
hasDashDash = true
break
}
// 传递给nodejs的参数, 比如npx --node-arg=--inspect cowsay
// 使用 `--inspect` 选项执行node二进制文件
else if (opt === '--node-arg' || opt === '-n') {
argv[i] = `${opt}=${argv[i + 1]}`
argv.splice(i + 1, 1)
// 如果元素以'-'开头
} else if (opt[0] === '-') {
// npx --no-install webpack
if (opt !== '--no-install' && !bools.has(opt.replace(/^--?(no-)?/i, '')) && opt.indexOf('=') === -1) {
i++
}
} else {
cmdIndex = i
break
}
}
if (cmdIndex) {
/**
* 解析npx中的参数而不是需要执行的包的参数
* 比如npx --no-install webpack --watch, parsed应为:
* {
* _: [ NODE_PATH, process.cwd(), .etc ],
* 'no-install': true,
* 其他npx默认参数
* }
*/
const parsed = parser.parse(argv.slice(0, cmdIndex))
const parsedCmd = npa(argv[cmdIndex])
parsed.command = parsed.package && parsedCmd.type !== 'directory'
? argv[cmdIndex]
: guessCmdName(parsedCmd)
parsed.isLocal = parsedCmd.type === 'directory'
parsed.cmdOpts = argv.slice(cmdIndex + 1)
if (typeof parsed.package === 'string') {
parsed.package = [parsed.package]
}
parsed.packageRequested = !!parsed.package
parsed.cmdHadVersion = parsed.package || parsedCmd.type === 'directory'
? false
: parsedCmd.name !== parsedCmd.raw
const pkg = parsed.package || [argv[cmdIndex]]
parsed.p = parsed.package = pkg.map(p => npa(p).toString())
return parsed
} else {
const parsed = parser.parse(argv)
if (typeof parsed.package === 'string') {
parsed.package = [parsed.package]
}
// 指定了-c并且-p之后, -p命令用来安装npm包(仅安装, 不执行相关二进制文件), -c命令用来执行bash
// 比如: npx -p nodemon -c 'echo $PATH'
// 会先按照nodemon这个包, 然后打印出系统的PATH
if (parsed.call && parsed.package) {
parsed.packageRequested = !!parsed.package
parsed.cmdHadVersion = false
const pkg = parsed.package
parsed.p = parsed.package = pkg.map(p => npa(p).toString())
} else if (parsed.call && !parsed.package) {
parsed.packageRequested = false
parsed.cmdHadVersion = false
parsed.p = parsed.package = []
} else if (hasDashDash) {
// 存在'--'的情况, '--'会被忽略, 重新截取参数进行解析
const splitCmd = parsed._.slice(2)
const parsedCmd = npa(splitCmd[0])
parsed.command = parsed.package
? splitCmd[0]
: guessCmdName(parsedCmd)
parsed.cmdOpts = splitCmd.slice(1)
parsed.packageRequested = !!parsed.package
parsed.cmdHadVersion = parsed.package
? false
: parsedCmd.name !== parsedCmd.raw
const pkg = parsed.package || [splitCmd[0]]
parsed.p = parsed.package = pkg.map(p => npa(p).toString())
}
return parsed
}
}
function fastPathArgs (argv, defaultNpm) {
let parsedCmd
let pkg
// npx xxx, xxx中只包含大小写字母/数字/中划线/下划线
if (argv[2].match(/^[a-z0-9_-]+$/i)) {
parsedCmd = { registry: true, name: argv[2], raw: argv[2] }
// 使用该包的最新版本
pkg = [`${argv[2]}@latest`]
} else {
npa = require('npm-package-arg')
parsedCmd = npa(argv[2])
// npx ./xxx或者npx /xxx/yyy时, 认为走本地宝, 不需要安装模块
if (parsedCmd.type === 'directory') {
pkg = []
} else {
pkg = [parsedCmd.toString()]
}
}
return {
command: guessCmdName(parsedCmd),
// 把argv的第4项以及后面的元素作为执行该条命令的参数, 如npx webpack --watch, 则cmdOpts为['--watch']
cmdOpts: argv.slice(3),
packageRequested: false,
// 是否执行本地的包
isLocal: parsedCmd.type === 'directory',
// 遇到类似npx webpack@4时, cmdHadVersion为true, 后续自动安装的时候会按照这个版本来装并执行
cmdHadVersion: (
parsedCmd.name !== parsedCmd.raw &&
parsedCmd.type !== 'directory'
),
package: pkg,
p: pkg,
shell: false,
noYargs: true,
npm: defaultNpm || 'npm'
}
}
parseArgs.showHelp = () => require('yargs').showHelp()
module.exports._guessCmdName = guessCmdName
/**
* 根据spec返回不同命令
* @param {String|Object} spec
*/
function guessCmdName (spec) {
// 略
// https://github.com/npm/npx/blob/latest/parse-args.js#L132
}
/**
* npx内置参数解析
* @param {String[]} argv 由[node路径, npx路径, npx xxx后面的xxx, .etc]组成的数组
* @param {String} defaultNpm 默认的npm路径, 从当前项目的node_modules中找
*/
function yargsParser (argv, defaultNpm) {
// 略
// https://github.com/npm/npx/blob/latest/parse-args.js#L160
}
var _y
function Y () {
if (!_y) { _y = require('./y.js') }
return _y
}
参数解析大致实现就是上面的内容,从入口文件中我们可以看到,参数解析完成后就是执行npx
函数:
/**
*
* @param {*} argv npx.parseArgs返回的参数对象
*/
function npx (argv) {
// 命令行自动回滚
const shell = argv['shell-auto-fallback']
if (shell || shell === '') {
const fallback = require('./auto-fallback.js')(
shell, process.env.SHELL, argv
)
if (fallback) {
return console.log(fallback)
} else {
process.exitCode = 1
return
}
}
// -c, -p, 包名称都没有指定
if (!argv.call && (!argv.command || !argv.package)) {
!argv.q && console.error(Y()`\nERROR: You must supply a command.\n`)
!argv.q && parseArgs.showHelp()
process.exitCode = 1
return
}
const startTime = Date.now()
return localBinPath(process.cwd()).then(local => {
if (local) {
// 将当前项目路径与系统中的$PATH路径进行拼接
process.env.PATH = `${local}${path.delimiter}${process.env.PATH}`
}
return Promise.all([
// 如果命令存在, 则先找出当前命令所在路径
argv.command && getExistingPath(argv.command, argv),
// 如果是npx -c 'echo $PATH'这种形式, 并且当前项目的.bin目录已经存在, 获取系统中所有的变量(npm run env --parseable)
argv.call && local && getEnv(argv)
]).then(args => {
const existing = args[0]
const newEnv = args[1]
if (newEnv) {
// NOTE - we don't need to manipulate PATH further here, because
// npm has already done so. And even added the node-gyp path!
Object.assign(process.env, newEnv)
}
// 在安装模块之前, 首先检查npx版本, 如果有更新则打印更新信息
if ((!existing && !argv.call) || argv.packageRequested) {
if (argv.npxPkg) {
try {
require('update-notifier')({
pkg: require(argv.npxPkg)
}).notify()
} catch (e) {}
}
// 确保这个npm包存在于当前项目
return ensurePackages(argv.package, argv).then(results => {
if (results && results.added && results.updated && !argv.q) {
console.error(Y()`npx: installed ${
results.added.length + results.updated.length
} in ${(Date.now() - startTime) / 1000}s`)
}
// 略
// https://github.com/npm/npx/blob/latest/index.js#L79
})
} else {
// We can skip any extra installation, 'cause everything exists.
return existing
}
}).then(existing => {
/**
* 开始执行真正的命令
* npx webpack --watch -> wabpack --watch
*/
return execCommand(existing, argv)
}).catch(err => {
!argv.q && console.error(err.message)
process.exitCode = err.exitCode || 1
})
})
}
在npx
这个函数中, 首先调用了localBinPath
这个函数,该函数的作用就是找到当前项目中node_modules/.bin
的绝对路径,这边不做分析,可以从这里找到源码,下面我们看下getExistingPath
这个函数:
/**
* 找出要执行的命令的路径是否存在
* @param {String} command 命令: webpack/nodemon这些
* @param {*} opts 从npx xxx中解析的出参数
*/
function getExistingPath (command, opts) {
if (opts.isLocal) {
return Promise.resolve(command)
// 如果cmd中指定了版本, 或者需要安装, 或者忽略已经存在的包
} else if (opts.cmdHadVersion || opts.packageRequested || opts.ignoreExisting) {
return Promise.resolve(false)
} else {
// 使用which命令来查找, 类似linux平台下的which xxx
return which(command).catch(err => {
if (err.code === 'ENOENT') {
if (opts.install === false) {
err.exitCode = 127
throw err
}
} else {
throw err
}
})
}
}
在npx
中的Promise.all
中同时调用了getEnv
,在上面已经有注释,也不做分析了,可以从[这里]([https://github.com/npm/npx/blob/latest/index.js#L120](https://github.com/npm/npx/blob/latest/index.js#L127找到源码,下面我们看下ensurePackages
这个函数,这个函数相当重要:
/**
* 作用:
* 安装模块
* 在程序退出后,删除安装的模块
* 修改本次运行时的PATH,优先级: 安装缓存 > 当前项目路径 > 系统PATH
*/
function ensurePackages (specs, opts) {
return (
opts.cache ? Promise.resolve(opts.cache) : getNpmCache(opts)
).then(cache => {
// 将模块安装在全局的NPM_CHCHE/_npx下面
const prefix = path.join(cache, '_npx', process.pid.toString())
const bins = process.platform === 'win32'
? prefix
: path.join(prefix, 'bin')
const rimraf = require('rimraf')
// 订阅退出事件, 退出后同步删除NPM_CHCHE/_npx里面安装的内容
process.on('exit', () => rimraf.sync(prefix))
// 先删除之前的bin目录, 然后安装新包
return promisify(rimraf)(bins).then(() => {
return installPackages(specs, prefix, opts)
}).then(info => {
// 把本次的缓存路径也加到PATH前面
process.env.PATH = `${bins}${path.delimiter}${process.env.PATH}`
// 返回安装目录信息, 给后续查找使用
if (!info) { info = {} }
info.prefix = prefix
info.bin = bins
return info
})
})
}
在ensurePackages
中调用了installPackages
这个函数,由它负责安装具体的模块到具体路径,这里也省略分析, 可以从这里找到源码,现在我们回到npx
函数,继续往下分析,下面就是执行真正的命令了,在execCommand
这个函数,在execCommand
中还有一个相对重要的函数,我们先分析它,在之前被省略的内容中,也有多次调用了,下面我们先看下findNodeScript
这个函数:
/**
* 找出具体可执行文件的路径
* @param {*} existing
* @param {*} opts
*/
function findNodeScript (existing, opts) {
if (!existing) {
return Promise.resolve(false)
} else {
return promisify(fs.stat)(existing).then(stat => {
// 如果是执行本地文件, 并且文件格式为js, 直接返回
// 类似npx ./xxx.js [--xxx] [--yyy]这种形式
if (opts && opts.isLocal && path.extname(existing) === '.js') {
return existing
}
// 如果执行的是本地目录
else if (opts && opts.isLocal && stat.isDirectory()) {
// npx will execute the directory itself
try {
// 从package.json中的bin或者main指向找目标文件, 否则默认就是目录下的index.js
const pkg = require(path.resolve(existing, 'package.json'))
const target = path.resolve(existing, pkg.bin || pkg.main || 'index.js')
// 再判断下, 其实是走的上面的(opts && opts.isLocal && path.extname(existing) === '.js')分支
return findNodeScript(target, opts).then(script => {
if (script) {
return script
} else {
throw new Error(Y()`command not found: ${target}`)
}
})
} catch (e) {
throw new Error(Y()`command not found: ${existing}`)
}
} else if (process.platform !== 'win32') {
const bytecount = 400
const buf = Buffer.alloc(bytecount)
return promisify(fs.open)(existing, 'r').then(fd => {
return promisify(fs.read)(fd, buf, 0, bytecount, 0).then(() => {
return promisify(fs.close)(fd)
}, err => {
return promisify(fs.close)(fd).then(() => { throw err })
})
}).then(() => {
// 读取需要执行的文件内容, 并且判断文件内容里是否包含 #!/usr/bin/env node、/usr/local/bin/node、/usr/bin/node这里面的其中一个
const re = /#!\s*(?:\/usr\/bin\/env\s*node|\/usr\/local\/bin\/node|\/usr\/bin\/node)\s*\r?\n/i
// 如果包含上面三个其中一个, 就返回具体的路径, 否则返回null
return buf.toString('utf8').match(re) && existing
})
} else if (process.platform === 'win32') {
const buf = Buffer.alloc(1000)
return promisify(fs.open)(existing, 'r').then(fd => {
return promisify(fs.read)(fd, buf, 0, 1000, 0).then(() => {
return promisify(fs.close)(fd)
}, err => {
return promisify(fs.close)(fd).then(() => { throw err })
})
}).then(() => {
return buf.toString('utf8').trim()
}).then(str => {
const cmd = /"%~dp0\\node\.exe"\s+"%~dp0\\(.*)"\s+%\*/
const mingw = /"\$basedir\/node"\s+"\$basedir\/(.*)"\s+"\$@"/i
return str.match(cmd) || str.match(mingw)
}).then(match => {
return match && path.join(path.dirname(existing), match[1])
})
}
})
}
}
有了上面的findNodeScript
,下面就是真正的调用了execCommand
:
/**
* 执行真正需要执行的命令
* @param {*} _existing
* @param {*} argv
*/
function execCommand (_existing, argv) {
return findNodeScript(_existing, argv).then(existing => {
const argvCmdOpts = argv.cmdOpts || []
// npx ./xxx.js或者npx ./xxxx会进这个分支
if (existing && !argv.alwaysSpawn && !argv.nodeArg && !argv.shell && existing !== process.argv[1]) {
const Module = require('module')
if (!argv.noYargs) {
require('yargs').reset()
}
process.argv = [
process.argv[0],
existing
].concat(argvCmdOpts)
// 执行本地js, 类似node ./xxx.js [-xxx] [-yyy]或者node ./xxx [-xxx] [-yyy]
Module.runMain()
} else if (!existing && argv.nodeArg && argv.nodeArg.length) {
throw new Error(Y()`ERROR: --node-arg/-n can only be used on packages with node scripts.`)
} else {
let cmd = existing
let cmdOpts = argvCmdOpts
if (existing) {
cmd = process.argv[0]
if (process.platform === 'win32') {
cmd = child.escapeArg(cmd, true)
}
cmdOpts = argv.nodeArg
// 拼接命令中的参数
if (cmdOpts) {
cmdOpts = Array.isArray(cmdOpts) ? cmdOpts : [cmdOpts]
} else {
cmdOpts = []
}
cmdOpts = cmdOpts.reduce((acc, arg) => {
return acc.concat(arg.split(/\s+/))
}, [])
cmdOpts = cmdOpts.concat(existing, argvCmdOpts)
}
const opts = Object.assign({}, argv, { cmdOpts })
// runCommand在./child.js中封装, 大致实现逻辑用child_process.spawn来执行相关命令
// https://github.com/npm/npx/blob/latest/child.js
return child.runCommand(cmd, opts).catch(err => {
if (err.isOperational && err.exitCode) {
process.exitCode = err.exitCode
} else {
throw err
}
})
}
})
}
这大概就是我个人认为npx
中比较重要的相关代码的分析,踉踉跄跄,网上关于npx
的源码基本上找不到,所以也基本上没有参考,如果有分析的不到位的,还望指正。
总结
我们在命令行中输入了npx xxxx [--yyy]
之后,npx
内部大概实现了下面几件事情:
-
分析
xxx [--yyy]
这段内容,最后得出是否是执行本地文件,还是npm
包,判断是否需要安装新版本等 -
然后先找出当前命令所在真实路径,并且修改本次执行时
process
中的信息 -
确保模块存在当前项目,如果不存在,安装模块到
$NPM_CACHE_$/_npx/xxx
下面 -
根据路径找到真正的可执行二进制文件或者
js
的绝对路径,用child_process.spawn
或者类似node ./[xxx/]yyy.js
的形式来实现启动 -
程序退出,删除之前安装的临时模块