Наверняка вы сталкивались с принципами (пусть и противоречивыми) о написании модулей и классов на JavaScript. Когда мне понадобилось написать встраиваемый в веб-страницу cкрипт, который предоставляет API для работы определённого сервиса, то я не смог найти достойных рекомендаций о проектировании подобных скриптов.


Итак, вот (довольно очевидные) требования к скрипту, с которыми я столкнулся: 


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

image


Из реальной практики родились принципы, описанные ниже. Это не полностью уникальные идеи, а скорее сборка лучших практик, которых я видел в чужих решениях, например в библиотечках google analytics и jquery.


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


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


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


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


Весь скрипт при этом надо завернуть в один scope. Очевидно? Да.


(function () {
 // Здесь будет твой код
}());

Кстати, чтобы обернуть код в scope с помощью Grunt, используйте options banner и footer:


concat: {
  injectScriptProd: {
    src: [...],
    dest: 'someScript.js',
    options: {
        banner: '(function(){\n',
        footer: '\n}());'
    }
},

2. Переключение между локальной и продакшн конфигурацией


Чтобы можно было легко управлять сборками и конфигурациями, мне очень помогло завести одну переменную config, положить её в отдельный файл configDev.js или configProd.js и иметь отдельные сборки скрипта. А вариантов сборок по-другим причинам потребовалось больше двух. В результате, наличие этих простых файлов очень облегчило мне и сборку, и код, и жизнь. При конкатенации просто указываете, из каких файлов собрать скрипт,?—? и цельный файл-скрипт готов. 


Плохая практика: иметь замещаемые переменные по всему JavaScript-коду вида: <% serverUrl %>/someApi. Портит читаемость кода, медленнее собирается. И хочется, чтобы grunt watch работал действительно быстро, не правда ли? 


Пример нашего prod config-файла:


var config = {
    server: "https://www.yourserver.com/api/",
    resourcesServer: "https://www.yourserver.com/cdn/",
    envSuffix: "Prod",
    globalName: "yourProjectName"
}; 

// Маленький, да удаленький!

3. Как передать API наружу?


Есть разные способы, но сейчас делаем так:


window[config.globalName] = yourApiVar;

Это позволяет:


  • Тестировать несколько версий библиотечки на странице, причём так, что они друг-другу не мешают.
  • Весь скрипт поместить в один закрытый scope.
  • (Если вдруг понадобится) решать проблемы с совместимостью. Мы ведь будем знать, что управление экземпляром API происходит в коде самого скрипта, а не в коде клиента библиотечки. И поэтому у нас есть полный контроль над всеми экземплярами.

4. “Правильная” система модулей


Я знаю, чтобы я здесь ни сказал, в меня полетят гнилые помидоры от людей, которые предпочитает другую систему модулей. Начинаем.


Правильно делать так:


var module = (function () { // for each module have this structure
    var someInnerModuleVar;

    // здесь мог бы быть твой гениальный код

    return {
        publicMethod: publicMethod
   };
}());

А почему именно так? Ответ очевиден: когда вы сконкатенируете код таких модулей, всё будет работать безо всяких библиотечек для модулей.


5. Инициализация API


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


Для первого раза, наверное, хватит. Вот структура получившегося модуля: 


(function () {
    'use strict';
    var config = {}; 
    var sharedState = {}; 

    var module = (function () { 
        var someInnerModuleVar;

        // крутой js код

        return {
            publicMethod: publicMethod,
            init: init
        };
    }());

    start();
}());

Если у вас есть идеи, как улучшить шаблон, то буду рад их услышать. Я в основном писал на java, этот проект,? —? мой самый интенсивный опыт в JavaScript. Напишите идеи по улучшению в комментариях. 


Ещё думаю написать про работу с cookies, localStorage, db, network. Напишите, какие темы наиболее интересны.

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


  1. ivsol
    19.04.2016 16:21
    +1

    >>> Напишите, какие темы наиболее интересны.

    Типизированные массивы.


    1. Kaigorodov
      19.04.2016 16:34

      Подумаю… А вам интересно это со стороны производительности, или чтения из сетевых потоков, или по какой-нибудь ещё причине?


      1. ivsol
        19.04.2016 18:27

        И то и другое.

        С ArrayBuffer в принципе всё понятно, хотелось бы посмотреть на класс DataView на примере.

        Но и ArrayBuffer тоже будет не лишним.


  1. ybondarets
    19.04.2016 16:35
    +2

    Работа с LocalStorage будет очень интересной, попробуй сделать поддержку версионности и устареваня данных. Круто будет записывать обьектыв LocalStorage через свою обертку при наличии коей ненужно будет думать у тебя там скаляр или обьект.

    P.S. Поддерживаю такую реализацию модулей. Она позволяет имплементировать приватность, что тяжело при помощи других методов.
    Спасибо за труды при написанни статьи, коротко и по сути.


    1. Kaigorodov
      19.04.2016 16:41

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


  1. shoomyst
    19.04.2016 16:58
    +9

    С приветом из 2014-го? :)
    В JS сейчас такое безумие творится, что подобная статья смотрится уже устаревшей. Ну и пункт 3 какой-то стремный в сегодняшних реалиях. Gulp многие пытаются на свалку отправить, не говоря уже про Grunt.
    Бекендщикам сейчас конечно сложновато погружаться в JS — много времени потребуется на всю эту «хипстоту» :)


    1. Kaigorodov
      19.04.2016 17:56
      +1

      Я тоже был удивлён, когда не нашёл подобных статей. Но когда я стартанул с JS, мне очень не хватало такого.


      window[config.globalName] = yourApiVar;

      Это на самом деле классика. Во многих библиотечках используется. И хипстерских, и супер-оптимизированных. Необходимость.


      И что вместо Gulp и Grunt? Прочитал сейчас пару статей, интересно. Рекомендуют чистый npm и прочее.


      1. stas404
        19.04.2016 18:47
        +2

        Много чего сейчас есть. Например, определенной (и вполне заслуженной) популярностью пользуется сборка на webpack с использованием npm-скриптов (для действий не покрываемых сборщиком).
        «Накладные расходы» на поддержку нормальной модульности минимальны, призывы «только конкатенация» непонятны.


      1. prolis
        20.04.2016 08:28

        Староверы собирают на Apache Ant.


      1. shoomyst
        20.04.2016 18:08

        Я кстати не пытался призвать всех отказываться от Gulp. Некоторая агитация по npm scripts имеется, но думаю Gulp еще поживет. Причины отказа от него несколько надуманы, но для мелких проектов возможно да, нет особого смысла его тянуть.


    1. Kaigorodov
      20.04.2016 01:14

      Рекомендуют книжку https://www.manning.com/books/third-party-javascript


  1. Per_Ardua
    19.04.2016 17:57

    Спасибо за статью.

    Было бы интересно почитать по сокетам. По ним довольно много материала. Но так, чтобы коротко и по делу я не видел. Может плохо искал, каюсь.


    1. Kaigorodov
      19.04.2016 18:21

      Хорошая тема, если будет чем поделиться, напишу.


  1. gearbox
    19.04.2016 20:46

    Давно собираюсь с силами написать статью, но руки не доходят, так что делюсь идеей, может заинтересует. Напишите про контексты, в которых может оказаться джисер и в чем их разница —
    — код в странице открытой с сайта
    — во фрейме
    — на странице, открытой локально с диска
    — в расширении (popup,  background, content) — для хрома(оперы), файрфокса (да, движок хромиум, но обвес там совсем другой),
    — далее вебворкеры
    — да, для firefox-а — bootstrap.js в интересном контексте запускается, там не все модули доступны, я например до сторейджа (simple-storage) так и не смог достучаться
    — pac-файлы тоже, хоть и стандарт есть — везде в разном контексте запускаются (в хроме это не совсем песочница, по крайней мере регексы доступны, хотя по стандарту не должны, а в лисе даже описанные в стандарте функции недоступны)

    Так, навскидку вроде все распространенное охватил на клиенте. В общем если займетесь — могу даже подсобить.


    1. stas404
      19.04.2016 21:17

      Я тут заканчиваю небольшой бойлерплейт для создания chrome-расширений на базе webpack с hot module replacement и автоматическим chrome.runtime.reload() при компиляции, есть мысли по завершении статейку написать.
      Про контексты в расширениях — это довольно значимый пунктик, в частности, доступ к chrome API из injected-скриптов, доступ к js-окружению на странице, контекст применения HMR-обновлений (т.к. это не обычная страница, пришлось немного подпилить механизм hot-апдейтов) и т.д. нюансы.
      Если будут желающие поучаствовать словом и делом — буду рад.


      1. Kaigorodov
        19.04.2016 23:19

        На самом деле всё началось именно с chrome extension, а потом функциональность перекочевала в injected script, потом во встраиваемый скрипт.


        "доступ к chrome API из injected-скриптов" — я в расширении реализовал специальный channel — он заворачивает функции и шлёт события и на другой стороне вызывает реализацию этих фунций. Вы сделали похоже?


        Буду иметь ввиду. Напишите, по крайней мере интересно посмотреть: dmitry@kuoll.com


      1. gearbox
        20.04.2016 09:31

        Да, могу накидать хинтов по портированию этого дела в firefox. Созреете — стукните в личку.


    1. Kaigorodov
      19.04.2016 23:19

      Да, хороший вопрос, вполне достойный отдельной статьи.


  1. acupofspirt
    19.04.2016 23:01

    Расскажите про удобную работу с куками, ибо это «самое убогое API, которое пролезло в JS». Вы скажете что мол вон, есть localStorage, но бывает когда использование кукисов необходимо.


    1. Kaigorodov
      19.04.2016 23:02

      Да, хороший вопрос. И надо ещё подумать когда cookies, а когда localStorage.


  1. pov
    19.04.2016 23:02
    +1

    config = {};
    sharedState = {};

    Вы действительно хотите сделать эти 2 переменные видимыми снаружи?
    Почему не сделать этого явно?
    Откуда берется функция start, которую вы вызываете?


    1. Kaigorodov
      19.04.2016 23:04

      config и sharedState не видны снаружи, они же ведь объявлены внутри функции.


      Функция start() — из пункта 5; её задача — явная инициализация всего API.


      1. pov
        19.04.2016 23:43
        +3

        >config и sharedState не видны снаружи, они же ведь объявлены внутри функции.

        Вы ошибаетесь.

        (function(){
        config={test:'test'};
        })();

        console.log(config);


        1. Kaigorodov
          20.04.2016 00:52

          Исправил.


        1. berman
          20.04.2016 01:30
          +1

          А если бы код был в strict mode, то проблема бы даже не появилась


          1. Kaigorodov
            28.04.2016 15:05

            Это же псевдо-код, там реализации функций нет и прочего.


            А так все модули обязательно strict mode. Добавляю к шаблону, чтобы было очевидно.


  1. xGromMx
    19.04.2016 23:25
    +1

    Правильная система модулей? ES next import/export, не, не слышал :D


    1. dannyzubarev
      20.04.2016 00:05

      Всё-таки, если стоит вопрос возможности работы библиотеки в среде без поддержки ES2015, UMD – наиболее удобный вариант, т.к. поддерживает AMD, CommonJS, vanilla definition :)
      https://github.com/umdjs/umd


      1. xGromMx
        20.04.2016 00:17

        babel + webpack, typescript + webpack и webpack делает umd из коробки


        1. dannyzubarev
          20.04.2016 00:39

          В Webpack нельзя выбрать вариант экспорта (см. Variations в репозитории)


  1. Finom
    20.04.2016 09:11

    Ответ очевиден: когда вы сконкатенируете код таких модулей, всё будет работать безо всяких библиотечек для модулей.

    Серьезно?


    1. Kaigorodov
      28.04.2016 15:00

      Если есть возражения, мы готовы их услышать.


      1. Finom
        28.04.2016 16:29

        Окей, я даже немного растерялся. Webpack, Browserify, Systemjs, Reflow (пусть меня поправят, если я что-то упустил) не требуют никаких "библиотечек для модулей". Только бандл проекта, использующего RequireJS требует библиотеку, имплементирующую AMD, например Almond (это всего 1К оверхеда), и то, даже с этим древним бандлером можно воспользоваться не менее древним AMD Clean. Ваши знания явно немного устарели.


  1. AlexPTS
    20.04.2016 10:59
    +2

    Хотело бы добавить по пункту 4 и 5.

    Паттерн «модуль» и паттерн «открытия модуля» однозначно стоит применять. Но на мой взгляд нужно возвращать не инстанс модуля, а конструктор модуля. Это позволит из внешнего кода создать инстанс модуля самостоятельно и пробросить в функцию конструктор модуля нужные параметры или зависимости. Сконфигурировать модуль под место его работы.

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


    1. Kaigorodov
      28.04.2016 15:00

      Да, хорошая идея.