Refactor Blog: (1) Webpack

March 9, 2021
Recently, I rewrote my blog for the second time. Every time, I rewrite to solve particular problems.
The previous version's problems are the architecture: I used Gatsby for v2, and I didn't enjoy it. There are many plugins that I don't fully understand, and they handle a lot of things. I don't know what's under the hood of my blog while building it.
I wanted more control over my project.
That's why after I felt I had grasped enough knowledge to start a project from scratch, I started the rewrite straightaway. This time -- for the first time -- I'm not using npx create-react-app or gatsby new. I'm writing every line of the configs by myself.
If you've struggled with all those config setups, ex. webpack.config.js, babelrc, tsconfig.json -- I know the feeling. I used to be afraid of dealing with them, but not anymore. I hope this article helps others to achieve that too.

Dependencies

I used React and Typescript for this project. The minimum dependencies for this architecture are:
  • webpack, webpack-cli, @babel/core
  • babel-loader, @babel/preset-react
  • ts-loader, @babel/preset-typescript
  • babel-plugin-styled-components
  • @babel/preset-env, @babel/plugin-proposal-class-propoties, @babel/plugin-proposal-object-rest-spread, webpack-node-externals
  • all the @types

.babelrc

Some of the syntaxes or features that React development needs are not supported by all browsers yet. To ensure the project works in all environments, the source code needs to be compiled into a backward-compatible version. That's what babel does.
{
  "presets": [
    "@babel/preset-react",
    // tell babel it has to compile React
    "@babel/preset-typescript"
    // tell babel it has to compile Typescript
  ],
  "plugins": [
    "@babel/proposal-class-properties",
    "@babel/proposal-object-rest-spread",
    // enables above features of JavaScript in development
    "babel-plugin-styled-components"
    // I used styled-components in this project, this is the plugin to help babel process it
  ]
}

tsconfig.json

The configurations for typescript.
{
  "compilerOptions": {
    "target": "ES5",
    "module": "CommonJS",
    "lib": ["DOM", "ES2020"],
    "strict": true,
    "noImplicitAny": true,
    "moduleResolution": "Node",
    "jsx": "react"
  },
  "include": ["src"],
  "exclude": ["node_modules"]
}

includes

Define the paths with .ts files that need to be compiled.

excludes

The opposite of includes, define the paths that do NOT need compiling.

compilerOptions

Most of the typescript compiling options

target

The js (ECMA) version of the output. Use ES5 or a newer version without worrying about compatibility issues because babel will compile again.

module

Which pattern to use regarding modules (export). Use commonJS for applications running on node.

lib

Other libraries to be used in compilation. Add DOM if features like document or window are used.

moduleResolution

How ts compilation deals with modules (import). Use node to mock node's way to resolve module importing.

jsx

How to deal with .jsx files. Since we're developing React app, use react.

webpack.client.config.ts

I split the webpack configs for the front-end and the SSR server for clarity. Also, I write both my webpack config files in typescript instead of javascript, but it's totally optional. I only did so because I wanted to try. Choose the one you're more comfortable with.
import * as webpack from "webpack";
import { resolve } from "path";

const config: webpack.Configuration = {
  mode: "development",
  entry: "./src/client/index.tsx",
  context: resolve(__dirname),
  output: {
    filename: "[name].bundle.js",
    path: resolve(__dirname, "dist", "static"),
  },
  module: {
    rules: [
      {
        test: /\\.tsx?$/,
        exclude: /node_modules/,
        use: [
          {
            loader: "babel-loader",
          },
          {
            loader: "ts-loader",
            options: {
              configFile: "tsconfig.json",
            },
          },
        ],
      },
    ],
  },
  optimization: {
    minimize: true,
    splitChunks: {
      cacheGroups: {
        commons: {
          test: /node_modules/,
          name: "vendor",
          chunks: "initial",
        },
      },
    },
  },
  resolve: {
    extensions: [".ts", ".tsx", ".js", ".json"],
  },
  plugins: [
    new webpack.DefinePlugin({
      "process.env": {
        CONTENTFUL_TOKEN: JSON.stringify(process.env.CONTENTFUL_TOKEN || ""),
        CONTENTFUL_SPACE_ID: JSON.stringify(
          process.env.CONTENTFUL_SPACE_ID || ""
        ),
      },
    }),
  ],
  devtool: "inline-source-map",
};

export default config;

mode

Mostly changes the value of process.env.NODE_ENV on DefinePlugin. This can be set in command line parameter --mode too. I set this to develop and add --mode production in scripts for production building.

entry

The starting point of the project; the root file for every other file imported or used. Likely the index.js in src folder for MVVM frameworks.
There might be multiple entry files in an optimized setup. I plan to do it in the future.

context

The reference directory for the path of entry and loaders. The default value is the webpack config file's location, so it shouldn't be a big problem without it. However, it's still recommended to set context, so the configuration is independent of its location when dealing with paths.

output

The building result. This can be very optimized and complex, but I'm only using the minimum configuration here: filename for, well, the output filename, and path for the output folder path.

module

Options for specifying where (target files)and how (which tool)to build the source codes. One of the most important parts of webpack configuration.

test

The paths of target files, using regular expressions.

use

The loaders for building. At default, webpack can only parse .js and .json files, so we need to add custom loaders for packaging other types of files.
Pass an array to use multiple loaders for the same target. The loaders' applying order depends on their order in the array: from last to first (from right to left/ from bottom to top).
For packaging .ts files, you can add configFile in use to specifying the typescript config you want to use.

exclude

The opposite of test, specifying the file you wish not to package, like node modules.

resolve

The configurations for the modules.

extensions

The extensions for the files of the modules. Add the extensions that cover all of the files of the modules. Without this option, webpack may be unable to read some modules.

plugins

Miscellaneous tools to customize the build. I'm only adding DefinePlugin to define the environment variables in this configuration.

devtool

Build output is suppressed and sometimes uglified. It protects the source code, but it may make debugging more difficult too.
devtool generates a map between the output and the source, so the console logs can display which line of the source code is causing the error.

webpack.server.config.ts

The webpack configurations for SSR server code. The idea is the same as the client part, but there are some differences too.
import * as webpack from "webpack";
import { resolve } from "path";
import * as nodeExternals from "webpack-node-externals";

const config: webpack.Configuration = {
  mode: "development",
  entry: "./src/server/index.ts",
  context: resolve(__dirname),
  output: {
    filename: "server.bundle.js",
    path: resolve(__dirname, "dist", "server"),
  },
  target: "node",
  node: {
    __dirname: false,
    __filename: false,
  },
  module: {
    rules: [
      {
        test: /\\.tsx?$/,
        exclude: /node_modules/,
        use: ["babel-loader", "ts-loader"],
      },
    ],
  },
  resolve: {
    extensions: [".ts", ".tsx"],
  },
  externals: [nodeExternals()],
  plugins: [
    new webpack.DefinePlugin({
      "process.env": {
        CONTENTFUL_TOKEN: JSON.stringify(process.env.CONTENTFUL_TOKEN || ""),
        CONTENTFUL_SPACE_ID: JSON.stringify(
          process.env.CONTENTFUL_SPACE_ID || ""
        ),
        PORT: JSON.stringify(process.env.PORT) || 3000,
      },
    }),
  ],
};

export default config;

target

The environment that the output is supposed to run on. The default value is web, which is the browser-like environment. For the nodejs applications, use node.

externals

Excludes modules that are expected to be provided in the running environment. I use this to exclude node modules.

node

Configurations for the node-related parts in the source. I set __dirname and __filename so these two's behaviors are not changed during building.