webpack
makes its usage more flexible through the plugin
mechanism to adapt to various application scenarios. Of course, this greatly increases the complexity of webpack
. During the lifecycle of webpack
, many events will be broadcast, and plugin
can hook into these events to change the output results in the processing process through the APIs provided by webpack
at the appropriate time.
webpack
is a modern static module bundler for JavaScript applications. When webpack
processes an application, it recursively builds a dependency graph containing every module the application needs, and then bundles all of these modules into one or more bundles.
Using webpack
as a front-end build tool can usually achieve the following:
- Code transformation: compiling
TypeScript
toJavaScript
, compilingSCSS
toCSS
, and so on. - File optimization: compressing
JavaScript
,CSS
,HTML
code, compressing and merging images, and so on. - Code splitting: extracting common code from multiple pages, extracting code that does not need to be executed on the initial screen for asynchronous loading.
- Module merging: in a modular project, there are many modules and files that need to be categorized and merged into one file.
- Auto refresh: listening to changes in local source code, automatically rebuilding, refreshing the browser page, usually called hot module replacement (HMR).
- Code validation: before the code is submitted to the repository, it needs to be validated against coding standards and pass unit tests.
- Auto publishing: after updating the code, automatically build the production code and transfer it to the publishing system.
In a webpack
application, there are two core components:
- Module transformer, used to transform the original content of a module into new content as needed, and to load non-
js
modules. - Extension plugins, which inject extension logic at specific times in the
webpack
build process to change the build result or do what you want.
This article is about writing a simple webpack
plugin. Imagine a simple scenario: if we have implemented a multi-page Vue
application, each packed page will share a common header and footer, which are the top navigation bar and bottom footer. Since frameworks like Vue
load the header and footer at runtime, this part of the code can actually be developed as a standalone common sub-project, without the need to reference the component on every page of the multi-page application for the framework to parse the component. Additionally, when navigating between pages of a multi-page application, if a header component is written to be referenced within each page component, it is easy to experience navbar flashing due to the longer time needed to load and parse the JS
.
To address the issues mentioned above, one solution is to use static page fragments. During the webpack
packing process, we can inject the header and footer of the pages into the html
pages to be packed. This approach not only saves some JS
overhead for framework parsing of components, but also improves SEO
performance. Although it's only a header and footer with not much information, in a large amount of repeated CPU
tasks in an SSR
scenario, even a small improvement can have a significant impact overall, just like the line drawing algorithm in computer graphics, which can't stand too many computational iterations. Furthermore, this approach effectively solves the problem of component header flashing because it is returned with the HTML
and can be immediately rendered on the page without the need for JS
loading and parsing. Similarly, this approach can also be used for loading the skeleton screen. All the code mentioned in this article is available at https://github.com/WindrunnerMax/webpack-simple-environment
.
To explore webpack
, let's start by setting up a simple webpack
environment. First, initialize and install dependencies.
$ yarn init -y
$ yarn add -D webpack webpack-cli cross-env
You can first try packaging with webpack
. webpack
can be packaged with zero configuration. The directory structure is as follows:
webpack-simple
├── package.json
├── src
│ ├── index.js
│ └── sum.js
└── yarn.lock
// src/sum.js
export const add = (a, b) => a + b;
// src/index.js
import { add } from "./sum";
console.log(add(1, 1));
Then, add a packaging command.
// package.json
{
// ...
"scripts": {
"build": "webpack"
},
// ...
}
Run npm run build
, which will call the webpack
command under node_modules/.bin
by default. Internally, webpack-cli
will parse user parameters for packaging, and by default, it will use src/index.js
as the entry file.
$ npm run build
After the execution is completed, a warning will appear, reminding us that the default mode
is production
. At this point, you can see the appearance of the dist
folder, which is the final packaged result. Inside, there is a main.js
file. Webpack
will perform some syntax analysis and optimization, and you can see the structure of the completed package.
// src/main.js
(() => {
"use strict";
console.log(2);
})();
Of course, we generally don't use zero configurations for packaging, so we first create a file webpack.config.js
. Since webpack
says the default mode
is production
, let's solve this issue by configuring it. Because it's just a simple webpack
environment, we won't differentiate between webpack.dev.js
and webpack.prod.js
for configuration. Simply use process.env.NODE_ENV
to differentiate in webpack.config.js
. Here, we mainly care about the files after the dist
package. We won't handle the dev
environment or set up webpack-dev-server
here. cross-env
is a plugin used to configure environment variables.
// package.json
{
// ...
"scripts": {
"build": "cross-env NODE_ENV=production webpack --config webpack.config.js"
},
// ...
}
const path = require("path");
module.exports = {
mode: process.env.NODE_ENV,
entry: "./src/index.js",
output: {
filename: "index.js",
path: path.resolve(__dirname, "dist")
}
}
However, according to the above requirements, we not only need to handle js
files but also need to handle html
files, for which we need to use the html-webpack-plugin
plugin.
$ yarn add -D html-webpack-plugin
Then configure it in webpack.config.js
, simply configure the relevant input/output and compression information. Additionally, if you want to delete the dist
folder every time you package, you can consider using the clean-webpack-plugin
plugin.
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
mode: process.env.NODE_ENV,
entry: "./src/index.js",
output: {
filename: "index.js",
path: path.resolve(__dirname, "dist")
},
plugins: [
new HtmlWebpackPlugin({
title: "Webpack Template",
filename: "index.html",
template: path.resolve("./public/index.html"),
hash: true,
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
minifyCSS: true,
minifyJS: true,
},
inject: "body",
scriptLoading: "blocking"
})
]
}
Next, when it comes to the main body, we need to write a plugin to handle the requirements mentioned earlier. Specifically, what we need to do is first leave a comment tag similar to <!-- inject:name="head" -->
in the html
. Then after webpack
bundles the html
file, we need to perform a regular match to replace the information related to the comment with a page slice, distinguishing which page slice to load by the name
. Additionally, personally, when actually writing webpack
plugins, it's best to first refer to the implementations of webpack
plugins written by others. It's a bit costly to go through the documents and look up various hooks
on your own.
For this plugin, we will directly create a static-page-slice.js
in the root directory. The plugin is instantiated by a constructor function, and the constructor function defines the apply
method. When webpack
processes the plugin, the apply
method will be called once by the webpack compiler
. The apply
method can receive a reference to the webpack compiler
object, so that we can access the compiler
object in the callback function. The structure of the most basic Plugin
is similar to the following:
class BasicPlugin{
// Obtain the configuration passed in by the user in the constructor
constructor(options){
this.options = options || {};
}
// `Webpack` will call the `apply` method of the `BasicPlugin` instance to pass the `compiler` object to the plugin instance
apply(compiler){
compiler.hooks.someHook.tap("BasicPlugin", (params) => {
/* ... */
});
}
}
// Export the Plugin
module.exports = BasicPlugin;
When developing a plugin
, the two most commonly used objects are compiler
and compilation
. They are the bridge between the plugin
and webpack
. The meanings of compiler
and compilation
are as follows:
- The
compiler
object contains all the configuration information of thewebpack
environment, includingoptions
,loaders
, andplugins
. This object is instantiated whenwebpack
is started and is globally unique. It can be simply understood as thewebpack
instance. - The
compilation
object contains the current module resources, compiled generated resources, and changed files. Whenwebpack
is running in development mode, each time a file change is detected, a newcompilation
will be created. Thecompilation
object also provides many event callbacks for plugins to extend, and thecompiler
object can also be read through thecompilation
.
The difference between compiler
and compilation
is that compiler
represents the entire life cycle of webpack
from start to finish, while compilation
only represents a new compilation. The related information can be referred to at https://webpack.docschina.org/api/compiler-hooks/
.
Webpack
is like a production line. The source files must go through a series of processing before they can be converted into output results. The responsibilities of each processing step on this production line are single, and there are dependencies between multiple steps. Only after the current processing is completed can it be handed over to the next step for processing. Plugins are like features inserted into the production line, processing resources on the production line at specific times. Webpack
organizes this complex production line through tapeable
at https://github.com/webpack/tapable
.
Here, we choose to process resource files during the emit
period of the compiler
hook, which means executing before outputting the asset
to the output
directory. At this time, it is important to note that emit
is an AsyncSeriesHook
, which means it is an asynchronous hook
, so we need to use tapAsync
or tapPromise
from Tapeable
. If a synchronous hook
is chosen, tap
can be used instead.
class StaticPageSlice {
constructor(options) {
this.options = options || {};
}
apply(compiler) {
compiler.hooks.emit.tapPromise("StaticPageSlice", compilation => {
return new Promise(resolve => {
console.log("StaticPageSlice is being called")
resolve();
})
});
}
}
module.exports = StaticPageSlice;
Next, let's officially start processing the logic. First, we need to determine the type of this file. We only need to handle html
files, so we need to check if it's an html
file. Then it's a process of regular expression matching. After matching the comment information, we will replace it with a page fragment. We will directly simulate an asynchronous process using Promise
here for the page fragment. After that, we can reference it in webpack
and successfully bundle it.
// static-page-slice.js
const simulateRemoteData = key => {
const data = {
header: "<div>HEADER</div>",
footer: "<div>FOOTER</div>",
}
return Promise.resolve(data[key]);
}
class StaticPageSlice {
constructor(options) {
this.options = options || {}; // Parameters passed in
}
apply(compiler) {
compiler.hooks.emit.tapPromise("StaticPageSlice", compilation => {
return new Promise(resolve => {
const cache = {};
const assetKeys = Object.keys(compilation.assets);
for (const key of assetKeys) {
const isLastAsset = key === assetKeys[assetKeys.length - 1];
if (!/.*\.html$/.test(key)) {
if (isLastAsset) resolve();
continue;
}
let target = compilation.assets[key].source();
const matchedValues = target.matchAll(/<!-- inject:name="(\S*?)" -->/g); // `matchAll` function requires `Node v12.0.0` or higher
const tags = [];
for (const item of matchedValues) {
const [tag, name] = item;
tags.push({
tag,
name,
data: cache[name] ? cache[name] : simulateRemoteData(name),
});
}
Promise.all(tags.map(item => item.data))
.then(res => {
res.forEach((data, index) => {
const tag = tags[index].tag;
const name = tags[index].name;
if (!cache[name]) cache[name] = data;
target = target.replace(tag, data);
});
})
.then(() => {
compilation.assets[key] = {
source() {
return target;
},
size() {
return this.source().length;
},
};
})
.then(() => {
if (isLastAsset) resolve();
});
}
});
});
}
}
module.exports = StaticPageSlice;
// webpack.config.js
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const StaticPageSlice = require("./static-page-slice");
module.exports = {
mode: process.env.NODE_ENV,
entry: "./src/index.js",
output: {
filename: "index.js",
path:path.resolve(__dirname, "dist")
},
plugins:[
new HtmlWebpackPlugin({
title: "Webpack Template",
filename: "index.html", // File name after packaging, the root path is `module.exports.output.path`
template: path.resolve("./public/index.html"),
hash: true, // Add `hash` at the end of the referenced resource
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
minifyCSS: true,
minifyJS: true,
},
inject: "body", // `head`, `body`, `true`, `false`
scriptLoading: "blocking" // `blocking`, `defer`
}),
new StaticPageSlice({
url: "https://www.example.com/"
})
]
}
You can then see the difference between the html
files before and after packaging.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<!-- inject:name="header" -->
<div id="app"></div>
<!-- inject:name="footer" -->
<!-- built files will be auto injected -->
</body>
</html>
<!DOCTYPE html><html lang=en><head><meta charset=utf-8><meta http-equiv=X-UA-Compatible content="IE=edge"><meta name=viewport content="width=device-width,initial-scale=1"><title>Webpack Template</title></head><body><div>HEADER</div><div id=app></div><div>FOOTER</div><!-- built files will be auto injected --><script src=index.js?7e2c7994f2e0891ec351></script></body></html>
Webpack5
has made an update to the hooks
. Using the plugin above will prompt:
(node:5760) [DEP_WEBPACK_COMPILATION_ASSETS] DeprecationWarning: Compilation.assets will be frozen in future, all modifications are deprecated.
BREAKING CHANGE: No more changes should happen to Compilation.assets after sealing the Compilation.
Do changes to assets earlier, e. g. in Compilation.hooks.processAssets.
Make sure to select an appropriate stage from Compilation.PROCESS_ASSETS_STAGE_*.
Therefore, we can process the resources in advance according to its prompt to achieve the same effect.
// static-page-slice.js
const simulateRemoteData = key => {
const data = {
header: "<div>HEADER</div>",
footer: "<div>FOOTER</div>",
};
return Promise.resolve(data[key]);
};
class StaticPageSlice {
constructor(options) {
this.options = options || {}; // Passing parameters
}
apply(compiler) {
compiler.hooks.thisCompilation.tap("StaticPageSlice", compilation => {
compilation.hooks.processAssets.tapPromise(
{
name: "StaticPageSlice",
stage: compilation.constructor.PROCESS_ASSETS_STAGE_ADDITIONS,
additionalAssets: true,
},
assets => this.replaceAssets(assets, compilation)
);
});
}
replaceAssets(assets, compilation) {
return new Promise(resolve => {
const cache = {};
const assetKeys = Object.keys(assets);
for (const key of assetKeys) {
const isLastAsset = key === assetKeys[assetKeys.length - 1];
if (!/.*\.html$/.test(key)) {
if (isLastAsset) resolve();
continue;
}
let target = assets[key].source();
const matchedValues = target.matchAll(/<!-- inject:name="(\S*?)" -->/g); // The `matchAll` function requires `Node v12.0.0` or above
const tags = [];
for (const item of matchedValues) {
const [tag, name] = item;
tags.push({
tag,
name,
data: cache[name] ? cache[name] : simulateRemoteData(name),
});
}
Promise.all(tags.map(item => item.data))
.then(res => {
res.forEach((data, index) => {
const tag = tags[index].tag;
const name = tags[index].name;
if (!cache[name]) cache[name] = data;
target = target.replace(tag, data);
});
})
.then(() => {
compilation.assets[key] = {
source() {
return target;
},
size() {
return this.source().length;
},
};
})
.then(() => {
if (isLastAsset) resolve();
});
}
});
}
}
module.exports = StaticPageSlice;
https://github.com/WindrunnerMax/EveryDay
https://webpack.js.org/concepts/
https://juejin.cn/post/6854573216108085261
https://webpack.js.org/api/plugins/
https://juejin.cn/post/6844903942736838670
https://segmentfault.com/a/1190000012840742
https://segmentfault.com/a/1190000021821557
https://webpack.js.org/api/compilation-hooks/
https://webpack.js.org/api/normalmodulefactory-hooks/