捋一捋React SSR 服务端渲染实现

863 阅读6分钟

早期的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"
  }