精读《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。同时,由于函数式组件无状态且无生命周期概念,我们在初始化函数式组件时选择性地复用有状态组件的初始化逻辑。

相关推荐
桂月二二4 小时前
探索前端开发中的 Web Vitals —— 提升用户体验的关键技术
前端·ux
CodeClimb5 小时前
【华为OD-E卷 - 第k个排列 100分(python、java、c++、js、c)】
java·javascript·c++·python·华为od
沈梦研5 小时前
【Vscode】Vscode不能执行vue脚本的原因及解决方法
ide·vue.js·vscode
hunter2062066 小时前
ubuntu向一个pc主机通过web发送数据,pc端通过工具直接查看收到的数据
linux·前端·ubuntu
qzhqbb6 小时前
web服务器 网站部署的架构
服务器·前端·架构
刻刻帝的海角6 小时前
CSS 颜色
前端·css
轻口味6 小时前
Vue.js 组件之间的通信模式
vue.js
九酒6 小时前
从UI稿到代码优化,看Trae AI 编辑器如何帮助开发者提效
前端·trae
浪浪山小白兔7 小时前
HTML5 新表单属性详解
前端·html·html5
lee5767 小时前
npm run dev 时直接打开Chrome浏览器
前端·chrome·npm