前言
所谓同构,简而言之就是,第一次访问后台服务时,后台直接把前端要显示的界面全部返回,而不是像 SPA 项目只渲染一个 <div id="root"></div> 剩下的都是靠 JavaScript 脚本去加载。这样一来可以大大减少首屏等待时间。
同构概念并不复杂,它也非项目必需品,但是探索它的原理却是必须的。
阅读本文需要你具备以下技术基础: Node.js 、 React 、 React Router 、 Redux 、 webpack 。
本文将分以下两部分去讲述:
同构思路分析,让你对同构有一个概念上的了解;
手写同构框架,深入理解同构原理。
同构思路
CSR 客户端渲染
CSR 客户端渲染,这个就是很好理解了,使用 React , React Router 前端自己控制路由的 SPA 项目,就可以理解成客户端渲染。它有一个非常大的优势就是,只是首次访问会请求后台服务加载相应文件,之后的访问都是前端自己判断 URL 展示相关组件,因此除了首次访问速度慢些之外,之后的访问速度都很快。
执行命令: create-react-app react-csr 创建一个 React SPA 单页面应用项目 。执行命令: npm run start 启动项目。
查看网页源代码:
只有一个 <div id="root"></div> 和 一些 script 脚本。最终呈现出来的界面却是这样的:
原理很简单,相信学习过 webpack 的同学都知道,那就是 webpack 把所有代码都打包成相应脚本并插入到 HTML 界面中,浏览器会解析 script 脚本,通过动态插入 DOM 的方式展示出相应界面。
客户端渲染的优劣势
客户端渲染流程如下:
优势:
劣势:
SSR 服务端渲染
SSR 是服务端渲染技术,它本身是一项比较普通的技术, Node.js 使用 ejs 模板引擎输出一个界面这就是服务端渲染。每次访问一个路由都是请求后台服务,重新加载文件渲染界面。
同样我们也来创建一个简单的 Node.js 服务:
mkdir express-ssrcd express-ssrnpm init -ytouch app.jsnpm i express --save
复制代码
app.js
const express = require('express')const app = express()
app.get('/',function (req,res) { res.send( `<html> <head> <title>express ssr</title> </head> <body> <h1>Hello SSR</h1> </body> </html>` )})
app.listen(3000);
复制代码
启动服务: node app.js
这就是最简单的服务端渲染一个界面了。服务端渲染的本质就是页面显示的内容是服务器端生产出来的。
服务端渲染的优劣势
服务端渲染流程:
优势:
劣势:
我们会发现一件很有意思的事,服务端渲染的优点就是客户端渲染的缺点,服务端渲染的缺点就是客户端渲染的优点,反之亦然。那为何不将传统的纯服务端直出的首屏优势和客户端渲染站内跳转优势结合,以取得最优解?这就引出了当前流行的服务端渲染( Server Side Rendering ),或者称之为“同构渲染”更为准确。
同构渲染
所谓同构,通俗的讲,就是一套 React 代码在服务器上运行一遍,到达浏览器又运行一遍。服务端渲染完成页面结构,客户端渲染绑定事件。它是在 SPA 的基础上,利用服务端渲染直出首屏,解决了单页面应用首屏渲染慢的问题。参考 前端进阶面试题详细解答
同构渲染流程
简单同构案例
要实现同构,简单来说就是以下两步:
服务端要能运行 React 代码;
浏览器同样运行 React 代码。
1、创建项目
mkdir react-ssrcd react-ssrnpm 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 ( <div> hello world <br/> <button onClick={()=> alert("hello world")}>按钮</button> </div> )}
export default Home;
复制代码
安装客户端渲染的惯例,我们写一个客户端渲染的入口文件, client/index.js
import React from "react";import ReactDom from "react-dom";import Home from "../containers/Home";
ReactDom.hydrate(<Home/>,document.getElementById("root"));// ReactDom.render(<Home/>,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(<Home />); //{3}
app.get('/',function (req,res) { // {4} res.send(` <!doctype html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>React SSR</title> </head> <body> <div id="root">${content}</div> <script src="/index.js"></script> </body> </html> `)})
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 片段也插入进去一同返回给用户了,这样就实现了服务端渲染。通过 <script src="/index.js"></script> 这段脚本加载了客户端打包后的 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);
复制代码
到此我们就完成了一个简单的同构项目,这里您应该会有几个疑问?
renderToString 有什么作用?
为什么服务端加载了一次,客户端还需要再次加载呢?
服务端加载了 React 输出的代码片段,客户端又执行了一次,这样是不是会加载两次导致资源浪费呢?
ReactDOMServer.renderToString(element)
将 React 元素渲染为初始 HTML 。 React 将返回一个 HTML 字符串。你可以使用此方法在服务端生成 HTML ,并在首次请求时将标记下发,以加快页面加载速度,并允许搜索引擎爬取你的页面以达到 SEO 优化的目的。
为什么服务端加载了一次,客户端还需要再次加载呢?
原因很简单,服务端使用 renderToString 渲染页面,而 react-dom/server 下的 renderToString 并没有做事件相关的处理,因此返回给浏览器的内容不会有事件绑定,渲染出来的页面只是一个静态的 HTML 页面。只有在客户端渲染 React 组件并初始化 React 实例后,才能更新组件的 state 和 props ,初始化 React 的事件系统,让 React 组件真正“ 动” 起来。
是否加载两次?
如果你在已有服务端渲染标记的节点上调用 ReactDOM.hydrate() 方法, React 将会保留该节点且只进行事件处理绑定,从而让你有一个非常高性能的首次加载体验。因此不必担心加载多次的问题。
是否意犹未尽?那就让我们更加深入的学习它,手写一个同构框架,彻底理解同构渲染的原理。
手写同构框架
实现一个同构框架,我们还有很多问题需要解决:
兼容路由;
兼容 Redux ;
兼容异步数据请求;
兼容 CSS 样式渲染。
问题很多,我们逐个击破。
兼容路由
同构项目中当在浏览器中输入 URL 后,浏览器是如何找到对应的界面?
浏览器收到 URL 地址例如: http://localhost:3000/login ;
后台路由找到对应的 React 组件传入到 renderToString 中,然后拼接 HTML 输出页面;
浏览器加载打包后的 JS 文件,并解析执行前端路由,输出相应的前端组件,发现是服务端渲染,因此只做事件绑定处理,不进行重复渲染,此时前端路由路由开始接管界面,之后跳转界面与后台无关。
既然需要路由我们就先安装下: npm install react-router-dom
之前我们只定义了一个 Home 组件,为了演示路由,我们再定义一个 Login 组件:
...import { Link } from "react-router-dom";
const Login = ()=>{ return ( <div> <h1>登录页</h1> <br/> <Link to="/">跳转到首页</Link> </div> )}
复制代码
改造 Home 组件
const Home = ()=>{ return ( <div> <h1>首页</h1> <br/> <Link to="/login">跳转到登录页</Link> <br/> <button onClick={() => console.log("click me")}>点击</button> </div> )}
复制代码
现在我们有两个组件了,可以开始定义相关路由:
src/Routes.js
...import {Route} from "react-router-dom";
export default ( <div> <Route path="/" exact component={Home} /> // 访问根路径时展示Home组件 <Route path="/login" component={Login} /> // 访问/login路径时展示Login组件 </div>)
复制代码
改造客户端路由:src/client/index.js
...import { BrowserRouter } from "react-router-dom";import Routes from "../Routes";
const App = ()=>{ return ( <BrowserRouter> {Routes} </BrowserRouter> )}
ReactDom.hydrate(<App />,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(( <StaticRouter location={req.path}> {Routes} </StaticRouter> )); return ` <!doctype html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>React SSR</title> </head> <body> <div id="root">${content}</div> <script src="/index.js"></script> </body> </html> `}
app.get('*',function (req,res) { res.send(render(req))})
复制代码
服务端跟之前的区别就是这段代码:
<StaticRouter location={req.path}> {Routes}</StaticRouter>
复制代码
为什么不是 BrowserRouter 而是 StaticRouter 呢?
主要是因为 BrowserRouter 使用的是 History API 记录位置,而 History API 是属于浏览器的 API ,在 SSR 的环境下,服务端不能使用浏览器 API 。
StaticRouter
静态路由,通过初始传入的 location 地址找到相应组件。区别于客户端的动态路由。
兼容 Redux
Redux 一直以来都是 React 技术栈里最难理解的部分,它的概念繁多,如果想要彻底理解本小节及以后的内容,需要您对 Redux 有一定的了解
安装包:
npm i redux react-redux redux-thunk --save
复制代码
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(( <Provider store={getStore()}> <StaticRouter location={req.path}> {Routes} </StaticRouter> </Provider> )); return ` ... 省略 `}
复制代码
通过 Provider 组件把 store 对象数据共享给所有子组件,它的本质还是通过 context 共享数据。
改造 client 端, src/client/index.js
...import { Provider } from "react-redux";import getStore from "../store";
const App = ()=>{ return ( <Provider store={getStore()}> <BrowserRouter> {Routes} </BrowserRouter> </Provider> )}
ReactDom.hydrate(<App />,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 ( <div> <h1>首页</h1> <div>{props.name}</div> <br/> <Link to="/login">跳转到登录页</Link> <br/> <button onClick={() => console.log("click me")}>点击</button> </div> )}
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 接收 mapStateToProps 、 mapDispatchToProps 两个方法,返回一个高阶函数,这个高阶函数接收一个组件,返回一个新组件,其实就是给传入的组件增加一些属性和功能。
这样一来我们的 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; }}
复制代码
代码解析:
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 ( <div> <h1>首页</h1> <ul> { {/* 3 */} userList.map(user=>{ const { first_name, last_name, email, avatar, id } = user; return <li key={id}> <img src={avatar} alt="用户头像" style={{width:"30px",height:"30px"}}/> <div>姓名:{`${first_name}${last_name}`}</div> <div>email:{email}</div> </li> }) } </ul> <br/> <Link to="/login">跳转到登录页</Link> <br/> <button onClick={() => console.log("click me")}>点击</button> </div> )}
const mapStateToProps = (state)=>({ name:state.name, userList:state.userList});// {1}const mapDispatchToProps = (dispatch)=>({ getUserList(){ dispatch(getUserList(dispatch)) }})
export default connect(mapStateToProps,mapDispatchToProps)(Home);
复制代码
代码解析:
{1}, mapDispatchToProps 同 mapStateToProps 作用一致都是 connect 的入参,把相关的 dispatch 与 state 传入 Home 组件中。
{2}, useEffect Hook 中调用 getUserList 方法,获取后台真实数据
{3},根据真实返回的 userList 渲染组件
我们来看看实际效果:
看起来很不错, react-router 与 redux 都已经支持了,但是当你查看下网页源码时会发现一个问题:
用户列表数据并不是服务端渲染的,而是通过客户端渲染的。为什么会这样呢?我们一起分析下请求过程你就会明白:
接下来我们主要的目标就是服务端如何可获取到数据?既然 useEffect 不会在服务端执行,那么我们就自己创建一个 “Hook” 。
在 Next.js 中 getInitialProps 就是这个被创建的 “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(( <Provider store={store}> // {3} <StaticRouter location={req.path}>{renderRoutes(Routes)}</StaticRouter> </Provider> )); res.send( ` ... `) })}
复制代码
代码解析:
{1}, matchRoutes 获取当前访问路由所匹配到的组件,匹配到的组件如果有 getInitialData 方法就直接调用;
{2}, component.getInitialData(store) 返回都是 Promise , 等待全部 Promise 执行完成后, store 中的 state 就有数据了,此时服务端就已经获取到相应组件的后台数据;
{3},renderRoutes 它的作用是根据静态路由配置渲染出 <Route /> 组件,类似下面代码,不过 renderRoutes 边界处理的更加完善。
{routes.map(route => ( <Route {...route} />))}
复制代码
细心的你肯定会发现,明明服务器已经拿到数据了为什么刷新浏览器会一闪一闪呢,原因在于,客户端渲染接管时,初始化的用户列表依然是个空数组,通过发送后台请求获取到数据这个异步过程,导致的页面一闪一闪的。它的解决方案有一个术语叫做数据的脱水与注水。
数据脱水与注水
其实非常简单,在渲染服务端时,已经拿到了后台请求数据,因此我们可以做:
res.send( ` <!doctype html> <html lang="en"> ... <body> <div id="root">${content}</div> <script> window.INITIAL_STATE = ${JSON.stringify(store.getState())} </script> <script src="/index.js"></script> </body> </html> `)
复制代码
通过 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";
<div className={styles.box}>...</div>
复制代码
直接编译肯定报错,我们需要在 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-loader 而 client 端使用了 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(( <Provider store={store}> <StaticRouter location={req.path} context={context}>{renderRoutes(Routes)}</StaticRouter> </Provider> ));
const css = context.css.length ? context.css.join('\n') : '';
res.send( ` <!doctype html> <html lang="en"> <head> ... <style>${css}</style> </head> ... </html> `)}
复制代码
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 友好,如果你的项目没有这方面的需求,则不需要选择同构。
它的缺点是,不能在服务端渲染期间操作 DOM 、 BOM 等 api ,比如 document 、 window 对象等,并且它增加了代码的复杂度,某些代码操作需要区分运行环境。
在实际项目中,建议使用 Next.js 框架去做,站在巨人的肩旁上,可以少踩很多坑。
评论