The new plugin system (week 22–23)

2017/08/28–2017/09/10

One breaking change of webpack 4 will be the new plugin system. But why changing one of the core components of webpack? Isn’t the current version working great? This post will go into more details.

The problems with the current plugin system:

Performance

The plugin system is a core component of webpack. This means plugin hooks are called pretty often. Many of the hooks are in the hot path.

But the current version of the plugin system is written is a very generic way using arguments for any number of arguments and loops to cover any number of plugins from zero to many. The same code is used for every hook. This makes calling plugins polymorphic and difficult to optimize.

I was able to optimize a bit of the code by copying methods and manually specializing it for a fixed number of arguments, but this makes the API a bit weird: applyPlugins applyPlugins1 applyPlugins2 applyPlugins3 …

Typings

The very generic approach with any number of arguments plus hook name as string is also a source of problems like typos or incorrect arguments. When passing a not existing hook name it’s just silently accepted but don’t work. It would be great to get a runtime error in this situation.

Even better would be a compilation/linter error when using non-existent hooks or assuming incorrect arguments. This is possible with strongly typed languages like TypeScript/Flow. This single generic method approach doesn’t make providing these typings easy.

But typings are important for plugin authors and contributors. They make developing much easier.

Async

The current plugin assumes that async hooks use a callback function as last argument which is called with error or result. But recently Promises are pretty successful too. Of course you can wrap the plugins/hooks to promisify them, but native support would be better for usability and performance perspective. Best if you can mix callback-style, promise-style and synchronous plugins and hooks.

Looping hooks and Order of excecution

webpack uses the default hook calls in a creative way in the optimization phase.

while(
this.applyPluginsBailResult1("optimize-modules")
) { /* empty */ }

The optimize-modules hook is called repeatedly until it returns a falsy value. Plugins should return a truthy value when they modified something. A truthy value skips all remaining plugins (Bail), but restarts the complete chain (while). This usage keeps optimizing until all plugins are happy with the result.

The actually usage in webpack looks more like this:

while(
this.applyPluginsBailResult1("optimize-modules-basic") ||
this.applyPluginsBailResult1("optimize-modules") ||
this.applyPluginsBailResult1("optimize-modules-advanced")
) { /* empty */ }
this.applyPlugins1("after-optimize-modules");

This splits the hook into multiple phases. In this way plugins can choose in which order they are executed. (When adding plugins you can only append them to the end of list for the hook.) But it’s still limited to 4 phases basic normal advanced after.

Inspecting and Profiling

When calling webpack with the --progress argument you see basic information about the current state. You actually see the hook it’s currently processing in a readable matter. You won’t see the current plugin because the plugin system has no API to communicate this information.

Even if you find a hacky way to track these (like overriding the applyPlugins method), you only see functions but not the actual plugin names. It would be great if plugins are named.

An API to access this information would be awesome to profile builds and find slow plugins.

Inheritance

All classes using the plugin system have to extend Tapable to use the functionality. This is not a problem (yet), but a bit annoying since best practices point your to composition instead of inheritance.

The new API

I like to solve all problems above with a new API for plugins and hooks. It’s actually a pretty simple API but has some magic in the implementation.

  • All hooks live in a hooks object as property of the extensible class.
  • There are multiple Hook classes depending on the type of the hook: sync/async normal/bailing/waterfall/looping
  • You have to “register” hooks by creating a new Hook object as property of the hooks object: this.hooks = { myHook: new SyncHook(...) }
  • Each hook has a fixed number of arguments, with names: 
    new SyncHook([ "arg1", "arg2" ])
  • When adding plugins you can choose the type of the plugin (sync/callback/promise): .tap .tapAsync .tapPromise
  • When adding plugins you must provide a name.
  • When adding plugins you may provide order information like before and stage
  • When calling plugins you can choose the type of async: .call (for sync hooks) .callAsync (with callback argument) callPromise (returning a promise)
class MyExtensibleClass {
constructor() {
this.value = 0;
this.hooks = {
valueChanged: new SyncHook(["newValue", "oldValue"])
};
}
set(newValue) {
this.hooks.valueChanged.call(newValue, this.value);
this.value = newValue;
}
}

Ok great, doing all this above solves all problems except performance which is a topic for the implementation:

The implementation

Here is where the magic happens. The challenge is that the most performant implementation depends on a number of factors:

  • Is any plugin registered at all?
  • Is only a single plugin registered?
  • Are there only sync plugins registered?
  • Are there only callback-style or promise-style plugins registered?
  • Are different types mixed?
  • Is the hook inspected?
  • Is the hook called with callback-style or promise-style?

The good thing is that plugins rarely change. They are registered once at bootstrap and than called multiple times. We can optimize for this scenario.

When registering plugins we just put them all in a list (using insertion sort to insert them in correct order). Once one of the call method are used we “compile” an optimize call method depending on above factors. Each call method is initialized with a compile stub which triggers the compilation and than replaced with the optimize function. Adding new plugins will invalidate the compiled result, but this should happen rarely (never in webpack afaik).

“Compile” sounds expensive and complicated, but it’s just a switch-case with template literals containing the code. The generated code is passed to the Function constructor and a optimized function is generated by the javascript engine. Each function is compiled with the concrete arguments of the hook (that’s why the names are passed via the constructor) which should result in a monomorphic hook calls which can be even inlined. (I need to validate this. Maybe someone from the v8 team can give some insight.)

The nice result of this is that unused hooks have very little overhead. They compile to an empty function, which can be optimized “away” by the javascript engine. This allows us to happily add new hooks without adding a big performance cost.

Note that afaik in v8 Function and eval use a internal cache to cache the javascript parser/compiler results which speeds up repeated compiling of the same hook. Maybe someone from the v8 team can give some insight how this affects the monomorphicicity of the functions. Do they share bytecode/generated code? Does my approach of having concrete argument names (which roughly reflect types) help here or does it have negative impact?

The Challenges

Some usages of the plugin system are a bit challenging for the new API.

this.applyPluginsBailResult1("evaluate typeof " + name, expr)

The Parser uses dynamic hook names pretty often. Dynamic names are in conflict with the static nature of the new API. To solve with problem we can use a Map to map the dynamic part to hooks. A simple class which wraps this behavior is included in the tapable package.

// register
this.hooks = {
evaluateTypeof: new HookMap(() => new SyncBailHook(["expr"]))
};
// add plugin
this.hooks.evaluateTypeof.tap("require", "TypeofRequirePlugins",...)
// call
this.hooks.evaluateTypeof.for(name).call(expr);
// call with check (extra performance)
const typeofHook = this.hooks.evaluateTypeof.get(name);
if(typeofHook !== undefined)
typeofHook.call(expr);

Backward compatiblity

Of course we want to be as compatible as possible to the old implementation.

It’s possible to emulate most of the existing methods when we have a way to map old hook name to new hook name. In most cases it’s just a dashed-case to camelCase conversion, in some cases we need a custom function to map it.

plugin: We just call .tap resp. .tapAsync of the new API. We could generate a plugin name from the call stack but this would be expensive. Using fn.name is cheaper but less accurate. We also need to bind the fn to the this context as the old API called handlers this way.

applyPlugins: We just call .call of the new API.

We no longer support using not registered plugins. Every hook have to be registered. This may break existing plugins, but only a few.

Feedback

I would love some feedback on these changes. Did I missed something? Did I have wrong assumptions? Do you have a use case that we should consider? Could we improve type-ablity?

Now we have the chance to break stuff…


In case you missed it

Monthly contributors summary
Performance hints for TypeScript users

< week 20–21


webpack is not backed by a big company, unlike many other big Open Source products. The development is funded by donations. Please consider donating if you depend on webpack… (Ask your boss!)

Special thanks to these sponsors: (Top 5)

  • Trivago (Hotel metasearch) donated a total of $20,000
  • ag-Grid (DataGrid) donated a total of $17,500
  • Capital One (Bank) donated a total of $12,000
  • Segment (Data Infrastructure) donated a total of $12,000
  • Slack (Web-IM) donated a total of $8,000
  • Full list
Show your support

Clapping shows how much you appreciated Tobias Koppers’s story.