React移动端项目-03

文章搜索

文章搜索页的静态结构

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

首页入口:

在这里插入图片描述

搜索页面:

在这里插入图片描述

操作步骤

  1. 为首页 Tab 栏右边的 ”放大镜“ 按钮添加点击事件,点击后跳转到搜索页:
import { useHistory } from 'react-router'
const history = useHistory()
<Icon type="iconbtn_search" onClick={() => history.push('/search')} />
  1. 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)新建定时器执行任务

操作步骤

  1. 声明一个用于存放关键字的状态
import { useState } from 'react'
// 搜索关键字内容
const [keyword, setKeyword] = useState('')
  1. 为输入框设置 value 属性和 onChange 事件
<input
  type="text"
  placeholder="请输入关键字搜索"
  value={keyword}
  onChange={onKeywordChange}
  />
const onKeywordChange = e => {
  const text = e.target.value.trim()
  setKeyword(text)
  console.log(text)
}

当前效果:每次键盘敲击都会打印出输入框中的内容

在这里插入图片描述

  1. 防抖处理
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 中

操作步骤

  1. 创建 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
  }
}
  1. store/index.js中配置新建的 reducer
// ...
import { search } from './search'

const rootReducer = combineReducers({
  // ...
  search
})
  1. 创建 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))
  }
}
  1. 在之前的防抖定时器中调用 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 中获取数据

操作步骤

  1. 从 Redux 中获取搜索建议数据
import { getSuggestions } from '@/store/actions/search'
const suggestions = useSelector(state => state.search.suggestions)
  1. 将搜索建议数据渲染到界面上
<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 中保存的搜索建议结果

操作步骤

  1. store/reducers/search.js中添加清空搜索建议数据的 Reducer 逻辑
export const search = (state = initialState, action) => {
  const { type, payload } = action

  switch (type) {
    case 'search/clear':
      return {
        ...state,
        suggestions: []
      }
      
    // ...
  }
}
  1. store/actions/search.js中编写 Action Creator:
/**
 * 清空搜索建议
 * @returns thunk
 */
export const clearSuggestions = () => {
  return {
    type: 'search/clear'
  }
}
  1. 为输入框内的 x 按钮添加点击事件
{/* 清空输入框按钮,且在输入内容时才显示 */}
{keyword && (
  <Icon type="iconbtn_tag_close" className="icon-close" onClick={onClear} />
)}
// 清空
const onClear = () => {
  // 清空输入框内容
  setKeyword('')

  // 设置为非搜索状态
  setIsSearching(false)

  // 清空Redux中的搜索建议数据
  dispatch(clearSuggestions())
}

动态渲染搜索历史记录

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

实现思路:

  • 在成功搜索后,将关键字存入 Redux 和 LocalStorage 中
  • 从 Redux 中获取所有关键字,并渲染到界面

操作步骤

  1. 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]
      }
      
    // ...
  }
}
  1. 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)
}
  1. 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)
  }
}
  1. store/index.js中,添加从本地缓存初始化搜索历史的逻辑:
import { getLocalHistories, getTokenInfo } from '@/utils/storage'

const store = createStore(
  // ...

  // 参数二:初始化时要加载的状态
  {
		// ...
    
    search: {
      histories: getLocalHistories(),
      suggestions: []
    }
  },

  // ...
)
  1. 在搜索页面中,从 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 进行自动去重

操作步骤

  1. 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 个,如果已满则删除最后一个关键字

操作步骤

  1. 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 中存储的历史记录

操作步骤

  1. store/reducers/seach.js中,添加删除搜索历史相关的 Reducer 逻辑
export const search = (state = initialState, action) => {
  const { type, payload } = action

  switch (type) {
    case 'search/clear_histories':
      return {
        ...state,
        histories: []
      }

    // ...
  }
}
  1. 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()
  }
}
  1. 在搜索页面中,为 “清除全部” 按钮添加点击事件
<span onClick={onClearHistories}>
  <Icon type="iconbtn_del" />清除全部
</span>
// 清空搜索历史
const onClearHistories = () => {
  dispatch(clearHistories())
}

点击”搜索“或建议结果跳到搜索详情页

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

在这里插入图片描述

操作步骤

  1. 为元素添加点击事件

点击搜索按钮跳转时,携带当前输入的关键字作为参数

<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}`)
  }
}

搜索详情页的静态结构

目标:实现搜索详情页的静态结构和样式

在这里插入图片描述

操作步骤

  1. 将资源包中对应的样式文件,拷贝到 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

请求搜索详情页数据

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

操作步骤

  1. 获取通过 URL 地址传入到搜索详情页的查询字符串参数 q
// 获取通过 URL 地址传入的查询字符串参数
const params = new URLSearchParams(location.search)
const q = params.get('q')
  1. 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
      }

    // ...
  }
}
  1. 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))
  }
}
  1. 在搜索详情页面中,通过 useEffect 在进入页面时调用以上编写的 Action:
import { getSearchResults } from '@/store/actions/search'
import { useEffect } from 'react'
useEffect(() => {
  dispatch(getSearchResults(q))
}, [q, dispatch])

渲染搜索详情列表

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

操作步骤

  1. 从 Redux 中获取搜索详情数据
import { useDispatch, useSelector } from 'react-redux'
const articles = useSelector(state => state.search.searchResults)
  1. 将数据渲染成列表
<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>

点击搜索详情列表跳到文章详情页

目标:实现在搜索详情列表中点击一个列表项,跳转到文章的详情页面

操作步骤

  1. 为搜索详情列表项添加点击事件
<div key={article.art_id} onClick={() => gotoAritcleDetail(article.art_id)}>
  // ...
</div>
// 跳转到文章详情页面
const gotoAritcleDetail = articleId => {
  history.push(`/article/${articleId}`)
}

文章详情页

文章详情页的基本静态结构

目标:实现详情页基本的文章内容展示相关的静态结构和样式

在这里插入图片描述

在这里插入图片描述

操作步骤

  1. 将资源包的相关样式文件拷贝到 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,发起请求

操作步骤

  1. 创建 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
  }
}
  1. store/reducers/index.js 中配置刚刚新建的 Reducer 模块:
// ...

import { article } from './article'

const rootReducer = combineReducers({
  // ...
  article
})
  1. 创建 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))
  }
}
  1. 在文章详情页面中,获取动态路由参数,并调用 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])

渲染文章详情

目标:将请求到的文章详情数据,渲染到界面上

操作步骤

  1. 从 Redux 中获取文章加载状态和详情数据
import { useDispatch, useSelector } from "react-redux"
const { isLoading, info } = useSelector(state => state.article)
  1. 使用 isLoading 控制骨架屏和正文区域的条件渲染:
{isLoading ? (
  // 数据正在加载时显示的骨架屏界面
	// ...
) : (
  // 数据加载完成后显示的实际界面
  // ...
)
  1. 填充文章数据:
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 内容进行净化处理

操作步骤

  1. 安装包
npm i dompurify --save
  1. 在页面中调用 dompurify 来对文章正文内容做净化:
import DOMPurify from 'dompurify'
<div
  className="content-html dg-html"
  dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(info.content || '') }}
  ></div>

文章内容中的代码高亮

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

在这里插入图片描述

实现思路:

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

操作步骤

  1. 安装包
npm i highlight.js --save
  1. 在页面中引入 highlight.js
import hljs from 'highlight.js'
import 'highlight.js/styles/vs2015.css'
  1. 在文章加载后,对文章内容中的代码进行语法高亮
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 中间内容设置为显示;否则设置为隐藏

操作步骤

  1. 为顶部导航栏添加作者信息
<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>
  1. 声明状态和对界面元素的引用
const [isShowNavAuthor, setShowNavAuthor] = useState(false)
const wrapperRef = useRef()
const authorRef = useRef()
<div className="wrapper" ref={wrapperRef}>

<div className="author" ref={authorRef}>
  1. 设置滚动事件监听,判断是否显示导航栏中的作者信息
// 监听滚动,控制 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])

文章评论:封装没有评论时的界面组件

目标:实现一个组件,展示没有任何评论时的提示信息

在这里插入图片描述

操作步骤

  1. 创建 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

文章评论:封装用户评论列表项组件

目标:将评论列表中的一项封装成一个组件

在这里插入图片描述

操作步骤

  1. 创建 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

文章评论:请求评论列表数据

目标:调用后端接口,获取当前文章的评论数据

操作步骤

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

    // ...
  }
}
  1. 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))
  }
}
  1. 在进入文章详情页面时调用 Action
import { getArticleComments, getArticleInfo } from "@/store/actions/article"
// 进入页面时
useEffect(() => {
  // 请求文章详情数据
  dispatch(getArticleInfo(articleId))

  // 请求评论列表数据
  dispatch(getArticleComments({
    type: 'a',
    source: articleId
  }))
}, [dispatch, articleId])

文章评论:渲染评论列表

目标:将请求到的评论数据渲染到界面上

操作步骤

  1. 从 Redux 中获取评论数据
const { isLoading, info, isLoadingComment, comment } = useSelector(state => state.article)
const comments = comment.results
  1. 在之前渲染文章正文的元素下,渲染文章评论相关的元素:
<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 的回调函数

操作步骤

  1. 创建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 函数,实现对评论列表区域的触底监听

操作步骤

  1. 为评论列表中的占位元素添加 ref
const placeholderRef = useRef()
<div className="placeholder" ref={placeholderRef}></div>
  1. 调用自定义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 打印信息。

  1. 使用自定义 Hook 返回的 finished 状态,控制评论列表界面元素的显示或隐藏:
{finished && <div className="no-more">没有更多了</div>}

文章评论:上拉发送请求加载更多评论

目标:在自定义 Hook 引发触底时,调用后端接口获取下一页的评论数据

操作步骤

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

    // ...
  }
}
  1. 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))
  }
}
  1. 在自定义 Hook onReachBottom 的回调函数中,调用以上 Action:
import { getArticleComments, getArticleInfo, getMoreArticleComments } from "@/store/actions/article"
const { finished } = useReachBottom(
  () => {
    dispatch(getMoreArticleComments({
      type: 'a',
      source: articleId,
      offset: comment.last_id
    }))
  },
  
  // ...
)

文章评论:封装并显示评论工具栏组件

目标:将详情页底部的评论工具栏封装成一个组件,并在文章详情页中调用

在这里插入图片描述

操作步骤

  1. 创建 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
  1. 在文章详情页中调用 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={() => { }}
  />
</>

文章评论:封装评论表单组件

目标:将发表评论的表单界面封装成一个组件

风格一:直接评论一篇文章时

在这里插入图片描述

风格二:回复某人的评论时

在这里插入图片描述

操作步骤

  1. 创建 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

文章评论:显示评论表单抽屉

目标:点击评论工具栏的“输入框”,弹出一个抽屉式评论表单

在这里插入图片描述

操作步骤

  1. 声明用于控制抽屉显示隐藏的状态
// 评论抽屉状态
const [commentDrawerStatus, setCommentDrawerStatus] = useState({
  visible: false,
  id: 0
})
  1. 在页面中创建评论表单抽屉
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>
  1. 编写表单抽屉上的一系列回调函数
// 关闭评论抽屉表单
const onCloseComment = () => {
  setCommentDrawerStatus({
    visible: false,
    id: 0
  })
}

// 发表评论后,插入到数据中
const onAddComment = comment => {

}
  1. 为评论工具栏“输入框”设置点击回调函数:
{/* 评论工具栏 */}
<CommentFooter
  // ...
  onComment={onComment}
  />
// 点击评论工具栏“输入框”,打开评论抽屉表单
const onComment = () => {
  setCommentDrawerStatus({
    visible: true,
    id: info.art_id
  })
}

文章评论:发表评论后更新评论列表

目标:当在评论抽屉表单中发表评论后,将新发表的评论显示到评论列表中

实现思路:

  • 我们不用重新请求后端接口来获取最新的评论列表数据,因为提交评论表单,调用后端接口后返回了新评论的数据对象,我们只需要将该对象添加到列表数据中即可

操作步骤

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

    // ...
  }
}
  1. 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
})
  1. 在抽屉表单发表评论后的回调函数 onAddComment 中调用 Action:
// 发表评论后,插入到数据中
const onAddComment = comment => {
  // 将新评论添加到列表中
  dispatch(setArticleComments({
    results: [comment, ...comments]
  }))

  // 将文章详情中的评论数 +1
  dispatch(setArticleInfo({
    comm_count: info.comm_count + 1
  }))
}

文章评论:封装回复某人的评论界面

目标:将针对某个用户的评论回复界面封装成单独组件

在评论列表中点击“回复”某人的评论:

在这里插入图片描述

进入针对该评论的回复评论抽屉界面:

在这里插入图片描述

操作步骤

  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

文章评论:显示回复评论抽屉

目标:点击某个用户评论中的“回复” 按钮,打开回复评论抽屉界面

操作步骤

  1. 在文章详情页面中添加回复抽屉
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}
  />
  1. 添加回复抽屉相关的回调函数
// 关闭回复评论抽屉
const onCloseReply = () => {
  setReplyDrawerStatus({
    visible: false,
    data: {}
  })
}
  1. 设置评论列表项上的 onOpenReply 回调函数
{comments?.map(item => {
  return (
    <CommentItem
      // ...
      onOpenReply={() => onOpenReply(item)}
      />
  )
})}
// 点击评论中的 “回复” 按钮,打开回复抽屉
const onOpenReply = data => {
  setReplyDrawerStatus({
    visible: true,
    data
  })
}

文章评论:点击“评论”按钮滚动到评论列表

目标:点击评论工具栏上的“评论”按钮,文章详情页面直接滚动到评论区域

在这里插入图片描述

在这里插入图片描述

实现思路:

  • 点击按钮后,将页面滚动容器的 scrollTop 设置为评论列表容器的offsetTop即可

操作步骤

  1. 为文章评论容器元素添加 ref 引用
const commentRef = useRef()
{/* 文章评论区 */}
<div className="comment" ref={commentRef}>
  1. 为评论工具栏组件设置 onShowComment 回调函数
{/* 评论工具栏 */}
<CommentFooter
  // ...
  onShowComment={onShowComment}
  />
// 点击工具栏评论按钮,滚动到评论区位置
const onShowComment = () => {
  wrapperRef.current.scrollTop = commentRef.current.offsetTop - 46
}

以上代码中 - 46 是为了显示出评论区的统计信息,而不被顶部导航栏盖住:

在这里插入图片描述

文章评论:给评论点赞

目标:点击每一条评论中的点赞按钮,为当前评论点赞

在这里插入图片描述

操作步骤

  1. 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
          }
        })
      }))
    }
  }
}
  1. 为评论项组件 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))
}

给文章点赞

目标:点击底部评论工具栏上的 “点赞” 按钮,为当前文章点赞

在这里插入图片描述

操作步骤

  1. 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
    }))
  }
}
  1. 为评论工具栏设置 onLike 回调函数
{/* 评论工具栏 */}
<CommentFooter
  // ...
  onLike={onLike}
  />
// 点击工具栏点赞按钮
const onLike = () => {
  // 在 “点赞” 和 “不点赞” 之间取反
  const newAttitude = info.attitude === 0 ? 1 : 0

  // 调用 Action 
  dispatch(setArticleLiking(info.art_id, newAttitude))
}

收藏文章

目标:点击评论工具栏上的“收藏”按钮,实现对当前文章的收藏

在这里插入图片描述

操作步骤

  1. 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
    }))
  }
}
  1. 为评论工具栏设置 onCollected 回调函数
{/* 评论工具栏 */}
<CommentFooter
  // ...
  onCollected={onCollected}
  />
// 收藏文章
const onCollected = () => {
  // 取反
  const newIsCollect = !info.is_collected

  // 调用 Action
  dispatch(setAritcleCollection(info.art_id, newIsCollect))
}

关注作者

目标:点击文章详情页的 “关注” 按钮,关注当前文章的作者

在这里插入图片描述

在这里插入图片描述

操作步骤

  1. 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
    }))
  }
}
  1. 为界面上的两处“关注” 按钮设置点击事件:
<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))
}

分享文章

目标:点击分享按钮,弹出分享抽屉式菜单

分享按钮:

在这里插入图片描述

在这里插入图片描述

弹出菜单:

在这里插入图片描述

操作步骤

  1. 封装抽屉中的界面组件

创建 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
  1. 在文章详情页中创建分享菜单抽屉及控制菜单开关的状态、函数:
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}
  />
  1. 为顶部导航栏右侧按钮、以及底部工具栏“分享”按钮设置点击回调:
{/* 顶部导航栏 */}
<NavBar
  onLeftClick={() => history.go(-1)}
  rightContent={
    <span onClick={onOpenShare}>
      <Icon type="icongengduo" />
    </span>
  }
  >
{/* 评论工具栏 */}
<CommentFooter
  // ..
  onShare={onOpenShare}
  />

评论统计信息的吸顶效果

目标:

在这里插入图片描述

在这里插入图片描述

实现思路:

  • 监听页面滚动容器元素的 scroll 事件,判断要吸顶的元素是否已到达指定位置,如果是,就将它设置成固定定位 fixed

操作步骤

  1. 为提示吸顶效果的复用性,我们封装一个组件 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
  1. 将文章详情页中的要吸顶的元素用 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 组件缓存

操作步骤

  1. 创建 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
  1. layouts/TabBarLayout.js 组件代码中,使用 KeepAlive 组件替代 Route 组件来做首页的路由:
import KeepAlive from '@/components/KeepAlive'
<KeepAlive alivePath="/home/index" path="/home/index" exact component={Home} />

解决进入详情页后退出时文章列表的刷新

目标:实现当点击首页文章列表进入详情页面后再后退到首页,文章列表不刷新并保持滚动位置

实现思路:

  • 还是借助之前实现的 KeepAlive 组件

操作步骤

  1. 在根组件 App.js 中,删除原先的首页 Route 路由组件:
{/* <Route path="/home" component={TabBarLayout} /> */}
  1. 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>
  1. 将原先的 404 错误路由改写成如下形式:
{/* 因为 /home 不在 Switch 内部,所以需要手动处理 /home 开头的路由,否则会被当做 404 处理 */}
<Route render={props => {
    if (!props.location.pathname.startsWith('/home')) {
      return <NotFound {...props} />
    }
  }} />

性能优化

性能优化的原则

目标:了解什么时候应该做性能优化

【重点】不要过早的进行优化!

  1. 因为代码优化本身也是要付出一定的性能代价的!!!
  2. 因为优化会引入额外的代码,一定程度上会影响到代码的可读性
  3. 因为你也没有把握一开始就知道哪些代码是需要优化的

何时进行代码优化?

  1. 项目开发的中后期,模块功能已较为稳定的时候
  2. 经过一轮或多轮测试,测试人员给出了一些关于性能和用户体验方面的反馈的时候
  3. 代码中涉及较为复杂计算的时候
  4. 当你经过优化尝试,确定你添加的优化代码本身的性能损耗小于带来的性能提升的时候

函数组件的性能优化方案

React 函数组件 + Hooks 的开发模式下,常用的性能提升方案有三个:

  • React.memo - 相当于类组件的 shouldComponentUpdate
  • useMemo - 仅当依赖项变化时进行值的计算,避免组件每次渲染时都发生重复计算
  • useCallback - 仅当依赖项变化时进行函数的创建,避免组件每次渲染时都重新创建新函数

切忌滥用它们!因为使用它们时也是有性能开销的。只有当你经过实际测试,确定带来的性能提升要比带来的额外开销要更大,才值得去使用它们。


打包上线

利用 CDN 减少打包后的代码大小

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
React移动端项目中,你可以通过添加CSS样式和使用React组件来禁止滑动。以下是一种常见的方法: 1. 创建一个全局的CSS文件,例如`global.css`,并在你的项目中引入它。 2. 在`global.css`文件中添加以下CSS样式: ```css /* 禁止滑动 */ body { overflow: hidden; } .modal-open { overflow: hidden; position: fixed; width: 100%; } ``` 上述CSS样式会将页面的滚动条和滑动行为禁止掉,同时保持弹窗内容可滚动。 3. 在你的React组件中,使用state来控制弹窗的显示与隐藏,并通过条件渲染来添加相应的CSS类名。 ```jsx import React, { useState } from 'react'; import './global.css'; function App() { const [modalOpen, setModalOpen] = useState(false); const openModal = () => { setModalOpen(true); }; const closeModal = () => { setModalOpen(false); }; return ( <div className={modalOpen ? 'modal-open' : ''}> {/* 页面内容 */} <button onClick={openModal}>打开弹窗</button> {/* 弹窗 */} {modalOpen && ( <div className="modal"> <h2>弹窗内容</h2> <button onClick={closeModal}>关闭弹窗</button> </div> )} </div> ); } export default App; ``` 在上述示例中,我们使用了`modalOpen`状态来控制弹窗的显示与隐藏。当弹窗打开时,给根元素添加`modal-open`类名,这将应用之前定义的CSS样式,禁止页面滑动。 通过点击按钮来打开和关闭弹窗,并更新`modalOpen`状态。 这样就可以在React移动端项目中实现弹窗后禁止滑动的效果了。记得在修改完配置后重新启动应用程序。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值