React服務端渲染(前後端路由同構)

code_mcx發表於2018-10-15

Web應用是通過url訪問某個具體的HTML頁面,每個url都對應一個資源。傳統的Web應用中,瀏覽器通過url向伺服器傳送請求,伺服器讀取資源並把處理好的頁面內容傳送給瀏覽器,而在單頁面應用中,所有url變化的處理都在瀏覽器端完成,url發生變化時瀏覽器通過js將內容替換。對於服務端渲染的應用,當請求某個url資源,伺服器要將該url對應的頁面內容傳送給瀏覽器,瀏覽器下載頁面引用的js後執行客戶端路由初始化,隨後的路由跳轉都是在瀏覽器端,服務端只負責從瀏覽器傳送請求的第一次渲染

首先在之前搭建的專案中src目錄下建立4個頁面元件

React服務端渲染(前後端路由同構)

然後安裝React Web端依賴react-router-dom

注:react-router-dom版本4.x
上一節:專案搭建

原始碼地址見文章末尾

本節服務端程式碼已進行重寫,詳情請戳這裡

前端路由

編寫React路由時,我們先用最基本的做法,在App.jsx中使用BrowserRouter元件包裹根節點,用NavLink元件包裹li標籤中的文字

import { 
BrowserRouter as Router, Route, Switch, Redirect, NavLink
} from "react-router-dom";
import Bar from "./views/Bar";
import Baz from "./views/Baz";
import Foo from "./views/Foo";
import TopList from "./views/TopList";
複製程式碼
render() { 
return ( <
Router>
<
div>
<
div className="title">
This is a react ssr demo<
/div>
<
ul className="nav">
<
li>
<
NavLink to="/bar">
Bar<
/NavLink>
<
/li>
<
li>
<
NavLink to="/baz">
Baz<
/NavLink>
<
/li>
<
li>
<
NavLink to="/foo">
Foo<
/NavLink>
<
/li>
<
li>
<
NavLink to="/top-list">
TopList<
/NavLink>
<
/li>
<
/ul>
<
div className="view">
<
Switch>
<
Route path="/bar" component={Bar
} />
<
Route path="/baz" component={Baz
} />
<
Route path="/foo" component={Foo
} />
<
Route path="/top-list" component={TopList
} />
<
Redirect from="/" to="/bar" exact />
<
/Switch>
<
/div>
<
/div>
<
/Router>
);

}複製程式碼

上述程式碼中每個路由檢視都用Route佔位,而路由檢視對應的元件在當前元件中都需要import進來,如果有路由巢狀,檢視元件就會被分散到不同的元件中被import,當元件巢狀太多,會變得難以維護

接下來針對上述問題進行改造,所有檢視元件都在一個js檔案中import,匯出一個路由配置物件列表,分別用path指定路由路徑,component指定路由檢視元件

src/router/index.js

import Bar from "../views/Bar";
import Baz from "../views/Baz";
import Foo from "../views/Foo";
import TopList from "../views/TopList";
const router = [ {
path: "/bar", component: Bar
}, {
path: "/baz", component: Baz
}, {
path: "/foo", component: Foo
}, {
path: "/top-list", component: TopList, exact: true
}];
export default router;
複製程式碼

App.jsx中匯入配置好的路由物件,迴圈返回Route

<
div className="view">
<
Switch>
{
router.map((route, i) =>
( <
Route key={i
} path={route.path
} component={route.component
} exact={route.exact
} />
))
} <
Redirect from="/" to="/bar" exact />
<
/Switch>
<
/div>
複製程式碼

複雜的應用中免不了元件巢狀的情況,Routecomponent屬性不僅可以傳遞元件型別還可以傳遞迴調函式,通過回撥函把當前元件的子路由通過props傳遞,然後繼續迴圈

為了支援元件巢狀,我們使用Route進行封裝一個NestedRoute元件

src/router/NestedRoute.jsx

import React from "react";
import {
Route
} from "react-router-dom";
const NestedRoute = (route) =>
( <
Route path={route.path
} exact={route.exact
} /*渲染路由對應的檢視元件,將路由元件的props傳遞給檢視元件*/ render={(props) =>
<
route.component {...props
} router={route.routes
}/>

} />
);
export default NestedRoute;
複製程式碼

然後從src/router/index.js中匯出

import NestedRoute from "./NestedRoute";
...export {
router, NestedRoute
}複製程式碼

App.jsx

import { 
router, NestedRoute
} from "./router";
複製程式碼
<
div className="view">
<
Switch>
{
router.map((route, i) =>
( <
NestedRoute key={i
} {...route
} />
))
} <
Redirect from="/" to="/bar" exact />
<
/Switch>
<
/div>
複製程式碼

使用巢狀的路由像下面這樣

const router = [  { 
path: "/a", component: A
}, {
path: "/b", component: B
}, {
path: "/parent", component: Parent, routes: [ {
path: "/child", component: Child,
} ]
}];
複製程式碼

Parent.jsx

this.props.router.map((route, i) =>
( <
NestedRoute key={i
} {...route
} />
))複製程式碼

後端路由

服務端路由不同於客戶端,它是無狀態的。React提供了一個無狀態的元件StaticRouter,向StaticRouter傳遞url,呼叫ReactDOMServer.renderToString()就能匹配到路由檢視

App.jsx中區分客戶端和服務端,然後export不同的根元件

let App;
if (process.env.REACT_ENV === "server") {
// 服務端匯出Root元件 App = Root;

} else {
App = () =>
{
return ( <
Router>
<
Root />
<
/Router>
);

};

}export default App;
複製程式碼

接下來對entry-server.js進行修改,使用StaticRouter包裹根元件,傳入上下文contextlocation,同時使用函式來建立一個新的元件

import React from "react";
import {
StaticRouter
} from "react-router-dom";
import Root from "./App";
const createApp = (context, url) =>
{
const App = () =>
{
return ( <
StaticRouter context={context
} location={url
}>
<
Root/>
<
/StaticRouter>
)
} return <
App />
;

}module.exports = {
createApp
};
複製程式碼

server.js中獲取createApp函式

let createApp;
let template;
let readyPromise;
if (isProd) {
let serverEntry = require("../dist/entry-server");
createApp = serverEntry.createApp;
template = fs.readFileSync("./dist/index.html", "utf-8");
// 靜態資源對映到dist路徑下 app.use("/dist", express.static(path.join(__dirname, "../dist")));

} else {
readyPromise = require("./setup-dev-server")(app, (serverEntry, htmlTemplate) =>
{
createApp = serverEntry.createApp;
template = htmlTemplate;

});

}複製程式碼

在服務端處理請求時把當前url傳入,服務端會匹配和當前url對應的檢視元件

const render = (req, res) =>
{
console.log("======enter server======");
console.log("visit url: " + req.url);
let context = {
};
let component = createApp(context, req.url);
let html = ReactDOMServer.renderToString(component);
let htmlStr = template.replace("<
!--react-ssr-outlet-->
"
, `<
div id='app'>
${html
}
<
/div>
`);
// 將渲染後的html字串傳送給客戶端 res.send(htmlStr);

}複製程式碼

404和重定向

當請求伺服器資源不存在時,伺服器需要做出404響應,路由發生了重定向,伺服器也需要重定向到指定的url。StaticRouter提供了一個props用來傳遞上下文物件context,在渲染路由元件時通過staticContext獲取並設定狀態碼,服務端渲染時通過狀態碼判斷做響應處理。如果服務端路由渲染時發生了重定向,通過context自動新增上與重定向相關資訊的屬性,如url

為了處理404狀態,我們封裝一個狀態元件StatusRoute

src/router/StatusRoute.jsx

import React from "react";
import {
Route
} from "react-router-dom";
const StatusRoute = (props) =>
( <
Route render={({staticContext
}) =>
{
// 客戶端無staticContext物件 if (staticContext) {
// 設定狀態碼 staticContext.status = props.code;

} return props.children;

}
} />
);
export default StatusRoute;
複製程式碼

src/router/index.js中匯出

import StatusRoute from "./StatusRoute";
...export {
router, NestedRoute, StatusRoute
}複製程式碼

App.jsx中使用StatusRoute元件

<
div className="view">
<
Switch>
{
router.map((route, i) =>
( <
NestedRoute key={i
} {...route
} />
))
} <
Redirect from="/" to="/bar" exact />
<
StatusRoute code={404
}>
<
div>
<
h1>
Not Found<
/h1>
<
/div>
<
/StatusRoute>
<
/Switch>
<
/div>
複製程式碼

render函式修改如下

let context = {
};
let component = createApp(context, req.url);
let html = ReactDOMServer.renderToString(component);
if (!context.status) {
// 無status欄位表示路由匹配成功 let htmlStr = template.replace("<
!--react-ssr-outlet-->
"
, `<
div id='app'>
${html
}
<
/div>
`);
// 將渲染後的html字串傳送給客戶端 res.send(htmlStr);

} else {
res.status(context.status).send("error code:" + context.status);

}複製程式碼

服務端渲染時判斷context.status,不存在status屬性表示匹配到路由,存在則設定狀態碼並響應結果

App.jsx中使用了一個重定向路由<
Redirect from="/" to="/bar" exact />
,訪問http://localhost:3000時就會重定向到http://localhost:3000/bar,而在StaticRouter中路由是沒有狀態的,無法進行重定向,當訪問http://localhost:3000服務端返回的是App.jsx中渲染的html片段,不包含Bar.jsx元件渲染的內容

React服務端渲染(前後端路由同構)
React服務端渲染(前後端路由同構)

Bar.jsxrender方法如下

render() { 
return ( <
div>
<
div>
Bar<
/div>
<
/div>
);

}複製程式碼

因為客戶端的路由,瀏覽器位址列已經變成了http://localhost:3000/bar,並且渲染出Bar.jsx中的內容,但是客戶端和服務端渲染不一致

server.jsx中增加一行程式碼console.log(context)

let context = {
};
let component = createApp(context, req.url);
let html = ReactDOMServer.renderToString(component);
console.log(context);
...複製程式碼

然後訪問http://loclahost:3000,可以在終端看到以下輸出資訊

======enter server======visit url: /{ 
action: 'REPLACE', location: {
pathname: '/bar', search: '', hash: '', state: undefined
}, url: '/bar'
}複製程式碼

通過context獲取url進行服務端重定向處理

if (context.url) { 
// 當發生重定向時,靜態路由會設定url res.redirect(context.url);
return;

}複製程式碼
React服務端渲染(前後端路由同構)

此時訪問http://loclahost:3000,瀏覽器傳送了兩次請求,第一次請求/,第二次重定向到/bar

Head管理

每一個頁面都有對應的head資訊如title、meta和link等,這裡使用react-helmet外掛來管理Head,它同時支援服務端渲染

先安裝react-helmet

npm install react-helmet

然後在App.jsximport,新增自定義head

import { 
Helmet
} from "react-helmet";
複製程式碼
<
div>
<
Helmet>
<
title>
This is App page<
/title>
<
meta name="keywords" content="React SSR">
<
/meta>
<
/Helmet>
<
div className="title">
This is a react ssr demo<
/div>
...<
/div>
複製程式碼

在服務端渲染時,呼叫ReactDOMServer.renderToString()後需要呼叫Helmet.renderStatic()才能獲取head相關資訊,為了在server.js中使用App.jsx中的Helmet,需要在入口entry-server.jsApp.jsx做一些修改

entry-server.js

const createApp = (context, url) =>
{
const App = () =>
{
return ( <
StaticRouter context={context
} location={url
}>
<
Root setHead={(head) =>
App.head = head
}/>
<
/StaticRouter>
)
} return <
App />
;

}複製程式碼

App.jsx

class Root extends React.Component { 
constructor(props) {
super(props);
if (process.env.REACT_ENV === "server") {
// 當前如果是服務端渲染時將Helmet設定給外層元件的head屬性中 this.props.setHead(Helmet);

}
} ...
}複製程式碼

Root元件傳入一個props函式setHead,在Root元件初始化時呼叫setHead函式給新的App元件新增一個head屬性

修改模板index.html,新增<
!--react-ssr-head-->
作為head資訊佔位

<
head>
<
meta charset="UTF-8">
<
meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<
link rel="shortcut icon" href="/public/favicon.ico">
<
title>
React SSR<
/title>
<
!--react-ssr-head-->
<
/head>
複製程式碼

server.js中進行替換

if (!context.status) { 
// 無status欄位表示路由匹配成功 // 獲取元件內的head物件,必須在元件renderToString後獲取 let head = component.type.head.renderStatic();
// 替換註釋節點為渲染後的html字串 let htmlStr = template .replace(/<
title>
.*<
\/title>
/, `${head.title.toString()
}
`) .replace("<
!--react-ssr-head-->
"
, `${head.meta.toString()
}
\n${head.link.toString()
}
)`) .replace("<
!--react-ssr-outlet-->
"
, `<
div id='app'>
${html
}
<
/div>
`);
// 將渲染後的html字串傳送給客戶端 res.send(htmlStr);

} else {
res.status(context.status).send("error code:" + context.status);

}複製程式碼

component<
App />
經過jsx語法轉換後的物件,component.type是獲取該物件的元件型別,這裡是entry-server.js中的App

注意:這裡必須通過App.jsximport進來的Helmet呼叫renderStatic()後才能獲頭部資訊

訪問http://localhost:3000時,頭部資訊已經被渲染出來了

React服務端渲染(前後端路由同構)

每一個路由對應一個檢視,每一個檢視都有各自的head資訊,檢視元件是巢狀在根元件中的,當元件發生巢狀使用react-helmet時會自動替換相同的資訊

Bar.jsxBaz.jsxFoo.jsxTopList.jsx中分別使用react-helmet自定義標題。如

class Bar extends React.Component { 
render() {
return ( <
div>
<
Helmet>
<
title>
Bar<
/title>
<
/Helmet>
<
div>
Bar<
/div>
<
/div>
);

}
}複製程式碼

瀏覽器輸入http://localhost:3000/bar時標題渲染成<
title data-react-helmet="true">
Bar<
/title>

React服務端渲染(前後端路由同構)

輸入http://localhost:3000/baz時標題渲染成<
title data-react-helmet="true">
Baz<
/title>

React服務端渲染(前後端路由同構)

總結

本節對React基本路由進行配置化管理,使得維護起來更加簡單,也為後續資料預取奠定了基礎。在服務端路由渲染中使用了StaticRouter元件,這個元件有contextlocation兩個props,渲染時可以自行給context賦予自定義屬性,比如設定狀態碼,location則用來匹配路由。服務端渲染中head資訊必不可少,react-helmet外掛提供了簡單的用法來定義head資訊,同時支援客戶端和服務端

本章節原始碼

下一節:程式碼分割和資料預取

來源:https://juejin.im/post/5bbedfca5188255c5e670682

相關文章