Vue3 setup 記法を理解しつつ、 Nuxt で使うコンポーネントを色々作ってみる
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 を装飾すればいい感じ
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>
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 に文字列しか渡ってこないケースだと、一旦これでよい。
そうでない場合はコケるよね。どうしよう
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)}`, '')
})
"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>
ページのメタデータからタイトルを取得して描画するコンポーネントを作る
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>
これでページ毎にヘッダーを実装する必要がなくなるから楽でいいね
無限スクロールを作る
問題
例えば、
<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>