Vue.jsのカスタムディレクティブの使い方とフック関数のタイミングを理解する

## はじめに

Vue.js（2.6.7）のカスタムディレクティブに関しての備忘録です。

## そもそもディレクティブとは？

テンプレートに記述できる`v-`から始まる属性のこと。

様々なディレクティブが存在し、属性値に応じた DOM 操作ができる。

以下は DOM 操作の例。

- 要素の表示制御
- データバインディング
- イベントリスナのアタッチ

### ディレクティブの利用例

以下は`v-show`（値に応じて要素の`style.display`プロパティを変更するディレクティブ）の利用例。

```html
<div id="app">
  <h1 v-show="ok">Hello!</h1>
</div>
```

```js
const vm = new Vue({
  el: '#app',
  data: {
    ok: true
  }
});
// `ok`を`false`にすれば、`<h1 v-show="ok">Hello!</h1>`は非表示になる。
// vm.$data.ok = false;
```

<p class="codepen" data-height="265" data-theme-id="0" data-default-tab="js,result" data-user="soarflat" data-slug-hash="WmNgpY" style="height: 265px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid black; margin: 1em 0; padding: 1em;" data-pen-title="Vue.js v-show">
  <span>See the Pen <a href="https://codepen.io/soarflat/pen/WmNgpY/">
  Vue.js v-show</a> by soarflat (<a href="https://codepen.io/soarflat">@soarflat</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://production-assets.codepen.io/assets/embed/ei.js"></script>

`data`の`ok`が`true`の場合、`<h1>Hello!</h1>`は表示され、`false`の場合、`<h1 style="display:none;">Hello!</h1>`となり表示されない。

この他にも様々なディレクティブが存在する。詳細は公式ドキュメントを参照。

[ディレクティブ](https://jp.vuejs.org/v2/api/#%E3%83%87%E3%82%A3%E3%83%AC%E3%82%AF%E3%83%86%E3%82%A3%E3%83%96)

## カスタムディレクティブとは？

自作したディレクティブ、もしくは自作したディレクティブを登録できる機能（仕組み）のこと。

カスタムディレクティブを利用することで、属性の付与もしくは属性値の変化に伴う DOM 操作を定義できる。

### なぜカスタムディレクティブを利用するのか（カスタムディレクティブの使い所）

前述の通り、`v-show`は値に応じて`style.display`プロパティを変更しているだけなので、この DOM 操作は自前でも定義できる。

しかし、ディレクティブという仕組みを利用することで、それぞれのコンポーネント(Vue インスタンス)に「値に応じて`style.display`プロパティを変更する DOM 操作」を定義せずとも、共通の DOM 操作が可能になる。

そのため、複数のコンポーネント(Vue インスタンス)で独自の DOM 操作を共通化したい場合、カスタムディレクティブを定義する。

### カスタムディレクティブの利用例

以下は、`v-focus`というカスタムディレクティブを定義する例。

ページを読み込む（Vue インスタンスをマウントする）と`input`要素に自動でフォーカスが当たるようにする。

```js
Vue.directive('focus', {
  inserted(el) {
    el.focus();
  }
});
```

`inserted`はカスタムディレクティブと紐付いた要素が親 Node に挿入された時に呼ばれるフック関数。引数`el`には親 Node に挿入された要素が渡される。

カスタムディレクティブと要素を紐付けるためには、以下のように定義した`v-focus`を付与する。

```html
<input v-focus>
```

今回の場合、この`input`要素が親 Node（今回の場合は`body`要素）に挿入された時に`inserted`が呼び出され、引数`el`に挿入された`input`要素が渡される。

実際に動作するコードは以下の通り。

```html
<input id="input" v-focus>
```

```js
Vue.directive('focus', {
  // `el`に`input`要素が渡されるので、それにフォーカスを当てる
  inserted(el) {
    el.focus();
  }
});

new Vue({
  el: '#input'
});
```

<p class="codepen" data-height="265" data-theme-id="light" data-default-tab="js,result" data-user="soar793" data-slug-hash="exqQpx" style="height: 265px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;" data-pen-title="Vue.js CustomDirective(inserted) Example">
  <span>See the Pen <a href="https://codepen.io/soar793/pen/exqQpx">
  Vue.js CustomDirective(inserted) Example</a> by hira (<a href="https://codepen.io/soar793">@soar793</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://static.codepen.io/assets/embed/ei.js"></script>

### ローカルディレクティブに登録する

`directives`オプションを利用すれば、コンポーネント毎にカスタムディレクティブを登録できる。

```html
<input id="input" v-focus>
```

```js
new Vue({
  el: '#input',
  directives: {
    focus: {
      inserted(el) {
        el.focus();
      }
    }
  }
});
```

## フック関数

前述の`inserted`を含め、カスタムディレクティブでは以下のフック関数を利用できる。

- `bind`: カスタムディレクティブが初めて対象の要素に紐付いた時に１度だけ呼ばれる。
- `inserted`: カスタムディレクティブと紐付いた要素が親 Node に挿入された時に呼ばれる。
- `update`: 紐付いた要素を抱合しているコンポーネントの VNode が更新される度に呼ばれる（子コンポーネントが更新される前に呼ばれる）。
- `componentUpdated`: 紐付いた要素を抱合しているコンポーネントの VNode と子コンポーネントの VNode が更新された後に呼ばれる。
- `unbind`: 紐付いた要素からディレクティブが取り除かれた時に呼ばれる。

### それぞれのフック関数が呼ばれるタイミングを理解する

以下は全てのフック関数が呼ばれるサンプル。

```html
<div id="app">
  <h1 v-message v-if="message">{{ message }}</h1>
  <button @click="update">update</button>
  <button @click="remove">remove</button>
  <button @click="init">init</button>
</div>
```

```js
Vue.directive('message', {
  bind(el) {
    console.log('bind');
    console.log('el.parentNode(bind)', el.parentNode);
  },
  inserted(el) {
    console.log('inserted');
    console.log('el.parentNode(inserted)', el.parentNode);
  },
  update(el) {
    console.log('update');
    console.log('el.innerHTML(update)', el.innerHTML);
  },
  componentUpdated(el) {
    console.log('componentUpdated');
    console.log('el.innerHTML(componentUpdated)', el.innerHTML);
  },
  unbind(el) {
    console.log('unbind');
  }
});

new Vue({
  el: '#app',
  data: {
    message: 'Hello Vue!'
  },
  methods: {
    update() {
      this.message = 'Hello React!';
    },
    remove() {
      this.message = '';
    },
    init() {
      this.message = 'Hello Vue!';
    }
  }
});
```

<p class="codepen" data-height="265" data-theme-id="0" data-default-tab="js,result" data-user="soarflat" data-slug-hash="YBmMPY" style="height: 265px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid black; margin: 1em 0; padding: 1em;" data-pen-title="Vue.js Hook Functions(Custom Directives)">
  <span>See the Pen <a href="https://codepen.io/soarflat/pen/YBmMPY/">
  Vue.js Hook Functions(Custom Directives)</a> by soarflat (<a href="https://codepen.io/soarflat">@soarflat</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://production-assets.codepen.io/assets/embed/ei.js"></script>

それぞれのフック関数がどのタイミングで呼ばれるのかは、以下の通り。

- ページロード時（Vue インスタンスを生成時）: `bind`、`inserted`
- 「update」ボタンをクリック時: `update`、`componentUpdated`
- 「remove」ボタンをクリック時: `unbind`
- 「init」ボタンをクリック時（「remove」ボタンをクリックした後）: `bind`、`inserted`

#### ページロード時（Vue インスタンスを生成時）

`bind`と`inserted`が呼ばれて以下がコンソール出力される。

```js
// bind
// el.parentNode(bind) null
// inserted
// el.parentNode(inserted) <div id=​"app">​…​</div>​
```

`inserted`は親 Node に挿入された時に呼ばれるため、`parentNode`（`<div id=​"app">​…​</div>​`）が存在する。

#### 「update」ボタンをクリック時

`this.message`が`"Hello React!"`になる。

`update`と`componentUpdated`が呼ばれて以下がコンソール出力される。

```js
// update
// el.innerHTML(update) Hello Vue!
// componentUpdated
// el.innerHTML(componentUpdated) Hello React!
```

`update`が呼ばれた時点では、子コンポーネントの VNode（`{{ message }}`）は更新されていないため、`el.innerHTML`の結果は`"Hello Vue!"`。

`componentUpdated`が呼ばれた時は、子コンポーネントの VNode も更新された後のため、`el.innerHTML`の結果は`"Hello React!"`。

#### 「remove」ボタンをクリック時

`this.message`が`""`になる。

今回紐付けをした要素は`v-if="message"`を付与しており、このタイミングで`<h1 v-message v-if="message">{{ message }}</h1>`が破棄され`unbind`が呼ばれる。

```js
// unbind
```

#### 「init」ボタンをクリック時（「remove」ボタンをクリックした後）

`this.message`が`"Hello Vue!"`になる。

`<h1 v-message v-if="message">{{ message }}</h1>`が再生成され、`bind`と`inserted`が呼ばれて以下がコンソール出力される。

```js
// bind
// el.parentNode(bind) null
// inserted
// el.parentNode(inserted) <div id=​"app">​…​</div>​
```

### フック関数に渡される引数

`el`以外にも複数の引数が渡される。詳細は公式ドキュメントを参照。

[ディレクティブフック引数](https://jp.vuejs.org/v2/guide/custom-directive.html#%E3%83%87%E3%82%A3%E3%83%AC%E3%82%AF%E3%83%86%E3%82%A3%E3%83%96%E3%83%95%E3%83%83%E3%82%AF%E5%BC%95%E6%95%B0)

## 終わり

間違いがあれば、ご指摘いただけると幸いでございます。