JavaScript のグローバルオブジェクトに立ち向かう

こんにちは kintone のフロントエンド刷新プロジェクトでフロントエンドエンジニアをしている Nokogiri です

現在 kintone ではフロントエンドのレガシーコードの刷新に取り組んでいます。 このプロジェクトでグローバルスコープの JS オブジェクト(以降グローバルオブジェクトと呼びます)と向き合ってきた過程について紹介したいと思います。 同じようなグローバルオブジェクトの取り扱いに困っている人の助けになれば幸いです。

kintone フロントエンド刷新プロジェクト(フロリア)とは

kintone フロントエンドリアーキテクチャプロジェクト(フロリア)は kintone のフロントエンド部分を刷新するプロジェクトです。次の 3 点をゴールに掲げています。

  • 全てのページが React によって表示されている ​
  • フロントエンドが技術的にもチーム的にも分割されている ​
  • ユーザー体験に関する指標に対する計測が行われており、チームの関心ごとになっている

フロリアについて詳しく知りたい方はぜひ以下のブログも読んでみてください。

blog.cybozu.io

kintone の画面初期表示用データ取得方法の現状

kintone では画面初期用データの多くをサーバーサイドのテンプレートエンジンで window 領域にグローバルオブジェクトとして埋め込む形でクライアント側に渡しています。

例)一般的なサーバーサイドのテンプレートエンジンでのグローバルオブジェクト埋め込み

<script type="text/javascript">
  /*<![CDATA[*/
  window.userData = /*[[${data}]]*/ "{ userName: 'hoge', userAge: 10, ... }";
</script>

例)フロントエンドでのデータ取得

const User = () => {
  const { userName, userAge } = window.userData;
  return (
    <div>
      <div>UserName is : {userName}</div>
      <div>UserAge is : {userAge}</div>
    </div>
  );
};

kintone では歴史的経緯から先述したグローバルオブジェクトを使った方法でサーバーサイドとのデータのやり取りをしていました。 開発者としてはこのグローバルオブジェクトを使ったデータのやり取りに課題を感じています。

kintone のフロントエンドのデータ取得の課題

おもに課題として感じているのは次のような点です。

  • メンテナンス性が悪い
  • 型安全ではない
  • 名前空間が汚染される
  • 意図しない参照が発生する

メンテナンス性が悪い

実装上どのファイルからでも自由にアクセスできるためコードの可読性と保守性を低下させます。 特にテストや Storybook で事前に準備すべきデータがわかりにくくなるため扱いづらいという問題もあります。

型安全ではない

フロントエンドの TypeScript から扱うためには、任意の型を自前でつける必要があるため型安全ではないという問題があります。

名前空間の汚染される

異なるスクリプトやライブラリなどにおける名前空間の衝突を引き起こす可能性があります。

意図しない参照が発生する

Chrome 拡張など他のスクリプトからアクセス可能であり不用意にアクセスされてしまう可能性があります。 特に kintone ではユーザーが自分で JavaScript を記述してスクリプトを実行可能であり開発側として意図せずアクセスされるリスクが高いです。

課題に対してどのように検討し対策を試してきたか?

フロリアのプロジェクトではいくつかの方法で対処をしてきました

  • REST API などの非同期通信を使って値を取得する方法に変更する
  • 参照専用の関数を作り、それ以外からの直接アクセスを禁止する

検討したそれぞれの案について、試した方法と試してみて感じた課題について紹介します。

REST API などの非同期通信を使って値を取得する方法に変更する

この方法は先述したデメリットを全て解消できる良い方法ではあるものの 「表示速度面でパフォーマンスが悪くなりユーザー体験が今よりも悪くなる」「既にユーザーに提供している API に互換性がなくなる」という問題があります。

非同期処理は同期処理に比べて画面が表示されるまでの待ち時間が長くなってしまいユーザー体験が悪くなるデメリットがあります。SSR を導入するという選択肢もありますが kintone では現在フロントエンドだけでなくインフラ基盤刷新の過渡期でもあるため、フロリアではフロントエンド基盤の範囲を超えた刷新を選択していません。

kintone には kintone JavaScript API というものがあります。 これは kintone ユーザーが自分で記述した JavaScript から kintone を操作するための API です。 この API では現在同期的にデータを参照するものがあり、非同期通信に変更することで API の互換性がなくなってしまうという課題があります。

kintone というプロダクトはこういった API を利用したカスタマイズが魅力の一つでもあり、多くのユーザーにご利用いただていることもあって簡単に互換性をなくしたアップデートができるわけではありません。

参照専用の関数を作り、それ以外からの直接アクセスを禁止する

グローバルオブジェクトにアクセスできるものを一部の関数に限定することでコードの可読性を上げることはできます。 TypeScript を採用して関数経由でアクセスするルールにすることで関数の戻り値を指定でき型の安全性も担保できました。

しかしこの専用関数はコンポーネントや Hooks のどこからアクセスされるのか理解するのが難しいという問題は残ります。 結局実際にテストや Storybook で事前に window 領域に値を注入してから起動するのですが、何が必要かわからないため既存の画面の全てのグローバルオブジェクト を注入してから開始するという運用に落ち着きました。

例)

// アクセサー
export const getGlobalUserData = () => window.userData;
export const getGlobalCompanyData = () => window.companyData;
// 子供のコンポーネントからグローバルオブジェクトにアクセスしている場合、何が必要なのかわからない
const Story = <Page />;

課題を踏まえてのアプローチ

課題を踏まえてグローバルオブジェクトの取り扱いについて、別のアプローチを検討し試しています。

react-dom の render 関数 実行時に component の props としてグローバルスコープ JS オブジェクトを渡す

Next.jsgetSeverSidePropsgetStaticProps から着想を得ました。 Next.js ではページ表示前に prefetch した値を props として渡すことで 非同期通信処理なしでサーバーから取得した値やビルド時に設定した値を渡すことができます。

これに倣い、以下のような実装で Page コンポーネント描画前にグローバルオブジェクトを props として渡すようにしました。

import { Page } from "./Page";

const container = document.getElementById("root");
const root = createRoot(container!);

// Page以下のコンポーネントからはアクセスを禁止しrender時のpropsでのみ渡すようにする
root.render(<Page {...getServerSideProps()} />);

const getServerSideProps = () => {
  return {
    userName: window.userData.userName,
    userAge: window.userData.userAge,
  };
};

グローバルオブジェクトの参照箇所を ReduxStore と ReactContextProvider 初期化時に限定する

ページのトップレベルコンポーネントに props として渡すだけだと、子供のコンポーネントへのバケツリレーが多く発生してしまいます。 そこで画面の GUI の表示に必要な値で更新する値は ReduxStore に、静的な値は ReactContext 経由で子供のコンポーネントに渡すことにしました。

const PageContents = () => {
  // ReduxStoreにselector経由でアクセスしたり、useContextでアクセスする
  ...
}

const Page = (props: ServerSideProps) => {
  // ReduxStoreの初期
  const store = createReduxStore(props);
  // ContextValueの初期化
  const initialContextValue = createReactContextInitialValue(props);

  return (
    <ReduxProvider store={store}>
      <ReactContext.Provider value={initialContextValue}>
       <PageContents>
      </ReactContext.Provider>
    </ReduxProvider>
  );
};

テストや Storybook 用の MockProvider を準備する

Redux や Context を使っても子供のコンポーネントがどのグローバルオブジェクトにアクセスしているかわかりにくいことには変わりありません。 とはいえグローバルオブジェクトにアクセスできるのは初期化時のみに限定しているので、テストや Storybook では MockProvider を用意することでグローバルオブジェクトを個別に準備しなくてもよいようにしました。

const FAKE_PROPS = {
  userName: "hoge",
  userAge: 10,
};

// グローバルオブジェクトのうちテスト時に上書きしたいものだけを上書きできるようにするため再起的にPartialな値を受け取る
type Props = NestedPartial<ServerSideProps>;

const MockProvider = ({
  props,
  children,
}: {
  props: Props;
  children: React.ReactNode;
}) => {
  // あらかじめデフォルト値をFAKE_PROPSとして準備し、引数のPropsとマージする
  const merged = merge(FAKE_PROPS, props);
  const store = createReduxStore(merged);
  const initialContextValue = createReactContextInitialValue(merged);
  return (
    <ReduxProvider store={store}>
      <ReactContext.Provider value={initialContextValue}>
        {children}
      </ReactContext.Provider>
    </ReduxProvider>
  );
};
const Story = (
  <MockProvider
    props={{
      // FAKE_PROPSとの差分だけ宣言する
      userName: "fuga",
    }}
  >
    <PageContents />
  </MockProvider>
);

グローバルオブジェクトを Zod を使って validate する

getServerSidePropswindow領域にアクセスするときに型安全ではないという問題は依然として残りました。

そのためZod を利用して型安全に扱えるようにしています。

Zod は TypeScript の型を利用してデータを静的にチェックできるライブラリです。

const ServerSidePropsSchema = z.object({
  userName: z.string(),
  userAge: z.number(),
});

const parseServerSideProps = (serverSideProps: unknown) =>
  ServerSidePropsSchema.parse(serverSideProps);

export type ServerSideProps = ReturnType<typeof parseServerSideProps>;

export const getServerSideProps = (): ServerSideProps => {
  const data: unknown = {
    // @ts-ignore
    userName: window.userData.userName,
    // @ts-ignore
    userAge: window.userData.userAge,
  };

  return parseServerSideProps(data);
};

これで getServerSideProps したときに意図せずデータの型が変わった場合やプロパティが削除された場合などでエラーが発生するので開発中のバグに気づきやすくなります。

これらのアプローチを踏まえた結果のイメージは以下のようになります。

最終的なグローバルオブジェクトの取り扱い構造イメージ

おわりに

グローバルオブジェクトの扱いについて試行錯誤の結果、テストでのモックがしやすくなり Storybook で必要なデータのみを用意することができるようになりました。 トレードオフとしてコードの複雑性が多少上がってしまいましたが、チームとしては元の状態よりもグローバルオブジェクトを扱いやすくなっており、今後も改善していきたいと思っています。

刷新プロジェクトではレガシーコードの刷新をするために様々な工夫をしながら粛々と進めています。 今回の取り組みが同じようなレガシー刷新の助けになれば幸いです。

サイボウズではフロントエンドエンジニアを募集しています