前端 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 组件库模块化打包方案的优化,不足的地方欢迎指正(下方留言)一起进步。

相关推荐
湛海不过深蓝20 分钟前
【ts】defineProps数组的类型声明
前端·javascript·vue.js
layman052826 分钟前
vue 中的数据代理
前端·javascript·vue.js
柒七爱吃麻辣烫33 分钟前
前端项目打包部署流程j
前端
layman05281 小时前
vue中理解MVVM
前端·javascript·vue.js
一舍予2 小时前
八股文-js篇
开发语言·前端·javascript
鸡鸭扣3 小时前
DRF/Django+Vue项目线上部署:腾讯云+Centos7.6(github的SSH认证)
前端·vue.js·python·django·腾讯云·drf
龙井茶Sky3 小时前
验证码与登录过程逻辑学习总结
前端·登录·验证码
sunbyte4 小时前
Three.js + React 实战系列 - 职业经历区实现解析 Experience 组件✨(互动动作 + 3D 角色 + 点击切换动画)
javascript·react.js·3d
2401_831943324 小时前
Element Plus对话框(ElDialog)全面指南:打造灵活弹窗交互
前端·vue.js·交互
strongwyy4 小时前
DA14585墨水屏学习(2)
前端·javascript·学习