環境変数一つでスタイルを切り替えるシステムを作った

お知らせ
Table of Content

はじめましてこんにちは。フロントエンドエンジニアの山浦です。

今回、表題の通り「環境変数一つでスタイルを切り替えるシステム」の実装を任されたので、その全貌を追っていければと思います。

やりたいこと

同じブランチ・ソースコードでも、デプロイ先によって色や文言、画像などの見た目の情報が異なっている。そんなwebフロントシステムを作りたい!

というのが、今回のやりたいことです。言い換えると、

mainブランチに変更をpushしたら複数の環境にデプロイされ、違う見た目だけど同様のものが動いて変更もそれぞれに反映されているような状態を作りたい!

要件

やりたいことから読み取れる要件はざっくり以下の通りです。

  • CIでプロジェクトごとに設定できる一つの環境変数で以下が切り替わること
    1. 文言。例えば、
      カード→アセット
      カード一覧→アセット一覧
      カード販売所→アセットマーケット
      など
    2. サイト内で使用する色情報。
      ボタン、背景色、svgアイコン、など
    3. キービジュアルやアイコン、ファビコンなどの画像群
    4. その他リンクコンポーネントのリンク先等
  • ソースファイルとは別のディレクトリで、上述の情報を環境ごとに分けて記述できること。
  • 後から上述の変更箇所を追加・変更・削除できること。

実装方針

基本的なところはあまり難しくなく、

  1. ルート直下にparametersディレクトリを設置し、環境ごとに変更内容を記述するためのディレクトリを作成
  2. parameters/index.jsでexportする内容を環境変数(VUE_APP_PROJECT)によって決める

でOKです。こうすることで、変更内容が見えやすく、また編集しやすくなり、新たに環境の追加や変更箇所の追加が必要になっても容易に実現できます。また、ソースファイルから完全に独立させているのもポイントです。

// ファイル構成
project_root
| parameters/
| | ProjectA/
| | | imgs/
| | | | index.json
| | | | logo.svg
| | | | ...
| | | colors.scss
| | | words.js
| | | index.js
| | ProjectB/   // ProjectAと同様
| | index.js
| src/
| vue.config.js
| ...
// parameters/index.js
import ProjectA from "./ProjectA";
import ProjectB from "./ProjectB";

export const Words =
  process.env.VUE_APP_PROJECT === "ProjectA"
    ? ProjectA.Words
    : process.env.VUE_APP_PROJECT === "ProjectB"
    ? ProjectB.Words
    : undefined;

export const Imgs =
  process.env.VUE_APP_PROJECT === "ProjectA"
    ? ProjectA.Imgs
    : process.env.VUE_APP_PROJECT === "ProjectB"
    ? ProjectB.Imgs
    : undefined;

export const ...
// parameters/ProjectA/index.js
// 詳細は後ほど解説
import Words from "./words";
import Imgs from "./imgs/index.json";

export default {
  Words,
  Imgs
};

あとは、文言、色情報、画像群など、それぞれでexportの形式・手法を考えてあげます。

変更箇所のexport手法

(ここが今回一番のお悩みポイントです。)
しかし結局はソースファイルで値を読み込んで使用するので、どういう形式でimpotできれば嬉しいかを考えてあげると、答えが見えてきます。
※今回のプロジェクトではVue.jsを既に使用していましたが、他の場合でも大きな差はないと思います。

文言

<template>
  <div>
    <h1>{{ Words.card_market_name }}</h1>
    <h2>{{ Words.card_list_name }}</h2>
  </div>
</template>

<script lang="ts">
import { Words } from "/parameters";

export default {
  data() {
    return {
      Words,
    };
  },
};
</script>

だいたいこんな感じで使えたらOKでしょうか。
parameters/ProjectA/words.jsでWordsオブジェクトを定義してそれに一つ一つ値を設定していけば大丈夫そうですね。

ただ、今回のケースは少し特殊で、

カード→アセット
カード一覧→アセット一覧
カード販売所→アセットマーケット

というような、変則的な文言の変更が生じ得ました。これは少し厄介ですが、classとgetter/setterで乗り切ることにします。

class Words {
  set card_name(name) {
    this._card_name = name;
  }
  get card_name() {
    return this._card_name || "カード";
  }
  set card_list_name(name) {
    this._card_list = name;
  }
  get card_list_name() {
    return this._cards_list_name || `${this.card_name}一覧`;
  }
  set card_market_name(name) {
    this._card_market = name;
  }
  get card_market_name() {
    return this._cards_market_name || `${this.card_name}販売所`;
  }
  ...
}

このWordsクラスを各ディレクトリでインスタンス生成し、必要な分だけ値をセットしexportします。

// parameters/ProjectA/words.js
const words = new Words();

words.card_name = "アセット";
words.card_market_name = "アセットマーケット";

export default words;

いい感じですね。

色情報

今回のプロジェクトでは、スタイリングにはSCSSを使用していました。そのため、ここでも最適なimportの形を考えてあげる必要があります。(もしStyled-ComponentなどのCSS in JSを使用するのであれば、文言と全く同様の実装をして問題ないでしょう。)

こんな感じでしょうか。

<style lang="scss" scoped>
@import "~colorParameter";
h1 {
  color: $title-color,
}
</style>

parameters/ProjectA/colors.scssで各種変数を定義してやればいいのですが、jsではないのでそのままだと「環境変数によってexportするファイルを分ける」というのがやりにくそうです。ここは、vue.config.jsを書くことで解決しました。

module.exports = {
  ...
  chainWebpack: (config) => {
    const colorFilePath = path.join(
      __dirname,
      `parameters/${process.env.VUE_APP_PROJECT}/colors.scss`
    );
    config.resolve.alias.set("colorParameter", colorFilePath);
  }
}

ファイル名がcolors.scssで決めうちになってしまうのが少し残念ですが、ひとまずこれで大丈夫そうです。

画像群

文言・色情報と同様にimport形式を考えてあげれば良さそうですが、ちょっと待ってください。
画像なので動的importを使用するのですが、今回、他の環境で表示する画像はアクセスできる状態で置いておきたくない、という問題がありました。(権利とかあるし...)
なので、publicフォルダに置くのは論外、require()を使って呼び出すにしても、呼び出し方を考えてあげる必要があります。

そこで、require.contextを使います。こうすることで、トランスパイル時に使いたいimgsディレクトリの画像だけを事前にrequireし、それ以外のファイルは対象から外すことができます。

// parameters/index.js
export const context = require.context(
  `./${process.env.VUE_APP_PROJECT}/imgs`,
  true
);

また、parameters/ProjectA/imgs/index.jsonにおいて各画像の役割を決めてあげます。

{
  "logo": "./logo.svg",
  "carousel": [
    "./carousel1.png",
    ...
  ],
  "favicon": "./favicon.ico"
}

ソースファイルでは次のように使います。

<template>
    <img :src="logo" />
</template>

<script>
import { context, Imgs } from "/parameters";

export default {
  data() {
    return {
      logo: context(Imgs.logo),
    };
  },
};
</script>

これで、他の環境で使用する画像も一緒のブランチ・リポジトリに入れながら、デプロイ後はアクセスできなくなる仕組みが完成しました。ソースファイルでの使用もわかりやすくっていい感じだなと思います。

(ちなみにファビコンですが、metaタグを挿入するスクリプトを書いて適切なところで呼び出しています。)

その他

その他の変更箇所も、文言・色情報と同様に基本的には欲しいimport形式から逆算して考えてあげると、複雑なことをしないで済むと考えています。もしコンポーネント一つまるっと入れ替えるみたいなことがあっても、環境ごとのディレクトリに作成して、parameters/index.jsでexportする、という基本的な方針は変える必要はないでしょう。ただ、今回の画像群のように特別な事情がある場合は、それに則した実装方法を考えてやらないといけません。
また、事前に各環境を跨いでセンシティブな情報が含まれないかはご注意ください。

作ってみて

お気づきの方もいるかもしれませんが、ファイル名やフォルダ名を決めうちにしてしまえばparameters/ProjectA/index.jsなどを記述しないで実装することが可能です。しかしファイル名の決め打ち設定は内部の構造がブラックボックスになりやすいため、可能な限りimport/export 文を記述して関係を明確にしていった方が、他のエンジニアがコードをみたときにわかりやすくて良いと思ったためこのような実装をしました。

ところで、この切り替えシステムには欠点があります。それは、新たに切り替え可能にしなくてはならない箇所が出てきたときにソースファイルをいじることになるため、修正・コミット後正しく稼働しているかどうかを全環境で確認しなくてはならないという点です。全環境に変更等を反映させるための切り替えシステムなのですが、その分確認が大変ということですね。
ここら辺は、やっぱりテストを書いていくしかないと思います。スナップショットも必須でしょう。今後CIで効率よく回していきたいです。

まとめ・あとがき

今回は、作成した環境変数一つでスタイルを切り替えるシステムについて、ディレクトリ構成からお話ししました。
実際にはデフォルトの状態を設定したりなどもう少し色々なことをやっていますが、説明を簡単にするために省略しました。
このようなシステムが必要になる機会はあまりないと思いますが、このページに辿り着いた人の何かの役に立てば幸いです。

タイトルとURLをコピーしました