refactor(nuxt3): cleanup data fetching and improved useAsyncData (#699)

This commit is contained in:
pooya parsa 2021-10-08 16:21:55 +02:00 committed by GitHub
parent e614328406
commit 2bf645bd73
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 112 additions and 191 deletions

View File

@ -24,7 +24,7 @@ Under the hood, `defer: false` uses `<Suspense>` to block the loading of the rou
```vue ```vue
<script setup> <script setup>
const { data } = await asyncData('time', () => $fetch('/api/count')) const { data } = await useAsyncData('time', () => $fetch('/api/count'))
</script> </script>
<template> <template>

View File

@ -1,9 +1,12 @@
<template> <template>
<div> <div>
Page visits: {{ data.count }} {{ data }}
<button :disabled="pending" @click="refresh">
Refrash Data
</button>
</div> </div>
</template> </template>
<script setup> <script setup>
const { data } = await asyncData('time', () => $fetch('/api/count')) const { data, refresh, pending } = await useAsyncData('/api/hello', () => $fetch('/api/hello'))
</script> </script>

View File

@ -1,3 +0,0 @@
let ctr = 0
export default () => ({ count: ++ctr })

View File

@ -0,0 +1 @@
export default () => `Hello world! (Generated at ${new Date().toGMTString()})`

View File

@ -35,7 +35,7 @@ import { ContentLoader } from 'vue-content-loader'
export default defineNuxtComponent({ export default defineNuxtComponent({
components: { ContentLoader }, components: { ContentLoader },
setup () { setup () {
const { data, pending } = asyncData( const { data, pending } = useAsyncData(
'time', 'time',
() => () =>
new Promise(resolve => new Promise(resolve =>

View File

@ -8,10 +8,6 @@ export * from '@vue/composition-api'
const mock = () => () => { throw new Error('not implemented') } const mock = () => () => { throw new Error('not implemented') }
export const useAsyncData = mock() export const useAsyncData = mock()
export const asyncData = mock()
export const useSSRRef = mock()
export const useData = mock()
export const useGlobalData = mock()
export const useHydration = mock() export const useHydration = mock()
// Runtime config helper // Runtime config helper

View File

@ -317,7 +317,7 @@ export default {
/** Set to false to disable the Nuxt `validate()` hook */ /** Set to false to disable the Nuxt `validate()` hook */
validate: true, validate: true,
/** Set to false to disable the Nuxt `asyncData()` hook */ /** Set to false to disable the Nuxt `asyncData()` hook */
asyncData: true, useAsyncData: true,
/** Set to false to disable the Nuxt `fetch()` hook */ /** Set to false to disable the Nuxt `fetch()` hook */
fetch: true, fetch: true,
/** Set to false to disable `$nuxt.isOnline` */ /** Set to false to disable `$nuxt.isOnline` */

View File

@ -60,7 +60,7 @@ export default {
* @example * @example
* ```js * ```js
* export default { * export default {
* async asyncData ({ params, error, payload }) { * async useAsyncData ({ params, error, payload }) {
* if (payload) return { user: payload } * if (payload) return { user: payload }
* else return { user: await backend.fetchUser(params.id) } * else return { user: await backend.fetchUser(params.id) }
* } * }

View File

@ -1,76 +1,79 @@
import { onBeforeMount, onUnmounted, ref, unref } from 'vue' import { onBeforeMount, onUnmounted, ref } from 'vue'
import type { UnwrapRef, Ref } from 'vue' import type { Ref } from 'vue'
import { ensureReactive, useGlobalData } from './data'
import { NuxtApp, useNuxtApp } from '#app' import { NuxtApp, useNuxtApp } from '#app'
export type AsyncDataFn<T> = (ctx?: NuxtApp) => Promise<T> export interface AsyncDataOptions<T> {
export interface AsyncDataOptions {
server?: boolean server?: boolean
defer?: boolean defer?: boolean
default?: () => T
} }
export interface AsyncDataState<T> { export interface _AsyncData<T> {
data: UnwrapRef<T> data: Ref<T>
pending: Ref<boolean> pending: Ref<boolean>
fetch: (force?: boolean) => Promise<UnwrapRef<T>> refresh: (force?: boolean) => Promise<void>
error?: any error?: any
} }
export type AsyncDataResult<T> = AsyncDataState<T> & Promise<AsyncDataState<T>> export type AsyncData<T> = _AsyncData<T> & Promise<_AsyncData<T>>
export function useAsyncData (defaults?: AsyncDataOptions) { const getDefault = () => null
const nuxt = useNuxtApp()
const onBeforeMountCbs: Array<() => void> = []
if (process.client) { export function useAsyncData<T extends Record<string, any>> (key: string, handler: (ctx?: NuxtApp) => Promise<T>, options: AsyncDataOptions<T> = {}): AsyncData<T> {
onBeforeMount(() => { // Validate arguments
onBeforeMountCbs.forEach((cb) => { cb() }) if (typeof key !== 'string') {
onBeforeMountCbs.splice(0, onBeforeMountCbs.length) throw new TypeError('asyncData key must be a string')
})
onUnmounted(() => onBeforeMountCbs.splice(0, onBeforeMountCbs.length))
} }
nuxt._asyncDataPromises = nuxt._asyncDataPromises || {}
return function asyncData<T extends Record<string, any>> (
key: string,
handler: AsyncDataFn<T>,
options: AsyncDataOptions = {}
): AsyncDataResult<T> {
if (typeof handler !== 'function') { if (typeof handler !== 'function') {
throw new TypeError('asyncData handler must be a function') throw new TypeError('asyncData handler must be a function')
} }
options = {
server: true, // Apply defaults
defer: false, options = { server: true, defer: false, default: getDefault, ...options }
...defaults,
...options // Setup nuxt instance payload
const nuxt = useNuxtApp()
// Setup hook callbacks once per instance
const instance = getCurrentInstance()
if (!instance._nuxtOnBeforeMountCbs) {
const cbs = instance._nuxtOnBeforeMountCbs = []
if (instance && process.client) {
onBeforeMount(() => {
cbs.forEach((cb) => { cb() })
cbs.splice(0, cbs.length)
})
onUnmounted(() => cbs.splice(0, cbs.length))
}
} }
const globalData = useGlobalData(nuxt) const asyncData = {
data: ref(nuxt.payload.data[key] ?? options.default()),
pending: ref(true),
error: ref(null)
} as AsyncData<T>
const state = { asyncData.refresh = (force?: boolean) => {
data: ensureReactive(globalData, key) as UnwrapRef<T>, // Avoid fetching same key more than once at a time
pending: ref(true)
} as AsyncDataState<T>
const fetch = (force?: boolean): Promise<UnwrapRef<T>> => {
if (nuxt._asyncDataPromises[key] && !force) { if (nuxt._asyncDataPromises[key] && !force) {
return nuxt._asyncDataPromises[key] return nuxt._asyncDataPromises[key]
} }
state.pending.value = true asyncData.pending.value = true
nuxt._asyncDataPromises[key] = Promise.resolve(handler(nuxt)).then((result) => { // TODO: Cancel previus promise
for (const _key in result) { // TODO: Handle immediate errors
// @ts-expect-error nuxt._asyncDataPromises[key] = Promise.resolve(handler(nuxt))
state.data[_key] = unref(result[_key]) .then((result) => {
} asyncData.data.value = result
return state.data asyncData.error.value = null
}).finally(() => { })
state.pending.value = false .catch((error: any) => {
nuxt._asyncDataPromises[key] = null asyncData.error.value = error
asyncData.data.value = options.default()
})
.finally(() => {
asyncData.pending.value = false
nuxt.payload.data[key] = asyncData.data.value
delete nuxt._asyncDataPromises[key]
}) })
return nuxt._asyncDataPromises[key] return nuxt._asyncDataPromises[key]
} }
@ -80,40 +83,33 @@ export function useAsyncData (defaults?: AsyncDataOptions) {
// Server side // Server side
if (process.server && fetchOnServer) { if (process.server && fetchOnServer) {
fetch() asyncData.refresh()
} }
// Client side // Client side
if (process.client) { if (process.client) {
// 1. Hydration (server: true): no fetch // 1. Hydration (server: true): no fetch
if (nuxt.isHydrating && fetchOnServer) { if (nuxt.isHydrating && fetchOnServer) {
state.pending.value = false asyncData.pending.value = false
} }
// 2. Initial load (server: false): fetch on mounted // 2. Initial load (server: false): fetch on mounted
if (nuxt.isHydrating && clientOnly) { if (nuxt.isHydrating && clientOnly) {
// Fetch on mounted (initial load or deferred fetch) // Fetch on mounted (initial load or deferred fetch)
onBeforeMountCbs.push(fetch) instance._nuxtOnBeforeMountCbs.push(asyncData.refresh)
} else if (!nuxt.isHydrating) { // Navigation } else if (!nuxt.isHydrating) { // Navigation
if (options.defer) { if (options.defer) {
// 3. Navigation (defer: true): fetch on mounted // 3. Navigation (defer: true): fetch on mounted
onBeforeMountCbs.push(fetch) instance._nuxtOnBeforeMountCbs.push(asyncData.refresh)
} else { } else {
// 4. Navigation (defer: false): await fetch // 4. Navigation (defer: false): await fetch
fetch() asyncData.refresh()
} }
} }
} }
const res = Promise.resolve(nuxt._asyncDataPromises[key]).then(() => state) as AsyncDataResult<T> // Allow directly awaiting on asyncData
res.data = state.data const asyncDataPromise = Promise.resolve(nuxt._asyncDataPromises[key]).then(() => asyncData) as AsyncData<T>
res.pending = state.pending Object.assign(asyncDataPromise, asyncData)
res.fetch = fetch
return res
}
}
export function asyncData<T extends Record<string, any>> ( return asyncDataPromise as AsyncData<T>
key: string, handler: AsyncDataFn<T>, options?: AsyncDataOptions
): AsyncDataResult<T> {
return useAsyncData()(key, handler, options)
} }

View File

@ -4,7 +4,7 @@ import type { DefineComponent } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import type { LegacyContext } from '../legacy' import type { LegacyContext } from '../legacy'
import { useNuxtApp } from '../nuxt' import { useNuxtApp } from '../nuxt'
import { asyncData } from './asyncData' import { useAsyncData } from './asyncData'
export const NuxtComponentIndicator = '__nuxt_component' export const NuxtComponentIndicator = '__nuxt_component'
@ -14,7 +14,7 @@ async function runLegacyAsyncData (res: Record<string, any> | Promise<Record<str
const vm = getCurrentInstance() const vm = getCurrentInstance()
const { fetchKey } = vm.proxy.$options const { fetchKey } = vm.proxy.$options
const key = typeof fetchKey === 'function' ? fetchKey(() => '') : fetchKey || route.fullPath const key = typeof fetchKey === 'function' ? fetchKey(() => '') : fetchKey || route.fullPath
const { data } = await asyncData(`options:asyncdata:${key}`, () => fn(nuxt._legacyContext)) const { data } = await useAsyncData(`options:asyncdata:${key}`, () => fn(nuxt._legacyContext))
Object.assign(await res, toRefs(data)) Object.assign(await res, toRefs(data))
} }

View File

@ -1,65 +0,0 @@
import { getCurrentInstance, isReactive, reactive } from 'vue'
import type { UnwrapRef } from 'vue'
import { useNuxtApp } from '#app'
export function ensureReactive<
T extends Record<string, any>,
K extends keyof T
> (data: T, key: K): UnwrapRef<T[K]> {
if (!isReactive(data[key])) {
data[key] = reactive(data[key] || ({} as T[K]))
}
return data[key]
}
/**
* Returns a unique string suitable for syncing data between server and client.
*
* @param nuxt (optional) A Nuxt instance
* @param vm (optional) A Vue component - by default it will use the current instance
*/
export function useSSRRef (nuxt = useNuxtApp(), vm = getCurrentInstance()): string {
if (!vm) {
throw new Error('This must be called within a setup function.')
}
// Server
if (process.server) {
if (!vm.attrs['data-ssr-ref']) {
nuxt._refCtr = nuxt._refCtr || 1
vm.attrs['data-ssr-ref'] = String(nuxt._refCtr++)
}
return vm.attrs['data-ssr-ref'] as string
}
// Client
/* TODO: unique value for multiple calls */
return vm.vnode.el?.dataset?.ssrRef || String(Math.random())
}
/**
* Allows accessing reactive data that can be synced between server and client.
*
* @param nuxt (optional) A Nuxt instance
* @param vm (optional) A Vue component - by default it will use the current instance
*/
export function useData<T = Record<string, any>> (
nuxt = useNuxtApp(),
vm = getCurrentInstance()
): UnwrapRef<T> {
const ssrRef = useSSRRef(nuxt, vm)
nuxt.payload.data = nuxt.payload.data || {}
return ensureReactive(nuxt.payload.data, ssrRef)
}
/**
* Allows accessing reactive global data that can be synced between server and client.
*
* @param nuxt - (optional) A Nuxt instance
*/
export function useGlobalData (nuxt = useNuxtApp()): Record<string, any> {
nuxt.payload.data = nuxt.payload.data || {}
return nuxt.payload.data
}

View File

@ -1,4 +1,3 @@
export { defineNuxtComponent } from './component' export { defineNuxtComponent } from './component'
export { useAsyncData, asyncData } from './asyncData' export { useAsyncData } from './asyncData'
export { useData } from './data'
export { useHydration } from './hydrate' export { useHydration } from './hydrate'

View File

@ -70,8 +70,9 @@ export function createNuxtApp (options: CreateOptions) {
const nuxt: NuxtApp = { const nuxt: NuxtApp = {
provide: undefined, provide: undefined,
globalName: 'nuxt', globalName: 'nuxt',
payload: {}, payload: reactive(process.server ? { serverRendered: true, data: {} } : (window.__NUXT__ || { data: {} })),
isHydrating: process.client, isHydrating: process.client,
_asyncDataPromises: {},
...options ...options
} as any as NuxtApp } as any as NuxtApp
@ -95,20 +96,11 @@ export function createNuxtApp (options: CreateOptions) {
} }
if (process.server) { if (process.server) {
nuxt.payload = {
serverRendered: true
}
nuxt.ssrContext = nuxt.ssrContext || {}
// Expose to server renderer to create window.__NUXT__ // Expose to server renderer to create window.__NUXT__
nuxt.ssrContext = nuxt.ssrContext || {}
nuxt.ssrContext.payload = nuxt.payload nuxt.ssrContext.payload = nuxt.payload
} }
if (process.client) {
nuxt.payload = window.__NUXT__ || {}
}
// Expose runtime config // Expose runtime config
if (process.server) { if (process.server) {
nuxt.provide('config', options.ssrContext.runtimeConfig.private) nuxt.provide('config', options.ssrContext.runtimeConfig.private)

View File

@ -33,4 +33,7 @@ declare module '@vue/runtime-core' {
interface App<HostElement> { interface App<HostElement> {
$nuxt: NuxtApp $nuxt: NuxtApp
} }
interface ComponentInternalInstance {
_nuxtOnBeforeMountCbs: Function[]
}
} }

View File

@ -1,7 +1,6 @@
const identifiers = { const identifiers = {
'#app': [ '#app': [
'useAsyncData', 'useAsyncData',
'asyncData',
'defineNuxtComponent', 'defineNuxtComponent',
'useNuxtApp', 'useNuxtApp',
'defineNuxtPlugin', 'defineNuxtPlugin',