使指令@Input 成为必需

在 AngularJs 中,我们可以创建一个必需的指令属性。我们如何做到这一点,在角度与@输入?医生没提过。

艾格。

@Component({
selector: 'my-dir',
template: '<div></div>'
})
export class MyComponent {
@Input() a: number; // Make this a required attribute. Throw an exception if it doesn't exist.
@Input() b: number;
}
70252 次浏览

You can do it like this:

constructor() {}
ngOnInit() {
if (!this.a) throw new Error();
}

Check in ngOnInit() (inputs aren't yet set when the constructor is executed) whether the attribute has a value.

Component({
selector: 'my-dir',
template: '<div></div>'
})
export class MyComponent implements OnInit, OnChanges {
@Input() a:number; // Make this a required attribute. Throw an exception if it doesnt exist
@Input() b:number;


constructor(){
}


ngOnInit() {
this.checkRequiredFields(this.a);
}


ngOnChanges(changes) {
this.checkRequiredFields(this.a);
}


checkRequiredFields(input) {
if(input === null) {
throw new Error("Attribute 'a' is required");
}
}
}

You might also check in ngOnChanges(changes) {...} if the values wasn't set to null. See also https://angular.io/docs/ts/latest/api/core/OnChanges-interface.html

For me, I had to do it this way:

ngOnInit() {
if(!this.hasOwnProperty('a') throw new Error("Attribute 'a' is required");
}

FYI, If you want to require @Output directives, then try this:

export class MyComponent {
@Output() myEvent = new EventEmitter(); // This a required event


ngOnInit() {
if(this.myEvent.observers.length === 0) throw new Error("Event 'myEvent' is required");
}
}

Official solution

As answered by Ryan Miglavs – smart usage of Angular's selectors solves the issue.

/** Note: requires the [a] attribute to be passed */
@Component({
selector: 'my-dir[a]', // <-- use attribute selector along with tag to ensure both tag name and attribute are used to "select" element by Angular in DOM
});
export class MyComponent {
@Input() a: number;
}

Personally I prefer this solution in most cases, as it doesn't require any additional effort during the coding time. However, it has some disadvantages:

  • it's not possible to understand what argument is missing from the error thrown
  • error is confusing itself as it says, that tag isn't recognized by Angular, when just some argument is missing

Both of these negatives can be partially ameliorated by adding a decorative comment above the @Component decorator as seen above, and most editors will show that along with any tooltip information for the component name. It does not help with the Angular error output though.


For alternative solutions – look below, they require some additional codding, but doesn't have disadvantages described above.


So, here is my solution with getters/setters. IMHO, this is quite elegant solution as everything is done in one place and this solution doesn't require OnInit dependency.

Solution #2

Component({
selector: 'my-dir',
template: '<div></div>',
});
export class MyComponent {
@Input()
get a() {
throw new Error('Attribute "a" is required');
}
set a(value: number) {
Object.defineProperty(this, 'a', {
value,
writable: true,
configurable: true,
});
}
}

Solution #3:

It could be done even easier with decorators. So, you define in your app once decorator like this one:

function Required(target: object, propertyKey: string) {
Object.defineProperty(target, propertyKey, {
get() {
throw new Error(`Attribute ${propertyKey} is required`);
},
set(value) {
Object.defineProperty(target, propertyKey, {
value,
writable: true,
configurable: true,
});
},
configurable: true
});
}

And later in your class you just need to mark your property as required like this:

Component({
selector: 'my-dir',
template: '<div></div>',
});
export class MyComponent {
@Input() @Required a: number;
}

Explanation:

If attribute a is defined - setter of property a will override itself and value passed to attribute will be used. Otherwise - after component init - first time you want to use property a in your class or template - error will be thrown.

Note: getters/setters works well within Angular's components/services, etc and it's safe to use them like this. But be careful while using this approach with pure classes outside Angular. The problem is how typescript transpiles getters/setters to ES5 - they are assigned to prototype property of the class. In this case we do mutate prototype property which will be the same for all instances of class. Means we can get something like this:

const instance1 = new ClassStub();
instance1.property = 'some value';
const instance2 = new ClassStub();
console.log(instance2.property); // 'some value'

The official Angular way to do this is to include the required properties in the selector for your component. So, something like:

/** Note: requires the [a] attribute to be passed */
Component({
selector: 'my-dir[a]', // <-- Check it
template: '<div></div>'
})
export class MyComponent {
@Input() a:number; // This property is required by virtue of the selector above
@Input() b:number; // This property is still optional, but could be added to the selector to require it


constructor(){


}


ngOnInit() {
        

}
}

The advantage to this is that if a developer does not include the property (a) when referencing the component in their template, the code won't compile. This means compile-time safety instead of run-time safety, which is nice.

The bummer is that the error message the developer will receive is "my-dir is not a known element", which isn't super clear.

That negative can be partially ameliorated by adding a decorative comment above the @Component decorator as seen above, and most editors will show that along with any tooltip information for the component name. It does not help with the Angular error output though.

I tried the decorator approach mentioned by ihor, and I ran into issues since it applies to the Class (and therefore after TS compilation to the prototype), not to the instance; this meant that the decorator only runs once for all copies of a component, or at least I couldn't find a way to make it work for multiple instances.

Here are the docs for the selector option. Note that it actually allows very flexible CSS-style selector-ing (sweet word).

I found this recommendation on a Github feature request thread.

Why not use the @angular/forms library to validate your @Inputs? The following solution:

  • Fails fast (not just when the @input value is accessed by the component for the first time)
  • Allows re-using rules that you've already used for your Angular forms

Usage:

    export class MyComponent {


@Input() propOne: string;
@Input() propTwo: string;


ngOnInit() {
validateProps<MyComponent>(this, {
propOne: [Validators.required, Validators.pattern('[a-zA-Z ]*')],
propTwo: [Validators.required, Validators.minLength(5), myCustomRule()]
})
}
}

Utility function:

    import { FormArray, FormBuilder, ValidatorFn, FormControl } from '@angular/forms';


export function validateProps<T>(cmp: T, ruleset: {[key in keyof T]?: ValidatorFn[]} ) {
const toGroup = {};
Object.keys(ruleset)
.forEach(key => toGroup[key] = new FormControl(cmp[key], ruleset[key]));
const formGroup = new FormBuilder().group(toGroup);
formGroup.updateValueAndValidity();
const validationResult = {};
Object.keys(formGroup.controls)
.filter(key => formGroup.controls[key].errors)
.forEach(key => validationResult[key] = formGroup.controls[key].errors);
if (Object.keys(validationResult).length) {
throw new Error(`Input validation failed:\n ${JSON.stringify(validationResult, null, 2)}`);
}
}

Stackblitz

Here is another TypeScript decorator based approach that is less complicated and easier to understand. It also supports Component inheritance.


// Map of component name -> list of required properties
let requiredInputs  = new Map<string, string[]>();


/**
* Mark @Input() as required.
*
* Supports inheritance chains for components.
*
* Example:
*
* import { isRequired, checkRequired } from '../requiredInput';
*
*  export class MyComp implements OnInit {
*
*    // Chain id paramter we check for from the wallet
*    @Input()
*    @isRequired
*    requiredChainId: number;
*
*    ngOnInit(): void {
*      checkRequired(this);
*    }
*  }
*
* @param target Object given by the TypeScript decorator
* @param prop Property name from the TypeScript decorator
*/
export function isRequired(target: any, prop: string) {
// Maintain a global table which components require which inputs
const className = target.constructor.name;
requiredInputs[className] = requiredInputs[className] || [];
requiredInputs[className].push(prop);
// console.log(className, prop, requiredInputs[className]);
}


/**
* Check that all required inputs are filled.
*/
export function checkRequired(component: any) {


let className = component.constructor.name;
let nextParent = Object.getPrototypeOf(component);


// Walk through the parent class chain
while(className != "Object") {


for(let prop of (requiredInputs[className] || [])) {
const val = component[prop];
if(val === null || val === undefined) {
console.error(component.constructor.name, prop, "is required, but was not provided, actual value is", val);
}
}


className = nextParent.constructor.name;
nextParent = Object.getPrototypeOf(nextParent);
// console.log("Checking", component, className);
}
}




Very simple and adaptive way to declare required field

Many answers are already showing this official technique. What if you want to add multiple required fileds? Then do the following:

Single required field

@Component({
selector: 'my-component[field1]',
templateUrl: './my-component.component.html',
styleUrls: ['./my-component.component.scss']
})

Multiple fields and all are required

@Component({
selector: 'my-component[field1][field2][field3]',
templateUrl: './my-component.component.html',
styleUrls: ['./my-component.component.scss']
})

Multiple fields but atleast one will be required

@Component({
selector: 'my-component[field1], my-component[field2], my-component[field3]',
templateUrl: './my-component.component.html',
styleUrls: ['./my-component.component.scss']
})

Here is how to use in html

<my-component [field1]="value" [field2]="value" [field3]="value"></my-component>

I was able to make @ihor 's Required decorator work using this in the second Object.defineProperty. this forces decorator to define property on each instance.

export function Required(message?: string) {
return function (target: Object, propertyKey: PropertyKey) {
Object.defineProperty(target, propertyKey, {
get() {
throw new Error(message || `Attribute ${String(propertyKey)} is required`);
},
set(value) {
Object.defineProperty(this, propertyKey, {
value,
writable: true
});
}
});
};
}