你們好,我是神三元,這一次,讓咱們來以React爲例,把服務端渲染(Server Side Render,簡稱「SSR」)學個明明白白。css
這裏附上這個項目的github地址:
https://github.com/sanyuan070...html
歡迎你們點star,提issue,一塊兒進步!前端
## part1:實現一個基礎的React組件SSR
這一部分來簡要實現一個React組件的SSR。node
### 一. SSR vs CSR
什麼是服務端渲染?react
廢話很少說,直接起一個express服務器。webpack
var express = require('express') var app = express() app.get('/', (req, res) => { res.send( ` <html> <head> <title>hello</title> </head> <body> <h1>hello</h1> <p>world</p> </body> </html> ` ) }) app.listen(3001, () => { console.log('listen:3001') })
啓動以後打開localhost:3001能夠看到頁面顯示了hello world。並且打開網頁源代碼:ios
也可以完成顯示。git
這就是服務端渲染。其實很是好理解,就是服務器返回一堆html字符串,而後讓瀏覽器顯示。github
與服務端渲染相對的是客戶端渲染(Client Side Render)。那什麼是客戶端渲染?
如今建立一個新的React項目,用腳手架生成項目,而後run起來。
這裏你能夠看到React腳手架自動生成的首頁。web
然而打開網頁源代碼。
body中除了兼容處理的noscript標籤以外,只有一個id爲root的標籤。那首頁的內容是從哪來的呢?很明顯,是下面的script中拉取的JS代碼控制的。
所以,CSR和SSR最大的區別在於前者的頁面渲染是JS負責進行的,然後者是服務器端直接返回HTML讓瀏覽器直接渲染。
爲何要使用服務端渲染呢?
傳統CSR的弊端:
SSR的出現,就是爲了解決這些傳統CSR的弊端。
剛剛起的express服務返回的只是一個普通的html字符串,但咱們討論的是如何進行React的服務端渲染,那麼怎麼作呢?
首先寫一個簡單的React組件:
// containers/Home.js import React from 'react'; const Home = () => { return ( <div> <div>This is sanyuan</div> </div> ) } export default Home
如今的任務就是將它轉換爲html代碼返回給瀏覽器。
總所周知,JSX中的標籤實際上是基於虛擬DOM的,最終要經過必定的方法將其轉換爲真實DOM。虛擬DOM也就是JS對象,能夠看出整個服務端的渲染流程就是經過虛擬DOM的編譯來完成的,所以虛擬DOM巨大的表達力也可見一斑了。
而react-dom這個庫中恰好實現了編譯虛擬DOM的方法。作法以下:
// server/index.js import express from 'express'; import { renderToString } from 'react-dom/server'; import Home from './containers/Home'; const app = express(); const content = renderToString(<Home />); app.get('/', function (req, res) { res.send( ` <html> <head> <title>ssr</title> </head> <body> <div id="root">${content}</div> </body> </html> ` ); }) app.listen(3001, () => { console.log('listen:3001') })
啓動express服務,再瀏覽器上打開對應端口,頁面顯示出"this is sanyuan"。
到此,就初步實現了一個React組件是服務端渲染。
固然,這只是一個很是簡陋的SSR,事實上對於複雜的項目而言是無能爲力的,在以後會一步步完善,打造出一個功能完整的React的SSR框架。
其實前面的SSR是不完整的,平時在開發的過程當中不免會有一些事件綁定,好比加一個button:
// containers/Home.js import React from 'react'; const Home = () => { return ( <div> <div>This is sanyuan</div> <button onClick={() => {alert('666')}}>click</button> </div> ) } export default Home
再試一下,你會驚奇的發現,事件綁定無效!那這是爲何呢?緣由很簡單,react-dom/server下的renderToString並無作事件相關的處理,所以返回給瀏覽器的內容不會有事件綁定。
那怎麼解決這個問題呢?
這就須要進行同構了。所謂同構,通俗的講,就是一套React代碼在服務器上運行一遍,到達瀏覽器又運行一遍。服務端渲染完成頁面結構,瀏覽器端渲染完成事件綁定。
那如何進行瀏覽器端的事件綁定呢?
惟一的方式就是讓瀏覽器去拉取JS文件執行,讓JS代碼來控制。因而服務端返回的代碼變成了這樣:
有沒有發現和以前的區別?區別就是多了一個script標籤。而它拉取的JS代碼就是來完成同構的。
那麼這個index.js咱們如何生產出來呢?
在這裏,要用到react-dom。具體作法其實就很簡單了:
//client/index. js import React from 'react'; import ReactDom from 'react-dom'; import Home from '../containers/Home'; ReactDom.hydrate(<Home />, document.getElementById('root'))
而後用webpack將其編譯打包成index.js:
//webpack.client.js const path = require('path'); const merge = require('webpack-merge'); const config = require('./webpack.base'); const clientConfig = { mode: 'development', entry: './src/client/index.js', output: { filename: 'index.js', path: path.resolve(__dirname, 'public') }, } module.exports = merge(config, clientConfig); //webpack.base.js module.exports = { module: { rules: [{ test: /\.js$/, loader: 'babel-loader', exclude: /node_modules/, options: { presets: ['@babel/preset-react', ['@babel/preset-env', { targets: { browsers: ['last 2 versions'] } }]] } }] } } //package.json的script部分 "scripts": { "dev": "npm-run-all --parallel dev:**", "dev:start": "nodemon --watch build --exec node \"./build/bundle.js\"", "dev:build:server": "webpack --config webpack.server.js --watch", "dev:build:client": "webpack --config webpack.client.js --watch" },
在這裏須要開啓express的靜態文件服務:
const app = express(); app.use(express.static('public'));
如今前端的script就能拿到控制瀏覽器的JS代碼啦。
綁定事件完成!
如今來初步總結一下同構代碼執行的流程:
如今寫一個路由的配置文件:
// Routes.js import React from 'react'; import {Route} from 'react-router-dom' import Home from './containers/Home'; import Login from './containers/Login' export default ( <div> <Route path='/' exact component={Home}></Route> <Route path='/login' exact component={Login}></Route> </div> )
在客戶端的控制代碼,也就是上面寫過的client/index.js中,要作相應的更改:
import React from 'react'; import ReactDom from 'react-dom'; import { BrowserRouter } from 'react-router-dom' import Routes from '../Routes' const App = () => { return ( <BrowserRouter> {Routes} </BrowserRouter> ) } ReactDom.hydrate(<App />, document.getElementById('root'))
這時候控制檯會報錯,
由於在Routes.js中,每一個Route組件外面包裹着一層div,但服務端返回的代碼中並無這個div,因此報錯。如何去解決這個問題?須要將服務端的路由邏輯執行一遍。
// server/index.js import express from 'express'; import {render} from './utils'; const app = express(); app.use(express.static('public')); //注意這裏要換成*來匹配 app.get('*', function (req, res) { res.send(render(req)); }); app.listen(3001, () => { console.log('listen:3001') });
// server/utils.js import Routes from '../Routes' import { renderToString } from 'react-dom/server'; //重要是要用到StaticRouter import { StaticRouter } from 'react-router-dom'; import React from 'react' export const render = (req) => { //構建服務端的路由 const content = renderToString( <StaticRouter location={req.path} > {Routes} </StaticRouter> ); return ` <html> <head> <title>ssr</title> </head> <body> <div id="root">${content}</div> <script src="/index.js"></script> </body> </html> ` }
如今路由的跳轉就沒有任何問題啦。
注意,這裏僅僅是一級路由的跳轉,多級路由的渲染在以後的系列中會用react-router-config中renderRoutes來處理。
這一節主要是講述Redux如何被引入到同構項目中以及其中須要注意的問題。
從新回顧一下redux的運做流程:
再回顧一下同構的概念,即在React代碼客戶端和服務器端各自運行一遍。
如今開始建立store。
在項目根目錄的store文件夾(總的store)下:
import {createStore, applyMiddleware, combineReducers} from 'redux'; import thunk from 'redux-thunk'; import { reducer as homeReducer } from '../containers/Home/store'; //合併項目組件中store的reducer const reducer = combineReducers({ home: homeReducer }) //建立store,並引入中間件thunk進行異步操做的管理 const store = createStore(reducer, applyMiddleware(thunk)); //導出建立的store export default store
Home文件夾下的工程文件結構以下:
在Home的store目錄下的各個文件代碼示例:
//constants.js export const CHANGE_LIST = 'HOME/CHANGE_LIST';
//actions.js import axios from 'axios'; import { CHANGE_LIST } from "./constants"; //普通action const changeList = list => ({ type: CHANGE_LIST, list }); //異步操做的action(採用thunk中間件) export const getHomeList = () => { return (dispatch) => { return axios.get('xxx') .then((res) => { const list = res.data.data; console.log(list) dispatch(changeList(list)) }); }; }
//reducer.js import { CHANGE_LIST } from "./constants"; const defaultState = { name: 'sanyuan', list: [] } export default (state = defaultState, action) => { switch(action.type) { default: return state; } }
//index.js import reducer from "./reducer"; //這麼作是爲了導出reducer讓全局的store來進行合併 //那麼在全局的store下的index.js中只需引入Home/store而不須要Home/store/reducer.js //由於腳手架會自動識別文件夾下的index文件 export {reducer}
下面是Home組件的編寫示例。
import React, { Component } from 'react'; import { connect } from 'react-redux'; import { getHomeList } from './store/actions' class Home extends Component { render() { const { list } = this.props return list.map(item => <div key={item.id}>{item.title}</div>) } } const mapStateToProps = state => ({ list: state.home.newsList, }) const mapDispatchToProps = dispatch => ({ getHomeList() { dispatch(getHomeList()); } }) //鏈接store export default connect(mapStateToProps, mapDispatchToProps)(Home);
對於store的鏈接操做,在同構項目中分兩個部分,一個是與客戶端store的鏈接,另外一部分是與服務端store的鏈接。都是經過react-redux中的Provider來傳遞store的。
客戶端:
//src/client/index.js import React from 'react'; import ReactDom from 'react-dom'; import {BrowserRouter, Route} from 'react-router-dom'; import { Provider } from 'react-redux'; import store from '../store' import routes from '../routes.js' const App = () => { return ( <Provider store={store}> <BrowserRouter> {routes} </BrowserRouter> </Provider> ) } ReactDom.hydrate(<App />, document.getElementById('root'))
服務端:
//src/server/index.js的內容保持不變 //下面是src/server/utils.js import Routes from '../Routes' import { renderToString } from 'react-dom/server'; import { StaticRouter } from 'react-router-dom'; import { Provider } from 'react-redux'; import React from 'react' export const render = (req) => { const content = renderToString( <Provider store={store}> <StaticRouter location={req.path} > {Routes} </StaticRouter> </Provider> ); return ` <html> <head> <title>ssr</title> </head> <body> <div id="root">${content}</div> <script src="/index.js"></script> </body> </html> ` }
其實上面這樣的store建立方式是存在問題的,什麼緣由呢?
上面的store是一個單例,當這個單例導出去後,全部的用戶用的是同一份store,這是不該該的。那麼這麼解這個問題呢?
在全局的store/index.js下修改以下:
//導出部分修改 export default () => { return createStore(reducer, applyMiddleware(thunk)) }
這樣在客戶端和服務端的js文件引入時其實引入了一個函數,把這個函數執行就會拿到一個新的store,這樣就能保證每一個用戶訪問時都是用的一份新的store。
在日常客戶端的React開發中,咱們通常在組件的componentDidMount生命週期函數進行異步數據的獲取。可是,在服務端渲染中卻出現了問題。
如今我在componentDidMount鉤子函數中進行Ajax請求:
import { getHomeList } from './store/actions' //...... componentDidMount() { this.props.getList(); } //...... const mapDispatchToProps = dispatch => ({ getList() { dispatch(getHomeList()); } })
//actions.js import { CHANGE_LIST } from "./constants"; import axios from 'axios' const changeList = list => ({ type: CHANGE_LIST, list }) export const getHomeList = () => { return dispatch => { //另外起的本地的後端服務 return axiosInstance.get('localhost:4000/api/news.json') .then((res) => { const list = res.data.data; dispatch(changeList(list)) }) } } //reducer.js import { CHANGE_LIST } from "./constants"; const defaultState = { name: 'sanyuan', list: [] } export default (state = defaultState, action) => { switch(action.type) { case CHANGE_LIST: const newState = { ...state, list: action.list } return newState default: return state; } }
好,如今啓動服務。
如今頁面可以正常渲染,可是打開網頁源代碼。
源代碼裏面並無這些列表數據啊!那這是爲何呢?
讓咱們來分析一下客戶端和服務端的運行流程,當瀏覽器發送請求時,服務器接受到請求,這時候服務器和客戶端的store都是空的,緊接着客戶端執行componentDidMount生命週期中的函數,獲取到數據並渲染到頁面,然而服務器端始終不會執行componentDidMount,所以不會拿到數據,這也致使服務器端的store始終是空的。換而言之,關於異步數據的操做始終只是客戶端渲染。
如今的工做就是讓服務端將得到數據的操做執行一遍,以達到真正的服務端渲染的效果。
在完成這個方案以前須要改造一下原有的路由,也就是routes.js
import Home from './containers/Home'; import Login from './containers/Login'; export default [ { path: "/", component: Home, exact: true, loadData: Home.loadData,//服務端獲取異步數據的函數 key: 'home' }, { path: '/login', component: Login, exact: true, key: 'login' } }];
此時客戶端和服務端中編寫的JSX代碼也發生了相應變化
//客戶端 //如下的routes變量均指routes.js導出的數組 <Provider store={store}> <BrowserRouter> <div> { routers.map(route => { <Route {...route} /> }) } </div> </BrowserRouter> </Provider>
//服務端 <Provider store={store}> <StaticRouter> <div> { routers.map(route => { <Route {...route} /> }) } </div> </StaticRouter> </Provider>
其中配置了一個loadData參數,這個參數表明了服務端獲取數據的函數。每次渲染一個組件獲取異步數據時,都會調用相應組件的這個函數。所以,在編寫這個函數具體的代碼以前,咱們有必要想清楚如何來針對不一樣的路由來匹配不一樣的loadData函數。
在server/utils.js中加入如下邏輯
import { matchRoutes } from 'react-router-config'; //調用matchRoutes用來匹配當前路由(支持多級路由) const matchedRoutes = matchRoutes(routes, req.path) //promise對象數組 const promises = []; matchedRoutes.forEach(item => { //若是這個路由對應的組件有loadData方法 if (item.route.loadData) { //那麼就執行一次,並將store傳進去 //注意loadData函數調用後須要返回Promise對象 promises.push(item.route.loadData(store)) } }) Promise.all(promises).then(() => { //此時該有的數據都已經到store裏面去了 //執行渲染的過程(res.send操做) } )
如今就能夠安心的寫咱們的loadData函數,其實前面的鋪墊工做作好後,這個函數是至關容易的。
import { getHomeList } from './store/actions' Home.loadData = (store) => { return store.dispatch(getHomeList()) }
//actions.js export const getHomeList = () => { return dispatch => { return axios.get('xxxx') .then((res) => { const list = res.data.data; dispatch(changeList(list)) }) } }
根據這個思路,服務端渲染中異步數據的獲取功能就完成啦。
其實目前作了這裏仍是存在一些細節問題的。好比當我將生命週期鉤子裏面的異步請求函數註釋,如今頁面中不會有任何的數據,可是打開網頁源代碼,卻發現:
數據已經掛載到了服務端返回的HTML代碼中。那這就說明服務端和客戶端的store不一樣步的問題。
其實也很好理解。當服務端拿到store並獲取數據後,客戶端的js代碼又執行一遍,在客戶端代碼執行的時候又建立了一個空的store,兩個store的數據不能同步。
那如何才能讓這兩個store的數據同步變化呢?
首先,在服務端獲取獲取以後,在返回的html代碼中加入這樣一個script標籤:
<script> window.context = { state: ${JSON.stringify(store.getState())} } </script>
這叫作數據的「注水」操做,即把服務端的store數據注入到window全局環境中。
接下來是「脫水」處理,換句話說也就是把window上綁定的數據給到客戶端的store,能夠在客戶端store產生的源頭進行,即在全局的store/index.js中進行。
//store/index.js import {createStore, applyMiddleware, combineReducers} from 'redux'; import thunk from 'redux-thunk'; import { reducer as homeReducer } from '../containers/Home/store'; const reducer = combineReducers({ home: homeReducer }) //服務端的store建立函數 export const getStore = () => { return createStore(reducer, applyMiddleware(thunk)); } //客戶端的store建立函數 export const getClientStore = () => { const defaultState = window.context ? window.context.state : {}; return createStore(reducer, defaultState, applyMiddleware(thunk)); }
至此,數據的脫水和注水操做完成。可是仍是有一些瑕疵,其實當服務端獲取數據以後,客戶端並不須要再發送Ajax請求了,而客戶端的React代碼仍然存在這樣的浪費性能的代碼。怎麼辦呢?
仍是在Home組件中,作以下的修改:
componentDidMount() { //判斷當前的數據是否已經從服務端獲取 //要知道,若是是首次渲染的時候就渲染了這個組件,則不會重複發請求 //若首次渲染頁面的時候未將這個組件渲染出來,則必定要執行異步請求的代碼 //這兩種狀況對於同一組件是都是有可能發生的 if (!this.props.list.length) { this.props.getHomeList() } }
一路作下來,異步數據的服務端渲染仍是比較複雜的,可是難度並非很大,須要耐心地理清思路。
至此一個比較完整的SSR框架就搭建的差很少了,可是還有一些內容須要補充,以後會繼續更新的。加油吧!
其實任何技術都是與它的應用場景息息相關的。這裏咱們反覆談的SSR,其實不到萬不得已咱們是用不着它的,SSR所解決的最大的痛點在於SEO,但它同時帶來了更昂貴的成本。不只由於服務端渲染須要更加複雜的處理邏輯,還由於同構的過程須要服務端和客戶端都執行一遍代碼,這雖然對於客戶端並無什麼大礙,但對於服務端倒是巨大的壓力,由於數量龐大的訪問量,對於每一次訪問都要另外在服務器端執行一遍代碼進行計算和編譯,大大地消耗了服務器端的性能,成本隨之增長。若是訪問量足夠大的時候,之前不用SSR的時候一臺服務器可以承受的壓力如今或許要增長到10臺才能抗住。痛點在於SEO,但若是實際上對SEO要求並不高的時候,那使用SSR就大可沒必要了。
那一樣地,爲何要引入node做爲中間層呢?它是處在哪二者的中間?又是解決了什麼場景下的問題?
在不用中間層的先後端分離開發模式下,前端通常直接請求後端的接口。但真實場景下,後端所給的數據格式並非前端想要的,但處於性能緣由或者其餘的因素接口格式不能更改,這時候須要在前端作一些額外的數據處理操做。前端來操做數據自己無可厚非,可是當數據量變得龐大起來,那麼在客戶端就是產生巨大的性能損耗,甚至影響到用戶體驗。在這個時候,node中間層的概念便應運而生。
它最終解決的先後端協做的問題。
通常的中間層工做流是這樣的:前端每次發送請求都是去請求node層的接口,而後node對於相應的前端請求作轉發,用node去請求真正的後端接口獲取數據,獲取後再由node層作對應的數據計算等處理操做,而後返回給前端。這就至關於讓node層替前端接管了對數據的操做。
在以前搭建的SSR框架中,服務端和客戶端請求利用的是同一套請求後端接口的代碼,但這是不科學的。
對客戶端而言,最好經過node中間層。而對於這個SSR項目而言,node開啓的服務器原本就是一箇中間層的角色,於是對於服務器端執行數據請求而言,就能夠直接請求真正的後端接口啦。
//actions.js //參數server表示當前請求是否發生在node服務端 const getUrl = (server) => { return server ? 'xxxx(後端接口地址)' : '/api/sanyuan.json(node接口)'; } //這個server參數是Home組件裏面傳過來的, //在componentDidMount中調用這個action時傳入false, //在loadData函數中調用時傳入true, 這裏就不貼組件代碼了 export const getHomeList = (server) => { return dispatch => { return axios.get(getUrl(server)) .then((res) => { const list = res.data.data; dispatch(changeList(list)) }) } }
在server/index.js應拿到前端的請求作轉發,這裏是直接用proxy形式來作,也能夠用node單獨向後端發送一次HTTP請求。
//增長以下代碼 import proxy from 'express-http-proxy'; //至關於攔截到了前端請求地址中的/api部分,而後換成另外一個地址 app.use('/api', proxy('http://xxxxxx(服務端地址)', { proxyReqPathResolver: function(req) { return '/api'+req.url; } }));
其實請求的代碼仍是有優化的餘地的,仔細想一想,上面的server參數實際上是不用傳遞的。
如今咱們利用axios的instance和thunk裏面的withExtraArgument來作一些封裝。
//新建server/request.js import axios from 'axios' const instance = axios.create({ baseURL: 'http://xxxxxx(服務端地址)' }) export default instance //新建client/request.js import axios from 'axios' const instance = axios.create({ //即當前路徑的node服務 baseURL: '/' }) export default instance
而後對全局下store的代碼作一個微調:
import {createStore, applyMiddleware, combineReducers} from 'redux'; import thunk from 'redux-thunk'; import { reducer as homeReducer } from '../containers/Home/store'; import clientAxios from '../client/request'; import serverAxios from '../server/request'; const reducer = combineReducers({ home: homeReducer }) export const getStore = () => { //讓thunk中間件帶上serverAxios return createStore(reducer, applyMiddleware(thunk.withExtraArgument(serverAxios))); } export const getClientStore = () => { const defaultState = window.context ? window.context.state : {}; //讓thunk中間件帶上clientAxios return createStore(reducer, defaultState, applyMiddleware(thunk.withExtraArgument(clientAxios))); }
如今Home組件中請求數據的action無需傳參,actions.js中的請求代碼以下:
export const getHomeList = () => { //返回函數中的默認第三個參數是withExtraArgument傳進來的axios實例 return (dispatch, getState, axiosInstance) => { return axiosInstance.get('/api/sanyuan.json') .then((res) => { const list = res.data.data; console.log(res) dispatch(changeList(list)) }) } }
至此,代碼優化就作的差很少了,這種代碼封裝的技巧其實能夠用在其餘的項目當中,其實仍是比較優雅的。
如今將routes.js的內容改變以下:
import Home from './containers/Home'; import Login from './containers/Login'; import App from './App' //這裏出現了多級路由 export default [{ path: '/', component: App, routes: [ { path: "/", component: Home, exact: true, loadData: Home.loadData, key: 'home', }, { path: '/login', component: Login, exact: true, key: 'login', } ] }]
如今的需求是讓頁面公用一個Header組件,App組件編寫以下:
import React from 'react'; import Header from './components/Header'; const App = (props) => { console.log(props.route) return ( <div> <Header></Header> </div> ) } export default App;
對於多級路由的渲染,須要服務端和客戶端各執行一次。
所以編寫的JSX代碼都應有所實現:
//routes是指routes.js中返回的數組 //服務端: <Provider store={store}> <StaticRouter location={req.path} > <div> {renderRoutes(routes)} </div> </StaticRouter> </Provider> //客戶端: <Provider store={getClientStore()}> <BrowserRouter> <div> {renderRoutes(routes)} </div> </BrowserRouter> </Provider>
這裏都用到了renderRoutes方法,其實它的工做很是簡單,就是根據url渲染一層路由的組件(這裏渲染的是App組件),而後將下一層的路由經過props傳給目前的App組件,依次循環。
那麼,在App組件就能經過props.route.routes拿到下一層路由進行渲染:
import React from 'react'; import Header from './components/Header'; //增長renderRoutes方法 import { renderRoutes } from 'react-router-config'; const App = (props) => { console.log(props.route) return ( <div> <Header></Header> <!--拿到Login和Home組件的路由--> {renderRoutes(props.route.routes)} </div> ) } export default App;
至此,多級路由的渲染就完成啦。
仍是以Home組件爲例
//Home/style.css body { background: gray; }
如今,在Home組件代碼中引入:
import styles from './style.css';
要知道這樣的引入CSS代碼的方式在通常環境下是運行不起來的,須要在webpack中作相應的配置。
首先安裝相應的插件。
npm install style-loader css-loader --D
//webpack.client.js const path = require('path'); const merge = require('webpack-merge'); const config = require('./webpack.base'); const clientConfig = { mode: 'development', entry: './src/client/index.js', module: { rules: [{ test: /\.css?$/, use: ['style-loader', { loader: 'css-loader', options: { modules: true } }] }] }, output: { filename: 'index.js', path: path.resolve(__dirname, 'public') }, } module.exports = merge(config, clientConfig);
//webpack.base.js代碼,回顧一下,配置了ES語法相關的內容 module.exports = { module: { rules: [{ test: /\.js$/, loader: 'babel-loader', exclude: /node_modules/, options: { presets: ['@babel/preset-react', ['@babel/preset-env', { targets: { browsers: ['last 2 versions'] } }]] } }] } }
好,如今在客戶端CSS已經產生了效果。
但是打開網頁源代碼:
咦?裏面並無出現任何有關CSS樣式的代碼啊!那這是什麼緣由呢?很簡單,其實咱們的服務端的CSS加載尚未作。接下來咱們來完成CSS代碼的服務端的處理。
首先,來安裝一個webpack的插件,
npm install -D isomorphic-style-loader
而後再webpack.server.js中作好相應的css配置:
//webpack.server.js const path = require('path'); const nodeExternals = require('webpack-node-externals'); const merge = require('webpack-merge'); const config = require('./webpack.base'); const serverConfig = { target: 'node', mode: 'development', entry: './src/server/index.js', externals: [nodeExternals()], module: { rules: [{ test: /\.css?$/, use: ['isomorphic-style-loader', { loader: 'css-loader', options: { modules: true } }] }] }, output: { filename: 'bundle.js', path: path.resolve(__dirname, 'build') } } module.exports = merge(config, serverConfig);
它作了些什麼事情?
再看看這行代碼:
import styles from './style.css';
引入css文件時,這個isomorphic-style-loader幫咱們在styles中掛了三個函數。輸出styles看看:
如今咱們的目標是拿到CSS代碼,直接經過styles._getCss便可得到。
那咱們拿到CSS代碼後放到哪裏呢?其實react-router-dom中的StaticRouter中已經幫咱們準備了一個鉤子變量context。以下
//context從外界傳入 <StaticRouter location={req.path} context={context}> <div> {renderRoutes(routes)} </div> </StaticRouter>
這就意味着在路由配置對象routes中的組件都能在服務端渲染的過程當中拿到這個context,並且這個context對於組件來講,就至關於組件中的props.staticContext。而且,這個props.staticContext只會在服務端渲染的過程當中存在,而客戶端渲染的時候不會被定義。這就讓咱們可以經過這個變量來區分兩種渲染環境啦。
如今,咱們須要在服務端的render函數執行以前,初始化context變量的值:
let context = { css: [] }
咱們只須要在組件的componentWillMount生命週期中編寫相應的邏輯便可:
componentWillMount() { //判斷是否爲服務端渲染環境 if (this.props.staticContext) { this.props.staticContext.css.push(styles._getCss()) } }
服務端的renderToString執行完成後,context的CSS如今已是一個有內容的數組,讓咱們來獲取其中的CSS代碼:
//拼接代碼 const cssStr = context.css.length ? context.css.join('\n') : '';
如今掛載到頁面:
//放到返回的html字符串裏的header裏面 <style>${cssStr}</style>
網頁源代碼中看到了CSS代碼,效果也沒有問題。CSS渲染完成!
也許你已經發現,對於每個含有樣式的組件,都須要在componentWillMount生命週期中執行徹底相同的邏輯,對於這些邏輯咱們是否可以把它封裝起來,不用反覆出現呢?
實際上是能夠實現的。利用高階組件就能夠完成:
//根目錄下建立withStyle.js文件 import React, { Component } from 'react'; //函數返回組件 //須要傳入的第一個參數是須要裝飾的組件 //第二個參數是styles對象 export default (DecoratedComponent, styles) => { return class NewComponent extends Component { componentWillMount() { //判斷是否爲服務端渲染過程 if (this.props.staticContext) { this.props.staticContext.css.push(styles._getCss()) } } render() { return <DecoratedComponent {...this.props} /> } } }
而後讓這個導出的函數包裹咱們的Home組件。
import WithStyle from '../../withStyle'; //...... const exportHome = connect(mapStateToProps, mapDispatchToProps)(withStyle(Home, styles)); export default exportHome;
這樣是否是簡潔不少了呢?未來對於愈來愈多的組件,採用這種方式也是徹底能夠的。
這一節咱們來簡單的聊一點SEO相關的內容。
所謂SEO(Search Engine Optimization),指的是利用搜索引擎的規則提升網站在有關搜索引擎內的天然排名。如今的搜索引擎爬蟲通常是全文分析的模式,分析內容涵蓋了一個網站主要3個部分的內容:文本、多媒體(主要是圖片)和外部連接,經過這些來判斷網站的類型和主題。所以,在作SEO優化的時候,能夠圍繞這三個角度來展開。
對於文原本說,儘可能不要抄襲已經存在的文章,以寫技術博客爲例,東拼西湊抄來的文章排名通常不會高,若是須要引用別人的文章要記得聲明出處,不過最好是原創,這樣排名效果會比較好。多媒體包含了視頻、圖片等文件形式,如今比較權威的搜索引擎爬蟲好比Google作到對圖片的分析是基本沒有問題的,所以高質量的圖片也是加分項。另外是外部連接,也就是網站中a標籤的指向,最好也是和當前網站相關的一些連接,更容易讓爬蟲分析。
固然,作好網站的門面,也就是標題和描述也是相當重要的。如:
網站標題中不只僅包含了關鍵詞,並且有比較詳細和靠譜的描述,這讓用戶一看到就以爲很是親切和可靠,有一種想要點擊的衝動,這就代表網站的轉化率
比較高。
而React項目中,開發的是單頁面的應用,頁面始終只有一份title和description,如何根據不一樣的組件顯示來對應不一樣的網站標題和描述呢?
實際上是能夠作到的。
npm install react-helmet --save
組件代碼:(仍是以Home組件爲例)
import { Helmet } from 'react-helmet'; //... render() { return ( <Fragment> <!--Helmet標籤中的內容會被放到客戶端的head部分--> <Helmet> <title>這是三元的技術博客,分享前端知識</title> <meta name="description" content="這是三元的技術博客,分享前端知識"/> </Helmet> <div className="test"> { this.getList() } </div> </Fragment> ); //...
這只是作了客戶端的部分,在服務端仍須要作相應的處理。
其實也很是簡單:
//server/utils.js import { renderToString } from 'react-dom/server'; import { StaticRouter } from 'react-router-dom'; import React from 'react'; import { Provider } from "react-redux"; import { renderRoutes } from 'react-router-config'; import { Helmet } from 'react-helmet'; export const render = (store, routes, req, context) => { const content = renderToString( <Provider store={store}> <StaticRouter location={req.path} context={context}> <div> {renderRoutes(routes)} </div> </StaticRouter> </Provider> ); //拿到helmet對象,而後在html字符串中引入 const helmet = Helmet.renderStatic(); const cssStr = context.css.length ? context.css.join('\n') : ''; return ` <html> <head> <style>${cssStr}</style> ${helmet.title.toString()} ${helmet.meta.toString()} </head> <body> <div id="root">${content}</div> <script> window.context = { state: ${JSON.stringify(store.getState())} } </script> <script src="/index.js"></script> </body> </html> ` };
如今來看看效果:
網頁源代碼中顯示出對應的title和description, 客戶端的顯示也沒有任何問題,大功告成!
關於React的服務端渲染原理,就先分享到這裏,內容仍是比較複雜的,對於前端的綜合能力要求也比較高,可是堅持跟着學下來,必定會大有裨益的。相信你看了這一系列以後也有能力造出本身的SSR輪子,更加深入地理解這一方面的技術。
參考資料:
慕課網《React服務器渲染原理解析與實踐》課程