01-页面结构

目标:能够根据模板搭建首页页面结构
步骤

  1. 根据模板搭建 Home 页面结构

    02-使用 antd-mobile 组件库的 Tabs 组件

    目标:能够掌握 antd-mobile 组件中 Tabs 组件的使用
    步骤

  2. 打开 antd-mobile 组件库的文档,找到 Tabs 组件

  3. 找到示例代码,并拷贝到项目中
  4. 分析 Tabs 组件的结构和基本使用
  5. 调整 Tabs 的样式

核心代码
Home/index.tsx 中:
import { Tabs } from ‘antd-mobile’;

const Home = () => {
return (
// …
// 注意:此处别忘了添加 tabs 类名


推荐频道的内容


html频道的内容


开发者资讯频道的内容


c++频道的内容


css频道的内容


);
};

03-获取首页频道列表数据

目标:能够获取首页频道列表数据
分析说明
对于首页来说,不管用户是否登录,都可以查看。但是,是否登录会对后续的频道管理操作产生影响:

  1. 如果用户已登录,此时,获取到的就是用户自己的频道数据
    • 不管是添加频道还是删除频道,操作的都是自己的数据,并且这个数据是同步到服务器中的
  2. 如果用户未登录,此时,获取的是默认的频道列表数据
    • 注意:因为用户未登录,但还要实现频道的添加或删除操作并且在刷新页面后,也要保持修改后的频道数据
    • 所以,为了实现该效果,在用户未登录时,将频道数据保存到本地,在本地进行后续的操作

步骤

  1. 根据接口,在 types/data.d.ts 中添加频道列表数据的类型
  2. 创建 actions/home.ts 文件,在该文件中创建获取频道列表数据的 action 并导出
  3. 在该 action 中,判断用户是否登录
  4. 如果已登录,发送请求获取用户的频道列表数据
  5. 如果未登录,先判断本地缓存中有没有频道列表数据
  6. 如果有,拿到本地的频道列表数据
  7. 如果没有,就发送请求获取默认的频道列表数据,并存储到本地缓存中
  8. 在 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(‘/user/channels’);
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(‘/user/channels’);
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-渲染频道列表数据

目标:能够渲染首页频道列表数据
步骤

  1. 在 types/store.d.ts 中添加获取频道列表数据的 action 类型
  2. 回到获取频道列表数据的 action 中,分发 action 以将频道数据保存到 redux 中
  3. 创建 reducers/home.ts 文件,处理获取频道数据的 action
  4. 将状态 home 合并到根 reducer 中
  5. 在 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(‘/user/channels’);
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(‘/user/channels’);
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-频道管理-渲染频道管理弹出层

目标:能够渲染频道管理弹出层
步骤

  1. 将模板 Channels 拷贝到 Home/components 目录中
  2. 在 Home 组件中导入 Channels 组件
  3. 使用 Popup 组件渲染 Channels 内容
  4. 创建控制频道管理弹出层展示或隐藏的状态
  5. 控制弹出层的展示或隐藏

核心代码
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 (



// …



visible={visible}
onMaskClick={onChannelClose}
position=”left”
className=”channel-popup”
>



);
};
Home/components/Channels/index.tsx 中:
type Props = {
onClose: () => void;
};

const Channels = ({ onClose }: Props) => {
return (
// …



);
};

06-频道管理-渲染我的频道

目标:能够渲染我的频道列表
分析说明
我的频道中展示的数据就是在首页中获取到的用户频道列表数据,因此,只需要在频道管理组件中拿到用户频道列表数据即可
步骤

  1. 在 Channels 中,从 redux 中获取到用户频道数据
  2. 渲染用户频道列表数据

核心代码
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 }]
步骤

  1. 根据接口,在 types 中添加所有频道数据的类型
  2. 在 actions 中,发送请求,获取所有频道数据
  3. 拿到所有频道数据排除掉我的频道数据,得到可选频道数据
  4. 在 types 中,添加保存频道推荐数据的 action 类型
  5. 在 actions 中分发 action 将频道推荐数据存储到 redux 中
  6. 在 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(‘channels’);
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-频道管理-切换频道编辑状态

目标:能够切换频道编辑状态
步骤

  1. 添加控制是否为编辑的状态
  2. 给编辑/保存按钮添加点击事件
  3. 在点击事件中切换编辑状态
  4. 根据编辑状态判断展示保存或编辑文字内容

核心代码
Channels/index.tsx 中:
import { useState } from ‘react’;

const Channels = () => {
const [isEdit, setIsEdit] = useState(false);

const onChangeEdit = () => {
setIsEdit(!isEdit);
};

return (
// …


// …

{isEdit ? ‘保存’ : ‘编辑’}


);
};

10-频道管理-切换频道和高亮

目标:能够实现切换频道功能
分析说明
首页顶部的频道和频道管理中的我的频道是关联在一起的:

  1. 点击频道管理中的我的频道时,首页顶部的频道会切换,并高亮
  2. 点击首页顶部的频道时,频道管理对应的频道也要高亮

因此,需要准备一个状态用来记录当前选中频道,并且两个组件中都需要用到该状态,所以,可以直接将该状态存储到 redux 中,实现状态共享。 然后,不管是首页顶部的频道还是频道管理中的我的频道,只需要在点击切换时,修改 redux 中记录的高亮状态值即可。
步骤

  1. 在 home reducer 中,添加一个状态 channelActiveKey 用来记录当前选中频道的键
  2. 在 Channel 组件中拿到该状态,在渲染我的频道列表时,让对应 key 的频道高亮
  3. 为每个频道项添加点击事件,在点击事件中拿到每一个频道的 key,并分发 action 来切换选中项
  4. 在 types 中,添加修改 channelActiveKey 的 action 类型
  5. 在 home reducer 中,处理 action 来更新 channelActiveKey 的值
  6. 在 home reducer 获取首页顶部频道数据时,为 channelActiveKey 指定默认值
  7. 切换频道时,关闭频道弹出层

核心代码
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-频道管理-首页顶部频道切换

目标:能够实现首页频道切换和高亮功能
步骤

  1. 在 Home 组件中拿到该状态,并设置为 Tabs 组件的 activeKey
  2. 为 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-频道管理-删除频道

目标:能够删除我的频道数据
分析说明

  1. 推荐频道不能删除
  2. 至少要保留 4 个频道

步骤

  1. 修改频道项的点击事件参数为 channel 即当前频道数据
  2. 在我的频道项的点击事件中,判断当前是否为编辑状态
  3. 如果不是编辑状态,执行频道切换操作
  4. 如果是编辑状态,判断是否为推荐频道或频道数量小于等于 4
  5. 如果是,阻止删除
  6. 如果不是,分发删除频道的 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-频道管理-删除频道更新状态

目标:能够在删除频道后更新页面状态
步骤

  1. 在 types 中添加删除频道的 action 类型
  2. 在删除频道 action 中,分发 action 到 redux
  3. 在 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-频道管理-添加频道

目标:能够实现添加频道功能步骤

  1. 为可选频道中的频道项绑定点击事件,并拿到当前被点击的频道
  2. 在 actions 中根据是否登录处理添加频道逻辑
  3. 在 types 中增加添加频道的 action 类型
  4. 在添加频道的 action 中分发 action 来更新 redux
  5. 在 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-文章列表-根据模板搭建文章列表结构

目标:能够根据模板搭建频道文章列表结构
步骤

  1. 将模板 ArticleItem 拷贝到 src/components 公共组件目录中
  2. 将模板 components 拷贝到 pages/Home 目录中
  3. 在 Home 组件中渲染文章列表结构
  4. 分析每个模板的作用,以及模板的结构

核心代码
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 组件来说,在第一次渲染时,会进行以下判断,来决定是否加载更多数据:
    1. hasMore 是否为 true,如果为 true 调用 loadMore 加载更多数据
    2. 该组件所在位置 减去 可滚动区域的底部位置是否小于 threshold (默认值 250),如果小于就说明触底了,就会加载更多数据
  • 造成递归获取数据的情况:只发送请求获取数据,但是,没有渲染数据,导致位置判断一直小于 threshold,就会一直获取数据。

步骤

  1. 找到 antd-mobile 组件库中的 InfiniteScroll 组件
  2. 拷贝示例代码到 ArticleList 组件中
  3. 通过示例代码给出的数据,渲染文章列表

核心代码
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 表示没有更多数据了
/}


);
};
总结

  1. InfiniteScroll 组件会自动调用 loadMore 加载数据吗?如果 hasMore 为 true,就会自动加载数据;否则,不会自动加载
  • 该组件会自动填满可滚动区域,并且保证触发加载事件的滚动触底距离阈值(threshold)大于等于 250

    17-文章列表-获取频道文章列表数据

    目标:能够获取频道文章列表数据
    步骤
  1. 给 ArticleList 组件传递频道 id
  2. 根据接口,在 types/data.d.ts 中添加频道文章列表数据的类型
  3. 在 actions 中创建获取文章列表数据的 action
  4. 发送请求,获取频道文章列表数据
  5. 在 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(‘/articles’, {
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]
};
步骤

  1. 在 types/store.d.ts 中,添加获取频道文章列表数据对应的 action 类型
  2. 在 actions 中,分发 action 将状态保存到 redux 中
  3. 在 reducers 中,添加频道文章列表数据的类型和默认值
  4. 处理获取频道文章列表的 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-文章列表-触底加载更多文章列表项

目标:能够实现触底加载更多文章列表项
分析说明

  1. 如何加载前一页的数据?传递接口返回的 pre_timestamp
  2. 是否有更多数据:如果没有更多文章数据,则 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 组件来说,在第一次渲染时,会进行以下判断,来决定是否加载更多数据:
    1. hasMore 是否为 true,如果为 true 调用 loadMore 加载更多数据
    2. 该组件所在位置 减去 可滚动区域的底部位置是否小于 threshold (默认值 250),如果小于就说明触底了,就会加载更多数据
  • 造成递归获取数据的情况:只发送请求获取数据,但是,没有渲染数据,导致位置判断一直小于 threshold,就会一直获取数据。

步骤

  1. 在 ArticleList 组件中拿到频道文章列表数据
  2. 根据当前频道 id 拿到当前频道的文章列表数据,并处理默认值问题
  3. 修改判断是否有更多文章列表数据的逻辑
  4. 渲染文章列表数据

核心代码
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 组件,进入页面就会一直不停的加载所有文章列表数据,可能是以下原因造成的:

  1. 只更新文章列表状态,没有渲染文章列表数据。导致,InfiniteScroll 组件感觉有更多数据(一直没有触底),就会一直加载数据
  2. 样式造成的问题:要保证从 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-文章列表-下拉加载更多文章数据

目标:能够下拉加载更多文章数据
步骤

  1. 导入 antd-mobile 的 PullToRefresh 下拉刷新组件
  2. 使用下拉刷新组件获取最新数据

核心代码
Home/index.tsx 中:
import { PullToRefresh } from ‘antd-mobile’;

const ArticleList = () => {
// 下拉刷新文章列表
const onRefresh = async () => {
await dispatch(getArticleList(channelId, Date.now() + ‘’));
};

return (
// …

{renderArticleList()}


);
};
补充:

  1. 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;
};

  1. 使用字符串模板优化字面量类型

type A =
| {
type: ‘home/getChannels’;
payload: Channel[];
}
| {
type: ‘home/getRestChannels’;
payload: Channel[];
};

// 该写法的功能等价于上述写法:

// 使用字符串模板来优化字面类型
type A = {
// 使用字符串模板,来优化上面类型中的两个不同的 字面量类型
type: home/${'getChannels' | 'getRestChannels'};
payload: Channel[];
};

20-文章列表-渲染文章列表项内容

目标:能够通过文章列表数据渲染文章列表项内容
分析说明
我们使用 dayjs 来格式化日期,文章列表项需要用到相对时间和国际化:
dayjs 国际化文档dayjs 相对时间插件
步骤

  1. 安装 dayjs:yarn add dayjs
  2. 启用相对时间插件,并将语言设置为 zh-cn 中文
  3. 创建 renderArticleList 函数,来渲染文章列表
  4. 根据文章列表项的数据格式,为 ArticleItem 组件设置 props
  5. 组装好文章列表项数据,传递给 ArticleItem 组件
  6. 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 (

className={classnames(
‘article-content’,
type === 3 && ‘t3’,
type === 0 && ‘none-mt’,
)}
>

{title}


{type !== 0 && (

{/
渲染文章的封面图片 */}
{images.map((item, index) => (

首页 - 图1

))}

)}


{aut_name}
{comm_count} 评论
{dayjs().from(dayjs(pubdate))}





);
};

21-文章列表-点击文章项跳转到详情

目标:能够在点击文章项时跳转到文章详情页面
步骤

  1. 将文章详情页面模板拷贝到 pages 目录中
  2. 在 App 组件中配置文章详情页路由
  3. 为每个文章列表项绑定点击事件
  4. 点击时,根据文章 id,跳转到文章详情页对应的路由

核心代码
ArticleList/index.tsx 中:
import { useHistory } from ‘react-router-dom’;

const ArticleList = () => {
const history = useHistory();

const renderArticleList = () => {
return articles.map((item, index) => {
return

history.push(/article/${art_id})}>
;
});
};
};
App.tsx 中:
// 导入路由
import { Router, Route } from ‘react-router-dom’;

import Article from ‘./pages/Article’;

function App() {
return (







);
}