看完这篇文章,你将系统性地掌握如何运用现代前端技术栈,从零开始构建一个高性能的数据可视化大屏项目。我们将涉及全局状态管理、函数缓存、Canvas动画、大规模数据分片渲染以及自动化部署等核心知识。线上 Demo 地址:https://lxfu1.github.io/large-screen-visualization/。

你将收获:
- 全局状态管理新思路:5分钟上手,告别繁琐的状态提升与传递
- 高性能函数缓存:入参不变时,直接返回缓存结果,避免重复计算
- 千万级节点渲染优化:通过分片渲染技术,实现流畅的页面交互
- 前端项目单元测试:搭建测试环境,编写有效的测试用例
- Canvas 实战技巧:绘制复杂图表与实现平滑动画
- 自动化部署流程:利用 GitHub Actions 轻松部署静态网站
实现
项目基于 Create React App --template typescript 搭建,包管理工具使用 pnpm。pnpm 以其安装速度快和节省磁盘空间而著称。由于项目中限制了特定包的版本(最新版本的 @antv/G6 可能导致内存溢出,官方预计会修复),如果你使用 yarn 或 npm,请将配置项改为对应的 resolutions。
pnpm 配置:
"pnpm": {
"overrides": {
"@antv/g6": "4.7.10"
}
}
npm/yarn 配置:
"resolutions": {
"@antv/g6": "4.7.10"
},
启动
- 克隆项目
git clone https://github.com/lxfu1/large-screen-visualization.git
- 安装 pnpm
npm install -g pnpm
- 启动项目:运行
pnpm start 即可。建议配置别名以简化命令,例如 alias p=pnpm,之后可使用 p start。正常情况下,你可以通过 http://localhost:3000/ 访问应用。
- 运行测试:
p test
- 构建项目:
p build
强烈建议先克隆项目,边操作边理解!
核心技术点分析
全局状态管理:Valtio
项目使用 Valtio 进行全局状态管理,相关代码位于 src/models 目录。Valtio 提供了数据与视图分离的心智模型,让你无需在 React 组件或 Hooks 中纠结于 useState、useReducer,也无需在 useEffect 中处理初始化请求,或烦恼该用 Context 还是 Props 传递数据。
优点:心智模型简单,代码清晰。
缺点:基于 Proxy 实现,对低版本浏览器(如 IE)不友好。不过,数据可视化大屏通常无需考虑此类浏览器。
状态定义示例:
import { proxy } from “valtio”;
import { NodeConfig } from “@ant-design/graphs”;
type IState = {
sliderWidth: number;
sliderHeight: number;
selected: NodeConfig | null;
};
export const state: IState = proxy({
sliderWidth: 0,
sliderHeight: 0,
selected: null,
});
状态更新:
import { state } from “src/models”;
state.selected = e.item?.getModel() as NodeConfig;
状态消费:
import { useSnapshot } from “valtio”;
import { state } from “src/models”;
export const BarComponent = () => {
const snap = useSnapshot(state);
console.log(snap.selected)
}
当选中图谱节点时,由于 BarComponent 组件订阅了 selected 状态,该组件会自动更新。是不是非常简单?更多高级用法建议查阅官方文档。
函数缓存:提升重复计算性能
为什么需要函数缓存?设想一个计算量巨大的函数,其所在的组件又有多个状态频繁更新,如何确保该函数不被重复调用?你可能会想到 useMemo、useCallback 等 React Hooks。这里我们介绍一种借鉴 React 内部 cache 方法的实现(React 官方尚未暴露此 API)。
其核心原理是:通过被缓存的函数 fn 及其参数列表构建一个 cacheNode 链表,基于链表最后一项的状态来存储函数 fn 与对应参数的计算结果。
代码位于 src/utils/cache:
interface CacheNode {
/**
* 节点状态
* - 0:未执行
* - 1:已执行
* - 2:出错
*/
s: 0 | 1 | 2;
// 缓存值
v: unknown;
// 特殊类型(object,fn),使用 weakMap 存储,避免内存泄露
o: WeakMap<Function | object, CacheNode> | null;
// 基本类型
p: Map<Function | object, CacheNode> | null;
}
const cacheContainer = new WeakMap<Function, CacheNode>();
export const cache = (fn: Function): Function => {
const UNTERMINATED = 0;
const TERMINATED = 1;
const ERRORED = 2;
const createCacheNode = (): CacheNode => {
return {
s: UNTERMINATED,
v: undefined,
o: null,
p: null,
};
};
return function () {
let cacheNode = cacheContainer.get(fn);
if (!cacheNode) {
cacheNode = createCacheNode();
cacheContainer.set(fn, cacheNode);
}
for (let i = 0; i < arguments.length; i++) {
const arg = arguments[i];
// 使用 weakMap 存储,避免内存泄露
if (
typeof arg === “function” ||
(typeof arg === “object” && arg !== null)
) {
let objectCache: CacheNode[“o”] = cacheNode.o;
if (objectCache === null) {
objectCache = cacheNode.o = new WeakMap();
}
let objectNode = objectCache.get(arg);
if (objectNode === undefined) {
cacheNode = createCacheNode();
objectCache.set(arg, cacheNode);
} else {
cacheNode = objectNode;
}
} else {
let primitiveCache: CacheNode[“p”] = cacheNode.p;
if (primitiveCache === null) {
primitiveCache = cacheNode.p = new Map();
}
let primitiveNode = primitiveCache.get(arg);
if (primitiveNode === undefined) {
cacheNode = createCacheNode();
primitiveCache.set(arg, cacheNode);
} else {
cacheNode = primitiveNode;
}
}
}
if (cacheNode.s === TERMINATED) return cacheNode.v;
if (cacheNode.s === ERRORED) {
throw cacheNode.v;
}
try {
const res = fn.apply(null, arguments as any);
cacheNode.v = res;
cacheNode.s = TERMINATED;
return res;
} catch (err) {
cacheNode.v = err;
cacheNode.s = ERRORED;
throw err;
}
};
};
如何验证其效果?可以查看位于 src/__tests__/utils/cache.test.ts 的单元测试:
import { cache } from “src/utils”;
describe(“cache”, () => {
const primitivefn = jest.fn((a, b, c) => {
return a + b + c;
});
it(“primitive”, () => {
const cacheFn = cache(primitivefn);
const res1 = cacheFn(1, 2, 3);
const res2 = cacheFn(1, 2, 3);
expect(res1).toBe(res2);
expect(primitivefn).toBeCalledTimes(1);
});
});
从测试可以看出,尽管调用了两次 cacheFn,但由于入参相同,原始函数 primitivefn 仅执行了一次,第二次调用直接返回了缓存的结果。
项目中,在实现圆形背景动画时使用了缓存。因为该动画是绕圆周无限循环的,循环一周后,后续的计算与之前完全一致,无需重复计算坐标。相关代码位于 src/components/background/index.tsx:
const cacheGetPoint = cache(getPoint);
let p = 0;
const animate = () => {
if (p >= 1) p = 0;
const { x, y } = cacheGetPoint(p);
ctx.clearRect(0, 0, 2 * clearR, 2 * clearR);
createCircle(aCtx, x, y, circleR, “#fff”, 6);
p += 0.001;
requestAnimationFrame(animate);
};
animate();
分片渲染:应对海量数据绘制
你检查过页面元素吗?项目的背景图并非静态图片,而是通过 Canvas 实时绘制的!用 Canvas 绘制数量庞大的小圆点,会阻塞页面操作吗?当数据量极大时,确实会。你可以尝试将 NodeMargin 设置为 0.1,并移除调度器,改为同步绘制。当节点数量达到 500 万时,若不开启分片,在 MacBook Pro M1 上页面白屏时间约为 8.5 秒;而开启分片渲染后,页面不会白屏,背景图会从左到右逐步绘制,每个分片任务的执行时间控制在 16ms 左右。

分片调度器核心代码:
const schduler = (tasks: Function[]) => {
const DEFAULT_RUNTIME = 16;
const { port1, port2 } = new MessageChannel();
let isAbort = false;
const promise: Promise<any> = new Promise((resolve, reject) => {
const runner = () => {
const preTime = performance.now();
if (isAbort) {
return reject();
}
do {
if (tasks.length === 0) {
return resolve([]);
}
const task = tasks.shift();
task?.();
} while (performance.now() - preTime < DEFAULT_RUNTIME);
port2.postMessage(“”);
};
port1.onmessage = () => {
runner();
};
});
// @ts-ignore
promise.abort = () => {
isAbort = true;
};
port2.postMessage(“”);
return promise;
};
分片渲染的优势在于不阻塞用户操作,但会延长任务的总执行时间。是否启用需权衡数据量。需注意,如果单个分片的实际执行时间超过 16ms,仍会造成阻塞,并且任务会堆积,可能导致最终渲染状态与预期不符,因此合理拆分任务至关重要。
单元测试环境搭建
关于单测,这里不做深入展开。你可以运行 pnpm test 查看效果,测试环境已预先配置好。由于项目使用了 Canvas,需要模拟(Mock)浏览器环境。可以这样理解:我们的前端代码依赖浏览器环境及 API,但单测并非在真实浏览器中运行,因此需要模拟这些环境。项目中使用了 jsdom、jest-canvas-mock 来模拟 window、Canvas 及 Worker 等。更多细节推荐直接查阅 Jest 官网。
环境 Mock 示例 (src/setupTests.ts):
// jest-dom adds custom jest matchers for asserting on DOM nodes.
import “@testing-library/jest-dom”;
Object.defineProperty(URL, “createObjectURL”, {
writable: true,
value: jest.fn(),
});
class Worker {
onmessage: () => void;
url: string;
constructor(stringUrl) {
this.url = stringUrl;
this.onmessage = () => {};
}
postMessage() {
this.onmessage();
}
terminate() {}
onmessageerror() {}
addEventListener() {}
removeEventListener() {}
dispatchEvent(): boolean {
return true;
}
onerror() {}
}
window.Worker = Worker;
自动化部署:GitHub Pages + Actions
对于前端项目,最终都需要部署。目前流行的是前后端分离,前端独立部署。对于不依赖后端的纯静态项目,我们可以借助 GitHub 提供的 GitHub Pages 服务实现自动化部署,CI/CD 只需配置对应的 Actions 工作流即可。在仓库的 Settings -> Pages 页面,选择承载页面的分支(如 gh-pages)即可完成部署。

例如,项目中的 .github/workflows/gh-pages.yml 文件定义了:当 master 分支有代码推送时,会自动执行构建任务,并借助 peaceiris/actions-gh-pages@v3 将构建产物同步到 gh-pages 分支。
name: github pages
on:
push:
branches:
- master # default branch
env:
CI: false
PUBLIC_URL: ‘/large-screen-visualization’
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: yarn
- run: yarn build
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./build
希望这篇结合了 React 状态管理、Canvas 绘图、性能优化与 开源实战 部署的详细指南,能帮助你高效构建属于自己的数据可视化大屏应用。如果你在实践过程中有更多心得,欢迎在 云栈社区 与更多开发者交流探讨。