React 中的 lazy 与 loadable-components 实现

使用 React.lazy 方法可以进行代码分割,配合 Suspend 可以实现异步加载组件期间显示 loading 效果,组件加载完毕后渲染显示内容的效果,那么 lazy 是如何实现的?与 loadable-components 又有什么不同?

下面是 React.lazy 与 loadable 的使用方式很简单,都需要使用 Suspend 进行包裹,从而使得异步组件加载时可以展现一个占位元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { lazy, Suspense } from 'react'
import { lazy as loadableLazy} from '@loadable/component'

const LazyChild = lazy(() => import('./Child'));
const LoadableChild = loadableLazy(() => import('./Child'))

function App() {

return (
<>
<div className='lazy'>
<Suspense fallback={<div>loading...</div>}>
<LazyChild/>
</Suspense>
</div>
<div className='loadable'>
<Suspense fallback={<div>loading...</div>}>
<LoadableChild/>
</Suspense>
</div>
</>
)
}

export default App

如果想要知道 React.lazy 的工作方法, 我们需要先知道 Suspend 是如何执行的。

在 React 中,Suspense 是一种用于处理异步操作的机制,特别是数据加载或代码拆分(code splitting)。它做的事情其实说起来很简单,就是判断自己的子元素中是否有处于 suspend 状态的,如果有,则渲染占位元素,待子组件全都不为 suspend 状态后,再渲染整个子组件树。那么问题的关键就在于 React 内部是如何判断子组件处在 suspend 状态的

当一个组件被包裹在 Suspense 中时,React 内部会通过以下几个步骤判断子组件是否处于 suspense 状态:

  1. 抛出 Promise
    当 React 渲染一个组件时,如果组件内部有异步操作(例如数据加载),这些操作通常会返回一个 Promise。如果这个 Promise 还没有解决(即处于 pending 状态),需要将这个组件抛出去。(抛出去?没错,就是使用 throw 方法)
  2. 捕获 Promise
    在组件渲染过程中,如果某个组件抛出了一个未解决的 Promise,React 会识别到这是一个异步操作,并且当前渲染无法完成。
  3. Suspense 边界
    React 会向上查找最近的 Suspense 边界。Suspense 边界是指被 Suspense 组件包裹的部分。当找到最近的 Suspense 边界后,React 会暂停该边界内的渲染,并显示 fallback(备用内容)。
  4. 触发重新渲染
    当捕获到 Promise 解决(resolved)或拒绝(rejected)时,Promise 会触发状态更新,React 会重新尝试渲染被暂停的组件。

在上面的例子中,Child 是一个异步加载的组件。当 Child 被加载时,lazy 方法需要 throw 一个 Promise,React 会捕获到这个异步操作,并在加载完成之前显示 Suspense 组件的 fallback 内容(即 Loading...)。

React.lazy

下面我们来看下 React.lazy 的具体实现。React.lazy 的代码在 React 源码的 packages/react/src/ReactLazy.js 文件中。我们一拉到底先看这个文件导出的 lazy 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export function lazy<T>(
ctor: () => Thenable<{default: T, ...}>,
): LazyComponent<T, Payload<T>> {
const payload: Payload<T> = {
// We use these fields to store the result.
_status: Uninitialized,
_result: ctor,
};

const lazyType: LazyComponent<T, Payload<T>> = {
$$typeof: REACT_LAZY_TYPE,
_payload: payload,
_init: lazyInitializer,
};

return lazyType;
}

这里 lazy 函数接收一个返回值为 Promise 的函数,返回一个 LazyComponentLazyComponent 的定义也在这个文件中,它其实是一个简单的对象:

1
2
3
4
5
6
export type LazyComponent<T, P> = {
$$typeof: symbol | number,
_payload: P,
_init: (payload: P) => T,
_debugInfo?: null | ReactDebugInfo,
};

这里 $$typeof 用来标记当前组件类型为 LazyComponent_payload 是当前组件的 state,用来保存组件的初始化状态与异步组件加载函数。

然后就是 _init 方法,React 内部判断当前组件为 LazyComponent 后会调用该方法初始化组件。我们可以看到这个方法对应了文件中的 lazyInitializer 函数(为了简单我只保留了该函数第一次被调用时的理想情况,因为整个过程是异步的,因此省略了很多边界判断。):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function lazyInitializer<T>(payload: Payload<T>): T {
const ctor = payload._result;
const thenable = ctor();
thenable.then(
moduleObject => {
const resolved: ResolvedPayload<T> = (payload: any);
resolved._status = Resolved;
resolved._result = moduleObject;
},
error => {
// 省略
},
);

const pending: PendingPayload = (payload: any);
pending._status = Pending;
pending._result = thenable;

throw payload._result;
}

在该函数第一次被调用时,会调用异步组件加载函数,并修改组件的状态。最后在异步组件还未加载完毕时,将异步函数加载组件返回的 Promise 抛出。

这个 throw 出的 Promise 会被上层的 Suspend 拦截,进入 fiber 的调度过程。等 Promise 完成时,触发重渲染,移除占位组件,渲染子组件。

loadable-components

(Loadable Components)[https://loadable-components.com/] 是一个第三方库,它也提供了类似 lazy 的方法(见上面的例子)。这里我们看她的另一个方法 loadable ,使用方法如下:

1
2
3
4
5
6
7
8
9
import loadable from '@loadable/component'
const OtherComponent = loadable(() => import('./OtherComponent'))
function MyComponent() {
return (
<div>
<OtherComponent />
</div>
)
}

注意这里 loadable 是不需要跟 Suspend 一起使用的,我们可以直接使用 fallback 参数来指定一个占位元素:

1
2
3
4
5
6
7
8
9
import loadable from '@loadable/component'
const OtherComponent = loadable(() => import('./OtherComponent'))
function MyComponent() {
return (
<div>
<OtherComponent fallback={<div>Loading...</div>} />
</div>
)
}

那么 loadable 该如何实现呢?其实比 React.lazy 更加简单。只需要在渲染时调用异步组件加载函数获取到 Promise,在 Promise 返回前,渲染占位元素。等待 Promise 返回后,触发状态变更,渲染异步组件即可。

我们打开 loadable 的实现:packages/component/src/createLoadable.js,可以看到 loadable 方法同样也返回了一个组件也就是其中的 InnerLoadable ,我们首先来看这个组件的 render 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
render() {
const {
forwardedRef,
fallback: propFallback,
__chunkExtractor,
...props
} = this.props
const { error, loading, result } = this.state

if (error) {
throw error
}

const fallback = propFallback || options.fallback || null

if (loading) {
return fallback
}

return render({
fallback,
result,
options,
props: { ...props, ref: forwardedRef },
})
}

可以看到这里如果组件处在 loading 状态,那么 render 函数就会返回 fallback 元素,否则调用外层的 render 方法,其内部会渲染加载好的异步组件。所关键就在两个事情:

  1. 调用异步组件加载函数。
  2. 修改 loading 变量。

在哪里调用异步组件加载函数呢?由于这是一个类组件,因此 loadable 将其放在了 componentDidMount 钩子函数中:

1
2
3
4
5
6
componentDidMount() {
this.mounted = true
if (this.state.loading) {
this.loadAsync()
}
}

loadAsync 方法内部会调用异步组件加载函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
loadAsync() {
const promise = this.resolveAsync()

promise
.then(loadedModule => {
const result = resolve(loadedModule, this.props, Loadable)
this.safeSetState(
{
result,
loading: false,
},
() => this.triggerOnLoad(),
)
})
.catch(error => this.safeSetState({ error, loading: false }))

return promise
}

同时我们可以看到在 promise resolve 后,直接将 loading 状态改为了 false 触发了组件的更新,进入到渲染子组件的部分,大功告成。

这里我们还可以有一个极简版的实现,看起来更直观一些:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import React, { useState, useEffect } from 'react';

// 模拟异步加载组件的函数
const loadComponent = () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(() => <div>异步加载的组件</div>);
}, 2000); // 模拟网络延迟
});
};

const AsyncComponentLoader = () => {
const [loading, setLoading] = useState(true);
const [Component, setComponent] = useState(null);

useEffect(() => {
let isMounted = true;

loadComponent().then((LoadedComponent) => {
if (isMounted) {
setComponent(() => LoadedComponent);
setLoading(false);
}
});

return () => {
isMounted = false;
};
}, []);

if (loading) {
return <div>加载中...</div>;
}

return <Component />;
};

性能?

可以看到 loadable components 的方法简单直接,没有用到 Suspend 内的调度,他们的性能会有差别吗?我们可以通过以下代码来测试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// App.jsx
import { lazy, Suspense } from 'react'
import loadable from '@loadable/component'

const LazyChild = lazy(() => import('./Child'));
const LoadableChild = loadable(() => import('./Child'))

function App() {

return (
<>
<div className='lazy'>
<h1>lazy</h1>
<Suspense fallback={<div>loading...</div>}>
<LazyChild/>
</Suspense>
</div>
<div className='loadable'>
<h1>loadable</h1>
<LoadableChild fallback={<div>loading...</div>}/>
</div>
</>
)
}

export default App

// Child.jsx
export default function Child() {
return <div>123123</div>
}

差距很细微,但多次渲染都可以发现 loadable 的优先级要高一些,当然这个差距可以忽略不记了,lazy 由于官方支持,有更多的功能和组合,还是更推荐用的。

Untitled.png