普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月18日首页

Element Plus 组件库实战技巧与踩坑记录

作者 晴天丨
2026年4月17日 20:18

🎨 Element Plus 组件库实战技巧与踩坑记录

分享我在Vue 3项目中使用Element Plus的经验技巧和踩坑记录

前言

Element Plus是Vue 3生态中最流行的UI组件库之一,提供了丰富的组件和良好的设计。在开发博客项目的过程中,我积累了很多使用Element Plus的经验和技巧,也踩过一些坑。本文将分享这些实战经验。

快速上手

1. 安装与配置

# 安装Element Plus
npm install element-plus

# 安装图标库
npm install @element-plus/icons-vue
// main.ts
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'

const app = createApp(App)

// 注册所有组件
app.use(ElementPlus)

// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.component(key, component)
}

app.mount('#app')

2. 按需引入(推荐)

为了减小包体积,建议按需引入组件:

# 安装按需引入插件
npm install -D unplugin-vue-components unplugin-auto-import
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

export default defineConfig({
  plugins: [
    vue(),
    AutoImport({
      resolvers: [ElementPlusResolver()],
    }),
    Components({
      resolvers: [ElementPlusResolver()],
    }),
  ],
})

这样配置后,使用组件时会自动按需引入,无需手动import。

常用组件技巧

1. 表单组件

el-form深度验证
<template>
  <el-form
    ref="formRef"
    :model="formData"
    :rules="rules"
    label-width="120px"
  >
    <el-form-item label="标题" prop="title">
      <el-input v-model="formData.title" />
    </el-form-item>

    <el-form-item label="邮箱" prop="email">
      <el-input v-model="formData.email" />
    </el-form-item>

    <el-form-item label="密码" prop="password">
      <el-input
        v-model="formData.password"
        type="password"
        show-password
      />
    </el-form-item>

    <el-form-item>
      <el-button type="primary" @click="handleSubmit">
        提交
      </el-button>
      <el-button @click="handleReset">
        重置
      </el-button>
    </el-form-item>
  </el-form>
</template>

<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'

const formRef = ref<FormInstance>()

const formData = reactive({
  title: '',
  email: '',
  password: ''
})

const rules = reactive<FormRules>({
  title: [
    { required: true, message: '请输入标题', trigger: 'blur' },
    { min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
  ],
  email: [
    { required: true, message: '请输入邮箱地址', trigger: 'blur' },
    { type: 'email', message: '请输入正确的邮箱地址', trigger: ['blur', 'change'] }
  ],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { min: 6, message: '密码长度不能少于 6 位', trigger: 'blur' }
  ]
})

const handleSubmit = async () => {
  if (!formRef.value) return

  await formRef.value.validate((valid, fields) => {
    if (valid) {
      // 验证通过,提交表单
      console.log('提交:', formData)
    } else {
      console.log('验证失败:', fields)
    }
  })
}

const handleReset = () => {
  formRef.value?.resetFields()
}
</script>
动态表单
<template>
  <el-form :model="formData">
    <el-form-item
      v-for="(item, index) in formData.items"
      :key="index"
      :label="'项目 ' + (index + 1)"
    >
      <el-input v-model="item.value" />
      <el-button
        @click="removeItem(index)"
        icon="Delete"
        type="danger"
      >
        删除
      </el-button>
    </el-form-item>

    <el-button @click="addItem" icon="Plus">
      添加项目
    </el-button>
  </el-form>
</template>

<script setup lang="ts">
const formData = reactive({
  items: [{ value: '' }]
})

const addItem = () => {
  formData.items.push({ value: '' })
}

const removeItem = (index: number) => {
  formData.items.splice(index, 1)
}
</script>

2. 表格组件

表格排序和筛选
<template>
  <el-table
    :data="filteredData"
    :default-sort="{ prop: 'date', order: 'descending' }"
    @sort-change="handleSortChange"
  >
    <el-table-column prop="title" label="标题" sortable />
    <el-table-column
      prop="category"
      label="分类"
      :filters="categoryFilters"
      :filter-method="filterCategory"
    />
    <el-table-column prop="views" label="浏览量" sortable />
    <el-table-column prop="date" label="日期" sortable />
  </el-table>
</template>

<script setup lang="ts">
const articles = ref<Article[]>([])

const filteredData = computed(() => {
  return articles.value
})

const categoryFilters = [
  { text: 'Vue', value: 'Vue' },
  { text: 'React', value: 'React' },
  { text: 'TypeScript', value: 'TypeScript' }
]

const filterCategory = (value: string, row: Article) => {
  return row.category === value
}

const handleSortChange = (sort: any) => {
  console.log('排序改变:', sort)
}
</script>
表格分页
<template>
  <el-table :data="paginatedData">
    <!-- 列定义 -->
  </el-table>

  <el-pagination
    v-model:current-page="currentPage"
    v-model:page-size="pageSize"
    :total="total"
    :page-sizes="[10, 20, 50, 100]"
    layout="total, sizes, prev, pager, next, jumper"
    @size-change="handleSizeChange"
    @current-change="handleCurrentChange"
  />
</template>

<script setup lang="ts">
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)

const paginatedData = computed(() => {
  const start = (currentPage.value - 1) * pageSize.value
  const end = start + pageSize.value
  return articles.value.slice(start, end)
})

const handleSizeChange = (size: number) => {
  pageSize.value = size
}

const handleCurrentChange = (page: number) => {
  currentPage.value = page
}
</script>

3. 弹窗组件

对话框嵌套
<template>
  <el-button @click="showDialog = true">打开对话框</el-button>

  <el-dialog v-model="showDialog" title="父对话框">
    <p>这是父对话框的内容</p>

    <el-button @click="showChildDialog = true">
      打开子对话框
    </el-button>

    <el-dialog
      v-model="showChildDialog"
      title="子对话框"
      append-to-body
    >
      <p>这是子对话框的内容</p>
    </el-dialog>
  </el-dialog>
</template>

<script setup lang="ts">
const showDialog = ref(false)
const showChildDialog = ref(false)
</script>

注意:嵌套对话框时,子对话框需要添加append-to-body属性。

4. 树形组件

异步加载树
<template>
  <el-tree
    :props="defaultProps"
    :load="loadNode"
    lazy
    show-checkbox
  />
</template>

<script setup lang="ts">
const defaultProps = {
  label: 'name',
  children: 'children',
  isLeaf: 'leaf'
}

const loadNode = async (node: Node, resolve: (data: TreeData[]) => void) => {
  if (node.level === 0) {
    // 加载根节点
    const data = await loadRootNodes()
    resolve(data)
  } else {
    // 加载子节点
    const data = await loadChildNodes(node.data.id)
    resolve(data)
  }
}

const loadRootNodes = async () => {
  // 异步加载数据
  return [
    { name: '节点1', id: 1 },
    { name: '节点2', id: 2 }
  ]
}
</script>

主题定制

1. 使用CSS变量

// styles/theme.scss
:root {
  --el-color-primary: #409eff;
  --el-color-success: #67c23a;
  --el-color-warning: #e6a23c;
  --el-color-danger: #f56c6c;
  --el-color-info: #909399;
}

// 使用自定义主题
$--color-primary: var(--el-color-primary);

2. SCSS变量覆盖

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import ElementPlus from 'unplugin-element-plus/vite'

export default defineConfig({
  plugins: [
    vue(),
    ElementPlus({
      // 使用scss样式
      useSource: true
    })
  ]
})
// styles/element-variables.scss
/* 改变主题色变量 */
$--color-primary: #1890ff;
$--color-success: #52c41a;
$--color-warning: #faad14;
$--color-danger: #f5222d;
$--color-info: #909399;

/* 改变icon字体路径变量,必需 */
$--font-path: '~element-plus/lib/theme-chalk/fonts';

@import "~element-plus/packages/theme-chalk/src/index";

3. 暗黑模式

<template>
  <el-switch
    v-model="isDark"
    @change="toggleDark"
    inline-prompt
    active-text="暗"
    inactive-text="亮"
  />
</template>

<script setup lang="ts">
const isDark = ref(false)

const toggleDark = (value: boolean) => {
  if (value) {
    document.documentElement.classList.add('dark')
  } else {
    document.documentElement.classList.remove('dark')
  }
}
</script>

<style>
/* 暗黑模式样式 */
html.dark {
  --el-bg-color: #141414;
  --el-text-color-primary: #e5eaf3;
  --el-border-color: #4c4d4f;
  --el-border-color-light: #414243;
}
</style>

性能优化

1. 图标按需加载

// utils/icons.ts
import { registerIcons } from 'element-plus/es/components/icon'

// 只注册需要的图标
export function lazyRegisterIcons() {
  const icons = [
    'Edit',
    'Delete',
    'View',
    'Download',
    'Share',
    'Star',
    'Plus',
    'Search',
    'Home'
  ]

  // 使用requestIdleCallback在空闲时注册
  const idleCallback = window.requestIdleCallback || window.setTimeout

  idleCallback(() => {
    registerIcons(icons)
  })
}

// main.ts
import { lazyRegisterIcons } from './utils/icons'
lazyRegisterIcons()

2. 虚拟滚动

<template>
  <el-virtual-list
    :data="items"
    :height="400"
    :item-size="50"
  >
    <template #default="{ item, index }">
      <div class="item">
        {{ index }} - {{ item.name }}
      </div>
    </template>
  </el-virtual-list>
</template>

<script setup lang="ts">
import { ElVirtualList } from 'element-plus'

// 生成大量数据
const items = Array.from({ length: 10000 }, (_, i) => ({
  id: i,
  name: `Item ${i}`
}))
</script>

踩坑记录

1. Dialog关闭不触发事件

问题:点击遮罩层关闭Dialog时,没有触发关闭事件。

解决:使用before-close属性:

<el-dialog
  v-model="visible"
  :before-close="handleClose"
>
  <template #header>
    <span>标题</span>
  </template>
</el-dialog>

<script setup lang="ts">
const handleClose = (done: () => void) => {
  // 执行关闭前的逻辑
  done()
}
</script>

2. Table固定列错位

问题:表格固定列在滚动时出现错位。

解决:监听窗口大小变化,调用doLayout方法:

<template>
  <el-table
    ref="tableRef"
    :data="tableData"
  >
    <el-table-column prop="date" label="日期" fixed />
    <el-table-column prop="name" label="姓名" />
  </el-table>
</template>

<script setup lang="ts">
const tableRef = ref()

onMounted(() => {
  window.addEventListener('resize', () => {
    tableRef.value?.doLayout()
  })
})
</script>

3. Select下拉框显示位置错误

问题:Select组件的下拉框在页面滚动后显示位置错误。

解决:使用popper-options配置:

<el-select
  v-model="value"
  :popper-options="{
    modifiers: [
      {
        name: 'flip',
        options: {
          fallbackPlacements: ['bottom-start', 'top-start']
        }
      }
    ]
  }"
>
  <el-option
    v-for="item in options"
    :key="item.value"
    :label="item.label"
    :value="item.value"
  />
</el-select>

4. DatePicker时间格式问题

问题:DatePicker返回的日期格式不符合预期。

解决:使用value-format属性:

<el-date-picker
  v-model="date"
  type="datetime"
  value-format="YYYY-MM-DD HH:mm:ss"
  placeholder="选择日期时间"
/>

5. Upload组件上传失败

问题:Upload组件在某些情况下上传失败。

解决:正确处理on-successon-error回调:

<el-upload
  action="/api/upload"
  :on-success="handleSuccess"
  :on-error="handleError"
  :before-upload="beforeUpload"
>
  <el-button type="primary">上传文件</el-button>
</el-upload>

<script setup lang="ts">
const handleSuccess = (response: any, file: any) => {
  if (response.code === 200) {
    ElMessage.success('上传成功')
  } else {
    ElMessage.error(response.message)
  }
}

const handleError = (error: any) => {
  ElMessage.error('上传失败:' + error.message)
}

const beforeUpload = (file: File) => {
  const isJPG = file.type === 'image/jpeg' || file.type === 'image/png'
  const isLt2M = file.size / 1024 / 1024 < 2

  if (!isJPG) {
    ElMessage.error('只能上传JPG/PNG图片!')
  }
  if (!isLt2M) {
    ElMessage.error('图片大小不能超过2MB!')
  }
  return isJPG && isLt2M
}
</script>

最佳实践

1. 统一配置

// config/element-plus.ts
import { ElConfigProvider } from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'

export default {
  locale: zhCn,
  size: 'default',
  zIndex: 3000
}
<!-- App.vue -->
<template>
  <el-config-provider :locale="locale">
    <router-view />
  </el-config-provider>
</template>

<script setup lang="ts">
import zhCn from 'element-plus/es/locale/lang/zh-cn'
const locale = zhCn
</script>

2. 封装常用组件

<!-- components/SearchInput.vue -->
<template>
  <el-input
    v-model="searchText"
    :placeholder="placeholder"
    clearable
    @clear="handleClear"
    @input="handleInput"
  >
    <template #prefix>
      <el-icon><Search /></el-icon>
    </template>
    <template #suffix>
      <el-button
        v-if="searchText"
        link
        icon="Close"
        @click="handleClear"
      />
    </template>
  </el-input>
</template>

<script setup lang="ts">
import { ref, watch } from 'vue'

interface Props {
  modelValue: string
  placeholder?: string
}

const props = withDefaults(defineProps<Props>(), {
  placeholder: '请输入搜索内容'
})

const emit = defineEmits<{
  (e: 'update:modelValue', value: string): void
  (e: 'search', value: string): void
}>()

const searchText = ref(props.modelValue)

watch(() => props.modelValue, (val) => {
  searchText.value = val
})

watch(searchText, (val) => {
  emit('update:modelValue', val)
})

const handleClear = () => {
  searchText.value = ''
  emit('search', '')
}

const handleInput = debounce((value: string) => {
  emit('search', value)
}, 300)
</script>

3. 全局样式覆盖

// styles/element-overrides.scss

// 全局修改el-button样式
.el-button {
  border-radius: 4px;
  font-weight: 500;

  &--primary {
    background-color: #1890ff;
    border-color: #1890ff;

    &:hover {
      background-color: #40a9ff;
      border-color: #40a9ff;
    }
  }
}

// 修改el-dialog样式
.el-dialog {
  border-radius: 8px;
  overflow: hidden;

  .el-dialog__header {
    padding: 20px 20px 10px;
    border-bottom: 1px solid #f0f0f0;
  }

  .el-dialog__body {
    padding: 20px;
  }
}

总结

Element Plus是一个功能强大、设计优秀的UI组件库,掌握以下要点可以更好地使用它:

  1. 按需引入 - 减小包体积
  2. 主题定制 - 符合项目风格
  3. 性能优化 - 图标懒加载、虚拟滚动
  4. 踩坑经验 - 了解常见问题和解决方案
  5. 最佳实践 - 封装常用组件、统一配置

希望这些经验能帮助你在Vue 3项目中更好地使用Element Plus!


标签:#ElementPlus #Vue3 #UI组件库 #前端 #实战技巧

点赞❤️ + 收藏⭐️ + 评论💬,你的支持是我创作的动力!

Vue 3项目架构设计:从2200行单文件到24个组件

作者 晴天丨
2026年4月17日 20:17

🏗️ Vue 3项目架构设计:从2200行单文件到24个组件

分享我在Vue 3博客项目中的架构重构经验,代码可维护性大幅提升

前言

在项目初期,为了快速实现功能,我把大部分代码都写在了App.vue中,导致单文件达到了2200多行。随着功能增多,代码越来越难以维护。于是我开始进行架构重构,将代码拆分成24个独立组件,最终实现了更好的代码组织和可维护性。

重构前后对比

代码结构对比

重构前:

App.vue (2200+ 行)
├── 布局代码
├── 业务逻辑
├── 组件代码
└── 工具函数

重构后:

src/
├── components/
│   ├── layout/        (5个组件)
│   ├── features/      (4个组件)
│   ├── gamification/  (4个组件)
│   └── article/       (6个组件)
├── composables/       (5个组合函数)
├── utils/             (3个工具模块)
└── views/             (5个页面组件)

数据对比

指标 重构前 重构后 改善
单文件最大行数 2200+ 400 ⬇️ 82%
组件数量 1 24 ⬆️ 24倍
代码复用率 0% 40%+ ⬆️ 40%
可维护性 ⬆⬆⬆

架构设计原则

1. 单一职责原则

每个组件只负责一个功能模块。

<!-- ❌ 错误:一个组件包含多个职责 -->
<template>
  <div>
    <Header />
    <ArticleList />
    <Sidebar />
    <MusicPlayer />
    <Notification />
    <Footer />
  </div>
</template>

<!-- ✅ 正确:每个组件单一职责 -->
<template>
  <div>
    <AppBackground />
    <TheHeader />
    <TheMain>
      <RouterView />
    </TheMain>
    <TheFooter />
    <BackToTop />
    <Notification />
  </div>
</template>

2. 开闭原则

通过props和emits扩展组件功能,不修改组件内部代码。

<!-- ArticleCard.vue -->
<template>
  <article :class="['article-card', variant]">
    <ArticleMeta :article="article" />
    <ArticleContent :article="article" />
    <slot name="actions">
      <ArticleActions :article="article" />
    </slot>
  </article>
</template>

<script setup lang="ts">
interface Props {
  article: Article
  variant?: 'default' | 'compact' | 'featured'
}

defineProps<Props>()
</script>

3. 依赖倒置原则

组件依赖于抽象的接口(props/emits),而非具体实现。

// composables/usePagination.ts
export function usePagination(options: PaginationOptions) {
  const currentPage = ref(options.page || 1)
  const pageSize = ref(options.pageSize || 10)

  const nextPage = () => {
    currentPage.value++
  }

  const prevPage = () => {
    currentPage.value--
  }

  return {
    currentPage,
    pageSize,
    nextPage,
    prevPage
  }
}

组件分类体系

1. 布局组件(5个)

AppBackground
<!-- components/layout/AppBackground.vue -->
<template>
  <div class="app-background">
    <div class="gradient-bg"></div>
    <div class="particles"></div>
  </div>
</template>

<script setup lang="ts">
// 背景动画逻辑
</script>

<style scoped>
.app-background {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: -1;
}
</style>
TheHeader
<!-- components/layout/TheHeader.vue -->
<template>
  <header class="header">
    <Logo />
    <Navigation />
    <SearchTrigger />
    <SettingsTrigger />
    <NotificationTrigger />
  </header>
</template>

<script setup lang="ts">
import Logo from './Logo.vue'
import Navigation from './Navigation.vue'
import SearchTrigger from './SearchTrigger.vue'
</script>
TheFooter
<!-- components/layout/TheFooter.vue -->
<template>
  <footer class="footer">
    <Copyright />
    <SocialLinks />
    <Links />
  </footer>
</template>
BackToTop
<!-- components/layout/BackToTop.vue -->
<template>
  <transition name="fade">
    <button
      v-show="visible"
      @click="scrollToTop"
      class="back-to-top"
    >
      <el-icon><ArrowUp /></el-icon>
    </button>
  </transition>
</template>

<script setup lang="ts">
const visible = ref(false)

onMounted(() => {
  window.addEventListener('scroll', handleScroll)
})

const handleScroll = () => {
  visible.value = window.scrollY > 300
}

const scrollToTop = () => {
  window.scrollTo({ top: 0, behavior: 'smooth' })
}
</script>
ReadingProgressBar
<!-- components/layout/ReadingProgressBar.vue -->
<template>
  <div class="reading-progress">
    <div
      class="progress-bar"
      :style="{ width: progress + '%' }"
    ></div>
  </div>
</template>

<script setup lang="ts">
const progress = ref(0)

const updateProgress = () => {
  const scrollTop = window.scrollY
  const docHeight = document.documentElement.scrollHeight - window.innerHeight
  progress.value = (scrollTop / docHeight) * 100
}

onMounted(() => {
  window.addEventListener('scroll', updateProgress)
})
</script>

2. 功能组件(4个)

Notification
<!-- components/features/Notification.vue -->
<template>
  <transition-group name="notification">
    <div
      v-for="notif in notifications"
      :key="notif.id"
      :class="['notification', notif.type]"
    >
      <el-icon><component :is="notif.icon" /></el-icon>
      <span>{{ notif.message }}</span>
      <el-button
        icon="Close"
        @click="remove(notif.id)"
      />
    </div>
  </transition-group>
</template>

<script setup lang="ts">
import { useNotification } from '@/composables/useNotification'

const { notifications, remove } = useNotification()
</script>
SearchPanel
<!-- components/features/SearchPanel.vue -->
<template>
  <div class="search-panel">
    <el-input
      v-model="searchText"
      placeholder="搜索文章..."
      @input="handleSearch"
    >
      <template #prefix>
        <el-icon><Search /></el-icon>
      </template>
    </el-input>

    <div class="search-results">
      <ArticleCard
        v-for="article in results"
        :key="article.id"
        :article="article"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
const searchText = ref('')
const results = ref<Article[]>([])

const handleSearch = debounce(async (text: string) => {
  if (!text) {
    results.value = []
    return
  }
  results.value = await searchArticles(text)
}, 300)
</script>
SettingsPanel
<!-- components/features/SettingsPanel.vue -->
<template>
  <div class="settings-panel">
    <SettingSection title="主题">
      <ThemeToggle />
    </SettingSection>

    <SettingSection title="字体">
      <FontSizeSlider />
    </SettingSection>

    <SettingSection title="其他">
      <el-checkbox v-model="settings.enableMusic">
        启用背景音乐
      </el-checkbox>
    </SettingSection>
  </div>
</template>

<script setup lang="ts">
const settings = useSettings()
</script>
KeyboardHints
<!-- components/features/KeyboardHints.vue -->
<template>
  <div class="keyboard-hints">
    <kbd v-for="hint in hints" :key="hint.key">
      {{ hint.key }}
      <span>{{ hint.action }}</span>
    </kbd>
  </div>
</template>

<script setup lang="ts">
const hints = [
  { key: 'K', action: '搜索' },
  { key: 'N', action: '下一篇' },
  { key: 'P', action: '上一篇' }
]
</script>

3. 游戏化组件(4个)

EnergyDisplay
<!-- components/gamification/EnergyDisplay.vue -->
<template>
  <div class="energy-display">
    <div class="energy-bar">
      <div
        class="energy-fill"
        :style="{ width: energyPercentage + '%' }"
      ></div>
    </div>
    <div class="energy-value">{{ energy }}/100</div>
  </div>
</template>

<script setup lang="ts">
const { energy } = useEnergy()
const energyPercentage = computed(() => energy.value)
</script>
SignDialog
<!-- components/gamification/SignDialog.vue -->
<template>
  <el-dialog v-model="visible" title="每日签到">
    <div class="sign-calendar">
      <div
        v-for="day in 7"
        :key="day"
        :class="['sign-day', signedDays.includes(day) ? 'signed' : '']"
      >
        {{ day }}
      </div>
    </div>

    <el-button
      type="primary"
      :disabled="signedToday"
      @click="handleSign"
    >
      {{ signedToday ? '已签到' : '签到' }}
    </el-button>
  </el-dialog>
</template>

<script setup lang="ts">
const { signedDays, signedToday, sign } = useSign()
const visible = ref(false)

const handleSign = () => {
  sign()
}
</script>
MusicPlayer
<!-- components/gamification/MusicPlayer.vue -->
<template>
  <div class="music-player">
    <div class="player-info">
      <img :src="currentTrack.cover" :alt="currentTrack.name" />
      <div class="track-info">
        <div class="track-name">{{ currentTrack.name }}</div>
        <div class="track-artist">{{ currentTrack.artist }}</div>
      </div>
    </div>

    <div class="player-controls">
      <button @click="prevTrack">
        <el-icon><DArrowLeft /></el-icon>
      </button>
      <button @click="togglePlay">
        <el-icon><component :is="isPlaying ? VideoPause : VideoPlay" /></el-icon>
      </button>
      <button @click="nextTrack">
        <el-icon><DArrowRight /></el-icon>
      </button>
    </div>

    <div class="player-progress">
      <div
        class="progress-bar"
        :style="{ width: progress + '%' }"
      ></div>
    </div>
  </div>
</template>

<script setup lang="ts">
const {
  currentTrack,
  isPlaying,
  progress,
  togglePlay,
  prevTrack,
  nextTrack
} = useMusicPlayer()
</script>

4. 文章组件(6个)

ArticleCard
<!-- components/article/ArticleCard.vue -->
<template>
  <article class="article-card">
    <ArticleMeta :article="article" />
    <ArticleContent :article="article" />
    <ArticleActions :article="article" />
  </article>
</template>

<script setup lang="ts">
import ArticleMeta from './ArticleMeta.vue'
import ArticleContent from './ArticleContent.vue'
import ArticleActions from './ArticleActions.vue'

defineProps<{ article: Article }>()
</script>
ArticleMeta
<!-- components/article/ArticleMeta.vue -->
<template>
  <div class="article-meta">
    <div class="meta-row">
      <span class="author">{{ article.author }}</span>
      <span class="date">{{ formatDate(article.date) }}</span>
    </div>

    <div class="tags">
      <el-tag
        v-for="tag in article.tags"
        :key="tag"
        size="small"
      >
        {{ tag }}
      </el-tag>
    </div>
  </div>
</template>

<script setup lang="ts">
import { formatDate } from '@/utils/format'

defineProps<{ article: Article }>()
</script>

Composables设计

useArticle

// composables/useArticle.ts
export function useArticle() {
  const articles = ref<Article[]>([])
  const loading = ref(false)
  const error = ref<Error | null>(null)

  const fetchArticles = async () => {
    loading.value = true
    try {
      articles.value = await getArticles()
    } catch (e) {
      error.value = e as Error
    } finally {
      loading.value = false
    }
  }

  const getArticleById = (id: number) => {
    return articles.value.find(a => a.id === id)
  }

  return {
    articles,
    loading,
    error,
    fetchArticles,
    getArticleById
  }
}

useTheme

// composables/useTheme.ts
export function useTheme() {
  const isDark = ref(false)

  const toggleTheme = () => {
    isDark.value = !isDark.value
    document.documentElement.classList.toggle('dark')
  }

  return {
    isDark,
    toggleTheme
  }
}

useLocalStorage

// composables/useLocalStorage.ts
export function useLocalStorage<T>(key: string, defaultValue: T) {
  const stored = ref<T>(defaultValue)

  // 初始化时读取
  const init = () => {
    const item = localStorage.getItem(key)
    if (item) {
      try {
        stored.value = JSON.parse(item)
      } catch (e) {
        console.error('Failed to parse localStorage', e)
      }
    }
  }

  // 监听变化并保存
  watch(stored, (value) => {
    localStorage.setItem(key, JSON.stringify(value))
  }, { deep: true })

  init()

  return stored
}

组件通信方式

1. Props Down

<!-- 父组件 -->
<ArticleCard :article="article" variant="featured" />

<!-- 子组件 -->
<script setup lang="ts">
interface Props {
  article: Article
  variant?: 'default' | 'compact' | 'featured'
}

defineProps<Props>()
</script>

2. Emits Up

<!-- 子组件 -->
<script setup lang="ts">
const emit = defineEmits<{
  (e: 'like', articleId: number): void
  (e: 'collect', articleId: number): void
}>()

const handleLike = () => {
  emit('like', props.article.id)
}
</script>

<!-- 父组件 -->
<ArticleCard @like="handleLike" />

3. Provide/Inject

// 祖先组件
provide('theme', isDark)

// 后代组件
const theme = inject('theme')

4. Event Bus

// utils/eventBus.ts
import mitt from 'mitt'

export const eventBus = mitt<{
  notification: NotificationEvent
  refresh: void
}>()

// 发送事件
eventBus.emit('notification', { type: 'success', message: '操作成功' })

// 监听事件
eventBus.on('notification', (event) => {
  // 处理通知
})

性能优化

1. 组件懒加载

const HeavyComponent = defineAsyncComponent({
  loader: () => import('./HeavyComponent.vue'),
  loadingComponent: LoadingSpinner,
  errorComponent: ErrorComponent,
  delay: 200,
  timeout: 3000
})

2. 虚拟滚动

<VirtualList
  :data-sources="articles"
  :data-key="'id'"
  :keeps="30"
/>

3. 计算属性缓存

const hotArticles = computed(() => {
  return articles.value
    .filter(a => a.views > 1000)
    .sort((a, b) => b.views - a.views)
})

最佳实践

1. 组件命名

  • 使用PascalCase
  • 组件名与文件名保持一致
  • 使用语义化的名称

2. Props定义

  • 明确定义类型
  • 提供合理的默认值
  • 使用TypeScript类型检查

3. 样式管理

  • 使用scoped CSS
  • 避免样式污染
  • 使用CSS变量

总结

通过合理的架构设计和组件拆分,我们实现了:

  1. 更好的代码组织 - 职责清晰,易于理解
  2. 更高的可维护性 - 修改某个功能只需修改对应组件
  3. 更强的可复用性 - 组件可在多个页面中复用
  4. 更好的可测试性 - 独立组件更容易编写单元测试
  5. 更高的开发效率 - 团队成员可同时开发不同组件

标签:#Vue3 #组件化 #架构设计 #前端 #代码重构

点赞❤️ + 收藏⭐️ + 评论💬,你的支持是我创作的动力!

❌
❌