在前文中,ES Module 解决了 JavaScript 文件之间如何拆分和引用的问题。如果只引用自己的文件,并且路径都是 ./todo.js 这种明确的相对路径,浏览器可以直接处理。

但是模块化还没有解决另一个问题:如果希望使用别人已经写好的库,应该从哪里下载,怎样记录版本,怎样保证不同机器上的依赖尽量一致?

这就是 npm 主要解决的问题。

第三方依赖

对于简单页面,第三方依赖可以直接通过一个 URL 引入。例如在 HTML 中写:

1
<script src="https://cdn.jsdelivr.net/npm/lodash@4/lodash.min.js"></script>

这种写法的意思是:浏览器直接去这个 URL 下载一份已经构建好的第三方 JS 文件,然后把它作为普通脚本执行。对于临时 demo 或很小的页面,这样做没有问题。

但是项目一旦变复杂,直接引用第三方脚本会遇到几个问题:

  • 依赖分散在 HTML 或 JS 文件里,不容易集中查看;
  • 版本号写在 URL 中,升级和回退都需要手动维护;
  • 多人协作或换机器时,很难保证大家使用的是同一组依赖;
  • 一个库可能还依赖其它库,手动处理依赖关系会越来越麻烦;
  • 如果项目需要开发工具,例如 Vite、ESLint、TypeScript,也需要有统一的安装和调用方式。

所以对于正式一点的 JavaScript 项目,更常见的做法是使用 package manager 管理第三方依赖。Node.js 生态中最标准的包管理器是 npm,当然现在也有 pnpm、yarn 等替代品。

还有一个名称相似的 nvm,但是它是用来管理和切换 Node.js 版本的官方工具,与 npm 完全不同,而且通常搭配使用。

这里需要区分 Node.js 和浏览器:

  • Node.js:开发工具的运行环境,例如 npm;
  • 浏览器:网页的运行环境,例如 Chrome、Firefox、Edge。

npm 本身运行在 Node.js 环境中,主要用于开发阶段。最终用户访问网页时不需要安装 npm。

基本使用

npm 安装 package 时,首先要区分本地安装和全局安装。

本地安装是最常用的方式,例如:

1
npm install lodash

它会把依赖安装到当前项目的:

1
node_modules/

并写入当前项目的 package.json。这种方式适合项目代码实际依赖的库,例如 lodash、axios、React 等。

全局安装使用 -g 选项:

1
npm install -g typescript

它会把命令安装到全局环境中,方便在任意目录执行。例如安装 TypeScript 后,可以直接使用:

1
tsc --version

不过现在很多工具并不一定需要全局安装。更常见的做法是把工具安装到当前项目中,然后通过 npm run 调用。除此之外,还可以临时使用 npx 或 npm create,例如:

1
2
npx eslint .
npm create vite@latest todo-react -- --template react

这样可以减少不必要的全局安装,也能让不同项目使用各自需要的工具版本。

package.json

package.json 是 Node.js 项目的配置文件,记录了项目的依赖关系和常用脚本,必须纳入 git 版本管理。

一个简化的例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"react": "<version>",
"react-dom": "<version>"
},
"devDependencies": {
"vite": "<version>"
}
}

注意这里把依赖分成两类:

  • dependencies:应用本身运行时使用的库,例如 React;
  • devDependencies:开发和构建时使用的工具,例如 Vite、ESLint 等。

此外,还需要重点关注的是 scripts 字段。例如执行:

1
npm run dev

本质上只是让 npm 执行 package.json 中名为 dev 的脚本。在上面的例子中,它等价于执行:

1
vite

node_modules/ 和 package-lock.json

执行 npm install 之后通常会有:

1
2
node_modules/
package-lock.json

其中

  • node_modules/ 是实际安装的依赖,不应该提交到 git;
  • package-lock.json 记录实际解析得到的精确依赖树,应该提交到 git。

注意:

  • package.json 记录的是依赖的声明和约束,package-lock.json 才是精确的依赖信息。
  • 克隆一个使用 npm 的项目到本地,只需要重新执行 npm install 就可以精确恢复记录的依赖。

常用命令

创建或初始化项目:

1
2
npm init
npm init -y

npm init 会交互式生成 package.json,npm init -y 则使用默认值快速生成。

安装和卸载依赖:

1
2
3
4
5
6
npm install
npm install react
npm uninstall react

npm install -D vite
npm install -g typescript

其中:

  • npm install 根据 package.json 和 package-lock.json 安装当前项目全部依赖到本地的 node_modules/;
  • 项目中的安装和卸载命令都会及时更新 package.json 和 package-lock.json;
  • 默认是运行时依赖,加 -D 选项则是开发依赖;
  • 默认项目级别的操作,加 -g 选项全局操作。

查看依赖:

1
2
3
npm list
npm list --depth=0
npm list -g --depth=0

--depth=0 表示只看顶层依赖,否则依赖树可能非常长。

升级依赖:

1
2
3
npm update
npm update react
npm install react@latest

npm update 会在 package.json 允许的版本范围内更新依赖。如果希望直接安装最新版本,可以显式写 @latest。

执行脚本:

1
2
3
npm run dev
npm run build
npm run preview

这些命令本身没有固定含义,具体做什么取决于 package.json 中的 scripts。

本地 JS 如何使用安装的库

学完 npm 的基本用法以后,还会遇到一个关键问题:安装完成以后,本地 JS 文件中应该怎样使用这个库?

例如执行:

1
npm install lodash

按照 ES Module 的写法,很自然会想写:

1
import lodash from "lodash";

这里需要区分运行环境。

  • 如果这段代码是在 Node.js 中运行,Node.js 知道如何按照 package 解析规则去 node_modules/ 中寻找 lodash,因此这种写法是合理的。(npm 最初就是服务于 Node.js 生态的,Node.js 本身理解 package 名和 node_modules/)
  • 但是浏览器原生只认识相对路径或完整 URL,"lodash" 既不是 ./lodash.js,也不是 https://.../lodash.js。npm 只负责把 lodash 下载到 node_modules/ 并记录依赖关系,并不会让浏览器自动知道 "lodash" 应该对应哪个文件。

对于浏览器运行的情况,如果不使用构建工具,也有一些补丁性质的办法。但是在稍复杂的前端项目中,更推荐的主流做法是引入构建工具例如 vite 去解决这些问题。