当承诺解决时,状态更改意外重置

State changes unexpectedly resets when promise resolves

我有以下代码:

http-common.ts

import axios from 'axios';

export default axios.create({
  baseURL: window.location.origin,
  headers: {
    'Content-type': 'application/json',
  },
});

types.ts

enum UploadStatus {
  NONE,
  UPLOADING,
  DONE,
}

export default UploadStatus;

UploadForm.tsx

import * as React from 'react';

import http from './http-common';
import UploadStatus from './types';

type UploadItem = {
  file: File,
  progress: number,
  uploadSuccess: boolean | undefined,
  error: string,
};

const sendUpload = (file: File, onUploadProgress: (progressEvent: any) => void) => {
  const formData = new FormData();
  formData.append('file', file);

  return http.post('/api/uploadFile', formData, {
    headers: {
      'Content-Type': 'multipart/form-data',
    },
    onUploadProgress,
  });
};

const UploadForm = () => {
  const [fileSelection, setSelection] = useState<UploadItem[]>([]);
  const [currUploadStatus, setUploadStatus] = useState<UploadStatus>(UploadStatus.NONE);

  useEffect(() => {
    if (currUploadStatus === UploadStatus.UPLOADING) {
      const promises: Promise<void>[] = [];

      // Problem code to be discussed

      Promise.allSettled(promises)
        .then(() => setUploadStatus(UploadStatus.DONE));
    }
  }, [currUploadStatus]);

  const handleFileSelectChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    if (event.target.files !== null) {
      let newSelection: UploadItem[] = Array.from(event.target.files).map((currFile) => ({
        file: currFile,
        progress: 0,
        uploadSuccess: undefined,
        error: '',
        isChecked: false,
      }));

      if (currUploadStatus === UploadStatus.NONE) {
        newSelection = newSelection.reduce((currSelection, newSelItem) => {
          if (
            currSelection.every((currSelItem) => currSelItem.file.name !== newSelItem.file.name)
          ) {
            currSelection.push(newSelItem);
          }
          return currSelection;
        }, [...fileSelection]);
      }

      setSelection(newSelection);
    }
  };

  // Rest of code
}

我正在尝试将文件从客户端上传到服务器。 sendUpload中的第二个参数,onUploadProgress,是一个返回当前文件上传进度的函数,让我在屏幕上渲染一个进度条。

在我的第一次尝试中,useEffect实现如下:

  useEffect(() => {
    if (currUploadStatus === UploadStatus.UPLOADING) {
      const promises: Promise<void>[] = [];

      fileSelection.forEach((currUploadItem) => {
        const promise: Promise<void> = sendUpload(
          currUploadItem.file,
          (event: any) => {
            setSelection(() => fileSelection.map((currMapItem) => (
              currMapItem.file.name === currUploadItem.file.name
                ? {
                  ...currMapItem,
                  progress: Math.round((100 * event.loaded) / event.total),
                }
                : currMapItem
            )));
          },
        )
          .then(({ data }) => {
            setSelection(() => fileSelection.map((currMapItem) => (
              currMapItem.file.name === currUploadItem.file.name
                ? {
                  ...currMapItem,
                  uploadSuccess: data.uploadSuccess,
                }
                : currMapItem
            )));
          })
          .catch((error) => {
            setSelection(() => fileSelection.map((currMapItem) => (
              currMapItem.file.name === currUploadItem.file.name
                ? {
                  ...currMapItem,
                  error,
                  uploadSuccess: false,
                }
                : currMapItem
            )));
          });

        promises.push(promise);
      });

      Promise.allSettled(promises)
        .then(() => setUploadStatus(UploadStatus.DONE));
    }
  }, [currUploadStatus]);

在上面的 useEffect 代码中,我观察到当文件正在上传时(即 sendUpload(currUploadItem.file, ...) 部分),进度正在正确更新为 fileSelection 状态。当我限制我的浏览器时,我可以看到我的进度条随着时间的推移正确填满。

但是,一旦 promise 解决并且我到达代码的 .then(...) 部分,进度值将返回 0。在 then(...) 代码段中,uploadSuccess 是正确更新为 true,并且此值已成功保存到状态并保持该状态。换句话说,我可以在屏幕上看到上传成功的消息,但是我的进度条在达到100%后重置为0%

在第二次尝试中,我将代码更改为如下:

  useEffect(() => {
    if (currUploadStatus === UploadStatus.UPLOADING) {
      const promises: Promise<void>[] = [];

      for (const [idx, item] of fileSelection.entries()) {
        const promise: Promise<void> = sendUpload(
          item.file,
          (event: any) => {
            const updatedSelection = [...fileSelection];
            updatedSelection[idx].progress = Math.round((100 * event.loaded) / event.total);
            setSelection(updatedSelection);
          },
        )
          .then(({ data }) => {
            const updatedSelection = [...fileSelection];
            updatedSelection[idx].uploadSuccess = data.uploadSuccess;
            setSelection(updatedSelection);
          })
          .catch((error) => {
            const updatedSelection = [...fileSelection];
            updatedSelection[idx] = {
              ...updatedSelection[idx],
              error,
              uploadSuccess: false,
            };
            setSelection(updatedSelection);
          });

        promises.push(promise);
      }

      Promise.allSettled(promises)
        .then(() => setUploadStatus(UploadStatus.DONE));
    }
  }, [currUploadStatus]);

在第二个版本中,代码运行完美。 sendUpload(currUploadItem.file, ...) 正确更新我的进度条。当达到100%时,上传成功,promise resolve,渲染进度条停留在100%then(...) 完成,uploadSuccess 更新为 true。我的成功消息出现在屏幕上,进度条保持完整,这是正确的行为。

为什么第一个版本的代码失败了,而第二个版本成功了?在我看来,两个版本都在做完全相同的事情:

  1. 遍历 fileSelection 中的每个项目。
  2. 对于每个项目,通过axios承诺将文件上传到服务器,并实时获取上传进度
  3. axios更新上传进度时,新建一个数组。将旧数组中的项目插入到这个新数组中。对于正在发送文件的数组项,实时更新其进度值。设置状态。
  4. 上传完成后,progress 为 100。承诺现在解析并执行 .then(...)
  5. 创建一个新数组。将旧数组中的项目插入到这个新数组中。对于正在发送文件的数组项,progress 仍应为 100。将 uploadSuccess 更新为从服务器发送的布尔值。设置状态。

我原以为两个版本的代码都在做上述相同的事情。然而由于某种原因,第一个版本在第 4 步将 progress 100 保存到状态,但在第 5 步返回到 0。第二个版本将 progress 100 保存到第 4 步的状态,但在第 5 步它应该保持在 100。

发生了什么事?

您的第二个代码复制了数组,但它仍然包含相同的对象,然后其中一个对象在行突变

updatedSelection[idx].progress = Math.round((100 * event.loaded) / event.total);

您的第一个代码也通过使用扩展语法正确地克隆了对象。但是,问题在于它在每个事件中一次又一次地以效果执行时的旧 fileSelection 值作为起点。请注意,如果您一次上传多个文件,情况会更糟。

原因是关闭了过时的 constant,如 , , How to deal with stale state values inside of a useEffect closure? or this blog article 中所述。

要解决此问题,请使用 setSelection 的回调版本:

  useEffect(() => {
    if (currUploadStatus === UploadStatus.UPLOADING) {
      const promises = fileSelection.map(uploadItem =>
        sendUpload(
          uploadItem.file,
          (event: any) => {
            setSelection(currSelection =>
//                       ^^^^^^^^^^^^^
              currSelection.map(item => item.file.name === uploadItem.file.name
                ? {
                  ...item,
                  progress: Math.round((100 * event.loaded) / event.total),
                }
                : item
              )
            );
          },
        )
          .then(({data}) => ({
            uploadSuccess: data.uploadSuccess
          }, error => ({
            error,
            uploadSuccess: false
          })
          .then(result => {
            setSelection(currSelection =>
//                       ^^^^^^^^^^^^^
              currSelection.map(item => item.file.name === uploadItem.file.name
                ? {
                  ...item,
                  ...result,
                }
                : item
              )
            );
          })
      );

      Promise.all(promises)
        .then(() => setUploadStatus(UploadStatus.DONE));
    }
  }, [currUploadStatus]);