跳到主要内容

前端文件处理

File 对象,FileList 对象,FileReader 对象​

File 对象​

File 对象代表一个文件,用来读写文件信息。它继承了 Blob 对象,或者说是一种特殊的 Blob 对象,所有可以使用 Blob 对象的场合都可以使用它。

最常见的使用场合是表单的文件上传控件(<input type="file">),用户选中文件以后,浏览器就会生成一个数组,里面是每一个用户选中的文件,它们都是 File 实例对象。

// HTML 代码如下
// <input id="input" type="file">
var file = document.getElementById("input").files[0];
file instanceof File; // true

上面代码中,file是用户选中的第一个文件,它是 File 的实例。

构造函数​

浏览器原生提供一个File()构造函数,用来生成 File 实例对象。

new File(array, name [, options])

File()构造函数接受三个参数。

  • array:一个数组,成员可以是二进制对象或字符串,表示文件的内容。
  • name:字符串,表示文件名或文件路径。
  • options:配置对象,设置实例的属性。该参数可选。

第三个参数配置对象,可以设置两个属性。

  • type:字符串,表示实例对象的 MIME 类型,默认值为空字符串。
  • lastModified:时间戳,表示上次修改的时间,默认为Date.now()。

实例属性和实例方法​

File 对象有以下实例属性。

  • File.lastModified:最后修改时间
  • File.name:文件名或文件路径
  • File.size:文件大小(单位字节)
  • File.type:文件的 MIME 类型

FileList 对象​

FileList对象是一个类似数组的对象,代表一组选中的文件,每个成员都是一个 File 实例。它主要出现在两个场合。

  • 文件控件节点(<input type="file">)的files属性,返回一个 FileList 实例。
  • 拖拉一组文件时,目标区的DataTransfer.files属性,返回一个 FileList 实例。
// HTML 代码如下
// <input id="input" type="file">
var files = document.getElementById("input").files;
files instanceof FileList; // true

上面代码中,文件控件的files属性是一个 FileList 实例。

FileList 的实例属性主要是length,表示包含多少个文件。

FileList 的实例方法主要是item(),用来返回指定位置的实例。它接受一个整数作为参数,表示位置的序号(从零开始)。但是,由于 FileList 的实例是一个类似数组的对象,可以直接用方括号运算符,即myFileList[0]等同于myFileList.item(0),所以一般用不到item()方法。


FileReader 对象​

FileReader 对象用于读取 File 对象或 Blob 对象所包含的文件内容。

浏览器原生提供一个FileReader构造函数,用来生成 FileReader 实例。

var reader = new FileReader();

FileReader 有以下的实例属性。

  • FileReader.error:读取文件时产生的错误对象
  • FileReader.readyState:整数,表示读取文件时的当前状态。一共有三种可能的状态,0表示尚未加载任何数据,1表示数据正在加载,2表示加载完成。
  • FileReader.result:读取完成后的文件内容,有可能是字符串,也可能是一个 ArrayBuffer 实例。
  • FileReader.onabort:abort事件(用户终止读取操作)的监听函数。
  • FileReader.onerror:error事件(读取错误)的监听函数。
  • FileReader.onload:load事件(读取操作完成)的监听函数,通常在这个函数里面使用result属性,拿到文件内容。
  • FileReader.onloadstart:loadstart事件(读取操作开始)的监听函数。
  • FileReader.onloadend:loadend事件(读取操作结束)的监听函数。
  • FileReader.onprogress:progress事件(读取操作进行中)的监听函数。

FileReader 有以下实例方法:

  • FileReader.abort():终止读取操作,readyState属性将变成2。
  • FileReader.readAsArrayBuffer():以 ArrayBuffer 的格式读取文件,读取完成后result属性将返回一个 ArrayBuffer 实例。
  • FileReader.readAsBinaryString():读取完成后,result属性将返回原始的二进制字符串。
  • FileReader.readAsDataURL():读取完成后,result属性将返回一个 Data URL 格式(Base64 编码)的字符串,代表文件内容。对于图片文件,这个字符串可以用于<img>元素的src属性。注意,这个字符串不能直接进行 Base64 解码,必须把前缀data:*/*;base64,从字符串里删除以后,再进行解码。
  • FileReader.readAsText():读取完成后,result属性将返回文件内容的文本字符串。该方法的第一个参数是代表文件的 Blob 实例,第二个参数是可选的,表示文本编码,默认为 UTF-8。

前端二进制 ArrayBuffer、TypedArray、DataView、Blob、File、Base64、FileReader 一次性搞清楚​

  • Blob、ArrayBuffer、File 可以归为一类,它们都是数据;
  • FileReader 算是一种工具,用来读取数据;
  • FormData 可以看做是一个应用数据的场景

ArrayBuffer​

ArrayBuffer对象代表储存二进制数据的一段内存,它不能直接读写,只能通过视图进行操作。

// 创建一个长度为 16 的 buffer 它会分配一个 16 字节(byte)的连续内存空间,并用 0 进行预填充。
const buffer1 = new ArrayBuffer(16);

TypedArray​

TypedArray 是一组构造函数,一共包含九种类型,每一种都是一个构造函数。

TypedArray 的构造函数接受三个参数,第一个 ArrayBuffer(其实还可以是数组、视图这里不细说)对象,第二个视图开始的字节号(默认 0),第三个视图结束的字节号(默认直到本段内存区域结束)。

名称占用字节描述
Int8Array18 位有符号整数
Uint8Array18 位无符号整数
Uint8ClampedArray18 位无符号整型固定数组(数值在 0~255 之间)
Int16Array216 位有符号整数
Uint16Array216 位无符号整数
Int32Array432 位有符号整数
Uint32Array432 位无符号整数
Float32Array432 位 IEEE 浮点数
Float64Array864 位 IEEE 浮点数
// Uint8Array —— 将 ArrayBuffer 中的每个字节视为 0 到 255 之间的单个数字(每个字节是 8 位)。这称为 “8 位无符号整数”。
// Uint16Array —— 将每 2 个字节视为一个 0 到 65535 之间的整数。这称为 “16 位无符号整数”。
// Uint32Array —— 将每 4 个字节视为一个 0 到 4294967295 之间的整数。这称为 “32 位无符号整数”。
// Float64Array —— 将每 8 个字节视为一个 5.0x10-324 到 1.8x10308 之间的浮点数。

const uint8 = new Uint8Array(buffer1);
const uint16 = new Uint16Array(buffer1);
const uint32 = new Uint32Array(buffer1);
const float64 = new Float64Array(buffer1);

DataView​

DataView 就是一种更灵活的视图,DataView 视图支持除 Uint8ClampedArray 以外的八种类型。DataView 比使用 TypedArray 更方便,只需要简单的创建一次就能进行各种转换。

// 可以转成各种格式
const dataView1 = new DataView(buffer1);
console.log(dataView1);
console.log(dataView1.getUint8(0));
console.log(dataView1.getUint16(0));
console.log(dataView1.getUint32(0));
console.log(dataView1.getFloat64(0));

Blob​

Blob 对象表示一个不可变、原始数据的类文件对象。

// 构造函数
const blob = new Blob(array, options);
  • array 是一个由ArrayBuffer, ArrayBufferView, Blob, DOMString 等对象构成的数组,DOMStrings会被编码为 UTF-8。
  • options 是一个可选,它可能会指定如下两个属性:
    • type,默认值为 "",内容的 MIME 类型。
    • endings,默认值为"transparent",用于指定包含行结束符\n的字符串如何被写入。 它是以下两个值中的一个: "native",代表行结束符会被更改为适合宿主操作系统文件系统的换行符,或者 "transparent",代表会保持 blob 中保存的结束符不变
const blob1 = new Blob(["hello randy"], { type: "text/plain" });

属性​

const blob = new Blob(["hello", "randy"], { type: "text/plain" });
// 输出的对象有如下属性
// size: 10;
// type: "text/plain";
console.log(blob);

方法​

  • slice() 从 Blob 中截取一部分并返回一个新的 Blob(用法同数组的 slice)
  • arrayBuffer() 返回一个以二进制形式展现的 promise
  • stream() 返回一个ReadableStream对象
  • text() 返回一个文本形式的 promise
// 转成stream
console.log(blob.stream());

// 转成Arraybuffer
blob.arrayBuffer().then((res) => {
console.log(res);
});

// 转成文本
blob.text().then((res) => {
console.log(res);
});

blob url​

简单的理解一下就是将一个file或Blob类型的对象转为UTF-16的字符串,并保存在当前操作的document下,存储在内存中。

类似这样一个链接

blob:http://localhost:3000/53acc2b6-f47b-450f-a390-bf0665e04e59

生成 blob url 使用的方法是URL.createObjectURL(file/blob)。清除方式只有页面unload()事件或者使用URL.revokeObjectURL(objectURL)手动清除 。

这在前端下载中经常会用到。

export const downloadFile = async (params, fileName) => {
// 我们使用axios设置接口返回类型 responseType: "blob", 所以这里从后端返回的是blob。
const results = await download(params);

const a = document.createElement("a");
a.download = fileName + ".xlsx";
// 生成blob url。这里可以使用Blob对象或者File对象
a.href = window.URL.createObjectURL(results);
a.style.display = "none";
document.body.appendChild(a);
a.click();
// 释放内存
window.URL.revokeObjectURL(a.href);
document.body.removeChild(a);
};

File​

File 描述文件信息的一个对象,可以让 JavaScript 访问文件信息。File 继承于 Blob。

const file = new File(array, name[, options])
  • array 是一个由ArrayBuffer, ArrayBufferView, Blob, DOMString 等对象构成,DOMStrings会被编码为 UTF-8。
  • name 表示文件名称,或者文件路径。
  • options 是一个可选,它可能会指定如下两个属性:
    • type,默认值为 "",内容的 MIME 类型。
    • lastModified: 数值,表示文件最后修改时间的 Unix 时间戳(毫秒)。默认值为 Date.now()。

属性​

  • type 类型 常见的 MIME 类型
  • size 大小、单位为字节
  • name 文件名称
  • lastModified 最后修改时间(时间戳)
  • lastModifiedDate 最后修改时间
const file1 = new File(["文件对象"], "test", { type: "text/plain" });
// 输出的对象有如下属性
// lastModified: 1640589621358
// lastModifiedDate: Mon Dec 27 2021 15:20:21 GMT+0800 (中国标准时间) {}
// name: "test"
// size: 12
// type: "text/plain"
// webkitRelativePath: ""
console.log(file1);

方法​

  • slice() 从 Blob 中截取一部分并返回一个新的 Blob(用法同数组的 slice)
  • arrayBuffer() 返回一个以二进制形式展现的 promise
  • stream() 返回一个ReadableStream对象
  • text() 返回一个文本形式的 promise
// 转成stream
console.log(file1.stream());

// 转成Arraybuffer
file1.arrayBuffer().then((res) => {
console.log(res);
});

// 转成文本
file1.text().then((res) => {
console.log(res);
});

Base64​

定义​

Base64 是一种编码格式,在前端经常会碰到,格式是 data:[<mediatype>][;base64],<data> 。

除了使用工具进行 Base64 编码外,js 还内置了两个方法能进行字符串的 Base64 的编码和解码。

const str1 = "hello randy";

// 编码
const b1 = window.btoa(str1);
console.log(b1); // aGVsbG8gcmFuZHk=

// 解码
const str2 = window.atob(b1);
console.log(str2); // hello randy
复制代码;

优点​

  1. 可以将二进制数据(比如图片)转化为可打印字符,方便传输数据。
  2. 对数据进行简单的加密,肉眼是安全的。
  3. 如果是在 html 或者 css 处理图片,可以减少 http 请求。

缺点​

  1. 内容编码后体积变大, 至少大 1/3。因为是三字节变成四个字节,当只有一个字节的时候,也至少会变成三个字节。
  2. 编码和解码需要额外工作量。

FileReader​

FileReader 对象允许 Web 应用程序异步读取存储在用户计算机上的文件(或原始数据缓冲区)的内容.

属性​

属性描述
FileReader.error一个 DOMException,表示在读取文件时发生的错误 。
FileReader.result返回文件的内容。只有在读取操作完成后,此属性才有效,返回的数据的格式取决于是使用哪种读取方法来执行读取操作的。
FileReader.readyState表示 FileReader 状态的数字。0 还没有加载任何数据。1 数据正在被加载。2 已完成全部的读取请求。

方法​

需要注意的是 ,无论读取成功或失败,方法并不会返回读取结果,这一结果存储在 result 属性中。

方法名描述
FileReader.abort()中止读取操作。在返回时,readyState 属性为 DONE。
FileReader.readAsArrayBuffer()将读取的内容转成 ArrayBuffer。
FileReader.readAsBinaryString()将读取的内容转成二进制数据。
FileReader.readAsDataURL()将读取的内容转成并将其编码为 base64 的 data url。 格式是 data:[<mediatype>][;base64],<data>
FileReader.readAsText()将数据读取为给定编码(默认为 utf-8 编码)的文本字符串。

事件​

事件描述
FileReader.onabort处理 abort 事件。该事件在读取操作被中断时触发。
FileReader.onerror处理 error 事件。该事件在读取操作发生错误时触发。
FileReader.onload处理 load 事件。该事件在读取操作完成时触发。
FileReader.onloadstart处理 loadstart 事件。该事件在读取操作开始时触发。
FileReader.onloadend处理 loadend 事件。该事件在读取操作结束时(要么成功,要么失败)触发。
FileReader.onprogress处理 progress 事件。该事件在读取 Blob 时触发。

例子​

const blob3 = new Blob(["hello", "randy"], { type: "text/plain" });

const fileReader = new FileReader();

fileReader.readAsDataURL(blob3);

fileReader.onload = () => {
console.log(fileReader);
// 通过fileReader获取结果
// fileReader.result 是结果(如果成功)
// fileReader.error 是 error(如果失败)。
};

相互转换​

Blob 和 File 的互相转换​

Blob 转 File​
const blob1 = new Blob(["blob文件"], { type: "text/plain" });
// blob转file
const file2 = new File([blob1], "test2", { type: blob1.type });
console.log("file2: ", file2);
File 转 Blob​
const file1 = new File(["文件对象"], "test", { type: "text/plain" });
// file转blob
const blob2 = new Blob([file1], { type: file1.type });
console.log("blob2: ", blob2);

File、Blob、img 转 Base64​

Blob 转 Base64​
// Blob转Base64
const blob = new Blob(["hello", "randy"], { type: "text/plain" });

const fileReader = new FileReader();

fileReader.readAsDataURL(blob);

fileReader.onload = () => {
console.log(fileReader.result); // "data:text/plain;base64,aGVsbG9yYW5keQ=="
};
File 转 Base64​
// File转Base64
const file1 = new File(["文件对象"], "test", { type: "text/plain" });

const fileReader = new FileReader();

fileReader.readAsDataURL(file1);

fileReader.onload = () => {
console.log(fileReader); // "data:text/plain;base64,5paH5Lu25a+56LGh"
};
img 转 Base64​
// 本地图片转base64,注意链接是本地链接不能是网络地址。
const img2base64 = (imgUrl) => {
let image = new Image();
image.src = imgUrl;
return new Promise((resolve) => {
image.onload = () => {
let canvas = document.createElement("canvas");
canvas.width = image.width;
canvas.height = image.height;
var context = canvas.getContext("2d");
context.drawImage(image, 0, 0, image.width, image.height);
let dataUrl = canvas.toDataURL("image/png");
resolve(dataUrl);
};
});
};

img2base64("../vue2/src/assets/logo.png").then((res) => {
console.log(res);
});

Base64 转 Blob、File​

Base64 转 Blob​
function dataURLtoBlob(dataurl) {
// `data:[<mediatype>][;base64],<data>`
var arr = dataurl.split(","),
mime = arr[0].match(/:(.*?);/)[1],
bstr = atob(arr[1]),
n = bstr.length,
u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new Blob([u8arr], { type: mime });
}
Base64 转 File​
function dataURLtoFile(dataurl, filename) {
//将base64转换为文件
var arr = dataurl.split(","),
mime = arr[0].match(/:(.*?);/)[1],
bstr = atob(arr[1]),
n = bstr.length,
u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new File([u8arr], filename, { type: mime });
}

FormData​

使用 FormData 我们可以异步上传一个二进制文件,而这个二进制文件就是 Blob 对象

在 post 提交数据时我们常采用 application/json、application/x-www-form-urlencoded 等类型,也确实能够覆盖到大部分的场景,但是有一些场景下,比如文件上传的时候,就不算是好的解决方案了

application/json 作为请求头 Content-Type 字段值时,表示告知服务端参数是序列化后的 JSON 字符串,所以一般在传参时都会用 JSON.stringify 序列化一下

浏览器对 JSON.stringify API 支持程度比较高,但是 JSON.stringify 在转换某一些数据结构时会出问题,比如 会丢失 function 类型的参数、循环引用时会报错、Blob /File 对象会被转化成 {} 等等

FormData 这种方式相信很多同学都比较熟悉,它提供了一种表示表单数据的键值对 key/value 的构造方式,由名称和定义就知道 FormData 是专门为表单量身定做的类型,但其实其功能要比 application/json 强得多,比如文件上传的问题,用 FormData 传参能很好的解决,window 上也直接挂载了 FormData 对象,很方便我们直接使用。

我们在控制台实例化一个 FormData 对象

img

使用​

可以看到其原型上有很多的方法,个人感觉这个 FormData 跟 Map 有点像,仔细观察可以知道都有 set、get、values、has 等方法,我们平常开发主要的使用也就是 append 方法了,一般都会封装一层 request,调用层只需要传入参数的对象集合就可以。

const specialFileType = ["Blob", "File"];

function formatData(_data) {
const data = new window.FormData();
for (const key in _data) {
let value = _data[key];
if (
_data[key] instanceof Object &&
!specialFileType.includes(_data[key].constructor.name)
) {
value = JSON.stringify(_data[key]);
}
data.append(key, value);
}
return data;
}

append or set​

这就有同学要问了,为啥不用 set 方法, MDN 上面写的很清楚,append 的 key 存在,就会附加到已有值集合的后面,而 set 会使用新值覆盖已有的值,所以选择使用哪一种取决于你的需求。

img

FormData 在文件上传这一块比较有优势,那么它是怎么处理的呢?FormData 对象能够设置三种类型的值,string、Blob、File,所以我们不需要转换格式,可以直接传文件,当我们传递 File 到 formatData 层,会直接被 append 到 FormData 对象里,且可以通过 get 获取到值,然后发送请求到服务端,我们能从浏览器入参中清晰的看到 d 、e 参数的类型是 binary,因为就是二进制的文件类型,这样服务端接到值之后很方便获取。

cosnt View = () => {
const [fileA, setFileA] = useState(null);
const [fileB, setFileB] = useState(null);
const handleClick = () => {
console.log('fileA:', fileA)
console.log('fileB:', fileB)
const p = {
a: { a1: 11, a2: 22 },
b: [1,2,3],
c: 123,
d: fileA[0],
e: fileB[0],
}
const data = formatData(p);
axios({
method: 'POST',
url: '/aa',
data,
// headers: {
// 'content-type': 'multipart/formdata'
// },
})
}

return <div>
<div onClick={handleClick}>发送请求</div>
<input
type='file'
onChange={(a) => {
const v = a.target.files;
setFileA(v);
}}
/>
<input
type='file'
onChange={(a) => {
const v = a.target.files;
setFileB(v);
}}
/>
</div>
}
img

一个参数之间都有一个 ------WebKitFormBoundary *** 区分开,这实际上是 FormData 的规范标志,后面的字符串是浏览器帮我们自动创建的,以 ------WebKitFormBoundary *** 作为分隔符,也作为开始和结尾,其内容主要有 Content-Disposition、Content-Type 等,其中 Content-Disposition 是必选项, name 属性代表着表单元素的 key,filename 则是上传文件的名称,也可以使用 FormData 第三个参数更改 ,另外,我在发送请求时,并没有更改请求头里面的 Content-Type,但实际上我们看到的是正确的 multipart/form-data,这是因为现在的浏览器比较智能,当客户端未设置请求头的 Content-Type 时,请求参数为对象时,某一些浏览器会自动帮我们在 请求头中添加 Content-Type: text/plain,如果传输的数据是 FormData,也会自动帮我们加上 Content-Type: multipart/form-data 等,可能不同浏览器表现行为不一样,但是最好的方式就是客户端与服务端约定好 Content-Type 类型,固定传递

form-data 与 x-www-form-urlencoded​

1.x-www-form-urlencoded​

application/x-www-form-urlencoded 是最常见的 POST 提交数据的方式。浏览器的原生

表单,如果不设置 enctype 属性,那么最终就会以 application/x-www-form-urlencoded 方式提交数据。 例如:

<form action="form_action.asp" enctype="text/plain">
<p>First name: <input type="text" name="fname" /></p>
<p>Last name: <input type="text" name="lname" /></p>
<input type="submit" value="Submit" />
</form>

查看请求头信息可以知道:

Content-Type: application/x-www-form-urlencoded;charset=utf-8
title=test&sub%5B%5D=1&sub%5B%5D=2&sub%5B%5D=3

首先,Content-Type 被指定为 application/x-www-form-urlencoded;

其次,提交的数据按照 key1=val1&key2=val2 的方式进行编码,key 和 val 都进行了 URL 转码。大部分服务端语言都对这种方式很好的支持,常用的如 jQuery 中的 ajax 请求,Content-Type 默认值都是「application/x-www-form-urlencoded;charset=utf-8

2.multipart/form-data​

这也是常见的 post 请求方式,一般用来上传文件,各大服务器的支持也比较好。所以我们使用表单 上传文件 时,必须让表单的 enctype 属性值为 multipart/form-data.

3.两者的联系与区别​

以上两种方式:application/x-www-form-urlencoded 和 multipart/form-data 都是浏览器原生支持的。

两者的区别

x-www-form-urlencoded

1)只能上传键值对,而且键值对都是通过&间隔分开的。

当使用 js 中 URLencode 转码方法使用,包括将 name、value 中的空格替换为加号;将非 ascii 字符做百分号编码;将 input 的 name、value 用‘=’连接,不同的 input 之间用‘&’连接。 百分号编码什么意思呢。比如汉字‘丁’吧,他的 utf8 编码在十六进制下是 0xE4B881,占 3 个字节,把它转成字符串‘E4B881’,变成了六个字节,每两个字节前加上百分号前缀,得到字符串“%E4%B8%81”,变成九个 ascii 字符,占九个字节(十六进制下是 0x244534254238253831)。

把这九个字节拼接到数据包里,这样就可以传输“非 ascii 字符的 utf8 编码的 十六进制表示的 字符串的 百分号形式”,^_^。

multipart/form-data

1)可以上传文件或者键值对,最后都会转化为一条消息.

它会将表单的数据处理为一条消息,以标签为单元,用分隔符分开。既可以上传键值对,也可以上传文件。当上传的字段是文件时,会有 Content-Type 来表名文件类型;content-disposition,用来说明字段的一些信息;

另一种常用的方式:application/json

application/json 作为响应头并不陌生,实际上,现在很多时候也把它作为请求头,用来告诉服务端消息主体是序列化的 JSON 字符串。

4.请求方法问题​

先使用 get 方法和 post 方法,但不写 enctype,即以默认的application/x-www-form-urlencoded表格数据格式进行表单请求

发现 post 方法和 get 方法都只是把文件名编码进了 url 中,文件内容无法得到,这也证实了上述文档中的内容,使用application/x-www-form-urlencoded无法实现文件上传

若使用enctype='multipart/form-data',并分别使用 post 和 get 方法提交表单

在上传文件中使用 get 方法是无效的,依然只能得到文件名。而 post 结合multipart/form-data才能真正将文件内容传入请求体。

提交文件的格式使用一长串字符作为 boundtry 封装线对字段进行分割。这也很符合 multipart 多个部分的语义,包含了多个部分集,每一部分都包含了一个content-desposition头,其值为form-data,以及一个name属性,其值为表单的字段名,文件输入框还可以使用filename参数指定文件名。content-type非必须属性,其值会根据文件类型进行变化,默认值是text/plain。multipart 的每一个 part 上方是边缘,最后一个 part 的下方添加一个边缘。

前端利用 Blob 对象创建指定文件并下载​

1.Blob 对象​

Blob 对象表示一个不可变、原始数据的类文件对象。Blob 表示的不一定是 JavaScript 原生格式的数据。File 接口基于 Blob,继承了 blob 的功能并将其扩展使其支持用户系统上的文件。

构造函数​

var aBlob = new Blob(array, options);
  • array 是一个由 ArrayBuffer(二进制数据缓冲区)、ArrayBufferView(二进制数据缓冲区的 array-like 视图)、Blob、DOMString 等对象构成的 Array,或者其他类似对象的混合体,它将会被放进 Blob。DOMStrings 会被编码为 UTF-8。
  • options 是可选的,它可能会指定如下两个属性:
    • type,默认值为 "",它代表了将会被放入到 blob 中的数组内容的 MIME 类型。
    • endings,默认值为"transparent",用于指定包含行结束符\n 的字符串如何被写入。 它是以下两个值中的一个: "native",代表行结束符会被更改为适合宿主操作系统文件系统的换行符,或者 "transparent",代表会保持 blob 中保存的结束符不变。

示例​

var debug = { hello: "world" };
var blob = new Blob([JSON.stringify(debug, null, 2)], {
type: "application/json",
});

2.URL 对象​

通过创建 URL 对象指定文件的下载链接。

构造函数​

创建新的 URL 表示指定的 File 对象或者 Blob 对象。

objectURL = window.URL.createObjectURL(blob);

window.URL.revokeObjectURL()​

在每次调用createObjectURL方法时,都会创建一个新的 URL 对象,即使你已经用相同的对象作为参数创建过。当不再需要这些 URL 对象时,每个对象必须通过调用 URL.revokeObjectURL方法来释放。浏览器会在文档退出的时候自动释放它们,但是为了获得最佳性能和内存使用状况,你应该在安全的时机主动释放掉它们。

window.URL.revokeObjectURL(objectURL);

3.利用<a>标签下载​

生成一个<a>标签。

const link = document.createElement("a");

href 属性指定下载链接​

link.href = window.URL.createObjectURL(blob);

dowload 属性指定文件名​

download 属性规定被下载的超链接目标。在<a>标签中必须设置 href 属性。该属性也可以设置一个值来规定下载文件的名称。所允许的值没有限制,浏览器将自动检测正确的文件扩展名并添加到文件 (.img, .pdf, .txt, .html, 等等)。

link.download = fileName;

click()事件触发下载​

link.click();

4.创建并下载​

选择相应的 MIME 类型并设置编码。

const foo = {hello: "world"};
const blob = new Blob([JSON.stringify(foo)], type: 'application/vnd.ms-excel;charset=utf-8');
const fileName = `${new Date().valueOf()}.xls`;
const link = document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = fileName;
link.click();
window.URL.revokeObjectURL(link.href);

注 :下载指定扩展名的文件只需要对照MIME 参考手册设置 type 即可。

5.兼容 IE​

在 IE 中要使用window.navigator.msSaveOrOpenBlob来处理 Blob 对象。

window.navigator.msSaveOrOpenBlob(blob, fileName);

6.Promise 写法​

基于 axios 的写法。

axios
.get(`url`, {
responseType: "arraybuffer",
})
.then((res) => {
if (res.status == 200) {
let blob = new Blob([res.data], {
type: "application/vnd.ms-excel;charset=utf-8",
});
let fileName = `yourfile.xls`;
// for IE
if (window.navigator && window.navigator.msSaveOrOpenBlob) {
window.navigator.msSaveOrOpenBlob(blob, fileName);
} else {
// for Non-IE
let objectUrl = URL.createObjectURL(blob);
let link = document.createElement("a");
link.href = objectUrl;
link.setAttribute("download", fileName);
document.body.appendChild(link);
link.click();
window.URL.revokeObjectURL(link.href);
}
} else {
// error handler
}
});

7.实现导出 table 生成 excel​

const saveFile = (content, fileName) => {
const blob = new Blob([content]);
if (window.navigator.msSaveOrOpenBlob) {
navigator.msSaveBlob(blob, fileName);
} else {
const elink = document.createElement("a");
elink.download = fileName;
elink.style.display = "none";
elink.href = URL.createObjectURL(blob);
document.body.appendChild(elink);
elink.click();
document.body.removeChild(elink);
}
};
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>

<body>
<table id="table">
<tr>
<th>asf</th>
<th>asf</th>
<th>asf</th>
</tr>
<tr>
<td>asf</td>
<td>asf</td>
<td>asf</td>
</tr>
</table>
<script>
function base64(content) {
return window.btoa(unescape(encodeURIComponent(content)));
}
function tableToExcel(tableID, fileName) {
var excelContent = document.querySelector(tableID).innerHTML;
var excelFile = "<html xmlns:o='urn:schemas-microsoft-com:office:office' xmlns:x='urn:schemas-microsoft-com:office:excel' xmlns='http://www.w3.org/TR/REC-html40'>";
excelFile += "<head><!--[if gte mso 9]><xml><x:ExcelWorkbook><x:ExcelWorksheets><x:ExcelWorksheet><x:Name>{worksheet}</x:Name><x:WorksheetOptions><x:DisplayGridlines/></x:WorksheetOptions></x:ExcelWorksheet></x:ExcelWorksheets></x:ExcelWorkbook></xml><![endif]--></head>";
excelFile += "<body><table width='10%' border='1'>";
excelFile += excelContent;
excelFile += "</table></body>";
excelFile += "</html>";
var link = "data:application/vnd.ms-excel;base64," + base64(excelFile);
var a = document.createElement("a");
a.download = fileName + ".xlsx";
a.href = link;
a.click();
}
tableToExcel('#table', 'table')
</script>
</body>

</html>

如何使用 WebWorker 对用户体验进行革命性的提升​

纯前端生成 Excel 文件骚操作——WebAssembly & web workers​

纯前端生成 Excel 文件骚操作——WebAssembly & web workers

前端文件压缩​

前端批量获取文件并打包压缩解决方案

前端性能优化 gzip 初探(补充 gzip 压缩使用算法 brotli 压缩的相关介绍)

前端图片压缩​

前端图片压缩上传(压缩篇):可能是最适合小白的前端图片压缩文章了!

在前端如何玩转 Word 文档

towebp-loader

webp 图片的优劣势及生成

towebp-loader 可以在 webpack 中根据图片类型转换成一份webp和原图两份图片,并且集成了url-loader的功能 支持 url 的limit功能和file-loader文件名的功能。

// 获取loader中的查询字符串 使用webpack loader api
var query = loaderUtils.parseQuery(this.query);
// 保存源文件的路径如果穿 name就使用不然使用默认hash.ext模式
var url = loaderUtils.interpolateName(this, query.name || "[hash].[ext]", {
content: content,
regExp: query.regExp
});
// webp 文件的保存路径
var webpUrl = url.substring(0, url.lastIndexOf('.')) + '.webp';
// limit参数来自url-loader 如果小于这个值使用base64字符串替换图片
if (query.limit) {
limit = parseInt(query.limit, 10);
}
var mimetype = query.mimetype || query.minetype || mime.lookup(this.resourcePath);
if (limit <= 0 || content.length < limit) {
return "module.exports = " + JSON.stringify("data:" + (mimetype ? mimetype + ";" : "") + "base64," + content.toString("base64"));
}
……
// 转换原图成webp
imagemin.buffer(content, { plugins: [imageminWebp(options)] }).then(file => {
// 保存原图
this.emitFile(url, content);
// 保存压缩后的webp图片
this.emitFile(webpUrl, file);
callback(null, "module.exports = __webpack_public_path__ + " + JSON.stringify(url) + ";");
}).catch(err => {
callback(err);
});

execel 处理​

开篇​

在处理完成了 个人中心之后, 那么接下来我们就需要来处理 用户 相关的模块了

整个用户相关的模块分为三部分:

  1. 员工管理
  2. 角色列表
  3. 权限列表

这三部分的内容我们会分成两个大章来进行处理。

那么这一大章我们要来处理的就是 员工管理 模块的内容,整个 员工管理 模块可以分为以下功能:

  1. 用户列表分页展示
  2. excel 导入用户
  3. 用户列表导出为 excel
  4. 用户详情的表格展示
  5. 用户详情表格打印
  6. 用户删除
  7. 用户角色分配(需要在完成角色列表之后处理)

那么明确好了这样的内容之后,接下来我们就进入到 员工管理 模块的开发之中

用户列表分页展示​

首先我们先来处理最基础的 用户列表分页展示 功能,整个功能大体可以分为两步:

  1. 获取分页数据
  2. 利用 el-table 和 el-pagination 渲染数据

那么下面我们就根据这个步骤进行一个实现即可:

  1. 创建 api/user-manage 文件,用于定义接口

    import request from "@/utils/request";

    /**
    * 获取用户列表数据
    */
    export const getUserManageList = (data) => {
    return request({
    url: "/user-manage/list",
    params: data,
    });
    };
  2. 在 user-manage 中获取对应数据

    <script setup>
    import { ref } from "vue";
    import { getUserManageList } from "@/api/user-manage";
    import { watchSwitchLang } from "@/utils/i18n";

    // 数据相关
    const tableData = ref([]);
    const total = ref(0);
    const page = ref(1);
    const size = ref(2);
    // 获取数据的方法
    const getListData = async () => {
    const result = await getUserManageList({
    page: page.value,
    size: size.value,
    });
    tableData.value = result.list;
    total.value = result.total;
    };
    getListData();
    // 监听语言切换
    watchSwitchLang(getListData);
    </script>
  3. 根据数据利用 el-table 和 el-pagination 渲染视图

    <template>
    <div class="user-manage-container">
    <el-card class="header">
    <div>
    <el-button type="primary">
    {{ $t("msg.excel.importExcel") }}</el-button
    >
    <el-button type="success">
    {{ $t("msg.excel.exportExcel") }}
    </el-button>
    </div>
    </el-card>
    <el-card>
    <el-table :data="tableData" border style="width: 100%">
    <el-table-column label="#" type="index" />
    <el-table-column prop="username" :label="$t('msg.excel.name')">
    </el-table-column>
    <el-table-column prop="mobile" :label="$t('msg.excel.mobile')">
    </el-table-column>
    <el-table-column :label="$t('msg.excel.avatar')" align="center">
    <template v-slot="{ row }">
    <el-image
    class="avatar"
    :src="row.avatar"
    :preview-src-list="[row.avatar]"
    ></el-image>
    </template>
    </el-table-column>
    <el-table-column :label="$t('msg.excel.role')">
    <template #default="{ row }">
    <div v-if="row.role && row.role.length > 0">
    <el-tag v-for="item in row.role" :key="item.id" size="mini">{{
    item.title
    }}</el-tag>
    </div>
    <div v-else>
    <el-tag size="mini">{{ $t("msg.excel.defaultRole") }}</el-tag>
    </div>
    </template>
    </el-table-column>
    <el-table-column prop="openTime" :label="$t('msg.excel.openTime')">
    </el-table-column>
    <el-table-column
    :label="$t('msg.excel.action')"
    fixed="right"
    width="260"
    >
    <template #default>
    <el-button type="primary" size="mini">{{
    $t("msg.excel.show")
    }}</el-button>
    <el-button type="info" size="mini">{{
    $t("msg.excel.showRole")
    }}</el-button>
    <el-button type="danger" size="mini">{{
    $t("msg.excel.remove")
    }}</el-button>
    </template>
    </el-table-column>
    </el-table>

    <el-pagination
    class="pagination"
    @size-change="handleSizeChange"
    @current-change="handleCurrentChange"
    :current-page="page"
    :page-sizes="[2, 5, 10, 20]"
    :page-size="size"
    layout="total, sizes, prev, pager, next, jumper"
    :total="total"
    >
    </el-pagination>
    </el-card>
    </div>
    </template>

    <script setup>
    import { ref } from "vue";
    import { getUserManageList } from "@/api/user-manage";
    import { watchSwitchLang } from "@/utils/i18n";

    // 数据相关
    const tableData = ref([]);
    const total = ref(0);
    const page = ref(1);
    const size = ref(2);
    // 获取数据的方法
    const getListData = async () => {
    const result = await getUserManageList({
    page: page.value,
    size: size.value,
    });
    tableData.value = result.list;
    total.value = result.total;
    };
    getListData();
    // 监听语言切换
    watchSwitchLang(getListData);

    // 分页相关
    /**
    * size 改变触发
    */
    const handleSizeChange = (currentSize) => {
    size.value = currentSize;
    getListData();
    };

    /**
    * 页码改变触发
    */
    const handleCurrentChange = (currentPage) => {
    page.value = currentPage;
    getListData();
    };
    </script>

    <style lang="scss" scoped>
    .user-manage-container {
    .header {
    margin-bottom: 22px;
    text-align: right;
    }
    ::v-deep .avatar {
    width: 60px;
    height: 60px;
    border-radius: 50%;
    }

    ::v-deep .el-tag {
    margin-right: 6px;
    }

    .pagination {
    margin-top: 20px;
    text-align: center;
    }
    }
    </style>

全局属性处理时间展示问题​

在 Vue3中取消了 过滤器的概念,其中:

  1. 局部过滤器被完全删除
  2. 全局过滤器虽然被移除,但是可以使用 全局属性 进行替代

那么在列表中的时间处理部分,在 vue2 时代通常我们都是通过 全局过滤器 来进行实现的,所以在 vue3 中我们就顺理成章的通过 全局属性 替代实现

  1. 时间处理部分我们通过 Day.js 进行处理

  2. 下载 Day.js

    npm i dayjs@1.10.6
  3. 创建 src/filter 文件夹,用于定义 全局属性

    import dayjs from "dayjs";

    const dateFilter = (val, format = "YYYY-MM-DD") => {
    if (!isNaN(val)) {
    val = parseInt(val);
    }

    return dayjs(val).format(format);
    };

    export default (app) => {
    app.config.globalProperties.$filters = {
    dateFilter,
    };
    };
  4. 在 main.js 中导入

    // filter
    import installFilter from "@/filters";

    installFilter(app);
  5. 在 user-manage 中使用全局属性处理时间解析

    <el-table-column :label="$t('msg.excel.openTime')">
    <template #default="{ row }">
    {{ $filters.dateFilter(row.openTime) }}
    </template>
    </el-table-column>

excel 导入原理与实现分析​

在处理完成这些基础的内容展示之后,接下来我们来看 excel 导入 功能

对于 excel 导入 首先我们先来明确一下它的业务流程:

  1. 点击 excel 导入 按钮进入 excel 导入页面
  2. 页面提供了两种导入形式
    1. 点击按钮上传 excel
    2. 把 excel 拖入指定区域
  3. 选中文件,进行两步操作
    1. 解析 excel 数据
    2. 上传解析之后的数据
  4. 上传成功之后,返回 员工管理(用户列表) 页面,进行数据展示

所以根据这个业务我们可以看出,整个 excel 导入核心的原理部分在于 选中文件之后,上传成功之前 的操作,即:

  1. 解析 excel 数据(最重要)
  2. 上传解析之后的数据

对于解析部分,我们回头再去详细说明,在这里我们只需要明确大的实现流程即可。

根据上面所说,整个的实现流程我们也可以很轻松得出:

  1. 创建 excel 导入页面
  2. 点击 excel 导入按钮,进入该页面
  3. 该页面提供两种文件导入形式
  4. 选中文件之后,解析 excel 数据(核心)
  5. 上传解析之后的数据
  6. 返回 员工管理(用户列表) 页面

那么明确好了这样的流程之后,接下来我们就可以实现对应的代码了。

业务落地:提供两种文件导入形式​

excel 页面我们在之前已经创建过了,就是 views/import/index 。

所以此处,我们只需要在按钮处完成页面跳转即可,在 user-manage 中:

<el-button type="primary" @click="onImportExcelClick">
{{ $t('msg.excel.importExcel') }}</el-button
>

const router = useRouter()
/**
* excel 导入点击事件
*/
const onImportExcelClick = () => {
router.push('/user/import')
}

这样我们就已经完成了前面两步,那么接下来我们就来实现 提供两种文件导入形式

  1. 创建 components/UploadExcel 组件,用于处理上传 excel 相关的问题

  2. 在 import 中导入该组件

    <template>
    <upload-excel></upload-excel>
    </template>

    <script setup>
    import UploadExcel from "@/components/UploadExcel";
    </script>
  3. 整个 UploadExcel 组件的内容可以分成两部分:

    1. 样式
    2. 逻辑
  4. 那么首先我们先处理样式内容

    <template>
    <div class="upload-excel">
    <div class="btn-upload">
    <el-button :loading="loading" type="primary" @click="handleUpload">
    {{ $t("msg.uploadExcel.upload") }}
    </el-button>
    </div>

    <input
    ref="excelUploadInput"
    class="excel-upload-input"
    type="file"
    accept=".xlsx, .xls"
    @change="handleChange"
    />
    <!-- https://developer.mozilla.org/zh-CN/docs/Web/API/HTML_Drag_and_Drop_API -->
    <div
    class="drop"
    @drop.stop.prevent="handleDrop"
    @dragover.stop.prevent="handleDragover"
    @dragenter.stop.prevent="handleDragover"
    >
    <i class="el-icon-upload" />
    <span>{{ $t("msg.uploadExcel.drop") }}</span>
    </div>
    </div>
    </template>

    <script setup>
    import {} from "vue";
    </script>

    <style lang="scss" scoped>
    .upload-excel {
    display: flex;
    justify-content: center;
    margin-top: 100px;
    .excel-upload-input {
    display: none;
    z-index: -9999;
    }
    .btn-upload,
    .drop {
    border: 1px dashed #bbb;
    width: 350px;
    height: 160px;
    text-align: center;
    line-height: 160px;
    }
    .drop {
    line-height: 60px;
    display: flex;
    flex-direction: column;
    justify-content: center;
    color: #bbb;
    i {
    font-size: 60px;
    display: block;
    }
    }
    }
    </style>

业务落地:文件选择之后的数据解析处理​

那么接下来我们来处理整个业务中最核心的一块内容 选中文件之后,解析 excel 数据

解析的方式根据我们的导入形式的不同也可以分为两种:

  1. 文件选择(选择隐藏域)导入
  2. 文件拖拽导入

那么这一小节,我们先来处理第一种。

处理之前我们需要先来做一件事情:

  1. 解析 excel 数据我们需要使用 xlsx ,所以我们需要先下载它

    npm i xlsx@0.17.0

xlsx 安装完成之后,接下来我们就可以来去实现对应代码了:

<script setup>
import XLSX from "xlsx";
import { defineProps, ref } from "vue";
import { getHeaderRow } from "./utils";

const props = defineProps({
// 上传前回调
beforeUpload: Function,
// 成功回调
onSuccess: Function,
});

/**
* 点击上传触发
*/
const loading = ref(false);
const excelUploadInput = ref(null);
const handleUpload = () => {
excelUploadInput.value.click();
};
const handleChange = (e) => {
const files = e.target.files;
const rawFile = files[0]; // only use files[0]
if (!rawFile) return;
upload(rawFile);
};

/**
* 触发上传事件
*/
const upload = (rawFile) => {
excelUploadInput.value.value = null;
// 如果没有指定上传前回调的话
if (!props.beforeUpload) {
readerData(rawFile);
return;
}
// 如果指定了上传前回调,那么只有返回 true 才会执行后续操作
const before = props.beforeUpload(rawFile);
if (before) {
readerData(rawFile);
}
};

/**
* 读取数据(异步)
*/
const readerData = (rawFile) => {
loading.value = true;
return new Promise((resolve, reject) => {
// https://developer.mozilla.org/zh-CN/docs/Web/API/FileReader
const reader = new FileReader();
// 该事件在读取操作完成时触发
// https://developer.mozilla.org/zh-CN/docs/Web/API/FileReader/onload
reader.onload = (e) => {
// 1. 获取解析到的数据
const data = e.target.result;
// 2. 利用 XLSX 对数据进行解析
const workbook = XLSX.read(data, { type: "array" });
// 3. 获取第一张表格(工作簿)名称
const firstSheetName = workbook.SheetNames[0];
// 4. 只读取 Sheet1(第一张表格)的数据
const worksheet = workbook.Sheets[firstSheetName];
// 5. 解析数据表头
const header = getHeaderRow(worksheet);
// 6. 解析数据体
const results = XLSX.utils.sheet_to_json(worksheet);
// 7. 传入解析之后的数据
generateData({ header, results });
// 8. loading 处理
loading.value = false;
// 9. 异步完成
resolve();
};
// 启动读取指定的 Blob 或 File 内容
reader.readAsArrayBuffer(rawFile);
});
};

/**
* 根据导入内容,生成数据
*/
const generateData = (excelData) => {
props.onSuccess && props.onSuccess(excelData);
};
</script>

getHeaderRow 为 xlsx 解析表头数据的通用方法,直接使用即可

import XLSX from "xlsx";
/**
* 获取表头(通用方式)
*/
export const getHeaderRow = (sheet) => {
const headers = [];
const range = XLSX.utils.decode_range(sheet["!ref"]);
let C;
const R = range.s.r;
/* start in the first row */
for (C = range.s.c; C <= range.e.c; ++C) {
/* walk every column in the range */
const cell = sheet[XLSX.utils.encode_cell({ c: C, r: R })];
/* find the cell in the first row */
let hdr = "UNKNOWN " + C; // <-- replace with your desired default
if (cell && cell.t) hdr = XLSX.utils.format_cell(cell);
headers.push(hdr);
}
return headers;
};

在 import 组件中传入 onSuccess 事件,获取解析成功之后的 excel 数据

<template>
<upload-excel :onSuccess="onSuccess"></upload-excel>
</template>

<script setup>
import UploadExcel from "@/components/UploadExcel";

/**
* 数据解析成功之后的回调
*/
const onSuccess = (excelData) => {
console.log(excelData);
};
</script>
、

业务落地:文件拖入之后的数据解析处理​

想要了解 文件拖入,那么我们就必须要先能够了解 HTML_Drag_and_Drop(HTML 拖放 API) 事件,我们这里主要使用到其中三个事件:

  1. drop (en-US):当元素或选中的文本在可释放目标上被释放时触发
  2. dragover (en-US):当元素或选中的文本被拖到一个可释放目标上时触发
  3. dragenter (en-US):当拖拽元素或选中的文本到一个可释放目标时触发

那么明确好了这三个事件之后,我们就可以实现对应的拖入代码逻辑了

<script setup>
...
import { getHeaderRow, isExcel } from './utils'
import { ElMessage } from 'element-plus'

...
/**
* 拖拽文本释放时触发
*/
const handleDrop = e => {
// 上传中跳过
if (loading.value) return
const files = e.dataTransfer.files
if (files.length !== 1) {
ElMessage.error('必须要有一个文件')
return
}
const rawFile = files[0]
if (!isExcel(rawFile)) {
ElMessage.error('文件必须是 .xlsx, .xls, .csv 格式')
return false
}
// 触发上传事件
upload(rawFile)
}

/**
* 拖拽悬停时触发
*/
const handleDragover = e => {
// https://developer.mozilla.org/zh-CN/docs/Web/API/DataTransfer/dropEffect
// 在新位置生成源项的副本
e.dataTransfer.dropEffect = 'copy'
}

。。。
</script>

在 utils 中生成 isExcel 方法

export const isExcel = (file) => {
return /\.(xlsx|xls|csv)$/.test(file.name);
};

业务落地:传递解析后的 excel 数据​

那么到现在我们已经处理好了 excel 的数据解析操作。

接下来就可以实现对应的数据上传,完成 excel 导入功能了

  1. 定义 api/user-manage 上传接口

    /**
    * 批量导入
    */
    export const userBatchImport = (data) => {
    return request({
    url: "/user-manage/batch/import",
    method: "POST",
    data,
    });
    };
  2. 在 onSuccess 中调用接口上传数据,但是此处大家要注意两点内容:

    1. header 头不需要上传
    2. results 中 key 为中文,我们必须要按照接口要求进行上传
  3. 所以我们需要处理 results 中的数据结构

  4. 创建 import/utils 文件

    /**
    * 导入数据对应表
    */
    export const USER_RELATIONS = {
    姓名: "username",
    联系方式: "mobile",
    角色: "role",
    开通时间: "openTime",
    };
  5. 创建数据解析方法,生成新数组

    /**
    * 筛选数据
    */
    const generateData = (results) => {
    const arr = [];
    results.forEach((item) => {
    const userInfo = {};
    Object.keys(item).forEach((key) => {
    userInfo[USER_RELATIONS[key]] = item[key];
    });
    arr.push(userInfo);
    });
    return arr;
    };
  6. 完成数据上传即可

    /**
    * 数据解析成功之后的回调
    */
    const onSuccess = async ({ header, results }) => {
    const updateData = generateData(results);
    await userBatchImport(updateData);
    ElMessage.success({
    message: results.length + i18n.t("msg.excel.importSuccess"),
    type: "success",
    });
    router.push("/user/manage");
    };

业务落地:处理剩余 bug​

截止到目前整个 excel 上传我们就已经处理完成了,只不过目前还存在两个小 bug 需要处理:

  1. 上传之后的时间解析错误
  2. 返回用户列表之后,数据不会自动刷新

那么这一小节我们就针对这两个问题进行分别处理

上传之后的时间解析错误:

导致该问题出现的原因是因为 excel 导入解析时间会出现错误, 处理的方案也很简单,是一个固定方案,我们只需要进行固定的时间解析处理即可:

  1. 在 import/utils 中新增事件处理方法(固定方式直接使用即可)

    /**
    * 解析 excel 导入的时间格式
    */
    export const formatDate = (numb) => {
    const time = new Date((numb - 1) * 24 * 3600000 + 1);
    time.setYear(time.getFullYear() - 70);
    const year = time.getFullYear() + "";
    const month = time.getMonth() + 1 + "";
    const date = time.getDate() - 1 + "";
    return (
    year +
    "-" +
    (month < 10 ? "0" + month : month) +
    "-" +
    (date < 10 ? "0" + date : date)
    );
    };
  2. 在 generateData 中针对 openTime 进行单独处理

    /**
    * 筛选数据
    */
    const generateData = results => {
    ...
    Object.keys(item).forEach(key => {
    if (USER_RELATIONS[key] === 'openTime') {
    userInfo[USER_RELATIONS[key]] = formatDate(item[key])
    return
    }
    userInfo[USER_RELATIONS[key]] = item[key]
    })
    ...
    })
    return arr
    }

返回用户列表之后,数据不会自动刷新:

出现该问题的原因是因为:appmain 中使用 keepAlive 进行了组件缓存。

解决的方案也很简单,只需要:监听 onActivated 事件,重新获取数据即可

在 user-manage 中:

import { ref, onActivated } from "vue";

// 处理导入用户后数据不重新加载的问题
onActivated(getListData);

excel 导入功能总结​

那么到这里我们的 excel 导入功能我们就已经实现完成了,再来回顾一下我们整体的流程:

  1. 创建 excel 导入页面
  2. 点击 excel 导入按钮,进入该页面
  3. 该页面提供两种文件导入形式
  4. 选中文件之后,解析 excel 数据(核心)
  5. 上传解析之后的数据
  6. 返回 员工管理(用户列表) 页面

游离于这些流程之外的,还包括额外的两个小 bug 的处理,特别是 excel 的时间格式问题, 大家要格外注意,因为这是一个必然会出现的错误,当然处理方案也是固定的。

辅助业务之用户删除​

完成了 excel 的用户导入之后,那么我们肯定会产生很多的无用数据,所以说接下来我们来完成一个辅助功能:删除用户(希望大家都可以在完成 excel 导入功能之后,删除掉无用数据,以方便其他的同学进行功能测试)

删除用户的功能比较简单,我们只需要 调用对应的接口即可

  1. 在 api/user-manage 中指定删除接口

    /**
    * 删除指定数据
    */
    export const deleteUser = (id) => {
    return request({
    url: `/user-manage/detele/${id}`,
    });
    };
  2. 在 views/user-manage 中调用删除接口接口

    <el-button type="danger" size="mini" @click="onRemoveClick(row)"
    >{{ $t('msg.excel.remove') }}</el-button
    >
    /**
    * 删除按钮点击事件
    */
    const i18n = useI18n();
    const onRemoveClick = (row) => {
    ElMessageBox.confirm(
    i18n.t("msg.excel.dialogTitle1") +
    row.username +
    i18n.t("msg.excel.dialogTitle2"),
    {
    type: "warning",
    }
    ).then(async () => {
    await deleteUser(row._id);
    ElMessage.success(i18n.t("msg.excel.removeSuccess"));
    // 重新渲染数据
    getListData();
    });
    };

excel 导出原理与实现分析​

对于 excel 导出而言我们还是先来分析一下它的业务逻辑:

  1. 点击 excel 导出按钮
  2. 展示 dialog 弹出层
  3. 确定导出的 excel 文件名称
  4. 点击导出按钮
  5. 获取 所有用户列表数据
  6. 将 json 结构数据转化为 excel 数据,并下载

有了 excel 导入的经验之后,再来看这样的一套业务逻辑,相信大家应该可以直接根据这样的一套业务逻辑得出 excel 导出的核心原理了:将 json 结构数据转化为 excel 数据,并下载

那么我们对应的实现方案也可以直接得出了:

  1. 创建 excel 导出弹出层
  2. 处理弹出层相关的业务
  3. 点击导出按钮,将 json 结构数据转化为 excel 数据,并下载(核心)

业务落地:Export2Excel 组件​

那么首先我们先去创建 excel 弹出层组件 Export2Excel

  1. 创建 views/user-manage/components/Export2Excel

    <template>
    <el-dialog
    :title="$t('msg.excel.title')"
    :model-value="modelValue"
    @close="closed"
    width="30%"
    >
    <el-input :placeholder="$t('msg.excel.placeholder')"></el-input>
    <template #footer>
    <span class="dialog-footer">
    <el-button @click="closed">{{ $t("msg.excel.close") }}</el-button>
    <el-button type="primary" @click="onConfirm">{{
    $t("msg.excel.confirm")
    }}</el-button>
    </span>
    </template>
    </el-dialog>
    </template>

    <script setup>
    import { defineProps, defineEmits } from "vue";

    defineProps({
    modelValue: {
    type: Boolean,
    required: true,
    },
    });
    const emits = defineEmits(["update:modelValue"]);

    /**
    * 导出按钮点击事件
    */
    const onConfirm = async () => {
    closed();
    };

    /**
    * 关闭
    */
    const closed = () => {
    emits("update:modelValue", false);
    };
    </script>
  2. 在 user-manage 中进行导入 dialog 组件

    1. 指定 excel按钮 点击事件

      <el-button type="success" @click="onToExcelClick">
      {{ $t('msg.excel.exportExcel') }}
      </el-button>
    2. 导入 ExportToExcel 组件

      <export-to-excel v-model="exportToExcelVisible"></export-to-excel>
      import ExportToExcel from './components/Export2Excel.vue'
    3. 点击事件处理函数

      /**
      * excel 导出点击事件
      */
      const exportToExcelVisible = ref(false);
      const onToExcelClick = () => {
      exportToExcelVisible.value = true;
      };

业务落地:导出前置业务处理​

那么这一小节我们来处理一些实现 excel 导出时的前置任务,具体有:

  1. 指定 input 默认导出文件名称
  2. 定义 获取全部用户 列表接口,并调用

那么下面我们先来处理第一步:指定 input 默认导出文件名称

  1. 指定 input 的双向绑定

    <el-input
    v-model="excelName"
    :placeholder="$t('msg.excel.placeholder')"
    ></el-input>
  2. 指定默认文件名

    const i18n = useI18n();
    let exportDefaultName = i18n.t("msg.excel.defaultName");
    const excelName = ref("");
    excelName.value = exportDefaultName;
    watchSwitchLang(() => {
    exportDefaultName = i18n.t("msg.excel.defaultName");
    excelName.value = exportDefaultName;
    });

定义获取全部用户列表接口,并调用:

  1. 在 user-manage 中定义获取全部数据接口

    /**
    * 获取所有用户列表数据
    */
    export const getUserManageAllList = () => {
    return request({
    url: "/user-manage/all-list",
    });
    };
  2. 调用接口数据,并指定 loading

    <el-button type="primary" @click="onConfirm" :loading="loading"
    >{{ $t('msg.excel.confirm') }}</el-button
    >
    import { getUserManageAllList } from "@/api/user-manage";

    /**
    * 导出按钮点击事件
    */
    const loading = ref(false);
    const onConfirm = async () => {
    loading.value = true;
    const allUser = (await getUserManageAllList()).list;

    closed();
    };

    /**
    * 关闭
    */
    const closed = () => {
    loading.value = false;
    emits("update:modelValue", false);
    };

业务落地:实现 excel 导出逻辑​

那么万事俱备,到此时我们就可以来实现整个业务逻辑的最后步骤:

  1. 将 json 结构数据转化为 excel 数据
  2. 下载对应的 excel 数据

对于这两步的逻辑而言,最复杂的莫过于 将 json 结构数据转化为 excel 数据 这一步的功能,不过万幸的是对于该操作的逻辑是 通用处理逻辑,搜索 Export2Excel 我们可以得到巨多的解决方案,所以此处我们 没有必要 手写对应的转换逻辑

该转化逻辑我已经把它放置到 课程资料/Export2Excel.js 文件中,大家可以直接把该代码复制到 utils 文件夹下

PS:如果大家想要了解该代码的话,那么对应的业务逻辑我们也已经全部标出,大家可以直接查看

那么有了 Export2Excel.js 的代码之后 ,接下来我们还需要导入两个依赖库:

  1. xlsx (已下载):excel 解析器和编译器
  2. file-saver:文件下载工具,通过 npm i file-saver@2.0.5 下载

那么一切准备就绪,我们去实现 excel 导出功能:

  1. 动态导入 Export2Excel.js

    // 导入工具包
    const excel = await import("@/utils/Export2Excel");
  2. 因为从服务端获取到的为 json 数组对象 结构,但是导出时的数据需要为 二维数组,所以我们需要有一个方法来把 json 结构转化为 二维数组

  3. 创建转化方法

    1. 创建 views/user-manage/components/Export2ExcelConstants.js 中英文对照表

      /**
      * 导入数据对应表
      */
      export const USER_RELATIONS = {
      姓名: "username",
      联系方式: "mobile",
      角色: "role",
      开通时间: "openTime",
      };
    2. 创建数据解析方法

      // 该方法负责将数组转化成二维数组
      const formatJson = (headers, rows) => {
      // 首先遍历数组
      // [{ username: '张三'},{},{}] => [[’张三'],[],[]]
      return rows.map((item) => {
      return Object.keys(headers).map((key) => {
      // 角色特殊处理
      if (headers[key] === "role") {
      const roles = item[headers[key]];

      return JSON.stringify(roles.map((role) => role.title));
      }
      return item[headers[key]];
      });
      });
      };
  4. 调用该方法,获取导出的二维数组数据

    import { USER_RELATIONS } from "./Export2ExcelConstants";

    const data = formatJson(USER_RELATIONS, allUser);
  5. 调用 export_json_to_excel 方法,完成 excel 导出

    excel.export_json_to_excel({
    // excel 表头
    header: Object.keys(USER_RELATIONS),
    // excel 数据(二维数组结构)
    data,
    // 文件名称
    filename: excelName.value || exportDefaultName,
    // 是否自动列宽
    autoWidth: true,
    // 文件类型
    bookType: "xlsx",
    });

业务落地:excel 导出时的时间逻辑处理​

因为服务端返回的 openTime 格式问题,所以我们需要在 excel 导出时对时间格式进行单独处理

  1. 导入时间格式处理工具

    import { dateFormat } from "@/filters";
  2. 对时间格式进行单独处理

    // 时间特殊处理
    if (headers[key] === "openTime") {
    return dateFormat(item[headers[key]]);
    }

excel 导出功能总结​

那么到这里我们的整个 excel 导出就算是实现完成了。

整个 excel 导出遵循以下业务逻辑:

  1. 创建 excel 导出弹出层
  2. 处理弹出层相关的业务
  3. 点击导出按钮,将 json 结构数据转化为 excel 数据
    1. json 数据转化为 二维数组
    2. 时间处理
    3. 角色数组处理
  4. 下载 excel 数据

其中 将 json 结构数据转化为 excel 数据 部分因为有通用的实现方式,所以我们没有必要进行手动的代码书写,毕竟 “程序猿是最懒的群体嘛”

但是如果大家想要了解一下这个业务逻辑中所进行的事情,我们也对代码进行了完整的备注,大家可以直接进行查看

局部打印详情原理与实现分析​

那么接下来就是我们本章中最后一个功能 员工详情打印

整个员工详情的打印逻辑分为两部分:

  1. 以表格的形式展示员工详情
  2. 打印详情表格

其中 以表格的形式展示员工详情 部分我们需要使用到 el-descriptions 组件,并且想要利用该组件实现详情的表格效果还需要一些小的技巧,这个具体的我们到时候再去说

而 打印详情表格 的功能就是建立在展示详情页面之上的

大家知道,当我们在浏览器右键时,其实可以直接看到对应的 打印 选项,但是这个打印选项是直接打印整个页面,不能指定打印页面中的某一部分的。

所以说 打印是浏览器本身的功能,但是这个功能存在一定的小缺陷,那就是 只能打印整个页面

而我们想要实现 详情打印,那么就需要在这个功能的基础之上做到指定打印具体的某一块视图,而这个功能已经有一个第三方的包 vue-print-nb 帮助我们进行了实现,所以我们只需要使用这个包即可完成打印功能

那么明确好了原理之后,接下来步骤就呼之欲出了:

  1. 获取员工详情数据
  2. 在员工详情页面,渲染详情数据
  3. 利用 vue-print-nb 进行局部打印

业务落地:获取展示数据​

首先我们来获取对应的展示数据

  1. 在 api/user-manage 中定义获取用户详情接口

    /**
    * 获取用户详情
    */
    export const userDetail = (id) => {
    return request({
    url: `/user-manage/detail/${id}`,
    });
    };
  2. 在 views/user-info 中根据 id 获取接口详情数据,并进行国际化处理

    <script setup>
    import { userDetail } from "@/api/user-manage";
    import { watchSwitchLang } from "@/utils/i18n";
    import { defineProps, ref } from "vue";

    const props = defineProps({
    id: {
    type: String,
    required: true,
    },
    });

    // 数据相关
    const detailData = ref({});
    const getUserDetail = async () => {
    detailData.value = await userDetail(props.id);
    };
    getUserDetail();
    // 语言切换
    watchSwitchLang(getUserDetail);
    </script>
  3. 因为用户详情可以会以组件的形式进行呈现,所以对于此处我们需要得到的 id ,可以通过 vue-router Props 传参 的形式进行

  4. 指定路由表

    {
    path: '/user/info/:id',
    name: 'userInfo',
    component: () => import('@/views/user-info/index'),
    props: true,
    meta: {
    title: 'userInfo'
    }
    }
  5. 在 views/user-manage 中传递用户 id

    <el-button type="primary" size="mini" @click="onShowClick(row._id)">
    {{ $t('msg.excel.show') }}
    </el-button>

    /** * 查看按钮点击事件 */ const onShowClick = id => {
    router.push(`/user/info/${id}`) }

业务落地:渲染详情结构​

渲染用户详情结构我们需要借助 el-descriptions 组件,只不过使用该组件时我们需要一些小的技巧

因为 el-descriptions 组件作用为:渲染描述列表。但是我们想要的包含头像的用户详情样式,直接利用一个 el-descriptions 组件并无法进行渲染,所以此时我们需要对多个 el-descriptions 组件 与 img 标签进行配合使用

image-20210929233418837

如果得出渲染代码

<template>
<div class="user-info-container">
<el-card class="print-box">
<el-button type="primary">{{ $t("msg.userInfo.print") }}</el-button>
</el-card>
<el-card>
<div class="user-info-box">
<!-- 标题 -->
<h2 class="title">{{ $t("msg.userInfo.title") }}</h2>

<div class="header">
<!-- 头部渲染表格 -->
<el-descriptions :column="2" border>
<el-descriptions-item :label="$t('msg.userInfo.name')">{{
detailData.username
}}</el-descriptions-item>
<el-descriptions-item :label="$t('msg.userInfo.sex')">{{
detailData.gender
}}</el-descriptions-item>
<el-descriptions-item :label="$t('msg.userInfo.nation')">{{
detailData.nationality
}}</el-descriptions-item>
<el-descriptions-item :label="$t('msg.userInfo.mobile')">{{
detailData.mobile
}}</el-descriptions-item>
<el-descriptions-item :label="$t('msg.userInfo.province')">{{
detailData.province
}}</el-descriptions-item>
<el-descriptions-item :label="$t('msg.userInfo.date')">{{
$filters.dateFilter(detailData.openTime)
}}</el-descriptions-item>
<el-descriptions-item :label="$t('msg.userInfo.remark')" :span="2">
<el-tag
class="remark"
size="small"
v-for="(item, index) in detailData.remark"
:key="index"
>{{ item }}</el-tag
>
</el-descriptions-item>
<el-descriptions-item
:label="$t('msg.userInfo.address')"
:span="2"
>{{ detailData.address }}</el-descriptions-item
>
</el-descriptions>
<!-- 头像渲染 -->
<el-image
class="avatar"
:src="detailData.avatar"
:preview-src-list="[detailData.avatar]"
></el-image>
</div>
<div class="body">
<!-- 内容渲染表格 -->
<el-descriptions direction="vertical" :column="1" border>
<el-descriptions-item :label="$t('msg.userInfo.experience')">
<ul>
<li v-for="(item, index) in detailData.experience" :key="index">
<span>
{{ $filters.dateFilter(item.startTime, "YYYY/MM") }}
----
{{ $filters.dateFilter(item.endTime, "YYYY/MM") }}</span
>
<span>{{ item.title }}</span>
<span>{{ item.desc }}</span>
</li>
</ul>
</el-descriptions-item>
<el-descriptions-item :label="$t('msg.userInfo.major')">
{{ detailData.major }}
</el-descriptions-item>
<el-descriptions-item :label="$t('msg.userInfo.glory')">
{{ detailData.glory }}
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 尾部签名 -->
<div class="foot">{{ $t("msg.userInfo.foot") }}</div>
</div>
</el-card>
</div>
</template>

<style lang="scss" scoped>
.print-box {
margin-bottom: 20px;
text-align: right;
}
.user-info-box {
width: 1024px;
margin: 0 auto;
.title {
text-align: center;
margin-bottom: 18px;
}
.header {
display: flex;
::v-deep .el-descriptions {
flex-grow: 1;
}
.avatar {
width: 187px;
box-sizing: border-box;
padding: 30px 20px;
border: 1px solid #ebeef5;
border-left: none;
}
.remark {
margin-right: 12px;
}
}
.body {
ul {
list-style: none;
li {
span {
margin-right: 62px;
}
}
}
}
.foot {
margin-top: 42px;
text-align: right;
}
}
</style>

业务落地:局部打印功能实现​

局部详情打印功能我们需要借助 vue-print-nb,所以首先我们需要下载该插件

npm i vue3-print-nb@0.1.4

然后利用该工具完成下载功能:

  1. 指定 printLoading

    <el-button type="primary" :loading="printLoading">{{
    $t('msg.userInfo.print')
    }}</el-button>

    // 打印相关
    const printLoading = ref(false)
  2. 创建打印对象

    const printObj = {
    // 打印区域
    id: "userInfoBox",
    // 打印标题
    popTitle: "imooc-vue-element-admin",
    // 打印前
    beforeOpenCallback(vue) {
    printLoading.value = true;
    },
    // 执行打印
    openCallback(vue) {
    printLoading.value = false;
    },
    };
  3. 指定打印区域 id 匹配

    <div id="userInfoBox" class="user-info-box"></div>
  4. vue-print-nb 以指令的形式存在,所以我们需要创建对应指令

  5. 新建 directives 文件夹,创建 index.js

  6. 写入如下代码

    import print from "vue3-print-nb";

    export default (app) => {
    app.use(print);
    };
  7. 在 main.js 中导入该指令

    import installDirective from "@/directives";
    installDirective(app);
  8. 将打印指令挂载到 el-button 中

    <el-button type="primary" v-print="printObj" :loading="printLoading"
    >{{ $t('msg.userInfo.print') }}</el-button
    >

局部打印功能总结​

整个局部打印详情功能,整体的核心逻辑就是这么两块:

  1. 以表格的形式展示员工详情
  2. 打印详情表格

其中第一部分使用 el-descriptions 组件配合一些小技巧即可实现

而局部打印功能则需要借助 vue-print-nb 这个第三方库进行实现

所以整个局部打印功能应该并不算复杂,掌握这两部分即可轻松实现

总结​

那么到这里我们整个章节就全部完成了,最后的 为用户分配角色 功能需要配合 角色列表 进行实现,所以我们需要等到后面进行

那么整个章节所实现的功能有:

  1. 用户列表分页展示
  2. excel 导入用户
  3. 用户列表导出为 excel
  4. 用户详情的表格展示
  5. 用户详情表格打印
  6. 用户删除

其中比较复杂的应该就是 excel 导入 & 导出 了,所以针对这两个功能我们花费了最多的篇幅进行讲解

但是这里有一点大家不要忘记,我们在本章开篇的时候说过,员工管理 是 用户权限中的一个前置! 比如我们的分配角色功能就需要配合其他的业务实现,那么具体的整个用户权限都包含了哪些内容呢?

el-upload 使用​

1、action 上传的地址,接口地址 直接在 action 中写后端地址会出现跨域问题,而且传参数不方便 就用 http-request 指定具体上传方法

2、auto-upload 是否在选取文件后立即进行上传,默认 true 在 action 赋空值,使用 http-request 指定方法上传时,auto-upload 为 false

3、http-request 覆盖默认的上传行为 1,可以自定义上传的实现 默认的上传方法均失效,获取不到 file 值 需要使用 on-change2 做上传文件前的处理

4、上传文件显示进度条 el-progress3 5、上传 .xls , .xlsx 文件并显示进度条的实现代码

<el-dialog
ref=""
append-to-body
:title="excel.title"
v-if="excel.visible"
:close-on-click-modal="false"
:visible.sync="excel.visible"
>
<el-upload
class="upload-demo"
ref="upload"
action=""
:limit="1"
:auto-upload="false"
:file-list="excel.upload.fileList"
:on-change="excelChange"
:http-request="excelRequest"
:on-remove="excelRemove"
>
<el-button icon="el-icon-upload2" plain @click="excelReset">{{ st('frame.choiceFile') }}</el-button>
<div slot="tip" class="el-upload__tip">只能上传 .xlsx 和 .xls 文件,且不超过1个文件</div>
</el-upload>
<el-progress v-show="excel.progressFlag" :percentage="excel.loadProgress"></el-progress>
<div ref="uploadFile"></div>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="cancel()">{{st('frame.cancel')}}</el-button>
<el-button type="success" @click="submitFile()">{{st('publicCustom.ok')}}</el-button>
</div>
</el-dialog>
data() {
return {
excel: {
title: this.st('frame.import'),
visible: false,
progressFlag: false,
loadProgress: 0,
upload: {
fileList: [],
action: '',
headers: {},
data: {
jsondata: ''
}
}
}
}
}
methods: {
excelReset() {
this.excel.upload.fileList = []
this.$refs.uploadFile.innerHTML = null
},
excelRemove() {
this.excel.upload.fileList = []
this.excel.progressFlag = false
this.$refs.uploadFile.innerHTML = null
this.excel.loadProgress = 0
},
excelChange(file) {
if (file.name.indexOf('.xlsx') == -1 && file.name.indexOf('.xls') == -1) {
this.$message.error(this.st('frame.uploadError'))
this.excel.upload.fileList = []
} else {
if(file.status === 'ready'){
this.excel.progressFlag = true
this.excel.loadProgress = 0
const interval = setInterval(() => {
if(this.excel.loadProgress >=99){
clearInterval(interval)
return
}
this.excel.loadProgress += 1
}, 20)
}
this.excelRequest(file)
}
},
excelRequest(file) {
var form = new FormData()
form.append("file", file.raw)
InportPbomPartExcel(form).then((res) => {
if (res.resultData.success) {
const url = settings.api.url + res.resultData.fileName
window.open(url)
let template = res.resultData.MessageString
this.$refs.uploadFile.innerHTML = template
this.excel.progressFlag = false
this.excel.loadProgress = 100
} else {
this.$message.error(res.resultData.MessageString)
}
})
}
}

on-preview 点击文件列表中已上传的文件时的钩子 on-success 文件上传成功时的钩子 on-progress 文件上传时的钩子 before-upload 上传文件之前的钩子,参数为上传的文件,若返回 false 或者返回 Promise 且被 reject,则停止上传 on-change 文件状态改变时的钩子,添加文件、上传成功和上传失败时都会被调用

<el-upload
action="Fake Action" :before-upload="uploadSuccess" :on-change="uploadVideoProcess" :show-file-list="false" :file-list="fileList">
<el-button v-if="typePage !=='view'" size="mini" type="primary">点击上传</el-button>
<span v-if="typePage !=='view'" slot="tip" class="el-upload__tip">支持pdf,jpg,png格式文件</span>
</el-upload>
<el-progress v-show="progressFlag" :percentage="loadProgress"></el-progress>

uploadVideoProcess(file, fileList) {
if(file.status === 'ready'){
this.progressFlag = true; // 显示进度条
this.loadProgress = 0;
const interval = setInterval(() => {
if(this.loadProgress >=99){
clearInterval(interval)
return
}
this.loadProgress += 1
}, 20);
}
if (file.status === 'success') {
this.progressFlag = false; // 不显示进度条
this.loadProgress = 100;
}
}

el-upload 实现多个文件上传​

最近在用 Vue 开发项目的时候有一个需求,需要导入 word 文档,并且需要支持多选。element-ui 的 upload 组件支持多选文件,只需要配置参数 multiple 为 true 即可。但是这个组件默认会将多选的文件分多次进行上传,于是就会存在多次的上传请求,由于后台的接口要求是一次请求能够上传多个文件,且我们也知道请求多了会对服务器造成更大的压力,因而基于多种原因,这个 upload 的上传行为得进行改造一番。

前后的结果​

改造前的结果:同时上传 2 个文件,会发出 2 次对接口的请求,每次请求里包含了一个文件。

upload_result_after

改造后的结果:同时上传 2 个文件,会发出一次接口请求,接口入参里包含 2 个文件。

upload_result_after

方法一:通过配置 file-list(推荐使用)​

html 部分:

<el-upload
class="upload-demo list-uploadbtn"
ref="upload"
:action="curBastUrl"
:auto-upload="false"
:on-remove="updataRemove"
:before-upload="beforeUpload"
:on-change="updatachange"
:file-list="fileList"
:multiple="true"
>
<el-button size="small">点击上传</el-button>
</el-upload>
<el-button type="primary" @click="submitUpload">确 定</el-button>

js 部分:

submitUpload() {  // 导入
let formData = new FormData(); // 用FormData存放上传文件
this.fileList.forEach(file => {
formData.append('file', file.raw)
})
  
formData.append('categoryDirectory', this.filedata.categoryDirectory)

// importCase是上传接口
importCase(formData).then((res) => {
//手动上传无法触发成功或失败的钩子函数,因此这里手动调用
  this.updataSuccess(res.data)
}, (err) => {
  
})
}

关键代码说明:

  • auto-upload 设置为 false 用于关闭组件的自动上传;
  • file-list 配置一个数组用于接收上传的文件列表;
  • multiple 设置为 true 表示支持多选文件;
  • action 配置为完整的上传接口 url,不配置会报错
  • 不用配置 data、on-success、on-error等参数,因为手动上传不会用到这些配置信息;
  • 最后通过点击按钮手动调用上传函数 submitUpload ,创建一个 FormData 把 fileList 的文件存进去。

方法二:通过配置 http-request​

html 部分:

<el-upload
class="upload-demo list-uploadbtn"
ref="upload"
:action="curBastUrl"
:auto-upload="false"
:http-request="uploadFile"
:on-remove="updataRemove"
:before-upload="beforeUpload"
:on-change="updatachange"
:multiple="true"
>
<el-button size="small">点击上传</el-button>
</el-upload>
<el-button type="primary" @click="submitUpload">确 定</el-button>

js 部分:

submitUpload() {  // 导入
let tempData = this.filedata
this.filedata = new FormData() // 用FormData存放上传文件
this.$refs.upload.submit() // 会循环调用uploadFile方法,多个文件调用多次

this.filedata.append('categoryDirectory', tempData.categoryDirectory)

// importCase是上传接口
importCase(this.filedata).then((res) => {
//手动上传无法触发成功或失败的钩子函数,因此这里手动调用
  this.updataSuccess(res.data)
}, (err) => {
  
})
}
uploadFile(file) {
this.filedata.append('file', file.file)
}

关键代码说明:

  • http-request 自定义上传方法;
  • 最后通过点击按钮手动调用上传函数 submitUpload ,创建一个 FormData, 调用 upload 组件的 submit 方法的时候会循环调用 http-request 配置的方法,从而往 FormData 里存放文件。

el-upload 源码分析​

el-upload 组件二次封装​

主要需求就三个,如下:

  • 文件拖拽上传
  • 不仅能单文件上传,多文件也可以同时上传
  • 显示上传列表,能够对已上传文件进行撤销操作

template​

    <div class="upload-file">
<el-upload
:action="uploadFileUrl"
:before-upload="handleBeforeUpload"
:file-list="fileList"
show-file-list
drag
multiple
:limit="limit"
:on-error="handleUploadError"
:on-exceed="handleExceed"
:on-success="handleUploadSuccess"
:on-preview="handleUploadedPreview"
:before-remove="beforeDelete"
:on-remove="handleDelete"
class="uploader"
ref="upload"
>
<i class="el-icon-upload"></i>
<div class="el-upload__text">将文件拖到此处,或 <em>选取文件</em> 上传</div>

<!-- 上传按钮 -->
<!--<el-button size="mini" type="primary">选取文件</el-button>-->

<!-- 上传提示 -->
<div class="el-upload__tip" slot="tip" v-if="showTip">
<template v-if="fileSize"> 请上传大小不超过 <b style="color: #f56c6c"> {{ fileSize }} KB</b> </template>
<template v-if="fileType"> 格式为 <b style="color: #f56c6c">{{ fileType.join("/") }}</b> 的文件</template>
</div>
</el-upload>
</div>

props​

props 自定义属性,接收来自父组件的数据

props: {
// 上传文件数量限制
limit: {
type: Number,
default: 5
},
// 单个上传文件大小限制
fileSize: {
type: Number,
default: 500
},
// 允许上传的文件类型, 例如['png', 'jpg', 'jpeg']
fileType: {
type: Array,
default: () => ["doc", "xls", "ppt", "txt", "pdf", 'png', 'jpg', 'jpeg']
},
// 是否显示文件上传提示
isShowTip: {
type: Boolean,
default: true
}
}

data​

data 数据定义

data() {
return {
// 上传的图片请求地址
uploadFileUrl: "http://localhost:8088/file/upload",
fileList: [],
notifyPromise: Promise.resolve()
};
}

methods 方法​

各方法解析​

  • handleBeforeUpload() 上传文件之前的钩子,参数为上传的文件,若返回 false 或者返回 Promise 且被 reject,则停止上传。
  • handleExceed() 文件超出个数限制时执行弹出警告通知框。
  • handleUploadError() 文件上传失败时执行弹出警告通知框,同时关闭上传加载。
  • handleUploadSuccess() 单个文件上传成功就执行。
  • beforeDelete() 删除文件之前的钩子,参数为上传的文件和文件列表,若返回 false 或者返回 Promise 且被 reject,则停止删除。
  • handleDelete() 文件列表移除文件时执行,调用删除文件接口,去删除指定的上传文件。
  • uploadFileDelete() 传入指定文件 url 和该文件在 fileList 中的索引,后端根据文件文件路径删除已上传的文件,然后移除 fileList 中索引值位置上的 file。
  • warningNotify() 接收一个参数,即警告信息,用来弹出警告框的,有 2s 的延迟消失时间。
methods: {
// 上传前校检格式和大小
handleBeforeUpload(file) {
// 校检文件类型
if (this.fileType) {
let fileExtension = "";
if (file.name.lastIndexOf(".") > -1) {
fileExtension = file.name.slice(file.name.lastIndexOf(".") + 1);
}
const isTypeOk = this.fileType.some((type) => {
return fileExtension && fileExtension.indexOf(type) > -1;

});
if (!isTypeOk) {
this.warningNotify(`文件格式不正确,请上传${this.fileType.join("/")}格式文件!`);
return false;
}
}
// 校检文件大小
if (this.fileSize) {
// KB
const fileSize = file.size / 1024;
const isLt = fileSize < this.fileSize;
if (!isLt) {
this.warningNotify(`上传文件大小不能超过 ${this.fileSize} KB!`);
return false;
}
}
// 开始上传
this.loading = this.$loading({
lock: true,
text: "上传中...",
background: "rgba(0, 0, 0, 0.7)",
});
return true;
},
// 文件个数超出限制
handleExceed() {
this.$message.warning(`上传文件数量不能超过 ${this.limit} 个!`);
},
// 上传失败
handleUploadError(err) {
this.$message.error(`上传失败[${err}], 请重试`);
this.loading.close();
},
// 上传成功回调
handleUploadSuccess(res, file, fileList) {
if (res.resultCode === 200) {
file['url'] = res.data.path;
//this.fileList.push(file); 报错 Cannot set properties of null (setting 'status')
this.$message.success("上传成功");
this.loading.close();
} else {
this.handleUploadError(res.message);
}
},
// 删除上传文件前
beforeDelete(file, fileList) {
this.fileList = fileList;
if (file.status === 'success') {
return this.$confirm(`确定删除文件【${file.name}】`);
}
},
// 删除上传文件
handleDelete(file, fileList) {
if (file.status === 'success') {
let filePath = file.url;
let fileIndex;
this.fileList.forEach((it, index) => {
if (it.url === filePath) {
fileIndex = index;
}
});
// 删除已上传的文件
this.uploadFileDelete(filePath, fileIndex);
}
},
uploadFileDelete(filePath, fileIndex) {
let _this = this;
if (fileIndex >= 0) {
this.axios({
method: 'DELETE',
url: '/file/upload/delete',
headers: {'content-type': 'application/json'},
data: filePath
}).then((response) => {
let data = response.data;
if (data.resultCode === 200) {
this.$message({
type: 'success',
message: data.message
});
_this.fileList.splice(fileIndex, 1);
} else {
this.$message.error(data.message);
}
}).catch(error => {
this.$message.error(error);
});
} else {
this.$message.error("未找到上传文件,无法删除");
}
}
}

bug 解决​

notifyPromise: Promise.resolve() 解决组件高度坍塌问题​

当多文件上传前文件格式校验不通过时,弹出警告消息,但 Element 一下子同时调用了多次 this.$notify 方法,导致通知消息框高度坍塌,重叠在一起了 ↓

image.png

面向 Baidu 编程后,找到了一种采用 Promise 的回调方法可以解决 Element Notification 组件高度塌陷问题

$notify

$notify计算通知的间距时,会拿当前元素的高度,但是因为vue的异步更新队列存在缓冲机制,第一次方法调用时,并没有更新dom,导致拿到的高度为0,所有第二个通知框只是上移了默认的 offset 16px。

    warningNotify(msg) {
let _this = this;
this.notifyPromise = this.notifyPromise.then(_this.$nextTick).then(() => {
_this.$notify({
type: 'warning',
title: '警告',
message: msg,
duration: 2000
});
});
}

使用vue提供的nextTick方法,保证第一次通知的dom更新之后,再执行第二次通知的代码,此时通知框的高度就会加上第一个通知框的高度,得到正确的计算高度,这时框重叠问题就解决了。

多文件上传​

因为我在上传文件成功回调函数中向实例 fileList 中 push 当前上传的 file , 我原本单纯认为上传成功后就可以添加到上传文件列表之中,但是实际上是不需要我们手动添加的,我这波操作简直是脱裤子放屁 o(╥﹏╥)o 还是开裆裤的那种!

从文件开始上传就已经全在文件上传列表里了,不必再次 push,否则会在异步多文件上传过程中干扰原来的 fileList ,导致上传文件的 status 状态为 null,从而导致报错

方法一: 将 ...push(file) 注释,然后在删除文件前的回调函数中对实例中的 fileList 赋值就好了

方法二: 再定义一个文件上传列表 uploadList 用来存储上传成功的 files

使用​

<template>
<upload :limit="10" :file-size="100" :is-show-tip="false"/>
</template>
<script>
import Upload from "../file/Upload";
export default {
name: 'Example',
components: {Upload},
data() {
return {
}
},
methods: {
}
}
</script>

如何实现大文件分片上传​

平时在移动和客户端有普通的文件上传,但这种文件大多不大,从几 k 到几十兆,平时完全可以满足。但是对于有些终端系统(pc 端、移动端),有时候存在文件过大,如拍摄的高清视频,导出上传不了(内存过小或响应时间过长)的问题,用户体验及不佳。这里上传不了的原因是前端也是需要将文件加载到内存后再上传。但文件过大,内存过小时问题就麻烦了。针对这种情景,特提供文件分片上传的功能。不仅可以提高上传速率,而且对普通和大文件都适用。并且对于文件实现断点续传、秒传的功能。

解决思路

首先,前端制定分片的规则。比如对于手机移动端,当上传文件大于 10M 时,采用分片的方式上传,并切割一片就上传,不用等待每片响应的结果。

对于前端,当前端上传文件时,前端边加载的时候边分割文件,每分割一片就上传。如前端加载完 5M 就直接上传,完成上传动作后释放上传的那块内存。防止占用内存的问题, 减少了内存占用。而分片可以采用线程、异步的方式,缩短了上传的时间,也解决了用户等待时间过长的问题。

对于后端,每次上传的文件,获取文件的 md5 值,保存 md5 值至数据库中。对于完整的文件 md5 值,作为文件信息存储;对于分片文件的 md5 值,保存在分片信息中。当上传一个文件时,首先是根据完整的 md5 值查找是否有上传的记录,有则说明上传的文件有上传的记录,若成功过直接返回 url(文件秒传);没有成功过,但有上传记录,则有可能之前上传过部分,则需要继续上传未上传的文件(断点续传);没有则按照完整的流程上传。上传完成后,合并分片文件,更新并保存信息。

但是在开发的过长中,遇到几个问题:

①:对于文件 md5 值,前端如何获取到?因为文件 md5 值是通过文件中的内容确定的,每个不同的文件 md5 值是不一样的,而文件本身不可能加载全量文件再获取的。

②:如何判断文件是否全部上传完,并是否可以进行合并了?

③:上传的某片文件若出错了,怎么让该片文件重新上传?

④:合并文件时,如何保证合并的顺序?

针对上述问题,在开发的过程都一一解决了。对于

问题 ①:经过斟酌,做了一些取舍,舍弃了文件秒传的精确度。采用文件的属性(如文件名、类型、大小等) 加第一个分片的内容作为确定 md5 值;

问题 ②:在后端的表结构中,会记录这个文件以及这个分片文件的状态,前端也会告诉后端分了多少个文件。当上传一个分片时,会更新分片文件的状态,同时分片文件上传的数量会+1;当文件的状态已经成功并且上传成功的数量和需要上传的数量相同时就可以进行合并了。

问题 ③:在生成 md5 值后且在上传前,通过 md5 值去调用另外一个接口,获取上传信息,检测是否上传过。

问题 ④:每个上传的分片文件名和第几个分片都会记录下来,合并文件的时候按照这个顺序进行合并。


前言​

一个文件资源服务器,很多时候需要保存的不只是图片,文本之类的体积相对较小的文件,有时候,也会需要保存音视频之类的大文件。在上传这些大文件的时候,我们不可能一次性将这些文件数据全部发送,网络带宽很多时候不允许我们这么做,而且这样也极度浪费网络资源。

因此,对于这些大文件的上传,往往会考虑用到分片传输。

分片传输,顾名思义,也就是将文件拆分成若干个文件片段,然后一个片段一个片段的上传,服务器也一个片段一个片段的接收,最后再合并成为完整的文件。

下面我们来一起简单地实现以下如何进行大文件分片传输。

前端​

拆分上传的文件流​

首先,我们要知道一点:文件信息的 File 对象继承自 Blob 类,也就是说, File 对象上也存在 slice 方法,用于截取指定区间的 Buffer 数组。

通过这个方法,我们就可以在取得用户需要上传的文件流的时候,将其拆分成多个文件来上传:

<script setup lang='ts'>
import { ref } from "vue"
import { uploadLargeFile } from "@/api"

const fileInput = ref<HTMLInputElement>()

const onSubmit = () => {
// 获取文件对象
const file = onlyFile.value?.file;
if (!file) {
return
}

const fileSize = file.size; // 文件的完整大小
const range = 100 * 1024; // 每个区间的大小
let beginSide = 0; // 开始截取文件的位置

// 循环分片上传文件
while (beginSide < fileSize) {
const formData = new FormData()
formData.append(
file.name,
file.slice(beginSide, beginSide + range),
(beginSide / range).toString()
)
beginSide += range

uploadLargeFile(formData)
}
}
</script>

<template>
<input
ref="fileInput"
type="file"
placeholder="选择你的文件"
>
<button @click="onSubmit">提交</button>
</template>

我们先定义一个 onSubmit 方法来处理我们需要上传的文件。

在 onSubmit 中,我们先取得 ref 中的文件对象,这里我们假设每次有且仅有一个文件,我们也只处理这一个文件。

然后我们定义 一个 beginSide 和 range 变量,分别表示每次开始截取文件数据的位置,以及每次截取的片段的大小。

这样一来,当我们使用 file.slice(beginSide, beginSide + range) 的时候,我们就取得了这一次需要上传的对应的文件数据,之后便可以使用 FormData 封装这个文件数据,然后调用接口发送到服务器了。

接着,我们使用一个循环不断重复这一过程,直到 beginSide 超过了文件本身的大小,这时就表示这个文件的每个片段都已经上传完成了。当然,别忘了每次切完片后,将 beginSide 移动到下一个位置。

另外,需要注意的是,我们将文件的片添加到表单数据的时候,总共传入了三个参数。第二个参数没有什么好说的,是我们的文件片段,关键在于第一个和第三个参数。这两个参数都会作为 Content-Disposition 中的属性。

第一个参数,对应的字段名叫做 name ,表示的是这个数据本身对应的名称,并不区分是什么数据,因为 FormData 不只可以用作文件流的传输,也可以用作普通 JSON 数据的传输,那么这时候,这个 name 其实就是 JSON 中某个属性的 key 。

而第二个参数,对应的字段则是 filename ,这个其实才应该真正地叫做文件名。

我们可以使用 wireshark 捕获一下我们发送地请求以验证这一点。

wireshark.png

我们再观察上面构建 FormData 的代码,可以发现,我们 append 进 FormData 实例的每个文件片段,使用的 name 都是固定为这个文件的真实名称,因此,同一个文件的每个片,都会有相同的 name ,这样一来,服务器就能区分哪个片是属于哪个文件的。

而 filename ,使用 beginSide 除以 range 作为其值,根据上下文语意可以推出,每个片的 filename 将会是这个片的 序号 ,这是为了在后面服务端合并文件片段的时候,作为前后顺序的依据。

当然,上面的代码还有一点问题。

在循环中,我们确实是将文件切成若干个片单独发送,但是,我们知道, http 请求是异步的,它不会阻塞主线程。所以,当我们发送了一个请求之后,并不会等这个请求收到响应再继续发送下一个请求。因此,我们只是做到了将文件拆分成多个片一次性发送而已,这并不是我们想要的。

想要解决这个问题也很简单,只需要将 onSubmit 方法修改为一个异步方法,使用 await 等待每个 http 请求完成即可:

// 省略一些代码
const onSubmit = async () => {
// ......
while (beginSide < fileSize) {
// ......
await uploadLargeFile(formData);
}
};
// ......

这样一来,每个片都会等到上一个片发送完成才发送,可以在网络控制台的时间线中看到这一点:

timing.png

后端​

接收文件片段​

这里我们使用的 koa-body 来 处理上传的文件数据:

import Router = require("@koa/router")
import KoaBody = require("koa-body")
import { resolve } from 'path'
import { publicPath } from "../common";
import { existsSync, mkdirSync } from "fs"
import { MD5 } from "crypto-js"

const router = new Router()
const savePath = resolve(publicPath, 'assets')
const tempDirPath = resolve(publicPath, "assets", "temp")

router.post(
"/upload/largeFile",
KoaBody({
multipart: true,
formidable: {
maxFileSize: 1024 * 1024 * 2,
onFileBegin(name, file) {
const hashDir = MD5(name).toString()
const dirPath = resolve(tempDirPath, hashDir)
if (!existsSync(dirPath)) {
mkdirSync(dirPath, { recursive: true })
}
if (file.originalFilename) {
file.filepath = resolve(dirPath, file.originalFilename)
}
}
}
}),
async (ctx, next) => {
ctx.response.body = "done";
next()
}
)

我们的策略是先将同一个 name 的文件片段收集到以这个 name 进行 MD5 哈希转换后对应的文件夹名称的文件夹当中,但使用 koa-body 提供的配置项无法做到这么细致的工作,所以,我们需要使用自定义 onFileBegin ,即在文件保存之前,将我们期望的工作完成。

首先,我们拼接出我们期望的路径,并判断这个路径对应的文件夹是否已经存在,如果不存在,那么我们先创建这个文件夹。然后,我们需要修改 koa-body 传给我们的 file 对象。因为对象类型是引用类型,指向的是同一个地址空间,所以我们修改了这个 file 对象的属性, koa-body 最后获得的 file 对象也就被修改了,因此, koa-body 就能够根据我们修改的 file 对象去进行后续保存文件的操作。

这里我们因为要将保存的文件指定为我们期望的路径,所以需要修改 filepath 这个属性。

而在上文中我们提到,前端在 FormData 中传入了第三个参数(文件片段的序号),这个参数,我们可以通过 file.originalFilename 访问。这里,我们就直接使用这个序号字段作为文件片段的名称,也就是说,每个片段最终会保存到 ${tempDir}/${hashDir}/${序号} 这个文件。

由于每个文件片段没有实际意义以及用处,所以我们不需要指定后缀名。

合并文件片段​

在我们合并文件之前,我们需要知道文件片段是否已经全部上传完成了,这里我们需要修改一下前端部分的 onSubmit 方法,以发送给后端这个信号:

// 省略一些代码
const onSubmit = async () => {
// ......
while(beginSide < fileSize) {
const formData = new FormData()
formData.append(
file.name,
file.slice(beginSide, beginSide + range),
(beginSide / range).toString()
)

beginSide += range

// 满足这个条件表示文件片段已经全部发送完成,此时在表单中带入结束信息
if(beginSide >= fileSize) {
formData.append("over", file.name)
}

await uploadLargeFile(formData)
}
}
// ......
复制代码

为图方便,我们直接在一个接口中做传输结束的判断。判断的依据是:当 beiginSide 大于等于 fileSize 的时候,就放入一个 over 字段,并以这个文件的真实名称作为其属性值。

这样,后端代码就可以以是否存在 over 这个字段作为文件片段是否已经全部发送完成的标志:

router.post(
"/upload/largeFile",
KoaBody({
// 省略一些配置
}),
async (ctx, next) => {
if (ctx.request.body.over) {
// 如果 over 存在值,那么表示文件片段已经全部上传完成了
const _fileName = ctx.request.body.over;
const ext = _fileName.split(".")[1];
const hashedDir = MD5(_fileName).toString();
const dirPath = resolve(tempDirPath, hashedDir);
const fileList = readdirSync(dirPath);
let p = Promise.resolve(void 0);
fileList.forEach((fragmentFileName) => {
p = p.then(
() =>
new Promise((r) => {
const ws = createWriteStream(
resolve(savePath, `${hashedDir}.${ext}`),
{ flags: "a" }
);
const rs = createReadStream(resolve(dirPath, fragmentFileName));
rs.pipe(ws).on("finish", () => {
ws.close();
rs.close();
r(void 0);
});
})
);
});
await p;
}
ctx.response.body = "done";
next();
}
);

我们先取得这个文件真实名字的 hash ,这个也是我们之前用于存放对应文件片段使用的文件夹的名称。

接着我们获取该文件夹下的文件列表,这会是一个字符串数组(并且由于我们前期的设计逻辑,我们不需要在这里考虑文件夹的嵌套)。然后我们遍历这个数组,去拿到每个文件片段的路径,以此来创建一个读入流,再以存放合并后的文件的路径创建一个写入流(注意,此时需要带上扩展名,并且,需要设置 flags 为 'a' ,表示追加写入),最后以管道流的方式进行传输。

但我们知道,这些使用到的流的操作都是异步回调的。可是,我们保存的文件片段彼此之间是有先后顺序的,也就是说,我们得保证在前面一个片段写入完成之后再写入下一个片段,否则文件的数据就错误了。

要实现这一点,需要使用到 Promise 这一 api。

首先我们定义了一个 fulfilled 状态的 Promise 变量 p ,也就是说,这个 p 变量的 then 方法将在下一个微任务事件的调用时间点直接被执行。

接着,我们在遍历文件片段列表的时候,不直接进行读写,而是把读写操作放到 p 的 then 回调当中,并且将其封装在一个 Promsie 对象当中。在这个 Promise 对象中,我们把 resolve 方法的执行放在管道流的 finish 事件中,这表示,这个 then 回调返回的 Promise 实例,将会在一个文件片段写入完成后被修改状态。此时,我们只需要将这个 then 回调返回的 Promsie 实例赋值给 p 即可。

这样一来,在下个遍历节点,也就是处理第二个文件片段的时候,取得的 p 的值便是上一个文件片段执行完读写操作返回的 Promise 实例,而且第二个片段的执行代码会在第一个片段对应的 Promise 实例 then 方法被触发,也就是上一个片段的文件写入完成之后,再添加到微任务队列。

以此类推,每个片段都会在前一个片段写入完成之后再进行写入,保证了文件数据先后顺序的正确性。

当所有的文件片段读写完成后,我们就拿实现了将完整的文件保存到了服务器。

不过上面的还有许多可以优化的地方,比如:在合并完文件之后,删除所有的文件片段,节省磁盘空间;使用一个 Map 来保存真实文件名与 MD5 哈希值的映射关系,避免每次都进行 MD5 运算等等。但这里只是给出了简单的实习,具体的优化还请根据实际需求进行调整。

总结​

  • 使用 slice 方法可以截取 file 对象的片段,分次发送文件片段;
  • 使用 koa-body 保存每个文件片段到一个指定的暂存文件夹,在文件片段全部发送完成之后,将片段合并。