从业务场景出发,搞明白组件间的数据交互可以怎么做

500 阅读5分钟

前端组件设计之数据交互

引言

关于自己在实际业务开发中,二次封装组件或编写复杂的业务组件时,所使用的几种组件数据交互方式,做一个总结,其他没有涉及到的,欢迎评论区补充。

TIPS: 文中所贴代码都是码字时顺便写的简单demo,不保证能跑通。代码皆为React + TS

在现代前端框架中,数据交互是构建复杂用户界面的关键。React框架的单向数据流为我们提供了一种清晰的数据传递模式:父组件定义数据源,并通过props将数据传递给子组件。这种模式确保了数据的一致性和可预测性,但在实际开发中,我们常常会遇到需要跨多层级组件共享数据或在包裹型组件内部传递数据的场景。本文将探讨几种常见的数据交互模式,并分析它们在不同场景下的适用性。

内部数据与方法的暴露

一般来说基本上都是根据React的单向数据流原则,父组件定义数据、回调方法,props传入子组件内部消费、数据变化时,触发回调更新数据。如下例子

const Child = (props) => {
  const { value, onChange } = props;

  return <>{value}</>;
};

const Parent = (props) => {
  const [value, setValue] = useState('');

  return <Child value={value} onChange={setValue} />;
};

但是在复杂组件树中,这种初步的实现就会出现一些弊端。比如

  1. 多层级跨组件需要消费该数据。
  2. 组件的所有子组件需要消费该数据。

以下讨论针对这两种场景进行。

常见场景分析

场景一:多层级组件间的数据共享

在多层级组件树中,如何高效地共享数据是一个挑战。例如,我们有一个组件链A > B > C > Comp,其中Comp是最底层的原子控件。如果需要在A或B组件中访问子组件的数据,我们可以考虑以下几种方案:

  1. Props透传:这是一种直接但成本较高的方法。需要在每个层级的组件中声明和传递props,这会导致组件间的耦合度增加。
  2. Context API:通过创建一个上下文环境,我们可以在组件树中的任何位置访问共享数据,而无需层层透传props。
  3. 状态管理库:如Redux或MobX,它们提供了更高级的数据管理能力,允许我们在组件间共享和响应式地更新状态。
  4. 中介模式:这是一种通过中介对象来获取和更新组件状态的方法,适用于需要在单一系统内多次使用同一组件的场景。

Context API

针对入参比较复杂的组件,即可先制定一个针对性的hook,即useCompProps,来提供组件所需要的数据,然后通过context将数据传递给子组件。

const useCompProps = (props) => {
  const [stateA, setStateA] = useState();
  const [stateB, setStateB] = useState();

  const onChange = (type, value) => {
    switch(type) {
      case A:
        setStateA(value);
        break;
      case B:
        setStateB(value);
        break;
      default:
        break;
    }
  }

  /** 根据Comp props类型进行定制返回 */
  const getCompProps = (extraProps = {}) => {
    return {
      a: stateA,
      b: stateB,
      onChange,
      ...extraProps
    }
  }

  return {
    a: stateA,
    b: stateB,
    onChange,
    getCompProps,
  };
}
// provider
import React, { Context } from 'react';

interface ICompContextValue {
  compAProps: ICompProps;
  compBProps: ICompProps;
  compCProps: ICompProps;
}

const CompContext = React.createContext<ICompContextValue>(null);

const CompContextProvider = (props: React.PropsWithChildren<ICompContextValue>) => {
  const { children, compAProps, compBProps, compCProps } = props;

  return (
    <CompContext.Provider value={{ compAProps, compBProps, compCProps }}>
      {children}
    </CompContext.Provider>
  )
}
/// business

const Parent = () => {
  const { getCompProps: getCompAProps } = useCompProps();
  const { getCompProps: getCompBProps } = useCompProps();
  const { getCompProps: getCompCProps } = useCompProps();

  return (
    <CompContextProvider
      compAProps={getCompProps()}
      compBProps={getCompBProps()}
      compCProps={getCompCProps()}
    >
      <A />
    </CompContextProvider>
  );
}

const A = () => {
  const { compAProps, compBProps, compCProps } = useContext(CompContext);

  // 消费子组件的数据
  const getLabel = () => {
    return (<p>{`${compAProps.a}${compBProps.a}${compCProps.a}`}</p>)
  }

  return (
    <div>
      {getLabel()}
      <B />
    </div>
  );
}

const B = () => {
  const { compAProps, compBProps, compCProps } = useContext(CompContext);

  // 消费子组件的数据
  const getLabel = () => {
    return (<p>{`${compAProps.b}${compBProps.b}${compCProps.b}`}</p>)
  }
  return (
    <div>
      {getLabel()}
      <C />
    </div>
  )
}

const C = () => {
  const { compAProps, compBProps, compCProps } = useContext(CompContext);

  return {
    <div>
      <Comp {...compAProps} />
      <Comp {...compBProps} />
      <Comp {...compCProps} />
    </div>
  }
}

这里的 context 解决的是 props 多层透传的问题。

状态管理库

采用redux/mobx类的状态管理。

还需要考虑组件Comp是否为多例模式,即在页面中需要同时存在多个不同的组件Comp,差异的点就是其数据。那么状态管理的实现方式也需改造成多例模式,可用一个map数据结构来存储数据,根据不同的组件标识符获取数据,也可以手动定义不同数据来管理,这也是使用状态管理库跟使用state需要考虑的点。

// mobx
class Store {
  mapCompData = {
    compAProps: {}, // 待具体完善
    // ... 更多的CompProps
  }

  constructor() {
    makeObservable(this, {
      mapCompData: observable,
      updateMapCompData: action
    })
  }

  updateMapCompData = (key, data) => {
    this.mapCompData[key] = data;
  }
}

export default new Store();

// observer包裹
const C = observer(() => {
  const { mapCompData } = store;
  const { compAProps, compBProps, compCProps } = mapCompData;
  
  return {
    <div>
      <Comp {...compAProps} />
      <Comp {...compBProps} />
      <Comp {...compCProps} />
    </div>
  }
})

// 要用observer包裹来监听mobx数据更新
const B = observer(() => {
  const { mapCompData } = store;
  const { compAProps, compBProps, compCProps } = mapCompData;

  // 消费子组件的数据
  const getLabel = () => {
    return (<p>{`${compAProps.b}${compBProps.b}${compCProps.b}`}</p>)
  }
  return (
    <div>
      {getLabel()}
      <C />
    </div>
  )
})

场景二:包裹型组件的数据管理

当组件需要包裹其他子控件时,我们可以借鉴一些流行的组件库的设计思路。例如,react-router-domRouter,UI库中的Tabs、List等组件也采用了类似的模式。

通过使用React的Context API,我们可以将数据传递给包裹型组件的子组件,从而实现跨组件的通信和数据共享。

举个表单组件的例子

import React from 'react';

interface IForm {
  setFieldValue: (field: string, value: any) => void;
  getFieldValue: (field: string) => any;
  validateField: (field: string) => void;
}

interface IFormContextValue {
  form: IForm;
}

const FormContext = React.createContext<IFormContextValue>(null);

const FormContextProvider = (
  props: React.PropsWithChildren<IFormContextValue>
) => {
  const { children, form } = props;

  return (
    <FormContext.Provider value={{ form }}>{children}</FormContext.Provider>
  );
};
const useForm = () => {
  // 虚拟实现,无实际意义
  const [form] = useState({
    setFieldValue: (field: string, value: any) => {},
    getFieldValue: (field: string) => {},
    validateField: (field: string) => {},
  });
  return form;
};

const useFormContext = () => {
  return useContext(FormContext);
};

const CustomForm = (props: { form?: IForm; children: React.ReactNode }) => {
  const { form: propsForm, children } = props;
  const innerForm = useForm();
  const form = propsForm || innerForm;
  return <FormContextProvider form={form}>{children}</FormContextProvider>;
};

const CustomFormField = () => {
  const { form } = useFormContext(); // 通过context获取form实例
  form.setFieldValue('name', ''); // 使用form实例进行相关操作
  return <div>test</div>;
};

const Business = () => {
  const form = useForm();
  return (
    <CustomForm form={form}>
      <CustomFormField />
    </CustomForm>
  );
};

这样CustomForm所有的子组件,都可以通过context去获取form实例,从而实现跨组件的通信,获取数据。

全局化配置的设置

在单一系统内,业务组件的输入参数应设定为全局统一的预设值。这可以通过以下几种方式实现:

  1. HOC(高阶组件):定义一个HOC来固定参数,使得组件内部无需改造,即可使用预设的全局参数。
  2. 借助第三方容器:组件内部可以进行一定的改造,支持外部固定以及动态参数传入。可以利用全局对象、容器或Context来实现这一点。

HOC

首先第一种就是,定义一个HOC来固定参数。

import React from 'react';

const withFixedProps = <Props extends Record<string, any>>(
  Comp: React.ComponentType<Props>,
  fixedProps: Partial<Props> = {}
) => {
  const EnhancedComponent = (props: Props) => {
    return <Comp {...props} {...fixedProps} />;
  };
  EnhancedComponent.displayName = `WithFixedProps${
    Comp.displayName || Comp.name || 'Unknown'
  }`;
  return EnhancedComponent;
};

// 定义新的组件暴露出去
export const NewComp = withFixedProps(Comp, { systemName: 'SystemA' });

const App = () => {
  return <NewComp />;
};

这种HOC优点就是适合所有类型的组件,组件内部无需改造,缺点就是包多了一层。

借助第三方容器

第二种就是组件内部进行一定的改造,支持外部固定以及动态参数传入。核心的思路就是借助第三方,可以是一个简单的全局对象,也可以是一个所谓的容器,也可以是React的Context。

举个简单的例子,比如

declare class Container {
    private dependencies;
    private config;
    constructor();
    setDependency(name: string, value: any): void;
    getDependency(name: string): any;
    getConfig<T extends ConfKey>(name: T): ConfValue<T>;
    setConfig<T extends ConfKey>(name: T, value: ConfValue<T>): void;
}
// 项目入口处使用

const CustomModal = (props) => {
  return <Modal {...props} okText="确定" cancelText="取消"></Modal>;
};

// 注入自定义Modal组件
container.setDependency('modal', CustomModal);
// 组件内部消费,获取动态依赖
const Modal = container.getDependency('modal') ?? DefaultModal;

这样就能实现注入参数等操作了。

也可以借助React Context来进行全局参数的配置,参考 UI组件库的 ConfigProvider 即可

interface ConfigContextValue {
  size?: 'small' | 'large';
}

export const ConfigContext = React.createContext<ConfigContextValue>(
  {} as ConfigContextValue
);

export const ConfigProvider = (
  props: React.PropsWithChildren<ConfigContextValue>
) => {
  const { children, ...restProps } = props;
  return (
    <ConfigContext.Provider value={...restProps}>
      {children}
    </ConfigContext.Provider>
  );
};

// 组件消费
const Card = (props) => {
  const config = useContext(ConfigContext);
  const size = config.size || props.size;
};

总结

在实际开发中,我们应根据具体的业务需求和场景,选择最合适的数据交互方式。无论是通过props、context、状态管理还是其他的方式,目标都是实现组件之间的有效通信,以及数据的正确传递和更新。通过借鉴其他优秀组件库的设计思想,我们可以找到适合自己项目场景的解决方案,从而构建出高效、可维护的前端应用。