這裡給大家分享我在網上總結出來的一些知識,希望對大家有所幫助 前言 可視化大屏該如何做?有可能一天完成嗎?廢話不多說,直接看效果,線上 Demo 地址 lxfu1.github.io/large-scree…。 看完這篇文章(這個項目),你將收穫: 全局狀態真的很簡單,你只需 5 分鐘就能上手 如何 ...
這裡給大家分享我在網上總結出來的一些知識,希望對大家有所幫助
前言
可視化大屏該如何做?有可能一天完成嗎?廢話不多說,直接看效果,線上 Demo 地址 lxfu1.github.io/large-scree…。
看完這篇文章(這個項目),你將收穫:
- 全局狀態真的很簡單,你只需 5 分鐘就能上手
- 如何緩存函數,當入參不變時,直接使用緩存值
- 千萬節點的圖如何分片渲染,不卡頓頁面操作
- 項目單測該如何寫?
- 如何用 canvas 繪製各種圖表,如何實現 canvas 動畫
- 如何自動化部署自己的大屏網站
效果
實現
項目基於 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" },
啟動
- clone項目
git clone https://github.com/lxfu1/large-screen-visualization.git
- pnpm 安裝
npm install -g pnpm
- 啟動:
pnpm start
即可,建議配置 alias ,可以簡化各種命令的簡寫 eg:p start
,不出意外的話,你可以通過 http://localhost:3000/ 訪問了 - 測試:
p test
- 構建:
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<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); }); });
可以看出,即使我們調用了 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<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 也會造成阻塞,並且會堆積,並且任務執行的時候沒有等,最終渲染狀態和預期不一致,所以 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 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