---
title: "Устойчивость JS-кода к изменениям"
description: "Для многих в разработке программ самыми большими проблемами являются (а) их сложность и (б) изменчивость требований. Решение обеих проблем — в декомпозиции целого приложения на бол"
date: 2021-09-22
---

Для многих в разработке программ самыми большими проблемами являются (а)
их сложность и (б) изменчивость требований. Решение обеих проблем — в
декомпозиции целого приложения на более мелкие части (пакеты, модули,
классы и функции). Декомпозиция для уменьшения сложности в целом
достаточно проста ([закон
Миллера](https://ru.wikipedia.org/wiki/%D0%9C%D0%B0%D0%B3%D0%B8%D1%87%D0%B5%D1%81%D0%BA%D0%BE%D0%B5_%D1%87%D0%B8%D1%81%D0%BB%D0%BE_%D1%81%D0%B5%D0%BC%D1%8C_%D0%BF%D0%BB%D1%8E%D1%81-%D0%BC%D0%B8%D0%BD%D1%83%D1%81_%D0%B4%D0%B2%D0%B0)).
Но нужно не просто разбить приложение на части, а сделать эти части
устойчивыми к изменениям требований.

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

## Элементы кода в JS

В зависимости от уровня детализации в JavaScript можно выделить
следующие группы кода:

- объекты (прописанные в коде, а не создаваемые программно)
- функции
- классы
- `es`-модули
- `npm`-пакеты

Первые три (объекты, функции, классы) — это базовые элементы. Они могут
взаимодействовать друг с другом. Вторые два (`es`-модули и `npm`-пакеты)
— составные объекты. Они взаимодействуют друг с другом на уровне базовых
элементов, входящих в их состав.

Для каждого типа элементов кода существует свой интерфейс взаимодействия
с ним — каким образом внешний код может использовать код элемента:

- объекты — через свойства объекта;
- функции — через входные и выходные аргументы;
- классы — через свойства и методы класса;
- es-модули — через экспорт-объект модуля;
- npm-пакеты — [primary entry
  point](https://docs.npmjs.com/cli/v7/configuring-npm/package-json#main)
  (`main`) и прямое обращение к `es`-модулям пакета;

При разработке приложения “снизу-вверх” мы создаём сначала базовые
элементы кода (объекты, функции, классы), а затем объединяем их в более
крупные образования. Базовые элементы кода могут состоять из других
базовых элементов (функция внутри функции, например), `es`-модули - из
базовых элементов, `npm`-пакеты — из `es`-модулей. При разработке
“сверху-вниз” мы движемся в обратном направлении — от пакетов к базовым
элементам.

## Связность и зацепление

Для оценки качества декомпозиции используют такие понятия, как
“связность”
([cohesion](https://ru.wikipedia.org/wiki/%D0%A1%D0%B2%D1%8F%D0%B7%D0%BD%D0%BE%D1%81%D1%82%D1%8C_(%D0%BF%D1%80%D0%BE%D0%B3%D1%80%D0%B0%D0%BC%D0%BC%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D0%BD%D0%B8%D0%B5)))
и “зацепление”
([coupling](https://ru.wikipedia.org/wiki/%D0%97%D0%B0%D1%86%D0%B5%D0%BF%D0%BB%D0%B5%D0%BD%D0%B8%D0%B5_(%D0%BF%D1%80%D0%BE%D0%B3%D1%80%D0%B0%D0%BC%D0%BC%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D0%BD%D0%B8%D0%B5))):

<figure>
<img src="/medium/img/e9a0fe4f1e1f/image-01.png" alt="Image 2" />
</figure>

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

## Адресация элементов кода

### Адресация на уровне всего приложения

Я рассматриваю JS в варианте ES2015+, где базовым “кирпичом” в
построении приложений является `es`-модуль, основанный на механизме
экспорта-импорта.

Если смотреть на ES2015+ приложение с точки зрения браузера, то оно
состоит из `es`-модулей, полученных с внешних серверов и размещённых в
иерархии, напоминающей файловую структуру:

<figure>
<img src="/medium/img/e9a0fe4f1e1f/image-02.png" alt="Image 3" />
</figure>

С точки зрения nodejs все es-модули находятся в обычной файловой
структуре — файлы модулей находятся в `npm`-пакетах, а пакеты находятся
в каталоге `./node_modules/`:

<figure>
<img src="/medium/img/e9a0fe4f1e1f/image-03.jpg" alt="Image 4" />
</figure>

Адрес элемента кода на уровне приложения состоит из двух частей:

- путь к `es`-модулю в иерархии всех файлов приложения (web или nodejs);
- имя экспорта соответствующего `es`-модуля;

Пример абсолютной адресации:

``` js
import { exportName } from 'https://domain.com/path/to/mod.mjs';
import { exportName as localExport } from '/var/prj/path/to/mod.mjs';
```

Адресация относительно местоположения текущего модуля:

``` js
import { exportName } from '../../path/to/mod.mjs';
```

В nodejs-приложениях возможна также адресация относительно каталога `./node_modules/`:

``` js
import { exportName } from '@vnd/prj/src/path/to/mod.mjs';
```

Таким образом, основным элементом кода в JS-приложениях является es-модуль, который может входить в состав npm-пакета (в nodejs-приложениях) и может включать в себя базовые элементы кода (объекты, функции, классы) в качестве экспорта:

<figure>
<img src="/medium/img/e9a0fe4f1e1f/image-04.png" alt="Image 5" />
</figure>

Получается, что на уровне приложения выстраиваются связи между базовыми
элементами кода, экспортируемыми `es`-модулями (экспортами):

<figure>
<img src="/medium/img/e9a0fe4f1e1f/image-05.png" alt="Image 6" />
</figure>

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

``` text
[path_to_the_module][exportName]
```

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

### ‘export default’

Использование `export default` в некотором роде отвязывает потребителя
кода от его поставщика. Сравните два варианта экспорта:

``` js
const fn = function (data) {};
export default fn;
export { fn };
```

И соответствующего ему импорта:

``` js
import func from './mod.mjs';
import { fn } from './mod.mjs';
```

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

### Внутримодульная адресация

Внутри отдельного `es`-модуля адресация базовых элементов кода требует
только, чтобы в пределах одной области видимости (scope) имена элементов
были уникальны. Во вложенных областях видимости есть риск перекрытия
имён используемых элементов (например, `obj`).

``` js
function outer() {
  const obj = {};
  function inner() {
    const obj = {};
  }
}
```

> Глубоко вложенные области видимости ухудшают читаемость кода, а использование элементов кода с верхних уровней снижает устойчивость кода к изменениям (за исключением случая, когда элементы кода определяются на самом верхнем уровне — через импорты в es-модуле).

### Адресация пакетов

Адресация пакетов актуальна только для `nodejs`-приложений и является
частью адресации `es`-модулей.

## Интерфейсы элементов кода

Каждый элемент кода, помимо своего адреса (имени), обладает также
некоторым интерфейсом, который определяет способы “зацепления” с ним
других элементов кода.

### Объекты

Для JS-объекта интерфейсом взаимодействия внешнего кода с объектом
являются имена свойств:

``` js
const OBJ = { prop: 'value' };
function consumer() {
  console.log(OBJ.prop);
}
```

### Функции

“*Классический*” вариант
[предполагает](http://fkn.ktu10.com/?q=node%2F5934), что функция
определяется своим именем, входными аргументами (кол-во, порядок, тип) и
типом возвращаемого результата:

``` js
function producer(x1, x2) {
  return x2 * x1;
}
const y = producer(1, 2);
```

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

``` js
const y = fn(x);
```

JS с его деструктурирующим присваиванием вплотную подошёл к этому варианту:

``` js
function producer({ x1, x2 }) {
  return { y1: x1 + x2, y2: x2 * x1 };
}
const { y1, y2 } = producer({ x1: 1, x2: 2 });
```

> Использование функций в таком виде добавляет коду устойчивости к изменениям относительно “классического” варианта. В этом случае мы можем смелее изменять входные и выходные аргументы (их количество и порядок следования), но платим за это читабельностью кода.

Использование переменной `arguments` для анализа входных аргументов и
[остаточных
параметров](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Rest_parameters)
я отношу к промежуточным вариантам (между одним входным аргументом и
“*классической*” формой).

### Классы

Классы совмещают в себе структуру обрабатываемых данных и методы,
которыми эти данные обрабатываются. В ООП классы зачастую являются теми
самыми элементами кода, отношение между которыми оцениваются на предмет
связности и зацепления. Во многих языках программировании рядом с
классами стоят *интерфейсы*, но JS в их список не входит (хотя на уровне
JSDoc’ов такое понятие
[присутствует](https://jsdoc.app/tags-interface.html)).

Интерфейс класса в JS, как и в других ЯП, определяется именем класса,
именами доступных свойств и методов, входными/выходными аргументами
методов.

### es-модуль

Интерфейс взаимодействия с `es`-модулем определяется его
[export-объектом](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export)
(упоминался выше, когда обсуждалась адресация элементов в `es`-модуле).

### `npm`-пакет

Пакет является самым верхним уровнем группировки кода, самым крупным
“кирпичом” в приложении. Основа для взаимодействия пакетов — имя пакета
и его версия, которые прописываются в `npm`-дескрипторе приложения
(`./package.json`):

``` json
{
  "name": "my_package",
  "version": "1.0.0",
  "dependencies": {
    "my_dep": "^1.0.0",
    "another_dep": "~2.2.0"
  }
}
```

Имя npm-пакета участвует в адресации es-модулей в nodejs-приложениях. В пакете может быть определён входной объект, в его `./package.json`:

``` json
{
  "main": "lib/entry.js"
}
```

Таким образом, на уровне пакетов интерфейсом является имя пакета, его
версия, входной объект и структура es-модулей внутри пакета.

## Private & public области

Для всех типов элементов кода, за исключением разве что объектов, можно
выделить публичный и приватный код. Всё, что касается интерфейсной части
элемента кода (функции, класса, `es`-модуля, `npm`-пакета), является
публичной частью, всё остальное — приватной. Изменения в приватной части
объекта кода никак не отражаются за границами элемента кода (функции,
класса, …), в отличие от изменений в его публичной части.

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

### Функции

Вот так выглядят приватные области кода на уровне функций:

``` js
function producer(opts) {
  function nested() {}
}
```

Функция `nested` не видна за пределами `producer` и мы можем спокойно её
менять в рамках функции `producer`.

### Классы

В классе могут быть определены приватные члены (свойства и методы) на
уровне прототипа класса, а также переменные и функции ограниченного
доступа на уровне экземпляра класса (в конструкторе):

``` js
class SomeClass {
  #privProp;

  constructor() {
    const nestedObj = {};
    this.instMethod = function () {
      nestedObj.prop = this.#privProp;
    };
  }

  setProp(data) {
    this.#privProp = data;
  }
}
```

`#privProp` и `nestedObj` являются внутренними элементами кода для класса `SomeClass` и недоступны извне напрямую.

### es-модули

В `es`-модуле всё, что не является предметом экспорта, является
приватным кодом.

### npm-пакеты

В пакетах предусмотрено “*джентльменское соглашение*”, что основная
точка входа в пакет объявляется в дескрипторе пакета (`package.json`), в
узле `main` (по-умолчанию
[используется](https://docs.npmjs.com/cli/v7/configuring-npm/package-json#main)`./index.js`):

``` json
{
  "main": "src/Shared/Container.mjs"
}
```

Тогда для импорта основной точки входа достаточно написать:

``` js
import Container from '@teqfw/di';
```

Остальные элементы кода пакета можно сделать доступными по цепочке через
основную точку входа:

``` js
import subModule from './path/to/sub/module.mjs';
export { subModule };
```

И использовать:

``` js
import * as module from '@scope/prj';
const sub = module.subModule;
```

Но это именно “джентльменское соглашение” — ничто не мешает разработчику другого npm-пакета обратиться внутрь нашего npm-пакета к любому es-модулю напрямую.

## Резюме

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

Код в целом устойчив к добавлению (свойств, аргументов, методов), менее
устойчив к их удалению и совсем не устойчив к переименованию. При
добавлении к объекту новых свойств существующие “*потребители*” никак не
затрагиваются. При удалении какого-либо свойства и обращении к нему
извне возвращается `undefined`. Переименование свойства объекта приводит
к необходимости изменения всего кода, завязанного на данное свойство.

Поэтому наименование элементов кода (`npm`-пакеты, классы, функции,
объекты) и их размещение (`es`-модули) особенно важно с точки зрения
устойчивости, т.к. изменение имён впоследствие может привести к
массовому рефакторингу или сделает невозможным использование нашего кода
внешними “*потребителями*”.

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

Использование алиасов при использовании внешних элементов кода позволяют
замкнуть текущий контекст (модуль или пакет) на алиас, что несколько
уменьшает зацепление:

``` js
export default class TeqFw_Web_Back_Defaults {
  constructor(spec) {
    this.MOD_DI = spec['TeqFw_Di_Back_Defaults$'];
  }
}
```

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

“*Джентльменское соглашение*” по поводу “*публичной*” и “*приватной*”
частей пакета можно использовать не только в виде *primary entry point*,
но и на уровне *naming/placing*-соглашений (например, определить, что
все `es`-модули из каталога `./Api/` являются публичными модулями
пакета, а все остальные — приватные).

Такое же соглашение можно ввести на уровне группы `es`-модулей
(*AZ-order —* об этом отдельно).
