前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >学用Hooks写React组件——基础版Select组件

学用Hooks写React组件——基础版Select组件

作者头像
gary12138
发布2022-10-05 15:59:54
3K0
发布2022-10-05 15:59:54
举报
文章被收录于专栏:前端随笔

前言

Select组件是我们在PC上常用组件,由于原生Select组件样式定制化困难,各个浏览器样式“百花齐放”, 不得不自己定制Select组件,已有很多很强大的UI库(antd, element),Hooks是已经出来一段时间的新功能,抱着学习hooks的心态自己造轮子,本文会一步一步描述自己编写整个组件过程和思路。

思路构图与UI分层

方案一:

通过一个父组件包裹显示框组件和下拉框组件,这样的实现方式简单粗暴,而且能解决大部分场景,但是存在几个问题:

  1. 在scroll容器中会存在显示遮挡问题
  2. 父组件容器层级较低时,高层级组件与下拉框组件位置重合问题

作为码农当然不能满足于此所以

方案二:

通过React提供的createPortal来实现render body的方式渲染到body节点下,解决方案一的问题。

这里我们当然选择了render body的方案,整个组件思路是:点击显示组件,通过定位显示组件的位置来计算下拉框应该出现的位置。下拉框选中或者点击屏幕其他地方下拉框自动消失。选中后,显示对应的值。如果Select组件在带有滚动条的容器里,则监听容器的滚动来改变下拉框的位置。

是不是想准备开始撸起袖子干了呢,请稍等这里我们写代码之前先做了一个组件的拆分规划,便于我们提前预知一些问题。

这里我把组件拆分为:

  • Select组件(显示选择结果)
  • Menu组件(显示选择列表)
  • Position组件(用于定位下拉框显示位置)

准备完毕,开始输出

Menu组件
  • label 显示项的展示值
  • value 显示项的设置值
  • className

menu.css

代码语言:javascript
复制
.ll-selected{
    background: #000;
    color: #fff;
}

Menu.jsx

代码语言:javascript
复制
const SelectMenu = (props) => {
    const [ selected, setSelected ] = useState(false);
    const { label, value, className = '', handleSelect, defaultValue } = props;

    useEffect(() => {
        if (defaultValue === value) {
            setSelected(true);
        }
    }, [value, defaultValue])
    return (
        <div 
            onClick={() => handleSelect({value, label})} 
            className={`${className} ${selected ? 'll-selected': ''}`}>{label}</div>
    )
}

menu是Select组件最容易实现的,自己内部实现是否被选中。点击后向上传递选中的数据

Position组件
  • targetRef 根据哪个组件位置进行定位
  • getContainer 获取定位节点,默认render body
  • onNotVisibleArea 组件不在可视区域内时会被调用

position.css

代码语言:javascript
复制
.ll-position {
    position: absolute;
    z-index: 99;
    background: #fff;
}

Position.jsx

代码语言:javascript
复制
let instance = null;

const Position = (props) => {
    const { targetRef, children, getContainer, onNotVisibleArea } = props;
    const container = getContainer && getContainer();
    
    if (!instance) {
        instance = document.createElement('div');
        instance.className = 'll-position';
        document.body.appendChild(instance);
    }

    useEffect(() => {

        function setInstanceStyle() {
            const { top, left, height } = targetRef.current.getBoundingClientRect();
            const style = {
                top: document.documentElement.scrollTop + top + height + 10 + 'px',
                left: document.documentElement.scrollLeft + left + 'px'
            }
    
            instance.style.top = style.top;
            instance.style.left = style.left;

            return { top, left, height }
        }

        setInstanceStyle();

        function handleScroll() {
            const { top, height } = setInstanceStyle();
            
            if (container.offsetTop > top) {
                onNotVisibleArea();
            }
            if (top - container.offsetTop + height > container.offsetHeight) {
                onNotVisibleArea();
            }
        }

        if (container) {
            container.addEventListener('scroll', handleScroll, false);
        }

        return () => {
            if (container) {
                container.removeEventListener('scroll', handleScroll, false);
            }
        }

    }, [targetRef])

    return instance && ReactDOM.createPortal(children, instance);
}

Position组件通过传入的targetRef来获取到需要定位dom的位置,instance没有销毁,是为了下次再点开就不需要重新创建根节点。这里使用了React.createProtal来创建root节点外的组件,参考:https://zh-hans.reactjs.org/docs/portals.html。如果定位组件是在一个Scroll容器中,接收一个getContainer方法获取scroll容器,通过监听容器的scroll事件,来对定位组件进行移动,如果targetRef不在可视区域内了,调用onNotVisibleArea()方法,来通知上层组件。

Select组件
  • defaultValue 默认选中的值
  • onChange 当值被改变的时候调用的方法
  • getContainer 获取菜单渲染的父节点,默认render body

Select.jsx

代码语言:javascript
复制
const Select = (props) => {
    const { defaultValue, onChange, getContainer } = props;
    // 控制下拉框显示/隐藏
    const [visible, setVisible] = useState(false);
    // 当前选中的值
    const [data, setData] = useState({ value: defaultValue, label: '' });
    // 是否设置默认值
    const [defaultValueState, setDefaultValueState] = useState(true)
    const inputRef = useRef(null);

    // 查找defaultValue对应的label并展示出来
    useEffect(() => {
        if (!defaultValueState) return;
        const i = props.children.findIndex(n => n.props.value === defaultValue);
        if (i > -1) {
            setData(props.children[i].props);
            setDefaultValueState(true);
        }
    }, [defaultValue, props.children, defaultValueState])

    function handleSelect(data) {
        setData(data);
        onChange && onChange(data);
        setVisible(false);
    }

    function bindBodyClick(e) {
        if (e.target === inputRef.current) return;
        setVisible(false);
    }

    useEffect(() => {
        document.addEventListener('click', bindBodyClick, false);
        return () => {
            document.removeEventListener('click', bindBodyClick, false);
        }
    }, [visible])

    return (
        <React.Fragment>
            <input
                defaultValue={data.label}
                onClick={() => setVisible(true)}
                ref={inputRef}
                readOnly />
            {
                visible ?
                    <Position 
                        onNotVisibleArea={() => setVisible(false)}
                        getContainer={getContainer} 
                        targetRef={inputRef}>
                        {
                            React.Children.map(props.children, child => (
                                React.cloneElement(child, {
                                    defaultValue: data.value,
                                    handleSelect
                                })
                            ))
                        }
                    </Position> : null
            }
        </React.Fragment>
    )
}

以上的代码展示了一个简单的Select组件,通过visible来显示或者隐藏下拉框。

  • input接收一个defaultValue来设置初始值,我们传入的初始值是对应的value而不是label,所以这里我是用循环props.children来查找对应的label,然后展示input的defaultValue。input点击则显示下拉框。
  • Position组件是我们上面提到的定位组件,如果Position组件不在可视区时,执行noNotVisibleArea()方法让下拉框不显示。
  • 使用React.cloneElement对props.children进行新的props传输,参考官网:https://zh-hans.reactjs.org/docs/react-api.html#cloneelement
  • 监听document的click事件,来实现点击空白处下拉框消失。但是因为React的事件自己封装了一层并不是原生事件,这里涉及到了原生事件和合成事件的冒泡和捕获问题。参考官网:https://zh-hans.reactjs.org/docs/events.html#___gatsby

最终使用呈现

代码语言:javascript
复制
<div>
    <div style={{background: 'red', height: '200px'}}></div>
    <div id="area" style={{ margin: 10, overflow: 'scroll', height: 200 }}>
        <div style={{ padding: 100, height: 1000, background: '#eee' }}>
            <h4>滚动的区域</h4>
            <h4>滚动的区域</h4>
            <Select getContainer={() => document.getElementById('area')}>
                <SelectMenu label="第一" value="1"></SelectMenu>
                <SelectMenu label="第二" value="2"></SelectMenu>
                <SelectMenu label="第三" value="3"></SelectMenu>
                <SelectMenu label="第四" value="4"></SelectMenu>
            </Select>
        </div>
    </div>
    <Select>
        <SelectMenu label="第一" value="1"></SelectMenu>
        <SelectMenu label="第二" value="2"></SelectMenu>
        <SelectMenu label="第三" value="3"></SelectMenu>
        <SelectMenu label="第四" value="4"></SelectMenu>
    </Select>
</div>

结尾

忽略了所有select不必要的样式和很多细节功能和优化,比如多选、禁止选中、搜索筛选、节流等等。自己去实现的时候遇到很多问题,然后去阅读别人的源码收获很多。水平有限,如果错误请大家指出,谢谢。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2019-08-20 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 思路构图与UI分层
    • 方案一:
      • 方案二:
        • Menu组件
          • Position组件
            • Select组件
            • 结尾
            相关产品与服务
            容器服务
            腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
            领券
            问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档