Rollup2.x 搭建React 组件库

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”,

实现目标

  • 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',
            ]
        ]
    }
}
  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值