アシアルブログ

アシアルの中の人が技術と想いのたけをつづるブログです

Vue.jsはより速く、より小さく!〜Evan氏の来日講演から読み解くVue 3の進化〜

f:id:knight_9999:20181108183653p:plain:w600

こんにちは。Monaca/Cordova担当の内藤です。

今年(2018年)の11月3日に、秋葉原 UDXでVue Fes Japan 2018が開催されました。Vue.jsの開発者であるEvanさんをはじめ、Vue CLI UIクリエーターのGuillaumeさん、Microsoft シニアデベロッパーアドボケイトのSarahさん、Vue Test UtilsメインクリエーターのEddさんらをお招きしています。

開発者であるEvanさんの基調講演では、新しくリリースが予定されているVue 3.0の概要について、お話いただきました。Vue 3.0の特徴として、以下の点を解説していただきました。

  • より速く
  • より小さく
  • よりメンテナンスしやすく
  • よりネイティブ向けに作りやすく
  • よりあなたのコードの保守性を向上

f:id:knight_9999:20181108183756p:plain:w600

どれも魅力的な改善点ですが、このブログ記事では特に、コンポーネントレンダリングのロジックの変更されたいくつかの点についてクローズアップして、現在の最新安定板であるVue 2.5との比較しながら、見ていきたいと思います。講演でも説明されていましたが、「アプリ全体の速度が2倍に、そして、メモリ使用量が半減」というのは本当にすばらしいです。時間の関係で今回説明された部分以外にも、随所に工夫がされているのではないかと思います。

なお、仮想DOMの実装は、フルスクラッチから作り直しということなのですが、呼び出し方はほぼVue 2.x系のものと同様で、互換性は担保されているとのことですので、安心して開発に利用出来るようです。 Vue Fesでの講演スライドは、こちらで公開されています。

スライドの日本語翻訳は弊社代表の田中が行ないました。日本語スライドで何か気づいた点などありましたらコメントして下さい。

プロキシを用いた監視で、完全な言語レベルでの監視と、速度向上

f:id:knight_9999:20181108183800p:plain:w600

Vueをはじめ、データバインディングを行うフレームワークでは、データのプロパティの変化を監視しておく必要があります。これまでのVueでは、Object.defineProperty メソッドを利用して、getter、setterをオーバーライドすることで実現していました。 しかし、Vue 3.0では、このObject.definePropertyメソッドは利用せず、ネイティブプロキシを使うことで高速化されたということです。スライドでも

"プロキシを用いた監視で完全な言語レベル & 速度向上"

"Proxy-based observation mechanism with full language coverage + better perf"

と説明されていました。

 実際のコードがどうなっているのかは、Vue 3.0のコードが公開されるまで分かりませんが、おそらく、ES2015から導入されたProxyクラスを使って次のようになっているのではないかと思います。

  new Proxy(target, {
    get(target, name, receiver) {
      var value = Reflect.get(target, name, receiver);
      // 依存性の設定
      return value
    },
    set(target, name, value, receiver) {
      // 変更の通知
      Reflect.set(target, name, value, receiver);
    }
  });

一方、現状のVue 2.5では、内部でdefineReactiveという関数の中で、次のようにdefinePropertyメソッドを使って、オブジェクトのgetterやsetterをオーバーライドして、大まかに次のような処理を行なっています。

  Object.defineProperty(obj, key, {
    ...
    get: function reactiveGetter () {
      var value = getter ? getter.call(obj) : val;
      // 依存性の設定
      return value
    },
    set: function reactiveSetter (newVal) {
      var value = getter ? getter.call(obj) : val;
      if (setter) {
        setter.call(obj, newVal);
      }
      // 変更の通知
    }
  });

definePropertyが、プロパティごとにgetter、setterを再定義しなくてはいけなかったのに対して、Proxyではすべてのメソッドに対して一律に監視することが出来ることと、やはりネイティブで実行されるところで速度が向上しているのではないかと思います。

そこで、Proxyを使った方が早いかどうかを確認するために、次のようなサンプルコードを考えて見ました。 まず、definePropertyを使う場合のコードです。

function defineReactive(obj, key) {
  var val = obj[key];
  Object.defineProperty(obj, key, {
    get: function () {
      var value = val;
      return value;
    },
    set: function reactiveSetter (newValue) {
      val = newValue;
      obj.cb[key](newValue);
    }
  });
};

var myObject = { 
  name: "Asial", 
  age: 20, 
  score: 10,
  cb: {
    age: (v) => { console.log('calling set age :' + v) },
    score: (v) => { console.log('calling set score :' + v)},
  }
};

defineReactive(myObject, 'age');
defineReactive(myObject, 'score');

var startAt = new Date().getTime();
for (let i=0; i<100000; i++) {
  myObject.age = i;
  myObject.score = myObject.score + 5;
}
var endAt = new Date().getTime();
console.log('time = ' + (endAt - startAt));

myObjectのagescoreという属性に対し、getterとsetterを定義することで、監視を行なっています。setterが呼ばれた時は、対応するコールバックcbが呼ばれるようにして見ました。 私のローカル環境でこれを実行すると、2407 msという結果になりました。

次に、Proxyを使ったコードに書き換えて見ます。

function defineReactiveAll(obj) {
  return new Proxy(obj, {
    get(target, name, receiver) {
      var value = Reflect.get(target , name, receiver);
      return value;
    },
    set(target, name, value, receiver) {
      Reflect.set(target, name, value, receiver);
      obj.cb[name](value);
    }
  });
};

var myObject = { 
  name: "Asial", 
  age: 20, 
  score: 10,
  cb: {
    age: (v) => { console.log('calling set age :' + v) },
    score: (v) => { console.log('calling set score :' + v)},
  }
};

myObject = defineReactiveAll(myObject, myObject.cb);

var startAt = new Date().getTime();
for (let i=0; i<100000; i++) {
  myObject.age = i;
  myObject.score = myObject.score + 5;
}
var endAt = new Date().getTime();
console.log('time = ' + (endAt - startAt));

Proxyを使った場合の結果は、2573 msとなりました。

あれ、ほとんど同じではあるものの、definePropertyを使った場合よりもわずかですが遅い感じですね?

このサンプルでは、ややdefinePropertyの方が早いという結果になってしまいましたが、実際上は、definePropertyではプロパティごとに指定しなくてはならないため、大量のオブジェクトに対しては定義時にオーバーヘッドがかかってしまうことや、あるいは、既存のgetterやsetterがすでに実装されていた場合に上記サンプルコードより複雑になってしまうことなど、いろいろな要因が重なって、全体としてネイティブProxyを使うよりも重くなってしまうのではないかと思います。Vue 3.0のコードが公開されたら、どうなっているのか確認してみたいですね!

実行中のオーバーヘッドを削減するため、コンパイル時にヒントを追加

Vue 3.0では、コンパイル時に解析した情報をあらかじめ出力コードに組み込んでおくことにより、実行時の処理を向上させているとのことです。いくつかのケースについて、より詳しく見ていきましょう。

コンポーネント探索の高速化、単型の呼び出し、子要素の種類を検出

f:id:knight_9999:20181108183830p:plain:w600

実行時に不要な条件分岐を省略することで、JavaScriptエンジンが最適化しやすくすると説明されています。 エヴァンさんのスライドには、次のようなコードが解説されていました。

テンプレート

<Comp></Comp>
<div>
  <span></span>
</div>

コンパイラ出力 (Vue 3.0)

render() {
  const Comp = resolveComponent('Comp', this)
  return createFragment([
    createComponentVNode(Comp, null, null, 0 /* 子要素なし */), 
    createElementVNode('div', null, [
      createElementVNode('span', null, null, 0 /* 子要素なし */) 
    ], 2 /* 子vnodeは1つ */)
  ], 8 /* 複数のキーを持たない子 */) }

ここでちょっと主題から外れてしまいますが、このコードをよく見ると、テンプレートがコンポーネントではなくて、フラグメント(ルートNodeが1つになっていない)になっていますね。Vue 3.0では、フラグメントも利用出来るようです! Reactも、比較的最近なされたアップデートv16.2でフラグメントを使えるようになっているので、Vue 3.0でも同機能を実装されたようですね。もともとVueは1.x系ではフラグメントを持っていたので、再実装されたということかも知れません。

さて、現状のVue 2.5では、フラグメントは使えませんが、同じようなレンダリング処理をコンパイルすると、およそ次のような感じになります。 (比較のために、ES2015的な記述にしました。また、createTextVnodeなどは省略し、実際はStaticRendringとされている部分も全部一つの関数として表現しています)

コンパイラ出力 (Vue 2.5)

render() {
  return createElement('div', null, null, [
    createElement('Comp', null, null, null),
    createElement('div', null, null, null, [  
      createElement('span', null, null, null)
    ])
  ], 1)
}

Vue 3.0のコードでは、出力するDOMの種類によって createFragmentcreateComponentVNodecreateElementVnode が使い分けられているのに比べて、Vue 2.5のコードでは、すべて同じ createElement が利用されています。Vue 3.0のように、機能に応じて専用のメソッドが呼ばれることが、エヴァンさんのスライドでMonomorphic calls (単型の呼び出し)と呼ばれる手法ですね。このことは、Vue 3.0ではコンパイル時点でDOMの構造を静的に分析した結果が反映されていることを意味していて、これにより、JavaScriptエンジンが最適化しやすい状況を実現しているということなのだと思います。

また、第4パラメータに設定されている数値の意味はよくわかりませんが、Vue 2.5では内部配列の展開処理を区別するための2つしか値がなかったのに比べて、Vue 3.0ではおおくのフラグが用意されているように思えます。これも、実行時の処理を効率的に行うため、コンパイル時に判明したヒントを埋め込んでいるということだと思います。

スロット生成の最適化

 
f:id:knight_9999:20181108183825p:plain:w600

インスタンスが依存関係を正しくトラッキング出来るようになり、不要な親・子のレンダリングを回避出来るようになったとのことです。 これはどういうことでしょうか? サンプルコードは

テンプレート

<Comp>
  <div>{{ hello }}</div>
</Comp>

コンパイラ出力(Vue 3.0)

render() {
  return h(Comp, null, {
    default: () => [h('div', this.hello)] }, 16 /* コンパイラが生成したスロット */)
}

です。

これを見ると、render()関数が返しているオブジェクトの中に、default関数が定義されていて、この関数が呼び出された時に初めて、子要素である [h('div', this.hello)] を返すようになっています。 つまり、親要素のrender処理と、その中の一部であるスロットの処理が分離されていて、これにより、スロットが変更された時に子要素のみが再描画されるようになり、また、親要素が再描画された時でもスロットの内容が変更されていない時は子要素は再描画されなったということのようです。

これもVue 2.5で同じテンプレートをコンパイルしたときを見てみましょう。(ここでは、上記のコードに合わせるため、createElementではなくhとしています。また、vはcreateTextVnodeです。Vue 3.0に合わせてthisと記述していますが、実際はモデルオブジェクトです)

コンパイラ出力(Vue 2.5)

render() {
    return h('Comp', null, [
            h('div', [
                v(toString(this.hello))
            ])
        ])
}

Vue 2.5では、Comp全体を再描画する構造になっていて、スロットの部分だけを切り離すことが出来ません。そのため、スロットが変更された時は、要素全体を再レンダリングし、また、親要素が再描画になったときは子要素も再描画されることになります。

静的ツリーの巻き上げ

ツリー全体へのパッチ適用をスキップされているということです。スライドに記述されていたサンプルコードは次のようになります。

テンプレート

<div>
  <span class="foo">
    Static
  </span>
  <span>
{{ dynamic }}
  </span>
</div>

コンパイラ出力(Vue 3.0)

const __static1 = h('span', {
  class: 'foo'
}, 'static')

render() {
  return h('div', [
    __static1,
    h('span', this.dynamic)
  ])
}

コンパイラ出力を見た感じ、確かに、もとのテンプレートでスタティックになっている部分

  <span class="foo">
    Static
  </span>

を切り離して、その部分だけ巻き上げられて(関数の外側で)定数として定義されています。

Vue 2.5で同様のテンプレートをコンパイルしてみると、おおよそ次のような感じになります。(ここでは、上記のコードに合わせるため、createElementではなくhとしています。また、vはcreateTextVnodeです。Vue 3.0に合わせてthisと記述していますが、実際はモデルオブジェクトです)

コンパイラ出力(Vue 2.5)

render() {
  return h('div', [
    h('span', { staticClass: "foo" }, [
      v("Static")
    ]),
    h('span', [
      h( toString(this.dynamic) )
    ])
  ])
}

こちらでは、全体がrenderメソッドの中に記述されていますね。このため、Vue 2.5では実行時に静的部分も毎回評価されることになります。

インラインハンドラのホイスティング

もう一つ、巻き上げの例として、インラインハンドラの巻き上げについて見て見ましょう。

サンプルコードは

テンプレート

<Comp @event="count++"/>

コンパイラ出力(Vue 3.0)

import { getBoundMethod } from 'vue'
function __fn1 () {
  this.count++
}
render() {
 return h(Comp, {
   onEvent: getBoundMethod(__fn1, this)
 })
}

@eventというのが、抽象的なイベントを表しているのか(つまり、具体的には@click.nativeなどが入る)、それとも、Compコンポーネント内から発火するeventイベントのことなのかよく分からなかったのですが、ここでは、Compコンポーネント内からeventイベントが発火していると想定します。つまり、Compコンポーネントの作りが次のようになっていると仮定します。

<template>
  <div>
  <button v-on:click="fireEvent()">Click Here</button>
  </div>
</template>

<script>
export default {
  name: 'Comp',
  methods: {
    fireEvent () {
      this.$emit('event', null)
    }
  }
}
</script>

さて、Vue 3.0のコンパイラ出力では、getBoundMethod という処理の内容が良く分かりませんが、おおよそ this を 関数__ fn1 にバインドしていると予想できます。 いずれにしても、 __fn1 という関数部分は巻き上げられて(renderメソッドの外側で定義されて)いて、再レンダリングのときも、最初に1回定義されたものを再利用することが出来ます。こうして、実行時の処理を効率化しているようです。

同様のコードをVue 2.5でも確認してみましょう。

コンパイラ出力(Vue 2.5)

render() {
  return h('Comp', {
        on:{ "event": function ($event) { this.count++ } }
    })
}

このようになっていて、こちらでは、レンダリングの度に新しいイベント処理関数が定義されてしまいます。

まとめ

いかがでしたでしょうか? 基調講演で解説していただいたコードを元に、現状のVue 2.5と比較して、どのような変更がなされているかについて確認してみました。 まだVue 3.0のコードは一般公開されていないために、あとは想像するかないのですが、基調講演で垣間見ただけでもさまざまな工夫がされていて、とても期待出来る内容に仕上がっていると思います。

さらに、ネイティブでのレンダリングを行えるリアクティビティAPIや、タイプスクリプトサポートの改善、フックスAPI、タイムスライシングなど、すばらしい機能が盛りだくさんのVue 3.0とのことですので、リリースされるのが楽しみです。

f:id:knight_9999:20181108183834p:plain:w600