React SSR 簡介與 Next.js 使用入門
- 2020 年 3 月 12 日
- 筆記
React SSR 是什麼?React SSR 是 React 伺服器端渲染 (SSR: server side render) 技術。傳統的服務端渲染方式是使用 HTML 模板的方式渲染出來的。訪問資料庫,拿到數據然後將數據填充到 HTML 模板上,比如 Node.js 中的 pug 模板引擎、ejs 模板引擎等都是服務端渲染的模板。傳統的服務端渲染通常用在文檔型頁面上,而現在網頁被稱為 web app,頁面更像 app 應用,現在做伺服器渲染主要是為了 SEO 和首屏。React 與模板渲染很相似,都是通過數據驅動,將頁面渲染出來。
服務端渲染
服務端渲染早已經存在,可以說是很老的技術。比如 JSP
、ASP
等都是服務端渲染技術。它與 客戶端渲染相對應,所謂服務端渲染就是在用戶訪問頁面時,服務端先渲染出 HTML 網頁結構,然後發給前端。而客戶端渲染是使用 js 腳本動態的在前端生成頁面,前端 js 腳本會像後端發起網路請求,然後把請求到的數據渲染出來。

客戶端渲染
服務端返回的 HTML 程式碼很少,因為有些 HTML 程式碼是使用後端發來的數據動態渲染出來的。

服務端渲染
服務端返回的 HTML 程式碼比較多,整個頁面基本已經通過後端渲染了出來。有些初始化的數據不需要在通過前端動態獲取。
上面兩張圖可以看出,服務端渲染與客戶端渲染主要區別在於用戶首次訪問頁面時,頁面數據的渲染方式。如果使用前端渲染,可能首次訪問頁面時,頁面載入會比較慢,這是因為前端需要向後端請求數據。而服務端渲染並不需要網路請求,它通過訪問資料庫將數據渲染到 HTML 頁面上,再返回到前端。後端渲染效率要比前端高,首屏不會出現太長久的空白頁。而且後端渲染對於網站 SEO 友好。因為搜索引擎可以看到完整的 HTML 頁面。
服務端渲染有優點,但是也有不好的地方,比如數據在後端渲染無疑會增加服務的壓力,而前端渲染並不用擔心。在服務端渲染數據會使項目不太好管理,而使用前端渲染的話,後端只需要提供介面即可。
在如今普遍推廣前後端分離的模式,也就是數據渲染通常在前端進行,前後端各司其職。但是如果一個網站全部都是前端渲染模式,搜索引擎幾乎抓不到非同步介面返回的內容,這種情況對面向消費者的網站來說問題是非常嚴重的。於是有些網站就做了優化,比如把重要的頁面通過服務端渲染。在如今 React、Vue 等框架的出現,也讓服務端渲染髮生了一些變化。
使用 React 做伺服器渲染,主要是通過下面這幾個方法來實現:
renderToString
: 將組件轉化為 HTML 字元串,生成的 HTML 的 DOM 會帶有額外的屬性,比如最外層的 DOM 會有data-reactroot
屬性。renderToStaticMarkup
: 同樣將組件轉換成 HTML 字元串,但是生成的 HTML 的 DOM 不會有額外的屬性,從而節省 HTML 字元串的大小。renderToNodeStream
返回一個可輸出 HTML 字元串的可讀流(不是字元串)。通過可讀流輸出的 HTML 完全等同於 ReactDOMServer.renderToString 返回的 HTML。renderToStaticNodeStream
此方法與renderToNodeStream
相似,但此方法不會在 React 內部創建的額外 DOM 屬性,例如data-reactroot
。如果你希望把 React 當作靜態頁面生成器來使用,此方法會非常有用,因為去除額外的屬性可以節省一些位元組。
這幾個方法存在於 react-dom/server
庫中。使用這幾個方法都是可以將 React 組件轉化成 HTML 字元串,而前端不變的去寫 React 組件即可。這種前後端共用一套程式碼的方式被稱為同構。
下面就簡單的說一下 react 服務端渲染的構建流程。
首先是配置 webpack:
const path = require("path"); const HtmlWebpackPlugin = require("html-webpack-plugin"); const { CleanWebpackPlugin } = require("clean-webpack-plugin"); module.exports = { entry: path.join(__dirname,"../src/index.js"), output: { path: path.join(__dirname, "../dist"), filename: "main.js", }, mode: "development", module: { rules: [{ test: /.jsx?$/, exclude: /node_modules/, use: { loader: 'babel-loader' } }] }, resolve: { extensions: ['.js','.jsx','.mjs'] }, plugins: [ new HtmlWebpackPlugin({ template: path.join(__dirname, "../public/index.html") }), new CleanWebpackPlugin({ cleanOnceBeforeBuildPatterns: ["../dist"], dry: false, dangerouslyAllowCleanPatternsOutsideProject: true }) ] }
配置 babel:
{ "presets": [ "@babel/preset-env", "@babel/preset-react" ] }
添加腳本:
{ "scripts": { "server": "cross-env NODE_ENV=development nodemon --exec babel-node ./server/server.js", "build": "cross-env NODE_ENV=production webpack --config ./config/webpack.prod.js" } }
最後是服務端:
const express = require("express"); const path = require("path"); const React = require("react"); const { renderToString } = require("react-dom/server"); const { readFile: rf } = require("fs"); const { promisify } = require("util"); const App = require("../src/App").default; const app = express(); const readFile = promisify(rf); app.use(express.json()); app.use(express.urlencoded({ extended: false })); app.get("/",async (req,res) => { const content = renderToString(<App />); // 讀取打包出來的 HTML 文件 const str = await readFile(path.join(__dirname, "../dist/index.html")); // 把內容替換掉 const html = str.toString().replace("<!--app-->",content); // 將頁面發到前端 res.send(html); }); // 打包生成的文件夾作為靜態服務路徑,這樣靜態文件就可以請求到了 app.use("/",express.static(path.join(__dirname, "../dist"))); app.listen(8001,() => { console.log("Server is running: http://localhost:8001"); });
我們需要一個 HTML 模板,打包好後,將 build 目錄作為靜態資源目錄。使用 renderToString
函數拿到 HTML 字元串,把 HTML 模板中的內容替換成 HTML 字元串。HTML 模板如下:
<body> <div id="root"><!--app--></div> <script type="text/javascript" src="main.js"></script> </body>
因為 node 不支援 jsx 語法,因此使用了 babel-node。整體思路就是先打包,然後把打包好的目錄作為靜態資源目錄,然後啟動服務。 頁面是服務端渲染還是客戶端渲染有明顯的差別。來到瀏覽器,右鍵查看網頁源程式碼,當源碼中有很多 HTML 程式碼是通常就是服務端渲染,服務端渲染後,頁面上對應的文字資訊通常都能找到。而客戶端渲染通常沒有多少 HTML 程式碼,基本都是通過 js 動態生成的。因此,如果是 React SSR,那麼在瀏覽器上查看源碼時,源碼應該有比較多的 HTML 程式碼,而前端渲染是沒有的。

renderToString
一個基本的 ssr 項目就夠建好了,但是很雞肋,但大致流程就是這樣的。在其中也可以引入路由、css 靜態資源、或者結合 redux。而這個項目每次想要看到效果時必須先打包然後啟服務,這也會降低開發效率,因此項目搭建比較複雜。好在 next.js 的出現,讓構建 ssr 應用變得簡單。
文章結構
本文並不會從零搭建一個 React ssr,主要是 next.js 的內容。從零搭建一個 react ssr 項目還是很麻煩的,坑也有不少,要實現一個令人滿意的框架是很難的。需要考慮 css 樣式引入問題、結合 react-router、如何與 redux 結合,開發環境下開發效率問題等等吧。如果想了解這方面的內容,可以來到掘金,搜索 react ssr,裡面會有許多大牛分享的 ssr 搭建流程。而 next.js 是 react 官方提供的 react ssr 框架,基本配置已經封裝好了。使用時就像使用 create-react-app 一樣。本文的內容主要分為:
- next.js 工程構建;
- next.js 中的路由;
- 自定義 Head;
- 引入 css;
- 預載入與動態導入;
- 數據的獲取(在 next.js 中如何非同步獲取數據);
- 與 redux 結合;
- 項目打包與自定義後端;
工程構建
有兩種構建方式,一種是手動構建,需要下載三個模組:
- react
- react-dom
- next
首先執行 npm init,然後下載模組,然後來到 package.json 文件中,添加下面的腳本:
{ "scripts": { "dev": "next", "build": "next build", "start": "next start", } }
最後建立三個文件夾,pages 是必須要建立的,其他兩個是為了我們方便管理。
- pages 用來存放路由級的頁面組件;
- static 用來存放靜態文件;
- components 用來存放 React 組件;
然後在 pages 文件夾中創建一個 index.js
文件,內容如下:
function Index(){ return <h1>Hello Next</h1> } export default Index;
運行 npm run dev
命令,打開瀏覽器輸入 http://localhost:3000
,就會看到我們寫的組件頁面。Next 默認會把 pages 下的 index.js 文件作為網頁根路徑。
如果你把 index.js
改成 aaa.js
,就會發現頁面變成了 404
。當訪問 /aaa
路徑時就會渲染出我們寫的組件。可見 next.js 以文件名作為路由路徑。因此我們可以建立多級路由,比如在 pages 下建立一個 user
目錄,user 目錄中建立 index.js
後,訪問 /user
路徑時就會渲染出組件,因此 index
表示根路徑的意思。
第二種方式是使用下面的命令安裝,這個命令就像 create-react-app
一樣創建出完整的項目目錄:
npx create-next-app project_name
路由
Link
頁面級的路由用 pages 文件夾表示。要在 next.js 中使用路由可以這麼引入:
import Link from "next/link"; function Index(){ return ( <> <Link href="/pageA"> <h3 style={{color: "red"}}> 跳轉到 pageA 頁面 </h3> </Link> <Link href="/pageB"> <h3 style={{color: "green"}}> 跳轉到 pageB 頁面 </h3> </Link> </> ); } export default Index;
next.js 中的 Link 是使用 href 作為跳轉的屬性。而在 react-router-dom
中是 to
屬性。
除了直接傳入一個字元串之外也可以傳入一個對象:
<Link href={{pathname: "/pageA", query: {name: "Ming", age: 18} }}> <h3 style={{color: "red"}}> 跳轉到 pageA 頁面 </h3> </Link>
query 就是查詢字元串。要想在頁面級組件中拿到 query 字元串,就要使用 withRouter 函數。用這個函數包裹一下,頁面的路由資訊存放在 props 的 router 屬性中。
import { withRouter } from "next/router" function PageA(props){ var person = props.router.query; return <h2>Hello! {person.name}</h2> } export default withRouter(PageA);
Router
如果你想點擊按鈕跳轉頁面,也可以使用 next 中的 Router
組件:
import Router from "next/router"; function Index(){ // 當然,你也可以在 push 中直接傳入一個字元串 function handleClick(){ Router.push({ pathname: "/pageA", query: { name: "Fang", age: 18 } }); } return ( <> <button onClick={handleClick}>跳轉到 PageA 頁面</button> </> ); } export default Index;
重定向
在 next 中使用重定向可以使用 Router.replace("/xxx")
方法重定向,也可以使用 withRouter
包裹組件,在 props.router.replace
中使用重定向函數。比如下面的組件,當訪問 /pageA
頁面時總是會重定向到 /pageB
頁面:
import { withRouter } from "next/router" function PageA(props){ (() => { props.router.replace("/pageB"); })(); return <h2>Hello!</h2> } export default withRouter(PageA);
路由遮蓋
看下面的程式碼:
<Link as="/A" href="/pageA"><a>to pageA</a></Link> <Link as="/B" href="/pageB"><a>to pageB</a></Link>
當點擊第一個鏈接時,路由是 /A
,同樣第二個鏈接的路由將是 /B
。as 屬性可以簡化路由長度。當手動訪問 /pageA
時也是可以正常訪問的。但手動訪問 /A
是訪問不到頁面的。當不想讓別人知道真正的路由資訊時,可以使用路由遮蓋。
路由事件
路由事件有六個,分別是:
- routeChangeStart 路由開始切換時觸發;
- routeChangeComplete 完成路由切換時觸發;
- routeChangeError 路由切換報錯時觸發,這個事件不容易觸發,404 頁面不屬於這樣的錯誤;
- beforeHistoryChange 瀏覽器 history 模式開始切換時觸發,history 是 HTML5 中新出的 API,react 路由就是就是基於這個實現的。
- hashChangeStart 開始切換 hash 值但是沒有切換頁面路由時觸發;
- hashChangeComplete 完成切換 hash 值但是沒有切換頁面路由時觸發;
下面是綁定事件的例子:
import Link from "next/link"; import Router from "next/router"; function Index(){ // 使用 Router.events.on 來綁定 Router.events.on("routeChangeStart",(url) => { console.log("Index 路由頁進行了跳轉:", url); }); return ( <> <button onClick={handleClick}>跳轉到 PageA 頁面</button> </> ); } export default Index;
需要注意的是 routeChangeError
事件的回調函數有兩個參數,第一個是 error,第二個是 url,其他五個事件都是只有 url 參數。
Head 組件
在 next 中你可以自定義 HTML 網頁的 head 標籤部分,自定義的內容需要 next 內部的 Head
組件進行包裹。我們可以在 components 文件夾下建立一個 MyHead 組件,內容如下:
import Head from "next/head"; // 在 Head 組件內部放入 head 標籤中的內容 function MyHead(){ return ( <Head> <title>歡迎!!</title> <meta charSet="UTF-8" /> <meta name="author" content="Ming" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> </Head> ); } export default MyHead;
然後就可以在其他頁面級組件中引入我們自定義的 Head 組件了:
import Link from "next/link"; import Router from "next/router"; import MyHead from "../components/Head"; function Index(){ return ( <> <MyHead /> <Link href="/pageB"> <h3 style={{color: "green"}}> 跳轉到 pageB 頁面 </h3> </Link> </> ); } export default Index;
預載入與動態導入
預載入與動態導入不同。添加預載入功能的組件會在後台「偷偷」的載入頁面(就像 webpack 魔法注釋中的 prefetch)。而動態導入一般是當頁面觸發某個事件或者渲染到動態導入的組件時會發起網路請求,渲染組件。
在 next 中使用預載入,可以使用 Link 組件的 prefetch
:
<Link prefetch href="/about"> <a>About</a> </Link>
從 next9 版本開始,就不需要自己定義 prefetch 屬性了,next 會自動在後台預取頁面。
動態導入
比如:
const Hello = dynamic(import("../components/Hello"),{ loading: () => <h2 style={{color: "red"}}>Loading...</h2> });
也可以多個導入:
import dynamic from 'next/dynamic' const HelloBundle = dynamic({ modules: () => { const components = { Hello1: import('../components/hello1'), Hello2: import('../components/hello2') } return components }, render: (props, { Hello1, Hello2 }) => <div> <h1> {props.title} </h1> <Hello1 /> <Hello2 /> </div> }); export default () => <HelloBundle title="Dynamic Bundle" />
引入 css
在 next 中有專門書寫 css 的組件,使用時不用引入模組:
function Index(){ return ( <> <button>跳轉到 PageA 頁面</button> <style jsx>{` button{ padding: .6rem; background-color: green; color: #ffffff; border: none; cursor: pointer; border-radius: 6px; } `}</style> </> ); } export default Index;
也可以定義全局的 CSS 樣式:
function Index(){ return ( <> <h1>Hello</h1> <style global jsx>{` body{ color: red; } `}</style> </> ); } export default Index;
當然,這種寫法也有弊端,頁面不好管理,可以將通用的樣式標籤封裝成一個組件,這很像 styled-components
,它是一個 css in js
的庫,而在 next.js 中使用的則是 styled-jsx
。使用時不需要下載,next 內部已經集成。
export const Button = function (props) { return ( <button><style jsx>{` button{ padding: .6rem; background-color: ${props.bgColor || 'green'}; color: ${props.color || "white"}; border: none; cursor: pointer; border-radius: 6px; } `}</style>{props.children}</button> ); }
使用時引入即可:
import { Button } from "./components/Button"; function App(){ return ( <Button bgColor={"#f22"}>Click</Button> ); }
如果你想將樣式與組件分離,即:單獨的寫成 css 文件或者 sass 文件,則需要下載模組,還需要配置。以 CSS 為例,需要先下載 @zeit/next-css
:
npm install --save @zeit/next-css
然後在項目最外層目錄新建一個 next.config.js
文件:
const withCss = require("@zeit/next-css"); module.exports = withCss();
然後重啟伺服器,就可以在 next 項目中引入 css 文件了。如果要使用 sass
、less
或者 stylus
需要分別下載這幾個包:
- @zeit/next-sass
- @zeit/next-less
- @zeit/next-stylus
需要注意的是,使用 sass 還要下載 node-sass
,使用 less 還需要額外下載 less
,使用 stylus 需要額外下載 stylus
。
如果使用多個 css 預處理器,可以這樣配置:
const withSass = require('@zeit/next-sass'); const withCss = require("@zeit/next-css"); module.exports = { webpack(config, options){ config = withCss().webpack(config, options); config = withSass({ cssModules: true }).webpack(config, options); return config; } }
配置和使用細節可以在 npm 官網或者 GitHub 官方倉庫上查看。
css Modules
css modules 可以減少樣式之間的相互影響,避免預料之外的樣式覆蓋。在 next 中使用 css module 也很簡單,這裡以 sass 為例,首先先做配置:
// next.config.js const withSass = require("@zeit/next-sass"); module.exports = withSass({ cssModules: true, });
然後就可以使用了:
// sass 文件: .wrapper{ display: flex; flex-direction: column; }
import css from "./index.scss"; function App(){ // 使用 css modules 中的 wrapper 類名 return <div class={css.wrapper}>css modules</div>; }
打開控制台就可以看到,原來定義的 css 類名已經變了,但我們還可以使用類名中的樣式。
數據獲取
在 next 中有一個 getInitialProps
方法,它在初始化組件的 props 屬性時被調用,而且只在服務端運行,沒有跨域的限制。
getInitialProps
方法只能用於頁面組件上,不能用於子組件上。
在服務端渲染時,React props 需要有初始值,通常使用 getInitialPorps
來獲取非同步請求來的數據,它是在服務端運行,因此在列印數據時,只會在後端的終端列印出來。這個方法必須返回東西,作為頁面組件 props 上的屬性。比如下面的例子,使用 axios 庫獲取 LOL 英雄的基本資訊並渲染出來:
function App(props){ return ( <div> <h1>{props.msg}</h1> <Hero list={props.hero.hero} /> </div> ); } App.getInitialProps = async () => { let data = await axios.get("https://game.gtimg.cn/images/lol/act/img/js/heroList/hero_list.js"); return { msg: "英雄聯盟台詞集", hero: data.data } }; export default App;
如果頁面組件是使用 ES6 類定義的,則應這麼使用 getInitialPorps
:
class App extends React.Compoonent{ static async getInitialPorps(){ // ... } }
結合 redux
可以自建,或者使用下面的命令生成一個搭建好的項目:
npx create-next-app --example with-redux next-redux-app
--example
後跟的是參數,前一個參數是固定的,表示使用 redux,後一個是項目目錄的名字。
創建好後,最外層會有一個 lib
目錄和一個 store.js
文件。運行 npm run dev
後,就可以看到頁面了。
如果要修改內容的話就是修改 store.js 文件中的內容,還有 pages 目錄下的文件。源碼中的 redux-devtools-extension
是 redux 調試工具,使用時需要下載 redux 的瀏覽器插件。
lib 目錄中有兩個文件:
redux.js
提供 withRedux 函數,它是將 redux 融入到 next 應用的關鍵,一般不會修改它;useInterval.js
一個第三方的 React hook,它是默認程式的一個工具函數,實際開發中可能並不會用到;
在普通的 React + redux 項目中,一般會使用 react-redux
庫。如果要拿到 store 中的方法,需要使用 connect
高階函數。通過 mapStateToProps
和 mapStateToDispatch
函數可以拿到 state 以及 dispatch。而在 next 中用的不是 connect,而是 withRedux
函數,它接受一個組件然後返回一個組件。在第一次渲染的時候,withRedux
會把初始化的 store
作為服務端渲染的初始化數據,之後會把 store 遷移到了客戶端,由客戶端來維護。也就是說之後的狀態變化都發生在客戶端,服務端只做初始化 Redux Store 的工作。而要在組件中獲得 state 數據或者 dispatch 的話,可以使用 react-redux
庫中的 useDispatch
和 useSelector
兩個內置鉤子,這是 react-redux7.x 新出的 API,用來代替 connect
高階函數。而且使用腳手架生成的項目默認也是使用的這兩個鉤子來獲取 state 和 dispatch。使用也很簡單:
import { useDispatch, useSelector } from "react-redux"; function App(){ // 獲得指定的 state 數據 let count = useSelector(state => state.count); // 獲得 dispatch let dispatch = useDispatch(); dispatch({type: "COUNT", payload: 1}); return <h1>{count}</h1>; }
比如下面的例子,點擊按鈕就會加一:
import React from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { withRedux } from '../lib/redux'; import { setCount } from "../store/actions/appAction"; const IndexPage = () => { let count = useSelector(state => state.appReducer).count; let dispatch = useDispatch(); function handleClick(){ dispatch(setCount(1)); } return ( <> <h1>{count}</h1> <button onClick={handleClick}>Click</button> </> ) } export default withRedux(IndexPage);
reducer 函數與 action:
// action function setCount(step = 1){ return { type: "COUNT", payload: step } } // reducer function appReducer(state, action){ switch(action.type){ case "COUNT": return { count: state.count + action.payload }; default: return state || {}; } }
在 Redux 中非同步獲取數據
首屏渲染髮請求時,這種情況就不需要使用 redux-thunk 這樣的庫了,而是使用 getInitialProps
來獲取。後端獲取,而不再是前端。
IndexPage.getInitialProps = async ({reduxStore}) => { const dispatch = reduxStore.dispatch; const data = await axios.get("https://game.gtimg.cn/images/lol/act/img/js/heroList/hero_list.js"); dispatch(getHeroData(data.data.hero)); return {}; } export default withRedux(IndexPage);
不是首屏渲染請求數據的話,那就跟前端邏輯一樣了,使用像 redux-thunk 這樣的庫。
項目打包與自定義後端
next 是 React 同構的框架。同構涉及到前端和後端。next 的其他兩個命令用於打包:
- next build 打包項目;
- next start 啟動打包後的項目,先運行 next build 命令才能運行該命令;
除此之外還可以使用 next export
導出 HTML 靜態頁面,導出之前需要先打包(next build)。運行該命令後項目中會多出一個 out 目錄。
{ "scripts": { "dev": "next", "build": "next build", "start": "next start", "export": "next export" } }
自定義後端
在 next 框架中,默認情況下我們想操作後端是不太容易的,我們可以使用下面的程式碼來訂製後端:
const next = require('next'); const express = require("express"); const dev = process.env.NODE_ENV !== 'production'; const app = next({ dev }); const handle = app.getRequestHandler(); app.prepare().then(() => { const server = express(); server.get("*", (req,res) => { return handle(req,res); }); server.listen(3000, () => { console.log('> Ready on http://localhost:3000') }); });
或者:
const { parse } = require('url'); const app = next({ dev }); const { createServer } = require("http"); app.prepare().then(() => { createServer((req, res) => { const parsedUrl = parse(req.url, true); const { pathname, query } = parsedUrl if (pathname === '/a') { app.render(req, res, '/b', query); } else if (pathname === '/b') { app.render(req, res, '/a', query); } else { handle(req, res, parsedUrl); } }).listen(3000); });
配置好後,需要下載 express 和 cross-env,然後來到 package.json 文件:
{ "scripts": { "server:dev": "cross-env NODE_ENV=development nodemon ./server.js", "server:prod": "cross-env NODE_ENV=production nodemon ./server.js" } }
上面兩條命令分別相當於 next dev
和 next start
命令。因此運行 server:prod
前需要先運行 next build
(npm run build)命令。
關於 next.js 的內容就說到這裡,如果想要更深入的了解 next.js 可以進入官網閱讀官方文檔:https://nextjs.org/