谈谈我对服务端渲染(SSR)的理解

9,295 阅读17分钟

前言

在阅读文章之前,让我们先明白几个概念。

  • SPA(Single Page Application): 单页面应用程序,是前后端分离时提出的一种解决方案。在一个应用或者站点中,只有一个完整的html页面,这个页面有一个容器root节点,可以把需要加载的代码片段插入到该容器中。
    SPA的工作原理:采用的前端路由跳转子页面系统,通过改变页面的URL,在不重新请求页面的情况下,实现局部更新页面视图。

  • SEO(Search Engine Optimization ): 搜索引擎优化,利用搜索引擎的规则提高网站在有关搜索引擎内的自然排名。
    SEO的工作原理:网页在被请求的时候,从服务器发出的内容可以被搜索引擎的爬虫爬到数据,这个时候从搜索引擎搜索的关键字包含在这些内容中,那么这个网址的信息就更容易显示在搜索结果中。


为什么现在又回到了服务端渲染?

我们先来回顾下页面渲染方式的发展历程:

传统服务端渲染: 也称后端模板渲染(如 jsp 或者 php),这是最早时期的web,是指客户端请求时,在服务器上使用模板引擎将模板与数据拼接成完整的html,再发送给客户端,客户端接收后直接解析 HTML就可以在浏览器上展示出来,不需要额外的异步请求获取数据。但是这时候的web只能实现简单的信息展示,要使web有交互性,客户端需要再用 js 去操作 dom 或者渲染其他动态的部分。 传统服务端渲染流程如下:

这个时期的服务器上面有模板、有读取数据的后端代码,还夹杂着很多js的代码。这就导致了以下的一些不足:

  1. 前后端职责不清
  2. 前后端代码杂揉在一起
  3. 项目难以管理和维护 尽管如此,这种渲染方式还是有一些好处:
  4. 客户端能够快速呈现服务器端渲染好的页面,减少白屏时间,这能够提供很好的用户体验
  5. SEO 友好,服务端渲染从服务器发出的html带有页面内容,可提高搜索排名。

客户端渲染: 指使用 JS 来渲染页面大部分内容,代表是现在流行的 SPA 单页面应用(如react和vue)。客户端在请求时,服务端不做任何处理,直接将前端资源打包后生成的html返回给客户端,此时的html中无任何网页内容,需要客户端去加载执行js代码才能渲染生成页面内容,同时完成事件绑定,然后客户端再去通过ajax请求后端api获取数据更新视图。 客户端渲染流程如下:

这样一来,前后端代码解耦,两端使用 ajax 进行交互实现局部刷新。
但这种方式也有一些不足:

  1. 首屏加载缓慢,因为要等 js 加载完毕后,才能进行页面渲染
  2. SEO 不友好,SPA模式的客户端渲染从服务器发出的只是一个没有内容的空壳,大部分页面内容通过js渲染,搜索引擎自然爬不到东西
  3. 客户端渲染至少发起http请求三次,第一次是请求页面,第二次是请求页面里的JS脚本(渲染dom和进行事件绑定),第三次是动态数据ajax请求。而服务端渲染将传统的3次串行http请求简化成一次http请求,客户端只需要请求页面,解析服务端返回的html即可。
  4. 需要重复渲染,因为发送ajax请求向后台获取数据,一般在componentDidMounted生命周期,页面已经渲染过一次,等到获取数据后更新视图,页面又需要再渲染一次

对比下传统的服务端渲染与客户端渲染的优缺点,我们会发现一件很有意思的事,服务端渲染的优点就是客户端渲染的缺点,服务端渲染的缺点就是客户端渲染的优点,反之亦然。那为何不将传统的纯服务端直出的首屏优势和SPA的站内体验优势结合起来,以取得最优解?这就引出了当前流行的服务端渲染(Server Side Rendering),或者称之为“同构渲染”更为准确。


什么是同构渲染?

同构渲染: 所谓同构,通俗的讲,就是一套React代码在服务器上运行一遍,到达浏览器又运行一遍。服务端渲染完成页面结构,客户端渲染绑定事件。它是在SPA的基础上,利用服务端渲染直出首屏,解除单页面应用在首屏渲染上面临的窘境。

同构渲染流程如下:


为什么能实现同构渲染?

SSR 之所以能够实现,有两个重要前提,缺一不可:

  • 1. Node.js的存在
    前面我们讲过,同构渲染项目的 React.js 代码会在客户端和服务器端各执行一次。这得益于 Node 环境下能运行 JavaScript 代码,才使得同构成为可能。

    JavaScript 为什么能在 Node 环境下运行?
    JS是脚本语言,需要一个解析器才能运行。对于写在HTML页面里的 JS,浏览器充当着解析器的角色。而对于需要独立运行的 JS,NodeJS 就是一个解析器。
    每一种解析器都是一个运行环境,不但允许 JS 定义各种数据结构,进行各种计算,还允许 JS 使用运行环境提供的内置对象和方法做一些事情。例如运行在浏览器中的 JS 的用途是操作 DOM,浏览器就提供了 document 之类的内置对象。而运行在 NodeJS 中的 JS 的用途是操作磁盘文件或搭建 HTTP 服务器,NodeJS 就相应提供了 fs、http 等内置对象。

  • 2. 虚拟 DOM 的存在
    前面讲到,js操作 DOM 的能力是浏览器给予它的, 而在 Node 环境下,是没有 DOM 这个概念存在的,如果你的 React 代码里存在直接操作 DOM 的代码,在 Node 环境下这些代码会报错,就无法实现同构了。幸好在 React 框架中引入了虚拟 DOM 这个概念

虚拟 DOM:虚拟 DOM 其实就是一个 JavaScript 对象 ,通过这个 JavaScript 对象来描述真实 DOM。
比如,现在需要描述一个按钮(button),这用 HTML 语法表示非常简单:

<button class="btn btn-blue"> 
 <em>Confirm</em> 
</button>

其中包括了元素的类型和属性,而当我们使用 JavaScript 来描述这个元素时,这个元素就可以简单地被表示成纯粹的 JSON 对象,其中依然包括元素的类型以及属性:

{ 
 type: 'button', 
 props: { 
   className: 'btn btn-blue', 
   children: [{ 
     type: 'em', 
     props: { 
     children: 'Confirm' 
     } 
   }] 
 } 
} 

这样,我们就可以在 JavaScript 中创建 Virtual DOM 元素了。

React 在做页面操作时,实际上不是直接操作 DOM,而是通过操作虚拟 DOM 完成数据变化,也就是操作普通的 JavaScript 对象,这才使得同构渲染成为了可能。

其他的一些框架,比如 Vue,它能够实现 SSR 也是因为引入了和 React 中一样的虚拟 DOM 技术。


react怎么实现同构渲染?

首先我们需要知道应用程序的哪些部分应该是同构的:

  • 路由
  • 视图
  • 数据获取
  • 状态管理 我们一个个往下看如何它们分别是这么实现同构的。

1. 同构路由

无论是客户端还是服务端,我们都需要在用户发起请求时匹配路由,找到对应的的路由组件。 但是他们的原理却不相同,在服务器端,找到路由组件需要通过请求路径,而在客户端找到路由组件需通过浏览器中的网址,它们是完全不同的两套机制。

首先我们需要获得路由规则, 这一部份的代码服务端和客户端是可以公用的,它最基本的责任就是在 URL 和自己的路径对应时渲染 UI,可以采用约定式文件系统路由读取,得到像下面这段路由的配置文件:

// Routes.js
import React from 'react';
import {Route} from 'react-router-dom'
import Home from './containers/Home';
import Login from './containers/Login'

export default (
  <div>
    <Route path='/' exact component={Home}></Route>
    <Route path='/login' exact component={Login}></Route>
  </div>
)

在客户端路由中,使用react-router-dom的BrowserRouter, 它是有状态的,我们在浏览器地址栏输入一个url,BrowserRouter 会自动从浏览器地址中,匹配对应的路由组件。

// client/index.js
import React from 'react';
import { BrowserRouter } from 'react-router-dom'
import Routes from '../Routes'

const App = () => {
  return (
    <BrowserRouter>
      {Routes}
    </BrowserRouter>
  )
}

在服务端路由中,使用react-router-dom的StaticRouter, 它是无状态的,需要我们把location(当前请求路径)传递给 StaticRouter 组件,这样 StaticRouter 才能根据路径分析出当前所需要的组件是谁。所以我们需要设置一个中间件,使服务端接收到请求后会将当前请求路径作为props传递给StaticRouter。

// server/index.js
import express from 'express';
import {render} from './utils';

const app = express();
app.use(express.static('public'));
// 将请求路径传递给StaticRouter
app.get('*', function (req, res) {
   res.send(render(req));
});

app.listen(3001, () => {
  console.log('listen:3001')
});
// server/utils.js
import Routes from '../Routes'
import { StaticRouter } from 'react-router-dom'; 
import React from 'react'

export const render = (req) => {
  const App = () => {
    return (
        <StaticRouter location={req.path}>
            {Routes}
        </StaticRouter>
    )
  }
}

2. 同构视图

在客户端和服务端匹配路由拿到对应的react component时,我们需要对它执行不同的操作。

在客户端渲染中,我们是使用ReactDOM.render() 将 react component 直接渲染成真实dom

// client/index.js
import { BrowserRouter } from 'react-router-dom';

ReactDom.render(<App />, document.getElementById('root'))

而在服务端渲染中,我们是使用renderToString api 将 react component 转换成 html 字符串,使用renderToStaticMarkup补充头部和引入js脚本,然后发送给客户端渲染成真实 dom

// server/utils.js
import { renderToString } from 'react-dom/server';

// ...
  const content = renderToString(
    <App />
  );
  // 补充头部和引入前端资源打包后的css、js资源
  const html = ReactDOMServer.renderToStaticMarkup(
    <html>
      <head>
        <link href={"assets/style.css" />
      </head>
      <body>
        <!--将服务端渲染返回网页内容填充进root节点中 -->
        <div id="root" dangerouslySetInnerHTML={
          {__html: content}
        } />
        <!--引入前端资源打包后的入口文件 -->
        <script src={"assets/index.js"}></script>
      </body>
    </html>
  );
  return html;

我们先来看看react component是怎么转换成html 字符串的?
react-dom(react-dom/server)包中有两个react api可以实现这个功能:renderToStringrenderToStaticMarkup。 这两个函数都接受一个 React Component 参数,返回一个 html String。但两者是有区别的:
renderToString 生成的HTML的DOM会带有额外属性:各个 DOM 都会有 data-react-id 属性,其值由单调递增的 ID 组成,text 节点也含有 react-text 和 ID,此外,第一个 DOM 还会有 data-checksum 属性,其值是通过 adler32 算法算出来:如果两个组件有相同的 props 和 DOM 结构时,adler32 算法算出的 checksum 值会一样,有点类似于哈希算法。 我们查看下代码看下效果:

renderToString(
  <div>
  	Thisissome<span>server-generated</span><span>HTML.</span>
  </div>
);

该片段生成的HTML如下:

<div data-react-root="" data-react-id="1"
  data-react-checksum="122239856">
  <!--react-text:2-->Thisissome<!--/react-text-->
  <span data-react-id="3">server-generated</span>
  <!--react-text:4--><!--/react-text-->
  <span data-react-id="5">HTML.</span>
</div>

在客户端进行渲染时,会检查 HTML DOM 是否存在相同的 data-react-checksum,如果一致,则客户端可以直接使用服务端生成的 DOM 树,不会重复渲染。如果不一致,则客户端会重新渲染整个HTML。这样就使得服务端渲染的视图和客户端渲染的视图保持一致。

renderToStaticMarkup 生成 HTML 的 DOM 不会有额外属性,从而节省 HTML 字符串的大小。在服务器端使用 renderToStaticMarkup 渲染的组件不会带有data-react-checksum属性,此时客户端会重新渲染组件,覆盖掉服务器端的组件。因此,当页面不是渲染一个静态的页面时,最好还是使用 renderToString 方法。

(这里我讲的是react 15中的ssr,而在react 16中所有的ID都从节点中移除了,不仅简洁便于阅读,而且显著地减小HTML文件体积。React 16中的客户端渲染使用差异算法检查服务端生成的节点的准确性也相比于React 15更宽松;例如,不要求服务端生成的节点属性与客户端顺序完全一致。当React 16的客户端渲染器检测到节点不匹配,仅仅是尝试修改不匹配的HTML子树,而不是修改整个HTML树。关于react15和react 16 ssr的差异可以看看这篇文章

3. 同构数据获取

在平常客户端的React开发中,我们一般在组件的 componentDidMount 生命周期函数进行异步数据的获取。然而服务器端始终不会执行 componentDidMount ,这样就导致服务端拿不到数据。现在我们的工作就是让服务端将获得数据的操作执行一遍,以达到真正的服务端渲染的效果。

在此之前,我们需要改造下路由,给它配置一个loadData参数,这个参数代表了服务端获取数据的函数。

每次服务端匹配路由渲染一个组件时,我们先判断这个路由对应的组件有无loadData方法,如果有的话,就会在服务端调用这个函数获取数据,然后将作为props,渲染路由组件。 但是这样会有一个问题,客户端和服务端是共用一套react代码的,在客户端渲染时路由组件却没有props可以取得数据,这样就会报错。

为了让服务器端和客户端的 props 一致,我们可以在服务端 loadData 获取数据之后,在返回的 html 代码中加入一个 script 标签,将一个服务端生成的首屏 props 赋给客户端的 window.APP_PROPS 对象上,在客户端初始化根组件时使用这个 APP_PROPS 传递这个根组件的 props。

// server/utils.js
const propsScript = 'window.APP_PROPS = ' + JSON.stringify(props);

const html = ReactDOMServer.renderToStaticMarkup(
        <html>
            <!--...-->
            <body>
         	<!--...-->
                <!--将服务端获取的数据注入到window上,客户端收到html源码,即可拿到window上的数据-->
                <script dangerouslySetInnerHTML={
                    {__html: propsScript}
                }></script>
            </body>
        </html>
    );
 
    return html;
 }

这叫做数据的“注水”操作,即把服务端的数据注入到window全局环境中,客户端收到html源码,有了数据,就可以把它作为客户端的根组件的props,这个就叫做“脱水”处理。

// client/index.js
// 获取服务端返回的接口数据
const APP_PROPS = window.APP_PROPS || {};

// 客户端初始化根组件时使用这个 APP_PROPS 传递这个根组件的 props
ReactDom.render(<App initialData={APP_PROPS.initialData}/>, document.getElementById('root'))

在上面,我们说到,服务器端渲染时,页面的数据是通过 loadData 函数来获取的。而在客户端,数据获取依然要做,因为如果这个页面是你访问的第一个页面,那么你看到的内容是服务器端渲染出来的,但是如果经过 react-router 路由跳转道第二个页面,那么这个页面就完全是客户端渲染出来的了,所以客户端也要去拿数据。

在客户端还是通过 componentDidMount 获取数据,因为 componentDidMount 只在客户端才会执行,在服务器端这个生命周期函数是不会执行的,不像 componentWillMount 在客户端和服务端都会执行。所以我们不必担心 componentDidMount 和 loadData 会有冲突,这也是为什么数据的获取应该放到 componentDidMount 这个生命周期函数中而不是 componentWillMount 中 的原因,可以避免服务器端获取数据和客户端获取数据的冲突。

4. 同构状态管理

同构状态管理的 store 与同构数据获取的 props 非常相似,需要把服务端预取数据以后的整棵状态树渲染到页面上然后前端初始化状态管理器 store 的时候使用这棵树来做为初始状态,以此来保证前端渲染的结果和后端一致。
但是在创建服务端的store时,要注意不能像下面创建客户端的store一样写

const store=createStore(reducer,defaultState);
export default store;

客户端渲染中,用户的浏览器中永远只存在一个 Store,所以代码上你可以这么写,然而在服务器端,这么写就有问题了,因为服务器端的 Store 是所有用户都要用的,如果像上面这样构建 Store,Store 变成了一个单例,所有用户共享 Store,显然就有问题了。所以在服务器端渲染中,Store 的创建应该像下面这样,返回一个函数,每个用户访问的时候,这个函数重新执行,为每个用户提供一个独立的 Store:

const getStore=(req)=>{
	return createStore(reducer,defaultState);
}
export default getStore;

回顾与总结

1. 什么是同构渲染?
同构渲染其实就是将同一套react代码在服务端执行一遍渲染静态页面,又在客户端执行一遍完成事件绑定。

2. 为什么react需要在服务端执行一遍,又在客户端执行一遍?/为什么服务端渲染完成的页面结构事件绑定无效?
原因很简单,服务端使用 renderToString 渲染页面,而 react-dom/server 下的 renderToString 并没有做事件相关的处理,因此返回给浏览器的内容不会有事件绑定,渲染出来的页面只是一个静态的 HTML 页面。只有在客户端渲染 React 组件并初始化 React 实例后,才能更新组件的 state 和 props,初始化 React 的事件系统,执行虚拟 DOM 的重新渲染机制,让 React 组件真正“ 动” 起来。

3. 服务器端已经渲染了一次 React 组件,如果在客户端中再渲染一次 React 组件,会不会渲染两次 React 组件?
答案是不会的。秘诀在于 data-react-checksum 属性,上文有说过,如果使用 renderToString 渲染组件,会在组件的第一个 DOM 带有 data-react-checksum 属性,这个属性是通过 adler32 算法算出来:如果两个组件有相同的 props 和 DOM 结构时,adler32 算法算出的 checksum 值会一样,有点类似于哈希算法。所以当客户端渲染 React 组件时,首先计算出组件的 checksum 值,然后检索 HTML DOM 看看是否存在数值相同的data-react-checksum属性,如果存在,则组件只会渲染一次,如果不存在,则会抛出一个 warning 异常。也就是说,当服务器端和客户端渲染具有相同的 props 和相同 DOM 结构的组件时,该 React 组件只会渲染一次。

4. 同构应用最重要的特点?
同构应用最重要的特点:只有首屏是服务端渲染(目的:加快首屏渲染,优化SEO),之后的页面跳转通过前端路由进行,遵循SPA(目的:支持局部刷新,前后端分离,减轻服务器压力。

5. 同构应用带来的问题?
上面只讲了同构渲染的优点,但其实同构渲染的引发的问题也不少,比如:

  • 不能在服务端渲染期间操作 DOM、BOM api,比如 document、window对象等,应尽量避免在首屏逻辑使用到它们
  • 增加了代码的复杂度,某些代码操作需要区分运行环境
  • 要注意不打包只在服务端运行的外部扩展库,否则会造成构建后的文件体积过大

6. 同构应用使用建议

  • 首屏渲染速度是否真的重要
  • SEO是否真的需要

参考资料

ReactDOMServer

在ReactJS中创建同构应用程序的痛苦和喜悦

从头开始,彻底理解服务端渲染原理