使用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, }; };