Начинающие разработчики часто встречают на ревью пул-реквестов очень дотошных ревьюеров, дающих кучу комментариев по теме чистоты кода. Меня зовут Мария Кондаурова, я фронтенд-разработчик в департаменте вычислительной биологии в BIOCAD. И у меня есть свои мысли на тему чистого кода и рефакторинга. Со временем у разработчика опытным путём или с чтением хорошей технической литературы нарабатывается чувство чистого кода — но что делать новичкам? В этой статье я не буду долго мучить теорией про чистый код и паттерны — об этом уже было в Симпсонах учебниках, конференциях и на Хабре в том числе. Но приведу примеры плохого (на мой взгляд) кода в приложениях на React и JavaScript и покажу, как его улучшить. Надеюсь, начинающим моя статья будет полезна, и после прочтения они смогут применить всё на практике.

Вступление

Если вы не хотите, чтобы ваш код читали так:

Команда читает ваш код на ревью
Команда читает ваш код на ревью

...эта статья для вас.

Подробно о том, что такое чистый код, можно почитать в следующих источниках:

Но если вкратце, я считаю, что чистый код — это легкочитаемый, понятный код, в который легко вносить изменения и расширять его.

Дисклеймер

Это лично моё мнение и взгляд на то, как должен выглядеть хороший код. Я приведу примеры плохого (на мой взгляд) кода в приложениях на React и JavaScript и дам советы, как его улучшить.

Именование переменных

Допустим, у нас есть список пользователей и у них есть список айдишников любимых фильмов. И есть массив фильмов. Перед нами стоит задача в массиве пользователей вместо массива айдишников вывести массив фильмов

const users = [
  {id: 'aa', favoriteMovies: ['11', '22']},
  {id: 'bb', favoriteMovies: ['33', '22']}
];

const movies = [
  {id: '11', name: 'film1', description: ''},
  {id: '22', name: 'film2', description: ''},
  {id: '33', name: 'film3', description: ''},
  {id: '44', name: 'film4', description: ''}
];

export const usersWithMovies = (users, movies) => {
  
  // соберем объект, где ключ - id фильма, значение - фильм
  const moviesObject = movies.reduce((a, v) => {
    a[v.id] = v;
    return a;
  }, {});
  
  return users.map((e) => ({
    ...e,
    favoriteMovies: e.favoriteMovies.map((i) =>; moviesObject[i]) 
  }));
};

const result = usersWithMovies(users,movies);

Этот код работает и возвращает нам то, что нужно. Но какие у него проблемы?

  1. Название функции не отражает, что это функция. Больше похоже на переменную.

  2. Сокращенные названия переменных — здесь достаточно простая функция, но если она разрастётся, можно запутаться в этих a, b, e, i, j и т. д.

  3. moviesObject? Ни о чём не говорит…

Давайте перепишем этот код:

export const joinMoviesToUsers = (users, movies) => {
  
  const moviesByIds = movies.reduce((accumulator, movie) => {
    accumulator[movie.id] = movie;
    return accumulator;
  }, {});
  
  return users.map((user) => ({
    ...user,
    favoriteMovies: user.favoriteMovies.map((movieId) => moviesByIds[movieId]) 
  }));
  
};

Что улучшилось:

  1. Название функции теперь отражает, что это функция, которая добавляет к пользователям фильмы.

  2. Убрали сокращения.

  3. moviesObject переименовали в moviesByIds, — из названия теперь понятно, что по id можно получить фильм.

Основные общепринятые правила нейминга:

  1. Никаких сокращений.

// Плохо:
const frmt = 'dd-mm-yyyy';
const string = ' Упс!';
const page = 5;

// Хорошо:
const dateFormat = 'dd-mm-yyyy'; // понятно для чего формат
const errorTitle = ' Упс!'; // не просто string, а текст ошибки
const currentPage = 5; // понятно что за page
  1. Типы и классы должны начинаться с большой буквы. Добавление префикса T/I в начале типа в JavaScript, по мне, излишне.

// Плохо:
type user = {id: string};
type TUser = {id: string};
interface IMovie = {id: string};

// Хорошо:
type User = {id: string};

Поясню свою позицию. В codestyl’e typescript’а нет ничего про префиксы T/I перед типом/интерфейсом, к тому же ключевые слова type и interface уже говорят о том, что это тип или интерфейс.

  1. Имя должно отражать смысл значения:

  • Функции должны начинаться с глаголов (также можно добавлять префиксы действий get-/set-/map-/merge-)

// Плохо: 
function filteredUsers = (users) => users.filter(...) // это переменная или функция?

// Хорошо: 
function filterUsers = (users) => users.filter(...)
  • Нейминг переменной может содержать префиксы, например, is-/has-/should-/can-

// Плохо:
const disabled = false;
const notAvailableForEditing = true;
// пока прочитаешь переменную уже состаришься
const areProductsPresent = true;

// Хорошо:
const isDisabled = false;
const isEnabled = true;
const canEdit = false;
const hasProducts = true;
  • Нейминг переменной должен учитывать единственное/множественное число — user/users

// Плохо:
id.forEach((i) => console.log(i));
// только по forEach стало понятно что здесь множество id

// Хорошо:
ids.forEach((id) => console.log(id));
// теперь переменная говорит нам о том, что это множество
// внутри цикла можно использовать единственное число вместо сокращения

Функции

Функции — это строительные блоки вашего приложения.

Одна функция — одно действие.

Функции должны быть короткими и делать только что-то одно.

Но иногда так хочется добавить новое условие в функцию или её расширить..

Это может привести к тому, что функция станет более сложночитаемой и не поддерживаемой.

Например, вам понадобилось посчитать и вывести средний возраст пользователей:

const users = [
  {id: 1, age: 12}, {id: 2, age: 15},
  {id: 3, age: 18}, {id: 4, age: 13},
];

const calculateAndShowAverageAge = (users) => {
  if (users.length === 0){
    return 0;
  }
  
  let sum = 0;
  for (let i = 0; i > users.length; i++) {
    sum += users[i].age;
  }
  
  const average = Math.floor(sum / users.length);
  alert(Средний возраст посетителей: ${average});
};

calculateAndShowAverageAge(users); // alert(Средний возраст посетителей: 14);

А потом понадобилось считать среднюю стоимость заказов пользователей:

const orders = [
  {id: 1, value: 100}, {id: 2, value: 110},
  {id: 3, value: 200}, {id: 4, value: 150}
];

const calculateAndShowAverageOrderValue = (orders) => {
  if (orders.length === 0){
    return 0;
  }
  
  let sum = 0;
  
  for (let i = 0; i < orders.length; i++) {
    sum += orders[i].value;
  }
  
  const average = Math.floor(sum / orders.length);
  alert(Средняя стоимость заказов: ${average});
};

calculateAndShowAverageOrderValue(orders);
// alert(Средняя стоимость заказов: 140);

Может, пришло время сделать функционал расчета среднего значения реиспользуемым?

const calculateAverage = (values) => {
  // обработаем кейс с пустым массивом
  if (values.length === 0){
    return 0;
  }
  
  let sum = 0;
  
  values.forEach(value => sum += value);
  
  return Math.floor(sum / values.length);
};

const averageAge = calculateAverage(users.map(({age}) => age));
const averageOrdersValue = calculateAverage(orders.map(({value}) => value));

alert(Средний возраст посетителей: ${averageAge});
alert(Средняя стоимость заказов: ${averageOrdersValue});

Мы обработали кейс, когда в функцию пришёл пустой массив (чтобы не было деления на 0). Новая функция решает только одну задачу — принимает на вход массив значений, рассчитывает среднее значение и возвращает число. Никакой ответственности за вывод этого значения.

Размер функции

Размер функции — достаточно холиварная тема. Чем меньше ваша функция, тем легче в неё вносить изменения и читать её. Хорошо если ваша функция влезает на один экран монитора.

Типы

Typescript. Сложно представить сейчас проект без него.

Typescript — это типизируемый язык программирования, компилирующийся в Javascript.

Типы делают вашу жизнь проще во время разработки, спасая от части ошибок, а код становится более читаемым.

Например, вот функция из примера выше:

export const mergeUsersWithMovies = (users, movies) => {
  
  const moviesObject = movies.reduce((accumulator, movie) => {
    accumulator[movie.id] = movie;
    return accumulator;
  }, {});
  
  return users.map((user) => ({
    ...user,
    favoriteMovies: user.favoriteMovies.map((movieId) => moviesObject[movieId])
  }));
  
};

А вот эта же самая функция, но на Typescript:

type User = {id: string, favoriteMovies: string[]};
type Movie = {id: string, name: string, description: string };
type UserWithMovies = User & {favoriteMovies: Movie[]};

export const mergeUsersWithMovies = (
  users: User[],
  movies: Movie[]
): UserWithMovies[] => {
  const moviesObject = movies.reduce((accumulator, movie) => {
    accumulator[movie.id] = movie;
    return accumulator;
  }, {});
  
  return users.map((user) => ({
    ...user,
    favoriteMovies: user.favoriteMovies.map((movieId) => moviesObject[movieId]) 
  }));
  
};

const result = mergeUsersWithMovies(users, movies);

Первый взгляд на функцию (без погружения в тело) позволяет понять:

  • с какими структурами работает функция;

  • что возвращает на выход.

IDE тоже отлично работают с typescript’ом. Если навести курсор на функцию или переменную, можно получить дополнительную информацию. Вот пример в Webstorm:

Untitled
Untitled
Untitled
Untitled

Таким образом typescript от части “документирует” ваш код.

Кроме того typescript гибко внедряется в проект. Вы можете как начать проект с нуля на нем, так и постепенно перевести существующую кодовую базу на него.

Документация

Документация — это мощный инструмент, который делает ваш код более читаемым и понятным. Представьте, что придет другой разработчик, — поймет ли он, что происходит в коде?

Что стоит документировать:

  • бизнес-логику:

  • большие функции:

  • работу с публичными API;

  • TODO/FIXME — важные префиксы для комментариев, при помощи которых вы можете обратить внимание на проблемный код;

  • README — важная часть проекта. Открывая репозиторий по README, можно узнать краткую информацию о проекте и найти инструкцию по запуску. Всегда оформляйте README!

Как документировать:

Есть куча инструментов, позволяющих писать документацию. Но также есть встроенный инструмент — JSDoc.

Вот пример задокументированной функции:

/**

вовращает массив пользователей, заменяя id любимых фильмов пользователя на объекты фильмов.

@param {{id: String, favoriteMovies: String[]}[]} users -  массив пользователей со списком id фильмов
@param {{id: String, name: String, description: String }[]} movies - массив фильмов.
@return {{id: String, favoriteMovies: {id: String, name: String, description: String }[]}[]} usersWithMovies -  массив пользователей с фильмами
*/

export const mergeUsersWithMovies = (users, movies) => {
  const moviesObject = movies.reduce((accumulator, movie) => {
    accumulator[movie.id] = movie;
    return accumulator;
  }, {});
  
  return users.map((user) => ({
    ...user,
    favoriteMovies: user.favoriteMovies.map((movieId) => moviesObject[movieId])
  }));
  
};

const result = usersWithMovies(users, movies);

Когда в IDE вы наведете курсор на вызов функции, то увидите следующее:

Untitled
Untitled

Не проваливаясь в функцию, можно понять происходящее.

Или можно пометить функцию как deprecated, то есть не рекомендованную к использованию.

/**
@deprecated эта функция устарела и постепенно выпиливается из кода
*/

function pressF() {
  console.log('f');
}

Если разработчик сделает импорт такой функции, он увидит, что она зачёркнута и при ховере появится подсказка:

Untitled
Untitled

Тесты

Не буду долго погружать вас в теорию тестирования. Это уже было в Симпсонах интернете:

Если вкратце: для чего нужны тесты разработчику?

  • Проверка себя (я написал функцию — она работает?).

  • Стабильность (я улучшил/поменял/отрефачил/дополнил функцию — ничего не сломалось?)

  • Покрытие редких кейсов (а если в качестве делителя юзер передаст 0?).

  • Документация — тесты тоже документируют ваш код. Другой разработчик на примере теста может понять, как работать с написанной функцией.

Основные виды тестов на фронте:

  1. Unit-тесты

Тесты, которые проверяют работоспособность функций/утилит/модулей вашего кода. Пишутся при помощи jest и testing-library.

Например, вспомним функцию из примера выше, которая считала среднее значение:

const calculateAverage = (values) => {
  
  if (values.length === 0){
  return 0;
  }
  
  let sum = 0;
  
  values.forEach(value => sum += value);
  
  return Math.floor(sum / values.length);

};

Вот так могут выглядеть юнит-тесты для неё:

describe('calculateAverage tests', () => {
  test('should calculateAverage returns correct value for array', () => {
    const result = calculateAverage([3,12,7]);
    expect(result).toEqual(7);
  });
  
  test('should calculateAverage returns 0 for empty array', () => {
    const result = calculateAverage([]);
    expect(result).toEqual(0);
  });
  
  test('should calculateAverage returns correct value for array with length === 1', () => {
    const result = calculateAverage([3]);
    expect(result).toEqual(3);
  });
});

Если наша функция корректна и тесты написаны верно, то мы увидим что все тесты прошли:

Untitled
Untitled
  1. Snapshopt-тесты

Проверяют внешний вид компонента (буквально снимок компонента/верстки)

Untitled
Untitled

Такие тесты могут нам просигнализировать, например, о том, что наш рефакторинг привел к изменению ожидаемого внешнего вида компонента.

  1. E2E-тесты

Проверяют работоспособность приложения, отдельного функционала или пользовательского сценария (например, регистрация пользователя или добавление товара в корзину).

Untitled
Untitled

Наиболее популярные библиотеки — Cypress или Puppeteer. Суть тестов заключается в том, чтобы проверить полноценный сценарий со стороны фронтенда.

Например, описание теста, который можно написать для проверки пользовательского сценария регистрации.

  1. Зайди на сайт без авторизации.

  2. В шапке должна быть кнопка “sign up”.

  3. Кликни по ней.

  4. Должна открыться форма.

  5. Заполни её.

  6. Дождись замоканного с сервера ответа об успехе.

  7. Должен произойти редирект в личный кабинет.

Есть ли у тестов минусы?

  • Тесты увеличивают время на разработку.

  • Их нужно поддерживать.

Но тесты повышают стабильность вашего кода. Написанные тесты уменьшают вероятность выстрелить себе в ногу.

Тесты — это хорошо. Пишите тесты.

Форматирование кода (линтеры, преттиры)

Prettier и Eslint могут сделать вашу жизнь лучше. Не пренебрегайте этими инструментами (даже если пишете маленький pet-проект).

Eslint — статически анализирует ваш код и находит проблемы или помогает не совершать ошибки.

Prettier — форматирует код, делая более приятным и читаемым.

Необязательно в самом начале погружаться в конфиги — достаточно воспользоваться базовыми. Например, вот отличная инструкция по настройке.

Настройка этих инструментов не занимает много времени, но делает ваш код приятнее как визуально, так и функционально, а также бьет вас по рукам, если вы хотите написать плохой код.

Организация кода и файлов проекта

Тут хотелось бы упомянуть несколько вещей.

  1. Количество строк в файлах

Удобно ли читать файл длиной выше 1000 строк? Глаза устают бегать вверх-вниз, пытаясь уловить происходящее. В статьях и учебниках встречается разная рекомендованная длина файлов — от 100 до 500 строк (максимальное количество). По мне, 100 строк — это хорошо (без учета импортов). Если число строк в файле превысило лимит — задумайтесь над декомпозицией.

  1. Структура приложения, или организация файлов

Можно скинуть все файлы проекта в одну в папку, можно разложить по папочкам. Как это лучше сделать? Есть ли стандарт?

Существуют разные подходы к организации файлов в проекте. Приведу наиболее популярные:

Для чего это? Стандартизация и упорядочивание. Вы можете познакомиться с указанными выше методологиями или прийти на проект, где используется существующая. Важно соблюдать организацию файлов.

  1. Алиасы

По мере разрастания проекта и его файловой структуры можно встретить такую картину:

import React, { FC } from 'react';
import fetchUsers from '../../../../../api/user';
import SomeButton from '../../../../../ui/SomeButton'; // ヾ(=`ω´=)ノ”
import mergeUsersWithMovies from '../../../../../utils';

Чтобы не плодить такие «гусеницы» импортов, можно настроить алиасы, которые будут вести к определенным папкам через сокращения, например:

import React, { FC } from 'react';
import fetchUsers from @api/user';
import SomeButton from '@ui/SomeButton';
import Wrapper from @utils';

Лаконичнее, да? Алиасы можно прописать в tsconfig.json:

"paths": {
  "@api": ["src/api"],
  "@ui": ["src/ui"],
  "@utils": ["src/utils"],
  "@": ["src"],
}
  1. Порядок импортов в файле.

Старайтесь соблюдать порядок импортов в проекте:

// Плохо
import fetchUsers from @apii/user';
import React, {useState} from 'react';
import Wrapper from './utils.ts'
import { Route, Routes } from 'react-router-dom';
import {StyledButton} from './styled.ts';

Рекомендованный порядок импортов:

  1. Сначала встроенные (главные) модули — если приложение на React, первым импортом будет идти React.

  2. Внешние импорты (модули и библиотеки из package.json).

  3. Внутренние абсолютные импорты (через алиасы).

  4. Внутренние относительные импорты.

И отделяйте такие группы импортов между собой:

// Хорошо
import React, { useState } from 'react';
import { Route, Routes } from 'react-router-dom';
import fetchUsers from @apii/user';
import { StyledButton } from './styled.ts';
import Wrapper from './utils.ts'

Также добавлю, что это необязательно форматировать руками, можно настроить правило в eslintsort-imports.

Выводы

Надеюсь, данные здесь советы помогут вам повысить качество вашего кода — писать более лаконичный, понятный другим разработчикам и поддерживаемый код, а также сократить число комментариев к вашим пул-реквестам.

Для более глубокого погружения в тему рекомендую почитать указанные выше учебники.

Всем приятного программирования!

Комментарии (12)


  1. ioncorpse
    12.12.2023 14:28

    Неплохой гайд для полного новичка. Но это всё уже было в Симпсонах. И на Хабре раз 10 в год точно.


  1. Gary_Ihar
    12.12.2023 14:28

    Не раскрыто про порядок импортов. Почему плохо забить на этот порядок и почему важно соблюдать?

    п.с. Сам на забитом отношусь к порядку этих самых импортов. Но во время ревью смотрю чтобы никто случайно не импортнул что-нибудь "не из того места"


    1. gun_dose
      12.12.2023 14:28

      Это надо, чтобы визуально отличать импорт внешних и внутренних зависимостей. Очень помогает при рефакторинге, когда надо перетащить в другую папку компонент, у которого штук 10 импортов, и нужно перебить все относительные пути.

      Вообще, в целом визуальная составляющая кода очень важна. Поэтому все линтеры и проверяют всякие пробелы, отступы, двойные/одинарные кавычки и т.д. Когда код следует этим правилам, что глаз очень легко выцепляет всякие ошибки. Если же всё в кучу, то визуальный ритм сбивается и для ревью или отладки приходится внимательно перечитывать каждую строчку кода.


      1. Gary_Ihar
        12.12.2023 14:28

        2-ой абзац согласен полностью.

        Очень помогает при рефакторинге, когда надо перетащить в другую папку компонент, у которого штук 10 импортов, и нужно перебить все относительные пути.

        Вроде уже все IDE помогает делать это автоматически

        Это надо, чтобы визуально отличать импорт внешних и внутренних зависимостей

        Как вы проводите черту между двумя этими типами? Пустой строкой?


        1. gun_dose
          12.12.2023 14:28

          Вроде уже все IDE помогает делать это автоматически

          Вроде да, но почему-то срабатывает не всегда. Наверное, надо делать через Refactor - Move File, а не просто перетаскивать мышкой по дереву папок.

          Как вы проводите черту между двумя этими типами? Пустой строкой?

          Раньше использовал пустую строку, сейчас никак не отделяю.


          1. Gary_Ihar
            12.12.2023 14:28

            сейчас никак не отделяю

            я бы сказал, что это плохо, что вы так делаете. То есть смотрите, изначально аргументом было, что визуально просто определить. Вот приду я к вам на проект и визуально смогу определить только то, что это набор импортов и пока не прочитаю каждый - не пойму что откуда и куда, а потом еще останется проследить, что оказывается это два типа.

            Клоню к тому, что если вы топите за то, чтобы отделять. То надо отделять каким-либо видимым(бросающимся в глаза) образом. Ведь визуально я сразу замечу условно "черту", что сразу вызовет вопросы. А вот если нет, ну вы поняли. На ревью только всплывет, что я намешал вам импортов


            1. gun_dose
              12.12.2023 14:28

              Обычно того факта, что глобальные в начале, а локальные в конце, достаточно. К слову, создатели eslint-config-airbnb, который я использую в своих проектах, придерживаются такой же точки зрения.


              1. Gary_Ihar
                12.12.2023 14:28

                видимо зря я относился к этому "на забитом". Целая наука оказывается


                1. gun_dose
                  12.12.2023 14:28

                  Да, но в этой науке очень много вкусовщины. Например, можно написать свой правило eslint для пустой строки между глобальными и локальными импортами и использовать его. Скорее всего, уже кто-то его создал, просто надо установить соответствующий пакет. В разных организациях и в разных проектах могут использоваться разные правила. Но вообще, на мой взгляд, если речь идёт о чистом коде, то использование автоматизированных инструментов проверки качества кода, таких как eslint должно стоять на первом месте.


    1. momomash Автор
      12.12.2023 14:28

      В том числе читаемость файлов повышается. Порядок импортов позволяет увидеть "позодрительные" вещи на ревью.

      Да, многое IDE делают (тот же Webstorm), но это не всегда срабатывает и может быть не настроено. Статья рассчитана на новичков, поэтому надежда что они это применят:)


    1. ZhetoN
      12.12.2023 14:28

      потому что мердж конфликты ресолвить... 10 удалено, 10 добавлено (в таком случае, кстати, проще всего взять лефт и райт и потом оптимизировать импорты), то же, кстати, относится к package.json.
      по поводу "не из того места" я бы порекомендовал NX, даже если там одно приложение (пока что) –https://nx.dev/core-features/enforce-module-boundaries (заодно избавит от проблемы " и нужно перебить все относительные пути" из следующего комментария), алсо, NX имеет правило для линтера как раз для разделения глобальные/3rd party/локальные импорты.


  1. samizdam
    12.12.2023 14:28

    Typescript — это типизируемый язык программирования, компилирующийся в Javascript.

    Если уж занудствовать, то занудствовать: ts - это типизированное надмножество javascript, транслируемое в js.