Rollup2.x 搭建React 组件库

说明

重要依赖版本

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

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