01-路由和页面结构
目标:能够根据模板搭建搜索页面结构
步骤:
- 将搜索页面的模板拷贝到 pages 目录中
- 在 App 组件中分别配置搜索和搜索结果页面的路由
- 在 Home 组件中为搜索按钮绑定点击事件,跳转到搜索页面
核心代码:
App.tsx 中:
import Search from ‘./pages/Search’import SearchResult from ‘./pages/Search/Result’ const App = () => { return ( // …
Home/index.tsx 中:
import { useHistory } from ‘react-router-dom’; const Home = () => { const history = useHistory(); return ( // …
02-Search 组件基本使用
目标:能够使用 antd-mobile 组件库中的 Search 组件步骤:
- 创建状态,通过受控组件方式获取 Search 的值
核心代码:
Search/index.tsx 中:
import { useState } from ‘react’; const SearchPage = () => { const history = useHistory(); const [searchTxt, setSearchTxt] = useState(‘’); const onSearchChange = (value: string) => { setSearchTxt(value); }; return ( // …
04-获取搜索联想关键词
目标:能够在搜索框输入内容时获取搜索联想关键词
步骤:
- 在搜索框的 change 事件中分发 action 获取搜索联想关键词
- 创建 actions/search.ts 并创建获取搜索联想关键词的函数
- 根据接口,在 types 中添加联想关键词返回类型
- 在搜索联想关键词的 aciton 中发送请求获取联想关键词
- 在 types 中添加保存联想关键词状态的 action 类型
- 分发 action 将联想关键词状态保存到 redux 中
- 创建 reducers/search.ts 并合并到根 reducer 中
- 在 reducer 中处理联想关键的 action
核心代码:
Search/index.tsx 中:
import { useDispatch } from ‘react-redux’;import { getSuggestion } from ‘@/store/actions’; const SearchPage = () => { const dispatch = useDispatch(); const onSearchChange = (value: string) => { // … dispatch(getSuggestion(value)); };};
types/data.d.ts 中:
// 搜索关键词export type Suggestion = { options: string[];};export type SuggestionResponse = ApiResponse
types/store.d.ts 中:
// 联合到 RootAction 中export type RootAction = | LoginAction | ProfileAction | HomeAction | SearchAction; export type SearchAction = { type: ‘search/suggestion’; payload: Suggestion[‘options’];};
actions/search.ts 中:
import { SuggestionResponse } from ‘@/types/data’;import { RootThunkAction } from ‘@/types/store’;import { http } from ‘@/utils’; export const getSuggestion = (value: string): RootThunkAction => { return async (dispatch) => { const res = await http.get
reducers/search.ts 中:
import { Suggestion } from ‘@/types/data’;import { SearchAction } from ‘@/types/store’; type SearchState = { suggestion: Suggestion[‘options’];}; const initialState: SearchState = { suggestion: [],}; const Search = (state = initialState, action: SearchAction): SearchState => { switch (action.type) { case ‘search/suggestion’: return { …state, suggestion: action.payload, }; default: return state; }}; export default Search;
reducers/index.ts 中:
import search from ‘./search’; const rootReducer = combineReducers({ // … search,});
05-搜索输入时防抖
目标:能够在搜索框中输入内容时进行防抖处理
分析说明:
实际开发中,直接借助第三方库来实现防抖功能即可。有两种方式:
- lodash 的 debounce 函数
- ahooks(阿里提供的 hooks 库)库提供的 useDebounceFn hook 来实现。)
先看第一种:
// 导入 lodash 中的防抖函数import debounce from ‘lodash/debounce’;import { useState, useRef } from ‘react’;import { DebouncedFunc } from ‘lodash’; const SearchPage = () => { const dispatch = useDispatch(); const [searchText, setSearchText] = useState(‘’); // 注意:如果 useRef 有默认值,那么,该 ref 对象的 current 属性是只读的! // const debounceFnRef = useRef
再来看第二种方案:
const { // 防抖函数 run,} = useDebounceFn( // 需要防抖执行的函数 fn, // 配置防抖的配置项,比如,设置超时时间 options,);
注意:由于 antd-mobile 组件库依赖了 ahooks 并且是 ahooks@2.10.14 版本,所以,在安装 ahooks 时需要明确指定版本;否则,会导致报错
步骤:
- 安装 ahooks 包:yarn add ahooks@2.10.14
- 导入 useDebounceFn hook
- 创建防抖函数
- 搜索框中输入内容时,调用防抖函数
核心代码:
Search/index.tsx 中:
import { useDebounceFn } from ‘ahooks’; const SearchPage = () => { const { run: debounceGetSuggest } = useDebounceFn( (value: string) => { dispatch(getSuggestion(value)); }, { wait: 500, }, ); const onSearchChange = (value: string) => { // … debounceGetSuggest(value); };};
06-渲染联想关键词
目标:能够渲染联想关键词列表
步骤:
- 获取联想关键词的状态
- 判断是否有联想关键词,有的话添加 show 类名,来展示列表
- 遍历联想关键词并渲染
核心代码:
Search/index.tsx 中:
const SearchPage = () => { const { suggestion } = useSelector((state: RootState) => state.search); return ( // …
07-清空联想关键词
目标:能够在搜索文本框为空时清空联想关键词
步骤:
核心代码:
Search/index.tsx 中
import { clearSuggestion } from ‘@/store/actions’; const SearchPage = () => { const onSearchChange = (value: string) => { // … if (!value) return dispatch(clearSuggestion()); };};
actions/search.ts 中:
export const clearSuggestion = () => ({ type: ‘search/clearSuggestion’ });
types/store.d.ts 中:
const Search = () => { switch (action.type) { // … case ‘search/clearSuggestion’: return { …state, suggestion: [], }; }};
08-联想关键词高亮
目标:能够让联想搜索关键词高亮
分析说明:
比如,搜索内容为 ‘1’,接口返回的数据:[‘1’, ‘012’, ‘1.11’, ‘01’, ‘18’],约定让每一项联想建议的第一个 ‘1’ 高亮
那么,就要找到 ‘1’ 的位置,然后,分别得到 ‘1’ 前面的内容 和 ‘1’ 后面的内容。比如,以 ‘012’ 为例:
{ left: ‘0’, search: ‘1’, // 高亮 right: ‘2’,}
注意:如果搜索关键词为 ‘mac’,搜索结果中会包含 ‘MAC’,所以,为了在这种情况下也实现高亮,应该忽略大小写
步骤:
- 遍历 suggestion 数组,创建一个新的带有高亮关键字的联想结果
- 将每一项联想内容和当前搜索内容转小写
- 找到搜索内容在联想内容中的位置
- 分别获取到左侧、右侧以及搜索内容对应的真实联想内容
- 渲染带有高亮关键字的联想结果
核心代码:
Search/index.tsx 中:
const SearchPage = () => { // … const highlightSuggestion = suggestion.map((item) => { const lowerCaseItem = item.toLocaleLowerCase(); const lowerCaseSearchTxt = searchTxt.toLocaleLowerCase(); const index = lowerCaseItem.indexOf(lowerCaseSearchTxt); const searchTxtLength = searchTxt.length; const left = item.slice(0, index); const right = item.slice(index + searchTxtLength); const search = item.slice(index, index + searchTxtLength); return { left, right, search, }; }); return ( // …
09-跳转到搜索结果页面
目标:能够点击搜索关键词跳转到结果页面
步骤:
- 为联想列表项绑定点击事件
- 清空联想建议
- 在点击事件中拿到联想关键词,跳转到结果页面,同时传递联想关键词
- 为搜索按钮绑定点击事件
- 在点击事件中,拿到当前搜索内容,跳转到结果页面,同时传递搜索内容
核心代码:
Search/index.tsx 中:
const SearchPage = () => { // … const onSearch = (value: string) => { dispatch(clearSuggestion()) history.push(/search/result?q=${value}
) } return ( // … onSearch(searchTxt)}> 搜索
10-搜索历史记录
目标:能够将搜索内容保存到历史记录
步骤:
- 创建保存历史记录的函数 saveHistories
- 从本地缓存中获取到历史记录,判断本地缓存中是否有历史记录数据
- 如果没有,直接添加当前搜索内容到历史记录中
- 如果有,判断是否包含当前搜索内容
- 如果没有包含,直接添加到历史记录中
- 如果包含,将其移动到第一个
- 将最新的历史记录存储到本地缓存中
核心代码:
Search/index.tsx 中:
const SearchPage = () => { const onSearch = (value: string) => { // … saveHistories(value); }; const saveHistories = (value: string) => { const localHistories = JSON.parse( localStorage.getItem(GEEK_SEARCH_KEY) ?? ‘[]’, ) as string[]; let histories = []; if (localHistories.length === 0) { // 没有 histories = [value]; } else { // 有 const exist = localHistories.indexOf(value) >= 0; if (exist) { // 存在 const leftHistories = localHistories.filter((item) => item !== value); histories = [value, …leftHistories]; } else { // 不存在 histories = [value, …localHistories]; } } localStorage.setItem(GEEK_SEARCH_KEY, JSON.stringify(histories)); };};
11-渲染历史记录
目标:能够在进入搜索页面时渲染历史记录
步骤:
- 创建存储历史记录的状态
- 在进入页面时,从本地缓存中获取历史记录,并更新状态
- 根据是否有历史记录来决定是否展示历史记录内容
- 遍历历史记录数据,渲染列表
核心代码:
Search/index.tsx 中:
import { useEffect } from ‘react’;const SearchPage = () => { const [searchHistory, setSearchHistory] = useState
12-删除和清空历史记录
目标:能够实现删除搜索历史记录
步骤:
- 为历史记录列表项的删除按钮绑定点击事件
- 在点击事件中删除当前历史记录,并更新到本地缓存中
- 为清除全部按钮绑定点击事件
- 在点击事件中清空历史记录,并移除本地缓存
- 判断是否有历史记录如果有,才展示历史记录内容
核心代码:
Search/index.tsx 中:
const SearchPage = () => { // … const onDeleteHistory = (value: string) => { const newSearchHistory = searchHistory.filter((item) => item !== value); setSearchHistory(newSearchHistory); localStorage.setItem(GEEK_SEARCH_KEY, JSON.stringify(newSearchHistory)); }; const onClearHistory = () => { setSearchHistory([]); localStorage.removeItem(GEEK_SEARCH_KEY); }; return (
13-获取搜索结果数据
目标:能够获取搜索结果数据
分析说明:
可以通过 DOM 自带的 URLSearchParams 来获取查询参数,也就是 URL 地址中 ? 后面的参数
参考:MDN URLSearchParams
步骤:
- 进入页面时,获取到搜索内容
- 调用自定义 hook,分发 action 准备获取搜索结果数据,并传递搜索内容给 action
- 根据接口,在 types 中创建搜索结果数据的类型
- 在 action 中发送请求获取搜索结果数据
- 在 types 中添加相应的 action 类型
- 分发 action 将搜索结果数据保存到 redux 中
- 在 redcuer 中处理 action 以更新状态
核心代码:
Result/index.tsx 中:
const Result = () => { // … const params = new URLSearchParams(location.search); const q = params.get(‘q’) ?? ‘’; const { searchResults } = useInitialState(() => getSearchResult(q), ‘search’); const { results } = searchResults; const renderArticleList = () => { return results.map((item, index) => { // … }); }; // …};
types/data.d.ts 中:
// 搜索结果export type SearchResult = { page: number; per_page: number; total_count: number; results: Articles[‘results’];};export type SearchResultResponse = ApiResponse
types/store.d.ts 中:
export type SearchAction = // … { type: ‘search/getSearchResult’; payload: SearchResult };
actions/search.ts 中:
export const getSearchResult = (query: string): RootThunkAction => { return async (dispatch) => { const res = await http.get
reducers/search.ts 中:
import { Suggestion } from ‘@/types/data’; type SearchState = { // … searchResults: SearchResult;}; const initialState: SearchState = { // … searchResults: { page: 1, per_page: 10, total_count: 0, results: [], },}; const Search = (state = initialState, action: SearchAction): SearchState => { switch (action.type) { // … case ‘search/getSearchResult’: return { …state, searchResults: action.payload, }; }};
14-优化:useInitialState
目标:能够优化获取状态数据的自定义 hook 只发送一次请求
分析说明:
问题:Result 组件中使用 useInitialState 自定义 hook 会重复发送请求
针对该问题的分析过程:
- 定位出问题的代码位置
- 既然造成了重复请求,说明 dispatch 分发 action 的代码重复执行了。可以通过 console.log 来确认,是否会重复执行
- 分析原因
- dispatch 是在 useEffect hook 中执行的,说明 effect 重复执行。而 effect 重复执行的原因只有一个,就是:依赖项发生改变
- 第一个依赖项 dispatch 函数是不变的
- 只能是第二个依赖项 action 函数改变了
- 确认分析是否正确
- Result 组件中 useInitialState 重复执行,也就是重复更新了状态,每次更新状态都会导致组件重新渲染
- 组件重新渲染时,会重新执行组件中的所有代码
- 而我们传递给 useInitialState hook 的第一个回调函数,每次都会重新创建
const Result = () => { // const { searchResults } = useInitialState(() => getSearchResult(q), ‘search’) // 这种调用方式,等价于: const action = () => getSearchResult(q); const { searchResults } = useInitialState(action, ‘search’);};
总结出现问题的原因:
- 每次组件更新都会给 useInitialState 传递新创建的 action 回调函数,
- 导致自定义 hook 内部 useEffect 的依赖项发生改变,effect 重新执行。
给出解决方案
- 保持传入的 action 引用不变,将 action 从 useEffect 的依赖项中移除
- 如何实现? useRef hook
步骤:
- 导入 useRef hook
- 创建 ref 对象,默认值为 action 参数
- 在 useEffect 中调用 ref 引用的函数
- 从依赖项中去掉 action
核心代码:
import { useRef } from ‘react’; const useInitialState =
// 使用:const Result = () => { // 注意:回调函数必须返回 action 函数 const { searchResults } = useInitialState(() => getSearchResult(q), ‘search’);};
15-使用 Image 组件实现图片懒加载
目标:能够使用 Image 组件优化图片展示
核心代码:
ArticleItem/index.tsx 中:
import { Image } from ‘antd-mobile’; const ArticleItem = () => { return ( // … );};