双向绑定

双向绑定是表单场景中的常见需求——子组件修改值后,需要同步通知父组件更新。Vitarx 通过 useModel 提供了一种优雅的双向绑定方案。

useModel() — 创建双向绑定属性引用

useModel 创建一个 ModelRef 对象,它代理了 props 中某个属性的读写操作:

  • 读取 model.value 时,返回 props[propName] 的当前值
  • 设置 model.value 时,自动调用 props['onUpdate:propName'] 通知父组件
typescript
declare function useModel<T extends AnyProps, K extends keyof T>(
  props: T,
  propName: K,
  defaultValue?: T[K]
): ModelRef<T, K>
tsx
import { useModel } from 'vitarx'

function MyInput(props: { value?: string; 'onUpdate:value'?: (val: string) => void }) {
  const model = useModel(props, 'value')

  return (
    <input
      value={model.value}
      onInput={(e) => {
        model.value = e.target.value
      }}
    />
  )
}

命名约定

Vitarx 的双向绑定遵循以下命名约定:

属性名更新事件名说明
valueonUpdate:value通用值绑定
modelValueonUpdate:modelValuev-model 默认绑定
customProponUpdate:customProp自定义属性绑定

当你在子组件中通过 useModel(props, 'value') 修改值时,框架会自动调用 props['onUpdate:value'] 将新值传递给父组件。

WithModelEvent 类型工具

在 TypeScript 中手动声明 onUpdate:xxx 事件类型比较繁琐。WithModelEvent 类型工具可以自动为指定属性生成对应的更新事件类型:

typescript
type WithModelEvent<T extends AnyProps, K extends keyof T> = T & {
  [key in K extends string ? `onUpdate:#123;K}` : never]?: (value: T[K]) => void
}

使用示例:

tsx
import { useModel, type WithModelEvent } from 'vitarx'

// 不用 WithModelEvent — 需要手动声明更新事件
interface MyInputProps1 {
  value?: string
  'onUpdate:value'?: (val: string) => void
  title?: string
  'onUpdate:title'?: (val: string) => void
}

// 使用 WithModelEvent — 自动生成更新事件类型
type MyInputProps2 = WithModelEvent<
  {
    value?: string
    title?: string
  },
  'value' | 'title'
>

function MyInput(props: MyInputProps2) {
  const valueModel = useModel(props, 'value')
  const titleModel = useModel(props, 'title')

  return (
    <div>
      <input
        value={titleModel.value}
        onInput={(e) => {
          titleModel.value = e.target.value
        }}
      />
      <input
        value={valueModel.value}
        onInput={(e) => {
          valueModel.value = e.target.value
        }}
      />
    </div>
  )
}

v-model 语法糖

在 JSX 中,Vitarx 提供了 v-model 属性作为语法糖,仅支持 v-model <-> modelValue 的约定:

tsx
import { ref } from 'vitarx'

function App() {
  const text = ref('')

  // 使用 v-model 语法糖
  return <MyInput v-model={text} />

  // 等效于:
  // return <MyInput modelValue={text} onUpdate:modelValue={(v) => { text.value = v }} />
}

v-model 仅支持与 modelValue 属性的绑定,不支持 v-model:propName 形式。自定义属性的双向绑定请使用 useModel

自定义 v-model 示例

下面展示如何创建一个支持双向绑定的自定义组件:

tsx
import { ref, useModel, type WithModelEvent } from 'vitarx'

// 定义 props 类型,使用 WithModelEvent 自动生成更新事件
type SearchInputProps = WithModelEvent<
  {
    modelValue?: string
    placeholder?: string
  },
  'modelValue'
>

function SearchInput(props: SearchInputProps) {
  const model = useModel(props, 'modelValue')
  const isFocused = ref(false)

  return (
    <div class={`search-input #123;isFocused.value ? 'focused' : ''}`}>
      <span class="search-icon">🔍</span>
      <input
        value={model.value}
        placeholder={props.placeholder || '搜索...'}
        onInput={(e) => {
          model.value = e.target.value
        }}
        onFocus={() => {
          isFocused.value = true
        }}
        onBlur={() => {
          isFocused.value = false
        }}
      />
      {model.value && (
        <button
          class="clear-btn"
          onClick={() => {
            model.value = ''
          }}
        >
          ×
        </button>
      )}
    </div>
  )
}

// 使用
function App() {
  const keyword = ref('')

  return (
    <div>
      <SearchInput v-model={keyword} placeholder="输入关键词" />
      <p>搜索关键词:{keyword}</p>
    </div>
  )
}

useModel 的默认值

useModel 的第三个参数可以为属性设置默认值。当 props[propName]undefined 时,会使用默认值:

tsx
import { useModel } from 'vitarx'

function Toggle(props: { visible?: boolean; 'onUpdate:visible'?: (v: boolean) => void }) {
  // 当 props.visible 为 undefined 时,默认值为 false
  const visible = useModel(props, 'visible', false)

  return (
    <button
      onClick={() => {
        visible.value = !visible.value
      }}
    >
      {visible.value ? '开启' : '关闭'}
    </button>
  )
}

巧用 useModel 绕过 props 只读限制

由于 useModel 在设置值时会通过事件通知父组件更新,你可以用它来"修改"props 的值,而不会违反 props 只读的规则:

tsx
import { useModel } from 'vitarx'

function Dialog(props: { show?: boolean; 'onUpdate:show'?: (v: boolean) => void }) {
  // 通过 useModel 代理 show 属性,实现"修改"效果
  const visible = useModel(props, 'show', false)

  return (
    <div v-show={visible}>
      <p>对话框内容</p>
      <button
        onClick={() => {
          visible.value = false
        }}
      >
        关闭
      </button>
    </div>
  )
}

完整示例

下面是一个完整的表单组件示例,展示多种双向绑定场景:

tsx
import { ref, useModel, type WithModelEvent } from 'vitarx'

// ========== 输入框组件 ==========
type TextInputProps = WithModelEvent<
  {
    modelValue?: string
    label?: string
    type?: string
  },
  'modelValue'
>

function TextInput(props: TextInputProps) {
  const model = useModel(props, 'modelValue')

  return (
    <div class="form-item">
      {props.label && <label>{props.label}</label>}
      <input
        type={props.type || 'text'}
        value={model.value}
        onInput={(e) => {
          model.value = e.target.value
        }}
      />
    </div>
  )
}

// ========== 开关组件 ==========
type SwitchProps = WithModelEvent<
  {
    modelValue?: boolean
    label?: string
  },
  'modelValue'
>

function Switch(props: SwitchProps) {
  const model = useModel(props, 'modelValue', false)

  return (
    <div class="form-item switch-item">
      {props.label && <label>{props.label}</label>}
      <button
        class={`switch #123;model.value ? 'on' : 'off'}`}
        onClick={() => {
          model.value = !model.value
        }}
      >
        {model.value ? '开启' : '关闭'}
      </button>
    </div>
  )
}

// ========== 选择器组件 ==========
type SelectProps = WithModelEvent<
  {
    modelValue?: string
    options: Array<{ label: string; value: string }>
    label?: string
  },
  'modelValue'
>

function Select(props: SelectProps) {
  const model = useModel(props, 'modelValue')

  return (
    <div class="form-item">
      {props.label && <label>{props.label}</label>}
      <select
        value={model.value}
        onChange={(e) => {
          model.value = e.target.value
        }}
      >
        {props.options.map((opt) => (
          <option key={opt.value} value={opt.value}>
            {opt.label}
          </option>
        ))}
      </select>
    </div>
  )
}

// ========== 注册表单 ==========
function RegisterForm() {
  const username = ref('')
  const password = ref('')
  const agree = ref(false)
  const role = ref('user')

  const handleSubmit = () => {
    if (!username.value || !password.value) {
      alert('请填写用户名和密码')
      return
    }
    if (!agree.value) {
      alert('请同意用户协议')
      return
    }
    console.log('提交:', {
      username: username.value,
      password: password.value,
      agree: agree.value,
      role: role.value
    })
  }

  return (
    <div class="register-form">
      <h2>注册账号</h2>

      <TextInput v-model={username} label="用户名" />
      <TextInput v-model={password} label="密码" type="password" />
      <Select
        v-model={role}
        label="角色"
        options={[
          { label: '普通用户', value: 'user' },
          { label: '管理员', value: 'admin' }
        ]}
      />
      <Switch v-model={agree} label="同意用户协议" />

      <button onClick={handleSubmit}>注册</button>
    </div>
  )
}

下一步