前端 node
npm install做了什么?

背景

用你配吗(npm)很多年了,一直不清楚背后的原理,一窥究竟。

概括

总的来说,npm install要做的事情就一个:准确的把包安装到项目中,并且能平衡效率。

npm通过对比本地和未来结果的包,得出最少的改动执行安装。本文将重点关注包的拉取部分(包清单获取+解压包)

源码流程

  • 注: npm一直在更新版本,我学习的是node@16也即npm@8 主入口是npm的reify方法

1. 构建现实树

遍历本地node_modules下的文件夹 ,通过loadActual=>_loadActual=>await Shrinkwrap.load()=>Shrinkwrap.loadAll()获取所有的依赖

依赖树的大致样子:

 actualTree: { // lock文件获取的全部详细依赖: 版本,压缩包远程地址,hash值
    meta: shrinkwrap = {
    type: 'package-lock.json',
    data: {
       dependencies: {
           jquery: { version: '^3.5.1', integrity: 'sha512-xxx', resolved: "https://registry.npmmirror.com/jquery/-/jquery-3.7.1.tgz" },
           axios: ...
           lodash: ...
           form-data: ...
           react: ...
           ...
       },
       packages: {
            ...
            "node_modules/axios": { dependencies: [{form-data: '4.0.0'}, ...], version: "...", integrity: "sha512-xxx}
       }
    }
},
    // 通过loadFSNode(对package分析)=>new Node生成edgesOut
    edgesOut: {
    // package文件用户声明的依赖关系
        jquery: {},
        axios: {},
        lodash: {},
        ...
    }
}

2. 构建期望树

遍历package.json文件|package-lock.json文件以及可能的x包(如npm install x)

ideaTree具有跟actualTree一样的结构,很多流程都是一致的

比如同样有package文件用户声明的依赖关系,具体执行栈:edgesOut 通过buildIdealTree=>initTree异步=>rootNodeFromPaceage:newClas=>Node.constructor中_loadDeps=>_loadDepType(prod)=>new Edge=>Edge.constructor的_setFrom=>node.addEdgeOut(this)=>Node.edgesOut.set(edge.name, edge)

ideaTree中间是通过vistrualTree生成的,vistrualTree也是走Node那套

this.idealTree = tree ;this.virtualTree = null

其中loadFromShrinkwrap=>resolveNodes=>loadNode能从data.package键提取到childrens键:

 ideaTree ={ 
     childrens: Map = 
     { 
         jquery: Node(), 
         axios: Node(), 
         lodash: Node(), 
         @node_modules/@babel/parser: Node() 
         ... 
     }, 
     meta,
     edgesOut等一致 
     ...其他一致 
}

建立完理想树后还有很多操作,比如:

  • 在检查到package文件多出包或者[npm install xxx]多出xxx包时(又或者是这份lock文件构建时的npm版本跟此次版本对不上时,特别耗时,而且容易挂),填充lock文件:_inflateAncientLockfile
  • [npm install xxx]多出xxx包时:_applyUserRequests先add包信息,_buildDeps再发出http请求获取xxx包信息(通过pacote.manifest获取包清单),并且深度遍历包里的依赖包(depth=>visit=>#fetchManifest=>pacote.manifest)
  • 检查node版本的engines和npm版本的engines到底符不符合包,抛出错误(从node的package发问),还有系统版本的os和cpu(还有linux的libc)
  • 最后结合各种参数检查理想树,如是否dev的依赖已经全部在树的顶级
const platform = environment.os || process.platform
const arch = environment.cpu || process.arch

3. 两个树计算差别

通过【现实树】和【期望树】的对比,得出最小变化差别如:{add: ['jquery', 'axios'], delete: ['lodash']}

_diffTrees:将结果存入diff.children中,并标注每个操作项和类型以及【安装后理想的节点:idea:Node】

// find all the nodes that need to change between the actual
    // and ideal trees.
    this.diff = Diff.calculate({
      shrinkwrapInflated: this[_shrinkwrapInflated],
      filterNodes,
      actual: this.actualTree,
      ideal: this.idealTree,
    })
    
    class Diff {
        constructor(){
            // ...
            this.action = getAction()
        }
    }
    
    const getAction = ({ actual, ideal }) => {
    // 理想数没有代表删除
    if (!ideal) {
      return 'REMOVE'
    }
    // 现实树没有代表新增
    if (!actual) {
      return ideal.inDepBundle ? null : 'ADD'
    }

    // 版本号不同代表更新
    if (ideal.version !== actual.version) {
      return 'CHANGE'
    }
    // ...
    return null
  }

---

  • 到这里需要理解为何需要对比,会有n种情况,这里举例两种假设:
  1. 本地无node_modules,现实树为空,期望树为package.json|package-lock.json。(通常这是项目首次安装的样子)
  2. 本地有node_modules,但jquery包被误删了,期望树package-lock.json,安装新包:npm i axios。

之所以要对比,是因为install不单关注此次新增,还需要检查现有的文件,万一有了axios可以不做任何事,或者万一没了jq(情况2)还需要新增jq

小结:其实树的对比几乎贯穿了整个web,从dom树到包依赖到webpack打包再到vue虚拟dom对比算法

---

npm初始化配置优先级

发出包清单需要提前定义仓库的地址:

npm.load = () => {
     // ...
    const registry = npmcliarg || npm.config.get('registry')  || defaultRegistry = 'https://registry.npmjs.org'
}

对应上了官网的优先级: https://nodejs.cn/npm/cli/v8/using-npm/config/#command-line-flags

即: 最优先读cli->环境变量->各级.npmrc文件声明->npm config set(覆盖默认值)->默认值

pacote.manifest获取包的信息清单

// api用法
// pacote.manifest('axios', {
//     cache: 'c:/project/a'
// }).then(manifest => {
//     console.log('got it', manifest)
// })


// pacote.manifest主要的逻辑:
pacote.manifest = async (name, {
    where = `npm初始化`, version = 'latest', cachePath = 'xxx'
}) => {
    // 甄别来源类型:源仓库||git||file等等
    const type = 'registry'

    // 获取源仓库地址,默认官方源
    const registry = where || 'https://registry.npmjs.org'
    // 也意味着cli入参【npm i --registry=xxx】优先级比【npm.config.get('registry')】高

    // 拼接请求地址
    const url = registry + '/' + name 

    // 取缓存地址,要么自定义,要么默认windiw找npm-cache linux找.npm
    const cachePath = cachePath || (platform === 'win32' ? 'npm-cache' : '.npm')

    // 通过url当唯一key获取缓存入口(实际可能会获取到多个,还会过滤比如过期的缓存,最终会返回命中的首个entry)
    const entry = await CacheEntry.find({ url }, options)

    // 模拟简单的拼接,实际是用url换hash再拼接
    const filePath = cachePath + url

    // fs读取到包信息:{integrity:'sha..', ...}
    const data = await fs.readFile(filePath, 'utf8')

    if (!entry) {
        return await http.post(url)
    } else {
        // 直接返回文件里对应的数据
        return data
    }

}

到这里可以解释为什么缓存可以源不同了:缓存存储(包信息)的设计的key是host+path的完整url

4. 实现压缩包的获取和解压

async[_reifyPackages]() {
    // ...

    let reifyTerminated = null
    const removeHandler = onExit(({ signal }) => {
        removeHandler()
        reifyTerminated = Object.assign(new Error('process terminated'), {
            signal,
        })
        return false
    })

    // 非常帅的回滚操作
    const steps = [
        [_rollbackRetireShallowNodes/* 但凡有一个包换不了, 把_retiredPaths里的新旧位置替换:删新增旧 */, [
            _retireShallowNodes,/* 把现实树中的change和remove的包丢进垃圾桶:准备丢弃 */
        ]],
        [_rollbackCreateSparseTree/*  最后走_rollbackRetireShallowNodes */, [
            _createSparseTree /* 存下安装路径和根路径 */,
            _addOmitsToTrashList/* 把不安装的比如(optional可选安装)推进trashList准备丢弃 */,
            _loadShrinkwrapsAndUpdateTrees/* 有更高级别的通过npm shrinkwrap生成的锁定版本【npm-shrinkwrap.json】时,会替代package-lock重新走diff流程 */,
            _loadBundlesAndUpdateTrees/* 处理带捆绑压缩包文件的依赖,通常需要直接解压,同时需要再次loadActual把捆绑的包也带到理想树中,npm pack获取压缩包:https://nodejs.cn/npm/cli/v8/configuring-npm/package-json/#bundledependencies  */,
            _submitQuickAudit/* 发【/-/npm/v1/security/advisories/bulk】请求审核, 这里有个for-of和await的结合问题:http的回调会在_unpackNewModules之后执行 */,
            _unpackNewModules,/* 解压新的包 */
            /* 通过depth深度遍历执行到reifyNode,._extractOrLink通过pacote.extract提取文件 */
        ]],
        [_rollbackMoveBackRetiredUnchanged/* 带捆绑的压缩包从目标转移到/node_modules中,可能是恶意的,无耐触发_rollbackCreateSparseTree回滚 */, [
            _moveBackRetiredUnchanged,/* 带捆绑的压缩包从目标转移到/node_modules */
            _build,/* 对链接类型的Node,需要重新通过c++编译如esbuild:timing build:link:node_modules/esbuild Completed in 7ms */
            // 日志表明less,rollup,sass这种模块都需要重现编译,应该是解决链接类型并不清楚项目路径外的真实文件是否过期?
        ]],
    ]
    for (const [rollback, actions] of steps) {
        for (const action of actions) {
            try {
                await this[action]()
                if (reifyTerminated) {
                    throw reifyTerminated
                }
            } catch (er) {
                await this[rollback](er)
                // 通过报错回滚
                throw er
            }
        }
    }

    // 到这已经算安装成功,不需要垃圾桶了
    await this[_removeTrash]()
    if (reifyTerminated) {
        throw reifyTerminated
    }
    removeHandler()
}

pacote.extract提取文件包

_unpackNewModules最终调用pacote.extract

pacote.extract源码就没这么好读了,tarballStream各种回调,通过调用tar.x做解压,最终是 node的流api先读后写:stream.pipe(u)

以下为简洁例子和流程

// 提取文件包
// npm install axios@1.0.0 --registry=https://registry.npm.taobao.org
pacote.extract(
    'axios@https://registry.npmmirror.com/axios/-/axios-1.0.0.tgz',
    'C:\project\gjx\test-npm-install\node_modules',
    {
        resolved: 'https://registry.npmmirror.com/axios/-/axios-1.0.0.tgz',
        integrity: 'sha512-SsHsGFN1qNPFT5QhSoSD37SHDfGyLSW5AESmyLk2JeCMHv5g0I9g0Hz/zQHx2KNe0jGXh2q2hAm7OdkXm360CA==',
        registry: "https://registry.npmmirror.com",
        caches: 'C://xxx/npm-cache/_cacache'
    }
)

pacote.extract = ()=>{
    // 先建立文件夹
    this[_mkdir](dest)

    // 先通过lru算法获取内存有无缓存
    const memoized = memo.get.byDigest(cache, integrity, opts)

    // 如果没有,再从缓存换取压缩包路径
    // 缓存路径:'xxx\\npm-cache\\_cacache\\content-v2\\sha512\\4a\\c1\\xxx'
    const stream = read.readStream(cache, integrity, opts)

    // 再没有或者获取失败,从远程拉取
    fromCache ? fromCache.catch(fromResolved) : fromResolved()
    // 从远程拉取也是先调用的pacote.manifest,看看本地缓存有无关于压缩包的信息,没有再发出http
    // pacote.manifest('https://registry.npmmirror.com/axios/-/axios-1.0.0.tgz')

    // 拿到流
    const buffer = http.fetch('https://registry.npmmirror.com/axios/-/axios-1.0.0.tgz')

    // 文件目的地.pipe(文件源路径),解压流
    tarball.pipe(tar.x(buffer))

    // 最终把文件写入
    const stream = new fsm.ReadStreamSync(file, {
        readSize: readSize,
        size: stat.size,
    })
    stream.pipe(u)
}

调试到这里一开始有个疑问:为什么cli配置了【 https://registry.npm.taobao.org 】,但是提取的时候又换回了【 https://registry.npmmirror.com 】?

是何时做的源替换? 答案是第一次pacota获取清单后做的替换:

 this[_fetchManifest](spec)
      .then(pkg => new Node({ name, pkg, parent, installLinks, legacyPeerDeps }) // 拉取完清单马上生成新的Node

这也是为什么淘宝源旧址还能一直使用(截止2024-1-22域名证书过期之前)的真正原因: http返回的清单的链接域名是新的

提取文件包的逻辑看完后,你能解释在多项目情况下,如果有多个版本依赖的包(如axios,项目1依赖0.xx版本,项目2依赖1.xx版本),cache是如何工作的了吗?

这个问题也可以解答:为什么缓存可以版本不同。答(尝试选中后面的白色字体😎):缓存存储(压缩包信息)的设计的key是integrity,同时与来源地址resolved也一一对应

5. 收尾工作

  1. _saveIdealTree 将理想的树元数据保存到package-lock,且更新package.json(updatePackageJson的fs.readFile)
  2. _copyIdealToActual 清空垃圾桶,通过Shrinkwrap.save把理想树写入lock文件
  3. 最后reifyFinish打印控制台和日志,输出本次安装的统计,以及有多少依赖需要你的资金支持(就是拉投资哈哈哈)

npm fund: 看看哪些包的作者差钱

发现问题

本地npm一直是淘宝源:npm config set registry  https://registry.npmmirror.com
我们来调试下,如果这时把源调整成官方源,但是lock文件又是淘宝源的情况下,会发生什么?

npm install --registry=https://registry.npmjs.org

打开log文件,发现了两处问题:

416 verbose audit error FetchError: request to https://registry.npmjs.org/-/npm/v1/security/audits/quick failed, reason: connect ETIMEDOUT 104.16.27.34:443
418 timing auditReport:getReport Completed in 262719ms

1. 审计是失败的,拿不到报告

2. 很耗时。

而正常的执行npm i的打印是这样的:极快还能拿到审计报告

326 http fetch POST 200 https://registry.npmjs.org/-/npm/v1/security/advisories/bulk 1764ms
327 timing auditReport:getReport Completed in 1770ms

audit是在_reifyPackages处理压缩包前发出的(默认开启的)审计,来看下报错的源码:

async [_getReport] () {
    // ...
    process.emit('time', 'auditReport:getReport')
    try {
      try {
        // ...
        const res = await fetch('/-/npm/v1/security/advisories/bulk', {
          ...this.options,
          registry: this.options.auditRegistry || this.options.registry,
          method: 'POST',
          gzip: true,
          body,
        })
        return await res.json()
      } catch (er) {
        log.silly('audit', 'bulk request failed', String(er.body))
        const res = await fetch('/-/npm/v1/security/audits/quick', {
          ...this.options,
          registry: this.options.auditRegistry || this.options.registry,
          method: 'POST',
          gzip: true,
          body,
        })
        return AuditReport.auditToBulk(await res.json())
      }
    } catch (er) {
      log.verbose('audit error', er)
      return null
    } finally {
      process.emit('timeEnd', 'auditReport:getReport')
    }
  }

很显然,【--registry=https://registry.npmjs.org】的模式导致了两个http请求都发出失败,最终连累了正常的安装过程

接下来排查问题:

1. 首先是ping一下 https://registry.npmjs.org ,证明网络是通的

2. 再用原样的请求体post发出重试:

果然是没有回应的,哪怕只有个【rollup】都不行

但是偶尔又是可以的。。所以只能归结为网络不稳定的问题

解决

1. 最粗暴的办法是 停止审计:npm i --audit=false

2. 当然也有一种巧妙的越过:npm i  --registry=https://registry.npmmirror.com/

这是基于淘宝镜像没有实现audit的情况下(失败的返回也会很快,不过log日志会有报错):

小结:这是一个registry网络问题引发的npm审计流程太久,耽误总体安装进度的问题

其他改进

通过学习源码,还可以解决一些log日志问题,在fetch包之后,npm会给出了一些包暴露的warning:

[_reifyNode] (node) {
    const p = Promise.resolve().then(async () => {
      await this[_checkBins](node)
      await this[_extractOrLink](node)
      await this[_warnDeprecated](node) // 打印警告
    })
  }
  [_warnDeprecated] (node) {
    const { _id, deprecated } = node.package
    if (deprecated) {
      log.warn('deprecated', `${_id}: ${deprecated}`)
    }
  }

可以针对性的去掉这些warning,以下是本人做项目时的清空log强迫症流程

问题:294

warn deprecated image-compressor.js@1.1.4: No longer maintainted, please use comprossorjs

作者拼错了。。是compressorjs。。 很简单,代码里改掉引入就行

import ImageCompressor from 'compressorjs' // 引入
// import ImageCompressor from 'image-compressor.js'

问题:327

warn deprecated axios@0.18.1: Critical security vulnerability fixed in v0.21.1. For more information, see https://github.com/axios/axios/pull/3410

意思是有重大的漏洞,但项目升级之后请求发不出去了,原因是原来0.18.1的时候表单的设置办法是句无效代码,更改之

var instance = axios.create({
  timeout: 1000 * 30,
});
instance.defaults.headers.post['Content-Type'] = 'application/json';
// instance.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded'; 

问题:367

warn deprecated core-js@2.6.12: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.

解释下,这个问题是由于eleui的依赖包-async-validator的依赖-babel-runtime还是旧的6版本引起的core-js版本过低: https://github.com/ElemeFE/element/pull/21741

这里利用到了源码的覆盖逻辑,对于这种几乎无解的问题,npm真的太贴心了,代码留了口子:

if (overrides) {
      this.overrides = overrides
    } else if (loadOverrides) {
      const overrides = this[_package].overrides || {}
      if (Object.keys(overrides).length > 0) {
        this.overrides = new OverrideSet({ // 这里执行了覆盖
          overrides: this[_package].overrides,
        })
      }
} // node_modules\@npmcli\arborist\lib\node.js

package.json添加overrides解决:

"overrides": {
    "element-ui": {
      "async-validator": "~4.0.7"
    }
}

最终效果:4秒250个包

总结

通过学习npm install源码,着重理解了包的获取流程,解决了audit耗时慢的问题以及挨个处理log日志警告问题,最终达到【安装提速】和【控制台干净无错】的效果

日期:2024-01-24 15:48 | 阅读:185 | 评论:0