乐趣区

关于前端:useI18n基于依赖注入实现国际化功能

前情提要

一个月前,我基于 React hook 实现前端组件的依赖注入,前文:useIoC:仅一百多行代码实现前端的依赖注入。

同时也尝试基于依赖注入实现一套 UI 库,目标是验证前端依赖注入的可行性,而后意外解锁 React children 的全新用法:useIoC 答疑 & 对 children 属性的深刻开掘。

UI 库已在 Github 开源:https://github.com/zaoying/uikit

在造轮子的过程中,通过一直尝试又找到一些前有未见的最佳实际,还封装一些好用的工具。

上面一一分享给大家

国际化:useI18n

业务常常会遇到页面反对国际化的需要,然而组内对于国际化性能的实现却非常简单粗犷,间接在 assets 目录新建 i18n 目录,而后新增 zh-cn.jsonen-use.json,而后往里面写对应的中文、英文,如下:

{
    "modal": {
        "confirm": "Confirm",
        "cancel": "Cancel"
    }
}

这样的做法一开始没啥问题,可随着页面的一直减少,慢慢地文件越来越大,有些翻译太长主动换行后就重大影响可读性。

除此之外,还会常常遇到遗记翻译的难堪情景;或者是文件太长,想找到对应的翻译十分困难。

更令人恶感的是,应用时还须要把 json 文件转换成 Object 对象,而后在应用 TranslateService 翻译一遍,这样的做法切实是过于鸡肋和繁琐。

我在外网以 react i18n 搜寻一番,发现有个比拟受欢迎的我的项目 react-i18next,官网的代码演示如下:

<div>{t('simpleContent')}</div>
<Trans i18nKey="userMessagesUnread" count={count}>
  Hello <strong title={t('nameTitle')}>{{name}}</strong>, you have {{count}} unread message(s). <Link to="/msgs">Go to messages</Link>.
</Trans>

呃。。。说实话,这个我的项目的做法和组内大差不差,程度都是一丘之貉。

作为一个七年教训的开发,背过的八股文不可胜数,我很天然就想到通过依赖注入的形式实现国际化性能,并且天经地义地认为业界应该有很多的优良实际,而后事实却狠狠地打我的脸。

为什么当初前端连个优雅的国际化工具都找不到?

难道那些在面试的时候,靠八股文尴尬他人的“高手”,预计也是死记硬背、冒名顶替的水货,目标仅仅就是为了 PUA 老实人?

吐槽结束,回到正题,我的解决思路是依据不同 locale 别离创立独立的 context,而后通过 navigator.locales 获取对应的 context

首先,先定义一个词条 ModalDict,并且指定 localeen-us

export const ModalDict = i18n("en-us", () => {
    return {
        confirm: "Confirm",
        cancel: "Cancel"
    }
})

接着通过 register 函数,给特定的 locale 注册翻译:

register("zh-cn", (locale) => {locale.define(ModalDict, () => ({confirm: "确定", cancel: "勾销"}))
})

最初通过 useI18n 注入到组件中,通过 dict.key 的形式拜访属性有语法提醒,能够无效渐少通过 translate(key) 传入字符串时因为拼写错误而导致翻译失败的难堪局面,更符合人体工程学。

export type FooterProps = {
    confirm?: ReactNode
    cancel?: ReactNode
    onConfirm?: () => boolean,
    onCancel?: () => boolean}

export const Footer: FC<FooterProps> = (props) => {const dict = useI18n(ModalDict)({})

    return <div className="two buttons">
        <Button type="primary" onClick={props.onConfirm}>
            {dict.confirm}
        </Button>
        <Button type="grey" onClick={props.onCancel}>
            {dict.cancel}
        </Button>
    </div>
}

仔细的你可能留神到,ModalDict 是个无参数的函数。为什么不间接用 Object 呢?

应用函数的长处是能够同时反对依据不同的 locale 注入不同的国际化组件,而不局限于文字信息。

举个例子,有个需要须要你依据不同地区输入不同的日期:大中华地区就按年月日输入,而欧美地区的就须要反过来,依照日月年的程序输入;不仅“年”、“月”、“日”要被翻译成 “Year” / “Month” / “Day”,而且程序还刚好相同。

这种状况,如果还是逐字翻译,必然要写一大堆的 if else,还不如间接依据 locale 注入不同的组件,代码实现起来更简洁易懂。

接下来,给大家看看 useI18n 的具体实现:

import {Context, Func, NewIoCContext} from "./ioc"

const GlobalDict = new Map<string, any>()

/**
 * 批量注册国际化组件
 * @param locale 地区
 * @param callback 用于注册组件的回调函数
 */
export function register(locale: string, callback: (locale: Context) => void) {const key = new Intl.Locale(locale).toString()
    const localizeCtx = GlobalDict.get(key) ?? NewIoCContext()
    callback(localizeCtx)
    GlobalDict.set(key, localizeCtx)
}

/**
 * 依据不同的地区获取组件的上下文
 * @param locale 地区
 * @returns 组件的上下文
 */
export function get(locale: Intl.Locale) {const key = locale.toString()
    return GlobalDict.get(key) ?? NewIoCContext()}

/**
 * 注册单个国际化组件
 * @param locale 地区
 * @param component 组件
 * @returns 返回带有 componentId 的组件
 */
export function i18n<I, O>(locale: string, component: Func<I, O>): Func<I, O> {const canonical = new Intl.Locale(locale)
    const localizeCtx = get(canonical)
    return localizeCtx.define(component)
}

// 默认地区
let defaultLocale: Intl.Locale = new Intl.Locale("en-us")

/**
 * 初始化默认地区
 */
export function initLocale() {const locale = navigator.languages?.length ? navigator.languages[0] : navigator.language;
    defaultLocale = new Intl.Locale(locale)
}

/**
 * 手动设置默认地区
 * @param locale 地区
 */
export function setLocale(locale: string) {defaultLocale = new Intl.Locale(locale)
}

/**
 * 依据默认地区获取国际化组件
 * @param component 国际化组件 
 * @returns 默认地区的国际化组件
 */
export function useI18n<I, O>(component: Func<I, O>): Func<I,O> {const localizeCtx = get(defaultLocale)
    return localizeCtx.inject(component)
}

算上正文总共才 60 多行代码,就能优雅地实现根本的国际化性能,这就是依赖注入的便利性。

组件之间共享状态:WithState

应用 React 搞前端开发,常常会遇到组件之间须要共享状态的场景,比方:填写地址时省份和地级市的联动、填写日期时年月日之间的联动等等,而后就参考 React 官网文档,将状态通过 useState 的形式将变量晋升到父级组件。

一次偶尔的机会,我在编写 Stepper 组件时,轻易点点一个联动组件,后果 Stepper 组件意外从新渲染了,如果不是 Stepper 组件从新渲染数据超级加倍了,我甚至都没意识到这个问题。

export default function Home() {const [state, useState] = useState("bottom")
    return <div>
        <Tooltip message="一般按钮" direction={state}>
            <Button> 一般按钮 </Button>
        </Tooltip>
        <Dropdown trigger="click">
            <Button type="grey"> 请抉择方向 <i className="icon">﹀</i></Button>
            <a key="top" onClick={()=> setState("top")}> 上 </a>
            <a key="bottom"  onClick={()=> setState("bottom")}> 下 </a>
            <a key="left"  onClick={()=> setState("left")}> 左 </a>
            <a key="right"  onClick={()=> setState("right")}> 右 </a>
        </Dropdown>
        
        <Stepper></Stepper>
    </div>
}

从源码来看,联动组件跟 Stepper 组件之间毫无瓜葛,仅仅是因为两者在同一个父组件,而后联动组件批改 state 就会触发父级组件下所有的子组件全副刷新,这切实太离谱!

而后我就尝试把联动组件提取进去独自封装一个组件:ButtonAndDropdown,看这简短的名字就晓得违反了繁多准则。

然而当我尝试通过 Closure 闭包,把 useState 包裹起来时,却被 React 正告,不能在闭包内应用 hook;因而我只能封装一个 hook,然而还是没用,因为没有闭包无奈限度 state 的作用域,闭包又不能应用 hook。

我只能退而求其次,把自定义 hook 革新成一个组件,后果出乎意外的好:

<WithState state={"bottom" as Direction}>{({state, setState}) => <>
        <Tooltip message="一般按钮" direction={state}>
            <Button> 一般按钮 </Button>
        </Tooltip>
        <Dropdown trigger="click">
            <Button type="grey"> 请抉择方向 <i className="icon">﹀</i></Button>
            <a key="top" onClick={()=> setState("top")}> 上 </a>
            <a key="bottom"  onClick={()=> setState("bottom")}> 下 </a>
            <a key="left"  onClick={()=> setState("left")}> 左 </a>
            <a key="right"  onClick={()=> setState("right")}> 右 </a>
        </Dropdown>
    </>
}</WithState>

无论是从代码实现,还是实际效果,看起来跟应用闭包毫无差异。要害是 WithState 组件的实现也很简略:

export type StateProps<S> = {
    state: S
    children: FC<{state: S, setState: Dispatch<SetStateAction<S>>}>
}

export function WithState<S>(props: StateProps<S>) {const [state, setState] = useState(props.state)
    return props.children({state, setState})
}

因而,我也学到一个技巧:当自定义 hook 不好使的时候,能够试试用组件包裹起来

简略一个 WithState 组件,却能够解决困扰许久的问题,能够将组件之间的状态包裹起来,防止影响到其余的组件。

我的集体认识:React 官网应该提供如此简洁无效的计划,而不是推广那种反模式的做法。

避免有限渲染:Once

在封装 Modal 组件的过程,发现设置 onConfirmonCancel 回调时,总是会陷入有限循环的渲染

<Modal width={360} title="批改用户材料">{({ctl, ctx}) => {ctl.onConfirm(() => {const setForm = ctx.inject(FormPropsDispatcher)
            const formCtl = NewFormController(setForm)
            const formRef = ctx.inject(FormReference)({})
            formCtl.validate(formRef);
            return true
        })
        return <Button onClick={ctl.open}>
            <span><i>🎨</i> 关上模态框 </span>
        </Button>
    }
}</Modal>

之前封装的组件也遇到相似的问题,但之前的组件是通过判断某个字段是否已存在。

如果该字段曾经存在,就不批改任何 state,从而阻断有限循环。

但这就要求 state 必须具备某个惟一的字段。

然而 Modal 组件的 onConfirmonCancel 都是函数,连字段都没有。

所以我想到自定义一个 hook:useOnce,然而还是遇到闭包不能应用 hook 的限度,所以我又把它革新成一个组件:

import {FC, ReactNode, useEffect, useState} from "react"

export type OnceProps = {children: FC}

export function Once(props: OnceProps) {const [children, setChildren] = useState<ReactNode>()
    useEffect(() => {if (!children) setChildren(props.children({}))
    }, [children, props])
    return <>
        {children}
    </>
}

而后给 Modal 组件用上:

export const Modal: FC<ModalProps> = (old) => {const [props, setProps] = useState<ModalProps>(old)
    const context = useIoC()
    context.define(ModalPropsDispatcher, setProps)

    const className = props.className ?? "modal"
    return <>
        <Once>{() => props.children && props.children({
                ctx: context,
                ctl: NewModalController(setProps)
            })
        }</Once>
        <div className={`dimmer ${props.visible ? "show" : ""}`}>
        <div className={className} style={{width: props.width, height: props.height}}>
                <div className="header">{context.inject(Header)(props)}</div>
                <div className="body">
                    {context.inject(Body)(props)}
                </div>
                <div className="center footer">
                    {context.inject(Footer)(props)}
                </div>
        </div>
    </div>
    </>
}

套上 Once 组件后,成果的确如我所料,终于不会再陷入有限循环渲染。

在编写 Stepper 组件的时候,还是发现一些问题:尽管 Once 的确让以后组件只渲染一次。

但在某些非凡场景,子组件还是屡次渲染,只不过不会陷入有限循环。

所以这就要子组件必须满足 幂等 ,简略来说就是不论执行多少次的后果,都第一次执行的后果统一。

Table 组件为例,无论反复调用 setData 多少次,后果都是一样的:

<Table data={new Array<{id: number, name: string, age: number}>()}>{({ctl}) => {return <TableColumn name="id" title={<input type="checkbox" name="ids" value="*"/>} width={10}>
            {({data}) => {
                ctl.setData([{id: 0, name: "张三", age: 35}, 
                    {id: 1, name: "李四", age: 28},
                    {id: 2, name: "王五", age: 40}
                ])
                return <input type="checkbox" name="ids" value={data.id}/>
            }
        </TableColumn>
    }
}</Table>

但调用 appendData 则不同,反复执行 n 次 appendData,数据就是会反复 n 次。

Stepper 组件的子组件 StepperItem 就存在这样的问题,解决办法就是通过 useId 生成惟一 ID 去重。

对于没有惟一字段的状况,能够通过 useId 生成惟一字段进行去重。

泛型化组件

在开发 Form 组件时,已经想过给组件加上泛型,如此在应用的时候还能享受 IDE 的智能提醒。

然而我很快发现 inputvalue 早就被限度得死死的:string | ReadonlyArray<string> | number | undefined

而且我也发现闭包模式的 function(){} 以及 箭头函数模式 () => {} 都不反对泛型。

要应用泛型,只能老老实实地回到惯例的函数定义:function Xxx() {}

所以我就把 Table 组件改成最常见的函数定义:

export function TableColumn<T>(props: TableColumnProps<T>) {const context = useIoC()
    const setProps = context.inject(TablePropsDispatcher)
    const ctl = NewTableController<T>(setProps)
    useEffect(() => ctl.insert(props))
    return <></>
}

export function Table<T>(old: TableProps<T>) {}

仔细的网友可能会发现,TableTableColumn 居然不须要 define 函数包裹一层,那还能应用依赖注入吗?

答案就是丝毫不影响,因为我参考 React 官网对 FunctionComponent 的定义,重构 Func 的定义:

// 组件的构造函数定义
export interface Func<I, O> {(props: I, context?: Context): O;
    componentId?: string
    displayName?: string | undefined;
    originFunction?: Func<I, O>
}

这样做的益处有很多:

首先,React 在打印谬误日志时能正确显示组件名称,而不是千篇一律的 wrapper

其次,所有的函数式组件都不须要通过 define 函数包裹,调用方能够间接通过 inject 进行依赖注入。

最初,被调用方还是通过 define(component, subType) 注入不同的子类型。

在应用的时候,泛型化的组件跟一般的组件简直没有区别:

<Table data={new Array<{id: number, name: string, age: number}>()}>{({ctl, Column}) => {
        ctl.setData({id: 0, name: "张三", age: 35}, 
            {id: 1, name: "李四", age: 28},
            {id: 2, name: "王五", age: 40}
        )
        return <Column name="id" title={<input type="checkbox" name="ids" value="*"/>} width={10}>
            {({data}) => <input type="checkbox" name="ids" value={data.id}/>}
        </Column>
    }
</Table>

惟一须要留神的是,TableColumn 被改成 Column,起因是 Table 父组件并不能把具体的泛型 T 穿给子组件 TableColumn

而且 TableColumn<T><T>JSX/TSX 语法抵触,所以只能通过以下的形式:

export type TableArgs<T> = {
    ctx: Context
    ctl: TableController<T>
    Column: FC<TableColumnProps<T>>
}

export type TableProps<T> = {data: T[]
    children: FC<TableArgs<T>>
}

export function Table<T>(old: TableProps<T>) {const [props, setProps] = useState<TP<T>>({data: old.data, columns: []})
    const context = useIoC()
    context.define(TablePropsDispatcher, setProps)

    const tabHeader = context.inject(TableHeader<T>)
    const tabBody = context.inject(TableBody<T>)
    return <div className="table">
        <table>
            {tabHeader(props)}
            {tabBody(props)}
        </table>
        <div className="footer">
            <Once>{() => old.children({
                    ctx: context,
                    ctl: NewTableController<T>(setProps),
                    Column: TableColumn<T>
                })
            }</Once>
        </div>
    </div>
}

这里的 Column 就等价于 TableColumn<T>

这种做法其实解锁一个前所未有的用法,既然能够通过闭包向子类型传递泛型 T,天然也能够传递更多的依赖。

这种传递依赖的形式,比依赖注入更高效,而且直观易懂,并非没有对外裸露外部的属性。。

总结

  • 通过依赖注入的形式来实现国际化性能个性,高效又优雅
  • 将 useState 封装成 WithState 组件,能够优雅实现组件之间的共享状态
  • 将 useEffect 封装成 Once 组件,能够确保 effect 只执行一次
  • 子组件如果无奈满足唯一性的要求,能够通过 useId 生成惟一 ID
  • 父级组件能够通过批改 children 的类型,向子组件传递包含但不限于泛型等信息或依赖
退出移动版