從零搭建基於 Vue 3.x + ElementPlus 的組件庫


前言

由於最近在我司開發中開啟了Vue3的重構工作。於是乎,Vue組件庫的抽離工作開啟,此次算是基於 vue3.x + element-plus 的二次封裝,封裝常見通用組件。一年前做了 react+antd的組件庫 的整合及示例,一年后再看該方案還有諸多缺陷,發現了很多新的東西可以用,在本次的Vue組件庫技術選型中調研了更多的方式方法,最終選型不代表是最好的,但一定是現階段我認為最合適該場景的技術選型,實際上寫組件庫並不復雜,技術調研和技術選型的整個流程下來,算是對各種方法方法及其優缺點有了一個稍微寬泛的認識,本文目的是記錄下本次選型到最終成品的全過程。基礎配置不在贅述,文章只大致記錄大致的開發框架以及打包和單測,僅供參考。
不想看文章只想看代碼的點這里 想先看下交互文檔效果的點這里

目的

為什么要做組件庫?這個問題也是老生常談了,PC端后台xx管理系統這種場景下,通用的東西很容易抽象出來,這樣就不需要每開一個新項目就在項目里寫一套基礎組件了,在可直接使用基礎組件的基礎上開發能省不少時間,本次只對集成度和通用度較高的幾個組件進行抽離出幾個 npm 包,先實現常見通用功能,后續擴展則可以在不改變原來架構的基礎上進行添加功能。這些組件包括通用的布局組件、通用的表格組件、通用的表單組件、通用的文件圖片上傳組件等。

尤其是表格和表單組件,直接在頁面中使用的話會出現很多重復代碼,寫起來代碼冗余多,所以一般項目里就會封裝這些基礎組件來實現通用,想要實現的目的就是只需要通過不同的配置就能在頁面中通用,一般這些其實都大同小異,這也是想要開發這樣基礎組件庫的初衷,不必要在每個項目里都寫一套,直接將組建庫丟到 npm 管理已經后續升級,這樣一來就能慢慢的沉淀出公司自己特有的基礎和業務組件庫,后續新項目開箱即用。

開始之前

開始之前,除了定好技術棧 Vue 3.x + ElementPlus 外,需要明確組件庫的開發原則:簡潔、高效、靈活、可擴展。
首先要有可讀性好的文檔庫,有示例可交互;其次能自動化的重復工作絕不手動復制,文檔庫自動化部署;最后,最好還有組件測試來保證組件的正確性和完成性。

項目結構

├── docs                                     /* 組件庫文檔 */
│   .vuepress                                  /* vuepress 配置 */
│   ├── clientAppEnhance.ts         /* 注冊全局組件 */
│   ├── config.ts                         /* vuepress配置文件 */
│   index.md                                   /* 文檔 */
└── packages                              /* 包 */
│   ├── layout                             /* 布局組件 */
│   │   ├── src                           /* vue組件 */
│   │   ├── package.json             /* 組件配置文件 */
│   │   ├── typings                     /* 組件聲明文件 */
│   ├── form                               /* 表單組件 */
│   ├── table                              /* 表格組件 */
├── templates                             /* plop 配置clone的模板文件夾 */
├── typings                                /* 聲明文件夾 */
├── .eslintrc.js                            /* eslint 配置 */
├── .gitignore                             /* gitignore 配置 */
├── .prettierrc                            /* prettier 配置 */
├── .stylelintrc                            /* stylelint 配置 */
├── babel.config.js                      /* babel 配置 */
├── jest.config.js                        /* jest 配置 */
├── LICENSE                             /* license */
├── package.json                       /* package.json */
├── plopfile.js                            /* plop 配置 */
├── tsconfig.json                       /* ts 配置 */
├── rollup.config.js                    /* rollup 打包配置 */
└── README.md                      /* 文檔說明文件 */

包管理模式

由於是組件庫,多個組件包會有共用的依賴,為減少重復代碼,因此選用 lerna + yarn workspace 來進行包管理,這也是現如今大多數組件庫的選擇。

組件打包

組件打包選用 rollup,因為本次的組件是針對幾個通用場景來封裝組件,打算分開包來進行管理,rollup 打包能打包多種模式的包 esm, cjs, umd 等等,並且esm自帶 tree-shaking,打出來的包語義明確,也比較易於調試。
rollup 打包配置文件放到了最外層,對組件的打包進行統一配置

下面的配置有幾個關鍵點:

  1. 多入口,每個組件分開打包,並且分別打包出 umd 格式的 index.js 文件以及 esm 格式的 index.module.js 文件
  2. babel 配置的時候需要手動添加 .ts 和 .vue 的擴展名來正常的編譯 ts 和 vue 文件
  3. 每個包下的 package.json 聲明 main module 和 typings ,當支持 esm 方式加載的時候回默認加載 index.module.js,否則加載 index.js
  4. 配置的時候將 peerDependencies 添加到 external 配置項中,將peerDependencies的包不打包進去,減小包體積,提高打包效率
  5. esm 支持 tree-shaking,故css不分開打包,這樣直接使用 esm 格式就會按需加載,無需借助插件

rollup.config.js

/* eslint-disable @typescript-eslint/no-var-requires */
import fs from 'fs'
import path from 'path'
import json from '@rollup/plugin-json'
import postcss from 'rollup-plugin-postcss'
import vue from '@vitejs/plugin-vue'
import { terser } from 'rollup-plugin-terser'
import { nodeResolve } from '@rollup/plugin-node-resolve'
import typescript from '@rollup/plugin-typescript'
import babel from '@rollup/plugin-babel'
import commonjs from '@rollup/plugin-commonjs'
import { DEFAULT_EXTENSIONS } from '@babel/core'

const isDev = process.env.NODE_ENV !== 'production'
// packages 文件夾路徑
const root = path.resolve(__dirname, 'packages')

// 公共插件配置
const getPlugins = () => {
    return [
        vue(),
        typescript({
            tsconfig: './tsconfig.json'
        }),
        nodeResolve({
            mainField: ['jsnext:main', 'browser', 'module', 'main'],
            browser: true
        }),
        commonjs(),
        json(),
        postcss({
            plugins: [require('autoprefixer')],
            // 把 css 插入到 style 中
            inject: true,
            // 把 css 放到和js同一目錄
            // extract: true
            // Minimize CSS, boolean or options for cssnano.
            minimize: !isDev,
            // Enable sourceMap.
            sourceMap: isDev,
            // This plugin will process files ending with these extensions and the extensions supported by custom loaders.
            extensions: ['.sass', '.less', '.scss', '.css']
        }),
        babel({
            exclude: 'node_modules/**',
            babelHelpers: 'runtime',
            // babel 默認不支持 ts 需要手動添加
            extensions: [...DEFAULT_EXTENSIONS, '.ts', '.tsx', '.vue']
        }),
        // 如果不是開發環境,開啟壓縮
        !isDev && terser({ toplevel: true })
    ]
}

module.exports = fs
    .readdirSync(root)
    // 過濾,只保留文件夾
    .filter(item => fs.statSync(path.resolve(root, item)).isDirectory())
    // 為每一個文件夾創建對應的配置
    .map(item => {
        const pkg = require(path.resolve(root, item, 'package.json'))
        return {
            input: path.resolve(root, item, 'src/main.ts'),
            output: [
                {
                    name: 'index',
                    file: path.resolve(root, item, pkg.main),
                    format: 'umd',
                    sourcemap: isDev,
                    globals: {
                        vue: 'vue',
                        'element-plus': 'element-plus'
                    }
                },
                {
                    name: 'index.module',
                    file: path.join(root, item, pkg.module),
                    format: 'es',
                    sourcemap: isDev,
                    globals: {
                        vue: 'vue',
                        'element-plus': 'element-plus'
                    }
                }
            ],
            onwarn: function (warning) {
                if (warning.code === 'THIS_IS_UNDEFINED' || warning.code === 'CIRCULAR_DEPENDENCY') {
                    return
                }
                console.error(`(!) ${warning.message}`)
            },
            plugins: getPlugins(),
            external: Object.keys(require(path.join(root, item, 'package.json'))?.peerDependencies || {})
        }
    })

組件庫文檔

sum-ui 組件庫文檔
本次對比之前的文檔庫考慮上有所不同,用的是 vuepress ,選它的原因之一是頁面簡潔靈活,利用插件不僅可以配置組件交互說明,還能配置其他說明引導文檔,之前考慮了 vue-styleguidst ,但是其局限性比較強,只能配置組件交互文檔並且頁面樣式沒有vuepress 的簡潔好看,還調研了 vitepress ,但因為 vitepress 還一直在 WIP 並且把 vuepress 里的 plugins 等多項配置去掉了,如果是純說明文檔用這個完全夠,但我們需要有組件交互說明,因而最終還是選擇了支持Vue3的 vuepress@next。

vuepress 打包

除了webpack,vuepress@next 還添加了 vite 開發打包的方式,可以在 .vuepress/config.ts 下進行配置

下面的配置有幾個關鍵點:

  1. 讀取packges文件夾下的文件夾名,給引用的包添加 alias 別名
  2. 由於組件里支持了jsx語法,所以添加了 @vitejs/plugin-vue-jsx 插件
  3. bundler 的配置(@vuepress/webpack / @vuepress/vite ),如果不設置則默認 webpack, 如果安裝了 vuepress-vite 則默認vite打包
  4. 添加 vuepress 插件 vuepress-plugin-demoblock-plus ,該插件參照了 element-plus 的文檔渲染實現做了交互組件渲染
  5. 由於使用了 GitHub Actions 自動化部署文檔到 GitHub pages, 所以 base 選項的配置需要和github的項目名保持一致,因為加載的靜態資源路徑是該文件夾下的

.vuepress/config.js

const { readdirSync } = require('fs')
const { join } = require('path')
const chalk = require('chalk')
const headPkgList = []; // 非 @sum-ui/開頭的組件

const pkgList = readdirSync(join(__dirname, '../../packages')).filter(
  (pkg) => pkg.charAt(0) !== '.' && !headPkgList.includes(pkg),
);

const alias = pkgList.reduce((pre, pkg) => {
  pre[`@sum-ui/${pkg}`] = join(__dirname, '../../packages', pkg, 'src/Index.vue');
  return {
    ...pre,
  };
}, {});

console.log(`🌼 alias list \n${chalk.blue(Object.keys(alias).join('\n'))}`);

module.exports = {
  title: "sum-ui", // 頂部左側標題
  description: 'Vue3 + ElementPlus 組件庫',
  base: '/sum-ui/',
  bundler: '@vuepress/vite',
  bundlerConfig: {
    viteOptions: {
      plugins: [
        vueJsx()
      ]
    }
  },
  alias,
  head: [
    // 設置 描述 和 關鍵詞
    [
      "meta",
      { name: "keywords", content: "Vue3 UI 組件庫" },
    ]
  ],
  themeConfig: {
    sidebar: {
      // 側邊欄
      "/": [
        {
          text: "介紹",
          children: [
            { text: "安裝", link: "/guide/install" },
            { text: "快速上手", link: "/guide/start" },
          ],
        },
        {
          text: "組件",
          children: [
            
            { text: "Layout 布局", link: "/components/layout" },
            { text: "Table 表格", link: "/components/table" }
          ],
        },
      ],
    },
    nav: [
      // 頂部右側導航欄
      { text: "介紹", link: "/", activeMatch: "^/$|^/guide/" },
      {
        text: "組件",
        link: "/components/layout.html",
        activeMatch: "^/$|^/components/"
      }
    ],
    // page meta
    editLinkText: '在 GitHub 上編輯此頁',
    lastUpdatedText: '上次更新',
    contributorsText: '貢獻者',
  },
  plugins: ['demoblock-plus'] // vuepress-plugin-demoblock-plus 插件,作用是展示交互文檔和代碼展開
};

.vuepress/clientAppEnhance.ts

除 config.ts 的配置外,還需要全局注冊組件才生效,需要加 clientAppEnhance.ts 來進行配置


import { defineClientAppEnhance } from '@vuepress/client'
import 'element-plus/theme-chalk/src/index.scss' // 全量引入樣式文件 scss TODO: 這里如果用element-plus 文檔里的方法 vite-plugin-element-plus 插件按需引入的話,dev 正常但是 vuepress build 打包 scss @import 就會報錯
import SumTable from '@sum-ui/table'
import SumLayout from '@sum-ui/layout'

export default defineClientAppEnhance(({ app }) => {
  app.component('SumTable', SumTable)
  app.component('SumLayout', SumLayout)
})

組件開發預覽

交互文檔庫配置完成之后,就能邊開發組件庫,邊看組件最終效果了

yarn docs:dev // vuepress 文檔庫開發模式
yarn docs:build // vuepress 文檔庫打包成靜態資源文件

打包生成的資源文件可以利用 Github Actions 自動部署到 GitHub Pages 上
sum-ui組件庫文檔地址

組件測試

組件測試放到每個組件目錄下,組件寫完可以寫該組件的單元測試
vue 的單測用 @vue/test-utils 就可以,另外在組件測試中導入組件的時候,不可直接識別 ts、vue 文件,需要 ts-jest vue-jest babel-jest 來做轉換

配置 jest.config.js

const alias = require('./alias')

module.exports = {
    globals: {
        // work around: https://github.com/kulshekhar/ts-jest/issues/748#issuecomment-423528659
        'ts-jest': {
            diagnostics: {
                ignoreCodes: [151001]
            }
        }
    },
    testEnvironment: 'jsdom',
    transform: {
        '^.+\\.vue$': 'vue-jest',
        '^.+\\.(t|j)sx?$': [
            'babel-jest',
            {
                presets: [
                    [
                        '@babel/preset-env',
                        {
                            targets: {
                                node: true
                            }
                        }
                    ],
                    [
                        '@babel/preset-typescript',
                        {
                            isTSX: true,
                            allExtensions: true
                        }
                    ]
                ]
            }
        ]
    },
    moduleNameMapper: alias, // 聲明別名以便於在jest中導入文件加載的時候能夠正確加載文件
    moduleFileExtensions: ['ts', 'tsx', 'js', 'json'],
    // u can change this option to a more specific folder for test single component or util when dev
    // for example, ['<rootDir>/packages/input']
    roots: ['<rootDir>']
}

vue 支持 tsx

babel 的 preset 配置 isTSX: true, allExtensions: true 兩個選項,allExtensions 為 true 支持所有擴展名,主要是為了支持 .vue 文件的解析,isTSX 為 true 支持 jsx 語法的解析

babel.config.js

module.exports = {
    // ATTENTION!!
    // Preset ordering is reversed, so `@babel/typescript` will called first
    // Do not put `@babel/typescript` before `@babel/env`, otherwise will cause a compile error
    // See https://github.com/babel/babel/issues/12066
    presets: [
        '@vue/cli-plugin-babel/preset',
        [
            '@babel/typescript',
            {
                isTSX: true,
                allExtensions: true
            }
        ]
    ],
    plugins: ['@babel/transform-runtime']
}

主題色相關

由於 element-plus 使用了 css 變量,可以通過改變 css 變量來覆蓋主題色

main.js

import { themeVarsKey } from 'element-plus'
const themeVars = {
  '--el-color-primary': '#29b6b0'
}
const app = createApp(App)
app.provide(themeVarsKey, themeVars)

其他可能會遇到的問題

Cannot read property 'isCE' of null 報錯 https://github.com/vuejs/vue-next/issues/4344

在我本地打包組件后 yarn link 到全局,然后在其他項目里引用的時候遇到這個報錯,是由於多個 Vue 包引用問題,發布到 npm 之后從 npm 安裝引用正常


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM