🤩

[JS] グローバル変数を見つけ出せ!

に公開

はじめに

メリークリスマス🎅✨
現在ZOZOTOWN開発本部でフロントエンドリプレイスに従事しております、ツチヤカイタと申します。
弊社のリプレイスプロジェクトでは、旧環境のコードを読み解き、仕様を洗い出し、それを新環境向けに実装するというステップでリプレイスを進めています。
旧環境のコードは長い間保守されているためレガシーな記述も多く残っています。そこにはグローバルに定義された変数や、windowオブジェクトに差し込まれた値なども含まれています🥲

ということで、今回は私がリプレイスを進める中でグローバルな値の定義元や代入元を特定する際に行うステップを記事にしてみました。

リプレイスについての概要は下記をご覧いただけますと
https://www.wantedly.com/companies/zozo-tech/post_articles/479351

STEP1: grep / AIに聞いてみる

実際に私が行うステップを記事にしているため冗長だと思いますが記載します。

私は開発でVisual Studio Codeを利用しているのですが、まずは検索窓からワード検索を行ってみたり、Copilotに質問したりしています。運が良い時はこれで定義場所を見つけることができます。

STEP2: Object.defineProperty + console.trace() で定義ファイルを特定する

私はリプレイスプロジェクトでコードリーディングに苦戦するまで知らなかったのですが、console.trace()はウェブコンソールにスタックトレースを出力してくれるとても便利なメソッドです。
これを使うことで、どのファイル・どのコードパスから処理が実行されたかを行番号付きで確認できます。

また、Object.defineProperty はあるオブジェクトに新しいプロパティを直接定義したり、オブジェクトの既存のプロパティを変更したりして、そのオブジェクトを返します。

わかりやすくいうと、代入を検知したいグローバル変数やグローバルなObjectの対象プロパティに対し、getter / setter を定義することで、値の読み書きに処理(console.trace())を差し込める API です。
(※ただし、対象プロパティが configurable: false の場合は再定義できません)

今回は主に setter(代入) にフックを仕込み、グローバル変数に値が設定された瞬間のスタックトレースを取得します。

Object.defineProperty , console.trace() の2つを組み合わせて下記のようなコードを書きます。
なお、概要を簡潔に伝えたいため細かい部分は端折っております。

(() => {
  const target = "variableName";
  let current = globalThis[target];

  Object.defineProperty(globalThis, target, {
    configurable: true,
    get() {
      return current;
    },
    set(v) {
      console.group(`[trace] ${target} assigned: `, v);
      console.trace();
      console.groupEnd();
      current = v;
    },
  });
})();

上記のコードをDevToolsのコンソールから実行して利用します。

調査対象の Web アプリケーションの JavaScript が評価される前に、可能な限り早いタイミングで実行する必要があるため、DevToolsのブレークポイントを利用します。

私の場合はGoogle Chromeを使って開発しており、下記の手順で実行しています。

  1. 「Sources」タブより、「Event Listener Breakpoints > Script > Script First Statement」にチェックをいれる
  2. リロードし、ブレークポイントを有効化した状態で上述のコードをコンソールから実行
  3. ブレークポイントを解除
  4. コンソールに出力されたconsole.trace()の結果を確認

参考:
https://developer.chrome.com/docs/devtools/javascript/breakpoints?utm_source=chatgpt.com&hl=ja

出力例

例えばmodule1.jsで定義し、module2.jsで再代入した時は下記のように出力されます。

コードサンプル
// module1.js
var sampleVariable = "init";

// module2.js
sampleVariable = "updated";

// debug.js
(() => {
  let current = globalThis["sampleVariable"];

  Object.defineProperty(globalThis, "sampleVariable", {
    configurable: true,
    get() {
      return current;
    },
    set(v) {
      console.group(`[trace] "sampleVariable" assigned: `, v);
      console.trace();
      console.groupEnd();
      current = v;
    },
  });
})();

ウェブコンソールに出力されたスタックトレースのキャプチャ

⚠️注意

letやconstでの変数宣言ではグローバルオブジェクト(globalThis/window)のプロパティを作らないためSTEP2の方法では捕捉できません。
そのためlet/constを対象にするケースでは次のSTEP3の方法を利用します。
またESMなどのモジュール環境では、最上位の宣言はモジュールスコープに閉じ込められ、グローバルオブジェクトに乗りません。

ただし、let/constを重複定義するとSyntaxErrorになることや、モジュール環境では暗黙的にファイルを横断した変数利用が行われないことから、変数が追跡しにくい場面はそう多くないと考えています。

STEP3: DevToolsのブレークポイントで頑張る

STEP2までの手順で追えないとき、私はブレークポイントを使った目視のデバッグで泥臭く頑張っています。

例えば上述の「Event Listener Breakpoints > Script > Script First Statement」を利用すると、対象のWebページが読み込むJSファイルごとに1行目でブレークポイントを設定することができます。

つまり下記の手順でグローバル変数を定義しているファイルを特定することができます。

  1. Script First Statementを使いブレークポイントを設定する
  2. console.log(対象の変数)で値が定義されているか確認
  3. 次のブレークポイントまで継続実行
  4. (1 ~ 3を対象が見つかるまで繰り返す...)

DevToolsでは他にも様々なタイミングでブレークポイントを設定することができますし、ある程度当たりがついている場合は任意のポイントに設定しても良いと思います。

おわりに

あくまでも業務を進める上で私が行き着いた方法の紹介であり、一番筋の良い方法ではないかもしれません。もっと良い方法をご存知の方はご教示いただけますと幸いです🙏(また間違いについてもご指摘ください)

私は新規開発を担当している時期はObject.definePropertyを使用したこともなく知りませんでした。
リプレイスプロジェクトや運用では、こういった新規開発だけでは得られにくい部分を鍛えられる点が面白いと感じています🔥

株式会社ZOZO

Discussion