原生 canvas 如何實(shí)現(xiàn)大屏?

前言
可視化大屏該如何做?有可能一天完成嗎?廢話不多說(shuō),直接看效果,線上 Demo 地址 lxfu1.github.io/large-scree…。

看完這篇文章(這個(gè)項(xiàng)目),你將收獲:

全局狀態(tài)真的很簡(jiǎn)單,你只需 5 分鐘就能上手
如何緩存函數(shù),當(dāng)入?yún)⒉蛔儠r(shí),直接使用緩存值
千萬(wàn)節(jié)點(diǎn)的圖如何分片渲染,不卡頓頁(yè)面操作
項(xiàng)目單測(cè)該如何寫?
如何用 canvas 繪制各種圖表,如何實(shí)現(xiàn) canvas 動(dòng)畫(huà)
如何自動(dòng)化部署自己的大屏網(wǎng)站
實(shí)現(xiàn)
項(xiàng)目基于 Create React App --template typescript搭建,包管理工具使用的 pnpm ,pnpm 的優(yōu)勢(shì)這里不多介紹(快+節(jié)省磁盤空間),之前在其它平臺(tái)寫過(guò)相關(guān)文章,后續(xù)可能會(huì)搬過(guò)來(lái)。由于項(xiàng)目 package.json 里面有限制包版本(最新版本的 G6 會(huì)導(dǎo)致 OOM,官方短時(shí)間能應(yīng)該會(huì)修復(fù)),如果使用的 yarn 或 npm 的話,改為對(duì)應(yīng)的 resolutions 即可。

perl
復(fù)制代碼 "pnpm": {
    "overrides": {
      "@antv/g6": "4.7.10"
    }
  }
perl
復(fù)制代碼"resolutions": {
  "@antv/g6": "4.7.10"
},
啟動(dòng)
clone項(xiàng)目
bash
復(fù)制代碼git clone https://github.com/lxfu1/large-screen-visualization.git
pnpm 安裝 npm install -g pnpm
啟動(dòng):pnpm start 即可,建議配置 alias ,可以簡(jiǎn)化各種命令的簡(jiǎn)寫 eg:p start,不出意外的話,你可以通過(guò) http://localhost:3000/ 訪問(wèn)了
測(cè)試:p test
構(gòu)建:p build
強(qiáng)烈建議大家先 clone 項(xiàng)目!

分析
全局狀態(tài)
全局狀態(tài)用的 valtio ,位于項(xiàng)目 src/models目錄下,強(qiáng)烈推薦。

優(yōu)點(diǎn):數(shù)據(jù)與視圖分離的心智模型,不再需要在 React 組件或 hooks 里用 useState 和 useReducer 定義數(shù)據(jù),或者在 useEffect 里發(fā)送初始化請(qǐng)求,或者考慮用 context 還是 props 傳遞數(shù)據(jù)。

缺點(diǎn):兼容性,基于 proxy 開(kāi)發(fā),對(duì)低版本瀏覽器不友好,當(dāng)然,大屏應(yīng)該也不會(huì)考慮 IE 這類瀏覽器。

typescript
復(fù)制代碼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,
});
狀態(tài)更新:

ini
復(fù)制代碼import { state } from "src/models";

state.selected = e.item?.getModel() as NodeConfig;
狀態(tài)消費(fèi):

javascript
復(fù)制代碼import { useSnapshot } from "valtio";
import { state } from "src/models";

export const BarComponent = () => {
  const snap = useSnapshot(state);

  console.log(snap.selected)
}
當(dāng)我們選中圖譜節(jié)點(diǎn)的時(shí)候,由于 BarComponent 組件監(jiān)聽(tīng)了 selected 狀態(tài),所以該組件會(huì)進(jìn)行更新。有沒(méi)有感覺(jué)非常簡(jiǎn)單?一些高級(jí)用法建議大家去官網(wǎng)查看,不再展開(kāi)。

函數(shù)緩存
為什么需要函數(shù)緩存?當(dāng)然,在這個(gè)項(xiàng)目中函數(shù)緩存比較雞肋,為了用而用,試想,如果有一個(gè)函數(shù)計(jì)算量非常大,組件內(nèi)又有多個(gè) state 頻繁更新,怎么確保函數(shù)不被重復(fù)調(diào)用呢?可能大家會(huì)想到 useMemo``useCallback等手段,這里要介紹的是 React 官方的 cache 方法,已經(jīng)在 React 內(nèi)部使用,但未暴露。實(shí)現(xiàn)上借鑒(抄襲)ReactCache,通過(guò)緩存的函數(shù) fn 及其參數(shù)列表來(lái)構(gòu)建一個(gè) cacheNode 鏈表,然后基于鏈表最后一項(xiàng)的狀態(tài)來(lái)作為函數(shù) fn 與該組參數(shù)的計(jì)算緩存結(jié)果。

代碼位于 src/utils/cache

ini
復(fù)制代碼interface CacheNode {
  /**
   * 節(jié)點(diǎn)狀態(tài)
   *  - 0:未執(zhí)行
   *  - 1:已執(zhí)行
   *  - 2:出錯(cuò)
   */
  s: 0 | 1 | 2;
  // 緩存值
  v: unknown;
  // 特殊類型(object,fn),使用 weakMap 存儲(chǔ),避免內(nèi)存泄露
  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 存儲(chǔ),避免內(nèi)存泄露
      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;
    }
  };
};
如何驗(yàn)證呢?我們可以簡(jiǎn)單看下單測(cè),位于src/__tests__/utils/cache.test.ts:

ini
復(fù)制代碼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);
  });
});
可以看出,即使我們調(diào)用了 2 次 cacheFn,由于入?yún)⒉蛔?,fn 只被執(zhí)行了一次,第二次直接返回了第一次的結(jié)果。

項(xiàng)目里面在做 circle 動(dòng)畫(huà)的時(shí)候使用了,因?yàn)樵搫?dòng)畫(huà)是繞圓周無(wú)限循環(huán)的,當(dāng)循環(huán)過(guò)一周之后,后的動(dòng)畫(huà)和之前的完全一致,沒(méi)必要再次計(jì)算對(duì)應(yīng)的 circle 坐標(biāo),所以我們使用了 cache ,位于src/components/background/index.tsx。

ini
復(fù)制代碼  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();
分片渲染
你有審查元素嗎?項(xiàng)目背景圖是通過(guò) canvas 繪制的,并不是背景圖片!通過(guò) canvas 繪制如此多的小圓點(diǎn),會(huì)不會(huì)阻礙頁(yè)面操作呢?當(dāng)數(shù)據(jù)量足夠大的時(shí)候,是會(huì)阻礙的,大家可以把 NodeMargin 設(shè)置為 0.1 ,同時(shí)把 schduler 調(diào)用去掉,直接改為同步繪制。當(dāng)節(jié)點(diǎn)數(shù)量在 500 W 的時(shí)候,如果沒(méi)有開(kāi)啟切片,頁(yè)面白屏?xí)r間在 MacBook Pro M1 上白屏?xí)r間大概是 8.5 S;開(kāi)啟分片渲染時(shí)頁(yè)面不會(huì)出現(xiàn)白屏,而是從左到右逐步繪制背景圖,每個(gè)任務(wù)的執(zhí)行時(shí)間在 16S 左右波動(dòng)。

248820bk-2.jpg

ini
復(fù)制代碼  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;
  };
分片渲染可以不阻礙用戶操作,但延遲了任務(wù)的整體時(shí)長(zhǎng),是否開(kāi)啟還是取決于數(shù)據(jù)量。如果每個(gè)分片實(shí)際執(zhí)行時(shí)間大于 16ms 也會(huì)造成阻塞,并且會(huì)堆積,并且任務(wù)執(zhí)行的時(shí)候沒(méi)有等,最終渲染狀態(tài)和預(yù)期不一致,所以 task 的拆分也很重要。

單測(cè)
這里不想多說(shuō),大家可以運(yùn)行 pnpm test看看效果,環(huán)境已經(jīng)搭建好;由于項(xiàng)目里面用到了 canvas 所以需要 mock 一些環(huán)境,這里的 mock 可以理解為“我們前端代碼跑在瀏覽器里運(yùn)行,依賴了瀏覽器環(huán)境以及對(duì)應(yīng)的 API,但由于單測(cè)沒(méi)有跑在瀏覽器里面,所以需要 mock 瀏覽器環(huán)境”,例如項(xiàng)目里面設(shè)置的 jsdom、jest-canvas-mock 以及 worker 等,更多推薦直接訪問(wèn) jest 官網(wǎng)。

typescript
復(fù)制代碼// 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;
自動(dòng)化部署
開(kāi)發(fā)過(guò)項(xiàng)目的同學(xué)都知道,前端編寫的代碼最終是要進(jìn)行部署的,目前比較流行的是前后端分離,前端獨(dú)立部署,通過(guò) proxy 的方式請(qǐng)求后端服務(wù);或者是將前端構(gòu)建產(chǎn)物推到后端服務(wù)上,和后端一起部署。如何做自動(dòng)化部署呢,對(duì)于一些不依賴后端的項(xiàng)目來(lái)說(shuō),我們可以借助 github 提供的 gh-pages 服務(wù)來(lái)做自動(dòng)化部署,CI、CD 僅需配置對(duì)應(yīng)的 actions 即可,在倉(cāng)庫(kù) settings/pages 下面選擇對(duì)應(yīng)分支即可完成部署。

248820bk-3.jpg

例如項(xiàng)目里面的.github/workflows/gh-pages.yml,表示當(dāng) master 分支有代碼提交時(shí),會(huì)執(zhí)行對(duì)應(yīng)的 jobs,并借助 peaceiris/actions-gh-pages@v3將構(gòu)建產(chǎn)物同步到 gh-pages 分支。

yaml
復(fù)制代碼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
總結(jié)
寫文檔不易,如果看完有收獲,記得給個(gè)小星星!歡迎大家 PR!

Ant Design Charts
[示例倉(cāng)庫(kù)](https://github.com/lxfu1/large-screen-visualization)


作者:小丑依然是我原文:
https://juejin.cn/post/7165564571128692773



作者:小丑依然是我


歡迎關(guān)注微信公眾號(hào) :深圳灣碼農(nóng)