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<word.length; i++) {
    let item = word[i]
    let lower =  item.toLowerCase() // 小写
    let upper = item.toUpperCase() // 大写
    if (i === 0) {
      s += lower;
      continue;
    } else if (item === upper) {
      s += `-${lower}`
      continue;
    } else {
      s += lower;
    }
  }
  return s
}
const buildAll = async () => {
  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去覆盖样式,这么写:
<template>
  <paymeny-chart class="payment" />
</template>

按照我的想法是最终的渲染结果应该是有两个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 真正保证代码质量、让项目重构有底气
  • 输出类型声明,让用户引用时有类型提示
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容