Советы для разработчика интерфейсов

4790

Советы для разработчика интерфейсов

Советы для разработчика интерфейсов
Советы для разработчика интерфейсов

Ссылки с target=»_blank»

Добавляйте к ссылкам с target="_blank" атрибут rel="noopener noreferrer", чтобы открытая вкладка (или окно) браузера не имели доступ к родительской вкладке (это нарушает безопасность). Ссылка на глобальный объект родительской вкладки лежит в window.openernoopener в атрибуте rel принудительно обнуляет значение window.opener для открываемых вкладок; это работает с 49 версии Хрома и 36 версии Оперы. Для более старых браузеров нужно добавлять noreferrer — это тоже обнуляет window.opener, но также предотвращает отправку заголовка Referer (это может негативно сказаться на сборе статистики переходов). Подробное объяснение и демо.

Кроме того, согласно исследованию Джейка Арчибальда, отсутствие rel="noopener" в некоторых случаях плохо сказывается на производительности.

aria-label

Используйте атрибут aria-label для описания элементов интерфейса, которые не получается описать с помощью обычного <label>.

<button aria-label='Закрыть окно'>✕</button>

Именование переменных и функций

Явное лучше неявного. Не нужно использовать сокращения для имён, даже если кажется, что эти сокращения общеизвестны. Другой человек, возможно, раньше с ними не сталкивался. Также сокращения вынуждают держать в голове контекст их использования:

console.log(e); // что такое e?

// Плохо, сокращение требует контекста:
document.addEventListener('click', (e) => {
  console.log(e);
});

 try {} catch (e) {
   console.log(e);
 }

 // Хорошо, явное имя
document.addEventListener('click', (event) => {
  console.log(event);
})

try {} catch (error) {
  console.log(error);
}

Имена предикатов должны быть вопросами, на которые можно ответить «да» или «нет». Если вопрос сам по себе не является глаголом (например, includes, equals), имя предиката должно начинаться с is, are, has, can и т. п.:

// плохо
const authorisation = Boolean(state.currentUser);
const allowPublishing = state.currentUser.permissions.includes('publishing');
const strictEquality = (a) => (b) => a === b;

// хорошо
const isAuthorised = Boolean(state.currentUser);
const canPublish = state.currentUser.permissions.includes('publishing');
const hasFriends = state.currentUser.friends.length > 0;
const strictEquals = (a) => (b) => a === b;

Переменные

Имя переменной должно описывать суть хранимого значения и начинаться с существительного (или прилагательного, а затем существительного):

// плохо, начинается с глагола
const getUserName = 'andrew-r';

// хорошо
const userName = 'andrew-r';
const currentPageNumber = 1;

Функции

Имя функции должно начинаться с глагола:

// плохо
function nextPageNumber() {}

// хорошо
function getNextPageNumber() {}

Имя функции должно описывать не момент вызова, а совершаемое действие:

// плохо
function onClick() {}

// хорошо
function goToNextPage() {}

Если функция возвращает или устанавливает какое-либо значение, её имя должно начиться с get или, соответственно, set:

// плохо
function userAge() {}
function userName(name) {}

// хорошо
function getUserAge() {}
function setUserName(name) {}

Объявление параметров функций

Обязательные параметры функции должны перечисляться в первую очередь. Опциональные параметры следует объединить в один параметр options, следующий в конце списка параметров:

// плохо
function initTabs(rootNode, defaultTabIndex = 0, trackEvents = false) {}

// непонятно, что означают последние два параметра,
// если не посмотреть на исходное объявление функции
initTabs(document.querySelector('.js-tabs'), 1, true);

// хорошо
function initTabs(rootNode, options) {
  const { defaultTabIndex = 0, trackEvents = false } = options;
}

// понятно, за что отвечает каждый параметр
initTabs(document.querySelector('.js-tabs'), {
  defaultTabIndex: 0,
  trackEvnets: false,
});

Работа с потенциально несуществующими данными

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

Велик соблазн начать вручную проверять существование каждого уровня вложенности:

let loadedComments = null;

fetch('...')
  .then((response) => response.json())
  .then((response) => {
    if (response.result && response.result.user && response.result.user.comments) {
      loadedComments = response.result.user.comments;
    } else {
      loadedComments = [];
    }
  });

Такой способ работает, но в нём много дублирования кода и в целом он многословный.

Более лаконичная альтернатива без дублирования кода:

let loadedComments = null;

fetch('...')
  .then((response) => response.json())
  .then((response) => {
    loadedComments = ((response.result || {}).user || {}).comments || [];
  });

Хранение утилитарных функций в проекте

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

Плохо:

.
├── utils.js
└── utils.spec.js

Хорошо:

utils
├── chunk
│   ├── chunk.js
│   └── chunk.spec.js
├── flat-map
│   ├── flat-map.js
│   └── flat-map.spec.js
└── pluck
    ├── pluck.js
    └── pluck.spec.js

В случае с одним файлом utils.js можно импортировать несколько функций одновременно:

import { chunk, pluck } from '../utils.js';

Чтобы сохранить такую возможность после разбиения utils.js на отдельные файлы, добавьте в директорию utils файл index.js и экспортируйте из него все хелперы:

import chunk from './chunk/chunk';
import pluck from './pluck/pluck';

export {
	chunk,
	pluck
};

С помощью babel-плагина export extensions можно написать то же самое лаконичнее:

export { default as chunk } from './chunk/chunk';
export { default as pluck } from './pluck/pluck';

В результате можно будет писать импорты вида import { chunk } from '../utils';.

Когда следует дублировать код?

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

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

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

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

Доступные уведомления

Иногда требуется показать пользователю уведомление: об успешной отправке формы, об ошибке или о чём-либо ещё. Доступные уведомления соответствуют нескольким простым критериям.

Не блокируют работу со страницей

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

Не скрываются по истечении некоторого времени

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

Автоматическое скрытие уведомлений по таймеру нарушает пункт 2.2.3 WCAG.

Располагаются в контейнере с атрибутом role="alert"

Ассистивные технологии вроде электронных читалок отслеживают изменение содержимого элементов с role="alert". При изменениях они немедленно озвучивают новое содержимое, даже если они в этот момент озвучивали другую часть страницы.

Контейнер с role="alert" должен быть статичным. Уведомление должно встраиваться в контейнер непосредственно в момент показа, иначе ассистивные технологии не смогут отследить его появление. На каждое уведомление нужен отдельный контейнер с role="alert", потому что читалки озвучивают всё содержимое контейнера при изменении любой его части.

Состояние загружаемых данных

Дисклеймер: код максимально упрощён, чтобы проиллюстрировать основную мысль, не отвлекаясь на детали.

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

const comments = {
  isLoading: false,
  isLoaded: false,
  isError: false,
  errorText: '',
  data: [],
};

Флаги легко использовать во внешнем коде, но ими сложно управлять. Так может выглядеть код, управляющий их состоянием:

const comments = {
  isLoading: false,
  isLoaded: false,
  isError: false,
  errorText: '',
  data: [],
};

function loadComments() {
  comments.isLoaded = false;
  comments.isError = false;
  comments.isLoading = true;

  fetch('/comments')
    .then((response) => response.json())
    .then((data) => {
      comments.isLoading = false;
      comments.isLoaded = true;
      comments.data = data;
    })
    .catch((error) => {
      commments.isLoading = false;
      comments.isError = true;
      comments.errorText = error.message;
    });
}

Получается запутанно и многословно, нужно помнить, значения каких флагов следует сбросить. Более практичный подход — описывать состояние данных одним полем dataState, а все флаги автоматически вычислять на основе значения этого поля:

const dataStates = {
  notAsked: 'notAsked',
  loading: 'loading',
  loaded: 'loaded',
  failed: 'failed',
};

const comments = {
  dataState: dataStates.notAsked,
  errorText: '',
  data: [],

  get isLoading() {
    return this.dataState === dataStates.loading;
  },

  get isLoaded() {
    return this.dataState === dataStates.loaded;
  },

  get isError() {
    return this.dataState === dataStates.failed;
  }
};

Кода стало чуть больше, зато теперь значения всех флагов меняются автоматически, и вероятность ошибки из-за неправильного обновления одного из флагов сведена к нулю. Функция загрузки комментариев становится значительно проще:

function loadComments() {
  comments.dataState = dataStates.loading;

  fetch('/comments')
    .then((response) => response.json())
    .then((data) => {
      comments.dataState = dataStates.loaded;
      comments.data = data;
    })
    .catch((error) => {
      comments.dataState = dataStates.failed;
      comments.errorText = error.message;
    });
}

Дополнительное преимущество такого подхода — расширяемость. Представим, что для данных нужно добавить ещё одно состояние — например, deprecated. Добавление нового состояния ограничивается лишь правкой объекта comments:

const comments = {
  /* ... */,

  get isDeprecated() {
    return this.dataState === dataStates.deprecated;
  },
};

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

Доступные переходы между страницами в SPA

В SPA сломано много вещей, которые нормально работают в обычных сайтах. Одна из таких вещей — переходы между страницами. Используете динамический роутинг? Оберните содержимое страниц в элемент с tabindex="-1"role="region" и aria-label="Содержимое текущей страницы", а при переходах между страницами устанавливайте фокус на эту обёртку. Эта базовая техника сделает ваше приложение немного доступнее для тех, кто пользуется кнопкой tab или экранными читалками.

Реализация такой обёртки на Реакте:

import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';

export default class A11yRegion extends PureComponent {
  static propTypes = {
    autoFocus: PropTypes.bool,
    children: PropTypes.node
  };

  componentDidMount = () => {
    if (this.props.autoFocus) {
      this.rootNode.focus();
    }
  };

  render = () => {
    const { children } = this.props;

    return (
      <div
        ref={(node) => (this.rootNode = node)}
        className={styles.root}
        role='region'
        tabIndex='-1'
        aria-label='Page content'>
        {children}
      </div>
    );
  };
}

export const wrapWithA11yRegion = (WrappedComponent, a11yRegionProps = {}) => (props) => {
  return (
    <A11yRegion {...a11yRegionProps}>
      <WrappedComponent {...props} />
    </A11yRegion>
  );
};

Пример использования этой обёртки с Реакт-Роутером третьей версии:

import { wrapWithA11yRegion } from './a11y-region.js';
/* ... */

export default () => (
  <Route component={App}>
    <Route path='/' component={wrapWithA11yRegion(MainPage, { autoFocus: true })} />
    <Route path='/news' component={wrapWithA11yRegion(NewsPage, { autoFocus: true })} />
  </Route>
);

Импорт модулей относительно проекта, а не текущего файла

Обычный подход к импорту модуля в проекте — указание пути к нему относительно текущего файла:

import smoosh from '../../../utils/flatten';

Этот подход хрупкий и неудобный:

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

Эти проблемы решаются использованием путей относительно корня проекта, например:

import smoosh from '~/utils/flatten';

Здесь ~ — это алиас корня проекта (например, /Users/andrew-r/work/personal-site/source). Недостаток такого подхода заключается в том, что он не работает из коробки. Чаще всего этот подход используют с Вебпаком, в конфигурации которого можно указать нужные алиасы:

const path = require('path');

module.exports = {
  entry: './source/index.js',
  /* ... */
  resolve: {
    alias: {
      '~': path.resolve(__dirname, './source'),
    }
  }
};

Если вы не используете Вебпак, есть альтернатива в виде переменной окружения NODE_PATH. Она говорит ноде о дополнительных директориях, в которых нужно искать модули при импортах. Можно прописать в NODE_PATH путь к директории с исходниками проекта, тогда импорты будут такого вида:

import smoosh from 'utils/flatten';

В таком случае импорты получаются короче, но повышается вероятность конфликта имён между директориями проекта и установленными NPM-пакетами.

Автоудаление UTM-меток

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

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

Например, так делает Medium: сразу после открытия страницы UTM-метки исчезают из урла. Попробуйте: https://medium.com/web-standards/episode-115-9bdbcfa6bac4?utm_source=ui-developer-tips

Element.matches()

Для проверки активности элемента часто используется что-то вроде node.classList.contains('active'). В спецификации DOM есть более удобный и универсальный метод для проверки элемента на соответствие селектору — node.matches('.active').

Поддержка браузерами приличная, но для Edge 14- и IE нужно подключить полифил.

Три тонкости, о которых нужно помнить при использовании стрелочных функций

  1. Стрелочные функции не имеют собственного контекста, this внутри них ссылается на родительский контекст.
  2. У стрелочных функций нет собственного списка arguments, он ссылается на arguments родительской функции.
  3. Стрелочные функции нельзя вызывать с оператором new.

Выделение текста при кликах по label

По умолчанию при частых кликах по label выделяется текст лейбла. Это стандартное поведение, к которому привыкли пользователи; не отключайте его. Если же вас под угрозой увольнения заставляют отключить выделение текста, запретите выделение только при клике по лейблу, так у пользователя останется возможность выделить его текст:

/* полностью запрещает выделение текста на элементе */
label {
  user-select: none;
}

/* запрещает выделение текста только при кликах по элементу */
label:active {
  user-select: none;
}

URL.createObjectURL вместо FileReader.readAsDataURL

Если вам нужно отобразить картинку, которая изначально представлена в виде файла или блоба, не используйте для этого FileReader.readAsDataURL — он требует значительных ресурсов для чтения содержимого блоба и его конвертации в data URL, хоть это и происходит асинхронно и не блокирует основной поток.

Вместо него лучше применить синхронный URL.createObjectURL — он моментально сгенерирует и свяжет с блобом временный URL, который можно использовать как угодно, например, в качестве src для <img />. Генерация URL для блоба не требует чтения его содержимого, поэтому работает быстро и не расходует ресурсы (подробное описание алгоритма в спецификации).

Пока для блоба существуют временные URL, он не может быть удалён из памяти сборщиком мусора, поэтому по завершении использования URL не забудьте отвязать его от блоба с помощью вызова URL.revokeObjectURL.

Конфигурация CI как код

Для непрерывной интеграции (CI, Continuous Integration) обычно используют инструменты вроде Jenkins, TeamCity, Travis или CircleCI. Эти инструменты предоставляют собственный интерфейс для конфигурирования CI, в котором процесс обычно описывается шагами: настройка окружения, запуск тестов, сборка, деплой и так далее.

Настройка CI через интерфейс используемого инструмента непрозрачна и неудобна:

  • изменения не версионируются;
  • для внесения изменений каждый раз нужно открывать сам инструмент, который может быть недоступен (например, из-за выключенного корпоративного VPN);
  • для изменения процесса в рамках конкретной задачи придётся плясать с бубном и разделять в инструменте общий билд и билд этой задачи.

Решение этих проблем — описание процесса в виде кода (например, в виде sh-скрипта или Makefile) и хранение этого кода непосредственно в репозитории проекта. В используемом вами инструменте остаётся только указать команду запуска этого кода.

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

Drag-and-drop загрузка файлов

При реализации формы с полем загрузки файлов хорошим тоном считается поддержка drag-and-drop, чтобы пользователь мог перетащить нужный файл из проводника в форму.

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

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

Более дружелюбный к пользователям подход — растянуть доступную для перетаскивания область на весь экран. Так, например, сделано в Амплифере и на ГитХабе:

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