Back to blog
design-patternstypescriptreactrefactoring

Strategy Pattern trong thực chiến - dọn dẹp switch-case animation transition

Từ một switch-case gần 300 dòng trong hook animation, áp dụng Strategy Pattern để tách mỗi hiệu ứng transition thành một class riêng, dễ mở rộng và dễ test hơn.

Strategy Pattern trong thực chiến - dọn dẹp switch-case animation transition

Vấn đề: switch-case phình to theo thời gian

Trong một app editor video, có một hook tên useTransitionPreview chịu trách nhiệm chạy animation preview khi người dùng hover vào một loại transition (Fade, Slide, Appear...). Ban đầu, toàn bộ logic animation của từng loại transition nằm chung trong một switch-case khổng lồ:

ts
switch (transitionType) { case ETransition.FADE_SCALE_DOWN: animationConfig = { /* ~25 dòng cấu hình anime.js */ } break case ETransition.SLIDE_LEFT: { animationConfig = { /* ~45 dòng, có animation lồng nhau */ } break } case ETransition.SLIDE_RIGHT: { // gần như copy-paste của SLIDE_LEFT, chỉ khác hướng di chuyển } case ETransition.FADE_IN_BLACK: { // tạo thêm DOM overlay, animation song song } // ...còn 4 case nữa }

Từng case không tệ khi đứng riêng lẻ. Vấn đề là khi gộp lại một chỗ:

Đây là dấu hiệu kinh điển để áp dụng Strategy Pattern.

Strategy Pattern là gì

Strategy Pattern định nghĩa một họ thuật toán (algorithm family), đóng gói từng thuật toán vào một class/object riêng, và làm cho chúng có thể hoán đổi cho nhau thông qua một interface chung. Thay vì if/switch để chọn hành vi, code gọi thẳng vào interface - việc "chọn strategy nào" chỉ còn là tra một bảng (registry/map).

Ba thành phần cốt lõi:

1. Strategy interface - hợp đồng chung mà mọi thuật toán phải tuân theo. 2. Concrete strategies - từng cách hiện thực cụ thể, độc lập với nhau. 3. Context - nơi gọi ra strategy phù hợp và thực thi nó, không cần biết chi tiết bên trong.

Lợi ích lớn nhất là tuân theo Open/Closed Principle: muốn thêm hành vi mới, chỉ cần thêm một class mới và đăng ký nó - không cần sửa code đã chạy ổn định.

Bước 1: định nghĩa Strategy interface

Việc đầu tiên là tìm ra "hợp đồng chung" giữa các case. Nhìn lại switch cũ, mọi case đều nhận vào cùng một tập dữ liệu (stage Konva, kích thước slide, ảnh preview trang kế tiếp...) và trả về một cấu hình animation. Từ đó rút ra:

ts
// strategies/types.ts export interface TransitionPreviewContext { stage: Konva.Stage stageRef: RefObject<Konva.Stage> animationTarget: StageTransformState originalStateRef: MutableRefObject<StageTransformState | null> stageWidth: number stageHeight: number slideWidth: number slideHeight: number offsetX: number offsetY: number targetScale: number nextPageImage: string | Blob | null | undefined createPreviewDiv: (container: HTMLElement, scale?: number, backgroundSize?: string, fadeInDuration?: number) => void setInnerAnimation: (instance: anime.AnimeInstance | null) => void } export interface TransitionStrategy { buildAnimation(ctx: TransitionPreviewContext): anime.AnimeParams }

TransitionPreviewContext chính là "context" theo nghĩa Strategy Pattern - toàn bộ dữ liệu và callback mà một strategy có thể cần, gói gọn trong một object. Mỗi strategy chỉ implement một hàm buildAnimation, nhận context và trả về cấu hình animation.

Bước 2: tách từng case thành một strategy

Mỗi case cũ giờ là một file riêng, chỉ chứa đúng logic của nó:

ts
// strategies/fadeScaleDown.ts export const fadeScaleDownStrategy: TransitionStrategy = { buildAnimation: (ctx) => { const { stage, animationTarget, offsetX, offsetY, targetScale } = ctx return { targets: animationTarget, x: offsetX, y: offsetY, scaleX: targetScale, scaleY: targetScale, opacity: 0, duration: 600, easing: 'easeInOutQuad', update: () => { stage.x(animationTarget.x) stage.opacity(animationTarget.opacity) stage.batchDraw() }, complete: () => { const container = ctx.stageRef.current?.container() if (container && ctx.nextPageImage) ctx.createPreviewDiv(container) }, } }, }

Phần thú vị là SLIDE_LEFT và SLIDE_RIGHT. Trong switch cũ, đây là hai case gần như copy-paste nhau. Với Strategy Pattern, có thể dùng một factory function để sinh ra hai strategy từ một logic dùng chung:

ts
// strategies/slide.ts const createSlideStrategy = (direction: 'left' | 'right'): TransitionStrategy => ({ buildAnimation: (ctx) => { const exitX = direction === 'left' ? ctx.offsetX - OFFSET_MOVING : ctx.offsetX + OFFSET_MOVING return { targets: ctx.animationTarget, x: ctx.offsetX, // ... phần animation tiến vào giống hệt nhau complete: () => { ctx.setInnerAnimation( anime({ targets: ctx.animationTarget, x: exitX, // chỉ khác nhau ở đây // ... }), ) }, } }, }) export const slideLeftStrategy = createSlideStrategy('left') export const slideRightStrategy = createSlideStrategy('right')

Đây là chỗ Strategy Pattern trả về nhiều hơn switch-case: nó không chỉ tách code ra, mà còn cho phép compose (tổ hợp) các strategy với nhau khi chúng có phần chung.

Bước 3: registry thay cho switch

Switch-case thực chất đang làm một việc: ánh xạ từ transitionType sang một đoạn logic. Việc đó diễn tả tự nhiên hơn bằng một object tra cứu:

ts
// strategies/index.ts export const transitionStrategies: Partial<Record<ETransition, TransitionStrategy>> = { [ETransition.FADE_SCALE_DOWN]: fadeScaleDownStrategy, [ETransition.SLIDE_LEFT]: slideLeftStrategy, [ETransition.SLIDE_RIGHT]: slideRightStrategy, [ETransition.APPEAR]: appearStrategy, [ETransition.FADE_IN_DEKA]: fadeInDekaStrategy, [ETransition.FADE_IN_BLACK]: fadeInBlackStrategy, [ETransition.FADE_IN_BLACK_FIRST_SCENE]: fadeInBlackFirstSceneStrategy, }

Và hook gốc, thay vì switch dài dằng dặc, giờ chỉ còn:

ts
const strategy = transitionStrategies[transitionType] ?? defaultTransitionStrategy animationRef.current = anime( strategy.buildAnimation({ stage, stageRef, animationTarget, originalStateRef: originalState, stageWidth, stageHeight, slideWidth, slideHeight, offsetX, offsetY, targetScale, nextPageImage, createPreviewDiv, setInnerAnimation: (instance) => { innerAnimationRef.current = instance }, }), )

Hook không còn biết gì về chi tiết từng transition - nó chỉ đóng vai trò context: chuẩn bị dữ liệu, gọi strategy, và lo phần vòng đời chung (hover, revert, cleanup, dọn animation cũ). Đúng như tinh thần Strategy Pattern: context không quan tâm thuật toán cụ thể là gì, chỉ quan tâm nó tuân theo đúng interface.

Được gì, và đánh đổi gì

Được:

Đánh đổi:

Khi nào nên dùng Strategy Pattern

Dấu hiệu nên cân nhắc:

Dấu hiệu KHÔNG cần:

Kết luận

Strategy Pattern không phải phép màu biến switch xấu thành code đẹp chỉ bằng cách đổi tên. Giá trị thật của nó nằm ở việc ép người viết code phải trả lời câu hỏi: "hợp đồng chung giữa các nhánh là gì?" Một khi trả lời được câu đó bằng một interface rõ ràng, mỗi nhánh logic có thể sống độc lập, dễ đọc, dễ test, và dễ mở rộng hơn hẳn một switch-case đang lớn dần theo thời gian.