当前位置:   article > 正文

React服务端渲染(前后端路由同构)

前后端路由相同

Web应用是通过url访问某个具体的HTML页面,每个url都对应一个资源。传统的Web应用中,浏览器通过url向服务器发送请求,服务器读取资源并把处理好的页面内容发送给浏览器,而在单页面应用中,所有url变化的处理都在浏览器端完成,url发生变化时浏览器通过js将内容替换。对于服务端渲染的应用,当请求某个url资源,服务器要将该url对应的页面内容发送给浏览器,浏览器下载页面引用的js后执行客户端路由初始化,随后的路由跳转都是在浏览器端,服务端只负责从浏览器发送请求的第一次渲染

首先在之前搭建的项目中src目录下创建4个页面组件

然后安装React Web端依赖react-router-dom

注:react-router-dom版本4.x
上一节:项目搭建

源码地址见文章末尾

本节服务端代码已进行重写,详情请戳这里

前端路由

编写React路由时,我们先用最基本的做法,在App.jsx中使用BrowserRouter组件包裹根节点,用NavLink组件包裹li标签中的文本

  1. import {
  2. BrowserRouter as Router,
  3. Route,
  4. Switch,
  5. Redirect,
  6. NavLink
  7. } from "react-router-dom";
  8. import Bar from "./views/Bar";
  9. import Baz from "./views/Baz";
  10. import Foo from "./views/Foo";
  11. import TopList from "./views/TopList";
  12. 复制代码
  1. render() {
  2. return (
  3. <Router>
  4. <div>
  5. <div className="title">This is a react ssr demo</div>
  6. <ul className="nav">
  7. <li><NavLink to="/bar">Bar</NavLink></li>
  8. <li><NavLink to="/baz">Baz</NavLink></li>
  9. <li><NavLink to="/foo">Foo</NavLink></li>
  10. <li><NavLink to="/top-list">TopList</NavLink></li>
  11. </ul>
  12. <div className="view">
  13. <Switch>
  14. <Route path="/bar" component={Bar} />
  15. <Route path="/baz" component={Baz} />
  16. <Route path="/foo" component={Foo} />
  17. <Route path="/top-list" component={TopList} />
  18. <Redirect from="/" to="/bar" exact />
  19. </Switch>
  20. </div>
  21. </div>
  22. </Router>
  23. );
  24. }
  25. 复制代码

上述代码中每个路由视图都用Route占位,而路由视图对应的组件在当前组件中都需要import进来,如果有路由嵌套,视图组件就会被分散到不同的组件中被import,当组件嵌套太多,会变得难以维护

接下来针对上述问题进行改造,所有视图组件都在一个js文件中import,导出一个路由配置对象列表,分别用path指定路由路径,component指定路由视图组件

src/router/index.js

  1. import Bar from "../views/Bar";
  2. import Baz from "../views/Baz";
  3. import Foo from "../views/Foo";
  4. import TopList from "../views/TopList";
  5. const router = [
  6. {
  7. path: "/bar",
  8. component: Bar
  9. },
  10. {
  11. path: "/baz",
  12. component: Baz
  13. },
  14. {
  15. path: "/foo",
  16. component: Foo
  17. },
  18. {
  19. path: "/top-list",
  20. component: TopList,
  21. exact: true
  22. }
  23. ];
  24. export default router;
  25. 复制代码

App.jsx中导入配置好的路由对象,循环返回Route

  1. <div className="view">
  2. <Switch>
  3. {
  4. router.map((route, i) => (
  5. <Route key={i} path={route.path} component={route.component}
  6. exact={route.exact} />
  7. ))
  8. }
  9. <Redirect from="/" to="/bar" exact />
  10. </Switch>
  11. </div>
  12. 复制代码

复杂的应用中免不了组件嵌套的情况,Routecomponent属性不仅可以传递组件类型还可以传递回调函数,通过回调函把当前组件的子路由通过props传递,然后继续循环

为了支持组件嵌套,我们使用Route进行封装一个NestedRoute组件

src/router/NestedRoute.jsx

  1. import React from "react";
  2. import { Route } from "react-router-dom";
  3. const NestedRoute = (route) => (
  4. <Route path={route.path} exact={route.exact}
  5. /*渲染路由对应的视图组件,将路由组件的props传递给视图组件*/
  6. render={(props) => <route.component {...props} router={route.routes}/>}
  7. />
  8. );
  9. export default NestedRoute;
  10. 复制代码

然后从src/router/index.js中导出

  1. import NestedRoute from "./NestedRoute";
  2. ...
  3. export {
  4. router,
  5. NestedRoute
  6. }
  7. 复制代码

App.jsx

  1. import { router, NestedRoute } from "./router";
  2. 复制代码
  1. <div className="view">
  2. <Switch>
  3. {
  4. router.map((route, i) => (
  5. <NestedRoute key={i} {...route} />
  6. ))
  7. }
  8. <Redirect from="/" to="/bar" exact />
  9. </Switch>
  10. </div>
  11. 复制代码

使用嵌套的路由像下面这样

  1. const router = [
  2. {
  3. path: "/a",
  4. component: A
  5. },
  6. {
  7. path: "/b",
  8. component: B
  9. },
  10. {
  11. path: "/parent",
  12. component: Parent,
  13. routes: [
  14. {
  15. path: "/child",
  16. component: Child,
  17. }
  18. ]
  19. }
  20. ];
  21. 复制代码

Parent.jsx

  1. this.props.router.map((route, i) => (
  2. <NestedRoute key={i} {...route} />
  3. ))
  4. 复制代码

后端路由

服务端路由不同于客户端,它是无状态的。React提供了一个无状态的组件StaticRouter,向StaticRouter传递url,调用ReactDOMServer.renderToString()就能匹配到路由视图

App.jsx中区分客户端和服务端,然后export不同的根组件

  1. let App;
  2. if (process.env.REACT_ENV === "server") {
  3. // 服务端导出Root组件
  4. App = Root;
  5. } else {
  6. App = () => {
  7. return (
  8. <Router>
  9. <Root />
  10. </Router>
  11. );
  12. };
  13. }
  14. export default App;
  15. 复制代码

接下来对entry-server.js进行修改,使用StaticRouter包裹根组件,传入上下文contextlocation,同时使用函数来创建一个新的组件

  1. import React from "react";
  2. import { StaticRouter } from "react-router-dom";
  3. import Root from "./App";
  4. const createApp = (context, url) => {
  5. const App = () => {
  6. return (
  7. <StaticRouter context={context} location={url}>
  8. <Root/>
  9. </StaticRouter>
  10. )
  11. }
  12. return <App />;
  13. }
  14. module.exports = {
  15. createApp
  16. };
  17. 复制代码

server.js中获取createApp函数

  1. let createApp;
  2. let template;
  3. let readyPromise;
  4. if (isProd) {
  5. let serverEntry = require("../dist/entry-server");
  6. createApp = serverEntry.createApp;
  7. template = fs.readFileSync("./dist/index.html", "utf-8");
  8. // 静态资源映射到dist路径下
  9. app.use("/dist", express.static(path.join(__dirname, "../dist")));
  10. } else {
  11. readyPromise = require("./setup-dev-server")(app, (serverEntry, htmlTemplate) => {
  12. createApp = serverEntry.createApp;
  13. template = htmlTemplate;
  14. });
  15. }
  16. 复制代码

在服务端处理请求时把当前url传入,服务端会匹配和当前url对应的视图组件

  1. const render = (req, res) => {
  2. console.log("======enter server======");
  3. console.log("visit url: " + req.url);
  4. let context = {};
  5. let component = createApp(context, req.url);
  6. let html = ReactDOMServer.renderToString(component);
  7. let htmlStr = template.replace("<!--react-ssr-outlet-->", `<div id='app'>${html}</div>`);
  8. // 将渲染后的html字符串发送给客户端
  9. res.send(htmlStr);
  10. }
  11. 复制代码

404和重定向

当请求服务器资源不存在时,服务器需要做出404响应,路由发生了重定向,服务器也需要重定向到指定的url。StaticRouter提供了一个props用来传递上下文对象context,在渲染路由组件时通过staticContext获取并设置状态码,服务端渲染时通过状态码判断做响应处理。如果服务端路由渲染时发生了重定向,通过context自动添加上与重定向相关信息的属性,如url

为了处理404状态,我们封装一个状态组件StatusRoute

src/router/StatusRoute.jsx

  1. import React from "react";
  2. import { Route } from "react-router-dom";
  3. const StatusRoute = (props) => (
  4. <Route render={({staticContext}) => {
  5. // 客户端无staticContext对象
  6. if (staticContext) {
  7. // 设置状态码
  8. staticContext.status = props.code;
  9. }
  10. return props.children;
  11. }} />
  12. );
  13. export default StatusRoute;
  14. 复制代码

src/router/index.js中导出

  1. import StatusRoute from "./StatusRoute";
  2. ...
  3. export {
  4. router,
  5. NestedRoute,
  6. StatusRoute
  7. }
  8. 复制代码

App.jsx中使用StatusRoute组件

  1. <div className="view">
  2. <Switch>
  3. {
  4. router.map((route, i) => (
  5. <NestedRoute key={i} {...route} />
  6. ))
  7. }
  8. <Redirect from="/" to="/bar" exact />
  9. <StatusRoute code={404}>
  10. <div>
  11. <h1>Not Found</h1>
  12. </div>
  13. </StatusRoute>
  14. </Switch>
  15. </div>
  16. 复制代码

render函数修改如下

  1. let context = {};
  2. let component = createApp(context, req.url);
  3. let html = ReactDOMServer.renderToString(component);
  4. if (!context.status) { // 无status字段表示路由匹配成功
  5. let htmlStr = template.replace("<!--react-ssr-outlet-->", `<div id='app'>${html}</div>`);
  6. // 将渲染后的html字符串发送给客户端
  7. res.send(htmlStr);
  8. } else {
  9. res.status(context.status).send("error code:" + context.status);
  10. }
  11. 复制代码

服务端渲染时判断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组件渲染的内容

Bar.jsxrender方法如下

  1. render() {
  2. return (
  3. <div>
  4. <div>Bar</div>
  5. </div>
  6. );
  7. }
  8. 复制代码

因为客户端的路由,浏览器地址栏已经变成了http://localhost:3000/bar,并且渲染出Bar.jsx中的内容,但是客户端和服务端渲染不一致

server.jsx中增加一行代码console.log(context)

  1. let context = {};
  2. let component = createApp(context, req.url);
  3. let html = ReactDOMServer.renderToString(component);
  4. console.log(context);
  5. ...
  6. 复制代码

然后访问http://loclahost:3000,可以在终端看到以下输出信息

  1. ======enter server======
  2. visit url: /
  3. { action: 'REPLACE',
  4. location: { pathname: '/bar', search: '', hash: '', state: undefined },
  5. url: '/bar' }
  6. 复制代码

通过context获取url进行服务端重定向处理

  1. if (context.url) { // 当发生重定向时,静态路由会设置url
  2. res.redirect(context.url);
  3. return;
  4. }
  5. 复制代码

此时访问http://loclahost:3000,浏览器发送了两次请求,第一次请求/,第二次重定向到/bar

Head管理

每一个页面都有对应的head信息如title、meta和link等,这里使用react-helmet插件来管理Head,它同时支持服务端渲染

先安装react-helmet

npm install react-helmet

然后在App.jsximport,添加自定义head

  1. import { Helmet } from "react-helmet";
  2. 复制代码
  1. <div>
  2. <Helmet>
  3. <title>This is App page</title>
  4. <meta name="keywords" content="React SSR"></meta>
  5. </Helmet>
  6. <div className="title">This is a react ssr demo</div>
  7. ...
  8. </div>
  9. 复制代码

在服务端渲染时,调用ReactDOMServer.renderToString()后需要调用Helmet.renderStatic()才能获取head相关信息,为了在server.js中使用App.jsx中的Helmet,需要在入口entry-server.jsApp.jsx做一些修改

entry-server.js

  1. const createApp = (context, url) => {
  2. const App = () => {
  3. return (
  4. <StaticRouter context={context} location={url}>
  5. <Root setHead={(head) => App.head = head}/>
  6. </StaticRouter>
  7. )
  8. }
  9. return <App />;
  10. }
  11. 复制代码

App.jsx

  1. class Root extends React.Component {
  2. constructor(props) {
  3. super(props);
  4. if (process.env.REACT_ENV === "server") {
  5. // 当前如果是服务端渲染时将Helmet设置给外层组件的head属性中
  6. this.props.setHead(Helmet);
  7. }
  8. }
  9. ...
  10. }
  11. 复制代码

Root组件传入一个props函数setHead,在Root组件初始化时调用setHead函数给新的App组件添加一个head属性

修改模板index.html,添加<!--react-ssr-head-->作为head信息占位

  1. <head>
  2. <meta charset="UTF-8">
  3. <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
  4. <link rel="shortcut icon" href="/public/favicon.ico">
  5. <title>React SSR</title>
  6. <!--react-ssr-head-->
  7. </head>
  8. 复制代码

server.js中进行替换

  1. if (!context.status) { // 无status字段表示路由匹配成功
  2. // 获取组件内的head对象,必须在组件renderToString后获取
  3. let head = component.type.head.renderStatic();
  4. // 替换注释节点为渲染后的html字符串
  5. let htmlStr = template
  6. .replace(/<title>.*<\/title>/, `${head.title.toString()}`)
  7. .replace("<!--react-ssr-head-->", `${head.meta.toString()}\n${head.link.toString()})`)
  8. .replace("<!--react-ssr-outlet-->", `<div id='app'>${html}</div>`);
  9. // 将渲染后的html字符串发送给客户端
  10. res.send(htmlStr);
  11. } else {
  12. res.status(context.status).send("error code:" + context.status);
  13. }
  14. 复制代码

component<App />经过jsx语法转换后的对象,component.type是获取该对象的组件类型,这里是entry-server.js中的App

注意:这里必须通过App.jsximport进来的Helmet调用renderStatic()后才能获头部信息

访问http://localhost:3000时,头部信息已经被渲染出来了

每一个路由对应一个视图,每一个视图都有各自的head信息,视图组件是嵌套在根组件中的,当组件发生嵌套使用react-helmet时会自动替换相同的信息

Bar.jsxBaz.jsxFoo.jsxTopList.jsx中分别使用react-helmet自定义标题。如

  1. class Bar extends React.Component {
  2. render() {
  3. return (
  4. <div>
  5. <Helmet>
  6. <title>Bar</title>
  7. </Helmet>
  8. <div>Bar</div>
  9. </div>
  10. );
  11. }
  12. }
  13. 复制代码

浏览器输入http://localhost:3000/bar时标题渲染成<title data-react-helmet="true">Bar</title>

输入http://localhost:3000/baz时标题渲染成<title data-react-helmet="true">Baz</title>

总结

本节对React基本路由进行配置化管理,使得维护起来更加简单,也为后续数据预取奠定了基础。在服务端路由渲染中使用了StaticRouter组件,这个组件有contextlocation两个props,渲染时可以自行给context赋予自定义属性,比如设置状态码,location则用来匹配路由。服务端渲染中head信息必不可少,react-helmet插件提供了简单的用法来定义head信息,同时支持客户端和服务端

本章节源码

下一节:代码分割和数据预取

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/秋刀鱼在做梦/article/detail/751976
推荐阅读
相关标签
  

闽ICP备14008679号