Всем привет! Меня зовут Соня Гусева, я фронтенд-разработчик в Яндекс Практикуме (или фронтенд-капибара). Вместе с командой мы развиваем платформу practicum.yandex.ru. Например, сделали поиск по пройденным материалам — тот самый, где «найдётся всё». И тёмную тему — для комфортной учёбы даже ночью.

Практикум появился в 2019 году и с тех пор растёт. В какой-то момент нам стало сложно погружать новичков в проект. Дело в том, что стиль кода всё время развивался, но правила оставались на уровне устных договорённостей. В итоге приходили новые тиммейты, видели легаси и более свежий код — и не понимали, какой написан правильно и почему они разные. Как следствие, код-ревью растягивалось, и тестирование проходило в разы сложнее. Люди чувствовали себя неуютно в таких процессах. 

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

Если вы любите больше смотреть, а не читать, с этой темой я выступила на Frontend meetup, где рассказала подробно о внедрении стиля кода в проекты. 

Как гайд по стилю кода улучшает процессы 

Упрощает онбординг сотрудников 

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

Например, в одном месте видит типы, а в другом — интерфейсы:

Или видит иногда padding с использованием переменной spacing, а в других случаях — без неё: 

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

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

Ускоряет код-ревью

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

Комментарии по стилю тянут за собой долгие обсуждения предложений. Вместо того чтобы сфокусироваться на архитектуре проекта или обработке ошибок, сотрудники больше спорят о том, «как надо» оформлять код, — и разработка затягивается.

Гайд сокращает количество споров, потому что каждое правило в нём проходит согласования с командой. Если правило зафиксировано, значит, вы по конкретному вопросу договорились и спорить об этом уже нет смысла. Можно фокусироваться на важном.

Упрощает тестирование кода 

С гайдом проверку стиля кода можно автоматизировать. И это хорошо, ведь правил обычно довольно много, запомнить их смогут в лучшем случае 3—5 человек. 

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

Самое простое тестирование в кубке — статическое, его цель — подсвечивать опечатки и ошибки ввода по мере написания кода, в том числе стилистические.

Проводить статическое тестирование можно с помощью анализатора кода ESLint или инструмента для линтинга стилей Stylelint. Дальше я расскажу, как их настроить и всё автоматизировать. 

Как создать гайд по стилю кода

Самый простой способ — добавить в проект готовый конфиг и адаптировать его под свой проект. Есть два таких конфига — Google Style Guide и Airbnb Style Guide. Вы можете просмотреть набор правил, которые они содержат, и выбрать более подходящий лично вам. 

Разумеется, не все правила вам могут подойти, а некоторых, которые вам нужны, в готовом решении не будет. Поэтому на старте вы можете отключить или изменить те правила, которые вам изначально не подходят, а дальше по мере работы добавлять свои.  В Практикуме мы в итоге остановились на Airbnb Style Guide, поэтому именно про него я и расскажу. 

Шаг 1. Внедряем готовый Airbnb Style Guide

Устанавливаем конфиг, прогоняем код

Вероятно, после первого прогона кода линтеры выявят множество ошибок. И вы начнёте обсуждать, какие правила оставите, какие отмените, какие измените.

Когда ошибок 20–50, их можно быстро поправить вручную. Но если несколько тысяч, это уже совсем другая история.

Что делать, если первый прогон выдал 1 000+ ошибок:

  • Первый вариант — настроить прогон линтеров не на все файлы, а на изменённые в пул-реквесте, так при изменении файла появляются старые ошибки. 

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

Мы у себя включили в пул-реквесте прохождение линтеров в ci, поэтому,  пока разработчик не поправит всё, он не сможет влить свой код в репозиторий. Однако это не очень удобно, потому что поначалу придётся больше времени тратить на исправление старого кода, чем на доставление пользователям какой-либо фичи.

  • Второй вариант — создать скрипт, расставляющий в коде команду eslint-disable-next-line (отключение проверок отдельных строк). Преимущества этого подхода в том, что не нужно исправлять старый код при пул-реквесте. Разработчики могут сами решать, когда они исправят ту или иную часть кода.

Ниже подробнее поговорим о втором варианте решения проблемы.

Отключаем проверку отдельных строк

1. Создаём скрипт для добавления eslint-disable-next-line

Первым делом инициализируем eslint, прогоняем через lintFiles по всему проекту и записываем результаты в переменную results.

(async function main() {
    const eslint = new ESLint();
    const results = await eslint.lintFiles(['*/**/*.{js,jsx,ts,tsx}']);

}());

Вот что у нас оказалось в переменной results:

{
    "filePath": '',
    "messages": [
      {
        "ruleId": '@typescript-eslint/naming-convention',
        "severity": 2,
        "message": 'Enum name `BLOCK_TYPES` must match one of the following formats: PascalCase',
        "line": 26,
        "column": 6,
        "nodeType": 'Identifier',
        "messageId": 'doesNotMatchFormat',
        "endLine": 26,
        "endColumn": 18
      }
    ],
    "errorCount": 6,
    "fatalErrorCount": 0,
    "warningCount": 0,
    "fixableErrorCount": 0,
    "fixableWarningCount": 0,
    "source": '',
    "usedDeprecatedRules": [Getter]
 }

В этой переменной содержится массив таких объектов, и из всего этого нужно только:

  • filePath — путь до файла,

  • messages — массив сообщений, где мы смотрим на строки ruleId (какое правило нарушено) и line (номер строки, где была ошибка).

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

(async function main() {
    const eslint = new ESLint();
    const results = await eslint.lintFiles(['*/**/*.{js,jsx,ts,tsx}']);

    results.forEach((result) => {
        if (!result.messages.length) {
            return;
        }
    });
}());

Если же ошибки нашлись, то проходимся по уникальным строкам, создаём массив сообщений и join'им через запятую с пробелом. Так же тут есть переменная index — она указывает, какое количество комментариев уже вставили, чтобы правильно вставить следующий. 

Создаём комментарий eslint-disable-next-line, туда вставляем строку из правил и с помощью функции splice вставляем комментарий в файл:

(async function main() {
    const eslint = new ESLint();
    const results = await eslint.lintFiles(['*/**/*.{js,jsx,ts,tsx}']);

    results.forEach((result) => {
        if (!result.messages.length) {
            return;
        }

		const lines = result.messages.map((message) => message.line);
		const uniqueLines = new Set(lines);
		const data = fs.readFileSync(result.filePath).toString().split('\n');

		let index = 0;

		for (const line of uniqueLines) {
    		const messagesLine = result.messages.filter((message) => message.line === line).join(', ');
			const comment = `/* eslint-disable-next-line ${messagesLine} */`;
			data.splice(messagesLine[0].line + index - 1, 0, comment);

			index += 1;
		}

		fs.appendFileSync(result.filePath, data.join('\n'), { flag: 'w' });
    });
}());

Какие в описанном способе могут появиться подводные камни:

  1. Комментарии в языке JSX пишутся в формате {/* {правило} */}. Тут надо учитывать, что если линтер среагировал на атрибуты, то комментарий будет обычным — и код не будет автоматически исправляться по обычному правилу. 

  2. При отключении или изменении правила надо не забыть удалить из кода комментарии, которые потеряли актуальность, чтобы не копился техдолг.

Давайте посмотрим, как можно решить эти проблемы.

2. Переписываем комментарии в JSX

Как и в предыдущем случае, создаём функцию eslint, результаты проверки линтерами записываем в переменную results и проходимся по результатам:

const eslint = new ESLint();
const results = await eslint.lintFiles(['*/**/*.{js,jsx,ts,tsx}']);

results.forEach((result) => {    
    const data = fs.readFileSync(result.filePath).toString().split('\n');
    
    result.messages.forEach((message) => {
        if(message.ruleId?.includes('jsx-no-comment-textnodes')){
            data[message.line] = data[message.line].replace('/*', '{/*').replace('*/', '*/}');
        }
    });

    fs.appendFileSync(result.filePath, data.join('\n'), { flag: 'w' });
});

Если в сообщении встречается ошибка jsx-no-comment-textnodes, меняем на комментарии нужного нам вида.

3. Удаляем из кода лишние комментарии

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

const eslint = new ESLint();
const results = await eslint.lintFiles(['*/**/*.{js,jsx,ts,tsx}']);

results.forEach((result) => {    
	const data = fs.readFileSync(result.filePath).toString().split('\n');
    const rulesDelete = ['import/no-extraneous-dependencies', 'import/no-unresolved'];
	const deleteComments = data.filter((line) => 
                      rulesDelete.filter((rule) => line.includes(rule)).length);

	deleteComments.forEach((str) => {
	     data.splice(data.indexOf(str), 1)
	});

    fs.appendFileSync(result.filePath, data.join('\n'), { flag: 'w' });
});

Итак, Airbnb Style Guide подключён и настроен. Но нередко бывает так, что инструменты не покрывают все случаи вашего кода. В таком случае нужно создавать собственные правила в дополнение к плагинам. 

Шаг 2. Добавляем свои правила в гайд

Если в готовом гайде нет правил, которые вам нужны, создать свои их можно с помощью анализатора кода ESLint или инструмента для линтинга стилей Stylelint. Я расскажу про оба.

Вариант 1. Пишем кастомные правила в ESLint

Важно отметить, что ESLint использует абстрактное синтаксическое дерево (AST). Увидеть его позволяет сайт astexplorer.net, на нём можно вставить свой код — и посмотреть AST. 

Сначала покажу, как в ESLint написать простое правило, а затем более сложное. Первый вариант мы напишем в конфиге ESLint, второй — с помощью отдельного плагина.

Простое правило без автофикса в ESLint

Такое правило можно написать в конфиге ESLint. Для этого понадобится функция no-restricted-syntax.

"no-restricted-syntax": [
    "error",
    {
        selector: "",
        message: ""
    }
]

Сначала пишем тип ошибки, дальше в selector само правило, а в message — текст ошибки.

Для примера возьмём правило, когда в файле компонента есть несколько интерфейсов и где-то внизу компонент. 

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

Перейдём к написанию самого правила:

"no-restricted-syntax": [
    "error",
    {
        selector: "TSInterfaceDeclaration ~ TSInterfaceDeclaration",
        message: "Кол-во интерфейсов больше одного, перенесите в отдельный файл"
    }
]

Чтобы увидеть AST, я вставила свой код на сайт astexplorer.net, о котором писала в начале пункта. Сайт предлагает для интерфейсов тип TSInterfaceDeclaration, его можно взять для правила. В самом правиле так и напишем: если с одним интерфейсом такого типа встретился другой такого же типа → сообщи об ошибке.

Если вы захотите изучить тему глубже, можете заглянуть на сайт no-restricted-syntax. 

Сложное правило с автофиксом в ESLint

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

1. Создаём плагин 

У вас есть папка для плагина, в ней есть package.json, файл index.js и папка rules, где будут храниться все кастомные правила.

Файл package.json для плагина выглядит стандартно:

// package.json плагина

{
  "name": "eslint-plugin-custom",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "directories": {
    "test": "tests"
  },
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

В файле index.js есть функция getDirFiles, которая получает все названия файлов в виде массива. Дальше экспортируем объект правил, где ключ — это название файла, значение — содержимое правила.

// index.js

const getDirFiles = (dir) => {
   const files = fs.readdirSync(dir);
   return files.map(file => path.basename(file, path.extname(file)));
};

const rules = getDirFiles(`${__dirname}/rules/`);

module.exports = {
    rules: rules.reduce(
        (acc, ruleName) => ({
            ...acc,
            [ruleName]: require(`./rules/${ruleName}`),
        }),
        {}
    ),
};

В package.json проекта записываете название вашего плагина и путь к нему, устанавливаете его:

// package.json проекта
"eslint-plugin-custom": "file:./linter-rules/eslint-plugin-custom",

В eslintrc.js: пишете название плагина и дальше, если у вас есть готовое правило, можете поместить его туда и использовать, как вам нужно.

// .eslintrc.js

module.exports = {
  env: {
    browser: true,
    node: true,
  },
  plugins: ['custom', '@typescript-eslint'],
  parser: '@typescript-eslint/parser',
  rules: {
    'custom/name-interface-const-component': 'warn',
  },
};

2. Пишем правило в плагине

Код будет выглядеть так:

module.exports = {
    meta: {
        type: 'problem',
        docs: {
            description: '',
        },
		fixable: "code",
    },
    create: () => {
        return {
            TSInterfaceDeclaration(node) {
                const { parent } = node;

                if (parent?.type !== 'ExportNamedDeclaration') {
                    context.report({
                        node,
                        message: 'Экспортируйте интерфейс пропсов',
                        fix: function (fixer) {
                            return fixer.insertTextBeforeRange(node.range, 'export ');
                        },
                    });
                }
			}
        };
    },
};

Сначала пишем метаинформацию (тип, описание и правило с автофиксом или без), далее идёт функция create.

В качестве примера напишем правило, требующее экспортировать интерфейс пропсов (один из пунктов нашего гайда). Здесь используем тип TSInterfaceDeclaration. Если тип родительского элемента не ExportNamedDeclaration, тогда с помощью context.report сообщим об ошибке.

В context.report есть функция fix с параметром fixer. У него есть несколько методов: «вставить перед», «вставить после», «удалить», «заменить». Мы будем использовать insertTextBeforeRange, для этого определяем позицию интерфейса и вставляем экспорт.

Как проверить, что линтер отрабатывает нормально и ничего не ломает?

3. Пишем автотесты

В ESLint есть возможность писать автотесты. Для этого нам потребуется функция rule tester. В коде указываем название правила, его содержимое и текст, сообщающий об ошибке. В rule tester нужно указать парсер для TypeScript. Дальше с помощью run вызываем тест, указывая название правила и его содержимое. В объекте есть массив валидных и невалидных тест-кейсов.

const RuleTester = require('eslint').RuleTester;
const ruleName = 'export-interface';
const rule = require(`../rules/${ruleName}`);
const errorMessage = 'Экспортируйте интерфейс пропсов';

const ruleTester = new RuleTester({
    parser: require.resolve('@typescript-eslint/parser'),
});

ruleTester.run(ruleName, rule, {
    valid: [
        {
            code: `export interface ComponentProps {}`,
        },
    ],
    invalid: [
        {
            code: `interface ComponentProps {}`,
            output: `export interface ComponentProps {}`,
            errors: [{ message: errorMessage }],
        },
    ],
});

В нашем случае валидные — это export interface ComponentProps, невалидные записываются в такой же массив, только без экспорта. Ещё нужно указать, что должно произойти после автофикса.

Запускаем автотесты через node, указывая путь до файла. Можно сделать отдельный файл, который перебирает все остальные тест-файлы.

Итак, это всё, что нужно знать про кастомные правила в ESLint, чтобы начать их писать. А если вы хотите углубиться в тему, можете почитать больше о селекторах

Вариант 2. Пишем кастомные правила в Stylelint

Плагин в Stylelint создаётся примерно так же, как и в ESLint, поэтому сразу перейдём к написанию собственно правила. 

В примере выше margin и padding в одном файле записаны через переменные, в другом margin через переменную, а padding — уже числом, а в третьем файле есть и первый, и второй вариант. Такое странно отдавать на код-ревью, поэтому напишем правило. 

const ruleName = 'custom/spacing-rule';

module.exports = function rule(expectation, options, context) {
    return (root, result) => {
        const validOptions = utils.validateOptions(
            result,
            ruleName,
        );

        if (!validOptions) {
            return;
        }

        root.walkDecls((decl) => {
			...           
        });
    };
};

В коде, приведённом выше, сначала записываем название правила в переменную в ruleName, потом создаём правило и в нём указываем, что нужно проверить параметры, которые ввёл пользователь в конфиге. Утилиты находятся в самом Stylelint. Затем с помощью walkDecls мы перемещаемся по строкам в CSS. В переменной decl CSS отдаётся по строкам.

const key = decl.prop;
const value = decl.value;

const hasMarginSpacing = key.includes('margin') && value.includes('var(--spacing');
const hasPaddingNoSpacing = key.includes('padding') && !value.includes('var(--spacing');
const hasMarginOrPadding = key.includes('padding') || value.includes('margin');

if ((hasMarginSpacing || hasPaddingNoSpacing) || !hasMarginOrPadding) {
    return;
}

if (context.fix && key.includes('padding')) {
    decl.value = value.replace(/var\(--spacing-\d{1,3}\)/g, (match) => {
        return `${match.replace(/\D/g, '')}px`;
    });
} else {
    utils.report({
        message: messages.expected(key),
        node: decl,
        result,
        ruleName,
    });
}

Для удобства я создала переменные key и value. Указываем условия, где margin написан через spacing, padding не через переменную. Если всё ок, идём к следующей строке. Если нет, тогда для padding через регулярное выражение присваиваем новое значение в пикселях. Для margin можем сообщить об ошибке через utils.report.

const messages = utils.ruleMessages(ruleName, {
    expected: (prop) => `Измените значение ${prop} на spacing`,
});

Сообщения создаются через utils.ruleMessages. Туда передаём названия правила и текст ошибки. Всё, правило готово. 

Итак, по этим инструкциям вы можете дополнять свой гайд каждый раз, как появляется повод для нового правила. Рекомендую не относиться к документу как к готовому и законченному раз и навсегда, а регулярно выносить на обсуждения какие-то изменения и обновлять его. Хоть договориться и не всегда просто.

История создания стайлгайда Практикума

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

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

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

В один из вечеров мы с тимлидом написали наш стайлгайд. За основу взяли наработки одного из стажёров Практикума. Когда всё было готово, вынесли на общее обсуждение и согласовали.

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

Итоги

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

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

P.S. Не занимайтесь на код-ревью стилем, занимайтесь самим кодом ????

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


  1. slavaRomantsov
    09.10.2023 13:01
    +1

    Привет! Спасибо за материал! Подскажи есть ли идеи как выторговать время на правку 1000+ ошибок во время спринта при внедрении стайл гайда?


    1. sonya_guseva Автор
      09.10.2023 13:01
      +1

      Привет!
      Торговаться не нужно и не обязательно сразу править всё, в статье описала два способа
      1) Настроить прогон линтеров по измененным файлам, это уменьшит время прогона линтеров локально и в ci. Уже по мере написания кода разработчик будет править ошибки в файлах и легче править понемногу, чем сразу всё (а некоторые файлы возможно не нужно править, которые не меняются)
      2) Автоматически расставить eslint-disable-next-line и уже если будет код переписываться, то сразу перейдет на новый стиль кода

      Важно уметь договариваться в команде, так как качество кода влияет сразу на всех.


  1. Cere8ellum
    09.10.2023 13:01

    Позанудствую. У Вас в примерах интерфейсы именуются не с буквы I, а типы не с буквы T :)


  1. zhekaal
    09.10.2023 13:01

    Я бы отметил что все это хорошо, но тоже "не бесплатно". Вы добавили несколько файлов с кодом, который надо поддерживать и который зависит еще от пары сторонних библиотек. Он может сломать ci в самый неподходящий момент.