Vue Router にミドルウェア機構を実装する

## ミドルウェアとは

Vue Router を使うにあたって、それぞれのルートで何かしら共通の処理を行いたい場合に、ロジックを使いまわせると便利です。使い回しが効くように切り出されたロジックをここではミドルウェアと呼んでいます。Vue Router のライフサイクルに組み込まれたフックを利用してミドルウェア機構を実装していきます。

## Router の作成

```js:router/index.js
import Vue from 'vue'
import store from '~/store'
import Meta from 'vue-meta'
import routes from './routes'
import Router from 'vue-router'
import { sync } from 'vuex-router-sync'

Vue.use(Meta)
Vue.use(Router)

// The middleware for every page of the application.
const globalMiddleware = ['locale', 'check-auth']

// Load middleware modules dynamically.
const routeMiddleware = resolveMiddleware(require.context('~/middleware', false, /.*\.js$/))

const router = createRouter()

sync(store, router)

export default router
```

- **`globalMiddleware`**
    - すべてのページに適用されるミドルウェアを設定します。
- **`resolveMiddleware`**
    - 後述します
- **`createRouter`**
    - `Router` のインスタンス化とフックの登録をおこなう。

    ```js:createRouter
    function createRouter() {
      const router = new Router({
        scrollBehavior,
        mode: 'history',
        routes
      })

      router.beforeEach(beforeEach) // before ガードの登録
      router.afterEach(afterEach) // after フックの登録

      return router
    }
    ```

- **`sync`**
    - Vuex のストアで route 情報にアクセスできるようにします。

## ナビゲーションガード

ナビゲーションガードとは、ルーターによる遷移の可否をつかさどる仕組みで、特定のコンテンツを認証済みのユーザーだけに公開したりする際に利用します。これをガードと呼びます。

- `router.beforeEach`: グローバル before ガード
    - `next` 関数を受け取り、ナビゲーションを確立するには `next` 関数を呼び出してフックを解決します。
    - 前処理を記述します。

    ```js:beforeEach
    /**
     * Global router guard.
     *
     * @param {Route} to
     * @param {Route} from
     * @param {Function} next
     */
    async function beforeEach(to, from, next) {
      // Get the matched components and resolve them.
      const components = await resolveComponents(router.getMatchedComponents({ ...to }))

      if (components.length === 0) {
        return next()
      }

      // Start the loading bar.
      if (components[components.length - 1].loading !== false) {
        router.app.$nextTick(() => router.app.$loading.start())
      }

      // Get the middleware for all the matched components.
      const middleware = getMiddleware(components)

      // Call each middleware.
      callMiddleware(middleware, to, from, (...args) => {
        // Set the application layout only if "next()" was called with no args.
        if (args.length === 0) {
          router.app.setLayout(components[0].layout || '')
        }

        next(...args)
      })
    }
    ```

    - **`router.getMatchedComponents `**

        > 現在のルートまたは提供されたロケーションにマッチしているコンポーネント (インスタンスではなく定義 / コンストラクタ) の配列を返します。

    - **`resolveComponents`**

        ```js
        /**
         * Resolve async components.
         *
         * @param  {Array} components
         * @return {Array}
         */
        async function resolveComponents(components) {
          return await Promise.all(
            components.map(async component => {
              return typeof component === 'function' ? await component() : component
            })
          )
        }
        ```

    - **`getMiddleware`**
        - 後述します
    - **`callMiddleware `**
        - 後述します

## グローバル after フック

グローバル after フックも `router.beforeEach` と同様にルーターのライフサイクルの中で呼ばれるメソッドですが、ガードとは意味合いが異なるため単純に「フック」と呼んでいます。

- `router.afterEach`: グローバル after フック
    - `next` 関数を受け取らず、遷移の可否には影響しません。
    - 後処理を記述します。

    ```js:afterEach
    /**
     * Global after hook.
     *
     * @param {Route} to
     * @param {Route} from
     * @param {Function} next
     */
    async function afterEach(to, from, next) {
      await router.app.$nextTick()

      router.app.$loading.finish()
    }
    ```

    ローディングインジケータを終了させる `vm.$loading.finish` トリガは、確実にDOMが更新された後にコールされるように、インスタンスプロパティ `vm.$nextTick` の `Promise` が解決されるのを待つようにします(`async/await`)。

## ミドルウェア機構の実装

- **`callMiddleware`**

    ```js
    /**
     * Call each middleware.
     *
     * @param {Array} middleware
     * @param {Route} to
     * @param {Route} from
     * @param {Function} next
     */
    function callMiddleware(middleware, to, from, next) {
      const stack = middleware.reverse()

      const _next = (...args) => {
        // Stop if "_next" was called with an argument or the stack is empty.
        if (args.length > 0 || stack.length === 0) {
          if (args.length > 0) {
            router.app.$loading.finish()
          }

          return next(...args)
        }

        const middleware = stack.pop()

        if (typeof middleware === 'function') {
          middleware(to, from, _next)
        } else if (routeMiddleware[middleware]) {
          routeMiddleware[middleware](to, from, _next)
        } else {
          throw Error(`Undefined middleware [${middleware}]`)
        }
      }

      _next()
    }
    ```

    - ミドルウェアは複数指定が可能なため、ミドルウェアのスタックを再帰的に処理している。
    - 各ミドルウェアは `_next` をコールして解決する。
    - `middleware` が `function` の場合は、その関数をそのまま実行する。
    - `middleware` が `string` の場合は `routeMiddleware` 配列から既定のミドルウェアを実行する。

- **`getMiddleware`**

    ```js:router/index.js
    /**
     * Merge the the global middleware with the components middleware.
     *
     * @param  {Array} components
     * @return {Array}
     */
    function getMiddleware(components) {
      const middleware = [...globalMiddleware]

      components.filter(c => c.middleware).forEach(component => {
        if (Array.isArray(component.middleware)) {
          middleware.push(...component.middleware)
        } else {
          middleware.push(component.middleware)
        }
      })

      return middleware
    }
    ```

    - コンポーネントからミドルウェアを取り出します。
        - コンポーネント側で `middleware` が配列で指定されていた場合、`globalMiddleware` とマージされ1次配列にフラット化されます。

- **`scrollBehavior`**

    ```js
    /**
     * Scroll Behavior
     *
     * @link https://router.vuejs.org/en/advanced/scroll-behavior.html
     *
     * @param  {Route} to
     * @param  {Route} from
     * @param  {Object|undefined} savedPosition
     * @return {Object}
     */
    function scrollBehavior(to, from, savedPosition) {
      if (savedPosition) {
        return savedPosition
      }

      if (to.hash) {
        return { selector: to.hash }
      }

      const [component] = router.getMatchedComponents({ ...to }).slice(-1)

      if (component && component.scrollToTop === false) {
        return {}
      }

      return { x: 0, y: 0 }
    }
    ```

    - 遷移時のスクロールの振る舞いをカスタマイズします。
    - コンポーネントに対し `scrollTop: true` が設定されている場合、ポジションをトップへ移動せさます。

- **`resolveMiddleware`**

    ```js
    /**
     * @param  {Object} requireContext
     * @return {Object}
     */
    function resolveMiddleware(requireContext) {
      return requireContext
        .keys()
        .map(file => [file.replace(/(^.\/)|(\.js$)/g, ''), requireContext(file)])
        .reduce((guards, [name, guard]) => ({ ...guards, [name]: guard.default }), {})
    }
  ```

## ミドルウェアの利用

- **`middleware`** の指定方法
    - **`string`**
    - **`function`**
    - **`[string|function]`**

    ```html:string
    <script>
    export default {
      middleware: 'auth'
    }
    </script>
    ```

    ```html:array
    <script>
    export default {
      middleware: ['auth', 'logger']
    }
    </script>
    ```

    ```html:array-and-function
    <script>
    const bar = (to, from, next) => {
      //
      // do something
      //

      next() // important!
    }

    export default {
      middleware: [
        'foo',
        bar,
      ]
    }
    </script>
    ```

※本記事では、Vue.js を[単一ファイルコンポーネント](https://jp.vuejs.org/v2/guide/single-file-components.html)で利用することを想定しています。