Angular 核心指北:Input Transform

我相信你已经听说过 Angular@Input 装饰器!

这个装饰器允许我们将数据从父组件传递给子组件,它是框架的核心功能之一。

今天我们来聊聊,关于该装饰器具有许多额外功能。

在聊这个新功能之前,我们先看一个 defineProps 函数。

ts 复制代码
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.PropTypesReact v15.5 起已弃用。后续只能使用 prop-types 库代替,出于性能考虑,一般 propTypes 只在开发模式下进行检查。

那么对于 Angular 来说,我们重点关注的输入验证特性,只能仅靠 ts 类型系统和 eslint 代码检查来约束。然而在大多数情况下,想要更严格验证,只能用 getter/setter 来处理验证。

@Input 基础知识

@Input() 是一个 Angular 装饰器,它把一个类属性标记为组件的输入属性,就是常说 prop

@Input 装饰器用于将数据从父组件传递给子组件。

下面是 @Input 装饰器的基本用法:

ts 复制代码
@Component({
  selector: "child",
  template: `<p>{{ name }}</p>`,
})
export class ChildComponent {
  @Input() name: string;
}

这里,@Input 装饰器被用来将 name 属性标记为输入属性。

这意味着使用 ChildComponent 的父组件可以使用属性模板绑定语法来设置 name 属性的值:

ts 复制代码
@Component({
  selector: "parent",
  template: `<child [name]="name" />`,
})
export class ParentComponent {
  name = "Angular";
}

如果在父组件中 name 属性的值发生了变化,那么这个变化将通过 @Input() 装饰器传递。

这就是 @Input 的意义所在。

现在,我们学习基础知识。让我们开始探索其他可用的额外功能和配置选项。

@Input 设置别名

可以通过定义输入别名显式地定义输入属性的名称。

这是使用简化符号进行操作的方法:

ts 复制代码
@Component({
  selector: "app-child",
  template: `<p>{{ name }}</p>`,
})
export class ChildComponent {
  @Input("userName") name: string;
  
  // v16 以上版本允许新写法
  @Input({
    alias: "userName"
  }) 
  name: string;
}

在上面的代码中,name 输入属性别名设置为 userName

有了这些别名,就可以在父组件中设置属性:

ts 复制代码
@Component({
  selector: "app-parent",
  template: `<app-child [userName]="name" />`,
})
export class ParentComponent {
  name = "Angular";
}

注意:尽量少用别名,因为它会使你的组件内部和外部属性名不一致,导致歧义,使你的维护修改成本增加。

@Input 用 setter/getter

现在让我们开始深入了解 @Input 的一些不太为人所知的特性,从 getter/setter 开始。

除了对成员变量应用 @Input 装饰器,我们还可以使用访问修饰符 getter/setter

ts 复制代码
@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 中设置这个输入的值:

ts 复制代码
@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,默认情况下,输入是可选的,因此当没有提供输入时不会出现验证错误。

我们先来看看如果没有这个特性之前我们该如何做必填:

ts 复制代码
@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.`);
    }
  }
}

如果不提供该值,我们现在可以在控制台中看到错误:

text 复制代码
ERROR Error: Required input 'name' from component ChildComponent must be specified.

如果我们父组件没有提供 name,想要在组件直接操作它,就会得到 JS 常见错误,因为它是 undefined

还有一种特殊情况:

ts 复制代码
@Component({
  selector: "app-child[name]",
  ...
})

它会抛出,让人看着一脸懵逼。错误:

ts 复制代码
[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:

ts 复制代码
@Component({
  selector: "app-child",
  template: `<p>{{ name }}</p>`,
})
export class ChildComponent {
  @Input({
    required: true,
  })
  name: string;
}

在上面的代码中,name 属性是必需的,这意味着父组件必须为 name 属性提供一个值。

如果未向输入属性提供值,则将抛出错误:

text 复制代码
[ERROR] NG8008: Required input 'name' from component ChildComponent must be specified.

注意:这个在编译时检查不是在运行时。开发时候,如果你不处理,可以通过各种手段忽略它。最终发布时候还是会抛出这个错误。如果在开发时发现这个错误,要尽早处理。

@Input Router data

路由器数据作为组件输入是 Angular v16 的另一个新特性。

ts 复制代码
const routes: Routes = [ 
    { 
        path: 'hero/:id', 
        component: ChildComponent, 
        resolve: { 
            heroName: () => 'Jack', 
        }, 
        data: { 
            heroFaction: 'Protoss', 
        } 
     } 
];

以前我们想要获取这些数据就需要注入:

ts 复制代码
export class ChildComponent {
  constructor(route: ActivatedRoute) {
    route.params.subscribe((params) => console.log(params.id));
    route.data.subscribe(({heroName, heroFaction}) => console.log(heroName, heroFaction));
  } 
}

我们只需要加上配置 withComponentInputBinding

ts 复制代码
const appRoutes: Routes = [];
bootstrapApplication(AppComponent,
  {
    providers: [
      provideRouter(appRoutes, withComponentInputBinding())
    ]
  }
);

我们现在只需要这样即可:

ts 复制代码
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 一个例子:

ts 复制代码
/** Whether the checkbox is required. */
@Input()
get required(): boolean {
    return this._required;
}
set required(value: BooleanInput) {
    this._required = coerceBooleanProperty(value);
}
private _required: boolean;

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

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

ts 复制代码
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

ts 复制代码
/**
 * 处理 `@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 来简化上面的例子:

ts 复制代码
/** Whether the checkbox is required. */
@Input()
@InputBoolean()
required: boolean;

这种方式在组件库 ng-zorro-antd 大量运用。

使用 transform 输入转换

使用输入转换,我们可以在将输入值赋给组件的属性之前轻松地修改它的值,本质上实现了与我们刚刚使用 setter 例子所做的相同的效果。

ts 复制代码
/** 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 属性完成的:

ts 复制代码
@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 为我们提供了两个内置的输入变换,它们在很多常见场景中都很有用:booleanAttributenumberAttribute

这些转换可以帮助我们的模板在某些情况下更具可读性。

例如,有时我们可能想要创建一个布尔输入属性,比如 disabled 标志

ts 复制代码
@Component({
  selector: "app-child",
  template: `<p>{{ name }}</p>`,
})
export class ChildComponent {
  @Input()
  disabled: boolean;
}

但问题是,要设置这个标志,我们需要在 ParentComponent 中使用一个输入表达式:

ts 复制代码
@Component({
  selector: "app-parent",
  template: `<app-child [disabled]="true" />`,
})
export class ParentComponent {}

这是可行的,但是如果我们可以像这样设置禁用标志是不是更方便。

ts 复制代码
@Component({
  selector: "app-child",
  template: `<app-child disabled />`,
})
export class ParentComponent {}

这只是一个小细节,但对于组件的用户来说,它确实感觉更自然了。

在这个例子中,仅仅是 disabled 属性的存在就表明 disabled 属性应该被设置为 true

我认为这使代码看起来更可读,它看起来更像普通的 HTML

默认情况下,这种不使用 [] 表达式的简化语法不能像预期的那样工作。

但是我们可以通过应用 booleanAttribute 来实现输入变换这一点:

ts 复制代码
@Component({
  selector: "app-child",
  template: ` <p>{{ name }}</p> `,
})
export class ChildComponent {
  @Input({
    transform: booleanAttribute,
  })
  disabled: boolean;
}

现在我们的 disabled 输入属性按预期工作:仅仅是 disabled 属性的存在(即使没有值)就会导致 disabled 属性为 true,否则为 false

请注意,如果需要使用 [] 表达式来动态地正确设置它,那么一切仍将正常工作。

内置的 numberAttribute 输入转换

现在让我们看一下 numberAttribute,它是另一个内置的输入转换。

使用 numberAttribute 转换将输入的字符串值转换为数值:

ts 复制代码
@Component({
  selector: "app-child",
  template: `<p>{{ age }}</p>`,
})
export class ChildComponent {
  @Input({
    transform: numberAttribute,
  })
  age: number;
}

有了这个转换,我们现在可以用下面的方式设置 age 属性:

ts 复制代码
@Component({
  selector: "app-parent",
  template: `<app-child age="20" />`,
})
export class ParentComponent {}

我们设置为 age 属性的任何字符串都将自动转换为数字,如果无法转换则转换为 NaN

如果我们没有进行这种转换,我们将不得不使用稍微冗长的语法:

ts 复制代码
@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 功能,如果你的父组件不写这个属性,默认就是设置的值。

ts 复制代码
@Component({
  standalone: true,
  selector: 'm-name',
  template: '{{name}}',
})
export class NameComponent {

  @Input()
  name: string = 'Angular 17';
  constructor() { }
}

但是这个有个问题,一旦我父组件里写的这个 name 属性,无论是否有值,那么默认值就将无效。这时候就体现 transform 好处了。

ts 复制代码
@Input({
    transform: (v: unknown) => typeof v !== 'string' ? 'Angular 17' : v
})
name: string = 'Angular 17';

这样才能确保输入值更符合我们的预期。

根据源码里 writeToDirectiveInput 函数显示:

ts 复制代码
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

这个可以直接设置:

ts 复制代码
@Input({
    required: true,
})
name: string;
  1. type and validator

简单理解它们工作是一样,都是验证输入数据,起到开发警告作用。validateProp

ts 复制代码
// 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 注意事项里面第一要点就是纯函数。

ts 复制代码
@Input({
    transform: (v: unknown) => typeCheck(v, String)
})
name: string = 'Angular';

@Input({
    transform: (v: unknown) => typeCheck(v, [String, Number])
})
age: number = 17

至于怎么实现 typeCheck 函数,相信不要我教了,你真的不会,可以直接借鉴 Vue 源码。

ts 复制代码
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

ts 复制代码
@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 约束,不能直接写闭包函数,必须要返回在函数里:

ts 复制代码
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';

如果我想实现一个组合函数,去处理只能这样去实现。

不能直接写成:

ts 复制代码
@Input({
    transform: compose((v: unknown) => {
      return v;
    }, (v: unknown) => {
      return v;
    })
})
name: string = 'Angular';

首先编译会报错:

text 复制代码
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个功能。

ts 复制代码
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 执行。

ts 复制代码
@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 的一个例子:

ts 复制代码
@Component({
  selector: "app-child",
  template: ` <p>{{ age }}</p> `,
})
export class ChildComponent {
  constructor(@Attribute("age") public age: string) {}
}

正如所看到的,@Attribute 装饰器只能在构造函数中使用。

下面是我们如何设置 attribute:

ts 复制代码
@Component({
  selector: "app-parent",
  template: ` <app-child age="10" /> `,
})
export class ParentComponent {}

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

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

写在最后

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

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

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

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

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

  • 找到错字了?下面评论
  • 如果有问题吗?下面评论
  • 对你有用吗?表达你的支持并分享它。
相关推荐
崔庆才丨静觅4 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60614 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了4 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅4 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅5 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅5 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment5 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅6 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊6 小时前
jwt介绍
前端
爱敲代码的小鱼6 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax