包管理知识架构图

基本概念
npm概念
npm,即 node package manager,是 node 包管理器。
在现代前端开发中,包管理器是必不可少的工具,这主要基于以下几点原因:
- 在项目开发进程中,经常会用到他人编写好的现成代码。若采用传统方式引入包,每次都要从官网手动下载代码、解压,再将其放入自己的项目里。这种方式不仅原始,而且操作极为繁琐。
- 在当下的开发环境里,引入的包通常存在复杂的依赖关系。若让开发者手动管理这些依赖,不仅容易出错,而且操作起来十分麻烦。
鉴于这些问题,包管理器应运而生。它是专门用于管理软件包、库及其依赖关系的工具。
npm 主要由三部分构成:
- npm 官网 :网址为www.npmjs.com 。开发者可以在该网站注册账号,搜索特定的包并查看其详细说明。这里提供了丰富的文档、用户评价等信息,帮助开发者更好地了解每个包的功能、使用方法和适用场景。
- CLI(command line interface):也就是命令行接口。这是开发者日常与 npm 打交道最频繁的方式。通过在控制台输入命令,开发者可以方便地与 npm 进行交互。
- registry(包注册表):这是 npm 对应的大型仓库,所有上传的包都会存储在这里。包注册表就像是一个巨大的数据库,记录着每个软件包的详细信息,包括包的名称、版本号、作者、描述、依赖关系、下载地址等。
包的概念
什么是包
包是一种组织代码的方式,一般来说,一个包提供了一个功能来解决某一个问题,一般一个包会将相关的所有目录和文件放到一个独立的文件夹当中,并通过一个特殊的文件来描述这个包。另外,如果你需要向npm发布包,npm要求你必须要有package.json这个文件
包的组成
一个完整的包通常包含以下几个主要部分:
- 代码文件:这是包的核心部分,包含实现特定功能的代码。这些代码可以是各种编程语言的文件,如 JavaScript、Python 等。例如,一个 JavaScript 包可能包含多个 .js 文件,每个文件实现不同的功能模块。
- package.json 文件:在使用 npm 进行包管理时,package.json 是必不可少的文件。它记录了包的元数据,包括包的名称、版本号、作者、描述、依赖关系等信息。通过这个文件,包管理器可以了解包的基本情况,并正确安装和管理包及其依赖。
- 文档和说明文件:为了让其他开发者能够正确使用包,通常会包含一些文档和说明文件,如 README.md。这些文件会详细介绍包的功能、使用方法、安装步骤、示例代码等信息。
- 测试文件:为了保证包的质量和稳定性,包中可能会包含一些测试文件,用于对包的功能进行单元测试和集成测试。测试文件可以帮助开发者及时发现和修复代码中的问题。
包和模块
- 模块 :模块是一个相对较小的代码单元,通常指一个单独的文件或一组紧密相关的文件。它可以导出变量、函数、类等,供其他模块或包使用。在 JavaScript 中,一个 .js 文件就可以被看作一个模块,通过
export
关键字可以将模块中的内容导出,使用import
关键字可以在其他模块中导入这些内容。 - 包与模块的关系:包是模块的集合,一个包可以包含多个模块,这些模块共同协作实现包的功能。包通过 package.json 文件来管理和组织这些模块,同时还可以包含其他资源和配置信息。模块是包的基础组成部分,多个模块组合在一起形成了具有更强大功能的包。
包的分类
一个包可以分为作用域包和非作用域包:
- 作用域包:作用域包以 @ 符号开头,后面跟上作用域名称,然后是斜杠和包名,格式为 @scope-name/package-name,例如 @vue/cli。作用域的存在可以避免包名冲突,因为不同作用域下可以使用相同的包名。作用域名可以看作是一个命名空间,通常表示某个组织或团队。通过作用域包,开发者可以清晰地知道某个包的归属和来源,同时也方便组织内部的包管理和共享。
- 非作用域包:非作用域包没有 @ 符号和作用域名称,只有一个简单的包名,如 lodash、vue 等。由于没有作用域的限制,非作用域包的包名必须在全局范围内唯一,以避免冲突。在 npm 上发布非作用域包时,需要确保所选的包名没有被其他开发者使用。
初次之外,一个包还可以分为公共包和私有包:顾名思义,公共包就是大家都可以访问安装的、私有包就是只有特定人或者团队才可以使用的,一般用于存储企业资源,需要一个付费的npm账户 也可以自己搭建私服。
npm指令
常用指令
npm init -y
:能快速初始化一个新的 npm 项目。-y 选项可跳过所有交互式询问,直接按默认设置生成 package.json 文件,大大节省项目初始化的时间。npm instal xxx / npm i xxx
:此指令用于在项目中安装指定的包。安装完成后,包会被放置在项目的 node_modules 目录下,同时依赖信息会记录到 package.json 文件中。npm uninstal xxx / npm rm xxx
:用于从项目中卸载指定的包。卸载操作会将包从 node_modules 目录移除,同时从 package.json 文件里删除对应的依赖信息。
查看相关信息指令
npm version
:该指令用于查看当前 npm CLI 的详细信息。相较于 npm -v 仅显示版本号,npm version 能提供更全面、详细的信息。npm root [-g]
:借助此指令可查找本地或全局安装的包的根目录。若添加 -g 参数,则查找全局安装包的根目录;不添加时,查找当前项目本地安装包的根目录。npm info <package-name>
:使用该指令可以查看某个特定包的详细信息,包括版本号、依赖项、作者以及包的描述等内容。npm search <package-name>
:当你输入关键字后,该指令会搜索所有与关键字相关的包。npm outdated
:用于检查当前项目中的依赖包是否有可用的更新版本。若存在更新,会列出需要更新的包及其当前版本和最新版本。npm ls [--depth number] [-g]
:此指令用于列出当前项目中安装的依赖包。--depth number
参数可指定列出依赖的深度,-g
参数则用于列出全局安装的包。
配置相关指令
一个成熟的指令系统通常会支持配置功能,因为不可能将所有设置固定不变,很多时候需要允许用户根据自身需求进行灵活配置。npm 也是如此,其配置可以通过以下几种途径实现:
- 命令行配置 :
npm config get/set registry
:可用于查看或设置当前使用的镜像源。通过切换镜像源,能显著提升包的下载速度。npm config list
:该指令会列出当前 npm 的所有配置信息,方便你查看各项配置的具体值。npm config edit
:进入编辑模式,允许你手动修改 npm 的配置文件,进行更精细的配置调整。
- 环境变量配置:通过设置系统的环境变量,也能对 npm 的行为进行配置。
.npmrc
文件配置:它是npm的配置文件,可以通过key-value的形式来对npm进行配置,支持项目级、用户级、全局级配置(这由该文件定义的位置决定,一般来说项目级配置优先级最高)。
软链接相关指令
npm link <package-name>
:该指令用于为指定的包创建一个链接(相当于一个快捷方式)。当其他项目需要使用这个包时,借助这个快捷方式,其他项目可以快速链接到该包。这样,即使该包进行了重新发布,其他项目也无需重新安装,只需通过快捷方式即可使用最新版本。npm unlink <package-name>
:用于移除指定包的链接,解除项目与该包的快捷链接关系。
缓存相关
npm在安装、更新或者卸载包的时候,npm会将这些包的tarball文件缓存到本地磁盘上,这样有助于未来的安装过程,有助于加速将来的安装过程,之后再次安装的时候,可以直接从缓存文件中去获取,无需再次从远程仓库中下载。
tarball文件是一种压缩文件格式,在npm中,tarball文件用于将包的所有文件打包成一个单独的文件,以便在安装或者更新包时在npm仓库下载。当你运行
npm install <package>
时,npm会从远程仓库下载包的tarball文件,然后本地解压缩和安装该包
包更新相关
npm update <package-name>
:该指令用于更新当前项目的依赖包,npm会检查是否有新的版本,如果有就会更新,但是注意在更新的时候回去满足package.json里面的版本范围规定(~^)。npm audit
:用于检查当前项目中哪些依赖存在漏洞,在审计的同时可以使用npm audit fix
来修复漏洞npm dedupe
:用于优化项目里面的依赖树的结构,可以消除重复依赖,但是无法完全消除,因为有时候重复的依赖可能版本不一样。npm prune
:用于删除没有在package.json文件中列出的包,可以帮我们清理node_modules,删除不再需要的依赖
提供帮助
npm help <command>
:用于查看有哪些指令/某个指令的帮助
包的说明文件
包的说明文件,指的是package.json,当我们用npm init去初始化一个项目的时候,会自动生成一个package.json文件。
包说明信息相关的配置项
-
name:包的名称,在 npm 生态系统中必须保证唯一性。发布包时,该名称将作为唯一标识,其他开发者会通过这个名字来搜索和引用你的包。因此,选择一个有意义且独特的名称至关重要。
-
version :包的版本号,通常采用 x.y.z 的格式,这种格式遵循语义化版本控制(SemVer)规则,有助于开发者清晰地了解包的更新情况。
- 主版本号(major - x):当软件包发生重大变化,如架构重构、接口不兼容的升级时,需要增加主版本号。这意味着使用该包的项目可能需要进行较大的修改才能适配新版本。
- 次版本号(minor - y):当软件包添加了新的功能或特性,但保持向后兼容性时,应增加次版本号。使用该包的项目通常可以直接升级到这个新版本,无需进行大量修改。
- 修订号(patch - z):当软件包进行了 bug 修复、性能优化或其他较小的改动时,需要增加修订号。这些更新通常不会影响包的现有功能,项目可以安全地升级到新版本。
-
description:包的简要描述信息,会在 npm 官网的搜索结果中展示。清晰、准确的描述有助于其他开发者快速了解包的功能和用途,从而决定是否使用该包。
-
keywords:包的关键词列表,用于搜索和分类。该字段接收一个数组,你可以在其中列出与包相关的重要关键词,这样其他开发者在搜索时更容易找到你的包。
-
author:包的作者信息,通常包括作者的姓名、邮箱和网址等。这有助于其他开发者了解包的来源和联系作者。
-
contributors:包的贡献者名单,列出了除作者之外对包做出贡献的开发者。这是对所有贡献者的认可,也方便用户了解包的开发团队。
-
license:包的许可信息,指定了包的开源类型。常见的开源许可证包括 MIT、Apache 2.0、GPL 等。选择合适的许可证可以明确包的使用和分发规则,保护开发者的权益。
-
repository:包的源代码仓库信息,通常提供一个 Git 地址。这使得其他开发者可以方便地访问包的源代码,查看历史版本、提交记录,并参与开发和贡献。
-
engines :指定项目所需的 Node.js 版本以及 npm 版本。通过明确这些要求,可以避免用户在使用你的包时因版本不兼容而出现问题。例如:
json"engines": { "node": ">=14.0.0", "npm": ">=7.0.0" }
这表示该包需要 Node.js 版本 14.0.0 或更高,以及 npm 版本 7.0.0 或更高才能正常运行。
包执行相关配置项
- main:此属性用于指定包的入口文件。当其他项目引入该包时,npm 会依据这个入口文件来加载包的主要功能代码。
- browser:该选项主要针对浏览器环境进行设置。在浏览器环境下,有时需要对特定的模块或者文件进行替换,以适配浏览器的运行机制。
- scripts:能够配置一系列快捷可执行命令。这些命令能够极大地简化开发流程,例如启动开发服务器、运行测试用例、打包项目等操作,都可以通过在命令行中输入预先配置好的快捷命令来完成。
包的依赖信息相关的配置项
- dependencies:用于列出包的依赖列表。这些依赖是项目在生产环境中正常运行所必需的。当对项目进行最终打包时,此部分依赖会一同被打包进项目中。比如项目用lodash处理数据,lodash就该加进dependencies,保证生产环境项目功能完整。
- devDependencies:是开发依赖列表。里面的包只在开发时用,生产环境打包不需要,像webpack(模块打包)、eslint(代码规范检查)、typescript(若用 TypeScript 开发)、sass(编译 SCSS 样式)等。这些工具对开发很重要,但生产环境用户无需,放这里能减小项目体积。
- peerDependencies :主要用于开发插件或库。它规定使用该项目时,用户项目必须满足的依赖版本,确保兼容性。比如开发基于特定React版本的插件,在插件package.json设好peerDependencies,若用户安装插件时React版本不符,npm 会警告。 但是需要注意,不要滥用peerDependencies,因为使用peerDependencies意味着用户需要手动去安装这些版本对应的依赖,这无疑增加了用户的操作步骤和难度。只有那些真正需要与宿主项目共享,或者对版本有严格要求的依赖才需要设置为peerDependencies,例如vue、react。
版本依赖控制符号 在package.json定义依赖时,可用^和~控制更新版本范围:
- ^(脱字符) :允许依赖更新到同主版本下最新版,次版本和补丁版本可更新,主版本不变。如依赖设为
^1.2.3
,那么当1.3.0
、1.3.1
等版本发布时 npm 会自动更新,但2.0.0
不会,既获新功能和修复,又不影响项目架构。 - ~(波浪字符) :只允许更新补丁版本,主版本和次版本不变。如依赖设为
~1.2.3
,只有1.2.4
、1.2.5
等补丁版本发布时 npm 才更新,适合对依赖稳定性要求高,不想因版本更新出兼容性问题的情况。 - 关于版本范围的符号,还有很多,有一套详细的规则。可以参考:docs.npmjs.com/cli/v11/con...
发布npm包
发布npm包的大致步骤如下:
- 准备自己的npm账号
- 配置 package.json
- 打包并发布
准备帐号
若要将自己的包发布到 npm 平台,首先得有一个 npm 账号。
你可以前往 npm 官网(www.npmjs.com/)进行注册。
注册完成后,在本地使用 npm login
命令登录你的 npm 账号,登陆完毕后可以使用npm whoami
来检查是否登陆成功。
登录成功后,若想获取个人账号的相关信息,可使用 npm profile
命令。若后续需要退出登录,使用 npm logout
命令即可。
配置package.json
重点的配置项如下: ● 包的信息:name
、version
、description
、keywords
、author
、license
等。 ● 模块类型:type
、exports
● 忽略文件:files
模块类型设置
通过type
值可以设置模块类型。关于type
这个配置项,不是npm所提供的配置项,而是node提供的配置项。type
配置项主要有两个值:
- commonjs :指定使用CommonJS模块系统,这是node.js默认所使用的模块系统,在这种情况下,需要使用require函数来导入模块。如果需要使用ECMAScript模块,则需要将文件扩展名设置为
.mjs
- module :指定使用ECMAScript模块系统,在这种情况下,可以直接使用import和export语法来导入导出模块。如果需要使用CommonJS模块,则需要将文件扩展名设置为
.cjs
除此之外,还有一个配置项exports
,该配置项用于定义一个模块的导出映射,通过这个配置项,可以对模块的导入导出提供一个更加精细的控制,指定不同模块的入口文件。
配置示范:
json
{
"name": "ziheng77-tools",
"version": "1.0.0",
"description": "Some js tools",
"main": "./src/index.js",
"type": "module",
"scripts": {
"build": "rollup -c"
},
"exports": {
"require": "./dist/index.cjs",
"import": "./dist/index.js"
},
"keywords": [
"tools"
],
"files": [
"/dist"
],
"author": "ziheng77",
"license": "ISC"
}
设置忽略文件
发布包到 npm 时,通常只需上传核心运行文件,像 TypeScript 配置文件等配置文件无需上传。设置忽略文件有黑名单和白名单两种方式:
- 黑名单 :在项目根目录下创建
.npmignore
文件,在其中设置不需要上传的文件。不过,使用这种方式时,若新增了无需发布的文件,容易忘记修改.npmignore
文件。 - 白名单 :在
package.json
文件中有files
字段,只有该字段中列出的文件或目录才会被上传,这种方式更为可靠。
配置示范:
json
{
"name": "mytools",
"version": "1.0.0",
"description": "Some js tools",
"main": "./src/index.js",
"type": "module",
"scripts": {
"build": "rollup -c"
},
"exports": {
"require": "./dist/index.cjs",
"import": "./dist/index.js"
},
"keywords": [
"mytools",
"tools",
"jstools"
],
"files": [
"/dist"
],
"author": "ziheng77",
"license": "ISC"
}
打包与发布
可使用 webpack、rollup 等打包工具进行项目打包。这里以 rollup 为例,配置如下:
javascript
import { defineConfig } from 'rollup';
import commonjs from '@rollup/plugin-commonjs';
import nodeResolve from '@rollup/plugin-node-resolve';
import { babel } from '@rollup/plugin-babel';
import { terser } from 'rollup-plugin-terser';
export default defineConfig({
input: 'path/to/your/entry/file.js', // 指定打包入口文件路径
output: [
{
file: 'path/to/your/output/file.esm.js', // 输出的ES模块文件路径
format: 'esm' // 输出格式为ES模块
},
{
file: 'path/to/your/output/file.cjs', // 输出的CommonJS模块文件路径
format: 'cjs' // 输出格式为CommonJS
}
],
plugins: [
nodeResolve(), // 帮助Rollup找到外部模块
commonjs(), // 将CommonJS模块转换为ES6模块
babel({
babelHelpers: 'bundled',
exclude: 'node_modules/**' // 排除node_modules下的文件
}),
terser() // 压缩打包后的代码
]
});
配置完成之后使用rollup -c
进行打包,打包完成之后如果需要向官方发布,那么需要检查镜像源是否为官方镜像源,如果不是则需要使用npm config set registry=https://registry.npmjs.org/
来进行配置,最后使用npm publish
进行发布。
搭建npm私有服务器
在企业应用开发场景下,有时我们期望发布的包仅在公司内部使用,具备私有性。此时,搭建 npm 私有服务器成为必要选择。搭建方式主要有两种:一是在 npm 平台付费使用其私有服务;二是自行搭建私有服务器。
通常,企业倾向于自行搭建私有服务器,原因如下:
- 代码保密性强:可有效保障企业内部代码的私密性,防止敏感信息泄露。
- 下载速度快:在局域网环境中,能显著提升包的下载速度,优化开发流程。
- 配置灵活:能够进行更为细致的配置,如设置权限控制,便于维护和管理。
Verdaccio:轻量级 npm 私有代理仓库
Verdaccio 是一款基于 Node.js 的轻量级私有代理仓库,可助力我们快速搭建 npm 私服。
主要特点
- 轻量级架构:采用 Node.js 编写,安装简便且运行高效,不依赖外部数据库,直接将数据存储于本地文件系统。
- 配置简易:仅需一个 YAML 文件就能完成配置,可轻松设置用户权限、上游代理以及缓存等相关参数。
- 缓存与代理功能:既能作为上游 npm 注册表的代理,减轻网络延迟,加快包的安装速度;又能缓存已下载的包,以便在未联网状态下仍可获取过往使用过的包。
- 精细访问控制:支持基于用户和包的访问控制,可灵活管理哪些用户能够访问、发布以及安装私有 npm 包。
- 插件扩展支持:具备插件系统,能够对其功能进行拓展,以满足不同的业务需求。
使用方法
使用方法非常简单,只需要3步即可启动一个verdaccio服务:
- 安装 :在命令行中执行
npm install verdaccio -g
即可完成安装。 - 查看基本信息 :运行
verdaccio -h
指令,可查看 Verdaccio 的基本信息。 - 启动服务器 :输入
verdaccio
命令,启动 Verdaccio 服务器。
常见配置
详细配置可参考官网:verdaccio.org/zh-CN/docs/...
- storage:用于指定存储包的路径。
- web:负责网站相关的配置。
- uplinks:此为上游配置,当私服中不存在所需包时,会自动查找上游代理获取,安装过的包则会缓存至私服。
- packages:实现对包的权限控制。
- auth:用于设置用户身份的验证方法,默认采用 htpasswd 方式。
镜像管理工具 nrm
nrm 是一款专门用于管理 npm 镜像的工具,能够便捷地切换 npm 镜像。
nrm使用方法
- 安装 :通过
npm install nrm open -g
命令进行安装。 - 常用指令 :
nrm ls
:可列出所有可用的镜像列表。nrm use <registry>
:用于切换镜像。nrm add <registry> <url>
:实现添加镜像的操作。nrm del <registry>
:能够删除指定镜像。
- 检查镜像 :使用
npm config get registry
指令,可检查当前使用的镜像。
其他包管理器
在 npm 主导包管理的基础上,因开发需求催生了 Yarn、pnpm 等其他包管理器,它们各具特色,有力推动着前端开发依赖管理的发展 。
Yarn:npm的有力竞争者
2016 年,Google、Facebook、Exponent、Tilde 等团队联合开发并推出了 Yarn。它的诞生主要是为了解决当时 npm 在速度、安全性以及一致性方面存在的问题。
从速度上,Yarn 采用并行下载策略,能同时安装多个依赖包,相比 npm 早期顺序安装方式大幅缩短安装时长。
在安全性方面,Yarn 采用了严格的校验机制,在包的传输过程中,通过数字签名等技术验证包的完整性,确保其在传输过程中未被篡改。同时,Yarn 仓库拥有一套细致的包审核流程,在包发布前会对其进行多维度审查,涵盖代码规范、潜在安全漏洞等方面,极大程度降低了恶意包混入的风险 。
从一致性角度,Yarn 引入yarn.lock文件,精确锁定每个依赖包的版本、来源与校验和,无论在哪安装,只要有此文件,就能保证依赖结构完全一致 。
使用yarn的方法基本和使用npm没什么区别,主要区别就是在安装包: npm使用的是 npm install <package-name>
,而yarn使用yarn add <package-name>
。其他命令基本上是兼容的
pnpm:独具优势的新一代包管理器
继 yarn 之后,pnpm 作为独具优势的新一代包管理器,以其创新的核心策略、出色的性能表现以及对复杂项目结构的良好支持,在包管理领域占据了重要地位。
在npm和yarn的时代中,还存在一些痛点:
- node_modules体积庞大,如果项目多起来将会占用很大部分的磁盘空间
- npm和yarn安装依赖时会将所有间接依赖拍平到node_modules中,这就会导致幽灵依赖问题,即package.json没有声明的依赖也可以被引用。
- 对于大型项目的monorepo管理方案不支持,需要配合第三方库来实现,如lerna。
他主要的创新就是使用了包隔离和包提升策略来解决这两个问题,并且原生就支持了monorepo。
硬链接与软链接(符号链接)
硬链接和软链接(符号链接)这两种链接机制是实现包隔离和包提升的基础,它们各自发挥着独特作用,有效解决了传统包管理中的痛点问题。
- 硬链接:在文件系统里,硬链接是多个目录条目指向同一物理数据的方式,这些链接共享相同的 inode(索引节点)。可以把 inode 想象成文件的 "身份证",它包含了文件的物理存储位置等关键信息。当多个硬链接指向同一个文件时,它们实际上访问的是磁盘上同一份物理数据。而且,删除其中一个硬链接,并不会影响其他硬链接对文件的访问,因为物理数据依旧存在。
- 软链接(符号链接):软链接也被称为符号链接,它本质上是一种特殊的文件,其内容是指向另一个文件或目录的路径信息,类似于我们常见的快捷方式。当访问软链接时,系统会根据其存储的路径信息去找到对应的目标文件或目录。如果目标文件被删除,软链接就会失效,因为它指向的路径已经不存在。
使用包提升机制减少磁盘占用
传统 npm 包管理中,每个项目的node_modules独立存储所有依赖,随着依赖增多,磁盘空间被大量占用。例如大型项目的node_modules常达数百 MB 甚至数 GB。
pnpm 的包提升机制改变了这一局面。安装包时,包被存于全局中。
项目直接依赖通过硬链接与全局存储关联,硬链接让多个项目可共享同一物理数据,如多个项目依赖lodash,磁盘仅存一份文件,各项目通过硬链接访问,删除某项目链接不影响其他项目。
间接依赖用符号链接,避免重复存储。不同项目依赖同一包的不同版本时,pnpm 只存版本差异文件。以此,pnpm 显著降低多项目开发时的磁盘空间消耗。
使用包隔离机制解决幽灵依赖
npm 和 yarn 管理下,幽灵依赖问题频发。幽灵依赖指项目中未在package.json声明,却因其他依赖间接引入的第三方包,这易引发版本冲突和安全隐患,如不同版本共存导致运行错误,或引入有漏洞的包使项目面临风险。
pnpm 通过包隔离机制化解此难题。所有依赖统一存于全局node_modules/.pnpm目录,为每个项目构建独立node_modules。
项目仅通过符号链接从全局引入package.json声明的依赖,未声明的幽灵依赖无法进入项目node_modules,不能被访问。例如项目 A 未声明依赖库 C,其node_modules就不会有库 C 的链接。
加之 pnpm 的严格模式主动检测并阻止非法访问未声明依赖,确保了项目依赖树的纯净,提升项目稳定性与安全性。
所以可以注意到使用pnpm的node_modules文件夹是非常干净的,只会包含直接依赖的文件夹。
原生支持monorepo
使用 pnpm 搭建 Monorepo,只需在项目根目录创建 pnpm - workspace.yaml 配置文件并定义工作区包目录,示例如下:
yaml
packages:
- 'packages/*'
- 'apps/*'
配置完成后,pnpm 会自动扫描指定目录,识别含 package.json 的子包并统一管理。
pnpm 处理 Monorepo 项目的大概流程如下:
- 解析子包信息:解析所有子包的 package.json 文件,提取关键信息。
- 构建依赖关系图:基于解析信息构建完整的项目依赖关系图。
- 智能处理依赖 :
- 公共第三方依赖共享:对多子包共同依赖的第三方包,采用链接(硬链接 / 符号链接)实现跨子包共享,避免重复安装。
- 本地子包引用处理 :本地子包间相互引用时,通过
workspace:*
协议处理,确保直接引用源码。
处理过程中,pnpm 会自动将多子包共用的第三方公共依赖提升到 Monorepo 根目录的 node_modules 中,避免重复安装,提升效率。
搭建Monorepo
常见的项目管理方案
- 多仓管理(Multirepo):作为传统模式,一个包对应一个仓库。比如公司若有多个前端项目,每个项目都有独立的 Git 仓库,自行管理代码、依赖与构建流程。其逻辑隔离清晰,权限管理便捷,适合独立迭代的项目。但项目增多时,像 ESLint、Prettier、CI/CD 等工具都需在每个项目中单独配置,维护成本会大幅上升。
- 单仓管理(Monorepo):属于现代管理方式,多个包或项目共用一个仓库,像知名开源项目 Babel、React、Vue 3 都采用此模式。它便于代码共享,能统一工具链,依赖管理也更高效。不过,仓库体积易膨胀,权限管理相对复杂,对工具链要求较高。
Multirepo的痛点
多仓管理在小型团队或简单项目中还能应付,但随着项目规模增长,问题会逐渐暴露:
- 重复的基建工作:每个新项目都要从头搭建 ESLint、Prettier、CommitLint、StyleLint 等工具链,耗时且容易不一致。
- 公共代码难以复用:比如公司有 5 个项目都用到了相同的工具函数或 UI 组件,但每个项目都自己维护一份,修改时要在多个仓库同步更新。
- 依赖版本混乱:项目 A 用 Vue 2,项目 B 用 Vue 3,项目 C 又用 Vue 2 但 patch 版本不同,升级和维护成本高。
- 跨项目调试困难:如果项目 B 依赖项目 A 的组件,在 Multirepo 下需要先发布 A 的改动,再更新 B 的依赖,调试反馈周期长。
Monorepo如何解决这些问题?
Monorepo 的核心思想是"共享",它通过统一仓库带来以下优势:
- 统一工具链:所有项目共用一套 ESLint、Prettier、Husky 等配置,维护成本大幅降低。
- 代码共享更便捷:公共组件、工具函数可以直接通过内部依赖引用,修改能实时生效,无需发布到 npm。
- 依赖管理更高效:所有项目共享同一个 node_modules,避免重复安装,且版本冲突问题更容易被发现和解决。
- 原子级提交:一次提交可以同时修改多个包的代码,并确保它们之间的兼容性(比如同时改 API 和调用方)。
- 跨项目重构更安全:在 IDE 中可以直接全局搜索和修改所有相关代码,减少遗漏。
举个例子,假设你正在开发一家在线教育公司的项目,采用 Monorepo 模式进行管理,其中包含三个包:
- utils(公共工具函数):这里面封装了日期格式化、数据验证、加密解密等通用工具函数,这些函数是整个项目各部分都会用到的基础功能。
- web-app(前端网页应用):是供学生和教师在网页端使用的在线教育平台,包含课程展示、在线学习、作业提交等功能。
- mobile-app(移动端应用):是学生和教师在手机或平板上使用的在线教育应用,提供和网页端类似的核心功能,但针对移动端设备做了针对性适配和优化。
当 web-app
和 mobile-app
依赖 utils
时,若优化 utils
中的日期格式化函数,改动会立即同步到两个应用,无需手动操作与重新发布。
Monorepo的缺点
没有完美的架构,Monorepo 也存在局限性:主要还是在仓库体积和学习成本心智负担方面上,这考验团队对于monorepo的熟悉程度。
- 仓库管理难度:所有代码历史汇聚于单一仓库,仓库体积会随时间不断膨胀。这不仅导致克隆仓库时耗时大幅增加,日常操作也会因庞大的数据量而变慢,给开发流程带来诸多不便。
- 团队适应成本:采用 Monorepo 时,团队需掌握新开发模式与工具。以 pnpm + workspace 方案为例,伴随项目规模持续扩大,为增强 Monorepo 功能、优化开发体验,可能要引入相关工具。如 turborepo,其凭借增量构建特性,能依据代码变更智能构建受影响部分,大幅缩短构建时长;还有 changesets,可有效管理版本变更,方便团队成员创建、跟踪和应用版本变化,确保项目版本管理的规范与可追溯 。
如何选择是否用 Monorepo?
主要分两种场景:重构还是新项目。可以基于STAR(情景-任务-行动-结果)原则进行思考:
场景一:重构项目
- 情景(situation):现有一个大型项目,由多个独立的子项目构成,各子项目有自己独立的代码仓库、依赖管理和构建流程。随着业务的不断拓展,子项目之间的交互日益频繁,代码重复问题严重,不同子项目间相似功能模块的维护成本极高。
- 任务(task):通过重构,优化项目结构,提高代码复用率,降低维护成本。在此过程中,需评估将分散的子项目整合到 Monorepo 架构下是否能有效解决现有问题。
- 行动(action) :
- 代码梳理:对现有各子项目的代码进行全面梳理,识别出可共享的通用代码模块。
- 依赖分析:深入分析各子项目的依赖关系,统计公共依赖的数量和使用频率。若存在大量公共依赖,评估在 Monorepo 架构下统一管理依赖,减少版本冲突的可行性。
- 团队沟通:与各子项目开发团队进行充分沟通,了解他们对开发模式变更的接受程度和担忧。因为切换到 Monorepo 意味着开发流程、工具使用等方面的改变,需提前做好团队的思想工作和培训准备。
- 风险评估:考虑 Monorepo 架构引入的潜在风险,如仓库体积增大对现有 CI/CD 流程的影响,是否需要升级服务器资源以支持代码的快速克隆和构建;权限管理方面,如何确保原有的子项目权限体系在新架构下依然有效,是否需要引入额外的权限管理工具等。
- 结果(result) :
- 使用monorepo:若代码梳理发现大量可复用模块,依赖分析表明统一管理依赖能显著减少版本冲突,团队对变更接受度较高,且风险评估显示通过合理措施可应对潜在问题,那么采用 Monorepo 进行重构。重构后,预计可大幅减少重复代码量,降低维护成本,增强系统稳定性,提升开发效率,实现各子项目间更高效的协作与交互。
- 保持原来方案:若在行动过程中,发现可复用代码有限,统一依赖管理难度大,团队对新开发模式抵触情绪严重,且解决 Monorepo 引入风险的成本过高,超过重构带来的收益,那么保持原来多仓库独立管理的方案。后续可通过其他方式,如制定更严格的代码规范、加强跨团队沟通等,缓解当前存在的问题。
场景二:新项目
- 情景(situation):公司计划启动一个全新的综合性项目,该项目涵盖多个紧密相关的业务领域,如社交平台与电商功能的融合项目,需要多个技术团队协同开发,包括前端 Web 开发、移动端开发、后端服务开发以及数据处理团队等。各团队之间需要频繁共享代码和数据,并且对项目的整体一致性和协同效率要求极高。
- 任务(task):在项目初始阶段选择合适的项目架构,以满足项目高效开发、长期维护和快速迭代的需求。此时,需判断 Monorepo 架构是否适合该新项目的特点和团队协作模式。
- 行动(action) :
- 业务分析:剖析项目的 UI 设计稿,若有大量如按钮、导航栏、弹窗这类通用 UI 组件,在 Monorepo 架构下,利于统一开发维护,保障多端视觉一致及风格调整的高效同步;考量项目多端开发情况,对于 Web、移动端 APP、小程序等多端产品,Monorepo 可集中管理像用户认证、网络请求处理、工具链配置等共享逻辑,减少重复开发;审视团队基建,若已具备基础工具库、组件库或成熟开发规范流程,Monorepo 能更好整合基建成果,便于各业务模块调用,如自研的数据验证库可在不同端及业务模块便捷使用 。
- 团队评估:了解各技术团队的规模、技术能力和协作经验。若团队成员技术水平较高,对新的开发模式接受度好,且过往有过类似多团队协同开发的经验,那么采用 Monorepo 架构更易推行。
- 工具选型:调研适合当前业务的 Monorepo 架构的工具链,如 pnpm + workspace 用于依赖管理,Turborepo 用于高效构建等。评估团队对这些工具的学习成本和使用可行性,确保在项目开发过程中能够充分发挥工具的优势。
- 架构规划:提前规划 Monorepo 的项目结构,包括如何划分不同业务模块的目录,如何设置统一的开发规范和流程,以保证项目的可维护性和扩展性。
- 结果(result) :
- 使用monorepo:若团队评估显示技术能力和协作经验适配 Monorepo,业务分析从 UI 设计稿、多端需求到团队基建都表明 Monorepo 能带来显著优势,工具选型和架构规划合理且可行,那么采用 Monorepo 架构启动新项目。
- 使用multirepo:若团队对新架构和工具接受度低,业务分析发现从 UI 设计到多端开发及团队基建等方面,采用 Monorepo 无法带来明显优势甚至存在诸多阻碍,或者工具选型和架构规划难以有效实施,那么放弃 Monorepo 架构,采用传统的多仓库独立开发方案。
以上为两种不同场景的思考举例,实际场景还需要根据业务特性、时间、人力等因素来进行权衡考虑。
搭建Monorepo工程
这里使用pnpm + workspace来搭建Monorepo项目,主要流程如下:
1. 初始化项目
首先,你要在全局环境安装 pnpm,然后创建一个新的项目目录并进入该目录,最后对项目进行初始化。具体命令如下:
bash
npm install -g pnpm
mkdir monorepo-template && cd monorepo-template
pnpm init
执行上述命令后,会在项目根目录生成一个 package.json 文件。后续所有子包的公共配置,例如脚本和依赖,都可以放在这个文件中。
2. 配置workspace
在项目根目录创建一个名为 pnpm-workspace.yaml 的文件,该文件用于定义哪些目录属于工作空间。以下是一个示例:
yaml
packages:
- "packages/*" # 所有子包放在 packages 目录下
- "apps/*" # 应用项目放在 apps 目录下
- "!**/test/**" # 排除测试目录
在这个配置文件中,packages 字段是必需的,它是一个数组,数组中的每一项代表一个匹配规则。这些规则可以是具体的目录名,也可以使用通配符。通配符 * 表示匹配任意名称的子目录,** 则可以匹配任意层级的目录结构。
3. 创建和编写子包
在上述定义的工作空间目录中,你可以创建子包,每个子包都相当于一个独立的项目。操作步骤如下:
bash
mkdir -p packages/utils apps/web
cd packages/utils && pnpm init
cd apps/web && pnpm init
如果需要使用typescript,则需要安装一些相关的依赖和对typescript编译器进行配置,步骤如下:
- 安装依赖 :使用
pnpm add -w @types/node typescript -D
安装typescript的依赖到全局中。 - 配置utils子包的tsconfig.json:
json
{
"compilerOptions": {
"target": "ES6",
"module": "ESNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist/types",
"rootDir": "./src",
"baseUrl": ".",
"paths": {
"@utils/*": ["src/utils/*"]
},
"types": ["node"],
"declaration": true,
"declarationDir": "./dist/types",
"moduleResolution": "node",
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "test"]
}
之后就是对子包的代码进行编写,这里以utils为例编写两个工具函数:格式化日期的函数和一个计算数组元素和的函数
typescript
// index.ts
// 格式化日期函数,将日期对象格式化为 'YYYY-MM-DD' 格式的字符串
export function formatDate(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// 计算数组元素和的函数,接受一个数字数组,返回数组元素的总和
export function sumArray(arr: number[]): number {
return arr.reduce((acc, num) => acc + num, 0);
}
4. 对子包进行打包
编写完代码之后需要进行打包构建的配置,这里以rollup为例,步骤如下:
- 安装依赖:
bash
pnpm --filter utils add rollup @rollup/plugin-commonjs @rollup/plugin-node-resolve rollup-plugin-typescript2 rollup-plugin-terser -D
- 创建rollup.config.js并进行配置:
javascript
import commonjs from '@rollup/plugin-commonjs';
import nodeResolve from '@rollup/plugin-node-resolve';
import typescript from 'rollup-plugin-typescript2';
import { terser } from 'rollup-plugin-terser';
export default {
// 入口文件,根据实际项目修改
input: 'src/index.ts',
output: [
{
// 输出为 CommonJS 模块,适用于 Node.js 环境
file: 'dist/index.cjs',
format: 'cjs',
sourcemap: false
},
{
// 输出为 ES 模块,适用于现代浏览器和支持 ES 模块的环境
file: 'dist/index.js',
format: 'esm',
sourcemap: false
}
],
plugins: [
// 解析 Node.js 模块,使 Rollup 能够处理来自 node_modules 的依赖
nodeResolve(),
// 将 CommonJS 模块转换为 ES 模块,以便 Rollup 能够处理
commonjs(),
// 处理 TypeScript 文件,使用项目中的 tsconfig.json 配置
typescript({
// 如果你想覆盖 tsconfig.json 中的某些配置,可以在这里添加
// tsconfigOverride: { compilerOptions: { module: 'ESNext' } },
tsconfig: './tsconfig.json',
useTsconfigDeclarationDir: true // 必须加上这句,否则declaretaionDir不会生效
}),
// 对打包后的代码进行压缩
terser()
],
// 外部依赖,这些依赖不会被打包进最终的文件中,而是作为外部依赖引用
// 根据实际项目中的依赖情况修改
external: [],
};
- 配置package.json:指定打包后的入口文件,并且对cjs和esm分别作处理
json
{
"name": "utils",
"version": "1.0.0",
"description": "",
"main": "dist/index.cjs",
"module":"dist/index.js",
"type":"module",
"types":"dist/types/index.d.ts",
"files":["dist"],
"exports":{
".":{
"import":"./dist/index.js",
"require":"./dist/index.cjs"
}
},
"scripts": {
"build":"rollup -c"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
}
}
5. 对子包进行引用
我们可以通过pnpm --workspace --filter web add utils
来给web子包安装utils子包。此时web子包的package.json会出现如下依赖:
workspace:^
表示依赖来自于当前 Monorepo 中的其他工作区。接下来我们对其引入进行测试:在src中创建一个index.js进行测试,导入utils的formatDate
javascript
import { formatDate } from 'utils'
console.log(formatDate(new Date()))
接下来cd到该目录使用node执行,可以看到以下结果: 这说明utils子包导入成功。