React SSR Server Rendering introduction

preface

As a solution to the problems such as weak SEO optimization of single-page applications and the speed bottleneck of the first screen, the front and back ends are homogeneous, which has been supported in the react, VUE and other front-end technology stacks recently. While we are moving away from the traditional pure server-side rendering model and embracing the best practices of separating the front and back ends, some people are moving away from the single-page application landscape and rethinking and defining server-side rendering.

Why server side rendering?

  1. Speed up first screen rendering and reduce white screen time

Different from traditional Web projects that directly obtain the HTML rendered by the server, single-page applications use JavaScript to generate HTML at the script client to present content. Users need to wait for the completion of JS parsing to see the page, which makes the loading time of the white screen longer and affects user experience.

  1. SEO friendly

For a single page application, when the search engine crawler crawls the HTMl file of the website, usually there is no content in the single page application, only such a sentence as

, thus affecting the ranking.

The React/Vue/Angular front-end framework is implemented on the server side to generate HTML, and then the rendered HTML is returned directly to the client.

Images from https://www.jianshu.com/p/a3bce57e7349

The technology principle

Take React as an example. First, we run the React code on the server so that the user downloads the HTML that already contains all the page display content (for additional SEO purposes). At the same time, users do not need to wait for the JavaScript code to complete the execution of the page to see the effect, enhancing the user experience. After that, React was executed on the client to bind data and events to the content in the HTML page, and the page had various interaction capabilities of React.

Core API

Server: use ReactDOMServer renderToString | ReactDOMServer. RenderToNodeStream generate HTML, and issued in the first request.

Client: Use reactdom.hydrate to do hydrate operations based on the HTML returned by the server. React tries to bind event listeners to existing tags (the HTMl returned from the server is eventless).

Render components in SSR projects

Technology stack: React + Koa2 + Webpack

1. Set up the server environment using koA

Create a folder and initialize the project

mkdir ssr-demo && cd ssr-demo

npm init -y
Copy the code

Install the Koa environment

cnpm install --save koa
Copy the code

Create app.js in the project root directory, listen on port 8888, and return some HTML when requesting the root directory

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

app.use(async (ctx) => {
  ctx.body = `   ssr demo   
      
Hello World `
}) app.listen(8888); console.log('app is starting at port 8888'); Copy the code

Enter the command on the terminal to start the service Node app.js

Visit http://localhost:8888/ locally and see the HTMl returned.

2. Write the React code on the server

Now that we have started a Node server, we need to write React code on the server (which can also be Vue or any other framework language). We create a React component and return it in the App.

Install the React environment, create the SRC/Components folder, and create the home.js file

cnpm install --save-dev React

mkdir src && cd src && mkdir components && cd components && touch home.js
Copy the code

Write the simplest React component in JSX

import React from 'react'

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

export default home;
Copy the code

And referenced in 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');
Copy the code

However, this code will not run successfully. Here’s why

  1. In the Node environment, Node does not recognize import and export. These are ESM syntax, whereas Node follows the common.js specification

  2. Node does not recognize JSX syntax

Fortunately, however, Babel can help us convert import and export into the common.js specification, and it also provides plug-ins to convert JSX syntax into normal JavaScript.

For convenience, we use @babel/preset-env and @babel/preset-react presets directly. Babel’s default is a collection of plug-ins. Babel-plugin-transform-modules-commonjs, babel-plugin-transform-react-jsx, babel-plugin-transform-modules-commonJS, babel-plugin-transform-react-jsx

@babel/register

One way to use Babel is through the Require hook (it can also be used in conjunction with other tools such as Webpack, but register is used for convenience). The Require hook binds itself to node’s Require module and compiles on the fly at runtime. This is similar to CoffeeScript coffee-script/ Register.

This is koA’s official @babel/register method. When we introduce @babel/register, we inject the Babel hook into the require method and compile it at runtime.

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

So we need to revamp our current content

app.js

require('@babel/register');

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

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

The new 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;



Copy the code

Create the Babel configuration file babel.config.js in the project root directory

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',].}}Copy the code

Of course, don’t forget installation-related dependencies

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

And you’re done! Run the node app. Js

3. Concept of isomorphism

With the example above, we were able to render the React component to the page. To illustrate the concept of isomorphism, let’s bind a click event to the component.

import React from 'react';

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

export default Home;
Copy the code

Re-run the code and refresh the page (since we didn’t integrate hot updates in our project, we had to reboot and refresh the page for every change). We will see that the onClick event does not execute after the button is clicked. This is because the renderToString() method only renders the content of the component and does not bind events (the DOM is hosted by the browser). Therefore, we need to execute the React code once on the server and again on the client. This way that the server and client share the same set of code is called isomorphism.

4. Run the React code on the client

As mentioned earlier, the React code, when executed on the server, can only return AN HTML page, but does not have any interaction. We need to re-execute the React code on the client side to make sure the page responds to events such as onClick. React provides the Hydrate method.

ReactDOM.hydrate(element, container[, callback])
Copy the code

In order to execute this code on the client side, we need to manually import the code in the template

A new client – SSR. Js

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

hydrate(
   <Home />.document.getElementById('app'))Copy the code

The new 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;
}
Copy the code

Modify server.js to return the contents of the template as a result

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;
Copy the code

Restart, found an error!

Because the JSX syntax of React in our client-SSR is directly returned to the browser, it cannot be parsed, so we need to use Babel to parse it into JavaScript that the browser can recognize.

Webpack packaging

Install webPack dependencies and command-line tools

cnpm install --save-dev webpack webpack-cli
Copy the code

Create a new WebPack configuration file, and we need to install babel-Loader to parse our JSX syntax

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" }
    ]
 }
}
Copy the code

The terminal runs NPX webpack and packages the client-ssr.js file.

Use koa-static to specify the root directory

So far, our documents are ready. The /asset/client.js file in template.js is not available. So we need to tell the current service where the root directory of the project is, so that the server can correctly return the files we need, using the KOA-static middleware.

Modify 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;
Copy the code

Restart the server, click the Click button, and there you go! After rendering on the client side again, our page responds to click events properly.

Using routing in SSR projects (Routing isomorphism)

1. Enable routes on the client

Similarly, to use routing, we need to configure routing on both the server and the client. First we install react-router-dom

To make the client and server routes match, we create router/index.js in the SRC folder to store the common route configuration

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>
)
Copy the code

To test the route, create another Login component under SRC/Components

import React from "react";

const Login = () = > {
  return (
    <div>
      <div>
        <span>Please enter your account number</span>
        <input placeholder="Please enter your password" />
      </div>
      <div>
        <span>Please enter your password</span>
        <input placeholder="Please enter your password" />
      </div>
    </div>
  );
};

export default Login;
Copy the code

Then the Route file is introduced in client-ssr. Js and is wrapped with 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"));Copy the code

Don’t forget to run the NPX webpack command to compile client-swr.js

2. Use routes on the server

2.1 Replace BrowserRouter with StaticRouter

On the server side we need to replace BrowserRouter with a StaticRouter, which is a routing component of the React-Router for server-side rendering. Since StaticRouter doesn’t know the current PAGE URL like BrowserRouter does, we need to pass location={current page URL} to StaticRouter. In addition, we must pass a context parameter to StaticRouter. Used for parameter passing during server rendering.

ctx.body = template(
  renderToString(
    // Pass in the current path
    // Context is a mandatory parameter for server render parameter passing
    <StaticRouter location={ctx.url} context={context}>
      {Router}
    </StaticRouter>
    // <Home />));Copy the code

2.2 Using koa-Router to control the request path on the server

Since we are not sure what the user’s initial path will be when accessing the page, we simply receive all paths and pass them through location to Route for matching.

koa-router

Koa – the router wildcards

The final server.js code

import Koa from "koa";
import serve from "koa-static"; // Specifies the root directory of the project. Files above the root directory cannot be accessed
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(
      // Pass in the current path
      // Context is a mandatory parameter for server render parameter passing
      <StaticRouter location={ctx.url} context={context}>
        {Router}
      </StaticRouter>
      // <Home />)); }); app.use(koaRouter.routes());export default app;
Copy the code

Run the node app. Js, enter http://localhost:8002/home and http://localhost:8002/login respectively in the browser can achieve the React routing functions.

Note that only when the browser requests the page file for the first time when the page is accessed, subsequent route switching operations do not request the page again, because the redirects of the page are already redirects of the client’s React route.

Using Redux (Data isomorphism) in SSR projects

Of the pages returned by our server, none carry any data. And tend to show the contents of our pages, may require a callback interface to get on the service projects, the page content, once established, there is no way to Rerender, which requires the component displays, has put all the data are ready before (on the server returns HTML interface has completed the call), and the prepared data, The client does not need to repeat the request, which is data isomorphism.

In SSR project, data isomorphism is a very important link, that is to use the same set of code to request data, with the same data to render. So there are three questions:

  1. When the server returns the component, it needs to complete the request and carry data with it.
  2. When the browser takes over the page, it needs to get this data so it doesn’t have to rerequest it, or have no data.
  3. After the browser takes over the control, the browser initiates a request to switch to this route from other routes.

1. Data dehydration and water injection

PRELOADED_STATE = ${json.stringify (initialState)}

Dehydration: after the browser loads the page for the first time, it synchronizes the store data obtained by the server from the Window, and determines whether there is a value before the code to obtain the data in the page

2. Integrate Redux into your project

2.1 Install redux dependencies first

cnpm install --save-dev redux react-redux redux-thunk
Copy the code

Create a store directory under the SRC directory

├ ─ ─ the SRC │ ├ ─ ─ store │ │ ├ ─ ─ actions. Js │ │ └ ─ ─ reducer. Js └ ─ ─ ─ ─ ─ ─ ─ └ ─ ─ index, jsCopy the code

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:
      returnstate; }};Copy the code

Pit using Redux on the server side

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

Since the store created by createStore is a singleton store, this writing on the server side results in all users sharing a store, so we encapsulate the store creation step as a method that returns a new store each time we call it.

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;
Copy the code

2.2 Transform the Home component

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);

Copy the code

2.3 Mount server store data to global variables in template

server.js

import Koa from "koa";
import serve from "koa-static"; // Specifies the root directory of the project. Files above the root directory cannot be accessed
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;
Copy the code

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__ = The ${JSON.stringify(initialState)}
    </script>
    <script src="/asset/client.js"></script>
  </body>
  `;
  return page;
}
Copy the code

Client-ssr.js also connects to 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"));

Copy the code

Run NPX webpack to recompile and nodeapp.js to restart the service

However, the server does not return data

Note that useEffect is not executed when rendered on the server, only before the componentDidMount lifecycle function is executed on the server.

Get the initialization data of the component on the server side

1. Use react-route to initialize component data

In order for the HTMl returned to the client to contain the asynchronously requested data, we actually need to populate the current store with data based on the different pages on the first rendering. In order to do this, we must satisfy two conditions

  • When code enters a page, it matches the axios request in the corresponding component (later replaced with setTimeout).

  • The matched component can pass the retrieved data to the server’s store and mount it globally to the returned page.

For this problem, react-router already provides methods for SSR. What we need to do is to modify our router/index.js and add custom lifecycle functions to the component

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) }
  }
];
Copy the code

2. Modify the component to add its own lifecycle functions for retrieving data

Home.js

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

const getUserList = new Promise((resolved, reject) = > {
  setTimeout( () = > {
    console.log('I was executed!! ');
    // 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);
Copy the code

Login.js

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

3. Modify the rendering mode of route in client-ssr.js

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"));

Copy the code

4. Invoke the component’s initialization method in server.js

import Koa from "koa";
import serve from "koa-static"; // Specifies the root directory of the project. Files above the root directory cannot be accessed
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));
  });

  // How to handle getInitialValue in child components??

  console.log('promises', promises);

  Promise.all(promises).then(() = > {
    // You can console to see that the current store already has data
    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;

Copy the code

Re-run NPX webpack and start the Node service, Node app.js

As you can see, we have the server component initialization data.

But! There are some problems. We talked about dehydration earlier. The current situation is that after we get the data on the server side, we will request it again when rendering on the client side, which is undoubtedly a waste!

And as you can see from the data, the content on the page flashes because the client initializes the data in redux again.

How do you get initialization data when a component contains child components?

Data obtained on the server is not retrieved on the client

Two things need to be done to address the problem of duplicate data retrieval.

  1. Determine whether it is server side rendering or normal page access to determine if a request needs to be made

  2. The data put back by the server is used as the initial value of Redux

For problem 1, we can useEffect on all components such as home.js

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

In the Login. Js

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

Perhaps you can also use the window._state_ variable to determine whether a server is rendering or a normal page is being accessed.

Redux initialization data, we just need to pass it in when the Store is created.

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;
Copy the code

In client-ssr.js, the initialized value is passed in when getStore is called

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"));

Copy the code

Problems that need to be solved further

  1. Server-side rendering loading on demand

  2. Welcome to add questions

Reference links:

1 w word | React rendering of a service from scratch

Review the isomorphism (SSR) principle in React

react-router

Next. Js server rendering mechanism (Part 1)

Rohitkrops-ssr-demo