React动态加载-@loadable-components源码解析

介绍

loadable-components是React动态加载的库,支持组件/库懒加载、动态引入。

对比 React.lazy

Library Suspense SSR Library Splitting 支持动态岛入import(./${value})
React.lazy ✅ (18)
@loadable/component

@loadable/component在React18未发布之前比较有优势,因为在React18之前使用动态分割需要自己实现lazyLoad高阶组件,Library分割和动态导入平常我也不怎么使用,且自己用import也能快速解决需求。所以@loadable/component在React18发布之后优势不那么大了,但是了解它的实现原理对提升技术还是比较有帮助的。

loadable-components的使用

lazy Component

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import loadable, {lazy} from '@loadable/component'
const Comp = loadable(() => import(`./Comp`))
function App() {
return (
<Comp />
)
}

// or
// use lazy with suspense
const Comp = lazy(() => import(`./Comp`))
function App() {
return (
<Suspense fallback='loading...'><Comp /></Suspense>
)
}

lazy library

1
2
3
4
5
6
7
8
const Moment = loadable.lib(() => import('moment'), {
resolveComponent: moment => moment.default || moment,
})
function App() {
return (
<Moment>{moment => moment().format('HH:mm')}</Moment>
)
}

服务端渲染时,在loadableReady之后进行hydrate/hydrateRoot并且需要引入@loadable/babel-plugin、@loadable/server和@loadable/webpack-plugin

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
// client.js
loadableReady(() => {
const root = document.getElementById('main')
hydrate(<App />, root)
})

// webpack.config
new LoadablePlugin({ filename: 'loadable-stats.json', writeToDisk: true })

// server.js
import { ChunkExtractor } from '@loadable/server'
const statsFile = path.resolve('./loadable-stats.json')
const nodeExtractor = new ChunkExtractor({ statsFile })
const { default: App } = nodeExtractor.requireEntrypoint()

const webExtractor = new ChunkExtractor({ statsFile })
const jsx = webExtractor.collectChunks(<App />)

const html = renderToString(jsx)

res.set('content-type', 'text/html')
res.send(`
<!DOCTYPE html>
<html>
<head>
${webExtractor.getLinkTags()}
${webExtractor.getStyleTags()}
</head>
<body>
<div id="main">${html}</div>
${webExtractor.getScriptTags()}
</body>
</html>
`)

源码解析

lazy Component

loadable源码结构大致如下,核心构造函数createLoadable返回loadable和lazy函数

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
const { loadable, lazy } = createLoadable({
render({ result: Component, props }) {
return <Component {...props} />
},
})
function createLoadable({
render,
onLoad,
}) {
function loadable(loadableConstructor, options = {}) {
function getCacheKey(props) {}
function resolve(module, props, Loadable){}
const cachedLoad = props => {}
class InnerLoadable extends React.Component {
constructor(props) {
super(props)

this.state = {
result: null,
error: null,
loading: true,
cacheKey: getCacheKey(props),
}
// server side
if (props.__chunkExtractor) {
// initial chunks
if (options.ssr === false) {
return
}
ctor.requireAsync(props).catch(() => null)
// initial chunks 直接require
this.loadSync()
props.__chunkExtractor.addChunk(ctor.chunkName(props))
return
}
// client side
if (
options.ssr !== false &&
// is ready - was loaded in this session
((ctor.isReady && ctor.isReady(props)) ||
// is ready - was loaded during SSR process
(ctor.chunkName &&
LOADABLE_SHARED.initialChunks[ctor.chunkName(props)]))
) {
// 假如是initialChunks直接加载,防止mismatch
this.loadSync()
}
}
render() {
if (options.suspense) {
const cachedPromise = this.getCache() || this.loadAsync()
if (cachedPromise.status === STATUS_PENDING) {
// throw 给外层的Suspense
throw this.loadAsync()
}
}
if (error) {
// throw to ErrorBoundry
throw error
}
const fallback = propFallback || options.fallback || null
if (loading) {
return fallback
}
return props.render()
}
}
const EnhancedInnerLoadable = withChunkExtractor(InnerLoadable);
const Loadable = React.forwardRef((props, ref) => (
// 转发forwardedRef
<EnhancedInnerLoadable forwardedRef={ref} {...props} />
))
return Loadable;
}

function lazy(ctor, options) {
// 添加suspense:true,InnerLoadable会throw promise给外层的Suspense组件
return loadable(ctor, { ...options, suspense: true })
}
return { loadable, lazy }
}

componentDidMount处理客户端异步加载和渲染loading fallback。

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
37
38
39
40
41
42
componentDidMount() {
this.mounted = true
// component might be resolved synchronously in the constructor
if (this.state.loading) {
this.loadAsync()
}
}
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.setState({ error, loading: false }))
return promise
}
render() {
if (options.suspense) {
// 如果suspense是true,throw promise,外层的Suspense接收
const cachedPromise = this.getCache() || this.loadAsync()
if (cachedPromise.status === STATUS_PENDING) {
throw this.loadAsync()
}
}
const fallback = propFallback || options.fallback || null
if (loading) {
return fallback
}
return render({
fallback,
result,
options,
props: { ...props, ref: forwardedRef },
})
}

lazy lib

相比于Component多了onLoad方法,支持让组件使用者调用通过ref.current获取库的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const { loadable, lazy } = createLoadable({
onLoad(result, props) {
// 这里的result就是import('moment')的返回值
if (result && props.forwardedRef) {
if (typeof props.forwardedRef === 'function') {
props.forwardedRef(result)
} else {
props.forwardedRef.current = result
}
}
},
render({ result, props }) {
if (props.children) {
// <Moment>{({ default: moment }) => moment(date).fromNow()}</Moment>
return props.children(result)
}
return null
},
})

重点是SSR相关的实现

loadableReady

在服务端渲染时,需要在loadableReady的callback进行hydrate,loadableReady的回调时机是在所有initial chunks加载完成之后,如何实现呢?

主要通过@loadable/webpack-plugin和@loadable/babel-plugin配合实现
@loadable/webpack-plugin
负责将chunkLoadingGlobal键名改为自定义的名称,输出stats file,该文件包含所有的chunk信息。

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
37
38
39
40
41
class LoadablePlugin {
constructor({
filename = 'loadable-stats.json',
path,
writeToDisk,
outputAsset = true,
chunkLoadingGlobal = '__LOADABLE_LOADED_CHUNKS__',
} = {}) {
this.opts = { filename, writeToDisk, outputAsset, path, chunkLoadingGlobal }
// The Webpack compiler instance
this.compiler = null
}
apply(compiler) {
this.compiler = compiler
const version = 'jsonpFunction' in compiler.options.output ? 4 : 5
// Add a custom chunk loading callback
compiler.options.output.chunkLoadingGlobal = this.opts.chunkLoadingGlobal
compiler.hooks.make.tap(name, compilation => {
compilation.hooks.processAssets.tap(
{
name,
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_REPORT,
},
() => {
const asset = this.handleEmit(compilation)
if (asset) {
// 输出stats file,里面包含了chunkGroup,SSR和loadaleReady都需要用到
compilation.emitAsset(this.opts.filename, asset)
}
},
)
})
}
handleEmit = compilation => {
const stats = compilation.getStats().toJson();
const result = JSON.stringify(stats, null, 2)
if (this.opts.writeToDisk) {
this.writeAssetsFile(result)
}
}
}

ChunkExtractorManager
ChunkExtractorManager负责抽取initialChunk,每个loadable组件由withChunkExtractor包裹,在组件构造函数中将该组件依赖的chunk添加到__chunkExtractor中

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
const ChunkExtractorManager = ({ extractor, children }) => (
<Context.Provider value={extractor}>{children}</Context.Provider>
)
const withChunkExtractor = Component => {
const LoadableWithChunkExtractor = props => (
<Context.Consumer>
{extractor => <Component __chunkExtractor={extractor} {...props} />}
</Context.Consumer>
)
return LoadableWithChunkExtractor
}
const EnhancedInnerLoadable = withChunkExtractor(InnerLoadable)
const Loadable = React.forwardRef((props, ref) => (
<EnhancedInnerLoadable forwardedRef={ref} {...props} />
))
class InnerLoadable extends React.Component {
constructor(props) {
super(props)
this.state = {}
// Server-side
if (props.__chunkExtractor) {
props.__chunkExtractor.addChunk(ctor.chunkName(props))
return
}
}
}

ctor.chunkName从哪里来?

@loadable/babel-plugin会将组件编译成如下代码,给组件添加chunkName、resolve、importAsync等等。

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
37
38
39
40
41
var A = Object(_loadable_component__WEBPACK_IMPORTED_MODULE_1__["default"])({
resolved: {},
chunkName: function chunkName() {
return "letters-A".replace(/[^a-zA-Z0-9_!§$()=\-^°]+/g, "-");
},
isReady: function isReady(props) {
var key = this.resolve(props);
if (this.resolved[key] !== true) {
return false;
}
if (true) {
return !!__webpack_require__.m[key];
}
return false;
},
importAsync: function importAsync() {
return Promise.all(/*! import() | letters-A */[__webpack_require__.e("vendors~letters-A~letters-B"), __webpack_require__.e("letters-A")]).then(__webpack_require__.bind(null, /*! ./letters/A */ "./src/client/letters/A.js"));
},
requireAsync: function requireAsync(props) {
var _this = this;
var key = this.resolve(props);
this.resolved[key] = false;
return this.importAsync(props).then(function (resolved) {
_this.resolved[key] = true;
return resolved;
});
},
requireSync: function requireSync(props) {
var id = this.resolve(props);
if (true) {
return __webpack_require__(id);
}
return eval('module.require')(id);
},
resolve: function resolve() {
if (true) {
return /*require.resolve*/(/*! ./letters/A */ "./src/client/letters/A.js");
}
return eval('require.resolve')("./letters/A");
}
});

chunks收集完了之后便可以通过extractor.getLinkTags()和extractor.getStyleTags()获取scripts和styles,写入html返回给浏览器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
app.get('*', (req, res) => {
const nodeExtractor = new ChunkExtractor({ statsFile: nodeStats })
const { default: App } = nodeExtractor.requireEntrypoint()

const webExtractor = new ChunkExtractor({ statsFile: webStats })
const jsx = webExtractor.collectChunks(<App />)

const html = renderToString(jsx)

res.set('content-type', 'text/html')
res.send(`
<!DOCTYPE html>
<html>
<head>
${webExtractor.getLinkTags()}
${webExtractor.getStyleTags()}
</head>
<body>
<div id="main">${html}</div>
${webExtractor.getScriptTags()}
</body>
</html>
`)
})

webExtractor.getScriptTags里包含的是main.js以及chunks string,chunks string后续会在loadableReady中用到。

1
2
`<script id="${id}" ${props}>${this.getRequiredChunksScriptContent()}</script>`,
`<script id="${id}_ext" ${props}>${this.getRequiredChunksNamesScriptContent()}</script>`,

loadableReady
获取Client依赖的chunks,重写window[chunkLoadingGlobal].push方法,对比当前window[chunkLoadingGlobal](已经加载的chunks)和 requiredChunks 判断依赖的chunks是否加载完成,完成之后执行loadableReady的callback

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
37
function loadableReady(
done = () => {},
{ namespace = '', chunkLoadingGlobal = '__LOADABLE_LOADED_CHUNKS__' } = {},
) {
let requiredChunks = null
if (!requiredChunks) {
done()
return Promise.resolve()
}

let resolved = false
return new Promise(resolve => {
window[chunkLoadingGlobal] = window[chunkLoadingGlobal] || []
const loadedChunks = window[chunkLoadingGlobal]
const originalPush = loadedChunks.push.bind(loadedChunks)

function checkReadyState() {
if (
requiredChunks.every(chunk =>
loadedChunks.some(([chunks]) => chunks.indexOf(chunk) > -1),
)
) {
if (!resolved) {
resolved = true
resolve()
}
}
}

loadedChunks.push = (...args) => {
originalPush(...args)
checkReadyState()
}

checkReadyState()
}).then(done)
}

总结

分析loadable-components的源码还是能收获不少东西的,也有一些值得深入的比如@loadable/babel-plugin如何实现,后续我还会分析React18 SSR Streaming以及Suspense的实现方式。