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


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


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


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


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

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


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


  • объекты — через свойства объекта;
  • функции — через входные и выходные аргументы;
  • классы — через свойства и методы класса;
  • es-модули — через экспорт-объект модуля;
  • npm-пакеты — primary entry point (main) и прямое обращение к es-модулям пакета;

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


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


Для оценки качества декомпозиции используют такие понятия, как “связность” (cohesion) и “зацепление” (coupling):



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


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


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


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


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


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



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



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


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

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


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

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


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

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


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

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



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



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


[path_to_the_module][exportName]

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

‘export default’


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


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

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


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

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


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


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


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

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

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


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


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


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


Объекты


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


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

Функции


“Классический” вариант предполагает, что функция определяется своим именем, входными аргументами (кол-во, порядок, тип) и типом возвращаемого результата:


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

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


const y = fn(x);

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


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

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

Использование переменной arguments для анализа входных аргументов и остаточных параметров я отношу к промежуточным вариантам (между одним входным аргументом и “классической” формой).


Классы


Классы совмещают в себе структуру обрабатываемых данных и методы, которыми эти данные обрабатываются. В ООП классы зачастую являются теми самыми элементами кода, отношение между которыми оцениваются на предмет связности и зацепления. Во многих языках программировании рядом с классами стоят интерфейсы, но JS в их список не входит (хотя на уровне JSDoc’ов такое понятие присутствует).


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


es-модуль


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


npm-пакет


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


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

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


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

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


Private & public области


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


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


Функции


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


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

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


Классы


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


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 (по-умолчанию используется ./index.js):


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

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


import Container from '@teqfw/di';

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


import subModule from './path/to/sub/modle.mjs';
export {
    subModule
}

и использовать их во внешнем коде:


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

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


Резюме


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


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


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


import func from './mod.mjs';

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


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


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

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


  1. dimoff66
    23.09.2021 14:47
    +4

    Переименование свойства объекта приводит к необходимости изменения всего кода, завязанного на данное свойство (как следствие, вместо переименования свойства/функции/… можно использовать добавление нового, с аналогичным функционалом, а затем, через какое-то время, удаление старого).

    Это очень странный совет. Придется держать в голове где мы используем старое свойство, где новое. Почему сразу не заменить все через Ctrl + Shift + R ?

    “Джентльменское соглашение” по поводу “публичной” и “приватной” частей пакета можно использовать не только в виде primary entry point, но и на уровне naming/placing-соглашений

    Почему бы не использовать typescript вместо джентельменских соглашений?


    1. flancer Автор
      23.09.2021 15:43
      +1

      Почему сразу не заменить все через Ctrl + Shift + R ?

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

      Почему бы не использовать typescript вместо джентельменских соглашений?

      А разве import в TS работает по-другому, чем в JS? Через import можно тянуть es-модуль в том числе и из "нутрянки" npm-пакета. То, что мы этого не делаем, а общаемся только через primary entry point, и есть "джентльменское соглашение".