Angular 核心指北:Input Transform

我相信你已经听说过 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'
    }
  }
})

你没看错,这是 Vueprop-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,默认情况下,输入值只能通过 gettersetter 的做转换,使用 transform 可以来简化这个操作。

使用输入 gettersetter 实现输入转换

我们来看一个官方组件库 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;

使用自定义装饰器实现输入转换

使用输入 gettersetter 实现输入转换,这样会有很多这样样板代码需要写,然后我就利用装饰器来简化这个操作:

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 个新装饰器 InputBooleanInputNumber

/**
 * 处理 `@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

除了允许我们替换 gettersetter 的使用外,输入转换还可以方便地支持布尔或常量数字属性等常见用例。

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 模拟呢?

  1. 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 才能正常工作,否则就无效了。

  1. required

这个可以直接设置:

@Input({
    required: true,
})
name: string;
  1. 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';

transformsetter 可以同时使用:

  • transform 主要工作是做数据输入转换,确保属性值符合预期,不会引起后续代码运行错误,相当于 setter 前置工作,如果只限于值转换处理,不需要书写 setter 一堆样板代码。
  • setter 主要处理输入值,你可以把它理解成响应式互调函数,只要值有变化就会触发这个函数。Angular 没有 watch 属性功能。还有一个类似方案就是生命周期钩子 ngOnChanges

你是不是发现使用 transformsetter 去实现 typevalidator 太麻烦了。

受限 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 {}

这将在 ChildComponentconstructor 中注入字符串'10'

这个值是常量,不会随时改变,因为它是一个只读值。

写在最后

在本文中,我们详细探讨了 @Input 装饰器,涵盖了它当前所有可用的功能。

@Input 最有趣的特性之一是 transform,它允许我们通过支持 booleanAttributenumberAttribute 等常见用例来使代码更具可读性。

@Input 的转换值使使用输入 getters/setters 比以前不需要。transform 是一个非常方便的特性,所以我鼓励你开始在项目中使用它们。

今天就到这里吧,伙计们,玩得开心,祝你好运

谢谢你读到这里。下面是你接下来可以做的一些事情:

  • 找到错字了?下面评论
  • 如果有问题吗?下面评论
  • 对你有用吗?表达你的支持并分享它。

原文链接:https://juejin.cn/post/7324750284189024307 作者:jiayi

(0)
上一篇 2024年1月17日 下午4:05
下一篇 2024年1月17日 下午4:16

相关推荐

发表回复

登录后才能评论