ElectronでDiffツールを作ってみた

  • 36
    いいね
  • 0
    コメント

この記事は

アイスタイルアドベントカレンダー2016の11日目の記事です。
10月入社の@romiogakuが書きます。(@fagaiさんと順番かわりました)

git不要のテキストdiffツールが欲しかったので、勉強がてらElectron+react+reduxで作ってみました。

できたもの

Difftron

difftron

名前は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
- seamless-immutable
- timm

知名度が高いのは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について確認できます。

内部的な話

ディレクトリ構成(レンダラプロセス)

36F527512BE1DBEF61FB5A812B37D3D7.jpg

Action

ActionのタイプとActionCreatorを置きます。
reduxsauceを使っています。

Components

FD0D75EA4DE277768FB43435CA040579.jpg

左入力エリア、右入力エリア、下部diff出力エリアコンポーネントとcssを配置します。
コンポーネントは全てStateless Functionで記述しています。所謂Presentational Componentです。
イベントハンドラは後述するConteinersで定義します。

Config

主に定数等を定義しています。

Containers

602CF5556E2CD8C6C0DC277D98F46CED.jpg

各コンポーネントの親にあたります。所謂Container Componentです。
今回は単一ページのアプリケーションなのでContainerはPresentationScreen1つだけです。
stateとdispatcherはreact-reduxconnect()を使い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

292B2AB1389D18AEDA6820DCCA5ED3DD.jpg

Reducerを置きます。stateはseamless-immutableを使っているので、新しいstateへの更新はmergeするだけで済みます。

import immutable from 'seamless-immutable';
const changeLeft = (state, action) =>
  state.merge({
    left: action.input,
  });

Sagas

D59AD9648616DF22AB5E501F1E49C054.jpg

ビジネスロジックを配置します。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-sagaselectで利用する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.jscombineReducersしています。

データフロー

485C66327C287F90646D34D89FB04D13.jpg

入力が変化したら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テスト足す
  • フォントサイズ等設定項目を増やす
  • ウィンドウサイズの永続化
  • アイコンをちゃんとしたやつにする
  • ストアに出す?

明日は

・・・誰か書きます!
お楽しみに!!!

この投稿は アイスタイル Advent Calendar 201611日目の記事です。