看不懂你打我係列;REACT-SSR 服務端渲染

先附上github地址github.com/hzfvictory/…,方便你們更容易理解,否則後面不少地方會一頭霧水。javascript

歡迎你們點star,提issue,一塊兒進步!😄php

客戶端渲染與服務端渲染

CSR:css

頁面渲染是JS負責進行的
複製代碼

瀏覽器發送請求–>服務器返回HTML–>瀏覽器發送bundle.js請求–>服務器返回bundle.js–>瀏覽器執行bundle.js中的react代碼完成渲染html

SSR:前端

服務器端直接返回HTML讓瀏覽器直接渲染
複製代碼

瀏覽器發送請求–>服務器運行React代碼生成頁面–>服務器返回頁面vue

傳統CSR的弊端:java

  1. 因爲頁面顯示過程要進行JS文件拉取和React代碼執行,在這個渲染過程當中至少涉及到兩個 HTTP請求週期(html+js),因此會有必定的耗時,首屏加載時間會比較慢。node

  2. 對於SEO(Search Engine Optimazition,即搜索引擎優化),徹底無能爲力,由於搜索引擎爬蟲只認識html結構的內容,而不能識別JS代碼內容。react

SSR的弊端:webpack

ssr的出現,就是爲了解決這些傳統CSR的弊端
複製代碼

在 React 中使用 ssr 技術,咱們讓 React 代碼在服務器端先執行一次,使得用戶下載的 HTML 已經包含了全部的頁面展現內容,這樣,頁面展現的過程只須要經歷一個 HTTP 請求週期,TTFP(Time To First Page) 時間獲得一倍以上的縮減

可是使用 ssr 這種技術,將使本來簡單的 React 項目變得很是複雜

  1. 相對於僅僅須要提供靜態文件的服務器,ssr中使用的渲染程序天然會佔用更多的CPU和內存資源

  2. 在服務器生成的頁面因此,一些經常使用的瀏覽器API可能沒法正常使用,好比window、docment和alert等,若是使用的話須要對運行的環境加以判斷

  3. 開發調試會有一些麻煩,由於涉及了瀏覽器及服務器,對於SPA的一些組件的生命週期的管理會變得複雜

  4. 可能會因爲某些因素致使服務器端渲染的結果與瀏覽器端的結果不一致,項目的可維護性會下降,代碼問題的追溯也會變得困難

因此,使用 ssr 在解決問題的同時,也會帶來很是多的反作用,有的時候,這些反作用的傷害比起 ssr 技術帶來的優點要大的多。通常建議ssr,除非你的項目特別依賴搜索引擎流量,或者對首屏時間有特殊的要求,不然不建議使用 ssr,若是隻對seo有要求可以使用 prerender預渲染

SSR的實現本質

這裏介紹的是ssr,是基於React 的SPA項目,不是像 thinkphp、jsp、nodeJs+ejs 這種純後端直出渲染方式,因此這種大多數只是針對首屏的ssr,由於瀏覽器的路由跳轉方式是用的H5的history APIwindow.history.pushState() ,使得咱們便可以修改 url 也能夠不刷新頁面,因此是不會走服務端的【能夠經過預加載獲取須要的數據】。

ssr 之因此可以實現,本質上是由於虛擬 DOM 的存在

ssr 的工程中,React 代碼會在客戶端和服務器端各執行一次,由於代碼在 Node 環境下是沒有DOM這個概念的,因此在React 框架中引入了一個概念叫作虛擬 DOM,React 在作頁面操做時,實際上不是直接操做 DOM,而是操做虛擬 DOM,也就是操做普通的 JavaScript 對象,這就使得 ssr 成爲了可能。在服務器,我能夠操做 JavaScript 對象,判斷環境是服務器環境,咱們把虛擬 DOM 映射成字符串輸出;在客戶端,我也能夠操做 JavaScript 對象,判斷環境是客戶端環境,我就直接將虛擬 DOM 映射成真實 DOM,完成頁面掛載。

方案篩選

  • next.js/nuxt.js 成本低,安心的寫頁面就好了,無需過多關心服務端路由
  • renderer 實現spa項目的服務端預渲染
  • 使用谷歌 rendertron 實現spa項目的服務端渲染 【聽說會被判做弊的,降權處理】
  • 秉承學習的態度瞭解下基本原理,選擇了本身去搭,(中間斷了一段時間,如今又從新拾起來),以前看到有人用 react + redux + Express 搭ssr的文章,因此基於對dva和koa的熟悉和特別喜愛,就直接選擇了dva-core + koa 作狀態管理搭建。

Koa實現基礎版本的SSR

不使用koa-router

const Koa = require('koa');
const app = new Koa();

app.use((ctx) => {
  if (ctx.path === '/') {
    ctx.body =
      ` <html> <head> <title>禾口和ssr</title> </head> <body> <h1>hello</h1> <h2>world</h2> </body> </html> `;
  }
})
const server = app.listen('9999', () => {
  const {port} = server.address();
  console.log(`http://localhost:${port}`)
})
複製代碼

使用koa-router

const Koa = require('koa');
const app = new Koa();
const route = require("koa-router")() // 這裏也可使用構造函數

route.get("/", (ctx) => {
  ctx.body =
    ` <html> <head> <title>禾口和ssr</title> </head> <body> <h1>hello</h1> <h2>world</h2> </body> </html> `
})

app.use(route.routes());
app.use(route.allowedMethods()); //自動設置響應頭ctx.status完善response響應頭

const server = app.listen('9999', () => {
  const {port} = server.address();
  console.log(`http://localhost:${port}`)
})
複製代碼

這樣一個簡單的服務端渲染就搞定了,服務器端直接返回HTML讓瀏覽器直接渲染,並且網頁源代碼中是有這些dom信息的對seo很是友好,咱們react、vue這些都是經過webpack引入了js,全部的功能頁面展現通通由js完成。

實現React組件的服務端渲染

到這一步已經不能直接用node啓動服務了,由於沒有babel, React不會轉化成createElement的形式,並且使用node也不能直接使用import導入方式。

隨便編寫一個React的組件

// src/pages/home
import React from 'react';
const Home = () => {
  return (
    <div> <div>Home組件</div> </div>
  )
}
export default Home
複製代碼

而後咱們把當前組件,使用服務員渲染出來,看下面配置:

Webpack base

// config/webpack.base.js
const path = require('path')

module.exports = {
  module: {
    rules: [{
      test: /\.js|jsx$/,
      loader: 'babel-loader',
      exclude: /node_modules/,
      options: {
        presets: ['@babel/preset-react', ['@babel/preset-env', {
          targets: {
            browsers: ['last 2 versions']
          }
        }]]
      }
    }]
  }
}
複製代碼

服務器端 Webpack 配置

服務端運行的代碼若是須要依賴 Node 核心模塊或者第三方模塊,就再也不須要把客戶端的一些模塊代碼打包到最終代碼中了。由於環境已經安裝這些依賴,能夠直接引用。這樣一來,就須要咱們在 webpack 中配置:target:node,並藉助 webpack-node-externals 插件,解決第三方依賴打包的問題。

// config/webpack.server.js
const path = require('path')
const nodeExternals = require('webpack-node-externals')
const merge = require('webpack-merge')
const config = require('./webpack.base')

const serverConfig = {
  target: 'node', // 編譯出能讓node識別的代碼 https://webpack.docschina.org/concepts/targets/
  mode: 'development', // 這裏的mode要特別注意
  entry: './src/server/index.js', // 對應服務端的代碼
  // https://webpack.docschina.org/configuration/externals/
  externals: [nodeExternals()], // 爲了忽略node_modules文件夾中的全部模塊
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, '../bundle')
  }
}
module.exports = merge(config, serverConfig)
複製代碼

target: 'node' 和 target: 'web' 的大體區別

// target: 'node'
exports.ids = [0];
exports.modules = {};
// target: 'web' 
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([[0],{
}])
複製代碼
// server/index.js
import Koa from 'koa';
import Router from "koa-router"
import React from "react"; // 必須引入
import {renderToString} from 'react-dom/server'; // react-dom提供的方法

import Home from "../src/pages/home"

const app = new Koa();
const route = new Router()

const content = renderToString(<Home/>);

route.get("/", (ctx) => {
  ctx.body =
    ` <html> <head> <title>禾口和ssr</title> </head> <body> <div id="root">${content}</div> </body> </html> `
})

app.use(route.routes());
app.use(route.allowedMethods());

const server = app.listen('9999', () => {
  const {port} = server.address();
  console.log(`http://localhost:${port}`)
})
複製代碼

以上使用了renderToString, 咱們都知道react-dom提供了四種服務端渲染函數,以下:

  1. renderToString:將 React Component 轉化爲 HTML 字符串,生成的 HTML 的 DOM 會帶有額外屬性:各個 DOM 會有data-react-id屬性,第一個 DOM 會有data-reactroot屬性。
  2. renderToStaticMarkup:將 React Component 轉化爲 HTML 字符串,可是生成 HTML 的 DOM 不會有額外屬性,從而節省 HTML 字符串的大小。
  3. renderToNodeStream: 以流的形式輸出html, 不用像renderToString生成整個html才發送給客戶端。相對於renderToString能更快的響應客戶端,提高頁面渲染速度。
  4. renderToStaticNodeStream:和 renderToNodeStream同樣,也是輸出流,可是html中不帶data-reactroot等屬性。

對於服務端渲染而言

  • renderToString方法渲染的節點會帶有data-react-id屬性, 在前端 react 加載完成後, 前端 react 會認識以前服務端渲染的內容, 不會從新渲染 DOM 節點, 前端 react 會接管頁面, 執行 componentDidMout 綁定瀏覽器事件等 這些在服務端沒完成也不可能執行任務。
  • renderToStaticMarkup 渲染出的是不帶data-react-id的純 html 在前端 react 加載完成後, 以前服務端渲染的頁面會抹掉以前服務端的從新渲染(可能頁面會閃一下). 換句話說 前端react就根本就不認識以前服務端渲染的內容, render 方法會使用 innerHTML 的方法重寫 #react-target 裏的內容

在package添加啓動配置

// package.json
"scripts": {
    "dev": "npm-run-all --parallel dev:build:server dev:start",
    "dev:build:server": "webpack --config config/webpack.server.js --watch",
    "dev:start": "nodemon ./bundle/bundle.js"
}
複製代碼

執行 yarn dev ,打開 http://localhost:9999/ 頁面直接在瀏覽上顯示, 到此,就初步實現了一個React組件是服務端渲染,加入你在組件Home裏面添加一些方法或者調取接口,你會發現這些都沒有執行,因此咱們還須要接下來進一步完善。

同構

要解決上面上面的問題,就須要同構了,所謂同構,通俗的講,就是一套React代碼在服務器上運行一遍,到瀏覽器渲染時在運行一遍,服務端渲染完成頁面結構,瀏覽器端渲染完成事件綁定接口調取(重複加載的js或者css客戶端協調階段時候會進行比對,若是同樣則不渲染了)。

客戶端針對路由打包JS

把打包後的js,注入到html中,這樣到瀏覽器就會再次請求,就能夠完成事件綁定等行爲操做。

咱們要用到react-dom的hydrate

// client/index.js
import React, {Component} from "react"
import ReactDom from 'react-dom';
import {BrowserRouter as Router, Switch} from 'react-router-dom';
import {renderRoutes} from 'react-router-config';
import Loadable from 'react-loadable'; // 這裏是個人一個路由拆分,大家能夠不用

import routes from '../router';

class App extends Component {
  render() {
    return (
      <Router> <Switch> {renderRoutes(routes.routes)} </Switch> </Router>
    )
  }
}

Loadable.preloadReady().then(() => {
  ReactDom.hydrate(
    <App/>, document.getElementById('root'));
})
複製代碼

hydrate() 描述的是 ReactDOM 複用 ReactDOMServer 服務端渲染的內容時儘量保留結構,並補充事件綁定等 Client 特有內容的過程

說白了render() 標籤上沒有惟一的屬性,可是要儘量複用 ssr 的 HTML 結構,因此就出現了hydrate(),可是目前二者都是能夠用的,17版本render()就不在支持ssr

知乎對 ReactDom.hydrate 的解釋

而後配置客戶端的webpack將其編譯打包成js,在服務端html裏面引入。

客戶端 Webpack 配置

客戶端和服務端打包後的輸出目錄

// config/outputPath 
module.exports = {
  OUTPUTCLIENT: 'static',
  OUTPUTSERVER: 'bundle'
}
複製代碼
// config/webpack.client.js
const path = require('path')
const merge = require('webpack-merge')
const config = require('./webpack.base')
const {OUTPUTCLIENT} = require("./outputPath")
const outputPath = `../${OUTPUTCLIENT}`

const clientConfig = {
  mode: 'development',
  entry: path.resolve(__dirname, '../client/index.js'),
  output: {
    filename: 'index.[chunkhash:8].js', // 這裏我用的hash,目的是防止緩存
    path: path.resolve(__dirname, outputPath),
    publicPath: '/'
  },
  module: {
    rules: [
      {
        test: /\.css?$/,
        use: ['style-loader', {// 這裏建議使用style-loader,少許的css直接採用客戶端渲染了
          loader: 'css-loader',
          options: {
            modules: true, // 這要跟服務端保持一致,否則head裏面有樣式,客戶端沒有對應的class
          }
        }]
      },
      {
        test: /\.(png|jpeg|jpg|gif|svg)?$/,
        loader: 'url-loader',
        options: {
          limit: 8000,
          outputPath: outputPath, // 輸入路徑
          publicPath: '/'
        }
      }
    ]
  }
}
module.exports = merge(config, clientConfig)
複製代碼

而後在上面的package.json,裏面添加 "dev:build:client": "webpack --config webpack.client.js --watch",就能對瀏覽器用到的一些js完成打包。

服務端的路由邏輯

服務器端路由代碼相對要複雜一點,須要你把 location(當前請求路徑)傳遞給 StaticRouter 組件,這樣 StaticRouter 才能根據路徑分析出當前所須要的組件是誰。(PS:StaticRouterReact-Router針對服務器端渲染專門提供的一個路由組件。)

// server/index.js
import Koa from 'koa';
import React from "react";
import Router from "koa-router"
import {renderToString} from 'react-dom/server';
import {StaticRouter} from 'react-router-dom';
import Loadable from 'react-loadable';
import routes from '@/router';
import {renderRoutes, matchRoutes} from "react-router-config";

import {renderHTML} from "./tem"

const app = new Koa();
const route = new Router()

route.get(["/:route?", /\/([\w|\d]+)\/.*/], (ctx) => {
  const content = renderToString(
    // 重點是這
    <StaticRouter location={ctx.path}> {renderRoutes(routes.routes)} </StaticRouter>
  );
  ctx.body = renderHTML(content, {})
})
// 這裏要注意下中間件的前後順序
app.use(require('koa-static')(process.cwd() + '/static'));
app.use(route.routes());
app.use(route.allowedMethods());

Loadable.preloadAll().then(() => {
  const server = app.listen('9999', () => {
    const {port} = server.address();
    console.log(`\x1B[33m\x1B[4mhttp://localhost:${port}\x1B[0m`)
  })
});
複製代碼
// server/tem.js
const glob = require('glob');
let project = glob.sync(process.cwd() + '/static/index.*.js');


let path = project[0].split('/')

export const renderHTML = (content, store) => ` <!DOCTYPE html> <html lang="zh"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no" /> <meta name="theme-color" content="#000000"> <title>禾口和ssr</title> </head> <body> <div id="root">${content}</div> <script src=/${path[path.length - 1]}></script> // 這個 '/' 必定要添加,坑了很久 </body> </html> `
複製代碼

CSS樣式問題處理

正常的服務端渲染只是返回了 HTML 字符串,樣式須要瀏覽器加載完 CSS 後纔會加上,這個樣式添加的過程就會形成頁面的閃動,因此在服務端裏面直接添加須要引用的CSS。

咱們不能再使用 style-loader 了,由於這個 webpack loader 會在編譯時將樣式模塊載入到 HTML header 中。可是在服務端渲染環境下,沒有 window 對象,style-loader 進而會報錯。通常咱們換用 isomorphic-style-loader ,同時 isomorphic-style-loader 也會解決頁面樣式閃動的問題,它的原理也不難理解:isomorphic-style-loader 利用 context API,在渲染頁面組件時獲取全部 React 組件的樣式信息,在服務器端輸出 html 字符串的同時,也將樣式插入到 html 字符串當中,將結果一同傳送到客戶端。

由於咱們已經開啓了cssmodules,因此直接導入到head裏面是不會存在樣式衝突的問題。isomorphic-style-loader 已經給咱們提供了一些導入css 的 高階函數 withsSyles

和 hooks useStyles,用的時候比較省事。

看下代碼配置

// config/webpack.client.js
{
  test: [/\.css|less$/],
    use: [
      'style-loader', // 也能夠跟服務端的同樣,就是麻煩點,每次使用css,都須要額外的手續
      {
        loader: 'css-loader',
        options: {
          modules: true,
        }
      },
      'less-loader',
    ]
}
複製代碼
// config/webpack.server.js
{
  test: [/\.css|less$/],
    use: [
      'isomorphic-style-loader',
      {
        loader: 'css-loader',
        options: {
          modules: true,
        }
      },
      'less-loader'  // 必須配置 否則會當成css,視覺可能看不出來,由於客戶端配置了less
    ]
}
複製代碼

服務端首頁

// server/index.js 
// ...
const css = new Set() // 這個必須在路由函數裏面,在外面的話,就會累加出現以前的css
const insertCss = (...styles) => styles.forEach(style => css.add(style._getCss()));

const content = renderToString(
  <StaticRouter location={ctx.path}> <StyleContext.Provider value={{insertCss}}> {renderRoutes(routes.routes)} </StyleContext.Provider> </StaticRouter>
)
 
ctx.body = renderHTML(content, {}, css)
// ....
複製代碼

客戶端也須要配置

// client/index.js
import React from "react"
import ReactDom from 'react-dom';
import {BrowserRouter as Router} from 'react-router-dom';
import {renderRoutes} from 'react-router-config';
import Loadable from 'react-loadable';
import StyleContext from 'isomorphic-style-loader/StyleContext'


import routes from '../router';

const insertCss = (...styles) => {
  const removeCss = styles.map(style => style._insertCss && style._insertCss());
  return () => removeCss.forEach(dispose => dispose && dispose())
}

const App = () => {
  return (
    <Router> {renderRoutes(routes.routes)} </Router>
  )
};

Loadable.preloadReady().then(() => {
  ReactDom.hydrate(
    <StyleContext.Provider value={{insertCss}}> <App/> </StyleContext.Provider>, document.getElementById('root'));
})
複製代碼

這樣服務端和客戶端均可以直接使用isomorphic-style-loader的一些API, 可是有些不重要的頁面,或者不重要的css能夠直接採用客戶端渲染由於客戶端用的是style-loader,就不須要引入高階函數或者useStyles。

在頁面內具體使用

// 函數式組件
import useStyles from 'isomorphic-style-loader/useStyles'
import styles from "./index.css"

const Index = (props) => {
  useStyles(styles)
}  
複製代碼
// 類組件使用
import withStyles from 'isomorphic-style-loader/withStyles'
import styles from "./index.css"
@withStyles(styles) // 須要在webpack.base.js裏面額外配置

class Index extends React.Component {}
複製代碼
// 使用客戶端渲染
import styles from "./index.css"

const Index = () => {
  // 這裏也可使用useStyles 部分使用客戶端渲染
  return (
    <div> <h1 className={styles['title-center']}>message</h1> <h1 className={'title-center'}>message</h1> </div>
  )
} 
複製代碼

而後打開網頁的源代碼就能夠看見head裏面已經有咱們須要的css了。

image-20200702185101916

SSR中異步數據的獲取 + Dva的使用

Dva的使用

以前項目一直用的dva,這裏直接使用的dva-core代替的redux,不會配置的自行查下文檔。

建立 Store:這一部分有坑,要注意避免,你們知道,在客戶端渲染中,用戶的瀏覽器中永遠只存在一個 Store,因此代碼上你能夠這麼寫

const dvaApp = createApp({
  initialState: {},
  models: models,
});
const store = dvaApp.getStore();
export default store;
複製代碼

然而在服務器端,這麼寫就有問題了,由於服務器端的 Store 是全部用戶都要用的,若是像上面這樣構建 StoreStore 變成了一個單例,全部用戶共享 Store,顯然就有問題了。因此在服務器端渲染中,Store 的建立應該i像下面這樣,返回一個函數,每一個用戶訪問的時候,這個函數從新執行,爲每一個用戶提供一個獨立的 Store

const dvaApp = createApp({
  initialState: {},
  models: models,
});

export const getStore =  () => {
  return dvaApp.getStore();
}
複製代碼

別慌,你若是這樣的作的話,redux的數據仍是全部客戶同步公用,由於你的model是一個對象,是靜態導入,這個時候你應該把model寫成函數的形式,這樣後臺每次都能獲取到最新的數據

const menuTree = () => {
  return {
    namespace: 'menuTree',
    state: {
      routes: []
    },
    effects: {
      * reset(payload, {call, put, select, update}) {
         // .........
      },
    },
    reducers: {
      save(state, {payload}) {
        return {...state, ...payload};
      },
    },
  }
};
export default menuTree
複製代碼

換成函數的導出形式就OK了,而後在createApp的方法,原有的models.forEach(model => app.model(model);轉換成 models.forEach(model => app.model(model())); 就OK了。

數據獲取

數據獲取的解決方案是配置路由 route-router-config,結合 matchRoutes,找到頁面上相關組件所需的請求接口的方法並執行請求,這就要求開發者經過路由配置信息,顯式地告知服務端請求內容。

客戶端路由改造

// router/index.js
{
  path: '/login',
  exact: true,
  component: Login,
  loadData: Login.loadData, // 這裏就是請求數據的方法
  title: '登陸頁'
}
複製代碼
// 客戶端組件使用
class Index extends Component {}

Index.loadData = async (store) => {
  store.dispatch({
    type: "menuTree/reset",
  });
  console.log('我試試這個到底加載不');
}
export default Index
複製代碼

服務端代碼

// server/index.js

// 獲取請求的方法
const promises = [];

matchedRoutes.forEach(item => {
  if (item.route.loadData) {
    const promise = new Promise((resolve, reject) => {
      // 這裏用了.then 因此組件裏面必須使用async或者promise
      item.route.loadData(store).then(resolve).catch(reject)
    })
    promises.push(promise);
  }
});
// 這裏要注意的一個問題,你的方法多是異步的,會出現 ctx.body 沒有執行的問題,因此要把這個中間件設置爲異步的

// 爲了確保組件的loadData的方法執行完畢
await Promise.all(promises).then(() => {
  const css = new Set(); // 防止鉤子函數執行兩次
  const insertCss = (...styles) => styles.forEach(style => css.add(style._getCss()));
  const helmet = Helmet.renderStatic();
  const content = renderToString(
    <Provider store={store}> <StaticRouter location={ctx.path}> <StyleContext.Provider value={{insertCss}}> {renderRoutes(routes.routes)} </StyleContext.Provider> </StaticRouter> </Provider>
     )
     ctx.body = renderHTML(content, store, css, helmet)
})
複製代碼

注水和脫水

涉及到數據的預獲取,也是服務端渲染的真正意義。

上面的代碼正常運行是沒問題了,可是發現客戶端和服務端的store,存在不一樣步的問題。

其實也很好理解。當服務端拿到store並獲取數據後,客戶端的js代碼又執行一遍,在客戶端代碼執行的時候又建立了一個空的store,兩個store的數據不能同步。

因此 在服務器端渲染時,首先服務端請求接口拿到數據,並處理準備好數據狀態(若是使用 Redux,就是進行 store 的更新),爲了減小客戶端的請求,咱們須要保留住這個狀態。通常作法是在服務器端返回 HTML 字符串的時候,將數據 JSON.stringify 一併返回,這個過程,叫作注水;在客戶端,就再也不須要進行數據的請求了,能夠直接使用服務端下發下來的數據,這個過程叫脫水。

<script>
   window.context = {
   // 這裏是注水
   state: ${serialize(store.getState())}  // serialize 是爲了防止xss的攻擊
}
</script>
複製代碼
import {create} from 'dva-core';

function createApp(opt) {
  // .....
  return app;
}

// 服務端的redux
const dvaApp = createApp({
  initialState: {},
  models: models,
});
export const getStore = () => {
  return dvaApp.getStore();
}

// 客戶端的redux
export const getClientStore = () => {
  // 須要先拿到服務端的數據, 脫水
  const initialState = window.context ? window.context.state : {};
  const dvaClientApp = createApp({
    initialState,
    models: models,
  });

  return dvaClientApp.getStore();
}
複製代碼

配置代理

服務端是沒有域的存在,因此不會存在跨域的問題,可是在客戶端調取接口還存在跨域的問題,因此還須要配置下代理,代碼以下:

import httpProxy from 'http-proxy-middleware';
import k2c from "koa2-connect"

// 轉發代理
app.use(async (ctx, next) => {
  if (ctx.url.startsWith('/api')) { //匹配有api字段的請求url
    ctx.respond = false // 繞過koa內置對象response ,寫入原始res對象,而不是koa處理過的response
    await k2c(httpProxy({
        target: 'https://api.xxxxx.xxx',
        changeOrigin: true,
        secure: false,
        pathRewrite: {
          '^/api': ''
        }
      }
    ))(ctx, next);
  }
  await next()
})
複製代碼

還能夠安裝koa的代理模塊 koa2-proxy-middleware,用法以下:

const proxy = require('koa2-proxy-middleware');
const options = {
  targets: {
    '/user': {
      // this is option of http-proxy-middleware
      target: 'http://localhost:3001', // target host
      changeOrigin: true, // needed for virtual hosted sites
    },
    '/user/:id': {
      target: 'http://localhost:3001',
      changeOrigin: true,
    },
    '/api/*': {
      target: 'http://localhost:3001',
      changeOrigin: true,
      pathRewrite: {
        '/passager/xx': '/mPassenger/ee', // rewrite path
      }
    },
  }
}
app.use(proxy(options));
複製代碼

源碼也沒幾行,有興趣能夠看下 koa2-proxy-middleware

引入react-helmet

作更完整的SEO

App 組件嵌入到 document.getElementById('root') 節點當中,通常是不包含 head 標籤的,可是單頁應用在切換路由時,可能也會須要動態修改 head 標籤信息,好比 title 內容。也就是說:在單頁面應用切換頁面,不會通過服務端渲染,可是咱們仍然須要更改 document 的 title 內容。

若是直接改客戶端的title,直接就可使用document.title,可是咱們如今要把SEO作好,而後咱們要更改服務端head裏面的meta title等內容,這裏咱們要用到 react-helmet

具體代碼很是簡單

// 客戶端實現方式
import React, {Component, Fragment} from "react"
import {Helmet} from "react-helmet";

class Index extends Component {
  render() {
    return (
      <Fragment> <Helmet> <title>這是login頁</title> <meta name="description" content="這裏是禾口和react-ssr的調研"/> </Helmet> </Fragment>
     )
  }
}
複製代碼
// 服務端實現
import Koa from 'koa';
import React from "react";
import Router from "koa-router"
import {renderToString} from 'react-dom/server';
import {StaticRouter} from 'react-router-dom';
import {Helmet} from 'react-helmet'; // 這裏引入
// ....
const app = new Koa();
const route = new Router()

route.get(["/:route?", /\/([\w|\d]+)\/.*/], (ctx) => {
  // ....
  const helmet = Helmet.renderStatic(); // 這裏獲取下當前的head信息

  const content = renderToString(
    <StaticRouter location={ctx.path}> <StyleContext.Provider value={{insertCss}}> {renderRoutes(routes.routes)} </StyleContext.Provider> </StaticRouter>
  )
  
  
  ctx.body = ` <!DOCTYPE html> <html lang="zh-Hans-CN"> <head> <meta charset="utf-8"> ${helmet.title.toString()} ${helmet.meta.toString()} <link rel="shortcut icon" href="/favicon.ico"> <style>${[...css].join('')}</style> </head> <body> <div id="root">${content}</div> <script src=/index.js></script> </body> </html> `
})
// ... ...
複製代碼

請求token處理

客戶端登陸的時候,把登陸的token,放到瀏覽器的cookie中而且存到redux一份,cookie在服務端能夠經過請求的頁面直接獲取到;因此當用戶刷新頁面的時候,能夠經過頁面請求獲取到token,而後向redux裏面存放一份,這樣客戶端想要獲取token就能夠直接在redux裏面拿了,loadDate函數能夠經過第二個參數傳進獲取。

404頁面

react-router-configmatchRoutes方法,當捕獲爲空數組的時候,說明沒有當前路由,跳轉到404 頁面,這裏面有一個注意的點是,如說有二級或二級以上的路由,這個方法能捕獲第一個路由的方法,因此要判斷當前獲取到的是否是一級路由,並且當前數據還不能爲空。

// server/index.js 
// 判斷404
let hasRoute = matchedRoutes.length === 1 && !!matchedRoutes[0].route.routes
if (hasRoute || !matchedRoutes.length) {
  ctx.response.redirect('/404');
  return;
}
// 添加 ‘/’ 重定向是同樣的套路
複製代碼

安全問題

安全問題很是關鍵,尤爲是涉及到服務端渲染,開發者要格外當心。這裏提出一個點:咱們前面提到了注水和脫水過程,其中的代碼:

<script>
  window.context = {
    initialState: ${JSON.stringify(store.getState())}
   }
</script>
複製代碼

很是容易遭受 XSS 攻擊,JSON.stringify 可能會形成 script 注入,使用 serialize-javascript 庫進行處理,這也是同構應用中最容易被忽視的細節。

另外一個規避這種 XSS 風險的作法是:將數據傳遞個頁面中一個隱藏的 textarea 的 value 中,textarea 的 value 天然就不怕 XSS 風險了。

優化

  1. 客戶端js拆包,壓縮代碼
  2. 客戶端打包的js帶有hash後綴
  3. 使用copy-webpack-plugin,直接把須要的文件,打包到對應的文件夾。
  4. 中間件轉發代理 跨域等
  5. 靜態資源使用cdn
  6. 服務端使用緩存
  7. 對服務端壓力過大的時候,切換到客戶端渲染
  8. nodeJs/ReactJs的版本升級

遇到的問題彙總

  1. 二級菜單的時候獲取到靜態資源的路徑,帶着第一級菜單的路徑
  2. 服務端導入css 的時候,css是有作hash 處理不能正確的加載css (cssmodules)
  3. 服務端導入css時發生在componentWillMount周期函數,不能在componentDidMount,此時已經到客戶端了。
  4. koa的路由不像express那樣不能直接使用 ***** , (可能能夠,在我這報錯)
  5. 中間件的順序、和異步時候 ctx.body='' 的問題
  6. react-helmet 使用時,服務端沒有顯示設置的title等信息 (在最外層導入)
  7. 注水的時候,注意redux客戶端和服務端的區別和聯繫
  8. 客戶端路由使用的history,路徑跳轉不訪問koa的路由
  9. ssr 部署代碼體積特別大 ,添加併發,公共單獨拆出,添加cdn
  10. pm2環境變量的問題(從新加載歷史遺留要清楚)
  11. 開啓cssModules後把antd的樣式也編譯了
  12. 添加一個常量數組,用來表示那些必須用來服務端渲染,不能太多,影響性能【記得去重】
  13. 從別的頁面跳轉過來的,爲何打開網頁源代碼有渲染好的html,不該該只有首屏渲染嗎?【打開控制檯至關於從新渲染了】
  14. 服務端獲取了數據,客戶端怎麼判斷已經獲取了,再也不調取接口
  15. 服務端有了css客戶端還須要嗎?
  16. ssr怎麼進行參數的傳輸和獲取?

上面的問題,均已解決,可能文章介紹的不具體,具體以源碼爲準。

參考文檔

從零到一搭建React SSR工程架構

知乎 rendertron

本文的github地址

喜歡的mark👍

相關文章
相關標籤/搜索