CoffeeScript
AngularJS
es2015

CoffeeScript → ES2015移行でトラブったことまとめ

CoffeeScriptで書かれたAngularJSアプリケーションのコード群を順次ES2015に置き換えた際、いくつかやらかしてしまった問題があったので、備忘録としてここにまとめます。

環境

  • CoffeeScript 1系
  • AngualrJS 1.3
  • Grunt
  • Browserify

移行手順

  • decaffeinateを使ってコード変換
    • 一度に全部のコードを変換するのに耐えられるテストが無いため、細かく1ファイルずつ移行していく
  • ESLintへの適応、ESModulesの対応
  • ファイルの切り替え

陥った問題

二重定義とinstanceofによる問題

細かく移行していくため、jsに変換したあとも元のCoffeeScriptファイルを消せない場合が出てきます。例えば

a.coffee
class A
  test: () ->
    console.log(10)
a.js
export default class A {
  test() {
    console.log(10);
  }
}

この変換に問題はありませんが、CoffeeScript側のクラスAがほかの未変換のCoffeeScriptから参照されていた場合、それらがすべてjsに変換されるまでa.coffeeは消せず、クラスAは二重に定義された状態になります。
ふたつのクラスAは同じふるまいをするので多くの場合問題にはなりませんが、Aのインスタンスに対してinstanceofしている場合は致命的にマズイです。

b.js
import A from './a.js';

function test(obj) {
  if (obj instanceof A) {
    // ...
  }
}

このコードでは、引数objにjs側のクラスAインスタンスが渡ってくる場合はinstanceof Atrueになりますが、CoffeeScript側のクラスAインスタンスが渡ってきた場合はfalseとなり、意図しない動作の元となります。

@とグローバル変数問題

decaffeinateの変換によって不具合が発生する場合があります。以下がその例です。

sample.coffee
class Test
  constructor: (@hoge) ->
    console.log(hoge)

このようなCoffeeScriptをdecaffeinateで変換すると、以下のようなjsが出力されます。

sample.js
class Test {
  constructor(hoge) {
    this.hoge = hoge;
    console.log(hoge);
  }
}

しかしこの変換は正しくありません。
正しく動作させるには以下のように修正する必要があります。

sample.js
class Test {
  constructor(hoge) {
    this.hoge = hoge;
    console.log(window.hoge);
  }
}

つまり、CoffeeScript側のconsole.log(hoge)におけるhogeはグローバル変数であり引数@hogeとは別物であるるため、js側では引数hogeとグローバル変数hogeを明確に分けるためwindowの明示が必要だったということです。
この変換による不具合は、一見して間違いが見えづらく、十分なテストが書かれていなければ防ぐのは難しいかもしれません。

$scopeとbind問題

c.coffee
class C
  constructor: (scope) ->
    scope.foo = @foo

  foo: () =>
    console.log(this)

C.$inject = ['$scope']
c.js
class C {
  static $inject = ['$scope'];

  constructor(scope) {
    this.foo = this.foo.bind(this);
    scope.foo = this.foo;
  }

  foo() {
    console.log(this);
  }
}

このthis.foo.bind()の部分を「無くてもなんとかなるのでは?」と安易に考えて消してしまうと

test.html
<button ng-click="foo()" />

のようにscope経由でメソッドを触った時、メソッド内のthisがオブジェクト自身ではなくscopeを指してしまい、意図しない動作の元となります。bind()は残すか、Controller asでメソッドを使う必要があると思われます。

さいごに

当移行では、かなりのトラブルが発生してしまいました。それなりの規模のアプリケーションであることもあり、リリースには恐怖感すらありました。
なんとか大半の移行は完了しましたが、このような事態を避けるため日頃からテストを書いておくべきだと思うのでした。