之前写过一篇文章关于如何编写前端组件库模块打包工具的,至今已经差不多一年半了。这两天没啥事情做,便对这个工具做了一个优化和升级。对比之前的内容,具体优化如下:
- 1、重写清空输出目录的任务;
- 2、es 模块构建,体积小于
assetInlineLimit
的资源打包成内联 Base64 的格式,反之输出 url; - 3、在构建后的 es 模块基础上重新构建 cjs;
- 4、重写 css 以及相关资源的构建;
这边文章将根据上述列表来介绍,读者可以对照之前的文章进行阅读。我会在文章的最后,贴出所有的源码。组件库官网。
清空输出目录(重写)
js
import gulp from 'gulp';
import clean from 'gulp-clean';
function cleanOutDir() {
//
return gulp.src(
[
path.resolve(context, 'lib'),
path.resolve(context, 'es')
],
{ read: false, allowEmpty: true }
).pipe(clean({ force: true }));
}
gulp.src()
表示读取指定的文件目录内容;- 可选项
read: false
表示不读取文件或目录内容,这样删除文件时效率更高; - 可选项
allowEmpty: true
表示指定的目录可以为空,当指定的文件不存在时不会报错; clean({ force: true })
表示强制删除。
es 模块构建(优化被 JS 脚本引入的静态资源)
js
import path from 'path';
import chalk from 'chalk';
import { rollup } from 'rollup';
import { fileURLToPath } from 'url';
import url from '@rollup/plugin-url';
import alias from '@rollup/plugin-alias';
import babel from '@rollup/plugin-babel';
// import image from '@rollup/plugin-image';
import commonjs from '@rollup/plugin-commonjs';
import typescript from '@rollup/plugin-typescript';
import nodeResolve from '@rollup/plugin-node-resolve';
import replaceLessToCss from './rollup-plugin-less2css.js';
const context = fileURLToPath(new URL('../', import.meta.url));
const extensions = [ '.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs' ];
const assetInlineLimit = 10 * 1024;
const inputOptions = {
input: path.resolve(context, 'src/lib/index.ts'),
external: [ /[\\/]node_modules[\\/]/, /\.less/, /\.css/ ],
makeAbsoluteExternalsRelative: false,
plugins: [
nodeResolve(),
commonjs(),
alias({ entries: { '@': path.resolve(context, 'src') } }),
typescript(),
babel({
extensions,
babelHelpers: 'runtime',
exclude:/[\\/]node_modules[\\/]/,
}),
// image()
url({
limit: assetInlineLimit,
fileName: '[dirname][name][extname]',
sourceDir: path.resolve(context, 'src/lib'),
}),
// 将 JSX、TSX 文件中引入的 less 全部替换成 css。
// 回顾一下之前的文章,那有源码。
replaceLessToCss(),
]
};
const outputOptions = {
// 只构建 es 模块,
format: 'es',
preserveModules: true,
preserveModulesRoot: 'src/lib',
dir: path.resolve(context, 'es'),
};
export default async function build () {
let bundle = null;
try {
bundle = await rollup(inputOptions);
await bundle.write(outputOptions);
} catch (error) {
const msg = error.stack.replace(/^\b/mg, ' ');
process.stdout.write('\n');
process.stdout.write(chalk.red(msg));
process.stdout.write('\n');
throw error;
}
await bundle.close();
process.stdout.write(chalk.green('rollup 打包成功\n'));
}
这里我直接贴源码了,优化的部分在代码 32-35
行,通过 @rollup/plugin-url
插件来自动提取 JS 脚本中静态资源的打包,这个插件和 url-loader
非常类似,这里就不再详细介绍了。其他地方没有变动。
这里还要在介绍一下 preserveModules
和 preserveModulesRoot
。在输出配置选项中,这两配置选项用来保证文件被打包后是否仍然保持原有的目录结构。读者可以亲自试一试。
另外,在输入配置选项中作者将 externals
配置成 [/[\\/]node_modules[\\/]/, /\.less/, /\.css/]
,这样 Rollup 就会将 node_modules
以及 Less、Css 都当做外部资源,不会执行编译打包。同时还需要配置 makeAbsoluteExternalsRelative: false
,这是因为对于 Less、Css 文件被当做外部依赖打包输出后路径会发生错乱,使用 false
表示打包后使用相对路径来引入,此时就不会出现这种问题。对于 node_modules
中的资源则没有这种情况。
注意,作者使用的是 Rollup API 的方式编写了一个构建函数,这个函数作者会将其封装在一个 gulp 任务中执行:
js
import gulp from 'gulp';
import buildRollup from './config/rollup.config.lib.js';
// 打包构建 ESM 模块,这里使用的 rollup 工具进行构建
async function buildEs() {
await buildRollup();
}
在构建后的 es 模块基础上重新构建 cjs(重写)
js
import gulp from 'gulp';
import babel from 'gulp-babel';
import through from 'through2';
import filter from 'gulp-filter';
import { fileURLToPath } from 'url';
const context = fileURLToPath(new URL('./', import.meta.url));
// 打包构建 commonjs 模块
function buildLib () {
// 过滤掉不匹配的文件,restore 表示存储那些被过滤的文件。
// 使用 .pipe(filterJS.restore) 将被过滤的文件添加到流中。
const filterJS = filter((file) => file.extname === '.js', { restore: true });
// 读取 es/ 目录下的所有内容
return gulp.src('es/**/*')
.pipe(filterJS)
.pipe(through.obj(
function(chunk, _, callback) {
if (/\.(png|jpg|jpeg|gif|webp|bmp|svg)\.js$/.test(chunk.path)) return callback(null, chunk);
let contents = chunk.contents.toString();
const match = /import {(.+)} from (['"])@ant-design\/icons\2/.exec(contents);
if (match) {
const result = [];
const values = match[1];
const matches = values.matchAll(/\b([^\s,]+)\b/g);
for (let item of matches) result.push(item[1]);
let context = '';
result.forEach(item => context += `import ${item} from '@ant-design/icons/${item}';`);
contents = contents.replace(/import .* from (['"])@ant-design\/icons\1;/, context);
chunk.contents = Buffer.from(contents);
}
callback(null, chunk);
}
))
.pipe(babel({ configFile: './babel.config.lib.cjs' }))
.pipe(filterJS.restore)
.pipe(gulp.dest('./lib'));
}
构建思路:
- 首先就是读取已经构建完成的
es/
目录下的所有文件; - 通过
gulp-filter
把 JS 文件过滤出来,设置restore
可以将被过滤的文件临时存储起来,通过.restore
的方式可以读取这些文件; - 在构建 es 模块时,如果资源被打包成内联 Base64 的形式,它会以
[ext].js
的形式输出到文件目录中,所以我们还应该过滤掉/\.(png|jpg|jpeg|gif|webp|bmp|svg)\.js$/
这部分文件; - cjs 中不支持按需引入,例如
import { DownOutlined } from '@ant-design/icons'
装换成 cjs 后require("@ant-design/icons").DownOutlined
。这里理解它会将所有的 icons 全部加载进来。作者这里只想按需引入,所以这个地方我们要做一下拆分的。注意babel-plugin-import
无法处理@ant-design/icons
; - 引入 babel,以及指定
babel.config。cjs
- 最后,我们引入在最开始被过滤掉的文件,全部输出到
lib/
目录下。
重写 css 以及相关资源的构建(重写)
在构建 CSS 时,开发者需要注意以下几点:
- 体积小于 assetInlineLimit 应该使用 Base64,反之要输出的文件目录;
- url(...) 引入的静态资源应该考虑是否使用了路径别名,具体引入方式如下:
- 使用路径别名;
- 使用相对路径
../
; - 使用相对路径
./
; - 使用相对路径
aaa.png
;
根据第二点,我们应该提前定义一个正则表达式,用来匹配 CSS 中通过 url(...)
引入的资源:
js
const assetPattern = /url\(['"]?((?:\.{1,2}|@|[a-zA-Z]*)?(?:\/[^\s\)]*)*\.(?:png|jpg|jpeg|gif|webp|bmp|svg|ttf|woff|woff2|eot)(?:\?[^\s\'")]*)?)['"]?\)/g;
// 匹配路径别名 true
assetPattern.test('background: url(@/lib/assets/images/default.svg)');
// 相对路径,不带引号 true
assetPattern.test('background: url(iconfont.woff?t=1713144147948)');
// 相对路径,带引号 true
assetPattern.test('background: url("iconfont.woff2?t=1713144147948")');
// 相对路径(../),带引号 true
assetPattern.test('background: url("../assets/images/default.svg")')
构建思路:
- 先读取目录下的所有 less 文件,使用
gulp-less
插件处理转成 css ; - 再读取目录下的所有 css 文件;
- 定义一个正则表示
assetPattern
,匹配 CSS 文件中的 url 资源,然后分析资源的 size 是否大于assetInlineLimit
,将大于assetInlineLimit
的资源全部收集起来。考虑到资源的重复使用,使用 Set 数据结构来存储。 - 对于小于
assetInlineLimit
的资源暂时不做处理,最后全部交给gulp-css-base64
插件,转成 base64;
定义一个 CSSAssetsProcessor 类,用来收集大于 assetInlineLimit
的资源
js
class CssAssetsProcessor {
constructor(assetInlineLimit = 10 * 1024) {
// 内联文件的大小,超过的
this.assetInlineLimit = assetInlineLimit;
// 使用 Set 数据结构,避免重复的资源。
this.assets = new Set();
}
handle(url, base) {
try {
// 使用 URL 的目的就是将 url 后的 ?=xxxx 去掉。
const { pathname, protocol } = new URL(path.resolve(base, url));
const stat = fs.statSync(protocol + pathname);
// 判断文件的大小
if (stat.size > this.assetInlineLimit) this.assets.add(protocol + pathname);
} catch (err) {
console.log(err);
}
}
// 最后将收集到的资源全部输出的目标目录中
outputCssAssets = () => {
return gulp.src([...this.assets], { base: 'src/lib' })
.pipe(gulp.dest('es'))
.pipe(gulp.dest('lib'));
}
}
CSS 构建完整的流程:
js
// 生成样式、以及相关的资源
function buildStyleSteet() {
const assetPattern = /url\(['"]?((?:\.{1,2}|@|[a-zA-Z]*)?(?:\/[^\s\)]*)*\.(?:png|jpg|jpeg|gif|webp|bmp|svg|ttf|woff|woff2|eot)(?:\?[^\s\'")]*)?)['"]?\)/g;
return gulp.src([ 'src/lib/**/*less' ])
.pipe(less())
.pipe(gulp.src([ 'src/lib/**/*.css' ]))
.pipe(through.obj(function(chunk, _, callback) {
// 处理 CSS 文件中通过 url(...) 引入的资源
const contents = chunk.contents.toString();
// 匹配内容,length <= 0 表示没有匹配到资源、
const matches = [...contents.matchAll(assetPattern)];
if (matches.length <= 0) return callback(null, chunk);
let newContents = '';
let length = matches.length;
while (length--) {
const item = matches[length];
let content = item[1];
// 处理通过路径别名引入的资源。 url(@/lib/...)
if (content.startsWith('@/lib')) {
content = content.replace('@/lib', 'src/lib');
// 将路径别名转换成相对路径
content = path.relative(chunk.dirname, path.resolve(context, content));
}
// 在 windows 系统中路径分隔符使用的是 '\'。
content = content.replace(/\\/g, '/');
// 完成内容的替换
newContents = contents.slice(0, item.index);
newContents += `url(${content})`;
newContents += contents.slice(item[0].length + item.index);
cssAssetsProcessor.handle(content, chunk.dirname);
}
chunk.contents = Buffer.from(newContents);
callback(null, chunk);
}))
.pipe(postcss())
// 体积小于 maxWeightResource 的将打包成 base64,大于等于的则被忽略
.pipe(cssBase64({ maxWeightResource: assetInlineLimit }))
.pipe(gulp.dest('es'))
.pipe(gulp.dest('lib'));
}
完整代码展示:
引入依赖
js
import fs from 'fs';
import path from 'path';
import gulp from 'gulp';
import less from 'gulp-less';
import babel from 'gulp-babel';
import clean from 'gulp-clean';
import through from 'through2';
import filter from 'gulp-filter';
import postcss from 'gulp-postcss';
import { fileURLToPath } from 'url';
import cssBase64 from 'gulp-css-base64';
import child_process from 'child_process';
import buildRollup from './config/rollup.config.lib.js';
const assetInlineLimit = 1 * 1024;
const context = fileURLToPath(new URL('./', import.meta.url));
清空输出目录
js
import gulp from 'gulp';
import clean from 'gulp-clean';
function cleanOutDir() {
//
return gulp.src(
[
path.resolve(context, 'lib'),
path.resolve(context, 'es')
],
{ read: false, allowEmpty: true }
).pipe(clean({ force: true }));
}
构建ES、CJS 模块
js
// 打包构建 ESM 模块,这里使用的 rollup 工具进行构建
async function buildEs() {
await buildRollup();
}
// 打包构建 commonjs 模块
function buildLib () {
// 过滤掉不匹配的文件,restore 表示存储那些被过滤的文件。
// 使用 .pipe(filterJS.restore) 将被过滤的文件添加到流中。
const filterJS = filter((file) => file.extname === '.js', { restore: true });
// 读取 es/ 目录下的所有内容
return gulp.src('es/**/*')
.pipe(filterJS)
.pipe(through.obj(
function(chunk, _, callback) {
if (/\.(png|jpg|jpeg|gif|webp|bmp|svg)\.js$/.test(chunk.path)) return callback(null, chunk);
let contents = chunk.contents.toString();
const match = /import {(.+)} from (['"])@ant-design\/icons\2/.exec(contents);
if (match) {
const result = [];
const values = match[1];
const matches = values.matchAll(/\b([^\s,]+)\b/g);
for (let item of matches) result.push(item[1]);
let context = '';
result.forEach(item => context += `import ${item} from '@ant-design/icons/${item}';`);
contents = contents.replace(/import .* from (['"])@ant-design\/icons\1;/, context);
chunk.contents = Buffer.from(contents);
}
callback(null, chunk);
}
))
.pipe(babel({ configFile: './babel.config.lib.cjs' }))
.pipe(filterJS.restore)
.pipe(gulp.dest('./lib'));
}
TS 声明文件
js
// 执行有关生成 .d.ts 文件相关的任务
const tscTask = gulp.series(
function () {
return child_process.exec('npx tsc -p tsconfig.lib.json');
},
function () {
return gulp.src([ 'dts/**/*.d.ts' ])
.pipe(through.obj(
function(chunk, _, callback) {
const newBase = path.join(chunk.base, 'lib');
if (chunk.path.startsWith(newBase)) chunk.base = newBase;
return callback(null, chunk);
}
))
.pipe(gulp.dest('lib'))
.pipe(gulp.dest('es'));
},
function () {
return gulp.src(path.resolve(context, 'dts'), { read: false, allowEmpty: true })
.pipe(clean({ force: true }));
}
);
CSS 打包
js
class CssAssetsProcessor {
constructor(assetInlineLimit = 10 * 1024) {
// 内联文件的大小,超过的
this.assetInlineLimit = assetInlineLimit;
this.assets = new Set();
}
handle(url, base) {
try {
// 使用 URL 的目的就是将 url 后的 ?=xxxx 去掉。
const { pathname, protocol } = new URL(path.resolve(base, url));
const stat = fs.statSync(protocol + pathname);
// 判断文件的大小
if (stat.size > this.assetInlineLimit) this.assets.add(protocol + pathname);
} catch (err) {
console.log(err);
}
}
// 最后将收集到的资源全部输出的目标目录中
outputCssAssets = () => {
return gulp.src([...this.assets], { base: 'src/lib' })
.pipe(gulp.dest('es'))
.pipe(gulp.dest('lib'));
}
}
js
// 生成样式、以及相关的资源
function buildStyleSteet() {
const assetPattern = /url\(['"]?((?:\.{1,2}|@|[a-zA-Z]*)?(?:\/[^\s\)]*)*\.(?:png|jpg|jpeg|gif|webp|bmp|svg|ttf|woff|woff2|eot)(?:\?[^\s\'")]*)?)['"]?\)/g;
return gulp.src([ 'src/lib/**/*less' ])
.pipe(less())
.pipe(gulp.src([ 'src/lib/**/*.css' ]))
.pipe(through.obj(function(chunk, _, callback) {
// 处理 CSS 文件中通过 url(...) 引入的资源
const contents = chunk.contents.toString();
// 匹配内容,length <= 0 表示没有匹配到资源、
const matches = [...contents.matchAll(assetPattern)];
if (matches.length <= 0) return callback(null, chunk);
let newContents = '';
let length = matches.length;
while (length--) {
const item = matches[length];
let content = item[1];
// 处理通过路径别名引入的资源。 url(@/lib/...)
if (content.startsWith('@/lib')) {
content = content.replace('@/lib', 'src/lib');
// 将路径别名转换成相对路径
content = path.relative(chunk.dirname, path.resolve(context, content));
}
// 在 windows 系统中路径分隔符使用的是 '\'。
content = content.replace(/\\/g, '/');
// 完成内容的替换
newContents = contents.slice(0, item.index);
newContents += `url(${content})`;
newContents += contents.slice(item[0].length + item.index);
cssAssetsProcessor.handle(content, chunk.dirname);
}
chunk.contents = Buffer.from(newContents);
callback(null, chunk);
}))
.pipe(postcss())
// 体积小于 maxWeightResource 的将打包成 base64,大于等于的则被忽略
.pipe(cssBase64({ maxWeightResource: assetInlineLimit }))
.pipe(gulp.dest('es'))
.pipe(gulp.dest('lib'));
}
组合所有任务
js
export default gulp.series(
cleanOutDir,
buildEs,
buildLib,
tscTask,
buildStyleSteet,
cssAssetsProcessor.outputCssAssets
);
以上便是我对前端 UI 组件库模块化打包方案的优化,不足的地方欢迎指正(下方留言)一起进步。