使用React Hooks + 自定義Hook封裝一步一步打造一個完善的小型應用。

晨曦時夢見兮發表於2019-08-29

前言

Reack Hooks自從16.8釋出以來,社群已經有相當多的討論和應用了,不知道各位在公司裡有沒有用上這個酷炫的特性~

今天分享一下利用React Hooks實現一個功能相對完善的todolist。

特點:

  • 利用自定義hook管理請求
  • 利用hooks做程式碼組織和邏輯分離

介面預覽

預覽

體驗地址

codesandbox.io/s/react-hoo…

程式碼詳解

介面

首先我們引入antd作為ui庫,節省掉無關的一些邏輯,快速的構建出我們的頁面骨架


const TAB_ALL = "all";
const TAB_FINISHED = "finished";
const TAB_UNFINISHED = "unfinished";
const tabMap = {
  [TAB_ALL]: "全部",
  [TAB_FINISHED]: "已完成",
  [TAB_UNFINISHED]: "待完成"
};

function App() {
  const [activeTab, setActiveTab] = useState(TAB_ALL);
  
  return (
    <>
      <Tabs activeKey={activeTab} onChange={setActiveTab}>
        <TabPane tab={tabMap[TAB_ALL]} key={TAB_ALL} />
        <TabPane tab={tabMap[TAB_FINISHED]} key={TAB_FINISHED} />
        <TabPane tab={tabMap[TAB_UNFINISHED]} key={TAB_UNFINISHED} />
      </Tabs>
      <div className="app-wrap">
        <h1 className="app-title">Todo List</h1>
        <Input />
        <TodoList />
      </div>
    </>
  );
}
複製程式碼

資料獲取

有了介面以後,接下來就要獲取資料。

模擬api

這裡我新建了一個api.js專門用來模擬介面獲取資料,這裡面的邏輯大概看一下就好,不需要特別在意。

const todos = [
  {
    id: 1,
    text: "todo1",
    finished: true
  },
  {
    id: 2,
    text: "todo2",
    finished: false
  },
  {
    id: 3,
    text: "todo3",
    finished: true
  },
  {
    id: 4,
    text: "todo4",
    finished: false
  },
  {
    id: 5,
    text: "todo5",
    finished: false
  }
];

const delay = time => new Promise(resolve => setTimeout(resolve, time));
// 將方法延遲1秒
const withDelay = fn => async (...args) => {
  await delay(1000);
  return fn(...args);
};

// 獲取todos
export const fetchTodos = withDelay(params => {
  const { query, tab } = params;
  let result = todos;
  // tab頁分類
  if (tab) {
    switch (tab) {
      case "finished":
        result = result.filter(todo => todo.finished === true);
        break;
      case "unfinished":
        result = result.filter(todo => todo.finished === false);
        break;
      default:
        break;
    }
  }

  // 帶引數查詢
  if (query) {
    result = result.filter(todo => todo.text.includes(query));
  }

  return Promise.resolve({
    tab,
    result
  });
});
複製程式碼

這裡我們封裝了個withDelay方法用來包裹函式,模擬非同步請求介面的延遲,這樣方便我們後面演示loading功能。

基礎資料獲取

獲取資料,最傳統的方式就是在元件中利用useEffect來完成請求,並且宣告依賴值來在某些條件改變後重新獲取資料,簡單寫一個:

import { fetchTodos } from './api'

const TAB_ALL = "all";
const TAB_FINISHED = "finished";
const TAB_UNFINISHED = "unfinished";
const tabMap = {
  [TAB_ALL]: "全部",
  [TAB_FINISHED]: "已完成",
  [TAB_UNFINISHED]: "待完成"
};

function App() {
  const [activeTab, setActiveTab] = useState(TAB_ALL);
  
  
  // 獲取資料
  const [loading, setLoading] = useState(false)
  const [todos, setTodos] = useState([])
  useEffect(() => {
    setLoading(true)
    fetchTodos({tab: activeTab})
        .then(result => {
            setTodos(todos)
        })
        .finally(() => {
            setLoading(false)
        })
  }, [activeTab])
  
  
  return (
    <>
      <Tabs activeKey={activeTab} onChange={setActiveTab}>
        <TabPane tab={tabMap[TAB_ALL]} key={TAB_ALL} />
        <TabPane tab={tabMap[TAB_FINISHED]} key={TAB_FINISHED} />
        <TabPane tab={tabMap[TAB_UNFINISHED]} key={TAB_UNFINISHED} />
      </Tabs>
      <div className="app-wrap">
        <h1 className="app-title">Todo List</h1>
        <Input />
        <Spin spinning={loading} tip="稍等片刻~">
          <!--把todos傳遞給元件-->
          <TodoList todos={todos}/>
        </Spin>
      </div>
    </>
  );
}
複製程式碼

這樣很好,在公司內部新啟動的專案裡我的同事們也都是這麼寫的,但是這樣的獲取資料有幾個小問題。

  • 每次都要用useState建立loading的的狀態
  • 每次都要用useState建立請求結果的狀態
  • 對於請求如果有一些更高階的封裝的話,不太好操作。

所以這裡要封裝一個專門用於請求的自定義hook。

自定義hook(資料獲取)

忘了在哪看到的說法,自定hook其實就是把useXXX方法執行以後,把方法體裡的內容平鋪到元件內部,我覺得這種說法對於理解自定義hook很友好。

useTest() {
    const [test, setTest] = useState('')
    setInterval(() => {
        setTest(Math.random())
    }, 1000)
    return {test, setTest}
}

function App() {
    const {test, setTest} = useTest()
    
    return <span>{test}</span>
}

複製程式碼

這段程式碼等價於:

function App() {
    const [test, setTest] = useState('')
    setInterval(() => {
        setTest(Math.random())
    }, 1000)
    
    return <span>{test}</span>
}

複製程式碼

是不是瞬間感覺自定hook很簡單了~ 基於這個思路,我們來封裝一下我們需要的useRequest方法。

export const useRequest = (fn, dependencies) => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  
  // 請求的方法 這個方法會自動管理loading
  const request = () => {
    setLoading(true);
    fn()
      .then(setData)
      .finally(() => {
        setLoading(false);
      });
  };

  // 根據傳入的依賴項來執行請求
  useEffect(() => {
    request()
  }, dependencies);
    
  return {
      // 請求獲取的資料
      data,
      // loading狀態
      loading,
      // 請求的方法封裝
      request
  };
};
複製程式碼

有了這個自定義hook,我們元件內部的程式碼又可以精簡很多。

import { fetchTodos } from './api'
import { useRequest } from './hooks'

const TAB_ALL = "all";
const TAB_FINISHED = "finished";
const TAB_UNFINISHED = "unfinished";
const tabMap = {
  [TAB_ALL]: "全部",
  [TAB_FINISHED]: "已完成",
  [TAB_UNFINISHED]: "待完成"
};

function App() {
  const [activeTab, setActiveTab] = useState(TAB_ALL);
  
  // 獲取資料
  const {loading, data: todos} = useRequest(() => {
      return fetchTodos({ tab: activeTab });
  }, [activeTab]) 
  
  return (
    <>
      <Tabs activeKey={activeTab} onChange={setActiveTab}>
        <TabPane tab={tabMap[TAB_ALL]} key={TAB_ALL} />
        <TabPane tab={tabMap[TAB_FINISHED]} key={TAB_FINISHED} />
        <TabPane tab={tabMap[TAB_UNFINISHED]} key={TAB_UNFINISHED} />
      </Tabs>
      <div className="app-wrap">
        <h1 className="app-title">Todo List</h1>
        <Input />
        <Spin spinning={loading} tip="稍等片刻~">
          <!--把todos傳遞給元件-->
          <TodoList todos={todos}/>
        </Spin>
      </div>
    </>
  );
}
複製程式碼

果然,樣板程式碼少了很多,腰不酸了腿也不痛了,一口氣能發5個請求了!

消除tab頻繁切換產生的髒資料

在真實開發中我們特別容易遇到的一個場景就是,tab切換並不改變檢視,而是去重新請求新的列表資料,在這種情況下我們可能就會遇到一個問題,以這個todolist舉例,我們從全部tab切換到已完成tab,會去請求資料,但是如果我們在已完成tab的資料還沒請求完成時,就去點選待完成的tab頁,這時候就要考慮一個問題,非同步請求的響應時間是不確定的,很可能我們發起的第一個請求已完成最終耗時5s,第二個請求待完成最終耗時1s,這樣第二個請求的資料返回,渲染完頁面以後,過了幾秒第一個請求的資料返回了,但是這個時候我們的tab是停留在對應第二個請求待完成上,這就造成了髒資料的bug。

這個問題其實我們可以利用useEffect的特性在useRequest封裝解決。


export const useRequest = (fn, dependencies, defaultValue = []) => {
  const [data, setData] = useState(defaultValue);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(false);

  const request = () => {
    // 定義cancel標誌位
    let cancel = false;
    setLoading(true);
    fn()
      .then(res => {
        if (!cancel) {
          setData(res);
        } else {
          // 在請求成功取消掉後,列印測試文字。
          const { tab } = res;
          console.log(`request with ${tab} canceled`);
        }
      })
      .catch(() => {
        if (!cancel) {
          setError(error);
        }
      })
      .finally(() => {
        if (!cancel) {
          setLoading(false);
        }
      });

    // 請求的方法返回一個 取消掉這次請求的方法
    return () => {
      cancel = true;
    };
  };

  // 重點看這段,在useEffect傳入的函式,返回一個取消請求的函式
  // 這樣在下一次呼叫這個useEffect時,會先取消掉上一次的請求。
  useEffect(() => {
    const cancelRequest = request();
    return () => {
      cancelRequest();
    };
    // eslint-disable-next-line
  }, dependencies);

  return { data, setData, loading, error, request };
};
複製程式碼

其實這裡request裡實現的取消請求只是我們模擬出來的取消,真實情況下可以利用axios等請求庫提供的方法做不一樣的封裝,這裡主要是講思路。 useEffect裡返回的函式其實叫做清理函式,在每次新一次執行useEffect時,都會先執行清理函式,我們利用這個特性,就能成功的讓useEffect永遠只會用最新的請求結果去渲染頁面。

可以去預覽地址快速點選tab頁切換,看一下控制檯列印的結果。

主動請求的封裝

現在需要加入一個功能,點選列表中的專案,切換完成狀態,這時候useRequest好像就不太合適了,因為useRequest其實本質上是針對useEffect的封裝,而useEffect的使用場景是初始化和依賴變更的時候發起請求,但是這個新需求其實是響應使用者的點選而去主動發起請求,難道我們又要手動寫setLoading之類的冗餘程式碼了嗎?答案當然是不。
我們利用高階函式的思想封裝一個自定義hook:useWithLoading

useWithLoading程式碼實現

export function useWithLoading(fn) {
  const [loading, setLoading] = useState(false);

  const func = (...args) => {
    setLoading(true);
    return fn(...args).finally(() => {
      setLoading(false);
    });
  };

  return { func, loading };
}
複製程式碼

它本質上就是對傳入的方法進行了一層包裹,在執行前後去更改loading狀態。
使用:

 // 完成todo邏輯
  const { func: onToggleFinished, loading: toggleLoading } = useWithLoading(
    async id => {
      await toggleTodo(id);
    }
  );
  
<TodoList todos={todos} onToggleFinished={onToggleFinished} />
      
複製程式碼

程式碼組織

加入一個新功能,input的placeholder根據tab頁的切換去切換文案,注意,這裡我們先提供一個錯誤的示例,這是剛從Vue2.x和React Class Component轉過來的人很容易犯的一個錯誤。

❌錯誤示例

import { fetchTodos } from './api'
import { useRequest } from './hooks'

const TAB_ALL = "all";
const TAB_FINISHED = "finished";
const TAB_UNFINISHED = "unfinished";
const tabMap = {
  [TAB_ALL]: "全部",
  [TAB_FINISHED]: "已完成",
  [TAB_UNFINISHED]: "待完成"
};

function App() {
  // state放在一起
  const [activeTab, setActiveTab] = useState(TAB_ALL);
  const [placeholder, setPlaceholder] = useState("");
  const [query, setQuery] = useState("");
  
  // 副作用放在一起
  const {loading, data: todos} = useRequest(() => {
      return fetchTodos({ tab: activeTab });
  }, [activeTab]) 
  useEffect(() => {
    setPlaceholder(`在${tabMap[activeTab]}內搜尋`);
  }, [activeTab]);
  const { func: onToggleFinished, loading: toggleLoading } = useWithLoading(
    async id => {
      await toggleTodo(id);
    }
  );
  
  return (
    <>
      <Tabs activeKey={activeTab} onChange={setActiveTab}>
        <TabPane tab={tabMap[TAB_ALL]} key={TAB_ALL} />
        <TabPane tab={tabMap[TAB_FINISHED]} key={TAB_FINISHED} />
        <TabPane tab={tabMap[TAB_UNFINISHED]} key={TAB_UNFINISHED} />
      </Tabs>
      <div className="app-wrap">
        <h1 className="app-title">Todo List</h1>
        <Input />
        <Spin spinning={loading} tip="稍等片刻~">
          <!--把todos傳遞給元件-->
          <TodoList todos={todos}/>
        </Spin>
      </div>
    </>
  );
}
複製程式碼

注意,在之前的vue和react開發中,因為vue程式碼組織的方式都是 based on options(基於選項如data, methods, computed組織),
React 也是state在一個地方統一初始化,然後class裡定義一堆一堆的xxx方法,這會導致新接手程式碼的人閱讀邏輯十分困難。

所以hooks也解決了一個問題,就是我們的程式碼組織方式可以 based on logical concerns(基於邏輯關注點組織)了 不要再按照往常的思維把useState useEffect分門別類的組織起來,看起來整齊但是毫無用處 !!

這裡上一張vue composition api介紹裡對於@vue/ui庫中一個元件的對比圖

對比圖
顏色是用來區分功能點的,哪種程式碼組織方式更利於維護,一目瞭然了吧。

Vue composition api 推崇的程式碼組織方式是把邏輯拆分成一個一個的自定hook function,這點和react hook的思路是一致的。

export default {
  setup() { // ...
  }
}

function useCurrentFolderData(nextworkState) { // ...
}

function useFolderNavigation({ nextworkState, currentFolderData }) { // ...
}

function useFavoriteFolder(currentFolderData) { // ...
}

function useHiddenFolders() { // ...
}

function useCreateFolder(openFolder) { // ...
}
複製程式碼

✔️正確示例

import React, { useState, useEffect } from "react";
import ReactDOM from "react-dom";
import TodoInput from "./todo-input";
import TodoList from "./todo-list";
import { Spin, Tabs } from "antd";
import { fetchTodos, toggleTodo } from "./api";
import { useRequest, useWithLoading } from "./hook";

import "antd/dist/antd.css";
import "./styles/styles.css";
import "./styles/reset.css";

const { TabPane } = Tabs;

const TAB_ALL = "all";
const TAB_FINISHED = "finished";
const TAB_UNFINISHED = "unfinished";
const tabMap = {
  [TAB_ALL]: "全部",
  [TAB_FINISHED]: "已完成",
  [TAB_UNFINISHED]: "待完成"
};

function App() {
  const [activeTab, setActiveTab] = useState(TAB_ALL);

  // 資料獲取邏輯
  const [query, setQuery] = useState("");
  const {
    data: { result: todos = [] },
    loading: listLoading
  } = useRequest(() => {
    return fetchTodos({ query, tab: activeTab });
  }, [query, activeTab]);

  // placeHolder
  const [placeholder, setPlaceholder] = useState("");
  useEffect(() => {
    setPlaceholder(`在${tabMap[activeTab]}內搜尋`);
  }, [activeTab]);

  // 完成todo邏輯
  const { func: onToggleFinished, loading: toggleLoading } = useWithLoading(
    async id => {
      await toggleTodo(id);
    }
  );

  const loading = !!listLoading || !!toggleLoading;
  return (
    <>
      <Tabs activeKey={activeTab} onChange={setActiveTab}>
        <TabPane tab={tabMap[TAB_ALL]} key={TAB_ALL} />
        <TabPane tab={tabMap[TAB_FINISHED]} key={TAB_FINISHED} />
        <TabPane tab={tabMap[TAB_UNFINISHED]} key={TAB_UNFINISHED} />
      </Tabs>
      <div className="app-wrap">
        <h1 className="app-title">Todo List</h1>
        <TodoInput placeholder={placeholder} onSetQuery={setQuery} />
        <Spin spinning={loading} tip="稍等片刻~">
          <TodoList todos={todos} onToggleFinished={onToggleFinished} />
        </Spin>
      </div>
    </>
  );
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
複製程式碼

總結

React Hook提供了一種新思路讓我們去更好的組織元件內部的邏輯程式碼,使得功能複雜的大型元件更加易於維護。並且自定義Hook功能十分強大,在公司的專案中我也已經封裝了很多好用的自定義Hook比如UseTable, useTreeSearch, useTabs等,可以結合各自公司使用的元件庫和ui互動需求把一些邏輯更細粒度的封裝起來,發揮你的想象力!useYourImagination!

相關文章