沐鳴娛樂_【重構】使用 Hooks 讓代碼更易於變更

重構過程中,肯定會遇到新的代碼如何做技術選型的問題,要考慮到這套技術的生命力,也就是他是否是更新的技術,還有他的靈活和拓展性,期望能夠達到在未來至少 3 年內不需要做大的技術棧升級。我的這次重構經歷是把 jQuery 的代碼變為 react ,你品品,算是最難,勞動最密集的重構任務了吧。看多了之前代碼動輒上千行的 Class ,混亂的全局變量使用,越來越覺得,代碼一定要寫的簡單,不要使用過多的黑科技,尤其是各種設計模式,為了復用而迭代出來的海量 if 判斷。代碼不是給機器看的,是給人看的,他需要讓後來人快速的看懂,還要能讓別人在你的代碼的基礎上快速的迭代新的需求。所以我們需要想清楚,用什麼技術棧,怎麼組織代碼。

為什麼要用 Function Component

對於 Class Component 和 Function Component 之爭由來已久。從我自身的實踐來看,我覺得這是兩種不同的編程思路。

Class Component 面向對象編程 繼承 生命周期
Function Component 函數式編程 組合 數據驅動

為什麼不用 Class

首先,如果我們使用面向對象這種編程方式,我們要注意,他不只是定義一個 Class 那麼簡單的事情,我們知道面向對象有三大特性,繼承,封裝,多態。

首先前端真的適合繼承的方式嗎?準確的說,UI 真的適合繼承的方式嗎?在真實世界里,抽象的東西更適合定義成一個類,類本來的意思就是分類和類別,正如我們把老虎,貓,獅子這些生物統稱為動物,所以我們就可以定義一個動物的類,但是真實世界並沒有動物這種實體,但是頁面 UI 都是真實存在可以看到的東西,我們可以把一個頁面分成不同的區塊,然後區塊之間採用的是「組合」的方式。因此我認為 UI 組件不適合繼承,更應該組合。如果你寫過繼承類的組件,你將很難去重構,甚至是重寫他。

封裝講究使用封裝好的方法對外暴露類中的屬性,但是我們的組件基本是通過 props 暴露內部事件和數據,通過 Ref 暴露內部方法,本質上並沒有使用封裝的特性。

多態就更少用了,多態更多是基於接口,或者抽象類的,但是 js 這塊比較弱,用 TS 或許會好一些。

綜上,作為前端 UI 編程,我更傾向於使用函數組合的方式。

為什麼要用數據變化驅動

不論是在 react 或者在 vue 里,都講究數據的變化,數據與視圖的綁定關係,數據驅動,數據的變化引起 UI 的重新渲染,但是生命周期在描述這個問題的時候,並不直接,在 Class Component 里,我們如何檢測某個數據的變化呢,基本是用 shouldUpdate 的生命周期,為什麼我們在編程的時候,正在關注數據和業務的時候,還要關心一個生命周期呢,這部分內容對於業務來說更像是副作用,或者不應該暴露給開發者的。

綜上,是我認為 Function Component + Hooks 編程體驗更好的地方,但是這也只是一個相對片面的角度,並沒有好壞之分,畢竟連 React 的官方都說,兩種寫法沒有好壞之分,性能差距也幾乎可以忽略,而且 React 會長期支持這兩種寫法。

hooks:真正的響應式編程

到底是什麼是響應式編程?大家各執一詞,模模糊糊,懵懵懂懂。很多人沒有把他的本質說明白。從我多年的編程經驗來看,響應式編程就是「使用異步數據流編程」。我們來看看前端在處理異步操作的時候通常是怎麼做的,常見的異步操作有異步請求和頁面的鼠標操作事件,在處理這樣的操作的時候,我們通常採取的方法是事件循環,也就是異步事件流的方式。但是事件循環並沒有顯式的解決事件依賴問題,而是需要我們自己在編碼的時候做好調用順序的管理,比如:

const x = 1;
const a = (x) => new Promise((r, j)=>{
  const y = x + 1;
    r(y);
});
const b = (y) => new Promise((r, j)=>{
  const z = y + 1;
    r(z);
});
const c = (z) =>  new Promise((r, j)=>{
  const w = z + 1;
    r(w);
});
// 上面是三個異步請求,他們之間有依賴關係,我們通常的操作是
a(x).then((y)=>{
    b(y).then((z)=>{
      c(z).then((w)=>{
          // 最終的結果
      console.log(w);
      })
  })
})

上述的基於事件流的回調方式,我們使用 Hooks 來替換的話,就是這樣的:

import { useState, useEffect } from 'react';

const useA = (x) => {
    const [y, setY] = useState();
  useEffect(()=>{
    // 假設此處包含異步請求
      setY(x + 1);
  }, [x]);
  return y;
}

const useB = (y) => {
    const [z, setZ] = useState();
  useEffect(()=>{
    // 假設此處包含異步請求
      setZ(y + 1);
  }, [y]);
  return z;
}

const useC = (z) => {
    const [w, setW] = useState();
  useEffect(()=>{
    // 假設此處包含異步請求
      setW(z + 1);
  }, [z]);
  return w;
}

// 上面是三個是自定義 Hooks,他表明了每個變量數據之間的依賴關係,你甚至不需要
// 知道他們每個異步請求的返回順序,只需要知道數據是否發生了變化。
const x = 1;
const y = useA(x);
const z = useB(y);
const w = useC(z);
// 最終的結果
console.log(w);

我們從上面的例子看到, Hooks 的寫法,簡直就像是在進行簡單的過程式編程一樣,步驟化,邏輯清晰,而且每個自定義 Hooks 你可以把他理解為一個函數,他不需要與外界共享狀態,他是自封閉的,可以很方便的進行測試。

開始精簡代碼

我們基於 React Hooks 提供的工具和上面講的響應式編程的思維,開始我們的精簡代碼之旅,這次旅程可以概括為:遇到千行代碼文件怎麼辦?拆分最有效!怎麼拆分?先按照功能模塊來分文件,這裏的功能模塊是指相同的語法結構,比如副作用函數,事件處理函數等。單個文件內可以按照具體實現寫多個自定義 Hooks 和函數。這樣做的最終目的就是,讓主文件里只保留這個組件要實現的業務邏輯的步驟。


為什麼會有上千行的單個代碼文件?

如果我們把一個組件的所有代碼都寫到一個組件里,那麼極有可能會出現一個文件里有上千行代碼的情況,如果你用的是 Function Component 來寫這個組件的話,那麼就會出現一個函數里有上千行代碼的情況。當然上千行代碼的文件對於一個健全的開發者來說都是不可忍受的,對於後來的重構者來說也是一個大災難。

為什麼要把這個代碼都放到一個文件里?拆分下不香嗎?那下面的問題就變成了如何拆分一個組件,要拆分一個組件,我們要先知道一個典型的組件是什麼樣子的。

一個典型的組件

Hooks 是個新東西,他像函數一樣靈活,甚至不包含我選用了上面的方式來編寫新的代碼,那我們來看看一個典型的基於 Function Component + Hooks 的組件包含什麼?

import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import {
  Row, Select,
} from 'antd';
import Service from '@/services';

let originList = [];
const Demo = ({
  onChange,
  value,
  version,
}) => {
  // 狀態管理
  const [list, setList] = useState([]);

  // 副作用函數 
  useEffect(() => {
    const init = async () => {
        const list = await Service.getList(version);
        originList = list;
        setList(list);
    };
    init();
  }, []);

  // 事件 handler
  const onChangeHandler = useCallback((data) => {
    const item = { ...val, value: val.code, label: val.name };
    onChange(item);
  }, [onChange]);
  
  const onSearchHandler = useCallback((val) => {
    if (val) {
      const listFilter = originList.filter(item => item.name.indexOf(val) > -1);
      setList(listFilter);
    } else {
      setList(originList);
    }
  }, []);
  
  // UI 組件渲染
  return (
    <Row>
        <Select
        labelInValue
        showSearch
        filterOption={false}
        value={value}
        onSearch={onSearchHandler}
        onChange={onChangeHandler}
        >
         {list.map(option => (<Option value={option.id} key={option.id}>{option.name}</Option>))}
        </Select>
    </Row>
  );
};

export default Demo;

從上面的例子我們可以看出,一個基本的 Function Component 包含哪些功能模塊:

  • useState 為主的狀態管理
  • useEffect 為主的副作用管理
  • useCallback 為主的事件 handler
  • UI 部分
  • 轉換函數,用於請求返回數據的轉換,或者一些不具有通用性的工具函數

拆分功能模塊

首先,我們把上面講到的功能模塊拆分成多個文件:

|— container
        |— hooks.js // 各種自定義的 hooks
      |— handler.js // 轉換函數,以及不需要 hooks 的事件處理函數
        |— index.js // 主文件,只保留實現步驟
        |— index.css // css 文件

什麼樣的代碼一看就懂?

我重構過太多別人的代碼,但凡遇到那種看着邏輯代碼一大堆放在一起的,我就頭大,後來發現,這些代碼都犯了一個相同的錯誤。沒有分清楚什麼是步驟,什麼是實現細節。當你把步驟和細節寫在一起的時候,災難也就發生了,尤其是那種長年累月迭代出來的代碼,if 遍地。
Hooks 是一個做代碼拆分的高效工具,但是他也非常的靈活,業界一直沒有比較通用行的編碼規範,但是我有點不同的觀點,我覺得他不需要像 Redux 一樣的模式化的編碼規範,因為他就是函數式編程,他遵循函數式編程的一般原則,函數式編程最重要的是拆分好步驟和實現細節,這樣的代碼就好讀,好讀的代碼才是負責任的代碼。

到底怎麼區分步驟和細節?有一個很簡單的方法,在你梳理需求的時候,你用一個流程圖把你的需求表示出來,這時候的每個節點基本就是步驟,因為他不牽扯到具體的實現。解釋太多,有點啰嗦了,相信你肯定懂,對吧。
步驟和細節分清楚以後,對重構也有很大的好處,因為每個步驟都是一個函數,不會有像 class 中 this 這種全局變量,當你需要刪除一個步驟或者重寫這個步驟的時候,不用影響到其他步驟函數。
同樣,函數化以後,無疑單元測試就變得非常簡單了。

按照步驟拆分主文件

目的是主文件里只保留業務步驟。

import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import {
  Row, Select,
} from 'antd';
import { onChangeHandler } from './handler';
import { useList } from './hooks';
import Service from '@/services';

const Demo = ({
  onChange,
  value,
  version,
}) => {
  // list 狀態的操作,其中有搜索改變 list 
  const [originList, list, onSearchHandler] = useList(version);
  
  // UI 組件渲染
  return (
    <Row>
      <Select
      labelInValue
      showSearch
      filterOption={false}
      value={value}
      onSearch={onSearchHandler}
      onChange={() => onChangeHandler(originList, data, onChange)}
      >
        {list.map(option => (<Option value={option.id} key={option.id}>{option.name}</Option>))}
      </Select>
    </Row>
  );
};

export default Demo;

看到上面是基於步驟和細節分離的思路,將上面的組件做了一次重構,只包含兩步:

  • 對 list 數據的操作
  • UI 渲染

通過拆分以後主文件代碼里就只包含一些步驟了,全部使用自定義的 hooks 替換了,自定義的 hooks 可以寫到 hooks.js 文件中。
hooks.js 里文件內容如下:

import { useState, useEffect, useCallback } from 'react';

let originList = [];
export const useList = (version) => {
  // 狀態管理
  const [list, setList] = useState([]);
   // 副作用函數 
  useEffect(() => {
    const init = async () => {
        const list = await Service.getList(version);
        originList = list;
        setList(list);
    };
    init();
  }, []);
  
  // 處理 select 搜索
  const onSearchHandler = useCallback((val) => {
    if (val) {
      const listFilter = originList.filter(item => item.name.indexOf(val) > -1);
      setList(listFilter);
    } else {
      setList(originList);
    }
  }, []);
  
  return [originList, list, onSearchHandler];
}

可以看到 hooks.js 文件里包含的就是數據和改變數據的方法,所有的副作用函數都包含在裏面。同時建議所有的異步請求都是用 await 來處理。啥好處可以自行 Google。

handler.js 文件內容如下:

// 事件 handler
export const onChangeHandler = (originList, data, onChange) => {
  const val = originList.find(option => (option.id === data.value));
  const item = { ...val, value: val.code, label: val.name };
  onChange(item);
};

上面的例子非常簡單,你可能覺得根本不需要這樣重構,因為本來代碼量就不大,這樣拆分增加了太多文件。很好!這樣抬杠說明你有了思考,我同意你的觀點,一些簡單的組件根本不需要如此拆分,但是我將這種重構方法不是一種規範,不是一種強制要求,相反他是一種價值觀,一種對於什麼是好的代碼的價值觀。這種價值觀歸根結底就是一句話:讓你的代碼易於變更。 Easier To Change! 簡稱 ETC。


編碼價值觀 ETC

ETC 這種編碼的價值觀是很多好的編碼原則的本質,比如單一職責原則,解耦原則等,他們都體現了 ETC 這種價值觀念。能適應使用者的就是好的設計,對於代碼而言,就是要擁抱變化,適應變化。因此我們需要信奉 ETC 。價值觀念是幫助你在寫代碼的時候做決定的,他告訴你應該做這個?還是做那個?他幫助你在不同編碼方式之間做選擇,他甚至應該成為你編碼時的一種潛意識,如果你接受這種價值觀,那麼在編碼的時候,請時刻提醒自己,遵循這種價值觀。

參考

  • 響應式編程 https://zhuanlan.zhihu.com/p/27678951
  • 《程序員修鍊之道》Andrew Hunt, David Thomas

來自:https://segmentfault.com/a/1190000037490145

站長推薦

1.雲服務推薦: 國內主流雲服務商,各類雲產品的最新活動,優惠券領取。地址:阿里雲騰訊雲華為雲

2.廣告聯盟: 整理了目前主流的廣告聯盟平台,如果你有流量,可以作為參考選擇適合你的平台點擊進入

鏈接: http://www.fly63.com/article/detial/9715