Using Webpack For Component System

Webpack is a module bundler. Its main purpose is to bundle JavaScript files for usage in a browser. It can also transform, bundle, or package just about any resource or asset. Redux is a predictable state container for JavaScript applications.

Reusable HTML components with Webpack

JavaScript components can be created using ES6 standard classes. However, to use those classes in Prysm, we need some type of loader/module bundler. Bundles created with Webpack v3 work perfectly with Prysm. With the right configuration, we can bundle JavaScript, HTML, CSS and load fonts and images.

The following guide will take us through the steps to set up an environment with Webpack and create a simple page, with reusable HTML and JavaScript components. We’ll use a Redux store to handle the app’s state. We’ll use Webpack to bundle the HTML, CSS and JavaScript. We’ll also use webpack loaders to handle different types of files.

You can see the live sample in the Coherent Prysm launcher.

A component

A component is any reusable piece of JavaScript or HTML. We can separate the components into two groups - HTML and JavaScript, or we can use them together as one component. For example, a component can be a simple line of HTML:

<div class="my-component"></div>

Or it can be more complex where interaction logic and styles are needed:

import '../styles/style.css';

class MyComponent() {
    constructor() {
        this.attachHandlers()
    }

    attachHandlers() {
        const componentElements =
            document.getElementsByClassName('my-component-elements');
        for(let i = 0; i < a.length; i++) {
            componentElements[i].addEventListener('click', () => {
                // do something on click
            });
        }
    }
}

Creating an application

Let’s begin the setup by specifying the folder structure of the application

ProjectDir:.
|   .babelrc
|   cohtml.js
|   package.json
|   webpack.config.js
|   webpack.dev.js
|   webpack.prod.js
|
+---master
|       index.html
|       index.js
|
\---src
    +---actions
    |       actions.js
    |
    +---components
    |   +---containers
    |   |   \---text-container
    |   |       |   text-container.html
    |   |       |   TextContainer.js
    |   |       |
    |   |       +---css
    |   |       |       text-component.css
    |   |       |
    |   |       \---img
    |   |               Gameface_white.png
    |   |
    |   \---presentational
    |       \---text-component
    |           |   text-component.html
    |           |   TextComponent.js
    |
    +---constants
    |       greetings.js
    |
    +---reducers
    |       ChangeText.js
    |
    \---store
            configureStore.js
            connect.js

All configuration files are located on the root level of the app - webpack.* files, babelrc, package.json.

The src folder is the source of the application:

  1. actions - the Redux actions, which will be dispatched when a store change occurs.
  2. constants - the action types.
  3. components - presentational and container components; the presentational components present the data and manage the DOM, the container components have access to the Redux store and can dispatch actions; this pattern is adopted from React; for more info - check this page
  4. reducers - the reducers specify how the state will be changed when an action is dispatched
  5. store - the store folder contains a configureStore module, which creates a new store. the store is a Singleton; the connect module provides helper functions, which allow us to access properties more descriptively.

Then we create the webpack config files

We’ll have production and development configurations.

The difference between the two will be that in the prod environment we’ll use source maps and the app will not be automatically rebuilt if a code change occurs.

webpack.config.js

module.exports = (env) => {
    return require(`./webpack.${env}.js`)
};

webpack.dev.js

const HtmlWebPackPlugin = require("html-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const HtmlWebpackIncludeAssetsPlugin = require('html-webpack-include-assets-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const path = require('path');

module.exports = {
    mode: 'development',
    entry: './master/index.js',
    watch: true,
    output: {
        filename: 'app.bundle.js',
        path: path.resolve(__dirname, 'dist')
    },
    module: {
        rules: [
            {
                test: /\.css$/,
                use: [
                    {
                        loader: MiniCssExtractPlugin.loader,
                        options: { publicPath: './', }
                    },
                    "css-loader"
                ]
            },
            {
                test: /\.(js|jsx)$/,
                exclude: /node_modules/,
                use: { loader: "babel-loader" }
            },
            {
                test: /\.(png|jpg|svg)$/,
                use: [{
                    loader: "file-loader",
                    options: { name: '[path][name].[ext]', outputPath: 'img' }
                }]
            },
            {
                test: /\.(ttf|otf)$/,
                use: [{
                    loader: "file-loader",
                    options: { name: '[name].[ext]', outputPath: 'fonts' }
                }]
            },
            {
                test: /\.html$/,
                use: [
                    {
                        loader: 'mustache-loader',
                        options: { delimiters: '{# #}' }
                    },
                    {
                        loader: 'html-loader',
                        options: { interpolate: true }
                    }
                ]
            }
        ]
    },
    plugins: [
        new HtmlWebPackPlugin({ template: "./master/index.html", filename: "./index.html" }),
        new MiniCssExtractPlugin({ filename: "styles.css", }),
        new HtmlWebpackIncludeAssetsPlugin({
            assets: ['./cohtml.js'], append: false
        }),
        new CopyWebpackPlugin([{ from: './cohtml.js', to: './' }])
    ]
};

webpack.prod.js

const HtmlWebPackPlugin = require("html-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const HtmlWebpackIncludeAssetsPlugin = require('html-webpack-include-assets-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const path = require('path');

module.exports = {
    mode: 'production',
    entry: './master/index.js',
    devtool: 'inline-sourcemap',
    output: {
        filename: 'app.bundle.js',
        path: path.resolve(__dirname, 'dist-prod')
    },
    module: {
        rules: [
            {
                test: /\.css$/,
                use: [
                    {
                        loader: MiniCssExtractPlugin.loader,
                        options: { 
                            publicPath: './',
                        }
                    },
                    "css-loader"
                ]
            },
            {
                test: /\.(js|jsx)$/,
                exclude: /node_modules/,
                use: { loader: "babel-loader" }
            },
            {
                test: /\.(png|jpg|svg)$/,
                use: [{
                    loader: "file-loader",
                    options: {
                        name: '[path][name].[ext]',
                        outputPath: 'img'
                    }
                }]
            },
                        {
                test: /\.(ttf|otf)$/,
                use: [{
                    loader: "file-loader",
                    options: { name: '[name].[ext]', outputPath: 'fonts' }
                }]
            },
            {
                test: /\.html$/,
                use: [
                    {
                        loader: 'mustache-loader',
                        options: {
                            delimiters: '{# #}'
                        }
                    },
                    {
                        loader: 'html-loader',
                        options: {
                            interpolate: true
                        }
                    }
                ]
            }
        ]
    },
    plugins: [
        new HtmlWebPackPlugin({
            template: "./master/index.html",
            filename: "./index.html"
        }),
        new MiniCssExtractPlugin({
            filename: "styles.css",
        }),
        new HtmlWebpackIncludeAssetsPlugin({
            assets: ['./cohtml.js'],
            append: false
        }),
        new CopyWebpackPlugin([
            { from: './cohtml.js', to: './'}
        ])
    ]
};

Now we can run webpack –env prod for minified production build and webpack –env dev for a development build with a watcher. Notice that the watch parameter is set to true. This ensures that webpack will re-bundle all files that have changed.

.babelrc

{
    "presets": [
        "@babel/preset-env",
        "@babel/preset-react"
    ]
}

We’ll add the constants next

We’ll use the constants in the actions to specify the type of the action. Using constants for action types is a good practice because it makes keeping the code cleaner. Read the Reducing Boilerplate page to find more benefits to defining action types as constants.

Let’s add the types of the actions

We’ll have two actions - sayHello and sayGoodbye.

src/constants/greetings.js

/**
* Constants - used in the actions
* Defining action types as constants makes it easier to maintain them as well
* as to avoid repeating string literals
*/
export const HELLO = 'HELLO';
export const FAREWELL = 'FAREWELL';

Then we’ll create the Actions

src/actions/actions.js

import { HELLO, FAREWELL } from '../constants/greetings';

// we will dispatch this action, when the Hello button is pressed
export function sayHello(message) {
    return {
        type: HELLO,
        message: message
    }
}

// we will dispatch this action, when the Goodbye button is pressed
export function sayGoodbye(message, name) {
    return {
        type: FAREWELL,
        message: `${message}, ${name}!`
    }
}

Let’s add the reducers next

We’ll only have one reducer. It will change the message property of the store. The initial message will be ‘Hello, CoherentLabs!’.

src/reducers/ChangeText.js

import { HELLO, FAREWELL } from '../constants/greetings';

const initialState = {
    message: 'Hello!'
};

function changeText(state = initialState, action) {
    switch (action.type) {
        case HELLO:
            return Object.assign({}, state, {
                message: action.message
            });
        case FAREWELL:
            return Object.assign({}, state, {
                message: `${action.message} It was nice seing you!`
            });
        default:
            return state
    }
}

export default changeText;

Now that we have actions and reducers, we need a store

src/store/configureStore.js

import { createStore } from 'redux';
import ChangeText from '../reducers/ChangeText';

export default function configureStore() {
return createStore(ChangeText)
};

src/store/connect.js

export function getMessage(store) {
    return store.getState().message
};

Now we need components

The components folder contains two types of components - containers and presentational. In this application, the presentational components will handle the interactions and the action dispatching.

src/components/containers/text-container/TextContainer.js

/**
* The TextContainer class has access to the store and can dispatch actions, as
* well as to subscribe to changes to the store
*/
import './css/text-container.css';
import TextComponent from '../../presentational/text-component/TextComponent';
import { getMessage } from '../../../store/connect';
import { sayHello, sayGoodbye } from '../../../actions/actions';

export default class TextContainer {
    constructor(store) {
        this.store = store;
        this.store.subscribe(() => { this.render() });

        this.textComponentEl = document.querySelector(`.${TextComponent.DOMElement}`);
        this.attachHandlers();
    }

    attachHandlers() {
        document.querySelector('.hello')
            .addEventListener('click', () => {
                this.store.dispatch(sayHello(`Hello Coherent Gameface, it's nice to see you!`))
            });

        document.querySelector('.goodbye')
            .addEventListener('click', () => {
                this.store.dispatch(sayGoodbye('Farewell', 'Coherent Gameface'))
            });
    }

    render() {
        const message = getMessage(this.store);
        this.textComponentEl.textContent = message;
    }
}

The TextContainer component attaches click event handlers to the sayHello and sayGoodbye buttons. When a click occurs an action is dispatched. The TextContainer includes the presentational TextComponent and updates its content on render.

src/components/presentational/text-component/TextComponent.js

/**
* A simple presentational component
* it doesn't have access to the store
* its main purpose is to display information
*/
class TextComponent {
    constructor() {
        this.DOMElement = 'message';
    }
}

export default new TextComponent();

This presentational component is very simple, but we can add logic, which describes and changes the look of the component, we can also call functions passed from the container component. Read the comparison of the container and presentation components in Redux.

We’ve created the store with its reducers and actions, and we’ve also created the app’s JavaScript components. Now we need to add the markup of the components.

src/components/containers/text-container/text-container.html

<div class="container welcome">
    <div class="greeting text">Welcome to </div>
</div>
<div class="container">
    <div class="logo">
    </div>
</div>
<div class="container">
    <div class="button hello">
        <span>Say hello!</span>
    </div>
    <div class="button goodbye">
        <span class="inner-text">Say goodbye!</span>
    </div>
</div>
<div class="container">
    ${require('../../presentational/text-component/text-component.html')()}
</div>

src/components/presentational/text-component/text-component.html

<p class="message text"></p>

We use the following syntax to load an HTML template into other HTML files:

${require(<path_to_component>)(<parameters>)}

The Webpack mustache-loader handles the loading of HTML files. It is possible to use dynamic content inside the HTML by passing parameters:

wrapper element

${require('../components/my-component.html')({text: 'Hello!'})}

included element

<div>{# text #}<div>

The text will be replaced with ‘Hello!’ in the included element.

Now that we have all components and the store set up, we can include them in the index.js and index.html files located in the master folder:

master/index.js

import './styles.css';
import TextObserver from
    '../src/components/containers/text-container/TextContainer';
import configureStore from '../src/store/configureStore';

class App {
    constructor() {
        // container component
        new TextObserver(configureStore());
    }
}

export default new App();

master/index.html

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>

<body>
    <div class="wrapper">
        ${require('../src/components/containers/text-container/text-container.html')()}
    </div>
</body>

</html>

Importing other files

We already saw that ES6 classes can be loaded into other JavaScript files using import and export statements. The babel-loader handles the loading and transpiling of the JavaSCript files. We also use css-loader for CSS files and a file loader for fonts and images. This is how you can import a file into another and it will automatically be included in the bundle:

An image

import './img/MyImage.png';

A font

import './fonts/MyFont.ttf';

A CSS file

import './styles/MyStyle.css';

Note that in the examples above we add PNG and TTF files. The current webpack configuration is set up for these types of files, but if you need to load any other type of file, for example, a JPEG image, not a PNG, just add it to the loader configuration:

{
    test: /\.(png|jpg|ttf)$/,
    use: [{
        loader: "file-loader",
        options: { name: '[path][name].[ext]', outputPath: 'my-path' }
    }]
}