React SSR 服务端渲染原理解析与同构实践

1,164 阅读10分钟

什么是 SSR

SSR 的全称是Server Side Rendering,对应的中文名称是:服务端渲染,也就是将渲染的工作放在服务端进行。

这种方式很早就存在,早在 Ajax出现之前全部都是这种方式, 由服务端返回给浏览器完整的 html 内容。浏览器得到完整的结构后就可直接进行 DOM 的解析、构建、加载资源及后续的渲染。

SSR 优缺点

这种页面(html)直出的方式可以让页面首屏较快的展现给用户,对搜索引擎比较友好,爬虫可以方便的找到页面的内容,非常有利于SEO。

不好的地方就是所有页面的加载都需要向服务器请求完整的页面内容和资源,访问量较大的时候会对服务器造成一定的压力,另外页面之间频繁刷新跳转的体验并不是很友好。

什么是 CSR

SSR 对应的就是 CSR,全称是 Client Side Rendering,也就是客户端渲染。

它是目前 Web 应用中主流的渲染模式,一般由 Server 端返回初始 HTML 内容,然后再由 JS 去异步加载数据,再完成页面的渲染。

客户端渲染模式中最流行的开发模式当属SPA(单页应用),所以后文都会基于SPA进行说明。

这种模式下服务端只会返回一个页面的框架和js 脚本资源,而不会返回具体的数据。

CSR(SPA) 优缺点

页面之间的跳转不会刷新整个页面,而是局部刷新,体验上有了很大的提升。

单页应用中,只有首次进入或者刷新的时候才会请求服务器,只需加载一次js css资源,页面的路由维护在客户端,页面间的跳转就是切换相关的组件所以切换速度很快,另外数据渲染都在客户端完成,服务器只需要提供一个返回数据的接口,大大降低了服务器的压力。

所以后来就有了 web app的叫法,也是为了突出这种体验很像是Native App 。

SPA这种客户端渲染的方式在整体体验上有了很大的提升,但是它仍然有缺陷 - 对 SEO 不友好,页面首次加载可能有较长的白屏时间。

SSR VS CSR(SPA)

橙色部分为页面背景色,对应了常规意义上的白屏时间 可以看到,从内容可见的时间上,SSR比CSR更快。

这是由于 SSR 的工作原理,决定了它的优势,这种差异在弱网环境下会体现的更加明显。

React SSR

确定问题:其实就两个问题

  • SEO不友好
  • 首次白屏等待。

SSR + SPA 完美的结合

只实现 SSR 其实没啥意义,技术上没有任何发展和进步,否则 SPA 技术就不会出现。

但是单纯的 SPA又不够完美,所以最好的方案就是这两种体验和技术的结合, 第一次访问页面是服务端渲染,基于第一次访问后续的交互就是 SPA 的效果和体验,还不影响SEO 效果,这就有点完美了。

单纯实现 ssr 很简单,毕竟这是传统技术,也不分语言,随便用 php 、jsp、asp、node 等都可以实现。

但是要实现两种技术的结合,同时可以最大限度的重用代码(同构),减少开发维护成本,那就需要采用 react 或者 vue 等前端框架相结合 node (ssr) 来实现。

本文主要说 React SSR 技术 ,当然 vue 也一样,只是技术栈不同而已。

核心原理

整体来说 react 服务端渲染原理不复杂,其中最核心的内容就是同构。(所谓同构,就是指前后端公用一套代码,比如我们的组件可以在服务端渲染也可以在客户端渲染,但都是同一个组件。这样的方式应该是可以甩传统方式好几条街了把。

当然打造同构应用还有另外一个得天独厚的条件,双端使用同一种语言 - javascript。)

node server 接收客户端请求,得到当前的req url path,然后在已有的路由表内查找到对应的组件,拿到需要请求的数据,将数据作为 props 、context或者store 形式传入组件,然后基于 react 内置的服务端渲染api renderToString() or renderToNodeStream() 把组件渲染为 html字符串或者 stream 流, 在把最终的 html 进行输出前需要将数据注入到浏览器端(注水),server 输出(response)后浏览器端可以得到数据(脱水),浏览器开始进行渲染和节点对比,然后执行组件的componentDidMount 完成组件内事件绑定和一些交互,浏览器重用了服务端输出的 html 节点,整个流程结束。

技术点确实不少,但更多的是架构和工程层面的,需要把各个知识点进行链接和整合。

这里放一个架构图

双端对比机制

import ReactDOMServer from 'react-dom/server'

ReactDOMServer 类可以帮我们在服务端渲染组件 - 得到组件的 html 字符串。

ReactDOMServer.renderToString(element)

把一个React组件渲染为原始的HTML。 我们可以用这个方法在服务端生成HTML字符串,然后将该字符串返回给浏览器端,完成页面内容的初始化,同时让搜索引擎可以抓取你的页面来达到优化SEO的目的。

组件在服务端渲染后,在浏览器端还会渲染一次,来完成组件的交互等逻辑。渲染时,react在浏览器端会计算出组件的data-react-checksum属性值,如果发现和服务端计算的值一致,则不会进行客户端渲染,只有对比失败的时候才会采用客户端的内容进行渲染,且react会尽量多的复用已有的节点。(data-react-checksum属性的作用是为了完成组件的双端对比)。

如果两个组件的props和DOM结构是相同的,那么计算出的该属性值就是一致的。

也可以换个角度来理解,当双端渲染的组件的props和DOM结构一致时,那么该组件只会渲染一次,客户端会采用服务端渲染的结果,仅作事件绑定等处理,这会让我们的应用有一个非常高效的初次加载体验。

一、路由同构

我们使用的是React,那前端路由肯定会使用react-router来处理

import { StaticRouter} from 'react-router';

该组件主要用于服务端渲染,可以帮助我们完成路由查找功能,无需再做手动匹配。

基本的思路是,将替换为无状态的。

将服务器上接收到的path传递给此组件用来匹配,同时支持传入context特性,此组件会自动匹配到目标组件进行渲染。

context属性是一个普通的JavaScript对象。

在组件渲染时,可向该对象添加属性以存储有关渲染的信息,比如302 404等结果状态,然后服务端可以针对不同的状态进行具体的响应处理。

二、基于路由的按需渲染

  • next.js

自动根据页面进行代码分割,无需配置。

  • egg-react-ssr 实现方式

Egg + React + SSR 服务端渲染

  {
      path: '/news/:id',
      exact: true,
      Component: () => (__isBrowser__ ? require('react-loadable')({
        loader: () => import(/* webpackChunkName: "news" */ '@/page/news'),
        loading: function Loading () {
          return React.createElement('div')
        }
      }) : require('@/page/news').default // 通过这种方式来让服务端bundle不要分块打包
      ),
      controller: 'page',
      handler: 'index'
    }

使用react-loadable库实现,没有将服务端bundle打包成多个文件,依然保持一个文件,因为服务端直接处理的是静态路由。

这样配置有个坑,导致Loadable没办法预先知道你有哪些组件被包裹了,所以没办法直接调用Loadable.preloadReady()来预加载。

用preloadComponen方法来手动调用组件的preload方法

源码

import { pathToRegexp } from 'path-to-regexp'
import cloneDeepWith from 'lodash.clonedeepwith'
import { RouteItem } from './interface/route'

const preloadComponent = async (Routes: RouteItem[]) => {
  const _Routes = cloneDeepWith(Routes)
  for (let i in _Routes) {
    const { Component, path } = _Routes[i]
    let activeComponent = Component()
    if (activeComponent.preload && pathToRegexp(path).test(location.pathname)) {
      // 只有在你访问的path和组件为同一个path才拿到真实的组件,其他情况还是返回Loadable Compoennt来让首屏不要去加载这些组件
      activeComponent = (await activeComponent.preload()).default
    }
    _Routes[i].Component = () => activeComponent
  }
  return _Routes
}

export {
    preloadComponent
}

然后在客户端渲染的时候调用一下该方法

const clientRender = async () => {
  const clientRoutes = await preloadComponent(Routes)
  // 客户端渲染||hydrate
  ReactDOM[window.__USE_SSR__ ? 'hydrate' : 'render'](
    <BrowserRouter>
      {
        // 使用高阶组件getWrappedComponent使得csr首次进入页面以及csr/ssr切换路由时调用getInitialProps
        clientRoutes.map(({ path, exact, Component }) => {
          const activeComponent = Component()
          const WrappedComponent = getWrappedComponent(activeComponent)
          const Layout = WrappedComponent.Layout || defaultLayout
          return <Route exact={exact} key={path} path={path} render={() => <Layout><WrappedComponent /></Layout>} />
        })
      }
    </BrowserRouter>
    , document.getElementById('app'))

  if (process.env.NODE_ENV === 'development' && module.hot) {
    module.hot.accept()
  }
}

我们 前端 react-loadable

服务端

import React from 'react';
//使用静态 static router
import { StaticRouter } from 'react-router-dom';
import ReactDOMServer from 'react-dom/server';
import Loadable from 'react-loadable';
//下面这个是需要让react-loadable在服务端可运行需要的
import { getBundles } from 'react-loadable/webpack';
//webpack出的json 文件
import stats from '../build/react-loadable.json';

//这里吧react-router的路由设置抽出去,使得在浏览器跟服务端可以共用

import AppRoutes from 'src/AppRoutes';

//这里我们创建一个简单的class,暴露一些方法出去,然后在服务端调用来实现服务端渲染
class SSR {
 
 render(url, data) {
  let modules = [];
  const context = {};
  const html = ReactDOMServer.renderToString(
   <Loadable.Capture report={moduleName => modules.push(moduleName)}>
    <StaticRouter location={url} context={context}>
     <AppRoutes initialData={data} />
    </StaticRouter>
   </Loadable.Capture>
  );
  //获取服务端已经渲染好的组件数组
  let bundles = getBundles(stats, modules);
  return {
   html,
   scripts: this.generateBundleScripts(bundles),
  };
 }
 //把SSR过的组件都转成script标签扔到html里
 generateBundleScripts(bundles) {
  return bundles.filter(bundle => bundle.file.endsWith('.js')).map(bundle => {
   return `<script type="text/javascript" src="${bundle.file}"></script>\n`;
  });
 }

 static preloadAll() {
  return Loadable.preloadAll();
 }
}

export default SSR;

当编译这个文件的时候,在webpack配置里使用target: "node" 和 externals,并且在你的打包前端app的webpack配置中,需要加入react-loadable的插件


const ReactLoadablePlugin = require('react-loadable/webpack')
 .ReactLoadablePlugin;

module.exports = {
 //...
 plugins: [
  //...
  new ReactLoadablePlugin({ filename: './build/react-loadable.json', }),
 ]
}


在.babelrc中加入loadable plugin:

{
 "plugins": [
   "syntax-dynamic-import",
   "react-loadable/babel",
   ["import-inspector", {
    "serverSideRequirePath": true
   }]
  ]
}

配置publicPath: /${settings.projectName}/${settings.staticVersion}/,

path”仅仅告诉Webpack结果存储在哪里, 然而“publicPath”项则被许多Webpack的插件用于在生产模式下更新内嵌到css、html文件里的url值。

上面的配置会让react-loadable知道哪些组件最终在服务端被渲染了,然后直接插入到html script标签中,并在前端初始化时把SSR过的组件考虑在内,避免重复加载

ssr打包

//webpack.ssr.js
const nodeExternals = require('webpack-node-externals');

module.exports = {
 //...
 target: 'node',
 output: {
  path: path.resolve(settings.ssrOutDir),
  filename: `[name].js`,
  chunkFilename: `[name][chunkhash].js`,
  libraryExport: 'default',
  libraryTarget: 'commonjs2',
 },
 //避免把node_modules里的库都打包进去,此ssr js会直接运行在node端,
 //所以不需要打包进最终的文件中,运行时会自动从node_modules里加载
 externals: [nodeExternals()],
 //...
}


踩坑记:!!开始ssr配置如下,服务端一直报错,找不到js,但已经成功打包,后来改为path路径增加js/

 filename: `js/[name].js`,
 chunkFilename: `js/[name][chunkhash].js`,

三、数据处理

1、数据预取

了解下业内框架 next.js和egg-react-ssr的实现。

当然还有umi,不过umi ssr代码核心部分也是egg-react-ssr团队贡献的代码

  • next.js 数据预取代码
import React from 'react'

export default class extends React.Component {
  static async getInitialProps({ req }) {
    const userAgent = req ? req.headers['user-agent'] : navigator.userAgent
    return { userAgent }
  }

  render() {
    return (
      <div>
        Hello World {this.props.userAgent}
      </div>
    )
  }
}

当页面渲染时加载数据,使用了一个异步方法getInitialProps。它能异步获取数据,并绑定在props上。当服务渲染时,getInitialProps将会把数据序列化,就像JSON.stringify。

当第一次进入页面时,getInitialProps只会在服务端执行。只有当路由跳转(Link组件跳转或 API 方法跳转)时,客户端才会执行getInitialProps。

另外此方法只能用于页面组件内,不能在子组件内使用。

  • egg-react-ssr 数据预取代码
import React from 'react'
import { Link } from 'react-router-dom'
import './index.less'

function Page (props) {
  return (
    <div className='normal'>
      <div className='welcome' />
      <ul className='list'>
        {
          props.news && props.news.map(item => (
            <li key={item.id}>
              <div>文章标题: {item.title}</div>
              <div className='toDetail'><Link to={`/news/${item.id}`}>点击查看详情</Link></div>
            </li>
          ))
        }
      </ul>
    </div>
  )
}

Page.getInitialProps = async (ctx) => {
  // ssr渲染模式只在服务端通过Node获取数据,csr渲染模式只在客户端通过http请求获取数据,getInitialProps方法在整个页面生命周期只会执行一次
  return __isBrowser__ ? (await window.fetch('/api/getIndexData')).json() : ctx.service.api.index()
}

export default Page

页面初始化时,服务端根据当前请求的path,来确定我们要渲染哪一个组件,getComponent可以理解为一个根据path从路由表中找到匹配的组件的方法,检测该组件上有没有getInitialProps静态方法,这里之所以要用静态方法,是为了不需要实例化就可以拿到方法。

如果有的话,将调用这个方法,将数据作为组件的props传入,使得组件可以通过props.xxx的方式来读取到服务端获取的数据。

组件添加getInitialProps静态方法,服务端根据当前请求的path,调用matchRoute方法查找到对应的路由,得到具体的组件,判断组件上是否有getInitialProps此方法,然后进行数据预取。

最后把数据作为组件的props,在组件内可以通过props.initialData固定属性来获取。

整体来看,本应用的实现方式和egg-react-ssr,next.js非常相似,可能这也是业内一种默认的通用做法吧。

2、 数据脱水

从运行时的页面看下服务端直出数据的方式。

  • next.js

数据直出到页面后,通过script标签来进行包裹,且type="application/json",标签内直接是 json数据。

  • egg-react-ssr

也是作为脚本加载,然后将数据保存在了window.__INITIAL_DATA__全局变量内。