Прощай, плохой код: вот как не лажать в JavaScript
На JavaScript легко писать работающие решения, но легко и совершать ошибки. Рассказываем и показываем, как стать лучше в JavaScript.
JavaScript – это сила, с которой приходится считаться. Он является самым широко-используемым языком программирования в мире. Благодаря своей простоте и изобилию источников обучения он доступен для начинающих. Большой кадровый резерв привлекает к JavaScript компании всех размеров. Большая экосистема инструментов и библиотек повышают продуктивность разработчиков. Использование единого языка для фронтенда и бэкенда является огромным преимуществом, можно использовать одинаковый набор навыков во всём стеке.
В то же время JavaScript никак не ограничивает разработчиков. Дать его человеку без опыта – всё равно что дать спички двухлетнему ребёнку вместе с канистрой бензина.
На заметку начинающим
В этой статье обильно используются функции ES6. Убедитесь, что вы знакомы с ES6, прежде чем продолжить чтение. Давайте вспомним:
// --------------------------------------------- // лямбда (толстая стрелка) анонимная функция // --------------------------------------------- const doStuff = (a, b, c) => {...} // то же самое: function doStuff(a, b, c) { ... } // --------------------------------------------- // деструктуризация объекта // --------------------------------------------- const doStuff = ({a, b, c}) => { console.log(a); } // то же самое: const doStuff = (params) => { const {a, b, c} = params; console.log(a); } // то же самое: const doStuff = (params) => { console.log(params.a); } // --------------------------------------------- // деструктуризация массива // --------------------------------------------- const [a, b] = [1, 2]; // то же самое: const array = [1, 2]; const a = array[0]; const b = array[1];
Оснастка
Одно из достоинств JavaScript – обилие инструментов. Воспользуемся этим преимуществом на примере ESLint.
ESLint – утилита статического анализа кода и главный помощник в поиске потенциальных проблем, а также в поддержке высокого качества кода. Самое лучшее: линтинг – полностью автоматический процесс, который предотвращает проникновение низкокачественного кода в исходники.
Многие едва используют ESLint: они просто включают предустановленный конфиг типа eslint-config-airbnb и думают, что на этом можно закончить. Такой подход не раскрывает возможностей ESLint. В JavaScript нет ограничений. Неадекватная настройка линтинга имеет далеко идущие последствия.
Конечно, полезно знать все функции языка. Но опытный разработчик также знает, какие функции не стоит использовать. JavaScript – это старый язык с большим багажом, включающим всё. Важно отделять хорошие части от плохих.
Настройка ESLint
Решили воспользоваться советами из этой статьи? Тогда настройте ESLint следующим образом. Познакомьтесь со всеми подсказками поочерёдно и включайте правила в свой проект одно за другим. Настройте их изначально как warn
, позже вы сможете конвертировать некоторые правила в error
.
Запустите в корне вашего проекта:
npm i -D eslint npm i -D eslint-plugin-fp
Там же создайте файл .eslintrc.yml
:
env: es6: true plugins: fp rules: # здесь будут правила
Используете VSCode? Установите плагин ESLint.
Вы можете запускать ESLint вручную в командной строке:
npx eslint .
Самый большой источник сложности
Возможно, это прозвучит странно, но код сам по себе – самый большой источник сложности. По факту, лучший способ писать безопасный и стабильный софт – это не писать его. К сожалению или к счастью, это не всегда возможно. Поэтому второй лучший способ – снизить количество кода. Меньше кода – меньше сложность, вот так легко! Малое количество кода уменьшает вероятность возникновения багов. Говорят, что джуниор пишет код, а сеньор удаляет :)
Длинные функции
Давайте рассмотрим следующий отрывок кода express.js
, который обновляет запись в блоге:
router.put('/api/blog/posts/:id', (req, res) => { if (!req.body.title) { return res.status(400).json({ error: 'title is required', }); } if (!req.body.text) { return res.status(400).json({ error: 'text is required', }); } const postId = parseInt(req.params.id); let blogPost; let postIndex; blogPosts.forEach((post, i) => { if (post.id === postId) { blogPost = post; postIndex = i; } }); if (!blogPost) { return res.status(404).json({ error: 'post not found', }); } const updatedBlogPost = { id: postId, title: req.body.title, text: req.body.text }; blogPosts.splice(postIndex, 1, updatedBlogPost); return res.json({ updatedBlogPost, }); });
Тело функции длиной в 38 строк выполняет несколько действий: считывает id публикации, находит существующий пост в блоге, проверяет пользовательский ввод, возвращает ошибки в случае неверного ввода, обновляет коллекцию постов и возвращает обновлённые посты.
Определённо можно провести рефакторинг на несколько малых функций. Конечный обработчик маршрута будет выглядеть так:
router.put("/api/blog/posts/:id", (req, res) => { const { error: validationError } = validateInput(req.body); if (validationError) return errorResponse(res, validationError, 400); const { blogPost } = findBlogPost(blogPosts, req.params.id); const { error: postError } = validateBlogPost(blogPost); if (postError) return errorResponse(res, postError, 404); const updatedBlogPost = buildUpdatedBlogPost(req.body); updateBlogPosts(blogPosts, updatedBlogPost); return res.json({updatedBlogPost}); });
Рекомендованный конфиг ESLint:
rules: max-lines-per-function: - warn - 20
Сложные функции
Сложные функции идут рука об руку с длинными. Длинные функции всегда сложнее коротких. Что делает функции сложными? Из того, что можно легко пофиксить: вложенные обратные вызовы и высокая цикломатическая сложность.
Вот пример функции с глубоко вложенными обратными вызовами:
fs.readdir(source, function (err, files) { if (err) { console.error('Error finding files: ' + err) } else { files.forEach(function (filename, fileIndex) { gm(source + filename).size(function (err, values) { if (err) { console.error('Error identifying file size: ' + err) } else { aspect = (values.width / values.height) widths.forEach(function (width, widthIndex) { height = Math.round(width / aspect) this.resize(width, height).write(dest + 'w' + width + '_' + filename, function(err) { if (err) console.error('Error writing file: ' + err) }) }.bind(this)) } }) }) } })
Цикломатическая сложность
Очередной существенный источник сложности функций – цикломатическая сложность. Если коротко, она ссылается на количество операторов (логики) в любой данной функции. Имеются в виду оператор if
, циклы и оператор switch
. Такие функции тяжело понять, а их использование должно быть ограниченным. Пример:
if (conditionA) { if (conditionB) { while (conditionC) { if (conditionD && conditionE || conditionF) { ... } } } }
Рекомендованный конфиг ESLint:
rules: complexity: - warn - 5 max-nested-callbacks: - warn - 2 max-depth: - warn - 3
Есть ещё один способ уменьшить количество кода, а вместе с тем и его сложность. Подробнее о декларативном коде позже.
Изменчивое состояние
Состояние – это временные данные, хранящиеся в памяти. Это могут быть переменные или поля внутри объектов. Само по себе состояние неопасно. В то время как изменчивое состояние является одним из самых больших источников сложности софта, особенно в ООП.
Проблемы с изменчивым состоянием
Посмотрим на практике, как изменчивое состояние может сделать код проблемным:
const increasePrice = (item, increaseBy) => { // никогда не делайте этого item.price += increaseBy; return item; }; const oldItem = { price: 10 }; const newItem = increasePrice(oldItem, 3); // выводит newItem.price 13 console.log('newItem.price', newItem.price); // выводит oldItem.price 13 // неожиданно?? console.log('oldItem.price', oldItem.price);
Этот баг коварен, но изменяя аргументы функции, мы случайно изменили цену исходного элемента. Он должен был остаться равным 10, но в реальности поменялся на 13!
Как избежать таких проблем? Созданием и возвратом нового объекта:
const increasePrice = (item, increaseBy) => ({ ...item, price: item.price + increaseBy }); const oldItem = { price: 10 }; const newItem = increasePrice(oldItem, 3); // выводит newItem.price 13 console.log('newItem.price', newItem.price); // выводит oldItem.price 10 // как и ожидалось! console.log('oldItem.price', oldItem.price);
Рекомендованный конфиг ESLint:
rules: fp/no-mutation: warn no-param-reassign: warn
Не используйте метод push с массивами
Те же проблемы свойственны в изменении массивов при использовании таких методов, как push
:
const a = ['apple', 'orange']; const b = a; a.push('microsoft') // ['apple', 'orange', 'microsoft'] console.log(a); // ['apple', 'orange', 'microsoft'] // неожиданно? console.log(b);
Кажется, вы ожидали, что массив b
не изменится? Эту ошибку можно обойти, создав новый массив вместо вызова push
.
Подобные ошибки легко предотвратить созданием нового массива:
const newArray = [...a, 'microsoft'];
Рекомендованный конфиг ESLint:
rules: fp/no-mutating-assign: warn fp/no-mutating-methods: warn fp/no-mutation: warn
Избегайте использования let
Да, var
никогда не используется для объявления переменных в JavaScript. Этим никого не удивишь. Тем не менее, вы можете удивиться, узнав, что нужно избегать использования ключевого слова let
. Переменные, объявленные с помощью let
, можно переназначить, что затруднит понимание кода. Программируя с ключевым словом let
, мы вынуждены держать в уме все возможные побочные эффекты. Можно случайно назначить неправильное значение переменной и тратить время на отладку.
Так каковы альтернативы let
? Кончено же const
! Хотя оно не гарантирует неизменность, оно улучшает читаемость кода, запрещая переназначения. И, честно говоря, вам не нужен let
– в большинстве случаев код, который переназначает переменные можно вынести в отдельную функцию. Давайте посмотрим пример:
let discount; if (isLoggedIn) { if (cartTotal > 100 && !isFriday) { discount = 30; } else if (!isValuedCustomer) { discount = 20; } else { discount = 10; } } else { discount = 0; }
И тот же пример, извлечённый в функцию:
const getDiscount = ({isLoggedIn, cartTotal, isValuedCustomer}) => { if (!isLoggedIn) { return 0; } if (cartTotal > 100 && !isFriday()) { return 30; } if (!isValuedCustomer) { return 20; } return 10; }
Рекомендованный конфиг ESLint:
rules: fp/no-let: warn
Декларативный код
Если вы программировали некоторое время, скорее всего, вы использовали императивный стиль, который описывает набор определённых шагов, ведущих к желаемому результату. Декларативный стиль, напротив, описывает желаемый результат, а не определённые инструкции.
Примеры распространённых декларативных языков – это SQL и HTML. И даже JSX в React!
Мы не говорим базе данных, как получить данные, и не указываем конкретные шаги. Вместо этого мы используем SQL, чтобы описать то, что нам нужно:
SELECT * FROM Users WHERE Country='USA';
Это можно представить грубо в императивном JavaScript:
let user = null; for (const u of users) { if (u.country === 'USA') { user = u; break; } }
Или в декларативном JavaScript, используя экспериментальный оператор конвейера:
import { filter, first } from 'lodash/fp'; const filterByCountry = country => filter( user => user.country === country ); const user = users |> filterByCountry('USA') |> first;
Какой способ вы бы предпочли?
Предпочитайте выражения операторам
Если наша цель – писать декларативный код, выражения должны быть предпочтительней операторов. Выражения всегда возвращают значение, в то время как операторы используются для выполнения действия и не возвращают результат. В функциональном программировании это называется «побочным эффектом». Кстати, изменчивое состояние, описанное ранее, – тоже побочный эффект.
Какие операторы обычно используются? Считайте таковыми if
, return
, switch
, for
, while
.
Давайте посмотрим на простой пример:
const calculateStuff = input => { if (input.x) { return superCalculator(input.x); } return dumbCalculator(input.y); };
Это можно легко переписать в троичное выражение (которое декларативно):
const calculateStuff = input => { return input.x ? superCalculator(input.x) : dumbCalculator(input.y); };
И если в лямбда-функции содержится только оператор возврата, JavaScript позволяет нам избавиться от лямбда-оператора:
const calculateStuff = input => input.x ? superCalculator(input.x) : dumbCalculator(input.y);
Тело функции сократилось с шести строчек кода до одной-единственной. Супермощь декларативного кода!
Декларативное программирование требует усилий
Декларативному программированию нельзя научиться за ночь. Особенно учитывая то, что большинство людей в основном обучались императивному программированию. Декларативное программирование требует дисциплины и умения мыслить совершенно по-новому. Как научиться декларативному программированию? Первым делом научиться программировать без изменчивого состояния – не использовать ключевое слово let
и не изменять состояние.
Рекомендованный конфиг ESLint:
rules: fp/no-let: warn fp/no-loops: warn fp/no-mutating-assign: warn fp/no-mutating-methods: warn fp/no-mutation: warn fp/no-delete: warn
Избегайте передачи множества параметров функциям
JavaScript – это не статически типизированный язык, и в нём нет гарантированного вызова функции с правильными и ожидаемыми параметрами. ES6 вносит много классных функций, включая деструктуризацию объектов, которую можно использовать для аргументов функции.
Вам понятен следующий код? Вы можете сразу сказать, каковы параметры?
const total = computeShoppingCartTotal(itemList, 10.0, 'USD');
А как насчёт этого примера?
const computeShoppingCartTotal = ({ itemList, discount, currency }) => {...}; const total = computeShoppingCartTotal({ itemList, discount: 10.0, currency: 'USD' });
Последний пример удобней для чтения. Это особенно касается вызовов функций из другого модуля. При использовании объекта в качестве аргумента порядок аргументов не имеет значения.
Рекомендованный конфиг ESLint:
rules: max-params: - warn - 2
Предпочитайте возврат объектов из функций
Как много следующий отрывок говорит вам о сигнатуре функции? Что она возвращает? Она возвращает объект пользователя, id пользователя, статус операции? Сложно понять без окружающего контекста.
const result = saveUser(...);
Возврат объекта из функции ясно отражает намерения разработчика, а код становится более читаемым:
const { user, status } = saveUser(...); ... const saveUser = user => { ... return { user: savedUser, status: "ok" }; };
Исключения для контроля выполнения
Вам понравится Internal Server Error 500 при попытке неверного ввода в форму? Как насчёт работы с API, которое не даёт никаких деталей и выводит только эту ошибку? Наверняка с подобной проблемой сталкивались все, и это нельзя назвать приятным опытом.
Нас также учили генерировать исключения, когда происходит что-то неожиданное. Это не лучший способ обработки ошибок. Давайте разбираться, почему.
Исключения ломают безопасность типов
Даже в статически типизированных языках. В соответствии со своей сигнатурой функция fetchUser(id: number): User
должна возвращать пользователя. В сигнатуре нет и намёка на исключение в случае, если пользователь не найден. Если ожидается исключение, то более подходящей сигнатурой будет: fetchUser(...): User|throws UserNotFoundError
. Конечно, такой синтаксис неверен вне зависимости от языка.
Сложно понять программу с исключениями – можно никогда не узнать, будет ли функция генерировать исключение. Да, можно обернуть каждый отдельный вызов функции в блок try/catch, но это непрактично и снизит читаемость кода.
Исключения ломают композицию функции
Исключения делают виртуально невозможным использование композиции функции. В следующем примере сервер возвращает 500 Internal Error, если один из постов блога не может быть найден:
const fetchBlogPost = id => { const post = api.fetch(`/api/post/${id}`); if (!post) throw new Error(`Post with id ${id} not found`); return post; }; const html = postIds |> map(fetchBlogPost) |> renderHTMLTemplate;
Что, если один из постов был удалён, но пользователь всё ещё пытается получить доступ к посту из-за неизвестного бага? Это существенно снизит user experience.
Кортежи как альтернативный способ обработки ошибок
Не вдаваясь в подробности функционального программирования: простой способ обработки ошибок – это возврат кортежа, содержащего результат и ошибку вместо генерации исключения. Да, JavaScript не поддерживает кортежи, но их легко эмулировать с помощью двузначного массива в форме [error, result]
. Кстати, это хороший способ обработки ошибок в Go:
const fetchBlogPost = id => { const post = api.fetch(`/api/post/${id}`); return post // null для ошибки, если пост был найден ? [null, post] // null для результата, если пост был найден : [`Post with id ${id} not found`, null]; }; const blogPosts = postIds |> map(fetchBlogPost); const errors = blogPosts |> filter(([err]) => !!err) // хранить только элементы с ошибками |> map(([err]) => err); // деструктуризировать кортеж и вернуть ошибку const html = blogPosts |> filter(([err]) => !err) // хранить только элементы без ошибок |> map(([_, result]) => result) // деструктуризировать кортеж и вернуть результат |> renderHTML;
Иногда исключения – это нормально
Исключения до сих пор имеют своё место в коде. Как правило, вы должны задать себе один вопрос: хочу ли я, чтобы программа упала? Любое сгенерированное исключение может сорвать весь процесс. Даже если мы тщательно продумали все потенциальные крайние случаи, исключения всё ещё небезопасны, и вызовут падение программы когда-нибудь в будущем. Генерируйте исключения, только когда вы действительно намерены «уронить» программу, например, из-за ошибки разработчика, или неудавшегося подключения к базе данных.
Исключения названы исключениями не случайно. Их нужно использовать, только когда случается что-то исключительное, и у программы не остаётся другого выбора кроме как упасть. Генерация и отлавливание исключений – не самый лучший способ контролировать исполнение. Мы должны прибегать к генерированию исключений только в том случае, если произошла неисправимая ошибка. Неправильный пользовательский ввод, к примеру, к таковым не относится.
Что дальше?
Вам действительно нужен стабильный код? Решать вам. Ваша организация приравнивает продуктивность разработчиков к числу завершённых историй в Jira? Вы работаете на «фабрике идей», которая не ценит ничего, кроме количества нововведений? Надеемся нет. Но если это так, подумайте о лучшем месте работы...
Возможно, применять всё сразу из этой статьи не стоит. Добавляйте статью в закладки и возвращайтесь. Каждый раз выбирайте одну вещь, на которой вы намеренно сосредоточитесь. И включите соответствующие правила ESLint – главного помощника в вашем путешествии.