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”,
實作目标
- Tree-shaking 支援
- Code-splitting 代碼分割實作(元件級别的分割)
- 對外輸出子產品類型 esm、umd、commonjs
- 公共依賴不打包僅元件中(external 掉),使用 peerDependencies 讓使用方決定使用版本
- 打包後 按照元件拆分
- 樣式檔案抽離(css in js 除外)同樣按照元件拆分 (可拆分,但拆分後不能自動引入js 子產品,esm 需要全局引入樣式檔案,cjs 可借助 babel-plugin-import 順便引用)
- 不支援 Tree-shaking 的環境可使用 babel-plugin-import 實作元件的按需引入
- 靜态資源例如圖檔 字型檔案正确引入(僅能内聯引入,即打包進元件或者樣式中,不可拆分,否則會有引用路徑問題)
- test 支援
- 輸出 Typescript 類型聲明
- eslint lint-stage husky prettier 內建
與 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
來實作樣式抽離,但是這個插件隻會把所有元件的樣式抽離抽離出一個單獨的檔案。
後來調研了
這個插件,他可以與 preserveModules 做适配,即輸出按照元件次元拆分的 css 檔案,不過要想正确的實作這個功能需要我們做一些配置
rollup-plugin-styles
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',
]
]
}
}