使用 Typescript、Next.js、NewsDataHub 和 CoinGecko API 的加密新闻聚合器

在本文中,我们将介绍如何使用 NewsDataHub 和 CoinGecko API 构建一个简单但实​​用的加密货币新闻聚合器应用程序。本文面向初级开发人员 - 如果您认为某些部分不会为您的学习体验增添价值,请随意跳过它们。

您还可以在此处查看最终的项目代码:https://github.com/newsdatahub/crypto-news-aggregator

您可以在此处查看此应用程序的生产版本:https://newsdatahub.com/crypto

让我们首先创建一个支持 Typescript 的全新 Next.js 项目。

npx create-next-app@latest crypto-news-aggregator --typescript

出现提示时,选择:

  • 对于 ESLint 是
  • Tailwind CSS 否(我们将使用 CSS 模块)
  • 对于 src/ 目录没有
  • 对于 App Router 来说,是的
  • Turbopack 不支持
  • 否,用于自定义导入别名(我们将手动设置)
  • cd 进入项目文件夹:

    cd crypto-news-aggregator

    项目结构设置

    初始化后,让我们创建项目结构。我将解释每个目录和文件的用途。

    mkdir -p app/components/{news-feed,price-ticker} __tests__ types

    在本教程结束时,您应该得到以下结构。

    crypto-news-aggregator/
    ├── __tests__/                    # Test files
    │   ├── Home.test.tsx
    │   ├── NewsCard.test.tsx
    │   └── PriceTicker.test.tsx
    ├── app/                          # Next.js app directory
    │   ├── components/               # React components
    │   │   ├── news-feed/            # News-related components
    │   │   │   ├── NewsCard.tsx
    │   │   │   └── index.ts
    │   │   └── price-ticker/         # Price ticker components
    │   │       ├── PriceTicker.tsx
    │   │       └── index.ts
    │   ├── layout.tsx               # Root layout component
    │   ├── page.module.css          # Styles for main page
    │   └── page.tsx                 # Main page component
    ├── public/                      # Static assets
    ├── types/                       # TypeScript type definitions
    │   ├── cache.ts
    │   ├── crypto.ts
    │   ├── env.d.ts
    │   ├── index.ts
    │     └── news.ts
    ├── .env.example                # Example environment variables
    ├── .env.local                  # Environment variables (gitignored)
    ├── .eslintrc.json              # ESLint configuration
    ├── .gitignore                  # Git ignore rules
    ├── eslint.config.mjs           # ESLint module configuration
    ├── jest.config.mjs             # Jest configuration
    ├── jest.setup.js               # Jest setup file
    ├── next-env.d.ts               # Next.js TypeScript declarations
    ├── next.config.js              # Next.js configuration
    ├── package-lock.json           # Locked dependency versions
    ├── package.json                # Project dependencies
    ├── README.md
    ├── tsconfig.json               # TypeScript configuration
    └── types.d.ts                  # Global TypeScript declarations

    但在此之前,我们需要稍微清理一下项目目录,然后创建一些文件。

    可以安全删除的文件

  • app/globals.css(如果您使用 module.css 文件)
  • 所有 .svg 文件(在 /public 目录中)
  • README.md(删除或更新,因为这是 create-next-app 的默认文件)
  • 如果您的 favicon.ico 位于应用程序目录中,请考虑将其移动到公共文件夹。虽然 favicon 可以在两个位置工作,但将其移动到“public/”符合传统结构并使资产位置更加明确。

    测试

    我们需要安装几个测试包

    npm install --save-dev @testing-library/react @testing-library/jest-dom jest jest-environment-jsdom

    让我们了解一下每个包的作用:

  • @testing-library/react:提供用于测试 React 组件的实用程序
  • @testing-library/jest-dom:添加自定义 Jest 匹配器
  • jest:主要的测试框架
  • jest-environment-jsdom:为我们的测试模拟浏览器环境
  • 创建“types.d.ts”用于测试类型定义

    import '@testing-library/jest-dom';
    declare global {
      namespace jest {
        interface Matchers {
          toBeInTheDocument(): R;
        }
      }
      interface Window {
        fetch: jest.Mock;
      }
    }
    export {};

    现在让我们安装 TypeScript 类型定义,以便我们的代码编辑器可以理解 Node.js、React 和 Jest API,从而实现自动完成并在开发过程中捕获类型错误。

    npm install --save-dev @types/node @types/react @types/jest

    安装软件包后,我们需要配置 Jest。在项目根目录中创建一个“jest.config.mjs”文件。

    `jest.config.mjs`:

    import nextJest from 'next/jest.js';
    
    const createJestConfig = nextJest({
      dir: './',
    });
    
    export default createJestConfig({
      testEnvironment: 'jest-environment-jsdom',
      setupFilesAfterEnv: ['/jest.setup.js']
    });

    创建一个 `jest.setup.js` 文件来导入 DOM 匹配器。

    `jest.setup.js`:

    import '@testing-library/jest-dom';

    最后,将以下几行添加到“scripts”下的“package.json”中以运行测试脚本:

    "test": "jest",
    "test:watch": "jest --watch"

    所以它看起来像这样:

    "scripts": {
        "dev": "next dev",
        "build": "next build",
        "start": "next start",
        "lint": "next lint",
        "test": "jest",
        "test:watch": "jest --watch"
      },

    现在,您可以使用“npm test”或“npm run test:watch”运行测试,以进入监视模式。但我们目前还没有任何测试,我们将很快添加一个测试。

    获取您的 NewsDataHub API 令牌

    让我们了解一下获取 API 令牌的过程。

    访问 NewsDataHub.com

    创建您的帐户(无需信用卡)

  • 在注册表中输入你的电子邮件地址
  • 您需要检查电子邮件以获取验证码
  • 验证帐户后,您将进入仪表板,您可以在其中找到 API 密钥
  • 将 API 密钥添加到您的项目

    在项目根目录中创建一个 `.env.example` 文件,作为所需环境变量的模板

    NEXT_PUBLIC_API_URL=https://api.newsdatahub.com/v1/news
    NEXT_PUBLIC_API_TOKEN=your_token_here

    然后运行以下命令将 `.env.example` 模板复制到 `.env.local` 中,您的实际配置将位于其中

    cp .env.example .env.local

    将 .env.local 中的 your_token_here 替换为您仪表板上的 NewsDataHub API 令牌。

    `.env.example` 作为模板提交给 git,而 `.env.local` 包含实际秘密并且被 gitignored。

    在提及 API 密钥时,我们将交替使用“token”和“key”两个词。获得 API 令牌后,将其添加到项目的 `.env.local` 文件中:

    NEXT_PUBLIC_API_URL=https://api.newsdatahub.com/v1
    NEXT_PUBLIC_API_TOKEN=your_newsdatahub_token

    配置文件设置和概述

    让我们设置必要的配置文件。我将解释每个文件的用途和内容:

    更新 .gitignore

    `.gitignore` 文件是在项目初始化期间自动创建的。它告诉 Git 要从版本控制中排除哪些文件和文件夹。

    通过将以下内容添加到“.gitignore”来确保我们的“.env.local”文件被忽略。

    # Environment files
    .env*.local

    设置环境类型定义

    创建“types/env.d.ts”来为我们的环境变量提供 TypeScript 类型定义:

    declare global {
      namespace NodeJS {
        interface ProcessEnv {
          NEXT_PUBLIC_API_URL: string;
          NEXT_PUBLIC_API_TOKEN: string;
        }
      }
    }
    
    export {};

    此文件告知 TypeScript 我们的环境变量,以便在访问 `process.env` 值时进行适当的类型检查并提供自动完成建议。如果没有它,TypeScript 会认为这些变量属于 `any` 类型。

    ESLint 设置

    添加 `.eslintrc.json` 以启用 Next.js 的默认 linting 规则,以实现性能和最佳实践。

    {
      "extends": [
        "next/core-web-vitals"
      ]
    }

    现在让我们添加项目代码

    创建“types/cache.ts”。

    定义我们的客户端缓存系统的结构,指定我们如何存储时间戳和新闻数据

    import { NewsItem } from ".";
    
    export interface CacheData {
        timestamp: number;
        data: NewsItem[];
    }

    创建“types/crypto.ts”。

    从 CoinCap API 定义加密货币价格数据结构,包括价格、市值和 24 小时变化。

    export type CoinData = {
      [key: string]: {
        usd: number;
        usd_market_cap: number;
        usd_24h_vol: number;
        usd_24h_change: number;
        last_updated_at: number;
      }
    }

    创建 `news.ts`

    包含来自 NewsDataHub API 的新闻项目的接口和我们的 NewsCard 组件的道具。

    export interface NewsItem {
        id: string;
        title: string;
        article_link: string;
        description: string;
        pub_date: string;
      }
    
    export interface NewsCardProps {
        index: number;
        item: NewsItem;
    }

    创建“types/index.ts”。

    所有类型定义的中央导出点,支持清洁导入。

    export * from './cache';
    export * from './news';
    export * from './crypto';

    NewsCard 和 PriceTicker 组件实现

    接下来,我们实现组件及其样式。每个组件都应位于其各自的目录中。

    NewsCard 组件

    `app/components/news-feed/NewsCard.tsx`

    import styles from './styles.module.css';
    import { NewsCardProps } from '@/types';
    
    export const NewsCard: React.FC = ({index, item}) => {
        return (
            

    {item.title}

    {item.description.slice(0, 200)+"..."} Read more

    {item.article_link}

    {new Date(item.pub_date).toLocaleDateString()}
    ) }

    `应用程序/组件/新闻提要/styles.module.css`

    .newsCard {
      padding: 15px;
      border: 1px solid #ddd;
      border-radius: 4px;
      margin-bottom: 15px;
    }
    
    .newsTitle {
      margin: 0 0 10px 0;
      font-size: 1.2em;
      color: #2e009a;
      font-family: math;
    }
    
    .newsDate {
      color: #666;
      font-size: 0.9em;
      margin-top: 10px;
    }
    
    .newsLink {
      color:darkcyan;
    }
    
    .newsLink:hover {
      color: rgb(3, 79, 79);
      cursor: pointer;
      text-decoration: underline;
    }

    `app/components/news-feed/index.tsx`

    export { NewsCard } from './NewsCard';

    PriceTicker 组件

    `app/components/price-ticker/PriceTicker.tsx`

    import { useState, useEffect } from 'react';
    import styles from './styles.module.css';
    import { CoinData } from '@/types';
    
    export const  PriceTicker = () => {
      const [prices, setPrices] = useState({});
      const [error, setError] = useState(null);
      const [loading, setLoading] = useState(true);
    
      const fetchPrices = async () => {
        try {
          const response = await fetch('https://api.coingecko.com/api/v3/simple/price?ids=bitcoin,ethereum,dogecoin&vs_currencies=usd&include_24hr_change=true');
          if (!response.ok) throw new Error('Failed to fetch prices');
    
          const data = await response.json();
    
          setPrices(data);
          setError(null);
        } catch (err) {
          setError('Failed to load prices');
          console.error('Price fetch error:', err);
        } finally {
          setLoading(false);
        }
      };
    
      useEffect(() => {
        fetchPrices();
        const interval = setInterval(fetchPrices, 60000); // Update every minute
        return () => clearInterval(interval);
      }, []);
    
      if (loading) return 
    Loading prices...
    ; if (error) return
    Price data unavailable
    ; return (
    {Object.entries(prices).map(([coinId, data]) => { const price = data.usd.toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 }); const change = data.usd_24h_change || 0; const changeClass = change >= 0 ? styles.positive : styles.negative; return (
    {coinId.toUpperCase()} {price} {change >= 0 ? '↑' : '↓'} {Math.abs(change).toFixed(2)}%
    ); })}
    ); }

    `应用程序/组件/价格标签/styles.module.css`

    .ticker {
      background: #1a1a1a;
      color: white;
      padding: 10px;
      border-radius: 8px;
      margin-bottom: 20px;
      overflow-x: auto;
      display: flex;
      gap: 20px;
      align-items: center;
    }
    
    .cryptoPrice {
      display: flex;
      align-items: center;
      gap: 8px;
      padding: 4px 8px;
      background: rgba(255, 255, 255, 0.1);
      border-radius: 4px;
      white-space: nowrap;
    }
    
    .symbol {
      font-weight: bold;
      color: #ffd700;
    }
    
    .price {
      font-family: monospace;
    }
    
    .change {
      font-size: 0.9em;
      padding: 2px 6px;
      border-radius: 4px;
    }
    
    .positive {
      color: #00ff00;
    }
    
    .negative {
      color: #ff4444;
    }
    
    @keyframes slide {
      0% {
        transform: translateX(100%);
      }
      100% {
        transform: translateX(-100%);
      }
    }

    `app/components/news-feed/index.tsx`

    export { PriceTicker } from './PriceTicker';

    构建主页组件

    在 Next.js App Router 中,我们应用程序的主页位于“app/page.tsx”中。虽然按照 Next.js 惯例,该文件将被命名为“page.tsx”,但我们将组件命名为“Home”,以明确表明其用途是作为应用程序的主页。

    继续使用以下代码更新 `app/components/page.tsx`

    'use client';
    
    import { useState, useEffect } from 'react';
    import { PriceTicker } from '@/app/components/price-ticker';
    import { NewsCard } from '@/app/components/news-feed';
    import { CacheData, NewsItem } from '@/types';
    import styles from './page.module.css';
    
    // Environment variables for API configuration
    const API_URL = process.env.NEXT_PUBLIC_API_URL;
    const API_TOKEN = process.env.NEXT_PUBLIC_API_TOKEN;
    
    // Cache duration set to one hour
    const CACHE_DURATION = 1000 * 60 * 60;
    const TOPICS = ['cryptocurrency'];
    
    // In-memory cache for storing news data
    const cache: Record = {};
    
    export default function Home() {
      // State management using React hooks
      const [news, setNews] = useState([]);
      const [loading, setLoading] = useState(false);
      const [error, setError] = useState(null);
      const [lastUpdated, setLastUpdated] = useState(null);
    
      // Fetches news data with built-in caching
      const fetchNews = async (topics: string[]) => {
        const cacheKey = topics.sort().join(',');
        const cachedData = cache[cacheKey];
    
        if (cachedData && Date.now() - cachedData.timestamp < CACHE_DURATION) {
          setNews(cachedData.data);
          setLastUpdated(new Date(cachedData.timestamp));
          return;
        }
    
        setLoading(true);
        setError(null);
    
        try {
          const response = await fetch(`${API_URL}?language=en&topic=cryptocurrency`, {
            headers: {
              'x-api-key': API_TOKEN,
              'Content-Type': 'application/json'
            },
          });
    
          if (!response.ok) throw new Error('Failed to fetch news');
    
          const articles = await response.json();
          const data: NewsItem[] = articles.data;
    
          cache[cacheKey] = {
            timestamp: Date.now(),
            data
          };
    
          setNews(data);
          setLastUpdated(new Date());
        } catch (err) {
          setError(err instanceof Error ? err.message : 'An error occurred');
        } finally {
          setLoading(false);
        }
      };
    
      // Fetch data when component mounts
      useEffect(() => {
        fetchNews(TOPICS);
      }, []);
    
      // Handler for manual refresh
      const handleRefresh = () => {
        const cacheKey = TOPICS.sort().join(',');
        delete cache[cacheKey];
        fetchNews(TOPICS);
      };
    
      return (
        
    {lastUpdated && (
    Last updated: {lastUpdated.toLocaleTimeString()}
    )} {error &&
    {error}
    } {loading ? (
    Loading...
    ) : ( news.map((item: NewsItem, index: number) => ( )) )}
    ); }

    使用以下样式更新“page.module.css”:

    .container {
      max-width: 800px;
      margin: 0 auto;
      padding: 20px;
    }
    
    .topics {
      margin-bottom: 20px;
    }
    
    .topic {
      margin-right: 10px;
      padding: 5px 10px;
      border: 1px solid #ddd;
      border-radius: 4px;
      background: none;
      cursor: pointer;
    }
    
    .topicSelected {
      background: #007bff;
      color: white;
      border-color: #007bff;
    }
    
    .error {
      color: #dc3545;
      padding: 10px;
      border: 1px solid #dc3545;
      border-radius: 4px;
      margin-bottom: 15px;
    }
    
    .loading {
      text-align: center;
      padding: 20px;
    }
    
    .lastUpdated {
      color: #666;
      font-size: 0.9em;
      margin-bottom: 15px;
    }
    
    .refreshButton {
      background: #007bff;
      color: white;
      border: none;
      padding: 5px 10px;
      border-radius: 4px;
      cursor: pointer;
      margin-left: 10px;
    }

    使用以下内容更新 `app/layout.tsx`:

    export default function RootLayout({
      children,
    }: {
      children: React.ReactNode
    }) {
      return (
        
          
            Crypto News Aggregator Application
            
          
          
            {children}
          
        
      )
    }

    运行你的项目

    继续运行你的项目

    npm run dev

    您可以在 https://localhost:3000 找到您正在运行的应用程序

    恭喜完成项目!🏆 👏

    测试页面组件

    我们将为 Home 组件添加一个测试,以验证它在获取数据后是否正确呈现新闻内容。继续创建此测试文件。

    `__tests__/Home.test.tsx`

    import { render, screen, waitFor } from '@testing-library/react';
    import Home from '@/app/page';
    
    describe('Home', () => {
      beforeEach(() => {
        // Set up test environment variables
        process.env.NEXT_PUBLIC_API_URL = 'http://test-api.com';
        process.env.NEXT_PUBLIC_API_TOKEN = 'test-token';
    
        // Mock fetch for both API endpoints
        global.fetch = jest.fn((url) => {
          // Mock responses for different API calls
          if (url.includes('api.coingecko.com')) {
            return Promise.resolve({
              ok: true,
              json: () => Promise.resolve({
                bitcoin: { usd: 65000, usd_24h_change: 2.5 }
              }),
              status: 200,
            } as Response);
          }
          return Promise.resolve({
            ok: true,
            json: () => Promise.resolve({
              data: [{
                id: '1',
                title: 'News Title',
                description: 'News Description',
                url: 'https://test.com',
                published_at: '2024-03-25'
              }]
            }),
            status: 200,
          } as Response);
        }) as jest.Mock;
      });
    
      test('renders news feed', async () => {
        render();
        await waitFor(
          () => expect(screen.getByText("News Title")).toBeInTheDocument(),
          { timeout: 3000 }
        );
      });
    });

    运行测试

    npm run test

    此测试验证我们的 Home 组件在获取数据后是否成功呈现新闻内容。

    可以在项目的 GitHub 存储库中找到 PriceTicker 和 NewsCard 组件的其他测试。这些测试涵盖了组件特定的基本功能和渲染行为。我鼓励您为该项目创建更多测试。

    考虑进一步改进这个项目。

    一些想法:

  • 实现适当的加载状态
  • 添加新闻分页
  • 实现更复杂的缓存
  • 增强测试套件
  • 您可以更改主题查询参数来获取不同类型的新闻
  • 感谢关注!😄

    封面图片来源:RDNE Stock 项目拍摄:https://www.pexels.com/photo/selective-focus-photo-of-silver-and-gold-bitcoins-8369648/