如何在 React 应用程序中重新渲染 Uppy 实例时保留我已经上传的图像的图像预览

How to keep the image previews of images that I have already uploaded upon re-render of Uppy instance in a React app

我可以在我的 React 应用程序上使用 Uppy 上传图片:

选择并上传图片后,我会看到图片的预览,以及删除图片、添加更多图片等选项:

点击“继续”按钮后, reporting(来自 const [reporting, setReporting] = useState(false);)的状态设置为 true,我认为这会触发 DOM 的重新渲染,包括 UploadManager 组件的消失,这包含上图所示的 Uppy 仪表板。现在,呈现的是 <ReportForm> 组件:

...
{reporting ? (
        <ReportForm assetReferences={files} exifData={exifData} />
      ) : (
        <>
          <Grid item style={{ marginTop: 20 }}>
            <UploadManager
              onUploadStarted={() => setUploadInProgress(true)}
              onUploadComplete={() => setUploadInProgress(false)}
...

当用户从 <ReportForm> 组件(见上图)单击“返回照片”时,它只是将 reporting 的状态重置回 false。但是,Uppy 仪表板现在再次显示其默认值“将文件拖放到此处或浏览文件”(参见第一张图片)。 我想查看刚上传的图片的预览。

我是 React 的新手,但我可以看到 uppyInstance 是在 useEffect 挂钩中创建的,它似乎在 reporting 状态发生变化时被调用。我怀疑这就是“重置”uppy 仪表板的原因(请参阅下面与 uppy 相关的组件的代码):

UploadManager.jsx:

import React, { useEffect, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
import { get } from 'lodash-es';
import Uppy from '@uppy/core';
import Tus from '@uppy/tus';
import Dashboard from '@uppy/react/lib/Dashboard';
import Skeleton from '@material-ui/lab/Skeleton';
import Cropper from './Cropper';

import '@uppy/core/dist/style.css';
import '@uppy/dashboard/dist/style.css';

const dashboardWidth = 600;
const dashboardHeight = 400;

export default function UploadManager({
  files,
  assetSubmissionId,
  onUploadStarted = Function.prototype,
  onUploadComplete = Function.prototype,
  setFiles,
  exifData,
  disabled = false,
  alreadyFiles = false,
}) {
  const intl = useIntl();
  const currentExifData = useRef();
  currentExifData.current = exifData;
  const [uppy, setUppy] = useState(null);
  const [cropper, setCropper] = useState({
    open: false,
    imgSrc: null,
  });

  /* Resolves closure / useEffect issue */
  // https://www.youtube.com/watch?v=eTDnfS2_WE4&feature=youtu.be
  const fileRef = useRef([]);
  fileRef.current = files;

  if (alreadyFiles) {
    // MAYBE I CAN DO SOMETHING HERE???
  }

  useEffect(() => {
    console.log('deleteMe useEffect entered');
    const uppyInstance = Uppy({
      meta: { type: 'Report sightings image upload' },
      restrictions: {
        allowedFileTypes: ['.jpg', '.jpeg', '.png'],
      },
      autoProceed: true,
      // browserBackButtonClose: true,
    });

    uppyInstance.use(Tus, {
      endpoint: `${__houston_url__}/api/v1/asset_groups/tus`,
      headers: {
        'x-tus-transaction-id': assetSubmissionId,
      },
    });

    uppyInstance.on('upload', onUploadStarted);

    uppyInstance.on('complete', uppyState => {
      const uploadObjects = get(uppyState, 'successful', []);
      const assetReferences = uploadObjects.map(o => ({
        path: o.name,
        transactionId: assetSubmissionId,
      }));

      onUploadComplete();
      // console.log('deleteMe fileRef.current is: ');
      // console.log(fileRef.current);
      // console.log('deleteMe ...fileRef.current is: ');
      // console.log(...fileRef.current);
      // // eslint-disable-next-line no-debugger
      // debugger;
      setFiles([...fileRef.current, ...assetReferences]);
    });

    uppyInstance.on('file-removed', (file, reason) => {
      if (reason === 'removed-by-user') {
        const newFiles = fileRef.current.filter(
          f => f.path !== file.name,
        );
        setFiles(newFiles);
      }
    });

    setUppy(uppyInstance);

    return () => {
      if (uppyInstance) uppyInstance.close();
    };
  }, []);

  return (
    <div
      style={{
        opacity: disabled ? 0.5 : 1,
        pointerEvents: disabled ? 'none' : undefined,
      }}
    >
      {cropper.open && (
        <Cropper
          imgSrc={cropper.imgSrc}
          onClose={() => setCropper({ open: false, imgSrc: null })}
          setCrop={croppedImage => {
            const currentFile = files.find(
              f => f.filePath === cropper.imgSrc,
            );
            const otherFiles = files.filter(
              f => f.filePath !== cropper.imgSrc,
            );
            setFiles([
              ...otherFiles,
              { ...currentFile, croppedImage },
            ]);
          }}
        />
      )}
      {uppy ? (
        <div style={{ marginBottom: 32, maxWidth: dashboardWidth }}>
          <Dashboard
            uppy={uppy}
            note={intl.formatMessage({ id: 'UPPY_IMAGE_NOTE' })}
            showLinkToFileUploadResult={false}
            showProgressDetails
            showRemoveButtonAfterComplete
            doneButtonHandler={null}
            height={dashboardHeight}
            locale={{
              strings: {
                dropHereOr: intl.formatMessage({
                  id: 'UPPY_DROP_IMAGES',
                }),
                browse: intl.formatMessage({ id: 'UPPY_BROWSE' }),
                uploading: intl.formatMessage({
                  id: 'UPPY_UPLOADING',
                }),
                complete: intl.formatMessage({ id: 'UPPY_COMPLETE' }),
                uploadFailed: intl.formatMessage({
                  id: 'UPPY_UPLOAD_FAILED',
                }),
                paused: intl.formatMessage({ id: 'UPPY_PAUSED' }),
                retry: intl.formatMessage({ id: 'UPPY_RETRY' }),
                cancel: intl.formatMessage({ id: 'UPPY_CANCEL' }),
                filesUploadedOfTotal: {
                  0: intl.formatMessage({
                    id: 'UPPY_ONE_FILE_PROGRESS',
                  }),
                  1: intl.formatMessage({
                    id: 'UPPY_MULTIPLE_FILES_PROGRESS',
                  }),
                },
                dataUploadedOfTotal: intl.formatMessage({
                  id: 'UPPY_DATA_UPLOADED',
                }),
                xTimeLeft: intl.formatMessage({
                  id: 'UPPY_TIME_LEFT',
                }),
                uploadXFiles: {
                  0: intl.formatMessage({
                    id: 'UPPY_UPLOAD_ONE_FILE',
                  }),
                  1: intl.formatMessage({
                    id: 'UPPY_UPLOAD_MULTIPLE_FILES',
                  }),
                },
                uploadXNewFiles: {
                  0: intl.formatMessage({
                    id: 'UPPY_PLUS_UPLOAD_ONE_FILE',
                  }),
                  1: intl.formatMessage({
                    id: 'UPPY_PLUS_UPLOAD_MULTIPLE_FILES',
                  }),
                },
              },
            }}
          />
        </div>
      ) : (
        <Skeleton
          variant="rect"
          style={{
            width: '100%',
            maxWidth: dashboardWidth,
            height: dashboardHeight,
          }}
        />
      )}
    </div>
  );
}

这里是UploadManager所在的父组件的代码:

import React, { useState, useMemo } from 'react';
import { FormattedMessage } from 'react-intl';
import { v4 as uuid } from 'uuid';

import Grid from '@material-ui/core/Grid';
import InfoIcon from '@material-ui/icons/InfoOutlined';

import UploadManager from '../../components/report/UploadManager';
import ReportSightingsPage from '../../components/report/ReportSightingsPage';
import Text from '../../components/Text';
import Link from '../../components/Link';
import Button from '../../components/Button';
import ReportForm from './ReportForm';
// import useAssetFiles from '../../hooks/useAssetFiles';

export default function ReportSighting({ authenticated }) {
  // console.log('deleteMe ReportSighting called');
  const assetSubmissionId = useMemo(uuid, []);
  const [uploadInProgress, setUploadInProgress] = useState(false);
  const [alreadyFiles, setAlreadyFiles] = useState(false);
  const [files, setFiles] = useState([]);
  const [exifData, setExifData] = useState([]);
  const [reporting, setReporting] = useState(false);
  const noImages = files.length === 0;
  // const {
  //   setFilesFromComponent,
  //   getFilesFromComponent,
  // } = useAssetFiles();

  const onBack = () => {
    window.scrollTo(0, 0);
    // setFilesFromComponent(files);
    // setFiles(files);
    if (files) setAlreadyFiles(true);
    setReporting(false);
  };

  let continueButtonText = 'CONTINUE';
  if (noImages) continueButtonText = 'CONTINUE_WITHOUT_PHOTOGRAPHS';
  if (uploadInProgress) continueButtonText = 'UPLOAD_IN_PROGRESS';

  return (
    <ReportSightingsPage
      titleId="REPORT_A_SIGHTING"
      authenticated={authenticated}
    >
      {reporting ? (
        <Button
          onClick={onBack}
          style={{ marginTop: 8, width: 'fit-content' }}
          display="back"
          id="BACK_TO_PHOTOS"
        />
      ) : null}
      {reporting ? (
        <ReportForm assetReferences={files} exifData={exifData} />
      ) : (
        <>
          <Grid item style={{ marginTop: 20 }}>
            <UploadManager
              onUploadStarted={() => setUploadInProgress(true)}
              onUploadComplete={() => setUploadInProgress(false)}
              assetSubmissionId={assetSubmissionId}
              exifData={exifData}
              setExifData={setExifData}
              files={
                files
                // getFilesFromComponent()
                // ? getFilesFromComponent()
                // : files
              }
              setFiles={setFiles}
              alreadyFiles={alreadyFiles}
            />
            <div
              style={{
                display: 'flex',
                alignItems: 'center',
                marginTop: 20,
              }}
            >
              <InfoIcon fontSize="small" style={{ marginRight: 4 }} />
              <Text variant="caption">
                <FormattedMessage id="PHOTO_OPTIMIZE_1" />
                <Link
                  external
                  href="https://docs.wildme.org/docs/researchers/photography_guidelines"
                >
                  <FormattedMessage id="PHOTO_OPTIMIZE_2" />
                </Link>
                <FormattedMessage id="PHOTO_OPTIMIZE_3" />
              </Text>
            </div>
          </Grid>
          <Grid item>
            <Button
              id={continueButtonText}
              display="primary"
              disabled={uploadInProgress}
              onClick={async () => {
                window.scrollTo(0, 0);
                setReporting(true);
              }}
              style={{ marginTop: 16 }}
            />
          </Grid>
        </>
      )}
    </ReportSightingsPage>
  );
}

我怀疑问题是 useEffect 触发了 UploadManager 的重新渲染,但是

  1. 我不确定这是不是真的
  2. 我正在寻找防止上述重新渲染的好策略。我应该以某种方式在上传管理器逻辑上使用 disabled=reporting 吗?我应该限制触发 useEffect 钩子的东西吗?如果是这样,具体来说,我该怎么做?我可以将触发 useEffect 的东西(例如 reporting)列入黑名单吗?

这是我正在工作的 branch of the repository 以供需要时参考。在 stackblitz 上创建一个示例被证明是非常重要的。

非常感谢您提出任何建议!

我无法 运行 您提供的复制品,因为它似乎需要私有 API 密钥。

但是,检查代码后,似乎发生了以下情况:

  1. <ReportSighting> 组件首次呈现 reporting 状态为 false。这会导致 <UploadManager> 组件呈现,并且 Uppy 的新实例将由该组件内的效果创建并存储在 这个实例 <UploadManager>的状态。
  2. 用户上传文件,然后点击“继续”按钮,将 reporting 状态设置为 true。这会导致 <ReportSighting> 组件重新呈现,并改为显示 <ReportForm> 组件。原始 <UploadManager> 组件已卸载,其状态下存在的内容(尤其是具有用户上传文件的 Uppy 实例)已丢失。
  3. 用户点击“返回照片”按钮,将 reporting 状态设置回 false。这会导致 <UploadManager>new 实例呈现,并且创建 Uppy 实例的效果会重新 运行s。创建的实例也是新实例,因此不会显示上次呈现时包含的内容。

解决此问题的一种方法是 lift up state 需要保持不变,因为 <UploadManager> 已安装和卸载到父 <ReportSighting> 组件中。所以 <ReportSighting> 组件将负责创建 Uppy 实例,并将其作为 prop 传递给 <UploadManager>.

另见 this part of the Uppy docs:

Functional components are re-run on every render. This could lead to accidentally recreate a fresh Uppy instance every time, causing state to be reset and resources to be wasted.

The @uppy/react package provides a hook useUppy() that can manage an Uppy instance’s lifetime for you. It will be created when your component is first rendered, and destroyed when your component unmounts.

如果感觉单个组件中发生了太多事情,您也可以通过其他方式重构您的应用程序。但指导原则是:“不要在单个用户流中多次创建 uppy 实例”。

这其实是https://uppy.io/docs/react/initializing/

的回答

Importantly, the useUppy() hook takes a function that returns an Uppy instance. This way, the useUppy() hook can decide when to create it. Otherwise you would still be creating an unused Uppy instance on every render.

此处示例:

const uppy = useUppy(() => {
    return new Uppy({
      autoProceed: false,
      restrictions: {
        maxFileSize: 15 * 1024 * 1024,
        maxNumberOfFiles: 1,
        allowedFileTypes: ['image/*'],
      }
    }).use(Webcam)
      .use(ImageEditor, {
        id: "ImageEditor",
        quality: 0.8
      })
  })

  uppy.on('file-editor:complete', (result) => {
    props.images(result)
  })