Vite + React + Typescript: Ứng dụng thời tiết
Đây là hướng dẫn xây dựng một ứng dụng đơn giản – Hiển thị thông tin thời tiết theo tên thành phố. Ứng dụng sẽ được xây dựng bằng Vite, React và Typescript.
Trong bài viết này tập trung giới thiệu việc sử dụng Typescript để tạo ra một ứng dụng React, có nhiều phần sẽ không được sâu. Chính vì vậy bạn cũng phải có một chút hiểu biết nhất định về React hay Typescript.
Tạo một dự án với Vite
Hãy chắc chắn rằng bạn đã cài đặt NodeJS >= 18, tiếp đó bạn có thể tạo một dự án Vite bằng câu lệnh:
npm create vite@latest
Câu lệnh trên sẽ hỏi bạn về tên của dự án, hãy điền tên dự án của bạn (ví dụ: `vite-ts-weather-app`).
Tiếp theo hãy chọn React khi lựa chọn framework.
Cuối cùng, hãy chọn Typescript cho ngôn ngữ của dự án.
Chạy ứng dụng
Chúng ta đã hoàn thành việc khởi tạo dự án. Bây giờ bạn có thể di chuyển vào thư mục dự án và sử dụng những câu lệnh sau để chạy ứng dụng ở chế độ dev:
cd vite-ts-weather-app npm install npm run dev
Để xác nhận ứng dụng đang được chạy, cửa sổ terminal của bạn sẽ có kết quả tương tự như sau:
Hãy nhấn phím “o” để mở ứng dụng bằng trình duyệt mặc định của bạn.
Xây dựng ứng dụng thời tiết
Ứng dụng sẽ hiển thị thông tin thời tiết theo thành phố mà người nhập vào ô tìm kiếm. Chính vì vậy chúng ta sẽ phải gọi một API để lấy thông tin thời tiết, để giảm thiểu số lần gọi API chúng ta cũng cần sửa đổi logic khi gọi API.
Cài đặt Test Package
Hy vọng chúng ta sẽ quen với việc viết test cho mọi dòng code mà chúng ta viết.
Để viết test cho cho ứng dụng này, chúng ta sẽ dụng jest làm test framework, và một vài gói hỗ trợ từ @testing-library
npm install -D jest ts-jest jest-environment-jsdom @testing-library/react @testing-library/user-event
Tạo file cấu hình cho tiến trình jest – jest.config.js
/** @type {import('ts-jest').JestConfigWithTsJest} **/ export default { testEnvironment: 'jsdom', transform: { '^.+.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.app.json' }], }, };
Chúng ta sẽ chạy test bằng lệnh npx jest
(Có thể chúng ta sẽ gặp lỗi không có file test nào được tìm thấy, hãy tạm thời bỏ qua).
useFetch hook
Đây là một custom hook có thể thấy ở đa số dự án React, hook này hỗ trợ gọi một api với các state của data, loading, error.
Đây là spec của useFetch
hook – `src/hooks/useFetch.test.ts`
import { renderHook, act, waitFor } from '@testing-library/react'; import useFetch from './useFetch'; describe('useFetch', () => { beforeEach(() => { global.fetch = jest.fn(); }); afterEach(() => { jest.clearAllMocks(); }); it('fetches data successfully', async () => { const mockData = { message: 'Success' }; (fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: jest.fn().mockResolvedValueOnce(mockData), }); const { result } = renderHook(() => useFetch<typeof mockData>()); act(() => { result.current.call('https://api.example.com/data'); }); await waitFor(() => { expect(result.current.loading).toBe(false); expect(result.current.data).toEqual(mockData); expect(result.current.error).toBeNull(); }); }); it('handles fetch errors', async () => { (fetch as jest.Mock).mockRejectedValueOnce(new Error('Network Error')); const { result } = renderHook(() => useFetch()); act(() => { result.current.call('https://api.example.com/data'); }); await waitFor(() => { expect(result.current.loading).toBe(false); expect(result.current.data).toBeNull(); expect(result.current.error).toEqual(new Error('Network Error')); }); }); it('handles non-200 status codes', async () => { (fetch as jest.Mock).mockResolvedValueOnce({ status: 404, json: jest.fn(), }); const { result } = renderHook(() => useFetch()); act(() => { result.current.call('https://api.example.com/data'); }); await waitFor(() => { expect(result.current.loading).toBe(false); expect(result.current.data).toBeNull(); expect(result.current.error).toEqual(new Error('Failed to fetch!')); }); }); });
Dựa vào spec này chúng ta sẽ có implement của hook như sau: src/hooks/useHook.ts
import { useCallback, useState } from 'react'; export default function useFetch<T>() { const [data, setData] = useState<T | null>(null); const [error, setError] = useState<Error | null>(null); const [loading, setLoading] = useState<boolean>(false); const call = useCallback(async (url: string) => { try { setLoading(true); const response = await fetch(url); if (response.status !== 200) { setError(new Error('Failed to fetch!')); return; } setData(await response.json()); } catch (error) { setError(error as Error); } finally { setLoading(false); } }, []); return { data, error, loading, call }; }
chúng ta có một generic hook, T
là kiểu trả về của API.
Bạn sẽ phải chạy lại test để chắc code chúng ta viết đã thoả mãn yêu của của file test.
DebounceInput
Đây là một React component, có chức năng tương tự input
component. Điểm khác là sự kiện onChange
sẽ được gọi sau một thời gian delay nhất định, các sự kiện trong khoảng thời gian chờ đợi cũng sẽ bị huỷ bỏ, nói cách khác chỉ có kết quả cuối cùng được tạo ra.
Đây là spec của component – src/components/DebounceInput.test.ts
import { render, fireEvent } from '@testing-library/react'; import { screen } from '@testing-library/react'; import DebounceInput from './DebounceInput'; describe('DebounceInput', () => { const waitTime = 300; let handleChange: jest.Mock; let input: HTMLInputElement; beforeEach(() => { jest.useFakeTimers(); handleChange = jest.fn(); render(<DebounceInput wait={waitTime} onChange={handleChange} />); input = screen.getByRole('textbox'); }); it('calls onChange after the specified wait time', () => { // Simulate changing the input value fireEvent.change(input, { target: { value: 'Hello' }, }); // Fast forward the timers jest.advanceTimersByTime(waitTime); // Check that onChange was called with the correct value expect(handleChange).toHaveBeenCalledTimes(1); expect(handleChange).toHaveBeenCalledWith('Hello'); }); it('clears previous timeout on new input', () => { // Simulate first change fireEvent.change(input, { target: { value: 'Hello' }, }); jest.advanceTimersByTime(waitTime - 100); // Advance time partially // Simulate second change before the first change has completed fireEvent.change(input, { target: { value: 'World' }, }); // Fast forward the timers to finish the debounce jest.advanceTimersByTime(waitTime); // Complete the remaining time // Check that onChange was called with the second value expect(handleChange).toHaveBeenCalledTimes(1); expect(handleChange).toHaveBeenCalledWith('World'); }); });
và đây là nội dung của DebounceInput component – src/components/DebounceInput.tsx
import { useEffect, useState, InputHTMLAttributes } from 'react'; export interface DebounceInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> { onChange: (value: string) => void; wait: number; } export default function DebounceInput({ onChange, wait, ...inputProps }: DebounceInputProps) { const [value, setValue] = useState(''); useEffect(() => { const timeoutId = setTimeout(() => { onChange(value); }, wait); return () => clearTimeout(timeoutId); }, [value, onChange, wait]); return <input {...inputProps} onChange={(e) => setValue(e.target.value)} />; }
Kết quả khi chạy test:
App.tsx
import { useEffect, useState } from 'react'; import './App.css'; import useFetch from './hooks/useFetch'; import WeatherData from './models/WeatherData'; import DebounceInput from './components/DebounceInput'; function App() { const { data: weather, call } = useFetch<WeatherData>(); const [city, setCity] = useState<string>('Tokyo'); useEffect(() => { if (city) { call( `https://api.openweathermap.org/data/2.5/weather?units=metric&appid=9505fd1df737e20152fbd78cdb289b6a&q=${city}` ); } }, [city]); return ( <> <main> <form> <DebounceInput type="text" autoComplete="off" wait={500} onChange={(v) => setCity(v)} /> <button> <i className="fa-solid fa-magnifying-glass"></i> </button> </form> {weather ? ( <section className="result"> <figure className="name"> <figcaption>{weather.name}</figcaption> <img src={`https://flagsapi.com/${weather.sys.country}/shiny/32.png`} /> </figure> <figure className="temperature"> <img src={`https://openweathermap.org/img/wn/${weather.weather[0].icon}@4x.png`} /> <figcaption> <span>{Math.floor(weather.main.temp)}</span> <sup>o</sup> </figcaption> </figure> <p className="description">{weather.weather[0].description}</p> <ul> <li> <span>Clouds</span> <i className="fa-solid fa-cloud"></i> <span id="clouds">{weather.clouds.all}</span>% </li> <li> <span>Humidity</span> <i className="fa-solid fa-droplet"></i> <span id="humidity">{weather.main.humidity}</span>% </li> <li> <span>Pressure</span> <i className="fa-solid fa-gauge"></i> <span id="pressure">{weather.main.pressure}</span>hPa </li> </ul> </section> ) : ( <>TODO</> )} </main> </> ); } export default App;
Và file css – `App.css`
@import url('https://use.fontawesome.com/releases/v6.5.2/css/all.css'); #root { margin: 0; padding: 0; display: grid; height: 100vh; width: 100vw; overflow: hidden; font-size: 2rem; } * { padding: 0; margin: 0; } main { width: 100%; height: 100%; background-color: #f7f7f7; padding: 30px; box-sizing: border-box; font-family: sans-serif; } form { border: 1px solid #5552; display: flex; border-radius: 30px; justify-content: space-between; } input, button { border: none; background-color: transparent; outline: none; padding: 10px; box-sizing: border-box; } i { opacity: 0.7; } .result { padding-top: 20px; text-align: center; } .result .name { font-weight: bold; font-size: large; display: flex; justify-content: center; align-items: center; gap: 10px; } .temperature img { width: 150px; filter: drop-shadow(0 10px 50px #555); } .temperature figcaption { font-size: 3em; } .description { padding: 10px 0 30px; } .description::first-letter { text-transform: capitalize; } ul { list-style: none; display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; } li { background-color: #f78a55; color: #fff; border-radius: 10px; padding: 20px 10px; background-image: linear-gradient(to bottom, transparent 50%, #0003 50%); font-weight: bold; font-size: small; } li i { font-size: 2em; margin: 20px 0; display: block !important; } li:nth-child(2) { background-color: #b56291; } li:nth-child(3) { background-color: #48567b; }
File src/models/WeatherData.ts
export default interface WeatherData { coord: Coord; weather: Weather[]; base: string; main: Main; visibility: number; wind: Wind; clouds: Clouds; dt: number; sys: Sys; timezone: number; id: number; name: string; cod: number; } interface Coord { lon: number; lat: number; } interface Weather { id: number; main: string; description: string; icon: string; } interface Main { temp: number; feels_like: number; temp_min: number; temp_max: number; pressure: number; humidity: number; sea_level: number; grnd_level: number; } interface Wind { speed: number; deg: number; } interface Clouds { all: number; } interface Sys { type: number; id: number; country: string; sunrise: number; sunset: number; }
Kết luận
Với việc sử dụng Vite, với một vài bước đơn giản, chúng ta đã hoàn thành một ứng dụng React. Ứng dụng chưa có nhiều chức năng, nhưng hy vọng nó có thể giúp bạn hình dung được việc phát triển một ứng dụng React bằng Typescript như thế nào.
Source code và Demo: https://stackblitz.com/edit/stackblitz-starters-s8gcrt
Tham khảo
- Xây dựng ứng dụng bằng React sử dụng Typescript 2023: TDD với jest và @testing-library
- Xây dựng ứng dụng bằng React sử dụng Typescript