ぼくのかんがえたさいきょうのElectron

  • 681
    いいね
  • 2
    コメント

はじめに

ElectronはNode.js + HTML5フロントエンドのいいとこ取りな開発が行えるのが特徴です。その分、開発環境もElectronならではの考慮・工夫が必要になります.
今回のエントリでは, Electronアプリを快適に開発するための開発環境Tipsを書いていきます.

そもそも「Elecronってなんぞや?」とか「どういう風にアプリを作るの?」という方については, 手前味噌で恐縮ではありますが, Electronでアプリケーションを作ってみよう を目を通すことをオススメします.

また, 今回のエントリの元として、Quramy/electron-jsx-babel-boilerplate のレポジトリが出来上がっています.
React + Bable + Sass + Livereload + Platform用Packaging 入りのBoilerplateですので, これ以降の記事を読むのが面倒な人は使えばいいと思う.

Electron_App.png

画面系のフレームワーク

僕は日頃の仕事であるWebフロントエンド開発では、AngularJS + TypeScript(or CoffeeScript)で開発をしていますが、今回は敢えてReactを選択しています。

AngularJSはViewと関係の無い機能も含まれた(Webブラウザを動作環境とするという意味においての)Full Stackなフレームワークです。
例えば、通信系の機能について、Ajaxをラップした$http や Promise実装である $q 等が最初から含まれています。
一方、Electronの開発では、Node.jsで出来ることは素直にNode.jsの機能を使った方が早いことも多く、ライブラリも豊富です。HTTP通信をしたければ、素直にrequire('http').requestを呼び出せば良いのです。
そんなことを考えた結果、シンプルなViewフレームワークであるReactを選択しました.

また, Reactのv0.13.xによりES6のClass記法との相性が格段に上がったので、BabelでES6, JSXをトランスパイルする構成としています.

src/app.js
'use strict';
import polyfill from 'babel/polyfill';
import app from 'app';
import BrowserWindow from 'browser-window';
import crashReporter from 'crash-reporter';

let mainWindow = null;
if(process.env.NODE_ENV === 'develop'){
  crashReporter.start();
}

app.on('window-all-closed', () => {
  app.quit();
});

app.on('ready', () => {
  mainWindow = new BrowserWindow({width: 580, height: 365});
  mainWindow.loadUrl('file://' + __dirname + '/renderer/index.html');
});
src/renderer/index.html
<html>
<head>
<!-- 中略 -->
</head>
<body>
<div id="app"></div>
<script src="bootstrap.js"></script>
</body>
</html>
src/renderer/components/main.jsx
'use strict';

import React from 'react';

export class Main extends React.Component {
  state = {
    message: 'Hello, Electron'
  }
  constructor () {
    super();
  }
  render() {
    return (
      <div className="container">
        <div className="jumbotron main">
          <h1>{this.state.message}</h1>
        </div>
      </div>
    );
  }
}
src/renderer/bootstrap.js
'use strict';

import polyfill from 'babel/polyfill';
import React from 'react';
import {Main} from './components/main';

React.render(React.createElement(Main), document.getElementById('app'));

gulpでトランスパイル部分のタスクを書くと、下記のようになります。

gulpfile.js
var gulp = require('gulp'), $ = require('gulp-load-plugins');

gulp.task('compile:scripts', function () {
  return gulp.src('src/**/*.{js,jsx}')
  .pipe($.sourcemaps.init())
  .pipe($.babel({
    stage: 0
  }))
  .pipe($.sourcemaps.write('.'))
  .pipe(gulp.dest('.serve'))
  ;
});

JSX -> JavaScript変換もBabelで出来てしまうので、かなりシンプルです.
また、通常のブラウザ向けアプリの開発であれば、bootstrap.jsをエントリとしてBrowserify(+ Reactify or Babelify)でbundle.jsを作るのが王道ですが, 何せElectronはRenderer Process用のコードから直接requireできるので、Browserify等を使わなくても実行可能です.

その他ポイントを挙げるとすれば, babelのオプションに--stage 0相当を渡している所でしょうか(Electronとは関係ないですが).
このオプションが無い場合, JSXのコード中のstate: {...}の初期化部分でCompile Errorとなります.
Class宣言でのメンバ変数初期化はES6仕様には含まれず, ES7の仕様として提案されているため, BabelのExperimental機能を使う形になります.

ちなみに, babel/registerをindex.htmlから読み込んでしまえば, 実行時にtranspileすることも可能ですが, どうせ配布用のアプリケーションを作る際は事前にコンパイルするので, 最初からタスクを用意しています.

Livereload

Electronでの開発でもLivereloadしたいですよね.

ここで, Livereloadの要件は以下の2パターンがあります.

  1. RendererProcessで読み込んでいるコードが変更されたら, 画面をreload.
  2. BrowserProcess(MainProcess, app.jsなど)で動作するコードが変更されたら, RendererProcessもろともBrowserProcessを再起動

1.は通常のWeb開発におけるLivereloadのイメージとほぼ同様です. 2.はlivereloadというよりはnodemonのイメージに近いかもしれません.

1.については, Stackoverflowなどでも取り沙汰されているように, gulp-livereloadあたりで実現できるのですが, 2.との統合も視野に入れた結果として, electron-connect というnpmモジュールを作成して, 1.と2.を簡単にタスクに組み込めるようにしました.

gulpfile.js側でファイル変更を通知するサーバを立て, ElectronのProcess側からサーバへ接続してファイル変更イベント時に内部的にBrowserWindow.reloadIgnoringCacheでリロードをかける仕組みです.
2.の機能としては, BrowserProcessを復帰させた際に, 再起動前の表示位置へPositionを復帰させるといった細かい機能も仕込んでいます.
利用する際のコードは以下のようになります.

gulpfile.js
'use strict';
var gulp, electron = require('electron-connect').server.create();

gulp.task('serve', function () {

  // Electronの起動
  electron.start();

  // BrowserProcess(MainProcess)が読み込むリソースが変更されたら, Electron自体を再起動
  gulp.watch(['.serve/app.js', '.serve/browser/**/*.js'], electron.restart);

  // RendererProcessが読み込むリソースが変更されたら, RendererProcessにreloadさせる
  gulp.watch(['.serve/styles/**/*.css', '.serve/renderer/**/*.{html,js}'], electron.reload);
});
src/renderer/index.html
<html>
<head>
<!-- 中略 -->
</head>
<body>
<div id="app"></div>

<!-- gulp側で立てたserverへ接続する -->
<script>require('electron-connect').client.create()</script>

<script src="bootstrap.js"></script>
</body>
</html>

パッケージング

配布用のアプリのパッケージングには, electron-packager を用います.

このモジュールを使うと, 本家ドキュメントのdistributionガイドに記載されているパッケージングの作業を自動化することができます.

gulpfile.js
'use strict';

var gulp = require('gulp'), packager = require('electron-packager');

gulp.task('package:darwin', ['build'], function (done) {
  packager({
    dir: 'dist',              // アプリケーションのパッケージとなるディレクトリ
    out: 'release/darwin',    // .app や .exeの出力先ディレクトリ
    name: 'ElectronApp',      // アプリケーション名
    arch: 'x64',              // CPU種別. x64 or ia32
    platform: 'darwin',       // OS種別. darwin or win32 or linux
    version: '0.28.1'         // Electronのversion
  }, function (err, path) {
    // 追加でパッケージに手を加えたければ, path配下を適宜いじる
    done();
  });
});

上記以外にも, 追加できるoptionはありますが, electron-packagerのREADMEにある通り, platform: win32に対してiconのオプションを利用する場合は, rcedit.exe が動作する環境でタスクを実行する必要があります.
wineAppVeyorの利用を検討しましょう.

Browserify

このエントリを最初に投稿した当初は, browserifyどうでもよくね?と思っていましたが, @mizchi 氏からnode_modules配下のminify効果を具体的な数値としてコメントで教えてもらったため、minify欲求が再び鎌首をもたげてきました(本節は投稿後に書き直しています).

最初にBrowserifyで不要コードの削除に取り組んだ際は、BrowserProcess向けとRendererProcess向けの.jsコードに対して、Browserifyでそれぞれのbundle.jsを作ってみたのですが, これは単純には動きませんでした.

BrowserProcess側のコードをBrowserifyしてしまうと, RendererProcess側でrequire('remote').require('...') が記載してある場合に動作しなくなるのです.
Electron本体のコードatom/browser/lib/rpc-server.coffeeに記載されている下記が曲者です.

process.mainModule.require(module)

BrowserProcessが利用しているモジュールがbundleにまとめられると, remote.require実行時にはmoduleが存在しないのが原因です.

現状では, node_modulse内の各モジュール毎にBrowserifyでbundleを作成するようにしています(Appendixbundle:dependenciesタスク).
こうすることにより, BrowserProcessとnode_modulesの関係が保たれるため, remote.require('...')も正常に動作します.

2016.08.16 追記

bundleの作成については、最近はwebpackに落ち着きつつあります。

webpack.config.js
"use strict";

var webpack = require("webpack");
var path = require("path");

module.exports = {
  target: "electron",
  node: {
    __dirname: false,
    __filename: false
  },
  entry: {
    "main/index": "./src/main/index.js",
    "renderer/app": "./src/renderer/app.jsx"
  },
  resolve: {
    extensions: ["", ".js", ".jsx"]
  },
  module: {
    loaders: [
      { exclude: /node_modules/, test: /\.jsx?$/, loader: "babel" }
    ]
  },
  output: {
    path: path.join(__dirname, "dist"),
    filename: "[name].js"
  }
};

こぼれ話

ここからは, 比較的どうでも良さげな内容をオマケ的に書いておきます.

前述の通り, RendererProcessのhtmlからelectron-connectを利用しています. electron-connectは開発補助専用のモジュールであり, 実行時は不要です. わざわざPackagingしたアプリケーションにコピーしたくありません.
下記のように, <!-- build:remove -->gulp-useref を利用し, Packagingするhtmlからは <script> が消去されるようにタスクを組んでいます.

src/renderer/index.html
<!-- build:remove-->
<script>require('electron-connect').client.create()</script>
<!-- endbuild -->

Async/Await

今回作成したboilerplate にはソースコードとして含めていませんが, Babelのoptionにstage: 0 を追加した副次的(?)な効果として, ES7のAsync/Awaitも利用できるようになります.
とあるアプリケーション を作成していたときに非同期のコードがAsync/Awaitで綺麗にかけることを実感しました.
Async/Awaitの詳細については, ES7 の Async/Await を使ってみた等を参照してください.

Appendix

完成形のgulpfile

gulpfile.js
'use strict';

var gulp = require('gulp');
var $ = require('gulp-load-plugins')();
var _ = require('lodash');
var fs = require('fs');
var path = require('path');
var del = require('del');
var mainBowerFiles = require('main-bower-files');
var electronServer = require('electron-connect').server;
var packager = require('electron-packager');
var merge = require('merge2');
var browserify = require('browserify');
var source = require('vinyl-source-stream');
var buffer = require('vinyl-buffer');
var packageJson = require('./package.json');

var srcDir      = 'src';      // source directory
var serveDir    = '.serve';   // directory for serve task
var distDir     = 'dist';     // directory for serve:dist task
var releaseDir  = 'release';  // directory for application packages

// Compile *.scss files with sourcemaps
gulp.task('compile:styles', function () {
  return gulp.src([srcDir + '/styles/**/*.scss'])
    .pipe($.sourcemaps.init())
    .pipe($.sass())
    .pipe($.sourcemaps.write('.'))
    .pipe(gulp.dest(serveDir + '/styles'))
    ;
});

// Inject *.css(compiled and depedent) files into *.html
gulp.task('inject:css', ['compile:styles'], function() {
  return gulp.src(srcDir + '/**/*.html')
    .pipe($.inject(gulp.src(mainBowerFiles().concat([serveDir + '/styles/**/*.css'])), {
      relative: true,
      ignorePath: ['../../.serve', '..'],
      addPrefix: '..'
    }))
    .pipe(gulp.dest(serveDir))
  ;
});

// Copy assets
gulp.task('misc', function () {
  return gulp.src(srcDir + '/assets/**/*')
    .pipe(gulp.dest(serveDir + '/assets'))
    .pipe(gulp.dest(distDir + '/assets'))
  ;
});

// Incremental compile ES6, JSX files with sourcemaps
gulp.task('compile:scripts:watch', function (done) {
  gulp.src('src/**/*.{js,jsx}')
    .pipe($.watch('src/**/*.{js,jsx}', {verbose: true}))
    .pipe($.plumber())
    .pipe($.sourcemaps.init())
    .pipe($.babel({stage: 0}))
    .pipe($.sourcemaps.write('.'))
    .pipe(gulp.dest(serveDir))
  ;
  done();
});

// Compile scripts for distribution
gulp.task('compile:scripts', function () {
  return gulp.src('src/**/*.{js,jsx}')
    .pipe($.babel({stage: 0}))
    .pipe($.uglify())
    .pipe(gulp.dest(distDir))
  ;
});

// Make HTML and concats CSS files.
gulp.task('html', ['inject:css'], function () {
  var assets = $.useref.assets({searchPath: ['bower_components', serveDir + '/styles']});
  return gulp.src(serveDir + '/renderer/**/*.html')
    .pipe(assets)
    .pipe($.if('*.css', $.minifyCss()))
    .pipe(assets.restore())
    .pipe($.useref())
    .pipe(gulp.dest(distDir + '/renderer'))
  ;
});

// Copy fonts file. You don't need to copy *.ttf nor *.svg nor *.otf.
gulp.task('copy:fonts', function () {
  return gulp.src('bower_components/**/fonts/*.woff')
    .pipe($.flatten())
    .pipe(gulp.dest(distDir + '/fonts'))
  ;
});

// Minify dependent modules.
gulp.task('bundle:dependencies', function () {
  var streams = [], dependencies = [];
  var defaultModules = ['assert', 'buffer', 'console', 'constants', 'crypto', 'domain', 'events', 'http', 'https', 'os', 'path', 'punycode', 'querystring', 'stream', 'string_decoder', 'timers', 'tty', 'url', 'util', 'vm', 'zlib'],
      electronModules = ['app', 'auto-updater', 'browser-window', 'content-tracing', 'dialog', 'global-shortcut', 'ipc', 'menu', 'menu-item', 'power-monitor', 'protocol', 'tray', 'remote', 'web-frame', 'clipboard', 'crash-reporter', 'native-image', 'screen', 'shell'];

  // Because Electron's node integration, bundle files don't need to include browser-specific shim.
  var excludeModules = defaultModules.concat(electronModules);

  for(var name in packageJson.dependencies) {
    dependencies.push(name);
  }

  // create a list of dependencies' main files
  var modules = dependencies.map(function (dep) {
    var packageJson = require(dep + '/package.json');
    var main;
    if(!packageJson.main) {
      main = ['index.js'];
    }else if(Array.isArray(packageJson.main)){
      main = packageJson.main;
    }else{
      main = [packageJson.main];
    }
    return {name: dep, main: main.map(function (it) {return path.basename(it);})};
  });

  // add babel/polyfill module
  modules.push({name: 'babel', main: ['polyfill.js']});

  // create bundle file and minify for each main files
  modules.forEach(function (it) {
    it.main.forEach(function (entry) {
      var b = browserify('node_modules/' + it.name + '/' + entry, {
        detectGlobal: false,
        standalone: entry
      });
      excludeModules.forEach(function (moduleName) {b.exclude(moduleName)});
      streams.push(b.bundle()
        .pipe(source(entry))
        .pipe(buffer())
        .pipe($.uglify())
        .pipe(gulp.dest(distDir + '/node_modules/' + it.name))
      );
    });
    streams.push(
      // copy modules' package.json
      gulp.src('node_modules/' + it.name + '/package.json')
        .pipe(gulp.dest(distDir + '/node_modules/' + it.name))
    );
  });

  return merge(streams);
});

// Write a package.json for distribution
gulp.task('packageJson', ['bundle:dependencies'], function (done) {
  var json = _.cloneDeep(packageJson);
  json.main = 'app.js';
  fs.writeFile(distDir + '/package.json', JSON.stringify(json), function (err) {
    done();
  });
});

// Package for each platforms
gulp.task('package', ['win32', 'darwin', 'linux'].map(function (platform) {
  var taskName = 'package:' + platform;
  gulp.task(taskName, ['build'], function (done) {
    packager({
      dir: distDir,
      name: 'ElectronApp',
      arch: 'x64',
      platform: platform,
      out: releaseDir + '/' + platform,
      version: '0.28.1'
    }, function (err) {
      done();
    });
  });
  return taskName;
}));

// Delete generated directories.
gulp.task('clean', function (done) {
  del([serveDir, distDir, releaseDir], function () {
    done();
  });
});

gulp.task('serve', ['inject:css', 'compile:scripts:watch', 'compile:styles', 'misc'], function () {
  var electron = electronServer.create();
  electron.start();
  gulp.watch(['bower.json', srcDir + '/renderer/index.html'], ['inject:css']);
  gulp.watch([serveDir + '/app.js', serveDir + '/browser/**/*.js'], electron.restart);
  gulp.watch([serveDir + '/styles/**/*.css', serveDir + '/renderer/**/*.html', serveDir + '/renderer/**/*.js'], electron.reload);
});

gulp.task('build', ['html', 'compile:scripts', 'packageJson', 'copy:fonts', 'misc']);

gulp.task('serve:dist', ['build'], function () {
  electronServer.create({path: distDir}).start();
});

gulp.task('default', ['build']);