Vue.js 2.0でtwoWayなカスタムディレクティブを実装する方法

以下はvuejs-jp slackに投稿したネタですが、ここのブログにも書いておきます。

先日正式リリースされたVue.js 2.0では、React.jsと同様のVirtual DOMやOne way data flowが採用され高速描画が可能となった反面、ディレクティブは大幅に機能縮小され、"twoWay: true"による双方向データ同期機能は無くなってしまいました。
migration方法としては「コンポーネントで書き直してください」が公式回答なわけですが、正直言うとその辺の辛さがあってReactではなくVueを使っていた人間としては「うーむ」という感想なのです。ダッタラ初めからReactで良くね?…

というわけでいろいろ試行錯誤したところ、実はちょっとしたテクニックで2.0でも依然としてtwoWayなカスタムディレクティブを実装可能なことがわかりました。
ポイントはv⇒vm方向のデータ同期に使っていた

    this.set(val);

    Vue.set(vnode.context, binding.expression, val);

に置き換える、になります。
これだけでは分かる人にしか分からないので、以前に作成した3-stateチェックボックスの例で具体的に説明します。
1.0/2.0それぞれのLiveデモは以下

まず前提知識として、2.0ではディレクティブがインスタンスを持たなくなりました。これはすなわち1.0での"this.el"や"this.set"等が軒並み使えなくなったことを意味します。
これと"twoWay"プロパティの廃止をもって「双方向なカスタムディレクティブは作れなくなった」というわけです。
一方、one wayすなわちvm⇒v方向のデータ同期は従来と同じupdateフック関数で対応できますが、引数が1.0とは大きく異なります。
2.0のフック関数には、bind, unbind, update他の全関数共通で引数として"el", "binding", "vnode"が渡されます。それぞれ、

el: 1.0における"this.el"に対応
binding: ディレクティブの各種引数
  binding.value: ディレクティブにbindされたvmオブジェクトの現在値
vnode: ディレクティブに対応するVirtual DOMのnode

という意味なので、update関数は

-    update: function(val) {
-        threeStateByVal(this.el, val);
+    update: function(el, binding) {
+        threeStateByVal(el, binding.value);
     },

という修正で事足ります。

後はv⇒vm方向のデータ同期で使っていた"this.set"の代替ですが、2.0の各種ドキュメントをいろいろ漁っていたところ

vm.$set deprecated, use Vue.set

という記述を発見しました。これはもしかして、と思ってVue.setの仕様を確認すると、

Vue.set( object, key, value )

Arguments:
  {Object} object
  {string} key
  {any} value

Usage:
  Set a property on an object.

となっています。valueは設定したい値そのものなので、あとはobjectとkeyに相当する値、それぞれvmオブジェクトと変数名をどっかから調達できればなんとかなりそうです。

ここで改めて2.0のフック関数に渡される引数を精査したところ、

  binding.expression: ディレクティブにbindされたvmオブジェクトの変数名
  vnode.context: ディレクティブにbindされたvmオブジェクト

であることがわかりました。ということは、

object = vnode.context
key = binding.expression
value = (v⇒vm方向に同期したい値)

をVue.setに渡せばよい、ということで一番最初に書いたポイントのとおりとなります。

ここまでの内容を整理すると、以下のdiffとなります。

--- 1.0	Sat Oct 22 17:30:36 2016
+++ 2.0	Sat Oct 22 17:31:52 2016
@@ -1,17 +1,17 @@
 Vue.directive('three-state', {
-    twoWay: true,
-    bind: function() {
+    //twoWay: true,
+    bind: function(el, binding, vnode) {
         this.handler = function() {
-            var val = threeStateByClick(this.el);
-            this.set(val);
+            var val = threeStateByClick(el);
+            Vue.set(vnode.context, binding.expression, val);
         }.bind(this);
 
-        this.el.addEventListener('click', this.handler);
+        el.addEventListener('click', this.handler);
     },
-    unbind: function() {
-        this.el.removeEventListener('click', this.handler);
+    unbind: function(el) {
+        el.removeEventListener('click', this.handler);
     },
-    update: function(val) {
-        threeStateByVal(this.el, val);
+    update: function(el, binding) {
+        threeStateByVal(el, binding.value);
     },
 });