博客 / 詳情

返回

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 的類型,向子組件傳遞包括但不限於泛型等信息或依賴
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.