原生 canvas 如何实现大屏?
创始人
2024-01-29 22:31:07
0

前言

可视化大屏该如何做?有可能一天完成吗?废话不多说,直接看效果,线上 Demo 地址 lxfu1.github.io/large-scree…。

看完这篇文章(这个项目),你将收获:

  1. 全局状态真的很简单,你只需 5 分钟就能上手
  2. 如何缓存函数,当入参不变时,直接使用缓存值
  3. 千万节点的图如何分片渲染,不卡顿页面操作
  4. 项目单测该如何写?
  5. 如何用 canvas 绘制各种图表,如何实现 canvas 动画
  6. 如何自动化部署自己的大屏网站

实现

项目基于 Create React App --template typescript搭建,包管理工具使用的 pnpm ,pnpm 的优势这里不多介绍(快+节省磁盘空间),之前在其它平台写过相关文章,后续可能会搬过来。由于项目 package.json 里面有限制包版本(最新版本的 G6 会导致 OOM,官方短时间能应该会修复),如果使用的 yarn 或 npm 的话,改为对应的 resolutions 即可。

 "pnpm": {"overrides": {"@antv/g6": "4.7.10"}}
复制代码
"resolutions": {"@antv/g6": "4.7.10"
},
复制代码

启动

  1. clone项目
git clone https://github.com/lxfu1/large-screen-visualization.git
复制代码
  1. pnpm 安装 npm install -g pnpm
  2. 启动: pnpm start 即可,建议配置 alias ,可以简化各种命令的简写 eg:p start,不出意外的话,你可以通过 http://localhost:3000/ 访问了
  3. 测试:p test
  4. 构建:p build

强烈建议大家先 clone 项目!

分析

全局状态

全局状态用的 valtio ,位于项目 src/models目录下,强烈推荐。

优点:数据与视图分离的心智模型,不再需要在 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 状态,所以该组件会进行更新。有没有感觉非常简单?一些高级用法建议大家去官网查看,不再展开。

函数缓存

为什么需要函数缓存?当然,在这个项目中函数缓存比较鸡肋,为了用而用,试想,如果有一个函数计算量非常大,组件内又有多个 state 频繁更新,怎么确保函数不被重复调用呢?可能大家会想到 useMemo``useCallback等手段,这里要介绍的是 React 官方的 cache 方法,已经在 React 内部使用,但未暴露。实现上借鉴(抄袭)ReactCache,通过缓存的函数 fn 及其参数列表来构建一个 cacheNode 链表,然后基于链表最后一项的状态来作为函数 fn 与该组参数的计算缓存结果。

代码位于 src/utils/cache

interface CacheNode {/*** 节点状态*  - 0:未执行*  - 1:已执行*  - 2:出错*/s: 0 | 1 | 2;// 缓存值v: unknown;// 特殊类型(object,fn),使用 weakMap 存储,避免内存泄露o: WeakMap | null;// 基本类型p: Map | null;
}const cacheContainer = new WeakMap();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);});
});
复制代码

可以看出,即使我们调用了 2 次 cacheFn,由于入参不变,fn 只被执行了一次,第二次直接返回了第一次的结果。

项目里面在做 circle 动画的时候使用了,因为该动画是绕圆周无限循环的,当循环过一周之后,后的动画和之前的完全一致,没必要再次计算对应的 circle 坐标,所以我们使用了 cache ,位于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 ,同时把 schduler 调用去掉,直接改为同步绘制。当节点数量在 500 W 的时候,如果没有开启切片,页面白屏时间在 MacBook Pro M1 上白屏时间大概是 8.5 S;开启分片渲染时页面不会出现白屏,而是从左到右逐步绘制背景图,每个任务的执行时间在 16S 左右波动。

  const schduler = (tasks: Function[]) => {const DEFAULT_RUNTIME = 16;const { port1, port2 } = new MessageChannel();let isAbort = false;const promise: Promise = 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-ignorepromise.abort = () => {isAbort = true;};port2.postMessage("");return promise;};
复制代码

分片渲染可以不阻碍用户操作,但延迟了任务的整体时长,是否开启还是取决于数据量。如果每个分片实际执行时间大于 16ms 也会造成阻塞,并且会堆积,并且任务执行的时候没有等,最终渲染状态和预期不一致,所以 task 的拆分也很重要。

单测

这里不想多说,大家可以运行 pnpm test看看效果,环境已经搭建好;由于项目里面用到了 canvas 所以需要 mock 一些环境,这里的 mock 可以理解为“我们前端代码跑在浏览器里运行,依赖了浏览器环境以及对应的 API,但由于单测没有跑在浏览器里面,所以需要 mock 浏览器环境”,例如项目里面设置的 jsdom、jest-canvas-mock 以及 worker 等,更多推荐直接访问 jest 官网。

// 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;
复制代码

自动化部署

开发过项目的同学都知道,前端编写的代码最终是要进行部署的,目前比较流行的是前后端分离,前端独立部署,通过 proxy 的方式请求后端服务;或者是将前端构建产物推到后端服务上,和后端一起部署。如何做自动化部署呢,对于一些不依赖后端的项目来说,我们可以借助 github 提供的 gh-pages 服务来做自动化部署,CI、CD 仅需配置对应的 actions 即可,在仓库 settings/pages 下面选择对应分支即可完成部署。

例如项目里面的.github/workflows/gh-pages.yml,表示当 master 分支有代码提交时,会执行对应的 jobs,并借助 peaceiris/actions-gh-pages@v3将构建产物同步到 gh-pages 分支。

name: github pageson:push:branches:- master # default branchenv:CI: falsePUBLIC_URL: '/large-screen-visualization'jobs:deploy:runs-on: ubuntu-lateststeps:- uses: actions/checkout@v3- run: yarn- run: yarn build- name: Deployuses: peaceiris/actions-gh-pages@v3with:github_token: ${{ secrets.GITHUB_TOKEN }}publish_dir: ./build
复制代码

总结

写文档不易,如果看完有收获,记得给个小星星!欢迎大家 PR!

  • Ant Design Charts
  • 示例仓库

相关内容

热门资讯

上海小本自主创业持久项目 上海... 虽然今年的疫情人很多人受到了影响,有人看到了市场经济受影响,有人看到了商机。创业大军每年都有很多,虽...
马云说私人定制是最长久的 而它... 手机微信制作平台照片书照片书就是把您手机里的照片,比如,宝宝的照片,爱人的、家人的、朋友的照片,婚纱...
强烈推荐:2019年适合白手起... 世界上大多数胜利的事例,都是从小做起的,创业也不是一开端就有大资本做后台,小本创业是迈向创业疆场的第...
适合小本创业的项目 穷人告别打... 人不能以挣钱为目的,但是又有谁不是在为挣钱而做着不同的努力呢。上班族整天辛苦的加班,但是工资报酬并不...
五种适合小本创业的项目 五种适... 说起创业,我们应该从小本生意做起,我们看看有哪些最新适合小本创业的项目,让我们一起来看一看,希望每个...
适合小本创业的五个好项目 最适... 二胎政策开放很久了,儿童行业是具有潜力的市场,现在的家长都很疼爱自己的孩子,很注重孩子的早期教育培训...
小本投资创业项目有哪些 *新小... 餐饮粥火锅加盟重庆串串中式快餐品牌小型餐饮加盟小吃加盟铁锅焖面加盟中式快餐店十大咖啡品牌品牌快餐老火...
小本创业投资项目2011年 小... 刚开始投资的时候可以试试小本投资,投资金额的大小决定了很多创业项目风险性,投资潜力,今天先为大家分享...
2011年在河北省小本创业投资... 从目前的创业项目看来,女性创业的项目:服装,小吃餐饮,化妆品等,偏门项目。1、想开服装店,如果是初次...
99年版100元值多少钱 99... 杨静国际项目融资经理,既有项目法人融资“往年高交会的主题‘坚持新开展理念、推进高质量开展’,以及以后...