LoginSignup
121
120

More than 5 years have passed since last update.

Electron+React+Reduxで作るレトロなエクスプローラのハンズオン(チュートリアル)

Last updated at Posted at 2017-05-20

はじめに

前提知識ゼロからReact、Redux、Electronの組み合わせでレトロなエクスプローラーを作って、その仕組みを知るハンズオンです。
プロジェクト作成から、モジュールのフォルダ構成の決定、実際のコードを書き、ビルドして実行するまでを長々と解説します。

目的

ReactやReduxって何?、Fluxとか敷居高そう、解説記事を読んでもよくわからないし、難しいアプリケーションやそのソースコードは重たい。
手っ取り早く動く簡単なサンプル作って動きや書き方を知りたい!という方に向けて作ったものです。

あらかじめお断りしますが、記事単体で完結させたいため長いです。しかしほとんどコードです。

解説は、今回の範囲だけで別の記事がたくさん書けそうなので、整理が終わってから(来週中には)作成したいと思います。

あいさつ

01、02、03は犠牲となったのだ。04以降の犠牲にな。

この記事を読んでできるもの

レトロなエクスプローラー、プロトタイプ04です。
右は[View]->[Toggle Develop Tools]で表示されるデバッグのコンソールです。実際に操作しながらイベントの発行、ロジックの状態とイベントの内容を見ることができます。
レトロエクスプローラー
ソースコードはこの記事に全てあります。

サマリ

  • 幾つかのコマンド実行ですぐ開発できる環境を構築し、
  • 十数ファイル(多い)をコピーするだけで、
    (ほとんどが30行以下。画面コンポーネントの数分膨らんでいます)
  • すぐ動くレトロなエクスプローラーが作成でき、
  • 開発過程とデバッグ情報からどういう仕組みかを知ることができます。

「レトロエクスプローラーの実装方針・構成」が唯一の解説個所です。
それ以外は注釈はつけていますが作業として流していただいて構いません。
詳しい解説は別記事を予定しています。

React/Reduxについて

Reactは、FacebookがMVCを採用をやめることにしたときに代替として作ったVを担当するフレームワークです。

  • UIだけのフレームワークです。Fluxという名前です。
  • データの流れが一方向になります。アクション→ディスパッチ→ストア→ビュー→アクションと、データの流れが一方向になるアーキテクチャです。
  • JSXという拡張タグを使ってVirtual DOM操作と呼ばれる直感的な方法でビューを作成することができます。
  • 担当領域はプレゼンテーションのみなので、データの永続化、ビジネスロジック等はアプリケーションで解決する必要があります。

ReduxはReactのフレームワークの考え方であるFluxの実装の一つです。

  • React以外にもAngularやVueでも使えますが、Reactとの相性がいいです。
  • 3つの方針があります。
    • Single source of truth - アプリケーションのStoreは一つとするべき。
    • State is read-only - stateは、読み取り専用とするべき。
    • Mutations are written as pure functions - 状態遷移のreducersはpureな関数とするべき。
  • Action, Action Creator, Components, Containers, Reducers, Store, state, props という基本概念を理解する必要があります。本記事で概要を知る助けになれば幸いです。

前提条件

Electronの開発環境構築をしておく必要があります。
最新の記事だと、自分の記事で恐縮ですがElectronのインストールからHello Worldまで
他にもいろいろ記事はありますので参考にしてください。
どの記事でも時間がたつと変わる可能性があるため、公式サイトのQuick Startを見てほしいです。
エディタは何でも構いません。私はとりあえずVisual Stdio Codeを使っています。
プロジェクトのフォルダを開く(右クリックメニュー Open With Code)を行い、Ctrl+@でターミナルが表示されるので、そこでコマンドを「npm」や「electron .」コマンドを打っています。

プロジェクト作成

React、Redux、その他必要なモジュールが入ったプロジェクトを作成します。
コマンドのカタマリを4回実行するだけです。

create-react-app

create-react-appはFacebookがReactを使った新規プロジェクト作成用のために作ったもモジュールです。
React,Babel(*1),Webpack(*2), ESLint(*3)やその他のツールなど全部入りプロジェクトのひな型を生成できます。
公式ページのCreate New Applicationのところで紹介されています。

*1 ES6などの拡張されたJavaScriptを通常のJavaScriptにするコンパイラ
*2 ビルド管理ツール
*3 構文や文法のエラーをチェックする静的解析ツール

create-react-appだけは、npmでグローバルにインストールします。

  npm install -g create-react-app

プロジェクトの作成、Reduxのインストール

プロジェクトは以下のコマンドで作成します。これでひな形となるファイル群が生成されます。

create-react-app file-explorer-handson

フォルダはcreate-react-appが作るため作成する必要はありません。
またすべて小文字で書かないとエラーとなります。

Redux(*4)のインストール。

*4 Fluxフレームワークの実装の一つでReactと相性が良い。

  cd file-explorer-handson
  npm install --save redux react-redux
  npm install --save-dev redux-devtools

ビルドできるかの確認。

  npm run build

Electron化

Electronのインストール。

以下のコマンドをfile-explorer-handsonフォルダで実行します。

  npm install --save electron

不要なファイルを削除します。

  public/favicon.ico
  src/App.css, App.js, App.test.js, registerServiceWorker.js
      index.css, index.js, logo.svg (記事作成時点ではsrcフォルダ以下すべて不要です)
  • public/index.html, manifest.jsonのみ残ります。

ハンズオンで使うフォルダ、ファイルのひな型を作成

コマンドプロンプトのcdコマンドでfile-explorer-handsonに移動し以下を実行します。(Windows限定)
最後の空行までコピーして貼り付ければファイルが作成されます。最後のファイルが作られないときはエンターを押してください。

mkdir .\src\actions
mkdir .\src\components
mkdir .\src\containers
mkdir .\src\reducers
echo //main.js> .\src\main.js
echo //index.js> .\src\index.js
echo //index.js> .\src\actions\index.js
echo //App.js> .\src\components\App.jsx
echo //FolderTreeItem.jsx> .\src\components\FolderTreeItem.jsx
echo //PathItemList.jsx> .\src\components\PathItemList.jsx
echo //AddressBarPanel.js> .\src\containers\AddressBarPanel.js
echo //FolderTreePanel.js> .\src\containers\FolderTreePanel.js
echo //PathListPanel.js> .\src\containers\PathListPanel.js
echo //index.js> .\src\reducers\index.js
echo //folderTreeItem.js>.\src\reducers\folderTreeItem.js
echo //pathItemList.js>.\src\reducers\pathItemList.js

main.jsの作成(Quick Startからコピー)

以下をコピーします。
このファイルは、記事作成時点でのElectronのQuick Startのページからコピーしたものを、コメントを消しloadURLのパスの変更(10L)と、デバッグモードをコメントアウト(15L)したものです。

src/main.js
const {app, BrowserWindow} = require('electron')
const path = require('path')
const url = require('url')

let win
function createWindow () {
  win = new BrowserWindow({width: 800, height: 600})
  win.loadURL(url.format({
    //react-scrpitでビルドされるファイルをロードする
    pathname: path.join(__dirname, '/../build/index.html'),
    protocol: 'file:',
    slashes: true
  }))
  // デバッグツールはデフォルトOFF.
  //win.webContents.openDevTools()
  win.on('closed', () => {
    win = null
  })
}

app.on('ready', createWindow)
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit()
  }
})
app.on('activate', () => {
  if (win === null) {
    createWindow()
  }
})

index.htmの変更(タイトルの変更)

自動生成された public/index.html を開き、以下のようにタイトルを書き換えます。

public/index.html
 <title>Retro Explorer HandsOn</title>

package.jsonの変更(Electron化)

Electronアプリとして認識させるため、package.json に以下の2行を追加します。記述場所はどこでもよいですが、いつもは"private":true,の下に書いています。

package.json
  "homepage" : ".",
  "main" : "./src/main.js",

"homepage" : "."を忘れると、コンパイルしたファイルを実行した際に、絶対パスの
リソース(js,css等)を参照してエラーになるため必ず設定してください。

レトロエクスプローラーの実装方針・構成

Flux、Reduxのサンプルや概念にのっとったフォルダ構成の作成

フォルダ構成は公式サンプルと同じにしますが、ファイル名は分かりやすく変えます。
Todoアプリケーションのサンプルが公開されていますが、ファイル名がTodo.js, TodoList.js, todos.jsなどとなっており混乱の元になったため、クラッシクなMVCや .Net、一般的な単語、既存のフレームワークの単語にマッピングした名前でクラス名、モジュール名を記述します。
そうすることで類推ができるので覚える負荷は下がると思います。
ただし、Reducersだけは覚える必要があります。

Flux、Reduxの用語と既存の用語へのマッピング

React/Reduxの用語 既存の概念や用語 概要
Action, Action Creator イベントの定義 イベントの種類を表すタイプと、データの状態を変えるためのパラメータを持つ。 例:ツリーノードでマウスがクリックされた=ノードがパラメータ フォルダが選択された=パスがパラメータ
Component 画面を構成する部品。コンポーネント、コントロール データの状態に応じた描画とイベントハンドラの呼び出しを行う 例:ボタン、ツリーのノード
Container 画面の単位。フォーム、パネル、フレーム ユーザーの操作や入力からイベントの発行をし、データの状態と画面の表示と結びつける 例:検索画面、ツリー表示画面
Reducer データの状態を遷移させるロジック イベント(action)のパラメータと前のデータの状態(state)を元に新しい状態(state)を作るピュアな関数群 例:フォルダが選択されたイベントのパラメータ(パス)をもとに、リストの状態(フォルダにあるファイルの一覧)を更新する
state データの状態(ミュータブル=可変) ReduxではReducersに、イベント(action)と共にわたってくる。
props データの状態(イミュータブル=読み取り専用) Reduxではhtmlタグの要素などとしてわたってくる
Store データの状態の保管庫 Reduxではデータの状態の構造と表示はフレームワークによって自動的に管理される

公開されているTodoアプリなどのExampleのフォルダ構成(下の4フォルダ)と、今回作るファイル名の対応は以下の通りです。

フォルダ 内容 ハンズオンでの命名ルール
actionsフォルダ actionがまとめてindex.js(=action creator)に関数として記述されている。関数名は小文字から始められる。 actionの関数名は*Event(openButtonClickEvent ... )と名付けます。
componentsフォルダ 画面の部品のjsをまとめるフォルダ。大文字から始まるファイル名を付けられている アイテム(PathItem, PathItemList, FolderTreeItem)と名付けます
containersフォルダ 画面の単位のjsをまとめるフォルダ。大文字から始まるファイル名を付けられている パネル *Panel (PathListPanel, FolderTreePanel ... )と名付けます。
reducersフォルダ 状態遷移のロジックが書かれたjsをまとめるフォルダ。index.jsにインポートして他のファイル向けにexportしている。 アプリケーションで管理する状態の名前を小文字から始める慣例にしたがいます。(pathItemList, folderTreeItem)

作るファイルの一覧です。public/index.htmlは含んでいません。
ファイル数は多いのですが、1つ1つは30行以下がほとんどです。
ポイントも記述していきますので長丁場にお付き合いください。


src                     ... ソースのルートフォルダ
│  index.js                 ... アプリケーションを表すファイル(状態と画面の接続)
│  main.js                  ... Electronのエントリーポイント(QuickStartからコピー)
│
├─actions               ... イベントの定義
│      index.js
│
├─components            ... 画面の部品(アイテム)
│      App.jsx              ... アプリケーションを表す src/index.jsで使う
│      FolderTreeItem.jsx   ... フォルダツリーのノードのアイテム
│      PathItemList.jsx     ... パス一覧の行と全体のリスト
│
├─containers            ... 画面の単位(パネル)
│      AddressBarPanel.js   ... アドレスを入力し開くするパネル
│      FolderTreePanel.js   ... フォルダツリーを表示するパネル
│      PathListPanel.js     ... パス一覧を表示するパネル
│
└─reducers              ... reducers(状態遷移のロジック)
        index.js            ... reducerの結合を行う
        folderTreeItem.js   ... フォルダツリーの状態
        pathItemList.js     ... パス一覧の状態

React+Reduxを使ったElectronディスクトップアプリケーション開発の流れ

5ステップに分けています。特にこれに従わないといけないわけではありません。

1-4のイメージです。5はフレームワークによって行われます。
画面と状態
イベントと状態遷移

  1. 画面を設計します。

    • 画面の単位の構成(パネル=container)を決めます。
      エクスプローラーのなので、アドレスバーとツリー、リストのパネルです。
    • 画面の部品(アイテム=components)を決めます。
      ツリーのノード、パスの1行、パス一覧をそれぞれ表すアイテムです。
  2. 画面からアプリケーションが管理するもの=状態を抜き出します。

    • フォルダツリー構造を持ったルートのアイテムと、リストに表示するパス一覧です。
    • 後述もしていますが状態遷移のロジックは分割したreducersとして作成し、合成します。
  3. ユーザーの画面入力から、発行されるイベントとそのパラメータを定義します。

    • 今回は3つだけです。
      • 「アドレスバーからルートフォルダを開く」、
      • 「ツリーノードをクリックしてフォルダパスを表示」、
      • 「ツリーノードの展開/折畳状態の反転」
    • イベントのパラメータは、パス(3つとも)、展開状態(最後の一つのみ)です。
    • イベントの定義=actionは一番簡単です。
  4. アプリケーションが受け取るイベントごとに状態を変える処理(reducers)を作ります。
    ここは非常に重要なので詳しく説明します。

    • reducers(reduce関数*が由来)という名前の状態遷移を行う純粋な関数群を作ります。
    • reducersは、イベント(action)と前の状態(state)から新しい状態(state)を返す関数です。 前の状態(state)を変えてはいけません。常に新しい状態を作って返します。 平たく言うとGUIの状態を遷移させるロジックを書きます。
    • 通常は、モジュールが大きくならないよう分割して記述し結合します。
  5. アプリケーションはイベントの送出と変わった状態を表示します。
    開発者は何もする必要ありません。フレームワークが状態を表示します。

2017/5/23訂正

  • reduce関数
    reduce関数は配列の要素に対して、引数で渡した関数を実行する関数です。 渡す関数には、引数として前回の実行結果(最初の場合は初期値)、 現在の実行結果、インデックス、対象の配列を指定します。戻り値は最後に行った値になります。

Redux+Reactでは、他のフレームワークと同じように、ふつうの設計と開発をすればよいです。
Redux+Reactが2のツリー構成管理、5の表示を自動でやってくれます。
1と2の状態の定義を設計した後は、3と4の実装に注力できるフレームワークになっています。

レトロなエクスプローラーのハンズオン

それでは上の流れに沿って実装を行っていきます。解説の都合上、手順は少し前後します。

1. 画面の設計

1-1. メイン画面の作成

画面の作成では、componentsフォルダにファイルを作成します。
まず、アプリケーションの画面の定義を行います。

ポイント

  • 画面の単位である、AddressBarPanel, FolderTreePanel, PathListPanelをタグで記述しています。画面のレイアウトを変える場合はこのファイルを編集すればOKです。
  • このファイル(App.jsx)はAppとうタグとしてエクスポートしています。
  • Appタグは、最後につくるsrc/index.jsでデータの状態(explorerAppというstore)と結合されます。
src/components/App.jsx
import React from 'react'
import AddressBarPanel from '../containers/AddressBarPanel'
import FolderTreePanel from '../containers/FolderTreePanel'
import PathListPanel from '../containers/PathListPanel'
/* アプリケーション画面の定義 */
const App = () => (
  <div>
    <AddressBarPanel />
    <hr/>
    <FolderTreePanel />
    <hr/>
    <PathListPanel />
  </div>
)
export default App

2. 状態(reducers)の定義

エクスプローラーは、2つの状態を管理します。
ツリーノードの構造の状態(folderTreeItem)と、現在選択しているフォルダのパス一覧(pathItemList)です。ここでは定義だけで、2つの状態遷移のロジックのファイルは最後に作成します。

ポイント

  • 2つの状態遷移の関数群(folderTreeItemと、pathItemList)をインポートします。
  • combineReducers で2つの状態遷移のロジックを結合します。
  • 最後に、explorerApp という名前でエクスポートします。このexplorerAppも、最後に作るsrc/index.jsでストアとして上記 App タグと結合します。
src/reducers/index.js
import { combineReducers } from 'redux'
import folderTreeItem from './folderTreeItem'
import pathItemList from './pathItemList'
/* 分割した状態遷移の関数群(reducers)の結合(combineReducers呼び出し) */
const explorerApp = combineReducers({
  folderTreeItem,
  pathItemList,
})

export default explorerApp

1. の続き

1-2. 画面の単位ごとにパネル(container)の作成

コンテナの役目は複雑です。一番つまづくところかもしれません。簡単な例から挙げていきます。

ポイント

  • コンテナには、画面の部品(components)が乗ります。
  • ユーザーの入力からイベントを発行(dispatch)し、その後、状態遷移のロジックがフレームワークによって呼び出されます。
  • コンテナと関係のあるデータの状態を切り出し(mapStaeteToPropsという作成した関数)、イベントとのマップ(mapDispatchToPropsという作成した関数)を定義し、それを画面コンポーネントに接続(connectというAPI)します。
    • 書き方:const (コンテナ)=connect(切り出した状態,イベントのマップ)(接続させるコンポーネント)
    • 例:const FolderTreePanel = connect(mapStateToProps,mapDispatchToProps)(FolderTreeItem)

PathListPanelは一番簡単で行数も少ないコンテナです。更新する状態はpathItemList、イベント発行、イベントのマップともに持ちません。表示するだけのコンテナです。

src/containers/PathListPanel.js
import { connect } from 'react-redux'
import PathItemList from '../components/PathItemList'
/* パスアイテムリストの状態の切り出し */
const mapStateToProps = (state)=> {
  return {
    pathItemList: state.pathItemList
  }
}
/* パスアイテムリストの状態と画面(コンテナ)の接続(connect呼び出し) */
const PathItemListPanel = connect(
  mapStateToProps
)(PathItemList)

export default PathItemListPanel

AddressBarPanelは、コンポーネント(AddressBar)の描画も行っているコンテナです。アドレスバーの開くボタンから最初のツリーノードを表示するイベントの発行(dispatch)を行っています。
更新する状態はツリーノード(FolderTreeItem)です。ただし、FolderTreePanelと違い、イベントのマップ(mapDispatchToProps)を持ちません。

src/containers/AddressBarPanel.js
import React from 'react'
import { connect } from 'react-redux'
import { openButtonClickEvent } from '../actions'

const inputStyle = {"width":"300px"}
/* アドレスバーの画面とイベント発行 
   dispatch後、reducers(folderTreeItem)が呼び出される。*/
const AddressBar = ({ dispatch }) => {
  let folderPath
  return (
    <div>
      <form onSubmit={e => {
        e.preventDefault()
        if (!folderPath.value.trim()) {
          return
        }
        console.log('==> イベント発行(コンテナ) openButtonClickEvent rootPath=' + folderPath.value)
        dispatch(openButtonClickEvent(folderPath.value))
       }}>
        <input style={inputStyle} ref={node => {
          folderPath = node
        }} />
        <button type="submit">
          開く
        </button>
      </form>
    </div>
  )
}
/* フォルダツリーアイテムの状態の切り出し */
const mapStateToPorps = (state) => {
  return {
    folderTreeItem : state.folderTreeItem, 
  }
}
/* フォルダツリーアイテムの状態と画面(コンテナ)の接続(connect呼び出し) */
const AddressBarPanel = connect(
  mapStateToPorps
)(AddressBar)

export default AddressBarPanel

FolderTreePanelは、上記のconnectの例の通りの実装をしています。folderTreeItemを対象の状態(state)として切り出し、展開リンクのクリックによる展開/折畳のイベントの発行、パスのクリックによるパス一覧の表示イベントの発行をします。そしてconnectによってFolderTreeItemコンポーネントとFolderTreePanelを接続します。

src/containers/FolderTreePanel.js
import { connect } from 'react-redux'
import { folderTreeItemClickEvent, toggleExpandClickEvent } from '../actions'
import FolderTreeItem from '../components/FolderTreeItem'
/* フォルダツリーのアイテムの状態の切り出し */
const mapStateToProps = (state) => ({
  folderTreeItem: state.folderTreeItem,
})
/* イベントハンドラ関数とイベント(action)のマッピング
   dispatch後、reducers(folderTreeItem)が呼び出される。*/
const mapDispatchToProps = (dispatch) => ({
   onFolderClick: (fullpath) => {
     console.log('==> イベント発行(コンテナ) folderTreeItemClickEvent fullpath=' + fullpath)
     dispatch(folderTreeItemClickEvent(fullpath))
   },
   onExpandClick: (fullpath, isExpanded) => {
     console.log('==> イベント発行(コンテナ) toggleExpandClickEvent fullpath=' + fullpath)
     dispatch(toggleExpandClickEvent(fullpath, isExpanded))
   },
})
/* フォルダアイテムの状態+イベントマッピングと画面(コンテナ)の接続(connect呼び出し) */
const FolderTreePanel = connect(
  mapStateToProps,
  mapDispatchToProps
)(FolderTreeItem)

export default FolderTreePanel

1-3. コンポーネント(component)の作成

Reactでは、JSXというHTMLタグに似たタグをJavaScript内に埋め込むことができます。
仮想DOMという技術で、より直感的な画面をイメージして記述することができます。
ソースコードを書くときは、「{」と「}」でくくって書きます。JavaでいうEL式のようなものです。

ポイント

  • 自分で作ったコンポーネントをタグのように記述して画面を表していきます。
  • パス一覧(PathItemList)は、内部にパスの一行を表す別のコンポーネント(PathItem)を記述しています。
  • ツリーノード(FolderTreeItem)は再帰処理を行うコンポーネントです。
  • PropTypesによって、描画コンポーネントの属性の型をチェックすることができます。

先に簡単なPathItemListから紹介します。

src/components/PathItemList.jsx
import React from 'react'
import PropTypes from 'prop-types'
/* パスアイテムリストの描画 */
const PathItemList = ({pathItemList = []}) => (
  <table>
    <tr>
      <th>Name</th>
      <th>Modified</th>
    </tr>
    { 
      pathItemList.map(pathItem =>
      <PathItem
        {...pathItem} // パスアイテムの描画を呼び出す
      />
      )
    }
  </table>
)
/* パスアイテムリストの型の定義*/
PathItemList.propTypes = {
  pathItemList: PropTypes.arrayOf(PropTypes.shape({
    name: PropTypes.string.isRequired,
    modified: PropTypes.string.isRequired,
    isDirectory: PropTypes.bool.isRequired
  }).isRequired).isRequired,
}
/* パスアイテムの描画 */
const PathItem = ({ name, modified, isDirectory }) => (
  <tr>
    <td>
      {/* 名前 */name}{/*ディレクトリなら[/]を付ける*/isDirectory ? "/" : ""}
    </td>
    <td>
      {/* 更新日 */modified}
    </td>
  </tr>
)
/* パスアイテムの型の定義 */
PathItem.propTypes = {
  name: PropTypes.string.isRequired,
  modified: PropTypes.string.isRequired,
  isDirectory: PropTypes.bool.isRequired
}

export default PathItemList

次が再帰処理を行うFolderTreeItemです。このファイルだけテイストが違うのは、再帰描画をする際にイベントのバブリング(下から上のタグへの伝播)に対応したためです。e.stopPropagationがReactでは効かないため、thisスコープのイベントハンドラ関数にbindを使って結合しています。

src/components/FolderTreeItem.jsx
import React from 'react'
import PropTypes from 'prop-types'

const divStyle = { "list-style": "none" }
/* フォルダツリーアイテムの描画 */
const FolderTreeItem = React.createClass({
  /* フォルダ展開/折畳イベントのハンドラ */
  onExpandClick: function(fullpath, isExpanded) {
    this.props.onExpandClick(fullpath, isExpanded);
  },
  /* パスリスト表示イベントのハンドラ */
  onFolderClick: function(fullpath) {
    this.props.onFolderClick(fullpath);
  },
  /* フォルダツリーアイテムの描画処理 */
  render : function() {
    let folderTreeItem = this.props.folderTreeItem
    return <div style={divStyle}>
     <li>
       <a onClick={() => { // 展開/折畳イベントの実行(bubblingさせないようbindする)
         this.onExpandClick.bind(this, folderTreeItem.fullpath, folderTreeItem.isExpanded)
         this.onExpandClick(folderTreeItem.fullpath, folderTreeItem.isExpanded)
        }}>
         {/* (+), (-)表示 */ 
           folderTreeItem.isExpanded ? <font color='red'>(-)</font> : <font color='blue'>(+)</font>
         }
        </a>
        &nbsp;
        <a onClick={()=>{ // パス一覧表示イベントの実行(bubblingさせないようbindする)
          this.onFolderClick.bind(this, folderTreeItem.fullpath)
          this.onFolderClick(folderTreeItem.fullpath)
         }}>
          {/* フォルダ名 */folderTreeItem.name} 
        </a>
        <ul>
        { // 子フォルダツリーアイテムの再帰描画
          folderTreeItem.children.map(child =>
            <FolderTreeItem 
              folderTreeItem={child} 
              onExpandClick={this.props.onExpandClick} 
              onFolderClick={this.props.onFolderClick} />
          )
        }
        </ul>
      </li>
    </div>
  }
})
/* フォルダツリーアイテムの型の定義*/
FolderTreeItem.propTypes = {
  folderTreeItem: PropTypes.object.isRequired,
  onFolderClick: PropTypes.func.isRequired,
  onExpandedClick: PropTypes.func.isRequired,
}

export default FolderTreeItem

3. イベント(action)の定義

イベントの定義です。一番簡単です。

ポイント

  • イベントには、typeという識別文字列を付けます。イベントは、すべての状態遷移のロジックに飛ぶため、アプリケーションで一意になるようにする必要があります。
  • type以外のパラメータは任意でつけることができます。フォルダを開くイベントでは、選択されたfullpathを、フォルダの展開状態のトグルイベントでは、fullpathとisExpandedという状態を渡しています。
  • typeは、ただの文字列ではなく、constで定義するのが一般的です。
src/actions/index.js
export const EXPAND_ROOT   = 'ルートフォルダの展開イベント'
export const TOGGLE_EXPAND = 'フォルダの展開/折畳イベント'
export const SHOW_PATHLIST = 'フォルダの一覧表示イベント'
/* ルートフォルダの展開イベント(action) */
export const openButtonClickEvent = (rootpath) => ({
  type: EXPAND_ROOT,
  rootpath,
})
/* フォルダの展開/折畳イベント(action) */
export const toggleExpandClickEvent = (fullpath, isExpanded) => ({
  type: TOGGLE_EXPAND,
  fullpath,
  isExpanded,
})
/* フォルダの一覧表示イベント(action) */
export const folderTreeItemClickEvent = (fullpath) => ({
  type: SHOW_PATHLIST,
  fullpath,
})

4. 状態遷移のロジックの実装

単純な機能しかないエクスプローラーであってもロジックは、非常に長くなります。
適度に分割し、2番目に作ったsrc/reducers/index.jsにあるようcombineReducersで結合します。

ポイント

  • 状態遷移の関数には、dispatchされたaction(イベント)のtypeとパラメータ、および遷移前の状態が渡されます。また初回表示などで使う state の初期値を設定することができます。
  • 新しい状態(state)は、必ず新規作成(Object.assign({},state)など)を行って帰す必要があります。前の状態を変えるのはNGです。
  • stateは、すべての状態を含むわけでなく、コンテナで切り出した状態のみを含みます。コンテナのmapStateToProps関数で定義しています。このアプリケーションではstateは、pathListItemだったり、folderTreeItemだったります。
  • actionのtypeが関係ないときは、そのままstateを返します。
  • 状態遷移の関数は、デバッグライトを入れているので初回表示時にtypeがReduxの値で呼び出されていることがわかります。

特に解説はありませんが、はまった事例があるので紹介しておきます。

  • const fs = require('fs')やpathのrequireが動作しませんでした。window.require('fs')という呼び方をしています。海外のサイトで解決策が上がっていましたので、別記事にて紹介します。

それ以外は普通の処理なので、何も考えずにコピーしてください。

src/reducers/pathItemList.js
import {SHOW_PATHLIST} from '../actions'
/* パスリストの初期値 */
const initialState = []
/* パスリストの状態の更新 */
const pathItemList = (state = initialState, action) => {
  console.log("--イベント(action)の内容---")
  console.log(JSON.stringify(action))
  console.log("--------------------------")
  console.log("--変更前の状態(state)------")
  console.log(state)
  console.log("--------------------------")
  switch (action.type) {
    case SHOW_PATHLIST: // 'フォルダの一覧表示イベント'
      let fullpath = action.fullpath
      console.log('* 表示するフォルダパス=' + fullpath)
      const fs = window.require('fs')
      const path = window.require('path')
      let names = fs.readdirSync(fullpath)
      let pathItemList = []
      names.map(name => {
        let stat = fs.statSync(path.join(fullpath, name))
        let modified = toLocaleTimeString(stat.mtime)
        let isDirectory = stat.isDirectory()
        pathItemList.push(createPathItem(name,modified,isDirectory))
      })
      console.log("* 表示するパス一覧の数=" + pathItemList.length)
      return pathItemList
    default:
      return state
  }
}
/* パスアイテムの作成 */
const createPathItem = (name, modified, isDirectory) => {
  return {
    name: name,
    modified: modified,
    isDirectory: isDirectory,
  }
}
/* 時刻の文字列変換 */
const toLocaleTimeString = ( date ) => {
  return [
      date.getFullYear(),
      date.getMonth() + 1,
      date.getDate()
      ].join( '/' ) + ' '
      + date.toLocaleTimeString();
}

export default pathItemList
src/reducers/folderTreeItem.js
import {EXPAND_ROOT, TOGGLE_EXPAND} from '../actions'
/* フォルダアイテムの初期値 */
const initialState = {
  name:'アドレスバーにフォルダパスを貼り付けて「開く」を押してください', 
  fullpath:'', 
  children:[],
  isExpanded: false,
}
/* フォルダアイテムの状態更新 */
const folderTreeItem = (state = initialState, action) => {
  console.log("--イベント(action)の内容---")
  console.log(JSON.stringify(action))
  console.log("--------------------------")
  console.log("--変更前の状態(state)------")
  console.log(state)
  console.log("--------------------------")
  switch (action.type) {
    case EXPAND_ROOT: // 'ルートフォルダの展開イベント'
      const path = window.require('path')
      let name = path.basename(action.rootpath)
      let folderTreeItem = createFolderTreeItem(name, action.rootpath)
      return folderTreeItem
    case TOGGLE_EXPAND: // 'フォルダの展開/折畳イベント'
      if( action.fullpath === '')
        return state
      console.log('* 対象のフォルダパス=' + action.fullpath)
      console.log('* フォルダの展開状態=' + action.isExpanded)
      if(action.isExpanded === false) {
        let children = getChildFolderTreeItemList(action.fullpath)
        console.log("* 展開する 子の数=" + children.length)
        return toggleExpandChildren(state, action.fullpath, true, children)
      }
      else {
        console.log("* 折り畳む")
        return toggleExpandChildren(state, action.fullpath, false, [])
      }
    default:
      return state
  }
}
/* フォルダアイテムの作成 */
const createFolderTreeItem = (name, fullpath, children = []) => {
  return {
    name: name,
    fullpath: fullpath,
    children: children,
    isExpanded: false,
  }
}
/* 子のフォルダアイテム一覧の取得 */
const getChildFolderTreeItemList = (folderPath) => {
  const fs = window.require('fs')
  const path = window.require('path')
  let names = fs.readdirSync(folderPath)
  let children = []
  names.map(name => {
    let fullpath = path.join(folderPath, name)
    let stat = fs.statSync(fullpath)
    if(stat.isDirectory()) {
        children.push(createFolderTreeItem(name,fullpath))
    }
  })
  return children
}
/* サブフォルダと展開状態の入れ替え */
const toggleExpandChildren = (state, fullpath, isNewExpanded, children) => {
  let newState = Object.assign({}, state)
  seekAndUpdateChildren(newState, fullpath, isNewExpanded, children)
  return newState
}
/* 子のフォルダツリーアイテムの検索と更新(再帰検索) */
const seekAndUpdateChildren = (folderTreeItem, fullpath, isNewExpanded, children) => {
  if( folderTreeItem.fullpath === fullpath ) {
     folderTreeItem.children = children
     folderTreeItem.isExpanded = isNewExpanded
     return true
  }
  folderTreeItem.children.map( child => {
     if( seekAndUpdateChildren(child, fullpath, isNewExpanded, children) )
       return true
  })
  return false
}

export default folderTreeItem

5. 状態(store)とApp画面定義の合成

最後に、4で作成した状態遷移のreducersと、最初に作成したアプリケーションの画面定義のAppタグを接続します。

ポイント

  • createStoreで、エクスポートしたreducers = explorerAppをデータの状態の保管庫storeとして取り出します。
  • react-reduxのProviderタグを利用してstoreを渡します。
  • Appタグを記述してアプリケーションの描画をroot要素に表示(render)します。
src/index.js
import React from 'react'
import { render } from 'react-dom'
import { createStore } from 'redux'
import { Provider } from 'react-redux'
import App from './components/App'
import explorerApp from './reducers'
/* 状態遷移(reducers)をstoreとして取得 */
const store = createStore(explorerApp)
/* アプリケーションをstoreを指定して描画 */
render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
)

ビルドと実行

ビルド

react-scriptsというものがプロジェクトにインストールされており、package.jsonにタスクとして定義されています。

package.json
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  }

ビルドは、以下のコマンドを、file-explorer-handsonフォルダで実行します。

npm run build

実行

npm run startを実行するとよさそうに思えますが、ブラウザを起動しようとします。
次のコマンドが正解です。

.\node_modules\.bin\electron .

デバッグコンソールの表示

[View]メニューの[Toggle Develop Tools]を実行し、コンソールタブを選択してください。
イベントの発行(コンテナから)、アクションの内容、状態遷移する前の状態が表示されます。
右クリックのClear consoleを行ってログを消して、操作することで、どの部分のコードが動いているか確認できるかと思います。

最後に

長い文章にお付き合いいただきありがとうございました。お疲れさまでした。
質問やご指摘があればコメントください。

121
120
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
121
120