📝

vue-i18nの翻訳をコンポーネント(SFC)毎にする普通ではない方法

2021/07/17に公開

はじめに

vue-i18nを使っている人なら一度や二度は翻訳のキーのつけ方に悩んだ人は多いかと思います。
しかも複数人で開発していると人によってキーのつけ方の癖が違ったり、ルール化していてもそのルールを常に意識しながら開発するのは辛いです。そういう問題に対しての一つの解決策を提示してみようかと思います。

「コンポーネント専用の翻訳ファイルでルートキーがユニークならそれに続くキーは適当でも大して問題ないのではないか? キーのつけ方が適当になってしまったとしてもそれはそのコンポーネントの翻訳で閉じているし、影響範囲は明確なので大して問題ないのではなかろうか?」

です。

環境

  • vue3
  • vue-i18n@next

この記事を読めば

  • SFCでのcustom blockの活用事例が分かるようになります。
  • chainWebpackのカスタマイズ例が分かるようになります。
  • vue-i18nでの翻訳キーのつけ方の別解を知れます。
  • vue3でのプラグインの作り方が分かります。

ゴール

このような翻訳ファイルを用意して

export default {
  "src/App": {
    link: {
      home: "Home",
      about: "About",
    },
  },
  "src/views/About": {
    title: "This is an about page",
  },
  "src/views/Home": {
    message: "Welcome to Your Vue.js + TypeScript App",
  },
};

$tを拡張した$etを使って以下のようにかけるようになるというものです。

<template>
  <div class="about">
    <h1>{{ $et('title') }}</h1>
    <!-- 以下の実装と等価です -->
    <h1>{{ $t('src/views/About.title') }}</h1>
  </div>
</template>

便利でしょうか?

翻訳ファイルを見ただけで画面がどういうものなのか伝わってくる感を体験してみてほしいです。

以下に例を置いてます。

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

$etを作る

$etというのは適当に名前をつけたのですが、$tを拡張した関数です。

src/plugins/ext-vue-i18n/index.ts
import i18n from "@/locales";
import { getCurrentInstance } from "vue";

/**
 * @see https://github.com/vuejs/vue-devtools/blob/8e42bfec667489bb52e13b9885c1e684e0f1cc2a/src/backend/index.js#L328
 * @see https://github.com/d2-projects/vue-filename-injector
 * @see https://v3.vuejs.org/guide/typescript-support.html#augmenting-types-for-globalproperties
 */
export default {
  install(app: any, _options: any) {
    app.use(i18n);
    app.config.globalProperties.$et = function (key: string, options: any) {
      const vm = getCurrentInstance()!.proxy!;
      const original_t = vm.$t.bind(this);
      //「vue-filename-injector」 sets $options.__source to file path to itself at compile time
      const compPath = vm.$options.__source.replace(/\.[^/.]+$/, "");

      return original_t(`${compPath}.${key}`, options);
    };
  },
};

このような実装になります。細かい事を除いてざっくり表示すると

app.config.globalProperties.$et = function (key: string, options: any) {
   // 省略
      const compPath = vm.$options.__source.replace(/\.[^/.]+$/, "");
   return original_t(`${compPath}.${key}`, options);
}

$etというのはvue-i18nのプラグインを読み込む事でvmに生えた$tを拡張しているだけの関数であることがわかります。

そしてvue-i18nのプラグインを読み込む代わりにこれを読み込む事で使えるようになるのですが

import VueI18nPlugin from "@/plugins/ext-vue-18n";
createApp(App).use(router).use(VueI18nPlugin).mount("#app");

vue.config.jsを削除してから動かそうとすると、エラーが出ます。

Uncaught TypeError: Cannot read property 'replace' of undefined
    at Proxy.app.config.globalProperties.$et (index.ts?fc16:17)
    at eval (App.vue?3dfd:3)
    at Proxy.renderFnWithContext (runtime-core.esm-bundler.js?5c40:1125)
    at Proxy.eval (vue-router.esm-bundler.js?6c02:2146)
    at renderComponentRoot (runtime-core.esm-bundler.js?5c40:1168)
    at componentEffect (runtime-core.esm-bundler.js?5c40:5214)
    at reactiveEffect (reactivity.esm-bundler.js?a1e9:42)
    at effect (reactivity.esm-bundler.js?a1e9:17)
    at setupRenderEffect (runtime-core.esm-bundler.js?5c40:5167)
    at mountComponent (runtime-core.esm-bundler.js?5c40:5126)

compPathを用意するところで

const compPath = vm.$options.__source.replace(/\.[^/.]+$/, "");

と書いているのですが、vm.$optionsにはそもそも__sourceというプロパティーが生えていおりません。なのでこれを用意してあげる必要があります。

vm.$options.__sourceを用意する方法

まず、vm.$optins.__sourceは、 コンポネント自体のファイルパスを表しています。
ファイルパスを取得できるのはビルドの時なのでその時になんとか解決してやる必要があります。

vueにはvue.config.jsのchainWebpackの設定を書く事でビルド時に色々処理をできるような、仕組みがあります。それを利用して作ります。

とりあえずvm.$optins.\__sourceにファイルパスを与える機能をVueFilenameInjectorとして用意したとするとこんな感じに書くことになります。

vue.config.js
const VueFilenameInjector = require("./vue-config/vue-filename-injector");

module.exports = {
  chainWebpack: (config) => {
    VueFilenameInjector(config, {
      propName: "__source",
    });
  },
};

VueFilenameInjectorの実装は以下のようになっています。

vue-config/vue-filename-loader/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) {
  config.module
    .rule("vue")
    .use("vue-filename-injector")
    .loader(loaderPath)
    .options(options)
    .after("vue-loader")
    .end();
};

簡単に説明したら

  • .vue拡張子のファイルを見つけたらvue-filename-injectorというloaderPathにあるローダーを使います。
  • その後にvue-loaderでSFCをJavaScriptのモジュールにトランスパイルして終わります。

というものです。

loaderPathが指しているローダーが以下の通りです。

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

const path = require("path");
const loaderUtils = require("loader-utils");
const blockName = "vue-filename-injector";
const defaultPropName = "__source";

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 propName = options.propName || defaultPropName;

  content += `
<${blockName}>
export default function (Component) {
  Component.${propName} = ${JSON.stringify(
    rawShortFilePath.replace(/\\/g, "/")
  )}
}
</${blockName}>
`;
  return content;
};

このローダーがsrc/views/App.vueに対して走った場合の事を考えると、contentというのにsrc/views/App.vueテキストとして読み込んだものが入っていて、それに以下のカスタムブロック(vue-filename-injector)を追加しますという事です。

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

つまりこのVueFilenameInjectorをchainWebpackで読み込まず、直接src/views/App.vue
に直接書いても同じ結果が得られます。しかしながらいちいち本質的でない内容をSFCに書いていくのはナンセンスなのでビルド時のvue-loaderが走る前に動的に設定してあげているのです。

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

これをvue-loaderがトランスパイルすると、vm.$options.__sourceに./src/views/App.vue が設定されるというカラクリです。

これで$etをVueのプラグインとして動的に定義する際にエラーが出ないようになります。

$etの型に関して

$etを動的に定義できましたが型推論が効かない状態です。
なので効かせるように設定しましょう。

src/index.d.ts
import VueI18n from "vue-i18n";

declare class VueI18nExt extends VueI18n {
  et(key: VueI18n.Path, values?: VueI18n.values): VueI18n.TranslateResult;
}

/**
 * @see https://v3.vuejs.org/guide/typescript-support.html#augmenting-types-for-globalproperties
 */
declare module "@vue/runtime-core" {
  export interface ComponentCustomProperties {
    $et: typeof VueI18nExt.prototype.et;
  }
}

デメリット

ここまでの話を聞けば「魚ーこれは便利」と思われるかもしれないですが、最後にデメリットに関して書いておきます。

- ビルドに時間が掛るようになる
 - vue-loaderで処理される前にカスタムブロックを挿入処理が入るので
- vueの破壊的な変更ですぐ動かなくなる
 - consoleデバッグでの問題解決が苦手な人には辛いかもしれないです。。。
- 生成物にファイル構造が分かってしまう情報が出てしまう。
 - 翻訳のルートキーをSFCへの相対パスにしているので。。。
 - これが一番問題な気がする。

うーん...。利用するかどうかは個人にお任せします。😅

まとめ

こちらに例を置いております。ぜひ動かして体験してみてください。

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

参考

Discussion