📜 ⬆️ ⬇️

Simplify the universal / isomorphic application on React + Router + Redux + Express

On Habré, there were already plenty of articles on how to make a universal (isomorphic) application on the React + Redux + Router + Koa / Express stack ( Google help ), but I noticed that all of them contain duplicate code for server rendering. I decided to simplify the task and allocate this common code to the library, and Create React Server came into being, it works like this:


import Express from "express"; import config from "./webpack.config"; import createRouter from "./src/createRouter"; import createStore from "./src/createStore"; import {createExpressServer} from "create-react-server"; createExpressServer({ createRouter: (history) => (createRouter(history)), createStore: ({req, res}) => (createStore()), port: 3000 })); 

The proposed method can save power and save from copy-paste. In the approach itself, there is nothing fundamentally new, and for a deeper understanding, you can read the official documentation, as well as the articles on the links above. Next, I will try to briefly outline the essence of server rendering and the necessary preparatory steps.


The essence of server rendering is quite simple: on the server we need to determine, based on the router's rules, which component will be shown on the page, find out what data it needs to work, request this data, render HTML, and send this HTML along with the data to the client. If we want to be completely cool, we can still run through the component tree and load data for all of them (not only for the content area), but this is beyond the scope of the article, although it is planned for implementation in the library.


If you are too lazy to understand all this - take a look at other candidates, for example Next.JS and Electrode from my review article What to base React application on .


Customer


The preparatory stages are reduced to four things:


  1. To clear the code from all browser-specific goodness like window , DOM manipulations, direct access to location , history , document , etc., none of this is on the server. Anyway, this is a bad practice.
  2. The next step is to realize that every time you run the code you need to have a fresh context. Otherwise, requests from different clients will overlap. It is highly desirable to store data either locally or in the Redux Store, but not in the general code, there are only static things that do not change from request to request.
  3. It is highly desirable to analyze the code for memory leaks, on the server this will quickly become critical.
  4. Check and make sure that all used libraries are able to work from under the server.

Router


This is probably the easiest part. You only need to create a function that will return routes each time.


 import React from "react"; import {IndexRoute, Route} from "react-router"; import NotFound from './NotFound'; function def(promise) { return promise.then(cmp => cmp.default); } export default function() { return <Route path="/"> <IndexRoute getComponent={() => def(import('./App'))}/> <Route path='*' component={NotFound}/> </Router>; } 

Redux store


Many export the Redux Store instance in such a way that it becomes a singleton, and even access it not from under React components, you cannot do this on the server. Each request must have its own Store, so now we will export a function that, on each call, creates it based on the transferred initial state:


 import {createStore} from "redux"; import reducers from "./reducers"; export default function configureStore(initialState) { return createStore( reducers, initialState ); } 

Page (end point)


The router allows the server to find the desired page, and the page itself should let the server know what data it needs. For simplicity, we use the convention adopted in the framework of the NextJS framework: the static getInitialProps method. In this method, we need to make dispatch actions that will bring the store to the desired state and then return control to the outside.


 import {withWrapper} from "create-react-server/wrapper"; @connect(state => ({foo: state.foo})) @withWrapper() export default class Page extends React.Component { async getInitialProps({store, history, location, params, query, req, res}) { await store.dispatch({type: 'FOO', payload: 'foo'}); } render() { return ( <div> <div>{this.props.foo}</div> </div> ) } } 

Instead of async/await you can simply return a Promise or a specific value. Instead, annotations can be used as - export default connect(...)(Page) .


In order for the server to understand that the page is a stub for 404, you need to mark it with the static property notFound :


 import {withWrapper} from "create-react-server/wrapper"; @withWrapper export default class 404Page extends React.Component { static notFound = true; render() { return ( <h1>Not Found</h1> ) } } 

Application Initialization


Now we need to put everything together in the main entry point of the application. A witch with a router is especially necessary if asynchronous paths are used (in our example, it is, a whole one).


 import React from "react"; import {render} from "react-dom"; import {Provider} from "react-redux"; import {browserHistory, match, Router} from "react-router"; import createRoutes from "./routes"; import createStore from "./reduxStore"; import {WrapperProvider} from "create-react-server/wrapper"; const mountNode = document.getElementById('app'); const store = createStore(window.__INITIAL__STATE__); //      function renderRouter(routes, store, mountNode) { match({history: browserHistory, routes}, (error, redirect, props) => { render(( <Provider store={store}> <WrapperProvider initialProps={window.__INITIAL__PROPS__}> <Router {...props}>{routes}</Router> </WrapperProvider> </Provider> ), mountNode); }); } renderRouter(createRoutes(), store, mountNode); 

HTML template


In the example we use the HtmlWebpackPlugin plugin for convenience and automation. It is not necessary to do this, but index.html (or another file, how to configure) is required to participate in the Webpack assembly (that is, to get to the output path).


 <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>App</title> <body> <div id="app"></div> </body> </html> 

Server


So we got to the server side directly. When a request arrives at the server, the following chain of events occurs:


  1. the server tries to find a static file, if it does not work for him, the server tries to determine the final page through a router, if that fails, the router will give the NotFound stub
  2. creates a new redux store
  3. calls the getInitialProps page, throwing the newly created Store there
  4. waits for all asynchronous activity to end
  5. renders the application in an HTML string
  6. serializes the state of the Store and injects it and HTML into the template (simultaneously waiting for the template to be available, in the dev mode it is generated by the plugin)
  7. sends everything to the customer

Step 6 is necessary, otherwise the client will not be able to correctly apply its code to the resulting HTML due to a state mismatch, as a result, a warning will be displayed that the client has rendered from scratch and all server rendering bonuses have been ignored.


Training


 npm install babel-cli express webpack webpack-dev-server html-webpack-plugin --save-dev 

For babel-cli to work correctly, you need to either create a .babelrc , or babel section in package.json . Keep in mind that if you use babel-plugin-syntax-dynamic-import , then in the Webpack config itself you will need to create a separate config for Babel, which should not have babel-plugin-syntax-dynamic-import , but instead the following things: babel-plugin-dynamic-import-webpack and babel-plugin-transform-ensure-ignore (the first will replace import() with require.ensure , and the second with require.ensure with regular synchronous require ).


In the scripts section of your package.json add the following:


 { "scripts": { "build": "webpack --progress", "start": "webpack-dev-server --progress", "dev-server": "NODE_ENV=development babel-node ./index.js", "server": "NODE_ENV=production babel-node ./index.js" } } 

Thus, we will have 3 modes: without server rendering start , with rendering and dev-server on the fly, battle server mode (which requires pre-build build ).


For convenience, webpack.config.js will have a devServer section, where at least you need to register the port and where to get the files, as well as add HtmlWebpackPlugin to the plugins section:


 var HtmlWebpackPlugin = require('html-webpack-plugin'); module.exports = { //... "output": { path: process.cwd() + '/build', //    publicPath: '/', }, "plugins": [new HtmlWebpackPlugin({ filename: 'index.html', favicon: './src/favicon.ico', //   template: './src/index.html' })], devServer: { port: process.env.PORT || 3000, contentBase: './src', } //... } 

Create React Server


Now we will install the create-react-server package, which will ease the rendering process.


 npm install create-react-server --save-dev 

We will use the capabilities provided by the webpack-dev-server , and the rendering itself will occur by the same mechanism, but use different file systems (real for the combat mode, and virtual in memory for development). Middleware will take care of this.


Static server (skeleton)


Start by creating a regular static server in the server.js file:


 import Express from "express"; import webpack from "webpack"; import Server from "webpack-dev-server"; import config from "./webpack.config"; const port = process.env.PORT || 3000; //  if     if (process.env.NODE_ENV !== 'production') { const compiler = webpack(config); new Server(compiler, config.devServer) .listen(port, '0.0.0.0', listen); } else { const app = Express(); app.use(Express.static(config.output.path)); app.listen(port, listen); } function listen(err) { if (err) throw err; console.log('Listening %s', port); } 

Server side renderer


Now let's add the rendering directly, and add the middleware configuration for the imports:


 import path from "path"; import createRoutes from "./src/routes"; import createStore from "./src/reduxStore"; import { createExpressMiddleware, createWebpackMiddleware, skipRequireExtensions } from "create-react-server"; //   ,    NodeJS  -JS  skipRequireExtensions(); const options = { createRoutes: () => (createRoutes()), createStore: ({req, res}) => (createStore({ foo: Date.now() //   state     })), templatePath: path.join(config.output.path, 'index.html'), outputPath: config.output.path }; 

The template({template, html, store, initialProps, component, req, res}) function template({template, html, store, initialProps, component, req, res}) can also perform any other transformations with the template, as well as use any template engine instead of the banal .replace() , the output should be a regular HTML string.


You can also pass an errorTemplate for those cases when something absolutely badly broke and nothing was rendered (in fact, this is the 500th error on the server, an abnormal situation).


Now you need to replace the code for distributing statics with the configured middleware:


 if (process.env.NODE_ENV !== 'production') { const compiler = webpack(config); //     config.devServer.setup = function(app) { app.use(createWebpackMiddleware(compiler, config)(options)); }; new Server(compiler, config.devServer) .listen(port, '0.0.0.0', listen); } else { const app = Express(); //    ,  ! app.use(createExpressMiddleware(options)); app.use(Express.static(config.output.path)); app.listen(port, listen); } 

A full example is here: https://github.com/kirill-konshin/create-react-server/tree/master/examples/webpack-blocks/server.js .


Now it all remains to run:


 npm run dev-server 

What can be improved


Traversing all components in getInitialProps


There are plans to add this to the library in order to have a fully rendered site at the output, and not just the content area. With this there is a problem associated with settling the execution sequence and common data between calls within a single request, but this is all solved.


Server build


For combat mode, you can build a separate version of the server, so as not to use babel-cli in babel-cli , so we will win some memory and reduce the launch time. You can collect as a stand-alone Babel, as well as through an additional config for Webpack, you must specify {target: 'node', library: 'commonjs'} , and the input point must export createRouter and createStore . I will add this to the article, if there are requests in the comments, now for the sake of clarity everything is done as simply as possible.


renderToString optimization


At some point, the renderToString method, which is part of the React DOM, may be the bottleneck. You can deal with this, for example, https://github.com/walmartlabs/react-ssr-optimization, but this is beyond the scope of the article.


')

Source: https://habr.com/ru/post/323500/


All Articles