React 练习:实现一个 SelectPanel 组件

95 阅读3分钟

在写一个 React 小组件的时候,发现里面涉及到多个原理相关的知识点,分享一下思路,感觉对 React 新手会有些启发,看看你能理解这其中涉及到的知识点吗。

目标

工作中,需要做一个选项组件,它和Arco Select 组件的弹出框内容一样。唯一的不同是,我希望不用点击弹出,直接就在呈现在页面上,供用户进行选择。

image.png

于是,我希望复用 Select 组件,基于它实现一个 SelecPanel 组件。

实际做的时候,发现这个问题并不简单,中间尝试过没有做出来,到最近才有思路。

难点

组件没有提供这么使用的 API,无法通过传递 props 达到我的目的。

接近的 props 是 triggerElement,能够自定义触发节点,见文档案例。但尝试了 triggerElement={null},无效,翻了源码,确实也不支持这么用。

目光又停留在 dropdownRender:(menu: ReactNode) => ReactNode,参数 menu 就是下拉面板节点,既然这样,如果我拿到之后,把它返回,是不是就能渲染出来了。

尝试1

创建一个变量 panel,在dropdownRender执行时,把 menu 赋值给 panel

function SelectPanel(props){
  let panel;

  const select = <Select {...props} dropdownRender={renderDropdown} />

  function renderDropdown(node){
    panel=node;
    return node
  }

  return <div>{panel}</div>
}

这能行吗?为什么?

当然不行,这么写,SelectPanel 组件没有返回 <Select ... />Select 组件函数甚至都没有执行,renderDropdown 自然也不会执行。

尝试2

那就返回 <Select ... /> 试试。

function SelectPanel(props){
  let panel;

  const select = <Select {...props} dropdownRender={renderDropdown} />

  function renderDropdown(node){
    panel=node;
    return node
  }

  return <div>
    {select}  {/* 先不隐藏,看看效果 */}
     {panel}
  </div>
}

结果,下拉框组件渲染了,但是 panel 没有渲染出任何东西。这又是为什么?

原因在于执行顺序:

  1. SelectPanel()
  2. Select(),内部调用 renderDropdown()

React 执行 SelectPanel(props) 时,获得 panel,但此时 panel 未赋值,React 认为它应该渲染为空,当后面赋值后,React 并不会感知更新。

(如果你不理解这个执行顺序,说明你该补补 React 的渲染相关内容了)

尝试3

有办法延迟 panel 的渲染吗?

有的,如果能让一个比 <Select/> 更晚渲染的组件去渲染 panel,就可以了。怎么做呢?请看。

function SelectPanel(props){
  let panel;

  const select = <Select {...props} dropdownRender={renderDropdown} />

  function renderDropdown(node){
    panel=node;
    return node
  }

  return <div>
    {select}  {/* 先不隐藏 */}
    <Slot render={ ()=>panel } />
  </div>
}

function Slot({render}){
  return <>{render()}</>
}

很好,已经出现内容了,不过样式还有点问题,处理一下样式,同时把下拉框组件隐藏起来。

function SelectPanel(props){
  let panel;

  const select = <Select {...props} dropdownRender={renderDropdown} style={{display:'none'}} />

  function renderDropdown(node){
    panel=node;
    return node
  }

  return <div>
    {select}  
    <Slot 
    render={()=>
      <div 
        className="arco-select-popup" 
        style={{boxShadow:'none'}}>
          {panel}
      </div>
    } 
    />
  </div>
}

function Slot({render}){
  return <>{render()}</>
}

看起来完美,试用了一下,发现没有 hover 和选中的样式。

不难发现,虽然渲染出来了,但组件无法正常更新。这是因为,Select 内部触发更新时,并不会触发父组件 SelectPanel 的更新,那 panel自然也无法更新。

解决组件更新

上面这个问题很简单,当 renderDropdown 执行的时候,通知父组件更新。

function SelectPanel(props){

  // 这个状态用于触发组件更新
  const [,setUpdateFlag]=React.useState(false);
  const update = ()=>setUpdateFlag(v=>!v);

  let panel;

  const select = <Select {...props} dropdownRender={renderDropdown} style={{display:'none'}} />

  function renderDropdown(node){
    panel=node;
    // setTimout 解决 Cannot update a component (`SelectPanel`) while rendering a different component (`Trigger`).
    setTimeout(update)    
    return node
  }

  return <div>
    {select}  
    <Slot 
    render={()=>
      <div 
        className="arco-select-popup" 
        style={{boxShadow:'none'}}>
          {panel}
      </div>
    } 
    />
  </div>
}

如果你这么写,那你的组件就炸了,陷入死循环了:renderDropdown => update() => SelectPanel => Select => renderDrowpdown => ...

解决更新循环

这个循环能解吗?能的,用 React.memo

const MemoedSelect = React.memo(
  Select,
  (props,nextProps)=>_.isEqual(_.omit(props,'dropdownRender'),_.omit(nextProps,'dropdownRender'))
)
//...
// SelectPanel 中,<Select ... /> 改成 <MemoedSelect .... />

这样一改,发现组件又出不来了...

分析原因,update 触发更新后,panel 变量重新创建为 undifinedSelect 组件被 memo 阻止更新,Slot 渲染了 undifined

解决引用丢失

这个问题比较简单,用 useRef 保存面板节点即可。

最终结果


const MemoedSelect = React.memo(
  Select,
  (props,nextProps)=>_.isEqual(_.omit(props,'dropdownRender'),_.omit(nextProps,'dropdownRender'))
)

function SelectPanel(props){

  const [,setUpdateFlag]=React.useState(false);
  const update = ()=>setUpdateFlag(v=>!v);

  const panelRef=React.useRef();

  const select = <MemoedSelect {...props} dropdownRender={renderDropdown} style={{display:'none'}} />

  function renderDropdown(node){
    panelRef.current=node;
    setTimeout(update)   
    return node
  }

  return <div>
    {select}  
    <Slot 
    render={()=>
      <div 
        className="arco-select-popup" 
        style={{boxShadow:'none'}}>
          {panelRef.current}
      </div>
    } 
    />
  </div>
}

function Slot({render}){
  return <>{render()}</>
}

大功告成,这个小玩意,包含了许多 React 的原理,要是没点脑子,还真不好整了。