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

CSR & SSR

客戶端渲染(Client Side Rendering)

  • CSR 渲染流程:
    React CSR.png

服務端渲染(Server Side Rendering)

  • 是指將單頁應用(SPA)在服務器端渲染成 HTML 片斷,發送到瀏覽器,而後交由瀏覽器爲其綁定狀態與事件,成爲徹底可交互頁面的過程。(PS:本文中的 SSR 內容都是圍繞同構應用來說的
  • SSR 渲染流程:

React SSR.jpg

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

爲何要用 SSR

優勢:javascript

  • 更快的首屏加載速度:無需等待 JavaScript 完成下載且執行才顯示內容,更快速地看到完整渲染的頁面,有更好的用戶體驗。
  • 更友好的 SEO
    • 爬蟲能夠直接抓取渲染以後的頁面,CSR 首次返回的 HTML 文檔中,是空節點(root),不包含內容,爬蟲就沒法分析你的網站有什麼內容,因此就沒法給你好的排名。而 SSR 返回渲染以後的 HTML 片斷,內容完整,因此能更好地被爬蟲分析與索引。


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

缺點:css

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

什麼是同構應用

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

手動搭建一個 SSR 框架

使用 Next.js(成熟的 SSR 框架)

  • 這裏只是展現了一些值得注意的知識點以及本身的心得,更多細節請看 官方文檔 中文文檔

安裝

npx create-next-app project-name
複製代碼

查看 package.jsonhtml

{
  "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 目錄新建一個文件夾

image.png

  • 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前端

渲染流程

Next.js 渲染流程 (1).jpg

服務端執行順序

在線demojava

  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 表示路由組件node

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

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

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

路由跳轉執行順序

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

使用 Next.js 的優缺點

優勢:webpack

  • 輕量易用,學習成本低,開箱即用(如:內部集成 Webpack、約定式路由等),不須要本身去折騰搭建項目。我的見解:是一個用自由度來換取易用性的框架。
  • 自帶數據同步策略,解決服務端渲染最大難點。把服務端渲染好的數據,拿到客戶端重用,這個在沒有框架的時候,是很是麻煩的。
  • 擁有豐富的插件,讓咱們能夠在使用的時候按需使用。
  • 配置靈活:能夠根據項目要求的不一樣快速靈活的進行配置。

缺點: 必須遵循它的規範(如:必須在 getInitialProps 中獲取數據),寫法固定,不利於拓展。git

展望 Serverless

  • Serverless —— 無服務架構
  • Serverless 不表明不再須要服務器了,而是說:開發者不再用過多考慮服務器的問題,計算資源做爲服務而不是服務器的概念出現
  • Serverless 確定會火,前端能夠不考慮部署、運維、環境等場景,直接編寫函數來實現後端邏輯,對生產力上有着顯著的提高
  • 有了 Serverless ,以後的 SSR 能夠稱爲 Serverless Side Rendering
  • 由於對 Serverless 不是很瞭解,只知道它的概念以及帶來的影響是什麼,因此不敢過多妄言,有興趣的同窗能夠自行了解

看懂 Serverless,這一篇就夠了
理解serverless無服務架構原理(一)
什麼是Serverless無服務器架構?github

常見問題

客戶端須要使用 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

image.png

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 Hooks 詳解 【近 1W 字】+ 項目實戰

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

相關文章
相關標籤/搜索