详解package.json和package-lock.json

5,347 阅读8分钟

前言

之前在开发项目的时候首先接触到的就是package.json和package-lock.json,但由于种种原因一直都没有探究下去,留的坑总要埋的,所以这里补一下课。

package.json

Specifics of npm's package.json handling

All npm packages contain a file, usually in the project root, called package.json - this file holds various metadata relevant to the project. This file is used to give information to npm that allows it to identify the project as well as handle the project's dependencies. It can also contain other metadata such as a project description, the version of the project in a particular distribution, license information, even configuration data - all of which can be vital to both npm and to the end users of the package. The package.json file is normally located at the root directory of a Node.js project.

package.json文件通常位于项目的根目录下,该文件包含了与项目相关的各种数据。该文件通常用于npm识别项目信息以及处理项目的依赖关系。也包含了别的数据例如,项目描述,项目特定发布的版本,许可信息,甚至是对npm包或者最终用户重要的配置数据。该文件通常位于nodeJs项目的根目录下。

初始化

需要安装node环境,没有安装的请自行安装 下载

npm init

目录结构

一个基于Vuepackage.json文件可能如下所示

注:以下项目如无特殊说明均指项目或包,不再赘述

{
  "name": "test-project", // 名称,通常是github仓库名称
  "author": "xxx", // 作者的信息
  "contributors": ["xxx", "xxxx"], // 贡献者信息数组
  "bugs": "https://github.com/nodejscn/node-api-cn/issues", // bug信息,通常是github的issue页面
  "homepage": "http://nodejs.cn", // 发布项目时,项目的主页
  "version": "1.0.0", // 当前版本, 遵循semver语义版本控制规范,具体含义将在后面详细解释
  "license": "MIT", // 许可证信息
  "keywords": ["xxx", "xxxx"], // 关键字数组
  "description": "A Vue.js project", // 描述信息
  "repository": "git://github.com/xxxx.git", // 仓库地址
  "main": "src/main.js", // 当引用这个包时,应用程序会在该位置搜索模块的导出
  "private": true,  // 防止包意外的发布到npm上,如果是true,npm将拒绝发布
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint"
  }, // 可运行的node脚本,通常命令是npm run serve
  "dependencies": {
    "core-js": "^3.6.5",
    "vue": "^3.0.0-0",
    "vue-router": "^4.0.0-0",
    "vuex": "^4.0.0-0"
  }, // 生产环境所依赖的安装包
  "devDependencies": {
    "@vue/cli-plugin-babel": "~4.5.0",
    "@vue/cli-plugin-eslint": "~4.5.0",
    "@vue/cli-plugin-router": "~4.5.0",
    "@vue/cli-plugin-vuex": "~4.5.0",
    "@vue/cli-service": "~4.5.0",
    "@vue/compiler-sfc": "^3.0.0-0",
    "babel-eslint": "^10.1.0",
    "eslint": "^6.7.2",
    "eslint-plugin-vue": "^7.0.0-0",
    "less": "^3.0.4",
    "less-loader": "^5.0.0"
  } // 开发环境所依赖的安装包
  "engines": {
    "node": ">= 6.0.0",
    "npm": ">= 3.0.0"
  }, // 要运行的 Node.js 或其他命令的版本,但似乎没卵用,可参考https://github.com/nodejs/node/issues/29249
  "browserslist": ["> 1%", "last 2 versions", "not ie <= 8"]  //支持的浏览器及其版本号,polyfill时会用到
}

开发环境与生产环境

生产环境dependencies

  • npm install xxx,默认会安装到生产环境里
  • npm install xxx --s或者npm install xxx -S

开发环境devDependencies

  • npm install xxx --save-dev或者npm install xxx -D

tips:生产环境下需要确保打包出来的代码尽可能的体积小,所以在安装包时要正确区分安装在哪个环境下。

版本号

鉴于使用semver(语义版本控制),所有的版本都有 3 个数字,主版本.次版本.补丁版本,具有以下规则:

  • ~: 如果写入的是 〜0.13.0,则只更新补丁版本:即 0.13.1 可以,但 0.14.0 不可以。
  • ^: 如果写入的是 ^0.13.0,则要更新补丁版本和次版本:即 0.13.10.14.0、依此类推。
  • *: 如果写入的是 *,则表示接受所有的更新,包括主版本升级。
  • >: 接受高于指定版本的任何版本。
  • >=: 接受等于或高于指定版本的任何版本。
  • <=: 接受等于或低于指定版本的任何版本。
  • <: 接受低于指定版本的任何版本。


还有其他的规则:

  • 无符号: 仅接受指定的特定版本。
  • latest: 使用可用的最新版本。

还可以在范围内组合以上大部分内容,例如:1.0.0 || >=1.1.0 <1.2.0,即使用 1.0.0 或从 1.1.0 开始但低于 1.2.0 的版本。

tips:推荐使用指定版本号 npm install xxx@x.x.x,避免因版本升级造成莫名其妙的问题(掉进坑里过😭)

问题

到这里我们已经知道了package.json是干嘛的,那么问题来了,当我们安装一个包比如lodash时,他的版本号是"lodash": "^4.17.20",通过上面的版本号说明我们知道,这代表只要大版本不变,但是有更新,可能是4.18.20,那么后面再安装时版本就变成了最新的版本。在正常情况下,我们是不允许大家协作时包的版本号不一致的,所以这里就出现了package-lock.json

package-lock.json

A manifestation of the manifest

package-lock.json is automatically generated for any operations where npm modifies either the node_modules tree, or package.json. It describes the exact tree that was generated, such that subsequent installs are able to generate identical trees, regardless of intermediate dependency updates.
This file is intended to be committed into source repositories, and serves various purposes:

  • Describe a single representation of a dependency tree such that teammates, deployments, and continuous integration are guaranteed to install exactly the same dependencies.
  • Provide a facility for users to “time-travel” to previous states of node_modules without having to commit the directory itself.
  • To facilitate greater visibility of tree changes through readable source control diffs.
  • And optimize the installation process by allowing npm to skip repeated metadata resolutions for previously-installed packages.

npm有任何修改node_modules tree或者package.json的动作时,都会自动生成package-lock.json。他描述了要生成的具体的依赖树,因此不管中间的依赖项怎样更新,都能确保之后的安装都能生成相同的树。
该文件目的是被提交到源仓库中,并且有一下多种用途:

  • 描述了一个单独表达的依赖树,因此确保你的队友,部署和持续集成能安装完全相同的依赖。
  • 为用户提供了一个便利,使其“时间旅行”到node_modules之前的状态而不用提交本身目录。
  • 通过可读的源代码控制差异而更好的看到树的变化。
  • 允许npm跳过之前安装的软件包的重复数据解析,从而优化安装过程。

简单看一下package-lock.json长啥样 package-lock.json 可以很明显的看到他包括了所有依赖的具体版本号,安装地址,sha-1加密后的值,安装在哪个环境,依赖内部所需要的依赖项。。。 别的字段可以看一下官方文档,npm-package-lock.json这里不再赘述。

问题

  • Question1: 当我们执行npm install时,会发生什么呢?
    有的小伙伴可能会说了,你这问题也太简单了吧。如果项目中有package-lock.json,那么就会从中解析所需安装的依赖,而不是通过package.json
  • Question2: 你知道node_modules的目录结构么?
    这时平时比较细心的小伙伴可能会说,我知道,npm在解析node_modules时会尽可能扁平化的处理依赖,放在顶级node_modules
  • Question3: 那么在安装依赖时,依赖自身所需要的依赖是怎么处理的呢?

npm是如何处理依赖关系的

首先我们在仓库test中npm init一个package.json,然后npm install react@16.13.1,此时node_modules的目录结构如下:

test
	node_modules
  	|	prop-types@15.6.2
	|	react@16.13.1

我们再安装一下prop-types@15.5.0,此时的依赖图如下:

test
	node_modules
	|	prop-types@15.5.0
	|	react@16.13.1
			node_modules
        	|	prop-types@15.6.2

我们会发现

  1. 由于npm会扁平化的安装依赖,所以prop-types@15.5.0会安装到顶级node_modules中。
  2. 由于顶级node_modules已经有了prop-types@15.5.0,所以react内部所依赖的prop-types@15.6.2会安装在react内部的node_modules中。

假如我们再安装一个react-xxx@3.1.0,他依赖于prop-types@15.5.0react@16.12.0,所以,此时的node_modules结构图如下:

test
	node_modules
	|	prop-types@15.5.0
	|	react@16.13.1
			node_modules
        	|	prop-types@15.6.2
    |	react-xxx@3.1.0
			node_modules
        	|	react@16.12.0

ok,我们发现

  1. npm在安装依赖时,首先在顶级node_modules下安装了react-xxx@3.1.0
  2. 由于prop-types@15.5.0和顶级node_modules下的prop-types版本相同,所以不再单独安装
  3. 由于react和顶级node_modules下的react版本不一致,所以在自己node_modules内部单独安装react@16.12.0

如果有其他安装包,以此类推。。。。

*tips:cnpm既不会生成package-lock.json,也不会根据package-lock.json来安装依赖 *

当然我们在npm-install中也可以找到其算法:

load the existing node_modules tree from disk clone the tree fetch the package.json and assorted metadata and add it to the clone walk the clone and add any missing dependencies dependencies will be added as close to the top as is possible without breaking any other modules compare the original tree with the cloned tree and make a list of actions to take to convert one to the other execute all of the actions, deepest first kinds of actions are install, update, remove and move


看一段其diff的源码
> -选自于https://github.com/npm/cli/blob/latest/lib/install/diff-trees.js

module.exports = function (oldTree, newTree, differences, log, next) { validate('OOAOF', arguments) pushAll(differences, sortActions(diffTrees(oldTree, newTree))) log.finish() next() }

重点不必多说,我们看一下`diffTrees`做了什么

var diffTrees = module.exports._diffTrees = function (oldTree, newTree) { validate('OO', arguments) var differences = [] var flatOldTree = flattenTree(oldTree) var flatNewTree = flattenTree(newTree) var toRemove = {} var toRemoveByName = {}

// Build our tentative remove list. We don't add remove actions yet // because we might resuse them as part of a move. Object.keys(flatOldTree).forEach(function (flatname) { if (flatname === '/') return if (flatNewTree[flatname]) return var pkg = flatOldTree[flatname] if (pkg.isInLink && /^[.][.][/\]/.test(path.relative(newTree.realpath, pkg.realpath))) return

toRemove[flatname] = pkg
var name = moduleName(pkg)
if (!toRemoveByName[name]) toRemoveByName[name] = []
toRemoveByName[name].push({flatname: flatname, pkg: pkg})

})

// generate our add/update/move actions Object.keys(flatNewTree).forEach(function (flatname) { if (flatname === '/') return var pkg = flatNewTree[flatname] var oldPkg = pkg.oldPkg = flatOldTree[flatname] if (oldPkg) { // if the versions are equivalent then we don't need to update… unless // the user explicitly asked us to. if (!pkg.userRequired && pkgAreEquiv(oldPkg, pkg)) return setAction(differences, 'update', pkg) } else { var name = moduleName(pkg) // find any packages we're removing that share the same name and are equivalent var removing = (toRemoveByName[name] || []).filter((rm) => pkgAreEquiv(rm.pkg, pkg)) var bundlesOrFromBundle = pkg.fromBundle || pkg.package.bundleDependencies // if we have any removes that match AND we're not working with a bundle then upgrade to a move if (removing.length && !bundlesOrFromBundle) { var toMv = removing.shift() toRemoveByName[name] = toRemoveByName[name].filter((rm) => rm !== toMv) pkg.fromPath = toMv.pkg.path setAction(differences, 'move', pkg) delete toRemove[toMv.flatname] // we don't generate add actions for things found in links (which already exist on disk) } else if (!pkg.isInLink || !(pkg.fromBundle && pkg.fromBundle.isLink)) { setAction(differences, 'add', pkg) } } })

// finally generate our remove actions from any not consumed by moves Object .keys(toRemove) .map((flatname) => toRemove[flatname]) .forEach((pkg) => setAction(differences, 'remove', pkg))

return filterActions(differences) }

首先我们知道diff无非就是增删改这三种操作,只是其中再添加亿点点细节,那么这段代码就很好理解了。
- delete:先从oldTree中找到newTree没有的,放入toRemove中。
<br />之所以放到toRemove,是因为node_modules的扁平化操作以及各模块之间的相互依赖,后面的操作可能会复用到这里的包。不禁想起了经典的递归优化。。。

// Build our tentative remove list. We don't add remove actions yet // because we might resuse them as part of a move. Object.keys(flatOldTree).forEach(function (flatname) { if (flatname === '/') return if (flatNewTree[flatname]) return var pkg = flatOldTree[flatname] if (pkg.isInLink && /^[.][.][/\]/.test(path.relative(newTree.realpath, pkg.realpath))) return

toRemove[flatname] = pkg var name = moduleName(pkg) if (!toRemoveByName[name]) toRemoveByName[name] = [] toRemoveByName[name].push({flatname: flatname, pkg: pkg}) })

- update:遍历newTree,如果newTree中有oldTree的包,就把当前包的状态置于update,当然npm并不会自动update这些包,除非用户update

var oldPkg = pkg.oldPkg = flatOldTree[flatname] if (oldPkg) { // if the versions are equivalent then we don't need to update… unless // the user explicitly asked us to. if (!pkg.userRequired && pkgAreEquiv(oldPkg, pkg)) return setAction(differences, 'update', pkg) }

- move: 如果从toRemove中找到和新增的包信息相同并且该包没有捆绑操作的话,置于move。随后从toRemove中删掉此包。

// find any packages we're removing that share the same name and are equivalent var removing = (toRemoveByName[name] || []).filter((rm) => pkgAreEquiv(rm.pkg, pkg)) var bundlesOrFromBundle = pkg.fromBundle || pkg.package.bundleDependencies // if we have any removes that match AND we're not working with a bundle then upgrade to a move if (removing.length && !bundlesOrFromBundle) { var toMv = removing.shift() toRemoveByName[name] = toRemoveByName[name].filter((rm) => rm !== toMv) pkg.fromPath = toMv.pkg.path setAction(differences, 'move', pkg) delete toRemove[toMv.flatname] // we don't generate add actions for things found in links (which already exist on disk) }```

  • add:既在oldTree中找不到信息,又不在move list内,说明状态是add
  • remove: 最后剩下的toRemove状态既是remove

end

参考链接