Angular DevTools
Angular DevTools is a browser extension that provides debugging and profiling capabilities for Angular applications.
There’s a good coverage of its capabilities in the official docs.
Here I want to briefly describe the architecture of this tool and outline utilities it uses to build a components
tree and the change detection profiler.
The “Components” tab lets you inspect a tree of components for a current application page:
Interestingly, though not surprisingly, the DevTools extension uses Angular to build the UI you see in the Chrome console.
You can think of this user facing part of the extension as a frontend equivalent of a browser application.
For example, those two tabs:
are rendered by the
DevToolsTabs
component. The component is part of the ng-devtools
module in the sources:
As you can see, there’s also ng-devtools-backend
implementation that’s responsible for interaction with your Angular application.
Its primary job is to build a components tree that is then rendered by the frontend part of the DevTools extension.
But it does lots of other things too, e.g.
highlighting
a node in your inspected Angular application by applying styles to a node:
Frontend and backend parts of the DevTools extension communicate through a
message bus:
export abstract class MessageBus<T> {
abstract on<E extends keyof T>(topic: E, cb: T[E]): void;
abstract once<E extends keyof T>(topic: E, cb: T[E]): void;
abstract emit<E extends keyof T>(topic: E, args?: Parameters<T[E]>): boolean;
abstract destroy(): void;
}
Here’s, for example,
how
Angular subscribes to the createHighlightOverlay
event that initiates the highlighting process:
const setupInspector = (messageBus: MessageBus<Events>) => {
const inspector = new ComponentInspector({...});
messageBus.on('createHighlightOverlay', (position: ElementPosition) => {
inspector.highlightByPosition(position);
});
Using discovery utils
To build a tree of components and attach corresponding information it uses the same set of discovery
utils that we explored in the global utils section.
Those utils are employed by the
RStrategy,
which is the primary way for Angular to build to inspect the components tree.
This strategy is used by default if the discovery utils are exposed globally:
export class RTreeStrategy {
supports(_: any): boolean {
return ['getDirectiveMetadata', 'getComponent', 'getDirectives'].every(
(method) => typeof (window as any).ng[method] === 'function');
}
build(element: Element): ComponentTreeNode[] {
// We want to start from the root element so that we can find components which are attached to
// the application ref and which host elements have been inserted with DOM APIs.
while (element.parentElement) {
element = element.parentElement;
}
const getComponent = (window as any).ng.getComponent as (element: Element) => {};
const getDirectives = (window as any).ng.getDirectives as (node: Node) => {}[];
const result = extractViewTree(element, [], getComponent, getDirectives);
return result;
}
}
This means that information presented here:
Could be extracted and inspected in the Chrome console using ng.getComponent
utility:
If you’re curious to see how this process happens inside the build
method of the strategy,
you could even put a breakpoint into the source code of the extension:
On the other hand, when Angular runs in the production mode the framework doesn't expose discovery utils through the global ng
namespace.
For this reason inspecting the application that runs in the production mode isn't supported yet.
You will see the following when trying to run the DevTools:
There’s the
LTreeStrategy
in the works that possibly could enable that scenario. The strategy relies on the custom
ngContext
DOM element property to retrieve the corresponding LView
and TNode
.
The strategy uses these nodes to retrieve the meta information about a component or directives:
export class LTreeStrategy {
supports(element: Element): boolean {
return typeof (element as any).__ngContext__ !== 'undefined';
}
private _getNode(lView: any, data: any, idx: number): ComponentTreeNode {
const directives: DirectiveInstanceType[] = [];
let component: ComponentInstanceType|null = null;
const tNode = data[idx];
const node = lView[idx][ELEMENT];
for (let i = tNode.directiveStart; i < tNode.directiveEnd; i++) {
const instance = lView[i];
const dirMeta = data[i];
if (dirMeta && dirMeta.template) {
component = { ... };
} else if (dirMeta) {
directives.push({... });
}
}
}
}
The custom __ngContext__
property at this point is available even in the production environment.
To retrieve the LView
, Angular records the id of the corresponding LView
in the __ngContext__
property of each component's host element:
Then it retrieves it using getLViewById
from the
TRACKED_LVIEWS
storage:
/** Starts tracking an LView. */
export function registerLView(lView: LView): void {
ngDevMode &&
assertNumber(lView[ID], 'LView must have an ID in order to be registered');
TRACKED_LVIEWS.set(lView[ID], lView);
}
/** Gets an LView by its unique ID. */
export function getLViewById(id: number): LView | null {
ngDevMode && assertNumber(id, 'ID used for LView lookup must be a number');
return TRACKED_LVIEWS.get(id) || null;
}
By doing this, Angular supports the LView
retrieval in runtime even in the production mode.
And that’s exactly what LTreeStrategy
relies on.
Profiling implementation
The "Profiler" tab lets you inspect the process of Angular's change detection in great detail.
You click the “Start recording” button and Angular DevTools starts capturing change detection execution events,
such as template update or lifecycle hook execution.
Once the change detection process recording is stopped, it’s rendered like this:
Backend of the DevTools extension is responsible for capturing those events and creating frames. To do that, the profiler
uses
a strategy called
NgProfiler.
This strategy registers a callback through the same
setProfiler
function that we explored in previous section.
When a particular change detection event arrives to the callback, the corresponding
hook
is triggered:
const getHooks = (onFrame) => {
const timeStartMap: Record<string, number> = {};
return {
onCreate,
onChangeDetectionStart,
onChangeDetectionEnd,
onDestroy,
onLifecycleHookStart,
onLifecycleHookEnd,
onOutputStart,
onOutputEnd,
};
};
As you can see by the hooks structure, most events have a start and an end equivalent.
Inside the hook, when a start equivalent of a change detection event is received, Angular records the time in the
timeStartMap.
When the end equivalent of an event is received, it
calculates the duration
of the time between the start and the end part an event.
Here how it's done for the onOutput[Start/End]
event:
const getHooks = (onFrame) => {
const timeStartMap: Record<string, number> = {};
return {
...,
onOutputStart(componentOrDirective, outputName, node, isComponent) {
startEvent(timeStartMap, componentOrDirective, outputName);
...
},
onOutputEnd(componentOrDirective: any, outputName: string): void {
const name = outputName;
const entry = eventMap.get(componentOrDirective);
const startTimestamp = getEventStart(timeStartMap, componentOrDirective, name);
...
const duration = performance.now() - startTimestamp;
entry.outputs[name] = (entry.outputs[name] || 0) + duration;
frameDuration += duration;
},
}
}
Once the entire change detection process is recorded, it’s
sent
to the frontend part for processing. The result is rendered by the
TimelineComponent.