手写TS装饰器之@localeLoader 优雅实现React类组件多语言

3,976 阅读4分钟

"Hello! こんにちは! انتبه للسلامة!안녕하십니까!你好"

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第16天,点击查看活动详情

公司级的大型项目或者社区优秀的开源项目,如 Ant Design,一般都会有 "多语言" 配置,而且支持多语言也是组件的基本能力。多语言功能的本质其实是文本替换,一个词汇“Hello”,在英文语境下是“Greeting”,日语语境下是“こんにちは”,在汉语语境下是“你好”等。

本文将基于 类装饰器 特性来实现多语言配置。

类装饰器与HOC

类装饰器其本质是JS函数,接受一个构造函数(类)作为参数,如果类装饰器函数有返回值,那么该返回值将替代原来的构造函数。举个例子,

类装饰器

type Consturctor = { new (...args: unknown[]): unknown };

function addMethod<T extends Consturctor>(ParentClass: T) {
  return ChildClass extends ParentClass {
    method1() { /** 可以拿到 this */}
  };
}

使用方法

@addMethod
class C {}

// 这里new出来的实例是ChildClass的实例
(new C()).method1()

我们发现类装饰器和HOC的使用方式不谋而合。如果项目中支持装饰器语法,那么类装饰器和HOC用法一致(虽然Class的本质也是JS函数,但类装饰器只能作用于Class语法)。

高阶组件(HOC)是 React 中用于复用组件逻辑的一种高级技巧。HOC 自身不是 React API 的一部分,它是一种基于 React 的组合特性而形成的设计模式。高阶组件将组件(函数)作为参数,返回值为新组件的函数。

加载多语言包

既然类装饰器中返回的子类(新构造函数)可以替代父类(原来的构造函数),那么就可以在子类的实例(this)上挂载多语言包(可以把多语言包理解成一个JS文件,里面存放一个 Object 类型的值),并将该子类返回。

实现多语言加载器

type LocalePackage = Record<string, unknown>;

export function localeLoader<P, S>(locale: LocalePackage) {
  return function (Parent: React.ComponentClass<P, S>) {
    class Child extends Parent {
      public locale: LocalePackage;

      constructor(props: Readonly<P>) {
        super(props);
        this.locale = locale; // 把多语言包挂在子类的实例(this)上
      }
    }

    Child.displayName = Parent.displayName || Parent.name;

    return Child;
  };
}

实验多语言加载器

import React from 'react';
import { localeLoader } from './localeLoader';
import defaultLocale from './locale/zh_CN';

@localeLoader(defaultLocale)
class Hello extends React.Component {
  render() {
    return <div>{this.locale.hello}</div>;
  }
}

其中,

// ./locale/zh_CN.js 文件 

export default {
  hello: '您好',
};

测试一下:

describe('localeLoader', () => {
  it('测试多语言', () => {
    const component = shallow(<Hello />);

    console.log(component.debug());
  });
});

换一个语言包,

import React from 'react';
import { localeLoader } from './localeLoader';
import englishLocale from './locale/en_US';

@localeLoader<P, S>(englishLocale)
class Hello<P, S> extends React.Component<P, S> {
  render() {
    return <div>{this.locale.hello}</div>;
  }
}

其中,

// ./locale/en_US.js 文件 

export default {
  hello: 'greeting',
};

再测试一下,

✅ 多语言的雏形算是完成了...但是,每次更换多语言包还要手动更新,也不太方便了,接下来我们继续实现基于类装饰的多语言包切换功能。

多语言包切换

既然要求多语言文案可以更新,那么组件就必须重新渲染,此时我们一定会想到props 或者 state 更新后,React 组件才会重新渲染。同时我们考虑语言包应该属于组件内部属性,应有组件内部控制,调用方只需要告诉组件想用什么语言(通过 props 传参)即可,然后组件内部通过 setState 方法进行重新渲染。

定义多语言类型枚举

const enum LocaleEnum {
  EN_US = 'en_US',
  ZH_CN = 'zh_CN',
  ZH_TW = 'zh_TW',
}

定义多语言配置类型

type LocaleConfigs = {
    /** 默认的语言包数据,优先 */
    locale: LocalePackage
} & Partial<Record<LocaleEnum, LocalePackage>>;

实现可切换的多语言加载器

子组件在 props 上提供 lang 属性(组件内支持的多语言类型的枚举值),

export function localeLoader<
  P extends { lang: LocaleEnum },
  S extends { locale: LocalePackage }
>(localeConfig: LocaleConfig) {
  return function (ParentClass: React.ComponentClass<P, S>) {
    class ChildClass extends ParentClass {
      constructor(props: Readonly<P>) {
        super(props);

        this.state = {
          ...this.state,
          locale: localeConfig[props.lang] ?? localeConfig.locale,
        };
      }

      componentDidUpdate(prevProps: Readonly<P>, prevState: Readonly<S>, snapshot: unknown) {
        super.componentDidUpdate?.call(this, prevProps, prevState, snapshot);

        const { lang } = this.props;

        if (lang === prevProps.lang) {
          return;
        }

        this.loadLocale(lang);
      }

      loadLocale(lang: LocaleEnum) {
        this.setState({
          locale: localeConfig[lang] ?? localeConfig.locale,
        });
      }
    }

    ChildClass.displayName = ParentClass.displayName || ParentClass.name;
    ChildClass.defaultProps ??= {};
    ChildClass.defaultProps.lang ??= LocaleEnum.ZH_CN;

    return ChildClass;
  };
}

实验可切换的多语言加载器

import React from 'react';
import { localeLoader } from './localeLoader';
import defaultLocale from './locale/zh_CN';
import englishLocale from './locale/en_US';
import taiwanLocale from './locale/zh_TW';

@localeLoader({
  en_US: englishLocale,
  zh_TW: taiwanLocale,
  locale: defaultLocale,
})
class Hello extends React.Component {
  render() {
    const { locale } = this.state;

    return <div>{locale.hello}</div>;
  }
}

其中,

// ./locale/zh_TW.js 文件

export default {
  hello: '雷猴',
};

测试一下:

describe('src/js/decorator/localeLoader.ts', () => {
  it('测试多语言', () => {
    const component = shallow(<Hello />);
    console.log(component.debug());

    component.setProps({ lang: 'en_US' });
    console.log(component.debug());

    component.setProps({ lang: 'zh_TW' });
    console.log(component.debug());
  });
});

异步加载多语言包

作为汉语App,国内用户在大部分场景下还是选择汉语语言包,可能也不会切换英语或其他语言包,而语言包一般会比较大,所以我们一般会选择同步加载汉语语言包,异步加载其他语言的语言包。

更新多语言配置类型

// locale:同步加载;LocaleEnum:其他语言包异步加载

type LocaleConfig = {
  locale: LocalePackage;
} & Partial<Record<LocaleEnum, () => Promise<{ default: LocalePackage }>>>;

实现异步多语言加载器

export function localeLoader<
  P extends { lang: LocaleEnum },
  S extends { locale: LocalePackage }
>(localeConfig: LocaleConfig) {
  return function (ParentClass: React.ComponentClass<P, S>) {
    class ChildClass extends ParentClass {
      constructor(props: Readonly<P>) {
        super(props);

        this.state = {
          ...this.state,
          locale: localeConfig.locale,
        };
        
        if (props.lang !== LocaleEnum.ZH_CN) {
          this.loadLocale(props.lang);
        }
      }

      componentDidUpdate(prevProps: Readonly<P>, prevState: Readonly<S>, snapshot: unknown) {
        super.componentDidUpdate?.call(this, prevProps, prevState, snapshot);

        const { lang } = this.props;

        if (lang === prevProps.lang) {
          return;
        }

        this.loadLocale(lang);
      }

      loadLocale(lang: LocaleEnum) {
        if (lang === LocaleEnum.ZH_CN || !lang) {
          this.setState({
            locale: localeConfig.locale,
          });
          return;
        }

        localeConfig[lang]?.()
          .then(({ default: locale }) => {
            this.setState({ locale });
          })
          .catch((error) => {
            console.log('多语言加载失败', error);
          });
      }
    }

    ChildClass.displayName = ParentClass.displayName || ParentClass.name;
    ChildClass.defaultProps ??= {};
    ChildClass.defaultProps.lang ??= LocaleEnum.ZH_CN;

    return ChildClass;
  };
}

实验异步语言包加载器

import React from 'react';
import { localeLoader } from './localeLoader';
import defaultLocale from './locale/zh_CN';

@localeLoader({
  en_US: () => import('./locale/en_US'),
  zh_TW: () => import('./locale/zh_TW'),
  locale: defaultLocale,
})
class Hello extends React.Component {
  render() {
    const { locale } = this.state;

    return <div>{locale.hello}</div>;
  }
}

测试一下:

describe('localeLoader.ts', () => {
  it('测试多语言', async () => {
    const component = shallow(<Hello />);
    console.log(component.debug());

    component.setProps({ lang: 'zh_TW' });
    await new Promise(resolve => setTimeout(resolve));
    console.log(component.debug());

    component.setProps({ lang: 'en_US' });
    await new Promise(resolve => setTimeout(resolve));
    console.log(component.debug());
  });
});

~~~完美~~~