Edited at

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

More than 3 years have passed since last update.


はじめに

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']);