React SSR 详解【近 1W 字】+ 2个项目实战
CSR & SSR
客户端渲染(Client Side Rendering)
服务端渲染(Server Side Rendering)
- 是指将单页应用(SPA)在服务器端渲染成 HTML 片段,发送到浏览器,然后交由浏览器为其绑定状态与事件,成为完全可交互页面的过程。(PS:本文中的 SSR 内容都是围绕同构应用来讲的)
- SSR 渲染流程:

- 服务端只负责首次“渲染”(真正意义上,只有浏览器才能渲染页面,服务端其实是生成 HTML 内容),然后返回给客户端,客户端接管页面交互(事件绑定等逻辑),之后客户端路由切换时,直接通过 JS 代码来显示对应的内容,不再需要服务端渲染(只有页面刷新时会需要)
为什么要用 SSR
优点:
- 更快的首屏加载速度:无需等待 JavaScript 完成下载且执行才显示内容,更快速地看到完整渲染的页面,有更好的用户体验。
- 更友好的 SEO:
- 爬虫可以直接抓取渲染之后的页面,CSR 首次返回的 HTML 文档中,是空节点(root),不包含内容,爬虫就无法分析你的网站有什么内容,所以就无法给你好的排名。而 SSR 返回渲染之后的 HTML 片段,内容完整,所以能更好地被爬虫分析与索引。

- 基于旧版本的搜索引擎:我们会给 html 加 title 和 description 来做简单的 seo 优化,这两个本质上并不会提高搜索的排名,而是提高网站转化率。给网站提供更多的描述,让用户有点击的欲望,从而提高排名。
<title>首页标题</title>
<meta name="description" content="首页描述"></meta>
- 基于新版本的搜索引擎(全文搜索):想要光靠上面两个来给网站有个好的排名是不行的,所以需要 SSR 来提供更多的网站内容。
缺点:
- 对服务器性能消耗较高
- 项目复杂度变高,出问题需要在前端、node、后端三者之间找
- 需要考虑 SSR 机器的运维、申请、扩容,增加了运维成本(可以通过 Serverless 解决)
什么是同构应用
- 一套代码既可以在服务端运行又可以在客户端运行,这就是同构应用。
- 在服务器上生成渲染内容,让用户尽早看到有信息的页面。一个完整的应用除包括纯粹的静态内容以外,还包括各种事件响应、用户交互等。这就意味着在浏览器端一定还要执行 JavaScript 脚本,以完成绑定事件、处理异步交互等工作。
- 从性能及用户体验上来看,服务端渲染应该表达出页面最主要、最核心、最基本的信息;而浏览器端则需要针对交互完成进一步的页面渲染、事件绑定等增强功能。所谓同构,就是指前后端共用一套代码或逻辑,而在这套代码或逻辑中,理想的状况是在浏览器端进一步渲染的过程中,判断已有的 DOM 结构和即将渲染出的结构是否相同,若相同,则不重新渲染 DOM 结构,只需要进行事件绑定即可。
- 从这个维度上讲,同构和服务端渲染又有所区别,同构更像是服务端渲染和浏览器端渲染的交集,它弥补了服务端和浏览器端的差异,从而使得同一套代码或逻辑得以统一运行。同构的核心是“同一套代码”,这是脱离于两端角度的另一个维度。
手动搭建一个 SSR 框架
- 项目地址:https://github.com/yjdjiayou/react-ssr-demo
- 项目源码中已经有大量的注释,所以这里不做过多的介绍
使用 Next.js(成熟的 SSR 框架)
安装
npx create-next-app project-name
查看 package.json
{
"name": "next-demo-one",
"version": "0.1.0",
"private": true,
"scripts": {
// 默认端口 3000,想要修改端口用 -p
"dev": "next dev -p 4000",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "9.1.4",
"react": "16.12.0",
"react-dom": "16.12.0"
}
}
Head
- next/head 的作用就是给每个页面设置
<head>
标签的内容,相当于 react-helmet
import Head from 'next/head'
export default () =>
<div>
<Head>
<title>My page title</title>
<meta name="viewport" content="initial-scale=1.0, width=device-width" />
</Head>
<p>Hello world!</p>
</div>
getInitialProps
- Next.js 有一套自己的获取数据的规范,数据请求需要放在
getInitialProps
内部,而不是放在组件的生命周期里,需要遵循它的规范。 getInitialProps
入参对象的属性如下:pathname
- URL 的 path 部分query
- URL 的 query 部分,并被解析成对象asPath
- 显示在浏览器中的实际路径(包含查询部分),为String
类型req
- HTTP 请求对象 (只有服务器端有)res
- HTTP 返回对象 (只有服务器端有)jsonPageRes
- 获取数据响应对象 (只有客户端有)err
- 渲染过程中的任何错误
- 当页面初始化加载时,
getInitialProps
只会在服务端被调用。只有当路由跳转(Link
组件跳转或 API 方法跳转)时,客户端才会执行getInitialProps
。在线demo - 只有放在 pages 目录下的组件,它的
getInitialProps
才会被调用,子组件使用getInitialProps
是无效的- 因为 pages 目录下的组件都默认是一个路由组件,只有路由组件才会被处理。Next.js 会先调用路由组件上的
getInitialProps
方法,获取返回的数据作为props
传入到该路由组件中,最后渲染该路由组件。在线demo - 子组件想要获取数据,最直接的方法如下:
- 因为 pages 目录下的组件都默认是一个路由组件,只有路由组件才会被处理。Next.js 会先调用路由组件上的
function PageA(props){
const {childOneData,childTwoData} = props;
return <div>
<ChildOne childOneData/>
<ChildTwo childTwoData/>
</div>;
}
PageA.getInitialProps = async ()=>{
// 在父组件中的 getInitialProps 方法里,调用接口获取子组件所需要的数据
const childOneData = await getPageAChildOneData();
const childTwoData = await getPageAChildTwoData();
return {childOneData, childTwoData}
};
- 当一个页面结构复杂,多个子组件需要同时请求数据或者子组件需要动态加载时,以上的方案可能就不太适合了。千万不要想着在子组件的生命周期中去请求数据,要遵守 Next.js 的规范。比较好的方法是:将这些子组件拆分一个个子路由,作为路由组件就能调用
getInitialProps
方法获取数据
路由
- 约定式路由
- 默认在
pages
目录下的.js
文件都是一级路由 - 如果要使用二级路由,就在
pages
目录新建一个文件夹
- 默认在

- Next.js 中的
Link
组件,默认不会渲染出任何内容(如a
标签),需要指定渲染内容,并且内部必须有一个顶层元素,不能同时出现两个兄弟元素。它只是监听了我们指定内容的click
事件,然后跳转到指定的路径
import Link from 'next/link'
const Index = () => {
return (
<>
<Link href="/a?id=1">
<div>
<Button>AAA</Button>
<Button>BBB</Button>
</div>
</Link>
</>
)
};
- Next.js 中的路由是通过约定文件目录结构来生成的,所以无法定义
params
,动态路由只能通过query
实现
import Router from 'next/router'
import Link from 'next/link'
const Index = () => {
// 通过 API 跳转
function gotoTestB() {
Router.push(
{
pathname: '/test/b',
query: {
id: 2,
},
}
)
}
return (
<>
<Link href="/test/b?id=1" >
<Button>BBB</Button>
</Link>
</>
)
};
- 如果想要浏览器中的路由更好看些(如:
/test/id
,而不是/test?id=123456
),可以用路由映射
import Router from 'next/router'
import Link from 'next/link'
const Index = () => {
// 通过 API 跳转
function gotoTestB() {
Router.push(
{
pathname: '/test/b',
query: {
id: 2,
},
},
'/test/b/2',
)
}
return (
<>
<Link href="/test/b?id=1" as="/test/b/1" >
<div>
<Button>BBB</Button>
</div>
</Link>
</>
)
};
- 但是以上页面刷新的时候,页面会 404 ,因为是 SPA 应用,前端改变浏览器路由可以不刷新页面,但是在刷新页面,重新请求该路由对应的文件时,服务端找不到该路径对应的文件。所以需要借助 Node 框架(如:Koa2 )来替代 Next.js 默认自带的 server
const Koa = require('koa');
const Router = require('koa-router');
const next = require('next');
const app = next({ dev });
const handle = app.getRequestHandler();
app.prepare().then(() => {
const server = new Koa();
const router = new Router();
router.get('/a/:id', async ctx => {
const id = ctx.params.id;
await handle(ctx.req, ctx.res, {
pathname: '/a',
query: { id },
});
});
server.listen(3000, () => {
console.log('koa server listening on 3000')
});
}
- 路由拦截器
import Router from 'next/router'
Router.beforePopState(({ url, as, options }) => {
// I only want to allow these two routes!
if (as !== "/" || as !== "/other") {
// Have SSR render bad routes as a 404.
window.location.href = as
// 返回 false,Router 将不会执行 popstate 事件
return false
}
return true
});
- 路由事件
routeChangeStart(url)
- 路由开始切换时触发routeChangeComplete(url)
- 完成路由切换时触发routeChangeError(err, url)
- 路由切换报错时触发beforeHistoryChange(url)
- 浏览器history
模式开始切换时触发hashChangeStart(url)
- 开始切换hash
值但是没有切换页面路由时触发hashChangeComplete(url)
- 完成切换hash
值但是没有切换页面路由时触发- 这里的
url
是指显示在浏览器中的url
。如果你使用了路由映射,那浏览器中的url
将会显示as
的值
import React from 'react';
import Router from 'next/router'
class User extends React.Component {
handleRouteChange = url => {
console.log('url=> ', url);
};
componentDidMount() {
Router.events.on('routeChangeStart', (res) => {
console.log(res);
});
Router.events.on('routeChangeComplete', (res) => {
console.log(res);
});
Router.events.on('routeChangeError', (res) => {
console.log(res);
});
}
componentWillUnmount() {
Router.events.off('routeChangeStart', (res) => {
console.log(res);
});
Router.events.off('routeChangeComplete', (res) => {
console.log(res);
});
Router.events.off('routeChangeError', (res) => {
console.log(res);
});
}
render() {
return <div>User </div>;
}
}
style jsx
- Next.js 中有各种 CSS 解决方案,默认集成了 styled-jsx
const A = ({ router, name}) => {
return (
<>
<Link href="#aaa">
<a className="link">
A {router.query.id} {name}
</a>
</Link>
<style jsx>{`
a {
color: blue;
}
.link {
color: ${color};
}
`}</style>
</>
)
};
动态加载资源 & 组件
import { withRouter } from 'next/router'
import dynamic from 'next/dynamic'
import Link from 'next/link'
const LazyComp = dynamic(import('../components/lazy-comp'));
const A = ({time }) => {
return (
<>
<div>Time:{time}</div>
<LazyComp />
</>
)
};
A.getInitialProps = async ctx => {
// 动态加载 moment,只有到了当前页面的时候才去加载它,而不是在页面初始化的时候去加载
const moment = await import('moment');
const promise = new Promise(resolve => {
setTimeout(() => {
resolve({
name: 'jokcy',
// 默认加载的是 ES6 模块
time: moment.default(Date.now() - 60 * 1000).fromNow(),
})
}, 1000)
});
return await promise
};
export default A;
_app.js
- 新建
./pages/_app.js
文件,自定义 App 模块 - 自定义 Next.js 中的 ,可以有如下好处:
- 实现各个页面通用的布局 —— Layout
- 当路由变化时,保持一些公用的状态(使用 redux)
- 给页面传入一些自定义的数据
- 使用
componentDidCatch
自定义处理错误
// lib/my-context
import React from 'react'
export default React.createContext('')
// components/Layout
// 固定布局
xxx
xxx
xxx
// _app.js
import 'antd/dist/antd.css';
import App, { Container } from 'next/app';
import Layout from '../components/Layout'
import MyContext from '../lib/my-context'
import {Provider} from 'react-redux'
class MyApp extends App {
state = {
context: 'value',
};
/**
* 重写 getInitialProps 方法
*/
static async getInitialProps(ctx) {
const {Component} = ctx;
// 每次页面切换的时候,这个方法都会被执行!!!
console.log('app init');
let pageProps = {};
// 因为如果不加 _app.js,默认情况下,Next.js 会执行 App.getInitialProps
// 所以重写 getInitialProps 方法时,路由组件的 getInitialProps 必须要执行
if (Component.getInitialProps) {
pageProps = await Component.getInitialProps(ctx)
}
return {
pageProps
}
}
render() {
const { Component, pageProps, reduxStore } = this.props;
return (
// 在最新的 Next.js 版本中,Container 被移除了,不再需要 Container 包裹组件
// https://github.com/zeit/next.js/blob/master/errors/app-container-deprecated.md
<Container>
<Layout>
<MyContext.Provider value={this.state.context}>
<Component {...pageProps} />
</MyContext.Provider>
</Layout>
</Container>
)
}
}
export default MyApp;
_document.js
- 只有在服务端渲染的时候才会被调用,客户端是不会执行的
- 用来修改服务端渲染的文档内容
- 一般配合第三方 css-in-js 方案使用,如 styled-components
import Document, { Html, Head, Main, NextScript } from 'next/document'
class MyDocument extends Document {
// 重写 getInitialProps 方法
static async getInitialProps(ctx) {
// 因为如果不加 _document.js,默认情况下,Next.js 会执行 Document.getInitialProps
// 所以自定义的时候,必须执行 Document.getInitialProps
const props = await Document.getInitialProps(ctx);
return {
...props
}
}
// render 要么不重写,重写的话,以下的内容都必须加上
// render() {
// return (
// <Html>
// <Head>
// <style>{`body { background:red;} /* custom! */`}</style>
// </Head>
// <body className="custom_class">
// <Main />
// <NextScript />
// </body>
// </Html>
// )
// }
}
export default MyDocument
内部集成 Webpack
- Next.js 内部集成了 Webpack,开箱即用
- 生成环境下默认会分割代码和 tree-shaking
集成 Redux
渲染流程

服务端执行顺序
- _app getInitialProps()
- page getInitialProps()
- _document getInitialProps()
- _app constructor()
- _app render()
- page constructor()
- page render()
- _document constructor()
- _document render()
page 表示路由组件
客户端执行顺序(首次打开页面)
- _app constructor()
- _app render()
- page constructor()
- page render()
注意: 当页面初始化加载时,getInitialProps
只会在服务端被调用。只有当路由跳转( Link
组件跳转或 API 方法跳转)时,客户端才会执行 getInitialProps
。
路由跳转执行顺序
- _app getInitialProps()
- page getInitialProps()
- _app render()
- page constructor()
- page render()
使用 Next.js 的优缺点
优点:
- 轻量易用,学习成本低,开箱即用(如:内部集成 Webpack、约定式路由等),不需要自己去折腾搭建项目。个人看法:是一个用自由度来换取易用性的框架。
- 自带数据同步策略,解决服务端渲染最大难点。把服务端渲染好的数据,拿到客户端重用,这个在没有框架的时候,是非常麻烦的。
- 拥有丰富的插件,让我们可以在使用的时候按需使用。
- 配置灵活:可以根据项目要求的不同快速灵活的进行配置。
缺点: 必须遵循它的规范(如:必须在 getInitialProps
中获取数据),写法固定,不利于拓展。
展望 Serverless
- Serverless —— 无服务架构
- Serverless 不代表再也不需要服务器了,而是说:开发者再也不用过多考虑服务器的问题,计算资源作为服务而不是服务器的概念出现
- Serverless 肯定会火,前端可以不考虑部署、运维、环境等场景,直接编写函数来实现后端逻辑,对生产力上有着显著的提升
- 有了 Serverless ,之后的 SSR 可以称为 Serverless Side Rendering
- 因为对 Serverless 不是很了解,只知道它的概念以及带来的影响是什么,所以不敢过多妄言,有兴趣的同学可以自行了解
看懂 Serverless,这一篇就够了理解serverless无服务架构原理(一)什么是Serverless无服务器架构?
常见问题
客户端需要使用 ReactDOM.hydrate 代替 ReactDOM.render ,完成 SSR 未完成的事情(如:事件绑定)
- 在 React v15 版本里,
ReactDOM.render
方法会根据data-react-checksum
的标记,复用ReactDOMServer
的渲染结果,不重复渲染。根据data-reactid
属性,找到需要绑定的事件元素,进行事件绑定的处理。 - 在 React v16 版本里,
ReactDOMServer
渲染的内容不再带有data-react
属性,ReactDOM.render
可以使用但是会报警告。 - 在 React v17 版本里,
ReactDOM.render
将不再具有复用 SSR 内容的功能,统一用hydrate()
来进行服务端渲染。 - 因为服务端返回的 HTML 是字符串,虽然有内容,但是各个组件没有事件,客户端的仓库中也没有数据,可以看做是干瘪的字符串。客户端会根据这些字符串完成 React 的初始化工作,比如创建组件实例、绑定事件、初始化仓库数据等。hydrate 在这个过程中起到了非常重要的作用,俗称“注水”,可以理解为给干瘪的种子注入水分,使其更具生机。
- 在使用 Next.js 时, 打开浏览器控制台 => 找到 network => 找到当前路由的请求并查看 response => 可以看到服务端返回的 html 里包含着当前页面需要的数据,这样客户端就不会重新发起请求了,靠的就是
ReactDOM.hydrate
。

SSR 需要使用 StaticRouter
(静态路由容器),而非 BrowserRouter
和 HashRouter
客户端和服务端都需要配置 store 仓库,但是两个仓库会不大一样
componentDidMount
在服务器端是不执行的,而 componentWillMount
在客户端和服务端都会执行,所以这就是为什么不建议在 componentWillMount
发送请求的原因
注册事件必须要放在 componentDidMount
中,不能放在 componentWillMount
中,因为 服务端是不会执行 componentWillUnmount
的,如果放在 componentWillMount
中,会导致事件重复注册,发生内存泄漏
如果不想使用 SSR,但是又想要优化 SEO ,可以使用 prerender 或者 prerender-spa-plugin 来替代 SSR
在手动搭建 SSR 框架时:使用 npm-run-al
l & nodemon
来提高开发 Node 项目的效率
- nodemon 监听代码文件的变动,当代码改变之后,自动重启
- npm-run-all 用于并行或者顺序运行多个 npm 脚本的 cli 工具
npm install npm-run-all nodemon --save-dev
"scripts": {
"dev": "npm-run-all --parallel dev:**",
"dev:start": "nodemon build/server.js",
"dev:build:client": "webpack --config webpack.client.js --watch",
"dev:build:server": "webpack --config webpack.server.js --watch"
}
在 Next.js 中:默认会引入 import React from "react"
,但是如果不引入,在写组件时,编辑器会发出警告,所以还是引入下较好
在 Next.js 中:会对 pages 目录下的每个路由组件分开打包,所以当点击按钮进行路由跳转时,并不会马上跳转到对应的路由页面,而是要先加载好目标路由的资源文件,然后再跳转过去。这个可以用预加载优化。
在 Next.js 中:内部集成了 Webpack,生成环境下默认会分割代码和 tree-shaking
Next.js 适用于任何 node 框架,但是这些框架的对于 request
、response
的封装方式肯定有不同之处,它是如何保证 Next.js 导出的 handle
方法能兼容这些框架尼?
- 保证
handle
方法接收到的是 NodeJS 原生的requset
对象以及response
对象,不是框架基于原生封装的request
、response
对象。所以这就是为什么在使用 koa 时,handle
接收的是ctx.req
、ctx.res
,而不是ctx.request
、ctx.response
的原因。
在 Next.js 中:如何集成 styled-components
- 需要在 _document.js 中集成
- 利用 AOP 面向切面编程思想
cnpm i styled-components babel-plugin-styled-components -D
// .babelrc
{
"presets": ["next/babel"],
"plugins": [
[
"import",
{
"libraryName": "antd"
}
],
["styled-components", { "ssr": true }]
]
}
// _document.js
import Docuemnt, { Html, Head, Main, NextScript } from 'next/document'
import { ServerStyleSheet } from 'styled-components'
function withLog(Comp) {
return props => {
console.log(props);
return <Comp {...props} />
}
}
class MyDocument extends Docuemnt {
static async getInitialProps(ctx) {
const sheet = new ServerStyleSheet();
const originalRenderPage = ctx.renderPage;
try {
ctx.renderPage = () =>
originalRenderPage({
// 增强 APP 功能
enhanceApp: App => props => sheet.collectStyles(<App {...props} />),
// 增强组件功能
// enhanceComponent: Component => withLog(Component)
});
const props = await Docuemnt.getInitialProps(ctx);
return {
...props,
styles: (
<>
{props.styles}
{sheet.getStyleElement()}
</>
),
}
} finally {
sheet.seal()
}
}
render() {
return (
<Html>
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
}
export default MyDocument
// pages/a.js
import { withRouter } from 'next/router'
import Link from 'next/link'
import styled from 'styled-components'
const Title = styled.h1`
color: yellow;
font-size: 40px;
`;
const color = '#113366';
const A = ({ router, name}) => {
return (
<>
<Title>This is Title</Title>
<Comp />
<Link href="#aaa">
<a className="link">
A {router.query.id} {name}
</a>
</Link>
<style jsx>{`
a {
color: blue;
}
.link {
color: ${color};
}
`}</style>
</>
)
};
export default withRouter(A)
在 Next.js 中:如何集成 CSS / Sass / Less / Stylus
支持用 .css
、 .scss
、 .less
、 .styl
,需要配置默认文件 next.config.js,具体可查看下面链接
在 Next.js 中:打包的时候无法按需加载 Antd 样式
在 Next.js 中:不要自定义静态文件夹的名字
在根目录下新建文件夹叫 static
,代码可以通过 /static/
来引入相关的静态资源。但只能叫static
,因为只有这个名字 Next.js 才会把它当作静态资源。
在 Next.js 中:为什么打开应用的速度会很慢
- 可能将只有服务端用到的模块放到了 getInitialProps 中,然后 Webpack 把该模块也打包了。可参考 import them properly
Next.js 常见错误列表
后语
- 本文只是基于我的理解写的,如有错误的理解还请指正或者更好的方案还请提出
- 为了写的尽量详细点,前前后后花了两个月的时间才整理出了这篇文章,看到这里,如果觉得这篇文章还不错,还请点个赞~~
项目地址
React16.8 + Next.js + Koa2 开发 Github 全栈项目
参考
淘宝前后端分离实践 !!!!!!
推荐阅读
转载自:https://juejin.cn/post/6844904017487724557