在构建大型单页应用时,组件的按需加载和延迟加载对于性能优化至关重要。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中注册路由表时。这样能能让不同的路由独立打包,按需加载。
Vue.component('async-component', function(resolve, reject) {
import('./AsyncComponent.vue').then((module) => {
resolve(module.default);
}).catch((error) => {
reject(error);
});
});
Vue.component('async-component',()=>import('./AsyncComponent.vue'))
// 高级异步组件
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)
})
}
}
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>
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