React SSR 详解 + 2个项目实战
0 条评论原文地址:https://juejin.im/post/5def0816f265da33aa6aa7fe
CSR & SSR
客户端渲染(Client Side Rendering)
- CSR 渲染流程:
服务端渲染(Server Side Rendering)
- 是指将单页应用(SPA)在服务器端渲染成 HTML 片段,发送到浏览器,然后交由浏览器为其绑定状态与事件,成为完全可交互页面的过程。(PS:本文中的 SSR 内容都是围绕同构应用来讲的)
- SSR 渲染流程:
- 服务端只负责首次“渲染”(真正意义上,只有浏览器才能渲染页面,服务端其实是生成 HTML 内容),然后返回给客户端,客户端接管页面交互(事件绑定等逻辑),之后客户端路由切换时,直接通过 JS 代码来显示对应的内容,不再需要服务端渲染(只有页面刷新时会需要)
为什么要用 SSR
优点:
更快的首屏加载速度:无需等待 JavaScript 完成下载且执行才显示内容,更快速地看到完整渲染的页面,有更好的用户体验。
更友好的 SEO:
爬虫可以直接抓取渲染之后的页面,CSR 首次返回的 HTML 文档中,是空节点(root),不包含内容,爬虫就无法分析你的网站有什么内容,所以就无法给你好的排名。而 SSR 返回渲染之后的 HTML 片段,内容完整,所以能更好地被爬虫分析与索引。
基于旧版本的搜索引擎:我们会给 html 加 title 和 description 来做简单的 seo 优化,这两个本质上并不会提高搜索的排名,而是提高网站转化率。给网站提供更多的描述,让用户有点击的欲望,从而提高排名。
首页标题 复制代码 基于新版本的搜索引擎(全文搜索):想要光靠上面两个来给网站有个好的排名是不行的,所以需要 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-helmetimport Head from’next/head’exportdefault () =>
复制代码My page title <metaname=”viewport”content=”initial-scale=1.0, width=device-width” />Hello world!
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子组件想要获取数据,最直接的方法如下:
functionPageA(props){
const {childOneData,childTwoData} = props;
return;
}
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 (
<><Linkhref=”/a?id=1”>
)
};
复制代码Next.js 中的路由是通过约定文件目录结构来生成的,所以无法定义
params
,动态路由只能通过query
实现import Router from’next/router’import Link from’next/link’const Index = () => {
// 通过 API 跳转functiongotoTestB() {
Router.push(
{
pathname: ‘/test/b’,
query: {
id: 2,
},
}
)
}
return (
<><Linkhref=”/test/b?id=1” ></>
)
};
复制代码如果想要浏览器中的路由更好看些(如:
/test/id
,而不是/test?id=123456
),可以用路由映射import Router from’next/router’import Link from’next/link’const Index = () => {
</>
// 通过 API 跳转functiongotoTestB() {
Router.push(
{
pathname: ‘/test/b’,
query: {
id: 2,
},
},
‘/test/b/2’,
)
}
return (
<><Linkhref=”/test/b?id=1”as=”/test/b/1” >
)
};
复制代码但是以上页面刷新的时候,页面会 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 事件returnfalse
}
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’classUserextendsReact.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 (
<><Linkhref=”#aaa”><aclassName=”link”>
A {router.query.id} {name}
{ a { color: blue; } .link { color: ${color}; }
}</>
)
};复制代码
动态加载资源 & 组件
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-contextimport React from’react’exportdefault React.createContext(‘’)
// components/Layout// 固定布局
xxx
xxx
xxx
// _app.jsimport’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’classMyAppextendsApp{
state = {
context: ‘value’,
};
/**
* 重写 getInitialProps 方法
*/staticasync 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
<MyContext.Providervalue={this.state.context}><Component {…pageProps} /></MyContext.Provider>
)
}
}
export default MyApp;复制代码
_document.js
只有在服务端渲染的时候才会被调用,客户端是不会执行的
用来修改服务端渲染的文档内容
一般配合第三方 css-in-js 方案使用,如 styled-components
import Document, { Html, Head, Main, NextScript } from’next/document’classMyDocumentextendsDocument{
// // // // //
// 重写 getInitialProps 方法staticasync getInitialProps(ctx) {
// 因为如果不加 _document.js,默认情况下,Next.js 会执行 Document.getInitialProps// 所以自定义的时候,必须执行 Document.getInitialPropsconst props = await Document.getInitialProps(ctx);
return {
…props
}
}
// render 要么不重写,重写的话,以下的内容都必须加上// render() {// return (// //// // // )// }
}exportdefault 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.jsimport Docuemnt, { Html, Head, Main, NextScript } from’next/document’import { ServerStyleSheet } from’styled-components’functionwithLog(Comp) {
returnprops => {
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.jsimport { 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 (
<>This is Title <Linkhref=”#aaa”><aclassName=”link”>
A {router.query.id} {name}
{ a { color: blue; } .link { color: ${color}; }
}</>
)
};
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://xuehuayu.cn/article/57965.html
- 版权声明:① 标为原创的文章为博主原创,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接。② 标为转载的文章来自网络,已标明出处,侵删。