Как я выкинул 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'

Комментарии 8

    +2
    После сборки в каталог build упало 2 файла Button.js, Dropdown.js — заглядываем внутрь. Внутри лицензии react.production.min.js, тяжелочитаемый минимизированный код, и куча всякой фигни.

    Автор смог в glob и в multiple entries, но до externals не дочитал. Ну окей. Тяжело, наверное, пользоваться вещами без RTFM, но кто я такой чтоб судить.
      0
      Что есть в голове, и что нашел в процессе — то и написал :)
      0

      "import styles from «styles.scss» (никогда так не импортировал стили и мое личное мнение — не надо так делать..."


      Но как же CSS модули?
      className={styles.foobar}

        0
        В конце стати примечание есть) В связи с этим я расширил библиотеку для поддержки css модулей
          0

          Больше интересует, откуда такая категоричность, раз это всё же в тексте

            0
            Это была моя большая ошибка, говорить такое. Я осознал и удалил из текста.
        0
        Вот этот кусочек кода очень понравился :))
                                  )
                                )
                              ],
                              []
                            ),
                            null
                          )
                        ],
                        []
                      ),
                      false,
                      false
                    ),
                    []
                  )
                );
                }
            }
        }


        А вообще молодцом! Как-то так и рождаются, мне кажется, хорошие инструменты. Проблема — решение. Даже есть пока только для себя. Грац!
          0
          Автору + в карму :)

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое