持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第1天,点击查看活动详情
前言
作为一个刚接触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 guthub 中十分受欢迎的一个拖拽库,功能十分完备,但是用于本页面貌似有点太“重”了,遂放弃
- 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>
)
}
最终效果:
最终目录结构:
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 直接查看效果:实时演示