preface

Single page apps (SPAs) are very popular at the moment, but they also bring some problems, such as SEO being unfriendly and slow to load on the first screen when the network is poor. It’s like going back to the traditional Web development model in order to solve these problems. There’s no going back. There’s no going back. React is a SPA application development framework that also supports server rendering. This series of articles will introduce how to build a React server rendering project

  • Project structures,
  • The routes at the front and back ends are isomorphic
  • Code splitting and data prefetching

If you prefer an out-of-the-box experience, try higher-level solutions like Next-.js, Next-.js and offer some additional functionality. This series of articles is designed to give you an idea of how to build server-side renderings. As you become familiar with them, you will be able to control your application more directly. Before you read, you will need the following technical skills

The front end

  1. React/react-router/Redux
  2. Webpack (Familiar)
  3. Babel (Understood)
  4. Eslint (Understood)
  5. ES6 (Understanding)
  6. Promise (understand)

The back-end

  • Express (understand)

The source address is at the end of the article

If you use webpack4, babel7 full source code here

Webpack configuration

Note: Version 3.x

Server-side rendering is to have the server generate the HTML string, and then send the generated HTML string to the browser. The browser receives the HTML and renders it, while the client only does the DOM event binding. At this point we need to package up two pieces of code, one for the server to render the HTML and one for the browser, and most of the code can be executed on both the server and the client

The directory structure

. + - config configuration directory | dev env. | js development environment configuration prod. The env. | js production configuration util. Js | webpack. Config. Base. | js common packaging configuration Webpack. Config. Client. Js client packaging configuration | webpack. Config. Server js + server package configuration - public | favicon. Ico + - SRC | + -- -- -- assets source directory Resource directory | App. JSX root component | entry - client. Js client packaged entrance | entry - server. | js server packaged entrance for server js server startup js | setup - dev - server. Js Development environment package |. Babelrc Babel configuration file. | eslintignore. | eslintrc. Js eslint configuration file | index. The HTML template HTML | package - lock. Json | package.jsonCopy the code

Common configuration

First of all, write the general configuration of the server and client, including various Loaders corresponding to JS, font, image, audio and other files. Distinguish between the development environment and production environment in the common configuration. If the production environment uses UglifyJsPlugin plug-in for JS ugliness, and DefinePlugin is used to define the configuration for different environments

webpack.config.base.js

const webpack = require("webpack");
const UglifyJsPlugin = require("uglifyjs-webpack-plugin");

let env = "dev";
let isProd = false;
let prodPlugins = [];
if (process.env.NODE_ENV === "production") {
  env = "prod";
  isProd = true;
  prodPlugins = [
    new UglifyJsPlugin({sourceMap: true})
  ];
}

const baseWebpackConfig = {
  devtool: isProd ? "#source-map" : "#cheap-module-source-map",
  resolve: {
    extensions: [".js", ".jsx", ".json"]
  },
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        loader: ["babel-loader", "eslint-loader"],
        exclude: /node_modules/
      },
      {
        test: /\.(png|jpe?g|gif|svg)$/,
        loader: "url-loader",
        options: {
          limit: 10000,
          name: "static/img/[name].[hash:7].[ext]"
        }
      },
      {
        test: /\.(woff2?|eot|ttf|otf)$/,
        loader: 'url-loader',
        options: {
          limit: 10000,
          name: "static/fonts/[name].[hash:7].[ext]"
        }
      }
    ]
  },
  plugins: [
    new webpack.DefinePlugin({
      "process.env": require("./" + env + ".env")
    }),
    ...prodPlugins
  ]
}

module.exports = baseWebpackConfig;

Copy the code

Client Configuration

The client configuration is the same as the common single-page application configuration. HtmlWebpackPlugin is used to inject the packaged style and JS into the template index.html, and dist is designated as the packaged root directory. Subsequently, Express will use this directory as the static resource directory for resource mapping. CSS, postCSS, sass, less, stylus and other loader configurations are written in the styleLoaders function in util.js. In the production environment, the ExtractTextPlugin is used to extract styles into CSS files

webpack.config.client.js

const path = require("path"); const merge = require("webpack-merge"); const HtmlWebpackPlugin = require("html-webpack-plugin"); const ExtractTextPlugin = require("extract-text-webpack-plugin"); const baseWebpackConfig = require("./webpack.config.base"); const util = require("./util"); const isProd = process.env.NODE_ENV === "production"; const webpackConfig = merge(baseWebpackConfig, { entry: { app: "./src/entry-client.js" }, output: { path: path.resolve(__dirname, ".. /dist"), filename: "static/js/[name].[chunkhash].js", publicPath: "/dist/" // util.styleLoaders({ sourceMap: isProd ? true : false, usePostCSS: true, extract: isProd ? true : false }) }, plugins: [ new HtmlWebpackPlugin({ filename: "index.html", template: "index.html" }) ] }); if (isProd) { webpackConfig.plugins.push( new ExtractTextPlugin({ filename: "static/css/[name].[contenthash].css" }) ); } module.exports = webpackConfig;Copy the code

Server Configuration

The server configuration is different from the client. The server runs in Node and does not support Babel and styles. It also does not support some browser global objects such as window and document. The server simply runs JS to generate HTML fragments, and the styles are packaged by the client and downloaded for execution by the browser. Some people will use babel-Register or babel-Node, both of which are transcoding from Node to Babel in real time. Therefore, performance will be affected. It is recommended to use babel-Register in development environment, but should be pre-converted in production environment

webpack.config.server.js

const path = require("path"); const webpack = require("webpack"); const merge = require("webpack-merge"); const baseWebpackConfig = require("./webpack.config.base"); const ExtractTextPlugin = require("extract-text-webpack-plugin"); const util = require("./util"); const webpackConfig = merge(baseWebpackConfig, { entry: { app: "./src/entry-server.js" }, output: { path: path.resolve(__dirname, ".. /dist"), filename: "entry-server.js", libraryTarget: "commonjs2", target: Module: {rules: util.styleLoaders({sourceMap: true, usePostCSS: true, extract: true }) }, plugins: [ new webpack.DefinePlugin({ "process.env.REACT_ENV": Json.stringify ("server") // Specify the React environment as the server}), // The server does not support the window document object, the CSS external chain new ExtractTextPlugin({filename: "static/css/[name].[contenthash].css" }) ] }); module.exports = webpackConfig;Copy the code

Babel and Eslint

React needs to be preset with Babel. Install babel-core, babel-env, babel-react, and babel-loader. Babel is configured as follows

{
  "presets": ["env", "react"]
}
Copy the code

Env contains ES2015, ES2016, ES2017, and the latest version. React Converts react

Note: Babel uses version 6.x

A good code specification is the foundation of collaborative development. This article uses ESLint for code specification checking, installing ESLint, ESlint-plugin-React, babel-ESlint, eslint-Loader. Eslint is configured as follows

module.exports = { root: true, parser: "babel-eslint", env: { es6: true, browser: true, node: true }, extends: [ "eslint:recommended", "plugin:react/recommended" ], parserOptions: { sourceType: "module", ecmaFeatures: { jsx: true } }, rules: { "no-unused-vars": 0, "react/display-name": 0, "react/prop-types": 0 }, settings: { react: { version: "16.4.2"}}}Copy the code

Configure JSX :true Enables support for JSX, configure ESLint :recommended Enables esLint core rules, and configure Plugin :react/recommended enables support for React semantics.

Eslint-plugin-react plugin: github.com/yannickcr/e…

The entrance

Write the entry component app.jsx

import React from "react"; import "./assets/app.css"; class Root extends React.Component { render() { return ( <div> <div className="title">This is a react ssr demo</div> <ul  className="nav"> <li>Bar</li> <li>Baz</li> <li>Foo</li> <li>TopList</li> </ul> <div className="view"> </div> </div> ); } } export default Root;Copy the code

Get the root component in the client entry and mount it

entry-client.js

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";

ReactDOM.hydrate(<App />, document.getElementById("app"));
Copy the code

React16 provides a function hydrate() that replaces render on the server side. Hydrate does not patch the DOM, only the text, if the text is not the same as the client-side text

Exports of the App component module.exports

entry-server.js

import React from "react";
import App from "./App";

module.exports = <App/>;
Copy the code

Start the service with Express in server.js to handle any GET requests. Get the root component from the server’s packaged JS, read the packaged index.html template, and map dist to express static resource directory with /dist as THE URL prefix (consistent with output.publicPath in the client’s packaged configuration). React provides the renderToString() function for server-side rendering, which is used to render components as HTML strings. This function is called to pass in the root component and replace the placeholders in the template with the RETURNED HTML string

server.js

const express = require("express"); const fs = require("fs"); const path = require("path"); const ReactDOMServer = require("react-dom/server"); const app = express(); let serverEntry = require(".. /dist/entry-server"); let template = fs.readFileSync("./dist/index.html", "utf-8"); App. use("/dist", express.static(path.join(__dirname, ".. /dist"))); app.use("/public", express.static(path.join(__dirname, ".. /public"))); /* eslint-disable no-console */ const render = (req, res) => { console.log("======enter server======"); console.log("visit url: " + req.url); let html = ReactDOMServer.renderToString(serverEntry); let htmlStr = template.replace("<! --react-ssr-outlet-->", `<div id='app'>${html}</div>`); // Send the rendered HTML string to the client res.send(htmlStr); } app.get("*", render); app.listen(3000, () => { console.log("Your app is running"); });Copy the code

The index.html before packaging looks like this

<! DOCTYPE html> <html lang="zh"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, Initial - scale = 1.0, user-scalable=no"> <link rel="shortcut icon" href="/public/favicon.ico"> <title>React SSR</title> </head> <body> <! --react-ssr-outlet--> </body> </html>Copy the code

run

Write scripts in package.json

"scripts": {
    "start": "node src/server.js",
    "build": "rimraf dist && npm run build:client && npm run build:server",
    "build:client": "webpack --config config/webpack.config.client.js",
    "build:server": "webpack --config config/webpack.config.server.js"
}
Copy the code

First runnpm run buildPackage the client and server and run itnpm run startStart the service, open your browser and enterhttp://localhost:3000. Open network in the browser to view the content returned by the serverYou can see the final rendered HTML content

Development environment hot update

The problem with running this way is that you need to package the client and server and restart the service after each code change. When the server is restarted, you need to scan the changed code to take effect, which greatly affects the development efficiency and experience in development mode. React provides a scaffolding for create-React -app, which internally uses webpack-dev-server as a development environment service to support hot updates. Some people will use scaffolding as the client and then express or KOA as the server, so the client and the server occupy two service ports and cannot be used in common. If the client uses WebPack and watch function without scaffolding, the server will make resource mapping for the packaged resources. Although the server and client share the same service, hot browser updates are not possible. It is better to use webpack-dev-middleware and Webpack-hot-middleware. Instead of writing packaged resources to disk, webpack-dev-middleware processes them in memory, recompiling when the contents of a file change. Webpack-hot-middleware is used for hot updates

Modify package.json first

"scripts": {
    "dev": "node src/server.js",
    "start": "cross-env NODE_ENV=production node src/server.js",
    "build": "rimraf dist && npm run build:client && npm run build:server",
    "build:client": "cross-env NODE_ENV=production webpack --config config/webpack.config.client.js",
    "build:server": "cross-env NODE_ENV=production webpack --config config/webpack.config.server.js"
}
Copy the code

Change the client and server packaging scripts to production, and the service startup scripts also add production identification. In server.js, determine if the current environment is a production environment. The production environment keeps its logic, and the non-production environment uses webpack-dev-middleware and webpack-hot-middleware for hot updates

const isProd = process.env.NODE_ENV === "production"; let serverEntry; let template; if (isProd) { serverEntry = require(".. /dist/entry-server"); template = fs.readFileSync("./dist/index.html", "utf-8"); App. use("/dist", express.static(path.join(__dirname, ".. /dist"))); } else { require("./setup-dev-server")(app, (entry, htmlTemplate) => { serverEntry = entry; template = htmlTemplate; }); }Copy the code

This code calls the function from module.exports in setup-dev-server.js, passing in the Express instance app object and a callback function once packaged. In setup-dev-server.js, the client uses Webpack-dev-middleware and webpack-hot-middleware, and the server uses Webpack and watches

Webpack-dev – Middleware Note: Webpack-dev – Middleware uses the 1.x version


The client

Use webpack functions packaged before webpack – hot – middleware/client added to the entry, add HotModuleReplacementPlugin plug-in (the plug-in used to enable hot update). Webpack-dev-middleware is packaged in memory and requires reading index.html from the file system and calling the incoming callback to pass out the template

const webpack = require("webpack"); const clientConfig = require(".. /config/webpack.config.client"); // Add hot update file clientconfig.entry. app = ["webpack-hot-middleware/client", clientconfig.entry.app]; clientConfig.output.filename = "static/js/[name].[hash].js"; clientConfig.plugins.push(new webpack.HotModuleReplacementPlugin()); // Client package const clientCompiler = webpack(clientConfig); const devMiddleware = require("webpack-dev-middleware")(clientCompiler, { publicPath: clientConfig.output.publicPath, noInfo: true }); // Use webpack-dev-middleware app.use(devMiddleware); clientCompiler.plugin("done", stats => { const info = stats.toJson(); if (stats.hasWarnings()) { console.warn(info.warnings); } if (stats.hasErrors()) { console.error(info.errors); return; } / / from webpack - dev - middleware middleware is stored in memory to read inddex after packaging. The HTML document template template = readFile (devMiddleware fileSystem, "index.html"); update(); }); Use (require("webpack-hot-middleware")(clientCompiler));Copy the code

The service side

Webpack can be packaged not only to disk but also to custom file systems such as in-memory file systems, using the outputFileSystem property to specify the file system to package the output. The server uses the watch function to detect file changes during packaging. After packaging, it also obtains the contents of entry-server.js file from memory. And ReactDOMServer. RenderToString (serverEntry) of the incoming object is a component, we need to use the compiled module in the node, instantiate a call _compile method module, the first parameter is the javascript code, The second custom name, and finally the object from module. Exports in entry-server.js

const webpack = require("webpack"); const MFS = require("memory-fs"); const serverConfig = require(".. /config/webpack.config.server"); Const serverCompiler = webpack(serverConfig); // Use memory file system const MFS = new MFS(); serverCompiler.outputFileSystem = mfs; serverCompiler.watch({}, (err, stats) => { const info = stats.toJson(); if (stats.hasWarnings()) { console.warn(info.warnings); } if (stats.hasErrors()) { console.error(info.errors); return; } // Read the packaged content and compile the module const bundle = readFile(MFS, "entry-server.js"); const m = new module.constructor(); m._compile(bundle, "entry-server.js"); serverEntry = m.exports; update(); });Copy the code
module.exports = function setupDevServer(app, callback) { let serverEntry; let template; const update = () => { if (serverEntry && template) { callback(serverEntry, template); }}... }Copy the code

Package and access synchronization

After running NPM run dev, the terminal displays the following information: open the browser and visit http://localhost:3000

E:\react- SSR > NPM run dev > [email protected] dev E:\react- SSR > node SRC /server.js Your app is runningCopy the code

The following error occurs

E:\ react-SSR > NPM run dev > [email protected] dev E:\ react-SSR > node SRC /server.js Your app is running ======enter server====== visit url: / TypeError: Cannot read property 'replace' of undefined at render (E:\react-ssr\src\server.js:32:26) at Layer.handle [as handle_request] (E:\react-ssr\node_modules\express\lib\r outer\layer.js:95:5) at next (E:\react-ssr\node_modules\express\lib\router\route.js:137:13) at Route.dispatch (E:\react-ssr\node_modules\express\lib\router\route.js:112 :3) at Layer.handle [as handle_request] (E:\react-ssr\node_modules\express\lib\r outer\layer.js:95:5) at E:\react-ssr\node_modules\express\lib\router\index.js:281:22 at param (E:\react-ssr\node_modules\express\lib\router\index.js:354:14) at param (E:\react-ssr\node_modules\express\lib\router\index.js:365:14) at Function.process_params (E:\react-ssr\node_modules\express\lib\router\ind ex.js:410:3)Copy the code

The code of the problem line

let htmlStr = template.replace("<! --react-ssr-outlet-->", `<div id='app'>${html}</div>`);Copy the code

After a period of time, the output is as follows

. at next (E:\react-ssr\node_modules\express\lib\router\index.js:275:10) at middleware (E:\react-ssr\node_modules\webpack-hot-middleware\middleware.j s:37:48) at Layer.handle [as handle_request] (E:\react-ssr\node_modules\express\lib\r outer\layer.js:95:5) at trim_prefix (E:\react-ssr\node_modules\express\lib\router\index.js:317:13 ) at E:\react-ssr\node_modules\express\lib\router\index.js:284:7 at Function.process_params (E:\react-ssr\node_modules\express\lib\router\ind ex.js:335:12) at next (E:\react-ssr\node_modules\express\lib\router\index.js:275:10) webpack built 545c3865aff0cdac2a64 in 3570msCopy the code

Webpack Built 545C3865AFF0CDAC2a64 in 3570ms Indicates that webpack is packed

This is because WebPack packages both the client and the server asynchronously, calling a callback function to assign a value to template when the packaging is complete. During the packaging process, the Express service is already started, and template is undefined when accessing the server. To synchronize browser requests and webpack package synchronization, Promise is used here

setup-dev-server.js

module.exports = function setupDevServer(app, callback) { let serverEntry; let template; let resolve; const readyPromise = new Promise(r => { resolve = r }); const update = () => { if (serverEntry && template) { callback(serverEntry, template); resolve(); // resolve Promise to render... return readyPromise; }Copy the code

Create a Promise instance, assign resolve to the external variable resolve, and return readyPromise. Calling resolve in the callback function makes the Promise become the fulfilled state

server.js

let serverEntry; let template; let readyPromise; if (isProd) { serverEntry = require(".. /dist/entry-server"); template = fs.readFileSync("./dist/index.html", "utf-8"); App. use("/dist", express.static(path.join(__dirname, ".. /dist"))); } else { readyPromise = require("./setup-dev-server")(app, (entry, htmlTemplate) => { serverEntry = entry; template = htmlTemplate; }); }Copy the code
app.get("*", isProd ? Render: (req, res) => {then(() => render(req, res)); });Copy the code

Express receives the GET request and calls the Render function only when the readyPromise becomes a fulfilled state.

Write hot update code

Run NPM run dev, visit http://localhost:3000, and open the network panel of your browser

Seeing the http://localhost:3000/__webpack_hmr request and [HMR] Connected in the console indicates that hot updates are in effect, but can you hot update now? Let’s try it out. App.jsx 更 新

This is a react SSR demo

The following output is displayed on the terminal

. webpack building... webpack built 6d23c952cd6c3bf01ed6 in 299msCopy the code

I don’t see any changes in the browser page, but I see a warning in the console panel

/ SRC/app.jsx has not been updated, so it cannot be hot updated.

The Webpack-hot-Middleware plugin is just a bridge between the browser and the server. It notifies the client when the server changes. It doesn’t actually do hot updates, so you need to use The WebPack’s HMR API to write hot updates

Description about webPack hot update

Webpack.js.org/concepts/ho… Webpack.js.org/guides/hot-…

In fact, webpack many loader plug-ins are their own implementation of hot update, the following is the style-loader plugin part of the source

style-loader/index.js

var hmr = [ // Hot Module Replacement, "if(module.hot) {", // When the styles change, update the <style> tags " module.hot.accept(" + loaderUtils.stringifyRequest(this, "!! " + request) + ", function() {", " var newContent = require(" + loaderUtils.stringifyRequest(this, "!! " + request) + ");", "", " if(typeof newContent === 'string') newContent = [[module.id, newContent, '']]; ", "", " var locals = (function(a, b) {", " var key, idx = 0;", "", " for(key in a) {", " if(!b || a[key] ! == b[key]) return false;", " idx++;", " }", "", " for(key in b) idx--;", "", " return idx === 0; ", " }(content.locals, newContent.locals));", "", // This error is caught and not shown and causes a full reload " if(! locals) throw new Error('Aborting CSS HMR due to changed css-modules locals.');", "", " update(newContent);", " }); ", "", // When the module is disposed, remove the <style> tags " module.hot.dispose(function() { update(); }); ", "}" ].join("\n");Copy the code

We write hot update code in entry-client.js with app.jsx as the hot update dependency entry

If (module.hot) {module.hot.accept("./ app.jsx ", () => {const NewApp = require("./App").default; ReactDOM.hydrate(<NewApp />, document.getElementById("app")); }); }Copy the code

This is a react SSR demo

The browser automatically updates the page content

conclusion

This section writes the client and server configuration when using WebPack packaging. How to use WebPack with Express to do hot updates, and how to use WebPack’s HMR API to do hot updates

Source code of this chapter

Next section: Isomorphism of front – and back-end routes