React组件设计,仿米游社首页频道设置页面

3,368 阅读7分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第1天,点击查看活动详情

前言

作为一个刚接触react 组件设计不久的新人,独立完成一个组件的设计开发其中过程是十分卡手的,本篇详尽的描述了米游社首页频道选择页面组件开发的全过程,希望这个这个简单组件的设计开发能对和我一样接触react组件开发不久的人有点帮助

准备阶段

页面分析

在正式开始仿页面之前,先看下原页面效果:

演示.gif 布局十分常见,头、身、尾,三部分,对应三个组件,点击推荐频道组件中的添加符号,可以添加到我的频道组件中,我的频道中的列表数据可以长按进行拖拽排序,原页面那个句柄符号好像就是提示用,没有实际功能作用,整个列表长按都可以拖拽,当删除到最后一个游戏时会有一个小的模态框提示,原页面数据发生改变右上角确定高亮,综上我们需要完成:

  • 监听列表数据state 改变实现增加删除
  • 我的频道列表长按拖拽排序
  • 我的频道列表只剩一个游戏时,删除弹出提示
  • 数据发生改变,tab 中确定按钮高亮显示 根据需求我划分组件文件目录如下:
SelectChannel
├─ Body
│  ├─ content
│  │  ├─ index.jsx
│  │  └─ style.js
│  ├─ index.jsx
│  └─ style.js
├─ Footer
│  ├─ content
│  │  ├─ index.jsx
│  │  └─ style.js
│  ├─ index.jsx
│  └─ style.js
├─ Header
│  ├─ index.jsx
│  └─ style.js
├─ index.jsx
└─ style.js

使用工具

vite: 脚手架,初始化react项目
dnd-kit: 拖拽排序功能就是靠他实现的,官方文档
styled-components: css in js,官方文档
classnames: 动态类名,官方文档
fastmock: 接口假数据
axios: 数据请求

开发阶段

1. 初始化项目

  • 终端npm init @vitejs/app 对项目进行初始化工作,根据提示输入项目名,选react,顺便打开生成的vite配置文件设置src目录别名为@
  • fastmock 准备好接口假数据,并在api 目录中请求数据,组件中不做数据请求:数据
  • iconfont 选择需要的icon 相似即可,解压放assets 目录下

2. 移动端适配

  • 移动端页面开发当然少不了适配
    • 在public 目录下创建js 文件adapter.js 内容如下:
    var init = function () {
    var clientWidth = document.documentElement.clientWidth || document.body.clientWidth;
    if (clientWidth >= 640) {
        clientWidth = 640;
    }
    var fontSize = (20 / 375) * clientWidth;
    document.documentElement.style.fontSize = fontSize + 'px';
    };
    
    init();
    
    window.addEventListener('resize', init);
    
    
    • 在src 下创建目录modules 创建rem.js如下:
    document.documentElement.style.fontSize = 
        document.documentElement.clientWidth / 3.75 + 'px';
    // 横竖屏切换
    window.onresize = function() {
        document.documentElement.style.fontSize = 
            document.documentElement.clientWidth / 3.75 + 'px';
    }
    
  • index.html中引用adapter.js ,main.jsx 中引用rem.js

3. 实现父组件 SelectChannel

  • 除了子组件独有的部分,数据状态改变和函数都在父组件里进行,传给子组件,完整文件如下:
export default function SelectChannel() {
   
   const [list, setList] = useState([
       {
           id: 7,
           title: '大别野',
           img: 'https://bbs.mihoyo.com/_nuxt/img/game-dby.7b16fa8.jpg',
           checked: true,
       },
   ]);
   const [loading,setLoading] = useState(false)
   const [change,setChange] = useState(false)

   // 筛选出已选择和未选择项
   const TrueCheck = list.filter(item => item.checked == true);
   const FalseCheck = list.filter(item => item.checked == false);

   // 提示模态框
   const modal=()=>{
       return(
           loading && 
           <Modal>
               <span>至少选择一个游戏哦~</span>
           </Modal>
       )
       }
   // 定时让模态框消失
   const setState = () =>{
       setTimeout(()=>{
           setLoading(false)
       },2000)
   }

   // 选择
   const choose = item => {
       // console.log('--------');
       let idx = list.findIndex(data => item.id === data.id);
       // console.log(idx);
       list[idx].checked = !list[idx].checked;
       setList([...list]);
       setChange(true)
   };

   // 删除已选择项
   const deleteList = item => {
       let idx = list.findIndex(data => item.id === data.id);
       // 判断已选择项是否小于或等于两个,若是,那么不可删除,弹出提示模态框,若大于两个则执行删除
       if(TrueCheck.length <= 2){
           setLoading(true);
           setState();
       }else{
           list[idx].checked = !list[idx].checked;
           setList([...list]);
           setChange(true)
       }
   };

   // 拿取数据
   useEffect(() => {
       (async () => {
           let { data } = await select();
           // console.log(data);
           setList([...list, ...data]);
       })();
   }, []);

   // 拖拽后排序
   const handleDragEnd = ({active, over}) => {
       if(active.id !== over.id){
           setList((items) => {
           const oldIndex = items.findIndex(item => item.id === active.id)
           const newIndex = items.findIndex(item => item.id === over.id)
           return arrayMove(items, oldIndex, newIndex)
       })
       }
       setChange(true)
   }

   return (
       <>
           {modal()}
           <Header change={change} />
           <Content data={list} 
               deleteList={deleteList} 
               handleDragEnd={handleDragEnd} 
               />
           <Footer data={list} 
               choose={choose} 
               FalseCheck={FalseCheck} 
               />
       </>
   );

3.1 小模态框

  • 给小模态框组件一个状态loading 默认为false 当触发删除函数时判断我的频道中数组数据长度,改变loading 状态
    const [loading,setLoading] = useState(false)
    const deleteList = item => {
        let idx = list.findIndex(data => item.id === data.id);
        // 判断已选择项是否小于或等于两个,若是,那么不可删除,弹出提示模态框,若大于两个则执行删除
        if(TrueCheck.length <= 2){
            setLoading(true);
            setState();
        }else{
            list[idx].checked = !list[idx].checked;
            setList([...list]);
            setChange(true)
        }
    };
  • 我的频道中数组数据长度只剩两个时再点击删除会弹出提示,由原页面可知整个页面就这一个提示数据,所以写死就可
    const [loading,setLoading] = useState(false)
    // 提示模态框
    const modal=()=>{
        return(
            loading && 
            <Modal>
            // 没有其他弹出项,弹出数据写死
                <span>至少选择一个游戏哦~</span>
            </Modal>
        )
        }
    // 定时让模态框消失
    const setState = () =>{
        setTimeout(()=>{
            setLoading(false)
        },2000)
    }

3.2 删除和添加函数

  • 逻辑一样,findIndex 找出list 中的数据,将其和子组件触发事件传过来的 item 的id 进行对比,改变找出数据的checked ,setList 即可实现两个组件显示列表数据的改变
    // 选择
    const choose = item => {
        // console.log('--------');
        let idx = list.findIndex(data => item.id === data.id);
        // console.log(idx);
        list[idx].checked = !list[idx].checked;
        setList([...list]);
        setChange(true)
    };

    // 删除已选择项
    const deleteList = item => {
        let idx = list.findIndex(data => item.id === data.id);
        // 判断已选择项是否小于或等于两个,若是,那么不可删除,弹出提示模态框,若大于两个则执行删除
        if(TrueCheck.length <= 2){
            setLoading(true);
            setState();
        }else{
            list[idx].checked = !list[idx].checked;
            setList([...list]);
            setChange(true)
        }
    };

3.3 拖拽后排序

  • 逻辑和删除添加大致相同,调用了 dnd-kit 中的arrayMove 函数,对交换后的数据进行处理
    // 拖拽后排序
    const handleDragEnd = ({active, over}) => {
        if(active.id !== over.id){
            setList((items) => {
            const oldIndex = items.findIndex(item => item.id === active.id)
            const newIndex = items.findIndex(item => item.id === over.id)
            return arrayMove(items, oldIndex, newIndex)
        })
        }
        setChange(true)
    }

4. 页面头部tab

  • 布局常见的三列式布局,左右两个地方可点击跳转首页,这里可以设置路由,使用Link 但这里就展示独立的一个页面组件开发,先用a 标签代替,后续若需要可替换
  • 使用classnames 可以十分简单的设置动态类名,利用父组件中传过来的 chang 值对“确认”按钮是否高亮做出改变 代码如下:
export default function Header({change}) {
    return (
        <Tab>
            <div className="left">
                <a href="#">
                    <i className="iconfont icon-fanhui"></i>
                </a>
            </div>
            <div className="content">首页频道选择</div>
            <div className="right">
                <a href="#" className={classnames("noChange",{changeItem: change})}>
                    确定
                </a>
            </div>
        </Tab>
    );
}

5. 我的频道和推荐频道组件实现

5.1 组件分析

我的频道和推荐频道都有两个部分,一个固定的头,显示我的频道和推荐频道标题,标题下方是map 动态生成的列表组件,我的频道还需要拖拽排序,遂这里都相应再增加了个子组件 ContentList

5.2 拖拽排序组件库选择

  • 这个组件是整个组件实现的难点,拖拽排序自己实现很难,我尝试自己用原生react 实现了下,效果不尽人意,最终决定用现成的方案,常见的拖拽库选择有下:
    • react-dnd github 中十分受欢迎的一个拖拽库,功能十分完备,但是用于本页面貌似有点太“重”了,遂放弃
    • react-beautiful-dnd 和react-dnd 类似,但是我下载包貌似不支持react18,install 不下来,遂寄
    • dnd-kit 芜湖,看了下官方官方文档使用十分简单,只需要用DndContext、 SortableContext 包装拖拽根组件,Sensors 监听不同的拖动设备,再加上组件库现成的碰撞算法即可,十分滴简单

5.3 我的频道组件实现

5.3.1 父组件实现

  • 使用@dnd-kit/core 中的hook useSensor捕获传感器
  • 使用@dnd-kit/core 中的 DndContext SortableContext 组件包装拖拽根组件
  • 使用@dnd-kit/modifiers 中的 verticalListSortingStrategy 动态修改传感器检测到的运动坐标,限制拖拽方向为纵向 父组件代码如下:
export default function Content(props) {

    const { data, deleteList, handleDragEnd } = props

    // 捕获触摸传感器
    const touchSensor = useSensor(TouchSensor,{
        activationConstraint:{
            delay: 300,
            tolerance: 10,
        }
    })
    // 捕获鼠标
    const mouseSensor = useSensor(MouseSensor,{
        activationConstraint:{
            delay: 300,
            tolerance: 0,
        }
    })

    const sensors = useSensors(
        touchSensor,
        mouseSensor
    )

    return (
        <BodyWrapper>
        <TabWrapper>
            <header>
                <div className='left'>
                    <p>我的频道</p>
                </div>
                <div className='right'>
                    <p>长按拖动排序</p>
                </div>
            </header>
        </TabWrapper>
        // DndContext SortableContext 包装拖拽根组件
        <DndContext
            sensors={sensors}
            collisionDetection={closestCenter}
            onDragEnd={handleDragEnd}
            modifiers={[restrictToVerticalAxis, restrictToWindowEdges]}
        >
        <SortableContext
            items={data.map(item => item.id)}
            strategy={verticalListSortingStrategy}
        >
            {
                data.map((item) =>
                <ContentList key={item.id} 
                    deleteList={deleteList} 
                    item={item}
                    {...item}
                />
                )
            }
        </SortableContext>
        </DndContext>
        </BodyWrapper>
    );

5.3.2 子组件实现

  • 使用@dnd-kit/sortable 中的hook useSortable 匹配父元素id 参数
  • 使用@dnd-kit/utilities 中的CSS 搭配一些css 属性实现选中拖拽时的样式 代码如下:
export default function ContentList(props) {

    const { checked, id, title, img, deleteList, item } = props;

    const {
        setNodeRef,
        attributes,
        listeners,
        transition,
        transform,
        isDragging
    } = useSortable({id: id})

    // 长按选中元素拖动时样式
    const style = {
        transition,
        transform: CSS.Transform.toString(transform),
        // 拖拽时透明度,原版为1
        opacity: isDragging ? 0.6 : 1,
        dragSelectorExclude: "i"
    }

    return (
        <>
        {
            checked == true &&
            <Tab
                ref={setNodeRef}
                {...attributes}
                {...listeners}
                style={style}
            >
                    <TabItem>
                        <img src={img} alt="" />
                        <span>{title}</span>
                        {
                            title !== '大别野' && 
                            <i className="iconfont icon-shanjian" onClick={() => deleteList(item)} ></i>
                        }
                        <i className="iconfont icon-shouye" ></i>
                    </TabItem>
            </Tab>
        }
        </>
    )

官方拖拽时没有样式改变我这给了个0.6的透明

5.4 推荐频道组件实现

  • 除了没有拖拽排序外几乎和我的频道一样
  • 判断FalseCheck 数组长度以控制组件是否显示,若组件列表中没有数据了,不显示组件 代码如下:

5.4.1 父组件

export default function Footer(props) {

    const { data, choose, FalseCheck } = props

    return (
        <FooterWrapper>
            {
            FalseCheck.length > 0 &&
            <TabWrapper>
                <header>
                    <div className='left'>
                        <p>推荐频道</p>
                    </div>
                </header>
            </TabWrapper>
            }
            <ContentList data={data} choose={choose} />
        </FooterWrapper>
    );
}

5.4.2 子组件

export default function ContentList(props) {

    const { data , choose } = props

    return (
        <Tab>
            {
                data.map((item) => 
                    item.checked == false &&
                    <TabItem key={item.id}>
                        <img src={item.img} alt="" />
                        <span>{item.title}</span>
                        <i className="iconfont icon-tianjia" onClick={() => choose(item)}></i>
                    </TabItem>
                )
            }
        </Tab>
    )
}

最终效果:

实现.gif

演示2.gif

最终目录结构:

select-channel
├─ index.html
├─ package-lock.json
├─ package.json
├─ public
│  └─ js
│     └─ adapter.js
├─ src
│  ├─ api
│  │  └─ request.js
│  ├─ App.css
│  ├─ App.jsx
│  ├─ assets
│  │  ├─ font
│  │  └─ styles
│  │     └─ reset.css
│  ├─ components
│  │  └─ SelectChannel
│  │     ├─ Body
│  │     │  ├─ content
│  │     │  │  ├─ index.jsx
│  │     │  │  └─ style.js
│  │     │  ├─ index.jsx
│  │     │  └─ style.js
│  │     ├─ Footer
│  │     │  ├─ content
│  │     │  │  ├─ index.jsx
│  │     │  │  └─ style.js
│  │     │  ├─ index.jsx
│  │     │  └─ style.js
│  │     ├─ Header
│  │     │  ├─ index.jsx
│  │     │  └─ style.js
│  │     ├─ index.jsx
│  │     └─ style.js
│  ├─ index.css
│  ├─ main.jsx
│  └─ modules
│     └─ rem.js
└─ vite.config.js

最后

这就是这次组件实现的全过程,后续会继续完善,代码在仿米游社首页频道设置页面
github page 直接查看效果:实时演示