VitePress のルータの実装について

<!--
「Goの父」ロブ・パイクの「プログラミング5カ条」、ネット上で話題に
https://gigazine.net/news/20200817-rob-pike-5-rules-programming/
  -->

## はじめに

* バージョン ... 0.2.0
* SHA-1 ハッシュ ... d58b0231f63362aadde2d75feebb7b8c567bad0f

1. [VitePress のディレクトリ構成](https://qiita.com/domodomodomo/items/1048f51d98ab5fd52d12)
2. [src/client/app と src/client/theme-default のつなぎ込み](https://qiita.com/domodomodomo/items/49b1944f09f8f9f57519)
3. [Markdown ファイルと src/client/app, Vue インスタンス本体のつなぎ込み <-- いまココです。](https://qiita.com/domodomodomo/items/f44bf1f49cff511890c7)
4. [Vite と VitePress のつなぎ込み](https://qiita.com/domodomodomo/items/c3b828bf9f7cb7e9a18f)
  1. [dev 編](https://qiita.com/domodomodomo/items/aea100ae69344d6dcc39)
  2. build 編(未着手)

## 概要

VitePress は vue-router を使っていないそうです。

> * Lighter page weight.
>   * Does not use vue-router because the need of VitePress is very simple and specific - a simple custom router (under 200 LOC) is used instead. (訳注: LOC は Line of Code の略、under 200 LOC ... 200 行以下で)
>
> [vuejs/vitepress - GitHub](https://github.com/vuejs/vitepress/tree/d58b0231f63362aadde2d75feebb7b8c567bad0f)

リアクティブにしたルート `route` を `RouterSymbol` というシンボルに紐付けて管理しているようです。




## Step 1.

`route.contentComponent` に Vue コンポーネントに変換された Markdown ファイルがはいります。テーマを自作する際に `<Content />` を参照します。以下が `<Content />` にあたります。

```/src/client/app/components/Content.ts
import { h } from 'vue'
import { useRoute } from '../router'
import { usePrefetch } from '../composables/preFetch'


export const Content = {
  setup() {
    const route = useRoute()
    if (!__DEV__) {
      // in prod mode, enable intersectionObserver based pre-fetch.
      usePrefetch()
    }
    return () => (route.contentComponent ? h(route.contentComponent) : null)
  }
}
```

* [vuejs/vitepress - /src/client/app/components/Content.ts#L1-L14](https://github.com/vuejs/vitepress/blob/da4852a61bd73a8b46c4971c330f95761237c733/src/client/app/components/Content.ts#L1-L14)

`useRoute()` を使って `route` を取り出しています。Step 5 で、`useRoute()` を見てみます。

## Step 2.

`route` は「現在のパス `path`」と「それに対応したコンポーネント(Vue コンポーネントに変換された Markdown ファイル) `contentComponent`」を保持しています。

```typescript:/src/client/app/router.ts#L9-L7
export interface Route {
  path: string
  contentComponent: Component | null
}
```

* [vuejs/vitepress - /src/client/app/router.ts#L4-L7](https://github.com/vuejs/vitepress/blob/da4852a61bd73a8b46c4971c330f95761237c733/src/client/app/router.ts#L4-L7)


## Step 3.

`route` は、リアクティブです。

```typescript:/src/client/app/router.ts#L16-L25
const getDefaultRoute = (): Route => ({
  path: '/',
  contentComponent: null
})

// ...
// 中略
// ...

  const route = reactive(getDefaultRoute())
```

* [vuejs/vitepress - /src/client/app/router.ts#L16-L25](https://github.com/vuejs/vitepress/blob/da4852a61bd73a8b46c4971c330f95761237c733/src/client/app/router.ts#L16-L25)



## Step 4.

`router` から `route` を取り出しています。

```typescript:/src/client/app/router.ts#L130-L141
export function useRouter(): Router {
  const router = inject(RouterSymbol)
  if (!router) {
    throw new Error('useRouter() is called without provider.')
  }
  // @ts-ignore
  return router
}


export function useRoute(): Route {
  return useRouter().route
}
```

* [vuejs/vitepress - /src/client/app/router.ts#L130-L141](https://github.com/vuejs/vitepress/blob/da4852a61bd73a8b46c4971c330f95761237c733/src/client/app/router.ts#L130-L141)

`inject` は Composition API で策定された関数のようです。`provide` でキーと値を指定して詰め込んだものを、`inject` で取り出すことができます。

* [Vue Composition API - Dependency Injection](https://vue-composition-api-rfc.netlify.app/api.html#dependency-injection)

以下、`provide` されているところを確認します。



## Step 5.

`app.provide(RouterSymbol, router)` で `provide` が、確認できます。また、そのすぐ下で `app.component('Content', Content)` で、さきほど確認した `Content`, Vue コンポーネントに変換された Markdown ファイルも、確認できました。

```typescript:/src/client/app/index.ts#L74-L78
  app.provide(RouterSymbol, router)
  app.provide(pageDataSymbol, pageDataRef)


  app.component('Content', Content)
  app.component('Debug', __DEV__ ? Debug : () => null)
```

* [vuejs/vitepress - /src/client/app/index.ts#L74-L78](https://github.com/vuejs/vitepress/blob/da4852a61bd73a8b46c4971c330f95761237c733/src/client/app/index.ts#L74-L78)



## Step 6.

`createRouter` で `router` を作成しています。


```typescript:/src/client/app/index.ts#L39-L68
  const router = createRouter(
    // ...
    // 中略
    // ...
  )
```

* [vuejs/vitepress - /src/client/app/index.ts#L39-L68](https://github.com/vuejs/vitepress/blob/da4852a61bd73a8b46c4971c330f95761237c733/src/client/app/index.ts#L39-L68)



## Step 7.

`router` のプロパティには `route` と `go` が定義されています。`route` は "現在" の `route` です。currentRoute とかの方がニュアンスはもしかしたら近かったかもしれません。`go` の動作は、次節を参照してください。

```typescript:/src/client/app/router.ts#L4-L7
export interface Router {
  route: Route
  go: (href?: string) => Promise<void>
}
```

* [vuejs/vitepress - /src/client/app/router.ts#L4-L7](https://github.com/vuejs/vitepress/blob/da4852a61bd73a8b46c4971c330f95761237c733/src/client/app/router.ts#L9-L12)



## Step 8.

`router` の `go` を実行すると「それに対応したコンポーネント `contentComponent`」を fetch し、あとは mount すればページが表示されます。

```typescript:/src/client/app/index.ts#L106-L109
  // wait unitl page component is fetched before mounting
  router.go().then(() => {
    app.mount('#app')
  })
```

* [vuejs/vitepress - /src/client/app/index.ts#L106-L109]( https://github.com/vuejs/vitepress/blob/da4852a61bd73a8b46c4971c330f95761237c733/src/client/app/index.ts#L106-L109)

どこかで、パスの変更を検知しているのかもしれませんが、それに類するものを見つけられずにいます。SPA かどうか、ちょっと気になっています。



## Step 9. createRouter

`createRouter` の中で `go` が定義されています。

```typescript:/src/client/app/router.ts#L21-L128
export function createRouter(
  loadComponent: (route: Route) => Component | Promise<Component>,
  fallbackComponent?: Component
): Router {
  const route = reactive(getDefaultRoute())
  const inBrowser = typeof window !== 'undefined'


  function go(href?: string) {
    href = href || (inBrowser ? location.href : '/')
    if (inBrowser) {
      // save scroll position before changing url
      history.replaceState({ scrollPosition: window.scrollY }, document.title)
      history.pushState(null, '', href)
    }
    return loadPage(href)
  }


  async function loadPage(href: string, scrollPosition = 0) {
    // we are just using URL to parse the pathname and hash - the base doesn't
    // matter and is only passed to support same-host hrefs.
    const targetLoc = new URL(href, `http://vuejs.org`)
    const pendingPath = (route.path = targetLoc.pathname)


    try {
      //
      // - loadComponent 
      //   - Step 6 の第一引数をリンク先からご確認ください。
      //   - route.path で指定されたパスにある markdown ファイルを返します。
      //
      // - route
      //   - Route 型です。
      //     export interface Route {
      //       path: string
      //       contentComponent: Component | null
      //     }
      //   - この行 `let comp = loadComponent(route)` では、まだ
      //     contentComponent に Component が、代入されていません。
      //
      //
      let comp = loadComponent(route)
      // only await if it returns a Promise - this allows sync resolution
      // on initial render in SSR.
      if ('then' in comp && typeof comp.then === 'function') {
        comp = await comp
      }
      if (route.path === pendingPath) {
        if (!comp) {
          throw new Error(`Invalid route component: ${comp}`)
        }


        // ---- ポイント ----
        //     ここでルートが変化します。
        //     /src/client/app/components/Content.ts も変化します。  
        route.contentComponent = markRaw(comp)
        if (inBrowser) {
          await nextTick()


          if (targetLoc.hash && !scrollPosition) {
            const target = document.querySelector(targetLoc.hash) as HTMLElement
            if (target) {
              scrollPosition = target.offsetTop
            }
          }


          window.scrollTo({
            left: 0,
            top: scrollPosition,
            behavior: 'auto'
          })
        }
      }
    } catch (err) {
      if (!err.message.match(/fetch/)) {
        console.error(err)
      }
      if (route.path === pendingPath) {
        route.contentComponent = fallbackComponent
          ? markRaw(fallbackComponent)
          : null
      }
    }
  }

  // a タグにイベントを付与している。
  // これで SPA のような動作を実現しているはず...
  //
  // if (inBrowser) { となっていていr
  // dev 時のみで build 時も探したけど見当たらない...
  // build 時も動作するのかな。確認しないと。
  // -> build して Netlify にあげて、動作していることを確認。
  if (inBrowser) {
    window.addEventListener(
      'click',
      (e) => {
        const link = (e.target as Element).closest('a')
        if (link) {
          const { href, protocol, hostname, pathname, hash, target } = link
          const currentUrl = window.location
          // only intercept inbound links
          if (
            target !== `_blank` &&
            protocol === currentUrl.protocol &&
            hostname === currentUrl.hostname
          ) {
            if (pathname === currentUrl.pathname) {
              // smooth scroll bewteen hash anchors in the same page
              if (hash !== currentUrl.hash) {
                e.preventDefault()
                window.scrollTo({
                  left: 0,
                  top: link.offsetTop,
                  behavior: 'smooth'
                })
              }
            } else {
              e.preventDefault()
              go(href)
            }
          }
        }
      },
      { capture: true }
    )


    window.addEventListener('popstate', (e) => {
      loadPage(location.href, (e.state && e.state.scrollPosition) || 0)
    })
  }


  return {
    route,
    go
  }
}
```

* [vuejs/vitepress - /src/client/app/router.ts#L21-L128](https://github.com/vuejs/vitepress/blob/da4852a61bd73a8b46c4971c330f95761237c733/src/client/app/router.ts#L21-L128)

## おわりに

以上になります。ありがとうございました。次は、以下の記事になります。

* [Vite と VitePress のつなぎ込み](https://qiita.com/domodomodomo/items/c3b828bf9f7cb7e9a18f)