angular-docs-cn/packages/elements/src/component-factory-strategy.ts

289 lines
10 KiB
TypeScript
Raw Normal View History

/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {ApplicationRef, ComponentFactory, ComponentFactoryResolver, ComponentRef, EventEmitter, Injector, NgZone, OnChanges, SimpleChange, SimpleChanges, Type} from '@angular/core';
fix(elements): fire custom element output events during component initialization (#37570) Previously, event listeners for component output events attached on an Angular custom element before inserting it into the DOM (i.e. before instantiating the underlying component) didn't fire for events emitted during initialization lifecycle hooks, such as `ngAfterContentInit`, `ngAfterViewInit`, `ngOnChanges` (initial call) and `ngOnInit`. The reason was that `NgElementImpl` [subscribed to events][1] _after_ calling [ngElementStrategy#connect()][2], which is where the [initial change detection][3] takes place (running the initialization lifecycle hooks). This commit fixes this by: 1. Ensuring `ComponentNgElementStrategy#events` is defined and available for subscribing to, even before instantiating the component. 2. Changing `NgElementImpl` to subscribe to `NgElementStrategy#events` (if available) before calling `NgElementStrategy#connect()` (which initializes the component instance) if available. 3. Falling back to the old behavior (subscribing to `events` after calling `connect()` for strategies that do not initialize `events` before their `connect()` is run). NOTE: By falling back to the old behavior when `NgElementStrategy#events` is not initialized before calling `NgElementStrategy#connect()`, we avoid breaking existing custom `NgElementStrategy` implementations (with @remackgeek's [ElementZoneStrategy][4] being a commonly used example). Jira issue: [FW-2010](https://angular-team.atlassian.net/browse/FW-2010) [1]: https://github.com/angular/angular/blob/c0143cb2abdd172de1b95fd1d2c4cfc738640e28/packages/elements/src/create-custom-element.ts#L167-L170 [2]: https://github.com/angular/angular/blob/c0143cb2abdd172de1b95fd1d2c4cfc738640e28/packages/elements/src/create-custom-element.ts#L164 [3]: https://github.com/angular/angular/blob/c0143cb2abdd172de1b95fd1d2c4cfc738640e28/packages/elements/src/component-factory-strategy.ts#L158 [4]: https://github.com/remackgeek/elements-zone-strategy/blob/f1b6699495a8c0909dbbfbdca12770ecca843e15/projects/elements-zone-strategy/src/lib/element-zone-strategy.ts Fixes #36141 PR Close #37570
2020-06-13 04:06:35 -04:00
import {merge, Observable, ReplaySubject} from 'rxjs';
import {map, switchMap} from 'rxjs/operators';
import {NgElementStrategy, NgElementStrategyEvent, NgElementStrategyFactory} from './element-strategy';
import {extractProjectableNodes} from './extract-projectable-nodes';
import {isFunction, scheduler, strictEquals} from './utils';
/** Time in milliseconds to wait before destroying the component ref when disconnected. */
const DESTROY_DELAY = 10;
/**
* Factory that creates new ComponentNgElementStrategy instance. Gets the component factory with the
* constructor's injector's factory resolver and passes that factory to each strategy.
*
* @publicApi
*/
export class ComponentNgElementStrategyFactory implements NgElementStrategyFactory {
componentFactory: ComponentFactory<any>;
constructor(component: Type<any>, injector: Injector) {
this.componentFactory =
injector.get(ComponentFactoryResolver).resolveComponentFactory(component);
}
create(injector: Injector) {
return new ComponentNgElementStrategy(this.componentFactory, injector);
}
}
/**
* Creates and destroys a component ref using a component factory and handles change detection
* in response to input changes.
*
* @publicApi
*/
export class ComponentNgElementStrategy implements NgElementStrategy {
fix(elements): fire custom element output events during component initialization (#37570) Previously, event listeners for component output events attached on an Angular custom element before inserting it into the DOM (i.e. before instantiating the underlying component) didn't fire for events emitted during initialization lifecycle hooks, such as `ngAfterContentInit`, `ngAfterViewInit`, `ngOnChanges` (initial call) and `ngOnInit`. The reason was that `NgElementImpl` [subscribed to events][1] _after_ calling [ngElementStrategy#connect()][2], which is where the [initial change detection][3] takes place (running the initialization lifecycle hooks). This commit fixes this by: 1. Ensuring `ComponentNgElementStrategy#events` is defined and available for subscribing to, even before instantiating the component. 2. Changing `NgElementImpl` to subscribe to `NgElementStrategy#events` (if available) before calling `NgElementStrategy#connect()` (which initializes the component instance) if available. 3. Falling back to the old behavior (subscribing to `events` after calling `connect()` for strategies that do not initialize `events` before their `connect()` is run). NOTE: By falling back to the old behavior when `NgElementStrategy#events` is not initialized before calling `NgElementStrategy#connect()`, we avoid breaking existing custom `NgElementStrategy` implementations (with @remackgeek's [ElementZoneStrategy][4] being a commonly used example). Jira issue: [FW-2010](https://angular-team.atlassian.net/browse/FW-2010) [1]: https://github.com/angular/angular/blob/c0143cb2abdd172de1b95fd1d2c4cfc738640e28/packages/elements/src/create-custom-element.ts#L167-L170 [2]: https://github.com/angular/angular/blob/c0143cb2abdd172de1b95fd1d2c4cfc738640e28/packages/elements/src/create-custom-element.ts#L164 [3]: https://github.com/angular/angular/blob/c0143cb2abdd172de1b95fd1d2c4cfc738640e28/packages/elements/src/component-factory-strategy.ts#L158 [4]: https://github.com/remackgeek/elements-zone-strategy/blob/f1b6699495a8c0909dbbfbdca12770ecca843e15/projects/elements-zone-strategy/src/lib/element-zone-strategy.ts Fixes #36141 PR Close #37570
2020-06-13 04:06:35 -04:00
// Subject of `NgElementStrategyEvent` observables corresponding to the component's outputs.
private eventEmitters = new ReplaySubject<Observable<NgElementStrategyEvent>[]>(1);
/** Merged stream of the component's output events. */
fix(elements): fire custom element output events during component initialization (#37570) Previously, event listeners for component output events attached on an Angular custom element before inserting it into the DOM (i.e. before instantiating the underlying component) didn't fire for events emitted during initialization lifecycle hooks, such as `ngAfterContentInit`, `ngAfterViewInit`, `ngOnChanges` (initial call) and `ngOnInit`. The reason was that `NgElementImpl` [subscribed to events][1] _after_ calling [ngElementStrategy#connect()][2], which is where the [initial change detection][3] takes place (running the initialization lifecycle hooks). This commit fixes this by: 1. Ensuring `ComponentNgElementStrategy#events` is defined and available for subscribing to, even before instantiating the component. 2. Changing `NgElementImpl` to subscribe to `NgElementStrategy#events` (if available) before calling `NgElementStrategy#connect()` (which initializes the component instance) if available. 3. Falling back to the old behavior (subscribing to `events` after calling `connect()` for strategies that do not initialize `events` before their `connect()` is run). NOTE: By falling back to the old behavior when `NgElementStrategy#events` is not initialized before calling `NgElementStrategy#connect()`, we avoid breaking existing custom `NgElementStrategy` implementations (with @remackgeek's [ElementZoneStrategy][4] being a commonly used example). Jira issue: [FW-2010](https://angular-team.atlassian.net/browse/FW-2010) [1]: https://github.com/angular/angular/blob/c0143cb2abdd172de1b95fd1d2c4cfc738640e28/packages/elements/src/create-custom-element.ts#L167-L170 [2]: https://github.com/angular/angular/blob/c0143cb2abdd172de1b95fd1d2c4cfc738640e28/packages/elements/src/create-custom-element.ts#L164 [3]: https://github.com/angular/angular/blob/c0143cb2abdd172de1b95fd1d2c4cfc738640e28/packages/elements/src/component-factory-strategy.ts#L158 [4]: https://github.com/remackgeek/elements-zone-strategy/blob/f1b6699495a8c0909dbbfbdca12770ecca843e15/projects/elements-zone-strategy/src/lib/element-zone-strategy.ts Fixes #36141 PR Close #37570
2020-06-13 04:06:35 -04:00
readonly events = this.eventEmitters.pipe(switchMap(emitters => merge(...emitters)));
/** Reference to the component that was created on connect. */
private componentRef: ComponentRef<any>|null = null;
/** Changes that have been made to the component ref since the last time onChanges was called. */
private inputChanges: SimpleChanges|null = null;
/** Whether the created component implements the onChanges function. */
private implementsOnChanges = false;
/** Whether a change detection has been scheduled to run on the component. */
private scheduledChangeDetectionFn: (() => void)|null = null;
/** Callback function that when called will cancel a scheduled destruction on the component. */
private scheduledDestroyFn: (() => void)|null = null;
/** Initial input values that were set before the component was created. */
private readonly initialInputValues = new Map<string, any>();
/**
* Set of component inputs that have not yet changed, i.e. for which `ngOnChanges()` has not
* fired. (This is used to determine the value of `fistChange` in `SimpleChange` instances.)
*/
private readonly unchangedInputs = new Set<string>();
/** Service for setting zone context. */
private readonly ngZone = this.injector.get<NgZone>(NgZone);
/** The zone the element was created in or `null` if Zone.js is not loaded. */
private readonly elementZone =
(typeof Zone === 'undefined') ? null : this.ngZone.run(() => Zone.current);
constructor(private componentFactory: ComponentFactory<any>, private injector: Injector) {}
/**
* Initializes a new component if one has not yet been created and cancels any scheduled
* destruction.
*/
connect(element: HTMLElement) {
this.runInZone(() => {
// If the element is marked to be destroyed, cancel the task since the component was
// reconnected
if (this.scheduledDestroyFn !== null) {
this.scheduledDestroyFn();
this.scheduledDestroyFn = null;
return;
}
if (this.componentRef === null) {
this.initializeComponent(element);
}
});
}
/**
* Schedules the component to be destroyed after some small delay in case the element is just
* being moved across the DOM.
*/
disconnect() {
this.runInZone(() => {
// Return if there is no componentRef or the component is already scheduled for destruction
if (this.componentRef === null || this.scheduledDestroyFn !== null) {
return;
}
// Schedule the component to be destroyed after a small timeout in case it is being
// moved elsewhere in the DOM
this.scheduledDestroyFn = scheduler.schedule(() => {
if (this.componentRef !== null) {
this.componentRef.destroy();
this.componentRef = null;
}
}, DESTROY_DELAY);
});
}
/**
* Returns the component property value. If the component has not yet been created, the value is
* retrieved from the cached initialization values.
*/
getInputValue(property: string): any {
return this.runInZone(() => {
if (this.componentRef === null) {
return this.initialInputValues.get(property);
}
return this.componentRef.instance[property];
});
}
/**
* Sets the input value for the property. If the component has not yet been created, the value is
* cached and set when the component is created.
*/
setInputValue(property: string, value: any): void {
this.runInZone(() => {
if (this.componentRef === null) {
this.initialInputValues.set(property, value);
return;
}
// Ignore the value if it is strictly equal to the current value, except if it is `undefined`
// and this is the first change to the value (because an explicit `undefined` _is_ strictly
// equal to not having a value set at all, but we still need to record this as a change).
if (strictEquals(value, this.getInputValue(property)) &&
!((value === undefined) && this.unchangedInputs.has(property))) {
return;
}
this.recordInputChange(property, value);
this.componentRef.instance[property] = value;
this.scheduleDetectChanges();
});
}
/**
* Creates a new component through the component factory with the provided element host and
* sets up its initial inputs, listens for outputs changes, and runs an initial change detection.
*/
protected initializeComponent(element: HTMLElement) {
const childInjector = Injector.create({providers: [], parent: this.injector});
const projectableNodes =
extractProjectableNodes(element, this.componentFactory.ngContentSelectors);
this.componentRef = this.componentFactory.create(childInjector, projectableNodes, element);
this.implementsOnChanges = isFunction((this.componentRef.instance as OnChanges).ngOnChanges);
this.initializeInputs();
this.initializeOutputs(this.componentRef);
this.detectChanges();
const applicationRef = this.injector.get<ApplicationRef>(ApplicationRef);
applicationRef.attachView(this.componentRef.hostView);
}
/** Set any stored initial inputs on the component's properties. */
protected initializeInputs(): void {
this.componentFactory.inputs.forEach(({propName}) => {
if (this.implementsOnChanges) {
// If the component implements `ngOnChanges()`, keep track of which inputs have never
// changed so far.
this.unchangedInputs.add(propName);
}
if (this.initialInputValues.has(propName)) {
// Call `setInputValue()` now that the component has been instantiated to update its
// properties and fire `ngOnChanges()`.
this.setInputValue(propName, this.initialInputValues.get(propName));
}
});
this.initialInputValues.clear();
}
/** Sets up listeners for the component's outputs so that the events stream emits the events. */
protected initializeOutputs(componentRef: ComponentRef<any>): void {
fix(elements): fire custom element output events during component initialization (#37570) Previously, event listeners for component output events attached on an Angular custom element before inserting it into the DOM (i.e. before instantiating the underlying component) didn't fire for events emitted during initialization lifecycle hooks, such as `ngAfterContentInit`, `ngAfterViewInit`, `ngOnChanges` (initial call) and `ngOnInit`. The reason was that `NgElementImpl` [subscribed to events][1] _after_ calling [ngElementStrategy#connect()][2], which is where the [initial change detection][3] takes place (running the initialization lifecycle hooks). This commit fixes this by: 1. Ensuring `ComponentNgElementStrategy#events` is defined and available for subscribing to, even before instantiating the component. 2. Changing `NgElementImpl` to subscribe to `NgElementStrategy#events` (if available) before calling `NgElementStrategy#connect()` (which initializes the component instance) if available. 3. Falling back to the old behavior (subscribing to `events` after calling `connect()` for strategies that do not initialize `events` before their `connect()` is run). NOTE: By falling back to the old behavior when `NgElementStrategy#events` is not initialized before calling `NgElementStrategy#connect()`, we avoid breaking existing custom `NgElementStrategy` implementations (with @remackgeek's [ElementZoneStrategy][4] being a commonly used example). Jira issue: [FW-2010](https://angular-team.atlassian.net/browse/FW-2010) [1]: https://github.com/angular/angular/blob/c0143cb2abdd172de1b95fd1d2c4cfc738640e28/packages/elements/src/create-custom-element.ts#L167-L170 [2]: https://github.com/angular/angular/blob/c0143cb2abdd172de1b95fd1d2c4cfc738640e28/packages/elements/src/create-custom-element.ts#L164 [3]: https://github.com/angular/angular/blob/c0143cb2abdd172de1b95fd1d2c4cfc738640e28/packages/elements/src/component-factory-strategy.ts#L158 [4]: https://github.com/remackgeek/elements-zone-strategy/blob/f1b6699495a8c0909dbbfbdca12770ecca843e15/projects/elements-zone-strategy/src/lib/element-zone-strategy.ts Fixes #36141 PR Close #37570
2020-06-13 04:06:35 -04:00
const eventEmitters: Observable<NgElementStrategyEvent>[] =
this.componentFactory.outputs.map(({propName, templateName}) => {
const emitter: EventEmitter<any> = componentRef.instance[propName];
return emitter.pipe(map(value => ({name: templateName, value})));
});
fix(elements): fire custom element output events during component initialization (#37570) Previously, event listeners for component output events attached on an Angular custom element before inserting it into the DOM (i.e. before instantiating the underlying component) didn't fire for events emitted during initialization lifecycle hooks, such as `ngAfterContentInit`, `ngAfterViewInit`, `ngOnChanges` (initial call) and `ngOnInit`. The reason was that `NgElementImpl` [subscribed to events][1] _after_ calling [ngElementStrategy#connect()][2], which is where the [initial change detection][3] takes place (running the initialization lifecycle hooks). This commit fixes this by: 1. Ensuring `ComponentNgElementStrategy#events` is defined and available for subscribing to, even before instantiating the component. 2. Changing `NgElementImpl` to subscribe to `NgElementStrategy#events` (if available) before calling `NgElementStrategy#connect()` (which initializes the component instance) if available. 3. Falling back to the old behavior (subscribing to `events` after calling `connect()` for strategies that do not initialize `events` before their `connect()` is run). NOTE: By falling back to the old behavior when `NgElementStrategy#events` is not initialized before calling `NgElementStrategy#connect()`, we avoid breaking existing custom `NgElementStrategy` implementations (with @remackgeek's [ElementZoneStrategy][4] being a commonly used example). Jira issue: [FW-2010](https://angular-team.atlassian.net/browse/FW-2010) [1]: https://github.com/angular/angular/blob/c0143cb2abdd172de1b95fd1d2c4cfc738640e28/packages/elements/src/create-custom-element.ts#L167-L170 [2]: https://github.com/angular/angular/blob/c0143cb2abdd172de1b95fd1d2c4cfc738640e28/packages/elements/src/create-custom-element.ts#L164 [3]: https://github.com/angular/angular/blob/c0143cb2abdd172de1b95fd1d2c4cfc738640e28/packages/elements/src/component-factory-strategy.ts#L158 [4]: https://github.com/remackgeek/elements-zone-strategy/blob/f1b6699495a8c0909dbbfbdca12770ecca843e15/projects/elements-zone-strategy/src/lib/element-zone-strategy.ts Fixes #36141 PR Close #37570
2020-06-13 04:06:35 -04:00
this.eventEmitters.next(eventEmitters);
}
/** Calls ngOnChanges with all the inputs that have changed since the last call. */
protected callNgOnChanges(componentRef: ComponentRef<any>): void {
if (!this.implementsOnChanges || this.inputChanges === null) {
return;
}
// Cache the changes and set inputChanges to null to capture any changes that might occur
// during ngOnChanges.
const inputChanges = this.inputChanges;
this.inputChanges = null;
(componentRef.instance as OnChanges).ngOnChanges(inputChanges);
}
/**
* Schedules change detection to run on the component.
* Ignores subsequent calls if already scheduled.
*/
protected scheduleDetectChanges(): void {
if (this.scheduledChangeDetectionFn) {
return;
}
this.scheduledChangeDetectionFn = scheduler.scheduleBeforeRender(() => {
this.scheduledChangeDetectionFn = null;
this.detectChanges();
});
}
/**
* Records input changes so that the component receives SimpleChanges in its onChanges function.
*/
protected recordInputChange(property: string, currentValue: any): void {
// Do not record the change if the component does not implement `OnChanges`.
// (We can only determine that after the component has been instantiated.)
if (this.componentRef !== null && !this.implementsOnChanges) {
return;
}
if (this.inputChanges === null) {
this.inputChanges = {};
}
// If there already is a change, modify the current value to match but leave the values for
// previousValue and isFirstChange.
const pendingChange = this.inputChanges[property];
if (pendingChange) {
pendingChange.currentValue = currentValue;
return;
}
const isFirstChange = this.unchangedInputs.has(property);
this.unchangedInputs.delete(property);
const previousValue = isFirstChange ? undefined : this.getInputValue(property);
this.inputChanges[property] = new SimpleChange(previousValue, currentValue, isFirstChange);
}
/** Runs change detection on the component. */
protected detectChanges(): void {
if (this.componentRef === null) {
return;
}
this.callNgOnChanges(this.componentRef);
this.componentRef.changeDetectorRef.detectChanges();
}
/** Runs in the angular zone, if present. */
private runInZone(fn: () => unknown) {
return (this.elementZone && Zone.current !== this.elementZone) ? this.ngZone.run(fn) : fn();
}
}