主页
手机版
扫描查看手机站
所在位置:首页 → 教程资讯 → vue3的defineAsyncComponent是如何实现异步组件的呢?

vue3的defineAsyncComponent是如何实现异步组件的呢?

发布: 更新时间:2024-08-13 09:50:57

前言

在上一篇
给我5分钟,保证教会你在vue3中动态加载远程组件
文章中,我们通过

defineAsyncComponent

实现了动态加载远程组件。这篇文章我们将通过debug源码的方式来带你搞清楚

defineAsyncComponent

是如何实现异步组件的。注:本文使用的vue版本为

3.4.19

欧阳写了一本开源电子书
vue3编译原理揭秘
,这本书初中级前端能看懂。完全免费,只求一个star。

看个demo

还是一样的套路,我们来看个

defineAsyncComponent

异步组件的demo。

本地子组件

local-child.vue

代码如下:

<template>
  <p>我是本地组件</p>
</template>

异步子组件

async-child.vue

代码如下:

<template>
  <p>我是异步组件</p>
</template>

父组件

index.vue

代码如下:

<template>
  <LocalChild />
  <button @click="showAsyncChild = true">load async child</button>
  <AsyncChild v-if="showAsyncChild" />
</template>

<script setup lang="ts">
import { defineAsyncComponent, ref } from "vue";
import LocalChild from "./local-child.vue";

const AsyncChild = defineAsyncComponent(() => import("./async-child.vue"));
const showAsyncChild = ref(false);
</script>

我们这里有两个子组件,第一个

local-child.vue

,他和我们平时使用的组件一样,没什么说的。

第二个子组件是

async-child.vue

,在父组件中我们没有像普通组件

local-child.vue

那样在最上面import导入,而是在

defineAsyncComponent

接收的回调函数中去动态import导入

async-child.vue

文件,这样定义的

AsyncChild

组件就是异步组件。

在template中可以看到,只有当点击

load async child

按钮后才会加载异步组件

AsyncChild

我们先来看看执行效果,如下gif图:

从上面的gif图可以看到,当我们点击

load async child

按钮后,在network面板中才会去加载异步组件

async-child.vue


defineAsyncComponent

除了像上面这样直接接收一个返回Promise的回调函数之外,还可以接收一个对象作为参数。demo代码如下:

const AsyncComp = defineAsyncComponent({
  // 加载函数
  loader: () => import('./async-child.vue'),

  // 加载异步组件时使用的组件
  loadingComponent: LoadingComponent,
  // 展示加载组件前的延迟时间,默认为 200ms
  delay: 200,

  // 加载失败后展示的组件
  errorComponent: ErrorComponent,
  // 如果提供了一个 timeout 时间限制,并超时了
  // 也会显示这里配置的报错组件,默认值是:Infinity
  timeout: 3000
})

其中对象参数有几个字段:


  • loader

    字段其实对应的就是前面那种写法中的回调函数。


  • loadingComponent

    为加载异步组件期间要显示的loading组件。


  • delay

    为显示loading组件的延迟时间,默认200ms。这是因为在网络状况较好时,加载完成得很快,加载组件和最终组件之间的替换太快可能产生闪烁,反而影响用户感受。


  • errorComponent

    为加载失败后显示的组件。


  • timeout

    为超时时间。

在接下来的源码分析中,我们还是以前面那个接收一个返回Promise的回调函数为例子进行debug调试源码。

开始打断点

我们在浏览器中接着来看父组件

index.vue

编译后的代码,很简单,在浏览器中可以像vscode一样使用command(windows中是control)+p就可以唤起一个输入框,然后在输入框中输入

index.vue

点击回车就可以在source面板中打开编译后的

index.vue

文件了。如下图:

我们看到编译后的

index.vue

文件代码如下:

import { defineComponent as _defineComponent } from "/node_modules/.vite/deps/vue.js?v=868545d8";
import {
  defineAsyncComponent,
  ref,
} from "/node_modules/.vite/deps/vue.js?v=868545d8";
import LocalChild from "/src/components/defineAsyncComponentDemo/local-child.vue?t=1723193310324";
const _sfc_main = _defineComponent({
  __name: "index",
  setup(__props, { expose: __expose }) {
    __expose();
    const showAsyncChild = ref(false);
    const AsyncChild = defineAsyncComponent(() =>
      import("/src/components/defineAsyncComponentDemo/async-child.vue")
    );
    const __returned__ = { showAsyncChild, AsyncChild, LocalChild };
    return __returned__;
  },
});

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
  // ...省略
}

export default _export_sfc(_sfc_main, [["render", _sfc_render]]);

从上面的代码可以看到编译后的

index.vue

主要分为两块,第一块为

_sfc_main

对象中的

setup

方法,对应的是我们的

script

模块。第二块为

_sfc_render

,也就是我们常说的render函数,对应的是template中的内容。

我们想要搞清楚

defineAsyncComponent

方法的原理,那么当然是给setup方法中的

defineAsyncComponent

方法打断点。刷新页面,此时代码将会停留在断点

defineAsyncComponent

方法处。


defineAsyncComponent

方法

然后将断点走进

defineAsyncComponent

函数内部,在我们这个场景中简化后的

defineAsyncComponent

函数代码如下:

function defineAsyncComponent(source) {
  if (isFunction(source)) {
    source = { loader: source };
  }
  const { loader, loadingComponent, errorComponent, delay = 200 } = source;
  let resolvedComp;

  const load = () => {
    return loader()
      .catch(() => {
        // ...省略
      })
      .then((comp) => {
        if (
          comp &&
          (comp.__esModule || comp[Symbol.toStringTag] === "Module")
        ) {
          comp = comp.default;
        }
        resolvedComp = comp;
        return comp;
      });
  };

  return defineComponent({
    name: "AsyncComponentWrapper",
    setup() {
      const instance = currentInstance;
      const loaded = ref(false);
      const error = ref();
      const delayed = ref(!!delay);
      if (delay) {
        setTimeout(() => {
          delayed.value = false;
        }, delay);
      }
      load()
        .then(() => {
          loaded.value = true;
        })
        .catch((err) => {
          onError(err);
          error.value = err;
        });
      return () => {
        if (loaded.value && resolvedComp) {
          return createInnerComp(resolvedComp, instance);
        } else if (error.value && errorComponent) {
          return createVNode(errorComponent, {
            error: error.value,
          });
        } else if (loadingComponent && !delayed.value) {
          return createVNode(loadingComponent);
        }
      };
    },
  });
}

从上面的代码可以看到

defineAsyncComponent

分为三部分。

  • 第一部分为:处理传入的参数。

  • 第二部分为:

    load

    函数用于加载异步组件。

  • 第三部分为:返回

    defineComponent

    定义的组件。

第一部分:处理传入的参数

我们看第一部分:处理传入的参数。代码如下:

function defineAsyncComponent(source) {
  if (isFunction(source)) {
    source = { loader: source };
  }
  const { loader, loadingComponent, errorComponent, delay = 200 } = source;
  let resolvedComp;
  // ...省略
}

首先使用

isFunction(source)

判断传入的

source

是不是函数,如果是函数,那么就将

source

重写为包含

loader

字段的对象:

source = { loader: source }

。然后使用

const { loader, loadingComponent, errorComponent, delay = 200 } = source

解构出对应的loading组件、加载失败组件、延时时间。

看到这里我想你应该明白了为什么

defineAsyncComponent

函数接收的参数可以是一个回调函数,也可以是包含

loader



loadingComponent



errorComponent

等字段的对象。因为如果我们传入的是回调函数,在内部会将传入的回调函数赋值给

loader

字段。不过loading组件、加载失败组件等参数不会有值,只有

delay

延时时间默认给了200。

接着就是定义了

load

函数用于加载异步组件,这个函数是在第三部分的

defineComponent

中调用的,所以我们先来讲

defineComponent

函数部分。

第三部分:返回defineComponent定义的组件

我们来看看

defineAsyncComponent

的返回值,是一个

defineComponent

定义的组件,代码如下:

function defineAsyncComponent(source) {
  // ...省略

  return defineComponent({
    name: "AsyncComponentWrapper",
    setup() {
      const instance = currentInstance;
      const loaded = ref(false);
      const error = ref();
      const delayed = ref(!!delay);
      if (delay) {
        setTimeout(() => {
          delayed.value = false;
        }, delay);
      }
      load()
        .then(() => {
          loaded.value = true;
        })
        .catch((err) => {
          onError(err);
          error.value = err;
        });
      return () => {
        if (loaded.value && resolvedComp) {
          return createInnerComp(resolvedComp, instance);
        } else if (error.value && errorComponent) {
          return createVNode(errorComponent, {
            error: error.value,
          });
        } else if (loadingComponent && !delayed.value) {
          return createVNode(loadingComponent);
        }
      };
    },
  });
}


defineComponent

函数的接收的参数是一个vue组件对象,返回值也是一个vue组件对象。他其实没有做什么事情,单纯的只是提供ts的类型推导。

我们接着来看vue组件对象,对象中只有两个字段:

name

属性和

setup

函数。


name

属性大家都很熟悉,表示当前vue组件的名称。

大家平时

<script setup>

语法糖用的比较多,这个语法糖经过编译后就是

setup

函数,当然vue也支持让我们自己手写

setup

函数。

提个问题:

setup

函数对应的是

<script setup>

,我们平时写代码都有template模块对应的是视图部分,也就是熟悉的render函数。为什么这里没有render函数呢?



setup

函数打个断点,当渲染异步组件时会去执行这个

setup

函数。代码将会停留在

setup

函数的断点处。



setup

函数中首先使用

ref

定义了三个响应式变量:

loaded



error



delayed


  • loaded

    是一个布尔值,作用是记录异步组件是否加载完成。


  • error

    记录的是加载失败时记录的错误信息,如果同时传入了

    errorComponent

    组件,在加载异步组件失败时就会显示

    errorComponent

    组件。


  • delayed

    也是一个布尔值,由于loading组件不是立马就显示的,而是延时一段时间后再显示。这个

    delayed

    布尔值记录的是是当前是否还在延时阶段,如果是延时阶段那么就不显示loading组件。

接下来判断传入的参数中设置设置了

delay

延迟,如果是就使用

setTimeout

延时

delay

毫秒才将

delayed

的值设置为false,当

delayed

的值为false后,在loading阶段才会去显示loading组件。代码如下:

if (delay) {
  setTimeout(() => {
    delayed.value = false;
  }, delay);
}

接下来就是执行

load

函数,这个

load

函数就是我们前面说的

defineAsyncComponent

函数中的第二部分代码。代码如下:

load()
  .then(() => {
    loaded.value = true;
  })
  .catch((err) => {
    onError(err);
    error.value = err;
  });

从上面的代码可以看到

load

函数明显返回的是一个Promise,所以才可以在后面使用

.then()



.catch()

。并且这里在

.then()

中将

loaded

的值设置为true,将断点走进

load

函数,代码如下:

const load = () => {
  return loader()
    .catch(() => {
      // ...省略
    })
    .then((comp) => {
      if (
        comp &&
        (comp.__esModule || comp[Symbol.toStringTag] === "Module")
      ) {
        comp = comp.default;
      }
      resolvedComp = comp;
      return comp;
    });
};

这里的

load

函数代码也很简单,在里面直接执行

loader

函数。还记得这个

loader

函数是什么吗?


defineAsyncComponent

函数可以接收一个异步加载函数,这个异步加载函数可以在运行时去import导入组件。这个异步加载函数就是这里的

loader

函数,执行

loader

函数就会去加载异步组件。在我们这里是异步加载

async-child.vue

组件,代码如下:

const AsyncChild = defineAsyncComponent(() => import("./async-child.vue"));

所以这里执行

loader

函数就是在执行

() => import("./async-child.vue")

,执行了

import()

后就可以在

network

面板看到加载

async-child.vue

文件的网络请求。

import()

返回的是一个Promise,等import的文件加载完了后就会触发Promise的

then()

,所以这里的

then()

在此时不会触发。

接着将断点走出

load

函数回到

setup

函数的最后一个return部分,代码如下:

setup() {
  // ...省略
  return () => {
    if (loaded.value && resolvedComp) {
      return createInnerComp(resolvedComp, instance);
    } else if (error.value && errorComponent) {
      return createVNode(errorComponent, {
        error: error.value,
      });
    } else if (loadingComponent && !delayed.value) {
      return createVNode(loadingComponent);
    }
  };
},

注意看,这里的

setup

的返回值是一个函数,不是我们经常看见的对象。由于这里返回的是函数,此时代码将不会走到返回的函数里面去,给return的函数打个断点。我们暂时先不看函数中的内容,让断点走出

setup

函数。发现

setup

函数是由vue中的

setupStatefulComponent

函数调用的,在我们这个场景中简化后的

setupStatefulComponent

函数代码如下:

function setupStatefulComponent(instance) {
  const Component = instance.type;
  const { setup } = Component;
  const setupResult = callWithErrorHandling(setup, instance, 0, [
    instance.props,
    setupContext,
  ]);
  handleSetupResult(instance, setupResult);
}

上面的

callWithErrorHandling

函数从名字你应该就能看出来,调用一个函数并且进行错误处理。在这里就是调用

setup

函数,然后将调用

setup

函数的返回值丢给

handleSetupResult

函数处理。

将断点走进

handleSetupResult

函数,在我们这个场景中

handleSetupResult

函数简化后的代码如下:

function handleSetupResult(instance, setupResult) {
  if (isFunction(setupResult)) {
    instance.render = setupResult;
  }
}

在前面我们讲过了我们这个场景

setup

函数的返回值是一个函数,所以

isFunction(setupResult)

的值为true。代码将会走到

instance.render = setupResult

,这里的

instance

是当前vue组件实例,执行这个后就会将

setupResult

赋值给

render

函数。

我们知道render函数一般是由template模块编译而来的,执行render函数就会生成虚拟DOM,最后由虚拟DOM生成对应的真实DOM。



setup

的返回值是一个函数时,这个函数就会作为组件的render函数。这也就是为什么前面

defineComponent

中只有

name

熟悉和

setup

函数,却没有

render

函数。

在执行render函数生成虚拟DOM时就会去执行

setup

返回的函数,由于我们前面给返回的函数打了一个断点,所以代码将会停留在

setup

返回的函数中。回顾一下

setup

返回的函数代码如下:

setup() {
  // ...省略
  return () => {
    if (loaded.value && resolvedComp) {
      return createInnerComp(resolvedComp, instance);
    } else if (error.value && errorComponent) {
      return createVNode(errorComponent, {
        error: error.value,
      });
    } else if (loadingComponent && !delayed.value) {
      return createVNode(loadingComponent);
    }
  };
},

由于此时还没将异步组件加载完,所以

loaded

的值也是false,此时代码不会走进第一个

if

中。

同样的组件都还没加载完也不会有error,代码也不会走到第一个

else if

中。

如果我们传入了loading组件,此时代码也不会走到第二个

else if

中。因为此时的

delayed

的值还是true,代表还在延时阶段。只有等到前面

setTimeout

的回调执行后才会将

delayed

的值设置为false。

并且由于

delayed

是一个ref响应式变量,所以在

setTimeout

的回调中改变了

delayed

的值就会重新渲染,也就是再次执行render函数。前面讲了这里的render函数就是

setup

中返回的函数,代码就会重新走到第二个

else if

中。

此时

else if (loadingComponent && !delayed.value)

,其中的

loadingComponent

是loading组件,并且

delayed.value

的值也是false了。代码就会走到

createVNode(loadingComponent)

中,执行这个函数就会将loading组件渲染到页面上。

加载异步组件

前面我们讲过了在渲染异步组件时会执行

load

函数,在里面其实就是执行

() => import("./async-child.vue")

加载异步组件

async-child.vue

,我们也可以在network面板中看到多了一个

async-child.vue

文件的请求。

我们知道

import()

的返回值是一个Promise,当文件加载完成后就会触发Promise的

then()

。此时代码将会走到第一个

then()

中,回忆一下代码:

const load = () => {
  return loader()
    .catch(() => {
      // ...省略
    })
    .then((comp) => {
      if (
        comp &&
        (comp.__esModule || comp[Symbol.toStringTag] === "Module")
      ) {
        comp = comp.default;
      }
      resolvedComp = comp;
      return comp;
    });
};



then()

中判断加载进来的文件是不是一个es6的模块,如果是就将模块的

default

导出重写到

comp

组件对象中。并且将加载进来的vue组件对象赋值给

resolvedComp

变量。

执行完第一个

then()

后代码将会走到第二个

then()

中,回忆一下代码:

load()
  .then(() => {
    loaded.value = true;
  })

第二个

then()

代码很简单,将

loaded

变量的值设置为true,也就是标明已经将异步组件加载完啦。由于

loaded

是一个响应式变量,改变他的值就会导致页面重新渲染,将会再次执行render函数。前面我们讲了这里的render函数就是

setup

中返回的函数,代码就会重新走到第二个

else if

中。

再来回顾一下

setup

中返回的函数,代码如下:

setup() {
  // ...省略
  return () => {
    if (loaded.value && resolvedComp) {
      return createInnerComp(resolvedComp, instance);
    } else if (error.value && errorComponent) {
      return createVNode(errorComponent, {
        error: error.value,
      });
    } else if (loadingComponent && !delayed.value) {
      return createVNode(loadingComponent);
    }
  };
},

由于此时

loaded

的值为true,并且

resolvedComp

的值为异步加载vue组件对象,所以这次render函数返回的虚拟DOM将是

createInnerComp(resolvedComp, instance)

的执行结果。

createInnerComp函数

接着将断点走进

createInnerComp

函数,在我们这个场景中简化后的代码如下:

function createInnerComp(comp, parent) {
  const { ref: ref2, props, children } = parent.vnode;
  const vnode = createVNode(comp, props, children);
  vnode.ref = ref2;
  return vnode;
}


createInnerComp

函数接收两个参数,第一个参数为要异步加载的vue组件对象。第二个参数为使用

defineAsyncComponent

创建的vue组件对应的vue实例。

然后就是执行

createVNode

函数,这个函数大家可能有所耳闻,vue提供的

h()

函数其实就是调用的

createVNode

函数。

在我们这里

createVNode

函数接收的第一个参数为子组件对象,第二个参数为要传给子组件的props,第三个参数为要传给子组件的children。

createVNode

函数会根据这三个参数生成对应的异步组件的虚拟DOM,将生成的异步组件的虚拟DOM进行return返回,最后就是根据虚拟DOM生成真实DOM将异步组件渲染到页面上。如下图(图后还有一个总结):

总结

本文讲了

defineAsyncComponent

是如何实现异步组件的:



  • defineAsyncComponent

    函数中会返回一个vue组件对象,对象中只有

    name

    属性和

    setup

    函数。

  • 当渲染异步组件时会执行

    setup

    函数,在

    setup

    函数中会执行内置的一个

    load

    方法。在

    load

    方法中会去执行由

    defineAsyncComponent

    定义的异步组件加载函数,这个加载函数的返回值是一个Promise,异步组件加载完成后就会触发Promise的

    then()



  • setup

    函数中会返回一个函数,这个函数将会是组件的render函数。

  • 当异步组件加载完了后会走到前面说的Promise的

    then()

    方法中,在里面会将

    loaded

    响应式变量的值修改为true。

  • 修改了响应式变量的值导致页面重新渲染,然后执行render函数。前面讲过了此时的render函数是

    setup

    函数中会返回的回调函数。执行这个回调函数会调用

    createInnerComp

    函数生成异步组件的虚拟DOM,最后就是根据虚拟DOM生成真实DOM,从而将异步子组件渲染到页面上。

关注公众号:【前端欧阳】,给自己一个进阶vue的机会

另外欧阳写了一本开源电子书
vue3编译原理揭秘
,这本书初中级前端能看懂。完全免费,只求一个star。

软件上新 查看更多