今日目标
✔ 理解频道管理的业务逻辑。
✔ 理解文章列表的业务逻辑。
✔ 理解搜索功能的业务逻辑。
渲染我的频道
页面结构
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
|
获取频道列表数据
types/data.d.ts
1 2 3 4
| export type Channel = { id: number name: string }
|
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) } }
|
pages/Home/index.tsx
1 2
| 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.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; } } } } }
|
步骤
将模板 Channels 拷贝到 Home/components 目录中。
在 Home 组件中导入 Channels 组件。
使用 Popup 组件渲染 Channels 内容。
创建控制频道管理弹出层展示或隐藏的状态。
控制弹出层的展示或隐藏。
代码
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> ) }
|
渲染我的频道
目标
能够渲染我的频道列表。
步骤
我的频道中展示的数据就是在首页中获取到的用户频道列表数据,因此只需要在频道管理组件中拿到用户频道列表数据即可。
在 Channels 中,从 redux 中获取到用户频道数据。
渲染用户频道列表数据。
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 = () => { 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 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')
|
我的频道逻辑
分析
代码
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()) { const res = await request.get<ApiResponse<{ channels: Channel[] }>>('/user/channels') dispatch({ type: 'home/getUserChannels', payload: res.data.data.channels, }) } else { const channels = getChannels() if (channels.length > 0) { dispatch({ type: 'home/getUserChannels', payload: channels, }) } else { const res = await request.get<ApiResponse<{ channels: Channel[] }>>('/user/channels') dispatch({ type: 'home/getUserChannels', payload: res.data.data.channels, }) setChannels(res.data.data.channels) } } } }
|
频道管理交互
点击我的频道高亮
目标
能够实现切换频道功能。
分析
首先明确:首页顶部的频道和频道管理中的我的频道是关联在一起的。
点击频道管理中的我的频道时,首页顶部的频道会切换,并高亮。
点击首页顶部的频道时,频道管理对应的频道也要高亮。
因此,需要准备一个状态用来记录当前选中频道,并且两个组件中都需要用到该状态,所以,可以直接将该状态存储到 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[] active: number } const initState: HomeState = { userChannels: [], allChannels: [], 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, } 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() const { userChannels, allChannels, active } = useSelector((state: RootState) => state.home) const recommendChannels = differenceBy(allChannels, userChannels, 'id') 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 的高亮
目标
能够实现首页频道切换和高亮功能。
步骤
在 Home 组件中拿到该状态(active),并设置为 Tabs 组件的 activeKey。
为 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) const { userChannels, active } = useInitState(getUserChannel, 'home') useInitState(getAllChannel, 'home') const show = () => { setVisible(true) } const hide = () => { setVisible(false) } 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
|
编辑状态的处理
目标
能够切换频道编辑状态。
步骤
添加控制是否为编辑的状态。
给编辑/保存按钮添加点击事件。
在点击事件中切换编辑状态。
根据编辑状态判断展示保存或编辑文字内容。
代码
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() const [isEdit, setIsEdit] = useState(false) const { userChannels, allChannels, active } = useSelector((state: RootState) => state.home) const recommendChannels = differenceBy(allChannels, userChannels, 'id') const onChange = (id: number) => { if (isEdit) return dispatch(changeActive(id)) hide() } 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
|
删除频道
目标
能够删除我的频道数据。
分析
推荐频道不能删除。
至少要保留 4 个频道。
步骤
如果是推荐频道则不显示删除按钮。
给频道的删除按钮绑定点击事件。
如果频道数量小于等于 4 则返回。
如果点击的是当前的,则激活推荐频道。
删除。
代码
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 { setChannels(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 = {
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; 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; overflow-y: scroll;
:global { .articles { padding-bottom: 50px; } .article-item { position: relative; color: #323233; font-size: 14px; background-color: #fff; } } }
|
步骤
将模板 ArticleList 和 ArticleItem 拷贝到 pages/Home/components
目录中。
在 Home 组件中渲染文章列表结构。
分析每个模板的作用,以及模板的结构。
代码
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> ) }
|
获取文章列表数据
目标
能够获取到文章列表数据并且进行渲染。
步骤
分析接口文档,定义频道的数据类型,types/data.d.ts
中。
准备获取文章列表的 action,在 action 中发送请求。
发送请求需要频道 id,父组件将频道 id 传递给 ArticleList 组件。
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: [] }
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 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, }, }) } }
|
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 channelArticles: { [key: number]: { timestamp: string articles: Article[] } } } const initValue: HomeState = { userChannels: [], allChannels: [], active: 0, 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, } } if (action.type === 'home/saveChannelArticles') { const { channel_id, timestamp, articles } = action.payload return { ...state, channelArticles: { ...state.channelArticles, [channel_id]: { timestamp, articles, }, }, } } return state }
|
渲染文章列表
目标
根据文章列表数据渲染文章。
步骤
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> ) }
|
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] || {}
const hasMore = true 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 const old = state.channelArticles[channel_id]?.articles || [] return { ...state, channelArticles: { ...state.channelArticles, [channel_id]: { timestamp, articles: [...old, ...articles], }, }, } }
|
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) const { articles = [], timestamp } = channelArticles[channelId] || {} const hasMore = timestamp !== null const loadMore = async () => { 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
属性进行图片加载。
步骤
- 基本封装。
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; } } }
|
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 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) const onError = () => setError(true) 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
|
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;
} } } }
: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 = { 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 = { 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; } } } }
|
步骤
将文章详情页面模板拷贝到 pages 目录中。
在 App 组件中配置文章详情页路由。
为每个文章列表项绑定点击事件。
点击时,根据文章 id,跳转到文章详情页对应的路由。
代码
pages/Home/component/ArticleList/index.tsx
1 2 3 4
| <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
| <Tabs.Tab forceRender title={item.name} key={item.id}></Tabs.Tab>
|
1 2 3 4 5 6
| 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; } } }
|
步骤
Home/index.tsx
,为首页 Tab 栏右边的 ”放大镜“ 按钮添加点击事件,点击后跳转到搜索页。
1
| <Icon type='iconbtn_search' onClick={() => history.push('/search')} />
|
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() const [keyword, setKeyword] = useState('') const handleChange = (v: string) => { setKeyword(v) clearTimeout(timerRef.current) timerRef.current = window.setTimeout(() => { console.log(v) }, 500) } 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
步骤
安装 ahooks 包:yarn add ahooks
。
导入 useDebounceFn
hook。
创建防抖函数。
搜索框中输入内容时,调用防抖函数。
代码
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, }, }) 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') let text = 'c++真好'
const reg = new RegExp(regStr, 'gi') const result = text.replace(reg, `<span class="active">${searchText}</span>`)
console.log(result)
|
按需展示历史记录和联想建议
目标
实现在做搜索操作时只显示搜索建议列表;其他情况只显示搜索历史。
实现思路:利用之前创建的 isSearching
状态,控制建议列表和搜索历史的显示、隐藏。
步骤
- 提供 isSearching 状态控制联想建议的显示/隐藏。
1 2
| const [isSearching, setIsSearching] = useState(false)
|
1 2 3 4 5 6 7 8 9 10
| const handleChange = (v: string) => { setKeyword(e) 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 存储内容的顺序也就没有保证。
解决方式,请求之前关闭掉上一次正在发送的请求。
- 创建 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
| const { CancelToken } = axios
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, }, cancelToken: new CancelToken((c) => { cancel = c }), }) dispatch({ type: 'SEARCH_SUGGESTION_SAVE', payload: res.data.data.options.filter((item) => item), }) } }
|
- 调用 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, } )
|
- 关闭请求统一错误处理,
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 }>) { if (error.name === 'CanceledError') { Toast.show(error.message) return Promise.reject(error) } } )
|
- 如何关闭任何一个请求?
1 2 3 4 5 6 7 8 9 10 11 12
| export let cancel: Canceler | null = null request.interceptors.request.use( function (config) { config.cancelToken = new axios.CancelToken((c) => (cancel = c)) return config }, function (error) { return Promise.reject(error) } )
|
搜索历史记录
添加历史记录
目标
点击顶部 ”搜索“ 按钮,或点击搜索建议列表中的一项,跳转到搜索详情页。
步骤
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
| 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' } | { type: 'search/saveHistories' payload: string[] }
|
reducers/search.ts
1 2 3 4 5 6 7
| case 'search/saveHistories': { return { ...state, 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 histories = histories.filter((item) => item !== keyword) if (histories.length >= 10) { histories.pop() } histories.unshift(keyword) dispatch({ type: 'search/saveHistories', payload: histories, }) } }
|
渲染删除清空
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 histories = histories.filter((item) => item !== keyword) dispatch({ type: 'search/saveHistories', payload: histories, }) } }
export function clearHistory(): SearchAction { 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>
|
历史记录的持久化
目标
将每次输入的搜索关键字记录下来,再动态渲染到界面上,实现思路:
步骤
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) }
|
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: [], 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': { setLocalHisories(action.payload) return { ...state, histories: 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
| 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; } } }
|
步骤
将资源包中对应的样式文件,拷贝到 pages/Search
目录下。
配置路由。
App.tsx
1 2 3 4
| import SearchResult from './pages/Search/Result'
<Route exact path='/search' component={Search} /> <Route path='/search/result' component={SearchResult} />
|
- 搜索的时候需要跳转。
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() 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=', ''))
|
请求搜索结果
目标
获取从搜索页面传入的参数后,调用后端接口获取搜索详情。
步骤
- 获取通过 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 }
|
- 在
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, }) } }
|
- 在
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 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, } } 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')! const { searchResult: { results = [] }, } = useSelector((state: RootState) => state.search) 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'
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 = [] }, } = useSelector((state: RootState) => state.search) const hasMore = results.length < 100 const pageRef = useRef(1) const bBarRef = useRef(true) const loadMore = async () => { if (bBarRef.current) { bBarRef.current = false await dispatch(getSearchResults(keyword, pageRef.current)) pageRef.current++ bBarRef.current = true } }
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, } } 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
| { 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>
|