問題現象
當用戶上傳包含中文字符的文件時,在服務器端獲取到的文件名可能變成類似?
?μ?èˉ???????.txt
?這樣的亂碼,而不是預期的中文文件名。
為什么只有Node會亂碼?
- 很多后端框架(如 Java Spring Boot、Python Django、PHP Laravel)為了簡化開發,在底層已經處理了 “編碼不匹配” 問題,開發者感知不到。
- Node.js 的核心特點是 “輕量、原生模塊僅提供基礎能力,不做過度封裝”,這導致它沒有默認解決編碼問題,需要開發者手動處理。
問題根源
首先了解 HTTP 協議和 Node.js 處理請求的方式:
HTTP 協議的歷史遺留問題:早期的 HTTP 協議主要設計用于傳輸英文內容,默認采用?
latin1
(ISO-8859-1)編碼。表單提交的編碼方式:當通過?
multipart/form-data
?格式上傳文件時,瀏覽器會使用?latin1
?編碼來傳輸文件名等元數據,即使其中包含非拉丁字符。Node.js 的默認處理:Node.js 在解析請求時,默認會將這些?
latin1
?編碼的數據直接轉換為字符串,而?latin1
?無法正確表示中文字符,從而導致亂碼。
簡單來說,中文文件名被瀏覽器以?
latin1
?編碼傳輸,但 Node.js 沒有正確解碼,導致了亂碼現象。
latin1
?是一種適合西歐語言的單字節編碼,因歷史原因成為早期互聯網的默認編碼,也因此導致了中文等多字節字符在傳輸中的亂碼問題
解決方案
一、接收前端傳遞:解決這個問題的關鍵在于正確地解碼文件名。我們可以使用 Node.js 的 Buffer 類來實現這一轉換:
// 將亂碼的文件名轉換為正確的中文
const correctFilename = Buffer.from(originalname, "latin1").toString("utf8");
第一步:Buffer.from (originalname, "latin1")
originalname
?是從請求中獲取的原始文件名(已被錯誤解碼為亂碼)- 第二個參數?
"latin1"
?表示:把亂碼的字符串按照?latin1
?編碼重新轉換為字節序列 - 這一步的作用是還原瀏覽器發送時的原始字節數據
第二步:.toString ("utf8")
- 將上一步得到的原始字節序列,用正確的編碼(
utf8
)重新解碼為字符串 - 這一步會把之前被拆分為單字節的中文字符重新組合為正確的多字節表示
為什么這樣有效?
latin1
?編碼的特性是:每個字符都直接對應一個字節(0-255),不會丟失信息- 即使原始字符是 UTF-8 編碼,用?
latin1
?解碼成亂碼后,依然可以通過反向操作還原 - 這是一種 "-lossless"(無損失)的轉換方式,專門用于修復此類編碼不匹配問題
二、返回前端響應:同時,為了確保服務器返回的響應中中文能正確顯示,我們需要設置響應頭的字符集
告訴瀏覽器:“我(服務器)成功處理了你的請求,接下來會返回一段 HTML 格式的內容,并且這段內容是用 UTF-8 編碼的,請你用 HTML 規則渲染、用 UTF-8 解碼,確保界面正常顯示且中文不亂碼”。
// 設置響應頭,確保中文正常顯示
res.writeHead(200, {'Content-Type': 'text/html;charset=utf-8'});
建議放置到全局中間件
// 全局編碼處理中間件
app.use((req, res, next) => {// 設置響應頭確保UTF-8編碼res.setHeader('Content-Type', 'application/json; charset=utf-8');// 處理請求中的文件名編碼問題if (req.headers['content-type'] && req.headers['content-type'].includes('multipart/form-data')) {// 對于multipart/form-data請求,確保正確處理文件名編碼req.setEncoding = 'utf8';}next();
});
完整示例(原生Nodejs示列)
const http = require('http');
const fs = require('fs');
const path = require('path');// 創建服務器
const server = http.createServer((req, res) => {// 處理 GET 請求 - 顯示上傳表單if (req.method === 'GET') {res.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' });res.end(`<form method="POST" enctype="multipart/form-data"><input type="file" name="file" /><button type="submit">上傳文件</button></form>`);return;}// 處理 POST 請求 - 處理文件上傳if (req.method === 'POST' && req.headers['content-type'].startsWith('multipart/form-data')) {// 獲取分隔符const boundary = req.headers['content-type'].split('; ')[1].split('=')[1];let fileName = '';let fileData = [];let isFilePart = false;// 接收數據req.on('data', (chunk) => {// 轉換為字符串用于解析文件名(臨時用 latin1)const chunkStr = chunk.toString('latin1');// 提取并處理文件名(核心解決方案)if (!fileName && chunkStr.includes('filename="')) {const match = chunkStr.match(/filename="(.*?)"/);if (match && match[1]) {// 關鍵步驟:修復中文文件名亂碼// 將 latin1 編碼的文件名重新解碼為 utf8fileName = Buffer.from(match[1], 'latin1').toString('utf8');}}// 收集文件內容if (fileName && !isFilePart && chunkStr.includes('\r\n\r\n')) {isFilePart = true;const start = chunkStr.indexOf('\r\n\r\n') + 4;fileData.push(chunk.slice(start - chunk.length));} else if (isFilePart && !chunkStr.includes(`--${boundary}--`)) {fileData.push(chunk);}});// 數據接收完成,保存文件req.on('end', () => {if (!fileName) {res.writeHead(400, { 'Content-Type': 'text/html;charset=utf-8' });return res.end('未找到文件');}// 合并并清理文件內容const fileBuffer = Buffer.concat(fileData);const endIndex = fileBuffer.lastIndexOf(Buffer.from(`--${boundary}--`));const cleanData = endIndex > 0 ? fileBuffer.slice(0, endIndex - 2) : fileBuffer;// 保存文件const savePath = path.join(__dirname, 'uploads', fileName);fs.writeFile(savePath, cleanData, (err) => {// 關鍵:設置響應編碼為 utf8,確保返回中文正常顯示res.writeHead(err ? 500 : 200, { 'Content-Type': 'text/html;charset=utf-8' });res.end(err ? '上傳失敗' : `文件上傳成功: ${fileName}`);});});}
});// 啟動服務器
const PORT = 3000;
server.listen(PORT, () => {console.log(`服務器運行在 http://localhost:${PORT}`);// 創建上傳目錄if (!fs.existsSync('./uploads')) fs.mkdirSync('./uploads');
});