useIoC 答疑
间隔上次发表 useIoC:仅一百多行代码实现前端的依赖注入 曾经过来一个多月,期间网友和我进行屡次深刻且敌对的互动。网友给我提了不少疑难和倡议,我通过一个多月的思考和实际,决定将这些疑难和倡议分享给大家。
1. 为什么不能照搬 Angular 的依赖注入?
Angular 依赖注入的对象是 实例 ,并且要求必须提供 无参数的构造函数。
这个要求和 React 对 Function Component
的定义产生十分重大抵触:
type FC<P = {}> = FunctionComponent<P>;
interface FunctionComponent<P = {}> {(props: P, context?: any): ReactNode;
propTypes?: WeakValidationMap<p> | undefined;
contextTypes?: ValidationMap<any> | undefined;
defaultProps?: Partial<any> | undefined;
displayName?: string | undefined;
}
强行照搬 Angular 的依赖注入,须要对现有代码进行重构,把入参全副去掉或改成可选,这显然不符合实际。
其次,略微具备亿点观察力的人,很容易发现 React 规定所有函数组件返回值必须是 ReactNode
。
而 ReactNode
的定义更是万金油,位置相当于 Java 的 Object
和 Go 的 interface{}
。
type ReactNode =
| ReactElement
| string
| number
| Iterable<reactnode>
| ReactPortal
| boolean
| null
| undefined
| DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_REACT_NODES[keyof DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_REACT_NODES];
设想一下,注入的实例类型都是 ReactNode,原来是想注入一个 Input,后果注入的是 Button。
当你无奈限度具体的注入变量的类型时,强行应用依赖注入只会产生更重大的结果!
2. 为什么依赖注入不间接应用 React Context,反而要从新造轮子?
简短地说,就是 React Context 并不好用,因为每个组件注入的依赖类型和数量都不雷同,相当于每个组件都须要从新定义一个 Context,操作起来十分麻烦。
但能够基于 React Context 实现一个 IoCContext
:
// 通过 React useContext 在组件树传递 ioc context
export const IoCContext = createContext<Context>(NewIoCContext())
// 从父级组件中获取 ioc context
export const useIoC = function(): Context {return useContext(IoCContext)
}
再通过 IoCContext
进行依赖注入,就不须要每个组件都创立新的 Context:
import {NewIoCContext, useIoC} from 'Com/app/hooks/ioc';
import {FC, ReactNode, useId} from 'react';
import {FormPropsDispatcher, InputType, NewFormController} from "./components/form/form";
const {define} = NewIoCContext()
export type LabelProps = {
label: ReactNode
for?: string
children?: ReactNode
}
export const GetLabelID = define(() => "")
export const Label: FC<labelprops> = define((props) => {const context = useIoC()
const id = useId()
context.define(GetLabelID, () => id)
return <>;
<label htmlfor={id}>{props.label}</label>
{props.children}
</>
})
export type InputProps = {
name: string
value?: InputType
type?: string
placeholder?: string
readonly?: boolean
onChange?: (val: InputType) => void
onBlur?: (e: any) => void
validate?: (val: InputType) => string
}
function transformValue(val: InputType) {if (val instanceof File) {return undefined}
return val
}
export const Input: FC<inputprops> = define((props) => {const context = useIoC()
const labelId = context.inject(GetLabelID)({})
return <input id={labelId} name={props.name} type={props.type} value={transformValue(props.value)} onblur={props.onBlur} placeholder={props.placeholder} readonly={props.readonly}/>
})
原来的
useIoC
被重命名为NewIoCContext
,起因是 React 规定所有自定义hook
都必须以useXXX
的形式命名,而 hook 只能在组件外部调用,但理论状况却是 NewIoCContext 并没有应用 React 官网的 hook,所以只能改名;而新的useIoC
因为外部调用useContext
是货真价实的hook
,但只能在组件外部被调用。
上述代码,次要是 Label
和 Input
两个组件,HTML5 标准要求 label 和 input 须要通过 id 绑定,所以 Label
脚印通过 useId
生成 id,而后通过 GetLabelID
注入 Input
组件中。
原本一切正常,但简直所有的依赖注入工具都仿佛建设在 单例模式
假如下,一旦遇到 单例模式
以外的场景就行不通,这其实也是为什么 Angular 早就反对依赖注入,但迟迟没能推广开来的起因之一。
以上面代码为例:
<Form action="">
<Label label="用户名">
<Input name="username" value="admin">
</Label>
<Label label="旧明码">
<Input name="oldPWD" type="password" validate="{checkPassword}">
</Label>
<Label label="新密码">
<Input name="newPWD" type="password" validate="{checkPassword}">
</Label>
<Label label="反复明码">
<Input name="repeat" type="password" validate="{checkPassword}">
</Label>
</Form>
上述代码,一共呈现四次 Label
组件,意味着注入四个不同 GetLabelID
函数,然而 React Fiber 并发可能使得四个组件更新的程序不一样,也就有可能导致 Input
组件被注入的依赖是随机,从而引发一系列的问题。
有位网友倡议给每个组件都套上一层 IoCContext.Provider
:
export const Label: FC<LabelProps> = define((props) => {const parent = useIoC()
const context = NewIoCContext(parent)
const id = useId()
context.define(GetLabelID, () => id)
return <>
<Label htmlfor={id}>{props.label}</Label>
<IoCContext.Provider value="{context}">
{props.children}
</Poccontext.Provider>
})
</labelprops>
这样的做法是临时解决问题,但并不是所有的场景都须要套上一层额定的 Provider。
只管在数据量少的时候能够手动套上,防止所有场景都套上的难堪情景;
但在数据量大的场景,比如说某个列表须要渲染上万个子项,手动套就十分麻烦,而且成为性能瓶颈。
对 children 属性的深刻开掘
相熟 Angular
或 Vue
的开发者都晓得有个 template
标签十分好用。
其实 React 也能实现相似的性能,函数式组件有一个很特地的属性 children
。
个别状况下,习惯把 children
的类型设置为 ReactNode
,这样就能够承受任意组件作为子组件,性能相似于 Vue
的动态 template
,毛病是没方法向子组件传递参数。
前面发现改成 Function Component
,也就是 FC
,就能够传递参数给子组件,起到相似于 Vue
的 scoped template
的作用:
export type LabelProps = {
label: ReactNode
children: FC<{id: string}>
}
export const Label: FC<LabelProps> = define((props) => {const id = useId()
return <>
<label htmlfor={id}>{props.label}</label>
{props.children({id})
}
</>
})
父级组件 Label
通过闭包的形式向子组件 Input
传递参数 id
:
<Form action="">
<Label label="用户名">
{({id}) => <Input id={id} name="username" value="admin">}
</Label>
<Label label="旧明码">
{({id}) => <input id={id} name="oldPWD" type="password" validate={checkPassword}>}
</Label>
<Label label="新密码">
{({id}) => <Input id={id} name="newPWD" type="password" validate={checkPassword}>}
</Label>
<Label label="反复明码">
{({id}) => <Input id={id} name="repeat" type="password" validate={checkPassword}>}
</label>
</Form>
这种形式,能够很好地解决 React Context 在 非单例模式
场景下,父级组件向子组件传递参数的问题。
React 官网对于 children
的相干应用始终十分含糊,以至于在开发过程中,常常受到编译器的错误信息误导;
别看当初的代码看起来很直观,但第一把就能写对的可能性很低,次要起因还是官网并没有思考开掘一下 children 的后劲。
大多数人一开始能写进去的代码:
<Label label="用户名">
<Input id={id} name="username" value="admin">
</Label>
而后编译报错提醒:变量 id
没有定义,而后只能把 id
提取进去:
const id = useId()
const field = <Label id={id} label="用户名">
<Input id={id} name="username" value="admin">
</Label>
然而这样的写法违反 迪米特里准则
,显然 id
只在 Label
和 Input
之间传递,但却裸露给更多的组件。
如果须要在同一个中央写很多 Label
和 Input
,那还得给 id
取别名能力防止互相烦扰。
异构 children
有不少的组件,须要传递不同的 children,但一个组件的只有一个 children
属性。
像 Vue
就提供了 named slot
的解决方案,但 React 没有,所以很多 React based
的 UI 库就常常这样写:
以标签页 Tab
为例,须要同时传递 TabHeader
和 TabContent
,并且还要求两者的长度和程序都完全一致:
<Tabs>
<TabList>
<Tab>One</tab>
<Tab>Two</tab>
<Tab>Three</tab>
</Tablist>
Tabpanels>
<TabPanel>
<p>one!</p>
</TabPanel>
<TabPanel>
<p>two!</p>
</TabPanel>
<TabpPnel>
<p>three!</p>
</TabpPnel>
</TabPanels>
</Tabs>
以上是 Chakra UI
的示例代码,这样写法不利于后续的保护,尤其是数据逐步减少后,常常会呈现遗记增加 TabHeader
或 TabContent
导致长度不统一,某个页签只有题目没有内容,又或者是有内容但标签为孔;而且必须要保障程序是正确的,否则会呈现标签和内容不统一的难堪局面!
面对这种问题,常见的解决办法,把数据提取到一个数组,而后通过数组的 map
函数一起映射。
惋惜,这样的做法又像后面的例子一样违反 迪米特里准则
。
以下是我的做法:
<Tab activetab="abc">
<Tabitem title="abc">123</tabitem>
<Tabitem title="def">456</tabitem>
<Tabitem title="ghi">789</tabitem>
</Tab>
title
是页签的题目,children
是页签的内容,TabItem
残缺代码如下:
export type TabItemProps = {
title: string,
closeable?: boolean,
children: ReactNode
}
export const TabItem: FC<TabItemProps> = define((tab) => {const context = useIoC()
const setTab = context.inject(TabPropsDispatcher)
useEffect(() => {
setTab(p => {if (p.tabs) {if (p.tabs.find(t => t.title == title)) {return p}
return {...p, tabs: [...p.tabs, tab], activeTab: p.activeTab ?? title}
}
return {...p, tabs: [tab]}
})
})
return <></>;
})
TabPropsDispatcher
是父组件 Tab
定义的,就是 const [props, setProps] = useState()
的 setProps
。
第一步是 Tab
组件定义 TabPropsDispatcher
注入到 TabItem
,
而后 TabItem
通过依赖注入取得 TabPorpsDispather
,
最初把当前页签的题目 title
和内容 content
注册到父级组件。
最初一步,要求
title
必须是惟一的,注册到父组件那一步呈现 在组件渲染的过程中应用 hook 批改属性,依照 React 官网的说法,这么做是会导致有限循环,所以注册到父组件的函数会判断一下title
是否已存在,如果已存在就进行批改属性,从而防止渲染陷入有限循环。
为了解决报错,注册组件必须被包裹在 useEffect
中运行,否则 React 会间接报错。
只有向父组件注册子组件的信息,Tab
组件能力正确的切换或敞开页签。
如果须要自定义标签页的款式,能够通过 define(TabHeader, () => < 自定义标签头 >)
,向 Tab 组件注入自定义的组件
更简单的例子
表格组件 Table
是前端 UI 不可或缺的重要组件,不少 React based
的 UI 库例如 antd
在封装 Table 组件的时候,因为不能应用 named slot
等起因,封装进去的 Table
组件在应用的过程中稍显啰嗦,头重脚轻:
const dataSource = [
{
key: '1',
name: '胡彦斌',
age: 32,
address: '西湖区湖底公园 1 号',
},
{
key: '2',
name: '胡彦祖',
age: 42,
address: '西湖区湖底公园 1 号',
},
];
const columns = [
{
title: '姓名',
dataIndex: 'name',
key: 'name',
},
{
title: '年龄',
dataIndex: 'age',
key: 'age',
},
{
title: '住址',
dataIndex: 'address',
key: 'address',
},
];
;
<Table datasource="{dataSource}" columns="{columns}"></Table>
上面通过把 children 类型改成 FC<{name: string, rowNum: number, data: any}>
,就能够让代码简洁不少:
<Table data="{[]}">
<Tablecolumn name="id" title="编号" width="{10}">
{({data}) => <input type="checkbox" name="ids" value={data.id}>}
</Tablecolumn>
<Tablecolumn name="name" title="名字" width="{40}">
{({data}) => data.name}
</Tablecolumn>
<Tablecolumn name="age" title="年龄" width="{20}">
{({data}) => data.age}
</Tablecolumn>
<Tablecolumn name="operation" title="操作" width="{20}">
{({rowNum}) => {const setTable = inject(TablePropsDispatcher)
const ctl =NewTableController(setTable)
return <button type="danger" onclick={()=> ctl.removeData(rowNum)}> 删除 </button>
}
}
</Tablecolumn>
</Table>
TableColumn
组件的title
类型是ReactNode
,这意味着你能够应用任何组件定制表格头。
基于 Vue
的 element-ui
也有相似的写法,能够点击 组件 | Element。
小结
通过将 children
类型改成 FC
的形式模仿 Vue 的 named slot
,不失为补救 React Context
不足之处的无效伎俩。
我集体比拟偏向于这样写前端代码:
<ParentComponent>
{
// 向子组件传递参数,不须要对外裸露任何外部属性,性能更内聚
(props) => <ChilrdComponent>{props.fromParent}</ChilrdComponent>
}
{
// 返回空的子组件,做一些带有副作用的操作:从远端服务获取数据
(props) => {useEffect(() => accessRemoteServer(props))
return <></>
}
}
</ParentComponent>
这样的代码看起来更直观易懂,因为所有与 ParentComponet
相干的代码都在 ParentComponent
组件外部,没有对外裸露任何的外部属性,使得组件的性能更加内聚,提供代码的健壮性。
总结
useIoC
和 IoCContext
是基于 React Context
的依赖注入工具,它克服了 React Context
须要针对每个组件的不同属性新增 Context
的毛病,使得各组件之间的耦合关系进一步升高,进步组件的可扩展性。
促使前端 UI 库或框架的代码程度更进一步,诸如 SOLID
等常见编码标准、编程模式,也能在前端落地。
而对 children: FC
的革新,使得功能模块更加内聚,与 useIoC
相互配合,就能够编写出 高内聚
、 低耦合
的代码。
适用范围比拟
useIoC
的长处是灵便,能够注入任意数量和类型的依赖,不毁坏组件的函数签名,适宜嵌套档次比拟深的组件
useIoC
的毛病源自于 React Context
,定义的依赖默认是全局只有一个实例,在多实例的状况须要减少 IoCContext.Provider
能力防止互相烦扰,在数据量比拟大的状况下容易呈现性能瓶颈。
children: FC
的长处是简略牢靠且容易保护,默认并发平安,性能开销少。
children: FC
的毛病是扩展性差,能传递的参数类型和数量比拟固定,想减少 / 批改属性会毁坏函数签名
把握 useIoC
和 children: FC
两种父组件向子组件传递参数的形式,并依据两者的适用范围灵活运用,就能够写出高质量的前端代码。
残缺的源代码在这里 zaoying/uikit (github.com)