feat: defineAsyncComponent

close #12608
This commit is contained in:
Evan You 2022-07-08 10:55:23 +08:00
parent 26ff4bc0ed
commit 9d12106e21
6 changed files with 405 additions and 1 deletions

117
src/v3/apiAsyncComponent.ts Normal file
View File

@ -0,0 +1,117 @@
import { warn, isFunction, isObject } from 'core/util'
interface AsyncComponentOptions {
loader: Function
loadingComponent?: any
errorComponent?: any
delay?: number
timeout?: number
suspensible?: boolean
onError?: (
error: Error,
retry: () => void,
fail: () => void,
attempts: number
) => any
}
type AsyncComponentFactory = () => {
component: Promise<any>
loading?: any
error?: any
delay?: number
timeout?: number
}
/**
* v3-compatible async component API.
* @internal the type is manually declared in <root>/types/v3-define-async-component.d.ts
* because it relies on existing manual types
*/
export function defineAsyncComponent(
source: (() => any) | AsyncComponentOptions
): AsyncComponentFactory {
if (isFunction(source)) {
source = { loader: source } as AsyncComponentOptions
}
const {
loader,
loadingComponent,
errorComponent,
delay = 200,
timeout, // undefined = never times out
suspensible = false, // in Vue 3 default is true
onError: userOnError
} = source
if (__DEV__ && suspensible) {
warn(
`The suspensiblbe option for async components is not supported in Vue2. It is ignored.`
)
}
let pendingRequest: Promise<any> | null = null
let retries = 0
const retry = () => {
retries++
pendingRequest = null
return load()
}
const load = (): Promise<any> => {
let thisRequest: Promise<any>
return (
pendingRequest ||
(thisRequest = pendingRequest =
loader()
.catch(err => {
err = err instanceof Error ? err : new Error(String(err))
if (userOnError) {
return new Promise((resolve, reject) => {
const userRetry = () => resolve(retry())
const userFail = () => reject(err)
userOnError(err, userRetry, userFail, retries + 1)
})
} else {
throw err
}
})
.then((comp: any) => {
if (thisRequest !== pendingRequest && pendingRequest) {
return pendingRequest
}
if (__DEV__ && !comp) {
warn(
`Async component loader resolved to undefined. ` +
`If you are using retry(), make sure to return its return value.`
)
}
// interop module default
if (
comp &&
(comp.__esModule || comp[Symbol.toStringTag] === 'Module')
) {
comp = comp.default
}
if (__DEV__ && comp && !isObject(comp) && !isFunction(comp)) {
throw new Error(`Invalid async component load result: ${comp}`)
}
return comp
}))
)
}
return () => {
const component = load()
return {
component,
delay,
timeout,
error: errorComponent,
loading: loadingComponent
}
}
}

View File

@ -87,4 +87,6 @@ export function defineComponent(options: any) {
return options
}
export { defineAsyncComponent } from './apiAsyncComponent'
export * from './apiLifecycle'

View File

@ -0,0 +1,241 @@
import Vue from 'vue'
import { defineAsyncComponent, h, ref, nextTick, defineComponent } from 'v3'
import { Component } from 'types/component'
const timeout = (n: number = 0) => new Promise(r => setTimeout(r, n))
const loadingComponent = defineComponent({
template: `<div>loading</div>`
})
const resolvedComponent = defineComponent({
template: `<div>resolved</div>`
})
describe('api: defineAsyncComponent', () => {
afterEach(() => {
Vue.config.errorHandler = undefined
})
test('simple usage', async () => {
let resolve: (comp: Component) => void
const Foo = defineAsyncComponent(
() =>
new Promise(r => {
resolve = r as any
})
)
const toggle = ref(true)
const vm = new Vue({
render: () => (toggle.value ? h(Foo) : null)
}).$mount()
expect(vm.$el.nodeType).toBe(8)
resolve!(resolvedComponent)
// first time resolve, wait for macro task since there are multiple
// microtasks / .then() calls
await timeout()
expect(vm.$el.innerHTML).toBe('resolved')
toggle.value = false
await nextTick()
expect(vm.$el.nodeType).toBe(8)
// already resolved component should update on nextTick
toggle.value = true
await nextTick()
expect(vm.$el.innerHTML).toBe('resolved')
})
test('with loading component', async () => {
let resolve: (comp: Component) => void
const Foo = defineAsyncComponent({
loader: () =>
new Promise(r => {
resolve = r as any
}),
loadingComponent,
delay: 1 // defaults to 200
})
const toggle = ref(true)
const vm = new Vue({
render: () => (toggle.value ? h(Foo) : null)
}).$mount()
// due to the delay, initial mount should be empty
expect(vm.$el.nodeType).toBe(8)
// loading show up after delay
await timeout(1)
expect(vm.$el.innerHTML).toBe('loading')
resolve!(resolvedComponent)
await timeout()
expect(vm.$el.innerHTML).toBe('resolved')
toggle.value = false
await nextTick()
expect(vm.$el.nodeType).toBe(8)
// already resolved component should update on nextTick without loading
// state
toggle.value = true
await nextTick()
expect(vm.$el.innerHTML).toBe('resolved')
})
test('error with error component', async () => {
let reject: (e: Error) => void
const Foo = defineAsyncComponent({
loader: () =>
new Promise((_resolve, _reject) => {
reject = _reject
}),
errorComponent: {
template: `<div>errored</div>`
}
})
const toggle = ref(true)
const vm = new Vue({
render: () => (toggle.value ? h(Foo) : null)
}).$mount()
expect(vm.$el.nodeType).toBe(8)
const err = new Error('errored')
reject!(err)
await timeout()
expect('Failed to resolve async').toHaveBeenWarned()
expect(vm.$el.innerHTML).toBe('errored')
toggle.value = false
await nextTick()
expect(vm.$el.nodeType).toBe(8)
})
test('retry (success)', async () => {
let loaderCallCount = 0
let resolve: (comp: Component) => void
let reject: (e: Error) => void
const Foo = defineAsyncComponent({
loader: () => {
loaderCallCount++
return new Promise((_resolve, _reject) => {
resolve = _resolve as any
reject = _reject
})
},
onError(error, retry, fail) {
if (error.message.match(/foo/)) {
retry()
} else {
fail()
}
}
})
const vm = new Vue({
render: () => h(Foo)
}).$mount()
expect(vm.$el.nodeType).toBe(8)
expect(loaderCallCount).toBe(1)
const err = new Error('foo')
reject!(err)
await timeout()
expect(loaderCallCount).toBe(2)
expect(vm.$el.nodeType).toBe(8)
// should render this time
resolve!(resolvedComponent)
await timeout()
expect(vm.$el.innerHTML).toBe('resolved')
})
test('retry (skipped)', async () => {
let loaderCallCount = 0
let reject: (e: Error) => void
const Foo = defineAsyncComponent({
loader: () => {
loaderCallCount++
return new Promise((_resolve, _reject) => {
reject = _reject
})
},
onError(error, retry, fail) {
if (error.message.match(/bar/)) {
retry()
} else {
fail()
}
}
})
const vm = new Vue({
render: () => h(Foo)
}).$mount()
expect(vm.$el.nodeType).toBe(8)
expect(loaderCallCount).toBe(1)
const err = new Error('foo')
reject!(err)
await timeout()
// should fail because retryWhen returns false
expect(loaderCallCount).toBe(1)
expect(vm.$el.nodeType).toBe(8)
expect('Failed to resolve async').toHaveBeenWarned()
})
test('retry (fail w/ max retry attempts)', async () => {
let loaderCallCount = 0
let reject: (e: Error) => void
const Foo = defineAsyncComponent({
loader: () => {
loaderCallCount++
return new Promise((_resolve, _reject) => {
reject = _reject
})
},
onError(error, retry, fail, attempts) {
if (error.message.match(/foo/) && attempts <= 1) {
retry()
} else {
fail()
}
}
})
const vm = new Vue({
render: () => h(Foo)
}).$mount()
expect(vm.$el.nodeType).toBe(8)
expect(loaderCallCount).toBe(1)
// first retry
const err = new Error('foo')
reject!(err)
await timeout()
expect(loaderCallCount).toBe(2)
expect(vm.$el.nodeType).toBe(8)
// 2nd retry, should fail due to reaching maxRetries
reject!(err)
await timeout()
expect(loaderCallCount).toBe(2)
expect(vm.$el.nodeType).toBe(8)
expect('Failed to resolve async').toHaveBeenWarned()
})
})

View File

@ -0,0 +1,19 @@
import { defineAsyncComponent } from '../../v3-define-async-component'
import { defineComponent } from '../../v3-define-component'
defineAsyncComponent(() => Promise.resolve({}))
// @ts-expect-error
defineAsyncComponent({})
defineAsyncComponent({
loader: () => Promise.resolve({}),
loadingComponent: defineComponent({}),
errorComponent: defineComponent({}),
delay: 123,
timeout: 3000,
onError(err, retry, fail, attempts) {
retry()
fail()
}
})

26
types/v3-define-async-component.d.ts vendored Normal file
View File

@ -0,0 +1,26 @@
import { AsyncComponent, Component } from './options'
export type AsyncComponentResolveResult<T = Component> = T | { default: T } // es modules
export type AsyncComponentLoader<T = any> = () => Promise<
AsyncComponentResolveResult<T>
>
export interface AsyncComponentOptions {
loader: AsyncComponentLoader
loadingComponent?: Component
errorComponent?: Component
delay?: number
timeout?: number
// suspensible?: boolean
onError?: (
error: Error,
retry: () => void,
fail: () => void,
attempts: number
) => any
}
export function defineAsyncComponent(
source: AsyncComponentLoader | AsyncComponentOptions
): AsyncComponent

View File

@ -1,4 +1,3 @@
import { Component } from '..'
import {
ComponentPropsOptions,
ExtractDefaultPropTypes,