前言: 当用户打开你的页面,面对一片空白等待加载时,体验是焦虑的。一个流畅的进度条能告诉用户"正在加载,请稍候",极大提升感知速度。本文从进度获取、渲染方式、页面加载、框架集成四个维度,给你一套完整的实现方案。
目录
- 一、背景:为什么需要加载进度条?
- 二、核心问题拆解
- 三、方案一:页面级加载进度条
- 四、方案二:数据请求进度追踪
- 五、方案三:路由切换进度条(React)
- 六、方案四:路由切换进度条(Vue)
- [七、渲染方式对比:DOM vs SVG vs Canvas](#七、渲染方式对比:DOM vs SVG vs Canvas "#%E4%B8%83%E6%B8%B2%E6%9F%93%E6%96%B9%E5%BC%8F%E5%AF%B9%E6%AF%94dom-vs-svg-vs-canvas")
- 八、进阶技巧与避坑指南
- 九、总结与选型建议
一、背景:为什么需要加载进度条?
arduino
没有进度条的用户心理:
打开页面 → 空白... → 还在空白... → "这网站是不是挂了?" → 离开 ❌
有进度条的用户心理:
打开页面 → 看到进度条在走 → "哦,在加载" → 继续等待 → 加载完成 ✅
核心价值:
- 降低用户焦虑感 --- 可视化反馈让等待变得可预期
- 提升感知性能 --- 同样的加载时间,有进度条感觉更快
- 传递专业感 --- 细节体现产品品质
二、核心问题拆解
实现一个加载进度条,本质上要解决两个问题:
2.1 怎么拿到进度?
| 方法 | 兼容性 | 适用场景 | 精度 |
|---|---|---|---|
fetch 的 ProgressEvent |
现代浏览器 | 文件上传/下载 | ⭐⭐⭐⭐⭐ 高精度 |
XMLHttpRequest 的 onprogress |
全兼容 | 旧项目/AJAX | ⭐⭐⭐⭐⭐ 高精度 |
Performance API |
IE10+ | 页面整体加载 | ⭐⭐⭐ 中等精度 |
| 路由钩子/拦截器 | 框架内 | SPA 路由切换 | ⭐⭐ 模拟进度 |
| 模拟递增 | 全兼容 | 无法获取真实进度时 | ⭐ 纯视觉 |
2.2 怎么绘制进度?
| 方式 | 特点 | 适用场景 |
|---|---|---|
| DOM + CSS | 简单直接,样式灵活 | 大多数场景首选 |
| SVG | 矢量无损,支持复杂动画 | 需要圆环/特殊形状时 |
| Canvas | 高性能,适合复杂图形 | 大量动画/游戏场景 |
三、方案一:页面级加载进度条
监听浏览器的页面加载事件,展示从导航开始到页面完全就绪的进度。
3.1 基于 Performance API 的真实进度
js
class PageLoadingBar {
constructor(options = {}) {
this.container = options.container || document.body;
this.color = options.color || '#42b883';
this.height = options.height || '3px';
this.position = options.position || 'top'; // top 或 bottom
this._progress = 0;
this._init();
}
_init() {
// 创建进度条 DOM
this.bar = document.createElement('div');
Object.assign(this.bar.style, {
position: 'fixed',
left: '0',
[this.position]: '0',
width: '0%',
height: this.height,
backgroundColor: this.color,
transition: 'width 0.3s ease-out',
zIndex: '99999',
boxShadow: `0 0 10px ${this.color}40`,
});
this.container.appendChild(this.bar);
}
setProgress(value) {
this._progress = Math.min(100, Math.max(0, value));
this.bar.style.width = `${this._progress}%`;
}
start() {
this.setProgress(0);
this._startMonitoring();
}
finish() {
this.setProgress(100);
// 动画结束后移除
setTimeout(() => {
if (this.bar.parentNode) {
this.bar.style.opacity = '0';
setTimeout(() => this.bar.remove(), 300);
}
}, 300);
}
_startMonitoring() {
// 使用 Performance API 追踪页面加载阶段
const updateProgress = () => {
const perf = performance.getEntriesByType('navigation')[0];
if (!perf) return;
// 根据各事件估算进度
const stages = [
{ event: 'responseStart', progress: 30 }, // 收到首字节
{ event: 'domInteractive', progress: 60 }, // DOM 可交互
{ event: 'domContentLoadedEventStart', progress: 80 }, // DOM 解析完成
{ event: 'loadEventEnd', progress: 100 }, // 完全加载
];
for (const stage of stages) {
if (perf[stage.event] > 0) {
this.setProgress(Math.max(this._progress, stage.progress));
}
}
};
// 定期检查进度
this._timer = setInterval(updateProgress, 50);
// 同时用模拟递增兜底(防止某些阶段无法检测)
this._simulate();
// 监听 load 事件
window.addEventListener('load', () => {
clearInterval(this._timer);
this.finish();
}, { once: true });
}
_simulate() {
// 模拟渐进式增长,让进度条看起来一直在动
const simulate = () => {
if (this._progress >= 90) return; // 90% 后停止模拟,等真实事件
let increment = 0;
if (this._progress < 30) increment = Math.random() * 8 + 2; // 前段快
else if (this._progress < 60) increment = Math.random() * 5 + 1; // 中段匀速
else increment = Math.random() * 2 + 0.5; // 后段慢
this.setProgress(this._progress + increment);
this._simTimer = setTimeout(simulate, 200 + Math.random() * 300);
};
simulate();
}
destroy() {
clearInterval(this._timer);
clearTimeout(this._simTimer);
if (this.bar.parentNode) this.bar.remove();
}
}
// 使用
const loader = new PageLoadingBar({ color: '#409eff' });
loader.start();
// 页面加载完成后自动调用 finish()
3.2 极简版:NProgress 风格的顶部进度条
html
<!-- 直接复制粘贴即可使用 -->
<style>
.nprogress-bar {
position: fixed;
top: 0; left: 0;
width: 0%; height: 3px;
background: #42b883;
z-index: 99999;
transition: width 0.2s ease-out;
}
.nprogress-bar.complete {
opacity: 0;
transition: width 0.3s linear, opacity 0.3s 0.2s ease-out;
}
</style>
<div class="nprogress-bar" id="top-loader"></div>
<script>
const bar = document.getElementById('top-loader');
let progress = 0;
function startLoader() {
progress = 0;
bar.style.width = '0%';
bar.classList.remove('complete');
requestAnimationFrame(animate);
}
function animate() {
if (progress < 90) {
// 非线性增速:越往后越慢
progress += (90 - progress) * 0.03 + Math.random() * 2;
bar.style.width = Math.min(progress, 90) + '%';
requestAnimationFrame(animate);
}
}
function doneLoader() {
bar.style.width = '100%';
bar.classList.add('complete');
}
// 使用
startLoader();
window.addEventListener('load', doneLoader);
</script>
四、方案二:数据请求进度追踪
针对 AJAX/Fetch 请求,获取真实的下载/上传进度。
4.1 Fetch API + ReadableStream(现代方案)
js
async function fetchWithProgress(url, options = {}) {
const { onProgress } = options;
const response = await fetch(url, options);
if (!onProgress || !response.body) return response;
const contentLength = response.headers.get('content-length');
const total = parseInt(contentLength, 10) || 0;
let loaded = 0;
const reader = response.body.getReader();
const stream = new ReadableStream({
async start(controller) {
while (true) {
const { done, value } = await reader.read();
if (done) {
onProgress({ loaded: total || loaded, total, percent: 100 });
controller.close();
break;
}
loaded += value.length;
const percent = total ? Math.round((loaded / total) * 100) : null;
onProgress({ loaded, total, percent });
controller.enqueue(value);
}
},
});
return new Response(stream, {
headers: response.headers,
status: response.status,
statusText: response.statusText,
});
}
// 使用示例
fetchWithProgress('/api/large-data', {
onProgress: ({ loaded, total, percent }) => {
console.log(`已下载 ${loaded}/${total} 字节 (${percent}%)`);
updateProgressBar(percent); // 更新你的进度条 UI
},
}).then(res => res.json()).then(data => {
console.log('数据加载完成:', data);
});
4.2 XMLHttpRequest 进度追踪(经典方案)
js
function xhrWithProgress(url, callbacks = {}) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
// ✅ 下载进度
xhr.onprogress = (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
callbacks.onDownloadProgress?.({
loaded: e.loaded,
total: e.total,
percent,
});
}
};
// ✅ 上传进度
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
callbacks.onUploadProgress?.({
loaded: e.loaded,
total: e.total,
percent,
});
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error(`HTTP ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error('Network error'));
xhr.send();
});
}
// 使用示例:带进度的大文件上传
async function uploadFile(file) {
const formData = new FormData();
formData.append('file', file);
try {
const result = await xhrWithProgress('/api/upload', {
onUploadProgress: ({ percent }) => {
document.getElementById('upload-progress').style.width = percent + '%';
document.getElementById('upload-text').textContent = `上传中 ${percent}%`;
},
});
console.log('上传成功:', result);
} catch (err) {
console.error('上传失败:', err);
}
}
4.3 Axios 请求拦截器统一处理
js
import axios from 'axios';
// 创建实例
const api = axios.create({ baseURL: '/api' });
// 全局进度条状态管理
let pendingRequests = 0;
// 请求拦截器:发起请求时启动/增加进度
api.interceptors.request.use((config) => {
pendingRequests++;
if (pendingRequests === 1) {
globalProgressBar.start(); // 启动全局进度条
}
return config;
});
// 响应拦截器:请求完成时减少进度
api.interceptors.response.use(
(response) => {
pendingRequests--;
if (pendingRequests === 0) {
globalProgressBar.done(); // 所有请求完成
}
return response;
},
(error) => {
pendingRequests--;
if (pendingRequests === 0) {
globalProgressBar.done();
}
return Promise.reject(error);
}
);
// 单个请求的进度(用于文件上传/下载)
api.post('/upload', formData, {
onUploadProgress: (progressEvent) => {
const percent = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
uploadProgressRef.current.style.width = `${percent}%`;
},
});
五、方案三:路由切换进度条(React)
SPA 应用中,路由切换时的白屏同样需要进度条来缓解。最流行的方案是 NProgress 或自研组件。
5.1 React Router + 自定义进度条
jsx
// components/RouteLoadingBar.jsx
import { useState, useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
export default function RouteLoadingBar({ color = '#1890ff', height = 3 }) {
const location = useLocation();
const [progress, setProgress] = useState(0);
const [isVisible, setIsVisible] = useState(false);
const animFrameRef = useRef(null);
useEffect(() => {
// 路由变化时开始
setIsVisible(true);
setProgress(0);
let current = 0;
const animate = () => {
if (current < 90) {
// 缓动函数:先快后慢
current += (90 - current) * 0.04 + Math.random() * 1.5;
setProgress(current);
animFrameRef.current = requestAnimationFrame(animate);
}
};
animFrameRef.current = requestAnimationFrame(animate);
// 模拟页面加载完成后结束
const timer = setTimeout(() => {
cancelAnimationFrame(animFrameRef.current);
setProgress(100);
setTimeout(() => setIsVisible(false), 300); // 过渡动画后隐藏
}, 800);
return () => {
clearTimeout(timer);
cancelAnimationFrame(animFrameRef.current);
};
}, [location.pathname]);
if (!isVisible) return null;
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
height,
backgroundColor: color,
transition: 'width 0.2s ease-out, opacity 0.3s ease-out',
boxShadow: `0 0 8px ${color}50`,
zIndex: 99999,
width: `${progress}%`,
}}
/>
);
}
jsx
// App.jsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import RouteLoadingBar from './components/RouteLoadingBar';
function Home() { return <h1>首页</h1>; }
function About() { return <h1>关于我们</h1>; }
export default function App() {
return (
<BrowserRouter>
{/* 放在 Router 内部即可自动工作 */}
<RouteLoadingBar color="#1677ff" />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
</BrowserRouter>
);
}
5.2 React Suspense 边界配合
jsx
import { Suspense, lazy } from 'react';
// 懒加载组件
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
function App() {
return (
<>
<RouteLoadingBar />
<Suspense fallback={
<div style={{ padding: 40, textAlign: 'center' }}>
<div className="spinner" /> {/* 可选:加个 spinner */}
<p>页面加载中...</p>
</div>
}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
</>
);
}
六、方案四:路由切换进度条(Vue)
Vue 通过导航守卫来控制路由切换进度条是最优雅的方式。
6.1 Vue Router 导航守卫 + NProgress
js
// router/index.js
import { createRouter, createWebHistory } from 'vue-router';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
// 配置 NProgress
NProgress.configure({
easing: 'ease-in-out', // 动画缓动函数
speed: 300, // 动画速度(ms)
showSpinner: false, // 是否显示右上角旋转图标
trickleSpeed: 200, // 自动增量速率
minimum: 0.1, // 最小百分比
});
const routes = [
{ path: '/', component: () => import('@/views/Home.vue') },
{ path: '/about', component: () => import('@/views/About.vue') },
];
const router = createRouter({
history: createWebHistory(),
routes,
});
// ✅ 全局前置守卫:路由跳转前开始
router.beforeEach((to, from, next) => {
NProgress.start();
next();
});
// ✅ 全局后置守卫:路由跳转后结束
router.afterEach(() => {
NProgress.done();
});
export default router;
6.2 纯手写 Vue 版进度条(不依赖第三方)
vue
<!-- components/PageLoadingBar.vue -->
<template>
<transition name="fade">
<div v-if="loading" class="page-loading-bar">
<div class="bar" :style="{ width: progress + '%' }"></div>
</div>
</transition>
</template>
<script setup>
import { ref, watch } from 'vue';
import { useRouter } from 'vue-router';
const props = defineProps({
color: { type: String, default: '#42b883' },
height: { type: String, default: '3px' },
errorColor: { type: String, default: '#f56c6c' },
});
const router = useRouter();
const loading = ref(false);
const progress = ref(0);
let animFrame = null;
function start() {
loading.value = true;
progress.value = 0;
animate();
}
function animate() {
if (progress.value >= 90) return;
progress.value += (90 - progress.value) * 0.04 + Math.random() * 1.5;
animFrame = requestAnimationFrame(animate);
}
function finish() {
cancelAnimationFrame(animFrame);
progress.value = 100;
setTimeout(() => { loading.value = false; }, 300);
}
// 监听路由变化
router.beforeEach((to, from, next) => {
start();
next();
});
router.afterEach(() => {
// 模拟最小显示时间,避免闪烁
setTimeout(finish, 300);
});
</script>
<style scoped>
.page-loading-bar {
position: fixed;
top: 0; left: 0; right: 0;
z-index: 99999;
height: v-bind(height);
}
.bar {
height: 100%;
background: v-bind(color);
transition: width 0.2s ease-out;
box-shadow: 0 0 8px v-bind(color + '50');
}
.fade-enter-active, .fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
</style>
vue
<!-- App.vue -->
<template>
<PageLoadingBar color="#409eff" />
<RouterView />
</template>
<script setup>
import PageLoadingBar from '@/components/PageLoadingBar.vue';
</script>
七、渲染方式对比:DOM vs SVG vs Canvas
7.1 DOM 方式(推荐大多数场景)
css
/* 最简单也最常用的方式 */
.loading-bar-dom {
position: fixed;
top: 0; left: 0;
width: 80%; /* 动态修改 */
height: 4px;
background: linear-gradient(90deg, #667eea, #764ba2);
border-radius: 0 2px 2px 0;
box-shadow: 0 0 12px rgba(102, 126, 234, 0.5);
transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
优点: CSS 动画 GPU 加速、样式灵活、易于定制
缺点: 复杂形状受限
7.2 SVG 方式(适合圆环/特殊形状)
html
<!-- 圆形进度环 -->
<svg width="48" height="48" viewBox="0 0 48 48">
<!-- 背景圆环 -->
<circle cx="24" cy="24" r="20"
fill="none" stroke="#eee" stroke-width="4" />
<!-- 进度圆环:通过 stroke-dasharray 和 stroke-dashoffset 控制 -->
<circle id="progress-ring" cx="24" cy="24" r="20"
fill="none" stroke="#42b883" stroke-width="4"
stroke-linecap="round"
stroke-dasharray="125.6"
stroke-dashoffset="125.6"
transform="rotate(-90 24 24)"
style="transition: stroke-dashoffset 0.35s ease" />
</svg>
<script>
function setRingProgress(percent) {
const ring = document.getElementById('progress-ring');
const circumference = 2 * Math.PI * 20; // r=20
const offset = circumference - (percent / 100) * circumference;
ring.style.strokeDashoffset = offset;
}
setRingProgress(65); // 设置为 65%
</script>
优点: 矢量无损缩放、任意形状、stroke 动画流畅
缺点: 大量 DOM 时性能不如 Canvas
7.3 Canvas 方式(高性能场景)
js
class CanvasProgressBar {
constructor(canvas, options = {}) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.progress = 0;
this.options = {
color: options.color || '#409eff',
bgColor: options.bgColor || '#e8e8e8',
height: options.height || 4,
radius: options.radius || 0,
...options,
};
this._draw();
}
setProgress(value) {
this.progress = Math.min(100, Math.max(0, value));
this._draw();
}
_draw() {
const { canvas, ctx, progress, options } = this;
const w = canvas.width;
const h = options.height;
ctx.clearRect(0, 0, w, h);
// 背景
ctx.fillStyle = options.bgColor;
ctx.beginPath();
ctx.roundRect(0, 0, w, h, options.radius);
ctx.fill();
// 进度
const gradient = ctx.createLinearGradient(0, 0, w, 0);
gradient.addColorStop(0, options.color);
gradient.addColorStop(1, this._lightenColor(options.color, 30));
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.roundRect(0, 0, (w * progress) / 100, h, options.radius);
ctx.fill();
// 高光效果
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.beginPath();
ctx.roundRect(0, 0, (w * progress) / 100, h / 2, [options.radius, options.radius, 0, 0]);
ctx.fill();
}
_lightenColor(hex, percent) {
const num = parseInt(hex.slice(1), 16);
const amt = Math.round(2.55 * percent);
const R = Math.min(255, (num >> 16) + amt);
const G = Math.min(255, ((num >> 8) & 0x00FF) + amt);
const B = Math.min(255, (num & 0x0000FF) + amt);
return `rgb(${R},${G},${B})`;
}
}
// 使用
const canvas = document.getElementById('progress-canvas');
canvas.width = window.innerWidth;
const bar = new CanvasProgressBar(canvas, { color: '#667eea', height: 3 });
// 动画演示
let p = 0;
const tick = () => {
p += 0.5;
bar.setProgress(p);
if (p < 100) requestAnimationFrame(tick);
};
tick();
优点: 高性能、适合复杂粒子/波纹效果
缺点: 需要手动处理 DPI 缩放、相对 DOM 不够声明式
八、进阶技巧与避坑指南
8.1 进度条的"欺骗艺术"
真相: 大多数网站的进度条都是"假的"。真实进度很难精确获取,所以优秀的进度条都在"演戏"。
shell
理想曲线:
0% ──────────────── 100%(匀速,但实际做不到)
实际策略(欺骗曲线):
0% ──快速── 30% ──中速── 60% ──慢速── 85% ──卡住── 100%
↑ ↑ ↑ ↑
给用户希望 保持动力 接近完成的期待 最终冲刺
核心原则:
- 永远不要回退 --- 进度只能增加,减少会让用户觉得出问题了
- 前期快、后期慢 --- 先给用户信心,后面留余地
- 卡在 90%-99% 是正常的 --- 最后阶段往往是最耗时的(渲染 DOM、执行 JS)
- 设置超时兜底 --- 如果真的卡住了,强制到 100%
8.2 常见坑
| 坑 | 现象 | 解决方案 |
|---|---|---|
| 进度条闪烁 | 快速路由切换时反复出现消失 | 设置最小显示时间(如 300ms) |
| 进度到 99% 就不动了 | 忘记调用 finish() | 在 finally/afterEach 中确保调用 |
| 多个请求导致进度条抖动 | 每个请求都独立控制进度 | 用计数器统一管理(pendingRequests) |
| 移动端掉帧 | CSS transition 太频繁 | 用 transform + will-change 触发 GPU 加速 |
| 颜色看不清 | 进度条颜色和页面背景太接近 | 加阴影或用渐变色增强辨识度 |
8.3 性能优化要点
css
/* ✅ 推荐:GPU 加速的进度条 */
.gpu-bar {
will-change: width, opacity; /* 提示浏览器优化 */
transform: translateZ(0); /* 触发硬件加速 */
backface-visibility: hidden; /* 减少重绘 */
}
/* ❌ 避免:频繁触发布局重排 */
.bad-bar {
/* 不要在循环中读取 offsetWidth/height 再写入 style */
}
js
// ✅ 正确:批量更新 DOM
requestAnimationFrame(() => {
bar.style.width = newPercent + '%';
});
// ❌ 错误:同步循环更新
for (let i = 0; i <= 100; i++) {
bar.style.width = i + '%'; // 浪费大量计算,中间状态根本看不到
}
九、总结与选型建议
9.1 场景 × 方案对照表
| 场景 | 推荐方案 | 关键技术 |
|---|---|---|
| 传统多页应用 | Performance API + DOM 进度条 | performance.getEntriesByType('navigation') |
| React SPA 路由切换 | useLocation + 自定义组件 |
useEffect 监听 pathname 变化 |
| Vue SPA 路由切换 | Router 导航守卫 + NProgress | beforeEach / afterEach |
| 大文件上传/下载 | XHR onprogress 或 Fetch Stream |
ReadableStream + onUploadProgress |
| 多个并发 API 请求 | Axios 拦截器 + 计数器 | interceptors + pendingRequests |
| 需要炫酷效果 | Canvas / SVG | 渐变、圆环、粒子特效 |
| 不想引入依赖 | 纯原生实现(本文所有代码) | 零依赖,复制即用 |
9.2 开源库推荐
| 库 | 特点 | 安装 |
|---|---|---|
| NProgress | jQuery 时代的老牌方案,极简 | npm install nprogress |
| react-top-loading-bar | React 专用,轻量 | npm install react-top-loading-bar |
| @tanstack/vue-query | Vue 数据请求自带 loading 状态 | npm install @tanstack/vue-query |
| angular-progressbar | Angular 生态 | npm install angular-progressbar |
9.3 一句话总结
好的进度条不是精确的报告器,而是耐心的安抚师。 用户不需要知道确切的百分比,他们只需要知道------"系统还在工作,稍等就好"。
如果你觉得这篇文章有帮助,欢迎点赞收藏!有问题欢迎评论区讨论 🎉