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

由於對網頁SEO的須要,要把以前的React項目改造爲服務端渲染,通過一番調查和研究,查閱了大量互聯網資料。成功踩坑。javascript

項目地址: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項目經驗css

項目目錄介紹:

├── 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處理
複製代碼

項目的構建思路

  1. 本地開發使用webpack-dev-server,實現熱更新,基本流程跟以前react開發相似,還是瀏覽器端渲染,所以在編寫代碼時要考慮到一套邏輯,兩種渲染環境的問題。
  2. 當前端頁面渲染完成後,其Router跳轉將不會對服務端進行請求,從而減輕服務端壓力,從而頁面的進入方式也是兩種,還要考慮兩種渲染環境下路由同構的問題。
  3. 生產環境要使用koa作後端服務器,實現按需加載,在服務端獲取數據,並渲染出整個HTML,利用React16最新的能力來合併整個狀態樹,實現服務端渲染。

本地開發介紹

  查看本地開發主要涉及的文件是src目錄下的index.js文件,判斷當前的運行環境,只有在開發環境下才會使用module.hot的API,實現當reducer發生變化時的頁面渲染更新通知,注意其中的hydrate方法,這是v16版本的一個專門爲服務端渲染新增的API方法,它在render方法的基礎上實現了對服務端渲染內容的最大可能重用,實現了靜態DOM到動態NODES的過程。實質是代替了v15版本下判斷checksum標記的過程,使得重用的過程更加高效優雅。html

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.jsRouterV4的標準寫法,經過遍歷配置數組的方式傳入路由配置,ConnectRouter是用於合併Router的一個組件,注意到history要做爲參數傳入,須要在createApp.js文件裏作單獨的處理。先大體看一下Route組件中的幾個配置項,值得注意的是其中的thunk屬性,這是實現後端獲取數據後渲染的關鍵一步,正是這個屬性實現了相似Next裏面的組件提早獲取數據的生命週期鉤子,其他的屬性均可以在相關React-router文檔中找到說明,這裏不在贅述。java

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這個配置項只會在開發環境生效,當頁面加載未完成前顯示,這個實際項目開發若是不須要能夠刪除此組件。react

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則包含參數可能略有不一樣,根據實際狀況取用。webpack

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

服務端渲染

  這裏就不會涉及到koa2的一些基礎知識,若是對koa2框架不熟悉能夠參考個人另一篇博文。這裏是看server文件夾下都是服務端的代碼。首先是簡潔的app.js用於保證每次鏈接都返回的是一個新的服務器端實例,這對於單線程的js語言是很關鍵的思路。須要重點介紹的就是clientRouter.js這個文件,結合/src/app/configureStore.js這個文件共同理解服務端渲染的數據獲取流程和React的渲染機制。github

/*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`中的邏輯處理。web

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頁面爲例的渲染流程

爲了方便你們理解,我以一個頁面爲例整理了一下數據流的總體過程,看一下思路:

  1. 服務端接收到請求,經過/home找到對應的路由配置
  2. 判斷路由存在thunk方法,此時執行store/actions/thunk.js裏面的暴露出的函數
  3. 異步獲取的數據會注入到全局state中,此時的dispatch分發其實並不生效
  4. 要輸出的HTML代碼中會將獲取到數據後的全局state放到window.__INITIAL_STATE__這個全局變量中,做爲initState
  5. window.__INITIAL_STATE__將在react生命週期起做用前合併入全局state,此時react發現dom已經生成,不會再次觸發render,而且數據狀態獲得同步

  基本的流程已經介紹結束,至於一些Reducer的函數式寫法,還有actions的位置都是參考網上的一些分析來組織的,具體見仁見智,這個只要符合本身的理解,而且有助於團隊開發就好。若是您符合我在文章一開始設定的讀者背景,相信本文的講述足夠您點亮本身的服務端渲染技術點啦。若是對React瞭解偏少也不要緊,能夠參考這裏來補充一些React的基礎知識,也能夠在個人博客學習交流。

本文博客地址:wlxadyl.cn/2018/03/16/… 若是這篇文章對您有幫助,或者用於您公司的項目發現問題,歡迎到個人博客裏加我微信打賞後討論並解決問題~。

相關文章
相關標籤/搜索