前言
隨著前端技術的發展,業界湧現出了許多的UI元件庫。例如我們熟知的ElementUI,Vant,AntDesign等等。但是作為一個前端開發者,你知道一個UI元件庫是如何被打造出來的嗎?
讀完這篇文章你將學會:
- 如何使用pnpm搭建出一個menorepo環境
- 如何使用vite搭建一個基本的Vue3腳手架專案
- 如何開發除錯一個自己的UI元件庫
- 如何使用vite打包併發布自己的UI元件庫
作為一個前端擁有一個屬於自己的UI元件庫是一件非常酷的事情。它不僅能讓我們對元件的原理有更深的理解,還能在找工作的時候為自己增色不少。試問有哪個前端不想擁有一套屬於自己的UI元件庫呢?
本文將使用Vue3和TypeScript來編寫一個元件庫,使用Vite+Vue3來對這個元件庫中的元件進行除錯,最後使用vite來對元件庫進行打包並且釋出到npm上。最終的產物是一個名為kitty-ui的元件庫。
話不多說~ 接下來讓我們開始搭建屬於我們自己的UI元件庫吧
menorepo環境
首先我們要了解什麼是menorepo及它是如何搭建的吧
就是指在一個大的專案倉庫中,管理多個模組/包(package),這種型別的專案大都在專案根目錄下有一個packages資料夾,分多個專案管理。大概結構如下:
-- packages
-- pkg1
--package.json
-- pkg2
--package.json
--package.json
簡單來說就是單倉庫 多專案
目前很多我們熟知的專案都是採用這種模式,如Vant,ElementUI,Vue3等。打造一個menorepo環境的工具有很多,如:lerna、pnpm、yarn等,這裡我們將使用pnpm來開發我們的UI元件庫。
為什麼要使用pnpm?
因為它簡單高效,它沒有太多雜亂的配置,它相比於lerna操作起來方便太多
好了,下面我們就開始用pnpm來進行我們的元件庫搭建吧
使用pnpm
安裝
npm install pnpm -g
初始化package.json
pnpm init
新建配置檔案 .npmrc
shamefully-hoist = true
這裡簡單說下為什麼要配置shamefully-hoist。
如果某些工具僅在根目錄的node_modules時才有效,可以將其設定為true來提升那些不在根目錄的node_modules,就是將你安裝的依賴包的依賴包的依賴包的...都放到同一級別(扁平化)。說白了就是不設定為true有些包就有可能會出問題。
安裝對應依賴
我們開發環境中的依賴一般全部安裝在整個專案根目錄下,方便下面我們每個包都可以引用,所以在安裝的時候需要加個 -w
pnpm i vue@next typescript less -D -w
因為我們開發的是vue3元件, 所以需要安裝vue3,當然ts肯定是必不可少的(當然如果你想要js開發也是可以的,甚至可以省略到很多配置和寫法。但是ts可以為我們元件加上型別,並且使我們的元件有程式碼提示功能,未來ts也將成為主流);less為了我們寫樣式方便,以及使用它的名稱空間(這個暫時這裡沒用到,後面有時間再補
- 配置tsconfit.json
這裡的配置就不細說了,可以自行搜尋都是代表什麼意思。或者你可以先直接複製
npx tsc --init
tsconfig.json:
{
"compilerOptions": {
"baseUrl": ".",
"jsx": "preserve",
"strict": true,
"target": "ES2015",
"module": "ESNext",
"skipLibCheck": true,
"esModuleInterop": true,
"moduleResolution": "Node",
"lib": ["esnext", "dom"]
}
}
monorepo的實現
接下就是pnpm如何實現monorepo的了。
為了我們各個專案之間能夠互相引用我們要新建一個pnpm-workspace.yaml檔案將我們的包關聯起來
packages:
- 'packages/**'
- 'examples'
這樣就能將我們專案下的packages目錄和examples目錄關聯起來了,當然如果你想關聯更多目錄你只需要往裡面新增即可。根據上面的目錄結構很顯然你在根目錄下新packages和examples資料夾,packages資料夾存放我們開發的包,examples用來除錯我們的元件
examples資料夾就是接下來我們要使用vite搭建一個基本的Vue3腳手架專案的地方
手動搭建一個基於vite的vue3專案
其實搭建一個vite+vue3專案是非常容易的,因為vite已經幫我們做了大部分事情
初始化倉庫
進入examples資料夾,執行
pnpm init
安裝vite和@vitejs/plugin-vue
@vitejs/plugin-vue用來支援.vue檔案的轉譯
pnpm install vite @vitejs/plugin-vue -D -w
這裡安裝的外掛都放在根目錄下
配置vite.config.ts
新建vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins:[vue()]
})
新建html檔案
@vitejs/plugin-vue 會預設載入examples下的index.html
新建index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app"></div>
<script src="main.ts" type="module"></script>
</body>
</html>
注意:
vite 是基於esmodule的 所以type="module"
新建app.vue模板
<template>
<div>
啟動測試
</div>
</template>
新建main.ts
import {createApp} from 'vue'
import App from './app.vue'
const app = createApp(App)
app.mount('#app')
此時會發現編譯器會提示個錯誤:找不到模組“./app.vue”或其相應的型別宣告
因為直接引入.vue檔案 TS會找不到對應的型別宣告;所以需要新建typings(命名沒有明確規定,TS會自動尋找.d.ts檔案)資料夾來專門放這些宣告檔案。
typings/vue-shim.d.ts
TypeScriptTS預設只認ES 模組。如果你要匯入.vue檔案就要declare module把他們宣告出來。
declare module '*.vue' {
import type { DefineComponent } from "vue";
const component:DefineComponent<{},{},any>
}
配置指令碼啟動專案
最後在package.json檔案中配置scripts指令碼
...
"scripts": {
"dev": "vite"
},
...
然後終端輸入我們熟悉的命令:pnpm run dev
vite啟動預設埠為3000;在瀏覽器中開啟localhost:3000 就會看我們的“啟動測試”頁面。
本地除錯
新建包檔案
本節可能和目前元件的開發關聯不大,但是未來元件需要引入一些工具方法的時候會用到
接下來就是要往我們的packages資料夾衝填充內容了。
- utils包
一般packages要有utils包來存放我們公共方法,工具函式等
既然它是一個包,所以我們新建utils目錄後就需要初始化它,讓它變成一個包;終端進入utils資料夾執行:pnpm init 然後會生成一個package.json檔案;這裡需要改一下包名,我這裡將name改成@kitty-ui/utils表示這個utils包是屬於kitty-ui這個組織下的。所以記住釋出之前要登入npm新建一個組織;例如kitty-ui
{
"name": "@kitty-ui/utils",
"version": "1.0.0",
"description": "",
"main": "index.ts",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
因為我們使用ts寫的,所以需要將入口檔案index.js改為index.ts,並新建index.ts檔案:(先匯出一個簡單的加法函式)
export const testfun = (a:number,b:number):number=>{
return a + b
}
- 元件庫包(這裡命名為kitty-ui)
components是我們用來存放各種UI元件的包
新建components資料夾並執行 pnpm init 生成package.json
{
"name": "kitty-ui",
"version": "1.0.0",
"description": "",
"main": "index.ts",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
新建index.ts入口檔案並引入utils包
import {testfun} from '@kitty-ui/utils'
const result = testfun (1,1)
console.log(result)
- esno
由於元件庫是基於ts的,所以需要安裝esno來執行ts檔案便於測試元件之間的引入情況
控制檯輸入esno xxx.ts即可執行ts檔案
npm i esno -g
包之間本地除錯
進入components資料夾執行
pnpm install @kitty-ui/utils
你會發現pnpm會自動建立個軟連結直接指向我們的utils包;此時components下的packages:
{
"name": "kitty-ui",
"version": "1.0.0",
"description": "",
"main": "src/index.ts",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@kitty-ui/utils": "workspace:^1.0.1"
}
}
你會發現它的依賴@kitty-ui/utils對應的版本為:workspace:^1.0.0;因為pnpm是由workspace管理的,所以有一個字首workspace可以指向utils下的工作空間從而方便本地除錯各個包直接的關聯引用。
到這裡基本開發方法我們已經知道啦;接下來就要進入正題了,開發一個button元件
試著開發一個button元件
在components資料夾下新建src,同時在src下新建button元件目錄和icon元件目錄(新建icon為了便於除錯);此時components檔案目錄如下
-- components
-- src
-- button
-- icon
-- index.ts
-- package.json
讓我們先測試一下我們的button元件能否在我們搭建的examples下的vue3專案本引用~
首先在button下新建一個簡單的button.vue
<template>
<button>測試按鈕</button>
</template>
然後在button/index.ts將其匯出
import Button from './src/button.vue'
export default Button
因為我們開發元件庫的時候不可能只有button,所以我們需要一個components/index.ts將我們開發的元件一個個的集中匯出
import Button from './button'
export {
Button
}
好了,一個元件的大體目錄差不多就是這樣了,接下來請進入我們的examples來看看能否引入我們的button元件
vue3專案使用button
上面已經說過執行在workspace執行 pnpm i xxx的時候pnpm會自動建立個軟連結直接指向我們的xxx包。
所以這裡我們直接在examples執行:pnpm i kitty-ui
此時你就會發現packages.json的依賴多了個
"kitty-ui": "workspace:^1.0.0"
這時候我們就能直接在我們的測試專案下引入我們本地的components元件庫了,啟動我們的測試專案,來到我們的 examples/app.vue 直接引入Button
<template>
<div>
<Button />
</div>
</template>
<script lang="ts" setup>
import { Button } from 'kitty-ui'
</script>
不出意外的話你的頁面就會展示我們剛剛寫的button元件了
好了萬事具...(其實還差個打包,這個後面再說~);接下來的工作就是專注於元件的開發了;讓我們回到我們的button元件目錄下(測試頁面不用關,此時我們已經可以邊開發邊除錯邊看效果了)
因為我們的button元件是需要接收很多屬性的,如type、size等等,所以我們要新建個types.ts檔案來規範這些屬性
在button目錄下新建types.ts
import { ExtractPropTypes } from 'vue'
export const ButtonType = ['default', 'primary', 'success', 'warning', 'danger']
export const ButtonSize = ['large', 'normal', 'small', 'mini'];
export const buttonProps = {
type: {
type: String,
values: ButtonType
},
size: {
type: String,
values: ButtonSize
}
}
export type ButtonProps = ExtractPropTypes<typeof buttonProps>
TIPS
import type 表示只匯入型別;ExtractPropTypes是vue3中內建的型別宣告,它的作用是接收一個型別,然後把對應的vue3所接收的props型別提供出來,後面有需要可以直接使用
很多時候我們在vue中使用一個元件會用的app.use 將元件掛載到全域性。要使用app.use函式的話我們需要讓我們的每個元件都提供一個install方法,app.use()的時候就會呼叫這個方法;
我們將button/index.ts調整為
import button from './button.vue'
import type {App,Plugin} from "vue"
type SFCWithInstall<T> = T&Plugin
const withInstall = <T>(comp:T) => {
(comp as SFCWithInstall<T>).install = (app:App)=>{
//註冊元件
app.component((comp as any).name,comp)
}
return comp as SFCWithInstall<T>
}
const Button = withInstall(button)
export default Button
此時我們就可以使用app.use來掛載我們的元件啦
其實withInstall方法可以做個公共方法放到工具庫裡,因為後續每個元件都會用到,這裡等後面開發元件的時候再調整
到這裡元件開發的基本配置已經完成,最後我們對我們的元件庫以及工具庫進行打包,打包之前如果要發公共包的話記得將我們的各個包的協議改為MIT開源協議
...
"license": "MIT",
...
vite打包
配置檔案
打包們這裡選擇vite,它有一個庫模式專門為我們來打包這種庫元件的。
前面已經安裝過vite了,所以這裡直接在components下直接新建vite.config.ts(配置引數檔案中已經註釋):
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue"
export default defineConfig(
{
build: {
target: 'modules',
//打包檔案目錄
outDir: "es",
//壓縮
minify: false,
//css分離
//cssCodeSplit: true,
rollupOptions: {
//忽略打包vue檔案
external: ['vue'],
input: ['src/index.ts'],
output: [
{
format: 'es',
//不用打包成.es.js,這裡我們想把它打包成.js
entryFileNames: '[name].js',
//讓打包目錄和我們目錄對應
preserveModules: true,
//配置打包根目錄
dir: 'es',
preserveModulesRoot: 'src'
},
{
format: 'cjs',
entryFileNames: '[name].js',
//讓打包目錄和我們目錄對應
preserveModules: true,
//配置打包根目錄
dir: 'lib',
preserveModulesRoot: 'src'
}
]
},
lib: {
entry: './index.ts',
formats: ['es', 'cjs']
}
},
plugins: [
vue()
]
}
)
這裡我們選擇打包cjs(CommonJS)和esm(ESModule)兩種形式,cjs模式主要用於服務端引用(ssr),而esm就是我們現在經常使用的方式,它本身自帶treeShaking而不需要額外配置按需引入(前提是你將模組分別匯出),非常好用~
其實到這裡就已經可以直接打包了;components下執行: pnpm run build你就會發現打包了es和lib兩個目錄
到這裡其實打包的元件庫只能給js專案使用,在ts專案下執行會出現一些錯誤,而且使用的時候還會失去程式碼提示功能,這樣的話我們就失去了用ts開發元件庫的意義了。所以我們需要在打包的庫里加入宣告檔案(.d.ts)。
那麼如何向打包後的庫里加入宣告檔案呢? 其實很簡單,只需要引入vite-plugin-dts
pnpm i vite-plugin-dts -D -w
然後修改一下我們的vite.config.ts引入這個外掛
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue"
import dts from 'vite-plugin-dts'
export default defineConfig(
{
build: {...},
plugins: [
vue(),
dts({
//指定使用的tsconfig.json為我們整個專案根目錄下掉,如果不配置,你也可以在components下新建tsconfig.json
tsConfigFilePath: '../../tsconfig.json'
}),
//因為這個外掛預設打包到es下,我們想讓lib目錄下也生成宣告檔案需要再配置一個
dts({
outputDir:'lib',
tsConfigFilePath: '../../tsconfig.json'
})
]
}
)
因為這個外掛預設打包到es下,我們想讓lib目錄下也生成宣告檔案需要再配置一個dts外掛,暫時沒有想到其它更好的處理方法~
然後執行打包命令你就會發現你的es和lib下就有了宣告檔案
其實後面就可以進行釋出了,釋出之前更改一下我們components下的package.json如下:
{
"name": "kitty-ui",
"version": "1.0.0",
"main": "lib/index.js",
"module":"es/index.js",
"scripts": {
"build": "vite build"
},
"files": [
"es",
"lib"
],
"keywords": [
"kitty-ui",
"vue3元件庫"
],
"author": "小月",
"license": "MIT",
"description": "",
"typings": "lib/index.d.ts"
}
解釋一下里面部分欄位
pkg.module
我們元件庫預設入口檔案是傳統的CommonJS模組,但是如果你的環境支援ESModule的話,構建工具會優先使用我們的module入口
pkg.files
files是指我們1需要釋出到npm上的目錄,因為不可能components下的所有目錄都被髮布上去
開始釋出
做了那麼多終於到釋出的階段了;其實npm發包是很容易的,就拿我們的元件庫kitty-ui舉例吧
釋出之前記得到npm官網註冊個賬戶,如果你要釋出@xx/xx這種包的話需要在npm新建個組織組織組織名就是@後面的,比如我建的組織就是kitty-ui,註冊完之後你就可以釋出了
首先要將我們程式碼提交到git倉庫,不然pnpm釋出無法通過,後面每次發版記得在對應包下執行 pnpm version patch你就會發現這個包的版本號patch(版本號第三個數) +1 了,同樣的 pnpm version major major和 pnpm version minor 分別對應版本號的第一和第二位增加。
如果你釋出的是公共包的話,在對應包下執行
pnpm publish --access public
輸入你的賬戶和密碼(記得輸入密碼的時候是不顯示的,不要以為卡了)正常情況下應該是釋出成功了
注意
釋出的時候要將npm的源切換到npm的官方地址(https://registry.npmjs.org/); 如果你使用了其它映象源的話
樣式問題
引入我們打包後的元件你會發現沒有樣式,所以你需要在全域性引入我們的style.css才行;如 main.ts中需要
import 'kitty-ui/es/style.css';
很顯然這種元件庫並不是我們想要的,我們需要的元件庫是每個css樣式放在每個元件其對應目錄下,這樣就不需要每次都全量匯入我們的css樣式。
下面就讓我們來看下如何把樣式拆分打包
處理less檔案
首先我們需要做的是將less打包成css然後放到打包後對應的檔案目錄下,我們在components下新建build資料夾來存放我們的一些打包工具,然後新建buildLess.ts,首先我們需要先安裝一些工具cpy和fast-glob
pnpm i cpy fast-glob -D -w
- cpy
它可以直接複製我們規定的檔案並將我們的檔案copy到指定目錄,比如buildLess.ts:
import cpy from 'cpy'
import { resolve } from 'path'
const sourceDir = resolve(__dirname, '../src')
//lib檔案
const targetLib = resolve(__dirname, '../lib')
//es檔案
const targetEs = resolve(__dirname, '../es')
console.log(sourceDir);
const buildLess = async () => {
await cpy(`${sourceDir}/**/*.less`, targetLib)
await cpy(`${sourceDir}/**/*.less`, targetEs)
}
buildLess()
然後在package.json中新增命令
...
"scripts": {
"build": "vite build",
"build:less": "esno build/buildLess"
},
...
終端執行 pnpm run build:less 你就會發現lib和es檔案對應目錄下就出現了less檔案.
但是我們最終要的並不是less檔案而是css檔案,所以我們要將less打包成css,所以我們需要用的less模組.在ts中引入less因為它本身沒有宣告檔案所以會出現型別錯誤,所以我們要先安裝它的 @types/less
pnpm i --save-dev @types/less -D -w
buildLess.ts如下(詳細註釋都在程式碼中)
import cpy from 'cpy'
import { resolve } from 'path'
import { promises as fs } from "fs"
import less from "less"
import glob from "fast-glob"
const sourceDir = resolve(__dirname, '../src')
//lib檔案目錄
const targetLib = resolve(__dirname, '../lib')
//es檔案目錄
const targetEs = resolve(__dirname, '../es')
const buildLess = async () => {
//直接將less檔案複製到打包後目錄
await cpy(`${sourceDir}/**/*.less`, targetLib)
await cpy(`${sourceDir}/**/*.less`, targetEs)
//獲取打包後.less檔案目錄(lib和es一樣)
const lessFils = await glob("**/*.less", { cwd: targetLib, onlyFiles: true })
//遍歷含有less的目錄
for (let path in lessFils) {
const lessPathLib = `${targetLib}/${lessFils[path]}`
const lessPathEs = `${targetEs}/${lessFils[path]}`
//獲取less檔案字串
const lessCode = await fs.readFile(lessPathLib, 'utf-8')
//將less解析成css
const code = await less.render(lessCode)
//拿到.css字尾path
const cssPathLib = lessPathLib.replace('.less', '.css')
const cssPathEs = lessPathEs.replace('.less', '.css')
//將css寫入對應目錄
await fs.writeFile(cssPathLib, code.css)
await fs.writeFile(cssPathEs, code.css)
}
}
buildLess()
執行打包命令之後你會發現對應資料夾下多了.css檔案
現在我已經將css檔案放入對應的目錄下了,但是我們的相關元件並沒有引入這個css檔案;所以我們需要的是每個打包後元件的index.js中出現如:
import "xxx/xxx.css"
之類的程式碼我們的css才會生效;所以我們需要對vite.config.ts進行相關配置
首先我們先將.less檔案忽略external: ['vue', /.less/],這時候打包後的檔案中如button/index.js就會出現
import "./style/index.less";
然後我們再將打包後程式碼的.less換成.css就大功告成了
...
plugins: [
...
{
name: 'style',
generateBundle(config, bundle) {
//這裡可以獲取打包後的檔案目錄以及程式碼code
const keys = Object.keys(bundle)
for (const key of keys) {
const bundler: any = bundle[key as any]
//rollup內建方法,將所有輸出檔案code中的.less換成.css,因為我們當時沒有打包less檔案
this.emitFile({
type: 'asset',
fileName: key,//檔名名不變
source: bundler.code.replace(/\.less/g, '.css')
})
}
}
}
...
]
...
我們最終的vite.config.ts如下
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue"
import dts from 'vite-plugin-dts'
export default defineConfig(
{
build: {
target: 'modules',
//打包檔案目錄
outDir: "es",
//壓縮
minify: false,
//css分離
//cssCodeSplit: true,
rollupOptions: {
//忽略打包vue和.less檔案
external: ['vue', /\.less/],
input: ['src/index.ts'],
output: [
{
format: 'es',
//不用打包成.es.js,這裡我們想把它打包成.js
entryFileNames: '[name].js',
//讓打包目錄和我們目錄對應
preserveModules: true,
//配置打包根目錄
dir: 'es',
preserveModulesRoot: 'src'
},
{
format: 'cjs',
//不用打包成.mjs
entryFileNames: '[name].js',
//讓打包目錄和我們目錄對應
preserveModules: true,
//配置打包根目錄
dir: 'lib',
preserveModulesRoot: 'src'
}
]
},
lib: {
entry: './index.ts',
formats: ['es', 'cjs']
}
},
plugins: [
vue(),
dts({
//指定使用的tsconfig.json為我們整個專案根目錄下掉,如果不配置,你也可以在components下新建tsconfig.json
tsConfigFilePath: '../../tsconfig.json'
}),
//因為這個外掛預設打包到es下,我們想讓lib目錄下也生成宣告檔案需要再配置一個
dts({
outputDir: 'lib',
tsConfigFilePath: '../../tsconfig.json'
}),
{
name: 'style',
generateBundle(config, bundle) {
//這裡可以獲取打包後的檔案目錄以及程式碼code
const keys = Object.keys(bundle)
for (const key of keys) {
const bundler: any = bundle[key as any]
//rollup內建方法,將所有輸出檔案code中的.less換成.css,因為我們當時沒有打包less檔案
this.emitFile({
type: 'asset',
fileName: key,//檔名名不變
source: bundler.code.replace(/\.less/g, '.css')
})
}
}
}
]
}
)
最後我們將打包less與打包元件合成一個命令(package.json):
...
"scripts": {
"build": "vite build & esno build/buildLess"
},
...
後續直接執行pnpm run build 即可完成所有打包啦
直接使用
如果你不想一步步的搭建,想直接使用現成的話,你可以直接把專案clone下來-> kittyui,然後你只需要以下幾步便可將其完成
- 安裝pnpm
npm i pnpm -g - 安裝esno
npm i esno -g - 安裝所有依賴
pnpm install - 本地測試
進入examples資料夾執行 pnpm run dev 啟動vue3專案 - 打包
pnpm run build
寫在最後
由於作者水平有限,難免會存在一些錯誤或不妥之處,希望各位能夠不吝指出,一定及時修改。如果你對這個專案有更好的想法或者建議也歡迎在評論區提出,不勝感激。
後續我會對一些常用元件進行開發,每個元件的開發都會以文章的形式展現出來以供大家參考。也歡迎大家將專案fork下來,提交自己元件或者對kittyui的修改到kittyui~
創作不易,你的點贊就是我的動力!如果感覺對自己有幫助的話就請點個贊吧,感謝~
我正在參與掘金技術社群創作者簽約計劃招募活動,點選連結報名投稿。