🌐

vue-i18nの翻訳をyamlファイルかつコンポーネント(SFC)毎にする普通ではない方法[改良版]

2021/07/18に公開

はじめに

この記事はvue-i18nの翻訳ファイルをコンポーネント毎に管理したいという思いで書いた前回の記事の改良版です。

https://zenn.dev/yukihirop/articles/b36c43014355fd

前回のやり方が「ほぼタラバガニ」なら今回のやり方は「タラバガニ」です。🦀

前回のやり方には以下の問題がありました。

- $etという$tを拡張したグローバルなプロパティーをVueプラグインで用意しなくちゃいけなかった
- 生成物にファイル構造が分かってしまう情報が出てしまう
 - 翻訳のルートキーをSFCへの相対パスにしているので...

これらの問題を解決してなおかつ記述量まで減ってしまう方法が今回紹介する方法です。

この記事を読めば

  • vue-i18nで翻訳ファイルをyamlでコンポーネント毎に管理できるようになります。
  • SFCでのcustom blockの活用事例が分かるようになります。(前回同様)
  • chainWebpackのカスタマイズ例が分かるようになります。(前回同様)
  • vue-i18nでの翻訳キーのつけ方の別解を知れます。(前回同様)

ゴール

locales/ja.yaml
src/App.vue:
  link:
    home: ホーム
    about: About
src/views/About.vue:
  title: これは「About」に関するページです
src/views/Home.vue:
  message: "Vue.jsへようこそ + TypeScript App"

のように用意して、

src/views/About.vue
<template>
  <div class="about">
    <h1>{{ $t('title') }}</h1>
  </div>
</template>

のようにかけるようになります。

ロケールファイルの場所はsrc配下でない事yamlファイルである事に注目してほしいです。

翻訳ファイルの読み込みはビルド時に行うのでyamlファイルでも大丈夫です。またクライアントサイドで読み込まれるファイル(src/main.tsの読み込み時に読み込まれるファイル)ではないので、src配下にある必要がなく生成物にも含まれてきません。

故に翻訳ファイルにディレクトリ構造を特定できる情報があっても生成物には含まれてきません。

yamlファイルなので例えば、GoogleSpreadSheetやPOEditorなどで管理できるようになるという点も大きいです。コンポーネント毎に分かれているので意味のある塊として管理できるのもメリットでしょう。

今回のサンプルは以下の通りです。

https://github.com/yukihirop/vue-i18n-yaml-translation-each-component

前回の失敗

前回、vue-loaderでSFCからpureなJavaScriptモジュールにトランスパイルされる時にloaderUtilsを使ってコンポーネント自身のパスを取得して、それをvmにセットして翻訳のルートキーを動的に取得していたわけですが

src/App.vue
<vue-filename-injector>
export default function (Component){
  Component.__source = "./src/App.vue"
}
</vue-filename-injector>

これの代わりにカスタムブロックを設定してあげるようにすればよかったです。

src/App.vue
<i18n>
"en":{
  "link": {
    "home": "Home",
    "about": "About"
  }
},
"ja": {
  "link": {
    "home": "ホーム",
    "about": "About"
  }
}
</i18n>

このようにi18nのカスタムブロックとして与えれば、ファイルパスをコード(生成物)に入れる事なくできる 事に気づきました。

i18nのカスタムブロックを動的に挿入する

<i18n>ブロックを動的に挿入するローダーをvue-i18n-block-dynamic-injectorとなずけ以下のように作りました。

vue-config/vue-i18n-block-dynamic-injector/index.js
/**
 * @see https://github.com/d2-projects/vue-filename-injector
 * @see https://vue-loader.vuejs.org/guide/custom-blocks.html#example
 */

const loaderPath = require.resolve("./loader");

module.exports = function (config, options) {
  // localesファイルから翻訳を読み込み、各コンポーネントに動的に<i18n>のカスタムブロックを挿入していく処理
  config.module
    .rule("vue")
    .use("vue-i18n-block-dynamic-injector")
    .loader(loaderPath)
    .options(options)
    .after("vue-loader")
    .end();
    
  // i18nカスタムブロックのトランスコンパイル
  // @see https://kazupon.github.io/vue-i18n/guide/sfc.html#vue-cli-3-0
  config.module
    .rule("i18n")
    .resourceQuery(/blockType=i18n/)
    .type("javascript/auto")
    .use("i18n")
      .loader("@intlify/vue-i18n-loader")
      .end();
};

前回と違うのは、動的に挿入されたi18nのカスタムブロックをトランスパイルしないといけないので、@intlify/vue-i18n-loaderが必要な点です。

そして、vue.config.jsに以下を書いたら終わりです。

vue.config.js
const VueI18nBlockDynamicInjector = require("./vue-config/vue-i18n-block-dynamic-injector");

module.exports = {
  chainWebpack: (config) => {
    VueI18nBlockDynamicInjector(config, {
      localePaths: ["./locales/ja.yaml", "./locales/en.yaml"],
    });
  },
};

前回よりはるかに簡単です。これで各コンポーネントにi18nのカスタムブロックが追加されるようになります。

あ、vue-i18のプラグインの読み込みを忘れないでください。

src/main.ts
const i18n = createI18n({
  locale: "ja",
  messages: {},
})
createApp(App).use(router).use(i18n).mount("#app");

翻訳はビルド時にi18nのカスタムブロックで動的に付与されるのでクライアントサイドで読み込む時は空でいいのです。

VueI18nBlockDynamicInjector(ローダー)

このローダーがやることは、ローダーオプションyomikomi(localePaths)として渡されたファイルを読み込み翻訳データを取得して、現在ビルドされているSFCの翻訳情報を抜き出し、i18nのカスタムブロックを生成して返すというものです。

vue-config/vue-i18n-block-dynamic-injector/loader.js
/**
 * @see https://github.com/d2-projects/vue-filename-injector
 * @see https://vue-loader.vuejs.org/guide/custom-blocks.html#example
 */
const fs = require("fs");
const path = require("path");
const loaderUtils = require("loader-utils");
const yaml = require("js-yaml");
const pkgDir = require("pkg-dir");
const deeepMerge = require("deepmerge");

const projectRoot = pkgDir.sync() || process.cwd();
const blockName = "i18n";

function getLocales(localePaths) {
  // { "src/views/App.vue": { ja: {...}, en: {...} } }
  return localePaths.reduce((acc, p) => {
    const localeName = path.basename(p, ".yaml");
    const yamlData = yaml.load(fs.readFileSync(`${projectRoot}/${p}`, "utf8"));

    const dataPerLocale = Object.keys(yamlData).reduce((childAcc, compPath) => {
      if (!childAcc[compPath]) {
        childAcc[compPath] = {};
      }
      if (!childAcc[compPath][localeName]) {
        childAcc[compPath][localeName] = {};
      }
      childAcc[compPath][localeName] = yamlData[compPath];
      return childAcc;
    }, {});

    acc = deeepMerge(acc, dataPerLocale);
    return acc;
  }, {});
}

module.exports = function (content) {
  const loaderContext = this;
  const { rootContext, resourcePath } = loaderContext;
  const context = rootContext || process.cwd();
  const options = loaderUtils.getOptions(loaderContext) || {};
  const rawShortFilePath = path
    .relative(context, resourcePath)
    .replace(/^(\.\.[\/\\])+/, "");

  const localePaths = options.localePaths;
  const localeData = getLocales(localePaths);
  let compPath = rawShortFilePath.replace(/\\/g, "/");

  const compLocaleData = localeData[compPath];

  /**
   * The loader may run even if 「compLocaleData」 data cannot be obtained.
   */
  if (compLocaleData != undefined) {
    content += `
<${blockName}>
${JSON.stringify(compLocaleData)}
</${blockName}>
  `;
  }

  return content;
};

試しに作っただけなので実装は至らぬところ多々あるかと思いますが、一応サンプルリポジトリ上では動きます。

デメリット

デメリットも前回と比べると随分減ります。それより翻訳ファイルをコンポーネント毎にyamlファイルで管理できるようになる利点が大きいでしょう。

- ビルドに時間が掛るようになる
 - vue-loaderで処理される前にカスタムブロックを挿入処理が入るので
- vueの破壊的な変更ですぐ動かなくなる
 - consoleデバッグでの問題解決が苦手な人には辛いかもしれないです...

前回よりデメリットも減ってgoodですね。👍

まとめ

ちょっといろんなローダーと組み合わせて試したわけではないのでもしかしたら状況によってはうまくいかないこともあるかもしれません。

是非動かして体感してみてください。

https://github.com/yukihirop/vue-i18n-yaml-translation-each-component

Discussion