【前端从0到1实战】第9篇:异步加载(Async)与骨架屏(Skeleton)
【前端从0到1实战】第9篇:异步加载(Async)与骨架屏(Skeleton)
在前面四篇中,我们掌握了“静态”组件的构建,即所有内容都在 HTML 中预先定义好。今天,我们将迈出从“网页”到“Web 应用”最关键的一步:异步数据加载。
在真实世界中,数据几乎都来自服务器。当用户点击按钮时,我们需要:
向服务器请求数据 (使用 fetch API)。
在等待数据返回时,给用户一个即时反馈(加载中...)。
数据返回后,将其动态渲染到页面上。
为了提供最佳用户体验 (UX),我们将摒弃简单的“旋转菊花图”,转而实现一个现代 Web 应用的标配——“骨架屏 (Skeleton)”加载动画。
第一部分:HTML 结构搭建 (状态容器)
与之前的组件不同,这一次的 HTML 扮演的是一个“状态机”的角色。它需要预先定义好所有可能的“状态”:
Idle (空闲):初始状态,提示用户操作。
Loading (加载中):骨架屏状态,默认隐藏。
Error (错误):请求失败状态,默认隐藏。
Success (成功):数据将被 JS 动态插入到这里。
<!-- 1. 控制区 -->
<div class="async-header">
<h2>用户列表</h2>
<p>点击下方按钮从服务器加载用户数据。</p>
<button id="btn-fetch-data" class="btn btn-primary">
加载数据
</button>
</div>
<!--
2. 内容区 (状态容器)
id="user-list-container" 是我们将要操作的主区域
-->
<div id="user-list-container" class="user-list">
<!--
状态 1: Idle (空闲)
id="idle-message" (默认显示)
-->
<div id="idle-message" class="state-message">
<p>尚未加载数据。请点击按钮。</p>
</div>
<!--
状态 2: Loading (加载中)
id="loading-skeleton" (默认隐藏)
这里是“骨架屏”的核心结构。
-->
<div id="loading-skeleton" class="skeleton-wrapper hidden">
<!-- 我们可以复制多个骨架卡片以模拟列表 -->
<!-- 骨架卡片 1 -->
<div class="skeleton-card">
<div class="skeleton skeleton-avatar"></div>
<div class="skeleton-text-group">
<div class="skeleton skeleton-line-title"></div>
<div class="skeleton skeleton-line-text"></div>
</div>
</div>
<!-- 骨架卡片 2 -->
<div class="skeleton-card">
<div class="skeleton skeleton-avatar"></div>
<div class="skeleton-text-group">
<div class="skeleton skeleton-line-title"></div>
<div class="skeleton skeleton-line-text"></div>
</div>
</div>
</div>
<!--
状态 3: Error (错误)
id="error-message" (默认隐藏)
-->
<div id="error-message" class="state-message error hidden">
<p>数据加载失败!请检查网络并重试。</p>
</div>
<!--
状态 4: Success (成功)
(这个状态没有 HTML 占位符,因为 JS 会动态创建 .user-card
并将其插入到 #user-list-container 中)
-->
</div>
第二部分:CSS 样式 (骨架屏动画)
CSS 的核心是实现“骨架屏”的微光闪烁 (Shimmer)动画。
我们定义一个 .skeleton 基础类,设置它的背景色为浅灰色。
我们使用 @keyframes 定义一个“微光”动画,它本质上是一个从左到右移动的“亮色渐变”。
我们将这个动画应用到 .skeleton 元素上。
/* --- 基础容器样式 --- /
.async-container {
width: 600px;
max-width: 100%;
margin: 40px auto;
border: 1px solid #dfe4ea;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
background-color: #fff;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
overflow: hidden; / 保证内部元素不溢出圆角 */
}
.async-header {
padding: 25px;
text-align: center;
background-color: #f8f9fa;
border-bottom: 1px solid #dfe4ea;
}
.async-header h2 { margin: 0 0 10px; }
.btn-primary {
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
}
.btn-primary:disabled {
background-color: #e9ecef;
cursor: not-allowed;
}
.user-list {
padding: 25px;
min-height: 200px;
}
.state-message {
text-align: center;
padding: 40px 0;
color: #57606f;
}
.state-message.error {
color: #dc3545;
font-weight: 500;
}
/* 工具类,用于 JS 切换状态 */
.hidden {
display: none !important;
}
/* --- 核心:骨架屏 (Skeleton) 样式 --- */
/* 1. 定义微光动画 /
@keyframes shimmer {
0% {
/ 动画开始前,微光在左侧 /
background-position: -468px 0;
}
100% {
/ 动画结束后,微光在右侧 */
background-position: 468px 0;
}
}
/* 2. 定义基础骨架元素 */
.skeleton {
animation-duration: 1.5s;
animation-fill-mode: forwards;
animation-iteration-count: infinite;
animation-name: shimmer;
animation-timing-function: linear;
/* 核心:创建“微光”渐变
从浅灰 -> 亮白 -> 浅灰
*/
background: linear-gradient(
to right,
#f6f7f8 8%, /* 基础色 */
#e9ecef 38%, /* 亮色 */
#f6f7f8 54% /* 基础色 */
);
background-size: 1000px 64px;
border-radius: 4px;
}
/* 3. 布局骨架卡片 /
.skeleton-wrapper {
/ (这个 .hidden 类会被 JS 移除) /
}
.skeleton-card {
display: flex;
align-items: center;
margin-bottom: 15px;
}
.skeleton-avatar {
width: 50px;
height: 50px;
border-radius: 50%; / 圆形头像 /
flex-shrink: 0; / 防止头像被压缩 */
}
.skeleton-text-group {
flex-grow: 1;
margin-left: 15px;
}
.skeleton-line-title {
width: 40%;
height: 18px;
margin-bottom: 8px;
}
.skeleton-line-text {
width: 80%;
height: 14px;
}
/* --- 状态 4:成功加载后的卡片样式 (用于对比) --- /
.user-card {
display: flex;
align-items: center;
padding: 15px;
margin-bottom: 10px;
border: 1px solid #dfe4ea;
border-radius: 8px;
animation: fadeIn 0.3s ease-out;
}
.user-avatar {
width: 50px;
height: 50px;
border-radius: 50%;
margin-right: 15px;
background-color: #dfe4ea; / 头像占位符 */
}
.user-info h4 {
margin: 0 0 5px;
color: #2f3542;
}
.user-info p {
margin: 0;
color: #57606f;
}
/* 淡入动画 */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
第三部分:JS 交互逻辑 (async/await)
这是本系列的“毕业大戏”。我们将使用现代 JavaScript 的 async/await 语法来处理 fetch 请求,这使得异步代码像同步代码一样易于阅读。
document.addEventListener('DOMContentLoaded', () => {
// --- 1. DOM 元素获取 (状态容器) ---
const fetchDataBtn = document.getElementById('btn-fetch-data');
const userListContainer = document.getElementById('user-list-container');
const idleMessage = document.getElementById('idle-message');
const loadingSkeleton = document.getElementById('loading-skeleton');
const errorMessage = document.getElementById('error-message');
// 我们将使用一个“假”的 API (JSONPlaceholder) 来模拟
const API_URL = '[https://jsonplaceholder.typicode.com/users?_limit=3](https://jsonplaceholder.typicode.com/users?_limit=3)'; // 只取3个用户
// --- 2. 状态切换函数 ---
// 切换到“加载中”状态
const showLoadingState = () => {
idleMessage.classList.add('hidden');
errorMessage.classList.add('hidden');
loadingSkeleton.classList.remove('hidden');
fetchDataBtn.disabled = true;
fetchDataBtn.textContent = '加载中...';
};
// 切换到“错误”状态
const showErrorState = () => {
loadingSkeleton.classList.add('hidden');
idleMessage.classList.add('hidden');
errorMessage.classList.remove('hidden');
fetchDataBtn.disabled = false;
fetchDataBtn.textContent = '重试';
};
// 切换到“空闲”状态 (用于清空)
const showIdleState = () => {
errorMessage.classList.add('hidden');
loadingSkeleton.classList.add('hidden');
idleMessage.classList.remove('hidden');
userListContainer.innerHTML = ''; // 清空动态内容
userListContainer.appendChild(idleMessage); // 把空闲消息放回去
};
// --- 3. 核心:数据渲染函数 ---
// (A) 渲染成功
const renderSuccess = (users) => {
// 隐藏所有状态指示器
loadingSkeleton.classList.add('hidden');
errorMessage.classList.add('hidden');
idleMessage.classList.add('hidden');
// 清空容器,准备填充新数据
userListContainer.innerHTML = '';
// 遍历 API 返回的 users 数组
users.forEach(user => {
// 动态创建 .user-card
const card = document.createElement('div');
card.className = 'user-card';
// (为简单起见,我们使用 innerHTML 来构建卡片内部)
// (在真实项目中,请注意防范 XSS 攻击)
card.innerHTML = `
<img src="[https://i.pravatar.cc/50?u=$](https://i.pravatar.cc/50?u=$){user.id}" alt="${user.name}" class="user-avatar">
<div class="user-info">
<h4>${user.name}</h4>
<p>${user.email}</p>
</div>
`;
// 将新创建的卡片添加到容器中
userListContainer.appendChild(card);
});
fetchDataBtn.disabled = false;
fetchDataBtn.textContent = '重新加载';
};
// --- 4. 事件绑定与 async/await ---
fetchDataBtn.addEventListener('click', async () => {
// (A) 进入加载状态
showLoadingState();
// (B) 尝试执行异步操作
try {
// 模拟2秒的网络延迟,让我们能看清骨架屏
await new Promise(resolve => setTimeout(resolve, 1500));
// (1) 发送网络请求,并“等待”响应
const response = await fetch(API_URL);
// (2) 检查响应是否成功
if (!response.ok) {
// 如果 HTTP 状态码不是 2xx,则抛出一个错误
throw new Error(`HTTP 错误! 状态: ${response.status}`);
}
// (3) 将响应体解析为 JSON,并“等待”
const users = await response.json();
// (4) 成功!调用渲染函数
renderSuccess(users);
} catch (error) {
// (C) 如果 try 块中的任何“await”失败,就会进入 catch 块
console.error('数据获取失败:', error);
showErrorState();
}
});
});
总结
恭喜您完成了本系列的全部课程!我们从一个简单的 Tab 选项卡开始,最终实现了一个使用 async/await 和“骨架屏”的动态数据组件。
我们学到了:
HTML: 如何将 HTML 作为一个“状态机”容器,预先定义 Idle、Loading 和 Error 状态。
CSS:
掌握了使用 @keyframes 和 linear-gradient 来创建专业级的“骨架屏微光”动画。
如何使用 .hidden 工具类来配合 JS 管理 UI 状态。
JS (核心):
如何使用 fetch 和 async/await 语法来处理异步 API 请求。
如何使用 try...catch 块来优雅地处理网络错误。
如何动态创建 DOM (document.createElement) 并将其渲染到页面上,这是 Web 应用的灵魂。
至此,您已经掌握了从0到1构建一个完整、专业、动态的前端组件所需的全部核心技能。

浙公网安备 33010602011771号