危险

为之则易,不为则难

0%

19_极客园 H5

今日目标

✔ 理解文章详情的业务逻辑。

✔ 理解评论、收藏、点赞的处理逻辑。

文章详情展示

获取详情数据

types/data.d.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export type ArticleDetail = {
art_id: string
attitude: number
aut_id: string
aut_name: string
aut_photo: string
comm_count: number
content: string
is_collected: boolean
is_followed: boolean
like_count: number
pubdate: string
read_count: number
title: string
}

actions/article.ts

1
2
3
4
5
6
7
8
9
10
import { ApiResponse, ArticleDetail } from '@/types/data'
import { RootThunkAction } from '@/types/store'
import request from '@/utils/request'

export function getArticleInfo(id: string): RootThunkAction {
return async (dispatch) => {
const res = await request.get<ApiResponse<ArticleDetail>>(`/articles/${id}`)
console.log(res.data.data)
}
}

pages/Article/index.tsx

1
2
3
4
5
6
7
8
9
const Article = () => {
const history = useHistory()
const dispatch = useDispatch()
const params = useParams<{ id: string }>()
const articleId = params.id
useEffect(() => {
dispatch(getArticleInfo(articleId))
}, [dispatch, articleId])
}

存储到 Redux

store.d.ts

1
2
3
4
5
export type ArticleAction = {
type: 'article/setArticleInfo'
payload: ArticleDetail
}
export type RootAction = LoginAction | ProfileAction | HomeAction | SearchAction | ArticleAction

actions/article.ts

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

export function getArticleInfo(id: string): RootThunkAction {
return async (dispatch) => {
const res = await request.get<ApiResponse<ArticleDetail>>(`/articles/${id}`)
dispatch({
type: 'article/setArticleInfo',
payload: res.data.data,
})
}
}

reducers/article.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { ArticleDetail } from '@/types/data'
import { ArticleAction } from '@/types/store'
type ArticleState = {
info: ArticleDetail
}
const initState: ArticleState = {
info: {},
} as ArticleState

export default function article(state = initState, action: ArticleAction): ArticleState {
switch (action.type) {
case 'article/setArticleInfo':
return {
...state,
info: action.payload,
}
default:
return state
}
}

reducers/index.ts

1
2
import article from './article'
export default combineReducers({ article })

基本渲染

pages/Article/index.tsx

1
const { info } = useSelector((state: RootState) => state.article)
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
<div className='article-wrapper'>
<div className='header'>
<h1 className='title'>{info.title}</h1>
<div className='info'>
<span>{dayjs(info.pubdate).format('YYYY-MM-DD')}</span>
<span>{info.read_count} 阅读</span>
<span>{info.comm_count} 评论</span>
</div>

<div className='author'>
<img src={info.aut_photo} alt='' />
<span className='name'>{info.aut_name}</span>
<span className={classNames('follow', info.is_followed ? 'followed' : '')}>{info.is_followed ? '已关注' : '关注'}</span>
</div>
</div>

<div className='content'>
<div
className='content-html dg-html'
dangerouslySetInnerHTML={{
__html: info.content,
}}
/>
<div className='date'>发布文章时间:{dayjs(info.pubdate).format('YYYY-MM-DD')}</div>
</div>
</div>

优化获取 useInitState

pages/Article/index.tsx

  • 使用 useInitState 来优化请求的获取,但是会遇到传参的问题,,因此需要包裹一个函数。
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
import { NavBar, InfiniteScroll } from 'antd-mobile'
import { useHistory, useParams } 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'
import { getArticleInfo } from '@/store/actions/article'
import dayjs from 'dayjs'
import { useInitState } from '@/utils/hooks'

const Article = () => {
const history = useHistory()
const params = useParams<{ id: string }>()
const articleId = params.id
/* const { info } = useSelector((state: RootState) => state.article)
useEffect(() => {
dispatch(getArticleInfo(articleId))
}, [dispatch, articleId]) */
const { info } = useInitState(() => getArticleInfo(articleId), 'article')
const renderArticle = () => {
// 文章详情
return (
<div className='wrapper'>
{/* //!文章基本信息 */}
<div className='article-wrapper'>
<div className='header'>
<h1 className='title'>{info.title}</h1>
<div className='info'>
<span>{dayjs(info.pubdate).format('YYYY-MM-DD')}</span>
<span>{info.read_count} 阅读</span>
<span>{info.comm_count} 评论</span>
</div>

<div className='author'>
<img src={info.aut_photo} alt='' />
<span className='name'>{info.aut_name}</span>
<span className={classNames('follow', info.is_followed ? 'followed' : '')}>{info.is_followed ? '已关注' : '关注'}</span>
</div>
</div>

<div className='content'>
<div
className='content-html dg-html'
dangerouslySetInnerHTML={{
__html: info.content,
}}
/>
<div className='date'>发布文章时间:{dayjs(info.pubdate).format('YYYY-MM-DD')}</div>
</div>
</div>
{/* //!评论信息 */}
<div className='comment'>
<div className='comment-header'>
<span>全部评论(101)</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
  • 使用 useInitialState 自定义 hook 会重复发送请求
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
针对该问题的分析过程:

1. 定位出问题的代码位置

- 既然造成了重复请求,说明 dispatch 分发 action 的代码重复执行了。可以通过 console.log 来确认,是否会重复执行

2. 分析原因

- dispatch 是在 useEffect hook 中执行的,说明 effect 重复执行。而 effect 重复执行的原因只有一个,就是:依赖项发生改变
- 第一个依赖项 `dispatch` 函数是不变的
- 只能是第二个依赖项 action 函数改变了

3. 确认分析是否正确
- Reesult 组件中 `useInitialState` 重复执行,也就是重复更新了状态,每次更新状态都会导致组件重新渲染。
- 组件重新渲染时,会重新执行组件中的所有代码。
- 而我们传递给 `useInitialState` hook 的第一个回调函数,每次都会重新创建
  • 解决思路:减少 action 的依赖

解决方法 1,src/utils/hooks.ts,去掉 action 依赖。

解决方法 2

1
2
3
4
5
6
7
8
9
10
11
12
13
import { RootState } from '@/types/store'
import { useEffect, useRef } from 'react'
import { useDispatch, useSelector } from 'react-redux'
export function useInitState<T extends keyof RootState>(action: () => void, stateName: T) {
const dispatch = useDispatch()
const actionRef = useRef(action)
useEffect(() => {
const actionFn = actionRef.current
dispatch(actionFn())
}, [dispatch])
const state = useSelector((state: RootState) => state[stateName])
return state
}

防 XSS 攻击

目标

清理正文中的不安全元素,防止 XSS 安全漏洞,实现思路:使用 dompurify 对 HTML 内容进行净化处理。

演示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { useState } from 'react'
const Question = () => {
const [v, setV] = useState('')
const onChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setV(e.target.value)
}
return (
<div className={styles.root}>
<textarea value={v} onChange={onChange} />
<div
dangerouslySetInnerHTML={{
__html: v,
}}
></div>
{/* <img src='http://geek.itheima.net/resources/images/19.jpg' onload='document.body.innerHTML = "铁子";'/> */}
</div>
)
}

export default Question
1
console.log(dompurify.sanitize(`<img src='http://geek.itheima.net/resources/images/19.jpg' onload='document.body.innerHTML = "铁子";'/>`))

步骤

  1. 安装包,yarn add dompurify @types/dompurify

  2. pages/Article/index.tsx 页面中调用 dompurify 来对文章正文内容做净化。

1
import dompurify from 'dompurify'
1
<div className='content-html dg-html' dangerouslySetInnerHTML={{ __html: dompurify.sanitize(info.content || '') }} />

代码高亮

目标

实现嵌入文章中的代码带有语法高亮效果。

实现思路:通过 highlight.js 库实现对文章正文 HTML 中的代码元素自动添加语法高亮。

步骤

  • 安装包 yarn add highlight.js

  • 在页面中引入 highlight.js

pages/Article/index.tsx

1
2
import hljs from 'highlight.js'
import 'highlight.js/styles/vs2015.css'
  • 在文章加载后,对文章内容中的代码进行语法高亮
1
2
3
4
5
6
7
8
9
10
11
12
13
14
useEffect(() => {
// !其实后端返回的内容已经自带了高亮的类名,只需要引入 CSS 即可
// 配置 highlight.js
hljs.configure({
// 忽略未经转义的 HTML 字符
ignoreUnescapedHTML: true,
})
// 获取到内容中所有的code标签
const codes = document.querySelectorAll('.dg-html pre code')
codes.forEach((el) => {
// 让 code 进行高亮
hljs.highlightElement(el as HTMLElement)
})
}, [articleId])
1
2
3
4
5
6
7
8
9
10
useEffect(() => {
// articleDetail 变化并且视图渲染完毕会走这儿,无论这儿有没有用到 articleDetail 这个依赖
hljs.configure({
ignoreUnescapedHTML: true,
})
const codes = document.querySelectorAll('.copyable')
codes.forEach((el) => {
hljs.highlightElement(el as HTMLElement)
})
}, [articleDetail])

控制头部的显示和隐藏

思路

  • 为顶部导航栏组件 NavBar 设置中间部分的内容。

  • 监听页面 scroll 事件,在页面滚动时判断描述信息区域的 top 是否小于等于 0;如果是,则将 NavBar 中间内容设置为显示;否则设置为隐藏。

步骤

  • 为顶部导航栏添加作者信息。
1
2
3
4
5
<div className='nav-author'>
<img src={info.aut_photo} alt='' />
<span className='name'>{info.aut_name}</span>
<span className={classNames('follow', info.is_followed ? 'followed' : '')}>{info.is_followed ? '已关注' : '关注'}</span>
</div>
  • 声明状态和对界面元素的引用。
1
2
3
4
5
6
7
8
// 控制标题上面作者信息的展示与隐藏
const [isShowAuthor, setIsShowAuthor] = useState(false)
// 标题下面作者信息
const authorRef = useRef<HTMLDivElement>(null)
// 最外包裹
const wrapRef = useRef<HTMLDivElement>(null)
<div className="wrapper" ref={wrapRef}>
<div className="author" ref={authorRef}>
  • 控制显示隐藏的逻辑。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
useEffect(() => {
const wrapDOM = wrapRef.current!
const authDOM = authorRef.current!
const onScroll = function () {
const rect = authDOM.getBoundingClientRect()!
console.log(rect.top)

if (rect.top <= 0) {
setIsShowAuthor(true)
} else {
setIsShowAuthor(false)
}
}
wrapDOM.addEventListener('scroll', onScroll)
return () => {
wrapDOM.removeEventListener('scroll', onScroll)
}
}, [])
1
2
3
{
isShowAuthor && <div className='nav-author'></div>
}

文章评论功能

获取评论数据

  • data.d.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export type Comment = {
aut_id: string
aut_name: string
aut_photo: string
com_id: string
content: string
is_followed: boolean
is_liking: boolean
like_count: number
pubdate: string
reply_count: number
}

export type CommentRes = {
end_id: string
last_id: string
results: Comment[]
total_count: number
}
  • actions/articles.ts
1
2
3
4
5
6
7
8
9
10
11
12
export function getCommentList(id: string, offset?: string): RootThunkAction {
return async (dispatch) => {
const res = await request.get<ApiResponse<CommentRes>>('/comments', {
params: {
type: 'a',
source: id,
offset,
},
})
console.log(res)
}
}
  • 组件中发送请求,pages/Article/index.tsx
1
useInitState(() => getCommentList(articleId), 'article')

存储到 Redux

  • types/store.d.ts
1
2
3
4
5
6
7
8
9
export type ArticleAction =
| {
type: 'article/setArticleInfo'
payload: ArticleDetail
}
| {
type: 'article/saveComment'
payload: CommentRes
}
  • actions/article.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export function getCommentList(id: string, offset?: string): RootThunkAction {
return async (dispatch) => {
const res = await request.get<ApiResponse<CommentRes>>('/comments', {
params: {
type: 'a',
source: id,
offset,
},
})
dispatch({
type: 'article/saveComment',
payload: res.data.data,
})
}
}
  • reducers/article.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
import { ArticleDetail, CommentRes } from '@/types/data'
import { ArticleAction } from '@/types/store'
type ArticleState = {
info: ArticleDetail
comment: CommentRes
}
const initState: ArticleState = {
info: {},
comment: {},
} as ArticleState

export default function article(state = initState, action: ArticleAction): ArticleState {
switch (action.type) {
case 'article/setArticleInfo':
return {
...state,
info: action.payload,
}
case 'article/saveComment':
const old = state.comment.results || []
return {
...state,
comment: {
...action.payload,
results: [...old, ...action.payload.results],
},
}
default:
return state
}
}

基本渲染

  1. 准备 noComment 组件。

pages/Article/components/NoComment/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
import noCommentImage from '@/assets/none.png'
import styles from './index.module.scss'

const NoComment = () => {
return (
<div className={styles.root}>
<img src={noCommentImage} alt='' />
<p className='no-comment'>还没有人评论哦</p>
</div>
)
}

export default NoComment

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.root {
padding-bottom: 34px;
text-align: center;
color: #969799;

:global {
img {
display: block;
width: 160px;
height: 160px;
margin: 12px auto;
}
}
}
  1. 获取评论数据并渲染,pages/Article/index.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const { comment } = useInitState(() => getCommentList(articleId), 'article')

<div className='comment'>
<div className='comment-header'>
<span>全部评论({comment.total_count})</span>
<span>{info.like_count} 点赞</span>
</div>
<div className='comment-list'>
{info.comm_count === 0 ? <NoComment /> : comment.results?.map((item) => <CommentItem key={item.com_id} />)}

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

渲染评论列表

pages/Article/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<div className='comment-list'>
{info.comm_count === 0 ? (
<NoComment />
) : (
comment.results?.map((item) => (
// #1
<CommentItem key={item.com_id} comment={item} type='normal' />
))
)}

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

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
69
import dayjs from 'dayjs'
import classnames from 'classnames'
import Icon from '@/components/Icon'
import styles from './index.module.scss'
import { Comment } from '@/types/data'

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

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

return (
<div className={styles.root}>
<div className='avatar'>
<img src={comment.aut_photo} alt='' />
</div>
<div className='comment-info'>
<div className='comment-info-header'>
<span className='name'>{comment.aut_name}</span>
{/* 文章评论、评论的回复 */}
{(type === 'normal' || type === 'reply') && (
<span className='thumbs-up'>
{comment.like_count}
<Icon type={comment.is_liking ? 'iconbtn_like_sel' : 'iconbtn_like2'} />
</span>
)}
{/* 要回复的评论 */}
{type === 'origin' && <span className={classnames('follow', comment.is_followed ? 'followed' : '')}>{comment.is_followed ? '已关注' : '关注'}</span>}
</div>
<div className='comment-content'>{comment.content}</div>
<div className='comment-footer'>
{replyJSX}
{/* 非评论的回复 */}
{type !== 'reply' && <span className='comment-time'>{dayjs(comment.pubdate).fromNow()}</span>}
{/* 文章的评论 */}
{type === 'origin' && (
<span className='thumbs-up'>
{comment.like_count}
<Icon type={comment.is_liking ? 'iconbtn_like_sel' : 'iconbtn_like2'} />
</span>
)}
</div>
</div>
</div>
)
}

export default CommentItem

组件销毁时清除评论数据

Bug 重现:多次进入同一篇带评论的文章试试。

store.d.ts

1
2
3
4
5
6
7
8
9
10
11
12
export type ArticleAction =
| {
type: 'article/setArticleInfo'
payload: ArticleDetail
}
| {
type: 'article/saveComment'
payload: CommentRes
}
| {
type: 'article/clearComment'
}

actions/article.ts

1
2
3
4
5
export function clearCommentList(): ArticleAction {
return {
type: 'article/clearComment',
}
}

reducers/article.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
import { ArticleDetail, CommentRes } from '@/types/data'
import { ArticleAction } from '@/types/store'
type ArticleState = {
info: ArticleDetail
comment: CommentRes
}
const initState: ArticleState = {
info: {},
comment: {},
} as ArticleState

export default function article(state = initState, action: ArticleAction): ArticleState {
switch (action.type) {
case 'article/setArticleInfo':
return {
...state,
info: action.payload,
}
case 'article/saveComment':
const old = state.comment.results || []
return {
...state,
comment: {
...action.payload,
results: [...old, ...action.payload.results],
},
}
case 'article/clearComment':
return {
...state,
comment: {} as CommentRes,
}
default:
return state
}
}

pages/Article/index.tsx

1
2
3
4
5
useEffect(() => {
return () => {
dispatch(clearCommentList())
}
}, [dispatch])

触底加载更多

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
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
import { useEffect, useState, useRef } from 'react'
import { NavBar, InfiniteScroll } from 'antd-mobile'
import { useDispatch, useSelector } from 'react-redux'
import { useHistory, useParams } 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'
import { clearCommentList, getArticleInfo, getCommentList } from '@/store/actions/article'
import dayjs from 'dayjs'
import { useInitState } from '@/utils/hooks'
import dompurify from 'dompurify'
import hljs from 'highlight.js'
import 'highlight.js/styles/vs2015.css'
import NoComment from './components/NoComment'
import { RootState } from '@/types/store'

const Article = () => {
const history = useHistory()
const dispatch = useDispatch()
const params = useParams<{ id: string }>()
const articleId = params.id
const [isShowAuthor, setIsShowAuthor] = useState(false)
const authorRef = useRef<HTMLDivElement>(null)
const wrapRef = useRef<HTMLDivElement>(null)
const { info } = useInitState(() => getArticleInfo(articleId), 'article')
// const { comment } = useInitState(() => getCommentList(articleId), 'article')
// #1 results 替换掉了前面的 comment.results
const {
results = [],
total_count = -1,
last_id,
// end_id = '',
} = useSelector((state: RootState) => state.article.comment)
// #2 不相等,表示还有更多
// const hasMore = last_id !== end_id
const hasMore = results.length !== total_count
// #3
const loadMore = async () => {
console.log('加载更多~~')
await dispatch(getCommentList(params.id, last_id))
}
useEffect(() => {
// 配置 highlight.js
hljs.configure({
// 忽略未经转义的 HTML 字符
ignoreUnescapedHTML: true,
})
// 获取到内容中所有的code标签
const codes = document.querySelectorAll('.dg-html pre code')
codes.forEach((el) => {
// 让code进行高亮
hljs.highlightElement(el as HTMLElement)
})
}, [articleId])
useEffect(() => {
const wrapDOM = wrapRef.current!
const authDOM = authorRef.current!
const onScroll = function () {
const rect = authDOM.getBoundingClientRect()!
if (rect.top <= 0) {
setIsShowAuthor(true)
} else {
setIsShowAuthor(false)
}
}
wrapDOM.addEventListener('scroll', onScroll)
return () => {
wrapDOM.removeEventListener('scroll', onScroll)
}
}, [])
useEffect(() => {
return () => {
dispatch(clearCommentList())
}
}, [dispatch])
return (
<div className={styles.root}>
<div className='root-wrapper'>
<NavBar
onBack={() => history.go(-1)}
right={
<span>
<Icon type='icongengduo' />
</span>
}
>
{isShowAuthor && (
<div className='nav-author'>
<img src={info.aut_photo} alt='' />
<span className='name'>{info.aut_name}</span>
<span className={classNames('follow', info.is_followed ? 'followed' : '')}>{info.is_followed ? '已关注' : '关注'}</span>
</div>
)}
</NavBar>
{/* //!文章详情和评论 */}
<div className='wrapper' ref={wrapRef}>
<div className='article-wrapper'>
<div className='header'>
<h1 className='title'>{info.title}</h1>
<div className='info'>
<span>{dayjs(info.pubdate).format('YYYY-MM-DD')}</span>
<span>{info.read_count} 阅读</span>
<span>{info.comm_count} 评论</span>
</div>
<div className='author' ref={authorRef}>
<img src={info.aut_photo} alt='' />
<span className='name'>{info.aut_name}</span>
<span className={classNames('follow', info.is_followed ? 'followed' : '')}>{info.is_followed ? '已关注' : '关注'}</span>
</div>
</div>

<div className='content'>
<div
className='content-html dg-html'
dangerouslySetInnerHTML={{
__html: dompurify.sanitize(info.content || ''),
}}
/>
<div className='date'>发布文章时间:{dayjs(info.pubdate).format('YYYY-MM-DD')}</div>
</div>
</div>
{/* //!评论信息 */}
<div className='comment'>
<div className='comment-header'>
<span>全部评论({total_count})</span>
<span>{info.like_count} 点赞</span>
</div>
{/* #4 */}
<div className='comment-list'>
{info.comm_count === 0 ? <NoComment /> : results?.map((item) => <CommentItem key={item.com_id} comment={item} type='normal' />)}

<InfiniteScroll hasMore={hasMore} loadMore={loadMore} />
</div>
</div>
</div>
{/* //!底部评论栏 */}
<CommentFooter />
</div>
</div>
)
}

export default Article

底部的渲染

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
52
53
54
import { useSelector } from 'react-redux'
import Icon from '@/components/Icon'
import styles from './index.module.scss'
import { RootState } from '@/types/store'

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

const CommentFooter = ({ type = 'normal' }: Props) => {
const { info } = useSelector((state: RootState) => state.article)
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>
{info.comm_count && <span className='bage'>{info.comm_count}</span>}
</div>
<div className='action-item'>
<Icon type={info.attitude === 1 ? 'iconbtn_like_sel' : 'iconbtn_like2'} />
<p>点赞</p>
</div>
<div className='action-item'>
<Icon type={info.is_collected ? 'iconbtn_collect_sel' : 'iconbtn_collect'} />
<p>收藏</p>
</div>
</>
)}

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

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

export default CommentFooter

文章点赞功能

actions/article.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
export function likeAritcle(id: string, attitude: number): RootThunkAction {
return async (dispatch) => {
if (attitude === 1) {
// 取消点赞
await request.delete('/article/likings/' + id)
} else {
// 点赞
await request.post('/article/likings', { target: id })
}
// 更新
await dispatch(getArticleInfo(id))
}
}

pages/Article/components/CommentFooter/index.tsx

1
2
3
4
const { info } = useSelector((state: RootState) => state.article)
const onLike = async () => {
await dispatch(likeAritcle(info.art_id, info.attitude))
}
1
2
3
4
<div className='action-item'>
<Icon type={info.attitude === 1 ? 'iconbtn_like_sel' : 'iconbtn_like2'} onClick={onLike} />
<p>点赞</p>
</div>

文章收藏功能

actions/article.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export function collectArticle(id: string, is_collected: boolean): RootThunkAction {
return async (dispatch) => {
if (is_collected) {
// 取消收藏
await request.delete('/article/collections/' + id)
} else {
// 收藏
await request.post('/article/collections', {
target: id,
})
}
await dispatch(getArticleInfo(id))
}
}

pages\Article\components\CommentFooter\index.tsx

1
2
3
const collect = async () => {
dispatch(collectArticle(info.art_id, info.is_collected))
}
1
2
3
4
<div className='action-item'>
<Icon type={info.is_collected ? 'iconbtn_collect_sel' : 'iconbtn_collect'} onClick={collect} />
<p>收藏</p>
</div>

关注用户功能

actions/article.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export function followUser(userId: string, is_follow: boolean): RootThunkAction {
return async (dispatch, getState) => {
if (is_follow) {
// 取消关注
await request.delete('/user/followings/' + userId)
} else {
// 关注
await request.post('/user/followings', {
target: userId,
})
}
await dispatch(getArticleInfo(getState().article.info.art_id))
}
}

pages\Article\index.tsx

1
2
3
4
const onFollowUser = async () => {
await dispatch(followUser(info.aut_id, info.is_followed))
Toast.show('操作成功')
}

点击评论位置跳转

  • 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
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
import { useEffect, useState, useRef } from 'react'
import { NavBar, InfiniteScroll, Toast } from 'antd-mobile'
import { useDispatch, useSelector } from 'react-redux'
import { useHistory, useParams } 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'
import { clearCommentList, followUser, getArticleInfo, getCommentList } from '@/store/actions/article'
import dayjs from 'dayjs'
import { useInitState } from '@/utils/hooks'
import dompurify from 'dompurify'
import hljs from 'highlight.js'
import 'highlight.js/styles/vs2015.css'
import NoComment from './components/NoComment'
import { RootState } from '@/types/store'

const Article = () => {
const history = useHistory()
const dispatch = useDispatch()
const params = useParams<{ id: string }>()
const articleId = params.id
const [isShowAuthor, setIsShowAuthor] = useState(false)
const authorRef = useRef<HTMLDivElement>(null)
const wrapRef = useRef<HTMLDivElement>(null)
// #1: 评论列表的顶部
const commentRef = useRef<HTMLDivElement>(null)
// #2
const [isComment, setIsComment] = useState(false)
// #3
const onComment = () => {
// 评论包裹
const commentDOM = commentRef.current!
// 最外部包裹
const wrapDOM = wrapRef.current!
// 操作的是最外部包裹的 scrollTop
if (isComment) {
wrapDOM.scrollTo(0, 0)
} else {
wrapDOM.scrollTo(0, commentDOM.offsetTop - 44)
}
setIsComment(!isComment)
}
const { info } = useInitState(() => getArticleInfo(articleId), 'article')
const {
results = [],
total_count = -1,
last_id,
// end_id = '',
} = useSelector((state: RootState) => state.article.comment)
// const hasMore = last_id !== end_id
const hasMore = results.length !== total_count
const loadMore = async () => {
console.log('加载更多~~')
await dispatch(getCommentList(params.id, last_id))
}
const onFollowUser = async () => {
await dispatch(followUser(info.aut_id, info.is_followed))
Toast.show('操作成功')
}
useEffect(() => {
// 配置 highlight.js
hljs.configure({
// 忽略未经转义的 HTML 字符
ignoreUnescapedHTML: true,
})
// 获取到内容中所有的code标签
const codes = document.querySelectorAll('.dg-html pre code')
codes.forEach((el) => {
// 让code进行高亮
hljs.highlightElement(el as HTMLElement)
})
}, [articleId])
useEffect(() => {
const wrapDOM = wrapRef.current!
const authDOM = authorRef.current!
const onScroll = function () {
const rect = authDOM.getBoundingClientRect()!
if (rect.top <= 0) {
setIsShowAuthor(true)
} else {
setIsShowAuthor(false)
}
}
wrapDOM.addEventListener('scroll', onScroll)
return () => {
wrapDOM.removeEventListener('scroll', onScroll)
}
}, [])
useEffect(() => {
return () => {
dispatch(clearCommentList())
}
}, [dispatch])
return (
<div className={styles.root}>
<div className='root-wrapper'>
<NavBar
onBack={() => history.go(-1)}
right={
<span>
<Icon type='icongengduo' />
</span>
}
>
{isShowAuthor && (
<div className='nav-author'>
<img src={info.aut_photo} alt='' />
<span className='name'>{info.aut_name}</span>
<span className={classNames('follow', info.is_followed ? 'followed' : '')} onClick={onFollowUser}>
{info.is_followed ? '已关注' : '关注'}
</span>
</div>
)}
</NavBar>
{/* //!文章详情和评论 */}
<div className='wrapper' ref={wrapRef}>
<div className='article-wrapper'>
<div className='header'>
<h1 className='title'>{info.title}</h1>
<div className='info'>
<span>{dayjs(info.pubdate).format('YYYY-MM-DD')}</span>
<span>{info.read_count} 阅读</span>
<span>{info.comm_count} 评论</span>
</div>
<div className='author' ref={authorRef}>
<img src={info.aut_photo} alt='' />
<span className='name'>{info.aut_name}</span>
<span className={classNames('follow', info.is_followed ? 'followed' : '')} onClick={onFollowUser}>
{info.is_followed ? '已关注' : '关注'}
</span>
</div>
</div>

<div className='content'>
<div
className='content-html dg-html'
dangerouslySetInnerHTML={{
__html: dompurify.sanitize(info.content || ''),
}}
/>
<div className='date'>发布文章时间:{dayjs(info.pubdate).format('YYYY-MM-DD')}</div>
</div>
</div>
{/* //!评论信息 */}
<div className='comment'>
<div className='comment-header' ref={commentRef}>
<span>全部评论({total_count})</span>
<span>{info.like_count} 点赞</span>
</div>
<div className='comment-list'>
{info.comm_count === 0 ? <NoComment /> : results?.map((item) => <CommentItem key={item.com_id} comment={item} type='normal' />)}

<InfiniteScroll hasMore={hasMore} loadMore={loadMore} />
</div>
</div>
</div>
{/* //! #4 底部评论栏 */}
<CommentFooter onComment={onComment} />
</div>
</div>
)
}

export default Article
  • 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
52
53
54
55
56
57
58
59
60
61
62
63
import { useSelector, useDispatch } from 'react-redux'
import Icon from '@/components/Icon'
import styles from './index.module.scss'
import { RootState } from '@/types/store'
import { collectArticle, likeAritcle } from '@/store/actions/article'

type Props = {
// normal 普通评论
// reply 回复评论
type?: 'normal' | 'reply'
onComment?: () => void
}

const CommentFooter = ({ type = 'normal', onComment }: Props) => {
const dispatch = useDispatch()
const { info } = useSelector((state: RootState) => state.article)
const onLike = async () => {
await dispatch(likeAritcle(info.art_id, info.attitude))
}
const onCollect = async () => {
dispatch(collectArticle(info.art_id, info.is_collected))
}
return (
<div className={styles.root}>
<div className='input-btn'>
<Icon type='iconbianji' />
<span>抢沙发</span>
</div>

{type === 'normal' && (
<>
<div className='action-item' onClick={onComment}>
<Icon type='iconbtn_comment' />
<p>评论</p>
{info.comm_count && <span className='bage'>{info.comm_count}</span>}
</div>
<div className='action-item' onClick={onLike}>
<Icon type={info.attitude === 1 ? 'iconbtn_like_sel' : 'iconbtn_like2'} />
<p>点赞</p>
</div>
<div className='action-item' onClick={onCollect}>
<Icon type={info.is_collected ? 'iconbtn_collect_sel' : 'iconbtn_collect'} />
<p>收藏</p>
</div>
</>
)}

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

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

export default CommentFooter

封装评论表单组件

素材中已经提供好了,把 CommentInput 粘贴到 pages/Article/components 文件夹。

pages/Article/components/CommentInput/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import styles from './index.module.scss'
import { NavBar, TextArea } from 'antd-mobile'
type Props = {
// 评论的作者的名字
name?: string
}
export default function CommentInput({ name }: Props) {
return (
<div className={styles.root}>
<NavBar right={<span className='publish'>发表</span>}>{name ? '回复评论' : '评论文章'}</NavBar>
<div className='input-area'>
{/* 回复别人的评论时显示:@某某 */}
{name && <div className='at'>@{name}:</div>}

{/* 评论内容输入框 */}
<TextArea placeholder='说点什么~' rows={10} />
</div>
</div>
)
}

pages/Article/components/CommentInput/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
.root {
height: 100%;
background-color: #fff;
width: 375px;
:global {
.publish {
color: #fc6627;
font-size: 17px;
}

.input-area {
padding: 15px 16px;
font-size: 16px;

&::placeholder {
font-size: 16px;
}

.at {
color: #969799;
}
}
}
}

显示评论表单组件

pages/Article/index.tsx,准备控制弹框展示与否的变量和方法,打开的方法传递给 CommentFooter 组件,关闭的方法传递给 CommentInput 组件。

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
import { useEffect, useState, useRef } from 'react'
import { NavBar, InfiniteScroll, Toast, Popup } from 'antd-mobile'
import { useDispatch, useSelector } from 'react-redux'
import { useHistory, useParams } 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'
import { clearCommentList, followUser, getArticleInfo, getCommentList } from '@/store/actions/article'
import dayjs from 'dayjs'
import { useInitState } from '@/utils/hooks'
import dompurify from 'dompurify'
import hljs from 'highlight.js'
import 'highlight.js/styles/vs2015.css'
import NoComment from './components/NoComment'
import { RootState } from '@/types/store'
import CommentInput from './components/CommentInput'

const Article = () => {
const history = useHistory()
const dispatch = useDispatch()
const params = useParams<{ id: string }>()
const articleId = params.id
const [isShowAuthor, setIsShowAuthor] = useState(false)
const authorRef = useRef<HTMLDivElement>(null)
const wrapRef = useRef<HTMLDivElement>(null)
const commentRef = useRef<HTMLDivElement>(null)
const [isComment, setIsComment] = useState(false)
const onComment = () => {
const commentDOM = commentRef.current!
const wrapDOM = wrapRef.current!
if (isComment) {
wrapDOM.scrollTo(0, 0)
} else {
wrapDOM.scrollTo(0, commentDOM.offsetTop - 44)
}
setIsComment(!isComment)
}
const { info } = useInitState(() => getArticleInfo(articleId), 'article')
const {
results = [],
total_count = -1,
last_id,
// end_id = '',
} = useSelector((state: RootState) => state.article.comment)
// const hasMore = last_id !== end_id
const hasMore = results.length !== total_count
const loadMore = async () => {
console.log('加载更多~~')
await dispatch(getCommentList(params.id, last_id))
}
const onFollowUser = async () => {
await dispatch(followUser(info.aut_id, info.is_followed))
Toast.show('操作成功')
}
// #1
const [commentShow, setCommentShow] = useState(false)
const hideComment = () => {
setCommentShow(false)
}
const showComment = () => {
setCommentShow(true)
}
useEffect(() => {
// 配置 highlight.js
hljs.configure({
// 忽略未经转义的 HTML 字符
ignoreUnescapedHTML: true,
})
// 获取到内容中所有的code标签
const codes = document.querySelectorAll('.dg-html pre code')
codes.forEach((el) => {
// 让code进行高亮
hljs.highlightElement(el as HTMLElement)
})
}, [articleId])
useEffect(() => {
const wrapDOM = wrapRef.current!
const authDOM = authorRef.current!
const onScroll = function () {
const rect = authDOM.getBoundingClientRect()!
if (rect.top <= 0) {
setIsShowAuthor(true)
} else {
setIsShowAuthor(false)
}
}
wrapDOM.addEventListener('scroll', onScroll)
return () => {
wrapDOM.removeEventListener('scroll', onScroll)
}
}, [])
useEffect(() => {
return () => {
dispatch(clearCommentList())
}
}, [dispatch])
return (
<div className={styles.root}>
<div className='root-wrapper'>
<NavBar
onBack={() => history.go(-1)}
right={
<span>
<Icon type='icongengduo' />
</span>
}
>
{isShowAuthor && (
<div className='nav-author'>
<img src={info.aut_photo} alt='' />
<span className='name'>{info.aut_name}</span>
<span className={classNames('follow', info.is_followed ? 'followed' : '')} onClick={onFollowUser}>
{info.is_followed ? '已关注' : '关注'}
</span>
</div>
)}
</NavBar>
{/* //!文章详情和评论 */}
<div className='wrapper' ref={wrapRef}>
<div className='article-wrapper'>
<div className='header'>
<h1 className='title'>{info.title}</h1>
<div className='info'>
<span>{dayjs(info.pubdate).format('YYYY-MM-DD')}</span>
<span>{info.read_count} 阅读</span>
<span>{info.comm_count} 评论</span>
</div>
<div className='author' ref={authorRef}>
<img src={info.aut_photo} alt='' />
<span className='name'>{info.aut_name}</span>
<span className={classNames('follow', info.is_followed ? 'followed' : '')} onClick={onFollowUser}>
{info.is_followed ? '已关注' : '关注'}
</span>
</div>
</div>

<div className='content'>
<div
className='content-html dg-html'
dangerouslySetInnerHTML={{
__html: dompurify.sanitize(info.content || ''),
}}
/>
<div className='date'>发布文章时间:{dayjs(info.pubdate).format('YYYY-MM-DD')}</div>
</div>
</div>
{/* //!评论信息 */}
<div className='comment'>
<div className='comment-header' ref={commentRef}>
<span>全部评论({total_count})</span>
<span>{info.like_count} 点赞</span>
</div>
<div className='comment-list'>
{info.comm_count === 0 ? <NoComment /> : results?.map((item) => <CommentItem key={item.com_id} comment={item} type='normal' />)}
<InfiniteScroll hasMore={hasMore} loadMore={loadMore} />
</div>
</div>
</div>
{/* //!底部评论栏 */}
{/* #2: 点击抢沙发的时候显示 */}
<CommentFooter onComment={onComment} showComment={showComment} />
</div>
{/* #3: 点击返回的时候隐藏 */}
<Popup visible={commentShow} position='right' destroyOnClose>
<CommentInput hideComment={hideComment} />
</Popup>
</div>
)
}

export default Article

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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import { useSelector, useDispatch } from 'react-redux'
import Icon from '@/components/Icon'
import styles from './index.module.scss'
import { RootState } from '@/types/store'
import { collectArticle, likeAritcle } from '@/store/actions/article'

type Props = {
// normal 普通评论
// reply 回复评论
type?: 'normal' | 'reply'
onComment?: () => void
showComment: () => void
}

const CommentFooter = ({ type = 'normal', onComment, showComment }: Props) => {
const dispatch = useDispatch()
const { info } = useSelector((state: RootState) => state.article)
const onLike = async () => {
await dispatch(likeAritcle(info.art_id, info.attitude))
}
const onCollect = async () => {
dispatch(collectArticle(info.art_id, info.is_collected))
}
return (
<div className={styles.root}>
{/* #4 */}
<div className='input-btn' onClick={showComment}>
<Icon type='iconbianji' />
<span>抢沙发</span>
</div>

{type === 'normal' && (
<>
<div className='action-item' onClick={onComment}>
<Icon type='iconbtn_comment' />
<p>评论</p>
{info.comm_count && <span className='bage'>{info.comm_count}</span>}
</div>
<div className='action-item' onClick={onLike}>
<Icon type={info.attitude === 1 ? 'iconbtn_like_sel' : 'iconbtn_like2'} />
<p>点赞</p>
</div>
<div className='action-item' onClick={onCollect}>
<Icon type={info.is_collected ? 'iconbtn_collect_sel' : 'iconbtn_collect'} />
<p>收藏</p>
</div>
</>
)}

{type === 'reply' && (
<div className='action-item'>
<Icon type={info.attitude === 1 ? '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/CommentInput/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
import { useRef, useEffect } from 'react'
import styles from './index.module.scss'
import { NavBar, TextArea } from 'antd-mobile'
import { TextAreaRef } from 'antd-mobile/es/components/text-area'
type Props = {
// 评论的作者的名字
name?: string
hideComment: () => void
}
export default function CommentInput({ name, hideComment }: Props) {
const textRef = useRef<TextAreaRef>(null)
// 自动获取焦点
useEffect(() => textRef.current?.focus(), [])
return (
<div className={styles.root}>
{/* #5 */}
<NavBar right={<span className='publish'>发表</span>} onBack={hideComment}>
{name ? '回复评论' : '评论文章'}
</NavBar>
<div className='input-area'>
{/* 回复别人的评论时显示:@某某 */}
{name && <div className='at'>@{name}:</div>}
{/* 评论内容输入框 */}
<TextArea placeholder='说点什么~' rows={10} ref={textRef} />
</div>
</div>
)
}

发表评论功能

  1. tore.d.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export type ArticleAction =
| {
type: 'article/setArticleInfo'
payload: ArticleDetail
}
| {
type: 'article/saveComment'
payload: CommentRes
}
| {
type: 'article/clearComment'
}
| {
type: 'article/saveNewComment'
payload: Comment
}
  1. store/actions/article.ts
1
2
3
4
5
6
7
8
9
10
11
12
export function addComment(articleId: string, content: string): RootThunkAction {
return async (dispatch) => {
const res = await request.post<ApiResponse<{ new_obj: Comment }>>('/comments', {
target: articleId,
content,
})
dispatch({
type: 'article/saveNewComment',
payload: res.data.data.new_obj,
})
}
}
  1. store/reducers/article.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
import { ArticleDetail, CommentRes } from '@/types/data'
import { ArticleAction } from '@/types/store'
type ArticleState = {
info: ArticleDetail
comment: CommentRes
}
const initState: ArticleState = {
info: {},
comment: {},
} as ArticleState

export default function article(state = initState, action: ArticleAction): ArticleState {
switch (action.type) {
case 'article/setArticleInfo':
return {
...state,
info: action.payload,
}
case 'article/saveComment':
const old = state.comment.results || []
return {
...state,
comment: {
...action.payload,
results: [...old, ...action.payload.results],
},
}
case 'article/clearComment':
return {
...state,
comment: {} as CommentRes,
}
case 'article/saveNewComment':
return {
...state,
comment: {
...state.comment,
results: [action.payload, ...state.comment.results],
},
}
default:
return state
}
}
  1. pages/Article/index.tsx
1
2
3
const onAddComment = (comment: string) => {
dispatch(addComment(articleId, comment))
}
1
2
3
<Popup visible={commentShow} position='right' destroyOnClose>
<CommentInput hideComment={hideComment} onAddComment={onAddComment} />
</Popup>
  1. pages/Article/components/CommentInput/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
import styles from './index.module.scss'
import { NavBar, TextArea } from 'antd-mobile'
import { useState } from 'react'
type Props = {
// 评论的作者的名字
name?: string
hideComment: () => void
onAddComment: (comment: string) => void
}
export default function CommentInput({ name, hideComment, onAddComment }: Props) {
const [comment, setComment] = useState('')
const onPublishComment = () => {
if (!comment) return
onAddComment && onAddComment(comment)
hideComment()
}
return (
<div className={styles.root}>
<NavBar
right={
<span className='publish' onClick={onPublishComment}>
发表
</span>
}
onBack={hideComment}
>
{name ? '回复评论' : '评论文章'}
</NavBar>
<div className='input-area'>
{/* 回复别人的评论时显示:@某某 */}
{name && <div className='at'>@{name}:</div>}

{/* 评论内容输入框 */}
<TextArea placeholder='说点什么~' rows={10} value={comment} onChange={(e) => setComment(e)} />
</div>
</div>
)
}
  1. pages/Article/index.tsx
1
2
3
4
5
6
const onAddComment = async (comment: string) => {
await dispatch(addComment(articleId, comment))
// 更新评论数量
await dispatch(getArticleInfo(articleId))
Toast.show({ icon: 'success', content: '发表成功' })
}
  1. pages/Article/index.jsx
1
2
// 之前用到是 total_count,改成 info.comm_count
<span>全部评论({info.comm_count})</span>

评论回复功能

素材准备

pages/Article/components/CommentReply/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
import { NavBar } from 'antd-mobile'
import CommentFooter from '../CommentFooter'
import NoComment from '../NoComment'
import styles from './index.module.scss'
export default function CommentReply() {
return (
<div className={styles.root}>
<div className='reply-wrapper'>
{/* 顶部导航栏 */}
<NavBar className='transparent-navbar'>
<div>{0}条回复</div>
</NavBar>

{/* 原评论信息 */}
<div className='origin-comment'>原评论</div>

{/* 回复评论的列表 */}
<div className='reply-list'>
<div className='reply-header'>全部回复</div>

<NoComment />
</div>

{/* 评论工具栏,设置 type="reply" 不显示评论和点赞按钮 */}
<CommentFooter />
</div>
</div>
)
}

pages/Article/components/CommentReply/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
@import '~@scss/hairline.scss';

.root {
height: 100%;
border-radius: 20px 20px 0px 0px;
background-color: #fff;
width: 375px;

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

.transparent-navbar {
background-color: transparent;
}

.origin-comment {
position: relative;
padding-top: 7px;
@include hairline(bottom, #efefef);
}

.reply-list {
flex: 1;
overflow-y: scroll;

.reply-header {
padding: 16px;
font-size: 17px;
font-weight: 500;
}
}
}
}

弹框控制

  1. 把素材中的 CommentReply 组件粘贴到 src\pages\Article\components 文件夹。

  2. src\pages\Article\index.tsx,父组件准备控制弹框的变量和方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const [replyShow, setReplyShow] = useState({
visible: false,
origin: {} as Comment,
})
const hideReply = () => {
setReplyShow({
visible: false,
origin: {} as Comment,
})
}
// !注意这个 Comment 类型是自己定义的,导入的时候细心
const onReply = (comment: Comment) => {
setReplyShow({
visible: true,
origin: comment,
})
}
1
<CommentItem key={item.com_id} comment={item} type='normal' onReply={onReply} />
1
2
3
<Popup visible={replyShow.visible} position='right' destroyOnClose>
<CommentReply onHideReply={hideReply} />
</Popup>
  1. src\pages\Article\components\CommentItem\index.tsx,点击回复按钮展示弹框。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type Props = {
type?: 'normal' | 'reply' | 'origin'
comment: Comment
onReply?: (comment: Comment) => void
}

const CommentItem = ({ type = 'normal', comment, onReply }: Props) => {
// 回复按钮
const replyJSX =
type === 'normal' ? (
<span className='replay' onClick={() => onReply && onReply(comment)}>
{comment.reply_count} 回复
<Icon type='iconbtn_right' />
</span>
) : null
}

export default CommentItem
  1. src\pages\Article\components\CommentReply\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
import { NavBar } from 'antd-mobile'
import CommentFooter from '../CommentFooter'
import NoComment from '../NoComment'
import styles from './index.module.scss'
type Props = {
onHideReply: () => void
}
export default function CommentReply({ onHideReply }: Props) {
return (
<div className={styles.root}>
<div className='reply-wrapper'>
{/* 顶部导航栏 */}
<NavBar className='transparent-navbar' onBack={onHideReply}>
<div>{0}条回复</div>
</NavBar>

{/* 原评论信息 */}
<div className='origin-comment'>原评论</div>

{/* 回复评论的列表 */}
<div className='reply-list'>
<div className='reply-header'>全部回复</div>

<NoComment />
</div>

{/* 评论工具栏,设置 type="reply" 不显示评论和点赞按钮 */}
<CommentFooter />
</div>
</div>
)
}
  1. src\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
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
import { useEffect, useState, useRef } from 'react'
import { NavBar, InfiniteScroll, Toast, Popup } from 'antd-mobile'
import { useDispatch, useSelector } from 'react-redux'
import { useHistory, useParams } 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'
import { addComment, clearCommentList, followUser, getArticleInfo, getCommentList } from '@/store/actions/article'
import dayjs from 'dayjs'
import { useInitState } from '@/utils/hooks'
import dompurify from 'dompurify'
import hljs from 'highlight.js'
import 'highlight.js/styles/vs2015.css'
import NoComment from './components/NoComment'
import { RootState } from '@/types/store'
import CommentInput from './components/CommentInput'
import CommentReply from './components/CommentReply'
import { Comment } from '@/types/data'

const Article = () => {
const history = useHistory()
const dispatch = useDispatch()
const params = useParams<{ id: string }>()
const articleId = params.id
const [isShowAuthor, setIsShowAuthor] = useState(false)
const authorRef = useRef<HTMLDivElement>(null)
const wrapRef = useRef<HTMLDivElement>(null)
const commentRef = useRef<HTMLDivElement>(null)
const [isComment, setIsComment] = useState(false)
const onComment = () => {
const commentDOM = commentRef.current!
const wrapDOM = wrapRef.current!
if (isComment) {
wrapDOM.scrollTo(0, 0)
} else {
wrapDOM.scrollTo(0, commentDOM.offsetTop - 44)
}
setIsComment(!isComment)
}
const { info } = useInitState(() => getArticleInfo(articleId), 'article')
const {
results = [],
total_count = -1,
last_id,
// end_id = '',
} = useSelector((state: RootState) => state.article.comment)
// const hasMore = last_id !== end_id
const hasMore = results.length !== total_count
const loadMore = async () => {
console.log('加载更多~~')
await dispatch(getCommentList(params.id, last_id))
}
const onFollowUser = async () => {
await dispatch(followUser(info.aut_id, info.is_followed))
Toast.show('操作成功')
}
const [commentShow, setCommentShow] = useState(false)
const hideComment = () => {
setCommentShow(false)
}
const showComment = () => {
setCommentShow(true)
}
const onAddComment = async (comment: string) => {
await dispatch(addComment(articleId, comment))
await dispatch(getArticleInfo(articleId))
Toast.show({ icon: 'success', content: '发表成功' })
}
// #1
const [replyShow, setReplyShow] = useState({
visible: false,
origin: {} as Comment,
})
const hideReply = () => {
setReplyShow({
visible: false,
origin: {} as Comment,
})
}
const onReply = (comment: Comment) => {
setReplyShow({
visible: true,
origin: comment,
})
}
useEffect(() => {
// 配置 highlight.js
hljs.configure({
// 忽略未经转义的 HTML 字符
ignoreUnescapedHTML: true,
})
// 获取到内容中所有的code标签
const codes = document.querySelectorAll('.dg-html pre code')
codes.forEach((el) => {
// 让code进行高亮
hljs.highlightElement(el as HTMLElement)
})
}, [articleId])
useEffect(() => {
const wrapDOM = wrapRef.current!
const authDOM = authorRef.current!
const onScroll = function () {
const rect = authDOM.getBoundingClientRect()!
if (rect.top <= 0) {
setIsShowAuthor(true)
} else {
setIsShowAuthor(false)
}
}
wrapDOM.addEventListener('scroll', onScroll)
return () => {
wrapDOM.removeEventListener('scroll', onScroll)
}
}, [])
useEffect(() => {
return () => {
dispatch(clearCommentList())
}
}, [dispatch])
return (
<div className={styles.root}>
<div className='root-wrapper'>
<NavBar
onBack={() => history.go(-1)}
right={
<span>
<Icon type='icongengduo' />
</span>
}
>
{isShowAuthor && (
<div className='nav-author'>
<img src={info.aut_photo} alt='' />
<span className='name'>{info.aut_name}</span>
<span className={classNames('follow', info.is_followed ? 'followed' : '')} onClick={onFollowUser}>
{info.is_followed ? '已关注' : '关注'}
</span>
</div>
)}
</NavBar>
{/* //!文章详情和评论 */}
<div className='wrapper' ref={wrapRef}>
<div className='article-wrapper'>
<div className='header'>
<h1 className='title'>{info.title}</h1>
<div className='info'>
<span>{dayjs(info.pubdate).format('YYYY-MM-DD')}</span>
<span>{info.read_count} 阅读</span>
<span>{info.comm_count} 评论</span>
</div>
<div className='author' ref={authorRef}>
<img src={info.aut_photo} alt='' />
<span className='name'>{info.aut_name}</span>
<span className={classNames('follow', info.is_followed ? 'followed' : '')} onClick={onFollowUser}>
{info.is_followed ? '已关注' : '关注'}
</span>
</div>
</div>

<div className='content'>
<div
className='content-html dg-html'
dangerouslySetInnerHTML={{
__html: dompurify.sanitize(info.content || ''),
}}
/>
<div className='date'>发布文章时间:{dayjs(info.pubdate).format('YYYY-MM-DD')}</div>
</div>
</div>
{/* //!评论信息 */}
<div className='comment'>
<div className='comment-header' ref={commentRef}>
<span>全部评论({info.comm_count})</span>
<span>{info.like_count} 点赞</span>
</div>
<div className='comment-list'>
{info.comm_count === 0 ? <NoComment /> : results?.map((item) => <CommentItem key={item.com_id} comment={item} type='normal' onReply={onReply} />)}
<InfiniteScroll hasMore={hasMore} loadMore={loadMore} />
</div>
</div>
</div>
{/* //!底部评论栏 */}
<CommentFooter onComment={onComment} showComment={showComment} />
</div>
<Popup visible={commentShow} position='right' destroyOnClose>
<CommentInput hideComment={hideComment} onAddComment={onAddComment} />
</Popup>
<Popup visible={replyShow.visible} position='right' destroyOnClose>
<CommentReply onHideReply={hideReply} />
</Popup>
</div>
)
}

export default Article

src\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
69
import dayjs from 'dayjs'
import classnames from 'classnames'
import Icon from '@/components/Icon'
import styles from './index.module.scss'
import { Comment } from '@/types/data'

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

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

return (
<div className={styles.root}>
<div className='avatar'>
<img src={comment.aut_photo} alt='' />
</div>
<div className='comment-info'>
<div className='comment-info-header'>
<span className='name'>{comment.aut_name}</span>
{/* 文章评论、评论的回复 */}
{(type === 'normal' || type === 'reply') && (
<span className='thumbs-up'>
{comment.like_count}
<Icon type={comment.is_liking ? 'iconbtn_like_sel' : 'iconbtn_like2'} />
</span>
)}
{/* 要回复的评论 */}
{type === 'origin' && <span className={classnames('follow', comment.is_followed ? 'followed' : '')}>{comment.is_followed ? '已关注' : '关注'}</span>}
</div>
<div className='comment-content'>{comment.content}</div>
<div className='comment-footer'>
{replyJSX}
{/* 非评论的回复 */}
{type !== 'reply' && <span className='comment-time'>{dayjs(comment.pubdate).fromNow()}</span>}
{/* 文章的评论 */}
{type === 'origin' && (
<span className='thumbs-up'>
{comment.like_count}
<Icon type={comment.is_liking ? 'iconbtn_like_sel' : 'iconbtn_like2'} />
</span>
)}
</div>
</div>
</div>
)
}

export default CommentItem

src\pages\Article\components\CommentReply\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
import { NavBar } from 'antd-mobile'
import CommentFooter from '../CommentFooter'
import NoComment from '../NoComment'
import styles from './index.module.scss'
type Props = {
onHideReply: () => void
}
export default function CommentReply({ onHideReply }: Props) {
return (
<div className={styles.root}>
<div className='reply-wrapper'>
{/* 顶部导航栏 */}
<NavBar className='transparent-navbar' onBack={onHideReply}>
<div>{0}条回复</div>
</NavBar>

{/* 原评论信息 */}
<div className='origin-comment'>原评论</div>

{/* 回复评论的列表 */}
<div className='reply-list'>
<div className='reply-header'>全部回复</div>

<NoComment />
</div>

{/* 评论工具栏,设置 type="reply" 不显示评论和点赞按钮 */}
<CommentFooter type='reply' />
</div>
</div>
)
}

渲染原始评论

src\pages\Article\index.tsx

1
<CommentReply origin={replyShow.origin} onHideReply={hideReply} />

src\pages\Article\components\CommentReply\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 { Comment } from '@/types/data'
import { NavBar } from 'antd-mobile'
import CommentFooter from '../CommentFooter'
import CommentItem from '../CommentItem'
import NoComment from '../NoComment'
import styles from './index.module.scss'
type Props = {
onHideReply: () => void
origin: Comment
}
export default function CommentReply({ onHideReply, origin }: Props) {
return (
<div className={styles.root}>
<div className='reply-wrapper'>
{/* 顶部导航栏 */}
<NavBar className='transparent-navbar' onBack={onHideReply}>
<div>{origin.reply_count}条回复</div>
</NavBar>
{/* 原评论信息 */}
<div className='origin-comment'>
<CommentItem comment={origin} type='origin' />
</div>
{/* 回复评论的列表 */}
<div className='reply-list'>
<div className='reply-header'>全部回复</div>
<NoComment />
</div>
{/* 评论工具栏,设置 type="reply" 不显示评论和点赞按钮 */}
<CommentFooter type='reply' />
</div>
</div>
)
}

渲染列表

  1. src\store\actions\article.ts
1
2
3
4
5
6
7
8
9
10
11
12
export function getReplyList(id: string, offset: string): RootThunkAction {
return async (dispatch) => {
const res = await request.get<ApiResponse<CommentRes>>('/comments', {
params: {
type: 'c',
source: id,
offset,
},
})
console.log(res.data.data)
}
}
  1. 测试:组件中发送请求并渲染。

src\pages\Article\components\CommentReply\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 { useEffect, useState } from 'react'
import { ApiResponse, Comment, CommentRes } from '@/types/data'
import { NavBar } from 'antd-mobile'
import CommentFooter from '../CommentFooter'
import CommentItem from '../CommentItem'
import NoComment from '../NoComment'
import styles from './index.module.scss'
import request from '@/utils/request'
type Props = {
onHideReply: () => void
origin: Comment
}
export default function CommentReply({ onHideReply, origin }: Props) {
let [reply, setReply] = useState<CommentRes>({} as CommentRes)
useEffect(() => {
const getReply = async () => {
const res = await request.get<ApiResponse<CommentRes>>('/comments', {
params: {
type: 'c',
source: origin.com_id,
offset: reply.last_id,
},
})
setReply(res.data.data)
}
getReply()
}, [])
reply.results = reply.results ? reply.results : []
return (
<div className={styles.root}>
<div className='reply-wrapper'>
{/* 顶部导航栏 */}
<NavBar className='transparent-navbar' onBack={onHideReply}>
<div>{origin.reply_count}条回复</div>
</NavBar>
{/* 原评论信息 */}
<div className='origin-comment'>
<CommentItem comment={origin} type='origin' />
</div>
{/* 回复评论的列表 */}
<div className='reply-list'>
<div className='reply-header'>全部回复</div>
{reply.results.length === 0 ? <NoComment /> : reply.results.map((item) => <CommentItem key={item.com_id} comment={item} type='reply' />)}
</div>
{/* 评论工具栏,设置 type="reply" 不显示评论和点赞按钮 */}
<CommentFooter type='reply' />
</div>
</div>
)
}
  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
import { useState } from 'react'
import { ApiResponse, Comment, CommentRes } from '@/types/data'
import { NavBar, InfiniteScroll } from 'antd-mobile'
import CommentFooter from '../CommentFooter'
import CommentItem from '../CommentItem'
import NoComment from '../NoComment'
import styles from './index.module.scss'
import request from '@/utils/request'
type Props = {
onHideReply: () => void
origin: Comment
}
export default function CommentReply({ onHideReply, origin }: Props) {
const [reply, setReply] = useState<CommentRes>({} as CommentRes)
const { last_id, end_id = '' } = reply
const hasMore = last_id !== end_id
const loadMore = async () => {
const res = await request.get<ApiResponse<CommentRes>>('/comments', {
params: {
type: 'c',
source: origin.com_id,
offset: reply.last_id,
},
})
setReply({
...res.data.data,
results: [...(reply.results || []), ...res.data.data.results],
})
}
return (
<div className={styles.root}>
<div className='reply-wrapper'>
{/* 顶部导航栏 */}
<NavBar className='transparent-navbar' onBack={onHideReply}>
<div>{origin.reply_count}条回复</div>
</NavBar>
{/* 原评论信息 */}
<div className='origin-comment'>
<CommentItem comment={origin} type='origin' />
</div>
{/* 回复评论的列表 */}
<div className='reply-list'>
<div className='reply-header'>全部回复</div>
{/* 注意这儿,reply.results?. */}
{reply.results?.length === 0 ? <NoComment /> : reply.results?.map((item) => <CommentItem key={item.com_id} comment={item} type='reply' />)}
<InfiniteScroll hasMore={hasMore} loadMore={loadMore} />
</div>
{/* 评论工具栏,设置 type="reply" 不显示评论和点赞按钮 */}
<CommentFooter type='reply' />
</div>
</div>
)
}

准备弹框

src\pages\Article\components\CommentReply\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
import { useState } from 'react'
import { ApiResponse, Comment, CommentRes } from '@/types/data'
import { NavBar, InfiniteScroll, Popup } from 'antd-mobile'
import CommentFooter from '../CommentFooter'
import CommentItem from '../CommentItem'
import NoComment from '../NoComment'
import styles from './index.module.scss'
import request from '@/utils/request'
import CommentInput from '../CommentInput'
type Props = {
onHideReply: () => void
origin: Comment
}
export default function CommentReply({ onHideReply, origin }: Props) {
const [reply, setReply] = useState<CommentRes>({} as CommentRes)
const { last_id, end_id = '' } = reply
const hasMore = last_id !== end_id
const loadMore = async () => {
const res = await request.get<ApiResponse<CommentRes>>('/comments', {
params: {
type: 'c',
source: origin.com_id,
offset: reply.last_id,
},
})
setReply({
...res.data.data,
results: [...(reply.results || []), ...res.data.data.results],
})
}
// #1
const [show, setShow] = useState(false)
const showComment = () => setShow(true)
const hideComment = () => setShow(false)
return (
<div className={styles.root}>
<div className='reply-wrapper'>
{/* 顶部导航栏 */}
<NavBar className='transparent-navbar' onBack={onHideReply}>
<div>{origin.reply_count}条回复</div>
</NavBar>
{/* 原评论信息 */}
<div className='origin-comment'>
<CommentItem comment={origin} type='origin' />
</div>
{/* 回复评论的列表 */}
<div className='reply-list'>
<div className='reply-header'>全部回复</div>
{reply.results?.length === 0 ? <NoComment /> : reply.results?.map((item) => <CommentItem key={item.com_id} comment={item} type='reply' />)}
<InfiniteScroll hasMore={hasMore} loadMore={loadMore} />
</div>
{/* 评论工具栏,设置 type="reply" 不显示评论和点赞按钮 */}
<CommentFooter showComment={showComment} type='reply' />
</div>
{/* #2 */}
<Popup visible={show} position='right' destroyOnClose>
<CommentInput hideComment={hideComment} />
</Popup>
</div>
)
}

完成功能

pages/Article/components/CommentReply/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
import { useState } from 'react'
import { useSelector } from 'react-redux'
import { ApiResponse, Comment, CommentRes } from '@/types/data'
import { NavBar, InfiniteScroll, Popup } from 'antd-mobile'
import CommentFooter from '../CommentFooter'
import CommentItem from '../CommentItem'
import NoComment from '../NoComment'
import styles from './index.module.scss'
import request from '@/utils/request'
import CommentInput from '../CommentInput'
import { RootState } from '@/types/store'
type Props = {
onHideReply: () => void
origin: Comment
}
export default function CommentReply({ onHideReply, origin }: Props) {
const [reply, setReply] = useState<CommentRes>({} as CommentRes)
const { last_id, end_id = '' } = reply
const hasMore = last_id !== end_id
const loadMore = async () => {
const res = await request.get<ApiResponse<CommentRes>>('/comments', {
params: {
type: 'c',
source: origin.com_id,
offset: reply.last_id,
},
})
setReply({
...res.data.data,
results: [...(reply.results || []), ...res.data.data.results],
})
}
const [show, setShow] = useState(false)
const showComment = () => setShow(true)
const hideComment = () => setShow(false)
const { art_id } = useSelector((state: RootState) => state.article.info)
// !对评论进行回复
const onAddComment = async (value: string) => {
const res = await request.post<ApiResponse<{ new_obj: Comment }>>('/comments', {
target: origin.com_id,
content: value,
art_id,
})
const old = reply.results || []
setReply({
...reply,
results: [res.data.data.new_obj, ...old],
})
hideComment()
}
return (
<div className={styles.root}>
<div className='reply-wrapper'>
{/* 顶部导航栏 */}
<NavBar className='transparent-navbar' onBack={onHideReply}>
<div>{origin.reply_count}条回复</div>
</NavBar>
{/* 原评论信息 */}
<div className='origin-comment'>
<CommentItem comment={origin} type='origin' />
</div>
{/* 回复评论的列表 */}
<div className='reply-list'>
<div className='reply-header'>全部回复</div>
{reply.results?.length === 0 ? <NoComment /> : reply.results?.map((item) => <CommentItem key={item.com_id} comment={item} type='reply' />)}
<InfiniteScroll hasMore={hasMore} loadMore={loadMore} />
</div>
{/* 评论工具栏,设置 type="reply" 不显示评论和点赞按钮 */}
<CommentFooter showComment={showComment} type='reply' />
</div>
<Popup visible={show} position='right' destroyOnClose>
<CommentInput onAddComment={onAddComment} hideComment={hideComment} name={origin.aut_name} />
</Popup>
</div>
)
}

数量 + 1

src\types\store.d.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export type ArticleAction =
| {
type: 'article/setArticleInfo'
payload: ArticleDetail
}
| {
type: 'article/saveComment'
payload: CommentRes
}
| {
type: 'article/clearComment'
}
| {
type: 'article/saveNewComment'
payload: Comment
}
| {
type: 'article/addReplyCount'
payload: string
}

src\store\actions\article.ts

1
2
3
4
5
6
export function addReplyCount(commentId: string): ArticleAction {
return {
type: 'article/addReplyCount',
payload: commentId,
}
}

src\store\reducers\article.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
57
58
59
60
61
import { ArticleDetail, CommentRes } from '@/types/data'
import { ArticleAction } from '@/types/store'
type ArticleState = {
info: ArticleDetail
comment: CommentRes
}
const initState: ArticleState = {
info: {},
comment: {},
} as ArticleState

export default function article(state = initState, action: ArticleAction): ArticleState {
switch (action.type) {
case 'article/setArticleInfo':
return {
...state,
info: action.payload,
}
case 'article/saveComment':
const old = state.comment.results || []
return {
...state,
comment: {
...action.payload,
results: [...old, ...action.payload.results],
},
}
case 'article/clearComment':
return {
...state,
comment: {} as CommentRes,
}
case 'article/saveNewComment':
return {
...state,
comment: {
...state.comment,
results: [action.payload, ...state.comment.results],
},
}
case 'article/addReplyCount':
return {
...state,
comment: {
...state.comment,
results: state.comment.results.map((item) => {
if (item.com_id === action.payload) {
return {
...item,
reply_count: item.reply_count + 1,
}
} else {
return item
}
}),
},
}
default:
return state
}
}

src\pages\Article\components\CommentReply\index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const onAddComment = async (value: string) => {
const res = await request.post<ApiResponse<{ new_obj: Comment }>>('/comments', {
target: origin.com_id,
content: value,
art_id,
})
const old = reply.results || []
setReply({
...reply,
results: [res.data.data.new_obj, ...old],
})
// 让评论数量 +1
dispatch(addReplyCount(origin.com_id))
hideComment()
}

KeepAlive

  • 需求:缓存首页的文章列表数据,从详情页返回过来时也能看到上次位置的数据。

  • 注意:React 并没有提供 KeepAlive 的功能(对组件进行缓存,而不是销毁),需要自己手动实现,内部封装了路由的 Route 组件来实现。

  • 原理:

    a, 默认情况下,Route 组件只在路由规则匹配时渲染,不匹配时什么都不渲染(return null),此时对应的组件会被销毁;

    b, Route 组件提供了一个 children 属性,类似于 render 属性,值也是一个回调函数,与 render 不同的是该回调函数不管路由规则是否匹配都会执行;

    c, 因此,可以利用 Route 组件的 children 属性,在路由规则匹配时展示组件内容,在路由规则不匹配时隐藏组件内容,从而实现 KeepAlive 的功能;

1
2
3
4
5
<Route
children={() => {
console.log('任何情况下我都会执行~')
}}
/>

src/components/KeepAlive/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
import { Route, RouteProps } from 'react-router-dom'

// !#1
// 使用
// <KeepAlive path='/home'><Home/></KeepAlive>
export default function KeepAlive({ children, ...rest }: RouteProps) {
return (
<Route
children={(props) => {
// 如果当前路由被匹配,就展示组件,否则,隐藏组件
const isMatch = props.match !== null
return (
<div
style={{
display: isMatch ? 'block' : 'none',
height: '100%',
}}
>
{children}
</div>
)
}}
{...rest}
/>
)
}

src/App.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 { Router, Route, Switch, Redirect } from 'react-router-dom'
import './App.scss'
import Layout from '@/pages/Layout'
import Login from '@/pages/Login'
import ProfileEdit from '@/pages/Profile/Edit'
import PrivateRoute from './components/PrivateRoute'
import history from './utils/history'
import Chat from './pages/Profile/Chat'
import Article from './pages/Article'
import SearchPage from './pages/Search'
import SearchResult from './pages/Search/Result'
import KeepAlive from './components/KeepAlive'
export default function App() {
return (
<Router history={history}>
<div className='app'>
{/* // !#3 */}
<KeepAlive path='/home'>
<Layout />
</KeepAlive>
<Switch>
<Redirect exact from='/' to='/home' />
<Route path='/login' component={Login} />
{/* // !#2 */}
{/* <Route path='/home' component={Layout} /> */}
{/* 编辑用户信息 */}
{/* <Route path='/profile/edit' component={ProfileEdit} /> */}
<PrivateRoute path='/profile/edit'>
<ProfileEdit />
</PrivateRoute>
<PrivateRoute path='/chat'>
<Chat />
</PrivateRoute>
<Route path='/article/:id' component={Article} />
<Route exact path='/search' component={SearchPage} />
<Route path='/search/result' component={SearchResult} />
</Switch>
</div>
</Router>
)
}

src/pages/Layout/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 { TabBar } from 'antd-mobile'
import { Route, Switch } from 'react-router-dom'
import styles from './index.module.scss'
import Icon from '@/components/Icon'
import { useHistory, useLocation } from 'react-router-dom'
import Home from '../Home'
import Question from '../Question'
import Video from '../Video'
import Profile from '../Profile'
import PrivateRoute from '@/components/PrivateRoute'
import KeepAlive from '@/components/KeepAlive'

const tabs = [
{ path: '/home', icon: 'iconbtn_home', text: '首页' },
{ path: '/home/question', icon: 'iconbtn_qa', text: '问答' },
{ path: '/home/video', icon: 'iconbtn_video', text: '视频' },
{ path: '/home/profile', icon: 'iconbtn_mine', text: '我的' },
]

const Layout = () => {
const history = useHistory()
const location = useLocation()
const handleChange = (path: string) => {
history.push(path)
}
return (
<div className={styles.root}>
{/* 二级路由出口 */}
{/* // !#5 */}
<KeepAlive exact path='/home'>
<Home />
</KeepAlive>
<Switch>
{/* // !#4 */}
{/* <Route exact path='/home' component={Home} /> */}
<Route path='/home/question' component={Question} />
<Route path='/home/video' component={Video} />
{/* <Route path='/home/profile' component={Profile} /> */}
<PrivateRoute path='/home/profile'>
<Profile />
</PrivateRoute>
</Switch>
{/* TabBar */}
<TabBar className='tab-bar' onChange={handleChange} activeKey={location.pathname}>
{tabs.map((item) => (
<TabBar.Item
key={item.path}
icon={(active) => {
return <Icon type={active ? `${item.icon}_sel` : item.icon} fontSize={20} />
}}
title={item.text}
/>
))}
</TabBar>
</div>
)
}

export default Layout