[譯]React高階話題之Context

鯊叔發表於2018-12-14

前言

本文為意譯,翻譯過程中摻雜本人的理解,如有誤導,請放棄繼續閱讀。

原文地址:Context

Context提供了一種不需要手動地通過props來層層傳遞的方式來傳遞資料。

正文

在典型的React應用中,資料是通過props,自上而下地傳遞給子元件的。但是對於被大量元件使用的固定型別的資料(比如說,本地的語言環境,UI主題等)來說,這麼做就顯得十分的累贅和笨拙。Context提供了一種在元件之間(上下層級關係的元件)共享這種型別資料的方式。這種方式不需要你手動地,顯式地通過props將資料層層傳遞下去。

什麼時候用Context?

這一小節,講的是context適用的業務場景。

Context是為那些可以認定為【整顆元件樹範圍內可以共用的資料】而設計的。比如說,當前已認證的使用者資料,UI主題資料,當前使用者的偏好語言設定資料等。舉個例子,下面的程式碼中,為了裝飾Button component我們手動地將一個叫“theme”的prop層層傳遞下去。 傳遞路徑是:App -> Toolbar -> ThemedButton -> Button

class App extends React.Component {
  render() {
    return <Toolbar theme="dark" />;
  }
}

function Toolbar(props) {
  // The Toolbar component must take an extra "theme" prop
  // and pass it to the ThemedButton. This can become painful
  // if every single button in the app needs to know the theme
  // because it would have to be passed through all components.
  return (
    <div>
      <ThemedButton theme={props.theme} />
    </div>
  );
}

class ThemedButton extends React.Component {
  render() {
    return <Button theme={this.props.theme} />;
  }
}
複製程式碼

使用context,我們可以跳過層層傳遞所經過的中間元件。現在我們的傳遞路徑是這樣的:App -> Button

// Context lets us pass a value deep into the component tree
// without explicitly threading it through every component.
// Create a context for the current theme (with "light" as the default).
const ThemeContext = React.createContext('light');

class App extends React.Component {
  render() {
    // Use a Provider to pass the current theme to the tree below.
    // Any component can read it, no matter how deep it is.
    // In this example, we're passing "dark" as the current value.
    return (
      <ThemeContext.Provider value="dark">
        <Toolbar />
      </ThemeContext.Provider>
    );
  }
}

// A component in the middle doesn't have to
// pass the theme down explicitly anymore.
function Toolbar(props) {
  return (
    <div>
      <ThemedButton />
    </div>
  );
}

class ThemedButton extends React.Component {
  // Assign a contextType to read the current theme context.
  // React will find the closest theme Provider above and use its value.
  // In this example, the current theme is "dark".
  static contextType = ThemeContext;
  render() {
    return <Button theme={this.context} />;
  }
}
複製程式碼

在你用Context之前

這一小節,講的是我們要慎用context。在用context之前,我們得考慮一下當前的業務場景有沒有第二種技術方案可用。只有在確實想不出來了,才去使用context。

Context主要用於這種業務場景:大量處在元件樹不同層級的元件需要共享某些資料。實際開發中,我們對context要常懷敬畏之心,謹慎使用。因為它猶如潘多拉的盒子,一旦開啟了,就造成很多難以控制的現象(在這裡特指,context一旦濫用了,就會造成很多元件難以複用)。

如果你只是單純想免去資料層層傳遞時對中間層元件的影響,那麼元件組合是一個相比context更加簡單的技術方案。

舉個例子來說,假如我們有一個叫Page的元件,它需要將useravatarSize這兩個prop傳遞到下面好幾層的Link元件和Avatar元件:

<Page user={user} avatarSize={avatarSize} />
// ... which renders ...
<PageLayout user={user} avatarSize={avatarSize} />
// ... which renders ...
<NavigationBar user={user} avatarSize={avatarSize} />
// ... which renders ...
<Link href={user.permalink}>
  <Avatar user={user} size={avatarSize} />
</Link>
複製程式碼

我們大費周章地將useravatarSize這兩個prop傳遞下去,最終只有Avatar元件才真正地用到它。這種做法顯得有點低效和多餘的。假如,到後面Avatar元件需要從頂層元件再獲取一些格外的資料的話,你還得手動地,逐層地將這些資料用prop的形式來傳遞下去。實話說,這真的很煩人。

不考慮使用context的前提下,另外一種可以解決這種問題的技術方案是:Avatar元件作為prop傳遞下去。這樣一來,其他中間層的元件就不要知道user這個prop的存在了。

function Page(props) {
  const user = props.user;
  const userLink = (
    <Link href={user.permalink}>
      <Avatar user={user} size={props.avatarSize} />
    </Link>
  );
  return <PageLayout userLink={userLink} />;
}

// Now, we have:
<Page user={user} />
// ... which renders ...
<PageLayout userLink={...} />
// ... which renders ...
<NavigationBar userLink={...} />
// ... which renders ...
{props.userLink}
複製程式碼

通過這個改動,只有最頂層的元件Page需要知道Link元件和Avatar元件需要用到“user”和“avatarSize”這兩個資料集。

在很多場景下,這種通過減少需要傳遞prop的個數的“控制反轉”模式讓你的程式碼更乾淨,並賦予了最頂層元件更多的控制許可權。然而,它並不適用於每一個業務場景。因為這種方案會增加高層級元件的複雜性,並以此為代價來使得低層家的元件來變得更加靈活。而這種靈活性往往是過度的。

在“元件組合”這種技術方案中,也沒有說限定你一個元件只能有一個子元件,你可以讓父元件擁有多個的子元件。或者甚至給每個單獨的子元件設定一個單獨的“插槽(slots)”,正如這裡所介紹的那樣。

function Page(props) {
  const user = props.user;
  const content = <Feed user={user} />;
  const topBar = (
    <NavigationBar>
      <Link href={user.permalink}>
        <Avatar user={user} size={props.avatarSize} />
      </Link>
    </NavigationBar>
  );
  return (
    <PageLayout
      topBar={topBar}
      content={content}
    />
  );
}
複製程式碼

這種模式對於大部分需要將子元件從它的父元件中分離開來的場景是足夠有用的了。如果子元件在渲染之前需要與父元件通訊的話,你可以進一步考慮使用render props技術。

然而,有時候你需要在不同的元件,不同的層級中去訪問同一份資料,這種情況下,還是用context比較好。Context負責集中分發你的資料,在資料改變的同時,能將新資料同步給它下面層級的元件。第一小節給出的範例中,使用context比使用本小節所說的“元件組合”方案更加的簡單。適用context的場景還包括“本地偏好設定資料”共享,“UI主題資料”共享和“快取資料”共享等。

相關API

React.createContext

const MyContext = React.createContext(defaultValue);
複製程式碼

該API是用於建立一個context object(在這裡是指Mycontext)。當React渲染一個訂閱了這個context object的元件的時候,將會從離這個元件最近的那個Provider元件讀取當前的context值。

建立context object時傳入的預設值只有元件在上層級元件樹中沒有找到對應的的Provider元件的時候時才會使用。這對於脫離Provider元件去單獨測試元件功能是很有幫助的。注意:如果你給Provider元件value屬性提供一個undefined值,這並不會引用React使用defaultValue作為當前的value值。也就是說,undefined仍然是一個有效的context value。

Context.Provider

<MyContext.Provider value={/* some value */}>
複製程式碼

每一個context object都有其對應的Provider元件。這個Provider元件使得Consumer元件能夠訂閱並追蹤context資料。

它接受一個叫value的屬性。這個value屬性的值將會傳遞給Provider元件所有的子孫層級的Consumer元件。這些Consumer元件會在Provider元件的value值發生變化的時候得到重新渲染。從Provider元件到其子孫Consumer元件的這種資料傳播不會受到shouldComponentUpdate(這個shouldComponentUpdate應該是指Cousumer元件的shouldComponentUpdate)這個生命週期方法的影響。所以,只要父Provider元件發生了更新,那麼作為子孫元件的Consumer元件也會隨著更新。

判定Provider元件的value值是否已經發生了變化是通過使用類似於Object.is演算法來對比新舊值實現的。

注意:當你給在Provider元件的value屬性傳遞一個object的時候,用於判定value是否已經發生改變的法則會導致一些問題,見注意點

Class.contextType

譯者注:官方文件給出的關於這個API的例子我並沒有跑通。不知道是我理解錯誤還是官方的文件有誤,讀者誰知道this.context在new context API中是如何使用的,麻煩在評論區指教一下。

class MyClass extends React.Component {
  componentDidMount() {
    let value = this.context;
    /* perform a side-effect at mount using the value of MyContext */
  }
  componentDidUpdate() {
    let value = this.context;
    /* ... */
  }
  componentWillUnmount() {
    let value = this.context;
    /* ... */
  }
  render() {
    let value = this.context;
    /* render something based on the value of MyContext */
  }
}
MyClass.contextType = MyContext;
複製程式碼

元件(類)的contextType靜態屬性可以賦值為一個context object。這使得這個元件類可以通過this.context來消費離它最近的context value。this.context在元件的各種生命週期方法都是可訪問的。

注意:

  1. 使用這個API,你只可以訂閱一個context object。如果你需要讀取多個context object,那麼你可以檢視Consuming Multiple Contexts
  2. 如果你想使用ES7的實驗性特徵public class fields syntax,你可以使用static關鍵字來初始化你的contextType屬性:
class MyClass extends React.Component {
  static contextType = MyContext;
  render() {
    let value = this.context;
    /* render something based on the value */
  }
}
複製程式碼

Context.Consumer

<MyContext.Consumer>
  {value => /* render something based on the context value */}
</MyContext.Consumer>
複製程式碼

Consumer元件是負責訂閱context,並跟蹤它的變化的元件。有了它,你就可以在一個function component裡面對context發起訂閱。

如上程式碼所示,Consumer元件的子元件要求是一個function(注意,這裡不是function component)。這個function會接收一個context value,返回一個React node。這個context value等同於離這個Consumer元件最近的Provider元件的value屬性值。假如Consumer元件在上面層級沒有這個context所對應的Provider元件,則function接收到的context value就是建立context object時所用的defaultValue。

注意:這裡所說的“function as a child”就是我們所說的render props模式。

示例

1. 動態context

我在這個例子裡面涉及到this.context的元件的某個生命週期方法裡面列印console.log(this.context),控制檯列印出來是空物件。從介面來看,DOM元素button也沒有background。

這是一個關於動態設定UI主題型別的context的更加複雜的例子:

theme-context.js

export const themes = {
  light: {
    foreground: '#000000',
    background: '#eeeeee',
  },
  dark: {
    foreground: '#ffffff',
    background: '#222222',
  },
};

export const ThemeContext = React.createContext(
  themes.dark // default value
);
複製程式碼

themed-button.js

import {ThemeContext} from './theme-context';

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

export default ThemedButton;
複製程式碼

app.js

import {ThemeContext, themes} from './theme-context';
import ThemedButton from './themed-button';

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

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 (
      <Page>
        <ThemeContext.Provider value={this.state.theme}>
          <Toolbar changeTheme={this.toggleTheme} />
        </ThemeContext.Provider>
        <Section>
          <ThemedButton />
        </Section>
      </Page>
    );
  }
}

ReactDOM.render(<App />, document.root);
複製程式碼

2. 在內嵌的元件中更新context

元件樹的底層元件在很多時候是需要更新Provider元件的context value的。面對這種業務場景,你可以在建立context object的時候傳入一個function型別的key-value,然後伴隨著context把它傳遞到Consumer元件當中:

theme-context.js

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

theme-toggler-button.js

import {ThemeContext} from './theme-context';

function ThemeTogglerButton() {
  // The Theme Toggler Button receives not only the theme
  // but also a toggleTheme function from the context
  return (
    <ThemeContext.Consumer>
      {({theme, toggleTheme}) => (
        <button
          onClick={toggleTheme}
          style={{backgroundColor: theme.background}}>
          Toggle Theme
        </button>
      )}
    </ThemeContext.Consumer>
  );
}

export default ThemeTogglerButton;
複製程式碼

app.js

import {ThemeContext, themes} from './theme-context';
import ThemeTogglerButton from './theme-toggler-button';

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}>
        <Content />
      </ThemeContext.Provider>
    );
  }
}

function Content() {
  return (
    <div>
      <ThemeTogglerButton />
    </div>
  );
}

ReactDOM.render(<App />, document.root);
複製程式碼

3. 同時消費多個context

為了使得context所導致的重新渲染的速度更快,React要求我們對context的消費要在單獨的Consumer元件中去進行。

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

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

class App extends React.Component {
  render() {
    const {signedInUser, theme} = this.props;

    // App component that provides initial context values
    // 兩個context的Provider元件巢狀
    return (
      <ThemeContext.Provider value={theme}>
        <UserContext.Provider value={signedInUser}>
          <Layout />
        </UserContext.Provider>
      </ThemeContext.Provider>
    );
  }
}

function Layout() {
  return (
    <div>
      <Sidebar />
      <Content />
    </div>
  );
}

// A component may consume multiple contexts
function Content() {
  return (
     // 兩個context的Consumer元件巢狀
    <ThemeContext.Consumer>
      {theme => (
        <UserContext.Consumer>
          {user => (
            <ProfilePage user={user} theme={theme} />
          )}
        </UserContext.Consumer>
      )}
    </ThemeContext.Consumer>
  );
}
複製程式碼

但是假如兩個或以上的context經常被一同消費,這個時候你得考慮合併它們,使之成為一個context,並建立一個接受多個context作為引數的render props component。

注意點

因為context是使用引用相等(reference identity)來判斷是否需要re-redner的,所以當你給Provider元件的value屬性提供一個字面量javascript物件值時,這就會導致一些效能問題-consumer元件發生不必要的渲染。舉個例子,下面的示例程式碼中,所有的consumer元件將會在Provider元件重新渲染的時候跟著一起re-render。這是因為每一次value的值都是一個新物件。

class App extends React.Component {
  render() {
    return (
     // {something: 'something'} === {something: 'something'}的值是false
      <Provider value={{something: 'something'}}>
        <Toolbar />
      </Provider>
    );
  }
}
複製程式碼

為了避免這個問題,我們可以把這種引用型別的值提升到父元件的state中去:

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

  render() {
    return (
      <Provider value={this.state.value}>
        <Toolbar />
      </Provider>
    );
  }
}
複製程式碼

遺留的API

React在先前的版本中引入了一個實驗性質的context API。相比當前介紹的這個context API,我們稱它為老的context API。這個老的API將會被支援到React 16.x版本結束前。但是你的app最好將它升級為上文中所介紹的新context API。這個遺留的API將會在未來的某個大版本中去除掉。想了解更多關於老context API,檢視這裡

相關文章