博客 / 詳情

返回

你應該瞭解的hooks式接口編程 - useSWR

什麼是 useSWR ?

聽名字我們都知道是一個 React 的 hooks,SWR 是stale-while-revalidate的縮寫, stale 的意思是陳舊的, revalidate 的意思是重新驗證/使重新生效, 合起來的意識可以理解成 在重新驗證的過程中先使用陳舊的,在http 請求中意味着先使用已過期的數據緩存,同時請求新的數據去刷新緩存。

這在 http 請求中Cache-Control響應頭中已經實現,比如:

Cache-Control: max-age=60, stale-while-revalidate=3600

這意味着在緩存過期時間為60秒,當緩存過期時,你請求了該接口,並且在緩存過期後的3600內,會先使用原來過期的緩存作為結果返回,同時會請求服務器去刷新緩存。

示例:

未使用 swr 的情況,緩存過期後直接重放304協商緩存請求

1.gif

使用 swr 的情況,緩存過期後直接返回200過期的緩存數據,再進行304協商緩存請求

2.gif

但通過 nginx 等網關層來實現 swr ,沒法做到接口緩存的精確控制,並且即使revalidate後的fresh數據返回了,也沒法讓頁面重新渲染,只能等待下次接口請求。

useSWR直接在前端代碼層實現了http請求SWR緩存的功能。

使用方式

在傳統模式下,我們會這樣寫一個數據請求, 需要通過定義多個狀態來管理數據請求, 並在副作用中進行指令式的接口調用。

import { useEffect, useState } from "react";
import Users from "./Users";

export default function App() {
  const [users, setUsers] = useState([]);
  const [isLoading, setLoading] = useState(false);

  const getUsers = () => {
    setLoading(true);
    fetch("/api/getUsers")
      .then((res) => res.json())
      .then((data) => {
         setUsers(data);
      })
      .finally(() => {
         setLoading(false)
      })
  }

  useEffect(() => {
    getUsers();
  }, []);


  return (
    <div>
      {isLoading && <h2>loading... </h2>}
      <UserList users={users} />
    </div>
  );
}

使用 useSWR 後, 我們只需要告訴 SWR 這個請求的唯一 key, 與如何處理該請求的 fetcher 方法,在組件掛載後會自動進行請求

import useSWR from "swr";
import Users from "./Users";

const fetcher = (...args) => fetch(...args).then((res) => res.json())

export default function App() {
  const { data: users, isLoading, mutate } = useSWR('/api/getUsers', fetcher)

  return (
    <div>
      {isLoading && <h2>loading... </h2>}
      <UserList users={users} />
    </div>
  );
}

useSWR的入參

  • key: 請求的唯一key,可以為字符串、函數、數組、對象等
  • fetcher:(可選)一個請求數據的 Promise 返回函數
  • options:(可選)該 SWR hook 的選項對象

key 會作為入參傳遞給 fetcher 函數, 一般來説可以是請求的URL作為key。可以根據場景自定義 key 的格式,比如我有額外請求參數,那麼就把 key 定義成一個數組 ['/api/getUsers', { pageNum: 1 }], SWR會在內部自動序列化 key 值,以進行緩存匹配。

useSWR的返回

  • data: 通過 fetcher 處理後的請求結果, 未返回前為 undefined
  • error: fetcher 拋出的錯誤
  • isLoading: 是否有一個正在進行中的請求且當前沒有“已加載的數據“。
  • isValidating: 是否有請求或重新驗證加載
  • mutate(data?, options?): 更改緩存數據的函數

核心

全局緩存機制

我們每次使用 SWR 時都有用到 key,這將作為唯一標識將結果存入全局緩存中,這種默認緩存的行為其實非常有用。

例如獲取用户列表在我們產品中是一個非常頻繁的請求,細分下來用户列表都會有很多個接口

我們在寫需求時,可能不知道這個接口數據有沒有往 redux 中存過,並且往 redux 中放數據是個相對麻煩的操作有管理成本,那麼大多數人的做法就是那有地方用,我就重新請求一遍。

例如一個模態框裏存在用户列表,每次打開都要請求一次 (帶遠程搜索),用户每次都需要等待,當然你也可以把用户列表狀態提升到模態框外面,但對應的就會有取捨,外部父組件其實根本不關心用户列表狀態。

請求狀態區分

當第一次請求,也就是沒有找到對應 key 的緩存時,那麼就會立即發起請求,isLoadingisValidating 都為 true。

當第二次請求時,有緩存,那麼先拿緩存數據渲染,再進行請求,isValidating 為 true。

也就是説只要正在請求中,就是 isValidating, 無緩存數據且正在請求時才為isLoading狀態。

1.gif

對應的狀態圖:

file

以上的案例中都是 key 為固定值的情況,但更多場景下 key 值會由於請求參數的變動而變動。

如一個搜索用户的 key 這樣定義

const [search, setSearch] = useState('');
const { data } = useSWR(['/api/users', search], fetcher)

每次輸入都會導致 key 變化,key 變化默認就會重新請求接口,但其實 key 變化了也就代表了數據不可信了,需要裏面重置數據,因此 data 會被立即重置為 undfined 。如果新的 key 已經有緩存值,那麼也會先拿緩存值進行渲染。

1.gif

那麼其實我們幾乎什麼額外代碼也沒加,就實現了一個自帶數據緩存的用户搜索功能。

對應的key變化時的狀態圖:

file

假如我們偏要保留 key 變化前的數據先展示呢?因為我們還是會看到短暫的no-data

我們主要在第三個參數 options 中加入配置項 keepPreviousData 即可實現

file

實現效果與我們 gitlab 搜索分支時其實是一致的

2.gif

key變化且保留數據的狀態圖:

file

聯動請求與手動觸發

很多情況下接口請求都依賴於另外一個接口請求的結果,或者在某種情況下才發起請求。

首先如何讓組件掛載時不進行請求,有三種方法

配置項實現

設置 options 參數 revalidateOnMount , 這種方法如果已有緩存數據,仍然會拿緩存數據渲染\

1.gif

依賴模式

給定 key 時返回 falsy 值 或者 提供函數並拋出錯誤

const { data } = useSWR(isMounted ? '/api/users' : null, fetcher)

const { data } = useSWR(() => isMounted ? '/api/users' : null, fetcher)

// 拋出錯誤
const { data: userInfo } = useSWR('/api/userInfo')
const { data } = useSWR(() => '/api/users?uid=' + userInfo.id, fetcher)

那我們實現一個業務場景:數據源-數據庫-數據表的聯動請求

2.gif

我們幾乎以一種自動化的方式實現了聯動請求。

以下是代碼示例:

const DependenceDataSource = () => {
    const [form] = Form.useForm();
    const dataSourceId = Form.useWatch("dataSourceId", form);
    const dbId = Form.useWatch("dbId", form);

    const { data: dataSourceList = [], isValidating: isDataSourceFetching } =
        useSWR({ url: "/getDataSource" }, dataSourceFetcher);
        
    const { data: dbList = [], isValidating: isDatabaseFetching } = useSWR(
        () =>
            dataSourceId
                ? { url: "/getDatabase", params: { dataSourceId } }
                : null,
        databaseFetcher
    );

    const { data: tableList = [], isValidating: isTableFetching } = useSWR(
        () =>
            dataSourceId && dbId
                ? { url: "/getTable", params: { dataSourceId, dbId } }
                : null,
        tableFetcher
    );

    return (
        <Form
            form={form}
            style={{width: 400}}
            layout="vertical"
            onValuesChange={(changedValue) => {
                if ("dataSourceId" in changedValue) {
                    form.resetFields(["dbId", "tableId"]);
                }
                if ("dbId" in changedValue) {
                    form.resetFields(["tableId"]);
                }
            }}
        >
            <Form.Item name="dataSourceId" label="數據源">
                <Select
                    placeholder="請選擇數據源"
                    options={dataSourceList}
                    loading={isDataSourceFetching}
                    allowClear
                />
            </Form.Item>
            <Form.Item name="dbId" label="數據庫">
                <Select
                    placeholder="請選擇數據庫"
                    options={dbList}
                    loading={isDatabaseFetching}
                    allowClear
                />
            </Form.Item>
            <Form.Item name="tableId" label="數據表">
                <Select
                    placeholder="請選擇數據表"
                    options={tableList}
                    loading={isTableFetching}
                    allowClear
                />
            </Form.Item>
        </Form>
    );
};
採用手動擋模式

使用上面這種方法利用了 key 變化會自動revalidate數據的機制實現了聯動,但是有個非常大的弊端,你需要把 key 中所有的依賴參數都提取為state使組件能夠重新 render 以進行revalidate。有點強制你使用受控模式的感覺,這會造成性能問題。

所以我們需要利用mutate進行手動請求, mutate(key?, data, options)

你可以直接從 swr 全局引入mutate方法,也可以使用 hooks 返回的 mutate 方法。

區別:

  • 全局mutate需要額外提供 key
  • hooks 內mutate直接綁定了key
// 全局使用
import { mutate } from "swr"
function App() {
  mutate(key, data, options)
}

// hook使用
const UsersMutate = () => {
    const { data, mutate } = useSWR({ url: "/getNewUsers" }, fetcher, {
        revalidateOnFocus: false,
        dedupingInterval: 0,
        revalidateOnMount: false
    });

    return (
        <div>
            <Input.Search
                onSearch={(value) => {
                    mutate([{ id: 3, name: "user_" + value }]);
                }}
            />
            <List style={{ width: 300 }}>
                {data?.map((user) => (
                    <List.Item key={user.id}>{user.name}</List.Item>
                ))}
            </List>
        </div>
    );
}

mutate 後會立馬使用傳入的 data 更新緩存,然後會再次進行一次 revalidate 數據刷新

1.gif

使用全局mutate傳入 key { url: "/getNewUsers" } 後能夠實現一樣的效果,並且使用全局mutate

傳入的 key 為函數時,你可以批量清除緩存。注意: mutate 中 key 傳入函數表示過濾函數,與 useSWR 中傳入 key 函數意義不同。

mutate(
    (key) => typeof key === 'object' && key.api === getUserAPI && key.params.search !== '',
    undefined,
  {
    revalidate: false
  }
);

但是,我們可以注意到現在傳入的key是不帶有請求參數的,hooks中mutate也無法修改綁定的key值,那麼怎麼攜帶請求參數呢?

useSWRMutation

useSWRMutation為一種手動模式的 SWR,只能通過返回的trigger方法進行數據更新。

這意味着:

  1. 它不會自動使用緩存數據
  2. 它不會自動寫入緩存(可以通過配置修改默認行為)
  3. 不會在組件掛載時或者 key 變化時自動請求數據

它的函數返回稍有不同:

const { data, isMutating, trigger, reset, error } = useSWRMutation(
    key,
    fetcher, // fetcher(key, { arg })
    options
);

trigger('xxx')

useSWRMutationfetcher函數額外可以傳遞一個arg參數, 在 trigger可以中傳遞該參數,那麼我們再來實現2.1依賴模式中的依賴聯動請求。

  1. 定義三個 fetcher, 接收參數, 這裏參數不曉得為啥一定設計成{ arg }形式
const dataSourceFetcher = (key) => {
    return new Promise<any[]>((resolve) => {
        request(key).then((res) => resolve(res))
    });
};

const databaseFetcher = (key, { arg }: { arg: DatabaseParams }) => {
    return new Promise<any[]>((resolve) => {
        const { dataSourceId } = arg;
        if (!dataSourceId) return resolve([])
        request(key, { dataSourceId }).then((res) => resolve(res))
    });
};

const tableFetcher = (key, { arg }: { arg: TableParams }) => {
    return new Promise<any[]>((resolve) => {
        const { dataSourceId, dbId } = arg;
        if (!dataSourceId || !dbId) return resolve([])
        request(key, { dataSourceId, dbId }).then((res) => resolve(res))
    });
};
  1. 定義 hooks
const { data: dataSourceList = [], isValidating: isDataSourceFetching } =
    useSWR({ url: "/getDataSource" }, dataSourceFetcher);

const { data: dbList = [], isMutating: isDatabaseFetching, trigger: getDatabase, reset: clearDatabase } = useSWRMutation(
    { url: "/getDatabase" },
    databaseFetcher,
);

const { data: tableList = [], isMutating: isTableFetching, trigger: getTable, reset: clearTable } = useSWRMutation(
    { url: "/getTable" },
    tableFetcher
);
  1. 手動觸發
<Form
    onValuesChange={(changedValue) => {
        if ("dataSourceId" in changedValue) {
            form.resetFields(["dbId", "tableId"]);
            clearDatabase();
            clearTable();
            getDatabase({ dataSourceId: changedValue.dataSourceId });
        }
        if ("dbId" in changedValue) {
            form.resetFields(["tableId"]);
            clearTable();
            getTable({
                dataSourceId: form.getFieldValue("dataSourceId"),
                dbId: changedValue.dbId,
            });
        }
    }}
>
    // FormItem略
</Form>

2.gif

無緩存寫入

file

但是使用useSWRMutation這種方式,如果庫表還帶有遠程數據搜索,就沒法用到緩存特性了。

性能優化

useSWR 在設計的時候充分考慮了性能問題

  • 自帶節流\
    當我們短時間內多次調用同一個接口時,只會觸發一次請求。如同時渲染多個用户組件,會觸發revalidate機制,但實際只會觸發一次。這個時間節流時間由dedupingInterval配置控制,默認為2000ms

A Question, 如何實現防抖呢?無可用配置項

  • revalidatefreshDatastaleData間進行的是深比較,避免不必要渲染, 詳見dequal。
  • 依賴收集\
    如果沒有消費hooks返回的狀態,則狀態變化不會導致重新渲染
const { data } = useSWR('xxx', fetcher);

// 僅在data變化時render, isValidating, isLoading由於沒有引入及時變化也不會觸發渲染

依賴收集的實現很巧妙

  1. 定義個ref進行依賴收集, 默認沒有任何依賴
    file
  2. 通過get實現訪問後添加\
    file
  3. 由於state改變必定會導致渲染,所以這些狀態全部由useSyncExternalStore管理\
    file
  4. 只有在不相等時才會觸發渲染,如果不在stateDependencies收集中,則直接\
    file

總結

useSWR能夠極大的提升用户體驗,但在實際使用時,可能仍需留點小心思結合業務來看是否要使用緩存特性,如某些提交業務場景下對庫表的實時性很高,這時就該考慮是否要有useSWR了。

再者,數棧產品中在實際開發中很少會對業務數據進行hooks封裝,如用户列表可以封裝成useUserList,表格使用useList等。感覺更多是開發習慣的原因,覺得以後自己可能也不會複用不會做過多的封裝,指令式編程一把梭。

最後

歡迎關注【袋鼠雲數棧UED團隊】\~\
袋鼠雲數棧 UED 團隊持續為廣大開發者分享技術成果,相繼參與開源了歡迎 star

  • 大數據分佈式任務調度系統——Taier
  • 輕量級的 Web IDE UI 框架——Molecule
  • 針對大數據領域的 SQL Parser 項目——dt-sql-parser
  • 袋鼠雲數棧前端團隊代碼評審工程實踐文檔——code-review-practices
  • 一個速度更快、配置更靈活、使用更簡單的模塊打包器——ko
  • 一個針對 antd 的組件測試工具庫——ant-design-testing
user avatar kasong 頭像 tigerandflower 頭像 icecreamlj 頭像 susouth 頭像 yilezhiming 頭像 iymxpc3k 頭像 huanjinliu 頭像 light_5cfbb652e97ce 頭像 fehaha 頭像 xiaohaiqianduan 頭像 dashnowords 頭像 nihaojob 頭像
21 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.