环境版本:
- vue-cli@4.15.7
- element-ui@2.15.8
- axios@0.27.0
- egg@2.15.1
- chrome: 101.0.4951.54(正式版本) (64 位)
- 前端用 Vue2 element-ui 演示
- 后端用 Egg 演示
概要
发送api, 携带文字与文件的 2 种方案:
- 图片转 base64
- 利用
XMLHttpRequest与FormData - 利用
fetch与FormData
本篇文章介绍的是XMLHttpRequest与FormData方式
核心代码在前端submitForm方法中
核心原理看chrome调试工具Network
目标: 发送1次api请求, 后端接收文字与图片
发送携带信息:
avatar: png | jpg //头像
name: string //名字
sex: number //性别
address: string //地址
Web展示
上传表单:
前端传参:
请求头:
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary4qDxe4D0iD6K8HUg
修改表单回显数据:
PostMan展示
Egg展示
路由app/router.js
router.put('/students/:id', controller.student.studentUpdate);
控制层app/controller/student.js
async studentUpdate() {
const { ctx, service } = this;
console.log(`method---`, ctx.request.method)
console.log(`params----`, ctx.params)
console.log(`body---`, ctx.request.body)
console.log(`files---`, ctx.request.files)
ctx.body = {
msg: 'test update'
}
}
后端控制台打印:
用
ctx.request.body接收文字
用ctx.request.files接收图片
实现代码(重要)
前端代码 Vue2.js
核心代码是 submitForm 方法中的 new FormData() 和 new XMLHttpRequest() 对象
<template>
<div>
<div class="header flex-end">
<el-button type="primary" @click="handleClick">增加学生</el-button>
</div>
<el-table :data="tableData" style="width: 90%; margin: 0 auto;">
<el-table-column prop="id" label="ID" width="180" />
<el-table-column label="学生照片">
<template slot-scope="scope">
<el-image class="img-atr" :src="scope.row.avatar" />
</template>
</el-table-column>
<el-table-column prop="name" label="姓名" />
<el-table-column prop="sex" label="性别" :formatter="formatterSex" />
<el-table-column prop="address" label="地址" />
<el-table-column label="操作">
<template slot-scope="scope">
<el-button size="mini" @click="handleEdit(scope.$index, scope.row)">编辑</el-button>
<el-button size="mini" type="danger" @click="handleDelete(scope.$index, scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!--增加 及 编辑 对话框-->
<el-dialog :title="title" :visible.sync="dialogFormVisible">
<!--核心表单部分-->
<el-form :model="ruleForm" :rules="rules" ref="ruleForm" label-width="100px" class="demo-ruleForm">
<el-form-item label="学生照片" prop="avatar">
<!-- list-type="text" text/picture/picture-card -->
<!-- :request-upload="submitForm" -->
<el-upload
action="#"
:auto-upload="false"
ref="upload"
accept="image/*"
list-type="text"
:show-file-list="false"
:multiple="false"
:headers="headers"
:file-list="fileList"
:on-change="handleChange"
:on-success="handleSuccess"
>
<div class="flex-start">
<el-image class="img-atr" :src="ruleForm.avatar" />
<el-button slot="trigger" size="small" type="primary" :style="{'margin-left':'20px'}">选取文件</el-button>
<!--<el-button style="margin-left: 10px;" size="small" type="success" @click.stop="submitUpload">上传到服务器</el-button>-->
</div>
<div slot="tip" class="el-upload__tip">只能上传jpg/png文件,且不超过500kb</div>
</el-upload>
</el-form-item>
<el-form-item label="学生姓名" prop="name">
<el-input v-model="ruleForm.name"></el-input>
</el-form-item>
<el-form-item label="学生性别" prop="sex">
<el-radio v-model="ruleForm.sex" :label="1">男</el-radio>
<el-radio v-model="ruleForm.sex" :label="0">女</el-radio>
</el-form-item>
<el-form-item label="学生地址" prop="address">
<el-input v-model="ruleForm.address"></el-input>
</el-form-item>
</el-form>
<!--提交表格按钮-->
<div slot="footer" class="dialog-footer">
<el-button @click="dialogFormVisible = false">取 消</el-button>
<el-button type="primary" @click="submitForm('ruleForm')">确 定</el-button>
</div>
</el-dialog>
<!--删除对话框, 二次确认-->
<el-dialog title="提示" :visible.sync="dialogVisible" width="30%">
<span>删除后不可以恢复, 请问要删除吗?</span>
<span slot="footer" class="dialog-footer">
<el-button @click="dialogVisible = false">取 消</el-button>
<el-button type="primary" @click="handleDeleteClose">确 定</el-button>
</span>
</el-dialog>
</div>
</template>
<script>
import Cookies from 'js-cookie'
export default {
data() {
return {
//
tableData: [],
// 增加 及 编辑 变量...
isAddOrEdit : true, // 增加 或 编辑
dialogFormVisible: false,
headers : {
token: Cookies.get('token')
},
fileList : [],
ruleForm : {
avatar : 'https://cube.elemecdn.com/9/c2/f0ee8a3c7c9638a54940382568c9dpng.png',
name : '',
sex : '',
address: '',
},
rules : {
avatar : [{ required: true, message: '请上传学生头像', trigger: 'blur' },],
name : [{ required: true, message: '请输入学生姓名', trigger: 'blur' },],
sex : [{ required: true, message: '请选择学生性别', trigger: 'blur' },],
address: [{ required: true, message: '请输入学生地址', trigger: 'blur' },],
},
// 删除对话框,显示隐藏
dialogVisible: false,
}
},
computed: {
title() {
return this.isAddOrEdit ? '增加' : '编辑'
}
},
mounted() {
this.getStudentList();
},
methods: {
// 本文章核心代码部分: 增加 或 编辑 学生
submitForm(formName) {
this.$refs[formName].validate(async (valid) => {
if (valid) {
const formData = new FormData()
formData.append('avatar', this.fileList[0]?.raw || this.ruleForm.avatar)
formData.append('name', this.ruleForm.name)
formData.append('sex', this.ruleForm.sex)
formData.append('address', this.ruleForm.address)
// 核心代码!!! 用 javascript 原生方式请求
const xhr = new XMLHttpRequest();
xhr.onerror = function error(e) {
console.error('报错')
};
xhr.onload = () => {
if (xhr.status < 200 || xhr.status >= 300) {
console.error('返回不是预期')
return;
}
// 后端返回的结构体是: { code, msg, data }
const { code } = JSON.parse(xhr.responseText)
if (code === 200) {
this.dialogFormVisible = false; //关闭对话框
this.ruleForm = {
avatar : '',
name : '',
sex : '',
address: '',
}; //清除字段信息
this.resetForm(); //清空表单
this.$refs['upload']?.clearFiles() //清空已上传的文件列表(该方法不支持在 before-upload 中调用)
this.fileList.length = 0
this.$message({
type : 'success',
message: '操作成功',
})
this.getStudentList() //刷新列表
}
};
// 增加
if (this.isAddOrEdit) {
xhr.open('post', `/api/students`, true);
}
// 修改
else {
const { id } = this.ruleForm;
formData.append('id', id)
xhr.open('put', `/api/students/${ id }`, true);
}
// xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); //不会解析
// xhr.setRequestHeader('Content-Type', 'multipart/form-data'); //nodejs.Error: Multipart: Boundary not found
xhr.send(formData)
}
else {
console.log('error submit!!');
return false;
}
});
},
resetForm(formName = 'ruleForm') {
this.$refs[formName]?.resetFields();
},
// 本文章没用到此方法
submitUpload() {
this.$refs.upload.submit();
},
// 核心代码: 钩子方法: 处理 fileList
handleChange(file, fileList) {
console.log('handleChange', file, fileList)
this.fileList[0] = file;
this.ruleForm.avatar = URL.createObjectURL(file.raw)
},
handleSuccess(file, fileList) {
console.log('handleSuccess', file, fileList)
},
// 格式化性别
formatterSex(row, column, cellValue, index) {
// console.log(`---`, row, column, cellValue, index)
return cellValue === 0 ? '女' : '男'
},
// 获取学生列表
async getStudentList() {
const stuList = await this.$api.studentList()
// 后端返回的结构体是: { code, msg, data }
const { data, msg, code } = stuList;
if (code === 200) {
this.tableData = data;
}
// console.log(`stuList--`, stuList)
},
// 弹出增加学生对话框
handleClick() {
this.isAddOrEdit = true;
this.resetForm()
this.$refs['upload']?.clearFiles() //清空已上传的文件列表(该方法不支持在 before-upload 中调用)
this.fileList.length = 0
this.ruleForm = {
avatar : 'https://cube.elemecdn.com/9/c2/f0ee8a3c7c9638a54940382568c9dpng.png',
name : '',
sex : '',
address: '',
}; //清除字段信息
this.dialogFormVisible = true;
},
// 弹出编辑学生对话框
handleEdit(index, row) {
console.log('handleEdit----', index, row);
console.log(`handleEdit----fileList---`, this.fileList)
this.isAddOrEdit = false;
this.resetForm()
this.$refs['upload']?.clearFiles() //清空已上传的文件列表(该方法不支持在 before-upload 中调用)
this.fileList.length = 0
this.ruleForm = { ...row }
this.dialogFormVisible = true;
},
// 弹出删除学生对话框
handleDelete(index, row) {
console.log(index, row);
this.ruleForm = { ...row }
this.dialogVisible = true;
},
// 二次确认删除
async handleDeleteClose() {
const { code, data } = await this.$api.studentDelete(this.ruleForm.id)
if (code === 200) {
this.dialogVisible = false; // 关闭对话框
this.resetForm(); // 清空表单
this.$refs['upload']?.clearFiles() //清空已上传的文件列表(该方法不支持在 before-upload 中调用)
this.fileList.length = 0
this.getStudentList() // 刷新列表
}
},
}
}
</script>
<style scope>
.header {
padding: 10px 66px;
}
.img-atr {
width: 40px;
height: 40px;
border-radius: 100%;
overflow: hidden;
}
</style>
后端代码 Egg.js
配置: config/config.default.js
...
multipart : {
mode: 'file',
},
oss : {
client: {
accessKeyId : '马赛克马赛克马赛克',
accessKeySecret: '马赛克马赛克马赛克',
bucket : '马赛克马赛克马赛克',
endpoint : '马赛克马赛克马赛克',
timeout : '60s',
},
},
...
路由层: app/router.js
...
router.post('/students', controller.student.studentAdd); //添加
router.put('/students/:id', controller.student.studentUpdate); //修改
...
};
控制层: app/controller/student.js
主要看两个方法: async studentAdd 和 async studentUpdate
...
//添加
async studentAdd() {
const { ctx, service } = this;
const { avatar, name, sex, address } = ctx.request.body;
const [file] = ctx.request.files;
// console.log(`method---`, ctx.request.method)
// console.log(`params----`, ctx.params)
// console.log(`body---`, ctx.request.body)
// console.log(`files---`, ctx.request.files)
let result;
//如果有图片, 发送至阿里云oss. 也可以选择自家静态服务器
if (file) {
const avatarName = `egg-oss-demo/img-${ Date.now() }-${ Math.random().toString().slice(2) }-${ path.basename(file.filename) }`;
try {
result = await ctx.oss.put(avatarName, file.filepath);
}
finally {
await fs.unlink(file.filepath);
}
}
const bool = await service.student.studentAdd({
avatar: result?.url || avatar, //阿里云oss图片地址 或 base64字符串
name,
sex,
address
});
//反馈给前端结果
if (bool) {
ctx.body = {
code: 200,
msg : '增加学生成功'
};
}
else {
ctx.body = {
code: 400,
msg : '增加学生失败'
};
}
}
//修改
async studentUpdate() {
const { ctx, service } = this;
const { id } = ctx.params;
const { avatar, name, sex, address } = ctx.request.body;
const [file] = ctx.request.files;
// console.log(`method---`, ctx.request.method)
// console.log(`params----`, ctx.params)
// console.log(`body---`, ctx.request.body)
// console.log(`files---`, ctx.request.files)
let result;
//如果有图片, 发送至阿里云oss. 也可以选择自家静态服务器
if (file) {
const avatarName = `egg-oss-demo/img-${ Date.now() }-${ Math.random().toString().slice(2) }-${ path.basename(file.filename) }`;
try {
result = await ctx.oss.put(avatarName, file.filepath);
}
finally {
await fs.unlink(file.filepath);
}
}
const bool = await service.student.studentUpdate({
id,
avatar: result?.url || avatar, //阿里云oss图片地址 或 base64字符串
name,
sex,
address
});
if (bool) {
ctx.body = {
code: 200,
msg : '修改学生成功'
};
}
else {
ctx.body = {
code: 400,
msg : '修改学生失败'
};
}
}
...
数据层: app/service/student.js
...
//添加 avatar是阿里云oss地址
async studentAdd({ avatar = '', name, sex, address }) {
const { app } = this;
const sql = `insert into students (avatar, name, sex, address)
values ('${ avatar }', '${ name }', ${ sex }, '${ address }')`
const result = await app.mysql.query(sql);
return result.affectedRows === 1;
}
//修改 avatar是阿里云oss地址
async studentUpdate({ id, avatar = '', name, sex = -1, address } = {}) {
const sql = `update students
set avatar='${ avatar }',
name='${ name }',
sex=${ sex },
address='${ address }'
where id = ${ id } `
const result = await this.app.mysql.query(sql);
return result.affectedRows === 1;
}
...
核心原理是 new XMLHttpRequest() new FormData()
用原生的
XMLHttpRequest发送string类型与File类型
修改学生时发送的 http 请求:
组件
el-upload中
必须写:auto-upload="false"属性
action="#"属性随便写一个即可
没有:http-request="httpRequest"属性
// 利用 FormData 对象装载数据
const formData = new FormData();
formData.append(filename, File);
formData.append(key, value);
formData.append(key, value);
// javascript 原生请求
const xhr = new XMLHttpRequest();
// some code...
// 详细法定参见本文章前端代码 Vue.js 实现部分
// ...
// 实现数据提交
el-upload 用到的部分很少
如果你想, 你甚至可以使用原生的
<input type=file>来实现核心点就是
XMLHttpRequest表单数据使用
new FormData()对象中进行传递请求方式灵活, 可以使用 RESTful
也可以用 fetch new FormData()
const formData = new FormData();
const fileField = document.querySelector('input[type="file"]');
formData.append("username", "abc123");
formData.append("avatar", fileField.files[0]);
fetch("https://example.com/profile/avatar", {
method: "PUT",
body: formData,
})
.then((response) => response.json())
.then((result) => {
console.log("Success:", result);
})
.catch((error) => {
console.error("Error:", error);
});
文章参考于
1. <input type="file"> - HTML: HyperText Markup Language | MDN
2. FormData() - Web API 接口参考| MDN
3. XMLHttpRequest - Web API 接口参考| MDN
4. JavaScript高级程序设计(第3版)
5. element-ui@2.15.8 <el-upload ... > ... </el-upload> 源码
node_modules/element-ui/packages/upload/src/ajax.js
...
export default function upload(option) {
if (typeof XMLHttpRequest === 'undefined') {
return;
}
const xhr = new XMLHttpRequest();
const action = option.action;
if (xhr.upload) {
xhr.upload.onprogress = function progress(e) {
if (e.total > 0) {
e.percent = e.loaded / e.total * 100;
}
option.onProgress(e);
};
}
const formData = new FormData();
if (option.data) {
console.log(`option.data--`,option.data)
Object.keys(option.data).forEach(key => {
formData.append(key, option.data[key]);
});
}
formData.append(option.filename, option.file, option.file.name);
xhr.onerror = function error(e) {
option.onError(e);
};
xhr.onload = function onload() {
if (xhr.status < 200 || xhr.status >= 300) {
return option.onError(getError(action, option, xhr));
}
option.onSuccess(getBody(xhr));
};
xhr.open('post', action, true);
if (option.withCredentials && 'withCredentials' in xhr) {
xhr.withCredentials = true;
}
const headers = option.headers || {};
for (let item in headers) {
if (headers.hasOwnProperty(item) && headers[item] !== null) {
xhr.setRequestHeader(item, headers[item]);
}
}
xhr.send(formData);
return xhr;
}
踩坑记
文字信息添加到:data="uploadData"中, 使用 el-upload 组件自带方法上传
使用this.$refs['upload'].submit()而后将
文字信息写到:data="uploadData"中
文件类型写到:file-list="fileList"中
虽然 确认增加 与 确认修改 也能实现, 但写的时候遇到很多坑, 如:
- 拿到图片
src, 转成文件类型要解决跨域问题 - 编辑的时候图片回显需求, 你需要将回显的图片
src转换成 element-ui 定义的文件结构, 并放在:file-list="fileList"中, 需要有status: "ready"及uid: 1652037695212等, 很是繁琐, 不然点击确认修改(表单有必填校验), 没有反应或报错. (el-upload源码中有关于status及uid的判断 ) :data="uploadData"为Object类型, 写的时候不能修改uploadData内存地址, 即不能使用this.uploadData={ ...this.ruleForm }这样类似的语法, 很是麻烦
此图为el-upload 组件触发:on-change钩子时, 打印的文件结构:
- 源码中, 可以看到 el-upload 的请求方法是写死的
post请求, 首先是不够灵活, 也不能满足 RESTful 风格的 api
综合以上原因, 最终放弃该思路
关于 chrome 浏览器 http
请求头 Content-Type类型:
| Payload | Request Headers |
|---|---|
| Request Payload | Content-Type: application/json |
| Form Data | Content-Type: application/x-www-form-urlencoded |
| Form Data | Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryNvXralFFBXBRg16b |
后端 egg 中app/controller/student.js编写如下代码进行测试说明:
const { ctx, service } = this;
console.log(`method---`, ctx.request.method) // PUT
console.log(`params----`, ctx.params) // '2'
console.log(`body---`, ctx.request.body) // 看文章下面的打印结果
console.log(`files---`, ctx.request.files) // 看文章下面的打印结果
ctx.body = {
msg: 'test update'
}
请求头 'Content-Type': 'application/x-www-form-urlencoded'
vue中src/service/index.js
import request from '@/utils/request.js'
...
//修改学生信息
studentUpdate(id, formData) {
return request({ // axios 实例
method : 'put',
url : `/students/${ id }`,
headers: {
'Content-Type': `application/x-www-form-urlencoded;charset=utf-8` // 设置请求头
},
data : formData
})
},
...
请求类型:
可以把数据传递这去:
后端可以在ctx.request.body拿到数据, 但是这样的结构不是我想要的
egg 控制台输出:
method--- PUT
params---- { id: '2' }
body--- {
'------WebKitFormBoundary0Z1jbcw85KbnADQC\r\nContent-Disposition: form-data; name': '"avatar"; filename="user.jpg"\r\n' +
'Content-Type: image/jpeg\r\n' +
'\r\n' +
'����\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00��\x00�\x00\t\x06\x07\b\x07\x06\t\b\x07\b\n' +
'\n' +
'\t\x0B\r\x16\x0F\r\f\f\r\x1B\x14\x15\x10\x16 \x1D"" \x1D\x1F\x1F$(4,$',
"1'\x1F\x1F-": '-157:::# ?D?8C49:7\x01\n' +
'\n' +
'\n' +
'\r\f\r\x1A\x0F\x0F\x1A7%\x1F%77777777777777777777777777777777777777777777777777��\x00\x11\b\x00�\x00�\x03\x01"\x00\x02\x11\x01\x03\x11\x01��\x00\x1C\x00\x00\x02\x03\x01
\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x04\x05\x02\x03\x06\x07\x01\x00\b��\x00D\x10\x00\x02\x01\x03\x03\x01\x06\x03\x05\x04\x07\x06\x07\x01\x00\x00\x01\x02\x03\x00\x04
\x11\x05\x12!1\x06\x13"AQq2a�\x14#B��\x15R��\x073S����\x16Ubcr�$CT����5��\x00\x19\x01\x00\x03\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x03\x04\x
01\x00\x05��\x00#\x11\x00\x02\x02\x03\x01\x00\x02\x03\x01\x01\x01\x00\x00\x00\x00\x00\x00\x01\x02\x11\x03\x12!1\x13A\x04"2aQ\x14��\x00\f\x03\x01\x00\x02\x11\x03\x11\x00?\x00�
�U��7vS���\x1F\x11<p*q"����9湜|Ǽ�\x12���yW��2٢D��e�����s�8P?�}cl�\x0E"RC�\x02��Q��u���\x04*�~#�1�4�D�o��J�FT\x1C\x13�\x1A��]6X;��e�
8,\x01\x01��:��R�ϴM4�˸����ڳx�5\x17ݱV�5��8a�p#\x00\x17��}��W�a�\x1Da�\x1C�.蘎�q�\x1E���XO�\x12�\x009�0��nfS"�l^�\x1A�\x16cȑ�m5{;�9f�F���\x
12��`���`c\x00��\x1F㊕�=�-�I"\\3���A���u�=���W.\x16M�P\x0E9�<�����ִ\x1B��\x19`�\x0F\x1F\x00cw�\x18#��ir�0�������0�M��2m�o(�=E5�_�
�\x03��i,�O\x13\x16�~U\x7Fwo{�1��T�\x12 P�\b�\x0B�@�\x17w\x10Y�Qd�|�',
'3��\x1A\x': { ...内容太多作者给省略了... \r\n' +
'------WebKitFormBoundary0Z1jbcw85KbnADQC\r\n' +
'Content-Disposition: form-data; name="name"\r\n' +
'\r\n' +
'李世民\r\n' +
'------WebKitFormBoundary0Z1jbcw85KbnADQC\r\n' +
'Content-Disposition: form-data; name="sex"\r\n' +
'\r\n' +
'0\r\n' +
'------WebKitFormBoundary0Z1jbcw85KbnADQC\r\n' +
'Content-Disposition: form-data; name="address"\r\n' +
'\r\n' +
'大唐\r\n' +
'------WebKitFormBoundary0Z1jbcw85KbnADQC\r\n' +
'Content-Disposition: form-data; name="id"\r\n' +
'\r\n' +
'2\r\n' +
'------WebKitFormBoundary0Z1jbcw85KbnADQC--\r\n'
}
files--- undefined
egg 能在
this.ctx.request.body中拿到数据在
this.ctx.request.files中拿不到数据, 打印结果也是undefined但数据结构不是理想中的, 需要解析, 不够完美
请求头 'Content-Type': 'multipart/form-data'
vue中src/service/index.js
import request from '@/utils/request.js'
...
//修改学生信息
studentUpdate(id, formData) {
return request({ // axios 实例
method : 'put',
url : `/students/${ id }`,
headers: {
'Content-Type': `multipart/form-data` // 设置请求头
},
data : formData
})
},
...
后端报 500 错误:
虽然 Payload 中也能把相关信息带过去:
egg 控制台报错:nodejs.Error: Multipart: Boundary not found
2022-05-09 02:40:06,957 ERROR 23248 [-/127.0.0.1/-/2ms PUT /students/11] nodejs.Error: Multipart: Boundary not found
... ...
...
请求头 'Content-Type': `multipart/form-data; boundary=----${ Date.now() }`
只写multipart/form-data报错提示找不到, 那就在后面加个时间戳.
vue中src/service/index.js
import request from '@/utils/request.js'
...
//修改学生信息
studentUpdate(id, formData) {
return request({ // axios 实例
method : 'put',
url : `/students/${ id }`,
headers: {
'Content-Type': `multipart/form-data; boundary=----${ Date.now() }` // 加个时间戳
},
data : formData
})
},
...
请求头中 Content-Type 加上了时间戳:
Payload 里没有数据:
加完后虽然 egg 不报错, 但也拿不到数据:
写在后面
文章写于: 2022年5月8日, 周日
企业级应用需要做进一步做优化迭代
文章写完时已是: 2022年05月09日, 凌晨03:47, 于 掘金社区.
juejin.cn/post/709544…