vue项目国际化处理(i18n)

371 阅读6分钟

1.1 前言

关于最近项目实现国际化改造的一些记录,针对 vue 项目,使用 vue-i18n 库。

1.2 对工作量的清晰认识

参考文章:juejin.cn/post/731967…

  • 词条提取翻译:文本要用翻译函数 $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 切换全局语言

关键点:

  1. 切换后当前语言存储在 localStorage,在非 vue 场景使用(比如:初始化 createI18n 时)
  2. 切换后改变 html 的 lang 属性,用于针对改某一语言的样式
  3. 切换后改变第三方库的语言,例如组件库、dayjs
  4. 切换后刷新当前页面,使组件重新渲染,接口重新调用
  5. 在请求拦截器的 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 中文搜索定位

流程:

  1. 打开要国际化的页面,右键选择国际化-扫描中文(Du I18N)
  2. 将扫描出来的所有中文词条复制到中文 json 中,并统一修改前缀(此时还不用翻译)
  3. 修复扫描后自动包裹 $t 的大量报错,包括:模板字符串错误、恢复注释和 log 等
  4. 使用 i18n Ally 插件删除没有使用到的多余词条
  5. 复制扫描出来的所有中文词条,用 LLM 翻译:> 直接返回 json,翻译要求:专业、简洁、信雅达
  6. 最后自测所有功能:切换到英文界面,观察有没有中文遗漏、翻译是否过长、布局是否需要变宽等

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:更新通过飞书多维表格维护词条