React躬行記(9)——元件通訊
根據元件之間的巢狀關係(即層級關係)可分為4種通訊方式:父子、兄弟、跨級和無級。
一、父子通訊
在React中,資料是自頂向下單向流動的,而父元件通過props向子元件傳遞需要的資訊是元件之間最常見的通訊方式,如下程式碼所示,父元件Parent向子元件Child傳遞了一個name屬性,其值為一段字串“strick”。
class Parent extends React.Component { render() { return <Child name="strick">子元件</Child>; } } class Child extends React.Component { render() { return <input name={this.props.name} type="text" />; } }
當需要子元件向父元件傳遞資訊時,也能通過元件的props實現,只是要多傳一個回撥函式,如下所示。
class Parent extends React.Component { callback(value) { console.log(value); //輸出從子元件傳遞過來的值 } render() { return <Child callback={this.callback} />; } } class Child extends React.Component { constructor(props) { super(props); this.state = { name: "" }; } handle(e) { this.props.callback(e.target.value); //呼叫父元件的回撥函式 this.setState({ name: e.target.value }); //更新文字框中的值 } render() { return <input value={this.state.name} type="text" onChange={this.handle.bind(this)} />; } }
父元件Parent會傳給子元件Child一個callback()方法,子元件中的文字框註冊了一個onChange事件,在事件處理程式handle()中將回調父元件的callback()方法,並把文字框的值傳遞過去,以此達到反向通訊的效果。
二、兄弟通訊
當兩個元件擁有共同的父元件時,就稱它們為兄弟元件,注意,它們可以不在一個層級上,如圖6所示,C與D或E都是兄弟關係。
圖6 元件樹
兄弟之間不能直接通訊,需要藉助狀態提升的方式間接實現資訊的傳遞,即把元件之間要共享的狀態提升至最近的父元件中,由父元件來統一管理。而任意一個兄弟元件可通過從父元件傳來的回撥函式更新共享狀態,新的共享狀態再通過父元件的props回傳給子元件,從而完成一次兄弟之間的通訊。在下面的例子中,會有兩個文字框(如圖7所示),當向其中一個輸入數字時,鄰近的文字框會隨之改變,要麼加一,要麼減一。
圖7 兩個文字框
class Parent extends React.Component { constructor(props) { super(props); this.state = { type: "p", digit: 0 }; this.plus = this.plus.bind(this); this.minus = this.minus.bind(this); } plus(digit) { this.setState({ type: "p", digit }); } minus(digit) { this.setState({ type: "m", digit }); } render() { let { type, digit } = this.state; let pdigit = type == "p" ? digit : (digit+1); let mdigit = type == "m" ? digit : (digit-1); return ( <> <Child type="p" digit={pdigit} onDigitChange={this.plus} /> <Child type="m" digit={mdigit} onDigitChange={this.minus} /> </> ); } } class Child extends React.Component { constructor(props) { super(props); this.handle = this.handle.bind(this); } handle(e) { this.props.onDigitChange(+e.target.value); } render() { return ( <input value={this.props.digit} type="text" onChange={this.handle} /> ); } }
上面程式碼實現了一次完整的兄弟之間的通訊,具體過程如下所列。
(1)首先在父元件Parent中定義兩個兄弟元件Child,其中type屬性為“p”的子元件用於遞增,綁定了plus()方法;type屬性為“m”的子元件用於遞減,綁定了minus()方法。
(2)然後在子元件Child中接收傳遞過來的digit屬性和onDigitChange()方法,前者會作為文字框的值,後者會在事件處理程式onChange()中被呼叫。
(3)如果在遞增文字框中修改數值,那麼就將新值傳給plus()方法。遞減文字框的處理過程與之類似,只是將plus()方法替換成minus()方法。
(4)最後更新父元件中的兩個狀態:type和digit,完成資訊的傳遞。
三、跨級通訊
在一棵元件樹中,當多個元件需要跨級通訊時,所處的層級越深,那麼需要過渡的中間層就越多,完成一次通訊將變得非常繁瑣,而在資料傳遞過程中那些作為橋樑的元件,其程式碼也將變得冗餘且臃腫。
在React中,還可用Context實現跨級通訊。Context能存放元件樹中需要全域性共享的資料,也就是說,一個元件可以藉助Context跨越層級直接將資料傳遞給它的後代元件。如圖8所示,左邊的資料會通過元件的props逐級顯式地傳遞,右邊的資料會通過Context讓所有元件都可訪問。
圖8 props和context
隨著React v16.3的釋出,引入了一種全新的Context,修正了舊版本中較為棘手的問題,接下來的篇幅將著重分析這兩個版本的Context。
1)舊的Context
在舊版本的Context中,首先要在頂層元件內新增getChildContext()方法和靜態屬性childContextTypes,前者用於生成一個context物件(即初始化Context需要攜帶的資料),後者通過prop-types庫限制該物件的屬性的資料型別,兩者缺一不可。在下面的示例中,Grandpa是頂層元件,Son是中間元件,要傳遞的是一個包含name屬性的物件。
//頂層元件 class Grandpa extends React.Component { getChildContext() { return { name: "strick" }; } render() { return <Son />; } } Grandpa.childContextTypes = { name: PropTypes.string }; //中間元件 class Son extends React.Component { render() { return <Grandson />; } }
然後給後代元件(例如下面的Grandson)新增靜態屬性contextTypes,限制要接收的屬性的資料型別,最後就能通過讀取this.context得到由頂層元件提供的資料。
class Grandson extends React.Component { render() { return <p>{this.context.name}</p>; } } Grandson.contextTypes = { name: PropTypes.string };
從上面的示例中可以看出,跨級通訊的準備工作並不簡單,需要在兩處做不同的配置。React官方建議慎用舊版的Context,因為它相當於JavaScript中的全域性變數,容易造成資料流混亂、重名覆蓋等各種副作用,並且在未來的React版本中有可能被廢棄。
雖然在功能上Context實現了跨級通訊,但本質上資料還是像props一樣逐級傳遞的,因此如果某個中間元件的shouldComponentUpdate()方法返回false的話,就會阻止下層的元件更新Context中的資料。接下來會演示這個致命的缺陷,沿用上一個示例,對兩個元件做些調整。在Grandpa元件中,先讓Context儲存元件的name狀態,再新增一個按鈕,併為其註冊一個能更新元件狀態的點選事件;在Son元件中,新增shouldComponentUpdate()方法,它的返回值是false。在把Grandpa元件掛載到DOM中後,點選按鈕就能發現Context的更新傳播終止於Son元件。
class Grandpa extends React.Component { constructor(props) { super(props); this.state = { name: "strick" }; this.click = this.click.bind(this); } getChildContext() { return { name: this.state.name }; } click() { this.setState({ name: "freedom" }); } render() { return ( <> <Son /> <button onClick={this.click}>提交</button> </> ); } } class Son extends React.Component { shouldComponentUpdate() { return false; } render() { return <Grandson />; } }
2)新的Context
這個版本的Context不僅採用了更符合React風格的宣告式寫法,還可以直接將資料傳遞給後代元件而不用逐級傳遞,一舉衝破了shouldComponentUpdate()方法的限制。下面仍然使用上一節的三個元件,完成一次新的跨級通訊。
const NameContext = React.createContext({name: "strick"}); class Grandpa extends React.Component { render() { return ( <NameContext.Provider value={{name: "freedom"}}> <Son /> </NameContext.Provider> ); } } class Son extends React.Component { render() { return <Grandson />; } } class Grandson extends React.Component { render() { return ( <NameContext.Consumer>{context => <p>{context.name}</p>}</NameContext.Consumer> ); } }
通過上述程式碼可知,新的Context由三部分組成:
(1)React.createContext()方法,接收一個可選的defaultValue引數,返回一個Context物件(例如NameContext),包含兩個屬性:Provider和Consumer,它們是一對相呼應的元件。
(2)Provider,來源元件,它的value屬性就是要傳送的資料,Provider可關聯多個來自於同一個Context物件的Consumer,像NameContext.Provider只能與NameContext.Consumer配合使用。
(3)Consumer,目標元件,出現在Provider之後,可接收一個返回React元素的函式,如果Consumer能找到對應的Provider,那麼函式的引數就是Provider的value屬性,否則就讀取defaultValue的值。
注意,Provider元件會通過Object.is()對其value屬性的新舊值做比較,以此確定是否更新作為它後代的Consumer元件。
四、無級通訊
當兩個沒有巢狀關係(即無級)的元件需要通訊時,可以藉助訊息佇列實現。下面是一個用觀察者模式實現的簡易訊息佇列庫,其處理過程類似於事件系統,如果將訊息看成事件,那麼訂閱訊息就是繫結事件,而釋出訊息就是觸發事件。
class EventEmitter { constructor() { this.events = {}; } sub(event, listener) { //訂閱訊息 if (!this.events[event]) { this.events[event] = { listeners: [] }; } this.events[event].listeners.push(listener); } pub(name, ...params) { //釋出訊息 for (const listener of this.events[name].listeners) { listener.apply(this, params); } } }
EventEmitter只包含了三個方法,它們的功能如下所列:
(1)建構函式,初始化了一個用於快取各類訊息的容器。
(2)sub()方法,將回調函式用訊息名稱分類儲存。
(3)pub()方法,依次執行了指定名稱下的訊息集合。
下面用一個示例演示無級通訊,在Sub元件的建構函式中,會訂閱一次訊息,訊息名稱為"TextBox",回撥函式會接收一個引數,並將其輸出到控制檯。
let emitter = new EventEmitter(); class Sub extends React.Component { constructor(props) { super(props); emitter.sub("TextBox", value => console.log(value)); } render() { return <p>訂閱訊息</p>; } }
在下面的Pub元件中,為文字框註冊了onChange事件,在事件處理程式handle()中釋出名為"TextBox"的訊息集合,並將文字框中的值作為引數傳遞到回撥函式中。
class Pub extends React.Component { constructor(props) { super(props); this.state = { value: "" }; } handle(e) { const value = e.target.value; emitter.pub("TextBox", value); this.setState({ value }); } render() { return <input value={this.state.value} onChange={this.handle.bind(this)} />; } }
Sub元件和Pub元件會像下面這樣,以兄弟的關係掛載到DOM中。當修改文字框中的內容時,就會觸發訊息的釋出,從而完成了一次它們之間的通訊。
ReactDOM.render( <> <Sub /> <Pub /> </>, document.getElementById("container") );
當業務邏輯複雜到一定程度時,普通的訊息佇列可能就捉襟見肘了,此時可以考慮引入Mobx、Redux等專門的狀態管理工具來實現元件之間的通訊。
&n