XXHolic/blog

Read rc-upload

Opened this issue · 0 comments

目录

引子

最近在使用上传组件的时候,感觉跟一般的组件用起来不太一样。于是,结合个人想法去收集相关的一些资料,形成一个大概实现思路,然后再去看 rc-upload 源码,进行对比看看有那些不同。

rc-upload 版本 2.8.1 。

功能分析

在常见的上传插件中,主要的功能点有:

  • 上传之前处理
  • 上传过程进度处理
  • 上传成功处理
  • 上传失败处理
  • 单/多文件上传
  • 请求头设置
  • 请求 body 参数设置
  • 拖拽上传

根据以上功能点,初步判断涉及到的知识点有:

  • input 标签 type="file" 类型相关特性。
  • 异步请求使用 XMLHttpRequestFetch,考虑到兼容性,优先选择前者。
  • File 对象
  • HTML 拖放 Drag and Drop

下面分别进行相关介绍。

相关知识点

input

用户选择本地的文件,就需要使用到 type="file"input 元素。这种类型的元素除了拥有共享的属性外,还支持下面的一些属性:

  • accept : 定义 input 接受的文件类型,如果是多种类型,则用逗号 , 隔开。
  • capture : 指定捕获图像或视频的设备。目前是一个布尔类型属性,当设置时,会请求设备的媒体捕捉设施,例如相机。
  • files : 选择的文件列表对象。
  • multiple : 表示是否允许用户一次选择多个文件。
  • webkitdirectory : 表示是否允许用户选择目录,这是一个非标准属性。

更多信息见 MDN input file

XMLHttpRequest

涉及到对象 XMLHttpRequest 的方法有:

  • open : 初始化请求。
  • send : 发送请求。
  • setRequestHeader : 设置 HTTP 请求头的值,必须在 open 之后、send 之前调用。
  • abort : 中止请求。

涉及到对象 XMLHttpRequest 的事件有:

  • abort : 当请求中断时触发,例如执行了 XMLHttpRequest.abort()
  • error : 当请求出现错误时触发。也可以使用 onerror 属性。
  • load : XMLHttpRequest 请求完成时触发。也可以使用 onload 属性。
  • loadend : 当一个请求完成,无论是否成功都会触发,在 aborterror 之后触发。也可以使用 onloadend 属性。
  • progress : 从接收到数据开始,周期触发。也可以使用 onprogress 属性。
  • timeout : 在预设时间内没有接收到响应时触发。也可以使用 ontimeout 属性。

更多信息见 MDN XMLHttpRequest

File

用户选择文件后返回的文件对象就是 File 对象,该对象拥有下面的属性:

  • lastModified : 当前 File 对象所引用文件最后修改时间,自 UNIX 时间起始值(1970年1月1日 00:00:00 UTC)以来的毫秒数。
  • name : 文件名称。
  • size : 文件所拥有字节大小。
  • type : 文件的 MIME 类型 。
  • webkitRelativePath : 相关的相对路径或 URL,非标准属性。

更多信息见 MDN File

Drag and Drop

拖动文件上传,可能涉及到的事件有:

  • dragover : 当元素或选中的文本被拖到一个可释放目标上时触发(每100毫秒触发一次)。
  • ondragleave : 当拖动元素或选中的文本离开一个可释放目标时触发。
  • drop : 当元素或选中的文本在可释放目标上被释放时触发。

DragEvent 接口有个 dataTransfer 属性,是一个 DataTransfer 对象。这个对象包含了拖拽事件的状态,例如拖动事件类型、拖动的数据。使用拖动方式上传文件的数据,就是从这个对象中获取。

更多信息见 MDN Drag and Drop

发现以上的知识点,跟大部分的功能点相对应:

  • 上传过程进度处理对应 XMLHttpRequestprogress 事件处理。
  • 上传成功对应 XMLHttpRequestload/loadend 事件处理。
  • 上传失败对应 XMLHttpRequesterror/timeout/abort 事件处理。
  • 单/多文件上传对应 inputmultiple 属性和 File 对象处理。
  • 请求头设置对应 XMLHttpRequestsetRequestHeader 方法。
  • 请求 body 参数设置对应 XMLHttpRequestsend 方法。
  • 拖拽上传对应 Drag/Drop 事件。

至于上传之前处理的功能在触发请求之前应该都可以。

接下来看 rc-upload 中的实现。

实现

这种基础组件类型,里面有非常多的类型检查。代码不是很多,主要逻辑在 Upload.jsxAjaxUploader.jsxIframeUpload.jsx 三个文件中,其中 IframeUpload 组件是在浏览器不支持 File 对象时使用,暂不考虑。

Upload.jsx

import AjaxUpload from './AjaxUploader';
import IframeUpload from './IframeUploader';

class Upload extends Component {
  static propTypes = {
    // 类型检查
  }

  static defaultProps = {
    // 默认值
  }

  state = {
    Component: null,
  }

  componentDidMount() {
    if (this.props.supportServerRender) {
      /* eslint react/no-did-mount-set-state:0 */
      this.setState({
        Component: this.getComponent(),
      }, this.props.onReady);
    }
  }

  getComponent() {
    return typeof File !== 'undefined' ? AjaxUpload : IframeUpload;
  }

  abort(file) {
    this.uploader.abort(file);
  }

  saveUploader = (node) => {
    this.uploader = node;
  }

  render() {
    if (this.props.supportServerRender) {
      const ComponentUploader = this.state.Component;
      if (ComponentUploader) {
        return <ComponentUploader {...this.props} ref={this.saveUploader} />;
      }
      return null;
    }
    const ComponentUploader = this.getComponent();
    return <ComponentUploader {...this.props} ref={this.saveUploader} />;
  }
}

逻辑很清晰,支持服务器端渲染,主要的不同是服务器端渲染会考虑到 onReady 方法执行。

AjaxUploader.jsx

简化代码
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import defaultRequest from './request'; // 请求
import getUid from './uid'; // 生成一个唯一的字符串标识
import attrAccept from './attr-accept'; // 文件过滤
import traverseFileTree from './traverseFileTree'; // 文件遍历

class AjaxUploader extends Component {
  static propTypes = {
    // 类型检查
  }

  state = { uid: getUid() }

  reqs = {}

  // 上传文件变化
  onChange = e => {
    const files = e.target.files;
    this.uploadFiles(files);
    this.reset();
  }

  // 主动触发上传的 click 事件
  onClick = () => {
    const el = this.fileInput;
    if (!el) {
      return;
    }
    el.click();
  }

  onKeyDown = e => {
    if (e.key === 'Enter') {
      this.onClick();
    }
  }

  // 文件拖拽
  onFileDrop = e => {
    const { multiple } = this.props;

    e.preventDefault();

    if (e.type === 'dragover') {
      return;
    }

    if (this.props.directory) {
      traverseFileTree(
        e.dataTransfer.items,
        this.uploadFiles,
        _file => attrAccept(_file, this.props.accept)
      );
    } else {
      let files = Array.prototype.slice
        .call(e.dataTransfer.files)
        .filter(file => attrAccept(file, this.props.accept));

      if (multiple === false) {
        files = files.slice(0, 1);
      }

      this.uploadFiles(files);
    }
  }

  componentDidMount() {
    this._isMounted = true;
  }

  componentWillUnmount() {
    this._isMounted = false;
    this.abort();
  }

  // 统一的处理文件,先每个文件添加标识,触发上传请求
  uploadFiles = (files) => {
    const postFiles = Array.prototype.slice.call(files);
    postFiles
      .map(file => {
        file.uid = getUid();
        return file;
      })
      .forEach(file => {
        this.upload(file, postFiles);
      });
  };

  // 文件上传 beforeUpload 处理
  upload(file, fileList) {
    const { props } = this;
    if (!props.beforeUpload) {
      // always async in case use react state to keep fileList
      return setTimeout(() => this.post(file), 0);
    }

    const before = props.beforeUpload(file, fileList);
    if (before && before.then) {
      before.then((processedFile) => {
        const processedFileType = Object.prototype.toString.call(processedFile);
        if (processedFileType === '[object File]' || processedFileType === '[object Blob]') {
          return this.post(processedFile);
        }
        return this.post(file);
      }).catch(e => {
        console && console.log(e); // eslint-disable-line
      });
    } else if (before !== false) {
      setTimeout(() => this.post(file), 0);
    }
  }

  // 组装请求参数,自定义请求也在这里
  post(file) {
    if (!this._isMounted) {
      return;
    }
    const { props } = this;
    let { data } = props;
    const {
      onStart,
      onProgress,
      transformFile = (originFile) => originFile,
    } = props;

    new Promise(resolve => {
      const { action } = props;
      if (typeof action === 'function') {
        return resolve(action(file));
      }
      resolve(action);
    }).then(action => {
      const { uid } = file;
      const request = props.customRequest || defaultRequest;
      const transform = Promise.resolve(transformFile(file));
      transform.then((transformedFile) => {
        if (typeof data === 'function') {
          data = data(file);
        }

        const requestOption = {
          action,
          filename: props.name,
          data,
          file: transformedFile,
          headers: props.headers,
          withCredentials: props.withCredentials,
          onProgress: onProgress ? e => {
            onProgress(e, file);
          } : null,
          onSuccess: (ret, xhr) => {
            delete this.reqs[uid];
            props.onSuccess(ret, file, xhr);
          },
          onError: (err, ret) => {
            delete this.reqs[uid];
            props.onError(err, ret, file);
          },
        };
        this.reqs[uid] = request(requestOption);
        onStart(file);
      });
    });
  }

  reset() {
    this.setState({
      uid: getUid(),
    });
  }

  abort(file) {
    const { reqs } = this;
    if (file) {
      let uid = file;
      if (file && file.uid) {
        uid = file.uid;
      }
      if (reqs[uid] && reqs[uid].abort) {
        reqs[uid].abort();
      }
      delete reqs[uid];
    } else {
      Object.keys(reqs).forEach((uid) => {
        if (reqs[uid] && reqs[uid].abort) {
          reqs[uid].abort();
        }
        delete reqs[uid];
      });
    }
  }

  saveFileInput = (node) => {
    this.fileInput = node;
  }

  render() {
    const {
      component: Tag, prefixCls, className, disabled, id,
      style, multiple, accept, children, directory, openFileDialogOnClick,
    } = this.props;
    const cls = classNames({
      [prefixCls]: true,
      [`${prefixCls}-disabled`]: disabled,
      [className]: className,
    });
    const events = disabled ? {} : {
      onClick: openFileDialogOnClick ? this.onClick : () => { },
      onKeyDown: openFileDialogOnClick ? this.onKeyDown : () => { },
      onDrop: this.onFileDrop,
      onDragOver: this.onFileDrop,
      tabIndex: '0',
    };
    return (
      <Tag
        {...events}
        className={cls}
        role="button"
        style={style}
      >
        <input
          id={id}
          type="file"
          ref={this.saveFileInput}
          key={this.state.uid}
          style={{ display: 'none' }}
          accept={accept}
          directory={directory ? 'directory' : null}
          webkitdirectory={directory ? 'webkitdirectory' : null}
          multiple={multiple}
          onChange={this.onChange}
        />
        {children}
      </Tag>
    );
  }
}

export default AjaxUploader;

以上逻辑中有下面几个特点:

  1. 隐藏了 input 元素,用另外一个元素触发上传事件。
  2. 每一个文件都有唯一的标识 uid,方便请求和中断查找处理。
  3. 记录了组件的渲染状态,在请求时会进行判断。
  4. 整个上传过程主要分成了 3 部分:uploadFilesuploadpostuploadFiles 中是为文件添加标识,upload 中处理上传前的逻辑,post 中包含了请求成功、进度、失败逻辑。
  5. 每个请求返回的是 XMLHttpRequest 对象的 abort 方法。
  6. 文件信息都放在 FormData 对象里面。

对比

跟自己最初的思路相比,发现了下面的不足:

  • 没有考虑服务器端渲染和兼容性
  • 没有考虑统一的触发方式
  • 缺乏文件状态记录和销毁方式
  • 各状态阶段没有清晰的界定,该在什么时候触发什么方法
  • 没有考虑上传文件传输方式
  • 缺少自定义请求的方式

小结

看似一个简单的功能,想要做的比较完善要考虑很多方面,需要不断的打磨。

参考资料

🗑️

以下是一些无关紧要的内容。

算是按照老早的计划写完了这篇总结,写完之后想着结尾该写点什么东西,突然想到《虫师》

《虫师》中的“虫”跟我们一般认知的虫子不太一样,在里面的旁白是这样说的:

于人外之轮回,栖息着一群低等而奇异的,与我们常见的动植物截然不同之物,对这些异形之物,人们自古以来便满怀敬畏,不知不觉将它们统称为“虫”。

这种虫有自己的生存方式,而这种方式却可能有悖于人类的常识,甚至危害人类的生存,于是就出现了“虫师”这种职业,他们云游四方,对虫的生命形态,生存方式进行研究,并接受人们的委托,解决可能是由虫引起的怪异事件。

到处旅行,见多识广的银古

44-waste

这种设定听起来还是蛮合理的,毕竟还是有很多人类并不知道的微生物。得益于这点,一些日常中奇怪的现象或者传说,都可以从虫的角度去进行另外一种形式演绎。

《虫师》中描绘了各种各样的奇妙的虫和现象,比如类似神笔马良的虫、会将梦境映射到现实的虫、喜欢“吃”声音的虫、会形成类似彩虹到处“旅行”的虫。这些没有台词的主角,在与其它生物产生关联时,述说的故事奇幻而独特,让人对大自然多了一份敬畏。