实现一个SSR同构应用

2,697 阅读6分钟

纸上得来终觉浅,我们来实现一个简易的服务端渲染流程,意在体会SSR带来的红利

页面源码来自React状态管理与同构实战

几个重要的概念

实现SSR是依靠React提供的ReactDomServer对象

它主要提供了只能在服务端使用的renderToString()renderToStaticMarkup()方法

renderToString()/renderToStaticMarkup()

使用方法: ReactDomServer.renderTostring(element)/ ReactDomServer.renderToStaticMarkup(element)

共同点:

  • 都接收一个React Element 并将此 Element 转化为HTML字符串,通过浏览器返回,实现了在服务端将页面拼接字符串插入HTML文档中并返回给浏览器 完成初步服务端渲染的目的

不同点

  • renderToString(注:React 15) 生成的HTML字符串的每个Dom节点都有data-react-id属性,根节点会有一个data-react-checkSum属性
  • renderToStaticMarkup 不带data-react-checkSum属性 浏览器渲染时必会重新渲染组件

关于data-react-checkSum:

如果两个组件有相同的props和Dom结构,这个值是一样的

我们知道 服务端渲染完页面内容难过之后,浏览器端也会渲染以完成组件的交互等能力,浏览器端会生成组件的data-react-checkSum值 然后跟服务端渲染组件的值做对比,如果相等,则不再重复渲染

这里有一张草图能大概描述这个过程嘤嘤嘤.

cache_detai

ReactDom.hydrate()

React 16以后通过 renderToString渲染的组件不再带有data-react-*属性,因此浏览器端的渲染方式无法简单通过data-react-checksum来判断是否需要重新渲染

基于这样儿的背景下ReactDom提供了一个新的API ReactDom.hydrate() 用法同render()在浏览器端渲染组件

当然,react是向下兼容的,浏览器端在渲染组件时使用render()仍然没有问题,但不论是面向未来,还是基于性能的考虑,都应该采用更好的模式

renderToNodeStream()/renderToStaticNodeStream()

React 16 为了优化页面的初始加载速度缩短TTFB时间,提供了这两个方法

概念

该方法持续产生子节流 返回 Readable stream 最终通过流形式返回的HTML字符串 这样 服务端处理内容时是实时向浏览器端传输数据而不是一次性处理完成后才开始返回结果的

renderToStaticNodeStream 之于 renderToNodeStream 也是不会产生data-react-*属性,对于静态页面 可以采用此方法。

实际开发中可能存在的问题

  1. 服务端不存在支持组件挂载的浏览器环境,所以react组件只有componentDidMount之前的生命周期方法有效,所以在其之前的生命周期方法中不能用到浏览器的特性,比如 window、localStorage.
  2. 双端可能都有拉取数据的需求,所以为了实现代码的复用,一种典型的做法就是把请求数据的逻辑放到React组件的静态方法中 然后双端共用,双端请求方法不一致的问题可以通过服务端与浏览器端的判断来封装一下 比如根据window是浏览器特有对象

React 16 在服务端渲染上的惊喜

前面也有混杂说过,在此总结一下

  • 在浏览器渲染组件需要配合服务端使用hydrate方法
  • 提供了stream方式的接口
  • 与浏览器的新特性相似,除了能处理React Element 也能处理别的类型,比如string number
  • 因为在返回结果Dom中废除了data-react-checksum等属性,所以服务端生成HTML更加高效
  • 允许在渲染Dom中加入非标准Dom属性

好了 测试一下,基于Node.js实现一个小栗子

Express4.15.3 进行服务端处理

ssrvs1

  • browser: 浏览器端渲染
  • server:服务端逻辑
  • share:同构的部分

运行效果:

ssr

share/app.js

import React, { Component } from "react";
import logo from "./logo.svg";
import "./App.css";

class App extends Component {
  constructor(props) {
    super(props);
    this.handleClick = this.handleClick.bind(this);
  }
  handleClick() {
    alert('我被触发辣')
  }
  render() {
    return (
      <div className="App">
        <div className="App-header">
          <img src={logo} className="App-logo" alt="logo" />
          <h2>Welcome to React in the Server</h2>
        </div>
        <p className="App-intro">点击按钮体验</p>
        <button onClick={e => this.handleClick()}> 我是按钮 </button>  
      </div>
    );
  }
}

export default App;

browser/index.js

import React from "react";
import { hydrate } from "react-dom";
import App from "../shared/App";

hydrate(<App />, document.getElementById("root"));

server/index.js

import express from "express";
import React from "react";
import { renderToString } from "react-dom/server";
import App from "../shared/App";

const app = express();

app.use(express.static("public"));

app.get("*", (req, res) => {
  const htmlMarkup = renderToString(<App />);
  res.send(`
      <!DOCTYPE html>
      <head>
        <title>Universal Reacl</title>
        <link rel="stylesheet" href="/css/main.css">
        <script src="/bundle.js" defer></script>
      </head>

      <body>
        <div id="root">${htmlMarkup}</div>
      </body>
    </html>
  `);
});

app.listen(process.env.PORT || 3000, () => {
  console.log("Server is listening");
});

server端:

使用 renderToString生成的字符串,使用res.send发送给浏览器

client端:

id为root的Dom节点就来自服务端返回的结果,用了React.hydrate完成了浏览器端的逻辑处理部分

假设一 client端渲染仍然使用render()

测试

import React from "react";
import {render } from "react-dom";
import App from "../shared/App";

render(<App />, document.getElementById("root"));

结果 由于实现了向下兼容,所以是可以的,但是会给如下警告⚠️

hydrate_to_rende

结论 尽量使用新特性

假设二 完全依赖服务端渲染会发生什么

测试browser/index.js代码注释掉 结果 页面正常显示,但是点击按钮没有不会弹窗 结论 需要双端一起完成页面的展示与交互

假设三 使用React 16 renderToNodeStream渲染

测试 更改 server/index.js


import express from "express";
import React from "react";
import { renderToNodeStream } from "react-dom/server";
import App from "../shared/App";

const app = express();

app.use(express.static("public"));

app.get("*", (req, res) => {
  res.write(`
      <!DOCTYPE html>
      <head>
        <meta http-equiv="content-type" content="text/html; charset=utf-8">
        <title>Universal Reacl</title>
        <link rel="stylesheet" href="/css/main.css">
        <script src="/bundle.js" defer></script>
      </head>`
  );
  res.write("<div id='root'>"); 
  const stream = renderToNodeStream(<App/>);
  stream.pipe(res, { end: false });
  stream.on('end', () => {
    res.write("</div></body></html>");
    res.end();
  });
});

说明: 为了配合返回一个流,使用res.write方法代替先前的res.end

好处 使用renderToString页面TTFB时间

TTFB2

使用renderToNodeStream页面TTFB时间

TTFB1

结论 采用渐进式流渲染可以最大限度的缩短服务器响应水间,从而使浏览器可以更快的接收到信息

假设三 同构应用与浏览器渲染优势对比

浏览器渲染:

client_rende

同构应用:

ss

假设三 react16比react15渲染更加高效

React 15

react15_rende
React 16
react16

遗留问题

  1. 鉴于renderToNodeStream()/renderToStaticNodeStream()renderToString()/renderToStaticMarkup() React 16之后都不存在data-react-*了 双方还有什么区别?
  2. react 16之后 如何做双端对比? 官方说是根据ReactDom.hydrate()renderToString()结合判断.. 一脸懵逼