前言
最近公司需要把一些vue3編寫(xiě)的業(yè)務(wù)組件抽離出來(lái),方便有需要的人使用,經(jīng)商量后決定要采用發(fā)npm包的方式。鑒于這些業(yè)務(wù)組件之前就是基于vite構(gòu)建環(huán)境開(kāi)發(fā)的,于是我簡(jiǎn)單調(diào)研后決定采用最新的pnpm + vite3搭建這個(gè)vue3業(yè)務(wù)組件庫(kù)。
技術(shù)棧
pnpm
pnpm是一個(gè)高性能包管理器,具有下載速度快、磁盤(pán)占用空間小、依賴(lài)管理清晰等特點(diǎn),性能比 npm \ yarn要好,解決了npm 、yarn的一些問(wèn)題如 幽靈依賴(lài)、重復(fù)下載;且它天然支持monorepo多包管理方式,不失為一個(gè)更好的選擇。同時(shí)目前不少優(yōu)秀庫(kù)都遷移到了pnpm管理,如vue3、vite、element-plus。
vite
vite是新一代構(gòu)建工具,以預(yù)構(gòu)建及按需編譯模塊的方式達(dá)到快速啟動(dòng)開(kāi)發(fā)環(huán)境而出名。同時(shí)它內(nèi)部?jī)?nèi)置很多插件支持開(kāi)箱即用去搭建常規(guī)的web項(xiàng)目。如下內(nèi)置插件
-
vite:esbuild實(shí)現(xiàn)了代替?zhèn)鹘y(tǒng)的 Babel 或者 TSC來(lái)對(duì).js、.ts、.jsx和tsx模塊進(jìn)行轉(zhuǎn)譯 -
vite:css處理樣式包括CSS 預(yù)處理器、CSS Modules、Postcss 的編譯; -
vite:json加載json -
vite:wasm用來(lái)加載 .wasm -
vite:asset處理靜態(tài)資源(圖片、字體、多媒體資源等)的加載 -
vite:worker內(nèi)部采用Rollup 對(duì)web weorker腳本進(jìn)行打包
組件支持 .vue .jsx .tsx編寫(xiě)
由于我們的業(yè)務(wù)組件支持.vue .jsx .tsx的編寫(xiě)方式,vite同樣提供相關(guān)插件安裝
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(),
]
確定輸出目標(biāo)
作為一個(gè)業(yè)務(wù)組件庫(kù),我們提供組件,用戶(hù)安裝使用我們的組件,那么用戶(hù)期待的是什么格式的組件呢?參考平時(shí)使用的組件庫(kù),如tdesign我們可以發(fā)現(xiàn)它打包了好幾種格式的包,如下所示:
├─ 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
這種方式是比較常見(jiàn)的做法。
還有一種就是直接導(dǎo)出src目錄下的組件源碼,不進(jìn)行任何打包
兩種方式的優(yōu)缺點(diǎn)對(duì)比:
通過(guò)打包輸出各種格式的組件的
優(yōu)點(diǎn):方便用戶(hù)直接引入使用,作者可以掌握要輸出哪些組件、哪種格式的產(chǎn)物
缺點(diǎn):組件庫(kù)需要開(kāi)發(fā)維護(hù)構(gòu)建腳本直接導(dǎo)出源碼
優(yōu)點(diǎn):組件作者不需要寫(xiě)構(gòu)建腳本,所有源碼導(dǎo)出即可,用戶(hù)要啥就引啥
缺點(diǎn):用戶(hù)要使用我們的組件的時(shí)候可能要為了適配要改動(dòng)項(xiàng)目基礎(chǔ)配置
經(jīng)過(guò)對(duì)比,最后采用打包構(gòu)建的方式,輸出 es\umd格式產(chǎn)物
確定了輸出產(chǎn)物的格式,同樣還有一個(gè)問(wèn)題需要確定,是否支持用戶(hù)全量引入或者按需引入?
毫無(wú)疑問(wèn)我們都要支持,那么目前組件的按需引入有兩個(gè)方法:
- 組件單獨(dú)分包 + 用戶(hù)按需導(dǎo)入 + 插件babel-plugin-component來(lái)改寫(xiě)導(dǎo)入路徑達(dá)到按需引入的目的
- ESModule + Treeshaking + 自動(dòng)按需 import(unplugin-vue-components 自動(dòng)化配置)
這兩種方法都是有一套約定的規(guī)則的,要統(tǒng)一劃分打包后的組件文件夾、樣式等,不過(guò)無(wú)論哪種方式的按需導(dǎo)入,其底層思想都是通過(guò)分析ast給你自動(dòng)重寫(xiě)這些引入路徑,如下例子
// 你寫(xiě)的代碼
import { ElButton } from 'element-plus'
// 工具轉(zhuǎn)換后的代碼 ↓ ↓ ↓ ↓ ↓ ↓
import { ElButton } from 'element-plus'
import 'element-plus/es/components/button/style/css'
約定優(yōu)先
既然確定輸出目標(biāo)了,同時(shí)為了方便構(gòu)建腳本的編寫(xiě)支持按需導(dǎo)入,我們就要做出一些約定:
- src/lib目錄下面按組件名劃分組件文件夾,每個(gè)組件文件夾統(tǒng)一采用index.ts進(jìn)行導(dǎo)出
- 全量包引入src/lib下的所有組件,再統(tǒng)一以插件方式默認(rèn)導(dǎo)出組件庫(kù)
- 每個(gè)組件都有獨(dú)立的 package.json 定義,包含 esm 和 umd 的入口定義
- 每個(gè)組件支持以 Vue 插件形式進(jìn)行加載
- 每個(gè)組件還需要有單獨(dú)的 css 導(dǎo)出
下面是一個(gè)矩陣圖組件的導(dǎo)出案例:
src/lib/Matrix/index
import Matrix from './components/data-matrix/index.vue'
import { App, Component } from "vue";
// 導(dǎo)出Matrix組件和工具函數(shù)
export { Matrix };
// 導(dǎo)出Vue插件
export default {
install(app: App) {
app.component((Matrix as Component).name , Matrix);
}
};
其中默認(rèn)導(dǎo)出export default 是以Vue插件方式導(dǎo)出,適合用戶(hù)全局引入,而 export{ Matrix } 導(dǎo)出是為了支持按需導(dǎo)入
entry.ts
import { App, Component } from "vue";
import { Matrix } from "./lib/Matrix/index";
// 導(dǎo)出單獨(dú)組件
export {
Matrix,
};
// 編寫(xiě)一個(gè)插件,實(shí)現(xiàn)一個(gè)install方法
export default {
install(app: App): void {
app.component((Matrix as Component).name, Matrix);
}
};
構(gòu)建腳本
按照約定的目錄,就可以寫(xiě)構(gòu)建腳本,實(shí)現(xiàn)全量打包與按需打包,我期待的輸出目錄結(jié)構(gòu)如下:
├─ 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
這里不以打包格式作為目錄,目錄下再組織各個(gè)組件的方式,而是直接用包名作為目錄,目錄下包含各個(gè)格式的產(chǎn)物的方式。
從這個(gè)結(jié)構(gòu)來(lái)看,我們需要打全量包配一個(gè)entry,然后每個(gè)獨(dú)立組件包有一個(gè)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, // 輸出單獨(dú) sourcemap文件
lib: {
entry: "./src/entry.ts",
name: "SutpcChartsUtils",
fileName: "sutpc-charts-utils",
formats: ["es", "umd"], // 導(dǎo)出模塊類(lèi)型
},
outDir: "./dist",
}
};
return config;
};
export default defineConfig(getConfig());
主要代碼在lib配置那里
構(gòu)建命令 build 是全量包與獨(dú)立組件包一起打
"scripts": {
"dev": "vite --open",
"build": "npm run build:components",
"build-all": "vite build",
"build:components": "esno ./scripts/build.ts"
}
對(duì)應(yīng)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() // 小寫(xiě)
let upper = item.toUpperCase() // 大寫(xiě)
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('開(kāi)始打全量包')
await build();
console.log('結(jié)束打全量包')
// 復(fù)制 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, // 導(dǎo)出模塊名
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('開(kāi)始打獨(dú)立組件包')
await Promise.all(buildBundleList);
console.log('結(jié)束打獨(dú)立組件包')
console.log('開(kāi)始生成獨(dú)立組件包的package.json')
const buildPackageJsonList = buildList.map(item => item.buildPackageJson())
await Promise.all(buildPackageJsonList);
console.log('結(jié)束生成獨(dú)立組件包的package.json')
};
buildAll();
bundle分析報(bào)告與產(chǎn)物測(cè)試examples
采用rollup-plugin-visualizer來(lái)進(jìn)行分析打包后的文件包含哪些模塊,方便調(diào)整體積優(yōu)化。
import { defineConfig } from "vite";
import { visualizer } from "rollup-plugin-visualizer";
export default defineConfig({
plugins: [
visualizer({
// 打包完成后自動(dòng)打開(kāi)瀏覽器,顯示產(chǎn)物體積報(bào)告
open: true,
}),
],
});
當(dāng)執(zhí)行npm run build-all之后,瀏覽器會(huì)自動(dòng)打開(kāi)產(chǎn)物分析頁(yè)面。

當(dāng)然這個(gè)插件只是在調(diào)試打包產(chǎn)物的時(shí)候才加上,一般要打包發(fā)布npm的時(shí)候不用加上這個(gè)插件。
打包后輸出的文件如下:

打包之后,怎么驗(yàn)證我們的包能不能正常顯示?最簡(jiǎn)單的方式就是有測(cè)試用例跑一遍,可惜目前還沒(méi)有,退而其次我們可以創(chuàng)建一個(gè)examples目錄,把開(kāi)發(fā)環(huán)境的調(diào)試代碼挪過(guò)來(lái),區(qū)別只是把引入的組件改為引入打包后的組件。當(dāng)然我們也要有啟動(dòng)一個(gè)服務(wù)預(yù)覽這些效果,所以我們希望也有一個(gè)命令類(lèi)似啟動(dòng)開(kāi)發(fā)環(huán)境一樣,
npm run examples 啟動(dòng)服務(wù)查看效果。
這個(gè)命令的主要做兩件事,執(zhí)行scripts/examples.ts腳本 把dist目錄拷過(guò)來(lái)到examples目錄,然后啟動(dòng)新的vite服務(wù)預(yù)覽效果
"scripts": {
"examples": "esno ./scripts/examples.ts & cd examples & vite"
}
examples目錄結(jié)構(gòu)如下

發(fā)包相關(guān)
發(fā)包之前要寫(xiě)README.md,確定License 代碼許可證,不然無(wú)法發(fā)npm包
npm發(fā)包流程
可以參考這篇 《圖文結(jié)合簡(jiǎn)單易學(xué)的npm 包的發(fā)布流程》,很詳細(xì)。
文檔
發(fā)包之后需要告訴用戶(hù)怎么使用你的組件,一般簡(jiǎn)單的可以寫(xiě)在README.md即可,但是組件庫(kù)存在很多組件的時(shí)候,最好搭一個(gè)有詳細(xì)的介紹信息的文檔網(wǎng)址。這里用了vitepress去搭建文檔,然后我們采用md去寫(xiě)使用說(shuō)明。
pnpm install vitepress -D
然后按照 vitepress文檔去走即可。最后即可部署一個(gè)靜態(tài)的文檔網(wǎng)站。

一些問(wèn)題與思考
- build api的使用,此處略坑,不傳參數(shù)會(huì)默認(rèn)找vite.config.ts去build,如果傳自定義的配置必須要加上
configFile: false參數(shù),不然內(nèi)部會(huì)調(diào)用mergeConfig(vite.config.ts的配置,你傳入的配置) -
第三方依賴(lài)的主題樣式丟失, 開(kāi)發(fā)環(huán)境顯示正常,打包后通過(guò)examples測(cè)試發(fā)現(xiàn)css樣式丟失
樣式丟失
查看發(fā)現(xiàn)是tdesign采用了css變量的方式,而我們打包的css里面用了這些變量,但是卻沒(méi)有變量對(duì)應(yīng)的聲明,解決方法,在需要的組件里面引入變量聲明
// 引入組件庫(kù)主題樣式,主要是掛在root下的css變量,組件打包后的style.css需要它
import "tdesign-vue-next/esm/style/index.js";

- 打包后使用組件時(shí),給組件寫(xiě)class發(fā)生覆蓋問(wèn)題,打包的一個(gè)組件叫payment-chart, 我想給它寫(xiě)個(gè)class去覆蓋樣式,這么寫(xiě):
<template>
<paymeny-chart class="payment" />
</template>
按照我的想法是最終的渲染結(jié)果應(yīng)該是有兩個(gè)class,一個(gè)是我的payment,一個(gè)是原組件里面的class,兩者共存,結(jié)果卻是只渲染我寫(xiě)的payment:

不加class覆蓋的原組件應(yīng)該是這樣子渲染:

這就有點(diǎn)不合常理了,最終排查到問(wèn)題是這個(gè)payment-chart組件的問(wèn)題,原組件是這么寫(xiě)的:

打包后的createVNode, 注意這里第二個(gè)參數(shù)里面是className而不是class!這才是罪魁禍?zhǔn)住?/p>

所以解決方案就是這里最好不要寫(xiě)className 直接寫(xiě)class沒(méi)問(wèn)題的,只有react才必須用className。

這樣子就能方便給用戶(hù)去寫(xiě)樣式進(jìn)行覆蓋。
todo
- 規(guī)范約束,eslint, commitlint, stylelint, 兜底保證代碼質(zhì)量
- unit test、e2e test 真正保證代碼質(zhì)量、讓項(xiàng)目重構(gòu)有底氣
- 輸出類(lèi)型聲明,讓用戶(hù)引用時(shí)有類(lèi)型提示
