React 中的 Canvas 動畫

作者簡介

 

摻水的醬油,攜程軟件技術專家,關注大前端及移動端相關技術。

移動端硬件性能越來越好的今天,頁面的交互也越來越豐富,Web 體驗在不斷向原生應用靠攏,加入了越來越多的手勢與動畫。除了常見的 CSS 動畫外,有時候我們還會使用到 Canvas 或者 SVG 進行動畫內容表現。

由於 React 在平日的開發中依舊擁有不少使用者,分享一個在 React 開發中使用 Canvas 動畫的方法及其性能優化。

一、動畫的基本原理

人的眼睛對圖像有短暫的記憶效應,當眼睛看到多張圖片連續快速切換時,就會認爲是一段連續播放的動畫了,而一秒內切換多少張,便是所說的幀率 (FPS),它也常被用作動畫流暢度的重要指標。雖然幀率是越高越好,但一般達到 30 幀後,便基本可以覺得是流暢的。

日本動漫的手繪(EVA、進擊的巨人等)、粘土動畫或者 3D 渲染等不同創作方式都能製作動畫,但原理都是一樣的。

二、Web 中的動畫

當聊到 Web 的動畫時,我們的第一反應可能是 CSS,通過 CSS 來實現各種各樣的效果——位移、旋轉、透明等等。其實,除了這些特效以外,Web 也有很多其他的動畫手段,其中比較主要的載體便是 SVG、Canvas、WebGL。通過這些載體除了可以實現上述 CSS 的效果以外,還可以實現更復雜的內容(比如遊戲動畫)。

由於有些動畫較爲細膩且複雜,無法通過簡單的位移或變形來實現(例如人物的行走、跳躍),我們便會使用到幀動畫。“幀動畫” 是一種常見的動畫形式,是將某時間軸拆分成若干個連續的關鍵幀,並在的每一幀上分解動畫動作、繪製不同內容,使之連續播放變爲動畫。幀動畫也被稱爲 “序列幀動畫”、“定格動畫”、“逐幀動畫”。

歸結起來,也就是下面這張圖片要表達的意思。

實現幀動畫的手段

實現幀動畫的手段也有很多種,比較常用的有下面三種:

GIF 圖片

CSS

JavaScript

利用 JavaScript 將內容繪製到 Canvas 等載體上,並通過實時計算來決定繪製的圖像、位置、變形、透明度等等,也是本篇的主要介紹方法。

當然,本篇還是着重介紹使用 JavaScript 的方式實現動畫,進而遷移到 React。

三、使用 JavaScript 實現動畫

如果計劃使用 JavaScript 來進行動畫的渲染,基本上都會選用一個渲染框架來將動畫內容渲染,來簡化我們的渲染操作、提高編碼效率,當然也可以直接使用原生 API 來進行操作,不過這樣會比較煩瑣。

JavaScript 的渲染框架有很多,包括 Lottie, Pixijs, Threejs, Createjs, Konva 等等。這些框架本身都很成熟,而且也有針對不同的場景,可以根據業務特點來進行選擇。因爲本文並不牽涉複雜場景,這裏選用比較簡單的 Konva 來作爲示例進行講解,如果讀者有興趣,也可以去了解一下其他的框架。

下面我們通過一些代碼片段來看下如何從一個基本的 Canvas 動畫,逐步的遷移到 React 中,並融合進 react-dom 中。使用 Canvas 來實現動畫的實現並不複雜,可以簡單地用 4 個字來概括:定時重繪

3.1 定時

我們先來看下定時,JavaScript 中可以實現定時的手段有好幾種,優先級排序上:requestAnimationFrame > setTimeout > setInterval,因此我們首選 requestAnimationFrame API。原因主要是在執行優先級上,這部分內容超出本文範圍,有興趣或者不太瞭解的讀者可以自行查閱。

通過定時任務,就可以實現動畫中最基本的 “tick 機制” 了。

function tick() {
    // 繪製動畫內容至載體上
    // 下一幀繼續執行,則調用
    requestAnimationFrame(tick);
};
// 開始執行 tick 邏輯,用於動畫的不間斷繪製
tick();

3.2 JavaScript 位移動畫

下面使用 Konva 實現一個簡單矩形的位移動畫,當 x 軸的移動到 30 時就停止,代碼在每次定時任務觸發時會重新計算矩形的位置,然後對內容進行了重新繪製。Konva 對 Canvas 進行了簡單的封裝,將繪製內容通過對象進行管理,每次繪製前會自動進行清除操作。

const stage = new Konva.Stage({
    container: 'container',
    width: 100,
    height: 100,
});
// 這裏 Layer 是實際的 Canvas 實例
const layer = new Konva.Layer();
stage.add(layer);
let x = 0;
// 創建一個矩形並添加到 Canvas 中
const rect = new Konva.Rect({
    x: x,
    y: 20,
    width: 50,
    height: 50,
    fill: 'red',
});
layer.add(rect);
// 定時更新 rect 這個對象的位置
function tick() {
    x += 1;
    // 更新位置後便重繪
    rect.setAttr('x', x);
    rect.draw();
    if (x > 30) {
        return;
    }
    requestAnimationFrame(tick);
};
tick();

上面的代碼,通過最簡單原始的方式實現了一段 Canvas 的位移動畫。由於我們平時多用 React 進行頁面的渲染,因此希望儘量避免直接使用 JavaScript 操作 DOM 元素構建動畫的容器或內容,更希望把它移植到 React 中。

3.3 React 構建 div 容器

react-dom 本身允許我們繪製各種各樣的 HTML 節點,因此利用 React 來創建畫布的 div 容器,然後用上面相同的代碼邏輯來繪製 Canvas 中的動畫即可。將上面的代碼稍作修改就可以移植到 React 中了,Konva 的 Layer 對象纔是真正的 canvas 畫布,所以代碼中 render 方法返回的是 div 而非 canvas(如果你選用的框架是使用 canvas 元素來進行初始化的,這裏也可以返回 canvas,依據場景決定)。

function createPic(canvasContainer) {
    const width = 100;
    const height = 100;
    // 根據傳入的 canvasContainer 來創建畫布
    const stage = new Konva.Stage({
        container: canvasContainer,
        width: width,
        height: height,
    });
    // 其餘與上面的代碼類同
    return stage;
}
// 使用 React 函數組件方式建立 Canvas 或者對應的容器
function DrawCanvas() {
    const ref = useRef();
    useEffect(() => {
        // 將 div 容器傳入方法創建對應的場景元素
        const stage = createPic(ref.current);
        // 銷燬容器
        return () => {
            stage.destroy();
        }
    }, []);
    return <div ref={ref} />;
}

通過上面的代碼我們已經讓動畫的代碼和 React 結合起來了,不過由於 react-dom 本身並不支持渲染 Konva 中的繪製元素,因此依舊有 2 種風格的代碼存在,一種是 JSX 風格,另一種則是傳統風格,即通過對象的添加與刪除來進行管理。

接下來我們會思考另一個問題——是否能夠將兩種代碼風格合併爲一個?畢竟不同代碼風格維護起來很難受(簡直逼死強迫症),而且 JSX 會更加直觀,更符合現在的編碼習慣。所以剩下的問題就是如何將 Konva 中的 Stage、Layer、Rect 這些對象也通過 JSX 進行管理。

3.4 react-konva

Konva 有提供 React 版本——react-konva,因此我們把上面的代碼改寫下。

import React, { useEffect, useRef, useState } from 'react';
import { Layer, Rect, Stage, Text } from 'react-konva';
import Konva from 'konva';
const Picture = () => {
    // 這裏只是爲了表明這裏 div 和 konva 的 Rect 能同時被繪製,因此加了一層 div 元素
    // 實際可以不需要
    return (
        <div>
            <Stage width={100} height={100}>
                <Layer>
                    <Rect
                        x={0}
                        y={20}
                        width={50}
                        height={50}
                        fill="red"
                    />
                </Layer>
            </Stage>
        </div>
    );
};

看到這裏也許你會有一個疑問,爲什麼 Layer、Rect 這些 Konva 中的對象能被正確解析並繪製到頁面上,react-dom 不是僅能夠渲染 HTML 組件嗎?

3.5 react-konva 源碼解讀

react-konva 的確封裝了一點內容,它實現一個自定義的 Render 來對 JSX 中的這些節點進行解析,最後將節點渲染至 Canvas 中。接下來我們抽取部分 react-konva 來分析下具體的實現(瞭解 React 自定義 Render 的可以跳過這一段)。

首先從系統上來考慮,使用自定義的 Render 來繪製這些圖形節點,必須要同時支持 react-dom 已有的功能,因爲除了圖形節點以外,系統依舊還是需要支持普通的 HTML 元素的現實的,因此 react-konva 選擇基於 react-reconciler 來實現。

react-reconciler 定義了各種操作接口,需要使用方來完成實現,包括創建、更新、移除等一系列操作來控制節點。react-konva 利用這套機制,將 React Element 對象轉化爲了 Konva 中的對象,進行內容的繪製。由於 react-konva 並不打算也不需要負責 react-dom 已有的功能,因此它在代碼中將自己標示爲輔助 Render,這樣就不會影響到 react-dom 的渲染。react-dom 並不會主動同步多個 Render 之間的生命週期,因此我們需要通過在節點的各個生命週期中主動調用來同步 2 個 Render 的生命週期。

不過官方文檔上指出 react-reconciler 相對於其他框架來說本身依舊還不穩定,API 依舊會有所變動,使用起來要記得鎖定版本號。

Its API is not as stable as that of React, React Native, or React DOM, and does not follow the common versioning scheme. Use it at your own risk.

Render 間的生命週期同步

下面是通過函數組件 (Function Component) 實現的自定義 render 與 react-dom 之間的生命週期同步的部分代碼。

import ReactFiberReconciler from 'react-reconciler';
// 這裏的 HostConfig 是接口的具體實現
import * as HostConfig from './ReactKonvaHostConfig';
// 創建自定義的 render
const KonvaRenderer = ReactFiberReconciler(HostConfig);
// Stage 的函數組件
function StageWrap(props) {
  // ...do something
  const container = useRef();
  React.useLayoutEffect(() => {
    // 創建渲染的根節點,傳入的屬性略過
    // 這裏使用 StageWrap 裏返回的 div 作爲 Stage 的容器
    // 相當於在 react-dom 中開啓了第二個 render
    const stage = new Konva.Stage({ container: container.current });
    // 利用自定義創建
    fiberRef.current = KonvaRenderer.createContainer(stage);
    KonvaRenderer.updateContainer(props.children, fiberRef.current);
    // unmount 的時候對 Stage 畫布本身進行銷燬
    return () => {
      KonvaRenderer.updateContainer(null, fiberRef.current, null);
      stage.destroy();
    };
  }, []);
  // 每次 StageWrap 被觸發 componentDidUpdate 時,同時更新內容
  React.useLayoutEffect(() => {
    applyNodeProps(stage.current, props, oldProps);
    KonvaRenderer.updateContainer(props.children, fiberRef.current, null);
  });
  return (
    <div
      ref={container}
    />
  );
}

ReactKonvaHostConfig 操作接口實現及屬性設置

react-reconciler 定義了一系列預定義的接口(即我們上面引用的 HostConfig),用於處理各種場景下對於渲染對象的處理。實現自定義的繪製框架便要對這些預定義的接口進行實現,不過並非所有的方法都必須要有完整的實現,你可以根據自己的需求實現部分功能,所有接口和配置的定義詳見文檔。下面列出幾個比較主要的定義,通過這些定義來看下如何將 React 中的節點轉換爲 Canvas 中實際繪製的內容的。

createInstance: 用於創建顯示的實際節點對象,例如 div、span 等,React 的文本節點不會被傳遞到這裏來,下面看下部分 react-konva 的 HostConfig 實現邏輯。

function createInstance(type, props, rootContainer, hostContext, internalHandle) {
    // 用於創建node的節點,!!!但不可操作本節點以外的內容,包括添加刪除,事件也可以在後續再添加
    // 這裏的type是string,因此可以直接根據type來選擇對應的konva對象
    let NodeClass = Konva[type];
    // 初始化節點的屬性,由於事件不在這個方法內添加,因此從props中濾除
    const propsWithoutEvents = excludeEvts(props);
    // 創建渲染用的對象並返回
    const instance = new NodeClass(propsWithoutEvents);
    return instance;
}

createTextInstance: 用於創建文本節點 (例如 foo),由於文本節點不支持屬性,因此如果你不打算支持這裏直接拋出異常 (throw error) 就好。

function createTextInstance(text, rootContainer, hostContext, internalHandle) {
  console.error(
    `Text components are not supported for now in ReactKonva. Your text is: "${text}"`
  );
}

appendInitialChild、appendChild、appendChildToContainer、insertBefore、insertInContainerBefore、removeChild、removeChildFromContainer: 對 createInstance 中創建出來的對象進行 增 / 刪 / 改 操作,以 appendInitialChild 舉例。

function appendInitialChild(parentInstance, child) {
  // 這邊做了一個額外的判斷,如果是字符串類型的子節點,則不支持
  //  (eg <Text>foo</Text>)
  if (typeof child === 'string') {
    console.error(
      `Do not use plain text as child of Konva.Node. You are using text: ${child}`
    );
    return;
  }
  // 節點添加
  parentInstance.add(child);
}

commitUpdate: 當 React 的屬性更新以後,這個方法便會被調用用於更新渲染對象中的屬性。

function commitUpdate(instance, updatePayload, type, prevProps, nextProps, internalHandle) {
    // 對比新舊屬性,並賦值屬性到渲染對象上
    applyNodeProps(instance, newProps, oldProps);
}

isPrimaryRenderer: 是否將自己作爲主 Render,這裏設置爲 false,便可以使自己作爲輔助 Render。

const isPrimaryRenderer = false;

React 的位移動畫

通過上面自定義的 Render 我們已經能夠將圖形繪製到畫布上了,最後我們把定時更新部分加上就可以了,這樣便完成我們的動畫了。由於是在組件內部開始的定時器,因此要記得中斷。

const Picture = () => {
    const updateRef = useRef();
    const xRef = useRef(0);
    const [x, setX] = useState(0);
    updateRef.current = setX;
    // 創建 tick 函數,進行動態更新,只需要執行一次就可以了
    useEffect(() => {
        let id;
        const tick = () => {
            // 更新矩形的位置
            xRef.current += 1;
            updateRef.current(xRef.current);
            if (xRef.current > 30) {
                return;
            }
            id = requestAnimationFrame(tick);
        };
        tick();
        return () => {
            cancelAnimationFrame(id);
        };
    }, []);
    return (
        <div>
            <Stage width={100} height={100}>
                <Layer>
                    <Rect
                        x={x}
                        y={20}
                        width={50}
                        height={50}
                        fill="red"
                    />
                </Layer>
            </Stage>
        </div>
    );
};

四、優化

4.1 問題

敏感的同學們應該已經注意到了,定時器每次在執行時,都會不斷通過 setState 來進行屬性的變更,這樣勢必會導致性能上較大的損耗,導致我們的動畫看起來很卡。可以看下下面這張圖,每次更新操作都會導致一系列的方法調用,整體的消耗很大。

因此爲了避免這個問題,我們需要對整體的實現進行重新思考。

4.2 渲染優化

我們在 Web 頁面上會選擇使用 React 來進行繪製時,一般都屬於 HTML 部分與 Canvas 互動較多,或者動畫本身並不複雜,雖然每一幀的內容都需要重新對元素屬性進行計算,但其實需要引起樹結構變化的次數並不多,因此每次更新都引發 React 的更新調用,就引起了很多不必要性能的消耗。爲了性能的提升,我們希望儘量避免這些更新操作,節點上的屬性變化直接進行修改,而不是通過 state 或者 prop 來進行控制,只在需要在對象變更的時候進行樹的變更操作就可以了。

依照這個思路,我們把整體的系統重新分析,根據系統特性嘗試將操作分爲兩部分,一部分是針對樹結構(相對穩定),用於對節點進行維護與更新(JSX);一部分則是針對繪製對象中的狀態進行實時計算與繪製。我們對下面的代碼進行調整。

updateRef.current(xRef.current);

這塊通過 state 形式進行更新的代碼調整爲直接更新,完後成直接渲染。

// 對 react 的節點,直接更新並繪製
rectRef.current.setAttr('x', xRef.current);
updatePicture(rectRef.current);

邏輯調整的部分不多,只是將 state 中存儲的屬性改爲 ref 來進行存儲,這樣我們已經可以減少掉很多多餘的操作了,我們拿上面的圖與下面這張來對比下就很明顯了。

const Picture = () => {
    // 獲取 rect 的節點
    const rectRef = useRef();
    const xRef = useRef(0);
    // 創建 tick 函數,進行動態更新,只需要執行一次就可以了
    useEffect(() => {
        let id;
        const tick = () => {
            xRef.current += 1;
            // 直接更新並繪製
            rectRef.current.setAttr('x', xRef.current);
            updatePicture(rectRef.current);
            if (xRef.current > 30) {
                return;
            }
            id = requestAnimationFrame(tick);
        };
        tick();
        return () => {
            cancelAnimationFrame(id);
        };
    }, []);
    return (
         <div>
            <Stage width={100} height={100}>
                <Layer>
                    <Text text="Hello world" />
                    <Rect
                        x={x}
                        y={20}
                        width={50}
                        height={50}
                        fill="red"
                    />
                </Layer>
            </Stage>
        </div>
    );
};

上面提供的僅僅是一種優化方式,實現比較簡單,可以考慮在節點間沒有依賴或者優先級的場景下使用。當然還有另一種方式也可以,例如通過實現特定的接口 (Interface),直接來調用對象的特定方法來繞過 React 的更新機制。方法的選擇完全取決於使用的場景。

結語

React 提供了非常便捷的手段用來對渲染部分進行自定義,使用這種自定義 Render 的方式就可以讓我們自己來實現一套基於 React 的渲染引擎,無論是基於 react-dom 的基礎上做爲 Canvas 的補充也罷,或者像 react-native 一樣完全實現另一個全新平臺也好,都有一套相對完整的手段。

使用 React 機制給我們帶來了代碼統一以及數據維護的便捷。不過如果打算使用這套機制直接來做動畫的話,可能會面臨性能問題。因此在使用上需要依據不同的場景選擇合適的優化方案。對於通常的使用場景,我們僅僅只需要嘗試避免通過 prop 或者 state 來進行屬性上的更新就能避免性能上無謂的開銷。

如果結構頻繁變化或者複雜度非常高的話,也可以考慮完全剝離兩套渲染體系,根據不同的場景選擇合適的方案即可。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/z-GxbF9SI0v1BJv3RiqjwA