Hooks 時代,如何寫出高質(zhì)量的 react 和 vue 組件?

vue和react都已經(jīng)全面進入了hooks時代(在vue中也稱為組合式api,為了方便后面統(tǒng)一稱為hooks),然而受到以前react中類組件和vue2寫法的影響,很多開發(fā)者都不能及時轉(zhuǎn)換過來,以致于開發(fā)出一堆面條式代碼,整體的代碼質(zhì)量反而不如改版以前了。

hooks組件到底應(yīng)該如何寫,我也曾為此迷惘過一段時間。特別我以前以react開發(fā)居多,但在轉(zhuǎn)到新崗位后又變成了使用vue3開發(fā),對于兩個框架在思維方式和寫法的不同上,很是花了一段時間適應(yīng)。好在幾個月下來,我發(fā)現(xiàn)二者雖然在寫法上有區(qū)別之處,但思想上卻大同小異。

所以在比較了兩個框架的異同后,我總結(jié)出了一套通用的hooks api的抽象方式,在這里分享給大家。

0、概述
一個組件內(nèi)部的所有代碼——無論vue還是react——都可以抽象成以下幾個部分:

組件視圖,組件中用來描述視覺效果的部分,如css和html、react的jsx或者vue的template代碼
組件相關(guān)邏輯,如組件生命周期,按鈕交互,事件等
業(yè)務(wù)相關(guān)邏輯,如登錄注冊,獲取用戶信息,獲取商品列表等與組件無關(guān)的業(yè)務(wù)抽象
單獨拆分這三塊并不難,難的是一個組件可能寫得特別復(fù)雜,里面可能包含了多個視圖,每個視圖相互之間又有交互;同時又可能包含多個業(yè)務(wù)邏輯,多個業(yè)務(wù)的函數(shù)和變量雜亂無章地隨意放置,導(dǎo)致后續(xù)維護的時候要在代碼之間反復(fù)橫跳。

要寫出高質(zhì)量的組件,可以思考以下幾個問題:

1.組件什么時候拆?怎么拆?
一個常見的誤區(qū)是,只有需要復(fù)用的時候才去拆分組件,這種看法顯然過于片面了。你可以思考一下,自己是如何抽象一個函數(shù)的,你只會在代碼需要復(fù)用的時候才抽出一個函數(shù)嗎?顯然不是。

因為函數(shù)不僅有代碼復(fù)用的功能,還具有一定的描述性質(zhì)以及代碼封閉性。這種特性使得我們看到一個函數(shù)的時候,不必關(guān)注代碼細(xì)節(jié),就能大概知道這部分代碼是干啥的。

我們還可以再用函數(shù)將一部分函數(shù)組合起來,形成更高層級的抽象。按國內(nèi)流行的說法,高層級的抽象被稱為粗粒度,低層級的抽象被稱為細(xì)粒度,不同粗細(xì)粒度的抽象可以稱它們?yōu)椴煌某橄髮蛹?。并且一個理想的函數(shù)內(nèi)部,一般只會包含同一抽象層級的代碼。

組件的拆分也可以遵循同樣的道理。我們可以按照當(dāng)前的結(jié)構(gòu)或者功能、業(yè)務(wù),將組件拆分為功能清晰且單一、與外部耦合程度低的組件(即所謂高內(nèi)聚,低耦合)。如果一個組件里面干了太多事,或者依賴的外部狀態(tài)太多,那么就不是一個容易維護的組件了。


然而,為了保持組件功能單一,我們是不是要將組件拆分得特別細(xì)才可以呢?事實并非如此。因為上面說過,抽象是有粗細(xì)粒度之分的,也許一個組件從較細(xì)的粒度來講功能并不單一,但是從較粗的粒度來說,可能他們的功能就是單一的了。

例如登錄和注冊是兩個不同的功能,但是你從更高層級的抽象來看,它們都屬于用戶模塊的一部分。

所以是否要拆分組件,最關(guān)鍵還是得看復(fù)雜度。如果一個頁面特別簡單,那么不進行拆分也是可以,有時候拆分得過于細(xì)可能反而不利于維護。

如何判斷一個組件是否復(fù)雜?恐怕這里不能給出一個準(zhǔn)確的答案,畢竟代碼的實現(xiàn)方式千奇百怪,很難有一個機械的標(biāo)準(zhǔn)評判。但是我們不妨站在第三方角度看看自己的代碼,如果你是一個工作一年的程序員,是否能比較容易地看懂這里的代碼?如果不能就要考慮進行拆分了。如果你非要一個機械的判斷標(biāo)準(zhǔn),我建議是代碼控制在200行內(nèi)。

總結(jié)一下,拆分組件的時候可以參考下面幾個原則:

拆分的組件要保持功能單一。即組件內(nèi)部代碼的代碼都只跟這個功能相關(guān);
組件要保持較低的耦合度,不要與組件外部產(chǎn)生過多的交互。如組件內(nèi)部不要依賴過多的外部變量,父子組件的交互不要搞得太復(fù)雜等等。
用組件名準(zhǔn)確描述這個組件的功能。就像函數(shù)那樣,可以讓人不用關(guān)心組件細(xì)節(jié),就大概知道這個組件是干嘛的。如果起名比較困難,考慮下是不是這個組件的功能并不單一。
2.如何組織拆分出的組件文件?
拆分出來的組件應(yīng)該放在哪里呢?一個常見的錯誤做法是一股腦放在一個名為components文件夾里,最后搞得這個文件夾特別臃腫。我的建議是相關(guān)聯(lián)的代碼最好盡量聚合在一起。

為了讓相關(guān)聯(lián)的代碼聚合到一起,我們可以把頁面搞成文件夾的形式,在文件夾內(nèi)部存放與當(dāng)前文件相關(guān)的組成部分,并將表示頁面的組件命名為index放在文件夾下。再在該文件夾下創(chuàng)建components目錄,將組成頁面的其他組件放在里面。

如果一個頁面的某個組成部分很復(fù)雜,內(nèi)部還需要拆分成更細(xì)的多個組件,那么就把這個組成部分也做成文件夾,將拆分出的組件放在這個文件夾下。

最后就是組件復(fù)用的問題。如果一個組件被多個地方復(fù)用,就把它單獨提取出來,放到需要復(fù)用它的組件們共同的抽象層級上。 如下:

如果只是被頁面內(nèi)的組件復(fù)用,就放到頁面文件夾下。
如果只是在當(dāng)前業(yè)務(wù)場景下的不同頁面復(fù)用,就放到當(dāng)前業(yè)務(wù)模塊的文件夾下。
如果可以在不同業(yè)務(wù)場景間通用,就放到最頂層的公共文件夾,或者考慮做成組件庫。
關(guān)于項目文件的組織方式已經(jīng)超過本文討論的范疇,我打算放到以后專門出一篇文章說下如何組織項目文件。這里只說下頁面級別的文件如何進行組織。下面是我常用的一種頁面級別的文件的組織方式:

homePage // 存放當(dāng)前頁面的文件夾
    |-- components // 存放當(dāng)前頁面組件的文件夾
        |-- componentA // 存放當(dāng)前頁面的組成部分A的文件夾
            |-- index.(vue|tsx) // 組件A
            |-- AChild1.(vue|tsx) // 組件a的組成部分1
            |-- AChild2.(vue|tsx) // 組件a的組成部分2
            |-- ACommon.(vue|tsx) // 只在componentA內(nèi)部復(fù)用的組件
        |-- ComponentB.(vue|tsx) // 當(dāng)前頁面的組成部分B
        |-- Common.(vue|tsx) // 組件A和組件B里復(fù)用的組件
    |-- index.(vue|tsx) // 當(dāng)前頁面
實際上這種組織方式,在抽象意義上并不完美,因為通用組件和頁面組成部分的組件并沒有區(qū)分開來。但是一般來說,一個頁面也不會抽出太多組件,為了方便放到一起也不會有太大問題。但是如果你的頁面實在復(fù)雜,那么再創(chuàng)建一個名為common的文件夾也未嘗不可。


3.如何用hooks抽離組件邏輯?






在hooks出現(xiàn)之前,曾流行過一個設(shè)計模式,這個模式將組件分為無狀態(tài)組件和有狀態(tài)組件(也稱為展示組件和容器組件),前者負(fù)責(zé)控制視覺,后者負(fù)責(zé)傳遞數(shù)據(jù)和處理邏輯。

但有了hooks之后,我們完全可以將容器組件中的代碼放進hooks里面。后者不僅更容易維護,而且也更方便把業(yè)務(wù)邏輯與一般組件區(qū)分開來。

在抽離hooks的時候,我們不僅應(yīng)該沿用一般函數(shù)的抽象思維,如功能單一,耦合度低等等,還應(yīng)該注意組件中的邏輯可分為兩種:組件交互邏輯與業(yè)務(wù)邏輯。如何把文章開頭說的視圖、交互邏輯和業(yè)務(wù)邏輯區(qū)分開來,是衡量一個組件質(zhì)量的重要標(biāo)準(zhǔn)。

以一個用戶模塊為例。一個包含查詢用戶信息,修改用戶信息,修改密碼等功能的hooks可以這樣寫:

// 用戶模塊hook
const useUser = () => {
    // react版本的用戶狀態(tài)
    const user = useState({});
    // vue版本的用戶狀態(tài)
    const userInfo = ref({});
    
    // 獲取用戶狀態(tài)
    const getUserInfo = () => {}
    // 修改用戶狀態(tài)
    const changeUserInfo = () => {};
    // 檢查兩次輸入的密碼是否相同
    const checkRepeatPass = (oldPass,newPass) => {}
    // 修改密碼
    const changePassword = () => {};
    
    return {
        userInfo,
        getUserInfo,
        changeUserInfo,
        checkRepeatPass,
        changePassword,
    }
}
交互邏輯的hook可以這么寫(為了方便只寫vue版本的,大家應(yīng)該也都看得懂):

// 用戶模塊交互邏輯hooks
const useUserControl = () => {
    // 組合用戶hook
    const { userInfo, getUserInfo, changeUserInfo, checkRepeatPass, changePassword } = useUser();
    // 數(shù)據(jù)查詢loading狀態(tài)
    const loading = ref(false);
    // 錯誤提示彈窗的狀態(tài)
    const errorModalState = reactive({
        visible: false, // 彈窗顯示/隱藏
        errorText: '',  // 彈窗文案
    });
    
    // 初始化數(shù)據(jù)
    const initData = () => {
        getUserInfo();
    }
    // 修改密碼表單提交
    const onChangePassword = ({ oldPass, newPass ) => {
        // 判斷兩次密碼是否一致
        if (checkRepeatPass(oldPass, newPass)) {
            changePassword();
        } else {
            errorModalState.visible = true;
            errorModalState.text = '兩次輸入的密碼不一致,請修改'
        }
    };
    return {
        // 用戶數(shù)據(jù)
        userInfo,
        // 初始化數(shù)據(jù)
        initData: getUserInfo,
        // 修改密碼
        onChangePassword,
        // 修改用戶信息
        onChangeUserInfo: changeUserInfo,
    }
}
然后只要在組件里面引入交互邏輯的hook即可:

vue版本:

<template>
    <!-- 視圖部分省略,在對應(yīng)btn處引用onChangePassword和onChangeUserInfo即可 -->
</template>
<script setup>
import useUserControl from './useUserControl';
import { onMounted } from 'vue';

const { userInfo, initData, onChangePassword, onChangeUserInfo } = useUserControl();
onMounted(initData);
<script>
react版本:

import useUserControl from './useUserControl';
import { useEffect } from 'react';

const UserModule = () => {
    const { userInfo, initData, onChangePassword, onChangeUserInfo } = useUserControl();
    useEffect(initData, []);
    return (
        // 視圖部分省略,在對應(yīng)btn處引用onChangePassword和onChangeUserInfo即可
    )
}
而拆分出的三個文件放在組件同級目錄下即可;如果拆出的hooks較多,可以單獨開辟一個hooks文件夾。如果有可以復(fù)用的hooks,參考組件拆分里面分享的方法,放到需要復(fù)用它的組件們共同的抽象層級上即可。

可以看到抽離出hooks邏輯后,組件變得十分簡單、容易理解,我們也實現(xiàn)了各個部分的分離。不過這里還有一個問題,那就是上面的業(yè)務(wù)場景實在太過簡單,有必要拆分得這么細(xì),搞出三個文件這么復(fù)雜嗎?

針對邏輯并不復(fù)雜的組件,我個人覺得和組件放到一起也未嘗不可。為了簡便,我們可以只把業(yè)務(wù)邏輯封裝成hooks,而組件的交互邏輯就直接放在組件里面。如下:

<template>
    <!-- 視圖部分省略,在對應(yīng)btn處引用changePassword和changeUserInfo即可 -->
</template>
<script setup>
import { onMounted } from 'vue';
// 用戶模塊hook
const useUser = () => {
    // 代碼省略
}

const { userInfo, getUserInfo, changeUserInfo, checkRepeatPass, changePassword } = useUser();
// 數(shù)據(jù)查詢loading狀態(tài)
const loading = ref(false);
// 錯誤提示彈窗的狀態(tài)
const errorModalState = reactive({
    visible: false, // 彈窗顯示/隱藏
    errorText: '', // 彈窗文案
});

// 初始化數(shù)據(jù)
const initData = () => { getUserInfo(); }
// 修改密碼表單提交
const onChangePassword = ({ oldPass, newPass ) => {};
    
onMounted(initData);
<script>
但是如果邏輯比較復(fù)雜,或者一個組件里面包含多個復(fù)雜業(yè)務(wù)或者復(fù)雜交互,需要抽離出多個hooks的情況,還是單獨抽出一個個文件比較好??偠灾?,依據(jù)代碼復(fù)雜度,選擇相對更容易理解的寫法。

也許單獨一個組件,你并不能體會出hooks寫法的優(yōu)越性。但當(dāng)你封裝出更多的hooks之后,你會逐漸發(fā)現(xiàn)這樣寫的好處。正因為不同的業(yè)務(wù)和功能被封裝在一個個hooks里面,彼此互不干擾,業(yè)務(wù)才能更容易區(qū)分和理解。對于項目的可維護性和可讀性提升是非常之大的。

下圖展示了vue2寫法和vue3 hooks寫法的區(qū)別。圖中相同顏色的代碼塊代表這些代碼是屬于同一個功能的,但vue2的寫法導(dǎo)致本來是相同功能的代碼,卻被拆散到了不同地方(react其實也容易有相同的問題,例如當(dāng)一個組件有多個功能時,不同功能的代碼也很容易混雜到一起)。而通過封裝成一個個hooks,相關(guān)聯(lián)的代碼就很容易被聚合到了一起,且和其他功能區(qū)分開了。


題外話:全局狀態(tài)的管理
現(xiàn)在的前端項目還有一個較為常見的誤區(qū),那就是全局狀態(tài)管理庫(即redux、vuex等)的濫用。依據(jù)抽象層級的思維,實際上很多項目并不需要放較多的狀態(tài)到全局,這種情況利用react和vue自身的狀態(tài)管理就足夠了。

如果非要用狀態(tài)管理庫,也要警惕放較多狀態(tài)和函數(shù)到全局。一個狀態(tài)是否要放到全局,我一般有兩個判斷標(biāo)準(zhǔn):

狀態(tài)是否在多個頁面間共享;
跳轉(zhuǎn)頁面后又返回該頁面,是否需要還原跳轉(zhuǎn)之前的狀態(tài)(僅對react而言,vue有keep-alive)
而全局狀態(tài)管理庫中的函數(shù),則只放置與全局狀態(tài)有關(guān)的邏輯。除此之外的狀態(tài),一律交由react和vue組件本身進行管理。

作者:monet

https://juejin.cn/post/7123961170188304391



作者:monet


歡迎關(guān)注微信公眾號 :前端開發(fā)愛好者


添加好友備注【進階學(xué)習(xí)】拉你進技術(shù)交流群