组件封装规范
介绍
本文档定义了 RuoYi-Plus-UniApp 项目中组件封装的规范标准,包括命名规则、代码组织、类型定义、样式规范等内容,确保团队开发的一致性和代码质量。组件封装是构建可维护、可复用、高质量应用的基础,遵循统一的规范能够大幅提升开发效率和代码可读性。
核心规范要点:
- 命名规范 - 统一的文件、组件、变量、CSS 类名命名规则
- 代码组织 - 规范的代码结构和模块顺序
- 类型规范 - TypeScript 类型定义标准
- 样式规范 - SCSS、BEM 命名和主题变量约定
- 文档规范 - 组件文档和注释标准
- 通信规范 - 组件间通信模式和最佳实践
- 性能规范 - 组件性能优化指南
- 测试规范 - 组件测试标准
架构设计
组件系统架构图
┌─────────────────────────────────────────────────────────────┐
│ 组件系统架构 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 应用层 │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │ 页面 │ │ 页面 │ │ 页面 │ ... │ │
│ │ └────┬────┘ └────┬────┘ └────┬────┘ │ │
│ └───────┼───────────┼───────────┼─────────────────────┘ │
│ │ │ │ │
│ ┌───────▼───────────▼───────────▼─────────────────────┐ │
│ │ 业务组件层 │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │AuthModal │ │UserCard │ │OrderList │ ... │ │
│ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │
│ └───────┼─────────────┼─────────────┼─────────────────┘ │
│ │ │ │ │
│ ┌───────▼─────────────▼─────────────▼─────────────────┐ │
│ │ 基础组件层 (WD UI) │ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ │
│ │ │ Button │ │ Input │ │ Form │ │ Popup │ │ │
│ │ └───┬────┘ └───┬────┘ └───┬────┘ └───┬────┘ │ │
│ └──────┼───────────┼───────────┼───────────┼──────────┘ │
│ │ │ │ │ │
│ ┌──────▼───────────▼───────────▼───────────▼──────────┐ │
│ │ 工具函数层 │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │Composables│ │ Utils │ │ Stores │ │ │
│ │ └─────────┘ └─────────┘ └─────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 平台适配层 │ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ │
│ │ │ H5 │ │ 微信 │ │ 支付宝 │ │ App │ │ │
│ │ └────────┘ └────────┘ └────────┘ └────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘组件生命周期流程
┌──────────────────────────────────────────────────────────────┐
│ 组件生命周期流程 │
├──────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ │
│ │ 创建阶段 │ │
│ └────┬────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ setup() - 组合式 API 入口 │ │
│ │ ├─ defineProps() - 定义属性 │ │
│ │ ├─ defineEmits() - 定义事件 │ │
│ │ ├─ ref/reactive - 响应式数据 │ │
│ │ ├─ computed - 计算属性 │ │
│ │ └─ watch/watchEffect - 侦听器 │ │
│ └────────────────┬────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ onBeforeMount() - 挂载前 │ │
│ │ └─ 初始化逻辑 │ │
│ └────────────────┬────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ onMounted() - 挂载完成 │ │
│ │ ├─ DOM 操作 │ │
│ │ ├─ 异步数据请求 │ │
│ │ └─ 事件监听注册 │ │
│ └────────────────┬────────────────────────┘ │
│ │ │
│ ┌───────────┴───────────┐ │
│ │ 更新阶段 │ │
│ ▼ ▼ │
│ ┌─────────┐ ┌──────────────┐ │
│ │ 数据变化 │◄─────────►│ onUpdated() │ │
│ └─────────┘ └──────────────┘ │
│ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ onBeforeUnmount() - 卸载前 │ │
│ │ ├─ 清理定时器 │ │
│ │ ├─ 取消事件监听 │ │
│ │ └─ 取消网络请求 │ │
│ └────────────────┬────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ onUnmounted() - 卸载完成 │ │
│ └─────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘命名规范
文件命名
# 组件文件:小写连字符 (kebab-case)
user-card.vue ✅ 正确
UserCard.vue ❌ 错误 - 不使用 PascalCase
userCard.vue ❌ 错误 - 不使用 camelCase
# 组件目录结构
components/
├── user-card/
│ ├── user-card.vue # 组件主文件
│ ├── types.ts # 类型定义文件
│ ├── index.ts # 导出文件
│ └── README.md # 组件文档(可选)
# 类型文件命名
types.ts ✅ 通用类型文件
user-card.types.ts ✅ 组件专用类型文件
UserCard.types.ts ❌ 错误
# 导出文件
index.ts ✅ 标准导出文件名
# Composable 文件命名
use-user.ts ✅ 正确 - 以 use 开头
useUser.ts ❌ 不推荐
user-composable.ts ❌ 不推荐组件名称
typescript
// 组件名使用 PascalCase,在 defineOptions 中声明
defineOptions({
name: 'UserCard', // ✅ 正确 - PascalCase
// name: 'user-card', // ❌ 错误 - 不使用 kebab-case
// name: 'userCard', // ❌ 错误 - 不使用 camelCase
})
// WD UI 组件命名规范:以 Wd 为前缀
defineOptions({
name: 'WdButton', // ✅ 正确 - WD UI 组件
name: 'WdInput', // ✅ 正确
})
// 业务组件命名规范:语义化命名
defineOptions({
name: 'AuthModal', // ✅ 正确 - 认证弹窗
name: 'UserSelect', // ✅ 正确 - 用户选择器
name: 'OrderCard', // ✅ 正确 - 订单卡片
})Props 接口命名
typescript
// 接口名:组件名 + Props
interface UserCardProps { } // ✅ 正确
interface IUserCardProps { } // ❌ 错误 - 不使用 I 前缀
interface UserCardPropsType { } // ❌ 错误 - 不需要 Type 后缀
// WD UI 组件 Props 命名
interface WdButtonProps { } // ✅ 正确
interface WdInputProps { } // ✅ 正确
// 复杂 Props 拆分
interface UserCardBaseProps {
userId: string
name?: string
}
interface UserCardStyleProps {
customClass?: string
customStyle?: string | Record<string, string>
}
// 组合 Props
interface UserCardProps extends UserCardBaseProps, UserCardStyleProps {
showBadge?: boolean
}Emits 接口命名
typescript
// 接口名:组件名 + Emits
interface UserCardEmits { // ✅ 正确
(e: 'click', userId: string): void
(e: 'load', data: UserInfo): void
}
// 替代方案:使用 Events 后缀(不推荐)
interface UserCardEvents { } // ❌ 不推荐,统一使用 Emits
// 简单事件定义
type UserCardEmits = {
click: [userId: string]
load: [data: UserInfo]
error: [error: Error]
}变量命名
typescript
// 响应式变量:camelCase
const isLoading = ref(false) // ✅ 正确
const currentUser = ref(null) // ✅ 正确
const is_loading = ref(false) // ❌ 错误 - 不使用下划线
// 常量:SCREAMING_SNAKE_CASE
const MAX_COUNT = 100 // ✅ 正确 - 模块级常量
const DEFAULT_PAGE_SIZE = 20 // ✅ 正确
const API_BASE_URL = '/api' // ✅ 正确
const maxCount = 100 // ❌ 错误 - 常量应使用大写
// 函数:camelCase + 动词前缀
const handleClick = () => { } // ✅ 正确 - 事件处理函数
const onClick = () => { } // ✅ 正确 - 事件处理函数
const fetchUserData = () => { } // ✅ 正确 - 数据获取函数
const computeTotal = () => { } // ✅ 正确 - 计算函数
const validateForm = () => { } // ✅ 正确 - 验证函数
const click = () => { } // ❌ 错误 - 缺少动词前缀
// 私有方法:下划线前缀(可选)
const _internalHelper = () => { } // ✅ 可选 - 内部辅助函数
// 布尔值命名
const isVisible = ref(true) // ✅ is 前缀
const hasError = ref(false) // ✅ has 前缀
const canSubmit = ref(true) // ✅ can 前缀
const shouldRefresh = ref(false)// ✅ should 前缀CSS 类名
scss
// BEM 命名规范
.user-card { } // Block - 块
.user-card__header { } // Element - 元素
.user-card__title { } // Element
.user-card__avatar { } // Element
.user-card--large { } // Modifier - 修饰符
.user-card--small { } // Modifier
.user-card.is-active { } // State - 状态类
.user-card.is-disabled { } // State
.user-card.is-loading { } // State
// 组合使用示例
.user-card {
&__header {
&--sticky { } // Element + Modifier
}
&--large {
.user-card__title { } // Block Modifier 影响 Element
}
}
// 工具类命名
.u-flex { } // u- 前缀表示工具类
.u-text-center { }
.u-mb-20 { }代码组织
组件结构顺序
vue
<template>
<!-- 1. 模板内容 - 保持清晰的层级结构 -->
<view :class="rootClass" :style="rootStyle">
<!-- 条件渲染放在前面 -->
<view v-if="loading" class="user-card__loading">
<wd-loading />
</view>
<!-- 主要内容 -->
<template v-else>
<view class="user-card__header">
<slot name="header" />
</view>
<view class="user-card__content">
<slot />
</view>
<view class="user-card__footer">
<slot name="footer" />
</view>
</template>
</view>
</template>
<script lang="ts" setup>
// ==================== 1. 导入语句 ====================
// 按以下顺序分组,每组之间空一行
// 1.1 Vue 核心
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
// 1.2 类型导入(单独分组)
import type { PropType, CSSProperties } from 'vue'
import type { UserInfo } from '@/types'
// 1.3 第三方库
import dayjs from 'dayjs'
// 1.4 项目内部模块
import { useUserStore } from '@/stores/user'
import { formatDate } from '@/utils/date'
// 1.5 组件导入
import WdButton from '@/wd/components/wd-button/wd-button.vue'
import UserAvatar from '../user-avatar/user-avatar.vue'
// 1.6 本地模块
import type { UserCardProps, UserCardEmits } from './types'
import { USER_CARD_DEFAULTS } from './constants'
// ==================== 2. 组件配置 ====================
defineOptions({
name: 'UserCard',
options: {
addGlobalClass: true, // 允许使用全局样式
virtualHost: true, // 虚拟主机节点
styleIsolation: 'shared', // 样式隔离模式
},
})
// ==================== 3. Props 定义 ====================
const props = withDefaults(defineProps<UserCardProps>(), {
size: 'medium',
showBadge: false,
customClass: '',
})
// ==================== 4. Emits 定义 ====================
const emit = defineEmits<UserCardEmits>()
// ==================== 5. 响应式状态 ====================
// 5.1 本地状态
const isLoading = ref(false)
const currentIndex = ref(0)
const userData = ref<UserInfo | null>(null)
// 5.2 Store 引用
const userStore = useUserStore()
// ==================== 6. 计算属性 ====================
const rootClass = computed(() => {
return [
'user-card',
`user-card--${props.size}`,
props.customClass,
{
'is-loading': isLoading.value,
'is-active': props.showBadge,
},
]
})
const rootStyle = computed(() => {
const style: CSSProperties = {}
if (props.customStyle) {
Object.assign(style, props.customStyle)
}
return style
})
const displayName = computed(() => {
return userData.value?.name || props.name || '未知用户'
})
// ==================== 7. 侦听器 ====================
watch(
() => props.userId,
(newId) => {
if (newId) {
loadUserData(newId)
}
},
{ immediate: true }
)
watch(
() => userStore.currentUser,
(user) => {
if (user?.id === props.userId) {
userData.value = user
}
}
)
// ==================== 8. 方法定义 ====================
// 8.1 事件处理方法
const handleClick = (event: Event) => {
if (isLoading.value) return
emit('click', props.userId)
}
const handleError = (error: Error) => {
console.error('[UserCard] Error:', error)
emit('error', error)
}
// 8.2 业务逻辑方法
const loadUserData = async (userId: string) => {
try {
isLoading.value = true
const data = await userStore.fetchUser(userId)
userData.value = data
emit('load', data)
} catch (error) {
handleError(error as Error)
} finally {
isLoading.value = false
}
}
const refresh = () => {
if (props.userId) {
loadUserData(props.userId)
}
}
// ==================== 9. 生命周期 ====================
onMounted(() => {
// 组件挂载后的初始化逻辑
})
onUnmounted(() => {
// 组件卸载前的清理逻辑
})
// ==================== 10. 暴露方法 ====================
defineExpose({
refresh,
userData,
})
</script>
<style lang="scss" scoped>
// ==================== 11. 样式定义 ====================
@import '@/styles/variables';
.user-card {
// 基础样式
display: flex;
padding: 24rpx;
background: $-color-white;
border-radius: 16rpx;
// Element 样式
&__header { }
&__content { }
&__footer { }
// Modifier 样式
&--small { }
&--large { }
// State 样式
&.is-loading { }
&.is-active { }
}
</style>导入语句分组规范
typescript
// ==================== 导入顺序规范 ====================
// 1. Vue 核心 API
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
// 2. 类型导入(使用 type 关键字)
import type { PropType, ComputedRef, Ref } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import type { UserInfo, ApiResponse } from '@/types'
// 3. 第三方库
import dayjs from 'dayjs'
import { debounce, throttle } from 'lodash-es'
// 4. 项目公共模块
import { useUserStore } from '@/stores/user'
import { useRequest } from '@/composables/use-request'
import { formatDate, formatMoney } from '@/utils/format'
import { userApi } from '@/api/user'
// 5. 相对路径组件导入
import WdButton from '@/wd/components/wd-button/wd-button.vue'
import UserAvatar from '../user-avatar/user-avatar.vue'
import InfoCard from './components/info-card.vue'
// 6. 当前模块文件
import type { UserCardProps, UserCardEmits } from './types'
import { DEFAULT_CONFIG, SIZE_MAP } from './constants'
import { useUserCard } from './composables'UniApp 组件配置
defineOptions 配置详解
typescript
defineOptions({
name: 'WdButton',
options: {
/**
* addGlobalClass - 允许组件使用全局样式
*
* 设置为 true 时,组件可以使用页面中定义的全局 CSS 类
* 这对于主题定制和样式覆盖非常有用
*/
addGlobalClass: true,
/**
* virtualHost - 虚拟主机节点
*
* 设置为 true 时,组件不会在 DOM 中创建额外的包装元素
* 好处:
* - 减少 DOM 层级
* - 样式穿透更容易
* - 性能更好
*/
virtualHost: true,
/**
* styleIsolation - 样式隔离模式
*
* 可选值:
* - 'isolated': 完全隔离,组件样式不受外部影响
* - 'apply-shared': 应用共享样式,可使用页面样式
* - 'shared': 共享样式,组件和页面样式互通
*
* 推荐使用 'shared' 以便主题定制
*/
styleIsolation: 'shared',
/**
* multipleSlots - 启用多插槽支持(微信小程序)
*
* 设置为 true 时,组件可以使用多个具名插槽
*/
multipleSlots: true,
},
})组件配置最佳实践
typescript
// ✅ 推荐的 WD UI 组件配置
defineOptions({
name: 'WdInput',
options: {
addGlobalClass: true,
virtualHost: true,
styleIsolation: 'shared',
},
})
// ✅ 推荐的业务组件配置
defineOptions({
name: 'UserCard',
options: {
addGlobalClass: true,
virtualHost: true,
styleIsolation: 'shared',
},
})
// ✅ 需要完全隔离的组件(如弹窗)
defineOptions({
name: 'ConfirmDialog',
options: {
addGlobalClass: false,
virtualHost: false, // 弹窗需要包装元素
styleIsolation: 'isolated',
},
})类型规范
Props 类型定义
typescript
// types.ts - 组件类型定义文件
/**
* 用户卡片尺寸类型
*/
export type UserCardSize = 'small' | 'medium' | 'large'
/**
* 用户卡片主题类型
*/
export type UserCardTheme = 'light' | 'dark' | 'primary'
/**
* 用户卡片属性接口
*
* @description 定义 UserCard 组件的所有可配置属性
*/
export interface UserCardProps {
/**
* 用户 ID
* @description 用于获取用户信息的唯一标识
*/
userId: string
/**
* 用户名称
* @description 直接显示的用户名,优先级高于 userId 获取的名称
* @default undefined
*/
name?: string
/**
* 头像地址
* @description 用户头像的 URL 地址
* @default undefined
*/
avatar?: string
/**
* 卡片尺寸
* @description 控制卡片的大小
* @default 'medium'
*/
size?: UserCardSize
/**
* 卡片主题
* @description 控制卡片的主题风格
* @default 'light'
*/
theme?: UserCardTheme
/**
* 是否显示徽章
* @description 在头像上显示状态徽章
* @default false
*/
showBadge?: boolean
/**
* 徽章内容
* @description 徽章显示的文本或数字
* @default undefined
*/
badgeContent?: string | number
/**
* 是否可点击
* @description 设置卡片是否响应点击事件
* @default true
*/
clickable?: boolean
/**
* 自定义类名
* @description 添加到根元素的自定义 CSS 类名
* @default ''
*/
customClass?: string
/**
* 自定义样式
* @description 添加到根元素的自定义内联样式
* @default undefined
*/
customStyle?: string | Record<string, string>
}Emits 类型定义
typescript
/**
* 用户卡片事件接口
*
* @description 定义 UserCard 组件触发的所有事件
*/
export interface UserCardEmits {
/**
* 点击事件
* @description 用户点击卡片时触发
* @param userId - 被点击用户的 ID
*/
(e: 'click', userId: string): void
/**
* 数据加载完成事件
* @description 用户数据加载成功时触发
* @param data - 加载的用户信息
*/
(e: 'load', data: UserInfo): void
/**
* 错误事件
* @description 发生错误时触发
* @param error - 错误对象
*/
(e: 'error', error: Error): void
/**
* 值更新事件(v-model 支持)
* @description 内部值变化时触发
* @param value - 新的值
*/
(e: 'update:modelValue', value: string): void
}
// 替代写法:使用类型别名
export type UserCardEmits = {
click: [userId: string]
load: [data: UserInfo]
error: [error: Error]
'update:modelValue': [value: string]
}内部类型定义
typescript
/**
* 组件内部状态类型
* @internal
*/
interface ComponentState {
loading: boolean
error: Error | null
data: UserInfo | null
}
/**
* 加载选项类型
* @internal
*/
interface LoadOptions {
/** 页码 */
page?: number
/** 每页数量 */
pageSize?: number
/** 是否强制刷新 */
refresh?: boolean
/** 是否显示加载状态 */
showLoading?: boolean
}
/**
* 组件实例类型
* 用于 ref 引用时的类型推断
*/
export type UserCardInstance = InstanceType<typeof UserCard>
/**
* 组件暴露的方法类型
*/
export interface UserCardExpose {
/** 刷新用户数据 */
refresh: () => Promise<void>
/** 重置组件状态 */
reset: () => void
/** 获取当前用户数据 */
getUserData: () => UserInfo | null
}类型导出规范
typescript
// types.ts - 类型导出文件
// 导出所有类型
export interface UserCardProps { ... }
export interface UserCardEmits { ... }
export type UserCardSize = 'small' | 'medium' | 'large'
export type UserCardTheme = 'light' | 'dark' | 'primary'
export type UserCardInstance = InstanceType<typeof UserCard>
// index.ts - 组件导出文件
// 导出组件
export { default as UserCard } from './user-card.vue'
// 重新导出所有类型
export * from './types'
// 导出 Composable(如果有)
export { useUserCard } from './composables'组件通信规范
父子组件通信
typescript
// ==================== 1. Props/Emits 模式(推荐) ====================
// 父组件
<template>
<user-card
:user-id="userId"
:name="userName"
@click="handleUserClick"
@load="handleUserLoad"
/>
</template>
<script lang="ts" setup>
const userId = ref('123')
const userName = ref('张三')
const handleUserClick = (id: string) => {
console.log('Clicked user:', id)
}
const handleUserLoad = (data: UserInfo) => {
console.log('User loaded:', data)
}
</script>
// 子组件
<script lang="ts" setup>
const props = defineProps<{
userId: string
name?: string
}>()
const emit = defineEmits<{
click: [userId: string]
load: [data: UserInfo]
}>()
const handleClick = () => {
emit('click', props.userId)
}
</script>v-model 双向绑定
typescript
// ==================== 2. v-model 模式 ====================
// 父组件
<template>
<user-select v-model="selectedUserId" />
<!-- 多个 v-model -->
<date-range-picker
v-model:start="startDate"
v-model:end="endDate"
/>
</template>
// 子组件 - 单个 v-model
<script lang="ts" setup>
interface Props {
modelValue: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
// 创建计算属性简化使用
const innerValue = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
</script>
// 子组件 - 多个 v-model
<script lang="ts" setup>
interface Props {
start: string
end: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:start': [value: string]
'update:end': [value: string]
}>()
</script>Provide/Inject 模式
typescript
// ==================== 3. Provide/Inject 模式 ====================
// types.ts - 定义注入键和类型
import type { InjectionKey, Ref } from 'vue'
export interface FormContext {
/** 表单模型数据 */
model: Ref<Record<string, any>>
/** 验证规则 */
rules: Ref<Record<string, any>>
/** 验证方法 */
validate: () => Promise<boolean>
/** 重置方法 */
resetFields: () => void
/** 注册表单项 */
registerField: (field: FormFieldContext) => void
/** 注销表单项 */
unregisterField: (field: FormFieldContext) => void
}
export const FORM_KEY: InjectionKey<FormContext> = Symbol('form')
// 父组件 (Form)
<script lang="ts" setup>
import { provide, ref, reactive } from 'vue'
import { FORM_KEY, type FormContext } from './types'
const model = ref<Record<string, any>>({})
const rules = ref<Record<string, any>>({})
const fields = reactive<FormFieldContext[]>([])
const validate = async () => {
const results = await Promise.all(
fields.map(field => field.validate())
)
return results.every(Boolean)
}
const resetFields = () => {
fields.forEach(field => field.reset())
}
const registerField = (field: FormFieldContext) => {
fields.push(field)
}
const unregisterField = (field: FormFieldContext) => {
const index = fields.indexOf(field)
if (index > -1) {
fields.splice(index, 1)
}
}
// 提供上下文给子组件
provide<FormContext>(FORM_KEY, {
model,
rules,
validate,
resetFields,
registerField,
unregisterField,
})
</script>
// 子组件 (FormItem)
<script lang="ts" setup>
import { inject, onMounted, onUnmounted } from 'vue'
import { FORM_KEY, type FormContext } from './types'
const formContext = inject<FormContext>(FORM_KEY, null)
if (!formContext) {
console.warn('[FormItem] 必须在 Form 组件内使用')
}
const fieldContext: FormFieldContext = {
validate: () => { ... },
reset: () => { ... },
}
onMounted(() => {
formContext?.registerField(fieldContext)
})
onUnmounted(() => {
formContext?.unregisterField(fieldContext)
})
</script>useParent/useChildren 模式
typescript
// ==================== 4. useParent/useChildren 模式(WD UI) ====================
// 父组件使用 useChildren
import { useChildren } from '@/wd/components/composables/useChildren'
const TABS_KEY: InjectionKey<TabsContext> = Symbol('tabs')
const { children, linkChildren } = useChildren<TabInstance>(TABS_KEY)
// 建立父子组件链接,并提供上下文数据
linkChildren({
activeIndex,
setActiveIndex,
scrollable,
})
// 子组件使用 useParent
import { useParent } from '@/wd/components/composables/useParent'
const { parent, index } = useParent<TabsContext>(TABS_KEY)
if (parent) {
// 可以访问父组件提供的数据和方法
const isActive = computed(() => parent.activeIndex.value === index.value)
}事件总线模式
typescript
// ==================== 5. 事件总线模式(跨组件通信) ====================
// 使用 mitt 或自定义事件总线
import { useEventBus } from '@/composables/use-event-bus'
// 组件 A - 发送事件
const eventBus = useEventBus()
const notifyUpdate = () => {
eventBus.emit('user:updated', { userId: '123', name: '张三' })
}
// 组件 B - 监听事件
const eventBus = useEventBus()
onMounted(() => {
eventBus.on('user:updated', (data) => {
console.log('User updated:', data)
// 处理更新
})
})
onUnmounted(() => {
eventBus.off('user:updated')
})样式规范
BEM 命名规范
scss
// ==================== BEM 命名规范 ====================
// Block - 块级元素,代表一个独立的组件
.user-card {
display: flex;
padding: 24rpx;
background: #fff;
border-radius: 16rpx;
}
// Element - 元素,块的组成部分
.user-card {
&__avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
}
&__content {
flex: 1;
margin-left: 16rpx;
}
&__title {
font-size: 32rpx;
font-weight: 500;
color: #333;
}
&__desc {
font-size: 24rpx;
color: #999;
margin-top: 8rpx;
}
&__actions {
display: flex;
gap: 16rpx;
}
}
// Modifier - 修饰符,表示块或元素的状态/变体
.user-card {
// 尺寸修饰符
&--small {
padding: 16rpx;
.user-card__avatar {
width: 60rpx;
height: 60rpx;
}
.user-card__title {
font-size: 28rpx;
}
}
&--large {
padding: 32rpx;
.user-card__avatar {
width: 100rpx;
height: 100rpx;
}
.user-card__title {
font-size: 36rpx;
}
}
// 主题修饰符
&--primary {
background: linear-gradient(135deg, #4D80F0, #3366CC);
.user-card__title,
.user-card__desc {
color: #fff;
}
}
&--dark {
background: #1a1a1a;
.user-card__title {
color: #fff;
}
.user-card__desc {
color: rgba(255, 255, 255, 0.7);
}
}
}
// State - 状态类(使用 is- 前缀)
.user-card {
&.is-disabled {
opacity: 0.5;
pointer-events: none;
}
&.is-loading {
.user-card__content {
visibility: hidden;
}
}
&.is-active {
border: 2rpx solid #4D80F0;
}
&.is-selected {
background: rgba(77, 128, 240, 0.1);
}
}变量系统使用
scss
// ==================== 项目样式变量 ====================
// 引入项目变量
@import '@/styles/variables';
.user-card {
// 颜色变量
color: $-color-content; // 内容文字颜色
background: $-color-white; // 白色背景
border-color: $-color-border; // 边框颜色
&__title {
color: $-color-title; // 标题颜色
font-size: $-fs-title; // 标题字号
}
&__desc {
color: $-color-aid; // 辅助文字颜色
font-size: $-fs-secondary; // 次要字号
}
// 使用主题色
&--primary {
background: $-color-theme; // 主题色
color: $-color-white;
}
// 功能色
&__success {
color: $-color-success; // 成功色
}
&__warning {
color: $-color-warning; // 警告色
}
&__error {
color: $-color-danger; // 危险色
}
}
// ==================== 尺寸变量 ====================
.user-card {
// 间距
padding: $-gap-medium; // 中等间距
margin: $-gap-small 0; // 小间距
// 圆角
border-radius: $-radius-medium; // 中等圆角
// 阴影
box-shadow: $-shadow-light; // 浅阴影
}响应式单位规范
scss
// ==================== 响应式单位规范 ====================
.user-card {
// 尺寸:使用 rpx 实现响应式
width: 690rpx; // 宽度
min-height: 160rpx; // 最小高度
padding: 24rpx 32rpx; // 内边距
margin: 24rpx 0; // 外边距
border-radius: 16rpx; // 圆角
// 边框:使用 px 保持 1 像素清晰
border: 1px solid #eee; // 1像素边框
border-bottom: 1px solid #f5f5f5;
// 字号:使用 rpx
font-size: 28rpx; // 正文字号
line-height: 40rpx; // 行高
&__title {
font-size: 32rpx; // 标题字号
line-height: 44rpx;
}
&__desc {
font-size: 24rpx; // 次要字号
line-height: 36rpx;
}
// 图标:使用 rpx 或 px(取决于图标库)
&__icon {
width: 40rpx;
height: 40rpx;
}
}
// ==================== 安全区域适配 ====================
.user-card {
// 底部安全区域
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
// 带固定高度的底部安全区
margin-bottom: calc(100rpx + constant(safe-area-inset-bottom));
margin-bottom: calc(100rpx + env(safe-area-inset-bottom));
}层级管理
scss
// ==================== Z-Index 层级规范 ====================
// 定义层级常量
$zindex-base: 0;
$zindex-dropdown: 1000;
$zindex-sticky: 1020;
$zindex-fixed: 1030;
$zindex-modal-backdrop: 1040;
$zindex-modal: 1050;
$zindex-popover: 1060;
$zindex-tooltip: 1070;
$zindex-toast: 1080;
$zindex-loading: 1090;
// 使用示例
.user-card {
position: relative;
z-index: $zindex-base;
&__dropdown {
position: absolute;
z-index: $zindex-dropdown;
}
}
.sticky-header {
position: sticky;
top: 0;
z-index: $zindex-sticky;
}
.modal {
position: fixed;
z-index: $zindex-modal;
&__backdrop {
z-index: $zindex-modal-backdrop;
}
}文档规范
组件文档结构
markdown
# 组件名称
## 介绍
组件功能的详细描述,包括使用场景和核心特性。
## 基本用法
### 默认用法
```vue
<template>
<user-card :user-id="userId" @click="handleClick" />
</template>
<script lang="ts" setup>
import { ref } from 'vue'
const userId = ref('123')
const handleClick = (id: string) => {
console.log('Clicked:', id)
}
</script>
```
### 自定义内容
```vue
<template>
<user-card :user-id="userId">
<template #header>
<text>自定义头部</text>
</template>
<template #default>
<text>自定义内容</text>
</template>
</user-card>
</template>
```
## Props
| 参数 | 说明 | 类型 | 默认值 |
|------|------|------|--------|
| userId | 用户 ID | `string` | - |
| name | 用户名称 | `string` | - |
| size | 卡片尺寸 | `'small' \| 'medium' \| 'large'` | `'medium'` |
## Events
| 事件名 | 说明 | 回调参数 |
|--------|------|----------|
| click | 点击卡片时触发 | `(userId: string) => void` |
| load | 数据加载完成时触发 | `(data: UserInfo) => void` |
## Slots
| 名称 | 说明 |
|------|------|
| default | 默认内容 |
| header | 头部内容 |
| footer | 底部内容 |
## Methods
通过 ref 获取组件实例后可调用以下方法:
| 方法名 | 说明 | 参数 | 返回值 |
|--------|------|------|--------|
| refresh | 刷新数据 | - | `Promise<void>` |
| reset | 重置状态 | - | `void` |
## 类型定义
```typescript
interface UserCardProps {
userId: string
name?: string
size?: 'small' | 'medium' | 'large'
}
interface UserCardEmits {
(e: 'click', userId: string): void
(e: 'load', data: UserInfo): void
}
```代码注释规范
typescript
/**
* 用户卡片组件
*
* @description 用于展示用户基本信息的卡片组件,支持头像、名称、描述等信息展示,
* 可配置尺寸、主题,支持点击交互。
*
* @example
* ```vue
* <user-card
* :user-id="userId"
* size="large"
* @click="handleClick"
* />
* ```
*
* @see {@link https://docs.example.com/components/user-card 文档链接}
*/
/**
* 加载用户数据
*
* @description 根据用户 ID 从服务端获取用户详细信息
*
* @param userId - 用户唯一标识
* @param options - 加载选项
* @param options.refresh - 是否强制刷新缓存
* @param options.showLoading - 是否显示加载状态
* @returns 返回用户信息对象
* @throws {Error} 当用户不存在或网络错误时抛出异常
*
* @example
* ```typescript
* const user = await loadUserData('123', { refresh: true })
* ```
*/
const loadUserData = async (
userId: string,
options?: LoadOptions
): Promise<UserInfo> => {
// 实现代码
}
/**
* 计算显示名称
*
* @description 根据优先级返回用户显示名称:
* 1. props.name(如果提供)
* 2. userData.name(从服务端获取)
* 3. 默认值 '未知用户'
*/
const displayName = computed(() => {
return props.name || userData.value?.name || '未知用户'
})Props 设计原则
1. 使用语义化命名
typescript
// ✅ 好的命名 - 清晰表达意图
interface Props {
/** 是否显示头部区域 */
showHeader: boolean
/** 是否启用拖拽功能 */
enableDrag: boolean
/** 最大输入长度 */
maxLength: number
/** 占位提示文字 */
placeholder: string
/** 是否只读 */
readonly: boolean
}
// ❌ 不好的命名 - 含义不清
interface Props {
sh: boolean // 不清晰,无法理解含义
flag1: boolean // 无意义的命名
num: number // 不明确具体用途
str: string // 过于通用
b: boolean // 单字母命名
}2. 提供合理默认值
typescript
const props = withDefaults(defineProps<Props>(), {
// 常用默认值
size: 'medium', // 尺寸默认中等
disabled: false, // 默认不禁用
loading: false, // 默认不加载
readonly: false, // 默认可编辑
// 数组和对象使用工厂函数(避免引用共享)
list: () => [],
config: () => ({}),
options: () => [],
// 函数默认值
formatter: (val: string) => val,
validator: () => true,
// 复杂对象默认值
pagination: () => ({
page: 1,
pageSize: 20,
total: 0,
}),
})3. 布尔值命名约定
typescript
interface Props {
// is 前缀 - 表示状态
isActive: boolean // 是否激活
isVisible: boolean // 是否可见
isValid: boolean // 是否有效
// has 前缀 - 表示拥有
hasHeader: boolean // 是否有头部
hasBorder: boolean // 是否有边框
hasIcon: boolean // 是否有图标
// can 前缀 - 表示能力
canEdit: boolean // 是否可编辑
canDelete: boolean // 是否可删除
canScroll: boolean // 是否可滚动
// show 前缀 - 表示显示
showFooter: boolean // 显示底部
showClose: boolean // 显示关闭按钮
showArrow: boolean // 显示箭头
// 省略前缀使用形容词(常用属性)
disabled: boolean // 禁用
loading: boolean // 加载中
readonly: boolean // 只读
required: boolean // 必填
clearable: boolean // 可清除
closable: boolean // 可关闭
filterable: boolean // 可筛选
sortable: boolean // 可排序
}4. 避免过度 Props
typescript
// ❌ Props 过多 - 难以维护
interface BadProps {
title: string
titleColor: string
titleSize: number
titleWeight: string
titleAlign: string
titleLineHeight: number
titleMaxLines: number
// ... 更多 title 相关属性
subtitle: string
subtitleColor: string
// ... 20+ props
}
// ✅ 使用对象聚合 - 更清晰
interface GoodProps {
/** 标题文本 */
title: string
/** 标题样式配置 */
titleStyle?: TitleStyleConfig
/** 副标题文本 */
subtitle?: string
/** 副标题样式配置 */
subtitleStyle?: TextStyleConfig
}
interface TitleStyleConfig {
color?: string
size?: number
weight?: string | number
align?: 'left' | 'center' | 'right'
lineHeight?: number
maxLines?: number
}
// 使用方式
<user-card
title="张三"
:title-style="{ color: '#333', size: 32, weight: 'bold' }"
/>5. 支持自定义样式
typescript
interface Props {
/**
* 自定义根元素类名
* @description 会添加到组件根元素上
*/
customClass?: string
/**
* 自定义根元素样式
* @description 支持字符串或对象格式
*/
customStyle?: string | Record<string, string>
}
// 使用示例
<user-card
custom-class="my-card"
:custom-style="{ marginTop: '20rpx', background: '#f5f5f5' }"
/>最佳实践
1. 保持组件纯净
typescript
// ✅ 好的做法:通过 props 和 events 通信
const props = defineProps<{ value: string }>()
const emit = defineEmits<{ change: [value: string] }>()
const handleInput = (val: string) => {
// 通过事件向上传递变化
emit('change', val)
}
// ❌ 不好的做法:直接操作外部状态
const store = useStore()
const handleClick = () => {
// 组件内部不应直接修改 store
store.setValue('xxx')
}
// ✅ 改进:通过事件让父组件处理
const emit = defineEmits<{ submit: [value: string] }>()
const handleClick = () => {
emit('submit', currentValue.value)
}
// 父组件监听事件并修改 store2. 合理拆分组件
# 组件粒度指南
何时抽取组件:
- 重复使用 2 次以上 → 抽取为公共组件
- 逻辑复杂度高 → 拆分为子组件
- 代码行数超过 300 行 → 考虑拆分
- 可独立测试的单元 → 抽取为组件
- 不同团队成员负责 → 拆分便于协作
何时不拆分:
- 只在一处使用的简单逻辑
- 拆分后增加不必要的通信成本
- 拆分后破坏语义完整性typescript
// ✅ 合理拆分示例
// user-card/
// ├── user-card.vue # 主组件
// ├── components/
// │ ├── avatar.vue # 头像子组件
// │ ├── info.vue # 信息子组件
// │ └── actions.vue # 操作按钮子组件
// ├── composables/
// │ └── use-user-card.ts # 组合式函数
// ├── types.ts # 类型定义
// └── index.ts # 导出
// 主组件只负责组合
<template>
<view class="user-card">
<Avatar :src="avatar" :size="avatarSize" />
<Info :name="name" :desc="desc" />
<Actions @edit="handleEdit" @delete="handleDelete" />
</view>
</template>3. 统一错误处理
typescript
// 定义错误处理工具
const handleError = (error: Error, context: string) => {
// 记录错误日志
console.error(`[UserCard] ${context}:`, error)
// 可选:上报错误监控
// errorReporter.capture(error, { context })
// 触发错误事件
emit('error', error)
}
// 在异步操作中使用
const loadData = async () => {
try {
isLoading.value = true
const data = await api.getData()
return data
} catch (error) {
handleError(error as Error, 'loadData')
return null
} finally {
isLoading.value = false
}
}
// 全局错误边界
const setupErrorHandler = () => {
const instance = getCurrentInstance()
if (instance) {
instance.appContext.config.errorHandler = (err, vm, info) => {
console.error('Component Error:', err, info)
}
}
}4. 性能优化实践
typescript
// ==================== 1. 避免不必要的响应式 ====================
// ✅ 静态数据不需要 ref
const OPTIONS = ['选项1', '选项2', '选项3']
// ❌ 不必要的响应式
const options = ref(['选项1', '选项2', '选项3'])
// ==================== 2. 使用 shallowRef 处理大型对象 ====================
import { shallowRef } from 'vue'
// ✅ 大型列表使用 shallowRef
const largeList = shallowRef<DataItem[]>([])
const updateList = (newList: DataItem[]) => {
largeList.value = newList // 整体替换触发更新
}
// ==================== 3. 计算属性缓存 ====================
// ✅ 使用计算属性缓存复杂计算
const filteredList = computed(() => {
return list.value.filter(item => item.active)
})
// ❌ 避免在模板中进行复杂计算
// <view v-for="item in list.filter(i => i.active)" />
// ==================== 4. 事件处理函数优化 ====================
import { debounce, throttle } from 'lodash-es'
// 搜索输入防抖
const handleSearch = debounce((keyword: string) => {
searchApi(keyword)
}, 300)
// 滚动事件节流
const handleScroll = throttle((e: Event) => {
updateScrollPosition(e)
}, 16)
// ==================== 5. 条件渲染优化 ====================
// ✅ 使用 v-show 处理频繁切换
<view v-show="isVisible">频繁切换的内容</view>
// ✅ 使用 v-if 处理不常切换或初始隐藏
<view v-if="isLoaded">加载后才显示的内容</view>
// ==================== 6. 列表渲染优化 ====================
// ✅ 使用唯一且稳定的 key
<view v-for="item in list" :key="item.id">{{ item.name }}</view>
// ❌ 避免使用 index 作为 key
<view v-for="(item, index) in list" :key="index">{{ item.name }}</view>5. 组件懒加载
typescript
// ==================== 异步组件加载 ====================
import { defineAsyncComponent } from 'vue'
// 基本用法
const AsyncUserCard = defineAsyncComponent(() =>
import('./user-card.vue')
)
// 带加载状态和错误处理
const AsyncHeavyComponent = defineAsyncComponent({
loader: () => import('./heavy-component.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: 200, // 显示加载组件前的延迟
timeout: 10000, // 超时时间
})
// 条件加载
const DynamicComponent = computed(() => {
if (props.type === 'simple') {
return SimpleCard
}
return defineAsyncComponent(() => import('./complex-card.vue'))
})6. 暴露组件方法
typescript
// ==================== defineExpose 使用规范 ====================
// 子组件
<script lang="ts" setup>
import { ref } from 'vue'
const isLoading = ref(false)
const data = ref<UserInfo | null>(null)
// 刷新数据
const refresh = async () => {
isLoading.value = true
try {
data.value = await fetchData()
} finally {
isLoading.value = false
}
}
// 重置状态
const reset = () => {
data.value = null
isLoading.value = false
}
// 获取当前数据
const getData = () => data.value
// 只暴露需要的方法和数据
defineExpose({
refresh,
reset,
getData,
// 可以暴露响应式数据(只读)
isLoading: readonly(isLoading),
})
</script>
// 父组件使用
<template>
<user-card ref="userCardRef" />
<button @click="handleRefresh">刷新</button>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import type { UserCardInstance } from './user-card'
const userCardRef = ref<UserCardInstance | null>(null)
const handleRefresh = async () => {
await userCardRef.value?.refresh()
}
</script>7. 插槽使用规范
vue
<template>
<view class="user-card">
<!-- 具名插槽:头部 -->
<view v-if="$slots.header" class="user-card__header">
<slot name="header" />
</view>
<!-- 默认插槽:内容区域 -->
<view class="user-card__content">
<slot>
<!-- 默认内容 -->
<text>{{ defaultContent }}</text>
</slot>
</view>
<!-- 作用域插槽:传递数据给父组件 -->
<view class="user-card__info">
<slot
name="info"
:user="userData"
:loading="isLoading"
:refresh="refresh"
>
<!-- 默认渲染 -->
<text>{{ userData?.name }}</text>
</slot>
</view>
<!-- 条件插槽:只在有数据时显示 -->
<view v-if="$slots.footer && userData" class="user-card__footer">
<slot name="footer" :data="userData" />
</view>
</view>
</template>
<script lang="ts" setup>
import { useSlots } from 'vue'
// 获取插槽信息
const slots = useSlots()
// 检查插槽是否存在
const hasHeader = computed(() => !!slots.header)
const hasFooter = computed(() => !!slots.footer)
</script>
<!-- 父组件使用 -->
<template>
<user-card :user-id="userId">
<template #header>
<text class="title">自定义头部</text>
</template>
<template #info="{ user, loading, refresh }">
<view v-if="loading">加载中...</view>
<view v-else>
<text>{{ user.name }}</text>
<button @click="refresh">刷新</button>
</view>
</template>
<template #footer="{ data }">
<text>创建于:{{ data.createTime }}</text>
</template>
</user-card>
</template>8. 平台兼容性处理
typescript
// ==================== 条件编译 ====================
// 平台判断
// #ifdef H5
const isH5 = true
// #endif
// #ifdef MP-WEIXIN
const isWeixin = true
// #endif
// #ifdef APP-PLUS
const isApp = true
// #endif
// ==================== 平台差异处理 ====================
const handleClick = () => {
// #ifdef H5
// H5 特定逻辑
window.open(url)
// #endif
// #ifdef MP-WEIXIN
// 微信小程序特定逻辑
wx.navigateTo({ url })
// #endif
// #ifdef APP-PLUS
// App 特定逻辑
plus.runtime.openURL(url)
// #endif
}
// ==================== 样式条件编译 ====================
<style lang="scss" scoped>
.user-card {
/* #ifdef H5 */
cursor: pointer;
/* #endif */
/* #ifdef MP-WEIXIN */
// 微信小程序特定样式
/* #endif */
}
</style>常见问题
1. 何时使用 setup 语法糖?
推荐始终使用 <script setup>,它更简洁且性能更好。
vue
<!-- ✅ 推荐:setup 语法糖 -->
<script lang="ts" setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<!-- ❌ 不推荐:Options API -->
<script lang="ts">
import { defineComponent, ref } from 'vue'
export default defineComponent({
setup() {
const count = ref(0)
return { count }
}
})
</script>2. Props 解构会丢失响应式?
是的,不要直接解构 props:
typescript
// ❌ 失去响应式
const { value, name } = props
// ✅ 保持响应式 - 方法 1:使用 computed
const value = computed(() => props.value)
const name = computed(() => props.name)
// ✅ 保持响应式 - 方法 2:使用 toRefs
const { value, name } = toRefs(props)
// ✅ 保持响应式 - 方法 3:直接使用 props
watch(() => props.value, (newVal) => { ... })3. 组件需要暴露方法给父组件?
使用 defineExpose:
typescript
const refresh = async () => { ... }
const reset = () => { ... }
const validate = () => { ... }
// 暴露给父组件
defineExpose({
refresh,
reset,
validate,
})
// 定义实例类型(供父组件使用)
export type UserCardInstance = {
refresh: () => Promise<void>
reset: () => void
validate: () => boolean
}4. 如何处理复杂的组件状态?
使用 Composable 抽离逻辑:
typescript
// composables/use-user-card.ts
export const useUserCard = (props: UserCardProps) => {
const isLoading = ref(false)
const userData = ref<UserInfo | null>(null)
const error = ref<Error | null>(null)
const fetchUser = async () => {
try {
isLoading.value = true
error.value = null
userData.value = await userApi.get(props.userId)
} catch (e) {
error.value = e as Error
} finally {
isLoading.value = false
}
}
watch(() => props.userId, fetchUser, { immediate: true })
return {
isLoading,
userData,
error,
refresh: fetchUser,
}
}
// 在组件中使用
<script lang="ts" setup>
import { useUserCard } from './composables'
const props = defineProps<UserCardProps>()
const { isLoading, userData, error, refresh } = useUserCard(props)
</script>5. 如何实现组件的 v-model?
typescript
// 单个 v-model
<script lang="ts" setup>
interface Props {
modelValue: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
// 方法 1:使用计算属性
const innerValue = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
// 方法 2:直接在事件中 emit
const handleChange = (val: string) => {
emit('update:modelValue', val)
}
</script>
// 多个 v-model
<script lang="ts" setup>
interface Props {
visible: boolean
title: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:visible': [value: boolean]
'update:title': [value: string]
}>()
const updateVisible = (val: boolean) => emit('update:visible', val)
const updateTitle = (val: string) => emit('update:title', val)
</script>
// 父组件使用
<template>
<my-dialog v-model:visible="showDialog" v-model:title="dialogTitle" />
</template>6. 如何避免组件重复渲染?
typescript
// 1. 使用 v-memo 缓存列表项
<template>
<view v-for="item in list" :key="item.id" v-memo="[item.id, item.selected]">
{{ item.name }}
</view>
</template>
// 2. 使用 computed 缓存计算结果
const expensiveResult = computed(() => {
return heavyComputation(props.data)
})
// 3. 使用 watchEffect 的 flush: 'post' 避免不必要的更新
watchEffect(() => {
// 副作用逻辑
}, { flush: 'post' })
// 4. 合理使用 shallowRef
const bigData = shallowRef<BigDataType>(null)7. 如何处理组件的异步初始化?
typescript
// 使用 Suspense(实验性)
<template>
<Suspense>
<template #default>
<AsyncComponent />
</template>
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
</template>
// 在组件内使用 async setup
<script lang="ts" setup>
// 顶层 await(需要 Suspense 支持)
const data = await fetchInitialData()
// 或者使用 onMounted
onMounted(async () => {
await initializeComponent()
})
</script>8. 如何处理循环依赖?
typescript
// 问题:A 组件引用 B,B 组件引用 A
// 解决方案 1:使用异步组件
const ComponentB = defineAsyncComponent(() => import('./ComponentB.vue'))
// 解决方案 2:使用 provide/inject 避免直接引用
// 在父组件中
provide('componentA', ComponentA)
// 在子组件中
const ComponentA = inject('componentA')
// 解决方案 3:重构组件关系,提取公共逻辑到 Composable组件测试规范
单元测试示例
typescript
// user-card.test.ts
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi } from 'vitest'
import UserCard from './user-card.vue'
describe('UserCard', () => {
// 测试 Props
it('renders with default props', () => {
const wrapper = mount(UserCard, {
props: { userId: '123' },
})
expect(wrapper.classes()).toContain('user-card')
expect(wrapper.classes()).toContain('user-card--medium')
})
it('applies size modifier class', () => {
const wrapper = mount(UserCard, {
props: { userId: '123', size: 'large' },
})
expect(wrapper.classes()).toContain('user-card--large')
})
// 测试 Events
it('emits click event with userId', async () => {
const wrapper = mount(UserCard, {
props: { userId: '123' },
})
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeTruthy()
expect(wrapper.emitted('click')![0]).toEqual(['123'])
})
// 测试 Slots
it('renders header slot content', () => {
const wrapper = mount(UserCard, {
props: { userId: '123' },
slots: {
header: '<div class="custom-header">Header</div>',
},
})
expect(wrapper.find('.custom-header').exists()).toBe(true)
})
// 测试异步行为
it('shows loading state while fetching data', async () => {
const wrapper = mount(UserCard, {
props: { userId: '123' },
})
expect(wrapper.classes()).toContain('is-loading')
await flushPromises()
expect(wrapper.classes()).not.toContain('is-loading')
})
// 测试暴露的方法
it('exposes refresh method', async () => {
const wrapper = mount(UserCard, {
props: { userId: '123' },
})
const refreshSpy = vi.spyOn(wrapper.vm, 'refresh')
await wrapper.vm.refresh()
expect(refreshSpy).toHaveBeenCalled()
})
})总结
本规范涵盖了 RuoYi-Plus-UniApp 项目中组件封装的各个方面:
- 命名规范 - 确保代码风格一致
- 代码组织 - 保持代码结构清晰
- 类型规范 - 充分利用 TypeScript 类型系统
- 样式规范 - BEM 命名和变量系统
- 通信规范 - 多种组件通信模式
- 性能优化 - 实用的性能优化技巧
- 测试规范 - 保证代码质量
遵循这些规范能够:
- 提高代码可读性和可维护性
- 减少 Bug 和重构成本
- 便于团队协作和代码审查
- 提升应用性能和用户体验
