As a front-end framework designed "for large-scale front-end projects", Angular actually has many designs worthy of reference and learning. This series is mainly used to study the implementation principles of these designs and functions. This article focuses on the biggest feature of Angular - dependency injection, and introduces the design of multi-level dependency injection in Angular. [Recommended related tutorials: "Angular Tutorial"]
In the previous article, we introduced Injectot
injector, Provider
provider, and injector mechanism in Angular. So, in Angular applications, how do components and modules share dependencies? Can the same service be instantiated multiple times?
The dependency injection process of components and modules is inseparable from Angular's multi-level dependency injection design. Let's take a look.
As we said earlier, the injector in Angular is inheritable and hierarchical.
In Angular, there are two injector hierarchies:
ModuleInjector
Module Injector: Configure ModuleInjector in this hierarchy using @NgModule()
or @Injectable()
annotation ModuleInjector
ElementInjector
Injector: Implicitly createmodules
on every DOM elementBoth injectors and element injectors are tree-structured, but their hierarchies are not exactly the same.
The hierarchical structure of module injector is not only related to the module design in the application, but also has the hierarchical structure of platform module (PlatformModule) injector and application module (AppModule) injector.
In Angular terminology, a platform is the context in which Angular applications run. The most common platform for Angular applications is a web browser, but it can also be a mobile device's operating system or a web server.
When an Angular application is started, it will create a platform layer:
the platform is Angular's entry point on the web page. Each page has only one platform. Each Angular application running on the page, and all common services are bound toan Angular
Platform, mainly including functions such as creating module instances and destroying them:
@Injectable() export class PlatformRef { // Pass in the injector as the platform injector constructor(private _injector: Injector) {} // Create an instance of @NgModule for the given platform for offline compilation bootstrapModuleFactory<M>(moduleFactory: NgModuleFactory<M>, options?: BootstrapOptions): Promise<NgModuleRef<M>> {} // Using the given runtime compiler, create an instance of @NgModule for the given platform bootstrapModule<M>( moduleType: Type<M>, compilerOptions: (CompilerOptions&BootstrapOptions)| Array<CompilerOptions&BootstrapOptions> = []): Promise<NgModuleRef<M>> {} // Register the listener to be called when destroying the platform onDestroy(callback: () => void): void {} // Get the platform injector // The platform injector is the parent injector for every Angular application on the page and provides the singleton provider get injector(): Injector {} // Destroy the current Angular platform and all Angular applications on the page, including destroying all modules and listeners registered on the platform destroy() {} }
In fact, when the platform starts (in the bootstrapModuleFactory
method), ngZoneInjector
is created in ngZone.run
so that all instantiated services are created in the Angular zone, and ApplicationRef
(Angular application running on the page) will be in the Angular zone Created outside.
When launched in the browser, the browser platform is created:
export const platformBrowser: (extraProviders?: StaticProvider[]) => PlatformRef = createPlatformFactory(platformCore, 'browser', INTERNAL_BROWSER_PLATFORM_PROVIDERS); // Among them, the platformCore platform must be included in any other platform export const platformCore = createPlatformFactory(null, 'core', _CORE_PLATFORM_PROVIDERS);
When creating a platform using a platform factory (such as createPlatformFactory
above), the platform of the page will be implicitly initialized:
export function createPlatformFactory( parentPlatformFactory: ((extraProviders?: StaticProvider[]) => PlatformRef)|null, name: string, providers: StaticProvider[] = []): (extraProviders?: StaticProvider[]) => PlatformRef { const desc = `Platform: ${name}`; const marker = new InjectionToken(desc); // DI token return (extraProviders: StaticProvider[] = []) => { let platform = getPlatform(); // If the platform has been created, no processing is performed if (!platform || platform.injector.get(ALLOW_MULTIPLE_PLATFORMS, false)) { if (parentPlatformFactory) { // If there is a parent platform, use the parent platform directly and update the corresponding provider parentPlatformFactory( providers.concat(extraProviders).concat({provide: marker, useValue: true})); } else { const injectedProviders: StaticProvider[] = providers.concat(extraProviders).concat({provide: marker, useValue: true}, { provide: INJECTOR_SCOPE, useValue: 'platform' }); // If there is no parent platform, create a new injector and create a platform createPlatform(Injector.create({providers: injectedProviders, name: desc})); } } return assertPlatform(marker); }; }
Through the above process, we know that when the Angular application creates the platform, it creates the module injector of the platform ModuleInjector
. We can also see from Injector
definition in the previous section that NullInjector
is the top of all injectors:
export abstract class Injector { static NULL: Injector = new NullInjector(); }
So, on top of the platform module injector, there is NullInjector()
. Under the platform module injector, there is also the application module injector.
Each application has at least one Angular module. The root module is the module used to start this application:
@NgModule({ providers: APPLICATION_MODULE_PROVIDERS }) export class ApplicationModule { // ApplicationRef requires the bootstrap to provide component constructor(appRef: ApplicationRef) {} }
AppModule
root application module is re-exported by BrowserModule
, and when we create a new application using the new
command of the CLI, it is automatically included in the root AppModule
. In the application root module, the provider is associated with a built-in DI token that is used to configure the root injector for the bootstrap.
Angular also adds ComponentFactoryResolver
to the root module injector. This parser stores entryComponents
family of factories, so it is responsible for dynamically creating components.
At this point, we can simply sort out the hierarchical relationship of module injectors:
the top level of the module injector tree is the application root module (AppModule) injector, called root.
There are two injectors above root, one is the platform module (PlatformModule) injector and the other is NullInjector()
.
Therefore, the module injector hierarchy is as follows:
In our actual application, it is likely to be like this:
Angular DI has a layered injection architecture, which means that lower-level injectors can also create their own service instances.
As mentioned earlier, there are two injector hierarchies in Angular, namely module injector and element injector.
When lazy-loaded modules began to be widely used in Angular, an issue arose: the dependency injection system caused the instantiation of lazy-loaded modules to double.
In this fix, a new design was introduced: the injector uses two parallel trees, one for elements and one for modules .
Angular creates host factories for all entryComponents
, which are the root views for all other components.
This means that every time we create a dynamic Angular component, the root view ( RootData
) will be created with the root data ( RootView
):
class ComponentFactory_ extends ComponentFactory<any>{ create( injector: Injector, projectableNodes?: any[][], rootSelectorOrNode?: string|any, ngModule?: NgModuleRef<any>): ComponentRef<any> { if (!ngModule) { throw new Error('ngModule should be provided'); } const viewDef = resolveDefinition(this.viewDefFactory); const componentNodeIndex = viewDef.nodes[0].element!.componentProvider!.nodeIndex; //Create the root view using root data const view = Services.createRootView( injector, projectableNodes || [], rootSelectorOrNode, viewDef, ngModule, EMPTY_CONTEXT); // Accessor for view.nodes const component = asProviderData(view, componentNodeIndex).instance; if (rootSelectorOrNode) { view.renderer.setAttribute(asElementData(view, 0).renderElement, 'ng-version', VERSION.full); } //Create a component return new ComponentRef_(view, new ViewRef_(view), component); } }
The root data ( RootData
) contains references to elInjector
and ngModule
injectors:
function createRootData( elInjector: Injector, ngModule: NgModuleRef<any>, rendererFactory: RendererFactory2, projectableNodes: any[][], rootSelectorOrNode: any): RootData { const sanitizer = ngModule.injector.get(Sanitizer); const errorHandler = ngModule.injector.get(ErrorHandler); const renderer = rendererFactory.createRenderer(null, null); return { ngModule, injector: elInjector, projectableNodes, selectorOrNode: rootSelectorOrNode, sanitizer, rendererFactory, renderer, errorHandler, }; }
Introducing the element injector tree because this design is relatively simple. By changing the injector hierarchy, avoid interleaving module and component injectors, resulting in double instantiation of lazy-loaded modules. Because each injector has only one parent, and each resolution must find exactly one injector to retrieve dependencies.
In Angular, a view is a representation of a template. It contains different types of nodes, among which is the element node. The element injector is located on this node:
export interface ElementDef { ... // Public providers of DI visible in this view publicProviders: {[tokenKey: string]: NodeDef}|null; // Same as visiblePublicProviders, but also includes private providers located on this element allProviders: {[tokenKey: string]: NodeDef}|null; }
ElementInjector
is empty by default unless configured in the providers
attribute of @Directive()
or @Component()
.
When Angular creates an element injector for a nested HTML element, it either inherits it from the parent element injector or assigns the parent element injector directly to the child node definition.
If an element injector on a child HTML element has a provider, it should be inherited. Otherwise, there is no need to create a separate injector for the child component, and dependencies can be resolved directly from the parent's injector if needed.
So, where do element injectors and module injectors start to become parallel trees?
We already know that the application root module ( AppModule
) will be automatically included in the root AppModule
when creating a new application using the CLI's new
command.
When the application ( ApplicationRef
) starts ( bootstrap
), entryComponent
is created:
const compRef = componentFactory.create(Injector.NULL, [], selectorOrNode, ngModule);
This process creates the root view ( RootView
) using the root data ( RootData
) , and the root element injector will be created, where elInjector
is Injector.NULL
.
Here, Angular's injector tree is divided into element injector tree and module injector tree, these two parallel trees.
Angular will create subordinate injectors regularly. Whenever Angular creates a component instance providers
specified in @Component()
, it will also create a new sub-injector for the instance. Similarly, when a new NgModule
is loaded at runtime, Angular can create an injector for it with its own provider.
Submodule and component injectors are independent of each other and each create its own instance for the provided service. When Angular destroys NgModule
or component instance, it also destroys these injectors and those service instances in the injectors.
Above we introduced two types of injector trees in Angular: module injector tree and element injector tree. So, how does Angular resolve it when providing dependencies?
In Angular, when resolving tokens to obtain dependencies for components/instructions, Angular resolves it in two stages:
ElementInjector
hierarchy (its parent)ModuleInjector
hierarchy (its parent).The process is as follows (refer to Multi-Level Injector - Resolution Rules):
When a component declares a dependency, Angular will try to satisfy that dependency using its own ElementInjector
.
If a component's injector lacks a provider, it will pass the request to its parent component's ElementInjector
.
These requests will continue to be forwarded until Angular finds an injector that can handle the request or runs out of ancestor ElementInjector
.
If Angular cannot find the provider in any ElementInjector
, it will return to the element from which the request was made and look up ModuleInjector
hierarchy.
If Angular still cannot find the provider, it will throw an error.
For this purpose, Angular introduces a special merge injector.
The merge injector itself has no value, it is just a combination of view and element definitions.
class Injector_ implements Injector { constructor(private view: ViewData, private elDef: NodeDef|null) {} get(token: any, notFoundValue: any = Injector.THROW_IF_NOT_FOUND): any { const allowPrivateServices = this.elDef ? (this.elDef.flags & NodeFlags.ComponentView) !== 0 : false; return Services.resolveDep( this.view, this.elDef, allowPrivateServices, {flags: DepFlags.None, token, tokenKey: tokenKey(token)}, notFoundValue); } }
When Angular resolves dependencies, the merge injector is the bridge between the element injector tree and the module injector tree. When Angular tries to resolve certain dependencies in a component or directive, it uses the merge injector to traverse the element injector tree and then, if the dependency is not found, switches to the module injector tree to resolve the dependency.
class ViewContainerRef_ implements ViewContainerData { ... //Query for the parent view element injector get parentInjector(): Injector { let view = this._view; let elDef = this._elDef.parent; while (!elDef && view) { elDef = viewParentEl(view); view = view.parent!; } return view ? new Injector_(view, elDef) : new Injector_(this._view, null); } }
injectors are inheritable, which means that if the specified injector cannot resolve a dependency, it will ask the parent injector to resolve it. The specific parsing algorithm is implemented in resolveDep()
method:
export function resolveDep( view: ViewData, elDef: NodeDef, allowPrivateServices: boolean, depDef: DepDef, notFoundValue: any = Injector.THROW_IF_NOT_FOUND): any { // // mod1 // / // el1 mod2 // / //el2 // // When requesting el2.injector.get(token), check and return the first value found in the following order: // - el2.injector.get(token, default) // - el1.injector.get(token, NOT_FOUND_CHECK_ONLY_ELEMENT_INJECTOR) -> do not check the module // - mod2.injector.get(token, default) }
If it is the root AppComponent
component of a template like <child></child>
, then there will be three views in Angular:
<!-- HostView_AppComponent --> <my-app></my-app> <!-- View_AppComponent --> <child></child> <!-- View_ChildComponent --> Some content
relies on the parsing process. The parsing algorithm will be based on the view hierarchy, as shown in the figure:
If some tokens are resolved in a child component, Angular will:
first look at the child element injector, checking elRef.element.allProviders|publicProviders
.
Then iterate through all parent view elements (1) and check the provider in the element injector.
If the next parent view element is equal to null
(2), then return to startView
(3) and check startView.rootData.elnjector
(4).
Only if the token is not found, check startView.rootData module.injector
(5).
It follows that Angular, when traversing components to resolve certain dependencies, will search for the parent element of a specific view rather than the parent element of a specific element. The view's parent element can be obtained via:
// For component views, this is the host element // For embedded views, this is the index of the parent node of the containing view container export function viewParentEl(view: ViewData): NodeDef|null { const parentView = view.parent; if (parentView) { return view.parentNodeDef !.parent; } else { return null; } }
This article mainly introduces the hierarchical structure of injectors in Angular. There are two parallel injector trees in Angular: module injector tree and element injector tree.
The introduction of the element injector tree is mainly to solve the problem of double instantiation of modules caused by dependency injection parsing and lazy loading of modules. After the introduction of the element injector tree, Angular's dependency parsing process has also been adjusted. It prioritizes looking for dependencies of injectors such as element injectors and parent view element injectors. Only when the token cannot be found in the element injector, the module injector will be queried. dependencies in.