最佳实践: react 组件封装 类似于 react-flow 源码

1,590 阅读6分钟

契子

编码能力谁都会,重要的是,如何写出好的代码

写代码和写好的代码,不是同一个东西,这就是最佳实践的意义

最佳实践 === 好的思路, 好的思路已经成功了一半

更新: 基于此理论,落地了一个react-fabric的库,已在生产环境使用 github.com/cs-open/rea…

正文

要实现的效果,参考react-flow, 我给你 ReactFlow 组件的时候,会顺便给你一个对应的Provider组件,

优点:

  1. 你需要访问ReactFlow组件内部数据的时候, 会很方便, 只需要把Provider组件套在两个组件的公共外层即可,代码相当干净
  2. 解决非受控场景,通过ref获取数据,ref数据不全的问题,比如antd 不支持受控多列排序,因为受控非受控属性都整理到一处,所以你可以访问任意时刻的快照状态
  3. 细细品吧, 对代码架构有兴趣的,可以直接看react-flow 源码
import ReactFlow, { ReactFlowProvider, useNodes } from 'reactflow'
 
export default function Flow() {
  return (
    <ReactFlowProvider>
      <ReactFlow nodes={...} edges={...} />
      <Sidebar />
    </ReactFlowProvider>
  )
}
 
function Sidebar() {
  // This hook will only work if the component it's used in is a child of a
  // <ReactFlowProvider />.
  const nodes = useNodes()
 
  return (
    <aside>
      {nodes.map((node) => (
        <div key={node.id}>
          Node {node.id} -
            x: {node.position.x.toFixed(2)},
            y: {node.position.y.toFixed(2)}
        </div>
      ))}
    </aside>
  )
}

最佳实践: stackblitz.com/edit/vitejs…

一 什么是好的组件?

  1. 支持组件之外访问内部状态, 不用ref
  2. 处理受控非受控模式, 解决掉 useUncontrolled 不支持队列的问题
  3. 复合组件的设计模式
  4. 支持多例单例模式
  5. 组件提供的插槽内部可以通过useStoreapi 访问和修改内部数据,而不仅仅只能读取页面上的受控状态(想想你antd是不是这样)

image.png

复杂的业务需求, 必须要有足够优秀的技术架构来支持,而技术架构,针对普通前端来说,组件就是最小单元, 如果组件写的不好,那么其他人根本无法复用你的组件,也无法扩展, 这就是很多东西屎山的由来

行文过程中, 我会列举一些项目中遇到的复杂需求,来佐证这种方案,也欢迎诸位提出更多的好问题:

假设有这么一个组件结构

<A>
 <B>
  <Table>
  </Table>
 </B>
</A>

受控模式 vs 非受控模式?

受控模式: 有回显数据的需求

非受控模式: 为了保证性能和调用方更少的维护props

非受控模式下, 一般搭配ref使用, 但是内部组件状态变更要通知外部组件怎么办?

  • 维护两份数据源, 比如添加一个状态管理库, 更新时同步更新另一个数据源
    • 优点: 几乎不用改动原代码
    • 缺点: 需保证两份数据同步,心智负担大, 而且有些边界情况很容易出问题
  • 要么改为受控模式

所以, 好的组件应该考虑到不同的使用情况, 一开始既要支持受控也要支持非受控, 如何整合呢? 查看各大组件库源码,可以发现都使用一个类似的 use-uncontrolled

 const [_value, handleChange] = useUncontrolled({
    value,
    defaultValue,
    finalValue: 'Final',
    onChange,
  });

但是我不推荐使用他们提供的此类hook, 它无法在复杂业务下工作, 已知的缺点:

  • 不支持回调函数,无法满足同时修改多状态的场景
<Table onChange={handleChange} />

const Table = (props)=>{
  const {obj} = props // obj:{country:string, city:string}
  const onCountryChange = ()=>{
  // bug写法
  // 多次setState, 这里将导致country更新失败
  props.onChange({...obj, country:''})
  props.onChange({...obj, city:''})
 
   // 正确写法 需要使用回调函数的形式
   props.onChange(prev=> {...prev,country:''})
   props.onChange(prev=> {...prev,city:''})
  }
}
  • 无法拦截转换数据

以下需求很常见, 但是写法是不支持的

const [state,setState] = useUncontrolled('')
// 比如说返回端返回的是'a,b,c'之类的格式, 但是组件需要的是['a','b','c']

const toArray = (str)=> str.split(',')
const toString = (array)=> array.join('')
return <Table value={toArray(state)}  onChange={ value=> setState(toString(value))}/>
  • 不止mantine, 几乎业界所有的此类hook都存在以上问题

如何解决? 越简单越好

const Table =(props)=>{
  const [state,setState] = useState(props.defaultValue)
  useeffect(()=>{
    setState(props.value)
  },[props.value])
}

但是 每个组件都要写这些东西,很不优雅,追求完美,如何优化?

既然hook不支持, 那就封装到一个 StoreUpdater 组件里, 当然这组件还有更妙的用处, 后面会提到

export const StoreUpdater = (props) => {
    const { value, defaultValue,  onChange} = props
  
    const [state,setState] = useState(props.defaultValue)
    useEffect(()=>{
      setState(props.value)
    },[props.value])

  return null;
};

<StoreUpdater value defaultValue onChange/>

支持插槽访问内部数据

看一个antd的例子, dropdownRender 就是插槽, 缺点:

  • 插槽内部仅能读menu数据,不能修改内部更多的数据

  • 假设需要拿到 search 筛选后的列表, ref也无法拿到,

    • 一是排序结果antd没有放在ref上
    • 二是我们在哪里调用ref呢?
    • 在 dropdownRender? 还是在downDoenOpen? 能保证cover住所有筛选列表变更的情况吗?

    幸亏searchvalue是一个受控属性,我们可以手动执行一遍排序, 但是我们的排序逻辑敢保证和黑盒内部的一致? 得看源码了

    假如你需要的数据, 不支持受控模式那直接完蛋, 比如 antd table ,只支持单列排序受控, 不支持多列排序受控, 再次刷新页面时, 不支持回显

<Select
      style={{ width: 300 }}
      placeholder="custom dropdown render"
      dropdownRender={(menu) => (

如何解决?

Select内部 使用Provider包裹Select, 提供一个useStore的 hook给用户, 在插槽里访问内部所有状态

此方案如何支持受控模式呢?

<Wrapper value={value}>
        {children}
        <Row />
</Wrapper>

export const Wrapper = ({
  children,
  value,
  defaultValue,
}: {
  defaultValue?: any;
  value?: any;
  children: ReactNode;
}) => {
  const isWrapped = useContext(StoreContext);
  if (isWrapped) {
    return <>{children}</>; // 做判断
  }
  return (
    <MyProvider defaultValue={defaultValue} value={value}>
      {children}
    </MyProvider>
  );
};

还有一个问题, 参看各组件库, 使用Provider时,就需要提供value数据,这有点问题

缺点:

  • provider太顶了, 使用时, 根本没有table需要的数据
  • table需要的数据太多了, 从table传参改到provider传参,这太奇怪了

优化: 组件内部调用useStore设置数据到provider

页面任意位置访问组件内部数据

假设你还没看这篇文章, 想象一下, 某一个角落的按钮需要访问table的数据, 怎么办?

  • ref 这需要在顶部某个组件管理ref通信, 而且智能访问,不能监听,ref的通病
  • 状态提升, 这也是官方推荐, 需要把table中该字段提到table和button的最外层,缺点:
    • 改动量太大
    • 敢保证数据没有依赖吗?
    • 迁移之后谁来测试?
    • 以后需要更多的字段,怎么办?
  • 外部 store, 维护两份数据源
    • 时间紧急的时候的无奈选择

你写的react组件, 或者ui组件库提供的组件, 要做到这件事, 都是不容易的

回到本文的解决办法, 只需要简单的把提供给用户的 Provider 组件放到Table的Button的最外层即可, 甚至你可以包装在多个路由外面, 跨页面做一些事情