Vue 3 Patterns: 5 Best Practice Nâng Cao Cho Developer

Khi xây dựng các ứng dụng web quy mô lớn với Vue.js, việc quản lý mã nguồn sao cho sạch sẽ, dễ bảo trì và dễ mở rộng luôn là một thách thức lớn đối với ...

Khi xây dựng các ứng dụng web quy mô lớn với Vue.js, việc quản lý mã nguồn sao cho sạch sẽ, dễ bảo trì và dễ mở rộng luôn là một thách thức lớn đối với các nhà phát triển.

Sự ra đời của Composition API trong phiên bản Vue 3 đã mang lại sự linh hoạt vượt trội, nhưng nó cũng đi kèm với nguy cơ tạo ra những đoạn code hỗn độn nếu không áp dụng đúng phương pháp. Việc áp dụng các vue 3 patterns chuẩn mực giúp giải quyết triệt để vấn đề này bằng cách thiết lập một hệ khung vue 3 patterns và kiến trúc rõ ràng và khoa học cho dự án của bạn.

Thực tế thì, việc viết mã nguồn chạy được là rất dễ dàng, nhưng viết mã nguồn có thể phát triển lâu dài lại là câu chuyện hoàn toàn khác. Khi dự án phình to, các component chồng chéo logic và state trở nên khó kiểm soát. Đó là lý do tại sao chúng ta cần hiểu rõ các vue 3 patterns thông dụng để tổ chức source code một cách thông minh nhất. Trong bài hướng dẫn chuyên sâu này, chúng ta sẽ phân tích 5 vue 3 patterns nâng cao giúp nâng tầm kỹ năng lập trình Vue 3 của bạn.

Vue 3 Patterns Trong Kiến trúc Component: Phân tách Container và Presentational Component

Một trong những vue 3 patterns cơ bản nhưng cực kỳ mạnh mẽ là phân tách component thành hai loại: Container Component (Smart) và Presentational Component (Dumb). Mô hình này giúp chia sẻ trách nhiệm rõ ràng, tăng khả năng tái sử dụng component và giúp cho việc kiểm thử (unit testing) trở nên đơn giản hơn rất nhiều.

Khi áp dụng các Vue 3 Patterns, presentational Component chỉ chịu trách nhiệm hiển thị giao diện và nhận dữ liệu thông qua props, sau đó phản hồi lại các tương tác của người dùng thông qua events. Nó hoàn toàn không biết về nguồn gốc của dữ liệu, không tương tác trực tiếp với API hoặc global store (Pinia). Ngược lại, Container Component chịu trách nhiệm quản lý state, thực hiện các tác vụ call API, giao tiếp với store và truyền dữ liệu xuống các Presentational Component con.

Hãy cùng xem xét một ví dụ minh họa cụ thể về cách triển khai vue 3 patterns này cho tính năng hiển thị danh sách sản phẩm. Đầu tiên là một Presentational Component có tên ProductList.vue:

<script setup lang="ts">
interface Product {
  id: string;
  name: string;
  price: number;
}

defineProps<{
  items: Product[];
  loading: boolean;
}>();

const emit = defineEmits<{
  select: [id: string];
}>();
</script>

<template>
  <div class="product-list">
    <div v-if="loading">Đang tải danh sách...</div>
    <div v-else class="grid">
      <div 
        v-for="product in items" 
        :key="product.id" 
        class="card"
        @click="emit('select', product.id)"
      >
        <h4>{{ product.name }}</h4>
        <p>{{ product.price }} USD</p>
      </div>
    </div>
  </div>
</template>

Khi áp dụng các Vue 3 Patterns, và dưới đây là Container Component tương ứng ProductDashboard.vue chịu trách nhiệm quản lý logic và gọi API thực tế:

<script setup lang="ts">
import { ref, onMounted } from "vue";
import ProductList from "./ProductList.vue";

const products = ref([]);
const isLoading = ref(false);

async function fetchProducts() {
  isLoading.value = true;
  try {
    const response = await fetch("/api/products");
    products.value = await response.json();
  } catch (error) {
    console.error("Lỗi khi fetch sản phẩm:", error);
  } finally {
    isLoading.value = false;
  }
}

function handleProductSelect(id: string) {
  console.log("Đã chọn sản phẩm có ID:", id);
}

onMounted(fetchProducts);
</script>

<template>
  <div class="dashboard">
    <h2>Bảng điều khiển sản phẩm</h2>
    <ProductList 
      :items="products" 
      :loading="isLoading" 
      @select="handleProductSelect"
    />
  </div>
</template>

Sự phân chia rõ ràng này giúp component ProductList trở nên cực kỳ linh hoạt. Bạn có thể tái sử dụng nó ở bất kỳ màn hình nào, chỉ cần truyền vào một mảng dữ liệu sản phẩm tương thích. Đây chính là cách triển khai vue 3 patterns mã nguồn bền vững được ưa chuộng hàng đầu trong cộng đồng Vue.js.

Đặc tínhContainer Component (Smart)Presentational Component (Dumb)
Nhiệm vụ chínhQuản lý logic, lấy dữ liệu, giao tiếp storeHiển thị giao diện, render UI từ props
Sở hữu stateCó (chứa local state hoặc kết nối Pinia)Không (chỉ dùng dữ liệu từ props)
Tác vụ phụ (Side Effects)Có (call API, router navigation, dispatch action)Không (chỉ gửi tín hiệu ngược lại qua emit)
Khả năng tái sử dụngThấp (gắn liền với logic của một tính năng cụ thể)Rất cao (hoàn toàn độc lập với business logic)

Tối ưu hóa Props và Ràng buộc hai chiều với defineModel

Trong các phiên bản trước của Vue 3, việc xử lý liên kết dữ liệu hai chiều (two-way data binding) giữa component cha và con thường yêu cầu định nghĩa props modelValue và emit sự kiện update:modelValue. Cách làm này tuy hoạt động tốt nhưng lại đòi hỏi khá nhiều boilerplate code. Rất may mắn, kể từ phiên bản Vue 3.4, macro defineModel ra đời đã mang lại cuộc cách mạng cho phần liên kết dữ liệu trong các vue 3 patterns.

Khi áp dụng các Vue 3 Patterns, sử dụng macro defineModel giúp chúng ta khai báo trực tiếp một model liên kết hai chiều mà không cần quản lý thủ công các sự kiện emit. Hãy xem xét ví dụ cụ thể về một CustomInput.vue sử dụng macro này để tạo ra một ô nhập liệu có thể liên kết dữ liệu hai chiều đơn giản:

<script setup lang="ts">
// Khai báo model mặc định liên kết trực tiếp với v-model của cha
const model = defineModel<string>({ default: "" });
</script>

<template>
  <input 
    v-model="model" 
    class="custom-input" 
    placeholder="Nhập nội dung..."
  />
</template>

Lúc này, ở component theo định hướng Vue 3 Patterns cha, chúng ta chỉ cần gọi CustomInput và truyền v-model bình thường giống như bất kỳ thẻ input HTML chuẩn nào:

<script setup lang="ts">
import { ref } from "vue";
import CustomInput from "./CustomInput.vue";

const username = ref("");
</script>

<template>
  <div class="form-group">
    <label>Tên người dùng:</label>
    <CustomInput v-model="username" />
    <p>Giá trị hiện tại: {{ username }}</p>
  </div>
</template>

Nếu bạn muốn sử dụng nhiều v-model trên cùng một component con, vue 3 patterns với defineModel cũng hỗ trợ khai báo tên định danh cụ thể vô cùng dễ dàng. Ví dụ dưới đây cho thấy cách định nghĩa hai model độc lập trên cùng một component FormUser.vue:

<script setup lang="ts">
const firstName = defineModel<string>("firstName", { default: "" });
const lastName = defineModel<string>("lastName", { default: "" });
</script>

<template>
  <div class="name-fields">
    <input v-model="firstName" placeholder="Họ" />
    <input v-model="lastName" placeholder="Tên" />
  </div>
</template>

Sau đó ở component cha, chúng ta liên kết các model cụ thể đó bằng cú pháp trực quan:

<FormUser 
  v-model:first-name="userFirstName" 
  v-model:last-name="userLastName" 
/>

Việc loại bỏ các boilerplate code dư thừa giúp mã nguồn trở nên sáng sủa hơn và hạn chế tối đa các lỗi cú pháp phát sinh trong quá trình truyền dữ liệu ngược từ con lên cha. Đây là một nâng cấp rất đáng giá mà mọi lập trình viên ứng dụng Vue với các Vue 3 Patterns chuẩn 3 nên tích hợp vào bộ công cụ phát triển phần mềm của mình.

Thiết kế Custom Composables và Quản lý Vòng đời Reactivity

Một trong những đặc điểm nổi bật nhất của Composition API là khả năng đóng gói và tái sử dụng logic thông qua các Custom Composables. Trong các vue 3 patterns nâng cao, custom composable đóng vai trò thay thế hoàn toàn cho Mixins của Vue 2, khắc phục triệt để các nhược điểm như xung đột tên biến và luồng dữ liệu mập mờ.

Quy tắc vàng khi xây dựng một composable là hàm của bạn phải luôn bắt đầu bằng tiền tố “use” và luôn trả về các reactive states (như ref, computed hoặc readonly reactive objects) để component theo định hướng Vue 3 Patterns sử dụng có thể theo dõi sự thay đổi của dữ liệu. Hãy cùng xây dựng một composable phức tạp giúp theo dõi kích thước cửa sổ trình duyệt và cập nhật trạng thái khi người dùng thay đổi kích thước cửa sổ:

import { ref, onMounted, onUnmounted, readonly } from "vue";

export function useWindowSize() {
  const width = ref(window.innerWidth);
  const height = ref(window.innerHeight);

  function handleResize() {
    width.value = window.innerWidth;
    height.value = window.innerHeight;
  }

  onMounted(() => {
    window.addEventListener("resize", handleResize);
  });

  onUnmounted(() => {
    window.removeEventListener("resize", handleResize);
  });

  return {
    width: readonly(width),
    height: readonly(height)
  };
}

Điểm quan trọng cần chú ý trong đoạn code trên là việc sử dụng hook onUnmounted để gỡ bỏ event listener. Nếu quên bước dọn dẹp (cleanup) này, ứng dụng của bạn sẽ bị rò rỉ bộ nhớ (memory leaks) khi component theo định hướng Vue 3 Patterns sử dụng composable này bị hủy. Việc dọn dẹp các side effects là một yêu cầu bắt buộc khi thiết kế các custom composable chuyên nghiệp.

Để tăng tính linh hoạt, custom composable cũng nên được thiết kế để chấp nhận các đối số đầu vào có thể là giá trị tĩnh hoặc ref reactive. Chúng ta có thể sử dụng hàm toValue() (được giới thiệu từ ứng dụng Vue với các Vue 3 Patterns chuẩn 3.3) để trích xuất giá trị thực tế của đối số đầu vào một cách an toàn. Hãy xem ví dụ về composable useFetch.ts chấp nhận một URL động:

import { ref, watchEffect, toValue, type MaybeRefOrGetter } from "vue";

export function useFetch<T>(url: MaybeRefOrGetter<string>) {
  const data = ref<T | null>(null);
  const error = ref<Error | null>(null);
  const loading = ref(false);

  watchEffect(async () => {
    loading.value = true;
    data.value = null;
    error.value = null;
    
    // toValue tự động trích xuất giá trị từ ref, getter function hoặc string thường
    const currentUrl = toValue(url);
    
    try {
      const res = await fetch(currentUrl);
      data.value = await res.json();
    } catch (err) {
      error.value = err as Error;
    } finally {
      loading.value = false;
    }
  });

  return { data, error, loading };
}

Bằng cách sử dụng toValue và watchEffect, composable useFetch sẽ tự động thực hiện lại yêu cầu gọi API mỗi khi ref truyền vào từ component thay đổi giá trị. Đây chính là cách triển khai luồng reactivity động và mượt mà mà các vue 3 patterns khuyến nghị.

State Management Hiện Đại Với Pinia Setup Store

Khi ứng dụng Vue của bạn phát triển lớn hơn, việc quản lý các state toàn cục (global states) trở thành điều bắt buộc. Pinia hiện là thư viện quản lý state chính thức được khuyên dùng thay cho Vuex. Trong các vue 3 patterns hiện đại, Pinia cung cấp hai cách viết store: Option Store (kiểu cũ, giống Vuex) và Setup Store (kiểu mới, giống Composition API).

Khi áp dụng các Vue 3 Patterns, lựa chọn Setup Store mang lại nhiều ưu điểm lớn về khả năng tối ưu hóa TypeScript, cú pháp ngắn gọn và khả năng sử dụng các composable khác trực tiếp bên trong store. Trong Setup Store, ref() đóng vai trò là state, computed() đóng vai trò là getters, và function() đóng vai trò là actions. Hãy cùng xây dựng một store quản lý giỏ hàng chi tiết dưới đây:

import { ref, computed } from "vue";
import { defineStore } from "pinia";

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

export const useCartStore = defineStore("cart", () => {
  // State
  const items = ref<CartItem[]>([]);
  const discountCode = ref<string>("");

  // Getters
  const totalItems = computed(() => 
    items.value.reduce((sum, item) => sum + item.quantity, 0)
  );

  const subTotal = computed(() => 
    items.value.reduce((sum, item) => sum + (item.price * item.quantity), 0)
  );

  const finalTotal = computed(() => {
    if (discountCode.value === "DISCOUNT10") {
      return subTotal.value * 0.9;
    }
    return subTotal.value;
  });

  // Actions
  function addToCart(product: Omit<CartItem, "quantity">) {
    const existing = items.value.find(item => item.id === product.id);
    if (existing) {
      existing.quantity++;
    } else {
      items.value.push({ ...product, quantity: 1 });
    }
  }

  function removeFromCart(productId: string) {
    items.value = items.value.filter(item => item.id !== productId);
  }

  function applyDiscount(code: string) {
    discountCode.value = code;
  }

  return {
    items,
    discountCode,
    totalItems,
    subTotal,
    finalTotal,
    addToCart,
    removeFromCart,
    applyDiscount
  };
});

Khi sử dụng Setup Store trong các component ứng dụng Vue với các Vue 3 Patterns chuẩn, một điểm cực kỳ lưu ý là bạn không nên phân rã (destructure) trực tiếp store vì điều này sẽ làm mất tính reactive của các states. Nếu muốn phân rã, bạn phải sử dụng hàm storeToRefs hỗ trợ của Pinia:

<script setup lang="ts">
import { storeToRefs } from "pinia";
import { useCartStore } from "./stores/useCartStore";

const cartStore = useCartStore();
// Phân rã state và getters giữ nguyên tính reactive
const { items, finalTotal, totalItems } = storeToRefs(cartStore);
// Các hàm action thì có thể phân rã trực tiếp bình thường
const { addToCart, removeFromCart } = cartStore;
</script>

Sự tích hợp mượt mà giữa Pinia Setup Store và Composition API giúp luồng dữ liệu của toàn bộ ứng dụng trở nên thống nhất và cực kỳ dễ hiểu đối với bất kỳ nhà phát triển nào đã quen thuộc với các vue 3 patterns.

Nuxt SSR Patterns và Giải pháp Tránh Hydration Mismatch

Khi xây dựng ứng dụng ứng dụng Vue với các Vue 3 Patterns chuẩn kết hợp Server-Side Rendering (SSR) bằng framework Nuxt, chúng ta thường gặp phải lỗi Hydration Mismatch. Lỗi này xảy ra khi cấu trúc HTML được kết xuất ở phía máy chủ (Server) không khớp chính xác với cấu trúc HTML mà trình duyệt (Client) tạo ra trong quá trình khởi tạo ứng dụng.

Lỗi Hydration Mismatch thường xuất hiện khi chúng ta render các thành phần phụ thuộc vào môi trường Client, ví dụ như ngày giờ hiện tại, kích thước màn hình thiết bị, thông tin lưu trữ trong LocalStorage hoặc các con số ngẫu nhiên. Trong các vue 3 patterns dành cho SSR, có một số phương pháp rất hiệu quả để giải quyết triệt để lỗi này.

Phương pháp đầu tiên và phổ biến nhất là sử dụng component theo định hướng Vue 3 Patterns đặc biệt ClientOnly của Nuxt. Bất kỳ đoạn mã nguồn nào nằm bên trong thẻ ClientOnly sẽ được bỏ qua trong giai đoạn render ở phía Server và chỉ được dựng lại hoàn toàn ở phía trình duyệt:

<template>
  <div class="page">
    <h1>Thông tin đặt hàng</h1>
    <!-- Phần này chỉ render ở Client để tránh Hydration Mismatch -->
    <ClientOnly>
      <p>Thời gian thanh toán: {{ new Date().toLocaleString() }}</p>
      <template #fallback>
        <p>Đang tải thời gian...</p>
      </template>
    </ClientOnly>
  </div>
</template>

Khi áp dụng các Vue 3 Patterns, phương pháp thứ hai là kiểm tra trạng thái môi trường thông qua cờ kiểm tra import.meta.client của Vite hoặc thuộc tính computed động. Chúng ta chỉ cho phép hiển thị giá trị nhạy cảm sau khi ứng dụng đã mount hoàn tất lên client:

<script setup lang="ts">
import { ref, onMounted } from "vue";

const isMounted = ref(false);
const token = ref("");

onMounted(() => {
  isMounted.value = true;
  // Lấy dữ liệu từ localStorage an toàn ở Client
  token.value = localStorage.getItem("user_token") || "";
});
</script>

<template>
  <div class="profile">
    <div v-if="isMounted">
      <p>Token của bạn: {{ token }}</p>
    </div>
    <div v-else>
      <p>Đang kiểm tra thông tin đăng nhập...</p>
    </div>
  </div>
</template>

Việc nắm vững cơ chế Hydration và cách xử lý xung đột rendering giúp xây dựng các ứng dụng web tối ưu hóa cho công cụ tìm kiếm (SEO) cực tốt mà vẫn đảm bảo trải nghiệm người dùng mượt mà không có bất kỳ thông báo lỗi đỏ nào trong console trình duyệt. Đây chính là mảnh ghép cuối cùng hoàn thiện bộ kỹ năng áp dụng các vue 3 patterns của bạn.

Tổng kết và Lời khuyên Xây dựng Kiến trúc Vue 3 Bền vững

Việc áp dụng các vue 3 patterns chuẩn mực không chỉ giúp mã nguồn của bạn trở nên gọn gàng, có tính tổ chức cao mà còn trực tiếp cải thiện tốc độ và hiệu suất chạy của toàn bộ ứng dụng. Hãy bắt đầu cải tổ dự án bằng cách phân chia lại các component theo mô hình Container vs Presentational, tối giản hóa code liên kết với defineModel và đóng gói logic lặp lại vào các Custom Composables an toàn.

Thực tế cho thấy, việc xây dựng và duy trì một vue 3 patterns và kiến trúc phần mềm tốt đòi hỏi sự kiên trì và tính kỷ luật cao của cả đội ngũ phát triển. Tuy nhiên, quả ngọt thu về là một hệ thống mã nguồn linh hoạt, dễ dàng mở rộng thêm tính năng mới mà không lo sợ phá vỡ các logic cũ. Hy vọng những chia sẻ kỹ thuật chi tiết trên sẽ là hành trang hữu ích cho các dự án phát triển phần mềm tiếp theo của bạn.

Để tìm hiểu thêm các tiêu chuẩn lập trình nâng cao, bạn có thể tham khảo thêm tài liệu chính thức từ trang chủ của ứng dụng Vue với các Vue 3 Patterns chuẩn.js và Nuxt framework. Chúc các bạn thành công trên con đường trở thành một Vue Developer xuất sắc!