给流程设计器加国际化,我没引 vue-i18n

给应用做国际化,第一反应是装 vue-i18n。但如果你要发布的是一个 npm 组件包------比如我们的钉钉风格流程设计器 mldong-flow-designer-dingtalk------这件事就没那么直接了:装你包的人,项目里可能已经有一套自己的多语言方案,可能用的根本不是 vue-i18n,也可能什么都没装。

这次给设计器内置 zh-CN / en-US 双语,我们的 dependencies 一个都没加,peerDependencies 仍然只有 vue,173 个词条中英全量对齐。切换语种的时候,表单、抽屉、控制条即时跟着变。效果是下面这样------同一个审批节点抽屉,左右是两个语种,注意「部门领导审批」这个值在英文界面下原样保留,它不是文案,是数据:

这篇讲讲为什么这么做、具体怎么做,以及三个写法坑------两个是开发时真翻过的车,一个是提前拆掉的雷。

一、组件包的国际化,和应用不是一件事

应用引入 vue-i18n 天经地义:你控制整个工程的依赖,装就是了。组件包不行,它面对的是别人的项目,约束完全不同:

第一,你不能替宿主选型。 组件包把 vue-i18n 写进 dependencies,装包的人就被迫拖进一整套他可能根本不需要的依赖;写进 peerDependencies 也不行------意味着宿主不装 vue-i18n 你的包就装不上,把选型成本甩给了每一个使用者。

第二,构建产物会出卖你。 就算只想"构建期用一下",我们的 lib 构建里 external 只排了 vue:

javascript 复制代码
// vite.config.lib.ts
rollup: {
  external: ['vue'],
},

一旦源码里 import 了 vue-i18n,整个库会被打进你的产物。更糟的是宿主自己也装了 vue-i18n 的场景:现在是两个实例------你那份和宿主那份,各自持有自己的消息表和当前语种,宿主 setLocale 切的是它那份,你组件里显示的是你那份,永远对不上。

第三,组件包需要的 i18n 能力其实非常薄。 掰着指头数:一张扁平词条表、{name} 插值、缺译回落、切语种后已渲染界面热更新。vue-i18n 的复数规则、日期格式化、懒加载、legacy 模式......对组件包都是用不上的重。

所以最后落成了一个 109 行的自研 store(src/locales/store.ts),核心状态就两个 shallowRef:

typescript 复制代码
const GLOBAL_KEY = '__MLDONG_FLOW_DESIGNER_LOCALE__'

const g = globalThis as Record<string, any>
let state: FDLocaleState = g[GLOBAL_KEY]
if (!state) {
  state = createState()
  g[GLOBAL_KEY] = state
}

export function setLocale(locale: FDLocale): void {
  state.locale.value = locale
}

export function t(key: string, params?: FDTranslateParams): string {
  // 当前语种缺译回落 zh-CN;zh-CN 也没有则返回 key 本身(只告警一次)
  const text = messages[current]?.[key] ?? messages[FD_DEFAULT_LOCALE]?.[key]
  if (text === undefined) {
    if (!warned.has(key)) {
      warned.add(key)
      console.warn(`[mldong-flow-designer] missing message: ${key}`)
    }
    return key
  }
  return interpolate(text, params)
}

对外 API 一共五个:setLocale / getLocale / t / extendMessages / useFDLocale,够用,也只够用。

二、key 化,而不是工厂化

决定自己管词条之后,下一个问题是:词条挂在哪?

我们的表单元数据(schema)是一份纯数据:每个节点类型的表单长什么样、字段名叫什么,业务方还可以传自己的 schema 进来覆盖合并。加国际化的直觉做法是"工厂化"------把 schema 改成工厂函数,每个语种现场生成一份。但这动的是数据结构:schema 会被业务方覆盖、合并、拼装,甚至持久化,为了翻译去动它,风险全在别人身上。

我们用的是键化(key 化):schema 一个字段的 label 原地写上词条 key,长这样:

typescript 复制代码
// plugins/schema.ts(节选,真实代码)
export const end: FDFormType = {
  labelWidth: 'fd.labelWidth.narrow',
  formItems: [{
    name: "name",
    label: "fd.field.code",        // 词条 key,不是文案
    component: 'Input'
  }, {
    name: "preInterceptors",
    label: "fd.field.preInterceptors",
    component: 'Input'
  }]
}

字段数据(name: "name")原样不动,只有显示文案的位置换成 fd. 开头的 key 串。解引用发生在全包唯一的渲染边界 FDSchemaForm.vue------所有表单(画布模式、钉钉模式、流程属性)最终都经它渲染,三个调用点:

typescript 复制代码
// 唯一的解引用函数:只有 fd. 前缀的串才当 key,其余原样输出
export function resolveText<T>(value: T, params?: FDTranslateParams): T {
  if (typeof value === 'string' && value.startsWith('fd.')) {
    return t(value, params) as unknown as T
  }
  return value
}

这个设计白捡了三样东西:

数据结构零风险。 业务方传进来的 schema 里想写死中文文案,resolveText 认不出 fd. 前缀就原样输出,一行不用改,一个现有用法都不破坏。

65 条占位符折成 2 个键。 原来每个字段的 placeholder 都是"请输入唯一编码""请输入参与人"这种「请输入/请选择 + 标签名」的同构串,65 条重复词条。现在只剩两个模板键,由渲染器按 label 派生:

typescript 复制代码
'fd.ph.input': '请输入{label}',
'fd.ph.select': '请选择{label}',

只有和这个句式不同构的占位符(比如操作按钮的"如 AGREE,REJECT,ROLLBACK")才显式给 key。

真热切换。 解引用发生在渲染边界,也就是在 computed/模板的响应式依赖里。setLocale 改的是 shallowRef,依赖它的界面下一拍全部重算------不用刷新页面,不用重挂载组件。

整条链路画出来是这样:

三、一半中文是文案,一半中文是数据

国际化最容易翻车的地方不是"漏翻了哪条文案",而是"把不该翻的翻了"。设计器里有两类中文,长得一模一样,命运完全不同。

看这张图------同一个请假流程,左边中文右边英文:

节点标题从「申请人 / 审批人 / 条件分支」变成了「Applicant / Approver / Condition」------这些是 UI 文案,该翻。但每个节点卡片里的名字,「请假申请」「部门领导审批」「人事审批」,两种语种下都是中文。这些是数据 :拖出节点、新建节点那一刻写进图数据(graphModel)的 text,会跟着流程定义 JSON 一起保存、交给引擎。翻译它们等于切个语言就把用户保存过的流程改写了。

具体到代码里,画布左侧面板的每一项都有两个字段,注释就写在组件里:

typescript 复制代码
// text 是新增节点时写入图数据的默认名(不译),label 是选择器显示名(可译)
const defaultDndPanelItems: FDPatternItem[] = [
  { type: 'task', text: '审批人', label: t('fd.ding.node.approver') },
  { type: 'custom', text: '自定义节点', label: t('fd.ding.node.custom') },
  { type: 'subProcess', text: '子流程', label: t('fd.dnd.subProcess.label') },
]

条件1 / 分支1 这种自动命名、默认流程里的节点名,同理保留。为了防止将来手滑,扫描门禁里专门有一条反向判定:数据字段(name/value/defaultValue/text)被 t( 赋值就报错

还有一类容易误伤的:引擎契约字面量。表单里"操作按钮"的占位符是「如 AGREE,REJECT,ROLLBACK」,会签完成条件里会出现 nrOfCompletedInstances------这些串是引擎接口的契约值,翻了他就认不出了。它们集中收在一张 FD_RAW 表里,由 t() 做插值时的默认参数注入,消息文本里用 {TOKEN} 引用,任何语种下原样输出。

唯一一个"不是文案却必须跟着语种变"的例外是 labelWidth。表单标签是不换行的,中文「会签完成条件」120px 够放,英文「Completion condition」120px 直接裁到抽屉外面。所以标签列宽的值也放进词条表,当词条翻译:

typescript 复制代码
'fd.labelWidth.narrow': '120px',   // zh-CN
'fd.labelWidth.narrow': '190px',   // en-US
'fd.labelWidth.wide':   '130px',   // zh-CN
'fd.labelWidth.wide':   '200px',   // en-US

四、三个写法坑:两个翻过车,一个提前拆雷

坑一:把默认文案写进 prop 默认值,切语种永远不更新。

抽屉和弹窗都有「确定/取消」按钮,最初的自然写法是给 prop 一个默认值,默认值里调 t()。问题是这个默认值只在实例创建那一刻求值一次,之后语种再怎么切它都不会重算。现在的写法是默认值留空,模板里兜底:

html 复制代码
<button class="fd-btn fd-btn--default" @click="internalClose">
  {{ cancelText || t('fd.ui.cancel') }}
</button>
<button class="fd-btn fd-btn--primary" @click="handleOk">
  {{ okText || t('fd.ui.confirm') }}
</button>

业务方显式传了 cancelText 就用业务方的,没传就走词条------顺带还比原来多支持了"按语种自动兜底"。

坑二:setup 作用域的数组常量调 t(),同样只求值一次。

钉钉模式的控制条按钮最初就是这种写法:一个 const defaultControl = [...] 平铺在 setup 里,每一项的 title 都是 t() 的返回值。实例创建那一刻定格,切语种纹丝不动。修法是挪进 computed,让求值进入响应式依赖:

typescript 复制代码
// 放在 computed 依赖里求值,切语种后无需重挂载即可更新按钮文案
const defaultControl = computed<FDControlItem[]>(() => [
  { key: 'save', iconClass: 'ding-icon-save', title: t('fd.control.save.text') },
  // ...
])

这两个坑本质是同一件事:t() 不是声明式的,它只是个普通函数调用 ------写在哪、什么时候被求值,决定了它跟不跟得上语种变化。规则很简单:想在切语种后更新的,必须让 t() 出现在渲染期(模板里或 computed 里)。

坑三:npm 包可能被宿主引成两个模块副本。

这一坑我们没踩过,但它足够阴险,值得提前拆掉。包的发布物里 files 白名单含 packages 目录,宿主项目完全可能一边从源码入口引、一边从 lib 产物引(或者 ESM/UMD 各引一份),两份模块作用域各自执行一遍 createState(),各建一个 store------你 setLocale 设的是 A 副本,界面上渲染的是 B 副本,怎么切都没反应,而且连一条报错都不会有。

防法是把单例挂到 globalThis 上,任何模块副本第一次初始化时都先问全局有没有现成的,有就共用------就是第一节那段代码里 __MLDONG_FLOW_DESIGNER_LOCALE__ 那四行。

五、173 个词条怎么保持不烂:扫描门禁

双语做完了只是一瞬间的事,难的是之后每次加字段、改文案都不烂掉。我们的兜底是一个 115 行的只读扫描脚本,五项判定,挂在每次提交前跑:

五项各防一种事故:缺键 (代码里引了 fd.xxx 但 zh-CN 表里没有,界面上会把 key 本身显示出来)、死键 (词条改了代码没同步删)、未覆盖 (新语种漏翻)、残留中文 (忘了 key 化的中文字面量------会自动剥注释、样式块,并对数据字段做白名单豁免)、误译 (数据字段被 t( 赋值,就是第三节那条红线)。

缺键和误译超过零直接 process.exit(1)。发布前跑一遍,输出是上面这张图的五项全零------这个数字不是做完那一刻的纪念照,是每次改动都要重新挣一遍的及格线。

顺带说回落保护的体验:词条缺了不会白屏,t() 先回落 zh-CN,zh-CN 也没有才返回 key 本身,并且同一个 key 只告警一次不刷屏。分阶段补语种的时候这个特性很有用------英文表可以先翻一半,没翻到的界面先显示中文,不影响使用。

六、宿主怎么接:一行,或者零行

对宿主来说接入方式按需三选一。

零行:什么都不做,默认中文。需要英文时命令式切一下:

typescript 复制代码
import Designer, { setLocale, extendMessages } from 'mldong-flow-designer-plus'

setLocale('en-US')
// 改术语:比如「参与人」在你们的业务语境里叫「办理人」
extendMessages('zh-CN', { 'fd.field.assignee': '办理人' })

extendMessages 是整体合并语义,改完已渲染的表单立即重算------宿主不需要重挂载设计器。

一行:宿主已有自己的 i18n 体系,把当前语种透传进来就行,不需要为组件包引入第二套翻译:

vue 复制代码
<Designer :locale="preferences.app.locale" />

兜底条款locale 这个 prop 的默认值是 undefined------不传就什么都不覆盖。这是刻意设计的:如果默认值是 'zh-CN',宿主用命令式 setLocale 切成英文之后,任何一处挂了默认值的组件实例反而会把全局值覆盖回中文。词条同理,messages prop 只做增量覆盖,不动内置表。

要说清的边界也一并说了:钉钉模式里表单、卡片、抽屉标题、控制条这些 computed 渲染处切语种即时生效;canvas 模式的 LogicFlow 拖拽面板和右键菜单是在插件初始化时求值的,切语种需要重挂载设计器组件------宿主如果本来就是按整页刷新处理语种切换的(大多数中后台项目都是),这个差异感知不到。

写在最后

回过头看,给组件包做国际化和给应用做国际化,方法论的差别就三条:

  1. 不替宿主选型------组件需要的最小能力集自己实现,dependencies 保持干净;
  2. 文案和数据划清界限------词条 key 只进渲染边界,写进图数据的东西任何语种原样保留;
  3. 别信自觉信门禁------173 个词条的整洁靠的不是当时仔细,是每次提交前重新挣一遍的五项全零。

vue-i18n 是个好库,我们自己的中后台项目里天天在用。只是npm 组件包这一层,它需要的国际化比任何库都小------小到 109 行就够了。

参考资料

相关推荐
IMPYLH1 小时前
HTML 的 <strong> 元素
前端·html
边境悍匪1 小时前
蜗牛学苑 Java 智能体学习 Day46|贯穿项目 2 思维导图复盘
java·开发语言·vue.js·学习·spring
开开心心就好7 小时前
批量提取PDF中的图片,直接导出原图
前端·javascript·支持向量机·智能手机·pdf·html·启发式算法
Setsuna_F_Seiei9 小时前
前端转型 Agent 开发 05 之 Agent Hooks 与 Checkpointer(让 Agent 从全自动转变人为可掌控)
前端·agent·ai编程
百万蹄蹄向前冲11 小时前
风扇转了一晚上MVP专家团翻车事故
前端·人工智能
默_笙11 小时前
🏛 给 AI 配一间办公室:Harness Engineering 六大模块与它的实现
前端·javascript
linux_cfan13 小时前
videojs v10 源代码系列解读:14 · 谓词守卫:在运行时安全地调用能力
前端·javascript·音视频
kyriewen13 小时前
我扒了 10,221 条 JD:腾讯技术岗 75% 在要 AI
前端·人工智能·ai编程
郑州光合科技余经理14 小时前
同城外卖小程序开发:下单成功后,后台导出能不能对上用户端状态
开发语言·前端·git·后端·uni-app·php·ai编程