前言
最近公司需要把一些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
、.jsx
和tsx
模块进行转译 -
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之后,浏览器会自动打开产物分析页面。
当然这个插件只是在调试打包产物的时候才加上,一般要打包发布npm的时候不用加上这个插件。
打包后输出的文件如下:
打包之后,怎么验证我们的包能不能正常显示?最简单的方式就是有测试用例跑一遍,可惜目前还没有,退而其次我们可以创建一个examples目录,把开发环境的调试代码挪过来,区别只是把引入的组件改为引入打包后的组件。当然我们也要有启动一个服务预览这些效果,所以我们希望也有一个命令类似启动开发环境一样,
npm run examples
启动服务查看效果。
这个命令的主要做两件事,执行scripts/examples.ts脚本 把dist目录拷过来到examples目录,然后启动新的vite服务预览效果
"scripts": {
"examples": "esno ./scripts/examples.ts & cd examples & vite"
}
examples目录结构如下
发包相关
发包之前要写README.md,确定License 代码许可证,不然无法发npm包
npm发包流程
可以参考这篇 《图文结合简单易学的npm 包的发布流程》,很详细。
文档
发包之后需要告诉用户怎么使用你的组件,一般简单的可以写在README.md即可,但是组件库存在很多组件的时候,最好搭一个有详细的介绍信息的文档网址。这里用了vitepress去搭建文档,然后我们采用md去写使用说明。
pnpm install vitepress -D
然后按照 vitepress文档去走即可。最后即可部署一个静态的文档网站。
一些问题与思考
- build api的使用,此处略坑,不传参数会默认找vite.config.ts去build,如果传自定义的配置必须要加上
configFile: false
参数,不然内部会调用mergeConfig(vite.config.ts的配置,你传入的配置)
-
第三方依赖的主题样式丢失, 开发环境显示正常,打包后通过examples测试发现css样式丢失
查看发现是tdesign采用了css变量的方式,而我们打包的css里面用了这些变量,但是却没有变量对应的声明,解决方法,在需要的组件里面引入变量声明
// 引入组件库主题样式,主要是挂在root下的css变量,组件打包后的style.css需要它
import "tdesign-vue-next/esm/style/index.js";
- 打包后使用组件时,给组件写class发生覆盖问题,打包的一个组件叫payment-chart, 我想给它写个class去覆盖样式,这么写:
按照我的想法是最终的渲染结果应该是有两个class,一个是我的payment
,一个是原组件里面的class,两者共存,结果却是只渲染我写的payment
:
不加class覆盖的原组件应该是这样子渲染:
这就有点不合常理了,最终排查到问题是这个payment-chart组件的问题,原组件是这么写的:
打包后的createVNode, 注意这里第二个参数里面是className而不是class!这才是罪魁祸首。
所以解决方案就是这里最好不要写className 直接写class没问题的,只有react才必须用className。
这样子就能方便给用户去写样式进行覆盖。
todo
- 规范约束,eslint, commitlint, stylelint, 兜底保证代码质量
- unit test、e2e test 真正保证代码质量、让项目重构有底气
- 输出类型声明,让用户引用时有类型提示