简单了解数据请求的 React Hooks

1,488 阅读6分钟

“人如其字”,代码亦是如此。优雅的代码往往体现出一位Coder高深的逼格

背景

你是一位刚入职的新人前端开发者,入职第一天PM带着PRD跑到你面前,说到:我需要你实现一个页面,这个页面中需要展示一些列表数据。你回答道:okok

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

export const Page: React.FC = () => {
  const [list, setList] = useState([]);

  useEffect(() => {
    fetch('xxx').then(res => setList(res.data));
  }, []);

  return (
    <div>
      {list.map(item => (
        <div key={item}>{item}</div>
      ))}
    </div>
  );
};

写好后部署上线,PM看后说道:我这网好差,为了提升用户体验,我还需要你写一个骨架屏。你心想这也不难,看我的!

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

export const Page: React.FC = () => {
  const [loading, setLoading] = useState(true);
  const [list, setList] = useState([]);

  useEffect(() => {
    fetch('xxx').then(res => {
      setLoading(false);
      setList(res.data);
    });
  }, []);

  if (loading) {
    return <div>这是骨架屏</div>;
  }

  return (
    <div>
      {list.map(item => (
        <div key={item}>{item}</div>
      ))}
    </div>
  );
};

写完后,你就提测了。提测中,QA同学给你提了一个bug:后端接口出现问题时,页面白屏。你一想,确实,自己没有做任何的错误处理,急急忙忙修了一版:

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

export const Page: React.FC = () => {
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [list, setList] = useState([]);

  useEffect(() => {
    fetch('xxx').then(res => {
      setLoading(false);
      setList(res.data);
    }).catch(err => setError(err));
  }, []);

  if (loading) {
    return <div>这是骨架屏</div>;
  }

  if (error) {
    return <div>出错了...</div>;
  }

  return (
    <div>
      {list.map(item => (
        <div key={item}>{item}</div>
      ))}
    </div>
  );
};

修改完这个问题后,QA验收也通过了,但组内CR的时候,又遇到了问题。你这个我好像也用到了,要不你抽离出来,避免之后写重复的代码。于是你又开始了修改:

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

const useFetch = url => {
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [data, setData] = useState(null);

  useEffect(() => {
    fetch(url)
      .then(res => {
        setLoading(false);
        setData(res.data);
      })
      .catch(err => setError(err));
  }, [url]);

  return { data, loading, error };
};

export const Page: React.FC = () => {
  const { data, loading, error } = useFetch('xxx');

  if (loading) {
    return <div>这是骨架屏</div>;
  }

  if (error) {
    return <div>出错了...</div>;
  }

  return (
    <div>
      {data.map(item => (
        <div key={item}>{item}</div>
      ))}
    </div>
  );
};

完成CR后,你成功上线!但第二个优化需求如约而至,PM发给你一个优化PRD说到,这一期我们要增加一个Tabs,用来切换列表数据。你按照需求写的时候发现现在抽离出来的hooks不能满足你这的需求,在连续切换Tabs的时候,很容易就出现了显示错误,后来你看了React官网后发现原来是没有在useEffect中去清除你的副作用导致的,经历了一系列改动后,这个内容你的开发也步入了尾声

const useFetch = url => {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [data, setData] = useState(null);

  useEffect(() => {
    let needSetData = false;
    setLoading(true);
    fetch(url)
      .then(res => {
        if (!needSetData) {
          setData(res.data);
          setLoading(false);
        }
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });

    return () => {
      needSetData = true;
      setLoading(false);
    };
  }, [url]);

  return { data, loading, error };
};

通过js闭包的能力,切换tabs导致数据不一致得到了解决,但你的写法又被CR的时候提出了问题。这个来回切换要一直请求数据,是不是性能不太好,这块可以优化一下。于是你又开始了修改,避免连续请求那我就存起来吧

const cacheMap = new Map();

const useFetch = url => {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [data, setData] = useState(null);

  useEffect(() => {
    let needSetData = false;
    setLoading(true);
    if (cacheMap.has(url)) {
      setData(cacheMap.get(url));
      setLoading(false);
    } else {
      fetch(url)
        .then(res => {
          if (!needSetData) {
            cacheMap.set(url, data);
            setData(res.data);
            setLoading(false);
          }
        })
        .catch(err => {
          setError(err);
          setLoading(false);
        });
    }

    return () => {
      needSetData = true;
      setLoading(false);
    };
  }, [url]);

  return { data, loading, error };
};

在书写完成后,新的优化需求如约而至,这次要将列表作为实时数据,所以要支持下拉更新。由于列表是一个可变的。你最初的想法是暴露一个函数出去刷新,但后来想想对于这个解决方案,你自己觉得还是有一些优化空间,例如可以让页面在用户点到后自动刷新

const cacheMap = new Map();

const useFetch = url => {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [data, setData] = useState(null);

  const handleFetch = useCallback(() => {
    fetch(url)
      .then(res => {
        cacheMap.set(url, data);
        setData(res.data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, [url]);

  const remove = useCallback(() => {
    cacheMap.delete(url);
  }, [url]);

  const mutate = useCallback(() => {
    remove();
    handleFetch();
  }, [url]);

  useEffect(() => {
    const onFocus = () => {
      mutate();
    };
    window.addEventListener('focus', onFocus);
    return () => {
      window.removeEventListener('focus', onFocus);
    };
  }, []);

  useEffect(() => {
    let needSetData = false;
    setLoading(true);
    if (cacheMap.has(url)) {
      setData(cacheMap.get(url));
      setLoading(false);
    } else {
      if (!needSetData) {
        handleFetch();
      }
    }

    return () => {
      needSetData = true;
      setLoading(false);
    };
  }, [url]);

  return { data, loading, error, mutate };
};

但随着之后的需求越来越庞大,内容越来越多,你发现自己写的useFetch缺少了很多内容,例如:

  • 完善的重试机制:在数据加载出问题的时候,要进行有条件的重试(如仅 5xx 时重试,403、404 时放弃重试)

  • 完善的错误日志机制:包括日志上报,报警等

  • 完善的预加载技术

  • 完善的 SSR、SSG 支持能力

  • 完善的分页请求缓存能力

  • 等等......

想到这些内容你就头痛,你心想这些东西我一个初级前端工程师怎么可能完成呢!!!带着怨气,你想从百度看看有没有参考的方向,应差阳错的打开了一个叫做SWR的网站,然后你发现你进入了新世界的大门(嘻嘻嘻)

正文

正如官网所说的,SWR是一个用于数据请求的 React Hooks 库,但不要只把它当作一个useFetch这么简单!!!

image

如果需要在自己的业务代码中用,官方推荐的写法是这样

function useUser(id) {
  const { data, error } = useSWR(`/api/user/${id}`, fetcher)

  return {
    user: data,
    isLoading: !error && !data,
    isError: error,
  }
}


function Avatar({ id }) {
  const { user, isLoading, isError } = useUser(id)

  if (isLoading) return <Spinner />
  if (isError) return <Error />
  return <img src={user.avatar} />
}

并且值得注意的是,多个组件引用swr的数据,也会实时更新,这样业务便不再关心UI视图为什么多处展示不一致,类似于下方的Content和Avatar两个组件,当页面自动刷新或者手动触发更新时,视图会一起发生改变,再也不用自己手动维护state状态

function Content() {
  const { user, isLoading } = useUser()
  if (isLoading) return <Spinner />
  return <h1>Welcome back, {user.name}</h1>
}


function Avatar() {
  const { user, isLoading } = useUser()
  if (isLoading) return <Spinner />
  return <img src={user.avatar} alt={user.name} />
}

这里只是简单介绍下SWR的使用方法,更加详细的例如刷新、分页等可以去他的官方文档中查看(API 选项 – SWR)这里就不再赘述。顺便也就简单从源码中解析了解下SWR的工作流程及它核心的逻辑部分。

通过config(包含所有入参),如果存在key对应的cache,则获取到对应数据,否则去执行对应的useSWRHandler函数,其实SWR v1.3.0暴露出来了更多的内容(相比于之前版本更新size、getSize、isLoading)

const useSwr = (
  getKey: SWRInfiniteKeyLoader,
  fn: BareFetcher<Data> | null,
  config: Omit<typeof SWRConfig.defaultValue, 'fetcher'>
    & Omit<SWRInfiniteConfiguration<Data, Error>, 'fetcher'>
  ) => {
  return {
      size: resolvePageSize(),
      setSize,
      mutate,
      get data() {
        return swr.data
      },
      get error() {
        return swr.error
      },
      get isValidating() {
        return swr.isValidating
      },
      get isLoading() {
        return swr.isLoading
      }
  }
}

getKey可以是字符串、任意数组或者 null,也可以是一个返回上述类型的函数。通过阅读源码可知,key参数会被serialize格式化处理,作为缓存的唯一依据,并且通过key作为唯一值调用swr的核心代码函数revalidate,在no-error并且visible online时候去获取数据。

function execute() 
      if (
            !getCache().error &&
            (refreshWhenHidden || getConfig().isVisible()) &&
            (refreshWhenOffline || getConfig().isOnline())
      ) {
            revalidate(WITH_DEDUPE).then(next)
      } else {
            // Schedule the next interval to check again.
            next()
      }
}

//  revalidate核心逻辑
const revalidate = useCallback(() => {
    try {
        if (shouldStartNewRequest) {
            setCache(initialState)
            // If no cache is being rendered currently (it shows a blank page),
            // we trigger the loading slow event.
            if (config.loadingTimeout && isUndefined(getCache().data)) {
                setTimeout(() => {
                    if (loading && callbackSafeguard()) {
                        getConfig().onLoadingSlow(key, config)
                    }
                }, config.loadingTimeout)}

                // Start the request and save the timestamp.
                // Key must be truthy if entering here.
                FETCH[key] = [currentFetcher(fnArg as DefinitelyTruthy<Key>),getTimestamp()]
            }

            // Wait until the ongoing request is done. Deduplication is also
            // considered here.
            ;[newData, startAt] = FETCH[key]
            newData = await newData

            if (shouldStartNewRequest) {
                // If the request isn't interrupted, clean it up after the
                // deduplication interval.
                setTimeout(cleanupState, config.dedupingInterval)
            }
        
            if (!FETCH[key] || FETCH[key][1] !== startAt) {
                if (shouldStartNewRequest) {
                    if (callbackSafeguard()) {
                        getConfig().onDiscarded(key)
                    }
                }
                return false
            }

            // Clear error.
            finalState.error = UNDEFINED

            /** 
            case 1:
            req------------------>res
            mutate------>end
            case 2:
            req------------>res
            mutate------>end
            case 3:
            req------------------>res
            mutate-------...---------->
            */
            const mutationInfo = MUTATION[key]
            if (
                !isUndefined(mutationInfo) &&
                // case 1
                (startAt <= mutationInfo[0] ||
                // case 2
                startAt <= mutationInfo[1] ||
                // case 3
                mutationInfo[1] === 0)
            ) {
                finishRequestAndUpdateState()
                if (shouldStartNewRequest) {
                    if (callbackSafeguard()) {
                        getConfig().onDiscarded(key)
                    }
                }
                return false
            }

            const cacheData = getCache().data

            finalState.data = compare(cacheData, newData) ? cacheData : newData

            // Trigger the successful callback if it's the original request.
            if (shouldStartNewRequest) {
                if (callbackSafeguard()) {
                    getConfig().onSuccess(newData, key, config)
                }
            }
            return true
      }
})

那对应的mutate主动刷新方法也就更好理解,在能够调用revalidate后,完成cache的刷新即可


// 暴露的mutate方法

// Default to true.
const shouldRevalidate = options.revalidate !== false

// It is possible that the key is still falsy.
if (!infiniteKey) return EMPTY_PROMISE

if (shouldRevalidate) {
    if (!isUndefined(data)) {
        // We only revalidate the pages that are changed
        const originalData = dataRef.current
        set({ _i: [false, originalData] })
    } else {
        // Calling `mutate()`, we revalidate all pages
        set({ _i: [true] })
    }
}
        
    
    
// 实际的mutateByKey方法    
const [key] = serialize(_k)
if (!key) return
const [get, set] = createCacheHelper<Data, MutateState<Data>>(cache, key)
const [EVENT_REVALIDATORS, MUTATION, FETCH] = SWRGlobalState.get(cache) as GlobalState

const revalidators = EVENT_REVALIDATORS[key]
const startRevalidate = () => {
    if (revalidate) {
    // Invalidate the key by deleting the concurrent request markers so new
    // requests will not be deduped.
    delete FETCH[key]
    if (revalidators && revalidators[0]) {
        return revalidators[0](revalidateEvents.MUTATE_EVENT).then(
            () => get().data
        )}
    }
    return get().data
}