- 01-页面结构
- 02-使用 antd-mobile 组件库的 Tabs 组件
- 03-获取首页频道列表数据
- 04-渲染频道列表数据
- 05-频道管理-渲染频道管理弹出层
- 06-频道管理-渲染我的频道
- 07-频道管理-获取频道推荐
- 08-频道管理-渲染频道推荐
- 09-频道管理-切换频道编辑状态
- 10-频道管理-切换频道和高亮
- 11-频道管理-首页顶部频道切换
- 12-频道管理-删除频道
- 13-频道管理-删除频道更新状态
- 14-频道管理-添加频道
- 15-文章列表-根据模板搭建文章列表结构
- 16-文章列表-InfiniteScroll 组件
- 17-文章列表-获取频道文章列表数据
- 18-文章列表-将频道文章列表数据存储到 redux
- 19-文章列表-触底加载更多文章列表项
- 20-文章列表-下拉加载更多文章数据
- 20-文章列表-渲染文章列表项内容
- 21-文章列表-点击文章项跳转到详情
01-页面结构
目标:能够根据模板搭建首页页面结构
步骤:
-
02-使用 antd-mobile 组件库的 Tabs 组件
目标:能够掌握 antd-mobile 组件中 Tabs 组件的使用
步骤: 打开 antd-mobile 组件库的文档,找到 Tabs 组件
- 找到示例代码,并拷贝到项目中
- 分析 Tabs 组件的结构和基本使用
- 调整 Tabs 的样式
核心代码:
Home/index.tsx 中:
import { Tabs } from ‘antd-mobile’;
const Home = () => {
return (
// …
// 注意:此处别忘了添加 tabs 类名
推荐频道的内容
html频道的内容
开发者资讯频道的内容
c++频道的内容
css频道的内容
);
};
03-获取首页频道列表数据
目标:能够获取首页频道列表数据
分析说明:
对于首页来说,不管用户是否登录,都可以查看。但是,是否登录会对后续的频道管理操作产生影响:
- 如果用户已登录,此时,获取到的就是用户自己的频道数据
- 不管是添加频道还是删除频道,操作的都是自己的数据,并且这个数据是同步到服务器中的
- 如果用户未登录,此时,获取的是默认的频道列表数据
- 注意:因为用户未登录,但还要实现频道的添加或删除操作并且在刷新页面后,也要保持修改后的频道数据
- 所以,为了实现该效果,在用户未登录时,将频道数据保存到本地,在本地进行后续的操作
步骤:
- 根据接口,在 types/data.d.ts 中添加频道列表数据的类型
- 创建 actions/home.ts 文件,在该文件中创建获取频道列表数据的 action 并导出
- 在该 action 中,判断用户是否登录
- 如果已登录,发送请求获取用户的频道列表数据
- 如果未登录,先判断本地缓存中有没有频道列表数据
- 如果有,拿到本地的频道列表数据
- 如果没有,就发送请求获取默认的频道列表数据,并存储到本地缓存中
- 在 Home 组件中通过 useInitialState 自定义 hook 验证是否正确
核心代码:
types/data.d.ts 中:
export type Channel = {
id: number;
name: string;
};
export type UserChannel = {
channels: Channel[];
};
export type UserChannelResponse = ApiResponse
actions/home.ts 中:
import { Channel, UserChannelResponse } from ‘@/types/data’;
import { RootThunkAction } from ‘@/types/store’;
import { http } from ‘@/utils’;
const CHANNEL_KEY = ‘geek-channels’;
export const getUserChannel = (): RootThunkAction => {
return async (dispatch, getState) => {
const {
login: { token },
} = getState();
if (token) {
// 登录
const res = await http.get
const { channels } = res.data.data;
console.log(‘登录’, channels);
} else {
// 未登录
const localChannels = JSON.parse(
localStorage.getItem(CHANNEL_KEY) ?? ‘[]’,
) as Channel[];
if (localChannels.length > 0) {
// 有
console.log(‘未登录,本地有’, localChannels);
} else {
// 没有
const res = await http.get
const { channels } = res.data.data;
localStorage.setItem(CHANNEL_KEY, JSON.stringify(channels));
console.log(‘未登录,本地没有’, channels);
}
}
};
};
Home/index.tsx 中:
import { useInitialState } from ‘@/utils/use-initial-state’;
import { getUserChannel } from ‘@/store/actions’;
const Home = () => {
useInitialState(getUserChannel, ‘home’);
};
04-渲染频道列表数据
目标:能够渲染首页频道列表数据
步骤:
- 在 types/store.d.ts 中添加获取频道列表数据的 action 类型
- 回到获取频道列表数据的 action 中,分发 action 以将频道数据保存到 redux 中
- 创建 reducers/home.ts 文件,处理获取频道数据的 action
- 将状态 home 合并到根 reducer 中
- 在 Home 组件中通过 useInitialState 自定义 hook 获取频道列表数据并渲染
核心代码:
actions/home.ts 中:
export const getUserChannel = (): RootThunkAction => {
return async (dispatch, getState) => {
const {
login: { token },
} = getState();
let userChannel: Channel[] = [];
if (token) {
// 登录
const res = await http.get
const { channels } = res.data.data;
userChannel = channels;
} else {
// 未登录
const localChannels = JSON.parse(
localStorage.getItem(CHANNEL_KEY) ?? ‘[]’,
) as Channel[];
if (localChannels.length > 0) {
// 有
userChannel = localChannels;
} else {
// 没有
const res = await http.get
const { channels } = res.data.data;
localStorage.setItem(CHANNEL_KEY, JSON.stringify(channels));
userChannel = channels;
}
}
dispatch({ type: ‘home/getUserChannel’, payload: userChannel });
};
};
types/store.d.ts 中:
import type { Channel } from ‘./data’;
type RootAction = LoginAction | ProfileAction | HomeAction;
export type HomeAction = {
type: ‘home/getUserChannel’;
payload: Channel[];
};
reducers/home.ts 中:
import { Channel } from ‘@/types/data’;
import { HomeAction } from ‘@/types/store’;
type HomeState = {
userChannel: Channel[];
};
const initialState: HomeState = {
userChannel: [],
};
const Home = (state = initialState, action: HomeAction): HomeState => {
switch (action.type) {
case ‘home/getUserChannel’:
return {
…state,
userChannel: action.payload,
};
default:
return state;
}
};
export default Home;
reducers/index.ts 中:
import home from ‘./home’;
const rootReducer = combineReducers({
// …
home,
});
Home/index.tsx 中:
const Home = () => {
const { userChannel } = useInitialState(getUserChannel, ‘home’);
return (
{/ 延迟渲染 Tabs,解决 tab 高亮位置错误 /}
{userChannel.length > 0 && (
{userChannel.map((item) => (
推荐频道的内容
))}
)}
);
};
05-频道管理-渲染频道管理弹出层
目标:能够渲染频道管理弹出层
步骤:
- 将模板 Channels 拷贝到 Home/components 目录中
- 在 Home 组件中导入 Channels 组件
- 使用 Popup 组件渲染 Channels 内容
- 创建控制频道管理弹出层展示或隐藏的状态
- 控制弹出层的展示或隐藏
核心代码:
Home/index.tsx 中:
import { useState } from ‘react’;
import { Popup } from ‘antd-mobile’;
import Channels from ‘./components/Channels’;
const Home = () => {
const [visible, setVisible] = useState(false);
const onChannelOpen = () => {
setVisible(true);
};
const onChannelClose = () => {
setVisible(false);
};
return (
// …
onMaskClick={onChannelClose}
position=”left”
className=”channel-popup”
>
);
};
Home/components/Channels/index.tsx 中:
type Props = {
onClose: () => void;
};
const Channels = ({ onClose }: Props) => {
return (
// …
);
};
06-频道管理-渲染我的频道
目标:能够渲染我的频道列表
分析说明:
我的频道中展示的数据就是在首页中获取到的用户频道列表数据,因此,只需要在频道管理组件中拿到用户频道列表数据即可
步骤:
- 在 Channels 中,从 redux 中获取到用户频道数据
- 渲染用户频道列表数据
核心代码:
Channels/index.tsx 中:
import { useSelector } from ‘react-redux’;
import { RootState } from ‘@/types/store’;
const Channels = ({ onClose }: Props) => {
const { userChannel } = useSelector((state: RootState) => state.home);
return (
// …
{/ 选中时,添加类名 selected /}
{userChannel.map((item) => (
{item.name}
))}
);
};
07-频道管理-获取频道推荐
目标:能够获取到频道推荐列表数据
分析说明:
频道推荐(可选频道)中展示的是除了我的频道之外的其他频道数据,由于接口并没有直接提供可选频道的数据, 因此,可以拿到所有频道数据,然后,排除掉我的频道数据,剩下的就是可选频道数据了。
问题:如何从一个数组中删除另一个数组中包含的元素?
// 原生方式:
const row = [1, 3, 5, 7];
const my = [1, 7];
// 最终希望拿到:[3, 5]
row.filter((itme) => my.findIndex(itemm) < 0);
// https://www.lodashjs.com/docs/lodash.differenceBy
// 可以使用 lodash 的 differenceBy 方法:
// 从第一个数组中,排除掉第二个数组中包含的元素
// 如何确定两个数组中的元素是否相同呢?根据第三个参数,比如,传入 x 表示如果两个数组中的元素的 x 属性
// 相同就表示两个元素相同
_.differenceBy([{ x: 2 }, { x: 1 }], [{ x: 1 }], ‘x’);
// 结果为:[{ ‘x’: 2 }]
步骤:
- 根据接口,在 types 中添加所有频道数据的类型
- 在 actions 中,发送请求,获取所有频道数据
- 拿到所有频道数据排除掉我的频道数据,得到可选频道数据
- 在 types 中,添加保存频道推荐数据的 action 类型
- 在 actions 中分发 action 将频道推荐数据存储到 redux 中
- 在 reducers 中存储推荐频道数据
核心代码:
types/data.d.ts 中:
// 所有频道数据
export type AllChannels = {
channels: Channel[];
};
export type AllChannelsResponse = ApiResponse
actions/home.ts 中:
import { AllChannelsResponse } from ‘@/types/data’;
export const getAllChannel = (): RootThunkAction => {
return async (dispatch, getState) => {
const res = await http.get
const {
home: { userChannel },
} = getState();
const restChannels = differenceBy(
res.data.data.channels,
userChannel,
‘id’,
);
dispatch({ type: ‘home/getAllChannel’, payload: restChannels });
};
};
types/store.d.ts 中:
import type { Channel } from ‘./data’;
export type HomeAction =
| {
type: ‘home/getUserChannel’;
payload: Channel[];
}
| {
type: ‘home/getAllChannel’;
payload: Channel[];
};
reducers/home.ts 中:
import { Articles, Channel } from ‘@/types/data’
type HomeState = {
// …
restChannel: Channel[]
}
const initialState: HomeState = {
// …
restChannel: [],
}
const Home = (state = initialState, action: HomeAction): HomeState => {
switch (action.type) {
// …
case ‘home/getAllChannel’:
return {
…state,
restChannel:
}
}
}
08-频道管理-渲染频道推荐
目标:能够渲染频道推荐列表
核心代码:
Channels/index.tsx 中:
import { useInitialState } from ‘@/utils/use-initial-state’;
import { getAllChannel } from ‘@/store/actions’;
const Channels = () => {
const { restChannel } = useInitialState(getAllChannel, ‘home’);
return (
// …
{restChannel.map((item) => (
+ {item.name}
))}
);
};
09-频道管理-切换频道编辑状态
目标:能够切换频道编辑状态
步骤:
- 添加控制是否为编辑的状态
- 给编辑/保存按钮添加点击事件
- 在点击事件中切换编辑状态
- 根据编辑状态判断展示保存或编辑文字内容
核心代码:
Channels/index.tsx 中:
import { useState } from ‘react’;
const Channels = () => {
const [isEdit, setIsEdit] = useState(false);
const onChangeEdit = () => {
setIsEdit(!isEdit);
};
return (
// …
// …
{isEdit ? ‘保存’ : ‘编辑’}
);
};
10-频道管理-切换频道和高亮
目标:能够实现切换频道功能
分析说明:
首页顶部的频道和频道管理中的我的频道是关联在一起的:
- 点击频道管理中的我的频道时,首页顶部的频道会切换,并高亮
- 点击首页顶部的频道时,频道管理对应的频道也要高亮
因此,需要准备一个状态用来记录当前选中频道,并且两个组件中都需要用到该状态,所以,可以直接将该状态存储到 redux 中,实现状态共享。 然后,不管是首页顶部的频道还是频道管理中的我的频道,只需要在点击切换时,修改 redux 中记录的高亮状态值即可。
步骤:
- 在 home reducer 中,添加一个状态 channelActiveKey 用来记录当前选中频道的键
- 在 Channel 组件中拿到该状态,在渲染我的频道列表时,让对应 key 的频道高亮
- 为每个频道项添加点击事件,在点击事件中拿到每一个频道的 key,并分发 action 来切换选中项
- 在 types 中,添加修改 channelActiveKey 的 action 类型
- 在 home reducer 中,处理 action 来更新 channelActiveKey 的值
- 在 home reducer 获取首页顶部频道数据时,为 channelActiveKey 指定默认值
- 切换频道时,关闭频道弹出层
核心代码:
redcuers/home.ts 中:
type HomeState = {
channelActiveKey: string;
};
const initialState: HomeState = {
channelActiveKey: ‘’,
};
const Home = (state = initialState, action: HomeAction): HomeState => {
switch (action.type) {
case ‘home/getUserChannel’:
return {
…state,
userChannel: action.payload,
// 设置默认值
channelActiveKey: action.payload[0]?.id + ‘’,
};
// …
case ‘home/changeTab’:
return {
…state,
channelActiveKey: action.payload,
};
}
};
types/store.d.ts 中:
export type HomeAction =
// …
{
type: ‘home/changeTab’;
payload: string;
};
Channel/index.tsx 中:
import { useDispatch } from ‘react-redux’;
const Channels = ({ onClose }: Props) => {
const dispatch = useDispatch();
const onChannelClick = (key: string) => {
dispatch({ type: ‘home/changeTab’, payload: key });
onClose();
};
return (
// …
{/ 选中时,添加类名 selected /}
{userChannel.map((item) => (
key={item.id}
className={classnames(
‘channel-list-item’,
channelActiveKey === item.id + ‘’ && ‘selected’,
)}
onClick={() => onChannelClick(item.id + ‘’)}
>
{item.name}
))}
);
};
11-频道管理-首页顶部频道切换
目标:能够实现首页频道切换和高亮功能
步骤:
- 在 Home 组件中拿到该状态,并设置为 Tabs 组件的 activeKey
- 为 Tabs 组件添加 onChange,拿到当前选中的 tab 的键,并且分发 action 来修改 channelActiveKey
核心代码:
Home/index.tsx 中:
import { useDispatch, useSelector } from ‘react-redux’;
import { HomeAction } from ‘@/types/store’;
const Home = () => {
const dispatch = useDispatch();
const { channelActiveKey } = useSelector((state: RootState) => state.home);
const onTabChange = (key: string) => {
dispatch({ type: ‘home/changeTab’, payload: key });
};
return (
// …
);
};
12-频道管理-删除频道
目标:能够删除我的频道数据
分析说明:
- 推荐频道不能删除
- 至少要保留 4 个频道
步骤:
- 修改频道项的点击事件参数为 channel 即当前频道数据
- 在我的频道项的点击事件中,判断当前是否为编辑状态
- 如果不是编辑状态,执行频道切换操作
- 如果是编辑状态,判断是否为推荐频道或频道数量小于等于 4
- 如果是,阻止删除
- 如果不是,分发删除频道的 action
核心代码:
Channels/index.tsx 中:
import { delChannel } from ‘@/store/actions’;
import { Channel } from ‘@/types/data’;
const Channels = () => {
const onChannelClick = (channel: Channel) => {
if (!isEdit) {
dispatch({ type: ‘home/changeTab’, payload: channel.id + ‘’ });
onClose();
return;
}
if (channel.id === 0) return;
if (userChannel.length <= 4) return;
dispatch(delChannel(channel));
};
};
actions/home.ts 中:
export const delChannel = (channel: Channel): RootThunkAction => {
return async (dispatch, getState) => {
const {
login: { token },
} = getState();
if (token) {
// 已登录
await http.delete(/user/channels/${channel.id}
);
} else {
// 未登录
const localChannels = JSON.parse(
localStorage.getItem(CHANNEL_KEY) ?? ‘[]’,
) as Channel[];
const userChannel = localChannels.filter(
(item) => item.id !== channel.id,
);
localStorage.setItem(CHANNEL_KEY, JSON.stringify(userChannel));
}
};
};
13-频道管理-删除频道更新状态
目标:能够在删除频道后更新页面状态
步骤:
- 在 types 中添加删除频道的 action 类型
- 在删除频道 action 中,分发 action 到 redux
- 在 reducers 中删除频道,并将被删除频道添加到推荐频道中
核心代码:
types/store.d.ts 中:
export type HomeAction =
// …
{
type: ‘home/delChannel’;
payload: Channel;
};
actions/home.ts 中:
export const delChannel = (channel: Channel): RootThunkAction => {
return async (dispatch, getState) => {
// …
dispatch({ type: ‘home/delChannel’, payload: channel });
};
};
reducers/home.ts 中:
import { sortBy } from ‘lodash’;
const Home = (state = initialState, action: HomeAction): HomeState => {
switch (action.type) {
// …
case ‘home/delChannel’:
return {
…state,
// 删除当前频道
userChannel: state.userChannel.filter(
(item) => item.id !== action.payload.id,
),
// 将被删除频道添加到推荐频道中,并且根据 id 进行排序
restChannel: sortBy([…state.restChannel, action.payload], ‘id’),
};
}
};
14-频道管理-添加频道
目标:能够实现添加频道功能步骤:
- 为可选频道中的频道项绑定点击事件,并拿到当前被点击的频道
- 在 actions 中根据是否登录处理添加频道逻辑
- 在 types 中增加添加频道的 action 类型
- 在添加频道的 action 中分发 action 来更新 redux
- 在 reducers 中处理添加频道的 action
核心代码:
Channels/index.tsx 中:
const Channels = () => {
const onAddChannel = (channel: Channel) => {
dispatch(addChannel(channel));
};
return (
// …
{restChannel.map((item) => (
onAddChannel(item)}>+ {item.name}
))}
);
};
actions/home.ts 中:
export const addChannel = (channel: Channel): RootThunkAction => {
return async (dispatch, getState) => {
const {
login: { token },
} = getState();
if (token) {
// 登录
await http.patch(‘/user/channels’, [channel]);
} else {
// 未登录
const localChannels = JSON.parse(
localStorage.getItem(CHANNEL_KEY) ?? ‘[]’,
) as Channel[];
const userChannel = […localChannels, channel];
localStorage.setItem(CHANNEL_KEY, JSON.stringify(userChannel));
}
dispatch({ type: ‘home/addChannel’, payload: channel });
};
};
types/store.d.ts 中:
export type HomeAction =
// …
{
type: ‘home/addChannel’;
payload: Channel;
};
reducers/home.ts 中:
const Home = () => {
switch (action.type) {
// …
case ‘home/addChannel’:
return {
…state,
userChannel: […state.userChannel, action.payload],
restChannel: state.restChannel.filter(
(item) => item.id !== action.payload.id,
),
};
}
};
15-文章列表-根据模板搭建文章列表结构
目标:能够根据模板搭建频道文章列表结构
步骤:
- 将模板 ArticleItem 拷贝到 src/components 公共组件目录中
- 将模板 components 拷贝到 pages/Home 目录中
- 在 Home 组件中渲染文章列表结构
- 分析每个模板的作用,以及模板的结构
核心代码:
Home/index.tsx 中:
import ArticleList from ‘./components/ArticleList’;
const Home = () => {
return (
// …
{/ 在每个 Tabs.TabPane 中渲染文章列表组件 /}
);
};
16-文章列表-InfiniteScroll 组件
目标:能够使用 antd-mobile 的 InfiniteScroll 组件
分析说明:
InfiniteScroll 组件在渲染时,会自动调用 loadMore 加载文章列表数据
- 注意:对于 InfiniteScroll 组件来说,如果没有正确处理 loadMore 函数,会导致在加载数据时,重复执行多次 loadMore 函数
// 正确:
const loadMore = async () => {
// 此处,await 异步操作完成
await dispatch(getArticleListByChannelId(channelId, Date.now()));
};
// 错误:
const loadMore = async () => {
// 此处,没有等待异步操作完成,会导致重复发送请求!!!
dispatch(getArticleListByChannelId(channelId, Date.now()));
};
- 对于 InfiniteScrool 组件来说,在第一次渲染时,会进行以下判断,来决定是否加载更多数据:
- hasMore 是否为 true,如果为 true 调用 loadMore 加载更多数据
- 该组件所在位置 减去 可滚动区域的底部位置是否小于 threshold (默认值 250),如果小于就说明触底了,就会加载更多数据
- 造成递归获取数据的情况:只发送请求获取数据,但是,没有渲染数据,导致位置判断一直小于 threshold,就会一直获取数据。
步骤:
- 找到 antd-mobile 组件库中的 InfiniteScroll 组件
- 拷贝示例代码到 ArticleList 组件中
- 通过示例代码给出的数据,渲染文章列表
核心代码:
ArticleList/index.tsx 中:
import { InfiniteScroll } from ‘antd-mobile’;
import { useState } from ‘react’;
import { sleep } from ‘antd-mobile/es/utils/sleep’;
let count = 0;
async function mockRequest() {
if (count >= 5) {
return [];
}
await sleep(2000);
count++;
return [
‘A’,
‘B’,
‘C’,
‘D’,
‘E’,
‘F’,
‘G’,
‘H’,
‘I’,
‘J’,
‘K’,
‘L’,
‘M’,
‘N’,
‘O’,
‘P’,
‘Q’,
];
}
const ArticleList = () => {
const [data, setData] = useState
const [hasMore, setHasMore] = useState(true);
async function loadMore() {
const append = await mockRequest();
setData((val) => […val, …append]);
setHasMore(append.length > 0);
}
return (
{/ 文章列表中的每一项 /}
{data.map((item, index) => (
))}
{/
loadMore 加载数据的函数
hasMore 布尔值,true 表示还有更多数据;false 表示没有更多数据了
/}
);
};
总结:
- InfiniteScroll 组件会自动调用 loadMore 加载数据吗?如果 hasMore 为 true,就会自动加载数据;否则,不会自动加载
- 给 ArticleList 组件传递频道 id
- 根据接口,在 types/data.d.ts 中添加频道文章列表数据的类型
- 在 actions 中创建获取文章列表数据的 action
- 发送请求,获取频道文章列表数据
- 在 ArticleList 组件中的 loadMore 内部分发获取频道文章列表数据的 action
核心代码:
Home/index.tsx 中:
const Home = () => {
return (
// …
{userChannel.map((item) => (
{/ 传递频道 id /}
))}
);
};
ArticleList/index.tsx 中:
import { getArticleList } from ‘@/store/actions’;
import { useDispatch } from ‘react-redux’;
type Props = {
channelId: number;
};
const ArticleList = ({ channelId }: Props) => {
const [data, setData] = useState
const [hasMore, setHasMore] = useState(true);
const dispatch = useDispatch();
const loadMore = async () => {
const timestamp = +new Date() + ‘’;
await dispatch(getArticleList(channelId, timestamp));
};
return (
{/ 文章列表中的每一项 /}
{data.map((item, index) => (
))}
);
};
types/data.d.ts 中:
export type Articles = {
pre_timestamp: string;
results: {
art_id: string;
aut_id: string;
aut_name: string;
comm_count: number;
cover: {
type: number;
images: string[];
};
pubdate: string;
title: string;
}[];
};
export type ArticlesResponse = ApiResponse
actions/home.ts 中:
import { ArticlesResponse } from ‘@/types/data’;
export const getArticleList = (
channel_id: number,
timestamp: string,
): RootThunkAction => {
return async (dispatch) => {
const res = await http.get
params: {
channel_id,
timestamp,
},
});
console.log(res);
};
};
18-文章列表-将频道文章列表数据存储到 redux
目标:能够格式化频道文章列表数据并存储到 redux 中
分析说明:
问题:用什么样的数据格式存储频道文章列表数据? 分析:每个频道,都对应到一个文章列表数据
// 推荐频道
1 ==> {}
// html 频道
2 ==> {}
// 开发者资讯频道
3 ==> {}
为了高效的存取数据,我们使用 对象 来存储频道文章列表数据
// 数据格式:
channelArticles = {
1: {},
2: {},
3: {}
}
// 根据频道 id 取数据
channelArticles[2]
// 根据频道 id 存数据
{
…channelArticles,
[channelId]: articles
}
举例说明 数组 和 对象 存取数据的差异:
// 使用数组存取数据
const arr = [{ id: 1 }, { id: 2 }, { id: 4 }]
// 取数据: 根据 id 来获取
// 不管是否用 find 这样的方法,都需要遍历
arr.find(item => item.id === 2)
// 改数据:
arr.map(item => {
if (item.id === 2) {
return { …item, 要修改的数据 }
}
return item
})
// ——-
// 使用对象存取数据
const obj = {
// id: 值
1: { id: 1 },
2: { id: 2 },
3: { id: 3 },
}
// 取数据: 根据 id 来获取数据
obj[2]
// 改数据:
{
…obj,
2: 要修改的数据
}
问题:这个对象对应到的 TS 类型该如何写? 分析:该对象中可能出现任意的 key
此时,可以通过 TS 中的 索引类型 来为对象执行类型
什么时候使用索引类型?当对象中有什么属性,无法提前确定下来,此时,就可以使用索引类型了
// 使用索引类型
type T = {
// [key in number] 表示索引类型
// key in number 表示:约束对象的键只能是数值类型
// key 仅仅是个占位符,可以是任何名称
[key in number]: Articles;
// 如果对象中可以出现任意 string 类型的键,可以这样实现:
// [key in string]
};
步骤:
- 在 types/store.d.ts 中,添加获取频道文章列表数据对应的 action 类型
- 在 actions 中,分发 action 将状态保存到 redux 中
- 在 reducers 中,添加频道文章列表数据的类型和默认值
- 处理获取频道文章列表的 action,追加文章列表数据到状态中
核心代码:
types/store.d.ts 中:
export type HomeAction = {
type: ‘home/getChannelArticles’;
payload: {
// 频道 id
channelId: number;
// 该频道的文章列表数据
data: Articles;
};
};
actions/home.ts 中:
export const getArticleList = (): RootThunkAction => {
return async (dispatch) => {
// …
// 分发 action
dispatch({
type: ‘home/getChannelArticles’,
payload: {
channelId: channel_id,
data: res.data.data,
},
});
};
};
reducers/home.ts 中:
import { Articles, Channel } from ‘@/types/data’;
import { HomeAction } from ‘@/types/store’;
type HomeState = {
channelArticles: {
[key: number]: Articles;
};
};
const initialState: HomeState = {
channelArticles: {},
};
const Home = (state = initialState, action: HomeAction): HomeState => {
switch (action.type) {
// …
case ‘home/getChannelArticles’:
// 注意:当前频道的文章列表数据可能为空(比如,第一次加载),为了方便后续操作
// 此处为其指定默认值
const curChannelArticles = state.channelArticles[
action.payload.channelId
] ?? {
pre_timestamp: null,
results: [],
};
const {
channelId,
data: { pre_timestamp, results },
} = action.payload;
return {
…state,
channelArticles: {
…state.channelArticles,
// 修改当前频道对应的文章列表数据
[channelId]: {
pre_timestamp,
// 追加文章列表数据
results: […curChannelArticles.results, …results],
},
},
};
}
};
19-文章列表-触底加载更多文章列表项
目标:能够实现触底加载更多文章列表项
分析说明:
- 如何加载前一页的数据?传递接口返回的 pre_timestamp
- 是否有更多数据:如果没有更多文章数据,则 pre_timestamp 为 null
- 注意:对于 InfiniteScroll 组件来说,如果没有正确处理 loadMore 函数,会导致在加载数据时,重复执行多次 loadMore 函数
// 正确:
const loadMore = async () => {
// 此处,await 异步操作完成
await dispatch(getArticleListByChannelId(channelId, Date.now()));
};
// 错误:
const loadMore = async () => {
// 此处,没有等待异步操作完成,会导致重复发送请求!!!
dispatch(getArticleListByChannelId(channelId, Date.now()));
};
- 对于 InfiniteScrool 组件来说,在第一次渲染时,会进行以下判断,来决定是否加载更多数据:
- hasMore 是否为 true,如果为 true 调用 loadMore 加载更多数据
- 该组件所在位置 减去 可滚动区域的底部位置是否小于 threshold (默认值 250),如果小于就说明触底了,就会加载更多数据
- 造成递归获取数据的情况:只发送请求获取数据,但是,没有渲染数据,导致位置判断一直小于 threshold,就会一直获取数据。
步骤:
- 在 ArticleList 组件中拿到频道文章列表数据
- 根据当前频道 id 拿到当前频道的文章列表数据,并处理默认值问题
- 修改判断是否有更多文章列表数据的逻辑
- 渲染文章列表数据
核心代码:
ArticleList/index.tsx 中:
import styles from ‘./index.module.scss’;
import { getArticleList } from ‘@/store/actions’;
import { useDispatch, useSelector } from ‘react-redux’;
import { RootState } from ‘@/types/store’;
const ArticleList = ({ channelId }: Props) => {
const dispatch = useDispatch();
// 获取当前频道的文章列表数据
const { channelArticles } = useSelector((state: RootState) => state.home);
// 注意:此处的 频道对应的 文章列表数据,可能是不存在的,所以,此处设置默认值
const currentChannelArticle = channelArticles[channelId] ?? {
pre_timestamp: Date.now() + ‘’,
results: [],
};
// pre_timestamp 时间戳
// results 该频道的文章列表数据
const { pre_timestamp, results } = currentChannelArticle;
// 加载更多数据的函数
const loadMore = async () => {
await dispatch(getArticleListByChannelId(channelId, pre_timestamp));
};
// 是否加载更多数据的条件:
// 如果 pre_timestamp 值为 null 说明没有更多数据了
// 此时, hasMore 值为 false,那么,InfiniteScroll 组件就不会再次获取数据了
const hasMore = pre_timestamp !== null;
return (
{articles.map((item, index) => (
))}
);
};
说明 1:使用 InfiniteScroll 组件,进入页面就会一直不停的加载所有文章列表数据,可能是以下原因造成的:
- 只更新文章列表状态,没有渲染文章列表数据。导致,InfiniteScroll 组件感觉有更多数据(一直没有触底),就会一直加载数据
- 样式造成的问题:要保证从 html => body => #root => .app => Layout_root => Home_root => adm-tabs tabs => adm-tabs-content => ArticleList_root 的高度都为 100%,也就是文章列表内容的全部父级元素都要设置高度。我们的项目中,要占满整个屏幕,所以高度为:100%。这样,才能保证文章列表内容超长时,出现区域(文章列表区域)滚动。InfiniteScroll 组件是相对于最近的一个可滚动的父元素,来判断位置的,所以,在 ArticleList_root 父元素中 设置了 overflow-y: scroll,也就是,判断 InfiniteScroll 组件所在位置是否超过 ArticleLIist_root 元素的视口 threshold 阈值,没超过也就是触底了,此时就继续获取数据,直到超过 threshold 阈值。
说明 2:文章列表数据触底加载更多的条件是,每次都拿到上一次请求返回的时间戳,根据该时间戳来获取下一页数据
第一次请求: 传入最新的时间戳 Date.now() ===> 接口返回: { pre_timestamp1, results }
第二次请求: 传入上次返回的时间戳 pre_timestamp1 ===> 接口返回: { pre_timestamp2, results }
第二次请求: 传入上次返回的时间戳 pre_timestamp2 ===> 接口返回: { pre_timestamp3, results }
…
20-文章列表-下拉加载更多文章数据
目标:能够下拉加载更多文章数据
步骤:
- 导入 antd-mobile 的 PullToRefresh 下拉刷新组件
- 使用下拉刷新组件获取最新数据
核心代码:
Home/index.tsx 中:
import { PullToRefresh } from ‘antd-mobile’;
const ArticleList = () => {
// 下拉刷新文章列表
const onRefresh = async () => {
await dispatch(getArticleList(channelId, Date.now() + ‘’));
};
return (
// …
{renderArticleList()}
);
};
补充:
- Record 泛型工具类型:
// 使用场景:如果已经知道对象中键的集合,可以直接通过 Record 来快速创建一个对象类型
// Record 内置泛型工具类型,用来创建一个对象类型
// Record 类型的作用:根据 联合类型 来得到一个对象类型。
// 第一个泛型参数是联合类型,用来指定对象中有什么键
// 第二个泛型参数表示对象中值的类型
// 比如,
type A = Record<’a’ | ‘b’, string>; // => { a: string; b: string }
// 该代码的作用:
// type PullStatus = ‘pulling’ | ‘canRelease’ | ‘refreshing’ | ‘complete’
const statusRecord: Record
pulling: ‘用力拉’,
canRelease: ‘松开吧’,
refreshing: ‘玩命加载中…’,
complete: ‘好啦’,
};
// 可以手动创建对象类型,但是,没有 Record 方便
type Obj = {
pulling: string;
canRelease: string;
refreshing: string;
complete: string;
};
- 使用字符串模板优化字面量类型
type A =
| {
type: ‘home/getChannels’;
payload: Channel[];
}
| {
type: ‘home/getRestChannels’;
payload: Channel[];
};
// 该写法的功能等价于上述写法:
// 使用字符串模板来优化字面类型
type A = {
// 使用字符串模板,来优化上面类型中的两个不同的 字面量类型
type: home/${'getChannels' | 'getRestChannels'}
;
payload: Channel[];
};
20-文章列表-渲染文章列表项内容
目标:能够通过文章列表数据渲染文章列表项内容
分析说明:
我们使用 dayjs 来格式化日期,文章列表项需要用到相对时间和国际化:
dayjs 国际化文档dayjs 相对时间插件
步骤:
- 安装 dayjs:yarn add dayjs
- 启用相对时间插件,并将语言设置为 zh-cn 中文
- 创建 renderArticleList 函数,来渲染文章列表
- 根据文章列表项的数据格式,为 ArticleItem 组件设置 props
- 组装好文章列表项数据,传递给 ArticleItem 组件
- ArticleItem 组件内部接收数据并渲染
核心代码:
ArticleList/index.tsx 中:
const ArticleList = ({ channelId }: Props) => {
const renderArticleList = () => {
return articles.map((item, index) => {
const {
title,
pubdate,
comm_count,
aut_name,
cover: { type, images },
} = item;
const articleData = {
title,
pubdate,
comm_count,
aut_name,
type,
images,
};
return (
);
});
};
return (
// …
{renderArticleList()}
);
};
ArticleItem/index.tsx 中:
import dayjs from ‘dayjs’;
// 相对时间插件
import relativeTime from ‘dayjs/plugin/relativeTime’;
// 国际化 - 中文
import ‘dayjs/locale/zh-cn’;
// 启用相对时间
dayjs.extend(relativeTime);
// 启用中文
dayjs.locale(‘zh-cn’);
type Props = {
/*
0 表示无图
1 表示单图
3 表示三图
/
type: number;
title: string;
pubdate: string;
comm_count: number;
aut_name: string;
art_id: string;
images: string[];
};
const ArticleItem = ({
type,
title,
pubdate,
comm_count,
aut_name,
images,
}: Props) => {
return (
‘article-content’,
type === 3 && ‘t3’,
type === 0 && ‘none-mt’,
)}
>
{title}
{type !== 0 && (
{/ 渲染文章的封面图片 */}
{images.map((item, index) => (
))}
)}
{aut_name}
{comm_count} 评论
{dayjs().from(dayjs(pubdate))}
);
};
21-文章列表-点击文章项跳转到详情
目标:能够在点击文章项时跳转到文章详情页面
步骤:
- 将文章详情页面模板拷贝到 pages 目录中
- 在 App 组件中配置文章详情页路由
- 为每个文章列表项绑定点击事件
- 点击时,根据文章 id,跳转到文章详情页对应的路由
核心代码:
ArticleList/index.tsx 中:
import { useHistory } from ‘react-router-dom’;
const ArticleList = () => {
const history = useHistory();
const renderArticleList = () => {
return articles.map((item, index) => {
return
/article/${art_id}
)}>});
};
};
App.tsx 中:
// 导入路由
import { Router, Route } from ‘react-router-dom’;
import Article from ‘./pages/Article’;
function App() {
return (
);
}