692
683

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2015-06-18

はじめに

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

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
692
683

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?