ice.js 国际化(i18n)最佳实践:@ice/plugin-i18n 插件完整指南与源码解析

原创2026-09-20 12:01:281,895 阅读
文章标签:前端Web框架小程序SSR前端构建

ice.js 国际化(i18n)最佳实践:@ice/plugin-i18n 插件完整指南与源码解析

导读

@ice/plugin-i18n 是 ice.js 3 官方提供的国际化插件,它把"语言切换"这一需求直接下沉到了路由层:通过为不同语言自动生成带 locale 前缀的页面路由(如 /en-US/about 与 /zh-CN/about),再配合 Cookie、Accept-Language 与服务端重定向,实现一套代码、多语言多入口的完整方案。本文将以该插件在 ice.js 仓库中的官方文档为主线,结合其源码实现与仓库自带的 with-i18n 示例项目,完整讲解插件安装、配置、运行时 API、语言检测原理与 SSR 部署方式,读完即可在自己的 ice.js 应用中落地一套可生产使用的国际化方案。

一、插件定位:为什么需要路由级 i18n

在单页应用中做国际化,常见做法是"组件级"的:用 react-intl、i18next 之类的库在运行时替换文案。但这种方式有两个痛点:一是搜索引擎只能索引到默认语言的内容,不利于多语言 SEO;二是每次切换语言都要靠前端状态记忆,刷新页面后容易丢失。

@ice/plugin-i18n 采用了"路由级(path-based)国际化"方案:每个受支持的语言都拥有独立的 URL 前缀,例如:

/zh-CN/home   (默认语言路径,无需前缀)
/en-US/home

这样做的好处是:URL 即语言标识,天然可收藏、可分享、可被搜索引擎索引;语言信息写入 Cookie,用户再次访问时自动恢复偏好。该插件在构建期自动为所有页面路由生成带 locale 前缀的副本,在运行期负责语言检测、Cookie 记录、导航前缀补全与自动重定向,开发者无需手动维护任何一份重复路由。

二、安装与基础配置

2.1 安装

作为构建期插件,它应作为开发依赖安装(参考 package.json 中 "description": "I18n plugin for ice.js 3" 的定义):

$ npm i @ice/plugin-i18n --save-dev

2.2 最小配置示例

在 ice.config.mts 中注册插件(此配置取自官方 README):

import { defineConfig } from '@ice/app';
import i18n from '@ice/plugin-i18n';

export default defineConfig({
  plugins: [
    i18n({
      locales: ['zh-CN', 'en-US'],
      defaultLocale: 'zh-CN',
    }),
  ],
});

配置完成后,构建期插件会自动完成两件事:

  1. 生成带 locale 前缀的路由:对所有非默认语言(prefixedLocales),为整棵路由树复制一份前缀路由,例如 /en-US、/en-US/blog、/en-US/blog/a(见 src/index.ts);
  2. 注入运行时文件 plugin-i18n.ts:导出 getDefaultLocale() 与 getAllLocales() 两个辅助函数(见 模板文件),并在 @ice/runtime 中注册插件运行时,导出 withLocale、useLocale(见 src/index.ts)。

2.3 插件选项一览

选项 类型 默认值 必填 说明
locales string[] — 是 应用需要支持的全部语言列表,如 ['zh-CN', 'en-US']
defaultLocale string — 是 默认语言。访问不带 locale 前缀的路径(如 /home)时使用的语言
autoRedirect boolean true 否 是否根据用户偏好的语言自动重定向到正确的 locale 前缀路径;生产环境配合服务端中间件使用

这三个选项的完整类型定义见 src/types.ts。其中 locales 与 defaultLocale 是必填项,插件在 setup 阶段会调用 checkPluginOptions 做类型校验,不合法时直接打印错误并 process.exit(1) 终止构建(见 src/index.ts)。

三、autoRedirect 与语言自动重定向

3.1 它解决什么问题

假设用户来自英文环境,直接访问了应用根路径 /(或 /app 这类带 basename 的根路径)。此时用户希望看到的是英文内容,但 URL 上没有任何语言信息。autoRedirect: true 时,插件会在服务端响应阶段介入:检测用户偏好的语言,若检测结果不是默认语言,就返回 302 Found 重定向,把路径改为带 locale 前缀的地址。

该逻辑在运行时入口 src/runtime/index.tsx 中实现:

if (autoRedirect) {
  addResponseHandler((req) => {
    const url = new URL(`${baseUrl}${req.url}`);
    const detectedLocale = detectLocale({
      locales, defaultLocale, basename,
      pathname: url.pathname,
      headers: req.headers,
      disableCookie: false,
    });

    const localeRedirectPath = getLocaleRedirectPath({
      pathname: url.pathname, defaultLocale,
      detectedLocale, basename,
    });
    if (localeRedirectPath) {
      url.pathname = localeRedirectPath;
      return {
        statusCode: 302,
        statusText: 'Found',
        headers: { location: /* 重组后的重定向地址 */ },
      };
    }
  });
}

addResponseHandler 是 @ice/runtime 提供的服务端响应钩子,仅在 SSR 场景下生效。重定向的具体规则封装在 getLocaleRedirectPath.ts:只有当路径是根路径(去除 basename 后为 /)且检测到的语言不等于默认语言时,才拼接 ${basename}/${detectedLocale} 进行重定向;否则不做处理。

3.2 为什么生产环境要配中间件

官方文档特别强调:autoRedirect 需要与**中间件(middleware)**配合使用,生产部署时应参考示例项目了解更多细节。这是因为:

  • autoRedirect 依赖 addResponseHandler,本质是 SSR 服务端能力,纯静态托管(如只部署 build 目录的静态文件)下不会执行;
  • 生产环境常见的做法是像 with-i18n 的 server.mts 那样,用 express 自建一个 Node 服务,把请求统一交给 renderToHTML({ req, res }, { basename }) 处理,服务端在渲染的同时完成语言检测与 302 重定向。
import express from 'express';
import { renderToHTML } from './build/server/index.mjs';

const app = express();
const port = 4000;
const basename = '/app';

app.use(express.static('build', {}));

app.use(async (req, res) => {
  const { statusCode, statusText, headers, value: body } = await renderToHTML({ req, res }, { basename });
  res.statusCode = statusCode;
  res.statusMessage = statusText;
  Object.entries((headers || {}) as Record<string, string>).forEach(([name, value]) => {
    res.setHeader(name, value);
  });
  if (body && req.method !== 'HEAD') {
    res.end(body);
  } else {
    res.end();
  }
});

app.listen(port, () => {
  console.log(`App listening on http://localhost:${port}${basename}`);
});

注意 renderToHTML 的第二个参数传入了 { basename: '/app' },这与应用配置的路由 basename 保持一致——服务端重定向拼接地址时会用到它。

四、语言检测的优先级(源码级原理)

用户到底该看哪个语言?插件定义了一条清晰的检测链,实现在 detectLocale.ts:

const detectedLocale = (
  normalizeLocalePath({ pathname, locales, basename }).pathLocale ||   // ① URL 路径前缀
  (!disableCookie && getLocaleFromCookie(locales, headers)) ||        // ② Cookie
  getPreferredLocale(locales, headers) ||                             // ③ Accept-Language / navigator.languages
  defaultLocale                                                       // ④ 兜底默认语言
);

优先级从高到低为:

  1. URL 路径中的 locale 前缀:通过 normalizeLocalePath.ts 解析,它会先把 basename 剥掉,再检查路径第二段是否是受支持的语言,是则提取出来并从路径中移除该段。这是最高优先级——用户显式访问 /en-US/xxx 时,直接以 URL 为准;
  2. Cookie:读取名为 ice_locale 的 Cookie(名称定义在 constants.ts),由 getLocaleFromCookie.ts 实现。服务端渲染时从请求头 cookie 解析,浏览器端则直接读 document.cookie(universal-cookie 会根据 typeof window 自动选择数据源);
  3. 浏览器/请求语言偏好:见 getPreferredLocale.ts——服务端读请求头的 Accept-Language(用 accept-language-parser 做质量值匹配),浏览器端读 window.navigator.languages,取第一个在 locales 中受支持的语言;
  4. 默认语言兜底:以上都未命中时,回落到配置的 defaultLocale。

这套优先级设计保证了"URL 显式指定 > 用户历史偏好(Cookie)> 系统语言偏好 > 应用默认语言",兼顾了确定性、记忆性与首次访问体验。

4.1 Cookie 的写入与失效开关

当用户通过 useLocale 的 setLocale 切换语言时,I18nContext.tsx 会把新语言写入 Cookie:

function setLocale(locale: string) {
  !disableCookie && setLocaleToCookie(locale);
  updateLocale(locale);
}

setLocaleToCookie.ts 用 cookies.set('ice_locale', locale, { path: '/' }) 落盘。这里的 disableCookie 开关来自应用的 i18nConfig(详见下一节):对于禁用/拦截 Cookie 的场景(如隐私受限环境),可以关闭 Cookie 读写,此时语言偏好完全依赖 URL 前缀与请求头判断。

4.2 basename 的兼容处理

ice.js 应用支持通过 router.basename 部署到子路径(如 /app)。插件所有路径解析逻辑都先经过 removeBasenameFromPath.ts 剥除 basename,还兼容了 ice(无前导斜杠)与 /ice 两种写法,避免 locale 前缀与 basename 混淆。

五、运行时 API:useLocale 与 withLocale

插件运行时以 Provider 形式注入到 React 应用中(src/runtime/index.tsx),向组件树提供两个核心 API,均可直接从 ice 包导入:

5.1 useLocale(Hooks 方式)

import { useLocale } from 'ice';

function LanguageSwitcher() {
  const [activeLocale, setLocale] = useLocale();
  return (
    <button onClick={() => setLocale('en-US')}>Switch to English</button>
  );
}

useLocale 返回 <a href="https://link.gitcode.com/i/5a81209514e98d6250e74a3bc73694c2" target="_blank">locale, setLocale] 元组:locale 为当前语言(初始值由 URL 前缀或默认语言决定,见 [I18nContext.tsx);setLocale 在更新 React 状态的同时写入 Cookie,并自动为后续导航补全 locale 前缀。

5.2 withLocale(HOC 方式)

import { withLocale } from 'ice';

function Header(props) {
  const { locale, setLocale } = props;
  return <span>{locale}</span>;
}

export default withLocale(Header);

withLocale 为类组件或偏好 HOC 的代码提供同样的 locale / setLocale props(见 I18nContext.tsx)。

5.3 导航前缀的自动补全:history hijack

为了让应用内的 history.push / history.replace(即 Link 跳转)始终带上正确的 locale 前缀,插件在运行时拦截了路由 history(hijackHistory.tsx):重写 push 与 replace,根据当前检测到的语言,用 urlJoin(basename, locale === defaultLocale ? '' : locale, newPathname, search) 重新拼接目标地址,再调用原始 history 方法。

这意味着开发者不需要在业务代码里手动给每个 Link 的 to 加前缀,插件会自动处理;默认语言路径则不加前缀(保持 URL 简洁)。此外它还支持通过 state.locale 显式指定某次跳转的语言。

六、应用级运行时配置:defineI18nConfig 与 disableCookie

除了构建配置,插件还提供了应用级配置入口。在 src/app.tsx 中导出 i18nConfig(参考 with-i18n 示例):

import { defineAppConfig } from 'ice';
import { defineI18nConfig } from '@ice/plugin-i18n/types';

export default defineAppConfig(() => ({
  router: {
    basename: '/app',
  },
}));

export const i18nConfig = defineI18nConfig(() => ({
  // disabledCookie: true,
}));

defineI18nConfig 的类型定义在 src/types.ts,目前支持一个配置项:

配置项 类型 默认值 说明
disableCookie boolean | (() => boolean) false 当前应用是否禁用 Cookie(例如被浏览器策略拦截时)。为 true 时不再从 ice_locale Cookie 读取语言,setLocale 也不再写 Cookie,语言完全由 URL 前缀与请求头决定

运行时拿到该配置后,会把它透传给 I18nProvider 与语言检测逻辑(见 src/runtime/index.tsx)。注意它支持函数形式,方便在运行时动态判断(比如按环境变量决定)。

七、完整示例:with-i18n 项目逐文件拆解

仓库的 examples/with-i18n 是一个可直接运行的完整示例,同时开启了 SSR 与 i18n,可作为生产配置的蓝本:

① 构建配置(ice.config.mts):

import { defineConfig } from '@ice/app';
import i18n from '@ice/plugin-i18n';

export default defineConfig({
  plugins: [
    i18n({
      locales: ['zh-CN', 'en-US'],
      defaultLocale: 'zh-CN',
      autoRedirect: true,
    }),
  ],
  ssr: true,
});

② 应用配置(src/app.tsx):设置路由 basename 为 /app,并导出 i18nConfig。

③ 布局组件(src/pages/layout.tsx):演示了完整的语言切换 UI 与 react-intl 的集成方式:

import { Outlet, useLocale, getAllLocales, getDefaultLocale, Link, useLocation } from 'ice';
import { IntlProvider as ReactIntlProvider } from 'react-intl';
import { messages } from '@/locales';

export default function Layout() {
  const location = useLocation();
  const [activeLocale, setLocale] = useLocale();

  return (
    <main>
      <p><b>Current locale: </b>{activeLocale}</p>
      <p><b>Default locale: </b>{getDefaultLocale()}</p>
      <p><b>Configured locales: </b>{JSON.stringify(getAllLocales())}</p>

      <b>Choose language: </b>
      <ul>
        {getAllLocales().map((locale: string) => (
          <li key={locale}>
            <Link to={location.pathname} onClick={() => setLocale(locale)}>
              {locale}
            </Link>
          </li>
        ))}
      </ul>
      <ReactIntlProvider locale={activeLocale} messages={messages[activeLocale]}>
        <Outlet />
      </ReactIntlProvider>
    </main>
  );
}

这段代码体现了插件的几个关键能力:

  • useLocale() 拿到当前语言与切换函数,setLocale 切换后,后续跳转自动带上前缀并写入 Cookie;
  • getAllLocales() / getDefaultLocale() 是由插件模板在构建期生成注入的(对应 plugin-i18n.ts.ejs),可用于动态渲染语言切换菜单;
  • Link 的 to 无需手写前缀,history 拦截会自动补齐;
  • 文案翻译仍交给 react-intl 这类成熟库:messages 按语言组织(src/locales.ts),ReactIntlProvider 的 locale 与插件的当前语言保持同步——插件负责"路由、URL 与偏好记忆",react-intl 负责"文案渲染",职责清晰。

④ 页面路由(src/pages):index.tsx、blog/index.tsx、blog/a.tsx 是普通业务页面,构建后每个页面都会自动生成默认语言与 /en-US 两套路由。

⑤ SSR 服务(server.mts):见 3.2 节,用于生产环境承接 autoRedirect 的服务端重定向。

运行后访问 http://localhost:4000/app,会依据浏览器的 Accept-Language 自动 302 到 /app/zh-CN 或 /app/en-US(或默认 /app);页面上的语言切换链接会更新 URL 前缀并写入 ice_locale Cookie。

八、版本演进:从 CHANGELOG 看插件稳定性

CHANGELOG.md 记录了插件从 1.0.0 到 5.0.2 的完整演进,从中可以读出几个关键信息:

  • 1.0.0(feat: init plugin):插件初始化,随之依赖 @ice/app@3.2.0 与 @ice/runtime@1.2.0,说明它是 ice.js 3 体系的原生组成部分;
  • 2.0.1(fix: sourceMap url in prod files but not publish with sourceMap file):修复了生产文件中的 sourceMap URL 引用问题,同时伴随 @ice/jsx-runtime、@ice/runtime、@ice/app 的一批补丁升级;
  • 3.0.0 / 4.0.0 / 5.0.0:均为跟随主框架 @ice/app、@ice/runtime 的依赖升级(如 @ice/app@3.4.0、@ice/runtime@1.4.0),插件 API 保持稳定;
  • 5.0.1 / 5.0.2:仅更新依赖,如 @ice/jsx-runtime@0.3.2、@swc/helpers@0.5.17 与 @ice/app@3.6.2。

结合 package.json 的依赖声明(accept-language-parser、universal-cookie、url-join)与 peer 依赖(@ice/app@^3.6.4、@ice/runtime@^1.5.7),可以看出插件自身的运行时依赖极轻,主要能力都构建在 ice.js 3 的插件体系(addRoutesDefinition、generator、addProvider、addResponseHandler)之上,因此版本节奏与主框架高度同步,升级成本低。

九、使用边界与注意事项

  1. autoRedirect 依赖 SSR:纯静态部署下服务端 addResponseHandler 不会执行,需要按 3.2 节自建 Node 中间件,或改用静态站点的边缘重定向方案;
  2. defaultLocale 路径无前缀:插件只给非默认语言生成前缀路由,默认语言访问 /home 而非 /zh-CN/home,这有助于保持默认体验 URL 的简洁与稳定;
  3. Cookie 被拦截时:通过 i18nConfig.disableCookie 关闭 Cookie 读写,语言恢复将完全依赖 URL 前缀与 Accept-Language,首次访问行为会略有变化;
  4. basename 一致性:构建配置的 router.basename、服务端 renderToHTML 的 basename 与反向代理的路径必须保持一致,否则 locale 前缀解析与重定向拼接可能错位;
  5. 文案翻译是独立职责:插件不负责文案翻译本身,推荐与 react-intl 等库组合使用,由插件统一管理"路由、URL、Cookie、语言检测",由翻译库管理"文案渲染",正如 with-i18n 示例所示。

十、总结

@ice/plugin-i18n 以"路由级国际化"为设计核心,把语言能力内建到 ice.js 3 的路由与运行时体系中:构建期自动生成多语言路由与辅助函数,运行期通过 useLocale / withLocale 管理语言状态并自动补全导航前缀,服务端通过 autoRedirect 按 Accept-Language 与 Cookie 完成首访重定向。开发者只需声明 locales 与 defaultLocale 两项必填配置,即可获得完整的 URL 化多语言体验,配合 react-intl 等翻译库即可构建可 SEO、可记忆、可分享的生产级国际化应用。

如需深入阅读,可继续查看插件的运行时入口、语言检测实现以及完整示例项目。

登录后查看全文
ice