この記事は
アイスタイルアドベントカレンダー2016の11日目の記事です。
10月入社の@romiogakuが書きます。(@fagaiさんと順番かわりました)
git不要のテキストdiffツールが欲しかったので、勉強がてらElectron+react+reduxで作ってみました。
できたもの
名前はDifftronにしました。
意外と◯◯tronという名前空間空いてるので早い者勝ちですよ!
gifアニメにあるようにQiitaのmarkdownはdiffフォーマットも対応してるんです。知ってました?
利用ライブラリ
diff
diffのjavascript実装です。
diff2html
diffからgithubのような差分表示HTMLを出力してくれます。
react-ace
コードエディタAceのreact版です。
redux-saga
副作用を扱うためのライブラリです。
後述しますが、結果として非同期処理の必要は無くなったのでビジネスロジックの配置場所としての意味合いが強くなりました。
redux-persist
storeを永続化してくれます。
autoRehydrate
enhancerを追加しpersistStore
にstoreを渡すだけでstoreの保存とアプリ起動時の読み込みを勝手にやってくれます。
import {persistStore, autoRehydrate} from 'redux-persist';
const store = createStore(reducer, undefined, autoRehydrate());
persistStore(store);
seamless-immutable
reduxの三原則のうちの一つに「State is read-only」(Stateは読み取り専用)というのがあります。
Stateを変更させる唯一の方法はActionを発行することです。
この原則を守るために、State自体をImmutableにするアプローチがあります。
JavaScriptにはObject.freeze()もありますが入れ子のオブジェクトまでfreezeできないといった問題があります。
Immutable Objectを扱うためのライブラリには以下の様なものがあります。
知名度が高いのはImmutable.jsでしょうか。
Immutable.jsは専用のオブジェクトに変換されるので、用途によって
【Immutable.jsオブジェクト】↔【vanilla JS】
の相互変換処理をかます必要があります。
そこでvanilla JSのままImmutableなオブジェクトを扱えるようにするseamless-immutableも人気です。
reduxsauce
ReducerやActionの記述をちょっと楽にできます。
ava
会社でmocha使っているのでavaを試してみました。
デフォルトでpower-assertが使われ、余計な設定無しでES2015記述で書けるのが良いですね。
reactotron
アプリケーションのインスペクタです。デバッグ用に使います。
reactotronを起動した状態でアプリケーションを立ち上げると、stateに変化があったりイベントが起きるたびにログがゲロゲロ吐き出され内部のstateについて確認できます。
内部的な話
ディレクトリ構成(レンダラプロセス)
Action
ActionのタイプとActionCreatorを置きます。
reduxsauce
を使っています。
Components
左入力エリア、右入力エリア、下部diff出力エリアコンポーネントとcssを配置します。
コンポーネントは全てStateless Function
で記述しています。所謂Presentational Component
です。
イベントハンドラは後述するConteiners
で定義します。
Config
主に定数等を定義しています。
Containers
各コンポーネントの親にあたります。所謂Container Component
です。
今回は単一ページのアプリケーションなのでContainerはPresentationScreen
1つだけです。
stateとdispatcherはreact-redux
のconnect()
を使いprops経由で各コンポーネントに渡しています。
import { connect } from 'react-redux';
import Actions from '../Actions/Creators';
const mapStateToProps = state => (
{
left: state.input.left,
right: state.input.right,
......(略)
}
);
const mapDispatchToProps = dispatch => (
{
changeLeft: input => dispatch(Actions.inputLeftChange(input)),
changeRight: input => dispatch(Actions.inputRightChange(input)),
......(略)
}
);
export default connect(mapStateToProps, mapDispatchToProps)(PresentationScreen);
Reducers
Reducerを置きます。stateはseamless-immutable
を使っているので、新しいstateへの更新はmerge
するだけで済みます。
import immutable from 'seamless-immutable';
const changeLeft = (state, action) =>
state.merge({
left: action.input,
});
Sagas
ビジネスロジックを配置します。redux-saga
を使っています。
元々、テキストエリアに何か入力があるたびにdiff計算とhtml変換をするため、変換処理に時間がかかることを想定していたのですが、思いの外サクサク動いたので非同期処理はありません。
アプリケーション全体のアーキテクチャとしては、テキストエリアや設定値の変更を全て一旦Sagaでwatchし、各workerで処理するという流れになります。
export function* leftWatcher() {
// 左入力エリアの変更を受け、workerへ処理を任せる
yield* takeEvery(Types.INPUT_LEFT_CHANGE, worker);
}
export function* worker() {
// 現在のstateを取得
const leftInput = yield select(left);
const rightInput = yield select(right);
const outputFormat = yield select(format);
const outputSplit = yield select(split);
// diffの計算(Serviceにまとめています)
const [rawDiff, contents] = createDiff(leftInput, rightInput, outputFormat, outputSplit);
// Actionのdispatch
yield put(Actions.outputDiffResult(rawDiff, contents));
}
Selectors
redux-saga
のselect
で利用するselector
を置いています。
Services
実際のDiff処理を行うメソッド等を配置しています。
helper的な役割に近いかもしれません。
Stores
Storeの生成、各Middlewareの設定、永続化設定等を行います。
*.global.css
アプリケーション全体のレイアウトファイルです。
index.js
レンダラプロセスのエントリーファイルです。
ipcイベントリスナもこちらで定義しています。
State構造
{
input: {
left: '左入力テキスト',
right: '右入力テキスト',
language: 'javascript', // 言語設定
theme: 'github' // テーマ設定
},
output: {
raw: '', // unified形式のdiff
contents: '', // 出力エリアに表示するコンテンツ
format: 0, // 出力フォーマット設定(html or unified)
split: 'line-by-line' // 出力方式設定
}
}
このへん再考の余地がありそうですが、inputとoutputをApp/Reducers/index.js
でcombineReducers
しています。
データフロー
入力が変化したらsagaがそれをwatchしdiffを計算し、outputのstateを更新します。
それ以外は一般的なreduxのデータフローです。
IPC(プロセス間通信)
ElectronにはメインプロセスとBrowserWindow上で走るレンダラプロセスがあります。
Difftronではメニューバーの「View」から、表示テーマやフォーマットを変更できます。
ユーザが設定を変更したらそれをレンダラプロセス側で受け取る必要があります。
メニューバーの設定は以下のようにメインプロセスで記述します。
main.development.js
一部抜粋
{
label: 'DiffFormat',
submenu: [
{
label: 'unified',
click() {
mainWindow.webContents.send('set-diff-format', DIFF_FORMAT.unified);
},
},
{
label: 'html',
click() {
mainWindow.webContents.send('set-diff-format', DIFF_FORMAT.html);
},
},
],
},
レンダラプロセスでは以下のように受け取ります。
App/index.js
一部抜粋
import { ipcRenderer } from 'electron';
ipcRenderer.on('set-diff-format', (event, arg) => {
dispatch(Actions.outputFormatChange(arg));
});
IPCを使いたいが為に設定項目をメニューバーに押し込んだので使いづらい...
今思えば素直にレンダラプロセスで設定画面を作っても良かったかもしれません。
yarnの威力
npmからyarnにするとnpm install: 5分半 だったのが yarn install: 1分半 になりました。
作ってみて
意外とちゃんとしたものが出来て満足です。
普段の仕事と違ってブラウザ互換性を考えないで良いjavascript開発は最高ですね!
昔からあるようなフリーソフトでも自分好みにモダンな感じでElectronで作り直すと案外覇権取れるかもしれませんよ。
今後
- flow導入
- E2Eテスト足す
- フォントサイズ等設定項目を増やす
- ウィンドウサイズの永続化
- アイコンをちゃんとしたやつにする
- ストアに出す?
明日は
・・・誰か書きます!
お楽しみに!!!