來源 | https://segmentfault.com/a/1190000023585646
這兩天被臨時抽調到别的項目組去做一個小項目的疊代。這個項目前端是用React,隻是個小型項目是以并沒有使用Redux等狀态管理的庫。剛好遇到了一個小問題:兩個不太相關的元件到底該怎麼進行通信。
我覺得這個問題還挺有趣的,是以把我的思考過程寫下來,大家也可以一起讨論讨論。
雖然重點是要講兩個不相關的元件間的通信,但我還是從最常見的父子元件通信講起,大家就當溫故而知新了。先把完整的總結列出來,然後再詳細展開。
元件間通信方式總結
- 父元件 => 子元件:
- Props
- Instance Methods
子元件 => 父元件:
- Callback Functions
- Event Bubbling
兄弟元件之間:
- Parent Component
不太相關的元件之間:
- Context
- Portals
- Global Variables
- Observer Pattern
- Redux等
1、Props
這是最常見的react元件之間傳遞資訊的方法了吧,父元件通過props把資料傳給子元件,子元件通過this.props去使用相應的資料。
{name}
}class Parent extends React.Component { constructor(props) { super(props) this.state = { name: 'zach' } } render() { return ( this.state.name} /> ) }}
2、Instance Methods
第二種父元件向子元件傳遞資訊的方式有些同學可能會比較陌生,但這種方式非常有用,請務必掌握。原理就是:父元件可以通過使用refs來直接調用子元件執行個體的方法,看下面的例子:
class Child extends React.Component { myFunc() { return "hello" }}class Parent extends React.Component { componentDidMount() { var x = this.foo.myFunc() // x is now 'hello' } render() { return ( <Child ref={foo => { this.foo = foo }} /> ) }}
大緻的過程:
- 首先子元件有一個方法myFunc。
- 父元件給子元件傳遞一個ref屬性,并且采用callback-refs的形式。這個callback函數接收react元件執行個體/原生dom元素作為它的參數。當父元件挂載時,react會去執行這個ref回調函數,并将子元件執行個體作為參數傳給回調函數,然後我們把子元件執行個體指派給this.foo。
- 最後我們在父元件當中就可以使用this.foo來調用子元件的方法咯
了解了這個方法的原理後,我們要考慮的問題就是為啥我們要用這種方法,它的使用場景是什麼?最常見的一種使用場景:比如子元件是一個modal彈窗元件,子元件裡有顯示/隐藏這個modal彈窗的各種方法,我們就可以通過使用這個方法,直接在父元件上調用子元件執行個體的這些方法來操控子元件的顯示/隐藏。
這種方法比起你傳遞一個控制modal顯示/隐藏的props給子元件要美觀多了。
class Modal extends React.Component { show = () => {// do something to show the modal} hide = () => {// do something to hide the modal} render() { return <div>I'm a modaldiv> }}class Parent extends React.Component { componentDidMount() { if(// some condition) { this.modal.show() } } render() { return ( <Modal ref={el => { this.modal = el }} /> ) }}
3、Callback Functions
講完了父元件給子元件傳遞資訊的兩種方式,我們再來講子元件給父元件傳遞資訊的方法。
回調函數這個方法也是react最常見的一種方式,子元件通過調用父元件傳來的回調函數,進而将資料傳給父元件。
onClick('zach')}>Click Me
}class Parent extends React.Component { handleClick = (data) => { console.log("Parent received value from child: " + data) } render() { return ( ) }}
4、 Event Bubbling
這種方法其實跟react本身沒有關系,我們利用的是原生dom元素的事件冒泡機制。
class Parent extends React.Component { render() { return ( <div onClick={this.handleClick}> <Child /> div> ); } handleClick = () => { console.log('clicked') }}function Child { return ( <button>Clickbutton> ); }
巧妙的利用下事件冒泡機制,我們就可以很友善的在父元件的元素上接收到來自子元件元素的點選事件
5、Parent Component
講完了父子元件間的通信,再來看非父子元件之間的通信方法。一般來說,兩個非父子元件想要通信,首先我們可以看看它們是否是兄弟元件,即它們是否在同一個父元件下。
如果不是的話,考慮下用一個元件把它們包裹起來進而變成兄弟元件是否合适。這樣一來,它們就可以通過父元件作為中間層來實作資料互通了。
class Parent extends React.Component { constructor(props) { super(props) this.state = {count: 0} } setCount = () => { this.setState({count: this.state.count + 1}) } render() { return (
count={this.state.count}
/>
onClick={this.setCount}
/>
); }}
6、Context
通常一個前端應用會有一些"全局"性質的資料,比如目前登陸的使用者資訊、ui主題、使用者選擇的語言等等。
這些全局資料,很多元件可能都會用到,當元件層級很深時,用我們之前的方法,就得通過props一層一層傳遞下去,這顯然太麻煩了,看下面的示例:
class App extends React.Component { render() { return "dark" />; }}function Toolbar(props) { return ( );}class ThemedButton extends React.Component { render() { return ; }}
上面的例子,為了讓我們的Button元素拿到主題色,我們必須把theme作為props,從App傳到Toolbar,再從Toolbar傳到ThemedButton,最後Button從父元件ThemedButton的props裡終于拿到了主題theme。
假如我們不同元件裡都有用到Button,就得把theme向這個例子一樣到處層層傳遞,麻煩至極。
是以react為我們提供了一個新api:Context,我們用Context改寫下上例:
const ThemeContext = React.createContext('light');class App extends React.Component { render() { return ( "dark"> ); }}function Toolbar() { return ( );}class ThemedButton extends React.Component { static contextType = ThemeContext; render() { return ; }}
簡單的解析一下:
1、React.createContext建立了一個Context對象,假如某個元件訂閱了這個對象,當react去渲染這個元件時,會從離這個元件最近的一個Provider元件中讀取目前的context值
2、Context.Provider: 每一個Context對象都有一個Provider屬性,這個屬性是一個react元件。
在Provider元件以内的所有元件都可以通過它訂閱context值的變動。具體來說,Provider元件有一個叫value的prop傳遞給所有内部元件,每當value的值發生變化時,Provider内部的元件都會根據新value值重新渲染
3、那内部的元件該怎麼使用這個context對象裡的東西呢?
a、假如内部元件是用class聲明的有狀态元件:我們可以把Context對象指派給這個類的屬性contextType,如上面所示的ThemedButton元件
class ThemedButton extends React.Component { static contextType = ThemeContext; render() { const value = this.context return <Button theme={value} />; } }
b、假如内部元件是用function建立的無狀态元件:我們可以使用Context.Consumer,這也是Context對象直接提供給我們的元件,這個元件接受一個函數作為自己的child,這個函數的入參就是context的value,并傳回一個react元件。可以将上面的ThemedButton改寫下:
function ThemedButton { return ( <ThemeContext.Consumer> {value => <Button theme={value} />} ThemeContext.Consumer> ) }
最後提一句,context對于解決react元件層級很深的props傳遞很有效,但也不應該被濫用。隻有像theme、language等這種全局屬性(很多元件都有可能依賴它們)時,才考慮用context。如果隻是單純為了解決層級很深的props傳遞,可以直接用component composition。
7、Portals
Portals也是react提供的新特性,雖然它并不是用來解決元件通信問題的,但因為它也涉及到了元件通信的問題,是以我也把它列在我們的十種方法裡面。
Portals的主要應用場景是:當兩個元件在react項目中是父子元件的關系,但在HTML DOM裡并不想是父子元素的關系。
舉個例子,有一個父元件Parent,它裡面包含了一個子元件Tooltip,雖然在react層級上它們是父子關系,但我們希望子元件Tooltip渲染的元素在DOM中直接挂載在body節點裡,而不是挂載在父元件的元素裡。這樣就可以避免父元件的一些樣式(如overflow:hidden、z-index、position等)導緻子元件無法渲染成我們想要的樣式。
如下圖所示,父元件是這個紅色框的範圍,并且設定了overflow:hidden,這時候我們的Tooltip元素超出了紅色框的範圍就被截斷了。
怎麼用portals解決呢?
首先,修改html檔案,給portals增加一個節點。
<html> <body> <div id="react-root">div> <div id="portal-root">div> body>html>
然後我們建立一個可複用的portal容器,這裡使用了react hooks的文法,看不懂的先過去看下我另外一篇講解react hooks的文章:30分鐘精通React今年最勁爆的新特性——React Hooks
import { useEffect } from "react";import { createPortal } from "react-dom";const Portal = ({children}) => { const mount = document.getElementById("portal-root"); const el = document.createElement("div"); useEffect(() => { mount.appendChild(el); return () => mount.removeChild(el); }, [el, mount]); return createPortal(children, el)};export default Portal;
最後在父元件中使用我們的portal容器元件,并将Tooltip作為children傳給portal容器元件。
const Parent = () => { const [coords, setCoords] = useState({}); return <div style={{overflow: "hidden"}}> <Button> Hover me Button> <Portal> <Tooltip coords={coords}> Awesome content that is never cut off by its parent container! Tooltip> Portal> div>}
這樣就ok啦,雖然父元件仍然是overflow: hidden,但我們的Tooltip再也不會被截斷了,因為它直接超脫了,它渲染到body節點下的
總結下适用的場景: Tooltip、Modal、Popup、Dropdown等等
8、Global Variables
哈哈,這也不失為一個可行的辦法啊。當然你最好别用這種方法。
class ComponentA extends React.Component { handleClick = () => window.a = 'test' ...}class ComponentB extends React.Component { render() { return <div>{window.a}div> }}
9、Observer Pattern
觀察者模式是軟體設計模式裡很常見的一種,它提供了一個訂閱模型,假如一個對象訂閱了某個事件,當那個事件發生的時候,這個對象将收到通知。
這種模式對于我們前端開發者來說是最不陌生的了,因為我們經常會給某些元素添加綁定事件,會寫很多的event handlers,比如給某個元素添加一個點選的響應事件elm.addEventListener('click', handleClickEvent),每當elm元素被點選時,這個點選事件會通知elm元素,然後我們的回調函數handleClickEvent會被執行。
這個過程其實就是一個觀察者模式的實作過程。
那這種模式跟我們讨論的react元件通信有什麼關系呢?當我們有兩個完全不相關的元件想要通信時,就可以利用這種模式,其中一個元件負責訂閱某個消息,而另一個元素則負責發送這個消息。
javascript提供了現成的api來發送自定義事件: CustomEvent,我們可以直接利用起來。
首先,在ComponentA中,我們負責接受這個自定義事件:
class ComponentA extends React.Component { componentDidMount() { document.addEventListener('myEvent', this.handleEvent) } componentWillUnmount() { document.removeEventListener('myEvent', this.handleEvent) } handleEvent = (e) => { console.log(e.detail.log) //i'm zach }}
然後,ComponentB中,負責在合适的時候發送該自定義事件:
class ComponentB extends React.Component { sendEvent = () => { document.dispatchEvent(new CustomEvent('myEvent', { detail: { log: "i'm zach" } })) } render() { return <button onClick={this.sendEvent}>Sendbutton> }}
這樣我們就用觀察者模式實作了兩個不相關元件之間的通信。當然現在的實作有個小問題,我們的事件都綁定在了document上,這樣實作起來友善,但很容易導緻一些沖突的出現,是以我們可以小小的改良下,獨立一個小子產品EventBus專門這件事:
class EventBus { constructor() { this.bus = document.createElement('fakeelement'); } addEventListener(event, callback) { this.bus.addEventListener(event, callback); } removeEventListener(event, callback) { this.bus.removeEventListener(event, callback); } dispatchEvent(event, detail = {}){ this.bus.dispatchEvent(new CustomEvent(event, { detail })); }}export default new EventBus
然後我們就可以愉快的使用它了,這樣就避免了把所有事件都綁定在document上的問題:
import EventBus from './EventBus'class ComponentA extends React.Component { componentDidMount() { EventBus.addEventListener('myEvent', this.handleEvent) } componentWillUnmount() { EventBus.removeEventListener('myEvent', this.handleEvent) } handleEvent = (e) => { console.log(e.detail.log) //i'm zach }}class ComponentB extends React.Component { sendEvent = () => { EventBus.dispatchEvent('myEvent', {log: "i'm zach"})) } render() { return <button onClick={this.sendEvent}>Sendbutton> }}
最後我們也可以不依賴浏覽器提供的api,手動實作一個觀察者模式,或者叫pub/sub,或者就叫EventBus。
function EventBus() { const subscriptions = {}; this.subscribe = (eventType, callback) => { const id = Symbol('id'); if (!subscriptions[eventType]) subscriptions[eventType] = {}; subscriptions[eventType][id] = callback; return { unsubscribe: function unsubscribe() { delete subscriptions[eventType][id]; if (Object.getOwnPropertySymbols(subscriptions[eventType]).length === 0) { delete subscriptions[eventType]; } }, }; }; this.publish = (eventType, arg) => { if (!subscriptions[eventType]) return; Object.getOwnPropertySymbols(subscriptions[eventType]) .forEach(key => subscriptions[eventType][key](arg)); };}export default EventBus;
10、Redux等
最後終于來到了大家喜聞樂見的Redux等狀态管理庫,當大家的項目比較大,前面講的9種方法已經不能很好滿足項目需求時,才考慮下使用redux這種狀态管理庫。這裡就先不展開講解redux了...否則我花這麼大力氣講解前面9種方法的意義是什麼???
總結
十種方法,每種方法都有對應的适合它的場景,大家在設計自己的元件前,一定要好好考慮清楚采用哪種方式來解決通信問題。
文初提到的那個小問題,最後我采用方案9,因為既然是小疊代項目,又是改别人的代碼,當然最好避免對别人的代碼進行太大幅度的改造。
而pub/sub這種方式就挺小巧精緻的,既不需要對别人的代碼結構進行大改動,又可以滿足産品需求。