-
Notifications
You must be signed in to change notification settings - Fork 18
Description
前言
宿主环境各不相同,需要将源码进行相关处理后发布至 npm。
明确以下目标:
- 导出类型声明文件;
- 导出
UMD
/Commonjs module
/ES module
等 3 种形式产物供使用者引入; - 支持样式文件
css
引入,而非只有less
,减少业务方接入成本; - 支持按需加载。
然后,向目标前进!
导出类型声明文件
既然是使用typescript
编写的组件库,那么使用者应当享受到类型系统的好处。
我们可以生成类型声明文件,并在package.json
中定义入口,如下:
package.json
{
"typings": "lib/index.d.ts", // 定义类型入口文件
"scripts": {
"build:types": "tsc -p tsconfig.build.json && cpr lib esm" // 执行tsc命令生成类型声明文件
}
}
值得注意的是:此处使用
cpr
(需要手动安装)将lib
的声明文件拷贝了一份,并将文件夹重命名为esm
,用于后面存放 ES module 形式的组件。这样做的原因是保证用户手动按需引入组件时依旧可以获取自动提示。
最开始的方式是将声明文件单独存放在
types
文件夹,但这样只有通过'happy-ui'引入才可以获取提示,而'happy-ui/esm/xxx'和'happy-ui/lib/xxx'就无法获取提示。
tsconfig.build.json
{
"extends": "./tsconfig.json",
"compilerOptions": { "emitDeclarationOnly": true }, // 只生成声明文件
"exclude": ["**/__tests__/**", "**/demo/**", "node_modules", "lib", "esm"] // 排除示例、测试以及打包好的文件夹
}
执行yarn build:types
,可以发现根目录下已经生成了lib
文件夹(tsconfig.json
中定义的declarationDir
字段)以及esm
文件夹(拷贝而来),目录结构与src
文件夹保持一致,如下:
lib
├── alert
│ ├── index.d.ts
│ └── style
│ └── index.d.ts
└── index.d.ts
这样使用者引入npm
包时,便能得到自动提示,也能够复用相关组件的类型定义。
接下来将ts(x)
等文件处理成js
文件。
需要注意的是,我们需要输出
Commonjs module
以及ES module
两种模块类型的文件(暂不考虑UMD
),以下使用cjs
指代Commonjs module
,esm
指代ES module
。对此有疑问的同学推荐阅读:import、require、export、module.exports 混合详解
导出 Commonjs 模块
其实完全可以使用babel
或tsc
命令行工具进行代码编译处理(实际上很多工具库就是这样做的),此处借助 gulp
来串起这个流程。
为什么是
gulp
而不是webpack
或rollup
?因为我们要做的是代码编译而非代码打包,同时需要考虑到样式处理及其按需加载。
babel 配置
首先安装babel
及其相关依赖
yarn add @babel/core @babel/preset-env @babel/preset-react @babel/preset-typescript @babel/plugin-proposal-class-properties @babel/plugin-transform-runtime --dev
yarn add @babel/runtime-corejs3
新建.babelrc.js
文件,写入以下内容:
.babelrc.js
module.exports = {
presets: ['@babel/env', '@babel/typescript', '@babel/react'],
plugins: [
'@babel/proposal-class-properties',
[
'@babel/plugin-transform-runtime',
{
corejs: 3,
helpers: true,
},
],
],
};
关于@babel/plugin-transform-runtime
与@babel/runtime-corejs3
:
- 若
helpers
选项设置为true
,可抽离代码编译过程重复生成的helper
函数(classCallCheck
,extends
等),减小生成的代码体积; - 若
corejs
设置为3
,可引入不污染全局的按需polyfill
,常用于类库编写(我更推荐:不引入polyfill
,转而告知使用者需要引入何种polyfill
,避免重复引入或产生冲突,后面会详细提到)。
更多参见官方文档-@babel/plugin-transform-runtime
配置目标环境
为了避免转译浏览器原生支持的语法,新建.browserslistrc
文件,根据适配需求,写入支持浏览器范围,作用于@babel/preset-env
。
.browserslistrc
>0.2%
not dead
not op_mini all
很遗憾的是,@babel/runtime-corejs3
无法在按需引入的基础上根据目标浏览器支持程度再次减少polyfill
的引入,参见@babel/runtime for target environment 。
这意味着@babel/runtime-corejs3
甚至会在针对现代引擎的情况下注入所有可能的 polyfill
:不必要地增加了最终捆绑包的大小。
对于组件库(代码量可能很大),个人建议将polyfill
的选择权交还给使用者,在宿主环境进行polyfill
。若使用者具有兼容性要求,自然会使用@babel/preset-env + core-js + .browserslistrc
进行全局polyfill
,这套组合拳引入了最低目标浏览器不支持API
的全部 polyfill
。
顺带一提,业务开发中,若将
@babel/preset-env
的useBuiltIns
选项值设置为usage
,同时把node_modules
从babel-loader
中exclude
,会导致babel
无法检测到nodes_modules
中所需要的polyfill
。"useBuiltIns: usage" for node_modules without transpiling #9419,在未支持该issue
提到的内容之前,请将useBuiltIns
设置为entry
,或者不要把node_modules
从babel-loader
中exclude
。
所以组件库不用画蛇添足,引入多余的polyfill
,写好文档说明,比什么都重要(就像zent和antd这样)。
现在@babel/runtime-corejs3
更换为@babel/runtime
,只进行helper
函数抽离。
yarn remove @babel/runtime-corejs3
yarn add @babel/runtime
.babelrc.js
module.exports = {
presets: ['@babel/env', '@babel/typescript', '@babel/react'],
plugins: ['@babel/plugin-transform-runtime', '@babel/proposal-class-properties'],
};
@babel/transform-runtime
的helper
选项默认为true
。
gulp 配置
再来安装gulp
相关依赖
yarn add gulp gulp-babel --dev
新建gulpfile.js
,写入以下内容:
gulpfile.js
const gulp = require('gulp');
const babel = require('gulp-babel');
const paths = {
dest: {
lib: 'lib', // commonjs 文件存放的目录名 - 本块关注
esm: 'esm', // ES module 文件存放的目录名 - 暂时不关心
dist: 'dist', // umd文件存放的目录名 - 暂时不关心
},
styles: 'src/**/*.less', // 样式文件路径 - 暂时不关心
scripts: ['src/**/*.{ts,tsx}', '!src/**/demo/*.{ts,tsx}'], // 脚本文件路径
};
function compileCJS() {
const { dest, scripts } = paths;
return gulp
.src(scripts)
.pipe(babel()) // 使用gulp-babel处理
.pipe(gulp.dest(dest.lib));
}
// 并行任务 后续加入样式处理 可以并行处理
const build = gulp.parallel(compileCJS);
exports.build = build;
exports.default = build;
修改package.json
package.json
{
- "main": "index.js",
+ "main": "lib/index.js",
"scripts": {
...
+ "clean": "rimraf lib esm dist",
+ "build": "npm run clean && npm run build:types && gulp",
...
},
}
执行yarn build
,得到如下内容:
lib
├── alert
│ ├── index.js
│ └── style
│ └── index.js
└── index.js
观察编译后的源码,可以发现:诸多helper
方法已被抽离至@babel/runtime
中,模块导入导出形式也是commonjs
规范。
lib/alert/alert.js
导出 ES module
生成ES module
可以更好地进行tree shaking,基于上一步的babel
配置,更新以下内容:
- 配置
@babel/preset-env
的modules
选项为false
,关闭模块转换; - 配置
@babel/plugin-transform-runtime
的useESModules
选项为true
,使用ES module
形式引入helper
函数。
.babelrc.js
module.exports = {
presets: [
[
'@babel/env',
{
modules: false, // 关闭模块转换
},
],
'@babel/typescript',
'@babel/react',
],
plugins: [
'@babel/proposal-class-properties',
[
'@babel/plugin-transform-runtime',
{
useESModules: true, // 使用esm形式的helper
},
],
],
};
目标达成,我们再使用环境变量区分esm
和cjs
(执行任务时设置对应的环境变量即可),最终babel
配置如下:
.babelrc.js
module.exports = {
presets: ['@babel/env', '@babel/typescript', '@babel/react'],
plugins: ['@babel/plugin-transform-runtime', '@babel/proposal-class-properties'],
env: {
esm: {
presets: [
[
'@babel/env',
{
modules: false,
},
],
],
plugins: [
[
'@babel/plugin-transform-runtime',
{
useESModules: true,
},
],
],
},
},
};
接下来修改gulp
相关配置,抽离compileScripts
任务,增加compileESM
任务。
gulpfile.js
// ...
/**
* 编译脚本文件
* @param {string} babelEnv babel环境变量
* @param {string} destDir 目标目录
*/
function compileScripts(babelEnv, destDir) {
const { scripts } = paths;
// 设置环境变量
process.env.BABEL_ENV = babelEnv;
return gulp
.src(scripts)
.pipe(babel()) // 使用gulp-babel处理
.pipe(gulp.dest(destDir));
}
/**
* 编译cjs
*/
function compileCJS() {
const { dest } = paths;
return compileScripts('cjs', dest.lib);
}
/**
* 编译esm
*/
function compileESM() {
const { dest } = paths;
return compileScripts('esm', dest.esm);
}
// 串行执行编译脚本任务(cjs,esm) 避免环境变量影响
const buildScripts = gulp.series(compileCJS, compileESM);
// 整体并行执行任务
const build = gulp.parallel(buildScripts);
// ...
执行yarn build
,可以发现生成了lib
/esm
两个文件夹,观察esm
目录,结构同lib
一致,js 文件都是以ES module
模块形式导入导出。
esm/alert/alert.js
别忘了给package.json
增加相关入口。
package.json
{
+ "module": "esm/index.js"
}
处理样式文件
拷贝 less 文件
我们会将less
文件包含在npm
包中,用户可以通过happy-ui/lib/alert/style/index.js
的形式按需引入less
文件,此处可以直接将 less 文件拷贝至目标文件夹。
在gulpfile.js
中新建copyLess
任务。
gulpfile.js
// ...
/**
* 拷贝less文件
*/
function copyLess() {
return gulp.src(paths.styles).pipe(gulp.dest(paths.dest.lib)).pipe(gulp.dest(paths.dest.esm));
}
const build = gulp.parallel(buildScripts, copyLess);
// ...
观察lib
目录,可以发现 less
文件已被拷贝至alert/style
目录下。
lib
├── alert
│ ├── alert.js
│ ├── index.js
│ ├── interface.js
│ └── style
│ ├── index.js
│ └── index.less # less文件
└── index.js
可能有些同学已经发现问题:若使用者没有使用less
预处理器,使用的是sass
方案甚至原生css
方案,那现有方案就搞不定了。经分析,有以下 4 种预选方案:
- 告知业务方增加
less-loader
。会导致业务方使用成本增加; - 打包出一份完整的
css
文件,进行全量引入。无法进行按需引入; css in js
方案;- 提供一份
style/css.js
文件,引入组件css
样式依赖,而非less
依赖,组件库底层抹平差异。
重点看一看方案 3 以及方案 4。
css in js
除了赋予样式编写更多的可能性之外,在编写第三方组件库时更是利器。
如果我们写一个react-use
这种hooks
工具库,不涉及到样式,只需要在package.json
中设置sideEffects
为false
,业务方使用 webpack 进行打包时,只会打包被使用到的 hooks(优先使用 ES module)。
入口文件index.js
中导出的但未被使用的其他 hooks 会被tree shaking
,第一次使用这个库的时候我很好奇,为什么没有按需引入的使用方式,后来进行打包分析,发现人家天生支持按需引入。
回到正题。如果将样式使用javascript
来编写,在某种维度上讲,组件库和工具库一致了,配好sideEffects
,自动按需引入。
而且每个组件都与自己的样式绑定,不需要业务方或组件开发者去维护样式依赖,什么是样式依赖,后面会讲到。
缺点:
- 样式无法单独缓存;
- styled-components 自身体积较大;
- 复写组件样式需要使用属性选择器或者使用
styled-components
自带方法。
需要看取舍了,偷偷说一句styled-components
做主题定制也极其方便。
方案 4 是antd
使用的这种方案。
在搭建组件库的过程中,有一个问题困扰了我很久:为什么需要alert/style/index.js
引入less
文件或alert/style/css.js
引入css
文件?
答案是管理样式依赖。
因为我们的组件是没有引入样式文件的,需要使用者去手动引入。
假设存在以下场景:使用者引入<Button />
,<Button />
依赖了<Icon />
,则需要手动去引入调用组件的样式(<Button />
)及其依赖的组件样式(<Icon />
),遇到复杂组件极其麻烦,所以组件库开发者可以提供一份这样的js
文件,使用者手动引入这个js
文件,就能引入对应组件及其依赖组件的样式。
那么问题又来了,为什么组件不能自己去import './index.less'
呢?
当然可以,但业务方需要配置less-loader
。
业务方不想配置 less-loader
?那我们import './index.css'
开发体验岂不是直线下降?
所以需要一个两全其美的方案:
- 保障组件库开发者的开发体验 DX;
- 减轻业务方的使用成本。
答案就是css in js单独提供一份style/css.js
文件,引入的是组件 css
样式文件依赖,而非 less
依赖,组件库底层抹平差异。
之前了解到 father 可以在打包的时候将index.less
转成index.css
,这倒是个好法子,但是一些重复引入的样式模块(比如动画样式),会被重复打包,不知道有没有好的解决方案。
生成 css 文件
安装相关依赖。
yarn add gulp-less gulp-autoprefixer gulp-cssnano --dev
将less
文件生成对应的css
文件,在gulpfile.js
中增加less2css
任务。
// ...
/**
* 生成css文件
*/
function less2css() {
return gulp
.src(paths.styles)
.pipe(less()) // 处理less文件
.pipe(autoprefixer()) // 根据browserslistrc增加前缀
.pipe(cssnano({ zindex: false, reduceIdents: false })) // 压缩
.pipe(gulp.dest(paths.dest.lib))
.pipe(gulp.dest(paths.dest.esm));
}
const build = gulp.parallel(buildScripts, copyLess, less2css);
// ...
执行yarn build
,组件style
目录下已经存在css
文件了。
接下来我们需要一个alert/style/css.js
来帮用户引入css
文件。
生成 css.js
此处参考antd-tools的实现方式:在处理scripts
任务中,截住style/index.js
,生成style/css.js
,并通过正则将引入的less
文件后缀改成css
。
安装相关依赖。
yarn add through2 --dev
gulpfile.js
// ...
/**
* 编译脚本文件
* @param {*} babelEnv babel环境变量
* @param {*} destDir 目标目录
*/
function compileScripts(babelEnv, destDir) {
const { scripts } = paths;
process.env.BABEL_ENV = babelEnv;
return gulp
.src(scripts)
.pipe(babel()) // 使用gulp-babel处理
.pipe(
through2.obj(function z(file, encoding, next) {
this.push(file.clone());
// 找到目标
if (file.path.match(/(\/|\\)style(\/|\\)index\.js/)) {
const content = file.contents.toString(encoding);
file.contents = Buffer.from(cssInjection(content)); // 文件内容处理
file.path = file.path.replace(/index\.js/, 'css.js'); // 文件重命名
this.push(file); // 新增该文件
next();
} else {
next();
}
}),
)
.pipe(gulp.dest(destDir));
}
// ...
cssInjection
的实现:
gulpfile.js
/**
* 当前组件样式 import './index.less' => import './index.css'
* 依赖的其他组件样式 import '../test-comp/style' => import '../test-comp/style/css.js'
* 依赖的其他组件样式 import '../test-comp/style/index.js' => import '../test-comp/style/css.js'
* @param {string} content
*/
function cssInjection(content) {
return content
.replace(/\/style\/?'/g, "/style/css'")
.replace(/\/style\/?"/g, '/style/css"')
.replace(/\.less/g, '.css');
}
再进行打包,可以看见组件style
目录下生成了css.js
文件,引入的也是上一步less
转换而来的css
文件。
lib/alert
├── alert.js
├── index.js
├── interface.js
└── style
├── css.js # 引入index.css
├── index.css
├── index.js
└── index.less
按需加载
在 package.json 中增加sideEffects
属性,配合ES module
达到tree shaking
效果(将样式依赖文件标注为side effects
,避免被误删除)。
// ...
"sideEffects": [
"dist/*",
"esm/**/style/*",
"lib/**/style/*",
"*.less"
],
// ...
使用以下方式引入,可以做到js
部分的按需加载,但需要手动引入样式:
import { Alert } from 'happy-ui';
import 'happy-ui/esm/alert/style';
也可以使用以下方式引入:
import Alert from 'happy-ui/esm/alert'; // or import Alert from 'happy-ui/lib/alert';
import 'happy-ui/esm/alert/style'; // or import Alert from 'happy-ui/lib/alert';
以上引入样式文件的方式不太优雅,直接入口处引入全量样式文件又和按需加载的本意相去甚远。
使用者可以借助 babel-plugin-import 来进行辅助,减少代码编写量(还是增加了使用成本)。
import { Alert } from 'happy-ui';
⬇️
import Alert from 'happy-ui/lib/alert';
import 'happy-ui/lib/alert/style';
最重要的构建流程到此结束,可以发现 sideEffects 字段对于非 CSS in JS
组件库用处并不大,还是依赖 babel 插件达到完整的按需引入效果。