React之如何阻止在已卸载的组件上进行setState

2,164 阅读2分钟

 使用React的开发者肯定对Warning: Can't call setState (or forceUpdate) on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in the componentWillUnmount method.这一句控制台的警告不陌生,它通常发生在异步的场景中。具体而言,当我们试图在一个timer或ajax请求的回调中去setState当前组件状态,就有一定风险看到这段警告。因为当setState真实被回调时,我们的组件可能已经被卸载了。那么我们该如何处理这个问题呢?

  一般来说,偶尔出现的这个Warning确实不会带来严重的性能问题,但是试想如果是setInterval的句柄没有被正确在卸载周期中进行清理,那即便你的组件销毁了,它也会持续地生效,不仅会造成memory leak,亦会拖慢你项目的响应速度。所以,作为一个严谨的开发者来说,我们在实现逻辑时,就须要先行考虑到这些问题。另外对于一些强迫症同学来说,肯定不会希望每次打开控制台看到一坨红屏,更别说我们在开发过程中,还会经常遇到另一个常见的对数组结构生成渲染Element缺少key值的Warning场景。

  在一波科学上网后,我大概得到了两种处理方式,“治标”“治本”

治标

  治标法本质上是在你的class组件或者hooks函数组件中声明一个哨兵变量,具体是用什么方式声明,如声明在实例属性useRefuseEffect的局部变量上都无所谓,它们都能达到同样的效果。

  我们就以一个获取后台日志的场景为例。

  class组件:

export default class LogList extends PureComponent {
    _isMounted = false
    componentDidMount() {
        this._isMounted = true
    }
    componentWillUnmount() {
        this._isMounted = false
    }
    fetchLogList = id => {
        return axios.get(`/fetchList/${id}`).then(res => {
            if (this._isMounted) {
                // setState动作...
            }
        })
    }
    render() {
        // 渲染
    }
}

  hooks组件:

// useRef保存哨兵变量
export default function LogList() {
    const _isMounted = useRef(false)
    const [logList, setLogList] = useState([])
    fetchLogList = id => {
        return axios.get(`/fetchList/${id}`).then(res => {
            if (_isMounted.current) {
                // setLogList...
            }
        })
    }
    useEffect(() => {
        _isMounted.current = true
        return () => {
            _isMounted.current = false
        }
    }, [])
    return (
        // 渲染
    )
}
// useEffect内部声明哨兵变量
export default function LogList() {
    const [logList, setLogList] = useState([])
    fetchLogList = id => {
        return axios.get(`/fetchList/${id}`)
    }
    useEffect(() => {
        let _isMounted = true
        fetchLogList(1).then(res => {
            if (_isMounted) {
                // setLogList...
            }
        })
        return () => {
            _isMounted = false
        }
    }, [])
    return (
        // 渲染
    )
}

治本

  治本要怎么治呢?其实在仔细观察治标中的操作后,我们发现我们都在当前组件上挂载了一个“脏东西”。作为一个组件本身的定位来说,它不再纯粹了,我们为了处理这种异步渲染的Warning而在组件本身上加东西是不太合适的。调整的核心思路在于**“解耦”**。

  参考js本身的timer,我们可以发现它们都会返回一个handler句柄用于之后的取消任务。那么诸如ajax请求之类的promise返回也是同理,问题就可以转移成:我们如何提供一个可以取消promise的方法? 从设计本身而言,这种异步等待的任务都应该具有一个取消的机制,等太久了我是不是应该直接将任务取消再主动发起?另外任务的等待处理逻辑本身也不应该放到组件属性上去做,会使得一个组件设计上职能不集中,看上去就很难受。

  大致方法就是实现一个高阶函数,同时返回封装后的新Promise实例以及支持取消该Promise的cancel方法:

const makeCancelable = (promise) => {
    let hasCanceled_ = false;

    const wrappedPromise = new Promise((resolve, reject) => {
        promise.then(
            val => hasCanceled_ ? reject({ isCanceled: true }) : resolve(val),
            error => hasCanceled_ ? reject({ isCanceled: true }) : reject(error)
        );
    });

    return {
        promise: wrappedPromise,
        cancel() {
            hasCanceled_ = true;
        },
    };
};

  之前的问题就可以修改为如下的样子:

import { makeCancelable } from '@/utils'

export default function LogList() {
    const [logList, setLogList] = useState([])
    fetchLogList = id => {
        return axios.get(`/fetchList/${id}`)
    }
    useEffect(() => {
        const { promise, cancel } = makeCancelable(fetchLogList(1))
        promise.then(res => {
            // setLogList...
        })
        return () => {
            cancel()
        }
    }, [])
    return (
        // 渲染
    )
}

  P.S. 实际上我们也可以再换个思路,通过将状态交由react-reduxstore掌控,组件拆分为无状态组件进行显示渲染,外层的业务组件进行通过dispatch派发action,中间件层进行异步动作,一样可以处理该问题。