前言
作为一个刚接触react 组件设计不久的新人,独立完成一个组件的设计开发其中过程是十分卡手的,本篇详尽的描述了米游社首页频道选择页面组件开发的全过程,希望这个这个简单组件的设计开发能对和我一样接触react组件开发不久的人有点帮助
准备阶段
页面分析
在正式开始仿页面之前,先看下原页面效果:
- 监听列表数据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 包装拖拽根组件<DndContextsensors={sensors}collisionDetection={closestCenter}onDragEnd={handleDragEnd}modifiers={[restrictToVerticalAxis, restrictToWindowEdges]}><SortableContextitems={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),// 拖拽时透明度,原版为1opacity: isDragging ? 0.6 : 1,dragSelectorExclude: "i"}return (<>{checked == true &&<Tabref={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 } = propsreturn (<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 } = propsreturn (<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>)
}
最终效果:
最终目录结构:
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 直接查看效果:实时演示
|