使用 60fps 的 JavaScript 代码,对 100MB 的数据进行字符串化、解析、处理、压缩和过滤。
太长不看
- 我创建了 JSON 的异步版本
stringify,以及parse一大堆数组函数,包括sort一些不会阻塞主线程的函数。 - 我最近添加了对 LZ 压缩和解压缩的支持
- 我已经将这些内容整理成一个库,您可以在自己的代码中轻松使用,它适用于所有可以使用 ES6 或将其转译的框架。
- 可在IE11上运行
- 您还可以构建自己的协程,使其在您自己的复杂逻辑或处理过程中展现类似的行为。
- 你也可以使用高优先级协程来处理使用命令式语句(例如循环)的
for动画while。 - 本软件采用 MIT 许可证,详情请参阅主页。
- 下面我详细写了这一切是如何运作的,以及我是如何受到 dev.to 的启发而弄明白的。
演示
此演示展示了主线程上的多个并行协程。
慢即是顺畅,顺畅即是快
我们都知道,用户对性能的感知很大程度上取决于我们对速度的感知。因此,如果流畅的动画突然出现故障,那就非常重要。如果用户发现自己点错了按钮,他们肯定希望有机会取消或更改选择,而不是等待几秒钟才能得到他们不再想要的结果。这些都与用户体验和性能感知有关。事实上,处理速度远不如流畅度重要。只要界面流畅且响应迅速,即使我们多花 500 毫秒来完成某项操作,用户也会觉得这款应用比那些运行速度更快但卡顿得像老旧汽车一样的应用更快。
我们经常编写需要在前端进行数据处理的代码,尤其是在单页应用程序(SPA)中。如果我们需要对大量数据进行排序或处理,就很容易导致故障和严重的延迟。这些问题调试起来会非常棘手,而且在不同的硬件配置下表现也会有所不同。
螺纹
使用Worker线程可以将大量处理任务卸载到另一个线程,而不会影响主线程的性能。听起来很完美,但事实并非如此。由于 JavaScript 环境的严格沙箱机制,使用另一个线程只有在输入数据量较小(例如小数据、URL 或其他参数)且输出数据量也相对较小时才能有效工作。因为所有进出另一个线程的数据都会被序列化,这会阻塞主线程(除非你使用的是可以传输的二进制数组)。
如果你的应用程序已经能够使用多线程,那么本文对你来说用处不大。本文介绍的是一种共享主线程的技术,因此它并非着重强调多核 CPU 的优势,而是通过充分利用主线程资源,同时避免阻塞高优先级更新,从而提供流畅的用户体验。
工作原理
好的,那么让我们深入探讨一下如何在不中断主线程动画等的情况下,处理只需几秒钟即可执行的大量数据。
归根结底就是协程。
协程
你很可能已经以某种形式了解过协程。协程本质上是一条程序逻辑线程,它与其他程序同时执行,直至完成。
线程是一种协程,但通常我们会将它们区分开来。协程是主线程中的另一个逻辑处理状态机。我们经常看到它们以 Promise 执行链和async函数的形式出现await。
熟悉 Unity 游戏引擎的人都知道,它在很多情况下都使用协程来创建“发射后不管”的逻辑,从而在每个游戏帧中更新控制子弹、AI 或其他元素。我们将看到如何以非常类似的方式使用它。
我们可以随时有多个承诺等待下一步操作,当满足其准入条件时,它们将恢复执行——以阻塞方式执行,直到它们返回或等待下一步为止。
通常,这些操作需要等待另一个进程、服务器或线程上的结果。您可能(和我一样)曾经尝试过使用异步函数来拆分长时间运行的进程:
await new Promise(resolve=>setTimeout(resolve))
主循环中有一个待办事项列表,上面的代码行将该例程的恢复操作放入队列,以便在下次循环执行后继续执行。
执行这段代码后,异步函数会在主循环完成其他可用任务后恢复执行,从而给系统时间去做其他事情,例如动画或恢复另一个异步函数。
更智能的协程
以上方法只是粗略地允许其他进程运行。它比较粗糙,会浪费主循环更多的时间,下次循环就得重新开始。如果在紧密循环的 for 循环中间这样做,代码运行时间会非常长。
for(let i = 0; i < 1000; i++) {
await new Promise(resolve=>setTimeout(resolve))
}
运行完成需要 16 秒。这种方法难以使用,而且情况还在恶化:
const myRecords = JSON.parse(someMassiveString)
可能需要 1 秒钟才能运行,所以会出现故障。
如果要实现这个功能,我们需要另一种编写协程的方法:
- 进程运行一段合理的时间后,将控制权交给其他可能正在等待的进程。
- 编写起来很方便,所以我们可以不用处理复杂的设计和难以发现的错误就能完成编写。
- 可用于构建我们常用功能的简化版本,例如 JSON 函数、排序等。
使用生成器
所以我们想做一些类似的事情await,但如果动画出现故障之前我们还有足够的时间,我们就想现在继续下去。
我们可以使用类似的东西await来做到这一点,事实上,之前await我们很多人都用它来使基于 Promise 的代码更易于阅读。那就是生成器函数。
大多数生成器函数的示例都会展示一个遍历斐波那契数列的 for 循环或其他类似的例子。但它们实际上非常强大。生成器函数本质上是迭代器的语法糖。迭代器是一个类,它包含一个next()函数,该函数会执行一些代码并返回下一个可用的值。因此,我们才会看到斐波那契数列的例子。
因此,如果我们编写一个生成器函数并调用它,它会给我们提供一个可以随时获取下一个值的对象。
function * myGenerator() {
for(let i = 1; i < 1000; i++) {
yield i;
}
}
const iterator = myGenerator();
iterator.next(); // -> {value: 1, done: false}
iterator.next(); // -> {value: 2, done: false}
...
iterator.next(); // -> {value: 1000, done: true}
所以现在我们无需再担心返回值,只需利用代码随时运行的副作用即可。(尽管在我的实现中,yieldtrue会放弃当前帧上的更多工作,以便控制垃圾回收的时机。)
我们可以运行代码的下一步,看看已经用了多少时间。如果时间不多,我们可以再运行一步。如果时间已经足够,我们可以将其推迟到主线程的下一个循环中。
还剩多少时间?
浏览器有一个函数requestIdleCallback()会在主线程空闲时调用我们,并提供一个参数,可以用来查询距离下一帧还有多少时间。不错。
我们可以构建一个生成器,反复调用它,直到没有足够的时间进行更多调用,然后在主线程空闲时请求再次运行。
这是为不支持的浏览器添加的 polyfill,因此它可以一直向下兼容所有组件。
空闲时间协程运行器
export async function run(
coroutine,
loopWhileMsRemains = 1,
timeout = 16 * 10
) {
const options = {timeout}
let terminated = false
let resolver = null
const result = new Promise(function (resolve, reject) {
resolver = resolve
const iterator = coroutine()
window.requestIdleCallback(run)
function run(api) {
if (terminated) {
iterator.return()
return
}
const minTime = Math.max(0.5, loopWhileMsRemains)
try {
do {
const {value, done} = iterator.next()
if (done) {
resolve(value)
return
}
if (value === true) {
break
}
} while (api.timeRemaining() > minTime)
} catch (e) {
reject(e)
return
}
window.requestIdleCallback(run, options)
}
})
result.terminate = function (result) {
terminated = true
if (resolver) {
resolver.resolve(result)
}
}
return result
}
就是这样。这个版本允许返回 true 来放弃当前帧,并且还为返回的 Promise 提供了一个terminate(result)方法,该方法可以在发生重入时提前停止。
调用此函数时,它会返回一个 Promise,该 Promise 会解析为生成器函数的最终返回值。它会在主线程空闲时运行,而且,是的,你可以运行多个这样的函数。
JSON 等
好的,既然已经构建好了,现在我们需要一些常见的“繁重”操作的版本,我们可以在其中添加一些 yield 语句。
Douglas Crockford 的 JSON stringify 很好,虽然它对需要拆分的字符串进行了大量工作,因此它被重写为stringifyjs -coroutinesstringifyAsync中的JSON stringify 。
Crockford 的代码解析方式eval()不太可行,因为我们无法将其拆分,所以我使用并优化了别人的 JSON AST 解析器,并添加了一些生成器。考虑到我们有 60 帧/秒的动画,性能似乎足够了。
一些显而易见的数组操作可以用生成器轻松实现:
export function* reduce(array, fn, initial) {
let result = initial || array[0]
let index = 0
for (let item of array) {
result = yield* fn(result, item, index, array)
}
return result
}
你可以看到,这里我们使用的yield*实际上并不产生 yield,而是让整个状态机传递给一个子函数,该子函数本身可以 yield 回我们的状态机.next()。因此,这些函数中的 yield 操作需要 reduce 函数来完成。为了简化操作,我编写了一个yielding(fn)调用,它使用一个标准函数创建一个生成器,该生成器每隔几次迭代就会 yield 一次。如果这听起来很难,其实不然:
export function yielding(fn, frequency = 8) {
let yieldCount = 0
return function* (...params) {
let result = fn(...params)
if (yieldCount++ > frequency) {
yieldCount = 0;
yield
}
return result
}
}
该函数返回一个生成器函数,该函数会传递其参数并生成每个frequency循环。
现在你可以像这样调用 reduce 函数:
yield* reduce(
results,
yielding((c, a) => c + a),
0
)
使其异步
所以能够编写自己的生成器固然很好,但很多时候我们可能只是想进行大型 JSON 解析或排序。如果还要费心去研究生成器语法——尤其是在你还没想好如何拆分自己的深度处理函数时——那就有点麻烦了。
它wrapAsPromise(generator)简化了操作,将生成器函数封装在所有必要的样板代码中,用于初始化并等待结果。它返回一个运行该过程的函数。
export function wrapAsPromise(coroutine) {
return async function (...params) {
return await run(function* () {
return yield* coroutine(...params)
})
}
}
这意味着我们可以像这样定义一个异步 JSON 函数(就像我在库中做的那样):
export const parseAsync = wrapAsPromise(parse)
我们只需在任何异步例程中调用以下代码即可获得异步 JSON:
// Yay no lag
let obj = await parseAsync(json)
另一种协程
命令式控制动画非常方便。我们可以编写一个 for 循环,并在每一帧都告诉某个东西该去哪里。高优先级协程使用生成器就能很好地实现这一点:
let multiplier = window.innerWidth / 300
return update(function* () {
while (true) {
for (let x = -200; x < 200; x++) {
logoRef.current.style.marginLeft = `${x * multiplier}px`
yield
}
for (let y = 0; y < 200; y++) {
logoRef.current.style.marginTop = `${y * multiplier}px`
yield
}
})
这里更新函数使用 arequestAnimationFrame()来运行,而 yield 会等待下一帧。
export async function update(coroutine) {
let terminated = false
let resolver = null
const result = new Promise(function (resolve, reject) {
resolver = resolve
const iterator = coroutine()
window.requestAnimationFrame(run)
function run() {
if (terminated) {
iterator.return()
return
}
try {
const {value, done} = iterator.next()
if (done) {
resolve(value)
return
}
} catch (e) {
reject(e)
return
}
window.requestAnimationFrame(run)
}
})
result.terminate = function (result) {
terminated = true
if (resolver) {
resolver.resolve(result)
}
}
return result
}
注意事项
我们无法预料垃圾回收器会偶尔触发某个帧。你可以尝试编写一些程序yield true来延长垃圾回收的等待时间。
结论
事实证明,将工作完全拆分到多个帧上并保持 60fps 的帧率其实并不难。我得感谢Paolo 和他那篇精彩的文章,他在文章中探讨了如何使用 React Fiber 来实现 React 组件的重新父级化,这篇文章启发我去阅读他的一些参考资料——正是这些资料requestIdleCallback()让我灵光一闪。
坦白说,经过多年的努力,我简直不敢相信自己现在竟然能写作了:
const records = await parseAsync(json)
await sortAsync(records, a=>a.lastName)
而且不能冒出现重大故障的风险。
其他优秀的 NPM 包包括 Timsort(用于排序)和 (yastjson),后者是一个快速 JSON 解析器的起点,可以作为协程运行。
项目主页包含更多详细信息和示例。该库可在GitHub和 npm 上获取:
npm install js-coroutines
麻省理工学院 (c) 2020 Mike Talbot 等人
感谢阅读。
MikeT
文章来源:https://dev.to/miketalbot/60fps-javascript-while-stringfying-and-parsing-100mbs-of-json-84l
