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 .
The preparatory stages are reduced to four things:
window
, DOM manipulations, direct access to location
, history
, document
, etc., none of this is on the server. Anyway, this is a bad practice.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>; }
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 ); }
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> ) } }
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);
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>
So we got to the server side directly. When a request arrives at the server, the following chain of events occurs:
getInitialProps
page, throwing the newly created Store thereStep 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.
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', } //... }
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.
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); }
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
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.
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
optimizationAt 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