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.

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ồ:
tsswitch (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ỗ:
- File dài gần 300 dòng chỉ để chứa một switch, dependency array của
useEffectphải tính tới toàn bộ biến dùng bên trong. - Muốn thêm một transition mới, phải chui vào giữa một hàm đang phình to, dễ đụng chạm logic của case khác.
- SLIDE_LEFT và SLIDE_RIGHT gần như giống hệt nhau, chỉ khác dấu offset - nhưng vẫn phải copy-paste vì logic bị khóa cứng trong switch.
- Muốn viết unit test cho riêng animation của FADE_IN_BLACK, không thể - nó không tách rời được khỏi phần còn lại của hook.
Đâ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:
tsconst 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:
- Thêm transition mới = thêm một file, không sửa file cũ. Không còn nguy cơ sửa nhầm logic của case khác khi chèn thêm case mới vào giữa switch.
- Test được từng strategy độc lập -
buildAnimationlà hàm thuần, không cần mount cả hook để kiểm tra logic của một transition. - Trùng lặp lộ ra rõ ràng hơn - việc SLIDE_LEFT/RIGHT dùng chung factory chỉ nhìn thấy được sau khi tách case; nằm chung trong switch, sự trùng lặp bị che bởi khoảng cách vài trăm dòng.
- Hook chính (context) ngắn lại, chỉ còn lo đúng một việc: vòng đời animation, không lo animation cụ thể ra sao.
Đánh đổi:
- Nhiều file hơn - 7 case trở thành 7-8 file nhỏ thay vì một file lớn. Với số lượng case ít và ổn định, chi phí này có thể không đáng - nhưng ở đây số transition có xu hướng tăng theo thời gian (roadmap còn giữ chỗ cho
ROUND,TRANSFER,STACKING...). - Phải thiết kế context object đủ tốt. Nếu context thiếu field, thêm strategy mới sẽ phải sửa lại interface
TransitionPreviewContext- tức là vẫn chạm vào code dùng chung, chỉ ít hơn so với sửa switch. - Với dưới 2-3 case và không có dấu hiệu tăng thêm, một switch đơn giản vẫn là lựa chọn hợp lý hơn - áp Strategy Pattern cho vài dòng logic là over-engineering.
Khi nào nên dùng Strategy Pattern
Dấu hiệu nên cân nhắc:
- switch/if-else chọn hành vi dựa trên một "loại" (type/enum), và số loại có xu hướng tăng theo thời gian.
- Các nhánh có xu hướng ngày càng dài, làm file chứa switch phình to.
- Một vài nhánh gần giống nhau nhưng vẫn phải copy-paste vì bị khóa trong switch.
- Muốn unit test riêng logic của từng nhánh mà không cần dựng toàn bộ context xung quanh.
Dấu hiệu KHÔNG cần:
- Chỉ có 2-3 nhánh, ổn định, không có kế hoạch mở rộng.
- Logic mỗi nhánh chỉ vài dòng, tách ra sẽ tạo nhiều file cho một lượng logic quá nhỏ.
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.