專案結構圖
本專案的主要構建思路是:- 開發環境使用webpack-dev-server做後端伺服器,實現不重新整理頁面的熱更新,包括元件和reducer變動的熱更新。
- 生產環境使用koa做後端伺服器,與前端公用createApp程式碼,打包後通過讀取檔案獲得createApp的方法,然後通過react-loadable按需分離程式碼,在渲染之前請求初始資料,一併塞入首頁。
Github地址: github.com/wd2010/Reac…
程式碼結構
前端用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服務端渲染,很多地方是參考了大神們的做法弄出來的,還有很多不懂得地方,請大家多多指點,完整的程式碼在 github.com/wd2010/Reac…