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 ,但只能在组件内部被调用。

上述代码,主要是 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) => {
    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}
        </IoCContext.Provider>
    </>
})

这样的做法是暂时解决问题,但并不是所有的场景都需要套上一层额外的 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>
    <TabPanel>
      <p>three!</p>
    </TabPanel>
  </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 通过依赖注入获得 TabPropsDispatcher , 最后把当前页签的标题 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) => <ChildComponent>{props.fromParent}</ChildComponent>
  }
  {
      // 返回空的子组件,做一些带有副作用的操作:从远端服务获取数据
      (props) => {
          useEffect(() => accessRemoteServer(props))
          return <></>
      }
  }
</ParentComponent>

这样的代码看起来更直观易懂,因为所有与 ParentComponent 相关的代码都在 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)