feat(app): asyncData with global state and explicit key (#37)

Co-authored-by: Pooya Parsa <pyapar@gmail.com>
Co-authored-by: Sébastien Chopin <seb@nuxtjs.com>
This commit is contained in:
Daniel Roe 2021-04-03 11:03:20 +01:00 committed by GitHub
parent 2849559598
commit efabacd8e2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 250 additions and 70 deletions

View File

@ -1,7 +1,8 @@
import { Ref, ref, onMounted, watch, getCurrentInstance, onUnmounted } from 'vue'
import { getCurrentInstance, onBeforeMount, onUnmounted, Ref, ref, unref, UnwrapRef, watch } from 'vue'
import { Nuxt, useNuxt } from '@nuxt/app'
import { ensureReactive, useData } from './data'
import { NuxtComponentPendingPromises } from './component'
import { ensureReactive, useGlobalData } from './data'
export type AsyncDataFn<T> = (ctx?: Nuxt) => Promise<T>
@ -10,35 +11,36 @@ export interface AsyncDataOptions {
defer?: boolean
}
export interface AsyncDataObj<T> {
data: Ref<T>
export interface AsyncDataState<T> {
data: UnwrapRef<T>
pending: Ref<boolean>
refresh: () => Promise<void>
fetch: (force?: boolean) => Promise<UnwrapRef<T>>
error?: any
}
export type AsyncDataResult<T> = AsyncDataState<T> & Promise<AsyncDataState<T>>
export function useAsyncData (defaults?: AsyncDataOptions) {
const nuxt = useNuxt()
const vm = getCurrentInstance()
const data = useData(nuxt, vm)
let dataRef = 1
const onMountedCbs: Array<() => void> = []
const onBeforeMountCbs: Array<() => void> = []
if (process.client) {
onMounted(() => {
onMountedCbs.forEach((cb) => { cb() })
onMountedCbs.splice(0, onMountedCbs.length)
onBeforeMount(() => {
onBeforeMountCbs.forEach((cb) => { cb() })
onBeforeMountCbs.splice(0, onBeforeMountCbs.length)
})
onUnmounted(() => onMountedCbs.splice(0, onMountedCbs.length))
onUnmounted(() => onBeforeMountCbs.splice(0, onBeforeMountCbs.length))
}
return async function asyncData<T = Record<string, any>> (
nuxt._asyncDataPromises = nuxt._asyncDataPromises || {}
return function asyncData<T extends Record<string, any>> (
key: string,
handler: AsyncDataFn<T>,
options?: AsyncDataOptions
): Promise<AsyncDataObj<T>> {
options: AsyncDataOptions = {}
): AsyncDataResult<T> {
if (typeof handler !== 'function') {
throw new TypeError('asyncData handler must be a function')
}
@ -49,71 +51,77 @@ export function useAsyncData (defaults?: AsyncDataOptions) {
...options
}
const key = String(dataRef++)
const pending = ref(true)
const globalData = useGlobalData(nuxt)
const datastore = ensureReactive(data, key)
const state = {
data: ensureReactive(globalData, key) as UnwrapRef<T>,
pending: ref(true)
} as AsyncDataState<T>
const fetch = async () => {
pending.value = true
const _handler = handler(nuxt)
if (_handler instanceof Promise) {
// Let user resolve if request is promise
// TODO: handle error
const result = await _handler
for (const _key in result) {
datastore[_key] = result[_key]
}
pending.value = false
} else {
// Invalid request
throw new TypeError('Invalid asyncData handler: ' + _handler)
const fetch = (force?: boolean): Promise<UnwrapRef<T>> => {
if (nuxt._asyncDataPromises[key] && !force) {
return nuxt._asyncDataPromises[key]
}
state.pending.value = true
nuxt._asyncDataPromises[key] = Promise.resolve(handler(nuxt)).then((result) => {
for (const _key in result) {
state.data[_key] = unref(result[_key])
}
return state.data
}).finally(() => {
state.pending.value = false
nuxt._asyncDataPromises[key] = null
})
return nuxt._asyncDataPromises[key]
}
const fetchOnServer = options.server !== false
const clientOnly = options.server === false
// Server side
if (process.server && fetchOnServer) {
fetch()
}
// Client side
if (process.client) {
// 1. Hydration (server: true): no fetch
if (nuxt.isHydrating && options.server) {
pending.value = false
}
// 2. Initial load (server: false): fetch on mounted
if (nuxt.isHydrating && !options.server) {
// Fetch on mounted (initial load or deferred fetch)
onMountedCbs.push(fetch)
} else if (!nuxt.isHydrating) {
if (options.defer) {
// 3. Navigation (defer: true): fetch on mounted
onMountedCbs.push(fetch)
} else {
// 4. Navigation (defer: false): await fetch
await fetch()
}
}
// Watch handler
watch(handler.bind(null, nuxt), fetch)
// 1. Hydration (server: true): no fetch
if (nuxt.isHydrating && fetchOnServer) {
state.pending.value = false
}
// 2. Initial load (server: false): fetch on mounted
if (nuxt.isHydrating && clientOnly) {
// Fetch on mounted (initial load or deferred fetch)
onBeforeMountCbs.push(fetch)
} else if (!nuxt.isHydrating) { // Navigation
if (options.defer) {
// 3. Navigation (defer: true): fetch on mounted
onBeforeMountCbs.push(fetch)
} else {
// 4. Navigation (defer: false): await fetch
fetch()
}
}
}
// Server side
if (process.server && !clientOnly) {
await fetch()
}
return {
data: datastore,
pending,
refresh: fetch
// Auto enqueue if within nuxt component instance
if (nuxt._asyncDataPromises[key] && vm[NuxtComponentPendingPromises]) {
vm[NuxtComponentPendingPromises].push(nuxt._asyncDataPromises[key])
}
const res = Promise.resolve(nuxt._asyncDataPromises[key]).then(() => state) as AsyncDataResult<T>
res.data = state.data
res.pending = state.pending
res.fetch = fetch
return res
}
}
export function asyncData<T = Record<string, any>> (
handler: AsyncDataFn<T>,
options?: AsyncDataOptions
): Promise<AsyncDataObj<T>> {
return useAsyncData()(handler, options)
export function asyncData<T extends Record<string, any>> (
key: string, handler: AsyncDataFn<T>, options?: AsyncDataOptions
): AsyncDataResult<T> {
return useAsyncData()(key, handler, options)
}

View File

@ -0,0 +1,59 @@
import { ComponentInternalInstance, DefineComponent, defineComponent, getCurrentInstance } from 'vue'
export const NuxtComponentIndicator = '__nuxt_component'
export const NuxtComponentPendingPromises = '_pendingPromises'
export interface NuxtComponentInternalInstance extends ComponentInternalInstance {
[NuxtComponentPendingPromises]: Array<Promise<void>>
}
export function getCurrentNuxtComponentInstance (): NuxtComponentInternalInstance {
const vm = getCurrentInstance() as NuxtComponentInternalInstance
if (!vm || !vm.proxy.$options[NuxtComponentIndicator]) {
throw new Error('This method can only be used within a component defined with `defineNuxtComponent()`.')
}
return vm
}
export function enqueueNuxtComponent (p: Promise<void>) {
const vm = getCurrentNuxtComponentInstance()
vm[NuxtComponentPendingPromises].push(p)
}
export const defineNuxtComponent: typeof defineComponent =
function defineNuxtComponent (options: any): any {
const { setup } = options
if (!setup) {
return {
[NuxtComponentIndicator]: true,
...options
}
}
return {
[NuxtComponentIndicator]: true,
...options,
setup (props, ctx) {
const vm = getCurrentNuxtComponentInstance()
let promises = vm[NuxtComponentPendingPromises] = vm[NuxtComponentPendingPromises] || []
const res = setup(props, ctx)
if (!promises.length && !(res instanceof Promise)) {
return res
}
return Promise.resolve(res)
.then(() => Promise.all(promises))
.then(() => res)
.finally(() => {
promises.length = 0
promises = null
delete vm[NuxtComponentPendingPromises]
})
}
} as DefineComponent
}

View File

@ -1,5 +1,4 @@
import { getCurrentInstance, isReactive, reactive, UnwrapRef } from 'vue'
import { useNuxt } from '@nuxt/app'
export function ensureReactive<
@ -51,3 +50,12 @@ export function useData<T = Record<string, any>> (
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 = useNuxt()): Record<string, any> {
nuxt.payload.data = nuxt.payload.data || {}
return nuxt.payload.data
}

View File

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

View File

@ -13,6 +13,8 @@ export interface Nuxt {
[key: string]: any
_asyncDataPromises?: Record<string, Promise<any>>
ssrContext?: Record<string, any>
payload: {
serverRendered?: true

View File

@ -0,0 +1,37 @@
<template>
<div>
<nuxt-link to="/">
Home
</nuxt-link>
<h2>{{ $route.path }}</h2>
<pre>{{ foo }}</pre>
<pre>{{ bar }}</pre>
<pre>{{ from }}</pre>
</div>
</template>
<script>
import { defineNuxtComponent, asyncData } from 'nuxt/app/composables'
const waitFor = (ms = 0) => new Promise(resolve => setTimeout(resolve, ms))
export default defineNuxtComponent({
setup () {
const { data: foo } = asyncData('foo', async () => {
await waitFor(500)
return { foo: true }
})
const { data: bar } = asyncData('bar', async () => {
await waitFor(500)
return { bar: true }
})
const { data: from } = asyncData('from', () => ({ from: process.server ? 'server' : 'client' }))
return {
foo,
bar,
from
}
}
})
</script>

View File

@ -0,0 +1,39 @@
<template>
<div>
<nuxt-link to="/">
Home
</nuxt-link>
<h2>{{ $route.path }}</h2>
<pre>{{ foo }}</pre>
<pre>{{ bar }}</pre>
<pre>{{ from }}</pre>
</div>
</template>
<script>
import { useAsyncData } from 'nuxt/app/composables'
const waitFor = (ms = 0) => new Promise(resolve => setTimeout(resolve, ms))
export default {
async setup () {
const asyncData = useAsyncData()
const { data: foo } = await asyncData('foo', async () => {
await waitFor(500)
return { foo: true }
})
const { data: bar } = await asyncData('bar', async () => {
await waitFor(500)
return { bar: true }
})
const { data: from } = await asyncData('from', () => ({ from: process.server ? 'server' : 'client' }))
return {
foo,
bar,
from
}
}
}
</script>

View File

@ -1,5 +1,28 @@
<template>
<div>
Hello world
<h2>
Hello world
</h2>
<strong>Playground pages</strong>
<ul>
<li v-for="link of links" :key="link">
<nuxt-link :to="link">
{{ link }}
</nuxt-link>
</li>
</ul>
</div>
</template>
<script>
import { defineComponent } from 'vue'
import { useRouter } from 'vue-router'
export default defineComponent({
setup () {
const links = useRouter().getRoutes().filter(route => ['index', '404'].includes(route.name) === false).map(route => route.path)
return { links }
}
})
</script>

View File

@ -10,6 +10,9 @@
"types": [
"node",
"jest"
]
],
"paths": {
"nuxt/app/composables": ["./packages/app/src/composables"]
}
}
}