初谈 React SSR

什么是 SSR?

Server Slide Rendering,缩写为 SSR 即服务器端渲染。

现在很多的前端项目都是单页应用,为了良好的用户体验和前后端分离,我们会单独创建独立的客户端程序。现在已经有了很多成熟的构建客户端应用程序的框架,我们可以直接拿来使用并加以修改成项目需要的,当然,我们也可以完全根据自己的需求去搭建。

默认情况下,可以在浏览器中输出组件,进行生成 DOM 和操作 DOM 来实现用户交互。然而,有时候也可以将同一个组件渲染为服务器端的 HTML 字符串,将它们直接发送到浏览器,最后将这些静态标记"激活"为客户端上完全可交互的应用程序,这就是服务器端渲染。

为什么使用 SSR

与传统 SPA (单页应用程序 (Single-Page Application)) 相比,服务器端渲染 (SSR) 的优势主要在于:

  • 更好的 SEO,由于搜索引擎爬虫抓取工具可以直接查看完全渲染的页面。

单页应用的页面都是通过 ajax 去请求数据,动态生成页面,而搜索引擎爬虫因为不能抓取JS生成后的内容,遇到单页应用项目,什么都抓取不到,不利于 SEO,而 SSR 会在服务器端生成页面发送到客户端,查看的是完整的页面,对于像 about 、contact 页等的页面更加方便 SEO。

  • 解决首屏白屏问题。对于缓慢的网络情况或运行缓慢的设备,无需等待所有的 JavaScript 都完成下载并执行,才显示服务器渲染的标记,所以你的用户将会更快速地看到完整渲染的页面。通常可以产生更好的用户体验。

单页应用在第一次加载时,需要将一个打包好(requirejs 或 webpack 打包)的 js 发送到浏览器后,才能启动应用,这样会有些慢。如果在服务器端就预先完成渲染网页后,直接发送到浏览器,这样用户将会更快速地看到完整的渲染的页面,通常会产生更好的用户体验。

SSR 工作流程

SSR 工作流程

由上图可以看到,服务端只生成 HTML 代码,而前端会生成一份 main.js 提供给服务端的 HTML 使用。这就是 React SSR 的工作流程。

准备

nodejs 建议 v8.9.4 版本以上

如果 nodejs 版本过低可能在运行程序时,报 async read ... 错误。

SSR 方法
  • renderToString(React 15)

把 React 实例渲染成 HTML 标签。在 React 15 中,SSR 文件中的每个 HTML 元素都有一个 data-reactid 属性。在浏览器访问页面的时候,main.js 能识别到 HTML 的内容,不会执行 React.createElement 二次创建 DOM。而在 React 16 中,所有的 data-reactid 都从节点中移除了,页面看起来干净了许多。

  • renderToStaticMarkup(React 15)

在 React 15 中,SSR 文件中的 HTML 元素没有 data-reactid 属性,页面看上去干净点。在浏览器访问页面的时候,main.js 不能识别到 HTML 内容,会执行 main.js 里面的 React.createElement 方法重新创建 DOM。

renderToString 和 renderToStaticMarkup 方法接收一个 React Element,并将它转化为 HTML 字符串。通过这两个方法,就可以在服务端生成 HTML,并在首次请求时将标记下发,以加快页面加载速度,并允许搜索引擎爬取你的页面以达到 SEO 优化的目的。

  • renderToNodeStream (React 16)

支持直接渲染到节点流。渲染到流可以减少内容的第一个字节(TTFB)的渲染时间,在文档的下一部分生成之前,将文档的开头至结尾发送到浏览器。 当内容从服务器流式传输时,浏览器将开始解析 HTML 文档。速度是 renderToString 的三倍。

  • renderToStaticNodeStream(React 16)

renderToStaticNodeStream() 与 renderToNodeStream() 相似,但此方法不会创建额外的 DOM 属性,若是静态页面,建议使用此方法,可以取出额外的属性节省一些字节。

React 16 为了优化页面初始加载速度,缩短 TTFB 时间,提供了这两个方法。这两个方法持续产生字节流,返回一个可输出 HTML 字符串的可读流。通过可读流输出的 HTML 与 ReactDOMServer.renderToString() 返回的 HTML 完全相同。

renderToNodeStream 和 renderToStaticNodeStream 方法返回 Readable

当收到 renderTo(Static)NodeStream 方法时会返回 Readable 流,它处于暂停模式,并且还没有渲染。当调用 readpipe Writable 时开始渲染,大部分 web 框架从 Writable 继承响应对象,因此,一般来说,只要将 Readable 发送即可得到响应。

renderToString 和 renderToNodeStream 的区别

renderToString 的功能是一口气同步产生最终 HTML,如果 React 组件树很庞大,那么这样一个同步过程就会比较耗时。假设渲染完整 HTML 需要 500 毫秒,那么当一个 HTTP / HTTPS 请求过来,500 毫秒之后才返回 HTML,显得不大合适,这也是为什么 React 16 提供了 renderToNodeStream 这个新 API 的原因。

renderToNodeStream 把渲染结果以“流”的形式塞给 response 对象(这里的 response 是 express 或者 koa 的概念),这意味着不用等到所有 HTML 都渲染出来了才给浏览器端返回结果,也许 10 毫秒内就渲染出来了网页头部,那就没必要等到 500 毫秒全部网页都出来了才推给浏览器,“流”的作用就是有多少内容给多少内容,这样用户只需要 10 毫秒多一点的延迟就可以看到网页内容,进一步改善了用户体验。

使用 create-react-app 创建一个 React 项目

目录结构如下:

项目目录结构

开始

新建server目录,用于存放服务端代码。
server 目录

项目中使用到了 ES6,所以还要配置下 .babelrc。

配置 .babelrc
{
    "presets": [
        "env",
        "react"
    ],
    "plugins": [
        "transform-decorators-legacy",
        "transform-runtime",
        "react-hot-loader/babel",
        "add-module-exports",
        "transform-object-rest-spread",
        "transform-class-properties",
        [
            "import",
            {
                "libraryName": "antd",
                "style": true
            }
        ]
    ]
}
过滤资源代码

server 的项目入口需要做一些预处理,因为服务端只需要纯的 HTML 代码,不过滤掉会报错。使用 asset-require-hook 过滤掉一些引入 css、图片这样的资源代码。

require("asset-require-hook")({
  extensions: ["svg", "css", "less", "jpg", "png", "gif", "jpeg"],
  name: '/static/media/[name].[ext]'
});
require("babel-core/register")();
require("babel-polyfill");
require("./app");

模板代码调整

public/index.html 模版代码需要调整,{{root}} 这个可以是任何可以替换的字符串,等下服务端会替换这段字符串。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1, shrink-to-fit=no"
    />
    <meta name="theme-color" content="#000000" />
    <!--
      manifest.json provides metadata used when your web app is installed on a
      user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
    -->
    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
    <!--
      Notice the use of %PUBLIC_URL% in the tags above.
      It will be replaced with the URL of the `public` folder during the build.
      Only files inside the `public` folder can be referenced from the HTML.

      Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
      work correctly both with client-side routing and a non-root public URL.
      Learn how to configure a non-root public URL by running `npm run build`.
    -->
    <title>React App</title>
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root">{{root}}</div>
    <!--
      This HTML file is a template.
      If you open it directly in the browser, you will see an empty page.

      You can add webfonts, meta tags, or analytics to this file.
      The build step will place the bundled scripts into the <body> tag.

      To begin the development, run `npm start` or `yarn start`.
      To create a production bundle, use `npm run build` or `yarn build`.
    -->
  </body>
</html>

服务端渲染页面

使用 renderToString 生成 html 代码,去替换掉 index.html 中的 {{root}} 部分。

import App from '../src/App';
import Koa from 'koa';
import React from 'react';
import Router from 'koa-router';
import fs from 'fs';
import koaStatic from 'koa-static';
import path from 'path';
import { renderToString } from 'react-dom/server';

// 配置文件
const config = {
  port: 8888
};

// 实例化 koa
const app = new Koa();

// 静态资源
app.use(
  koaStatic(path.join(__dirname, '../build'), {
    maxage: 365 * 24 * 60 * 1000,
    index: 'root' 
    // 这里配置不要写成'index'就可以了,因为在访问localhost:3030时,不能让服务默认去加载index.html文件,这里很容易掉进坑。
  })
);

// 设置路由
app.use(
  new Router()
    .get('*', async (ctx, next) => {
      ctx.response.type = 'html'; //指定content type
      let shtml = '';
      await new Promise((resolve, reject) => {
        fs.readFile(path.join(__dirname, '../build/index.html'), 'utf-8', function(err, data) {
          if (err) {
            reject();
            return console.log(err);
          }
          shtml = data;
          resolve();
        });
      });
      // 替换掉 {{root}} 为我们生成后的HTML
      ctx.response.body = shtml.replace('{{root}}', renderToString(<App />));
    })
    .routes()
);

app.listen(config.port, function() {
  console.log('服务器启动,监听 port: ' + config.port + '  running~');
});

去掉 hash 值

执行 npm run build 命令的时候会自动给资源加了 hash 值,而这个 hash 值,我们在 asset-require-hook 的时候去掉了,配置里面需要修改下,不然会出现图片不显示的问题。

module.exports = {
  webpack: function(config, env) {
    // ...add your webpack config
    // console.log(JSON.stringify(config));
    // 去掉hash值,解决asset-require-hook资源问题
    config.module.rules.forEach(d => {
      d.oneOf &&
        d.oneOf.forEach(e => {
          if (e && e.options && e.options.name) {
            e.options.name = e.options.name.replace('[hash:8].', '');
          }
        });
    });
    return config;
  }
};

现在,我们已经将一个最简单的项目完成了,由于服务端读取的资源是 build 目录下的,所以我们应先执行 npm run build 打包项目,再执行 npm run server 启动服务端项目。打开 http://localhost:8888/ 查看下:

hello world 网页展示

再查看下代码结构:

代码结构

{{root}} 已经成功被 HTML 标签替代,服务器渲染成功!

服务端使用 renderToNodeStream 生成页面

刚刚已经使用 renderToString 生成了页面,我们再尝试使用 renderToNodeStream 生成页面:

import App from '../src/App';
import Koa from 'koa';
import React from 'react';
import Router from 'koa-router';
import fs from 'fs';
import koaStatic from 'koa-static';
import path from 'path';
import { renderToNodeStream } from 'react-dom/server';

// 配置文件
const config = {
  port: 8888
};

// 实例化 koa
const app = new Koa();

// 静态资源
app.use(
  koaStatic(path.join(__dirname, '../build'), {
    maxage: 365 * 24 * 60 * 1000,
    index: 'root' 
    // 这里配置不要写成'index'就可以了,因为在访问localhost:3030时,不能让服务默认去加载index.html文件,这里很容易掉进坑。
  })
);

// 设置路由
app.use(
  new Router()
    .get('*', async (ctx, next) => {
      ctx.response.type = 'html'; //指定content type
      let shtml = '';
      await new Promise((resolve, reject) => {
        fs.readFile(path.join(__dirname, '../build/index.html'), 'utf-8', function(err, data) {
          if (err) {
            reject();
            return console.log(err);
          }
          shtml = data;
          resolve();
        });
      });
      // 替换掉 {{root}} 为我们生成后的HTML
      ctx.response.body = shtml.replace('{{root}}', renderToNodeStream(<App />));
    })
    .routes()
);

app.listen(config.port, function() {
  console.log('服务器启动,监听 port: ' + config.port + '  running~');
});

输入 http://localhost:8888/ 查看页面:

renderToNodeStream 生成页面

可以看到,renderToNodeStream 也同样生成了页面。

总结

我们现在已经学会了 React 15 和 React 16 的服务端渲染。可以总结为两点:

  1. 搭建 node 环境,可以访问到线上文件(build包)。

  2. 使用 renderToString 或者 renderToNodeStream 把 HTML 拼接好返回给前端。

注意:处理css、jsx、图片和 babel 。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,384评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,845评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,148评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,640评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,731评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,712评论 1 294
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,703评论 3 415
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,473评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,915评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,227评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,384评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,063评论 5 340
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,706评论 3 324
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,302评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,531评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,321评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,248评论 2 352

推荐阅读更多精彩内容