Edited at

Electron & React & Redux & TypeScript アプリ作成ワークショップ 7日目


概要

前回までに、タスク一覧の表示、追加等のアクションができるようになりました。

ただ、表示するデータはダミーのもので、データの永続化もしていないので、今回はそれを実装します。

データの入出力は、ファイルに対して非同期で行います。

Electron なので、PCのファイルにアクセスできますが、Webアプリでは多くの場合サーバーとの送受信ということになるでしょう。


ローディングのステータスとアクションの追加

非同期処理では、アクションから結果が変えるまで時間がかかることが多いです。(だからこその非同期処理なのですが。)

処理が終わるまでの間、画面には処理中であることを表示する スピナー(ぐるぐるまわるやつ) を表示することが一般的です。

この表示を on/off するステータスとアクションが必要なので、まずはこれを追加しましょう。

states/ITask.ts

// (略)

/**
* タスクのリスト
*/

export interface ITaskList {
/** ローディング表示 */
shownLoading: boolean; // <- 追加
/** タスクの一覧 */
tasks: ITask[];
}
/**
* タスクのリストの初期値
*/

export const initTaskList: ITaskList = {
shownLoading: false, // <- 追加
tasks: [],
};
// (略)

アクションは、これを dispatch するたびに on/off を切り替えるトグルとするので、アクションの値は必要ないです。

actions/TaskActions.ts

// 下記を追加

/**
* タスクロード開始のアクションタイプ
*/

export const TOGGLE_SHOW_SPINNER = UUID();
/**
* タスクロード開始のアクション
*/

// tslint:disable-next-line:no-empty-interface
export interface IToggleShowSpinnerAction extends Action {
}

これに対応する Reducer の用意します。

reducers/TaskReducer.ts

// (略)

a2RMapper.addWork<Action.IToggleShowSpinnerAction>(
Action.TOGGLE_SHOW_SPINNER,
(state, action) => {
state.shownLoading = !state.shownLoading;
},
);
// (略)


ローディングのコンポーネントを作成する

スピナーを表示する部品として、コンポーネントを追加します。

components/Loading.tsx

import React, { Component } from 'react';

import Styled, { keyframes } from 'styled-components';
import { $COLOR_PRIMARY_0, $COLOR_PRIMARY_1 } from './FoundationStyles';

interface IProps {
shown: boolean;
}

const BG = Styled.div`
background: #666;
height: 100%;
left: 0;
opacity: 0.5;
position: absolute;
top: 0;
width: 100%;
`
;

const RoundAnimate = keyframes`
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
`
;
const SpinnerBox = Styled.div`
align-items: center;
display: flex;
height: 100%;
left: 0;
position: absolute;
top: 0;
width: 100%;
`
;
const Spinner = Styled.div`
animation:
${RoundAnimate} 1.1s infinite linear;
border-bottom: 1.1em solid
${$COLOR_PRIMARY_1};
border-left: 1.1em solid
${$COLOR_PRIMARY_0};
border-radius: 50%;
border-right: 1.1em solid
${$COLOR_PRIMARY_1};
border-top: 1.1em solid
${$COLOR_PRIMARY_1};
font-size: 10px;
height: 10em;
margin: 60px auto;
position: relative;
transform: translateZ(0);
width: 10em;
&:after {
border-radius: 50%;
width: 10em;
height: 10em;
}
`
;

export class Loading extends Component<IProps> {
public render() {
if (!this.props.shown) {
return null;
}
return (
<div>
<BG />
<SpinnerBox>
<Spinner />
</SpinnerBox>
</div>
);
}
}

ローディングの表示は、画面全体に半透明なスクリーン(BG)を表示、その上にスピナー(Spinner)を表示します。

スピナーは、円がグルグル回るアニメーションとしますが、これはGIFアニメーション画像ではなく、CSSのanimationkeyframeを利用して表現しています。

CSS アニメーションについての詳しい解説はこちら。-> https://qiita.com/soarflat/items/4a302e0cafa21477707f

このコンポーネントのプロパティは、表示/非表示を制御するshownがあります。

これを TaskList コンポーネント内で利用します。

components/TaskList.tsx

// (略)

import { Loading } from './Loading'; // <-追加
// (略)
class TodoList extends React.Component<ITaskList, {}> {
// (略)
public render() {
// (略)
return (
<div>
<Header>TODO</Header>
<MainContainer>
<AddTask taskName="" deadline={Moment().add(1 , 'days').toDate()} />
<TaskList>
{taskListElems}
</TaskList>
</MainContainer>
<Loading shown={this.props.shownLoading} />{/* <-追加 */}
</div>
);
}
}
// (略)


IState の分離

store.ts から様々なファイルへ参照しています。循環参照がされないよう、ActionCreator から store を直接参照するのを避けるため、 IStore を別ファイルに定義します。

Visual Studio Code を利用している場合は、リファクタ機能を利用するとファイルへの分割が簡単にできます。

Store.ts

// ↓ 削除

// import { ITaskList } from './states/ITask';
// ↑ 削除

// (略)
// ↓ 削除
// /**
// * store のデータ型を定義する。(親state)
// *
// * プロパティには、管理する child_state を指定する
// */
// export interface IState {
// taskList: ITaskList;
// }
// ↑ 削除
// (略)

IStore.ts (新規作成)

import { ITaskList } from './states/ITask';

/**
* store のデータ型を定義する。(親state)
*
* プロパティには、管理する child_state を指定する
*/

export interface IState {
taskList: ITaskList;
}


データファイルの内容

タスクの情報を保持するファイルは、JSONで保存することとします。

日付は、プログラムで使いやすいように、Date#getTime で取得できる値(1970年1月1日(UTC)からのミリ秒累計)とします。

コンピューターシステム、アプリケーション全般に言えることですが、日付を文字列で持つ場合は、タイムゾーンや地域によってはサマータイムを意識する必要があり、入出力のフォーマットを合わせるなど、面倒なことが多いです。内部のデータとしては UTC で管理し、表示するときにタイムゾーンやサマータイムを適用して表示するのが望ましいです。

例:

{

"data": [
{
"complete": false,
"deadline": 1539212890057,
"id": "f547b24c-5559-4fac-a31e-bc65fba312eb",
"taskName": "qqqq"
},
{
"complete": true,
"deadline": 1539213741954,
"id": "c6bb4bee-c388-447f-acf8-1c0f48dff7a7",
"taskName": ""
}
]
}


ファイルの操作を行う


ファイルの入出力を行う処理を追加する

ファイルの入出力を、ユーティリティとしてのモジュールを追加します。

ファイルの操作には、fs-extraというライブラリを利用するので、npm でインポートしておきます。

> npm install --save fs-extra && npm install --save-dev @types/fs-extra

読み込み、書き込みいずれも非同期メソッドとして定義しています。

ファイルは、OSごとのユーザープロファイルフォルダに保存します。ユーザープロファイルフォルダは、OS毎に違いますが、Electronではos#homedir でそのの違いを意識することなく取得できます。

windows であれば、c:\users\{ユーザー名} (環境変数の%USERPROFILE%)のフォルダを返します。

utils/TaskFileIF.ts

import FsEx from 'fs-extra'; // ...(a)

import OS from 'os';
import Path from 'path';

import { ITask } from '../states/ITask';

// OSごとのユーザーのプロファイルフォルダに保存される
const dataFilePath = Path.join(OS.homedir(), 'todo.json');

/**
* ファイルからタスクのデータをロードする
*/

export const loadTask = async () => {
const exist = await FsEx.pathExists(dataFilePath); // ...(b)
if (!exist) { // ...(c)
// データファイルがなけれが、ファイルを作成して、初期データを保存する
FsEx.ensureFileSync(dataFilePath);
await FsEx.writeJSON(dataFilePath, { data: [] });
}
// データファイルを読み込む ...(d)
const jsonData = await FsEx.readJSON(dataFilePath, {
// 日付型は、数値で格納しているので、日付型に変換する
reviver: (key: string, value: any) => {
if (key === 'deadline') {
return new Date(value as number);
} else {
return value;
}
},
});
// 早すぎて非同期処理を実感できないので、ちょっと時間がかかる処理のシミュレート
await setTimeoutPromise(1000);
return jsonData;
};

/**
* ファイルにタスクのデータを保存する
*/

export const saveState = async (taskList: ITask[]) => {
// 早すぎて非同期処理を実感できないので、ちょっと時間がかかる処理のシミュレート
await setTimeoutPromise(1000);
await FsEx.writeJSON(dataFilePath, { data: taskList }, {
replacer: (key: string, value: any) => {
if (key !== 'deadline') { return value; }
return new Date((value as string)).getTime();
},
spaces: 2,
});
};
/** 指定ミリ秒 待つ関数 */
const setTimeoutPromise = (count: number) => {
return new Promise((resolve) => {
setTimeout(() => { resolve(); }, count);
});
};


  • (a)…fs-extraというライブラリを利用します。Nodeのファイル操作のビルドインのオブジェクトfsをラップしたもので、ファイルの copy や move などのよく使われる処理をメソッドとして提供されます。

  • (b)…データファイルの存在チェックを行います。pathExistsは Promise オブジェクトを返すので、非同期で行われますが、awaitをつけているのでこの処理の完了を待って、次のステートメントに進みます。

  • (c)…初回起動時などデータファイルが存在しない場合、それを作成します。

  • (d)…データファイルをJSONとして読み込みます。項目deadlineの日付型はJSONで表現できないので、UNIX時間を数値で保持することとしています。readJSONの関数で、deadlineの項目を数値型から日付型に変換する関数を引数で定義しています。

非同期メソッドを定義する方法として、Promiseが利用できますが、ES2017 で追加された async/await を利用するとさらに簡単に非同期処理がかけます。

詳細はこちら -> async function - JavaScript | MDN


IE では利用できません。 ブラウザ対応状況



非同期の処理をどこで実行するか

Redux で非同期の処理をどこで行うのかは、悩むところです。これ、という決まりはないのですが選択肢としては、下記があります。


  • コンポーネントで行う


    • データの送受信処理を行い、完了したら Action にデータ(例えば非同期で受信したした値)を入れて dispatch する。



  • アクション・クリエイターで行う


    • ロード開始のアクションを生成すると同時に、データの送受信を行い、完了時に データの更新等の別の Action を dispatch する。



  • Reducerで行う


    • ロードするアクションを受信したら、非同期処理を開始。完了時に データの更新等の別の Action を dispatch する。



Reducerで行うのは、Reducer はデータの更新のみに従事すべき、という Redux の原則に外れるので、できればここではしたくないです。

unit test も、 redux に依存しない形が望ましいですし。

コンポーネントで行うのはわかりやすくて良い方法です。ただ、非同期処理が多くなるどどうしても煩雑になってしまいます。コンポーネントは表示に特化する、という目的では外れます。

上記のような理由から、やや消極的に アクション・クリエイターで行うこととします。消極的とは書きましたが、コンポーネントは簡潔になり、非同期処理が増えても各アクション・クリエイター毎に分散されるので、コードの見通しは良くなります。


また、この方法では、ロードの開始、ロードの終了、ロードの異常完了、とアクションを分けることになり、ロード中のスピナー(ローディングのグルグル回るアイコン)を表示したりすることも簡単になります。


非同期処理をどこで行うのかについては、決まりはありません。プロジェクトに見合った決定をすれば良いと思います。

react-thunkreact-saga といった、非同期処理用ライブラリもあります。しかし、アクション・クリエイターを使った非同期処理はそれほど難しくなく、導入するメリットが薄いと判断し、私は使っていません。もちろん、それらを調べた上で採用するのも良い選択だと思います。



Reduxの非同期処理のシーケンス


データロード開始用のアクションとそのクリエイターを作成する

データロードを開始するアクションとして、 アクションクリエイター createLoadTasksAction を追加します。

アクションクリエイターでは、ロード中を示すスピナーを表示するためのアクションIToggleShowSpinnerActionを返します。

同時に非同期で、ファイルからデータを取得し完了したら、リストを表示するIShowTaskActionアクションを dispatch します。

ActionCreatorからstoreを参照させたくないので、引数でdispatchをもらうこととします。

actions/TaskActionCreators.ts

// (省略)

import { loadTask, saveState } from '../utils/TaskFileIF'; // 追加
// (省略)
/**
* タスクロード開始のアクションを作成する
*/

export const createLoadTasksAction = (dispatch: Dispatch): IToggleShowSpinnerAction => {
let tasks: ITask[] = [];
// ファイル読み込み処理(非同期)
loadTask().then((jsonData) => {
// 読み込んだデータで値を表示する
// 実際にはデータの内容をチェックする必要がある
tasks = jsonData.data as ITask[];
// 読み込んだタス行くデータを表示する
dispatch(createShowTasksAction(tasks)); // ...(a)
// スピナーを非表示にする
dispatch<IToggleShowSpinnerAction>({
type: TOGGLE_SHOW_SPINNER,
});
});
// スピナーを表示する
// loadTask() は非同期メソッドなので、このアクションオブジェクトが先に return される。
return { // ...(b)
type: TOGGLE_SHOW_SPINNER,
};
};


  • (a)…読み込んだデータを表示するアクションを生成し、Reducer に dispatch しています。

  • (b)…ローディングの表示として、スピーナーを表示するためのアクションを返す。loadTaskの処理は非同期で行われるので、その処理を待たずに、この関数はすぐにこのアクションを返します。


ここでは、データファイルの読み込み時のエラーなどを考慮していません。

http://azu.github.io/promises-book/#ch2-promise.then


ここでは、例外が起こった場合、メッセージを画面に表示するのが適切でしょう。



タスクの追加、削除、変更結果をファイルに書き出す

タスクの追加、削除、完了/未完了の変更についても非同期処理とし、それぞれの処理が終わったあとにファイルにデータを書き込む処理を行います。

ActionCreator 全体を掲載します。

actions/TaskActionCreator.ts

import { Dispatch, Store } from 'redux';

import { IState } from '../IState';
import { ITask } from '../states/ITask';
import { loadTask, saveState } from '../utils/TaskFileIF';
import {
ADD_TASK,
DELETE_TASK,
IAddTaskAction,
IDeleteAction,
IShowTaskAction,
IToggleCompleteAction,
IToggleShowSpinnerAction,
SHOW_TASKS,
TOGGLE_COMPLETE_TASK,
TOGGLE_SHOW_SPINNER,
} from './TaskActions';

/**
* タスクの表示アクションを作成する
* @param tasks 表示するタスクのリスト
*/

export const createShowTasksAction = (tasks: ITask[]): IShowTaskAction => {
return {
// tasks, // 本来はこっち
tasks,
type: SHOW_TASKS,
};
};
/**
* 新しいタスクを作成するアクションを作成する
* @param taskName 新しいタスクの名前
* @param deadline 新しいタクスの期限
*/

export const createAddTaskAction =
(taskName: string, deadline: Date, store: Store<IState>): IToggleShowSpinnerAction => {
(async () => {
const addAction: IAddTaskAction = {
deadline,
taskName,
type: ADD_TASK,
};
store.dispatch(addAction);
const taskList = store.getState().taskList;
await saveState(taskList.tasks);
store.dispatch<IToggleShowSpinnerAction>({
type: TOGGLE_SHOW_SPINNER,
});
})();
return {
type: TOGGLE_SHOW_SPINNER,
};
};
/**
* タスクの完了状態を切り替える
* @param taskId 完了状態を切り替える対象のタスクのID
*/

export const createToggleCompleteAction =
(taskId: string, store: Store<IState>): IToggleShowSpinnerAction => {
(async () => {
store.dispatch<IToggleCompleteAction>({
taskId,
type: TOGGLE_COMPLETE_TASK,
});
const taskList = store.getState().taskList;
await saveState(taskList.tasks);
store.dispatch<IToggleShowSpinnerAction>({
type: TOGGLE_SHOW_SPINNER,
});
})();
return {
type: TOGGLE_SHOW_SPINNER,
};
};
/**
* タスクを削除するアクションを作成する
* @param taskId 削除するタスクのID
*/

export const createDeleteTaskAction = (taskId: string, store: Store<IState>): IToggleShowSpinnerAction => {
(async () => {
store.dispatch<IDeleteAction>({ taskId, type: DELETE_TASK });
const taskList = store.getState().taskList;
await saveState(taskList.tasks);
store.dispatch<IToggleShowSpinnerAction>({
type: TOGGLE_SHOW_SPINNER,
});
})();
return {
type: TOGGLE_SHOW_SPINNER,
};
};

/**
* タスクロード開始のアクションを作成する
*/

export const createLoadTasksAction = (dispatch: Dispatch): IToggleShowSpinnerAction => {
// ファイルを非同期で読み込む
let tasks: ITask[] = [];
// データファイルの存在チェック
loadTask().then((jsonData) => {
// 読み込んだデータで値を表示する
// 実際にはデータの内容をチェックする必要がある
tasks = jsonData.data as ITask[];
dispatch(createShowTasksAction(tasks));
dispatch<IToggleShowSpinnerAction>({
type: TOGGLE_SHOW_SPINNER,
});
});
return {
type: TOGGLE_SHOW_SPINNER,
};
};

追加、削除、変更については、それぞれ下記の流れの処理となります。

1. スピナーを表示する Action を返す(以下2-5は非同期処理)

2. 非同期でそれぞれの Action を dispatch

3. 最新のステータスを取得

4. そのデータをファイルに書き込む

5. スピナーを非表示にする Action を dispatch

上記、ステータスを取得および各 dispatch の処理のため、store を引数でもらうようにしています。


コンポーネントのアクションクリエイターの呼び出しの変更

リストを表示するアクションクリエイターの変更と、追加等のアクションクリエイターの引数が追加になっているので、修正します。

components/TaskList.tsx

+import { createLoadTasksAction } from '../actions/TaskActionCreators'; // 変更

+// (略)
class TodoList extends React.Component<ITaskList, {}> {
public componentDidMount() {
store.dispatch(createLoadTasksAction(store.dispatch)); // 変更
}
// (略)
}

components/AddTask.tsx

    /**

* 追加ボタンを押すと、タスク一覧にタスクを追加する
*/

private onClickAdd = (e: React.MouseEvent) => {
store.dispatch(createAddTaskAction(this.state.taskName, this.state.deadline, store)); // <- 変更
const m = Moment(new Date()).add(1, 'days');
this.setState({
deadline: m.toDate(),
taskName: '',
});
}

components/TaskRow.ts

    /**

* ボックスをクリックすると、タスク完了 <-> 未完了 がトグルする
*/

private onClickBox = (id: string, e: React.MouseEvent<HTMLElement>) => {
store.dispatch(createToggleCompleteAction(id, store)); // <-変更
}
/**
* 削除ボタンを押すと、タスクを削除する
*/

private onClickDelete = (id: string, e: React.MouseEvent) => {
store.dispatch(createDeleteTaskAction(id, store)); // <-変更
// クリックイベントを親要素の伝播させない
e.stopPropagation();
}


実行して確認する

ここまでできたら、実行して非同期の動作を確認してみましょう。

> npm run build && npm start


次回

アプリの実装が完了しました。

次回は最終回として、配布のための electron アプリのパッケージ化(windows だと exeファイルの作成)とインストーラーの作成について解説する予定です。