从0到1搭建 react 组件库- Form 篇

Form使用场景?

前端开发中,经常通过输入组件,去获取用户的输入内容。每新增一个输入组件的时就要在页面中增加一个新的状态来记录用户输入的值。 但是如果为每一个控件都声明一遍 valueonChange ,或者是通过 useReducer 的方式来实现,但都不可避免的会增加代码的复杂程度。

此时,就需要 Form 组件来统一管理整个表单的状态。每一个输入组件通过 Form + Form.Item 组件嵌套的形式,把状态托管给了 FormInstance (组件内部的状态实例)来维护。

Form 方案:

这里参照了抱枕老师的 🍓中台表单技术选型实践(表单实践) 这篇文章内容。首先来总体介绍一下目前主流的 Form 方案,然后分析其中的优缺点。 然后会针对其中的发布订阅模式进行一个基本实现。

高阶组件(Antd Form 3.X ):

场景: 每次新增一个表单组件时,就需要声明一组 valueonChange 或者 useReducer 的方式去管理每一个表单组件的用户输入。

实现: 表单一开始的形态,是通过高阶组件的形式来完成的, 首先将自定义的 Form 组件整体通过一个高阶函数包裹起来, 以便向这个自定义的 Form 组件中注入 form 实例,在这一过程中也创建一个状态管理上下文, 通过 form 实例, 获取相关的操作 API。 然后通过这些 API 来管控状态树, 每次 value 的变化都会触发状态树的更新,进而调用 forceUpdate 刷新了整个表单。

优点: 此时通过 getFieldDecorator 高阶函数,完成了对组件的状态管理, 省略了重复的状态和事件监听函数的声明。

缺点: 每次表单中某个组件的 value 变化, 会引起整个表单的渲染,面临复杂的场景联动的情况下就显得有些捉襟见肘。

发布订阅模式(Antd Form 4.X、Arco ):

场景: 希望通过类似状态管理的思路,去完成表单的状态管理,达成细粒度更新的效果。

实现: 表单函数式组件时的形态,则是通过更细粒度的发布订阅模式完成的,此时的 Form 的状态就分成两部分进行维护。 首先,通过 Context 传递整个表单实例,以此在 Form 以及 FormItem 来操作状态树,然后在每个 FormItem 中代理 onChange 事件, 负责同步状态树和真实值。 而不是在 Form 中统一的通过 forceUpdate 进行管控。其次, 外界获取到表单实例, 也可以操作整体的状态树。这也就意味着每一个 FormItem 也会订阅整个 Store 的变化,进而更新 FormItem。 此时已经达到了更好的更新粒度。

优点: 通过发布订阅,完成整体表单的状态管理,达成细粒度的更新每个 FormItem 的目的,在性能表现上基本已经基本够用。

缺点: 面对复杂的表单场景(动态表单,联动表单)时,需要通过 shouldUpdate 去额外处理细粒度的更新。

非受控组件(react-hook-form):

场景: 同样为了解决细粒度更新的问题,还有一种方案是完全拥抱非受控组件, 比如 react-hook-form

实现: 通过 register 注册字段后的返回值代理表单组件中的 refonChange 等属性,通过代理 onChange 事件在表单的生命周期外维护一套 form 的输入数据, 然后 onSubmit 时, 将维护的表单数据作为参数传入。相当于是把值存在了函数式组件的闭包中, 这也就意味着每次更新值不会触发 rerender 。但是为了触发渲染,又不得不设置整个表单的数据模型,比如 errorsisSubmiting 等状态,所以当提交、校验时,就会触发整个表单的重新渲染。

优点: react-hook-form 在获取输入的值时不使用传统的 useState 的方案,尽可能规避掉了输入过程中的重新渲染。

缺点: 整体设计中,为整个表单创建了一个 state 作为表单的数据模型,尽可能的描述表单所处的状态,当校验或者复杂的表单场景时, 会调用这个表单的 setState 的方法去更新整体表单的状态,这会导致整个表单的重新渲染。

响应式(Formily):

场景: 上述的解决方案,对于表单之间的字段联动,复杂表单关系的嵌套的处理仍然是表现不足。那有没有一套为使用者提供像 react 的各种生命周期和钩子一样的表单解决方案呢?答案是有的,那就是 formily

实现: formily@formily/core 包中对表单进行建模,通过属性、方法对 FormField 的状态进行描述,在 @formily/reactive 中通过 Proxy 实现响应式的数据代理,进而在 @formily/reactive-react 中结合 React 本身的更新机制,借此收集触发组件渲染的回调。最终在 @formily/react 中完成了 APIReact 生态的桥接。但是最终使用的组件库需要针对 formily 的生态进行拓展二开。

优点: 在细粒度更新之上 formilyForm 以及 Field 的生命周期和事件提供了对应的钩子, 同时还支持 json schema 协议。

缺点: 上手难度大,并且如果是企业内部的组件库,需要针对 formily 的特性去进行二开。

发布订阅方案思路:

核心要素大概有两步:

  1. 通过 Context 传递整个表单的实例,在不同层级的 FormItem 中能够订阅其中发布的变更, 完成表单和字段之间的串联。
  2. 创建包含整个表单的状态、相对应的修改的事件(包含内部和外部)、发布订阅的实例。 这里相当于有一个订阅者(表单控件内部的 ui 更新)以及两个发布者(外部直接操作表单实例, 用户操作 ui 引发的组件更新), 主流的组件库通过将 Control 设为类组件完成调用, 这里直接通过声明的回调函数实现

创建表单实例:

在表单实例中设计 4 个变量分别用来存储以下内容:

  • store: 存放 form 的数据,之后不管外部使用 form 实例操作还是内部借助 FormItem 完成变更初始化操作的都是 store
  • initialValues: 用来存放 form 表单的初始值,方便之后调用 reset 方法。
  • subscribers: 存放 FormItem 中的订阅,在这里我们要先约定好协议。(目前 antd 和 arco 的做法是将控制组件变化的组件做成类组件,然后在表单实例中直接操作这个类组件中的数据。)
  • callbacks:存放 **form 组件通过 props 传入的 回调函数

定义好上面的数据存储项之后, 我们就可以一步步的完成表单组件中与表单实例之间的数据传递的事件。

ts 复制代码
export class Store<
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  FormData = any,
  FieldKey extends FieldKeyType = keyof FormData
> {
  store: Partial<FormData>; // 存放 form 数据
  initialValues: Partial<FormData>; // form 表单的初始值

  subscribers: StoreSubscriberProtocol<FormData> = {} // Control 组件中注册的 FormItem 相关操作
  callbacks: StoreCallbacks<FormData> = {} // 存放 form 组件通过 props 传入的回调函数

  constructor() {
    this.store = {}
    this.initialValues = {}
    // this.registerFieldChangeCallbacks = {}
  }

  // ... do something 稍后补全表单与字段通信的内容
}

完善表单实例的通信:

首先来介绍一下目录及每个文件对应的功能:

sh 复制代码
/mini-ui/packages/ui/src/components/Form
    ├── Control.tsx // FormItem 核心:复制、代理表单控件的事件,并对改变、校验进行处理。
    ├── Form.tsx    // Form 表单,初始化表单实例、创建上下文,传递数据。
    ├── FormItem.tsx    // 组装 Control、FormLabel 以及 错误提示等元素
    ├── FormLabel.tsx    // 表单控件的标题
    ├── context         
    │   └── index.ts    // 表单整体的上下文
    ├── index.ts        // 表单组件导出文件
    ├── interface.ts    // 类型声明文件
    ├── store.ts        // 表单实例文件
    ├── styles            // 样式
    │   ├── Form.less
    │   └── FormItem.less
    ├── useForm.ts        // 单例模式创建表单实例
    └── utils.ts            // 通用函数,包含校验。
上下文初始化:

使用 useContext 的过程中, 个人习惯将 Provider 的声明, 以及获取对应的 Context 的钩子声明, 都放在一起。

类型声明:

ts 复制代码
 /**
* @desc FormContext 和 FormItem 和 FormProps 共用的 API
* */ 
export interface CommonProps {
  /**
* @zh 标签的文本对齐方式
* */ 
  labelAlign?: 'left' | 'right'

  /**
* @zh 表单的布局
* */
  layout?: FormLayout

  /**
* @zh label 栅格布局的比例
* */
  labelCol?: ColProps;

  /**
* @zh value 容器的布局比例
* */
  wrapperCol?: ColProps;

  /**
* @zh 是否在 required 的时候显示加重的红色星号, 可通过设置 position 设定展示在 label 的位置('start' | 'end')
* */ 
  requiredSymbol?: boolean | { position: "start" | "end" }

  /**
* @zh 是否显示标签后的一个冒号
* */ 
  colon?: boolean | ReactNode
}


export interface FormContextType extends CommonProps {
  /**
* @zh form实例
* */
  form?: Store
}

Context 初始化:

ts 复制代码
// packages/ui/src/components/Form/context/index.ts

const defaultFormContext: FormContextType = {
}

const FormContext = createContext<FormContextType>(defaultFormContext)

export const FromProvider = FormContext.Provider

export function useFormContext () {
  return useContext(FormContext)
}
初始化表单实例:

通过 useForm 钩子实现单例模式创建 form 实例。

ts 复制代码
// packages/ui/src/components/Form/useForm.ts

import { useRef } from "react";
import { Store } from "./store";

function getInstance() {
  return new Store()
}

export function useForm(form?: Store) {
  const formRef = useRef(form)

  if(form) {
    formRef.current = form
  } else {
    formRef.current = getInstance() 
  }

  return formRef.current
}
传递布局配置以及表单实例:

通过上文中的 useForm 创建表单的实例, 然后根据 Form 组件中的属性, 设定表单 Context 中关于布局配置的值。

tsx 复制代码
// packages/ui/src/components/Form/Form.tsx

const FormInner = <FormData, >(props: PropsWithChildren<FormProps<FormData>>) => {

  // props
  const {
    form,
    initialValue,
    layout = 'horizontal',
    labelAlign = 'right',
    labelCol = { span: 5 },
    wrapperCol = { span: 18, offset: 1},
    requiredSymbol = true,
    colon = false,
    children
  } = props

  // status
  const formInstance = useForm(form)  // form 实例

  // 派生数据
  const formContext = { // form 组件的上下文
    form: formInstance,
    layout: layout,
    labelAlign,
    labelCol,
    wrapperCol,
    requiredSymbol,
    colon
  }
  // class names
  const prefix = getPrefix("form")
  const classNames = cls(
    prefix,
    {
      [`${prefix}-${layout}`]: layout
    }
  )

  return (
    <FromProvider value={formContext}>
      <form className={classNames} onSubmit={handleSubmit}>
        {children}
      </form>
    </FromProvider>
  )
}
表单 props 事件处理:

这一步,将用户以 props 形式传入 Form 组件的事件进行绑定,完成用户操作 Form 中的 UI 发生的事件的订阅。然后设置通知的机制,当用户操作导致值改变的事件时,发布给订阅者(Form props 中的事件)。

表单实例订阅、发布的方法:

  • 提供给组件中使用的方法,用来订阅组件 props 中的事件。
  • 完成发布的函数,将内部更新事件发布给 props 订阅事件。
ts 复制代码
export class Store<
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  FormData = any,
  FieldKey extends FieldKeyType = keyof FormData
> {
  callbacks: StoreCallbacks<FormData> = {} // 存放 form 组件通过 props 传入的回调函数

  /**
* @desc 任何变更引发 store 变更的事件, 都会通知 form 的 onValuesChange
* */
  private triggerValueChange(changeValues: Partial<FormData>) {
    if (changeValues && Object.keys(changeValues).length) {
      this.callbacks?.onValuesChange?.(changeValues, this.getFields())
    }
  } 

  /**
* @desc Control 触发内部 store 发生变更后通知 form 中的 onChange
* */
  private triggerTouchChange(changeValues: Partial<FormData>) {
    if (changeValues && Object.keys(changeValues).length) {
      this.callbacks?.onChange?.(changeValues, this.getFields())
    }
  }

  /**
* @desc 注册 Form props 中的事件 onChange、onValuesChange、onSubmit、 onSubmitFailed
* */
  innerRegisterEventCallbacks(callbacks: StoreCallbacks<FormData>) {
    this.callbacks = callbacks
  }

表单 props 事件注册(订阅):

  • 调用上一步的方法,将 Form props 中的事件存入到表单实例中, 方便 FormItem 被操作后调用。
tsx 复制代码
const FormInner = <FormData, >(props: PropsWithChildren<FormProps<FormData>>) => {

  // props
  const {
    form,
    initialValue,
    layout = 'horizontal',
    labelAlign = 'right',
    labelCol = { span: 5 },
    wrapperCol = { span: 18, offset: 1},
    requiredSymbol = true,
    colon = false,
    children
  } = props

  // status
  const formInstance = useForm(form)  // form 实例

  // ... do something
  
  // 给 form store 注册事件
  formInstance?.innerRegisterEventCallbacks({
    onChange: props.onChange,
    onValuesChange: props.onValuesChange,
    onSubmit: props.onSubmit,
    onSubmitFaild: props.onSubmitFaild
  })

  return (
    <FromProvider value={formContext}>
      <form className={classNames} onSubmit={handleSubmit}>
        {children}
      </form>
    </FromProvider>
  )
}
表单值初始化:

packages/ui/src/components/Form/Form.tsx 中完成整个表单值的初始化,有一个 useCreate 的钩子需要进行说明,我理解这里保证 表单实例创建之后,初始值就被设置到表单实例中(仅进行一次,避免 initial 可能带来的副作用,如果需要网络请求设置初始值,让用户自行通过表单实例操作。)然后 useCreate 执行时间在整个表单组件树构建的早起,能够确保后续FormItem 组件在渲染是,能够正确的获取到正确的初始值。

useCreate:

ts 复制代码
// packages/ui/src/hooks/useCreate.ts
export function useCreate(fn: () => void) {
  const hasCreated = useRef(false)

  if(!hasCreated.current) {
    fn?.()
    hasCreated.current = true
  }
}

初始化:

  • Form 组件中调用表单实例中的方法,初始化 props 中的初始值。。
tsx 复制代码
const FormInner = <FormData, >(props: PropsWithChildren<FormProps<FormData>>) => {

  // ... do something
  // status
  const formInstance = useForm(form)  // form 实例

  // effect 初始化form的初始值
  useCreate(() => {
    formInstance?.innerSetInitialValues(initialValue)
  })
  
  // ...do something

  return (
    <FromProvider value={formContext}>
      <form className={classNames} onSubmit={handleSubmit}>
        {children}
      </form>
    </FromProvider>
  )
}
ts 复制代码
export class Store<
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  FormData = any,
  FieldKey extends FieldKeyType = keyof FormData
> {
  store: Partial<FormData>; // 存放 form 数据
  initialValues: Partial<FormData>; // form 表单的初始值

  subscribers: StoreSubscriberProtocol<FormData> = {} // Control 组件中注册的 FormItem 相关操作
  callbacks: StoreCallbacks<FormData> = {} // 存放 form 组件通过 props 传入的回调函数

  /**
* @desc 内部初始化
* */
  innerSetInitialValues = (values?: Partial<FormData>) => {
    if (!values) return;
    this.initialValues = cloneDeep(values);

    Object.keys(values).forEach((field) => {
      set(this.store, field, values[field as keyof Partial<FormData>]);
    });

    // 触发内部 store 的变更
    this.triggerValueChange(values)
}
表单控件订阅更新&校验:

外界会通知内部进行的操作主要是 更新、校验两部分,我把这两个函数单独抽离出来,然后统一注册进表单实例中。

这一步完成之后,方便后续的外部更新、内部设置的处理。

订阅的数据规范:

ts 复制代码
 /**
* @desc 注册进入store的回调函数的协议
* */
export type StoreSubscriberProtocol<FormData> = Record<
  FieldKeyType,
  {
    validate: (value?: FormData[keyof FormData]) => Promise<FieldErrorType>;
    onStoreChange: (type: StoreChangeType, info: StoreChangeInfo<FormData>) => void
  }
>

表单实例中的订阅函数 & 发布函数:

ts 复制代码
export class Store<
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  FormData = any,
  FieldKey extends FieldKeyType = keyof FormData
> {

  subscribers: StoreSubscriberProtocol<FormData> = {} // Control 组件中注册的 FormItem 相关操作
    
  // ... do something
  
  /**
* @desc store 发布订阅注册符合协议的订阅
* */
  innerRegistFieldCallback(field: FieldKey, subscriber: StoreSubscriberProtocol<FormData>[FieldKey]) {

    this.subscribers = {
      ...this.subscribers,
      [field]: subscriber
    }
  }
  
  /**
* @desc 发布Store内部的状态变更
* */
  private notifyStoreChange(type: StoreChangeType, info: StoreChangeInfo<FormData>) {
    this.subscribers?.[info?.field as keyof StoreSubscriberProtocol<FormData>]?.onStoreChange?.(type, info)
  }

}

Control 中订阅:

  • onValidate: 用来校验输入的值是否符合校验关系,如果不符合就返回相应的值。
  • handleStoreChange: 用来响应不同数据源(外部、内部)的更新。(PS: 仅简单实现细粒度的更新, field的对比还应该考虑到深层级嵌套的问题。)。
ts 复制代码
import { cloneElement, PropsWithChildren, ReactElement, useEffect } from "react"
import { useFormContext } from "./context"
import { ControlProps, StoreChangeInfo, StoreChangeType } from "./interface"
import { validate } from "./utils";
import { isArray } from "lodash-es";
import { useUpdate } from "../../hooks/useUpdate";

export const Control = (props: PropsWithChildren<ControlProps>) => {
  // props
  const {
    field,
    rules,
    validateStatus,
    onError,
    children
  } = props

  // force re-render
  const forceUpdate = useUpdate()

  // context
  const { form } = useFormContext()

  async function onValidateValue( _value: FormData[keyof FormData]) {
    const errors = await validate(_value, field, rules)
    onError?.(errors)

    return errors
  }

  function handleStoreChange(type: StoreChangeType, info: StoreChangeInfo<FormData>) {
    switch(type) {
      case 'innerSetValue':
        if(info.field === field) {
          forceUpdate()
        }
        break
      case 'setFieldValue':
        if(info.errors)
          onError?.(info.errors)

        if(info.field === field) {
          forceUpdate()
        }
        break
      default:
        break
    }
  }

  // 副作用
  useEffect(() => {
    if(!field)
      return

    form?.innerRegistFieldCallback(field, {
      validate: onValidateValue,
      onStoreChange: handleStoreChange
    })
  }, [form])

  return (
      <div>
        {
          cloneChildElement()
        }
      </div>
  )
}
表单内部设置字段值:

通过 React.cloneElement 的函数复制子元素,然后代理 valueonChange 使其能够成为受控组件,然后 onChange 被触发后就调用表单实例中的方法去完成设置 store 的值,并且通知订阅的事件。

表单实例中内部设置值的方法:

  • 修改整体 store的值。
  • 完成上文中 表单组件内部事件通信表单 props 事件处理 以及表单控件订阅的更新事件的发布。
ts 复制代码
export class Store<
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  FormData = any,
  FieldKey extends FieldKeyType = keyof FormData
> {
  store: Partial<FormData>; // 存放 form 数据
  initialValues: Partial<FormData>; // form 表单的初始值

  subscribers: StoreSubscriberProtocol<FormData> = {} // Control 组件中注册的 FormItem 相关操作
  callbacks: StoreCallbacks<FormData> = {} // 存放 form 组件通过 props 传入的回调函数

  /**
* @desc Control 中触发改变事件后, 同步 store 中的状态。
* */
  innerSetFieldValue(field?: FieldKey, value?: string) {
    if (!field) return
    this.store = {
      ...this.store,
      [field]: value
    }

    this.triggerTouchChange({ [field]: value } as Partial<FormData>)
    this.triggerValueChange({ [field]: value } as Partial<FormData>)

    this.notifyStoreChange(
      'innerSetValue',
      {
        field,
        value: value as FormData[keyof FormData]
      }
    )
  }

}

复制子元素,并代理部分 props

ts 复制代码
export const Control = (props: PropsWithChildren<ControlProps>) => {
 
  // context
  const { form } = useFormContext()

  function onValueChange(value?: string) {
    // setValue(value)
    form?.innerSetFieldValue(field, value)
  }

  function cloneChildElement() {
    if (isArray(children)) {
      return children.map((com, i) => {
        return cloneElement (
          com,
          {
            key: com.key || i
          }
        )
      })
    }
    return cloneElement(
        children as ReactElement,
        {
          value: getValue(),
          onChange: onValueChange,
          status: validateStatus
        }
    )
  }

  return (
      <div>
        {
          cloneChildElement()
        }
      </div>
  )
}
外部操作表单实例:

表单实例暴露给外层操作的方法大致可以分为: 获取、更新。 其中获取表单状态值直接可以通过获取表单实例中的 store 即可,或者对其在进行一些额外的处理。因此我们主要关注的还是外部更新表单值如何通知给内部组件、以及校验相关内容。

获取表单状态值:

通过 lodashget 方法,实现多层级的数据获取。以及返回 store 的值。

ts 复制代码
export class Store<
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  FormData = any,
  FieldKey extends FieldKeyType = keyof FormData
> {
  store: Partial<FormData>; // 存放 form 数据

  // ... do something

  getFieldsValue() {
    return this.store
  }

  getFieldValue(field: string) {
    return get(this.store, field)
  }

  getFields() {
    return cloneDeep(this.store)
  }

  // ... do something
}

更新表单状态值:

setFieldValue 用来设定指定字段的值, setFieldsValue 用来更新 object 类型的值,也就是 Partial<FormData>的值。 但是上面这两个函数本质上都是做了一层转化然后调用 setFieldssetFields 是根据约定的数据更新协议进行设置。

ts 复制代码
export class Store<
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  FormData = any,
  FieldKey extends FieldKeyType = keyof FormData
> {
  store: Partial<FormData>; // 存放 form 数据
  initialValues: Partial<FormData>; // form 表单的初始值

  subscribers: StoreSubscriberProtocol<FormData> = {} // Control 组件中注册的 FormItem 相关操作
  callbacks: StoreCallbacks<FormData> = {} // 存放 form 组件通过 props 传入的回调函数

  //... do something

  /**
* @desc 外部设置单个 field 的值。
* */
  setFieldValue(field: FieldKeyType, value: FormData[keyof FormData]) {

    this.setFields({
      [field]: {
        value
      }
    })
  }

  /**
* @desc 外部设置 form 表单的值
* */
  setFields(obj: Record<FieldKeyType, Omit<StoreChangeInfo<FormData>, 'field'>>) {
    const keys = Object.keys(obj)
    const changeValues: Partial<FormData> = {}

    keys.forEach(field => {
      const item = obj[field]

      if ('value' in item) {
        set(this.store, field, item.value)
        set(changeValues, field, item.value)
      }

      this.notifyStoreChange(
        'setFieldValue',
        {
          ...item,
          field
        }
      )
    })

    this.triggerValueChange(changeValues)
  }

  /**
* @desc 外部设置多个表单控件的值
* */
  setFieldsValue(obj: Record<FieldKey, unknown>) {
    const changedValue = Object.keys(obj)
      .reduce((prev, cur) => {

        return {
          ...prev,
          [cur]: {
            value: obj[cur as keyof typeof obj]
          }
        }
      }, {})

    this.setFields(changedValue)
  }

  /**
* @desc 重置表单控件的值为初始值
* */
  resetFields(fields?: string[]) {
    fields = isArray(fields) ? fields : Object.keys(this.store)

    const changeValue = {} as Record<FieldKey, unknown>
    fields?.forEach(field => {
      const value = this.initialValues?.[field as keyof FormData]
      set(changeValue, field, value)
    })

    this.setFieldsValue(changeValue)
  }
}

总结:

随着前端技术的不断发展,表单解决方案也日趋成熟。今天我们探讨了表单发展历程中的四种处理方案(高阶组件、 发布订阅、 非受控组件、 响应式),并重点实现了基于发布-订阅模式的方案。仓库代码中还包含了表单校验和提交功能,但由于篇幅限制,未做详细展开。嵌套表单场景目前也尚未完善。。。

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