小编和同事在使用el-upload时发现el-upload有以下特点:
1.设置limit = 1,如果组件没有销毁,那么只在第一次上传文件时会触发上传方法,第二次上传的时候不会触发上传方法。解决办法是可以调用clearFile方法。
2.autoUpload设置为false, 之后不再触发beforeUpload的调用。
3.httpRequest设置了之后也不再触发beforeUpload。
4.虽然我们实现自定义上传的时候要把文件手动上传到云端,但是也要给action属性赋值
这些特点有的通过文档可以推测出来,有的则比较隐晦,下面通过阅读源码来一一揭晓这些注意事项背后的原理
1.模块结构和引用关系
先看一下el-upload模块的整体结构:
下面说一下这些文件之间的引用关系:
index.js引用了src 下的 index.vue
index.vue引用了upload-list.vue和upload.vue
upload.vue引用了upload-dragger.vue和ajax
从组件的父子关系层面看是这样的:
2.源码文件分析
下面详细看一下各个文件:
2.1 index.js文件
import Upload from './src';
/* istanbul ignore next */
Upload.install = function(Vue) {
Vue.component(Upload.name, Upload);
};
export default Upload;
此文件做的事情就是把Upload组件变为可以被vue注册的组件。
2.2 index.vue文件中的props,data等
2.2.1 导入的文件
首先看导入的文件:
import UploadList from './upload-list';
import Upload from './upload';
import ElProgress from 'element-ui/packages/progress';
import Migrating from 'element-ui/src/mixins/migrating';
导入了两个子组件UploadList和Upload,它们在render方法中被使用,用来组装最终的上传组件;
ElProgress引入了并注册成为子组件,但是在index中并没有使用,感觉冗余;
Migrating为混入,定义在element-ui\lib\mixins\migrating.js,大概意思看的似懂非懂,具体干嘛的我不瞎说,好像是提示迁移信息,里面有一段代码挺有意思:
for (var propName in definedProps) {
propName = (0, _util.kebabCase)(propName); // compatible with camel case
if (props[propName]) {
console.warn('[Element Migrating][' + this.$options.name + '][Attribute]: ' + props[propName]);
}
}
这里第二行的代码写的挺有意思,(a,b)(c)这是什么语法呢?明白的同学可以留言赐教~
我们看一下在index.vue中如何使用这个文件的:
getMigratingConfig() {
return {
props: {
'default-file-list': 'default-file-list is renamed to file-list.',
'show-upload-list': 'show-upload-list is renamed to show-file-list.',
'thumbnail-mode': 'thumbnail-mode has been deprecated, you can implement the same effect according to this case: http://element.eleme.io/#/zh-CN/component/upload#yong-hu-tou-xiang-shang-chuan'
}
};
}
从这段代码可以看出,这是用来提示一些属性被重命名或者废弃了,所以英文名叫migrating(迁移)还是非常准确的。
2.2.2 属性props
下面接着往下看index.vue:
function noop() {}
这里定义了一个noop空方法,主要是作为组件函数属性(props)的默认值使用的:
props: {
onRemove: {
type: Function,
default: noop
},
onChange: {
type: Function,
default: noop
},
}
如果我自己写函数的默认值,我可能这么写:
props: {
onRemove: {
type: Function,
default: ()=>{}
},
onChange: {
type: Function,
default: ()=>{}
},
}
虽然箭头函数也挺简洁,但是也没有noop简洁~
props中定义了el-upload的属性,我们在文档中看到啥这里就定义的啥,如下图所示:
2.2.3 使用provide/inject进行组件传值
还有这样的一段代码值得注意:
provide() {
return {
uploader: this
};
},
熟悉vue的同学都知道有一些深度嵌套的组件,而深层的子组件只需要父组件的部分内容,那么使用provide/inject无疑是非常好的选择,这里index.vue把this提供给子组件了,这种用法我第一次见到。主要是为了让子组件能够使用index.vue中定义的一些属性和方法。看一下子组件中如何使用的:
upload-dragger.vue中:
inject: {
uploader: {
default: ''
}
},
onDrop(e) {
if (this.disabled || !this.uploader) return;
const accept = this.uploader.accept;
this.dragover = false;
if (!accept) {
this.$emit('file', e.dataTransfer.files);
return;
}
}
upload-dragger.vue中使用到了index.vue中定义的accept属性。如果你还不熟悉provide和inject可以查看vue的文档:
2.3.4 数据data
data() {
return {
uploadFiles: [],
dragOver: false,
draging: false,
tempIndex: 1
};
},
uploadFiles存放上传的文件列表,好多方法里面都有用到;dragOver和draging没有用到;tempIndex用于生成文件的uid使用的,用法:
handleStart(rawFile) {
rawFile.uid = Date.now() + this.tempIndex++;
}
index.vue中定义的方法比较多,我们逐个看一下
2.3 index.vue中的方法methods
2.3.1 handleStart
handleStart(rawFile) {
rawFile.uid = Date.now() + this.tempIndex++;
let file = {
status: 'ready',
name: rawFile.name,
size: rawFile.size,
percentage: 0,
uid: rawFile.uid,
raw: rawFile
};
if (this.listType === 'picture-card' || this.listType === 'picture') {
try {
file.url = URL.createObjectURL(rawFile);
} catch (err) {
console.error('[Element Error][Upload]', err);
return;
}
}
this.uploadFiles.push(file);
this.onChange(file, this.uploadFiles);
},
handleStart方法首先对组件的文件进行进一步的封装,rawFile是初始用户上传的文件,file是封装后的文件。给初始文件uni属性赋值;状态设置为ready;判断listType是否为picture-card或者picture,如果是则给url属性赋值, URL.createObjectURL(rawFile)是根据rawFile创建文件的url。
然后把文件放入uploadFiles,调用onChange方法。onChange就是组件的使用者定义的onChange方法:
可以看到,用户在on-change钩子函数中拿到的fileList就是内部的uploadFiles。
关于URL.createObjectURL可以参考:developer.mozilla.org/zh-CN/docs/… ,另外在beforeDestroy中,使用了URL.revokeObjectURL, 用来释放已经存在的URL对象。
beforeDestroy() {
this.uploadFiles.forEach(file => {
if (file.url && file.url.indexOf('blob:') === 0) {
URL.revokeObjectURL(file.url);
}
});
}
2.3.2handleProgress
handleProgress(ev, rawFile) {
const file = this.getFile(rawFile);
this.onProgress(ev, file, this.uploadFiles);
file.status = 'uploading';
file.percentage = ev.percent || 0;
},
handleProgress用于监听当前文件的上传进度。首先获取当前文件,然后触发用户传入的onProgress钩子函数,把file给钩子函数。然后设置文件状态为uploading, 设置文件上传的百分比。个人觉得把最后两句写在this.onProgress之前从语义上看可能更好。
那么handleProgress怎么就能监听文件上传状态呢?如何做到的呢?
我们看render函数中有这样的一段代码:
const uploadData = {
props: {
'on-progress': this.handleProgress,
'on-success': this.handleSuccess,
'on-error': this.handleError,
'on-preview': this.onPreview,
'on-remove': this.handleRemove,
'http-request': this.httpRequest
},
ref: 'upload-inner'
};
const uploadComponent = <upload {...uploadData}>{trigger}</upload>;
也就是把handleProgress赋值给upload组件的on-progress属性了,我们看upload组件中是如何使用这个属性的:
upload.vue:
export default {
props: {
onProgress: Function,
},
// methods中定义的一个post方法
post(rawFile) {
const { uid } = rawFile;
const options = {
headers: this.headers,
withCredentials: this.withCredentials,
file: rawFile,
data: this.data,
filename: this.name,
action: this.action,
onProgress: e => {
this.onProgress(e, rawFile);
},
onSuccess: res => {
this.onSuccess(res, rawFile);
delete this.reqs[uid];
},
onError: err => {
this.onError(err, rawFile);
delete this.reqs[uid];
}
};
const req = this.httpRequest(options);
this.reqs[uid] = req;
if (req && req.then) {
req.then(options.onSuccess, options.onError);
}
},
}
通过这段代码我们知道了,index.vue中定义的onProgress方法在发送ajax请求的选项对象的onProgress回调中被调用了,这样就能够实时监控文件上传的百分比了。
2.3.3 handleSuccess和handleError
同理,handleSuccess和handleError能够监听文件上传成功和上传出错也是一样的道理,都是在ajax的回调中调用了handleSuccess方法和handleError方法。
handleSuccess(res, rawFile) {
const file = this.getFile(rawFile);
if (file) {
file.status = 'success';
file.response = res;
this.onSuccess(res, file, this.uploadFiles);
this.onChange(file, this.uploadFiles);
}
},
handleError(err, rawFile) {
const file = this.getFile(rawFile);
const fileList = this.uploadFiles;
file.status = 'fail';
fileList.splice(fileList.indexOf(file), 1);
this.onError(err, file, this.uploadFiles);
this.onChange(file, this.uploadFiles);
},
handleSuccess中把文件状态设置为success, 触发onSuccess钩子; handleError中把文件状态设置为fail,触发onError钩子, 并把文件从fileList中删除。
2.3.4 handleRemove
下面看handleRemove:
handleRemove(file, raw) {
if (raw) {
file = this.getFile(raw);
}
let doRemove = () => {
this.abort(file);
let fileList = this.uploadFiles;
fileList.splice(fileList.indexOf(file), 1);
this.onRemove(file, fileList);
};
if (!this.beforeRemove) {
doRemove();
} else if (typeof this.beforeRemove === 'function') {
const before = this.beforeRemove(file, this.uploadFiles);
if (before && before.then) {
before.then(() => {
doRemove();
}, noop);
} else if (before !== false) {
doRemove();
}
}
},
handleRemove用于一些情况下删除文件。 里面定义了一个doRemove方法是专门用于删除文件的,会触发onRemove钩子,调用了abort方法(下文介绍)。如果用于没有定义beforeRemove钩子函数,则直接删除,如果用户定义了beforeRemove,则先调用beforeRemove,然后决定是否删除。element-ui中对before-remove说明如下:
\
2.3.5 getFile
下面看handleRemove,handleError,handleSuccess,handleProgress中都有用到的一个方法:getFile:
getFile(rawFile) {
let fileList = this.uploadFiles;
let target;
fileList.every(item => {
target = rawFile.uid === item.uid ? item : null;
return !target;
});
return target;
},
getFile方法主要是做遍历,根据uid进行判断,返回目标target。
2.3.6 abort
abort方法:
abort(file) {
this.$refs['upload-inner'].abort(file);
},
在handleRemove的doRemove中调用了这个abort方法, 这里是调用upload-inner的abort,upload-inner是谁?看下图:
可以发现refs.upload-inner就是指向了upload组件,所以abort方法是upload文件中定义的abort方法:
abort(file) {
const { reqs } = this;
if (file) {
let uid = file;
if (file.uid) uid = file.uid;
if (reqs[uid]) {
reqs[uid].abort();
}
} else {
Object.keys(reqs).forEach((uid) => {
if (reqs[uid]) reqs[uid].abort();
delete reqs[uid];
});
}
},
abort方法内部首先获取所有的请求reqs, 然后根据uid找到此文件对应的请求,并调ajax的 abort方法,注意上文中提到upload.vue文件中定义的post方法中有这样的一段代码:
const req = this.httpRequest(options);
this.reqs[uid] = req;
if (req && req.then) {
req.then(options.onSuccess, options.onError);
}
req是httpRequest方法的返回值,httpRequest是el-upload(index.vue)暴露出来的一个属性,接收用户传入的方法,并传给upload.vue文件;如果用户没有定义httpRequest,那么httpRequest的默认值为ajax:
import ajax from './ajax';
props: {
httpRequest: {
type: Function,
default: ajax
},
}
2.3.7 clearFiles
clearFiles() {
this.uploadFiles = [];
},
clearFiles方法将uploadFiles置为空,这个方法是el-upload的使用者的:
2.3.8 submit
submit() {
this.uploadFiles
.filter(file => file.status === 'ready')
.forEach(file => {
this.$refs['upload-inner'].upload(file.raw);
});
}
submit方法把uploadFiles中状态为ready的文件上传至云端,实质调用的也是upload.vue中定义的方法:
upload(rawFile) {
this.$refs.input.value = null;
// beforeUpload不存在直接调用post方法
if (!this.beforeUpload) {
return this.post(rawFile);
}
// 调用beforeUpload
const before = this.beforeUpload(rawFile);
// before存在并且返回promise
if (before && before.then) {
before.then(processedFile => {
const fileType = Object.prototype.toString.call(processedFile);
if (fileType === '[object File]' || fileType === '[object Blob]') {
// Blob转换为
if (fileType === '[object Blob]') {
processedFile = new File([processedFile], rawFile.name, {
type: rawFile.type
});
}
for (const p in rawFile) {
if (rawFile.hasOwnProperty(p)) {
processedFile[p] = rawFile[p];
}
}
this.post(processedFile);
} else {
this.post(rawFile);
}
}, () => {
// 移除文件
this.onRemove(null, rawFile);
});
} else if (before !== false) {
// before为一个真值
this.post(rawFile);
} else {
// 移除文件
this.onRemove(null, rawFile);
}
},
upload方法中首先判断是否有beforeUpload,如果没有则直接调用post方法上传文件;如果有则判断before的返回值是否为真值,如果是则判断是否为promise, 如果否则移除文件。顺便看一下文档对before-upload的描述,加强理解:
2.4 index.vue中render函数
看一下render函数(保留了主要部分):
render(h) {
let uploadList;
if (this.showFileList) {
uploadList = (
<UploadList
// 省略好多属性
>
</UploadList>
);
}
const uploadData = {
props: {
type: this.type,
drag: this.drag,
// 省略好多属性
'on-preview': this.onPreview,
'on-remove': this.handleRemove,
'http-request': this.httpRequest
},
ref: 'upload-inner'
};
const trigger = this.$slots.trigger || this.$slots.default;
const uploadComponent = <upload {...uploadData}>{trigger}</upload>;
return (
<div>
{ this.listType === 'picture-card' ? uploadList : ''}
{
this.$slots.trigger
? [uploadComponent, this.$slots.default]
: uploadComponent
}
{this.$slots.tip}
{ this.listType !== 'picture-card' ? uploadList : ''}
</div>
);
}
首先判断是否传入了showFileList属性,是的话则uploadList变量赋值为UploadList组件;接着定义uploadData获取属性并定义ref;获取trigger和default插槽,赋值给upload;最后的return语句中的判断逻辑是:判断listType是不是picture-card,如果是则渲染uploadList,否则设么也不渲染;判断有没有trigger, 有则渲染upload和插槽,否则渲染upload;渲染tip插槽;判断listType不是picture-card则渲染uploadList(这块我没搞明白,怎么又判断一次,上面不是判断了嘛~)。
2.5 upload.vue分析
2.5.1 props和data
upload.vue中定义的props和index.vue中基本一样,data中定义了reqs用于保存ajax请求:
data() {
return {
mouseover: false,
reqs: {}
};
},
我们看一下reqs被引用的代码:
abort(file) {
const { reqs } = this;
if (file) {
let uid = file;
if (file.uid) uid = file.uid;
if (reqs[uid]) {
reqs[uid].abort();
}
} else {
Object.keys(reqs).forEach((uid) => {
if (reqs[uid]) reqs[uid].abort();
delete reqs[uid];
});
}
},
post(rawFile) {
const { uid } = rawFile;
const options = {
headers: this.headers,
withCredentials: this.withCredentials,
file: rawFile,
data: this.data,
filename: this.name,
action: this.action,
onProgress: e => {
this.onProgress(e, rawFile);
},
onSuccess: res => {
this.onSuccess(res, rawFile);
delete this.reqs[uid];
},
onError: err => {
this.onError(err, rawFile);
delete this.reqs[uid];
}
};
const req = this.httpRequest(options);
this.reqs[uid] = req;
if (req && req.then) {
req.then(options.onSuccess, options.onError);
}
},
在abort方法和post方法中引用了reqs,可以看到当上传成功和上传错误的时候会从reqs中删除请求,所以确定的说reqs通过uid作为键持有没完成的请求的引用。
2.5.2 isImage方法
isImage(str) {
return str.indexOf('image') !== -1;
},
判断是否为图片,通过判断一个字符串中是否含有image子串来判断。
2.5.3 handleChange方法
handleChange(ev) {
const files = ev.target.files;
if (!files) return;
this.uploadFiles(files);
}
文件发生变化时上传文件,之所以要在ev.target.files上取文件是因为handleChange方法用在了原生input上面:
2.5.4 uploadFiles 方法
uploadFiles(files) {
if (this.limit && this.fileList.length + files.length > this.limit) {
this.onExceed && this.onExceed(files, this.fileList);
return;
}
let postFiles = Array.prototype.slice.call(files);
if (!this.multiple) { postFiles = postFiles.slice(0, 1); }
if (postFiles.length === 0) { return; }
postFiles.forEach(rawFile => {
this.onStart(rawFile);
if (this.autoUpload) this.upload(rawFile);
});
},
uploadFiles 用于上传多个文件,(1)首先做上传条件判断。如果limit是真值,并且当前fileList中的文件数与本次要上传的文件数之和大于limit则禁止上传,如果用户定义了onExceed钩子属性则触发。(2)然后是获取所有文件,根据multiple确定是上传一个文件还是多个文件。(3)最后是逐个上传文件,每次都调用onStart,也就是index.vue中定义的handleStart方法;如果autoUpload为真值,则调用upload方法, 而upload又会调用post(上文提到过好几次了)。
另外需要注意uploadFiles的触发场景是upload-dragger.vue子组件emit了file事件:
// upload-dragger.vue
onDrop(e) {
if (this.disabled || !this.uploader) return;
const accept = this.uploader.accept;
this.dragover = false;
if (!accept) {
this.$emit('file', e.dataTransfer.files);
return;
}
// ...省略部分代码
}
在upload.vue的render函数中:
return (
<div {...data} tabindex="0" >
{
drag
? <upload-dragger disabled={disabled} on-file={uploadFiles}>{this.$slots.default}</upload-dragger>
: this.$slots.default
}
<input class="el-upload__input" type="file" ref="input" name={name} on-change={handleChange} multiple={multiple} accept={accept}></input>
</div>
);
注意到on-file={uploadFiles}就是对file事件的监听。至此我们可以梳理一下在upload.vue中函数的调用关系:
2.5.6 handleClick和handleKeydown方法
handleClick() {
if (!this.disabled) {
this.$refs.input.value = null;
this.$refs.input.click();
}
},
handleKeydown(e) {
if (e.target !== e.currentTarget) return;
if (e.keyCode === 13 || e.keyCode === 32) {
this.handleClick();
}
}
handleClick和handleKeydown分别是对鼠标点击事件和键盘按键事件的响应,handleKeydown里面又调用了handleClick。使用方式详见下图:
代码中用到的控制键码值13代表Enter, 32代表Spacebar:
更多关于键盘事件码值可以参考文章 www.cnblogs.com/daysme/p/62…
2.5.7 render方法
上文中已经多次见到render方法中的代码了,就不重复粘贴了,主要就是返回的内容最外层是div包裹,内部是upload-dragger和input
2.6 upload-dragger.vue分析
el-upload组件支持拖拽上传的底层原理就在这个文件里面。
首先看一下模板部分:
<template>
<div
class="el-upload-dragger"
:class="{
'is-dragover': dragover
}"
@drop.prevent="onDrop"
@dragover.prevent="onDragover"
@dragleave.prevent="dragover = false"
>
<slot></slot>
</div>
</template>
这里用到了拖拽相关的API, 这个知识在《JavaScript高级程序设计(第四版)》第20章第6小节有介绍。某个元素被拖动的时候会按顺序触发以下事件:
(1)dragStart
(2)drag
(3)dragend
当拖动到一个有效的放置目标时会触发以下事件:
(1)dragenter
(2)dragover
(3)dragleave或者drop
upload-dragger用到了drop,dragover和dragleave,并定义了对这些事件响应的方法:
onDragover() {
if (!this.disabled) {
this.dragover = true;
}
},
onDragover是用来改变样式的,文件拖拽过来的时候要改变以下样式。
upload-dragge没有为dragleave定义专门的方法,直接把dragover设为false。
接下来看onDrop:
onDrop(e) {
if (this.disabled || !this.uploader) return;
const accept = this.uploader.accept;
this.dragover = false;
if (!accept) {
this.$emit('file', e.dataTransfer.files);
return;
}
this.$emit('file', [].slice.call(e.dataTransfer.files).filter(file => {
// 获取文件名字和类型
const { type, name } = file;
// 获取文件拓展名
const extension = name.indexOf('.') > -1
? `.${ name.split('.').pop() }`
: '';
const baseType = type.replace(//.*$/, '');
return accept.split(',')
.map(type => type.trim())
.filter(type => type)
.some(acceptedType => {
// 扩展名
if (/..+$/.test(acceptedType)) {
return extension === acceptedType;
}
// 类型
if (//*$/.test(acceptedType)) {
return baseType === acceptedType.replace(//*$/, '');
}
// 类型
if (/^[^/]+/[^/]+$/.test(acceptedType)) {
return type === acceptedType;
}
return false;
});
}));
}
这段代码主要逻辑包括:
(1)如果上传组件禁用了或者uploader为假则直接返回。
(2)否则取可接受的格式,如果用户没有定义格式则直接上传;如果用户定义了可接受的格式则对文件逐一检查,因为用户可能传多个格式所以需要把逗号分隔的accept转成数组,去掉前后空格,保留为真的值(因为用户可能传'.doc,,.ppt',这里两逗号之间的空格就是假值)。
(3)处理完可接受格式之后就是拿文件的类型或者拓展名和格式进行比较,上传符合accept的文件。
2.7 upload-list.vue 分析
upload-list.vue主要逻辑在模板部分,我们至顶向下、由外而内地看一下:
<li
v-for="file in files"
:class="['el-upload-list__item', 'is-' + file.status, focusing ? 'focusing' : '']"
:key="file.uid"
tabindex="0"
@keydown.delete="!disabled && $emit('remove', file)"
@focus="focusing = true"
@blur="focusing = false"
@click="focusing = false"
>
// 内容
</li>
外层是for循环生成li,这段代码主要是遍历多个文件,生成文件的列表。注意绑定的键盘事件,点击delete键触发remove方法,这和index.vue中的代码对应,如下图所示:
li里面是作用域插槽:
<slot :file="file">
<img
class="el-upload-list__item-thumbnail"
v-if="file.status !== 'uploading' && ['picture-card', 'picture'].indexOf(listType) > -1"
:src="file.url" alt=""
>
<a class="el-upload-list__item-name" @click="handleClick(file)">
<i class="el-icon-document"></i>{{file.name}}
</a>
<label class="el-upload-list__item-status-label">
<i :class="{
'el-icon-upload-success': true,
'el-icon-circle-check': listType === 'text',
'el-icon-check': ['picture-card', 'picture'].indexOf(listType) > -1
}"></i>
</label>
<i class="el-icon-close" v-if="!disabled" @click="$emit('remove', file)"></i>
<i class="el-icon-close-tip" v-if="!disabled">{{ t('el.upload.deleteTip') }}</i> <!--因为close按钮只在li:focus的时候 display, li blur后就不存在了,所以键盘导航时永远无法 focus到 close按钮上-->
<el-progress
v-if="file.status === 'uploading'"
:type="listType === 'picture-card' ? 'circle' : 'line'"
:stroke-width="listType === 'picture-card' ? 6 : 2"
:percentage="parsePercentage(file.percentage)">
</el-progress>
<span class="el-upload-list__item-actions" v-if="listType === 'picture-card'">
<span
class="el-upload-list__item-preview"
v-if="handlePreview && listType === 'picture-card'"
@click="handlePreview(file)"
>
<i class="el-icon-zoom-in"></i>
</span>
<span
v-if="!disabled"
class="el-upload-list__item-delete"
@click="$emit('remove', file)"
>
<i class="el-icon-delete"></i>
</span>
</span>
</slot>
当文件上传状态不是uploading并且文件列表的类型(list-type)是picture或者picture-card时,则渲染图片;当文件上传状态是正在上传(uploading)时,渲染el-progress;如果文件列表类型是picture-card时,显示预览和移除按钮。
2.7 总结
整个el-upload源码过了一遍,还是挺有收获的,知道了el-upload一些外在表现对应的内部原理,以后无论是使用组件还是进行二次封装都能够做到心中有数了。首先梳理了el-upload组件整个模块之间的文件引用关系;然后着重分析了inde.vue和upload.vue的相关方法;最后分析了upload-dragger和upload-list.vue文件的主要逻辑。喜欢我文章的可以点赞转发,有问题更正的可以留言评论,也可以关注公众号“重温新知”获取更多前端原创文章~