Element Plus正式版发布了,来写个Vue3+Element Plus的组件库

6,045 阅读6分钟

前言

之前做了Vue3重构,过程中抽出了组件库,趁element-plus正式版发布,组件库也跟着更新到了最新版,目前更新了layout和table两个组件,利用esm做到了按需加载。

image.png

之前做了 react+antd的组件库 的整合及示例,现在再看该方案还有诸多缺陷,发现了很多新的东西可以用,在本次的Vue组件库技术选型中调研了更多的方式方法,最终选型不代表是最好的,但一定是现阶段我认为最合适该场景的技术选型,实际上写组件库并不复杂,技术调研和技术选型的整个流程下来,算是对各种方法方法及其优缺点有了一个稍微宽泛的认识。

不想看文章只想看代码的点这里 想先看下交互文档效果的点这里

目的

为什么要做组件库?这个问题也是老生常谈了,PC端后台xx管理系统这种场景下,通用的东西很容易抽象出来,这样就不需要每开一个新项目就在项目里写一套基础组件了,在可直接使用基础组件的基础上开发能省不少时间,本次只对集成度和通用度较高的几个组件进行抽离出几个 npm 包,先实现常见通用功能,后续扩展则可以在不改变原来架构的基础上进行添加功能。这些组件包括通用的布局组件、通用的表格组件、通用的表单组件、通用的文件图片上传组件等。

开始之前

开始之前,除了定好技术栈 Vue 3.x + ElementPlus 外,需要明确组件库的开发原则:简洁、高效、灵活、可扩展。

首先要有可读性好的文档库,有示例可交互;其次能自动化的重复工作绝不手动复制,利用GitHub Actions 自动化部署文档库到GitHub Pages;最后,最好还有组件测试来保证组件的正确性和完成性。

项目结构

├── 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 格式就会按需加载,无需借助插件
  6. unplugin-vue-components 和 unplugin-auto-import实现对element-plus的按需导入

rollup.config.js

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'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

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

// 公共插件配置
const getPlugins = () => {
    return [
        vue(),
        AutoImport({
            resolvers: [ElementPlusResolver()]
        }),
        Components({
            resolvers: [ElementPlusResolver()]
        }),
        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/main.ts');
  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 来进行配置


/**
 * 全局注册组件 下文注释:重要、勿删,plop在自动新增组件的时候注入,不需要手动添加
 */
import { defineClientAppEnhance } from '@vuepress/client'
import 'element-plus/theme-chalk/src/index.scss'
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组件库文档地址

deploy.yml

name: Build and Deploy
on:
  push:
    branches:
      - master
jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout 🛎️
        uses: actions/checkout@v2.3.1 # If you're using actions/checkout@v2 you must set persist-credentials to false in most cases for the deployment to work correctly.
        with:
          persist-credentials: false

      - name: Install and Build 🔧 # This example project is built using npm and outputs the result to the 'build' folder. Replace with the commands required to build your project, or remove this step entirely if your site is pre-built.
        run: |
          npm install
          npm run docs:build
      - name: Deploy 🚀
        uses: JamesIves/github-pages-deploy-action@3.6.2
        with:
          GITHUB_TOKEN: ${{ secrets.ACTION_TOKEN }}
          BRANCH: gh-pages # The branch the action should deploy to.
          FOLDER: docs/.vuepress/dist # The folder the action should deploy.
          CLEAN: true # Automatically remove deleted files from the deploy branch

组件测试

组件测试放到每个组件目录下,组件写完可以写该组件的单元测试 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 变量来覆盖主题色 element-plus最新文档

style.css

:root {
  --el-color-primary: green;
}