Chapter 14

第8章 機能を実装してツールを仕上げる その4 -ブラウザに保存する-

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

今現状ではページをリロードするたびにフローチャートがリセットされてしまいます。これはjavascriptがロードされるたびに実行されるため、reduxの初期化処理がリロードするたびに走ってしまうためです。これは回避しようがないので、一定時間ごとにフローチャートの情報(storeState.itemsやstoreState.meta)をブラウザに保存し、ページをリロードするたびに保存したデータをロードすれば解決します。この章ではこのようなブラウザへの保存処理を実装してみましょう。s

フローチャートを保存する

今回はブラウザへの保存のためにStorejsを使用します。はじめにインストールしておきましょう。

npm i storejs

フローチャートの情報をブラウザに保存するという処理に注目して実装を進めるため、保存処理をuseSaveBrowserとして定義します。この処理はプロジェクトルート/src/util/save.tsに記述します。

プロジェクトルート/src/util/save.ts

export const useSaveBrowser = ()=>{
    //セーブする処理
} ;


このhookはどのように使用(呼び出す)するか考えます。次のように使用できるといいでしょう。

useSaveBrowserの使用方法
const SomeComponent:FC<{}> = (props)=>{
  useSaveBrowser() ;  //データのロード、自動セーブを行う
  return (
    ...
  ) ;
} ;

このhookを呼び出すだけでデータのロード、自動セーブを行ってくれることにします。この2つの機能について順に実装していきます。

自動セーブ機能の実装

まずは自動セーブから実装してみます。そもそもフローチャートのセーブ処理とは具体的には何をする処理でしょうか?

フローチャートのセーブ処理とは、storeState.itemsstoreState.metaのデータをブラウザに保存することです。つまりこの処理を実装するためにはstoreState.itemsstoreState.metaが必要になります。よってこれらをuseSelector(もしくはそれをラップしたhook)で取得します。

プロジェクトルート/src/util/save.ts
+ import { useSelector } from "react-redux";
+ import { useItems } from "redux/items/hooks";
+ import { StoreState } from "redux/store";

  export const useSaveBrowser = ()=>{
+     //itemsとmetaを取得
+     const items = useItems();
+     const meta = useSelector((state:StoreState)=>state.meta);
  } ;


次に実際にセーブ処理を実行する関数であるsave関数を定義します。またブラウザにセーブするときはJSON形式でセーブすることにします(Storejsで保存できるのは文字列だけのため)。

プロジェクトルート/src/util/save.ts
  import { useSelector } from "react-redux";
  import { useItems } from "redux/items/hooks";
  import { StoreState } from "redux/store";
+ import store from "storejs" ;  //reduxのstoreとは別物なので注意!!!

+ const STORE_KEY = "flowchartbuilder-savedata" ;

  export const useSaveBrowser = ()=>{
      //itemsとmetaを取得
      const items = useItems();
      const meta = useSelector((state:StoreState)=>state.meta);
+     const save = ()=>{
+         //セーブ対象をオブジェクトとして取得
+         const saveData = {
+             items,
+             meta,
+         } ;
+         //ブラウザに保存
+         store(STORE_KEY,JSON.stringify(saveData));
+     } ;
  } ;


これでsave関数は大丈夫そうです。あとはこれを定期的に実行するだけです。

プロジェクトルート/src/util/save.ts
  import { useEffect } from "react";
  import { useSelector } from "react-redux";
  import { useItems } from "redux/items/hooks";
  import { StoreState } from "redux/store";
  import store from "storejs" ;

  const STORE_KEY = "flowchartbuilder-savedata" ;

  export const useSaveBrowser = ()=>{
      //itemsとmetaを取得
      const items = useItems();
      const meta = useSelector((state:StoreState)=>state.meta);
      const save = ()=>{
          //セーブ対象をオブジェクトとして取得
          const saveData = {
              items,
              meta,
          } ;
          //ブラウザに保存
          store(STORE_KEY,JSON.stringify(saveData));
      } ;
+     useEffect(()=>{
+         const autoSaveId = setInterval(()=>{
+             save();
+         },5*1000);//5秒ごとに保存
+         return ()=>{
+             clearInterval(autoSaveId) ;
+         }
+     },[items,meta,])
  } ;


ページ読み込み時にロードする処理

続いてページを読み込んだ時にJSON形式のセーブデータを取得し、reduxのstoreに反映する(=ロードする)処理を実装します。

この機能ではReduxのitem,meta stateに対してロードするといった変更を加える処理を実行する必要があるため、item,meta stateに新しいActionを定義(具体的にはロードする)する必要がありますので、それぞれActionを定義してreducerに処理を追加していきます。

プロジェクトルート/src/redux/items/actions.ts
  import actionCreatorFactory from "typescript-fsa" ;
+ import { Item, ItemId, Items } from "./types";

  const actionCreator = actionCreatorFactory() ;

  //いつ使うのかわからないけど公式ドキュメントに載っていたのでおいておきます
  export const init = actionCreator("items/init");
  //アイテムの追加(更新)
  export const set = actionCreator<{ itemId:ItemId,item:Item, }>("items/set");
  //アイテムの削除
  export const remove = actionCreator<{ itemId:ItemId}>("items/remove");
+ //アイテムをセーブデータからロード
+ export const loadItems = actionCreator<{items:Items}>("items/load") ;

プロジェクトルート/src/redux/items/reducers.ts
  import { reducerWithInitialState } from "typescript-fsa-reducers";
  import * as actions from "./actions" ;
  import { Flow, isSym, Items } from "./types";

  export const init :Items = {} ;

  export const items = reducerWithInitialState(init)
      .case( actions.set ,(state,payload)=>{
          const newState = {
              ...state,
              [payload.itemId] : payload.item,
          } ;
          return newState ;
      })
      .case( actions.remove ,(state,payload)=>{
          const deleteItem = state[payload.itemId] ;
          let newState = {...state} as Items ;
          let isUpdate = false ;
          //アイテム一覧から削除
          const keys = Object.keys(state)
          if(keys.includes(payload.itemId)){
              isUpdate = true ;
              delete newState[payload.itemId] ;
          }
          //フローの場合はchildrenSymsからも削除
          if(isSym(deleteItem)){
              const parentFlow = (newState[deleteItem.parentFlow]) as Flow ;
              parentFlow.childrenSyms = parentFlow.childrenSyms.filter(symId=>symId!==payload.itemId) ;
          }
          //戻り値
          if(isUpdate){
              return newState ;
          }
          return state ;
      })
+     .case( actions.loadItems,(state,payload)=>{
+         return payload.items ;
+     })

プロジェクトルート/src/redux/meta/actions.ts
  import { ItemId } from "redux/items/types";
+ import { StoreState } from "redux/store";
  import actionCreatorFactory from "typescript-fsa" ;

  const actionCreator = actionCreatorFactory() ;

  //titleに関するActionCreator
  export const setTitle = actionCreator<{title:string}>("meta/setTitle") ;

  //flowに関するActionCreator
  export const addFlow = actionCreator<{flowId:ItemId}>("meta/flow/add");
  export const removeFlow = actionCreator<{flowId:ItemId}>("meta/flow/remove");

+ //ロード処理
+ export const loadMeta = actionCreator<{meta:StoreState["meta"]}>("meta/load") ;

プロジェクトルート/src/redux/meta/actions.ts
  import { ItemId } from "redux/items/types";
  import { reducerWithInitialState } from "typescript-fsa-reducers";
  import * as actions from "./actions" ;

  export const init = {
      title:"タイトル未設定",
      flows:[] as ItemId[] ,
  };

  export const meta = reducerWithInitialState(init) 
      .case( actions.setTitle , (state,payload)=>{
          if(state.title === payload.title) return state ;
          const newState = {
              ...state,
          } ;
          newState.title = payload.title ;
          return newState ;
      } )
      .case( actions.addFlow , (state,payload)=>{
          const newState = {...state} ;
          newState.flows = [
              ...newState.flows,
              payload.flowId
          ] ;
          return newState ;
      } )
      .case( actions.removeFlow , (state,payload)=>{
          if(!state.flows.includes(payload.flowId))return state ;
          const newState = {...state} ;
          newState.flows = newState.flows.filter(flowId=>flowId !== payload.flowId) ;
          return newState ;
      })
+     .case( actions.loadMeta, (state,payload)=>{
+         return payload.meta ;
+     })
      ;

これで適切なアクションを呼び出すことでセーブデータをロードすることができます。先ほどのuseSaveBrowserでこれらを使用しましょう!

プロジェクトルート/src/util/save.ts
  import { useEffect } from "react";
  import { useDispatch, useSelector } from "react-redux";
  import { loadItems } from "redux/items/actions";
  import { useItems } from "redux/items/hooks";
  import { ItemId, Items } from "redux/items/types";
  import { loadMeta } from "redux/meta/actions";
  import { StoreState } from "redux/store";
  import store from "storejs" ;

  const STORE_KEY = "flowchartbuilder-savedata" ;

  export const useSaveBrowser = ()=>{
      //itemsとmetaを取得
      const items = useItems();
      const meta = useSelector((state:StoreState)=>state.meta);
      const dispatch = useDispatch() ;
      const save = ()=>{
          //セーブ対象をオブジェクトとして取得
          const saveData = {
              items,
              meta,
          } ;
          //ブラウザに保存
          store(STORE_KEY,JSON.stringify(saveData));
      } ;
+     useEffect(()=>{
+         //セーブデータのロード機能
+         if(!store.keys().includes(STORE_KEY)){
+             //ロードするデータがない場合はロードしない
+             return ;
+         }
+         const loadData = JSON.parse(store.get(STORE_KEY)) ;
+         console.log("load",loadData);
+         const saveData = {
+             items:{},
+             meta:{
+                 title:"",
+                 flows:[],
+             },
+             ...loadData,
+         } as const ;
+         dispatch(loadItems({items:saveData.items}));
+         dispatch(loadMeta({meta:saveData.meta}));
+     },[]);
      useEffect(()=>{
          //オートセーブ機能
          const autoSaveId = setInterval(()=>{
              save();
          },5*1000);//5秒ごとに保存
          return ()=>{
              clearInterval(autoSaveId) ;
          }
      },[items,meta,])
  } ;
  

これでセーブ関連の機能が実装できたはずです。このuseSaveBrowserApp.tsxで読み込みましょう!

プロジェクトルート/src/App.tsx
  import Card from '@mui/material/Card';
  import CardContent from '@mui/material/CardContent';
  import Grid from '@mui/material/Grid';
  import BuildPanel from 'components/App/BuildPanel';
  import Header from 'components/App/Header';
  import Sidebar from 'components/App/Sidebar';
  import { FC, } from 'react';
+ import { useSaveBrowser } from 'util/save';

  import "./App.css" ;

  interface AppProps{}
  const App :FC<AppProps> = ()=>{
+   useSaveBrowser();
    return (
      <Grid container>
        <Grid item xs={12}>
          <Header />
        </Grid>
        <Grid item xs>
          <Card>
            <CardContent>
              <BuildPanel />
            </CardContent>
          </Card>
        </Grid>
        <Grid item xs="auto">
          <Card>
            <CardContent>
             <Sidebar />
            </CardContent>
          </Card>
        </Grid>
  
      </Grid>
    ) ;
  };

  export default App;

自動保存機能が完成しました!これで前回の編集状況から編集作業を再開できるようになりました。

ここまでのソースコード
プロジェクトルート/src/redux/items/actions.ts
import actionCreatorFactory from "typescript-fsa" ;
import { Item, ItemId, Items } from "./types";

const actionCreator = actionCreatorFactory() ;

//いつ使うのかわからないけど公式ドキュメントに載っていたのでおいておきます
export const init = actionCreator("items/init");
//アイテムの追加(更新)
export const set = actionCreator<{ itemId:ItemId,item:Item, }>("items/set");
//アイテムの削除
export const remove = actionCreator<{ itemId:ItemId}>("items/remove");
//アイテムをセーブデータからロード
export const loadItems = actionCreator<{items:Items}>("items/load") ;


プロジェクトルート/src/redux/items/reducers.ts
import { reducerWithInitialState } from "typescript-fsa-reducers";
import * as actions from "./actions" ;
import { Flow, isSym, Items } from "./types";

export const init :Items = {} ;

export const items = reducerWithInitialState(init)
    .case( actions.set ,(state,payload)=>{
        const newState = {
            ...state,
            [payload.itemId] : payload.item,
        } ;
        return newState ;
    })
    .case( actions.remove ,(state,payload)=>{
        const deleteItem = state[payload.itemId] ;
        let newState = {...state} as Items ;
        let isUpdate = false ;
        //アイテム一覧から削除
        const keys = Object.keys(state)
        if(keys.includes(payload.itemId)){
            isUpdate = true ;
            delete newState[payload.itemId] ;
        }
        //フローの場合はchildrenSymsからも削除
        if(isSym(deleteItem)){
            const parentFlow = (newState[deleteItem.parentFlow]) as Flow ;
            parentFlow.childrenSyms = parentFlow.childrenSyms.filter(symId=>symId!==payload.itemId) ;
        }
        //戻り値
        if(isUpdate){
            return newState ;
        }
        return state ;
    })
    .case( actions.loadItems,(state,payload)=>{
        return payload.items ;
    })




プロジェクトルート/src/redux/meta/actions.ts

import { ItemId } from "redux/items/types";
import actionCreatorFactory from "typescript-fsa" ;

const actionCreator = actionCreatorFactory() ;

//titleに関するActionCreator
export const setTitle = actionCreator<{title:string}>("meta/setTitle") ;

//flowに関するActionCreator
export const addFlow = actionCreator<{flowId:ItemId}>("meta/flow/add");
export const removeFlow = actionCreator<{flowId:ItemId}>("meta/flow/remove");

//ロード処理
export const loadMeta = actionCreator<{meta:{flows:ItemId[],title:string,}}>("meta/load") ;



プロジェクトルート/src/redux/meta/reducers.ts
import { ItemId } from "redux/items/types";
import { reducerWithInitialState } from "typescript-fsa-reducers";
import * as actions from "./actions" ;

export const init = {
    title:"タイトル未設定",
    flows:[] as ItemId[] ,
};

export const meta = reducerWithInitialState(init) 
    .case( actions.setTitle , (state,payload)=>{
        if(state.title === payload.title) return state ;
        const newState = {
            ...state,
        } ;
        newState.title = payload.title ;
        return newState ;
    } )
    .case( actions.addFlow , (state,payload)=>{
        const newState = {...state} ;
        newState.flows = [
            ...newState.flows,
            payload.flowId
        ] ;
        return newState ;
    } )
    .case( actions.removeFlow , (state,payload)=>{
        if(!state.flows.includes(payload.flowId))return state ;
        const newState = {...state} ;
        newState.flows = newState.flows.filter(flowId=>flowId !== payload.flowId) ;
        return newState ;
    })
    .case( actions.loadMeta, (state,payload)=>{
        return payload.meta ;
    })
    ;


プロジェクトルート/src/util/save.ts
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { loadItems } from "redux/items/actions";
import { useItems } from "redux/items/hooks";
import { ItemId, Items } from "redux/items/types";
import { loadMeta } from "redux/meta/actions";
import { StoreState } from "redux/store";
import store from "storejs" ;

const STORE_KEY = "flowchartbuilder-savedata" ;

export const useSaveBrowser = ()=>{
    //itemsとmetaを取得
    const items = useItems();
    const meta = useSelector((state:StoreState)=>state.meta);
    const dispatch = useDispatch() ;
    const save = ()=>{
        //セーブ対象をオブジェクトとして取得
        const saveData = {
            items,
            meta,
        } ;
        //ブラウザに保存
        store(STORE_KEY,JSON.stringify(saveData));
    } ;
    useEffect(()=>{
        //セーブデータのロード機能
        if(!store.keys().includes(STORE_KEY)){
            //ロードするデータがない場合はロードしない
            return ;
        }
        const loadData = JSON.parse(store.get(STORE_KEY)) ;
        console.log("load",loadData);
        const saveData = {
            items:{},
            meta:{
                title:"",
                flows:[],
            },
            ...loadData,
        } as const ;
        dispatch(loadItems({items:saveData.items}));
        dispatch(loadMeta({meta:saveData.meta}));
    },[]);
    useEffect(()=>{
        //オートセーブ機能
        const autoSaveId = setInterval(()=>{
            save();
        },5*1000);//5秒ごとに保存
        return ()=>{
            clearInterval(autoSaveId) ;
        }
    },[items,meta,])
} ;

プロジェクトルート/src/App.tsx
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Grid from '@mui/material/Grid';
import BuildPanel from 'components/App/BuildPanel';
import Header from 'components/App/Header';
import Sidebar from 'components/App/Sidebar';
import { FC, } from 'react';
import { useSaveBrowser } from 'util/save';

import "./App.css" ;

interface AppProps{}
const App :FC<AppProps> = ()=>{
  useSaveBrowser();
  return (
    <Grid container>
      <Grid item xs={12}>
        <Header />
      </Grid>
      <Grid item xs>
        <Card>
          <CardContent>
            <BuildPanel />
          </CardContent>
        </Card>
      </Grid>
      <Grid item xs="auto">
        <Card>
          <CardContent>
           <Sidebar />
          </CardContent>
        </Card>
      </Grid>

    </Grid>
  ) ;
};

export default App;

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

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