我相信你已经听说过 Angular
的 @Input
装饰器!
这个装饰器允许我们将数据从父组件传递给子组件,它是框架的核心功能之一。
今天我们来聊聊,关于该装饰器具有许多额外功能。
在聊这个新功能之前,我们先看一个 defineProps 函数。
defineProps({
// Basic type check
// (`null` and `undefined` values will allow any type)
propA: Number,
// Multiple possible types
propB: [String, Number],
// Required string
propC: {
type: String,
required: true
},
// Number with a default value
propD: {
type: Number,
default: 100
},
// Object with a default value
propE: {
type: Object,
// Object or array defaults must be returned from
// a factory function. The function receives the raw
// props received by the component as the argument.
default(rawProps) {
return { message: 'hello' }
}
},
// Custom validator function
// full props passed as 2nd argument in 3.4+
propF: {
validator(value, props) {
// The value must match one of these strings
return ['success', 'warning', 'danger'].includes(value)
}
},
// Function with a default value
propG: {
type: Function,
// Unlike object or array default, this is not a factory
// function - this is a function to serve as a default value
default() {
return 'Default function'
}
}
})
你没看错,这是 Vue
的 prop-validation
,仅在开发模式下进行检查。
Vue
的对于输入 prop
进行验证:
- default:默认值
- required:是否必填验证
- type:输入值类型验证
- validator:自定义验证
这个验证对于第三方组件库来说很有必要输入拦截,只有保证输入才能让后续代码正常运行。
React
也有类似的功能,不过 React.PropTypes
自 React v15.5
起已弃用。后续只能使用 prop-types 库代替,出于性能考虑,一般 propTypes
只在开发模式下进行检查。
那么对于 Angular
来说,我们重点关注的输入验证特性,只能仅靠 ts
类型系统和 eslint
代码检查来约束。然而在大多数情况下,想要更严格验证,只能用 getter/setter
来处理验证。
@Input 基础知识
@Input()
是一个 Angular
装饰器,它把一个类属性标记为组件的输入属性,就是常说 prop
。
@Input
装饰器用于将数据从父组件传递给子组件。
下面是 @Input
装饰器的基本用法:
@Component({
selector: "child",
template: `<p>{{ name }}</p>`,
})
export class ChildComponent {
@Input() name: string;
}
这里,@Input
装饰器被用来将 name
属性标记为输入属性。
这意味着使用 ChildComponent
的父组件可以使用属性模板绑定语法来设置 name
属性的值:
@Component({
selector: "parent",
template: `<child [name]="name" />`,
})
export class ParentComponent {
name = "Angular";
}
如果在父组件中 name
属性的值发生了变化,那么这个变化将通过 @Input()
装饰器传递。
这就是 @Input
的意义所在。
现在,我们学习基础知识。让我们开始探索其他可用的额外功能和配置选项。
@Input 设置别名
可以通过定义输入别名显式地定义输入属性的名称。
这是使用简化符号进行操作的方法:
@Component({
selector: "app-child",
template: `<p>{{ name }}</p>`,
})
export class ChildComponent {
@Input("userName") name: string;
// v16 以上版本允许新写法
@Input({
alias: "userName"
})
name: string;
}
在上面的代码中,name
输入属性别名设置为 userName
。
有了这些别名,就可以在父组件中设置属性:
@Component({
selector: "app-parent",
template: `<app-child [userName]="name" />`,
})
export class ParentComponent {
name = "Angular";
}
注意:尽量少用别名,因为它会使你的组件内部和外部属性名不一致,导致歧义,使你的维护修改成本增加。
@Input 用 setter/getter
现在让我们开始深入了解 @Input
的一些不太为人所知的特性,从 getter/setter
开始。
除了对成员变量应用 @Input
装饰器,我们还可以使用访问修饰符 getter/setter
:
@Component({
selector: "app-child",
template: `<p>{{ name }}</p>`,
})
export class ChildComponent {
private _name: string;
@Input()
get name() {
return this._name;
}
set name(name: string) {
// 以在这里添加一些逻辑来修改输入值
this._name = name;
}
}
在想将某种转换应用于传递给组件的值的情况下,这可能很有用。
在接下来的部分中,我们将展示一种修改输入值的更好方法。
但是让我们首先完成访问修饰符输入的覆盖。
在上面的代码中,我们定义了一个名为 name
的组件输入属性,它在内部使用了一个名为 _name
的私有成员变量。
下面是我们如何在 ParentComponent
中设置这个输入的值:
@Component({
selector: "app-parent",
template: `<app-child [name]="name"/>`,
})
export class ParentComponent {
name: string = // some initial value
}
注意:无法在父组件上设置
_name
属性的值。
你可能想知道输入属性(name
)的名称是如何确定的。这是根据 getter
函数的名称确定的,它也被称为 name
。
@Input required
Angular v16.0
发布 @Input
的一个新的特性 required
,默认情况下,输入是可选的,因此当没有提供输入时不会出现验证错误。
我们先来看看如果没有这个特性之前我们该如何做必填:
@Component({
selector: "app-child",
template: `<p>{{ name }}</p>`,
})
export class ChildComponent implements OnInit {
@Input({
required: true,
})
name: string;
ngOnInit(): void {
if (this.name === undefined) {
throw new Error(`Required input 'name' from component ChildComponent must be specified.`);
}
}
}
如果不提供该值,我们现在可以在控制台中看到错误:
ERROR Error: Required input 'name' from component ChildComponent must be specified.
如果我们父组件没有提供 name
,想要在组件直接操作它,就会得到 JS 常见错误
,因为它是 undefined
。
还有一种特殊情况:
@Component({
selector: "app-child[name]",
...
})
它会抛出,让人看着一脸懵逼。错误:
[ERROR] NG8001: 'app-child' is not a known element:
1. If 'app-child' is an Angular component, then verify that it is included in the '@Component.imports' of this component.
2. If 'app-child' is a Web Component then add 'CUSTOM_ELEMENTS_SCHEMA' to the '@Component.schemas' of this component to suppress this message.
幸运的是,Angular
开发团队也注意到了这个问题:feat(compiler): add support for compile-time required inputs #49468
这样我们可以选择将输入标记为 required
:
@Component({
selector: "app-child",
template: `<p>{{ name }}</p>`,
})
export class ChildComponent {
@Input({
required: true,
})
name: string;
}
在上面的代码中,name
属性是必需的,这意味着父组件必须为 name
属性提供一个值。
如果未向输入属性提供值,则将抛出错误:
[ERROR] NG8008: Required input 'name' from component ChildComponent must be specified.
注意:这个在编译时检查不是在运行时。开发时候,如果你不处理,可以通过各种手段忽略它。最终发布时候还是会抛出这个错误。如果在开发时发现这个错误,要尽早处理。
@Input Router data
路由器数据作为组件输入是 Angular v16
的另一个新特性。
const routes: Routes = [
{
path: 'hero/:id',
component: ChildComponent,
resolve: {
heroName: () => 'Jack',
},
data: {
heroFaction: 'Protoss',
}
}
];
以前我们想要获取这些数据就需要注入:
export class ChildComponent {
constructor(route: ActivatedRoute) {
route.params.subscribe((params) => console.log(params.id));
route.data.subscribe(({heroName, heroFaction}) => console.log(heroName, heroFaction));
}
}
我们只需要加上配置 withComponentInputBinding
:
const appRoutes: Routes = [];
bootstrapApplication(AppComponent,
{
providers: [
provideRouter(appRoutes, withComponentInputBinding())
]
}
);
我们现在只需要这样即可:
export class ChildComponent {
@Input() id?: string;
@Input() heroName?: string;
@Input() heroFaction?: string;
}
这样就可以省略 ActivatedRoute
注入了。
@Input transform
Angular v16.1
发布 @Input
的一个新的特性 transform
,默认情况下,输入值只能通过 getter
和 setter
的做转换,使用 transform
可以来简化这个操作。
使用输入 getter
和 setter
实现输入转换
我们来看一个官方组件库 material v15 checkbox 一个例子:
/** Whether the checkbox is required. */
@Input()
get required(): boolean {
return this._required;
}
set required(value: BooleanInput) {
this._required = coerceBooleanProperty(value);
}
private _required: boolean;
使用自定义装饰器实现输入转换
使用输入 getter
和 setter
实现输入转换,这样会有很多这样样板代码需要写,然后我就利用装饰器来简化这个操作:
const checkDescriptor = <T, K extends keyof T>(target: T, propertyKey: K) => {
const descriptor = Object.getOwnPropertyDescriptor(target, propertyKey);
if (descriptor && !descriptor.configurable) {
throw new TypeError(`property ${propertyKey} is not configurable`);
}
return {
oGetter: descriptor && descriptor.get,
oSetter: descriptor && descriptor.set
};
};
export type ValueHookSetter<T, K extends keyof T> = (key: symbol, value?: T[K]) => boolean | void;
export type ValueHookGetter<T, K extends keyof T> = (value: T[K]) => T[K];
/**
* @description 劫持属性值
* @param [setter]
* @param [getter]
* @returns
* @example
* ValueHook 回调函数里面导出普通函数 不然无法正确获取 this 和 aot 打包错误
*
* export function setHook(key, value) {
* // do something
* // 如果需要修改值需要返回false
* this[key] = value;
* return false;
* }
* export function getHook(value) {
* // do something
* // 需要返回的值
* return value
* }
*
* @Component({})
* export class ChildComponent {
* @Input()
* @ValueHook(setHook, getHook)
* name: string;
* }
*/
export function ValueHook<T, K extends keyof T>(setter?: ValueHookSetter<T, K>, getter?: ValueHookGetter<T, K>) {
return (target: T, propertyKey: K) => {
const { oGetter, oSetter } = checkDescriptor(target, propertyKey);
const symbol = Symbol(`_$$_${propertyKey}`);
type Mixed = T & {
symbol: T[K];
};
Object.defineProperty(target, propertyKey, {
enumerable: true,
configurable: true,
get(this: Mixed) {
return getter && this[symbol] !== undefined ? getter.call(this, this[symbol]) : oGetter ? oGetter.call(this) : this[symbol];
},
set(this: Mixed, value: T[K]) {
if (value === this[propertyKey] || (setter && setter.call(this, symbol, value) === false)) {
return;
}
if (oSetter) {
oSetter.call(this, symbol, value);
}
this[symbol] = value;
}
});
};
}
通过装饰器 ValueHook
封装 2 个新装饰器 InputBoolean
和 InputNumber
:
/**
* 处理 `@Input` boolean 类型属性
*/
export function InputBoolean<T, K extends keyof T>() {
return ValueHook<T, K>(function (key: symbol, value: T[K]) {
this[key] = coerceBooleanProperty(value);
return false;
});
}
/**
* 处理 `@Input` number 类型属性
*/
export function InputNumber<T, K extends keyof T>(fallbackValue: number = 0) {
return ValueHook<T, K>(function (key: symbol, value: T[K]) {
this[key] = coerceNumberProperty(value, fallbackValue);
return false;
});
}
这样我们就可以使用 InputBoolean
来简化上面的例子:
/** Whether the checkbox is required. */
@Input()
@InputBoolean()
required: boolean;
这种方式在组件库 ng-zorro-antd 大量运用。
使用 transform
输入转换
使用输入转换,我们可以在将输入值赋给组件的属性之前轻松地修改它的值,本质上实现了与我们刚刚使用 setter
例子所做的相同的效果。
/** Whether the checkbox is required. */
@Input({transform: booleanAttribute}) required: boolean;
/** Tabindex for the checkbox. */
@Input({transform: (value: unknown) => (value == null ? undefined : numberAttribute(value))})
tabIndex: number;
这是通过使用 @Input
装饰器的 transform
属性完成的:
@Component({
selector: "app-child",
template: ` <p>{{ name }}</p> `,
})
export class ChildComponent {
@Input({
transform: (value: string) => value.toUpperCase(),
})
name: string;
}
有了这个 transform
函数,任何传递给名称输入的字符串都将立即转换为大写。
在创建输入 transform
时要知道的要点:
transform
函数应该是纯函数。这意味着函数不应该有副作用。transform
函数应该是简洁高效的,它不应该包含大量且复杂的计算。transform
函数不能有条件地设置,但如果需要,可以在函数中添加逻辑条件。不能 transform: true ? numberAttribute : booleanAttribute 可以 transform: (v) => true ? numberAttribute(v) : booleanAttribute(v)
transform
函数不应该是函数返回函数。这意味着函数不应该是闭包或高阶函数。不能 transform: compose(fn1, fn2) 可以 transform: (v) => compose(fn1, fn2)(v)
@Input transform 内置 booleanAttribute 和 numberAttribute
除了允许我们替换 getter
和 setter
的使用外,输入转换还可以方便地支持布尔或常量数字属性等常见用例。
Angular
为我们提供了两个内置的输入变换,它们在很多常见场景中都很有用:booleanAttribute
和 numberAttribute
。
这些转换可以帮助我们的模板在某些情况下更具可读性。
例如,有时我们可能想要创建一个布尔输入属性,比如 disabled
标志
@Component({
selector: "app-child",
template: `<p>{{ name }}</p>`,
})
export class ChildComponent {
@Input()
disabled: boolean;
}
但问题是,要设置这个标志,我们需要在 ParentComponent
中使用一个输入表达式:
@Component({
selector: "app-parent",
template: `<app-child [disabled]="true" />`,
})
export class ParentComponent {}
这是可行的,但是如果我们可以像这样设置禁用标志是不是更方便。
@Component({
selector: "app-child",
template: `<app-child disabled />`,
})
export class ParentComponent {}
这只是一个小细节,但对于组件的用户来说,它确实感觉更自然了。
在这个例子中,仅仅是 disabled
属性的存在就表明 disabled
属性应该被设置为 true
。
我认为这使代码看起来更可读,它看起来更像普通的 HTML
。
默认情况下,这种不使用 []
表达式的简化语法不能像预期的那样工作。
但是我们可以通过应用 booleanAttribute
来实现输入变换这一点:
@Component({
selector: "app-child",
template: ` <p>{{ name }}</p> `,
})
export class ChildComponent {
@Input({
transform: booleanAttribute,
})
disabled: boolean;
}
现在我们的 disabled
输入属性按预期工作:仅仅是 disabled
属性的存在(即使没有值)就会导致 disabled
属性为 true
,否则为 false
。
请注意,如果需要使用 []
表达式来动态地正确设置它,那么一切仍将正常工作。
内置的 numberAttribute 输入转换
现在让我们看一下 numberAttribute
,它是另一个内置的输入转换。
使用 numberAttribute
转换将输入的字符串值转换为数值:
@Component({
selector: "app-child",
template: `<p>{{ age }}</p>`,
})
export class ChildComponent {
@Input({
transform: numberAttribute,
})
age: number;
}
有了这个转换,我们现在可以用下面的方式设置 age
属性:
@Component({
selector: "app-parent",
template: `<app-child age="20" />`,
})
export class ParentComponent {}
我们设置为 age
属性的任何字符串都将自动转换为数字,如果无法转换则转换为 NaN
。
如果我们没有进行这种转换,我们将不得不使用稍微冗长的语法:
@Component({
selector: "app-parent",
template: `<app-child [age]="20" />`,
})
export class ParentComponent {}
这也可以工作,但是仅仅设置一个简单的数字常量会让人感觉有点过分。
使用转换使我们的父组件模板更容易阅读,并使它看起来更接近纯 HTML。
使用 @Input 实现 prop-validation
我们在开篇也介绍这个功能主要有四个特点:
- default:默认值
- required:是否必填
- type:输入值类型
- validator:自定义验证
我们如何 @Input
模拟呢?
- default
在 @Input
里,自带 default
功能,如果你的父组件不写这个属性,默认就是设置的值。
@Component({
standalone: true,
selector: 'm-name',
template: '{{name}}',
})
export class NameComponent {
@Input()
name: string = 'Angular 17';
constructor() { }
}
但是这个有个问题,一旦我父组件里写的这个 name
属性,无论是否有值,那么默认值就将无效。这时候就体现 transform
好处了。
@Input({
transform: (v: unknown) => typeof v !== 'string' ? 'Angular 17' : v
})
name: string = 'Angular 17';
这样才能确保输入值更符合我们的预期。
根据源码里 writeToDirectiveInput 函数显示:
const inputTransforms = def.inputTransforms;
if (inputTransforms !== null && inputTransforms.hasOwnProperty(privateName)) {
value = inputTransforms[privateName](value);
}
if (def.setInput !== null) {
def.setInput(instance, value, publicName, privateName);
} else {
(instance as any)[privateName] = value;
}
一定要在父数组书写这个属性,transform
才能正常工作,否则就无效了。
- required
这个可以直接设置:
@Input({
required: true,
})
name: string;
- type and validator
简单理解它们工作是一样,都是验证输入数据,起到开发警告作用。validateProp
// type check
if (type != null && type !== true && !skipCheck) {
let isValid = false
const types = isArray(type) ? type : [type]
const expectedTypes = []
// value is valid as long as one of the specified types match
for (let i = 0; i < types.length && !isValid; i++) {
const { valid, expectedType } = assertType(value, types[i])
expectedTypes.push(expectedType || '')
isValid = valid
}
if (!isValid) {
warn(getInvalidTypeMessage(name, value, expectedTypes))
return
}
}
// custom validator
if (validator && !validator(value, props)) {
warn('Invalid prop: custom validator check failed for prop "' + name + '".')
}
这里 transform
只能实现 type check
的工作,因为源码是 transform(value)
这样调用的,没有任何上下文,所以前面 transform
注意事项里面第一要点就是纯函数。
@Input({
transform: (v: unknown) => typeCheck(v, String)
})
name: string = 'Angular';
@Input({
transform: (v: unknown) => typeCheck(v, [String, Number])
})
age: number = 17
至于怎么实现 typeCheck
函数,相信不要我教了,你真的不会,可以直接借鉴 Vue
源码。
declare global {
const ngDevMode: null|unknown;
}
function typeCheck<T>(value: unknown, type: T) {
// dev type check warn
if(typeof ngDevMode === 'undefined' || ngDevMode) {
// type check code
}
return value;
}
这里只有使用 ngDevMode
,也是 Angular
内置全局变量,ng build --configuration=production
之后就会 tree shake
优化。
这里有段关于 ngDevMode vs isDevMode() 解释
关于 validator
实现,如果你想要访问组件里上下文,其他属性,那使用 transform
就不合适,只能使用 setter
。
@Input({
transform: (v: unknown) => typeCheck(v, String)
})
name: string = 'Angular';
get name(): boolean {
return this._required;
}
set name(value: BooleanInput) {
// dev custom validator warn
if(typeof ngDevMode === 'undefined' || ngDevMode) {
// validator code
}
this._name = value;
}
private _name: string = 'Angular';
transform
和 setter
可以同时使用:
transform
主要工作是做数据输入转换,确保属性值符合预期,不会引起后续代码运行错误,相当于setter
前置工作,如果只限于值转换处理,不需要书写setter
一堆样板代码。setter
主要处理输入值,你可以把它理解成响应式互调函数,只要值有变化就会触发这个函数。Angular
没有watch
属性功能。还有一个类似方案就是生命周期钩子ngOnChanges
。
你是不是发现使用 transform
和 setter
去实现 type
和 validator
太麻烦了。
受限 transform
约束,不能直接写闭包函数,必须要返回在函数里:
function compose(...fns: ((v: unknown) => unknown)[]) {
return function (value: unknown) {
return fns.reduceRight((currentValue, currentFunction) => currentFunction(currentValue), value);
}
}
@Input({
transform: (v: unknown) => compose((v: unknown) => {
return v;
}, (v: unknown) => {
return v;
})(v)
})
name: string = 'Angular';
如果我想实现一个组合函数,去处理只能这样去实现。
不能直接写成:
@Input({
transform: compose((v: unknown) => {
return v;
}, (v: unknown) => {
return v;
})
})
name: string = 'Angular';
首先编译会报错:
Input transform must be a function
Value could not be determined statically.
Unable to evaluate this expression statically.
This syntax is not supported.
那有没有更好方式实现,目前只能是装饰器了。还记得前面的自定义装饰器 ValueHook
,就可以轻松实现这个2个功能。
declare global {
const ngDevMode: null|unknown;
}
export function TypeCheck<T, K extends keyof T>(type: unknown | unknown[]) {
// dev type check warn
if(typeof ngDevMode === 'undefined' || ngDevMode) {
return ValueHook<T, K>(function (key: symbol, value: T[K]) {
// type check code
});
}
return () => {};
}
export function Validator<T, K extends keyof T>(validator: (value: T[K], props: T) => boolean) {
// dev custom validator warn
if(typeof ngDevMode === 'undefined' || ngDevMode) {
return ValueHook<T, K>(function (this: T, key: symbol, value: T[K]) {
// custom validator code
if(!validator(value, this)) {
console.warn("Invalid prop: custom validator check failed for @input " + key.description?.replace('_$$_', '') + '.');
}
});
}
return () => {};
}
自定义验证装饰器,会先于 transform
执行。
@Input({
transform: (v: unknown) => (console.log('transform', v), v),
})
@TypeCheck([String])
@Validator((value, props) => {
console.log('Validator', value, props);
return false;
})
name!: string;
注意: transform
只有外部变化以后才会重新执行,自定义验证装饰器无论内外,只要这个值执行都会重新执行。
@Input vs @Attribute
你可能遇到的另一个看起来与 @Input
非常相似的装饰器是 @Attribute
装饰器。
这两个装饰器可能看起来很相似,但它们的用途却截然不同。
要理解为什么存在这两种装饰器,重要的是要明白 DOM
一般是如何工作的html-attribute-vs-dom-property。
让我们记住,DOM
元素具有两组不同的键值对:
DOM attributes
: 这些是HTML
标签的属性,它们总是字符串。DOM properties
: 这些是实际DOM
节点的属性,因此它们可以是任何类型,而不仅仅是字符串,就像任何 Javascript 对象的属性一样。
一般来说,你可以有一个DOM property
和一个 DOM attribute
具有相同的名称,它们不会保持双向同步。
如何使用 @Attribute
一般来说,在框架里我们总是使用 DOM property
和非 DOM attribute
,所以在绝大多数情况下,应该始终使用 @Input
。
如果有一个值它反映了元素创建时属性的初始值。一旦设置,即使属性值以后发生变化,它也不会改变。Angular
为我们提供了一个装饰器来获取属性的这个值,是以字符串形式提供属性值的只读视图。
下面是如何使用 @Attribute
的一个例子:
@Component({
selector: "app-child",
template: ` <p>{{ age }}</p> `,
})
export class ChildComponent {
constructor(@Attribute("age") public age: string) {}
}
正如所看到的,@Attribute
装饰器只能在构造函数中使用。
下面是我们如何设置 attribute
:
@Component({
selector: "app-parent",
template: ` <app-child age="10" /> `,
})
export class ParentComponent {}
这将在 ChildComponent
的 constructor
中注入字符串'10'
。
这个值是常量,不会随时改变,因为它是一个只读值。
写在最后
在本文中,我们详细探讨了 @Input
装饰器,涵盖了它当前所有可用的功能。
@Input
最有趣的特性之一是 transform
,它允许我们通过支持 booleanAttribute
或 numberAttribute
等常见用例来使代码更具可读性。
@Input
的转换值使使用输入 getters/setters
比以前不需要。transform
是一个非常方便的特性,所以我鼓励你开始在项目中使用它们。
今天就到这里吧,伙计们,玩得开心,祝你好运
谢谢你读到这里。下面是你接下来可以做的一些事情:
- 找到错字了?下面评论
- 如果有问题吗?下面评论
- 对你有用吗?表达你的支持并分享它。
原文链接:https://juejin.cn/post/7324750284189024307 作者:jiayi