ReactSSR詳解【近1W字】+2個項目實戰

NO IMAGE
目錄

CSR & SSR

客戶端渲染(Client Side Rendering)

  • CSR 渲染流程:
    ReactSSR詳解【近1W字】+2個項目實戰

服務端渲染(Server Side Rendering)

  • 是指將單頁應用(SPA)在服務器端渲染成 HTML 片段,發送到瀏覽器,然後交由瀏覽器為其綁定狀態與事件,成為完全可交互頁面的過程。(PS:本文中的 SSR 內容都是圍繞同構應用來講的
  • SSR 渲染流程:
ReactSSR詳解【近1W字】+2個項目實戰

  • 服務端只負責首次“渲染”(真正意義上,只有瀏覽器才能渲染頁面,服務端其實是生成 HTML 內容),然後返回給客戶端,客戶端接管頁面交互(事件綁定等邏輯),之後客戶端路由切換時,直接通過 JS 代碼來顯示對應的內容,不再需要服務端渲染(只有頁面刷新時會需要)

為什麼要用 SSR

優點:

  • 更快的首屏加載速度:無需等待 JavaScript 完成下載且執行才顯示內容,更快速地看到完整渲染的頁面,有更好的用戶體驗。
  • 更友好的 SEO
    • 爬蟲可以直接抓取渲染之後的頁面,CSR 首次返回的 HTML 文檔中,是空節點(root),不包含內容,爬蟲就無法分析你的網站有什麼內容,所以就無法給你好的排名。而 SSR 返回渲染之後的 HTML 片段,內容完整,所以能更好地被爬蟲分析與索引。
ReactSSR詳解【近1W字】+2個項目實戰

  • 基於舊版本的搜索引擎:我們會給 html 加 title 和 description 來做簡單的 seo 優化,這兩個本質上並不會提高搜索的排名,而是提高網站轉化率。給網站提供更多的描述,讓用戶有點擊的慾望,從而提高排名。
<title>首頁標題</title>
<meta name="description" content="首頁描述"></meta>
  • 基於新版本的搜索引擎(全文搜索):想要光靠上面兩個來給網站有個好的排名是不行的,所以需要 SSR 來提供更多的網站內容。

缺點:

  • 對服務器性能消耗較高
  • 項目複雜度變高,出問題需要在前端、node、後端三者之間找
  • 需要考慮 SSR 機器的運維、申請、擴容,增加了運維成本(可以通過 Serverless 解決)

什麼是同構應用

  • 一套代碼既可以在服務端運行又可以在客戶端運行,這就是同構應用。
  • 在服務器上生成渲染內容,讓用戶儘早看到有信息的頁面。一個完整的應用除包括純粹的靜態內容以外,還包括各種事件響應、用戶交互等。這就意味著在瀏覽器端一定還要執行 JavaScript 腳本,以完成綁定事件、處理異步交互等工作。
  • 從性能及用戶體驗上來看,服務端渲染應該表達出頁面最主要、最核心、最基本的信息;而瀏覽器端則需要針對交互完成進一步的頁面渲染、事件綁定等增強功能。所謂同構,就是指前後端共用一套代碼或邏輯,而在這套代碼或邏輯中,理想的狀況是在瀏覽器端進一步渲染的過程中,判斷已有的 DOM 結構和即將渲染出的結構是否相同,若相同,則不重新渲染 DOM 結構,只需要進行事件綁定即可。
  • 從這個維度上講,同構和服務端渲染又有所區別,同構更像是服務端渲染和瀏覽器端渲染的交集,它彌補了服務端和瀏覽器端的差異,從而使得同一套代碼或邏輯得以統一運行。同構的核心是“同一套代碼”,這是脫離於兩端角度的另一個維度。

手動搭建一個 SSR 框架

使用 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
    • 子組件想要獲取數據,最直接的方法如下:
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 目錄新建一個文件夾
ReactSSR詳解【近1W字】+2個項目實戰

  • 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

在線demo

渲染流程

ReactSSR詳解【近1W字】+2個項目實戰

服務端執行順序

在線demo

  1. _app getInitialProps()
  2. page getInitialProps()
  3. _document getInitialProps()
  4. _app constructor()
  5. _app render()
  6. page constructor()
  7. page render()
  8. _document constructor()
  9. _document render()

page 表示路由組件

客戶端執行順序(首次打開頁面)

  1. _app constructor()
  2. _app render()
  3. page constructor()
  4. page render()

注意: 當頁面初始化加載時,getInitialProps 只會在服務端被調用。只有當路由跳轉( Link 組件跳轉或 API 方法跳轉)時,客戶端才會執行 getInitialProps

路由跳轉執行順序

  1. _app getInitialProps()
  2. page getInitialProps()
  3. _app render()
  4. page constructor()
  5. 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
ReactSSR詳解【近1W字】+2個項目實戰

SSR 需要使用 StaticRouter(靜態路由容器),而非 BrowserRouterHashRouter

客戶端和服務端都需要配置 store 倉庫,但是兩個倉庫會不大一樣

componentDidMount 在服務器端是不執行的,而 componentWillMount 在客戶端和服務端都會執行,所以這就是為什麼不建議在 componentWillMount 發送請求的原因

註冊事件必須要放在 componentDidMount 中,不能放在 componentWillMount 中,因為 服務端是不會執行 componentWillUnmount 的,如果放在 componentWillMount 中,會導致事件重複註冊,發生內存洩漏

如果不想使用 SSR,但是又想要優化 SEO ,可以使用 prerender 或者 prerender-spa-plugin 來替代 SSR

在手動搭建 SSR 框架時:使用 npm-run-all & 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 框架,但是這些框架的對於 requestresponse 的封裝方式肯定有不同之處,它是如何保證 Next.js 導出的 handle 方法能兼容這些框架尼?

  • 保證 handle 方法接收到的是 NodeJS 原生的requset 對象以及 response 對象,不是框架基於原生封裝的 requestresponse 對象。所以這就是為什麼在使用 koa 時,handle 接收的是 ctx.reqctx.res ,而不是 ctx.requestctx.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 樣式

www.cnblogs.com/1wen/p/1079…

www.jianshu.com/p/2f9f3e41c…

在 Next.js 中:不要自定義靜態文件夾的名字

在根目錄下新建文件夾叫 static,代碼可以通過 /static/ 來引入相關的靜態資源。但只能叫static ,因為只有這個名字 Next.js 才會把它當作靜態資源。

在 Next.js 中:為什麼打開應用的速度會很慢

  • 可能將只有服務端用到的模塊放到了 getInitialProps 中,然後 Webpack 把該模塊也打包了。可參考 import them properly

Next.js 常見錯誤列表

後語

  • 本文只是基於我的理解寫的,如有錯誤的理解還請指正或者更好的方案還請提出
  • 為了寫的儘量詳細點,前前後後花了兩個月的時間才整理出了這篇文章,看到這裡,如果覺得這篇文章還不錯,還請點個贊~~

項目地址

手動搭建簡易版 SSR 框架

React16.8 + Next.js + Koa2 開發 Github 全棧項目

參考

淘寶前後端分離實踐 !!!!!!

UmiJS SSR

為什麼將 react 項目做成 ssr 同構應用

揭祕 React 同構應用

打造高可靠與高性能的 React 同構解決方案

慕課網 Next.js 教程

推薦閱讀

你真的瞭解 React 生命週期嗎

React Hooks 詳解 【近 1W 字】+ 項目實戰

Webpack 設置環境變量的誤區

從 0 到 1 實現一款簡易版 Webpack

相關文章

Java線程池的四種用法與使用場景

如何真正寫好代碼註釋—現代JavaScript教程

0.57*100===56.99999999999999之謎

SVG入門指南(看完,對SVG結構不在陌生)