Как я выкинул webpack и написал babel-plugin для транспила scss/sass

от автора

Предыстория

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

ссылка на оригинал статьи https://habr.com/ru/post/513564/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *