前端 UI 组件库模块化打包工具更新

之前写过一篇文章关于如何编写前端组件库模块打包工具的,至今已经差不多一年半了。这两天没啥事情做,便对这个工具做了一个优化和升级。对比之前的内容,具体优化如下:

  • 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 非常类似,这里就不再详细介绍了。其他地方没有变动。

这里还要在介绍一下 preserveModulespreserveModulesRoot。在输出配置选项中,这两配置选项用来保证文件被打包后是否仍然保持原有的目录结构。读者可以亲自试一试。

另外,在输入配置选项中作者将 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 组件库模块化打包方案的优化,不足的地方欢迎指正(下方留言)一起进步。

相关推荐
加班是不可能的,除非双倍日工资3 小时前
css预编译器实现星空背景图
前端·css·vue3
wyiyiyi3 小时前
【Web后端】Django、flask及其场景——以构建系统原型为例
前端·数据库·后端·python·django·flask
gnip4 小时前
vite和webpack打包结构控制
前端·javascript
excel4 小时前
在二维 Canvas 中模拟三角形绕 X、Y 轴旋转
前端
阿华的代码王国4 小时前
【Android】RecyclerView复用CheckBox的异常状态
android·xml·java·前端·后端
一条上岸小咸鱼4 小时前
Kotlin 基本数据类型(三):Booleans、Characters
android·前端·kotlin
Jimmy5 小时前
AI 代理是什么,其有助于我们实现更智能编程
前端·后端·ai编程
ZXT5 小时前
promise & async await总结
前端
Jerry说前后端5 小时前
RecyclerView 性能优化:从原理到实践的深度优化方案
android·前端·性能优化
画个太阳作晴天5 小时前
A12预装app
linux·服务器·前端