Как мы заставили npm-пакеты работать в браузере
- пятница, 20 октября 2017 г. в 03:13:06
В ходе начальной разработки проекта CodeSandbox я всегда игнорировал поддержку npm-зависимостей. Я думал, что невозможно установить в браузер произвольное, случайное количество пакетов, мой мозг просто отказывался об этом думать.
Сегодня поддержка npm — одна из определяющих возможностей CodeSandbox, так что как-то нам удалось это реализовать. Чтобы фича работала при любых сценариях, пришлось сделать немало итераций, много раз переписывая код, и даже сегодня мы всё ещё можем улучшить логику. Я расскажу, с чего у нас начиналась поддержка npm, что имеем сегодня и что можем сделать для её улучшения.
Я просто не знал, как за это браться, так что начал с очень простой версии поддержки npm:
Первая версия, импорт стилизованных компонентов и React (25 ноября 2016 года)
Эта версия была очень простой. Настолько, что на самом деле поддержки-то и не было, я просто локально установил зависимости и для каждого вызова сделал заглушку в виде уже установленной зависимости. Конечно, масштабируемостью до 400 тысяч пакетов разных версий тут и не пахнет.
Хотя эта версия бесполезна, было приятно увидеть, что я заставил работать в sandbox-среде хотя бы две зависимости.
Первой версией я был удовлетворён, и мне казалось, что её достаточно для MVP (первого релиза CodeSandbox). Я не представлял, что вообще можно без применения магии установить любую зависимость. До тех пор пока не наткнулся на https://esnextb.in/. Ребята уже поддерживали любые зависимости из npm, их достаточно было определить в package.json — и всё волшебным образом работало!
Это стало для меня большим уроком. Я даже в мыслях не покушался на такую поддержку npm, потому что считал её нереальной. Но, увидев вживую доказательство реальности, я начал много над этим размышлять. Сначала нужно было изучить возможности, прежде чем отбрасывать идею.
В своих раздумьях я слишком усложнил задачу. Моя первая версия не помещалась в уме, так что я нарисовал схему:
Первая идея, вероятно, ошибочная
У этого подхода было одно преимущество: настоящая реализация гораздо проще, чем ожидалось!
Я узнал, что плагин WebPack DLL мог собирать зависимости в пачку и выдавать JS-пакет (bundle) с манифестом. Этот манифест выглядел так:
{
"name": "dll_bundle",
"content": {
"./node_modules/fbjs/lib/emptyFunction.js": 0,
"./node_modules/fbjs/lib/invariant.js": 1,
"./node_modules/fbjs/lib/warning.js": 2,
"./node_modules/react": 3,
"./node_modules/fbjs/lib/emptyObject.js": 4,
"./node_modules/object-assign/index.js": 5,
"./node_modules/prop-types/checkPropTypes.js": 6,
"./node_modules/prop-types/lib/ReactPropTypesSecret.js": 7,
"./node_modules/react/cjs/react.development.js": 8
}
}
Каждый путь сопоставлен с ID модуля. Если мне потребуется react, то достаточно вызвать dll_bundle(3)
, и я получу React! Для нас это было идеально, так что я придумал вот такую реальную систему:
Исходный код сервиса лежит тут. Сервис также содержит код для публикации любых песочниц в npm, позже мы отказались от этой функции
Для каждого запроса к упаковщику я создавал новую директорию в /tmp/:hash
, запускал yarn add ${dependencyList}
и позволял WebPack собирать пакет. Результат я сохранял в gcloud в качестве кеша. Получается гораздо проще, чем на схеме, в основном потому, что я заменил установку зависимостей на Yarn и собирал в пакеты с помощью WebPack.
При загрузке песочницы, прежде чем проводить выполнение, мы сначала проверяли наличие манифеста и пакета. В ходе анализа мы для каждой зависимости вызывали dll_bundle(:id)
. Это решение прекрасно работало, я создал первую версию с нормальной поддержкой npm-зависимостей!
Ура! У нас интерфейс в стиле Material Design и динамически исполняемый React! (24 декабря 2016 года)
У системы всё ещё было большое ограничение: она не поддерживала файлы, отсутствовавшие в графе зависимостей WebPack. Это означает, что, к примеру, require('react-icons/lib/fa/fa-beer')
работать не станет, потому что он никогда не будет в первую очередь запрошен входной точкой зависимости.
Всё же я создал релиз CodeSandbox с такой поддержкой и связался с автором WebPackBin Кристианом Альфони. Для поддержки npm-зависимостей мы использовали очень похожие системы и столкнулись с одинаковыми ограничениями. Поэтому решили объединить усилия и создать абсолютный упаковщик!
«Абсолютный» упаковщик получил ту же функциональность, что и предыдущий, за исключением созданного Кристианом алгоритма, который добавлял файлы в пакет в зависимости от их важности. Мы вручную добавляли входные точки, чтобы удостовериться, что WebPack тоже упакует эти файлы. После многократных настроек системы она стала работать при любой (?) комбинации. Так что мы уже могли запрашивать React-иконки и CSS-файлы.
Новая система получила архитектурный апгрейд: у нас был лишь один dll-сервис, обслуживающий балансировщик нагрузки и кеш. Упаковкой занимались несколько упаковщиков, которые могли добавляться динамически.
Мы хотели, чтобы наш сервис упаковки стал доступным для всех. Поэтому сделали сайт, на котором объяснялась работа сервиса и варианты использования. Это принесло нам известность, нас даже упомянули в блоге CodePen!
Но у «абсолютного» упаковщика были некоторые ограничения и недостатки. По мере роста популярности экспоненциально росли и затраты, и мы кешировали по комбинации пакетов. Это означает, что при добавлении зависимости приходилось пересобирать всю комбинацию.
Мне всегда хотелось попробовать эту классную технологию — бессерверную (serverless) обработку. С её помощью можно определять функцию, которая будет исполняться по запросу.
Она запустится, обработает запрос и через какое-то время убьёт себя. Это означает очень высокую масштабируемость: если получаете тысячу одновременных запросов, то можете мгновенно запустить тысячу серверов. Но при этом платите только за реальное время работы серверов.
Звучит идеально для нашего сервиса: он не работает постоянно, и нам нужна высокая согласованность на случай одновременного получения многочисленных запросов. Так что я начал экспериментировать с фреймворком с соответствующим названием Serverless.
Изменение нашего сервиса шло спокойно (благодаря Serverless!), через два дня у меня была рабочая версия. Я создал три serverless-функции:
Я запустил новый сервис рядом со старым, всё прекрасно работало! Мы спрогнозировали расходы на уровне 0,18 доллара в месяц (по сравнению с предыдущими 100 долларами), а время отклика улучшилось на 40—700 %.
Через несколько дней я заметил одно ограничение: лямбда-функция имела всего 500 Мб пространства на диске. Значит, некоторые комбинации зависимостей не могли быть установлены. Это было недопустимо, и я снова вернулся к рисованию схем.
Прошла пара месяцев, и я выпустил новый упаковщик для CodeSandbox. Он был очень мощный и поддерживал больше библиотек наподобие Vue и Preact. Благодаря этому у нас появились интересные запросы. Например: если вы хотите использовать React-библиотеки в Preact, то нужно связать require('react')
с require('preact-compat')
. Для Vue вам может понадобиться разрешить (resolve) @/components/App.vue
для sandbox-файлов. Наш упаковщик не делает этого для зависимостей, а другие делают.
Я начал думать, что, быть может, мы переложим задачу по упаковке на упаковщик браузера. Если просто отправлять соответствующие файлы в браузер, то в результате его упаковщик будет собирать зависимости в пакеты. Так будет быстрее, потому что мы обрабатываем не весь пакет, а лишь часть.
У этого подхода большое преимущество: мы можем независимо устанавливать и кешировать зависимости. Или просто объединять файлы зависимостей на клиенте. Это означает, что, если вы запрашиваете новую зависимость поверх существующих, нам нужно лишь собрать файлы для новой зависимости! Это решает проблему 500 Мб для AWS Lambda, потому что мы устанавливаем лишь одну зависимость. Так что можно выкинуть WebPack из упаковщика, потому что он теперь полностью отвечает за вычисление релевантных файлов и их отправку.
Распараллеливание упаковки наших зависимостей
Примечание: можно выкинуть упаковщик и динамически запрашивать каждый файл из unpkg.com. Пожалуй, это быстрее моего подхода. Но я решил пока оставить упаковщик (как минимум для редактора), потому что хочу предоставлять офлайн-поддержку. Это возможно, лишь если у вас есть все возможные релевантные файлы.
Запрашивая комбинацию зависимостей, мы сначала проверяем, хранится ли она уже в S3. Если нет, то запрашиваем комбинацию у API-сервиса, а тот запрашивает у всех упаковщиков отдельно по каждой зависимости. Если получаем в ответ 200 OK, то опять запрашиваем S3.
Упаковщик устанавливает зависимости с помощью Yarn и, обходя AST всех файлов в директории входной точки, находит все релевантные файлы. Он ищет выражения require и добавляет в список файлов. Это делается рекурсивно, и в результате мы получаем граф зависимостей. Пример выходных данных (react@latest
):
{
"aliases": {
"asap": "asap/browser-asap.js",
"asap/asap": "asap/browser-asap.js",
"asap/asap.js": "asap/browser-asap.js",
"asap/raw": "asap/browser-raw.js",
"asap/raw.js": "asap/browser-raw.js",
"asap/test/domain.js": "asap/test/browser-domain.js",
"core-js": "core-js/index.js",
"encoding": "encoding/lib/encoding.js",
"fbjs": "fbjs/index.js",
"iconv-lite": "iconv-lite/lib/index.js",
"iconv-lite/extend-node": false,
"iconv-lite/streams": false,
"is-stream": "is-stream/index.js",
"isomorphic-fetch": "isomorphic-fetch/fetch-npm-browserify.js",
"js-tokens": "js-tokens/index.js",
"loose-envify": "loose-envify/index.js",
"node-fetch": "node-fetch/index.js",
"object-assign": "object-assign/index.js",
"promise": "promise/index.js",
"prop-types": "prop-types/index.js",
"react": "react/index.js",
"setimmediate": "setimmediate/setImmediate.js",
"ua-parser-js": "ua-parser-js/src/ua-parser.js",
"whatwg-fetch": "whatwg-fetch/fetch.js"
},
"contents": {
"react/index.js": {
"requires": [
"./cjs/react.development.js"
],
"content": "/* code */"
},
"object-assign/index.js": {
"requires": [],
"content": "/* code */"
},
"fbjs/lib/emptyObject.js": {
"requires": [],
"content": "/* code */"
},
"fbjs/lib/invariant.js": {
"requires": [],
"content": "/* code */"
},
"fbjs/lib/emptyFunction.js": {
"requires": [],
"content": "/* code */"
},
"react/cjs/react.development.js": {
"requires": [
"object-assign",
"fbjs/lib/warning",
"fbjs/lib/emptyObject",
"fbjs/lib/invariant",
"fbjs/lib/emptyFunction",
"prop-types/checkPropTypes"
],
"content": "/* code */"
},
"fbjs/lib/warning.js": {
"requires": [
"./emptyFunction"
],
"content": "/* code */"
},
"prop-types/checkPropTypes.js": {
"requires": [
"fbjs/lib/invariant",
"fbjs/lib/warning",
"./lib/ReactPropTypesSecret"
],
"content": "/* code */"
},
"prop-types/lib/ReactPropTypesSecret.js": {
"requires": [],
"content": "/* code */"
},
"react/package.json": {
"requires": [],
"content": "/* code */"
}
},
"dependency": {
"name": "react",
"version": "16.0.0"
},
"dependencyDependencies": {
"asap": "2.0.6",
"core-js": "1.2.7",
"encoding": "0.1.12",
"fbjs": "0.8.16",
"iconv-lite": "0.4.19",
"is-stream": "1.1.0",
"isomorphic-fetch": "2.2.1",
"js-tokens": "3.0.2",
"loose-envify": "1.3.1",
"node-fetch": "1.7.3",
"object-assign": "4.1.1",
"promise": "7.3.1",
"prop-types": "15.6.0",
"setimmediate": "1.0.5",
"ua-parser-js": "0.7.14",
"whatwg-fetch": "2.0.3"
}
}
Я развернул новый упаковщик 5 октября, и за два дня мы заплатили смехотворные 0,02 доллара! И это за создание кеша. Гигантская экономия по сравнению со 100 долларами в месяц.
Новую комбинацию зависимостей вы можете получить за 3 секунды. Любую комбинацию. На старой системе иногда это занимало минуту. Если комбинация закеширована, вы получите её через 50 миллисекунд при быстром подключении. Мы кешируем с помощью Amazon Cloudfront по всему миру. Также наша песочница работает быстрее, потому что теперь мы парсим и исполняем только релевантные JS-файлы.
Наш упаковщик теперь обрабатывает зависимости, словно это локальные файлы. Это означает, что наши отслеживания стека ошибок (error stack traces) стали гораздо чище, теперь мы можем включать любые файлы из зависимостей (.scss, .vue и т. д.) и легко поддерживать алиасы. Всё это работает так, словно зависимости установлены локально.
Я начал использовать новый упаковщик рядом со старым, чтобы построить кеш. За два дня было закешировано 2000 разных комбинаций и 1400 разных зависимостей. Я хочу интенсивно протестировать новую версию, прежде чем полностью перейти на неё. Можете попробовать, включив её в своих настройках.
Исходный код. Он пока непригляден, скоро я его почищу, напишу README.md и т. д.
Я очень впечатлён этой технологией, она невероятно облегчает масштабирование и управление серверами. Единственное, что меня всегда удерживало, — это очень сложная настройка, но разработчики из serverless.com сильно её упростили. Очень благодарен за их работу, думаю, что serverless — это будущее многих форм приложений.
Мы всё ещё можем во многом улучшить нашу систему. Я хочу исследовать динамические запросы, требуемые во встраивании и офлайн-сохранении. Трудно соблюсти баланс, но это должно быть возможно. Можно начать независимо кешировать зависимости в браузере, опираясь на то, что он позволяет делать. В этом случае вам иногда даже не понадобится скачивать новые зависимости при посещении новой песочницы с другой комбинацией зависимостей. Также я хочу лучше исследовать разрешение зависимостей. В новой системе есть вероятность конфликта версий, которую я хочу исключить перед отказом от старой версии.
В любом случае я очень доволен результатом и намерен работать над нововведениями для CodeSandbox!