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

posted @ 2020-12-25 18:20  hero~  阅读(1097)  评论(0编辑  收藏  举报