提示条贴合右侧边栏
好的,很高兴能为你提供这个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><LoadingNotificationv-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 的核心思想有更深入的理解!