後端 i18n 的三層問題
第一層:資料儲存——時間用 UTC、金額用整數分、語言碼標準化。這是資料的問題,設計時就要做對,之後改很痛。
第二層:Response 格式化——時間轉用戶時區、數字加千分位、貨幣加符號。這是輸出的問題,通常在 Serializer 層做。
第三層:翻譯文字——錯誤訊息、email 內容、通知文字。這是內容的問題,要有翻譯管理機制。
時區:永遠用 UTC 存,轉換在邊界做
最常見的 i18n bug 都跟時區有關。
// ❌ 存本地時間
const order = await Order.create({
createdAt: new Date().toLocaleString('zh-TW'), // '2026/4/22 上午10:30:00'
});
// 問題:這個字串沒有時區資訊,跨時區的系統永遠算不對
// ✅ 存 UTC,交給 DB
const order = await Order.create({
// DB 欄位是 TIMESTAMPTZ,自動存 UTC
// new Date() 就是 UTC,直接存
});PostgreSQL 的 TIMESTAMPTZ vs TIMESTAMP:
TIMESTAMP:不帶時區資訊,存什麼拿什麼TIMESTAMPTZ:帶時區資訊,存入時自動轉 UTC,讀出時轉 session 時區
永遠用 TIMESTAMPTZ,session 設為 UTC,轉換在應用層做。
在 API Response 轉時區:
// 從 request header 或 user 設定拿時區
function formatDateForUser(date: Date, timezone: string): string {
return new Intl.DateTimeFormat('zh-TW', {
timeZone: timezone,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
}).format(date);
}
// Serializer 層做轉換
class OrderSerializer {
static toResponse(order: Order, userTimezone: string) {
return {
id: order.id,
createdAt: order.createdAt.toISOString(), // 給前端的 ISO 8601(帶 Z,UTC)
createdAtDisplay: formatDateForUser(order.createdAt, userTimezone), // 給人看的本地時間
};
}
}前端負責 display 格式化是更乾淨的做法——API 只回 ISO 8601 UTC,前端用 Intl.DateTimeFormat 格式化成用戶時區。後端不用管顯示格式。
金額:整數分,不用 float
// ❌ float 有精度問題
const price = 19.99;
const tax = price * 0.1;
console.log(tax); // 1.9999999999999998,不是 2.00
// ✅ 整數分(分 / 分錢 / 最小單位)
const priceInCents = 1999; // 19.99 TWD = 1999 分
const taxInCents = Math.round(priceInCents * 0.1); // 200
// DB 也存整數
amount INTEGER NOT NULL, -- 單位:分(最小貨幣單位)
currency VARCHAR(3) NOT NULL, -- 'TWD', 'USD', 'JPY'格式化在 Serializer 做:
function formatCurrency(amountInCents: number, currency: string, locale: string): string {
const amount = amountInCents / 100; // 大部分貨幣是分
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
}).format(amount);
}
// 'zh-TW', TWD, 1999 → 'NT$19.99'
// 'en-US', USD, 1999 → '$19.99'
// 'ja-JP', JPY, 500 → '¥500'(日圓沒有分)翻譯文字管理
i18next(Node.js 標準)
import i18next from 'i18next';
import Backend from 'i18next-fs-backend';
await i18next
.use(Backend)
.init({
lng: 'zh-TW',
fallbackLng: 'en',
backend: {
loadPath: './locales/{{lng}}/{{ns}}.json',
},
ns: ['common', 'errors', 'emails'],
defaultNS: 'common',
});
// 翻譯 key
i18next.t('errors.user_not_found');
i18next.t('emails.order_confirmation.subject', { orderId: 'ORD-123' });翻譯 key 的命名慣例:
// locales/zh-TW/errors.json
{
"user_not_found": "找不到用戶",
"insufficient_stock": "庫存不足,目前僅剩 {{available}} 件",
"invalid_token": "登入憑證無效或已過期"
}
// locales/en/errors.json
{
"user_not_found": "User not found",
"insufficient_stock": "Insufficient stock, only {{available}} left",
"invalid_token": "Invalid or expired token"
}從 Request 決定語系
import i18nextMiddleware from 'i18next-http-middleware';
import i18next from 'i18next';
app.use(i18nextMiddleware.handle(i18next));
// middleware 注入後,req.t 可以直接用
router.get('/products/:id', async (req, res) => {
const product = await productService.findById(req.params.id);
if (!product) {
return res.status(404).json({
error: req.t('errors.product_not_found'),
});
}
res.json(product);
});語系偵測順序(i18next-http-middleware 的預設):
?lng=zh-TWquery paramAccept-Language: zh-TWheader- Cookie
- fallback to default
翻譯 Key 的常見問題
複數形式:
{
"items_count": "{{count}} 件商品",
"items_count_plural": "{{count}} 件商品"
}中文的複數跟英文不一樣——英文 1 item / 2 items,中文不變形。但用 count_plural 是 i18next 的慣例,不同語言會自動選對的形式:
i18next.t('items_count', { count: 1 }); // '1 件商品'
i18next.t('items_count', { count: 10 }); // '10 件商品'
// 英文版:'1 item' / '10 items'(自動選 plural form)翻譯 key 不要用中文:
// ❌ 用中文 key
{ "找不到用戶": "找不到用戶" }
// ✅ 用語意化英文 key
{ "user_not_found": "找不到用戶" }中文 key 在 code 裡很難維護、grep 困難、CI/CD 工具也不一定支援。
多語系的 DB 內容
應用層文字(錯誤訊息、Email 模板)放 locale 檔案;DB 裡的用戶內容(商品名稱、描述)有不同做法:
方案一:每個語系一個欄位(小量語系):
CREATE TABLE products (
id UUID PRIMARY KEY,
name_zh_tw VARCHAR(255),
name_en VARCHAR(255),
description_zh_tw TEXT,
description_en TEXT
);簡單,但新增語系要改 schema。
方案二:獨立翻譯表(多語系):
CREATE TABLE product_translations (
product_id UUID REFERENCES products(id),
locale VARCHAR(10) NOT NULL, -- 'zh-TW', 'en', 'ja'
name VARCHAR(255) NOT NULL,
description TEXT,
PRIMARY KEY (product_id, locale)
);// 查詢時 join 翻譯表
async findById(id: string, locale: string) {
const product = await Product.findOne({
where: { id },
include: [{
model: ProductTranslation,
where: { locale },
required: false, // 沒有這個語系時不回傳空
}],
});
// fallback 到預設語系
if (!product.translation) {
product.translation = await ProductTranslation.findOne({
where: { productId: id, locale: 'zh-TW' },
});
}
return product;
}常見踩坑
排序規則(Collation):ORDER BY name 在不同語系下排序不同。DB 建立時要設定 collation:
-- PostgreSQL
CREATE DATABASE mydb WITH LC_COLLATE = 'zh_TW.UTF-8';
-- 或欄位層級
name VARCHAR(255) COLLATE "zh-TW-x-icu"字元集:永遠用 utf8mb4(MySQL)或 UTF-8(PostgreSQL)。utf8(MySQL)不支援 emoji(4-byte UTF-8)。
數字格式:小數點在不同文化是 . 或 ,。API 回傳數字用 number type(不是字串),讓前端負責格式化:
// ❌ 後端格式化數字(前端沒法再處理)
{ "price": "1,999.99" }
// ✅ 後端回數字,前端格式化
{ "price": 199999, "currency": "TWD" } // 整數分