Edited at

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

前回は、TODO アプリの Store、Action、Reducer まで作成しました。

今回は、Component を作っていきますが、その前に styled-components の話をします。


styled-components


概要

今回は CSS を使って装飾をするのですが、CSS ファイルを作成するのではなく、SASS などの AltCSS を使うわけでもなく、CSS in JavaScript を使って見たいと思います。

従来の CSS や AltCSS を別ファイルとして管理する場合、定義したクラス名と JavaScript での実装と離れているので、下記のような問題があります。


  • どこで何が使われているのかわかりにくい。未使用のものの検出が困難。

  • スペルミスした場合に実行時にしかわからない。

  • BEM による命名規則では、冗長的で長すぎる。

  • BEM の思想はブロック単位に独立していることだが、これがコーディング規約で守られにくい。

CSS in JavaScript を実現するために、 styled-components というライブラリを利用します。

これを利用することで、


  • Component 単位で定義するので、スタイルの定義と利用箇所が同じファイル内でわかりやすい。

  • ビルド時に、未使用のものやスペルミスなどの検出ができる。

  • クラス名は、ビルド時に一意なものに自動的に振られるので、命名規則に頭を悩ませなくて良い。


    • 実行時にどのスタイルがあたっているか分かりにくい、という欠点も併せ持ちます。



  • Component 単位で定義するので、独立性が保ちやすい。(完全に独立性が保たれるわけではない)


React は、構造(HTML)に後付で処理を追加する、という手法から、振舞いを持つ構造 という UI 指向の手法にシフトさせる目的がありました。

CSS が、この思想に外れていたのですが、styled-components で 振舞いと装飾を持った構造 で管理することができるようになりました。



使用例

import Styled from 'styled-component';

const RedBox = Styled.div`
background-color: red;
color: white;
`
;

export class Component1 extends React.Component {
public render() {
return (
<RedBox>
赤いよ
</RedBox>
);
}
}

上記のように、Styled.div として、バッククオートを続けてその中にCSSを文字列として定義します。

戻り値は、React の Component を拡張したクラスのオブジェクトが返るので、Component の render でそれを タグとして記載することで、実行時には 指定されたスタイルが適用された div 要素が出力されます。

div の部分には、 pinputspan など HTML として出力したいタグと同じ名前を指定します。

CSS の記述は、JavaScript としては単純な文字列なので、色を変数として持たせて、それを共有することもできます。


HTML では インライン スタイル で作成されるような気がしますが、実際には HTML の head に style タグが作成され、その中に記載されます。


よって、@media や @keyframe など インライン スタイル で使えないものも、styled-components では利用できます。



styled-component のインストール

例によって、 npm でインストールします。

$ npm install --save styled-components && npm install --save-dev @types/styled-components


グローバルスタイルと共通スタイルの実装

コンポーネントを作成する前に、ブラウザのデフォルトスタイルのリセットと、html,body 要素へのスタイル、全体の配色を統一するために、共通色の定義を行います。

スタイルのリセットですが、 reset-css という CSS ライブラリを利用します。

$ npm install --save reset-css

これでダウンロードできるのは、CSS ファイルなので、通常であれば HTML の head に link タグで記述しないといけないのですが、webpack を利用すると CSS ファイルも JavaScript で import で取り込めてしまいます。

ts/components/FoundationStyles.ts

import 'reset-css/reset.css';

import {createGlobalStyle} from 'styled-components';

// グローバル スタイル 定義
// tslint:disable-next-line:no-unused-expression
export const GlobalStyle = createGlobalStyle`
html, body {
font-family: "Meiryo UI";
font-size: 12pt;
height: 100%;
width: 100%;
}
button {
background-color: #ccc;
border-radius: 5px;
border-style: none;
cursor: pointer;
padding: .5em;
transition-property: all;
transition-duration: .2s;
&:hover {
box-shadow: 3px 3px 3px rgba(200,200,200,4);
transform: translate(-2px, -2px);
}
&:active {
background-color: #cccc00;
}
}
input[type=text] {
border-radius: 5px;
border: 1px solid #ddd;
padding: .5em;
}
`
;

// 共通スタイル
// SASS style sheet */
// Palette color codes */
// Palette URL: http://paletton.com/#uid=54r1g0knvBjdsPDiZI7sCwOvApZ */

// Feel free to copy&paste color codes to your application */

// As hex codes */

export const $COLOR_PRIMARY_0 = '#723FBD'; // MAIN PRIMARY COLOR */
export const $COLOR_PRIMARY_1 = '#AE8CE2';
export const $COLOR_PRIMARY_2 = '#8C5FCF';
export const $COLOR_PRIMARY_3 = '#5A21AF';
export const $COLOR_PRIMARY_4 = '#410E8D';

export const $COLOR_SECONDARY_1_0 = '#30B698'; // MAIN SECONDARY COLOR (1) */
export const $COLOR_SECONDARY_1_1 = '#81DFCA';
export const $COLOR_SECONDARY_1_2 = '#52CAAF';
export const $COLOR_SECONDARY_1_3 = '#12A785';
export const $COLOR_SECONDARY_1_4 = '#028568';

export const $COLOR_SECONDARY_2_0 = '#FF5644'; // MAIN SECONDARY COLOR (2) */
export const $COLOR_SECONDARY_2_1 = '#FF9E94';
export const $COLOR_SECONDARY_2_2 = '#FF7668';
export const $COLOR_SECONDARY_2_3 = '#FF311B';
export const $COLOR_SECONDARY_2_4 = '#CF1603';

// Generated by Paletton.com © 2002-2014 */
// http://paletton.com */

export const $COLOR_FOREGROUND = '#333';
export const $COLOR_FOREGROUND_REVERSE = '#fff';


配色パターンは、 http://paletton.com で作成しました。


CSS をインポートするために、 webpack のローダーと config ファイルを修正します。

ローダーには、 css-loaderstyle-loader というものを利用します。

$ npm install --save-dev style-loader css-loader

webpack.config.js の、module.rules に下記を追加します。

module.exports = {

// 省略
module: {
rules: [
// 省略
{
test: /\.css$/,
loaders: ['style-loader', 'css-loader'],
},
]
}
}


Component の作成

Component は、State と連携させる Container となる TaskList.tsx と、リストの各行の要素を TaskRow.tsx 、新規登録部分の要素を AddTask.tsx として作成します。

また、日付を表示するのに、date 型の値から書式を指定した文字列変換が必要です。

日付や時刻を操作するのに、moment というJavaScript界隈では非常にメジャーなライブラリがあるので、インストールしておきます。

$ npm install --save moment && npm install --save-dev @types/moment


moment 公式サイト https://momentjs.com/



タスクリストのタスクごとの部品を作成する

まずは、タスクリストの各行の component を作ります。

ts/components/TaskRow.tsx

import Moment from 'moment';

import React from 'react';
import Styled from 'styled-components';

import { createDeleteTaskAction, createToggleCompleteAction } from '../actions/TaskActionCreators';
import { ITask } from '../states/ITask';
import store from '../Store';
import { $COLOR_SECONDARY_1_3, $COLOR_SECONDARY_2_0 } from './FoundationStyles';

//#region styled
/**
* 行の大外枠...(1)
*/

const Task = Styled.div<{expiration: boolean; }>`
align-items: center;
background-color:
${(p) => p.expiration ? 'inherit' : $COLOR_SECONDARY_2_0};
border-radius: 5px;
cursor: pointer;
border: 1px solid rgb(200,200,200);
display: flex;
flex-direction: row;
margin-bottom: 1em;
padding: 10px;
transition-duration: .2s;
transition-property: all;
/* (2) */
&:hover {
transform: translate(-5px, -5px);
box-shadow: 5px 5px 5px rgba(200,200,200,4);
}
`
;
/**
* タスク完了のチェックアイコン表示 枠
*/

const TaskCheckBox = Styled.div`
align-items: center;
background-color: #fff;
border: 2px solid #ccc;
border-radius: 50%;
display: flex;
justify-content: center;
flex-grow: 0;
flex-shrink: 0;
height: 2em;
width: 2em;
`
;
/**
* タスク完了チェックアイコン
*/

const TaskCheck = Styled.p`
color:
${$COLOR_SECONDARY_1_3};
font-size: 150%;
`
;
/**
* タスク名と期日の表示 枠
*/

const TaskBody = Styled.div`
display: flex;
flex-direction: column;
flex-grow: 1;
flex-shrink: 0;
height: 3em;
justify-content: space-around;
`
;
/**
* タスク削除アイコン
*/

const TaskRemove = Styled.div`
flex-grow: 0;
flex-shrink: 0;
`
;
/**
* タスク名
*/

const TaskName = Styled.div`
font-size: 120%;
`
;

/**
* 期日
*/

const Deadline = Styled.div`
`
;

//#endregion

class TaskRow extends React.Component<ITask, {}> {
public render() {
const it = this.props;
const deadlineString = Moment(it.deadline).format('YYYY-MM-DD hh:mm');
return (
<Task expiration={new Date() < it.deadline || it.complete}
onClick={this.onClickBox.bind(this, it.id)}>
<TaskCheckBox>
<TaskCheck>
{it.complete ? '' : null}
</TaskCheck>
</TaskCheckBox>
<TaskBody>
<TaskName>{it.taskName}</TaskName>
<Deadline>{deadlineString}</Deadline>
</TaskBody>
<TaskRemove onClick={this.onClickDelete.bind(this, it.id)}></TaskRemove>
</Task>
);
}
/**
* ボックスをクリックすると、タスク完了 <-> 未完了 がトグルする
*/

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

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

export default TaskRow;


  • (1)... styled のコンポーネントに引数を付けて、それによってCSSを変化させる場合、引数をジェネリック型で記述し、文字列の中でソースにあるような関数を宣言します。

  • (2)... sass 記述の一部が利用できます。

styled-components の記述で多少長くなっていますが、肝心のコンポーネントの部分は、プロパティとして受けたデータ(ITask 型のオブジェクト)を使った表示のみの記述になっており、シンプルになっています。

クリックのイベント処理も、create~ で action を作成し、 store.dispatch を呼ぶだけになっているので、これも簡素です。

このように、Redux のフレームワークを利用することで、表示の部分とデータの更新の部分が分離でき、ソースが管理しやすくなっていることがわかると思います。


タスク追加部分の部品を作成する

続けて、タスクを追加する component を作ります。

その前に、ここでは日時の入力に、 react-datepicker というライブラリを利用しますので、npm でインストールしておきます。


最新の react-datepicker のバージョン(v2.x)では、moment に依存しない仕様となり、これ以降に書いてある記事ではエラーが発生します。

ここでは、執筆時点の過去のバージョンを指定します。


$ npm install --save react-datepicker@1.7.0 && npm install --save-dev @types/react-datepicker@1.1.7

ts/components/AddTask.tsx (下記は不完全)

import 'react-datepicker/dist/react-datepicker.css'; // (1)

import Moment from 'moment';
import React from 'react';
import DatePicker from 'react-datepicker';
import Styled from 'styled-components';
import { v4 as UUID } from 'uuid';

import { createAddTaskAction } from '../actions/TaskActionCreators';
import store from '../Store';
import { $COLOR_SECONDARY_1_3 } from './FoundationStyles';

/**
* コンポーネント プロパティ
*
* ここでは、初期値として扱う
*/

interface IProps {
/** タスク名 */
taskName: string;
/** 期限 */
deadline: Date;
}

//#region styled
const Container = Styled.div`
align-items: center;
display: flex;
flex-direction: row;
margin: 1em 0;
width: 100%;
`
;

const TextBox = Styled.input`
box-sizing: border-box;
width: 100%;
`
;

const TaskNameBox = Styled.p`
flex-grow: 1;
`
;

const DeadlineBox = Styled.div`
`
;

const AddButton = Styled.button`
background-color:
${$COLOR_SECONDARY_1_3};
border-radius: 50%;
color: white;
display: block;
font-size: 150%;
height: 40px;
padding: 0;
width: 40px;
`
;

//#endregion

export class AddTask extends React.Component<IProps, {}> {
public render() {
const date = Moment(this.props.deadline);
const taskNameId = UUID();
const deadlineId = UUID();
return (
<Container>
<TaskNameBox>
<label htmlFor={taskNameId}>task name</label>
<TextBox id={taskNameId} type="text" value={this.props.taskName}
onChange={() => {/* ここは後で */ }} />
</TaskNameBox>
<DeadlineBox>
<label htmlFor={deadlineId}>dead line</label>
<DatePicker selected={date} showTimeSelect={true}
dateFormat="YYYY-MM-DD HH:mm" onChange={() => {/* ここは後で */ }} />
</DeadlineBox>
<AddButton onClick={this.onClickAdd}>+</AddButton>
</Container>
);
}

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

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


  • (1)... react-datepicker のためのスタイルを読み込みます。

後で説明しますが、このコンポーネントは不完全です。

とりあえず、画面を表示するために、タスクのリストとタスク追加を表示する component を作成します。


タスクリストとタスク追加を表示する画面を作成する

このコンポーネントが、Redux の Store と連携する"Container"となります。

ts/components/TaskList.tsx

import Moment from 'moment';

import React from 'react';
import { connect } from 'react-redux';
import Styled from 'styled-components';

import { createShowTasksAction } from '../actions/TaskActionCreators';
import { ITaskList } from '../states/ITask';
import store, { IState } from '../Store';
import { AddTask } from './AddTask';
import { $COLOR_FOREGROUND_REVERSE, $COLOR_PRIMARY_0, $COLOR_PRIMARY_3 } from './FoundationStyles';
import TaskRow from './TaskRow';

//#region styled
const MainContainer = Styled.div`
margin: 10px auto 0 auto;
max-width: 600px;
min-width: 300px;
width: 80%;
`
;

const Header = Styled.h1`
background-color:
${$COLOR_PRIMARY_3};
color:
${$COLOR_FOREGROUND_REVERSE};
font-size: 160%;
padding: 1em;
text-align: center;
`
;

const AddButton = Styled.button`
border-radius: 5px;
background-color:
${$COLOR_PRIMARY_0};
color:
${$COLOR_FOREGROUND_REVERSE};
width: 100%;
padding: 1em;
`
;

const TaskList = Styled.div`
display: flex;
flex-direction: column;
margin-top: 1em;
`
;

//#endregion

class TodoList extends React.Component<ITaskList, {}> {
public componentDidMount() {
store.dispatch(createShowTasksAction([])); //...(a)
}
public render() {
const { tasks } = this.props;
const taskListElems = tasks.sort((a, b) => { // ...(b)
return (a.deadline < b.deadline) ? -1
: (a.deadline.getTime() === b.deadline.getTime()) ? 0 : 1;
}).map((it) => {
return (
<TaskRow key={it.id} {...it} /> // ...(c)
);
});
return (
<div>
<Header>TODO</Header>
<MainContainer>
<AddTask taskName="" deadline={Moment().add(1, 'days').toDate()} />
<TaskList>
{taskListElems /* ...(b')*/}
</TaskList>
</MainContainer>
</div>
);
}
}

const mapStateToProps = (state: IState): ITaskList => {
return state.taskList;
};

export default connect(mapStateToProps)(TodoList);

コードの説明です。


  • (a)...componentDidMount メソッドとは、React.Component のライフサイクル・メソッドで、コンポーネントの初回レンダリング直後に実行されます。 (参考: React.jsのComponent Lifecycle - Qiita)

    ここでは、初回は空の配列を渡されているので、データをロードして表示する処理をコールしています。

    今はデータをソースにハードコーディングしていますが、今後ファイルから非同期に取得することになるので、このようにしています。

  • (b)...プロパティとして渡されたITaksのリストオブジェクトをもとに、先に作成したTaskRowの要素のリストを作成します。動的な Element を作成するには、このように変数に格納して、(b')の箇所のように展開することができます。

  • (c)...{...it}という記述は、プロパティ名と it のプロパティ名が一致している場合にこのように省略して書くことができます。この行は下記と同じ意味になります。

<TaskRow key={it.id} id={it.id} complete={it.complete} deadline={it.deadline} taskName={it.taskName} />


index.tsx の修正

最後に、作成した container (TaskList) を画面に表示するように、index.tsx を修正します。

また、全体のスタイルを割り当てます。

ts/index.tsx

import React from 'react';

import ReactDom from 'react-dom';
import { Provider } from 'react-redux';
import TaskList from './components/TaskList'; import Store from './Store';
import { ThemeProvider } from 'styled-components';
import { GlobalStyle } from './components/FoundationStyles';

const container = document.getElementById('contents');

ReactDom.render(
<div>
<Provider store={Store}>
<TaskList />
</Provider>
<ThemeProvider theme={{}}>
<GlobalStyle theme="" />
</ThemeProvider>
</div>,
container,
);


ThemeProvider は、styled-component vテーマを定義して動的に切り替える事ができる仕組みです。

ここでは、単一のテーマしか用意していないので、theme={{}} としています。



とりあえず動かしてみる

これで、とりあえずビルドして動作するところまでできました。

$ npm run build

$ npm start

下記のように画面が出たでしょうか?

タスクのリスト表示、タスクの完了/未完了のトグル、タスクの削除は、問題なく動作すると思います。

しかし、タスクの追加については、textbox の変更イベントによるステートの変更の処理がないので、入力できない状態になっています。

次回は、これをコンポーネントの local state を利用して解決したいと思います。