介绍
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 /> ) }
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
| loadableReady(() => { const root = document.getElementById('main') hydrate(<App />, root) })
new LoadablePlugin({ filename: 'loadable-stats.json', writeToDisk: true })
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), } if (props.__chunkExtractor) { if (options.ssr === false) { return } ctor.requireAsync(props).catch(() => null) this.loadSync() props.__chunkExtractor.addChunk(ctor.chunkName(props)) return } if ( options.ssr !== false && ((ctor.isReady && ctor.isReady(props)) || (ctor.chunkName && LOADABLE_SHARED.initialChunks[ctor.chunkName(props)])) ) { this.loadSync() } } render() { if (options.suspense) { const cachedPromise = this.getCache() || this.loadAsync() if (cachedPromise.status === STATUS_PENDING) { throw this.loadAsync() } } if (error) { 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) => ( <EnhancedInnerLoadable forwardedRef={ref} {...props} /> )) return Loadable; }
function lazy(ctor, options) { 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 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) { 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) { if (result && props.forwardedRef) { if (typeof props.forwardedRef === 'function') { props.forwardedRef(result) } else { props.forwardedRef.current = result } } }, render({ result, props }) { if (props.children) { 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 } this.compiler = null } apply(compiler) { this.compiler = compiler const version = 'jsonpFunction' in compiler.options.output ? 4 : 5 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) { 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 = {} 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([__webpack_require__.e("vendors~letters-A~letters-B"), __webpack_require__.e("letters-A")]).then(__webpack_require__.bind(null, "./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 ( "./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的实现方式。