使用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組件的開發,相信這對大家來説都不是什麼問題。如果有什麼其他需要的可以自行查看本項目倉庫。