Building Static Sites without React - Using React (Part 3/3)

After configuring the development setup with Storybook for JS, CSS and other assets, it's time to finally create a build pipeline to publish our HTML, JS and CSS with Webpack.

The Javascript

As all the files are imported via useScripts hook in the second part, we have to traverse through each page file and collect all of the scripts and bundle them with Webpack.

More dependencies

We'll add Webpack, MiniCssExtractPlugin, CSSLoader and Lodash in this part, go ahead and run:
yarn add webpack webpack-cli lodash --dev

Lodash gives tons of useful array utility which we'll use in the next part.

Enter Webpack

Webpack is so popular that it comes hand-in-hand with every web development project nowadays. It bundles all the scattered JS imports from each ES6 module (or each .js, .jsx files) and handles stylesheets compilation for us. So, what's not to love?

The Entry Points

Since we're building on a per page basis, we treat each page as an entry point. The content of each entry will be the imported Javascripts within useScripts hook. We'll create a script that generates entryPoints.js file while traversing through pages.

Here's the overview of what we're doing:
In order to accumulate scripts, we'll create a utility module to keep track of scripts within each page component. 

The key idea is to traverse through the component tree by invoking React's render function and trigger side-effect operations to gather all scripts. 

Also, we can tap into the import statements to gather CSS imports as well.

To make it easier to understand, we'll see how we can use the utility first.

Let's modify our useScripts behavior for building.
// useScripts.js
import { useEffect } from "react";
// The utils we'll be building
import { addScripts } from "../scripts/buildUtils";

export function useScripts(scriptLoaders) {
  // Accumulate imported scripts during builds
  if (process.env.BUILD) {
    const scriptPaths = scriptLoaders.map((load) => {
      const stringified = load.toString();
      // We extract the import path from the stringified load function
      const match = stringified.match(/"([^"]*)"/);
      if (!match) {
        throw new Error(
          `Error finding matching imported module under \`useScripts\` hooks\nstringified:\n${stringified}`
        );
      }

      return match[1].replace(/\?.*$/, "");
    });

    // The parent's id is required to resolve the absolute path for the script
    addScripts(scriptPaths, module.parent.id);
    // Node caches only the first parent but we need the intermediate ones
    // so we have to clear it.
    delete require.cache[__filename];
  }

  useEffect(() => {
    loadScripts(scriptLoaders);
  });
}

// Each dynamic import is asynchronous
async function loadScripts(scriptLoaders) {
  for (const load of scriptLoaders) {
    await load();
  }
}


Now, the actual script that will invoke React's rendering and trigger side-effects, scripts/generateEntryPoints.js.
// generateEntryPoints.js
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import path from "path";
import fs from "fs";
import uniq from "lodash/uniq";
import glob from "glob";
// The utils are also used here
import {
  setCurrentChunkName,
  getScriptGroup,
  addScripts,
  getChunkNameFromPagePath,
} from "../scripts/buildUtils";

// Side-effects tapped into import statements for CSS files
require.extensions[".css"] = (cssModule) => {
  addScripts([cssModule.id], cssModule.parent.id);
};

const cwd = path.resolve(__dirname, "..");

const pages = glob.sync("pages/**/*.jsx", { cwd });

function main() {
  pages.forEach((pagePath) => {
    // We'll use each page's path as its own chunk name
    setCurrentChunkName(getChunkNameFromPagePath(pagePath));
    const PageComponent = require(path.resolve(cwd, pagePath)).default;
    // This is where the magic happens. It invokes React's rendering and will trigger
    // the side-effects to gather the scripts.
    renderToStaticMarkup(<PageComponent />);
  });

  const assetsImportContent = Object.entries(getScriptGroup())
    .map(([chunkName, scriptPaths]) => {
      return `  '${chunkName}': [${uniq(scriptPaths)
        .map((scriptPath) => `'${scriptPath}'`)
        .join(",\n")}]`;
    })
    .join(",\n");

  const assetsMapContent = `module.exports = {\n${assetsImportContent}\n}\n`;

  // The generated entry points are in object notation written in to a file.
  fs.writeFileSync(
    path.resolve(__dirname, "../.tmp/entries.js"),
    assetsMapContent
  );
}

main();

Finally, let's implement our utility, scripts/buildUtils.js
// buildUtils.js
import path from "path";

let currentChunkName;
const scriptGroup = {};

export function getScriptGroup() {
  return scriptGroup;
}

export function addScripts(scripts, parentId) {
  if (!scriptGroup[currentChunkName]) {
    scriptGroup[currentChunkName] = [];
  }
  const resolvedScripts = scripts.map((s) =>
    path.resolve(path.basename(parentId), s)
  );
  scriptGroup[currentChunkName].push(...resolvedScripts);
}

export function setCurrentChunkName(chunkName) {
  currentChunkName = chunkName;
  scriptGroup[currentChunkName] = [];
}

export function getChunkNameFromPagePath(pagePath) {
  return pagePath.replace(/\//g, "-");
}

Now our structure should look like this.
├── pages
│   ├── AboutUs.jsx
│   ├── Home.css
│   └── Home.jsx
├── scripts
│   ├── build.js
│   ├── buildUtils.js // added
│   └── generateEntryPoints.js // added
├── src
│   ├── about-us.js
│   ├── home.js
│   └── useScripts.js // modified
├── stories
│   └── pages.stories.js
├── babel.config.js
└── package.json

We can then add a script to our package.json to execute the generateEntryPoints.js script.
// package.json
"scripts": {
  "build:entries": "mkdir -p .tmp && BUILD=1 babel-node scripts/generateEntryPoints.js",
  ...
}

After we run
yarn build:entries

we should see a file at .tmp/entries.js which looks like this:
// entries.js
module.exports = {
  "pages-AboutUs.jsx": [
    "/abs/path/to/pages/Home.css",
    "/abs/path/to/src/about-us",
  ],
  "pages-Home.jsx": [
    "/abs/path/to/pages/Home.css",
    "/abs/path/to/src/home",
  ],
};

That's all set for our entry points. Now, we'll have Webpack bundle our JS and use its compilation stats to reference them back in our HTML. Let's add MiniCssExtractPlugin, CSSLoader and StatsWebpackPlugin
yarn add mini-css-extract-plugin css-loader stats-webpack-plugin --dev

The MiniCssExtractPlugin and CSSLoader will be used for extracting our final CSS files. StatsWebpackPlugin is needed to generate Webpack's compilation stats so that we can refer to the correct JS, CSS files built for each page.

And here's our configuration - just create the webpack.config.js file at the root of the project.
// webpack.config.js
const path = require("path");
const glob = require("glob");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const StatsPlugin = require("stats-webpack-plugin");

const entries = require("./.tmp/entries");

module.exports = {
  entry: {
    ...entries,
  },
  resolve: {
    extensions: [".js", ".jsx"],
  },
  output: {
    path: path.resolve(__dirname, "dist/assets"),
    publicPath: "/assets/",
    filename: "[name]-[contenthash].js",
  },
  mode: "production",
  target: "web",
  module: {
    rules: [
      {
        test: /\.css$/i,
        use: [MiniCssExtractPlugin.loader, "css-loader"],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin(),
    new StatsPlugin("../../.tmp/webpack-stats.json"),
  ],
};

Let's add the script for Webpack execution
"scripts": {
  ...
  "build:webpack": "webpack",
  ...
},

and run
yarn build:webpack

Now, you'll see the .tmp/webpack-stats.json file. We will use this as our reference for the compiled JS and CSS files.

The final piece of the puzzle that connects everything together is the scripts/build.js file. Let's modify it like this:
import React from "react";
import path from "path";
import fs from "fs";
import glob from "glob";
import { renderToStaticMarkup } from "react-dom/server";
import { spawnSync } from "child_process";
import webpackStats from "../.tmp/webpack-stats.json";
import { getChunkNameFromPagePath } from "./buildUtils";

require.extensions[".css"] = () => {};

const cwd = path.resolve(__dirname, "..");

// We glob all the files under "pages" directory
const reactFiles = glob.sync("pages/**/*.jsx", { cwd });

// Generate <link rel="stylesheet" href="..."> tags from Webpack's stats file
function generateStyleTags(assets) {
  function getCSSAssets() {
    if (!assets) {
      return [];
    }

    if (Array.isArray(assets)) {
      return assets.filter((asset) => asset.endsWith(".css"));
    }

    return assets.endsWith(".css") ? [assets] : [];
  }

  const cssAssets = getCSSAssets();

  return cssAssets.length > 0
    ? cssAssets
        .map((asset) => `<link rel="stylesheet" href="/assets/${asset}">`)
        .join("\n")
    : "";
}

// Generate <script type="text/javascript" src="..."> tags from Webpack's stats file
function generateScriptTags(assets) {
  function getJSAssets() {
    if (!assets) {
      return [];
    }

    if (Array.isArray(assets)) {
      return assets.filter((asset) => asset.endsWith(".js"));
    }

    return assets.endsWith(".js") ? [assets] : [];
  }

  const jsAssets = getJSAssets();

  return jsAssets.length > 0
    ? jsAssets
        .map(
          (asset) =>
            `<script type="text/javascript" src="/assets/${asset}"></script>`
        )
        .join("\n")
    : "";
}

reactFiles.forEach((reactFile) => {
  const fileName = path.basename(reactFile).replace(".jsx", "");

  // Page components are from "default" export
  const Component = require(path.resolve(__dirname, "../", reactFile)).default;
  const chunkName = getChunkNameFromPagePath(reactFile);
  const assets = webpackStats.assetsByChunkName[chunkName];
  const staticMarkup = renderToStaticMarkup(<Component />);

  // Generate HTML file name from ".jsx" files by replacing the extension with ".html"
  const htmlFile = path.resolve(
    __dirname,
    "../dist/",
    reactFile.replace(".jsx", ".html")
  );

  const dirName = path.dirname(htmlFile);

  // In case we have nested page structure, we need to create directory recursively.
  fs.mkdirSync(dirName, { recursive: true });

  fs.writeFileSync(
    htmlFile,
    `<!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>${fileName}</title>
  ${generateStyleTags(assets) /* CSS files are linked here */}
</head>
<body>
  ${staticMarkup}
  ${generateScriptTags(assets) /* JS files are linked here */}
</body>
</html>`,
    "utf8"
  );
});

Finally, let's modify our build script to run all 3 processes in sequence.
"scripts": {
  ...
  "build": "yarn build:entries && yarn build:webpack && babel-node scripts/build.js",
  ...
},

After that, we run
yarn build

Now, we can serve HTML, CSS and JS files using any static server. I prefer http-server. We can install it globally like this:
npm install --global http-server

Let's try running
http-server dist

and then go check http://127.0.0.1:8080/pages/Home.html

Voila!
Final Output

You can see in the inspected HTML that we have /assets/pages-Home.jsx.css file and /assets/pages-Home.jsx-...-js all linked correctly from the stats plugin and "LOOK, MOM! NO REACT!!!"

Whew! That was quite a marathon. To truly understand the concepts and the APIs used in this series we require a deep understanding of how Node.js, React and Webpack work altogether. This series aims to give some ideas of how a developer who's been exposed to the tech they use daily can come up with a way to solve problems. You can see lots of hacks and wild stuff going on. There is, of course, room for improvement, such as:
  • Hot reloading of scripts being imported under useScripts (Require import caches management)
  • Preloading final assets in the HTML
  • Refactoring to reduce the boilerplate code

Finally, the source code of the whole process can be found here.

Congratulations to all who've read till the end. I hope this gives you some insight into how we can create a plain static site with React. Feel free to leave me comments so I can improve my writing and knowledge sharing later on. Looking forward to it! That'll be it for now. Until next time.
Like 3 likes
Aun Trirongkit
Aun Jessada - A full-stack Javascript developer interested in frontend, animations and user interface interaction. Experienced in game development, React and NodeJS. Also, a part-time musician, singer and songwriter.
Share:

Join the conversation

This will be shown public
All comments are moderated

Get our stories delivered

From us to your inbox weekly.