封装一个 input file

258 阅读2分钟

背景:选择文件有复杂的UI需求。

结论:

封装一个选择文件组件,关键点有:

  • Children 响应 onClick,以便实现 UI 无关
  • 通过换 key 解决相同文件 onChange 不触发问题

最近有文件选择的需求,而且对 UI 有一定的要求。比如简单的

还有更复杂的点击重新上传。

然而默认的很丑。

那怎么办?

可以看看 antd 的实现 ant-design.antgroup.com/components/…

可以看到 input file 隐藏在了后面,大概率就能联想到通过间接的方式点击了 input file,这个不难实现,domInput.click()即可。

于是,UI 和原生 input file 没关系了,那么灵活性就很高了。

我们可以很快写出第一版组件

const noneStyle = {
  display: 'none',
};

function File(props){
  const { onChange } = props;
  const refInput = useRef(null);

  const handleClick = useCallback(() => {
    refInput.current.click();
  }, [])
  
  return (
    <>
      <input ref={refInput} type="file" onChange={onChange} style={noneStyle} />
      <Button onClick={handleClick}>选择文件</Button>
    </>
  )
}

那如果触发选择的不是一个按钮,而是这种怎么处理?

把 Children 变的更灵活,Children 只需响应 onClick 即可。改改代码是

function File(props){
  const { children, onChange } = props;
  const refInput = useRef(null);

  const handleClick = useCallback(() => {
    refInput.current.click();
  }, [])
  
  return (
    <>
      <input ref={refInput} type="file" onChange={onChange} style={noneStyle} />
      {children({ onClick: handleClick })}
    </>
  )
}

调用方代码是这样

function Demo(){
  return (
    <File onChange={(e) => console.log(e.target.files[0])}>
      {({onClick}) => (
        <SomeComponent>
          <span onClick={onClick}>选择文件</a>
        </SomeComponent>
      )}
    </File>  
  )
}

实际使用过程中,我们会发现一些 bug,还是这张图

1 UserA 点击触发弹窗,选择了 FileA,呈现在界面上。

2 UserA 在界面上移除了次文件。

3 UserA 再次选择文件,选择了 FileA。问题来了:onChange 不会触发了。

出现上述问题的原因是对于 input file 来说,之前是 FileA 现在还是 FielA,所以不会触发 onChange。

这种问题虽然边缘,但是遇到了还是挺懵逼的,因为很少会注意这种情况,但是想了想 onChange 合情合理。

那怎么规避这种“预期之外”的问题?

问题转换:避免 onChange 不触发 =》每次 onChange 都可以触发 =》对于 input file 来说,每次内容都是变化的 =》FileA 没法变,那么可以改变 input 实例。=》通过 key 变更 input 实例。

代码变成这样,(再补充 accept)。

细心的小伙伴会发现 props 的 onChange 改成了 onSelect。因为 onChange 合情合理,这个命名的行为就是如此。所以我们需要换个名字 onSelect。

import React, { useCallback, useRef, useState } from 'react';

interface FileProps {
  children: ({ onClick }) => React.ReactNode;
  onSelect: (files: FileList | null) => void;
  accept?: string;
}

const noneStyle = {
  display: 'none',
};

const File = (props: FileProps) => {
  const [n, setN] = useState(1);
  const { children, accept, onSelect } = props;
  const refInput = useRef<HTMLInputElement>(null);

  const handleClick = useCallback(() => {
    refInput.current?.click();
  }, []);

  const handleChange = useCallback(
    (e) => {
      setN((prev) => prev + 1);
      onSelect(e.target.files);
    },
    [onSelect]
  );

  return (
    <>
      <input
        // 每次渲染新的,这样 onChange 总能响应
        key={n}
        ref={refInput}
        type="file"
        accept={accept}
        style={noneStyle}
        onChange={handleChange}
      />
      {children({ onClick: handleClick })}
    </>
  );
};