今回達成すること
マークダウンから生成した内部リンクを、VueRouterのpush()
メソッドを使って高速にページ遷移する実装を行います。
aタグとnuxt-linkの違い
内部リンクは、<a>
タグと<nuxt-link>
を使用した場合の挙動が変わります。
-
<a>
タグ- サーバーからHTMLファイルを取得する通常のページ遷移の挙動。この時ブラウザはロードされる。
-
<nuxt-link>
-
ブラウザの表示領域内にリンクが表示されたとき、ページを自動的に先読みし、高速でページ遷移を行う。
-
内部リンクは<nuxt-link>
でページ遷移した方が高速に移動でき、ユーザーを待たせません。
マークダウン記法で高速なページ遷移を行うには?
markdown-it
では、<nuxt-link>
にマークアップすることができません。
そこで<a>
タグにclick
イベントを追加し、router.push()
メソッドを使用してページ遷移を行う実装を行います。
router.push()
も<nuxt-link>
と同じ挙動で、高速にページ遷移を行うことができます。
router.push()のサンプルコード
<template>
<a
href=""
@click="routerPush"
>
client route
</a>
</template>
<script lang="ts">
@Component
export default class IndexPage extends Vue {
routerPush (e: any): void {
e.preventDefault()
this.$router.push('/posts/main-category-01/markdown-test')
}
}
</script>
-
routerPush (e: any)
... 引数のe
にはクリックイベントのプロパティが返される。 -
e.preventDefault()
... ブラウザにデフォルトの動作を発生させないことを通知するメソッド。<a>
タグのクリックイベントに渡した場合、ページ遷移が行われなくなる。Document: Event.preventDefault() - Web API | MDN
-
this.$router.push()
... JavaScriptでページ遷移を行う。push()
の引数には遷移先のパスを渡す。
aタグにclickイベントを付与する方法
markdown-it
でHTMLにマークアップする際は、Vueを経由しないため@click
イベントを追加することができません。
今回の手法は、
markdown-it
でマークアップされた後のHTMLから<a>
タグを取得し- その中の内部リンクにのみ
- JavaScriptの
addEventListener
を使用し - クリックイベントを追加します。
プラグインファイルの作成とセットアップ
実装に入ります。
「plugins」ディレクトリに、クライアントイベントを集約するプラグインファイルを作成します。
% touch plugins/client-event.ts
作成したClientEvent
クラスを宣言しておきましょう。
plugins/client-event.ts
import { Plugin, Context } from '@nuxt/types'
export interface ClientEventInterface {
}
class ClientEvent implements ClientEventInterface {
}
const clientEvent: Plugin = (_context: Context, inject) => {
inject('client', new ClientEvent())
}
export default clientEvent
nuxt.config.jsへ登録する
プラグインファイルを
このプラグインにはmode: client
を追加し、クライアントで読み込むように設定します。
nuxt.config.js
plugins: [
...
// 追加
{ src: '~/plugins/client-event', mode: 'client' }
],
Vueインスタンスに型を登録する
ClientEvent
クラスはNuxtのinject
を使用しているので、$client
で呼び出すことができます。
型定義ファイルに登録しましょう。
injectについての解説記事はこちら
types/module.d.ts
...
// 追加
import { ClientEventInterface } from '~/plugins/client-event'
declare module 'vue/types/vue' {
// Vueインスタンス(this)の型追加
interface Vue {
...
// 追加
// plugins/client-event
$client: ClientEventInterface
}
}
...
これでクライアントプラグインファイルのセットアップは完了です。
clickイベントを追加するメソッドを作成する
addEventListener
とremoveEventListener
を操作するメソッドを追加します。
plugins/client-event.ts
import { Plugin, Context } from '@nuxt/types'
import VueRouter from 'vue-router/types'
export interface ClientEventInterface {
routerPush (event: MouseEvent, href: string): void
addRouterPushClickEvent (element: HTMLElement): void
removeRouterPushClickEvent (): void
}
class ClientEvent implements ClientEventInterface {
// HTMLAnchorElement: https://developer.mozilla.org/ja/docs/Web/API/HTMLAnchorElement
anchorElements: HTMLAnchorElement[] = []
// 内部リンクをVueRouterで高速にページ遷移する
routerPush (event: MouseEvent, href = ''): void {
if (event.view && href) {
// callback関数内でthisが参照不可のためeventからVueRouterを取得
const router: VueRouter = event.view.$nuxt.$router
/*
preventDefault(): ブラウザにデフォルトの挙動を実行しないよう通知(aタグのページ遷移をストップする)
Doc: https://qiita.com/tochiji/items/4e9e64cabc0a1cd7a1ae
*/
event.preventDefault()
// VueRouterでページ遷移
router.push(href)
}
}
// @click="router.push()"イベントの追加
addRouterPushClickEvent (element: HTMLElement): void {
// HTML内のaタグを全て取得
this.anchorElements = Array.from(element.getElementsByTagName('a'))
if (this.anchorElements.length) {
for (const anchor of this.anchorElements) {
// hrefを取得
const href: string | null = anchor.getAttribute('href')
// 内部リンクの場合
if (href && href[0] === '/') {
// clickイベント追加
anchor.addEventListener(
'click',
// 関数の中でrouterPush()を呼び出し第二引数を渡す
(event: MouseEvent) => {
return this.routerPush(event, href)
},
false
)
}
}
}
}
// addRouterPushClickEventの削除
removeRouterPushClickEvent (): void {
// aタグ配列が存在する場合
if (this.anchorElements.length) {
for (const anchor of this.anchorElements) {
/*
removeEventListener: イベントが登録されていなかったら何もしない
Doc: https://developer.mozilla.org/ja/docs/Web/API/EventTarget/removeEventListener
*/
anchor.removeEventListener('click', this.routerPush, false)
}
// aタグ配列を初期化
this.anchorElements = []
}
}
}
const clientEvent: Plugin = (_context: Context, inject) => {
inject('client', new ClientEvent())
}
export default clientEvent
-
anchorElements: HTMLAnchorElement[] = []
... この配列にブログ記事内の<a>
タグを格納する。 -
addRouterPushClickEvent(element: HTMLElement)
-
getElementsByTagName('a')
... 引数に指定したタグ名をもつ要素を返す。 -
getAttribute
... 引数に指定したプロパティの値を返す。
-
addEventListener()
イベントを登録する JavaScriptのメソッドです。
-
click
... 第一引数にはイベントの種類を指定。 -
this.routerPush
... 第二引数にはイベント時に実行する関数を指定。 -
false
... 要素がイベントを受け取る順番を決定する。false(default)
の場合は子イベントから発火、true
の場合は親イベントから発火する。
removeEventListener()
登録されたイベントリスナーを取り外すJavaScriptのメソッドです。
イベントリスナーが見つからなかった場合は何もしません。
addEventListener
で追加したイベントの第三引数と一致していないと、イベントを取り外すことができないので注意してください。
element.removeEventListener("mousedown", handleMouseDown, false); // 失敗
element.removeEventListener("mousedown", handleMouseDown, true); // 成功
addEventListener()
は、オプションが異なれば同じリスナーを同じ種類に複数回追加することができますが、removeEventListener()
がチェックするオプションはcapture
/useCapture
フラグのみとなります。この値はremoveEventListener()
で一致するためには一致していなければなりませんが、他の値は一致していなくてもかまいません。
これで内部リンクをVueRouterで高速にページ遷移するイベントが作成できました。
Vueファイルからclickイベントを呼び出す
作成したイベントをNuxtのmounted()
内で登録し、beforeDestroy()
内で削除します。
pages/posts/_category/_slug.vue
<template>
<div>
Post title: {{ post.fields.title }}
<div>
<!-- refプロパティの追加 -->
<div
ref="postBody"
>
<!-- eslint-disable vue/no-v-html -->
<div
v-html="$md.render(post.fields.body)"
/>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Context } from '@nuxt/types'
// Ref追加, Prop削除
import { Component, Vue, Ref } from 'nuxt-property-decorator'
// BlogCategory削除
import { BlogPost } from '~/store/types'
type AsyncData = void | {
post: BlogPost
}
@Component
export default class PostsCategorySlugPage extends Vue {
// 削除
// @Prop({
// type: Object,
// required: true
// })
// category!: BlogCategory
validate ({ params }: Context): boolean {
return !!params.slug
}
// 追加
@Ref('postBody')
readonly postBody!: HTMLElement
asyncData ({ app: { $accessor }, params, $my }: Context): AsyncData {
const post: BlogPost | undefined =
$accessor.posts.find((post: BlogPost) =>
post.fields.slug === params.slug
)
if (!post) {
return $my.errorHandler(404)
}
return {
post
}
}
// 以下、追加
mounted (): void {
// nextTick: DOMを更新した後にそのDOMに対して何らかの処理をする
this.$nextTick(() => this.addClientEvents())
}
beforeDestroy (): void {
this.removeClientEvents()
}
addClientEvents (): void {
this.$client.addRouterPushClickEvent(this.postBody)
}
removeClientEvents (): void {
this.$client.removeRouterPushClickEvent()
}
}
</script>
beforeDestroy()
... Vueインスタンスを削除する直前に呼ばれる。ページ遷移の直前に発火する。addClientEvents()
... ここにDOMを操作するクライアントイベントを集約する。removeClientEvents()
... クライアントイベントを削除するメソッドを集約するメソッド。
nuxt-property-decoratorの@Ref
nuxt-property-decorator
でVueのref
を呼び出すには@Ref
を使用します。
@Ref
の引数には、DOMに指定したref
プロパティの値を指定します。
下記コードでは、this.postBody
にDOM要素が格納されています。
<template>
<div
ref="postBody"
>
<!-- ref以下のDOMは「this.postBody」に格納される -->
</div>
</template>
<script lang="ts">
@Component
export default class PostsCategorySlugPage extends Vue {
@Ref('postBody')
readonly postBody!: HTMLElement
}
</script>
Vueのmounted()とは?
mounted()
は、インスタンスの状態を使用し、DOMが作成された直後に呼ばれるVue.jsのフックメソッドです。
つまり、
<script>
タグ内のデータと<template>
タグ内のHTMLの紐付けが行われ、- 描画の準備が整った後に発火します。
このフックは、
- 全ての子コンポーネントがマウントされていることは保証しません。
- また、サーバー側のレンダリング中には呼び出されません。
this.$nextTick()とは?
Vue.jsは、仮想DOMによって非同期にDOMが更新されるため、DOMの更新にわずかなタイムラグが発生します。
つまりデータを更新してすぐにDOMにアクセスしても、まだ更新が済んでいない場合がある、ということです。
そこでDOMを完全に更新したことを検知するnextTick()
というメソッドが用意されています。
完全なDOMの操作はthis.$nextTick()
内で行うと良いでしょう。
nuxt-childコンポーネントの問題に対応する
<nuxt-child>
コンポーネントで呼び出された子ページファイルは、mounted()
メソッドが最初の1回しか実行されません。
そこで、ページ遷移するたびにmounted()
が発火するように、
_category.vue から_slug.vue を呼び出す方法を<nuxt>
タグに変更します。
変更目的はnuxt-child-key
を使用するためです。
pages/posts/_category.vue
<template>
...
<!-- 追加 -->
<!--
nuxt-child-key: 違うキーを持つルートの場合ページコンポーネントを再レンダリングする(ページ遷移毎にmounted()を再実行するため)
Doc: https://develop365.gitlab.io/nuxtjs-2.8.X-doc/ja/api/pages-key/
-->
<nuxt
:nuxt-child-key="$route.fullPath"
/>
<!-- 削除 -->
<!-- <nuxt-child
:category="category"
/> -->
...
</template>
nuxt-child-keyとは?
nuxt-child-key
に渡した値が変更されるたびに、子ページファイルである
nuxt-child-key
に$route.fullPath
を渡すことで、ページ遷移のたびに子ページが再レンダリングが実行されます。
今回のページファイル設計
pages
├── posts
│ ├── _category
│ │ └── _slug.vue
│ └── _category.vue
再レンダリングされると、mounted()
が再実行され、this.addClientEvents()
が呼び出されます。
これにより、記事ページに遷移するたびにmounted()
が実行されるようになり、下記問題を解決できます。
<nuxt-child>
コンポーネントで呼び出されたページファイルは、mounted()
が最初の1回しか実行されません。
nuxtタグはバインドキーでデータ送信できない
ちなみに、<nuxt>
タグはバインドキーで子コンポーネントにデータを渡すことができないので、:category="category"
は削除しています。
以上で内部リンクを高速にページ遷移する実装は完了です。
ブラウザで実装を確認しよう
Contentfulにログインし、複数の記事に内部リンクを追加しましょう。
開発環境でページ遷移が高速になっているか確認してください。
確認ができたら本番環境でも高速になっているか、デプロイして確認を行いましょう。
Contentfulのマークダウンテスト記事を公開状態にし、デプロイコマンドを実行してください。
% yarn netlify:deploy
マークダウンテスト記事は、ブログ完成前に下書きに戻してください。
今回の実装は以上です。
% git add -A
% git commit -m "Add plugin file for client router push event"
まとめと次回
マークダウンの内部リンクが高速にページ遷移しない問題に対応するため、VueRouterを使用したページ遷移を行う実装を行いました。
次回は、ブログ記事の目次に使用するアンカーリンク生成プラグイン、markdown-it-anchor
を導入します。