上一篇文章,我们把整个项目代码稍微调整优化了一遍,也实现了一个简单的博客系统的前后端功能的实现,对接和展示。
那么接下来呢,我们来实现文件上传功能,为我们后续的开发可以展示丰富图文的博客文章做好准备。
话不多说,来看看文件上传效果。
可以看到,当我们在网页中,点击选择文件,选择图片文件,就能马上把文件上传到我们项目中的 /public/uploads
目录下。
然后,在地址栏输入 http://127.0.0.1:3000/uploads/图片名称.png
,就能在浏览器看到我们刚才上传的文件了。
那么这是怎么做到的呢?请听我一一道来。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>博客管理后台 - 文件上传</title>
</head>
<body>
<div class="page-upload">
<input
type="file"
class="file"
/>
</div>
<script src="../utils.js"></script>
<script>
// 点击上传按钮
document.querySelector(".file").addEventListener("change", async (e) => {
const { code, msg } = await utilHttp("/api/tool/upload", e.target.files[0], "upload");
utilShowMsg(msg);
});
</script>
</body>
</html>
首先是前端部分,我新增了一个 /public/admin/upload.html
文件,专门用来上传图片。
然后,当我们的文件组件选择文件后,就会触发 change事件
,立刻将图片发送到 /api/tool/upload
接口。
至于为什么是将 e.target.files[0]
上传,请回想一下本小册编程思想:耳听为虚,眼见为实
。
打印一下变量 e
即可。
把上面两个图中,红色划线处的连起来就是:e.target.files[0]
,就找到了我们的图片了。
这也是我自己学习技术的核心方法:做实验
。
不要盲目地去猜,要用眼睛去看,去打印,去观察。
说实话,只要你理解了笔者的这个思路并付诸实践,写代码这个事情,将没有什么难度。
然后呢,有几处地方需要改动。
// 请求封装
const utilHttp = async (url, data, action = "common") => {
// 初始化参数
const params = {
method: "POST",
headers: {},
};
// 普通请求
if (action === "common") {
params.headers["Content-Type"] = "application/json;charset=utf-8";
params.body = JSON.stringify(data);
}
// 上传文件
if (action === "upload") {
const formData = new FormData();
formData.append("file", data);
params.body = formData;
}
// 收到的返回值
const response = await fetch(url, params);
// 把拿到的数据变成JSON结构
const result = await response.json();
return result;
};
请求封装要改造下,原来只支持普通请求,现在增加上传文件的逻辑。
这里上传文件,我们用 formData
来实现。
const server = createServer(async (req, res) => {
if (req.url.startsWith("/api/") === true) {
const apiName = req.url.replace("/api/", "");
res.setHeader("Content-Type", "application/json");
if (apiName === "tool/upload") {
// 文件上传
const result = await apiMaps[apiName].default(req);
res.end(JSON.stringify(result));
} else {
// 普通请求
if (apiMaps[apiName]) {
let body = "";
req.on("data", (chunk) => {
body += chunk.toString();
});
req.on("end", async () => {
try {
req.body = JSON.parse(body);
const result = await apiMaps[apiName].default(req);
res.end(JSON.stringify(result));
} catch (err) {
console.log("🚀 ~ req.on ~ err:", err);
res.end(
JSON.stringify({
code: 1,
msg: "请求参数结构有误",
})
);
}
});
} else {
res.end(
JSON.stringify({
code: 1,
msg: "接口不存在",
})
);
}
}
} else {
// 如果是资源
let staticFile = req.url.split("?")?.[0] || "";
if (staticFile === "/") {
staticFile = "/index.html";
}
if (existsSync(`./public/${staticFile}`) === true) {
res.setHeader("Content-Type", lookup(staticFile));
res.end(readFileSync(`./public/${staticFile}`));
} else {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("404 Not Found");
}
}
});
请看注释 文件上传
处,因为图片上传跟普通请求的传参不一样,所以要判断一下,分别处理。
import { writeFileSync } from "node:fs";
import { extname } from "node:path";
export default async (req) => {
return new Promise((resolve, reject) => {
let body = [];
req.on("data", (chunk) => {
body.push(chunk);
}).on("end", () => {
try {
body = Buffer.concat(body);
const contentType = req.headers["content-type"];
const boundary = contentType.split("; ")[1].split("=")[1];
// 查找文件数据的起始和结束位置
const fileDataStart = body.indexOf(`\r\n\r\n`, body.indexOf(`Content-Type:`)) + 4;
const fileDataEnd = body.indexOf(`\r\n--${boundary}--`) - 2;
const fileExt = extname(
body
.slice(0, fileDataStart)
.toString()
?.match(/filename="(.+?)"/)?.[1] || ""
);
// 提取文件数据
const fileData = body.slice(fileDataStart, fileDataEnd);
const fileName = Date.now() + fileExt;
// 写入文件
writeFileSync(`./public/uploads/${fileName}`, fileData);
resolve({
code: 0,
data: {
fileName: fileName,
},
msg: "文件上传成功",
});
} catch (err) {
console.log("🚀 ~ req.on ~ err:", err);
resolve({
code: 1,
msg: "文件上传失败",
});
}
});
});
};
然后就是后端接口部分,新增了一个 /apis/tool/upload.js
接口文件,用来接收上传的图片。
接下来,分析一下,图片上传的细节。
这是浏览器端,跟文件上传有关的内容。
与我们之前调用接口,上传 json数据
不同,这里上传的是 formData
,也就是表单数据。
图中,------WebKitFormBoundarySsMJmgoZBb2hfvNg
包裹的内容,就是我们上传的图片相关信息。
接下来,把目光聚焦到后端代码部分,文件的上传逻辑。
与接口一样,文件上传也是把数据放在 http 的 body 中进行上传,所以我们也要用 data 和 end 事件来进行监听数据的分段传出和传输结束事件。
否则的话,比如一个 100M 的文件,它不可能一次性传到服务器,带宽也收不了,而是一次传一段,一次传一段,在 data事件
中把这一段一段的数据,拼接到一起。
最后,在 end事件
触发的时候,文件数据就传输完毕了。
当我们上传一个图片时,打印的内容如上。也分别标记了数字,与上一个截图的数字一一对应。
接下来呢,我们看上一个截图的第 (5) 处和第 (6) 处,查找文件的起始位置和结束位置。
看起来有点绕,总而言之呢,就是把上图红框部分的真正的图片数据给拿出来。
拿到之后,干嘛呢?直接保存,完事。
图片上传,就这么简单。