Skip to content

上一篇文章,我们把整个项目代码稍微调整优化了一遍,也实现了一个简单的博客系统的前后端功能的实现,对接和展示。

那么接下来呢,我们来实现文件上传功能,为我们后续的开发可以展示丰富图文的博客文章做好准备。

话不多说,来看看文件上传效果。

文件上传效果演示

可以看到,当我们在网页中,点击选择文件,选择图片文件,就能马上把文件上传到我们项目中的 /public/uploads 目录下。

然后,在地址栏输入 http://127.0.0.1:3000/uploads/图片名称.png,就能在浏览器看到我们刚才上传的文件了。

那么这是怎么做到的呢?请听我一一道来。

html
<!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 即可。

打印1

打印2

把上面两个图中,红色划线处的连起来就是:e.target.files[0],就找到了我们的图片了。

这也是我自己学习技术的核心方法:做实验

不要盲目地去猜,要用眼睛去看,去打印,去观察。

说实话,只要你理解了笔者的这个思路并付诸实践,写代码这个事情,将没有什么难度。

然后呢,有几处地方需要改动。

js
// 请求封装
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 来实现。

js
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");
        }
    }
});

请看注释 文件上传 处,因为图片上传跟普通请求的传参不一样,所以要判断一下,分别处理。

js
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 接口文件,用来接收上传的图片。

接下来,分析一下,图片上传的细节。

图片上传1

图片上传2

图片上传3

这是浏览器端,跟文件上传有关的内容。

与我们之前调用接口,上传 json数据 不同,这里上传的是 formData,也就是表单数据。

图中,------WebKitFormBoundarySsMJmgoZBb2hfvNg 包裹的内容,就是我们上传的图片相关信息。

接下来,把目光聚焦到后端代码部分,文件的上传逻辑。

与接口一样,文件上传也是把数据放在 http 的 body 中进行上传,所以我们也要用 data 和 end 事件来进行监听数据的分段传出和传输结束事件。

否则的话,比如一个 100M 的文件,它不可能一次性传到服务器,带宽也收不了,而是一次传一段,一次传一段,在 data事件 中把这一段一段的数据,拼接到一起。

最后,在 end事件 触发的时候,文件数据就传输完毕了。

picture 7

当我们上传一个图片时,打印的内容如上。也分别标记了数字,与上一个截图的数字一一对应。

接下来呢,我们看上一个截图的第 (5) 处和第 (6) 处,查找文件的起始位置和结束位置。

picture 8

看起来有点绕,总而言之呢,就是把上图红框部分的真正的图片数据给拿出来。

拿到之后,干嘛呢?直接保存,完事。

图片上传,就这么简单。

何以解忧,唯有代码。不忘初心,方得始终。