封面图片:将 Google Sheets 用作 Web 应用翻译数据库(Apps Script + Next.js)

Hayrullah Kar

我见过的每一个 i18n 方案都有相同的三方僵局。开发者想要仓库中类型安全的 JSON,译者想要熟悉的工具,而非拉取请求,产品想要在不部署的情况下修复错字。于是你要么每月支付 50–500 美元购买本地化 SaaS,要么在译者的表格和 JSON 文件之间复制粘贴字符串,直到出现静默错误。

对于少于约 1,000 个键的项目,有一个更好的折中方案:表格本身就是数据库。译者编辑 Google Sheet;Apps Script 端点将其作为干净的区域设置 JSON 提供;你的应用在构建时拉取这些数据。下面是整个模式及代码。

为什么对小型项目来说,表格优于翻译服务

本地化 SaaS 在规模化场景下——数十名译者、数千个键、截图和审阅工作流——才值这个价。一个 300 键的营销站点没有这种问题,它有的是协调问题。Sheet 免费解决了协调问题:译者已经熟悉它,它内置修订历史和建议编辑,产品可以在十秒内更改字符串。你只需添加原始表格所缺乏的两个功能——干净的 JSON API 和缺失翻译的回退。

模式:一个标签页,每行一个键

一个 strings 标签页,A 列是键,其余列分别为各区域设置:

key en tr es fr
hero.title Welcome Hoş geldiniz Bienvenido Bienvenue
hero.cta Get started Başla Empezar Commencer

使用点号表示法键(hero.title),这样 JSON 就能自然嵌套到你的 i18n 库中。还需保留一个小型 meta 标签页: B1 = 默认区域设置(en), B3 = 版本(1.0.0)。

Apps Script 端点

将其作为 Web App 部署(与任何 Apps Script webhook 机制相同)。doGet 以 JSON 形式提供一个或全部区域设置,回退逻辑直接放在查询中:空单元格解析为默认区域设置,因此半翻译的键永远不会以空白形式发布。

// Code.gs
const SHEET_ID = 'your-sheet-id';

function doGet(e) {
  const locale = (e.parameter.locale || 'all').toLowerCase();
  const result = buildLocaleData(locale);
  return ContentService
    .createTextOutput(JSON.stringify(result))
    .setMimeType(ContentService.MimeType.JSON);
}

function buildLocaleData(locale) {
  const ss = SpreadsheetApp.openById(SHEET_ID);
  const data = ss.getSheetByName('strings').getDataRange().getValues();
  const headers = data[0];            // ['key','en','tr','es','fr']
  const localeIdx = {};
  headers.forEach((h, i) => {
    if (i > 0) localeIdx[String(h).toLowerCase()] = i;
  });

  const meta = ss.getSheetByName('meta');
  const defaultLocale = meta.getRange('B1').getValue() || 'en';
  const version = meta.getRange('B3').getValue() || '1.0.0';

  if (locale !== 'all' && !localeIdx[locale]) {
    return { error: 'Unknown locale', available: Object.keys(localeIdx) };
  }

  const targets = locale === 'all' ? Object.keys(localeIdx) : [locale];
  const out = { locale, defaultLocale, version, strings: {} };
  if (locale === 'all') targets.forEach(l => out.strings[l] = {});

  for (let row = 1; row < data.length; row++) {
    const key = data[row][0];
    if (!key) continue;
    if (locale === 'all') {
      targets.forEach(l => {
        out.strings[l][key] =
          data[row][localeIdx[l]] || data[row][localeIdx[defaultLocale]];
      });
    } else {
      out.strings[key] =
        data[row][localeIdx[locale]] || data[row][localeIdx[defaultLocale]];
    }
  }
  return out;
}

Enter fullscreen mode Exit fullscreen mode

我在发布前对 buildLocaleData 进行了单元测试——关键的测试用例包括:空单元格回退到默认区域设置、跳过空白键行,以及未知区域设置返回可用区域设置列表而非 500 错误。

前端集成:构建时同步

不要在运行时调用端点——在构建时一次性拉取 JSON 并提交到 public/locales。你的应用发布静态文件,Sheet 宕机永远不会导致站点瘫痪。

// scripts/sync-locales.ts
import fs from 'fs';
import path from 'path';

const URL = process.env.LOCALE_SOURCE_URL!;
const LOCALES = ['en', 'tr', 'es', 'fr'];

async function sync() {
  for (const locale of LOCALES) {
    const res = await fetch(`${URL}?locale=${locale}`);
    const data = await res.json();
    const out = path.join(process.cwd(), 'public', 'locales', `${locale}.json`);
    fs.writeFileSync(out, JSON.stringify(data.strings, null, 2));
    console.log(`✓ ${locale}: ${Object.keys(data.strings).length} strings`);
  }
}

sync();

Enter fullscreen mode Exit fullscreen mode

将其挂到 prebuild 中,像处理其他 JSON 一样把输出交给 next-intli18next。两次部署之间,如果还需要预览环境读取实时数据,在端点前加 5 分钟边缘缓存即可。

缓存、版本控制与捕获缺失键

meta 标签页中的 version 字段允许你有意识地进行缓存失效,而非猜测。每日触发器能将“译者漏译了三条字符串”从生产事故转化为一封邮件:

// Missing-keys alert — run on a daily time trigger
function alertMissingKeys() {
  const ss = SpreadsheetApp.openById(SHEET_ID);
  const data = ss.getSheetByName('strings').getDataRange().getValues();
  const headers = data[0];
  const missing = [];
  for (let r = 1; r < data.length; r++) {
    headers.forEach((h, c) => {
      if (c > 0 && !data[r][c]) missing.push(`${data[r][0]}${h}`);
    });
  }
  if (missing.length) {
    MailApp.sendEmail('[email protected]',
      `${missing.length} missing translations`,
      missing.join('\n'));
  }
}

Enter fullscreen mode Exit fullscreen mode

何时停止使用 Sheets 并付费使用 Lokalise

请诚实地评估上限。Sheets 能舒适支撑到大约 1,000 个键;Apps Script 提供约 50–100 次/秒的请求,这对于构建时同步完全够用。真正的限制是人数:超过 5 名左右活跃译者 时,你会需要完善的角色、截图和审阅队列——这时可以将表格导出为 CSV 并迁移到 Lokalise 或 Crowdin。在此规模以下,工具本身就是协调成本,而非价值所在。

常见陷阱

  • 不要在运行时拉取。 构建时同步意味着 Sheet 宕机不会破坏你的线上站点。提交 JSON。
  • 把回退逻辑放在查询中,而不是客户端。 空单元格解析为默认区域设置(如上所示)比散布在各组件中的回退逻辑更简单、更安全。
  • 跳过空白键行。 译者会留下空行;用 if (!key) continue; 守卫,否则它们会在 JSON 中变成 "" 键。
  • 有意识地管理版本。 基于 version 字段缓存,而不是时间戳,这样修复错字不会强制全量重建,而需要重建时也不会被跳过。
  • 复数:每种形式一行。 对于超出简单计数的场景,存储 ICU MessageFormat 字符串并在客户端解析——不要自己发明复数列。

总结

对于不到一千个键的项目,i18n 不需要订阅——它需要一个共享的可编辑源和一个干净的 JSON API。Google Sheet 是前者;约 40 行 Apps Script 是后者。译者得到熟悉的工具,开发者得到 JSON,产品得到即时编辑,且不会发布空白内容。

生产版——边缘缓存层、版本固定以及完整的 Next.js 集成——已在 MageSheet 博客 上发布。

由 MageSheet 团队构建。