-
-
Notifications
You must be signed in to change notification settings - Fork 4.2k
Extremely detailed guide to how react starter kit is wired and works, from beginning to end
Note: in order to keep this as detailed and relevant at the same time as possible, the guide currently only covers everything from running a tool like npm run start
to the end of server-side stuff. Still missing is a detailed explanation of what happens only on the client side.
The react-starter-kit
, or rsk
, is a yet another boilerplate. It is intended to provide an example of a complete React-based, universal application with multiple persistence stores, GraphQL, and a modular design. It happens to be one of the more popular and better-designed boilerplates available, but it is also a bit more complicated than the average boilerplate, mainly because of its complexity.
This guide explains in detail why the rsk
is constructed as it is, an explanation that is sorely lacking from the current project. That includes, crucially, some detail about the persistence mechanisms it uses and the tools
folder that backs all of the npm run
commands.
Project authors have recently opted to drop other task-running systems, including previous heavyweights like grunt
and gulp
, in favor of an npm
-only approach. rsk
follows this trend, and all of its tasks
are executed by running an npm
script defined in the scripts
node of the root package.json
.
These scripts
can be divided into two types: those that are executed by running the task running script in tools/run
, and those that are not. The interesting scripts
- like start
and build
and deploy
- all start with that tools/run
script, so it is worth examining.
The tools/run
script is very short and simple: it defines two functions, runs a conditional, and exports one function.
function format(time) {
return time.toTimeString().replace(/.*(\d{2}:\d{2}:\d{2}).*/, '$1');
}
function run(fn, options) {
const task = typeof fn.default === 'undefined' ? fn : fn.default;
const start = new Date();
console.log(
`[${format(start)}] Starting '${task.name}${options ? `(${options})` : ''}'...`
);
return task(options).then(resolution => {
const end = new Date();
const time = end.getTime() - start.getTime();
console.log(
`[${format(end)}] Finished '${task.name}${options ? `(${options})` : ''}' after ${time} ms`
);
return resolution;
});
}
if (require.main === module && process.argv.length > 2) {
delete require.cache[__filename]; // eslint-disable-line no-underscore-dangle
const module = require(`./${process.argv[2]}.js`).default;
run(module).catch(err => { console.error(err.stack); process.exit(1); });
}
export default run;
The run
function is explained in the next section, and the other function format(time)
is not important.
The other part of the script, the conditional inside of the if
block, runs whenever both of the following are true:
- The module is imported as a module rather than called as a command. That is,
require.main === module
if the module is running as an imported module and not as a command. - At least one argument is provided to the original
process
. Recall thatprocess.argv
always includes at least two arguments,['node', 'myScript.js']
, so the first "real" argument exists ifprocess.argv.length > 2
That second condition, by the way, doesn't seem to make a lot of sense at first glance. Why would the author check that this is not being called as a command and then parse out command arguments?
The answer is within the conditional:
delete require.cache[__filename]; // eslint-disable-line no-underscore-dangle
const module = require(`./${process.argv[2]}.js`).default;
run(module).catch(err => { console.error(err.stack); process.exit(1); });
The first line is a call to delete the require
cache for the current module's filename
. Whenever a module
is required
, it typically stays cached
for the duration of the request or command or run. However, using delete require.cache
, a developer can force the module
to reload.
That would be important - that is, run.js
needs to be reloaded - if a developer plans on calling run
more than once. The delete
removes the responsibility of clearing the cache from the developer.
The second line sets the module
to run
by reading the provided argument in process.argsv[2]
, which was determined to be set previously from the conditional. Again this begs a question: why is there an argument being provided to a require call in the first place, especially when it is a given that it is being called as a module and not a command?
In other words, if this is being used as a module, isn't it probably used as follows?
const run = require('./run')
run(something)
run(somethingElse)
The answer is yes, of course. But this is not the only way it can be used as a module. Here is another very important way in which it can be used as a module while being specified from the shell or command line.
$ babel-node tools/run start
Note that node
is the executable, tools/run
is the file being executed, and so, start
is actually the first argument (or the third, if you count the node
way). babel-node
is just node
that can run babel
on the fly and transpile code as necessary.
This can be confirmed by logging the process.argv
array anywhere in the run
script:
console.log(`Printing the args: ${process.argv}`)
The result is as follows:
Printing the args: node,/path/to/project/tools/run,start
1 2 3
Finally, the third line of the conditional does the interesting part - it calls run
on the requested module
loaded in the previous step, and handles any errors with a very simple error handler.
The run
function can be used directly if called as a command or from a module that is not providing an argument to run.
In either case, it accomplishes the same ends:
- Given
(fn, options)
, it sets aconst task
whose value is eitherfn
orfn.default
iffn.default
is defined. - It creates a
start Date()
and logs it to the console - It runs the
const task
with the providedoptions
if they exist and then runs a slightly-modified completion block that logs the end time and the duration
The only thing worth noting here is that fn
is not a particularly good choice of name for the argument that is actually needed by the run
method. In fact, promise
would be a much better name, because it is a Promise
- or an async function
- that is required as an input to run
, as evidenced by the way in which the completion block is set up. It assumes the then
method is available on task
, which in turn assumes that task
or fn
or fn.default
has the method, which is only true when fn
or the default is a Promise
.
With a clear understanding of what npm run X
is doing, the next obvious step is to examine what npm run start
in particular does - and how it does it. Because npm run start
kicks off or bootstraps the entire application on both client and server, it is fair to claim that understanding this script
is tantamount to understanding how the whole project works.
As evidenced by the previous section, npm run start
kicks off tools/run.js start
, which then executes run(start)
, which simply runs the async function
or Promise
named start.default
in tools/start.js
.
The async function start()
itself consists of three strictly sequential steps:
- Cleaning the target directory, accomplished in its first line,
await run(clean)
- Copying the necessary files over to the target directory, accomplished in its second line,
await run(copy.bind(undefined, { watch: true }))
- Everything else, all
webpack
-related, in its other hundred or so lines
The first two steps make use of tools/run.js
to call two other tools that are worth a look but not as important to understand, because they do something very obvious: cleaning and copying files.
The third step is a bit more eclectic but its high points can be summarized as follows:
- Patch the client
webpack
configuration to enableHMR
andReact Transform
forbabel
- Run
webpack(webpackConfig)
wherewebpackConfig
is derived after the previous step, and assign it tobundler
- Declare/assign
const wpMiddleware = webpackMiddleware(bundler, { ... some config ... })
- Also use the
bundler
andwebpackHotMiddleware
to derivehotMiddlewares
- Define a handler
handleServerBundleComplete
to kick off theserver
withrunServer
as soon as thebundler
is finished bundling. - Set the
bundler
's handler for the'done'
event to the handler in the previous step withbundler.plugin('done', () => handleServerBundleComplete());
It should be said, by the way, that this portion of the guide makes a lot more sense after a few casual but repeated glances at the actual tools/start.js
code.
If the steps above seem a bit random or out-of-place, or their motivation remains unclear, the following section explains what is happening and cruically why it is necessary.
From the developer's perspective, npm run start
has a single and loose kind of purpose: to "start" the application in some meaningful fashion, so that the developer can test it or otherwise use it locally.
From the other side of the coin, though, npm run start
is a tougher cookie to crack. To define such a task requires understanding what starting actually entails, which is more complicated for universal react
applications than one may be inclined to imagine.
Recall that a universal application is one that can run, at least in part, on both client
and server
, and from the same code base. Here is one way it can be accomplished in react
, and the way endorsed by the authors of rsk
:
- Define a set of routes that should be addressable on the client or server sides. In
rsk
, these routes are found atsrc/routes
. - Define an
express
-based server application and set upexpress
to handle this set ofroutes
, ideally using a universal-style router like theuniversal-router
used inrsk
. - For each server route, render both the
Html
container page and the innerApp
part of the page (very likely actually the entire page), at the end of each successful request - When the client finally loads, the
App
markup will be rendered already, but anycomponentDidMount
and subsequent calls will be handled by the client itself, just like any calls to otherroutes
will.
The file src/server.js
is responsible for doing steps three and four above; that is, for boostrapping the server, defining handlers for shared routes, and including a render method at the end of the handlers that mirrors the client render method.
So, one thing is or should be certain by now: npm run start
needs to finish with an import
and call to start bootstrapping the src/server
.
With the understanding that "bootstrapping the test application" and "starting the server" and "running src/server.js
" are synonymous, it is confusing that nearly all of the tools/start.js
file concerns webpack
. A better way of putting it may be: what does webpack
have to do with the server, and why must one wait for webpack
to finish before launching the server? Isn't webpack
more of a client-side concern?
For non-universal applications, this would be a very good question indeed. While webpack
is a great tool that has saved developers untold miliions of hours, it is far less important to bundle a server application prior to testing it locally. And furthermore, that could be accomplished without such a heavy-duty tool.
But what makes universal applications different, and what makes webpack
a prerequisite operation before starting src/server
, is that the server renders the client! Without a working client code base, the server would not be able to complete a request, so it is sensible to wait for webpack
to complete on both client and server before running the src/server
.
What is the webpack configuration file for, after all, if it doesn't suffice?
This is a good question and one that the authors should reconsider potentially. The reason that there is any webpack
configuration in start.js
owes to the fact that start.js
is specifically a script to start running the server on a local development environment and not in production. So, certain features - namely the much-touted HMR or hot module replacement - require additional configuration but only apply in development and not in production or release configuration.
The authors of rsk
opted to essentially add the relevant configuration for HMR in the tools/start.js
file rather than to default with the configuration and remove it from other places, which makes sense.
But it makes better sense to segregate this aspect of bootstrapping from the start
method, if only to make things a bit less hazy for the mere mortals out there. Here is a quick attempt at that separation, a new file tools/startFromWebpackConfig.js
:
import Browsersync from 'browser-sync'
import webpack from 'webpack'
import webpackMiddleware from 'webpack-middleware'
import webpackHotMiddleware from 'webpack-hot-middleware'
import run from './run'
import runServer from './runServer'
import clean from './clean'
import copy from './copy'
const DEBUG = !process.argv.includes('--release')
export function applyIfTargetIsNotNode (webpackConfig, toApply) {
webpackConfig.filter(x => x.target !== 'node').forEach(config => {
toApply(config)
})
return webpackConfig
}
export function applyHmr(config) {
/* eslint-disable no-param-reassign */
config.entry = ['webpack-hot-middleware/client'].concat(config.entry)
config.output.filename = config.output.filename.replace('[chunkhash]', '[hash]')
config.output.chunkFilename = config.output.chunkFilename.replace('[chunkhash]', '[hash]')
config.plugins.push(new webpack.HotModuleReplacementPlugin())
config.plugins.push(new webpack.NoErrorsPlugin())
config
.module
.loaders
.filter(x => x.loader === 'babel-loader')
.forEach(x => (x.query = {
...x.query,
// Wraps all React components into arbitrary transforms
// https://github.com/gaearon/babel-plugin-react-transform
plugins: [
...(x.query ? x.query.plugins : []),
['react-transform', {
transforms: [
{
transform: 'react-transform-hmr',
imports: ['react'],
locals: ['module'],
}, {
transform: 'react-transform-catch-errors',
imports: ['react', 'redbox-react'],
},
],
},
],
],
}))
/* eslint-enable no-param-reassign */
return config
}
export function applyHmrToWebpackConfig (wConfig) {
return applyIfTargetIsNotNode(wConfig, applyHmr)
}
export function runWebpackGetBundler(wConfig) {
return webpack(applyHmrToWebpackConfig(wConfig))
}
export function runWebpackGetBundlerAndMiddlewares (wConfig) {
console.log(`Running webpack get bundler`)
const bundler = runWebpackGetBundler(wConfig)
console.log(`Gettuing webpackMiddleware`)
const wpMiddleware = webpackMiddleware(bundler, {
publicPath: wConfig[0].output.publicPath,
stats: wConfig[0].stats,
})
console.log(`Getting hotMiddleware`)
const hotMiddlewares = bundler.compilers
.filter(compiler => compiler.options.target !== 'node')
.map(compiler => webpackHotMiddleware(compiler))
return {
bundler,
wpMiddleware,
hotMiddlewares,
}
}
export function startServerWithConfiguration (wConfig, resolve): void {
console.log(`Entering startServerWithConfiguration printed below`)
console.log(`${wConfig}`)
console.log(`Running webpack bundler and configuring middleware`)
const {
bundler,
wpMiddleware,
hotMiddlewares
} = runWebpackGetBundlerAndMiddlewares(wConfig)
let handleServerBundleComplete = () => {
console.log('starting')
runServer((err, host) => {
if (!err) {
const bs = Browsersync.create()
bs.init({
...(DEBUG ? {} : { notify: false, ui: false }),
proxy: {
target: host,
middleware: [wpMiddleware, ...hotMiddlewares],
},
// no need to watch '*.js' here, webpack will take care of it for us,
// including full page reloads if HMR won't work
files: ['build/content/**/*.*'],
}, resolve)
handleServerBundleComplete = runServer
}
})
}
console.log(`Setting handler for server bundle completion`)
bundler.plugin('done', () => handleServerBundleComplete())
console.log(`Exiting startServerWithConfiguration. All done!`)
}
export default startServerWithConfiguration
In the start
method, the following now suffices:
async function start() {
await run(clean)
await run(copy.bind(undefined, { watch: true }))
await new Promise(resolve => {
startFromWebpack(webpackConfig, resolve)
})
}
This is certainly not a necessary modification and there are better ways of accomplishing this end, but it does illustrate the point.
Although it would be nice to know all the details, it isn't all that important to know and it certainly isn't important in the context of understanding how the npm run start
actually starts the application specifically.
Let's recall what has been covered thus far:
- The interesting subset of
npm run
scripts (those that start or build or bundle the application) start by callingtools/run
and providing an argument,process.argsv[2]
, of which tool to start - The
tools/run.js
script is a very simple wrapper script that can be called multiple times and will clean up therequire
cache all by itself - The
tools/start.js
script is called bynpm run start
and does three things before startingsrc/server.js
: clean, copy, and configurewebpack
- The
webpack
configuration that occurs intools/start.js
by default can and probably ought to be moved to a separate set of smaller methods and in another file, as suggested above - The line at the end of the default
start
method,bundler.plugin('done', () => handleServerBundleComplete())
, is responsible for starting the server when thewebpack
ing and bundling is done. - The
handleServerBundleComplete
method calls therunServer
method with an argument in the form of a callback(Error, String) => void
.
When the start
method specifies a callback for the completion of bundler
, that callback is itself another call to runServer
with a callback in a particular shape:
runServer((err, host) => {
if (!err) {
const bs = Browsersync.create()
bs.init({ ...glossing over... }, resolve)
handleServerBundleComplete = runServer
}
})
That callback is the only argument to runServer
, which, as its name suggests, kicks off the src/server.js
.
Starting src/server.js
involves:
- Using the
cp = require('child_process')
methodcp.spawn
to spawn a new child process that runsnode
- Handling events from the child process
server = cp.spawn('node', [serverPath]), options
- Handling
server.once('exit')
- Handling
server.stdout.on('data')
or whenever the server has output data - Handling
server.stderr.on('data')
or whenever the server has error data
- Handling
The first time tools/runServer.js
is imported, the following housekeeping takes place immediately:
- The
const serverPath
is set in memory from the webpack configuration to the entry pointsrc/server.js
- The
server
child is declared but not assigned (it will be assigned whenrunServer
callscp.spawn
for a new child process) - The
exit
event ofprocess
(the parent) is set up to kill any children (server
) withprocess.on('exit', () => { if (server) server.kill(SIGTERM) })
Besides those three things taking place on the first import, everything else about runServer.js
is found in the single method runServer(cb)
, and everything it does is outlined in the previous section about starting `src/server.js
Nothing in runServer
is particularly important for developers to understand but it is sufficiently simple and short that understanding it takes very little time or effort.
The first thing that happens in runServer
is the assignment of an important local variable cbIsPending
to !!cb
.
function runServer(cb) {
let cbIsPending = !!cb
This is a bit sloppy, but it works. Doing !!cb
is exactly what it appears - that is, the same as let cbIsPending = !(cb == true)
or let cbIsPending = (cb == true) != true)
. The purpose of the double cast is to cast cbIsPending
to a boolean
that indicates whether or not cb
is null
. This will be used later to determine whether or not the callback should be executed - and a null callback, at the very least, should not be executed.
The next part is a function onStdOut(data)
that logs any stdout
output to the console with process.stdout.write
, and only one time, at most, also runs the callback if it is set. The one time it may run the callback is when match
is true
and cbIsPending
is true.
function onStdOut(data) {
const time = new Date().toTimeString();
const match = data.toString('utf8').match(RUNNING_REGEXP);
process.stdout.write(time.replace(/.*(\d{2}:\d{2}:\d{2}).*/, '[$1] '));
process.stdout.write(data);
if (match) {
server.stdout.removeListener('data', onStdOut);
server.stdout.on('data', x => process.stdout.write(x));
if (cb) {
cbIsPending = false;
cb(null, match[1]);
}
}
}
Now match
is only true when the server outputs a message that matches the regex, like:
The server is running at http://localhost:3001
The first time and probably the only time this will occur is when the server first starts, which is where the callback cb
comes into play.
If match
is true and cbIsPending
, meaning that cb
is not null and hasn't already been called, the program will set cbIsPending
to false
(preventing another run) and then run it with the parameters cb(null, match[1]
or the hostname.
The following kills any existing server
if it is set, then spawns and assigns to server
a new node
process set to serverPath
.
if (server) {
server.kill('SIGTERM');
}
server = cp.spawn('node', [serverPath], {
env: Object.assign({ NODE_ENV: 'development' }, process.env),
silent: false,
});
The reasoning here is that the server
child process should not exit
by itself without the callback being executed one time.
if (cbIsPending) {
server.once('exit', (code, signal) => {
if (cbIsPending) {
throw new Error(`Server terminated unexpectedly with code: ${code} signal: ${signal}`);
}
});
}
Pretty self-explanatory.
server.stdout.on('data', onStdOut);
server.stderr.on('data', x => process.stderr.write(x));
return server;
}
Once tools/runServer.js
starts node
with the right serverPath
, the src/server.js
file will start.
src/server.js
is a bootstrapper for the server, specifically, for express
web server.
The rkt
authors include quite a bit of special configuration in src/server.js
to parse. This configuration is necessary though because it accomplishes quite a bit, most of which is presented below in order of appearance:
- Sets up static rendering
- Sets up a cookie parser
- Sets up a request parser for URL-encoded, forms, and JSON
- Sets up
jwt
-based authentication for Express - Sets up
passport
-based Facebook OAuth authentication - Sets up
graphql
What is missing from the list above, of course, is the actual setup of the routing to render the routes that are the point of the application.
All of the above is good to know and understand, and will be covered later, but for now, the routing setup is a far more pressing matter to cover. That magic part of the server setup takes place over about 30 lines, lines 85 to 115, in the src/server.js
file.
All of the server-side routing is handled in a single call to app.get
, shown below with comments and additional formatting added for convenience. The application.get
method in express
is a shortcut method to set up middleware for handling HTTP/GET
requests, which are of course the kind produced by browsers when traversing links and addresses. The comments should generally suffice to explain the function call except for a few details about the call to UniversalRouter.resolve
:
app.get('*', async (req, res, next) => {
/***** OUTER TRY LOOP ****/
try {
/***** SETUP PARAMETERS FOR CALL TO UniversalRouter ****/
let css = []
let statusCode = 200
const data = { title: '', description: '', style: '', script: assets.main.js, children: '' }
/***** CALL (blocking) UniversalRouter(routes, configuration, renderFn) ****/
await UniversalRouter.resolve(routes, {
path: req.path,
query: req.query,
context: {
insertCss: (...styles) => {
// eslint-disable-line no-underscore-dangle, max-len
styles.forEach(style => css.push(style._getCss()))
},
setTitle: value => (data.title = value),
setMeta: (key, value) => (data[key] = value),
},
render(component, status = 200) {
css = []
statusCode = status
data.children = ReactDOM.renderToString(component)
data.style = css.join('')
return true
},
})
/***** WHEN UniversalRouter CALL DONE, RENDER/ASSIGN html ****/
const html = ReactDOM.renderToStaticMarkup(<Html {...data} />)
/***** SET statusCode, SEND html response *****/
res.status(statusCode)
res.send(`<!doctype html>${html}`)
} catch (err) {
/***** HANDLE ERROR BY SENDING IT TO OTHER MIDDLEWARE with next(err) *****/
next(err)
}
})
The birds-eye view of what takes place is the following and in sequential order:
- Within a
try/catch
block, assign some local variables includingdata
, which will be manipulated byUniversalRouter
- Call
UniversalRouter.resolve
and wait for it to finish - Render
html
by callingReactDOM
to render theHtml
component, providingdata
- Set the response status code and send the response with the
html
- On any error, send it to the
middleware
loop withnext
The configuration provided to UniversalRouter.resolve
is of obvious interest and can be broken down into two types: required configuration and custom configuration.
The custom configuration is found in the context
key of the configuration object, which includes three low-level methods for adding CSS to the response, setting the title, and setting the meta
.
The rest of the configuration shown is required configuration, and includes the path
and query
, which are easily derived from the request or req.path
and req.query
, as well as a special render
function.
The render
function is also quite simple, but makes more sense after (along with much of this explanation) after examining the code in its entirety, and especially after examining the Html
component. All the render
function achieves is:
- Setting the
data.children
property toReactDOM.renderToString(component)
, wherecomponent
is whatever needs to be rendered. - Setting the
data.style
property to the imploded value of thecss
array, which is set by thecontext
methodinsertCss
during the rendering call in the previous step
For the purposes of understanding how the application works, the rest of src/server.js
is definitely important and vital, but not as important as some of the other points left to cover. So for now, table questions about the rest of src/server.js
, and continue to src/components/Html.js
, which is the very last piece of this part of the high-level puzzle - and begs the right questions about the next part, the client
part.
The previous section reached the end of the server pipeline, the part of the application in which the server sends the response from the request.
The most critical thing to understand is how the UniversalRouter
plays a major role in the bridging of server and client, or rather, src/server.js
and src/client.js
.
With the information covered thus far it should be clear how the UniversalRouter
fits into the picture - when src/server.js
is imported, it makes a bunch of configuration-related calls, including an important one to app.get
to handle GET
requests. In that call to set up handling, the UniversalRouter
is itself configured with a similar call, from where the render
function is defined, rendering finally happens, and a response is sent back.
However, there is still a smaller missing link that must be addressed: what, exactly, does that response contain?
Of course, one could argue that since much of the server configuration has been skipped thus far, and much of that configuration pertains to request handling of other sorts, it follows that there are many other missing links that have yet to be covered.
That configuration will be covered later, but it does not relate very much to the most critical piece to understand - how the server and client bridge together. At least, not directly.
The Html
component, meaning a subclass of React.Component
, does relate because it defines the "wrapper" page that serves up the src/client.js
application. Every time a GET
request is handled successffuly, and even in the case of errors as will later be discovered, the Html
component is the component being rendered upon handling.
The following is an abridged reproduction of Html.js
source, missing some of the machinery and details that are not necessary to understand how it works:
function Html({ title, description, style, script, children }) {
return (
<html className="no-js" lang="">
<head>
<title>{title}</title>
<meta name="description" content={description} />
<style id="css" dangerouslySetInnerHTML={{ __html: style }} />
</head>
<body>
<div id="app" dangerouslySetInnerHTML={{ __html: children }} />
{script && <script src={script} />}
{/* Google Analytics code removed for clarity. */}
</body>
</html>
);
}
Html.propTypes = {
title: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
style: PropTypes.string.isRequired,
script: PropTypes.string,
children: PropTypes.string,
};
export default Html;
Recall that the data
hash was provided to the UniversalRouter.resolve
method and accessed from the render
method to render individual components. The pertinent code is reproduced below:
const data = { title: '', description: '', style: '', script: assets.main.js, children: '' }
await UniversalRouter.resolve(routes, {
path: req.path,
query: req.query,
context: {
insertCss: (...styles) => {
// eslint-disable-line no-underscore-dangle, max-len
styles.forEach(style => css.push(style._getCss()))
},
setTitle: value => (data.title = value),
setMeta: (key, value) => (data[key] = value),
},
render(component, status = 200) {
css = []
statusCode = status
data.children = ReactDOM.renderToString(component)
data.style = css.join('')
return true
},
})
const html = ReactDOM.renderToStaticMarkup(<Html {...data} />)
It should be very clear by now that data
is the props
of Html
, and further, that each member of data
is used somehow in the rendering of Html
.
- The
data.style
are meshed together and then rendered usingdangerouslySetInnerHTML
in thestyle
tag of thehead
- The
data.children
rendered by theReactDOM.renderToString(component)
call inrender()
and then assigned to theapp
div
interior - The
data.script
is a path provided asassets.main.js
, which is a build artifact; the path is set as ascript src
- The
data.title
is used to assign the<title>
- The
data.description
is used to assign the<meta>
description tag
The only pressing question really left outstanding is the perfect segue into the client world: how does data.children
actually get assigned by the UniversalRouter
? And how does data.style
get assigned at all?
The question of how routes are configured only makes sense to ask once it is clear how the configuration gets to matter in the first place. Now that the question of why it matters is resolved, it is time to examine how to use the UniversalRouter
to define both client and server routes.
One of the (not shown) imports to src/server.js
is the following:
import routes from './routes'
That import, in turn, actually resolves to src/routes/index.js
, which is the only proper route configured. All other routes are its children, which makes sense, because its path is given as /
, the root path.
The entirety of the single application route is given below (in reality, it is wrapped with export default
):
path: '/',
children: [home, contact, login, register, sandbox, content, error,],
async action({ next, render, context }) {
const component = await next()
if (component === undefined) return component
return render(<App context={context}>{component}</App>)
}
The path defines the pattern of the uri that should trigger this handler. Since the root route has no parent, the path is just the part that comes after the hostname (and port) in the uri. If it is changed to /monkey/
, all of the routes will suddenly be relative to /monkey/
, and all of the paths underneath /
besides monkey
will remain unhandled by this router.
The children
routes are evaluated IN ORDER of inclusion. They are defined relative to their parent, which in this case, means they are defined relative to the site root.
The route's action
is the important part of the route. It defines how the route will handle a given request, as parameterized by the single object with the keys next
, render
, and context
.
- The
next
key is the first route handler to handle the given request after this one. To get the firstchildren
handler, callnext()
- The
render
key is the function for rendering stuff that was defined previously in the set up forsrc/server.js
(recall that it is one of thedata
keys itself). - The
context
key is a key for holding custom contextual dependencies and information. In this case, thus far, it was configured to provide three useful functions from the server:insertCss
,setMeta
, andsetTitle
, which set thedata
keys forstyle
,description
, andtitle
respectively.
With this in mind it is easy to understand what is happening in the root render function.
- Call the
next()
method to get the next handler's return value byawait
ing its result - Return
undefined
ifnext()
returnsundefined
; otherwise, call the providedrender
function from the parent to render theApp
component with the givencontext
and with the value ofnext()
as its child
It is useful to understand how this all works together by examining a specific child route - for instance, the contact
route.
The folder structure of the src/routes/contact
folder includes three files: index.js
, which is the module imported when requiring src/routes/contact
, along with Contact.js
and Contact.css
. The index.js
file is really simple:
path: '/contact',
action() {
return <Contact />;
},
Once again, the path
is relative to the parent, which is the root, so to fire this route, hostname:port/contact
would be the right way.
The contact
route evidently has no children
, which means that it has no further routes to call with next()
.
Finally, the action
method is straightforward: return the Contact
component in the same folder. If it did have children, it could call next()
to have one of its children
potentially handle the route. But it would be pointless to call in this case because it is known in advance that there are no children to call.
The Contact
component is returned by this route. It is found in the same directory, which is a recurring pattern in this boilerplate's setup and a good practice: that is, route-specific Component
subclasses are kept in the same place as routes, and thus seperated from more general Component
subclasses found in src/components
.
Its code, including its imports, is provided below:
import React, { PropTypes } from 'react'
import withStyles from 'isomorphic-style-loader/lib/withStyles'
import s from './Contact.css'
const title = 'Contact Us'
function Contact(props, context) {
context.setTitle(title)
return (
<div className={s.root}>
<div className={s.container}>
<h1>{title}</h1>
<p>...</p>
</div>
</div>
)
}
Contact.contextTypes = { setTitle: PropTypes.func.isRequired }
export default withStyles(s)(Contact)
Quite a bit is actually happening here so it is important not to gloss over any of it.
The line context.setTitle(title)
uses the context
function defined way back in the server/src.js
setup to give the lowly Contact
component a means to set the <title>
tag for the entire page so that it matches what it returns in the markup.
Pretty cool, right?
The s
module is derived from Contact.css
, which is in turn served by webpack
in a very particular way for this to all work. That will be covered later, but please note that the className
rather than the class
are set (class
is a reserved keyword in JavaScript
) and that the className
set is not a string literal but a value from the object s
. This is intentional because the actual rendered classes may or may not be named as they are defined in the stylesheet for good reasons that will be explained later.
Here's a hint: withStyles
. It will be covered a bit later.
This is not strictly necessary but good practice. In essence, this is like saying, "the class Contact
will need a dependency setTitle
provided in its context
." Whether or not that is the case, the setTitle
method's availability would have been determined elsewhere (specifically, in the chain of calls passing context from the server
all the way to the parent route). But by defining the needs up front, the design is a bit less murky and if somebody forgets to provide some necessary context
key, it will be clear what is happening.
For now, just know that withStyles
takes as an input the imported CSS module and returns a function. That function in turn takes as an input a function that transforms a function into a Component
equipped with the styles defined in the argument to withStyles
.
In some sense, programming is all about wiring things together. Maybe it can be done in a more aesthetic way, or a more purposeful way, or a more telegraphic way, but one has to wire things together.
And in some sense, what has been covered in this section particularly and in the whole guide has been just that:
-
npm run start
is wired to a start script as defined bypackage.json
- The start script really calls
tools/run.js start
- The
tools/run.js
script wraps the call totools/start.js
, which prepareswebpack
for development usage and then instructswebpack
to starttools/runServer.js
when it is finished bundling -
tools/runServer.js
does just that: it runs thesrc/server.js
in a child process usingnpm
modulechild_process
, but also implements logic to handle some key events with a user-defined callback -
src/server.js
does a bunch of configuration of a newexpress
application, but the most important configuration it does pertains to route handling, which works as follows:- A single call to
app.get()
defines all the linkable routes (theGET
handlers for the application) - In the call to
app.get
, all requests are delegated to be handled by theUniversalRouter
- The
UniversalRouter
is configured to use the routes defined insrc/routes
- The routes in
src/routes
are really a bunch of child routes defined in subfolders, each of which renders a special component for those routes handled by the route in question (there are some components that can be used for multiple routes that have not been covered so far). - If a route in
src/routes
matches, the root route renders it and then returns anApp
component with thecontext
it was provided and a child equal to the matched child's rendered html.
- A single call to
- If the
app.get
call concludes with some route being handled and returning html, it returns the html. Otherwise, it calls the next middleware or handler.
Another aspect of wiring, or way of thinking about wiring, is to examine dependencies between parts of the whole.
The flow from npm run start
to a correctly-configured server is somewhat complicated but relatively linear.
Dependencies from the tools
folder tend to be simplistic and minimal by design; most of the them are in the form of continuations or function pointers (closures).
Once the application starts in src/server.js
, many dependencies are needed to configure and bootstrap the server, but most of them are outside one-time-use dependencies for very specific ends, like Facebook authentication or cookie parsing.
By the time application request handling kicks in, though, dependency analysis gets a bit more interesting and more potentially insightful.
By far the most consequential data structure thus discussed has been the data
argument provided to the Html
component being rendered at the end of a route being handled by the app.get
call in src/server.js
.
It is consequential because it is a dependency of so many consequential-themself dependencies!
Here it is again:
const data = { title: '', description: '', style: '', script: assets.main.js, children: '' }
Shortly after it is first introduced, the context
dependency is introduced and two methods are defined to manipulate data
along with its lesser cousin, css
:
context: {
insertCss: (...styles) => {
// eslint-disable-line no-underscore-dangle, max-len
styles.forEach(style => css.push(style._getCss()))
},
setTitle: value => (data.title = value),
setMeta: (key, value) => (data[key] = value),
},
The context
is particularly useful because it allows developers to arbitrarily introduce dependencies and share them from the top to the bottom. Recall that the Contact
component defined a contextTypes
which specified the need for a method setTitle
. That method need is satisfied by the context
defined and provided to UniversalRouter
.
Contact.contextTypes = { setTitle: PropTypes.func.isRequired }
The context
from this high level is passed through the initial route to the child route and eventually to the component without any of the glue typically involved. There is no reference to context
passed from the root-level route to the child, only a call to next
.
action() {
return <Contact />;
},
But the context
is provided to the Contact
indirectly: through the route handler when it renders the App
component:
async action({ next, render, context }) {
const component = await next()
if (component === undefined) return component
return render(
<App context={context}>
{component} // <---- in this case, component is <Contact />
</App>
)
}
Note that by dealing in Component
instances rather than raw HTML it is possible to late bind such dependencies - and to define them all in one convenient place like src/server.js
:
context: {
something_We_Need_Every_Time,
something_We_Need_Sometimes,
something_else_We_Need_Sometimes,
favoriteColor,
// something_we_dont_need_at_this_time
},
...and yet, the context can be set over over-written at a lower level when necessary:
val specialContext = {
context.something_We_Need_Every_Time,
something_else_We_Need_Sometimes,
favoriteColor: 'blue', // <---- "overriding" the favoriteColor
something_we_dont_need_at_this_time: 15 // <---- later binding
},
The render
function sets the data.children
and data.style
values. The render
function is itself passed first into the argument for UniversalRouter.resolve
, then as an argument to the root-level route in src/routes/index.js
:
render(component, status = 200) {
css = []
statusCode = status
data.children = ReactDOM.renderToString(component)
data.style = css.join('')
return true
},
Finally, data
is used to render Html
:
const html = ReactDOM.renderToStaticMarkup(<Html {...data} />)
Note of course that by visiting again the Html
component it is clear how and why each data
member is set up and used as it is.