React SSR 服務端渲染實現


ReactDOMServer

ReactDOMServer 對象允許你將組件渲染成靜態標記。通常,它被使用在 Node 服務端上:

// ES modules import ReactDOMServer from 'react-dom/server'; // CommonJS var ReactDOMServer = require('react-dom/server');

概覽

下述方法可以被使用在服務端和瀏覽器環境。

下述附加方法依賴一個只能在服務端使用的 package(stream)。它們在瀏覽器中不起作用。


參考

renderToString()

ReactDOMServer.renderToString(element)

將 React 元素渲染為初始 HTML。React 將返回一個 HTML 字符串。你可以使用此方法在服務端生成 HTML,並在首次請求時將標記下發,以加快頁面加載速度,並允許搜索引擎爬取你的頁面以達到 SEO 優化的目的。

如果你在已有服務端渲染標記的節點上調用 ReactDOM.hydrate() 方法,React 將會保留該節點且只進行事件處理綁定,從而讓你有一個非常高性能的首次加載體驗。


renderToStaticMarkup()

ReactDOMServer.renderToStaticMarkup(element)

此方法與 renderToString 相似,但此方法不會在 React 內部創建的額外 DOM 屬性,例如 data-reactroot。如果你希望把 React 當作靜態頁面生成器來使用,此方法會非常有用,因為去除額外的屬性可以節省一些字節。

如果你計划在前端使用 React 以使得標記可交互,請不要使用此方法。你可以在服務端上使用 renderToString 或在前端上使用 ReactDOM.hydrate() 來代替此方法。


renderToNodeStream()

ReactDOMServer.renderToNodeStream(element)

將一個 React 元素渲染成其初始 HTML。返回一個可輸出 HTML 字符串的可讀流。通過可讀流輸出的 HTML 完全等同於 ReactDOMServer.renderToString 返回的 HTML。你可以使用本方法在服務器上生成 HTML,並在初始請求時將標記下發,以加快頁面加載速度,並允許搜索引擎抓取你的頁面以達到 SEO 優化的目的。

如果你在已有服務端渲染標記的節點上調用 ReactDOM.hydrate() 方法,React 將會保留該節點且只進行事件處理綁定,從而讓你有一個非常高性能的首次加載體驗。

注意:

這個 API 僅允許在服務端使用。不允許在瀏覽器使用。

通過本方法返回的流會返回一個由 utf-8 編碼的字節流。如果你需要另一種編碼的流,請查看像 iconv-lite 這樣的項目,它為轉換文本提供了轉換流。


renderToStaticNodeStream()

ReactDOMServer.renderToStaticNodeStream(element)

此方法與 renderToNodeStream 相似,但此方法不會在 React 內部創建的額外 DOM 屬性,例如 data-reactroot。如果你希望把 React 當作靜態頁面生成器來使用,此方法會非常有用,因為去除額外的屬性可以節省一些字節。

通過可讀流輸出的 HTML,完全等同於 ReactDOMServer.renderToStaticMarkup 返回的 HTML。

如果你計划在前端使用 React 以使得標記可交互,請不要使用此方法。你可以在服務端上使用 renderToNodeStream 或在前端上使用 ReactDOM.hydrate() 來代替此方法。

 

//下面為轉載部分呢內容

早期的SSR(Server Side Rendering) : 服務端渲染,在最早期的網頁開發時代,就是采用這種形式,由服務端渲染出頁面結構,直接返回給客戶端,首屏頁面直出,SEO也較友好,但頁面路由跳轉會導致整個頁面重新加載;

**CSR(Client Side Rendering):**隨着前后端分離、提高開發效率的思想逐漸流行,react、vue等前端框架的默認支持,前端路由的無刷新切換頁面,逐漸成為目前前端開發的主流形式。服務端返回的只是一個空頁面,通過客戶端加載js,填充生成整個頁面展現給客戶,減小了服務端的壓力,但首屏等待時間較長,而且由於服務端返回空頁面,導致對SEO並不友好。

**新時代的SSR:**為了解決CSR的痛點,開發者們重新把目光投向了SSR,結合CSR, 采用同構的模式,刷新SSR直出頁面結構,之后客戶端接管頁面,前端路由無刷新切頁,兼具了SSR和CSR的優點。目前結合react和vue也有了對應的SSR框架,next.js和nuxt.js.

本文通過實現簡單的demo, 理解React SSR 服務端渲染的過程。

**同構:**同構這個概念存在於 Vue,React 這些新型的前端框架中,同構實際上是客戶端渲染和服務器端渲染的一個整合。我們把頁面的展示內容和交互寫在一起,讓代碼執行兩次。在服務器端執行一次,用於實現服務器端渲染,在客戶端再執行一次,用於接管頁面交互。

SSR 之所以能夠實現,本質上是因為虛擬 DOM 的存在,dom的操作在服務端是無法實現的,而虛擬 DOM 是真實 DOM 的一個 JavaScript 對象映射,React 在做頁面操作時,實際上不是直接操作 DOM,而是操作虛擬 DOM,也就是操作普通的 JavaScript 對象,這就使得 SSR 成為了可能。在服務端將虛擬dom映射成字符串返回,在客戶端將虛擬dom映射為真實dom掛載到頁面上。

SSR一般都需要一個node服務器作為中間層,由node處理服務端渲染,以及轉發客戶端到數據服務器的請求。

1. 配置webpack

既然需要node中間層, 那么就必須有node服務代碼和客戶端代碼的入口,配置兩份webpack配置

客戶端 webpack.client.js:

const path = require('path');
module.exports = {
  mode: 'development',
  entry: './src/client/index.js',
  output: {
    filename: 'index.js',
    path: path.resolve(__dirname, 'public')
  },
  module: {
    rules: [
      { 
        test: /\.jsx?$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
      }
  }
  resolve: {
    extensions: [".js", ".jsx"], //引入文件時支持省略后綴,配置越多性能消耗越多
    alias: {
        "@": path.resolve(__dirname, "../src"), //引用文件時可以用“@”代表“src”的絕對路徑,樣式文件中為“~@”
    }
  }
}

服務端 webpack.server.js

const path = require('path');
const nodeExternals = require('webpack-node-externals');
module.exports = {
  target: 'node',
  mode: 'development',
  entry: './src/server/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'build')
  },
  externals: [nodeExternals()],
  module: {
    rules: [
      { 
        test: /\.jsx?$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
      }
  }
  resolve: {
    extensions: [".js", ".jsx"], //引入文件時支持省略后綴,配置越多性能消耗越多
    alias: {
        "@": path.resolve(__dirname, "../src"), //引用文件時可以用“@”代表“src”的絕對路徑,樣式文件中為“~@”
    }
  }
}

webpack-node-externals插件是用於在node環境下三方模塊不被打包到最終的源碼中,因為node環境下的npm已經安裝了這些依賴;target: node 是讓node 的核心模塊不被webpack打包。

2. 配置路由,前后端同構 --- react-router-config;

對於頁面代碼我們使用的同一套,只是前后端使用的路由並不同,客戶端使用BrowserRouter, 而react-router-dom為客戶端渲染提供了StaticRouter, 對於路由的渲染管理建議使用react-router-config

路由配置文件:

import App from "./containers/App"
import Home from "./containers/Home";
import Login from "./containers/Login";
import Personal from "./containers/Personal";
import NotFound from "./containers/NotFound";

const routes = [
  {
    path: "/",
    component: App,
    loadData: App.loadData,
    routes:[
      {
        path: "/",
        component: Home,
        exact: true,
        // 每個路由組件的靜態方法就是為在服務端的store灌入初始數據
        loadData: Home.loadData,
      },
      {
        path: "/login",
        exact: true,
        component: Login,
      },
      {
        path: "/personal",
        exact: true,
        component: Personal
      },
      {
        component: NotFound,
      }
    ]
  }
]

export default routes;

client端入口路由:

import { renderRoutes } from "react-router-config";
import routes from '../Router';
const App = () => {
  return <Provider store={getClientStore()}>
      <BrowserRouter>
        {renderRoutes(routes)}
      </BrowserRouter>
    </Provider>
}
// 掛載到頁面
ReactDom.render(<App/>, document.querySelector('#root'))
server端入口路由:
import { renderRoutes } from "react-router-config";
import routes from '../Router';
const App = () => {
  return <Provider store={getClientStore()}>
      <StaticRouter location={url} context={{}}>
          {renderRoutes(routes)}
       </StaticRouter>
    </Provider>
}
// 轉換為字符串返回
return ReactDom.renderToString(<App/>)
StaticRouter的匹配需要手動傳入匹配的路由地址 location={url}。

3. 結合Redux實現首頁的數據直出

node轉發請求, node端我采用了koa, 使用koa-server-http-proxy做代理請求

import proxy from 'koa-server-http-proxy';
...
app.use(proxy('/api', {
  target: 'http://xxx.com',
  changeOrigin: true
}))
...

store的創建:

// 服務端store
// 服務器端的 Store 是所有用戶都要用的,每個用戶訪問的時候,這個函數重新執行,為每個用戶提供一個獨立的 Store, 而不是提前創建好的一個單例:
export const getServerStore = (ctx) => createStore(reducer, applyMiddleware(logger, thunk.withExtraArgument(serverHttp)));

// 客戶端store
export const getClientStore = () => {
    const initState = window._content.state;
    return createStore(reducer, initState, applyMiddleware(logger, thunk.withExtraArgument(clientHttp)));
}

同構的存在服務端的初始頁面數據請求不需要代理,而客戶端需要代理,解決方案:

axios構建兩個實例clientHttp 和 serverHttp,設置不同的baseURL,在createStore應用redux-thunk中間件時 thunk.withExtraArgument(api)傳入,在異步dispatch的第三個參數獲取到axios實例,通過該實例派發請求。

首屏數據的獲取, 通過redux和dispatch去獲取

....server端解析頁面需要的數據
import routes from '../Router';
// 獲取匹配到的路由
  const matchedRoutes = matchRoutes(routes, ctx.url);
  // 得到數據請求數組 --- 一組promise
  const promiseDatas =  [];
  matchedRoutes.forEach(({route}) => {
    if(route.loadData) {
      promiseDatas.push(route.loadData(store));
    }
  })
  // 執行數據請求,為store灌入初始數據
Promise.all(promises).then(() => {
  // 生成要返回的頁面
})

................................
...組件中

import {getNewsList} from './store/actions';
import {useSelector, useDispatch} from 'react-redux';
import styles from './index.css';
const Home = () => {
  const name = useSelector(({root}) => root.name);
  const list = useSelector(({home}) => home.list);
  const dispatch = useDispatch();
  useEffect(() => {
    if(!list.length) {
      dispatch(getNewsList());
    }
  }, [])
  return <div>
    <h1 className={styles.title}>Home Page !!!</h1>
    <h2>name: {name}</h2>
    <ul>
      {
        list.map(({title, content}) => <li key={title}>
            <h4>{title}</h4>
            <p>{content}</p>
          </li>)
      }
    </ul>
    <button onClick={() => console.log('click button')}>click</button>
  </div> 
}

// 此靜態方法為服務端用來做數據直出
Home.loadData = (store) => {
  return store.dispatch(getNewsList());
}
export default Home;

數據的脫水和注水

服務端渲染之后,拿到了首頁數據,但客戶端會再次渲染,store是空的。解決辦法:在服務端渲染的時候將獲取到的數據賦值一個全局變量(注水),客戶端創建的store以這個變量的值作為初始值(脫水),這樣就做到的首屏的數據直出。

// server端注水,再返回的模板字符串中注入數據
`<!DOCTYPE html>
  <html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/antd/4.8.2/antd.min.css" integrity="sha512-CPolmBEaYWn1PClN5taQQ0ucEhAt+9j7+Tiog/SblkFjZ5k6M3TioqmlpcHKwUhIcsu1s7lgnX4Plsb6T8Kq5A==" crossorigin="anonymous" />
    <title>React-SSR</title>
  </head>
  <body>
    <div id="root">${contents}</div>
    <script>
      window._content = {
        state: ${JSON.stringify(store.getState())}
      }
    </script>
    <script src="/index.js"></script>
  </body>
  </html>`

// 客戶端脫水
export const getClientStore = () => {
    const initState = window._content.state;
    return createStore(reducer, initState, applyMiddleware(logger, thunk.withExtraArgument(clientHttp)));
}

4. 首屏樣式的直出

webpack配置css解析

// webpack.client.js --- 客戶端正常配置css-loader和style-loader
.....
module: {
    rules: [
      { 
        test: /\.css$/i,
        use: [
          'style-loader', 
          {
            loader: 'css-loader',
            options: {
              importLoaders: 1,
              esModule: false,
              modules: {
                compileType: 'module',
                localIdentName: '[name]_[local]_[hash:base64:5]'
              },
            }
          }
        ]
      },
    ]
  }
.....


// webpack.server.js --- server端使用isomorphic-style-loader代替style-loader, 因為style-loader是生成style標簽掛載到頁面的,服務端明顯不合適

module: {
    rules: [
      { 
        test: /\.css$/,
        use: ['isomorphic-style-loader', {
          loader: 'css-loader',
          options: {
            esModule: false,
            importLoaders: 1,
            modules: {
              compileType: 'module',
              localIdentName: '[name]_[local]_[hash:base64:5]'
            },
          }
        }]
      },
    ]
  }

服務端改造

import React from 'React';
import {renderToString} from 'react-dom/server';
import { renderRoutes } from "react-router-config";
import StyleContext from 'isomorphic-style-loader/StyleContext';
// react服務端渲染路由需要使用StaticRouter
import {StaticRouter} from 'react-router-dom';
import {Provider} from 'react-redux';

export const render = (store, routes, url, context) => {
  const css = new Set();
  const insertCss = (...styles) => {
    styles.forEach(style => {
      css.add(style._getCss());
    })
  };
  const contents = renderToString(
    <StyleContext.Provider value={{ insertCss }}>
      <Provider store={store}>
        // context可以在服務端渲染時在組件的props.staticContext中獲取到,以區分兩端環境
        <StaticRouter location={url} context={{}}>
          {renderRoutes(routes)}
        </StaticRouter>
      </Provider>
    </StyleContext.Provider>
  );
  return `<!DOCTYPE html>
  <html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <style id="ssr-style">${[...css].join('\n')}</style>
    <title>React-SSR</title>
  </head>
  <body>
    <div id="root">${contents}</div>
    <script>
      window._content = {
        state: ${JSON.stringify(store.getState())}
      }
    </script>
    <script src="/index.js"></script>
  </body>
  </html>`;
}

客戶端使用

import useStyles from 'isomorphic-style-loader/useStyles';
const Home = () => {
 ...
 // 區分server端和client端
 if(props.staticContext) {
   useStyles(styles);
 }
  return <div>
    ....
  </div> 
}

最后貼一下依賴版本

"dependencies": {
    "@babel/core": "^7.12.3",
    "@babel/plugin-proposal-function-bind": "^7.12.1",
    "@babel/plugin-transform-runtime": "^7.12.1",
    "@babel/preset-env": "^7.12.1",
    "@babel/preset-react": "^7.12.1",
    "@babel/preset-stage-0": "^7.8.3",
    "@babel/runtime": "^7.12.1",
    "axios": "^0.21.0",
    "babel-loader": "^8.1.0",
    "css-loader": "^5.0.1",
    "isomorphic-style-loader": "^5.1.0",
    "koa": "^2.13.0",
    "koa-router": "^9.4.0",
    "koa-server-http-proxy": "^0.1.0",
    "koa-static": "^5.0.0",
    "react": "16.14.0",
    "react-dom": "16.14.0",
    "react-redux": "^7.2.2",
    "react-router-config": "^5.1.1",
    "react-router-dom": "^5.2.0",
    "redux": "^4.0.5",
    "redux-thunk": "^2.3.0",
    "style-loader": "^2.0.0",
    "webpack": "5.4.0",
    "webpack-cli": "^4.1.0",
    "webpack-node-externals": "^2.5.2"
  },
  "devDependencies": {
    "redux-logger": "^3.0.6",
    "webpack-merge": "^5.3.0"
  }

部分摘自juejin :https://juejin.cn/post/6907164030385782791


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM