# React 同构 (Isomorphic)

所谓同构,就是一个 web 应用在浏览器端和 Node 端共用一套代码。同构直出可以加快首屏渲染速度,提供友好的 SEO。

既然我们已经有了服务端渲染,为什么还需要同构?这是因为,传统 MPA 应用的服务端渲染每次切页都会重新加载资源,并且 html 标签先回来,再由客户端去请求加载 js 等资源,虽然可见但不一定可操作。

接到同构任务时,一开始觉得这东西既然 react 官方已经提供了解决方案,应该不会太难。但是实际重构的过程却是非常难受的。下面是这次重构过程的总结。

# 项目使用的主要框架

  • webpack
  • react
  • react-router
  • redux
  • styled-components
  • @material-ui
  • koa
  • koa-router
  • koa-swig

# 同构需要解决的问题

  • webpack改造
  • 组件同构
  • 样式同构
  • 路由同构
  • 数据同构

# webpack改造

webpack改造这部分和项目相关系太大。我们项目是MPA的,主要工作是把多页变成同构应用,最终把组件都打到一个html中去。服务端用的gulp打包,也用了一些ioc框架。用gulp打包会遇到组件依赖问题,用webpack打包ioc会报错,没有找到好的解决办法,于是重构了一下路由,把ioc去掉了。有时间还是需要继续看看这块怎么把ioc保留。最终的方案是,服务端去掉ioc全部用webpack打包,打成一个server.bundle.js。用Node启动这个server.bundle.js。

接下来来看下组件同构。

# 组件同构

组件同构要解决的问题是在服务端将我们编写的 jsx 或者 tsx 组件渲染出来,再塞到模板里吐回给客户端。 这部分 react 官方已经给出解决方案,只需要使用 react-dom/server 提供的 renderToString 方法,就可以将 react 组件渲染成字符串。代码如下:

//server端
import { renderToString } from "react-dom/server";
import routes from "../webapp/shared/Routes";
import XXXApp from "../webapp/shared/XXXApp";//y应用主路由入口
const router = new Router();
router.get(CONTROLLER_PATH, async (ctx, next) => {
    const isomorphicBody = renderToString(
        <StaticRouter location={ctx.req.url}>
            <XXXApp></XXXApp>
        </StaticRouter>)
}

//client端 入口
ReactDOM.hydrate(<BrowserRouter>
          <XXXApp></XXXApp>
        </BrowserRouter>, document.querySelector("#app-root"));
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 处理静态资源

一开始项目是用的 gulp 打包服务端代码,如果按 react 官方推荐的这种写法,编译之后运行会报错。因为我们组件中可能会引入了图片资源,如果没有配置 gulp 不认识这些资源。

解决办法就是用 webpack 先把这个组件编译成一个 node 端的 js 组件。这种办法到目前为止是能解决我们的问题,但是在后续的同构步骤中会发现这种办法不能解决所有问题。这部分后面再介绍。

# DOM API

组件同构过程中还会碰到的一个问题就是组件中如果用了 window、document、fetch 这些 DOM API 时,启动服务端服务会报错。这是因为服务端没有这些 API。

解决办法就是判断代码的执行环境,如果是 Node 端就不使用或者加载这些 DOM API。

export function isNode() {
  return typeof window === "undefined";
}

if (!isNode()) {
  const { $APP } = window;
}
1
2
3
4
5
6
7

# 样式同构

样式同构是碰到的比较头疼的问题。我们用的是 styled-components, styled-component 官方给我们提供了一套服务端渲染的机制。 于是,一顿操作:

//server端
import { ServerStyleSheet } from "styled-components";
// ... 省略代码
router.get(CONTROLLER_PATH, async (ctx, next) => {
    const styleSheets = new ServerStyleSheet();
    styleSheets.collectStyles(
        <StaticRouter location={ctx.req.url}>
          <XXXApp></XXXApp>
        </StaticRouter>
  );

  const styles = styleSheets.getStyleTags();

  var html = await ctx.render("xxxapp/pages/index");//拿到html模板

  if (html.indexOf("<!-- IsmorphicInjectCSS -->") != -1) {
    html = html.replace("<!-- IsmorphicInjectCSS -->", styles);//动态替换css
  }

  // ... 省略代码
    ctx.body = html;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

打包编译重新启动服务之后,样式确实已经出来了。然而,定睛一看,有部分样式仍然很奇怪。进入代码查看这些样式有什么不一样的地方,发现了一个问题,所有出问题的地方都使用了@material-ui 组件。我们知道 material-ui 本身也是基于 styled-components,使用了其中很多的特性。大胆的猜想,styled-components 的服务端渲染方案对 material-ui 并不适用。于是去 material-ui 官网查找服务端渲染方案。(@material-ui 服务端渲染方案)。

material-ui 官方提供了一套类似 styled-components 的服务端渲染方案:

import { ServerStyleSheets, ThemeProvider } from "@material-ui/core/styles";
function handleRender(req, res) {
  const sheets = new ServerStyleSheets();

  // 将组件渲染成字符串。
  const html = ReactDOMServer.renderToString(
    sheets.collect(
      <ThemeProvider theme={theme}>
        <App />
      </ThemeProvider>
    )
  );

  // 从 sheet 中抓取 CSS。
  const css = sheets.toString();

  // 将渲染的页面发送回客户端。
  res.send(renderFullPage(html, css));
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

既然 styled-components 的服务端渲染方案无法兼容 material-ui,那 material-ui 的方案可以兼容 styled-components 吗?遗憾的是,一顿操作之后,发现还是 Too Young。

styled-components 的服务端渲染给我们提供了一个返回 styledComponents 的方法,根据这个继续大胆猜想,那如果先用 styled-components 处理一遍,把返回的组件再交给 material-ui 呢?所幸,这个方法完美运行:

// server

import { ServerStyleSheets, ThemeProvider } from "@material-ui/core/styles";
import { ServerStyleSheet } from "styled-components";
const router = new Router();

// ...
router.get(CONTROLLER_PATH, async (ctx, next) => {
      var html = await ctx.render("XXXApp/pages/index");//获取到html模板

  const sheet = new ServerStyleSheets();
  const styleSheets = new ServerStyleSheet();
  styleSheets.collectStyles(
        <StaticRouter location={ctx.req.url}>
          <XXXApp></XXXApp>
    </StaticRouter>
  );

const isomorphicBody = renderToString(
    sheet.collect(styleSheets.getStyleElement())
  );

  const styles = sheet.toString();
  if (html.indexOf("<!-- IsmorphicInjectCSS -->") != -1) {
    var injectCss = `<style id="jss-server-side">${styles}</style>`;
    html = html.replace("<!-- IsmorphicInjectCSS -->", injectCss);
  }
  if (html.indexOf("<!-- IsmorphicInject -->") != -1) {
    html = html.replace("<!-- IsmorphicInject -->", isomorphicBody);
  }
  ctx.body = html;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

如果组件中引入了第三方的css库,可以判断运行环境,Node端不加载,浏览器端再加载。但要用require加载。

//一个轮播组件.js
import {isNode} from '../utils/BrowserUtil';

if(!isNode()) {
  require("react-responsive-carousel/lib/styles/carousel.min.css");
}
1
2
3
4
5
6

# 路由同构

使用react-router官方提供的同构方案即可。

//server
router.get(CONTROLLER_PATH, async (ctx, next) => {
      var html = await ctx.render("XXXApp/pages/index");//获取到html模板

  const sheet = new ServerStyleSheets();
  const styleSheets = new ServerStyleSheet();
  styleSheets.collectStyles(
        <StaticRouter location={ctx.req.url}>
          <XXXApp></XXXApp>
    </StaticRouter>
  );
   // ...
}

//client
ReactDOM.hydrate(<BrowserRouter>
          <XXXApp></XXXApp>
        </BrowserRouter>, document.querySelector("#panda-app-root"));
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 数据同构

数据同构是将组件初始化过程中涉及到的网络请求放到服务端去请求,服务端等待所有请求完成之后,将数据注入到组件中,也就是俗称的“注水”操作。

数据同构的好处是可以加快首屏渲染速度,减少首屏网络请求。尤其是像我们现在这个项目,首页90%的内容都是需要经过网络请求后再加载的,这个时候做数据同构会极大提升用户体验。

数据同构主要用到的技术是redux。实现整套redux的过程步骤多,难度不大。这里主要遇到的问题是数据按项目之前的构建方式,只把XXXAPP这个组件打成一个js的方式行不通了,因此我们引入了路由,路由中定义了组件,直接在node端引入组件就会遇到之前的图片等资源的问题。解决办法就是把server端的入口app.js作为打server.bundle.js的入口,服务端代码全部打到一个js中。

最后贴一下最终服务端同构的完整代码,以及客户端入口代码,redux剩下的代码可以按官网操作。

  1. 服务端
import Koa from "koa";
import historyApiFallback from "koa2-connect-history-api-fallback";
import co from "co";
import render from "koa-swig";
import serve from "koa-static";
import errorHandler from "./middlewares/ErrorHandler";
import log4js from "log4js";
import config from "./config";
import Router from "@koa/router";
var path = require("path");
import { StaticRouter, matchPath } from "react-router-dom";
import React from "react";
import { renderToString } from "react-dom/server";
import { ServerStyleSheets, ThemeProvider } from "@material-ui/core/styles";
import { Provider } from "react-redux";
import XXXApp from "../webapp/shared/XXXApp";
import { createServerStore } from "../webapp/shared/store";
import routes from "../webapp/shared/Routes";
import CONTROLLER_PATH from "./ControllerPath";
import Theme from "../webapp/common/Theme";
import { ServerStyleSheet } from "styled-components";
const app = new Koa();
const router = new Router();

app.use(serve(config.staticDir));
app.use(historyApiFallback({ whiteList: ["/lpn", "/lpn/api"] }));
app.context.render = co.wrap(
  render({
    root: path.join(__dirname, "views"),
    autoescape: true,
    cache: config.memoryFlag,
    ext: "html",
    varControls: ["[[", "]]"],
    writeBody: false,
  })
);
//逻辑和业务错误 http日志
log4js.configure({
  pm2: true,
  disableClustering: true,
  appenders: {
    cheese: {
      type: "file",
      filename: "logs/panda-front.log",
    },
  },
  categories: {
    default: {
      appenders: ["cheese"],
      level: "info",
    },
  },
});
const logger = log4js.getLogger("cheese");
errorHandler.error(app, logger);

router.get(CONTROLLER_PATH, async (ctx, next) => {
  const promises = [];
  const store = createServerStore();
  routes.some((route) => {
    const match = matchPath(ctx.request.path, route);
    if (match && route.loadData) {
      promises.push(route.loadData(store));
    }
  });
  var html = await ctx.render("XXXApp/pages/index");

  await Promise.all(promises);
  const sheet = new ServerStyleSheets();
  const styleSheets = new ServerStyleSheet();
  styleSheets.collectStyles(
    <Provider store={store}>
      <ThemeProvider theme={Theme}>
        <StaticRouter location={ctx.req.url}>
          <XXXApp></XXXApp>
        </StaticRouter>
      </ThemeProvider>
    </Provider>
  );

  const isomorphicBody = renderToString(
    sheet.collect(styleSheets.getStyleElement())
  );

  const styles = sheet.toString();
  if (html.indexOf("<!-- IsmorphicInjectCSS -->") != -1) {
    var injectCss = `<style id="jss-server-side">${styles}</style>`;
    html = html.replace("<!-- IsmorphicInjectCSS -->", injectCss);
  }

  if (html.indexOf("<!-- IsmorphicInjectReduxStore -->") != -1) {
    var storeString = `<script>window.REDUX_STATE = ${JSON.stringify(
      store.getState()
    )}</script>`;
    html = html.replace("<!-- IsmorphicInjectReduxStore -->", storeString);
  }

  if (html.indexOf("<!-- IsmorphicInject -->") != -1) {
    html = html.replace("<!-- IsmorphicInject -->", isomorphicBody);
  }
  ctx.body = html;
});

app.use(router.routes()).use(router.allowedMethods());

app.listen(config.port, () => {
  console.log("服务已启动🍺🍞", config.port);
});

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
  1. 客户端
"use strict";
/**
 * APP主入口
 */
import React, { useEffect } from "react";
import ReactDOM from "react-dom";
import { ThemeProvider } from "styled-components";
import Theme from "../../common/Theme";
import XXXApp from "../../shared/XXXApp";
import { BrowserRouter } from "react-router-dom";
import { Provider } from "react-redux";
import { createClientStore } from "../../shared/store";

function Main() {
  React.useEffect(() => {
    const jssStyles = document.querySelector("#jss-server-side");
    if (jssStyles) {
      jssStyles.parentElement.removeChild(jssStyles);
    }
  }, []);

  return (
    <Provider store={createClientStore()}>
      <ThemeProvider theme={Theme}>
        <BrowserRouter>
          <XXXApp></XXXApp>
        </BrowserRouter>
      </ThemeProvider>
    </Provider>
  );
}
ReactDOM.hydrate(<Main />, document.querySelector("#panda-app-root"));

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

# MPA实现SPA效果的原理

MPA切页时如果想要达到SPA的效果,需要解决几个问题:

  1. node端如何判断是切页还是刷页
  2. 切页如何回吐需要的那部分html
  3. 如何处理事件绑定

# 如何判断切页还是刷页

要达到这个目的MPA做页面跳转时只能使用a标签,同时配合 pjax 库。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>{% block title %}{% endblock %}</title>
    {% block head %}{% endblock %}
  </head>
  <body>
    {% include "../../components/banner/banner.html"%}
    <div id="app">
      {% block content %}{% endblock %}
    </div>
    <script src="https://cdn.staticfile.org/jquery/3.5.1/jquery.js"></script>
    <script src="https://cdn.staticfile.org/jquery.pjax/2.0.1/jquery.pjax.min.js"></script>
    <script>
      $(document).pjax('a', '#app');
    </script>
    {% block scripts %}{% endblock %}
  </body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

子组件:

//html
<div class="list pjaxcontent">
  <h1>展示列表</h1>
  <hr />
  <h3 id="js-btn">[[data]]</h3>
</div>

//js
const list = {
  init() {
    $(document).on('click', '#js-btn', function (event) {
      // $('#js-btn').click(function () {
      alert('数据加载成功');
    });
    console.log('list');
  },
};
export default list;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

node端判断请求头是否有x-pjax

// import Book from '@models/Book';
import { route, GET } from 'awilix-koa';
import { Readable } from 'stream';
import cheerio from 'cheerio';
@route('/books')
class BooksController {
  constructor({ booksService }) {
    this.booksService = booksService;
  }
  @route('/list')
  @GET()
  async actionIndex(ctx, next) {
    const data = await this.booksService.getData();
    const html = await ctx.render('books/pages/list', {
      data,
    });
    if (ctx.request.header['x-pjax']) {
      console.log('站内切');
      
    } else {
      console.log('刷新');

      // ctx.body = html;
    }
  }

}
export default BooksController;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

# 切页如何回吐需要的那部分html

使用到的库

  • cheerio

node端代码:

// import Book from '@models/Book';
import { route, GET } from 'awilix-koa';
import { Readable } from 'stream';
import cheerio from 'cheerio';
@route('/books')
class BooksController {
  constructor({ booksService }) {
    this.booksService = booksService;
  }
  @route('/list')
  @GET()
  async actionIndex(ctx, next) {
    const data = await this.booksService.getData();
    const html = await ctx.render('books/pages/list', {
      data,
    });
    if (ctx.request.header['x-pjax']) {//区分是否
      console.log('站内切');
      const $ = cheerio.load(html);
      ctx.status = 200;
      ctx.type = 'html';
      $('.pjaxcontent').each(function () {
        ctx.res.write($(this).html());//吐需要的html组件
      });
      $('.lazyload-js').each(function () {
        ctx.res.write(
          `<script class="lazyload-js" src="${$(this).attr('src')}"></script>`//吐需要的js
        );
      });
      ctx.res.end();
    } else {
      function createSSRStreamPromise() {
        console.log('落地页');
        return new Promise((resolve, reject) => {
          const htmlStream = new Readable();
          htmlStream.push(html);
          htmlStream.push(null);
          ctx.status = 200;
          ctx.type = 'html';
          htmlStream
            .on('error', (err) => {
              reject(err);
            })
            .pipe(ctx.res);
        });
      }
      await createSSRStreamPromise();
      // ctx.body = html;
    }
  }
  @route('/create')
  @GET()
  async actionCreate(ctx) {
    ctx.body = await ctx.render('books/pages/create');
  }
}
export default BooksController;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58

# 如何处理事件绑定

如果DOM是js动态添加的,这个时候事件绑定有时会失效。这个时候需要做一层代理,把事件代理到顶层document上。

const list = {
  init() {
    $('#js-btn').click(function () {
      alert('数据加载成功');
    });
  },
};
export default list;

1
2
3
4
5
6
7
8
9

解决办法:

const list = {
  init() {
    $(document).on('click', '#js-btn', function (event) {
      // $('#js-btn').click(function () {
      alert('数据加载成功');
    });
    console.log('list');
  },
};
export default list;
1
2
3
4
5
6
7
8
9
10

react的事件绑定也是绑定到document上。

最后更新时间: 10/29/2020, 8:01:15 PM