因為對網頁SEO的需要,要把之前的React專案改造為服務端渲染,經過一番調查和研究,查閱了大量網際網路資料。成功踩坑。
專案地址:https://github.com/wlx200510/react_koa_ssr
腳手架選型:webpack3.11.0 + react Router4 + Redux + koa2 + React16 + Node8.x
選型思路:實現服務端渲染,想用React最新的版本,並且不對現有的寫法做大的改動,如果一開始就打算服務端渲染,建議直接用NEXT
框架來寫
主要心得:對React
的相關知識更加熟悉,成功擴充自己的技術領域,對服務端技術在實際專案上有所積累
注意點:使用框架前一定確認當前webpack版本為3.x Node為8.x以上,讀者最好用React在3個月以上,並有實際React專案經驗
專案目錄介紹:
├── assets
│ └── index.css //放置一些全域性的資原始檔 可以是js 圖片等
├── config
│ ├── webpack.config.dev.js 開發環境webpack打包設定
│ └── webpack.config.prod.js 生產環境webpack打包設定
├── package.json
├── README.md
├── server server端渲染檔案,如果對不是很瞭解,建議參考[koa教程](http://wlxadyl.cn/2018/02/11/koa-learn/)
│ ├── app.js
│ ├── clientRouter.js // 在此檔案中包含了把服務端路由匹配到react路由的邏輯
│ ├── ignore.js
│ └── index.js
└── src
├── app 此資料夾下主要用於放置瀏覽器和服務端通用邏輯
│ ├── configureStore.js //redux-thunk設定
│ ├── createApp.js //根據渲染環境不同來設定不同的router模式
│ ├── index.js
│ └── router
│ ├── index.js
│ └── routes.js //路由配置檔案! 重要
├── assets
│ ├── css 放置一些公共的樣式檔案
│ │ ├── _base.scss //很多專案都會用到的初始化css
│ │ ├── index.scss
│ │ └── my.scss
│ └── img
├── components 放置一些公共的元件
│ ├── FloatDownloadBtn 公共元件樣例寫法
│ │ ├── FloatDownloadBtn.js
│ │ ├── FloatDownloadBtn.scss
│ │ └── index.js
│ ├── Loading.js
│ └── Model.js 函式式元件的寫法
│
├── favicon.ico
├── index.ejs //渲染的模板 如果專案需要,可以放一些公共檔案進去
├── index.js //包括熱更新的邏輯
├── pages 頁面元件資料夾
│ ├── home
│ │ ├── components // 用於放置頁面元件,主要邏輯
│ │ │ └── homePage.js
│ │ ├── containers // 使用connect來封裝出高階元件 注入全域性state資料
│ │ │ └── homeContainer.js
│ │ ├── index.js // 頁面路由配置檔案 注意thunk屬性
│ │ └── reducer
│ │ └── index.js // 頁面的reducer 這裡暴露出來給store統一處理 注意寫法
│ └── user
│ ├── components
│ │ └── userPage.js
│ ├── containers
│ │ └── userContainer.js
│ └── index.js
└── store
├── actions // 各action存放地
│ ├── home.js
│ └── thunk.js
├── constants.js // 各action名稱彙集處 防止重名
└── reducers
└── index.js // 引用各頁面的所有reducer 在此處統一combine處理
複製程式碼
專案的構建思路
- 本地開發使用webpack-dev-server,實現熱更新,基本流程跟之前react開發類似,仍是瀏覽器端渲染,因此在編寫程式碼時要考慮到一套邏輯,兩種渲染環境的問題。
- 當前端頁面渲染完成後,其Router跳轉將不會對服務端進行請求,從而減輕服務端壓力,從而頁面的進入方式也是兩種,還要考慮兩種渲染環境下路由同構的問題。
- 生產環境要使用koa做後端伺服器,實現按需載入,在服務端獲取資料,並渲染出整個HTML,利用React16最新的能力來合併整個狀態樹,實現服務端渲染。
本地開發介紹
檢視本地開發主要涉及的檔案是src
目錄下的index.js
檔案,判斷當前的執行環境,只有在開發環境下才會使用module.hot的API,實現當reducer發生變化時的頁面渲染更新通知,注意其中的hydrate
方法,這是v16版本的一個專門為服務端渲染新增的API
方法,它在render方法的基礎上實現了對服務端渲染內容的最大可能重用,實現了靜態DOM
到動態NODES
的過程。實質是代替了v15版本下判斷checksum標記的過程,使得重用的過程更加高效優雅。
const renderApp=()=>{
let application=createApp({store,history});
hydrate(application,document.getElementById('root'));
}
window.main = () => {
Loadable.preloadReady().then(() => {
renderApp()
});
};
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)
})
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'));
})
}
}
複製程式碼
注意window.main
這個函式的定義,結合index.ejs
可以知道這個函式是所有指令碼載入完成後才觸發,裡面用的是react-loadable
的寫法,用於頁面的懶載入,關於頁面分別打包的寫法要結合路由設定來講解,這裡有個大致印象即可。需要注意的是app這個檔案下暴露出的三個方法是在瀏覽器端和伺服器端通用的,接下來主要就是說這部分的思路。
路由處理
接下來看以下src/app
目錄下的檔案,index.js
暴露了三個方法,這裡面涉及的三個方法在服務端和瀏覽器端開發都會用到,這一部分主要講其下的router
檔案裡面的程式碼思路和createApp.js
檔案對路由的處理,這裡是實現兩端路由相互打通的關鍵點。
router
資料夾下的routes.js是路由配置檔案,將各個頁面下的路由配置都引進來,合成一個配置陣列,可以通過這個配置來靈活控制頁面上下線。同目錄下的index.js
是RouterV4
的標準寫法,通過遍歷配置陣列的方式傳入路由配置,ConnectRouter
是用於合併Router
的一個元件,注意到history
要作為引數傳入,需要在createApp.js
檔案裡做單獨的處理。先大致看一下Route元件中的幾個配置項,值得注意的是其中的thunk
屬性,這是實現後端獲取資料後渲染的關鍵一步,正是這個屬性實現了類似Next
裡面的元件提前獲取資料的生命週期鉤子,其餘的屬性都可以在相關React-router文件中找到說明,這裡不在贅述。
import routesConfig from './routes';
const Routers=({history})=>(
<ConnectedRouter history={history}>
<div>
{
routesConfig.map(route=>(
<Route key={route.path} exact={route.exact} path={route.path} component={route.component} thunk={route.thunk} />
))
}
</div>
</ConnectedRouter>
)
export default Routers;
複製程式碼
檢視app
目錄下的createApp.js
裡面的程式碼可以發現,本框架是針對不同的工作環境做了不同的處理,只有在生產環境下才利用Loadable.Capture方法實現了懶載入,動態引入不同頁面對應的打包之後的js檔案。到這裡還要看一下元件裡面的路由配置檔案的寫法,以home
頁面下的index.js
為例。注意/* webpackChunkName: 'Home' */
這串字元,實質是指定了打包後此頁面對應的js檔名,所以針對不同的頁面,這個註釋也需要修改,避免打包到一起。loading
這個配置項只會在開發環境生效,當頁面載入未完成前顯示,這個實際專案開發如果不需要可以刪除此元件。
import {homeThunk} from '../../store/actions/thunk';
const LoadableHome = Loadable({
loader: () =>import(/* webpackChunkName: 'Home' */'./containers/homeContainer.js'),
loading: Loading,
});
const HomeRouter = {
path: '/',
exact: true,
component: LoadableHome,
thunk: homeThunk // 服務端渲染會開啟並執行這個action,用於獲取頁面渲染所需資料
}
export default HomeRouter
複製程式碼
這裡多說一句,有時我們要改造的專案的頁面檔案裡有從window.location
裡面獲取引數的程式碼,改造成服務端渲染時要全部去掉,或者是要在render之後的生命週期中使用。並且頁面級別元件都已經注入了相關路由資訊,可以通過this.props.location
來獲取URL裡面的引數。本專案用的是BrowserRouter
,如果用HashRouter
則包含引數可能略有不同,根據實際情況取用。
根據React16的服務端渲染的API介紹:
瀏覽器端使用的注入ConnectedRouter
中的history
為:import createHistory from 'history/createBrowserHistory'
伺服器端使用的history
為import createHistory from 'history/createMemoryHistory'
服務端渲染
這裡就不會涉及到koa2
的一些基礎知識,如果對koa2
框架不熟悉可以參考我的另外一篇博文。這裡是看server資料夾下都是服務端的程式碼。首先是簡潔的app.js
用於保證每次連線都返回的是一個新的伺服器端例項,這對於單執行緒的js語言是很關鍵的思路。需要重點介紹的就是clientRouter.js
這個檔案,結合/src/app/configureStore.js
這個檔案共同理解服務端渲染的資料獲取流程和React的渲染機制。
/*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]; //把路由注入到reducer,可以從reducer中直接獲取路由資訊
let configureStore=(initialState)=>createStore(rootReducer,initialState,composeEnhancers(applyMiddleware(...middleware)));
export default configureStore;
複製程式碼
這個渲染的具體思路是:在服務端判斷路由的thunk方法,如果存在則需要執行這個獲取資料邏輯,這是個阻塞過程,可以當作同步,獲取後放到全域性State中,在前端輸出的HTML中注入window.__INITIAL_STATE__
這個全域性變數,當html載入完畢後,這個變數賦值已有資料的全域性State
作為initState
提供給react應用,然後瀏覽器端的js載入完畢後會通過複用頁面上已有的dom和初始的initState
作為開始,合併到render後的生命週期中,從而在componentDidMount
中已經可以從this.props中獲取渲染所需資料。
但還要考慮到頁面切換也有可能在前端執行跳轉,此時作為React的應用不會觸發對後端的請求,因此在componentDidMount
這個生命週期裡並沒有獲取資料,為了解決這個問題,我建議在這個生命週期中都呼叫props中傳來的action
觸發函式,但在action內部進行一層邏輯判斷,避免重複的請求,實際專案中請求資料往往會有個標識性ID,就可以將這個ID存入store
中,然後就可以進行一次對比校驗來提前返回,避免重複傳送ajax
請求,具體可看store/actions/home.js`中的邏輯處理。
import {ADD,GET_HOME_INFO} from '../constants'
export const add=(count)=>({type: ADD, count,})
export const getHomeInfo=(sendId=1)=>async(dispatch,getState)=>{
let {name,age,id}=getState().HomeReducer.homeInfo;
if (id === sendId) {
return //是通過對請求id和已有資料的標識性id進行對比校驗,避免重複獲取資料。
}
console.log('footer'.includes('foo'))
await new Promise(resolve=>{
let homeInfo={name:'wd2010',age:'25',id:sendId}
console.log('-----------請求getHomeInfo')
setTimeout(()=>resolve(homeInfo),1000)
}).then(homeInfo=>{
dispatch({type:GET_HOME_INFO,data:{homeInfo}})
})
}
複製程式碼
注意這裡的async/await
寫法,這裡涉及到服務端koa2使用這個來做資料請求,因此需要統一返回async
函式,這塊不熟的同學建議看下ES7的知識,主要是async
如何配合Promise
實現非同步流程改造,並且如果涉及koa2的服務端工作,對async
函式用的更多,這也是本專案要求Node版本為8.x以上的原因,從8開始就可以直接用這兩個關鍵字。
不過到具體專案中,往往會涉及到一些服務端引數的注入問題,但這塊根據不同專案需求差異很大,並且不屬於這個React服務端改造的一部分,沒法統一分享,如果真是公司專案要用到對這塊有需求諮詢可以打賞後加我微信討論。
以Home頁面為例的渲染流程
為了方便大家理解,我以一個頁面為例整理了一下資料流的整體過程,看一下思路:
- 服務端接收到請求,通過
/home
找到對應的路由配置 - 判斷路由存在
thunk
方法,此時執行store/actions/thunk.js
裡面的暴露出的函式 - 非同步獲取的資料會注入到全域性state中,此時的dispatch分發其實並不生效
- 要輸出的HTML程式碼中會將獲取到資料後的全域性state放到
window.__INITIAL_STATE__
這個全域性變數中,作為initState window.__INITIAL_STATE__
將在react生命週期起作用前合併入全域性state,此時react發現dom已經生成,不會再次觸發render,並且資料狀態得到同步
基本的流程已經介紹結束,至於一些Reducer
的函式式寫法,還有actions的位置都是參考網上的一些分析來組織的,具體見仁見智,這個只要符合自己的理解,並且有助於團隊開發就好。如果您符合我在文章一開始設定的讀者背景,相信本文的講述足夠您點亮自己的服務端渲染技術點啦。如果對React瞭解偏少也沒關係,可以參考這裡來補充一些React的基礎知識,也可以在我的部落格學習交流。
本文部落格地址:wlxadyl.cn/2018/03/16/… 如果這篇文章對您有幫助,或者用於您公司的專案發現問題,歡迎到我的部落格裡加我微信打賞後討論並解決問題~。