はじめに
ElectronはNode.js + HTML5フロントエンドのいいとこ取りな開発が行えるのが特徴です。その分、開発環境もElectronならではの考慮・工夫が必要になります.
今回のエントリでは, Electronアプリを快適に開発するための開発環境Tipsを書いていきます.
そもそも「Elecronってなんぞや?」とか「どういう風にアプリを作るの?」という方については, 手前味噌で恐縮ではありますが, Electronでアプリケーションを作ってみよう を目を通すことをオススメします.
また, 今回のエントリの元として、Quramy/electron-jsx-babel-boilerplate のレポジトリが出来上がっています.
React + Bable + Sass + Livereload + Platform用Packaging 入りのBoilerplateですので, これ以降の記事を読むのが面倒な人は使えばいいと思う.
画面系のフレームワーク
僕は日頃の仕事である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をトランスパイルする構成としています.
'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');
});
<html>
<head>
<!-- 中略 -->
</head>
<body>
<div id="app"></div>
<script src="bootstrap.js"></script>
</body>
</html>
'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>
);
}
}
'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でトランスパイル部分のタスクを書くと、下記のようになります。
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パターンがあります.
- RendererProcessで読み込んでいるコードが変更されたら, 画面をreload.
- 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を復帰させるといった細かい機能も仕込んでいます.
利用する際のコードは以下のようになります.
'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);
});
<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ガイドに記載されているパッケージングの作業を自動化することができます.
'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
が動作する環境でタスクを実行する必要があります.
wineやAppVeyorの利用を検討しましょう.
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を作成するようにしています(Appendixのbundle:dependencies
タスク).
こうすることにより, BrowserProcessとnode_modulesの関係が保たれるため, remote.require('...')
も正常に動作します.
2016.08.16 追記
bundleの作成については、最近はwebpackに落ち着きつつあります。
"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>
が消去されるようにタスクを組んでいます.
<!-- 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
'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']);