Help us understand the problem. What is going on with this article?

Kotlin/JS の現状を確かめる

More than 1 year has passed since last update.

はじめに

Kotlin コードから JavaScript コードに変換する Kotlin/JS1。現状を確かめるために、Vue.js のガイド を試してみました。

確認したいこと:

  • JavaScript ライブラリはどのように使う?
  • JavaScript ライブラリを使うときに、型はどうする?
  • ビルド環境はどうする?
  • HMR2 はどうする?
  • minify はどうする?

やらないこと:

React は JetBrains さんがライブラリを用意してくれているので、それに従って使えばよいと思います。今回は、React 以外のライブラリを使いたい場合はどうすれば良いのか?という辺りを調べました。また、プロダクションで使うことを想定して、ビルド環境、HMR、minify も調べました。

なお、著者はフロントエンドに弱いです。Vue.js, Node.js, webpack は今回の調査で初めて使いました。もっと良い方法があるよ、という方がいらっしゃったら教えてください。

環境

ツール/ライブラリ バージョン
Kotlin 1.2.0
Gradle 4.1
Vue.js 2.4.4
Node.js 8.5.0

Kotlin は 1.1.4 を使って試行錯誤した上で、1.2.0 でも動作することを確認しました。その他諸々のバージョンは build.gradle で確認できます。

成果

調査結果のまとめを述べておきます。

  • JavaScript ライブラリは容易に使えるが、型の付与は手動
    • アノテーションを付与すれば JavaScript ライブラリを参照できる
    • React のラッパーは JetBrains さんが提供している
    • ts2kt という TypeScript の型定義を Kotlin コードに変換する公式(?)ツールはあるが、ツライ
      • 生成された Kotlin コードは Deprecated だらけ
      • dynamic 型が多く、型チェックされない
  • ビルド環境に kotlin-frontend-plugin という公式(?) Gradle プラグインはあるが、ツライ
    • kotlinconf-app では使われておらず、やる気を感じない
    • webpack.config を隠そうと頑張っているが、中途半端
  • HMR は Gradle の continuous build と webpack の仕組みで実現できる
  • minify は webpack と連携させると、ツライ
    • JavaScript DCE3 ツールはあるが、webpack との連携は考慮されていない
    • DCE 後の minify(uglify) は JavaScript ツールを使えば良いので、調べていない

結果としては、ツライのが楽しい人には魅力的な状況です。

サンプルコードは vue-kotlin で確認できます。色々と試していたら肥大化してしまいました…。「試行方法」を試して頂くと良いかと思います。

JavaScript のライブラリはどのように使う?

Kotlin/JS で JavaScript モジュールを使うには、@JsModule(モジュール名) を使います。モジュールのファイル名が vue.js であれば、モジュール名は vue です。

index.kt
import kotlin.js.Json

// var Vue = require('vue')
@JsModule("vue")
external class Vue(options: Json)
computed.kt
// var axios = require('axios')
@JsModule("axios")
external val axios: dynamic
Lodash.kt
// var lodash = require('lodash')
@file:JsModule("lodash")
package lodash

external fun debounce(func: () -> Unit, wait: Int? = definedExternally, options: Any? = definedExternally): Function<Any>

external fun capitalize(string: String): String

例として、3 種類の使い方を挙げました。上から順に、クラス定義、プロパティ、ファイルに対して @JsModule を指定しています。コメントで示したとおり、@JsModule は CommonJS の require と対応しています。external 修飾子によって、外部 (JavaScript ライブラリ) で定義されていることを表します。dynamic 型によって、型が動的に変化することを表せます。definedExternally を指定すれば、引数をオプションにできます。

これらの、クラス定義、プロパティ、(ファイルに定義された) トップレベルの関数を使ってコードを記述します。

index.kt
import kotlin.js.json

/*
  var app = new Vue({
      el: '#app',
      data: {
          message: 'Hello Vue!'
      }
  })
*/
val app = Vue(json(
    "el" to "#app",
    "data" to json(
        "message" to "Hello Vue!"
    )
))
computed.kt
external val `this`: dynamic  // JavaScript の this を無理矢理参照する

axios.get("https://yesno.wtf/api")
    .then(fun(response: dynamic) {
        `this`.answer = lodash.capitalize(response.data.answer as String)
    })
    .catch(fun(error: String) {
        `this`.answer = "Error! Could not reach the API. $error"
    })

Json インターフェースと json 関数は、kotlin.js パッケージに定義されています。json 関数の引数は、Pair オブジェクトです。

dynamic 型では、型が定まらない前提であるため、任意のプロパティと関数を参照できます。例えば、axiosdynamic 型なので、任意の関数を呼び出すことができ、get 関数を呼び出せています。get 関数の戻り値の型も dynamic 型になるため、続けて任意の関数を呼び出せます。

`this` プロパティは、JavaScript の this を参照するために使っています。Kotlin の関数は this を持ちませんが、JavaScript の関数は this を持ちます。JavaScript では、関数が呼び出されたときに、関数を保持していたオブジェクトが this で参照されますが、これは Kotlin には無い仕組みです。関数の代わりにレシーバ付き関数を使えば、関数中で this を参照できますが、レシーバオブジェクトを指定する第一引数が追加されてしまいます。

Kotlin
external val `this`: dynamic

/*
  function fun1$lambda() {
      print(this.length);
  }
*/
val fun1 = fun() {
    print(`this`.length)
}

/*
  function fun2$lambda($receiver) {
      print($receiver.length);
  }
*/
val fun2 = fun String.() {
    print(this.length)
}

JavaScript ライブラリを使うときに、型はどうする?

external 修飾子、dynamic 型、js 関数 (ここでは紹介していない) を使えば、JavaScript ライブラリのクラスや関数を使うことができます。しかし、dynamic 型を使っていては、Kotlin を使う利点のひとつである、型安全の恩恵が得られません。

例えば、Vue.js を次の様に使いたいと思います。

computed.kt
fun main(args: Array<String>) {
    val example = ExampleVue(ComponentOptions {
        el = ElementConfig("#example")
        data = Data(json = json {
            message = "Hello"
        })
        computed = json {
            this["reversedMessage"] = ComputedConfig {
                val self = thisAs<ExampleVue>()
                self.message.split("").reversed().joinToString("")
            }
        }
    })

    println(example.reversedMessage) // => 'olleH'
    example.message = "Goodbye"
    println(example.reversedMessage) // => 'eybdooG'
}

この例は実際に動きます。IDE による補完と型チェックが有効になるので助かります。ComponentOptions, ElementConfig, Data, ComputedConfig, thisAs は、より簡潔に記述したいですが、試行錯誤した結果として他に良い方法が思いついていません。

thisAs 関数は、external val `this`: dynamic を型で制約するために定義した関数です。

Kotlin
inline fun <T : Any> thisAs(): T = `this`

ComponentOptions は、JSON オブジェクトの初期化時に、プロパティとその型を制約するために定義しているインターフェースです。定義の一部は次の様にしています。

Kotlin
external interface ComponentOptions<V : Vue> {
    var el: ElementConfig?
    var data: Data<V>?
    var computed: JsonOf<ComputedConfig<*>>?
}

inline fun <T : Any> json(): T = js("({})")
fun <T : Any> json(init: T.() -> Unit): T = json<T>().apply(init)
fun <V : Vue> ComponentOptions(init: ComponentOptions<V>.() -> Unit): ComponentOptions<V> = json(init)

公式サイトでも紹介されているexternal interface の使い方 です。JSON オブジェクトの初期化をプロパティへの代入で行えます。external interface を使った場合、JavaScript のコードにはインターフェースの定義が残らないため、変換後の JavaScript コード量を減らせます。

ElementConfig, Data, ComputedConfigは、Union 型を実現するために定義しています。例えば、Data インターフェースの定義です。

Kotlin
/**
 * `T | () -> T`
 */
external interface Data<T>

inline fun <T> Data(json: T): Data<T> = json.asDynamic()
inline fun <T> Data(factory: () -> T): Data<T> = factory.asDynamic()

Data() をインスタンス生成に見せかけていますが、実際は何も行わない関数です。asDynamic() は、型を dynamic 型に変換するだけです。行っていることはキャストなのですが、as を使ってキャストを行った場合には実行時型チェックが行われます。実行時型チェック (Kotlin.isType) の例を示します。

Kotlin
external interface Data

inline fun Data(msg: String): Data = msg.asDynamic()

fun main() {
    // var data1 = 'Hello';
    val data1: Data = Data("Hello")
    // var data2 = Kotlin.isType(tmp$ = 'Hello', Object) ? tmp$ : throwCCE();
    val data2: Data = "Hello" as Data
}

この実行時型チェックは不要であり、JavaScript コード量を削減するために、ここで紹介した方法を使っています。

これらの方法を使えば、次に示す様なコードが生成されます。型の制約を加えつつ、生成されるコード量は抑えられています。(読みやすくするために順番を入れ替え、僅かに変更しています。)

JavaScript
function main(args) {
    var example = new $module$vue(ComponentOptions(main$lambda));
    println(example.reversedMessage);
    example.message = 'Goodbye';
    println(example.reversedMessage);
}

function main$lambda($receiver) {
    $receiver.el = '#example';
    $receiver.data = json(main$lambda$lambda);
    $receiver.computed = json(main$lambda$lambda_0);
    return Unit;
}

function main$lambda$lambda($receiver) {
    $receiver.message = 'Hello';
    return Unit;
}

function main$lambda$lambda_0($receiver) {
    $receiver['reversedMessage'] = main$lambda$lambda$lambda;
    return Unit;
}

function main$lambda$lambda$lambda() {
    var self_0 = this;
    return joinToString(reversed(split(self_0.message, [''])), '');
}

function ComponentOptions(init) {
    return json(init);
}

function json(init) {
    var $receiver = {};
    init($receiver);
    return $receiver;
}

なお、ExampleVue()$module$vue() に変換されています。これは、ExampleVue クラスに @JsModule を指定しているためです。

computed.kt
@JsModule("vue")
external class ExampleVue(options: ComponentOptions<ExampleVue>) : Vue {
    var message: String
    val reversedMessage: String
}

ここまで、長々と試行錯誤の成果を紹介してきましたが、手作業でこれらを行いたくはありません。そこで、登場するのが TypeScript の型定義を Kotlin コードに変換するツールです。ts2kt というツールがあるので、試してみました。ts2kt で options.d.ts を変換した結果の一部を次に示します。

options.kt
external interface `T$1` {
    @nativeGetter
    operator fun get(key: String): dynamic /* (this: V) -> Any | ComputedOptions<V> */
    @nativeSetter
    operator fun set(key: String, value: (this: V) -> Any)
    @nativeSetter
    operator fun set(key: String, value: ComputedOptions<V>)
}

external interface ComponentOptions<V : Vue> {
    var el: dynamic /* Element | String */ get() = definedExternally; set(value) = definedExternally
    var data: dynamic /* Any | (this: V) -> Any */ get() = definedExternally; set(value) = definedExternally
    var computed: `T$1`? get() = definedExternally; set(value) = definedExternally
}

この変換結果には次の問題があります。

  • T$1 は他のファイルでも定義されていて名前が被る
    • ファイル毎にパッケージ指定されるわけでもない
  • 引数に this が存在する
  • @nativeGetter@nativeSetter は Deprecated
  • Union 型は dynamic 型になる

残念ですが、そのままでは使い物になりません。d.ts ファイルの定義に基づいて手作業で作成した Kotlin コードは vuekt として公開しています。

ビルド環境はどうする?

ビルド環境には Gradle を使います。kotlinconf-app では、Gradle の Node Plugin を使うことで、Node.js, webpack と合わせて使っています。Kotlin/JS のコードは build.gradlemoduleKind を設定すれば、CommonJS のモジュールとして生成できるため、Node.js のモジュールとして扱えます。

build.gradle
compileKotlin2Js.kotlinOptions.moduleKind = "commonjs"

たぶん、上記の Node Plugin を使う方がシンプルで融通が利くと思いますが、kotlin-frontend-plugin を使う選択肢もあります。kotlin-frontend-plugin で追加される Gradle Task は次の通りです。

  1. nodejs-download
    • Node.js をダウンロードする
    • 設定によりインストール済みの Node.js を選択可能
  2. npm-configure
    • build.gradle の設定に基づき package.json を生成する
  3. npm-preunpack
    • JAR ファイル形式の Kotlin/JS ライブラリを node_modules_imported に展開する
    • node_modules_imported 以下のディレクトリは、node_modules からリンクされる
  4. npm-install
    • package.json に従い Node.js のライブラリを node_modules にインストールする
  5. npm-index
    • 依存しているライブラリの一覧を作成する
  6. npm-deps
    • 依存しているライブラリの一覧を読み込み、プラグインに保持する
  7. webpack-config
    • build.gradle の設定と webpack.config.d ディレクトリ以下の JavaScript に基づき webpack.config.js を生成する
  8. webpack-bundle
    • node $buildDir/node_modules/webpack/bin/webpack.js --config $buildDir/webpack.config.js を実行して、bundle を生成する
  9. webpack-helper
    • WebPackHelper.js を生成する
    • webpack-dev-server-run.jsvar RunConfig = require('$RunConfig$'); を置換するために使われる
  10. webpack-run
    • node $buildDir/webpack-dev-server-run.js を実行して、開発サーバーを起動する
  11. webpack-stop
    • curl http://localhost:$port/webpack/dev/server/shutdown を実行して、開発サーバーを停止する

kotlin-frontend-plugin の作法を学べば、依存関係管理が楽になるかもしれません。Node.js のライブラリ、Kotlin/JS の JAR ファイル形式のライブラリ、プロジェクト内の他モジュール、これら様々な形式のモジュールと依存することになるので、それらの依存を Gradle を中心にしてまとめられる点は有効です。

しかし、bundle を生成する際に使われる webpack.config.js、開発サーバーを起動する際に使われる webpack-dev-server-run.js がプラグインに生成されるため、これらの設定ファイルを思うままに編集することが困難です。kotlin-frontend-plugin では、webpack.config.jsentry 設定が、一つの bundle ファイルだけを生成する前提で設計されています。複数の bundle ファイルを生成させようとする場合には工夫が必要ですし、一つの bundle ファイルを複数のファイルから生成させると問題が起こります。

kotlin-frontend-plugin に関する細かいことは、ブログに掲載しています。

HMR はどうする?

webpack には、ブラウザのリロードを行うことなく、ファイルの変更をブラウザの表示に反映する仕組みがあります。それが、HMR2 です。Kotlin/JS と webpack を組み合わせて使う場合にも、HMR は有効です。

Gradle は -t オプションを付けて実行することで continuous build が有効になります。continuous build が有効になっていれば、Kotlin コードを編集する度に build が実行されて JavaScript コードが更新されます。JavaScript コードが更新されることで、webpack の HMR の仕組みによりブラウザに反映されます。

kotlin-frontend-plugin では、開発サーバーを起動する場合に HMR が有効になるようになっています。kotlin-frontend-plugin が生成する webpack-dev-server-run.js で HMR の設定が行われています。ただし、このファイルには問題があり、次の様に entry を複数ファイルから構成するとエラーになります。

webpack.config.js
entry = {
    'greeting': 'greeting',
    'vendor': ['vue', 'vuekt'] // NG
}

minify はどうする?

JavaScript のコード量はできる限り減らしたいです。Kotlin/JS の JavaScript ライブラリのコードの大半はアプリケーションから使われませんが、対策を施さなければ全てブラウザにロードされます。Kotlin/JS には、使われていないコードを除去する DCE3 機能があり、現在は Gradle プラグインとして提供されています。

Kotlin/JS では、複数の Kotlin ファイルから一つの JavaScript ファイルを生成します。生成された JavaScript ファイルに基づき DCE を実行し、縮小化された kotlin.js が生成されます。つまり、kotlin.js は、モジュール (プロジェクト) 毎に別々に生成されます。

DCE を使う場合には、webpack で bundle を行う際に、モジュール毎の kotlin.js を使う必要があります。しかし、kotlin-frontend-plugin を使うと、kotlin.jsnode_modules に展開されるため、webpack の設定を工夫しなければいけません。

bundle 後の minify(uglify) は JavaScript のツールを使えばよいので、調べていません。

おわりに

Kotlin と JetBrains の GitHub リポジトリで提供されている Kotlin/JS のサンプルは、生成する JavaScript ファイルが一つになっています。提供されるツールも、生成する JavaScript ファイルが一つである前提です。このため、複数の JavaScript ファイルに分割したい場合には、ツライ部分が多いです。

今回は調べていない内容として、デバッグ環境があります。ソースマップの生成は行われますが、ソースマップを使って表示される Kotlin コードの行がずれるため、ソースマップの生成を無効にして試していました。

現状では、型による恩恵に対して、失うものが多いと思うため、プロダクションでの利用はすすめられません。しかし、OSS 活動に参加してみたい方には、貢献できる部分が多いと思うのでおすすめです。


  1. Kotlin/JS という表現は、Kotlin Blog などで使用されています。 

  2. Hot Module Replacement 

  3. Dead Code Elimination 

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away