前端组件库打包(vue)

563 阅读13分钟

前段时间写了一个关于 React UI 组件库的打包详解,同时也发布了一个 qm-vnit 组件库。好巧不巧的是,组件库刚搞完不到一个星期,就开始了新需求要求使用 Vue 3 进行开发,基于这个契机我就又搞了一个 qm-vnit-vue Vue Ui 组件库。接下来我就开始介绍 Vue Ui 组件库的打包步骤。

组件库开发注意事项

提供组件全局注册的方法

根据 Vue.use(myPlugin) 使用插件的逻辑,myPlugin 应该有一个 install 的方法,在 install 方法中你可以对组件进行全局的注册;

所以每个组件上都应该绑定一个 install(),并且每个组件都应该添加 name 属性,你可以使用 defineOptions({ name: 'xxx' }) 添加 name。

  Comp.install = (app: App) => {
    app.component(Comp.name, Comp);
    return app;
  }

样式

样式应该从 SFC 文件中分离出来,通过 import 的形式引入;

不使用 CssModule,开发者可以通过类名对样式进行二次修改;

按需引入

目前 webpack2+ 和 vite 都支持 Treehaking,只要我们的代码写的没有问题即可;

需要提供一个统一的入口文件,并使用 export { default as xxx } from 'xxx' 的形式将所有的组件挨个导出即可。

在库的 package.json 文件中添加 main、module、sideEffects 以及 types 字段。

{
  "main": "./lib/index.js",   // 表示 commonjs 文件入口
  "module": "./es/index.js",  // 表示 es 文件入口
  "types": "./es/index.d.ts", // 表示 typescript 声明文件入口
  "sideEffects": false        // 表示是否有副作用
}

依赖第三方库

就拿我自己的组件库来说,我自己的组件是依赖 ant-design-vue,@ant-design/icons-vue 这两个库。

在打包构建时需要对这些库进行按需引入,这对于打包生成 es 模块是没有问题的,但对于 commonjs 模式来说,就存在问题了。

import { Button, Spin, Select } from 'ant-design-vue';
import { UploadOutlined, PlusOutlined } '@ant-design/icons-vue';

对于上面的两行代码,如果打包模式为 commonjs 时,生成的代码如下:

const antd = require('ant-design-vue');
const antDesign = require('@ant-design/icons-vue');

对于这种情况,当别人使用了你的库后进行生产构建时就会将 ant-design-vue,@ant-design/icons-vue 所有的内容都打包到 bundle 中。

此时我们需要借助 babel-plugin-import 插件,并将其添加至 babel 配置文件中即可解决 ant-design-vue 的按需引入问题。

但对于 @ant-design/icons-vue 来说,该插件就无能为力了,但是这也难不倒我们,后面我们可以通过自定义 rollup、或 gulp 插件来解决这个问题。

静态文件资源要求

对于一些静态的的资源文件,我推荐将这些资源存放在一个公共目录下,不建议放在每个组件内部。这些文件应该尽可能的被压缩。

在构建库时,使用 rollup 构建 js 部分以及依赖的静态资源文件;

使用 gulp 对 css 部分以及被引入的静态资源文件进行打包构建,并将静态资源全部全部打包成 base64。

以上便是我开发组件库时的一些心得,有些还是蛮重要的。现在我们开始第二部分

组件库打包流程

构建流程我大概的分为以下几个步骤:

  • 构建 ES 模块

  • 基于已经构建完成的 ES 模块来构建 CJS 模块

  • 构建样式以及样式中引入的静态资源

  • 构建 .d.ts 声明文件

构建 ES 模块

构建 ES 模块我们使用 rollup 工具以及相关的插件来帮我完成

install dependencies

    yarn add --dev rollup 

    yarn add --dev rollup-plugin-vue rollup-plugin-typescript2
    
    yarn add --dev @rollup/plugin-babel @rollup/plugin-image @rollup/plugin-alias @rollup/plugin-commonjs @rollup/plugin-node-resolve 

注意,安装 rollup-plugin-vue 插件时,必须要要状态 6.0 以上的版本才能支持 vue3。

对于 typescript 中支持,必须安装 rollup-plugin-typescript2 插件,其他的如 @rollup/plugin-typescript 是不行的。

代码和注释

import path from 'path';
import chalk from 'chalk';
import process from 'process';
import { rollup } from 'rollup';
import { fileURLToPath } from 'url';
import babel from '@rollup/plugin-babel';
import alias from '@rollup/plugin-alias';
import vuePlugin from 'rollup-plugin-vue';
import commonjs from '@rollup/plugin-commonjs';
import less2css from './rollup-plugin-less2css.js';
import imageToBase64 from '@rollup/plugin-image';
import typescript from 'rollup-plugin-typescript2';
import nodeResolve from '@rollup/plugin-node-resolve';
import importAntDesignIconsVue from './rollup-plugin-import-ant-design-icons-vue.js';

// 该方法用于获取当前文件的目录
const __dirname = fileURLToPath(new URL('./', import.meta.url));

const inputOptions = {
  // 入口文件
  input: path.resolve(__dirname, '../src/library/index.ts'),
  // 指定外部依赖,他们将不会被打包到 bundle 中
  external: [ /[\\/]node_modules[\\/]/, /\.less/, /\.css/, /\.scss/ ],
  plugins: [
    // rollup 无法直接对 node_modules 中的第三方库进行加载,必须依赖 @rollup/plugin-node-resolve 插件
    nodeResolve(),
    // 如果依赖项是 commonjs 模块,必须要依赖 @rollup/plugin-commonjs 插件才能完成加载
    commonjs(),
    // 指定路径别名,与 webpack 或者 vite 中一样即可
    alias({ entries: { '@': path.resolve('src') } }),
    // 对 Vue 文件进行解析和转换,从而得到你想要的 JS 文件
    vuePlugin(),
    // 处理 ts
    typescript({ check: false }),
    // 对于转换后的 JS 文件,再使用 babel 进行转换,babel 的配置文件建议使用 babel.config.js
    babel({
      // 对于打包库来说,babelHelpers 必须为 runtime
      babelHelpers: 'runtime',
      exclude: /[\\/]node_modules[\\/]/,
      extensions: [ '.tsx', '.ts', '.jsx', '.js', '.cjs', '.mjs' ],
    }),
    // 将所有的被 js 引入的图片资源全部打包成 base64
    imageToBase64(),
    // 将文件中的所有引入 '.less' 全部转换成 '.css'
    // less2css(),
    // 将文件中引入的 @ant-design/icons-vue 进行拆分
    // importAntDesignIconsVue(),
  ],
  // 对于所有的外部资源,全部采用绝对路径
  makeAbsoluteExternalsRelative: false,
};

const outputOptions = {
  format: 'es',
  // 保持原有的目录结构
  preserveModules: true,
  // 库的代码是存在 src/library 目录下的。
  // 如果要保持原有的目录结构输出到 es 目录下,就需要将 src/library 这个路径去掉。
  // 否则打包生成的文件路径就包含 /library,例如 src/library/a.tsx => es/library/a.js
  // 这与我们的预期不一致,我们希望的结果应该是 es/a.js。
  preserveModulesRoot: 'src/library',
  dir: path.resolve(__dirname, '../es'),
};

async function buildES() {
  let bundle = null;
  try {
    // 开始构建
    bundle = await rollup(inputOptions);
    // 写入到本地文件系统(如果你是写入到内存中,则调用 bundle.generate(outputOptions))
    await bundle.write(outputOptions);
  } catch(error) {
    // 如果打包过程出现异常了,则打印异常信息并终止程序。
    const msg = error.stack.replace(/^\b/gm, '   ');
    process.stdout.write(chalk.hex('#ff0000')(msg));
    // 关闭进程
    process.exit(1);
  }

  if (bundle) bundle.close();
}

// 如果你希望单独执行 ES 代码构建,可以将下面一行代码注释放开,并执行 node ./buildES 命令。
// buildES()

export default buildES;

babel.config.js

module.exports = {
  presets: [
    [
      '@babel/preset-env', {
        // false 表示使用 ES Module 进行构建,默认为 true,表示使用 commonjs
        modules: false,
        debug: false,
        // "usage" 表示将根据您的上下文来引入需要的 polyfill。
        // 如果你使用 "entry",则将从入口文件处引入所有的 polyfill。
        useBuiltIns: 'usage', 
        // 这里的 version 请保持与您安装的 core-js 的版本一致 
        corejs: { version: "3.33", proposals: true } 
      }],
    '@babel/preset-typescript'
  ],
  plugins: [
    // 如果你使用了 jsx 语法,请配置该插件
    '@vue/babel-plugin-jsx',
    '@babel/plugin-transform-typescript',
    '@babel/plugin-proposal-export-default-from',
    '@babel/plugin-proposal-export-namespace-from',
    // @babel/preset-env 只对新的语法进行转换,对于新的API(内置类的静态方法,全局方法以及实例方法)不进行转换
    // 所以这里引入 transform-runtime 插件,将所有新语法经过 preset-env 转换后的辅助函数全部使用 runtime-corejs/helpers 替换;
    // 同时对于 preset-env 转换后产生的全局引入,替换成模块引入的方法,避免了全局污染;
    // 最后一点就是通过模块引入的方式按需引入 polyfill 来支持 ES6+ 新增的内置类的静态方法,全局方法以及实例方法
    ['@babel/plugin-transform-runtime', { corejs: 3 }],
    ['@babel/plugin-proposal-class-properties', { loose: false }],
  ]
}

执行 node node ./buildES 命令;

你会发现在项目根目录下会生成一个 es 的目录,并与 src/library 目录中的结构是一样的;

到这里,ES 模块的打包还没有完,因为我们还需要将 import './index.less' 替换成 import './index.css'

以及对 import { ... } from '@ant-design/icons-vue' 内容进行拆分。

此时我们要通过自定义 rollup 插件帮我们来完成这两个任务。

rollup-plugin-less2css

export default function() {
  return {
    name: 'rollup-plugin-less2css',
    version: '1.0.0',
    transform: {
      // 这是一个异步的钩子
      async: true,
      // 如果此配置项为 true,则 rollup 中出现多个相同异步钩子时,这些异步钩子将按照书写的先后顺序依次执行
      sequential: true,
      // 钩子函数
      handler(code, id) {
        // id 表示即将进行编译的资源的绝对路径
        if (!/\.(vue|tsx?|jsx?)$/.test(id)) return;
        if (!/\.less/.test(code)) return;
        return code.replace(/\.less/g, '.css');
      }
    }
  };
}

rollup-plugin-import-ant-design-icons-vue

// 过滤条件,它将匹配 import { ... } from '@ant-design/icons-vue'
const filterRegexp = /import\s+\{(.*)\}\s+from\s+(['"])@ant-design\/icons-vue\2/;
const contextRegExp = /([0-9a-zA-Z&]+)/g;

export default function() {
  return {
    name: 'rollup-plugin-import-ant-design-icons-vue',
    version: '1.0.0',
    transform: {
      async: true,
      quential: true,
      handler(code, id) {
        if (!/\.(vue|tsx?|jsx?)$/.test(id)) return;
        if (!filterRegexp.test(code)) return;
        // 注意这里的 $1 表示的是 filterRegexp 匹配的第一个子模式的内容
        const matched = RegExp.$1;
        const result = [];

        // contextRegExp 是一个全局匹配模式,需要匹配多次才能将所有的内容找出
        while (contextRegExp.test(matched)) {
          result.push(RegExp.$1);
        }

        // 最后进行拼接
        const text = result.reduce((memo, item) => {
          memo += `import ${item} from '@ant-design/icons-vue/${item}';`;
          return memo;
        }, '');

        return code.replace(filterRegexp, text);
      }
    }
  }
}

将以上两个插件添加到 inputOptions.plugins 中去,至此一个完整的 ES 模块就打包成功了

构建 CJS 模块

对于 CJS 模块的构建我使用的是 gulp 打包工具,并配合 babel 以及相关的插件来帮我们完成打包工作。

install dependencies

  yarn add --dev gulp gulp-cli gulp-babel gulp-base64 gulp-less gulp-postcss

创建 gulpfile.js

在项目根目录下,创建一个名为 gulpfile.js 的文件。此时我们再文件中引入 buildES.

import buildES from './buildES/index.js';

export default buildES;

此时你可以试试使用 gulp 工具来调用 rollup 进行打包操作。执行 npx gulp 命令。该命令执行的结果与我们单独执行 node ./buildES 是一样的。

创建一个清理文件夹的任务

import gulp from 'gulp';
// 清理匹配的文件和目录
import clean from 'gulp-clean'; 
import buildES from './buildES/index.js';

function cleanDir() {
  // 读取根目录下的 es、lib 目录,
  // read: false 表示不读取文件内容,这样设置可以使程序执行更快
  // allowEmpty: true 表示如果文件目录不存在时,gulp 不会抛出异常。
  return gulp.src([ './es', './lib' ], { read: false, allowEmpty: true })
    .pipe(clean({force: true}));
}

// gulp.series 可以将多个任务组成一个合成任务,原先的任务将按照传递的先后顺序依次执行
export default gulp.series(cleanDir, buildES);

此时,你可以试试执行 npx gulp

你是否发现 ./es/index.js 文件中会全局引入组件???

为了解决这个问题,我们需要对 ./src/library/index.ts 进行重新编译,并将编译后的内容覆盖 ./es/index.js

function rewriteBuildES() {
  return gulp.src('./src/library/index.ts')
  .pipe(babel({ configFile: './babel.config.cjs' }))
  .pipe(gulp.dest('./es'));
}

export default gulp.series(cleanDir, buildES, rewriteBuildES);

现在你再执行一次 gulp 命令,现在的结果才算是真正的满足我们的需求了。

创建构建 CJS 模块任务

import gulp from 'gulp';
import babel from 'gulp-babel';
import buildES from './buildES/index.js';

// 代码省略。。。

function buildCJS() {
  // CJS 的构建时依赖 ES 模块的,所以我们需要先构建 ES 模块,然后再构建 CJS
  // 这里我们通过 gulp.src() 去读 ./es 目录下的全部 JS 文件,然后将它们交给 babel 再进行 commonjs 的转译。
  return gulp.src('./es/**/*.js')
    // 这里我们制定了 babel 的配置文件
    .pipe(babel({ configFile: './babel.config.lib.cjs' }))
    // gulp.dest() 会见上面的转译后的内容全部输出到指定的文件目录中。并保持原有的目录结构
    .pipe(gulp.dest('./lib'));
}

export default gulp.series(cleanDir, buildES, rewriteBuildES, buildCJS);

babel.config.lib.cjs

module.exports = {
  presets: [
    [
      '@babel/preset-env', {
        // 使用 commonjs
        modules: 'commonjs', 
        debug: false, 
        useBuiltIns: 'usage', 
        corejs: { version: "3.33", proposals: true } 
      }
    ],
    '@babel/preset-typescript'
  ],
  plugins: [
    '@vue/babel-plugin-jsx',
    '@babel/plugin-transform-typescript',
    '@babel/plugin-proposal-export-default-from',
    '@babel/plugin-proposal-export-namespace-from',
    ['@babel/plugin-transform-runtime', { corejs: 3 }],
    ['@babel/plugin-proposal-class-properties', { loose: false }],
    // babel-plugin-import 对 ant-design-vue 库进行按需引入
    ['babel-plugin-import', { libraryName: 'ant-design-vue', libraryDirectory: 'lib', style: false }],
  ]
}

样式构建

到目前为止,我们已经将 library 全部转换成了 CJS、ES 模块。接下来我们将对样式部分进行构建。 我们继续在 gulpfile.js 文件中添加任务

import gulp from 'gulp';
import less from 'gulp-less';
import base64 from 'gulp-base64';
import postcss from 'gulp-postcss';

// 代码省略。。。

function buildStyle() {
  // 先读取 src/library 目录下的所有 less 文件,然后对 less 文件进行编译,编译后会转成 css 文件保存在内存中。
  return gulp.src('./src/library/**/*.less')
    .pipe(less())
    // 紧接着继续读取 src/library 目录下的所有 css 文件
    .pipe(gulp.src('./src/library/**/*.css'))
    // 然后将以上步骤中所有的 css 文件都交由 postcss 进行处理
    // 如果项目根目录下已经存在 postcss.config.cjs 文件,这里可以不用配置 options
    .pipe(postcss())
    // 最后是将 css 文件中所有依赖的静态资源全部打包成 base64 内嵌在 css 文件中
    .pipe(base64())
    // 写入到 es 目录下
    .pipe(gulp.dest('./es'))
    // 写入到 lib 目录下
    .pipe(gulp.dest('./lib'));
}

export default gulp.series(cleanDir, buildES, rewriteBuildES, buildCJS, buildStyle);

生成 TS 声明文件

这是打包的最后一步了,我们只需要将生成 ts 声明文件输出到 lib、es 目录结构中就可以了。

但是,我发现单单使用 tsc 命令生成声明文件,还是借助 rollup/gulp 工具,都无法直接满足我们需求,

因为以上两种方法,在打包后的产物中,会有一个 library 这个前置目录存在。这与我们的期望有所不同。

最终,我的解决办法时先使用 tsc 生成 .d.ts 文件,然后借助 gulp 再进行处理。

import path from 'path';
import gulp from 'gulp';
import clean from 'gulp-clean';
import through2 from 'through2';
import child_process from 'child_process';

const buildDTS = gulp.series(
  function () {
    // vue 项目中必须使用 vue-tsc 才能执行 tsc,否则无法对 vue 文件进行编译
    // 注意,这里我们指定了 vue-tsc 的配置文件为 ./tsconfig.lib.json
    // 建议在你的控制台中单独执行 `npx vue-tsc -p tsconfig.lib.json` 命令,并查看生成的内容。便于下文的展开 
    return child_process.exec('npx vue-tsc -p tsconfig.lib.json');
  },
  function () {
    return gulp.src('./dts/**/*.d.ts')
      .pipe(through2(
        { objectMode: true },
        function(chunk, _, callback) {
          const newBase = path.join(chunk.base, './library');
          // gulp.dest() 在输出时将每个资源的 chunk.base 路径全部截取掉。
          // 例如,当前的情况 base 就是 "/qm-vnit-vue/dts",这取决于 gulp.src(pattern) 的参数 pattern
          // 这里我们将 library 目录下的文件的 base 修改为 base = base + '/library',
          // 这样在输出文件时就会将 base + '/library' 路径全部截取掉。
          // 例如:/qm-vnit-vue/dts/library/index.d.ts ===> /index.d.ts
          // 这样通过 gulp.dest('es') 写入文件系统时就变成了 /qm-vnit-vue/es/index.d.ts 
          if (chunk.path.startsWith(newBase)) chunk.base = newBase;

          return callback(null, chunk);
        },
      ))
      // 最终,文件将输出到 es,lib 目录中,
      .pipe(gulp.dest('es'))
      .pipe(gulp.dest('lib'))
  },
  function () {
    // 上面的任务完成后,将 dts 目录删除
    return gulp.src([ './dts'], { read: false, allowEmpty: true })
      .pipe(clean());
  }
);

export default gulp.series(cleanDir, buildES, rewriteBuildES, buildCJS, buildStyle, buildDTS);

注意,我是单独指定一个 tsconfig.lib.json 文件来生成 .d.ts 文件,

这是因为默认的 tsconfig.json 是不会输出内容的,而我们指定的 tsconfig.lib.json 只需要输出声明文件即可;

tsconfig.lib.json 和 tsconfig.json 不同之处看下面

{
  "compilerOptions": {
    "declaration": true,
    "emitDeclarationonly": true,
    // 指定声明文件的输出目录
    "declarationDir": "dts",
    // "noEmit": true
  },
  // src/library 是我库所在的目录,src/vite-env.d.ts 时全局声明文件
  "include": [ "src/library", "src/vite-env.d.ts" ]
}

好了,到这里我们所有的打包都已经完成了,剩下的就是如何发布 npm 包了。

npm publish

在发布之前我们应该做一下几件事情

  • 1、添加 readme
  • 2、修改 version、name 字段
  • 3、修改 main、module、types 等字段
  • 4、修改 files 字段
  • 5、将一些公共依赖项从 dependencies中移动到 peerDependencies 中
  • 6、添加 keywords 和 description
  • 7、添加 contributes 和 respository
{
  "name": "qm-vnit-vue",
  "version": "0.0.0",
  "main": "./lib/index.js",
  "module": "./es/index.js",
  "types": "./lib/index.d.ts",
  "files": [ "lib", "es" ],
  "keywords": [
    "Qm-Vnit-Vue",
    "And-Design-Vue",
    "Vue Ui Library"
  ],
  "description": "Vue Ui Library",
  "contributes": [ "https://github.com/shenxuxiang/" ],
  "respository": "https://github.com/shenxuxiang/qm-vnit-vue"
}

🆗 大功告成。

总结

Vue 组件的打包方式与 React 组件的打包方式大体上基本一样,不同点我例举一下:

  • rollup 在构建 ES Module 时,需要配合 rollup-plugin-vue 插件完成对 Vue 文件的编译和转换;

  • tsc 无法完成 Vue 文件中 ts 语法的转换,需要使用 vue-tsc 工具帮忙;

  • 如果你使用了 jsx 语法,在编译时请安装 @vue/babel-plugin-jsx 插件;

总的来说,Vue 和 React 打包流程基本一致,会了一个,另一个自然就会了。