前情提要
一個月前,我基於React hook 實現前端組件的依賴注入,前文:useIoC:僅一百多行代碼實現前端的依賴注入。
同時也嘗試基於依賴注入實現一套UI庫,目的是驗證前端依賴注入的可行性,然後意外解鎖 React children 的全新用法:useIoC答疑 & 對children屬性的深入挖掘。
UI庫已在Github開源:https://github.com/zaoying/uikit
在造輪子的過程中,經過不斷嘗試又找到一些前有未見的最佳實踐,還封裝一些好用的工具。
下面逐個分享給大家
國際化:useI18n
業務經常會遇到頁面支持國際化的需求,然而組內對於國際化功能的實現卻非常簡單粗暴,直接在 assets 目錄新建 i18n 目錄,然後新增 zh-cn.json 或 en-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 ,並且指定 locale 為 en-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 組件的過程,發現設置 onConfirm 或 onCancel 回調時,總是會陷入無限循環的渲染
<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 組件的 onConfirm 和 onCancel 都是函數,連字段都沒有。
所以我想到自定義一個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的智能提示。
但是我很快發現 input 的 value 早就被限制得死死的: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>) {
}
細心的網友可能會發現,Table 和 TableColumn 竟然不需要 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 的類型,向子組件傳遞包括但不限於泛型等信息或依賴