Tìm hiểu hook useLifecycle trong Vue
Trong Vue, các lifecycle hooks như onMounted và onUnmounted là những công cụ rất quen thuộc. Chúng giúp ta chạy một đoạn logic tại đúng thời điểm trong vòng đời của component: khi component đã được gắn vào DOM, hoặc khi component chuẩn bị bị gỡ khỏi DOM.
Nhưng khi dự án lớn dần, việc gọi trực tiếp onMounted và onUnmounted ở nhiều nơi đôi khi làm code bị phân tán. Có những composable cần biết component đã mount chưa. Có những logic cần cleanup khi unmount. Có những trường hợp ta muốn gom cách xử lý lifecycle vào một abstraction nhỏ để dùng lại nhiều lần.
Đó là lý do hook useLifecycle trở nên hữu ích.
import {
readonly,
ref,
onMounted as vueOnMounted,
onUnmounted as vueOnUnmounted,
} from 'vue';
export function useLifecycle() {
const mounted = ref(false);
const unmounted = ref(false);
function onMounted(callback?: () => void): void {
vueOnMounted(() => {
mounted.value = true;
callback?.();
});
}
function onUnmounted(callback?: () => void): void {
vueOnUnmounted(() => {
unmounted.value = true;
callback?.();
});
}
return {
mounted: readonly(mounted),
unmounted: readonly(unmounted),
onMounted,
onUnmounted,
};
}Nhìn qua, hook này khá ngắn. Nhưng nó giải quyết một nhu cầu rất cụ thể: vừa bọc lại lifecycle hooks của Vue, vừa expose trạng thái vòng đời dưới dạng reactive state.
useLifecycle làm gì?
useLifecycle trả về bốn giá trị:
mounted: cho biết component đã chạy qua lifecyclemountedhay chưa.unmounted: cho biết component đã chạy qua lifecycleunmountedhay chưa.onMounted: hàm đăng ký callback chạy khi component mounted.onUnmounted: hàm đăng ký callback chạy khi component unmounted.
Điểm đáng chú ý là mounted và unmounted không phải boolean thông thường. Chúng là readonly ref, nghĩa là bên ngoài chỉ có thể đọc, không thể tự ý sửa.
Điều này giúp hook giữ quyền kiểm soát trạng thái nội bộ. Component hoặc composable sử dụng hook có thể biết component đã mount hay unmount, nhưng không thể làm sai lệch trạng thái bằng cách gán lại:
const { mounted } = useLifecycle();
// Chỉ nên đọc
console.log(mounted.value);
// Không nên và cũng không được phép sửa nếu dùng TypeScript đúng cách
mounted.value = true;Trong thiết kế composable, đây là một chi tiết nhỏ nhưng quan trọng. State nội bộ nên được bảo vệ nếu bên ngoài không có lý do chính đáng để thay đổi nó.
Vì sao phải alias onMounted và onUnmounted?
Ở phần import, code không import trực tiếp như bình thường:
import { onMounted, onUnmounted } from 'vue';Thay vào đó, nó dùng alias:
import {
onMounted as vueOnMounted,
onUnmounted as vueOnUnmounted,
} from 'vue';Lý do là bên trong hook cũng định nghĩa hai function tên onMounted và onUnmounted.
function onMounted(callback?: () => void): void {
vueOnMounted(() => {
mounted.value = true;
callback?.();
});
}Nếu không alias, tên import từ Vue sẽ bị trùng với tên function nội bộ. Cách đặt vueOnMounted và vueOnUnmounted giúp code rõ nghĩa hơn: đây là lifecycle gốc của Vue, còn onMounted trong return là phiên bản đã được bọc thêm logic riêng.
mounted và unmounted hoạt động như thế nào?
Ban đầu, cả hai state đều là false:
const mounted = ref(false);
const unmounted = ref(false);Khi component mounted, hook cập nhật:
mounted.value = true;Khi component unmounted, hook cập nhật:
unmounted.value = true;Nhờ dùng ref, những giá trị này vẫn reactive. Nếu có computed, watcher hoặc template nào phụ thuộc vào mounted, chúng có thể phản ứng khi giá trị thay đổi.
Ví dụ:
import { computed } from 'vue';
import { useLifecycle } from '~/hooks/useLifecycle';
export function useReadyState() {
const { mounted, unmounted, onMounted, onUnmounted } = useLifecycle();
const ready = computed(() => mounted.value && !unmounted.value);
onMounted(() => {
console.log('Component is ready');
});
onUnmounted(() => {
console.log('Component is no longer active');
});
return {
ready,
};
}Ở đây, ready không cần tự quản lý thêm state riêng. Nó được suy ra từ lifecycle state đã có.
Callback optional giúp hook linh hoạt hơn
Cả hai function đều nhận callback không bắt buộc:
function onMounted(callback?: () => void): voidVì callback là optional, ta có thể gọi onMounted() chỉ để đánh dấu mounted.value = true, hoặc truyền callback khi cần chạy thêm logic.
Dòng này là điểm làm code gọn:
callback?.();Optional chaining đảm bảo callback chỉ được gọi nếu nó thật sự tồn tại. Nhờ vậy, hook không cần viết thêm điều kiện:
if (callback) {
callback();
}Với một hook nhỏ, sự gọn gàng này giúp code dễ đọc hơn mà vẫn rõ ràng.
Ví dụ thực tế
Một nơi rất hợp lý để dùng useLifecycle là các composable cần truy cập API của trình duyệt. Ví dụ hook theo dõi kích thước cửa sổ:
import { readonly, ref } from 'vue';
import { useLifecycle } from '~/hooks/useLifecycle';
interface WindowDimensions {
width: number;
height: number;
}
export function useWindowDimensions() {
const dimensions = ref<WindowDimensions>({
width: 0,
height: 0,
});
const { onMounted, onUnmounted } = useLifecycle();
function update(): void {
dimensions.value = {
width: window.innerWidth,
height: window.innerHeight,
};
}
onMounted(() => {
update();
window.addEventListener('resize', update);
});
onUnmounted(() => {
window.removeEventListener('resize', update);
});
return readonly(dimensions);
}Composable này có hai việc quan trọng:
- Khi component mounted, lấy kích thước hiện tại của cửa sổ và đăng ký event
resize. - Khi component unmounted, gỡ event
resizeđể tránh memory leak.
Nếu viết trực tiếp bằng Vue, code vẫn chạy tốt. Nhưng khi dùng useLifecycle, lifecycle logic có một lớp thống nhất hơn. Những composable khác cũng có thể đi theo cùng một pattern: setup trong onMounted, cleanup trong onUnmounted.
Vì sao không truy cập window ngay từ đầu?
Trong useWindowDimensions, giá trị ban đầu là:
const dimensions = ref<WindowDimensions>({
width: 0,
height: 0,
});Hook không lấy window.innerWidth ngay khi khởi tạo. Việc này quan trọng trong các môi trường có server-side rendering hoặc static rendering, nơi window không tồn tại ở phía server.
Thay vào đó, code chỉ truy cập window bên trong onMounted:
onMounted(() => {
update();
window.addEventListener('resize', update);
});onMounted chỉ chạy ở phía client, sau khi component đã được mount. Đây là cách an toàn hơn khi làm việc với browser APIs.
Lợi ích của abstraction nhỏ
useLifecycle không phải một hook phức tạp. Giá trị của nó nằm ở việc chuẩn hóa một thao tác lặp lại.
Thay vì mỗi composable tự import lifecycle hooks từ Vue, tự quản lý trạng thái, tự quyết định cách expose state, ta có thể dùng một helper chung. Điều này giúp codebase có cùng một phong cách:
- Lifecycle gốc của Vue được bọc lại ở một nơi.
- State
mountedvàunmountedđược expose dưới dạng readonly. - Callback setup và cleanup có cùng chữ ký đơn giản.
- Các composable khác dễ đọc hơn vì logic lifecycle đã quen thuộc.
Với những dự án nhỏ, lợi ích này có thể chưa rõ. Nhưng khi có nhiều composable cùng cần lifecycle, abstraction như vậy giúp giảm sự lặp lại và làm code dễ bảo trì hơn.
Một vài lưu ý khi sử dụng
Điểm đầu tiên cần nhớ: useLifecycle vẫn phụ thuộc vào lifecycle context của Vue. Điều đó có nghĩa là nó nên được gọi trong setup() hoặc trong một composable được gọi từ setup().
Ví dụ đúng:
export default {
setup() {
const { onMounted } = useLifecycle();
onMounted(() => {
console.log('Mounted');
});
},
};Ví dụ không nên làm:
const lifecycle = useLifecycle();Nếu gọi composable ở ngoài setup context, lifecycle hooks của Vue sẽ không có component instance hiện tại để gắn vào. Khi đó code có thể cảnh báo hoặc không hoạt động như mong muốn.
Điểm thứ hai: unmounted chỉ có ý nghĩa sau khi component bắt đầu bị gỡ. Với nhiều use case thông thường, ta chỉ cần onUnmounted để cleanup. unmounted hữu ích hơn khi có logic reactive cần biết component đã kết thúc vòng đời.
Điểm thứ ba: không nên biến useLifecycle thành nơi chứa quá nhiều trách nhiệm. Hook này nên chỉ làm một việc: quản lý trạng thái vòng đời cơ bản và bọc callback lifecycle. Nếu thêm quá nhiều logic khác, nó sẽ mất đi sự đơn giản ban đầu.
Có cần reset unmounted không?
Trong hook hiện tại, unmounted bắt đầu là false và chuyển thành true khi component unmount. Nó không reset lại.
Điều này hợp lý vì một instance component sau khi unmount không quay lại trạng thái mounted cũ. Nếu component được render lại, Vue sẽ tạo một instance mới, và hook sẽ được khởi tạo lại từ đầu:
const mounted = ref(false);
const unmounted = ref(false);Vì vậy, không cần thêm logic reset trong hook này.
Có thể mở rộng hook này như thế nào?
Nếu sau này cần theo dõi nhiều trạng thái hơn, ta có thể mở rộng theo hướng thêm computed state:
import {
computed,
readonly,
ref,
onMounted as vueOnMounted,
onUnmounted as vueOnUnmounted,
} from 'vue';
export function useLifecycle() {
const mounted = ref(false);
const unmounted = ref(false);
const active = computed(() => mounted.value && !unmounted.value);
function onMounted(callback?: () => void): void {
vueOnMounted(() => {
mounted.value = true;
callback?.();
});
}
function onUnmounted(callback?: () => void): void {
vueOnUnmounted(() => {
unmounted.value = true;
callback?.();
});
}
return {
active,
mounted: readonly(mounted),
unmounted: readonly(unmounted),
onMounted,
onUnmounted,
};
}Tuy nhiên, chỉ nên thêm khi thật sự có nhu cầu. Nếu codebase hiện tại chỉ cần mounted, unmounted, onMounted và onUnmounted, phiên bản ban đầu đã đủ tốt.
Kết luận
useLifecycle là một composable nhỏ nhưng có thiết kế rõ ràng. Nó không thay thế lifecycle hooks của Vue, mà bọc chúng lại để thêm hai giá trị reactive: mounted và unmounted.
Điểm hay của hook này nằm ở sự cân bằng. Nó đủ đơn giản để dễ hiểu, đủ hữu ích để tái sử dụng, và đủ an toàn khi expose state bằng readonly.
Trong thực tế, những abstraction tốt thường không cần quá lớn. Chúng chỉ cần làm cho phần code lặp lại trở nên nhất quán hơn. Với các composable cần setup và cleanup theo vòng đời component, useLifecycle là một ví dụ gọn gàng và đáng dùng.