vue.js
Vuex
vue-router

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

ミドルウェアとは

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

Router の作成

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 のインスタンス化とフックの登録をおこなう。
    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 関数を呼び出してフックを解決します。
    • 前処理を記述します。
    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

      /**
       * 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 関数を受け取らず、遷移の可否には影響しません。
    • 後処理を記述します。
    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.$nextTickPromise が解決されるのを待つようにします(async/await)。

ミドルウェア機構の実装

  • callMiddleware

    /**
     * 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 をコールして解決する。
    • middlewarefunction の場合は、その関数をそのまま実行する。
    • middlewarestring の場合は routeMiddleware 配列から既定のミドルウェアを実行する。
  • getMiddleware

    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

    /**
     * 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

    /**
     * @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]
    string
    <script>
    export default {
      middleware: 'auth'
    }
    </script>
    
    array
    <script>
    export default {
      middleware: ['auth', 'logger']
    }
    </script>
    
    array-and-function
    <script>
    const bar = (to, from, next) => {
      //
      // do something
      //
    
      next() // important!
    }
    
    export default {
      middleware: [
        'foo',
        bar,
      ]
    }
    </script>
    

※本記事では、Vue.js を単一ファイルコンポーネントで利用することを想定しています。