React Hooks 實用

復雜狀態處理: 如何保持狀態一致性

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 的時候,冗余狀態就沒那么明顯。這時候你就需要準確定位狀態的數據源究竟是什么,并且在開發中確保它始終是唯一的數據源,以此避免定義中間狀態

異步處理: 如何向服務器發送請求

  1. 實現自己的 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
  1. 使用 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>
  )
}

函數組件設計模式:如何應對復雜條件渲染場景?

  1. 容器模式: 實現按條件執行 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
    // 獲取用戶信息的邏輯
  })
}
  1. 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 }
}

事件處理: 如何創建自定義事件

  1. React 中使用原生事件: 約定使用駱駝體 (onMouseOver, onChange) 等
  2. React 原生事件的原理: 合成事件 由于虛擬 DOM 的存在, 在 React 綁定一個事件到原生的 DOM 節點, 事件也不會綁定在對應的節點上, 而是所有的事件都綁定在根節點上. 然后由 React 統一監聽和管理, 代理模式, 分發到具體的虛擬 DOM 上
  3. 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 處理帶來的那些新變化

  1. 受控組件 和 非受控組件
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>
  )
}
  1. 使用 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)}
/>
  1. 處理表單驗證
  • 如何定義這樣的錯誤狀態
  • 如何設置這個錯誤狀態
// 除了初始值之外,還提供了一個 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 是用于唯一的定位某個資源的

  1. 路由工作原理

在前端路由管理中,則一般只在主內容區域 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 />
}
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,443評論 6 532
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,530評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,407評論 0 375
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,981評論 1 312
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,759評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,204評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,263評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,415評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,955評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,782評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,983評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,528評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,222評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,650評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,892評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,675評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,967評論 2 374

推薦閱讀更多精彩內容