Vueと比べて理解するNuxtの機能~auto-import編~

2023/10/18に公開

はじめに

この記事は先日、株式会社ヒュープロ様で開催された「Engineer LT Night #1 @渋谷」で登壇した際に発表した内容を記事に書き起こしたものになります。

本題

早速ですが、Nuxt3には様々な便利機能があります。auto import機能もその一つです。pluginsやmiddleware,utils,components,pages,layout,composables,assets,public,serverと多くのフォルダがこのauto importの機能を備えています。

今回はその中でも初期化のタイミングに注目してpluginsとmiddlewareに注目します。

ご存知の通り、pluginsフォルダには各種外部ライブラリ等を初期化する処理を書いたファイルを配置します。
そして、middlewareはvue-routerの拡張であり、vue-routerのbeforeEach hookに相当するnavigation guardを書くフォルダになります。

きっかけ

もう少しだけ余談を挟みます。
そもそもこの内容で登壇しようと思った理由なんですが、Nuxt3で開発したこともvue3で開発したこともある私IIHARAが目下vue3での開発を担当していて「初期化周りのハンドリング面倒だな。Nuxtだったらもっと気にせず開発できていた気がするんだけどなあ。よし、せっかくだしちゃんと調査してみるか」と思い立ったのがきっかけです。

そのため、次項からvueだとどうなってるか、nuxtだとどうなってるかの順で比較していきます。


vueで調査

ログを埋め込む

vueではpluginsフォルダやmiddlewareフォルダはないので、外部ライブラリを初期化するという意味でmain.tsとルーティング処理を書く場所という意味でrouter.tsをログを埋め込む対象とする

// main.ts
const resolveFunc = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve('main.ts:resolveFunc:resolved')
    }, 2000)
  })
}
const asyncCall = async () => {
  console.debug('main.ts:asyncCall:calling')
  const result = await resolveFunc()
  console.debug(result)
}
new Promise((resolve) => {
  setTimeout(() => {
    resolve('main.ts:Promise:resolved')
  }, 2000)
}).then(() => {
  console.debug('main.ts:Promise:then')
})
asyncCall().then(() => {
  console.debug('main.ts:asyncCall:then')
})
// router/middleware.ts
const resolveFunc = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve('beforeEach:resolveFunc:resolved')
    }, 2000)
  })
}
const asyncCall = async () => {
  console.debug('beforeEach:asyncCall:calling')
  const result = await resolveFunc()
  console.debug(result)
}
new Promise((resolve) => {
  setTimeout(() => {
    resolve('beforeEach:Promise:resolved')
  }, 2000)
}).then(() => {
  console.debug('beforeEach:Promise:then')
})
asyncCall().then(() => {
  console.debug('beforeEach:asyncCall:then')
})

結果

トップレベルに一切awaitを付けなかった場合

vue_no_await.png

一番最後に出力されてほしいapp.vueが2番目に来てしまっている

トップレベルにawaitを付けた場合

vue_await.png

app.vueが一番最後より前で出力されてしまっている

下記のように関連する処理全てにawaitを付けたとしても、、、
// main.ts
const app = await createApp(AppEmployee)
await app.use(router)
await app.component()
await app.provide(storeKey, createGlobalStore())
await app.mount('#app')
// router.ts
// これを
- router.beforeEach(globalNavigationGuard)
// こうする
+ router.beforeEach(async (to, from) => {
+   await globalNavigationGuard(to, from)
+ })

確かにapp.vueが最後に出力されているが、main.tsとrouter.tsの処理がそれぞれ独立して進みながらapp.vueのmountへとつながっている

そのため、main.ts等で初期化した外部ライブラリ等をルーティング処理内で参照したり呼び出したりすることがしにくい(ワークアラウンド的に無理矢理することは可能だが拡張性に難あり)

vue_all_await.png

nuxtで調査

ログを埋め込む

// plugin/index.client.ts
const resolveFunc = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve('plugin:resolveFunc:resolved')
    }, 2000)
  })
}
const asyncCall = async () => {
  console.debug('plugin:asyncCall:calling')
  const result = await resolveFunc()
  console.debug(result)
}
export default defineNuxtPlugin(async (app) => {
  await new Promise((resolve) => {
    setTimeout(() => {
      resolve('plugin:Promise:resolved')
    }, 2000)
  }).then(() => {
    console.debug('plugin:Promise:then')
  })
  await asyncCall().then(() => {
    console.debug('plugin:asyncCall:then')
  })
})
// middleware/index.global.ts
const resolveFunc = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve('middleware:resolveFunc:resolved')
    }, 2000)
  })
}
const asyncCall = async () => {
  console.debug('middleware:asyncCall:calling')
  const result = await resolveFunc()
  console.debug(result)
}
export default defineNuxtRouteMiddleware(async (to) => {
  await new Promise((resolve) => {
    setTimeout(() => {
      resolve('middleware:Promise:resolved')
    }, 2000)
  }).then(() => {
    console.debug('middleware:Promise:then')
  })
  await asyncCall().then(() => {
    console.debug('middleware:asyncCall:then')
  })
})

トップレベルに一切awaitを付けなかった場合

nuxt_no_await.png

当然この場合は、app.vueが最後には出力されない

トップレベルにawaitを付けた場合

nuxt_await.png

app.vueが最後の出力されつつ、pluginsの処理が全て終わったあとmiddlewareの処理に進んでいるのが見て取れる。

これが地味に嬉しい。
しかし、なぜこんな挙動をするのか

ソースコードで確認してみる

// https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/app/plugins/router.ts
export default defineNuxtPlugin<{ route: Route, router: Router }>({
  name: 'nuxt:router',
  enforce: 'pre',
  setup (nuxtApp) {
    // <中略>
    async function handleNavigation (url: string | Partial<Route>, replace?: boolean): Promise<void> {
      try {
        // Resolve route
        const to = getRouteFromPath(url)

        // Run beforeEach hooks
        for (const middleware of hooks['navigate:before']) {
          const result = await middleware(to, route)
          // Cancel navigation
          if (result === false || result instanceof Error) { return }
          // Redirect
          if (typeof result === 'string' && result.length) { return handleNavigation(result, true) }
        }

        for (const handler of hooks['resolve:before']) {
          await handler(to, route)
        }
        // Perform navigation
        Object.assign(route, to)
        if (import.meta.client) {
          window.history[replace ? 'replaceState' : 'pushState']({}, '', joinURL(baseURL, to.fullPath))
          if (!nuxtApp.isHydrating) {
            // Clear any existing errors
            await nuxtApp.runWithContext(clearError)
          }
        }
        // Run afterEach hooks
        for (const middleware of hooks['navigate:after']) {
          await middleware(to, route)
        }
      } catch (err) {
        if (import.meta.dev && !hooks.error.length) {
          console.warn('No error handlers registered to handle middleware errors. You can register an error handler with `router.onError()`', err)
        }
        for (const handler of hooks.error) {
          await handler(err)
        }
      }
    }
    // <中略>
  }
})
// https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/app/entry.ts
...
entry = async function initApp () {
  if (vueAppPromise) { return vueAppPromise }
  const isSSR = Boolean(
    window.__NUXT__?.serverRendered ||
    document.getElementById('__NUXT_DATA__')?.dataset.ssr === 'true'
  )
  const vueApp = isSSR ? createSSRApp(RootComponent) : createApp(RootComponent)

  const nuxt = createNuxtApp({ vueApp })

  try {
    await applyPlugins(nuxt, plugins)
  } catch (err) {
    await nuxt.callHook('app:error', err)
    nuxt.payload.error = (nuxt.payload.error || err) as any
  }

  try {
    await nuxt.hooks.callHook('app:created', vueApp)
    await nuxt.hooks.callHook('app:beforeMount', vueApp)
    vueApp.mount(vueAppRootContainer)
    await nuxt.hooks.callHook('app:mounted', vueApp)
    await nextTick()
  } catch (err) {
    await nuxt.callHook('app:error', err)
    nuxt.payload.error = (nuxt.payload.error || err) as any
  }
  return vueApp
}
...

nuxt/src/app/plugins/router.tsからpluginsフォルダ内の初期化の処理はもとより、ルーティングの処理もrouterのpluginとして登録しているのが見て取れる。

加えて、nuxt/src/app/entry.tsではawait applyPlugins(nuxt, plugins)でpluginsを読み込みきった後にvueApp.mount(vueAppRootContainer)でapp.vueの読み込みに進んでいる。

結果としてpluginに登録された処理が順番に同期的に読み込まれ、その処理が終わったあとにapp.vueのマウントがなされているようだった。

まとめ

今年の12月でvue2のサポートが終了し完全にvue3への移行が必須となってきます。
周辺ライブラリの整備も進んできた今Nuxt3への移行は良い選択肢だと思います。

ここまで読んで頂きありがとうございました。

GitHubで編集を提案

Discussion