一个 useEffect 小坑

167 阅读3分钟

这几天准备整个分片上传的demo,然后遇到了一个问题

抄了个在useEffect里addEventListener的写法,本来是想多用用React Hook让代码更好看,结果问题很严重

整体代码如下

import React, { useState, useRef, useEffect } from 'react'
import SparkMD5 from 'spark-md5'

const sliceUploader = () => {
  const [uploading, setUploading] = useState(false)
  const [uploadedFileList, setUploadedFileList] = useState([])
  const [uploadingFile, setUploadingFile] = useState(null)
  const inputRef = useRef(null)
  let currentFile = null
  const chunkSize = 5 * 1024 * 1024
  let chunkNum = 1
  let currentChunk = 0

  const readFile = (file) => {
    console.group('file', file.name)
    console.log('select file')

    chunkNum = Math.ceil(file.size/chunkSize)

    console.log('chunk num', chunkNum)

    const fileReader = new FileReader()
    const spark = new SparkMD5.ArrayBuffer()
    fileReader.onload = ({ target }) => {
      console.log(`read ${chunkNum>1 ? `chunk ${currentChunk}` : 'file'}`)
      spark.append(target.result)
      currentChunk++
      if(currentChunk < chunkNum) {
        readChunk()
      } else {
        const md5 = spark.end()
        console.log('file read, md5', md5)

        const pos = uploadedFileList.findIndex(v=>v.md5===md5)
        if(pos>-1) {
          const list = [...uploadedFileList]
          list.splice(pos, 1)
          setUploadedFileList([uploadedFileList[pos], ...list])
          console.log('file exist')
        } else {
          currentFile = { name: file.name, status: false, md5 }
          setUploadingFile(currentFile)
          uploadChunks(file)
        }
      }
    }

    const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice

    const readChunk = () => {
      const start = currentChunk * chunkSize, end = (start + chunkSize >= file.size) ? file.size : (start + chunkSize)
      fileReader.readAsArrayBuffer(blobSlice.call(file, start, end))
    }

    setUploading(true)
    readChunk()
  }

  const uploadChunk = (chunk) => {
    return new Promise((resolve) => {
      setTimeout(() => {
        console.log('uploaded chunk', chunk)
        resolve()
      }, Math.random(0, 1)*5000)
    })
  }

  const uploadChunks = (file) => {
    const reqList = []
    for(let i = 0; i < chunkNum; i++) {
      reqList.push(uploadChunk(i))
    }
    Promise.all(reqList).then(() => {
      console.log('uploaded all chunks')
      mergeChunks()
    })
  }

  const mergeChunks = () => {
    setTimeout(() => {
      console.log('merged')
      console.groupEnd()
      currentFile.status = true
      // 问题出在这里
      console.log('uploadedFileList', uploadedFileList)
      setUploadedFileList([currentFile, ...uploadedFileList])
      currentFile = null
      setUploadingFile(null)
      setUploading(false)
    }, 1000)
  }

  const uploader = ({ target }) => {
    console.log('change', target.files)
    if(!target.files.length) return
    readFile(target.files[0])
  }

  // 使用useEffect的时候, jsx不写onChange事件
  useEffect(()=>{
    console.log('mounted')
    document.getElementById('file').addEventListener('change', uploader)

    return () => {
      console.log('unmount')
      document.getElementById('file').removeEventListener('change', uploader)
    }
  }, [])

  return <div>
    <input type="file" id="file" ref={inputRef} style={{display: 'none'}} />
    {/* <input type="file" id="file" ref={inputRef} style={{display: 'none'}} onChange={uploader} /> */}
    <button onClick={()=>inputRef.current.click()} disabled={uploading}>Select File</button>
    <ul>
      {uploadingFile && <li>
        <span>uploading: {uploadingFile.name} {uploadingFile.status?.toString()}</span>
      </li>}
      {
        uploadedFileList.length>0 && uploadedFileList.map((item) => (
          <li key={item.md5}>
            <span>uploaded: {item.name} {item.status.toString()}</span>
          </li>
        ))
      }
    </ul>
  </div>
}

export default sliceUploader

当用普通的onChange事件的话,setUploadFileList是没问题的

而使用useEffect来自定义事件时,这里的uploadFileList就会一直为空

在segmentfault提了个问

因为没有贴完整代码(太长了感觉别人也不想看),所以虽然大家大概知道是什么原因但对于我的错误使用可能不是很明白我在问什么

所以如何setState可能不是问题,而是获取到的值永远都是空的


使用多一个useRef肯定是可以解决问题的,但是对这个bug的理解没有帮助


setUploadedFileList((pre) => [currentFile, ...pre])

更改为上面的写法能顺利更新list的数量,但是每次新增的currentFile都是null

这里应该也是闭包问题,执行代码的时候currentFile已经设置为空

setState会在渲染的时候一起调用,此时currentFile已经设为空


useEffect(() => {
  // ...
}, [uploader]);

这样写是可以解决问题的,但显得十分意义不明(?)


想了一下,其实这个问题很好理解

useEffect的第二个参数是[],所以只会执行一次

eventListener绑定的是最初的uploader,只能获取到永远为空的list

而函数式编程每次渲染都会生成新的uploader

所以需要完成需求的话,就对uploader直接依赖就行了,打印也能看出每次渲染都会走useEffect绑定新的uploader

如果直接使用onChange的话,其实和在参数中加入uploader是一样的

所以这时候看,这个写法并没有意义不明,它只是和onChange做了一样的事情

闭包陷阱是存在的,但是不是这个问题需要的解释


那么我最想知道的应该是,怎样写是最好的呢!


总结

切记:Function组件接受props输出jsx,用完即销毁,每次render都是一次重新执行