React服務端渲染改造框架(webpack3.11.0 + React16 + koa2)

因為對網(wǎng)頁SEO的需要,要把之前的React項目改造為服務端渲染庭瑰,經(jīng)過一番調(diào)查和研究枪狂,查閱了大量互聯(lián)網(wǎng)資料危喉。成功踩坑。

項目地址:https://github.com/wlx200510/react_koa_ssr
腳手架選型:webpack3.11.0 + react Router4 + Redux + koa2 + React16 + Node8.x
選型思路:實現(xiàn)服務端渲染州疾,想用React最新的版本辜限,并且不對現(xiàn)有的寫法做大的改動,如果一開始就打算服務端渲染孝治,建議直接用NEXT框架來寫
主要心得:對React的相關知識更加熟悉列粪,成功拓展自己的技術領域审磁,對服務端技術在實際項目上有所積累
注意點:使用框架前一定確認當前webpack版本為3.x Node為8.x以上,讀者最好用React在3個月以上岂座,并有實際React項目經(jīng)驗

項目目錄介紹:

├── assets
│   └── index.css //放置一些全局的資源文件 可以是js 圖片等
├── config
│   ├── webpack.config.dev.js  開發(fā)環(huán)境webpack打包設置
│   └── webpack.config.prod.js 生產(chǎn)環(huán)境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       //根據(jù)渲染環(huán)境不同來設置不同的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           函數(shù)式組件的寫法
    │
    ├── favicon.ico
    ├── index.ejs              //渲染的模板 如果項目需要钾恢,可以放一些公共文件進去
    ├── index.js               //包括熱更新的邏輯
    ├── pages                  頁面組件文件夾
    │   ├── home
    │   │   ├── components     // 用于放置頁面組件,主要邏輯
    │   │   │   └── homePage.js
    │   │   ├── containers     // 使用connect來封裝出高階組件 注入全局state數(shù)據(jù)
    │   │   │   └── homeContainer.js
    │   │   ├── index.js       // 頁面路由配置文件 注意thunk屬性
    │   │   └── reducer
    │   │       └── index.js   // 頁面的reducer 這里暴露出來給store統(tǒng)一處理 注意寫法
    │   └── user
    │       ├── components
    │       │   └── userPage.js
    │       ├── containers
    │       │   └── userContainer.js
    │       └── index.js
    └── store
        ├── actions            // 各action存放地
        │   ├── home.js
        │   └── thunk.js
        ├── constants.js       // 各action名稱匯集處 防止重名
        └── reducers
            └── index.js       // 引用各頁面的所有reducer 在此處統(tǒng)一combine處理

項目的構建思路

  1. 本地開發(fā)使用webpack-dev-server鸳址,實現(xiàn)熱更新瘩蚪,基本流程跟之前react開發(fā)類似,仍是瀏覽器端渲染稿黍,因此在編寫代碼時要考慮到一套邏輯疹瘦,兩種渲染環(huán)境的問題。
  2. 當前端頁面渲染完成后巡球,其Router跳轉將不會對服務端進行請求言沐,從而減輕服務端壓力,從而頁面的進入方式也是兩種酣栈,還要考慮兩種渲染環(huán)境下路由同構的問題险胰。
  3. 生產(chǎn)環(huán)境要使用koa做后端服務器,實現(xiàn)按需加載矿筝,在服務端獲取數(shù)據(jù)起便,并渲染出整個HTML,利用React16最新的能力來合并整個狀態(tài)樹窖维,實現(xiàn)服務端渲染榆综。

本地開發(fā)介紹

查看本地開發(fā)主要涉及的文件是src目錄下的index.js文件,判斷當前的運行環(huán)境陈辱,只有在開發(fā)環(huán)境下才會使用module.hot的API奖年,實現(xiàn)當reducer發(fā)生變化時的頁面渲染更新通知,注意其中的hydrate方法沛贪,這是v16版本的一個專門為服務端渲染新增的API方法陋守,它在render方法的基礎上實現(xiàn)了對服務端渲染內(nèi)容的最大可能重用,實現(xiàn)了靜態(tài)DOM到動態(tài)NODES的過程利赋。實質(zhì)是代替了v15版本下判斷checksum標記的過程水评,使得重用的過程更加高效優(yōu)雅。

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這個函數(shù)的定義媚送,結合index.ejs可以知道這個函數(shù)是所有腳本加載完成后才觸發(fā)中燥,里面用的是react-loadable的寫法,用于頁面的懶加載塘偎,關于頁面分別打包的寫法要結合路由設置來講解疗涉,這里有個大致印象即可拿霉。需要注意的是app這個文件下暴露出的三個方法是在瀏覽器端和服務器端通用的,接下來主要就是說這部分的思路咱扣。

路由處理

接下來看以下src/app目錄下的文件绽淘,index.js暴露了三個方法,這里面涉及的三個方法在服務端和瀏覽器端開發(fā)都會用到闹伪,這一部分主要講其下的router文件里面的代碼思路和createApp.js文件對路由的處理沪铭,這里是實現(xiàn)兩端路由相互打通的關鍵點。
  router文件夾下的routes.js是路由配置文件偏瓤,將各個頁面下的路由配置都引進來杀怠,合成一個配置數(shù)組,可以通過這個配置來靈活控制頁面上下線厅克。同目錄下的index.jsRouterV4的標準寫法赔退,通過遍歷配置數(shù)組的方式傳入路由配置,ConnectRouter是用于合并Router的一個組件已骇,注意到history要作為參數(shù)傳入离钝,需要在createApp.js文件里做單獨的處理票编。先大致看一下Route組件中的幾個配置項拷姿,值得注意的是其中的thunk屬性卜朗,這是實現(xiàn)后端獲取數(shù)據(jù)后渲染的關鍵一步,正是這個屬性實現(xiàn)了類似Next里面的組件提前獲取數(shù)據(jù)的生命周期鉤子,其余的屬性都可以在相關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里面的代碼可以發(fā)現(xiàn)逞怨,本框架是針對不同的工作環(huán)境做了不同的處理捌省,只有在生產(chǎn)環(huán)境下才利用Loadable.Capture方法實現(xiàn)了懶加載,動態(tài)引入不同頁面對應的打包之后的js文件互订。到這里還要看一下組件里面的路由配置文件的寫法吱肌,以home頁面下的index.js為例。注意/* webpackChunkName: 'Home' */這串字符仰禽,實質(zhì)是指定了打包后此頁面對應的js文件名氮墨,所以針對不同的頁面,這個注釋也需要修改吐葵,避免打包到一起规揪。loading這個配置項只會在開發(fā)環(huán)境生效,當頁面加載未完成前顯示温峭,這個實際項目開發(fā)如果不需要可以刪除此組件猛铅。

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 // 服務端渲染會開啟并執(zhí)行這個action,用于獲取頁面渲染所需數(shù)據(jù)
}
export default HomeRouter

這里多說一句凤藏,有時我們要改造的項目的頁面文件里有從window.location里面獲取參數(shù)的代碼奸忽,改造成服務端渲染時要全部去掉堕伪,或者是要在render之后的生命周期中使用。并且頁面級別組件都已經(jīng)注入了相關路由信息栗菜,可以通過this.props.location來獲取URL里面的參數(shù)刃跛。本項目用的是BrowserRouter,如果用HashRouter則包含參數(shù)可能略有不同苛萎,根據(jù)實際情況取用桨昙。

根據(jù)React16的服務端渲染的API介紹:
  瀏覽器端使用的注入ConnectedRouter中的history為:import createHistory from 'history/createBrowserHistory'
  服務器端使用的historyimport createHistory from 'history/createMemoryHistory'

服務端渲染

這里就不會涉及到koa2的一些基礎知識,如果對koa2框架不熟悉可以參考我的另外一篇博文腌歉。這里是看server文件夾下都是服務端的代碼蛙酪。首先是簡潔的app.js用于保證每次連接都返回的是一個新的服務器端實例,這對于單線程的js語言是很關鍵的思路翘盖。需要重點介紹的就是clientRouter.js這個文件桂塞,結合/src/app/configureStore.js這個文件共同理解服務端渲染的數(shù)據(jù)獲取流程和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方法阁危,如果存在則需要執(zhí)行這個獲取數(shù)據(jù)邏輯,這是個阻塞過程汰瘫,可以當作同步狂打,獲取后放到全局State中,在前端輸出的HTML中注入window.__INITIAL_STATE__這個全局變量混弥,當html載入完畢后趴乡,這個變量賦值已有數(shù)據(jù)的全局State作為initState提供給react應用,然后瀏覽器端的js加載完畢后會通過復用頁面上已有的dom和初始的initState作為開始蝗拿,合并到render后的生命周期中晾捏,從而在componentDidMount中已經(jīng)可以從this.props中獲取渲染所需數(shù)據(jù)。
  但還要考慮到頁面切換也有可能在前端執(zhí)行跳轉哀托,此時作為React的應用不會觸發(fā)對后端的請求惦辛,因此在componentDidMount這個生命周期里并沒有獲取數(shù)據(jù),為了解決這個問題仓手,我建議在這個生命周期中都調(diào)用props中傳來的action觸發(fā)函數(shù)胖齐,但在action內(nèi)部進行一層邏輯判斷,避免重復的請求俗或,實際項目中請求數(shù)據(jù)往往會有個標識性ID市怎,就可以將這個ID存入store中,然后就可以進行一次對比校驗來提前返回辛慰,避免重復發(fā)送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和已有數(shù)據(jù)的標識性id進行對比校驗,避免重復獲取數(shù)據(jù)驰弄。
  }
  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使用這個來做數(shù)據(jù)請求,因此需要統(tǒng)一返回async函數(shù)戚篙,這塊不熟的同學建議看下ES7的知識五鲫,主要是async如何配合Promise實現(xiàn)異步流程改造,并且如果涉及koa2的服務端工作岔擂,對async函數(shù)用的更多位喂,這也是本項目要求Node版本為8.x以上的原因,從8開始就可以直接用這兩個關鍵字乱灵。
  不過到具體項目中塑崖,往往會涉及到一些服務端參數(shù)的注入問題,但這塊根據(jù)不同項目需求差異很大痛倚,并且不屬于這個React服務端改造的一部分规婆,沒法統(tǒng)一分享,如果真是公司項目要用到對這塊有需求咨詢可以打賞后加我微信討論蝉稳。

以Home頁面為例的渲染流程

為了方便大家理解抒蚜,我以一個頁面為例整理了一下數(shù)據(jù)流的整體過程,看一下思路:

  1. 服務端接收到請求耘戚,通過/home找到對應的路由配置
  2. 判斷路由存在thunk方法嗡髓,此時執(zhí)行store/actions/thunk.js里面的暴露出的函數(shù)
  3. 異步獲取的數(shù)據(jù)會注入到全局state中,此時的dispatch分發(fā)其實并不生效
  4. 要輸出的HTML代碼中會將獲取到數(shù)據(jù)后的全局state放到window.__INITIAL_STATE__這個全局變量中毕莱,作為initState
  5. window.__INITIAL_STATE__將在react生命周期起作用前合并入全局state器贩,此時react發(fā)現(xiàn)dom已經(jīng)生成,不會再次觸發(fā)render朋截,并且數(shù)據(jù)狀態(tài)得到同步
服務端直出HTML

基本的流程已經(jīng)介紹結束,至于一些Reducer的函數(shù)式寫法吧黄,還有actions的位置都是參考網(wǎng)上的一些分析來組織的部服,具體見仁見智,這個只要符合自己的理解拗慨,并且有助于團隊開發(fā)就好廓八。如果您符合我在文章一開始設定的讀者背景,相信本文的講述足夠您點亮自己的服務端渲染技術點啦赵抢。如果對React了解偏少也沒關系剧蹂,可以參考這里來補充一些React的基礎知識,也可以在我的博客學習交流烦却。

本文博客地址:http://wlxadyl.cn/2018/03/16/react-ssr-learn/
如果這篇文章對您有幫助宠叼,或者用于您公司的項目發(fā)現(xiàn)問題,歡迎到我的博客里加我微信打賞后討論并解決問題~。

?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末冒冬,一起剝皮案震驚了整個濱河市伸蚯,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌简烤,老刑警劉巖剂邮,帶你破解...
    沈念sama閱讀 206,968評論 6 482
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異横侦,居然都是意外死亡挥萌,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,601評論 2 382
  • 文/潘曉璐 我一進店門枉侧,熙熙樓的掌柜王于貴愁眉苦臉地迎上來瑞眼,“玉大人,你說我怎么就攤上這事棵逊∩烁恚” “怎么了?”我有些...
    開封第一講書人閱讀 153,220評論 0 344
  • 文/不壞的土叔 我叫張陵辆影,是天一觀的道長徒像。 經(jīng)常有香客問我,道長蛙讥,這世上最難降的妖魔是什么锯蛀? 我笑而不...
    開封第一講書人閱讀 55,416評論 1 279
  • 正文 為了忘掉前任,我火速辦了婚禮次慢,結果婚禮上旁涤,老公的妹妹穿的比我還像新娘。我一直安慰自己迫像,他們只是感情好劈愚,可當我...
    茶點故事閱讀 64,425評論 5 374
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著闻妓,像睡著了一般菌羽。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上由缆,一...
    開封第一講書人閱讀 49,144評論 1 285
  • 那天注祖,我揣著相機與錄音,去河邊找鬼均唉。 笑死是晨,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的舔箭。 我是一名探鬼主播罩缴,決...
    沈念sama閱讀 38,432評論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了靴庆?” 一聲冷哼從身側響起时捌,我...
    開封第一講書人閱讀 37,088評論 0 261
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎炉抒,沒想到半個月后奢讨,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,586評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡焰薄,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,028評論 2 325
  • 正文 我和宋清朗相戀三年拿诸,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片塞茅。...
    茶點故事閱讀 38,137評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡亩码,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出野瘦,到底是詐尸還是另有隱情描沟,我是刑警寧澤,帶...
    沈念sama閱讀 33,783評論 4 324
  • 正文 年R本政府宣布鞭光,位于F島的核電站吏廉,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏惰许。R本人自食惡果不足惜席覆,卻給世界環(huán)境...
    茶點故事閱讀 39,343評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望汹买。 院中可真熱鬧佩伤,春花似錦、人聲如沸晦毙。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,333評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽结序。三九已至障斋,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間徐鹤,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,559評論 1 262
  • 我被黑心中介騙來泰國打工邀层, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留返敬,地道東北人。 一個月前我還...
    沈念sama閱讀 45,595評論 2 355
  • 正文 我出身青樓寥院,卻偏偏與公主長得像劲赠,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 42,901評論 2 345

推薦閱讀更多精彩內(nèi)容

  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 171,521評論 25 707
  • 本項目github地址 react-koa2-ssr 所用到技術棧 react16.x + react-route...
    yangfan0095閱讀 3,550評論 2 14
  • react+redux+webpack+babel+npm+shell+git這方面的內(nèi)容我會隨時更新凛澎,更新內(nèi)容放...
    liangklfang閱讀 648評論 0 1
  • 《一念紅塵》 目錄 天空中現(xiàn)出一縷晨曦霹肝,塌上的女子仍未蘇醒。燭光將她睫毛的影子拉長塑煎,細長眼瞼遮住了美麗靈動的雙眸沫换,...
    翼如閱讀 1,134評論 0 6
  • 隨著一首踏雪尋梅歌,我們今天的晨讀又開始了最铁! 梅花那頑強不屈的精神卻更令我贊嘆讯赏。自古以來,它和松冷尉、竹被人...
    羽一教育肖莉麗閱讀 387評論 0 0