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 Promise
s 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 thehooks
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
andstage
- 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
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)