Bladeren bron

feat: defineAsyncComponent

close #12608
Evan You 3 jaren geleden
bovenliggende
commit
9d12106e21

+ 117 - 0
src/v3/apiAsyncComponent.ts

@@ -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
+    }
+  }
+}

+ 2 - 0
src/v3/index.ts

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

+ 241 - 0
test/unit/features/v3/apiAsyncComponent.spec.ts

@@ -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()
+  })
+})

+ 19 - 0
types/test/v3/define-async-component-test.tsx

@@ -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 - 0
types/v3-define-async-component.d.ts

@@ -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

+ 0 - 1
types/v3-define-component.d.ts

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