2022年1月5日

Continuous Delivery

Continuous Integration

Reactで文字列を外部化する その1

私たちは文字列の外部化を解決しました。その方法と、そのために必要な要件をご紹介します。ヒント:社内で解決策を構築しました。

06.-Design_Blog-header-2-1920w.webp

本日は、Harnessで文字列の外部化をどのように解決したかを紹介します。react-intlreact-i18nextのように、すでにこれを解決しているパッケージはたくさん存在します。しかし、これらのライブラリーのうち、私たちの要件に合うものはありませんでした(少なくとも私たちのプロジェクトを立ち上げた時点では)。

要件は次の通りです。

  1. 私たちドキュメントチームが文字列を簡単に更新できるようにする必要があります。
  2. テンプレに対応すること。
  3. 開発者にとって使いやすいものであること。
  4. 文字列が存在するかどうかの検証を行う必要があります。
  5. オートコンプリートを提供すること。

両ライブラリーとも、1、2については対応していますが、それ以外の点については完全に条件を満たしているとは言えません。

react-intlの気に入らなかった点。

  1. サイズが大きい(50KB以上)。
  2. カスタムプラグイン/トランスフォーマーが必要。

しかし、私たちはreact-intlのAPIをとても気に入っており、私たち自身のソリューションにも同様のAPIを使いたいと考えていました。

react-i18nextの気に入らなかった点。

  1. APIが開発者にあまり優しいものではありませんでした。

チームで少し話し合った後、私たちは社内でソリューションを構築することにしました。その道のりをご案内します。

要件1については、Harnessの全員がYAMLに慣れているため、文字列の保存にYAMLを使用することにしました(JSONや他のフォーマットも使用可能です)。

要件2については、サイズが小さく、(特にセキュリティーの面で)よくテストされていることから、テンプレートにmustache.jsライブラリーを使うことにしました。

要件4と要件5については、TypeScriptで実現することができます。

実装

文字列に、アプリ内のどこからでもアクセスできるようにする必要があるので、ReactのContext APIを利用しました。全てのコードがここで公開されています。

今回は、以下のアプリケーションを起点に説明します。

HomePage.tsxとAboutPage.tsxの2ページだけの、とてもシンプルなアプリケーションです。

まず、文字列データを格納するためのコンテキストを作成します。

// StringsContext.tsx
import { createContext } from "react";

// initialize the context
const StringsContext = createContext({});

// props for StringsContextProvider
export interface StringsContextProviderProps {
  data: Record<string, any>;
}

// a wrapper for StringsContext.Provider for better API
export function StringsContextProvider(
  props: React.PropsWithChildren<StringsContextProviderProps>
) {
  return (
    <StringsContext.Provider value={props.data}>
      {props.children}
    </StringsContext.Provider>
  );
}

さて、文字列を保存するためのファイルを追加しましょう。ここではYAMLファイルを使いますが、好きなフォーマットを使えます。また、型安全性を確保するためにstrings.yaml.d.tsファイルを作成する必要があります。今のところ、これは一般的なものにして、後の段階で作り直すことにします。

strings.yaml.d.ts ファイルに以下の内容を追加します。

// strings.yaml.d.ts
declare const strings: Record<string, string>;
export default strings;

さて、strings.yamlファイルにコンテンツを追加してみましょう。

homePageTitle: Home
aboutPageTitle: About
homePageContent:
  para1: |
    Lorem ipsum dolor sit amet consectetur adipisicing elit. Aliquam, eos
    animi. Corporis harum quaerat dicta possimus illum aut unde laborum
    excepturi suscipit quibusdam perspiciatis dolorum alias, exercitationem
    similique illo? Distinctio.
  para2: |
    Lorem ipsum dolor sit amet, consectetur adipisicing elit. Reprehenderit
    soluta aliquam sit aliquid qui possimus? Doloremque voluptatibus
    perferendis, quas laboriosam alias eum minus pariatur, temporibus ab
    sequi facilis eaque numquam.
  para3: |
    Lorem ipsum dolor sit amet consectetur adipisicing elit. Explicabo
    optio, fuga cum velit corporis ipsam labore saepe assumenda inventore
    nobis. Laboriosam, cumque blanditiis minima accusantium inventore
    mollitia dolor optio exercitationem?
aboutPageContent:
  para1: |
    Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus
    enim accusantium cum aspernatur repellat saepe similique rerum,
    voluptatum quas voluptas omnis sint reprehenderit modi, ducimus
    provident, reiciendis porro odit impedit.
  para2: |
    Lorem ipsum dolor sit amet consectetur adipisicing elit. Omnis animi
    nisi quasi saepe nulla, aperiam blanditiis nihil quae praesentium neque
    temporibus, culpa magni excepturi ipsum nemo ratione! Recusandae,
    distinctio adipisci.
  para3: |
    Lorem ipsum, dolor sit amet consectetur adipisicing elit. Eligendi
    labore architecto recusandae nesciunt rem quo dolore quas nisi modi,
    quam quaerat voluptates nostrum, similique atque earum cumque eaque
    laudantium alias?

StringsContextProvider を使ってみましょう。そのためには、.yaml ファイルをロードできるようにビルドを設定する必要があります。その方法は、使っているツールによって異なります。今回はvitejsを使用しているので、@rollup/plugin-yamlを使う予定です。

このプラグインを使用するために、vite.config.jsを更新する必要があります。

// vite.config.js
+import yaml from "@rollup/plugin-yaml";
+
/**
  * @type {import('vite').UserConfig}
  */
const config = {
  root: "./src",
+  plugins: [yaml()],
};

StringsContextProviderを使用するようにindex.tsxを更新しました。

// index.tsx
import HomePage from "./HomePage";
import AboutPage from "./AboutPage";
import Nav from "./Nav";
+import strings from "./strings.yaml";
+import { StringsContextProvider } from "./StringsContext";
function App() {
  return (
+    <StringsContextProvider data={strings}>
      <div style={ { maxWidth: "500px" } }>
      <Nav />
      <Routes>
          <Route path="/" element={<HomePage />} />
          <Route path="/about" element={<AboutPage />} />
        </Routes>
      </div>
+    </StringsContextProvider>
  );
}

次に、コンシューマの部分をフレームワークにします。

// StringsContext.tsx
export function useStringsContext(): Record<string, any> {
  return React.useContext(StringsContext);
}
export interface UseLocaleStringsReturn {
  getString(key: string): string;
}
export function useLocaleStrings() {
  const strings = useStringsContext();
  return {
    getString(key: string): string {
      if (key in strings) {
        return strings[key];
      }
      throw new Error(`Strings data does not have a definition for: "${key}"`);
    },
  };
}

次に、useLocaleStringsフックを消費します。

// usage in Nav.tsx
import React from "react";
import { Link } from "react-router-dom";
+ import { useLocaleStrings } from './StringsContext'
export default function Nav(): React.ReactElement {
+  const { getString } = useLocaleStrings()
  return (
    <nav>
      <ul>
        <li>
-          <Link to="/">Home</Link>
+          <Link to="/">{getString("homePageTitle")}</Link>
        </li>
        <li>
-          <Link to="/about">About</Link>
+          <Link to="/about">{getString("aboutPageTitle")}</Link>
        </li>
      </ul>
    </nav>
  );
}

この時点で、strings.yamlファイルから<Nav/>コンポーネントの文字列を使用するようになったことが分かると思います。

lodashのgetとhasユーティリティー関数を利用することで、さらにAPIを改善することができます(注:代わりに同等のライブラリーやユーティリティーを利用することも可能です)。

// StringsContext.tsx
+ import has from "lodash.has";
+ import get from "lodash.get";
export function useLocaleStrings() {
  const strings = useStringsContext();
  return {
    getString(key: string): string {
-      if (key in strings) {
+      if (has(strings, key)) {
-        return strings[keys];
+        return get(strings, key);
      }
      throw new Error(`Strings data does not have a definition for: "${key}"`);
    },
  };
}

この機能拡張により、ネストしたオブジェクトに文字列だけでアクセスできるようになります。

// HomePage.tsx
import React from "react";
+import { useLocaleStrings } from "./StringsContext";
export default function HomePage(): React.ReactElement {
+  const { getString } = useLocaleStrings();
  return (
    <div>
      <h1>Home</h1>
-     <p>
-       Lorem ipsum dolor sit amet consectetur adipisicing elit. Aliquam, eos
-       animi. Corporis harum quaerat dicta possimus illum aut unde laborum
-       excepturi suscipit quibusdam perspiciatis dolorum alias, exercitationem
-       similique illo? Distinctio.
-     </p>
+      <p>{getString("homePageContent.para1")}</p>
      <p>
        Lorem ipsum dolor sit amet, consectetur adipisicing elit. Reprehenderit
        soluta aliquam sit aliquid qui possimus? Doloremque voluptatibus
        perferendis, quas laboriosam alias eum minus pariatur, temporibus ab
        sequi facilis eaque numquam.
      </p>
      <p>
        Lorem ipsum dolor sit amet consectetur adipisicing elit. Explicabo
        optio, fuga cum velit corporis ipsam labore saepe assumenda inventore
        nobis. Laboriosam, cumque blanditiis minima accusantium inventore
        mollitia dolor optio exercitationem?
      </p>
    </div>
  );
}

また、文字列を利用するためのReactコンポーネントも作ることができます。

// StringsContext.tsx
export interface LocaleStringProps extends React.HTMLAttributes<any> {
  strKey: string;
  as?: keyof JSX.IntrinsicElements;
}
export function LocaleString(props: LocaleStringProps): React.ReactElement {
  const { strKey, as, ...rest } = props;
  const { getString } = useLocaleStrings();
  const Component = as || "span";
  return <Component {...rest}>{getString(strKey)}</Component>;
}

HomePage.tsxを更新しましょう:

// HomePage.tsx
import React from "react";
-import { useLocaleStrings } from "./StringsContext";
+import { useLocaleStrings, LocaleString } from "./StringsContext";
export default function HomePage(): React.ReactElement {
  const { getString } = useLocaleStrings();
  return (
    <div>
      <h1>Home</h1>
      <p>{getString("homePageContent.para1")}</p>
-     <p>
-       Lorem ipsum dolor sit amet, consectetur adipisicing elit. Reprehenderit
-       soluta aliquam sit aliquid qui possimus? Doloremque voluptatibus
-       perferendis, quas laboriosam alias eum minus pariatur, temporibus ab
-       sequi facilis eaque numquam.
-     </p>
+     <LocaleString strKey="homePageContent.para2" as="p" />
      <p>
        Lorem ipsum dolor sit amet consectetur adipisicing elit. Explicabo
        optio, fuga cum velit corporis ipsam labore saepe assumenda inventore
        nobis. Laboriosam, cumque blanditiis minima accusantium inventore
        mollitia dolor optio exercitationem?
      </p>
    </div>
  );
}

次に、mustache.js を使って、テンプレート化のサポートを追加します。

// StringsContext.tsx
+ import mustache from 'mustache';
export interface UseLocaleStringsReturn {
-  getString(key: string): string
+  getString(key: string, variables?: any): string
}
export function useLocaleStrings() {
  const strings = useStringsContext();
  return {
-    getString(key: string): string {
+    getString(key: string, variables: any = {}): string {
      if (has(strings, key)) {
+        const str = get(strings, key);
-        return get(strings, key);
+        return mustache.render(str, variables);
      }
      throw new Error(`Strings data does not have a definition for: "${key}"`);
    },
  };
}
export interface LocaleStringProps extends HTMLAttributes<any> {
  strKey: string;
  as?: keyof JSX.IntrinsicElements;
+ variables?: any
}
export function LocaleString(props: LocaleStringProps): React.ReactElement {
-  const { strKey, as, ...rest } = props;
+  const { strKey, as, variables, ...rest } = props;
  const { getString } = useLocaleStrings();
  const Component = as || "span";
-  return <Component {...rest}>{getString(strKey)}</Component>;
+  return <Component {...rest}>{getString(strKey, variables)}</Component>;
}

アプリケーションを見ると、名前なしでHelloという文字だけが表示されています。

データを埋めるためにコンポーネントを更新してみましょう。

import React from "react";
import { useLocaleStrings, LocaleString } from "./StringsContext";
export default function HomePage(): React.ReactElement {
  const { getString } = useLocaleStrings();
  return (
    <div>
      <h1>Home</h1>
-     <p>{getString("homePageContent.para1")}</p>
+     <p>{getString("homePageContent.para1", { name: "John Doe 1" })}</p>
-     <LocaleString strKey="homePageContent.para2" as="p" />
+     <LocaleString
+       strKey="homePageContent.para2"
+       as="p"
+       variables={{ name: "World!" }
}
+     />
      <p>
        Lorem ipsum dolor sit amet consectetur adipisicing elit. Explicabo
        optio, fuga cum velit corporis ipsam labore saepe assumenda inventore
        nobis. Laboriosam, cumque blanditiis minima accusantium inventore
        mollitia dolor optio exercitationem?
      </p>
    </div>
  );
}

これで、データが正しく入力された文字列が表示されるようになりました。

このように、文字列を別々のファイルに整理して、アプリケーション内で使用することができます。

このブログで使用したコードは全てGitHubで公開されています。このブログは少し長くなってきたので、近日中に「Reactで文字列を外部化する その2」を紹介します。その2では、静的解析、検証、オートコンプリートを提供する上でTypeScriptをどのように活用できるかを見ていきたいと思います。それでは、お楽しみに。


この記事はHarness社のウェブサイトで公開されているものをDigital Stacksが日本語に訳したものです。無断複製を禁じます。原文はこちらです。


 

Harnessに関するお問い合わせはお気軽にお寄せください。

お問い合わせ