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也提供了按需引入样式的方式,共有两种
- 手动按需引入
- 自动按需引入
手动按需引入
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处理
总结
本篇主要介绍了antd样式的使用方式,然后从antd代码组织的方式分析了为什么这么用,以及样式按需的原理;另外介绍了如何通过rollup构建出antd这样的组件库;我自己梳理完之后,对于在公司的组件库中怎么组织css代码有了进一步的理解,希望大家看完之后也有所帮助。