如何使用设计模式优雅的实现前端国际化工具

119 阅读12分钟

需求说明

  1. 可以动态注册语言包,语言包资源为一个对象,键为翻译文本的标识,值为翻译的文本或是文本集合
i18n.registerBase('zh', {
    greeting: '你好',
    button: { confirm: '确定', cancel: '取消' }
});
  1. 可以对已经注册的语言资源【A】进行方言拓展,在切换到方言时,方言内有的文本,会优先采纳,若无,则回退到资源 A 中的文本。
i18n.registerBase('zh', {
    greeting: '你好',
    button: { confirm: '确定', cancel: '取消' }
});
// 扩展简体中文(中国大陆)
i18n.createVariant('zh', 'CN', {
    button: { cancel: '退出' }
});
console.log(i18n.getLang()); // 输出 "zh"
console.log(i18n.t('greeting')); // 输出 "你好"
console.log(i18n.t('button.cancel')); // 输出 "取消"
i18n.setLang('zh-CN', false)
console.log(i18n.getLang()); // 输出 "zh-CN"
console.log(i18n.t('greeting')); // 输出 "你好"
console.log(i18n.t('button.cancel')); // 输出 "退出"
  1. 可以获取当前语言,可设置当前语言,并在设置时可选择是否进行页面刷新操作,页面刷新操作可自定义。
let i18n = new I18nLang('zh', () => { console.log('自定义刷新') });
i18n.setLang('zh-CN', false); // 默认刷新,false表示不刷新
console.log(i18n.getLang()); // 输出 "zh-CN"
  1. 项目全局无论在哪创建实例,已注册的语言包不会丢失,用户设置的语言要做到在指定过期时间内不用重复设置。

let i18n = new I18nLang('zh');
i18n.registerBase('zh', {
    greeting: '你好',
    button: { confirm: '确定', cancel: '取消' }
});
i18n.registerBase('en', {
    greeting: 'Hello',
    button: { confirm: 'OK', cancel: 'Cancel' }
});
// 扩展美式英语
i18n.createVariant('en', 'US', {
    greeting: 'Howdy',
    button: { confirm: 'Got it' }
});

// 扩展简体中文(中国大陆)
i18n.createVariant('zh', 'CN', {
    button: { cancel: '退出' }
});
i18n = new I18nLang('zh');
i18n = new I18nLang('zh');
console.log(i18n.getLang())
console.log(i18n.t('greeting')); // 第一次输出 "你好"  第2次输出 "Hello"
console.log(i18n.t('button.cancel')); // 第一次输出 "取消" 第2次输出 "Cancel"
i18n.setLang('zh-CN', false)
console.log(i18n.getLang()); // 输出 "zh-CN"
console.log(i18n.t('greeting')); // 输出 "你好"
console.log(i18n.t('button.cancel')); // 输出 "退出"
i18n.setLang('en', false)

具体代码及实现思路

1. 实现语言资源实例原型类。

国际化其实就是对一些语言包资源的管理与使用,每一个资源包都有对应的语言标识,所以我们需要建立一个资源包类,用于存储对应的语言资源。

由于我们要支持方言扩展,那么每个方言都需要对应一个具体的资源,而且语言资源都是大json,那创建对象的成本太大了,我们就可以在 建立资源包类 时用上原型模式, 并且后续进行资源查找时也在方言中找不到可以直接获取原型,此时原型就是方言的基础资源,方便查找。

当需要创建多个相似的翻译原型时,原型模式可以避免重复创建对象,通过克隆现有对象并进行修改。

// 引入 js-cookie
import Cookies from 'js-cookie';

/**
 * 表示一个国际化翻译原型,用于存储特定语言的翻译内容,并支持克隆和扩展。
 */
class I18nPrototype {
    /**
     * 构造一个新的 I18nPrototype 实例。
     * @param {string} lang - 语言标识符,例如 'zh'、'en' 等。
     * @param {Object | string} [translations={}] - 包含翻译键值对的对象,默认为空对象。
     */
    constructor(lang, translations = {}) {
        // 存储语言标识符
        this.lang = lang;
        // 存储该语言对应的翻译内容
        this._translations = translations;
    }

    /**
     * 深拷贝当前原型实例,并应用可选的覆盖翻译。
     * @param {Object} [overrides={}] - 用于覆盖原翻译内容的对象,默认为空对象。
     * @returns {I18nPrototype} 一个新的 I18nPrototype 实例,包含合并后的翻译内容。
     */
    clone(overrides = {}) {
        // 创建一个新对象,其原型指向当前对象
        const cloned = Object.create(this);
        // 将当前对象的语言标识符复制到新对象
        cloned.lang = this.lang;
        cloned._translations = deepMerge(Object.create(this._translations), overrides);
        // 返回克隆并合并后的新对象
        return cloned;
    }

}

// 深度合并工具
/**
 * 深度合并多个对象到目标对象中。
 * 该函数会递归合并嵌套对象,同时对数组进行拼接操作。
 * @param {Object} target - 目标对象,合并结果将存储在该对象中。
 * @param {...Object} sources - 一个或多个源对象,这些对象的属性将被合并到目标对象中。
 * @returns {Object} 合并后的目标对象。
 */
function deepMerge(target, ...sources) {
    // 遍历每个源对象
    sources.forEach(source => {
        // 遍历源对象的每个属性
        for (const key in source) {
            // 检查属性是否为源对象自身的属性
            if (Object.prototype.hasOwnProperty.call(source, key)) {
                // 如果属性值是数组
                if (Array.isArray(source[key])) {
                    // 将源数组拼接到目标数组后面,如果目标数组不存在则初始化为空数组
                    target[key] = (target[key] || []).concat(source[key]);
                }
                // 如果属性值是普通对象
                else if (source[key] instanceof Object && typeof source[key] !== 'function') {
                    // 递归调用 deepMerge 合并嵌套对象,如果目标对象对应属性不存在则初始化为空对象
                    target[key] = deepMerge(target[key] || {}, source[key]);
                }
                // 其他情况(基本类型值)
                else {
                    // 直接将源对象的属性值赋值给目标对象
                    target[key] = source[key];
                }
            }
        }
    });
    // 返回合并后的目标对象
    return target;
}

2. 创建原型管理器,并注册初始语言配置实例

建立了资源包类之后,我们就应该对整体资源进行一个统一的管理,创建语言标识与资源包的映射关系,并进行管理。

考虑到代码需要高内聚低耦合,且单一职责。所以我们将整个语言资源的创建、映射、扩展方言与访问的功能都集合到该管理器类中:

  • 创建于映射:其实就是创建资源原型类,并与标识符绑定
  • 扩展方言:之前说过这个功能需要用到原型模式,对原始语言资源进行整合,所以在扩展方言时并不是创建资源原型类,而是调用原始资源类的 clone 方法进行资源的创建
  • 语言资源的访问:其实就是通过标识找到对应资源包以及资源包中对应的翻译文本。那我们其实需要考虑如下几个方面:
    • 在访问资源时,该资源之前是否已经访问过,若是的话感觉可以直接返回以前的结果,并不用进行再次查找,这样性能较好。

那我们就得使用缓存模式实现一个缓存机制了,建立一个 映射 Map ,用于存储以前访问过的资源,由于不同语言可能有相同资源路径,所以再进行缓存时,要同时以语言标识和资源路径为基生成新的 key ,将资源结果与这个 key 进行绑定。

    • 由于是有方言扩展的,基础语言相当于方言资源的原型,方言资源中没有的标识符会从基础语言资源中寻找,所以需要支持原型链查找


/**
 * 国际化管理器类,负责管理不同语言的翻译原型,支持注册基础语言、创建地区变种以及获取翻译文本。
 */
class I18nManager {
    /**
     * 构造函数,初始化一个 Map 用于存储不同语言的翻译原型。
     */
    constructor() {
        // 存储不同语言的翻译原型,键为语言标识符,值为 I18nPrototype 实例
        this.prototypes = new Map();
    }

    /**
     * 注册基础语言的翻译原型。
     * @param {string} lang - 语言标识符,例如 'zh'、'en' 等。
     * @param {Object} translations - 包含翻译键值对的对象。
     */
    registerBase(lang, translations) {
        // 创建一个新的 I18nPrototype 实例
        const proto = new I18nPrototype(lang, translations);
        // 将新创建的原型实例存储到 prototypes Map 中
        this.prototypes.set(lang, proto);
    }

    /**
     * 创建指定基础语言的地区变种。
     * @param {string} baseLang - 基础语言标识符。
     * @param {string} region - 地区标识符。
     * @param {Object} overrides - 用于覆盖基础语言翻译内容的对象。
     * @returns {I18nPrototype} 新创建的地区变种翻译原型实例。
     * @throws {Error} 若基础语言的原型未找到,抛出错误。
     */
    createVariant(baseLang, region, overrides) {
        // 从 prototypes Map 中获取基础语言的翻译原型
        const base = this.prototypes.get(baseLang);
        // 检查基础语言的原型是否存在,不存在则抛出错误
        if (!base) throw Error('Base lang not found');
        // 克隆基础语言的原型,并应用覆盖翻译
        const variant = base.clone(overrides);
        // 设置新变种的语言标识符为基础语言标识符和地区标识符的组合
        variant.lang = `${baseLang}-${region}`;
        // 将新创建的地区变种存储到 prototypes Map 中
        this.prototypes.set(variant.lang, variant);
        // 返回新创建的地区变种翻译原型实例
        return variant;
    }

    // 翻译缓存,用于存储已经查找过的翻译结果,提高性能
    _translationCache = new Map();

    /**
     * 动态获取指定语言和键的翻译文本,支持原型链查找,并使用缓存机制。
     * @param {string} lang - 语言标识符。
     * @param {string} key - 翻译键,支持点语法访问嵌套对象,例如 'button.confirm'。
     * @returns {string} 找到的翻译文本,若未找到则返回 'Missing: {key}'。
     */
    t(lang, key) {
        // 生成缓存键,格式为 语言标识符:翻译键
        const cacheKey = `${lang}:${key}`;
        // 检查缓存中是否存在该翻译结果,存在则直接返回
        if (this._translationCache.has(cacheKey)) return this._translationCache.get(cacheKey);
        // 从 prototypes Map 中获取指定语言的翻译原型
        let current = this.prototypes.get(lang);
        // 将翻译键按点分割成数组,用于访问嵌套对象
        let result = '';
        // 将翻译键按点分割成数组,用于访问嵌套对象
        let keys = key.split('.');
        // 遍历原型链查找翻译结果
        while (current) {
            // 使用 reduce 方法递归访问嵌套对象,查找对应的翻译文本
            const temp = keys.reduce((a, b) => {
                return a[b]
            }, current._translations);
            // 若找到翻译结果,则更新 result 并跳出循环
            if (temp) {
                result = temp
                break;
            }
            // 若未找到,继续查找原型链的上一级
            current = Object.getPrototypeOf(current);
        }
        // 若找到翻译结果,将其存入缓存
        if (result) {
            this._translationCache.set(cacheKey, result); // 缓存结果
            return result;
        }
        // 若未找到翻译结果,返回提示信息
        return `Missing: ${key}`;
    }

}

3. 实现前端国际化产品类,采用单例模式

为了让代码符合高内聚低耦合,我们应该单独实现一个用于进行资源使用的类,而不是集成在语言资源管理类中,并将资源管理的相关的使用方法【t、registerBase、createVariant】集成到该资源使用类中。

  • 支持自定义刷新:其实就是构造函数提供一个参数,用于表示刷新操作,默认为刷新页面
  • 全局创建对象,此前资源不丢失:由于我们要求在项目全局中无论何时创建新的国际化对象都不能丢失之前注册的语言资源,所以我们的资源使用类是要用单例模式创建的,这样可以保证项目全局只有一个实例,所有资源都会在该实例上存储,不会丢失。
  • 用户二次访问时,沿用先前设置语言:我们可以想到使用 cookie 存储信息,这样与用户唯一绑定。
  • 获取当前语言:我们应该防止用户清除 cookie 之类的操作,所以应该在创建国际化使用类时,进行默认语言的设定,在没有查询到 cookie 中的语言时,使用当前设定的语言,如果之前没有进行语言设定操作,则应该使用默认语言,并将其设置到 cookie 中。剩余情况直接从 cookie 中获取当前语言
  • 设置当前语言:需要提供是否进行页面刷新操作,刷新时要调用之前设定好的刷新方式。


/**
 * 国际化语言管理类,基于 I18nManager 实现语言管理功能,支持获取和设置当前语言,使用单例模式。
 */
class I18nLang {
    // 默认语言标识符
    defaultLang = '';

    /**
     * 获取当前使用的语言标识符。
     * 优先从 cookie 中获取,若不存在则使用默认语言或实例的私有语言属性。
     * @returns {string} 当前语言标识符。
     */
    getLang() {
        // 从 cookie 中获取当前语言标识符
        let currLang = Cookies.get("_i18nLang");
        // 若 cookie 中未获取到语言标识符
        if (!currLang) {
            // 使用实例的私有语言属性或默认语言标识符
            currLang = this.#lang || this.defaultLang;
            // 将当前语言标识符存入 cookie,不刷新 UI
            this.setLang(currLang, false);
            return currLang;
        }
        // 返回从 cookie 中获取的语言标识符
        return currLang;
    }

    /**
     * 设置当前使用的语言标识符,并将其存入 cookie。
     * @param {string} lang - 要设置的语言标识符。
     * @param {boolean} [refresh=true] - 是否刷新 UI,默认为 true。
     * @param {number} [expires=14] - cookie 的过期时间(天数),默认为 14 天。
     */
    setLang(lang, refresh = true, expires = 14,) {
        // 将语言标识符存入 cookie
        Cookies.set(
            '_i18nLang',
            lang,
            {
                expires,
                path: '/',
            }
        );
        // 更新实例的私有语言属性
        this.#lang = lang;
        // 若 refresh 为 true,则调用 updateUI 方法刷新 UI
        if (refresh)
            this.updateUI();
    }

    // 私有属性,存储当前语言标识符
    #lang = '';

    /**
     * 获取指定翻译键的翻译文本,使用当前语言。
     * @param {string} key - 翻译键,支持点语法访问嵌套对象。
     * @returns {string} 翻译文本,若未找到则返回 'Missing: {key}'。
     */
    t(key) {
        // 调用 I18nManager 的 t 方法,传入当前语言和翻译键
        return this.I18nManager.t(this.getLang(), key)
    }

    /**
     * 注册基础语言的翻译原型。
     * @param {string} lang - 语言标识符。
     * @param {Object} translations - 包含翻译键值对的对象。
     */
    registerBase(lang, translations) {
        // 调用 I18nManager 的 registerBase 方法
        this.I18nManager.registerBase(lang, translations);
    }

    /**
     * 创建指定基础语言的地区变种。
     * @param {string} baseLang - 基础语言标识符。
     * @param {string} region - 地区标识符。
     * @param {Object} overrides - 用于覆盖基础语言翻译内容的对象。
     * @returns {I18nPrototype} 新创建的地区变种翻译原型实例。
     */
    createVariant(baseLang, region, overrides) {
        // 调用 I18nManager 的 createVariant 方法
        this.I18nManager.createVariant(baseLang, region, overrides);
    }

    /**
     * 构造函数,使用单例模式确保全局只有一个实例。
     * @param {string} defaultLang - 默认语言标识符。
     * @param {Function} [updateUI] - 用于刷新 UI 的函数,默认为刷新页面。
     * @returns {I18nLang} 单例实例。
     */
    constructor(defaultLang, updateUI) {
        // 尝试获取已存在的单例实例
        let _this = I18nLang.instance;
        // 若单例实例不存在
        if (!I18nLang.instance) {
            // 将当前实例赋值给 _this
            _this = this;
            // 将当前实例设置为单例实例
            I18nLang.instance = this;
            // 创建一个新的 I18nManager 实例
            _this.I18nManager = new I18nManager();
        }
        // 更新默认语言标识符
        _this.defaultLang = defaultLang;
        // 更新实例的私有语言属性为当前使用的语言标识符
        _this.#lang = _this.getLang();
        // 设置更新 UI 的函数,若未传入则默认刷新页面
        _this.updateUI = updateUI || (() => window.location.reload());
        // 设置当前语言,不刷新 UI
        _this.setLang(_this.#lang, false);
        // 返回单例实例
        return _this;
    }
}

4. 具体使用


let i18n = new I18nLang('zh');
i18n.registerBase('zh', {
    greeting: '你好',
    button: { confirm: '确定', cancel: '取消' }
});
i18n.registerBase('en', {
    greeting: 'Hello',
    button: { confirm: 'OK', cancel: 'Cancel' }
});
// 扩展美式英语
i18n.createVariant('en', 'US', {
    greeting: 'Howdy',
    button: { confirm: 'Got it' }
});

// 扩展简体中文(中国大陆)
i18n.createVariant('zh', 'CN', {
    button: { cancel: '退出' }
});

i18n = new I18nLang('zh');
i18n = new I18nLang('zh');
console.log(i18n.getLang())
console.log(i18n.t('greeting')); // 输出 "你好"
console.log(i18n.t('button.cancel')); // 输出 "取消"
i18n.setLang('zh-CN', false)
console.log(i18n.getLang()); // 输出 "zh-CN"
console.log(i18n.t('greeting')); // 输出 "你好"
console.log(i18n.t('button.cancel')); // 输出 "退出"
console.log(i18n.t('button.confirm')); // 输出 "确定"
i18n.setLang('en', false)

第一次访问

zh
你好
取消
zh-CN
你好
退出
确定

第二次访问

en
Hello
Cancel
zh-CN
你好
退出
确定