好的,很高兴能为你提供这个Vue3 + Element Plus项目中自定义加载组件的设计思路和具体代码实现。这个需求非常典型,因为它不仅包含组件的视觉设计,还涉及到了组件间通信、状态管理和 DOM 交互,非常适合用来学习和巩固 Vue 的核心概念。
我将按照一个互联网大厂前端开发的标准和流程,为你深入浅出地讲解。
1. 设计思路与架构(Design Philosophy)
在动手写代码之前,我们先进行高层设计。这是一个好习惯,能让我们的代码结构更清晰、更易于维护。
1.1. 组件拆分(Component Decomposition)
首先,我们会将这个功能拆分为两个核心部分:
父组件 (Parent Component): 比如叫做
DataDisplayPage.vue
。这个组件是“指挥官”,它负责:- 包含可拖动的右侧边栏和主内容区域。
- 管理核心的业务逻辑状态,例如:
'idle'
(空闲),'loading'
(加载中),'error'
(错误),'success'
(成功)。 - 发起获取数据的API请求。
- 根据API请求的结果,更新状态。
- 控制加载提示组件的显示和隐藏,并传递当前的状态给它。
- 实现侧边栏的拖动逻辑,并把侧边栏的宽度实时同步。
子组件 (Child Component): 我们就叫它
LoadingNotification.vue
。这个组件是“展示者”,它非常“纯粹”,只负责:- 接收父组件传递过来的状态 (
props
)。 - 根据不同的状态,显示不同的UI(“正在加载中…”+旋转图标,或“数据异常”+重试按钮)。
- 当用户点击“重新获取数据”按钮时,通知父组件 (
emits
)。 - 它不关心数据是怎么来的,也不关心侧边栏是怎么拖动的,只关心自己的外观和位置。
- 接收父组件传递过来的状态 (
为什么这么拆分?
这就是关注点分离 (Separation of Concerns) 的设计原则。父组件管逻辑和状态,子组件管UI展示。这样做的好处是:
- 高内聚,低耦合:
LoadingNotification.vue
组件可以被复用到项目的任何地方,因为它不依赖任何特定的页面逻辑。 - 易于维护: 修改提示条的样式只需要改子组件,修改数据获取逻辑只需要改父组件,互不影响。
- 清晰的单向数据流: 数据从父组件通过
props
流向子组件,子组件通过emits
将事件发送给父组件。这是 Vue 推荐的最佳实践。
1.2. 定位与拖动策略 (Positioning Strategy)
这是本需求的难点和亮点。提示框要跟随可拖动侧边栏移动。
- 我们不能将提示框放在侧边栏内部,因为提示框在侧边栏的左侧。
- 我们也不能简单地使用
fixed
定位,因为它需要相对侧边栏移动。
最佳方案是:
- 父组件的主内容区域(Main Content Area)使用
position: relative;
。 LoadingNotification.vue
组件将被放置在主内容区域中,并使用position: absolute;
。- 父组件会有一个
ref
变量,比如sidebarWidth
,用来存储侧边栏的实时宽度。 - 我们会通过一个
computed
属性来动态计算LoadingNotification
的style
。具体来说,它的right
值将是sidebarWidth.value + 'px'
再加上一些间距。 - 当用户拖动侧边栏时,
sidebarWidth
的值会改变,computed
属性会重新计算,从而 Vue 会自动更新LoadingNotification
的style
,实现跟随效果。
2. 代码实现 (Implementation)
现在,我们开始一步步编写代码。
步骤 1: 创建 LoadingNotification.vue
子组件
这是我们的UI展示组件。
// src/components/LoadingNotification.vue
<script setup lang="ts">
import { computed } from 'vue';
// 1. 定义组件接收的props
// 使用TS来定义类型,更规范
// 'loading': 加载中状态
// 'error': 错误状态
const props = defineProps<{
state: 'loading' | 'error';
}>();
// 2. 定义组件可以触发的事件
const emit = defineEmits<{
(e: 'retry'): void; // 定义一个名为 'retry' 的事件
}>();
// 3. 使用计算属性来根据 state 返回不同的文本
const message = computed(() => {
return props.state === 'loading' ? '正在加载中' : '数据异常';
});
// 4. 定义重试按钮的点击事件处理函数
function handleRetry() {
// 当按钮被点击时,触发 retry 事件通知父组件
emit('retry');
}
</script>
<template>
<div class="loading-notification">
<div class="message-area">
{{ message }}
</div>
<div class="spinner-area">
<div v-if="state === 'loading'" class="loading-spinner"></div>
<button v-if="state === 'error'" @click="handleRetry" class="retry-button">
重新获取
</button>
</div>
</div>
</template>
<style scoped>
/* 使用 scoped 来确保样式只作用于当前组件,避免全局污染 */
.loading-notification {
/* 绝对定位,相对于父容器 */
position: absolute;
top: 20px; /* 距离顶部20px */
/* right 属性将由父组件动态提供 */
z-index: 2000; /* 确保在顶层 */
display: flex;
align-items: center;
padding: 10px 16px;
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: opacity 0.3s ease, transform 0.3s ease;
color: #303133;
}
.message-area {
font-size: 14px;
margin-right: 12px;
}
.spinner-area {
display: flex;
align-items: center;
justify-content: center;
}
/* 这是我们手写的旋转空心圆 */
.loading-spinner {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid #E4E7ED; /* 灰色部分 */
border-top-color: #409EFF; /* 蓝色部分 */
animation: spin 1s linear infinite;
}
/* 定义旋转动画 */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.retry-button {
/* 简单的按钮样式,你可以使用 Element Plus 的 ElButton 替代 */
font-size: 12px;
padding: 4px 8px;
border: 1px solid #409EFF;
color: #409EFF;
background-color: transparent;
border-radius: 4px;
cursor: pointer;
outline: none;
}
.retry-button:hover {
background-color: #ecf5ff;
}
</style>
步骤 2: 创建父组件 DataDisplayPage.vue
这是我们的页面和逻辑控制中心。
// src/views/DataDisplayPage.vue
<script setup lang="ts">
import { ref, computed } from 'vue';
import LoadingNotification from '@/components/LoadingNotification.vue';
// --- State Management ---
// 侧边栏的初始宽度
const sidebarWidth = ref<number>(250);
// 加载状态,'idle' 表示初始/成功状态,此时不显示提示
type LoadingState = 'idle' | 'loading' | 'error';
const loadingState = ref<LoadingState>('idle');
// 控制提示框是否应该显示
const showNotification = computed(() => {
return loadingState.value === 'loading' || loadingState.value === 'error';
});
// --- API Simulation ---
// 模拟API调用
function mockApiCall(shouldFail: boolean = false): Promise<string> {
console.log('开始获取数据...');
return new Promise((resolve, reject) => {
setTimeout(() => {
if (shouldFail) {
console.error('API请求失败');
reject(new Error('Backend error'));
} else {
console.log('API请求成功');
resolve('这是从后端获取的数据');
}
}, 2000); // 模拟2秒的网络延迟
});
}
// --- Event Handlers ---
// "获取数据" 按钮的点击事件
async function handleGetData() {
loadingState.value = 'loading'; // 进入加载中状态
try {
const data = await mockApiCall(false); // 模拟成功
// const data = await mockApiCall(true); // 取消注释此行以模拟失败
console.log('数据处理成功:', data);
loadingState.value = 'idle'; // 数据成功返回,恢复idle状态,提示框消失
} catch (error) {
loadingState.value = 'error'; // 捕获到异常,进入错误状态
}
}
// "获取异常数据" 按钮的点击事件,用于演示
async function handleGetErrorData() {
loadingState.value = 'loading';
try {
await mockApiCall(true); // 强制失败
} catch (error) {
loadingState.value = 'error';
}
}
// 处理子组件发出的 'retry' 事件
async function handleRetry() {
console.log('接收到重试请求,重新获取数据...');
// 重新尝试获取数据,可以复用 handleGetData 逻辑
// 这里我们直接调用会失败的接口,方便演示
await handleGetErrorData();
}
// --- Draggable Sidebar Logic ---
const isDragging = ref(false);
function startDrag(event: MouseEvent) {
isDragging.value = true;
// 监听整个窗口的 mousemove 和 mouseup 事件
window.addEventListener('mousemove', onDrag);
window.addEventListener('mouseup', stopDrag);
}
function onDrag(event: MouseEvent) {
if (!isDragging.value) return;
// 实时计算侧边栏宽度
// window.innerWidth 是浏览器窗口的总宽度
// event.clientX 是鼠标当前的X坐标
// 新的宽度 = 窗口宽度 - 鼠标X坐标
const newWidth = window.innerWidth - event.clientX;
// 设置一个最小和最大宽度防止拖动出界
if (newWidth > 200 && newWidth < 600) {
sidebarWidth.value = newWidth;
}
}
function stopDrag() {
isDragging.value = false;
// 拖动结束,移除事件监听器,避免不必要的性能开销
window.removeEventListener('mousemove', onDrag);
window.removeEventListener('mouseup', stopDrag);
}
// --- Computed Styles ---
// 动态计算 LoadingNotification 的样式
const notificationStyle = computed(() => {
return {
// 关键:right 的值依赖于 sidebarWidth
// 20px 是提示框和侧边栏之间的间距
right: `${sidebarWidth.value + 20}px`,
};
});
</script>
<template>
<div class="page-layout">
<main class="main-content">
<div class="controls">
<el-button type="primary" @click="handleGetData">点击获取数据 (模拟成功)</el-button>
<el-button type="danger" @click="handleGetErrorData">点击获取数据 (模拟失败)</el-button>
</div>
<h1>主内容区域</h1>
<p>这里的宽度会随着右侧边栏的拖动而变化。</p>
<LoadingNotification
v-if="showNotification"
:style="notificationStyle"
:state="loadingState"
@retry="handleRetry"
/>
</main>
<aside class="sidebar" :style="{ width: `${sidebarWidth}px` }">
<div class="drag-handle" @mousedown="startDrag"></div>
<div class="sidebar-content">
<h2>可拖动侧边栏</h2>
<p>从左侧边缘拖动来改变宽度。</p>
</div>
</aside>
</div>
</template>
<style scoped>
.page-layout {
display: flex;
height: 100vh; /* 占满整个视口高度 */
width: 100%;
overflow: hidden; /* 防止出现滚动条 */
}
/* 主内容区域需要相对定位,作为 LoadingNotification 的定位父级 */
.main-content {
flex-grow: 1; /* 占据剩余所有空间 */
position: relative; /* 关键! */
padding: 20px;
background-color: #f0f2f5;
}
.controls {
margin-bottom: 20px;
}
.sidebar {
flex-shrink: 0; /* 防止侧边栏被压缩 */
position: relative; /* 为拖动条定位 */
background-color: #ffffff;
border-left: 1px solid #dcdfe6;
}
.sidebar-content {
padding: 20px;
}
.drag-handle {
position: absolute;
left: -5px; /* 稍微突出一点,方便点击 */
top: 0;
height: 100%;
width: 10px;
cursor: col-resize; /* 鼠标样式变为列拖动 */
z-index: 10;
}
</style>
3. 如何使用与讲解 (How to Use & Explanation)
文件结构:
- 将
LoadingNotification.vue
文件放在src/components/
目录下。 - 将
DataDisplayPage.vue
文件放在src/views/
目录下。 - 确保你的项目已经安装了
vue
,element-plus
。
- 将
核心概念讲解 (适合初学者):
<script setup>
: 这是 Vue 3 中推荐的组合式 API (Composition API) 语法糖。它让代码更简洁,所有在<script setup>
中定义的变量和函数都可以直接在<template>
中使用。ref
: 用来创建一个响应式变量。当ref
变量的值改变时,Vue 会自动更新界面上用到这个变量的地方。我们用它来存储sidebarWidth
和loadingState
。computed
: 计算属性。它像一个智能的变量,会根据其他响应式变量(如ref
)的变化而自动重新计算自己的值。我们用它来动态生成notificationStyle
,这是实现跟随效果的灵魂。props
和emits
: 这是父子组件通信的“标准协议”。props
让父组件可以“发号施令”给子组件(比如告诉它当前是什么状态),emits
让子组件可以“汇报情况”给父组件(比如告诉它“用户点击了重试按钮”)。- CSS
position
: 我们利用position: relative
和position: absolute
建立了一个定位上下文。子组件的absolute
定位是相对于第一个position
不是static
的祖先元素。在这里,就是我们的.main-content
。 - 事件监听 (
@mousedown
,@mousemove
,@mouseup
): 这是实现拖动功能的标准 DOM 操作。我们在父组件中处理这些事件,因为是父组件控制着侧边栏的宽度,体现了“谁的数据谁负责”的原则。
4. 大厂规范与其它方案
- 代码规范: 以上代码遵循了
TypeScript
类型定义、ESLint 推荐规则(如const
优先)、单向数据流等,符合大厂的开发规范。注释清晰,变量命名有意义。 - CSS 变量: 在大型项目中,颜色、间距等值可以使用 CSS 变量 (
var(--el-color-primary)
) 来定义,方便进行全局主题切换。 - 替代方案: Element Plus 自带 Loading: 对于简单场景,可以直接使用 Element Plus 提供的
ElLoading
服务或指令。- 服务调用:
const loading = ElLoading.service({ text: 'Loading...' })
,然后在数据返回后调用loading.close()
。 - 指令:
<div v-loading="isLoading"></div>
。 - 为什么我们不直接用? 因为你的需求非常具体(特定的样式、错误状态和重试按钮、跟随侧边栏移动的定位),手写一个自定义组件能提供最大的灵活性和控制力,这也是一个很好的学习过程。
- 服务调用:
希望这份详尽的指南能帮助你成功实现这个功能,并对 Vue 的核心思想有更深入的理解!