SODA Engineering Blog
🔍

TypeScriptを使って、エントリーポイントに紐づくts, js, vueの依存関係を追う

2024/06/13に公開

はじめに

現在 https://snkrdunk.com/ では、多くのWebアプリケーションにおいて、webpackを利用してマルチページアプリケーション(MPA)の構成でFrontendの実装をしています。
エントリーポイントは100を超え、その大半がVue.jsとJavaScript、TypeScriptを組み合わせて実装されています。
今回は、そんな環境の依存関係を紐解くべく調査のためのscriptを作成したお話になります。

エントリーポイントごとの依存関係をJSON形式で生成

outputData.json
[
  {
    "entryPointPath": "src/entryPointA.js",
    "dependencies": [
      "vue",
      "vue-router",
      "src/moduleA.js",
      "src/moduleB.js"
    ]
  },
  {
    "entryPointPath": "src/entryPointB.js",
    "dependencies": [
      "vue",
      "vue-router",
      "src/moduleA.js",
      "src/moduleC.js"
    ]
  },
  ...
]

JSON形式で作っておけば、加工は後からいくらでもできるなと思い、今回はそのようにしました。

調査用scriptを用意

ASTを使えば、欲しい情報が生成できそう!というイメージはなんとなく持っていました。
ChatGPTと何往復かやりとりをし、TypeScriptをlibraryとして利用すれば出来そうとなりました。

サンプルコード

実際には、構成に合わせていくつか工夫して実装し直しています。

scripts/list-dependencies-by-webpack-workspace.js
const fs = require("fs");
const path = require("path");
const ts = require("typescript");
const webpackConfig = require(path.resolve(__dirname, "../webpack.config.cjs"));

const rootDir = path.resolve(__dirname, "../");

/**
 * ファイルの依存関係を再帰的に探索する。
 * @param {string} filePath - 解析するファイルのパス
 * @param {Set<string>} seen - 既に解析されたファイルのセット
 */
function findDependencies(filePath, seen = new Set()) {
  if (fs.statSync(filePath).isDirectory()) return;
  if (seen.has(trimPath(filePath))) return;
  seen.add(trimPath(filePath));

  let content;
  if (filePath.endsWith(".vue")) {
    content = extractScriptFromVue(filePath);
  } else {
    content = fs.readFileSync(filePath, "utf8");
  }

  // TypeScript の AST 構造にパースする
  const sourceFile = ts.createSourceFile(
    filePath,
    content,
    ts.ScriptTarget.ESNext,
    true,
    ts.ScriptKind.TS,
  );

  // AST 構造を走査し、`import` と `require` の依存関係を探す
  function visitNode(node) {
    if (ts.isImportDeclaration(node)) {
      // import文の処理
      const moduleName = node.moduleSpecifier.text;
      const resolvedPath = resolveModulePath(filePath, moduleName);
      if (fs.existsSync(resolvedPath)) {
        findDependencies(resolvedPath, seen);
      } else {
        if (seen.has(resolvedPath)) return;
        seen.add(resolvedPath);
      }
    } else if (
      ts.isCallExpression(node) &&
      node.expression.escapedText === "require" &&
      node.arguments.length
    ) {
      // require文の処理
      const moduleName = node.arguments[0].text;
      const resolvedPath = resolveModulePath(filePath, moduleName);
      if (fs.existsSync(resolvedPath)) {
        findDependencies(resolvedPath, seen);
      } else {
        if (seen.has(resolvedPath)) return;
        seen.add(resolvedPath);
      }
    } else if (
      // export { ... from '...' }の処理
      ts.isExportDeclaration(node) &&
      node.moduleSpecifier &&
      ts.isStringLiteral(node.moduleSpecifier)
    ) {
      const moduleName = node.moduleSpecifier.text;
      const resolvedPath = resolveModulePath(filePath, moduleName);
      if (fs.existsSync(resolvedPath)) {
        findDependencies(resolvedPath, seen);
      } else {
        if (seen.has(resolvedPath)) return;
        seen.add(resolvedPath);
      }
    ts.forEachChild(node, visitNode);
  }

  visitNode(sourceFile);

  return Array.from(seen);
}

/**
 * プロジェクト内の相対パスおよびエイリアスパスを解決する。
 * @param {string} importerFilePath - インポート元のファイルパス
 * @param {string} moduleName - インポートされたモジュール名
 * @returns {string} - 解決されたモジュールのファイルパス
 */
function resolveModulePath(importerFilePath, moduleName) {
  let resolvedPath;
  if (moduleName.startsWith("@/")) {
    // エイリアスパスの解決
    resolvedPath = path.resolve(rootDir, moduleName.slice(2));
  } else if (moduleName.startsWith("./") || moduleName.startsWith("../")) {
    // 相対パスの解決
    const basePath = path.dirname(importerFilePath);
    resolvedPath = path.resolve(basePath, moduleName);
  } else {
    // node_modulesの解決
    return moduleName;
  }

  const possibleExtensions = [
    "",
    ".ts",
    ".d.ts",
    ".js",
    ".vue",
    "/index.ts",
    "/index.d.ts",
    "/index.js",
    "/index.vue",
  ];

  for (let ext of possibleExtensions) {
    const filePath = resolvedPath + ext;
    // 存在をチェック
    if (!fs.existsSync(filePath)) continue;
    const stat = fs.statSync(resolvedPath + ext);
    // ファイルかどうかチェック
    if (stat.isFile()) {
      // 該当するファイルが見つかった場合はそのパスを返す
      return filePath;
    }
  }

  return moduleName;
}

/**
 * Vue ファイルからスクリプト部分を抽出する
 * @param {string} filePath - スクリプトを抽出するVueファイルのパス
 * @returns {string}
 */
function extractScriptFromVue(filePath) {
  const fileContent = fs.readFileSync(filePath, "utf8");
  const scriptMatch = fileContent.match(/<script[^>]*>([\s\S]*?)<\/script>/);
  return scriptMatch ? scriptMatch[1] : "";
}

/**
 * ファイルパスからルートディレクトリを除いたファイルパスを返す
 * @param {string} p - ファイルパス
 * @returns {string}
 */
function trimPath(p) {
  return p.replace(`${rootDir}/`, "");
}

const outputData = [
  ...Object.values(webpackConfig({}).entry).map((entryPointPath) => ({
    entryPointPath: trimPath(entryPointPath),
    dependencies: findDependencies(entryPointPath).sort(),
  })),
];

fs.writeFileSync(
  path.resolve(__dirname, "outputData.json"),
  JSON.stringify(outputData, null, 2),
);

まとめ

今回はTypeScriptをlibraryとして利用し、AST(抽象構文木)を活用することで、内部モジュールやnpmモジュールの依存関係を把握することができました。
今後も積極的に活用していきたいと思います。

SODA Engineering Blog
SODA Engineering Blog

Discussion