Render Props and Hooks

大板慄發表於2019-03-29

文章目錄:

  • 什麼是 Render Props
  • Render Props 的應用
  • 什麼是 React Hooks
  • React Hooks 的應用
  • 總結

什麼是 Render Props

簡而言之,只要一個元件中某個屬性的值是函式,那麼就可以說改元件使用了 Render Props 這種技術。聽起來好像就那麼回事兒,那到底 Render Props 有哪些應用場景呢,讓我們還是從簡單的例子講起,假如我們要實現一個打招呼的元件,一開始可能會這麼實現:

const Greeting = props => (
    <div>
        <h1>{props.text}</h1>
    </div>
);

// 然後這麼使用
<Greeting text="Hello ?!" />
複製程式碼

但是如果在打招呼的時候同時還需要傳送一個表情呢,然後可能會這麼實現:

const Greeting = props => (
    <div>
        <h1>{props.text}</h1>
        <p>{props.emoji}</p>
    </div>
);
// how to use
<Greeting text="Hello ?!" emoji="?" />
複製程式碼

然後如果還要加上鍊接呢,又要在 Greeting 元件的內部實現傳送連結的邏輯,很明顯這種方式違背了軟體開發六大原則之一的 開閉原則,即每次修改都要到元件內部需修改。

開閉原則:對修改關閉,對擴充開放。

那有什麼方法可以避免這種方式的修改呢,當然有,也就是接下來要講的 Render Props,不過在此之前,我們先來看一個非常簡單的求和函式:

const sumOf = array => {
    const sum = array.reduce((prev, current) => {
        prev += current;
        return prev;
    }, 0);
    console.log(sum);
}
複製程式碼

這個函式的功能非常簡單,對陣列求和並列印它。但是如果需要把 sum 通過 alert 顯示出來,是不是又要到 sumOf 內部去修改呢,和上面的 Greeting 類似,是的,這兩個函式存在相同的問題,就是當需求有變是,都需要要函式內部去修改。

對於第二個函式,你可能很快就能想出用 回撥函式 去解決:

const sumOf = (array, done) => {
    const sum = array.reduce((prev, current) => {
        prev += current;
        return prev;
    }, 0);
    done(sum);
}

sumOf([1, 2, 3], sum => {
    console.log(sum);
    // or
    alert(sum);
})
複製程式碼

會發現回撥函式很完美的解決了之前存在的問題,每次修改,我們只需要在 sumOf 函式的回撥函式中去修改,而不需要到 sumOf 內部去修改。

反觀 React 元件 Greeting,要解決前面遇到的問題,其實和 sumOf 的回撥函式一樣:

const Greeting = props => {
    return props.render(props);
};
// how to use
<Greeting
    text="Hello ?!"
    emoji="?"
    link="link here"
    render={(props) => (
    <div>
        <h1>{props.text}</h1>
        <p>{props.emoji}</p>
        <a href={props.link}></a>
    </div>
)}></Greeting>
複製程式碼

類比之前的 sumOf 是不是非常的相似,簡直就是一毛一樣:

  • sumOf 中通過執行回撥函式 done 並把 sum 傳入其中,此時只要在 sumOf 函式的第二個引數中傳入一個函式即可獲得 sum 的值,進而做一寫定製化的需求
  • Greeting 中通過執行回撥函式 props.render 並把 props 傳入其中,此時只要在 Greeting 元件的 render 屬性中傳入一個函式即可獲得 props 的值並返回你所需要的 UI

值得一提的是,並不是只有在 render 屬性中傳入函式才能叫 Render Props,實際上任何屬性只要它的值是函式,都可稱之為 Render Props,比如上面這個例子把 render 屬性名改成 children 的話使用上其實更為簡便:

const Greeting = props => {
    return props.children(props);
};
// how to use
<Greeting text="Hello ?!" emoji="?" link="link here">
{(props) => (
    <div>
        <h1>{props.text}</h1>
        <p>{props.emoji}</p>
        <a href={props.link}></a>
    </div>
)}
</Greeting>
複製程式碼

這樣就可以直接在 Greeting 標籤內寫函式了,比起之前在 render 中更為直觀。

所以,React 中的 Render Props 你可以把它理解成 JavaScript 中的回撥函式

Render Props 的應用

上面簡單介紹了什麼是 Render Props,那麼在實際開發中 Render Props 具體有什麼實際應用呢,簡單來講 Render Props 所解決的問題和 高階元件 所解決的問題類似,都是為了 解決程式碼複用的問題

如果對高階元件不熟悉的話,可以看一下筆者之前寫的 React 中的高階元件及其應用場景

簡單實現一個「開關」功能的元件:

class Switch extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            on: props.initialState || false,
        };
    }
    toggle() {
        this.setState({
            on: !this.state.on,
        });
    }
    render() {
        return (
            <div>{this.props.children({
                on,
                toggle: this.toggle,
            })}</div>
        );
    }
}
// how to use
const App = () => (
    <Switch initialState={false}>{({on, toggle}) => {
        <Button onClick={toggle}>Show Modal</Button>
        <Modal visible={on} onSure={toggle}></Modal>
    }}</Switch>
);
複製程式碼

這是一個簡單的 複用顯隱模態彈窗邏輯 的元件,比如要顯示 OtherModal 就直接替換 Modal 就行了,達到複用「開關」邏輯程式碼的目的。

Render Props 更像是 控制反轉(IoC),它只負責定義介面或資料並通過函式引數傳遞給你,具體怎麼使用這些介面或者資料完全取決於你。

如果對控制反轉不熟悉的話,可以看一下筆者之前寫的 前端中的 IoC 理念

Render Props VS HOC

前面提到過 Render Props 所解決的問題和 高階元件 所解決的問題類似,都是為了 解決程式碼複用的問題,那它們有什麼區別呢,讓我們來簡單分析一下它們各自的特點:

HOC

缺點:

  • 由於可能會多次巢狀高階元件,而我們又很難確保每個高階元件中的屬性名是不同的,所以 屬性容易被覆蓋
  • 當在使用高階元件的時候,高階元件相當於一個 黑盒,我們必須去看如何實現才能去使用它:

優點:

  • 可以使用 compose 方法合併多個高階元件然後在使用
// 不要這麼使用
const EnhancedComponent = withRouter(connect(commentSelector)(WrappedComponent));
// 可以使用一個 compose 函式組合這些高階元件
// lodash, redux, ramda 等第三方庫都提供了類似 `compose` 功能的函式
const enhance = compose(withRouter, connect(commentSelector));
const EnhancedComponent = enhance(WrappedComponent);
複製程式碼
  • 呼叫方便(ES6 + 裝飾器語法)
@withData   
class App extends React.Component {}
複製程式碼

Render Props

  • 缺點
    • 巢狀過深也會形成 地獄回撥
  • 優點
    • 解決了 HOC 的缺點

Render Props 和 HOC 並不是非此即彼的關係,明白它們各自的優缺點之後,我們就可以在合適的場景下使用適合的方式去實現了。

什麼是 React Hooks

React Hooks 是 React 16.8 版本推出的新特性,讓函式式元件也可以和類風格元件一樣擁有(類似)「生命週期」,進而更好的在函式式元件中發揮 React 的特性。

React 團隊推出 Hooks 的目的和前面提到的 高階元件Render Props 一樣,都是為了程式碼複用。

在瞭解 React Hooks 之前我們先拿前面 Render Props 的 Switch 例子做個對比:

class Switch extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            on: props.initialState || false,
        };
    }
    toggle() {
        this.setState({
            on: !this.state.on,
        });
    }
    render() {
        return (
            <div>{this.props.children({
                on,
                toggle: this.toggle,
            })}</div>
        );
    }
}
// how to use
const App = () => (
    <Switch initialState={false}>{({on, toggle}) => {
        <Button onClick={toggle}>Show Modal</Button>
        <Modal visible={on} onSure={toggle}></Modal>
    }}</Switch>
);
// use hooks
const App = () => {
    const [on, setOn] = useState(false);
    return (
        <div>
            <Button onClick={() => setOn(true)}>Show Modal</Button>
            <Modal visible={on} onSure={() => setOn(false)}></Modal>
        </div>
    );
}
複製程式碼

通過對比我們很容易發現 Hooks 版本只用了幾個簡單的 API (useState, setOn, on) 就幹掉了 Switch 類元件 20 行程式碼,極大的減少了程式碼量。

程式碼量減少只是 Hooks 所帶來的好處之一,而它更重要的目的是 狀態邏輯複用。(高階元件和 Render Props 也是一樣,狀態邏輯複用其實就是程式碼複用的更具體的說法)

Hooks 的幾個優點

  • 雖然 Hooks 的目的和高階元件、Render Props 一樣,都是為了程式碼複用,但是 Hooks 較高階元件和 Render Props 更為簡單明瞭,且不會造成巢狀地獄。
  • 能更容易的把 UI 和狀態分離
  • 一個 Hooks 中可以引用另外的 Hooks
  • 解決類元件的痛點
    • this 指向容易錯誤
    • 分割在不同宣告週期中的邏輯使得程式碼難以理解和維護
    • 程式碼複用成本高(高階元件容易使程式碼量劇增)

React Hooks 的應用

React 官方提供了以下幾個常用的鉤子:

  • 基礎鉤子:useStateuseEffectuseContext
  • 附加鉤子:useReduceruseCallbackuseMemouseRefuseImperativeHandleuseLayoutEffectuseDebugValue

useEffect

useEffect 鉤子的作用正如其名 —— 為了處理比如 訂閱資料獲取DOM 操作 等等一些副作用。它的作用與 componentDidMount, componentDidUpdatecomponentWillUnmount 這些生命週期函式類似。

比如我們要監聽輸入框 input 的輸入,用 useEffect 我們可以這麼實現:

function Input() {
    const [text, setText] = useState('')
    function onChange(event) {
        setText(event.target.value)
    }
    useEffect(() => {
        // 類似於 componentDidMount 和 componentDidUpdate 兩個生命週期函式
        const input = document.querySelector('input')
        input.addEventListener('change', onChange);
        return () => {
            // 類似於 componentWillUnmount
            input.removeEventListener('change', onChange);
        }
    })
    return (
        <div>
            <input onInput={onChange} />
            <p>{text}</p>
        </div>    
    )
}
複製程式碼

useEffect 鉤子的用法就是把函式作為第一個引數傳入 useEffect 中,在該傳入的函式中我們就可以做一些 有副作用 的事情了,比如操作 DOM 等等。

如果傳入 useEffect 方法的函式返回了一個函式,該 返回的函式 會在元件即將解除安裝時呼叫,我們可以在這裡做一些比如清除 timerID 或者取消之前釋出的訂閱等等一些清除操作,下面這麼寫可能比較直觀:

useEffect(function didUpdate() {
    // do something effects
    return function unmount() {
         // cleaning up effects
    }
})
複製程式碼

useEffect 只傳入一個引數時,每次 render 之後都會執行 useEffect 函式

useEffect(() => {
    // render 一次,執行一次
    console.log('useEffect');
})
複製程式碼

useEffect 傳入第二個引數是陣列時,只有當陣列的值(依賴)發生變化時,傳入回撥函式才會執行,比如下面這種情況:

雖然 React 的 diff 演算法在 DOM 渲染時只會更新變化的部分,但是卻無法識別到 useEffect 內的變化,所以需要開發者通過第二個引數告訴 React 用到了哪些外部變數。

useEffect(() => {
    document.title = title
}, [title])
複製程式碼

因為 useEffect 回撥內部用到了外部的 title 變數,所以如果需要僅當 title 值改變時才執行回撥的話,只需在第二個引數中傳入一個陣列,並把內部所依賴的變數寫在陣列中,此時如果 title 值改變了的話,useEffect 回撥內部就可以通過傳入的依賴判斷是否需要執行回撥。

所以如果給 useEffect 第二個引數傳入一個空陣列的話,useEffect 的回撥函式只會在首次渲染之後執行一次:

useEffect(() => {
    // 只會在首次 render 之後執行一次
    console.log('useEffect')
}, [])
複製程式碼

useContext

React 中有個 context 的概念,讓我們可以 跨元件共享狀態,無需通過 props 層層傳遞,一個簡單的例子:

redux 就是利用 React 的 context 的特性實現跨元件資料共享的。

const ThemeContext = React.createContext();
function App() {
    const theme = {
        mode: 'dark',
        backgroundColor: '#333',
    }
    return (
        <ThemeContext.Provider value={theme}>
            <Display />
        </ThemeContext.Provider>
    )
}

function Display() {
    return (
        <ThemeContext.Consumer>
            {({backgroundColor}) => <div style={{backgroundColor}}>Hello Hooks.</div>}
        </ThemeContext.Consumer>
    )
}
複製程式碼

下面是 useContext 版本:

function Display() {
    const { backgroundColor } = useContext(ThemeContext);
    return (<div style={{backgroundColor}}>Hello Hooks.</div>)
}
複製程式碼

巢狀版 Consumer

function Header() {
    return (
        <CurrentUser.Consumer>
            {user =>
                <Notifications.Consumer>
                    {notifications =>
                        <header>
                            Hello {user.name}!
                            You have {notifications.length} notifications.
                        </header>
                    }
                </Notifications.Consumer>
            }
        </CurrentUser.Consumer>
    );
}
複製程式碼

useContext 拍平:

function Header() {
    const user = useContext(CurrentUser)
    const notifications = useContext(Notifications)
    return (
        <header>
            Hello {user.name}!
            You have {notifications.length} notifications.
        </header>
    )
}
複製程式碼

emm... 這效果有點類似用 asyncawait 改造地獄回撥的感覺。

以上就是 React 的基礎 Hooks。對於其他官方提供的 Hooks 如果感興趣建議直接閱讀文件,本文就不一一介紹了。

使用中需要注意的點

React Hooks 在帶給我們方便的同時,我們也需要遵循它們的一些約定,不然效果只會變得適得其反:

  • 避免在 迴圈、條件判斷、巢狀函式 中呼叫 Hooks,保證呼叫順序的穩定;
  • 只有 函式定義元件Hooks 可以呼叫 Hooks,避免在 類元件 或者 普通函式 中呼叫;
  • 不能在 useEffect 中使用 useState,React 會報錯提示;
  • 類元件不會被替換或廢棄,不需要強制改造類元件,兩種方式能並存

總結

Render Props 的一個核心思想就是在元件內部通過呼叫 this.props.children() (當然可以是其他任意值是函式屬性名)把元件內部的一些狀態傳遞出去(參考回撥函式),然後在元件外部對應屬性的函式中通過函式的引數來獲取元件內部的狀態,進而在該函式中處理相應的 UI 或邏輯。而 Hooks 有點像是 Render Props 的 拍平版 (參考前面 useContext)栗子。

目前為止,介紹了 React 程式碼複用的三種方式:

  • Render Props
  • Hooks
  • HOC

通過對比發現,Hooks 的方式的優勢最大,解決解決了另外兩種方式的一些痛點,所以建議使用。

相關閱讀:

前端小專欄

更多幹貨請關注公眾號「前端小專欄:QianDuanXiaoZhuanLan

相關文章