preface

Some time ago to think of doing a personal website, and then immediately action. How does individual website realize to choose what technical plan, oneself can decide freely. Just before have roughly thought about server rendering, fast loading, and SEO is very suitable for personal websites. So I built my own wheel and used KOA + React to implement SSR server rendering.

What is the SSR

When I first heard of single-page server rendering, I understood it as similar to traditional server routing + template rendering, but written in the framework of a single-page application. Thought behind it seems a little silly, again understanding, the original is just at the time of loading for the first time, the back-end to the current path component of the page rendering and data request, assembled into HTML returned to the front end, users can quickly see, see page when JS resources in HTML after completion of loading, the implementation and operation is usually a single page application. So SSR is a combination of back-end template rendering and single pages.

SSR has two modes, single page mode and non-single page mode. The first mode is single-page application with the first rendering at the back end, and the second mode is back-end template rendering mode with the complete use of back-end routing. They differ in the degree to which back-end routing is used.

advantage

SSR has two obvious advantages: fast first load and SEO.

Why is the first load fast? A normal single-page application will need to load all the related static resources for the first time, and then the core JS will start executing. This process will take some time, and then the network interface will be requested, and finally the complete rendering will be completed.

SSR mode, the back-end intercepted routing, find the corresponding components, prepare rendering component, all JS resources locally, ruled out JS resources network load time, then only need to the current routing component rendering, and the ajax request page, may be on the same server, if so speed will be a lot faster. Finally, the back end passes the rendered page back to the front end.

Note: the page can be displayed quickly, but because the current return is just a simple display of DOM, CSS, JS related events and so on in the client are not bound, so it is necessary to render the current page again after LOADING JS, which is called isomorphism. So SSR is faster to show the content of the page first, so that users can see it first.

Why is SEO friendly? Because when the search engine crawler crawls the page information, it will send HTTP request to obtain the web content, and the data rendered by our server for the first time is returned by the back end, which has already rendered the title, content and other information, so that the crawler can easily grab the content.


How to implement

Roughly have an understanding of SSR, we now need to sort out the realization of the idea and process.
  1. Select a single page framework (I currently choose React)
  2. Select the Node server framework (I currently choose KOA2)
  3. Implement the core logic that allows the Node server to route and render single-page components (this is broken down into small implementation points, described below)
  4. Automated Build tools for optimized development and release environments (Webpack)


To start the implementation, create a React-SSR project. Under the project, create client and server directories for writing client and server code and webpack directories for weppack file configuration.

1. The react applications

Install the React dependency, create a basic structure of the React client folder, and write an application that can run with routing configuration.

2. The server application

Install koA and dependencies, create a basic server folder structure in server, and write a simple running back-end application service. The server folder is as follows:

3. Core implementation

Since the repository code doesn’t explain the base code, we now have a react single-page application and a back-end application that can run separately, each with its own route. Next we do transformation, to achieve SSR single page mode (non single page mode is only part of the adjustment, so here only to achieve single page mode).


The core implementation is divided into the following steps:
  • 1) The back end intercepts the route and finds the React page component X that needs to be rendered according to the path
  • 2) Invoke the interface required for component X initialization, and after synchronizing the data, use react renderToString method to render the component to render node strings.
  • 3) The back end gets the basic HTML file, inserts the rendered node string into the body, and also operates the title, script and other nodes in it. Return the complete HTML to the client.
  • 4) The client gets the HTML returned by the backend, displays and loads the JS in it, and finally completes react isomorphism.


1)When we write react on the client, the router will normally define an array to store components and their corresponding paths, and then register routes as follows:

import Index from ".. /pages/index";
import List from ".. /pages/list";
const routers = [
  { exact: true.path: "/".component: Index },
  { exact: true.path: "/list".component: List }
];Copy the code

Said above, to achieve SSR is to achieve a single page application + first server rendering, so we are doing a single page application. Now that you have implemented a single-page application, you need to implement your first server-side rendering. When the server application starts, it receives a URL request, such as an access
http://localhost:9999/,The back end service gets the current path of /, so we want the back end to find the Index component with path of ‘/’ and render it.

Create two JS files index and Pages in the router folder of client:

Configure routing path and component mapping in Pages, the code is roughly as follows, so that it can be used by both client and server routes.

import Index from ".. /pages/index";
import List from ".. /pages/list";
const routers = [
  { exact: true.path: "/".component: Index },
  { exact: true.path: "/list".component: List }
];
// Register page and import component, stored in object, server route match after rendering
export const clientPages = (() = > {
  const pages = {};
  routers.forEach(route= > {
    pages[route.path] = route.component;
  });
  returnpages; }) ();export default routers;Copy the code

After the server receives the GET request, the server matches the path. If the path has a mapping page component, the server obtains the component and renders it. This is the first step: the back end intercepts the route and finds the React page component to render according to the path.

import { clientPages } from ". /.. /.. /client/router/pages";
router.get("*", (ctx, next) => {
  let component = clientPages[ctx.path];
  if (component) {
    const data = await component.getInitialProps();
    // Because component is a variable, create is required
    const dom = renderToString(
      React.createElement(component, {
        ssrData: data
      })
    )
  }
})Copy the code


2) As shown in the figure above, after matching the component, execute the getInitialProps method of the component (named as nextjs). This method is a wrapped static method that gets the Ajax data needed for initialization, which is synchronized on the server side. The component prorps is then passed in with the ssrData parameter and the component rendering is performed. This method is still an asynchronous request on the client side.

This step is important, why do we need a static method instead of writing the request directly in willmount? Because when renderToString is used on the server side to render components, the life cycle will only execute to the first render after Willmount. Inside Willmount, the request is asynchronous, and when the first render is completed, none of the asynchronous data is retrieved. At this point, renderToString has already returned. The initialization data for our page is gone, and the HTML returned is not what we expected. Therefore, a static method is defined. The method is obtained before the component is instantiated and executed synchronously. After the data is obtained, the data is passed to the component for rendering through props.

So how does this approach work? Let’s look at base.js from the code screenshot:

import React from "react";
export default class Base extends React.Component {
  // Override gets the asynchronous data that requires the server to render for the first time
  static async getInitialProps() {
    return null;
  }
  static title = "react ssr";
  // Do not rewrite constructor in the page component
  constructor(props) {
    super(props);
    // If static state is defined, state should take precedence over ssrData by lifecycle
   if (this.constructor.state) {
      this.state = { ... this.constructor.state }; }// If it is the first rendering, ssrData will be retrieved
    if (props.ssrData) {
      if (this.state) {
        this.state = { ... this.state, ... props.ssrData }; }else {
        this.state = { ... props.ssrData }; }}}async componentWillMount() {
    // The client is running
    if (typeof window! ="undefined") {
      if (!this.props.ssrData) {
        // Non-first rendering, i.e. single-page route state changes, calls static methods directly
        // We are not sure if there is any asynchronous code, if getInitialProps returns an initialization state, then it should be executing synchronously, because await is not executing synchronously, causing state confusion
        // It is recommended that state be initialized in the class attribute, defined using the static static method, and incorporated into the instance when constructor is used.
        // Why not add static instead of state, since the default property is executed after constructor, overwriting the state defined by constructor
        const data = await this.constructor.getInitialProps(); // Static method, obtained by constructor
        if (data) {
          this.setState({ ...data });
        }
      }
      // Set the title
      document.title = this.constructor.title; }}}Copy the code



A static method getInitialProps is used to create a base component that inherits from react.component.http. The base component inherits from react.component.http. This method mainly returns asynchronous data needed for component initialization. If there is an initial Ajax request, it should be overridden in this method and return the data object.

Constructor determines whether the page component has an initialized state static property, passing it to the component-instantiated state object if it does, and passing ssrData to the component state object if the props has ssrData.

Base componentWillMount determines whether the getInitialProps method is still needed. If the server rendering has been synchronized to the props before the component is instantiated, it is ignored.

If in a client environment, there are two cases.

The first: When the user enters the page for the first time, it is the server side that requests the data. After obtaining the data, the server side renders the component on the server side. At the same time, it also stores the data in the HTML script code and defines a global variable ssrData, as shown in the following figure. React registers a single page application and passes global ssrData to the page component. In this case, the page component can continue to use the data from the server during the client’s isomorphic rendering. In this way, the consistency of isomorphism is maintained and repeated requests are avoided.

In the second case, if the current user switches routes in a single page and there is no server rendering, the getInitialProps method is executed, returning the data directly to state, which is almost the same as executing the request in Willmount. This encapsulation allows us to use one set of code that is compatible with both server-side and single-page rendering.

client/app.js

import React from "react";
import { hydrate } from "react-dom";
import Router from "./router";
class App extends React.Component {
  render() {
    return <Router ssrData={this.props.ssrData} ssrPath={this.props.ssrPath} />;
  }
}
hydrate(
  <App ssrData={window.ssrData} ssrPath={window.ssrPath} />,
  document.getElementById("root")
);Copy the code


Index inherits Base and defines static state. The constructor method passes this object to the state object instantiated by the component. To ensure that the default state defined by the interface is passed to the props data, the interface requests the props data to be passed to the props data.

Why not just write the state property instead of static, because the state property executes after constructor, which overrides the state defined by constructor, which overrides the data returned by getInitialProps?

export default class Index extends Base {
  // Look at the comment: base about getInitialProps
  static state = {
    desc: "Hello world~"
  };
  / / replace componentWillMount
  static async getInitialProps() {
    let data;
    const res = await request.get("/api/getData");
    if(! res.errCode) data = res.data;return{ data }; }}Copy the code

Note: When renderToString is executed on the server, the component is instantiated and the DOM is returned as a string. The React component’s life cycle only executes to render after Willmount.


3) We write an HTML file that looks something like this. Now that the node string has been rendered, the back end needs to return HTML text containing the title, the node, and finally the packed JS that needs to be loaded to replace the HTML placeholder.

index.html

<! DOCTYPE html><html lang="en">
  <head>
    <title>/*title*/</title>
  </head>
  <body>
    <div id="root">??</div>
    <script>
      /*getInitialProps*/
    </script>
    <script src="/*app*/"></script>
    <script src="/*vendor*/"></script>
  </body>
</html>Copy the code

server/router.js

indexHtml = indexHtml.replace("/*title*/", component.title);
indexHtml = indexHtml.replace(
  "/*getInitialProps*/".`window.ssrData=The ${JSON.stringify(data)}; window.ssrPath='${ctx.path}'`
);
indexHtml = indexHtml.replace("/*app*/", bundles.app);
indexHtml = indexHtml.replace("/*vendor*/", bundles.vendor);
ctx.response.body = indexHtml;
next();Copy the code



4) Finally, when the client JS is loaded, react will run and reactdom.hydrate will be executed instead of the usual reactdom.render.

import React from "react";
import { hydrate } from "react-dom";
import Router from "./router";
class App extends React.Component {
  render() {
    return <Router ssrData={this.props.ssrData} ssrPath={this.props.ssrPath} />;
  }}
hydrate(
  <App ssrData={window.ssrData} ssrPath={window.ssrPath} />,
  document.getElementById("root")
);Copy the code

Below is an overview of the first rendering process, click to see a larger image

CSS

We have now completed the core logic, but there is a problem. I found that the style-loader would give an error when rendering the component at the back end. The style-loader would find the CSS that the component depends on and load the style into the HTML header when the component is loaded. However, when we render the component at the server side, there is no window object. Therefore, the style-loader internal code will report an error.

The server webpack needs to remove the style-loader and replace it with another method. Later, I assign the style to the static variable of the component and then render it back to the front end through the server. However, the problem is that I can only get the style of the current component, not the style of the child component. It would be too much trouble to find another way to get it.

Later, I found a library isomorphic-style-loader that could support the functions we wanted, looked at its source code and usage method, assigned styles to components through higher-order functions, and then used react Context to get styles of all components that needed to be rendered. Finally, the style is inserted into the HTML, which solves the problem that the child component styles cannot be imported. However, I found it a bit troublesome. First, I needed to define the higher-order functions of all components and import the library, then I needed to write relevant code in the Router to collect the style, and finally insert it into the HTML.

I then define a ProcessSsrStyle method that takes the style file as an input and the logic is to determine the environment, if it is the server that loads the style into the DOM of the current component, if it is the client that does not process it (because the client has a style-loader). Implementation and use are very simple, as follows:

ProcessSsrStyle.js

import React from "react";
export default style => {
  if (typeof window! ="undefined") {
    / / the client
    return;
  }
  return <style>{style}</style>;
};Copy the code

Use:

render() {
    return (
      <div className="index">
        {ProcessSsrStyle(style)}
      </div>
    );
}Copy the code

When the react isomorphism is complete, the DOM will be replaced with a pure DOM, because ProcessSsrStyle will not print a style on the client. Finally, after style-loader is executed, the header will also have styles, and the page will not have inconsistent changes, which will be insensitive to the user.

At this point, the core features were implemented, but later in development, I found that things were not that simple, as the development environment seemed too unfriendly and inefficient, requiring manual restarts.

The development environment

Let’s start with how the initial development environment worked:

  • NPM run dev starts the development environment
  • Webpack.client-dev.js packages the server code, which is packaged into dist/ Server
  • Webpack.server-dev.js packages the client code, which is packaged into dist/client
  • Start the server application on port 9999
  • Start webpack-dev-server on port 8888

After webpack is packaged, two services are started, one is the app application on the server side with port 9999, and the other is the dev-server on the client side with port 8888. Dev-server will listen and package the client code, so that when the client code is updated, Hot update front-end code in real time.

When accessing localhost:9999, the server will return HTML. Our server will return the JS script path in HTML pointing to the address of the dev-serve port, as shown in the following figure. That is, the client program and the server program are packaged separately and run two different port services.

In a production environment, since dev-server is not required to listen and hot update, a single service is sufficient, as shown in the figure below, where the server registers the static resource folder:

server/app.js

  app.use(
    staticCache("dist/client", {
      cacheControl: "no-cache,public".gzip: true}));Copy the code

Current build systems, which distinguish between production and development environments, have no problems building development environments today. But the development environment problem is more obvious, the biggest problem is that the server does not have hot update or repackage restart. This will lead to many problems, the most serious is that the front-end component has been updated, but the server has not been updated, so there will be inconsistency in the isomorphism, which will lead to errors, some errors will affect the operation, the solution is to restart. The development experience was unbearable. Then I started thinking about doing hot updates on the server side.

Listen, package, restart

My initial approach was to listen for changes, package and restart the application. Remember our client/router/pages. Js file, which is imported into both client and server routes, so both server and client package dependencies have pages. Js, so all component-related dependencies of Pages can be listened to by the client and server. Now that dev-server has helped us listen to and hot update the client code, it’s up to us to deal with how to update and restart the server code.

In fact, the method is very simple, is in the server packaging configuration to enable listening, and then in the plug-in configuration, write a restart plug-in, plug-in code is as follows:

  plugins: [
    new function() {
      this.apply = compiler= > {
        // Create a custom register hook function. Watch listens for changes and after compiling, done is triggered, callback must execute, otherwise the subsequent process will not be executed
        compiler.hooks.done.tap(
          "recomplie_complete",
          (compilation, callback) => {
            if (serverChildProcess) {
              console.log("server recomplie completed");
              serverChildProcess.kill();
            }
            serverChildProcess = child_process.spawn("node", [
              path.resolve(cwd, "dist/server/bundle.js"),
              "dev"
            ]);
            serverChildProcess.stdout.on("data", data => {
              console.log(`server out: ${data}`);
            });
            serverChildProcess.stderr.on("data", data => {
              console.log(`server err: ${data}`); }); callback && callback(); }); }; } ()]Copy the code

When WebPack runs for the first time, the plugin starts a child process, runs app.js, and when the file changes, compiles again to determine whether there are children, and if there are children, kills them, and then restarts them, thus implementing an automatic restart. Because the client and server are two different packages and configuration, when a file is modified, they will be recompiled at the same time, in order to ensure the compiled operation in line with expectations, to ensure that the server first compiled, compiled after the client, so in the client’s watch configuration, add a little delay, as the chart, the default is 300 milliseconds, So the server compiled 300 milliseconds later, and the client compiled 1000 milliseconds later.

watchOptions: {
  ignored: ["node_modules"].aggregateTimeout: 1000 // optimize to ensure that the back-end repackaging is performed first
}Copy the code

Now the restart problem is solved, but I think it is not enough, because during most of the development time, the components in the pages. So I thought I’d optimize it again.

Remove client/ Router/Pages and package them separately

Add a webpack.server-dev-pages.js configuration file, listen and package dist/pages separately, server code determines if it is a development environment, in the route listener method to fetch the dist/ Pages package again each time. The server listening configuration ignores the client folder.

This may seem confusing, but the end result is that when the components that pages relies on are updated, webpack.server-dev-pages.js is recompiled and packaged into dist/ Pages, and the server app is not compiled and restarted. Simply fetching the latest Dist/Pages package in the server app route ensures that the server application updates all client components without the server application compiling and restarting. When the server’s own code changes, it will compile and restart automatically.

So our development environment ended up with three packaged configurations to boot

  • webpack.server-dev-pages
  • webpack.server-dev
  • webpack.client-dev

Server /router, how to clear and update the Pages package

const path = require("path");
const cwd = process.cwd();
delete __non_webpack_require__.cache[
  __non_webpack_require__.resolve(
      path.resolve(cwd, "dist/pages/pages.js"))]; component = __non_webpack_require__( path.resolve(cwd,"dist/pages/pages.js")
).clientPages[ctx.path];Copy the code

At this point, the more satisfactory development environment is basically realized. Later, I decided that it was unnecessary to repack the pages on the backend every time I updated the CSS, plus the CSS is not consistent when isomorphic, just a warning, no real impact, so I ignored the less file in server-dev-Pages (because I used less). This leads to a problem because pages is not updated, so the page will refresh to show the old style and then become the new style immediately after isomorphism, which is acceptable in a development environment for a moment and doesn’t matter. But it avoids unnecessary compilation.

watchOptions: {
  ignored: ["**/*.less"."node_modules"] // Ignore less, style changes do not affect isomorphism
}Copy the code

Things not done

  • Encapsulate into a more enveloping three-way scaffolding
  • CSS scope control
  • A more encapsulated WebPack configuration
  • In the development environment, the image path may be inconsistent

The original purpose of doing their own station is to learn, plus their own use, so there are too many personality things. I have removed a lot of packages and code from my site in order to make the core code easier to understand. There are a lot of comments in the code to help others understand, if you want to use the current library to develop a website of your own, it is completely possible, but also to help you better understand it. Nextjs is recommended for commercial projects.

CSS does not have scope control, so if you want to isolate scopes, manually add upper-layer CSS isolation, such as.index{….. } wrap a layer, or try importing a tripartite package yourself.

The general configuration of WebPack can be packaged into a file, then imported in each file and personalized. But when I looked at other code before, I found that this method, which makes it more difficult to read, plus the configuration itself is not much, so it looks more intuitive without encapsulation.

In the development environment, the image path may appear inconsistent, for example, the client address request address is localhost… Assets /xx.jpg, while the server is assets/xx.jpg, there may be a warning, but it does not affect. Because there’s only one absolute path and one relative path.

The last

For the SSR server rendering implementation or quite satisfied, but also spent a lot of time. Feel the load speed and welcome to dashiren.cn/. Some pages have interface requests, such as dashiren.cn/space, which still load quickly.

The repository is ready, download it and try it out. After installing the dependencies, run the command. Github.com/zimv/react-…

The code word is not easy, give it a thumbs up

Share a friend’s AI tutorial. Zero basis! Easy to understand! Funny humor! More than jokes! You can see if it helps you.


Follow the official account of the great poet, the first time to get the latest articles.