Código JavaScript resistente al cambio

Fecha de publicación: 2021-09-22

El desarrollo de software enfrenta dos dificultades persistentes: complejidad y requisitos cambiantes. La descomposición ayuda con ambas, pero solo si las partes resultantes resisten el cambio. Este artículo repasa los elementos del código JavaScript, sus interfaces y las fronteras que impiden que un cambio se propague por toda la aplicación.

Elementos e interfaces

En distintos niveles de detalle, JavaScript contiene objetos, funciones, clases, módulos ES y paquetes npm. Objetos, funciones y clases son elementos básicos; módulos y paquetes los combinan.

El desarrollo bottom-up crea paquetes a partir de piezas pequeñas; el top-down recorre el camino inverso. En ambos casos, una buena descomposición busca alta cohesión dentro del componente y bajo acoplamiento entre componentes.

Direccionar código

En aplicaciones ES2015+, el módulo ES es el bloque principal. En navegador se carga mediante una jerarquía de URL; en Node.js es un archivo dentro de un paquete de node_modules.

La dirección a nivel de aplicación tiene dos partes: ruta del módulo y nombre de exportación:

import { exportName } from 'https://domain.example/path/to/mod.mjs';
import { exportName } from '../../path/to/mod.mjs';
import { exportName } from '@vendor/project/src/path/to/mod.mjs';

Las direcciones absolutas son más frágiles que las relativas o mapeadas. Los import maps desacoplan al consumidor de la ubicación física. export default también desacopla al consumidor del nombre interno de exportación, aunque limita el módulo a un único export por defecto.

Interfaces en cada nivel

Para una función, nombre, entradas y resultado constituyen la interfaz. Una firma posicional es concisa:

function producer(x1, x2) { return x1 * x2; }

Desestructurar un único objeto da una frontera pública más tolerante al cambio:

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

Se pueden añadir campos sin desplazar posiciones. A cambio hay más código y menos legibilidad inmediata, así que el patrón es especialmente valioso en fronteras públicas o duraderas. Las clases exponen nombres, miembros públicos y contratos de método. Un módulo ES expone solo lo que exporta.

Un paquete npm es la unidad reutilizable mayor. Nombre, versión y punto de entrada main/exports forman parte del contrato. Sin restricciones de exportación, un consumidor puede importar archivos internos; por eso las convenciones público/privado deben estar claras.

Áreas públicas y privadas

Todo lo que queda fuera de la interfaz es implementación privada. Una función anidada, un campo privado de clase o una vinculación no exportada puede cambiar sin obligar a cambiar a consumidores:

class SomeClass {
  #privateValue;
  constructor() {
    const internal = {};
    this.useInternal = () => { internal.value = this.#privateValue; };
  }
  setValue(value) { this.#privateValue = value; }
}

Cuanto más comportamiento útil se mantenga privado, menor será la superficie pública y menos dependencias habrá que mantener. Un paquete puede declarar entrada principal y reexportar API prevista; también puede adoptar convenciones de carpetas, por ejemplo Api/ pública y demás módulos internos.

Reglas prácticas

Cuanto mayor es el componente, más importa la estabilidad de nombre e interfaz. Las buenas fronteras no evitan el cambio: mantienen local un cambio local.

Fragmentos adicionales de código fuente

import func from ‘./mod.mjs’;
import {fn} from ‘./mod.mjs’;
function outer() {
const obj = {};
function inner() {
const obj = {};
}
} > Глубоко вложенные области видимости ухудшают читаемость кода, а использование элементов кода с верхних уровней снижают устойчивость кода к изменениям (за исключением случая, когда элементы кода определяются на самом верхнем уровне — через импорты в es-модуле).
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}); > Использование функций в таком виде добавляет коду устойчивости к изменениям относительно “классического” варианта. В этом случае мы можем смелее изменять входные и выходные аргументы (их количество и порядок следования), но платим за это читабельностью кода.
{
“another_dep”: “~2.2.0”
}
} Имя npm-пакета участвует в адресации es-модулей в nodejs-приложениях. В пакете может быть определён входной объект, в его ./package.json:
{
“main”: “lib/entry.js”
}
function producer(opts) {
function nested() {}
}
class SomeClass {
const nestedObj = {};
this.instMethod = function () {
nestedObj.prop = this.#privProp;
}
} setProp(data) {
this.#privProp = data;
}
} privProp и nestedObj являются внутренними элементами кода для класса SomeClass и недоступны извне напрямую.
{
“main”: “src/Shared/Container.mjs”
}
import Container from ‘@teqfw/di’;
import subModule from ‘./path/to/sub/modle.mjs’;export {
}
import * as module from ‘@scope/prj’;
const sub = module.subModule; Но это именно “джентльменское соглашение” — ничто не мешает разработчику другого npm-пакета обратиться внутрь нашего npm-пакета к любому es-модулю напрямую.
export default class TeqFw_Web_Back_Defaults {
constructor(spec) {
this.MOD_DI = spec[‘TeqFw_Di_Back_Defaults$’];
}
}