TL; DR

如果没有对后端的掌控,可以考虑使用状态保存是否认证,在执行完服务端请求后判断认证的状态并决定是否跳转;如果有对后端的掌控权,可以添加一个专门用于验证 token 的接口,在 Nuxt Middlware 中请求接口,并决定是否跳转。


最佳的用户体验

Nuxt 由于 SSR 的特性,可以从 Nuxt 服务端获取数据,因此可以在用户获取到页面之前就提前得知用户是否有权限访问当前页面,也就可以像 PHP/JSP 等 SSR 技术一样,做到用户访问直接跳转到登录页面,而并非是进入页面后弹出一个无权限提示再跳转达到登录页面。

符合直觉的代码

思考到上面的需求,便写了一段看起来最符合直觉的代码,这是 setup 中的代码。

1
2
3
4
5
6
7
8
9
const fetchData = async () => {
const response = await $fetch('...') // ...fetching

if (/* unauthorized */) {
navigateTo('/login') // cause an error
}
}

await fetchData()

最后发现如果走到 navigateTo 这一行会报错,信息如下:

1
[nuxt] A composable that requires access to the Nuxt instance was called outside of a plugin, Nuxt hook, Nuxt middleware, or Vue setup function. This is probably not a Nuxt bug. Find out more at `https://nuxt.com/docs/guide/concepts/auto-imports#using-vue-and-nuxt-composables`.

大概就是说不能在 setup 以外调用 composable 函数,虽然但是,咱这个代码确实是在 setup 中的。

后来搜索到了这个:https://github.com/nuxt/nuxt/issues/14269#issuecomment-1397352832

简单来说就是为了避免不同实例之间的状态互相影响,Vue 会在当前这个 tick 最后 unset 这些 composable 函数所依赖的上下文。Nuxt 也一样。

因此在 await 的异步函数内,这些上下文就不存在了。而 Vue 也为了解决这个问题做了一些特殊的处理(Nuxt 也是),可以在 await 之后恢复上下文,但这似乎仅限于 setupdefineNuxtPlugindefineNuxtRouteMiddleware 中。

翻阅了 Nuxt 的代码,确实在 navigateTo 函数中使用了一个 useRouter,而这个函数是依赖于上下文的,而在上面代码的 fetchData 中,上下文是不存在的。

1
2
3
4
5
6
7
export const navigateTo = (/* ... */) => {
// ...
const router = useRouter()

const nuxtApp = useNuxtApp()
// ...
}

解决方案

方案 1

上面说到 Vue 做了在 await 异步函数后恢复上下文的处理,因此实际上可以把获取数据的代码放到 setup 顶层来,这样在执行 navigateTo 的时候仍然是有上下文的。

1
2
3
4
5
const response = await $fetch('...')

if (/* unauthorized */) {
navigateTo('/login') // cause an error
}

如果你的需求中这段数据只需要获取一次,那么这是一个很好的解决方案,但咱的需求里实际上还需要在本页面刷新,也就是重新获取数据,因此需要将其抽取成一个函数,否则就需要分别在顶层和事件函数中写两次一模一样的获取数据的代码(那你能忍吗?咱不能忍)。

方案 2

既然上面说到,await 的异步函数中上下文会丢失,那么干脆不写 await,而是直接在顶层调用异步函数。

1
fetchData()

方案 3

还有一种思路:不妨自己保存一下 router 的引用,而不是当场使用 useRouter() 来获取,最后在获取数据函数中使用 router.push('/login') 替代 navigateTo('/login')

1
2
3
4
5
6
7
8
9
10
11
const router = useRouter()

const fetchData = async () => {
const response = await $fetch('...') // ...fetching

if (/* unauthorized */) {
router.push('/login')
}
}

await fetchData()

顺带一提:如果手贱在前面加上 await: await router.push('/login'),那么又会报错。

客户端侧解决方案

上面方案 2 和 3 虽然可以正常运行,但:用户访问需要认证的页面,会先进入该页面,然后再跳转到登录页面;打开浏览器调试工具,发现获取数据的请求实际上在客户端发送了,这样就导致这个部分失去了 SSR——页面到了浏览器,浏览器解析 JavaScript 再请求服务器,最后由渲染 API 返回的数据,而不是咱们想要的「数据在服务端请求并渲染,最后直接发送给浏览器」(对应这里也就是直接返回 HTTP 302 而不是请求完毕后通过 JavaScript 修改 route)。

客户端侧的最终解决方案是:获取数据后,将是否认证保存为状态,然后根据状态判断是否自动跳转:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const authed = ref(true)

const fetchData = async () => {
const response = await $fetch('...') // ...fetching

if (/* unauthorized */) {
authed.value = false
}
}

await fetchData()

if (!authed.value) {
navigateTo('/login')
}

更方便的解决方案

当然如果你的 Nuxt 应用后端就在自己手里,不需要和别的后端开发人员协商添加一个 verify 接口的话,也是可以采用下面的方案的:

添加一个 verify 接口,其功能就是验证请求携带的 token 是否合法、是否过期。然后再添加一个页面 middleware,请求这个 verify 接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
// middleware/auth.ts

export default defineNuxtRouteMiddleware(async () => {
const token = unref(useCookie('token'))
if (!token) {
return navigateTo('/login')
}
const headers = useRequestHeaders()
const result = await $fetch('/api/verify', { method: 'post', headers })
if (result.code < 0) {
return navigateTo('/login')
}
})

然后在需要认证的页面上应用这个 Middleware 即可。

1
2
3
definePageMeta({
middleware: 'auth',
})

怎么样?是不是肥肠简单~这种方式可能存在一定的性能损失,毕竟每个需要认证的页面都多了一次额外的服务端请求,不过也无伤大雅。

参考

  1. nuxt/packages/nuxt/src/app/composables/router.ts at c044d0eef57467622a103776d9846b41175ce653 · nuxt/nuxt - GitHub
  2. using try/catch with an async method inside a middleware causes a nuxt instance unavailable error. · Issue #14269 · nuxt/nuxt
  3. Auto-imports · Nuxt Concepts