6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Vue Routerに型をもたらすVue Routiderをつくった

Posted at

題名の通り、Vue RoutiderというVue Routerに型をつけるラッパーライブラリをつくりました!:hand_splayed:
8月13日から作り始めたので今日でちょうど3週間です。
この記事の下のほうにありますが、制約事項がいくつか存在しています。ただほとんどは今後対応予定です。

注意事項として、Vue3用のVue Router v4でしか動かなくて、Vue2用のVue Router v3では動きません。
ほぼ型情報を付与するだけのライブラリなのでminify時に1.4kB程度と小さめになっています1

経緯

究極のReact向けルーターライブラリ「Rocon」を作った」という記事を読んでVueでもルーターに型がほしいと感じました。
しかし、Vuexにはdirect-vuextyped-vuexなどの型をつけるライブラリが存在していますが、Vue Routerには調べたところでは一つも見つからなかったのでちょうどいい機会なので作ってみようと思って作りました。

作るにあたって、Vue Routerから利用方法が大きく変わるのは避けたかったので、できる限り同じような書き方ができるようにしました。

使い方

ルーター部分に関しては基本的には配列でルートを宣言していた箇所をオブジェクトにするだけです。

router/index.ts
import { createWebHistory } from 'vue-router'
import { createRoutider, createPath, createPaths, createQueries } from 'vue-routider'

const { router, useRouter, useRoute } = createRoutider({
  history: createWebHistory(),
  routes: {
    // Vue Routerのようにルートの配列を渡すのではなくオブジェクトで渡します
    Index: {
      path: '/', // パラメータが含まれない場合は普通の文字列が使えます
      component: /* コンポーネント */
    },
    Item: {
      path: createPath`/items/${'id'}`, // パラメータを含む場合はパラメータ名だけ${}でくくって、先頭にcreatePathをつけて宣言します
      component: /* コンポーネント */
    },
    UserItem: {
      path: createPath`/users/${'userId'}/${'id'}`,
      query: createQueries('order'), // クエリパラメータも対応していてこのように書きます
      component: /* コンポーネント */
    },
    Users: {
      // aliasの宣言はcreatePathsを利用します(パラメータが含まれない場合は文字列の配列が使えます)
      path: createPaths(
        createPath`/users/${'id'}`,
        createPath`/u/${'id'}` // ここで例えばcreatePath`/u/${'i'}`のようにパラメータが一致していないものを渡すと型エラーが発生します
      ),
      component: /* コンポーネント */
    }
  }
})

export default router
export { useRouter, useRoute }

ほかの箇所もVue Routerとほぼ同じ使い方です。

main.ts
import { createApp } from 'vue'
import router from './router'

const app = createApp(App)
app.use(router)
app.mount('#app')
pages/Item.vue
import { defineComponent } from 'vue'
import { useRoute, useRouter } from '../router'

export default defineComponent({
  setup() {
    const route = useRoute('Item') // 宣言したときのオブジェクトのキーで指定します
    // ここのroute.paramsの型はしっかり{ id: string | string[] }になります

    const router = useRouter()
    router.push({ name: 'Index' })
    // ここでは{ name: 'Index', params: { id: '0' } }のような指定をすると型で怒られます
    // router.push('Index') の書き方は今のところ対応していません。パラメータのないルートに対しては受け取れるようにしてもよさそうですね。
  }
})

ここではComposition APIで書いていますが、Options APIに関しては$routeをどうするのがよさそうか思いついていないので今のところ対応していません2

ここまで見たように、router/index.tsは似たような書き方、main.tsとそれぞれの.vueについてはほぼ同じ書き方で型がつくようになっています。
型でチェックできそうなものは基本的にほぼすべての箇所でできるようになっているはずです。
ただし、ナビゲーションガードに関しては次のように書く必要があります。これは実行時まで型情報が定まらないためですが、Type Guard関数を提供することで、それを利用して型が絞れるようにしてあります3

router.beforeEach((to, from, next) => {
  // ここではtoがどこかが絞り込まれていないため、to.paramsは存在しないようになっています
  if (router.isRouteName(to, 'Item')) {
    // ここではto.paramsがItemルートのパラメータの型になります
  }

  next(true)
})

ネストされているルートに関しても以下のように宣言できます。同じように配列をオブジェクトに変えるという感じです。

      Users: {
        path: '/users',
        component: /* コンポーネント */,
        children: {
          User: {
            path: createPath`${'userId'}`,
            component: /* コンポーネント */,
            children: {
              UserDetail: {
                path: 'detail',
                component: /* コンポーネント */
              },
              UserItem: {
                path: createPath`${'id'}`,
                component: /* コンポーネント */
              }
            }
          }
        }
      },

エディタの補完

型がついているのでもちろん下のように補完の表示がされます。:sunglasses:(画面はVSCodeのものです。)
image.png
image.png
image.png
image.png
beforeEnterの場合はcreateRouteで括る必要があります4

仕組み

型の変換の流れ

Vue Routerをラップして型情報をつけたものを返すような仕組みになっています。

まず、createPathを利用してパラメータの文字列を取り出し、ルート名をキーにしたオブジェクトのような型を構築します。
その型がcreateRoutiderの型引数になるようにし、その型が使われるようにしたuseRouteなどの関数をcreateRoutiderの返り値で返します。

こうすることで、パラメータの文字列を利用した型をuseRouteの引数や返り値で利用することができます。

簡略化したコードで表すと下のような形です。

type Component = any // 実際はVueからimport

// 実際はパラメータの抽出を行いますがここではパスそのものとして書きます
type Path<T extends string = string> = string & { path: T }
// Tが型引数になっているので型推論によってTが渡した文字列そのものの型になってくれます cf. createPath('a')ならPath<'a'>が返り値の型
const createPath = <T extends string>(path: T): Path<T> => (path as unknown) as Path<T>

type Route<P extends Path = Path> = {
  path: P,
  component: Component
}
type Routes = Record<string, Route>

// ここでもRを型引数にすることで関数内でRが利用できるようになってます
// 引数を「routes: R」ではなく「routes: Routes」にしてtypeof routesを利用すると、
// routesに渡すオブジェクトそのものの型ではなく、Routesという広い型になってしまいます
const createRoutider = <R extends Routes>(routes: R) => {
  // ここでRを利用した型を構築する
  const useRoute = <N extends keyof R>(name: N): R[N] => routes[name]
  return { useRoute } // Pathsなどの構築したものを利用して型をつけた関数を含むオブジェクトを返す
}

const { useRoute } = createRoutider({
    Index: {
        path: createPath('/index'),
        component: {}
    }
})

const route = useRoute('Index')
// ここでrouteはしっかり上で宣言した{ path: Path<'/index'>, component: {} }という型になっています

TypeScript Playgroundで開く
image.png
上のように補完がききます。

createPath

const path = createPath`/items/${'itemId'}`

という不思議な書き方をしますが、この書き方をすることで文字列中の文字を抽出しています5
タグ付きテンプレートといって、このcreatePathでは第一引数に${}ではない部分の配列(型はTemplateStringsArray)、第二引数以降に${}の中身の部分が受け取れます。
例えば、createPath`a${'b'}c${'d'}` だと第一引数が['a', 'c']、第二引数以降が'b', 'd'になります。
これを利用し、第二引数以降の型を推論させることで['itemId']という型を取り出すことができます。

createQueries

実際は関数を利用しなくても利用側できっちりas const記述することで正しい型をつけられます。

const route1 = {
  query: ['a', 'b'] // literal type wideningでstring[]になってしまい、'a'と'b'という情報が欠落してしまう
}

const route2 = {
  query: ['a', 'b'] as const // as constをつけることでliteral type wideningを回避して['a', 'b']になる
}

const route3 = {
  query: createQueries('a', 'b') // 型推論を利用してas constと同じ挙動にする
}

Vue Routiderでは別の形の型を利用しているのでas constでは動きません。

制約事項

ドキュメントのLimitationsの通り、いくつかの制約事項があります。

  • <router-link>は型がつきません
    • Vueのコンポーネントが受け取るpropsをジェネリクスを扱う方法がおそらくないので対応できません
  • /:id?/:id*のような存在しないことのあるパラメータは今のところ対応していません
    • これは今後対応していく予定です
  • 動的にルートを追加するaddRouteや削除するremoveRouteは対応していません
    • optionalな型が存在するようにするのは手ですが、今のところは対応予定はないです6

あとがき

Vue2用のComposition APIのプラグインでちょこっと型推論周りを触って面白いなと思っていたので、ちょうどいいタイミングでちょうどよさそうな題材があったので、とてもよかったのと作っていて楽しかったです。:grinning:
設計的にVue Routerに密結合なのでどうにかしたいと思っていますが難しそうです。
また、11月にリリースされる予定のTypeScript 4.1.0ではtemplate string types7という型をテンプレート文字列みたいに扱える機能も入る予定みたいなので、リリースされたらcreatePathの宣言方法を変えるのもよさそうかなと思っています。

普段は、traPというサークルでいろいろやっているのでよろしければそちらでの記事も見ていただけると嬉しいです:pray:
https://trap.jp/author/sappi_red/

  1. 試しにVue Routiderを導入して書き換えたものでは1kB程度増加しました。減った0.4kBはtree shakeの影響だと思われます。

  2. sapphi-red/vue-routider#39

  3. https://vue-routider.sapphi.red/navigation-guards, https://vue-routider.sapphi.red/route-type-guards

  4. beforeEnter以外にもいくつか必要なものはありますが、これは型推論の仕組み上必要です。必要なものはドキュメント(https://vue-routider.sapphi.red/ )に記述しています。

  5. この形ではなくcreatePath`/items/${param('id')}` という形でもよいのですが、こっちのが短かったのでこっちにしました。こっちの場合VSCodeだと補完がちょっと怪しい挙動をしてる気がしますが、打つ文字数自体少ないので問題ないかなと思っています。

  6. assert T is typeという手もありますが、実装してもあまり嬉しくなさそう

  7. microsoft/TypeScript#40336

6
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?