跳至主要内容

React - useContext

Context 是什麼 ?

Context 是 React 的一個功能,它可以在 React App 的樹狀元件結構中的任意深度傳遞數據,而無需手動從父組件傳遞 props 到子組件。使用 Context 可以更方便地在程式中管理全域資料,例如語言設置、主題、用戶資料等。

如上所述,在一個 React App 中,元件可能不只有父子層 ( Parent and Child ),祖孫層或祖宗十八代層都有可能。

如上圖,當第四層的 Data 需要第一層的 Data,使用 props 傳遞需要經過兩層元件,最終才能到達需要 Data 的第四層。

如果只是少數幾層傳遞 props 那是 OK 的,而且使用 props 傳遞資料的好處是好追蹤資料源頭,但如果元件架構像下圖這樣呢 ?

底下有不同層級的元件需要第一層的 Data,因此使用 props 傳很多層才能到達目標元件,有個名詞可以形容他,叫做 "Prop drilling",簡單來說就是要個資料還要問候祖宗十八代。

這種複雜(問候祖宗)資料傳遞的方式不僅寫起來麻煩,也會徒增維護的難度,Context 就是用來解決 Prop drilling 帶來的困境。


使用 Context 的三個必要步驟

  1. 建立一個 Context。 ( createContext )
  2. 找到資料源頭的元件,提供 Context 和資料。 ( Context.Provider )
  3. 在目標元件裡面使用 Context 的資料。 ( useContext )

概念如上圖,接下來我們依照這三個步驟,一步一步介紹實際的作法。


實作 Context

假設元件架構如下 :

// 第一層 (user 資料在這層)
function App() {
const [user, setUser] = useState({ name: 'Tim' })

return (
<>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<p style={{ textAlign: 'center' }}>App</p>
<p>
User: <span style={{ color: 'navy' }}>{user.name}</span>
</p>
</div>
<MainPage user={user}></MainPage>
</>
)
}

// 第二層
function MainPage({ user }) {
return (
<div style={{ border: '1px solid red', padding: '1rem' }}>
<p style={{ textAlign: 'center' }}>Main Page</p>
<Content user={user}></Content>
</div>
)
}

// 第三層
function Content({ user }) {
return (
<div style={{ border: '1px solid red', padding: '1rem' }}>
<p style={{ textAlign: 'center' }}>Content</p>
<Message user={user}></Message>
</div>
)
}

// 第四層 (這層需要第一層的資料 user)
function Message({ user }) {
return (
<div style={{ border: '1px solid red', padding: '1rem' }}>
<p style={{ textAlign: 'center' }}>
Hello, <span style={{ color: 'navy' }}>{user.name}</span> (´• ω •`)ノ
</p>
</div>
)
}

Message 元件為了取得 App 元件的 user 資料,將資料用 prop 向下傳遞經過 MainPageContent 兩個不需要 user 資料的元件,讓我們使用 Context 三個步驟來讓 user 資料跨元件傳遞。


Step 1 : 建立一個 Context

createContext 基本語法

const Context = createContext(defaultValue)

使用 createContext 在元件外面建立一個 context object,而 defaultValue 不一定需要,有的話可以給這個 Context 預設值,如果沒有 Provider,那麼拿取 Context 資料的元件將會拿到這個預設值。

了解 createContext 後,我們在上面範例的元件外建立 UserContext :

// 在元件外建立 Context
const UserContext = createContext()

// 第一層 (user 資料在這層)
function App() {
// ...
}

// 第二層
function MainPage({ user }) {
// ...
}

// 第三層
function Content({ user }) {
// ...
}

// 第四層 (這層需要第一層的資料 user)
function Message({ user }) {
// ...
}

Step2 : 找到資料源頭的元件,提供 Context 和資料

const UserContext = createContext()

// 第一層 (user 資料在這層)
function App() {
const [user, setUser] = useState({ name: 'Tim' })

return (
<>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<p style={{ textAlign: 'center' }}>App</p>
<p>
User: <span style={{ color: 'navy' }}>{user.name}</span>
</p>
</div>
<UserContext.Provider value={user}>
<MainPage></MainPage>
</UserContext.Provider>
</>
)
}

// ...

因為 user 資料在 App 元件裡面,因此我們將 Provider 定位在這邊,使用 UserContext.Provider 包住 MainPage 元件,Provider 的 value 類似 props,讓 Provider 裡面所有元件都能取得到 value 資料,這邊就在 value 給予 user 資料。


Step3 : 在目標元件裡面使用 Context 的資料

// 第一層 (user 資料在這層)
function App() {
const [user, setUser] = useState({ name: 'Tim' })

return (
<>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<p style={{ textAlign: 'center' }}>App</p>
<p>
User: <span style={{ color: 'navy' }}>{user.name}</span>
</p>
</div>
<UserContext.Provider value={user}>
<MainPage></MainPage>
</UserContext.Provider>
</>
)
}

// 第二層
function MainPage() {
return (
<div style={{ border: '1px solid red', padding: '1rem' }}>
<p style={{ textAlign: 'center' }}>Main Page</p>
<Content></Content>
</div>
)
}

// 第三層
function Content() {
return (
<div style={{ border: '1px solid red', padding: '1rem' }}>
<p style={{ textAlign: 'center' }}>Content</p>
<Message></Message>
</div>
)
}

// 第四層 (這層需要第一層的資料 user)
function Message() {
const user = useContext(UserContext) // 取得 UserContext 的資料

return (
<div style={{ border: '1px solid red', padding: '1rem' }}>
<p style={{ textAlign: 'center' }}>
Hello, <span style={{ color: 'navy' }}>{user.name}</span> (´• ω •`)ノ
</p>
</div>
)
}

MainPageContentMessage 這三個元件不再需要使用 props 傳遞 user 資料,因此我們將 props 移除,然後在 Message 裡面使用 useContext(UserContext),它會回傳我們需要的 user 資料,這樣就大功告成了 !

CodeSandbox 完整程式碼


將 Context 封裝

相同的 Context 可能會有不同元件也需要使用,因此 Context 通常會封裝到外部,在需要的元件裡面 import 使用,讓我們在使用上更加方便 !

延續上面的範例,先建立 contexts 資料夾,在裡面建立一個 UserContext.js

/src/contexts/UserContext.js
const UserContext = createContext()

眼尖的會注意到我沒有 export 這個 UserContext,因為我們可以在這個 UserContext.js 裡面把 Provider 封裝好,甚至也將 useContext 一起封裝,再 export,這樣在需要使用 Context 的元件僅需要將封裝好的 useContext 一起 import 就可以使用囉 !

/src/contexts/UserContext.js
const UserContext = createContext()

// 封裝好的 Provider
export function UserProvider({ children, user }) {
return <UserContext.Provider value={user}>{children}</UserContext.Provider>
}

// 封裝好的 useContext
export function useUser() {
return useContext(UserContext)
}

UserProvider 也別忘記需要將 user 以 props 的方式傳遞給 Provider 的 value

在原本的元件 import 這兩個方法使用 :

/src/App.js
import { useState } from 'react'
import { UserProvider, useUser } from './contexts/UserContext'

// 第一層 (user 資料在這層)
function App() {
const [user, setUser] = useState({ name: 'Tim' })

return (
<>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<p style={{ textAlign: 'center' }}>App</p>
<p>
User: <span style={{ color: 'navy' }}>{user.name}</span>
</p>
</div>
<UserProvider user={user}>
<MainPage></MainPage>
</UserProvider>
</>
)
}

// 第二層
function MainPage() {
// ...
}

// 第三層
function Content() {
// ...
}

// 第四層 (這層需要第一層的資料 user)
function Message() {
const user = useUser() // 使用封裝好的 custom hook

return (
<div style={{ border: '1px solid red', padding: '1rem' }}>
<p style={{ textAlign: 'center' }}>
Hello, <span style={{ color: 'navy' }}>{user.name}</span> (´• ω •`)ノ
</p>
</div>
)
}

這樣就大功告成囉 !

CodeSandbox 完整程式碼


useContext 會向上查找相同且最近的 Provider

讓我們改寫一下 App 元件,然後 UserContext 加入預設值 { name: 'Default' } :

/src/App.js
import { UserProvider, useUser } from './contexts/UserContext'

// 第一層 (user 資料在這層)
function App() {
const [user1, setUser1] = useState({ name: 'Tim' })
const [user2, setUser2] = useState({ name: 'John' })
const user = useUser()

return (
<>
<UserProvider user={user1}>
<UserProvider user={user2}>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<p style={{ textAlign: 'center' }}>App</p>
<p>
User: <span style={{ color: 'navy' }}>{user.name}</span>
</p>
</div>
<MainPage></MainPage>
</UserProvider>
</UserProvider>
</>
)
}

這邊用兩個 UserProvider 覆蓋裡面的 DOM 元素和 MainPage 元件,其餘元件不變,此時的畫面會是這樣

最裡面 Message 元件拿到的資料會是 user2{ name: 'John' },而不是 user1{ name: 'Tim' },因為 Message 元件使用 useUser ,也就是使用 useContext(UserContext) 的時候會往上查找到離它最近、對應 Context 的 Provider,因此才會拿到資料 user2

還沒結束,焦點轉移到 App 元件中,這邊改使用 const user = useUser() 拿到 user 資料,如上圖所示,渲染出來的 user.name,會是 default

import { UserProvider, useUser } from './contexts/UserContext'

// 第一層 (user 資料在這層)
function App() {
const [user1, setUser1] = useState({ name: 'Tim' })
const [user2, setUser2] = useState({ name: 'John' })
const user = useUser()

return (
<>
<UserProvider user={user1}>
<UserProvider user={user2}>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<p style={{ textAlign: 'center' }}>App</p>
<p>
{/* user.name 會是 default */}
User: <span style={{ color: 'navy' }}>{user.name}</span>
</p>
</div>
<MainPage></MainPage>
</UserProvider>
</UserProvider>
</>
)
}

我第一次看的時候覺得很奇怪,明明這個 user.name 是包在 UserProvider 裡面沒錯,但它並不像我們前面說會往上查找拿到最近 Provider 而拿到 user2{ name: 'John' },這是為什麼呢 ?

其實仔細思考一下 JavaScript closure 的原理就不難理解,在 App 元件 UserProvider 裡面整個 div 都是屬於 App 元件所執行且 return 的,在 App 元件的外面並沒有任何 Provider,因此在 App 元件裡面 useUser() 所拿到的 user 資料就會是這個 Context 的預設值 { name: 'Default' }

所以寫 React 真的要對 JavaScript 的基礎有相當的理解,我還思考了一段時間呢。 ( 汗

CodeSandbox 完整程式碼


希望這個筆記對大家有幫助,如果有錯誤或不夠清楚的地方也歡迎在底下留言讓我改進 (-ω-ゞ


Reference