项目结构图
本项目的主要构建思路是:
- 开发环境使用webpack-dev-server做后端服务器,实现不刷新页面的热更新,包括组件和reducer变动的热更新。
- 生产环境使用koa做后端服务器,与前端公用createApp代码,打包后通过读取文件获得createApp的方法,然后通过react-loadable按需分离代码,在渲染之前请求初始数据,一并塞入首页。
Github地址: https://github.com/wd2010/React-universal-ssr
代码结构
前端用react+redux+router4,其中在处理异步action使用redux-thunk。前后端公用了configureStore和createApp,还有后端需要的前端路由配置routesConfig,所以在一个文件里暴露他们三。
export default {
configureStore,
createApp,
routesConfig
}
其中configureStore.js为:
import {createStore, applyMiddleware,compose} from "redux";
import thunkMiddleware from "redux-thunk";
import createHistory from 'history/createMemoryHistory';
import { routerReducer, routerMiddleware } from 'react-router-redux'
import rootReducer from '../store/reducers/index.js';
const routerReducers=routerMiddleware(createHistory());//路由
const composeEnhancers = process.env.NODE_ENV=='development'?window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ : compose;
const middleware=[thunkMiddleware,routerReducers];
let configureStore=(initialState)=>createStore(rootReducer,initialState,composeEnhancers(applyMiddleware(...middleware)));
export default configureStore;
其中我把router放入到reducer中
const routerReducers=routerMiddleware(createHistory());//路由
const middleware=[thunkMiddleware,routerReducers];
这样就可以在reducer中直接读取router的信息而不需要从组件中一层层往下传。
createApp.js
import React from 'react';
import {Provider} from 'react-redux';
import Routers from './router/index';
import Loadable from 'react-loadable';
const createApp=({store,history,modules})=>{
if(process.env.NODE_ENV==='production'){
return (
<Loadable.Capture report={moduleName => modules.push(moduleName)}>
<Provider store={store}>
<Routers history={history} />
</Provider>
</Loadable.Capture>
)
}else{
return (
<Provider store={store}>
<Routers history={history} />
</Provider>
)
}
}
export default createApp;
前端使用的history为:
import createHistory from 'history/createBrowserHistory';
let history=createHistory();
而后端使用的history为:
import createHistory from 'history/createMemoryHistory';
let history=createHistory();
开发版热加载更新
if(process.env.NODE_ENV==='development'){
if(module.hot){
module.hot.accept('./store/reducers/index.js',()=>{
let newReducer=require('./store/reducers/index.js');
store.replaceReducer(newReducer)
/*import('./store/reducers/index.js').then(({default:module})=>{
store.replaceReducer(module)
})*/
})
module.hot.accept('./app/index.js',()=>{
let {createApp}=require('./app/index.js');
let newReducer=require('./store/reducers/index.js');
store.replaceReducer(newReducer)
let application=createApp({store,history});
hydrate(application,document.getElementById('root'));
/*import('./app/index.js').then(({default:module})=>{
let {createApp}=module;
import('./store/reducers/index.js').then(({default:module})=>{
store.replaceReducer(module)
let application=createApp({store,history});
render(application,document.getElementById('root'));
})
})*/
})
}
}
其中包括组件的热更新和reducer热更新,在引入变化的文件时可以使用require或import。
前端dom节点生成
const renderApp=()=>{
let application=createApp({store,history});
hydrate(application,document.getElementById('root'));
}
window.main = () => {
Loadable.preloadReady().then(() => {
renderApp()
});
};
其中 Loadable.preloadReady() 是按需加载'react-loadable'写法,在服务器渲染时也会用到。
router4动态按需加载
本项目使用react-loadable实现按需加载。
const Loading=(props)=>
<div>Loading...</div>
const LoadableHome = Loadable({
loader: () =>import(/* webpackChunkName: 'Home' */'../../containers/Home'),
loading: Loading,
});
const LoadableUser = Loadable({
loader: () =>import(/* webpackChunkName: 'User' */'../../containers/User'),
loading: Loading,
});
const routesConfig=[{
path: '/',
exact: true,
component: LoadableHome,
thunk: homeThunk,
}, {
path: '/user',
component: LoadableUser,
thunk: ()=>{},
}];
不仅仅是在路由里面可以这样使用,也可以在组件中动态import()一个组件可以动态按需加载组件。thunk: homeThunk
为路由跳转时的action处理,因为第一种可能是在刚开始进入Home页面之前是需要服务器先请求home页面初始数据再渲染给前端,另一种是服务器进入的是user页面,当从user页面跳转至home页面时也需要请求初始数据,此时是前端组件ComponentDidMount时去请求,所以为了公用这个方法放到跳转路由时去请求,不管是从前端link进去的还是从服务器进入的。
export const homeThunk=store=>store.dispatch(getHomeInfo())
//模拟动态请求数据
export const getHomeInfo=()=>async(dispatch,getState)=>{
let {name,age}=getState().homeInfo;
if(name || age)return
await new Promise(resolve=>{
let homeInfo={name:'wd2010',age:'25'}
console.log('-----------请求getHomeInfo')
setTimeout(()=>resolve(homeInfo),1000)
}).then(homeInfo=>{
dispatch({type:GET_HOME_INFO,data:homeInfo})
})
}
而服务器端是通过react-router-config
的matchRoutes
去匹配当前的url和路由routesConfig
let branch=matchRoutes(routesConfig,ctx.req.url)
let promises = branch.map(({route,match})=>{
return route.thunk?(route.thunk(store)):Promise.resolve(null)
});
await Promise.all(promises)
koa渲染renderToString
通过前端暴露的createApp、configureStore和routesConfig,通过renderToString方法渲染前端html页面需要的rootString字符串。结合按需加载有:
let store=configureStore();
let history=createHistory({initialEntries:[ctx.req.url]});
let rootString= renderToString(createApp({store,history,modules}));
在koa server 入口文件监听端口时使用react-loadable:
Loadable.preloadAll().then(() => {
app.listen(port)
})
这样koa后端渲染就能动态按需加载。
而动态生成的html是没有User.js的:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>yyy</title>
<link href="/css/style.7dae77f648cd2652a570.css" rel="stylesheet"></head>
<body>
<div id="root"></div>
<script type="text/javascript" src="/manifest.7dae77f6.js"></script>
<script type="text/javascript" src="/vendors.7dae77f6.js"></script>
<script type="text/javascript" src="/client.7dae77f6.js"></script>
</body>
<script>window.main()</script>
</html>
在每次刷新时,localhost已经包含了首屏的所有内容,解决了首屏白屏和SEO搜索问题。
结语
做完这个练习后我在想,当代码编译之后,服务器渲染之前去请求首屏需要的数据时会出现短暂的白屏,那此时其实还是没有解决白屏的问题,所以是否可以在编译代码时就去请求所有的首页需要的数据呢?又想到此时的编译过程需要大量的时间,而且请求了本可以在前端路由跳转时的数据。所有首屏白屏问题看似解决,其实还有更好的解决办法。
因为自己也是初次弄react服务端渲染,很多地方是参考了大神们的做法弄出来的,还有很多不懂得地方,请大家多多指点,完整的代码在 https://github.com/wd2010/React-universal-ssr
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。