はじめに
Kotlin コードから JavaScript コードに変換する Kotlin/JS1。現状を確かめるために、Vue.js のガイド を試してみました。
確認したいこと:
- JavaScript ライブラリはどのように使う?
- JavaScript ライブラリを使うときに、型はどうする?
- ビルド環境はどうする?
- HMR2 はどうする?
- minify はどうする?
やらないこと:
- Kotlin/JS のライブラリを使う
- Kotlin/JS で React を使う
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
です。
import kotlin.js.Json
// var Vue = require('vue')
@JsModule("vue")
external class Vue(options: Json)
// var axios = require('axios')
@JsModule("axios")
external val axios: dynamic
// 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
を指定すれば、引数をオプションにできます。
これらの、クラス定義、プロパティ、(ファイルに定義された) トップレベルの関数を使ってコードを記述します。
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!"
)
))
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
型では、型が定まらない前提であるため、任意のプロパティと関数を参照できます。例えば、axios
は dynamic
型なので、任意の関数を呼び出すことができ、get
関数を呼び出せています。get
関数の戻り値の型も dynamic
型になるため、続けて任意の関数を呼び出せます。
`this`
プロパティは、JavaScript の this
を参照するために使っています。Kotlin の関数は this
を持ちませんが、JavaScript の関数は this
を持ちます。JavaScript では、関数が呼び出されたときに、関数を保持していたオブジェクトが this
で参照されますが、これは Kotlin には無い仕組みです。関数の代わりにレシーバ付き関数を使えば、関数中で this
を参照できますが、レシーバオブジェクトを指定する第一引数が追加されてしまいます。
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 を次の様に使いたいと思います。
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
を型で制約するために定義した関数です。
inline fun <T : Any> thisAs(): T = `this`
ComponentOptions
は、JSON オブジェクトの初期化時に、プロパティとその型を制約するために定義しているインターフェースです。定義の一部は次の様にしています。
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
インターフェースの定義です。
/**
* `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
) の例を示します。
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 コード量を削減するために、ここで紹介した方法を使っています。
これらの方法を使えば、次に示す様なコードが生成されます。型の制約を加えつつ、生成されるコード量は抑えられています。(読みやすくするために順番を入れ替え、僅かに変更しています。)
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
を指定しているためです。
@JsModule("vue")
external class ExampleVue(options: ComponentOptions<ExampleVue>) : Vue {
var message: String
val reversedMessage: String
}
ここまで、長々と試行錯誤の成果を紹介してきましたが、手作業でこれらを行いたくはありません。そこで、登場するのが TypeScript の型定義を Kotlin コードに変換するツールです。ts2kt というツールがあるので、試してみました。ts2kt で options.d.ts を変換した結果の一部を次に示します。
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.gradle
で moduleKind
を設定すれば、CommonJS のモジュールとして生成できるため、Node.js のモジュールとして扱えます。
compileKotlin2Js.kotlinOptions.moduleKind = "commonjs"
たぶん、上記の Node Plugin を使う方がシンプルで融通が利くと思いますが、kotlin-frontend-plugin を使う選択肢もあります。kotlin-frontend-plugin で追加される Gradle Task は次の通りです。
- nodejs-download
- Node.js をダウンロードする
- 設定によりインストール済みの Node.js を選択可能
- npm-configure
-
build.gradle
の設定に基づきpackage.json
を生成する
- npm-preunpack
- JAR ファイル形式の Kotlin/JS ライブラリを
node_modules_imported
に展開する -
node_modules_imported
以下のディレクトリは、node_modules
からリンクされる
- npm-install
-
package.json
に従い Node.js のライブラリをnode_modules
にインストールする
- npm-index
- 依存しているライブラリの一覧を作成する
- npm-deps
- 依存しているライブラリの一覧を読み込み、プラグインに保持する
- webpack-config
-
build.gradle
の設定とwebpack.config.d
ディレクトリ以下の JavaScript に基づきwebpack.config.js
を生成する
- webpack-bundle
-
node $buildDir/node_modules/webpack/bin/webpack.js --config $buildDir/webpack.config.js
を実行して、bundle を生成する
- webpack-helper
-
WebPackHelper.js
を生成する -
webpack-dev-server-run.js
のvar RunConfig = require('$RunConfig$');
を置換するために使われる
- webpack-run
-
node $buildDir/webpack-dev-server-run.js
を実行して、開発サーバーを起動する
- 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.js
の entry
設定が、一つの 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
を複数ファイルから構成するとエラーになります。
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.js
は node_modules
に展開されるため、webpack の設定を工夫しなければいけません。
bundle 後の minify(uglify) は JavaScript のツールを使えばよいので、調べていません。
おわりに
Kotlin と JetBrains の GitHub リポジトリで提供されている Kotlin/JS のサンプルは、生成する JavaScript ファイルが一つになっています。提供されるツールも、生成する JavaScript ファイルが一つである前提です。このため、複数の JavaScript ファイルに分割したい場合には、ツライ部分が多いです。
今回は調べていない内容として、デバッグ環境があります。ソースマップの生成は行われますが、ソースマップを使って表示される Kotlin コードの行がずれるため、ソースマップの生成を無効にして試していました。
現状では、型による恩恵に対して、失うものが多いと思うため、プロダクションでの利用はすすめられません。しかし、OSS 活動に参加してみたい方には、貢献できる部分が多いと思うのでおすすめです。