乐趣区

关于react-hooks:useIoC答疑-对children属性的深入挖掘

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) =&gt; void
    onBlur?: (e: any) =&gt; void
    validate?: (val: InputType) =&gt; string
}

function transformValue(val: InputType)  {if (val instanceof File) {return undefined}
    return val
}

export const Input: FC<inputprops> = define((props) =&gt; {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,但只能在组件外部被调用。

上述代码,次要是 LabelInput 两个组件,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) =&gt; {const parent = useIoC()
    const context = NewIoCContext(parent)
    const id = useId()
    context.define(GetLabelID, () =&gt; id)
    
    return &lt;&gt;
        <Label htmlfor={id}>{props.label}</Label>
        <IoCContext.Provider value="{context}">
            {props.children}
        </Poccontext.Provider>
    
})
</labelprops>

这样的做法是临时解决问题,但并不是所有的场景都须要套上一层额定的 Provider。
只管在数据量少的时候能够手动套上,防止所有场景都套上的难堪情景;
但在数据量大的场景,比如说某个列表须要渲染上万个子项,手动套就十分麻烦,而且成为性能瓶颈。

对 children 属性的深刻开掘

相熟 AngularVue 的开发者都晓得有个 template 标签十分好用。

其实 React 也能实现相似的性能,函数式组件有一个很特地的属性 children

个别状况下,习惯把 children 的类型设置为 ReactNode,这样就能够承受任意组件作为子组件,性能相似于 Vue 的动态 template,毛病是没方法向子组件传递参数。

前面发现改成 Function Component,也就是 FC,就能够传递参数给子组件,起到相似于 Vuescoped 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 只在 LabelInput 之间传递,但却裸露给更多的组件。
如果须要在同一个中央写很多 LabelInput,那还得给 id 取别名能力防止互相烦扰。

异构 children

有不少的组件,须要传递不同的 children,但一个组件的只有一个 children 属性。

Vue 就提供了 named slot 的解决方案,但 React 没有,所以很多 React based 的 UI 库就常常这样写:

以标签页 Tab 为例,须要同时传递 TabHeaderTabContent,并且还要求两者的长度和程序都完全一致:

<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 的示例代码,这样写法不利于后续的保护,尤其是数据逐步减少后,常常会呈现遗记增加 TabHeaderTabContent 导致长度不统一,某个页签只有题目没有内容,又或者是有内容但标签为孔;而且必须要保障程序是正确的,否则会呈现标签和内容不统一的难堪局面!

面对这种问题,常见的解决办法,把数据提取到一个数组,而后通过数组的 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,这意味着你能够应用任何组件定制表格头。

基于 Vueelement-ui 也有相似的写法,能够点击 组件 | Element。

小结

通过将 children 类型改成 FC 的形式模仿 Vue 的 named slot,不失为补救 React Context 不足之处的无效伎俩。

我集体比拟偏向于这样写前端代码:

<ParentComponent>
  {
      // 向子组件传递参数,不须要对外裸露任何外部属性,性能更内聚
      (props) => <ChilrdComponent>{props.fromParent}</ChilrdComponent>
  }
  {
      // 返回空的子组件,做一些带有副作用的操作:从远端服务获取数据
      (props) => {useEffect(() => accessRemoteServer(props))
          return <></>
      }
  }
</ParentComponent>

这样的代码看起来更直观易懂,因为所有与 ParentComponet 相干的代码都在 ParentComponent 组件外部,没有对外裸露任何的外部属性,使得组件的性能更加内聚,提供代码的健壮性。

总结

useIoCIoCContext 是基于 React Context 的依赖注入工具,它克服了 React Context 须要针对每个组件的不同属性新增 Context 的毛病,使得各组件之间的耦合关系进一步升高,进步组件的可扩展性。

促使前端 UI 库或框架的代码程度更进一步,诸如 SOLID 等常见编码标准、编程模式,也能在前端落地。

而对 children: FC 的革新,使得功能模块更加内聚,与 useIoC 相互配合,就能够编写出 高内聚 低耦合 的代码。

适用范围比拟

useIoC 的长处是灵便,能够注入任意数量和类型的依赖,不毁坏组件的函数签名,适宜嵌套档次比拟深的组件

useIoC 的毛病源自于 React Context,定义的依赖默认是全局只有一个实例,在多实例的状况须要减少 IoCContext.Provider 能力防止互相烦扰,在数据量比拟大的状况下容易呈现性能瓶颈。

children: FC 的长处是简略牢靠且容易保护,默认并发平安,性能开销少。

children: FC 的毛病是扩展性差,能传递的参数类型和数量比拟固定,想减少 / 批改属性会毁坏函数签名

把握 useIoCchildren: FC 两种父组件向子组件传递参数的形式,并依据两者的适用范围灵活运用,就能够写出高质量的前端代码。

残缺的源代码在这里 zaoying/uikit (github.com)

退出移动版