01-路由和页面结构

目标:能够根据模板搭建搜索页面结构
步骤

  1. 将搜索页面的模板拷贝到 pages 目录中
  2. 在 App 组件中分别配置搜索和搜索结果页面的路由
  3. 在 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 ( // … history.push(‘/search’)} /> );};

02-Search 组件基本使用

目标:能够使用 antd-mobile 组件库中的 Search 组件步骤

  1. 创建状态,通过受控组件方式获取 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-获取搜索联想关键词

目标:能够在搜索框输入内容时获取搜索联想关键词
步骤

  1. 在搜索框的 change 事件中分发 action 获取搜索联想关键词
  2. 创建 actions/search.ts 并创建获取搜索联想关键词的函数
  3. 根据接口,在 types 中添加联想关键词返回类型
  4. 在搜索联想关键词的 aciton 中发送请求获取联想关键词
  5. 在 types 中添加保存联想关键词状态的 action 类型
  6. 分发 action 将联想关键词状态保存到 redux 中
  7. 创建 reducers/search.ts 并合并到根 reducer 中
  8. 在 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(‘/suggestion’, { params: { q: value, }, }); dispatch({ type: ‘search/suggestion’, payload: res.data.data.options }); };};
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-搜索输入时防抖

目标:能够在搜索框中输入内容时进行防抖处理
分析说明
实际开发中,直接借助第三方库来实现防抖功能即可。有两种方式:

  1. lodash 的 debounce 函数
  2. 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 void>>(null) // 只要去掉 默认值 ,ref 中的 current 属性就不是只读属性了 const debounceFnRef = useRef void>>(); // 如果每次输入内容都更新了状态,就会导致每次输入内容该组件重新渲染 // 而该组件重新渲染会让组件中的代码重新执行 // 代码重新执行,就会导致 防抖函数 重复创建,这样的话,每次输入后拿到的都是新创建的防抖函数 // 也就是有多个防抖函数了。而多个防抖函数之间是不会相互防抖的! // 正确的操作:不管组件更新多少次,都应该只创建一次防抖函数,才能实现防抖功能 const debounceFn = debounce((value) => { dispatch(getSuggestion(value)); }, 500); // 判断 ref 中是否已经有值,如果没有值,就添加值;如果有了,就不再添加了 if (!debounceFnRef.current) { debounceFnRef.current = debounceFn; } // 搜索 const onSearchChange = (value: string) => { setSearchText(value); if (value.trim() === ‘’) return; // 调用防抖函数 debounceFnRef.current?.(value); }; return ( );};
再来看第二种方案:
const { // 防抖函数 run,} = useDebounceFn( // 需要防抖执行的函数 fn, // 配置防抖的配置项,比如,设置超时时间 options,);
注意:由于 antd-mobile 组件库依赖了 ahooks 并且是 ahooks@2.10.14 版本,所以,在安装 ahooks 时需要明确指定版本;否则,会导致报错
步骤

  1. 安装 ahooks 包:yarn add ahooks@2.10.14
  2. 导入 useDebounceFn hook
  3. 创建防抖函数
  4. 搜索框中输入内容时,调用防抖函数

核心代码
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-渲染联想关键词

目标:能够渲染联想关键词列表
步骤

  1. 获取联想关键词的状态
  2. 判断是否有联想关键词,有的话添加 show 类名,来展示列表
  3. 遍历联想关键词并渲染

核心代码
Search/index.tsx 中:
const SearchPage = () => { const { suggestion } = useSelector((state: RootState) => state.search); return ( // …

0 ? ‘show’ : ‘’, )} > {suggestion.map((item, index) => (
{item}
))}
);};

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’,所以,为了在这种情况下也实现高亮,应该忽略大小写
步骤

  1. 遍历 suggestion 数组,创建一个新的带有高亮关键字的联想结果
  2. 将每一项联想内容和当前搜索内容转小写
  3. 找到搜索内容在联想内容中的位置
  4. 分别获取到左侧、右侧以及搜索内容对应的真实联想内容
  5. 渲染带有高亮关键字的联想结果

核心代码
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 ( // …

0 ? ‘show’ : ‘’, )} > {highlightSuggestion.map((item, index) => (
{item.left} {/ 放在 span 中的内容会高亮 /} {item.search} {item.right}
))}
);};

09-跳转到搜索结果页面

目标:能够点击搜索关键词跳转到结果页面
步骤

  1. 为联想列表项绑定点击事件
  2. 清空联想建议
  3. 在点击事件中拿到联想关键词,跳转到结果页面,同时传递联想关键词
  4. 为搜索按钮绑定点击事件
  5. 在点击事件中,拿到当前搜索内容,跳转到结果页面,同时传递搜索内容

核心代码
Search/index.tsx 中:
const SearchPage = () => { // … const onSearch = (value: string) => { dispatch(clearSuggestion()) history.push(/search/result?q=${value}) } return ( // … onSearch(searchTxt)}> 搜索

{highlightSuggestion.map((item, index) => (
onSearch(item.left + item.search + item.right)} >
))}
)}

10-搜索历史记录

目标:能够将搜索内容保存到历史记录
步骤

  1. 创建保存历史记录的函数 saveHistories
  2. 从本地缓存中获取到历史记录,判断本地缓存中是否有历史记录数据
  3. 如果没有,直接添加当前搜索内容到历史记录中
  4. 如果有,判断是否包含当前搜索内容
  5. 如果没有包含,直接添加到历史记录中
  6. 如果包含,将其移动到第一个
  7. 将最新的历史记录存储到本地缓存中

核心代码
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-渲染历史记录

目标:能够在进入搜索页面时渲染历史记录
步骤

  1. 创建存储历史记录的状态
  2. 在进入页面时,从本地缓存中获取历史记录,并更新状态
  3. 根据是否有历史记录来决定是否展示历史记录内容
  4. 遍历历史记录数据,渲染列表

核心代码
Search/index.tsx 中:
import { useEffect } from ‘react’;const SearchPage = () => { const [searchHistory, setSearchHistory] = useState([]); useEffect(() => { const histories = JSON.parse( localStorage.getItem(GEEK_SEARCH_KEY) ?? ‘[]’, ) as string[]; setSearchHistory(histories); }, []); return ( // …

0 ? ‘none’ : ‘block’, }} > // …
{searchHistory.map((item, index) => (
{item}
))}
);};

12-删除和清空历史记录

目标:能够实现删除搜索历史记录
步骤

  1. 为历史记录列表项的删除按钮绑定点击事件
  2. 在点击事件中删除当前历史记录,并更新到本地缓存中
  3. 为清除全部按钮绑定点击事件
  4. 在点击事件中清空历史记录,并移除本地缓存
  5. 判断是否有历史记录如果有,才展示历史记录内容

核心代码
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 (

// … {searchHistory.length > 0 && (
// … 清除全部
// … onDeleteHistory(item)} />
)}
);};

13-获取搜索结果数据

目标:能够获取搜索结果数据
分析说明
可以通过 DOM 自带的 URLSearchParams 来获取查询参数,也就是 URL 地址中 ? 后面的参数
参考:MDN URLSearchParams
步骤

  1. 进入页面时,获取到搜索内容
  2. 调用自定义 hook,分发 action 准备获取搜索结果数据,并传递搜索内容给 action
  3. 根据接口,在 types 中创建搜索结果数据的类型
  4. 在 action 中发送请求获取搜索结果数据
  5. 在 types 中添加相应的 action 类型
  6. 分发 action 将搜索结果数据保存到 redux 中
  7. 在 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(‘/search’, { params: { q: query, page: 1, }, }); dispatch({ type: ‘search/getSearchResult’, payload: res.data.data }); };};
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 会重复发送请求
针对该问题的分析过程:

  1. 定位出问题的代码位置
    • 既然造成了重复请求,说明 dispatch 分发 action 的代码重复执行了。可以通过 console.log 来确认,是否会重复执行
  2. 分析原因
    • dispatch 是在 useEffect hook 中执行的,说明 effect 重复执行。而 effect 重复执行的原因只有一个,就是:依赖项发生改变
    • 第一个依赖项 dispatch 函数是不变的
    • 只能是第二个依赖项 action 函数改变了
  3. 确认分析是否正确
    • 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

步骤

  1. 导入 useRef hook
  2. 创建 ref 对象,默认值为 action 参数
  3. 在 useEffect 中调用 ref 引用的函数
  4. 从依赖项中去掉 action

核心代码
import { useRef } from ‘react’; const useInitialState = ( action: () => void, stateName: StateName,) => { // … const actionRef = useRef(action); useEffect(() => { const actionFn = actionRef.current; dispatch(actionFn()); }, [dispatch]); // …};
// 使用:const Result = () => { // 注意:回调函数必须返回 action 函数 const { searchResults } = useInitialState(() => getSearchResult(q), ‘search’);};

15-使用 Image 组件实现图片懒加载

目标:能够使用 Image 组件优化图片展示
核心代码
ArticleItem/index.tsx 中:
import { Image } from ‘antd-mobile’; const ArticleItem = () => { return ( // … 搜索 - 图1 );};