umijs@use-request源码解读

一、了解ts基本语法

涉及ts的变量声明、接口、类、函数、泛型等

ts语法知识

二、支持功能

前提:定义了一个 Fecth 类,用于处理请求数据。

class Fetch<R, P extends any[]> {
  config: FetchConfig<R, P>;
  service: Service<R, P>;

  // 请求时序
  count = 0;

  // 是否卸载
  unmountedFlag = false;
  // visible 后,是否继续轮询
  pollingWhenVisibleFlag = false;

  pollingTimer: any = undefined;
  loadingDelayTimer: any = undefined;

  subscribe: Subscribe<R, P>;

  unsubscribe: noop[] = [];

  that: any = this;

  state: FetchResult<R, P> = {
    loading: false,
    params: [] as any,
    data: undefined,
    error: undefined,
    run: this.run.bind(this.that),
    mutate: this.mutate.bind(this.that),
    refresh: this.refresh.bind(this.that),
    cancel: this.cancel.bind(this.that),
    unmount: this.unmount.bind(this.that),
  }

  debounceRun: any;
  throttleRun: any;
  limitRefresh: any;

  constructor(
    service: Service<R, P>,
    config: FetchConfig<R, P>,
    subscribe: Subscribe<R, P>,
    initState?: { data?: any, error?: any, params?: any, loading?: any }
  ) {...}

  // 类本身的方法,用于更新 state 并通知订阅
  setState(s = {}) {
    this.state = {
      ...this.state,
      ...s
    }
    this.subscribe(this.state);
  }

  // 实际取值函数
  _run(...args: P) {...}

  // 根据配置,分别对防抖、节流处理
  run(...args: P) {...}

  // 取消防抖、节流,清除定时器
  cancel() {...}

  // 重新请求新数据
  refresh() {...}

  // 轮询,重新调接口取值
  rePolling() {...}

  // 支持对接口返回的数据进行修改
  mutate(data: any) {...}

  // 调用cancel,并取消所有订阅
  unmount() {...}



1. 默认自动请求:在组件初次加载时自动触发请求函数,并自动管理 loading, data , error 状态。

1)用法

// 用法1:直接传入接口地址
const { data, error, loading } = useRequest<User>('/api/userInfo')

// 用法2:传入接口调用配置
const { loading, run } = useRequest((username) => ({
    url: '/api/changeUsername',
    method: 'post',
    data: { username },
  }), {
    manual: true,
    onSuccess: (_, params) => {
      setState('');
      alert(`The username was changed to "${params[0]}" !`);
    }
  });

// 用法3:传入异步函数
import request from 'umi-request';
export async function getUserInfo(): Promise<User> {
  return request('/api/userInfo');
}

const { data, error, loading } = useRequest(getUserInfo)

2)源码分析

第一次调用时,缓存中不存在数据,则会自动执行获取数据


// 第一次默认执行
useEffect(() => {
    if (!manual) {
      // 如果有缓存
      if (Object.keys(fetches).length > 0) {
        /* 重新执行所有的 */
        Object.values(fetches).forEach((f) => {
          f.refresh();
        });
      } else {
        // 第一次默认执行,可以通过 defaultParams 设置参数
        run(...defaultParams as any);
      }
    }
}, []);

2. 手动触发请求:设置 options.manual = true , 则手动调用 run 时才会取数

1)用法

import { changeUsername } from '@/service';

const { loading, run } = useRequest(changeUsername, {
    manual: true,
    onSuccess: (_, params) => {
      setState('');
      alert(`The username was changed to "${params[0]}" !`);
    }
});

...
<button onClick={() => run(state)}>
    {loading ? 'Editting...' : 'Edit'}
</button>

2)源码分析

当开启 manual 禁止自动请求时,将 run 函数暴露给用户调用。

如果 fetchKey 不存在,则新建 Fetch 实例,保存到 feches 对象中,并调用实例的 run ,最后返回调用结果数据。
如果 fetchKey 存在,则直接调用 Fetch 实例的 run

const run = useCallback((...args: P) => {
    if (fetchKeyPersist) {
      const key = fetchKeyPersist(...args);
      newstFetchKey.current = key === undefined ? DEFAULT_KEY : key;
    }
    const currentFetchKey = newstFetchKey.current;
    // 这里必须用 fetchsRef,而不能用 fetches。
    // 否则在 reset 完,立即 run 的时候,这里拿到的 fetches 是旧的。
    let currentFetch = fetchesRef.current[currentFetchKey];
    if (!currentFetch) {
      const newFetch = new Fetch(
        servicePersist,
        config,
        subscribe.bind(null, currentFetchKey),
        {
          data: initialData
        }
      );
      currentFetch = newFetch.state;
      setFeches((s) => {
        s[currentFetchKey] = currentFetch;
        return { ...s };
      });
    }
    return currentFetch.run(...args);

  }, [fetchKey, subscribe])

3. 轮询请求:设置 options.pollingInterval 则进入轮询模式,可通过 run / cancel 开始与停止轮询

作用:在取数结束后设定 setTimeout 重新触发下一轮取数。

1)用法

const { data, loading, cancel, run } = useRequest(getRandom, {
    pollingInterval: 1000,
    pollingWhenHidden: false
});

2)源码分析

在 Fetch 类中 _run(...args: P) 的实际取值函数中,最后会判断,是否设置了轮询 pollingInterval,设置了则开启定时器。 注意,前提是当前页面没有被隐藏。

定时器及时销毁:在 _run 函数最开始,会对现有的定时器先进行销毁。

this.service(...args).then(...).finally(() => {
  if (!this.unmountedFlag && currentCount === this.count) {
    if (this.config.pollingInterval) {
      // 如果屏幕隐藏,并且 !pollingWhenHidden, 则停止轮询,并记录 flag,等 visible 时,继续轮询
      if (!isDocumentVisible() && !this.config.pollingWhenHidden) {
        this.pollingWhenVisibleFlag = true;
        return;
      }
      this.pollingTimer = setTimeout(() => {
        this._run(...args);
      }, this.config.pollingInterval);
    }
  }
});

4. 并行请求:设置 options.fetchKey 可以对请求状态隔离,通过 fetches 拿到所有请求状态

作用:设置 options.cacheKey 后开启对请求结果缓存机制,下次请求前会优先返回缓存并在后台重新取数。

1)用法

const { run, fetches } = useRequest(disableUser, {
    manual: true,
    fetchKey: (id) => id, // 当前请求唯一标识
    onSuccess: (_, params) => {
      message.success(`Disabled user ${params[0]}`);
    }
  });

...
<ul>
    <li>user A: <Button loading={fetches['A']?.loading} onClick={() => { run('A') }}>disabled</Button></li>
    <li>user B: <Button loading={fetches['B']?.loading} onClick={() => { run('B') }}>disabled</Button></li>
    <li>user C: <Button loading={fetches['C']?.loading} onClick={() => { run('C') }}>disabled</Button></li>
</ul>

2)源码分析

每次请求都是创建一个 Fetch 实例,并用 fetchKey 进行唯一标识,并且调用 run 函数时,优先调用缓存实例。

// hooks 的 run 方法
 const run = useCallback((...args: P) => {
    if (fetchKeyPersist) {
      const key = fetchKeyPersist(...args);
      newstFetchKey.current = key === undefined ? DEFAULT_KEY : key;
    }
    const currentFetchKey = newstFetchKey.current;
    // 这里必须用 fetchsRef,而不能用 fetches。
    // 否则在 reset 完,立即 run 的时候,这里拿到的 fetches 是旧的。
    let currentFetch = fetchesRef.current[currentFetchKey];
    if (!currentFetch) {
      const newFetch = new Fetch(
        servicePersist,
        config,
        subscribe.bind(null, currentFetchKey),
        {
          data: initialData
        }
      );
      currentFetch = newFetch.state;
      setFeches((s) => {
        s[currentFetchKey] = currentFetch;
        return { ...s };
      });
    }
    return currentFetch.run(...args);

  }, [fetchKey, subscribe])

5. 请求防抖、请求节流:设置 options.debounceInterval 开启防抖,设置 options.throttleInterval 开启节流

1)用法

// 请求防抖
const { data, loading, run, cancel } = useRequest(getEmail, {
    debounceInterval: 500,
    manual: true
  });

// 请求节流
const { data, loading, run, cancel } = useRequest(getEmail, {
    throttleInterval: 500,
    manual: true
});

2)源码分析

根据传入的 config 配置来判断是否进行防抖和节流分发处理。

// 在 Fetch 类中
import debounce from 'lodash.debounce';
import throttle from 'lodash.throttle';

class Fetch<R, P extends any[]> {
    this.debounceRun = this.config.debounceInterval ? debounce(this._run, this.config.debounceInterval) : undefined;
        this.throttleRun = this.config.throttleInterval ? throttle(this._run, this.config.throttleInterval) : undefined;

    ...

    run(...args: P) {
        if (this.debounceRun) {
          this.debounceRun(...args);
          return;
        }
        if (this.throttleRun) {
          this.throttleRun(...args);
          return;
        }
        return this._run(...args);
    }
}

6. 请求预加载:由于 options.cacheKey 全局共享,可以提前执行 run 实现预加载效果

1)用法

// --------- index.js ------------
export default () => {

  const getArticleAction = useRequest(getArtitle, {
    manual: true,
    cacheKey: 'article'
  });

  const getIntroAction = useRequest(getIntro, {
    manual: true,
    cacheKey: 'intro'
  });

  return (
    <div>
      <p>When the mouse hovers over the link, the detail page data is preloaded.</p>
      <ul>
        <li><Link to="/preload/intro" onMouseEnter={() => getIntroAction.run()}>intro</Link></li>
        <li><Link to="/preload/article" onMouseEnter={() => getArticleAction.run()}>article</Link></li>
      </ul>
    </div>
  );
};


// ---------- intro.js ----------
export default () => {
  const { data, loading } = useRequest(getIntro, {
    cacheKey: 'intro'
  });

  return (
    <Spin spinning={!data && loading}>
      <p>Latest request time: {data?.time}</p>
      <p>{data?.data}</p>
    </Spin>
  );
};

// ---------- article.js ----------
export default () => {
  const { data, loading, ...rest } = useRequest(getArtitle, {
    cacheKey: 'article'
  });
  return (
    <Spin spinning={!data && loading}>
      <p>Latest request time: {data?.time}</p>
      <p>{data?.data}</p>
    </Spin>
  );
};

2)源码分析

预加载本质是缓存机制,通过利用 useEffect 同步缓存实例, 保证缓存数据的最新,然后当需要用到数据时,优先调用缓存实例。

// cache
  useEffect(() => {
    if (cacheKey) {
      setCache(cacheKey, {
        fetches,
        newstFetchKey: newstFetchKey.current
      });
    }
  }, [cacheKey, fetches]);

7. 屏幕聚焦重新请求:设置 options.refreshOnWindowFocus = true 在浏览器 refocus 与 revisible 时重新请求

1)用法

const { data, loading } = useRequest(getUserInfo, {
    refreshOnWindowFocus: true,
    focusTimespan: 5000
});

2)源码分析

  • 全局监听 visibilitychangefocus 事件
  • 当屏幕聚焦时,重新调用全部需要订阅的方法 revalidate
  • 添加订阅时,返回取消订阅的方法(这一点很巧妙,用得好!)
// -------- subscribeFocus 方法 --------------

// from swr
import { isDocumentVisible, isOnline } from './index';

let listeners: any[] = [];

function subscribe(listener: () => void) {
  listeners.push(listener);
  return function unsubscribe() {
    const index = listeners.indexOf(listener);
    listeners.splice(index, 1);
  };
}

let eventsBinded = false;
if (typeof window !== 'undefined' && window.addEventListener && !eventsBinded) {
  const revalidate = () => {
    if (!isDocumentVisible() || !isOnline()) return;
    for (let i = 0; i < listeners.length; i++) {
      const listener = listeners[i];
      listener();
    }
  };

  window.addEventListener('visibilitychange', revalidate, false);
  window.addEventListener('focus', revalidate, false);
  // only bind the events once
  eventsBinded = true;
}

export default subscribe;

  • 设置 屏幕聚焦=true 时,订阅 limitRefresh 方法
  • limitRefresh 方法,调用了 Fetch 实例的 refresh 方法 -> Fetch 实例的 run 方法 -> Fetch 实例的 _run 方法 -> 调用接口请求数据
  • limit 限制调用的频率
  • 挂载实例 unmount 时,取消当前实例的全部订阅
// Fetch类的 constructor 中
{
    this.limitRefresh = limit(this.refresh.bind(this), this.config.focusTimespan);

    if (this.config.pollingInterval) {
      this.unsubscribe.push(subscribeVisible(this.rePolling.bind(this)));
    }
    if (this.config.refreshOnWindowFocus) {
      this.unsubscribe.push(subscribeFocus(this.limitRefresh.bind(this)));
    }
}

// Fetch类的 unmount 方法
unmount() {
    this.unmountedFlag = true;
    this.cancel();
    this.unsubscribe.forEach((s) => {
      s();
    });
}

8. 请求结果突变:可以通过 mutate 直接修改取数结果

1)用法

export default () => {
  const [state, setState] = useState('');

  const { data, mutate } = useRequest(getUserInfo, {
    onSuccess: (data) => {
      setState(data.username);
    }
  });

  const editAction = useRequest(changeUsername, {
    manual: true,
    onSuccess: (_, params) => {
      mutate((d) => ({
        ...d,
        username: params[0]
      }));
      alert(`The username was changed to "${params[0]}" !`);
    }
  });

  return (
    <div>
      {data &&
        <>
          <div>userId: {data.id}</div>
          <div>usrename: {data.username}</div>
        </>
      }

      <input
        onChange={e => setState(e.target.value)}
        value={state}
        placeholder="Please enter username"
        style={{ width: 240, marginRight: 16 }}
      />
      <button onClick={() => editAction.run(state)}>
        {editAction.loading ? 'Editting...' : 'Edit'}
      </button>
    </div>
  );
};

2)源码分析

调用 mutate 传入的方法

// Fetch 类中实现了mutate
mutate(data: any) {
    if (typeof data === 'function') {
      this.setState({
        data: data(this.state.data) || {}
      });
    } else {
      this.setState({
        data
      });
    }
}
  • cancel、refresh、mutate 都必须在初次请求完成后才有意义,当调用完 hookrun 方法后,fetches[newstFetchKey.current] 就能取到 fetch 实例,然后覆盖掉 cancel、refresh、mutate 的异常报错。
// 在 hook 中
const noReady = useCallback((name: string) => {
    return () => {
      throw new Error(`Cannot call ${name} when service not executed once.`);
    }
  }, [])

  return {
    loading: !manual,
    data: initialData,
    error: undefined,
    params: [],
    cancel: noReady('cancel'),
    refresh: noReady('refresh'),
    mutate: noReady('mutate'),

    ...(fetches[newstFetchKey.current] || {}),
    run,
    fetches,
    reset
  } as BaseResult<U, P>;

9. 分页和加载更多

分页:设置 options.paginated 支持分页场景
加载更多:设置 options.loadMore 支持加载更多的情况

分页和加载原理:在 useAsync这个基础请求 hook 基础上再包一层 hook,扩展取数参数与返回结果。

所以,不在此处多余赘述了。

三、扩展知识点

1. 如何判断页面被隐藏(页面在后台标签页中 或者 浏览器最小化)

export function isDocumentVisible(): boolean {
  if (typeof document !== 'undefined' && typeof document.visibilityState !== 'undefined') {
    return document.visibilityState !== 'hidden';
  }
  return true;
}

document.visibilityState:表示下面 4 个可能状态的值
hidden:页面在后台标签页中或者浏览器最小化
visible:页面在前台标签页中
prerender:页面在屏幕外执行预渲染处理 document.hidden 的值为 true
unloaded:页面正在从内存中卸载

visibilitychange事件:当文档从可见变为不可见或者从不可见变为可见时,会触发该事件。

2. useState 惰性初始 state

函数返回值只会在组件的初始渲染中起作用,后续渲染时会被忽略

const [state, setState] = useState(() => {
  const initialState = someExpensiveComputation(props);
  return initialState;
});

3. 利用闭包保持Fetch实例最新值

分析:对于同一个实例,可能出现多次调用 _run 方法,导致 this.countcurrentCount 出现数据不同步的情况,比如,第一次调用 _run 后,刚好执行“关键点 闭包取数”后,还未执行到 return, 又执行了_run,导致此时 this.count+=1 ,那么第一次调用 _run.currentCount的值比当前的 this.count 小1。

作用:保证 state 中的数据是最近一次访问接口得到的数据


// Fetch类的实际取值函数

_run(...args: P) {
    // 取消已有定时器
    if (this.pollingTimer) {
      clearTimeout(this.pollingTimer);
    }
    // 取消 loadingDelayTimer
    if (this.loadingDelayTimer) {
      clearTimeout(this.loadingDelayTimer);
    }

    // ----------- 关键点 闭包取数 ------------

    this.count += 1;
    // 闭包存储当次请求的 count
    const currentCount = this.count;

    // ----------- 关键点 闭包取数 ------------

    this.setState({
      loading: this.config.loadingDelay ? false : true,
      params: args
    });

    if (this.config.loadingDelay) {
      this.loadingDelayTimer = setTimeout(() => {
        this.setState({
          loading: true,
        });
      }, this.config.loadingDelay);
    }

    return this.service(...args).then(res => {
      // ----------- 关键点 currentCount === this.count ------------
      if (!this.unmountedFlag && currentCount === this.count) {
        if (this.loadingDelayTimer) {
          clearTimeout(this.loadingDelayTimer);
        }
        const formattedResult = this.config.formatResult ? this.config.formatResult(res) : res;
        this.setState({
          data: formattedResult,
          error: undefined,
          loading: false
        });
        if (this.config.onSuccess) {
          this.config.onSuccess(formattedResult, args);
        }
        return formattedResult;
      }
    }).catch(error => {
      if (!this.unmountedFlag && currentCount === this.count) {
        if (this.loadingDelayTimer) {
          clearTimeout(this.loadingDelayTimer);
        }
        this.setState({
          data: undefined,
          error,
          loading: false
        });
        if (this.config.onError) {
          this.config.onError(error, args);
        }
        console.error(error);
        return error;
        // throw error;
      }
    }).finally(() => {
      if (!this.unmountedFlag && currentCount === this.count) {
        if (this.config.pollingInterval) {
          // 如果屏幕隐藏,并且 !pollingWhenHidden, 则停止轮询,并记录 flag,等 visible 时,继续轮询
          if (!isDocumentVisible() && !this.config.pollingWhenHidden) {
            this.pollingWhenVisibleFlag = true;
            return;
          }
          this.pollingTimer = setTimeout(() => {
            this._run(...args);
          }, this.config.pollingInterval);
        }
      }
    });

  }

4. useUpdateEffect : 更新才调用,初始化不调用

import { useEffect, useRef } from 'react';

const useUpdateEffect: typeof useEffect = (effect, deps) => {
  const isMounted = useRef(false);

  useEffect(() => {
    if (!isMounted.current) {
      isMounted.current = true;
    } else {
      return effect();
    }
  }, deps);
};

export default useUpdateEffect;

5. 限制函数调用次数的方法

export default function limit(fn: any, timespan: number) {
  let pending = false
  return (...args: any[]) => {
    if (pending) return
    pending = true
    fn(...args)
    setTimeout(() => (pending = false), timespan)
  }
}

参考链接

源码github地址
用法地址
精读《@umijs/use-request》源码

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,684评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,143评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,214评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,788评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,796评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,665评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,027评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,679评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,346评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,664评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,766评论 1 331
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,412评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,015评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,974评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,073评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,501评论 2 343

推荐阅读更多精彩内容

  • 前言 dyld全称the dynamic link editor,即动态链接器,其本质是Mach-O文件,他是专门...
    01_Jack阅读 3,726评论 2 14
  • Volley 的中文翻译为“齐射、并发”,是在2013年的Google大会上发布的一款Android平台网络通信库...
    万剑阅读 1,273评论 0 3
  • 简介 Glide由Google推出的图片加载框架,支持多种图片格式,同类的还有picasso,fresco。pic...
    拔萝卜占坑阅读 404评论 0 1
  • 一. 概述 Picasso是Square出品的一个非常精简的图片加载及缓存库,其主要特点包括: 易写易读的流式编程...
    SparkInLee阅读 1,077评论 2 11
  • 前言 最近有个想法——就是把 Android 主流开源框架进行深入分析,然后写成一系列文章,包括该框架的详细使用与...
    wildma阅读 1,923评论 0 12