React 之 Context 的變遷與背後實現

冴羽發表於2022-12-14

Context

本篇我們講 Context,Context 可以實現跨元件傳遞資料,大部分的時候並無需要,但有的時候,比如使用者設定 了 UI 主題、地區偏好,如果從頂層一層層往下傳反而有些麻煩,不如直接藉助 Context 實現資料傳遞。

老的 Context API

基礎示例

在講最新的 API 前,我們先回顧下老的 Context API:

class Child extends React.Component {
  render() {
    // 4. 這裡使用 this.context.value 獲取
    return <p>{this.context.value}</p>
  }
}

// 3. 子元件新增 contextTypes 靜態屬性
Child.contextTypes = {
  value: PropTypes.string
};

class Parent extends React.Component {

  state = {
    value: 'foo'
  }

  // 1. 當 state 或者 props 改變的時候,getChildContext 函式就會被呼叫
  getChildContext() {
    return {value: this.state.value}
  }

  render() {
    return (
      <div>
        <Child />
      </div>
    )
  }
}

// 2. 父元件新增 childContextTypes 靜態屬性
Parent.childContextTypes = {
  value: PropTypes.string
};

context 中斷問題

對於這個 API,React 官方並不建議使用,對於可能會出現的問題,React 文件給出的介紹為:

問題是,如果元件提供的一個 context 發生了變化,而中間父元件的 shouldComponentUpdate 返回 false,那麼使用到該值的後代元件不會進行更新。使用了 context 的元件則完全失控,所以基本上沒有辦法能夠可靠的更新 context。

對於這個問題,我們寫個示例程式碼:

// 1. Child 元件使用 PureComponent
class Child extends React.Component {
  render() {
    return <GrandChild />
  }
}

class GrandChild extends React.Component {
  render() {
    return <p>{this.context.theme}</p>
  }
}

GrandChild.contextTypes = {
  theme: PropTypes.string
};

class Parent extends React.Component {

  state = {
    theme: 'red'
  }

  getChildContext() {
    return {theme: this.state.theme}
  }

  render() {
    return (
      <div onClick={() => {
        this.setState({
          theme: 'blue'
        })
      }}>
        <Child />
        <Child />
      </div>
    )
  }
}

Parent.childContextTypes = {
  theme: PropTypes.string
};

在這個示例程式碼中,當點選文字 red 的時候,文字並不會修改為 blue,如果我們把 Child 改為 extends Component,則能正常修改

這說明當中間元件的 shouldComponentUpdatefalse 時,會中斷 Context 的傳遞。

PureComponent 的存在是為了減少不必要的渲染,但我們又想 Context 能正常傳遞,哪有辦法可以解決嗎?

既然 PureComponent 的存在導致了 Context 無法再更新,那就乾脆不更新了,Context 不更新,GrandChild 就無法更新嗎?

解決方案

方法當然是有的:

// 1. 建立一個訂閱釋出器,當然你也可以稱呼它為依賴注入系統(dependency injection system),簡稱 DI
class Theme {
  constructor(value) {
    this.value = value
    this.subscriptions = []
  }

  setValue(value) {
    this.value = value
    this.subscriptions.forEach(f => f())
  }

  subscribe(f) {
    this.subscriptions.push(f)
  }
}


class Child extends React.PureComponent {
    render() {
        return <GrandChild />
    }
}


class GrandChild extends React.Component {
    componentDidMount() {
      // 4. GrandChild 獲取 store 後,進行訂閱
        this.context.theme.subscribe(() => this.forceUpdate())
    }

    // 5. GrandChild 從 store 中獲取所需要的值
    render() {
        return <p>{this.context.theme.value}</p>
    }
}

GrandChild.contextTypes = {
  theme: PropTypes.object
};

class Parent extends React.Component {
    constructor(p, c) {
      super(p, c)
      // 2. 我們例項化一個 store(想想 redux 的 store),並存到例項屬性中
      this.theme = new Theme('blue')
    }

    // 3. 透過 context 傳遞給 GrandChild 元件
    getChildContext() {
        return {theme: this.theme}
    }

    render() {
        // 6. 透過 store 進行釋出
        return (
            <div onClick={() => {
                this.theme.setValue('red')
            }}>
              <Child />
              <Child />
            </div>
        )
    }
}

Parent.childContextTypes = {
  theme: PropTypes.object
};

為了管理我們的 theme ,我們建立了一個依賴注入系統(DI),並透過 Context 向下傳遞 store,需要用到 store 資料的元件進行訂閱,傳入一個 forceUpdate 函式,當 store 進行釋出的時候,依賴 theme 的各個元件執行 forceUpdate,由此實現了在 Context 不更新的情況下實現了各個依賴元件的更新。

你可能也發現了,這有了一點 react-redux 的味道。

當然我們也可以藉助 Mobx 來實現並簡化程式碼,具體的實現可以參考 Michel Weststrate(Mobx 的作者) 的 How to safely use React context

新的 Context API

基礎示例

想必大家都或多或少的用過,我們直接上示例程式碼:

// 1. 建立 Provider 和 Consumer
const {Provider, Consumer} = React.createContext('dark');

class Child extends React.Component {
  // 3. Consumer 元件接收一個函式作為子元素。這個函式接收當前的 context 值,並返回一個 React 節點。
  render() {
    return (
      <Consumer>
        {(theme) => (
        <button>
          {theme}
        </button>
      )}
      </Consumer>
    )
  }
}

class Parent extends React.Component {

  state = {
    theme: 'dark',
  };

  componentDidMount() {
    setTimeout(() => {
      this.setState({
        theme: 'light'
      })
    }, 2000)
  }


  render() {
    // 2. 透過 Provider 的 value 傳遞值
    return (
      <Provider value={this.state.theme}>
        <Child />
      </Provider>
    )
  }
}

當 Provider 的 value 值發生變化時,它內部的所有 consumer 元件都會重新渲染。

新 API 的好處就在於從 Provider 到其內部 consumer 元件(包括 .contextType 和 useContext)的傳播不受制於 shouldComponentUpdate 函式,因此當 consumer 元件在其祖先元件跳過更新的情況下也能更新。

模擬實現

那麼 createContext 是怎麼實現的呢?我們先不看原始碼,根據前面的訂閱釋出器的經驗,我們自己其實就可以寫出一個 createContext 來,我們寫一個試試:

class Store {
    constructor() {
        this.subscriptions = []
    }

    publish(value) {
        this.subscriptions.forEach(f => f(value))
    }

    subscribe(f) {
        this.subscriptions.push(f)
    }
}

function createContext(defaultValue) {
    const store = new Store();

    // Provider
    class Provider extends React.PureComponent {
        componentDidUpdate() {
            store.publish(this.props.value);
        }

        componentDidMount() {
            store.publish(this.props.value);
        }

        render() {
            return this.props.children;
        }
    }

    // Consumer
    class Consumer extends React.PureComponent {
        constructor(props) {
            super(props);
            this.state = {
                value: defaultValue
            };

            store.subscribe(value => {
                this.setState({
                        value
                });
            });
        }

        render() {
            return this.props.children(this.state.value);
        }
    }

    return {
            Provider,
            Consumer
    };
}

用我們寫的 createContext 替換 React.createContext 方法,你會發現,同樣可以執行。

它其實跟解決老 Context API 問題的方法是一樣的,只不過是做了一層封裝。Consumer 元件構建的時候進行訂閱,當 Provider 有更新的時候進行釋出,這樣就跳過了 PureComponent 的限制,實現 Consumer 元件的更新。

createContext 原始碼

現在我們去看看真的 createContext 原始碼,原始碼位置packages/react/src/ReactContext.js,簡化後的程式碼如下:

import {REACT_PROVIDER_TYPE, REACT_CONTEXT_TYPE} from 'shared/ReactSymbols';

export function createContext(defaultValue) {
  const context = {
    $$typeof: REACT_CONTEXT_TYPE,
    // As a workaround to support multiple concurrent renderers, we categorize
    // some renderers as primary and others as secondary. We only expect
    // there to be two concurrent renderers at most: React Native (primary) and
    // Fabric (secondary); React DOM (primary) and React ART (secondary).
    // Secondary renderers store their context values on separate fields.
    _currentValue: defaultValue,
    _currentValue2: defaultValue,
    // Used to track how many concurrent renderers this context currently
    // supports within in a single renderer. Such as parallel server rendering.
    _threadCount: 0,
    // These are circular
    Provider: null,
    Consumer: null,

    // Add these to use same hidden class in VM as ServerContext
    _defaultValue: null,
    _globalName: null,
  };

  context.Provider = {
    $$typeof: REACT_PROVIDER_TYPE,
    _context: context,
  };

  context.Consumer = context;


  return context;
}

你會發現,如同之前的文章中涉及的原始碼一樣,React 的 createContext 就只是返回了一個資料物件,但沒有關係,以後的文章中會慢慢解析實現過程。

React 系列

講解 React 原始碼、React API 背後的實現機制,React 最佳實踐、React 的發展與歷史等,預計 50 篇左右,歡迎關注

如果喜歡或者有所啟發,歡迎 star,對作者也是一種鼓勵。

相關文章