使用 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
出现提示时,选择:
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
但在此之前,我们需要稍微清理一下项目目录,然后创建一些文件。
可以安全删除的文件
如果您的 favicon.ico 位于应用程序目录中,请考虑将其移动到公共文件夹。虽然 favicon 可以在两个位置工作,但将其移动到“public/”符合传统结构并使资产位置更加明确。
测试
我们需要安装几个测试包
npm install --save-dev @testing-library/react @testing-library/jest-dom 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 密钥添加到您的项目
在项目根目录中创建一个 `.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) returnPrice 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/