使用Generator实现动态进度条
1. 核心实现思路
本文将通过以下三个步骤,实现一个功能完整的动态进度条系统:
- 使用Generator模拟进度数据流,实现数据的渐进式生成
- 基于HTML5原生进度条,实现基础的进度展示组件
- 扩展实现多文件并发下载的进度管理
2. Generator进度模拟器
首先,我们使用Generator函数来模拟文件下载的进度增长。通过控制每次增长的幅度在0-5%之间,并确保最终值不超过100%,可以实现真实的下载进度效果:
function* progressGenerator() {
let progress = 0;
while (progress < 100) {
// 通过随机增量模拟真实下载进度
progress = Math.min(+(progress + Math.random() * 5).toFixed(2), 100);
yield progress;
}
}接下来,实现进度更新的核心逻辑。通过异步函数配合Generator的迭代特性,我们可以实现进度的平滑更新:
const handleDownload = async () => {
let result: IteratorResult<number, void>;
while ((result = generator.next()) && !result.done) {
setProgress(result.value);
// 随机延时模拟网络波动
await delay(Math.floor(Math.random() * 100));
};
}3. 基础进度条实现
在实现具体的进度条组件之前,我们先来了解HTML5提供的原生进度条标签:
<progress value="70" max="100"></progress>效果预览:
基于原生进度条,我们实现了一个简单的单文件下载进度展示:
4. 多文件下载进度
为了支持多文件同时下载,我们需要对之前的实现进行改造。主要思路是将单文件下载的进度更新逻辑封装到独立的ProgressBar组件中,并通过React的useEffect钩子自动触发进度更新。
列表管理代码
function App() {
// 维护下载文件列表
const [list, setList] = useState<Array<string>>([])
return <div>
<button
onClick={() => { setList(v => [`文件 ${v.length}`, ...v]) }}
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
download
</button>
<div className="flex flex-col">
{
list.map((name) => {
return <DownloadProgressBar name={name} key={name} />
})
}
</div>
</div>
}进度条组件代码
function DownloadProgressBar({ name }: { name: string }) {
const [progress, setProgress] = useState(0);
// 为每个下载任务创建独立的进度生成器
const generator = useRef(progressGenerator()).current;
const wookloop = useCallback(async function wookloop() {
let result: IteratorResult<number, void>;
while ((result = generator.next()) && !result.done) {
setProgress(result.value);
await delay(Math.floor(Math.random() * 100));
};
}, [])
useEffect(() => {
wookloop();
}, [])
return (
<div className="flex items-center gap-4 p-4 border rounded-lg shadow-sm">
<span className="text-gray-700 font-medium min-w-[100px]">{name}</span>
<div className="flex-1">
<progress
className="w-full h-2 rounded-full"
max="100"
value={progress}
>
{progress}%
</progress>
<div className="text-sm text-gray-500 mt-1">{progress.toFixed(1)}%</div>
</div>
</div>
)
}进度条样式代码
/* 样式来自 https://www.codewithshripal.com/playground/html/progress-element */
.add-download-progress {
border-radius: 8px;
overflow: hidden;
height: 12px;
appearance: none;
/* 清除默认样式 */
background-color: lightgrey;
/* Firefox背景色 */
}
/* Firefox进度条样式 */
.add-download-progress::-moz-progress-bar {
background-color: deeppink;
}
/* Webkit/Blink浏览器进度条背景 */
.add-download-progress::-webkit-progress-bar {
background-color: lightgrey;
}
/* Webkit/Blink浏览器进度条前景 */
.add-download-progress::-webkit-progress-value {
background-color: deeppink;
}
/* 条纹效果 */
.add-download-progress.with-stripes::-webkit-progress-value {
background-image: repeating-linear-gradient(45deg,
deeppink 0,
deeppink 10px,
lightpink 10px,
lightpink 20px);
}
progress.with-stripes::-moz-progress-bar {
background-image: repeating-linear-gradient(45deg,
deeppink 0,
deeppink 10px,
lightpink 10px,
lightpink 20px);
}完整效果预览
点击下载按钮添加新的下载任务:
基于事件的下载进度管理
实现轻量级事件系统
参考mitt库的设计思路,我们实现了一个简洁的事件管理系统:
createEmit实现
export function createEmit<T extends Record<string, any>>(obj: T = {} as T) {
// 使用Map存储事件监听器
const event = new Map<keyof T, Set<T[keyof T][0]>>(
Object.entries(obj).map(([type, fns]) => [type, new Set(fns)])
);
return {
// 注册事件监听器
on: (type: keyof T, fn: T[keyof T][0]) => {
if (!event.has(type)) {
event.set(type, new Set());
}
event.get(type)!.add(fn);
},
// 触发事件
emit: (type: keyof T, ...arg: Parameters<T[keyof T][0]>) => {
const set = event.get(type);
if (set) {
set.forEach(fn => fn(...arg));
}
},
// 移除事件监听器
off: (type: keyof T, fn: T[keyof T][0]) => {
const set = event.get(type);
if (set) {
set.delete(fn);
if (set.size === 0) {
event.delete(type);
}
}
}
};
}定义下载事件接口
创建Native和React Native之间的通信桥接:
export const eventBus = createEmit({
"add": [] as Array<(item: DownloadItem) => void>,
"delete": [] as Array<(id: string) => void>,
"modify": [] as Array<(id: string, progress: number, downloaded: boolean) => void>,
})模拟下载数据
为了演示效果,我们准备了三组测试数据:
正在下载列表
const downloadingList: DownloadItem[] = [
{ id: "4", name: "三国演义", progress: 0, downloaded: false },
{ id: "5", name: "论语", progress: 0, downloaded: false },
{ id: "6", name: "道德经", progress: 0, downloaded: false },
{ id: "7", name: "孙子兵法", progress: 0, downloaded: false },
{ id: "8", name: "史记", progress: 0, downloaded: false },
{ id: "9", name: "资治通鉴", progress: 0, downloaded: false },
{ id: "10", name: "楚辞", progress: 0, downloaded: false },
];已下载列表
const downloadedList: DownloadItem[] = [
{ id: "1", name: "红楼梦", progress: 100, downloaded: true },
{ id: "2", name: "西游记", progress: 100, downloaded: true },
{ id: "3", name: "水浒传", progress: 100, downloaded: true },
];待下载列表
const pendingDownloadingList: DownloadItem[] = [
{ id: "11", name: "诗经", progress: 0, downloaded: false },
{ id: "12", name: "汉书", progress: 0, downloaded: false },
{ id: "13", name: "后汉书", progress: 0, downloaded: false },
{ id: "14", name: "三字经", progress: 0, downloaded: false },
{ id: "15", name: "百家姓", progress: 0, downloaded: false },
{ id: "16", name: "千字文", progress: 0, downloaded: false },
{ id: "17", name: "大学", progress: 0, downloaded: false },
{ id: "18", name: "中庸", progress: 0, downloaded: false },
{ id: "19", name: "孟子", progress: 0, downloaded: false },
{ id: "20", name: "庄子", progress: 0, downloaded: false },
{ id: "21", name: "列子", progress: 0, downloaded: false },
{ id: "22", name: "荀子", progress: 0, downloaded: false },
{ id: "23", name: "韩非子", progress: 0, downloaded: false },
{ id: "24", name: "战国策", progress: 0, downloaded: false },
{ id: "25", name: "左传", progress: 0, downloaded: false },
{ id: "26", name: "国语", progress: 0, downloaded: false },
{ id: "27", name: "山海经", progress: 0, downloaded: false },
{ id: "28", name: "黄帝内经", progress: 0, downloaded: false },
{ id: "29", name: "本草纲目", progress: 0, downloaded: false },
{ id: "30", name: "周易", progress: 0, downloaded: false },
];下载任务管理
初始化下载数据
getDownloadData() {
// 延迟2秒后启动下载循环
setTimeout(() => {
workLoop();
}, 2_000)
return Promise.resolve({
downloading: deepClone(downloadingList),
downloaded: deepClone(downloadedList)
})
}下载进度更新循环
workLoop实现
async function modifyDownloadingItem(item: DownloadItem) {
const generator = progressGenerator();
let result: IteratorResult<number, void>;
const id = item.id;
// 循环更新下载进度
while ((result = generator.next()) && !result.done) {
item.progress = result.value;
const downloaded = item.progress === 100;
// 发送进度更新事件
eventBus.emit("modify", id, item.progress, false);
// 下载完成后的处理
if (downloaded) {
downloadingList.splice(
downloadingList.findIndex(item => item.id === id),
1
);
downloadedList.push({ ...item });
eventBus.emit("modify", id, 100, true);
}
await delay(Math.floor(Math.random() * 100));
}
// 继续处理下一个下载任务
workLoop();
}
// 采用while循环处理下载队列,避免forEach可能带来的重复执行问题
function workLoop() {
let item;
while (item = downloadingList.shift()) {
modifyDownloadingItem(item);
}
}添加下载任务
addDownload() {
const item = pendingDownloadingList.shift();
if (item) {
// 如果当前没有正在下载的任务,需要重新启动下载循环
if (downloadingList.length === 0) {
queueMicrotask(() => {
workLoop();
})
}
downloadingList.push(item!);
eventBus.emit("add", deepClone(item));
}
}deleteDownload
deleteDownload(id: string) {
downloadingList.splice(downloadingList.findIndex(item => item.id === id), 1);
downloadedList.splice(downloadedList.findIndex(item => item.id === id), 1);
eventBus.emit("delete", id);
}createNativeModule完整代码
function deepClone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj))
}
export function createNativeModule() {
const downloadingList: DownloadItem[] = [
{ id: "4", name: "三国演义", progress: 0, downloaded: false },
{ id: "5", name: "论语", progress: 0, downloaded: false },
{ id: "6", name: "道德经", progress: 0, downloaded: false },
{ id: "7", name: "孙子兵法", progress: 0, downloaded: false },
{ id: "8", name: "史记", progress: 0, downloaded: false },
{ id: "9", name: "资治通鉴", progress: 0, downloaded: false },
{ id: "10", name: "楚辞", progress: 0, downloaded: false },
];
const downloadedList: DownloadItem[] = [
{ id: "1", name: "红楼梦", progress: 100, downloaded: true },
{ id: "2", name: "西游记", progress: 100, downloaded: true },
{ id: "3", name: "水浒传", progress: 100, downloaded: true },
];
const pendingDownloadingList: DownloadItem[] = [
{ id: "11", name: "诗经", progress: 0, downloaded: false },
{ id: "12", name: "汉书", progress: 0, downloaded: false },
{ id: "13", name: "后汉书", progress: 0, downloaded: false },
{ id: "14", name: "三字经", progress: 0, downloaded: false },
{ id: "15", name: "百家姓", progress: 0, downloaded: false },
{ id: "16", name: "千字文", progress: 0, downloaded: false },
{ id: "17", name: "大学", progress: 0, downloaded: false },
{ id: "18", name: "中庸", progress: 0, downloaded: false },
{ id: "19", name: "孟子", progress: 0, downloaded: false },
{ id: "20", name: "庄子", progress: 0, downloaded: false },
{ id: "21", name: "列子", progress: 0, downloaded: false },
{ id: "22", name: "荀子", progress: 0, downloaded: false },
{ id: "23", name: "韩非子", progress: 0, downloaded: false },
{ id: "24", name: "战国策", progress: 0, downloaded: false },
{ id: "25", name: "左传", progress: 0, downloaded: false },
{ id: "26", name: "国语", progress: 0, downloaded: false },
{ id: "27", name: "山海经", progress: 0, downloaded: false },
{ id: "28", name: "黄帝内经", progress: 0, downloaded: false },
{ id: "29", name: "本草纲目", progress: 0, downloaded: false },
{ id: "30", name: "周易", progress: 0, downloaded: false },
]
async function modifyDownloadingItem(item: DownloadItem) {
const generator = progressGenerator();
let result: IteratorResult<number, void>;
const id = item.id;
while ((result = generator.next()) && !result.done) {
item.progress = result.value;
const downloaded = item.progress === 100;
eventBus.emit("modify", id, item.progress, false);
if (downloaded) {
downloadingList.splice(downloadingList.findIndex(item => item.id === id), 1);
downloadedList.push({ ...item });
eventBus.emit("modify", id, 100, true);
}
await delay(Math.floor(Math.random() * 100));
}
workLoop();
}
function workLoop() {
let item;
while (item = downloadingList.shift()) {
modifyDownloadingItem(item);
}
}
return {
getDownloadData() {
setTimeout(() => {
workLoop();
}, 2_000)
return Promise.resolve({ downloading: deepClone(downloadingList), downloaded: deepClone(downloadedList) })
},
addDownload() {
const item = pendingDownloadingList.shift();
if (item) {
if (downloadingList.length === 0) {
queueMicrotask(() => {
workLoop();
})
}
downloadingList.push(item!);
eventBus.emit("add", deepClone(item));
}
},
deleteDownload(id: string) {
downloadingList.splice(downloadingList.findIndex(item => item.id === id), 1);
downloadedList.splice(downloadedList.findIndex(item => item.id === id), 1);
eventBus.emit("delete", id);
},
}
}RN这边的监听
useDownload 内部状态没有拆分两个, 考虑到两个downloading会转化为downloaded的场景, 在effect内监听会导致event频繁的变化
const useDownload = () => {
const data = use(useMemo(() => nativeModule.getDownloadData(), []));
const [downloadData, setDownloadData] = useState(data);
useEffect(() => {
eventBus.on("add", (item) => {
setDownloadData(({ downloaded, downloading }) => {
return {
downloaded,
downloading: [...downloading, item]
}
})
})
eventBus.on("delete", (id) => {
setDownloadData(({ downloaded, downloading }) => {
return {
downloaded: downloaded.filter(item => item.id !== id),
downloading: downloading.filter(item => item.id !== id)
}
})
})
eventBus.on("modify", (id, progress, isDownloaded) => {
setDownloadData(({ downloaded, downloading }) => {
const item = downloading.find(i => i.id === id)
if (isDownloaded) {
if (item) {
return {
downloaded: [{
...item,
progress: 100,
downloaded: true
}, ...downloaded],
downloading: downloading.filter(item => item.id !== id)
}
} else {
return { downloaded, downloading }
}
} else {
return {
downloaded,
downloading: downloading.map(item => item.id === id ? { ...item, progress } : item)
}
}
})
})
}, [])
const addDownload = () => nativeModule.addDownload();
return { downloading: downloadData.downloading, downloaded: downloadData.downloaded, addDownload }
}deleteDownload
deleteDownload(id: string) {
downloadingList.splice(downloadingList.findIndex(item => item.id === id), 1);
downloadedList.splice(downloadedList.findIndex(item => item.id === id), 1);
eventBus.emit("delete", id);
}从上述demo就能看到有不小的瑕疵了, 虽然基础进度条实现表现的很好 主要问题在列表从downloading转化为downloaded的时候, 发现进度条还没到100%就消失了,主要的问题在于动画有个时间差(这里是500ms),导致动画的时间和进度的时间不一致,导致进度条消失了。 针对这个思路, 设置了500ms的节流函数, 好处有2
- 解决了上述的download列表转换导致的进度条没到100%消失的问题
- 在delay期间接受到后续的更新, 在下一轮统一批处理 ,减少了不必要的渲染, 提升了性能
节流改造
流程大概两种 add -> modify[0] -> modify(0-100) -> … -> modify[100] add -> modify[0] -> modify(0-100) -> … delete … -/> modify[100]
动画的高频部分是实在modify的过程中, 所以这里只需要对modify做节流处理就可以了, 另外就是在delete时, 将对应的modify任务清除掉
收到更新, 根据id更新map, 启动进度条动画相关的任务
workLoop(id)id已在执行列表中 -> END id不再执行列表中
检查在map中是否有id对应的更新数据
没有 -> END 有
将id添加到执行列表中
循环执行更新任务
performWorkUnit(id)将id从执行列表删除 -> END
workLoop
async function workLoop(id: string) {
if (taskIds.has(id)) {
return;
}
const task = taskDataMap.get(id);
if (!task) {
return
}
taskIds.add(id)
while (await performWorkUnit(id)) {
}
taskIds.delete(id);
}performWorkUnit(id)
map中没有id对应的数据 -> return false -> 退出
workLoop(id)更新进度条 -> 500 延迟 -> 下载未完成 -> return true -> 下一轮
performWorkUnit(id)下载完成 -> 将downloadingList中对应的数据移动到downloadedList中 -> return false -> 退出
workLoop(id)
performWorkUnit
async function performWorkUnit(id: string) {
const target = taskDataMap.get(id);
if (!target) {
return false;
}
setDownloadData(({ downloaded, downloading }) => {
// 更新进度条
});
await delay(500)
if (!target.downloaded) {
return true
}
taskDataMap.delete(id);
setDownloadData(({ downloaded, downloading }) => {
// 移动数据
})
return false;
}完整代码
const nativeModule = createNativeModule();
const taskIds = new Set<string>();
const taskDataMap = new Map<
string, Pick<DownloadItem, 'downloaded' | 'id' | 'progress'>
>();
export const useDownloadDelay = () => {
const data = use(useMemo(() => nativeModule.getDownloadData(), []));
const [downloadData, setDownloadData] = useState(data);
async function performWorkUnit(id: string) {
const target = taskDataMap.get(id);
if (!target) {
return false;
}
setDownloadData(({ downloaded, downloading }) => {
return {
downloaded,
downloading: downloading.map(item =>
item.id === target.id ? { ...item, progress: target.progress } : item,
),
};
});
await delay(500)
if (!target.downloaded) {
return true
}
taskDataMap.delete(id);
setDownloadData(({ downloaded, downloading }) => {
const item = downloading.find(i => i.id === target.id);
if (item) {
return {
downloaded: [
{
...item,
progress: 100,
downloaded: true,
},
...downloaded,
],
downloading: downloading.filter(item => item.id !== target.id),
};
} else {
return { downloaded, downloading };
}
})
return false;
}
async function workLoop(id: string) {
if (taskIds.has(id)) {
return;
}
const task = taskDataMap.get(id);
if (!task) {
return
}
taskIds.add(id)
while (await performWorkUnit(id)) {
}
taskIds.delete(id);
}
useEffect(() => {
eventBus.on('add', (item: DownloadItem) => {
(async () => {
setDownloadData(({ downloaded, downloading }) => {
return {
downloaded,
downloading: [...downloading, item],
};
});
return false;
})()
// }
});
eventBus.on('delete', (id: string) => {
taskDataMap.delete(id);
setDownloadData(({ downloaded, downloading }) => {
return {
downloaded: downloaded.filter(item => item.id !== id),
downloading: downloading.filter(item => item.id !== id),
};
});
});
eventBus.on('modify', (id, progress, isDownloaded) => {
taskDataMap.set(id, { id, progress, downloaded: isDownloaded });
workLoop(id);
});
}, []);
const addDownload = () => nativeModule.addDownload();
return {
downloading: downloadData.downloading,
downloaded: downloadData.downloaded,
addDownload,
};
};