react ssr 服务端渲染入门

2,725 阅读14分钟

react ssr 服务端渲染入门

前言

前后端同构,作为针对单页应用 SEO 优化乏力、首屏速度瓶颈等问题而产出的解决方案,近来在 react、vue 等前端技术栈中都得到了支持。当我们正打算抛弃传统的纯服务端渲染模式,拥抱前后端分离的最佳实践时,有些人却已经从单页应用的格局里跳出,重新去思考和定义服务端渲染。

为什么要用服务端渲染?

  1. 加快首屏渲染,减少白屏时间

与传统的web项目直接获取服务端渲染好的HTML不同,单页面应用使用JavaScript在脚本客户端生成HTML来呈现内容,用户需要等待JS解析执行完成后才能看到页面,这就使得白屏加载时间变长,影响用户体验。

  1. SEO 友好

对于单页面应用,当搜索引擎的爬虫爬取网站HTMl文件时,通常情况下单页面应用中没有任何的内容,仅有<div id="root"> </div>这么一句话,从而影响排名。

因此,业界借鉴传统的服务端渲染的方案,提出在服务端执行前端框架(React/Vue/Angular)代码生成HTML,然后将渲染好的HTML直接返回给客户端。

图片引自 https://www.jianshu.com/p/a3bce57e7349

技术原理

以React为例,首先我们让React代码在服务端执行一次,使得用户下载的HTML已经包含了所有的页面展示内容(达到新增SEO的目的)。同时,用户不需要等到JavaScript代码全部执行完就可以看到页面效果,增强用户体验。之后,我们让React在客户端再次执行,为HTML页面中的内容添加数据及事件的绑定,页面就具备了React的各种交互能力。

核心API

服务端:使用 ReactDOMServer.renderToString | ReactDOMServer.renderToNodeStream 生成HTML,并且在首次请求下发。

客户端:使用 ReactDOM.hydrate 根据服务端返回的HTML进行 hydrate 操作。React 会尝试在已有标记上绑定事件监听器(从服务端返回的HTMl是不带任何事件的)。

在SSR项目中渲染组件

技术栈: React + Koa2 + Webpack

1. 使用koa搭建服务端环境

新建文件夹,并且初始化项目

mkdir ssr-demo && cd ssr-demo

npm init -y

安装Koa环境

cnpm install --save koa

在项目根目录创建app.js,监听8888端口,当请求根目录时,返回一些HTML

// app.js
const Koa = require('koa');
const app = new Koa();

app.use(async (ctx) => {
  ctx.body = `
  <html>
     <head>
         <title>ssr demo</title>
     </head>
     <body>
        <div style="color: red"> Hello World </>
     </body>
  </html>
  `
})

app.listen(8888);
console.log('app is starting at port 8888');

在终端输入命令启动服务 node app.js

访问本地的 http://localhost:8888/,可以看到HTMl返回了。

2.在服务端编写React代码

我们已经启动了一个Node服务器,下一步我们需要在服务器上编写React代码(也可以是Vue或者是其他框架语言),我们创建一个React组件,并且在App中返回。

安装React环境,创建src/components文件夹,新建home.js文件

cnpm install --save-dev React

mkdir src && cd src && mkdir components && cd components && touch home.js

用jsx编写一个最简单的React组件

import React from 'react'

const home = () => {
  return <div> This is a React Component</div>
}

export default home;

并且在app.js中引用

const Koa = require('koa');
const { renderToString } = require('react-dom/server');
const Home = require('./src/components/home');
const app = new Koa();

app.use(async (ctx) => {
  ctx.body = renderToString(<Home />)
})

app.listen(8888);
console.log('app is starting at port 8888');

然而这段代码并不会成功运行成功。原因如下

  1. 当前是在Node环境下,Node不能识别import和export。这二者属于ESM语法,而Node遵循的是common.js规范

  2. Node不能识别JSX语法

不过,幸运的是Babel可以帮助我们将import和export转化成common.js的规范,同时Babel也提供了插件将JSX语法转化成正常的JavaScript。

为了方便起见,我们直接使用Babel的@babel/preset-env和@babel/preset-react预设。Babel的预设是一系列插件的集合。包括了用来转化成Commonjs的 babel-plugin-transform-modules-commonjs 插件,也包括了转化JSX的 babel-plugin-transform-react-jsx 等一系列插件)

@babel/register

Babel 其中的一种使用方法是通过 require 钩子(也可以结合webpack等其他工具使用,这里为了方便理解暂时用register举例)。require 钩子 将自身绑定到 node 的 require 模块上,并在运行时进行即时编译。 这和 CoffeeScript 的 coffee-script/register 类似。

这是 koa 官方给出的@babel/register使用方法,当我们引入了@babel/register之后,就会在require方法中注入Babel钩子,并且在运行时进行即时编译。

require('babel-core/register');
// require the rest of the app that needs to be transpiled after the hook
const app = require('./app');

所以需要对我们目前的内容进行改造

app.js

require('@babel/register');

const app = require('./server').default;

app.listen(8888);
console.log('app is starting at port 8888');

新建server.js

const Koa = require('koa');
import React from 'react';
const { renderToString } = require('react-dom/server');
const Home = require('./src/components/home').default;
const app = new Koa();

app.use(async (ctx) => {
  ctx.body = renderToString(<Home />)
})

app.listen(8001);
console.log('app is starting at port 8888');
export default app;



在项目根目录上创建babel的配置文件 babel.config.js

module.exports = function(api) {
  api.cache(true);
  return {
    presets: [
      ['@babel/preset-env', {
        targets: {
          node: true,
        },
        modules: 'commonjs',
        useBuiltIns: 'usage',
        corejs: { version: 3, proposals: true },
      }],
      '@babel/preset-react',
    ],
  }
}

当然,别忘记了安装相关的依赖

npm install --save-dev @babel/core @babel/preset-env @babel/preset-react @babel/register react-dom

大功告成! 运行 node app.js

3.同构的概念

通过上面的例子,我们已经能够将React组件渲染到页面。为了讲明白同构的概念,下面我们为组件绑定一个点击事件。

import React from 'react';

const Home = () => {
  return <div> This is a React Component
    <button onClick={()=>{alert('click')}}>click</button>
  </div>
}

export default Home;

重新运行代码,刷新页面(由于我们的工程里没有集成热更新,所以每次修改还需要重启并且刷新页面)。我们会发现,点击按钮后onClick事件并不会执行。这是因为renderToString()方法只渲染了组件的内容,并不会绑定事件(DOM的宿主是浏览器)。因此我们需要将React代码在服务端执行一遍,在客户端再执行一遍,这种服务端和客户端公用一套代码的方式就称之为同构。

4.在客户端执行React代码

之前我们说过,React代码在服务端执行的时候只能返回HTML页面,但是不具备任何交互。需要我们将React代码在客户端重新再执行一遍,确保页面能响应onClick等事件。React提供了 hydrate 方法。

ReactDOM.hydrate(element, container[, callback])

为了能在客户端执行这段代码,我们需要在模板中手动引入该代码

新建client-ssr.js

import React from 'react'
import {hydrate} from 'react-dom'
import Home from './src/components/home'

hydrate(
   <Home />,
  document.getElementById('app')
)

新建template.js

export default function template(content = "") {
  let page = `<!DOCTYPE html>
              <html lang="en">
              <head>
                <meta charset="utf-8">
                <link rel="icon" href="data:;base64,=">
              </head>
              <body>
                <div id="app">
                  ${content}
                </div>
                <script src="/asset/client-ssr.js"></script>
              </body>
              `;
  return page;
}

修改server.js 将template的内容作为结果返回

const Koa = require('koa');
import React from 'react';
import template from './template';
const { renderToString } = require('react-dom/server');
const Home = require('./src/components/home').default;
const app = new Koa();

app.use(async (ctx) => {
  ctx.body = template(renderToString(<Home />)) 
})

app.listen(8001);
console.log('app is starting at port 8888');
export default app;

重启,发现报错了!

由于我们的client-ssr中是React的JSX语法,直接返回给浏览器是解析不了的,需要用babel解析成浏览器能识别的JavaScript。

webpack打包

安装webpack依赖和命令行工具

cnpm install --save-dev webpack webpack-cli

新建webpack配置文件,另外我们需要安装babel-loader去解析我们的JSX语法

const path = require('path');

module.exports = {
  mode: 'development',
  entry: {
    client: './client-ssr',
  },
  output: {
    path: path.resolve(__dirname, 'asset'),
    filename: "[name].js"
  },
  module: {
    rules: [
      { test: /\.js$/, exclude: /node_modules/, loader: "babel-loader" }
    ]
 }
}

终端运行 npx webpack,打包client-ssr.js文件。

使用koa-static指定根目录

到目前为止,我们的文件已经准备好了。但是在访问的时候会发现,我们在template.js中的/asset/client.js文件并没有拿到。所以我们需要告诉当前服务,项目的根目录在哪里,服务端才能正确返回我们需要的文件,这里使用 koa-static 中间件。

修改server.js

import Koa from 'koa';
import serve from 'koa-static';
import path from 'path';
import React from 'react';
import template from './template';
import { renderToString } from 'react-dom/server';
import Home from './src/components/home';

const app = new Koa();

app.use(serve(path.resolve(__dirname)));

app.use(async (ctx) => {
  ctx.body = template(renderToString(<Home />)) 
})

export default app;

重新启动服务器,点击click按钮,成功了!再次在客户端渲染后,我们的页面能正常相应click事件了。

在SSR项目中使用路由 (路由同构)

1. 在客户端中使用路由

同样的,在使用路由时,我们需要在服务端和客户都各配置一遍路由。首先我们安装react-router-dom

为了使得客户端和服务端的路由能够匹配,我们在src文件夹下创建router/index.js来存放公共的路由配置

import React from 'react';
import { Route } from 'react-router-dom'
import Home from '../components/home'
import Login from '../components/Login'

export default (
  <div>
    <Route path="/" exact component={Home} />
    <Route path="/home" exact component={Home} />
    <Route path="/login" exact component={Login} />
  </div>
)

为了测试路由,在src/components下再创建一个Login组件

import React from "react";

const Login = () => {
  return (
    <div>
      <div>
        <span>请输入账号</span>
        <input placeholder="请输入密码" />
      </div>
      <div>
        <span>请输入密码</span>
        <input placeholder="请输入密码" />
      </div>
    </div>
  );
};

export default Login;

然后在client-ssr.js中引入Route文件,并且采用BrowserRouter包起来

import React from "react";
import { hydrate } from "react-dom";
import { BrowserRouter } from "react-router-dom";
import Router from './src/router'

hydrate(
  <BrowserRouter> {Router} </BrowserRouter>,
  document.getElementById("app")
);

别忘了,运行 npx webpack命令 对client-srr.js进行编译

2. 在服务端中使用路由

2.1 用 StaticRouter 替代 BrowserRouter

在服务器端我们需要使用StaticRouter来替代BrowserRouter,StaticRouter 是 React-Router 针对服务器端渲染专门提供的一个路由组件,由于StaticRouter不能像BrowserRouter一样感知页面当前页面的url,所以我们需要给StaticRouter传入location={当前页面url},另外使用 StaticRouter时必须传递一个context参数,用于服务端渲染时的参数传递。

ctx.body = template(
  renderToString(
    //传入当前path
    //context为必填参数,用于服务端渲染参数传递
    <StaticRouter location={ctx.url} context={context}>
      {Router}
    </StaticRouter>
    // <Home />
  )
);

2.2 使用 koa-router 来控制服务端的请求路径

由于我们不确定用户在访问页面时候的初始路径是什么,所以干脆对所有的路径都进行接收,然后通过location传递到Route中进行匹配。

koa-router

koa-router 通配符

最终的server.js代码

import Koa from "koa";
import serve from "koa-static"; // 用来指定项目的根目录,根目录之上的文件都不能被访问到
import path from "path";
import React from "react";
import template from "./template";
import { renderToString } from "react-dom/server";
import { StaticRouter } from "react-router-dom";
import KoaRouter from "koa-router";
import Router from "./src/router";

const koaRouter = new KoaRouter();

const app = new Koa();

app.use(serve(path.resolve(__dirname)));

koaRouter.get("/(.*)", async (ctx) => {
  const context = {};
  console.log('ctx.url', ctx.url);
  ctx.body = template(
    renderToString(
      //传入当前path
      //context为必填参数,用于服务端渲染参数传递
      <StaticRouter location={ctx.url} context={context}>
        {Router}
      </StaticRouter>
      // <Home />
    )
  );
});

app.use(koaRouter.routes());

export default app;

运行node app.js, 在浏览器中分别输入http://localhost:8002/homehttp://localhost:8002/login就能实现React路由功能。

值得注意的是,只有在第一次进入页面时,浏览器请求了页面文件,之后切换路由的操作都不会重新请求页面,因为这时页面的路由跳转已经是客户端React的路由跳转了。

在SSR项目中使用Redux (数据同构)

在目前我们服务端返回的页面中,所有的页面都是不携带任何数据的。而往往我们的页面所要展示的内容,可能需要调接口来拿到,而在服务端的项目中,页面一旦确定内容,就没有办法Rerender了,这就要求组件显示的时候,已经将所有数据都准备好了(在服务端返回HTML以前已经完成了接口的调用),而这些准备好的数据,客户端也不需要重复请求,这就是数据同构。

在ssr项目中,数据同构是非常重要的一环,即用同一套代码请求数据,用同一个数据去渲染。那么就牵扯到三个问题:

  1. 服务器返回组件的时候,需要完成请求,并且携带数据。
  2. 浏览器接管页面的时候,需要拿到这个数据,不至于重新请求,或者没有数据。
  3. 浏览器接管以后,从其他路由跳转到该路由,那么需要浏览器发起请求。

1. 数据脱水与注水

注水:服务端异步获取到数据后将store数据通过window.PRELOADED_STATE = ${JSON.stringify(initialState)}注入到页面中

脱水:浏览器初次加载页面后先从window中同步服务端获取到的store数据,并且在页面中获取数据的代码前加一层判断是否有值再决定获取

2. 在项目中集成Redux

2.1 首先安装redux依赖

cnpm install --save-dev redux react-redux redux-thunk

在src目录下创建store目录

├── src
│   ├── store
│   │   ├── actions.js
│   │   └── reducer.js
└───────└── index.js

reducer.js

const defaultState = {
  userList: [],
  userInfo: {},
};

export default (state = defaultState, action) => {
  console.log("reducers - action", action);
  switch (action.type) {
    case "CHANGE_USER_LIST":
      return {
        ...state,
        userList: action.list,
      };
    case "CHANGE_USER_INFO":
      return {
        ...state,
        userInfo: action.userInfo,
      };
    default:
      return state;
  }
};

在服务端使用redux的坑

const store= createStore(reducer,applyMiddleware(thunk))
  const content = renderToString((
      <Provider store={store}>
         <StaticRouter location={req.path} context={{}}>
             {Router}
         </StaticRouter>
      </Provider>
  ));

由于createStore创建的store是单例的store,在服务器端这样的写法将导致所有用户共享一个store,所以我们将创建store这一步封装成一个方法,每次调用都返回一个新的store。

index.js

import {createStore, applyMiddleware} from "redux";
import thunk from "redux-thunk";

const reducer = (state={},action)=>{
  return state;
}

const getStore = ()=>{
  return createStore(reducer,applyMiddleware(thunk));
}

export default getStore;

2.2 对Home组件进行改造

import React, { useEffect } from "react";
import { connect } from 'react-redux';
import { getUserList } from "../store/actions";

const Home = ( {getUserList, userList }) => {

  useEffect(() => {
    getUserList();
  }, [])

  return (
  <div>
    <span>
      This is a React Component
    </span>
    <button
      onClick={() => {
        alert('click');
      }}
    >
      <span> click </span>
    </button>
  </div>
  );
};

const mapStateToProps = (state)=>({
  userList:state.userList
});

const mapDispatchToProps = (dispatch)=>({
  getUserList(){
    dispatch(getUserList(dispatch))
  }
})

export default connect(mapStateToProps,mapDispatchToProps)(Home);

2.3 将服务端store中的数据 在template中挂载到全局变量中

server.js

import Koa from "koa";
import serve from "koa-static"; // 用来指定项目的根目录,根目录之上的文件都不能被访问到
import path from "path";
import React from "react";
import template from "./template";
import { renderToString } from "react-dom/server";
import { StaticRouter } from "react-router-dom";
import KoaRouter from "koa-router";
import { Provider } from 'react-redux';
import getStore from "./src/store";
import Router from "./src/router";

const koaRouter = new KoaRouter();

const app = new Koa();

app.use(serve(path.resolve(__dirname)));

koaRouter.get("/(.*)", async (ctx) => {
  const context = {};
  console.log('ctx.url', ctx.url);

  const store = getStore();
  ctx.body = template(
    renderToString(
      <Provider store={store}>
        <StaticRouter location={ctx.url} context={context}>
          {Router}
        </StaticRouter>
      </Provider>,
      store.getState()
    ),
  );
});

app.use(koaRouter.routes());

export default app;

template.js

export default function template(content = "", initialState = {}) {
  let page = `<!DOCTYPE html>
  <html lang="en">
  <head>
    <meta charset="utf-8">
    <link rel="icon" href="data:;base64,=">
  </head>
  <body>
    <div class="content">
       <div id="app" class="wrap-inner">
          ${content}
       </div>
    </div>
    <script>
      window.__STATE__ = ${JSON.stringify(initialState)}
    </script>
    <script src="/asset/client.js"></script>
  </body>
  `;
  return page;
}

客户端的client-ssr.js也接入redux

import React from "react";
import { hydrate } from "react-dom";
import { BrowserRouter } from "react-router-dom";
import { Provider } from "react-redux";
import getStore from "./src/store";
import Router from "./src/router";

const App = () => (
  <Provider store={getStore()}>
    <BrowserRouter>{Router}</BrowserRouter>
  </Provider>
);
hydrate(<App />, document.getElementById("app"));

运行npx webpack重新编译 并且node app.js重启服务

然而,服务端并没有返回数据

需要注意的是useEffect在服务端渲染的时候并不会执行,服务端中只能执行componentDidMount生命周期函数之前的代码。

在服务端获取组件的初始化数据

1. 使用react-route执行初始化组件数据的方法

为了使得返回客户端的HTMl包含异步请求的数据,实际上我们需要在首次渲染的时候,根据把不同的页面,给当前的store填充数据,为了实现这个目的,我们必须满足两个条件

  • 代码进入某个页面时能匹配到对应组件里的axios请求(之后用setTimeout替代)

  • 被匹配到的组件能够将获取的数据传递到服务端的store中,并且挂载到返回页面的全局中。

对于这个问题,react-router已经为SSR提供了方法,我们需要做的是改造我们的router/index.js并且给组件添加自定义生命周期函数

import React from 'react';
import { Route } from 'react-router-dom';
import Home from '../components/home';
import Login from '../components/Login';

export default [
  {
    key:"default",
    path: "/",
    exact: true,
    component: Home,
    loadData: (store) => { Home.getInitialData(store) }
  },
  {
    key:"home",
    path: "/home",
    exact: true,
    component: Home,
    loadData: (store) => { Home.getInitialData(store) }
  },
  {
    key:"login",
    path: "/login",
    exact: true,
    component: Login,
    loadData: (store) => { Login.getInitialData(store) }
  }
];

2. 修改组件,添加自己的生命周期函数用于获取数据

Home.js

import React, { useEffect } from "react";
import { connect } from 'react-redux';

const getUserList = new Promise((resolved, reject) => {
  setTimeout( () => {
    console.log('我被执行了!!');
    // dispatch(changeUserList([{name: 'zaoren'}], [{name: 'ssr'}]));
    resolved([{name: 'zaoren'}, {name: 'ssr'}]);
  }, 300)
})

const Home = ( {dispatchUserList, userList }) => {

  useEffect(() => {
    getUserList.then((list) => {
      dispatchUserList(list)
    })
  }, [])

  return (
  <div>
    <span>
      This is a React Component
    </span>
    <button
      onClick={() => {
        alert('click');
      }}
    >
      <span> click </span>
      <p> {JSON.stringify(userList)} </p>
    </button>
  </div>
  );
};

const mapStateToProps = (state)=>({
  userList:state.userList
});

const mapDispatchToProps = (dispatch)=>({
  dispatchUserList: (list) => {
    dispatch({type:'CHANGE_USER_LIST', list})
  }
})

Home.getInitialData = (store) => {
  return getUserList.then((list) => {
    store.dispatch( {type:'CHANGE_USER_LIST', list } )
  })
}

export default connect(mapStateToProps,mapDispatchToProps)(Home);

Login.js

Login.getInitialData = (store) => {
  return getUserInfo.then((obj) => {
    store.dispatch({ type: "CHANGE_USER_INFO", userInfo: obj });
  });
};

3. 修改client-ssr.js中route的渲染方式

import React from "react";
import { hydrate } from "react-dom";
import { BrowserRouter, Route } from "react-router-dom";
import { Provider } from "react-redux";
import getStore from "./src/store";
import Router from "./src/router";

const App = () => (
  <Provider store={getStore()}>
    <BrowserRouter>
      {Router.map((router) => (
        <Route {...router} />
      ))}
    </BrowserRouter>
  </Provider>
);
hydrate(<App />, document.getElementById("app"));

4. 在server.js中调用组件中的初始化方法

import Koa from "koa";
import serve from "koa-static"; // 用来指定项目的根目录,根目录之上的文件都不能被访问到
import path from "path";
import React from "react";
import template from "./template";
import { renderToString } from "react-dom/server";
import { StaticRouter, Route, matchPath } from "react-router-dom";
import KoaRouter from "koa-router";
import { Provider } from "react-redux";
import getStore from "./src/store";
import Router from "./src/router";

const koaRouter = new KoaRouter();

const app = new Koa();

app.use(serve(path.resolve(__dirname)));

koaRouter.get("/(.*)", async (ctx) => {
  const context = {};
  const store = getStore();
  const matchRoutes = [];
  const promises = [];

  Router.some((route) => {
    matchPath(ctx.url, route) ? matchRoutes.push(route) : "";
  });

  console.log('matchRoutes', matchRoutes); 
  matchRoutes.forEach((item) => {
    promises.push(item.loadData(store));
  });

  // 子组件中的 getInitialValue 怎么处理???

  console.log('promises', promises);

  Promise.all(promises).then(() => {
    //可以console一下看到当前的store已经有数据
    console.log('store.getState()', store.getState());

    ctx.body = template(
      renderToString(
        <Provider store={store}>
          <StaticRouter location={ctx.url} context={context}>
            {Router.map((router) => (
              <Route {...router} />
            ))}
          </StaticRouter>
        </Provider>
      ),
      store.getState()
    );
  });
});

app.use(koaRouter.routes());

export default app;

重新运行npx webpack,并且启动node服务 node app.js

可以看到,我们已经拿到服务端组件初始化的数据了。

但是!还存在一些问题,之前我们提到脱水的概念。现在的情况是,我们在服务端拿到数据后,到客户端渲染的时候,还会再去请求一次,这无疑是一种浪费!

而且我们从数据也可以看出,页面上的内容会闪一下,因为客户端又初始化了一遍redux中的数据。

思考:当组件中含有子组件的话,初始化数据怎么获取???

在服务端获取过的数据不在客户端重新获取

为了数据重复获取的问题,需要做两件事。

  1. 判断是服务端渲染,还是正常的页面访问,来决定需不需要发起请求

  2. 将服务端放回的数据作为Redux的初始值

对于问题1,我们可以在所有组件的 useEffect 中判断 比如Home.js中

useEffect(() => {
  userList.length === 0 ? getUserList.then((list) => {
    dispatchUserList(list)
  }) : ""
}, [])

在Login.js中

useEffect(() => {
    Object.keys(userInfo).length === 0 ? getUserInfo.then((list) => {
      dispatchUserInfo(list);
    }) : '';
  }, []);

或许,你也可以通过window._STATE_变量来判断是服务端渲染还是正常页面的访问。

Redux的初始化数据,我们只需要在store创建的时候传进去就可以。

src/store/index.js

import { createStore, applyMiddleware } from "redux";
import thunk from "redux-thunk";
import reducer from "./reducer";

const getStore = (preLoadStore) => {
  let store;
  preLoadStore
    ? store = createStore(reducer, preLoadStore, applyMiddleware(thunk))
    : store = createStore(reducer, applyMiddleware(thunk));
    return store
};

export default getStore;

在client-ssr.js中,调用getStore时候传入初始化的值

import React from "react";
import { hydrate } from "react-dom";
import { BrowserRouter, Route } from "react-router-dom";
import { Provider } from "react-redux";
import getStore from "./src/store";
import Router from "./src/router";

const state = window.__STATE__;

const App = () => (
  <Provider store={getStore(state)}>
    <BrowserRouter>
      {Router.map((router) => (
        <Route {...router} />
      ))}
    </BrowserRouter>
  </Provider>
);
hydrate(<App />, document.getElementById("app"));

需要进一步解决的问题

  1. 服务端渲染的按需加载

  2. 欢迎提问补充

参考链接:

1w字 | 从零开始的React服务端渲染

React 中同构(SSR)原理脉络梳理

react-router

next.js 的服务端渲染机制(一)

Rohitkrops-ssr-demo