復雜狀態處理: 如何保持狀態一致性
1: 保證狀態最小化
在保證 State 完整性的同時,也要保證它的最小化
: 某些數據如果能從已有的 State 中計算得到, 那么我們應該始終在用的時候去計算, 而不是把計算的結果存到某個 State 中, 這樣, 才能簡化我們的狀態處理邏輯
function FilterList({ data }) {
// 設置關鍵字的 State
const [searchKey, setSearchKey] = useState('');
// 設置最終要展示的數據狀態,并用原始數據作為初始值
const [filtered, setFiltered] = useState(data);
// 處理用戶的搜索關鍵字
const handleSearch = useCallback(evt => {
setSearchKey(evt.target.value);
setFiltered(data.filter(item => {
return item.title.includes(evt.target.value)));
}));
}, [filtered])
return (
<div>
<input value={searchKey} onChange={handleSearch} />
{/* 根據 filtered 數據渲染 UI */}
</div>
);
}
// 一致性, 根據 data 關鍵字, 來緩存 filter 的值
function FilterList({ data }) {
const [searchKey, setSearchKey] = useState("");
// 每當 searchKey 或者 data 變化的時候,重新計算最終結果
const filtered = useMemo(() => {
return data.filter((item) =>
item.title.toLowerCase().includes(searchKey.toLowerCase())
);
}, [searchKey, data]);
return (
<div className="08-filter-list">
<h2>Movies</h2>
<input
value={searchKey}
placeholder="Search..."
onChange={(evt) => setSearchKey(evt.target.value)}
/>
<ul style={{ marginTop: 20 }}>
{filtered.map((item) => (
<li key={item.id}>{item.title}</li>
))}
</ul>
</div>
);
}
2. 避免中間狀態, 確保唯一數據源
在有的場景下,特別是原始狀態數據來自某個外部數據源,而非 state 或者 props
的時候,冗余狀態就沒那么明顯。這時候你就需要準確定位狀態的數據源究竟是什么,并且在開發中確保它始終是唯一的數據源,以此避免定義中間狀態
異步處理: 如何向服務器發送請求
- 實現自己的 API Client
無論大小項目,在開始實現第一個請求的時候,通常我們要做的第一件事應該都是創建一個自己的 API Client
,之后所有的請求都會通過這個 Client 發出去。而不是上來就用 fetch 或者是 axios 去直接發起請求
,因為那會造成大量的重復代碼
可以對你需要連接的服務端做一些通用的配置和處理,比如 Token、URL、錯誤處理等等
- 通用的 Header, 比如:
Authorization Token
- 服務器地址的配置
- 請求未認證, 錯誤處理等
import axios from "axios"
// 定義相關的 endpoint
const endPoints = {
test: "https://api.io/",
prod: "https://prod.myapi.io/",
staging: "https://staging.myapi.io/",
}
// 創建 axios 的實例
const instance = axios.create({
// 實際項目中根據當前環境設置 baseURL
baseURL: endPoints.test,
timeout: 30000,
// 為所有請求設置通用的 header
headers: { Authorization: "Bear mytoken" },
})
// 聽過 axios 定義攔截器預處理所有請求
instance.interceptors.response.use(
(res) => {
// 可以假如請求成功的邏輯,比如 log
return res
},
(err) => {
if (err.response.status === 403) {
// 統一處理未授權請求,跳轉到登錄界面
document.location = "/login"
}
return Promise.reject(err)
}
)
export default instance
- 使用 Hooks 思考異步請求, 封裝遠程資源
- Data: 請求成功后的數據
- Error: 請求失敗, 錯誤信息
- Pending: loading
上面三個狀態, 我們可以在 UI 上做一些處理, 寫一個 Hook
import { useState, useEffect } from "react"
import apiClient from "./apiClient"
// 將獲取文章的 API 封裝成一個遠程資源 Hook
const useArticle = (id) => {
// 設置三個狀態分別存儲 data, error, loading
const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
// 重新獲取數據時重置三個狀態
setLoading(true)
setData(null)
setError(null)
apiClient
.get(`/posts/${id}`)
.then((res) => {
// 請求成功時設置返回數據到狀態
setLoading(false)
setData(res.data)
})
.catch((err) => {
// 請求失敗時設置錯誤狀態
setLoading(false)
setError(err)
})
}, [id]) // 當 id 變化時重新獲取數據
// 將三個狀態作為 Hook 的返回值
return {
loading,
error,
data,
}
}
多個 API 調用, 如何處理并發或串行請求?
例如: 需要顯示作者、作者頭像,以及文章的評論列表, 需要發送三個請求 GetAvatar GetAuthor GetComments
Promise.all([fetch1, fetch2])
傳統思路, React 函數組件是一個同步的函數, 沒法直接使用 await
, 而是要把請求通過副作用去觸發
從狀態變化的角度去組織異步調用, 通過不同的狀態組合
,來實現異步請求的邏輯
import { useState, useEffect } from "react"
import apiClient from "./apiClient"
export default (id) => {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
// 當 id 不存在,直接返回,不發送請求
if (!id) return
setLoading(true)
setData(null)
setError(null)
apiClient
.get(`/users/${id}`)
.then((res) => {
setLoading(false)
setData(res.data)
})
.catch((err) => {
setLoading(false)
setError(err)
})
}, [id])
return {
loading,
error,
data,
}
}
import useArticle from "./useArticle"
import useUser from "./useUser"
import useComments from "./useComments"
const ArticleView = ({ id }) => {
// article comments 并行
const { data: article, loading, error } = useArticle(id)
const { data: comments } = useComments(id)
// 串行的請求
const { data: user } = useUser(article?.userId)
if (error) return "Failed."
if (!article || loading) return "Loading..."
return (
<div className='exp-09-article-view'>
<h1>
{id}. {article.title}
</h1>
{user && (
<div className='user-info'>
<img src={user.avatar} height='40px' alt='user' />
<div>{user.name}</div>
<div>{article.createdAt}</div>
</div>
)}
<p>{article.content}</p>
<CommentList data={comments || []} />
</div>
)
}
函數組件設計模式:如何應對復雜條件渲染場景?
- 容器模式: 實現按條件執行 Hooks
Hooks 必須在頂層作用域調用,而不能放在條件判斷、循環等語句中,同時也不能在可能的 return 語句之后執行。換句話說,Hooks 必須按順序被執行到。
但假如我們希望實現一下 Modal, 像下面代碼會報錯
import { Modal } from "antd"
import useUser from "useUser"
function UserInfoModal({ visible, userId, ...rest }) {
// 當 visible 為 false 時,不渲染任何內容
if (!visible) return null
// 這一行 Hook 在可能的 return 之后,會報錯!
const { data, loading, error } = useUser(userId)
return (
<Modal visible={visible} {...rest}>
{/* 對話框的內容 */}
</Modal>
)
}
我們可以使用容器模式: 把條件判斷的結果放到兩個組件之中,確保真正 render UI 的組件收到的所有屬性都是有值的
// 定義一個容器組件用于封裝真正的 UserInfoModal
export default function UserInfoModalWrapper({
visible,
...rest // 使用 rest 獲取除了 visible 之外的屬性
}) {
// 如果對話框不顯示,則不 render 任何內容
if (!visible) return null
// 否則真正執行對話框的組件邏輯
return <UserInfoModal visible {...rest} />
}
把判斷條件放到 Hooks 中去
const ArticleView = ({ id }) => {
const { data: article, loading } = useArticle(id)
let user = null
// Hook 不能放到條件語句中,那我們應該如何做呢
if (article?.userId) user = useUser(article?.userId).data
// 組件其它邏輯
}
function useUser(id) {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
// 當 id 不存在,直接返回,不發送請求
if (!id) return
// 獲取用戶信息的邏輯
})
}
- render props 模式重用 UI 邏輯
render props
就是把一個 render 函數作為屬性傳遞給某個組件,由這個組件去執行這個函數從而 render 實際的內容。
在 Class 組件時期,render props 和 HOC(高階組件)兩種模式可以說是進行邏輯重用的兩把利器,但是實際上,HOC 的所有場景幾乎都可以用 render props 來實現。可以說,Hooks 是邏輯重用的第一選擇。
舉例演示: 計數器, 演示純數據邏輯的重用, 就是重用的業務邏輯自己不產生任何 UI
import { useState, useCallback } from "react"
// 把計數邏輯封裝到一個自己不 render 任何 UI 的組件中
function CounterRenderProps({ children }) {
const [count, setCount] = useState(0)
const increment = useCallback(() => {
setCount(count + 1)
}, [count])
const decrement = useCallback(() => {
setCount(count - 1)
}, [count])
return children({ count, increment, decrement })
}
function CounterRenderPropsExample() {
return (
// children 這個特殊屬性。也就是組件開始 tag 和結束 tag 之間的內容,其實是會作為 children 屬性傳遞給組件
<CounterRenderProps>
{({ count, increment, decrement }) => {
return (
<div>
<button onClick={decrement}>-</button>
<span>{count}</span>
<button onClick={increment}>+</button>
</div>
)
}}
</CounterRenderProps>
)
}
在上面這種場景下, Hooks 更方便
import { useState, useCallback } from "react"
function useCounter() {
// 定義 count 這個 state 用于保存當前數值
const [count, setCount] = useState(0)
// 實現加 1 的操作
const increment = useCallback(() => setCount(count + 1), [count])
// 實現減 1 的操作
const decrement = useCallback(() => setCount(count - 1), [count])
// 將業務邏輯的操作 export 出去供調用者使用
return { count, increment, decrement }
}
事件處理: 如何創建自定義事件
- React 中使用原生事件: 約定使用駱駝體 (onMouseOver, onChange) 等
- React 原生事件的原理: 合成事件 由于虛擬 DOM 的存在, 在 React 綁定一個事件到原生的 DOM 節點, 事件也不會綁定在對應的節點上, 而是所有的事件都綁定在根節點上. 然后由 React 統一監聽和管理, 代理模式, 分發到具體的虛擬 DOM 上
- React17 版本前: 綁定在 document, 之后, 綁定在整個 App 上的根節點上
- 虛擬 DOM render 的時候, DOM 可能還沒有真實的 render 到頁面上, 所以無法綁定事件
- React 屏蔽底層事件的細節, 避免瀏覽器兼容問題
創建自定義事件
- 原生事件是瀏覽器機制
- 自定義事件是組件自己的行為, 本質是一種回調機制
- 通過
props 給組件傳遞一個回調函數
,然后在組件中的某個時機,比如用戶輸入,或者某個請求完成時,去調用這個傳過來的回調函數就可以了 - 習慣上以
onSomething
命名
- 通過
import { useState } from "react"
// 創建一個無狀態的受控組件
function ToggleButton({ value, onChange }) {
const handleClick = () => {
onChange(!value)
}
return (
<button style={{ width: "60px" }} onClick={handleClick}>
按鈕
</button>
)
}
Hooks 封裝鍵盤事件
import { useEffect, useState } from "react"
// 使用 document.body 作為默認的監聽節點
const useKeyPress = (domNode = document.body) => {
const [key, setKey] = useState(null)
useEffect(() => {
const handleKeyPress = (evt) => {
setKey(evt.keyCode)
}
// 監聽按鍵事件
domNode.addEventListener("keypress", handleKeyPress)
return () => {
// 接觸監聽按鍵事件
domNode.removeEventListener("keypress", handleKeyPress)
}
}, [domNode])
return key
}
Form: Hooks 給 Form 處理帶來的那些新變化
- 受控組件 和 非受控組件
function MyForm() {
const [value, setValue] = useState("")
const handleChange = useCallback((evt) => {
setValue(evt.target.value)
}, [])
// React 統一了表單組件的 onChange 事件
return <input value={value} onChange={handleChange} />
}
// 非受控組件 表單元素的值不是由父組件決定的,而是完全內部的狀態
import { useRef } from "react"
export default function MyForm() {
// 定義一個 ref 用于保存 input 節點的引用
const inputRef = useRef()
const handleSubmit = (evt) => {
evt.preventDefault()
// 使用的時候直接從 input 節點獲取值
alert("Name: " + inputRef.current.value)
}
return (
<form onSubmit={handleSubmit}>
<label>
Name:
<input type='text' ref={inputRef} />
</label>
<input type='submit' value='Submit' />
</form>
)
}
- 使用 Hooks 簡化表單處理
我們對每一個表單元素, 都是遵循這樣處理
- 設置一個 state 綁定 value
- 監聽表單元素的 onChange 事件, 同步 value 到 state
維護表單組件的狀態邏輯: 核心
- 字段的名字
- 綁定 value 值
- 處理 onChange 事件
import { useState, useCallback } from "react"
const useForm = (initialValues = {}) => {
// 設置整個 form 的狀態:values
const [values, setValues] = useState(initialValues)
// 提供一個方法用于設置 form 上的某個字段的值
const setFieldValue = useCallback((name, value) => {
setValues((values) => ({
...values,
[name]: value,
}))
}, [])
// 返回整個 form 的值以及設置值的方法
return { values, setFieldValue }
}
;<input
value={values.email || null}
onChange={(evt) => setFieldValue("email", evt.target.value)}
/>
- 處理表單驗證
- 如何定義這樣的錯誤狀態
- 如何設置這個錯誤狀態
// 除了初始值之外,還提供了一個 validators 對象,
// 用于提供針對某個字段的驗證函數
const useForm = (initialValues = {}, validators) => {
const [values, setValues] = useState(initialValues)
// 定義了 errors 狀態
const [errors, setErrors] = useState({})
const setFieldValue = useCallback(
(name, value) => {
setValues((values) => ({
...values,
[name]: value,
}))
// 如果存在驗證函數,則調用驗證用戶輸入
if (validators[name]) {
const errMsg = validators[name](value)
setErrors((errors) => ({
...errors,
// 如果返回錯誤信息,則將其設置到 errors 狀態,否則清空錯誤狀態
[name]: errMsg || null,
}))
}
},
[validators]
)
// 將 errors 狀態也返回給調用者
return { values, errors, setFieldValue }
}
function MyForm() {
// 用 useMemo 緩存 validators 對象
const validators = useMemo(() => {
return {
name: (value) => {
// 要求 name 的長度不得小于 2
if (value.length < 2) return "Name length should be no less than 2."
return null
},
email: (value) => {
// 簡單的實現一個 email 驗證邏輯:必須包含 @ 符號。
if (!value.includes("@")) return "Invalid email address"
return null
},
}
}, [])
// 從 useForm 的返回值獲取 errors 狀態
const { values, errors, setFieldValue } = useForm({}, validators)
// UI 渲染邏輯...
}
路由管理
路由管理,就是讓你的頁面能夠根據 URL 的變化進行頁面的切換,這是前端應用中一個非常重要的機制
URL 的全稱是 Uniform Resource Locator,中文意思是“統一資源定位符”,表明 URL 是用于唯一的定位某個資源的
- 路由工作原理
在前端路由管理中,則一般只在主內容區域 Content 部分變化, Header 和 Sider 是不會變化的。
實現路由機制的核心邏輯就是根據 URL 路徑這個狀態,來決定在主內容區域顯示什么組件, 示意代碼
const MyRouter = ({ children }) => {
const routes = _.keyBy(
children.map((c) => c.props),
"path"
)
const [hash] = useHash()
// 通過 URL 中的 hash,也就是“#”后面的部分來決定具體渲染哪個組件到主區域
const Page = routes[hash.replace("#", "")]?.component
// 如果路由不存在就返回 Not found.
return Page ? <Page /> : "Not found."
}
// 定義了一個空組件 Route,來接收路由的具體參數 path 和 component,從而以聲明式的方式去定義路由
const Route = () => null
function SamplePages {
return (
<div className="sample-pages">
{/* 定義了側邊導航欄 */}
<div className="sider">
<a href="#page1">Page 1</a>
<a href="#page2">Page 2</a>
<a href="#page3">Page 3</a>
<a href="#page4">Page 4</a>
</div>
<div className="exp-15-page-container">
{/* 定義路由配置 */}
<MyRouter>
<Route path="page1" component={Page1} />
<Route path="page2" component={Page2} />
<Route path="page3" component={Page3} />
<Route path="page4" component={Page4} />
</MyRouter>
</div>
</>
);
};
按需加載
import 語句,定義按需加載的起始模塊
按需加載,就是指在某個組件需要被渲染到頁面時,才會去實際地下載這個頁面,以及這個頁面依賴的所有代碼
// return promise
import(someModule)
// 演示使用 import 語句
function ProfilePage() {
// 定義一個 state 用于存放需要加載的組件
const [RealPage, setRealPage] = useState(null)
// 根據路徑動態加載真正的組件實現
import("./RealProfilePage").then((comp) => {
setRealPage(Comp)
})
// 如果組件未加載則顯示 Loading 狀態
if (!RealPage) return "Loading...."
// 組件加載成功后則將其渲染到界面
return <RealPage />
}