kahirokunn

概要

  • Vue3から組み込まれる
  • Vue2の現時点でも公式ライブラリを使えば使える
  • rfc段階
  • Vue.jsが抱える型問題の大部分を解決するapi

資料

useCounter

import { computed, reactive } from "@vue/composition-api";

export function useCounter(initial = 0) {
  const state = reactive({ count: 0 });
  return {
    count: computed(() => state.count),
    set: (count: number) => {
      state.count = count
    },
    inc: () => {
      state.count++;
    },
    dec: () => {
      state.count--;
    },
    reset: () => {
      state.count = initial
    }
  } as const;
}
import { useState } from 'react'

const add = value => prev => prev + value
const useCounter = (initial = 0) => {
  const [count, set] = useState(initial)
  return {
    count,
    set,
    inc: (number = 1) => set(add(number)),
    dec: (number = 1) => set(add(-number)),
    reset: () => set(initial),
  }
}

export default useCounter

Sample Repo

Vue composition apiを触った際に作成したシンプルなサンプルです

 

mountされたらイベントをアタッチして、マウント解除されるタイミングで解除してます。

x, yはsourceがマウスカーソルなので代入禁止の意味を込めてcomputedにしています

import {
  reactive,
  onMounted,
  onUnmounted,
  computed
} from "@vue/composition-api";

export function useMouse() {
  const pos = reactive({ x: 0, y: 0 });
  const update = (e: MouseEvent) => {
    pos.x = e.pageX;
    pos.y = e.pageY;
  };
  onMounted(() => {
    window.addEventListener("mousemove", update);
  });
  onUnmounted(() => {
    window.removeEventListener("mousemove", update);
  });
  return {
    x: computed(() => pos.x),
    y: computed(() => pos.y)
  } as const;
}

Component Code

useMouseを利用したコンポーネントコードはこんな感じです。

コードが凄く読みやすいと思います。

<template>
  <div>
    <h2>mouse pointer</h2>
    <div>x: {{ x }}</div>
    <div>y: {{ y }}</div>
    <button @click="setDummyPos">setDummyPos</button>
  </div>
</template>

<script lang="ts">
import { defineComponent } from "@vue/composition-api";
import { useMouse } from "@/hooks/useMouse";

export default defineComponent({
  setup() {
    const { x, y } = useMouse();

    return {
      x,
      y,
    };
  }
});
</script>

再利用性

debug時にこんなhooksがあると便利だと思います。
こんな感じにも使える

import {
  onBeforeMount,
  onMounted,
  onBeforeUnmount,
  onUnmounted,
  onActivated,
  onBeforeUpdate,
  onDeactivated,
  onErrorCaptured,
  onUpdated
} from "@vue/composition-api";

export function useLifeCycleLog() {
  onBeforeMount(() => console.log("onBeforeMount"));
  onMounted(() => console.log("onMounted"));
  onBeforeUnmount(() => console.log("onBeforeUnmount"));
  onUnmounted(() => console.log("onUnmounted"));
  onActivated(() => console.log("onActivated"));
  onBeforeUpdate(() => console.log("onBeforeUpdate"));
  onDeactivated(() => console.log("onDeactivated"));
  onErrorCaptured(() => console.log("onErrorCaptured"));
  onUpdated(() => console.log("onUpdated"));
}

vs mixin

Vuetifyのコードはmixinでコードの共通化と再利用を上手くやっています。
しかし、これは宣言的ではない為、使い方が直感的ではなく、読み解くのに体力が必要です。

composition apiの利点は独立したネームスペースと宣言的なプログラミングができるため、使う側のコードも読みやすく、ロジックがバラけない3点かと思います。

// Styles
import './VAlert.sass'

// Extensions
import VSheet from '../VSheet'

// Components
import VBtn from '../VBtn'
import VIcon from '../VIcon'

// Mixins
import Toggleable from '../../mixins/toggleable'
import Themeable from '../../mixins/themeable'
import Transitionable from '../../mixins/transitionable'

// Utilities
import mixins from '../../util/mixins'
import { breaking } from '../../util/console'

// Types
import { VNodeData } from 'vue'
import { VNode } from 'vue/types'

/* @vue/component */
export default mixins(
  VSheet,
  Toggleable,
  Transitionable
).extend({
  name: 'v-alert',

  props: {
    border: {
      type: String,
      validator (val: string) {
        return [
          'top',
          'right',
          'bottom',
          'left',
        ].includes(val)
      },
    },
    closeLabel: {
      type: String,
      default: '$vuetify.close',
    },
    coloredBorder: Boolean,
    dense: Boolean,
    dismissible: Boolean,
    icon: {
      default: '',
      type: [Boolean, String],
      validator (val: boolean | string) {
        return typeof val === 'string' || val === false
      },
    },
    outlined: Boolean,
    prominent: Boolean,
    text: Boolean,
    type: {
      type: String,
      validator (val: string) {
        return [
          'info',
          'error',
          'success',
          'warning',
        ].includes(val)
      },
    },
    value: {
      type: Boolean,
      default: true,
    },
  },

  computed: {
    __cachedBorder (): VNode | null {
      if (!this.border) return null

      let data: VNodeData = {
        staticClass: 'v-alert__border',
        class: {
          [`v-alert__border--${this.border}`]: true,
        },
      }

      if (this.coloredBorder) {
        data = this.setBackgroundColor(this.computedColor, data)
        data.class['v-alert__border--has-color'] = true
      }

      return this.$createElement('div', data)
    },
    __cachedDismissible (): VNode | null {
      if (!this.dismissible) return null

      const color = this.iconColor

      return this.$createElement(VBtn, {
        staticClass: 'v-alert__dismissible',
        props: {
          color,
          icon: true,
          small: true,
        },
        attrs: {
          'aria-label': this.$vuetify.lang.t(this.closeLabel),
        },
        on: {
          click: () => (this.isActive = false),
        },
      }, [
        this.$createElement(VIcon, {
          props: { color },
        }, '$cancel'),
      ])
    },
    __cachedIcon (): VNode | null {
      if (!this.computedIcon) return null

      return this.$createElement(VIcon, {
        staticClass: 'v-alert__icon',
        props: { color: this.iconColor },
      }, this.computedIcon)
    },
    classes (): object {
      const classes: Record<string, boolean> = {
        ...VSheet.options.computed.classes.call(this),
        'v-alert--border': Boolean(this.border),
        'v-alert--dense': this.dense,
        'v-alert--outlined': this.outlined,
        'v-alert--prominent': this.prominent,
        'v-alert--text': this.text,
      }

      if (this.border) {
        classes[`v-alert--border-${this.border}`] = true
      }

      return classes
    },
    computedColor (): string {
      return this.color || this.type
    },
    computedIcon (): string | boolean {
      if (this.icon === false) return false
      if (typeof this.icon === 'string' && this.icon) return this.icon
      if (!['error', 'info', 'success', 'warning'].includes(this.type)) return false

      return `$${this.type}`
    },
    hasColoredIcon (): boolean {
      return (
        this.hasText ||
        (Boolean(this.border) && this.coloredBorder)
      )
    },
    hasText (): boolean {
      return this.text || this.outlined
    },
    iconColor (): string | undefined {
      return this.hasColoredIcon ? this.computedColor : undefined
    },
    isDark (): boolean {
      if (
        this.type &&
        !this.coloredBorder &&
        !this.outlined
      ) return true

      return Themeable.options.computed.isDark.call(this)
    },
  },

  created () {
    /* istanbul ignore next */
    if (this.$attrs.hasOwnProperty('outline')) {
      breaking('outline', 'outlined', this)
    }
  },

  methods: {
    genWrapper (): VNode {
      const children = [
        this.$slots.prepend || this.__cachedIcon,
        this.genContent(),
        this.__cachedBorder,
        this.$slots.append,
        this.$scopedSlots.close
          ? this.$scopedSlots.close({ toggle: this.toggle })
          : this.__cachedDismissible,
      ]

      const data: VNodeData = {
        staticClass: 'v-alert__wrapper',
      }

      return this.$createElement('div', data, children)
    },
    genContent (): VNode {
      return this.$createElement('div', {
        staticClass: 'v-alert__content',
      }, this.$slots.default)
    },
    genAlert (): VNode {
      let data: VNodeData = {
        staticClass: 'v-alert',
        attrs: {
          role: 'alert',
        },
        class: this.classes,
        style: this.styles,
        directives: [{
          name: 'show',
          value: this.isActive,
        }],
      }

      if (!this.coloredBorder) {
        const setColor = this.hasText ? this.setTextColor : this.setBackgroundColor
        data = setColor(this.computedColor, data)
      }

      return this.$createElement('div', data, [this.genWrapper()])
    },
    /** @public */
    toggle () {
      this.isActive = !this.isActive
    },
  },

  render (h): VNode {
    const render = this.genAlert()

    if (!this.transition) return render

    return h('transition', {
      props: {
        name: this.transition,
        origin: this.origin,
        mode: this.mode,
      },
    }, [render])
  },
})

vs class

classを使うとDecoratorを使う

その際の問題を1つサンプルに

 

Vuexの標準的なapiであるmapStateでmapしたものをclassの中等で参照すると型エラーがでる
Decoratorはあくまでも暗黙的に拡張してくれるだけで、標準的な型システムでは拡張されていることを認識できない

@Component({
  computed: {
    mapState(['hello', 'world'])
  },
})
export default class HelloWorld extends Vue {
  mounted() {
    this.hello // ERROR!!!!!!
  }
}

導入タイミング

現在まだrfc段階だが、この段階まで進めばもうほぼ仕様は変わらないらしい

多少多少関数名や使用感等が変わってもdryそんなになければ追従コストもそこまでではないんじゃないかと

なので、Vue Tsで型に困っているプロダクトにはもう組み込んでもいいかなと思います

留意点

composition apiはあくまでもVue.jsのリアクティブシステムの上に乗かっているapi。reactのhooksと同じ使用感ではない。かなりシンプルなapi。しかし、react hooksでやれることは大概やれるので便利なhooksがあったら移植するとか、知見の流用がしやすくなる部分もある。

また、Vue.jsはプログレッシブなフレームワークなので、composition apiが出たからと言って必ず使わないといけないわけではない。あくまでも型を守ったりロジック再利用の開発したい際の有力な選択肢が1つ増えるだけである。

Vue.jsらしく

チームやプロジェクトの段階に合う開発をすれば良い

ありがとうございました

Composition API

By kahirokunn

Composition API

  • 2,147