jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Vue.js 3 · Phần 6 — Form & v-model (kể cả component tùy biến)

Làm chủ form trong Vue 3: v-model trên mọi loại input và modifier, defineModel để dựng v-model cho component của bạn, nhiều v-model trên một component, và validate form với zod sạch sẽ.

Form là nơi state hai chiều thật sự cần thiết — và là nơi v-model tỏa sáng. Phần 2 đã giới thiệu v-model trên input gốc; phần này đi xa hơn: dựng v-model cho component tùy biến bằng defineModel, đặt nhiều v-model trên một component, và validate form gọn gàng với zod.


1. v-model ôn nhanh

v-model là ràng buộc hai chiều, tự chọn đúng property/event theo loại input:

<input v-model="name" />                          <!-- value + input -->
<input type="checkbox" v-model="agreed" />        <!-- checked + change -->
<input type="radio" value="a" v-model="choice" />
<select v-model="role"> … </select>

<!-- mảng checkbox: gom các value đã chọn -->
<input type="checkbox" value="vue" v-model="skills" />
<input type="checkbox" value="node" v-model="skills" />

Modifier hay dùng: .trim (cắt khoảng trắng), .number (ép số), .lazy (sync ở change thay vì input).


2. defineModel — v-model cho component của bạn

Bạn muốn <MyInput v-model="email" /> dùng được như input gốc. Trước Vue 3.4 phải tự khai báo prop modelValue + emit update:modelValue. Giờ chỉ cần defineModel:

<!-- MyInput.vue -->
<script setup lang="ts">
const model = defineModel<string>(); // tạo ra prop + emit tự động, dùng như ref
</script>

<template>
  <input :value="model" @input="model = ($event.target as HTMLInputElement).value" />
</template>
<!-- cha -->
<MyInput v-model="email" />

model là một ref hai chiều: đọc nó để lấy giá trị, gán nó để báo cập nhật lên cha. Toàn bộ boilerplate prop/emit biến mất. Thêm tùy chọn:

const model = defineModel<string>({ required: true });
const count = defineModel<number>('count', { default: 0 }); // v-model:count

3. Nhiều v-model trên một component

Một component có thể phơi nhiều ràng buộc hai chiều, mỗi cái một tên:

<!-- NameFields.vue -->
<script setup lang="ts">
const firstName = defineModel<string>('firstName');
const lastName = defineModel<string>('lastName');
</script>

<template>
  <input v-model="firstName" placeholder="Họ" />
  <input v-model="lastName" placeholder="Tên" />
</template>
<NameFields v-model:firstName="form.first" v-model:lastName="form.last" />

Hữu ích cho component composite (khoảng ngày, range slider, địa chỉ).


4. Quản lý state của cả form

Với form nhiều trường, gom vào một object reactive thay vì hàng tá ref rời:

<script setup lang="ts">
import { reactive } from 'vue';

const form = reactive({
  email: '',
  password: '',
  remember: false,
});

function submit() {
  console.log({ ...form });
}
</script>

<template>
  <form @submit.prevent="submit">
    <input v-model.trim="form.email" type="email" />
    <input v-model="form.password" type="password" />
    <label><input type="checkbox" v-model="form.remember" /> Ghi nhớ</label>
    <button>Đăng nhập</button>
  </form>
</template>

@submit.prevent chặn reload mặc định của form — luôn dùng cho form xử lý bằng JS.


5. Validate với zod

Đừng tự viết validate tay rối rắm. zod mô tả schema một lần, vừa validate vừa suy ra kiểu TypeScript:

import { z } from 'zod';

const schema = z.object({
  email: z.string().email('Email không hợp lệ'),
  password: z.string().min(8, 'Tối thiểu 8 ký tự'),
});

type LoginForm = z.infer<typeof schema>; // kiểu tự suy ra từ schema

Gói lại thành composable tái dùng (đúng tinh thần Phần 5):

// composables/useZodForm.ts
import { reactive, ref } from 'vue';
import type { ZodSchema } from 'zod';

export function useZodForm<T extends object>(schema: ZodSchema<T>, initial: T) {
  const form = reactive({ ...initial });
  const errors = ref<Record<string, string>>({});

  function validate(): boolean {
    const result = schema.safeParse(form);
    errors.value = {};
    if (!result.success) {
      for (const issue of result.error.issues) {
        errors.value[issue.path.join('.')] = issue.message;
      }
    }
    return result.success;
  }

  return { form, errors, validate };
}
<script setup lang="ts">
const { form, errors, validate } = useZodForm(schema, { email: '', password: '' });
function submit() {
  if (validate()) login(form);
}
</script>

<template>
  <input v-model.trim="form.email" />
  <span v-if="errors.email" class="err">{{ errors.email }}</span>
</template>

Cho form lớn/phức tạp (field array, validate async, touched/dirty), dùng VeeValidate hoặc FormKit — chúng tích hợp zod và lo phần khó. Nhưng hiểu được cơ chế tay như trên là nền tảng.


6. Bài tập

1. defineModel<string>() thay thế những gì so với cách cũ?

Lời giải

Thay thế việc tự khai báo defineProps({ modelValue }) + defineEmits(['update:modelValue']) + xử lý đọc/ghi. defineModel gói tất cả thành một ref hai chiều.

2. Làm sao có <DateRange v-model:start="a" v-model:end="b" />?

Lời giải

Trong DateRange.vue: const start = defineModel('start'); const end = defineModel('end'); rồi v-model chúng vào hai input.

3. Vì sao nên dùng zod thay vì viết if (!email.includes('@')) tay?

Lời giải

Zod cho một nguồn-sự-thật cho cả validate runtime lẫn kiểu TS (z.infer), thông báo lỗi nhất quán, dễ mở rộng, và tránh logic validate rải rác dễ sai.


Điểm chính

  • v-model + modifier (.trim/.number/.lazy) xử lý mọi input gốc.
  • defineModel dựng v-model cho component tùy biến — bỏ hết boilerplate prop/emit.
  • Đặt nhiều v-model có tên trên một component cho input composite.
  • Gom form vào reactive; luôn @submit.prevent.
  • Validate bằng zod (một schema → validate + kiểu); form phức tạp dùng VeeValidate/FormKit.

Phần tiếp theo

App một trang chưa đủ — ta cần nhiều trang. Phần 7 — Vue Router dạy định tuyến client-side: định nghĩa route, route động và param, navigation programmatic, nested route, lazy-load route để chia bundle, và navigation guard cho bảo vệ trang cần đăng nhập.