前提基础: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"
},
// ...
}
复制代码
上面组件的打包只用了 es
和 umd
格式,如果需要其它的加上就行。这上面其实有一个问题,在 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代码检测。
到这里,所有配置都已完成了。