Kiến trúc biểu mẫu Vue 3 cho ứng dụng doanh nghiệp: các mẫu tái sử dụng
Kiến trúc biểu mẫu Vue 3 cho ứng dụng doanh nghiệp: component trường tái sử dụng, quy tắc xác thực rõ ràng và cách thực tế để hiện lỗi server ngay trên từng input.

Tại sao mã biểu mẫu dễ hỏng trong các ứng dụng doanh nghiệp thực tế
Một biểu mẫu trong ứng dụng doanh nghiệp hiếm khi giữ mã nhỏ. Nó bắt đầu như "chỉ vài ô nhập", rồi phát triển thành hàng chục trường, phần có điều kiện, quyền truy cập, và các quy tắc phải đồng bộ với logic backend. Sau vài thay đổi sản phẩm, biểu mẫu vẫn chạy được, nhưng mã trở nên mong manh.
Kiến trúc biểu mẫu Vue 3 quan trọng vì biểu mẫu là nơi các "sửa nhanh" chồng chất: thêm một watcher nữa, một trường hợp đặc biệt nữa, một component sao chép nữa. Hôm nay nó vẫn chạy, nhưng sẽ khó mà tin tưởng và khó sửa đổi.
Dấu hiệu cảnh báo quen thuộc: hành vi input lặp trên các trang (nhãn, định dạng, dấu bắt buộc, gợi ý), vị trí hiển thị lỗi không nhất quán, quy tắc xác thực rải rác khắp component, và lỗi backend chỉ được thu gọn thành một toast chung không cho người dùng biết cần sửa gì.
Những bất nhất đó không chỉ là vấn đề phong cách mã. Chúng biến thành vấn đề UX: người dùng gửi lại biểu mẫu, ticket hỗ trợ tăng, và các nhóm tránh động vào biểu mẫu vì sợ phá vỡ một trường hợp góc ẩn.
Một thiết lập tốt làm cho biểu mẫu trở nên nhàm chán theo cách tốt nhất. Với cấu trúc đoán trước được, bạn có thể thêm trường, thay đổi quy tắc và xử lý phản hồi từ server mà không phải đi dây lại mọi thứ.
Bạn cần một hệ thống biểu mẫu cung cấp tính tái sử dụng (một trường có hành vi giống nhau ở mọi nơi), rõ ràng (quy tắc và xử lý lỗi dễ rà soát), hành vi dự đoán được (touched, dirty, reset, submit), và phản hồi tốt hơn (lỗi phía server xuất hiện ngay tại ô cần chú ý). Các mẫu dưới đây tập trung vào component trường tái sử dụng, xác thực dễ đọc, và ánh xạ lỗi server về đúng input.
Một mô hình tư duy đơn giản cho cấu trúc biểu mẫu
Biểu mẫu giữ ổn định theo thời gian là một hệ thống nhỏ với các phần rõ ràng, chứ không phải một đống input.
Hãy nghĩ theo bốn lớp giao tiếp một chiều: UI thu nhập, trạng thái form lưu trữ, xác thực giải thích lỗi, và lớp API tải/lưu.
Bốn lớp (và mỗi lớp chịu trách nhiệm gì)
- Component UI trường: render input, nhãn, gợi ý và văn bản lỗi. Phát ra sự thay đổi giá trị.
- Trạng thái form: giữ giá trị và lỗi (cộng với cờ touched và dirty).
- Quy tắc xác thực: hàm thuần đọc giá trị và trả về thông báo lỗi.
- Gọi API: tải dữ liệu ban đầu, gửi thay đổi và chuyển phản hồi server thành lỗi trường.
Sự tách biệt này giữ thay đổi có phạm vi. Khi có yêu cầu mới, bạn chỉ cập nhật một lớp mà không làm hỏng các lớp khác.
Cái gì thuộc về trường và cái gì thuộc về form cha
Một component trường tái sử dụng nên nhàm chán. Nó không nên biết về API, model dữ liệu hay quy tắc xác thực của bạn. Nó chỉ hiển thị giá trị và lỗi.
Form cha điều phối mọi thứ còn lại: trường nào tồn tại, giá trị nằm ở đâu, khi nào xác thực, và cách gửi.
Một quy tắc đơn giản: nếu logic phụ thuộc vào các trường khác (ví dụ: "State" bắt buộc chỉ khi "Country" là US), hãy giữ nó trong form cha hoặc lớp xác thực, không phải trong component trường.
Khi thêm một trường mới thực sự ít tốn công, bạn thường chỉ chạm tới mặc định hoặc schema, phần markup chèn trường, và quy tắc xác thực của trường. Nếu thêm một input buộc thay đổi khắp những component không liên quan, ranh giới của bạn mơ hồ.
Component trường tái sử dụng: nên chuẩn hóa gì
Khi biểu mẫu lớn lên, cách nhanh nhất để có lợi là ngừng xây mỗi input như một cái làm một lần. Component trường nên cảm giác nhất quán. Đó là thứ giúp chúng dùng nhanh và dễ rà soát.
Một tập các building block thực dụng:
- BaseField: wrapper cho nhãn, gợi ý, văn bản lỗi, khoảng cách và thuộc tính truy cập.
- Component input: TextInput, SelectInput, DateInput, Checkbox, v.v. Mỗi cái tập trung vào control.
- FormSection: nhóm các trường liên quan với tiêu đề, mô tả ngắn và khoảng cách nhất quán.
Với props, giữ một tập nhỏ và áp dụng ở mọi nơi. Đổi tên prop trên 40 form rất mệt.
Những prop thường có lợi ngay:
modelValuevàupdate:modelValuechov-modellabelrequireddisablederror(1 thông điệp, hoặc mảng nếu bạn muốn)hint
Slots là nơi cho phép linh hoạt mà không phá vỡ tính nhất quán. Giữ layout BaseField ổn định, nhưng cho phép biến thể nhỏ như action bên phải ("Gửi mã") hoặc icon nằm trước. Nếu một biến thể xuất hiện hơn một lần, làm nó thành slot thay vì tách component.
Chuẩn hóa thứ tự render (nhãn, control, gợi ý, lỗi). Người dùng quét nhanh hơn, test đơn giản hơn, và ánh xạ lỗi server dễ vì mỗi trường có một vị trí hiển thị rõ ràng.
Trạng thái form: values, touched, dirty và reset
Phần lớn lỗi biểu mẫu trong ứng dụng doanh nghiệp không phải do input. Chúng đến từ trạng thái rải rác: giá trị ở chỗ này, lỗi ở chỗ khác, và nút reset chỉ hoạt động nửa vời. Một kiến trúc biểu mẫu Vue 3 sạch bắt đầu với một hình dạng trạng thái nhất quán.
Đầu tiên, chọn một cách đặt tên cho khóa trường và giữ nó. Quy tắc đơn giản nhất: khóa trường bằng khóa payload API. Nếu server mong first_name, khóa form nên là first_name. Quyết định nhỏ này làm cho xác thực, lưu và ánh xạ lỗi server dễ hơn nhiều.
Giữ trạng thái form ở một chỗ (một composable, store Pinia, hoặc component cha), và cho mỗi trường đọc/ghi qua trạng thái đó. Cấu trúc phẳng phù hợp với hầu hết màn hình. Chỉ dùng nested khi API thực sự lồng.
const state = reactive({
values: { first_name: '', last_name: '', email: '' },
touched: { first_name: false, last_name: false, email: false },
dirty: { first_name: false, last_name: false, email: false },
errors: { first_name: '', last_name: '', email: '' },
defaults: { first_name: '', last_name: '', email: '' }
})
Một cách thực tế suy nghĩ về các cờ:
touched: người dùng đã tương tác với trường này chưa?dirty: giá trị khác với mặc định (hoặc lần lưu gần nhất) không?errors: thông điệp người dùng nên thấy ngay bây giờ?defaults: ta reset về gì?
Hành vi reset nên dự đoán được. Khi bạn tải một bản ghi sẵn có, gán cả values và defaults từ cùng nguồn. Sau đó reset() sao chép defaults về values, xóa touched, xóa dirty, và xóa errors.
Ví dụ: form hồ sơ khách tải email từ server. Nếu người dùng sửa, dirty.email trở thành true. Nếu họ click Reset, email trở lại giá trị đã tải (không phải chuỗi rỗng), và màn hình sạch trở lại.
Quy tắc xác thực dễ đọc
Xác thực dễ đọc không chỉ phụ thuộc thư viện mà là cách bạn diễn đạt quy tắc. Nếu bạn có thể lướt qua một trường và hiểu quy tắc trong vài giây, mã biểu mẫu duy trì tốt hơn.
Chọn kiểu quy tắc bạn có thể gắn bó
Hầu hết đội chọn một trong các cách sau:
- Quy tắc theo trường: quy tắc sống gần nơi dùng trường. Dễ đọc, phù hợp form nhỏ đến trung bình.
- Quy tắc theo schema: quy tắc nằm trong một object hoặc file. Tốt khi nhiều màn hình dùng cùng model.
- Kết hợp: quy tắc đơn giản gần trường, quy tắc phức tạp/chung ở schema trung tâm.
Dù chọn gì, giữ tên quy tắc và thông điệp dễ đoán. Một vài quy tắc phổ biến (required, length, format, range) tốt hơn một danh sách dài helper một-lần.
Viết quy tắc như tiếng Anh đơn giản
Một quy tắc tốt đọc như một câu: "Email bắt buộc và phải có dạng email." Tránh các one-liner thông minh che giấu ý định.
Với hầu hết biểu mẫu doanh nghiệp, trả về một thông báo mỗi trường tại một thời điểm (lỗi đầu tiên) giữ UI bình tĩnh và giúp người dùng sửa nhanh hơn.
Quy tắc phổ biến thân thiện với người dùng:
- Required chỉ khi người dùng thực sự phải điền.
- Length với số thực tế (ví dụ 2 đến 50 ký tự).
- Format cho email, điện thoại, ZIP, tránh regex quá chặt khiến từ chối input hợp lệ.
- Range như "ngày không được ở tương lai" hoặc "số lượng giữa 1 và 999."
Làm cho kiểm tra bất đồng bộ rõ ràng
Xác thực bất đồng bộ (như "username đã có người dùng") gây rối nếu nó chạy im lặng.
Kích hoạt kiểm tra khi blur hoặc sau một khoảng dừng ngắn, hiển thị trạng thái "Đang kiểm tra...", và hủy hoặc bỏ qua các yêu cầu lỗi thời khi người dùng tiếp tục gõ.
Quyết định khi nào chạy xác thực
Thời điểm quan trọng như quy tắc. Một thiết lập thân thiện thường là:
- On change cho các trường hưởng lợi từ phản hồi trực tiếp (như độ mạnh mật khẩu), nhưng giữ nhẹ nhàng.
- On blur cho hầu hết trường, để người dùng gõ mà không bị lỗi liên tục.
- On submit cho toàn bộ form như bước kiểm tra cuối cùng.
Ánh xạ lỗi server về đúng input
Kiểm tra phía client chỉ là một nửa câu chuyện. Trong ứng dụng doanh nghiệp, server từ chối lưu vì các quy tắc browser không biết: trùng lặp, kiểm tra quyền, dữ liệu cũ, thay đổi trạng thái, v.v. UX tốt phụ thuộc vào việc biến phản hồi đó thành thông báo rõ ràng bên cạnh input đúng.
Chuẩn hóa lỗi thành một hình dạng nội bộ
Backend hiếm khi nhất quán về định dạng lỗi. Một số trả về object, số khác trả về danh sách, có nơi trả về map lồng nhau theo tên trường. Chuyển mọi thứ bạn nhận được thành một hình dạng nội bộ duy nhất mà form có thể render.
// what your form code consumes
{
fieldErrors: { "email": ["Already taken"], "address.street": ["Required"] },
formErrors: ["You do not have permission to edit this customer"]
}
Giữ vài quy tắc nhất quán:
- Lưu lỗi trường dưới dạng mảng (dù chỉ có một thông báo).
- Chuyển các kiểu đường dẫn khác nhau thành một kiểu (dot paths hiệu quả:
address.street). - Giữ lỗi không thuộc trường riêng như
formErrors. - Giữ payload thô của server để log, nhưng đừng render trực tiếp.
Ánh xạ đường dẫn server tới khóa trường của bạn
Khó nhất là căn chỉnh ý server về "đường dẫn" với khóa trường của bạn. Quyết định khóa cho mỗi component trường (ví dụ email, profile.phone, contacts.0.type) và dùng nó.
Rồi viết một bộ mapper nhỏ xử lý các dạng thường gặp:
address.street(dot notation)address[0].street(brackets cho mảng)/address/street(JSON Pointer style)
Sau khi chuẩn hóa, <Field name="address.street" /> nên đọc được fieldErrors["address.street"] mà không cần ngoại lệ.
Hỗ trợ alias khi cần. Nếu backend trả customer_email nhưng UI bạn dùng email, giữ một mapping như { customer_email: "email" } trong quá trình chuẩn hóa.
Lỗi trường, lỗi cấp form và focus
Không phải lỗi nào cũng thuộc về một input. Nếu server nói "Giới hạn gói đã đạt" hoặc "Cần thanh toán", hiển thị phía trên form như lỗi cấp form.
Với lỗi thuộc trường, hiển thị thông báo bên cạnh input và dẫn người dùng tới vấn đề đầu tiên:
- Sau khi gán lỗi server, tìm khóa đầu tiên trong
fieldErrorsxuất hiện trong form đã render. - Cuộn tới nó và focus (dùng ref cho mỗi trường và
nextTick). - Xóa lỗi server của trường khi người dùng chỉnh sửa trường đó lần nữa.
Bước theo bước: ghép kiến trúc lại với nhau
Biểu mẫu giữ bình tĩnh khi bạn quyết định sớm cái gì thuộc trạng thái form, UI, xác thực và API, rồi kết nối chúng bằng vài hàm nhỏ.
Một chuỗi làm việc cho hầu hết ứng dụng doanh nghiệp:
- Bắt đầu với một model form và khóa trường ổn định. Những khóa đó trở thành hợp đồng giữa component, validator và lỗi server.
- Tạo một BaseField duy nhất cho nhãn, text trợ giúp, dấu bắt buộc và hiển thị lỗi. Giữ component input nhỏ và nhất quán.
- Thêm lớp xác thực có thể chạy theo trường và có thể kiểm tra toàn bộ khi submit.
- Gửi lên API. Nếu thất bại, chuyển lỗi server thành
{ [fieldKey]: message }để input đúng hiện thông báo đúng. - Tách xử lý thành công (reset, toast, điều hướng) ra ngoài để không làm lẫn vào component và validator.
Một điểm khởi đầu trạng thái đơn giản:
const values = reactive({ email: '', name: '', phone: '' })
const touched = reactive({ email: false, name: false, phone: false })
const errors = reactive({}) // { email: '...', name: '...' }
BaseField của bạn nhận label, error, và có thể touched, rồi render thông điệp ở một chỗ. Mỗi component input chỉ lo-binding và phát sự kiện cập nhật.
Với xác thực, giữ quy tắc gần model dùng cùng các khóa:
const rules = {
email: v => (!v ? 'Email là bắt buộc' : /@/.test(v) ? '' : 'Nhập email hợp lệ'),
name: v => (v.length < 2 ? 'Tên quá ngắn' : ''),
}
function validateAll() {
Object.keys(rules).forEach(k => {
const msg = rules[k](values[k])
if (msg) errors[k] = msg
else delete errors[k]
touched[k] = true
})
return Object.keys(errors).length === 0
}
Khi server trả lỗi, ánh xạ chúng theo cùng khóa. Nếu API trả { "field": "email", "message": "Already taken" }, gán errors.email = 'Already taken' và đánh dấu touched cho trường đó. Nếu lỗi là toàn cục (ví dụ "permission denied"), hiển thị nó phía trên form.
Ví dụ tình huống: chỉnh sửa hồ sơ khách hàng
Hình dung màn hình admin nội bộ nơi nhân viên hỗ trợ chỉnh hồ sơ khách hàng. Biểu mẫu có bốn trường: name, email, phone, và role (Customer, Manager, Admin). Nó nhỏ nhưng thể hiện các vấn đề phổ biến.
Quy tắc client nên rõ ràng:
- Name: bắt buộc, độ dài tối thiểu.
- Email: bắt buộc, có định dạng email.
- Phone: không bắt buộc, nhưng nếu điền phải đúng định dạng chấp nhận được.
- Role: bắt buộc, và đôi khi có điều kiện (chỉ người có quyền mới gán Admin).
Một hợp đồng component nhất quán giúp: mỗi trường nhận giá trị hiện tại, văn bản lỗi (nếu có), và vài boolean như touched và disabled. Nhãn, dấu bắt buộc, khoảng cách và kiểu lỗi không nên bị phát minh lại trên mỗi màn hình.
Bây giờ là luồng UX. Nhân viên sửa email, nhấn tab, và thấy thông báo dưới Email nếu định dạng sai. Họ sửa, nhấn Save, và server phản hồi:
- email đã tồn tại: hiển thị dưới Email và focus trường đó.
- phone không hợp lệ: hiển thị dưới Phone.
- permission denied: hiển thị một thông báo cấp form ở đầu.
Nếu bạn giữ lỗi theo khóa trường (email, phone, role), ánh xạ đơn giản. Lỗi trường nằm cạnh input, lỗi cấp form nằm ở khu vực thông điệp riêng.
Sai lầm thường gặp và cách tránh
Giữ logic ở một chỗ
Sao chép quy tắc xác thực vào mọi màn hình trông nhanh lúc đầu cho tới khi chính sách thay đổi (quy tắc mật khẩu, ID thuế bắt buộc, domain được phép). Giữ quy tắc tập trung (schema, file rules, hàm chia sẻ), và form tiêu thụ cùng một bộ quy tắc.
Cũng tránh để input cấp thấp làm quá nhiều. Nếu <TextField> biết gọi API, retry khi lỗi và phân tích payload lỗi server, nó sẽ mất tính tái sử dụng. Component trường chỉ nên render, phát sự kiện thay đổi và hiển thị lỗi. Đặt cuộc gọi API và logic ánh xạ trong container form hoặc composable.
Triệu chứng bạn đang trộn lẫn concerns:
- Cùng thông điệp xác thực xuất hiện ở nhiều nơi.
- Component trường import client API.
- Thay đổi một endpoint làm hỏng nhiều form không liên quan.
- Test phải mount nửa app chỉ để kiểm tra một input.
Bẫy UX và truy cập
Một banner lỗi duy nhất như "Có lỗi xảy ra" là không đủ. Người dùng cần biết trường nào sai và làm gì tiếp theo. Dùng banner cho lỗi toàn cục (mất mạng, không có quyền), và ánh xạ lỗi server tới input cụ thể để người dùng sửa nhanh.
Vấn đề tải và gửi double tạo trạng thái khó hiểu. Khi gửi, disable nút submit, disable các trường không nên thay đổi khi lưu, và hiển thị trạng thái bận rõ ràng. Đảm bảo reset và cancel phục hồi form sạch.
Các cơ bản về truy cập thường bị bỏ qua với component tuỳ chỉnh. Một vài chọn lựa tránh đau đớn thực sự:
- Mỗi input có nhãn hiển thị (không chỉ placeholder).
- Lỗi liên kết với trường bằng aria attributes đúng.
- Focus di chuyển tới trường đầu tiên không hợp lệ sau submit.
- Trường disabled thực sự không tương tác và được thông báo đúng.
- Điều hướng bằng bàn phím hoạt động trơn tru.
Checklist nhanh và bước tiếp theo
Trước khi đưa biểu mẫu lên, chạy một checklist nhanh. Nó bắt lỗi nhỏ mà sau này thành ticket hỗ trợ.
- Mỗi trường có khóa ổn định khớp payload và phản hồi server (bao gồm đường dẫn lồng như
billing.address.zip)? - Bạn có thể render bất kỳ trường nào với một API component nhất quán (value vào, events ra, error và hint vào)?
- Khi submit, bạn xác thực một lần, chặn gửi đôi, và focus vào trường không hợp lệ đầu tiên để người dùng biết bắt đầu từ đâu?
- Bạn có hiển thị lỗi ở đúng chỗ: theo trường (bên cạnh input) và cấp form (thông báo chung khi cần)?
- Sau thành công, bạn reset trạng thái đúng (values, touched, dirty) để lần chỉnh sửa tiếp theo bắt đầu sạch?
Nếu một câu trả lời là "không", sửa điểm đó trước. Đau nhức biểu mẫu phổ biến nhất là sự không khớp: tên trường trôi dạt khỏi API, hoặc lỗi server trả về dạng mà UI bạn không thể đặt được.
Nếu bạn xây công cụ nội bộ và muốn nhanh hơn, AppMaster (appmaster.io) theo cùng các nguyên tắc: giữ UI trường nhất quán, tập trung quy tắc và luồng, và làm cho phản hồi server hiện ở nơi người dùng có thể hành động.
Câu hỏi thường gặp
Chuẩn hóa khi bạn bắt đầu thấy cùng nhãn, gợi ý, dấu bắt buộc, khoảng cách và kiểu hiện lỗi lặp lại trên nhiều trang. Nếu một thay đổi “nhỏ” buộc bạn phải sửa nhiều file, một BaseField chung và vài component input nhất quán sẽ nhanh chóng tiết kiệm công sức.
Giữ component trường thật đơn giản: nó render nhãn, control, gợi ý và lỗi, và phát ra sự thay đổi giá trị. Logic phụ thuộc chéo giữa các trường, quy tắc có điều kiện và bất cứ thứ gì cần biết các giá trị khác nên nằm ở form cha hoặc lớp xác thực để trường có thể tái sử dụng.
Dùng các khóa ổn định khớp với payload API theo mặc định, như first_name hoặc billing.address.zip. Điều này làm cho việc xác thực và ánh xạ lỗi từ server đơn giản vì bạn không phải dịch tên giữa các lớp.
Một cấu trúc mặc định đơn giản là một object trạng thái chứa values, errors, touched, dirty và defaults. Khi mọi thứ đọc và ghi qua cùng một hình dạng, hành vi reset và submit trở nên dự đoán được và bạn tránh được lỗi “reset một nửa”.
Khi tải dữ liệu, gán cả values và defaults từ cùng nguồn. Sau đó reset() sao chép defaults trở lại values và xóa touched, dirty, errors để giao diện sạch và khớp với giá trị server trả về lần cuối.
Bắt đầu với các hàm đơn giản theo khóa trường giống như trong trạng thái form. Trả về một thông báo rõ ràng cho mỗi trường (lỗi đầu tiên) để UI bớt rối và người dùng biết sửa gì trước.
Xác thực hầu hết trường khi blur, rồi xác thực toàn bộ khi submit như bước kiểm tra cuối cùng. Dùng xác thực on-change chỉ ở những nơi thực sự hữu ích (ví dụ: độ mạnh mật khẩu) để tránh làm phiền người dùng khi họ đang gõ.
Chạy kiểm tra bất đồng bộ khi blur hoặc sau một độ trễ ngắn, và hiển thị trạng thái “đang kiểm tra”. Hủy hoặc bỏ qua các yêu cầu cũ để kết quả chậm không ghi đè lên input mới và gây lỗi lẫn lộn.
Chuẩn hóa mọi định dạng backend thành một hình dạng nội bộ như { fieldErrors: { key: [messages] }, formErrors: [messages] }. Dùng một kiểu đường dẫn (dot notation thường phù hợp) để trường address.street luôn có thể đọc fieldErrors['address.street'] mà không cần ngoại lệ.
Hiển thị lỗi cấp form phía trên biểu mẫu, còn lỗi trường thì bên cạnh input tương ứng. Sau submit thất bại, focus trường đầu tiên có lỗi và xóa lỗi server của trường đó ngay khi người dùng chỉnh sửa lại.


