Предыстория
Как-то субботним вечером я сидел и искал способы сборки 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
```javascript
import 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:
```javascript
import 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'