Пет-проект — это индивидуальный проект разработчика для реализации совершенно любых идей, он нужен для практики написания кода. Для того, чтобы сесть за проект, необходима идея. Для пет-проекта лучше выбирать самые интересные и сложные задачи, то, чем вы сами и ваши друзья или знакомые стали бы пользоваться. Например, это может быть трекер передвижения любимого кота, обработка и распознавание простейших математических примеров через камеру, игра в пинг-понг через сокеты c другом и т. д.
В процессе работы обязательно прописывать идеи будущего развития проекта и не забывать, что разработка должна приносить радость.
Для примера я напишу свой пет-проект. Его идея состоит в создании мини-игры, цель которой — как можно быстрее воспроизвести фразу (напечатать буквы и знаки препинания, кроме пробелов), чтобы уложиться в таймер.
Заходим в консоль и первым делом вводим команду по инициализации package.json нашего проекта. Флаг -y указывает на то, что на все вопросы мы говорим «да».
yarn init -y
Проект инициализировали, самое время определиться с тем, пишем мы сборку сами или воспользуемся уже готовым бойлерплейтом. Для себя я решил пользоваться кастомной сборкой, чтобы можно было контролировать и настраивать процесс, вносить изменения и получать новые знания. Это важно, ведь ради практики мы и пишем свои проекты.
В качестве стека у меня Webpack 5, React 18 и TypeScript.
yarn add react react-dom
yarn add webpack webpack-cli webpack-dev-server ts-loader typescript html-webpack-plugin @types/react @types/react-dom clean-webpack-plugin handlebars handlebars-loader -D
После установки всех нужных библиотек время приступать к написанию конфига webpack: создадим папку config
, внутри нее будет файл config/webpack.common.js
. Он нужен, чтобы не повторять себя в development и production.
const path = require('path');
module.exports = {
module: {
rules: [
{
test: /\.(tsx|ts)$/,
use: 'ts-loader',
exclude: /node_modules/
},
{
test: /\.hbs$/,
use: ['handlebars-loader']
}
]
},
resolve: {
extensions: ['.tsx', '.ts', '.js']
}
};
Теперь в корне создадим файл webpack.dev.config.js
:
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const webpackCommon = require('./config/webpack.common');
module.exports = {
...webpackCommon,
entry: './src/App.tsx',
output: {
filename: '[name].[contenthash].js',
path: path.resolve(__dirname, './dist')
},
mode: 'development',
devtool: 'source-map',
devServer: {
static: path.resolve(__dirname, './dist'),
port: 9000,
devMiddleware: {
index: 'index.html',
writeToDisk: true
}
},
plugins: [
new HtmlWebpackPlugin({
title: 'Введите все буквы, за заданное время!',
template: './src/index.hbs'
}),
new CleanWebpackPlugin()
]
};
И не забудем про webpack.prod.config.js
:
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const webpackCommon = require('./config/webpack.common');
module.exports = {
...webpackCommon,
entry: './src/App.tsx',
output: {
filename: '[name].[contenthash].js',
path: path.resolve(__dirname, './dist')
},
mode: 'production',
optimization: {
splitChunks: {
chunks: 'all',
minSize: 3000
}
},
plugins: [
new HtmlWebpackPlugin({
title: 'Введите все буквы, за заданное время!',
template: './src/index.hbs'
}),
new CleanWebpackPlugin()
]
};
Если посмотреть по коду, здесь нужен src/index.hbs
как основная точка входа для приложения.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{htmlWebpackPlugin.options.title}}</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
Добавим src/App.tsx
для React-приложения:
import React from 'react';
import { createRoot } from 'react-dom/client';
import HelloWorld from './components/HelloWorld';
const container = document.getElementById('root');
const root = container ? createRoot(container) : null;
root?.render(<HelloWorld />);
Добавим тестовый компонент src/components/HelloWorld.tsx
:
import React from 'react';
const HelloWorld = () => {
return (
<div>
<h1>Hello World!</h1>
</div>
);
};
export default HelloWorld;
Осталось добавить tsconfig.json
, и можно запускать:
{
"compilerOptions": {
"outDir": "./dist/",
"noImplicitAny": true,
"module": "es6",
"target": "es5",
"jsx": "react",
"allowJs": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true
}
}
Основные скрипты для package.json
:
"scripts": {
"dev": "webpack serve --config webpack.dev.config.js --hot",
"build": "webpack --config webpack.prod.config.js"
}
В консоли гордо запускаем:
yarn dev
Открываем в браузере http://localhost:9000/ и видим сообщение Hello World! После этого можно сохранить сборку в отдельную ветку, как бойлерплейт, и использовать ее в остальных проектах, чтобы не писать каждый раз заново.
Теперь подумаем, чего нам не хватает, и пропишем наш сценарий. Не хватает стилей, поддержки .scss и красивого шрифта. Для добавления в проект в файле src/index.hbs
добавляем:
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Philosopherundefineddisplay=swap" rel="stylesheet">
Шрифт есть. Теперь допишем в config/webpack.common.js
новое правило для обработки понимания .scss файлов в rules:
{
test: /\.scss$/,
use: ['style-loader', 'css-loader', 'sass-loader']
}
Установим нужные зависимости:
yarn add css-loader style-loader sass sass-loader -D
Теперь протестируем наше решение. Создадим файл src/components/index.scss
со всеми стилями, которые нам будут нужны:
:root {
--white: #fff;
--black: #111;
--html-bg: rgb(244, 244, 244);
--wrapper-bg: #fee6e3;
}
* {
font-family: "Philosopher", sans-serif;
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: geometricPrecision;
outline: 0;
-moz-outline-style: none;
-webkit-tap-highlight-color: transparent;
}
body,
html {
background-color: var(--html-bg);
height: 100%;
overflow: hidden;
}
h1 {
font-size: 2.5em;
}
.section-quote {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.wrapper {
background-color: var(--wrapper-bg);
border: 2px solid var(--black);
border-radius: 8px;
box-sizing: border-box;
color: var(--black);
max-width: 100%;
padding: 0 25px;
position: relative;
undefined:after {
background-color: var(--black);
border-radius: 8px;
content: "";
display: block;
height: 100%;
left: 0;
width: 100%;
position: absolute;
top: -2px;
transform: translate(8px, 8px);
transition: transform 0.2s ease-out;
z-index: -1;
}
}
.keyboard {
display: flex;
justify-content: center;
align-items: center;
}
.quote-letters-count {
margin-bottom: 20px;
}
.badge {
padding: 4px 8px;
text-align: center;
border-radius: 5px;
margin-left: 4px;
color: var(--white);
background-color: var(--black);
}
.info-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
}
.start-btn-wrapper {
display: flex;
justify-content: center;
align-items: center;
}
.timer {
margin-top: 20px;
}
button {
background-color: var(--black);
border-radius: 4px;
border-style: none;
color: var(--white);
cursor: pointer;
font-size: 16px;
font-weight: 700;
max-width: none;
min-height: 44px;
min-width: 10px;
margin-bottom: 20px;
outline: none;
overflow: hidden;
padding: 9px 20px 8px;
position: relative;
text-align: center;
text-transform: none;
user-select: none;
undefined:hover,
undefined:focus {
opacity: 0.75;
}
}
.opacity-on {
opacity: 1;
transition: opacity 1s ease-out;
}
.opacity-off {
opacity: 0;
}
Не забываем подключить наш файл к приложению, запустим наше приложение еще раз, и вуаля — отличный шрифт и сглаживание у нас уже есть. Мы молодцы!
Теперь, когда у нас есть правила и шрифт, подумаем над сценарием поведения нашего приложения. Я вижу его таким: пользователь заходит на страницу, видит на ней статус с описанием того, что именно от него требуется. Под описанием есть кнопка «Старт», после нажатия которой пользователь видит какую-либо цитату. Сверху виден таймер с отсчетом времени, внизу — количество букв и знаков, которые осталось ввести для победы, а рядом — общее число побед. Если пользователь вводит фразу быстрее таймера, выпускаем конфетти, а если не уложился, пишем, что игра закончена, но всегда можно нажать на старт, чтобы начать заново.
Выглядит это так:
Мы можем вводить буквы в любой последовательности, ускоряя процесс, и у нас может быть сколько угодно побед.
Планирование и написание пользовательского сценария — важный процесс работы над пет-проектом, ведь все решения в нем принимаем мы.
Теперь пропишем компоненты и начнем их создавать.
Приложение состоит из следующих компонентов:
- Конфетти
- Счетчик букв и знаков
- Статус
- Количество побед
Итак, позаботимся о конфетти src/components/ConfettiSplash/index.tsx
:
import React, { FC } from 'react';
import Confetti from 'react-confetti';
const ConfettiSplash: FC<{ confetti: boolean }> = ({ confetti }) => (
<div className={`opacity-on ${confetti ? '' : 'opacity-off'}`}>
<Confetti />
</div>
);
export default ConfettiSplash;
Установим react-confetti
. Компонент работает очень просто: показываем конфетти в случае победы, в остальных случаях не показываем.
yarn add react-confetti
Напишем счетчик букв и знаков src/components/LettersCount/index.tsx
:
import React, { FC } from 'react';
const LettersCount: FC<{ quoteLetters: string }> = ({
quoteLetters
}) => (
<div className="quote-letters-count">
Букв и знаков осталось:
<span className="badge">{quoteLetters?.length}</span>
</div>
);
export default LettersCount;
Напишем статус, который будет встречать нашего пользователя, а также сообщать ему о том, что можно попробовать сыграть заново в случае проигрыша src/components/Status/index.tsx
.
import React, { FC } from 'react';
const Status: FC<{
start: boolean | undefined;
setStart: (value: boolean) => void;
}> = ({ start, setStart }) => (
<>
<h1>
{typeof start === 'undefined'
? `⏱ Цель игры, как можно быстрее напечатать буквы и
знаки, кроме пробелов, чтобы уложиться в таймер.`
: `???? Вы проиграли.`}
</h1>
<div className="start-btn-wrapper">
<button onClick={() => setStart(true)}>Старт</button>
</div>
</>
);
export default Status;
Осталось количество побед src/components/Victory/index.tsx
:
import React, { FC } from 'react';
const Victory: FC<{ victory: number }> = ({ victory }) => {
return (
<>
{victory > 0 undefinedundefined (
<div className="quote-letters-count">
Побед{victory === 1 ? 'а' : 'ы'}:
<span className="badge">{victory}????</span>
</div>
)}
</>
);
};
export default Victory;
Так у нас есть весь необходимый набор компонентов. Осталось добавить словарь с цитатами. Для этого создадим папку data c файлом src/data/quotes.json
:
[
"Чем умнее человек, тем легче он признает себя дураком.",
"Никогда не ошибается тот, кто ничего не делает.",
"Менее всего просты люди, желающие казаться простыми.",
"Музыка заводит сердца так, что пляшет и поёт тело. А есть музыка, с которой хочется поделиться всем, что наболело.",
"Если тебе тяжело, значит ты поднимаешься в гору. Если тебе легко, значит ты летишь в пропасть.",
"Мой способ шутить – это говорить правду. На свете нет ничего смешнее.",
"Чем больше любви, мудрости, красоты, доброты вы откроете в самом себе, тем больше вы заметите их в окружающем мире.",
"Единственный человек, с которым вы должны сравнивать себя, – это вы в прошлом. И единственный человек, лучше которого вы должны быть, – это вы сейчас.",
"История – самый лучший учитель, у которого самые плохие ученики.",
"Человечество обладает одним поистине мощным оружием, и это смех.",
"Тренируйся с теми, кто сильнее. Не сдавайся там, где сдаются другие. И победишь там, где победить нельзя.",
"Будьте менее любопытны о людях, но более любопытны об идеях.",
"Мышление – верх блаженства и радость жизни, доблестнейшее занятие человека.",
"Я серьёзно отношусь к своей работе, а это возможно только при несерьёзном отношении к собственной персоне.",
"Успех – паршивый учитель. Он заставляет умных людей думать, что они не могут проиграть.",
"Чемпионами становятся не в тренажёрных залах. Чемпиона рождает то, что у человека внутри: желания, мечты, цели.",
"Необходимо, чтобы художник, кроме глаза, воспитывал и свою душу.",
"То, что мы знаем, это капля, а то, что мы не знаем, это океан.",
"Ни высокий интеллект, ни воображение, ни то и другое вместе не творят гения. Любовь, любовь и любовь – вот в чём сущность гения.",
"Не оборачивается тот, кто устремлён к звёздам.",
"Шире открой глаза, живи так жадно, как будто через десять секунд умрёшь. Старайся увидеть мир. Он прекраснее любой мечты, созданной на фабрике и оплаченной деньгами. Не проси гарантий, не ищи покоя – такого зверя нет на свете.",
"Видите ли, художника отличает то, что в его жизни бывают минуты, когда он ощущает себя больше чем человеком.",
"Любовь к собственному благу производит в нас любовь к отечеству, а личное самолюбие – гордость народную, которая служит опорою патриотизма."
]
Добавим для работы с .json в tsconfig.json
:
"resolveJsonModule": true
Теперь нам доступен импорт .json файлов. Удаляем файл src/components/HelloWorld.tsx
и вместо него добавляем src/components/Index.tsx
:
import React, { useEffect, useState, KeyboardEvent, FC } from "react";
import _sample from "lodash/sample";
import _round from "lodash/round";
import ConfettiSplash from "./ConfettiSplash";
import LettersCount from "./LettersCount";
import Status from "./Status";
import Victory from "./Victory";
import quotes from "../data/quotes.json";
import "./index.scss";
const returnQuoteLetters = (quote: string) =>
quote.replace(/\s/g, "").split("_").join("");
const generateQuote = () => _sample(quotes);
const Index = () => {
const [confetti, setConfetti] = useState(false);
const [start, setStart] = useState<undefined | boolean>();
const [victory, setVictory] = useState(0);
const [exception, setException] = useState(generateQuote);
const quoteLetters = returnQuoteLetters(exception);
const [counter, setCounter] = useState(_round(quoteLetters.length / 2));
useEffect(() => {
const keyDownHandler = (event: KeyboardEvent<HTMLInputElement>) => {
const { key } = event;
const underscore = "_";
const space = " ";
if (key !== underscore undefinedundefined key !== space) {
setException(exception.replace(key, underscore));
}
};
window.addEventListener("keydown", keyDownHandler, false);
return () => window.removeEventListener("keydown", keyDownHandler, false);
}, [exception]);
useEffect(() => {
const timer =
counter > 0 undefinedundefined setTimeout(() => setCounter(counter - 1), 1000);
if (counter === 0) {
setStart(false);
}
return () => clearInterval(timer);
}, [counter]);
useEffect(() => {
if (!quoteLetters) {
const newQuote = generateQuote();
setVictory(victory + 1);
setConfetti(true);
setException(newQuote);
setCounter(_round(returnQuoteLetters(newQuote).length / 2));
setTimeout(() => setConfetti(false), 4000);
}
}, [victory, exception]);
useEffect(() => {
if (start) {
const newQuote = generateQuote();
setVictory(0);
setException(newQuote);
setCounter(_round(returnQuoteLetters(newQuote).length / 2));
}
}, [start]);
return (
<>
<ConfettiSplash confetti={confetti} />
<div className="section-quote">
<div className="wrapper">
{start ? (
<>
<div className="timer">Таймер:{counter}</div>
<h1>{exception}</h1>
<div className="info-wrapper">
<LettersCount quoteLetters={quoteLetters} />
<Victory victory={victory} />
</div>
</>
) : (
<Status start={start} setStart={setStart} />
)}
</div>
</div>
</>
);
};
export default Index;
Устанавливаем lodash:
yarn add lodash @types/lodash -D
И добавляем src/global.d.ts
, чтобы TS не ругался на window.addEventListener('keydown', keyDownHandler, false);
import { KeyboardEvent } from 'react';
declare global {
interface WindowEventMap {
keydown: KeyboardEvent<HTMLInputElement>;
}
}
Делаем правки в src/App.ts
:
import React from "react";
import { createRoot } from "react-dom/client";
import Index from "./components/Index";
const container = document.getElementById("root");
const root = createRoot(container);
root.render(<Index />);
Запускаем приложение и убеждаемся, что все работает согласно нашему сценарию.
Теперь более внимательно посмотрим на код. Важная часть в пет-проектах — идеи роста. Нам, как разработчикам, важен опыт и насмотренность (что хорошо, а что плохо) на общую кодовую базу. Для себя я пишу список упражнений, которые нужно выполнить, работая внутри своего пет-проекта, и таким образом получаю и подтверждаю свой опыт, если длительное время не работаю с кодом. Я задаю себе вопрос, что можно улучшить, и создаю список.
- В файле src/components/index.scss нет миксинов, и по-хорошему мы должны были разграничить стили от общих. Чтобы избежать ада глобальных имен, нам нужно использовать .module.scss или БЭМ-нотацию.
- В файле src/components/Index.tsx слишком много useState, можно использовать useReducer или новые и трендовые стейт-менеджеры — Effector, Recoil, Jotai, Rematch, Zustand — либо остановиться на React Context. Для опыта и насмотренности можно сделать для каждого решения свою ветку — это даст наглядное понимание, что хорошего в решениях на рынке стейт-менеджеров есть уже сейчас.
- Вынести в отдельный компонент таймер. Старайтесь писать списки и продумывать, как бы вы стали развивать свой проект, добавлять новые функции.
- Нет системы уровней, для ускорения таймера.
- Нет возможности посоревноваться с другом.
- Можно ли сделать удобным интерфейс для планшетов и мобильных устройств?
- Нет возможности поделиться результатом.
- Нет возможности поставить на паузу.
- Было бы здорово иметь на базе такого компонента виджет, который можно встроить на сторонний ресурс.
Когда мы описываем процесс работы с пет-проектом, у нас все время появляются новые задачи и вызовы. В принципе, это можно приравнять к постоянному стажу разработки, который помогает нам держать свои знания и насмотренность в тонусе. Это чрезвычайно важно при текущем положении дел, когда технологии и идеи развиваются со стремительной скоростью.
И напоследок скажу: чем больше мы работаем с кодовой базой проекта, тем более живым становится код. Следует не забрасывать свои пет-проекты, а стараться реализовывать в них все больше новых идей.