This repository aims to show how to make a set of Node.js stack work, providing with a small example of graphics application working based on Babel, Webpack, Redux and D3 etc.
The behavior of the application was confirmed in Node.js 6.9.1 on OS X, and on Heroku.
The demo has only a flowing chart and a slider input. A new random data point is added to the chart every step, and its data range is controlled with the slider. We use D3 for rendering the chart and Redux for handling the data change.
$ git clone [email protected]:satzz/redux-d3-example.git
$ cd redux-d3-example
$ npm i
$ npm run build
$ npm start
That's it! The server starts at localhost:5000
.
The main files for the application are below.
├── client.js
├── package.json
├── scss
│ ├── InputRange.scss
| ...
├── server.js
├── views
│ └── index.pug
└── webpack.config.js
When working, it also generates a little bit more files as we will see later.
npm run build
defines two steps for hosting the application.
- Babel transpiles
client.js
intoclient.built.js
andserver.js
intoserver.built.js
. - Webpack bundles required modules for hosting.
package.json
:
"build": "babel server.js -o server.built.js && babel client.js -o client.built.js && webpack -v --config webpack.config.js",
We also need two Babel presets: babel-preset-es2015
for using import
and babel-preset-react
for transpiling React Components. If we want to use async
to mount koa middlewares, we need babel-preset-stage-0
.
And we also need babel-polyfill
to make browsers understand import
.
Webpack bundles up static files into static
directory.
webpack.config.js
:
const ExtractTextPlugin = require("extract-text-webpack-plugin");
const extractCSS = new ExtractTextPlugin('[name].css');
module.exports = {
entry: {
client: [
'./client.built.js',
],
page: [
'./scss/page.scss',
],
range: [
'./scss/InputRange.scss',
],
},
output: {
path: './static',
filename: "[name].js",
},
module: {
loaders: [
{
test: /\.scss$/,
loader: extractCSS.extract(['css', 'sass'])
},
],
},
plugins: [
extractCSS
],
}
We choose Koa
, "Express' spiritual successor" as our web framework.
Koa@2 allows to use async
instead of generators.
Some middlewares have different interfaces from those to Koa@1.
The server uses three middlewares(koa-router
, koa-static
, koa-pug
)
server.js
:
import 'babel-polyfill';
import Koa from 'koa';
const app = new Koa();
..
app.listen(process.env.PORT || 5000);
const router = require('koa-router')();
app.use(router.routes());
router.get('/', async (ctx) => {
ctx.render('index', { title: 'Koa + Redux + D3' });
});
const server = require('koa-static');
app.use(server('static'));
const Pug = require('koa-pug');
new Pug({
app,
viewPath: './views',
basedir: './views',
});
Pug is what was formerly known as Jade.
react-input-range
provides a component with a slider input interface.
It provides default scss files, which are copied and located in our scss/
.
These scss files are released under the MIT license.(https://github.com/davidchin/react-input-range/blob/master/LICENSE)
With Webpack, CSS also could be modularized. We only have to include InputRange.scss
as an entry point.
The CSS modules could be embedded in JS, but for now we use extractCSS
to keep CSS files separated from JS.
Now we are moving to the client side code.
React, as many of us love it, is now one of the first choices for rendering stateful DOMs.
We are free from the painful re-rendering of screen elements thanks to React, but we still have to manage our states.
With Redux, the main part of our experiment, we can describe and handle states in a unified way. We don't even call setState
of React.
A standard Redux application has three layers.
- Store: initializes and holds the state.
- Actions: describes what has happened(user inputs, time clocks, and so on.)
- Reducers: calculates the next state from the current state and the action given.
And when we use Redux, we will build two types of components, presentational and container. I cannot explain everything here, but the tutorial and the table here should be helpful for understanding.
Usually we separate these layers by directory structure and export functions and objects to each other, but we don't do so and write everything in our client.js
, just to keep our understanding simple.
Let's see a simple presentational component, Slider
. We can write it as a stateless functional component, which keeps the component pretty simple. We do not see render
.
client.js
:
const Slider = ({ onChange, min, max }) => (
<InputRange
maxValue={height}
minValue={0}
value={{ min, max }}
onChange={onChange}
/>
);
The props of the component are fed from outside, by react-redux
.
connect
generates a container component that works, connecting the store and the presentational component.
const SliderContainer = connect(
state => (
{
min: state.sliderMin,
max: state.sliderMax,
}
),
dispatch => (
{
onChange: (component, values) => {
dispatch(changeSliderMin(values.min));
dispatch(changeSliderMax(values.max));
},
}
),
)(Slider);
Action creators translate dispatched values into actions.
const changeSliderMin = value => (
{ type: 'CHANGE_SLIDER_MIN', value }
);
A reducer for the slider input could be like this. The next state will be just the value it receives (we will soon see the case where there is small calculation).
const sliderMin = (state = initialState.sliderMin, action) =>
(action.type === 'CHANGE_SLIDER_MIN' ? action.value : state);
Redux serves a magic, combineReducers
, which literally combines reducers into one reducer.
const rootReducer = combineReducers({
sliderMin,
sliderMax,
time,
chartData,
});
Now we create the store from reducers and initialize the state. What matters here is that reducers and the keys of the state should have the same name.
const initialState = {
sliderMin: Math.floor(height * 0.2),
sliderMax: Math.floor(height * 0.3),
time: 0,
chartData: [],
};
const store = createStore(rootReducer, initialState);
D3 is one of the popular visualization libraries. We use D3 to render SVG from data. Embedding D3 into React components can be sort of tricky, because both can handle the state. There seems no silver bullet, but in this case, life cycle methods of React meat the goal. Unfortunately, components with life cycle methods should not be functional but class-based.
class Chart extends Component {
componentDidMount() {
d3.interval(() => { this.props.onTick(this.props); }, interval);
}
componentDidUpdate() {
const line = d3.line()
.x(d => d.x)
.y(d => d.y);
d3.select('path')
.datum(this.props.chartData)
.attr('d', line);
}
render() {
return (
<svg width={width} height={height} >
<path
fill="none"
stroke="gray"
strokeWidth="2"
transform={this.props.transform}
/>
</svg>
);
}
}
this.props.onTick
is a function given by the store, as we will see below.
this.props.chartData
is a series of the position to render, as we can see in propTypes
.
The propTypes
definition is not mandatory to make the component work but I add it for ESLint.
Chart.propTypes = {
transform: PropTypes.string.isRequired,
onTick: PropTypes.func.isRequired,
chartData: PropTypes.arrayOf(PropTypes.shape({
x: PropTypes.number.isRequired,
y: PropTypes.number.isRequired,
}).isRequired).isRequired,
};
chartData
is calculated by a reducer. By a small math, it adds a random point to the state and remove old one.
const chartData = (state = initialState.chartData, action) => {
if (action.type !== 'TICK_TIME') { return state; }
const values = action.values;
const rnd = (2 * Math.random()) - 1;
const alpha = ((Math.atan(rnd) / Math.PI) * 2) + 0.5;
const y = values.min + ((values.max - values.min) * alpha);
const x = (values.time / visiblePoints) * width;
const a = state.concat({ x, y });
const extra = a.length - visiblePoints;
return extra >= 1 ? a.slice(extra) : a;
};
Likewise, time
is changed by TICK_TIME
action.
const time = (state = initialState.time, action) =>
(action.type === 'TICK_TIME' ? state + 1 : state);
TICK_TIME
is triggered by an action creator tickTime
.
const tickTime = values => (
{ type: 'TICK_TIME', values }
);
And tickTime
is dispatched on onTick
by connect
.
const ChartContainer = connect(
state => (
{
transform: `translate(${(1 - (state.time / visiblePoints)) * width} ${height}) scale(1 -1) `,
chartData: state.chartData,
time: state.time,
min: state.sliderMin,
max: state.sliderMax,
}
),
dispatch => (
{
onTick: (values) => {
dispatch(tickTime(values));
},
}
),
)(Chart);
To recap, here is how the SVG rendering works through the redux dataflow:
- After
Chart
component is mounted,d3.interval
starts and repeatedly triggersonTick
function. onTick
is dispatched totickTime
action creator, asconnect
defines so.tickTime
returnsTICK_TIME
action.- Two reducers,
time
andchartData
, calculate the next state fromTICK_TIME
action. Chart
component receives the state chanegs as props changes, asconnect
defines so.componentDidUpdate
calculates the SVG attributes by d3 and the new props.render
updates the SVG using the new attributes.
Linting does not matter to how the code works, but helps keeping the code under some rules.
By executing npm run lint
, we can check that.
package.json
:
"lint": "eslint client.js && eslint server.js",
We basically follow eslint-config-airbnb
, which is widely agreed, and turn off some of them exceptionally.
.eslintrc.json
:
{
"extends": "airbnb",
"env": {
"browser": true
},
"rules": {
"react/jsx-filename-extension": "off",
"no-new": "off"
}
}
ES6 provides the new syntaxes for defining variables securely, let
and const
.
Since our ESLint rules include no-var
and prefer-const
by default, we can keep ourselves from writing more var
and let
than necessary. For example, if we use let
to define width
, we'll get an error.
$ npm run lint
> [email protected] lint /Users/satzz/redux-d3-sample
> eslint client.js && eslint server.js
/Users/satzz/redux-d3-sample/client.js
8:5 error 'width' is never reassigned. Use 'const' instead prefer-const
✖ 1 problem (1 error, 0 warnings)
This repository has no tests. (Sorry!)
After making sure that the application works in local environments, we can do the same on Heroku. Here is how:
$ heroku create --app redux-d3-example
Creating ⬢ redux-d3-example... done
https://redux-d3-example.herokuapp.com/ | https://git.heroku.com/redux-d3-example.git
$ git push https://git.heroku.com/redux-d3-example.git master
..
remote: -----> Launching...
remote: Released v3
remote: https://redux-d3-example.herokuapp.com/ deployed to Heroku
remote:
remote: Verifying deploy... done.
To https://git.heroku.com/redux-d3-example.git
* [new branch] master -> master
Here is a working example.
Heroku automatically detects package.json
and recognizes that the application is written in and should be hosted using Node.js. Also, heroku-postbuild
command enables instruct Heroku what to do on pushing.
"heroku-postbuild": "npm run build",