1.1 前言
关于最近项目实现国际化改造的一些记录,针对 vue 项目,使用 vue-i18n 库。
1.2 对工作量的清晰认识
- 词条提取翻译:文本要用翻译函数 $t 包裹、词条写入 json、json 和 excel 转换
- 布局样式修改:文本翻译后可能溢出、排版错乱等,需要针对不同语言分别处理
- 功能修正:用中文做判断的地方、后端返回的中文等,需要修改原来逻辑
- 第三方库的国际化:组件库一般都有提供不同语言,但有些库是没有的,那就很麻烦
- 有些图片或文件包含中文,就需要提供不同语言的版本
从以上 5 点和页面复杂度来正确评估工作量,避免过于乐观!
(可以先拿一个简单页面和一个复杂页面进行国际化整改,记录花费的时间)
1.2 vue-i18n 使用
官网文档:vue-i18n.intlify.dev/guide/advan…
使用总结:
create.ts
import { createI18n } from 'vue-i18n';
import zh-CN from './zh-CN.json';
import en-US from './en-US.json';
const currentLang = localStorage.getItem('lang') || 'zh-CN';
const i18n = createI18n({
legacy: false, // 使用 Composition API 模式
globalInjection: true, // 在组件注入全局 Composer 实例的属性和方法,这样即使是 Composition API 模式,也能在模板上直接使用 $t
locale: currentLang, // 主语言
fallbackLocale: currentLang, // 备用
messages: {
'zh-CN': zh-CN,
'en-US': en-US,
},
});
export default i18n;
main.ts
import i18n from './i18n/create';
app.use(i18n);
zh-CN.json
- 将词条分成【Global】和【特定文件】两大类来维护
- 能复用的就放在 Global,文件特有的就放在文件名称下
{
"Global": {
// 能复用的就放在这
"0": "确认",
"1": "取消",
"2": "请输入"
},
"文件名称A(如果可能重复就加上路由名称)": {
// 针对文件A的词条
"0": "全部阶段",
"1": "全部状态",
"2": "@:Global.0" // 引用全局词条
}
}
在 vue3 组件内使用
// 模板
$t('orderNew.1') // 引用词条
$t('orderNew.2', [used, total]) // 动态拼接词条,词条里使用 {索引} 引用变量
:width="{ 'en-US': '150px' }[locale] || '100px'" // 动态设置不同语言样式
// setup
import { useI18n } from 'vue-i18n'
/**
* global:使用全局 Composer 实例,也是默认值
* local:使用本地 Composer 实例,只在本地需要语言特殊化处理时使用
* */
const { t: $t, locale } = useI18n({ useScope: 'global' });
const isZhCN = computed(() => locale.value === 'zh-CN')
const isEnUS = computed(() => locale.value === 'en-US') locale.value = 'en-US'; // 直接修改 locale 就能切换语言
在 js 文件内使用
import i18n from '@/i18n';
const $t = i18n.global.t;
i18n.scss
- 最好单独创建一个 i18n 的样式文件来存放国际化样式,而不是混杂在各个地方!
/* #region 新建订单页面 */
html[lang='en-US'] {
// 针对en-US做样式调整
.order-new .page-container .page-left {
width: 300px;
}
.order-new .page-container .page-left.fold {
left: -300px;
}
.order-new .page-container .page-right {
margin-left: 300px;
}
}
/* #endregion 新建订单页面 */
1.3 通过飞书多维表格维护词条
优势:不通过 excel 做中介,更加方便,并且飞书表格天然支持多人协作修改。
前端调用飞书 API 来控制飞书多维表格:
const fs = require('fs');
const path = require('path');
const axios = require('axios');
/** 中文json路径 */
const zhFile = path.resolve(__dirname, '../src/i18n/lang/zh-CN.json');
/** 英文json路径 */
const enFile = path.resolve(__dirname, '../src/i18n/lang/en-US.json');
/** 飞书应用ID */
const APP_ID = 'xxx';
/** 飞书应用Secret */
const APP_SECRET = 'xxx';
/** tenant_access_token */
let tenantAccessToken = null;
/** 飞书具体的多维表格的url链接 */
const wikiUrl = 'xxx';
/** 多维表格app_token */
let appToken = null;
/** 多维表格table_id */
let tableId = null;
/** 获取 tenant_access_token */
async function getTenantToken() {
const res = await axios.post('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
app_id: APP_ID,
app_secret: APP_SECRET,
});
return res.data.tenant_access_token;
}
/**
* 从飞书 wiki 链接中提取 node_token
*/
function extractNodeToken(wikiUrl) {
const u = new URL(wikiUrl);
// /wiki/xxxxxxx?...
const paths = u.pathname.split('/');
return paths[paths.length - 1]; // 最后一段就是 node_token
}
/**
* 调用飞书 API 获取节点信息,从而拿到 app_token
*/
async function getBitableInfo(wikiUrl, tenantAccessToken) {
const nodeToken = extractNodeToken(wikiUrl);
// 调用飞书 wiki 节点信息 API
const resp = await axios.get(`https://open.feishu.cn/open-apis/wiki/v2/spaces/get_node?token=${nodeToken}`, {
headers: {
Authorization: `Bearer ${tenantAccessToken}`,
},
});
if (resp.data.code !== 0) {
throw new Error('飞书 API 调用失败: ' + JSON.stringify(resp.data));
}
const node = resp.data.data.node;
if (node.obj_type !== 'bitable') {
throw new Error('该节点不是多维表格');
}
const appToken = node.obj_token;
// 从链接里拿 table_id
const u = new URL(wikiUrl);
const tableId = u.searchParams.get('table');
return {
node_token: nodeToken,
app_token: appToken,
table_id: tableId,
title: node.title,
};
}
/** 扁平化 JSON */
function collectKeys(obj, prefix = '') {
let res = {};
for (const key in obj) {
const value = obj[key];
const newKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'object') {
Object.assign(res, collectKeys(value, newKey));
} else {
res[newKey] = value;
}
}
return res;
}
/** 反扁平化 JSON */
function unflatten(obj) {
const result = {};
for (const key in obj) {
const keys = key.split('.');
keys.reduce((acc, k, i) => {
if (i === keys.length - 1) {
acc[k] = obj[key];
} else {
acc[k] = acc[k] || {};
}
return acc[k];
}, result);
}
return result;
}
/** JSON -> 飞书 */
async function pushToFeishu() {
const zh = JSON.parse(fs.readFileSync(zhFile, 'utf8'));
const en = JSON.parse(fs.readFileSync(enFile, 'utf8'));
const zhFlat = collectKeys(zh);
const enFlat = collectKeys(en);
const allKeys = Object.keys(zhFlat);
// 获取表格已有记录
let hasMore = true;
let pageToken = null;
const existingRecords = {}; // id -> record
while (hasMore) {
const res = await axios.get(
`https://open.feishu.cn/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records`,
{
headers: { Authorization: `Bearer ${tenantAccessToken}` },
params: { page_token: pageToken || undefined },
}
);
const { items, has_more, page_token } = res.data.data;
items?.forEach((r) => {
existingRecords[r.fields.id] = r;
});
hasMore = has_more;
pageToken = page_token;
}
// 准备批量更新/创建
const recordsToCreate = [];
const recordsToUpdate = [];
allKeys.forEach((id) => {
const fixedFields = { id, 中文: zhFlat[id] || '', 英文: enFlat[id] || '' };
if (existingRecords[id]) {
// 保留原有其他字段,只更新固定列
const updatedFields = { ...existingRecords[id].fields, ...fixedFields };
recordsToUpdate.push({ record_id: existingRecords[id].id, fields: updatedFields });
} else {
recordsToCreate.push({ fields: fixedFields });
}
});
// 批量更新(每次最多 500 条)
for (let i = 0; i < recordsToUpdate.length; i += 500) {
const batch = recordsToUpdate.slice(i, i + 500);
await axios.post(
`https://open.feishu.cn/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records/batch_update`,
{ records: batch },
{ headers: { Authorization: `Bearer ${tenantAccessToken}` } }
);
console.log(`已更新 ${i + batch.length}/${recordsToUpdate.length}`);
}
// 批量创建(每次最多 500 条)
for (let i = 0; i < recordsToCreate.length; i += 500) {
const batch = recordsToCreate.slice(i, i + 500);
await axios.post(
`https://open.feishu.cn/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records/batch_create`,
{ records: batch },
{ headers: { Authorization: `Bearer ${tenantAccessToken}` } }
);
console.log(`已创建 ${i + batch.length}/${recordsToCreate.length}`);
}
console.log('JSON 已推送到飞书表格');
}
/** 飞书 -> JSON */
async function pullFromFeishu() {
const tenantToken = await getTenantToken();
let zhFlat = {};
let enFlat = {};
let hasMore = true;
let pageToken = null;
while (hasMore) {
const res = await axios.get(
`https://open.feishu.cn/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records`,
{
headers: { Authorization: `Bearer ${tenantToken}` },
params: { page_token: pageToken || undefined },
}
);
const { items, has_more, page_token } = res.data.data;
items.forEach((r) => {
const f = r.fields;
zhFlat[f.id] = f['中文'] || '';
enFlat[f.id] = f['英文'] || '';
});
hasMore = has_more;
pageToken = page_token;
}
fs.writeFileSync(zhFile, JSON.stringify(unflatten(zhFlat), null, 4));
fs.writeFileSync(enFile, JSON.stringify(unflatten(enFlat), null, 4));
console.log('飞书表格已同步到 JSON');
}
/** 主入口 */
async function main() {
const tenantToken = await getTenantToken();
tenantAccessToken = tenantToken;
const info = await getBitableInfo(wikiUrl, tenantToken);
appToken = info.app_token;
tableId = info.table_id;
const cmd = process.argv[2];
if (cmd === 'push') {
await pushToFeishu();
} else if (cmd === 'pull') {
await pullFromFeishu();
} else {
console.log('用法: node i18nTool.js push | pull');
}
}
main();
1.3 切换全局语言
关键点:
- 切换后当前语言存储在 localStorage,在非 vue 场景使用(比如:初始化 createI18n 时)
- 切换后改变 html 的 lang 属性,用于针对改某一语言的样式
- 切换后改变第三方库的语言,例如组件库、dayjs
- 切换后刷新当前页面,使组件重新渲染,接口重新调用
- 在请求拦截器的 header 增加 lang 字段,表示当前使用的语言
参考代码:
const { locale } = useI18n({ useScope: 'global' });
const langStorage = useStorage('lang', locale.value);
const html = document.querySelector('html');
html.setAttribute('lang', locale.value);
function toggleGlobalLocale() {
locale.value = locale.value === 'zh-CN' ? 'en-US' : 'zh-CN';
langStorage.value = locale.value;
const html = document.querySelector('html');
html.setAttribute('lang', locale.value);
window.location.reload();
}
1.4 我的国际化处理套路(半自动)
vscode 安装插件:Du I18N(主要依赖的插件)、i18n Ally、vue-i18n 中文搜索定位
流程:
- 打开要国际化的页面,右键选择国际化-扫描中文(Du I18N)
- 将扫描出来的所有中文词条复制到中文 json 中,并统一修改前缀(此时还不用翻译)
- 修复扫描后自动包裹 $t 的大量报错,包括:模板字符串错误、恢复注释和 log 等
- 使用 i18n Ally 插件删除没有使用到的多余词条
- 复制扫描出来的所有中文词条,用 LLM 翻译:> 直接返回 json,翻译要求:专业、简洁、信雅达
- 最后自测所有功能:切换到英文界面,观察有没有中文遗漏、翻译是否过长、布局是否需要变宽等
PS:不要想着能有那种完全自动化的插件或库,能给你一次性处理到位。我觉得这是不存在的,即使真的存在,也不一定适用于你的项目。并且生产项目它改了哪些我也得一个个审核自测,否则不放心。
最后推荐一下我的 vscode 插件:vue-i18n 中文搜索定位
这个插件主要解决的问题是:当文件中都是用 $t('key') 引用词条时,能快速通过中文搜索定位词条
1.5 消除并提取重复的中文词条脚本
效果:找到所有重复中文,提取到 Global 中,原位置使用 @:Global 引用 脚本代码:
const fs = require('fs');
const path = require('path');
const zhPath = path.resolve(__dirname, '../src/i18n/lang/zh-CN.json');
const enPath = path.resolve(__dirname, '../src/i18n/lang/en-US.json');
const zhFile = JSON.parse(fs.readFileSync(zhPath, 'utf8'));
const enFile = JSON.parse(fs.readFileSync(enPath, 'utf8'));
// 遍历 JSON,收集词条及对应 key(跳过 Global 引用)
function collectEntries(obj, prefix = '') {
const result = [];
for (const key in obj) {
const value = obj[key];
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'string') {
if (!value.startsWith('@:Global.')) {
// 跳过已引用 Global 的
result.push({ key: fullKey, value });
}
} else if (typeof value === 'object' && value !== null) {
result.push(...collectEntries(value, fullKey));
}
}
return result;
}
const zhEntries = collectEntries(zhFile);
const enEntries = collectEntries(enFile);
// 确保 Global 存在
if (!zhFile.Global) zhFile.Global = {};
if (!enFile.Global) enFile.Global = {};
// 处理 Global 内部重复,建立 value -> key 映射
const globalValueToKey = {};
for (const [k, v] of Object.entries(zhFile.Global)) {
if (!globalValueToKey[v]) {
globalValueToKey[v] = k;
} else {
// 删除多余 key
delete zhFile.Global[k];
delete enFile.Global[k];
}
}
// 建立中文 -> 所在 key 映射
const map = new Map();
for (const entry of zhEntries) {
if (!map.has(entry.value)) map.set(entry.value, []);
map.get(entry.value).push(entry.key);
}
// 找出重复中文(出现次数 > 1)
const duplicates = [...map.entries()].filter(([_, keys]) => keys.length > 1);
// 获取 Global 最大数字 key
const getMaxGlobalKey = (obj) => {
const keys = Object.keys(obj.Global || {});
return keys.length ? Math.max(...keys.map((k) => parseInt(k))) : -1;
};
let nextKey = getMaxGlobalKey(zhFile) + 1;
duplicates.forEach(([zhValue, keys]) => {
// 如果 Global 里已有该 value,直接复用
let globalKey = globalValueToKey[zhValue];
if (!globalKey) {
globalKey = String(nextKey++);
zhFile.Global[globalKey] = zhValue;
const enSample = enEntries.find((e) => keys.includes(e.key));
enFile.Global[globalKey] = enSample ? enSample.value : '';
globalValueToKey[zhValue] = globalKey;
}
// 替换原 key 为 Global 引用(排除 Global 自身及已引用)
keys.forEach((k) => {
if (k.startsWith('Global.')) return;
const parts = k.split('.');
let cursorZh = zhFile;
let cursorEn = enFile;
for (let i = 0; i < parts.length - 1; i++) {
cursorZh = cursorZh[parts[i]];
cursorEn = cursorEn[parts[i]];
}
const last = parts[parts.length - 1];
// 已经是 Global 引用就跳过
if (typeof cursorZh[last] === 'string' && cursorZh[last].startsWith('@:Global.')) return;
cursorZh[last] = `@:Global.${globalKey}`;
cursorEn[last] = `@:Global.${globalKey}`;
});
});
// 写回文件
fs.writeFileSync(zhPath, JSON.stringify(zhFile, null, 4), 'utf8');
fs.writeFileSync(enPath, JSON.stringify(enFile, null, 4), 'utf8');
console.log('去重完成!');
1.6 结束
如果文章对你有帮助,可以点赞收藏。
2025/08/10:发布文章
2025/09/13:更新vue-i18n使用、我的国际化处理套路、消除并提取重复的中文词条脚本
2025/09/20:更新通过飞书多维表格维护词条