JavaScript
frontend
Svelte

Svelte ナメてたけど結構スゴい

More than 1 year has passed since last update.

 2017-04-02 23.40.20.png

Front-end Developer Handbook 2017 でも言及されていたりと少しずつ注目を集めている印象の Svelte ですが、「はいはいまた JS の流行り物 FW でしょ」とか「No Framework って Serverless みたいなバズワードになるんでしょ〜」などと正直ナメてたんですが、ナメっぱなしではいけない、とガイドを一通りさらったところ「なるほどこれは新しい勢力だな」と思い至ったので同じくナメてる人向けにスゴいところを紹介します。一応断っておきますが、さすがに商用環境に投入できるかというとまだ全然出来る気がしないので、今すぐあなたの xxx を置き換えるものではないです。


Svelte のルック&フィール

https://svelte.technology/guide

Svelte は Riot.js のコンポーネントや Vue.js のコンポーネントのように html ファイルにテンプレートとロジックを書いていくのが基本になっています。


Time.html

<h1>{{time}}</h1>

<script>
export default {
data () {
return {
time: new Date()
}
}
}
</script>


単一のコンポーネントであれば svelte-cli (https://www.npmjs.com/package/svelte-cli) を使って

$ svelte compile --format iife Time.html > index.js

でコンパイルできます。ブラウザで確認するために簡単な html を書きます。


index.html

<div id="app"></div>

<script src="index.js"></script>
<script>
new Time({
target: document.getElementById('app')
})
</script>

ひとまずこれで日付もろもろが表示されるページが作成できました。ここで、コンパイルされた js を読んでみると

function Time ( options ) {

options = options || {};
this._state = Object.assign( template.data(), options.data );

this._observers = {
pre: Object.create( null ),
post: Object.create( null )
};

this._handlers = Object.create( null );

this._root = options._root;
this._yield = options._yield;

this._torndown = false;

this._fragment = renderMainFragment( this._state, this );
if ( options.target ) this._fragment.mount( options.target, null );
}

というようにコンポーネントそれ自体が他の依存なく動く形にコンパイルされています。テンプレート部分は renderMainFragment に含まれていて

function renderMainFragment ( root, component ) {

var h1 = createElement( 'h1' );

var last_text = root.time;
var text = createText( last_text );
appendNode( text, h1 );

return {
mount: function ( target, anchor ) {
insertNode( h1, target, anchor );
},

update: function ( changed, root ) {
var __tmp;

if ( ( __tmp = root.time ) !== last_text ) {
text.data = last_text = __tmp;
}
},

teardown: function ( detach ) {
if ( detach ) {
detachNode( h1 );
}
}
};
}

というような調子です。ここで createElement とか createText ってなんでしょう、という話なんですが

function createElement( name ) {

return document.createElement( name );
}

function detachNode( node ) {
node.parentNode.removeChild( node );
}

function insertNode( node, target, anchor ) {
target.insertBefore( node, anchor );
}

function createText( data ) {
return document.createTextNode( data );
}

function appendNode( node, target ) {
target.appendChild( node );
}

こんな感じで JS の関数の薄いラッパーになっていることが分かります。ただ、この後に Observe あたり向けのコードがちょっとあるため合わせて80行ほどランタイムっぽいコードが挿入されることになります。

CSS は html ファイルの style タグを使って実装されています。HTML に

<style>

.time {
background-color: #ccc;
}
</style>

と書き加えると

var addedCss = false;

function addCss () {
var style = createElement( 'style' );
style.textContent = "\n [svelte-398421373].time, [svelte-398421373] .time {\n background-color: #ccc;\n }\n";
appendNode( style, document.head );

addedCss = true;
}

というような style タグを挿入するコードが追加されて、HTML には svelte-398421373 という属性が追加されて擬似的な Scoped CSS を実現しています。また、CSS はコンパイラで分離したりもできます。


Computed property

続いて Vue や knockout などでおなじみの computed property を試します。Time.html を以下のように書き換えます。


Time.html

<h1>{{hours}}:{{minutes}}:{{seconds}}</h1>

<script>
export default {
data () {
return {
time: new Date()
}
},
computed: {
hours: time => time.getHours(),
minutes: time => time.getMinutes(),
seconds: time => time.getSeconds()
}
}
</script>


これを先述したコマンドで同様にコンパイルしてブラウザで実行すると、時間のみが表示されるようになります。ここで computed のプロパティの関数の引数の time はどうやって参照されるのでしょうか?コンパイルされた JS を読むと

function recompute ( state, newState, oldState, isInitial ) {

if ( isInitial || ( 'time' in newState && differs( state.time, oldState.time ) ) ) {
state.hours = newState.hours = template.computed.hours( state.time );
}

if ( isInitial || ( 'time' in newState && differs( state.time, oldState.time ) ) ) {
state.minutes = newState.minutes = template.computed.minutes( state.time );
}

if ( isInitial || ( 'time' in newState && differs( state.time, oldState.time ) ) ) {
state.seconds = newState.seconds = template.computed.seconds( state.time );
}
}

新たに recompute 関数が追加されています。

isInitial || ( 'time' in newState && differs( state.time, oldState.time ) )

で変更を検査して、更新されていたら

state.seconds = newState.seconds = template.computed.seconds( state.time )

で computed property に指定した関数に state の当該 property を引数で渡していることが分かります。つまり、コンパイル時点で引数の名前から解決しているわけです。ここら辺はコンパイラとして作ってる利点かなとおもいます。


Lifecycle

次に、時間を毎秒更新していくように変更します。


Time.html

<h1>{{hours}}:{{minutes}}:{{seconds}}</h1>

<script>
export default {
data () {
return {
time: new Date()
}
},
computed: {
hours: time => time.getHours(),
minutes: time => time.getMinutes(),
seconds: time => time.getSeconds()
},
oncreate () {
this.interval = setInterval(() => this.set({
time: new Date()
}), 1000)
},
ondestroy () {
clearInterval(this.interval)
}
}
</script>


これを同様にコンパイルすると時間が動き出します。

コンポーネントのライフサイクルは至ってシンプルで oncreateondestroy のみです。また、コンポーネントには getset が定義されているのでこれを使って状態を更新します。set

Time.prototype._set = function _set ( newState ) {

var oldState = this._state;
this._state = Object.assign( {}, oldState, newState );
recompute( this._state, newState, oldState, false )

dispatchObservers( this, this._observers.pre, newState, oldState );
if ( this._fragment ) this._fragment.update( newState, this._state );
dispatchObservers( this, this._observers.post, newState, oldState );
};

というように新しい state を作って recompute で computed property を更新して observer に通知するだけ、という単純なものです。


Nested components

次に、date を受け取って UTC を表示する子コンポーネントを組み合わせてみましょう。現在のところ Svelte 自体にバンドルする仕組みはないようで、Browserify や Webpack 向けの Plugin が README で紹介されています。今回は熟れている Browserify と sveltify (https://www.npmjs.com/package/sveltify) でバンドルします。

HTML からエントリポイントを引き剥がして


index.html

<div id="app"></div>

<script src="index.js"></script>

のように変更し、


index.js

const Time = require('./Time.html')

new Time({
target: document.getElementById('app')
})


新たにエントリファイルを作成しました。続いて子コンポーネントを作成します。


UTC.html

<h2>{{hours}}:{{minutes}}:{{seconds}}</h2>

<script>
export default {
computed: {
hours: time => time.getUTCHours(),
minutes: time => time.getUTCMinutes(),
seconds: time => time.getUTCSeconds()
}
}
</script>


time は親から受け取るので computed property のみです。


Time.html

<h1>{{hours}}:{{minutes}}:{{seconds}}</h1>

<UTC time='{{time}}' />

<script>
import UTC from './UTC.html'

export default {
data () {
return {
time: new Date()
}
},
computed: {
hours: time => time.getHours(),
minutes: time => time.getMinutes(),
seconds: time => time.getSeconds()
},
oncreate () {
this.interval = setInterval(() => this.set({
time: new Date()
}), 1000)
},
ondestroy () {
clearInterval(this.interval)
},
components: {
UTC
}
}
</script>


子コンポーネントとなる html ファイルを import してきて親コンポーネントの components に渡してやると HTML で参照できるようになります。

$ browserify -t sveltify index.js > public/index.js

これでバンドルされた JS を生成してブラウザで確認するとローカルゾーンの時間の下に UTC の時間が表示されるようになります。

ここで、バンドルされた JS を確認すると当たり前ですが先述したヘルパ関数などが重複してしまいます。ここは

Svelte の特性上しょうがない部分と割り切ってるのかもしれないですが、コンパイラがバンドルまで含めて最適化された JS が出せると尚いいんじゃないかな〜とおもいました。


微妙なところ

こういう手合いの HTML のシンタックスを拡張したテンプレートを強要してくるタイプにありがちですが、テンプレートはかなり微妙だとおもいました。on:click='{{}}' とか #each ..., #if ... とか。

また、Two-way binding はまだ実装が途中のようですが bind:value のようなシンタックスで input の変更があったら state を更新できます。ここもヘンにシンタックスで解決するよりは React のように value と onChange を指定する方がしっくりくるのは私だけでしょうか...。


おわりに

Svelte はベンチマーク (http://www.stefankrause.net/js-frameworks-benchmark5/webdriver-ts/table.html) でパフォーマンスも良いようですし、ホビー程度なら採用する選択肢に入れてもいいんじゃないかなとおもいました。今年こそ dynamic import がなんとかなりそうな気配がするので、非同期に Svelte の小さいコンポーネントを読んできてちょっとしたところで(検索ボックスとか、モーダルとか)使ったりできるようになるかもしれません。

ところで、ベンチマークの記事で知ったんですが React ライクなインターフェイスの dio.js はめちゃくちゃ速いですね。また、今年は React が随分長いこと取り組んでいた Fiber が出そうだったり、Angular が 4 になったりと、昨年は「止まって見える」なんて言われてた JS 界隈が大きく前進するといいですね。