天天看點

Rollup2.x 搭建React 元件庫Rollup2.x 搭建React 元件庫

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 版本相比使用上有何不同

  1. Rollup 2.x 開始,其官方插件已經改用 scope package 的命名,例如 @rollup/plugin-eslint,所有官方插件都可以在 Rollup Plugins 上獲得
  1. 與舊版本相比較,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}
  }
}
           
  1. 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',
            ]
        ]
    }
}