文章搜索
文章搜索页的静态结构
目标:实现文章搜索页面的主要静态结构和样式
首页入口:
搜索页面:
操作步骤
- 为首页 Tab 栏右边的 ”放大镜“ 按钮添加点击事件,点击后跳转到搜索页:
import { useHistory } from 'react-router'
const history = useHistory()
<Icon type="iconbtn_search" onClick={() => history.push('/search')} />
- 在
pages/Search/index.js
中编写页面的静态结构:
import Icon from '@/components/Icon'
import NavBar from '@/components/NavBar'
import classnames from 'classnames'
import { useHistory } from 'react-router'
import styles from './index.module.scss'
const Search = () => {
const history = useHistory()
return (
<div className={styles.root}>
{/* 顶部导航栏 */}
<NavBar
className="navbar"
onLeftClick={() => history.go(-1)}
extra={
<span className="search-text">搜索</span>
}
>
<div className="navbar-search">
<Icon type="iconbtn_search" className="icon-search" />
<div className="input-wrapper">
{/* 输入框 */}
<input type="text" placeholder="请输入关键字搜索" />
{/* 清空输入框按钮 */}
<Icon type="iconbtn_tag_close" className="icon-close" />
</div>
</div>
</NavBar>
{/* 搜索历史 */}
<div className="history" style={{ display: 'block' }}>
<div className="history-header">
<span>搜索历史</span>
<span>
<Icon type="iconbtn_del" />清除全部
</span>
</div>
<div className="history-list">
<span className="history-item">
Python生成九宫格图片<span className="divider"></span>
</span>
<span className="history-item">
Python<span className="divider"></span>
</span>
<span className="history-item">
CSS<span className="divider"></span>
</span>
<span className="history-item">
数据分析<span className="divider"></span>
</span>
</div>
</div>
{/* 搜素建议结果列表 */}
<div className={classnames('search-result', 'show')}>
<div className="result-item">
<Icon className="icon-search" type="iconbtn_search" />
<div className="result-value">
<span>{'高亮'}</span>{`其余`}
</div>
</div>
</div>
</div>
)
}
export default Search
准备搜索的redux
- 在reducers中新建文件 search.ts
type SeartchType = {
suggestions: string[]
}
const initValue: SeartchType = {
// 存放推荐的结果
suggestions: [],
}
export type SearchAction = {
type: 'search/saveSuggestions'
payload: string[]
}
export default function reducer(state = initValue, action: SearchAction) {
if (action.type === 'search/saveSuggestions') {
return {
...state,
suggestions: action.payload,
}
}
return state
}
(2)在reducers中的index.ts中合并reducer
import login from './login'
import profile from './profile'
import home from './home'
import { combineReducers } from 'redux' // es6
import search from './search'
const reducer = combineReducers({
login,
profile,
home,
search,
})
export default reducer
(3)在store/index.ts中,合并action类型
type RootAction = HomeAction | LoginAction | ProfileAction | SearchAction
搜索关键字的输入与防抖处理
目标:从文本输入框获取输入的关键字内容,且运用防抖机制降低获取频繁
实现思路:
- 防抖实现步骤:
- 1)清理之前的定时器
- 2)新建定时器执行任务
操作步骤
- 声明一个用于存放关键字的状态
import { useState } from 'react'
// 搜索关键字内容
const [keyword, setKeyword] = useState('')
- 为输入框设置
value
属性和onChange
事件
<input
type="text"
placeholder="请输入关键字搜索"
value={keyword}
onChange={onKeywordChange}
/>
const onKeywordChange = e => {
const text = e.target.value.trim()
setKeyword(text)
console.log(text)
}
当前效果:每次键盘敲击都会打印出输入框中的内容
- 防抖处理
import { useRef } from 'react'
// 存储防抖定时器
const timerRef = useRef(-1)
const onKeywordChange = e => {
const text = e.target.value.trim()
setKeyword(text)
// 清除之前的定时器
clearTimeout(timerRef.current)
// 新建任务定时器
timerRef.current = setTimeout(() => {
console.log(text)
}, 500)
}
// 销毁组件时记得最好要清理定时器
useEffect(() => {
return () => {
clearTimeout(timerRef.current)
}
}, [])
发送请求获取搜索建议数据
目标:将输入的关键发送到服务端,获取和该关键字匹配的建议数据
实现思路:
- 通过 Redux Action 来发送请求,获取结果数据后保存在 Redux Store 中
操作步骤
- 创建
store/reducer/search.js
,编写 Reducer 函数
const initialState = {
suggestions: [],
}
export const search = (state = initialState, action) => {
const { type, payload } = action
switch (type) {
case 'search/suggestions':
return {
...state,
suggestions: payload
}
default:
return state
}
}
- 在
store/index.js
中配置新建的 reducer
// ...
import { search } from './search'
const rootReducer = combineReducers({
// ...
search
})
- 创建
store/actions/search.js
,编写 Action Creator:
import http from '@/utils/http'
/**
* 设置建议结果到 Redux 中
* @param {Array} list 建议结果
*/
const setSuggestions = list => {
return {
type: 'search/suggestions',
payload: list
}
}
/**
* 获取输入联想建议列表
* @param {string} q 查询内容
* @returns thunk
*/
export const getSuggestions = keyword => {
return async dispatch => {
// 请求建议结果
const res = await http.get('/suggestion', {
params: {
q: keyword
}
})
const { options } = res.data.data
// 转换结果:将每一项建议拆分成关键字匹配的高亮部分和非高亮部分
const list = options.map(item => {
const rest = item.substr(keyword.length)
return { keyword, rest }
})
// 保存到 Redux 中
dispatch(setSuggestions(list))
}
}
- 在之前的防抖定时器中调用 Action:
import { getSuggestions } from '@/store/actions/search'
const dispatch = useDispatch()
// 代表是否正处于搜索操作中
const [isSearching, setIsSearching] = useState(false)
const onKeywordChange = e => {
const text = e.target.value.trim()
setKeyword(text)
// 清除之前的定时器
clearTimeout(timerRef.current)
// 新建任务定时器
timerRef.current = setTimeout(() => {
// 仅当输入的关键字不为空时,执行搜索
if (text) {
setIsSearching(true)
dispatch(getSuggestions(text))
} else {
setIsSearching(false)
}
}, 500)
}
搜索建议结果列表的渲染
目标:从 Redux 中获取搜索建议数据,渲染到界面上
实现思路:
- 使用
useSelector
从 Redux 中获取数据
操作步骤
- 从 Redux 中获取搜索建议数据
import { getSuggestions } from '@/store/actions/search'
const suggestions = useSelector(state => state.search.suggestions)
- 将搜索建议数据渲染到界面上
<div className={classnames('search-result', 'show')}>
{suggestions.map((item, index) => {
return (
<div className="result-item" key={index}>
<Icon className="icon-search" type="iconbtn_search" />
<div className="result-value">
<span>{item.keyword}</span> {item.rest}
</div>
</div>
)
})}
</div>
效果:
搜索建议列表和搜索历史的按需显示
目标:实现在做搜索操作时只显示搜索建议列表;其他情况只显示搜索历史
实现思路:
- 利用之前创建的
isSearching
状态,控制建议列表和搜索历史的显示、隐藏
操作步骤
{/* 搜索历史 */}
<div className="history" style={{ display: isSearching ? 'none' : 'block' }}>
</div>
{/* 搜素建议结果列表 */}
<div className={classnames('search-result', isSearching ? 'show' : false)}>
</div>
清空输入框关键字的按钮
目标:点击输入框内的 x 按钮,清空输入的关键字内容
实现思路:
- 清空输入框绑定的状态
- 清空 Redux 中保存的搜索建议结果
操作步骤
- 在
store/reducers/search.js
中添加清空搜索建议数据的 Reducer 逻辑
export const search = (state = initialState, action) => {
const { type, payload } = action
switch (type) {
case 'search/clear':
return {
...state,
suggestions: []
}
// ...
}
}
- 在
store/actions/search.js
中编写 Action Creator:
/**
* 清空搜索建议
* @returns thunk
*/
export const clearSuggestions = () => {
return {
type: 'search/clear'
}
}
- 为输入框内的 x 按钮添加点击事件
{/* 清空输入框按钮,且在输入内容时才显示 */}
{keyword && (
<Icon type="iconbtn_tag_close" className="icon-close" onClick={onClear} />
)}
// 清空
const onClear = () => {
// 清空输入框内容
setKeyword('')
// 设置为非搜索状态
setIsSearching(false)
// 清空Redux中的搜索建议数据
dispatch(clearSuggestions())
}
动态渲染搜索历史记录
目标:将每次输入的搜索关键字记录下来,再动态渲染到界面上
实现思路:
- 在成功搜索后,将关键字存入 Redux 和 LocalStorage 中
- 从 Redux 中获取所有关键字,并渲染到界面
操作步骤
- 在
store/reducers/search.js
中,添加操作搜索历史相关的 Reducer 逻辑:
const initialState = {
// ...
histories: []
}
export const search = (state = initialState, action) => {
const { type, payload } = action
switch (type) {
case 'search/add_history':
return {
...state,
histories: [payload, ...state.histories]
}
// ...
}
}
- 在
utils/storage.js
中,添加在本地缓存中操作搜索历史相关的工具函数:
// 搜索关键字的本地缓存键名
const SEARCH_HIS_KEY = 'itcast_history_k'
/**
* 从缓存获取搜索历史关键字
*/
export const getLocalHistories = () => {
return JSON.parse(localStorage.getItem(SEARCH_HIS_KEY)) || []
}
/**
* 将搜索历史关键字存入本地缓存
* @param {Array} histories
*/
export const setLocalHistories = histories => {
localStorage.setItem(SEARCH_HIS_KEY, JSON.stringify(histories))
}
/**
* 删除本地缓存中的搜索历史关键字
*/
export const removeLocalHistories = () => {
localStorage.removeItem(SEARCH_HIS_KEY)
}
- 在
store/actions/search.js
中,修改原先请求搜索建议的 Action Creator:
export const getSuggestions = keyword => {
return async (dispatch, getState) => {
// ...
// 搜索成功后,保存为历史关键字
// 1)保存搜索关键字到 Redux 中
await dispatch(addSearchHistory(keyword))
// 2)保存搜索关键字到 LocalStorage 中
const { histories } = getState().search
setLocalHistories(histories)
}
}
- 在
store/index.js
中,添加从本地缓存初始化搜索历史的逻辑:
import { getLocalHistories, getTokenInfo } from '@/utils/storage'
const store = createStore(
// ...
// 参数二:初始化时要加载的状态
{
// ...
search: {
histories: getLocalHistories(),
suggestions: []
}
},
// ...
)
- 在搜索页面中,从 Redux 获取搜索历史数据,再渲染到界面
const histories = useSelector(state => state.search.histories)
<div className="history-list">
{histories.map((item, index) => {
return (
<span className="history-item" key={index}>
{item}<span className="divider"></span>
</span>
)
})}
</div>
效果:
搜索历史记录的去重
目标:当前搜索历史中会存储重复的关键字,我们要进行去重处理
实现思路:
- 使用 Set 进行自动去重
操作步骤
- 在
store/reducers/search.js
中,修改 Reducer 逻辑:
export const search = (state = initialState, action) => {
const { type, payload } = action
switch (type) {
case 'search/add_history':
// 将历史数组放入 Set 中,就能自动去除重复的关键字
// 注意:Set 只对基础类型的值有自动去重功能,对象无效
const set = new Set([payload, ...state.histories])
// 去重后将 Set 转回数组
const newArr = Array.from(set)
return {
...state,
histories: newArr
}
// ...
}
}
搜索历史记录的数量限制
目标:将搜索历史中的关键字最大数量限制在 10 个,防止列表太长
实现思路:
- 在完成去重后,判断是否已满 10 个,如果已满则删除最后一个关键字
操作步骤
- 在
store/reducers/search.js
中,修改 Reducer 逻辑:
export const search = (state = initialState, action) => {
const { type, payload } = action
switch (type) {
case 'search/add_history':
// 将历史数组放入 Set 中,就能自动去除重复的关键字
// 注意:Set 只对基础类型的值有自动去重功能,对象无效
const set = new Set([payload, ...state.histories])
// 去重后将 Set 转回数组
const newArr = Array.from(set)
// 判断是否已满10个,如已满则删除末尾的一个
if (newArr.length > 10) {
newArr.pop()
}
return {
...state,
histories: newArr
}
// ...
}
}
清空搜索历史记录
目标:点击”清除全部“按钮后,删除全部的搜索历史记录
实现思路:
- 删除 Redux 和 LocalStorage 中存储的历史记录
操作步骤
- 在
store/reducers/seach.js
中,添加删除搜索历史相关的 Reducer 逻辑
export const search = (state = initialState, action) => {
const { type, payload } = action
switch (type) {
case 'search/clear_histories':
return {
...state,
histories: []
}
// ...
}
}
- 在
store/actions/search.js
中,编写 Action Creator:
/**
* 删除 Redux 中的历史记录
*/
export const doClearHistories = () => {
return {
type: 'search/clear_histories'
}
}
/**
* 清空搜索历史
* @returns
*/
export const clearHistories = () => {
return dispatch => {
// 删除 Redux 中的历史记录
dispatch(doClearHistories())
// 删除 LocalStorage 中的历史记录
removeLocalHistories()
}
}
- 在搜索页面中,为 “清除全部” 按钮添加点击事件
<span onClick={onClearHistories}>
<Icon type="iconbtn_del" />清除全部
</span>
// 清空搜索历史
const onClearHistories = () => {
dispatch(clearHistories())
}
点击”搜索“或建议结果跳到搜索详情页
目标:点击顶部 ”搜索“ 按钮,或点击搜索建议列表中的一项,跳转到搜索详情页
操作步骤
- 为元素添加点击事件
点击搜索按钮跳转时,携带当前输入的关键字作为参数
<span
className="search-text"
onClick={() => gotoSearchDetail(keyword)}
>搜索</span>
点击搜索建议列表项跳转时,携带当前列表项上显示的内容作为参数
<div
className="result-item"
key={index}
onClick={() => gotoSearchDetail(item.keyword + item.rest)}
>
...
</div>
// 跳转到搜索详情页
const gotoSearchDetail = text => {
if (text) {
history.push(`/search/result?q=${text}`)
}
}
搜索详情页的静态结构
目标:实现搜索详情页的静态结构和样式
操作步骤
- 将资源包中对应的样式文件,拷贝到
pages/Search/Result/
目录下,然后编写该目录下的index.js
:
import ArticleItem from '@/components/ArticleItem'
import NavBar from '@/components/NavBar'
import { useDispatch } from 'react-redux'
import { useHistory, useLocation } from 'react-router-dom'
import styles from './index.module.scss'
const SearchResult = () => {
const history = useHistory()
const location = useLocation()
const dispatch = useDispatch()
return (
<div className={styles.root}>
{/* 顶部导航栏 */}
<NavBar onLeftClick={() => history.go(-1)}>搜索结果</NavBar>
<div className="article-list">
<div>
<ArticleItem
articleId={"111"}
coverType={0}
coverImages={[]}
title={"测试1"}
authorName={"张三"}
commentCount={10}
publishDate={"2010-10-10 19:08:10"}
onClose={() => { }}
/>
</div>
<div>
<ArticleItem
articleId={"222"}
coverType={0}
coverImages={[]}
title={"测试2"}
authorName={"李四"}
commentCount={66}
publishDate={"2010-10-10 9:08:10"}
onClose={() => { }}
/>
</div>
</div>
</div>
)
}
export default SearchResult
请求搜索详情页数据
目标:获取从搜索页面传入的参数后,调用后端接口获取搜索详情
操作步骤
- 获取通过 URL 地址传入到搜索详情页的查询字符串参数
q
// 获取通过 URL 地址传入的查询字符串参数
const params = new URLSearchParams(location.search)
const q = params.get('q')
- 在
store/reducers/search.js
中添加保存搜索详情数据的 Reducer 逻辑
const initialState = {
// ...
searchResults: []
}
export const search = (state = initialState, action) => {
const { type, payload } = action
switch (type) {
case 'search/results':
return {
...state,
searchResults: payload
}
// ...
}
}
- 在
store/actions/search.js
中编写 Action Creator:
/**
* 设置搜索详情到 Redux 中
* @param {Array} results
* @returns
*/
export const setSearchResults = results => {
return {
type: 'search/results',
payload: results
}
}
/**
* 获取搜索文章列表
* @param {string} q 查询内容
* @returns thunk
*/
export const getSearchResults = q => {
return async dispatch => {
const res = await http.get('/search', {
params: { q }
})
// 保存结果数据到 Redux 中
dispatch(setSearchResults(res.data.data))
}
}
- 在搜索详情页面中,通过
useEffect
在进入页面时调用以上编写的 Action:
import { getSearchResults } from '@/store/actions/search'
import { useEffect } from 'react'
useEffect(() => {
dispatch(getSearchResults(q))
}, [q, dispatch])
渲染搜索详情列表
目标:将请求到的搜索详情数据渲染到界面上
操作步骤
- 从 Redux 中获取搜索详情数据
import { useDispatch, useSelector } from 'react-redux'
const articles = useSelector(state => state.search.searchResults)
- 将数据渲染成列表
<div className="article-list">
{articles?.results?.map(article => {
return (
<div key={article.art_id}>
<ArticleItem
articleId={article.art_id}
coverType={article.cover.type}
coverImages={article.cover.images}
title={article.title}
authorName={article.aut_name}
commentCount={article.comm_count}
publishDate={article.pubdate}
onClose={() => { }}
/>
</div>
)
})}
</div>
点击搜索详情列表跳到文章详情页
目标:实现在搜索详情列表中点击一个列表项,跳转到文章的详情页面
操作步骤
- 为搜索详情列表项添加点击事件
<div key={article.art_id} onClick={() => gotoAritcleDetail(article.art_id)}>
// ...
</div>
// 跳转到文章详情页面
const gotoAritcleDetail = articleId => {
history.push(`/article/${articleId}`)
}
文章详情页
文章详情页的基本静态结构
目标:实现详情页基本的文章内容展示相关的静态结构和样式
操作步骤
- 将资源包的相关样式文件拷贝到
pages/Article/
目录,然后编写该目录下的index.js
:
import Icon from "@/components/Icon"
import NavBar from "@/components/NavBar"
import ContentLoader from "react-content-loader"
import { useHistory } from "react-router"
import styles from './index.module.scss'
const Article = () => {
const history = useHistory()
return (
<div className={styles.root}>
<div className="root-wrapper">
{/* 顶部导航栏 */}
<NavBar
onLeftClick={() => history.go(-1)}
rightContent={
<span>
<Icon type="icongengduo" />
</span>
}
>
{/* <div className="nav-author">
<img src={''} alt="" />
<span className="name">{'张三'}</span>
<span className="follow">关注</span>
</div> */}
</NavBar>
{false ? (
// 数据正在加载时显示的骨架屏界面
<ContentLoader
speed={2}
width={375}
height={230}
viewBox="0 0 375 230"
backgroundColor="#f3f3f3"
foregroundColor="#ecebeb"
>
{/* https://skeletonreact.com/ */}
<rect x="16" y="8" rx="3" ry="3" width="340" height="10" />
<rect x="16" y="26" rx="0" ry="0" width="70" height="6" />
<rect x="96" y="26" rx="0" ry="0" width="50" height="6" />
<rect x="156" y="26" rx="0" ry="0" width="50" height="6" />
<circle cx="33" cy="69" r="17" />
<rect x="60" y="65" rx="0" ry="0" width="45" height="6" />
<rect x="304" y="65" rx="0" ry="0" width="52" height="6" />
<rect x="16" y="114" rx="0" ry="0" width="340" height="15" />
<rect x="263" y="208" rx="0" ry="0" width="94" height="19" />
<rect x="16" y="141" rx="0" ry="0" width="340" height="15" />
<rect x="16" y="166" rx="0" ry="0" width="340" height="15" />
</ContentLoader>
) : (
// 数据加载完成后显示的实际界面
<>
<div className="wrapper">
<div className="article-wrapper">
{/* 文章描述信息栏 */}
<div className="header">
<h1 className="title">{"测试文字1234"}</h1>
<div className="info">
<span>{'2020-10-10'}</span>
<span>{10} 阅读</span>
<span>{10} 评论</span>
</div>
<div className="author">
<img src={''} alt="" />
<span className="name">{'张三'}</span>
<span className="follow">关注</span>
</div>
</div>
{/* 文章正文内容区域 */}
<div className="content">
<div className="content-html dg-html">测试内容123</div>
<div className="date">发布文章时间:{'2020-10-10'}</div>
</div>
</div>
</div>
</>
)}
</div>
</div>
)
}
export default Article
请求文章详情数据
目标:进入文章详情页时,调用后端接口查询当前文章的详细数据
实现思路:
- 进入页面时,获取通过URL传入的动态路由参数:文章ID
- 通过 Action 和 Reducer 实现对后端接口的调用,以及将返回结果保存到 Redux 中
- 通过
useEffect
实现进入页面时调用 Action,发起请求
操作步骤
- 创建
store/reducers/article.js
,编写操作文章详情相关状态的 Reducer 逻辑:
// 初始状态
const initialState = {
// 文章加载中的标识
isLoading: true,
// 文章详情数据
info: {},
}
export const article = (state = initialState, action) => {
const { type, payload } = action
switch (type) {
// 文章正在加载中:将加载标识设置为 true
case 'article/pengding':
return {
...state,
isLoading: true
}
// 文章加载完成:将加载标识设置为 false,并设置详情数据
case 'article/success':
return {
...state,
isLoading: false,
info: payload
}
default:
return state
}
}
- 在
store/reducers/index.js
中配置刚刚新建的 Reducer 模块:
// ...
import { article } from './article'
const rootReducer = combineReducers({
// ...
article
})
- 创建
store/actions/article.js
,编写请求文章详情相关的 Action Creator:
import http from "@/utils/http"
/**
* 设置为正在加载
*/
export const setLoadingPending = () => {
return {
type: 'article/pengding'
}
}
/**
* 设置为非加载状态,并将详情数据保存到 Redux
* @param {Object} info 文章详情数据
*/
export const setLoadingSuccess = info => {
return {
type: 'article/success',
payload: info
}
}
/**
* 请求指定 id 的文章详情
* @param {Number} id 文章id
* @returns thunk
*/
export const getArticleInfo = id => {
return async dispatch => {
// 准备发送请求
dispatch(setLoadingPending())
// 发送请求
const res = await http.get(`/articles/${id}`)
const info = res.data.data
// 请求成功,保存数据
dispatch(setLoadingSuccess(info))
}
}
- 在文章详情页面中,获取动态路由参数,并调用 Action
import { getArticleInfo } from "@/store/actions/article"
import { useDispatch } from "react-redux"
import { useHistory, useParams } from "react-router"
// 获取动态路由参数
const params = useParams()
const articleId = params.id
// 进入页面时,请求文章详情数据
useEffect(() => {
dispatch(getArticleInfo(articleId))
}, [dispatch, articleId])
渲染文章详情
目标:将请求到的文章详情数据,渲染到界面上
操作步骤
- 从 Redux 中获取文章加载状态和详情数据
import { useDispatch, useSelector } from "react-redux"
const { isLoading, info } = useSelector(state => state.article)
- 使用
isLoading
控制骨架屏和正文区域的条件渲染:
{isLoading ? (
// 数据正在加载时显示的骨架屏界面
// ...
) : (
// 数据加载完成后显示的实际界面
// ...
)
- 填充文章数据:
import dayjs from 'dayjs'
<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>
<div className="date">
发布文章时间:{dayjs(info.pubdate).format('YYYY-MM-DD')}
</div>
</div>
</div>
文章内容防 XSS 攻击
目标:清理正文中的不安全元素,防止 XSS 安全漏洞
实现思路:
- 使用
dompurify
对 HTML 内容进行净化处理
操作步骤
- 安装包
npm i dompurify --save
- 在页面中调用
dompurify
来对文章正文内容做净化:
import DOMPurify from 'dompurify'
<div
className="content-html dg-html"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(info.content || '') }}
></div>
文章内容中的代码高亮
目标:实现嵌入文章中的代码带有语法高亮效果
实现思路:
- 通过
highlight.js
库实现对文章正文 HTML 中的代码元素自动添加语法高亮
操作步骤
- 安装包
npm i highlight.js --save
- 在页面中引入
highlight.js
import hljs from 'highlight.js'
import 'highlight.js/styles/vs2015.css'
- 在文章加载后,对文章内容中的代码进行语法高亮
useEffect(() => {
if (isLoading) return
// 配置 highlight.js
hljs.configure({
// 忽略未经转义的 HTML 字符
ignoreUnescapedHTML: true
})
// 获取到渲染正文的容器元素
const dgHtml = document.querySelector('.dg-html')
// 查找容器元素下符合 pre code 选择器规则的子元素,进行高亮
const codes = dgHtml.querySelectorAll('pre code')
if (codes.length > 0) {
return codes.forEach(el => hljs.highlightElement(el))
}
// 查找容器元素下的 pre 元素,进行高亮
const pre = dgHtml.querySelectorAll('pre')
if (pre.length > 0) {
return pre.forEach(el => hljs.highlightElement(el))
}
}, [isLoading])
页面滚动后在导航栏显示文章作者
目标:实现当页面滚动至描述信息部分消失,顶部导航栏上显示作者信息
实现思路:
- 为顶部导航栏组件 NavBar 设置中间部分的内容
- 监听页面的
scroll
事件,在页面滚动时判断描述信息区域的top
是否小于等于 0;如果是,则将 NavBar 中间内容设置为显示;否则设置为隐藏
操作步骤
- 为顶部导航栏添加作者信息
<NavBar
// ...
>
<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>
- 声明状态和对界面元素的引用
const [isShowNavAuthor, setShowNavAuthor] = useState(false)
const wrapperRef = useRef()
const authorRef = useRef()
<div className="wrapper" ref={wrapperRef}>
<div className="author" ref={authorRef}>
- 设置滚动事件监听,判断是否显示导航栏中的作者信息
// 监听滚动,控制 NavBar 中作者信息的显示或隐藏
useEffect(() => {
if (isLoading) return
const wrapperEl = wrapperRef.current
const authorEl = authorRef.current
// 滚动监听函数
const onScroll = throttle(200, () => {
// 获取 .author 元素的位置信息
const rect = authorEl.getBoundingClientRect()
// 如果 .author 元素的顶部移出屏幕外,则显示顶部导航栏上的作者信息
if (rect.top <= 0) {
setShowNavAuthor(true)
}
// 否则隐藏导航栏上的作者信息
else {
isShowNavAuthor && setShowNavAuthor(false)
}
})
// 注册 .wrapper 元素的 scroll 事件
wrapperEl.addEventListener('scroll', onScroll)
return () => {
// 注销 .wrapper 元素的 scroll 事件
wrapperEl.removeEventListener('scroll', onScroll)
}
}, [isLoading, isShowNavAuthor])
文章评论:封装没有评论时的界面组件
目标:实现一个组件,展示没有任何评论时的提示信息
操作步骤
- 创建
components/NoComment/
目录,并将资源包中对应的样式文件拷贝进来,然后再编写index.js
代码:
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/CommentItem/
目录,拷贝资源包中的样式文件到该目录,然后编写index.js
代码:
import Icon from '@/components/Icon'
import classnames from 'classnames'
import dayjs from 'dayjs'
import styles from './index.module.scss'
/**
* 评论项组件
* @param {String} props.commentId 评论ID
* @param {String} props.authorPhoto 评论者头像
* @param {String} props.authorName 评论者名字
* @param {Number} props.likeCount 喜欢数量
* @param {Boolean} props.isFollowed 是否已关注该作者
* @param {Boolean} props.isLiking 是否已点赞该评论
* @param {String} props.content 评论内容
* @param {Number} props.replyCount 回复数
* @param {String} props.publishDate 发布日期
* @param {Function} props.onThumbsUp 点赞后的回调函数
* @param {Function} props.onOpenReply 点击“回复”按钮后的回调函数
* @param {String} props.type normal 普通 | origin 回复评论的原始评论 | reply 回复评论
*/
const CommentItem = ({
commentId,
authorPhoto,
authorName,
likeCount,
isFollowed,
isLiking,
content,
replyCount,
publishDate,
onThumbsUp,
onOpenReply = () => { },
type = 'normal'
}) => {
return (
<div className={styles.root}>
{/* 评论者头像 */}
<div className="avatar">
<img src={authorPhoto} alt="" />
</div>
<div className="comment-info">
{/* 评论者名字 */}
<div className="comment-info-header">
<span className="name">{authorName}</span>
{/* 关注或点赞按钮 */}
{type === 'normal' ? (
<span className="thumbs-up" onClick={onThumbsUp}>
{likeCount} <Icon type={isLiking ? 'iconbtn_like_sel' : 'iconbtn_like2'} />
</span>
) : (
<span className={classnames('follow', isFollowed ? 'followed' : '')}>
{isFollowed ? '已关注' : '关注'}
</span>
)}
</div>
{/* 评论内容 */}
<div className="comment-content">{content}</div>
<div className="comment-footer">
{/* 回复按钮 */}
{type === 'normal' && (
<span className="replay" onClick={() => onOpenReply(commentId)}>
{replyCount === 0 ? '' : replyCount}回复 <Icon type="iconbtn_right" />
</span>
)}
{/* 评论日期 */}
<span className="comment-time">{dayjs().from(publishDate)}</span>
</div>
</div>
</div>
)
}
export default CommentItem
文章评论:请求评论列表数据
目标:调用后端接口,获取当前文章的评论数据
操作步骤
- 在
store/reducers/article.js
中,添加操作评论相关状态的 Reducer 逻辑:
// 初始状态
const initialState = {
// ...
// 评论加载中的标识
isLoadingComment: true,
// 评论数据
comment: {
// 评论列表数组
results: []
},
}
export const article = (state = initialState, action) => {
const { type, payload } = action
switch (type) {
// 评论正在加载中:将加载标识设置为 true
case 'article/comment_loading':
return {
...state,
isLoadingComment: true
}
// 评论加载完成:将加载标识设置为 false,并设置详情数据
case 'article/comment_success':
return {
...state,
isLoadingComment: false,
comment: {
...payload,
results: payload.results
}
}
// ...
}
}
- 在
store/actions/article.js
中,编写 Action Creator:
/**
* 设置为正在加载评论
*/
export const setCommentPending = () => {
return {
type: 'article/comment_loading'
}
}
/**
* 设置为非加载评论的状态,并将评论数据保存到 Redux
* @param {Array} comments 评论
*/
export const setCommentSuccess = comments => {
return {
type: 'article/comment_success',
payload: comments
}
}
/**
* 获取文章的评论列表
* @param {String} obj.type 评论类型
* @param {String} obj.source 评论ID
* @returns thunk
*/
export const getArticleComments = ({ type, source }) => {
return async dispatch => {
// 准备发送请求
dispatch(setCommentPending())
// 发送请求
const res = await http.get('/comments', {
params: { type, source }
})
// 请求成功,保存数据
dispatch(setCommentSuccess(res.data.data))
}
}
- 在进入文章详情页面时调用 Action
import { getArticleComments, getArticleInfo } from "@/store/actions/article"
// 进入页面时
useEffect(() => {
// 请求文章详情数据
dispatch(getArticleInfo(articleId))
// 请求评论列表数据
dispatch(getArticleComments({
type: 'a',
source: articleId
}))
}, [dispatch, articleId])
文章评论:渲染评论列表
目标:将请求到的评论数据渲染到界面上
操作步骤
- 从 Redux 中获取评论数据
const { isLoading, info, isLoadingComment, comment } = useSelector(state => state.article)
const comments = comment.results
- 在之前渲染文章正文的元素下,渲染文章评论相关的元素:
<div className="article-wrapper">
// 这里是文章正文内容区域 ...
</div>
{/* 文章评论区 */}
<div className="comment">
{/* 评论总览信息 */}
<div className="comment-header">
<span>全部评论({info.comm_count})</span>
<span>{info.like_count} 点赞</span>
</div>
{info.comm_count === 0 ? (
// 没有评论时显示的界面
<NoComment />
) : (
// 有评论时显示的评论列表
<div className="comment-list">
{comments?.map(item => {
return (
<CommentItem
key={item.com_id}
commentId={item.com_id}
authorPhoto={item.aut_photo}
authorName={item.aut_name}
likeCount={item.like_count}
isFollowed={item.is_followed}
isLiking={item.is_liking}
content={item.content}
replyCount={item.reply_count}
publishDate={item.pubdate}
onThumbsUp={() => { }}
onOpenReply={() => { }}
/>
)
})}
{/* 评论正在加载时显示的信息 */}
{isLoadingComment && <div className="list-loading">加载中...</div>}
<div className="no-more">没有更多了</div>
<div className="placeholder"></div>
</div>
)}
</div>
文章评论:用自定义 Hook 实现上拉加载
目标:编写一个自定义 Hook 函数,实现对滚动容器滚动到最底部(触底)的监听,以此提供 “上拉加载” 的前置功能。
实现思路:
- 本 Hook 函数用于监听如上图结构的元素:一个滚动容器、一个在滚动容器底部的占位元素
- 监听容器滚动时,
占位元素的 bottom
<=容器元素的 scrollBottom
时,就达成了触底 - 达成触底后,调用作为参数传入该 Hook 的回调函数
操作步骤
- 创建
hooks/use-reach-bottom.js
,在该文件中编写自定义 Hook:
import throttle from 'lodash/fp/throttle'
import { useEffect, useMemo, useState } from 'react'
/**
* 自定义 Hook:
* 能监听带有滚动条的容器是否已滚到底部,
* 并在触底后执行一个回调函数。
*
* 通过该自定义 Hook,可以实现上拉加载功能
*/
export const useReachBottom = (
// 页面触底时执行的回调函数
onReachBottom = () => { },
{
// 滚动容器
container,
// 放在滚动容器最底部的占位元素
placeholder,
// 用来判断是否已到不能再作上拉加载
isFinished = () => false,
// 触底距离
offset = 300,
// 节流阀开关:值为 true 时停止监听滚动事件;为 false 则开始监听滚动事件
stop = false
}
) => {
// 代表是否正在执行加载的状态
const [loading, setLoading] = useState(false)
// 代表是否已完成全部加载的状态
const [finished, setFinished] = useState(false)
// 滚动事件监听函数
// - 使用了 lodash 的限流函数 throttle 来控制 200ms 执行一次滚动逻辑
// - 使用 useMemo 将该事件监听函数进行缓存,提升性能
const onScroll = useMemo(() => throttle(200, async () => {
// 如果已经完成全部加载,则忽略后续的执行
if (finished) return
// 获取滚动容器和底部占位元素的 bottom 位置
const { bottom: containerScrollBottom } = container.getBoundingClientRect()
const { bottom: placehoderBottom } = placeholder.getBoundingClientRect()
// 判断还未达到触底位置
if (placehoderBottom - containerScrollBottom <= offset) {
// 执行 onReachBottom 回调函数
setLoading(true)
await onReachBottom()
setLoading(false)
// 记录当前是否已完成全部加载
setFinished(isFinished())
}
}), [
onReachBottom,
placeholder,
container,
offset,
finished,
isFinished
])
// 组件挂载时,为滚动容器和底部占位元素添加 scroll 事件监听
useEffect(() => {
if (!container || !placeholder || stop) return
container.addEventListener('scroll', onScroll)
return () => {
// 组件销毁时,注销 scroll 事件监听
container.removeEventListener('scroll', onScroll)
}
}, [
container,
placeholder,
onScroll,
stop
])
// 返回本自定义 Hook 对外暴露的内容
return {
loading,
finished
}
}
文章评论:调用自定义 Hook 函数
目标:在文章详情页中调用我们封装的 Hook 函数,实现对评论列表区域的触底监听
操作步骤
- 为评论列表中的占位元素添加 ref
const placeholderRef = useRef()
<div className="placeholder" ref={placeholderRef}></div>
- 调用自定义Hook
import { useReachBottom } from "@/hooks/use-reach-bottom"
// 调用自定义 Hook 实现评论列表的触底监听
const { finished } = useReachBottom(
() => {
console.log('>>>>>>触底啦!')
},
{
container: wrapperRef.current,
placeholder: placeholderRef.current,
stop: isLoading || comments.length === 0 || isLoadingComment,
isFinished: () => comment.end_id === comment.last_id
}
)
成功调用后,滚动到页面底部会触发 console.log 打印信息。
- 使用自定义 Hook 返回的
finished
状态,控制评论列表界面元素的显示或隐藏:
{finished && <div className="no-more">没有更多了</div>}
文章评论:上拉发送请求加载更多评论
目标:在自定义 Hook 引发触底时,调用后端接口获取下一页的评论数据
操作步骤
- 在
store/reducers/article.js
中添加 Reducer 逻辑:
export const article = (state = initialState, action) => {
const { type, payload } = action
switch (type) {
// 加载更多评论后,与之前的评论进行数据合并
case 'article/comment_more':
return {
...state,
isLoadingComment: false,
comment: {
...payload,
results: [
...state.comment.results,
...payload.results
]
}
}
// ...
}
}
- 在
store/actions/article.js
中编写 Action Creator:
/**
* 将加载的更多评论保存到 Redux
* @param {Array} comments 评论
*/
export const setCommentMore = comments => {
return {
type: 'article/comment_more',
payload: comments
}
}
/**
* 获取更多评论
* @param {String} obj.type 评论类型
* @param {String} obj.source 评论ID
* @param {String} obj.offset 分页偏移量
* @returns thunk
*/
export const getMoreArticleComments = ({ type, source, offset }) => {
return async dispatch => {
dispatch(setCommentPending())
const res = await http.get('/comments', {
params: {
type,
source,
offset
}
})
dispatch(setCommentMore(res.data.data))
}
}
- 在自定义 Hook
onReachBottom
的回调函数中,调用以上 Action:
import { getArticleComments, getArticleInfo, getMoreArticleComments } from "@/store/actions/article"
const { finished } = useReachBottom(
() => {
dispatch(getMoreArticleComments({
type: 'a',
source: articleId,
offset: comment.last_id
}))
},
// ...
)
文章评论:封装并显示评论工具栏组件
目标:将详情页底部的评论工具栏封装成一个组件,并在文章详情页中调用
操作步骤
- 创建
pages/Article/components/CommentFooter/
目录,并拷贝资源包中相应的样式文件到该目录中,然后编写index.js
:
import Icon from '@/components/Icon'
import styles from './index.module.scss'
/**
* 评论工具栏组件
* @param {Number} props.commentCount 评论数
* @param {Number} props.attitude 点赞状态:1-已点赞 | 其他-未点赞
* @param {Number} props.isCollected 评论数
* @param {String} props.placeholder 输入框中的占位提示信息
* @param {Function} props.onComment 点击输入框的回调函数
* @param {Function} props.onShowComment 点击”评论”按钮的回调函数
* @param {Function} props.onLike 点击“点赞”按钮的回调函数
* @param {Function} props.onCollected 点击”收藏”按钮的回调函数
* @param {Function} props.onShare 点击”分享”按钮的回调函数
* @param {String} props.type 评论类型:normal 普通评论 | reply 回复评论
*/
const CommentFooter = ({
commentCount,
attitude,
isCollected,
placeholder,
onComment,
onShowComment,
onLike,
onCollected,
onShare,
type = 'normal'
}) => {
return (
<div className={styles.root}>
{/* 输入框(是个假的输入框,其实就是个按钮) */}
<div className="input-btn" onClick={onComment}>
<Icon type="iconbianji" />
<span>{placeholder}</span>
</div>
{type === 'normal' && (
<>
{/* 评论按钮 */}
<div className="action-item" onClick={onShowComment}>
<Icon type="iconbtn_comment" />
<p>评论</p>
{commentCount !== 0 && <span className="bage">{commentCount}</span>}
</div>
{/* 点赞按钮 */}
<div className="action-item" onClick={onLike}>
<Icon type={attitude === 1 ? 'iconbtn_like_sel' : 'iconbtn_like2'} />
<p>点赞</p>
</div>
</>
)}
{/* 收藏按钮 */}
<div className="action-item" onClick={onCollected}>
<Icon type={isCollected ? 'iconbtn_collect_sel' : 'iconbtn_collect'} />
<p>收藏</p>
</div>
{/* 分享按钮 */}
<div className="action-item" onClick={onShare}>
<Icon type="iconbtn_share" />
<p>分享</p>
</div>
</div>
)
}
export default CommentFooter
- 在文章详情页中调用
CommentFooter
组件:
import CommentFooter from "./components/CommentFooter"
<>
<div className="wrapper" ref={wrapperRef}>
// ...
</div>
{/* 评论工具栏 */}
<CommentFooter
commentCount={info.comm_count}
attitude={info.attitude}
isCollected={info.is_collected}
placeholder={info.comm_count === 0 ? '抢沙发' : '去评论'}
onComment={() => { }}
onShowComment={() => { }}
onLike={() => { }}
onCollected={() => { }}
onShare={() => { }}
/>
</>
文章评论:封装评论表单组件
目标:将发表评论的表单界面封装成一个组件
风格一:直接评论一篇文章时
风格二:回复某人的评论时
操作步骤
- 创建
pages/Article/components/CommentInput/
目录,拷贝资源包相关样式文件到该目录,然后编写index.js
:
import NavBar from '@/components/NavBar'
import { http } from '@/utils'
import { useEffect, useRef, useState } from 'react'
import styles from './index.module.scss'
/**
* @param {String} props.id 评论ID
* @param {String} props.name 评论人姓名
* @param {Function} props.onClose 关闭评论表单时的回调函数
* @param {Function} props.onComment 发表评论成功时的回调函数
* @param {String} props.articleId 文章ID
*/
const CommentInput = ({ id, name, onClose, onComment, articleId }) => {
// 输入框内容
const [value, setValue] = useState('')
// 输入框引用
const txtRef = useRef(null)
useEffect(() => {
// 输入框自动聚焦
setTimeout(() => {
txtRef.current.focus()
}, 600)
}, [])
// 发表评论
const onSendComment = async () => {
if (!value) return
// 调用接口,保存评论
const res = await http.post('/comments', {
target: id,
content: value,
art_id: articleId // 回复一个评论时需要此参数
})
const { new_obj } = res.data.data
onComment(new_obj)
onClose()
}
return (
<div className={styles.root}>
{/* 顶部导航栏 */}
<NavBar
onLeftClick={onClose}
rightContent={
<span className="publish" onClick={onSendComment}>发表</span>
}
>
{name ? '回复评论' : '评论文章'}
</NavBar>
<div className="input-area">
{/* 回复别人的评论时显示:@某某 */}
{name && <div className="at">@{name}:</div>}
{/* 评论内容输入框 */}
<textarea
ref={txtRef}
placeholder="说点什么~"
rows="10"
value={value}
onChange={e => setValue(e.target.value.trim())}
/>
</div>
</div>
)
}
export default CommentInput
文章评论:显示评论表单抽屉
目标:点击评论工具栏的“输入框”,弹出一个抽屉式评论表单
操作步骤
- 声明用于控制抽屉显示隐藏的状态
// 评论抽屉状态
const [commentDrawerStatus, setCommentDrawerStatus] = useState({
visible: false,
id: 0
})
- 在页面中创建评论表单抽屉
import { Drawer } from "antd-mobile"
<div className={styles.root}>
<div className="root-wrapper">
// ...
</div>
{/* 评论抽屉 */}
<Drawer
className="drawer"
position="bottom"
style={{ minHeight: document.documentElement.clientHeight }}
children={''}
sidebar={
<div className="drawer-sidebar-wrapper">
{commentDrawerStatus.visible && (
<CommentInput
id={commentDrawerStatus.id}
onClose={onCloseComment}
onComment={onAddComment}
/>
)}
</div>
}
open={commentDrawerStatus.visible}
onOpenChange={onCloseComment}
/>
</div>
- 编写表单抽屉上的一系列回调函数
// 关闭评论抽屉表单
const onCloseComment = () => {
setCommentDrawerStatus({
visible: false,
id: 0
})
}
// 发表评论后,插入到数据中
const onAddComment = comment => {
}
- 为评论工具栏“输入框”设置点击回调函数:
{/* 评论工具栏 */}
<CommentFooter
// ...
onComment={onComment}
/>
// 点击评论工具栏“输入框”,打开评论抽屉表单
const onComment = () => {
setCommentDrawerStatus({
visible: true,
id: info.art_id
})
}
文章评论:发表评论后更新评论列表
目标:当在评论抽屉表单中发表评论后,将新发表的评论显示到评论列表中
实现思路:
- 我们不用重新请求后端接口来获取最新的评论列表数据,因为提交评论表单,调用后端接口后返回了新评论的数据对象,我们只需要将该对象添加到列表数据中即可
操作步骤
- 在
store/reducers/article.js
中,添加修改文章详情、修改评论相关的 Reducer 逻辑:
export const article = (state = initialState, action) => {
const { type, payload } = action
switch (type) {
// 修改文章详情信息
case 'article/set_info':
return {
...state,
info: {
...state.info,
...payload
}
}
// 修改评论数据
case 'article/set_comment':
return {
...state,
comment: {
...state.comment,
...payload
}
}
// ...
}
}
- 在
store/actions/article.js
中,编写 Action Creator:
/**
* 修改 Redux 中的文章详情数据
* @param {Object} partial 文章详情中的一个或多个字段值
*/
export const setArticleInfo = partial => {
return {
type: 'article/set_info',
payload: partial
}
}
/**
* 修改 Redux 中的评论数据
* @param {Object} partial 评论中的一个或多个字段值
*/
export const setArticleComments = partial => ({
type: 'article/set_comment',
payload: partial
})
- 在抽屉表单发表评论后的回调函数
onAddComment
中调用 Action:
// 发表评论后,插入到数据中
const onAddComment = comment => {
// 将新评论添加到列表中
dispatch(setArticleComments({
results: [comment, ...comments]
}))
// 将文章详情中的评论数 +1
dispatch(setArticleInfo({
comm_count: info.comm_count + 1
}))
}
文章评论:封装回复某人的评论界面
目标:将针对某个用户的评论回复界面封装成单独组件
在评论列表中点击“回复”某人的评论:
进入针对该评论的回复评论抽屉界面:
操作步骤
- 创建
pages/Article/components/CommentReply/
目录,拷贝资源包中相应样式文件到该目录,然后编写index.js
import NavBar from '@/components/NavBar'
import NoComment from '@/components/NoComment'
import http from '@/utils/http'
import { Drawer } from 'antd-mobile'
import { useEffect, useState } from 'react'
import CommentFooter from '../CommentFooter'
import CommentInput from '../CommentInput'
import CommentItem from '../CommentItem'
import styles from './index.module.scss'
/**
* 回复评论界面组件
* @param {Object} props.originComment 原评论数据
* @param {String} props.articleId 文章ID
* @param {Function} props.onClose 关闭抽屉的回调函数
*/
const CommentReply = ({ originComment, articleId, onClose }) => {
// 评论相关数据
const [comment, setComment] = useState({})
// 抽屉表单状态
const [drawerStatus, setDrawerStatus] = useState({
visible: false,
id: originComment.com_id
})
useEffect(() => {
// 加载回复评论的列表数据
const loadData = async () => {
const res = await http.get('/comments', {
params: {
type: 'c',
source: originComment.com_id
}
})
setComment(res.data.data)
}
// 只有当原评论数据的 com_id 字段有值才开始加载数据
if (originComment?.com_id) {
loadData()
}
}, [originComment.com_id])
// 展示评论窗口
const onComment = () => {
setDrawerStatus({
visible: true,
id: originComment.com_id
})
}
// 关闭评论窗口
const onCloseComment = () => {
setDrawerStatus({
visible: false,
id: 0
})
}
// 发表评论后,插入到数据中
const onInsertComment = newItem => {
setComment({
...comment,
total_count: comment.total_count + 1,
results: [newItem, ...comment.results]
})
}
return (
<div className={styles.root}>
<div className="reply-wrapper">
{/* 顶部导航栏 */}
<NavBar className="transparent-navbar" onLeftClick={onClose}>
{comment.total_count}条回复
</NavBar>
{/* 原评论信息 */}
<div className="origin-comment">
<CommentItem
type="origin"
commentId={originComment.com_id}
authorPhoto={originComment.aut_photo}
authorName={originComment.aut_name}
likeCount={originComment.like_count}
isFollowed={originComment.is_followed}
isLiking={originComment.is_liking}
content={originComment.content}
replyCount={originComment.reply_count}
publishDate={originComment.pubdate}
/>
</div>
{/* 回复评论的列表 */}
<div className="reply-list">
<div className="reply-header">全部回复</div>
{comment?.results?.length === 0 ? (
<NoComment />
) : (
comment?.results?.map(item => {
return (
<CommentItem
key={item.com_id}
commentId={item.com_id}
authorPhoto={item.aut_photo}
authorName={item.aut_name}
likeCount={item.like_count}
isFollowed={item.is_followed}
isLiking={item.is_liking}
content={item.content}
replyCount={item.reply_count}
publishDate={item.pubdate}
/>
)
})
)}
</div>
{/* 评论工具栏,设置 type="reply" 不显示评论和点赞按钮 */}
<CommentFooter
type="reply"
placeholder="去评论"
onComment={onComment}
/>
</div>
{/* 评论表单抽屉 */}
<Drawer
className="drawer"
position="bottom"
style={{ minHeight: document.documentElement.clientHeight }}
children={''}
sidebar={
<div className="drawer-sidebar-wrapper">
{drawerStatus.visible && (
<CommentInput
id={drawerStatus.id}
name={originComment.aut_name}
articleId={articleId}
onClose={onCloseComment}
onComment={onInsertComment}
/>
)}
</div>
}
open={drawerStatus.visible}
onOpenChange={onCloseComment}
/>
</div>
)
}
export default CommentReply
文章评论:显示回复评论抽屉
目标:点击某个用户评论中的“回复” 按钮,打开回复评论抽屉界面
操作步骤
- 在文章详情页面中添加回复抽屉
import CommentReply from "./components/CommentReply"
{/* 回复抽屉 */}
<Drawer
className="drawer-right"
position="right"
style={{ minHeight: document.documentElement.clientHeight }}
children={''}
sidebar={
<div className="drawer-sidebar-wrapper">
{replyDrawerStatus.visible && (
<CommentReply
originComment={replyDrawerStatus.data}
articleId={info.art_id}
onClose={onCloseReply}
/>
)}
</div>
}
open={replyDrawerStatus.visible}
onOpenChange={onCloseReply}
/>
- 添加回复抽屉相关的回调函数
// 关闭回复评论抽屉
const onCloseReply = () => {
setReplyDrawerStatus({
visible: false,
data: {}
})
}
- 设置评论列表项上的
onOpenReply
回调函数
{comments?.map(item => {
return (
<CommentItem
// ...
onOpenReply={() => onOpenReply(item)}
/>
)
})}
// 点击评论中的 “回复” 按钮,打开回复抽屉
const onOpenReply = data => {
setReplyDrawerStatus({
visible: true,
data
})
}
文章评论:点击“评论”按钮滚动到评论列表
目标:点击评论工具栏上的“评论”按钮,文章详情页面直接滚动到评论区域
实现思路:
- 点击按钮后,将页面滚动容器的
scrollTop
设置为评论列表容器的offsetTop
即可
操作步骤
- 为文章评论容器元素添加 ref 引用
const commentRef = useRef()
{/* 文章评论区 */}
<div className="comment" ref={commentRef}>
- 为评论工具栏组件设置
onShowComment
回调函数
{/* 评论工具栏 */}
<CommentFooter
// ...
onShowComment={onShowComment}
/>
// 点击工具栏评论按钮,滚动到评论区位置
const onShowComment = () => {
wrapperRef.current.scrollTop = commentRef.current.offsetTop - 46
}
以上代码中 - 46
是为了显示出评论区的统计信息,而不被顶部导航栏盖住:
文章评论:给评论点赞
目标:点击每一条评论中的点赞按钮,为当前评论点赞
操作步骤
- 在
store/actions/article.js
中,编写更新对某条评论点赞的 Action Creator:
/**
* 取消评论点赞
* @param {String} id 评论id
* @param {Boolean} isLiking 是否点赞
* @returns thunk
*/
export const setCommentLiking = (id, isLiking) => {
return async (dispatch, getState) => {
// 获取评论数据
const { comment } = getState().article
const { results } = comment
// 点赞
if (isLiking) {
await http.post('/comment/likings', { target: id })
// 更新 Redux 中的评论数据
dispatch(setArticleComments({
results: results.map(item => {
if (item.com_id === id) {
return {
...item,
is_liking: true,
like_count: item.like_count + 1,
}
} else {
return item
}
})
}))
}
// 取消点赞
else {
await http.delete(`/comment/likings/${id}`)
// 更新 Redux 中的评论数据
dispatch(setArticleComments({
results: results.map(item => {
if (item.com_id === id) {
return {
...item,
is_liking: false,
like_count: item.like_count - 1,
}
} else {
return item
}
})
}))
}
}
}
- 为评论项组件
CommentItem
添加onThumbsUp
回调函数:
{comments?.map(item => {
return (
<CommentItem
// ...
onThumbsUp={() => onThumbsUp(item.com_id, item.is_liking)}
/>
)
})}
// 对某条评论点赞
const onThumbsUp = (commentId, isLiking) => {
// 取反
const newIsLiking = !isLiking
// 调用 Action
dispatch(setCommentLiking(commentId, newIsLiking))
}
给文章点赞
目标:点击底部评论工具栏上的 “点赞” 按钮,为当前文章点赞
操作步骤
- 在
store/actions/article.js
中,编写更新文章的点赞信息相关的 Action Creator:
/**
* 文章点赞
* @param {String} id 文章ID
* @param {Number} attitude 0-取消点赞|1-点赞
* @returns thunk
*/
export const setArticleLiking = (id, attitude) => {
return async (dispatch, getState) => {
// 获取文章详情
const { info } = getState().article
let likeCount = info.like_count
// 取消点赞
if (attitude === 0) {
await http.delete(`/article/likings/${id}`)
likeCount--
}
// 点赞
else {
await http.post('/article/likings', { target: id })
likeCount++
}
// 更新 Redux 中的数据
dispatch(setArticleInfo({
attitude,
like_count: likeCount
}))
}
}
- 为评论工具栏设置
onLike
回调函数
{/* 评论工具栏 */}
<CommentFooter
// ...
onLike={onLike}
/>
// 点击工具栏点赞按钮
const onLike = () => {
// 在 “点赞” 和 “不点赞” 之间取反
const newAttitude = info.attitude === 0 ? 1 : 0
// 调用 Action
dispatch(setArticleLiking(info.art_id, newAttitude))
}
收藏文章
目标:点击评论工具栏上的“收藏”按钮,实现对当前文章的收藏
操作步骤
- 在
store/actions/article.js
中,编写收藏文章相关的 Action Creator:
/**
* 文章收藏
* @param {String} id 文章id
* @param {Boolean} isCollect 是否收藏
* @returns thunk
*/
export const setAritcleCollection = (id, isCollect) => {
return async dispatch => {
// 收藏
if (isCollect) {
await http.post('/article/collections', { target: id })
}
// 取消收藏
else {
await http.delete(`/article/collections/${id}`)
}
// 更新 Redux 中的文章数据
dispatch(setArticleInfo({
is_collected: isCollect
}))
}
}
- 为评论工具栏设置
onCollected
回调函数
{/* 评论工具栏 */}
<CommentFooter
// ...
onCollected={onCollected}
/>
// 收藏文章
const onCollected = () => {
// 取反
const newIsCollect = !info.is_collected
// 调用 Action
dispatch(setAritcleCollection(info.art_id, newIsCollect))
}
关注作者
目标:点击文章详情页的 “关注” 按钮,关注当前文章的作者
操作步骤
- 在
store/actions/article.js
中,编写关注文章作者的 Action Creator:
/**
* 关注作者
* @param {String} id 作者id
* @param {Boolean} id 是否关注
* @returns thunk
*/
export const setAuthorFollow = (id, isFollow) => {
return async dispatch => {
// 关注
if (isFollow) {
await http.post('/user/followings', { target: id })
}
// 取消关注
else {
await http.delete(`/user/followings/${id}`)
}
dispatch(setArticleInfo({
is_followed: isFollow
}))
}
}
- 为界面上的两处“关注” 按钮设置点击事件:
<span
className={classnames('follow', info.is_followed ? 'followed' : '')}
onClick={onFollow}
>
{info.is_followed ? '已关注' : '关注'}
</span>
// 关注作者
const onFollow = async () => {
// 取反
const isFollow = !info.is_followed
// 调用 Action
dispatch(setAuthorFollow(info.aut_id, isFollow))
}
分享文章
目标:点击分享按钮,弹出分享抽屉式菜单
分享按钮:
弹出菜单:
操作步骤
- 封装抽屉中的界面组件
创建 pages/Article/components/Share/
目录,拷贝资源包中对应的样式文件到该目录下,然后编写 index.js
:
import styles from './index.module.scss'
const Share = ({ onClose }) => {
return (
<div className={styles.root}>
{/* 标题 */}
<div className="share-header">立即分享给好友</div>
{/* 第一排菜单 */}
<div className="share-list">
<div className="share-item">
<img src="https://img01.yzcdn.cn/vant/share-sheet-wechat.png" alt="" />
<span>微信</span>
</div>
<div className="share-item">
<img src="https://img01.yzcdn.cn/vant/share-sheet-wechat-moments.png" alt="" />
<span>朋友圈</span>
</div>
<div className="share-item">
<img src="https://img01.yzcdn.cn/vant/share-sheet-weibo.png" alt="" />
<span>微博</span>
</div>
<div className="share-item">
<img src="https://img01.yzcdn.cn/vant/share-sheet-qq.png" alt="" />
<span>QQ</span>
</div>
</div>
{/* 第二排菜单 */}
<div className="share-list">
<div className="share-item">
<img src="https://img01.yzcdn.cn/vant/share-sheet-link.png" alt="" />
<span>复制链接</span>
</div>
<div className="share-item">
<img src="https://img01.yzcdn.cn/vant/share-sheet-poster.png" alt="" />
<span>分享海报</span>
</div>
<div className="share-item">
<img src="https://img01.yzcdn.cn/vant/share-sheet-qrcode.png" alt="" />
<span>二维码</span>
</div>
<div className="share-item">
<img src="https://img01.yzcdn.cn/vant/share-sheet-weapp-qrcode.png" alt="" />
<span>小程序码</span>
</div>
</div>
{/* 取消按钮 */}
<div className="share-cancel" onClick={onClose}>取消</div>
</div>
)
}
export default Share
- 在文章详情页中创建分享菜单抽屉及控制菜单开关的状态、函数:
import Share from "./components/Share"
// 分享抽屉状态
const [shareDrawerStatus, setShareDrawerStatus] = useState({
visible: false
})
// 打开分享抽屉
const onOpenShare = () => {
setShareDrawerStatus({
visible: true
})
}
// 关闭分享抽屉
const onCloseShare = () => {
setShareDrawerStatus({
visible: false
})
}
{/* 分享抽屉 */}
<Drawer
className="drawer-share"
position="bottom"
style={{ minHeight: document.documentElement.clientHeight }}
children={''}
sidebar={
<Share onClose={onCloseShare} />
}
open={shareDrawerStatus.visible}
onOpenChange={onCloseShare}
/>
- 为顶部导航栏右侧按钮、以及底部工具栏“分享”按钮设置点击回调:
{/* 顶部导航栏 */}
<NavBar
onLeftClick={() => history.go(-1)}
rightContent={
<span onClick={onOpenShare}>
<Icon type="icongengduo" />
</span>
}
>
{/* 评论工具栏 */}
<CommentFooter
// ..
onShare={onOpenShare}
/>
评论统计信息的吸顶效果
目标:
实现思路:
- 监听页面滚动容器元素的
scroll
事件,判断要吸顶的元素是否已到达指定位置,如果是,就将它设置成固定定位fixed
操作步骤
- 为提示吸顶效果的复用性,我们封装一个组件
Sticky
,放在该组件下的元素都将具有吸顶功能:
创建components/Share/
目录,拷贝资源包对应样式文件到该目录,然后编写index.js
。
import throttle from 'lodash/fp/throttle'
import { useEffect, useRef } from 'react'
import styles from './index.module.scss'
/**
* 吸顶组件
* @param {HTMLElement} props.root 滚动容器元素
* @param {Number} props.height 吸顶元素的高度
* @param {HTMLElement} props.offset 吸顶位置的 top 值
* @param {HTMLElement} props.children 本组件的子元素
*/
const Sticky = ({ root, height, offset = 0, children }) => {
const placeholderRef = useRef(null)
const containerRef = useRef(null)
useEffect(() => {
if (!root) return
const placeholderDOM = placeholderRef.current
const containerDOM = containerRef.current
// 滚动事件监听函数
const onScroll = throttle(60, () => {
// 获取占位元素的 top 位置
const { top } = placeholderDOM.getBoundingClientRect()
// 占位元素的 top 值已达到吸顶位置
if (top <= offset) {
// 将要吸顶的容器元素设置成 fixed 固定定位
containerDOM.style.position = 'fixed'
containerDOM.style.top = `${offset}px`
placeholderDOM.style.height = `${height}px`
} else {
// 取消固定定位
containerDOM.style.position = 'static'
placeholderDOM.style.height = '0px'
}
})
// 添加事件监听
root.addEventListener('scroll', onScroll)
return () => {
// 注销事件监听
root.removeEventListener('scroll', onScroll)
}
}, [root, offset, height])
return (
<div className={styles.root}>
{/* 占位元素 */}
<div ref={placeholderRef} className="sticky-placeholder" />
{/* 吸顶显示的元素 */}
<div className="sticky-container" ref={containerRef}>
{children}
</div>
</div>
)
}
export default Sticky
- 将文章详情页中的要吸顶的元素用
Stick
组件包裹起来:
import Sticky from "@/components/Sticky"
{/* 评论总览信息 */}
<Sticky root={wrapperRef.current} height={51} offset={46}>
<div className="comment-header">
<span>全部评论({info.comm_count})</span>
<span>{info.like_count} 点赞</span>
</div>
</Sticky>
功能性优化
解决切换底部 Tab 后首页文章列表的刷新
目标:实现当底部 Tab 栏切换后,首页仍然能保持之前的滚动位置
当前问题:
将首页文章列表向下滚动到某一位置,然后切换到其他 Tab 页面(比如“问答”)后再切换回首页,你会发现文章列表刷新并回到页面顶部。
我们期望的效果:
切换 Tab 后,文章列表不刷新,且列表的滚动位置还保持在之前位置。
实现思路:
- Route 组件在路由路径重新匹配后,会销毁和重建它要展示的子组件。不过,我们可以改写它的子组件渲染逻辑,让它的子组件通过
display
样式来控制 显示、隐藏,而不是销毁和重建。
这种方式我们通常称为:KeepAlive 组件缓存
操作步骤
- 创建
components/KeepAlive/
目录,并复制资源包中的样式文件到该目录下,然后编写index.js
import { Route } from 'react-router-dom'
import styles from './index.module.scss'
/**
* 缓存路由组件
* @param {String} props.alivePath 要缓存的路径
* @param {ReactElement} props.component 匹配路由规则后显示的组件
* @param {rest} props.rest 任何 Route 组件可用的属性
*/
const KeepAlive = ({ alivePath, component: Component, ...rest }) => {
return (
<Route {...rest}>
{props => {
const { location } = props
const matched = location.pathname.startsWith(alivePath)
return (
<div className={styles.root} style={{ display: matched ? 'block' : 'none' }}>
<Component {...props} />
</div>
)
}}
</Route>
)
}
export default KeepAlive
- 在
layouts/TabBarLayout.js
组件代码中,使用KeepAlive
组件替代Route
组件来做首页的路由:
import KeepAlive from '@/components/KeepAlive'
<KeepAlive alivePath="/home/index" path="/home/index" exact component={Home} />
解决进入详情页后退出时文章列表的刷新
目标:实现当点击首页文章列表进入详情页面后再后退到首页,文章列表不刷新并保持滚动位置
实现思路:
- 还是借助之前实现的
KeepAlive
组件
操作步骤
- 在根组件
App.js
中,删除原先的首页Route
路由组件:
{/* <Route path="/home" component={TabBarLayout} /> */}
- 在
Switch
外部使用KeepAlive
配置首页的路由,然后在Switch
内配置一个重定向路由:
<Router history={history}>
<KeepAlive alivePath="/home" path="/home" component={TabBarLayout} />
<Switch>
<Route path="/" exact>
<Redirect to="/home/index" />
</Route>
<Route path="/home" exact>
<Redirect to="/home/index" />
</Route>
{/* ... */}
</Switch>
</Router>
- 将原先的 404 错误路由改写成如下形式:
{/* 因为 /home 不在 Switch 内部,所以需要手动处理 /home 开头的路由,否则会被当做 404 处理 */}
<Route render={props => {
if (!props.location.pathname.startsWith('/home')) {
return <NotFound {...props} />
}
}} />
性能优化
性能优化的原则
目标:了解什么时候应该做性能优化
【重点】不要过早的进行优化!
- 因为代码优化本身也是要付出一定的性能代价的!!!
- 因为优化会引入额外的代码,一定程度上会影响到代码的可读性
- 因为你也没有把握一开始就知道哪些代码是需要优化的
何时进行代码优化?
- 项目开发的中后期,模块功能已较为稳定的时候
- 经过一轮或多轮测试,测试人员给出了一些关于性能和用户体验方面的反馈的时候
- 代码中涉及较为复杂计算的时候
- 当你经过优化尝试,确定你添加的优化代码本身的性能损耗小于带来的性能提升的时候
函数组件的性能优化方案
React 函数组件 + Hooks 的开发模式下,常用的性能提升方案有三个:
- React.memo - 相当于类组件的
shouldComponentUpdate
- useMemo - 仅当依赖项变化时进行值的计算,避免组件每次渲染时都发生重复计算
- useCallback - 仅当依赖项变化时进行函数的创建,避免组件每次渲染时都重新创建新函数
切忌滥用它们!因为使用它们时也是有性能开销的。只有当你经过实际测试,确定带来的性能提升要比带来的额外开销要更大,才值得去使用它们。