在React中处理高频实时数据:从环形缓冲区到离屏canvas技术
React在很多方面都表现得非常出色。但如果你曾经尝试过每秒向它传输数千个数据点,你就会很快意识到:React并不像一根能输送大量水流的消防水管,而更像是一根普通的花园浇水软管。 如果强迫它处理过多的数据,要么会导致“草坪被淹没”(即DOM结构变得混乱),要么会使得“管道爆裂”(也就是应用程序运行出现严重问题)。 还有另一个与上述观点相关的观察结果:你的笔记本电脑通常拥有8到16个CPU核心,而你的React应用程序几乎总是只使用其中的一个核心。主线程负责处理JavaScript代码、DOM操作、布局计算以及绘制工作;而其他核心则处于闲置状态,因为主线程实在难以维持每秒60帧的渲染速度。 这两
React在很多方面都表现得非常出色。但如果你曾经尝试过每秒向它传输数千个数据点,你就会很快意识到:React并不像一根能输送大量水流的消防水管,而更像是一根普通的花园浇水软管。
如果强迫它处理过多的数据,要么会导致“草坪被淹没”(即DOM结构变得混乱),要么会使得“管道爆裂”(也就是应用程序运行出现严重问题)。
还有另一个与上述观点相关的观察结果:你的笔记本电脑通常拥有8到16个CPU核心,而你的React应用程序几乎总是只使用其中的一个核心。主线程负责处理JavaScript代码、DOM操作、布局计算以及绘制工作;而其他核心则处于闲置状态,因为主线程实在难以维持每秒60帧的渲染速度。
这两个问题其实有着相同的解决方法:你必须让React远离那些需要高性能处理的任务流程,并且必须使用多线程技术。实现这一目标的那些方法,同时也是Figma的画布引擎、Bloomberg的交易界面,以及所有生物信号监测工具所采用的底层技术。
在某个项目中,我需要处理19个脑电波通道的数据,每个通道每秒会生成大约1,000个数据点,这意味着总共有近19,000个数据点需要每秒被处理。如果直接将这些数据传入React系统,用户界面不仅会变得迟缓,甚至会完全无法正常使用。
本文介绍的就是我最终采用的端到端解决方案:包括环形缓冲区、辅助线程、共享内存机制、在主线程之外进行渲染的技术,以及那些能够在持续数小时的负载下依然保持高效运行的具体设计模式。
目录
先决条件
要想充分理解这篇文章的内容,你需要具备以下条件:
具备React 18或19的相关使用经验。你应该能够熟练运用`useState`、`useEffect`、`useRef`这些函数,并且了解组件挂载与重新渲染的区别。
掌握TypeScript的基础知识。文章中的大多数示例都是用TypeScript编写的,因此你应该能够顺利阅读类型注释而不会遇到任何障碍。
对浏览器的主线程和事件循环有基本的了解。虽然不需要你自己编写过Web Worker代码,但理解“阻塞主线程”这一概念会让你更容易完成第三步的内容。
熟悉Canvas 2D或某种图表库会更有帮助,但这并非强制要求。如果你曾经在画布上绘制过内容,那么你就已经具备了相关基础。
需要一台能够运行现代版Chrome或Edge浏览器的笔记本电脑。
文章中的示例依赖于`SharedArrayBuffer`、`OffscreenCanvas`以及Atomics这些技术,而这些功能只有在基于Chromium的浏览器中才能得到支持,同时还需要启用跨源隔离功能(这部分内容会在后续章节中详细讲解)。
你不需要具备使用Web Worker、环形缓冲区或WebGL的先验经验。这篇文章会结合实际问题来介绍这些技术的相关内容。
谁已经在使用这些技术了?
本文中介绍的各种技术模式并非实验性的概念,它们其实是那些需要处理高频数据的生产环境应用程序所采用的架构:
交易和金融领域的仪表盘(比如Bloomberg、Hyperliquid、dYdX等专业市场分析工具)每秒钟都会通过画布将数千条价格数据绘制出来。
Figma会在WebAssembly环境中通过worker线程来运行其画布引擎,而主线程则仅负责渲染React组件。
Google Docs和Microsoft Loop会利用worker线程来处理文档数据,最终将结果显示在DOM中。
像LightningChart、uPlot、Plotly和ECharts这样的图表库都会使用Canvas或WebGL进行绘图,而React则主要起到封装数据结构的作用。
生物信号监测、心电图分析、脑电图检测以及动作捕捉应用程序通常会以1kHz或更高的频率处理数据,并将结果实时显示在图表中。
可观测性工具和APM系统(如Datadog的实时监控功能、Grafana的实时面板)会将数据采集与渲染过程分离,从而确保界面的响应速度始终保持良好。
音频编辑器和可视化工具,以及任何使用Web Audio API并具备波形显示功能的应用程序。
transformers.js和ONNX Runtime Web默认都会在worker线程中执行机器学习推理任务。
不同的应用领域其实都在采用同样的原理:那些变化频率很低的数据由React来处理,而那些需要频繁更新的数据则由其他技术来处理;复杂的计算任务通常会在非主线程中完成。
1kHz这个频率背后的数学原理
下面有一些具体的数字,可以帮助你更好地理解这个问题。
每1毫秒会收到一个样本。
60Hz的显示器每16.67毫秒更新一次屏幕内容。
因此,在一帧图像中,每个数据流大约会收到16到17个样本。
如果同时有19个数据流在运行(以脑电图为例),那么每一帧图像将包含300到320个样本。
如果你在每个数据样本更新时都调用`setState`,React会试图每秒进行大约19,000次渲染操作。但由于性能限制,它无法完成这一目标,因此会跳过某些帧的渲染,从而导致用户界面出现卡顿现象,同时你的笔记本电脑风扇也会加速运转。
如果你每隔一帧才调用一次`setState`,并且处理的是约320个数据样本,那么React每秒只能进行60次渲染操作,这样的处理方式显然更加可行。
其实,关键就在于这种“每隔一帧更新一次”的机制,这一原则会在后续的每一个开发步骤中都起到至关重要的作用。
常见的问题所在
在大多数实时运行的React应用中,开发者初次尝试使用这种编码方式时,看到的代码看起来是合理的。然而,恰恰就是这种代码导致了后续出现的一系列问题,开发团队接下来两周的时间都在努力解决这些漏洞。
import { useEffect, useState } from "react";
export default function NaiveChart({ socket }) {
const [data, setData] = useState:>];
useEffect(() => {
socket.on("newPoint", (point: number) => {
setData((prev) => [...prev, point]); // 每次收到新数据都会重新渲染
});
}, [socket]);
return 有{data.length}个数据点;
}
短短七行代码中隐藏了三个问题:
每个新收到的数据样本都会触发重新渲染:以1千赫的频率发送数据的话,每秒就会进行1,000次渲染操作。React显然无法承受这样的负担。
[...prev, point]这种写法每次添加新数据都会创建一个新的数组:以1千赫的频率处理数据的话,每毫秒就会生成一个新数组,而垃圾回收器必须负责清理这些不再需要的数组。这样一来,内存使用量会急剧增加,垃圾回收的次数也会增多,从而导致设备性能下降。这个数组的大小没有上限:如果让这种代码运行一小时,内存中将会存储360万个数字,而React在每次渲染时都必须处理这些数据,这无疑会严重影响应用程序的性能。
解决这些问题并不需要一种复杂的技巧,而是需要一系列针对性的优化措施,每一项措施都是针对上述某个具体问题设计的。归根结底,问题的根源在于主线程是系统中唯一的线程这一设计缺陷。
步骤1:停止将数据放入React的状态对象中
在实时更新的React应用中,最大的错误就是把所有的数据都视为状态对象的一部分,但实际上并非如此。状态对象的作用是决定“哪些组件应该被显示出来,以及它们应该如何排列”。一个实时的数据图表就是一个组件,而在其上滚动的60,000个数据样本并不是60,000个独立的状态值,它们实际上只是一个缓冲区。
你需要一个存在于React框架之外的数据存储机制。使用基于类型化数组的环形缓冲区,就可以实现常数时间内的数据插入操作,并且还能保证内存的使用量是有限的:
type Listener = () => void;
export function createSampleStore(capacity: number) {
const buf = new Float32Array(capacity);
let head = 0;
let size = 0;
const listeners = new Set();
return {
push(sample: number) {
buf[head] = sample;
head = (head + 1) % capacity;
if (size < capacity) size++;
},
pushBatch(samples: Float32Array) {
for (let i = 0; i < samples.length; i++) {
buf[head] = samples[i];
head = (head + 1) % capacity;
if (size < capacity) size++;
}
},
read(): Float32Array {
if (size < capacity) return buf.subarray(0, size);
const out = new Float32Array(capacity);
out.set(buf.subarray(head));
out.set(buf.subarray(0, head), capacity - head);
return out;
},
subscribe(l: Listener) {
listeners.add(l);
return () => listeners.delete(l);
},
};
}
注意,这里并没有使用useState函数,也没有任何与React相关的代码。这完全就是普通的JavaScript代码。这样的数据存储机制每秒可以接受上百万次的数据插入操作,而React根本不会受到影响,因为根本没有为它订阅这些数据变化。
下面是通过图表来直观展示环形缓冲区的工作原理:
每次数据插入时,指针都会向前移动;当缓冲区满时,指针会重新开始循环。读取数据时,会按时间顺序取出最近N个数据样本。这样的设计使得数据插入和读取操作都能在常数时间内完成,并且内存占用也是有限的。
当你还不需要使用已输入的数据进行渲染时,一个更简单的临时解决方案是将滚动窗口存储在`ref`中,而不是作为组件的状态来保存:
import { useEffect, useRef } from "react";
export default function RefChart({ socket }) {
const bufferRef = useRef:>> [];
useEffect(() => {
socket.on("newPoint", (point: number) => {
bufferRef.current.push(point);
if (bufferRef.current.length > 1000) {
bufferRef.current.shift();
}
});
}, [socket]);
return 正在显示{bufferRef.current.length}个数据点;
}
这种实现方式“比NaiveChart快10倍,但仍然不够理想”:数据更新并不会立即触发渲染,除非有其他部分需要重新绘制,否则用户根本看不到任何变化。如果需要进行真正的绘图操作,就需要将`ref`与`requestAnimationFrame`循环结合使用(详见第2步)。
步骤2:将图形元素与数据分离
React只有在用户界面的图形结构发生变化时才会重新渲染。新增了一个图表?会重新绘制;删除了一个图表?也会重新绘制;从折线图切换到条形图?同样需要重新渲染。但这些操作并不会以1000Hz的频率频繁发生——实际上,只有当用户点击某个元素时,系统才会进行重新渲染,而这种操作的频率大概只有每分钟几次而已。
而每个图表中显示的数据本身则是按照数据产生的实际频率进行更新的,这些数据根本不需要触发React的重新渲染过程。
function LivePlot({ store }: { store: SampleStore }) {
const canvasRef = useRef/(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d")!;
let raf = 0;
const draw = () => {
const samples = store.read();
drawSeries(ctx, samples);
raf = requestAnimationFrame(draw);
};
raf = requestAnimationFrame(draw);
return () => cancelAnimationFrame(raf);
}, [store]);
return
React只会一次性渲染这个组件,`useEffect`也会只执行一次。而`requestAnimationFrame`循环会在每一帧中从数据存储中读取数据并进行绘制操作。
import { create } from "zustand";
const useDataStore = create/<{ latest: number | null; setLatest: (v: number) => void }>>((set) => ({
latest: null,
setLatest: (v) => set({ latest: v }),
}));
function LatestBadge() {
// 只有当`latest`的值发生变化时才会重新渲染,其他数据字段的变化不会影响渲染。
const latest = useDataStore((state) => state/latest);
return 最新值:{latest?.toFixed(2)};
}
将这种方法与RAF在写入操作时进行的数据合并机制结合使用(每个帧调用一次`setLatest`方法,而不是每个样本都调用一次),那么你就能得到一个无论数据传输速率如何都能以60帧每秒的频率流畅更新的React组件。
useSyncExternalStore是原生系统中对应的解决方案,它可以与任何基于发布-订阅机制的数据存储系统配合使用,包括上面提到的环形缓冲区。你可以根据团队的需求选择最适合的方法。
步骤3:将繁重的工作任务从主线程中分离出来
数据存储系统和那些执行绘制操作的代码负责解决React程序中可能造成的性能瓶颈问题。而主线程本身仍然负责数据的接收、解析以及各种数学计算工作。对于每秒每条数据流超过50,000个样本的情况,就需要采用其他方法来提升性能。
浏览器提供了四种解决方案:Web Workers、可传输对象、《SharedArrayBuffer》与Atomics技术,以及OffscreenCanvas。每种方案都能解决特定的问题。
主线程用于执行JavaScript代码、处理DOM结构、进行布局计算以及绘制操作。而工作线程则是独立的JavaScript运行环境,它们拥有自己的事件循环。默认情况下,工作线程无法访问DOM,也无法共享内存;所有跨线程通信都是异步进行的,除非开发者明确地使用某些机制来传递数据。
在设计基于这种模型的应用程序时,有三个关键属性需要特别注意:
工作线程无法访问DOM:这一点非常重要。因为工作线程只执行纯JavaScript代码,所以它们非常适合用于数据处理、网络请求解析、数学计算、编码解码以及机器学习推理等任务。
通信是异步的:在函数执行过程中不能直接读取共享变量。因此,在设计API时应该根据请求和事件来组织代码结构。
数据需要被复制才能传递或共享:默认情况下,数据在跨线程传输时会被复制;使用可传输对象可以避免这种复制操作,而
SharedArrayBuffer则能够永久性地避免数据复制。
实际应用中的专用工作线程
下面是一个用于数据接收处理的简单示例:
// worker.ts
self.onmessage = (event) => {
const { samples } = event.data;
// 对数据进行处理,包括解码、过滤和简化处理。主线程不会看到这些操作的结果。
const summary = computeStats(samples);
postMessage(summary);
};
// 主线程代码
import { useEffect, useRef } from "react";
export default function WorkerChart({ socket, store }) {
const workerRef = useRef<Worker>>();
useEffect(() => {
workerRef.current = new Worker(new URL("./worker.ts", import.meta.url), {
type: "module",
});
workerRef.current.onmessage = (event) => {
store.pushBatch(event.data); // 这些数据已经经过预处理,因此传输效率很高。
};
socket.on("newPoint", (point: number) => {
workerRef.current?.postMessage({ point });
});
return () => workerRef.current?.terminate();
}, [socket, store]);
return <LivePlot store={store} />;
}
关于worker,有三点需要牢记。
首先,从运行时的角度来看,每个worker实际上都是一个独立的进程。它们各自拥有自己的内存堆、事件循环以及`globalThis`对象。创建一个worker大约需要1到5毫秒的时间;因此,请避免在那些执行效率较高的代码路径中频繁创建worker。
其次,在现代环境中,使用模块形式的worker才是默认的选择。通过设置`type: "module"`,可以在worker内部使用ES模块,包括`import`语句。而传统的`importScripts`方法则是为旧版本的worker设计的,因此在新代码中应避免使用它。
最后,各种打包工具都认识worker的概念。Vite、Webpack、esbuild和Rspack等工具都能识别到`new Worker(new URL("./x.ts", import.meta.url))`这种创建worker的语法,并会为worker生成单独的代码片段。
能够随核心数量扩展的Worker池
对于那些需要大量CPU资源来完成的任务(比如解析二进制数据、解码音频文件、计算FFT等),使用worker池可以将这些任务分配到所有可用的核心上:
class WorkerPool {
private workers: Worker[];
private next = 0;
private pending = new Map void>();
constructor(scriptUrl: URL, size = Math.max(1, navigator.hardwareConcurrency - 1)) {
this.workers = Array.from({ length: size }, () => {
const w = new Worker/scriptUrl, { type: "module" });
w.onmessage = (event) => {
const cb = thispending.get(event.data.id);
if (!cb) return;
this.pending.delete(event.data.id);
cb(event.data.result);
};
return w;
});
}
async run(kind: string, payload: unknown, transferables: Transferable[] = []): Promise {
const id = crypto.randomUUID();
const result = new Promise((resolve) => this.pending.set(id, resolve as (r: unknown) => void));
const worker = this.workers[this.next];
this.next = (this.next + 1) % this.workers.length;
worker.postMessage({ id, kind, payload }, transferables);
return result;
}
}
export const pool = new WorkerPool(new URL("./decoder.worker.ts", import.meta.url));
这种分配机制采用轮询的方式,每个核心上会运行一个worker,总共就是核心数量减一。当对应的响应数据到达时,待处理的请求就会得到处理。这种方案成本低廉、性能可预测,并且能够线性扩展,直到遇到内存带宽的限制为止。
可传输对象:避免数据复制的解决方案
默认情况下,`postMessage`方法会复制传入的payload数据。如果需要复制一个10MB大小的缓冲区,这个过程会消耗数毫秒的时间,同时还会使内存使用量翻倍。解决办法是直接使用`transfer`方法来传递这些数据。

从语义上来说,当你传输一个缓冲区时,发送方会失去对该缓冲区的访问权限,而接收方则会获得它,而且这个过程不会产生任何副本,耗时仅为 O(1)。
const buf = new ArrayBuffer(10 * 1024 * 1024); // 10 MB
new Uint8Array(buf).set(somePayload);
worker.postMessage({ buf }, [buf]); // 第二个参数是可传输对象的列表
// 此时,发送方已经无法再访问 `buf`,尝试访问它会抛出错误。
在 2026 年,可供传输的对象类型包括 `ArrayBuffer`(以及任何由它支持的类型化数组视图)、`MessagePort`、`ImageBitmap`、`OffscreenCanvas`、各种流类型(`ReadableStream`、`WritableStream`、`TransformStream`)、`RTCDataChannel`、`VideoFrame`、`AudioData`,还有 WebTransport 流。对于 React 和实时应用来说,最实用的类型是 `ArrayBuffer`、`OffscreenCanvas` 和 `MessagePort`。
一个常见的陷阱就是忘记执行传输操作,这样会导致应用程序运行速度变慢,但表面上看起来程序仍然可以正常工作。如果你使用 `worker.postMessage` 方法发送数据,并且发现对于“小型”数据量来说,处理时间竟然需要几毫秒,那就说明你实际上是在复制数据,而不是进行传输。
// 错误的做法:复制整个数组。
worker.postMessage({ samples: float32Array });
// 正确的做法:直接传输底层的缓冲区。
worker.postMessage({ samples: float32Array }, [float32Array.buffer]);
数据传输完成后,该缓冲区就会与发送方断开连接,因此如果发送方还想继续向接收方发送数据,就需要重新分配一个缓冲区(或者从预先分配好的缓冲池中获取一个新的缓冲区)。
SharedArrayBuffer 与 Atomics
“传输”操作只是将一个缓冲区从发送方移交给接收方,而“共享”功能则能让两个线程同时访问同一块内存。
const sab = new SharedArrayBuffer(1024 * 1024); // 1 MB 的共享内存
worker.postMessage({ sab });
// 现在,主线程和 worker 线程都引用了同一块内存。
const viewMain = new Int32Array(sab);
// 在 worker 线程中:
// const viewWorker = new Int32Array(event.data.sab);
有三个属性非常重要。
SharedArrayBuffer 需要跨源隔离功能。你的网页必须使用 `Cross-Origin-Opener-Policy: same-origin` 和 `Cross-Origin-Embedder-Policy: require-corp` 这些头部信息进行配置。如果没有这些设置,浏览器会认为 `SharedArrayBuffer 是不存在的。关于这一点的详细内容会在文章后面部分进行介绍。
为了实现同步操作,你还需要使用 Atomics。如果多个线程在没有协调的情况下同时写入同一块内存,那么就会产生不可预测的结果。Atomics 提供了针对类型化数组视图的比较与交换、读取、存储、加法、减法、等待以及通知等操作。
const sab = new SharedArrayBuffer(8);
const view = new Int32Array(sab);
// 主线程:唤醒所有正在等待访问索引 0 的 worker 线程。
Atomics.store(view, 0, 1);
Atomics.notify(view, 0, 1);
// Worker 线程:阻塞直到索引 0 的值发生变化。
const result = Atomics.wait(view, 0, 0); // 返回 “ok”、“not-equal” 或 “timed-out”而无锁环形缓冲区正是这种架构的关键所在。它实现了两个线程之间的生产者-消费者通信机制,无需任何锁定操作,也不存在《postMessage》方法带来的往返延迟,更不会给垃圾回收系统带来压力。数据存储在共享缓冲区中,而原子操作则用于协调读写操作的顺序。
以下是一个最简单的单生产者-单消费者环形缓冲区的实现示例:
type SharedRing = {
data: Float32Array; // 数据部分
control: Int32Array; // 控制信息,包含“头部索引”和“尾部索引”
};
function createSharedRing(capacity: number): SharedRing {
const sab = new SharedArrayBuffer(capacity * 4 + 16);
const control = new Int32Array(sab, 0, 4); // [头部索引, 尾部索引, ...]
const data = new Float32Array(sab, 16, capacity);
return { data, control };
}
function push(ring: SharedRing, value: number): boolean {
const head = Atomics.load(ring.control, 0);
const tail = Atomics.load(ring_control, 1);
const next = (head + 1) % ring.data.length;
if (next === tail) return false; // 缓冲区已满
ring.data[head] = value;
Atomics.store(ring.control, 0, next);
return true;
}
function pop(ring: SharedRing): number | null {
const tail = Atomics.load(ring_control, 1);
const head = Atomics.load(ring CONTROL, 0);
if (tail === head) return null; // 缓冲区为空
const value = ring.data[tail];
Atomics.store(ring.control, 1, (tail + 1) % ring.data.length);
return value;
}
生产者在一个线程中写入数据,消费者在另一个线程中读取数据,两者都不会造成阻塞,也不需要使用《postMessage》方法进行通信。在高吞吐量的应用场景中,这种架构才是唯一具备扩展性的解决方案。
对于多生产者或多消费者的通信场景,就需要使用比较交换算法(如《Atomics.compareExchange》方法)。不过这类操作的速度会很快变得很慢。在大多数生产环境中,只要条件允许,都会优先采用单生产者-单消费者架构;而当条件不允许时,才会转而使用消息传递机制。
在Electron中,主进程负责数据采集
同样的原理也可以应用到更高级别的场景中。借助Electron和原生SDK,你可以在主进程中采集样本数据,将其存储在缓冲区中,然后按照渲染器的刷新频率,通过IPC机制将数据批量传输给渲染线程。如果每个样本只发送一条IPC消息且传输频率为1kHz,那么IPC机制的吞吐量就会达到极限;而如果每帧发送一条IPC消息,每次传输16个样本的数据,那么这种传输方式就非常高效了。
// 主进程代码:在Node.js中存储数据缓冲区,每60Hz刷新一次
let pending: number[] = [];
device.on("sample", (value) => pending.push(value));
setInterval(() => {
if (pending.length === 0) return;
const batch = new Float32Array(pending);
pending = [];
// 使用“transferable”类型避免进行结构化克隆操作
mainWindow.webContents.send("device:samples", batch.buffer, [batch.buffer]);
}, 1000 / 60);
// 预加载:向渲染器暴露一个简洁的订阅API
contextBridge.exposeInMainWorld("device", {
onSamples: (cb: (samples: Float32Array) => void) => {
const handler = (_: unknown, buf: ArrayBuffer) => cb(new Float32Array(buf));
ipcRenderer.on("device:samples", handler);
return () => ipcRenderer.removeListener("device:samples", handler);
},
});
// 渲染器:通过桥接层将数据传递给存储系统
useEffect(() => {
return window.device.onSamples((batch) => store.pushBatch(batch));
}, []);
无论设备的处理速度有多快,渲染器的主线程每帧也只会处理一批数据。所有的数据处理工作实际上都是在前端完成的。
步骤4:使用OffscreenCanvas在主线程之外进行渲染
DOM是单线程的,以前使用的Canvas API也是如此。OffscreenCanvas》打破了这一限制:你可以将canvas对象传输到工作线程中,让它在主线程之外独立进行绘制。
// 主线程
const canvas = canvasRef.current!;
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: offscreen }, [offscreen]);
// 工作线程
let ctx: OffscreenCanvasRenderingContext2D | null = null;
self.onmessage = (event) => {
if (event.data.canvas) {
ctx = event.data.canvas.getContext("2d");
return;
}
if (event.datasamples && ctx) {
drawSeries(ctx, event.data_samples);
}
};
现在,主线程可以自由地处理用户的点击、悬停等交互操作,而不会与渲染循环产生冲突。工作线程则会根据自己拥有的数据以自己的节奏进行绘制。
如果再将SharedArrayBuffer结合使用,你就得到了浏览器中最为高效、最实时的渲染流程:
数据采集工作线程会将样本数据写入共享缓冲区,而渲染工作线程则会读取这些数据并进行绘制。主线程仅需要读取汇总信息(如帧率、最新数值、通道标签等),然后更新UI界面。没有任何数据会经过主线程的事件循环。
对于每秒1千赫兹、包含19个通道的信号来说,只有这种架构才能确保在笔记本电脑上稳定地维持60帧每秒的渲染速度,而且还有足够的性能余量。
步骤5:在绘制之前对数据进行处理
一个宽度为1200像素的canvas最多只能显示1200个不同的X坐标位置。如果你的窗口中包含了60,000个样本数据,并且你尝试将所有这些数据都绘制出来,那么你所完成的工作量将是用户实际能够看到的内容的50倍。
为每一列像素选择合适的点进行绘制。“最小值-最大值”这种处理方法对于信号数据来说效果非常好:对于每一列像素,找出该范围内的最小值和最大值,然后在它们之间画一条垂直线。从视觉上看,这种方法与逐个点进行绘制的效果是一样的,但所需的计算资源要少得多。
function drawDecimated(
ctx: CanvasRenderingContext2D,
samples: Float32Array,
width: number,
) {
const samplesPerPixel = samples.length / width;
ctx.beginPath();
for (let x = 0; x < width; x++) {
const start = Math.floor(x * samplesPerPixel);
const end = Math.floor((x + 1) * samplesPerPixel);
let min = Infinity;
let max = -Infinity;
for (let i = start; i < end; i++) {
const v = samples[i];
if (v < min) min = v;
if (v > max) max = v;
}
ctx.moveTo(x, scale(min));
ctx.lineTo(x, scale(max));
}
ctx.stroke();
}
降采样是实时可视化技术中能够显著提升CPU使用效率的关键方法,然而几乎没有人真正采用这种方法。
LTTB(最大三角形三分法)是一种视觉效果更佳的降采样算法:它会在每个数据区间内选取一个具有代表性的样本进行绘制,从而更好地保持数据的原始形态。如果你需要绘制股票图表这类非信号数据,其中每一点的波动都十分重要,那么这种算法绝对值得一试。大多数优秀的图表库(如uPlot、Plotly、ECharts)都内置了降采样功能。
步骤6:封装外部渲染器
你并不总是需要自己编写绘制代码。React在处理组件的生命周期方面表现非常出色,而那些专注于提升性能的图表库则更擅长执行高效的计算任务。因此,通常最好的做法是让React负责图表的挂载和卸载,而将具体的渲染工作交给这些专门的库来完成。
import Uplot from "uplot";
import "uplot/dist/uPlot.min.css";
import { useEffect, useRef } from "react";
export default function UPlotChart({ data }: { data: AlignedData }) {
const ref = useRef〈HTMLDivElement〉(null);
const plotRef = useRef〈Uplot | null〉(null);
useEffect(() => {
const opts = {
title: "实时图表",
width: 600,
height: 300,
series: [{}, { label: "信号" }],
};
plotRef.current = new Uplot(opts, data, ref.current!);
return () => plotRef.current?.destroy();
}, []);
useEffect(() => {
plotRef.current?.setData(data); // 直接调用setter方法进行数据更新,无需通过React重新渲染
}, [data]);
return ;
}
uPlot、TimeChart、ECharts、Plotly、LightningChart等图表库都是按照这样的模式来设计的:在`useEffect`效应函数中创建图表实例,直接调用`setData`方法更新数据,然后在组件卸载时销毁相关对象。React负责整体协调,而具体的渲染工作则由这些库来完成。
function ChartWrapper({ config, data }) {
const ref = useRef〈HTMLDivElement〉(null);
useEffect(() => {
const chart = new SomeFastChartLib(ref.current, config);
chart.setData(data);
return () => chart.destroy();
}, [config, data]);
return ;
}
如果你所处的领域并不需要对绘图过程进行像素级别的控制,那么采取这种做法将带来最高的投资回报。首先尝试使用现有的库,只有当没有合适的库可用时,再自行编写绘图代码。
步骤7:当Canvas无法满足需求时,选择WebGL
Canvas 2D能够轻松处理每帧几千条线段。但当线条数量超过这个限度后,在调用stroke()函数时就会消耗大量的时间。
WebGL(或者像regl、twgl、pixi.js、deck.gl这样的高级封装库)会将渲染任务交给GPU来处理。虽然需要前期投入精力编写着色器并管理缓冲区,但这样一来,你就可以轻松地绘制数百万个点而不会遇到任何问题。
下面是一个简单的WebGL示例,它仅使用了一个顶点着色器来绘制线段:
const gl = canvas.getContext("webgl2")!;
const program = gl.createProgram()!;
// ... 编译顶点着色器和片段着色器,链接它们,并确定属性的位置……
const samplesBuffer = gl.createBuffer();
function drawWebGLsamples: Float32Array) {
gl.bindBuffer(gl.ARRAY_BUFFER, samplesBuffer);
gl.bufferData/glARRAY_BUFFER, samples, gl.STREAM_DRAW);
gl.useProgram(program);
gl.drawArrays(gl.LINE_STRIP, 0, samples.length);
}
其工作原理与Canvas 2D相同:在RAF循环中执行绘图操作。不同的是,实际的光栅化工作是由GPU来完成的。大多数声称能够处理“数百万个点”的实时图表库,在底层其实都是采用这种方式来实现的。
默认情况下,应该使用Canvas 2D;只有当确认Canvas确实成为了性能瓶颈时,才应考虑切换到WebGL。
步骤8:保持内存使用量稳定
实时应用程序往往会慢慢出现性能下降的问题。它们可能可以平稳运行一个小时,但随后笔记本电脑的散热风扇就会开始启动——造成这种问题的原因几乎总是内存使用量过高。
有三条规则可以帮助你保持内存使用的稳定性:
首先,对于数值型数据,应该使用类型化的数组。例如,一个包含60,000个浮点数的Float32Array所占用的内存仅为240KB,而同样大小的普通JavaScript数组则会占用大约1.4MB的内存,并且每次向其中添加新元素时都会给垃圾回收系统带来压力。
其次,要确保所有资源都被正确地绑定到相应的缓冲区中。应该使用环形缓冲区,而不是可无限扩展的数组。对于历史数据、队列以及待处理的任务,都应该设定上限——任何可能无限制增长的数据最终都会导致内存问题。
第三,要重复利用缓冲区。在程序初始化时只分配一次缓冲区,然后在后续的帧中继续使用它。如果以60帧每秒的速度进行渲染,那么每秒钟每个绘图对象都需要被分配60次内存;如果同时处理多个绘图对象,这个数字还会进一步增加。
// 不好的做法:每一帧都创建新的数组。
function drawFrame() {
const snapshot = store.read(); // 返回一个新的Float32Array
drawDecimated(ctx, snapshot, width);
}
// 更好的做法:重复使用现有的绘图缓冲区。
const drawBuf = new Float32Array(WINDOW_SIZE);
function drawFrame() {
store.readInto(drawBuf); // 将数据写入到已存在的缓冲区中
drawDecimated(ctx, drawBuf, width);
}
这些规则并不是 React 所特有的。任何实时系统都会遵循这些规则。之所以需要明确说明这些规则,是因为 React 的开发思路(“每次渲染时都生成一个新的数组”)与实时系统的运行原则恰恰相反。
步骤 9:调度策略
如果没有调度机制,工作池就只不过是一个队列而已。有时我们需要为某些操作设置优先级:用户发起的操作应该优先于后台任务被执行。
优先级队列
一个简单的优先级调度器示例:
type Priority = "high" | "normal" | "low";
class PriorityPool {
private queues: Record = { high: [], normal: [], low: [] };
private idle: Worker[];
enqueue(job: Job, priority: Priority = "normal") {
if (this.idle.length > 0) {
const worker = this.idle.pop()!;
this.dispatch(worker, job);
} else {
this.queues[priority].push(job);
}
}
private next(): Job | null {
for (const p of ["high", "normal", "low"] as const) {
const j = this.queues[p].shift();
if (j) return j;
}
return null;
}
private dispatch(worker: Worker, job: Job) {
worker.postMessage(job.payload, job.transferables);
worker.onmessage = (event) => {
job.resolve(event.data);
const next = this.next();
if (next) this.dispatch(worker, next);
else this.idle.pushworker);
};
}
}
通常来说,使用三个优先级等级就足够了:高优先级(用户发起的操作)、正常优先级(常规任务)以及低优先级(后台任务、预取操作或数据传输等)。
可分块处理的任务
如果一个任务需要 2 秒才能完成,那么这个任务就会占用 worker 2 秒的时间。为了确保 worker 能够及时处理高优先级的任务,这类任务就必须可以被分解成多个小部分来执行。
// 在 worker 中:
self.onmessage = async (event) => {
const { id, kind, payload } = event.data;
if (kind === "decode_large") {
const total = payload.byteLength;
for (let i = 0; i < total; i += CHUNK) {
const chunk = decodeChunk(payload, i, Math.min(i + CHUNK, total));
self.postMessage({ id, kind: "progress", chunk, offset: i });
// 等待一段时间,让 worker 有机会处理其他消息。
await new Promise((r) => setTimeout(r, 0));
}
self.postMessage({ id, kind: "done" });
}
if (kind === "cancel") {
// ... 中止当前任务
}
};
在 worker 中使用 `yield` 语句虽然会暂时让 worker 停止执行当前的代码,但这样可以让 worker 在处理高优先级的任务或取消请求时,能够及时响应其他任务。对于那些耗时较长的任务来说,这一点至关重要。
分而治之与合并处理
将一个大型任务分解成多个小部分,分别派发给不同的 worker 来执行,然后收集所有结果即可。
async function decodeFile(buffer: ArrayBuffer): Promise {
const chunks = splitBuffer(buffer, 8);
const results = await Promise.all(
chunks.map((chunk) => pool.run 对于那些可以并行处理的工作负载来说,这种方式能够带来线性的性能提升,这种提升的效果取决于同时运行的工作线程的数量。对于解码录音文件、总结长文档内容,或者为某个文件夹计算嵌入向量这类批处理操作而言,这一点至关重要。
步骤10:测量持续性能
在性能分析工具中看到性能峰值是一回事,但如果内存使用量在一段时间内持续缓慢增加,那就是另一回事了。对于实时应用来说,需要关注两方面的性能指标。
首先,要检查帧率是否稳定。你的应用程序能否始终保持60帧每秒的帧率,还是偶尔会出现帧率下降的情况?下面是一个简单的帧率测量代码示例:
let lastTime = performance.now();
let frames = 0;
function rafLoop(now: number) {
frames++;
if (now - lastTime >= 1000) {
const fps = (frames * 1000) / (now - lastTime);
frames = 0;
lastTime = now;
console.log(`fps: ${fps.toFixed(1)}`);
}
requestAnimationFrame(rafLoop);
}
requestAnimationFrame(rafLoop);
更客观的衡量标准应该是某一时间窗口内最差的帧率表现。平均帧率无法反映画面播放过程中的卡顿情况。
其次,要检查内存使用的稳定性。打开DevTools的内存管理工具,先获取一次内存快照,然后让应用程序运行10分钟后再获取第二次快照,通过对比这两次快照的数据就能判断内存使用情况是否稳定。如果内存使用量在持续增加,那就说明存在内存泄漏问题——可能是某些监听器被遗留下来,数组的大小在不断增长,或者是一些闭包在占用大量内存。
对于实际应用来说,应该将内存使用情况和帧率数据纳入分析系统,这样就能在用户开始抱怨之前及时发现性能下降的问题。
案例研究:19个脑电信号通道
让我们回到这篇文章开头提到的那个项目吧:在这个项目中,我们需要同时处理19个脑电信号通道,每个通道的采样频率为1千赫兹,而且这些数据需要在持续数小时的录制过程中保持稳定的显示效果。
最初我们使用了LightningChart这个图表库。它功能强大、性能优异,但同时也非常占用内存。使用它后,内存使用量明显增加了;而且对于我们的需求来说(只需要绘制19个简单的折线图,并不需要复杂的多轴金融图表),该库的内部实现结构也显得有些冗余。此外,许可方面的问题也给我们的开发工作带来了麻烦。
后来我们改用了uPlot。这个图表库体积小、运行速度快,而且它是专门为处理时间序列数据而设计的。使用它之后,内存使用量大幅下降,每帧的渲染时间也从“偶尔超出预算”变成了“始终在预算范围内”;我的电脑运行起来也不再那么吃力了。仅仅更换这个图表库,就为我们解决了大部分性能问题。
实际上,整个系统的架构设计也起到了关键作用。我们的处理流程是由四个线程共同完成的:
一个数据采集线程通过IPC机制从设备SDK中读取数据。
SharedArrayBuffer用于存储这19个通道的所有数据。一个渲染线程从共享缓冲区中读取数据,并将结果绘制到
OffscreenCanvas上。主线程负责显示通道标签、控制界面以及帧率计量器。
共享缓冲区的具体存储方式是一个大的Float32Array,其索引格式为[channel * samplesPerChannel + sampleIndex]。数据采集线程会不断向缓冲区中写入新数据,并更新每个通道的数据指针(这些指针存储在Int32Array中)。渲染线程则会在每一帧中读取最新的数据来进行绘制。
const CHANNELS = 19;
const WINDOW_samples = 60_000;
const sab = new SharedArrayBuffer(CHANNELS * WINDOW_SAMPLES * 4 + CHANNELS * 4);
const heads = new Int32Array(sab, 0, CHANNELS);
const samples = new Float32Array(sab, CHANNELS * 4, CHANNELS * WINDOWSAMPLES);
function writeSample(channel: number, value: number) {
const head = Atomics.load(heads, channel);
samples[channel * WINDOWSAMPLES + head] = value;
Atomics.store(heads, channel, (head + 1) % WINDOWSAMPLES);
}
渲染器会读取这些通道缓冲区数据,对每列像素数据进行压缩处理,然后进行绘制:
// render.worker.ts
function drawFrame() {
const ctx = offscreenCtx;
ctx.clear(0, 0, ctx.canvas.width, ctx.canvas.height);
for (let ch = 0; ch < CHANNELS; ch++) {
const start = ch * WINDOWSAMPLES;
drawDecimatedRow(ctx, samples.subarray(start, start + WINDOWSAMPLES), ch);
}
requestAnimationFrame(drawFrame);
}
在2024款M3型号的MacBook Pro上,使用19个通道、采样频率为1kHz的情况下,该技术能够实现60帧每秒的渲染速度;如果显示设备支持的话,这一数值还可以提升到144帧每秒。主线程的利用率始终保持在1%以下,而辅助线程则利用了多余的处理器核心。用户会感受到,这种效果原本需要通过原生应用程序才能实现。
简而言之,关键在于为内层循环选择合适的工具,并尽量减少其带来的开销。
基准测试:单线程与多线程
以下是在2024款M3型号的MacBook Pro上运行相同任务所得到的测试结果。这些数据仅供参考,并不构成任何承诺。
| 任务类型 | 仅使用主线程 | 使用辅助线程池 | 使用辅助线程池 + 共享内存 | 使用辅助线程池 + 共享内存 + 异步绘图API |
|---|---|---|---|---|
| 解析100MB的二进制文件 | 4.2秒(用户界面冻结) | 1.1秒 | 1.0秒 | 1.0秒 |
| 解码1,000帧视频数据 | 920毫秒 | 280毫秒 | 240毫秒 | 240毫秒 |
| 渲染包含100万个数据的图表 | 24帧每秒 | 24帧每秒 | 28帧每秒 | 60帧每秒 |
| 数据采集任务:4个数据流,采样频率为1000Hz | 22帧每秒 | 38帧每秒 | 55帧每秒 | 60帧每秒 |
| 脑电图检测:19个通道,采样频率为1kHz | 12帧每秒 | 25帧每秒 | 48帧每秒 | 60帧每秒(最高可达144帧每秒) |
| 主线程处理每帧数据所需时间 | 22毫秒 | 8毫秒 | 4毫秒 | <1毫秒 |
| 内存占用情况 | 基准值 | 增加50MB | 增加20MB | 增加20MB |
| 辅助线程启动延迟(首次调用时) | 0毫秒 | 2-5毫秒 | 2-5毫秒 | 5-10毫秒 |
从测试结果可以看出:单独使用辅助线程就已经能够提升性能,而当辅助线程与共享内存结合使用时,效果会更加显著;如果再加入异步绘图API,就能实现“主线程几乎不参与计算,但图表渲染效果依然流畅”的理想状态。
对于那些需要处理大量图表数据的应用程序来说,从仅使用辅助线程转变为同时使用辅助线程和异步绘图API,是无需离开浏览器环境即可实现的最佳性能优化方案。
COOP/COEP的相关限制措施
在“Spectre/Meltdown”安全漏洞被发现后,2020年开发者们对`SharedArrayBuffer`以及高精度计时功能进行了相应的调整。要使用这些功能,你的网页必须通过以下两个HTTP头部信息进行配置:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
这样配置后,你的网页就会进入“跨源隔离”状态。在这种隔离环境下,`SharedArrayBuffer`能够正常使用,`performance.now()`也能提供高精度的时间值,其他一些受限制的API也会正常工作。
然而,在非隔离环境下,`SharedArrayBuffer`会变得不可用,`performance.now()`的精度也会降低到大约1毫秒,某些涉及原子操作的功能也会出现错误。
这种限制带来的代价是相当大的。由于必须设置`Cross-Origin-Embedder-Policy: require-corp`,因此所有跨源资源(比如来自CDN的图片、嵌入的YouTube视频、第三方字体以及分析脚本等)都必须明确地进行相应的配置。很多第三方服务并没有遵循这一规定,因此导致它们的内容无法在你的应用中正常显示。
针对这种情况,主要有两种可行的解决方案:
对于Electron应用程序:可以通过配置渲染进程来轻松使用这些功能。大多数用于生产环境的Electron应用程序都会默认启用这些功能,以便实现实时可视化效果。
对于浏览器应用程序:你需要权衡因使用这些功能而可能丢失的某些功能与因此获得的性能提升之间的得失。如果你的应用本身就是用户关注的重点(比如Figma或Google Docs),那么建议启用这些功能;但如果你的应用依赖于第三方插件,那么这些限制措施就会带来实际的麻烦。
对于那些需要使用`SharedArrayBuffer`或高精度计时功能的图表密集型应用程序来说,采用Electron架构通常会是更合适的选择。
生产环境中的权衡
以下是上述所有限制措施所带来的实际代价:
代码复杂性:使用多线程架构的应用程序,其源文件数量通常是单线程应用程序的2到4倍(包括主线程代码、工作线程代码以及共享资源相关代码)。对于规模较大的应用来说,这种架构是值得采用的;但对于小型应用程序而言,它反而会增加开发难度。
调试难度:多线程应用程序中的错误堆栈信息会分散在不同的线程中。虽然Chrome DevTools在2026年已经能够很好地处理这类问题(每个工作线程都有独立的调试面板),但相比单线程应用程序,调试过程仍然会更加繁琐。
打包文件大小:每个工作线程都会生成单独的打包文件。由于工作线程内部的代码结构可能不如主线程那么规范,因此在进行代码压缩时,工作线程相关的文件可能会占用更多的空间。
应用启动延迟:在应用程序启动时,需要为每个工作线程分配资源,这会导致应用程序的启动时间增加50到200毫秒。可以在启动界面期间预先加载这些工作线程,以减少启动延迟;否则,用户就会看到第一帧画面出现延迟。
浏览器API的局限性:在工作线程中,`localStorage`、`document`以及大多数DOM API都是不可用的。一些第三方库可能会隐式地依赖这些API,从而导致程序出错。在将某个库打包到应用程序中之前,一定要先在工作线程环境下测试它是否能够正常使用。
对于采用命令式渲染模式的应用程序来说,还存在以下三个具体的权衡因素:
数据的表现方式应保持声明性: 图表框架、标签和控制元素都应保持声明性的设计风格,而图表中的数据才是真正需要重点处理的部分。
可以轻松地对渲染结果进行快照测试: Canvas没有可供查询的DOM结构,因此可以单独测试数据路径和绘制路径。应该对存储的结果进行快照检测,而不是检测像素信息。
对于内部循环来说,React组件的设计思路同样适用: 绘制循环实际上是一种闭包函数,组合这样的循环比组合组件更加复杂。因此需要仔细界定每个组件的职责范围,确保每个Canvas只负责完成一项特定的任务。
对于大多数应用程序而言,这些额外的开销并不值得投入。但对于那些需要以每秒1000次的速度流畅地渲染数据的应用来说,这些技术却是必不可少的。
你应该这样进行开发吗?
如果你的数据更新频率低于每秒30次,那么这些技术根本不需要。直接使用朴素的`setState`方法按批次处理数据即可。先分析性能瓶颈,再考虑优化措施。
只有在下述情况下,这种架构才会体现出其价值:
当你拥有大量的数据流或传感器时
数据输入是持续进行的,而不是偶尔发生的
用户期望系统能够提供连续数小时流畅的性能,而不仅仅是几秒钟而已
你并不愿意为了实现这些目标而重新用原生语言来编写用户界面代码
一个简单的原则是:首先使用`requestAnimationFrame`来合并数据更新,并使用外部存储机制来保存数据。当这些方法还不够时,再考虑使用Web Workers;如果Web Workers成为了性能瓶颈,那么就可以使用共享内存;而当绘制循环本身成为性能障碍时,就可以使用`OffscreenCanvas`。每一步都是对架构的实质性改进,请按照这个顺序逐步实施。
总结
只要正确使用React,它完全能够胜任实时可视化任务的处理。不要让React承担所有工作,而应该让它担任指挥者的角色,让专门的库和Web Workers来承担繁重的工作。
在我构建过的每一个高频运行的React应用中,以下三条原则都是不可或缺的:
让Web Workers来处理繁重的工作,主线程则负责用户界面的渲染。如果主线程还在进行计算操作,那就说明你的设计出现了问题。
如果可能的话,尽量进行数据传输;如果必须共享数据,那么也要选择最合适的方式。这两种方法都比直接复制数据更有效率,而共享数据通常会比传输数据更加复杂一些。
让React来协调各个环节的工作,让专门的工具来负责数据的渲染。数据存储由存储机制负责管理,数值的计算由绘制循环来完成,而整体的界面呈现效果则由React来控制。
只要遵循这些原则,你的React应用就不会再是一个在高频率数据处理时表现不佳的单核系统了。它将会成为一个真正能够利用多核硬件优势、能够持续高效地处理数据、并且渲染过程也不会出现卡顿现象的多核系统。
这些多核硬件的能力其实就在那里,只要合理利用它们即可。
参考资料
uPlot:一个体积小、运行速度快、专门用于处理时间序列数据的库。
TimeChart:基于WebGL的高性能实时图表库。
在React中使用Web Workers:MDN提供的参考资料。
MDN关于SharedArrayBuffer的介绍:这种共享内存技术的相关信息。
MDN关于OffscreenCanvas的说明:如何在主线程之外进行渲染操作。
COOP和COEP的解释:跨源隔离机制能为你带来什么好处。
相关文章
如何在现代API中实施“以隐私保护为导向的设计理念”——开发人员的实用指南
作为软件开发人员,我们通常被教导要优先考虑速度、性能和正常运行时间等因素。在构建API时,我们的核心目标就是确保数据能够顺利地从A点传输到B点。 然而,全球范围内的数据隐私法规正在日益严格,用户也越来越关注自己的数字足迹。将隐私问题视为“事后才需要处理的法律事项”,或者认为可以在生产环境中再解决这些问题,已经不再是一种可持续的做法。 这时,《设计即隐私》这一理念就派上了用场。 “设计即隐私”是一个框架,旨在将隐私保护措施主动融入到工程开发的整个生命周期中。这意味着,你的系统架构应该默认就能保护用户数据。 在这份全面的指南中,我们将通过现代的工程模式、代码设计理念以及有针对性的数据库结构,来讲解
阅读全文
为什么你的可穿戴设备需要收集数周的数据后才能真正发挥作用?
我还能清楚地记得第一次戴上Oura戒指,第二天早上查看自己的状态得分时的情景——那一刻,我觉得自己就像在查看考试成绩一样。 我看到得分大概是62分,顿时慌了神:难道是我生病了吗?还是压力太大了?又或者是睡眠姿势不对导致的? 但实际上,这些根本无关紧要,因为这款戒指根本不了解我的具体情况。它所掌握的关于我的信息仅仅是一晚上的数据而已。而我却把这些数据当成了绝对可靠的依据。 对于那些购买了智能手表或智能手环,却发现它们无法立刻了解自己的健康状况而感到失望的人来说,这款产品非常适合你们。事实上,在使用的第一周内,你们的可穿戴设备并没有出现故障,只是它还没有真正掌握你的健康数据而已。 目录 使用初期的
阅读全文
如何使用JavaScript构建基于浏览器的PDF过滤工具开发环境
PDF编辑并不仅限于添加签名或合并文档。有时,你只是想通过增加亮度、提升对比度、添加模糊效果、将其转换为灰度图,或者应用一些创意视觉效果来改善PDF的外观——而这一切都不需要打开Photoshop或安装任何桌面软件。 在这个教程中,你将使用JavaScript、PDF.js、Canvas API以及PDF-lib构建一个基于浏览器的PDF过滤器工具。用户可以上传PDF文件,预览每一页,叠加多个过滤层,应用预设效果,处理选定的页面,预览最终结果,重新命名文件,然后直接从浏览器中下载编辑后的PDF。 由于所有操作都在用户的设备上本地进行,因此上传的PDF文件永远不会离开用户的设备,这使得这个工具既
阅读全文
Netflix是如何扩展其实时服务架构的
Netflix详细说明了自己是如何重新设计其实时服务依赖关系映射系统“Service Topology”,以便使其能够支持大规模生产环境的。该系统通过三个阶段来区分数据的中转处理、内容丰富化处理以及持久化存储操作;它将产生的压力反馈给Kafka系统,而不是直接丢弃这些数据;同时,在进行大量内部数据传输时,它使用服务器发送的事件机制,而非gRPC协议。 作者:Eran Stiller
阅读全文