前言
所谓同构,简而言之就是,第一次访问后台服务时,后台直接把前端要显示的界面全部返回,而不是像 SPA
项目只渲染一个 剩下的都是靠
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
启动项目。
查看网页源代码: 只有一个 和 一些
script
脚本。最终呈现出来的界面却是这样的: 原理很简单,相信学习过 webpack
的同学都知道,那就是 webpack
把所有代码都打包成相应脚本并插入到 HTML
界面中,浏览器会解析 script
脚本,通过动态插入 DOM
的方式展示出相应界面。
客户端渲染的优劣势
优势:
- 前端负责渲染页面,后端负责实现接口,各自干好各自的事情,对开发效率有极大的提升;
- 前端在跳转界面的时候不需要请求后台,加速了界面跳转的速度,提高用户体验。
劣势:
- 由于需要等待
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
这就是最简单的服务端渲染一个界面了。服务端渲染的本质就是页面显示的内容是服务器端生产出来的。
服务端渲染的优劣势
服务端渲染流程:
优势:
- 整个
HTML
都通过服务端直接输出SEO
友好; - 加载首页不需要加载整个应用的
JS
文件,首页加载速度快。
劣势:
- 访问一个应用程序的每个界面都需要访问服务器,体验对比
CSR
稍差。
我们会发现一件很有意思的事,服务端渲染的优点就是客户端渲染的缺点,服务端渲染的缺点就是客户端渲染的优点,反之亦然。那为何不将传统的纯服务端直出的首屏优势和客户端渲染站内跳转优势结合,以取得最优解?这就引出了当前流行的服务端渲染( Server Side Rendering
),或者称之为“同构渲染”更为准确。
同构渲染
所谓同构,通俗的讲,就是一套 React
代码在服务器上运行一遍,到达浏览器又运行一遍。
服务端渲染完成页面结构,客户端渲染绑定事件。它是在 SPA
的基础上,利用服务端渲染直出首屏,解决了单页面应用首屏渲染慢的问题。参考 前端进阶面试题详细解答
同构渲染流程
简单同构案例
要实现同构,简单来说就是以下两步:
- 服务端要能运行
React
代码; - 浏览器同样运行
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);
到此我们就完成了一个简单的同构项目,这里您应该会有几个疑问?
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 (
登录页
跳转到首页
)
}
改造 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-redux
是react
与redux
的桥梁;redux-thunk
是redux
中间件,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
接收 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;
}
}
代码解析:
- {1},定义默认
state
,userList
为空数组; - {2},当接收到
type
为CHANGE_USER_LIST
的dispatch
时,更新用户列表,这也是我们在actions
那里接收到后台请求数据之后发送的dispatch
,dispatch(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},
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((
// {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-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((
{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
友好,如果你的项目没有这方面的需求,则不需要选择同构。 - 它的缺点是,不能在服务端渲染期间操作
DOM
、BOM
等api
,比如document
、window
对象等,并且它增加了代码的复杂度,某些代码操作需要区分运行环境。 - 在实际项目中,建议使用
Next.js
框架去做,站在巨人的肩旁上,可以少踩很多坑。