i18n(多语言 + 日期/数字/货币本地化)
一层薄薄的 i18n 缝:一份按 locale 分好的字典 + 一个把 active locale 放在 React state 的 I18nProvider + 一个 t(key, vars?)。UI 字符串走 t(),日期、数字、货币一律交给 Intl.* —— locale 一变,整页跟着刷新。
试这几下:
- 点右上角中文 / English —— 顶栏、卡片、表头、日期、金额一次性刷新,右下角 toast 提示
- 切到 English 看 promo 那一行 —— 英文字典里故意没这条 key,回落到中文并显示灰色「缺译回退 · zh」小徽标
- 日期在中文下是
2026年7月6日 09:14,英文下是Jul 6, 2026, 09:14;货币中文是¥1,234.56,英文换算成$172.67 - 刷新 iframe,语言不丢(写在
localStorage["demo-i18n.locale"]);点恢复默认先弹确认,确认后清空偏好回到中文
规矩
- active locale 只放在 state 里:
useState初值函数里typeof window !== "undefined"后再读 localStorage,模块顶层不碰window(SSR 安全)。写入放到useEffect里 - 字典键 en/zh 对齐:真实版靠
satisfies Record<Locale, …>+TranslationKey类型让漏译在编译期就冒出来;demo 没类型,靠一个missing(key)助手把回退可视化 - 回退链固定:
dict[key] ?? dictionaries[defaultLocale][key] ?? key。回退目标永远是声明期的defaultLocale,不受 provider 的初始 locale 影响 - 插值只支持
{var},值String()一下;不做复数、性别、ICU MessageFormat —— 那是 i18next / vue-i18n 的活 - 日期 / 数字 / 货币走 Intl.*:数据里存 ISO 时间 + 基准货币金额,渲染时用当前 locale 的 formatter 出结果;不在字典里塞
"¥{amount}"这种模板 - 真实版与 demo 的差:demo 是自造字典 +
createContext;真实版换成i18next/vue-i18n,多语言拆成按需加载的 JSON。持久化 demo 用localStorage,真实版初始 locale 通常读Accept-Languageheader 或 cookie(SSR 场景必须在服务端就定住,否则会 hydration 闪烁)
蓝本:
add-i18n.md(open-dashboard @aa9815f,MIT,Invariants 已消化进上面「规矩」)
demo 源码:assets/demo-i18n.html(自包含、未压缩)
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>形状 demo:i18n</title>
<!--
demo-i18n.html —— 「i18n」形状的最小可玩实现(蓝本:open-dashboard 的 I18nProvider + useTranslation)。
复现的不变量:
- active locale 只放在组件 state(SSR-safe,模块加载时不碰 window)
- 字典 en/zh 键要对齐;缺译时回退到 defaultLocale 字典 → 再回退到 key 本身
- 插值只支持 {var},值 String() 一下
- 日期 / 数字 / 货币走 Intl.*,不硬编码格式;locale 变了整页跟着刷新
- 用户偏好持久化到 localStorage,但读写都在 useState 初始化 / useEffect 里
运行时:/vendor 的 React 18 UMD + htm(免构建),样式共用同级 demo.css。
注:demo 的 defaultLocale 用了 zh,方便演示英文缺译回落到中文;真实版通常反过来(en 兜底)。
-->
<link rel="stylesheet" href="demo.css">
<style>
.miss { margin-left: 6px; padding: 1px 6px; border-radius: 999px;
background: var(--chip-off-bg); color: var(--chip-off-fg);
font-size: 11px; vertical-align: middle; }
code { background: var(--chip-off-bg); color: var(--chip-off-fg);
padding: 1px 6px; border-radius: 4px; font-size: 12px; }
</style>
</head>
<body>
<div id="root"></div>
<script src="/vendor/react.production.min.js"></script>
<script src="/vendor/react-dom.production.min.js"></script>
<script src="/vendor/htm.umd.js"></script>
<script>
"use strict";
const { useState, useMemo, useEffect, useCallback, useRef, createContext, useContext } = React;
const html = htm.bind(React.createElement);
const DEFAULT_LOCALE = "zh";
const STORAGE_KEY = "demo-i18n.locale";
const dictionaries = {
zh: {
"app.title": "商店后台",
"app.subtitle": "切右上角语言:日期 / 数字 / 货币会一起刷新",
"greeting.hello": "你好,{name}",
"greeting.welcome": "欢迎回到 {app}",
"stats.revenue": "本月营收",
"stats.orders": "订单数",
"stats.aov": "客单价",
"table.title": "最新订单",
"table.order": "订单号",
"table.customer": "客户",
"table.date": "下单时间",
"table.total": "金额",
"toast.switched": "语言已切换至 {label}",
"confirm.reset.title": "恢复默认语言?",
"confirm.reset.body": "本地保存的语言偏好会被清空,页面回到中文。",
"action.reset": "恢复默认",
"promo.tagline": "全场包邮 · 会员日双倍积分",
},
en: {
"app.title": "Store Admin",
"app.subtitle": "Switch language (top right): dates / numbers / currency refresh together",
"greeting.hello": "Hi, {name}",
"greeting.welcome": "Welcome back to {app}",
"stats.revenue": "Revenue MTD",
"stats.orders": "Orders",
"stats.aov": "AOV",
"table.title": "Recent orders",
"table.order": "Order",
"table.customer": "Customer",
"table.date": "Placed at",
"table.total": "Total",
"toast.switched": "Language switched to {label}",
"confirm.reset.title": "Reset language?",
"confirm.reset.body": "The saved language preference will be cleared and the page will fall back to Chinese.",
"action.reset": "Reset",
// promo.tagline 故意留空 —— 演示缺译回退到 defaultLocale (zh)
},
};
const LOCALES = [
{ value: "zh", label: "中文", currency: "CNY", bcp47: "zh-CN" },
{ value: "en", label: "English", currency: "USD", bcp47: "en-US" },
];
// demo 用固定汇率演示货币格式差异;真实版走服务端拿现价
const RATE = { CNY: 1, USD: 1 / 7.15 };
const ORDERS = [
{ id: "SO-24019", customer: "陈禾", iso: "2026-07-06T09:14:00Z", cny: 1234.56 },
{ id: "SO-24018", customer: "李慕白", iso: "2026-07-05T18:02:00Z", cny: 899.00 },
{ id: "SO-24017", customer: "王小满", iso: "2026-07-05T11:47:00Z", cny: 4288.20 },
{ id: "SO-24016", customer: "赵青", iso: "2026-07-04T22:31:00Z", cny: 156.90 },
{ id: "SO-24015", customer: "周芷", iso: "2026-07-04T08:05:00Z", cny: 2020.00 },
];
function interpolate(tpl, vars) {
if (!vars) return tpl;
return tpl.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m));
}
const I18nCtx = createContext(null);
function I18nProvider({ children }) {
// SSR-safe:初值函数里 typeof window 检查后再读;模块顶层不碰 window
const [locale, setLocale] = useState(() => {
try {
const saved = typeof window !== "undefined" && window.localStorage.getItem(STORAGE_KEY);
if (saved && dictionaries[saved]) return saved;
} catch (e) { /* localStorage 可能被禁 */ }
return DEFAULT_LOCALE;
});
// 持久化:副作用里写,SSR 期跳过
useEffect(() => {
try { window.localStorage.setItem(STORAGE_KEY, locale); } catch (e) { /* 忽略 */ }
}, [locale]);
const t = useCallback((key, vars) => {
const dict = dictionaries[locale];
const fb = dictionaries[DEFAULT_LOCALE];
const tpl = dict[key] ?? fb[key] ?? key;
return interpolate(tpl, vars);
}, [locale]);
// 判断某 key 当前是否走了回退(用于「缺译回退」小灰提示)
const missing = useCallback(
(key) => !(key in dictionaries[locale]) && (key in dictionaries[DEFAULT_LOCALE]),
[locale]);
const value = useMemo(() => ({ locale, setLocale, t, missing }), [locale, t, missing]);
return html`<${I18nCtx.Provider} value=${value}>${children}<//>`;
}
function useT() {
const ctx = useContext(I18nCtx);
if (!ctx) throw new Error("useT 必须在 I18nProvider 内使用");
return ctx;
}
function Confirm({ title, text, onOk, onCancel }) {
return html`
<div class="overlay" onClick=${e => { if (e.target === e.currentTarget) onCancel(); }}>
<div class="modal">
<h2>${title}</h2>
<p style=${{ margin: 0 }}>${text}</p>
<div class="actions">
<button class="btn" onClick=${onCancel}>取消</button>
<button class="btn primary" onClick=${onOk}>确认</button>
</div>
</div>
</div>`;
}
function LocaleSwitch() {
const { locale, setLocale } = useT();
return html`
<div class="seg" role="group" aria-label="语言">
${LOCALES.map(L => html`
<button key=${L.value} class=${L.value === locale ? "on" : ""}
onClick=${() => setLocale(L.value)}>${L.label}</button>`)}
</div>`;
}
function App() {
const { locale, setLocale, t, missing } = useT();
const [toast, setToast] = useState("");
const [confirm, setConfirm] = useState(null);
const prev = useRef(locale);
// locale 变化 → toast(首屏不出)
useEffect(() => {
if (prev.current !== locale) {
const label = LOCALES.find(L => L.value === locale)?.label ?? locale;
setToast(t("toast.switched", { label }));
prev.current = locale;
}
}, [locale, t]);
useEffect(() => {
if (!toast) return;
const h = setTimeout(() => setToast(""), 2200);
return () => clearTimeout(h);
}, [toast]);
const conf = LOCALES.find(L => L.value === locale);
// Intl formatter:locale 变了整套重建
const fmt = useMemo(() => ({
date: new Intl.DateTimeFormat(conf.bcp47, {
year: "numeric",
month: locale === "zh" ? "long" : "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}),
money: new Intl.NumberFormat(conf.bcp47, { style: "currency", currency: conf.currency }),
num: new Intl.NumberFormat(conf.bcp47),
}), [locale, conf.bcp47, conf.currency]);
const revenueCny = ORDERS.reduce((s, o) => s + o.cny, 0);
const revenue = revenueCny * RATE[conf.currency];
const aov = revenue / ORDERS.length;
const doReset = () => {
try { window.localStorage.removeItem(STORAGE_KEY); } catch (e) { /* 忽略 */ }
setLocale(DEFAULT_LOCALE);
setConfirm(null);
};
return html`
<div class="page">
<header class="head">
<div>
<h1>${t("app.title")}</h1>
<p class="sub">${t("app.subtitle")}</p>
</div>
<${LocaleSwitch} />
</header>
<div class="card" style=${{ padding: "14px 16px" }}>
<h2 style=${{ margin: 0, fontSize: "16px" }}>${t("greeting.hello", { name: "Aria" })}</h2>
<p style=${{ margin: "4px 0 0", color: "var(--muted)" }}>
${t("greeting.welcome", { app: t("app.title") })}
</p>
<p style=${{ margin: "10px 0 0" }}>
${t("promo.tagline")}
${missing("promo.tagline") && html`<span class="miss">缺译回退 · ${DEFAULT_LOCALE}</span>`}
</p>
</div>
<div class="stats">
<div class="stat card">
<p class="stat-label">${t("stats.revenue")}</p>
<p class="stat-value">${fmt.money.format(revenue)}</p>
</div>
<div class="stat card">
<p class="stat-label">${t("stats.orders")}</p>
<p class="stat-value">${fmt.num.format(ORDERS.length)}</p>
</div>
<div class="stat card">
<p class="stat-label">${t("stats.aov")}</p>
<p class="stat-value">${fmt.money.format(aov)}</p>
</div>
</div>
<h2 style=${{ margin: "18px 0 8px", fontSize: "15px" }}>${t("table.title")}</h2>
<table class="list">
<thead>
<tr>
<th>${t("table.order")}</th>
<th>${t("table.customer")}</th>
<th>${t("table.date")}</th>
<th class="num">${t("table.total")}</th>
</tr>
</thead>
<tbody>
${ORDERS.map(o => html`
<tr key=${o.id}>
<td style=${{ fontWeight: 550 }}>${o.id}</td>
<td>${o.customer}</td>
<td>${fmt.date.format(new Date(o.iso))}</td>
<td class="num">${fmt.money.format(o.cny * RATE[conf.currency])}</td>
</tr>`)}
</tbody>
</table>
<footer class="pager">
<span>已持久化:<code>localStorage["${STORAGE_KEY}"] = "${locale}"</code></span>
<div>
<button class="btn sm" onClick=${() => setConfirm({
title: t("confirm.reset.title"),
body: t("confirm.reset.body"),
})}>${t("action.reset")}</button>
</div>
</footer>
${confirm && html`<${Confirm} title=${confirm.title} text=${confirm.body}
onOk=${doReset} onCancel=${() => setConfirm(null)} />`}
${toast && html`<div class="toast">${toast}</div>`}
</div>`;
}
ReactDOM.createRoot(document.getElementById("root")).render(
html`<${I18nProvider}><${App} /><//>`
);
</script>
</body>
</html>