CoffeeScriptをやめてES6を使うためにやったことまとめ

この記事は Speee Advent Calendar 2017 11日目の記事です。

10日目は @iida-hayato による 5000兆円トークンに見るスマートコントラクトの設計と実装。 でした。

記事について

Rails製の自プロダクトからCoffeeScriptを廃止してES6を使えるようにした話です。

CoffeeScriptやめたいけど、色々やること多そう。。」みたいなところで悩まれている方々の参考になれば幸いです。

作業ログをベースに書いてるので、自プロダクト環境固有の話を含むことをご了承ください。

背景

  • オワコン化が叫ばれるCoffeeScript
    • ES6のリリースにより、クラス構文などが標準JSで利用できるようになった
    • 今となっては学習コストが高いわりに強い恩恵がない
      • 普段Rubyを書かないフロントエンジニアにとっては特に
    • TypeScriptの登場
  • フロントエンジニアの採用リスク
    • 上記要因などあって、CoffeeScriptを書ける/書きたがるフロントエンジニアが市場から減っている
  • TypeScript採用したい
    • そのためにもまずはCoffeeScriptを標準JSに置き換える

移行前のフロントエンド状況

ビルド方法

  • coffeeify + browserify + gulpで frontend/配下の .coffee をトランスパイル+バンドル
  • バンドルした .jsapp/assets/javascripts 配下に配置
const gulp = require('gulp');
const browserify = require('browserify');
const coffeeify = require('coffeeify');

const dir = {
    srcJS: "./frontend/js/",
    distJS: "./app/assets/javascripts/"
};

const coffeeFiles = [
    'hoge.coffee',
    'fuga.cofffe',
    ...
]

gulp.task('build', () => {
    jsFiles.forEach(function (file) {
        var srcfile = coffee2jsExtension(file);
        browserify({
            entries: [dir.srcJS + file],
            extensions: ['.coffee'],
            transform:  [coffeeify]
        })
        .bundle()
        .pipe(source(srcfile))
        .pipe(gulp.dest(dir.distJS));
    }
});

function coffee2jsExtension(file){
    return file.replace(".coffee", ".js");
}

やったこと

  1. decaffeinateで既存の.coffeeを.jsに全て置き換え
  2. トランスパイル周辺の設定
  3. コケるfeature specをなんとかする
  4. JSエラー検知の有効化

今回のスコープではやらないことにしたもの

使用言語やフレームワークのリプレイスは遅れるほど負債が溜まっていく上、ステークホルダーがいつまでも笑顔でいるとは限りません。

とにかくCoffeeScriptをやめることを第一優先に、下記に挙げられるものは別タイミングでやることに決めました。

  • ESLint導入
  • browserify => webpackへの移行
  • ES6風のコードにする

1. decaffeinateで .coffee.js

coffeeファイルをjsに置き換える上で、decaffeinateを利用します。

ちなみにdecaffeinateという言葉には「カフェインを取り除く」という意味があるようです。おしゃれ。

$ npm install -g decaffeinate
$ find ./ | grep .coffee | grep -v node_modules | xargs -I{} decaffeinate {} 
.//app/assets/javascripts/hogehoge.coffee → app/assets/javascripts/hogehoge.js
.//app/views/hoge/histories/edit.coffee → app/views/hoge/histories/edit.js
.//frontend/js/hoge/hige.coffee → frontend/js/hoge/hige.js
...

suggestionsが付与されたコードを確認する

decaffeinateの思想は 生成したコードの動作の正確性 に重きを置いており、そのため生成されたコードが既存コードの意図よりも 過剰に防御的 になってることがあります。

そういった可能性があるjsファイルに対して、decaffeinateはsuggestionsを残します。

decaffeinateの実行結果の例

-  $('.hogeButton').on 'click', (event) ->     
-   button  = $(event.target)      
-   {from, to}= button.data()      
-  
-   $.ajax(        
-     url: "/hoge/hige"      
-     data: { from: from, to: to }     
-     dataType: 'json'     
-     method: 'POST'       
-   )

+ /*
+ * decaffeinate suggestions:
+ * DS102: Remove unnecessary code created because of implicit returns
+ * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
+ */

+ $('.hogeButton').on('click', function(event) {
+   const button  = $(event.target);
+   const {from, to}= button.data();
+
+   return $.ajax({
+     url: "/hoge/hige",
+     data: { from, to },
+     dataType: 'json',
+     method: 'POST'
+   });
+ })

自プロダクト内で散見されたsuggestionsをいくつか紹介します。

DS102: Remove unnecessary code created because of implicit returns

  • CoffeeScriptは関数の終わりに暗黙的に最後のステートメントをreturnしている
  • JavaScriptにはこの動作はない
  • decafeinateはCoffeeScriptと同一の挙動を模倣するので、必ず最後にreturnが生成される
  • よって実装上不要なreturnは確認した上で消しましょう

DS104: Avoid inline assignments

  • 関数の結果を再利用するためにdecaffeinateが変数を自動生成する
  • 評価順序を模倣するためインラインで代入を行っており、コードが見辛くなることがある

CoffeeScript

accounts[getAccountId()] //= splitFactor

decaffeinate後のJavaScript

let name;
accounts[name = getAccountId()] = Math.floor(accounts[name] / splitFactor);

修正例

const accountId = getAccountId();
accounts[accountId] = Math.floor(accounts[accountId] / splitFactor);

DS207: Consider shorter variations of null checks

  • CoffeeScriptにはnullやundefinedを評価する ? 演算子がある
  • JavaScriptでは != nullに置き換えられるが、単にboolとして扱うほうが望ましいケースもある

CoffeeScript

if currentUser()?
  sendMessage(customMessage() ? 'Hello')

decaffeinate後のJavaScript

if (currentUser() != null) {
  let left;
  sendMessage((left = customMessage()) != null ? left : 'Hello');
}

修正例

if (currentUser()) {
  sendMessage(customMessage() || 'Hello');
}

その他のsuggestionsのパターンと解消例はsuggestions.mdを参照。

2. トランスパイル周辺の設定

  • coffeefy関連の処理を削除
  • babelifyを導入
    • ES6を既存ブラウザで実行できる形式(ES5)にトランスパイルする
    • IEなどES5のサポートが限定的なブラウザに向けてbabel-polyfillも併用

package.json

  "dependencies": {
+     "babel-plugin-espower": "2.1.2",
+     "babel-polyfill": "6.26.0",
+     "babel-preset-es2015": "6.6.0",
+     "babelify": "7.2.0",
    ...
  },
+   "babel": {
+     "presets": [
+       "es2015"
+     ]
+   }

gulpfile.js

const gulp = require('gulp');
const browserify = require('browserify');
- const coffeeify = require('coffeeify');
+ const babelify = require('babelify');
+ const fs = require('fs');

const dir = {
    srcJS: "./frontend/js/",
    distJS: "./app/assets/javascripts/"
};

- const coffeeFiles = [
-     'hoge.coffee',
-     'fuga.cofffe',
-     ...
+ const jsFiles = [
+     'hoge.js',
+     'fuga.js',
]

gulp.task('build', () => {
    jsFiles.forEach(function (file) {
-         var srcfile = coffee2jsExtension(file);
-         browserify({
-             entries: [dir.srcJS + file],
-             extensions: ['.coffee'],
-             transform:  [coffeeify]
-         })
-         .bundle()
-         .pipe(source(srcfile))
-         .pipe(gulp.dest(dir.distJS));
+         browserify(dir.srcJS + file, { debug: true } )
+             .transform(babelify)
+             .bundle()
+             .pipe(fs.createWriteStream(dir.distJS + file));
    }
});

- function coffee2jsExtension(file){
-     return file.replace(".coffee", ".js");
- }

各エントリポイントのJSにbabel-polyfillを適用

+ import 'babel-polyfill'

3. コケるfeature specをなんとかする

  • Poltergist + PhantomJSで書いてたfeature specがコケる
    • buttonのclickイベントが発火しない, etc
  • クロスブラウザチェック等では問題なく動作できている
  • PhantomJSでしか発現しない問題に対処するのは辛い

というような経緯で、これを期にHeadless Chromeに乗り換える決断をしました。Headless Chrome乗り換えに関する記事は後日書きます。

4. JSエラー検知の導入

クロスブラウザチェックとテストコードが通ることを確認したものの、 何か漏れてるリスクも踏まえてSentryによるJSのエラー通知を導入しました。

package.json

  "dependencies": {
+     "raven-js": "3.0.4",
  ...

各エントリポイントに追加

var Raven;

if (typeof history.pushState === "function") {
    Raven = require('raven-js');
    Raven.config(sentry_dsn).install();
}

5.リリース後

リリース後1ヶ月程度が経ちましたが、DS102の対応(不要なreturnの削除)で、消してはいけないreturnを消してた箇所が一つあったという人為的な凡ミス以外に、今のところ問題は起きていません。 工数的に大きかったのはクロスブラウザチェックやfeature spec回りで、decafeそのものは比較的スムーズに行うことができたという所感です。

とはいえフロントエンドも定期的に負債解消しないとツケが大きくなってくるのは当然で、 バックエンドだけでなくフロントエンドも継続的にキャッチアップしていかなければなぁ。。という気持ちになりました。

次回は @yt-tanabeより「SpeeeKaigiのつくりかた」です。