按需加载是前端性能优化中的一项重要措施,指的是根据当前页面的需要,只加载相应的必需资源。
要实现按需加载,首先需要进行代码分割。通过 Webpack 这样的工具,我们可以按模块将相应的代码打包到一个文件中,从而实现代码分割。
不过,今天并不是讨论如果使用 Webpack 进行代码分割,而是在 React+Redux 项目中,我们如何通过动态导入分离路由和相应的 Redux 模块。
准备一个基本项目
直接使用 create-react-app 初始化一个 React 项目,安装 react-router 和 redux。
接着创建两个路由页面,一个主页和一个登录页面,主页中有一个按钮可以跳转到登录页面。
// index.js
import React from 'react';
import ReactDOM from 'react-dom';
import RouterConfig from './router';
ReactDOM.render(<RouterConfig />, document.getElementById('root'));
// router.js
import React from 'react';
import { BrowserRouter as Router, Switch, Route } from 'react-router-dom';
import Home from './route/Home';
import Login from './route/Login';
function RouterConfig() {
return (
<Router>
<Switch>
<Route exact path="/" component={Home} />
<Route path="/login" component={Login} />
</Switch>
</Router>
);
}
export default RouterConfig;
// route/Home.js
import React from 'react';
import { Link } from 'react-router-dom';
export default () => {
return (
<div>
<h1>主页</h1>
<Link to="/login">登录页面</Link>
</div>
);
}
// route/Login.js
import React from 'react';
export default () => {
return (
<h1>登录页面</h1>
);
}
此时执行 yarn build
操作,主页和登录页面的代码会打包到一个文件中。
分割路由页面
通过动态导入路由组件分割路由页面从而实现按需加载,Webpack 内置了该功能。此外,还需要使用 @babel/syntax-dynamic-import 插件提供语法支持,对应的 babel 配置如下:
{
"presets": [
"react-app"
],
"plugins": [
"@babel/syntax-dynamic-import"
]
}
调整 router.js
代码,动态导入页面组件。
const Home = () => import('./route/Home');
const Login = () => import('./route/Login');
此时执行 yarn build
可以发现已经将代码进行了分割。但是页面无法正常访问了,因为动态导入是一个异步操作,它返回的并不是一个组件而是 Promise。
我们可以创建一个叫做 AsyncComponent
的组件,它接受动态导入的返回值作为属性,在内部会判断组件是否加载完毕,如果加载完毕则渲染组件,否则显示 Loading。
// component/AsyncComponent.js
import React, { Component } from 'react';
export default (loader) => {
class AsyncComponent extends Component {
state = {
component: null,
};
componentWillMount() {
if (!this.state.component) {
loader().then(module => {
this.setState({ component: module.default });
});
}
}
render() {
const Comp = this.state.component;
return Comp ? <Comp {...this.props} /> : <p>loading</p>;
}
}
return AsyncComponent;
}
// router.js
const Home = AsyncComponent(() => import('./route/Home'));
const Login = AsyncComponent(() => import('./route/Login'));
function RouterConfig() {
return (
<Router>
<Switch>
<Route exact path="/" component={Home} />
<Route path="/login" component={Login} />
</Switch>
</Router>
);
}
export default RouterConfig;
使用 react-loadable
我们大可不必自己实现 AsyncComponent
,react-loadable 就是一个不错的选择。
import React from 'react';
import { BrowserRouter as Router, Switch, Route } from 'react-router-dom';
import Loadable from 'react-loadable';
const LoadingComp = () => <span>loading</span>;
const Home = Loadable({
loader: () => import('./route/Home'),
loading: LoadingComp,
});
const Login = Loadable({
loader: () => import('./route/Login'),
loading: LoadingComp,
});
function RouterConfig() {
return (
<Router>
<Switch>
<Route exact path="/" component={Home} />
<Route path="/login" component={Login} />
</Switch>
</Router>
);
}
export default RouterConfig;
使用 React16.6 新特性
React 新发布的 16.6
版本提供了 lazy
方法和配套的 Suspense
组件,用来处理异步渲染场景。通过新特性重新实现 AsyncComponent
:
import React, { lazy, Suspense } from 'react';
export default (loader) => {
const OtherComponent = lazy(loader);
const Component = () => {
return (
<Suspense fallback={<div>Loading...</div>}>
<OtherComponent />
</Suspense>
);
};
return Component;
}
分割 Redux 模块
这一部分主要参考 Twitter 的做法。
一个 Redux 模块包含 reducers、actions、action creators、state selectors。我们来创建一个 user
Redux 模块。
// model/user.js
const reducerName = 'user';
const initialState = {
login: false,
profile: {
name: 'tom',
},
};
const createActionName = name => `app/${reducerName}/${name}`;
// actions
export const LOGIN_SUCCESS = createActionName('LOGIN_SUCCESS');
export const UPDATE_PROFILE = createActionName('UPDATE_PROFILE');
// action creators
export const updateProfile = payload => ({ payload, type: UPDATE_PROFILE });
// reducer
export default function reducer(state = initialState, action = {}) {
switch (action.type) {
case LOGIN_SUCCESS:
return { ...state, login: true };
case UPDATE_PROFILE:
return { ...state, profile: action.payload };
default:
return state;
}
}
// selectors
export const selectLoginState = state => state.login;
export const selectProfile = state => state.profile;
然后我们把 user Model 和登录页面 connect 连接起来:
import React from 'react';
import { connect } from 'react-redux';
const Login = ({ name }) => {
return (
<h1>{`登录页面, name: ${name}`}</h1>
);
};
const mapStateToProps = ({ user }) => ({
name: user.profile.name,
});
export default connect(mapStateToProps)(Login);
到目前为止,我们将登录页面和登录页面所需要用到的状态管理相关代码(user Redux moudle)整合到了一次,这对于代码分割非常有利。
但是当我们将 reducer 添加到 Redux store 时,就不是那么美好了。Model 相关代码在一开始就会被加载进来,即使目前还没有页面会用到它。
运行时注册 Reducer
通过 replaceReducer 函数,我们可以在访问相应的页面时再添加 Reducer 到 Redux store 中。
实现 ReducerRegistry
并修改 createStore
的代码:
// model/reducerRegistry
export class ReducerRegistry {
constructor() {
this._emitChange = null;
this._reducers = {};
}
getReducers() {
return { ...this._reducers };
}
register(name, reducer) {
this._reducers = { ...this._reducers, [name]: reducer };
if (this._emitChange) {
this._emitChange(this.getReducers());
}
}
setChangeListener(listener) {
this._emitChange = listener;
}
}
const reducerRegistry = new ReducerRegistry();
export default reducerRegistry;
// model/createStore
import { combineReducers, createStore } from 'redux';
import reducerRegistry from './reducerRegistry';
const initialState = {};
const combine = (reducers) => {
const reducerNames = Object.keys(reducers);
// 维持仍为加载的 reducer 的初始化状态
Object.keys(initialState).forEach(item => {
if (reducerNames.indexOf(item) === -1) {
reducers[item] = (state = null) => state;
}
});
return combineReducers(reducers);
};
const reducer = combine(reducerRegistry.getReducers());
const store = createStore(reducer, initialState);
// 当一个新的 reducer 注册时,替换 store 的 reducer
reducerRegistry.setChangeListener(reducers => {
store.replaceReducer(combine(reducers));
});
export default store;
现在,我们已经可以动态加载 reducer 了。接着,我们只需要在导入页面的同时注册相应的的 reducer 即可。
导入页面和模型
我们实现一个 dynamicWrapper
,接受一个页面 loader 函数和模型名称数组。默认模型都在 model 目录下,且文件名和模型相对应。
import React, { lazy, Suspense } from 'react';
import reducerRegistry from './model/reducerRegistry';
const asyncComponent = (loader) => {
const OtherComponent = lazy(loader);
const Component = () => {
return (
<Suspense fallback={<div>Loading...</div>}>
<OtherComponent />
</Suspense>
);
};
return Component;
};
export default (compLoader, modelNames) => {
const loader = () => {
let modelLoader;
if (modelNames) {
modelLoader = () => modelNames.map((name) => import(`./model/${name}`));
}
const component = compLoader();
const models = modelLoader ? modelLoader() : [];
return Promise.all([...models, component]).then(ret => {
if (!models || !models.length) {
return ret[0];
} else {
const len = models.length;
ret.slice(0, len).forEach((m, index) => {
m = m.default || m;
reducerRegistry.register(modelNames[index], m);
});
return ret[len];
}
});
};
return asyncComponent(loader);
}
使用起来也很简单,我们调整一下 router.js
中的代码:
const Home = dynamicWrapper(() => import('./route/Home'));
const Login = dynamicWrapper(() => import('./route/Login'), ['user']);
function RouterConfig() {
return (
<Provider store={store}>
<Router>
<Switch>
<Route exact path="/" component={Home} />
<Route path="/login" component={Login} />
</Switch>
</Router>
</Provider>
);
}
现在 model/user.js
并不会在一开始就加载,而是在访问登录页面时才会加载进来。
我最近打算做一个 React 模板项目,包含我平时 React 项目中常用的一些库和模板代码,到时候也会包含代码分割这部分。如果想看这部分源码,可以访问 Github。