T-CREATOR

Lodash で巨大 JSON を“正規化 → 集計 → 整形”する 7 ステップ実装

Lodash で巨大 JSON を“正規化 → 集計 → 整形”する 7 ステップ実装

実際のアプリケーション開発では、API から受け取った複雑な JSON データを、そのまま利用できることは稀です。巨大で複雑なネストした JSON を、まず正規化してフラットにし、次に集計処理を行い、最後にビューに最適な形に整形する――この一連の流れを Lodash を使って実装できれば、開発効率が劇的に向上します。

本記事では、Lodash の各種関数を駆使して、巨大 JSON データを「正規化 → 集計 → 整形」する 7 ステップの実装パターンを、豊富なサンプルコードとともに詳しく解説いたします。

背景

API レスポンスに潜む複雑なデータ構造の課題

現代の Web アプリケーション開発では、REST API や GraphQL から取得する JSON データは、非常に複雑な構造を持つことが一般的です。

例えば、EC サイトの注文データや、CRM システムの顧客情報など、実際のビジネスデータは多くの場合、以下のような特徴を持っています。

javascript// APIから取得した生のJSONデータの例
const rawApiResponse = {
  status: 'success',
  data: {
    orders: [
      {
        orderId: 'ORD-001',
        customer: {
          id: 'CUST-123',
          profile: {
            name: '田中太郎',
            contact: {
              email: 'tanaka@example.com',
              phone: '090-1234-5678',
            },
            address: {
              prefecture: '東京都',
              city: '渋谷区',
              street: '神南1-2-3',
            },
          },
          tier: 'premium',
        },
        items: [
          {
            productId: 'PROD-A001',
            name: 'ワイヤレスマウス',
            category: { main: '電子機器', sub: '周辺機器' },
            price: 3500,
            quantity: 2,
            discount: { type: 'percentage', value: 10 },
          },
          {
            productId: 'PROD-B002',
            name: 'USB充電ケーブル',
            category: { main: '電子機器', sub: 'ケーブル' },
            price: 1200,
            quantity: 3,
            discount: { type: 'fixed', value: 200 },
          },
        ],
        payment: {
          method: 'credit_card',
          status: 'completed',
          timestamp: '2024-01-15T10:30:00Z',
        },
        shipping: {
          method: 'express',
          status: 'pending',
          estimatedDate: '2024-01-17',
        },
      },
      // ... 数百〜数千件のデータ
    ],
    metadata: {
      total: 1523,
      page: 1,
      perPage: 50,
    },
  },
};

このような複雑にネストした構造を持つ JSON データには、以下のような課題があります。

以下の図は、複雑な JSON データの構造と課題を示しています。

mermaidflowchart TD
    A["API Response"] --> B["深いネスト構造"]
    A --> C["配列内のオブジェクト"]
    A --> D["重複データの混在"]

    B --> E["アクセスが煩雑"]
    C --> F["集計処理が複雑"]
    D --> G["データ整合性の問題"]

    E --> H["開発効率の低下"]
    F --> H
    G --> H

図で理解できる要点

  • API レスポンスには深いネスト、配列内オブジェクト、重複データなど複数の課題が存在
  • これらの課題は開発効率の低下に直結する
  • 適切なデータ変換処理が必要不可欠

データ正規化の必要性とメリット

データベース設計における正規化と同様に、JSON データも正規化することで、以下のようなメリットが得られます。

#メリット詳細
1アクセスの簡素化ネストを浅くし、データアクセスが容易に
2重複の排除同じデータの重複を避け、一貫性を保つ
3集計処理の効率化フラットなデータ構造により集計が高速化
4メモリ効率の向上重複データの削減によりメモリ使用量を削減
5更新処理の単純化データの更新箇所が一箇所に集約される

正規化されたデータ構造の例を見てみましょう。

javascript// 正規化後のデータ構造イメージ
const normalizedData = {
  customers: {
    'CUST-123': {
      id: 'CUST-123',
      name: '田中太郎',
      email: 'tanaka@example.com',
      tier: 'premium',
      // ... その他の顧客情報
    },
  },
  products: {
    'PROD-A001': {
      id: 'PROD-A001',
      name: 'ワイヤレスマウス',
      category: '電子機器',
      subCategory: '周辺機器',
      basePrice: 3500,
    },
    'PROD-B002': {
      id: 'PROD-B002',
      name: 'USB充電ケーブル',
      category: '電子機器',
      subCategory: 'ケーブル',
      basePrice: 1200,
    },
  },
  orders: {
    'ORD-001': {
      id: 'ORD-001',
      customerId: 'CUST-123',
      items: ['ITEM-001', 'ITEM-002'],
      paymentStatus: 'completed',
      shippingStatus: 'pending',
    },
  },
  orderItems: {
    'ITEM-001': {
      id: 'ITEM-001',
      orderId: 'ORD-001',
      productId: 'PROD-A001',
      quantity: 2,
      discountType: 'percentage',
      discountValue: 10,
    },
    'ITEM-002': {
      id: 'ITEM-002',
      orderId: 'ORD-001',
      productId: 'PROD-B002',
      quantity: 3,
      discountType: 'fixed',
      discountValue: 200,
    },
  },
};

Lodash を活用した効率的なデータ変換の優位性

生の JavaScript でこれらのデータ変換処理を実装しようとすると、以下のような問題が発生します。

javascript// 生のJavaScriptでの実装例(冗長で読みにくい)
function normalizeOrders(rawData) {
  const normalized = {
    customers: {},
    products: {},
    orders: {},
    orderItems: {},
  };

  // ネストしたループと条件分岐が複雑に絡み合う
  rawData.data.orders.forEach((order) => {
    // 顧客データの抽出
    if (!normalized.customers[order.customer.id]) {
      normalized.customers[order.customer.id] = {
        id: order.customer.id,
        name: order.customer.profile.name,
        email: order.customer.profile.contact.email,
        // ... 深いネストのアクセス
      };
    }

    // 商品データの抽出
    order.items.forEach((item, index) => {
      if (!normalized.products[item.productId]) {
        normalized.products[item.productId] = {
          id: item.productId,
          name: item.name,
          // ...
        };
      }

      // 注文アイテムの登録
      const itemId = `ITEM-${order.orderId}-${index}`;
      normalized.orderItems[itemId] = {
        // ...
      };
    });

    // さらにコードが続く...
  });

  return normalized;
}

このコードには以下のような課題があります:

  • コードが非常に長く、可読性が低い
  • ネストしたデータアクセスが煩雑で、エラーが発生しやすい
  • null や undefined のチェックが煩雑
  • 保守性が低く、後から修正が困難

一方、Lodash を使うことで、これらの処理を簡潔で読みやすく実装できます。

javascriptimport {
  flatMap,
  keyBy,
  mapValues,
  pick,
  get,
} from 'lodash';

// Lodashを使った簡潔な実装
function normalizeOrdersWithLodash(rawData) {
  const orders = rawData.data.orders;

  // 顧客データを正規化(1行で完結)
  const customers = keyBy(
    orders.map((order) => ({
      id: get(order, 'customer.id'),
      name: get(order, 'customer.profile.name'),
      email: get(order, 'customer.profile.contact.email'),
      tier: get(order, 'customer.tier'),
    })),
    'id'
  );

  // 商品データを正規化
  const products = keyBy(
    flatMap(orders, (order) =>
      order.items.map((item) => ({
        id: item.productId,
        name: item.name,
        category: get(item, 'category.main'),
        subCategory: get(item, 'category.sub'),
        basePrice: item.price,
      }))
    ),
    'id'
  );

  return { customers, products };
}

Lodash を活用することで、以下のメリットが得られるのです:

  • コードが簡潔で読みやすい
  • 安全な null チェックが組み込まれている(get 関数)
  • 関数型プログラミングの手法により保守性が向上
  • チーム開発での理解が容易

課題

巨大 JSON の処理におけるパフォーマンス問題

実際のプロダクション環境では、数千〜数万件のレコードを含む巨大な JSON データを扱うことも珍しくありません。

以下の表は、データ規模によるパフォーマンス課題を整理したものです。

#データ件数処理時間(生 JS)メモリ使用量主な課題
1100 件10ms2MB特になし
21,000 件80ms15MB実装の複雑さ
310,000 件850ms120MBUI の応答性低下
4100,000 件9,500ms1.2GBブラウザのフリーズリスク
51,000,000 件120,000ms12GB処理が実質的に不可能

大量のデータを扱う際の主な課題は以下の通りです:

javascript// パフォーマンス問題の例
function processLargeData(data) {
  // 問題1: 多重ループによる処理時間の増大
  const result = data.map((item) => {
    return item.children.map((child) => {
      return child.items.filter((i) => i.active);
    });
  });

  // 問題2: 不要なデータのコピーによるメモリ圧迫
  const copied = JSON.parse(JSON.stringify(data));

  // 問題3: 非効率な検索処理の繰り返し
  const enriched = data.map((item) => {
    const category = categories.find(
      (c) => c.id === item.categoryId
    );
    return { ...item, category };
  });

  return result;
}

データ集計における条件分岐の複雑化

ビジネスロジックに基づいたデータ集計では、様々な条件での集計が必要になります。

以下の図は、集計処理の複雑さを示しています。

mermaidflowchart TD
    A["生データ"] --> B{"集計軸の選択"}
    B -->|顧客属性別| C["年齢層・地域・会員ランク"]
    B -->|商品属性別| D["カテゴリ・価格帯・ブランド"]
    B -->|時系列別| E["日・週・月・四半期"]
    B -->|複合条件| F["複数軸の組み合わせ"]

    C --> G["集計値の計算"]
    D --> G
    E --> G
    F --> G

    G --> H["合計・平均・最大・最小"]
    G --> I["カウント・比率"]
    G --> J["カスタム計算"]

図で理解できる要点

  • 集計には複数の軸(顧客・商品・時系列など)が存在
  • 各軸ごとに異なる集計値の計算が必要
  • 複合条件の場合、さらに複雑性が増す

従来の実装では、これらの集計処理が非常に複雑になってしまいます。

javascript// 複雑な集計処理の例
function aggregateOrderData(orders) {
  const stats = {
    byCustomerTier: {},
    byCategory: {},
    byDate: {},
  };

  orders.forEach((order) => {
    const tier = order.customer.tier;
    if (!stats.byCustomerTier[tier]) {
      stats.byCustomerTier[tier] = {
        count: 0,
        total: 0,
        items: [],
      };
    }

    order.items.forEach((item) => {
      const category = item.category.main;
      if (!stats.byCategory[category]) {
        stats.byCategory[category] = {
          count: 0,
          total: 0,
          items: [],
        };
      }

      // さらにネストした集計処理が続く...
    });
  });

  return stats;
}

UI 表示に最適なデータ形式への整形の困難さ

最終的に、集計したデータを UI コンポーネントで表示するためには、フレームワークやライブラリが要求する特定の形式に整形する必要があります。

例えば、以下のような形式変換が必要になることがあります:

javascript// Chart.jsで必要なデータ形式
const chartData = {
  labels: ['1月', '2月', '3月'],
  datasets: [
    {
      label: '売上',
      data: [120000, 150000, 180000],
      backgroundColor: 'rgba(75, 192, 192, 0.2)',
    },
  ],
};

// テーブルコンポーネントで必要なデータ形式
const tableData = [
  {
    id: 1,
    name: '商品A',
    category: '電子機器',
    sales: 120000,
  },
  // ...
];

// セレクトボックスで必要なデータ形式
const selectOptions = [
  { value: 'cat1', label: '電子機器' },
  { value: 'cat2', label: '衣料品' },
  // ...
];

これらの変換処理を手動で実装すると、コードが冗長になり、保守性が低下してしまいます。

解決策

Lodash の各種関数を組み合わせることで、「正規化 → 集計 → 整形」の一連のデータ処理を、7 つのステップとして体系的に実装できます。

以下の図は、7 ステップの全体像を示しています。

mermaidflowchart LR
    A["Step1<br/>正規化"] --> B["Step2<br/>重複排除"]
    B --> C["Step3<br/>キー変換"]
    C --> D["Step4<br/>グルーピング"]
    D --> E["Step5<br/>集計"]
    E --> F["Step6<br/>ソート"]
    F --> G["Step7<br/>整形"]

    G --> H["UI表示用データ"]

図で理解できる要点

  • データ処理は 7 つの明確なステップに分解できる
  • 各ステップは独立しており、組み合わせが柔軟
  • 最終的に UI 表示に最適な形式になる

それでは、各ステップの実装方法を詳しく見ていきましょう。

Step 1: flatMap と get でネストを正規化

ネストした JSON データをフラットな構造に変換するには、flatMapget を組み合わせます。

flatMap は、配列の各要素をマッピングした後、結果を 1 階層フラットにする関数です。get は、安全にネストしたプロパティにアクセスできる関数です。

javascriptimport { flatMap, get } from 'lodash';

// ネストしたAPIレスポンス
const apiResponse = {
  data: {
    orders: [
      {
        orderId: 'ORD-001',
        customer: {
          id: 'CUST-123',
          profile: {
            name: '田中太郎',
            contact: { email: 'tanaka@example.com' },
          },
        },
        items: [
          {
            productId: 'PROD-A001',
            name: 'ワイヤレスマウス',
            price: 3500,
            quantity: 2,
          },
          {
            productId: 'PROD-B002',
            name: 'USB充電ケーブル',
            price: 1200,
            quantity: 3,
          },
        ],
      },
      {
        orderId: 'ORD-002',
        customer: {
          id: 'CUST-456',
          profile: {
            name: '佐藤花子',
            contact: { email: 'sato@example.com' },
          },
        },
        items: [
          {
            productId: 'PROD-C003',
            name: 'ノートPC',
            price: 120000,
            quantity: 1,
          },
        ],
      },
    ],
  },
};

この複雑にネストしたデータを、注文アイテム単位でフラットに正規化します。

javascript// Step 1: 正規化処理
const normalizedItems = flatMap(
  apiResponse.data.orders,
  (order) => {
    return order.items.map((item) => ({
      // 注文情報
      orderId: order.orderId,
      // 顧客情報(安全にネストアクセス)
      customerId: get(order, 'customer.id'),
      customerName: get(order, 'customer.profile.name'),
      customerEmail: get(
        order,
        'customer.profile.contact.email'
      ),
      // 商品情報
      productId: item.productId,
      productName: item.name,
      price: item.price,
      quantity: item.quantity,
      // 計算フィールド
      totalPrice: item.price * item.quantity,
    }));
  }
);

console.log(normalizedItems);
// [
//   {
//     orderId: 'ORD-001',
//     customerId: 'CUST-123',
//     customerName: '田中太郎',
//     customerEmail: 'tanaka@example.com',
//     productId: 'PROD-A001',
//     productName: 'ワイヤレスマウス',
//     price: 3500,
//     quantity: 2,
//     totalPrice: 7000
//   },
//   {
//     orderId: 'ORD-001',
//     customerId: 'CUST-123',
//     customerName: '田中太郎',
//     customerEmail: 'tanaka@example.com',
//     productId: 'PROD-B002',
//     productName: 'USB充電ケーブル',
//     price: 1200,
//     quantity: 3,
//     totalPrice: 3600
//   },
//   {
//     orderId: 'ORD-002',
//     customerId: 'CUST-456',
//     customerName: '佐藤花子',
//     customerEmail: 'sato@example.com',
//     productId: 'PROD-C003',
//     productName: 'ノートPC',
//     price: 120000,
//     quantity: 1,
//     totalPrice: 120000
//   }
// ]

get 関数の第 3 引数にデフォルト値を指定することで、さらに安全なアクセスが可能です。

javascript// デフォルト値を指定した安全なアクセス
const safeNormalizedItems = flatMap(
  apiResponse.data.orders,
  (order) => {
    return order.items.map((item) => ({
      orderId: order.orderId,
      customerId: get(order, 'customer.id', 'UNKNOWN'),
      customerName: get(
        order,
        'customer.profile.name',
        '名前なし'
      ),
      customerEmail: get(
        order,
        'customer.profile.contact.email',
        'no-email@example.com'
      ),
      productId: item.productId,
      productName: item.name,
      price: item.price,
      quantity: item.quantity,
      totalPrice: item.price * item.quantity,
    }));
  }
);

Step 2: uniqBy で重複データを除去

正規化したデータから、特定のキーで重複を除去するには uniqBy を使います。

javascriptimport { uniqBy } from 'lodash';

// 顧客情報の重複を除去
const uniqueCustomers = uniqBy(
  normalizedItems.map((item) => ({
    id: item.customerId,
    name: item.customerName,
    email: item.customerEmail,
  })),
  'id'
);

console.log(uniqueCustomers);
// [
//   { id: 'CUST-123', name: '田中太郎', email: 'tanaka@example.com' },
//   { id: 'CUST-456', name: '佐藤花子', email: 'sato@example.com' }
// ]

// 商品情報の重複を除去
const uniqueProducts = uniqBy(
  normalizedItems.map((item) => ({
    id: item.productId,
    name: item.productName,
    price: item.price,
  })),
  'id'
);

console.log(uniqueProducts);
// [
//   { id: 'PROD-A001', name: 'ワイヤレスマウス', price: 3500 },
//   { id: 'PROD-B002', name: 'USB充電ケーブル', price: 1200 },
//   { id: 'PROD-C003', name: 'ノートPC', price: 120000 }
// ]

複数のフィールドの組み合わせで重複判定を行う場合は、関数を指定します。

javascript// 注文IDと商品IDの組み合わせで重複判定
const uniqueOrderItems = uniqBy(
  normalizedItems,
  (item) => `${item.orderId}_${item.productId}`
);

Step 3: keyBy と mapValues でキー変換

配列データをオブジェクトに変換し、ID によるアクセスを高速化するには keyBy を使います。

javascriptimport { keyBy, mapValues } from 'lodash';

// 顧客データをIDでキー化
const customersById = keyBy(uniqueCustomers, 'id');

console.log(customersById);
// {
//   'CUST-123': { id: 'CUST-123', name: '田中太郎', email: 'tanaka@example.com' },
//   'CUST-456': { id: 'CUST-456', name: '佐藤花子', email: 'sato@example.com' }
// }

// 商品データをIDでキー化
const productsById = keyBy(uniqueProducts, 'id');

mapValues を使って、キー化したオブジェクトの各値を変換できます。

javascript// 顧客データに追加情報を付与
const enrichedCustomers = mapValues(
  customersById,
  (customer) => ({
    ...customer,
    displayName: `${customer.name} (${customer.id})`,
    domain: customer.email.split('@')[1],
  })
);

console.log(enrichedCustomers);
// {
//   'CUST-123': {
//     id: 'CUST-123',
//     name: '田中太郎',
//     email: 'tanaka@example.com',
//     displayName: '田中太郎 (CUST-123)',
//     domain: 'example.com'
//   },
//   ...
// }

Step 4: groupBy で集計軸ごとにグルーピング

データを特定の軸でグルーピングするには groupBy を使います。

javascriptimport { groupBy } from 'lodash';

// 顧客別にグルーピング
const itemsByCustomer = groupBy(
  normalizedItems,
  'customerId'
);

console.log(itemsByCustomer);
// {
//   'CUST-123': [
//     { orderId: 'ORD-001', customerId: 'CUST-123', productId: 'PROD-A001', ... },
//     { orderId: 'ORD-001', customerId: 'CUST-123', productId: 'PROD-B002', ... }
//   ],
//   'CUST-456': [
//     { orderId: 'ORD-002', customerId: 'CUST-456', productId: 'PROD-C003', ... }
//   ]
// }

// 商品別にグルーピング
const itemsByProduct = groupBy(
  normalizedItems,
  'productId'
);

// 注文別にグルーピング
const itemsByOrder = groupBy(normalizedItems, 'orderId');

関数を使った動的なグルーピングも可能です。

javascript// 価格帯別にグルーピング
const itemsByPriceRange = groupBy(
  normalizedItems,
  (item) => {
    if (item.price < 5000) return '低価格';
    if (item.price < 50000) return '中価格';
    return '高価格';
  }
);

console.log(itemsByPriceRange);
// {
//   低価格: [...],
//   中価格: [...],
//   高価格: [...]
// }

Step 5: sumBy・meanBy・countBy で集計値を計算

グルーピングしたデータから集計値を計算するには、sumBymeanBycountBy などを使います。

javascriptimport { sumBy, meanBy, countBy } from 'lodash';

// 顧客別の購入金額を集計
const customerStats = mapValues(
  itemsByCustomer,
  (items) => ({
    // 合計金額
    totalAmount: sumBy(items, 'totalPrice'),
    // 平均単価
    averagePrice: Math.round(meanBy(items, 'price')),
    // 商品数
    itemCount: items.length,
    // 合計購入個数
    totalQuantity: sumBy(items, 'quantity'),
  })
);

console.log(customerStats);
// {
//   'CUST-123': {
//     totalAmount: 10600,
//     averagePrice: 2350,
//     itemCount: 2,
//     totalQuantity: 5
//   },
//   'CUST-456': {
//     totalAmount: 120000,
//     averagePrice: 120000,
//     itemCount: 1,
//     totalQuantity: 1
//   }
// }

複数の集計軸を組み合わせた高度な集計も可能です。

javascript// 商品別の販売統計
const productStats = mapValues(itemsByProduct, (items) => ({
  // 販売合計金額
  totalRevenue: sumBy(items, 'totalPrice'),
  // 販売個数
  totalSold: sumBy(items, 'quantity'),
  // 購入顧客数
  uniqueCustomers: uniqBy(items, 'customerId').length,
  // 平均購入個数
  averageQuantity: meanBy(items, 'quantity'),
  // 価格
  price: items[0].price,
}));

console.log(productStats);
// {
//   'PROD-A001': {
//     totalRevenue: 7000,
//     totalSold: 2,
//     uniqueCustomers: 1,
//     averageQuantity: 2,
//     price: 3500
//   },
//   ...
// }

countBy を使うと、特定の条件でのカウント集計が簡単にできます。

javascript// 価格帯別の商品数をカウント
const countByPriceRange = countBy(
  normalizedItems,
  (item) => {
    if (item.price < 5000) return '低価格';
    if (item.price < 50000) return '中価格';
    return '高価格';
  }
);

console.log(countByPriceRange);
// { 低価格: 2, 高価格: 1 }

Step 6: sortBy・orderBy で並べ替え

集計結果を並べ替えるには sortByorderBy を使います。

javascriptimport { sortBy, orderBy } from 'lodash';

// 商品統計を売上順にソート
const sortedProductStats = sortBy(
  Object.entries(productStats).map(([id, stats]) => ({
    productId: id,
    ...stats,
  })),
  'totalRevenue'
).reverse(); // 降順にするために反転

console.log(sortedProductStats);
// [
//   { productId: 'PROD-C003', totalRevenue: 120000, ... },
//   { productId: 'PROD-A001', totalRevenue: 7000, ... },
//   { productId: 'PROD-B002', totalRevenue: 3600, ... }
// ]

orderBy を使うと、複数キーでのソートや昇順・降順の指定が簡単にできます。

javascript// 売上降順、次に商品ID昇順でソート
const orderedProductStats = orderBy(
  Object.entries(productStats).map(([id, stats]) => ({
    productId: id,
    ...stats,
  })),
  ['totalRevenue', 'productId'], // ソートキー
  ['desc', 'asc'] // ソート方向
);

console.log(orderedProductStats);

カスタム関数でのソートも可能です。

javascript// ROI(投資対効果)でソート
const sortedByROI = orderBy(
  Object.entries(productStats).map(([id, stats]) => ({
    productId: id,
    ...stats,
    roi: stats.totalRevenue / stats.price, // カスタム計算
  })),
  [(item) => item.roi], // カスタム関数
  ['desc']
);

Step 7: map・pick・omit で UI 表示用に整形

最後に、UI コンポーネントが必要とする形式にデータを整形します。

javascriptimport { map, pick, omit } from 'lodash';

// Chart.js用のデータ形式に整形
const chartData = {
  labels: map(orderedProductStats, 'productId'),
  datasets: [
    {
      label: '売上',
      data: map(orderedProductStats, 'totalRevenue'),
      backgroundColor: 'rgba(75, 192, 192, 0.2)',
      borderColor: 'rgba(75, 192, 192, 1)',
      borderWidth: 1,
    },
  ],
};

console.log(chartData);
// {
//   labels: ['PROD-C003', 'PROD-A001', 'PROD-B002'],
//   datasets: [
//     {
//       label: '売上',
//       data: [120000, 7000, 3600],
//       ...
//     }
//   ]
// }

pick を使って、必要なフィールドのみを抽出できます。

javascript// テーブル表示用に必要なフィールドのみ抽出
const tableData = orderedProductStats.map((item) =>
  pick(item, [
    'productId',
    'totalRevenue',
    'totalSold',
    'uniqueCustomers',
  ])
);

console.log(tableData);
// [
//   { productId: 'PROD-C003', totalRevenue: 120000, totalSold: 1, uniqueCustomers: 1 },
//   { productId: 'PROD-A001', totalRevenue: 7000, totalSold: 2, uniqueCustomers: 1 },
//   ...
// ]

omit を使って、特定のフィールドを除外することもできます。

javascript// 内部IDを除外してAPI用に整形
const apiResponse = orderedProductStats.map((item) =>
  omit(item, ['internalId', 'privateData'])
);

セレクトボックス用のオプション形式への変換も簡単です。

javascript// セレクトボックス用のオプションに整形
const selectOptions = uniqueProducts.map((product) => ({
  value: product.id,
  label: `${
    product.name
  } - ¥${product.price.toLocaleString()}`,
}));

console.log(selectOptions);
// [
//   { value: 'PROD-A001', label: 'ワイヤレスマウス - ¥3,500' },
//   { value: 'PROD-B002', label: 'USB充電ケーブル - ¥1,200' },
//   { value: 'PROD-C003', label: 'ノートPC - ¥120,000' }
// ]

具体例

ここからは、実際のユースケースに基づいた具体的な実装例を見ていきましょう。

EC サイトの売上分析ダッシュボード

EC サイトの管理画面で、売上データを多角的に分析するダッシュボードを実装します。

まず、API から取得する生のデータを準備します。

javascript// APIから取得した生の売上データ
const rawSalesData = {
  status: 'success',
  data: {
    transactions: [
      {
        transactionId: 'TXN-2024-001',
        timestamp: '2024-01-15T10:30:00Z',
        customer: {
          id: 'CUST-001',
          profile: {
            name: '田中太郎',
            tier: 'gold',
            region: '関東',
          },
        },
        cart: {
          items: [
            {
              product: {
                id: 'PROD-A001',
                name: 'ワイヤレスマウス',
                category: {
                  main: '電子機器',
                  sub: '周辺機器',
                },
                price: 3500,
              },
              quantity: 2,
              discount: { type: 'percentage', value: 10 },
            },
            {
              product: {
                id: 'PROD-B002',
                name: 'USB-Cケーブル',
                category: {
                  main: '電子機器',
                  sub: 'ケーブル',
                },
                price: 1200,
              },
              quantity: 3,
              discount: { type: 'fixed', value: 200 },
            },
          ],
          subtotal: 9600,
          tax: 960,
          total: 10560,
        },
      },
      {
        transactionId: 'TXN-2024-002',
        timestamp: '2024-01-15T14:20:00Z',
        customer: {
          id: 'CUST-002',
          profile: {
            name: '佐藤花子',
            tier: 'silver',
            region: '関西',
          },
        },
        cart: {
          items: [
            {
              product: {
                id: 'PROD-C003',
                name: 'ノートPC',
                category: {
                  main: '電子機器',
                  sub: 'コンピュータ',
                },
                price: 120000,
              },
              quantity: 1,
              discount: { type: 'percentage', value: 5 },
            },
          ],
          subtotal: 114000,
          tax: 11400,
          total: 125400,
        },
      },
      {
        transactionId: 'TXN-2024-003',
        timestamp: '2024-01-16T09:15:00Z',
        customer: {
          id: 'CUST-001',
          profile: {
            name: '田中太郎',
            tier: 'gold',
            region: '関東',
          },
        },
        cart: {
          items: [
            {
              product: {
                id: 'PROD-D004',
                name: 'ワイヤレスキーボード',
                category: {
                  main: '電子機器',
                  sub: '周辺機器',
                },
                price: 8500,
              },
              quantity: 1,
              discount: { type: 'percentage', value: 15 },
            },
          ],
          subtotal: 7225,
          tax: 722,
          total: 7947,
        },
      },
    ],
  },
};

この生データに対して、7 ステップの処理を適用していきます。

Step 1-3: 正規化・重複排除・キー変換

javascriptimport {
  flatMap,
  get,
  uniqBy,
  keyBy,
  groupBy,
  sumBy,
  meanBy,
  orderBy,
  map,
} from 'lodash';

// Step 1: フラットに正規化
const normalizedTransactions = flatMap(
  rawSalesData.data.transactions,
  (txn) => {
    return txn.cart.items.map((item) => ({
      // 取引情報
      transactionId: txn.transactionId,
      date: txn.timestamp.split('T')[0],
      datetime: txn.timestamp,
      // 顧客情報
      customerId: get(txn, 'customer.id'),
      customerName: get(txn, 'customer.profile.name'),
      customerTier: get(txn, 'customer.profile.tier'),
      region: get(txn, 'customer.profile.region'),
      // 商品情報
      productId: get(item, 'product.id'),
      productName: get(item, 'product.name'),
      category: get(item, 'product.category.main'),
      subCategory: get(item, 'product.category.sub'),
      unitPrice: get(item, 'product.price'),
      quantity: item.quantity,
      // 割引情報
      discountType: get(item, 'discount.type'),
      discountValue: get(item, 'discount.value'),
      // 計算フィールド
      subtotal: item.product.price * item.quantity,
      discountAmount:
        item.discount.type === 'percentage'
          ? Math.round(
              item.product.price *
                item.quantity *
                (item.discount.value / 100)
            )
          : item.discount.value,
    }));
  }
);

// 割引後の金額を計算
const enrichedTransactions = normalizedTransactions.map(
  (item) => ({
    ...item,
    finalAmount: item.subtotal - item.discountAmount,
  })
);

// Step 2: 顧客情報の重複を除去
const uniqueCustomers = uniqBy(
  enrichedTransactions.map((item) => ({
    id: item.customerId,
    name: item.customerName,
    tier: item.customerTier,
    region: item.region,
  })),
  'id'
);

// Step 3: 顧客をIDでキー化
const customersById = keyBy(uniqueCustomers, 'id');

Step 4-5: グルーピングと集計

javascript// Step 4: 各種軸でグルーピング
const byCustomer = groupBy(
  enrichedTransactions,
  'customerId'
);
const byProduct = groupBy(
  enrichedTransactions,
  'productId'
);
const byCategory = groupBy(
  enrichedTransactions,
  'category'
);
const byRegion = groupBy(enrichedTransactions, 'region');
const byDate = groupBy(enrichedTransactions, 'date');

// Step 5: 顧客別売上を集計
const customerSalesStats = map(
  Object.entries(byCustomer),
  ([customerId, items]) => ({
    customerId,
    customerName: customersById[customerId].name,
    tier: customersById[customerId].tier,
    region: customersById[customerId].region,
    // 購入回数
    transactionCount: uniqBy(items, 'transactionId').length,
    // 購入商品数
    totalItems: sumBy(items, 'quantity'),
    // 合計金額
    totalAmount: sumBy(items, 'finalAmount'),
    // 平均単価
    averagePrice: Math.round(meanBy(items, 'unitPrice')),
    // 平均割引率
    averageDiscountRate:
      Math.round(
        meanBy(items, (item) =>
          item.discountType === 'percentage'
            ? item.discountValue
            : (item.discountAmount / item.subtotal) * 100
        )
      ) || 0,
  })
);

// 商品別売上を集計
const productSalesStats = map(
  Object.entries(byProduct),
  ([productId, items]) => ({
    productId,
    productName: items[0].productName,
    category: items[0].category,
    subCategory: items[0].subCategory,
    unitPrice: items[0].unitPrice,
    // 販売個数
    totalSold: sumBy(items, 'quantity'),
    // 売上金額
    revenue: sumBy(items, 'finalAmount'),
    // 購入顧客数
    uniqueCustomers: uniqBy(items, 'customerId').length,
    // 取引回数
    transactionCount: uniqBy(items, 'transactionId').length,
  })
);

// カテゴリ別売上を集計
const categorySalesStats = map(
  Object.entries(byCategory),
  ([category, items]) => ({
    category,
    // 商品種類数
    productCount: uniqBy(items, 'productId').length,
    // 販売個数
    totalSold: sumBy(items, 'quantity'),
    // 売上金額
    revenue: sumBy(items, 'finalAmount'),
    // 顧客数
    uniqueCustomers: uniqBy(items, 'customerId').length,
  })
);

// 地域別売上を集計
const regionSalesStats = map(
  Object.entries(byRegion),
  ([region, items]) => ({
    region,
    // 取引回数
    transactionCount: uniqBy(items, 'transactionId').length,
    // 顧客数
    customerCount: uniqBy(items, 'customerId').length,
    // 売上金額
    revenue: sumBy(items, 'finalAmount'),
    // 平均単価
    averageOrderValue: Math.round(
      sumBy(items, 'finalAmount') /
        uniqBy(items, 'transactionId').length
    ),
  })
);

// 日別売上を集計
const dailySalesStats = map(
  Object.entries(byDate),
  ([date, items]) => ({
    date,
    // 取引回数
    transactionCount: uniqBy(items, 'transactionId').length,
    // 商品販売個数
    itemsSold: sumBy(items, 'quantity'),
    // 売上金額
    revenue: sumBy(items, 'finalAmount'),
  })
);

Step 6-7: ソートと整形

javascript// Step 6: 各統計をソート
const topCustomers = orderBy(
  customerSalesStats,
  ['totalAmount'],
  ['desc']
);

const topProducts = orderBy(
  productSalesStats,
  ['revenue'],
  ['desc']
);

const topCategories = orderBy(
  categorySalesStats,
  ['revenue'],
  ['desc']
);

// Step 7: UI表示用に整形

// ダッシュボード用サマリー
const dashboardSummary = {
  // 全体サマリー
  overview: {
    totalRevenue: sumBy(
      enrichedTransactions,
      'finalAmount'
    ),
    totalTransactions: uniqBy(
      enrichedTransactions,
      'transactionId'
    ).length,
    totalCustomers: uniqueCustomers.length,
    totalItemsSold: sumBy(enrichedTransactions, 'quantity'),
    averageOrderValue: Math.round(
      sumBy(enrichedTransactions, 'finalAmount') /
        uniqBy(enrichedTransactions, 'transactionId').length
    ),
  },

  // 売上チャート用データ(Chart.js形式)
  salesChart: {
    labels: map(dailySalesStats, 'date'),
    datasets: [
      {
        label: '売上',
        data: map(dailySalesStats, 'revenue'),
        backgroundColor: 'rgba(75, 192, 192, 0.2)',
        borderColor: 'rgba(75, 192, 192, 1)',
        borderWidth: 2,
      },
    ],
  },

  // カテゴリ別円グラフ用データ
  categoryPieChart: {
    labels: map(topCategories, 'category'),
    datasets: [
      {
        data: map(topCategories, 'revenue'),
        backgroundColor: [
          'rgba(255, 99, 132, 0.6)',
          'rgba(54, 162, 235, 0.6)',
          'rgba(255, 206, 86, 0.6)',
        ],
      },
    ],
  },

  // トップ顧客テーブル用データ
  topCustomersTable: topCustomers.slice(0, 10).map((c) => ({
    顧客名: c.customerName,
    会員ランク: c.tier,
    地域: c.region,
    購入回数: c.transactionCount,
    合計金額: ${c.totalAmount.toLocaleString()}`,
  })),

  // トップ商品テーブル用データ
  topProductsTable: topProducts.slice(0, 10).map((p) => ({
    商品名: p.productName,
    カテゴリ: p.category,
    単価: ${p.unitPrice.toLocaleString()}`,
    販売個数: p.totalSold,
    売上: ${p.revenue.toLocaleString()}`,
  })),

  // 地域別比較データ
  regionComparison: regionSalesStats.map((r) => ({
    地域: r.region,
    売上: r.revenue,
    顧客数: r.customerCount,
    平均単価: r.averageOrderValue,
  })),
};

console.log(dashboardSummary);

このように、7 ステップを順番に適用することで、複雑な生データから UI 表示に必要な形式まで、体系的にデータ変換を実現できます。

ユーザー行動ログの分析レポート

次に、Web アプリケーションのユーザー行動ログを分析するケースを見てみましょう。

javascript// 生のログデータ
const rawLogs = {
  logs: [
    {
      sessionId: 'SES-001',
      user: {
        id: 'USER-123',
        info: { name: '田中太郎', plan: 'premium' },
      },
      events: [
        {
          type: 'page_view',
          page: '/dashboard',
          timestamp: '2024-01-15T10:00:00Z',
          duration: 45,
        },
        {
          type: 'button_click',
          target: 'create_project',
          timestamp: '2024-01-15T10:00:45Z',
        },
        {
          type: 'page_view',
          page: '/project/new',
          timestamp: '2024-01-15T10:00:50Z',
          duration: 120,
        },
      ],
    },
    {
      sessionId: 'SES-002',
      user: {
        id: 'USER-456',
        info: { name: '佐藤花子', plan: 'free' },
      },
      events: [
        {
          type: 'page_view',
          page: '/pricing',
          timestamp: '2024-01-15T11:00:00Z',
          duration: 30,
        },
        {
          type: 'button_click',
          target: 'upgrade_plan',
          timestamp: '2024-01-15T11:00:30Z',
        },
      ],
    },
    {
      sessionId: 'SES-003',
      user: {
        id: 'USER-123',
        info: { name: '田中太郎', plan: 'premium' },
      },
      events: [
        {
          type: 'page_view',
          page: '/dashboard',
          timestamp: '2024-01-16T09:00:00Z',
          duration: 60,
        },
        {
          type: 'button_click',
          target: 'export_data',
          timestamp: '2024-01-16T09:01:00Z',
        },
      ],
    },
  ],
};

7 ステップで分析用データに変換します。

javascript// Step 1: 正規化
const normalizedEvents = flatMap(
  rawLogs.logs,
  (session) => {
    return session.events.map((event) => ({
      sessionId: session.sessionId,
      userId: get(session, 'user.id'),
      userName: get(session, 'user.info.name'),
      userPlan: get(session, 'user.info.plan'),
      eventType: event.type,
      page: event.page,
      target: event.target,
      timestamp: event.timestamp,
      date: event.timestamp.split('T')[0],
      hour: parseInt(
        event.timestamp.split('T')[1].split(':')[0]
      ),
      duration: event.duration || 0,
    }));
  }
);

// Step 2-3: ユーザー情報の抽出とキー化
const uniqueUsers = uniqBy(
  normalizedEvents.map((e) => ({
    id: e.userId,
    name: e.userName,
    plan: e.userPlan,
  })),
  'id'
);

const usersById = keyBy(uniqueUsers, 'id');

// Step 4: グルーピング
const byUser = groupBy(normalizedEvents, 'userId');
const byEventType = groupBy(normalizedEvents, 'eventType');
const byPage = groupBy(normalizedEvents, 'page');
const byPlan = groupBy(normalizedEvents, 'userPlan');
const byDate = groupBy(normalizedEvents, 'date');
const byHour = groupBy(normalizedEvents, 'hour');

// Step 5: ユーザー別行動分析
const userBehaviorStats = map(
  Object.entries(byUser),
  ([userId, events]) => ({
    userId,
    userName: usersById[userId].name,
    plan: usersById[userId].plan,
    // セッション数
    sessionCount: uniqBy(events, 'sessionId').length,
    // 総イベント数
    totalEvents: events.length,
    // ページビュー数
    pageViews: events.filter(
      (e) => e.eventType === 'page_view'
    ).length,
    // クリック数
    clicks: events.filter(
      (e) => e.eventType === 'button_click'
    ).length,
    // 平均滞在時間
    averageDuration: Math.round(
      meanBy(
        events.filter((e) => e.duration > 0),
        'duration'
      ) || 0
    ),
    // アクセス日数
    activeDays: uniqBy(events, 'date').length,
  })
);

// イベントタイプ別統計
const eventTypeStats = map(
  Object.entries(byEventType),
  ([type, events]) => ({
    eventType: type,
    count: events.length,
    uniqueUsers: uniqBy(events, 'userId').length,
    uniqueSessions: uniqBy(events, 'sessionId').length,
  })
);

// ページ別統計
const pageStats = map(
  Object.entries(byPage),
  ([page, events]) => {
    const pageViewEvents = events.filter(
      (e) => e.eventType === 'page_view'
    );
    return {
      page,
      views: pageViewEvents.length,
      uniqueVisitors: uniqBy(pageViewEvents, 'userId')
        .length,
      averageDuration: Math.round(
        meanBy(pageViewEvents, 'duration') || 0
      ),
      totalDuration: sumBy(pageViewEvents, 'duration'),
    };
  }
);

// プラン別統計
const planStats = map(
  Object.entries(byPlan),
  ([plan, events]) => ({
    plan,
    users: uniqBy(events, 'userId').length,
    sessions: uniqBy(events, 'sessionId').length,
    averageEventsPerUser: Math.round(
      events.length / uniqBy(events, 'userId').length
    ),
  })
);

// 時間帯別アクセス統計
const hourlyStats = map(
  Object.entries(byHour),
  ([hour, events]) => ({
    hour: parseInt(hour),
    events: events.length,
    uniqueUsers: uniqBy(events, 'userId').length,
  })
);

// Step 6: ソート
const topUsers = orderBy(
  userBehaviorStats,
  ['totalEvents'],
  ['desc']
);
const topPages = orderBy(pageStats, ['views'], ['desc']);
const sortedHourlyStats = orderBy(
  hourlyStats,
  ['hour'],
  ['asc']
);

// Step 7: レポート用に整形
const behaviorAnalysisReport = {
  // サマリー
  summary: {
    totalUsers: uniqueUsers.length,
    totalSessions: uniqBy(normalizedEvents, 'sessionId')
      .length,
    totalEvents: normalizedEvents.length,
    averageEventsPerSession: Math.round(
      normalizedEvents.length /
        uniqBy(normalizedEvents, 'sessionId').length
    ),
  },

  // 時間帯別アクセスチャート
  hourlyAccessChart: {
    labels: map(sortedHourlyStats, (s) => `${s.hour}時`),
    datasets: [
      {
        label: 'イベント数',
        data: map(sortedHourlyStats, 'events'),
        borderColor: 'rgba(54, 162, 235, 1)',
        backgroundColor: 'rgba(54, 162, 235, 0.2)',
      },
      {
        label: 'アクティブユーザー',
        data: map(sortedHourlyStats, 'uniqueUsers'),
        borderColor: 'rgba(255, 99, 132, 1)',
        backgroundColor: 'rgba(255, 99, 132, 0.2)',
      },
    ],
  },

  // プラン別比較テーブル
  planComparisonTable: planStats.map((p) => ({
    プラン: p.plan,
    ユーザー数: p.users,
    セッション数: p.sessions,
    平均イベント数: p.averageEventsPerUser,
  })),

  // トップページテーブル
  topPagesTable: topPages.map((p) => ({
    ページ: p.page,
    閲覧数: p.views,
    ユニーク訪問者: p.uniqueVisitors,
    平均滞在時間: `${p.averageDuration}秒`,
  })),

  // アクティブユーザーランキング
  activeUsersRanking: topUsers
    .slice(0, 10)
    .map((u, index) => ({
      順位: index + 1,
      ユーザー名: u.userName,
      プラン: u.plan,
      イベント数: u.totalEvents,
      セッション数: u.sessionCount,
      アクセス日数: u.activeDays,
    })),
};

console.log(behaviorAnalysisReport);

マルチレベルカテゴリの階層的集計

最後に、複雑な階層構造を持つカテゴリデータを集計する例を見てみましょう。

javascript// 階層的なカテゴリを持つ商品データ
const rawProductData = {
  categories: [
    {
      id: 'CAT-1',
      name: '電子機器',
      subcategories: [
        {
          id: 'SUBCAT-1-1',
          name: 'コンピュータ',
          products: [
            {
              id: 'PROD-001',
              name: 'ノートPC',
              price: 120000,
              stock: 15,
              sales: [
                {
                  date: '2024-01-15',
                  quantity: 2,
                  revenue: 240000,
                },
                {
                  date: '2024-01-16',
                  quantity: 1,
                  revenue: 120000,
                },
              ],
            },
            {
              id: 'PROD-002',
              name: 'デスクトップPC',
              price: 150000,
              stock: 8,
              sales: [
                {
                  date: '2024-01-15',
                  quantity: 1,
                  revenue: 150000,
                },
              ],
            },
          ],
        },
        {
          id: 'SUBCAT-1-2',
          name: '周辺機器',
          products: [
            {
              id: 'PROD-003',
              name: 'マウス',
              price: 3500,
              stock: 50,
              sales: [
                {
                  date: '2024-01-15',
                  quantity: 5,
                  revenue: 17500,
                },
                {
                  date: '2024-01-16',
                  quantity: 3,
                  revenue: 10500,
                },
              ],
            },
          ],
        },
      ],
    },
    {
      id: 'CAT-2',
      name: '家電',
      subcategories: [
        {
          id: 'SUBCAT-2-1',
          name: '調理家電',
          products: [
            {
              id: 'PROD-004',
              name: '電子レンジ',
              price: 25000,
              stock: 20,
              sales: [
                {
                  date: '2024-01-15',
                  quantity: 2,
                  revenue: 50000,
                },
              ],
            },
          ],
        },
      ],
    },
  ],
};

7 ステップで階層的に集計します。

javascript// Step 1: 完全に正規化
const normalizedSales = flatMap(
  rawProductData.categories,
  (category) => {
    return flatMap(
      category.subcategories,
      (subcategory) => {
        return flatMap(subcategory.products, (product) => {
          return product.sales.map((sale) => ({
            // カテゴリ情報
            categoryId: category.id,
            categoryName: category.name,
            subcategoryId: subcategory.id,
            subcategoryName: subcategory.name,
            // 商品情報
            productId: product.id,
            productName: product.name,
            unitPrice: product.price,
            stock: product.stock,
            // 売上情報
            date: sale.date,
            quantity: sale.quantity,
            revenue: sale.revenue,
          }));
        });
      }
    );
  }
);

// Step 2-3: 各エンティティの抽出とキー化
const uniqueCategories = uniqBy(
  normalizedSales.map((s) => ({
    id: s.categoryId,
    name: s.categoryName,
  })),
  'id'
);

const uniqueSubcategories = uniqBy(
  normalizedSales.map((s) => ({
    id: s.subcategoryId,
    name: s.subcategoryName,
    categoryId: s.categoryId,
  })),
  'id'
);

const uniqueProducts = uniqBy(
  normalizedSales.map((s) => ({
    id: s.productId,
    name: s.productName,
    price: s.unitPrice,
    subcategoryId: s.subcategoryId,
  })),
  'id'
);

// Step 4: 階層的グルーピング
const byCategory = groupBy(normalizedSales, 'categoryId');
const bySubcategory = groupBy(
  normalizedSales,
  'subcategoryId'
);
const byProduct = groupBy(normalizedSales, 'productId');
const byDate = groupBy(normalizedSales, 'date');

// Step 5: 各レベルで集計

// 商品レベルの集計
const productLevelStats = map(
  Object.entries(byProduct),
  ([productId, sales]) => {
    const product = uniqueProducts.find(
      (p) => p.id === productId
    );
    return {
      productId,
      productName: product.name,
      subcategoryId: product.subcategoryId,
      unitPrice: product.price,
      totalSold: sumBy(sales, 'quantity'),
      totalRevenue: sumBy(sales, 'revenue'),
      salesCount: sales.length,
    };
  }
);

// サブカテゴリレベルの集計
const subcategoryLevelStats = map(
  Object.entries(bySubcategory),
  ([subcategoryId, sales]) => {
    const subcategory = uniqueSubcategories.find(
      (s) => s.id === subcategoryId
    );
    const productsInSubcategory = productLevelStats.filter(
      (p) => p.subcategoryId === subcategoryId
    );

    return {
      subcategoryId,
      subcategoryName: subcategory.name,
      categoryId: subcategory.categoryId,
      productCount: productsInSubcategory.length,
      totalRevenue: sumBy(
        productsInSubcategory,
        'totalRevenue'
      ),
      totalItemsSold: sumBy(
        productsInSubcategory,
        'totalSold'
      ),
      averageProductPrice: Math.round(
        meanBy(productsInSubcategory, 'unitPrice')
      ),
    };
  }
);

// カテゴリレベルの集計
const categoryLevelStats = map(
  Object.entries(byCategory),
  ([categoryId, sales]) => {
    const category = uniqueCategories.find(
      (c) => c.id === categoryId
    );
    const subcategoriesInCategory =
      subcategoryLevelStats.filter(
        (s) => s.categoryId === categoryId
      );

    return {
      categoryId,
      categoryName: category.name,
      subcategoryCount: subcategoriesInCategory.length,
      totalRevenue: sumBy(
        subcategoriesInCategory,
        'totalRevenue'
      ),
      totalItemsSold: sumBy(
        subcategoriesInCategory,
        'totalItemsSold'
      ),
      productCount: sumBy(
        subcategoriesInCategory,
        'productCount'
      ),
    };
  }
);

// Step 6: ソート
const topCategories = orderBy(
  categoryLevelStats,
  ['totalRevenue'],
  ['desc']
);
const topSubcategories = orderBy(
  subcategoryLevelStats,
  ['totalRevenue'],
  ['desc']
);
const topProducts = orderBy(
  productLevelStats,
  ['totalRevenue'],
  ['desc']
);

// Step 7: 階層的レポートに整形
const hierarchicalSalesReport = {
  // 全体サマリー
  overall: {
    totalRevenue: sumBy(categoryLevelStats, 'totalRevenue'),
    totalCategories: uniqueCategories.length,
    totalSubcategories: uniqueSubcategories.length,
    totalProducts: uniqueProducts.length,
    totalItemsSold: sumBy(
      categoryLevelStats,
      'totalItemsSold'
    ),
  },

  // カテゴリ別階層データ
  hierarchicalData: topCategories.map((category) => {
    const subcategories = topSubcategories
      .filter((s) => s.categoryId === category.categoryId)
      .map((subcategory) => {
        const products = topProducts
          .filter(
            (p) =>
              p.subcategoryId === subcategory.subcategoryId
          )
          .map((product) => ({
            商品名: product.productName,
            単価: ${product.unitPrice.toLocaleString()}`,
            販売数: product.totalSold,
            売上: ${product.totalRevenue.toLocaleString()}`,
          }));

        return {
          サブカテゴリ名: subcategory.subcategoryName,
          商品数: subcategory.productCount,
          売上: ${subcategory.totalRevenue.toLocaleString()}`,
          商品詳細: products,
        };
      });

    return {
      カテゴリ名: category.categoryName,
      サブカテゴリ数: category.subcategoryCount,
      商品数: category.productCount,
      売上: ${category.totalRevenue.toLocaleString()}`,
      サブカテゴリ詳細: subcategories,
    };
  }),

  // カテゴリ別売上チャート
  categorySalesChart: {
    labels: map(topCategories, 'categoryName'),
    datasets: [
      {
        label: '売上',
        data: map(topCategories, 'totalRevenue'),
        backgroundColor: [
          'rgba(255, 99, 132, 0.6)',
          'rgba(54, 162, 235, 0.6)',
          'rgba(255, 206, 86, 0.6)',
        ],
      },
    ],
  },

  // トップ商品ランキング
  topProductsRanking: topProducts
    .slice(0, 10)
    .map((p, index) => ({
      順位: index + 1,
      商品名: p.productName,
      単価: ${p.unitPrice.toLocaleString()}`,
      販売数: p.totalSold,
      売上: ${p.totalRevenue.toLocaleString()}`,
    })),
};

console.log(
  JSON.stringify(hierarchicalSalesReport, null, 2)
);

まとめ

Lodash の各種関数を組み合わせることで、巨大で複雑な JSON データを「正規化 → 集計 → 整形」する処理を、体系的かつ効率的に実装できます。

本記事で解説した 7 ステップをまとめますと:

7 ステップの概要

#ステップ使用する主な関数目的
1正規化flatMap, getネストしたデータをフラット化
2重複排除uniqBy重複データを除去
3キー変換keyBy, mapValues配列をオブジェクトに変換
4グルーピングgroupBy集計軸ごとにデータを分類
5集計sumBy, meanBy, countBy各種統計値を計算
6ソートsortBy, orderBy結果を並べ替え
7整形map, pick, omitUI 表示用の形式に変換

実装における重要なポイント

  • get 関数により、null や undefined の安全なチェックが可能
  • flatMap により、ネストした配列を 1 階層フラット化
  • keyBy により、配列からオブジェクトへの変換が簡単
  • groupBy により、複数の集計軸での分類が容易
  • 各ステップは独立しており、必要に応じて組み合わせ可能

適用場面

  • EC サイトの売上分析ダッシュボード
  • ユーザー行動ログの集計レポート
  • マルチレベルカテゴリの階層的集計
  • API レスポンスの正規化と UI 表示用データへの変換

パフォーマンスの考慮点

  • 大量データ(10 万件以上)では、処理を分割してメモリ負荷を軽減
  • 頻繁に実行される処理は、React の useMemo や Vue の computed でメモ化
  • 不要なデータのコピーを避け、必要なフィールドのみを抽出

この 7 ステップのパターンをマスターすれば、どんなに複雑な JSON データでも、読みやすく保守しやすいコードで効率的に処理できるようになります。

特に、現代の SPA(Single Page Application)開発では、API から取得したデータを適切に変換して UI に表示する処理が頻繁に発生しますので、この手法は非常に実用的でしょう。

ぜひ、実際のプロジェクトでこの 7 ステップのパターンを試してみてください。データ処理が驚くほどシンプルになり、開発効率が大幅に向上するはずです。

関連リンク