聊一聊我對(duì) React Context 的理解以及應(yīng)用

前言

Context被翻譯為上下文麸粮,在編程領(lǐng)域,這是一個(gè)經(jīng)常會(huì)接觸到的概念鲤孵,React中也有。

在React的官方文檔中译秦,Context被歸類為高級(jí)部分(Advanced),屬于React的高級(jí)API击碗,但官方并不建議在穩(wěn)定版的App中使用Context筑悴。

The vast majority of applications do not need to use content.

If you want your application to be stable, don't use context. It is an experimental API and it is likely to break in future releases of React.

不過,這并非意味著我們不需要關(guān)注Context稍途。事實(shí)上阁吝,很多優(yōu)秀的React組件都通過Context來完成自己的功能,比如react-redux的<Provider />械拍,就是通過Context提供一個(gè)全局態(tài)的store突勇,拖拽組件react-dnd,通過Context在組件中分發(fā)DOM的Drag和Drop事件坷虑,路由組件react-router通過Context管理路由狀態(tài)等等甲馋。在React組件開發(fā)中,如果用好Context迄损,可以讓你的組件變得強(qiáng)大定躏,而且靈活。

今天就想跟大家聊一聊芹敌,我在開發(fā)當(dāng)中痊远,所認(rèn)識(shí)到的這個(gè)Context,以及我是如何使用它來進(jìn)行組件開發(fā)的氏捞。

注:本文中所有提到的App皆指Web端App碧聪。

初識(shí)React Context

官方對(duì)于Context的定義

React文檔官網(wǎng)并未對(duì)Context給出“是什么”的定義,更多是描述使用的Context的場(chǎng)景液茎,以及如何使用Context逞姿。

官網(wǎng)對(duì)于使用Context的場(chǎng)景是這樣描述的:

In Some Cases, you want to pass data through the component tree without having to pass the props down manuallys at every level. you can do this directly in React with the powerful "context" API.

簡(jiǎn)單說就是辞嗡,當(dāng)你不想在組件樹中通過逐層傳遞props或者state的方式來傳遞數(shù)據(jù)時(shí),可以使用Context來實(shí)現(xiàn)跨層級(jí)的組件數(shù)據(jù)傳遞滞造。

image

使用props或者state傳遞數(shù)據(jù)欲间,數(shù)據(jù)自頂下流。

image

使用Context断部,可以跨越組件進(jìn)行數(shù)據(jù)傳遞。

如何使用Context

如果要Context發(fā)揮作用班缎,需要用到兩種組件蝴光,一個(gè)是Context生產(chǎn)者(Provider),通常是一個(gè)父節(jié)點(diǎn)达址,另外是一個(gè)Context的消費(fèi)者(Consumer)蔑祟,通常是一個(gè)或者多個(gè)子節(jié)點(diǎn)。所以Context的使用基于生產(chǎn)者消費(fèi)者模式沉唠。

對(duì)于父組件疆虚,也就是Context生產(chǎn)者,需要通過一個(gè)靜態(tài)屬性childContextTypes聲明提供給子組件的Context對(duì)象的屬性满葛,并實(shí)現(xiàn)一個(gè)實(shí)例getChildContext方法径簿,返回一個(gè)代表Context的純對(duì)象 (plain object) 。

import React from 'react'
import PropTypes from 'prop-types'

class MiddleComponent extends React.Component {
  render () {
    return <ChildComponent />
  }
}

class ParentComponent extends React.Component {
  // 聲明Context對(duì)象屬性
  static childContextTypes = {
    propA: PropTypes.string,
    methodA: PropTypes.func
  }
  
  // 返回Context對(duì)象嘀韧,方法名是約定好的
  getChildContext () {
    return {
      propA: 'propA',
      methodA: () => 'methodA'
    }
  }
  
  render () {
    return <MiddleComponent />
  }
}

而對(duì)于Context的消費(fèi)者篇亭,通過如下方式訪問父組件提供的Context

import React from 'react'
import PropTypes from 'prop-types'

class ChildComponent extends React.Component {
  // 聲明需要使用的Context屬性
  static contextTypes = {
    propA: PropTypes.string
  }
  
  render () {
    const {
      propA,
      methodA
    } = this.context
    
    console.log(`context.propA = ${propA}`)  // context.propA = propA
    console.log(`context.methodA = ${methodA}`)  // context.methodA = undefined
    
    return ...
  }
}

子組件需要通過一個(gè)靜態(tài)屬性contextTypes聲明后锄贷,才能訪問父組件Context對(duì)象的屬性译蒂,否則,即使屬性名沒寫錯(cuò)谊却,拿到的對(duì)象也是undefined柔昼。

對(duì)于無狀態(tài)子組件(Stateless Component),可以通過如下方式訪問父組件的Context

import React from 'react'
import PropTypes from 'prop-types'

const ChildComponent = (props, context) => {
  const {
    propA
  } = context
    
  console.log(`context.propA = ${propA}`)  // context.propA = propA
    
  return ...
}
  
ChildComponent.contextProps = {
  propA: PropTypes.string    
}

而在接下來的發(fā)行版本中炎辨,React對(duì)Context的API做了調(diào)整捕透,更加明確了生產(chǎn)者消費(fèi)者模式的使用方式。

import React from 'react';
import ReactDOM from 'react-dom';

const ThemeContext = React.createContext({
  background: 'red',
  color: 'white'
});

通過靜態(tài)方法React.createContext()創(chuàng)建一個(gè)Context對(duì)象蹦魔,這個(gè)Context對(duì)象包含兩個(gè)組件激率,<Provider /><Consumer />

class App extends React.Component {
  render () {
    return (
      <ThemeContext.Provider value={{background: 'green', color: 'white'}}>
        <Header />
      </ThemeContext.Provider>
    );
  }
}

<Provider />value相當(dāng)于現(xiàn)在的getChildContext()勿决。

class Header extends React.Component {
  render () {
    return (
      <Title>Hello React Context API</Title>
    );
  }
}
 
class Title extends React.Component {
  render () {
    return (
      <ThemeContext.Consumer>
        {context => (
          <h1 style={{background: context.background, color: context.color}}>
            {this.props.children}
          </h1>
        )}
      </ThemeContext.Consumer>
    );
  }
}

<Consumer />children必須是一個(gè)函數(shù)乒躺,通過函數(shù)的參數(shù)獲取<Provider />提供的Context

可見低缩,Context的新API更加貼近React的風(fēng)格嘉冒。

幾個(gè)可以直接獲取Context的地方

實(shí)際上曹货,除了實(shí)例的context屬性(this.context),React組件還有很多個(gè)地方可以直接訪問父組件提供的Context讳推。比如構(gòu)造方法:

  • constructor(props, context)

比如生命周期:

  • componentWillReceiveProps(nextProps, nextContext)
  • shouldComponentUpdate(nextProps, nextState, nextContext)
  • componetWillUpdate(nextProps, nextState, nextContext)

對(duì)于面向函數(shù)的無狀態(tài)組件顶籽,可以通過函數(shù)的參數(shù)直接訪問組件的Context

const StatelessComponent = (props, context) => (
  ......
)

以上是Context的基礎(chǔ)银觅,更具體的指南內(nèi)容可參見這里

我對(duì)Context的理解

OK礼饱,說完基礎(chǔ)的東西,現(xiàn)在聊一聊我對(duì)React的Context的理解究驴。

把Context當(dāng)做組件作用域

使用React的開發(fā)者都知道镊绪,一個(gè)React App本質(zhì)就是一棵React組件樹,每個(gè)React組件相當(dāng)于這棵樹上的一個(gè)節(jié)點(diǎn)洒忧,除了App的根節(jié)點(diǎn)蝴韭,其他每個(gè)節(jié)點(diǎn)都存在一條父組件鏈。

image

例如上圖熙侍,<Child />的父組件鏈?zhǔn)?code><SubNode /> -- <Node /> -- <App />榄鉴,<SubNode />的父組件鏈?zhǔn)?code><Node /> -- <App /><Node />的父組件鏈只有一個(gè)組件節(jié)點(diǎn)蛉抓,就是<App />庆尘。

這些以樹狀連接的組件節(jié)點(diǎn),實(shí)際上也組成了一棵Context樹芝雪,每個(gè)節(jié)點(diǎn)的Context减余,來自父組件鏈上所有組件節(jié)點(diǎn)通過getChildContext()所提供的Context對(duì)象組合而成的對(duì)象。

image

有了解JS作用域鏈概念的開發(fā)者應(yīng)該都知道惩系,JS的代碼塊在執(zhí)行期間位岔,會(huì)創(chuàng)建一個(gè)相應(yīng)的作用域鏈,這個(gè)作用域鏈記錄著運(yùn)行時(shí)JS代碼塊執(zhí)行期間所能訪問的活動(dòng)對(duì)象堡牡,包括變量和函數(shù)抒抬,JS程序通過作用域鏈訪問到代碼塊內(nèi)部或者外部的變量和函數(shù)。

假如以JS的作用域鏈作為類比晤柄,React組件提供的Context對(duì)象其實(shí)就好比一個(gè)提供給子組件訪問的作用域擦剑,而Context對(duì)象的屬性可以看成作用域上的活動(dòng)對(duì)象。由于組件的Context由其父節(jié)點(diǎn)鏈上所有組件通過getChildContext()返回的Context對(duì)象組合而成芥颈,所以惠勒,組件通過Context是可以訪問到其父組件鏈上所有節(jié)點(diǎn)組件提供的Context的屬性。

所以爬坑,我借鑒了JS作用域鏈的思路纠屋,把Context當(dāng)成是組件的作用域來使用。

關(guān)注Context的可控性和影響范圍

不過盾计,作為組件作用域來看待的Context與常見的作用域的概念 (就我個(gè)人目前接觸到的編程語(yǔ)言而言) 是有所區(qū)別的售担。我們需要關(guān)注Context的可控性和影響范圍赁遗。

在我們平時(shí)的開發(fā)中,用到作用域或者上下文的場(chǎng)景是很常見族铆,很自然岩四,甚至是無感知的,然而哥攘,在React中使用Context并不是那么容易剖煌。父組件提供Context需要通過childContextTypes進(jìn)行“聲明”,子組件使用父組件的Context屬性需要通過contextTypes進(jìn)行“申請(qǐng)”逝淹,所以末捣,我認(rèn)為React的Context是一種“帶權(quán)限”的組件作用域

這種“帶權(quán)限”的方式有何好處创橄?就我個(gè)人的理解,首先是保持框架API的一致性莽红,和propTypes一樣妥畏,使用聲明式編碼風(fēng)格。另外就是安吁,可以在一定程度上確保組件所提供的Context的可控性和影響范圍醉蚁。

React App的組件是樹狀結(jié)構(gòu),一層一層延伸鬼店,父子組件是一對(duì)多的線性依賴网棍。隨意的使用Context其實(shí)會(huì)破壞這種依賴關(guān)系,導(dǎo)致組件之間一些不必要的額外依賴妇智,降低組件的復(fù)用性滥玷,進(jìn)而可能會(huì)影響到App的可維護(hù)性。

image

通過上圖可以看到巍棱,原本線性依賴的組件樹惑畴,由于子組件使用了父組件的Context,導(dǎo)致<Child />組件對(duì)<Node /><App />都產(chǎn)生了依賴關(guān)系航徙。一旦脫離了這兩個(gè)組件如贷,<Child />的可用性就無法保障了,減低了<Child />的復(fù)用性到踏。

image

在我看來杠袱,通過Context暴露數(shù)據(jù)或者API不是一種優(yōu)雅的實(shí)踐方案,盡管react-redux是這么干的窝稿。因此需要一種機(jī)制楣富,或者說約束,去降低不必要的影響讹躯。

通過childContextTypescontextTypes這兩個(gè)靜態(tài)屬性的約束菩彬,可以在一定程度保障缠劝,只有組件自身,或者是與組件相關(guān)的其他子組件才可以隨心所欲的訪問Context的屬性骗灶,無論是數(shù)據(jù)還是函數(shù)惨恭。因?yàn)橹挥薪M件自身或者相關(guān)的子組件可以清楚它能訪問Context哪些屬性,而相對(duì)于那些與組件無關(guān)的其他組件耙旦,無論是內(nèi)部或者外部的 脱羡,由于不清楚父組件鏈上各父組件的childContextTypes“聲明”了哪些Context屬性,所以沒法通過contextTypes“申請(qǐng)”相關(guān)的屬性免都。所以我理解為锉罐,給組件的作用域Context“帶權(quán)限”,可以在一定程度上確保Context的可控性和影響范圍绕娘。

在開發(fā)組件過程中脓规,我們應(yīng)該時(shí)刻關(guān)注這一點(diǎn),不要隨意的使用Context险领。

不需要優(yōu)先使用Context

作為React的高級(jí)API侨舆,React并不推薦我們優(yōu)先考慮使用Context。我的理解是:

  • Context目前還處于實(shí)驗(yàn)階段绢陌,可能會(huì)在后面的發(fā)行版本中有大的變化挨下,事實(shí)上這種情況已經(jīng)發(fā)生了,所以為了避免給今后升級(jí)帶來較大影響和麻煩脐湾,不建議在App中使用Context臭笆。
  • 盡管不建議在App中使用Context,但對(duì)于組件而言秤掌,由于影響范圍小于App愁铺,如果可以做到高內(nèi)聚,不破壞組件樹的依賴關(guān)系闻鉴,那么還是可以考慮使用Context的帜讲。
  • 對(duì)于組件之間的數(shù)據(jù)通信或者狀態(tài)管理,優(yōu)先考慮用props或者state解決椒拗,然后再考慮用其他第三方成熟庫(kù)解決的似将,以上方法都不是最佳選擇的時(shí)候,那么再考慮使用Context蚀苛。
  • Context的更新需要通過setState()觸發(fā)在验,但是這并不是可靠的。Context支持跨組件訪問堵未,但是腋舌,如果中間的子組件通過一些方法不響應(yīng)更新,比如shouldComponentUpdate()返回false渗蟹,那么不能保證Context的更新一定可達(dá)使用Context的子組件块饺。因此赞辩,Context的可靠性需要關(guān)注。不過更新的問題授艰,在新版的API中得以解決辨嗽。

簡(jiǎn)而言之,只要你能確保Context是可控的淮腾,使用Context并無大礙糟需,甚至如果能夠合理的應(yīng)用,Context其實(shí)可以給React組件開發(fā)帶來很強(qiáng)大的體驗(yàn)谷朝。

用Context作為共享數(shù)據(jù)的媒介

官方所提到Context可以用來進(jìn)行跨組件的數(shù)據(jù)通信洲押。而我,把它理解為圆凰,好比一座橋杈帐,作為一種作為媒介進(jìn)行數(shù)據(jù)共享。數(shù)據(jù)共享可以分兩類:App級(jí)組件級(jí)专钉。

  • App級(jí)的數(shù)據(jù)共享

App根節(jié)點(diǎn)組件提供的Context對(duì)象可以看成是App級(jí)的全局作用域娘荡,所以,我們利用App根節(jié)點(diǎn)組件提供的Context對(duì)象創(chuàng)建一些App級(jí)的全局?jǐn)?shù)據(jù)∈徽樱現(xiàn)成的例子可以參考react-redux,以下是<Provider />組件源碼的核心實(shí)現(xiàn):

export function createProvider(storeKey = 'store', subKey) {
    const subscriptionKey = subKey || `${storeKey}Subscription`

    class Provider extends Component {
        getChildContext() {
          return { [storeKey]: this[storeKey], [subscriptionKey]: null }
        }

        constructor(props, context) {
          super(props, context)
          this[storeKey] = props.store;
        }

        render() {
          return Children.only(this.props.children)
        }
    }

    // ......

    Provider.propTypes = {
        store: storeShape.isRequired,
        children: PropTypes.element.isRequired,
    }
    Provider.childContextTypes = {
        [storeKey]: storeShape.isRequired,
        [subscriptionKey]: subscriptionShape,
    }

    return Provider
}

export default createProvider()

App的根組件用<Provider />組件包裹后争群,本質(zhì)上就為App提供了一個(gè)全局的屬性store回怜,相當(dāng)于在整個(gè)App范圍內(nèi),共享store屬性换薄。當(dāng)然玉雾,<Provider />組件也可以包裹在其他組件中,在組件級(jí)的全局范圍內(nèi)共享store轻要。

  • 組件級(jí)的數(shù)據(jù)共享

如果組件的功能不能單靠組件自身來完成复旬,還需要依賴額外的子組件,那么可以利用Context構(gòu)建一個(gè)由多個(gè)子組件組合的組件冲泥。例如驹碍,react-router。

react-router的<Router />自身并不能獨(dú)立完成路由的操作和管理凡恍,因?yàn)閷?dǎo)航鏈接和跳轉(zhuǎn)的內(nèi)容通常是分離的志秃,因此還需要依賴<Link /><Route />等子組件來一同完成路由的相關(guān)工作。為了讓相關(guān)的子組件一同發(fā)揮作用嚼酝,react-router的實(shí)現(xiàn)方案是利用Context<Router />浮还、<Link />以及<Route />這些相關(guān)的組件之間共享一個(gè)router,進(jìn)而完成路由的統(tǒng)一操作和管理闽巩。

下面截取<Router />钧舌、<Link />以及<Route />這些相關(guān)的組件部分源碼担汤,以便更好的理解上述所說的。

// Router.js

/**
 * The public API for putting history on context.
 */
class Router extends React.Component {
  static propTypes = {
    history: PropTypes.object.isRequired,
    children: PropTypes.node
  };

  static contextTypes = {
    router: PropTypes.object
  };

  static childContextTypes = {
    router: PropTypes.object.isRequired
  };

  getChildContext() {
    return {
      router: {
        ...this.context.router,
        history: this.props.history,
        route: {
          location: this.props.history.location,
          match: this.state.match
        }
      }
    };
  }
  
  // ......
  
  componentWillMount() {
    const { children, history } = this.props;
    
    // ......
    
    this.unlisten = history.listen(() => {
      this.setState({
        match: this.computeMatch(history.location.pathname)
      });
    });
  }

  // ......
}

盡管源碼還有其他的邏輯洼冻,但<Router />的核心就是為子組件提供一個(gè)帶有router屬性的Context崭歧,同時(shí)監(jiān)聽history,一旦history發(fā)生變化碘赖,便通過setState()觸發(fā)組件重新渲染驾荣。

// Link.js

/**
 * The public API for rendering a history-aware <a>.
 */
class Link extends React.Component {
  
  // ......
  
  static contextTypes = {
    router: PropTypes.shape({
      history: PropTypes.shape({
        push: PropTypes.func.isRequired,
        replace: PropTypes.func.isRequired,
        createHref: PropTypes.func.isRequired
      }).isRequired
    }).isRequired
  };

  handleClick = event => {
    if (this.props.onClick) this.props.onClick(event);

    if (
      !event.defaultPrevented &&
      event.button === 0 &&
      !this.props.target &&
      !isModifiedEvent(event)
    ) {
      event.preventDefault();
      // 使用<Router />組件提供的router實(shí)例
      const { history } = this.context.router;
      const { replace, to } = this.props;

      if (replace) {
        history.replace(to);
      } else {
        history.push(to);
      }
    }
  };
  
  render() {
    const { replace, to, innerRef, ...props } = this.props;

    // ...

    const { history } = this.context.router;
    const location =
      typeof to === "string"
        ? createLocation(to, null, null, history.location)
        : to;

    const href = history.createHref(location);
    return (
      <a {...props} onClick={this.handleClick} href={href} ref={innerRef} />
    );
  }
}

<Link />的核心就是渲染<a>標(biāo)簽,攔截<a>標(biāo)簽的點(diǎn)擊事件普泡,然后通過<Router />共享的router對(duì)history進(jìn)行路由操作播掷,進(jìn)而通知<Router />重新渲染。

// Route.js

/**
 * The public API for matching a single path and rendering.
 */
class Route extends React.Component {
  
  // ......
  
  state = {
    match: this.computeMatch(this.props, this.context.router)
  };

  // 計(jì)算匹配的路徑撼班,匹配的話歧匈,會(huì)返回一個(gè)匹配對(duì)象,否則返回null
  computeMatch(
    { computedMatch, location, path, strict, exact, sensitive },
    router
  ) {
    if (computedMatch) return computedMatch;
    
    // ......

    const { route } = router;
    const pathname = (location || route.location).pathname;
    
    return matchPath(pathname, { path, strict, exact, sensitive }, route.match);
  }
 
  // ......

  render() {
    const { match } = this.state;
    const { children, component, render } = this.props;
    const { history, route, staticContext } = this.context.router;
    const location = this.props.location || route.location;
    const props = { match, location, history, staticContext };

    if (component) return match ? React.createElement(component, props) : null;

    if (render) return match ? render(props) : null;

    if (typeof children === "function") return children(props);

    if (children && !isEmptyChildren(children))
      return React.Children.only(children);

    return null;
  }
}

<Route />有一部分源碼與<Router />相似砰嘁,可以實(shí)現(xiàn)路由的嵌套件炉,但其核心是通過Context共享的router,判斷是否匹配當(dāng)前路由的路徑矮湘,然后渲染組件斟冕。

通過上述的分析,可以看出缅阳,整個(gè)react-router其實(shí)就是圍繞著<Router />Context來構(gòu)建的磕蛇。

使用Context開發(fā)組件

之前,通過Context開發(fā)過一個(gè)簡(jiǎn)單的組件十办,插槽分發(fā)組件秀撇。本章就借著這個(gè)插槽分發(fā)組件的開發(fā)經(jīng)歷,聊聊如何使用Context進(jìn)行組件的開發(fā)向族。

插槽分發(fā)組件

首先說說什么是插槽分發(fā)組件呵燕,這個(gè)概念最初是在Vuejs中認(rèn)識(shí)的。插槽分發(fā)是一種通過組件的組合件相,將父組件的內(nèi)容插入到子組件模板的技術(shù)再扭,在Vuejs中叫做Slot

為了讓大家更加直觀的理解這個(gè)概念夜矗,我從Vuejs搬運(yùn)了一段關(guān)于插槽分發(fā)的Demo霍衫。

對(duì)于提供的插槽的組件<my-component />,模板如下:

<div>
  <h2>我是子組件的標(biāo)題</h2>
  <slot>
    只有在沒有要分發(fā)的內(nèi)容時(shí)顯示
  </slot>
</div>

對(duì)于父組件侯养,模板如下:

<div>
  <h1>我是父組件的標(biāo)題</h1>
  <my-component>
    <p>這是一些初始內(nèi)容</p>
    <p>這是更多的初始內(nèi)容</p>
  </my-component>
</div>

最終渲染的結(jié)果:

<div>
  <h1>我是父組件的標(biāo)題</h1>
  <div>
    <h2>我是子組件的標(biāo)題</h2>
    <p>這是一些初始內(nèi)容</p>
    <p>這是更多的初始內(nèi)容</p>
  </div>
</div>

可以看到組件<my-component /><slot />節(jié)點(diǎn)最終被父組件中<my-component />節(jié)點(diǎn)下的內(nèi)容所替換敦跌。

Vuejs還支持具名插槽

例如,一個(gè)布局組件<app-layout />

<div class="container">
  <header>
    <slot name="header"></slot>
  </header>
  <main>
    <slot></slot>
  </main>
  <footer>
    <slot name="footer"></slot>
  </footer>
</div>

而在父組件模板中:

<app-layout>
  <h1 slot="header">這里可能是一個(gè)頁(yè)面標(biāo)題</h1>
  <p>主要內(nèi)容的一個(gè)段落柠傍。</p>
  <p>另一個(gè)段落麸俘。</p>
  <p slot="footer">這里有一些聯(lián)系信息</p>
</app-layout>

最終渲染的結(jié)果:

<div class="container">
  <header>
    <h1>這里可能是一個(gè)頁(yè)面標(biāo)題</h1>
  </header>
  <main>
    <p>主要內(nèi)容的一個(gè)段落。</p>
    <p>另一個(gè)段落惧笛。</p>
  </main>
  <footer>
    <p>這里有一些聯(lián)系信息</p>
  </footer>
</div>

插槽分發(fā)的好處體現(xiàn)在从媚,它可以讓組件具有可抽象成模板的能力。組件自身只關(guān)心模板結(jié)構(gòu)患整,具體的內(nèi)容交給父組件去處理拜效,同時(shí),不打破HTML描述DOM結(jié)構(gòu)的語(yǔ)法表達(dá)方式各谚。我覺得這是一項(xiàng)很有意義的技術(shù)紧憾,可惜,React對(duì)于這項(xiàng)技術(shù)的支持不是那么友好昌渤。于是我便參考Vuejs的插槽分發(fā)組件赴穗,開發(fā)了一套基于React的插槽分發(fā)組件,可以讓React組件也具模板化的能力膀息。

對(duì)于<AppLayout />組件般眉,我希望可以寫成下面這樣:

class AppLayout extends React.Component {
  static displayName = 'AppLayout'
  
  render () {
    return (
      <div class="container">
        <header>
          <Slot name="header"></Slot>
        </header>
        <main>
          <Slot></Slot>
        </main>
        <footer>
          <Slot name="footer"></Slot>
        </footer>
      </div>
    )
  }
}

在外層使用時(shí),可以寫成這樣:

<AppLayout>
  <AddOn slot="header">
    <h1>這里可能是一個(gè)頁(yè)面標(biāo)題</h1>
  </AddOn>
  <AddOn>
    <p>主要內(nèi)容的一個(gè)段落潜支。</p>
    <p>另一個(gè)段落甸赃。</p>
  </AddOn>
  <AddOn slot="footer">
    <p>這里有一些聯(lián)系信息</p>
  </AddOn>
</AppLayout>

組件的實(shí)現(xiàn)思路

根據(jù)前面所想的,先整理一下實(shí)現(xiàn)思路冗酿。

不難看出埠对,插槽分發(fā)組件需要依靠?jī)蓚€(gè)子組件——插槽組件<Slot />和分發(fā)組件<AddOn />。插槽組件已烤,負(fù)責(zé)打樁,提供分發(fā)內(nèi)容的坑位妓羊。分發(fā)組件胯究,負(fù)責(zé)收集分發(fā)內(nèi)容,并提供給插槽組件去渲染分發(fā)內(nèi)容躁绸,相當(dāng)于插槽的消費(fèi)者裕循。

顯然,這里遇到了一個(gè)問題净刮,<Slot />組件與<AddOn />組件是獨(dú)立的剥哑,如何將<AddOn />的內(nèi)容填充到<Slot />中呢?解決這個(gè)問題不難淹父,兩個(gè)獨(dú)立的模塊需要建立聯(lián)系株婴,就給他們建立一個(gè)橋梁。那么這個(gè)橋梁要如何搭建呢?回過頭來看看之前的設(shè)想的代碼困介。

對(duì)于<AppLayout />組件大审,希望寫成下面這樣:

class AppLayout extends React.Component {
  static displayName = 'AppLayout'
  
  render () {
    return (
      <div class="container">
        <header>
          <Slot name="header"></Slot>
        </header>
        <main>
          <Slot></Slot>
        </main>
        <footer>
          <Slot name="footer"></Slot>
        </footer>
      </div>
    )
  }
}

在外層使用時(shí),寫成這樣:

<AppLayout>
  <AddOn slot="header">
    <h1>這里可能是一個(gè)頁(yè)面標(biāo)題</h1>
  </AddOn>
  <AddOn>
    <p>主要內(nèi)容的一個(gè)段落座哩。</p>
    <p>另一個(gè)段落徒扶。</p>
  </AddOn>
  <AddOn slot="footer">
    <p>這里有一些聯(lián)系信息</p>
  </AddOn>
</AppLayout>

無論是<Slot />還是<AddOn />,其實(shí)都在<AppLayout />的作用域內(nèi)根穷。<Slot /><AppLayout />組件render()方法返回的組件節(jié)點(diǎn)姜骡,而<AddOn />則是<AppLayout />children節(jié)點(diǎn),所以屿良,可以將<AppLayout />視為<Slot /><AddOn />的橋梁的角色圈澈。那么,<AppLayout />通過什么給<Slot /><AddOn />建立聯(lián)系呢管引?這里就用到本文的主角——Context士败。接下來的問題就是,如何使用Context<Slot /><AddOn />建立聯(lián)系褥伴?

前面提到了<AppLayout />這座橋梁谅将。在外層組件,<AppLayout />負(fù)責(zé)通過<AddOn />收集為插槽填充的內(nèi)容重慢。<AppLayout />自身借助Context定義一個(gè)獲取填充內(nèi)容的接口饥臂。在渲染的時(shí)候,因?yàn)?code><Slot />是<AppLayout />渲染的節(jié)點(diǎn)似踱,所以隅熙,<Slot />可以通過Context獲取到<AppLayout />定義的獲取填充內(nèi)容的接口,然后通過這個(gè)接口核芽,獲取到填充內(nèi)容進(jìn)行渲染囚戚。

按照思路實(shí)現(xiàn)插槽分發(fā)組件

由于<AddOn /><AppLayout />children節(jié)點(diǎn),并且<AddOn />是特定的組件轧简,我們可以通過name或者displayName識(shí)別出來驰坊,所以,<AppLayout />在渲染之前哮独,也就是render()return之前拳芙,對(duì)children進(jìn)行遍歷,以slot的值作為key皮璧,將每一個(gè)<AddOn />children緩存下來舟扎。如果<AddOn />沒有設(shè)置slot,那么將其視為給非具名的<Slot />填充內(nèi)容悴务,我們可以給這些非具名的插槽定一個(gè)key睹限,比如叫$$default

對(duì)于<AppLayout />,代碼大致如下:

class AppLayout extends React.Component {
  
  static childContextTypes = {
    requestAddOnRenderer: PropTypes.func
  }
  
  // 用于緩存每個(gè)<AddOn />的內(nèi)容
  addOnRenderers = {}
  
  // 通過Context為子節(jié)點(diǎn)提供接口
  getChildContext () {
    const requestAddOnRenderer = (name) => {
      if (!this.addOnRenderers[name]) {
        return undefined
      }
      return () => (
        this.addOnRenderers[name]
      )
    }
    return {
      requestAddOnRenderer
    }
  }

  render () {
    const {
      children,
      ...restProps
    } = this.props

    if (children) {
      // 以k-v的方式緩存<AddOn />的內(nèi)容
      const arr = React.Children.toArray(children)
      const nameChecked = []
      this.addOnRenderers = {}
      arr.forEach(item => {
        const itemType = item.type
        if (item.type.displayName === 'AddOn') {
          const slotName = item.props.slot || '$$default'
          // 確保內(nèi)容唯一性
          if (nameChecked.findIndex(item => item === stubName) !== -1) {
            throw new Error(`Slot(${slotName}) has been occupied`)
          }
          this.addOnRenderers[stubName] = item.props.children
          nameChecked.push(stubName)
        }
      })
    }

    return (
      <div class="container">
        <header>
          <Slot name="header"></Slot>
        </header>
        <main>
          <Slot></Slot>
        </main>
        <footer>
          <Slot name="footer"></Slot>
        </footer>
      </div>
    )
  }
}

<AppLayout />定義了一個(gè)Context接口requestAddOnRenderer()邦泄,requestAddOnRenderer()接口根據(jù)name返回一個(gè)函數(shù)删窒,這個(gè)返回的函數(shù)會(huì)根據(jù)name訪問addOnRenderers的屬性,addOnRenderers就是<AddOn />的內(nèi)容緩存對(duì)象顺囊。

<Slot />的實(shí)現(xiàn)很簡(jiǎn)單肌索,代碼如下:

//            props,              context
const Slot = ({ name, children }, { requestAddOnRenderer }) => {
  const addOnRenderer = requestAddOnRenderer(name)
  return (addOnRenderer && addOnRenderer()) ||
    children ||
    null
}

Slot.displayName = 'Slot'
Slot.contextTypes = { requestAddOnRenderer: PropTypes.func }
Slot.propTypes = { name: PropTypes.string }
Slot.defaultProps = { name: '$$default' }

可以看到<Slot />通過context獲取到<AppLayout />提供的接口requestAddOnRenderer(),最終渲染的主要對(duì)象就是緩存在<AppLayout />中的<AddOn />的內(nèi)容特碳。如果沒有獲取到指定的<AddOn />的內(nèi)容诚亚,則渲染<Slot />自身的children

<AddOn />更簡(jiǎn)單:

const AddOn = () => null

AddOn.propTypes = { slot: PropTypes.string }
AddOn.defaultTypes = { slot: '$$default' }
AddOn.displayName = 'AddOn'

<AddOn />不做任何事情午乓,僅僅返回null站宗,它的作用就是讓<AppLayout />緩存分發(fā)給插槽的內(nèi)容。

可以讓<AppLayout />更具通用性

通過上文的代碼益愈,基本將<AppLayout />改造成了一個(gè)具備插槽分發(fā)能力的組件梢灭,但是很明顯的,<AppLayout />并不具備通用性蒸其,我們可以將它提升成一個(gè)獨(dú)立通用的組件敏释。

我給這個(gè)組件命名為SlotProvider

function getDisplayName (component) {
  return component.displayName || component.name || 'component'
}

const slotProviderHoC = (WrappedComponent) => {
  return class extends React.Component {
    static displayName = `SlotProvider(${getDisplayName(WrappedComponent)})`

    static childContextTypes = {
      requestAddOnRenderer: PropTypes.func
    }
  
    // 用于緩存每個(gè)<AddOn />的內(nèi)容
    addOnRenderers = {}
  
    // 通過Context為子節(jié)點(diǎn)提供接口
    getChildContext () {
      const requestAddOnRenderer = (name) => {
        if (!this.addOnRenderers[name]) {
          return undefined
        }
        return () => (
          this.addOnRenderers[name]
        )
      }
      return {
        requestAddOnRenderer
      }
    }

    render () {
      const {
        children,
        ...restProps
      } = this.props

      if (children) {
        // 以k-v的方式緩存<AddOn />的內(nèi)容
        const arr = React.Children.toArray(children)
        const nameChecked = []
        this.addOnRenderers = {}
        arr.forEach(item => {
          const itemType = item.type
          if (item.type.displayName === 'AddOn') {
            const slotName = item.props.slot || '$$default'
            // 確保內(nèi)容唯一性
            if (nameChecked.findIndex(item => item === stubName) !== -1) {
              throw new Error(`Slot(${slotName}) has been occupied`)
            }
            this.addOnRenderers[stubName] = item.props.children
            nameChecked.push(stubName)
          }
        })
      }
      
      return (<WrappedComponent {...restProps} />)
    }
  }
}

export const SlotProvider = slotProviderHoC

使用React的高階組件對(duì)原來的<AppLayout />進(jìn)行改造,將其轉(zhuǎn)變?yōu)橐粋€(gè)獨(dú)立通用的組件摸袁。對(duì)于原來的<AppLayout />钥顽,可以使用這個(gè)SlotProvider高階組件,轉(zhuǎn)換成一個(gè)具備插槽分發(fā)能力的組件靠汁。

import { SlotProvider } from './SlotProvider.js'

class AppLayout extends React.Component {
  static displayName = 'AppLayout'
  
  render () {
    return (
      <div class="container">
        <header>
          <Slot name="header"></Slot>
        </header>
        <main>
          <Slot></Slot>
        </main>
        <footer>
          <Slot name="footer"></Slot>
        </footer>
      </div>
    )
  }
}

export default SlotProvider(AppLayout)

通過以上的經(jīng)歷蜂大,可以看到,當(dāng)設(shè)計(jì)開發(fā)一個(gè)組件時(shí)蝶怔,

  • 組件可能需要由一個(gè)根組件和多個(gè)子組件一起合作來完成組件功能奶浦。比如插槽分發(fā)組件實(shí)際上需要SlotProvider<Slot /><AddOn />一起配合使用,SlotProvider作為根組件踢星,而<Slot /><AddOn />都算是子組件澳叉。
  • 子組件相對(duì)于根組件的位置或者子組件之間的位置是不確定。對(duì)于SlotProvider而言斩狱,<Slot />的位置是不確定的耳高,它會(huì)處在被SlotProvider這個(gè)高階組件所包裹的組件的模板的任何位置扎瓶,而對(duì)于<Slot /><AddOn />所踊,他們直接的位置也不確定,一個(gè)在SlotProvider包裝的組件的內(nèi)部概荷,另一個(gè)是SlotProviderchildren秕岛。
  • 子組件之間需要依賴一些全局態(tài)的API或者數(shù)據(jù),比如<Slot />實(shí)際渲染的內(nèi)容來自于SlotProvider收集到的<AddOn />的內(nèi)容。

這時(shí)我們就需要借助一個(gè)中間者作為媒介來共享數(shù)據(jù)继薛,相比額外引入redux這些第三方模塊修壕,直接使用Context可以更優(yōu)雅。

嘗試一下新版本的Context API

使用新版的Context API對(duì)之前的插槽分發(fā)組件進(jìn)行改造遏考。

// SlotProvider.js

function getDisplayName (component) {
  return component.displayName || component.name || 'component'
}

export const SlotContext = React.createContext({
  requestAddOnRenderer: () => {}
})

const slotProviderHoC = (WrappedComponent) => {
  return class extends React.Component {
    static displayName = `SlotProvider(${getDisplayName(WrappedComponent)})`

    // 用于緩存每個(gè)<AddOn />的內(nèi)容
    addOnRenderers = {}
  
    requestAddOnRenderer = (name) => {
      if (!this.addOnRenderers[name]) {
        return undefined
      }
      return () => (
        this.addOnRenderers[name]
      )
    }

    render () {
      const {
        children,
        ...restProps
      } = this.props

      if (children) {
        // 以k-v的方式緩存<AddOn />的內(nèi)容
        const arr = React.Children.toArray(children)
        const nameChecked = []
        this.addOnRenderers = {}
        arr.forEach(item => {
          const itemType = item.type
          if (item.type.displayName === 'AddOn') {
            const slotName = item.props.slot || '$$default'
            // 確保內(nèi)容唯一性
            if (nameChecked.findIndex(item => item === stubName) !== -1) {
              throw new Error(`Slot(${slotName}) has been occupied`)
            }
            this.addOnRenderers[stubName] = item.props.children
            nameChecked.push(stubName)
          }
        })
      }
      
      return (
        <SlotContext.Provider value={
            requestAddOnRenderer: this.requestAddOnRenderer
          }>
          <WrappedComponent {...restProps} />
        </SlotContext.Provider>
      )
    }
  }
}

export const SlotProvider = slotProviderHoC

移除了之前的childContextTypesgetChildContext()慈鸠,除了局部的調(diào)整,整體核心的東西沒有大變化灌具。

// Slot.js

import { SlotContext } from './SlotProvider.js'

const Slot = ({ name, children }) => {
  return (
    <SlotContext.Consumer>
      {(context) => {
        const addOnRenderer = requestAddOnRenderer(name)
          return (addOnRenderer && addOnRenderer()) ||
            children ||
            null
      }}
    </SlotContext.Consumer>
  )
}

Slot.displayName = 'Slot'
Slot.propTypes = { name: PropTypes.string }
Slot.defaultProps = { name: '$$default' }

由于之前就按照生產(chǎn)者消費(fèi)者的模式來使用Context青团,加上組件自身也比較簡(jiǎn)單,因此使用新的API進(jìn)行改造后咖楣,差別不大督笆。

總結(jié)

  • 相比propsstate,React的Context可以實(shí)現(xiàn)跨層級(jí)的組件通信诱贿。
  • Context API的使用基于生產(chǎn)者消費(fèi)者模式娃肿。生產(chǎn)者一方,通過組件靜態(tài)屬性childContextTypes聲明珠十,然后通過實(shí)例方法getChildContext()創(chuàng)建Context對(duì)象料扰。消費(fèi)者一方,通過組件靜態(tài)屬性contextTypes申請(qǐng)要用到的Context屬性宵睦,然后通過實(shí)例的context訪問Context的屬性记罚。
  • 使用Context需要多一些思考,不建議在App中使用Context壳嚎,但如果開發(fā)組件過程中可以確保組件的內(nèi)聚性桐智,可控可維護(hù),不破壞組件樹的依賴關(guān)系烟馅,影響范圍小说庭,可以考慮使用Context解決一些問題。
  • 通過Context暴露API或許在一定程度上給解決一些問題帶來便利郑趁,但個(gè)人認(rèn)為不是一個(gè)很好的實(shí)踐刊驴,需要慎重。
  • 舊版本的Context的更新需要依賴setState()寡润,是不可靠的捆憎,不過這個(gè)問題在新版的API中得以解決。
  • 可以把Context當(dāng)做組件的作用域來看待梭纹,但是需要關(guān)注Context的可控性和影響范圍躲惰,使用之前,先分析是否真的有必要使用变抽,避免過度使用所帶來的一些副作用础拨。
  • 可以把Context當(dāng)做媒介氮块,進(jìn)行App級(jí)或者組件級(jí)的數(shù)據(jù)共享。
  • 設(shè)計(jì)開發(fā)一個(gè)組件诡宗,如果這個(gè)組件需要多個(gè)組件關(guān)聯(lián)組合的滔蝉,使用Context或許可以更加優(yōu)雅。

以上是我的分享內(nèi)容塔沃,如有不足或者錯(cuò)誤的地方蝠引,歡迎批評(píng)指正。

引用

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末蛀柴,一起剝皮案震驚了整個(gè)濱河市乎完,隨后出現(xiàn)的幾起案子掷邦,更是在濱河造成了極大的恐慌谁尸,老刑警劉巖括儒,帶你破解...
    沈念sama閱讀 206,126評(píng)論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異肮韧,居然都是意外死亡融蹂,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,254評(píng)論 2 382
  • 文/潘曉璐 我一進(jìn)店門弄企,熙熙樓的掌柜王于貴愁眉苦臉地迎上來超燃,“玉大人,你說我怎么就攤上這事拘领∫馀遥” “怎么了?”我有些...
    開封第一講書人閱讀 152,445評(píng)論 0 341
  • 文/不壞的土叔 我叫張陵约素,是天一觀的道長(zhǎng)届良。 經(jīng)常有香客問我,道長(zhǎng)圣猎,這世上最難降的妖魔是什么士葫? 我笑而不...
    開封第一講書人閱讀 55,185評(píng)論 1 278
  • 正文 為了忘掉前任,我火速辦了婚禮送悔,結(jié)果婚禮上慢显,老公的妹妹穿的比我還像新娘。我一直安慰自己欠啤,他們只是感情好荚藻,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,178評(píng)論 5 371
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著洁段,像睡著了一般应狱。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上眉撵,一...
    開封第一講書人閱讀 48,970評(píng)論 1 284
  • 那天侦香,我揣著相機(jī)與錄音,去河邊找鬼纽疟。 笑死罐韩,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的污朽。 我是一名探鬼主播散吵,決...
    沈念sama閱讀 38,276評(píng)論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼蟆肆!你這毒婦竟也來了矾睦?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 36,927評(píng)論 0 259
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤炎功,失蹤者是張志新(化名)和其女友劉穎枚冗,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體蛇损,經(jīng)...
    沈念sama閱讀 43,400評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡赁温,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,883評(píng)論 2 323
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了淤齐。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片股囊。...
    茶點(diǎn)故事閱讀 37,997評(píng)論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖更啄,靈堂內(nèi)的尸體忽然破棺而出稚疹,到底是詐尸還是另有隱情,我是刑警寧澤祭务,帶...
    沈念sama閱讀 33,646評(píng)論 4 322
  • 正文 年R本政府宣布内狗,位于F島的核電站,受9級(jí)特大地震影響义锥,放射性物質(zhì)發(fā)生泄漏其屏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,213評(píng)論 3 307
  • 文/蒙蒙 一缨该、第九天 我趴在偏房一處隱蔽的房頂上張望偎行。 院中可真熱鬧,春花似錦贰拿、人聲如沸蛤袒。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,204評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)妙真。三九已至,卻和暖如春荚守,著一層夾襖步出監(jiān)牢的瞬間珍德,已是汗流浹背练般。 一陣腳步聲響...
    開封第一講書人閱讀 31,423評(píng)論 1 260
  • 我被黑心中介騙來泰國(guó)打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留锈候,地道東北人薄料。 一個(gè)月前我還...
    沈念sama閱讀 45,423評(píng)論 2 352
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像泵琳,于是被迫代替她去往敵國(guó)和親摄职。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,722評(píng)論 2 345

推薦閱讀更多精彩內(nèi)容