以结果为导向,写给刚学完前端三剑客和想要了解 React 框架的小伙伴,使得他们能快速上手(省略了历史以及一些不必要的介绍)。
状态进阶
如果你不熟悉 JS 中的 Reducer,可以先看这篇博客:What is a Reducer in JavaScript/React/Redux
我们可以使用 useReducer hook 替换掉大量的 useState hook 来简化【状态管理】,先在组件外部引入一个 reducer 函数,通过接收两个参数 state 和 action 返回一个新的 state。
// (state, action) => newState const storiesReducer = (state, action) => { if (action.type === "SET_STORIES") { return action.payload; } else { throw new Error(); } }; 复制代码
useReducer hook 接收一个 reducer 函数和一个初始 state 作为参数,与 useState 类似,它返回一个包含两项内容的数组:
- 第一项是当前 state
- 第二项是用于更新 state 的函数(也叫 dispatch 函数)
// 替换掉 const [stories, setStories] = React.useState([]); const [stories, dispatchStories] = React.useRe ducer(storiesReducer, []); 复制代码
用新的 dispatch 函数替换掉原本的 setStories 函数:
const App = () => { ... const [stories, dispatchStories] = React.useReducer(storiesReducer, []); React.useEffect(() => { setIsLoading(true); getAsyncStories() .then((res) => { // 传递一个 action 对象 dispatchStories({ type: "SET_STORIES", payload: res.data.stories, }); setIsLoading(false); }) .catch(() => setIsError(true)); }, []); const handleRemoveStory = (item) => { const newStories = stories.filter( (story) => item.objectID !== story.objectID ); dispatchStories({ type: "SET_STORIES", payload: newStories, }); }; ... }; 复制代码
与 useState 的更新函数不同,dispatch 函数需要传递给 reducer 一个【action 对象】,其中包含 type 和可选的 payload,以便 reducer 进行匹配。
我们还可以把功能封装在 reducer 中,然后用 action 进行多个 state 的管理:
const storiesReducer = (state, action) => { // 使用 switch 语句增加代码可读性 switch (action.type) { case "SET_STORIES": return action.payload; case "REMOVE_STORY": // 返回一个新的 state return state.filter( (story) => action.payload.objectID !== story.objectID ); default: throw new Error(); } }; const App = () => { ... const handleRemoveStory = (item) => { dispatchStories({ type: "REMOVE_STORY", payload: item, }); }; ... }; 复制代码
当我们连续使用了 state 更新函数后,就有可能【导致不合理状态】,从而引发 UI 的问题。
如果我们获取数据出错:
// 模拟没有获取到数据 const getAsyncStories = () => new Promise((resolve, reject) => setTimeout(reject, 2000)); getAsyncStories().catch(() => setIsError(true)); 复制代码
你会看到屏幕上同时显示了错误信息和无休止的加载信息,也就是说 isError 更新了,但 isLoading 没有更新,这显然是不合理的。
为了避免这种情况,我们可以把这些状态合并到同一个 useReducer hook 中,之后所有异步数据的相关操作都通过 dispatch 函数来更新:
const storiesReducer = (state, action) => { switch (action.type) { case "STORIES_FETCH_INIT": return { ...state, isLoading: true, isError: false, }; case "STORIES_FETCH_SUCCESS": return { ...state, isLoading: false, isError: false, data: action.payload, }; case "STORIES_FETCH_FAILURE": return { ...state, isLoading: false, isError: true, }; case "REMOVE_STORY": return { ...state, // 需要操作 state.data 而不是 state data: state.data.filter( (story) => action.payload.objectID !== story.objectID ), }; default: throw new Error(); } }; const App = () => { ... const [stories, dispatchStories] = React.useReducer( storiesReducer, { data: [], isLoading: false, isError: false, } ); React.useEffect(() => { dispatchStories({ type: "STORIES_FETCH_INIT" }); getAsyncStories() .then((result) => { dispatchStories({ type: "STORIES_FETCH_SUCCESS", payload: result.data.stories, }); }) .catch(() => dispatchStories({ type: "STORIES_FETCH_FAILURE" })); }, []); const handleRemoveStory = (item) => { dispatchStories({ type: "REMOVE_STORY", payload: item, }); }; ... // stories.data const searchedStories = stories.data.filter((story) => story.title.toLowerCase().includes(searchTerm.toLowerCase()) ); return ( <> ... {stories.isError && <p>Something went wrong ...</p>} {stories.isLoading ? ( <p>Loading...</p> ) : ( <List list={searchedStories} onRemoveItem={handleRemoveStory} /> )} </> ); }; 复制代码
每次转换 state 都会返回一个新的 state 对象,其中包含【被新属性值覆盖的】当前 state 的全部键值对(展开运算符)。
获取数据
使用真正的第三方API Hacker News API 来获取数据,删掉原来的 initialStories 和 getAsyncStories 函数:
const API_ENDPOINT = 'https://hn.algolia.com/api/v1/search?query='; const App = () => { ... React.useEffect(() => { dispatchStories({ type: "STORIES_FETCH_INIT" }); // query=react fetch(`${API_ENDPOINT}react`) .then(response => response.json()) .then((result) => { dispatchStories({ type: "STORIES_FETCH_SUCCESS", payload: result.hits, // API数据 }); }) .catch(() => dispatchStories({ type: "STORIES_FETCH_FAILURE" })); }, []); ... }; 复制代码
通过 fetch API 获取 React 相关的新闻数据,将数据转换为 JSON 发送给组件 state。
我们可以将原有的搜索功能升级一下,从客户端搜索改为服务端搜索,用 searchTerm 作为动态查询条件请求 API,获取一组被服务器筛选的列表:
const App = () => { const [searchTerm, setSearchTerm] = useSemiPersistentState( "search", "React" // 初始值为 React ); ... React.useEffect(() => { // 也可以写成 if (!searchTerm) if (searchTerm === "") return; dispatchStories({ type: "STORIES_FETCH_INIT" }); // query={searchTerm} fetch(`${API_ENDPOINT}${searchTerm}`) .then((response) => response.json()) .then((result) => { dispatchStories({ type: "STORIES_FETCH_SUCCESS", payload: result.hits, }); }) .catch(() => dispatchStories({ type: "STORIES_FETCH_FAILURE" })); }, [searchTerm]); // 依赖数组改变 // 删掉跟 searchedStories 有关的函数 ... return ( <> ... {stories.isLoading ? ( <p>Loading...</p> ) : ( {/* 传递常规数据 */} <List list={stories.data} onRemoveItem={handleRemoveStory} /> )} </> ); }; 复制代码
searchTerm 就是输入框输入的值:
- 初始值为 React,组件加载时
- 值为空,就什么也不做
- 每次值改变时,都执行副作用函数获取相关数据
也就是说我们【每次在输入框输入内容都会重新获取一次数据】,这个功能完全在服务端完成,但这样频繁的调用 API 可能会导致第三方的限速,接口会返回错误。
一个简单的解决方式就是【加一个确认按钮】,当点击按钮时才会重新获取数据:
const App = () => { const [searchTerm, setSearchTerm] = useSemiPersistentState("search", "React"); const [url, setUrl] = React.useState(`${API_ENDPOINT}${searchTerm}`); ... React.useEffect(() => { // 删掉 if (searchTerm === "") return; dispatchStories({ type: "STORIES_FETCH_INIT" }); fetch(url) ... }, [url]); ... const handleSearch = (e) => { setSearchTerm(e.target.value); }; // 点击按钮的处理函数 const handleSearchSubmit = () => { setUrl(`${API_ENDPOINT}${searchTerm}`); }; return ( <> ... {/* 增加一个按钮 */} <button disabled={!searchTerm} onClick={handleSearchSubmit}> Submit </button> <hr /> ... </> ); }; 复制代码
现在 searchTerm 仅用于更新输入框的值,url 代替了它获取数据的功能,当用户点击了提交按钮,url 会更新并调用副作用函数获取新的数据。
我们还可以通过 useCallback hook 进行优化(可跳过):
const App = () => { ... const handleFetchStories = React.useCallback(() => { dispatchStories({ type: "STORIES_FETCH_INIT" }); fetch(url) .then((response) => response.json()) .then((result) => { dispatchStories({ type: "STORIES_FETCH_SUCCESS", payload: result.hits, }); }) .catch(() => dispatchStories({ type: "STORIES_FETCH_FAILURE" })); }, [url]); React.useEffect(() => { handleFetchStories(); }, [handleFetchStories]); ... }; 复制代码
axios
原生浏览器提供的 fetch API 并不适用于所有情况,尤其是对于一些老版本的浏览器,所以我们决定使用一个稳定的库 axios 来替代 fetch API 完成异步数据的获取。
- 首先通过 npm 安装:
npm install axios 复制代码
- 引入到 App 文件中:
import axios from 'axios'; 复制代码
- 使用 axios 代替 fetch:
const handleFetchStories = React.useCallback(() => { dispatchStories({ type: "STORIES_FETCH_INIT" }); axios.get(url) .then((result) => { dispatchStories({ type: "STORIES_FETCH_SUCCESS", payload: result.data.hits, // 注意 }); }) .catch(() => dispatchStories({ type: "STORIES_FETCH_FAILURE" })); }, [url]); 复制代码
与 fetch 相似,它将 url 作为参数并返回一个 promise 对象,同时因为它把结果包装成了 JS 的数据对象,所以并不需要将返回的结果转换为 JSON。
表单
表单在 React 和 HTML 中并没有太大区别,我们只需要将 handleSearchSubmit()
绑定在 form 元素上,再把按钮的 type 属性设置为 submit 就好了:
const App = () => { ... return ( <> <form onsubmit={handleSearchSubmit}> <InputWithLabel id="search" value={searchTerm} onInputChange={handleSearch} isFocused > <strong>Search:</strong> </InputWithLabel> <button disabled={!searchTerm} type="submit"> Submit </button> </form> <hr /> ... </> ); }; 复制代码
这样我们就可以使用 Enter 键进行搜索了,也别忘了阻止浏览器刷新:
const handleSearchSubmit = (e) => { e.preventDefault(); setUrl(`${API_ENDPOINT}${searchTerm}`); }; 复制代码
继续将 form 提取为独立的 SearchForm 组件,同样引入到 App 组件中:
const App = () => { ... return ( <> <h1>Hacker Stories</h1> <SearchForm searchTerm={searchTerm} onSearch={handleSearch} onSearchSubmit={handleSearchSubmit} /> <hr /> ... </> ); }; const SearchForm = ({ searchTerm, onSearch, onSearchSubmit }) => ( <form onsubmit={onSearchSubmit}> <InputWithLabel id="search" value={searchTerm} onInputChange={onSearch} isFocused > <strong>Search:</strong> </InputWithLabel> <button disabled={!searchTerm} type="submit" className="btn"> Submit </button> </form> ); 复制代码
样式
React 中写样式的方法有很多种,我们这里只讨论最常见的 CSS 样式,与标准 CSS 的 class 属性类似,React 为每个元素都提供了一个 className 属性,可以通过它在 CSS 文件中设置样式。
... return ( <div className="container"> <h1 className="headline">Hacker Stories</h1> <SearchForm searchTerm={searchTerm} onSearch={handleSearch} onSearchSubmit={handleSearchSubmit} /> {stories.isError && <p>Something went wrong ...</p>} {stories.isLoading ? ( <p>Loading...</p> ) : ( <List list={stories.data} onRemoveItem={handleRemoveStory} /> )} </div> ); }; 复制代码
因为我们使用了 create-react-app 来创建应用,所以你会看到 src/App.css 文件以及它的导入语句:
import './App.css'; 复制代码
像这样修改它们的样式,你也可以仿照我写的代码:
body { background: linear-gradient(to left, #b6fbff, #83a4d4); color: #171212; } .container { padding: 20px; } .headline { font-size: 48px; letter-spacing: 2px; } 复制代码
我们的教程到这差不多结束了,之后就需要你自行探索了😎
有几个方向可以供你学习:
- 配置 Sass
- CSS Modules
- CSS in JS
- 部署应用
- React 性能
- TypeScript
- 测试
- ...