React Hooks 在 react-refresh 模块热替换(HMR)下的异常行为

前端桃园

共 6673字,需浏览 14分钟

 ·

2021-05-15 10:26

什么是 react-refresh

react-refresh-webpack-plugin[1] 是 React 官方提供的一个 模块热替换(HMR)插件。

A Webpack plugin to enable "Fast Refresh" (also previously known as Hot Reloading) for React components.

在开发环境编辑代码时,react-refresh 可以保持组件当前状态,仅仅变更编辑的部分。在 umi[2] 中可以通过 fastRefresh: {}快速开启该功能。



这张 gif 动图展示的是使用 react-refresh 特性的开发体验,可以看出,修改组件代码后,已经填写的用户名和密码保持不变,仅仅只有编辑的部分变更了。

react-refresh 的简单原理

对于 Class 类组件,react-refresh 会一律重新刷新(remount),已有的 state 会被重置。而对于函数组件,react-refresh 则会保留已有的 state。所以 react-refresh 对函数类组件体验会更好。本篇文章主要讲解 React Hooks 在 react-refresh 模式下的怪异行为,现在我来看下 react-refresh 对函数组件的工作机制。

  • 在热更新时为了保持状态,useStateuseRef 的值不会更新。
  • 在热更新时,为了解决某些问题[3]useEffectuseCallbackuseMemo 等会重新执行。

When we update the code, we need to "clean up" the effects that hold onto past values (e.g. passed functions), and "setup" the new ones with updated values. Otherwise, the values used by your effect would be stale and "disagree" with value used in your rendering, which makes Fast Refresh much less useful and hurts the ability to have it work with chains of custom Hooks.

如上图所示,在文本修改之后,state保持不变,useEffect被重新执行了。

react-refresh 工作机制导致的问题

在上述工作机制下,会带来很多问题,接下来我会举几个具体的例子。

第一个问题

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

export default () => {
  const [count, setState] = useState(0);

  useEffect(() => {
    setState(s => s + 1);
  }, []);

  return (
    <div>
      {count}
    </div>

  )
}

上面的代码很简单,在正常模式下,count值最大为 1。因为 useEffect 只会在初始化的时候执行一次。但在 react-refresh 模式下,每次热更新的时候,state 不变,但 useEffect 重新执行,就会导致 count 的值一直在递增。



如上图所示,count 随着每一次热更新在递增。


第二个问题

如果你使用了 ahooks[4] 或者 react-use[5]useUpdateEffect,在热更新模式下也会有不符合预期的行为。

import React, { useEffect } from 'react';
import useUpdateEffect from './useUpdateEffect';

export default () => {

  useEffect(() => {
    console.log('执行了 useEffect');
  }, []);

  useUpdateEffect(() => {
    console.log('执行了 useUpdateEffect');
  }, []);

  return (
    <div>
      hello world
    </div>

  )
}

useUpdateEffectuseEffect相比,它会忽略第一次执行,只有在 deps 变化时才会执行。以上代码的在正常模式下,useUpdateEffect 是永远不会执行的,因为 deps 是空数组,永远不会变化。但在 react-refresh 模式下,热更新时,useUpdateEffectuseEffect 同时执行了。


造成这个问题的原因,就是 useUpdateEffectref 来记录了当前是不是第一次执行,见下面的代码。


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;

上面代码的关键在 isMounted

  • 初始化时,useEffect 执行,标记 isMountedtrue
  • 热更新后,useEffect 重新执行了,此时 isMountedtrue,就往下执行了

第三个问题

最初发现这个问题,是 ahooks 的 useRequest 在热更新后,loading 会一直为 true。经过分析,原因就是使用 isUnmount ref 来标记组件是否卸载。

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

function getUsername({
  console.log('请求了')
  return new Promise(resolve => {
    setTimeout(() => {
      resolve('test');
    }, 1000);
  });
}

export default function IndexPage({

  const isUnmount = React.useRef(false);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    setLoading(true);
    getUsername().then(() => {
      if (isUnmount.current === false) {
        setLoading(false);
      }
    });
    return () => {
      isUnmount.current = true;
    }
  }, []);

  return loading ? <div>loading</div> : <div>hello world</div>;
}

如上代码所示,在热更新时,isUnmount 变为了true,导致二次执行时,代码以为组件已经卸载了,不再响应异步操作。

如何解决这些问题

方案一

第一个解决方案是从代码层面解决,也就是要求我们在写代码的时候,时时能想起来 react-refresh 模式下的怪异行为。比如 useUpdateEffect 我们就可以在初始化或者热替换时,将 isMounted ref 初始化掉。如下:

import { useEffect, useRef } from 'react';

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

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

export default useUpdateEffect;

这个方案对上面的问题二和三都是有效的。

方案二

根据官方文档[6],我们可以通过在文件中添加以下注释来解决这个问题。

/* @refresh reset */

添加这个问题后,每次热更新,都会 remount,也就是组件重新执行。useStateuseRef 也会重置掉,也就不会出现上面的问题了。

官方态度

本来 React Hooks 已经有蛮多潜规则了,在使用 react-refresh 时,还有潜规则要注意。但官方回复说这是预期行为,见该 issue[7]

Effects are not exactly "mount"/"unmount" — they're more like "show"/"hide".

不管你晕没晕,反正我是晕了,╮(╯▽╰)╭。

参考资料

[1]

react-refresh-webpack-plugin: https://github.com/pmmmwh/react-refresh-webpack-plugin

[2]

umi: https://umijs.org/zh-CN/docs/fast-refresh

[3]

为了解决某些问题: https://github.com/facebook/react/issues/21019#issuecomment-800650091

[4]

ahooks: https://github.com/alibaba/hooks/blob/master/packages/hooks/src/useUpdateEffect/index.ts

[5]

react-use: https://github.com/streamich/react-use/blob/master/docs/useUpdateEffect.md

[6]

官方文档: https://github.com/pmmmwh/react-refresh-webpack-plugin/blob/main/docs/API.md#reset

[7]

issue: https://github.com/facebook/react/issues/21019

浏览 21
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报