生成 Node.js 环境的 CJS 和 ESM 规范的代码
首先我们这里强调是 Node.js 环境,是因为也存在浏览器环境的 CommonJS 规范和 ESM 规范,当然所谓浏览器环境的 CommonJS 规范是不存在的,只是打包工具将 Node.js 环境的 CommonJS 规范的代码转换成浏览器能识别的代码而已,而浏览器环境的 ESM 规范代码确实是有,毕竟 ESM 规范本身就是浏览器环境的模块规范,但与 Node.js 环境的 ESM 规范不同的是,浏览器环境不能识别 node_modules 中的代码,而 Node.js 环境可以,所以浏览器环境的 ESM 规范则需要把 node_modules 中的代码也打包进去,而 Node.js 环境的则不需要,从而可以减少打包体积。
们在 build 目录下新建一个 modules.js 用于进行模块化打包的执行文件。我们的 rollup 部分的代码架构还是跟全量打包是一样,为了顾名思义,我们把打包的函数改成:buildModules。
import { rollup } from "rollup";
import vue from "@vitejs/plugin-vue";
import { nodeResolve } from "@rollup/plugin-node-resolve";
// 模块化打包任务函数
const buildModules = async () => {
const bundle = await rollup({
input: "", // 配置入口文件
plugins: [
// 配置插件
vue(),
nodeResolve(),
],
// 排除不进行打包的 npm 包,例如 Vue,以便减少包的体积
external: ["vue"],
});
// 配置输出文件格式
bundle.write({});
};
buildModules();
我们可以看到跟全量打包功能的 rollup 插件配置部分是一致的,不同的是入口文件和输出文件的配置。
我们提供的 CommonJS 规范和 ESM 规范的代码都需要是模块化的,这样有利于实现按需加载以及打包时候的 Tree shaking 优化。那么要模块化,我们只要打包后保持原来开发环境下的模块结构即可。那么 rollup 打包怎么做到这个功能呢?在 rollup 中文官网查到了如下一段话:
如果你想将一组文件转换为另一种格式,并同时保持文件结构和导出签名,推荐的方法是将每个文件变成一个入口文件。
这样我们可以通过 fast-glob 包,动态地处理入口文件。所以我们先安装 fast-glob。
关于 fast-glob 的教程文档,可以看 fast-glob 的 GitHub 地址: github.com/mrmlnc/fast...
我们可以通过一下方式读取 src 目录下的所有文件。
import glob from "fast-glob";
import { fileURLToPath } from "url";
import { resolve, dirname } from "path";
// 获取当前文件路径
const __filename = fileURLToPath(import.meta.url);
// 获取当前文件所在目录
const __dirname = dirname(__filename);
const projectRoot = resolve(__dirname, "..");
const pkgRoot = resolve(projectRoot, "src");
const epOutput = resolve(projectRoot, "dist");
const input = await glob("**/*.{js,ts,vue}", {
cwd: pkgRoot,
absolute: true, // 返回绝对路径
onlyFiles: true, // 只返回文件的路径
});
接下来我们进行配置输出文件信息,我们主要设置输出的代码格式,输出文件的目录,并且需要把源码中的目录结构完整输出,等于是复制一遍。那么我们只需进行以下配置即可。
// 配置输出文件格式
await bundle.write({
format: "esm", // 配置输出格式
dir: resolve(epOutput, "es"), // 配置输出目录
exports: "named", // 使用具名导出,避免混合导出警告
preserveModules: true, // 该选项将使用原始模块名作为文件名,为所有模块创建单独的 chunk
entryFileNames: `[name].mjs`, // [name]:入口文件的文件名(不包含扩展名),也就是生产 .mjs 结尾的文件
});
await bundle.write({
format: "cjs", // 配置输出格式
dir: resolve(epOutput, "lib"), // 配置输出目录
exports: "named", // 使用具名导出,避免混合导出警告
preserveModules: true, // 该选项将使用原始模块名作为文件名,为所有模块创建单独的 chunk
entryFileNames: `[name].js`, // [name]:入口文件的文件名(不包含扩展名),也就是生产 .js 结尾的文件
});
开启 preserveModules 选项,就是为了输入的产物结构与输入一致,简单来说就是与源码目录结构保持一致。
我们在命令终端进入 build 运行以下命令:
node ./modules.js
这个时候我们看到打包目录下成功出现了 es 和 lib 的两个目录:

如果我们存在 node_modules 目录,这是因为我们把相关依赖包也进行了打包,我们提供的 Node.js 环境的 CommonJS 和 ESM 模块规范的代码是不应该把 node_modules 中的代码包也进行打包的,
// 排除不进行打包的 npm 包,例如 Vue,以便减少包的体积
external: [
'vue'
],
完整代码配置如下
import { rollup } from "rollup";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import vue from "@vitejs/plugin-vue";
import glob from "fast-glob";
import { fileURLToPath } from "url";
import { resolve, dirname } from "path";
// 获取当前文件路径
const __filename = fileURLToPath(import.meta.url);
// 获取当前文件所在目录
const __dirname = dirname(__filename);
const projectRoot = resolve(__dirname, "..");
const pkgRoot = resolve(projectRoot, "src");
const epOutput = resolve(projectRoot, "dist");
// 模块化打包任务函数
export const buildModules = async () => {
const input = await glob("**/*.{js,ts,vue}", {
cwd: pkgRoot,
absolute: true, // 返回绝对路径
onlyFiles: true, // 只返回文件的路径
});
const bundle = await rollup({
input, // 配置入口文件
plugins: [
// 配置插件
vue(),
nodeResolve(),
],
});
// 配置输出文件格式
await bundle.write({
format: "esm", // 配置输出格式
dir: resolve(epOutput, "es"), // 配置输出目录
exports: "named", // 使用具名导出,避免混合导出警告
preserveModules: true, // 该选项将使用原始模块名作为文件名,为所有模块创建单独的 chunk
entryFileNames: `[name].mjs`, // [name]:入口文件的文件名(不包含扩展名),也就是生产 .mjs 结尾的文件
});
await bundle.write({
format: "cjs", // 配置输出格式
dir: resolve(epOutput, "lib"), // 配置输出目录
exports: "named", // 使用具名导出,避免混合导出警告
preserveModules: true, // 该选项将使用原始模块名作为文件名,为所有模块创建单独的 chunk
entryFileNames: `[name].js`, // [name]:入口文件的文件名(不包含扩展名),也就是生产 .js 结尾的文件
});
};
buildModules();
NPM 包 package.json 的设置原理
我们打包提供 Node.js 环境的 CommonJS 和 ESM 模块规范代码本质就是为了提供 npm 包,npm 包的详细信息都储存在 npm 包的 package.json 文件中。每个通过 package.json 文件来描述项目或软件信息的 JavaScript 项目,无论是 Node.js 还是浏览器应用程序都可以被当作 npm 软件包。
我们知道 package.json 文件中比较重要的选项有 dependencies 和 devDependencies,它们存在两种环境中,分别是 Node.js 应用项目中的 package.json 和 npm 包也就是依赖包里的 package.json。它们在执行 npm install 的时候的功能作用都不一样。
执行 npm install 的时候会检查项目中有没有 package-lock.json 文件,有则检查 package-lock.json 文件和 package.json 文件的依赖声明版本是否一致。如果没有 package-lock.json 文件,则会根据 package.json 文件递归构建依赖树,然后按照构建好的依赖树下载完整的依赖资源。
这里值得注意的是在 Node.js 应用程序的根目录下的 package.json 文件中的依赖,不管是 dependencies 依赖还是 devDependencies 依赖还是 peerDependencies 依赖都会被安装,它们的区别是在当 package.json 是子依赖中的话就只会安装 dependencies 选项中的依赖。
比如我们在项目中安装 Element Plus 组件时,Element Plus 组件库中的 dependencies 选项中的依赖就同时被下载。devDependencies 选项的依赖则不会被下载。因为 devDependencies 选项依赖一般只在开发环境中被使用到。但如果我们从 GitHub 上克隆 Element Plus 组件库源码项目下来,进入到 Element Plus 项目中进行 npm install 时则 dependencies 和 devDependencies 选项都会被下载,因为此时是在开发 Element Plus 组件库阶段了,此时 package.json 文件的作用是用来描述整个应用项目的,而 npm 包中的 package.json 文件则是描述 npm 包的。
基于此原理,那么我们在打包 npm 包的时候我们就将一些生产环境的的依赖不进行打包,以便减少包体积。那么这些不进行打包的依赖我们就要把它们设置到 package.json 文件中的 dependencies 选项中,这样将来它们作为依赖中依赖就会在 npm install 的时候被安装了。
例如
{
"name": "directives-expand",
"version": "1.0.0",
"description": "",
"license": "MIT",
"main": "lib/index.js",
"module": "es/index.mjs",
"dependencies": {
"@vue/shared": "^3.5.27",
"lodash-es": "^4.17.23"
},
"peerDependencies": {
"vue": "^3.3.0"
},
}
当在一个项目中进行 npm install 时,项目根目录的 package.json 中的 dependencies、devDependencies、peerDependencies 中的依赖都会被安装,而在子依赖中的 npm 包则只会安装 dependencies 中的依赖,也正是基于此原理,我们在打包 npm 包的时候我们就将一些生产环境的的依赖不进行打包,而那些不进行打包的依赖则需要设置到 package.json 中的 dependencies 选项中。
为什么要提供 .mjs 和 .cjs 的文件?
是因为 .mjs 文件总是以 ESM 模块加载,.cjs 文件总是以 CommonJS 模块加载,在 Node.js 环境中 .js 文件的加载取决于 package.json 里面 type 字段的设置,默认情况下是以 CommonJS 模块加载。 此外 ESM 模块与 CommonJS 模块尽量不要混用。require 命令不能加载 .mjs 文件,否则会报错,只有 import 命令才可以加载 .mjs 文件。同样地,.mjs 文件里面也不能使用 require 命令,必须使用 import。这样则更加有利于 Tree shaking。
更多详情可查看阮一峰老师的这篇文章 Node.js 如何处理 ES6 模块
通过 gulp 自动执行多项任务
我们上面是通过手动的方式,一步一步地执行不同的命令进行操作的,这样太麻烦了,我们希望可以只执行一次命令操作,然后后台就可以自动帮我们完成上述的各种操作。这样我们就可以采用 gulp 进行编排任务进行执行了。
我们现在安装 gulp:
pnpm add gulp -D
gulp 的使用还是比较简单的,我们先把 ./full-bundle.js 中的 buildFullEntry 函数,./modules.js 中的 buildModules 函数,数通过 export 进行导出,并且注释原来的执行函数。
然后在 build 目录下创建 gulpfile.js 文件,在 gulpfile.js 文件中编写任务代码。
import { parallel } from "gulp";
import { buildFullEntry } from "./full-bundle.js";
import { buildModules } from "./modules.js";
export default parallel(buildFullEntry, buildModules);
parallel 则是同时执行任务。那么很明显构建浏览器的代码和构建 ESM、CJS 的代码以及构建声明文件是可以同时执行的,所以他们放在 parallel 执行,
之后我们在 根 目录下的 package.json 文件的 script 选项中设置以下命令:
"scripts": {
"build": "gulp -f gulpfile.js"
},
直接我们运行命令行中运行: pnpm run start。
我们就可以看到正常打包出了相关的文件,而且我们通过 gulp 只需要执行一次命令即可,不用手动执行多次命令进行打包。
本地模拟测试 NPM 包
我们可以通过 npm link 命令进行本地 npm 包测试。npm link 可以帮助我们模拟 npm 包安装后的状态,它会在系统中做一个快捷方式映射,让本地的包就好像 install 过一样,可以直接使用。
先来完善package.json
-
name: 项目的名称,即在 npm 上发布的包名。
-
version: 项目的当前版本号,遵循语义化版本规范。
-
description: 对项目功能的简短描述。
-
type : 指定模块系统,
module表示该项目默认使用 ES 模块(ESM)。 -
author: 项目作者的姓名及联系邮箱。
-
keywords: 关键词列表,方便用户在 npm 搜索中找到该插件。
-
homepage: 项目的主页链接,通常指向 GitHub 仓库或文档页面。
-
bugs: 接收 Bug 反馈的链接或邮箱。
-
license: 项目开源许可协议,这里使用的是 MIT 协议。
-
main: 指定 CommonJS 环境下的入口文件路径。
-
module: 指定 ES 模块环境下的入口文件路径。
-
unpkg: 为浏览器环境提供的 CDN 入口文件(非压缩或全量版)。
-
jsdelivr: 指定 jsDelivr CDN 使用的入口文件。
-
exports: 定义了更精细的导出规则,支持不同引用路径及环境下的重映射。
-
repository: 源代码仓库的类型及具体 URL 地址。
-
publishConfig: 指定发布时的配置,如公共访问权限及官方 npm 镜像源。
-
scripts: 定义了自动化脚本命令,如构建(build)、清理(clean)和文档预览。
-
devDependencies: 开发环境依赖,仅在开发和构建阶段使用(如构建工具、文档工具)。
-
engines: 指定项目运行所需的 Node.js 或 npm 的版本范围。
-
peerDependencies: 同级依赖,要求使用该库的项目必须安装指定版本的 Vue。
-
browserslist: 配置项目支持的目标浏览器范围,用于代码转译(如 Babel)。
-
dependencies : 运行时依赖,这里比较特殊,通过
link关联了一个本地路径的包。{
"name": "directives-expand",
"version": "1.0.0",
"description": "Custom Vue3 directives",
"type": "module",
"author": {
"name": "Mr.Jia",
"email": "jiagongzi0625@gmail.com"
},
"keywords": [
"v3",
"directives",
"vue3"
],
"homepage": "https://github.com/jiayc4215/directives-expand",
"bugs": {
"url": "https://github.com/jiayc4215/directives-expand/issues"
},
"license": "MIT",
"main": "dist/lib/index.js",
"module": "dist/es/index.mjs",
"unpkg": "dist/index.full.js",
"jsdelivr": "dist/index.full.js",
"exports": {
".": {
"import": "./dist/es/index.mjs",
"require": "./dist/lib/index.js"
},
"./es": "./dist/es/index.mjs",
"./lib": "./dist/lib/index.js",
"./dist/": "./dist/",
"./es/": "./dist/es/",
"./lib/": "./dist/lib/",
"./": "./"
},
"repository": {
"type": "git",
"url": "git+https://github.com/jiayc4215/directives-expand.git"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"scripts": {
"build": "gulp -f build/gulpfile.js",
"clean:all": "pnpm -r exec rm -rf node_modules && rm -rf node_modules && pnpm store prune",
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs"
},
"devDependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@rollup/plugin-node-resolve": "^16.0.3",
"@vitejs/plugin-vue": "^6.0.4",
"@vitepress-demo-preview/component": "^2.6.1",
"@vitepress-demo-preview/plugin": "^1.4.1",
"element-plus": "^2.13.2",
"fast-glob": "^3.3.3",
"gulp": "^5.0.1",
"rollup": "^4.57.1",
"vitepress": "2.0.0-alpha.16"
},
"engines": {
"node": ">= 4.0.0",
"npm": ">= 3.0.0"
},
"peerDependencies": {
"vue": "^3.3.0"
},
"browserslist": [
"> 1%",
"not ie 11",
"not op_mini all"
],
"dependencies": {
"directives-expand": "link:../../../../Library/pnpm/global/5/node_modules/directives-expand"
}
}
然后我们在命令终端进入到 根 目录,然后运行命令 npm link,这操作相当于在全局 node_modules 目录下创建一个 directives-expand 的超链接,相当于全局安装了一个 npm 包。
接着我们在命令终端进入到 docs目录,然后运行命令 pnpm link directives-expand ,此操作则相当于本地安装 directives-expand 包。
这样我们就可以在 docs 文件中进行以下的方式引用我们打包好的库了
import DefaultTheme from "vitepress/theme";
import { ElementPlusContainer } from "@vitepress-demo-preview/component";
import "@vitepress-demo-preview/component/dist/style.css";
import ElementPlus from "element-plus";
import "element-plus/dist/index.css";
import * as ElementPlusIconsVue from "@element-plus/icons-vue";
import directivesExpand from "directives-expand";
export default {
extends: DefaultTheme, // 继承默认主题
enhanceApp: async ({ app }) => {
// 注册图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
// 注册 demo-preview 组件
app.component("demo-preview", ElementPlusContainer);
// 注册 ElementPlus
app.use(ElementPlus);
// 注册指令
app.use(directivesExpand);
},
};
上述的引用方式就像一个正常的发布后的 npm 包的引用方式了。
pnpm run docs:dev
我们发现正常启动了,说明我们的打包是成功的。

上述方式是 ESM 模块的测试,至于 CommonJS 模块的测试,本文不展会开讨论。
总结
至此我们整个 npm包 的打包流程大体上分析得差不多了,剩下的一些细节以及 CSS 模块,本篇限于篇幅就不再作过多的深入分析了,后续有机会再另起篇章再作分析。
整个的打包过程涉及到的知识还是比较过的,首先是 ESM、CommonJS、UMD 等模块规范需要熟悉了解,其次是 Rollup 的了解,熟悉了解不同的使用模式,比如 Rollup 的 API 使用模式、接着是 文件的编译原理,还有 npm 包的原理以及 npm install 的原理等。