公司内部组件库设计思路

·  阅读 1424

前提基础:vite + pnpm(workspace) + vue3 + ts + scss + element-plus

组件库整体设计分为三块:

  • svg 图标库 - icons
  • vue 组件库 - ui
  • 图标及组件的测试项目 - website

整体设计采用 pnpm 的 workspace 方式来管理三个项目:

packages
  - icons
  - ui
  - website
package.json
pnpm-workspace.yaml
复制代码
# pnpm-workspace.yaml
packages:
  - 'packages/**'
复制代码

图标库

在公司项目中,我们一般都需要自定义一些图标,为了更好的管理和使用,所以应该要有一个内部的图标库。

目前 UI 组件库的图标管理一般分为两种(瞎扯的):

  • 第一种是把图标转成字体,然后在项目内使用 className 的方式进行使用
  • 第二种就是像 element-plus 一样,直接将 svg 图标封装成一个个 vue 组件,然后在项目内当成 vue 组件来使用

至于有没有其它的方式,我也太多的去了解哈。

因为我们的组件库是以 element-plus 为基础的,所以采用第二种进行封装。

以组件的方式封装图标,比较简单,大体就是下面这样:

<template>
  <svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
    <path fill="currentColor" d="M338.752 104.704a64 64 0 0 0 0 90.496l316.8 316.8-316.8 316.8a64 64 0 0 0 90.496 90.496l362.048-362.048a64 64 0 0 0 0-90.496L429.248 104.704a64 64 0 0 0-90.496 0z"></path>
  </svg>
</template>

<script lang="ts">
export default {
  name: 'IconName'
}
</script>
复制代码

所以我们只需要将 UI 设计好的 svg 拿到手,按照上面简单的封装下就可以用了。

但是这里要注意一点的是,使用 svg 作为图标是因为 svg 的颜色渲染可以选择性的继承父级元素的颜色。所以我们需要将需要继承父元素颜色的 path 的 fill 的值设为 currentColor,而 UI 设计出的原始 svg 文件好像是做不到这点,所以我们拿到之后就需要手动调整下。

为了减少人为的工作和低级错误,我们可以对 svg 转 vue 组件这一步写一个脚本,自动去转换,思路:

  • 读取 svg 路径目录下的所有 svg 文件
  • 用写好的模板直接生成 vue 组件
// scripts/generate-components.js
const fs = require('fs')
const path = require('path')
const { success, error } = require('./logger')

const iconsPath = path.resolve(__dirname, '../')
const svgsPath = path.resolve(iconsPath, './svgs')
const componentsPath = path.resolve(iconsPath, './packages')

// 读取 svg 文件内容
function getSvg(path) {
  return fs.readFileSync(path).toString()
}

// 获取 vue 的组件名
function getVueComponentName(name) {
  return name.replace(/(^\w)|(-\w)/g, a => (a[1] || a[0]).toUpperCase())
}

// 生成 vue 组件
function generateVueContent(svgContent, fileName) {
  const vueName = fileName.split('.')[0]
  let context = `<template>\n${svgContent}\n</template>\n\n`

  context += `\
<script lang="ts">
export default {
  name: '${getVueComponentName(vueName)}'
}
</script>
`
  return context
}

// 生成 vue 文件
function createComponentFile(content, fileName) {
  if (!fs.existsSync(componentsPath)) {
    fs.mkdirSync(componentsPath)
  }
  const vueFilePath = path.resolve(componentsPath, fileName + '.vue')

  fs.writeFile(vueFilePath, content, (err) => {
    if (err) {
      error(err)
      return
    }
    success(`文件 ${vueFilePath} 创建成功!`)
  })
}

// 生成 vue 组件汇总文件
function createIndex(fileNames) {
  let content = "export * from '@element-plus/icons-vue'\n\n"
  const indexPath = path.resolve(componentsPath, './index.ts')
  const components = []

  fileNames.forEach(fileName => {
    const name = fileName.split('.')[0]
    const vueComponentName = getVueComponentName(name)
    components.push(vueComponentName)
    content += `import ${vueComponentName} from './${name}.vue'\n`
  })

  content += `\
\nexport {
  ${components.join(',\n\t')}
}
`

  fs.writeFile(indexPath, content, (err) => {
    if (err) {
      error(err)
      return
    }
    success(`文件 ${indexPath} 创建成功!`)
  })
}

// 初始化
function init() {
  // 先清除components内所有文件
  if (fs.existsSync(componentsPath)) {
    fs.rmSync(componentsPath, {
      recursive: true
    })
  }

  const svgs = fs.readdirSync(svgsPath)

  svgs.forEach(svgName => {
    const svgPath = path.resolve(svgsPath, svgName)
    const ctx = getSvg(svgPath)

    const fileName = svgName.split('.')[0]

    // 生成vue组件内容
    const vueContent = generateVueContent(ctx, fileName)
    // 生成vue组件文件
    createComponentFile(vueContent, fileName)
  })

  // 创建汇总index.ts
  createIndex(svgs)
}
init()
复制代码

然后在 package.json 文件中添加脚本执行命令:

{
    // ...
    "scripts": {
      "build": "node scripts/build.js",
      "prebuild": "node scripts/generate-components.js"
    },
    // ...
}
复制代码

如果我们需要与 @element-plus/vue-icons 一起用话,我门需要在组件汇总文件处引入 element-plus 的图标库。

// 引入并抛出 element-plus 的图标组件
export * from '@element-plus/icons-vue'

import IcAvatar from './ic-avatar.vue'
import IcBbgl from './ic-bbgl.vue'
// 其它图标

export {
  IcAvatar,
  IcBbgl,
  // ...
}

复制代码

最后进行打包并发布。

// scripts/build.js
const path = require('path')
const { defineConfig, build } = require('vite')
const vue = require('@vitejs/plugin-vue')
const dts = require('vite-plugin-dts')

const entryDir = path.resolve(__dirname, '../packages')
const outputDir = path.resolve(__dirname, '../dist')

build(defineConfig({
  configFile: false,
  publicDir: false,
  plugins: [
    vue(),
    dts({
      include: './packages',
      outputDir: './types'
    })
  ],
  build: {
    rollupOptions: {
      external: [
        'vue',
        '@element-plus/icons-vue'
      ],
      output: {
        globals: {
          vue: 'Vue'
        }
      }
    },
    lib: {
      entry: path.resolve(entryDir, 'index.ts'),
      name: 'xxxIcons',
      fileName: 'xxx-icons',
      formats: ['es', 'umd']
    },
    outDir: outputDir
  }
}))
复制代码

使用的方式就是和 @element-plus/vue-icons 一样了。

组件库

关于组件怎么写就不讲了,这里主要介绍组件库的构建设计。

为了更方便,更规范的去管理所有组件及方法,所以我们可以对组件的格式进行简单的约定。

ui
  - packages      // 组件目录
    - component-name // 组件
      - __tests__       // 组件单元测试
      - src             // 组件内容
      - index.ts        // 组件入口文件
  - scripts       // 构建脚本
  - src           // 组件库通用配置
    - index.ts       // 组件库的打包入口文件
  - package.json
  - tsconfig.json
  // 其它文件
复制代码

组件入口文件格式:

import type { App } from 'vue'
import ComponentName from './src/index.vue'
import './src/index.scss'

export { ComponentName }

export default {
  install: (app: App) => {
    app.component(ComponentName.name, ComponentName)
  }
}
复制代码

组件库的打包入口文件格式:

import type { App } from 'vue'
import componentNameInstall, { componentName } from '../packages/ep-announcement-list'
// 其它组件引入...

import { version } from '../package.json'
// 组件库的全局配置方法
import { setupGlobalOptions } from './global-config'

const components: Array<{ install: (_v: App) => void }> = [
  componentNameInstall,
  // ...
]

const install = (app: App, opts = {}) => {
  app.use(setupGlobalOptions(opts))

  components.forEach(component => {
    app.use(component)
  })
}

const ui = {
  version,
  install
}

export {
  componentName,
  // 其它组件
  install
}

export default ui
复制代码

因为单个组件的入口文件和打包的入口文件的格式已经固定,为了减少人为的工作和一些低级错误的出现,这里我们可以使用 @babel/traverse@babel/parser 对 packages 下面的文件进行简单的 AST 词法分析,解析出默认导出和单个导出变量,然后进行组装 scripts/index.ts 文件:

const fs = require('fs')
const path = require('path')
const traverse = require("@babel/traverse").default
const babelParser = require("@babel/parser")
const { success, error } = require('./logger')

const ignoreComponents = []

// 将 kebab-case 转换为 PascalCase 格式
function getComponentName(name) {
  return name.replace(/(^\w)|(-\w)/g, a => (a[1] || a[0]).toUpperCase())
}

// 进行 AST 词法分析并获取内部抛出变量
function analyzeCode(code, filePath, dir, components, services) {
  const ast = babelParser.parse(code, {
    sourceType: 'module',
    plugins: ['typescript']
  })

  const exportName = []
  let exportDefault = ''

  traverse(ast, {
    // 单个导出
    ExportNamedDeclaration({ node }) {
      if (node.specifiers.length) {
        node.specifiers.forEach(specifier => {
          exportName.push(specifier.local.name)
        })
      } else if (node.declaration) {
        if (node.declaration.declarations) {
          node.declaration.declarations.forEach(dec => {
            exportName.push(dec.id.name)
          })
        } else if (node.declaration.id) {
          exportName.push(node.declaration.id.name)
        }
      }
    },
    // 默认导出
    ExportDefaultDeclaration({ node }) {
      exportDefault = getComponentName(dir) + 'Install'
      components.push(exportDefault)
    }
  })

  if (!exportDefault && !exportName.length) {
    return ''
  }

  let exp = 'import '

  if (exportDefault) {
    exp += `${exportDefault}`
  }
  if (exportName.length) {
    services.push(...exportName)
    if (exportDefault) {
      exp += ', '
    }
    exp += `{ ${exportName.join(', ')} }`
  }

  exp += ` from '${path.join(filePath, dir)}'`.replace(/\\/g, '/')

  return exp
}

const relativePath = '../packages'
const desPath = path.resolve(__dirname, '../src/index.ts')
const packagePath = path.resolve(__dirname, relativePath)
let exportExp = "import type { App } from 'vue'\n"
const components = []
const services = []

// 组装 index.ts 文件内容
function getCode() {
  fs.readdirSync(packagePath).forEach(dir => {
    if (ignoreComponents.includes(dir)) {
      return
    }
    const dirPath = path.resolve(packagePath, dir)
    if (fs.statSync(dirPath).isDirectory()) {
      const filePath = path.resolve(dirPath, 'index.ts')
      const code = fs.readFileSync(filePath, 'utf-8')
      const exp = analyzeCode(code, relativePath, dir, components, services)

      if (exp) {
        exportExp += exp + '\n'
      }
    }
  })

  exportExp += `
import { version } from '../package.json'
import { setupGlobalOptions } from './global-config'

const components: Array<{ install: (_v: App) => void }> = [
  ${components.join(',\n  ')}
]

const install = (app: App, opts = {}) => {
  app.use(setupGlobalOptions(opts))

  components.forEach(component => {
    app.use(component)
  })
}

const xxxxui = {
  version,
  install
}

export {
  ${services.join(',\n  ')},
  install
}

export default xxxxui
`
  return exportExp

}

// 保存为文件
function save(desPath, code) {
  fs.writeFile(desPath, code, err => {
    if (err) {
      error(err)
      throw err
    }
    success(`文件 ${desPath} 创建成功!`)
  })
}

// 构建
function build() {
  save(desPath, getCode())
}
build()
复制代码

在入口文件生成之后,然后进行组件库的打包构建。

组件库的打包分为整体打包和单个组件打包,单个组件的打包是为了考虑组件库的按需加载功能。

const path = require('path')
const fs = require('fs')
const fsExtra = require('fs-extra')
const { defineConfig, build } = require('vite')
const vue = require('@vitejs/plugin-vue')
const dts = require('vite-plugin-dts')
const pkg = require('../package.json')

const entryDir = path.resolve(__dirname, '../packages');
const outputDir = path.resolve(__dirname, '../dist');

const baseConfig = defineConfig({
  configFile: false,
  publicDir: false,
  plugins: [
    vue(),
    dts({
      include: ['./packages', './src'],
      outputDir: './types'
    })
  ]
})

const createBanner = () => {
  return `/*!
  * ${pkg.name} v${pkg.version}
  * (c) ${new Date().getFullYear()} UI
  * @license ISC
  */`
}

const rollupOptions = {
  external: [
    'vue',
    // 组件库不打包内部引用的第三方包
  ],
  output: {
    globals: {
      vue: 'Vue'
    },
    banner: createBanner()
  }
}

const getFilename = (filename, format) => {
  return {
    es: filename + '.es.js',
    umd: filename + '.umd.js',
  }[format]
}

// 单个组件打包
const buildSingle = async (name) => {
  await build(
    defineConfig({
      ...baseConfig,
      build: {
        rollupOptions,
        lib: {
          entry: path.resolve(entryDir, name),
          name: 'index',
          fileName: getFilename.bind(null, 'index'),
          formats: ['es', 'umd']
        },
        outDir: path.resolve(outputDir, name)
      }
    })
  )
}

// 整体打包
const buildAll = async () => {
  await build(
    defineConfig({
      ...baseConfig,
      build: {
        rollupOptions,
        lib: {
          entry: path.resolve(__dirname, '../src/index.ts'),
          name: 'Xxxxui',
          fileName: getFilename.bind(null, 'xxxx-ui'),
          formats: ['es', 'umd']
        },
        outDir: outputDir
      }
    })
  )
}

// 单个组件打包的 package.json 文件
const createPackageJson = (name) => {
  const fileStr = `{
  "name": "${name}",
  "version": "0.0.0",
  "main": "index.umd.js",
  "module": "index.es.js",
  "style": "style.css",
  "types": "../../types/packages/${name}/index.d.ts"
}`

  fsExtra.outputFile(path.resolve(outputDir, `${name}/package.json`), fileStr, 'utf-8');
}

// 这一步是是因为组件库的某些组件是没有 css 的
// 为了按需加载功能的样式引入,需要创建一个空白的 css 文件
const createBlankCssFile = (name) => {
  const cssFilePath = path.resolve(outputDir, `${name}/style.css`)
  if (!fs.existsSync(cssFilePath)) {
    fsExtra.outputFile(cssFilePath, '', 'utf-8');
  }
}

const buildUI = async () => {
  await buildAll()

  const components = fs.readdirSync(entryDir).filter((name) => {
    const componentDir = path.resolve(entryDir, name);
    const isDir = fs.lstatSync(componentDir).isDirectory();
    return isDir && fs.readdirSync(componentDir).includes('index.ts');
  });

  for (const name of components) {
    await buildSingle(name);
    createPackageJson(name);
    createBlankCssFile(name)
  }
}

buildUI()
复制代码

最后在 package.json 中的 scripts 添加两个命令:

{
  // ...
  "scripts": {
    // 执行打包
    "build": "node scripts/build.js",
    // 生成打包入口文件
    "prebuild": "node scripts/generate-index.js"
  },
  // ...
}
复制代码

上面组件的打包只用了 esumd 格式,如果需要其它的加上就行。这上面其实有一个问题,在 getFilename 方法中定义不同格式的文件名,如果不用函数定义,es 格式打包出来的是 .mjs 文件后缀,但是发现这样在使用的时候,如 monaco-editor 等有些组件会报找不到引用地址的错误,但是定义成 .js 后缀就是 OK 的,我也没搞明白 -_- !

到这里打包发布之后我们在项目中就可以使用了,下面是按需加载的用法:

// vue.config.js
// ^0.21.1,之前用的是 0.17.x,0.21.x 版本返回值字段有变更,具体哪个版本变更的没去细看...
// 注意:这里用的是webpack,vite 的用 vite 版本
const Components = require('unplugin-vue-components/webpack')

module.exports = {
  // 其它配置...
  configureWebpack: {
    // 其它配置...
    plugins: [
      // 按需加载
      Components({
        resolvers: [
          name => {
            // 所有组件库内的组件名规定一个前缀,用来判断是自己的组件
            if (name.startsWith('Yy')) {
              // 将 PascalCase 转换为 kebab-case 格式
              const rawFileName = name.replace(
                /[A-Z]/g, 
                (a, b) => (b ? '-' : '') + a.toLowerCase()
               )
              return {
                sideEffects: [`xxxx-ui/dist/${rawFileName}/style.css`],
                name,
                from: 'xxxx-ui'
              }
            }
          }
        ]
      }),
      // 其它插件...
    ]
  }
}
复制代码

到这里 vue 的组件库已经完成了。

图标/组件测试项目

因为是公司项目的内部组件,在组件测试的时候有可能需要和项目一样的环境,然后我们的项目是用的 qiankunjs 做的微服务设计。所以这里我是放了一个子应用,挂在基座项目下,这样我们的测试项目也就拥有了和正式项目上一模一样的环境。

下面就是怎么去做组件库测试和联调了。

因为 pnpm 提供的一些功能,同级的包在 package.json 内用 workspace:^1.0.0 的方式直接调用,当组件库或者图标做的修改,website 是可以直接进行热更新的。而我们需要测试两种方式,一种是开发调试,一种是打包后的调试,这样我们可以添加两个 scripts 命令:

{
  // ...
  "scripts": {
    // 打包调试
    "dev": "vue-cli-service serve",
    // 开发调试
    "devtc": "vue-cli-service serve -t test",
  }
}
复制代码

然后修改 vue.config.js:

const Components = require('unplugin-vue-components/webpack')

const type = process.argv[4]
function resolveComponent() {
  return name => {
    if (name.startsWith('Yy')) {
      // 开发调试
      if (type === 'test') {
        return {
          name: name,
          from: 'xxxx-ui/src'
        }
      }
      // 打包后调试
      const rawFileName = name.replace(
        /[A-Z]/g, 
        (a, b) => (b ? '-' : '') + a.toLowerCase()
      )
      return {
        sideEffects: [`xxxx-ui/dist/${rawFileName}/style.css`],
        name: name,
        from: 'xxxx-ui'
      }
    }
  }
}


module.exports = {
  // 其它配置...
  configureWebpack: {
    plugins: [
      Components({
        resolvers: [
          resolveComponent()
        ]
      }),
      // ...
    ]
  }
}
复制代码

这里就可以启动项目进行调试了。

git hooks

最后我们可以对组件库添加一些代码规范、文件命名规范及提交规范等。

下面的工具只进行简单的使用,如有更好的用法,欢迎探讨

husky

husky 可以让我们更好的去向项目中添加 git hooks:

pnpm add husky -wD
复制代码

然后在 package.json 中的 scripts 添加:

{
  "scripts": {
    "prepare": "husky install"
  }
}
复制代码

安装之后在根目录会有一个 .husky 文件夹。

添加 git hook:

npx husky add .husky/pre-commit "npm run test"
复制代码

在 .husky 下面会生成一个 .pre-commit 文件

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npm run test
复制代码

这里就是在每次提交 commit 之前会执行 npm run test

commitlint

运行下面命令,可以生成 .husky/.commit-msg 文件:

npx husky add .husky/commit-msg 'npx --no-install commitlint --edit "$1"' 
复制代码

.husky/.commit-msg 文件。

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx --no-install commitlint --edit $1

复制代码

添加 @commitlint/cli @commitlint/config-conventional 两个组件。

设置 commit-msg 规则:

  • 添加 commitlint.config.js 文件:
// commitlint.config.js
const types = [
  'feat',     // 新增功能
  'fix',      // bug 修复
  'docs',     // 文档更新
  'style',    // 代码修改
  'refactor', // 重构代码
  'perf',     // 性能优化
  'test',     // 新增或更新现有测试
  'build',    // 修改项目构建系统
  'chore',    // 其它类型,日期事务
  'revert'    // 回滚之前提交
];

module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-empty': [2, 'never'],
    'type-enum': [2, 'always', types],
    'scope-case': [0, 'always'],
    'subject-empty': [2, 'never'],
    'subject-case': [0, 'never'],
    'header-max-length': [2, 'always', 88],
  },
};
复制代码

这样我们每次 commit 的提交信息的规则是 [types]: 提交信息,如不符合则会报错,中止提交。

eslint

这里添加 js 代码检测规范:

安装 eslint:

pnpm add eslint -wD
复制代码

添加 scripts 命令:

// xxxx-* 是图标库与组件库的目录名匹配
{
  "scripts": {
    "eslint": "eslint \"packages/xxxx-*/**/{*.ts,*.vue}\"",
    "eslint:fix": "eslint --fix \"packages/xxxx-*/**/{*.ts,*.vue}\"",
  }
}
复制代码

然后添加 eslint 配置文件 .eslintrc.js:

// .eslintrc.js 这里根据项目自己配置
module.exports = {
  "root": true,
  "env": {
    "node": true
  },
  "extends": [
    "plugin:vue/vue3-essential",
    "eslint:recommended",
    "@vue/typescript"
  ],
  "parserOptions": {
    "parser": "@typescript-eslint/parser"
  },
  "globals": {
    "defineProps": "readonly",
    "defineEmits": "readonly",
    "defineExpose": "readonly",
    "withDefaults": "readonly"
  },
  "rules": {
    "no-unused-vars": ["warn", {
      "varsIgnorePattern": "[iI]gnored",
      "argsIgnorePattern": "^_"
    }],
    // ...
  }
}
复制代码

stylelint

这里添加 css 检测规范:

添加 stylelint stylelint-config-standard-scss stylelint-config-recommended-scss stylelint-scss 组件。

添加 scripts 命令:

// 这里只检测 ui 库的样式
{
  "scripts": {
    "stylelint": "stylelint \"packages/xxxx-ui/**/*.scss\"",
    "stylelint:fix": "stylelint --fix \"packages/xxxx-ui/**/*.scss\""
  }
}
复制代码

然后添加 .stylelintrc.json 文件:

// .stylelintrc.json
{
  "extends": [
    "stylelint-config-standard-scss",
    "stylelint-config-recommended-scss"
  ],
  "plugins": [
    "stylelint-scss"
  ],
  "rules": { // 规范自己根据项目选择
    "color-no-invalid-hex": true,
    "font-family-no-duplicate-names": true,
    "function-calc-no-unspaced-operator": true,
    "selector-class-pattern": "^[a-z_]+(-{1,2}[a-z_]+)*$",
    "selector-pseudo-class-no-unknown": [
      true,
      {
        "ignorePseudoClasses": [
          "deep"
        ]
      }
    ]
  }
}
复制代码

lint-staged

lint-staged 是一个文件过滤和命令重组的工具,用法如下:

// package.json
{
 "lint-staged": {
   "packages/xxxx-*/**/*.{ts,vue}": "eslint --fix",
   "packages/xxxx-ui/**/*.scss": "stylelint --fix"
 }
}
复制代码

这里就是对命令的一个重新配置,可以理解为:

eslint --fix "packages/xxxx-*/**/*.{ts,vue}"
stylelint --fix "packages/xxxx-ui/**/*.scss"
复制代码

@ls-lint/ls-lint

@ls-lint/ls-lint 可以用来检测不同文件类型的文件名的命名规则:

pnpm add @ls-lint/ls-lint -wD
复制代码

在根目录添加 .ls-lint.yml 文件:

# .ls-lint.yml
ls:
  packages:
    .dir: kebab-case | regex:__[a-z0-9]+__
    .scss: kebab-case
    .vue: kebab-case | PascalCase
    .js: kebab-case
    .svg: kebab-case
    .ts: kebab-case
    .d.ts: kebab-case

ignore:
  # xxxx-icons
  - packages/xxxx-icons/dist
  - packages/xxxx-icons/node_modules
  - packages/xxxx-icons/types
  - packages/xxxx-icons/src
  # xxxx-ui
  - packages/xxxx-ui/assets
  - packages/xxxx-ui/dist
  - packages/xxxx-ui/node_modules
  - packages/xxxx-ui/types
  # website
  - packages/website
复制代码

最后修改 .husky/pre-commit 文件:

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

- npm run test
+ pnpx @ls-lint/ls-lint && pnpx lint-staged

复制代码

这里在每次提交之前,都会执行 pnpx @ls-lint/ls-lint && pnpx lint-staged 命令,对修改的文件进行文件名、js代码、css代码检测。

到这里,所有配置都已完成了。

分类:
前端
收藏成功!
已添加到「」, 点击更改