Skip to content

自定义组件库(Vue3)

组件库的环境配置

  • 技术选型:使用 Vite+Ts 开发的是 Vue3 组件库
  • typescript、vue3 、sass等
shell
pnpm add vue@next typescript sass -D -w
初始化ts
  • 执行npx tsc --init 生成tsconfig.json ts 的配置文件
shell
npx tsc --init
json
{
	"compilerOptions": {
		"baseUrl": ".",
		"jsx": "preserve",
		"strict": true,
		"target": "ES2015",
		"module": "ESNext",
		"skipLibCheck": true,
		"esModuleInterop": true,
		"moduleResolution": "Node",
		"lib": ["esnext", "dom"]
	}
}

搭建基于vite的vue3项目

  • 新建play 文件夹,初始化pnpm init, 后续的组件调试就在这个项目下进行
  • 安装依赖,在play安装vite和vitejs/plugin-vue插件,@vitejs/plugin-vue插件是为了解析后缀为.vue文件的
  • 新建vite.config.ts
shell
pnpm add vite @vitejs/plugin-vue -D

touch vite.config.ts
ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
	plugins: [vue()],
})
  • 新建index.html
html
<!doctype html>
<html lang="en">
	<head>
		<meta charset="UTF-8" />
		<meta http-equiv="X-UA-Compatible" content="IE=edge" />
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<title>play</title>
	</head>
	<body>
		<div id="app"></div>
		<script src="main.ts" type="module"></script>
	</body>
</html>
  • 新建app.vue
vue
<template>
	<div>启动测试</div>
</template>
  • 新建入口文件 main.ts
ts
import { createApp } from 'vue'
import App from './app.vue'

const app = createApp(App)

app.mount('#app')
  • play 项目需要测试本地的组件库,所以也需要将 play 和我们的组件库关联在一起。修改一下pnpm-workspace.yaml文件
yml
packages:
  - 'packages/**'
  - 'play'
  • package.json
json
{
	"workspaces": ["packages/**", "play"]
}
  • 新建一个声明文件vite.d.ts
ts
declare module '*.vue' {
	import type { DefineComponent } from 'vue'
	const component: DefineComponent<{}, {}, any>
}

开发一个组件

测试组件
  • packages目录下新建components和utils

    • components就是我们组件存放的位置
    • utils包则是存放一些公共的方法之类的
    • 分别在两个包下执行pnpm init 并更改name为 '@ued-plus/components', '@ued-plus/utils'
  • components/src/button创建测试组件button.vue

vue
<template>
	<button>测试按钮</button>
</template>
  • button/index.ts将其导出
ts
import Button from './button.vue'
export { Button }
export default Button
  • components/src/index.ts集中导出所有组件
ts
export * from './button'
  • components/index.ts导出所有组件提供给外部使用
ts
export * from './src/index'
  • 在play中进行测试
shell
# 安装组件依赖
pnpm add @ued-plus/components
  • app.vue中测试组件
vue
<template>
	<div>
		<Button />
	</div>
</template>
<script lang="ts" setup>
import { Button } from '@easyest/components'
</script>
全局挂载组件
  • 使用app.use 挂载全局组件,会调用传入参数的 install 方法,给每个组件添加一个 install 方法,然后再导出整个组件库
  • 改造button/index.ts
ts
import _Button from './button.vue'
import type { App, Plugin } from 'vue'
type SFCWithInstall<T> = T & Plugin
// 全局安装方法
const withInstall = <T>(comp: T) => {
	;(comp as SFCWithInstall<T>).install = (app: App) => {
		const name = (comp as any).name
		// 注册组件
		app.component(name, comp as SFCWithInstall<T>)
	}
	return comp as SFCWithInstall<T>
}
export const UedButton = withInstall(_Button)
export default UedButton
  • components/index.ts
ts
import { App } from 'vue'
import * as components from './src/index'
export * from './src/index'

export default {
	install: (app: App) => {
		for (const c in components) {
			app.use(components[c as keyof typeof components])
		}
	},
}
  • 需要给button.vue一个name好在全局挂载的时候作为组件名使用
vue
<template>
	<button>测试按钮</button>
</template>

<script lang="ts" setup>
defineOptions({ name: 'UedButton' })
</script>

<style lang="scss" scoped></style>
  • play/main.ts下挂载组件
ts
import { createApp } from 'vue'
import App from './app.vue'
import ued from '@ued-plus/components'
const app = createApp(App)

app.use(ued)
app.mount('#app')
  • app.vue直接使用
vue
<template>
	<div>
		<ued-button />
	</div>
</template>

<script lang="ts" setup></script>
  • 加上全局提示
  • 安装@vue/runtime-core
shell
pnpm add @vue/runtime-core -D -w
  • 新建components.d.ts
ts
import * as components from './index'
declare module '@vue/runtime-core' {
	// 组件全局提示效果
	export interface GlobalComponents {
		UedButton: typeof components.Button
	}
}
export {}
  • 注意:在 vue 3.3 版本之后 可以直接在 <script setup> 中使用 defineOptions 了
  • 使用unplugin-vue-define-options解决setup 语法糖name的问题
shell
# 根目录下
pnpm add unplugin-vue-define-options  -D -w
  • 在play/vite.config.ts引入
ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// 在 vue 3.3 版本之后 可以直接在 <script setup> 中使用 defineOptions 了
// import DefineOptions from 'unplugin-vue-define-options/vite'
export default defineConfig({
	plugins: [vue()],
})

使用vite 打包组件库

打包配置
  • 全局安装 vite 以及@vitejs/plugin-vue
shell
pnpm add vite @vitejs/plugin-vue -D -w
  • components/vite.config.ts components下新建vite.config.ts
ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
	build: {
		// 打包后文件目录
		outDir: 'ued',
		// 压缩
		minify: false,
		rollupOptions: {
			// 忽略打包vue文件
			external: ['vue'],
			output: {
				globals: {
					vue: 'Vue',
				},
				dir: 'dist',
			},
		},
		lib: {
			entry: './index.ts',
			name: 'ued',
			fileName: 'ued',
			formats: ['es', 'umd', 'cjs'],
		},
	},
	plugins: [vue()],
})
  • 配置打包命令并执行, components/package.json
  • 这种打包方式最终会将整个组件库打包到一个文件中,并且样式文件也不能按需加载
json
{
	"script": {
		"build": "vite build"
	}
}
shell
pnpm build
  • 需要修改一下配置让打包后的结构和我们开发的结构一致
ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
	build: {
		// 打包后文件目录
		outDir: 'ued',
		// 压缩
		minify: false,
		rollupOptions: {
			// 忽略打包vue文件
			external: ['vue'],
			input: ['index.ts'],
			output: [
				{
					// 打包格式
					format: 'es',
					// 打包后文件名
					entryFileNames: '[name].mjs',
					// 让打包目录和我们目录对应
					preserveModules: true,
					exports: 'named',
					// 配置打包根目录
					dir: '../ued/es',
				},
				{
					// 打包格式
					format: 'cjs',
					// 打包后文件名
					entryFileNames: '[name].js',
					// 让打包目录和我们目录对应
					preserveModules: true,
					exports: 'named',
					// 配置打包根目录
					dir: '../ued/lib',
				},
			],
		},
		lib: {
			entry: './index.ts',
		},
	},
	plugins: [vue()],
})
声明文件
  • 安装vite-plugin-dts
shell
pnpm add vite-plugin-dts -D -w
  • compoments/vite.config.ts
ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import dts from 'vite-plugin-dts'
export default defineConfig({
	plugins: [
		vue(),
		dts({
			entryRoot: './src',
			outDir: ['../ued/es/src', '../ued/lib/src'],
			// 指定使用的tsconfig.json为我们整个项目根目录下,如果不配置,你也可以在components下新建tsconfig.json
			tsconfigPath: '../../tsconfig.json',
		}),
	],
})

使用 gulp 打包组件库并实现按需加载

删除打包文件
  • 在 components 新建一个 script 文件夹用于存放我们的脚本相关内容
  • 安装 @types/node
shell
pnpm add @types/node -D -w
  • 新建 script/utils/path.ts
ts
import { resolve } from 'path'

//组件库根目录
export const componentPath = resolve(__dirname, '../../')

//pkg根目录
export const pkgPath = resolve(__dirname, '../../../')
  • 新建 script/utils/del-path.ts
ts
import fs from 'fs'
import { resolve } from 'path'
import { pkgPath } from './path'
// 保留的文件
const stayFile = ['package.json', 'README.md']

const delPath = async (path: string) => {
	let files: string[] = []

	if (fs.existsSync(path)) {
		files = fs.readdirSync(path)

		files.forEach(async (file) => {
			const curPath = resolve(path, file)

			if (fs.statSync(curPath).isDirectory()) {
				// recurse
				if (file !== 'node_modules') await delPath(curPath)
			} else {
				// delete file
				if (!stayFile.includes(file)) {
					fs.unlinkSync(curPath)
				}
			}
		})

		if (path !== `${pkgPath}/easyest`) fs.rmdirSync(path)
	}
}
export default delPath
使用 gulp 删除打包文件
  • 安装一些依赖使得 gulp 支持ts 以及新的 es6 语法
  • sucras 可以让执行 gulp 可以使用最新语法并且支持 ts
shell
pnpm i gulp @types/gulp sucrase -D -w
  • 新建 scropt/build/index.ts
ts
import { series } from 'gulp'
import delPath from '../utils/del-path'
import { pkgPath } from '../utils/path'

export const removeDist = () => {
	return delPath(`${pkgPath}/ued`)
}

export default series(async () => removeDist())
  • 根目录下添加脚本,并执行
json
{
	"script": {
		"build:ued": "gulp -f packages/components/script/build/index.ts"
	}
}
glup打包样式
  • 安装gulp-sass,同时在安装一个自动补全 css 前缀插件gulp-autoprefixer
  • gulp-autoprefixer@^9.0.0 会出现TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts"的问题(待解决)
shell
pnpm add gulp-sass @types/gulp-sass gulp-autoprefixer@^8.0.0 @types/gulp-autoprefixer -D -w
  • 修改build/index.ts
ts
import { series, src, dest, parallel } from 'gulp'
import autoprefixer from 'gulp-autoprefixer'
import delPath from '../utils/del-path'
import { pkgPath, componentPath } from '../utils/path'
const sass = require('gulp-sass')(require('sass'))

export const removeDist = () => {
	return delPath(`${pkgPath}/ued`)
}

// 打包样式
export const buildStyle = () => {
	return src(`${componentPath}/src/**/styles/**.scss`)
		.pipe(sass())
		.pipe(autoprefixer())
		.pipe(dest(`${pkgPath}/ued/lib/src`))
		.pipe(dest(`${pkgPath}/ued/es/src`))
}

export default series(
	async () => removeDist(),
	parallel(async () => buildStyle())
)
  • 新建utils/run.ts
ts
import { spawn } from 'child_process'

export default async (command: string, path: string) => {
	// cmd表示命令,args代表参数,如 rm -rf  rm就是命令,-rf就为参数
	const [cmd, ...args] = command.split(' ')
	return new Promise((resolve) => {
		const app = spawn(cmd, args, {
			cwd: path, // 执行命令的路径
			stdio: 'inherit', // 输出共享给父进程
			shell: true, // mac不需要开启,windows下git base需要开启支持
		})
		// 执行完毕关闭并resolve
		app.on('close', resolve)
	})
}
  • build/index.ts新增
ts
//打包组件
export const buildComponent = async () => {
	run('pnpm run build', componentPath)
}
  • vite.config.ts中忽略打包sass
ts
rollupOptions: {
	// 忽略打包vue文件
	external: ['vue', /\.scss/],
}
  • vite 打包忽略了 sass 文件打包,所以打包后的文件遇到.scss 文件的引入会自动跳过,所以引入代码没变将代码中的.scss换成.css

  • components/vite.config.ts 中的 plugins 中新增

ts
 plugins: [
    {
      name: "style",
      generateBundle(config, bundle) {
        //这里可以获取打包后的文件目录以及代码code
        const keys = Object.keys(bundle);

        for (const key of keys) {
          const bundler: any = bundle[key as any];
          //rollup内置方法,将所有输出文件code中的.scss换成.css,因为我们当时没有打包scss文件

          this.emitFile({
            type: "asset",
            fileName: key, //文件名名不变
            source: bundler.code.replace(/\.scss/g, ".css"),
          });
        }
      },
    },
  ],

VitePress 搭建部署组件库文档

安装vitepress
  • 新建site文件夹,并初始化 pnpm init
  • 安装 vitepress和vue
shell
pnpm install -D vitepress vue
  • 新建site/docs/index.md
md
# Hello ued-plus
  • 添加命令
json
{
	"scripts": {
		"docs:dev": "vitepress dev docs",
		"docs:build": "vitepress build docs",
		"docs:preview": "vitepress preview docs"
	}
}
  • 执行命令
shell
pnpm docs:dev
导航栏配置
  • 新建site/docs/.vitepress/config.js
js
export default {
	themeConfig: {
		siteTitle: 'vitepress',
		nav: [
			{ text: '指南', link: '/guide/installation/' },
			{ text: '组件', link: '/components/button/' },
		],
		socialLinks: [
			{ icon: 'github', link: 'https://github.com/bin-K/ued-plus' },
		],
	},
}
  • 新建site/docs/guide/index.md site/docs/guide/installation/index.md
  • 新建site/docs/components/button/index.md
侧边栏配置
  • site/docs/.vitepress/config.js配置sidebar
js
sidebar: {
	'/guide/': [
		{
			text: '基础',
			items: [
				{
					text: '安装',
					link: '/guide/installation/',
				},
				{
					text: '快速开始',
					link: '/guide/quickstart/',
				},
			],
		},
	],
	'/components/': [
		{
			text: '基础组件',
			items: [
				{
					text: 'Button',
					link: '/components/button/',
				},
			],
		},
	],
},
引入组件库
  • pnpm-workspace.yaml新增一个site目录
yml
packages:
  - 'packages/**'
  - 'play'
  - 'site'
  • site安装组件库
shell
pnpm add @ued-plus/components
  • 新建site/docs/.vitepress/theme/index.js引入组件库
js
import DefaultTheme from 'vitepress/theme'
import ued from '@ued-plus/components'
export default {
	...DefaultTheme,
	enhanceApp: async ({ app }) => {
		// app is the Vue 3 app instance from `createApp()`. router is VitePress'
		// custom router. `siteData`` is a `ref`` of current site-level metadata.
		app.use(ued)
	},
}
配置命令部署组件库
  • 新建site/deploy.sh
shell
#!/usr/bin/env sh

# 忽略错误
# set -e

# 构建
pnpm run docs:build
# 进入待发布的目录
cd docs/.vitepress/dist
git init
git remote add origin git@github.com:bin-K/ued-ui.git
git add -A
git commit -m 'docs(update):更新文档'
# 访问地址:https://bin-k.github.io/ued-ui/
git push -f origin master
  • 配置 命令
json
{
	"scripts": {
		"docs:publish": "sh deploy.sh"
	}
}
  • 新建 ued-ui仓库并且在setting中的pages 配置部署
  • 修改config.js
js
export default {
	base: process.env.NODE_ENV === 'production' ? '/ued-ui/' : '/',
}

集成项目的编程规范工具链(ESlint+Prettier+Stylelint)

  • ESlint和Prettier在搭建monorepo时已经添加,需要补充Stylelint
shell
pnpm add stylelint@^15.10.3 stylelint-prettier@^4.0.2 stylelint-config-standard stylelint-config-recommended-scss postcss-html stylelint-config-recommended-vue stylelint-config-recess-order stylelint-config-prettier -D -w
  • 新建.stylelintrc.cjs
js
module.exports = {
	// 注册 stylelint 的 prettier 插件
	plugins: ['stylelint-prettier'],
	// 继承一系列规则集合
	extends: [
		// standard 规则集合
		'stylelint-config-standard',
		'stylelint-config-recommended-scss',
		// 样式属性顺序规则
		'stylelint-config-recess-order',
		// 接入 Prettier 规则
		'stylelint-config-prettier',
		'stylelint-prettier/recommended',
	],
	// 配置 rules
	rules: {
		// 开启 Prettier 自动格式化功能
		'prettier/prettier': true,
	},
}
  • 新增命令
json
{
	"script": {
		// stylelint 命令
		"lint:style": "stylelint --fix \"**/*.{css,less,scss}\""
	},
	"lint-staged": {
		"**/*.{css,less,scss}": ["stylelint --fix \"**/*.{css,less}\""]
	}
}
  • 执行命令即可完成样式格式化

引入现代前端测试框架 Vitest

  • 安装依赖
shell
pnpm add vitest happy-dom @vitest/coverage-v8 -D -w
  • components/vite.config.ts中对 Vitest 做一个相关配置
ts
// 三斜线命令告诉编译器在编译过程中要引入的额外的文件
/// <reference types="vitest" />
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue"
...
export default defineConfig(
    {
        ...
        test: {
            environment: "happy-dom"
        },

    }
)
  • 添加测试命令, components/package.json
json
{
	"scripts": {
		"test": "vitest",
		"coverage": "vitest run --coverage"
	}
}
  • 执行test命令时,Vitest 会执行**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}的文件, 这里我们的测试文件统一命名为**/*.{test}.ts的形式并放在每个组件的__tests__目录下
  • 新建__tests__/button.test.ts
ts
import { describe, expect, it } from 'vitest'

describe('helloued', () => {
	it('should be helloued', () => {
		expect('hello' + 'ued').toBe('helloued')
	})
})
  • 测试组件
shell
pnpm add @vue/test-utils -D -w
ts
import { describe, expect, it } from 'vitest'

import { mount } from '@vue/test-utils'
import button from '../button.vue'

// The component to test
describe('test button', () => {
	it('should render slot', () => {
		const wrapper = mount(button, {
			slots: {
				default: 'ued',
			},
		})

		// Assert the rendered text of the component
		expect(wrapper.text()).toContain('ued')
	})

	it('should have class', () => {
		const wrapper = mount(button, {
			props: {
				type: 'primary',
			},
		})
		expect(wrapper.classes()).toContain('ued-button--primary')
	})
})