Предыстория
Как-то субботним вечером я сидел и искал способы сборки UI-Kit с помощью webpack. В качестве демо UI-kit я пользуюсь styleguidst. Конечно же, webpack умный и все файлы, которые есть в рабочем каталоге он запихивает в один бандл и оттуда всё крутится и вертится.
Я создал файл entry.js, импортнул туда все компоненты, затем оттуда же экспортнул. Вроде всё ок.
import Button from 'components/Button'import Dropdown from 'components/Dropdown 'export { Button, Dropdown }
И после сборки всего этого, я получил на выходе output.js, в котором как и ожидалось было всё все компоненты в куче в одном файле. Тут возник вопрос:
А как мне собрать все кнопочки, дропдауны и прочее по отдельности, что бы импортировать в других проектах?А я ведь хочу это ещё и в npm залить как пакет.
Хм Поехали по порядку.
Multiple entries
Конечно, первая идея, которая может прийти в голову спарсить все компоненты в рабочем каталоге. Пришлось немножко погуглить про парсинг файлов, т.к с NodeJS я работаю очень редко. Нашёл такую штуку, как glob.
Погнали писать multiple entries.
const { basename, join, resolve } = require("path");const glob = require("glob");const componentFileRegEx = /\.(j|t)s(x)?$/;const sassFileRegEx = /\s[ac]ss$/;const getComponentsEntries = (pattern) => { const entries = {}; glob.sync(pattern).forEach(file => { const outFile = basename (file); const entryName = outFile.replace(componentFileRegEx, ""); entries[entryName] = join(__dirname, file); }) return entries;}module.exports = { entry: getComponentsEntries("./components/**/*.tsx"), output: { filename: "[name].js", path: resolve(__dirname, "build") }, module: { rules: [ { test: componentFileRegEx, loader: "babel-loader", exclude: /node_modules/ }, { test: sassFileRegEx, use: ["style-loader", "css-loader", "sass-loader"] } ] } resolve: { extensions: [".js", ".ts", ".tsx", ".jsx"], alias: { components: resolve(__dirname, "components") } }}
Готово. Собираем.
После сборки в каталог build упало 2 файла Button.js, Dropdown.js заглядываем внутрь. Внутри лицензии react.production.min.js, тяжелочитаемый минимизированный код, и куча всякой фигни. Окей, попробуем использовать кнопку.
В демо файле кнопки меняем импорт на импорт из каталога build.
Вот так выглядит простая демка кнопки в styleguidist Button.md
```javascriptimport Button from '../../build/Button'<Button>Кнопка</Button>```
Заходим посмотреть на кнопочку иии
Error: Element type is invalid:
expected a string (for built-in components) or a class/function
(for composite components) but got: object.
На этом этапе уже отпали идея и желание собирать через webpack.
Ищем другой путь сборки без webpack
Идём за помощью к бабелю без вебпака. Пишем скрипт в package.json, указываем файл конфига, расширения, директорию где лежат компоненты, директорию куда собрать:
{ //...package.json всякие штуки-дрюки о которых обычно не паримся scripts: { "build": "babel --config-file ./.babelrc --extensions '.jsx, .tsx' ./components --out-dir ./build" }}
запускаем:
npm run build
Вуаля, у нас в каталоге build появились 2 файла Button.js, Dropdown.js, внутри файлов красиво оформленный ванильный js + некоторые полифилы и одинокий requre(styles.scss). Явно это не сработает в демке, удаляем импорт стилей(в этот момент меня гложила надежда, что я найду плагин для транспила scss), собираем ещё раз.
После сборки у нас остался читсый JS. Повторяем попытку интеграции собранного компонента в styleguidist:
```javascriptimport Button from '../../build/Button'<Button>Кнопка</Button>```
Скомпилировалось работает. Только кнопочка без стилей.
Ищем плагин для транспила scss/sass
Да, сборка компонентов работает, компоненты работают, можно собирать, паблишить в npm или свой рабочий нехус(nexus). Ещё бы только стили сохранить Окей, снова гугл нам поможет (нет).
Гугления плагинов не принесли мне каких-то результатов. Один плагин генерирует строку из стилей, другой вообще не работает да ещё требует импорта вида:import styles from styles.scss (никогда так не импортировал стили и мое личное мнение не надо так делать, но о вкусах не спорят)
Единственная надежда была на этот плагин: babel-plugin-transform-scss-import-to-string, но он просто генерирует строку из стилей (а я уже говорил выше. Блин...). Дальше всё стало ещё хуже, я дошел до 6 страницы в гугле (а на часах уже 3 утра). Да и вариантов особо уже не будет что-то найти. Да и думать то нечего либо webpack + sass-loader, которые хреново это делают и не для моего случая, либо ШТО-ТО ДРУГОЕ. Нервы Я решил немного передохнуть, попить чай, спать всё равно не хочется. Пока делал чай, идея написать плагин для транспила scss/sass все больше и больше влетала в мою голову. Пока мешал сахарочек, редкие звоны ложки в моей голове отдавались эхом: Пиши плааагин. Ок, решено, буду писать плагин.
Плагин не найден. Пишем сами
За основу своего плагина я взял babel-plugin-transform-scss-import-to-string, упомянутый выше. Я прекрасно понимал, что сейчас будет геморрой с AST деревом, и прочими хитростями. Ладно, поехали.
Делаем предварительные подготовочки. Нам нужны node-sass и path, а так же регулярочки для файлов и расширений. Идея такая:
- Получаем из строки импорта путь до файла со стилями
- Парсим через node-sass стили в строку (спасибо babel-plugin-transform-scss-import-to-string)
- Создаем style теги по каждому из импортов (плагин бабеля запускается на каждом импорте)
- Надо как-то идентифицировать созданный стиль, что бы не
накидывать одно и то же на каждый чих hot-reload. Впихнем ему
какой-нибудь аттрибут (data-sass-component) со значением текущего
файла и названием файла стилей. Будет что-то вроде этого:
<style data-sass-component="Button_style"> .button { display: flex; } </style>
В целях разработки плагина и тестирования на проекте, на уровне с каталогом components я создал babel-plugin-transform-scss каталог, запихнул туда package.json и запихнул туда каталог lib, а в него уже закинул index.js.
Что бы вы были вкурсе конфиг бабеля лезет за плагином, который указан в директиве main в package.json, для этого пришлось его запихать.Указываем:
{ //...package.json опять всякие штуки-дрюки о которых обычно не паримся, да и кроме main ничего нету main: "lib/index.js"}
Затем, пихаем в конфиг бабеля (.babelrc) путь до плагина:
{ //Тут всякие пресеты plugins: [ "./babel-plugin-transform-scss" //тут остальные плагины для сборки ]}
А теперь напихиваем в index.js магию.
Первый этап проверка на импорт именно scss или sass файла, получение имени импортируемых файлов, получение имени самого js файла(компонента), транспил в css строку scss или sass. Подрубаемся через WebStorm к npm run build через дебаггер, ставим точки останова, смотрим аргументы path и state и выуживаем имена файлов, обрабатываем руглярочками:
const { resolve, dirname, join } = require("path");const { renderSync } = require("node-sass");const regexps = { sassFile: /([A-Za-z0-9]+).s[ac]ss/g, sassExt: /\.s[ac]ss$/, currentFile: /([A-Za-z0-9]+).(t|j)s(x)/g, currentFileExt: /.(t|j)s(x)/g};function transformScss(babel) { const { types: t } = babel; return { name: "babel-plugin-transform-scss", visitor: { ImportDeclaration(path, state) { /** * Проверяем, содержит ли текущий файл scss/sass расширения в импорте */ if (!regexps.sassExt.test(path.node.source.value)) return; const sassFileNameMatch = path.node.source.value.match( regexps.sassFile ); /** * Получаем имя текущего scss/sass файла и текущего js файла */ const sassFileName = sassFileNameMatch[0].replace(regexps.sassExt, ""); const file = this.filename.match(regexps.currentFile); const filename = `${file[0].replace( regexps.currentFileExt, "" )}_${sassFileName}`; /** * * Получаем полный путь до scss/sass файла, транспилим в строку css */ const scssFileDirectory = resolve(dirname(state.file.opts.filename)); const fullScssFilePath = join( scssFileDirectory, path.node.source.value ); const projectRoot = process.cwd(); const nodeModulesPath = join(projectRoot, "node_modules"); const sassDefaults = { file: fullScssFilePath, sourceMap: false, includePaths: [nodeModulesPath, scssFileDirectory, projectRoot] }; const sassResult = renderSync({ ...sassDefaults, ...state.opts }); const transpiledContent = sassResult.css.toString() || ""; } }}
Fire. Первый успех, получена строка css в transpiledContent. Дальше самое страшное лезем в babeljs.io/docs/en/babel-types#api за API по AST дереву. Лезем в astexplorer.net пишем там код запихивания в head документа стилей.
В astexplorer.net пишем Self-Invoking функцию, которая будет вызываться на месте импорта стиля:
(function(){ const styles = "generated transpiledContent" // ".button {/n display: flex; /n}/n" const fileName = "generated_attributeValue" //Button_style const element = document.querySelector("style[data-sass-component='fileName']") if(!element){ const styleBlock = document.createElement("style") styleBlock.innerHTML = styles styleBlock.setAttribute("data-sass-component", fileName) document.head.appendChild(styleBlock) }})()
В AST explorer тыкаем в левой части на строки, объявления, литералы, справа в дереве смотрим структуру объявлений, по этой структуре лезем в babeljs.io/docs/en/babel-types#api, курим всё это и пишем замену.
A few moments later
Спустя 1-1,5 часа, бегая по вкладкам из ast в babel-types api, затем в код, я написал замену импорта scss/sass. Разбирать отдельно дерево ast и babel-types api я не буду, будет ещё больше буковок. Показываю сразу результат:
const { resolve, dirname, join } = require("path");const { renderSync } = require("node-sass");const regexps = { sassFile: /([A-Za-z0-9]+).s[ac]ss/g, sassExt: /\.s[ac]ss$/, currentFile: /([A-Za-z0-9]+).(t|j)s(x)/g, currentFileExt: /.(t|j)s(x)/g};function transformScss(babel) { const { types: t } = babel; return { name: "babel-plugin-transform-scss", visitor: { ImportDeclaration(path, state) { /** * Проверяем, содержит ли текущий файл scss/sass расширения в импорте */ if (!regexps.sassExt.test(path.node.source.value)) return; const sassFileNameMatch = path.node.source.value.match( regexps.sassFile ); /** * Получаем имя текущего scss/sass файла и текущего js файла */ const sassFileName = sassFileNameMatch[0].replace(regexps.sassExt, ""); const file = this.filename.match(regexps.currentFile); const filename = `${file[0].replace( regexps.currentFileExt, "" )}_${sassFileName}`; /** * * Получаем полный путь до scss/sass файла, транспилим в строку css */ const scssFileDirectory = resolve(dirname(state.file.opts.filename)); const fullScssFilePath = join( scssFileDirectory, path.node.source.value ); const projectRoot = process.cwd(); const nodeModulesPath = join(projectRoot, "node_modules"); const sassDefaults = { file: fullScssFilePath, sourceMap: false, includePaths: [nodeModulesPath, scssFileDirectory, projectRoot] }; const sassResult = renderSync({ ...sassDefaults, ...state.opts }); const transpiledContent = sassResult.css.toString() || ""; /** * Имплементируем функцию, написанную в AST Explorer и заменяем импорт методом * replaceWith аргумента path. */ path.replaceWith( t.callExpression( t.functionExpression( t.identifier(""), [], t.blockStatement( [ t.variableDeclaration("const", [ t.variableDeclarator( t.identifier("styles"), t.stringLiteral(transpiledContent) ) ]), t.variableDeclaration("const", [ t.variableDeclarator( t.identifier("fileName"), t.stringLiteral(filename) ) ]), t.variableDeclaration("const", [ t.variableDeclarator( t.identifier("element"), t.callExpression( t.memberExpression( t.identifier("document"), t.identifier("querySelector") ), [ t.stringLiteral( `style[data-sass-component='${filename}']` ) ] ) ) ]), t.ifStatement( t.unaryExpression("!", t.identifier("element"), true), t.blockStatement( [ t.variableDeclaration("const", [ t.variableDeclarator( t.identifier("styleBlock"), t.callExpression( t.memberExpression( t.identifier("document"), t.identifier("createElement") ), [t.stringLiteral("style")] ) ) ]), t.expressionStatement( t.assignmentExpression( "=", t.memberExpression( t.identifier("styleBlock"), t.identifier("innerHTML") ), t.identifier("styles") ) ), t.expressionStatement( t.callExpression( t.memberExpression( t.identifier("styleBlock"), t.identifier("setAttribute") ), [ t.stringLiteral("data-sass-component"), t.identifier("fileName") ] ) ), t.expressionStatement( t.callExpression( t.memberExpression( t.memberExpression( t.identifier("document"), t.identifier("head"), false ), t.identifier("appendChild"), false ), [t.identifier("styleBlock")] ) ) ], [] ), null ) ], [] ), false, false ), [] ) ); } }}
Итоговые радости
Ура!!! Импорт заменился на вызов функции, которая напихала в head документа стиль с этой кнопкой. И тут я подумал, а что если я стартану всю эту байдарку через вебпак, выкосив sass-loader? Будет ли оно работать? Окей, выкашиваем и проверяем. Запускаю сборку вебпаком, жду ошибку, что я должен определить loader для этого типа файла А ошибки-то нет, всё собралось. Открываю страницу, смотрю, а стиль воткнулся в head документа. Интересно получилось, я ещё избавился от 3 лоадеров для стилей(очень довольная улыбка).
Если вам была интересна статья поддержите звездочкой на github.
Так же ссылка на npm пакет: www.npmjs.com/package/babel-plugin-transform-scss
Примечание: Вне статьи добавлена проверка на импорт стиля по типу import styles from './styles.scss'