Vue3におけるvue-routerの簡易実装とルーター技術の進化に伴う考察

jQuery以前の時代、フロントエンドエンジニアはルーティングの概念を理解する必要がありませんでした。ページ遷移はすべてバックエンドがテンプレートを再描画することで処理していました。この開発スタイルではフロントエンドがバックエンドに依存し、開発スピードは速く、バックエンドが一部のフロントエンドタスクを担当することも可能でした。ただし、プロジェクトの分離が困難だったり、ページ遷移時にページ全体がリロードされるため待機時間が長かったりなど、ユーザー体験の低下という欠点がありました。

ページのインタラクション体験を向上させるために、多くのフロントエンドエンジニアがさまざまな試みを行いました。この過程で開発スタイルやプロジェクト構造が変化し、もともとの ルートアクセス → バックエンドでルートマッチ → フロントエンドテンプレートのマッチ → JS/CSSの読み込み という流れから、 ルートアクセス → フロントエンドエントリ → JS/CSSの読み込み → JSがルートに応じたコンポーネントをマッチしてレンダリング → ルートコンポーネントのレンダリング という流れへと変化しました。

このように、ユーザーがどんなURLにアクセスしても、常にフロントエンドのエントリーファイルindex.htmlをレンダリングします。その後、index.htmlファイル内でJSとCSSを読み込み、JSが現在のURLとマッチしたコンポーネントを取得して動的にページをレンダリングします。ユーザーがページ上でクリック操作を行う場合、ページのリロードではなくJSで再計算してマッチしたルートをレンダリングします。このようにフロントエンドがルーティングの制御権を得ることで、ページ遷移時のリロードが不要になり、ウェブブラウジング体験が向上しました。すべてのルートを1つのフロントエンドエントリーファイルでレンダリングするこの方式は、シングルページアプリケーション(SPA)の原型です。

JavaScriptでデータを動的に制御してユーザー体験を向上させる方法は新しいものではありません。HTML5時代には、ブラウザのルート変更をpushStateで操作できるようになりました。その後、この開発スタイルはMVVMフレームワークの時代に花を咲かせ、現在ではVue/React/Angularの大部分のアプリケーションがこのアーキテクチャを使用しています。SPAはテンプレート開発スタイルに比べてフロントエンドにとってより友好です。

フロントエンドルーティングの実装原理

URLでルートを区別するメカニズムには2つの実装方法があります。 一つはハッシュモードで、URLの#以降の部分を使って区別し、これをhash-routerと呼びます。 もう一つはhistoryモードで、この方式ではルートが通常のURLと完全に一致します。

この二つの異なる原理は、vue-routerではそれぞれcreateWebHashHistorycreateWebHistoryという関数に対応しています。

Vue3以前は、modeパラメータでhistoryhashを指定する方法が簡単でした。

hashモード

シングルページアプリケーションでは、ページのインタラクションやページ遷移がリロードなしで行われるため、ユーザーのウェブアクセス体験が大幅に向上します。このようなシングルページアプリケーションを実現するためには、フロントエンドルーティングが必要になります。サービスサイドルーティングと同様に、フロントエンドルーティングも簡単に実装できます。これは、異なるURLパスをマッチさせ、解析し、動的にHTMLコンテンツをレンダリングするものです。しかし、URL全体の変更はページのリロードを引き起こします。この問題を解決するアイデアは、URLを変更する際、ページのリロードを防ぐこと、つまり#以降の部分だけを変更することです。ページ遷移操作を行う際、ハッシュ値の変化はブラウザのページリロードを引き起こさず、hashchangeイベントをトリガーします。以下のコードでは、hashchangeイベントをリスナーとして登録し、fn関数内で動的にページ切り替えを実行できます。

window.addEventListener('hashchange', fn)

この方法は現在のマイクロフロントエンドのwebviewで広く使用されています。

historyモード

2014年以降、HTML5標準の発表により、ブラウザにpushStatereplaceStateという2つのAPIが追加されました。これらのAPIを使用することで、URLアドレスを変更でき、ブラウザがバックエンドにリクエストを送信しないだけでなく、popstateイベント(locationhistoryの長さを増やす)もトリガーされます。この2つのAPIとpopstateイベントを使えば、別の方法でフロントエンドルーティングを実現できます。以下のコードでは、popstateイベントをリスナーとして登録し、pushStateでルートを変更した際にイベントを検出します。そしてfn関数内でページの更新操作を実行します。

window.addEventListener('popstate', fn)

簡易なhash版vue-routerの実装

まず、Routerクラスをルーティングを管理するために実装し、createWebHashHistoryを使ってhashモードに関連するイベントリスナーと現在のURLを取得するメソッドを返します。次に、installメソッドでRouterのインスタンスを登録し、createRouterメソッドを外部に公開してRouterインスタンスを作成します。最後に、useRouterメソッドを公開して、ルーターインスタンスを取得します。

import { ref, inject } from 'vue'
import RouterLink from './RouterLink.vue'
import RouterView from './RouterView.vue'

const ROUTER_KEY = '__router__'

function createRouter(options) {
    return new Router(options)
}

function useRouter() {
    return inject(ROUTER_KEY) // 現在のインスタンスを受け取る
}
function createHashHistory() {
    function setupListeners(fn) {
        window.addEventListener('hashchange', fn)
    }
    return {
        setupListeners,
        url: window.location.hash.slice(1) || '/'
    }
}
class Router {
    constructor(options) {
        this.history = options.history
        this.routes = options.routes
        this.current = ref(this.history.url)

        this.history.setupListeners(() => {
            this.current.value = window.location.hash.slice(1)
        })
    }
    install(app) {
        app.provide(ROUTER_KEY, this) // インスタンスをrouterに渡す
        app.component("router-link", RouterLink)
        app.component("router-view", RouterView)
    }
}

export { createRouter, createHashHistory, useRouter }

上記のコードを元に、src/router/index.jsに戻ると、以下のように使用できます。createWebHashHistoryhistoryパラメータとして使用し、routescreateRouter関数に渡します。

import {
    createRouter,
    createHashHistory,
} from '手書きのrouterが保存されているファイル'
const router = createRouter({
  history: createHashHistory(),
  routes
})

このルーターの実装では、router-viewrouter-linkという2つの重要な組み込みコンポーネントのマウントが見られます。 createRouter関数で作成されたRouterインスタンスでは、currentが現在のルートアドレスを返し、refで反応性のあるデータとしてラップされています。 router-viewコンポーネントの機能は、currentが変化したときに、currentアドレスに該当するコンポーネントをマッチさせ、router-viewに動的にレンダリングすることです。 ルーターと同じディレクトリにRouterView.vueを作成し、以下のようにコードを記述します。コードではまずuseRouterを使って現在のルートインスタンスを取得し、その後router.current.valueの値を使ってユーザーが設定したルート設定からマッチするコンポーネントを計算し、最後に計算されたcomp変数をtemplate内でcomponentコンポーネントを使って動的にレンダリングします。

<template>
    <component :is="comp"></component>
</template>
<script setup>

import { computed } from 'vue'
import { useRouter } from '手書きのrouterのファイル'

let router = useRouter()

const comp = computed(() => {
    const route = router.routes.find(
        (route) => route.path === router.current.value
    )
    return route ? route.component : null
})
</script>

router-viewコンポーネントを実装した後、router-linkコンポーネントを実装します。ルーターと同じディレクトリにRouterLink.vueを作成し、以下のようにコードを記述します。テンプレートはaタグをレンダリングしますが、aタグのhref属性に#を追加することでハッシュを変更します。

<template>
    <a :href="'#'+props.to">
        <slot />
    </a>
</template>

<script setup>
import { defineProps } from 'vue'
let props = defineProps({ // コンポーネント呼び出し時に渡された値を取得
    to: { type: String, required: true }
})

</script>

これで、ハッシュモードのミニマムなvue-routerの実装が完了しました。

「振り返り」

installメソッドはどこで使用されますか? app.vueと同レベルのindex.jsでなぜ以下のように書く必要がありますか?

app.use(xxx)

これは、useメソッドがサードパーティライブラリ/プラグインのinstallメソッドを実行するからです!

タグ: Vue3 vue-router SPA hash-router history-api

8月17日 19:21 投稿