3192 words
16 minutes
实现动态进度条
2025-03-02 15:29:16
2025-12-24 23:45:46

使用Generator实现动态进度条#

1. 核心实现思路#

本文将通过以下三个步骤,实现一个功能完整的动态进度条系统:

  1. 使用Generator模拟进度数据流,实现数据的渐进式生成
  2. 基于HTML5原生进度条,实现基础的进度展示组件
  3. 扩展实现多文件并发下载的进度管理

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

  1. 解决了上述的download列表转换导致的进度条没到100%消失的问题
  2. 在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,
  };
};
实现动态进度条
https://0bipinnata0.my/posts/html/进度条/
Author
0bipinnata0
Published at
2025-03-02 15:29:16