单页应用代码分割

单页面代码分割

目前单页应用的 bundle.js 一般都会很大,这样的话会对启动时间造成影响。为此我们需要合理拆分 js 文件,然后在需要使用的时候才动态记在。
拆分 js 文件,我们通常会采用手段:

  • 基于业务逻辑和依赖库分割
  • 基于路由分割
  • 基于组件分割

左图所示是基于路由进行分割,有图为基于组件进行分割。基于路由分割的力度比较大,一个路由里会嵌套很多组件。每个路由 react 组件还是有很多,但并不是每个组件都会被立刻使用。同时,多个路由之间还有可能会产生很多重复的组件代码。这显然不如基于组件进行分割效果较好。

代码分割案例

假如,我们有个页面,页面的额外信息 extra 组件是不需要的,只有点击查看额外信息按钮后才会出现。我们对 extra 进行惰性加载。代码如下:

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
import React, {Component} from 'react';
class App extends Component {
constructor(prop) {
super(prop);
this.state = {
showExtra: false,
CExtra: {}
};
}
handleOpenExtra() {
import('./components/extra').then(res => {
this.setState({
showExtra: true,
CExtra: res.default
})
});
}
render() {
return (
<React.Fragment>
{this.state.showExtra ? <this.state.CExtra /> : null}
<div>...</div>
<div onClick={this.handleOpenExtra.bind(this)}>点击查看额外信息</div>
</React.Fragment>
);
}
};
export default App;

demo 中我们利用了 webpack 分包加载功能,当点击按钮时利用 import 来加载 extra 组件,而不是在开头直接引入。

动态导入(dynamic imports)

上述案例,我们借助 import 函数来引入 extra 包。webpack 在打包的时候会自动检测这样的 import 引入的文件,为其单独生成一个打包 js 文件。如图所示:

当我们点击加载按钮后,红线处的 js 包会被异步载入

webpack 实现动态导如的代码如下:

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
__webpack_require__.e = function requireEnsure(chunkId) {
var promises = [];
// JSONP chunk loading for javascript
var installedChunkData = installedChunks[chunkId];
if(installedChunkData !== 0) { // 0 means "already installed".
// a Promise means "currently loading".
if(installedChunkData) {
promises.push(installedChunkData[2]);
} else {
// 每次需要进行异步加载chunk时,会将这个chunk的加载状态进行初始化为一个数组,并以key/value的形式保存在installedChunks里
var promise = new Promise(function(resolve, reject) {
installedChunkData = installedChunks[chunkId] = [resolve, reject];
});
promises.push(installedChunkData[2] = promise);
// 开始异步 chunk 加载
var head = document.getElementsByTagName('head')[0];
var script = document.createElement('script');
var onScriptComplete;
script.charset = 'utf-8';
script.timeout = 120;
if (__webpack_require__.nc) {
script.setAttribute("nonce", __webpack_require__.nc);
}
script.src = jsonpScriptSrc(chunkId); // js 文件名
onScriptComplete = function (event) { // 加载完毕回调
// avoid mem leaks in IE.
script.onerror = script.onload = null;
clearTimeout(timeout);
var chunk = installedChunks[chunkId];
if(chunk !== 0) {
if(chunk) {
var errorType = event && (event.type === 'load' ? 'missing' : event.type);
var realSrc = event && event.target && event.target.src;
var error = new Error('Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')');
error.type = errorType;
error.request = realSrc;
chunk[1](error);
}
installedChunks[chunkId] = undefined;
}
};
var timeout = setTimeout(function(){
onScriptComplete({ type: 'timeout', target: script });
}, 120000);
script.onerror = script.onload = onScriptComplete;
head.appendChild(script);
}
}
return Promise.all(promises);
};

注意,使用 import 导入这种形式的话,我们需要使用 dynamic-import 包:

npm install –save-dev @babel/plugin-syntax-dynamic-import

1
2
3
{
"plugins": ["@babel/plugin-syntax-dynamic-import"]
}

或者你也可以使用 webpack 提供的 require.ensure 来代替。

react-loadable

上述代码,我们粗略的实现了一个分包加载的demo,业务上我们还要有很多问题来处理。比如:import 失败怎么办?加载前需要 loading 占位怎么做?服务端渲染怎么做?
这里介绍一个库 react-loadable。他会把我们要异步加载的组件封装成一个高阶组件供我们使用
例如,我们异步加载 extra 组件,并且引入一个 loading 作为加载占位,代码如下:

1
2
3
4
5
6
7
8
const LoadableComponent = Loadable({
loader: () => import('./components/extra'),
loading: () => {
return (
<div>组件加载中</div>
);
}
});

使用的时候我们直接在恰当的时机渲染 LoadableComponent 组件即可。
除此之外,react-loadable 还提供了预加载,服务端渲染等功能。这里就不详细介绍使用了,主要我们看一下源码实现的过程。

源码解析

函数入口

react-loadable 本质上就是一个高阶组件封装的过程。代码入口如下:

1
2
3
function Loadable(opts) {
return createLoadableComponent(load, opts);
}

Loadable 接受我们传入的参数 opts, 再调用了createLoadableComponent函数,传入了 load 函数和 opts。

load 函数

这里我们先来看一下 load 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function load(loader) {
let promise = loader();
let state = {
loading: true,
loaded: null, // 组件实例
error: null
};
state.promise = promise
.then(loaded => {
state.loading = false;
state.loaded = loaded;
return loaded;
})
.catch(err => {
state.loading = false;
state.error = err;
throw err;
});
return state;
}

load 函数里初始化了一个state对象,执行 loader 方法(即我们传入的 loader 参数 (import(…))) 来加载异步组件。之后将返回结果保存在 state 的 loaded 属性中,加载状态保存在其他属性上。返回这个 state 供后续使用。

createLoadableComponent

接下来,开始分析整个代码的主体部分 createLoadableComponent 函数。其作用就是包装加载后的异步组件,返回一个高阶组件供我们使用。代码如下:

首先检测必须含有 loading 组件,给参数添加默认值等。然后初始化一个 init 方法,用于执行上文的 load 函数以加载异步组件,并记录状态给 res 变量。之后就是返回 LoadableComponent 组件的过程。
在 LoadableComponent 中,发生如下情况:

  1. constructor 阶段:调用 init 方法,保存状态到 state 上
  2. componentWillMount 阶段:
    • 设置 this._mounted 状态默认为 ture 表示加载完成
    • 进行一些判断,如果 res.loading 为 false,说明之前的 init 执行出错,直接return
    • 如果opts.delay 和 opts.timeout有值,且为number属性的话,就加个定时器用来延迟显示 loading 组件(避免闪烁)
    • 声明 update 函数,用来根据异步组件加载的结果更新 state 值
    • 如果 this.state.loading 或者 this.state.error 为 true,就是整体状态是正在加载或者出错了,就用 React.createElement 生成出loading的过渡组件
    • 如果 this.state.loaded 有值了,说明传入的loader的promise异步操作执行完成,就开始渲染真正的组件,调用opts.render方法

大致过程就是这样,不贴代码了。还有一些细节,对 webpack 等处理,preload 静态方法等,也就一两句话,看看就知道了。

React.lazy

在 React 16.0.0 中,加入了 React.lazy 方法渲染动态导入为常规组件(尚不能用在服务端)。

a5.png

主要用法如下:

1
2
3
4
5
6
7
8
const OtherComponent = React.lazy(() => import('./OtherComponent'));
function MyComponent() {
return (
<div>
<OtherComponent />
</div>
);
}

当渲染此组件时,这将自动加载包含 OtherComponent 包。如果我们需要加载时的 Loading 组件做为
过度使用。我们需要调用 Suspense 组件。

1
2
3
4
5
6
7
8
9
10
11
const OtherComponent = React.lazy(() => import('./OtherComponent'));
function MyComponent() {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<OtherComponent />
</Suspense>
</div>
);
}

fallback prop(属性) 接受在等待加载组件时要渲染的任何 React 元素。

异步加载时机

想这么一个问题,如果 extra 包很大,我们点击打开 open 按钮后,要花很长一段时间才能看到组件内容。这样同样也不是什么好的体验。甚至,如果我们加载的包里面逻辑复杂,js 运行时间过长会阻塞掉用户的交互。这样要如何处理呢?

Idle Until Urgent (空闲等待 & 紧急优先)

点击 extra 肯定是延后操作,我们希望在主要任务都完成后,如果浏览器有空闲时间就来预加载组件(空闲等待)。如果我们点击打开按钮,这时候组件还没有加载完毕,那么我们就立刻加载组件(紧急优先)。
实现该操作,我们可以借助 requestIdleCallback 函数。改造代码如下:

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
import React, {Component} from 'react';
let rIc = window.requestIdleCallback;
let cIc = window.cancelIdleCallback;
class App extends Component {
constructor(prop) {
super(prop);
this.state = {
showExtra: false
};
// 开始空闲加载
this.idleHandle = rIC(this.loadComponent.bind(this));
}
// 打开 extra 列表
async handleOpenExtra() {
if (!this.CExtra) {
// 如果没有加载好,则取消空闲加载,直接进入加载阶段
cIC(this.idleHandle);
await this.loadComponent();
}
this.setState({
showExtra: true;
});
}
// 异步加载内容
async loadComponent() {
let result = await import('./components/extra').then();
this.CExtra = result;
}
render() {
return (
<React.Fragment>
{this.state.showExtra ? <this.CExtra.default /> : null}
<div>...</div>
<div onClick={this.handleOpenExtra.bind(this)}>点击查看额外信息</div>
</React.Fragment>
);
}
};
export default App;

当然,你也可以用 react-loadable 来实现该例子,简要改动如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
let LoadableMyComponent = Loadable({
loader: () => import('./components/extra'),
...
})
class App extends Component{
constructor() {
//...
this.idleHandle = rIC(() => {
LoadableMyComponent.preload()
});
}
//...
}

这种空闲执行的方案,同样使用于执行那些不是很紧急但是耗时很长的函数。例如处理大量数据集,localStorage 中获取数据等等。合理利用 requestIdleCallback 可以减少网页的阻塞,提高效果。

空闲任务队列

上面技术适用于可以通过单个函数计算出来的属性,但在某些情况下,逻辑可能无法写到单个函数里,或者,即使技术上可行,您仍然希望将其拆分为更小的一些函数,以免其长时间阻塞主线程。 这里提供一个 IdleQueue 你可以在里面找到利用 requestIdleCallback 的姿势。

mobx 是否需要拆分

通常我们在写业务的时候,会把 store 写在 <Provider> 上直直接注入。那我们需要对 mobx 的 store 也异步加载呢?理论上没有这个必要,因为 store 里面的内容确实一般不会很大并不是造成包体积过大的原因。优化效果不太明显。
当然如果非要异步加载的话,我这里有个想法,我们将 store 和 action 拆分出来。store 里面只包含数据:

1
2
3
4
5
6
7
class IndexStore {
@observable list = [];
}
class ExtraStore {
@observable extra = [];
}

store 依然在全局在 Provider 注入,每个业务的 action 单独抽离成一个文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
class IndexAction {
constructor(store) {
this.store = store;
}
@action getList() {
this.store.list = [
1, 2, 3, 4
];
}
@action addItem() {
this.store.list.push(this.list.length + 1);
}
}

action 们单独在一个全局变量中维护,需要异步加载的时候如下:

1
2
3
4
5
6
// actions 为一个全局变量
import('../../actions/extra').then(res => {
let ExtraAction = res && res.__esModule ? res.default : res;
actions.extra = new ExtraAction(self.props.rootStore.extraStore);
// ....
});

参考资料