Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

React 服务端渲染与同构 #10

Open
pspgbhu opened this issue Mar 23, 2018 · 0 comments
Open

React 服务端渲染与同构 #10

pspgbhu opened this issue Mar 23, 2018 · 0 comments

Comments

@pspgbhu
Copy link
Member

pspgbhu commented Mar 23, 2018

传统的 SPA 开发模式由于其页面渲染全部放在了客户端,从而导致了一些一直以来难以解决的痛点:首屏白屏时间较长、SEO 不友好等。

而服务端渲染则可以解决这些传统的痛点:

  1. 服务端直出 HTML 文档,让搜索引擎更容易读取页面内容,有利于 SEO。
  2. 不需要客户端执行 JS 就能直接渲染出页面,大大减少了页面白屏的时间。

服务端渲染的好处上面已经描述清楚了,那么同构呢?

感谢 Nodejs 的出现让服务端也有了运行 JavaScript 的能力,这样我们原本在前端运行的 React 在后端也可以运行了。两端公用一套逻辑代码,减少了开发量,也避免了前后端页面渲染的不一致。

React API 的支持

React 提供了四个 API 来将虚拟 DOM 输出为 HTML 文本。

前两个方法在浏览器和服务端都是可用的:

下面这两个方法会将文本以流的形式输出,因此只能在服务端运行。

renderToString 方法与 renderToStaticMarkup 的区别:

后者不会在创建出的 DOM 节点上添加任何的 React 属性,这也就意味着创建出的页面将不会具备响应式的特性,renderToStaticMarkup 方法适用于创建纯静态页。

我这边需要在客户端继续使用 React 的响应式特性,因此我选用了 renderToString 方法。

让服务端支持 JSX

Node 环境下是不可以直接运行 JSX 的,但是我们可以借助于 babel-register 来在服务端支持 jsx 格式的文件。

在整个 node 程序的最最开始引入 babel-register:

// app.js
require('babel-register')({
  presets: [
    'es2015',
    'react',
    'stage-2',
  ],
  plugins: [
    ['transform-runtime', {
      polyfill: false,
      regenerator: true,
    }],
  ],
  extensions: ['.jsx', '.js'],
});

const Koa = require('koa')
const app = new Koa();

//...
//...

这样就能正常处理 .jsx 后缀的文件和 jsx 语法了。同时还支持了 import 等 ES6 语法。

项目框架搭建

实现服务端渲染的第一步先从搭建起一个后端项目开始。为了减少 Node 项目的搭建成本,这里我推荐使用 koa-generator

在脚手架的基础上,再新建三个文件夹:

  • /build
    Webpack 启动配置,主要用于打包浏览器端所需要的静态资源。
  • /common
    主要是 React 的组件和逻辑代码。前后端公用这一部分代码。通常我们将 React 的根组件作为整个 common 文件夹的入口暴露出去。
  • /client
    只在浏览器端运行的文件。
├── app.js                # 程序入口文件
├── bin
│   └── www               # 程序启动脚本
├── build                 # Webpack 配置,用于打包前端静态资源
├── client                # 只在浏览器端运行的代码
|   └── index.jsx         # 浏览器端的入口文件
├── common                # 客户端和服务端共享代码, React 同构代码
|   └── App.jsx           # React 根组件
├── controllers           # Controllers
├── public                # 静态资源文件
├── routes                # Koa 路由
└── views                 # 页面模板文件
    └── index.jsx         # ejs 渲染模板

实现服务端渲染

单单实现 React 服务端渲染非常简单:使用 React 的 renderToString API 将虚拟 DOM 输出为 HTML 字符串,然后当浏览器请求页面时返回给浏览器就 OK 了。

// Node 服务端  ./routes/index.jsx

import Router from 'koa-router';
import { renderToString } from 'react-dom/server';
import App from '../common/App';
const router = Router();

/**
 * 为了配合浏览器端的 React-Router 中 BrowserRouter 路由
 * 我们使用了 '*' 来匹配任何 URL,以返回同样的 HTML。
 */
router.get('/', async (ctx) => {
  // 因为使用了 babel-register,因此我们可以直接使用 jsx 语法。
  // 将根组件 App 输出为 html 字符串。
  const content = renderToString(<App/>);

  await ctx.render('index', {
    html: content,  // 将 html 字符串通过 ejs 模板渲染出来
  });
});

export default router;

对应的 index.ejs 文件:

<!-- index.ejs -->

<!DOCTYPE html>
<html>
  <head>
    <title>React Isomorphic</title>
    <link rel='stylesheet' href='/css/style.css' />
  </head>
  <body>
    <!-- koa-router 将 react 虚拟dom 输出为字符串,渲染在 html 变量中 -->
    <div id="app"><%- html %></div>
    <script src="/js/app.js"></script>
  </body>
</html>

这样,在浏览器请求页面的时候,服务端就会返回具备完整 DOM 结构的 HTML 了。

服务端渲染只负责渲染出首屏的内容,往往在首屏渲染后页面上还是会有很多的交互和异步操作的,因此我们依然需要向之前前端开发一样,打包 JS 并在 HTML 中引入对应的 JS 文件。

/client/index.jsx 作为整个浏览器端的入口 JS 文件,内容大致如下:

// ./client/index.ejs

import React, { Component } from 'react';
import { hydrate } from 'react-dom';
import { BrowserRouter, withRouter } from 'react-router-dom';

// 用 hydrate 来替代 render
hydrate(
  <App></App>,
  document.querySelector('#app'),
);

React v16 提供了新的 render API 来专门为服务端渲染来做首屏渲染优化:
ReactDOM.hydrate (如果一个节点上有服务端渲染的标记,则 React 会保留现有 DOM,只去绑定事件处理程序,从而达到一个最佳的首屏渲染表现)。
我们将使用 hydrate 来替代 render

至此,一个最简单的服务端渲染就完全构建完毕了。

搭配 React Router 使用

浏览器端的 React Router 支持两种模式:

  • Hash Router:基于 URL Hash 来实现的 Router。
  • Browser Router:看起来更像是真实的链接跳转,但需要服务端的支持。

这里我选择了 Browser Router,它会让我在使用 React Router 跳转时有一种更真实的跳转的感觉。

这里我们需要对浏览器端和服务器端分别处理,对于浏览器端:

// ./client/index.ejs

import React, { Component } from 'react';
import { hydrate } from 'react-dom';
import { BrowserRouter, withRouter } from 'react-router-dom';

// 用 hydrate 来替代 render
hydrate(
  <BrowserRouter>
    <App></App>
  </BrowserRouter>,

  document.querySelector('#app'),
);

React-Router 提供了专门的 StaticRouter 来进行服务端渲染。 我们将浏览器请求的页面路径作为参数传入到 StaticRouter 的 location 参数中,StaticRouter 就会帮助我们来返回不同的虚拟 dom 节点。然后我们再用 renderToString 方法来转成 html 字符串。

同时我们还需要把 router.get('/') 更改为 router.get('*')。任何路径的请求都将会进入到该路由进行处理。

// Node /routes/index.jsx

import Router from 'koa-router';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom'

const router = Router();

/**
 * 为了配合浏览器端的 React-Router 中 BrowserRouter 路由
 * 我们使用了 '*' 来匹配任何来自浏览器的请求,这样保证了任何路径的请求都将得到该路由的处理。
 */
router.get('*', async (ctx) => {
  const context = {};
  const content = (
    <StaticRouter location={ctx.url} context={context}>
      <App/>
    </StaticRouter>
  );

  await ctx.render('index', {
    html: content,
  });
});

export default router;

Redux 的服务端渲染

Redux 的服务端渲染实现思路也很清晰,就是在服务端初始化一个 Store 的同时把这个 Store 也传递到客户端。

第一步:在服务端构建初始 store

扩充 Koa 的路由文件:

// server-side ./routes/index.jsx

import Router from 'koa-router';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import { createStore } from 'redux'

const router = Router();

router.get('*', async (ctx) => {
  // 在服务端初始化 store 的数据
  const store = createStore(state => state, {
    name: 'Pspgbhu',
    site: 'http://pspgbhu.me',
  });

  const context = {};
  const content = (
    <StaticRouter location={ctx.url} context={context}>
      {/* 增加 Provider */}
      <Provider store={store}>
        <App/>
      </Provider>
    </StaticRouter>
  );

  // 获取 store 数据对象
  const preloadedState = store.getState();

  await ctx.render('index', {
    html: content,
    state: preloadedState, // 将 store 数据传递给 ejs 模板引擎
  });
});

export default router;

第二步:模板引擎将初始的 store 渲染到页面中

模板引擎将 koa router 传来的 store 数据赋值给 window.__INITIAL_STATE_ 对象下。

<!-- index.ejs -->

<!DOCTYPE html>
<html>
  <head>
    <title>React Isomorphic</title>
    <link rel='stylesheet' href='/css/style.css' />
  </head>
  <body>
    <div id="app"><%- html %></div>
    <script>
      // 将服务端的 store 对象赋值给该变量
      window.__INITIAL_STATE_ = <%- state %>;
    </script>
    <script src="/js/app.js"></script>
  </body>
</html>

第三步:客户端获取 Redux store 的初始值

// client-side index.jsx

import React from 'react';
import { hydrate } from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import { createStore } from 'redux';
import { Provider } from 'react-redux';

import App from '../common/App';

// 通过服务端注入的全局变量得到初始的 state
const preloadedState = window.__INITIAL_STATE_;

const store = createStore(state => state, preloadedState);

hydrate(
  <Provider store={store}>
    <BrowserRouter>
      <App></App>
    </BrowserRouter>
  </Provider>,

  document.querySelector('#app'),
);

样式文件的处理

通常我们在前端开发时,会直接在 jsx 中引入 css, less 等样式文件。

// app.jsx
import './style/app.less';
import './style/style.css';

但是服务端却不能正确的处理这些文件,会引起服务端报错。 渲染 HTML 文档本身不需要 CSS 样式的参与,因此我们想办法忽略这些文件就可以了。

我们可以通过 babel-register 的插件 babel-plugin-transform-require-ignore 来忽略一些固定后缀的文件。下面让我们来扩充 app.js 中的 bable-register 配置。

// app.js

require('babel-register')({
  presets: [
    'es2015',
    'react',
    'stage-2',
  ],
  plugins: [
    ['transform-runtime', {
      polyfill: false,
      regenerator: true,
    }],
    // babel-plugin-transform-require-ignore 插件
    // 可以使 node 忽略一些固定的后缀文件。
    [
      'babel-plugin-transform-require-ignore', {
        extensions: ['.less', '.sass', '.css'],
      },
    ],
  ],
  extensions: ['.jsx', '.js'],
});

开发环境和生产环境下的静态资源打包

上面的内容都是在说服务端渲染的事情,当页面在浏览器端运行的时候还是需要在浏览器中加载 JS 静态资源的。这些静态资源我们通常使用 Webpack 进行打包。

生产环境下的静态资源

生产环境下处理静态资源的打包很简单,直接使用 webpack 打包出对应的 JS 和 CSS,然后在 ejs 模板中直接引用对应的静态资源即可。

开发环境下的静态资源

开发环境下,因为需要频繁的更改项目代码,所以很需要代码的热更新。 服务端 Node 代码的热更新通过 nodemon 就能轻松实现。

对于前端静态资源的热更新,我是通过 webpack 去 watch 相应的代码源码,每次检测到代码改动后 webpack 再自动去 rebuild 代码。

此外,在开发环境下,我会将静态资源全部都打包在 /.dev 目录下,而不是 /build 目录下。并且在 app.js 中进行如下设置

/**
 * 非生产环境下引用 .dev 目录作为静态资源目录。
 * .dev 目录下的资源会优先于 public 文件下的资源。
 */
if (process.env.NODE_ENV !== 'production') {
  app.use(serve(path.join(__dirname, '.dev')));
}

app.use(serve(path.join(__dirname, 'public')));

以此来区分不同环境下的静态资源引用。

一些要注意的地方

1. 生命周期的不同

在同构的基础上,Node 服务端和浏览器端虽然都公用了整个 /common 目录下的 React 组件,但是在具体运行中还是有些不同。

在 Node 服务端,组件的生命周期只走到了 componentWillMount()

在浏览器端,组件拥有着完整的生命周期。

2. Node 环境下没有 window 对象等其他一些浏览器端特有的全局对象或方法。

平时我们在做前端开发的时候或多或少都会用到 window 对象和其他一些浏览器端特有的全局对象(如 document 等), Node 环境下是没有这两个对象的,如果贸然的使用,肯定是会引起 Node 端的报错的。

那么在组件中是不是不能用这些对象了呢?当然不是。上面有提到 Node 环境下,组件的生命周期只能走到 componentWillMount(),再之后的生命周期函数就不会被调用了。因此我们就可以把这些浏览器端特有的对象和方法放在 componentWillMount() 之后的生命周期函数里使用,比如在 componentDidMount() 中使用 document.querySelector('#app') 方法。

写在最后

整的来说,由于 React 及其全家桶对服务端渲染的支持十分不错,因此在服务端渲染的实现上整体还是比较简单的。而且实现上也很灵活,肯定不止本文的这一种思路。

这里在推荐一下 React 服务端渲染脚手架 react-isomorphic,我的个人博客 便是基于该脚手架重构开发的。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant