危险

为之则易,不为则难

0%

18_极客园 H5

今日目标

✔ 理解频道管理的业务逻辑。

✔ 理解文章列表的业务逻辑。

✔ 理解搜索功能的业务逻辑。

渲染我的频道

页面结构

pages/Home/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import Icon from '@/components/Icon'

import styles from './index.module.scss'

const Home = () => {
return (
<div className={styles.root}>
{/* 频道 Tabs 列表 */}
<div className='tabs-opration'>
<Icon type='iconbtn_search' />
<Icon type='iconbtn_channel' />
</div>
</div>
)
}

export default Home

pages/Home/index.module.scss

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
.root {
height: 100%;

:global {
.tabs {
height: 100%;
padding: 44px 0 50px 0;

.adm-tabs-header {
position: fixed;
top: 0;
z-index: 2;
height: 44px;
width: calc(100vw - 86px);
background-color: #fff;
}

.adm-tabs-tab {
line-height: 25px;
color: #9ea1ae;
font-size: 15px;
}
.adm-tabs-tab-active {
color: var(--adm-color-text);
font-size: 18px;
}

.adm-tabs-tab-line {
height: 3px;
border-radius: 2px;
}

.adm-tabs-content {
height: 100%;
padding: 0;
}
}

.tabs-opration {
position: absolute;
top: 0;
right: 0;
z-index: 2;
display: flex;
align-items: center;
width: 86px;
height: 44px;
padding: 0 14px;
background: #fff;
border-bottom: 1px solid #efefef;

.icon {
color: #9ea1ae;
font-size: 18px;

&:first-child {
margin-right: 21px;
}
}
}
}
}

:global {
.channel-popup {
.adm-popup-body {
overflow-y: scroll;
}
}
}

Tabs 组件

pages/Home/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import Icon from '@/components/Icon'
import { Tabs } from 'antd-mobile'
import styles from './index.module.scss'

const Home = () => {
return (
<div className={styles.root}>
<Tabs className='tabs' activeLineMode='fixed'>
<Tabs.Tab title='推荐' key='1'>
推荐频道的内容
</Tabs.Tab>
<Tabs.Tab title='html' key='2'>
html频道的内容
</Tabs.Tab>
<Tabs.Tab title='开发者资讯' key='3'>
开发者资讯频道的内容
</Tabs.Tab>
<Tabs.Tab title='c++' key='4'>
c++频道的内容
</Tabs.Tab>
<Tabs.Tab title='css' key='5'>
css频道的内容
</Tabs.Tab>
</Tabs>
{/* 频道 Tabs 列表 */}
<div className='tabs-opration'>
<Icon type='iconbtn_search' />
<Icon type='iconbtn_channel' />
</div>
</div>
)
}

export default Home

获取频道列表数据

  1. types/data.d.ts
1
2
3
4
export type Channel = {
id: number
name: string
}
  1. actions/home.ts
1
2
3
4
5
6
7
8
9
10
11
import { ApiResponse, Channel } from '@/types/data'
import { RootThunkAction } from '@/types/store'
import request from '@/utils/request'

export const getUserChannel = (): RootThunkAction => {
return async (dispatch, getState) => {
const res = await request.get<ApiResponse<{ channels: Channel[] }>>('/user/channels')
const { channels } = res.data.data
console.log('频道', channels)
}
}
  1. pages/Home/index.tsx
1
2
// 先测试下拿到的数据,实际应该拿 'home' 的数据
useInitState(getUserChannel, 'profile')

存储到 Redux

types/store.d.ts

1
2
3
4
5
6
7
8
import { Channel } from './data'

export type HomeAction = {
type: 'home/getUserChannels'
payload: Channel[]
}

type RootAction = LoginAction | ProfileAction | HomeAction

actions/home.ts

1
2
3
4
5
6
7
8
9
export function getUserChannel(): RootThunkAction {
return async (dispatch) => {
const res = await request.get<ApiResponse<{ channels: Channel[] }>>('/user/channels')
dispatch({
type: 'home/getUserChannels',
payload: res.data.data.channels,
})
}
}

reducers/home.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { Channel } from '@/types/data'
import { HomeAction } from '@/types/store'
type HomeState = {
userChannels: Channel[]
}
const initValue: HomeState = {
userChannels: [],
}
export default function home(state = initValue, action: HomeAction): HomeState {
if (action.type === 'home/getUserChannels') {
return {
...state,
userChannels: action.payload,
}
}
return state
}

reducers/index.ts

1
2
3
import home from './home'

export default combineReducers({ login, profile, home })

渲染数据

Home/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import Icon from '@/components/Icon'
import { getUserChannel } from '@/store/actions/home'
import { useInitState } from '@/utils/hooks'
import { Tabs } from 'antd-mobile'
import styles from './index.module.scss'

const Home = () => {
const { userChannels } = useInitState(getUserChannel, 'home')
return (
<div className={styles.root}>
<Tabs className='tabs' activeLineMode='fixed'>
{userChannels.map((item) => (
<Tabs.Tab title={item.name} key={item.id}>
{item.name}的内容
</Tabs.Tab>
))}
</Tabs>
{/* 频道 Tabs 列表 */}
<div className='tabs-opration'>
<Icon type='iconbtn_search' />
<Icon type='iconbtn_channel' />
</div>
</div>
)
}

export default Home

解决初始高亮 Tab 底部的位置问题。

1
2
3
4
5
6
7
8
9
10
11
12
// 注意 userChannels 是在外部做的判断,ok 的话渲染整个 Tabs 组件
{
userChannels.length > 0 && (
<Tabs className='tabs' activeLineMode='fixed'>
{userChannels.map((item) => (
<Tabs.Tab title={item.name} key={item.id}>
{item.name}的内容
</Tabs.Tab>
))}
</Tabs>
)
}

渲染频道管理

弹层显示隐藏

目标

能够渲染频道管理弹出层。

素材

Home/components/Channels/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import classnames from 'classnames'

import Icon from '@/components/Icon'
import styles from './index.module.scss'

const Channels = () => {
return (
<div className={styles.root}>
<div className='channel-header'>
<Icon type='iconbtn_channel_close' />
</div>
<div className='channel-content'>
{/* 编辑时,添加类名 edit */}
<div className={classnames('channel-item')}>
<div className='channel-item-header'>
<span className='channel-item-title'>我的频道</span>
<span className='channel-item-title-extra'>点击进入频道</span>
<span className='channel-item-edit'>编辑</span>
</div>
<div className='channel-list'>
{/* 选中时,添加类名 selected */}
<span className={classnames('channel-list-item')}>
推荐
<Icon type='iconbtn_tag_close' />
</span>
</div>
</div>

<div className='channel-item'>
<div className='channel-item-header'>
<span className='channel-item-title'>频道推荐</span>
<span className='channel-item-title-extra'>点击添加频道</span>
</div>
<div className='channel-list'>
<span className='channel-list-item'>+ HTML</span>
</div>
</div>
</div>
</div>
)
}

export default Channels

Home/components/Channels/index.module.scss

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
.root {
width: 375px;
:global {
.channel-header {
position: relative;
height: 44px;
padding: 13px 0;

.icon {
position: absolute;
right: 16px;
width: 18px;
height: 18px;
}
}

.channel-content {
padding: 0 16px;

.channel-item {
margin-bottom: 16px;

&.edit {
.channel-item-edit {
color: #fff;
background-color: #de644b;
}
.icon {
display: block;
}
}
}

.channel-item-header {
position: relative;
height: 22px;
line-height: 22px;
margin-bottom: 22px;

.channel-item-title {
margin-right: 10px;
font-size: 17px;
color: #333;
font-weight: 500;
}

.channel-item-title-extra {
font-size: 12px;
color: #a5a6ab;
}

.channel-item-edit {
display: inline-block;
position: absolute;
right: 0;
width: 52px;
height: 22px;
line-height: 21px;
border: 1px solid #de644b;
border-radius: 12px;
color: #de644b;
text-align: center;
font-size: 12px;
}
}

.channel-list {
&-item {
position: relative;
display: inline-block;
padding: 10px 23px;
margin-right: 13px;
margin-bottom: 16px;
border-radius: 18px;
color: #3a3948;
font-size: 14px;
background-color: #f7f8fa;

.icon {
display: none;
position: absolute;
top: -5px;
right: 0;
}
}

&-item.selected {
color: #fc6627;
}
}
}
}
}

步骤

  1. 将模板 Channels 拷贝到 Home/components 目录中。

  2. 在 Home 组件中导入 Channels 组件。

  3. 使用 Popup 组件渲染 Channels 内容。

  4. 创建控制频道管理弹出层展示或隐藏的状态。

  5. 控制弹出层的展示或隐藏。

代码

Home/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import { useState } from 'react'
import Icon from '@/components/Icon'
import { getUserChannel } from '@/store/actions/home'
import { useInitState } from '@/utils/hooks'
import { Tabs, Popup } from 'antd-mobile'
import styles from './index.module.scss'
import Channels from './components/Channels'

const Home = () => {
const [visible, setVisible] = useState(false)
const { userChannels } = useInitState(getUserChannel, 'home')
const show = () => {
setVisible(true)
}
const hide = () => {
setVisible(false)
}
return (
<div className={styles.root}>
{userChannels.length > 0 && (
<Tabs className='tabs' activeLineMode='fixed'>
{userChannels.map((item) => (
<Tabs.Tab title={item.name} key={item.id}>
{item.name}的内容
</Tabs.Tab>
))}
</Tabs>
)}
{/* 频道 Tabs 列表 */}
<div className='tabs-opration'>
<Icon type='iconbtn_search' />
<Icon type='iconbtn_channel' onClick={show} />
</div>
<Popup position='left' visible={visible}>
<Channels hide={hide} />
</Popup>
</div>
)
}

export default Home

Home/components/Channels/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
type Props = {
hide: () => void
}

const Channels = ({ hide }: Props) => {
return (
<div className={styles.root}>
<div className='channel-header'>
<Icon type='iconbtn_channel_close' onClick={hide} />
</div>
</div>
)
}

渲染我的频道

目标

能够渲染我的频道列表。

步骤

我的频道中展示的数据就是在首页中获取到的用户频道列表数据,因此只需要在频道管理组件中拿到用户频道列表数据即可。

  1. 在 Channels 中,从 redux 中获取到用户频道数据。

  2. 渲染用户频道列表数据。

Channels/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { useSelector } from 'react-redux'
import { RootState } from '@/types/store'

const Channels = ({ hide }: Props) => {
const { userChannels } = useSelector((state: RootState) => state.home)
return (
<div className='channel-list'>
{/* 选中时,添加类名 selected */}
{userChannels.map((item) => (
<span key={item.id} className={classnames('channel-list-item')}>
{item.name}
<Icon type='iconbtn_tag_close' />
</span>
))}
</div>
)
}

获取所有频道

目标

能够获取到所有频道的数据。

代码

频道推荐中展示的是除了我的频道之外的其他频道数据,由于接口并没有直接提供频道推荐数据,因此可以拿到所有频道数据,然后排除掉我的频道数据,剩下的就是频道推荐数据了。

types/store.d.ts

1
2
3
4
5
6
7
8
9
export type HomeAction =
| {
type: 'home/getUserChannels'
payload: Channel[]
}
| {
type: 'home/getAllChannels'
payload: Channel[]
}

actions/home.ts

1
2
3
4
5
6
7
8
9
export function getAllChannel(): RootThunkAction {
return async (dispatch) => {
const res = await request.get<ApiResponse<{ channels: Channel[] }>>('/channels')
dispatch({
type: 'home/getAllChannels',
payload: res.data.data.channels,
})
}
}

reducers/home.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { Channel } from '@/types/data'
import { HomeAction } from '@/types/store'
type HomeState = {
userChannels: Channel[]
allChannels: Channel[]
}
const initValue: HomeState = {
userChannels: [],
allChannels: [],
}
export default function home(state = initValue, action: HomeAction): HomeState {
if (action.type === 'home/getUserChannels') {
return {
...state,
userChannels: action.payload,
}
}
if (action.type === 'home/getAllChannels') {
return {
...state,
allChannels: action.payload,
}
}
return state
}

home/index.tsx

1
2
3
4
const Home = () => {
// 目的:把请求到的数据存储到 Redux
useInitState(getAllChannel, 'home')
}

渲染频道推荐

目标

能够渲染频道推荐列表。

代码

Home/components/Channels/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const Channels = ({ hide }: Props) => {
const { userChannels } = useSelector((state: RootState) => state.home)
const recommendChannels = useSelector((state: RootState) => {
const { userChannels, allChannels } = state.home
// 保留从 userChannels 中找不到的
return allChannels.filter((item) => userChannels.findIndex((v) => v.id === item.id) === -1)
})
return (
<div className='channel-list'>
{recommendChannels.map((item) => (
<span key={item.id} className='channel-list-item'>
+ {item.name}
</span>
))}
</div>
)
}

使用 lodash 提供的方法,计算出推荐频道。

1
yarn add lodash @types/lodash
1
const recommendChannels = differenceBy(allChannels, userChannels, 'id')

我的频道逻辑

分析

  • 如果用户登录了,需要发送请求获取用户的频道。

  • 如果用户没有登录,从 localstorage 中获取用户的频道信息。

  • 如果本地没有用户的频道信息,需要发送请求获取默认的频道,把默认频道存储到本地。

代码

utils/storage.ts

1
2
3
4
5
6
7
const CHANNEL_KEY = 'GEEK_H5_CHANNEL'
export function setChannels(channels: Channel[]): void {
localStorage.setItem(CHANNEL_KEY, JSON.stringify(channels))
}
export function getChannels(): Channel[] {
return JSON.parse(localStorage.getItem(CHANNEL_KEY) || '[]')
}

actions/home.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
export const getUserChannel = (): RootThunkAction => {
return async (dispatch) => {
if (hasToken()) {
// !#1 登录了
const res = await request.get<ApiResponse<{ channels: Channel[] }>>('/user/channels')
dispatch({
type: 'home/getUserChannels',
payload: res.data.data.channels,
})
} else {
// !#2 未登录
const channels = getChannels()
if (channels.length > 0) {
// !#3 本地有
dispatch({
type: 'home/getUserChannels',
payload: channels,
})
} else {
// !#4 本地无
const res = await request.get<ApiResponse<{ channels: Channel[] }>>('/user/channels')
dispatch({
type: 'home/getUserChannels',
payload: res.data.data.channels,
})
// !#5 存储:本地存储的应该是与登录状态无关的频道数据
setChannels(res.data.data.channels)
}
}
}
}

频道管理交互

点击我的频道高亮

目标

能够实现切换频道功能。

分析

  1. 首先明确:首页顶部的频道和频道管理中的我的频道是关联在一起的。

  2. 点击频道管理中的我的频道时,首页顶部的频道会切换,并高亮。

  3. 点击首页顶部的频道时,频道管理对应的频道也要高亮。

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

代码

types/store.d.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
export type HomeAction =
| {
type: 'home/getUserChannels'
payload: Channel[]
}
| {
type: 'home/getAllChannels'
payload: Channel[]
}
| {
type: 'home/changeActive'
payload: number
}

actions/home.ts

1
2
3
4
5
6
export function changeActive(id: number): HomeAction {
return {
type: 'home/changeActive',
payload: id,
}
}

redcuers/home.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { Channel } from '@/types/data'
import { HomeAction } from '@/types/store'

type HomeState = {
userChannels: Channel[]
allChannels: Channel[]
// #1
active: number
}
const initState: HomeState = {
userChannels: [],
allChannels: [],
// #2
active: 0,
}

export default function home(state = initState, action: HomeAction): HomeState {
switch (action.type) {
case 'home/getUserChannels':
return {
...state,
userChannels: action.payload,
}
case 'home/getAllChannels':
return {
...state,
allChannels: action.payload,
}
// #3
case 'home/changeActive':
return {
...state,
active: action.payload,
}
default:
return state
}
}

pages/Home/components/Channels/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import classnames from 'classnames'
import { useSelector, useDispatch } from 'react-redux'
import { RootState } from '@/types/store'
import Icon from '@/components/Icon'
import { differenceBy } from 'lodash'
import styles from './index.module.scss'
import { changeActive } from '@/store/actions/home'
type Props = {
hide: () => void
}
const Channels = ({ hide }: Props) => {
const dispatch = useDispatch()
// !#3 获取 Redux 中的 active 并和循环时候的 item.id 进行比较,命中则应用高亮的 class
const { userChannels, allChannels, active } = useSelector((state: RootState) => state.home)
const recommendChannels = differenceBy(allChannels, userChannels, 'id')
// !#2 修改 Redux 中的 active 并隐藏弹框
const onChange = (id: number) => {
dispatch(changeActive(id))
hide()
}
return (
<div className={styles.root}>
<div className='channel-header'>
<Icon type='iconbtn_channel_close' onClick={hide} />
</div>
<div className='channel-content'>
{/* 编辑时,添加类名 edit */}
<div className={classnames('channel-item')}>
<div className='channel-item-header'>
<span className='channel-item-title'>我的频道</span>
<span className='channel-item-title-extra'>点击进入频道</span>
<span className='channel-item-edit'>编辑</span>
</div>
<div className='channel-list'>
{/* //!#1 绑定点击事件 */}
{userChannels.map((item) => (
<span
key={item.id}
className={classnames('channel-list-item', {
selected: item.id === active,
})}
onClick={() => onChange(item.id)}
>
{item.name}
<Icon type='iconbtn_tag_close' />
</span>
))}
</div>
</div>
<div className='channel-item'>
<div className='channel-item-header'>
<span className='channel-item-title'>频道推荐</span>
<span className='channel-item-title-extra'>点击添加频道</span>
</div>
<div className='channel-list'>
{recommendChannels.map((item) => (
<span key={item.id} className='channel-list-item'>
+ {item.name}
</span>
))}
</div>
</div>
</div>
</div>
)
}

export default Channels

处理 Tabs 的高亮

目标

能够实现首页频道切换和高亮功能。

步骤

  1. 在 Home 组件中拿到该状态(active),并设置为 Tabs 组件的 activeKey。

  2. 为 Tabs 组件添加 onChange,拿到当前选中的 tab 的键,并且分发 action 来修改 channelActiveKey。

核心代码

Home/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import { useState } from 'react'
import { useDispatch } from 'react-redux'
import Icon from '@/components/Icon'
import { changeActive, getAllChannel, getUserChannel } from '@/store/actions/home'
import { useInitState } from '@/utils/hooks'
import { Tabs, Popup } from 'antd-mobile'
import styles from './index.module.scss'
import Channels from './components/Channels'

const Home = () => {
const dispatch = useDispatch()
const [visible, setVisible] = useState(false)
// #1
const { userChannels, active } = useInitState(getUserChannel, 'home')
useInitState(getAllChannel, 'home')
const show = () => {
setVisible(true)
}
const hide = () => {
setVisible(false)
}
// !#3
const onChange = (key: string) => {
dispatch(changeActive(+key))
}
return (
<div className={styles.root}>
{/* //!#2 */}
{userChannels.length > 0 && (
<Tabs className='tabs' activeLineMode='fixed' activeKey={active + ''} onChange={onChange}>
{userChannels.map((item) => (
<Tabs.Tab title={item.name} key={item.id}>
{item.name}的内容
</Tabs.Tab>
))}
</Tabs>
)}
{/* 频道 Tabs 列表 */}
<div className='tabs-opration'>
<Icon type='iconbtn_search' />
<Icon type='iconbtn_channel' onClick={show} />
</div>
<Popup position='left' visible={visible}>
<Channels hide={hide} />
</Popup>
</div>
)
}

export default Home

编辑状态的处理

目标

能够切换频道编辑状态。

步骤

  1. 添加控制是否为编辑的状态。

  2. 给编辑/保存按钮添加点击事件。

  3. 在点击事件中切换编辑状态。

  4. 根据编辑状态判断展示保存或编辑文字内容。

代码

pages/Home/components/Channels/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
import { useState } from 'react'
import classnames from 'classnames'
import { useSelector, useDispatch } from 'react-redux'
import { RootState } from '@/types/store'
import Icon from '@/components/Icon'
import { differenceBy } from 'lodash'
import styles from './index.module.scss'
import { changeActive } from '@/store/actions/home'
type Props = {
hide: () => void
}
const Channels = ({ hide }: Props) => {
const dispatch = useDispatch()
// !#1
const [isEdit, setIsEdit] = useState(false)
const { userChannels, allChannels, active } = useSelector((state: RootState) => state.home)
const recommendChannels = differenceBy(allChannels, userChannels, 'id')
const onChange = (id: number) => {
// !#6 编辑状态不允许切换
if (isEdit) return
dispatch(changeActive(id))
hide()
}
// !#4
const changeEdit = () => {
setIsEdit(!isEdit)
}
return (
<div className={styles.root}>
<div className='channel-header'>
<Icon type='iconbtn_channel_close' onClick={hide} />
</div>
<div className='channel-content'>
{/* //!#2 编辑时,添加类名 edit */}
<div className={classnames('channel-item', isEdit && 'edit')}>
<div className='channel-item-header'>
<span className='channel-item-title'>我的频道</span>
{/* //!#5 */}
<span className='channel-item-title-extra'>{isEdit ? '点击删除频道' : '点击进入频道'}</span>
{/* //!#3: 绑定点击事件并处理文案 */}
<span className='channel-item-edit' onClick={changeEdit}>
{isEdit ? '完成' : '编辑'}
</span>
</div>
<div className='channel-list'>
{userChannels.map((item) => (
<span
key={item.id}
className={classnames('channel-list-item', {
selected: item.id === active,
})}
onClick={() => onChange(item.id)}
>
{item.name}
<Icon type='iconbtn_tag_close' />
</span>
))}
</div>
</div>
<div className='channel-item'>
<div className='channel-item-header'>
<span className='channel-item-title'>频道推荐</span>
<span className='channel-item-title-extra'>点击添加频道</span>
</div>
<div className='channel-list'>
{recommendChannels.map((item) => (
<span key={item.id} className='channel-list-item'>
+ {item.name}
</span>
))}
</div>
</div>
</div>
</div>
)
}

export default Channels

删除频道

目标

能够删除我的频道数据。

分析

  1. 推荐频道不能删除。

  2. 至少要保留 4 个频道。

步骤

  1. 如果是推荐频道则不显示删除按钮。

  2. 给频道的删除按钮绑定点击事件。

  3. 如果频道数量小于等于 4 则返回。

  4. 如果点击的是当前的,则激活推荐频道。

  5. 删除。

代码

actions/home.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export function delChannel(id: number): RootThunkAction {
return async (dispatch, getState) => {
const { userChannels } = getState().home
if (hasToken()) {
// 登录了
await request.delete('/user/channels', {
data: {
channels: [id],
},
})
} else {
// 没有登录
// const userChannels = getChannels()
setChannels(userChannels.filter((item) => item.id !== id))
}
// 更新到 Redux
/* dispatch({
type: 'home/getUserChannels',
payload: userChannels.filter((item) => item.id !== id),
}) */
dispatch(getUserChannel())
}
}

Channels/index.tsx

1
<Icon type='iconbtn_tag_close' onClick={() => dispatch(delChannel(item.id))} />

优化

推荐不能有 x 号,超过 4 条才能删。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<div className='channel-list'>
{userChannels.map((item) => (
<span
key={item.id}
className={classnames('channel-list-item', {
selected: item.id === active,
})}
onClick={() => onChange(item.id)}
>
{item.name}
{item.id !== 0 && (
<Icon
type='iconbtn_tag_close'
onClick={() => {
if (userChannels.length <= 4) return
// 删除的是当前的,就回到推荐吧
if (item.id === active) dispatch(changeActive(0))
dispatch(delChannel(item.id))
}}
/>
)}
</span>
))}
</div>

添加频道

目标

能够实现添加频道功能。

代码

store/actions/home.ts 中,实现 Action Creator。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export const addChannel = (channel: Channel): RootThunkAction => {
return async (dispatch, getState) => {
const { userChannels } = getState().home
if (hasToken()) {
await request.patch('/user/channels', {
channels: [
{
id: channel.id,
seq: userChannels.length + 1,
},
],
})
} else {
setChannels([...userChannels, channel])
}
dispatch({
type: 'home/getUserChannels',
payload: [...userChannels, channel],
})
}
}

Channels/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const Channels = () => {
const onAddChannel = (channel: Channel) => {
dispatch(addChannel(channel))
}
return (
<div className='channel-list'>
{recommendChannels.map((item) => (
<span key={item.id} className='channel-list-item' onClick={() => onAddChannel(item)}>
+ {item.name}
</span>
))}
</div>
)
}

文章列表处理

基本结构

目标

能够根据模板搭建频道文章列表结构。

素材

pages/Home/components/ArticleItem/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import classnames from 'classnames'

import Icon from '@/components/Icon'

import styles from './index.module.scss'

type Props = {
/**
* 0 表示无图
* 1 表示单图
* 3 表示三图
*/
type?: 0 | 1 | 3
}

const ArticleItem = ({ type = 0 }: Props) => {
return (
<div className={styles.root}>
<div className={classnames('article-content', type === 3 && 't3', type === 0 && 'none-mt')}>
<h3>Vue响应式----数据响应式原理</h3>
{type !== 0 && (
<div className='article-imgs'>
<div className='article-img-wrapper'>
<img src='http://geek.itheima.net/resources/images/63.jpg' alt='' />
</div>
</div>
)}
</div>
<div className={classnames('article-info', type === 0 && 'none-mt')}>
<span>黑马先锋</span>
<span>99 评论</span>
<span>2 天前</span>
<span className='close'>
<Icon type='iconbtn_essay_close' />
</span>
</div>
</div>
)
}

export default ArticleItem

pages/Home/components/ArticleItem/index.module.scss

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
@import '@scss/hairline.scss';

.root {
position: relative;
padding: 15px 0;
@include hairline(bottom, #f0f0f0);

:global {
.article-content {
display: flex;
align-items: flex-start;

h3 {
flex: 1;
padding-right: 10px;
margin-bottom: 10px;
font-weight: normal;
line-height: 22px;
font-size: 17px;
word-break: break-all;

display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}

.article-img-wrapper {
width: 110px;
height: 75px;
// 不要添 overflow 否则会导致 Image lazy 出现 Bug
// overflow: hidden;
border-radius: 4px;

img {
object-fit: cover;
height: 100%;
width: 100%;
}
}
}

.article-content.t3 {
display: block;

.article-imgs {
display: flex;
justify-content: space-between;
}
}

.article-info {
position: relative;
margin-top: 10px;
color: #999;
line-height: 22px;
font-size: 12px;

span {
padding-right: 10px;

&.close {
position: absolute;
right: 0;
padding-right: 0;
}
}
}

.none-mt {
margin-top: 0;

h3 {
margin-bottom: 4px;
}
}
}
}

pages/Home/components/ArticleList/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import ArticleItem from '../ArticleItem'

import styles from './index.module.scss'

const ArticleList = () => {
return (
<div className={styles.root}>
{/* 文章列表中的每一项 */}
<div className='article-item'>
<ArticleItem />
</div>
</div>
)
}

export default ArticleList

pages/Home/components/ArticleList/index.module.scss

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.root {
height: 100%;
padding: 0 16px;
// 注意:需要在此处设置超出滚动,这样才能保证每个 tab 之间的滚动位置不会相互影响
overflow-y: scroll;

:global {
.articles {
padding-bottom: 50px;
}
.article-item {
position: relative;
color: #323233;
font-size: 14px;
background-color: #fff;
}
}
}

步骤

  1. 将模板 ArticleList 和 ArticleItem 拷贝到 pages/Home/components 目录中。

  2. 在 Home 组件中渲染文章列表结构。

  3. 分析每个模板的作用,以及模板的结构。

代码

Home/index.tsx

1
2
3
4
5
6
7
8
9
10
11
import ArticleList from './components/ArticleList'

const Home = () => {
return (
// ...
<Tabs.Tab>
{/* 在每个 Tabs.TabPane 中渲染文章列表组件 */}
<ArticleList />
</Tabs.Tab>
)
}

获取文章列表数据

目标

能够获取到文章列表数据并且进行渲染。

步骤

  1. 分析接口文档,定义频道的数据类型,types/data.d.ts 中。

  2. 准备获取文章列表的 action,在 action 中发送请求。

  3. 发送请求需要频道 id,父组件将频道 id 传递给 ArticleList 组件。

  4. ArticleList 组件接受频道 Id 并发送请求获取频道列表数据。

代码

  • 分析接口文档,定义频道的数据类型,types/data.d.ts 中:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
export type Article = {
art_id: string
title: string
aut_id: string
comm_count: number
pubdate: string
aut_name: string
is_top: number
cover: {
type: 0 | 1 | 3
images: string[]
}
}

export type ArticleRes = {
pre_timestamp: string
results: Article[]
}
  • 准备获取文章列表的 action,actions/home.ts 中:
1
2
3
4
5
6
7
8
9
10
11
export const getArticleList = (channel_id: number, timestamp: string): RootThunkAction => {
return async (dispatch) => {
const res = await request.get<ApiResponse<ArticleRes>>('/articles', {
params: {
channel_id,
timestamp,
},
})
console.log(res)
}
}
  • Home/index.tsx 父组件中将频道 id 传递给 ArticleList 组件 。
1
2
3
4
5
6
7
8
9
10
11
12
const Home = () => {
return (
<Tabs>
{userChannel.map((item) => (
<Tabs.Tab title={item.name} key={item.id}>
{/* 传递频道 id */}
<ArticleList channelId={item.id} />
</Tabs.Tab>
))}
</Tabs>
)
}
  • ArticleList 组件接受频道 Id Home/components/ArticleList.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { getArticleList } from '@/store/actions/home'
import { useEffect } from 'react'
import { useDispatch } from 'react-redux'
import ArticleItem from '../ArticleItem'
import styles from './index.module.scss'
type Props = {
channelId: number
}
const ArticleList = ({ channelId }: Props) => {
const dispatch = useDispatch()
useEffect(() => {
dispatch(getArticleList(channelId, Date.now() + ''))
}, [dispatch, channelId])
return (
<div className={styles.root}>
{[1, 2, 3].map((item, index) => (
<div className='article-item' key={index}>
<ArticleItem />
</div>
))}
</div>
)
}

export default ArticleList

将数据存储到 Redux 中

目标

能够将数据存储到 Redux 中。

分析

问题:用什么样的数据格式存储频道文章列表数据?每个频道,都对应到一个文章列表数据

1
2
3
4
5
6
7
8
9
10
11
// 推荐频道
0 ==> {
pre_timestamp: xxxx,
articles: []
}
// html 频道
1 ==> {

}
// 开发者资讯频道
2 ==> {}

为了高效的存储数据,我们使用 对象 来存储频道文章列表数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 数据格式:
channelArticles = {
1: {
timestamp: '1638408103353',
articles: [],
},
2: {
timestamp: '1638408103353',
articles: [],
},
3: {
timestamp: '1638408103353',
articles: [],
},
}

代码

  • types/store.d.ts 中,定义 action 的类型。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export type HomeAction =
| {
type: 'home/getUserChannels'
payload: Channel[]
}
| {
type: 'home/getAllChannels'
payload: Channel[]
}
| {
type: 'home/changeActive'
payload: number
}
| {
type: 'home/saveChannelArticles'
payload: {
channel_id: number // key
timestamp: string // 请求下一页数据用的
articles: Article[] // 列表
}
}
  • 在 actions/home.ts 中,分发 action。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export const getArticleList = (channel_id: number, timestamp: string): RootThunkAction => {
return async (dispatch) => {
const res = await request.get<ApiResponse<ArticleRes>>('/articles', {
params: {
channel_id,
timestamp,
},
})
const { pre_timestamp, results } = res.data.data
dispatch({
type: 'home/saveChannelArticles',
payload: {
channel_id,
timestamp: pre_timestamp,
articles: results,
},
})
}
}
  • 在 reducers/home.ts 中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import { Article, Channel } from '@/types/data'
import { HomeAction } from '@/types/store'
type HomeState = {
userChannels: Channel[]
allChannels: Channel[]
active: number
// #1
channelArticles: {
[key: number]: {
timestamp: string
articles: Article[]
}
}
}
const initValue: HomeState = {
userChannels: [],
allChannels: [],
active: 0,
// #2
channelArticles: {},
}
export default function home(state = initValue, action: HomeAction): HomeState {
if (action.type === 'home/getUserChannels') {
return {
...state,
userChannels: action.payload,
}
}
if (action.type === 'home/getAllChannels') {
return {
...state,
allChannels: action.payload,
}
}
if (action.type === 'home/changeActive') {
return {
...state,
active: action.payload,
}
}
// #3
if (action.type === 'home/saveChannelArticles') {
const { channel_id, timestamp, articles } = action.payload
return {
...state,
channelArticles: {
...state.channelArticles,
[channel_id]: {
timestamp,
articles,
},
},
}
}
return state
}

渲染文章列表

目标

根据文章列表数据渲染文章。

步骤

  • 在 ArticleList 中获取文章列表数据。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const ArticleList = ({ channelId }: Props) => {
const dispatch = useDispatch()
const { channelArticles } = useSelector((state: RootState) => state.home)
const { articles = [] } = channelArticles[channelId] || {}
useEffect(() => {
dispatch(getArticleList(channelId, Date.now() + ''))
}, [dispatch, channelId])
return (
<div className={styles.root}>
{articles.map((item, index) => (
<div key={item.art_id} className='article-item'>
<ArticleItem article={item} />
</div>
))}
</div>
)
}
  • 传递给 ArticleItem 组件进行渲染
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import classnames from 'classnames'
import { useSelector } from 'react-redux'
import Icon from '@/components/Icon'
import styles from './index.module.scss'
import { Article } from '@/types/data'
import { RootState } from '@/types/store'

type Props = {
article: Article
}

const ArticleItem = ({ article }: Props) => {
const token = useSelector((state: RootState) => state.login.token)
const {
title,
cover: { type, images },
aut_name,
comm_count,
pubdate,
} = article
return (
<div className={styles.root}>
<div className={classnames('article-content', type === 3 && 't3', type === 0 && 'none-mt')}>
<h3>{title}</h3>
{type !== 0 && (
<div className='article-imgs'>
{images.map((image, index) => (
<div key={index} className='article-img-wrapper'>
<img src={image} alt='' />
</div>
))}
</div>
)}
</div>
<div className={classnames('article-info', type === 0 && 'none-mt')}>
<span>{aut_name}</span>
<span>{comm_count} 评论</span>
<span>{pubdate} 天前</span>
<span className='close'>{token && <Icon type='iconbtn_essay_close' />}</span>
</div>
</div>
)
}

export default ArticleItem
  • 处理时间

src/index.tsx

1
2
3
4
5
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import 'dayjs/locale/zh-cn'
dayjs.extend(relativeTime)
dayjs.locale('zh-cn')

src/pages/Home/components/ArticleItem/index.tsx

1
2
import dayjs from 'dayjs'
;<span>{dayjs(pubdate).fromNow()} 天前</span>

触底加载更多

目标

能够使用 antd-mobile 的 InfiniteScroll 组件。

代码

pages/Home/components/ArticleList/index.tsx

  • 使用 InfiniteScroll 组件,注意:有了 loadMore,useEffect 的第一个请求就可以注释了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import { getArticleList } from '@/store/actions/home'
import { RootState } from '@/types/store'
import { useDispatch, useSelector } from 'react-redux'
import { InfiniteScroll } from 'antd-mobile'
import ArticleItem from '../ArticleItem'
import styles from './index.module.scss'
type Props = {
channelId: number
}
const ArticleList = ({ channelId }: Props) => {
const dispatch = useDispatch()
const { channelArticles } = useSelector((state: RootState) => state.home)
const { articles = [] } = channelArticles[channelId] || {}
// #4 可以注释掉了
/* useEffect(() => {
dispatch(getArticleList(channelId, Date.now() + ''))
}, [dispatch, channelId]) */
// #2
const hasMore = true
// #3
const loadMore = async () => {
await dispatch(getArticleList(channelId, Date.now() + ''))
}
return (
<div className={styles.root}>
{articles.map((item, index) => (
<div key={item.art_id} className='article-item'>
<ArticleItem article={item} />
</div>
))}
{/* #1 */}
<InfiniteScroll loadMore={loadMore} hasMore={hasMore} />
</div>
)
}

export default ArticleList
  • 修改 reducers/home.ts,将数据改成追加。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if (action.type === 'home/saveChannelArticles') {
const { channel_id, timestamp, articles } = action.payload
// state.channelArticles => 可能是个空对象
const old = state.channelArticles[channel_id]?.articles || []
return {
...state,
channelArticles: {
...state.channelArticles,
[channel_id]: {
timestamp,
articles: [...old, ...articles],
},
},
}
}
  • 时间戳和 hasMore 的处理。
1
2
3
4
5
6
7
8
9
10
11
12
const ArticleList = ({ channelId }: Props) => {
const dispatch = useDispatch()
const { channelArticles } = useSelector((state: RootState) => state.home)
// #1
const { articles = [], timestamp } = channelArticles[channelId] || {}
// #3
const hasMore = timestamp !== null
const loadMore = async () => {
// #2
await dispatch(getArticleList(channelId, timestamp || Date.now() + ''))
}
}

下拉刷新

目标

能够下拉刷新文章数据。

分析

store.d.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
export type HomeAction =
| {
type: 'home/getUserChannels'
payload: Channel[]
}
| {
type: 'home/getAllChannels'
payload: Channel[]
}
| {
type: 'home/changeActive'
payload: number
}
| {
type: 'home/saveChannelArticles'
payload: {
channel_id: number
timestamp: string
articles: Article[]
}
}
| {
type: 'home/saveNewArticles'
payload: {
channel_id: number
timestamp: string
articles: Article[]
}
}

actions/home.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export const getNewList = (channel_id: number, timestamp: string): RootThunkAction => {
return async (dispatch) => {
const res = await request.get<ApiResponse<ArticleRes>>('/articles', {
params: {
channel_id,
timestamp,
},
})
const { pre_timestamp, results } = res.data.data
dispatch({
type: 'home/saveNewArticles',
payload: {
channel_id,
timestamp: pre_timestamp,
articles: results,
},
})
}
}

reducers/home.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
if (action.type === 'home/saveNewArticles') {
const { channel_id, timestamp, articles } = action.payload
return {
...state,
channelArticles: {
...state.channelArticles,
[channel_id]: {
timestamp,
articles: [...articles],
},
},
}
}

Home/components/ArticleList/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const onRefresh = async () => {
await dispatch(getNewList(channelId, Date.now() + ''))
}
return (
<div className={styles.root}>
<PullToRefresh onRefresh={onRefresh}>
{articles.map((item, index) => (
<div key={item.art_id} className='article-item'>
<ArticleItem article={item} />
</div>
))}
<InfiniteScroll loadMore={loadMore} hasMore={hasMore} />
</PullToRefresh>
</div>
)

IntersectionObserver

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>

<body>
<div style="height: 2000px;"></div>
<img data-src="http://geek.itheima.net/resources/images/19.jpg" alt="" />
<script>
const oImg = document.querySelector('img')
const observer = new IntersectionObserver(function ([{ isIntersecting }]) {
if (isIntersecting) {
oImg.src = oImg.getAttribute('data-src')
observer.unobserve()
}
})
observer.observe(oImg)
</script>
</body>
</html>

图片懒加载的封装

目标

封装一个拥有懒加载功能的图片组件,实现对文章列表项上的封面图片进行懒加载。

当前问题:文章列表一旦开始渲染,上面所有的封面图都会一次性加载,可能会浪费网络资源和降低用户体验。

解决方案:只有当用户滚动页面,真正浏览到这些列表项,才开始加载封面图片。

实现思路:利用浏览器提供的 IntersectionObserver,监听图片元素是否进入可视区域,进入后才真正去设置图片元素的 src 属性进行图片加载。

步骤

  1. 基本封装。

components/img/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import classnames from 'classnames'
import { useRef, useEffect } from 'react'
import styles from './index.module.scss'

type Props = {
src: string
className?: string
alt?: string
}
const Img = ({ src, className }: Props) => {
const imgRef = useRef<HTMLImageElement>(null)
useEffect(() => {
const img = imgRef.current!
const observer = new IntersectionObserver(([{ isIntersecting }]) => {
if (isIntersecting) {
img.src = img.getAttribute('data-src')!
observer.unobserve(img)
}
})
observer.observe(img)
return () => {
observer.unobserve(img)
}
}, [])
return (
<div className={classnames(styles.root, className)}>
{/* //!加载成功 */}
<img alt='' data-src={src} ref={imgRef} />
</div>
)
}

export default Img

components/img/index.module.scss

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
.root {
position: relative;
display: inline-block;
width: 100%;
height: 100%;
:global {
img {
display: block;
width: 100%;
height: 100%;
}

.image-icon {
position: absolute;
left: 0;
top: 0;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
background-color: #f7f8fa;
}

.icon {
color: #dcdee0;
font-size: 32px;
}
}
}
  1. pages/Question/index.tsx 中测试使用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { NavBar } from 'antd-mobile'
import styles from './index.module.scss'
import Img from '@/components/Img'

const Question = () => {
return (
<div className={styles.root} style={{ paddingBottom: 50 }}>
<NavBar>问答</NavBar>
<div style={{ height: 2000 }}></div>
<Img src='http://geek.itheima.net/resources/images/19.jpg' />
</div>
)
}

export default Question
  1. 处理加载状态。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import classnames from 'classnames'
import { useRef, useEffect, useState } from 'react'
import Icon from '../Icon'
import styles from './index.module.scss'

type Props = {
src: string
className?: string
alt?: string
}
const Img = ({ src, className }: Props) => {
const [error, setError] = useState(false)
const [loading, setLoading] = useState(true)
const imgRef = useRef<HTMLImageElement>(null)
// #2
// 如果触发 onError 表示有错
const onError = () => setError(true)
// 如果触发 onLoad 表示加载完了
const onLoad = () => setLoading(false)
useEffect(() => {
const img = imgRef.current!
const observer = new IntersectionObserver(([{ isIntersecting }]) => {
if (isIntersecting) {
img.src = img.getAttribute('data-src')!
observer.unobserve(img)
}
})
observer.observe(img)
return () => {
observer.unobserve(img)
}
}, [])
return (
<div className={classnames(styles.root, className)}>
{/* //!加载中... */}
{loading && (
<div className='image-icon'>
<Icon type='iconphoto' />
</div>
)}
{/* //!加载失败 */}
{/* #3 */}
{error && (
<div className='image-icon'>
<Icon type='iconphoto-fail' />
</div>
)}
{/* #1 */}
<img alt='' data-src={src} ref={imgRef} onError={onError} onLoad={onLoad} />
</div>
)
}

export default Img
  1. pages/Home/components/ArticleItem/index.tsx 中使用懒加载组件。
1
2
3
4
5
6
7
<div className='article-imgs'>
{images.map((image, index) => (
<div key={index} className='article-img-wrapper'>
<Img src={image} />
</div>
))}
</div>

点击文章项跳转到详情

目标

能够在点击文章项时跳转到文章详情页面。

素材

pages/Article/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
import { NavBar, InfiniteScroll } from 'antd-mobile'
import { useHistory } from 'react-router-dom'
import classNames from 'classnames'
import styles from './index.module.scss'

import Icon from '@/components/Icon'
import CommentItem from './components/CommentItem'
import CommentFooter from './components/CommentFooter'

const Article = () => {
const history = useHistory()
const renderArticle = () => {
// 文章详情
return (
<div className='wrapper'>
<div className='article-wrapper'>
<div className='header'>
<h1 className='title'>ES6 Promise 和 Async/await的使用</h1>

<div className='info'>
<span>2019-03-11</span>
<span>202 阅读</span>
<span>10 评论</span>
</div>

<div className='author'>
<img src='http://geek.itheima.net/images/user_head.jpg' alt='' />
<span className='name'>黑马先锋</span>
<span className={classNames('follow', true ? 'followed' : '')}>{true ? '已关注' : '关注'}</span>
</div>
</div>

<div className='content'>
<div className='content-html dg-html' />
<div className='date'>发布文章时间:2021-2-1</div>
</div>
</div>

<div className='comment'>
<div className='comment-header'>
<span>全部评论(10)</span>
<span>20 点赞</span>
</div>

<div className='comment-list'>
<CommentItem />

<InfiniteScroll
hasMore={false}
loadMore={async () => {
console.log(1)
}}
/>
</div>
</div>
</div>
)
}

return (
<div className={styles.root}>
<div className='root-wrapper'>
<NavBar
onBack={() => history.go(-1)}
right={
<span>
<Icon type='icongengduo' />
</span>
}
>
{true && (
<div className='nav-author'>
<img src='http://geek.itheima.net/images/user_head.jpg' alt='' />
<span className='name'>黑马先锋</span>
<span className={classNames('follow', true ? 'followed' : '')}>{true ? '已关注' : '关注'}</span>
</div>
)}
</NavBar>
{/* 文章详情和评论 */}
{renderArticle()}

{/* 底部评论栏 */}
<CommentFooter />
</div>
</div>
)
}

export default Article

pages/Article/index.module.scss

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
@import '@scss/hairline.scss';

.root {
height: 100%;

:global {
.root-wrapper {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
z-index: 1;
}

.adm-nav-bar-left {
flex: unset;
}
.adm-nav-bar-title {
padding: 0;
}

.am-icon-ellipsis {
color: #323233;
}

.nav-author {
display: flex;
text-align: left;
align-items: center;
color: #3a3948;
font-size: 15px;
img {
width: 24px;
height: 24px;
border-radius: 50%;
}
.name {
margin: 0 8px;
padding-right: 10px;
border-right: 1px solid #bfc2cb;
line-height: 10px;
}
.follow {
color: #de644b;
}
.followed {
color: #ccc;
}
}

.wrapper {
flex: 1;
padding-bottom: 50px;
overflow-y: auto;
background-color: #f7f8fa;
}

.article-wrapper {
padding: 0 16px;
background-color: #fff;
}

.header {
position: relative;
padding: 18px 0 12px;
@include hairline(bottom, #f0f0f0);

.title {
margin-bottom: 12px;
font-size: 23px;
color: #333;
}

.info {
height: 12px;
margin-bottom: 23px;
line-height: 12px;
color: #a5a6ab;
font-size: 11px;

span {
display: inline-block;
padding: 0 8px;
border-right: 1px solid #bfc2cb;

&:first-child {
padding-left: 0;
}
&:last-child {
border-right: 0;
}
}
}

.author {
position: relative;
display: flex;
align-items: center;

img {
width: 34px;
height: 34px;
margin-right: 12px;
object-fit: cover;
border-radius: 50%;
overflow: hidden;
}

.name {
color: #333;
font-size: 15px;
}

.follow {
position: absolute;
right: 0;
height: 29px;
padding: 8px 18px;
border-radius: 15px;
line-height: 1;
color: #fff;
font-size: 14px;
background-color: #fc6627;
}

.followed {
color: #a5a6ab;
background-color: #f7f8fa;
}
}
}

.content {
width: 100%;
padding: 30px 0 25px;
line-height: 27px;
overflow: hidden;
color: #595769;
font-size: 16px;

.content-html {
min-height: 200px;
word-break: break-all;

// 动态内容中的元素
pre {
font-size: 14px;
white-space: pre-wrap;

code {
white-space: pre;

&.hljs {
display: block;
padding: 7px;
overflow-x: auto;
}
}
}
}

img {
max-width: 100%;
}

.date {
padding-top: 25px;
text-align: right;
font-size: 12px;
color: #a5a6ab;
}
}

.comment {
margin-top: 12px;
background-color: #fff;

.comment-header {
display: flex;
justify-content: space-between;
padding: 16px;
font-size: 17px;
font-weight: 500;
color: #333;

span {
&:last-child {
color: #a5a6ab;
font-size: 14px;
font-weight: normal;
}
}
}

.comment-list {
margin-top: 3px;

// .placeholder {
// height: 0;
// }

// .list-loading {
// line-height: 49px;
// color: #969799;
// font-size: 12px;
// text-align: center;
// }

// .no-more {
// line-height: 49px;
// color: #969799;
// font-size: 14px;
// text-align: center;
// }
}
}
}
}

:global {
.comment-popup {
.adm-popup-body {
height: 100%;
}

&-wrapper {
height: 100%;
width: 100%;
background-color: #fff;
}
}

.reply-popup {
.adm-popup-body {
width: 100%;
}

&-wrapper {
height: 100%;
width: 100%;
background-color: #fff;
border-radius: 20px 20px 0px 0px;
}
}
}

pages/Article/components/CommentItem/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import 'dayjs/locale/zh-cn'
import classnames from 'classnames'
import Icon from '@/components/Icon'
import styles from './index.module.scss'
dayjs.extend(relativeTime)
dayjs.locale('zh-cn')

type Props = {
// normal 普通 - 文章的评论
// origin 回复评论的原始评论,也就是对哪个评论进行回复
// reply 回复评论
type?: 'normal' | 'reply' | 'origin'
}

const CommentItem = ({
// normal 普通
// origin 回复评论的原始评论
// reply 回复评论
type = 'normal',
}: Props) => {
// 回复按钮
const replyJSX =
type === 'normal' ? (
<span className='replay'>
0 回复
<Icon type='iconbtn_right' />
</span>
) : null

return (
<div className={styles.root}>
<div className='avatar'>
<img src='http://geek.itheima.net/images/user_head.jpg' alt='' />
</div>
<div className='comment-info'>
<div className='comment-info-header'>
<span className='name'>黑马先锋</span>
{/* 文章评论、评论的回复 */}
{(type === 'normal' || type === 'reply') && (
<span className='thumbs-up'>
10
<Icon type={true ? 'iconbtn_like_sel' : 'iconbtn_like2'} />
</span>
)}
{/* 要回复的评论 */}
{type === 'origin' && <span className={classnames('follow', true ? 'followed' : '')}>{true ? '已关注' : '关注'}</span>}
</div>
<div className='comment-content'>打破零评论</div>
<div className='comment-footer'>
{replyJSX}
{/* 非评论的回复 */}
{type !== 'reply' && <span className='comment-time'>{dayjs().from('2021-01-01')}</span>}
{/* 文章的评论 */}
{type === 'origin' && (
<span className='thumbs-up'>
10
<Icon type={true ? 'iconbtn_like_sel' : 'iconbtn_like2'} />
</span>
)}
</div>
</div>
</div>
)
}

export default CommentItem

pages/Article/components/CommentItem/index.module.scss

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
.root {
display: flex;
padding: 5px 0 16px 16px;
:global {
.avatar {
width: 34px;
height: 34px;
margin-right: 12px;

img {
width: 34px;
height: 34px;
border-radius: 50%;
object-fit: cover;
}
}

.comment-info {
flex: 1;
padding-right: 16px;
}

.comment-info-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 34px;
font-size: 15px;
color: #333;

.name {
font-weight: 500;
}
}

.thumbs-up {
font-size: 14px;

.icon {
margin-left: 8px;
}
}

.follow {
line-height: 29px;
color: #de644b;
font-size: 15px;
text-align: center;
}
.followed {
width: 64px;
height: 29px;
border-radius: 15px;
color: #a5a6ab;
background-color: #f7f8fa;
}

.comment-content {
margin-bottom: 8px;
line-height: 1.4;
color: #3a3948;
font-size: 16px;
}

.comment-footer {
display: flex;
align-items: center;
position: relative;

.close {
position: absolute;
right: 0;
top: 50%;
margin-top: -7px;
font-size: 14px;
}
}

.replay {
height: 25px;
padding: 6px 8px;
margin-right: 12px;
border-radius: 15px;
font-size: 12px;
background-color: #f7f8fa;

.icon {
position: relative;
top: -1px;
width: 11px;
height: 10px;
margin-left: 6px;
}
}

.comment-time {
flex: 1;
color: #a5a6ab;
font-size: 12px;
}
}
}

pages/Article/components/CommentFooter/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import Icon from '@/components/Icon'
import styles from './index.module.scss'

type Props = {
// normal 普通评论
// reply 回复评论
type?: 'normal' | 'reply'
}

const CommentFooter = ({ type = 'normal' }: Props) => {
return (
<div className={styles.root}>
<div className='input-btn'>
<Icon type='iconbianji' />
<span>抢沙发</span>
</div>

{type === 'normal' && (
<>
<div className='action-item'>
<Icon type='iconbtn_comment' />
<p>评论</p>
{!!1 && <span className='bage'>{1}</span>}
</div>
<div className='action-item'>
<Icon type={true ? 'iconbtn_like_sel' : 'iconbtn_like2'} />
<p>点赞</p>
</div>
<div className='action-item'>
<Icon type={true ? 'iconbtn_collect_sel' : 'iconbtn_collect'} />
<p>收藏</p>
</div>
</>
)}

{type === 'reply' && (
<div className='action-item'>
<Icon type={true ? 'iconbtn_like_sel' : 'iconbtn_like2'} />
<p>点赞</p>
</div>
)}

<div className='action-item'>
<Icon type='iconbtn_share' />
<p>分享</p>
</div>
</div>
)
}

export default CommentFooter

pages/Article/components/CommentFooter/index.module.scss

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
@import '@scss/hairline.scss';

.root {
display: flex;
align-items: center;
position: fixed;
bottom: 0;
width: 100%;
height: 50px;
padding: 0 16px;
background-color: #fff;
@include hairline(top, #f0f0f0);

:global {
.input-btn {
flex: 1;
display: flex;
align-items: center;
height: 36px;
padding-left: 10px;
color: #9ea1ae;
font-size: 14px;
background-color: #f7f8fa;
border-radius: 18px;

.icon {
margin-right: 4px;
color: #9ea1ae;
}
}

.action-item {
position: relative;
padding: 0 13px;
text-align: center;
color: #595769;
font-size: 12px;

.icon {
margin-bottom: 4px;
font-size: 20px;
}

&:last-child {
padding-right: 0;
}

.bage {
position: absolute;
right: 8px;
top: -3px;
padding: 1px 4px;
border-radius: 6px;
color: #fff;
background-color: #fc6627;
font-size: 12px;
line-height: 1;
}
}
}
}

步骤

  1. 将文章详情页面模板拷贝到 pages 目录中。

  2. 在 App 组件中配置文章详情页路由。

  3. 为每个文章列表项绑定点击事件。

  4. 点击时,根据文章 id,跳转到文章详情页对应的路由。

代码

pages/Home/component/ArticleList/index.tsx

1
2
3
4
// 建议:在 `pages/Home/component/ArticleItem/index.tsx` 组件的根节点进行跳转
<div key={item.art_id} className='article-item' onClick={() => history.push(`/article/${item.art_id}`)}>
<ArticleItem article={item} />
</div>

App.tsx

1
2
import Article from './pages/Article'
;<Route path='/article/:id' component={Article} />

Tab 栏切换记录滚动位置

src\pages\Home\index.tsx

1
2
// 通过 forceRender 属性,达到组件隐藏的时候不销毁结构
<Tabs.Tab forceRender title={item.name} key={item.id}></Tabs.Tab>
1
2
3
4
5
6
// 可以测试组件是否被销毁了 `src/pages/Home/components/ArticleList/index.tsx`
useEffect(() => {
return () => {
console.log('卸载了')
}
}, [])

搜索联想建议

静态结构

目标

实现文章搜索页面的主要静态结构和样式。

素材

pages/Search/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import classnames from 'classnames'
import { useHistory } from 'react-router'
import { NavBar, Search } from 'antd-mobile'

import Icon from '@/components/Icon'
import styles from './index.module.scss'

const SearchPage = () => {
const history = useHistory()

return (
<div className={styles.root}>
<NavBar className='navbar' onBack={() => history.go(-1)} right={<span className='search-text'>搜索</span>}>
<Search placeholder='请输入关键字搜索' />
</NavBar>

{true && (
<div
className='history'
style={{
display: true ? 'none' : 'block',
}}
>
<div className='history-header'>
<span>搜索历史</span>
<span>
<Icon type='iconbtn_del' />
清除全部
</span>
</div>

<div className='history-list'>
<span className='history-item'>
<span className='text-overflow'>黑马程序员</span>
<Icon type='iconbtn_essay_close' />
</span>
</div>
</div>
)}

<div className={classnames('search-result', true ? 'show' : '')}>
<div className='result-item'>
<Icon className='icon-search' type='iconbtn_search' />
<div className='result-value text-overflow'>
<span>黑马</span>
程序员
</div>
</div>
</div>
</div>
)
}

export default SearchPage

pages/Search/index.module.scss

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
@import '@scss/hairline.scss';

.root {
:global {
.navbar {
--height: 52px;

@include hairline-remove(bottom);

.adm-nav-bar-title {
height: 36px;
padding-left: 0;
}
.adm-nav-bar-left,
.adm-nav-bar-right {
flex: unset;
}

.adm-nav-bar-right {
font-size: 16px;
}

.adm-search {
height: 100%;
--border-radius: 18px;
}

.adm-search-input-box {
height: 100%;
border: none;
}
}

.history {
padding: 0 16px;
.history-header {
display: flex;
justify-content: space-between;
padding-top: 16px;
padding-bottom: 19px;
color: #a5a6ab;

.icon {
font-size: 16px;
margin-right: 6px;
}
}

.history-list {
color: #333333;

.history-item {
display: flex;
align-items: center;
position: relative;
padding: 12px 0;
font-size: 13px;
@include hairline(bottom, #f0f0f0);

span {
flex: 1;
}
.icon {
color: #999;
}

&:first-child {
padding-top: 0;
}
}

.divider {
display: inline-block;
height: 16px;
width: 1px;
margin: 0 16px;
vertical-align: middle;
background-color: #bfc2cb;
}
}
}

.search-result {
padding: 0 20px;
display: none;

.result-item {
position: relative;
display: flex;
align-items: center;
height: 44px;
padding: 10px 0;
font-size: 14px;
@include hairline(bottom, #f0f0f0);

.icon {
margin-right: 4px;
font-size: 12px;
}

.result-value {
flex: 1;

span {
color: red;
}
}
}
}
.search-result.show {
display: block;
}

.text-overflow {
word-break: break-all;
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
}
}
}

步骤

  1. Home/index.tsx,为首页 Tab 栏右边的 ”放大镜“ 按钮添加点击事件,点击后跳转到搜索页。
1
<Icon type='iconbtn_search' onClick={() => history.push('/search')} />
  1. App.jsx,把 Search 模板粘贴到 pages 目录并配置路由。
1
<Route path='/search' component={Search} />

防抖处理

目标

收集数据并防抖。

步骤

pages/Search/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import { useState, useRef, useEffect } from 'react'
import classnames from 'classnames'
import { useHistory } from 'react-router'
import { NavBar, Search } from 'antd-mobile'
import Icon from '@/components/Icon'
import styles from './index.module.scss'

const SearchPage = () => {
const timerRef = useRef(-1)
const history = useHistory()
// #1
const [keyword, setKeyword] = useState('')
// #2
const handleChange = (v: string) => {
setKeyword(v)
// #3
clearTimeout(timerRef.current)
// #4
timerRef.current = window.setTimeout(() => {
// 防抖
console.log(v)
}, 500)
}
// #5
useEffect(() => () => clearTimeout(timerRef.current), [])
return (
<div className={styles.root}>
<NavBar className='navbar' onBack={() => history.go(-1)} right={<span className='search-text'>搜索</span>}>
<Search placeholder='请输入关键字搜索' value={keyword} onChange={handleChange} />
</NavBar>

{true && (
<div
className='history'
style={{
display: true ? 'none' : 'block',
}}
>
<div className='history-header'>
<span>搜索历史</span>
<span>
<Icon type='iconbtn_del' />
清除全部
</span>
</div>

<div className='history-list'>
<span className='history-item'>
<span className='text-overflow'>黑马程序员</span>
<Icon type='iconbtn_essay_close' />
</span>
</div>
</div>
)}

<div className={classnames('search-result', true ? 'show' : '')}>
<div className='result-item'>
<Icon className='icon-search' type='iconbtn_search' />
<div className='result-value text-overflow'>
<span>黑马</span>
程序员
</div>
</div>
</div>
</div>
)
}

export default SearchPage

aHooks

步骤

  1. 安装 ahooks 包:yarn add ahooks

  2. 导入 useDebounceFn hook。

  3. 创建防抖函数。

  4. 搜索框中输入内容时,调用防抖函数。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { useDebounceFn } from 'ahooks'
const SearchPage = () => {
const history = useHistory()
const [keyword, setKeyword] = useState('')
const { run: getSuggest } = useDebounceFn(
function (v) {
console.log(v)
},
{
wait: 500,
}
)
const handleChange = (v: string) => {
setKeyword(v)
getSuggest(v)
}
}

获取数据

目标

将输入的关键发送到服务端,获取和该关键字匹配的建议数据。

代码

通过 Redux Action 来发送请求,获取结果数据后保存在 Redux Store 中。

types/data.d.ts

1
export type Suggestion = string[]

types/store.d.ts

1
2
3
4
5
export type SearchAction = {
type: 'search/suggestion'
payload: Suggestion
}
export type RootAction = LoginAction | ProfileAction | HomeAction | SearchAction

actions/search.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { ApiResponse, Suggestion } from '@/types/data'
import { RootThunkAction } from '@/types/store'
import request from '@/utils/request'

export const getSuggestion = (value: string): RootThunkAction => {
return async (dispatch) => {
const res = await request.get<ApiResponse<{ options: Suggestion }>>('/suggestion', {
params: {
q: value,
},
})
// res.data.data.options => 可能是 [null]
dispatch({ type: 'search/suggestion', payload: res.data.data.options.filter((item) => item !== null) })
}
}

reducers/search.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { Suggestion } from '@/types/data'
import { SearchAction } from '@/types/store'
type SearchState = {
suggestion: Suggestion
}
const initState: SearchState = {
suggestion: [],
}
export default function search(state = initState, action: SearchAction): SearchState {
switch (action.type) {
case 'search/suggestion':
return {
...state,
suggestion: action.payload,
}
default:
return state
}
}

reducers/index.ts

1
2
3
4
5
6
import { combineReducers } from 'redux'
import login from './login'
import profile from './profile'
import home from './home'
import search from './search'
export default combineReducers({ login, profile, home, search })

Search/index.tsx

1
2
3
4
5
6
7
8
const { run: getSuggest } = useDebounceFn(
function (v) {
dispatch(getSuggestion(v))
},
{
wait: 500,
}
)

渲染数据

pages/Search/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
const { suggestion } = useSelector((state: RootState) => state.search)

<div className={classnames('search-result', true ? 'show' : '')}>
{suggestion.map((item, index) => {
return (
<div className='result-item' key={index}>
<Icon className='icon-search' type='iconbtn_search' />
<div className='result-value text-overflow'>{item}</div>
</div>
)
})}
</div>

高亮处理

  • 基本操作。
1
2
3
4
const keyword = 'a'
let str = 'abcabc'
str = str.replace(new RegExp(keyword, 'gi'), (match) => `<span>${match}</span>`)
console.log(str)
1
2
3
const highLight = (str: string) => {
return str.replace(new RegExp(keyword, 'gi'), (match) => `<span>${match}</span>`)
}
1
2
3
4
5
6
<div
className='result-value text-overflow'
dangerouslySetInnerHTML={{
__html: highLight(item),
}}
/>

🧐 扩展。

1
2
3
4
5
6
7
8
9
10
let searchText = 'c+' // 用户输入的内容
let regStr = searchText.replace(/([+?|*])/g, '\\$1') // $1 代表第一个分组
let text = 'c++真好' // 后端返回的内容

// 需求:<span class="active">c+</span>+真好

const reg = new RegExp(regStr, 'gi') // 上面的 searchText 经过这里一处理,出事了,+ 是元字符,而不是 + 号本身
const result = text.replace(reg, `<span class="active">${searchText}</span>`)

console.log(result) // <span class="active">c+</span>+真好

按需展示历史记录和联想建议

目标

实现在做搜索操作时只显示搜索建议列表;其他情况只显示搜索历史。

image-20210908185453822

实现思路:利用之前创建的 isSearching 状态,控制建议列表和搜索历史的显示、隐藏。

步骤

  • 提供 isSearching 状态控制联想建议的显示/隐藏。
1
2
// 是否显示搜索
const [isSearching, setIsSearching] = useState(false)
  • 修改 isSearching 状态。
1
2
3
4
5
6
7
8
9
10
const handleChange = (v: string) => {
setKeyword(e)
// 有内容,发送请求并把搜索状态设置为 true
if (v) {
getSuggest(v)
setIsSearching(true)
} else {
setIsSearching(false)
}
}
  • 根据 isSearching 状态控制显示和隐藏。
1
2
3
4
5
6
7
8
9
10
11
12
13
{
/* 历史记录 */
}
;<div className='history' style={{ display: isSearching ? 'none' : 'block' }} />

{
/* 联想建议 */
}
;<div
className={classnames('search-result', {
show: isSearching,
})}
/>

内容为空时也清除联想建议

types/store.d.ts

1
2
3
4
5
6
7
8
export type SearchAction =
| {
type: 'search/suggestion'
payload: Suggestion
}
| {
type: 'search/clearSuggestion'
}

actions/search.ts

1
2
3
4
5
export function clearSuggestion(): SearchAction {
return {
type: 'search/clearSuggestion',
}
}

reducers/search.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { Suggestion } from '@/types/data'
import { SearchAction } from '@/types/store'

type SearchState = {
suggestion: Suggestion
}
const initState: SearchState = {
suggestion: [],
}

export default function search(state = initState, action: SearchAction): SearchState {
switch (action.type) {
case 'search/suggestion':
return {
...state,
suggestion: action.payload,
}
case 'search/clearSuggestion':
return {
...state,
suggestion: [],
}
default:
return state
}
}

pages/Search/index.tsx

1
2
3
4
5
6
7
8
9
10
11
const handleChange = (v: string) => {
setKeyword(v)
if (v) {
getSuggest(v)
setIsSearching(true)
} else {
setIsSearching(false)
// !清空联想建议的内容
dispatch(clearSuggestion())
}
}

一个 Bug

wait: 2000,快速输入 a => 清除 => 输入 b,只会以最终的 b 作为参数发起一次请求。

wait: 100,快速输入 a => 清除 => 输入 b,一般时间间隔都会大于 100ms,所以发起了 2 次请求,但请求结果到达的顺序没有保证,并不一定先发请求,结果就一定先到达,所以往 redux 存储内容的顺序也就没有保证。

解决方式,请求之前关闭掉上一次正在发送的请求。

  1. 创建 cancel 函数并导出,store/actions/search.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// #1
const { CancelToken } = axios
// #3
export let cancel: Canceler | null = null
export const getSuggestion = (value: string): RootThunkAction => {
return async (dispatch) => {
const res = await request.get<IResponse<{ options: string[] }>>('/suggestion', {
params: {
q: value,
},
// #2
cancelToken: new CancelToken((c) => {
cancel = c
}),
})
dispatch({
type: 'SEARCH_SUGGESTION_SAVE',
payload: res.data.data.options.filter((item) => item), // 联想建议
})
}
}
  1. 调用 cancel 函数,pages/Search/index.tsx
1
2
3
4
5
6
7
8
9
const { run: getSuggest } = useDebounceFn(
function (v) {
cancel && cancel()
dispatch(getSuggestion(v))
},
{
wait: 100,
}
)
  1. 关闭请求统一错误处理,utils/request.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
request.interceptors.response.use(
function (response) {
return response
},
async function (error: AxiosError<{ message: string }>) {
// axios.isCancel(error)
if (error.name === 'CanceledError') {
Toast.show(error.message)
return Promise.reject(error)
}
// ...
}
)
  1. 如何关闭任何一个请求?
1
2
3
4
5
6
7
8
9
10
11
12
export let cancel: Canceler | null = null
request.interceptors.request.use(
function (config) {
// #2
config.cancelToken = new axios.CancelToken((c) => (cancel = c))
// ...
return config
},
function (error) {
return Promise.reject(error)
}
)

搜索历史记录

添加历史记录

目标

点击顶部 ”搜索“ 按钮,或点击搜索建议列表中的一项,跳转到搜索详情页。

image-20210909103450023

步骤

types/data.d.ts

1
export type Histories = string[]

types/store.d.ts

1
2
3
4
5
6
7
8
9
10
11
12
export type SearchAction =
| {
type: 'search/suggestion'
payload: Suggestion
}
| {
type: 'search/clearSuggestion'
}
| {
type: 'search/saveHistories'
payload: string
}

actions/search.ts

1
2
3
4
5
6
export function addSearchList(keyword: string): SearchAction {
return {
type: 'search/saveHistories',
payload: keyword,
}
}

reducers/search.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { Histories, Suggestion } from '@/types/data'
import { SearchAction } from '@/types/store'

type SearchState = {
suggestion: Suggestion
histories: Histories
}
const initState: SearchState = {
suggestion: [],
histories: [],
}

export default function search(state = initState, action: SearchAction): SearchState {
switch (action.type) {
case 'search/suggestion':
return {
...state,
suggestion: action.payload,
}
case 'search/clearSuggestion':
return {
...state,
suggestion: [],
}
case 'search/saveHistories': {
return {
...state,
histories: [action.payload, ...state.histories],
}
}
default:
return state
}
}

pages/Search/index.tsx

1
2
3
4
5
// 给搜索按钮和联想建议绑定 onSearch 事件
const onSearch = (key: string) => {
if (!key) return
dispatch(addSearchList(key))
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<NavBar
className='navbar'
onBack={() => history.go(-1)}
right={
<span className='search-text' onClick={() => onSearch(keyword)}>
搜索
</span>
}
>
<Search
placeholder='请输入关键字搜索'
value={keyword}
onChange={handleChange}
onSearch={onSearch}
/>
</NavBar>
1
2
3
4
5
6
7
<div
className='result-value text-overflow'
dangerouslySetInnerHTML={{
__html: highLight(item),
}}
onClick={() => onSearch(item)}
></div>

去重前置功能

types/store.d.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export type SearchAction =
| {
type: 'search/suggestion'
payload: Suggestion
}
| {
type: 'search/clearSuggestion'
}
| {
// 上面的处理方式:action 接受一个 keyword,然后再交割 reduce 中进行处理(添加)
// 现在的处理方式:action 接受一个 keyword,把处理好的 payload 直接交给 reduce
type: 'search/saveHistories'
payload: string[]
}

reducers/search.ts

1
2
3
4
5
6
7
case 'search/saveHistories': {
return {
...state,
// histories: [action.payload, ...state.histories],
histories: action.payload,
}
}

actions/search.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export function addSearchList(keyword: string): RootThunkAction {
return (dispatch, getState) => {
let { histories } = getState().search
// #1 先干掉
histories = histories.filter((item) => item !== keyword)
if (histories.length >= 10) {
histories.pop()
}
// #2 再添加
histories.unshift(keyword)
dispatch({
type: 'search/saveHistories',
payload: histories,
})
}
}

渲染删除清空

image-20210909102100740

actions/search.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export function removeSearchList(keyword: string): RootThunkAction {
return (dispatch, getState) => {
let { histories } = getState().search
// 判断 keyword 在 hisrories 中是否存在,如果存在,删除
histories = histories.filter((item) => item !== keyword)
dispatch({
type: 'search/saveHistories',
payload: histories,
})
}
}

export function clearHistory(): SearchAction {
// 两种思路:一种是保存一个空,一种是单独准备一个 search/clearHistories 进行情况的操作
return {
type: 'search/saveHistories',
payload: [],
}
}

pages/Search/index.tsx,渲染、删除。

1
2
3
4
5
6
7
8
9
10
11
12
<div className='history-list'>
// 渲染
{histories.map((item, index) => (
<span className='history-item' key={index}>
<span className='text-overflow' onClick={() => onSearch(item)}>
{item}
</span>
// 删除
<Icon type='iconbtn_essay_close' onClick={() => dispatch(removeSearchList(item))} />
</span>
))}
</div>

清空。

1
2
3
4
5
6
7
8
9
const onClearHistory = () => {
Dialog.confirm({
title: '温馨提示',
content: '你确定要清空记录吗?',
onConfirm: function () {
dispatch(clearHistories())
},
})
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<div
className='history'
style={{
display: isSearching ? 'none' : 'block',
}}
>
<div className='history-header'>
<span>搜索历史</span>
// 清空
<span onClick={onClearHistory}>
<Icon type='iconbtn_del' />
清除全部
</span>
</div>
</div>

历史记录的持久化

目标

将每次输入的搜索关键字记录下来,再动态渲染到界面上,实现思路:

  • 在成功搜索后,将关键字存入 Redux 和 LocalStorage 中。

  • 从 Redux 中获取所有关键字,并渲染到界面。

步骤

  • utils/storage.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
const SEARCH_HIS_KEY = 'SEARCH_HIS_KEY'
// 取
export const getLocalHistories = (): Histories => {
return JSON.parse(localStorage.getItem(SEARCH_HIS_KEY) || '[]')
}
// 存
export const setLocalHisories = (histories: Histories): void => {
localStorage.setItem(SEARCH_HIS_KEY, JSON.stringify(histories))
}
// 删
export const removeLocalHistories = () => {
localStorage.removeItem(SEARCH_HIS_KEY)
}
  • reducers/search.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { Histories, Suggestion } from '@/types/data'
import { SearchAction } from '@/types/store'
import { getLocalHistories, setLocalHisories } from '@/utils/storage'

type SearchState = {
suggestion: Suggestion
histories: Histories
}
const initState: SearchState = {
suggestion: [],
// #2
histories: getLocalHistories(),
}

export default function search(state = initState, action: SearchAction): SearchState {
switch (action.type) {
case 'search/suggestion':
return {
...state,
suggestion: action.payload,
}
case 'search/clearSuggestion':
return {
...state,
suggestion: [],
}
case 'search/saveHistories': {
// #1
setLocalHisories(action.payload)
return {
...state,
histories: action.payload,
}
}
default:
return state
}
}

展示搜索结果

静态结构和样式

目标

实现搜索结果页的静态结构和样式。

image-20210909105543672

素材

pages/Search/Result/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { useHistory } from 'react-router-dom'
import { NavBar } from 'antd-mobile'

import styles from './index.module.scss'

const Result = () => {
const history = useHistory()

return (
<div className={styles.root}>
<NavBar onBack={() => history.go(-1)}>搜索结果</NavBar>
<div className='article-list'>
<div className='article-item'>文章列表</div>
</div>
</div>
)
}

export default Result

pages/Search/Result/index.module.scss

1
2
3
4
5
6
7
8
9
10
11
12
13
.root {
display: flex;
flex-direction: column;
height: 100%;

:global {
.article-list {
flex: 1;
padding: 0 16px;
overflow-y: scroll;
}
}
}

步骤

  1. 将资源包中对应的样式文件,拷贝到 pages/Search目录下。

  2. 配置路由。

App.tsx

1
2
3
4
import SearchResult from './pages/Search/Result'
// 注意这儿要加 exact 啦
<Route exact path='/search' component={Search} />
<Route path='/search/result' component={SearchResult} />
  1. 搜索的时候需要跳转。

pages/Search/index.tsx

1
2
3
4
5
const onSearch = (key: string) => {
if (!key) return
dispatch(addSearchList(key))
history.push('/search/result?key=' + key)
}

获取查询参数

pages/Search/Result/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { useHistory, useLocation } from 'react-router-dom'
import { NavBar } from 'antd-mobile'

import styles from './index.module.scss'

const Result = () => {
const history = useHistory()
const location = useLocation()
// location.search => ?key=a
const search = new URLSearchParams(location.search)
const value = search.get('key')!
console.log(value)
return (
<div className={styles.root}>
<NavBar onBack={() => history.go(-1)}>搜索结果</NavBar>
<div className='article-list'>
<div className='article-item'>文章列表</div>
</div>
</div>
)
}

export default Result

URLSearchParams 的另一种用法。

1
2
3
4
const search = new URLSearchParams()
search.append('name', 'ifer')
search.append('age', '18')
console.log(search + '')

处理参数:当数据带有 + 号的时候会有问题,例如 key=age 1+8我,得到的结果是 age 1 8我

1
2
3
4
5
// 解决办法
// 传参时对参数进行编码
const r = encodeURIComponent('age 1+8我')
// 接参时对参数进行解码
decodeURIComponent(r)

pages/Search/index.tsx

1
2
3
4
5
const onSearch = (key: string) => {
if (!key) return
dispatch(addSearchList(key))
history.push('/search/result?key=' + encodeURIComponent(key))
}

pages/Search/Result/index.tsx

1
2
const search = new URLSearchParams(location.search)
const keyword = decodeURIComponent(search.get('key')!)
1
2
// 了解,不推荐
const search = decodeURI(location.search.replace('?key=', ''))

请求搜索结果

目标

获取从搜索页面传入的参数后,调用后端接口获取搜索详情。

步骤

  1. 获取通过 URL 地址传入到搜索详情页的查询字符串参数 q

types/data.d.ts

1
2
3
4
5
6
export type SearchResult = {
page: number
per_page: number
total_count: number
results: Article[]
}

types/store.d.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export type SearchAction =
| {
type: 'search/suggestion'
payload: Suggestion
}
| {
type: 'search/clearSuggestion'
}
| {
type: 'search/saveHistories'
payload: string[]
}
| {
type: 'search/saveHistoriesResult'
payload: SearchResult
}
  1. store/actions/search.ts 中编写 Action Creator。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export function getSearchResults(keyword: string, page: number = 1, per_page = 10): RootThunkAction {
return async (dispatch) => {
const res = await request.get<ApiResponse<SearchResult>>('/search', {
params: {
q: keyword,
page,
per_page,
},
})
dispatch({
type: 'search/saveHistoriesResult',
payload: res.data.data,
})
}
}
  1. store/reducers/search.ts 中添加保存搜索详情数据的 Reducer 逻辑。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import { Histories, SearchResult, Suggestion } from '@/types/data'
import { SearchAction } from '@/types/store'
import { getLocalHistories, setLocalHisories } from '@/utils/storage'

type SearchState = {
suggestion: Suggestion
histories: Histories
// #1
searchResult: SearchResult
}
const initState: SearchState = {
suggestion: [],
histories: getLocalHistories(),
// #2
searchResult: {} as SearchResult,
}

export default function search(state = initState, action: SearchAction): SearchState {
switch (action.type) {
case 'search/suggestion':
return {
...state,
suggestion: action.payload,
}
case 'search/clearSuggestion':
return {
...state,
suggestion: [],
}
case 'search/saveHistories': {
// #1
setLocalHisories(action.payload)
return {
...state,
histories: action.payload,
}
}
// #3
case 'search/saveHistoriesResult':
return {
...state,
searchResult: action.payload,
}
default:
return state
}
}

渲染搜索结果

pages/Search/Result/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useHistory, useLocation } from 'react-router-dom'
import { NavBar } from 'antd-mobile'
import styles from './index.module.scss'
import { getSearchResults } from '@/store/actions/search'
import { RootState } from '@/types/store'
import ArticleItem from '@/pages/Home/components/ArticleItem'

const Result = () => {
const history = useHistory()
const location = useLocation()
const dispatch = useDispatch()
const search = new URLSearchParams(location.search)
const keyword = search.get('key')!
// #2
const {
searchResult: { results = [] },
} = useSelector((state: RootState) => state.search)
// #1
useEffect(() => {
dispatch(getSearchResults(keyword))
}, [dispatch, keyword])
return (
<div className={styles.root}>
<NavBar onBack={() => history.go(-1)}>搜索结果</NavBar>
<div className='article-list'>
{/* #3 */}
{results.map((item) => (
<div className='article-item' key={item.art_id}>
<ArticleItem article={item} />
</div>
))}
</div>
</div>
)
}

export default Result

图片 403 问题

1
<meta name="referrer" content="no-referrer" />

加载更多数据

pages/Search/Result/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import { useEffect, useRef, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useHistory, useLocation } from 'react-router-dom'
// #1
import { NavBar, InfiniteScroll } from 'antd-mobile'
import styles from './index.module.scss'
import { getSearchResults } from '@/store/actions/search'
import { RootState } from '@/types/store'
import ArticleItem from '@/pages/Home/components/ArticleItem'

const Result = () => {
const history = useHistory()
const location = useLocation()
const dispatch = useDispatch()
const search = new URLSearchParams(location.search)
const keyword = search.get('key')!
/* const {
searchResult: { results = [], total_count = 1 },
} = useSelector((state: RootState) => state.search)
const hasMore = results.length < total_count */
const {
searchResult: { results = [] },
} = useSelector((state: RootState) => state.search)
// #3
const hasMore = results.length < 100
// #4
const pageRef = useRef(1)
const bBarRef = useRef(true)
// #5
const loadMore = async () => {
// #6 节流
if (bBarRef.current) {
bBarRef.current = false
await dispatch(getSearchResults(keyword, pageRef.current))
pageRef.current++
bBarRef.current = true
}
}
// !务必注释
/* useEffect(() => {
dispatch(getSearchResults(keyword))
}, [dispatch, keyword]) */
return (
<div className={styles.root}>
<NavBar onBack={() => history.go(-1)}>搜索结果</NavBar>
<div className='article-list'>
{results.map((item) => (
<div className='article-item' key={item.art_id}>
<ArticleItem article={item} />
</div>
))}
{/* #2: 注意放的位置,丢外面会导致不会被内容撑下去,永远在可视区 */}
<InfiniteScroll hasMore={hasMore} loadMore={loadMore} />
</div>
</div>
)
}

export default Result

reducers/search.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import { Histories, SearchResult, Suggestion } from '@/types/data'
import { SearchAction } from '@/types/store'
import { getLocalHistories, setLocalHisories } from '@/utils/storage'

type SearchState = {
suggestion: Suggestion
histories: Histories
searchResult: SearchResult
}
const initState: SearchState = {
suggestion: [],
histories: getLocalHistories(),
searchResult: {} as SearchResult,
}

export default function search(state = initState, action: SearchAction): SearchState {
switch (action.type) {
case 'search/suggestion':
return {
...state,
suggestion: action.payload,
}
case 'search/clearSuggestion':
return {
...state,
suggestion: [],
}
case 'search/saveHistories': {
setLocalHisories(action.payload)
return {
...state,
histories: action.payload,
}
}
// #mark
case 'search/saveHistoriesResult':
const old = state.searchResult.results || []
return {
...state,
searchResult: {
...state.searchResult,
results: [...old, ...action.payload.results],
},
}
default:
return state
}
}

Bug:先搜索关键字 ‘a’,出来结果,返回,再进行搜索 ‘xxxx’,发现上次的结果还在。

解决:进入或销毁组件时清空 Redux 中的数据。

跳转到详情

目标

将请求到的搜索详情数据渲染到界面上。

步骤

pages/Home/components/ArticleList/index.tsx

1
2
3
4
5
6
7
8
// 注释掉 .article-item 的点击
{
articles.map((item) => (
<div key={item.art_id} className='article-item'>
<ArticleItem article={item} />
</div>
))
}

把点击事件绑定到 pages/Home/components/ArticleItem/index.tsx

1
<div className={styles.root} onClick={() => history.push(`/article/${art_id}`)}></div>