📜 ⬆️ ⬇️

Universal React + Express Applications (continued)

In the previous article , a simple draft of a universal application on React.js was considered, using only standard tools and code snippets from the official React.js documentation. But this is not enough for convenient development. It is necessary to form the environment so that there are standard features (for example, “hot” component overload) equally for both the server and the client part of the frontend.

The project from the previous article is built on the description of routes as a simple object:

// routes.js module.exports = [ { path: '/', exact: true, // component: Home, componentName: 'home' }, { path: '/users', exact: true, // component: UsersList, componentName: 'components/usersList', }, { path: '/users/:id', exact: true, // component: User, componentName: 'components/user', }, ]; 

This object also specifies code splitting. This is how it is configured for the client webpack:

 const webpack = require('webpack'); //to access built-in const HtmlWebpackPlugin = require('html-webpack-plugin'); //installed via npm const path = require('path'); const CommonsChunkPlugin = webpack.optimize.CommonsChunkPlugin; const nodeEnv = process.env.NODE_ENV || 'development'; const port = Number(process.env.PORT) || 3000; const isDevelopment = nodeEnv === 'development'; const routes = require('../src/react/routes'); const hotMiddlewareScript = `webpack-hot-middleware/client?path=/__webpack_hmr&timeout=20000`; const entry = {}; for (let i = 0; i < routes.length; i++ ) { entry[routes[i].componentName] = [ '../src/client.js', '../src/react/' + routes[i].componentName + '.js', ]; if (isDevelopment) { entry[routes[i].componentName].unshift(hotMiddlewareScript); } } module.exports = { name: 'client', target: 'web', cache: isDevelopment, devtool: isDevelopment ? 'cheap-module-source-map' : 'hidden-source-map', context: __dirname, entry, output: { path: path.resolve(__dirname, '../dist'), publicPath: isDevelopment ? '/static/' : '/static/', filename: isDevelopment ? '[name].bundle.js': '[name].[hash].bundle.js', chunkFilename: isDevelopment ? '[name].bundle.js': '[name].[hash].bundle.js', }, module: { rules: [{ test: /\.jsx?$/, exclude: /node_modules/, loader: "babel-loader", options: { cacheDirectory: isDevelopment, babelrc: false, presets: [ 'es2015', 'es2017', 'react', 'stage-0', 'stage-3' ], plugins: [ "transform-runtime", "syntax-dynamic-import", ].concat(isDevelopment ? [ ["react-transform", { "transforms": [{ "transform": "react-transform-hmr", "imports": ["react"], "locals": ["module"] }] }], ] : [ ] ), } } ] }, plugins: [ new webpack.optimize.OccurrenceOrderPlugin(), new webpack.HotModuleReplacementPlugin(), new webpack.NoEmitOnErrorsPlugin(), new webpack.NamedModulesPlugin(), //new webpack.optimize.UglifyJsPlugin(), function(compiler) { this.plugin("done", function(stats) { require("fs").writeFileSync(path.join(__dirname, "../dist", "stats.generated.js"), 'module.exports=' + JSON.stringify(stats.toJson().assetsByChunkName) + ';console.log(module.exports);\n'); }); } ].concat(isDevelopment ? [ ] : [ new CommonsChunkPlugin({ name: "common", minChunks: 2 }), ] ), }; 

Each fragment of the resulting code includes the common client.js entry client.js , the main component for the corresponding route name, and for the development environment also webpack-hot-middleware/client .
')
For the working build, a module is additionally formed with a code common to all components:

 new CommonsChunkPlugin({ name: "common", minChunks: 2 }), 

The minChunks value allows minChunks to control the size of the fragments. If set to 2, any section of the same code that is used in the two fragments will be moved to a file named common.bundle.js . Increasing the value allows reducing the size of the common.bundle.js module. And increases the size of other fragments.

For the server frontend build, another webpack configuration file is used:

 const webpack = require('webpack'); const path = require('path'); const nodeExternals = require('webpack-node-externals'); const externalFolder = new RegExp(`^${path.resolve(__dirname, '../src')}/(react|redux)/.*$`) const nodeEnv = process.env.NODE_ENV || 'development'; const isDevelopment = nodeEnv === 'development'; module.exports = { name: 'server', devtool: isDevelopment ? 'eval' : false, entry: './src/render.js', target: 'node', bail: !isDevelopment, externals: [ nodeExternals(), function(context, request, callback) { if (request == module.exports.entry || externalFolder.test(path.resolve(context, request))){ return callback(); } return callback(null, 'commonjs2 ' + request); } ], output: { path: path.resolve(__dirname, '../src'), filename: 'render.bundle.js', libraryTarget: 'commonjs2', }, module: { rules: [{ test: /\.jsx?$/, exclude: [/node_modules/], use: "babel-loader?retainLines=true" }] } }; 

It is much easier because we do not need to break the server code into fragments, as well as provide support for older versions of browsers (which do not support ES2017).

The devtool: 'eval' option for developer mode shows in the error message the real file and the line number of the source code.

The function defining directories not included in the build:

 const externalFolder = new RegExp(`^${path.resolve(__dirname, '../src')}/(react|redux)/.*$`); ... function(context, request, callback) { if (request == module.exports.entry || externalFolder.test(path.resolve(context, request))){ return callback(); } return callback(null, 'commonjs2 ' + request); } 

It is assumed that all modules except react and redux will be written taking into account the node.js capabilities and will not be converted to legacy JavaScript.

Now consider the server code, which can work in developer mode with hot reload, and in production mode:

 'use strict'; const path = require('path'); const createServer = require('http').createServer; const express = require('express'); const port = Number(process.env.PORT) || 3000; const api = require('./src/api/routes'); const app = express(); const serverPath = path.resolve(__dirname, './src/render.bundle.js'); let render = require(serverPath); let serverCompiler const nodeEnv = process.env.NODE_ENV || 'development'; const isDevelopment = nodeEnv === 'development'; app.set('env', nodeEnv); if (isDevelopment) { const webpack = require('webpack'); serverCompiler = webpack([require('./webpack/config.server')]); const webpackClientConfig = require('./webpack/config.client'); const webpackClientDevMiddleware = require('webpack-dev-middleware'); const webpackClientHotMiddleware = require('webpack-hot-middleware'); const clientCompiler = webpack(webpackClientConfig); app.use(webpackClientDevMiddleware(clientCompiler, { publicPath: webpackClientConfig.output.publicPath, headers: {'Access-Control-Allow-Origin': '*'}, stats: {colors: true}, historyApiFallback: true, })); app.use(webpackClientHotMiddleware(clientCompiler, { log: console.log, path: '/__webpack_hmr', heartbeat: 10 * 1000 })); app.use('/static', express.static('dist')); app.use('/api', api); app.use('/', (req, res, next) => render(req, res, next)); } else { app.use('/static', express.static('dist')); app.use('/api', api); app.use('/', render); } app.listen(port, () => { console.log(`Listening at ${port}`); }); if (isDevelopment) { const clearCache = () => { const cacheIds = Object.keys(require.cache); for (let id of cacheIds) { if (id === serverPath) { delete require.cache[id]; return; } } } const watch = () => { const compilerOptions = { aggregateTimeout: 300, poll: 150, }; serverCompiler.watch(compilerOptions, onServerChange); function onServerChange(err, stats) { if (err || stats.compilation && stats.compilation.errors && stats.compilation.errors.length) { console.log('Server bundling error:', err || stats.compilation.errors); } clearCache(); try { render = require(serverPath); } catch (ex) { console.log('Error detecded', ex) } return; } } watch(); } 

If with the listeners of the change in the client part of the frontend everything is clear and well described in the documentation, then with the server-side rendering I found the solution in the article and simplified it a bit. The point is that in the developer mode, the rendering function turns into another function, which always calls the most current version of the rendering function. In this case, after the compiler detects changes in the source files, the require cache is cleared and the compiled module is reloaded:

  clearCache(); try { render = require(serverPath); } catch (ex) { console.log('Error detecded', ex) } 

Now, when changing the source text of the components, both the server and client side of the code will be compiled, after which the component in the browser will reload. In parallel, the server rendering code of the component will be reloaded.

As often happens, the work done rested on an unforeseen moment. Code splitting is good. But how does an asynchronous loadable component behave in real life? Alas, all the routing and rendering code of React.js is synchronous, and the preloader is displayed at the time of the first component load (it can be made custom). But for this, did I start everything? Yet the solution was found. Based on the standard Link component, you can create an asynchronous AsyncLink component:

 import React from "react"; import PropTypes from "prop-types"; import invariant from "invariant"; import { Link, matchPath } from 'react-router-dom'; import routes from './routes'; const isModifiedEvent = event => !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey); class AsyncLink extends Link { handleClick = (event) => { if (this.props.onClick) this.props.onClick(event); if ( !event.defaultPrevented && // onClick prevented default event.button === 0 && // ignore everything but left clicks !this.props.target && // let browser handle "target=_blank" etc. !isModifiedEvent(event) // ignore clicks with modifier keys ) { event.preventDefault(); const { history } = this.context.router; const { replace, to } = this.props; function locate() { if (replace) { history.replace(to); } else { history.push(to); } } if (this.context.router.history.location.pathname) { const route = routes.find((route) => matchPath(this.props.to, route) ? route : null); if (route) { import(`${String('./' + route.componentName)}`).then(function() {locate();}) } else { locate(); } } else { locate(); } } }; } export default AsyncLink; 

In general, everything is quite smooth after that began to work.
https://github.com/apapacy/uni-react

apapacy@gmail.com
February 14, 2018

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


All Articles