Наверняка вы сталкивались с принципами (пусть и противоречивыми) о написании модулей и классов на JavaScript. Когда мне понадобилось написать встраиваемый в веб-страницу cкрипт, который предоставляет API для работы определённого сервиса, то я не смог найти достойных рекомендаций о проектировании подобных скриптов.
Итак, вот (довольно очевидные) требования к скрипту, с которыми я столкнулся:
- он будет встраиваться в страницы сторонних веб-приложений;
- он должен выполнять свою работу качественно;
- он должен загружаться быстро;
- он не должен (непредсказуемо) влиять на работу веб-приложения;
- ?должен соответствовать требованиям безопасности;
- … // много чего ещё :)
Из реальной практики родились принципы, описанные ниже. Это не полностью уникальные идеи, а скорее сборка лучших практик, которых я видел в чужих решениях, например в библиотечках 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)
ybondarets
19.04.2016 16:35+2Работа с LocalStorage будет очень интересной, попробуй сделать поддержку версионности и устареваня данных. Круто будет записывать обьектыв LocalStorage через свою обертку при наличии коей ненужно будет думать у тебя там скаляр или обьект.
P.S. Поддерживаю такую реализацию модулей. Она позволяет имплементировать приватность, что тяжело при помощи других методов.
Спасибо за труды при написанни статьи, коротко и по сути.Kaigorodov
19.04.2016 16:41Да, хранение данных на клиенте — важная штука; думаю, чем умнее будут становиться веб-приложения, тем активнее будет использоваться localStorage.
shoomyst
19.04.2016 16:58+9С приветом из 2014-го? :)
В JS сейчас такое безумие творится, что подобная статья смотрится уже устаревшей. Ну и пункт 3 какой-то стремный в сегодняшних реалиях. Gulp многие пытаются на свалку отправить, не говоря уже про Grunt.
Бекендщикам сейчас конечно сложновато погружаться в JS — много времени потребуется на всю эту «хипстоту» :)Kaigorodov
19.04.2016 17:56+1Я тоже был удивлён, когда не нашёл подобных статей. Но когда я стартанул с JS, мне очень не хватало такого.
window[config.globalName] = yourApiVar;
Это на самом деле классика. Во многих библиотечках используется. И хипстерских, и супер-оптимизированных. Необходимость.
И что вместо Gulp и Grunt? Прочитал сейчас пару статей, интересно. Рекомендуют чистый npm и прочее.
stas404
19.04.2016 18:47+2Много чего сейчас есть. Например, определенной (и вполне заслуженной) популярностью пользуется сборка на webpack с использованием npm-скриптов (для действий не покрываемых сборщиком).
«Накладные расходы» на поддержку нормальной модульности минимальны, призывы «только конкатенация» непонятны.
shoomyst
20.04.2016 18:08Я кстати не пытался призвать всех отказываться от Gulp. Некоторая агитация по npm scripts имеется, но думаю Gulp еще поживет. Причины отказа от него несколько надуманы, но для мелких проектов возможно да, нет особого смысла его тянуть.
Per_Ardua
19.04.2016 17:57Спасибо за статью.
Было бы интересно почитать по сокетам. По ним довольно много материала. Но так, чтобы коротко и по делу я не видел. Может плохо искал, каюсь.
gearbox
19.04.2016 20:46Давно собираюсь с силами написать статью, но руки не доходят, так что делюсь идеей, может заинтересует. Напишите про контексты, в которых может оказаться джисер и в чем их разница —
— код в странице открытой с сайта
— во фрейме
— на странице, открытой локально с диска
— в расширении (popup, background, content) — для хрома(оперы), файрфокса (да, движок хромиум, но обвес там совсем другой),
— далее вебворкеры
— да, для firefox-а — bootstrap.js в интересном контексте запускается, там не все модули доступны, я например до сторейджа (simple-storage) так и не смог достучаться
— pac-файлы тоже, хоть и стандарт есть — везде в разном контексте запускаются (в хроме это не совсем песочница, по крайней мере регексы доступны, хотя по стандарту не должны, а в лисе даже описанные в стандарте функции недоступны)
Так, навскидку вроде все распространенное охватил на клиенте. В общем если займетесь — могу даже подсобить.stas404
19.04.2016 21:17Я тут заканчиваю небольшой бойлерплейт для создания chrome-расширений на базе webpack с hot module replacement и автоматическим chrome.runtime.reload() при компиляции, есть мысли по завершении статейку написать.
Про контексты в расширениях — это довольно значимый пунктик, в частности, доступ к chrome API из injected-скриптов, доступ к js-окружению на странице, контекст применения HMR-обновлений (т.к. это не обычная страница, пришлось немного подпилить механизм hot-апдейтов) и т.д. нюансы.
Если будут желающие поучаствовать словом и делом — буду рад.Kaigorodov
19.04.2016 23:19На самом деле всё началось именно с chrome extension, а потом функциональность перекочевала в injected script, потом во встраиваемый скрипт.
"доступ к chrome API из injected-скриптов" — я в расширении реализовал специальный channel — он заворачивает функции и шлёт события и на другой стороне вызывает реализацию этих фунций. Вы сделали похоже?
Буду иметь ввиду. Напишите, по крайней мере интересно посмотреть: dmitry@kuoll.com
gearbox
20.04.2016 09:31Да, могу накидать хинтов по портированию этого дела в firefox. Созреете — стукните в личку.
acupofspirt
19.04.2016 23:01Расскажите про удобную работу с куками, ибо это «самое убогое API, которое пролезло в JS». Вы скажете что мол вон, есть localStorage, но бывает когда использование кукисов необходимо.
Kaigorodov
19.04.2016 23:02Да, хороший вопрос. И надо ещё подумать когда cookies, а когда localStorage.
pov
19.04.2016 23:02+1config = {};
sharedState = {};
Вы действительно хотите сделать эти 2 переменные видимыми снаружи?
Почему не сделать этого явно?
Откуда берется функция start, которую вы вызываете?Kaigorodov
19.04.2016 23:04config и sharedState не видны снаружи, они же ведь объявлены внутри функции.
Функция start() — из пункта 5; её задача — явная инициализация всего API.
pov
19.04.2016 23:43+3>config и sharedState не видны снаружи, они же ведь объявлены внутри функции.
Вы ошибаетесь.
(function(){
config={test:'test'};
})();
console.log(config);
berman
20.04.2016 01:30+1А если бы код был в strict mode, то проблема бы даже не появилась
Kaigorodov
28.04.2016 15:05Это же псевдо-код, там реализации функций нет и прочего.
А так все модули обязательно strict mode. Добавляю к шаблону, чтобы было очевидно.
xGromMx
19.04.2016 23:25+1Правильная система модулей? ES next import/export, не, не слышал :D
dannyzubarev
20.04.2016 00:05Всё-таки, если стоит вопрос возможности работы библиотеки в среде без поддержки ES2015, UMD – наиболее удобный вариант, т.к. поддерживает AMD, CommonJS, vanilla definition :)
https://github.com/umdjs/umdxGromMx
20.04.2016 00:17babel + webpack, typescript + webpack и webpack делает umd из коробки
dannyzubarev
20.04.2016 00:39В Webpack нельзя выбрать вариант экспорта (см. Variations в репозитории)
Finom
20.04.2016 09:11Ответ очевиден: когда вы сконкатенируете код таких модулей, всё будет работать безо всяких библиотечек для модулей.
Серьезно?
Kaigorodov
28.04.2016 15:00Если есть возражения, мы готовы их услышать.
Finom
28.04.2016 16:29Окей, я даже немного растерялся. Webpack, Browserify, Systemjs, Reflow (пусть меня поправят, если я что-то упустил) не требуют никаких "библиотечек для модулей". Только бандл проекта, использующего RequireJS требует библиотеку, имплементирующую AMD, например Almond (это всего 1К оверхеда), и то, даже с этим древним бандлером можно воспользоваться не менее древним AMD Clean. Ваши знания явно немного устарели.
AlexPTS
20.04.2016 10:59+2Хотело бы добавить по пункту 4 и 5.
Паттерн «модуль» и паттерн «открытия модуля» однозначно стоит применять. Но на мой взгляд нужно возвращать не инстанс модуля, а конструктор модуля. Это позволит из внешнего кода создать инстанс модуля самостоятельно и пробросить в функцию конструктор модуля нужные параметры или зависимости. Сконфигурировать модуль под место его работы.
Таким образом можно использовать несколько экземпляров одного модуля на странице и каждый настроить на свой вкус.
ivsol
>>> Напишите, какие темы наиболее интересны.
Типизированные массивы.
Kaigorodov
Подумаю… А вам интересно это со стороны производительности, или чтения из сетевых потоков, или по какой-нибудь ещё причине?
ivsol
И то и другое.
С ArrayBuffer в принципе всё понятно, хотелось бы посмотреть на класс DataView на примере.
Но и ArrayBuffer тоже будет не лишним.