解决 React Hooks 问题的解决方案

2025-06-08

解决 React Hooks 问题的解决方案

作者:Paul Cowan✏️

我之前的文章《React Hooks 的挫败感》获得了惊人的阅读量,一度荣登黑客新闻榜首。这篇文章也收获了不少评论,其中一些评论改变了我对 Hooks 的看法,让我对它有了全新的、更积极的看法。

上一篇文章引用了一个useFetch示例,该示例抽象了调用远程 API 端点的通用代码。fetch我希望抽象能够通过 Hook 实现可复用性。我希望将加载和错误状态都封装在一个 Hook 中,就像我们以前使用 Redux 中间件那样。下面是我希望为客户端代码编写的示例:

const asyncTask = useFetch(initialPage);
useAsyncRun(asyncTask);

const { start, loading, result: users } = asyncTask;

if (loading) {
  return <div>loading....</div>;
}

return (
  <>
    {(users || []).map((u: User) => (
      <div key={u.id}>{u.name}</div>
    ))}
  </>
);
Enter fullscreen mode Exit fullscreen mode

我引用了一个基于react-hooks-async的例子,它有一个useFetchHook。

下面是包含缩小版示例的CodeSandbox :

下面是代码清单:

const createTask = (func, forceUpdateRef) => {
  const task = {
    start: async (...args) => {
      task.loading = true;
      task.result = null;
      forceUpdateRef.current(func);
      try {
        task.result = await func(...args);
      } catch (e) {
        task.error = e;
      }
      task.loading = false;
      forceUpdateRef.current(func);
    },
    loading: false,
    result: null,
    error: undefined
  };
  return task;
};

export const useAsyncTask = (func) => {
  const forceUpdate = useForceUpdate();
  const forceUpdateRef = useRef(forceUpdate);
  const task = useMemo(() => createTask(func, forceUpdateRef), [func]);

  useEffect(() => {
    forceUpdateRef.current = f => {
      if (f === func) {
        forceUpdate({});
      }
    };
    const cleanup = () => {
      forceUpdateRef.current = () => null;
    };
    return cleanup;
  }, [func, forceUpdate]);

  return useMemo(
    () => ({
      start: task.start,
      loading: task.loading,
      error: task.error,
      result: task.result
    }),
    [task.start, task.loading, task.error, task.result]
  );
};
Enter fullscreen mode Exit fullscreen mode

许多评论提到了这种方法的复杂性,最有说服力的评论提到这种实现不是很具有声明性。

LogRocket 免费试用横幅

钩子用于可重复使用的生命周期行为

毫无疑问,评论部分最好的评论来自Karen Grigoryan,他指出 Hooks 是可重复使用生命周期行为的地方。

react-hooks-async和CodeSandbox中的示例使用该useAsyncRun函数来启动生命周期改变事件:

export const useAsyncRun = (asyncTask,...args) => {
  const { start } = asyncTask;
  useEffect(() => {
    start(...args);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [asyncTask.start, ...args]);

useEffect(() => {
    const cleanup = () => {
      // clean up code here
    };
    return cleanup;
  });
Enter fullscreen mode Exit fullscreen mode

React 经常被吹捧为一个声明式框架,而我爱上 React 的原因之一就是它的单向数据流故事。useAsyncRun感觉更像是命令式的而不是声明式的。

React 之道

React 的最佳工作方式是,我们改变 props 或 state,然后组件就会自然地做出反应。

Karen好心地创建了这个CodeSandbox,它不仅简化了事情,而且使事情感觉更具反应性(是的,这现在是一个实际的词)和声明性:

useFetch现在看起来像这样:

const fetchReducer: FetchReducer = (state, action) => {
  switch (action.type) {
    case "FETCH_START": {
      return { data: null, isLoading: true, error: null };
    }
    case "FETCH_SUCCESS": {
      return { data: action.payload, isLoading: false, error: null };
    }

    case "FETCH_ERROR": {
      return { data: null, isLoading: false, error: action.payload };
    }
    default:
      return state;
  }
};

export const useFetch = (initial) => {
  const [state, dispatch] = useReducer(fetchReducer, initialState);

  const getFetchResult = useCallbackOne(
    async (overrides) => {
      dispatch({ type: "FETCH_START" });
      try {
        const result = await api({ ...initial, ...overrides });
        dispatch({ type: "FETCH_SUCCESS", payload: (result as unknown) as T });
      } catch (err) {
        dispatch({ type: "FETCH_ERROR", payload: err });
      }
    },
    [initial]
  );

  return [state, getFetchResult];
};
Enter fullscreen mode Exit fullscreen mode

useFetch上面代码中的 Hook 返回一个函数getFetchResultgetFetchResult使用dispatch返回的函数useReducer来协调生命周期的变化。

我们用useStateanduseReducer来触发 effect 的变化,但这种方式是声明式的。强制重新渲染在 React 中是逆流而上的,违背了 React 的声明式本质。我想我又一次爱上了 React 的单向数据流。单向数据流正是吸引我使用 React 的原因,它仍然能够有效缓解繁重 JavaScript 应用程序中的混乱。

React 应该以这种方式工作,我们改变状态,组件知道如何重新渲染,并且useEffect代码块会根据状态变化执行。

客户端代码现在如下所示:

const [fetchResult, getfetchResult] = useFetch<User[]>(initialPage);

  const { data: users, isLoading, error } = fetchResult;

  // to keep reference identity in tact until next remount
  const defaultUsersRef = useRef<User[]>([]);

  // to kick off initial request
  useEffect(() => {
    getfetchResult(initialPage);
  }, [getfetchResult]);

  if (isLoading) {
    return <div>loading....</div>;
  }

  if (error) {
    return <div>error : {JSON.stringify(error)}</div>;
  }

  return (
    <>
      <Users users={users || defaultUsersRef.current} />
      <Knobs onClick={getfetchResult} />
    </>
  );
Enter fullscreen mode Exit fullscreen mode

getFetchResultuseEffect现在可以在组件首次安装时以及事件处理程序中使用。

非常感谢 Karen 提供的这个绝佳示例。

还值得注意的是,悬念可能很快就会消失,这可能是真正适合useFetch解决方案的地方。

细心的你一定注意到了,use-memo-one中的.是 . 的一个安全替代方案getFetchResult它对依赖数组的值进行了浅层检查,而不是对数组引用进行浅层检查。React Hooks 仍然令人沮丧,因为我们需要一个外部库来实现这一点,这很容易让我们陷入陈旧的闭包问题。useCallbackOneuseCallbackOneuseCallbackuseCallbackOne

陈旧的闭包问题

我一直对闭包心存疑虑,因为处理闭包时总会发生一些奇怪且不太妙的事情。但处理 Hook 时,闭包却是家常便饭。下面这个例子完美地诠释了这种现象:

const useInterval = (callback, delay) => {
  useEffect(() => {
    let id = setInterval(() => {
    callback();
  }, 1000);
    return () => clearInterval(id);
  }, []);
};

const App = () => {
 let [count, setCount] = useState(0);

 useInterval(() => setCount(count + 1), 1000);

 return <h1>{count}</h1>;
};
Enter fullscreen mode Exit fullscreen mode

这个CodeSandbox展示了这种邪恶行为:

实际情况是,useEffectHookuseInterval会捕获第一次渲染的计数,其初始值为0useEffect有一个空的依赖数组,这意味着它永远不会被重新应用,并且始终0从第一次渲染引用,并且计算结果始终为0 + 1

如果想要使用useEffect得好,需要确保依赖数组包含来自外部范围的任何随时间变化并被效果使用的值。

在大多数情况下,react-hooks/exhaustive-deps linting规则可以很好地突出显示缺少的依赖项,并且它正确地指出callback在作为第二个参数传递的数组中缺少useEffect

const useInterval = (callback, delay) => {
  useEffect(() => {
    let id = setInterval(() => {
      callback();
    }, delay);

    return () => clearInterval(id);
  }, [callback, delay]);
};

const App = () => {
  let [count, setCount] = useState(0);

  useInterval(() => setCount(count + 1), 1000);

  return <h1>{count}</h1>;
};
Enter fullscreen mode Exit fullscreen mode

我们遇到的问题是传递给的回调useInterval是一个箭头函数,这意味着它会在每次渲染时重新创建:

useInterval(() => setCount(count + 1), 1000);
Enter fullscreen mode Exit fullscreen mode

解决陈旧闭包的一个方法

Dan Abramov在这篇文章中提出了将回调存储在可变引用中的理由

我已经看到同样的解决方案出现在多个包中,这些包以各种形式出现,都基于将回调存储在可变引用中的原则。我以formik中的例子为例,它提供了一个useEventCallbackHook,负责将回调存储在可变引用中。

function useEventCallback(fn) {
  const ref = React.useRef(fn);

  useEffect(() => {
    ref.current = fn;
  });

  return React.useCallback(
    (...args) => ref.current.apply(void 0, args),
    []
  );
}

function useInterval(callback, delay) {
  const savedCallback = useEventCallback(callback);

  useEffect(() => {
    function tick() {
      savedCallback();
    }

    let id = setInterval(tick, delay);
    return () => clearInterval(id);
  }, [delay]);
}

const App = () => {
  let [count, setCount] = useState(0);

  useInterval(() => {
    setCount(count + 1);
  }, 1000);

  return <h1>{count}</h1>;
};
Enter fullscreen mode Exit fullscreen mode

将回调存储在可变的引用中意味着最新的回调可以在每次渲染时保存在引用中。

CodeSandbox 的useEventCallback实际演示如下:

结论

Hooks 带来了思维上的转变,我认为我们需要重新调整思路。我之前没有戴上 React 的眼镜来看待它们能提供什么。Hooks 完美契合了 React 的声明式特性,我认为它是一种很棒的抽象,它能够处理状态变化,并且组件知道如何对状态变化做出反应。太棒了!


编者注:觉得这篇文章有什么问题?您可以在这里找到正确版本

插件:LogRocket,一个用于 Web 应用的 DVR

 
LogRocket 仪表板免费试用横幅
 
LogRocket是一款前端日志工具,可让您重播问题,就像它们发生在您自己的浏览器中一样。您无需猜测错误发生的原因,也无需要求用户提供屏幕截图和日志转储,LogRocket 允许您重播会话以快速了解问题所在。它可与任何应用程序完美兼容,无论使用哪种框架,并且提供插件来记录来自 Redux、Vuex 和 @ngrx/store 的更多上下文。
 
除了记录 Redux 操作和状态之外,LogRocket 还记录控制台日志、JavaScript 错误、堆栈跟踪、带有标头 + 正文的网络请求/响应、浏览器元数据以及自定义日志。它还会对 DOM 进行插桩,以记录页面上的 HTML 和 CSS,即使是最复杂的单页应用程序,也能重现像素完美的视频。
 
免费试用


解决 React Hooks 问题的解决方案文首先出现在LogRocket 博客上。

鏂囩珷鏉ユ簮锛�https://dev.to/bnevilleoneill/solutions-to-frustrations-with-react-hooks-1l30
PREV
iframe 终极指南
NEXT
流行的 React Hook 库