Vue 组件库开发与设计系统构建实战指南
Orion K Lv6

随着前端项目规模的不断扩大,构建一套统一的组件库和设计系统变得越来越重要。本文将详细介绍如何从零开始构建一个现代化的 Vue 3 组件库,包括设计原则、开发规范、工程化配置、文档系统以及发布流程。

设计系统基础架构

1. 设计令牌(Design Tokens)

颜色系统设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// tokens/colors.ts
export const colors = {
// 主色调
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9', // 主色
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
950: '#082f49'
},

// 中性色
neutral: {
50: '#fafafa',
100: '#f5f5f5',
200: '#e5e5e5',
300: '#d4d4d4',
400: '#a3a3a3',
500: '#737373',
600: '#525252',
700: '#404040',
800: '#262626',
900: '#171717',
950: '#0a0a0a'
},

// 语义化颜色
semantic: {
success: {
light: '#dcfce7',
DEFAULT: '#16a34a',
dark: '#15803d'
},
warning: {
light: '#fef3c7',
DEFAULT: '#d97706',
dark: '#b45309'
},
error: {
light: '#fee2e2',
DEFAULT: '#dc2626',
dark: '#b91c1c'
},
info: {
light: '#dbeafe',
DEFAULT: '#2563eb',
dark: '#1d4ed8'
}
}
} as const

// 类型定义
export type ColorScale = typeof colors.primary
export type SemanticColor = typeof colors.semantic.success
export type ColorToken = keyof typeof colors

间距和尺寸系统

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// tokens/spacing.ts
export const spacing = {
0: '0px',
1: '4px',
2: '8px',
3: '12px',
4: '16px',
5: '20px',
6: '24px',
8: '32px',
10: '40px',
12: '48px',
16: '64px',
20: '80px',
24: '96px',
32: '128px',
40: '160px',
48: '192px',
56: '224px',
64: '256px'
} as const

// 组件尺寸
export const sizes = {
xs: {
height: '24px',
padding: '4px 8px',
fontSize: '12px'
},
sm: {
height: '32px',
padding: '6px 12px',
fontSize: '14px'
},
md: {
height: '40px',
padding: '8px 16px',
fontSize: '16px'
},
lg: {
height: '48px',
padding: '12px 20px',
fontSize: '18px'
},
xl: {
height: '56px',
padding: '16px 24px',
fontSize: '20px'
}
} as const

export type SpacingToken = keyof typeof spacing
export type SizeVariant = keyof typeof sizes

字体系统

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// tokens/typography.ts
export const typography = {
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'Consolas', 'monospace'],
serif: ['Georgia', 'serif']
},

fontSize: {
xs: ['12px', { lineHeight: '16px' }],
sm: ['14px', { lineHeight: '20px' }],
base: ['16px', { lineHeight: '24px' }],
lg: ['18px', { lineHeight: '28px' }],
xl: ['20px', { lineHeight: '28px' }],
'2xl': ['24px', { lineHeight: '32px' }],
'3xl': ['30px', { lineHeight: '36px' }],
'4xl': ['36px', { lineHeight: '40px' }],
'5xl': ['48px', { lineHeight: '1' }],
'6xl': ['60px', { lineHeight: '1' }]
},

fontWeight: {
thin: '100',
extralight: '200',
light: '300',
normal: '400',
medium: '500',
semibold: '600',
bold: '700',
extrabold: '800',
black: '900'
},

letterSpacing: {
tighter: '-0.05em',
tight: '-0.025em',
normal: '0em',
wide: '0.025em',
wider: '0.05em',
widest: '0.1em'
}
} as const

export type FontFamily = keyof typeof typography.fontFamily
export type FontSize = keyof typeof typography.fontSize
export type FontWeight = keyof typeof typography.fontWeight

2. CSS 变量系统

动态主题支持

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// styles/tokens.scss
:root {
// 颜色变量
--color-primary-50: #{map-get($colors-primary, 50)};
--color-primary-500: #{map-get($colors-primary, 500)};
--color-primary-600: #{map-get($colors-primary, 600)};

// 间距变量
--spacing-1: 4px;
--spacing-2: 8px;
--spacing-4: 16px;
--spacing-6: 24px;

// 字体变量
--font-size-sm: 14px;
--font-size-base: 16px;
--font-size-lg: 18px;

// 阴影变量
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);

// 圆角变量
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;

// 过渡变量
--transition-fast: 150ms ease;
--transition-normal: 250ms ease;
--transition-slow: 350ms ease;
}

// 暗色主题
[data-theme='dark'] {
--color-primary-50: #{map-get($colors-primary, 900)};
--color-primary-500: #{map-get($colors-primary, 400)};
--color-primary-600: #{map-get($colors-primary, 300)};

--color-neutral-50: #{map-get($colors-neutral, 900)};
--color-neutral-900: #{map-get($colors-neutral, 50)};
}

// 主题混合器
@mixin theme-colors($light-color, $dark-color) {
color: $light-color;

[data-theme='dark'] & {
color: $dark-color;
}
}

@mixin theme-background($light-bg, $dark-bg) {
background-color: $light-bg;

[data-theme='dark'] & {
background-color: $dark-bg;
}
}

基础组件开发

1. Button 组件设计

组件接口设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
// components/Button/types.ts
export interface ButtonProps {
/**
* 按钮变体
*/
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'link'

/**
* 按钮尺寸
*/
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'

/**
* 按钮颜色
*/
color?: 'primary' | 'success' | 'warning' | 'error' | 'neutral'

/**
* 是否禁用
*/
disabled?: boolean

/**
* 是否加载中
*/
loading?: boolean

/**
* 是否为块级元素
*/
block?: boolean

/**
* 图标位置
*/
iconPlacement?: 'left' | 'right'

/**
* HTML 按钮类型
*/
type?: 'button' | 'submit' | 'reset'

/**
* 自定义类名
*/
class?: string

/**
* 点击事件
*/
onClick?: (event: MouseEvent) => void
}

export interface ButtonSlots {
/**
* 默认插槽
*/
default?: () => any

/**
* 图标插槽
*/
icon?: () => any

/**
* 加载图标插槽
*/
loading?: () => any
}

export interface ButtonEmits {
/**
* 点击事件
*/
click: [event: MouseEvent]

/**
* 焦点事件
*/
focus: [event: FocusEvent]

/**
* 失焦事件
*/
blur: [event: FocusEvent]
}

组件实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
<!-- components/Button/Button.vue -->
<template>
<button
:class="buttonClasses"
:disabled="disabled || loading"
:type="type"
@click="handleClick"
@focus="handleFocus"
@blur="handleBlur"
>
<!-- 加载状态 -->
<span v-if="loading" class="btn-loading">
<slot name="loading">
<LoadingIcon class="btn-loading-icon" />
</slot>
</span>

<!-- 左侧图标 -->
<span
v-if="$slots.icon && iconPlacement === 'left' && !loading"
class="btn-icon btn-icon--left"
>
<slot name="icon" />
</span>

<!-- 按钮内容 -->
<span class="btn-content">
<slot />
</span>

<!-- 右侧图标 -->
<span
v-if="$slots.icon && iconPlacement === 'right' && !loading"
class="btn-icon btn-icon--right"
>
<slot name="icon" />
</span>
</button>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import type { ButtonProps, ButtonEmits } from './types'
import LoadingIcon from '../LoadingIcon/LoadingIcon.vue'

// 定义组件名称
defineOptions({
name: 'UiButton'
})

// Props 定义
const props = withDefaults(defineProps<ButtonProps>(), {
variant: 'primary',
size: 'md',
color: 'primary',
disabled: false,
loading: false,
block: false,
iconPlacement: 'left',
type: 'button'
})

// Emits 定义
const emit = defineEmits<ButtonEmits>()

// 计算按钮样式类
const buttonClasses = computed(() => {
return [
'btn',
`btn--${props.variant}`,
`btn--${props.size}`,
`btn--${props.color}`,
{
'btn--disabled': props.disabled,
'btn--loading': props.loading,
'btn--block': props.block
},
props.class
]
})

// 事件处理
const handleClick = (event: MouseEvent) => {
if (props.disabled || props.loading) {
event.preventDefault()
return
}

emit('click', event)
props.onClick?.(event)
}

const handleFocus = (event: FocusEvent) => {
emit('focus', event)
}

const handleBlur = (event: FocusEvent) => {
emit('blur', event)
}
</script>

<style lang="scss" scoped>
.btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--spacing-2);
border: 1px solid transparent;
border-radius: var(--radius-md);
font-family: inherit;
font-weight: 500;
text-decoration: none;
cursor: pointer;
transition: all var(--transition-fast);
user-select: none;
white-space: nowrap;

&:focus-visible {
outline: 2px solid var(--color-primary-500);
outline-offset: 2px;
}

// 尺寸变体
&--xs {
height: 24px;
padding: 0 8px;
font-size: 12px;
border-radius: var(--radius-sm);
}

&--sm {
height: 32px;
padding: 0 12px;
font-size: 14px;
}

&--md {
height: 40px;
padding: 0 16px;
font-size: 16px;
}

&--lg {
height: 48px;
padding: 0 20px;
font-size: 18px;
}

&--xl {
height: 56px;
padding: 0 24px;
font-size: 20px;
}

// 样式变体
&--primary {
background-color: var(--color-primary-500);
color: white;

&:hover:not(:disabled) {
background-color: var(--color-primary-600);
}

&:active:not(:disabled) {
background-color: var(--color-primary-700);
}
}

&--secondary {
background-color: var(--color-neutral-100);
color: var(--color-neutral-900);

&:hover:not(:disabled) {
background-color: var(--color-neutral-200);
}
}

&--outline {
background-color: transparent;
border-color: var(--color-primary-500);
color: var(--color-primary-500);

&:hover:not(:disabled) {
background-color: var(--color-primary-50);
}
}

&--ghost {
background-color: transparent;
color: var(--color-primary-500);

&:hover:not(:disabled) {
background-color: var(--color-primary-50);
}
}

&--link {
background-color: transparent;
color: var(--color-primary-500);
text-decoration: underline;

&:hover:not(:disabled) {
color: var(--color-primary-600);
}
}

// 状态
&--disabled {
opacity: 0.5;
cursor: not-allowed;
}

&--loading {
cursor: wait;
}

&--block {
width: 100%;
}
}

.btn-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}

.btn-loading-icon {
width: 1em;
height: 1em;
animation: spin 1s linear infinite;
}

.btn-icon {
display: flex;
align-items: center;

&--left {
margin-right: calc(var(--spacing-1) * -1);
}

&--right {
margin-left: calc(var(--spacing-1) * -1);
}
}

.btn-content {
opacity: 1;
transition: opacity var(--transition-fast);

.btn--loading & {
opacity: 0;
}
}

@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

2. Input 组件设计

组件接口设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
// components/Input/types.ts
export interface InputProps {
/**
* 输入框值
*/
modelValue?: string | number

/**
* 输入框类型
*/
type?: 'text' | 'password' | 'email' | 'number' | 'tel' | 'url' | 'search'

/**
* 占位符
*/
placeholder?: string

/**
* 是否禁用
*/
disabled?: boolean

/**
* 是否只读
*/
readonly?: boolean

/**
* 是否必填
*/
required?: boolean

/**
* 输入框尺寸
*/
size?: 'sm' | 'md' | 'lg'

/**
* 验证状态
*/
status?: 'success' | 'warning' | 'error'

/**
* 错误信息
*/
errorMessage?: string

/**
* 帮助文本
*/
helpText?: string

/**
* 标签文本
*/
label?: string

/**
* 最大长度
*/
maxLength?: number

/**
* 是否显示字符计数
*/
showCount?: boolean

/**
* 是否可清空
*/
clearable?: boolean

/**
* 前缀图标
*/
prefixIcon?: string

/**
* 后缀图标
*/
suffixIcon?: string
}

export interface InputEmits {
'update:modelValue': [value: string | number]
input: [event: Event]
change: [event: Event]
focus: [event: FocusEvent]
blur: [event: FocusEvent]
clear: []
'prefix-click': [event: MouseEvent]
'suffix-click': [event: MouseEvent]
}

组件实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
<!-- components/Input/Input.vue -->
<template>
<div :class="wrapperClasses">
<!-- 标签 -->
<label v-if="label" :class="labelClasses" :for="inputId">
{{ label }}
<span v-if="required" class="input-required">*</span>
</label>

<!-- 输入框容器 -->
<div :class="containerClasses">
<!-- 前缀图标 -->
<span
v-if="$slots.prefix || prefixIcon"
class="input-prefix"
@click="handlePrefixClick"
>
<slot name="prefix">
<Icon v-if="prefixIcon" :name="prefixIcon" />
</slot>
</span>

<!-- 输入框 -->
<input
:id="inputId"
ref="inputRef"
:class="inputClasses"
:type="type"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:required="required"
:maxlength="maxLength"
@input="handleInput"
@change="handleChange"
@focus="handleFocus"
@blur="handleBlur"
>

<!-- 清空按钮 -->
<button
v-if="clearable && modelValue && !disabled && !readonly"
type="button"
class="input-clear"
@click="handleClear"
>
<Icon name="x" />
</button>

<!-- 后缀图标 -->
<span
v-if="$slots.suffix || suffixIcon"
class="input-suffix"
@click="handleSuffixClick"
>
<slot name="suffix">
<Icon v-if="suffixIcon" :name="suffixIcon" />
</slot>
</span>

<!-- 字符计数 -->
<span v-if="showCount && maxLength" class="input-count">
{{ String(modelValue || '').length }}/{{ maxLength }}
</span>
</div>

<!-- 帮助文本和错误信息 -->
<div v-if="helpText || errorMessage" class="input-help">
<span v-if="errorMessage" class="input-error">
{{ errorMessage }}
</span>
<span v-else-if="helpText" class="input-help-text">
{{ helpText }}
</span>
</div>
</div>
</template>

<script setup lang="ts">
import { computed, ref, nextTick } from 'vue'
import type { InputProps, InputEmits } from './types'
import Icon from '../Icon/Icon.vue'

// 生成唯一 ID
let idCounter = 0
const generateId = () => `input-${++idCounter}`

defineOptions({
name: 'UiInput'
})

const props = withDefaults(defineProps<InputProps>(), {
type: 'text',
size: 'md',
disabled: false,
readonly: false,
required: false,
clearable: false,
showCount: false
})

const emit = defineEmits<InputEmits>()

// 输入框引用
const inputRef = ref<HTMLInputElement>()
const inputId = generateId()

// 计算样式类
const wrapperClasses = computed(() => [
'input-wrapper',
`input-wrapper--${props.size}`,
{
'input-wrapper--disabled': props.disabled,
'input-wrapper--error': props.status === 'error',
'input-wrapper--success': props.status === 'success',
'input-wrapper--warning': props.status === 'warning'
}
])

const containerClasses = computed(() => [
'input-container',
{
'input-container--focused': false, // 可以添加焦点状态
'input-container--disabled': props.disabled
}
])

const labelClasses = computed(() => [
'input-label',
{
'input-label--required': props.required
}
])

const inputClasses = computed(() => [
'input-field'
])

// 事件处理
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement
const value = props.type === 'number' ? Number(target.value) : target.value

emit('update:modelValue', value)
emit('input', event)
}

const handleChange = (event: Event) => {
emit('change', event)
}

const handleFocus = (event: FocusEvent) => {
emit('focus', event)
}

const handleBlur = (event: FocusEvent) => {
emit('blur', event)
}

const handleClear = () => {
emit('update:modelValue', '')
emit('clear')

nextTick(() => {
inputRef.value?.focus()
})
}

const handlePrefixClick = (event: MouseEvent) => {
emit('prefix-click', event)
}

const handleSuffixClick = (event: MouseEvent) => {
emit('suffix-click', event)
}

// 暴露方法
defineExpose({
focus: () => inputRef.value?.focus(),
blur: () => inputRef.value?.blur(),
select: () => inputRef.value?.select()
})
</script>

<style lang="scss" scoped>
.input-wrapper {
display: flex;
flex-direction: column;
gap: var(--spacing-1);

&--sm {
font-size: 14px;
}

&--md {
font-size: 16px;
}

&--lg {
font-size: 18px;
}
}

.input-label {
font-weight: 500;
color: var(--color-neutral-700);

.input-required {
color: var(--color-error-DEFAULT);
margin-left: 2px;
}
}

.input-container {
position: relative;
display: flex;
align-items: center;
border: 1px solid var(--color-neutral-300);
border-radius: var(--radius-md);
background-color: white;
transition: all var(--transition-fast);

&:hover:not(.input-container--disabled) {
border-color: var(--color-primary-400);
}

&:focus-within {
border-color: var(--color-primary-500);
box-shadow: 0 0 0 3px var(--color-primary-100);
}

&--disabled {
background-color: var(--color-neutral-50);
cursor: not-allowed;
}

.input-wrapper--error & {
border-color: var(--color-error-DEFAULT);

&:focus-within {
box-shadow: 0 0 0 3px var(--color-error-light);
}
}

.input-wrapper--success & {
border-color: var(--color-success-DEFAULT);
}

.input-wrapper--warning & {
border-color: var(--color-warning-DEFAULT);
}
}

.input-field {
flex: 1;
border: none;
outline: none;
background: transparent;
font-family: inherit;
font-size: inherit;
color: var(--color-neutral-900);

.input-wrapper--sm & {
height: 32px;
padding: 0 12px;
}

.input-wrapper--md & {
height: 40px;
padding: 0 16px;
}

.input-wrapper--lg & {
height: 48px;
padding: 0 20px;
}

&::placeholder {
color: var(--color-neutral-400);
}

&:disabled {
cursor: not-allowed;
color: var(--color-neutral-500);
}
}

.input-prefix,
.input-suffix {
display: flex;
align-items: center;
padding: 0 8px;
color: var(--color-neutral-500);
cursor: pointer;

&:hover {
color: var(--color-neutral-700);
}
}

.input-clear {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
margin-right: 8px;
border: none;
border-radius: 50%;
background-color: var(--color-neutral-400);
color: white;
cursor: pointer;
transition: background-color var(--transition-fast);

&:hover {
background-color: var(--color-neutral-500);
}
}

.input-count {
padding: 0 8px;
font-size: 12px;
color: var(--color-neutral-500);
white-space: nowrap;
}

.input-help {
font-size: 12px;
line-height: 1.4;
}

.input-error {
color: var(--color-error-DEFAULT);
}

.input-help-text {
color: var(--color-neutral-600);
}
</style>

复合组件开发

1. Form 表单组件

表单验证系统

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
// components/Form/validation.ts
export interface ValidationRule {
required?: boolean
min?: number
max?: number
minLength?: number
maxLength?: number
pattern?: RegExp
validator?: (value: any, formData: Record<string, any>) => boolean | string
message?: string
}

export interface FieldValidation {
rules: ValidationRule[]
value: any
error?: string
touched: boolean
}

export class FormValidator {
private fields = new Map<string, FieldValidation>()

addField(name: string, rules: ValidationRule[], initialValue: any = '') {
this.fields.set(name, {
rules,
value: initialValue,
touched: false
})
}

removeField(name: string) {
this.fields.delete(name)
}

setValue(name: string, value: any) {
const field = this.fields.get(name)
if (field) {
field.value = value
field.touched = true
}
}

validateField(name: string, formData: Record<string, any>): boolean {
const field = this.fields.get(name)
if (!field) return true

const { rules, value } = field

for (const rule of rules) {
const error = this.checkRule(rule, value, formData)
if (error) {
field.error = error
return false
}
}

field.error = undefined
return true
}

validateAll(formData: Record<string, any>): boolean {
let isValid = true

for (const [name] of this.fields) {
if (!this.validateField(name, formData)) {
isValid = false
}
}

return isValid
}

private checkRule(rule: ValidationRule, value: any, formData: Record<string, any>): string | null {
// 必填验证
if (rule.required && (value === '' || value == null)) {
return rule.message || '此字段为必填项'
}

// 如果值为空且不是必填,跳过其他验证
if (value === '' || value == null) {
return null
}

// 最小值验证
if (rule.min !== undefined && Number(value) < rule.min) {
return rule.message || `值不能小于 ${rule.min}`
}

// 最大值验证
if (rule.max !== undefined && Number(value) > rule.max) {
return rule.message || `值不能大于 ${rule.max}`
}

// 最小长度验证
if (rule.minLength !== undefined && String(value).length < rule.minLength) {
return rule.message || `长度不能少于 ${rule.minLength} 个字符`
}

// 最大长度验证
if (rule.maxLength !== undefined && String(value).length > rule.maxLength) {
return rule.message || `长度不能超过 ${rule.maxLength} 个字符`
}

// 正则验证
if (rule.pattern && !rule.pattern.test(String(value))) {
return rule.message || '格式不正确'
}

// 自定义验证
if (rule.validator) {
const result = rule.validator(value, formData)
if (result !== true) {
return typeof result === 'string' ? result : (rule.message || '验证失败')
}
}

return null
}

getFieldError(name: string): string | undefined {
return this.fields.get(name)?.error
}

getErrors(): Record<string, string> {
const errors: Record<string, string> = {}

for (const [name, field] of this.fields) {
if (field.error) {
errors[name] = field.error
}
}

return errors
}

clearErrors() {
for (const [, field] of this.fields) {
field.error = undefined
}
}

reset() {
for (const [, field] of this.fields) {
field.error = undefined
field.touched = false
}
}
}

Form 组件实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
<!-- components/Form/Form.vue -->
<template>
<form :class="formClasses" @submit="handleSubmit">
<slot :validate="validate" :reset="reset" :errors="errors" />
</form>
</template>

<script setup lang="ts">
import { reactive, provide, computed } from 'vue'
import { FormValidator } from './validation'
import type { ValidationRule } from './validation'

export interface FormProps {
/**
* 表单布局
*/
layout?: 'vertical' | 'horizontal' | 'inline'

/**
* 标签宽度(水平布局时)
*/
labelWidth?: string

/**
* 表单尺寸
*/
size?: 'sm' | 'md' | 'lg'

/**
* 是否禁用整个表单
*/
disabled?: boolean
}

export interface FormEmits {
submit: [data: Record<string, any>, isValid: boolean]
'field-change': [name: string, value: any]
'validation-change': [errors: Record<string, string>]
}

defineOptions({
name: 'UiForm'
})

const props = withDefaults(defineProps<FormProps>(), {
layout: 'vertical',
size: 'md',
disabled: false
})

const emit = defineEmits<FormEmits>()

// 表单验证器
const validator = new FormValidator()

// 表单数据
const formData = reactive<Record<string, any>>({})

// 错误信息
const errors = reactive<Record<string, string>>({})

// 计算样式类
const formClasses = computed(() => [
'form',
`form--${props.layout}`,
`form--${props.size}`,
{
'form--disabled': props.disabled
}
])

// 注册字段
const registerField = (name: string, rules: ValidationRule[], initialValue: any = '') => {
validator.addField(name, rules, initialValue)
formData[name] = initialValue
}

// 注销字段
const unregisterField = (name: string) => {
validator.removeField(name)
delete formData[name]
delete errors[name]
}

// 设置字段值
const setFieldValue = (name: string, value: any) => {
formData[name] = value
validator.setValue(name, value)

// 验证字段
const isValid = validator.validateField(name, formData)
const error = validator.getFieldError(name)

if (error) {
errors[name] = error
} else {
delete errors[name]
}

emit('field-change', name, value)
emit('validation-change', { ...errors })
}

// 验证表单
const validate = (): boolean => {
const isValid = validator.validateAll(formData)
const newErrors = validator.getErrors()

// 清空现有错误
Object.keys(errors).forEach(key => {
delete errors[key]
})

// 设置新错误
Object.assign(errors, newErrors)

emit('validation-change', { ...errors })
return isValid
}

// 重置表单
const reset = () => {
validator.reset()

// 清空数据和错误
Object.keys(formData).forEach(key => {
formData[key] = ''
})

Object.keys(errors).forEach(key => {
delete errors[key]
})

emit('validation-change', {})
}

// 提交表单
const handleSubmit = (event: Event) => {
event.preventDefault()

const isValid = validate()
emit('submit', { ...formData }, isValid)
}

// 提供给子组件的上下文
provide('form', {
layout: props.layout,
size: props.size,
disabled: props.disabled,
labelWidth: props.labelWidth,
registerField,
unregisterField,
setFieldValue,
getFieldValue: (name: string) => formData[name],
getFieldError: (name: string) => errors[name]
})

// 暴露方法
defineExpose({
validate,
reset,
setFieldValue,
getFormData: () => ({ ...formData }),
getErrors: () => ({ ...errors })
})
</script>

<style lang="scss" scoped>
.form {
&--vertical {
.form-item {
margin-bottom: var(--spacing-4);
}
}

&--horizontal {
.form-item {
display: flex;
align-items: flex-start;
margin-bottom: var(--spacing-4);

.form-item-label {
flex-shrink: 0;
margin-bottom: 0;
margin-right: var(--spacing-3);
padding-top: var(--spacing-2);
}

.form-item-content {
flex: 1;
}
}
}

&--inline {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4);

.form-item {
margin-bottom: 0;
}
}

&--disabled {
pointer-events: none;
opacity: 0.6;
}
}
</style>

2. Modal 对话框组件

Modal 组件实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
<!-- components/Modal/Modal.vue -->
<template>
<Teleport to="body">
<Transition
name="modal"
@enter="onEnter"
@after-enter="onAfterEnter"
@leave="onLeave"
@after-leave="onAfterLeave"
>
<div
v-if="visible"
:class="modalClasses"
@click="handleMaskClick"
@keydown.esc="handleEscKey"
>
<div
ref="modalRef"
:class="dialogClasses"
:style="dialogStyles"
@click.stop
>
<!-- 头部 -->
<div v-if="$slots.header || title" class="modal-header">
<slot name="header">
<h3 class="modal-title">{{ title }}</h3>
</slot>

<button
v-if="closable"
type="button"
class="modal-close"
@click="handleClose"
>
<Icon name="x" />
</button>
</div>

<!-- 内容 -->
<div class="modal-body">
<slot />
</div>

<!-- 底部 -->
<div v-if="$slots.footer" class="modal-footer">
<slot name="footer" />
</div>
</div>
</div>
</Transition>
</Teleport>
</template>

<script setup lang="ts">
import { computed, ref, watch, nextTick } from 'vue'
import Icon from '../Icon/Icon.vue'

export interface ModalProps {
/**
* 是否显示
*/
visible?: boolean

/**
* 标题
*/
title?: string

/**
* 宽度
*/
width?: string | number

/**
* 是否可关闭
*/
closable?: boolean

/**
* 点击遮罩是否关闭
*/
maskClosable?: boolean

/**
* 是否居中
*/
centered?: boolean

/**
* 层级
*/
zIndex?: number

/**
* 是否销毁内容
*/
destroyOnClose?: boolean
}

export interface ModalEmits {
'update:visible': [visible: boolean]
open: []
close: []
'after-open': []
'after-close': []
}

defineOptions({
name: 'UiModal'
})

const props = withDefaults(defineProps<ModalProps>(), {
visible: false,
closable: true,
maskClosable: true,
centered: false,
zIndex: 1000,
destroyOnClose: false
})

const emit = defineEmits<ModalEmits>()

// 模态框引用
const modalRef = ref<HTMLElement>()

// 计算样式类
const modalClasses = computed(() => [
'modal-mask',
{
'modal-mask--centered': props.centered
}
])

const dialogClasses = computed(() => [
'modal-dialog'
])

const dialogStyles = computed(() => ({
width: typeof props.width === 'number' ? `${props.width}px` : props.width,
zIndex: props.zIndex
}))

// 处理关闭
const handleClose = () => {
emit('update:visible', false)
emit('close')
}

// 处理遮罩点击
const handleMaskClick = () => {
if (props.maskClosable) {
handleClose()
}
}

// 处理 ESC 键
const handleEscKey = () => {
if (props.closable) {
handleClose()
}
}

// 动画事件
const onEnter = () => {
emit('open')
document.body.style.overflow = 'hidden'
}

const onAfterEnter = () => {
emit('after-open')
nextTick(() => {
modalRef.value?.focus()
})
}

const onLeave = () => {
// 开始关闭动画
}

const onAfterLeave = () => {
emit('after-close')
document.body.style.overflow = ''
}

// 监听 visible 变化
watch(
() => props.visible,
(visible) => {
if (visible) {
nextTick(() => {
modalRef.value?.focus()
})
}
}
)
</script>

<style lang="scss" scoped>
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-start;
justify-content: center;
padding: var(--spacing-8);
overflow-y: auto;

&--centered {
align-items: center;
}
}

.modal-dialog {
position: relative;
background: white;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
outline: none;

@media (max-width: 768px) {
width: 100% !important;
max-width: none;
margin: 0;
border-radius: 0;
}
}

.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-6);
border-bottom: 1px solid var(--color-neutral-200);
}

.modal-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--color-neutral-900);
}

.modal-close {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-neutral-500);
cursor: pointer;
transition: all var(--transition-fast);

&:hover {
background-color: var(--color-neutral-100);
color: var(--color-neutral-700);
}
}

.modal-body {
flex: 1;
padding: var(--spacing-6);
overflow-y: auto;
}

.modal-footer {
padding: var(--spacing-6);
border-top: 1px solid var(--color-neutral-200);
display: flex;
justify-content: flex-end;
gap: var(--spacing-3);
}

// 动画
.modal-enter-active,
.modal-leave-active {
transition: opacity var(--transition-normal);

.modal-dialog {
transition: transform var(--transition-normal);
}
}

.modal-enter-from,
.modal-leave-to {
opacity: 0;

.modal-dialog {
transform: scale(0.9) translateY(-20px);
}
}
</style>

工程化配置

1. 构建配置

Vite 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import dts from 'vite-plugin-dts'
import { resolve } from 'path'

export default defineConfig({
plugins: [
vue(),
dts({
insertTypesEntry: true,
cleanVueFileName: true,
skipDiagnostics: false,
tsConfigFilePath: './tsconfig.build.json'
})
],

build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'UiLibrary',
fileName: (format) => `ui-library.${format}.js`,
formats: ['es', 'umd']
},

rollupOptions: {
external: ['vue'],
output: {
globals: {
vue: 'Vue'
},
assetFileNames: (assetInfo) => {
if (assetInfo.name === 'style.css') {
return 'ui-library.css'
}
return assetInfo.name
}
}
},

cssCodeSplit: false,
sourcemap: true,
minify: 'terser',

terserOptions: {
compress: {
drop_console: true,
drop_debugger: true
}
}
},

resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
}
})

TypeScript 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// tsconfig.build.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": false,
"outDir": "dist",
"rootDir": "src"
},
"include": [
"src/**/*"
],
"exclude": [
"src/**/*.stories.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"docs",
"playground"
]
}

2. 组件导出

统一导出文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// src/index.ts
import type { App } from 'vue'

// 组件导入
import Button from './components/Button/Button.vue'
import Input from './components/Input/Input.vue'
import Modal from './components/Modal/Modal.vue'
import Form from './components/Form/Form.vue'
import FormItem from './components/Form/FormItem.vue'

// 类型导出
export type { ButtonProps } from './components/Button/types'
export type { InputProps } from './components/Input/types'
export type { ModalProps } from './components/Modal/types'
export type { FormProps } from './components/Form/types'

// 组件列表
const components = [
Button,
Input,
Modal,
Form,
FormItem
]

// 单独导出组件
export {
Button,
Input,
Modal,
Form,
FormItem
}

// 安装函数
export function install(app: App) {
components.forEach(component => {
app.component(component.name || component.__name, component)
})
}

// 默认导出
export default {
install
}

// 版本信息
export const version = '__VERSION__'

总结

构建一个现代化的 Vue 组件库需要考虑多个方面:

  1. 设计系统:建立统一的设计令牌和规范
  2. 组件设计:遵循单一职责、可组合、可扩展的原则
  3. 类型安全:完整的 TypeScript 支持
  4. 工程化:完善的构建、测试、文档系统
  5. 可访问性:遵循 WCAG 标准,支持键盘导航和屏幕阅读器
  6. 性能优化:按需加载、Tree-shaking 支持
  7. 主题定制:支持多主题和动态主题切换

通过系统性的设计和开发,可以构建出高质量、易维护、可扩展的组件库,为团队提供统一的开发体验和用户界面。

本站由 提供部署服务