原文链接:macarthur.me/posts/queue…
生成器执行完毕后便无法 “复活”,但借助 Promise,我们能打造出一个可续充的版本。接下来就动手试试吧。
自从深入研究并分享过生成器的相关内容后,JavaScript 生成器就成了我的 “万能工具”—— 只要有机会,我总会想方设法用上它。通常我会用它来分批处理有限的数据集,比如,遍历一系列闰年并执行相关操作:
function* generateYears(start = 1900) {
const currentYear = new Date().getFullYear();
for (let year = start + 1; year <= currentYear; year++) {
if (isLeapYear(year)) {
yield year;
}
}
}
for (const year of generateYears()) {
console.log('下一个闰年是:', year);
}
又或者惰性处理一批文件:
const csvFiles = ["file1.csv", "file2.csv", "file3.csv"];
function *processFiles(files) {
for (const file of files) {
// 加载并处理文件
yield `处理结果:${file}`;
}
}
for(const result of processFiles(csvFiles)) {
console.log(result);
}
这两个示例中,数据都会被一次性遍历完毕,且无法再补充新数据。for 循环执行结束后,迭代器返回的最后一个结果中会包含done: true,一切就此终止。
这种行为本就符合生成器的设计初衷 —— 它从一开始就不是为了执行完毕后能 “复活” 而设计的,其执行过程是一条单行道。但我至少有一次迫切希望它能支持续充,就在最近为 PicPerf 开发文件上传工具时。我当时铁了心要让生成器来实现一个可续充的先进先出(FIFO)队列,一番摸索后,最终的实现效果让我很满意。
可续充队列的设计思路
先明确一下,我所说的 “可续充” 具体是什么意思。生成器无法重启,但我们可以在队列数据耗尽时让它保持等待状态,而非直接终止,Promise 恰好能完美实现这个需求!
我们先从一个基础示例开始:实现一个队列,每隔 500 毫秒逐个处理队列中的圆点元素。
<html>
<ul id="queue">
<li class="item"></li>
<li class="item"></li>
<li class="item"></li>
</ul>
已处理总数:<span id="totalProcessed">0</span>
</html>
<script>
async function* go() {
// 初始化队列,包含页面中的初始元素
const queue = Array.from(document.querySelectorAll("#queue .item"));
for (const item of queue) {
yield item;
}
}
// 遍历队列,逐个处理并移除元素
for await (const value of go()) {
await new Promise((res) => setTimeout(res, 500));
value.remove();
totalProcessed.textContent = Number(totalProcessed.textContent) + 1;
}
</script>
这就是一个典型的 “单行道” 队列:
如果我们加一个按钮,用于向队列添加新元素,若在生成器执行完毕后点击按钮,页面不会有任何反应 —— 因为生成器已经 “失效” 了。所以,我们需要对代码做一些重构。
实现队列的可续充功能
首先,我们用while(true)让循环无限执行,不再依赖队列初始的固定数据。
async function* go() {
const queue = Array.from(document.querySelectorAll("#queue .item"));
while (true) {
if (!queue.length) {
return;
}
yield queue.shift();
}
}
现在只剩一个问题:代码中的return语句会让生成器在队列为空时直接终止。我们将其替换为一个 Promise,让循环在无数据可处理时暂停,直到有新数据加入:
let resolve = () => {};
const queue = Array.from(document.querySelectorAll('#queue .item'));
const queueElement = document.querySelector('#queue');
const addToQueueButton = document.querySelector('#addToQueueButton');
async function* go() {
while (true) {
// 创建Promise,并为本次生成器迭代绑定resolve方法
const promise = new Promise((res) => (resolve = res));
// 队列为空时,等待Promise解析
if (!queue.length) await promise;
yield queue.shift();
}
}
addToQueueButton.addEventListener("click", () => {
const newElement = document.createElement("li");
newElement.classList.add("item");
queueElement.appendChild(newElement);
// 添加新元素,唤醒队列
queue.push(newElement);
resolve();
});
// 后续处理代码不变
for await (const value of go()) {
await new Promise((res) => setTimeout(res, 500));
value.remove();
totalProcessed.textContent = Number(totalProcessed.textContent) + 1;
}
这次的实现中,生成器的每次迭代都会创建一个新的 Promise。当队列为空时,代码会await这个 Promise 解析,而解析的时机就是我们点击按钮、向队列添加新元素的时刻。
最后,我们对代码做一层封装,打造一个更优雅的 API:
function buildQueue<T>(queue: T[] = []) {
let resolve: VoidFunction = () => {};
async function* go() {
while (true) {
const promise = new Promise((res) => (resolve = res));
if (!queue.length) await promise;
yield queue.shift();
}
}
function push(items: T[]) {
queue.push(...items);
resolve();
}
return {
go,
push,
};
}
这里补充一个小技巧:你并非一定要将队列中的元素逐个移除。如果希望保留所有元素,只需通过一个索引指针来遍历队列即可:
async function* go() {
let currentIndex = 0;
while (true) {
const promise = new Promise((res) => (resolve = res));
// 索引指向的位置无数据时,等待新数据
if (!queue[currentIndex]) await promise;
yield queue[currentIndex];
currentIndex++;
}
}
大功告成!接下来,我们将这个实现落地到实际开发场景中。
在 React 中落地可续充队列
正如前文所说,PicPerf 是一个图片优化、托管和缓存平台,支持用户上传多张图片进行处理。其界面采用了一个常见的交互模式:用户拖拽图片到指定区域,图片会按顺序逐步完成上传。
这正是可续充先进先出队列的适用场景:即便 “待上传” 的图片全部处理完毕,用户依然可以拖拽新的图片进来,上传流程会自动继续,队列会直接从新添加的文件开始处理。
React 中的基础实现方案
首先,我们尝试纯 React 的实现思路,充分利用 React 的状态与渲染生命周期,核心依赖两个状态:
files: UploadedFile[]:存储所有拖拽到界面的文件,每个文件自身维护一个状态:pending(待上传)、uploading(上传中)、completed(已完成)。isUploading: boolean:标记当前是否正在上传文件,作为一个 “锁”,防止在已有上传任务执行时,启动新的上传循环。
这个组件的核心逻辑是监听files状态的变化,一旦有新文件加入,useEffect钩子就会触发上传流程;当一个文件上传完成后,将isUploading置为false,又会触发另一次useEffect执行,进而处理队列中的下一张图片。
以下是简化后的核心代码:
import { processUpload } from './wherever';
export default function MediaUpload() {
const [files, setFiles] = useState([]);
const [isUploading, setIsUploading] = useState(false);
const updateFileStatus = useEffectEvent((id, status) => {
setFiles((prev) =>
prev.map((file) => (file.id === id ? { ...file, status } : file))
);
});
useEffect(() => {
// 已有上传任务执行时,直接返回
if (isUploading) return;
// 找到队列中第一个待上传的文件
const nextPending = files.find((f) => f.status === 'pending');
// 无待上传文件时,直接返回
if (!nextPending) return;
// 加锁,标记为上传中
setIsUploading(true);
updateFileStatus(nextPending.id, 'uploading');
// 执行上传,完成后解锁并更新状态
processUpload(nextPending).then(() => {
updateFileStatus(nextPending.id, 'complete');
setIsUploading(false);
});
}, [files, isUploading]);
return <UploadComponent files={files} setFiles={setFiles} />;
}
在有文件正在上传时,用户依然可以添加新文件,新文件会被追加到队列末尾,等待后续逐个处理:
从 React 组件的设计角度来看,这种方案并非不可行,监听状态变化并做出相应响应也是很常见的实现方式。
但说实话,很难有人会觉得这个思路直观易懂。useEffect钩子的设计初衷是让组件与外部系统保持同步,而在这里,它却被用作了事件驱动的状态机调度工具,成了组件的核心行为逻辑,这显然偏离了其设计本意。
所以,我们不妨换掉这些useEffect钩子,用生成器实现的可续充队列来重构这个组件。
结合外部状态仓库实现
我们不再让 React 完全托管所有文件及其状态,而是将这些数据抽离到外部,从其他地方触发组件的重新渲染。这样一来,组件会变得更 “纯”,只专注于其核心职责 —— 渲染界面。
React 恰好提供了一个适配该场景的工具:useSyncExternalStore。这个钩子能让组件监听外部管理的数据变化,组件的 “React 特性” 会适当让步,等待外部的指令,而非全权掌控所有状态。在本次实现中,这个 “外部状态仓库” 就是一个独立的模块,专门负责文件的处理逻辑。
useSyncExternalStore至少需要两个方法:一个用于监听数据变化(让 React 知道何时需要重新渲染组件),另一个用于返回数据的最新快照。以下是仓库的基础骨架:
// store.ts
let listeners: Function[] = [];
let files: UploadableFile[] = [];
// 必须返回一个取消监听的方法(供React内部使用)
export function subscribe(listener: Function) {
listeners.push(listener);
return () => {
listeners = listeners.filter((l) => l !== listener);
};
}
// 返回数据最新快照
export function getSnapshot() {
return files;
}
接下来,我们补充实现所需的其他方法:
updateStatus():更新文件状态(待上传、上传中、已完成);add():向队列中添加新文件;process():启动并执行文件上传队列;emitChange():通知 React 的监听器数据发生变化,触发组件重新渲染。
最终,状态仓库的完整代码如下:
// store.ts
import { buildQueue, processUpload } from './whatever';
let listeners: Function[] = [];
let files: any[] = [];
// 初始化可续充队列
const queue = buildQueue();
// 通知监听器,触发组件重渲染
function emitChange() {
// 外部仓库的一个关键要点:数据变化时,必须返回新的引用
files = [...queue.queue];
for (let listener of listeners) {
listener();
}
}
// 更新文件状态
function updateStatus(file: any, status: string) {
file.status = status;
emitChange();
}
// 公共方法
export function getSnapshot() {
return files;
}
export function subscribe(listener: Function) {
listeners.push(listener);
return () => {
listeners = listeners.filter((l) => l !== listener);
};
}
// 向队列添加新文件
export function add(newFiles: any[]) {
queue.push(newFiles);
emitChange();
}
// 执行文件上传流程
export async function process() {
for await (const file of queue.go()) {
updateStatus(file, 'uploading');
await processUpload(file);
updateStatus(file, 'complete');
}
}
此时,我们的 React 组件会变得异常简洁:
import {
add,
process,
subscribe,
getSnapshot
} from './store';
export default function MediaUpload() {
// 监听外部仓库的数据变化
const files = useSyncExternalStore(subscribe, getSnapshot);
// 组件挂载时启动上传队列
useEffect(() => {
process();
}, []);
// 将文件数据和添加方法传递给子组件
return <UploadComponent files={files} setFiles={add} />;
}
现在只剩一个细节需要完善:合理的清理逻辑。当组件卸载时,我们不希望还有未完成的上传任务在后台执行。因此,我们为仓库添加一个abort方法,强制终止生成器,并在组件的useEffect中执行清理:
// store.ts
// 其他代码不变
let iterator = null;
export async function process() {
// 保存生成器迭代器的引用
iterator = queue.go();
for await (const file of iterator) {
updateStatus(file, 'uploading');
await processUpload(file);
updateStatus(file, 'complete');
}
iterator = null;
}
// 强制终止生成器
export function abort() {
return iterator?.return();
}
function MediaUpload() {
const files = useSyncExternalStore(subscribe, getSnapshot);
useEffect(() => {
process();
// 组件卸载时执行清理,终止上传队列
return () => abort();
}, []);
return <UploadComponent files={files} setFiles={add} />;
}
需要说明的是,为了简化代码,这里做了一些大胆的假设:上传过程永远不会失败、process方法同一时间只会被调用一次、该仓库只有一个使用者。请忽略这些细节以及其他可能的疏漏,重点来看这种实现方案带来的诸多优势:
- 组件的行为不再依赖
useEffect的反复触发,逻辑更清晰; - 所有文件上传的业务逻辑都被抽离到独立模块中,与 React 解耦,可单独维护和复用;
- 终于有机会实际使用
useSyncExternalStore这个 React 钩子; - 我们成功在 React 中用异步生成器实现了一个可续充队列。
对有些人来说,这种方案可能比最初的纯 React 方案复杂得多,我完全理解这种感受。但不妨换个角度想:现在把代码写得复杂一点,就能多拖延一点时间,避免 AI 工具完全取代我们的工作、毁掉我们的职业未来,甚至 “收割” 我们的价值。带着这个目标去写代码吧!
当然,说句正经的:要让 AI 辅助开发持续发挥价值,需要人类帮助 AI 理解底层技术原语的设计目的、取舍原则和发展前景。掌握这些底层知识,永远有其不可替代的价值。