アカベコマイリ

HEAR NOTHING SEE NOTHING SAY NOTHING

Electron を試す 11 - 簡易ファイラー with TypeScript

February 22, 2019開発Electron, TypeScript

ひさびさの Electron を試すシリーズ。

以前から機会があれば TypeScript を導入したいと考えていた。未知の技術を採用するのであれば既存プロジェクトよりも、しがらみのない新規のほうがよかろう。というわけで Electron による簡易ファイラーを実装しながら学ぶことにした。

簡易ファイラーについては大昔に実装した「node-webkit を使ってみる 3 - 簡易ファイラー」を踏襲。本記事では開発を通して得られた知見や感想などをまとめる。主に TypeScript 環境まわりの話になるだろう。

Simple Filer

簡易ファイラー

簡易ファイラーの仕様をまとめる。「簡易」なので編集や削除系の機能は実装しない。

  • 指定したフォルダーをルートとしたツリー ビューを持つ
  • ルート フォルダーは同一パスでなければ複数、指定可能
  • ツリー ビューでルート フォルダーが選択されている状態なら、そのツリーを削除可能
  • 選択したフォルダーの内容をテーブルとして表示
  • テーブルのアイテムをクリックすると選択状態となる
  • テーブルのアイテムをダブル クリックするとシェルで開く

Electron + TypeScript

Electron アプリケーションを TypeScript で開発するための環境構築について。examples-electron/electron-starter をベースに TypeScript 向け変更をおこなった。全体像は本記事の冒頭に掲載したサンプル プロジェクトを参照のこと。

Babel + TypeScript

TypeScript を JavaScript へ Transpile する標準ツールは tsc となる。これは typescript をインストールすることで利用可能。しかし今回は Babel 経由で Transpile する。関連する npm は以下。後述する webpack 関連は除いている。

npm 役割
@babel/core Babel 本体。
@babel/preset-env TypeScript などを指定された環境に応じて適切に JavaScript 変換するための Babel preset。
@babel/preset-react React 向けの JSX 記法などを適切に JavaScript 変換するための Babel preset。
@babel/preset-typescript TypeScript を適切JavaScript 変換するための Babel preset。
babel-preset-minify リリース用の JavaScript 変換時にソースコードを Minify するための Babel prest。
typescript TypeScript コンパイラー。@babel/preset-typescript などが利用する。

これらを組み合わせた Babel 設定ファイル babel.config.js の定義。

module.exports = (api) => {
  const presetEnv = [
    '@babel/preset-env',
    {
      targets: {
        electron: '4.0'
      }
    }
  ]

  return {
    presets: api.env('development')
      ? [presetEnv, '@babel/typescript', '@babel/preset-react', 'power-assert']
      : [presetEnv, '@babel/typescript', '@babel/preset-react', 'minify']
  }
}

以前「Babel 7 を試す」にも書いたとおり、Babel 7 から設定が JavaScript 形式となったのでそのようにしている。開発版 development とリリース版で設定を分岐。@babel/preset-env の仕向は最新の Electron v4.0 系。Web ブラウザーとしては Chrome (Chromium) v69 相当になる。

webpack

Electron アプリケーションをパッケージ化する際に node_modules を組み込むとサイズが巨大になるため、Babel による変換だけでなく webpack でソース コードを Bundle する。設定ファイルは webpack.config.babel.js@babel/register により ES.next な書式で定義。

import MiniCssExtractPlugin from 'mini-css-extract-plugin'
import OptimizeCssnanoPlugin from '@intervolga/optimize-cssnano-plugin'

export default (env, argv) => {
  const MAIN = !!(env && env.main)
  const PROD = !!(argv.mode && argv.mode === 'production')
  if (PROD) {
    process.env.NODE_ENV = 'production'
  }

  return {
    target: MAIN ? 'electron-main' : 'electron-renderer',
    entry: MAIN ? './src/main/AppMain.ts' : './src/renderer/AppRenderer.tsx',
    output: {
      path: PROD ? `${__dirname}/dist/src/assets` : `${__dirname}/src/assets`,
      filename: MAIN ? 'main.js' : 'renderer.js'
    },
    devtool: PROD ? '' : 'source-map',
    node: {
      __dirname: false,
      __filename: false
    },
    resolve: {
      extensions: ['*', '.js', '.jsx', '.ts', '.tsx']
    },
    module: {
      rules: [
        {
          test: /\.(ts|js)x?$/,
          exclude: /node_modules/,
          use: [
            { loader: 'babel-loader' },
            {
              loader: 'ifdef-loader',
              options: {
                env: PROD ? 'PRODUCTION' : 'DEBUG'
              }
            }
          ]
        },
        {
          test: /\.scss$/,
          use: [
            MiniCssExtractPlugin.loader,
            {
              loader: 'css-loader',
              options: {
                modules: true,
                localIdentName: PROD
                  ? '[hash:base64]'
                  : '[name]-[local]-[hash:base64:5]',
                url: false,
                importLoaders: 1,
                sourceMap: !PROD
              }
            },
            {
              loader: 'sass-loader',
              options: {
                outputStyle: PROD ? 'compressed' : 'expanded',
                sourceMap: !PROD
              }
            }
          ]
        }
      ]
    },
    plugins: PROD
      ? [
          new MiniCssExtractPlugin({ filename: 'bundle.css' }),
          new OptimizeCssnanoPlugin()
        ]
      : [new MiniCssExtractPlugin({ filename: 'bundle.css' })],
    externals: MAIN ? [] : ['electron']
  }
}

ベースにした electron-starter からの主な変更点は

  • 処理対象とする拡張子に tstsx を追加
  • ifdef 周り

となる。

ifdef

これまで ifdef デバッグ用のコンパイル分岐は「Electron を試す 2 - パッケージ化におけるプラットフォーム固有処理とコンパイル分岐に書いた方法で実現していた。しかし TypeScript ではグローバル変数の書き換えハックが通用しない。

いちおう declare を工夫すれば実現できなくもないが、ちょうどよい機会なのでよりよい代替がないか調べてみた。結果、ifdef-loader を採用。これはその名のとおり ifdef 機能を提供してくれる。以下のように定義すると

{
   loader: 'ifdef-loader',
   options: {
    env: PROD ? 'PRODUCTION' : 'DEBUG'
  }
}

ソース コード中に出現した

/// #if env == 'DEBUG'
console.log('DEBUG!!!')
/// #endif

を判定して分岐してくれる。この例では PROD フラグにより env 変数の値を分岐。PROD は開発時に false、リリース用のビルドで true となるように処理している。そのため開発時は console.log('DEBUG!!!') が実行され、リリース用ではコードそのものが削除される。

開発版に限定してログ出力するとか、Electron としては開発版のみ Developer Tools 表示を切り替えられるようにしてリリース版は無効にする、といった処理に便利。

ユニット テスト

TypeScript 導入にあたり採用したユニット テスト周りのツールは以下。

npm 役割
mocha テスト ランナー。
power-assert テスト時の assert を拡張してテスト失敗時のレポートを詳細に出力する。
babel-preset-power-assert power-assert を Babel と組み合わせて利用するための Babel preset。
espower-typescript TypeScript を power-assert で処理するためのツール

babel-preset-power-assert の README によると TypeScript をサポートしているとあるので、当初は従来どおり npm-scripts で

{
  "scripts": {
    "test": "mocha --require @babel/register src/**/*.test.ts"
  }
}

としていた。私の認識では @babel/register により Babel の require 解析に power-assert が介入、すなわち Babel として TypeScript を処理可能ならこれでよいと考えていたのだが、実際にはエラーとなる。そのため espower-typescript に置き換えた。

{
  "scripts": {
    "test": "mocha --require espower-typescript/guess src/**/*.test.ts"
  }
}

これでテスト対象、テスト コード共に TypeScript でも動作するようになる。ただし espower-typescript の package.json を見ると Babel は参照していない。おそらく解析は typescript でおこなっているのだろう。ならば babel-preset-power-assert は不要な気がする。

もし Babel を通してないとしても TypeScript 解析は共に typescript だから結果は一緒かもしれないけど、実行時と変換の処理系が異なるのはモヤモヤする。

テストについてもう一点。npm rewire を試すで使用した rewire は TypeScript でも動作した。対象コードに TypeScript 構文の型が含まれていても、ちゃんと読み込める。

というわけでユニット テストは Babel による ES.next と同等の使用感を実現できた。

Redux 関連

TypeScript における Redux について。

Reducer と型

せっかく TypeScript を採用したのだから Redux 関連も型をつけたい。特に Reducer に渡される payload。というわけで誰か先に実現していないかな?と調べたら以下の記事を見つけた。

この方法を採用すると以下のような Reducer において、それぞれの case 内で action がちゃんと個別の型を持っていることが分かる。

import {
  finishRegisterRootFolder,
  finishEnumSubFolders,
  finishEnumItems,
  unregisterRootFolder,
  selectItem
} from '../actions/index'
import { ActionType, AppState } from '../Types'
import { checkRegisterRootFolder } from './registerRootFolder'
import { checkUnregisterRootFolder } from './unregisterRootFolder'
import { checkEnumSubFolders } from './enumSubFolders'
import { checkEnumItems } from './enumItems'
import { checkSelectItem } from './selectItem'

type Actions =
  | ReturnType<typeof finishRegisterRootFolder>
  | ReturnType<typeof finishEnumSubFolders>
  | ReturnType<typeof finishEnumItems>
  | ReturnType<typeof unregisterRootFolder>
  | ReturnType<typeof selectItem>

const InitialState: AppState = {
  currentFolder: {
    treeId: 0,
    path: '',
    isRoot: false
  },
  folders: [],
  items: []
}

const reducer = (state = InitialState, action: Actions): AppState => {
  switch (action.type) {
    case ActionType.FinishRegisterRootFolder:
      return checkRegisterRootFolder(state, action)

    case ActionType.RequestUnregisterRootFolder:
      return checkUnregisterRootFolder(state, action)

    case ActionType.FinishEnumSubFolders:
      return checkEnumSubFolders(state, action)

    case ActionType.FinishEnumItems:
      return checkEnumItems(state, action)

    case ActionType.RequestSelectItem:
      return checkSelectItem(state, action)

    default:
      return state
  }
}

vscode 上で action をマウス オーバーすると個別に型になっているし action.payload. とタイピングすれば補完も効く。これを踏まえて Reducer の処理を関数に分割する場合は

import { AppState } from '../Types'
import { selectItem } from '../actions/index'

export const checkSelectItem = (
  state: AppState,
  action: ReturnType<typeof selectItem>
): AppState => {
  return Object.assign({}, state, {
    currentItem: action.payload.item
  })
}

と書ける。action の型は元記事に説明されているように ReturnType<typeof XXXX> となるため XXXX 部分に対応する Action を当てはめればよいのだ。

きちんと型チェックされて補完も効くので快適。payload の構成を変えたのに Reducer 側で対応が漏れていた!というミスが起きにくくなる。実際、このサンプルを開発している際にもこれで随分と助けられた。

react-redux ルートの props 警告

react-redux を利用して、アプリケーションのエントリー ポイントの render を以下のように定義したとする。

window.addEventListener('load', () => {
  let store = createStore(RootReducer, applyMiddleware(ReduxThunkMiddleware))

  render(
    <Provider store={store}>
      <>
        <Toolbar />
        <div className="content">
          <SplitPane split="vertical" minSize={256} defaultSize={256}>
            <Explorer />
            <FileItemList />
          </SplitPane>
        </div>
      </>
    </Provider>,
    document.querySelector('.app')
  )
})

このとき対象となるコンポーネントの props において、コールバック関数が

type Props = {
  folders: Folder[]
  currentFolder: CurrentFolder
  enumSubFolders: (folderPath: string) => void
  enumItems: (folder: Folder) => void
}

のように定義されていると以下の警告 (読みやすいように改行を追加) が表示される。

Type '{}' is missing the following properties from
type 'Readonly<PropsWithChildren<Pick<Props, "enumSubFolders" | "enumItems">>>':
enumSubFolders, enumItems ts(2739)

これを解決するには

type Props = {
  folders: Folder[]
  currentFolder: CurrentFolder
  enumSubFolders?: (folderPath: string) => void
  enumItems?: (folder: Folder) => void
}

のようにコールバック関数へ ? をつけて Optional として宣言すればよい。こうしても実行時には Container の mapDispatchToProps へ定義した関数が渡されからコールバック関数も適切に呼ばれる。しかしこの問題が起きないこともあり困惑。場当たりな対応で根本解決していない。識者の意見、求む。

Renderer プロセスにおける window.require 警告

webpack 設定で target: 'electron-renderer' を指定すると Electron の Renderer プロセスとしてビルドされる。この環境で Electron API を参照する場合は

const ipcRenderer = window.require('electron').ipcRenderer

とするのだが TypeScript としては windowrequire は未定義だと警告される。実行時には参照可能なので、以下のように宣言して警告を抑止する。

declare global {
  interface Window {
    require: any
  }
}

これはどこかで一度だけおこなえばよい。私はプロセス共有・固有の型について個別に Types.ts というファイルへ定義しており、これについては Renderer 固有の宣言とした。

CSS Modules

TypeScript でも CSS Modules を利用したい。しかしそのまま import しても当然ながら TypeScript ではないためエラーになる。どうしよう、と思って方法を調べていたら以下の記事を見つけた。

なるほど。CSS から TypeScript 側へ公開するものを d.ts に定義して参照解決すればよいのか。おまけにこれを自動化するため typed-css-modules というツールまで公開しており、至れり尽くせりだ。

私の環境は前述の webpack.babel.js のとおり Sass (SCSS) で CSS Module を利用しているのだが、これでも typed-css-modules は動作した。このツールは watch モードを提供しているので npm-scripts で以下のコマンドを定義して他の watch 系とあわせて実行。

{
  "scripts": {
    "watch:tcm": "tcm -p ./src/renderer/components/**/*.scss -w",
  }  
}

*.d.ts が更新されても import がエラーになる場合は vscode 上で d.ts ファイルを開けばよい。そうすると参照関係が更新されるのか、import 対象として *.d.ts に定義された SCSS クラスの参照エラーは解決。また入力補完にも候補として表示される。

気になる点として tcm -p の参照パスを ./src/**/*.scss にして配下の複数フォルダー複数階層に対して実行すると、はじめに *.scss を検出したフォルダーで変換が止まる。今回のプロジェクトで CSS Modules を利用している箇所は Redux でいう components だけなので tcm -p の対象をここへ限定することで対応した。

もうひとつ typedoc の問題もある。SCSS の importCannot find module エラーになる。オプションに --includeDeclarations をつけて tcm の出力した *.d.ts を含めてみたが、そうすると今度はいくら待っても (10 分ぐらい試した) 処理が終わらない。

これでは実用にならないので typedoc はやめた。もし私と同じような TypeScript + CSS Modules + typedoc で正常動作させている方がいれば、環境設定などを教えていただきたい。

Linter/Formatter

これまで TypeScript の Linter は TSLint がデファクト スタンダードだったようだけど、今後は ESLint へ切り替えられてゆくようだ。

現在は過渡期のようなのと vscode 上での TypeScript 解析が優秀なことから Prettier だけにした。.prettierrc 設定も最小限。

{
  "arrowParens": "always",
  "semi": false,
  "singleQuote": true
}

ESLint における TypeScript 知見が蓄積されてきたら改めて ESLint 導入を検討する予定。最近の記事だと以下がわかりやすい。

やはり過渡期ということもあって様々な対処が必要なようだ。記事にはこうした現状を踏まえて

普通の人はこんな本質的ではないyak shavingを頑張る必要ないので、あと半年も経って諸々こなれてきた頃、新規プロジェクトを作る機会があればググった設定をコピペしたら良いのではと思います。

と追記されている。実に現実的な指摘。少なくとも TSLint と ESLint で揺れてたのを後者へ集約する方針は定まったのだし「諸々こなれ」るのもそんなに時間はかからないだろう。

TypeScript 所感

すばらしい!!

私は元々 C++、C#、Java などの静的型付き言語の経験がそれなりに長いため JavaScript のような動的型付け言語に抵抗があった。現実には動的型付けでも滅多なことでは事故らないのだが、この「滅多なこと」は潜在的なプレッシャーになっていることが TypeScript の導入により明確になった感がある。最近、業務で従来の JavaScript をいじる機会があったのだけど、型がないことの不安なことといったらない。

vscode と組み合わせての開発体験もよい。JavaScript でも JSDoc をきちんと書いて入れば補完と型ヒント表示がそれなりに効くのだけど、TypeScript とは比べ物にならない。さすがの Visual Studio 開発元である。TypeScript を設計した Anders Hejlsberg は Delphi や C# も手がけている。これらの言語も IDE との親和性が高かったので、そういう経験や思想は TypeScript + vscode にも踏襲されているのだろう。

最近、新規プロジェクトなら TypeScript を採用したいという話が散見されるようになった。同感である。個人的には JavaScript (ECMAScript) として TypeScript 的な型が取り入れられ、Babel + babel-preset でも vscode なら TypeScript 並の体験を得られるのが理想。しかしその日が来るとしてもそれは遠いし、いま現実的に生産性の高い AltJS を享受したいなら TypeScript を選ぶことになるだろう。