feat(elements): injector create (#22413)

PR Close #22413
This commit is contained in:
Andrew Seguin
2018-03-06 14:02:25 -08:00
committed by Miško Hevery
parent 46efd4b938
commit 87f60bccfd
21 changed files with 275 additions and 143 deletions

View File

@ -6,29 +6,35 @@
* found in the LICENSE file at https://angular.io/license
*/
import {ApplicationRef, ComponentFactory, ComponentRef, EventEmitter, Injector, OnChanges, SimpleChange, SimpleChanges} from '@angular/core';
import {ApplicationRef, ComponentFactory, ComponentFactoryResolver, ComponentRef, EventEmitter, Injector, OnChanges, SimpleChange, SimpleChanges, Type} from '@angular/core';
import {Observable} from 'rxjs/Observable';
import {merge} from 'rxjs/observable/merge';
import {map} from 'rxjs/operator/map';
import {NgElementStrategy, NgElementStrategyEvent, NgElementStrategyFactory} from './element-strategy';
import {extractProjectableNodes} from './extract-projectable-nodes';
import {camelToDashCase, isFunction, scheduler, strictEquals} from './utils';
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 ComponentFactoryNgElementStrategy instances with the strategy factory's
* injector. A new strategy instance is created with the provided component factory which will
* create its components on connect.
* 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.
*
* @experimental
*/
export class ComponentFactoryNgElementStrategyFactory implements NgElementStrategyFactory {
constructor(private componentFactory: ComponentFactory<any>, private injector: Injector) {}
export class ComponentNgElementStrategyFactory implements NgElementStrategyFactory {
componentFactory: ComponentFactory<any>;
create() { return new ComponentFactoryNgElementStrategy(this.componentFactory, this.injector); }
constructor(private component: Type<any>, private injector: Injector) {
this.componentFactory =
injector.get(ComponentFactoryResolver).resolveComponentFactory(component);
}
create(injector: Injector) {
return new ComponentNgElementStrategy(this.componentFactory, injector);
}
}
/**
@ -37,12 +43,12 @@ export class ComponentFactoryNgElementStrategyFactory implements NgElementStrate
*
* @experimental
*/
export class ComponentFactoryNgElementStrategy implements NgElementStrategy {
export class ComponentNgElementStrategy implements NgElementStrategy {
/** Merged stream of the component's output events. */
events: Observable<NgElementStrategyEvent>;
/** Reference to the component that was created on connect. */
private componentRef: ComponentRef<any>;
private componentRef: ComponentRef<any>|null;
/** Changes that have been made to the component ref since the last time onChanges was called. */
private inputChanges: SimpleChanges|null = null;
@ -96,6 +102,7 @@ export class ComponentFactoryNgElementStrategy implements NgElementStrategy {
this.scheduledDestroyFn = scheduler.schedule(() => {
if (this.componentRef) {
this.componentRef !.destroy();
this.componentRef = null;
}
}, DESTROY_DELAY);
}

View File

@ -5,7 +5,7 @@
* 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 {ComponentFactory} from '@angular/core';
import {ComponentFactory, Injector} from '@angular/core';
import {Observable} from 'rxjs/Observable';
/**
@ -38,4 +38,7 @@ export interface NgElementStrategy {
*
* @experimental
*/
export interface NgElementStrategyFactory { create(): NgElementStrategy; }
export interface NgElementStrategyFactory {
/** Creates a new instance to be used for an NgElement. */
create(injector: Injector): NgElementStrategy;
}

View File

@ -6,12 +6,12 @@
* found in the LICENSE file at https://angular.io/license
*/
import {ComponentFactoryResolver, Injector, Type} from '@angular/core';
import {Injector, Type} from '@angular/core';
import {Subscription} from 'rxjs/Subscription';
import {ComponentFactoryNgElementStrategyFactory} from './component-factory-strategy';
import {ComponentNgElementStrategyFactory} from './component-factory-strategy';
import {NgElementStrategy, NgElementStrategyFactory} from './element-strategy';
import {camelToDashCase, createCustomEvent} from './utils';
import {createCustomEvent, getComponentInputs, getDefaultAttributeToPropertyInputs} from './utils';
/**
* Class constructor based on an Angular Component to be used for custom element registration.
@ -21,7 +21,7 @@ import {camelToDashCase, createCustomEvent} from './utils';
export interface NgElementConstructor<P> {
readonly observedAttributes: string[];
new (): NgElement&WithProperties<P>;
new (injector: Injector): NgElement&WithProperties<P>;
}
/**
@ -50,30 +50,19 @@ export type WithProperties<P> = {
};
/**
* Initialization configuration for the NgElementConstructor. Provides the strategy factory
* that produces a strategy for each instantiated element. Additionally, provides a function
* that takes the component factory and provides a map of which attributes should be observed on
* the element and which property they are associated with.
* Initialization configuration for the NgElementConstructor which contains the injector to be used
* for retrieving the component's factory as well as the default context for the component. May
* provide a custom strategy factory to be used instead of the default. May provide a custom mapping
* of attribute names to component inputs.
*
* @experimental
*/
export interface NgElementConfig {
injector: Injector;
strategyFactory?: NgElementStrategyFactory;
propertyInputs?: string[];
attributeToPropertyInputs?: {[key: string]: string};
}
/** Gets a map of default set of attributes to observe and the properties they affect. */
function getDefaultAttributeToPropertyInputs(inputs: {propName: string, templateName: string}[]) {
const attributeToPropertyInputs: {[key: string]: string} = {};
inputs.forEach(({propName, templateName}) => {
attributeToPropertyInputs[camelToDashCase(templateName)] = propName;
});
return attributeToPropertyInputs;
}
/**
* @whatItDoes Creates a custom element class based on an Angular Component. Takes a configuration
* that provides initialization information to the created class. E.g. the configuration's injector
@ -90,13 +79,10 @@ function getDefaultAttributeToPropertyInputs(inputs: {propName: string, template
*/
export function createNgElementConstructor<P>(
component: Type<any>, config: NgElementConfig): NgElementConstructor<P> {
const componentFactoryResolver =
config.injector.get(ComponentFactoryResolver) as ComponentFactoryResolver;
const componentFactory = componentFactoryResolver.resolveComponentFactory(component);
const inputs = componentFactory.inputs;
const inputs = getComponentInputs(component, config.injector);
const defaultStrategyFactory = config.strategyFactory ||
new ComponentFactoryNgElementStrategyFactory(componentFactory, config.injector);
const strategyFactory =
config.strategyFactory || new ComponentNgElementStrategyFactory(component, config.injector);
const attributeToPropertyInputs =
config.attributeToPropertyInputs || getDefaultAttributeToPropertyInputs(inputs);
@ -104,13 +90,9 @@ export function createNgElementConstructor<P>(
class NgElementImpl extends NgElement {
static readonly observedAttributes = Object.keys(attributeToPropertyInputs);
constructor(strategyFactoryOverride?: NgElementStrategyFactory) {
constructor(injector?: Injector) {
super();
// Use the constructor's strategy factory override if it is present, otherwise default to
// the config's factory.
const strategyFactory = strategyFactoryOverride || defaultStrategyFactory;
this.ngElementStrategy = strategyFactory.create();
this.ngElementStrategy = strategyFactory.create(injector || config.injector);
}
attributeChangedCallback(
@ -120,14 +102,6 @@ export function createNgElementConstructor<P>(
}
connectedCallback(): void {
// Take element attribute inputs and set them as inputs on the strategy
NgElementImpl.observedAttributes.forEach(attrName => {
const propName = attributeToPropertyInputs[attrName] !;
if (this.hasAttribute(attrName)) {
this.ngElementStrategy.setInputValue(propName, this.getAttribute(attrName) !);
}
});
this.ngElementStrategy.connect(this);
// Listen for events from the strategy and dispatch them as custom events
@ -149,8 +123,7 @@ export function createNgElementConstructor<P>(
// Add getters and setters to the prototype for each property input. If the config does not
// contain property inputs, use all inputs by default.
const propertyInputs = config.propertyInputs || inputs.map(({propName}) => propName);
propertyInputs.forEach(property => {
inputs.map(({propName}) => propName).forEach(property => {
Object.defineProperty(NgElementImpl.prototype, property, {
get: function() { return this.ngElementStrategy.getInputValue(property); },
set: function(newValue: any) { this.ngElementStrategy.setInputValue(property, newValue); },

View File

@ -5,8 +5,7 @@
* 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 {Type} from '@angular/core';
import {ComponentFactoryResolver, Injector, Type} from '@angular/core';
const elProto = Element.prototype as any;
const matches = elProto.matches || elProto.matchesSelector || elProto.mozMatchesSelector ||
@ -101,3 +100,25 @@ export function matchesSelector(element: Element, selector: string): boolean {
export function strictEquals(value1: any, value2: any): boolean {
return value1 === value2 || (value1 !== value1 && value2 !== value2);
}
/** Gets a map of default set of attributes to observe and the properties they affect. */
export function getDefaultAttributeToPropertyInputs(
inputs: {propName: string, templateName: string}[]) {
const attributeToPropertyInputs: {[key: string]: string} = {};
inputs.forEach(({propName, templateName}) => {
attributeToPropertyInputs[camelToDashCase(templateName)] = propName;
});
return attributeToPropertyInputs;
}
/**
* Gets a component's set of inputs. Uses the injector to get the component factory where the inputs
* are defined.
*/
export function getComponentInputs(
component: Type<any>, injector: Injector): {propName: string, templateName: string}[] {
const componentFactoryResolver: ComponentFactoryResolver = injector.get(ComponentFactoryResolver);
const componentFactory = componentFactoryResolver.resolveComponentFactory(component);
return componentFactory.inputs;
}

View File

@ -10,12 +10,12 @@ import {ComponentFactory, ComponentRef, Injector, NgModuleRef, SimpleChange, Sim
import {fakeAsync, tick} from '@angular/core/testing';
import {Subject} from 'rxjs/Subject';
import {ComponentFactoryNgElementStrategy, ComponentFactoryNgElementStrategyFactory} from '../src/component-factory-strategy';
import {ComponentNgElementStrategy, ComponentNgElementStrategyFactory} from '../src/component-factory-strategy';
import {NgElementStrategyEvent} from '../src/element-strategy';
describe('ComponentFactoryNgElementStrategy', () => {
let factory: FakeComponentFactory;
let strategy: ComponentFactoryNgElementStrategy;
let strategy: ComponentNgElementStrategy;
let injector: any;
let componentRef: any;
@ -25,22 +25,26 @@ describe('ComponentFactoryNgElementStrategy', () => {
factory = new FakeComponentFactory();
componentRef = factory.componentRef;
injector = jasmine.createSpyObj('injector', ['get']);
applicationRef = jasmine.createSpyObj('applicationRef', ['attachView']);
injector.get.and.returnValue(applicationRef);
strategy = new ComponentFactoryNgElementStrategy(factory, injector);
strategy = new ComponentNgElementStrategy(factory, injector);
});
it('should create a new strategy from the factory', () => {
const strategyFactory = new ComponentFactoryNgElementStrategyFactory(factory, injector);
expect(strategyFactory.create()).toBeTruthy();
const factoryResolver = jasmine.createSpyObj('factoryResolver', ['resolveComponentFactory']);
factoryResolver.resolveComponentFactory.and.returnValue(factory);
injector = jasmine.createSpyObj('injector', ['get']);
injector.get.and.returnValue(factoryResolver);
const strategyFactory = new ComponentNgElementStrategyFactory(FakeComponent, injector);
expect(strategyFactory.create(injector)).toBeTruthy();
});
describe('after connected', () => {
beforeEach(() => {
// Set up an initial value to make sure it is passed to the component
strategy.setInputValue('fooFoo', 'fooFoo-1');
injector.get.and.returnValue(applicationRef);
strategy.connect(document.createElement('div'));
});
@ -95,7 +99,7 @@ describe('ComponentFactoryNgElementStrategy', () => {
it('should not detect changes', fakeAsync(() => {
strategy.setInputValue('fooFoo', 'fooFoo-1');
tick(16); // scheduler waits 16ms if RAF is unavailable
expect(componentRef.changeDetectorRef.detectChanges).toHaveBeenCalledTimes(0);
expect(componentRef.changeDetectorRef.detectChanges).not.toHaveBeenCalled();
}));
});

View File

@ -51,7 +51,7 @@ if (typeof customElements !== 'undefined') {
});
it('should send input values from attributes when connected', () => {
const element = new NgElementCtor();
const element = new NgElementCtor(injector);
element.setAttribute('foo-foo', 'value-foo-foo');
element.setAttribute('barbar', 'value-barbar');
element.connectedCallback();
@ -62,7 +62,7 @@ if (typeof customElements !== 'undefined') {
});
it('should listen to output events after connected', () => {
const element = new NgElementCtor();
const element = new NgElementCtor(injector);
element.connectedCallback();
let eventValue: any = null;
@ -73,7 +73,7 @@ if (typeof customElements !== 'undefined') {
});
it('should not listen to output events after disconnected', () => {
const element = new NgElementCtor();
const element = new NgElementCtor(injector);
element.connectedCallback();
element.disconnectedCallback();
expect(strategy.disconnectCalled).toBe(true);
@ -86,7 +86,7 @@ if (typeof customElements !== 'undefined') {
});
it('should properly set getters/setters on the element', () => {
const element = new NgElementCtor();
const element = new NgElementCtor(injector);
element.fooFoo = 'foo-foo-value';
element.barBar = 'barBar-value';
@ -104,29 +104,26 @@ if (typeof customElements !== 'undefined') {
NgElementCtorWithChangedAttr = createNgElementConstructor(TestComponent, {
injector,
strategyFactory,
propertyInputs: ['prop1', 'prop2'],
attributeToPropertyInputs: {'attr-1': 'prop1', 'attr-2': 'prop2'}
attributeToPropertyInputs: {'attr-1': 'fooFoo', 'attr-2': 'barbar'}
});
customElements.define('test-element-with-changed-attributes', NgElementCtorWithChangedAttr);
});
beforeEach(() => { element = new NgElementCtorWithChangedAttr(); });
beforeEach(() => { element = new NgElementCtorWithChangedAttr(injector); });
it('should affect which attributes are watched', () => {
expect(NgElementCtorWithChangedAttr.observedAttributes).toEqual(['attr-1', 'attr-2']);
});
it('should send attribute values as inputs when connected', () => {
const element = new NgElementCtorWithChangedAttr();
const element = new NgElementCtorWithChangedAttr(injector);
element.setAttribute('attr-1', 'value-1');
element.setAttribute('attr-2', 'value-2');
element.setAttribute('attr-3', 'value-3'); // Made-up attribute
element.connectedCallback();
expect(strategy.getInputValue('prop1')).toBe('value-1');
expect(strategy.getInputValue('prop2')).toBe('value-2');
expect(strategy.getInputValue('prop3')).not.toBe('value-3');
expect(strategy.getInputValue('fooFoo')).toBe('value-1');
expect(strategy.getInputValue('barbar')).toBe('value-2');
});
});
});