kahirokunn

iCareでフロントの技術顧問させて頂いてる

composition apiにpull requestがマージされた🎉

最近あった嬉しいこと

Vue3.0

Vue3.0のtopics

  • 1. Typescript対応の強化
  • 2. jsx, tsxを標準サポート
  • 3. composition api

Vueを使ってるならVue3の把握をして、いち早く追従しないとですね

ざっくりな紹介になりますので、何か気になることがあれば後ほど質問して頂けたらと思います

Typescript

  • jsに型を付けた物
  • テストの代わりに導入してもよし
  • js docでバリデーションする代わりに書くもよし

Typescript

  • jsに型を付けた物
  • テストの代わりに導入してもよし
  • js docでバリデーションする代わりに書くもよし

メリット

タイポ、存在しない値へのアクセスなどつまらないバグを無くせる

tsの解析ツールの恩恵を最大限に活かせる

jsx, tsx

Reactで良く見る奴。

jsファイル内でhtmlライクな記述ができる。

export default {
  functional: true,
  render(h) {
    return (
      <b-tag
        class="carely-tag"
        ellipsis
        closable
        rounded
        aria-close-label="Remove file"
        onClose={() => console.log('hello world')}
      >こんにちは</b-tag>
    )
  }
}
import { FunctionalComponentOptions } from 'vue'

export default {
  name: 'SomeName',
  functional: true,
  props: {
    name: {
      type: String,
      required: true,
    },
  },
  render(h, { listeners, props: { name } }) {
    return (
      <b-tag
        class="classA classB"
        ellipsis
        closable
        rounded
        aria-close-label="Remove file"
        onClose={() => listeners['onClose']}
      >{name}</b-tag>
    )
  }
} as FunctionalComponentOptions
import { FunctionalComponentOptions } from 'vue'

export default {
  name: 'SomeName',
  functional: true,
  props: {
    name: {
      type: String,
      required: true,
    },
  },
  render(h, { listeners, props: { name } }) {
    return (
      <b-tag
        class="classA classB"
        ellipsis
        closable
        rounded
        aria-close-label="Remove file"
        onClose={listeners['onClose']}
      >{name}</b-tag>
    )
  }
} as FunctionalComponentOptions

v-on pattern1

pattern2

import { FunctionalComponentOptions } from 'vue'
import { emitGenerator } from 'functionalComponentHelper'

export default {
  name: 'SomeName',
  functional: true,
  props: {
    file: {
      type: File,
      required: true,
    },
    index: {
      type: Number,
      required: true,
    },
  },
  render(h, { listeners }) {
    const emit = emitGenerator(listeners)
    return (
      <b-tag
        class="carely-tag"
        ellipsis
        closable
        rounded
        aria-close-label="Remove file"
        onClose={(e: Event) => emit('close', e)}
      >{name}</b-tag>
    )
  }
} as FunctionalComponentOptions

概要

  • 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 { createComponent } from "@vue/composition-api";
import { useMouse } from "@/hooks/useMouse";

export default createComponent({
  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!!!!!!
  }
}

Vue3へのスムーズな移行の為の準備

composition apiが登場する。

typescript, jsx, tsxが標準でサポートされる。

どうしたらいい?

iCareでの備え

  • mixinの可読性に課題
    • composition apiを活用してmixin以上の再利用性を獲得
    • 且つ宣言的なプログラミングを導入
  • Typescript
    • Typescriptの型検査でテスト工数削減
    • その他恩恵を獲得
  • jsx, tsx
    • jsx, tsxにより、template内がuntypesafeな課題を解決
    • functional componentでmethodの定義もできない課題
      • jsxだとロジックが書けるので、jsxで課題解決

ご清聴ありがとうございました

Vue3.0

By kahirokunn