iT邦幫忙

第 12 屆 iT 邦幫忙鐵人賽

DAY 28
1
Modern Web

I Want To Know React系列 第 28

I Want To Know React - Context 範例 & 使用技巧

回顧 Context 語法

上一章節中,我們介紹了 Context 的使用簡介與語法。

React context 的使用環繞三個角色在運作:

  • Context object:代表 context 本身
  • Provider:用來提供 context 值
  • Consumer:用來使用 context 值

另外,我們也介紹了以下幾個 context 語法:

  • React.creatContext
  • Context.Provider
  • Context.Consumer
  • Class.contextType
  • Context.displayName

在下一章中,我們將介紹一些 context 的範例以及使用技巧。

Context 實際範例

現在,讓我們使用實際的範例來了解 context 如何使用。先來了解一下這個範例要達到哪些需求。

需求

現在要做一個小頁面,此頁面可以設定 UI theme。頁面中有兩個 button:

  • 一顆 button 在工具欄中,跟著 UI theme 改變顏色。另外,只要點擊此 button,就可切換 UI theme
  • 一顆 button 在工具欄外,不會跟著 UI theme 改變顏色

範例

我們如下實作需求:

首先先定義 theme 的種類,這個 app 中會有 lightdark 兩種 theme

另外,而此頁面的預設 theme 為 dark

const themes = {
  light: {
    background: "#eeeeee",
    color: "#000000"
  },
  dark: {
    background: "#222222",
    color: "#ffffff"
  }
};

const ThemeContext = React.createContext(
  themes.dark // default value
);

創建 context 的語法為 React.createContext(),參數帶入 theme.dark 代表預設的 theme。

接著製作一個可以隨著 theme 的變化而改變的 button:

class ThemedButton extends React.Component {
  render() {
    let props = this.props;
    let theme = this.context;
    return (
      <button
        {...props}
        style={{ backgroundColor: theme.background, color: theme.color }}
      />
    );
  }
}
ThemedButton.contextType = ThemeContext;

ThemeButton 採用 Class.contextType 語法,將要使用的 context 設定為 ThemeContext

因為要把 button 的背景與字體顏色都根據 context 的內容調整,因此在 ThemeButton 中用 this.context 的語法取得 theme 並設為 button 的 backgroundColorcolor

最後就來實作 Toolbar 與最重要的 App component:

// An intermediate component that uses the ThemedButton
function Toolbar(props) {
  return (
    <div className="toolbar">
      <ThemedButton onClick={props.changeTheme}>Change Theme</ThemedButton>
    </div>
  );
}

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      theme: themes.light
    };

    this.toggleTheme = () => {
      this.setState((state) => ({
        theme: state.theme === themes.dark ? themes.light : themes.dark
      }));
    };
  }

  render() {
    // The ThemedButton button inside the ThemeProvider
    // uses the theme from state while the one outside uses
    // the default dark theme
    return (
      <div>
        <ThemeContext.Provider value={this.state.theme}>
          <Toolbar changeTheme={this.toggleTheme} />
        </ThemeContext.Provider>
        <ThemedButton>Outside Button</ThemedButton>
      </div>
    );
  }
}
ReactDOM.render(<App />, document.getElementById('root'));

Toolbar 職責很簡單,只顯示一個外框,並負責傳遞 changeTheme 的事件。

App component 則負責:

  • 使用 ProviderToolbar 以下的 component 定為 light theme
  • Render Toolbar 與外層的 button
  • 實作轉換 theme 的函式 toggleTheme

讀者也可以到 CodePen 上參考完整範例。

從結果上可以看到,Toolbar 內的 button 使用了 Provider 提供的 theme context light,它會隨著 theme 的切換而改變顏色。相較之下,Toolbar 外的 button 沒有 Provider 包覆,取得的 theme context 會是預設值 dark,其顏色不會因為 Provider 的 context 值改變而有所變動。

從巢狀內層的 component 中觸發 context 更新

接下來來學習如何從巢狀內的 component 中觸發 context 更新。

讀者應該會有一個疑問,context 確實可以將值不透過中間層的 component 就傳遞到底層的 component,然而如果底層的 component 要觸發更新時要怎麼辦呢?是否還是要將 context 更新函式也透過每一個中間層 component 傳下去呢?這樣是否就失去 context 的作用了呢?

技巧:將 context 更新函式設為 context 內容之一

讓巢狀內層的 component 也可不經過中間層 component 就觸發更新的方法其實很簡單,把 context 更新函式也設為 context 內容之一即可。

也就是如果要讓 context 可以被內層 component 改變的話,context 就要有以下的內容:

  • Context 實際要提供的值
  • 更新 context 值的函式

接著就讓我們來看看實際範例。

需求

接續剛剛的範例,我們有一個可以設定 UI theme 的頁面,然而此頁面只會有一個可以隨著 UI theme 而改變顏色的 button。

但在程式面有另一個需求:

  • 要讓中間層的 component Toolbar 不用傳遞 toggleTheme prop 也能夠讓最下層的 button 可以改變 theme。

範例

實作方式如下,此範例會根據上個範例的內容修改,只會記錄有改動的部分:

首先,修改 ThemeContext

// Make sure the shape of the default value passed to
// createContext matches the shape that the consumers expect!
const ThemeContext = React.createContext({
  theme: themes.dark,
  toggleTheme: () => {},
});

theme 的內容也是一樣會有 lightdark,預設的 theme 也是 dark

然而不同的是,因為要讓下層的 button 不經過中間層 component 就可以改變 theme,因此額外把 context 更新函式 toggleTheme 也加到 context 中。

接著修改 ThemeButton

class ThemeTogglerButton extends React.Component {
  render() {
    let props = this.props;
    // The Theme Toggler Button receives not only the theme
    // but also a toggleTheme function from the context
    let { theme, toggleTheme } = this.context;
    return (
      <button
        {...props}
        onClick={toggleTheme}
        style={{ backgroundColor: theme.background, color: theme.color }}
      >
        Toggle Theme
      </button>
    );
  }
}
ThemeTogglerButton.contextType = ThemeContext;

現在,button 除了從 context 拿 theme 以外,還會順便拿 toggleTheme 函式。

因為功能增加的關係,把原本的 ThemeButton 改名為 ThemeTogglerButton

最後修改 App component:

class App extends React.Component {
  constructor(props) {
    super(props);

    this.toggleTheme = () => {
      this.setState((state) => ({
        theme: state.theme === themes.dark ? themes.light : themes.dark
      }));
    };

    // State also contains the updater function so it will
    // be passed down into the context provider
    this.state = {
      theme: themes.light,
      toggleTheme: this.toggleTheme
    };
  }

  render() {
    // The entire state is passed to the provider
    return (
      <ThemeContext.Provider value={this.state}>
        <Toolbar />
      </ThemeContext.Provider>
    );
  }
}

App component 唯一修改的部分就是把 state 加上 toggleTheme 後一併傳給 ThemeContext.Provider,這樣在 Provider 內的 Consumer ThemeTogglerButton 就可以同時收到 theme 的值與 theme 的更新函式了。

讀者也可以到 CodePen 查看完整範例。可以看到按下 button 時,theme context 依然可以正常切換。

如何使用多個 context

在一些需求下,一個 component 可能會要使用多個 context。

舉例來說,一個 Header component 可能會同時需要使用 theme 與 user 的內容。

那實作上,要如何讓一個 component 使用多個 context 呢?

技巧:使用多個 Context.Consumer

如果要在一個 component 中使用多個不同的 context 只要巢狀使用 Context.Consumer 的語法即可。

舉例來說:

function Page() {
  return (
    <ThemeContext.Consumer>
      {(theme) => (
        <UserContext.Consumer>
          {(user) => <Content user={user} theme={theme} />}
        </UserContext.Consumer>
      )}
    </ThemeContext.Consumer>
  );
}

要注意的是,Class.contextType 語法只能讓 component 使用一個 context 而已,因此無法支援使用多個 context 的需求。

接著就讓我們來看看實際應用吧!

需求

現在的需求是要做一個頁面,此頁面會有 Header 在最上方,且 Header 要有以下功能:

  • 顯示 App 的使用者名稱
  • 依照 UI theme 顯示顏色

範例

實作方式如下,首先定義 App 的 context:

// Theme context, default to light theme
const ThemeContext = React.createContext(themes.dark);

// Signed-in user context
const UserContext = React.createContext({
  name: "Guest"
});

會有兩個 context:

  • ThemeContext:代表 UI theme 的設定,預設為 dark
  • UserContext:代表使用者資訊,內容只有 name 屬性而已,預設為 Guest

接著就是製作顯示元件 ProfileHeader

function Profile({ user, theme }) {
  return (
    <div
      style={{
        backgroundColor: theme.background,
        color: theme.color
      }}
    >
      {user}
    </div>
  );
}

// A component may consume multiple contexts
function Header() {
  return (
    <ThemeContext.Consumer>
      {(theme) => (
        <UserContext.Consumer>
          {(user) => <Profile user={user} theme={theme} />}
        </UserContext.Consumer>
      )}
    </ThemeContext.Consumer>
  );
}

Profile 的職責很簡單,就是根據 props 顯示對應的顏色與使用者。

Header 就是負責同時使用 ThemeContextUserContext 的地方。

可以看到,因為巢狀的使用 context,所以最內層的 Profile 可以同時拿到 theme 與 user 的內容,這就達到要在一個 component 中使用兩個 context 的需求了。

最後是 App component:

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      theme: themes.light,
      signedInUser: "Henry"
    };
  }

  render() {
    const { signedInUser, theme } = this.state;

    // App component that provides initial context values
    return (
      <ThemeContext.Provider value={theme}>
        <UserContext.Provider value={signedInUser}>
          <Header />
        </UserContext.Provider>
      </ThemeContext.Provider>
    );
  }
}

App 則要負責兩件事:

  • 設定 context
  • 提供 Provider

因為現在的 context 有 theme 與 user,因此在 state 中指定完這兩個 context 的值後,就要讓這些 context 值分別放到 ThemeContext.ProviderUserContext.Provider 中,才可讓更下層的 Consumer 使用 context。

讀者也可以到 CodePen 上參考完整範例。

另外需要注意的是,如果有兩組 context consumer 經常一起使用,則可以考慮使用 Render Props 的技巧封裝成一個 component,讓重用性更高。

注意事項:避免在 Provider value 中創建物件

問題原因

還記得在上一章節中提過,只要 Provider 的 value 改變了,則底下的使用此 Provider 的 Consumer component 必定全部 re-render。

因此在使用 context 時應該避免在 Provider value 中直接新物件。如果每次 render 時,value 的值都是新的物件的話,就會導致下層的所有 Consumer 在也都跟著一起 re-render。在 Consumer 數量多的時候,這會造成很大的問題。

範例如下:

class App extends React.Component {
  render() {
    return (
      <MyContext.Provider value={{something: 'something'}}>
        <Toolbar />
      </MyContext.Provider>
    );
  }
}

可以看到每次執行 render 時,MyContext.Providervalue 都會是一個新的物件(就算實際上內容沒有改變),而這會導致 Toolbar 裡的 consumer 也隨著 App 的 re-render 而重新渲染。

解法

這個問題的解法就是把 Provider value 搬到 state 中。

如此一來,就算 App 重新 render 了,state 的 instance 依然會是一樣的,如此就可以避免 Provider 改值導致 Consumer 也一起 re-render 的問題了。

解法範例如下:

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      value: {something: 'something'},
    };
  }

  render() {
    return (
      <Provider value={this.state.value}>
        <Toolbar />
      </Provider>
    );
  }
}

小結

在這個章節中,我們用透過範例學習了 context 的實際使用範例與技巧,內容包括:

  • 學習 Context 實際範例
  • 學習如何從巢狀內層的 component 中觸發 context 更新
  • 學習如何使用多個 context

最後也提到了使用 context 時應該避免 inline 的把值宣告在 Provider 的 value 上,否則可能產生大量Consumer 非預期 re-render 的行為。

參考資料


上一篇
I Want To Know React - Context 語法
下一篇
I Want To Know React - PropTypes & DefaultProps
系列文
I Want To Know React30

尚未有邦友留言

立即登入留言