前情提要

一个月前,我基于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 的类型,向子组件传递包含但不限于泛型等信息或依赖