淺談 React 元件設計

語言: CN / TW / HK

前言

前端元件化一直是老生常談的話題,在前面介紹 React 的時候我們已經提到過 React 的一些優勢,今天則是帶大家瞭解一下元件設計原則。

jQuery 外掛

在開始講 React 元件之前,我們還是要先來聊聊 jQuery。在我看來,jQuery 外掛就已經具備了元件化的雛形。

在 jQuery 還大行其道的時代,我們在網上可以到處一些 jQuery 外掛,裡面有各種豐富的外掛,比如輪播圖、表單、選項卡等等。

元件?外掛?

元件和外掛的區別是什麼呢?外掛是整合到某個平臺上的,比如 Jenkins 外掛、Chrome 外掛等等,jQuery 外掛也類似。平臺只提供基礎能力,外掛則提供一些定製化的能力。 而元件則是偏向於 ui 層面的,將 ui 和業務邏輯封裝起來,供其他人使用。

封裝 DOM 結構

在一些最簡單無腦的 jQuery 外掛中,它們一般會將 DOM 結構直接寫死到外掛中,這樣的外掛拿來即用,但限制也比較大,我們無法修改外掛的 DOM 結構。

js // 輪播圖外掛 $("#slider").slider({ config: { showDot: true, // 是否展示小圓點 showArrow: true // 是否展示左右小箭頭 }, // 一些配置 data: [] // 資料 })

還有另一種極端的外掛,它們完全不把 DOM 放到外掛中,但會要求使用者按照某種固定格式的結構來組織程式碼。 一旦結構不準確,就可能會造成外掛內部獲取 DOM 出錯。但這種外掛的好處在於可以由使用者自定義具體的 DOM 結構和樣式。

```js

< >

$("#slider").slider({ config: {} // 配置 }) ```

當然,你也可以選擇將 DOM 通過配置傳給外掛,外掛內部去做這些渲染的工作,這樣的外掛比較靈活。有沒有發現?這和 render props 模式非常相似。

js $("#slider").slider({ config: {}, // 配置 components: { dot: (item, index) => `<span data-index=${index}></span>`, item: (item, index) => `<li data-index=${index}><img src=${item.src} /></li>` } })

React 元件設計

前面講了幾種 jQuery 外掛的設計模式,其實萬變不離其宗,不管是 jQuery 還是 React,元件設計思想都是一樣的。

image_1e5jp360218jbi4amj918oin89m.png-39.4kB

個人覺得,元件設計應該遵循以下幾個原則:

  1. 適當的元件粒度:一個元件儘量只做一件事。
  2. 複用相同部分:儘量複用不同元件相同的部分。
  3. 松耦合:元件不應當依賴另一個元件。
  4. 資料解耦:元件不應該依賴特定結構的資料。
  5. 結構自由:元件不應該封閉固定的結構。

容器元件與展示元件

顧名思義,容器元件就是類似於“容器”的元件,它可以擁有狀態,會做一些網路請求之類的副作用處理,一般是一個業務模組的入口,比如某個路由指向的元件。我們最常見的就是 Redux 中被 connect 包裹的元件。 容器元件有這麼幾個特點:

  1. 容器元件常常是和業務相關的。
  2. 統一的資料管理,可以作為資料來源給子元件提供資料。
  3. 統一的通訊管理,實現子元件之間的通訊。

展示元件就比較簡單的多,在 React 中元件的設計理念是 view = f(data),展示元件只接收外部傳來的 props,一般內部沒有狀態,只有一個渲染的作用。

image_1e5813mbgbmvc623215qo6pf9.png-29.5kB

適當的元件粒度

在專案開發中,可能你會看到懶同事一個幾千行的檔案,卻只有一個元件,render 函式裡面又臭又長,讓人實在沒有讀下去的慾望。 在寫 React 元件中,我見過最恐怖的程式碼是這樣的:

function App() { let renderHeader, renderBody, renderHTML if (xxxxx) { renderHeader = <h1>xxxxx</h1> } else { renderHeader = <header>xxxxx</header> } if (yyyyy) { renderBody = ( <div className="main"> yyyyy </div> ) } else { ... } if (...) { renderHTML = ... } else { ... } return renderHTML }

當我看到這個元件的時候,我想要搞清楚他最終都渲染了什麼。看到 return 的時候發現只返回了 renderHTML,而這個 renderHTML 卻是經過一系列的判斷得來的,相信沒人願意去讀這樣的程式碼。

拆分 render

我們可以將 render 方法進行一系列的拆分,建立一系列的子 render 方法,將原來大的 render 進行分割。

class App extends Component { renderHeader() {} renderBody() {} render() { return ( <> {this.renderHeader()} {this.renderBody()} ) } }

當然最好的方式還是拆分為更細粒度的元件,這樣不僅方便測試,也可以配合 memo/PureComponent/shouldComponentUpdate 做進一步效能優化。

const Header = () => {} const Body = () => {} const App = () => ( <> <Header /> <Body /> )

複用相同部分

對於可複用的元件部分,我們要儘量做到複用。這部分可以是狀態邏輯,也可以是 HTML 結構。 以下面這個元件為例,這樣寫看上去的確沒有大問題。

``` class App extends Component { state = { on: props.initial } toggle = () => { this.setState({ on: !this.state.on }) } render() { <>
} }

```

但如果我們有個 checkbox 的按鈕,它也會有開關兩種狀態,完全可以複用上面的 this.state.onthis.toggle,那該怎麼辦呢?

timg.gif-85.7kB

就像上一節講的一樣,我們可以利用 render props 來實現狀態邏輯複用。

js // 狀態提取到 Toggle 元件裡面 class Toggle extends Component { constructor(props) { this.state = { on: props.initial } } toggle = () => { this.setState({ on: !this.state.on }) } render() { return this.props.children({ on: this.state.on, toggle: this.toggle }) } } // Toggle 結合 Modal function App() { return ( <Toggle initial={false}> {({ on, toggle }) => ( <> <Button type="primary" onClick={toggle}> Open Modal </Button> <Modal visible={on} onOk={toggle} onCancel={toggle}/> )} </Toggle> ) } // Toggle 結合 CheckBox function App() { return ( <Toggle initial={false}> {({ on, toggle }) => ( <CheckBox visible={on} toggle={toggle} /> )} </Toggle> ) }

或者我們可以用上節講過的 React Hooks 來抽離這個通用狀態和方法。

const useToggle = (initialState) => { const [state, setState] = useState(initialState); const toggle = () => setState(!state); return [state, toggle] }

除了這種狀態邏輯複用外,還有一種 HTML 結構複用。比如有兩個頁面,他們都有頭部、輪播圖、底部按鈕,大體上的樣式和佈局也一致。如果我們對每個頁面都寫一遍,難免會有一些重複,像這種情況我們就可以利用高階元件來複用相同部分的 HTML 結構。

js const PageLayoutHoC = (WrappedComponent) => { return class extends Component { render() { const { title, sliderData, onSubmit, submitText ...props } = this.props return ( <div className="main"> <Header title={title} /> <Slider dataList={sliderData} /> <WrappedComponent {...props} /> <Button onClick={onSubmit}>{submitText}</Button> </div> ) } } }

元件松耦合

松耦合一般是和緊耦合相對立的,兩者的區別在於:

  1. 元件之間彼此依賴方法和資料,這種叫做緊耦合。

  2. 元件之間沒有彼此依賴,一個元件的改動不會影響到其他元件,這種叫做松耦合。

很明顯,我們在開發中應當使用松耦合的方式來設計元件,這樣不僅提供了複用性,還方便了測試。

我們來看一下簡單的緊耦合反面例子:

``` class App extends Component {
state = { count: 0 } increment = () => { this.setState({ count: this.state.count + 1 }) } decrement = () => { this.setState({ count: this.state.count - 1 }) } render() { return } }

class Counter extends Component { render() { return (

{this.props.count}
) } } ```

可以看到上面的 Counter 依賴了父元件的兩個方法,一旦父元件的 incrementdecrement 改了名字呢?那 Counter 元件只能跟著來修改,破壞了 Counter 的獨立性,也不好拿去複用。

所以正確的方式就是,元件之間的耦合資料我們應該通過 props 來傳遞,而非傳遞一個父元件的引用過來。

``` class App extends Component {
state = { count: 0 } increment = () => { this.setState({ count: this.state.count + 1 }) } decrement = () => { this.setState({ count: this.state.count - 1 }) } render() { return } }

class Counter extends Component { render() { return (

{this.props.count}
) } } ```

避免通過 ref 來 setState

對於需要在元件外面通知元件更新的操作,儘量不要在外面通過 ref 來呼叫元件的 setState,比如下面這種: js class Counter extends React.Component { state = { count: 0 } render() { return ( <div>{this.state.count}</div> ); } } class App { ref = React.createRef(); mount() { ReactDOM.render(<Counter ref={this.ref} />, document.querySelector('#app')); } increment() { this.ref.current.setState({ count: this.ref.current.state + 1 }); } } 對於元件 Counter 來說,並不知道外面會直接通過 ref 來呼叫 setState。如果以後發現 count 突然就變化了,也不知道是哪裡出了問題。

對於這種情況我們可以在元件裡面註冊事件,在外面傳送事件來通知。這樣我們可以明確知道元件監聽了外部的事件。 js class Counter extends React.Component { state = { count: 0 } componentDidMount() { event.on('increment', this.increment); } componentWillUnmount() { event.off('increment', this.increment); } increment = () => { this.setState({ count: this.state.count + 1 }); } render() { return ( <div>{this.state.count}</div> ); } } class App { ref = React.createRef(); mount() { ReactDOM.render(<Counter ref={this.ref} />, document.querySelector('#app')); } increment() { event.trigger('increment'); } }

如果在函式元件裡面,React 提供了 useImperativeHandle 這個 Hook,配合 forwardRef 可以支援傳遞函式元件內部的方法給外部使用。

```js import React, { useState, useImperativeHandle, forwardRef } from 'react';

const Counter = forwardRef((props, ref) => { const [count, setCount] = useState(0); useImperativeHandle( ref, () => ({ increment: () => { setCount(count + 1); } }) ); });

class App { ref = React.createRef(); mount() { ReactDOM.render(, document.querySelector('#app')); } increment() { this.ref.current.increment(); } } ```

資料解耦

我們的元件不應該依賴於特定格式的資料,元件中避免出現 data.xxx 這種資料。你可以通過 render props 的模式將要處理的物件傳到外面,讓使用者自行操作。 舉個栗子: 我設計了一個 Tabs 元件,我需要別人給我傳入這樣的結構:

js [ { key: 'Tab1', content: '這是 Tab 1', title: 'Tab1' }, {}, {} ]

這個 key 是我們用來關聯所有 Tab 和當前選中的 Tab 關係的。比如我選中了 Tab1,當前的 Tab1 會有高亮顯示,就通過 key 來關聯。 而我們的元件可能會這樣設計:

js <Tabs data={data} currentTab={'Tab1'} />

這樣的設計不夠靈活,一個是耦合了資料的結構,大多數時候,介面不會返回上圖中的 key 這種欄位,title 也很可能沒有,這就需要我們自己做一下資料格式化。 另一個是封裝了 DOM 結構,如果我們想定製化傳入的 Tab 結構就會變得非常困難。 我們不妨轉換一下思路,當設計一個通用元件的時候,一定要只有一個元件嗎?一定要把資料傳給元件嗎? 那麼來一起看看業界知名的元件庫 Ant Design 是如何設計 Tabs 元件的。

js <Tabs defaultActiveKey="1" onChange={callback}> <TabPane tab="Tab 1" key="1"> Content of Tab Pane 1 </TabPane> <TabPane tab="Tab 2" key="2"> Content of Tab Pane 2 </TabPane> <TabPane tab="Tab 3" key="3"> Content of Tab Pane 3 </TabPane> </Tabs>

Ant Design 將資料和結構進行了解耦,我們不再傳列表資料給 Tabs 元件,而是自行在外部渲染了所有的 TabPane,再將其作為 Children 傳給 Tabs,這樣的好處就是元件的結構更加靈活,TabPane 裡面隨便傳什麼結構都可以。

結構自由

一個好的元件,結構應當是靈活自由的,不應該對其內部結構做過度封裝。我們上面講的 Tabs 元件其實就是結構自由的一種代表。

考慮到這樣一種業務場景,我們頁面上有多個輸入框,但這些輸入框前面的 Icon 都是不一樣的,代表著不同的含義。我相信肯定不會有人會對每個 Icon 都實現一個 Input 元件。

image_1e5jq2o13qj0qmele81aahh6l13.png-10.7kB

你可能會想到我們可以把圖片的地址當做 props 傳給元件,這樣不就行了嗎?但萬一前面不是 Icon 呢?而是一個文字、一個符號呢?

那我們是不是可以把元素當做 props 傳給元件呢?元件來負責渲染,但渲染後長什麼樣還是使用者來控制的。這就是 Ant Design 的實現思路。

code.png-111.5kB

在前面資料解耦中我們就講過了類似的思路,實際上資料解耦和結構自由是相輔相成的。在設計一個元件的時候,很多人往往會陷入一種怪圈,那就是我該怎麼才能封裝更多功能?怎麼才能相容不同的渲染?

這時候我們就不妨換一種思路,如果將渲染交給使用者來控制呢?渲染成什麼樣都由使用者來決定,這樣的元件結構是非常靈活自由的。

當然,如果你把什麼都交給使用者來渲染,這個元件的使用複雜度就大大提高了,所以我們也應當提供一些預設的渲染,即使使用者什麼都不傳也可以渲染預設的結構。

總結

元件設計是一項重要的工作,好的元件我們直接拿來複用可以大大提高效率,不好的元件只會增加我們的複雜度。

在元件設計的學習中,你需要多探索、實踐,多去參考社群知名的元件庫,比如 Ant Design、Element UI、iview 等等,去思考他們為什麼會這樣設計,有沒有更好的設計?如果是自己來設計會怎麼樣?