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
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 的函数,返回一个 LazyComponent,LazyComponent 的定义也在这个文件中,它其实是一个简单的对象:

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