精读《Vuejs设计与实现》第 13 章(异步组件和函数式组件)

在第12章,我们深入探讨了组件的基本含义和实现方式。而在本章,我们将重点关注异步组件和函数式组件两个关键概念。

异步组件的特性在于它可以异步地加载和渲染组件,这在进行代码分割或服务端下发组件时尤其重要。

另一方面,函数式组件则允许我们用一个普通函数定义组件,并以该函数的返回值作为组件的渲染内容。函数式组件特点在于无状态、简洁且直观。

虽然在 Vue2 中,函数式组件相比于有状态组件有明显的性能优势,但在 Vue3 中,这两者的性能差距已非常小。

如 Vue.js RFC 所言,我们在 Vue3 中使用函数式组件主要是因为其简单易用,而非性能优越。

13.1 异步组件的问题与解决方法

从核心角度看,用户完全可以自主实现异步组件,而无需依赖框架。例如,App 组件的同步渲染如下:

javascript 复制代码
import App from 'App.vue'
createApp(App).mount('#app')

上述代码可以轻松地改为异步渲染:

javascript 复制代码
const loader = () => import('App.vue')
loader().then(App => {
  createApp(App).mount('#app')
})

此处,我们使用 import() 动态加载组件,返回一个 Promise 实例。组件加载成功后,使用 createApp 函数挂载,实现页面的异步渲染。

如果我们想要部分页面异步渲染,我们只需能够异步加载某个组件。假设下面的代码是 App.vue 组件:

vue 复制代码
<template>
  <CompA />
  <component :is="asyncComp" />
</template>

<script>
  import { shallowRef } from 'vue'
  import CompA from 'CompA.vue'

  export default {
    components: { CompA },
    setup() {
      const asyncComp = shallowRef(null)

      // 异步加载 CompB 组件
      import('CompB.vue').then(CompB => asyncComp.value = CompB)

      return {
        asyncComp
      }
    }
  }
</script>

此代码模板中,页面由同步渲染的 和动态组件构成,动态组件绑定了 asyncComp 变量。

脚本块异步加载 CompB 组件,加载成功后,设定 asyncComp 变量的值为 CompB,实现了 CompB 组件的异步加载和渲染。

虽然用户可以自定义异步组件,但其实现有复杂性,因为完整的异步组件设计包括以下考虑:

  1. 如果组件加载失败或超时,是否展示 Error 组件?
  2. 是否需要占位内容,例如 Loading 组件,于何时加载时展示?
  3. 是否设定延迟展示 Loading 组件的时间,避免由于组件加载过快导致的闪烁?
  4. 如果组件加载失败,是否需要重试?

为了更优雅地解决这些问题,我们需在框架层面为异步组件提供封装支持:

  1. 允许用户指定加载错误时的渲染组件。
  2. 允许用户指定 Loading 组件及其展示延迟。
  3. 允许用户设置组件加载超时时长。
  4. 提供组件加载失败后的重试功能。

总的来说,这些都是异步组件需要解决的核心问题。

13.2 异步组件的实现原理

3.2.1 封装 defineAsyncComponent 函数

异步组件基于封装理念设计,其目标是提供易用的接口以减轻用户使用难度。参考以下用户代码示例:

vue 复制代码
<template>
	<AsyncComp />
</template>

<script>
export default {
  components: {
    // 用 defineAsyncComponent 函数定义异步组件,接收一个加载器作为参数
    AsyncComp: defineAsyncComponent(() => import('CompA'))
  }
}
</script>

上述代码,通过 defineAsyncComponent 定义了异步组件,并在 components 组件选项中注册。

这样,异步组件可以像普通组件一样在模板中使用。这种使用 defineAsyncComponent 的方式比我们在 13.1 节中自行实现的异步组件更直观、简洁。

defineAsyncComponent 是一个高阶组件,它的基本实现如下:

javascript 复制代码
function defineAsyncComponent(loader) {
  // 存储异步加载的组件的变量
  let InnerComp = null
  // 返回一个包装组件
  return {
    name: 'AsyncComponentWrapper',
    setup() {
      // 异步组件加载成功的标记
      const loaded = ref(false)
      // 执行加载器函数,返回一个 Promise 实例
      // 加载成功后,将组件赋值给 InnerComp,并将 loaded 标记为 true
      loader().then(c => {
        InnerComp = c
        loaded.value = true
      })

      return () => {
        // 如果异步组件加载成功,则渲染该组件,否则渲染一个占位内容
        return loaded.value ? { type: InnerComp } : { type: Text, children: '' }
      }
    }
  }
}

关键点如下:

  1. defineAsyncComponent 本质是一个高阶组件,返回一个包装组件。
  2. 包装组件根据加载器的状态决定渲染内容。如果成功加载组件,渲染该组件;否则,渲染占位内容。
  3. 通常占位内容是注释节点。在组件未成功加载时,页面渲染一个注释节点作占位。但在这里,我们用一个空文本节点作为占位。

13.2.2 超时与 Error 组件

异步组件加载通常涉及网络请求,可能因网速慢导致加载时间过长,特别是在弱网环境下。

为了优化体验,我们可以允许用户设定超时时长。超过此时长仍未加载完成,则触发超时错误。如果用户配置了错误组件,此时则渲染错误组件。

为了实现这个功能,defineAsyncComponent 函数可以接受一个配置对象:

javascript 复制代码
const AsyncComp = defineAsyncComponent({
  loader: () => import('CompA.vue'), // 指定异步组件的加载器
  timeout: 2000, // 设定超时时间(ms)
  errorComponent: MyErrorComp // 指定在发生错误时要渲染的组件
})

有了用户接口后,我们可以给出实现代码:

javascript 复制代码
function defineAsyncComponent(options) {
  // options 可以是配置项,也可以是加载器
  if (typeof options === 'function') {
    // 如果 options 是加载器,则将其格式化为配置项形式
    options = {
      loader: options
    }
  }

  const { loader } = options

  let InnerComp = null

  return {
    name: 'AsyncComponentWrapper',
    setup() {
      const loaded = ref(false)
      // 代表是否超时,默认为 false,即没有超时
      const timeout = ref(false)

      loader().then(c => {
        InnerComp = c
        loaded.value = true
      })

      let timer = null
      if (options.timeout) {
        // 如果指定了超时时长,则开启一个定时器计时
        timer = setTimeout(() => {
          // 超时后将 timeout 设置为 true
          timeout.value = true
        }, options.timeout)
      }
      // 包装组件被卸载时清除定时器
      onUmounted(() => clearTimeout(timer))

      // 占位内容
      const placeholder = { type: Text, children: '' }

      return () => {
        if (loaded.value) {
          // 如果组件异步加载成功,则渲染被加载的组件
          return { type: InnerComp }
        } else if (timeout.value) {
          // 如果加载超时,并且用户指定了 Error 组件,则渲染该组件
          return options.errorComponent ? { type: options.errorComponent } : placeholder
        }
        return placeholder
      }
    }
  }
}

关键点如下:

  • 异步加载是否超时由 timeout.value 标志。
  • 开始加载组件的同时,启动一个定时器进行计时。如果超时,将 timeout.value 设置为 true。当包装组件被卸载时,需要清除定时器。
  • 包装组件根据 loaded 和 timeout 的值来决定渲染内容。如果加载成功,渲染被加载的组件;如果超时并且用户指定了错误组件,渲染错误组件。

为了更全面地处理异步组件加载过程中的错误(超时只是其中一种),我们希望为用户提供以下能力:

  1. 当错误发生时,将错误对象作为错误组件的 props 传递,以便用户做进一步处理。
  2. 除了超时,还能处理其他原因导致的加载错误,例如网络失败等。

为了达到这两个目标,我们需要对代码进行一些调整:

javascript 复制代码
function defineAsyncComponent(options) {
  if (typeof options === 'function') {
    options = {
      loader: options
    }
  }

  const { loader } = options

  let InnerComp = null

  return {
    name: 'AsyncComponentWrapper',
    setup() {
      const loaded = ref(false)
      // 定义 error,当错误发生时,用来存储错误对象
      const error = shallowRef(null)

      loader()
        .then(c => {
          InnerComp = c
          loaded.value = true
        })
        // 添加 catch 语句来捕获加载过程中的错误
        .catch(err => (error.value = err))

      let timer = null
      if (options.timeout) {
        timer = setTimeout(() => {
          // 超时后创建一个错误对象,并复制给 error.value
          const err = new Error(`Async component timed out after ${options.timeout}ms.`)
          error.value = err
        }, options.timeout)
      }

      const placeholder = { type: Text, children: '' }

      return () => {
        if (loaded.value) {
          return { type: InnerComp }
        } else if (error.value && options.errorComponent) {
          // 只有当错误存在且用户配置了 errorComponent 时才展示 Error 组件,同时将 error 作为 props 传递
          return { type: options.errorComponent, props: { error: error.value } }
        } else {
          return placeholder
        }
      }
    }
  }
}

上述代码,我们添加了对加载过程中的错误捕获。当加载超时时,我们创建一个新的错误对象。

在渲染组件时,如果存在 error.value 并且用户配置了错误组件,就直接渲染错误组件并将 error.value 传递作为 props。

这样,用户可以在他们的错误组件中接收错误对象,从而实现更精细的错误处理。

13.2.3 延迟与 Loading 组件

异步加载的组件因网络因素影响,加载速度可能较慢或非常快。

在网络环境良好的情况下,异步组件可能很快加载,这可能导致 Loading 组件刚出现就立即消失,导致页面闪烁,这对用户体验来说是不好的。

我们可以通过设置延迟展示 Loading 组件的时间来解决这个问题,比如在超过 200ms 后才展示 Loading 组件。以下是这种策略的实现方法。

首先,定义异步组件时,我们添加 delay 和 loadingComponent 两个选项:

javascript 复制代码
defineAsyncComponent({
  loader: () => new Promise(r => { /* ... */ }),
  delay: 200, // 延迟 200ms 展示 Loading 组件
  loadingComponent: { // Loading 组件
    setup() {
      return () => {
        return { type: 'h2', children: 'Loading...' }
      }
    }
  }
})

然后在 defineAsyncComponent 函数中实现这两个选项:

javascript 复制代码
function defineAsyncComponent(options) {
  if (typeof options === 'function') {
    options = {
      loader: options
    }
  }

  const { loader } = options

  let InnerComp = null

  return {
    name: 'AsyncComponentWrapper',
    setup() {
      const loaded = ref(false)
      const error = shallowRef(null)
      // 一个标志,代表是否正在加载,默认为 false
      const loading = ref(false)

      let loadingTimer = null
      // 如果配置项中存在 delay,则开启一个定时器计时,当延迟到时后将 loading.value 设置为 true
      if (options.delay) {
        loadingTimer = setTimeout(() => {
          loading.value = true
        }, options.delay)
      } else {
        // 如果配置项中没有 delay,则直接标记为加载中
        loading.value = true
      }
      loader()
        .then(c => {
          InnerComp = c
          loaded.value = true
        })
        .catch(err => (error.value = err))
        .finally(() => {
          loading.value = false
          // 加载完毕后,无论成功与否都要清除延迟定时器
          clearTimeout(loadingTimer)
        })

      let timer = null
      if (options.timeout) {
        timer = setTimeout(() => {
          const err = new Error(`Async component timed out after ${options.timeout}ms.`)
          error.value = err
        }, options.timeout)
      }

      const placeholder = { type: Text, children: '' }

      return () => {
        if (loaded.value) {
          return { type: InnerComp }
        } else if (error.value && options.errorComponent) {
          return { type: options.errorComponent, props: { error: error.value } }
        } else if (loading.value && options.loadingComponent) {
          // 如果异步组件正在加载,并且用户指定了 Loading 组件,则渲染 Loading 组件
          return { type: options.loadingComponent }
        } else {
          return placeholder
        }
      }
    }
  }
}

关键改动:

  • 新增了一个状态 loading,代表是否正在加载。
  • 如果用户设置了 delay,则在该延迟时间后,如果仍未加载完成,就将 loading 设置为 true。
  • 不论加载是否成功,都要将 loading 设置为 false,并清除定时器。
  • 在渲染函数中,如果正在加载且用户设置了 loadingComponent ,就渲染该组件。

此外,我们需要支持 loadingComponent 的卸载。因此需要更新 unmount 函数,使其能够卸载组件实例的渲染内容(即 subTree ):

javascript 复制代码
function unmount(vnode) {
  if (vnode.type === Fragment) {
    vnode.children.forEach(c => unmount(c))
    return
  } else if (typeof vnode.type === 'object') {
    // 对于组件的卸载,本质上是卸载其所渲染的内容
    unmount(vnode.component.subTree)
    return
  }
  
  const parent = vnode.el.parentNode
  if (parent) {
    parent.removeChild(vnode.el)
  }
}

组件的卸载,本质上是要卸载组件所渲染的内容,即 subTree。

所以在上面的代码中,我们通过组件实例的 vnode.component 属性得到组件实例,再递归地调用 unmount 函数完成 vnode.component.subTree 的卸载。

13.2.4 重试机制

重试机制在异步操作(如网络请求)中非常常见。当异步加载组件失败,尤其是在网络不稳定的情况下,我们希望提供一种方式让加载操作可以自动重试,这有助于提升用户体验。

首先,我们创建一个模拟网络请求的 fetch 函数,该函数在1秒后返回一个错误。

javascript 复制代码
function fetch() {
  return new Promise((resolve, reject) => {
    // 请求在1秒后失败
    setTimeout(() => {
      reject('err')
    }, 1000);
  })
}

接着,创建一个 load 函数来实现请求失败后的重试:

javascript 复制代码
function load(onError) {
  const p = fetch() // 发起请求,得到 Promise 实例
  return p.catch(err => {
    return new Promise((resolve, reject) => {
      const retry = () => resolve(load(onError)) // 重试
      const fail = () => reject(err) // 失败
      onError(retry, fail) // 当错误发生时,调用 onError 回调
    })
  })
}

用户可以使用 load 函数进行资源加载,并在发生错误时重试:

javascript 复制代码
load(
  (retry) => {
    retry() // 失败后重试
  }
).then(res => {
  console.log(res) // 成功
})

最后,我们可以将这个重试机制应用到异步组件加载中:

javascript 复制代码
function defineAsyncComponent(options) {
  if (typeof options === 'function') {
    options = {
      loader: options
    }
  }

  const { loader } = options

  let InnerComp = null

  // 记录重试次数
  let retries = 0
  // 封装 load 函数用来加载异步组件
  function load() {
    return (
      loader()
        // 捕获加载器的错误
        .catch(err => {
          // 如果用户指定了 onError 回调,则将控制权交给用户
          if (options.onError) {
            // 返回一个新的 Promise 实例
            return new Promise((resolve, reject) => {
              // 重试
              const retry = () => {
                resolve(load())
                retries++
              }
              // 失败
              const fail = () => reject(err)
              // 作为 onError 回调函数的参数,让用户来决定下一步怎么做
              options.onError(retry, fail, retries)
            })
          } else {
            throw error
          }
        })
    )
  }

  return {
    name: 'AsyncComponentWrapper',
    setup() {
      const loaded = ref(false)
      const error = shallowRef(null)
      const loading = ref(false)

      let loadingTimer = null
      if (options.delay) {
        loadingTimer = setTimeout(() => {
          loading.value = true
        }, options.delay)
      } else {
        loading.value = true
      }
      // 调用 load 函数加载组件
      load()
        .then(c => {
          InnerComp = c
          loaded.value = true
        })
        .catch(err => {
          error.value = err
        })
        .finally(() => {
          loading.value = false
          clearTimeout(loadingTimer)
        })

      // 省略部分代码
    }
  }
}

这段代码与接口请求的重试机制很类似。在异步组件加载过程中,如果加载失败,我们提供一个新的 Promise 实例,并将重试(retry)和失败(fail)的处理函数作为 onError 回调的参数,让用户决定下一步怎么做。

13.3 函数式组件

函数式组件简单易实现,其本质是一个返回虚拟 DOM 的函数。

在 Vue3 中,函数式组件的优点主要在于其简洁性,而非性能。即使是有状态组件,在 Vue3 中,其初始化的性能消耗也相对较低。

定义函数式组件如下:

javascript 复制代码
function MyFuncComp(props) {
  return { type: 'h1', children: props.title }
}

// 定义 props
MyFuncComp.props = {
  title: String
}

函数式组件无自身状态,但仍能接收外部传入的 props。对于 props 的定义,我们在组件函数上添加静态的 props 属性。

我们可以复用 mountComponent 函数来实现函数式组件的挂载,此外需要支持函数类型的 vnode.type。这可以在 patch 函数内实现:

javascript 复制代码
function patch(n1, n2, container, anchor) {
  if (n1 && n1.type !== n2.type) {
    unmount(n1)
    n1 = null
  }

  const { type } = n2

  if (typeof type === 'string') {
    // 省略部分代码
  } else if (type === Text) {
    // 省略部分代码
  } else if (type === Fragment) {
    // 省略部分代码
  } else if (
    // type 是对象 --> 有状态组件
    // type 是函数 --> 函数式组件
    typeof type === 'object' ||
    typeof type === 'function'
  ) {
    // component
    if (!n1) {
      mountComponent(n2, container, anchor)
    } else {
      patchComponent(n1, n2, anchor)
    }
  }
}

vnode.type 的类型用以判断组件类型:对象类型表示有状态组件,函数类型则表示函数式组件。

不论是哪种类型,都可以通过 mountComponent 完成挂载和通过 patchComponent 完成更新。

函数式组件的挂载可以在 mountComponent 函数中实现:

javascript 复制代码
function mountComponent(vnode, container, anchor) {
  // 检查是否是函数式组件
  const isFunctional = typeof vnode.type === 'function'
  let componentOptions = vnode.type
  if (isFunctional) {
    // 如果是函数式组件,则将 vnode.type 作为渲染函数,将 vnode.type.props 作为 props 选项定义
    componentOptions = {
      render: vnode.type,
      props: vnode.type.props
    }
  }
  // 省略部分代码
}

在 mountComponent 函数内,如果组件类型是函数式组件,则直接将组件函数作为组件选项对象的 render 选项,同时将组件函数的静态 props 属性作为组件的 props 选项。

从这里可以看出,由于函数式组件无需初始化 data 和生命周期钩子,其初始化性能消耗会比有状态组件少。

13.4 总结

本章首先深入探讨了异步组件的作用和需求。异步组件在提高页面性能、进行包裹分解和服务端下发组件等场景中发挥了重要作用。尽管异步组件的实现可以完全在用户层面完成,但为考虑到加载失败、加载中的显示、超时设定以及重试机制等复杂问题,框架的内置支持成为了必要。因此,Vue3 提供了 defineAsyncComponent 函数来定义异步组件。

我们接着讨论了异步组件加载超时和加载错误的处理。通过给 defineAsyncComponent 函数指定参数,我们可以设置超时时间并指定错误发生时展示的组件。

考虑到网络状况的不稳定,加载异步组件可能耗时较长。为了提供更好的用户体验,我们需要在加载时展示 Loading 组件。同时,我们提供了 loadingComponent 选项和 delay 选项,允许用户自定义 Loading 组件并设置展示的延迟时间,避免 Loading 组件的闪烁问题。

在面对加载错误时,我们设计了重试机制。这种处理方式与处理接口请求错误时的重试机制相似。

最后,我们讨论了函数式组件。它只是一个返回虚拟 DOM 的函数,它的实现可以复用有状态组件的逻辑。对于函数式组件,我们在主函数上添加静态 props 属性来定义 props。同时,由于函数式组件无状态且无生命周期概念,我们在初始化函数式组件时选择性地复用有状态组件的初始化逻辑。

相关推荐
neter.asia3 分钟前
vue中如何关闭eslint检测?
前端·javascript·vue.js
~甲壳虫4 分钟前
说说webpack中常见的Plugin?解决了什么问题?
前端·webpack·node.js
十一吖i22 分钟前
前端将后端返回的文件下载到本地
vue.js·elementplus
光影少年23 分钟前
vue2与vue3的全局通信插件,如何实现自定义的插件
前端·javascript·vue.js
As977_24 分钟前
前端学习Day12 CSS盒子的定位(相对定位篇“附练习”)
前端·css·学习
susu108301891126 分钟前
vue3 css的样式如果background没有,如何覆盖有background的样式
前端·css
Ocean☾28 分钟前
前端基础-html-注册界面
前端·算法·html
Rattenking28 分钟前
React 源码学习01 ---- React.Children.map 的实现与应用
javascript·学习·react.js
Dragon Wu30 分钟前
前端 Canvas 绘画 总结
前端
CodeToGym34 分钟前
Webpack性能优化指南:从构建到部署的全方位策略
前端·webpack·性能优化