Rollup2.x 搭建React 组件库
说明
- Rollup 官网 版本最新
- Rollup 官方中文 版本滞后,有些 Api 已经改了,因此建议还是看英文原版
- 打包工具对比测试网站
- 本文 Rollup 2.x 组件库 Demo 地址
- Rollup 0.67.x 版本组件库
重要依赖版本
• “rollup”: “^2.36.0”
• “rollup-plugin-styles”: “^3.12.1”,
• “@rollup/plugin-eslint”: “^8.0.1”,
• “@rollup/plugin-babel”: “^5.2.2”,
• “@babel/core”: “^7.12.10”,
实现目标
与 v0.67.x 版本相比使用上有何不同
- Rollup 2.x 开始,其官方插件已经改用 scope package 的命名,例如 @rollup/plugin-eslint,所有官方插件都可以在 Rollup Plugins 上获得
- 与旧版本相比较,Rollup 2.x Api 有部分改动,大多数都是增量的,而且其配置文件的格式也变的更多样化了,例如: rollup.config.js 输出的可以是一个 配置对象 Object,也可以输出一个配置对象的数组,还可以是一个返回配置对象的方法;使用方式变得更加灵活
// roullup.config.js
export default {
input: 'src/index.ts',
output: {file: 'dist/index.js'}
}
export default [
{
input: 'src/a.ts',
output: {file: 'dist/a.js'}
},
{
input: 'src/b.ts',
output: {file: 'dist/b.js'}
}
]
export default (commandLineArgs) => {
const format = commandLineArgs.format || 'es';
delete commandLineArgs.format;
return {
input: 'src/index.ts',
output: {file: 'dist/index.js', format}
}
}
- 2.x 版本中 我们除了给整个配置对象设置 plugins 添加插件外,在 output 配置下也可以设置 plugins 来为不同类型的 output 定制化插件
export default [
{
input: entryFile,
output: [
{
format: 'umd',
name: 'RollupUI',
globals,
assetFileNames: '[name].[ext]',
file: 'dist/umd/rollup-ui.development.js'
},
{
format: 'umd',
name: 'RollupUI',
globals,
assetFileNames: '[name].[ext]',
file: 'dist/umd/rollup-ui.production.min.js',
plugins: [terser()], // 使用 rollup-plugin-terser 来输出压缩后的js 文件
}
],
external,
plugins: [styles(stylePluginConfig), ...commonPlugins]
}
]
重要功能如何实现
1. 如何排除公共依赖不打包仅组件中
使用 extrenal 配置,将不需要打包的 依赖加进去;有些时候会使用依赖的子一级文件,所以这个 extrenal 可以是一个函数,其参数是引用的依赖地址,返回值是一个boolean true 则表示,这个依赖不会被打包进去
针对 esm 与 cjs 类型,因为经过 rollup 编译后代码,还是会被组件库的使用者再次引用,并编译打包,所以我们没有必要把 babel 运行时的代码打包进入,因此这里将 @babel/runtime 的代码也排除了,但是 @babel/runtime 会作为组件库的
dependencies
存在,也就是说,组件库使用者,在使用时一定会下载 @babel/runtime ,从而保证组件库整体的正确
// rollup-ui/rollup.config.js
const externalPkg = ['react', 'react-dom', 'lodash'];
BABEL_ENV !== 'umd' && externalPkg.push('@babel/runtime');
const external = id => externalPkg.some(e => id.indexOf(e) === 0);
export default {
input: 'xxx',
external
}
// rollup-ui/package.json
{
"dependencies": {
"@babel/runtime": "^7.12.5"
},
"peerDependencies": {
"react": "^17.0.1",
"react-dom": "^17.0.1"
},
}
最后可以查看编译后输出的文件,这些第三方包只留下了引用,并没有被打包进来
2. 怎么处理 Typescript
我们采用了如下方案,借助 @babel/preset-typescript 的能力,直接操作 ts 代码,一步到位;而不是采用 @rollup/plugin-typescript 先将ts 转成js 然后再交给 @rollup/plugin-babel 再装换一次,增加了适配成本。
3. 静态资源如图片怎么处理
针对 JS 文件中使用 图片,我们通过 @rollup/plugin-image 来提供支持,针对在 样式文件中使用图片,我们通过给 rollup-plugin-styles 实现
考虑到作为组件库,图片资源的使用不会很多,因此无论是 JS 中的图片还是 CSS 中的图片,我们都会将其直接打包进 JS 或 CSS 中,而不是抽出为 asset (打包时将图片抽离到 js css 之外技术上可以实现,如果作为普通使用端项目来操作没有问题,但是,如果这个项目是要交付给第三方二次引用,那么静态资源的引用路径就会找不到,需要用户特殊处理才行)
// rollup-ui/rollup.config.js
import image from '@rollup/plugin-image';
import styles from 'rollup-plugin-styles';
export default {
input: 'xxx',
plugins: [
styles({
mode: "extract",
less: {javascriptEnabled: true},
extensions: ['.less', '.css'],
use: ['less'],
url: { inline: true} // inline 表示 资源会被打包在 css 中
})
image(), // 默认 image 会被打包进 js 中
]
}
4. esm cjs 模块如何按照组件维度进行 code-splitting 拆分
在旧的版本中我们通过定义“多入口文件”来上输出的分割包按照 组件维度分割,
在 2.x 版本中我们同样是用“多入口文件”,同时还可以使用output.preserveModules 、output.preserveModulesRoot
来帮助我们生成与源码目录结构类似的分割包
// rollup.config.js
const entryFile = 'src/index.ts';
const componentDir = 'src/components';
const cModuleNames = fs.readdirSync(path.resolve(componentDir));
const componentEntryFiles = cModuleNames.map((name) => /^[A-Z]\w*/.test(name) ? `${componentDir}/${name}/index.tsx` : undefined).filter(n => !!n);
export default {
input: [entryFile, ...componentEntryFiles],
output: {
dir: 'dist/es',
format: 'es',
preserveModules: true,
preserveModulesRoot: 'src',
},
}
# 源代码 src/ 下文件结构
.
├── components
│ ├── Alert
│ │ ├── Alert.tsx
│ │ └── index.tsx
│ ├── Button
│ │ ├── Button.tsx
│ │ ├── NoteButton.tsx
│ │ ├── css-avatar.png
│ │ └── index.tsx
└── index.ts
# rollup -c 后输出 dist/es/ 下文件结构
.
├── components
│ ├── Alert
│ │ ├── Alert.js
│ │ ├── index.js
│ ├── Button
│ │ ├── Button.js
│ │ ├── NoteButton.js
│ │ ├── index.js
│ │ └── style
├── index.js
可以看到,编译后输出的文件结构与源代码基本是一致的
5. 样式文件如何输出及样式文件如何按照组件维度进行 code-splitting 拆分
首先调研了
rollup-plugin-postcss
来实现样式抽离,但是这个插件只会把所有组件的样式抽离抽离出一个单独的文件。
后来调研了rollup-plugin-styles
这个插件,他可以与 preserveModules 做适配,即输出按照组件维度拆分的 css 文件,不过要想正确的实现这个功能需要我们做一些配置
export default {
input: [entryFile, ...componentEntryFiles],
output: {
dir: 'dist/es',
format: 'es',
preserveModules: true,
preserveModulesRoot: 'src',
exports: 'named',
assetFileNames: ({name}) => {
// 抽离后的样式文件会作为 asset 输出,这里可以配置一下 样式文件的输出位置(为 babel-plugin-import 做准备)
const {ext, dir, base} = path.parse(name);
if (ext !== '.css') return '[name].[ext]';
// 规范 style 的输出格式
return path.join(dir, 'style', base);
},
},
plugins: [
styles({
mode: "extract", // 使得 css 是抽离的,而不是打包进 js 的
less: {javascriptEnabled: true},
extensions: ['.less', '.css'],
minimize: false,
use: ['less'],
url: {
inline: true
},
sourceMap: true, // 必须开启,否则 rollup-plugin-styles 会有 bug
onExtract(data) {
// 以下操作用来确保每个组件目录只输出一个 index.css,实际上每一个 子级组件都会输出样式文件,index.css 会包含所有子一个组件的样式
const {css, name, map} = data;
const {base} = path.parse(name);
if (base !== 'index.css') return false;
return true;
}
})
]
}
# build 后输出结果
.
├── components
│ ├── Alert
│ │ ├── Alert.js
│ │ ├── index.js
│ │ └── style
│ ├── index.css
│ └── index.css.map
│ ├── Button
│ │ ├── Button.js
│ │ ├── NoteButton.js
│ │ ├── index.js
│ │ └── style
│ ├── index.css
│ └── index.css.map
├── index.js
└── style
├── index.css # 样式汇总 全量
└── index.css.map
NOTICE: 需要注意的是,抽取的 css 与原模块之间已经没有了引用关系,使用者需要同时引入 组件及与其对应的样式文件
6. Typescript 声明文件生成
由于是TS 项目,开发时就会创建 tsconfig.json , 需要注意的是,在 tsconfig.json 中以下几项需要设置一下,以确保其他相关部分的使用不出错(eslint rollup, 主要是如果设置了以下几项,在跑 rollup eslint 时会出现中间文件 d.ts 导致 eslint 校验错误)
{
"declaration": false,
"noEmit": true,
"emitDeclarationOnly": false
}
build 后生成 esm 或者 cjs 代码的同时也需要输出 TS 类型声明文件,只要在 build 后执行如下脚本即可
{
"scripts": {
"build:typed": "tsc --declaration --emitDeclarationOnly --noEmit false --outDir dist/es",
}
}
7. 对外输出 esm umd commonjs 规范的模块
- esm: 就是 ES Module 的模块(import export)主要提供给现代的打包工具(Webpack, Rollup)(npm 引入)使用,现代的打包工具会识别 package.json 中的 module 字段,如果包含这个字段,则会优先加载使用这个字段所对应的 ES Module, 在结合组件库的 sideEffect 配置可以实现 tree-shaking , 从而实现代码体积优化
- umd: 是一个通用模块定义,结合amd cjs iife 为一体,其打包后不会按照组件 code-splitting 而是打包为一个整体,主要直接提供给浏览器使用(
<script src='xxx.umd.js'>
) - cjs: 即 CommonJS 规范定义的模块,同样提供给 node 和 打包工具使用(旧版本的 Webpack, Gulp等不能直接导入 ES Module 的情况)
与输出模块类型相关的 依赖主要有两个,一个时 Rollup (打包),一个是 Babel (编译),因为同时使用两者,难免会有冲突,因此经过实践,得到如下结论:关闭掉 babel 预设 @babel/preset-env 对于 es module 的转化功能,完全使用 Rollup 来做模块转化,否则会出现 cjs 编译结果部分丢失的问题。
// .babelrc.js
module.exports = function (api) {
const presets = [
[
'@babel/preset-env',
{
// es 模块要关闭模块转换, cjs 模块同样要关闭转化
modules: false,
browserslistEnv: process.env.BABEL_ENV || 'umd',
loose: true,
bugfixes: true
},
],
'@babel/preset-react',
'@babel/preset-typescript',
];
return { presets};
};
Rollup 来做模块类型转化比较简单,只需要给 output 设置 format 类型即可,其中umd 类型是要给浏览器直接引用的,因此还有输出一个 压缩后的结果, 完整的项目配置如下
/**
* rollup 配置
* */
import * as path from 'path';
import * as fs from 'fs';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import babel from '@rollup/plugin-babel';
import replace from '@rollup/plugin-replace';
import image from '@rollup/plugin-image';
import eslint from '@rollup/plugin-eslint';
import styles from 'rollup-plugin-styles';
import {terser} from 'rollup-plugin-terser';
import autoprefixer from 'autoprefixer';
const entryFile = 'src/index.ts';
const BABEL_ENV = process.env.BABEL_ENV || 'umd';
const extensions = ['.js', '.ts', '.tsx'];
const globals = {react: 'React', 'react-dom': 'ReactDOM'};
const externalPkg = ['react', 'react-dom', 'lodash'];
BABEL_ENV !== 'umd' && externalPkg.push('@babel/runtime');
const external = id => externalPkg.some(e => id.indexOf(e) === 0);
const componentDir = 'src/components';
const cModuleNames = fs.readdirSync(path.resolve(componentDir));
const componentEntryFiles = cModuleNames.map((name) => /^[A-Z]\w*/.test(name) ? `${componentDir}/${name}/index.tsx` : undefined).filter(n => !!n);
const commonPlugins = [
image(),
eslint({fix: true, exclude: ['*.less', '*.png', '*.svg']}),
resolve({ extensions }),
babel({
exclude: 'node_modules/**', // 只编译源代码
babelHelpers: 'runtime',
extensions,
skipPreflightCheck: true
}),
// 全局变量替换
replace({
exclude: 'node_modules/**',
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
}),
commonjs(),
];
const stylePluginConfig = {
mode: "extract",
less: {javascriptEnabled: true},
extensions: ['.less', '.css'],
minimize: false,
use: ['less'],
url: {
inline: true
},
plugins: [autoprefixer({env: BABEL_ENV})]
};
const umdOutput = {
format: 'umd',
name: 'RollupUI',
globals,
assetFileNames: '[name].[ext]'
};
const esOutput = {
globals,
preserveModules: true,
preserveModulesRoot: 'src',
exports: 'named',
assetFileNames: ({name}) => {
const {ext, dir, base} = path.parse(name);
if (ext !== '.css') return '[name].[ext]';
// 规范 style 的输出格式
return path.join(dir, 'style', base);
},
}
const esStylePluginConfig = {
...stylePluginConfig,
sourceMap: true, // 必须开启,否则 rollup-plugin-styles 会有 bug
onExtract(data) {
// 一下操作用来确保只输出一个 index.css
const {css, name, map} = data;
const {base} = path.parse(name);
if (base !== 'index.css') return false;
return true;
}
}
export default () => {
switch (BABEL_ENV) {
case 'umd':
return [{
input: entryFile,
output: {...umdOutput, file: 'dist/umd/rollup-ui.development.js'},
external,
plugins: [styles(stylePluginConfig), ...commonPlugins]
}, {
input: entryFile,
output: {...umdOutput, file: 'dist/umd/rollup-ui.production.min.js', plugins: [terser()]},
external,
plugins: [styles({...stylePluginConfig, minimize: true}), ...commonPlugins]
}];
case 'esm':
return {
input: [entryFile, ...componentEntryFiles],
preserveModules: true, // rollup-plugin-styles 还是需要使用
output: { ...esOutput, dir: 'dist/es', format: 'es'},
external,
plugins: [styles(esStylePluginConfig), ...commonPlugins]
};
case 'cjs':
return {
input: [entryFile, ...componentEntryFiles],
preserveModules: true, // rollup-plugin-styles 还是需要使用
output: { ...esOutput, dir: 'dist/cjs', format: 'cjs'},
external,
plugins: [styles(esStylePluginConfig), ...commonPlugins]
};
default:
return [];
}
};
{
"main": "dist/cjs/index.js", // cjs 入口
"module": "dist/es/index.js", // esm 入口
"unpkg": "dist/umd/rollup-ui.production.min.js",
"typings": "dist/cjs/index.d.ts", // ts 类型声明文件入口
"scripts": {
"build": "yarn build:esm && yarn build:cjs && yarn build:umd",
"build:esm": "rimraf dist/es && cross-env NODE_ENV=production BABEL_ENV=esm rollup -c && yarn build:typed --outDir dist/es",
"build:cjs": "rimraf dist/cjs && cross-env NODE_ENV=production BABEL_ENV=cjs rollup -c && yarn build:typed --outDir dist/cjs",
"build:umd": "rimraf dist/umd && cross-env NODE_ENV=production BABEL_ENV=umd rollup -c",
"build:typed": "tsc --declaration --emitDeclarationOnly --noEmit false",
},
"dependencies": {
"@babel/runtime": "^7.12.5"
},
"peerDependencies": {
"react": "^17.0.1",
"react-dom": "^17.0.1"
},
"sideEffects": [ // 有副作用的模块 tree-shaking 用
"dist/umd/*",
"dist/es/**/*.css",
"dist/cjs/**/*.css",
"*.less"
],
}
使用组件库时如何进行按需加载
方案一、用户使用现代打包工具 – ES Module tree-shaking 方案
用户使用现代打包工具(Webpack, Rollup),引用我们的组件库时,会查找对应
"module": "dist/es/index.js"
字段的ES模块代码,只要他的打包工具开启了 tree-shaking 功能(Webpack production 模式自动开启, Rollup 自动开启)即可实现 tree-shaking 带来的 JS 按需加载能力CSS 方面,用户需要在它们项目的入口引入
import '@mjz-test/rollup-ui/dist/es/style/index.css';
全量的样式。
方案二、用户使用非现代打包工具或者用户可以使用 ES Module 但同时也想要 css 方面的按需引入
用户需要使用 babel-plugin-import 作为按需加载的 babel 工具,其实现原理是将对组件的引用,重新指向所下载组件库目录下的某个文件,来实现“定向”的引用,顺便也可以将 css 也定向的引用
// 用户的 babel.config.js
module.exports = function () {
return {
plugins: [
[
'import', // 使用 babel-plugin-import
{
libraryName: '@mjz-test/rollup-ui',
libraryDirectory: 'dist/cjs',
camel2DashComponentName: false,
style: true,
},
'rollup-ui',
]
]
}
}