模块系统与 npm——万物皆模块
摘要Node.js 的生态建立在模块之上。本文将彻底解释 CommonJS 和 ES Modules 的区别与用法教你使用 npm 管理第三方包、理解 package.json、版本号语义以及如何创建并发布属于自己的 npm 包。读完这一篇你将拥有参与开源生态的能力。一、为什么需要模块化在 Node.js 诞生之前浏览器里的 JavaScript 根本没有正式的模块机制。所有的脚本通过script标签引入共享全局作用域极易冲突。Node.js 把 JavaScript 带到服务器端后必然要解决代码组织和复用的问题。它的答案是 CommonJS 规范。二、CommonJS 模块系统CommonJS 是 Node.js 默认的模块系统。每个文件就是一个模块有自己的作用域。模块通过require导入通过module.exports或exports导出。新建math.js// math.js const add (a, b) a b; const subtract (a, b) a - b; module.exports { add, subtract, };在main.js中使用const math require(./math); console.log(math.add(5, 3)); // 8模块加载过程解析路径找到文件。将文件内容包裹在一个函数中注入exports、require、module、__filename、__dirname五个参数。执行这个函数模块内部的变量无法污染全局。返回module.exports对象。所以本质上上面的math.js运行时等同于(function(exports, require, module, __filename, __dirname) { const add (a, b) a b; const subtract (a, b) a - b; module.exports { add, subtract }; });require 的查找规则以./或../开头按相对路径找文件。若没写后缀Node 会依次尝试.js、.json、.node如果找到的是目录则会查找该目录下的package.json里的main字段或直接找index.js。不以路径开头则视为内置模块或node_modules中的第三方模块。三、ES ModulesESM从 ES2015 开始JavaScript 有了官方的模块语法。Node.js 从 v12 开始以实验性支持 ESMv14 后基本稳定。使用 ESM文件后缀改为.mjs或在package.json中设置type: module。math.mjsexport const add (a, b) a b; export const subtract (a, b) a - b;main.mjsimport { add } from ./math.mjs; console.log(add(5, 3));ESM 与 CommonJS 的主要区别ESM 是静态导入import必须写在顶层不能放在条件中而require是动态的可以写在任何地方。ESM 使用严格模式this为undefined。ESM 是异步加载的支持顶级await。ESM 导出的是绑定引用CommonJS 导出的是值的拷贝。现在 Node.js 项目中两种方式并存。新项目建议使用 ESM但在大量老项目中仍是 CommonJS。两个系统尽量不要混用。四、走进 npm 世界npm 是 Node Package Manager 的缩写是目前世界上最大的开源包注册中心。安装 Node.js 时会自动安装 npm。你的项目需要什么功能大多数都可以在 npm 找到现成方案无需重复造轮子。4.1 初始化项目创建一个新项目文件夹运行npm init会交互式询问项目名称、版本、入口文件等一路回车可快速生成package.json。如果使用默认值可以npm init -ypackage.json是一个项目的“身份证”记录了依赖、脚本、元信息等示例{ name: my-project, version: 1.0.0, description: 一个学习项目, main: index.js, scripts: { start: node index.js, test: echo \Error: no test specified\ exit 1 }, dependencies: {}, devDependencies: {} }4.2 安装包以lodash这个工具库为例npm install lodash这会将包下载到node_modules文件夹并在package.json的dependencies中添加记录。node_modules通常很大不要提交到版本控制系统在.gitignore中加上它。使用安装好的包const _ require(lodash); console.log(_.capitalize(hello world)); // Hello world如果要把包作为开发依赖如测试框架、打包工具在安装时加--save-dev或简写-Dnpm install jest --save-dev它会出现在devDependencies中。4.3 全局安装有些工具需要在命令行直接使用如nodemon自动重启服务器可以全局安装npm install -g nodemon之后可直接在终端运行nodemon server.js。4.4 版本号语义npm 包的版本号遵循“主版本.次版本.修订号”格式如2.4.1主版本不兼容的 API 修改。次版本向下兼容的功能新增。修订号向下兼容的问题修正。package.json中版本号前的符号含义^2.4.1锁定主版本允许次版本和修订号更新2.4.1 3.0.0。~2.4.1锁定主版本和次版本允许修订号更新2.4.1 2.5.0。2.4.1精确版本。*或latest最新版。建议使用^以获取安全更新。五、开发自己的 npm 包我们来写一个小工具包并发布。5.1 创建包结构my-utils/ ├── package.json ├── index.js └── lib/ └── string.jspackage.json注意name要唯一发布前先去 npm 网站搜索是否占用main指向入口文件。{ name: my-utils-ysyx, version: 1.0.0, main: index.js, license: MIT }lib/string.jsfunction capitalize(str) { if (typeof str ! string) return ; return str.charAt(0).toUpperCase() str.slice(1); } module.exports { capitalize };index.jsconst string require(./lib/string); module.exports { capitalize: string.capitalize, };5.2 本地测试在另一个项目中用相对路径安装npm install /path/to/my-utils然后引入测试功能是否正常。5.3 发布到 npm先去 npmjs.com 注册账号。终端运行npm login登录。在包目录下执行npm publish。注意如果包名被占用会发布失败。每次更新版本需修改version字段后再次 publish。六、npx 与包运行器有时我们希望运行一个仅在当前项目中安装的命令而不全局污染。npx就是解决办法。比如安装create-react-app脚手架后原本需要./node_modules/.bin/create-react-app my-app有了 npx 后直接npx create-react-app my-appnpx 会先在本地node_modules/.bin中寻找命令找不到则临时下载执行非常方便。七、包管理器新选择认识 pnpm与 npm 有何不同7.1 从 npm 的“黑洞”说起通过前面几节你已经熟练使用npm install来安装依赖。但你可能注意到两个现象磁盘空间被大量重复占用。你电脑上有 10 个 Node 项目每个项目的node_modules都躺着一份一模一样的lodash即使版本完全相同。这会占用大量磁盘空间安装速度也受影响。“幽灵依赖”问题。npm 会把所有包的依赖都扁平化提升到顶层node_modules这导致你的代码可以require一个你从未在package.json中声明过的包比如你只装了express却可以直接用express内部依赖的debug包。这埋下了隐患一旦express不再依赖debug你的项目就会突然报错。为了解决这些问题出现了pnpmperformant npm。它和 npm、yarn 一样都是包管理器但内部设计截然不同。7.2 pnpm 的神奇设计硬链接 内容寻址pnpm 的核心思想是所有相同版本的包在你的电脑上只保存一份。全局仓库pnpm 会将所有下载过的包存储在系统的一个全局目录中~/.pnpm-store类似一个“公共仓库”。硬链接当你在项目中安装依赖时pnpm 不会复制文件而是创建硬链接Hard Link直接指向全局仓库中的文件。硬链接几乎不占额外空间且操作速度极快。非扁平化的 node_modulespnpm 构建的node_modules结构严格遵循你在package.json中声明的依赖关系。你只能使用自己明确安装的包彻底杜绝“幽灵依赖”。我们来一个简单比喻npm 是每家每户都买一本《词典》哪怕内容一模一样浪费书架空间。pnpm 则是社区图书馆每家只放一张索书卡链接要看时直接去图书馆取所有家庭共享同一本书。7.3 pnpm 与 npm、yarn 的直观对比特性npmyarn (classic)pnpm安装速度较慢较快并行下载非常快硬链接 缓存磁盘占用高每项目独立副本较高极低全局存储项目仅链接node_modules 结构扁平化有幽灵依赖扁平化有幽灵依赖非扁平严格隔离monorepo 支持较弱需 workspaces内置 workspaces一流支持原生过滤、并行执行CLI 命令npm install/addyarn addpnpm add与 npm 高度兼容lock 文件package-lock.jsonyarn.lockpnpm-lock.yaml关键优势一句话总结pnpm 在几乎不改变你使用习惯的前提下大幅节省磁盘空间加快安装速度并从根本上避免幽灵依赖。7.4 pnpm 安装与基本命令安装 pnpm推荐使用 npm 全局安装或者使用独立脚本npm install -g pnpm # 或使用官方脚本Windows PowerShell 中也可 # iwr https://get.pnpm.io/install.ps1 -useb | iex之后你可以完全像使用 npm 一样使用 pnpm命令几乎一致# 初始化项目生成 package.json pnpm init # 安装依赖 pnpm install pnpm add express pnpm add -D nodemon # 开发依赖 # 移除依赖 pnpm remove lodash # 运行脚本 pnpm run dev pnpm test # 全局安装 pnpm add -g pnpm # 更新自己与 npm 命令对比只需把npm换成pnpm大部分场景都能无缝替换。甚至npx也有对应命令pnpm exec或者直接pnpm dlx类似npx用于临时下载执行包。7.5 一个真实体验安装速度与磁盘空间假设你有一个中等规模的 Vite React 项目。我们分别用 npm 和 pnpm 安装依赖并查看node_modules大小实际测试数据可能因版本不同有浮动但比例大致如下安装方式耗时冷安装node_modules 大小npm install约 45 秒约 220 MBpnpm install约 18 秒约 120 MB大部分为链接这还只是一个项目。当你拥有 5 个相似项目时pnpm 能为你节省数百 MB 到 GB 级的磁盘空间因为相同依赖的物理文件只存一份。7.6 pnpm 的局限性为了让小白全面了解也要说清楚适用场景。pnpm 的缺点或注意事项包括对部分老旧或非标准的包的兼容性问题。极少数包在代码中使用了非绝对路径的文件读取可能因 pnpm 的严格链接结构而找不到文件。不过这类情况已非常罕见且 pnpm 社区有处理方案。学习成本几乎为零但如果你需要在团队中统一包管理器需要沟通协调确保每个人都使用pnpm而不是npm install否则 lock 文件可能冲突。如果你的项目是边缘物联网设备或极简环境不希望引入任何全局存储概念可能会倾向 npm 的“项目内自包含”方式。但绝大多数现代开发场景pnpm 都是更优选择。建议新项目可以直接使用 pnpm在学习阶段既然你已经懂了 npm不妨立刻体验 pnpm你会发现它更快、更干净而且完全兼容 npm 生态。八、模块缓存与循环依赖Node.js 在第一次require一个模块时会缓存其结果。后续require同一模块直接返回缓存模块代码不会再次执行。这有助于性能但也要小心循环依赖A 引用 BB 又引用 A。遇到循环依赖时可能得到一个未完全初始化的对象应尽量避免。九、脚本与自动化npm scripts在package.json的scripts字段可以定义自定义命令{ scripts: { start: node app.js, dev: nodemon app.js, test: jest, lint: eslint . } }运行npm run dev即可启动开发模式npm test运行测试。这类脚本可以串联复杂流程比如build: npm run lint npm run test node build.js。十、总结这一篇你掌握了 Node.js 模块化的两种方案彻底理解了require和import学会了用 npm 管理依赖、使用现成的包甚至能发布自己的包以及更先进的 pnpm。如果这篇文章帮你解决了实操上的困惑别忘记点击点赞、分享也可以留言告诉我你遇到的其它问题我会尽快回复。动手练习是掌握编程最快的方法请务必亲手敲一遍本文的所有示例代码并截图保存你的成果。你的关注是我坚持原创和细节共享的力量来源谢谢大家。