【Riot.js】カスタムディレクティブ

[Riot.js Advent Calendar 2020 - Qiita](https://qiita.com/advent-calendar/2020/riotjs
) の7日目の記事です。
## 最初に
`riot.pure`をつかってカスタムディレクティブっぽいものを実装します。
今回の完成物は[こちら](https://plnkr.co/edit/BYs4uPgIW87Jddan)です
こんな感じの`ref`ディレクティブを例としてつくります。

```html
<app>
  <!-- クリックしたらまわる -->
  <div is="custom-directive" ref="message" onclick="{ onClick }">
    { state.message }
  </div>

  <script>
    export default {
      state: {
        message: 'Hello'
      },
      onClick() {
        this.$refs.message.animate([
          { transform: 'rotate(0)' },
          { transform: 'rotate(360deg)' }
        ], { duration: 100 })
      }
    }
  </script>
  <style>
    div {
      display: inline-block;
    }
  </style>
</app>
```
さっそくやっていきましょう
## 1.`$refs`に対象の要素をセットする
親スコープの`$refs`にref属性に基づいて自分の要素をセットしています。
子要素がないものならこれだけで十分動きそうですね。(例：`<input is="custom-directive" ref="text" />`)

```html
<custom-directive>
  <script>
    import { pure } from 'riot'
    export default pure(({ slots, attributes, props }) => {
      return {
        el: null,
        mount(el, scope) {
          this.el = el
          this.refDirective(scope)
        },

        update(scope) {
        },

        unmount(...args) {
        },

        refDirective(scope) {
          const ref = this.el.getAttribute('ref')
          if (!ref) return
          scope.$refs = scope.$refs || {}
          scope.$refs[ref] = this.el
        }
      }
    })
  </script>
</custom-directive>
```

## 2. slotsをdomに反映させる
`dom-bindings`の`template`を使ってslotを反映させています。

```diff
<custom-directive>
  <script>
-    import { pure } from 'riot'
+    import { pure, __ } from 'riot'
+    const { template, bindingTypes } = __.DOMBindings
    export default pure(({ slots, attributes, props }) => {
      return {
        el: null,
+        slot: null

        mount(el, scope) {
          this.el = el
+          this.createSlot(scope)
          this.refDirective(scope)
        },

        update(scope) {
+          this.slot.update({}, scope)
        },

        unmount(...args) {
+          this.slot.unmount(...args)
        },

+        createSlot(scope) {
+          if (!slots && !slots.length) return
+
+          this.slot = template('<slot/>', [{
+            name: 'default',
+            type: bindingTypes.SLOT,
+            selector: 'slot'
+          }])
+          this.slot.mount(this.el, { slots }, scope)
+        },
...
      }
    })
  </script>
</custom-directive>
```

## 3. 属性式に対応する
属性式とは`<p data-test={ 1 + 1 }　onclick={ onClick }></p>`の属性にでてくる`{}`に囲んであるところ
`dom-bindings`の`createExpression`を使って属性式を反映させます。
ついでにref属性が属性式になっているときの対応もします。

```diff

<custom-directive>
  <script>
    import { pure, __ } from 'riot'
-    const { template, bindingTypes } = __.DOMBindings
+    const { createExpression, template, bindingTypes } = __.DOMBindings
    export default pure(({ slots, attributes, props }) => {
      return {
        el: null,
        slot: null,
+        expressions: [],
+        prevRef: null,
        mount(el, scope) {
          this.el = el
+          this.expressions = (attributes || []).map(a => createExpression(el, a))
+          this.expressions.forEach(e => e.mount(scope))
          this.createSlot(scope)
          this.refDirective(scope)
        },

        update(scope) {
          this.slot.update({}, scope)
+          this.expressions.forEach(e => e.update(scope))
+          this.refDirective(scope)
        },

        unmount(...args) {
          this.slot.unmount(...args)
+          this.expressions.forEach(e => e.unmount(scope))
        },
...
        refDirective(scope) {
          const ref = this.el.getAttribute('ref')
-          if (!ref) return
+          if (!ref || this.prevRef === ref) return
+          this.prevRef = ref
          scope.$refs = scope.$refs || {}
          scope.$refs[ref] = this.el
        }
      }
    })
  </script>
</custom-directive>
```

これで完成です。
こんな感じに`slots`と`attributes`を反映させれば、大抵のケースは対応できるのではないかと思います。

<details><summary>【おまけ】`custom-directive`にmodelディレクティブ(双方向バインディング)を追加しましょう。</summary>
<div>

```diff
<custom-directive>
  <script>
    import { pure, __ } from 'riot'
    const { createExpression, template, bindingTypes } = __.DOMBindings
    export default pure(({ slots, attributes, props }) => {
      return {
        el: null,
        slot: null,
        expressions: [],
        prevRef: null,
+        prevModel: null,
+        onChange: null,

        mount(el, scope) {
          this.el = el
          this.expressions = (attributes || []).map(a => createExpression(el, a))
          this.expressions.forEach(e => e.mount(scope))
          this.createSlot(scope)
          this.refDirective(scope)
+          this.modelDirective(scope)
        },

        update(scope) {
          this.slot.update({}, scope)
          this.expressions.forEach(e => e.update(scope))
          this.refDirective(scope)
+          this.modelDirective(scope)
        },

        unmount(...args) {
          this.slot.unmount(...args)
          this.expressions.forEach(e => e.unmount(scope))
+          this.el.removeEventListener('input', this.onChange)
        },

        createSlot(scope) {
          if (!slots && !slots.length) return
          this.slot = template('<slot/>', [{
            name: 'default',
            type: bindingTypes.SLOT,
            selector: 'slot'
          }])
          this.slot.mount(this.el, { slots }, scope)
        },

        refDirective(scope) {
          const ref = this.el.getAttribute('ref')
          if (!ref || this.prevRef === ref) return
          this.prevRef = ref
          scope.$refs = scope.$refs || {}
          scope.$refs[ref] = this.el
        },

+        modelDirective(scope) {
+          const model = this.el.getAttribute('model')
+          if (!model || this.prevModel === model) return
+          if (this.onChange) this.el.removeEventListener('input', this.onChange)
+          this.prevModel = model
+          this.onChange = e => scope.update({
+            ...scope.state,
+            [model]: e.target.value
+          })
+          this.el.addEventListener('input', this.onChange)
+          this.el.value = scope.state[model] || ''
+        }
      }
    })
  </script>
</custom-directive>
```
これで先程の例のメッセージを`input`要素に双方向バインディングさせられます。

```html
<app>
  <div is="custom-directive" ref="message" onclick="{ onClick }">
    { state.message }
  </div>
  <input is="custom-directive" type="text" model="message">
  <script>
    export default {
      state: {
        message: 'Hello'
      },
      onClick() {
        this.$refs.message.animate([
          { transform: 'rotate(0)' },
          { transform: 'rotate(360deg)' }
        ], { duration: 100 })
      }
    }
  </script>
  <style>
    div {
      display: inline-block;
    }
  </style>
</app>
```
</div>
</details>

## 最後に
[完成物](https://plnkr.co/edit/BYs4uPgIW87Jddan)
`riot.pure`を使えばかなり自由に拡張できそうですね。`@riotjs/loader`, `@riotjs/router`などのライブラリにも使われています。

reduxをriotと連携させる[サンプル](https://gist.github.com/Elastic1/7a03265a280c9a5dc13309a6d2e83a77)を作ったのでよかったら見てください