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資訊,同時支援客戶端和服務端

本章節原始碼

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

相關文章