vue异步组件使用及加载失败重新加载

发布时间:2024年01月24日

引言

在构建大型单页应用时,组件的按需加载和延迟加载对于性能优化至关重要。Vue.js 提供了一种实现这个需求的方式,那就是异步组件。异步组件允许我们将组件的加载延迟到实际需要的时候,而不是一开始就全部加载。这不仅可以减少首屏加载时间,还可以提高应用的响应速度。本文将介绍Vue2中异步组件的概念、使用方法以及一些技巧,重点介绍一下高阶异步组件及高阶异步组件加载失败后,如何实现重新加载。

什么是异步组件

在Vue中,通常我们使用import语句来导入组件,然后在components选项中注册它们。这种方式会导致所有组件在应用程序初始化时都被加载,可能会影响应用的初始加载性能。异步组件的概念就是延迟加载组件,只有在需要时才进行加载,从而提高了应用的加载速度。
如何区别一个组件是同步组件还是异步组件呢?我们日常开发过程中,常见的那些组件引用哪些是异步组件,哪些不是呢?
下面我们来举例说明:

// Welcome.vue
import HelloWorld from './HelloWorld.vue'
export default {
	components: {HelloWorld},
	template: '<HelloWorld/>'
}

在上面的例子中我们定义了一个组件Welcome.vue,在该组件中引入了局部组件HelloWorld,对于Welcome而言,此处的HelloWorld组件是作为同步组件引入的。

如果想使用异步组件的形式引入,该如何修改上面的代码呢?

// Welcome.vue
//import HelloWorld from './HelloWorld.vue'
const HellowWold = ()=>import('./HelloWorld.vue')
export default {
	components: {HelloWorld},
	template: '<HelloWorld/>'
}

将组件改成上面的形式注册局部组件,此时就是注册了一个异步组件。组件自身的实现并没有所谓的同步异步一说,关键在于如何引入组件。

上面异步组件的引入形式,常见于vue-router中注册路由表时。这样能能让不同的路由独立打包,按需加载。

异步组件的几种书写形式

  1. 普通异步组件,通过vue内部曝露出的resolve方法返回对应的组件
    Vue.component('async-component', function(resolve, reject) {
      import('./AsyncComponent.vue').then((module) => {
        resolve(module.default);
      }).catch((error) => {
        reject(error);
      });
    });
    
  2. promise异步组件
    Vue.component('async-component',()=>import('./AsyncComponent.vue'))
    
  3. 高阶异步组件
    // 高级异步组件
    const AsyncComponent = () => ({
      // 需要加载的组件 (应该是一个 `Promise` 对象)
      component: import('./MyComponent.vue'),
      // 异步组件加载时使用的组件
      loading: LoadingComponent,
      // 加载失败时使用的组件
      error: ErrorComponent,
      // 展示加载时组件的延时时间。默认值是 200 (毫秒)
      delay: 200,
      // 如果提供了超时时间且组件加载也超时了,
      // 则使用加载失败时使用的组件。默认值是:`Infinity`
      timeout: 3000
    })
    

异步组件的应用场景

路由懒加载

在 Vue Router 中,我们可以使用异步组件来实现路由懒加载。懒加载是一种优化策略,它只加载当前需要显示的组件,而不是一次性加载所有的组件。这样就可以减少首屏加载时间,提高应用的响应速度。

我们可以将每个路由配置中的 component 选项替换为一个返回 Promise 的函数,这样 Vue Router 就会自动将这个组件注册为异步组件,并在需要的时候进行加载。例如:

const router = new VueRouter({
  routes: [
    { path: '/foo', component: () => import('./Foo.vue') }
  ]
})

加载联邦模块

模块联邦:(Module Federation)是一种软件架构模式,主要用于解决在复杂分布式系统中,不同模块间的依赖管理和共享问题。这种模式通过将大型应用拆分并独立开发、构建和部署各个模块,使得它们可以在运行时动态地加载和共享。
日常开发中,开发人员可能将A应用中的某一组件独立打包js,以中间件或者SDK的形式供其他应用使用。而B作为引用方,如何加载远程js,引入对应的组件呢?
下面以我在开发中的一个实际应用举例,介绍两种常规解决方法:
项目组中B应用有一个远程服务的js文件,其中打包了我们需要引入的组件。在我们自身项目中需引入该组件,之所以采用加载js,而非npm包的形式,主要是为了解耦,后续该组件的迭代更新都不影响我们自身应用,自身应用也无需发版即可使用最新版本的组件。
首先,为方便后续使用,我们先封装一个异步方法,用于加载和执行对应的js文件。加载执行js的方式大致有两种:
方法一:通过fetch请求获取远程js内容,然后通过eval函数执行对应的js文件。这种方式要考虑跨域问题和eval安全执行策略问题,有些浏览器禁止在非安全环境下执行eval函数。

fetch('path/to/remote/js/file.js')
        .then(response => response.text())
        .then(jsCode => {
          // 执行js
         new Function('return ' + jsCode)();
        })
        .catch(error => {
          console.error('Error loading remote component:', error);
        });

方法二:通过动态创建script标签来执行对应的js文件。这种方式比较稳妥,无需考虑跨域问题。

const script = document.createElement('script')
script.type = 'text/javascript'
script.onload = () => resolve(void 0)
script.onerror = (err) => {
  console.error(`资源加载失败:`, src)
  reject(err)
}
script.src = src
document.head.appendChild(script)

这里我们项目中采用第二种动态标签的形式,封装了一个类,主要用于加载远程js,便于后续使用。其代码简写如下:

import { Vue } from 'vue-property-decorator'
export class AsyncComponentLoader {
  static cache: Record<string, any> = {}
  static async loadAsyncComponent(src: string, cachable: boolean = false) {
    if (!src) throw new Error('无法加载远程组件:未传入远程组件加载地址')
    const name = 'remote_sdk_key' 
    // 引入缓存,避免重复加载
    if (cachable && AsyncComponentLoader.cache[name]) {
        ///远程组件js执行后,在window上挂载,后续的逻辑有赖于远程js如何打包的,具体问题具体分析
      Vue.component('RemoteComp',window[name]?.default)
      return
    } else {
      await AsyncComponentLoader.loadScript(src)
      Vue.component('RemoteComp',window[name]?.default)
      AsyncComponentLoader.cache[name] = window[name]
    }
  }

  static async loadScript(src) {
    // 加载远程js并执行
    return new Promise((resolve, reject) => {
      const script = document.createElement('script')
      script.type = 'text/javascript'
      script.onload = () => resolve(void 0)
      script.onerror = (err) => {
        console.error(`资源加载失败:`, src)
        reject(err)
      }
      script.src = src
      document.head.appendChild(script)
    })
  }
}
  • 同步引入
    创建一个新的Vue组件文件AsyncComponent,在AsyncComponent文件中,你可以编写一个方法来请求远程JS文件,并将其动态导入为组件。例如:
import { Vue, Component, Prop } from 'vue-property-decorator'
import { AsyncComponentLoader } from './AsyncComponentLoader'
@Component
export default class extends Vue {
  @Prop() src!: string
  // sdk加载成功标识
  sdkLoaded = false
  // sdk加载loading
  showLoading = false
  async created() {
  //加载组件js
    this.loadComponent()
  }

  async loadComponent() {
    this.showLoading = true
    AsyncComponentLoader.loadAsyncComponent(this.src, true)
      .then(() => {
        this.sdkLoaded = true
        this.$emit('onload')
        //强制父组件重新渲染
        this.$parent?.$forceUpdate()
      })
      .catch(() => {
        this.$emit('onerror')
      })
      .finally(() => {
        this.showLoading = false
      })
  }

  genError() {
    const h = this.$createElement
    const style = { height: '150px', cursor: 'pointer', backgroundColor: '#fff' }
    return (
        ///加载失败时显示
      <div style={style} onClick={this.loadComponent}>
        加载失败,点击重新加载
      </div>
    )
  }
  render() {
    const h = this.$createElement
    if (this.showLoading) {
        //加载loading
      const directives = [{ name: 'loading', value: this.showLoading }]
      return <div {...{ directives }} style="width: 100%;height: 150px"></div>
    }
    if (!this.sdkLoaded) {
      return this.genError()
    }
    return this.$slots.default
  }
}

在上面代码中定义了一个组件,该组件在created钩子函数中去加载远程js组件。在加载过程中,显示loading,加载成功后显示默认插槽内容,在插槽里,可以直接使用注册的远程组件,如果js加载失败,则显示对应的error,并支持点击重新加载。
使用方式:

<async-load src="path/to/remote/js/file.js">
    <RemoteComp :env="env"  />
</async-load>
  • 异步引入
    通过直接注册一个高阶异步组件的形式,来引入远程js组件
Vue.component('async-component', function() {
  // 显示loading状态
  const loadingComponent = {
    template: '<div>Loading...</div>'
  };

  // 显示error状态
  const errorComponent = {
    template: '<div>Error! Failed to load component.</div>'
  };

  return {
    loading: loadingComponent,
    error:errorComponent,
    component:  new Promise((resolve,reject)=>{
        // 加载远程组件的js文件
        const script = document.createElement('script');
        script.src = 'path_to_remote_component.js';
        script.onload = () => {
          // 注册远程组件
          resolve({
            template: '<div>远程组件的模板</div>',
            // 远程组件的其他配置
          });
        };
        script.onerror = (error) => {
          reject(error)
        };
        document.head.appendChild(script);
    }),
    delay: 0,
    timeout: 3000
  }
});

注册的异步组件可以直接使用,该组件使用方式:

<async-component></async-component>

无论成功还是失败,异步组件的加载只执行一次,之后就一直保留该状态。这也就意味着,当异步组件作为局部组件引入时,一旦加载失败,后续无论路由如何跳转,该异步组件也一直渲染的是errorComp。除非刷新整个页面,重新加载响应异步组件。但是很多时候,作为局部组件加载失败,我们只想重新加载失败的那部分,局部刷新,该如何做呢

异步组件加载失败异常处理

通常,异步组件作为路由组件,一个路由就是一个页面,这个时候加载失败时,我们一般都是刷新整个浏览器页面就行。但是针对上面我们说的,当一个异步组件作为页面的一部分渲染时,如果加载失败,如何只加载这部分,而不需要刷新整个浏览器页面也是很有必要的。
在vue的issue中,也有人提出了类似的问题: https://github.com/vuejs/vue/issues/8524
在这个问题中,vue的作者尤大给出的解决方案是强制父组件重新渲染。
在这里插入图片描述
根据以上回答,我做了以下尝试,将上面异步组件的errorComponent重新改了下,增加了刷新方法,调用强制刷新方法$forceUpdate,强制父组件刷新。

@Component
class ErrorComp extends Vue {
  async refresh() {
    // 强制父组件刷新,用以重新加载异步组件
    this.$parent?.$forceUpdate()
  }
  render() {
    const h = this.$createElement
    return (
      <div style="min-height:150px;padding: 10px"  onClick={this.refresh}>
        加载失败!请刷新重试
      </div>
    )
  }
}

然而,经实际代码试验,这种直接调用父实例刷新方法,并不能重新加载异步组件,对应的异步组件依然渲染为errorComponent。此时,我们就需要执行下调试,看看强制刷新后,为什么没有重新加载异步组件。
首先,代码执行到vue源码中的createComponent中,这是vue在解析组件类型标签中很重要的一个函数,不了解的可以看下相关源码。源码中的Ctor就是上面我们注册异步组件时Vue.component('async-component', function() {/**bula bula/})的第二个函数入参。

function createComponent (
  Ctor,
  data,
  context,
  children,
  tag
) {
  if (isUndef(Ctor)) {
    return
  }
  var baseCtor = context.$options._base;
  // plain options object: turn it into a constructor
  //异步组件这里Ctor是个函数,所以不走extend方法
  if (isObject(Ctor)) {
    Ctor = baseCtor.extend(Ctor);
  }

  // if at this stage it's not a constructor or an async component factory,
  // reject.
  if (typeof Ctor !== 'function') {
    if (process.env.NODE_ENV !== 'production') {
      warn(("Invalid Component definition: " + (String(Ctor))), context);
    }
    return
  }

  // async component
  var asyncFactory;
  //由于没有走extend方法,自然没有对应的cid,异步组件可以执行到这个里面
  if (isUndef(Ctor.cid)) {
    asyncFactory = Ctor; //在异步组件加载失败以后,可以在此处打上断点,然后点击重新加载,观察后续执行过程
    //这里重点要进入这个方法,这个方法决定了异步组件渲染的是loading组件还是远程组件或者error组件
    Ctor = resolveAsyncComponent(asyncFactory, baseCtor, context);
    if (Ctor === undefined) {
      // return a placeholder node for async component, which is rendered
      // as a comment node but preserves all the raw information for the node.
      // the information will be used for async server-rendering and hydration.
      return createAsyncPlaceholder(
        asyncFactory,
        data,
        context,
        children,
        tag
      )
    }
  }
//后续代码省略不看
  ……
}

在上述代码中我们重点关注resolveAsyncComponent方法的执行,在下面代码中,我们可以看到,方法在最开始的时候就对factory.error进行了判断。在我们首次执行异步组件加载时,由于加载失败,此时会将factory.error置为true。所以后续我们强制父组件重新渲染时,在这个函数里,第一行判断成功就直接返回了factory.errorComp组件,所以我们如果要重向第一次那样重新加载该远程组件,我们需要把函数里面所有的提前返回都杜绝调,以便于执行到最终的加载逻辑中。

function resolveAsyncComponent (
    factory,
    baseCtor
  ) {
  	//此处加载失败,直接返回了errorComp,如重新加载,将对应的factory.error设为false
    if (isTrue(factory.error) && isDef(factory.errorComp)) {
      return factory.errorComp
    }
	//由于是失败重新加载,factory.resolved无值,无需处理
    if (isDef(factory.resolved)) {
      return factory.resolved
    }

    var owner = currentRenderingInstance;
    if (owner && isDef(factory.owners) && factory.owners.indexOf(owner) === -1) {
      // already pending
      factory.owners.push(owner);
    }
	// 失败重新时,将loading重新置为false
    if (isTrue(factory.loading) && isDef(factory.loadingComp)) {
      return factory.loadingComp
    }
	//为了进入这个判断里面,这里将factory.owners置为null
    if (owner && !isDef(factory.owners)) {
      var owners = factory.owners = [owner];
      var sync = true;
      var timerLoading = null;
      var timerTimeout = null

      ;(owner).$on('hook:destroyed', function () { return remove(owners, owner); });

      var forceRender = function (renderCompleted) {
        for (var i = 0, l = owners.length; i < l; i++) {
          (owners[i]).$forceUpdate();
        }

        if (renderCompleted) {
          owners.length = 0;
          if (timerLoading !== null) {
            clearTimeout(timerLoading);
            timerLoading = null;
          }
          if (timerTimeout !== null) {
            clearTimeout(timerTimeout);
            timerTimeout = null;
          }
        }
      };

      var resolve = once(function (res) {
        // cache resolved
        factory.resolved = ensureCtor(res, baseCtor);
        // invoke callbacks only if this is not a synchronous resolve
        // (async resolves are shimmed as synchronous during SSR)
        if (!sync) {
          forceRender(true);
        } else {
          owners.length = 0;
        }
      });

      var reject = once(function (reason) {
        warn(
          "Failed to resolve async component: " + (String(factory)) +
          (reason ? ("\nReason: " + reason) : '')
        );
        if (isDef(factory.errorComp)) {
        //执行失败时,将factory.error设为true
          factory.error = true;
          forceRender(true);
        }
      });
	  //这里传入上面定义的resolve,reject方法
      var res = factory(resolve, reject);

      if (isObject(res)) {
        if (isPromise(res)) {
          // () => Promise
          if (isUndef(factory.resolved)) {
            res.then(resolve, reject);
          }
        } else if (isPromise(res.component)) {
          res.component.then(resolve, reject);

          if (isDef(res.error)) {
          //此处初始化factory.errorComp,这里的res.error就是我们先前定义高阶异步组件时,函数返回对象中的error
            factory.errorComp = ensureCtor(res.error, baseCtor);
          }

          if (isDef(res.loading)) {
            factory.loadingComp = ensureCtor(res.loading, baseCtor);
            if (res.delay === 0) {
              factory.loading = true;
            } else {
              timerLoading = setTimeout(function () {
                timerLoading = null;
                if (isUndef(factory.resolved) && isUndef(factory.error)) {
                  factory.loading = true;
                  forceRender(false);
                }
              }, res.delay || 200);
            }
          }

          if (isDef(res.timeout)) {
            timerTimeout = setTimeout(function () {
              timerTimeout = null;
              if (isUndef(factory.resolved)) {
                reject(
                  "timeout (" + (res.timeout) + "ms)"
                );
              }
            }, res.timeout);
          }
        }
      }

      sync = false;
      // return in case resolved synchronously
      return factory.loading
        ? factory.loadingComp
        : factory.resolved
    }
  }

根据上面源码中代码执行结果,重新调整刷新方法如下:

@Component
class ErrorComp extends Vue {
  async refresh() {
    // 异步组件加载失败后,在不刷新页面的情况下重新加载远程组件
    // 该方法hack了vue内部实现,非必要不使用,且依赖于vue源码中resolveAsyncComponent方法,需注意vue版本
    // @ts-ignore
    const asyncFactory: any = this.constructor.asyncFactory //注意这里的asyncFactory不是本身就有的,需要手动挂载。这里是为了统一封装errorComp组件,便于其他异步组件也可以使用该errorComp
    if (!asyncFactory) return window.location.reload()
    // 异步组件加载失败后,该标识为true,返回之前设置的error component,如需重新加载,需重设error
    asyncFactory.error = false
    // 重设loading,否则重新加载后返回的是loading component
    asyncFactory.loading = false
    // 重设实例,否则无法进入异步组件加载逻辑,不同版本的变量命名不同,应用里用的是2.6.10版本,变量为owners,2.5中为contexts
    asyncFactory.contexts = null
    asyncFactory.owners = null
    // 强制父组件刷新,用以重新加载异步组件
    this.$parent?.$forceUpdate()
  }
  render() {
    const h = this.$createElement
    return (
      <div style="min-height:150px;padding: 10px">
        加载失败!请
        <span style="cursor: pointer;color: #3693ff" onClick={this.refresh}>
          刷新
        </span>
        重试
      </div>
    )
  }
}

在上面代码中,在$forceUpdate方法之前执行了一些重置操作,用于清空异步组件的加载状态。这里需要强调一下,代码中的constructor.asyncFactory不是本身就有的,需要手动挂载。这里是为了统一封装errorComp组件,便于其他异步组件也可以使用该errorComp。至于后面的owners和contexts是由于不同版本的vue,在此处的实现略有不同,为了兼容性,这里两个变量值都重置了一下。至于如何挂载asyncFactory,以下面伪代码为例:

import ErrorComp from 'ErrorComp'
Vue.component('async-component', function asyncComponent() {
	const error  = ErrorComp.extend()
	//这里挂载一下,便于后面失败时重新加载引用
	error.asyncFactory = asyncComponent
	return {
		loading: loadingComp,
		error:error,
		component: new Promise((resolve,reject)=>{
		// ……
		})
	}
})

经试验,改造后的代码可以实现异步组件加载失败重新加载。如果有遇到相似问题的,可以参考下该方案。不过由于该方案有较强的侵入性,依赖于vue源码的内部实现,不同vue版本,在这方面的实现策略不同,可能会导致不同的执行效果,所以请注意使用的vue版本及其在这一块的实现细节。本文中对应的代码适应于vue 2.5-2.6,我并没有查询其他版本的vue源码,有兴趣的可以自行参考此方案做响应调整。

结语

通过使用Vue2中的异步组件,我们可以优化应用程序的性能和加载速度,提升用户体验。同时,合理地处理加载状态和错误情况,以及灵活地使用高阶异步组件和按需加载,可以让我们更好地利用异步组件的优势,为用户提供更好的应用体验。

希望本文对你理解Vue2中的异步组件及其使用技巧有所帮助!

参考文档

vue2.x异步组件: https://blog.windstone.cc/vue/source-study/component/async-component.html

文章来源:https://blog.csdn.net/Bekind2010/article/details/135813909
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。