可靠React組件設計的7個準則之SRP

2021-02-21 前端宇宙

翻譯:劉小夕

原文連結:https://dmitripavlutin.com/7-architectural-attributes-of-a-reliable-react-component/

原文的篇幅非常長,不過內容太過於吸引我,還是忍不住要翻譯出來。此篇文章對編寫可重用和可維護的React組件非常有幫助。但因為篇幅實在太長,我不得不進行了分割,本篇文章重點闡述 SRP,即單一職責原則。

————————我是一條分割線——————

我喜歡React組件式開發方式。你可以將複雜的用戶界面分割為一個個組件,利用組件的可重用性和抽象的DOM操作。

基於組件的開發是高效的:一個複雜的系統是由專門的、易於管理的組件構建的。然而,只有設計良好的組件才能確保組合和復用的好處。

儘管應用程式很複雜,但為了滿足最後期限和意外變化的需求,你必須不斷地走在架構正確性的細線上。你必須將組件分離為專注於單個任務,並經過良好測試。

不幸的是,遵循錯誤的路徑總是更加容易:編寫具有許多職責的大型組件、緊密耦合組件、忘記單元測試。這些增加了技術債務,使得修改現有功能或創建新功能變得越來越困難。

編寫React應用程式時,我經常問自己:

如何正確構造組件?

在什麼時候,一個大的組件應該拆分成更小的組件?

如何設計防止緊密耦合的組件之間的通信?

幸運的是,可靠的組件具有共同的特性。讓我們來研究這7個有用的標準(本文只闡述 SRP,剩餘準則正在途中),並將其詳細到案例研究中。

單一職責

當一個組件只有一個改變的原因時,它有一個單一的職責。

編寫React組件時要考慮的基本準則是單一職責原則。單一職責原則(縮寫:SRP)要求組件有一個且只有一個變更的原因。

組件的職責可以是呈現列表,或者顯示日期選擇器,或者發出 HTTP 請求,或者繪製圖表,或者延遲加載圖像等。你的組件應該只選擇一個職責並實現它。當你修改組件實現其職責的方式(例如,更改渲染的列表的數量限制),它有一個更改的原因。

為什麼只有一個理由可以改變很重要?因為這樣組件的修改隔離並且受控。單一職責原則制了組件的大小,使其集中在一件事情上。集中在一件事情上的組件便於編碼、修改、重用和測試。

下面我們來舉幾個例子

實例1:一個組件獲取遠程數據,相應地,當獲取邏輯更改時,它有一個更改的原因。

發生變化的原因是:

修改伺服器URL

修改響應格式

要使用其他HTTP請求庫

或僅與獲取邏輯相關的任何修改。

示例2:表組件將數據數組映射到行組件列表,因此在映射邏輯更改時有一個原因需要更改。

發生變化的原因是:

你的組件有很多職責嗎?如果答案是「是」,則按每個單獨的職責將組件分成若干塊。

如果您發現SRP有點模糊,請閱讀本文。
在項目早期階段編寫的單元將經常更改,直到達到發布階段。這些更改通常要求組件在隔離狀態下易於修改:這也是 SRP 的目標。

1.1 多重職責陷阱

當一個組件有多個職責時,就會發生一個常見的問題。乍一看,這種做法似乎是無害的,並且工作量較少:

這種幼稚的結構在開始時很容易編碼。但是隨著應用程式的增加和變得複雜,在以後的修改中會出現困難。同時實現多個職責的組件有許多更改的原因。現在出現的主要問題是:出於某種原因更改組件會無意中影響同一組件實現的其它職責。

不要關閉電燈開關,因為它同樣作用於電梯。

這種設計很脆弱。意外的副作用是很難預測和控制的。

例如,<ChartAndForm> 同時有兩個職責,繪製圖表,並處理為該圖表提供數據的表單。<ChartandForm> 就會有兩個更改原因:繪製圖表和處理表單。

當你更改表單欄位(例如,將 <input> 修改為 <select> 時,你無意中中斷圖表的渲染。此外,圖表實現是不可重用的,因為它與表單細節耦合在一起。

解決多重責任問題需要將 <ChartAndForm> 分割為兩個組件:<Chart> 和<Form>。每個組件只有一個職責:繪製圖表或處理表單。組件之間的通信是通過props 實現。

多重責任問題的最壞情況是所謂的上帝組件(上帝對象的類比)。上帝組件傾向於了解並做所有事情。你可能會看到它名為 <Application>、<Manager> 、<Bigcontainer> 或 <Page>,代碼超過500行。

在組合的幫助下使其符合SRP,從而分解上帝組件。(組合(composition)是一種通過將各組件聯合在一起以創建更大組件的方式。組合是 React 的核心。)

1.2 案例研究:使組件只有一個職責

設想一個組件向一個專門的伺服器發出 HTTP 請求,以獲取當前天氣。成功獲取數據時,該組件使用響應來展示天氣信息:

import axios from 'axios';

class Weather extends Component {
    constructor(props) {
        super(props);
        this.state = { temperature: 'N/A', windSpeed: 'N/A' };
    }

    render() {
        const { temperature, windSpeed } = this.state;
        return (
            <div className="weather">
                <div>Temperature: {temperature}°C</div>
                <div>Wind: {windSpeed}km/h</div>
            </div>
        );
    }

    componentDidMount() {
        axios.get('http://weather.com/api').then(function (response) {
            const { current } = response.data;
            this.setState({
                temperature: current.temperature,
                windSpeed: current.windSpeed
            })
        });
    }
}

在處理類似的情況時,問問自己:是否必須將組件拆分為更小的組件?通過確定組件可能會如何根據其職責進行更改,可以最好地回答這個問題。

這個天氣組件有兩個改變原因:

componentDidMount() 中的 fetch 邏輯:伺服器URL或響應格式可能會改變。

render() 中的天氣展示:組件顯示天氣的方式可以多次更改。

解決方案是將 <Weather> 分為兩個組件:每個組件只有一個職責。命名為 <WeatherFetch> 和 <WeatherInfo>。

<WeatherFetch> 組件負責獲取天氣、提取響應數據並將其保存到 state 中。它改變原因只有一個就是獲取數據邏輯改變。

import axios from 'axios';

class WeatherFetch extends Component {
    constructor(props) {
        super(props);
        this.state = { temperature: 'N/A', windSpeed: 'N/A' };
    }

    render() {
        const { temperature, windSpeed } = this.state;
        return (
            <WeatherInfo temperature={temperature} windSpeed={windSpeed} />
        );
    }

    componentDidMount() {
        axios.get('http://weather.com/api').then(function (response) {
            const { current } = response.data;
            this.setState({
                temperature: current.temperature,
                windSpeed: current.windSpeed
            });
        });
    }
}

這種結構有什麼好處?

例如,你想要使用 async/await 語法來替代 promise 去伺服器獲取響應。更改原因:修改獲取邏輯


class WeatherFetch extends Component {
    
    async componentDidMount() {
        const response = await axios.get('http://weather.com/api');
        const { current } = response.data;
        this.setState({
            temperature: current.temperature,
            windSpeed: current.windSpeed
        });
    }
}

因為 <WeatherFetch> 只有一個更改原因:修改 fetch 邏輯,所以對該組件的任何修改都是隔離的。使用 async/await 不會直接影響天氣的顯示。

<WeatherFetch> 渲染 <WeatherInfo>。後者只負責顯示天氣,改變原因只可能是視覺顯示改變。


function WeatherInfo({ temperature, windSpeed }) {
    return (
        <div className="weather">
            <div>Temperature: {temperature}°C</div>
            <div>Wind: {windSpeed} km/h</div>
        </div>
    );
}

讓我們更改<WeatherInfo>,如不顯示 「wind:0 km/h」 而是顯示 「wind:calm」。這就是天氣視覺顯示發生變化的原因:


function WeatherInfo({ temperature, windSpeed }) {
    const windInfo = windSpeed === 0 ? 'calm' : `${windSpeed} km/h`;
    return (
        <div className="weather">
            <div>Temperature: {temperature}°C</div>
            <div>Wind: {windInfo}</div>
        </div>
    );
}

同樣,對 <WeatherInfo> 的修改是隔離的,不會影響 <WeatherFetch> 組件。

<WeatherFetch> 和 <WeatherInfo> 有各自的職責。一種組件的變化對另一種組件的影響很小。這就是單一職責原則的作用:修改隔離,對系統的其他組件產生影響很輕微並且可預測。

1.3 案例研究:HOC 偏好單一責任原則

按職責使用分塊組件的組合併不總是有助於遵循單一責任原則。另外一種有效實踐是高效組件(縮寫為 HOC)

高階組件是一個接受一個組件並返回一個新組件的函數。

HOC 的一個常見用法是為封裝的組件增加新屬性或修改現有的屬性值。這種技術稱為屬性代理:

function withNewFunctionality(WrappedComponent) {
    return class NewFunctionality extends Component {
        render() {
            const newProp = 'Value';
            const propsProxy = {
                ...this.props,
                
                ownProp: this.props.ownProp + ' was modified',
                
                newProp
            };
            return <WrappedComponent {...propsProxy} />;
        }
    }
}
const MyNewComponent = withNewFunctionality(MyComponent);

你還可以通過控制輸入組件的渲染過程從而控制渲染結果。這種 HOC 技術被稱為渲染劫持:

function withModifiedChildren(WrappedComponent) {
    return class ModifiedChildren extends WrappedComponent {
        render() {
            const rootElement = super.render();
            const newChildren = [
                ...rootElement.props.children,
                
                <div>New child</div>
            ];
            return cloneElement(
                rootElement,
                rootElement.props,
                newChildren
            );
        }
    }
}
const MyNewComponent = withModifiedChildren(MyComponent);

如果您想深入了解HOCS實踐,我建議您閱讀「深入響應高階組件」。

讓我們通過一個例子來看看HOC的屬性代理技術如何幫助分離職責。

組件 <PersistentForm> 由 input 輸入框和按鈕 save to storage 組成。更改輸入值後,點擊 save to storage 按鈕將其寫入到 localStorage 中。


input 的狀態在 handlechange(event) 方法中更新。點擊按鈕,值將保存到本地存儲,在  handleclick() 中處理:

class PersistentForm extends Component {
    constructor(props) {
        super(props);
        this.state = { inputValue: localStorage.getItem('inputValue') };
        this.handleChange = this.handleChange.bind(this);
        this.handleClick = this.handleClick.bind(this);
    }

    render() {
        const { inputValue } = this.state;
        return (
            <div className="persistent-form">
                <input type="text" value={inputValue}
                    onChange={this.handleChange} />
                <button onClick={this.handleClick}>Save to storage</button>
            </div>
        );
    }

    handleChange(event) {
        this.setState({
            inputValue: event.target.value
        });
    }

    handleClick() {
        localStorage.setItem('inputValue', this.state.inputValue);
    }
}

遺憾的是: <PersistentForm> 有2個職責:管理表單欄位;將輸如只保存中 localStorage。

讓我們重構一下 <PersistentForm> 組件,使其只有一個職責:展示表單欄位和附加的事件處理程序。它不應該知道如何直接使用存儲:

class PersistentForm extends Component {
    constructor(props) {
        super(props);
        this.state = { inputValue: props.initialValue };
        this.handleChange = this.handleChange.bind(this);
        this.handleClick = this.handleClick.bind(this);
    }

    render() {
        const { inputValue } = this.state;
        return (
            <div className="persistent-form">
                <input type="text" value={inputValue}
                    onChange={this.handleChange} />
                <button onClick={this.handleClick}>Save to storage</button>
            </div>
        );
    }

    handleChange(event) {
        this.setState({
            inputValue: event.target.value
        });
    }

    handleClick() {
        this.props.saveValue(this.state.inputValue);
    }
}

組件從屬性初始值接收存儲的輸入值,並使用屬性函數 saveValue(newValue) 來保存輸入值。這些props 由使用屬性代理技術的 withpersistence() HOC提供。

現在 <PersistentForm> 符合 SRP。更改的唯一原因是修改表單欄位。

查詢和保存到本地存儲的職責由 withPersistence() HOC承擔:

function withPersistence(storageKey, storage) {
    return function (WrappedComponent) {
        return class PersistentComponent extends Component {
            constructor(props) {
                super(props);
                this.state = { initialValue: storage.getItem(storageKey) };
            }

            render() {
                return (
                    <WrappedComponent
                        initialValue={this.state.initialValue}
                        saveValue={this.saveValue}
                        {...this.props}
                    />
                );
            }

            saveValue(value) {
                storage.setItem(storageKey, value);
            }
        }
    }
}

withPersistence()是一個 HOC,其職責是持久的。它不知道有關表單域的任何詳細信息。它只聚焦一個工作:為傳入的組件提供 initialValue 字符串和 saveValue() 函數。

將 <PersistentForm> 和 withpersistence() 一起使用可以創建一個新組件<LocalStoragePersistentForm>。它與本地存儲相連,可以在應用程式中使用:

const LocalStoragePersistentForm
    = withPersistence('key', localStorage)(PersistentForm);

const instance = <LocalStoragePersistentForm />;

只要 <PersistentForm> 正確使用 initialValue 和 saveValue()屬性,對該組件的任何修改都不能破壞 withPersistence() 保存到存儲的邏輯。

反之亦然:只要 withPersistence() 提供正確的 initialValue 和 saveValue(),對 HOC 的任何修改都不能破壞處理表單欄位的方式。

SRP的效率再次顯現出來:修改隔離,從而減少對系統其他部分的影響。

此外,代碼的可重用性也會增加。你可以將任何其他表單 <MyOtherForm> 連接到本地存儲:

const LocalStorageMyOtherForm
    = withPersistence('key', localStorage)(MyOtherForm);

const instance = <LocalStorageMyOtherForm />;

你可以輕鬆地將存儲類型更改為 session storage:

const SessionStoragePersistentForm
    = withPersistence('key', sessionStorage)(PersistentForm);

const instance = <SessionStoragePersistentForm />;

初始版本 <PersistentForm> 沒有隔離修改和可重用性好處,因為它錯誤地具有多個職責。

在不好組合的情況下,屬性代理和渲染劫持的 HOC 技術可以使得組件只有一個職責。

謝謝各位小夥伴願意花費寶貴的時間閱讀本文,如果本文給了您一點幫助或者是啟發,請不要吝嗇你的贊和Star,您的肯定是我前進的最大動力。

https://github.com/YvetteLau/Blog

相關焦點

  • ReactNative 的組件架構設計
    todo 一個問題:由於 react 是面向狀態編程,相當於 react 的組件只關注數據的最終狀態,數據是怎麼產生的並不關心,但是某些場景下,數據如何產生的是會影響到組件的一些行為的【比如一個新增行要求有動畫效果,查詢出的行就不需要等】,這在 RN 中很難描述。。。。。
  • 精通react/vue組件設計之實現一個Tag(標籤)和Empty(空狀態)組件
    本文轉載自【微信公眾號:趣談前端,ID:beautifulFront】經微信公眾號授權轉載,如需轉載與原文作者聯繫前言本文是筆者寫組件設計的第五篇文章,之所以會寫組件設計相關的文章,是因為作為一名前端優秀的前端工程師,面對各種繁瑣而重複的工作,我們不應該按部就班的去
  • 精通react/vue組件設計之實現一個輕量級可擴展的模態框組件
    組件設計思路按照之前筆者總結的組件設計原則,我們第一步是要確認需求.基於react實現一個Modal組件2.1.2.6 實現destroyOnClose這個功能意思是在彈窗關閉時是否清除子元素,我在:《精通react/vue組件設計》之配合React Portals實現一個功能強大的抽屜(Drawer)組件這篇文章中有詳細的介紹,大家感興趣可以研究以下,這裡我指介紹實現過程。
  • 《精通react/vue組件設計》之實現一個健壯的警告提示(Alert)組件
    本文轉載自【微信公眾號:趣談前端,ID:beautifulFront】經微信公眾號授權轉載,如需轉載與原文作者聯繫前言本文是筆者寫組件設計的第七篇文章, 今天帶大家實現一個自帶主題且可關閉的Alert組件, 該組件在諸如Antd或者elementUI等第三方組件庫中都會出現,主要用來提供系統的用戶反饋
  • 精通react/vue組件設計教你實現一個極具創意的加載(Loading)組件
    反饋型組件: 比如Progress進度條, Drawer抽屜, Modal對話框等.其他業務類型所以我們在設計組件系統的時候可以參考如上分類去設計,該分類也是antd, element, zend等主流UI庫的分類方式.
  • react複雜組件 - CSDN
    react 組件引用組件I've been experimenting a lot with both React and Cloudinary over the past six months and it's been a blast -- I'm learning a ton while also recovering the ambition and thirst
  • React組件邏輯復用的那些事兒
    推薦開發者使用高階組件來進行組件邏輯的復用。2. HOCReact 官方文檔對 HOC 進行了如下的定義:高階組件(HOC)是 React 中用於復用組件邏輯的一種高級技巧。HOC 自身不是 React API 的一部分,它是一種基於 React 的組合特性而形成的設計模式。
  • React 中高階函數與高階組件(上)
    : 40px;  border: 1px solid #de3636;  margin: 10px 0;  text-align:center;  line-height: 40px;}</style>經過 UI,可以將上面的公共的部分以及不同的部分給提取出來,封裝成三個組件
  • React源碼分析與實現(一):組件的初始化與渲染
    react最初的設計靈感來源於遊戲渲染的機制:當數據變化時,界面僅僅更新變化的部分而形成新的一幀渲染。所以設計react的核心就是認為UI只是把數據通過映射關係變換成另一種形式的數據,也就是展示方式。傳統上,web架構使用模板或者HTML指令構造頁面。
  • React系列六 - 父子組件通信
    認識組件的嵌套 組件之間存在嵌套關係:如果我們一個應用程式將所有的邏輯都放在一個組件中,那麼這個組件就會變成非常的臃腫和難以維護;所以組件化的核心思想應該是對組件進行拆分,拆分成一個個小的組件;再將這些組件組合嵌套在一起,最終形成我們的應用程式;我們來分析一下下面代碼的嵌套邏輯
  • 推薦11 款 React Native 開源移動 UI 組件
    本文推薦 11 個非常棒的 React Native 開源組件,希望能給移動應用開發者提供幫助。
  • kpc v0.7.8 發布,同時支持 Vue/React/Intact 的前端組件庫
    動機目前市面上已經存在大量組件庫,我們為什麼還要造這個輪子呢?下面我們解釋下這個組件庫開發的動機。
  • 前端之React實戰-組件
    組件是React的核心概念,React 允許將代碼封裝成組件(component),然後像插入普通 HTML 標籤一樣,在網頁中插入這個組件。React.createClass 方法就用於生成一個組件類。對React應用而言,你需要分割你的頁面,使其成為一個個的組件。也就是說,你的應用是由這些組件組合而成的。你可以通過分割組件的方式去開發複雜的頁面或某個功能區塊,並且組件是可以被復用的。
  • 基於jsoneditor二次封裝一個可實時預覽的json編輯器組件react版
    你將學到:react組件封裝的基本思路SOLID (面向對象設計)原則介紹jsoneditor用法使用PropTypes做組件類型檢查>設計思路在介紹組件設計思路之前,有必要介紹一下著名的SOLID原則.
  • 為什麼要在函數組件中使用React.memo?
    ❝其實簡單說來是這樣的:「函數組件本身沒有識別prop值的能力,每次父組件更新的時候都相當於是給子組件一個新的prop值」。所以就相當於B組件這小子因為沒帶腦子(React.memo),是個呆呆的二傻子,所以他做為一個普通組件,就沒有分別prop的能力,當他看到別人都更新了也就跟著把自己也造了一遍,因此就會造成上面🌰中的問題。
  • React組件測試虛擬DOM的方法
    淺渲染將組件渲染成虛擬DOM對象,但與通常的渲染過程不同的是,它只渲染第一層,而不涉及所有子組件,因此處理速度很快。淺渲染不需要真實瀏覽器DOM環境,也無法實現與DOM互動。淺渲染使用react-addons-test-utils模塊的createRenderer()函數創建一個渲染器,渲染器渲染組件並緩存渲染出的虛擬DOM節點,通過調用渲染器的getRenderOutput()函數獲得虛擬DOM對象。
  • React 16.8 之 React Hook
    有了 react hook 就不用寫class(類)了,組件都可以用function來寫,不用再寫生命周期鉤子,最關鍵的,不用再面對this了!2.只在react函數中調用hook不要在普通js函數中調用hookEffect Hook官方文檔中一句話說明 Effect Hook 可以讓你在函數組件中執行副作用操作,可能一句話並不能讓人很好的理解它是幹嘛用的,我們可以接著看上面那個最簡單的計數器的慄子,在它的基礎上加一個小功能
  • 如何用純css打造類materialUI的按鈕點擊動畫並封裝成react組件
    組件設計思路僅僅用上述代碼雖然可以實現一個按鈕點擊的動畫效果,但是並不通用, 也不符合作為一個經驗豐富的程式設計師的風格,所以接下來我們要一步步把它封裝成一個通用的按鈕組件,讓它無所不用.組件的設計思路我這裡參考ant-design的模式, 基於開閉原則,我們知道一個可擴展的按鈕組件一般都具備如下特點:允許用戶修改按鈕樣式對外暴露按鈕事件方法提供按鈕主題和外形配置
  • 基礎篇章:關於 React Native 之 Switch 和 ProgressBarAndroid 組件的講解
    ,分別是 Switch 和 ProgressBarAndroid 組件,由於非常簡單,所以這兩個控制項的講解就直接用一篇文章就夠了。Switch組件今天我們來講Switch組件,什麼是Switch組件呢?我感覺大家都是做過移動開發的,應該知道是做什麼用的。顧名思義:開關,控制組件。在使用它時,我們必須使用onValueChange回調來更新value屬性以響應用戶的動作。如果不更新value屬性,組件只會按一開始給定的value值來渲染且保持不變,看上去就像完全不動。
  • 【翻譯】基於 Create React App路由4.0的異步組件加載(Code...
    當然這個操作不是完全必要的,但如果你好奇的話,請隨意跟隨這篇文章一起用Create React App和 react路由4.0的異步加載方式來幫助react.js構建大型應用。 代碼分割(Code Splitting) 當我們用react.js寫我們的單頁應用程式時候,這個應用會變得越來越大,一個應用(或者路由頁面)可能會引入大量的組件,可是有些組件是第一次加載的時候是不必要的,這些不必要的組件會浪費很多的加載時間。