Single page apps in depth 2 (перевод)

Это вторая статья вольных переводов singlepageappbook. Очень хочу разобраться в теме. Весь цикл можно будет найти по тегу spaindepth.

Модульность: прекратите использовать пространства имен!

Модульность есть суть всего. Изначально я относился к этому очень по-разному, но после 20 черновиков ничего не становится так важно, как правильная модуляризация.

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

Что такое поддерживаемый код?

  • его просто понять чтобы решить проблему
  • его легко тестировать
  • его легко рефакторить

Что такое неподдерживаемый код?

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

Все эти выражения - о модульности.

Что такое модульный код

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

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

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

Проблема с пространством имен (namespaces)

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

Когда люди говорят о модульном Javascript, они как правило имеют в виду использование пространств имен. Как правило это прием, когда мы выбираем префикс вроде window.myApp и присваиваем все внутри него, держа в голове идею, что у каждого объекта есть свое глобальное имя. Пространства имен создают иерархию, но они страдают от двух других проблем:

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

Это не дает больше контроля. С пространством имен вы не можете раскрыть детали без помещения кода в глобальную область видимости. Мы должны иметь возможность раскрывать методы между модулями без помещения их в глобальную область видимости.

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

Несколько примеров плохого кода

Не сливайте глобальные переменные

Не пишите в глобальный scope, если нет необходимости:

// Плохо: добавляет глобальную переменную "window.foo"
  var foo = 'bar';

Чтобы запретить переменным быть глобальными, всегда пишите анонимные функции:

  ;(function() {
    // Хорошо: локальная переменная недоступна из глобальной области
    var foo = 'bar';
  }());

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

  function initialize(win) {
    // Хорошо: если вам нужны глобальные переменные, разделите определение и реализацию
    win.foo = 'bar';
  }

В данном примере переменная присваивается объекту win, переденному функции. Причина тому то, что модули не должны иметь side-эффектов при загрузке. Мы можем откладывать запуск этой функции до того времени, когда нам действительно понадобится что-то глобальное.

Снова: не показывайте детали реализации

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

Не определяйте двух вещей (или, о ужас, более чем двух) в одном файле, неважно как это удобно для вас сейчас. Каждый файл должен определять и экспортировать только одну вещь.

;(function() {
  // Плохо: global names = global state
  window.FooMachine = {};
  // Плохо: детали реализации публичны
  FooMachine.processBar = function () { ... };
  FooMachine.doFoo = function(bar) {
    FooMachine.processBar(bar);
    // ...
  };

  // Плохо: экспортируем объект из того же файла
  // No logical mapping from modules to files.
  window.BarMachine = { ... };
})();

Приведенный ниже код делает это правильно: внутренняя функция "processBar" является локальной, поэтому она не может быть доступна снаружи. Она также экспортирует только одну вещь, текущий модуль.

;(function() {
  // Хорошо: имя локально для модуля
  var FooMachine = {};

  // Хорошо: реализация деталей скрыта
  function processBar() { ... }

  FooMachine.doFoo = function(bar) {
    processBar(bar);
    // ...
  };

  // Хорошо: публичен только внешний интерфейс
  // все внутреннее может быть изменено безболезненно
  return FooMachine;
})();

Общий закон для классов (например, объектов, созданных из прототипа), чтобы пометить приватные методы, начинаем их с нижнего подчеркивания. Вы можете должным образом скрыть методы класса - с помощью .call / .apply установить this, но я не буду показывать это здесь; это незначительная деталь.

Не мешайте определения с вызовами

Код должен разделять определение и создание экземпляра/инициализацию. Объединение часто приводит к проблемам тестирования и повторного использования компонентов.

Не делайте этого:

function FooObserver() {
  // ...
}

var f = new FooObserver();
f.observe('window.Foo.Bar');

module.exports = FooObserver;

Хотя это модуль (я упустил обертку здесь), он смешивает инициализацию с определением. Вы должны разделить его на две части, одну ответственную за определение и другую, которая выполняет инициализацию для этого конкретного варианта использования. Пример foo_observer.js:

function FooObserver() {
  // ...
}
module.exports = FooObserver;

и bootstrap.js:

module.exports = {
  initialize: function(win) {
    win.Foo.Bar = new Baz();
    var f = new FooObserver();
    f.observe('window.Foo.Bar');
  }
};

Сейчас FooObserver может быть создан/инициализирован отдельно. Даже если использованием будет простое прикрепление к window.Foo.Bar, это по-прежнему полезно, потому становится возможным создание тестов с различной конфигурацией.

Не меняйте объекты, которые вам не принадлежат

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

Многие фреймворки предоставляют интерфейс для изменения ранее определенного объекта прототипа (например класса). Не делайте этого в ваших модулях.

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

;(function() {
  // Плохо: переопределяем поведение другого модуля
  window.Bar.reopen({
    // меняем поведение на лету
  });
  // Плохо: меняем встроенный тип
  String.prototype.dasherize = function() {
    // Хоть вы и прячете эту функцию
    // вы все еще занимаетесь манки-патчингом(monkey-patching) в неочевидном стиле
  };
})();

Если вы пишете фреймворк, ради всего святого (for f*ck's sake), не меняйте встроенные объекты путем добавления новых функций к ним. Да, вы можете сэкономить несколько символов (например, _(str).dasherize() вместо str.dasherize()), но это то же самое, что сделать очередную глобальную зависимость. Относитесь к чужим объектам с уважением и кладите эти специальные функции в служебную библиотеку.

Сборка модулей и пакетов с помощью CommonJS

Сейчас, когда мы рассмотрели несколько плохих примеров, перейдем к хорошим: как мы можем реализовать модули и пакеты для нашего Single Page App?

Мы хотим решить три проблемы:

  • Приватность: мы хотим более гранулированной приватности, чем просто глобальный или локальный scope.
  • Избежать хранения в глобальном пространстве имен.
  • Мы должны быть в состоянии создавать пакеты из нескольких файлов и каталогов, и быть в состоянии обернуть все подсистемы в единое замыкание (closure).

Модули CommonJS. CommonJS это формат модулей, который использует Node.js. Модуль CommonJS это просто кусок JS кода, который делает две вещи:

  • он использует require() для включения зависимостей
  • он присваивает переменной exports единый публичный интерфейс

Вот простой пример foo.js:

var Model = require('./lib/model.js'); // вклюаем зависимости

// реализация модуля
function Foo(){ /* ... */ }

module.exports = Foo; // публичный интерфейс

Зачем здесь var Model? Разве это оно в глобальной области видимости? Нет, здесь нет ничего глобального. Каждый модуль имеет свою область видимости. Можно представить, что каждый модуль неявно завернут в анонимную функцию (что означает, что переменные, определенные в нем, являются локальными для модуля).

Хорошо, как насчет загрузки JQuery или какой-либо другой библиотеки? Существуют два основных способа: либо указав путь к файлу (например, ./lib/model.js.) Или, загружая его по имени: var $ = require('jquery');. Элементы, загружаемые по пути к файлу расположены непосредственно по этому пути в файловой системе. Вещи, загружаемые по имени являются "пакеты" и используют специальный алгоритм поиска. В случае nodejs, require() использует простой поиск в каталоге; в браузере мы можем определить связи, но об этом позже.

Что мы имеем? Разве это не то же самое, что обернуть все в замыкания? Нет, не в долговременной перспективе.

Невозможно случайно изменить global scope, и мы экспортируем только одну сущность. Каждый модуль CommonJS выполняется в своем собственном контексте. Переменные локальны для модуля. Вы можете экспортировать только один объект на модуль.

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

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

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

Вы знаете эти невыносимые конфликты версий, которые возникают когда включение модуля изменяет среду? Таким образом, вы не можете иметь два модуля с тем же именем в разных частях вашей системы, потому что каждое имя может существовать только в одном экземпляре? CommonJS не страдает от этого, потому что require() просто возвращает модуль и вы даете ему локальное имя путем присвоения его переменной.

Он поставляется с системой распространения. Модули CommonJS могут быть распространены с помощью диспетчера пакетов npm. Я расскажу об этом больше в следующей главе.

Есть тысячи совместимых модулей. Ну, я утрирую, но все модули в npm основаны на CommonJS; не все из тех предназначены для браузера, но среди них есть много полезных.

И последнее, но не менее важное: CommonJS модули могут быть вложены друг в друга для создания пакетов. Семантика require() хоть и проста, но она обеспечивает возможность создания пакетов, которые раскрывают детали реализации внутренне (через файлы) в то же время скрывая их от внешнего мира.

Создание пакета CommonJS

Давайте посмотрим, как мы можем создать пакет из модулей следуя шаблону CommonJS. Создание пакета начинается с системы сборки. Давайте просто предположим, что у нас есть такая система сборки, которая может взять любой набор .JS файлов и объединить их в одном файле.

[
 [./model/todo.js] 
 [./view/todo_list.js]
 [./index.js]
] -> [Build process...] -> [ todo_package.js ]

Процесс сборки обертывае все файлы в замыкания и дописывает метаданны, а затем объединяет вывод в один файл и добавляет реализацию require() с семантикой, описанной ранее.

В основном, мы берем обертку-замыкание и расширяем ее всеми модулями пакета. Это дает возможность использовать require() внутри сборщика для получения доступа к другим модулям.

Вот как это будет выглядеть в виде кода:

;(function() {
  function require() { /* ... */ }
  modules = { 'jquery': window.jQuery };
  modules['./model/todo.js'] = function(module, exports, require){
    var Dependency = require('dependency');
    // ...
    module.exports = Todo;
  });
  modules['index.js'] = function(module, exports, require){
    module.exports = {
      Todo: require('./model/todo.js')
    };
  });
  window.Todo = require('index.js');
}());

Обратите внимание на локальный require(). Каждый модуль экспортирует внешний интерфейс по образцу CommonJS. Пакет, который мы построили содержит один файл index.js, в котором определено, что экспортируется из модуля. Как правило, это публичное API, или подмножество классов в модуле (сущности, которые являются частью общего интерфейса).

Каждый пакет экспортирует одну именованную переменную, например: window.Todo = require('index.js');. Таким образом, публикуются только нужные части модуля. Другие пакеты/код не могут получить доступ в другой пакет если они не экспортируются из index.js. Это предотвращает создание скрытых зависимостей.

Создаем приложение из пакетов

Общая структура каталогов может выглядеть примерно так:

assets
  - css
  - layouts
common
  - collections
  - models
  index.js
modules
  - todo
    - public
    - templates
    - views
    index.js
node_modules
package.json
server.js

Итак, у нас сдесь есть место, куда мы кладем активы (./assets/) и есть общая библиотека, содержащая многоразовые коллекции и модели (./common/)

./modules/ содержит подкаталоги, каждый из которых представляет собой индивидуально инициализируемую часть приложения. Каждый подкаталог является пакетом, который из них может быть загружен независимо от других (при условии, что загружены общие библиотеки).

Файл index.js в каждом пакете экспортирует функцию initialize(), которая позволяет конкретному пакету при инициализации учитывать такие параметры, как текущий URL и конфигурацию приложения.

Система сборки glue.js

Итак, теперь у нас есть относительно подробная спецификация того, как мы хотели бы собирать приложение. Node имеет встроенную поддержку require(), а браузер? Нужна ли нам комплексная библиотека для этго?

Неа. Это не трудно: сама система сборки занимает около ста пятидесяти строк кода плюс еще девяносто или около того - реализация require(). Когда я говорю собрать, я имеюю ввиду упаковку кода в замыкания и реализацию require() в браузере. Если вы хотите рассмотреть этот код в подробностях, вот он.

Раньше я использовал onejs и browserbuild. Я хотел что-то с поддержкой скриптования, поэтому (после контрибуций кода в эти проекты) я написал gluejs, которая адаптирована к системе описанной выше (в основном, имея более гибкий API).

С gluejs, вы пишете скрипты сборки в виде небольших блоков кода. Это хорошо для интеграции вашей системы сборки с остальной частью ваших инструментов - например, путем создания пакета по требованию, когда приходит запрос HTTP, или путем создания пользовательских скриптов, которые позволяют включать или исключать части (отладочный код к примеру) из кода.

Начнем с установки через npm:

  $ npm install gluejs

Теперь давайте соберем что-нибудь.

Включение файлов и сборка пакета

Давайте начнем с самых основ. Используйте include(path), чтобы добавить файлы. Путем может быть один файл или каталог (будет включен рекурсивно со всеми подкаталогами). Если вы хотите включить каталог, но не включать определенные файлы, используйте exclude(regexp) для фильтрации файлов.

Имя главного файла определяется через используя main(name);, в коде ниже это "index.js". Это файл, который является результатом сборки пакета.

var Glue = require('gluejs');
new Glue()
  .include('./todo')
  .main('index.js')
  .export('Todo')
  .render(function (err, txt) {
    console.log(txt);
  });

Каждый пакет экспортирует объект, и этот объект должен иметь имя. В приведенном ниже примере это "Todo" (например, пакет присвоен window.Todo).

Наконец, у нас есть render(callback). Он принимает function(err, txt) в качестве параметра, и возвращает результат рендеринга текста в качестве второго параметра (первый параметр используется для ошибок по соглашению в nodejs). В примере, мы просто выводим текст в консоль. Если вы поместите код, указанный выше в файл (и какие-нибудь .js файлы в "./todo/"), вы получите свой первый пакет, который выведется в консоль.

Если вы предпочитаете автоматически пересобирать файлы, используйте .watch() вместо .render(). Callback, переданный watch будет запускаться всякий раз когда файлы в пакете будут меняться.

Связывание с глобальными функциями

Мы часто хотим связать определенное имя, например, require('jquery') с внешней библиотекой. Вы можете сделать это через replace(moduleName, string).

Вот пример, который собирает пакет в ответ на HTTP GET:

var fs = require('fs'),
    http = require('http'),
    Glue = require('gluejs');

var server = http.createServer();

server.on('request', function(req, res) {
  if(req.url == '/minilog.js') {
    new Glue()
    .include('./todo')
    .basepath('./todo')
    .replace('jquery', 'window.$')
    .replace('core', 'window.Core')
    .export('Module')
    .render(function (err, txt) {
      res.setHeader('content-type', 'application/javascript');
      res.end(txt);
    });
  } else {
    console.log('Unknown', req.url);
    res.end();
  }
}).listen(8080, 'localhost');

Чтобы объединить несколько файлов в одном пакете используйте concat([packageA, packageB], function(err, txt)):

  var packageA = new Glue()
      .export('Foo')
      .include('./fixtures/lib/foo.js');
  var packageB = new Glue()
      .export('Bar')
      .include('./fixtures/lib/bar.js');

  Glue.concat([packageA, packageB], function(err, txt) {
    fs.writeFile('./build.js', txt);
  });

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

Яндекс.Метрика