Skip to content
⚠️ This article was written in 2019. Some content may be outdated.

JavaScript 常用設計模式實戰

設計模式不是後端專屬,前端開發中同樣大量使用。掌握這些模式,程式碼會更優雅、更易維護。

單例模式(Singleton)

保證一個類只有一個例項,全域性共享同一個物件。前端最常見的場景:全域性狀態管理、彈窗管理、登入態管理。

javascript
// 實現1:閉包方式
const Singleton = (function () {
    let instance;

    function createInstance(options) {
        return {
            name: options.name,
            log() {
                console.log(`例項名稱: ${this.name}`);
            },
        };
    }

    return {
        getInstance(options) {
            if (!instance) {
                instance = createInstance(options);
            }
            return instance;
        },
    };
})();

// 使用
const s1 = Singleton.getInstance({ name: 'app' });
const s2 = Singleton.getInstance({ name: 'other' });
console.log(s1 === s2); // true,始終是同一個例項
javascript
// 實現2:ES6 class + 靜態方法
class ModalManager {
    static instance = null;

    constructor() {
        this.modals = [];
    }

    static getInstance() {
        if (!ModalManager.instance) {
            ModalManager.instance = new ModalManager();
        }
        return ModalManager.instance;
    }

    open(config) {
        const modal = { id: Date.now(), ...config, visible: true };
        this.modals.push(modal);
        console.log(`開啟彈窗: ${config.title}, 當前共 ${this.modals.length} 個`);
        return modal.id;
    }

    close(id) {
        const index = this.modals.findIndex(m => m.id === id);
        if (index > -1) {
            this.modals.splice(index, 1);
            console.log(`關閉彈窗, 剩餘 ${this.modals.length} 個`);
        }
    }

    closeAll() {
        this.modals = [];
        console.log('關閉所有彈窗');
    }
}

// 任意位置獲取的都是同一個管理器例項
const manager1 = ModalManager.getInstance();
const manager2 = ModalManager.getInstance();
console.log(manager1 === manager2); // true

manager1.open({ title: '確認刪除', content: '確定要刪除嗎?' });
manager2.open({ title: '提示', content: '操作成功' });
console.log(manager1.modals.length); // 2
javascript
// 實現3:通用單例裝飾器(最實用)
function singleton(Constructor) {
    let instance;
    return function (...args) {
        if (!instance) {
            instance = new Constructor(...args);
        }
        return instance;
    };
}

// 使用
class EventBus {
    constructor() {
        this.events = {};
    }

    on(event, handler) {
        if (!this.events[event]) this.events[event] = [];
        this.events[event].push(handler);
    }

    emit(event, data) {
        (this.events[event] || []).forEach(handler => handler(data));
    }
}

// 整個專案只有一個 EventBus 例項
const createEventBus = singleton(EventBus);
const bus1 = new createEventBus();
const bus2 = new createEventBus();
console.log(bus1 === bus2); // true

工廠模式(Factory)

不直接使用 new 建立物件,而是通過工廠函式根據條件建立不同型別的物件。前端常見場景:建立不同型別的彈窗、表單元件、訊息通知等。

javascript
// 通知工廠:根據型別建立不同的通知物件
class Notification {
    constructor(options) {
        this.title = options.title;
        this.message = options.message;
        this.duration = options.duration || 3000;
    }

    show() {
        throw new Error('子類必須實現 show 方法');
    }
}

class SuccessNotification extends Notification {
    show() {
        console.log(`[成功] ${this.title}: ${this.message}`);
        return { type: 'success', el: '<div class="toast success">...</div>' };
    }
}

class ErrorNotification extends Notification {
    show() {
        console.log(`[錯誤] ${this.title}: ${this.message}`);
        return { type: 'error', el: '<div class="toast error">...</div>' };
    }
}

class WarningNotification extends Notification {
    show() {
        console.log(`[警告] ${this.title}: ${this.message}`);
        return { type: 'warning', el: '<div class="toast warning">...</div>' };
    }
}

// 工廠函式
function createNotification(type, options) {
    switch (type) {
        case 'success':
            return new SuccessNotification(options);
        case 'error':
            return new ErrorNotification(options);
        case 'warning':
            return new WarningNotification(options);
        default:
            throw new Error(`未知的通知型別: ${type}`);
    }
}

// 使用:呼叫者不需要知道具體的類
const notify = createNotification('success', {
    title: '儲存成功',
    message: '資料已儲存到伺服器',
});
notify.show();
// [成功] 儲存成功: 資料已儲存到伺服器
javascript
// 表單欄位工廠:動態生成不同型別的表單控制元件
function createFormField(config) {
    const { type, name, label, value, options } = config;

    const base = {
        name,
        label,
        value: value || '',
        required: config.required || false,
    };

    switch (type) {
        case 'input':
            return {
                ...base,
                type: 'input',
                inputType: config.inputType || 'text',
                placeholder: config.placeholder || '',
                render() {
                    return `<input type="${this.inputType}" name="${this.name}"
                            placeholder="${this.placeholder}" value="${this.value}">`;
                },
            };

        case 'select':
            return {
                ...base,
                type: 'select',
                options: options || [],
                render() {
                    const opts = this.options
                        .map(o => `<option value="${o.value}">${o.label}</option>`)
                        .join('');
                    return `<select name="${this.name}">${opts}</select>`;
                },
            };

        case 'textarea':
            return {
                ...base,
                type: 'textarea',
                rows: config.rows || 3,
                render() {
                    return `<textarea name="${this.name}" rows="${this.rows}">${this.value}</textarea>`;
                },
            };

        case 'checkbox':
            return {
                ...base,
                type: 'checkbox',
                checked: config.checked || false,
                render() {
                    const checkedAttr = this.checked ? 'checked' : '';
                    return `<input type="checkbox" name="${this.name}" ${checkedAttr}>`;
                },
            };

        default:
            throw new Error(`不支援的欄位型別: ${type}`);
    }
}

// 使用
const fields = [
    createFormField({ type: 'input', name: 'username', label: '使用者名稱', required: true }),
    createFormField({ type: 'input', name: 'email', label: '郵箱', inputType: 'email' }),
    createFormField({
        type: 'select', name: 'role', label: '角色',
        options: [{ value: 'admin', label: '管理員' }, { value: 'user', label: '普通使用者' }],
    }),
    createFormField({ type: 'checkbox', name: 'agree', label: '同意協議', checked: false }),
];

fields.forEach(f => console.log(f.render()));

觀察者模式 / 釋出訂閱(Observer / PubSub)

這是前端最常用的模式之一,Vue 的響應式、Redux 的 subscribe、DOM 事件都是這個模式的應用。

javascript
// 釋出訂閱中心
class PubSub {
    constructor() {
        // 儲存事件和對應的回撥函式列表
        this.handlers = {};
    }

    // 訂閱
    on(event, handler) {
        if (!this.handlers[event]) {
            this.handlers[event] = [];
        }
        this.handlers[event].push(handler);
        // 返回取消訂閱的函式
        return () => this.off(event, handler);
    }

    // 釋出(觸發事件)
    emit(event, ...args) {
        const handlers = this.handlers[event] || [];
        handlers.forEach(handler => handler(...args));
    }

    // 取消訂閱
    off(event, handler) {
        if (!this.handlers[event]) return;
        if (!handler) {
            // 不傳 handler 則移除該事件的所有監聽
            delete this.handlers[event];
            return;
        }
        this.handlers[event] = this.handlers[event].filter(h => h !== handler);
    }

    // 只訂閱一次
    once(event, handler) {
        const wrapper = (...args) => {
            handler(...args);
            this.off(event, wrapper);
        };
        this.on(event, wrapper);
    }
}

// 使用
const pubsub = new PubSub();

// 場景1:元件間通訊
const unsub1 = pubsub.on('user:login', (user) => {
    console.log(`更新導航欄: 歡迎 ${user.name}`);
});

const unsub2 = pubsub.on('user:login', (user) => {
    console.log(`載入使用者資料: ${user.id}`);
});

pubsub.emit('user:login', { id: 1, name: '張三' });
// 更新導航欄: 歡迎 張三
// 載入使用者資料: 1

// 取消訂閱
unsub1();

// 場景2:once 只觸發一次
pubsub.once('page:loaded', () => {
    console.log('頁面首次載入完成,顯示引導彈窗');
});

pubsub.emit('page:loaded'); // 顯示引導彈窗
pubsub.emit('page:loaded'); // 不會再觸發
javascript
// Vue 2 響應式原理簡化版(觀察者模式的經典應用)
function observe(obj) {
    if (typeof obj !== 'object' || obj === null) return;

    Object.keys(obj).forEach(key => {
        defineReactive(obj, key, obj[key]);
    });
}

function defineReactive(obj, key, val) {
    const dep = []; // 依賴收集器

    observe(val); // 遞迴處理巢狀物件

    Object.defineProperty(obj, key, {
        get() {
            // 如果當前有正在收集的 watcher,新增到依賴中
            if (Dep.target) {
                dep.push(Dep.target);
            }
            return val;
        },
        set(newVal) {
            if (newVal === val) return;
            val = newVal;
            observe(newVal);
            // 通知所有依賴(訂閱者)
            dep.forEach(watcher => watcher.update());
        },
    });
}

// 全域性依賴收集器
const Dep = { target: null };

// Watcher:訂閱者
class Watcher {
    constructor(obj, key, callback) {
        this.obj = obj;
        this.key = key;
        this.callback = callback;
        // 觸發 get,收集自己到 dep 中
        Dep.target = this;
        this.value = obj[key];
        Dep.target = null;
    }

    update() {
        const newVal = this.obj[this.key];
        if (newVal !== this.value) {
            this.callback(newVal, this.value);
            this.value = newVal;
        }
    }
}

// 使用
const data = { name: 'Vue', version: 2 };
observe(data);

new Watcher(data, 'name', (newVal, oldVal) => {
    console.log(`name 從 "${oldVal}" 變成了 "${newVal}"`);
});

data.name = 'Vue 2.6'; // name 從 "Vue" 變成了 "Vue 2.6"

策略模式(Strategy)

定義一系列演算法,把它們封裝起來,使它們可以互相替換。避免大量的 if-elseswitch-case

javascript
// 表單驗證策略
const validationStrategies = {
    required(value, fieldName) {
        if (!value || !value.toString().trim()) {
            return `${fieldName}不能為空`;
        }
        return null;
    },

    minLength(value, fieldName, min) {
        if (value.length < min) {
            return `${fieldName}長度不能小於${min}個字元`;
        }
        return null;
    },

    maxLength(value, fieldName, max) {
        if (value.length > max) {
            return `${fieldName}長度不能超過${max}個字元`;
        }
        return null;
    },

    email(value, fieldName) {
        const emailReg = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
        if (!emailReg.test(value)) {
            return `${fieldName}格式不正確`;
        }
        return null;
    },

    phone(value, fieldName) {
        const phoneReg = /^1[3-9]\d{9}$/;
        if (!phoneReg.test(value)) {
            return `${fieldName}格式不正確`;
        }
        return null;
    },

    pattern(value, fieldName, regex) {
        if (!regex.test(value)) {
            return `${fieldName}格式不正確`;
        }
        return null;
    },
};

// 驗證器
class FormValidator {
    constructor() {
        this.rules = [];
    }

    addRule(fieldName, value, strategy, ...args) {
        this.rules.push({ fieldName, value, strategy, args });
        return this; // 鏈式呼叫
    }

    validate() {
        const errors = [];

        this.rules.forEach(({ fieldName, value, strategy, args }) => {
            const strategyFn = validationStrategies[strategy];
            if (!strategyFn) {
                console.warn(`未知的驗證策略: ${strategy}`);
                return;
            }
            const error = strategyFn(value, fieldName, ...args);
            if (error) {
                errors.push({ field: fieldName, message: error });
            }
        });

        return {
            valid: errors.length === 0,
            errors,
        };
    }
}

// 使用
const validator = new FormValidator();
validator
    .addRule('使用者名稱', 'ab', 'required')
    .addRule('使用者名稱', 'ab', 'minLength', 3)
    .addRule('郵箱', 'not-an-email', 'email')
    .addRule('手機號', '12345', 'phone');

const result = validator.validate();
console.log(result);
// {
//   valid: false,
//   errors: [
//     { field: '使用者名稱', message: '使用者名稱長度不能小於3個字元' },
//     { field: '郵箱', message: '郵箱格式不正確' },
//     { field: '手機號', message: '手機號格式不正確' }
//   ]
// }
javascript
// 價格計算策略:電商場景
const pricingStrategies = {
    // 普通使用者:原價
    normal(originalPrice) {
        return originalPrice;
    },

    // 會員使用者:9 折
    vip(originalPrice) {
        return originalPrice * 0.9;
    },

    // 超級會員:7 折
    svip(originalPrice) {
        return originalPrice * 0.7;
    },

    // 滿減策略
    fullReduction(originalPrice, threshold, reduction) {
        return originalPrice >= threshold
            ? originalPrice - reduction
            : originalPrice;
    },
};

function calculatePrice(originalPrice, userType, fullReductionConfig) {
    // 會員折扣
    const discountFn = pricingStrategies[userType] || pricingStrategies.normal;
    let price = discountFn(originalPrice);

    // 滿減
    if (fullReductionConfig) {
        price = pricingStrategies.fullReduction(
            price,
            fullReductionConfig.threshold,
            fullReductionConfig.reduction
        );
    }

    return Math.round(price * 100) / 100;
}

// 使用
console.log(calculatePrice(100, 'normal'));          // 100
console.log(calculatePrice(100, 'vip'));              // 90
console.log(calculatePrice(100, 'svip'));             // 70
console.log(calculatePrice(100, 'vip', { threshold: 80, reduction: 10 }));  // 80

裝飾器模式(Decorator)

在不修改原有物件的前提下,動態地給物件增加功能。ES7 的裝飾器語法就是基於這個模式。

javascript
// 函式裝飾器:給函式新增日誌和效能監控
function withLog(fn, fnName) {
    return function (...args) {
        console.log(`[LOG] 呼叫 ${fnName}, 引數:`, args);
        const result = fn.apply(this, args);
        console.log(`[LOG] ${fnName} 返回:`, result);
        return result;
    };
}

function withPerformance(fn, fnName) {
    return function (...args) {
        const start = performance.now();
        const result = fn.apply(this, args);
        const duration = (performance.now() - start).toFixed(2);
        console.log(`[PERF] ${fnName} 執行耗時: ${duration}ms`);
        return result;
    };
}

function withRetry(fn, maxRetries = 3) {
    return function (...args) {
        for (let i = 0; i < maxRetries; i++) {
            try {
                return fn.apply(this, args);
            } catch (error) {
                console.log(`[RETRY] 第 ${i + 1} 次重試...`);
                if (i === maxRetries - 1) throw error;
            }
        }
    };
}

// 原始函式
function processData(data) {
    return data.map(item => item * 2);
}

// 裝飾
const enhancedProcess = withPerformance(
    withLog(processData, 'processData'),
    'processData'
);

enhancedProcess([1, 2, 3]);
// [LOG] 呼叫 processData, 引數: [[1, 2, 3]]
// [LOG] processData 返回: [2, 4, 6]
// [PERF] processData 執行耗時: 0.15ms
javascript
// 類裝飾器(ES7 語法,需要 Babel 外掛)
// 裝飾器提案目前還在 Stage 3

// 給類新增 mixins 功能
function withValidation(targetClass) {
    // 在原型上新增驗證方法
    targetClass.prototype.validate = function (rules) {
        const errors = [];
        Object.keys(rules).forEach(field => {
            const value = this[field];
            const fieldRules = rules[field];

            fieldRules.forEach(rule => {
                if (rule.required && (!value && value !== 0)) {
                    errors.push(`${field} 是必填項`);
                }
                if (rule.max && value && value.length > rule.max) {
                    errors.push(`${field} 不能超過 ${rule.max} 個字元`);
                }
            });
        });

        return { valid: errors.length === 0, errors };
    };

    return targetClass;
}

// 實際用法(需要 Babel 外掛 @babel/plugin-proposal-decorators)
// @withValidation
// class UserForm {
//     constructor(name, email) {
//         this.name = name;
//         this.email = email;
//     }
// }

// 不用裝飾器語法的等價寫法
class UserForm {
    constructor(name, email) {
        this.name = name;
        this.email = email;
    }
}
withValidation(UserForm);

const form = new UserForm('', 'test@example.com');
console.log(form.validate({
    name: [{ required: true }, { max: 20 }],
    email: [{ required: true }],
}));
// { valid: false, errors: ['name 是必填項'] }

模組模式(Module)

利用閉包和 IIFE 實現私有變數和方法,這是 ES6 Module 出現之前最常用的封裝方式。

javascript
// IIFE 模組模式
const ApiClient = (function () {
    // 私有變數
    let baseUrl = '';
    let token = '';
    let requestCount = 0;

    // 私有方法
    function buildHeaders() {
        const headers = { 'Content-Type': 'application/json' };
        if (token) {
            headers['Authorization'] = `Bearer ${token}`;
        }
        return headers;
    }

    function logRequest(method, url) {
        requestCount++;
        console.log(`[API #${requestCount}] ${method} ${url}`);
    }

    // 公開 API
    return {
        init(config) {
            baseUrl = config.baseUrl;
            token = config.token || '';
        },

        async get(path) {
            const url = `${baseUrl}${path}`;
            logRequest('GET', url);
            const res = await fetch(url, { headers: buildHeaders() });
            return res.json();
        },

        async post(path, data) {
            const url = `${baseUrl}${path}`;
            logRequest('POST', url);
            const res = await fetch(url, {
                method: 'POST',
                headers: buildHeaders(),
                body: JSON.stringify(data),
            });
            return res.json();
        },

        getRequestCount() {
            return requestCount;
        },

        // 外部無法直接修改 baseUrl 和 token
    };
})();

// 使用
ApiClient.init({ baseUrl: 'https://api.example.com', token: 'xxx' });
ApiClient.get('/users').then(data => console.log(data));
ApiClient.post('/users', { name: '張三' });
console.log(ApiClient.getRequestCount()); // 2
// console.log(ApiClient.baseUrl); // undefined,無法直接訪問私有變數
javascript
// ES6 Module 方式(推薦)
// utils/math.js
const _cache = new Map(); // 模組私有變數,外部無法直接訪問

function factorial(n) {
    if (n <= 1) return 1;
    if (_cache.has(n)) return _cache.get(n);
    const result = n * factorial(n - 1);
    _cache.set(n, result);
    return result;
}

function fibonacci(n) {
    if (n <= 1) return n;
    if (_cache.has(`fib_${n}`)) return _cache.get(`fib_${n}`);
    const result = fibonacci(n - 1) + fibonacci(n - 2);
    _cache.set(`fib_${n}`, result);
    return result;
}

function clearCache() {
    _cache.clear();
}

// 只匯出需要公開的方法
export { factorial, fibonacci, clearCache };
// _cache 是模組私有的,外部無法訪問

各模式在前端中的應用場景

模式              典型應用場景
─────────────────────────────────────────────────
單例模式          Vuex/Redux Store, 全域性 EventBus, 登入態管理
工廠模式          元件動態建立, 表單欄位生成, 通知彈窗系統
觀察者模式        Vue 響應式, DOM 事件, Redux subscribe, RxJS
策略模式          表單驗證, 價格計算, 許可權判斷, 排序演算法選擇
裝飾器模式        React HOC, 日誌/效能監控, 許可權校驗, 快取
模組模式          工具函式封裝, API 客戶端, 配置管理

小結

  • 單例模式適合全域性唯一的例項,用閉包或靜態屬性實現最簡單
  • 工廠模式把建立邏輯和使用邏輯分離,新增型別時只需改工廠函式
  • 觀察者/釋出訂閱是前端最核心的模式,Vue 響應式、DOM 事件、元件通訊都離不開它
  • 策略模式消滅 if-else 地獄,讓演算法獨立於使用它的客戶端
  • 裝飾器模式在不改原始碼的情況下增強功能,React HOC 就是它的應用
  • 模組模式封裝私有狀態,ES6 Module 是現代 JavaScript 的標準做法
  • 設計模式不是銀彈,不要為了用模式而用模式,解決實際問題才是目的

MIT Licensed