本文深入探讨 JavaScript 中 Generator 函数的实际应用,超越基础的 async/await。通过自定义执行器,展示如何利用 yield 关键字实现数据批处理以优化性能、支持请求取消以及提供暂停/恢复渲染的功能。文章提供了具体的代码示例,解析如何在不修改业务逻辑的前提下,通过 Generator 控制异步执行流程,解决复杂场景下的性能与交互问题。
Generator 概述与现状
Generator 是一种功能强大的语法特性,但在实际开发中使用频率并不高(参考相关调查数据)。其普及度低于 async/await 的主要原因在于使用复杂度较高,且调试体验往往不如预期,很多时候甚至需要回到传统的回调模式。尽管可以通过封装获得类似 async/await 的简洁体验,但开发者通常仍倾向于选择更直观的 async/await。
然而,Generator 的核心优势在于允许开发者通过 yield 关键字自定义代码遍历逻辑。这是一种极具威力的语法,因为它允许我们直接操控代码的执行流程。从基础的同步操作开始,我们可以逐步探索更高级的功能,例如取消操作等。
文中涉及的功能示例代码已开源至 github.com/Bloomca/obs…,供读者参考。
批处理优化
批处理 (或计划)
执行 Generator 函数会返回一个遍历器对象,这意味着我们可以通过该对象同步地遍历执行过程。这样做的一个典型应用场景是实现批处理。假设我们需要下载 1000 个项目并在表格中逐行显示(此处假设不使用现代框架)。虽然立即展示所有数据看似无害,但在性能受限的设备(如普通电脑或手机)上,这可能导致界面卡顿。因此,我们需要通过某种机制来延迟执行,以优化用户体验。
需要注意的是,此示例旨在展示性能优化技巧。在遇到实际性能瓶颈之前,不建议过度设计,因为过早优化往往是问题的根源。
// 最初的同步实现版本
function renderItems(items) {
for (item of items) {
renderItem(item);
}
}
// 函数将由我们的执行器遍历执行
// 实际上,我们可以用相同的同步方式来执行它!
function* renderItems(items) {
// 我使用 for..of 遍历方法来避免新函数的产生
for (item of items) {
yield renderItem(item);
}
}
代码看似没有本质区别,但关键差异在于:现在我们可以不修改源代码的情况下,以不同的方式运行该函数。正如前文所述,我们无需等待,而是可以同步执行。例如,在每个 yield 后添加 4 毫秒(JavaScript VM 的一个心跳周期)的延迟。如果有 1000 个项目,渲染总耗时约为 4 秒。若希望将渲染时间缩短至 2 秒,直观的想法是每次渲染 2 个项目。如果使用 Promise 方案,实现将变得复杂,需要额外传递每次渲染的项目数量参数。而通过 Generator 执行器,虽然仍需传递该参数,但 renderItems 方法本身完全不受影响。
function runWithBatch(chunk, fn, ...args) {
const gen = fn(...args);
let num = 0;
return new Promise((resolve, promiseReject) => {
callNextStep();
function callNextStep(res) {
let result;
try {
result = gen.next(res);
} catch (e) {
return reject(e);
}
next(result);
}
function next({ done, value }) {
if (done) {
return resolve(value);
}
// every chunk we sleep for a tick
if (num++ % chunk === 0) {
return sleep(4).then(proceed);
} else {
return proceed();
}
function proceed() {
return callNextStep(value);
}
}
});
}
// 第一个参数 —— 每批处理多少个项目
const items = [...];
batchRunner(2, function*() {
for (item of items) {
yield renderItem(item);
}
});
由此可见,我们可以轻松调整每批处理的项目数量,甚至切换回正常的同步执行方式,所有这些操作均不影响 renderItems 方法的定义。
请求取消机制
取消
接下来考虑传统功能——请求取消。在之前的文章《如何取消你的 Promise?》中已详细讨论过此话题,此处将引用部分代码进行说明。
function runWithCancel(fn, ...args) {
const gen = fn(...args);
let cancelled, cancel;
const promise = new Promise((resolve, promiseReject) => {
// define cancel function to return it from our fn
// 定义 cancel 方法,并返回它
cancel = () => {
cancelled = true;
reject({ reason: 'cancelled' });
};
onFulfilled();
function onFulfilled(res) {
if (!cancelled) {
let result;
try {
result = gen.next(res);
} catch (e) {
return reject(e);
}
next(result);
return null;
}
}
function onRejected(err) {
var result;
try {
result = gen.throw(err);
} catch (e) {
return reject(e);
}
next(result);
}
function next({ done, value }) {
if (done) {
return resolve(value);
}
// 假设我们总是接收 Promise,所以不需要检查类型
return value.then(onFulfilled, onRejected);
}
});
return { promise, cancel };
}
此方案的最大优势在于,我们可以取消所有尚未执行的请求(甚至可以向执行器传递类似 AbortController 的对象参数,从而取消当前正在进行的请求)。关键在于,我们无需修改业务逻辑中的任何一行代码即可实现这一功能。
暂停与恢复功能
暂停/恢复
另一个特殊需求是暂停与恢复功能。应用场景例如:渲染 1000 行数据速度较慢,我们希望为用户提供暂停/恢复渲染的功能,以便用户可以停止后台工作,专心阅读已下载的内容。
// 实现渲染的方法还是一样的
function* renderItems() {
for (item of items) {
yield renderItem(item);
}
}
function runWithPause(genFn, ...args) {
let pausePromiseResolve = null;
let pausePromise;
const gen = genFn(...args);
const promise = new Promise((resolve, reject) => {
onFulfilledWithPromise();
function onFulfilledWithPromise(res) {
if (pausePromise) {
pausePromise.then(() => onFulfilled(res));
} else {
onFulfilled(res);
}
}
function onFulfilled(res) {
let result;
try {
result = gen.next(res);
} catch (e) {
return reject(e);
}
next(result);
return null;
}
function onRejected(err) {
var result;
try {
result = gen.throw(err);
} catch (e) {
return reject(e);
}
next(result);
}
function next({ done, value }) {
if (done) {
return resolve(value);
}
// 假设我们总是接收 Promise,所以不需要检查类型
return value.then(onFulfilledWithPromise, onRejected);
}
});
return {
pause: () => {
pausePromise = new Promise(resolve => {
pausePromiseResolve = resolve;
});
},
resume: () => {
pausePromiseResolve();
pausePromise = null;
},
promise
};
}
调用该执行器后,我们将获得一个具有暂停/恢复功能的对象。这一切均基于我们之前的业务代码实现。因此,如果存在大量“沉重”的请求链且耗时较长,而你又希望为用户提供暂停/恢复功能,只需在代码中实现相应的执行器即可。
错误处理与重试策略
错误处理
这里涉及一个神秘的 onRejected 调用,这是本部分讨论的重点。如果使用常规的 async/await 或 Promise 链式写法,错误处理通常依赖 try/catch 语句,这往往需要添加大量逻辑代码。通常情况下,若需以特定方式(如重试)处理错误,我们只能在 Promise 内部进行处理,这可能导致回调自己,甚至回到同一点。这并非通用解决方案——遗憾的是,Generator 在此处也有限制:虽然我们可以控制执行流程,但无法移动 Generator 函数的主体,即无法后退一步重新执行命令。一个可行的解决方案是使用命令模式(command pattern),即 yield 的结果数据结构应包含执行该命令所需的所有信息,以便我们能够重新执行它。因此,我们的方法需要调整为:
function* renderItems() {
for (item of items) {
// 我们需要将所有东西传递出去:
// 方法, 内容, 参数
yield [renderItem, null, item];
}
}
正如所见,这种写法可能使代码意图变得晦涩。因此,或许更好的做法是编写一个 wrapWithRetry 方法,该方法检查 catch 代码块中的错误类型并尝试重新执行。但我们仍可以做些不影响功能的事情,例如增加忽略错误的策略。在 async/await 中,我们不得不使用 try/catch 包装每个调用,或者添加空的 .catch(() => {}) 部分。而使用 Generator,我们可以编写一个忽略所有错误的执行器。
function runWithIgnore(fn, ...args) {
const gen = fn(...args);
return new Promise((resolve, promiseReject) => {
onFulfilled();
function onFulfilled(res) {
proceed({ data: res });
}
// 这些是 yield 返回的错误
// 我们想忽略它们
// 所以我们像往常一样做,但不去传递出错误
function onRejected(error) {
proceed({ error });
}
function proceed(data) {
let result;
try {
result = gen.next(data);
} catch (e) {
// 这些错误是同步错误(比如 TypeError 等)
return reject(e);
}
// 为了区分错误和正常的结果
// 我们用它来执行
next(result);
}
function next({ done, value }) {
if (done) {
return resolve(value);
}
// 假设我们总是接收 Promise,所以不需要检查类型
return value.then(onFulfilled, onRejected);
}
});
}
Generator 与 async/await 的对比
关于 async/await
Async/await 是目前的首选语法(甚至 co 库也提及了它),也是未来的方向。然而,Generator 仍包含在 ECMAScript 标准中,这意味着使用它们无需额外依赖,只需编写几个工具函数即可。本文试图展示一些较为复杂的示例,其价值取决于具体应用场景。请记住,熟悉 Generator 的开发者并不多,如果整个代码库中仅有一处使用它们,使用 Promise 可能更为简单。但另一方面,通过 Generator,某些问题可以被优雅且简洁地解决。
总结
以上介绍了在 JavaScript 中使用 Generator 的高级方法,希望对大家有所帮助。如有疑问,欢迎留言,小编会及时回复。感谢大家对本站的支持!