跳到主要内容

rollup 实战讲解

Rollup 是一个 JavaScript 模块打包工具,可以将多个小的代码片段编译为完整的库和应用。它采用 ES6 Modules 标准进行实现,打包产物可以按需引入。目前也支持打包 CommonJS 、UMD、IIFE、amd 等格式。目前 vite 的生产模式就是采用的 Rollup 进行打包。rollup 比较适合框架或者工具库的打包,在打包速度和配置的复杂度上较 webpack 有一定的优势,但在在用来打包复杂应用时,相较于 webpack 还是有一定的不足。

本文将基于 zustand 的打包配置来学习 rollup 的使用

rollup 快速入门​

命令行打包​

最简单的打包方式是在安装 rollup 后使用命令行进行简单的打包

# 单文件打包
rollup src/main.js -o bundle.js -f cjs

#多文件打包
rollup src/main.js src/index.js xxx -o bundle.js -f cjs

-o指定输出文件名,如果不设置则只会将打包结果打印在命令行里,不会输出到文件中;-f指明了输出的格式。详细的命令行参数看这里命令行参数表

配置文件​

就像 babel、eslint、webpack 等工具一样,rollup 也支持通过读取配置文件的形式进行打包设置。配置文件常见用名是rollup.config.js,使用也很简单

# 配置文件名是 rollup.config.js 等 可以省略后续的文件名
# 会按照下面的顺序进行查找
# rollup.config.mjs -> rollup.config.cjs -> rollup.config.js
rollup -c/--config [config file name]

简单的配置文件内容如下,这是采用 es6 module 的写法,也可以用 cjs 的写法。如果想使用 ts 的话,需要安装插件@rollup/plugin-typescript ,然后运行命令 rollup -c rollup.config.ts --configPlugin typescript 具体的介绍可以看这个configPlugin

不是很推荐使用 ts 当配置文件,如果需要类型提示可以参考config-intellisense进行设置。

export default {
input: "src/main.js",
output: {
file: "bundle.js",
format: "cjs",
},
};

配置文件支持导出数组类型的配置,这样可以一次进行多种要求的打包

// rollup.config.js (building more than one bundle)

export default [
{
input: "main-a.js",
output: {
file: "dist/bundle-a.js",
format: "cjs",
},
},
{
input: "main-b.js",
output: [
{
file: "dist/bundle-b1.js",
format: "cjs",
},
{
file: "dist/bundle-b2.js",
format: "es",
},
],
},
];

配置文件也可写成一个函数的形式,它接受命令行参数

// rollup.config.js
import defaultConfig from "./rollup.default.config.js";
import debugConfig from "./rollup.debug.config.js";

export default (commandLineArgs) => {
if (commandLineArgs.configDebug === true) {
return debugConfig;
}
return defaultConfig;
};

对应的命令行是rollup --config --configDebug,获取到的命令行参数就是

commandLineArgs:{
config: true,
configDebug: true,
}

插件​

rollup 自身只有 esmodule 打包的功能,如果需要更高级的功能的话,需要引入 plugin。不像 webpack 有 loader、plugin 等扩展方式,rollup 只有 plugin 一种扩展方式。

常见的 plugin 介绍​

@rollup/plugin-json​

使用此插件后可以直接导入.json文件,读取里面的值。官方示例如下

// src/main.js
import { version } from "../package.json";
export default function () {
console.log("version " + version);
}

// rollup.config.js
import json from '@rollup/plugin-json';
export default {
input: 'src/main.js',
output: {
file: 'bundle.js',
format: 'cjs'
},
plugins: [json()]
};

// output
'use strict';
var version = '1.0.0';
function main() {
console.log('version ' + version);
}
module.exports = main;
@rollup/plugin-node-resolve​

在浏览器里,是无法解析import foo from 'foo'这个语句的,无法找到foo这个包,在 node 中使用这个不报错是因为寻找foo的算法在 node 中进行了实现。 readme 可以这样使用resolve({extensions:['.mjs', '.js', '.json', '.node']})指定插件起作用的文件类型

@rollup/plugin-commonjs​

这个包的作用是将 commonjs 的包转换成 esmodule 的形式,可以使用 import 语句进行导入

@rollup/plugin-babel​

使用这个包可以进行代码的转译,比如从 es6 转译成 es5,但是要注意需要在代码里配置.babelrc.json文件(babel.config.js也可,两者的区别可以看这篇文章)

代码分割​

代码分割可以看官方的code-splitting;简单来说就是采用了 esmodule 的import(..).then()懒加载模式的模块,会被自动分割成一个单独的模块,不会和它被导入部分的代码打包到一起。

简单示例

// src/main.js
export default function () {
import("./foo.js").then(({ default: foo }) => console.log(foo));
}

使用如下命令

rollup src/main.js -f cjs -d dist

可以得到如下结果

//→ main.js:
"use strict";

function main() {
Promise.resolve(require("./chunk-b8774ea3.js")).then(({ default: foo }) =>
console.log(foo)
);
}

module.exports = main;

//→ chunk-b8774ea3.js:
("use strict");

var foo = "hello world!";

exports.default = foo;

以 zustand 为例学习 rollup 打包​

前文已经介绍了 rollup 有关的基础知识,接下来就从 zustand 的打包配置来学习实际的 lib 库是如何打包的

前置相关内容​

相关插件​

@rollup/plugin-typescript​

@rollup/plugin-typescript 插件的作用是将 rollup 和 typescript 结合起来,入参包含 tsconfig.json 里的 compilerOptions 和一些其他定制参数

@rollup/plugin-alias​

@rollup/plugin-alias是别名插件,像下文例子所示,配置之后就可以使用import utils from 'utils'而不是'../../../utils'了

import alias from "@rollup/plugin-alias";

plugins: [
alias({
entries: [
{ find: "utils", replacement: "../../../utils" },
{ find: "batman-1.0.0", replacement: "./joker-1.5.0" },
],
}),
],
@rollup/plugin-replace​

readme说明这个插件的作用是在打包的时候进行字符串替换。

import replace from "@rollup/plugin-replace";

plugins: [
replace({
"process.env.NODE_ENV": JSON.stringify("production"),
__buildDate__: () => JSON.stringify(new Date()),
__buildVersion: 15,
}),
];

配置项​

external​

该选项用于匹配需要保留在 bundle 外部的模块,它的值可以是一个接收模块 id 参数并且返回 true(表示排除)或 false(表示包含)的函数,也可以是一个由模块 ID 构成的数组,还可以是可以匹配到模块 ID 的正则表达式。除此之外,它还可以是单个模块 ID 或者单个正则表达式。匹配得到的模块 ID 应该满足以下条件之一:

  • import 语句中外部依赖的名称。例如,如果标记 import "dependency.js" 为外部依赖,那么模块 ID 为 "dependency.js",而如果标记 import "dependency" 为外部依赖,那么模块 ID 为 "dependency"。
  • 绝对路径。(例如,文件的绝对路径)

当 external 是一个函数时,它会提供三个参数 (id, parent, isResolved)

id 值为相关模块的 id parent 值为执行 import 的模块的 id isResolved 值为布尔值,指是否已经通过插件等方式解决模块依赖

// rollup.config.js
import { fileURLToPath } from "node:url";

export default {
//...,
external: [
"some-externally-required-library",
fileURLToPath(
new URL(
"src/some-local-file-that-should-not-be-bundled.js",
import.meta.url
)
),
/node_modules/,
],
};
output​
  • esModule 该选项用于决定是否在生成非 ES 格式导出时添加 __esModule: true 属性。

  • intro/outro 除了在特定格式中代码不同外,该选项功能和 output.banner/output.footer 类似,用于在 bundle 前或后添加字符串

  • globals 类型为 { [id: string]: string }| ((id: string) => string),用于在 umd / iife bundle 中,使用 将模块 id 和全局变量绑定起来,比如{jquery:"$"}

  • name 输出格式为 iife / umd 的 bundle 来说,若想要使用全局变量名来表示你的 bundle 时,该选项是必要的。同一页面上的其他脚本可以使用这个变量名来访问你的 bundle 输出。

正文​

先看打包的命令

"build:base": "rollup -c",
"build:vanilla": "rollup -c --config-vanilla",
"build:middleware": "rollup -c --config-middleware",
"build:middleware:immer": "rollup -c --config-middleware_immer",
"build:shallow": "rollup -c --config-shallow",
"build:context": "rollup -c --config-context",

接下来看下rollup.config.js里的配置,可以发现采用的是返回数组的形式,一次打包多种格式的产物;可以看到没有对react.ts文件进行单独的打包,这是因为react.ts不需要单独导出,对index.ts打包即可。

const extensions = [".js", ".ts", ".tsx"];

module.exports = function (args) {
let c = Object.keys(args).find((key) => key.startsWith("config-"));
if (c) {
// replace针对的是build:middleware:immer,转换成middleware/immer
c = c.slice("config-".length).replace(/_/g, "/");
} else {
c = "index";
}
return [
...(c === "index" ? [createDeclarationConfig(`src/${c}.ts`, "dist")] : []),
createCommonJSConfig(`src/${c}.ts`, `dist/${c}`, {
addModuleExport: {
index: {
default: "react",
create: "create",
useStore: "useStore",
createStore: "vanilla.createStore",
},
vanilla: { default: "vanilla", createStore: "createStore" },
shallow: { default: "shallow$1", shallow: "shallow" },
}[c],
}),
createESMConfig(`src/${c}.ts`, `dist/esm/${c}.js`),
createESMConfig(`src/${c}.ts`, `dist/esm/${c}.mjs`),
createUMDConfig(`src/${c}.ts`, `dist/umd/${c}`, "development"),
createUMDConfig(`src/${c}.ts`, `dist/umd/${c}`, "production"),
createSystemConfig(`src/${c}.ts`, `dist/system/${c}`, "development"),
createSystemConfig(`src/${c}.ts`, `dist/system/${c}`, "production"),
];
};

下面就是对于引用函数的详细介绍了,因为大量配置重复,所以只介绍比较重要的几个。

  • createDeclarationConfig
const typescript = require("@rollup/plugin-typescript");
function createDeclarationConfig(input, output) {
return {
input,
output: {
dir: output,
},
external,
plugins: [
// 如函数名,只生成type
typescript({
declaration: true,
emitDeclarationOnly: true,
outDir: output,
}),
],
};
}
  • createESMConfig
const resolve = require("@rollup/plugin-node-resolve");
const { root } = path.parse(process.cwd());

// 外部npm包都external掉
function external(id) {
// 这个判断其实没必要!id.startsWith("."),因为入参是绝对路径
return !id.startsWith(".") && !id.startsWith(root);
}

function getEsbuild(target, env = "development") {
return esbuild({
minify: env === "production",
target,
tsconfig: path.resolve("./tsconfig.json"),
});
}

function createESMConfig(input, output) {
return {
input,
output: { file: output, format: "esm" },
external,
plugins: [
// const entries = [{ find: /.*\/vanilla\.ts$/, replacement: "zustand/vanilla" }];

alias({ entries: entries.filter((e) => !e.find.test(input)) }),
resolve({ extensions }),
replace({
...(output.endsWith(".js")
? {
"import.meta.env?.MODE": "process.env.NODE_ENV",
}
: {
"import.meta.env?.MODE":
"(import.meta.env && import.meta.env.MODE)",
}),
// a workround for #829
"use-sync-external-store/shim/with-selector":
"use-sync-external-store/shim/with-selector.js",
// 这个的作用是在正则匹配的时候作为字符串的边界
delimiters: ["\\b", "\\b(?!(\\.|/))"],
// 在进行赋值操作的时候,不会替换字符串
// 在true的时候,process.env.DEBUG = false;不会被替换
preventAssignment: true,
}),
// 使用esbuild进行编译,不minify
getEsbuild("node12"),
],
};
}
  • createCommonJSConfig
function getBabelOptions(targets) {
return {
...createBabelConfig({ env: (env) => env === "build" }, targets),
extensions,
comments: false,
babelHelpers: "bundled",
};
}

function createCommonJSConfig(input, output, options) {
return {
input,
output: {
file: `${output}.js`,
format: "cjs",
esModule: false,
// 手动添加commonjs导出
outro: options.addModuleExport
? [
`module.exports = ${options.addModuleExport.default};`,
...Object.entries(options.addModuleExport)
.filter(([key]) => key !== "default")
.map(([key, value]) => `module.exports.${key} = ${value};`),
`exports.default = module.exports;`,
].join("\n")
: "",
},
external,
plugins: [
alias({ entries: entries.filter((e) => !e.find.test(input)) }),
resolve({ extensions }),
replace({
"import.meta.env?.MODE": "process.env.NODE_ENV",
delimiters: ["\\b", "\\b(?!(\\.|/))"],
preventAssignment: true,
}),
babelPlugin(getBabelOptions({ ie: 11 })),
],
};
}
  • createUMDConfig
function createUMDConfig(input, output, env) {
let name = "zustand";
const fileName = output.slice("dist/umd/".length);
const capitalize = (s) => s.slice(0, 1).toUpperCase() + s.slice(1);
if (fileName !== "index") {
name += fileName.replace(/(\w+)\W*/g, (_, p) => capitalize(p));
}
return {
input,
output: {
file: `${output}.${env}.js`,
format: "umd",
name,
globals: {
react: "React",
immer: "immer",
// FIXME not yet supported
"use-sync-external-store/shim/with-selector":
"useSyncExternalStoreShimWithSelector",
"zustand/vanilla": "zustandVanilla",
},
},
external,
plugins: [
alias({ entries: entries.filter((e) => !e.find.test(input)) }),
resolve({ extensions }),
replace({
"import.meta.env?.MODE": JSON.stringify(env),
delimiters: ["\\b", "\\b(?!(\\.|/))"],
preventAssignment: true,
}),
babelPlugin(getBabelOptions({ ie: 11 })),
...(env === "production" ? [terser()] : []),
],
};
}