Nuxt.js×markdown-it 内部リンクをVueRouterで高速にページ遷移しよう
  • 2022.02.28に公開
  • ブログ構築TS
  • 9. ブログMarkdown対応
  • No.3 / 4

今回達成すること

マークダウンから生成した内部リンクを、VueRouterのpush()メソッドを使って高速にページ遷移する実装を行います。

2022-02-28 17-34-40

aタグとnuxt-linkの違い

内部リンクは、<a>タグと<nuxt-link>を使用した場合の挙動が変わります。

  • <a>タグ

    • サーバーからHTMLファイルを取得する通常のページ遷移の挙動。この時ブラウザはロードされる。
  • <nuxt-link>

    • ブラウザの表示領域内にリンクが表示されたとき、ページを自動的に先読みし、高速でページ遷移を行う。

      参考: API: コンポーネント - Nuxt.js

内部リンクは<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

作成したclient-event.tsClientEventクラスを宣言しておきましょう。

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へ登録する

プラグインファイルを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イベントを追加するメソッドを作成する

client-event.tsに、addEventListenerremoveEventListenerを操作するメソッドを追加します。

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

addEventListener()

イベントを登録する JavaScriptのメソッドです。

  • click ... 第一引数にはイベントの種類を指定。

    イベントの種類

  • this.routerPush ... 第二引数にはイベント時に実行する関数を指定。

  • false ... 要素がイベントを受け取る順番を決定する。false(default)の場合は子イベントから発火、trueの場合は親イベントから発火する。

    参考: JavaScriptのイベント伝播って? - Qiita

removeEventListener()

登録されたイベントリスナーを取り外すJavaScriptのメソッドです。

イベントリスナーが見つからなかった場合は何もしません。

addEventListenerで追加したイベントの第三引数と一致していないと、イベントを取り外すことができないので注意してください。

element.removeEventListener("mousedown", handleMouseDown, false);     // 失敗
element.removeEventListener("mousedown", handleMouseDown, true);      // 成功

addEventListener() は、オプションが異なれば同じリスナーを同じ種類に複数回追加することができますが、 removeEventListener() がチェックするオプションは capture/useCapture フラグのみとなります。この値は removeEventListener() で一致するためには一致していなければなりませんが、他の値は一致していなくてもかまいません。

引用: EventTarget.removeEventListener() - Web API | MDN

これで内部リンクを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()内で行うと良いでしょう。

参考: 基礎から学ぶ Vue.js - Amazon

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に渡した値が変更されるたびに、子ページファイルである_slug.vueは再レンダリングされます。

nuxt-child-key$route.fullPathを渡すことで、ページ遷移のたびに子ページが再レンダリングが実行されます。

今回のページファイル設計
pages
├── posts
│   ├── _category
│   │   └── _slug.vue
│   └── _category.vue

再レンダリングされると、mounted()が再実行され、this.addClientEvents()が呼び出されます。

これにより、記事ページに遷移するたびにmounted()が実行されるようになり、下記問題を解決できます。

<nuxt-child>コンポーネントで呼び出されたページファイルは、mounted()が最初の1回しか実行されません。

nuxtタグはバインドキーでデータ送信できない

ちなみに、<nuxt>タグはバインドキーで子コンポーネントにデータを渡すことができないので、:category="category"は削除しています。

参考: API: コンポーネント - Nuxt.js

以上で内部リンクを高速にページ遷移する実装は完了です。

ブラウザで実装を確認しよう

Contentfulにログインし、複数の記事に内部リンクを追加しましょう。

2022-02-28 10-30-12

開発環境でページ遷移が高速になっているか確認してください。

確認ができたら本番環境でも高速になっているか、デプロイして確認を行いましょう。

Contentfulのマークダウンテスト記事公開状態にし、デプロイコマンドを実行してください。

% yarn netlify:deploy

マークダウンテスト記事は、ブログ完成前に下書きに戻してください。

今回の実装は以上です。

% git add -A
% git commit -m "Add plugin file for client router push event"

まとめと次回

マークダウンの内部リンクが高速にページ遷移しない問題に対応するため、VueRouterを使用したページ遷移を行う実装を行いました。

次回は、ブログ記事の目次に使用するアンカーリンク生成プラグイン、markdown-it-anchorを導入します。

あなたの力になれること
私自身が独学でプログラミングを勉強してきたので、一人で学び続ける苦しみは痛いほど分かります。そこで、当時の私がこんなのあったら良いのにな、と思っていたサービスを立ち上げました。周りに質問できる人がいない、答えの調べ方が分からない、ここを聞きたいだけなのにスクールは高額すぎる。そんな方に向けた単発・短期間メンターサービスを行っています。
独学プログラマのサービス
ブログ構築TSの投稿
1
  • Nuxt.js×TypeScript開発環境構築
  • /
  • #01
Nuxt.jsをローカルPCに立ち上げよう
2
  • Nuxt.js×TypeScript開発環境構築
  • /
  • #02
Nuxt.jsプロジェクトをGitHubにPushしよう
3
  • Nuxt.js×TypeScript開発環境構築
  • /
  • #03
nuxt-property-decoratorのインストールとTypeScriptのセットアップ
1
  • Vuetifyセットアップ
  • /
  • #01
TypeScript環境のNuxt.jsにVuetifyを導入しよう
2
  • Vuetifyセットアップ
  • /
  • #02
VuetifyにカスタムCSSを追加してSASS変数を理解しよう
3
  • Vuetifyセットアップ
  • /
  • #03
VuetifyにカスタムSVGアイコンを追加しよう
1
  • NetlifyCLIを使ったNuxtデプロイ
  • /
  • #01
Netlify CLIをインストールして本番環境のサイトを作成しよう
2
  • NetlifyCLIを使ったNuxtデプロイ
  • /
  • #02
netlify.tomlを使ってNuxt.jsをNetlifyに手動デプロイしよう
1
  • Contentfulモデル構築
  • /
  • #01
Contentfulの料金とCommunityプランの無料枠を理解する
2
  • Contentfulモデル構築
  • /
  • #02
Contentfulへ新規会員登録、ロケールの変更、API Keyの発行を行う
3
  • Contentfulモデル構築
  • /
  • #03
Contentful ブログカテゴリーモデルを作成しよう
4
  • Contentfulモデル構築
  • /
  • #04
Contentful カテゴリーモデルに1対1で関連づくblogPostモデルを作成しよう
5
  • Contentfulモデル構築
  • /
  • #05
Contentful ブログ記事に1対多で関連づくplogTagモデルを作成しよう
6
  • Contentfulモデル構築
  • /
  • #06
Contentful カテゴリー・ブログ記事・タグコンテンツを作成しよう
1
  • Nuxt.js×Contentfulセットアップ
  • /
  • #01
Nuxt.js×Contentfulセットアップ。モジュールのインストールからAPI Keyの登録まで
2
  • Nuxt.js×Contentfulセットアップ
  • /
  • #02
Contentful APIリクエストの実行 Nuxt.jsにブログコンテンツを表示しよう
3
  • Nuxt.js×Contentfulセットアップ
  • /
  • #03
ContentfulAPIをNetlifyにデプロイしよう【Nuxt FullStaticのasyncDataとfetch】
1
  • Vuex×TypeScriptセットアップ
  • /
  • #01
Vuexの型付け vuex-module-decoratorsとnuxt-typed-vuexどちらを使用するか
2
  • Vuex×TypeScriptセットアップ
  • /
  • #02
nuxt-typed-vuexのインストールとセットアップ。Vuexの型定義と呼び出し方
3
  • Vuex×TypeScriptセットアップ
  • /
  • #03
VuexにContentfulの型定義ファイルとnuxtServerInitを追加しよう
4
  • Vuex×TypeScriptセットアップ
  • /
  • #04
VuexにContentfulAPIレスポンスを保存してVueファイルに表示しよう
1
  • コンテンツページ構築
  • /
  • #01
ブログアプリのページ設計とNuxt.jsの動的ルーティングについて理解しよう
2
  • コンテンツページ構築
  • /
  • #02
カテゴリーのコンテンツページを作成しよう【Nuxt.js×Contentful】
3
  • コンテンツページ構築
  • /
  • #03
カテゴリーに関連付くブログ記事一覧を表示しよう【Nuxt.js×Contentful】
4
  • コンテンツページ構築
  • /
  • #04
injectを使用して共通エラー処理メソッドを作成しよう【Nuxt×TypeScript】
5
  • コンテンツページ構築
  • /
  • #05
NuxtChildを使用してブログ記事ページを作成しよう【Nuxt.js×TypeScript】
6
  • コンテンツページ構築
  • /
  • #06
タグ一覧ページとタグ関連づく記事一覧を表示しよう【Nuxt.js×TypeScript】
7
  • コンテンツページ構築
  • /
  • #07
プライバシーポリシーページを作成しよう【Nuxt.js×TypeScript】
8
  • コンテンツページ構築
  • /
  • #08
@nuxtjs/i18nのインストールとセットアップ。ページタイトルの翻訳化【TypeScript】
1
  • NetlifyFunctionsを使った検索機能
  • /
  • #01
Netlify Functionsを使ってクエリを返す関数を作成しよう【Nuxt.js×TypeScript】
2
  • NetlifyFunctionsを使った検索機能
  • /
  • #02
Netlify Functionsプロジェクトをデプロイしよう【Nuxt.js×TypeScript】
3
  • NetlifyFunctionsを使った検索機能
  • /
  • #03
Nuxt.js × axiosセットアップ Netlify Functionsにリクエストを行う準備をしよう
4
  • NetlifyFunctionsを使った検索機能
  • /
  • #04
オリジン•CORS•プリフライトリクエストを理解する【Nuxt.js×Netlify Functions】
5
  • NetlifyFunctionsを使った検索機能
  • /
  • #05
Netlify Functionsを使ってフォームバリデーション機能を構築しよう【Nuxt.js】
6
  • NetlifyFunctionsを使った検索機能
  • /
  • #06
ツールバーに表示する検索フォームを作成しよう【Nuxt.js×TypeScript】
7
  • NetlifyFunctionsを使った検索機能
  • /
  • #07
検索ページを作成しよう【Vue propsとTypeScriptの書き方 解説】
8
  • NetlifyFunctionsを使った検索機能
  • /
  • #08
Netlify FunctionsからContentfulAPIリクエストを送ろう【Nuxt.js】
9
  • NetlifyFunctionsを使った検索機能
  • /
  • #09
検索ページに「もっと見る」ボタンを実装しよう【Nuxt.js×TypeScript】
1
  • ブログMarkdown対応
  • /
  • #01
@nuxtjs/markdownitのインストールとセットアップ【Nuxt.js×TypeScript】
2
  • ブログMarkdown対応
  • /
  • #02
Nuxt.js×markdown-it 外部リンクを別タブで開くプラグインを追加しよう
3
  • ブログMarkdown対応
  • /
  • #03
Nuxt.js×markdown-it 内部リンクをVueRouterで高速にページ遷移しよう
4
  • ブログMarkdown対応
  • /
  • #04
Nuxt.js×markdown-it アンカーリンクとブログ目次を自動生成しよう
独学プログラマ
独学でも、ここまでできるってよ。
CONTACT
Nuxt.js制作のご依頼は下記メールアドレスまでお送りください。