Chapter 15

第9章 パフォーマンスチューニング

てべすてん
てべすてん
2022.03.11に更新

この章では今まで作成したツールのパフォーマンスを向上させていきます。Reactにおいてパフォーマンスを向上させたいなら不要な再レンダリングや計算処理をなくすことに尽きるといっても過言ではないでしょう。そのためにReact標準で備わっている "コンポーネントをメモ化できる関数" たちを使って不要な再レンダリングを抑えていきます。

React.memoでコンポーネントをメモ化

React.memoは引数にコンポーネントを渡すことでpropsが同じならメモ化したコンポーネントを返す関数です。そのためpropsがほとんど変わらないコンポーネントの再レンダリング抑制につながります。

今回作成したツールに登場するコンポーネントのうちpropsが変わらないコンポーネントは以下のようなものがあります。

  • BuildPanel
  • Sidebar(Edit,Preview含む)
  • Header
  • SymBaseで生成するコンポーネント
  • Arrow
  • Flow

これらのうち多くはpropsを1つも持ちません。それぞれにReact.memoを適用していくことで再描画する回数を大幅に抑えることができます。

プロジェクトルート/src/components/App/BuildPanel.tsx
  import Box from "@mui/material/Box";  import Button from "@mui/material/Button";
  import Stack from "@mui/material/Stack";
  import React,{ FC } from "react" ;
  import { useMode } from "redux/app/hooks";
  import { useItems,useItemOperations } from "redux/items/hooks";
  import { Flow } from "redux/items/types";
  import { useFlows } from "redux/meta/hooks";
  import flowCreator from "sym/flow/creator";
  import FlowComp from "sym/flow/Flow";


  export interface BuildPanelProps{
  }

  const BuildPanel :FC<BuildPanelProps> = ({})=>{
    const {
      flows:flowIds,
      addFlow,
    } = useFlows();
    const flows = useItems(flowIds) ;
    const { setItem } = useItemOperations() ;

    //フローを追加する処理
    const handleAddFlow = ()=>{
      //フローオブジェクト作成
      const flow = flowCreator() ;
      //フローのIDを決定(ランダム)
      const flowId = `id-flow-${Math.floor(Math.random()*10000000)}` ;
      //idとオブジェクトを紐づける(setItem呼び出し)
      setItem(flowId,flow);
      //meta.flowsにフローのID追加する
      addFlow(flowId);
    } ;

    //previewモード中はボタンを表示しない
    const {
      mode,
    } = useMode() ;

    return (
      <div>
        <Stack spacing={2} id="flowchart">
          {Object.entries(flows).map(([flowId,_item])=>{
            return (
              <FlowComp flowId={flowId}/>
            ) ;
          })}
          {
            mode!=="preview"?
            <Box sx={{border:"dashed 1px black",p:2,width:"fit-content"}}>
              <Button onClick={handleAddFlow}>フローを追加</Button>
            </Box>
            :
            ""
          }
        </Stack>
      </div>
    )
  }
  export default React.memo(BuildPanel) ;

プロジェクトルート/src/components/App/BuildPanel.tsx
  import Box from "@mui/material/Box";  import Button from "@mui/material/Button";
  import Stack from "@mui/material/Stack";
+ import React,{ FC } from "react" ;
  import { useMode } from "redux/app/hooks";
  import { useItems,useItemOperations } from "redux/items/hooks";
  import { Flow } from "redux/items/types";
  import { useFlows } from "redux/meta/hooks";
  import flowCreator from "sym/flow/creator";
  import FlowComp from "sym/flow/Flow";


  export interface BuildPanelProps{
  }

  const BuildPanel :FC<BuildPanelProps> = ({})=>{
    const {
      flows:flowIds,
      addFlow,
    } = useFlows();
    const flows = useItems(flowIds) ;
    const { setItem } = useItemOperations() ;

    //フローを追加する処理
    const handleAddFlow = ()=>{
      //フローオブジェクト作成
      const flow = flowCreator() ;
      //フローのIDを決定(ランダム)
      const flowId = `id-flow-${Math.floor(Math.random()*10000000)}` ;
      //idとオブジェクトを紐づける(setItem呼び出し)
      setItem(flowId,flow);
      //meta.flowsにフローのID追加する
      addFlow(flowId);
    } ;

    //previewモード中はボタンを表示しない
    const {
      mode,
    } = useMode() ;

    return (
      <div>
        <Stack spacing={2} id="flowchart">
          {Object.entries(flows).map(([flowId,_item])=>{
            return (
              <FlowComp flowId={flowId}/>
            ) ;
          })}
          {
            mode!=="preview"?
            <Box sx={{border:"dashed 1px black",p:2,width:"fit-content"}}>
              <Button onClick={handleAddFlow}>フローを追加</Button>
            </Box>
            :
            ""
          }
        </Stack>
      </div>
    )
  }
+ export default React.memo(BuildPanel) ;

プロジェクトルート/src/components/App/Sidebar.tsx
+ import React,{ FC, } from "react" ;   import { useMode } from "redux/app/hooks";
  import EditSidebar from "./EditSidebar";
  import PreviewSidebar from "./PreviewSidebar";


  export interface SidebarProps{
  }

  const Sidebar :FC<SidebarProps> = ({})=>{
    const {mode} = useMode() ;
    return (
      <div>
        {mode==="edit"?<EditSidebar />:""}
        {mode==="preview"?<PreviewSidebar />:""}
      </div>
    )
  }
+ export default React.memo(Sidebar) ;

プロジェクトルート/src/components/App/EditSidebar.tsx
  import List from "@mui/material/List";  import ListItem from "@mui/material/ListItem";
  import Button from "components/util/Button";
  import TextField from "components/util/TextField";
+ import React,{ChangeEvent, FC, } from "react" ;
  import { useSelectItem } from "redux/app/hooks";
  import { useItem, useItemOperations, } from "redux/items/hooks";
  import { Sym } from "redux/items/types";

  export interface EditSidebarProps {}

  const EditSidebar: FC<EditSidebarProps> = ({}) => {
      const {
          selectItemId,
        } = useSelectItem() ;
        const selectItem = useItem(selectItemId??"") as Sym ;
        const { setItem,removeItem } = useItemOperations();
        const handleOptionChange = (idx:number, e :ChangeEvent<HTMLInputElement|HTMLTextAreaElement>)=>{
          //オプションの更新処理
          const newValue = e.target.value ;
          const newOptions = [...selectItem.options] ;
          newOptions[idx] = {
            ...newOptions[idx],
            value:newValue,
          } ;
          const newItem = {
            ...selectItem,
            options:newOptions,
          } ;
          setItem(selectItemId??"",newItem);
        }
        const handleRemoveSym = ()=>{
          if(selectItemId){
            //親から削除
            //アイテム自体を削除
            removeItem(selectItemId);
          }
        } ;
        return (
          <div>
            {selectItemId && selectItem?
            <>
            <List>
                {selectItem.options.map((option,idx)=>{
                  return (
                    <ListItem>
                      {option.name}
                      :
                      <TextField value={option.value} onChange={e=>handleOptionChange(idx,e)}/>
                    </ListItem>
                  ) ;
                })}
              </List>
              <Button onClick={handleRemoveSym}>
                記号を削除する
              </Button>
            </>
            :"アイテムが選択されていません"}
          </div>
        )
      };
+ export default React.memo(EditSidebar) ;


プロジェクトルート/src/components/App/PreviewSidebar.tsx
  import Button from "components/util/Button";
+ import React,{ FC } from "react";

  import {toPng} from "html-to-image" ;
  import { useTitle } from "redux/meta/hooks";

  export interface PreviewSidebarProps {}

  function downloadImage(title:string, image:string){
      const aEle = document.createElement("a") ;
      aEle.download = title+".png" ;
      aEle.href = image ;
      aEle.click();
      aEle.remove();
  }

  const PreviewSidebar: FC<PreviewSidebarProps> = ({}) => {
      const {title} = useTitle() ;
      const handleDownloadImage = async ()=>{
          //DOM要素を取得
          const ele = document.getElementById("flowchart");
          if(ele){
              //画像に変換
              const image = await toPng(ele) ;
              //画像をダウンロード
              downloadImage(title,image) ;
          }else{
              alert("エラー:画像に変換できませんでした")
          }
      } ;
      return (
          <div>
              <Button onClick={handleDownloadImage}>
                  画像として保存
              </Button>
          </div>
      );
  };
+ export default React.memo(PreviewSidebar) ;


プロジェクトルート/src/components/App/Header.tsx
  import AppBar, { AppBarProps } from "@mui/material/AppBar";  
  import IconButton from "@mui/material/IconButton/IconButton";
  import Toolbar from "@mui/material/Toolbar/Toolbar";
  import MenuIcon from "@mui/icons-material/Menu" ;
  import PreviewIcon from "@mui/icons-material/Preview" ;
  import EditIcon from "@mui/icons-material/Edit" ;
+ import React,{ FC } from "react";
  import { useTitle } from "redux/meta/hooks";
  import TextField from "components/util/TextField";
  import { useMode } from "redux/app/hooks";

  export type HeaderProps = {} & AppBarProps;

  const Header: FC<HeaderProps> = (props) => {
      const {title,setTitle} = useTitle() ;
      //モード関係
      const {mode,setMode} = useMode();
      const handleToggleMode = ()=>{
          if(mode === "edit") setMode("preview")
          if(mode === "preview") setMode("edit")
      } ;
      return (
          <AppBar position="static">
              <Toolbar>
                  <IconButton
                      size="large"
                      edge="start"
                      color="inherit"
                      sx={{ mr: 2 }}
                  >
                      <MenuIcon />
                  </IconButton>
                  <TextField value={title} onChange={e=>setTitle(e.target.value)} style={{flexGrow:1,color:"white"}}/>
                  <IconButton
                      size="large"
                      edge="start"
                      color="inherit"
                      sx={{ml:2}}
                      onClick={handleToggleMode}>
                      {
                          mode==="edit"?
                          <EditIcon />
                          :
                          mode==="preview"?
                          <PreviewIcon />
                          :
                          "???"
                      }
                  </IconButton>
              </Toolbar>
          </AppBar>
      );
  };
+ export default React.memo(Header) ;

プロジェクトルート/src/sym/base/SymBase.tsx
  import Box from "@mui/material/Box";  
+ import React,{ FC, useEffect, useRef } from "react";
  import { useSelectItem } from "redux/app/hooks";
  import { useItem } from "redux/items/hooks";
  import { Item, ItemId } from "redux/items/types";

  export const baseSetting = {
      size:{
          width:180,
          height:40,
          lineWidth:2,
      },
      color:{
          fore:"black",
          back:"white",
      },
  } ;
  const width = baseSetting.size.width ;
  const height = baseSetting.size.height ;

  export type SymChild = FC<{itemId:ItemId,item:Item}> ;
  export type SymRender = (
      ctx:CanvasRenderingContext2D,
      setting:typeof baseSetting,
  )=>void ;

  const SymBase = (Child:SymChild, render:SymRender)=>{
      const Sym :FC<{itemId:ItemId}> = ({itemId})=>{
          //選択処理
          const { selectItemId,changeSelectItemId, } = useSelectItem() ;

          //アイテムの取得 -> Childに渡す
          const item = useItem(itemId) ;
          //render関連の処理
          const ref = useRef<HTMLCanvasElement>(null) ;
          useEffect(() => {
              const canvas = ref.current ;
              if(canvas){
                  const ctx = canvas.getContext("2d") ;
                  if(ctx){
                      const setting = baseSetting ;
                      ctx.fillStyle = setting.color.back ;
                      ctx.strokeStyle = setting.color.fore ;
                      ctx.lineWidth = setting.size.lineWidth ;

                      if(selectItemId === itemId){
                          ctx.strokeStyle = "blue" ;
                      }

                      //renderの呼び出し
                      render(ctx,setting) ;
                  }
              }
          }, [itemId, selectItemId]) ;
          return (
              <Box sx={{
                  position:"relative",width:width,height:baseSetting.size,
                  textAlign:"center",fontSize:"14px"}}
                  onClick={()=>changeSelectItemId(itemId)}>

                  <Box sx={{position:"absolute",left:0,top:0,width,height,}}>
                      <canvas
                          ref={ref}
                          width={width}
                          height={height}/>
                  </Box>
                  <Box sx={{position:"absolute",left:0,top:0,width,height,}}>
                      <Child itemId={itemId} item={item} />
                  </Box>

              </Box>
          ) ;
      } ;
+     return React.memo(Sym) ;
  } ;

  export default SymBase ;

プロジェクトルート/src/sym/flow/Arrow.tsx
  import Box from "@mui/material/Box";  
+ import React,{ FC, useEffect, useRef } from "react";
  import { baseSetting } from "sym/base/SymBase";

  const width = baseSetting.size.width ;
  const height = baseSetting.size.height/2 ;

  export interface ArrowProps {}

  const Arrow: FC<ArrowProps> = () => {
      const ref = useRef<HTMLCanvasElement>(null) ;
      useEffect(()=>{
          const canvas = ref.current ;
          if(canvas){
              const ctx = canvas.getContext("2d");
              if(ctx){
                  ctx.fillStyle = baseSetting.color.back ;
                  ctx.strokeStyle = baseSetting.color.fore ;
                  ctx.lineWidth = baseSetting.size.lineWidth ;

                  //線を引く
                  ctx.beginPath();
                  ctx.moveTo(width/2,0);
                  ctx.lineTo(width/2,height);
                  ctx.closePath();
                  ctx.stroke() ;
              }
          }
      },[])
      return (
          <Box sx={{width,height,}}>
              <canvas
                  width={width}
                  height={height}
                  ref={ref}/>
          </Box>
      );
  };
+ export default React.memo(Arrow) ;

``

```diff tsx:プロジェクトルート/src/sym/flow/Flow.tsx
  import Box from "@mui/material/Box";  
  import Stack from "@mui/material/Stack";
  import Button from "components/util/Button";
+ import React,{ FC } from "react";
  import { useMode } from "redux/app/hooks";
  import { useItem, useItemOperations } from "redux/items/hooks";
  import { ItemId,Flow, Sym } from "redux/items/types";
  import { baseSetting } from "sym/base/SymBase";
  import rectCreator from "sym/rect/creator";
  import RectSym from "sym/rect/RectSym";
  import { createRandomItemId } from "sym/util";
  import Arrow from "./Arrow";

  export interface FlowProps {
      flowId:ItemId,
  }

  const FlowComp: FC<FlowProps> = ({flowId}) => {
      const flow = useItem(flowId) as Flow;
      const { setItem } = useItemOperations() ;
      //追加処理
      const handleAddSym = (idx:number)=>{
          //子要素の追加処理
          //idxで指定した位置に追加する
          const sym :Sym = rectCreator(flowId) ;
          const symId = createRandomItemId(sym.itemType) ;
          setItem(symId,sym);
          const newChildrenSyms = [...flow.childrenSyms] ;
          newChildrenSyms.splice(idx,0,symId) ;
          const newFlow = {
              ...flow,
              childrenSyms:newChildrenSyms,
          } ;
          setItem(flowId,newFlow)
      } ;
      const {mode} = useMode() ;
      return (
          <Stack direction="column" sx={{width:"fit-content"}}>

              {flow.childrenSyms.map((symId, idx) => (
                  <>
                      {idx === 0 ? null : <Arrow />}
                      {mode!=="preview"?
                      <Box sx={{display:"flex",justifyContent:"center",width:baseSetting.size.width}}>
                          <Button onClick={()=>handleAddSym(idx)} sx={{width:"fit-content"}}>追加</Button>
                      </Box>
                      :""}

                      <RectSym itemId={symId} />

                      {mode!=="preview"?
                      <Box sx={{display:"flex",justifyContent:"center",width:baseSetting.size.width}}>
                          <Button onClick={()=>handleAddSym(idx+1)} sx={{width:"fit-content"}}>追加</Button>
                      </Box>
                      :""}
                  </>
              ))}

              {/* 子要素がない */}
              {flow.childrenSyms.length === 0 && mode !== "preview"?
              <>
                  子要素がありません
                  <Button onClick={()=>handleAddSym(0)}>
                      記号を追加する
                  </Button>
              </>
              : ""}
          </Stack>
      );
  };
+ export default React.memo(FlowComp) ;

これで大幅にレンダリングの抑制が出来ました。

質問・指摘などはこちらから

https://zenn.dev/tbsten/scraps/7123b1257c2097