服务端渲染相关原理解析

1,037 阅读5分钟

服务端渲染

简介

  1. 服务端渲染是指在服务端完成页面的HTML拼接处理,然后再发送给浏览器,将不具有交互能力的html结构绑定事件和状态,在客户端展示未具有完整交互能力的应用程序。

优劣势

  1. 需更好的支持SEO

其优势在于同步,常规的前端界面是有异步请求数据后进行界面数据渲染逻辑,而爬虫搜索引擎是不会等待异步逻辑,并且只识别HTML结构的内容,对于像React和Vue这种依赖js进行渲染的页面是无法进行识别的,服务端渲染是将数据和页面HTML同步发送给前端,没有了异步请求数据的问题;

2.更快的界面相应速度

传统的SPA需要完整的JS下载后才可以执行,其界面的数据也是异步请求结束后才可以渲染完成,而SSR在服务器端完成拼接处理后就可以进行渲染交互,尤其在网络较慢和设备性能较差的场景下其优势更加明显;

  1. 劣势
  • 运行环境单一,程序只运行在node server环境下,部署构建需要特定的环境支持;
  • 不能进行过大的缓存和计算操作,不适应于高流量的场景。应用代码需要在双端运行解析,CPU性能消耗更大,负载均衡和多场景缓存处理比SPA更多消耗;
  • 与常规的前端庫有本质区别,比如在SSR中生命周期只有beforeCreate和created,在进行程序编写时需要同时支持客户端和服务端正常运行;
    CSR 与 SSR 流程

SSR.png

CSR.png

服务器端渲染同构

  1. 同构是指代码的复用,即实现客户端和服务器端最大程度的代码复用。

双端路由、双端Redux等需要同构,-- React服务器端渲染核心

  1. 同构原因
  • 性能:通过node直出,将传统的多次HTTP请求简化为一次HTTP请求,降低首屏的渲染时间;
  • SEO:服务端渲染对搜索引擎的爬取有着天然的优势;
  • 兼容性:部分展示类页面能够有效的规避客户端兼容性的问题,如白屏。
  • 一套代码多端使用,比如SSR中的同构是指服务端渲染完成页面结构,客户端渲染完成事件绑定;
    同构/非同构-网络 同构/非同构-网络 同构/非同构-渲染时间线 同构/非同构-渲染时间线
  1. 存在的问题

当页面中的节点数过多时,渲染时长也会变长,QPS(Queries-per-second)下降的也非常迅速了,尤其当页面节点超过3000左右的时候,QPS接近个位数了; QPS一般指每秒查询率。 每秒查询率(QPS,Queries-per-second)是对一个特定的查询服务器在规定时间内所处理流量多少的衡量标准

  1. 当超过3000节点的时候,需要制定对应的方案;
    • 模块拆分:模块拆分有利于并发渲染,组件的复用进行相关优化;
    • 模块级别缓存:比如常规页面中的头部和尾部是基本不动的,可以实现模块的缓存;
    • 组件级缓存:最小粒度的缓存单位,缓存方案得当,可以有非常大的性能提升;
    • 部分模块采用客户端渲染(对SEO无用的部分),直接降低SSR部分的复杂度;

整体思路

  1. 打包浏览器端代码、打包服务器端代码并启动服务、服务器端读取到浏览器端打包好的index.html文件为字符串,将渲染好的组件、数据和样式塞入到html字符串中,然后整体返回给浏览器、浏览器接收拼接好的字符串后加载打包好得到浏览器端js文件,进行事件绑定,初始化数状态据,完成同构;

基本步骤

  1. 引入需要渲染的React组件;
  2. 通过renderToString方法将React组件转换为HTML字符串;
  3. 将结果HTML字符串响应到客户端;

renderToString方法用于将React组件转换为HTML字符串,通过react-dom/server导入。但是renderToString方法并没有对js事件做相关的处理,因此返回到客户端的内容也就不会存在事件了。
import { renderToString } from 'react-dom/server';

SSR 添加事件

  1. 服务器端渲染不会添加事件到返回的HTML字符串中,需要通过客户端二次渲染进行事件初始化,这时需要依赖ReactDom的hydrate方法进行二次渲染,这个方法相比于render方法会进行DOM节点的复用对比,减少操作DOM节点的开销;

相比于普通的SSR,添加事件后的SSR只是在原有的基础上多了一个js文件,该文件就是用于客户端拉取js执行文件,从而添加对应的事件;

//client/index. js
//需要利用webpack打包成最终返回给客户端的js文件;
import React from 'react';
import ReactDom from 'react-dom';
import App from '../containers/App';
ReactDOM.hydrate(<App />,document.querySelector("#root"))
//webpack.client.js
const path = require('path');
const merge = require('webpack-merge');
const config = require('./webpack.base');

const clientConfig = {
  mode: 'development',
  entry: './src/client/index.js',
  output: {
    filename: 'index.js',
    path: path.resolve(__dirname, 'public')
  },
}

module.exports = merge(config, clientConfig);

//webpack.base.js
module.exports = {
  module: {
    rules: [{
      test: /\.js$/,
      loader: 'babel-loader',
      exclude: /node_modules/,
      options: {
        presets: ['@babel/preset-react',  ['@babel/preset-env', {
          targets: {
            browsers: ['last 2 versions']
          }
        }]]
      }
    }]
  }
}

//package.json的script部分
  "scripts": {
    "dev": "npm-run-all --parallel dev:*",
    "dev:start": "nodemon --watch build --exec node \"./build/bundle.js\"",
    "dev:build:server": "webpack --config webpack.server.js --watch",
    "dev:build:client": "webpack --config webpack.client.js --watch"
  },
  1. 服务器端实现静态资源访问;

服务器端程序实现静态资源访问的功能,客户端JavaScript打包文件会被作为静态资源使用;

const app = express();
app.use(express.static('public'))

路由支持

  1. 实现思路分析
    • 需要配置两端路由,客户端路由和服务器端路由
    • 客户端路由: 支持用户通过路由点击链接的方式跳页面,如react的单页应用就是;
    • 服务器端路由: 支持用户直接从地址栏中访问页面的; 注意:客户端和服务器端需要公用一套路由规则,路由规则属于同构代码
// Routes.js
import React from 'react';
import {Route} from 'react-router-dom'
import Home from './pages/Home';
import My from './pages/My'

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

// server/index.js
//服务端引用routers路由后,需要将路由配置逻辑执行一遍,返回给客户端;
import express from 'express';
import {render} from './utils';

const app = express();
app.use(express.static('public'));
//注意这里要换成*来匹配
app.get('*', function (req, res) {
   res.send(render(req));
});
 
app.listen(3000, () => {
  console.log('listen:3000')
});

// server/utils.js
import Routes from '../Routes'
import { renderToString } from 'react-dom/server';
//重要是要用到StaticRouter
import { StaticRouter } from 'react-router-dom'; 
import React from 'react'

export const render = (req) => {
  //构建服务端的路由
  const content = renderToString(
    <StaticRouter location={req.path} >
      {Routes}
    </StaticRouter>
  );
  return `
    <html>
      <head>
        <title>ssr</title>
      </head>
      <body>
        <div id="root">${content}</div>
        <script src="/index.js"></script>
      </body>
    </html>
  `
}

优化项

  1. 提取与合并webpack配置;

通过将服务端和客户端webpack的公共配置提取后,通过webpack-merge庫的mergeAPI进行配置合并

//webpack.server.js
const baseConfig = require('./webpack.base.js');//公共配置
const config = {}; //独立配置
module.exports = merge(baseConfig,config)
  1. 合并项目启动命令

使用一个命令启动项目,解决多个命令启动的繁琐问题,通过npm-run-all工具实现;

"dev": "npm-run-all --parallel dev:*" //--parallel同时去运行多个命令的意思
  1. 服务器端打包文件体积优化

在服务器端打包文件中,包含Node系统模块,导致了打包文件本身体积庞大;
解决方式就是通过webpack配置踢出掉打包文件中的Node模块;

//webpack.server.js
const nodeExternals = require('webpack-node-externals');
const config = {
    externals: [nodeExternals()]
}