使用vue3+vite開發一個仿element ui框架

語言: CN / TW / HK

theme: channing-cyan highlight: an-old-hope


看完這篇文章,你會有以下新的認識: 1. 如何使用vue3+vite封裝外掛併發布到npm 2. 如何構建一個ui框架文件網站 3. 外掛開發中的技巧

前言

在平日的開發中,我們經常使用不同的ui框架,不知道大家有沒有想法自己開發一個自己的ui框架,或許很多人感覺,沒有必要重複造輪子,但是現在前端工程師的要求越來越高,需要的技術棧也越來越多,學習一下這個開發流程和一些解決方案還是很有必要的。而且我覺得,最重要的是,在平時的專案開發中,會有許多ui框架無法覆蓋的元件,這是和這個業務比較繫結的,獨屬於這個業務需求的元件,當這個業務比較大的時候,這個元件就需要有更高的靈活性和易用性,有時候使用現有的ui框架進行二次封裝也具有一定的成本,甚至高過從頭開發,所以在這種情況下,我們就可以把常用的元件,封裝成ui外掛,配合上完整的元件文件,無論是方便以後專案迭代的時候檢視,還是分享給其他人,都是極好的。
下面,仿照element plus官網的樣子,來仿一個ui框架,以此講述開發流程和用到的技術與方案。成品展示:

image.png

image.png

倉庫地址: https://gitee.com/biluo_x/biluo-ui
npm地址:biluo-ui - npm (npmjs.com)

技術棧

  1. vue3 前端主流框架之一,這裡我們使用3.2版本
  2. vite 代替vue-cli的新腳手架
  3. typescript js的超集,提供型別系統
  4. vite-plugin-md vite的md外掛,提供把md檔案當做vue匯入的能力,最厲害的是,也可以在md檔案中使用vue元件
  5. tailwindcss 為了快速得到效果,使用原子類提供樣式
  6. prismjs 在程式碼展示的時候,提供程式碼高亮

    如果沒有使用過tailwindcss 可以看看這篇文章:受夠了重複繁瑣的css?來試試原子類吧 - 掘金 (juejin.cn)

需求分析

我們是仿照element plus來寫的所以,我們可以觀察一下element 的展示情況。

image.png 拋開那些其他的功能,主要部分分為三個,左邊根據元件分類的導航欄,中間的展示文件,以及右邊的文件目錄。
先看左側導航
一個元件對應了一個目錄,而我們需要把同種的目錄分組,比如基礎元件放一項,表單元件放一項等。
再看主體文件
1. 主體文件應該使用markdown編寫,一個元件對應一個md檔案,所以我們需要有在vue中匯入md的功能。 2. 元件有不同的功能,需要提供一個演示框,這個演示框裡面會放不同的元件功能展示,以及固定的檢視程式碼,貼上程式碼,前往倉庫的固定功能。我可以發現這個演示框應該是一個vue元件,所以需要有在md檔案中匯入vue元件的功能 最後看右側的目錄
1. 目錄需要自動提取md檔案中的標題 2. 目錄需要跟著文件滾動而滾動 3. 點選目錄可以跳轉到對應的標題

目錄介紹

image.png 專案使用vite初始化,選擇vue3+ts模板,然後包管理器使用的是yarn。具體初始化就不獻醜了。 除此之外,我這裡加入了eslint+prettier為程式碼格式化,[email protected]/test-utils來提供測試支援(寫了兩三個元件測試就懶得寫了...),這些沒有也不影響開發,這裡提一嘴。 目錄規劃如下:
1. src 和平時的頁面開發一致,這裡存放展示在外的文件頁面,打包成文件網站使用 2. packages 這裡存放我們ui元件相關的程式碼。主要結構如下:

image.png

在components資料夾下編寫ui元件,一個資料夾表示一個元件,元件中,src存放元件檔案,__tests__存放測試程式碼,index.ts 提供預設匯出。當然components資料夾下還有一個index.ts提供統一入口,匯出所有的元件。

元件開發

這裡我們用button元件的開發來展示基礎開發流程,用input元件的開發來講述vue3更好的開發方式。

button元件

button元件的資料夾結構 components ├── button │ ├── __tests__ │ │ ├── button.test.ts // bl-button.vue 測試 │ │ └── buttonGroup.test.ts // bl-button-group.vue 測試 │ └── src │ └── bl-button.vue // button 元件 |__bl-button-group.vue // button 組 ├── index.ts // 模組匯出檔案 |── index.ts // 元件庫匯出檔案 在button資料夾下的index.ts中我們將src下的兩個元件暴露出去: packages/components/button/index.ts ```js import BlButton from './src/bl-button.vue' import BlButtonGroup from './src/bl-button-group.vue' import { App } from 'vue'

export default { install(app: App) { app.component('BlButton', BlButton) app.component('BlButtonGroup', BlButtonGroup) } } export { BlButtonGroup, BlButton } 這裡選擇了兩種匯出,主要是為了能直接全域性註冊的同時,也支援單獨引用。 然後在總的index.ts中全部匯出: `packages/components/index.ts`js import { App } from 'vue' export * from './button' import button from './button' const components = [button] export default { install(app: App) { components.map((item) => item.install(app)) } } ``` 後續如果需要新增新的元件,按這個流程匯入即可。下面讓我們來看一下button元件的具體開發:

```js

這個程式碼看起來不少,實際上很簡單,最多的就是,prop和根據prop對類名進行處理。button的所有樣式都是使用css來控制的。js只在原生屬性上面稍微處理了一下。這個程式碼其實寫的不好,在類名處理哪裡寫了一堆的三元表示式,後來發現element原始碼裡面寫弄了一個hook專門搞這個,我也去整了一個,程式碼很簡單,大概就是根據bool改變類名之類的:ts type namespaceStyle = 'backgroundColor' | 'color' | 'width' | 'height' export const DEFAULT_NAMESPACE = 'bl' export const STATE_PREFIX = 'is'

export const useNamespace = (namespace: string) => { return { b() { return ${DEFAULT_NAMESPACE}-${namespace} }, is(state: boolean, name: string) { return name && state ? ${STATE_PREFIX}-${name} : '' }, m(suffix: string) { if (suffix) { return ${DEFAULT_NAMESPACE}-${namespace}-${suffix} } return '' }, sy(data: string, label: namespaceStyle) { return { [label]: data } as CSSProperties }, is_sy(is: Boolean, one: CSSProperties, two?: CSSProperties) { if (!two) { if (is) return one return {} as CSSProperties } if (is) { return one } else { return two } } } } 有了這個後,後來的類名處理就寫了這樣ts

``` 開發方面都很簡單,就不過多贅述了.

input 元件

這裡為什麼把input元件單獨拿出來說一下呢,因為大家也看到了上面button的程式碼,功能不多,但是程式碼量特別大,而且繁瑣。實際上,vue3的開發方式並不是這樣的,上面的開發把全部都合併到一起了,有點像以前vue2的感覺,我們來看一下input元件。用過element的朋友應該知道,input元件在開啟清除按鈕後,滑鼠滑入按鈕才會顯示,滑出後又會隱藏。這個功能我們要怎麼實現呢,其實很簡單,用一個bool變數,然後監聽滑鼠的滑入和滑出事件嘛。在這裡我們選擇封裝成hook的寫法,其實就是利用閉包 ts export const useMouseEnterLeave = () => { const mouse_is = ref(false) return { mouse_is, enter: () => (mouse_is.value = true), leave: () => (mouse_is.value = false) } } 然後在vue中引用 ts const { mouse_is, enter, leave } = useMouseEnterLeave() 因為vue3把響應式的功能封裝成了ref和reactive這兩個函式,不像以前vue2必須寫在data函式返回值裡面才具備相應監聽,這樣就讓我們開發與封裝更加靈活多變。

路由設計

根據上面的對元件導航欄的分析,我們可以發現,這是由多個型別元件的集合組成的大路由。簡而言之,就是一個一級標題代表的就是該分類下的所有元件。

image.png 原本我是打算把它設計成陣列的,但是考慮到對不同模組的顯示隱藏的控制,最終把它設計為了一個物件,各位可以根據自己的實際情況自行處理。 元件路由的型別如下 ts export interface routerType { title: string routerData: RouteRecordRaw[] } 這是具體設計 /src/router/routerConfig/index.ts ts export const routerDocsComponentConfig = { index: { title: '前言', routerData: beforeComponent }, baseComponents: { title: 'Basic 基礎元件', routerData: baseComponent }, dataShowComponents: { title: 'Data 資料展示', routerData: dataShowComponent }, ... } 基礎路由就是正常vue-router配置的型別 /src/router/routerConfig/base.component.ts

ts // 基礎元件路由 export const baseComponent: RouteRecordRaw[] = [ { path: 'button', meta: { title: 'Button 按鈕' }, component: () => import('../../docs/button/README.md') }, { path: 'layout', meta: { title: 'Layout 佈局' }, component: () => import('../../docs/layout/README.md') }, { path: 'container', meta: { title: 'Container 佈局容器' }, component: () => import('../../docs/container/README.md') }, { path: 'icon', meta: { title: 'Icon 圖示' }, component: () => import('../../docs/icon/README.md') } ] 以基礎元件路由舉例,我們把基礎路由相關的文件全部放在這裡。可以看到這裡引用的元件是一個md檔案,具體操作我們等下會講到。 具體的使用就是在通用路由中配置需要顯示的模組的key. /src/components/doc-component-pag.vue ```ts

``` asideKeys裡面配置了需要顯示的路由模組,可以通過引數的順序和增傷進一步控制導航的顯示。

文件主體

上面我們說到每一個元件路由其實是一個md檔案。要想在vue中正常解析md.我們需要下載一個vite外掛。 js yarn add [email protected]

為什麼使用這個固定版本,因為當時我下載的最新版,有一個bug,就是無法在md文件中匯入vue元件,通過它gitHub上提的issues說這個問題已經被解決,但是npm沒有更新,現在不曉得更新了沒得,但是我們不需要太多功能,這個版本夠用了

接下來我們在vite的配置檔案裡面配置它 ts plugins: [ vue({ include: [/.vue$/, /.md$/] }), vueJsx(), Markdown({ markdownItSetup(md) { // add anchor links to your H[x] tags md.use(require('markdown-it-anchor')) } }) ] 這裡用到了markdown-it-anchor這個外掛,這個外掛的作用是在上面那個外掛生成vue元件時候,把h標籤的內容作為它的id,這樣我們就可以通過id跳轉的方式從目錄跳轉到指定內容了。 如果你使用的是ts,請在環境中提供md支援,將其檔案型別定義為vue元件 ts declare module '*.md' { const Component: ComponentOptions export default Component } 接下來我們就可以愉快的使用vue和md雙向匯入功能了。 vue匯入md就不多說了,直接匯入作為元件就是,在md中使用vue元件的方法,這裡簡單說一下,md中可以用兩種元件. 1. 全域性元件 直接當html標籤使用,可以直接解析 2. 區域性元件,在md檔案中匯入使用,使用方式如下:

image.png 以上,我們就完成了md引入vue元件的操作,接下來我們來開發程式碼展示元件。 image.png 一共三個區域。 1. 展示區:通過slot,展示外部元件。 2. 控制元件去:前往倉庫,一鍵複製,程式碼展示,三個控制元件 3. 程式碼區:獲取展示區傳入的外部元件的程式碼,加上程式碼高亮展示 這個元件本身很簡單,因為使用頻繁,所以我們直接註冊為全域性元件,這樣就可以直接在md檔案中引入,而展示區的程式碼,則通過區域性引入的方式,匯入進行展示。檔案結構如下:

image.png 每一個展示區,對應一個vue檔案,這樣控制粒度更加精細。

程式碼展示

下面我們來看看程式碼展示功能是如何實現的,vite可以通過如這種形式import xx from 'xx?raw'把一個檔案標記為資原始檔,從而獲取檔案的內容,我們可以通過這種形式,獲取展示區的程式碼。但是這種方式只能在開發環境得到支援,所以生產環境需要換成網路請求的方式,具體程式碼如下: /src/components/common/show-code.vue ts onMounted(async () => { const isDev = import.meta.env.MODE === 'development' if (isDev) { /* @vite-ignore */ const data: any = await import(/* @vite-ignore */ `../../docs/${props.showPath}.vue?raw`) sourceCode.value = data.default } else { sourceCode.value = await fetch(`/docs/${props.showPath}.vue`).then((res) => res.text()) } await nextTick(() => { Prism.highlightAll() }) }) 判斷是否是開發環境,選擇靜態資源載入或者網路請求。這裡也可以看到,在開發環境下,我們需要把docs資料夾複製一份到打包後的根路徑。開發到後期經常打包,這樣手動cv實在是太惱火了,這裡寫了一個指令碼,在打包後自己複製過去,用到了copy-dir這個包,需要自行下載 js let copydir = require('copy-dir') copydir.sync( process.cwd() + '/src/docs', process.cwd() + '/BiLuoUiDoc/docs', { utimes: true, mode: true, cover: true }, function (err) { if (err) throw err console.log('done') } ) 使用方式只需要在原本的打包命令後加上,就會自動在打包後執行這個程式碼,node後面是程式碼所在相對路徑。 js && node ./config/copyDocs.js

一鍵複製

一鍵複製功能就比較簡單了,就是把程式碼的內容複製給一個input,進入選擇狀態後控制鍵盤執行copy指令 /src/components/common/show-code.vue ts // 複製程式碼 const copyCode = () => { const input = document.createElement('input') document.body.appendChild(input) input.setAttribute('value', sourceCode.value as string) input.setAttribute('readonly', 'readonly') input.select() input.setSelectionRange(0, 9999) // 如果select 沒有選擇到 if (document.execCommand('copy')) { // console.log('報文已複製到剪下板') BlMessageFn.success!({ message: '成功複製', duration: 2000 }) } document.body.removeChild(input) }

md檔案使用方式

當我們把show-code元件全域性註冊後,就可以在md檔案中使用它了 html <show-code showPath="button/baseButton"> <baseButton></baseButton> </show-code> showPath是展示元件的路徑,以便在展示程式碼的時候,獲取對應的資料。 具體細節請檢視 文件

打包上傳npm

編寫元件打包配置: /config/prod.com.config.ts ```ts import { defineConfig } from 'vite' // import vue from '@vitejs/plugin-vue' import { resolve } from 'path' import baseConfig from './base.config' // 主要用於alias檔案路徑別名

export default defineConfig({ ...baseConfig, // 打包配置 build: { sourcemap: false, //不開啟映象 outDir: 'BiLuoUI', assetsInlineLimit: 8192, // 小於 8kb 的匯入或引用資源將內聯為 base64 編碼 terserOptions: { // 生產環境移除console compress: { drop_console: true, drop_debugger: true } }, lib: { entry: resolve(process.cwd(), './packages/components/index.ts'), // 設定入口檔案 name: 'biluo-ui', // 起個名字,安裝、引入用 fileName: (format) => biluo-ui.${format}.js // 打包後的檔名 }, rollupOptions: { // 確保外部化處理那些你不想打包進庫的依賴 external: ['vue', 'tailwindcss', '@element-plus/icons-vue'], output: { // 在 UMD 構建模式下為這些外部化的依賴提供一個全域性變數 globals: { vue: 'Vue', tailwindcss: 'tailwindcss', '@element-plus/icons-vue': '@element-plus/icons-vue' } } } } }) 配置package.jsonjson { "name": "biluo-ui", "auther": "biluo. Email: [email protected]", "private": false, "version": "1.0.6", "description": "這是一個模仿element ui寫的ui元件。用以練手和學習。", "keyword": "typescript tailwindcss ui element", "files": ["BiLuoUI"], "main": "./BiLuoUI/biluo-ui.es.js", "module": "./BiLuoUI/biluo-ui.es.js", "repository": "https://gitee.com/biluo_x/biluo-ui", ... } 這裡最重要的是這三個欄位,files,main,module - files: 設定你要上傳的目錄,寫上我們打包輸出的目錄 - main: 專案主入口 這裡主要是require引用的入口 - module: 同樣的主入口,這裡是import引入的入口,比如我使用ts import BlUi from 'biluo-ui' `` 預設就是匯入:./BiLuoUI/biluo-ui.es.js`。

因為這是一個ui框架,用不上require匯入,所以我們都寫的一樣的入口檔案。

打包生成BiLuoUI:

image.png 上傳npm: - 登陸 執行npm login命令,系統會提示輸入賬戶和密碼。如果沒有npm賬戶,請註冊 → npm官網 - 釋出 若賬戶登入成功後,就可以再次執行 npm publish 進行釋出 - 注意 1. 每次釋出,都需要更新版本號,否則無法成功上傳 2. 上傳到npm上時,要將package.json中的private屬性值改為false

最後

這裡大概是梳理了一下開發一個開源元件網站的方案和基本流程,希望對有此想法的朋友提供一定的幫助。文章並沒有太過詳細的簡述ui元件的開發,相信這對大家來說都不是什麼問題。如果有什麼其他需要的可以自行檢視本專案倉庫。