{T}

组件库国际化完整方案:外部依赖、资源外置与远程加载

概述

组件库的国际化资源不是「写完语言包」就结束。课程把这件事拆成三层能力,每层解决不同问题:第一层是外部依赖 external,把 Element Plus 的 locale 从组件库打包链里剥离,交给宿主提供;第二层是资源外置,把组件库自有的 locales 从 JS 模块转成静态资源,用 rollup-plugin-copy 拷贝并透出子路径;第三层是远程加载,支持宿主把语言包放到 public 目录,运行时 fetch 读取并允许用户覆盖。真正成熟的工程意识是:国际化一旦允许远程加载,设计的就不再是「一个语言对象」,而是一套「语言资源加载协议」。

学习目标

  • 理解 Element Plus locale 为什么要 external,以及 external 掉整个 element-plus 依赖边界的前提
  • 掌握显式导入少量语言、显式命名导出 i18nPlugin、桥接 dollarT 这三步运行时能力接线
  • 用 rollup-plugin-copy + exports 子路径把 locales 变成可独立访问的静态资源
  • 掌握 BASE_URL + fetch 读取 public 资源,以及对象和函数两种 locale 值的统一解析
  • 理解 localesMapLoader 的合并顺序为何决定用户覆盖是否生效

一、外部依赖层:Element Plus locale external 与 $t 桥接

当宿主项目已经安装 element-plus,组件库里关于它的 locale 就不应重复打包。external 的意义在于避免重复产物、避免组件库无谓膨胀、避免同一份依赖在宿主和组件库各带一份。这符合组件库总体原则:大体量基础依赖尽量交给宿主提供,组件库只负责组织与桥接自己的业务能力。

前提是宿主确定会提供 element-plus。如果组件库目标是完全脱离宿主独立使用,这条策略要重新评估。「external 掉 locale」本质上是在 external 掉整个 element-plus 依赖边界。

ts
// vite.config.ts
build: {
  rollupOptions: {
    external: ['vue', 'element-plus'],
    output: {
      globals: {
        vue: 'Vue',
        'element-plus': 'ElementPlus',
      },
    },
  },
}

对固定支持的少数语言(如 zh-cn 和 en),显式导入比动态 Promise 化更适合组件库场景:逻辑更简单、类型更稳定、产物更可控。Element Plus 官方当前也推荐这种方式。

ts
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import en from 'element-plus/es/locale/lang/en'

const elementPlusLocaleMap: Record<string, any> = {
  'zh-cn': zhCn,
  en,
}

function getElementPlusLocale(lang: string) {
  return elementPlusLocaleMap[lang]
}

把 i18n 模块导出到入口时,推荐显式命名导出插件对象,而不是直接 default 导出。让消费方 app.use(I18nModule.i18nPlugin),语义比 app.use(defaultExport) 清晰得多。

ts
// src/modules/i18n/index.ts
export const i18nPlugin = {
  install(app: any) {
    app.use(i18n)
  },
}

export { i18n }

// 桥接 $t
export const dollarT = i18n.global.t

仅仅导出 i18n 插件,不代表组件内部自动知道怎么拿到 $t。还需要做一层显式桥接:从 i18n 实例把翻译函数导出,让业务组件主动 import 它。这一步把「隐式宿主全局能力」变成「组件库内部显式依赖」。

ts
// 某个组件
import { dollarT } from '@/modules/i18n'

const text = dollarT('components.iconPicker')

完整链路通常是:组件库侧 external 依赖、导出 plugin / helper;宿主侧 import 模块、app.use(plugin)、import 样式。少任何一步链路都不完整,Playground 正是用来验证这条链路有没有闭环。

二、资源外置层:locales 静态资源拷贝与子路径导出

locale JSON 如果只是「数据资源」,通常没必要被打包进主产物。直接 import 会被打进主 JS,可能额外生成 .js / .js.map 或内联到主包,而 locale 本质是静态资源、不是所有使用者都会立刻用到所有语言。判断原则很直接:如果资源只是配置 / 文案数据,优先考虑静态资源拷贝;如果资源必须参与代码依赖分析,再考虑 import 进主包。

用 rollup-plugin-copy 把 locales 原样复制到 dist,注意 hook: 'writeBundle' 放到后置写包阶段,避免被其他插件清理或覆盖;srcsrc/locales/* 而非整目录复制,否则会出现 dist/locales/locales/... 的错误嵌套;资源复制和库构建是两条链路,产物路径是否和 dist 对齐一定要实际看一眼。

ts
import copy from 'rollup-plugin-copy'

copy({
  targets: [{ src: 'src/locales/*', dest: 'dist/locales' }],
  hook: 'writeBundle',
})

即使 dist/locales/zh-cn.json 物理存在,如果包导出没暴露这条子路径,使用者依旧不一定能稳定 import。所以要把「物理存在于 dist 的文件」升级为「包的正式公共 API」:

json
{
  "name": "el-admin-components",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/el-admin-components.js",
      "require": "./dist/el-admin-components.umd.cjs"
    },
    "./style.css": "./dist/el-admin-components.css",
    "./locales/*": "./dist/locales/*"
  }
}

./* 太宽,容易把不想承诺的内部文件也暴露出去;对组件库来说 exports 越精确,公共 API 边界越清楚。组件库内部写 import zhCn from 'el-admin-components/locales/zh-cn.json' 看起来像「从自己导自己」,其实是在模拟真实消费场景:一旦发布安装,宿主的 node_modules/el-admin-components/ 就是正式的包,库内和消费方应以同一套包路径约定访问资源。

三、远程加载层:fetch 动态读取与覆盖体系

如果宿主把 locale 放在自己应用的 public 目录,组件库可以直接在运行时 fetch 这些 JSON,不一定需要改前端代码或重打组件库包。最适合的场景是线上文案经常微调、多语言运营内容独立维护、宿主希望对组件库文案拥有最终控制权。这类方案的优势不是「更高级」,而是「部署链路更灵活」,但首次加载可能有异步切换和闪烁,需要接受。

import.meta.env.BASE_URL 是 Vite 构建时注入的基础路径。宿主不一定部署在站点根,可能有子目录前缀;不拼 BASE_URL 本地开发没事,线上带子路径部署就可能 404。

ts
const originLocalesMap = {
  'zh-cn': () =>
    fetch(`${import.meta.env.BASE_URL}locales/zh-cn.json`).then((res) =>
      res.json(),
    ),
  en: () =>
    fetch(`${import.meta.env.BASE_URL}locales/en.json`).then((res) =>
      res.json(),
    ),
}

一旦 localesMap 的值允许既是对象又是函数,加载逻辑就必须显式区分「同步资源」和「异步 loader」:

ts
async function resolveLocaleValue(value: any) {
  if (typeof value === 'function') {
    return await value()
  }
  return value
}

async function loadLocaleMessage(language: string) {
  const localeSource = localesMap?.[language]
  if (!localeSource) return
  const messages = await resolveLocaleValue(localeSource)
  i18n.global.setLocaleMessage(language, messages)
}

自定义 locale 没覆盖成功,最常见根因不是路径错,而是默认 map 和用户 map 的合并顺序不对。...origin, ...custom...custom, ...origin 完全不同;对「默认配置 + 用户配置」体系来说,合并顺序就是 API 语义:默认值在前、用户值在后。

ts
export let localesMap: Record<string, any> = {}

export const localesMapLoader = (newLocalesMap: Record<string, any>) => {
  const originLocalesMap = { /* fetch 加载器 */ }
  localesMap = { ...originLocalesMap, ...newLocalesMap }
}

Playground 侧先 I18nModule.localesMapLoader({...}) 注入覆盖(用 ...zhCn 先继承再局部覆盖),再 loadLocaleMessage('en') 触发切换,就能验证远程 / 覆盖体系是否生效。

四、三层能力的工程边界与取舍

三层能力不是都要一次性上齐,而是按组件库成熟度递进:

  • 外部依赖层解决「依赖不重复」,前提是宿主已提供基础依赖;
  • 资源外置层解决「静态资源不污染主包」,适合语言种类固定、希望独立访问的场景;
  • 远程加载层解决「文案可运营」,适合文案频繁调整或由宿主掌控的场景。

真正关键的工程意识有三条:组件库里的「模块可用」不等于「组件知道去哪拿它的全局实例」,需要显式桥接;如果一个 JSON 资源不需要参与 JS 模块化和 tree-shaking,更适合作为静态资源被拷贝和按需加载;国际化资源一旦允许远程加载,设计的就不再是「一个语言对象」,而是一套「语言资源加载协议」。


常见问题

问题原因解决方案
只需要两种语言,dist 却带出很多 Element Plus locale语言资源没收敛、依赖没 external显式导入 zh-cn / en,并 external element-plus
导出 i18n 模块但消费方 app.use 写得很别扭暴露的是 default 语义,不是清晰的插件命名显式导出 i18nPlugin 这类语义明确的 API
组件里还是拿不到 $t只导出插件,没把翻译函数桥接出来从 i18n 实例导出 dollarT,让组件主动 import
dist 里没有 locales 目录copy 路径写错或执行时机太早被清理检查 src/dest 路径和 hook: 'writeBundle'
出现 dist/locales/locales 多嵌套一层src 和 dest 层级不对用 src: 'src/locales/*' 而不是复制整目录
组件库里写 el-admin-components/locales/zh-cn.json 但构建找不到exports 没暴露该子路径显式加上 ./locales/: ./dist/locales/
刷新后 locales/*.json 404public 路径不对或 BASE_URL 没拼先核 public 目录,再检查 fetch 拼接
localesMapLoader 调了但自定义文案没生效默认 map 和用户 map 合并顺序写反保证默认值在前、用户值在后
切换语言时页面闪烁远程 locale 本质是异步加载加 loading、预加载或提前初始化时机

延伸阅读