组件库国际化完整方案:外部依赖、资源外置与远程加载
概述
组件库的国际化资源不是「写完语言包」就结束。课程把这件事拆成三层能力,每层解决不同问题:第一层是外部依赖 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 依赖边界。
// vite.config.ts
build: {
rollupOptions: {
external: ['vue', 'element-plus'],
output: {
globals: {
vue: 'Vue',
'element-plus': 'ElementPlus',
},
},
},
}对固定支持的少数语言(如 zh-cn 和 en),显式导入比动态 Promise 化更适合组件库场景:逻辑更简单、类型更稳定、产物更可控。Element Plus 官方当前也推荐这种方式。
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) 清晰得多。
// 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 它。这一步把「隐式宿主全局能力」变成「组件库内部显式依赖」。
// 某个组件
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' 放到后置写包阶段,避免被其他插件清理或覆盖;src 用 src/locales/* 而非整目录复制,否则会出现 dist/locales/locales/... 的错误嵌套;资源复制和库构建是两条链路,产物路径是否和 dist 对齐一定要实际看一眼。
import copy from 'rollup-plugin-copy'
copy({
targets: [{ src: 'src/locales/*', dest: 'dist/locales' }],
hook: 'writeBundle',
})即使 dist/locales/zh-cn.json 物理存在,如果包导出没暴露这条子路径,使用者依旧不一定能稳定 import。所以要把「物理存在于 dist 的文件」升级为「包的正式公共 API」:
{
"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。
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」:
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 语义:默认值在前、用户值在后。
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 404 | public 路径不对或 BASE_URL 没拼 | 先核 public 目录,再检查 fetch 拼接 |
| localesMapLoader 调了但自定义文案没生效 | 默认 map 和用户 map 合并顺序写反 | 保证默认值在前、用户值在后 |
| 切换语言时页面闪烁 | 远程 locale 本质是异步加载 | 加 loading、预加载或提前初始化时机 |