Extensibility by Design: The Registry, Plugin System, and Component Lifecycle
Prerequisites
- ›Articles 1-3
- ›Understanding of JavaScript prototype chains
- ›Basic knowledge of the observer/hook pattern
Extensibility by Design: The Registry, Plugin System, and Component Lifecycle
Chart.js supports bar, line, doughnut, radar, polar area, bubble, scatter, and pie charts out of the box—but the system doesn't treat any of these as special. They're all registered components, identical in status to any third-party chart type you might create. This uniformity is the result of two key abstractions: the Registry (which manages component discovery and defaults merging) and the PluginService (which orchestrates lifecycle hooks). Together, they form the extensibility backbone of Chart.js.
Registry and TypedRegistry Architecture
The Registry singleton holds four TypedRegistry instances, one for each component category:
src/core/core.registry.js#L11-L19
classDiagram
class Registry {
+controllers: TypedRegistry~DatasetController~
+elements: TypedRegistry~Element~
+plugins: TypedRegistry~Object~
+scales: TypedRegistry~Scale~
-_typedRegistries: TypedRegistry[]
+add(...args)
+remove(...args)
-_each(method, args, typedRegistry?)
-_getRegistryForType(type)
}
class TypedRegistry {
+type: Constructor
+scope: string
+override: boolean
+items: Record~string, Component~
+isForType(type): boolean
+register(item): string
+unregister(item)
+get(id): Component
}
Registry "1" *-- "4" TypedRegistry
When Chart.register(BarController, LinearScale, BarElement) is called, the Registry needs to figure out which TypedRegistry each argument belongs to. The _getRegistryForType() method at line 161 iterates _typedRegistries and calls isForType() on each:
src/core/core.registry.js#L161-L170
The isForType() check uses prototype chain inspection: Object.prototype.isPrototypeOf.call(this.type.prototype, type.prototype). So a BarController matches the controllers registry because DatasetController.prototype is in its prototype chain. A LinearScale matches the scales registry because Scale.prototype is in its chain.
The order of _typedRegistries matters: [controllers, scales, elements]. Since Scale extends Element, scales must be checked before elements, or every scale would be incorrectly registered as an element. Plugins are the fallback—anything that doesn't match the other three registries is treated as a plugin.
The _each() method at line 124 also handles loopable arguments—when you pass a namespace like import * as controllers, it iterates the object's values and registers each one individually:
src/core/core.registry.js#L124-L146
Component Registration: Defaults Merging and Route Setup
When a component is registered via TypedRegistry.register(), several things happen in sequence:
src/core/core.typedRegistry.js#L8-L53
flowchart TD
REG["TypedRegistry.register(BarController)"] --> PROTO["Walk prototype chain"]
PROTO --> PARENT{"Parent has id & defaults?"}
PARENT -->|Yes| REC["Recursively register parent first"]
PARENT -->|No| CONT["Continue"]
REC --> CONT
CONT --> CHECK{"Already registered?"}
CHECK -->|Yes| SCOPE["Return existing scope"]
CHECK -->|No| STORE["Store in items[id]"]
STORE --> MERGE["registerDefaults:<br/>merge parent defaults + existing + item.defaults"]
MERGE --> ROUTE{"Has defaultRoutes?"}
ROUTE -->|Yes| ROUTES["routeDefaults:<br/>call defaults.route() for each"]
ROUTE -->|No| DESC{"Has descriptors?"}
ROUTES --> DESC
DESC -->|Yes| DESCRIBE["defaults.describe(scope, descriptors)"]
DESC -->|No| OVERRIDE{"Registry has override flag?"}
DESCRIBE --> OVERRIDE
OVERRIDE -->|Yes| OVERRIDES["defaults.override(id, item.overrides)"]
OVERRIDE -->|No| DONE["Done"]
OVERRIDES --> DONE
The registerDefaults() function at line 84 performs a three-way merge:
src/core/core.typedRegistry.js#L84-L101
- Parent scope defaults (e.g.,
DatasetController.defaults) - Any existing defaults at the target scope
- The component's own
defaultsproperty
This means defaults cascade through the prototype chain. A BarController inherits DatasetController's defaults and can override specific values. The routeDefaults() call at line 103 then establishes live fallback chains via defaults.route(), as we explored in Article 3.
Tip: When creating a custom chart type, define
static defaults = { ... }for base configuration andstatic defaultRoutes = { backgroundColor: 'color' }for properties that should fall back to global defaults. This ensures your component integrates naturally with the theme system.
PluginService and the Plugin Lifecycle
The PluginService class manages a rich lifecycle for plugins, with four distinct phases:
src/core/core.plugins.js#L20-L120
sequenceDiagram
participant Chart as Chart
participant PS as PluginService
participant Plugin as Plugin
Note over PS: On 'beforeInit' hook:
PS->>PS: _createDescriptors(chart, true)
PS->>Plugin: install(chart, args, options)
Note over PS: Normal operation:
PS->>Plugin: beforeInit / afterInit
PS->>Plugin: beforeUpdate / afterUpdate
PS->>Plugin: beforeLayout / afterLayout
PS->>Plugin: beforeDraw / afterDraw
PS->>Plugin: ...all other hooks...
Note over PS: On 'afterDestroy' hook:
PS->>Plugin: afterDestroy
PS->>Plugin: stop(chart)
PS->>Plugin: uninstall(chart)
PS->>PS: _init = undefined
The lifecycle phases are:
- install — Called once when the chart initializes. Used for one-time setup like adding DOM elements.
- start — Called when a plugin becomes active on a chart (including after plugin cache invalidation and re-evaluation).
- Chart hooks — The standard
before/afterpairs for each lifecycle event (init, update, layout, datasets, draw, etc.). - stop — Called when a plugin becomes inactive (e.g., disabled via options, or chart is being destroyed).
- uninstall — Called once during chart destruction, for final cleanup.
The notify() method at line 35 is the gateway for all hook calls. It handles the beforeInit hook specially—that's when _createDescriptors() is first called to build the plugin list, and install is invoked. The afterDestroy hook triggers the teardown sequence: stop then uninstall.
The invalidate() method at line 73 has a subtle safeguard against double-invalidation:
src/core/core.plugins.js#L73-L83
When plugins are re-registered, the cache might be invalidated twice before _descriptors() is called. The _oldCache pattern preserves the previous descriptor list so that _notifyStateChanges() can correctly determine which plugins were added or removed and call start/stop accordingly.
Anatomy of Built-in Plugins
The Colors Plugin (Simplest Example)
src/plugins/plugin.colors.ts#L94-L127
The Colors plugin is the minimal pattern. It has an id, defaults, and a single hook (beforeLayout). Its job is to auto-assign palette colors to datasets that don't define their own. The logic checks if any dataset already has explicit colors—if so, it backs off entirely unless forceOverride is true.
The palette is a simple 7-color array cycled via modulo. For doughnut and polar area charts, each data point gets its own color; for other chart types, each dataset gets a single border/background pair.
The Decimation Plugin (Algorithm-Focused)
src/plugins/plugin.decimation.js#L3-L60
The Decimation plugin implements the Largest Triangle Three Buckets (LTTB) algorithm for reducing large datasets to a visually representative subset. The algorithm divides data into buckets and selects the point from each bucket that forms the largest triangle with its neighbors—preserving the visual shape of the data while dramatically reducing the number of points to render.
The Filler Plugin (Multi-File Complexity)
src/plugins/plugin.filler/index.js#L12-L60
The Filler plugin shows what happens when plugin complexity grows. It's split across multiple files (index.js, filler.drawing.js, filler.helper.js, filler.options.js) and uses multiple hooks (afterDatasetsUpdate, beforeDraw, beforeDatasetsDraw, beforeDatasetDraw). It computes fill targets, resolves references between datasets (dataset A filled to dataset B), and handles the actual Canvas fill drawing.
Writing a Custom Plugin: Patterns and Hooks
A Chart.js plugin is any object with an id string and one or more hook methods. The minimal pattern:
const myPlugin = {
id: 'myPlugin',
defaults: {
enabled: true,
color: '#ff0000'
},
beforeDraw(chart, args, options) {
if (!options.enabled) return;
// options.color is resolved through the scope chain
const ctx = chart.ctx;
// Draw custom content...
}
};
// Register globally
Chart.register(myPlugin);
// Or use per-chart
new Chart(ctx, {
plugins: [myPlugin],
options: {
plugins: {
myPlugin: { color: '#00ff00' }
}
}
});
flowchart LR
subgraph "Plugin Resolution"
GLOBAL["registry.plugins.items"] --> ALL["allPlugins()"]
LOCAL["config.plugins array"] --> ALL
ALL --> OPTS["Merge with options.plugins[id]"]
OPTS --> RESOLVE["createResolver with plugin scopes"]
end
The options parameter passed to each hook is already a fully resolved Proxy (from the engine we explored in Article 3). Plugin options are resolved against scopes ['plugins.{id}', ...additionalOptionScopes], which means a plugin can declare additionalOptionScopes: ['interaction'] to inherit interaction options automatically.
Hook methods receive three arguments: (chart, args, options). The args object varies by hook—for beforeDraw, it's mostly empty; for beforeDatasetUpdate, it includes { meta, index, mode, cancelable }. Returning false from a cancelable hook prevents the default behavior.
Bridging to the Next Article
We've seen how components are registered and how plugins hook into the lifecycle. But the most important components—Scales, Elements, and DatasetControllers—have rich internal mechanics of their own. In the next article, we'll trace the data-to-pixel pipeline: how raw data values flow through Scale transformations, become Element geometry, and ultimately produce Canvas draw calls.