基于 Babel AST 与 Seaborn 的国际化复杂度可视化分析器构建实践


技术痛点:失控的国际化(i18n)文案

在一个大型、多团队协作的前端项目中,国际化(i18n)文案的管理逐渐成为一个隐蔽的技术债温床。最初,我们遵循简单的 t('key.name') 模式,一切井然有序。但随着业务迭代加速,上百个模块、数千个组件并行开发,i18n 系统开始出现混乱:

  1. Key 结构退化: common.ok, button.ok, confirm.ok 等大量重复或语义模糊的 key 涌现。
  2. Key 深度不一: 有的 key 路径深达七八层,如 pages.user.settings.profile.security.password.change.success,而有的则漂浮在顶层。这种不一致性增加了维护和翻译的认知成本。
  3. 废弃 Key 堆积: 代码重构后,大量无用的 key 残留在语言包中,无人敢删。
  4. 文案与代码耦合: 动态 key 的滥用(例如 t(\status.${statusCode}`)`)使得静态分析几乎不可能,翻译人员无法穷举所有文案。

手动审查无异于大海捞针,而简单的 grep 搜索无法理解代码结构,误报率极高。我们需要一个能深度理解代码、可量化、可视化的工具来自动化分析 i18n 的“健康状况”。

初步构想与技术选型

最初的想法是基于正则表达式构建一个扫描器。但这很快被证伪,正则表达式无法处理复杂的 JavaScript 语法,如函数调用嵌套、条件渲染、模板字符串等。正确的路径必须是基于抽象语法树(Abstract Syntax Tree, AST)。

这个工具的核心目标是:

  1. 精确提取: 准确识别所有 i18n 函数调用,并提取 key、默认值、所在文件等元数据。
  2. 深度分析: 对提取的数据进行结构化分析,例如 key 的深度、命名规范、模块分布等。
  3. 结果可视化: 将分析结果以直观的图表呈现,帮助团队快速定位问题。
  4. 可扩展性: 架构必须支持未来添加新的分析规则或报告类型。

基于以上目标,技术选型浮出水面:

  • AST 解析器: Babel。它是 JavaScript/TypeScript 生态的事实标准,拥有强大的解析器(@babel/parser)、遍历器(@babel/traverse)和丰富的插件生态。它能完美处理最新的 ECMAScript 语法及 JSX/TSX。
  • 分析与可视化: Python 生态。虽然解析在 Node.js 端完成,但对于数据处理和统计可视化,Python 的 pandasSeaborn 组合是无可匹敌的。Seaborn 尤其擅长生成富有信息量的统计图表,非常适合我们的场景。
  • 文案语义分析: NLP 库。为了解决 key 命名规范和潜在重复问题,我们需要引入基本的自然语言处理技术。Node.js 端的 natural 库提供了字符串相似度计算、分词等基础功能,可以作为起点。
  • 整体架构: 一个 Kit(工具集)。它是一个 CLI 工具,由 Node.js 驱动,负责解析代码并生成结构化的 JSON 数据。然后,一个 Python 脚本消费这份 JSON,利用 Seaborn 生成可视化报告。这种前后端分离的架构清晰且易于维护。

步骤化实现:构建分析器内核

1. 项目初始化与依赖

我们创建一个新的 Node.js 项目作为 CLI 工具的基础。

package.json

{
  "name": "i18n-analyzer-kit",
  "version": "1.0.0",
  "description": "Static analysis kit for i18n complexity.",
  "main": "index.js",
  "bin": {
    "i18n-analyzer": "./bin/cli.js"
  },
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "dependencies": {
    "@babel/parser": "^7.22.7",
    "@babel/traverse": "^7.22.8",
    "commander": "^11.0.0",
    "fast-glob": "^3.3.0",
    "natural": "^6.5.0"
  },
  "engines": {
    "node": ">=16.0.0"
  }
}

Python 环境则需要以下库:
requirements.txt

pandas
seaborn
matplotlib

2. 核心:Babel 插件提取 i18n 信息

这是整个工具的心脏。我们不直接使用 Babel CLI,而是将其作为库在我们的代码中调用。核心逻辑是编写一个自定义的访问者(visitor),用于遍历 AST 并捕获我们感兴趣的节点。

假设我们的 i18n 函数调用形式为 i18n.t('key.path', 'Default Text')

src/parser/i18n-visitor.js

const t = require('@babel/types');

// 生产级代码需要考虑更多边界情况,例如 i18n 对象被重命名
// const { t } = i18n; const translate = t; translate('key');
// 此处为简化模型,仅处理 i18n.t() 的直接调用
const I18N_OBJECT_NAME = 'i18n';
const I18N_METHOD_NAME = 't';

/**
 * 创建一个 Babel 访问者对象,用于收集 i18n 调用信息。
 * @param {string} filePath - 当前正在处理的文件路径。
 * @returns {{ CallExpression: (path: import('@babel/traverse').NodePath) => void, collectedKeys: Array<object> }}
 */
function createI18nVisitor(filePath) {
    const collectedKeys = [];

    return {
        collectedKeys,
        CallExpression(path) {
            const callee = path.get('callee');

            // 检查调用表达式是否为 i18n.t(...)
            if (!callee.isMemberExpression()) return;

            const object = callee.get('object');
            const property = callee.get('property');

            if (!object.isIdentifier({ name: I18N_OBJECT_NAME }) || !property.isIdentifier({ name: I18N_METHOD_NAME })) {
                return;
            }

            const args = path.get('arguments');
            // 至少需要一个参数(key)
            if (args.length === 0) {
                console.warn(`[WARN] Found i18n.t() call with no arguments in ${filePath} at line ${path.node.loc.start.line}`);
                return;
            }

            const keyNode = args[0];
            // 这里的坑在于:只处理静态的字符串 key,动态 key 是分析的难点,也是需要标记的风险点
            if (!keyNode.isStringLiteral()) {
                collectedKeys.push({
                    key: `[DYNAMIC_KEY]`,
                    defaultValue: null,
                    isDynamic: true,
                    filePath,
                    loc: path.node.loc.start,
                });
                return;
            }
            
            const keyValue = keyNode.node.value;
            let defaultValue = null;

            if (args.length > 1 && args[1].isStringLiteral()) {
                defaultValue = args[1].node.value;
            }

            collectedKeys.push({
                key: keyValue,
                defaultValue,
                isDynamic: false,
                filePath,
                loc: path.node.loc.start,
            });
        },
    };
}

module.exports = { createI18nVisitor };

3. 主解析流程

现在我们需要一个主流程来读取文件,调用 Babel 解析,并使用我们的 visitor。

src/parser/index.js

const fs = require('fs').promises;
const babelParser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const { createI18nVisitor } = require('./i18n-visitor');

/**
 * 解析单个文件并提取 i18n keys
 * @param {string} filePath 
 * @returns {Promise<Array<object>>}
 */
async function parseFile(filePath) {
    try {
        const code = await fs.readFile(filePath, 'utf-8');
        // 必须配置为能够解析项目中的所有语法,例如 jsx, typescript
        const ast = babelParser.parse(code, {
            sourceType: 'module',
            plugins: ['jsx', 'typescript'],
            errorRecovery: true, // 容错,避免单个文件语法错误导致整个流程中断
        });

        const visitor = createI18nVisitor(filePath);
        traverse(ast, visitor);

        return visitor.collectedKeys;
    } catch (error) {
        // 在真实项目中,这里应该使用更健壮的日志库
        console.error(`[ERROR] Failed to parse ${filePath}:`, error.message);
        return []; // 返回空数组,不中断整体分析
    }
}

module.exports = { parseFile };

4. 引入 NLP 进行 Key 结构分析

提取出数据后,我们可以在 Node.js 端进行初步的 NLP 分析,比如计算 key 的深度、检查命名风格(驼峰 vs. 点分隔),以及使用 Jaro-Winkler 算法发现可能相似的 key。

src/analysis/analyze-keys.js

const { JaroWinklerDistance } = require('natural');

const SIMILARITY_THRESHOLD = 0.90; // 相似度阈值

/**
 * 对提取的 key 数据进行分析和增强
 * @param {Array<object>} allKeys 
 * @returns {object}
 */
function analyzeKeys(allKeys) {
    const enhancedKeys = allKeys.map(item => ({
        ...item,
        keyDepth: item.isDynamic ? 0 : (item.key.match(/\./g) || []).length + 1,
    }));

    const potentialDuplicates = findPotentialDuplicates(enhancedKeys);

    return {
        summary: {
            totalKeys: enhancedKeys.length,
            uniqueKeys: new Set(enhancedKeys.map(k => k.key)).size,
            dynamicKeys: enhancedKeys.filter(k => k.isDynamic).length,
        },
        keys: enhancedKeys,
        potentialDuplicates,
    };
}

function findPotentialDuplicates(keys) {
    const staticKeys = keys.filter(k => !k.isDynamic).map(k => k.key);
    const uniqueStaticKeys = [...new Set(staticKeys)];
    const duplicates = [];

    for (let i = 0; i < uniqueStaticKeys.length; i++) {
        for (let j = i + 1; j < uniqueStaticKeys.length; j++) {
            const keyA = uniqueStaticKeys[i];
            const keyB = uniqueStaticKeys[j];
            const distance = JaroWinklerDistance(keyA, keyB);
            
            if (distance > SIMILARITY_THRESHOLD) {
                duplicates.push({
                    keyA,
                    keyB,
                    similarity: distance,
                });
            }
        }
    }
    return duplicates;
}


module.exports = { analyzeKeys };

5. 构建 CLI 入口

使用 commander 库构建一个用户友好的命令行界面。

bin/cli.js

#!/usr/bin/env node

const { Command } = require('commander');
const path = require('path');
const fs = require('fs').promises;
const fg = require('fast-glob');
const { parseFile } = require('../src/parser');
const { analyzeKeys } = require('../src/analysis/analyze-keys');

const program = new Command();

program
    .name('i18n-analyzer')
    .description('Analyzes i18n complexity in a codebase and generates visual reports.')
    .version('1.0.0')
    .requiredOption('-s, --source <glob>', 'Source files glob pattern (e.g., "src/**/*.{js,jsx,ts,tsx}")')
    .option('-o, --output <file>', 'Output JSON file path', 'i18n-analysis.json');

program.action(async (options) => {
    console.log('Starting i18n analysis...');
    
    const sourceFiles = await fg(options.source, { dot: true, ignore: ['**/node_modules/**'] });
    if (sourceFiles.length === 0) {
        console.error('[ERROR] No source files found matching the pattern. Please check your --source option.');
        process.exit(1);
    }
    
    console.log(`Found ${sourceFiles.length} files to analyze.`);

    let allKeys = [];
    // 并行处理文件以提高效率
    const analysisPromises = sourceFiles.map(file => parseFile(file));
    const results = await Promise.all(analysisPromises);
    results.forEach(keys => allKeys.push(...keys));

    console.log(`Extracted ${allKeys.length} i18n key usages.`);
    
    const analysisResult = analyzeKeys(allKeys);
    
    const outputPath = path.resolve(process.cwd(), options.output);
    await fs.writeFile(outputPath, JSON.stringify(analysisResult, null, 2));

    console.log(`✅ Analysis complete. Results saved to ${outputPath}`);
    console.log('Next step: run the Python visualization script on this JSON file.');
});

program.parse(process.argv);

6. Python 与 Seaborn 可视化

CLI 工具生成了 i18n-analysis.json。现在是 Python 和 Seaborn 发挥作用的时候了。

visualize.py

import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
import json
import argparse
import os

# 配置 Seaborn 样式
sns.set_theme(style="whitegrid", palette="muted", font='Heiti TC') # 使用支持中文的字体

def generate_visualizations(data_path, output_dir):
    """
    加载分析数据并生成一系列可视化图表
    """
    if not os.path.exists(output_dir):
        os.makedirs(output_dir)

    with open(data_path, 'r', encoding='utf-8') as f:
        data = json.load(f)

    df = pd.DataFrame(data['keys'])

    # 一个常见的错误是直接绘图,而不进行数据清洗或预处理
    # 过滤掉动态 key,因为它们的深度等指标没有意义
    static_df = df[df['isDynamic'] == False].copy()

    # 1. Key 深度分布直方图
    plt.figure(figsize=(12, 7))
    sns.histplot(data=static_df, x='keyDepth', discrete=True, kde=True)
    plt.title('国际化 Key 深度分布', fontsize=16)
    plt.xlabel('Key 深度 (点分隔数量 + 1)', fontsize=12)
    plt.ylabel('数量', fontsize=12)
    plt.savefig(os.path.join(output_dir, 'key_depth_distribution.png'), dpi=300)
    plt.close()
    print("Generated: key_depth_distribution.png")

    # 2. Top 20 文件 i18n 使用频率
    top_files = static_df['filePath'].value_counts().nlargest(20)
    plt.figure(figsize=(12, 10))
    sns.barplot(y=top_files.index, x=top_files.values, orient='h')
    plt.title('Top 20 文件 i18n Key 使用量', fontsize=16)
    plt.xlabel('Key 数量', fontsize=12)
    plt.ylabel('文件路径', fontsize=12)
    plt.tight_layout()
    plt.savefig(os.path.join(output_dir, 'top_files_by_keys.png'), dpi=300)
    plt.close()
    print("Generated: top_files_by_keys.png")
    
    # 3. 按目录聚合的 i18n 复杂度
    static_df['directory'] = static_df['filePath'].apply(lambda p: os.path.dirname(p).split(os.path.sep)[0] if os.path.sep in p else p)
    dir_summary = static_df.groupby('directory').agg(
        key_count=('key', 'size'),
        avg_depth=('keyDepth', 'mean')
    ).sort_values(by='key_count', ascending=False).nlargest(15, 'key_count')
    
    fig, ax1 = plt.subplots(figsize=(14, 8))
    sns.barplot(x=dir_summary.index, y=dir_summary['key_count'], ax=ax1, alpha=0.8, label='Key 总数')
    ax1.set_xlabel('项目顶层目录', fontsize=12)
    ax1.set_ylabel('Key 总数', fontsize=12)
    ax1.tick_params(axis='x', rotation=45)
    
    ax2 = ax1.twinx()
    sns.lineplot(x=dir_summary.index, y=dir_summary['avg_depth'], ax=ax2, color='r', marker='o', label='平均深度')
    ax2.set_ylabel('平均 Key 深度', fontsize=12)
    
    plt.title('各模块 i18n 复杂度分析 (Key 数量与平均深度)', fontsize=16)
    fig.tight_layout()
    plt.savefig(os.path.join(output_dir, 'module_complexity_analysis.png'), dpi=300)
    plt.close()
    print("Generated: module_complexity_analysis.png")


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description='Generate visualizations from i18n analysis data.')
    parser.add_argument('data_file', type=str, help='Path to the input JSON analysis file.')
    parser.add_argument('--output-dir', type=str, default='i18n-reports', help='Directory to save visualization images.')
    args = parser.parse_args()
    
    try:
        generate_visualizations(args.data_file, args.output_dir)
        print(f"\n✅ All visualizations saved to '{args.output_dir}' directory.")
    except FileNotFoundError:
        print(f"[ERROR] Input file not found: {args.data_file}")
    except Exception as e:
        print(f"[ERROR] An unexpected error occurred: {e}")

7. 架构与流程图

整个工作流可以用 Mermaid 图清晰地展示出来:

sequenceDiagram
    participant User as 用户
    participant CLI as i18n-analyzer
    participant Babel as Babel 内核
    participant FS as 文件系统
    participant PyScript as visualize.py
    participant Seaborn as Seaborn/Matplotlib

    User->>CLI: 执行 `i18n-analyzer --source "src/**/*.js"`
    CLI->>FS: 使用 fast-glob 查找文件
    FS-->>CLI: 返回文件列表
    CLI->>Babel: 对每个文件并行调用 parseFile()
    Babel->>Babel: 解析代码生成 AST
    Babel->>Babel: 使用 i18n-visitor 遍历 AST
    Babel-->>CLI: 返回提取的 key 列表
    CLI->>CLI: 聚合所有 key 并进行 NLP 分析
    CLI->>FS: 将分析结果写入 i18n-analysis.json
    User->>PyScript: 执行 `python visualize.py i18n-analysis.json`
    PyScript->>FS: 读取 i18n-analysis.json
    PyScript->>PyScript: 使用 pandas 加载数据为 DataFrame
    PyScript->>Seaborn: 调用绘图函数 (histplot, barplot)
    Seaborn-->>PyScript: 生成图表对象
    PyScript->>FS: 将图表保存为 .png 文件

最终成果与使用

现在,我们拥有了一个完整的工具集。

  1. 分析: 在项目根目录运行:
    npx i18n-analyzer --source "src/**/*.{ts,tsx}" --output reports/analysis.json
  2. 可视化: 然后运行 Python 脚本:
    python visualize.py reports/analysis.json --output-dir reports/images

执行完毕后,reports/images 目录下会生成一系列 PNG 图表,例如 “key_depth_distribution.png” 会清晰地展示项目 i18n key 的结构健康度,如果出现右偏态分布(大量深度很深的 key),则说明需要重构。而 “top_files_by_keys.png” 则能直接定位出 i18n 复杂度最高的组件或页面。

遗留问题与未来迭代

这个工具集虽然解决了核心痛点,但在生产环境中仍有可迭代的空间:

  1. 动态 Key 处理: 目前对动态 key 仅做了标记,并未尝试分析。未来的版本可以集成更复杂的静态分析技术,如污点分析(Taint Analysis),来推断动态 key 的可能取值范围,但这极具挑战性。
  2. 插件化架构: 当前的分析规则是硬编码的。一个更理想的架构是微内核 + 插件。内核负责 AST 解析和数据收集,而具体的分析逻辑(如 key 深度、命名规范检查)则由可插拔的插件提供。这样团队可以根据自身规范定制规则。
  3. CI/CD 集成: 可以将此工具集成到 CI 流程中。例如,设定阈值(如“最大 key 深度不得超过 5”),当新提交的代码违反规则时,CI 构建失败,从而实现 i18n 质量的自动化卡控。
  4. 交互式报告: 当前生成的是静态图片。一个更有价值的迭代方向是生成一个交互式的 HTML 报告(例如使用 D3.js 或 Plotly),用户可以点击图表进行下钻,直接链接到对应的源代码位置。

  目录