🔍
TypeScriptを使って、エントリーポイントに紐づくts, js, vueの依存関係を追う
はじめに
現在 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の開発組織がお届けするZenn Publicationです。 是非Entrance Bookもご覧ください! → recruit.soda-inc.jp/engineer
Discussion