在写一个 React 小组件的时候,发现里面涉及到多个原理相关的知识点,分享一下思路,感觉对 React 新手会有些启发,看看你能理解这其中涉及到的知识点吗。
目标
工作中,需要做一个选项组件,它和Arco Select 组件的弹出框内容一样。唯一的不同是,我希望不用点击弹出,直接就在呈现在页面上,供用户进行选择。
于是,我希望复用 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
没有渲染出任何东西。这又是为什么?
原因在于执行顺序:
SelectPanel()
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
变量重新创建为 undifined
,Select
组件被 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 的原理,要是没点脑子,还真不好整了。