Finding webpack 4 (use a Map)
Update: 03/02/2018
Tobias Koppers has written a migration guide for plugins / loaders as well - take a read here. It's very useful.
webpack 4
webpack 4 is on the horizon. The beta dropped last Friday. So what do you, as a plugin / loader author need to do? What needs to change to make your loader / plugin webpack 4 friendly?
This is a guide that should inform you about the changes you might need to make. It's based on my own experiences migrating ts-loader
and the fork-ts-checker-webpack-plugin
. If you'd like to see this in action then take a look at the PRs related to these. The ts-loader PR can be found here. The fork-ts-checker-webpack-plugin PR can be found here.
Plugins
One of the notable changes to webpack with v4 is the change to the plugin architecture. In terms of implications it's worth reading the comments made by Tobias Koppershere and here.
Previously, if your plugin was tapping into a compiler hook you'd write code that looked something like this:
this.compiler.plugin('watch-close', () => {
// do your thing here
});
With webpack 4 things done changed. You'd now write something like this:
this.compiler.hooks.watchClose.tap(
'name-to-identify-your-plugin-goes-here',
() => {
// do your thing here
},
);
Hopefully that's fairly clear; we're using the new hooks
property and tapping into our event of choice by camelCasing
what was previously kebab-cased
. So in this case plugin('watch-close' => hooks.watchClose.tap
.
In the example above we were attaching to a sync hook. Now let's look at an async hook:
this.compiler.plugin('watch-run', (watching, callback) => {
// do your thing here
callback();
});
This would change to be:
this.compiler.hooks.watchRun.tapAsync(
'name-to-identify-your-plugin-goes-here',
(compiler, callback) => {
// do your thing here
callback();
},
);
Note that rather than using tap
here, we're using tapAsync
. If you're more into promises there's a tapPromise
you could use instead.
Custom Hooks
Prior to webpack 4, you could use your own custom hooks within your plugin. Usage was as simple as this:
this.compiler.applyPluginsAsync('fork-ts-checker-service-before-start', () => {
// do your thing here
});
You can still use custom hooks with webpack 4, but there's a little more ceremony involved. Essentially, you need to tell webpack up front what you're planning. Not hard, I promise you.
First of all, you'll need to add the package tapable
as a dependency. Then, inside your plugin you'll need to import the type of hook that you want to use; in the case of the fork-ts-checker-webpack-plugin
we used both a sync and an async hook:
const AsyncSeriesHook = require('tapable').AsyncSeriesHook;
const SyncHook = require('tapable').SyncHook;
Then, inside your apply
method you need to register your hooks:
if (
this.compiler.hooks.forkTsCheckerServiceBeforeStart ||
this.compiler.hooks.forkTsCheckerCancel ||
// other hooks...
this.compiler.hooks.forkTsCheckerEmit
) {
throw new Error('fork-ts-checker-webpack-plugin hooks are already in use');
}
this.compiler.hooks.forkTsCheckerServiceBeforeStart = new AsyncSeriesHook([]);
this.compiler.hooks.forkTsCheckerCancel = new SyncHook([]);
// other sync hooks...
this.compiler.hooks.forkTsCheckerDone = new SyncHook([]);
If you're interested in backwards compatibility then you should use the _pluginCompat
to wire that in:
this.compiler._pluginCompat.tap('fork-ts-checker-webpack-plugin', (options) => {
switch (options.name) {
case 'fork-ts-checker-service-before-start':
options.async = true;
break;
case 'fork-ts-checker-cancel':
// other sync hooks...
case 'fork-ts-checker-done':
return true;
}
return undefined;
});
With your registration in place, you just need to replace your calls to compiler.applyPlugins('sync-hook-name',
and compiler.applyPluginsAsync('async-hook-name',
with calls to compiler.hooks.syncHookName.call(
and compiler.hooks.asyncHookName.callAsync(
. So to migrate our fork-ts-checker-service-before-start
hook we'd write:
this.compiler.hooks.forkTsCheckerServiceBeforeStart.callAsync(() => {
// do your thing here
});
Loaders
Loaders are impacted by the changes to the plugin architecture. Mostly this means applying the same plugin changes as discussed above. ts-loader
hooks into 2 plugin events:
loader._compiler.plugin('after-compile' /* callback goes here */);
loader._compiler.plugin('watch-run' /* callback goes here */);
With webpack 4 these become:
loader._compiler.hooks.afterCompile.tapAsync(
'ts-loader' /* callback goes here */,
);
loader._compiler.hooks.watchRun.tapAsync('ts-loader' /* callback goes here */);
Note again, we're using the string "ts-loader"
to identify our loader.
I need a Map
When I initially ported to webpack 4, ts-loader
simply wasn't working. In the end I tied this down to problems in our watch-run
callback. There's 2 things of note here.
Firstly, as per the changelog, the watch-run
hook now has the Compiler
as the first parameter. Previously this was a subproperty on the supplied watching
parameter. So swapping over to use the compiler directly was necessary. Incidentally, ts-loader
previously made use of the watching.startTime
property that was supplied in webpack's 1, 2 and 3. It seems to be coping without it; so hopefully that's fine.
Secondly, with webpack 4 it's "ES2015 all the things!" That is to say, with webpack now requiring a minimum of node 6, the codebase is free to start using ES2015. So if you're a consumer of compiler.fileTimestamps
(and ts-loader
is) then it's time to make a change to cater for the different API that a Map
offers instead of indexing into an object literal with a string
key.
What this means is, code that would once have looked like this:
Object.keys(watching.compiler.fileTimestamps)
.filter(
(filePath) =>
watching.compiler.fileTimestamps[filePath] > lastTimes[filePath],
)
.forEach((filePath) => {
lastTimes[filePath] = times[filePath];
// ...
});
Now looks more like this:
for (const [filePath, date] of compiler.fileTimestamps) {
if (date > lastTimes.get(filePath)) {
continue;
}
lastTimes.set(filePath, date);
// ...
}
Happy Porting!
I hope your own port to webpack 4 goes well. Do let me know if there's anything I've missed out / any inaccuracies etc and I'll update this guide.