使用 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) 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/