npm源码解析之启动流程

659 阅读3分钟

一、前言

npm作为前端开发人员必备工具之一,有着无法撼动的地位。但很多人只停留在使用的层面上,对于它内部工作原理很少去深究。笔者出于学习的目的,尝试从源码的角度去解析它的工作原理。本篇作为npm源码解析第一篇,会先分析它的启动流程,也即是从输入命令行到给出执行结果这一环节。

二、vscode源码调试

在开始解析之前,我们需要准备调试环境,这里推荐使用开源IDE Visual Studio Code 进行代码调试。

1、创建项目

mkdir npm-test
npm init -y
npm install npm@9.7.2 --no-save
  • -y跳过询问,快速生成package.json文件。
  • npm@9.7.2将npm安装到项目中,用于源码调试,因为npm是基于node.js开发的开源项目,代码并未经过压缩,可以直接阅读调试,因此直接下载。至于下载哪个版本,尽量选择9.x版本,本文是基于npm 9.7.2进行调试。
  • --no-save不将包写入package.json依赖项中。

2、启动调试

切换到运行和调试面板,创建launch.json文件,选择Node.js项目。

image.png

image.png

此时会生成.vscode目录和launch.json文件。接下来修改启动文件配置,修改program值为:${workspaceFolder}/node_modules/.bin/npm,指定调试入口,即npm可执行文件。args值为:["view", "npm", "versions"],携带的参数。总体配置的意思是:启动调试模式时,用node程序执行npm文件并添加view npm versions参数。

image.png

最后,点击启动程序按钮或者F5开始调试,在调试控制台输出信息即代表配置成功。接下来我们就可以在npm代码中打断点愉快的进行调试了。

image.png

三、启动流程

npm不管是全局还是项目内部安装,都会通过npm可执行文件来运行命令。这里npm可执文件指的是node_modules/.bin目录中存放的文件。安装npm包时,会根据package.json文件中的bin字段将可执行文件安装至.bin中,所以,.bin/npmnpm/bin/npm-cli.js的映射文件。而npm-cli.js只是简单的导入lib/cli.js文件

image.png

image.png

所以,我们先查看lib/cli.js文件:

// lib/cli.js
const validateEngines = require('./es6/validate-engines.js')
const cliEntry = require('path').resolve(__dirname, 'cli-entry.js')

module.exports = (process) => validateEngines(process, () => require(cliEntry))
  • validateEngines:验证node版本是否匹配npm版本,并执行cliEntry函数。
  • cliEntry:命令行工具执行入口lib/cli-entry.js文件,它导出一个函数。
// lib/cli-entry.js
module.exports = async (process, validateEngines) => {
  // ...
  // 创建npm实例
  const Npm = require('./npm.js')
  const npm = new Npm()
  
  // 定义变量存放命令行参数。
  let cmd
  
  try {
    // 启动
    await npm.load()
    
    // ...
    // 此处省略对命令行参数的一些转化处理,例如-v、-versions等。
    
    // 执行命令并将参数传入
    await npm.exec(cmd)
    
  } catch (err) {
    // ...
  }
}

cliEntry函数的重点就是创建npm实例,定义cmd变量处理命令行参数,通过npm.load方法处理一些控制台日志输出相关的逻辑,然后通过npm.exec()执行命令。所以核心代码就是npm类的实现。

image.png

// lib/npm.js
class Npm {
  // ...
  async exec (cmd, args = this.argv) {
     // 调用setCmd方法
     const command = this.setCmd(cmd)
     
     return command.cmdExec(args).finally(timeEnd)
  }
  
  setCmd (cmd) {
    // 调用Npm静态方法cmd,通过命令行参数加载模块,并实例化。
    const Command = Npm.cmd(cmd)
    const command = new Command(this)

    return command
  }
  
  static cmd (c) {
    // 命令行参数转化函数
    // 我们知道npm提供很多参数别名,而且容错率很高,这些都是通过内部转换函数处理的。
    // 例如:distTag转为dist-tag,i转为install、r转为uninstall等。
    const command = deref(c)
    
    // 通过参数名加载模块,如view返回 require(`./commands/view.js`) 
    return require(`./commands/${command}.js`)
  }
}

便于理解的伪代码:

// 当输入npm view 命令行:
const Command = require(`./commands/view.js`)
const command = new Command(this)

command.cmdExec(args)

image.png image.png

通过代码可以看出,当执行命令时,程序会解析输入的参数,然后巧妙的通过参数名来匹配对应的文件。接着导入该模块,而模块对应的是一个类,实现对应功能的类。最后通过调用实例方法cmdExec,作进一步处理。从这里,我们能够看到npm工程化的思路,将复杂的命令,分流至不同类中进行处理,这是分而治之的思想,值得我们学习借鉴。

四、view源码解析

image.png

通过调用堆栈可以看到cmdExec方法最终调用的是View class中的exec方法。而npm view npm versions命令的功能是在控制台输出npm版本信息。

// bin/commands/view.js
class View extends BaseCommand {
  // ...
  async exec (args) {
    // ...
    // getData方法用于获取包的指定信息,内部实现是通过npm-package-arg和pacote模块,具体阅读源码。
    // 这里pkg的值是npm,args是versions,也就是获取npm的所有版本信息。
    const [pckmnt, data] = await this.getData(pkg, args)
    
    if (!this.npm.config.get('json') && wholePackument) {
      // 进到这里,说明args参数为空,则打印当前项目信息。
      data.map((v) => this.prettyView(pckmnt, v[Object.keys(v)[0]]['']))
    } else {
      // JSON格式的输出
      let reducedData = data.reduce(reducer, {})
      
      // 转为字符串
      const msg = await this.jsonData(reducedData, pckmnt._id)
      if (msg !== '') {
       // 控制台打印信息
        this.npm.output(msg)
      }
  }
}

image.png

view命令的实现相对简单,可以自行阅读源码,这里就不作过多分析了。

五、总结

  • 创建项目,通过npm install安装npm包进行源码调试,这种方式简单直接,但你也可以去npm官方git仓库下载代码来调试。
  • 合理利用vscode代码调试工具可以做到事半功倍,但要懂得在阅读源码的同时,使用断点、上下文变量和调用堆栈进行辅助调试。
  • npm命令行启动流程的核心在于创建Npm class的实例,通过npm.exec方法执行lib/commands目录中对应的文件。所以npm view命令可以直接对应lib/commands/view.js文件内View class的实现。
  • View class功能是在控制台打印指定包的信息,可以是版本信息、名称、甚至package.json内的任何信息。