前言
所謂同構,簡而言之就是,第一次訪問後臺服務時,後臺直接把前端要顯示的界面全部返回,而不是像 SPA 項目只渲染一個 <div id="root"></div> 剩下的都是靠 JavaScript 腳本去加載。這樣一來可以大大減少首屏等待時間。
同構概念並不複雜,它也非項目必需品,但是探索它的原理卻是必須的。
閱讀本文需要你具備以下技術基礎: Node.js 、 React 、 React Router 、 Redux 、 webpack 。
本文將分以下兩部分去講述:
同構思路分析,讓你對同構有一個概念上的了解;手寫同構框架,深入理解同構原理。同構思路
CSR 客戶端渲染
CSR 客戶端渲染,這個就是很好理解了,使用 React , React Router 前端自己控制路由的 SPA 項目,就可以理解成客戶端渲染。它有一個非常大的優勢就是,只是首次訪問會請求後臺服務加載相應文件,之後的訪問都是前端自己判斷 URL 展示相關組件,因此除了首次訪問速度慢些之外,之後的訪問速度都很快。
執行命令: create-react-app react-csr 創建一個 React SPA 單頁面應用項目 。執行命令: npm run start 啟動項目。
查看網頁原始碼:
只有一個 <div id="root"></div> 和 一些 script 腳本。最終呈現出來的界面卻是這樣的:
原理很簡單,相信學習過 webpack 的同學都知道,那就是 webpack 把所有代碼都打包成相應腳本並插入到 HTML 界面中,瀏覽器會解析 script 腳本,通過動態插入 DOM 的方式展示出相應界面。
客戶端渲染的優劣勢
客戶端渲染流程如下:
優勢:
前端負責渲染頁面,後端負責實現接口,各自幹好各自的事情,對開發效率有極大的提升;前端在跳轉界面的時候不需要請求後臺,加速了界面跳轉的速度,提高用戶體驗。劣勢:
由於需要等待 JS 文件加載以及後臺接口數據請求因此首屏加載時間長,用戶體驗較差;由於大部分內容都是通過 JS 加載因此搜尋引擎無法爬取分析網頁內容導致網站無法 SEO 。SSR 服務端渲染
SSR 是服務端渲染技術,它本身是一項比較普通的技術, Node.js 使用 ejs 模板引擎輸出一個界面這就是服務端渲染。每次訪問一個路由都是請求後臺服務,重新加載文件渲染界面。
同樣我們也來創建一個簡單的 Node.js 服務:
mkdir express-ssrcd express-ssrnpm init -ytouch app.jsnpm i express --save複製代碼app.js
const express = require('express')const app = express()app.get('/',function (req,res) { res.send(`<html> <head> <title>express ssr</title> </head> <body> <h1>Hello SSR</h1> </body> </html>` )})app.listen(3000);複製代碼啟動服務: node app.js
這就是最簡單的服務端渲染一個界面了。服務端渲染的本質就是頁面顯示的內容是伺服器端生產出來的。
服務端渲染的優劣勢
服務端渲染流程:
優勢:
整個 HTML 都通過服務端直接輸出 SEO 友好;加載首頁不需要加載整個應用的 JS 文件,首頁加載速度快。劣勢:
訪問一個應用程式的每個界面都需要訪問伺服器,體驗對比 CSR 稍差。我們會發現一件很有意思的事,服務端渲染的優點就是客戶端渲染的缺點,服務端渲染的缺點就是客戶端渲染的優點,反之亦然。那為何不將傳統的純服務端直出的首屏優勢和客戶端渲染站內跳轉優勢結合,以取得最優解?這就引出了當前流行的服務端渲染( Server Side Rendering ),或者稱之為「同構渲染」更為準確。
同構渲染
所謂同構,通俗的講,就是一套 React 代碼在伺服器上運行一遍,到達瀏覽器又運行一遍。服務端渲染完成頁面結構,客戶端渲染綁定事件。它是在 SPA 的基礎上,利用服務端渲染直出首屏,解決了單頁面應用首屏渲染慢的問題。
同構渲染流程
簡單同構案例
要實現同構,簡單來說就是以下兩步:
服務端要能運行 React 代碼;瀏覽器同樣運行 React 代碼。1、創建項目
mkdir react-ssrcd react-ssrnpm init -y複製代碼2、項目目錄結構分析
├── src│ ├── client│ │ ├── index.js // 客戶端業務入口文件│ ├── server│ │ └── index.js // 服務端業務入口文件│ ├── container // React 組件│ │ └── Home│ │ └── Home.js│ │├── config // 配置文件夾│ ├── webpack.client.js // 客戶端配置文件│ ├── webpack.server.js // 服務端配置文件│ ├── webpack.common.js // 共有配置文件├── .babelrc // babel 配置文件├── package.json複製代碼首先我們編寫一個簡單的 React 組件, container/Home/Home.js
import React from"react";const Home = ()=>{return (<div> hello world<br/><button onClick={()=> alert("hello world")}>按鈕</button></div> )}exportdefault Home;複製代碼安裝客戶端渲染的慣例,我們寫一個客戶端渲染的入口文件, client/index.js
import React from"react";import ReactDom from"react-dom";import Home from"../containers/Home";ReactDom.hydrate(<Home/>,document.getElementById("root"));// ReactDom.render(<Home/>,document.getElementById("root"));複製代碼以前看到的都是調用 render 方法,這裡使用 hydrate 方法,它的作用是什麼?
ReactDOM.hydrate
與 render() 相同,但它用於在 ReactDOMServer 渲染的容器中對 HTML 的內容進行 hydrate 操作。 React 會嘗試在已有標記上綁定事件監聽器。
我們都知道純粹的 React 代碼放在瀏覽器上是無法執行的,因此需要打包工具進行處理,這裡我們使用 webpack ,下面我們來看看 webpack 客戶端的配置:
webpack.common.js
module.exports = { module:{ rules:[ { test: /\.js$/, exclude: /node_modules/, loader: "babel-loader", } ] }}複製代碼.babelrc
{"presets":[ ["@babel/preset-env"], ["@babel/preset-react"] ]}複製代碼webpack.client.js
const path = require("path");const {merge} = require("webpack-merge");const commonConfig = require("./webpack.common");const clientConfig = { mode: "development", entry:"./src/client/index.js", output:{ filename:"index.js", path:path.resolve(__dirname,"../public") },}module.exports = merge(commonConfig,clientConfig);複製代碼代碼解析:通過 entry 配置的入口文件,對 React 代碼進行打包,最後輸出到 public 目錄下的 index.js 。
在以往,直接在 HTML 引入這個打包後的 JS 文件,界面就顯示出來了,我們稱之為純客戶端渲染。這裡我們就不這樣使用,因為我們還需要服務端渲染。
接下來,看看服務端渲染文件 server/index.js
import express from"express";import { renderToString } from"react-dom/server";import React from"react";import Home from"../containers/Home";const app = express(); // {1}app.use(express.static('public')) // {2}const content = renderToString(<Home />); //{3}app.get('/',function (req,res) {// {4} res.send(` <!doctype html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>React SSR</title> </head> <body> <div id="root">${content}</div> <script src="/index.js"></script> </body> </html> `)})app.listen(3000);複製代碼代碼解析:
{1},創建一個 express 實例對象{2},開啟一個靜態資源服務,監聽 public 目錄,還記得客戶端的打包文件就放到了 public 目錄了把,這裡通過監聽,我們就可以這樣 localhost:3000/index.js 訪問該靜態資源{3},把 React 組件通過 renderToString 方法生成 HTML{4},當用戶訪問 localhost:3000 時便會返回 res.send 中的 HTML 內容,該 HTML 中把 React 生成的 HTML 片段也插入進去一同返回給用戶了,這樣就實現了服務端渲染。通過 <script src="/index.js"></script> 這段腳本加載了客戶端打包後的 React 代碼,這樣就實現了客戶端渲染,因此一個簡單同構項目就這樣實現了。你會發現一個奇怪的現象,為什麼寫 Node.js 代碼使用的卻是 ESModule 語法,是的沒錯,因為我們要在服務端解析 React 代碼,作為同構項目,因此統一語法也是非常必要的。所以 Node.js 也需要配置相應的 webpack 編譯文件:
webpack.server.js
const path = require("path");const nodeExternals = require("webpack-node-externals");const {merge} = require("webpack-merge");const commonConfig = require("./webpack.common");const serverConfig = { target:"node", //為了不把nodejs內置模塊打包進輸出文件中,例如:fs net模塊等; mode: "development", entry:"./src/server/index.js", output:{ filename:"bundle.js", path:path.resolve(__dirname,"../build") }, externals:[nodeExternals()], //為了不把node_modules目錄下的第三方模塊打包進輸出文件中,因為nodejs默認會去node_modules目錄下去尋找和使用第三方模塊。};module.exports = merge(serverConfig,commonConfig);複製代碼如果 webpack 不熟悉的可以查看作者的>>>深入淺出 webpack 之基礎配置篇
本小結完整代碼地址>>>點擊查看
到此我們就完成了一個簡單的同構項目,這裡您應該會有幾個疑問?
renderToString 有什麼作用?為什麼服務端加載了一次,客戶端還需要再次加載呢?服務端加載了 React 輸出的代碼片段,客戶端又執行了一次,這樣是不是會加載兩次導致資源浪費呢?ReactDOMServer.renderToString(element)
將 React 元素渲染為初始 HTML 。 React 將返回一個 HTML 字符串。你可以使用此方法在服務端生成 HTML ,並在首次請求時將標記下發,以加快頁面加載速度,並允許搜尋引擎爬取你的頁面以達到 SEO 優化的目的。
為什麼服務端加載了一次,客戶端還需要再次加載呢?
原因很簡單,服務端使用 renderToString 渲染頁面,而 react-dom/server 下的 renderToString 並沒有做事件相關的處理,因此返回給瀏覽器的內容不會有事件綁定,渲染出來的頁面只是一個靜態的 HTML 頁面。只有在客戶端渲染 React 組件並初始化 React 實例後,才能更新組件的 state 和 props ,初始化 React 的事件系統,讓 React 組件真正「 動」 起來。
是否加載兩次?
如果你在已有服務端渲染標記的節點上調用 ReactDOM.hydrate() 方法, React 將會保留該節點且只進行事件處理綁定,從而讓你有一個非常高性能的首次加載體驗。因此不必擔心加載多次的問題。
是否意猶未盡?那就讓我們更加深入的學習它,手寫一個同構框架,徹底理解同構渲染的原理。
手寫同構框架
實現一個同構框架,我們還有很多問題需要解決:
兼容路由;兼容 Redux ;兼容異步數據請求;兼容 CSS 樣式渲染。問題很多,我們逐個擊破。
兼容路由
同構項目中當在瀏覽器中輸入 URL 後,瀏覽器是如何找到對應的界面?
瀏覽器收到 URL 地址例如: http://localhost:3000/login ;後臺路由找到對應的 React 組件傳入到 renderToString 中,然後拼接 HTML 輸出頁面;瀏覽器加載打包後的 JS 文件,並解析執行前端路由,輸出相應的前端組件,發現是服務端渲染,因此只做事件綁定處理,不進行重複渲染,此時前端路由路由開始接管界面,之後跳轉界面與後臺無關。既然需要路由我們就先安裝下: npm install react-router-dom
之前我們只定義了一個 Home 組件,為了演示路由,我們再定義一個 Login 組件:
...import { Link } from"react-router-dom";const Login = ()=>{return (<div><h1>登錄頁</h1><br/><Link to="/">跳轉到首頁</Link></div> )}複製代碼改造 Home 組件
const Home = ()=>{return (<div><h1>首頁</h1><br/><Link to="/login">跳轉到登錄頁</Link><br/><button onClick={() => console.log("click me")}>點擊</button></div> )}複製代碼現在我們有兩個組件了,可以開始定義相關路由:
src/Routes.js
...import {Route} from"react-router-dom";exportdefault (<div><Route path="/"exactcomponent={Home} /> // 訪問根路徑時展示Home組件<Route path="/login"component={Login} /> // 訪問/login路徑時展示Login組件</div>)複製代碼改造客戶端路由:src/client/index.js
...import { BrowserRouter } from"react-router-dom";import Routes from"../Routes";const App = ()=>{return (<BrowserRouter> {Routes}</BrowserRouter> )}ReactDom.hydrate(<App />,document.getElementById("root"));複製代碼與普通 SPA 項目沒有任何區別。
改造服務端路由:src/server/index.js
...import { StaticRouter } from"react-router-dom";import Routes from"../Routes";const app = express();app.use(express.static('public'))const render = (req)=>{const content = renderToString((<StaticRouter location={req.path}> {Routes}</StaticRouter> ));return` <!doctype html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>React SSR</title> </head> <body> <div id="root">${content}</div> <script src="/index.js"></script> </body> </html> `}app.get('*',function (req,res) { res.send(render(req))})複製代碼服務端跟之前的區別就是這段代碼:
<StaticRouter location={req.path}> {Routes}</StaticRouter>複製代碼為什麼不是 BrowserRouter 而是 StaticRouter 呢?
主要是因為 BrowserRouter 使用的是 History API 記錄位置,而 History API 是屬於瀏覽器的 API ,在 SSR 的環境下,服務端不能使用瀏覽器 API 。
StaticRouter
靜態路由,通過初始傳入的 location 地址找到相應組件。區別於客戶端的動態路由。官網解釋
查看最終效果:
如果對於 React Router 不熟悉的同學可以查看作者的>>>React Router 入門與原理
本小結完整代碼地址>>>點擊查看
兼容 Redux
Redux 一直以來都是 React 技術棧裡最難理解的部分,它的概念繁多,如果想要徹底理解本小節及以後的內容,需要您對 Redux 有一定的了解,如果對於 Redux 不熟悉的同學可以查看作者的>>>Redux 應用與原理。
安裝包:
npm i redux react-redux redux-thunk --save複製代碼redux 庫;react-redux 是 react 與 redux 的橋梁;redux-thunk 是 redux 中間件, redux 處理異步請求方案。src/store/index.js
import {createStore, applyMiddleware} from"redux";import thunk from"redux-thunk";const reducer = (state={name:"Lion"},action)=>{return state;}const getStore = ()=>{return createStore(reducer,applyMiddleware(thunk));}exportdefault getStore;複製代碼輸出一個方法 getStore 用於創建全局 store 對象。
改造 server 端, src/server/render.js
... 省略import { Provider } from"react-redux";import getStore from"../store";exportconst render = (req)=>{const content = renderToString((<Provider store={getStore()}><StaticRouter location={req.path}> {Routes}</StaticRouter></Provider> ));return` ... 省略 `}複製代碼通過 Provider 組件把 store 對象數據共享給所有子組件,它的本質還是通過 context 共享數據。
改造 client 端, src/client/index.js
...import { Provider } from"react-redux";import getStore from"../store";const App = ()=>{return (<Provider store={getStore()}><BrowserRouter> {Routes}</BrowserRouter></Provider> )}ReactDom.hydrate(<App />,document.getElementById("root"));複製代碼同 server 端改造非常類似。
redux 都添加完畢後,最後我們在組件中使用 redux 的方式獲取數據,改造 Home 組件:
import React from"react";import { Link } from"react-router-dom";import { connect } from"react-redux";const Home = (props)=>{return (<div><h1>首頁</h1><div>{props.name}</div><br/><Link to="/login">跳轉到登錄頁</Link><br/><button onClick={() => console.log("click me")}>點擊</button></div> )}const mapStateToProps = (state)=>({ name:state.name})exportdefault connect(mapStateToProps,null)(Home);複製代碼其實核心就是這幾行代碼:
const mapStateToProps = (state)=>({ name:state.name})exportdefault connect(mapStateToProps,null)(Home);複製代碼connect 接收 mapStateToProps 、 mapDispatchToProps 兩個方法,返回一個高階函數,這個高階函數接收一個組件,返回一個新組件,其實就是給傳入的組件增加一些屬性和功能。
這樣一來我們的 Home 組件就可以使用 name 屬性了。改造完畢,我們來看看效果:
可以正常使用,這樣我們就輕鬆的集成了 redux 。
本小結完整代碼地址>>>點擊查看
兼容異步數據請求
在構建企業級項目時, redux 使用就更為複雜,而且實戰中我們一般都需要請求後臺數據,讓我們來改造改造項目,使他成為企業級項目。
redux 改造
一般我們會把 redux 相關的代碼都放入 store 文件夾下,我們來看看它的新目錄:
├── src│ ├── store│ │ ├── actions.js│ │ ├── constans.js│ │ └── reducer.js└───────└── index.js複製代碼actions 負責生成 action ;constans 定義常量;reducer 定義 reducer ;index 輸出 store 。actions.js
import axios from'axios';import {CHANGE_USER_LIST} from"./constants";const changeUserList = (list)=>{return { type:CHANGE_USER_LIST, list }}exportconst getUserList = (dispatch)=>{return ()=>{ axios.get('https://reqres.in/api/users').then((res)=>{ dispatch(changeUserList(res.data.data)); }); }}複製代碼導出 getUserList 方法,它的主要職責是向後臺發送真實數據請求。
[注意] 這裡發送的請求是真實的,具體可以參考Reqres是真正的API。
constants.js
exportconst CHANGE_USER_LIST = 'HOME/CHANGE_USER_LIST';複製代碼輸出常量,定義常量可以保證您在調用時不容易出錯。
reducer.js
import { CHANGE_USER_LIST } from"./constants";// {1}const defaultState = { userList:[]};exportdefault (state = defaultState , action)=>{switch (action.type) {// {2}case CHANGE_USER_LIST:return { ...state, userList:action.list } default:return state; }}複製代碼代碼解析:
{1},定義默認 state , userList 為空數組;{2},當接收到 type 為 CHANGE_USER_LIST 的 dispatch 時,更新用戶列表,這也是我們在 actions 那裡接收到後臺請求數據之後發送的 dispatch , dispatch(changeUserList(res.data.data));redux 改造的差不多了,接下來改造 Home 組件:src/containers/Home/index.js
import React,{useEffect} from"react";import { Link } from"react-router-dom";import { connect } from"react-redux";import { getUserList } from"../../store/actions";const Home = ({getUserList,name,userList})=>{// {2} useEffect(()=>{ getUserList(); },[])return (<div><h1>首頁</h1><ul> { {/* 3 */} userList.map(user=>{ const { first_name, last_name, email, avatar, id } = user; return <li key={id}><img src={avatar}alt="用戶頭像"style={{width:"30px",height:"30px"}}/><div>姓名:{`${first_name}${last_name}`}</div><div>email:{email}</div></li> }) }</ul><br/><Link to="/login">跳轉到登錄頁</Link><br/><button onClick={() => console.log("click me")}>點擊</button></div> )}const mapStateToProps = (state)=>({ name:state.name, userList:state.userList});// {1}const mapDispatchToProps = (dispatch)=>({getUserList(){ dispatch(getUserList(dispatch)) }})exportdefault connect(mapStateToProps,mapDispatchToProps)(Home);複製代碼代碼解析:
{1}, mapDispatchToProps 同 mapStateToProps 作用一致都是 connect 的入參,把相關的 dispatch 與 state 傳入 Home 組件中。{2}, useEffect Hook 中調用 getUserList 方法,獲取後臺真實數據{3},根據真實返回的 userList 渲染組件我們來看看實際效果:
看起來很不錯, react-router 與 redux 都已經支持了,但是當你查看下網頁源碼時會發現一個問題:
用戶列表數據並不是服務端渲染的,而是通過客戶端渲染的。為什麼會這樣呢?我們一起分析下請求過程你就會明白:
接下來我們主要的目標就是服務端如何可獲取到數據?既然 useEffect 不會在服務端執行,那麼我們就自己創建一個 「Hook」 。
在 Next.js 中 getInitialProps 就是這個被創建的 「Hook」 ,它的主要職責就是使服務端渲染可以獲取初始化數據。
getInitialProps 實現
在 Home 組件中我們先添加這個靜態方法:
Home.getInitialData = (store)=>{return store.dispatch(getUserList());}複製代碼在 getInitialData 中做的事情同 useEffect 相同,都是去發送後臺請求獲取數據。
在 React Router 文檔中關於服務端渲染想要先獲取到數據需要把路由改為靜態路由配置。
src/Routes.js
import { Home, Login } from"./containers";exportdefault [ { key:"home", path: "/", exact: true, component: Home, }, { key:"login", path: "/login", exact: true, component: Login, }];複製代碼現在剩下最主要的工作就是服務端渲染網頁之前拿到後臺數據了。
react-router-config 這個包是 React Router 提供給我們用於分析靜態路由配置的包。我們先安裝它 npm install react-router-config --save
src/server/render.js
... 省略import {matchRoutes, renderRoutes} from"react-router-config";import Routes from"../Routes";exportconst render = (req,res)=>{const store = getStore();// {1}const promises = matchRoutes(Routes, req.path).map(({ route }) => {const component = route.component;return component.getInitialData ? component.getInitialData(store) : null; });// {2}Promise.all(promises).then(()=>{const content = renderToString((<Provider store={store}> // {3}<StaticRouter location={req.path}>{renderRoutes(Routes)}</StaticRouter></Provider> )); res.send( ` ... `) })}複製代碼代碼解析:
{1}, matchRoutes 獲取當前訪問路由所匹配到的組件,匹配到的組件如果有 getInitialData 方法就直接調用;{2}, component.getInitialData(store) 返回都是 Promise , 等待全部 Promise 執行完成後, store 中的 state 就有數據了,此時服務端就已經獲取到相應組件的後臺數據;{3},renderRoutes 它的作用是根據靜態路由配置渲染出 <Route /> 組件,類似下面代碼,不過 renderRoutes 邊界處理的更加完善。{routes.map(route => (<Route {...route} />))}複製代碼此時運行效果:
細心的你肯定會發現,明明伺服器已經拿到數據了為什麼刷新瀏覽器會一閃一閃呢,原因在於,客戶端渲染接管時,初始化的用戶列表依然是個空數組,通過發送後臺請求獲取到數據這個異步過程,導致的頁面一閃一閃的。它的解決方案有一個術語叫做數據的脫水與注水。
數據脫水與注水
其實非常簡單,在渲染服務端時,已經拿到了後臺請求數據,因此我們可以做:
res.send( ` <!doctype html> <html lang="en"> ... <body> <div id="root">${content}</div> <script> window.INITIAL_STATE = ${JSON.stringify(store.getState())} </script> <script src="/index.js"></script> </body> </html> `)複製代碼通過 INITIAL_STATE 全局變量把後臺請求到的數據存起來。客戶端創建 store 時,當做初始化的 state 使用即可:
src/store/index.js
exportconst getClientStore = ()=>{const defaultState = window.INITIAL_STATE;return createStore(reducer,defaultState,applyMiddleware(thunk));}複製代碼這樣創建出來的 store 初始化的 state 中就已經有了用戶列表。界面就不再會出現一閃一閃的效果了。
到這裡為止,一個簡易的同構框架已經有了。
本小結完整代碼地址>>>點擊查看
兼容 CSS 樣式渲染
在 Home 組件中添加一個樣式文件: styles.module.css ,隨便寫點樣式
.box{ background: red; margin-top: 100px;}複製代碼在 Home 組件中引入樣式:
import styles from"./styles.module.css";<div className={styles.box}>...</div>複製代碼直接編譯肯定報錯,我們需要在 webpack 中添加相應的 loader
webpack.client.js
module:{ rules:[ { test:/\.css$/i, // 正則匹配到.css樣式文件 use:['style-loader', // 把得到的CSS內容插入到HTML中 { loader: 'css-loader', options: { modules: true// 開啟 css modules } } ] } ] }複製代碼webpack.server.js
module:{ rules:[ { test:/\.css$/i, use:['isomorphic-style-loader', { loader: 'css-loader', options: { modules: true } }, ] } ] }複製代碼細心的你肯定會發現, server 端的配置使用了 isomorphic-style-loader 而 client 端使用了 style-loader ,它們的區別是什麼?
isomorphic-style-loader vs style-loader
style-loader 它的作用是把生成出來的 css 樣式動態插入到 HTML 中,然而在服務端渲染是沒有辦法使用 DOM 的,因此服務端渲染不能使用它。
isomorphic-style-loader 主要是導出了3個函數, _getCss 、 _insertCss 與_getContent ,供使用者調用,而不再是簡單粗暴的插入 DOM 中。
server 端支持樣式
src/server/render.js
exportconst render = (req,res)=>{const context = { css: [] };Promise.all(promises).then(()=>{const content = renderToString((<Provider store={store}><StaticRouter location={req.path}context={context}>{renderRoutes(Routes)}</StaticRouter></Provider> ));const css = context.css.length ? context.css.join('\n') : ''; res.send( ` <!doctype html> <html lang="en"> <head> ... <style>${css}</style> </head> ... </html> `)}複製代碼StaticRouter 支持傳入一個 context 屬性,這樣被訪問的組件則可以共享該屬性。在被訪問組件的生命周期中通過調用 _getCss() 方法向 staticContext 中推入樣式。最後在服務端拼接出所有樣式插入到 HTML 中。
Home 組件(改造成 class 組件)
componentWillMount() {if(this.props.staticContext){this.props.staticContext.css.push(styles._getCss()); } }複製代碼在 componentWillMount 生命周期(服務端渲染會調用該生命周期),向 staticContext 中推入組件使用的樣式。最後在服務端拼接成完整的樣式文件。
這裡使用 staticContext 可以實現,使用 redux 也一樣可以實現。
本小結完整代碼地址>>>點擊查看
總結
到此為止我們就實現了一個簡易的同構框架。下面做一個簡單的總結:
同構渲染其實就是將同一套 react 代碼在服務端執行一遍渲染靜態頁面,又在客戶端執行一遍完成事件綁定。它的優勢是,加快首頁訪問速度以及 SEO 友好,如果你的項目沒有這方面的需求,則不需要選擇同構。它的缺點是,不能在服務端渲染期間操作 DOM 、 BOM 等 api ,比如 document 、 window 對象等,並且它增加了代碼的複雜度,某些代碼操作需要區分運行環境。在實際項目中,建議使用 Next.js 框架去做,站在巨人的肩旁上,可以少踩很多坑。