Noctis's tech blog

如何避免 unmounted component 因非同步執行 setState 導致的 memory leak

2021-09-08 at ReactJS category

A. 問題現象

假設於元件中實作了非同步的操作,當元件在 async promise 執行 resolve / reject 前就已經被卸載掉(unmounted),而這樣的 function call 依然會繼續的執行 async action 後的 setState 方法,因此導致 memory leak。(這是真的 memory leak,可透過 chrome devtool 的 profiling tool,take heap snapshot 中可以分析)

B. 如何避免?

1. 從元件的結構切分上劃分明確的職責


統一由 container component 來管理 async action,並確保 container component 不會重複 re-render or re-mount,可以避免 component 不會不預警的在 promise 返回前就被觸發 unmount (或者說是整個元件的 re-render)

  • 優點:程式碼職責清晰,對生命週期的控制十分完整。

  • 缺點:實作上相對有難度,如果是擴充元件功能,可能無法避免的可能要進行重構來掌握流程 ⇒ 難度高。

2. 在元件、hooks 中加入 isMounted 的狀態來管理


function MyComponent() {
    const [loading, setLoading] = useState(false);
    const [someData, setSomeData] = useState({});
    let isMounted = true; 
  
    useEffect(() => {
        const someResponse = await fetchSomeData();
        if (isMounted){
            setSomeData(someResponse);
        }
        return () => {
            isMounted = false;
        }
    }, []);
    return (
        <div>
            {someData}
        </div>
    );
}
  • 優點:容易實作、沒有需要引用第三方套件
  • 缺點:反模式、每個元件、hooks 都需要重複處理類似邏輯

3. 自建 promise cancelling function(ref


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

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

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

// usage
const somePromise = new Promise(r => setTimeout(r, 1000));
const cancelable = makeCancelable(somePromise);

cancelable
  .promise
  .then(() => console.log('resolved'))
  .catch(({isCanceled, ...error}) => console.log('isCanceled', isCanceled));

// Cancel promise
cancelable.cancel();
  • 優點:容易實作、沒有需要引用第三方套件,可複用於 react 以外
  • 缺點:promise 外部還要多做一層 wrapper,看起來很不直觀,不容易使用

4. 引用 react-use(useMountedStateuseUnmountPromise


import * as React from 'react';
import {useMountedState} from 'react-use';

const Demo = () => {
  const isMounted = useMountedState();

  React.useEffect(() => {
    setTimeout(() => {
      if (isMounted()) {
        // ...
      } else {
        // ...
      }
    }, 1000);
  });
};
import useUnmountPromise from 'react-use/lib/useUnmountPromise';

const Demo = () => {
  const mounted = useUnmountPromise();
  useEffect(async () => {
    await mounted(someFunction()); // Will not resolve if component un-mounts.
  });
};
  • 優點:實作容易、廣泛被應用的 library
  • 缺點:外部依賴

5. 建立 custom hook 解決 hook 使用上的問題(參考 react-use )


useMountedState hook

  • hook:
const useMountedState = (): () => boolean => {
    const mountRef = useRef<boolean>(false);
    useEffect(() => {
        mountedRef.current = true;

        return () => {
            mountRef.current = false;
        };
    });

    return useCallback(() => mountRef.current, []);
};

export default useMountedState;
  • usage:
import useMountedState from './useMountedState';
import React, { useCallback, useState } from 'react';

export default () => {
  const isMounted = useMountedState();
  const [state, setState] = useState(null);

  const ajaxFetch = useCallback(async () => {
    const result = await fetchSomeData();
    if (isMounted()) {
      setState(result);
    }
  });

  return (
    // ...
  );
};

useCancelablePromise hook

  • hook:

    import useMountedState from './useMountedState';
    import { useCallback } from 'react';
    
    const useCancelablePromise = () => {
        const isMounted = useMountedState();
        return useCallback((promise, onCancel) => new Promise((resolve, reject) => {
            promise
                .then((result) => {
                    if (isMounted()) {
                        resolve(result);
                    }
                })
                .catch((error) => {
                    if (isMounted()) {
                        reject(error);
                    }
                })
                .finally(() => {
                    if (!isMounted() && onCancel) {
                        onCancel();
                    }
                });
        }),
            [isMounted],
        );
    };
    
    export default useCancelablePromise;
    
  • usage:

    import useCancelablePromise from './useCancelablePromise';
    import fetchSomeData from './fetchSomeData';
    import React, { useCallback, useState } from 'react';
    
    export default () => {
        const makeCancelable = useCancelablePromise();
        const [state, setState] = useState(null);
    
        const ajaxFetch = useCallback(async () => {
            const result = await makeCancelable(
                fetchSomeData(),
                () => { console.log("canceled") }
            );
            setState(result);
        }, [makeCancelable]);
    
        return (
        // ...
      );
    };
    
  • 優點:重用性高且容易使用,不需要第三方依賴

  • 缺點:需要判別使用情境,以及對於 hook 內部的實作需要花時間理解

C. 追加說明(透過 AbortController 取消 api 執行)

參考文章

Using AbortController (with React Hooks and TypeScript) to cancel window.fetch requests

  • 優點:透過原生 fetch api 取消不必要的 api request,減少請求的連線與不必要的等待時間

  • 缺點:瀏覽器支援度問題

  • 可參考 library:

    GitHub - jomaxx/make-abortable

noctishsu

Personal blog by noctishsu.

An full-stack developer & javaScript lover