使用rollup构建企业级的类antd组件库

1,844 阅读4分钟

1. 背景

公司很多业务组需要沉淀自己的react组件库,于是准备构建一套通用的react组件库模版,然后通过脚手架的形式提供给业务组的同学使用。在打造组件库的时候,怎么组织css让我犯难了,即我该怎么输出样式,在决定样式组织方式之前,我想到了antd组件库,所以准备看下antd是怎么组织css的

由于公司的大部分antd版本还停留在4.x版本,所以本篇讲述的也是antd 4.x版本内样式组织方式,后面如果antd没带版本号,都特指的antd 4.x版本

2. antd使用方式

在研究antd样式的组织方式之前,先回忆下antd的使用方式

antd 默认支持基于 ES modules 的 tree shaking,对于 js 部分,直接引入 import { Button } from 'antd' 就会有按需加载的效果。

所以我们关注的是样式按需

2.1 全量引入样式

全量引入样式即引入antd组件库所有的样式,不论你在项目内有没有使用该组件

2.1.1 非自定义主题

项目不需要做任何主题色上的改动

import { Button } from 'antd';
import React from 'react';

const App: React.FC = () => (
  <>
    <Button type="primary">Primary Button</Button>
    <Button>Default Button</Button>
  </>
);

export default App;
// 引入所有antd样式
import 'antd/dist/antd.css';

这是最简单的方式,项目没有自定义主题的需求,又不想折腾,引入antd样式之后,项目跑起来正常,就不会在管了

2.1.2 自定义主题

项目有自定义主题色的需求

import { Button } from 'antd';
import React from 'react';

const App: React.FC = () => (
  <>
    <Button type="primary">Primary Button</Button>
    <Button>Default Button</Button>
  </>
);

export default App;

方式1: 引入所有antd样式,不过引入的是less文件,而不是css文件,然后在通过less-loader传入主题变量

import 'antd/dist/antd.less';
module.exports = {
  rules: [{
    test: /\.less$/,
    use: [{
      loader: 'style-loader',
    }, {
      loader: 'css-loader', // translates CSS into CommonJS
    }, {
      loader: 'less-loader', // compiles Less to CSS
+     options: {
+       lessOptions: { // 如果使用less-loader@5,请移除 lessOptions 这一级直接配置选项。
+         modifyVars: {
+           'primary-color': '#1DA57A'
+         },
+         javascriptEnabled: true,
+       },
+     },
    }],
    // ...other rules
  }],
  // ...other config
}

这是less本身提供的能力,可以在编译的时候提供less变量

方式2: 新建一个less文件,然后在less文件中引入所有antd less样式,在定义新的主题变量

@import '~antd/es/style/themes/default.less';
@import '~antd/dist/antd.less'; // 引入官方提供的 less 样式入口文件

// 自定义主题变量
@primary-color: #1DA57A;

这个也是less自身提供的能力,less的变量有懒加载、可覆盖、作用域的特性,所以我们可以直接在外面覆盖antd里面的样式变量,更多内容可以参考懒惰评估

上面的两种引入方式也是相对简单的方式,项目有自定义主题需求,又不想太折腾,引入antd样式之后,项目跑起来满足自定义主题就可以了

2.2 按需引入样式

antd样式全量引入太大,对追求项目性能的小伙伴肯定是不接受的,所以antd也提供了按需引入样式的方式,共有两种

  1. 手动按需引入
  2. 自动按需引入

手动按需引入

import { Button } from 'antd';
import 'antd/es/button/style/css';
// OR
import 'antd/es/button/style';


const App: React.FC = () => (
  <>
    <Button type="primary">Primary Button</Button>
    <Button>Default Button</Button>
  </>
);

export default App;

项目内如果antd组件用的少,手动写下还能接受,如果项目内antd组件用的多,每次都要这么引一次样式的话,肯定浪费时间

自动按需引入 借助babel插件自动插入样式

// 代码内不需要手动引入样式,但是需要借助babel-plugin-import插件
import { Button } from 'antd';

const App: React.FC = () => (
  <>
    <Button type="primary">Primary Button</Button>
    <Button>Default Button</Button>
  </>
);

export default App;
{
  "plugins": [["import", {
    "libraryName": "antd",
    "libraryDirectory": "es",
  	"style": true,   // or 'css'
  }]]
}

上面我们已经知道了怎么做到按需加载组件样式,那么非自定义主题场景与自定义主题场景分别要怎么配置呢?

2.2.1 非自定义主题

手动引入场景,需要引入的样式是antd/es/button/style/css文件

import { Button } from 'antd';
// 注意这里引入的一定是/style/css而不是/style
import 'antd/es/button/style/css';

const App: React.FC = () => (
  <>
    <Button type="primary">Primary Button</Button>
    <Button>Default Button</Button>
  </>
);

export default App;

自动引入则需要借助babel-plugin-import插件,且style设置成css

{
  "plugins": [["import", {
    "libraryName": "antd",
    "libraryDirectory": "es",
  	"style": 'css', // 注意这里一定要是css
  }]]
}

2.2.2 自定义主题

手动引入场景,需要引入的样式是antd/es/button/style/index.js文件

import { Button } from 'antd';
// 注意这里引入的一定是/style/css而不是/style
import 'antd/es/button/style';

const App: React.FC = () => (
  <>
    <Button type="primary">Primary Button</Button>
    <Button>Default Button</Button>
  </>
);

export default App;

自动引入则需要借助babel-plugin-import插件,且style设置成true

{
  "plugins": [["import", {
    "libraryName": "antd",
    "libraryDirectory": "es",
  	"style": true, // 注意这里true
  }]]
}

手动引入的区别就是引入样式文件不同,antd/es/button/style 相对 antd/es/button/style/css 少了一截/css,至于这两个文件有什么区别我们后面看

自动引入的区别就是"style": 'css'"style": true的区别,至于这两个文件有什么区别我们后面看

3. antd样式组织方式

上面我们已经回顾了antd的使用方式,那么为什么要这么使用?我们从antd是怎么组织代码,及最终输出的产物结构来找答案

3.1 源文件目录分析

antd代码button组件源码目录及公共样式目录如下所示,本篇的源码与产物目录选取的antd版本是4.16.13

components/button
├── LoadingIcon.tsx
├── button-group.txs
├── button.tsx  // button组件具体的逻辑,组件中没有引入样式
├── index.tsx
└── style
    ├── index.tsx  // 引入公共components/style下的样式及./index.less样式
    ├── index.less // 具体的样式

components/style // 公共样式,包含主题变量、mixin等
├── color // 处理颜色的函数
│   └── tinyColor.less
├── core // 公共样式
│   ├── index.less
│   └── motion.less
├── index.tsx  // 引入./index.less
├── index.less // 引入默认主题变量及公共样式
├── mixins
│   ├── index.less
│   └── typography.less
└── themes // 主题
    ├── default.less // 默认主题变量
    └── index.less

具体代码,以button组件为例index.tsx暴露组件

import Button from './button';

export default Button;

button.tsx则包含具体的组件逻辑,注意代码内是没有引入style下的样式

import * as React from 'react';

import Group from './button-group';

const Button = React.forwardRef<unknown, ButtonProps>(InternalButton) as CompoundedComponent;
export default Button;

style/index.less button组件的样式

@import '../../style/themes/index';
@import '../../style/mixins/index';
@import './mixin';

@btn-prefix-cls: ~'@{ant-prefix}-btn';
// Button styles
// -----------------------------
.@{btn-prefix-cls} {
  ...具体样式
}

style/index.tsx 则是引入了button的样式及style/index.less的样式(其实就是一些主题变量之类的)

import '../../style/index.less';
import './index.less';

在看下components/style/index.less到是是什么内容 style/index.tsx 就是引入index.less样式

import './index.less';

style/index.less 就是引入themes/index.less样式与core/index.less

@import './themes/index';
@import './core/index';

themes/default.less 其实就是一些自定义主题变量

@import '../color/colors';

@theme: default;

// The prefix to use on all css classes from ant.
@ant-prefix: ant;

// An override for the html selector for theme prefixes
@html-selector: html;

// -------- Colors -----------
@primary-color: @blue-6;

core/base.less 就是一些公共的样式

// Config global less under antd
[class^=~'@{ant-prefix}-'],
[class*=~' @{ant-prefix}-'] {
  // remove the clear button of a text input control in IE10+
  &::-ms-clear,
  input::-ms-clear,
  input::-ms-reveal {
    display: none;
  }
}

从源码目录我们可以看出

  • 逻辑与样式是完全分离的
  • 每个组件的样式放在组件自身目录下的style目录下,包含一个含真实样式的less文件及一个引入less文件的tsx文件
  • 公共样式放在components/style目录下,主要包含一些主题变量及mixin等

3.2 产物目录分析

antd最终输出的目录结构如下所示,只看es与dist目录,lib目录与es目录就是模块输出的格式不同

antd/es/button
├── LoadingIcon.js
├── button-group.js
├── button.js
├── index.js
└── style
    ├── css.js     // 多出来的新文件,引入components/style/index.css及./index.css
    ├── index.css  // 多出来的新文件,包含antd组件默认的主题色样式
    ├── index.js   // 保持原样输出
    ├── index.less // 保持原样输出

antd/es/style
├── color // 都是原目录原文件输出
│   └── tinyColor.less
├── core
│   ├── index.less
│   └── motion.less
├── css.js         // 引入./index.css
├── index.css      // 包含index.less引入的所有样式,即一些公共样式
├── index.js       // 保持原样输出
├── index.less     // 保持原样输出
├── mixins
│   ├── index.less
│   └── typography.less
└── themes
    ├── default.less
    └── index.less


antd/dist
├── antd.css // 通过默认主题打出来的所有css
├── antd.js  // umd格式的包含所有组件的antd
├── antd.less  // 引入../lib/style/index.less与../lib/style/components.less
├── antd.min.css
├── antd.min.js

antd/lib/style
├── components.less // 引入@import "../affix/style/index.less"所有组件的less样式

button/style/css.js

import '../../style/index.css';
import './index.css';

button/style/index.css

.ant-btn {
	...
}
.ant-btn > .anticon {
  line-height: 1;
}

components/style/css.js

import './index.css';

components/style/index.css

html,
body {
  width: 100%;
  height: 100%;
}
input::-ms-clear,
input::-ms-reveal {
  display: none;
}

components/style/components.less

@import "../affix/style/index.less";
@import "../alert/style/index.less";
...

从输出产物结构我们可以得出如下结论

  • es、lib目录是按源目录结构输出,保留了.less文件,.tsx文件变成了.js文件
  • 打包之后的样式产物中,多了一个.css文件(内容就是对应.less文件的内容),多了一个css.js文件(内容就是引入对应的.css)
  • lib/style目录下多了一个components.less文件,该文件引入了所有组件的less文件样式
  • dist目录下包含所有组件逻辑的antd.js、包含默认主题色的所有组件样式antd.css、包含所有组件less样式的antd.less

在回过头看全量引入样式

// 就是引入默认主题的所有组件样式,原因是构建的时候将所有组件样式打包成了antd.css
antd/dist/antd.css

// 就是引入默认主题的所有组件less样式,内部是引入了所有组件的less样式
antd/dist/antd.less

按需引入样式

// { "libraryName": "antd", style: true } 自动生成的样式连接是antd/lib/button/style,其实就是antd/lib/button/style/index.js
// 最终import就是组件的less样式,所以还可以通过less-loader来修改主题
import 'antd/lib/button/style';


// { "libraryName": "antd", style: 'css' } 自动生成的样式连接是antd/lib/button/style/css,其实就是antd/lib/button/style/css.js
// 最终import就是组件的css样式,所以无法less-loader再来修改主题
import 'antd/lib/button/style/css';

知道了输入输出,我们在看看,antd是借助什么工具构建的,从源码可以看出antd使用的构建工具是antd-tools,antd-tools内部采用的是webpack、gulp,而我们公司构建npm包使用的是rollup,那么可不可以借助rollup构建出现有antd的产物结构

4. 使用rollup构建组件库

4.1 逻辑梳理

根据上面的分析,我们梳理出如下关系

  • 根据原目录格式输出js代码到es、lib目录下
  • 根据原目录格式输出.less文件到es、lib目录下
  • 样式style目录下需要新增.css(源.less文件包含的样式)、css.js两个新文件
  • lib/style目录下需要新增一个components.less文件
  • dist目录下需要包含antd.js、antd.css、antd.less三个文件

关于根据原目录格式输出js代码,可以通过output.preserveModules参数解决

export default {
	output: {
    preserveModules: true, // 根据原始模块目录生成现有模块目录
  }
}

关于rollup构建的时候输出新的文件,可以通过rollup插件提供的钩子输出新的文件

function MyPlugin() {
  return {
    name: ' myPlugin',
    generateBundle(options, bundle) { // 在生成bundle之前修改最终输出的文件
      // 可以通过bundle删除某个文件
      delete bundle['index.js'];

      // 可以通过 this.emitFile 生成新的文件
      this.emitFile({
        name: 'antd.less',
        fileName: 'antd.less',
        type: 'asset',
        source: `
@import "../es/style/index.less";
@import "../es/style/components.less";
        `,
      });
    },
  };
}

关于根据原目录格式输出less代码与在style目录下生成.css、css.js文件,无法通过与js文件的处理放在一个output中,也无法放到一个input中处理,原因是现有的样式插件不支持多个样式提取,所以这里采用的是每个单独的less文件作为一个单独的input,然后根据原目录提取css文件、复制.less文件,最后在输出css.js文件

export default [{
  input: 'components/button/style/index.less',
  output: {
    format,
    entryFileNames: '[name].js',
    exports: 'named',
    preserveModules: false,
    sourcemap: true,
    dir,
  },
  plugins: [
    copy({ // copy组件下的.less文件及公共style目录下的.less文件
      copyOnce: true,
      targets: [{ src: file, dest: styleDir.replace('components', dir) }],
    }),
    postcss({ // 将.less文件转化成.css文件
      extensions: ['.less', '.css', '.sss', '.pcss'],
      extract: path.resolve(`${styleDir}/index.css`.replace('components', dir)),
    }),
    createCssAndIndexFile({
      dest: styleDir.replace('components/', ''),
      format,
      file,
      files,
    }),
  ],
}];

关于生成dist/antd.js文件,只需要设置umd模式即可

export default {
  output: {
    format: 'umd',
    entryFileNames: '[name].umd.js',
    file: 'dist/antd.js',
    name: 'antd',
    globals: {
      react: 'react',
      reactDom: 'react-dom',
    },
  }
}

关于生成dist/antd.css,需要将所有less文件作为入口文件,然后在通过postcss提取成单独的antd.css文件,另外在生成dist/antd.css的同时生成antd.less

export default {
  input: [
    'components/button/style/index.less',
    'components/style/index.less',
  ],
  output: {
    format: 'esm',
    entryFileNames: '[name].js',
    exports: 'named',
    dir: 'dist',
  },
  plugins: [
    postcss({ // 提取css文件
      extensions: ['.less', '.css', '.sss', '.pcss'],
      extract: 'antd.css',
    }),
    createComponentsLessFile(),
  ],
}

最终构建出来的产物目录如下所示

dist
├── antd.css
├── antd.js
└── antd.less

es
├── _util
│   ├── index.js
│   ├── isNumeric.js
├── avatar
│   ├── avatar.js
│   ├── index.js
│   └── style
│       ├── css.js
│       ├── index.css
│       ├── index.js
│       └── index.less
├── button
│   ├── button.js
│   ├── index.d.ts
│   ├── index.js
│   └── style
│       ├── css.js
│       ├── index.css
│       ├── index.js
│       └── index.less
├── index.d.ts
├── index.js
└── style
    ├── components.less
    ├── core
    │   └── index.less
    ├── css.js
    ├── index.css
    ├── index.js
    ├── index.less
    ├── mixins
    │   ├── index.less
    │   └── size.less
    └── themes
        ├── default.less
        └── index.less


4.2 最终的rollup配置

const resolve = require('@rollup/plugin-node-resolve');
const json = require('@rollup/plugin-json');
const commonjs = require('@rollup/plugin-commonjs');
const typescript = require('@rollup/plugin-typescript');
const babel = require('@rollup/plugin-babel');
const copy = require('rollup-plugin-copy');
const postcss = require('rollup-plugin-postcss');
const path = require('path');
const glob = require('glob');

const { ROLLUP_WATCH } = process.env;

function createCssAndIndexFile({ dest, format, file, files }) {
  return {
    name: 'createCssAndIndexFile',
    generateBundle(options, bundle) {
      console.log('dest', dest);
      this.emitFile({
        name: 'css.js',
        fileName: `${dest}/css.js`,
        type: 'asset',
        source:
          format === 'esm'
            ? `
import '../../style/index.css';
import './index.css';
        `
            : `
require('../../style/index.css');
require('./index.css');
`,
      });

      this.emitFile({
        name: 'index.js',
        fileName: `${dest}/index.js`,
        type: 'asset',
        source:
          format === 'esm'
            ? `
import '../../style/index.less';
import './index.less';
        `
            : `
require('../../style/index.less');
require('./index.less');
`,
      });

      if (file.includes('components/style/index.less') && format === 'esm') {
        const code = files
          .filter((item) => item !== file)
          .map((item) => {
            return `@import "${item.replace('components', '..')}"`;
          })
          .join(',')
          .replace(',', ';\n');
        this.emitFile({
          name: 'components.less',
          fileName: `${dest}/components.less`,
          type: 'asset',
          source: `${code};`,
        });
      }

      delete bundle['index.js'];
      delete bundle['index.js.map'];
    },
  };
}

function createComponentsLessFile() {
  return {
    name: 'createCssAndIndexFile',
    generateBundle(options, bundle) {
      Object.keys(bundle).forEach((filename) => {
        if (!filename.includes('antd')) {
          delete bundle[filename];
        }
      });
      this.emitFile({
        name: 'antd.less',
        fileName: 'antd.less',
        type: 'asset',
        source: `
@import "../es/style/index.less";
@import "../es/style/components.less";
        `,
      });
    },
  };
}

const dirMap = {
  esm: 'es',
  cjs: 'lib',
  umd: 'dist',
};

const _createStyleConfig = (file, format, files) => {
  const dir = dirMap[format];
  const styleDir = path.dirname(file);
  const isStyleIndex = file.includes('components/style/index.less');
  return {
    input: file,
    output: {
      format,
      entryFileNames: '[name].js',
      exports: 'named',
      preserveModules: false,
      sourcemap: true,
      dir,
    },
    plugins: [
      copy({ // 将源目录下的less文件,原样copy一份到lib、es目录下
        copyOnce: true,
        targets: isStyleIndex
          ? [
              { src: file, dest: styleDir.replace('components', dir) },
              { src: 'components/style/themes/*.less', dest: `${styleDir.replace('components', dir)}/themes` },
              { src: 'components/style/mixins/*.less', dest: `${styleDir.replace('components', dir)}/mixins` },
              { src: 'components/style/core/*.less', dest: `${styleDir.replace('components', dir)}/core` },
            ]
          : [{ src: file, dest: styleDir.replace('components', dir) }],
      }),
      postcss({ // 将源目录下的.less文件转化成.css文件输出
        extensions: ['.less', '.css', '.sss', '.pcss'],
        extract: path.resolve(`${styleDir}/index.css`.replace('components', dir)),
      }),
      createCssAndIndexFile({ // 创建css.js、index.js、components.less文件
        dest: styleDir.replace('components/', ''),
        format,
        file,
        files,
      }),
    ],
  };
};

// 找到所有需要处理的入口less文件,其实就是每个组件下的less文件及公共style下的入口less文件
const files = glob.sync('components/**/*.less', {
  ignore: ['**/themes/*.less', '**/mixins/*.less', '**/core/*.less'],
});

// 创建es、lib目录下的所有style下的.less、.css、.js文件
const createStyleConfig = () => {
  console.log('files', files);

  return files.reduce((prev, file) => {
    const result = (ROLLUP_WATCH ? ['esm'] : ['esm', 'cjs']).map((format) => {
      return _createStyleConfig(file, format, files);
    });
    return [...prev, ...result];
  }, []);
};

// 负责创建es、lib下的所有js文件及创建dist下的antd.js文件
function createJsConfig() {
  return (ROLLUP_WATCH ? ['esm'] : ['esm', 'cjs', 'umd']).map((format) => {
    return {
      input: ['components/index.ts'],
      treeshake: false,
      output:
        format === 'umd'
          ? {
              format,
              entryFileNames: '[name].umd.js',
              preserveModules: false,
              sourcemap: true,
              file: 'dist/antd.js',
              name: 'antd',
              globals: {
                react: 'react',
                reactDom: 'react-dom',
              },
            }
          : {
              format,
              entryFileNames: '[name].js',
              exports: 'named',
              preserveModules: true,
              sourcemap: true,
              dir: dirMap[format],
            },
      plugins: [
        resolve({
          browser: true,
        }),
        json({}),
        commonjs({
          transformMixedEsModules: true,
        }),
        typescript({
          declaration: format !== 'umd',
          declarationDir: format !== 'umd' ? dirMap[format] : null,
          noEmitOnError: false,
        }),
        babel({
          babelHelpers: 'runtime',
          exclude: [/node_modules/],
          extensions: ['.ts', '.tsx', '.js', '.jsx', '.es6', '.es', '.mjs'],
        }),
      ],
      external: ['react'],
    };
  });
}

const styleConfigs = createStyleConfig();
const jsConfigs = createJsConfig();

// 创建dist/antd.css及dist/antd.less
const umdCss = {
  input: files,
  output: {
    format: 'esm',
    entryFileNames: '[name].js',
    exports: 'named',
    dir: 'dist',
  },
  plugins: [
    postcss({
      extensions: ['.less', '.css', '.sss', '.pcss'],
      extract: 'antd.css',
    }),
    createComponentsLessFile(),
  ],
};

module.exports = [...jsConfigs, ...styleConfigs, ROLLUP_WATCH ? null : umdCss].filter((item) => item);

上面其实只是一个思路,还有很多种的配置及实现方式,但是关键还是在于样式文件的处理,可以自己写一个样式插件,这样就不会将样式文件的处理分割成不同的input处理

image.png react-components-style

总结

本篇主要介绍了antd样式的使用方式,然后从antd代码组织的方式分析了为什么这么用,以及样式按需的原理;另外介绍了如何通过rollup构建出antd这样的组件库;我自己梳理完之后,对于在公司的组件库中怎么组织css代码有了进一步的理解,希望大家看完之后也有所帮助。