Open9

Vue3 setup 記法を理解しつつ、 Nuxt で使うコンポーネントを色々作ってみる

mewtonmewton

input を wrap して v-model を使うコンポーネントを作る

コンポーネントの利用側からは v-model を使う想定の場合、

  • props で modelValue を受け取る
  • emit で update:modelValue で新しい値を返す

ができればよさそう

<script lang="ts" setup> 
import { computed } from 'vue'

interface Props {
  modelValue: string
}

interface Emits {
  (e: 'update:modelValue', newValue: string): void
}

const props = defineProps<Props>()
const emit = defineEmits<Emits>()

const value = computed({
  get(): string {
    return props.modelValue
  },
  set(value: string) {
    emit('update:modelValue', value)
  }
})

</script>

<template>
  <input v-model="value" type="text" />
</template>

あとは input を装飾すればいい感じ

mewtonmewton

v-model が オブジェクトの場合のコンポーネントを作る

例としてログインフォームを作ってみる

↓のオブジェクトを v-model として渡せるコンポーネントを作る

interface LoginPayload {
  loginId: string
  password: string
}

やることは v-model が primitive の場合と同じなんだけども、 emit するときに少し工夫すると記述が楽にできた

<script lang="ts" setup>
import type { LoginPayload } from 'types'
import { computed } from 'vue'

interface Props {
  modelValue: LoginPayload
}

interface Emits {
  (e: 'update:modelValue', newValue: LoginPayload): void
}

const props = defineProps<Props>()

const emit = defineEmits<Emits>()

const loginId = computed({
  get(): string {
    return props.modelValue.loginId
  },
  set(loginId: string) {
    update({ loginId }) // LoginPayload の一部をここで作る
  }
})

const password = computed({
  get(): string {
    return props.modelValue.password
  },
  set(password: string) {
    update({ password }) // LoginPayload の一部をここで作る
  }
})

function update(newValue: Partial<LoginPayload>) {
  emit('update:modelValue', { ...props.modelValue, ...newValue }) // 前の値と新しい値をマージして返す
}
</script>

<template>
  <div class="panel">
    <label for="login-id"> Login Id </label>
    <input id="login-id" v-model="loginId" />
    <label for="password"> Password </label>
    <input id="password" v-model="password" />
</template>
mewtonmewton

Slot で渡される vnode から文字列を作る

ユーザのアバター画像と名前を並べるような構成のコンポーネントで、
名前部分を slot で受け付ける設計にしてしまった。

使う側はこんなイメージ

<template>
  <UserAvatar :img="imgUrl">{{ name }}</UserAvatar>
</template>

img タグには alt 属性をつけなければならず、slot に来るであろう名前の文字列部分をそこに使いたいなと。

とりあえず slot は useSlot を使えば良さそうなことがわかった

<script lang="ts" setup>
import { computed } from 'vue'

interface Props {
  imgUrl: string
}

const props = defineProps<Props>()

const slots = useSlots()

const touching = ref<boolean>(false)

const alt = computed(() => {
  const nodes = slots['default']!()
  // TODO このままではダメ
  return nodes.filter(({ type }) => type.toString() === 'Symbol(Text)')
                        .reduce((p, n) => `${p} ${n.children as string}`, '') 
})
</script>

<template>
  <div>
    <img :src="props.imgUrl" :alt="alt" />
    <slot />
  </div>
</template>

slot に文字列しか渡ってこないケースだと、一旦これでよい。
そうでない場合はコケるよね。どうしよう

mewtonmewton

Slot で渡される vnode から文字列を作る (修正)

slotに string 以外がくるケースの対処をしてみた

type VNodeChildAtom = VNode | string | number | boolean | null | undefined | void

function extractChildString(target: VNodeNormalizedChildren | VNodeChildAtom): string {
  if (isString(target) || isNumber(target) || isBoolean(target)) {
    return target.toString()
  } else if (isArray(target)) {
    return target.map(extractChildString).join(' ')
  } else {
    return ''
  }
}

const alt = computed(() => {
  const nodes = slots['default']!()
  return nodes.filter(({ type }) => type.toString() === 'Symbol(Text)')
                        .reduce((p, n) => `${p} ${extractChildString(n.children)}`, '') 
})
mewtonmewton

"a" か "nuxt-link" かを選べるコンポーネントを作る

元の実装では "a" を使っていた。
リスト構造の <li> にあたる部分の実装となっており、template は以下の通り

<!-- NavigationItem.vue -->
<script lang="ts" setup>
import KeyboardArrowRight from 'vue-material-design-icons/ChevronRight.vue'

interface Props {
  href?: string
}

const props = withDefault(defineProps<Props>(), { href: '#' })
</script>

<template>
  <li>
    <a :href="props.href">
      <span class="item-label">
        <slot />
      </span>
      <span>
        <KeyboardArrowRight /> 
      </span>
    </a>
  </li>
</template>

このコンポーネントは以下のように利用する想定でいる

<!-- components/navigation.vue -->
<template>
    <List>
      <template
        v-for="l in links"
        :key="l.id">
        <!-- これを a か nuxt-link か選べるようにしたい -->
        <NavigationItem :href="l.url"> 
          {{ l.label }}
        </NavigationItem>
      </template>
    </List>
</template>

このとき、 Nuxt で使うことを考えると "a" タグではなく "nuxt-link" も使えると嬉しい。

解決方法

Vue3 では、 <component :is="componentName" /> を使うことで、動的なコンポーネントを描画できる。
また、 useAttrs を使うことで、defineProps の型に制限されないパラメータを全て取得できる

実装は以下のようになった

<!-- NavigationItem.vue (fixed) -->
<script lang="ts" setup>
import { computed, useAttrs } from 'vue'
import KeyboardArrowRight from 'vue-material-design-icons/ChevronRight.vue'

type HyperLinkComponentType = 'a' | 'NuxtLink' | 'nuxt-link'

interface Props {
  linkComponent?: HyperLinkComponentType
}

const props = withDefaults(defineProps<Props>(), { linkComponent: 'a' })

const attrs = useAttrs()

const internalProps = computed(() => {
  // link-component 以外のパラメータを抽出
  const { ["link-component"]: _linkComponent, ...internalProps } = attrs
  return internalProps
})
</script>

<template>
  <li>
    <!-- component :is で描画したいコンポーネントのタグ名を指定 -->
    <!-- v-bind で オブジェクトごと props として渡す -->
    <component
      :is="props.linkComponent"
      v-bind="internalProps">
      <span class="item-label">
        <slot />
      </span>
      <span>
        <KeyboardArrowRight />
      </span>
    </component>
  </li>
</template>

使う側はこうなった。便利!

<!-- A の場合 -->
<template>
  <NavigationItem :href="http://path/to/external/page" >
    外部リンクへ
  </NavigationItem>
</template>
<!-- NuxtLink の場合 -->
<template>
  <NavigationItem link-component="nuxt-link" :to="/path/to/next/page">
    次のページへ
  </NavigationItem>
</template>
mewtonmewton

ページのメタデータからタイトルを取得して描画するコンポーネントを作る

Nuxt の Layout 層に <Header /> コンポーネントを配置していて、これにタイトルを文字列で渡したい。

Page 層で vue-router のメタデータを設定することで Layout 層でその値を利用することが出来る。

Layout層ではそのメタデータの変更を検知できないので、 router のメタデータを watch することでなんとかできた。

結果的に、以下のような実装になった

<!-- page/somePage.vue -->
<script lang="ts" setup>
definePageMeta({
  layout: 'application',
  title: 'なにかのページ'
})
</script>
<!-- layout/application.vue -->
<script lang="ts" setup>
import { watch, ref } from 'vue'
import type { RouteLocationNormalizedLoaded } from 'vue-router';

const route = useRoute()

function getTitle({meta}: RouteLocationNormalizedLoaded) {
  return meta['title'] as string | undefined ?? ''
}

const title = ref<string>(getTitle(route))

watch(route, (r) => {
  title.value = getTitle(r)
})

</script>

<template>
  <header>
    <Header :title="title" />
  </header>
  <main>
    <slot />
  </main>
</template>

これでページ毎にヘッダーを実装する必要がなくなるから楽でいいね

mewtonmewton

無限スクロールを作る

問題

例えば、

<List>
  <ListItem v-for="i in items" > {{ i }} </ListItem>
</List>

のような構造にて、 List を縦スクロール可能にし、一番下にスクロールが到達したら items の追加データを取得するようにしたい。

また、このときDOMは以下のような構造に展開されるようにしたい。

<ul> <!-- List -->
  <li> item-0 </li> <!-- ListItem -->
  <li> item-1 </li> <!-- ListItem -->
  <!-- ... -->
</ul>

上記の要件を満たし、以下のような使い方ができるコンポーネントを実装したい。

<List>
  <Overflow @scroll-end="loadNext()">
    <ListItem v-for="i in items" > {{ i }} </ListItem>
  </Overflow>
</List>

実装

Overflow コンポーネントの実装では、

  • 親タグに対して overflow スタイルを当てる
  • スクロール位置が末尾に到達したら emit する
    ができればよいと考えられる。

親タグにスタイルを当てる方法

かなり悩んだが、結局 module style を使う方法が一番キレイだった。

style module タグを実装すると、 vue3 では useCssModule() を使って自動生成されたclass名を取得することができる。

<script lang="ts" setup>
import { useCssModule } from 'vue'

const style = useCssModule() // { 'overflow-wrap': '_overflow-wrap_yraz0_1' }
</script>

<style module>
.overflow-wrap {
    min-height: 0px;
    overflow-y: auto;
    overflow-x: hidden;
}
</style>

この場合だと、 domに _overflow-wrap_yraz0_1 クラスを設定していると、 style タグで設定した .overflow-wrap のスタイルが割当たるようになっている。
このスタイルは親子関係なくどのdomにも適用される。

親タグを参照する

DOMへのアクセスは基本的に useCurrentInstance() から行うようだ。
間違っても document.querySelector 等からやってはいけない。

function getParent(): ComponentInternalInstance {
  const instance = getCurrentInstance()
  if (isNil(instance) || isNil(instance.parent)) {
    throw new Error('Not Instantiate')
  }
  const { parent } = instance
  if (isNil(parent.vnode.el) || ['BODY', 'HTML'].includes(parent.vnode.el['tagName'])) {
    throw new Error('Cannot find parent node')
  }

  return parent
}

親のタグに動的にクラスを追加する

親タグインスタンスを取得できたんので、あとはその element に直接アクセスし、 classList に対して add するだけでよかった。

  const { vnode } = getParent()
  if (isNil(vnode.el)) {
    throw new Error('cannot apply overflow settings to parent el')
  }
  const el = vnode.el as HTMLElement
  const style = useCssModule()
  Object.values(style).forEach((k) => {
    el.classList.add(k)
  })

末尾にスクロールが到達したのを検知

親タグの element に scroll イベントを追加し、末尾にきたかどうかを計算する。
Vueとかの仕組みではなく、単純に Element のパラメータを使って愚直に計算する方法しかなかった。

el.addEventListener('scroll', function(this: HTMLElement, _e: Event) {
  if (isScrolledBottom(this)) {
    emit('scrollEnd')
  }
}, { passive: true })

function isScrolledBottom(el: HTMLElement): boolean {
  const { clientHeight, scrollHeight, scrollTop } = el
  return scrollTop + clientHeight >= scrollHeight
}

完成品

最終的にこうなった。

追加で以下について考慮して、安全に利用できるようにしてみた。

  • unmount 時にイベントハンドラをremove する
  • SSR の時に DOM アクセスがぬるぽならないように onMounted, onUnmounted の時だけ DOM アクセスする

ちょっとコアな実装になってしまったけど、最終的に要件を満たせたからヨシ!

<script lang="ts" setup>
import { ComponentInternalInstance, getCurrentInstance, onMounted, onUnmounted, useCssModule } from 'vue';
import isNil from 'lodash/isNil'

interface Emits {
  (e: 'scrollEnd'): void
}
const emit = defineEmits<Emits>()

onMounted(() => {
  applyOverflow()
})

onUnmounted(() => {
  removeOverflow()
})

function getParent(): ComponentInternalInstance {
  const instance = getCurrentInstance()
  if (isNil(instance) || isNil(instance.parent)) {
    throw new Error('Not Instantiate')
  }
  const { parent } = instance
  if (isNil(parent.vnode.el) || ['BODY', 'HTML'].includes(parent.vnode.el['tagName'])) {
    throw new Error('Cannot find parent node')
  }

  return parent
}

function applyOverflow() {
  const { vnode } = getParent()
  if (isNil(vnode.el)) {
    throw new Error('cannot apply overflow settings to parent el')
  }
  const el = vnode.el as HTMLElement
  const style = useCssModule()
  Object.values(style).forEach((k) => {
    el.classList.add(k)
  })
  el.addEventListener('scroll', onScroll, { passive: true })
}

function removeOverflow() {
  const { vnode } = getParent()
  if (isNil(vnode.el)) {
    throw new Error('cannot remove overflow settings to parent el')
  }
  const el = vnode.el as HTMLElement
  const style = useCssModule()
  Object.values(style).forEach((k) => {
    el.classList.remove(k)
  })
  el.removeEventListener('scroll', onScroll)
}

function onScroll(this: HTMLElement, _e: Event) {
  if (isScrolledBottom(this)) {
    emit('scrollEnd')
  }
}

function isScrolledBottom(el: HTMLElement): boolean {
  const { clientHeight, scrollHeight, scrollTop } = el
  return scrollTop + clientHeight + props.distance >= scrollHeight
}
</script>

<template>
  <slot />
</template>

<style lang="scss" module>
.overflow-wrap {
  @apply min-h-0 overflow-y-auto overflow-x-hidden;
}
</style>