pnpm+vite+vue3搭建业务组件库踩坑之旅

前言

最近公司需要把一些vue3编写的业务组件抽离出来,方便有需要的人使用,经商量后决定要采用发npm包的方式。鉴于这些业务组件之前就是基于vite构建环境开发的,于是我简单调研后决定采用最新的pnpm + vite3搭建这个vue3业务组件库。

技术栈

pnpm

pnpm是一个高性能包管理器,具有下载速度快、磁盘占用空间小、依赖管理清晰等特点,性能比 npm \ yarn要好,解决了npm 、yarn的一些问题如 幽灵依赖、重复下载;且它天然支持monorepo多包管理方式,不失为一个更好的选择。同时目前不少优秀库都迁移到了pnpm管理,如vue3、vite、element-plus。

vite

vite是新一代构建工具,以预构建及按需编译模块的方式达到快速启动开发环境而出名。同时它内部内置很多插件支持开箱即用去搭建常规的web项目。如下内置插件

  • vite:esbuild实现了代替传统的 Babel 或者 TSC来对 .js.ts.jsxtsx模块进行转译
  • vite:css处理样式包括CSS 预处理器、CSS Modules、Postcss 的编译;
  • vite:json加载json
  • vite:wasm用来加载 .wasm
  • vite:asset 处理静态资源(图片、字体、多媒体资源等)的加载
  • vite:worker 内部采用Rollup 对web weorker脚本进行打包

组件支持 .vue .jsx .tsx编写

由于我们的业务组件支持.vue .jsx .tsx的编写方式,vite同样提供相关插件安装

 pnpm install @vitejs/plugin-vue @vitejs/plugin-vue-jsx -D

vite.config.ts 使用

import vue from '@vitejs/plugin-vue';
import vueJsx from '@vitejs/plugin-vue-jsx';
// ... 其他配置代码
 plugins: [
      vue(),
      vueJsx(),
  ]

确定输出目标

作为一个业务组件库,我们提供组件,用户安装使用我们的组件,那么用户期待的是什么格式的组件呢?参考平时使用的组件库,如tdesign我们可以发现它打包了好几种格式的包,如下所示:

├─ dist                        ## umd
│   ├─ tdesign.js
│   ├─ tdesign.js.map
│   ├─ tdesign.min.js
│   ├─ tdesign.min.js.map
│   ├─ tdesign.css
│   ├─ tdesign.css.map
│   └─ tdesign.min.css
├─ esm                                ## esm
│   ├─ button
│        ├─ style
│             └─  index.js
│        ├─ button.js
│        ├─ button.d.ts
│        ├─ index.js
│        └─ index.d.ts
│   ├─ index.js
│   └─ index.d.ts
│
├─ es                                ## es
│   ├─ button
│        ├─ style
│             ├─ css.js
│             ├─ index.css
│             └─ index.js
│        ├─ button.js
│        ├─ button.d.ts
│        ├─ index.js
│        └─ index.d.ts
│   ├─ index.js
│   └─ index.d.ts
│
├─ lib                            ## cjs
│   ├─ button
│        ├─ button.js
│        ├─ button.d.ts
│        ├─ index.js
│        └─ index.d.ts
│   ├─ index.js
│   └─ index.d.ts
│
├─ LICENSE
├─ CHANGELOG.md
├─ README.md
└─ package.json

这种方式是比较常见的做法。

还有一种就是直接导出src目录下的组件源码,不进行任何打包

两种方式的优缺点对比:

  • 通过打包输出各种格式的组件的
    优点:方便用户直接引入使用,作者可以掌握要输出哪些组件、哪种格式的产物
    缺点:组件库需要开发维护构建脚本

  • 直接导出源码
    优点:组件作者不需要写构建脚本,所有源码导出即可,用户要啥就引啥
    缺点:用户要使用我们的组件的时候可能要为了适配要改动项目基础配置

经过对比,最后采用打包构建的方式,输出 es\umd格式产物

确定了输出产物的格式,同样还有一个问题需要确定,是否支持用户全量引入或者按需引入?
毫无疑问我们都要支持,那么目前组件的按需引入有两个方法:

  • 组件单独分包 + 用户按需导入 + 插件babel-plugin-component来改写导入路径达到按需引入的目的
  • ESModule + Treeshaking + 自动按需 import(unplugin-vue-components 自动化配置)

这两种方法都是有一套约定的规则的,要统一划分打包后的组件文件夹、样式等,不过无论哪种方式的按需导入,其底层思想都是通过分析ast给你自动重写这些引入路径,如下例子

// 你写的代码
import { ElButton } from 'element-plus' 

// 工具转换后的代码   ↓ ↓ ↓ ↓ ↓ ↓

import { ElButton } from 'element-plus'
import 'element-plus/es/components/button/style/css'

约定优先

既然确定输出目标了,同时为了方便构建脚本的编写支持按需导入,我们就要做出一些约定:

  • src/lib目录下面按组件名划分组件文件夹,每个组件文件夹统一采用index.ts进行导出
  • 全量包引入src/lib下的所有组件,再统一以插件方式默认导出组件库
  • 每个组件都有独立的 package.json 定义,包含 esm 和 umd 的入口定义
  • 每个组件支持以 Vue 插件形式进行加载
  • 每个组件还需要有单独的 css 导出

下面是一个矩阵图组件的导出案例:
src/lib/Matrix/index


import Matrix from './components/data-matrix/index.vue'
import { App, Component } from "vue";

// 导出Matrix组件和工具函数
export { Matrix };

// 导出Vue插件
export default {
  install(app: App) {
    app.component((Matrix as Component).name , Matrix);
  }
};

其中默认导出export default 是以Vue插件方式导出,适合用户全局引入,而 export{ Matrix } 导出是为了支持按需导入

entry.ts

import { App, Component } from "vue";
import { Matrix } from "./lib/Matrix/index";

// 导出单独组件
export {
  Matrix,
};

// 编写一个插件,实现一个install方法
export default {
  install(app: App): void {
    app.component((Matrix as Component).name, Matrix);
  }
};

构建脚本

按照约定的目录,就可以写构建脚本,实现全量打包与按需打包,我期待的输出目录结构如下:

├─ dist     
│   ├─ package.json         
│   ├─ sutpc-charts-utils.umd.js  ## umd格式
│   ├─ sutpc-charts-utils.mjs  ## esm格式
│   ├─ style.css
│   ├─ gantt-chart  
│        ├─ style.css
│        ├─ index.mjs
│        ├─ index.umd.js
│        ├─ package.json
│   ├─ invest-chart  
│        ├─ style.css
│        ├─ index.mjs
│        ├─ index.umd.js
│        ├─ package.json

这里不以打包格式作为目录,目录下再组织各个组件的方式,而是直接用包名作为目录,目录下包含各个格式的产物的方式。

从这个结构来看,我们需要打全量包配一个entry,然后每个独立组件包有一个entry;
vite.config.ts代码配置全量包的打包配置

import { defineConfig, UserConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import vueJsx from "@vitejs/plugin-vue-jsx";
import path from 'path';

export const getConfig = (): UserConfig => {
  const config: UserConfig = {
    resolve: {
      alias: {
        '@': path.resolve(__dirname, './src'),
      },
    },
    plugins: [
      vue(),
      vueJsx(),
    ],
    build: {
      rollupOptions: {
        external: ["vue", "vue-router"],
        output: {
          exports: "named",
          globals: {
            vue: "Vue",
            'vue-router': "VueRouter",
          },
        },
      },
      minify: 'terser', // boolean | 'terser' | 'esbuild'
      terserOptions: {
        compress: {
          drop_console: true,
          pure_funcs: ['console.error', 'console.warn']
        }
      },
      sourcemap: false, // 输出单独 sourcemap文件
      lib: {
        entry: "./src/entry.ts",
        name: "SutpcChartsUtils",
        fileName: "sutpc-charts-utils",
        formats: ["es", "umd"], // 导出模块类型
      },
      outDir: "./dist",
    }
  };
  return config;
};
export default defineConfig(getConfig());

主要代码在lib配置那里

构建命令 build 是全量包与独立组件包一起打

"scripts": {
    "dev": "vite --open",
    "build": "npm run build:components",
    "build-all": "vite build",
    "build:components": "esno ./scripts/build.ts"
  }

对应scripts/build.ts代码:

import * as fs from "fs-extra";
import * as path from "path";
import { getConfig } from "../vite.config";
import { build, InlineConfig, defineConfig, UserConfig, LibraryOptions } from "vite";

// MyComponent ----> my-component
function wordToLowerCase(word: string): string {
  let s = ''
  for(let i = 0; i {
  const config: UserConfig = getConfig()

  // 全量打包
  console.log('开始打全量包')
  await build();
  console.log('结束打全量包')

  // 复制 Package.json 文件
  const packageJson = require("../package.json");
  packageJson.main = `${packageJson.name}.umd.js`;
  packageJson.module = `${packageJson.name}.mjs`;
  fs.outputFile(
    path.resolve(config.build.outDir, `package.json`),
    JSON.stringify(packageJson, null, 2)
  );

  const srcDir = path.resolve(__dirname, "../src/lib/");
  const buildList = fs.readdirSync(srcDir).filter((name) => {
    // 只要目录不要文件,且里面包含index.ts
    const componentDir = path.resolve(srcDir, name);
    const isDir = fs.lstatSync(componentDir).isDirectory();
    return isDir && fs.readdirSync(componentDir).includes("index.ts");
  }).map((name) => {
    const config: UserConfig = getConfig();
    const outDir = path.resolve(config.build.outDir, wordToLowerCase(name));
    const fileName = 'index';
    const lib: LibraryOptions = {
      entry: path.resolve(srcDir, name),
      name, // 导出模块名
      fileName, // 文件名
      formats: ['es', 'umd'],
    }

    config.build.lib = lib;
    config.build.outDir = outDir;

    const inlineConfig: InlineConfig = {
      ...config,
      configFile: false,
    }

    return {
      buildBundle: () => build(defineConfig(inlineConfig) as InlineConfig),
      buildPackageJson: () => fs.outputFile(
        path.resolve(outDir, `package.json`),
        `{
  "name": "${packageJson.name}/${wordToLowerCase(name)}",
  "main": "${fileName}.umd.js",
  "module": "${fileName}.mjs"
}`, 'utf-8')
    }
  });

  const buildBundleList = buildList.map(item => item.buildBundle())

  console.log('开始打独立组件包')
  await Promise.all(buildBundleList);
  console.log('结束打独立组件包')

  console.log('开始生成独立组件包的package.json')
  const buildPackageJsonList = buildList.map(item => item.buildPackageJson())
  await Promise.all(buildPackageJsonList);
  console.log('结束生成独立组件包的package.json')
};

buildAll();

bundle分析报告与产物测试examples

采用rollup-plugin-visualizer来进行分析打包后的文件包含哪些模块,方便调整体积优化。

import { defineConfig } from "vite";
import { visualizer } from "rollup-plugin-visualizer";

export default defineConfig({
  plugins: [
    visualizer({
      // 打包完成后自动打开浏览器,显示产物体积报告
      open: true,
    }),
  ],
});

当执行npm run build-all之后,浏览器会自动打开产物分析页面。


image.png

当然这个插件只是在调试打包产物的时候才加上,一般要打包发布npm的时候不用加上这个插件。

打包后输出的文件如下:

打包文件

打包之后,怎么验证我们的包能不能正常显示?最简单的方式就是有测试用例跑一遍,可惜目前还没有,退而其次我们可以创建一个examples目录,把开发环境的调试代码挪过来,区别只是把引入的组件改为引入打包后的组件。当然我们也要有启动一个服务预览这些效果,所以我们希望也有一个命令类似启动开发环境一样,npm run examples 启动服务查看效果。

这个命令的主要做两件事,执行scripts/examples.ts脚本 把dist目录拷过来到examples目录,然后启动新的vite服务预览效果

  "scripts": {
    "examples": "esno ./scripts/examples.ts & cd examples & vite"
  }

examples目录结构如下


examples目录

发包相关

发包之前要写README.md,确定License 代码许可证,不然无法发npm包

npm发包流程
可以参考这篇 《图文结合简单易学的npm 包的发布流程》,很详细。

文档

发包之后需要告诉用户怎么使用你的组件,一般简单的可以写在README.md即可,但是组件库存在很多组件的时候,最好搭一个有详细的介绍信息的文档网址。这里用了vitepress去搭建文档,然后我们采用md去写使用说明。

pnpm install vitepress -D

然后按照 vitepress文档去走即可。最后即可部署一个静态的文档网站。

文档.png

一些问题与思考

  1. build api的使用,此处略坑,不传参数会默认找vite.config.ts去build,如果传自定义的配置必须要加上configFile: false参数,不然内部会调用mergeConfig(vite.config.ts的配置,你传入的配置)
  2. 第三方依赖的主题样式丢失, 开发环境显示正常,打包后通过examples测试发现css样式丢失


    样式丢失

    查看发现是tdesign采用了css变量的方式,而我们打包的css里面用了这些变量,但是却没有变量对应的声明,解决方法,在需要的组件里面引入变量声明

// 引入组件库主题样式,主要是挂在root下的css变量,组件打包后的style.css需要它
import "tdesign-vue-next/esm/style/index.js";

样式修复
  1. 打包后使用组件时,给组件写class发生覆盖问题,打包的一个组件叫payment-chart, 我想给它写个class去覆盖样式,这么写:

按照我的想法是最终的渲染结果应该是有两个class,一个是我的payment,一个是原组件里面的class,两者共存,结果却是只渲染我写的payment:

payment.png

不加class覆盖的原组件应该是这样子渲染:


不加payment.png

这就有点不合常理了,最终排查到问题是这个payment-chart组件的问题,原组件是这么写的:


payment-chart.png

打包后的createVNode, 注意这里第二个参数里面是className而不是class!这才是罪魁祸首。


createVnode.png

所以解决方案就是这里最好不要写className 直接写class没问题的,只有react才必须用className。


class.png

这样子就能方便给用户去写样式进行覆盖。

todo

  • 规范约束,eslint, commitlint, stylelint, 兜底保证代码质量
  • unit test、e2e test 真正保证代码质量、让项目重构有底气
  • 输出类型声明,让用户引用时有类型提示

你可能感兴趣的:(pnpm+vite+vue3搭建业务组件库踩坑之旅)