面试官:说说React-SSR的原理

前言

所谓同构,简而言之就是,第一次访问后台服务时,后台直接把前端要显示的界面全部返回,而不是像 SPA 项目只渲染一个

剩下的都是靠 JavaScript 脚本去加载。这样一来可以大大减少首屏等待时间。

同构概念并不复杂,它也非项目必需品,但是探索它的原理却是必须的。

阅读本文需要你具备以下技术基础: Node.jsReactReact RouterReduxwebpack

本文将分以下两部分去讲述:

  1. 同构思路分析,让你对同构有一个概念上的了解;
  2. 手写同构框架,深入理解同构原理。

同构思路

CSR 客户端渲染

CSR 客户端渲染,这个就是很好理解了,使用 ReactReact Router 前端自己控制路由的 SPA 项目,就可以理解成客户端渲染。它有一个非常大的优势就是,只是首次访问会请求后台服务加载相应文件,之后的访问都是前端自己判断 URL 展示相关组件,因此除了首次访问速度慢些之外,之后的访问速度都很快。

执行命令: create-react-app react-csr 创建一个 React SPA 单页面应用项目 。
执行命令: npm run start 启动项目。

查看网页源代码: 面试官:说说React-SSR的原理_第1张图片 只有一个

和 一些 script 脚本。最终呈现出来的界面却是这样的: 面试官:说说React-SSR的原理_第2张图片 原理很简单,相信学习过 webpack 的同学都知道,那就是 webpack 把所有代码都打包成相应脚本并插入到 HTML 界面中,浏览器会解析 script 脚本,通过动态插入 DOM 的方式展示出相应界面。

客户端渲染的优劣势

客户端渲染流程如下: image.png

优势:

  • 前端负责渲染页面,后端负责实现接口,各自干好各自的事情,对开发效率有极大的提升;
  • 前端在跳转界面的时候不需要请求后台,加速了界面跳转的速度,提高用户体验。

劣势:

  • 由于需要等待 JS 文件加载以及后台接口数据请求因此首屏加载时间长,用户体验较差;
  • 由于大部分内容都是通过 JS 加载因此搜索引擎无法爬取分析网页内容导致网站无法 SEO

SSR 服务端渲染

SSR 是服务端渲染技术,它本身是一项比较普通的技术, Node.js 使用 ejs 模板引擎输出一个界面这就是服务端渲染。每次访问一个路由都是请求后台服务,重新加载文件渲染界面。

同样我们也来创建一个简单的 Node.js 服务:

mkdir express-ssr
cd express-ssr
npm init -y
touch app.js
npm i express --save

app.js

const express = require('express')
const app = express()

app.get('/',function (req,res) {
  res.send(
    `                    express ssr                            

Hello SSR

` ) }) app.listen(3000);

启动服务: node app.js

面试官:说说React-SSR的原理_第3张图片

这就是最简单的服务端渲染一个界面了。服务端渲染的本质就是页面显示的内容是服务器端生产出来的。

服务端渲染的优劣势

服务端渲染流程:

image.png

优势:

  • 整个 HTML 都通过服务端直接输出 SEO 友好;
  • 加载首页不需要加载整个应用的 JS 文件,首页加载速度快。

劣势:

  • 访问一个应用程序的每个界面都需要访问服务器,体验对比 CSR 稍差。

我们会发现一件很有意思的事,服务端渲染的优点就是客户端渲染的缺点,服务端渲染的缺点就是客户端渲染的优点,反之亦然。那为何不将传统的纯服务端直出的首屏优势和客户端渲染站内跳转优势结合,以取得最优解?这就引出了当前流行的服务端渲染( Server Side Rendering ),或者称之为“同构渲染”更为准确。参考:前端react面试题详细解答

同构渲染

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

同构渲染流程

面试官:说说React-SSR的原理_第4张图片

简单同构案例

要实现同构,简单来说就是以下两步:

  1. 服务端要能运行 React 代码;
  2. 浏览器同样运行 React 代码。

1、创建项目

mkdir react-ssr
cd react-ssr
npm init -y

2、项目目录结构分析

├── src
│   ├── client
│   │   ├── index.js // 客户端业务入口文件
│   ├── server
│   │   └── index.js // 服务端业务入口文件
│   ├── container    // React 组件
│   │   └── Home
│   │       └── Home.js
│   │
├── config // 配置文件夹
│   ├── webpack.client.js // 客户端配置文件
│   ├── webpack.server.js // 服务端配置文件
│   ├── webpack.common.js // 共有配置文件
├── .babelrc // babel 配置文件
├── package.json

首先我们编写一个简单的 React 组件, container/Home/Home.js

import React from "react";

const Home = ()=>{
  return (
    
hello world
) } export default Home;

安装客户端渲染的惯例,我们写一个客户端渲染的入口文件, client/index.js

import React from "react";
import ReactDom from "react-dom";
import Home from "../containers/Home";

ReactDom.hydrate(,document.getElementById("root"));
// ReactDom.render(,document.getElementById("root"));

以前看到的都是调用 render 方法,这里使用 hydrate 方法,它的作用是什么?

ReactDOM.hydrate
render() 相同,但它用于在 ReactDOMServer 渲染的容器中对 HTML 的内容进行 hydrate 操作。 React 会尝试在已有标记上绑定事件监听器。

我们都知道纯粹的 React 代码放在浏览器上是无法执行的,因此需要打包工具进行处理,这里我们使用 webpack ,下面我们来看看 webpack 客户端的配置:

webpack.common.js

module.exports = {
  module:{
    rules:[
      {
        test: /\.js$/,
        exclude: /node_modules/,
        loader: "babel-loader",
      }
    ]
  }
}

.babelrc

{
  "presets":[
    ["@babel/preset-env"],
    ["@babel/preset-react"]
  ]
}

webpack.client.js

const path = require("path");
const {merge} = require("webpack-merge");
const commonConfig = require("./webpack.common");

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

module.exports = merge(commonConfig,clientConfig);

代码解析:通过 entry 配置的入口文件,对 React 代码进行打包,最后输出到 public 目录下的 index.js

在以往,直接在 HTML 引入这个打包后的 JS 文件,界面就显示出来了,我们称之为纯客户端渲染。这里我们就不这样使用,因为我们还需要服务端渲染。

接下来,看看服务端渲染文件 server/index.js

import express from "express";
import { renderToString } from "react-dom/server";
import React from "react";
import Home from "../containers/Home";

const app = express(); // {1}
app.use(express.static('public')) // {2}
const content = renderToString(); //{3}

app.get('/',function (req,res) {
  // {4}
  res.send(`                                                      React SSR                    
${content}
`) }) app.listen(3000);

代码解析:

  • {1},创建一个 express 实例对象
  • {2},开启一个静态资源服务,监听 public 目录,还记得客户端的打包文件就放到了 public 目录了把,这里通过监听,我们就可以这样 localhost:3000/index.js 访问该静态资源
  • {3},把 React 组件通过 renderToString 方法生成 HTML
  • {4},当用户访问 localhost:3000 时便会返回 res.send 中的 HTML 内容,该 HTML 中把 React 生成的 HTML 片段也插入进去一同返回给用户了,这样就实现了服务端渲染。通过 这段脚本加载了客户端打包后的 React 代码,这样就实现了客户端渲染,因此一个简单同构项目就这样实现了。

你会发现一个奇怪的现象,为什么写 Node.js 代码使用的却是 ESModule 语法,是的没错,因为我们要在服务端解析 React 代码,作为同构项目,因此统一语法也是非常必要的。所以 Node.js 也需要配置相应的 webpack 编译文件:

webpack.server.js

const path = require("path");
const nodeExternals = require("webpack-node-externals");
const {merge} = require("webpack-merge");
const commonConfig = require("./webpack.common");

const serverConfig = {
  target:"node", //为了不把nodejs内置模块打包进输出文件中,例如: fs net模块等;
  mode: "development",
  entry:"./src/server/index.js",
  output:{
    filename:"bundle.js",
    path:path.resolve(__dirname,"../build")
  },
  externals:[nodeExternals()], //为了不把node_modules目录下的第三方模块打包进输出文件中,因为nodejs默认会去node_modules目录下去寻找和使用第三方模块。
};

module.exports = merge(serverConfig,commonConfig);

到此我们就完成了一个简单的同构项目,这里您应该会有几个疑问?

  1. renderToString 有什么作用?
  2. 为什么服务端加载了一次,客户端还需要再次加载呢?
  3. 服务端加载了 React 输出的代码片段,客户端又执行了一次,这样是不是会加载两次导致资源浪费呢?
ReactDOMServer.renderToString(element)

React 元素渲染为初始 HTMLReact 将返回一个 HTML 字符串。你可以使用此方法在服务端生成 HTML ,并在首次请求时将标记下发,以加快页面加载速度,并允许搜索引擎爬取你的页面以达到 SEO 优化的目的。

为什么服务端加载了一次,客户端还需要再次加载呢?

原因很简单,服务端使用 renderToString 渲染页面,而 react-dom/server 下的 renderToString 并没有做事件相关的处理,因此返回给浏览器的内容不会有事件绑定,渲染出来的页面只是一个静态的 HTML 页面。只有在客户端渲染 React 组件并初始化 React 实例后,才能更新组件的 stateprops ,初始化 React 的事件系统,让 React 组件真正“ 动” 起来。

是否加载两次?

如果你在已有服务端渲染标记的节点上调用 ReactDOM.hydrate() 方法, React 将会保留该节点且只进行事件处理绑定,从而让你有一个非常高性能的首次加载体验。因此不必担心加载多次的问题。

是否意犹未尽?那就让我们更加深入的学习它,手写一个同构框架,彻底理解同构渲染的原理。

手写同构框架

实现一个同构框架,我们还有很多问题需要解决:

  1. 兼容路由;
  2. 兼容 Redux
  3. 兼容异步数据请求;
  4. 兼容 CSS 样式渲染。

问题很多,我们逐个击破。

兼容路由

同构项目中当在浏览器中输入 URL 后,浏览器是如何找到对应的界面?

  1. 浏览器收到 URL 地址例如: http://localhost:3000/login
  2. 后台路由找到对应的 React 组件传入到 renderToString 中,然后拼接 HTML 输出页面;
  3. 浏览器加载打包后的 JS 文件,并解析执行前端路由,输出相应的前端组件,发现是服务端渲染,因此只做事件绑定处理,不进行重复渲染,此时前端路由路由开始接管界面,之后跳转界面与后台无关。

既然需要路由我们就先安装下: npm install react-router-dom

之前我们只定义了一个 Home 组件,为了演示路由,我们再定义一个 Login 组件:

...
import { Link } from "react-router-dom";

const Login = ()=>{
  return (
    

登录页


跳转到首页
) }

改造 Home 组件

const Home = ()=>{
  return (
    

首页


跳转到登录页
) }

现在我们有两个组件了,可以开始定义相关路由:

src/Routes.js

...
import {Route} from "react-router-dom";

export default (
  
// 访问根路径时展示Home组件 // 访问/login路径时展示Login组件
)

改造客户端路由:src/client/index.js

...
import { BrowserRouter } from "react-router-dom";
import Routes from "../Routes";

const App = ()=>{
  return (
    
      {Routes}    
  )
}

ReactDom.hydrate(,document.getElementById("root"));

与普通 SPA 项目没有任何区别。

改造服务端路由:src/server/index.js

...
import { StaticRouter } from "react-router-dom";
import Routes from "../Routes";

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

const render = (req)=>{
  const content = renderToString((
    
      {Routes}    
  ));
  return `                                                      React SSR                    
${content}
` } app.get('*',function (req,res) { res.send(render(req)) })

服务端跟之前的区别就是这段代码:


  {Routes}

为什么不是 BrowserRouter 而是 StaticRouter 呢?

主要是因为 BrowserRouter 使用的是 History API 记录位置,而 History API 是属于浏览器的 API ,在 SSR 的环境下,服务端不能使用浏览器 API

StaticRouter

静态路由,通过初始传入的 location 地址找到相应组件。区别于客户端的动态路由。

兼容 Redux

Redux 一直以来都是 React 技术栈里最难理解的部分,它的概念繁多,如果想要彻底理解本小节及以后的内容,需要您对 Redux 有一定的了解

安装包:

npm i redux react-redux redux-thunk --save
  • redux 库;
  • react-reduxreactredux 的桥梁;
  • redux-thunkredux 中间件, redux 处理异步请求方案。

src/store/index.js

import {createStore, applyMiddleware} from "redux";
import thunk from "redux-thunk";

const reducer = (state={name:"Lion"},action)=>{
  return state;
}

const getStore = ()=>{
  return createStore(reducer,applyMiddleware(thunk));
}

export default getStore;

输出一个方法 getStore 用于创建全局 store 对象。

改造 server 端, src/server/render.js

... 省略
import { Provider } from "react-redux";
import getStore from "../store";

export const render = (req)=>{

  const content = renderToString((
    
      
        {Routes}      
    
  ));
  return `    ... 省略  `
}

通过 Provider 组件把 store 对象数据共享给所有子组件,它的本质还是通过 context 共享数据。

改造 client 端, src/client/index.js

...
import { Provider } from "react-redux";
import getStore from "../store";

const App = ()=>{
  return (
    
      
        {Routes}      
    
  )
}

ReactDom.hydrate(,document.getElementById("root"));

server 端改造非常类似。

redux 都添加完毕后,最后我们在组件中使用 redux 的方式获取数据,改造 Home 组件:

import React from "react";
import { Link } from "react-router-dom";
import { connect } from "react-redux";

const Home = (props)=>{
  return (
    

首页

{props.name}

跳转到登录页
) } const mapStateToProps = (state)=>({ name:state.name }) export default connect(mapStateToProps,null)(Home);

其实核心就是这几行代码:

const mapStateToProps = (state)=>({
  name:state.name
})

export default connect(mapStateToProps,null)(Home);

connect 接收 mapStateToPropsmapDispatchToProps 两个方法,返回一个高阶函数,这个高阶函数接收一个组件,返回一个新组件,其实就是给传入的组件增加一些属性和功能。

这样一来我们的 Home 组件就可以使用 name 属性了。改造完毕

可以正常使用,这样我们就轻松的集成了 redux

兼容异步数据请求

在构建企业级项目时, redux 使用就更为复杂,而且实战中我们一般都需要请求后台数据,让我们来改造改造项目,使他成为企业级项目。

redux 改造

一般我们会把 redux 相关的代码都放入 store 文件夹下,我们来看看它的新目录:

├── src
│   ├── store
│   │   ├── actions.js
│   │   ├── constans.js
│   │   └── reducer.js
└───────└── index.js
  • actions 负责生成 action
  • constans 定义常量;
  • reducer 定义 reducer
  • index 输出 store

actions.js

import axios from 'axios';
import {CHANGE_USER_LIST} from "./constants";

const changeUserList = (list)=>{
  return {
    type:CHANGE_USER_LIST,
    list
  }
}

export const getUserList = (dispatch)=>{
  return ()=>{
    axios.get('https://reqres.in/api/users').then((res)=>{
      dispatch(changeUserList(res.data.data));
    });
  }
}

导出 getUserList 方法,它的主要职责是向后台发送真实数据请求。

[注意] 这里发送的请求是真实的

constants.js

export const CHANGE_USER_LIST = 'HOME/CHANGE_USER_LIST';

输出常量,定义常量可以保证您在调用时不容易出错。

reducer.js

import { CHANGE_USER_LIST } from "./constants";
// {1}
const defaultState = {
  userList:[]
};

export default (state = defaultState , action)=>{
  switch (action.type) {
    // {2}
    case CHANGE_USER_LIST:
      return {
        ...state,
        userList:action.list
      }
    default:
      return state;
  }
}

代码解析:

  • {1},定义默认 stateuserList 为空数组;
  • {2},当接收到 typeCHANGE_USER_LISTdispatch 时,更新用户列表,这也是我们在 actions 那里接收到后台请求数据之后发送的 dispatchdispatch(changeUserList(res.data.data));

redux 改造的差不多了,接下来改造 Home 组件:src/containers/Home/index.js

import React,{useEffect} from "react";
import { Link } from "react-router-dom";
import { connect } from "react-redux";
import { getUserList } from "../../store/actions";

const Home = ({getUserList,name,userList})=>{
  // {2}
  useEffect(()=>{
    getUserList();
  },[])

  return (
    

首页

    { {/* 3 */} userList.map(user=>{ const { first_name, last_name, email, avatar, id } = user; return
  • 用户头像
    姓名:{`${first_name}${last_name}`}
    email:{email}
  • }) }

跳转到登录页
) } const mapStateToProps = (state)=>({ name:state.name, userList:state.userList }); // {1} const mapDispatchToProps = (dispatch)=>({ getUserList(){ dispatch(getUserList(dispatch)) } }) export default connect(mapStateToProps,mapDispatchToProps)(Home);

代码解析:

  • {1}, mapDispatchToPropsmapStateToProps 作用一致都是 connect 的入参,把相关的 dispatchstate 传入 Home 组件中。
  • {2}, useEffect Hook 中调用 getUserList 方法,获取后台真实数据
  • {3},根据真实返回的 userList 渲染组件

我们来看看实际效果:

面试官:说说React-SSR的原理_第5张图片

看起来很不错, react-routerredux 都已经支持了,但是当你查看下网页源码时会发现一个问题:

面试官:说说React-SSR的原理_第6张图片

用户列表数据并不是服务端渲染的,而是通过客户端渲染的。为什么会这样呢?我们一起分析下请求过程你就会明白:

面试官:说说React-SSR的原理_第7张图片

接下来我们主要的目标就是服务端如何可获取到数据?既然 useEffect 不会在服务端执行,那么我们就自己创建一个 “Hook”

Next.jsgetInitialProps 就是这个被创建的 “Hook” ,它的主要职责就是使服务端渲染可以获取初始化数据。

getInitialProps 实现

Home 组件中我们先添加这个静态方法:

Home.getInitialData = (store)=>{
  return store.dispatch(getUserList());
}

getInitialData 中做的事情同 useEffect 相同,都是去发送后台请求获取数据。

在 React Router 文档中关于服务端渲染想要先获取到数据需要把路由改为静态路由配置。

src/Routes.js

import { Home, Login } from "./containers";
export default [
  {
    key:"home",
    path: "/",
    exact: true,
    component: Home,
  },
  {
    key:"login",
    path: "/login",
    exact: true,
    component: Login,
  }
];

现在剩下最主要的工作就是服务端渲染网页之前拿到后台数据了。

react-router-config 这个包是 React Router 提供给我们用于分析静态路由配置的包。我们先安装它 npm install react-router-config --save

src/server/render.js

... 省略
import {matchRoutes, renderRoutes} from "react-router-config";
import Routes from "../Routes";

export const render = (req,res)=>{

  const store = getStore();
  // {1}
  const promises = matchRoutes(Routes, req.path).map(({ route }) => {
    const component = route.component;
    return component.getInitialData ? component.getInitialData(store) : null;
  });
  // {2}
  Promise.all(promises).then(()=>{
    const content = renderToString((
      
                // {3}        {renderRoutes(Routes)}
      
    ));
    res.send( `      ...    `)
  })
}

代码解析:

  • {1}, matchRoutes 获取当前访问路由所匹配到的组件,匹配到的组件如果有 getInitialData 方法就直接调用;
  • {2}, component.getInitialData(store) 返回都是 Promise , 等待全部 Promise 执行完成后, store 中的 state 就有数据了,此时服务端就已经获取到相应组件的后台数据;
  • {3},renderRoutes 它的作用是根据静态路由配置渲染出 组件,类似下面代码,不过 renderRoutes 边界处理的更加完善。
{routes.map(route => (
  
))}

细心的你肯定会发现,明明服务器已经拿到数据了为什么刷新浏览器会一闪一闪呢,原因在于,客户端渲染接管时,初始化的用户列表依然是个空数组,通过发送后台请求获取到数据这个异步过程,导致的页面一闪一闪的。它的解决方案有一个术语叫做数据的脱水与注水。

数据脱水与注水

其实非常简单,在渲染服务端时,已经拿到了后台请求数据,因此我们可以做:

  res.send( `                            ...                  
${content}
`)

通过 INITIAL_STATE 全局变量把后台请求到的数据存起来。客户端创建 store 时,当做初始化的 state 使用即可:

src/store/index.js

export const getClientStore = ()=>{
  const defaultState = window.INITIAL_STATE;
  return createStore(reducer,defaultState,applyMiddleware(thunk));
}

这样创建出来的 store 初始化的 state 中就已经有了用户列表。界面就不再会出现一闪一闪的效果了。

到这里为止,一个简易的同构框架已经有了。

兼容 CSS 样式渲染

Home 组件中添加一个样式文件: styles.module.css ,随便写点样式

.box{
    background: red;
    margin-top: 100px;
}

Home 组件中引入样式:

import styles from "./styles.module.css";

...

直接编译肯定报错,我们需要在 webpack 中添加相应的 loader

webpack.client.js

module:{
    rules:[
      {
        test:/\.css$/i, // 正则匹配到.css样式文件
        use:[
          'style-loader', // 把得到的CSS内容插入到HTML中
          {
            loader: 'css-loader',
            options: {
              modules: true // 开启 css modules
            }
          }
        ]
      }
    ]
  }

webpack.server.js

  module:{
    rules:[
      {
        test:/\.css$/i,
        use:[
          'isomorphic-style-loader', 
          {
            loader: 'css-loader',
            options: {
              modules: true
            }
          },
        ]
      }
    ]
  }

细心的你肯定会发现, server 端的配置使用了 isomorphic-style-loaderclient 端使用了 style-loader ,它们的区别是什么?

isomorphic-style-loader vs style-loader

style-loader 它的作用是把生成出来的 css 样式动态插入到 HTML 中,然而在服务端渲染是没有办法使用 DOM 的,因此服务端渲染不能使用它。

isomorphic-style-loader 主要是导出了3个函数, _getCss_insertCss_getContent ,供使用者调用,而不再是简单粗暴的插入 DOM 中。

server 端支持样式

src/server/render.js

export const render = (req,res)=>{
  const context = {
    css: []
  };
  Promise.all(promises).then(()=>{
    const content = renderToString((
      
        {renderRoutes(Routes)}
      
    ));

  const css = context.css.length ? context.css.join('\n') : '';

  res.send( `                                            ...                            ...          `)
}

StaticRouter 支持传入一个 context 属性,这样被访问的组件则可以共享该属性。在被访问组件的生命周期中通过调用 _getCss() 方法向 staticContext 中推入样式。最后在服务端拼接出所有样式插入到 HTML 中。

Home 组件(改造成 class 组件)

  componentWillMount() {
    if(this.props.staticContext){
      this.props.staticContext.css.push(styles._getCss());
    }
  }

componentWillMount 生命周期(服务端渲染会调用该生命周期),向 staticContext 中推入组件使用的样式。最后在服务端拼接成完整的样式文件。

这里使用 staticContext 可以实现,使用 redux 也一样可以实现。

总结

到此为止我们就实现了一个简易的同构框架。下面做一个简单的总结:

  • 同构渲染其实就是将同一套 react 代码在服务端执行一遍渲染静态页面,又在客户端执行一遍完成事件绑定。
  • 它的优势是,加快首页访问速度以及 SEO 友好,如果你的项目没有这方面的需求,则不需要选择同构。
  • 它的缺点是,不能在服务端渲染期间操作 DOMBOMapi ,比如 documentwindow 对象等,并且它增加了代码的复杂度,某些代码操作需要区分运行环境。
  • 在实际项目中,建议使用 Next.js 框架去做,站在巨人的肩旁上,可以少踩很多坑。

你可能感兴趣的:(react.js)