likes
comments
collection
share

vue UI 组件库构建方案

作者站长头像
站长
· 阅读数 24

工作的多数时候都是使用组件库,最近需要自己开发一个小型组件库(功能型,非全面的基础组件库),驱动我去了解vue组件库是如何组织项目目录、如何构建的。

第一个要解决的问题是,我希望打包后的组件怎么被使用方引入?我对打包结果有什么需求?

构建需求

  1. 组件能支持按需引入

    • 这是必须的,因为使用的项目几乎只会用到部分
    • 项目中会存在一些公共的模块、组件、样式,这部分不能以内联代码的形式出现在在按需引入的组件中,还是要保持文件的形式。不然引入多个组件时,这部分代码会重复。
  2. 希望组件能支持完整引入 只是多增加一种引入方式,有无都可

  3. 希望组件能支持一个unpkg的引入方式 只是多增加一种引入方式,这种比较适合开源项目,公司内部组件到不太有必要,因为实际项目基本都会有构建过程

需求想好了,那我们开始创建项目吧

项目目录结构

项目名称 vue2-components-build


├── babel.config.js // 使用babel打包js的配置
├── build // 存放构建方法
├── components // 对外暴露的组件
│   ├── button
│   │   ├── index.js // 打包入口,引入index.vue并提供install方法
│   │   ├── index.vue
│   │   └── style // 存放组件样式
│   │       ├── index.js // 组件依赖了哪些样式文件
│   │       └── index.less // 组件自己的样式文件
│   ├── form
│   │   ├── index.js
│   │   ├── index.vue
│   │   └── style
│   │       ├── index.js
│   │       └── index.less
│   ├── index.js // 导出所有组件
│   ├── index.less // 所有组件的样式
│   └── styles // 公共样式
│       └── index.less
├── example-dev // 组件开发调试时用到,省略内部结构
├── example-prod // 为了验证组件的引入方式,基本不需要
├── package-lock.json
├── package.json
└── src // 组件的公共依赖
     └──utils
        └── util.js

组件(暴露给用户使用的)内的依赖(import)分两种:

  • 公共依赖,希望打包后依旧保持import
  • 组件私有依赖,希望内联打包进入组件

要怎么做才能方便构建时区分?

  • 公共JS模块都放在src,公共组件都放在compnents。import时,不要采用相对路径,而是使用别名
  • 私有依赖,使用相对路径引入

以Form组件为例,需要使用公共方法和组件内的Button组件。 import时统一使用前缀vue2-components-build (后续会讲到作用)。

vue UI 组件库构建方案

OK,组件JS部分的源码准备好了,我们先来进行打包。

项目构建

打包JS

打包工具选用rollup(如不了解的,需要先去了解下rollup打包的基本方法) vite 库模式中有推荐构建格式

根据需求,准备打包两部分代码: 1)按需引入的组件 + 导出所有组件的index.js => 使用ES规范 2)unpkg导出所有组件 => 使用UMD规范

1. ES模块

此部分代码打包到根目录的lib目录

开始前的注意点

  1. vue2-components-build/componentsvue2-components-build/src配置别名,找到对应的文件目录
  2. 将文件中引入上面两个别名的地方转换为vue2-components-build/lib(这是打包后的位置)
  3. 因为是打包库文件,需要配置一些externals,其中包括vue2-components-build,这样才能保持打包后依旧是import的形式
  4. components目录下的组件打包的入口文件是index.js,src目录下的公共模块是打包所有js文件。

现在开始准备打包

  1. rollup配置(插件作用大概能做名字中看出来,不了解的请自行了解)
const fs = require('fs');
const path = require('path');
const { getBabelInputPlugin } = require('@rollup/plugin-babel');
const {nodeResolve: resolve} = require('@rollup/plugin-node-resolve');
const commonjs = require('@rollup/plugin-commonjs');
const alias = require('@rollup/plugin-alias');
const vue = require('rollup-plugin-vue');
const babelConfig = require('../babel.config.js');

function resolvePath(dir) {
    return path.resolve(__dirname, '../', dir);
};
// 后面umd也用得上
const commonPlugins = [
    resolve({
        extensions: ['.js', '.vue', '.json']
    }),
    vue(),
    commonjs(),
    alias({
        entries: [
            { find: 'vue2-components-build/components', replacement: path.resolve(__dirname, '../components') },
            { find: 'vue2-components-build/src', replacement: path.resolve(__dirname, '../src') },
        ]
    }),
    getBabelInputPlugin({
        babelHelpers: 'runtime',
        ...babelConfig,
    }),
];

function createESConfig(entry, outputFilePath, externals) {
    return {
        input: entry,
        output: {
            file: `lib/${outputFilePath}`,
            format: 'es',
            exports: 'auto',
            paths: (id) => {
                // 文件引用修改:源文件路径 -> 打包后的路径
                const newId = id.replace(/^vue2-components-build\/(components|src)\/(.*)/, function(_raw, _dirName, filePath) {
                    return 'vue2-components-build/lib/' + filePath;
                });
                return newId;
            }
        },
        external: externals,
        plugins: commonPlugins
    }
}
  1. 收集入口文件和对应的输入路径
/**
 * 组件打包
 * @returns
 */
function collectComponents(collection) {
    const componentsDir = resolvePath('components');
    const dirs = fs.readdirSync(componentsDir);
    dirs.forEach((componentName) => {
        // 其他地方会处理
        if(/.(js|less)$/.test(componentName) || componentName === 'styles') return;
        collection.push({
            entry: path.join(componentsDir, componentName, 'index.js'),
            // 直接放到lib目录下
            outputFile: path.join(componentName, 'index.js'),
        });
    });
}
/**
 * components中依赖的项目的模块
 */
function collectSrc(collection) {
    const srcDir = resolvePath('src');
    const dirs = fs.readdirSync(srcDir);
    dirs.forEach((categoryName) => {
        // 比如 src/utils
        const categoryDir = path.join(srcDir, categoryName);
        // 需要打包的文件 src/utils/util.js
        const fileNames = fs.readdirSync(categoryDir);
        fileNames.forEach((fileName) => {
            collection.push({
                entry: path.join(categoryDir, fileName),
                outputFile: path.join(categoryName, fileName),
            });
        });
    });
}
  1. 得到rollup配置并导出
/**
 * 按需加载的打包
 * @returns
 */
function getESConfigs() {
    const list = [];
    // 1. 
    collectComponents(list);
    collectSrc(list);
    const externals = (id) => {
        return /^vue2-components-build/.test(id) ||
            /^vue$/.test(id) ||
            /^vue-runtime-helpers/.test(id) ||
            /^@babel\/runtime/.test(id) ||
            /^@babel\/helpers/.test(id);
    };
    let result = list.map(({entry, outputFile}) => {
        return createESConfig(entry, outputFile, externals);
    });
    // 2. 所有组件
    const entry = resolvePath('src') + '/index.js';
    const indexExternals = (id) => {
        return id !== entry;
    };
    result.push(createESConfig(entry, 'index.js', indexExternals));

    return result;
}
const esConfigs = getESConfigs();
module.exports = [
    ...esConfigs,
];

执行上述代码得到lib文件

 ~/study/vue2-components-build   master ±  npm run build:js

> vue2-components-build@1.0.0 build:js /Users/xxx/study/vue2-components-build
> rm -rf lib && rollup -c ./build/rollup.config.js

/Users/xxx/study/vue2-components-build/components/button/index.js → lib/button/index.js...
created lib/button/index.js in 497ms

/Users/xxx/study/vue2-components-build/components/form/index.js → lib/form/index.js...
created lib/form/index.js in 63ms

/Users/xxx/study/vue2-components-build/src/utils/util.js → lib/utils/util.js...
created lib/utils/util.js in 18ms

/Users/xxx/study/vue2-components-build/src/index.js → lib/index.js...
created lib/index.js in 68ms

vue UI 组件库构建方案

2. UMD模块

打包到dist目录

/**
 * umd,导出所有的组件 unpkg,可直接用script引用
 * @returns
 */
function getUMDConfig() {
    const entry = resolvePath('components') + '/index.js';
    return {
        input: entry,
        output: {
            name: 'vue2-components-build',
            // 本来想压缩的,但后面发现有问题,看代码就去掉了
            file: 'dist/vue2-components-build.min.js',
            format: 'umd',
            sourcemap: true,
            exports: 'named',
            // 指明vue是外部依赖
            globals: {
                vue: 'Vue',
            },
        },
        external: ['vue'],
        plugins:  commonPlugins.concat([
            getBabelInputPlugin({
                //此时应该使用bundled
                babelHelpers: 'bundled',
                babelrc: false,
                exclude: /node_modules/,
                "presets": [
                    [
                        "@babel/preset-env",
                    ]
                ],
                plugins: [
                    [
                        '@babel/plugin-transform-runtime',
                        {
                            helpers: false,
                            // 一定要是false,不然rollup babel会报错
                        }
                    ],
                ],
            }),
        ]),
    }
}


const esConfigs = getESConfigs();
const umdConfig = getUMDConfig();

module.exports = esConfigs.concat(umdConfig);

vue UI 组件库构建方案 有循环引用的warning,后面再说

打包样式

  1. 使用gulp来对样式文件进行打包,相对于rollup,gulp虽然有些古老了,但很适合打包样式文件,配置简单,使用方便
  2. 需要处理的任务有
    • 组件自己的私有样式 components/style/index.less
    • 组件全部所需样式文件 components/style/index.js
    • 公共样式 src/styles/*.less
    • 所有组件的样式文件,此文件应该打包两份 lib和dist都需要
    • 保留一下所有的less文件(使用者要溯源某个样式的时候用的上)
const path = require('path');
const gulp = require('gulp');
const  { src, dest, series } = require('gulp');
const babel = require('gulp-babel');
const less = require('gulp-less');
const postcss = require('gulp-postcss');
const autoprefixer = require('autoprefixer');
const cssnano = require('gulp-cssnano');
const rename = require('gulp-rename');
const merge2js = require('merge2');
const through2 = require('through2');

// 这里的babel只需要最简单的配置即可,打包成es
const babelConfig = {
    presets: [
        ['@babel/preset-env', {
            modules: false,
            loose: true
        }]
    ],
}

function resolve(dir) {
    const rootDir = path.resolve(__dirname, '../');
    return path.join(rootDir, dir);
}

function buildStyle() {
    // 保留less源文件
    const styleLess = src('../components/styles/*.less')
            .pipe(dest(resolve('lib/styles')))
            .pipe(src(['../components/**/style/*.less', '../components/index.less']))
            .pipe(dest(resolve('lib')));

    // 公共样式
    const commonCss = src('../components/styles/*.less')
        .pipe(less())
        .pipe(postcss([
            autoprefixer()
        ]))
        .pipe(dest(resolve('/lib/styles')));

    // 组件的样式
    const componentCss = src('../components/**/style/index.less')
        .pipe(less())
        .pipe(postcss([
            autoprefixer()
        ]))
        .pipe(dest(resolve('lib')));

    // 组件样式依赖 js文件打包
    const cssJs = src('../components/**/style/index.js')
        .pipe(babel(babelConfig))
        .pipe(
            through2.obj(function (file, encoding, next) {
                const content = file.contents.toString(encoding);
                // 导入的less,改成导入css
                file.contents = Buffer.from(
                    content.replace(/\/style\/?'/g, '/style/css\'').replace(/\.less/g, '.css')
                );
                this.push(file);
                next();
            })
        )
        .pipe(dest(resolve('lib')));

    // 所有组件的样式
    const wholeCss = src('../components/index.less')
        .pipe(less())
        .pipe(postcss([
            autoprefixer()
        ]))
        .pipe(dest(resolve('lib')));
    // 所有组件的样式,压缩 打包到dist目录
    const wholeMin = src('../components/index.less')
        .pipe(less())
        .pipe(postcss([
            autoprefixer()
        ]))
        .pipe(cssnano())
        .pipe(rename('vue2-components-build.min.css'))
        .pipe(dest(resolve('dist')));

    return merge2js([styleLess, commonCss, cssJs, componentCss, wholeCss, wholeMin]);
}

gulp.task('style', () => {
    return merge2js([buildStyle()])
});


gulp.task('default', series('style'), ()=> {
    console.log('finish');
});

vue UI 组件库构建方案

开发调试(example-dev )

1. 使用组件

vue UI 组件库构建方案

vue UI 组件库构建方案

2. gitlab pages

如果只是小型组件库,不太会为此搭建组件说明的网站。但如果只有组件readme,也不方便其他相关人员体验。综合需求和开发成本考虑,可以使用gitlab pages

# .gitlab-ci.yml,这是一个组件实际使用的一个案例,可根据实际情况调整
stages:
 - deploy
# 打包demo页面,低成本给使用者查看组件效果
pages:
 image: imageName
 stage: deploy
 tags:
   - node
 script:
   - cd example
   - npm install && npm run build
   - mv dist ../public
 artifacts:
   paths:
     - public
 rules:
   - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

vue UI 组件库构建方案 注意

  1. 此域名是公司内搭建gitlab时决定的
  2. 组件的访问路径会是域名+组件名,所以打包的时候需要设置publicPath

安装使用(example-prod)

按需引入

vue UI 组件库构建方案

import { Form } from 'vue2-components-build';
// 等价于
import Form form 'vue2-components-build/lib/form/index.js';
import 'vue2-components-build/lib/form/style/index.js';

unpkg引入

vue UI 组件库构建方案 启动项目example-prod后,访问http://localhost:8080/demo.html vue UI 组件库构建方案 在网上搜索后发现,应该是打包出的有循环依赖导致的,由rollup打包引发的对JS模块循环引用的思考。 评论中说有新版本解决了此问题,但2022-08-18并没有发现有beta的版本,最新版22.0.2问题依然存在。 呃,可能需要换webpack来,但打算摆烂🐶。

写在最后

为什么文件目录要写成这样?我想换成别的可以吗? 当然可以,整体来看,文件目录和打包代码比较耦合。改了目录,代码需要调整一下就行。

完整代码见github vue2-components-build