본문 바로가기

개발👩‍💻/프론트엔드

Babel (2): macro

728x90

바벨은 플러그인을 사용하여 커스텀하게 변환 작업을 할 수 있다.

 

하지만 Babel 플러그인에는 몇 가지 문제가 있다.

  1. 프로젝트의 코드를 볼 때 해당 코드를 변환하는 플러그인이 있는지 모를 수 있기 때문에 혼동을 일으킬 수 있음.
  2. 전역적으로 구성하거나 대역 외( .babelrc또는 webpack 구성에서)로 구성해야 함.
  3. 모든 babel 플러그인이 동시에 실행되기 때문에(Babel AST의 단일 워크에서) 매우 혼란스러운 방식으로 충돌할 수 있음.

이러한 문제는 Babel 플러그인을 가져와서 코드에 직접 적용할 수 있다면 해결할 수 있는데, 이는 변환이 더 명시적이며 구성에 추가할 필요가 없으며 플러그인을 가져온 순서대로 정렬할 수 있음을 의미한다.

 

현재 babel 생태계의 각 babel 플러그인은 개별적으로 구성해야 한다. 이것은 언어 기능과 같은 경우에는 문제가 없지만 최적화로 컴파일 타임 코드 변환을 허용하는 라이브러리의 경우 답답한 오버헤드가 될 수 있다.

 

macros는 사용자가 빌드 시스템에 babel 플러그인을 추가할 필요 없이 컴파일 타임 코드 변환을 사용하려는 라이브러리에 대한 표준 인터페이스를 정의할 수 있다.

 

우리가 플러그인을 사용하다보면, 플러그인의 삽입 순서 등에서 에러가 발생한 경험을 해본 적 있을 것이다. 이럴 경우 에러 추적이 어렵고 해전역적으로 플러그인이 동작하기 때문에 여러가지 문제를 야기할 수 있었다.

 

이러한 문제 인식을 기반으로 매크로가 등장했다.

 

매크로는 기본적으로 `babel-plugin-macros` 플러그인을 사용하여 해당 플러그인을 `.babelrc`에 넣으면 어디서든 메크로를 사용할 수 있다.

 

이러한 macro는 cra 나 next에서 이미 사용중이다.

이러한 라이브러리, 프레임워크에서 우리가 svg를 쉽게 가지고 올 수 있는 것이 대표적인 예이다.

 

macro는 zero-config를 지향한다.

사용자입장에서는 babel에 babel-plugin-macros를 넣어놓기만하면 된다!

 

매크로는 macro.js 혹은 .macro로 끝나는 부분을 찾아 해당 부분에서 변환을 해준다.

사용자가 `.macro`로 끝나는 파일을 import하면 이를 바로 코드베이스에 적용한다. 이렇게 함으로써 사용자는 따로 플러그인을 사용하지 않고도 사용자 코드베이스에서 바로 macro에 해당하는 코드 변환을 이룰 수 있게 되는 것이다.

이를 제공하는 여러 라이브러리들은 여기서 찾아볼 수 있다.

 

대표적으로 macro를 사용하는 twin.macro 라이브러리를 살펴보며 macro가 대체 무엇인지 알아보자

twin.macro는 tailwind와 css-in-js 스타일엔진을 함께 사용할 수 있게 해주는 라이브러이인데, 다양한 스타일 엔진을 제공하고, 어떤 스타일 엔진을 사용할 것인지 사용자가 지정할 수 있다. twin.macro는 macro를 사용하여 사용자에게 다양한 라이브러리를 제공하고 있다.

어떻게 twin.macro가 작동하는지 알아보자.

 

twin.macro는 사용자가 설정할 때 어떤 스타일 엔진을 사용할 것인지 package.json에 설정해준다. 없다면 emotion 이 디폴트로 사용된다.

// package.json add
"babelMacros": {
  "twin": {
    "preset": "styled-components"
  }
},

이렇게 사용자가 설정하고 

 

import {styled} from "twin.macro"

와 같이 styled를 가져와서 사용할 수 있게 되는데 어떻게 이렇게 사용할 수 있는 것일까?

 

import { createMacro } from 'babel-plugin-macros'
import {
  validateImports,
  setStyledIdentifier,
  setCssIdentifier,
  generateUid,
  getCssAttributeData,
} from './macroHelpers'
import {
  getTailwindConfigProperties,
  getConfigTwinValidated,
} from './configHelpers'
import {
  getCssConfig,
  updateCssReferences,
  addCssImport,
  convertHtmlElementToStyled,
} from './macro/css'
import {
  getStyledConfig,
  updateStyledReferences,
  addStyledImport,
  handleStyledFunction,
} from './macro/styled'
import { handleThemeFunction } from './macro/theme'
import { handleScreenFunction } from './macro/screen'
import { handleGlobalStylesFunction } from './macro/globalStyles'
import { handleTwProperty, handleTwFunction } from './macro/tw'
import { handleCsProperty } from './macro/cs'
import { handleClassNameProperty } from './macro/className'
import getUserPluginData from './utils/getUserPluginData'
import { debugPlugins, debug } from './logging'

const packageCheck = (pkg, ctx) =>
  ctx.config.preset === pkg ||
  ctx.styledImport.from.includes(pkg) ||
  ctx.cssImport.from.includes(pkg)

const getPackageUsed = ctx => ({
  isEmotion: packageCheck('emotion', ctx),
  isStyledComponents: packageCheck('styled-components', ctx),
  isGoober: packageCheck('goober', ctx),
  isStitches: packageCheck('stitches', ctx),
})

const macroTasks = [
  handleTwFunction,
  handleGlobalStylesFunction, // GlobalStyles import
  updateStyledReferences, // Styled import
  handleStyledFunction, // Convert tw.div`` & styled.div`` to styled('div', {}) (stitches)
  updateCssReferences, // Update any usage of existing css imports
  handleThemeFunction, // Theme import
  handleScreenFunction, // Screen import
  addStyledImport,
  addCssImport, // Gotcha: Must be after addStyledImport or issues with theme`` style transpile
]

const twinMacro = args => {
  const {
    babel: { types: t },
    references,
    config,
  } = args
  let { state } = args

  validateImports(references)

  const program = state.file.path

  const isDev =
    process.env.NODE_ENV === 'development' ||
    process.env.NODE_ENV === 'dev' ||
    false
  state.isDev = isDev
  state.isProd = !isDev

  const { configExists, tailwindConfig } = getTailwindConfigProperties(
    state,
    config
  )

  // Get import presets
  const styledImport = getStyledConfig({ state, config })
  const cssImport = getCssConfig({ state, config })

  // Identify the css-in-js library being used
  const packageUsed = getPackageUsed({ config, cssImport, styledImport })
  for (const [key, value] of Object.entries(packageUsed)) state[key] = value

  const configTwin = getConfigTwinValidated(config, state)

  const stateContext = {
    configExists: configExists,
    config: tailwindConfig,
    configTwin: configTwin,
    debug: debug(isDev, configTwin),
    globalStyles: new Map(),
    tailwindConfigIdentifier: generateUid('tailwindConfig', program),
    tailwindUtilsIdentifier: generateUid('tailwindUtils', program),
    userPluginData:
      getUserPluginData({ tailwindConfig, configTwin, state }) || {},
    styledImport: styledImport,
    cssImport: cssImport,
    styledIdentifier: null,
    cssIdentifier: null,
  }

  state = { ...state, ...stateContext }

  // Group traversals together for better performance
  program.traverse({
    ImportDeclaration(path) {
      setStyledIdentifier({ state, path, styledImport })
      setCssIdentifier({ state, path, cssImport })
    },
    JSXElement(path) {
      const allAttributes = path.get('openingElement.attributes')
      const jsxAttributes = allAttributes.filter(a => a.isJSXAttribute())
      const { index, hasCssAttribute } = getCssAttributeData(jsxAttributes)
      // Make sure hasCssAttribute remains true once css prop has been found
      // so twin can add the css prop
      state.hasCssAttribute = state.hasCssAttribute || hasCssAttribute

      // Reverse the attributes so the items keep their order when replaced
      const orderedAttributes =
        index > 1 ? jsxAttributes.reverse() : jsxAttributes
      for (path of orderedAttributes) {
        handleClassNameProperty({ path, t, state })
        handleTwProperty({ path, t, state, program })
        handleCsProperty({ path, t, state })
      }

      hasCssAttribute && convertHtmlElementToStyled({ path, t, program, state })
    },
  })

  if (state.styledIdentifier === null)
    state.styledIdentifier = generateUid('styled', program)

  if (state.cssIdentifier === null)
    state.cssIdentifier = generateUid('css', program)

  for (const task of macroTasks) {
    task({ styledImport, cssImport, references, program, config, state, t })
  }

  program.scope.crawl()
}

export default createMacro(twinMacro, { configName: 'twin' })

위 코드는 twin.macro의 macro.js이다.

 

여기서 오늘 봐야할 중요 코드만 추려보면

import { createMacro } from 'babel-plugin-macros'
...

const macroTasks = [
 ...
  addStyledImport,
  addCssImport, // Gotcha: Must be after addStyledImport or issues with theme`` style transpile
]

const packageCheck = (pkg, ctx) =>
  ctx.config.preset === pkg ||
  ctx.styledImport.from.includes(pkg) ||
  ctx.cssImport.from.includes(pkg)

const getPackageUsed = ctx => ({
  isEmotion: packageCheck('emotion', ctx),
  isStyledComponents: packageCheck('styled-components', ctx),
  isGoober: packageCheck('goober', ctx),
  isStitches: packageCheck('stitches', ctx),
})

const twinMacro = args => {
  const {
    babel: { types: t },
    references,
    config,
  } = args
  let { state } = args

  validateImports(references)

  const program = state.file.path

  // Get import presets
  const styledImport = getStyledConfig({ state, config })
  const cssImport = getCssConfig({ state, config })

  // Identify the css-in-js library being used
  const packageUsed = getPackageUsed({ config, cssImport, styledImport })
  for (const [key, value] of Object.entries(packageUsed)) state[key] = value

  const stateContext = {
    ...
    styledImport: styledImport,
    cssImport: cssImport,
    styledIdentifier: null,
    cssIdentifier: null,
  }

  state = { ...state, ...stateContext }

  // Group traversals together for better performance
  program.traverse({
    ImportDeclaration(path) {
      setStyledIdentifier({ state, path, styledImport })
      setCssIdentifier({ state, path, cssImport })
    },
    JSXElement(path) {
      const allAttributes = path.get('openingElement.attributes')
      const jsxAttributes = allAttributes.filter(a => a.isJSXAttribute())
      const { index, hasCssAttribute } = getCssAttributeData(jsxAttributes)
      // Make sure hasCssAttribute remains true once css prop has been found
      // so twin can add the css prop
      state.hasCssAttribute = state.hasCssAttribute || hasCssAttribute

      // Reverse the attributes so the items keep their order when replaced
      const orderedAttributes =
        index > 1 ? jsxAttributes.reverse() : jsxAttributes
      for (path of orderedAttributes) {
        handleClassNameProperty({ path, t, state })
        handleTwProperty({ path, t, state, program })
        handleCsProperty({ path, t, state })
      }

      hasCssAttribute && convertHtmlElementToStyled({ path, t, program, state })
    },
  })

  if (state.styledIdentifier === null)
    state.styledIdentifier = generateUid('styled', program)

  if (state.cssIdentifier === null)
    state.cssIdentifier = generateUid('css', program)

  for (const task of macroTasks) {
    task({ styledImport, cssImport, references, program, config, state, t })
  }

  program.scope.crawl()
}

export default createMacro(twinMacro, { configName: 'twin' })

userPresets.js

/**
 * Config presets
 *
 * To use, add the preset in package.json/babel macro config:
 *
 * styled-components
 * { "babelMacros": { "twin": { "preset": "styled-components" } } }
 * module.exports = { twin: { preset: "styled-components" } }
 *
 * emotion
 * { "babelMacros": { "twin": { "preset": "emotion" } } }
 * module.exports = { twin: { preset: "emotion" } }
 *
 * goober
 * { "babelMacros": { "twin": { "preset": "goober" } } }
 * module.exports = { twin: { preset: "goober" } }
 */

export default {
  'styled-components': {
    styled: { import: 'default', from: 'styled-components' },
    css: { import: 'css', from: 'styled-components' },
    global: { import: 'createGlobalStyle', from: 'styled-components' },
  },
  emotion: {
    styled: { import: 'default', from: '@emotion/styled' },
    css: { import: 'css', from: '@emotion/react' },
    global: { import: 'Global', from: '@emotion/react' },
  },
  goober: {
    styled: { import: 'styled', from: 'goober' },
    css: { import: 'css', from: 'goober' },
    global: { import: 'createGlobalStyles', from: 'goober/global' },
  },
  stitches: {
    styled: { import: 'styled', from: 'stitches.config' },
    css: { import: 'css', from: 'stitches.config' },
    global: { import: 'global', from: 'stitches.config' },
  },
}

 

우선 userPresets.js에서 설정해준 각 4개의 객체의 키를 cofig로 설정하게 된다.

4개의 키 중 사용할 프리셋을 사용자가 가져와 넘겨주게 되는데 설정을 아래와 같이 어떤 엔진을 사용할 것인지 넘겨주면된다.

// package.json add
"babelMacros": {
  "twin": {
    "preset": "styled-components"
  }
},

이렇게 프리셋을 설정할 때 "twin" 이라는 객체를 사용한다. 

 

이는 macro를 생성할 때 정해준 configName이다.

export default createMacro(twinMacro, { configName: 'twin' })

이렇게 사용자의 설정을 가져올 수 있게 되고,

해당 매크로 내부의 함수를 보게 되면

 

어떤 스타일 엔진을 사용하는지 각 4개의 엔진을 객체 키로 하여 boolean값을 저장하게 된다.

그리고 해당 키와 값을 state에 저장한다.

const packageCheck = (pkg, ctx) =>
  ctx.config.preset === pkg ||
  ctx.styledImport.from.includes(pkg) ||
  ctx.cssImport.from.includes(pkg)

const getPackageUsed = ctx => ({
  isEmotion: packageCheck('emotion', ctx),
  isStyledComponents: packageCheck('styled-components', ctx),
  isGoober: packageCheck('goober', ctx),
  isStitches: packageCheck('stitches', ctx),
})

...
const packageUsed = getPackageUsed({ config, cssImport, styledImport })
for (const [key, value] of Object.entries(packageUsed)) state[key] = value

그리고 사용자 설정에 맞게 변경된 사항을 state에 저장한다.

  const stateContext = {
    ...
    styledImport: styledImport,
    cssImport: cssImport,
    styledIdentifier: null,
    cssIdentifier: null,
  }

  state = { ...state, ...stateContext }

그리고 travese() 메서드를 사용하여 각 노드를 순회하며 ImportDeclaration()과 JSXElement() visitor에 각각 필요한 함수를 구현해주었다.

 

 

ImportDeclaration은.. 예를들어

import React from "react"

라고 했다면 AST로

 

{
  ...
    "body": [
      {
        "type": "ImportDeclaration",
        "start": 0,
        "end": 26,
        "loc": {
          "start": {
            "line": 1,
            "column": 0,
            "index": 0
          },
          "end": {
            "line": 1,
            "column": 26,
            "index": 26
          }
        },
        "specifiers": [
          {
            "type": "ImportDefaultSpecifier",
            "start": 7,
            "end": 12,
            "loc": {
              "start": {
                "line": 1,
                "column": 7,
                "index": 7
              },
              "end": {
                "line": 1,
                "column": 12,
                "index": 12
              }
            },
            "local": {
              "type": "Identifier",
              "start": 7,
              "end": 12,
              "loc": {
                "start": {
                  "line": 1,
                  "column": 7,
                  "index": 7
                },
                "end": {
                  "line": 1,
                  "column": 12,
                  "index": 12
                },
                "identifierName": "React"
              },
              "name": "React"
            }
          }
        ],
        "importKind": "value",
        "source": {
          "type": "StringLiteral",
          "start": 18,
          "end": 25,
          "loc": {
            "start": {
              "line": 1,
              "column": 18,
              "index": 18
            },
            "end": {
              "line": 1,
              "column": 25,
              "index": 25
            }
          },
          "extra": {
            "rawValue": "react",
            "raw": "\"react\""
          },
          "value": "react"
        },
        ...
  }

위와 같이 표현할 수 있다.

 

twin.macro에서는 

program.traverse({
    ImportDeclaration(path) {
      setStyledIdentifier({ state, path, styledImport })
      setCssIdentifier({ state, path, cssImport })
    },
    JSXElement(path) {
      ...
    },
  })
const setStyledIdentifier = ({ state, path, styledImport }) => {
  const importFromStitches =
    state.isStitches && styledImport.from.includes(path.node.source.value)
  const importFromLibrary = path.node.source.value === styledImport.from

  if (!importFromLibrary && !importFromStitches) return
  ...
}

설정을 바꾸어서 styledImport.from 과 path.node.source.value가 일치하지 않게 된다면 return 되고

아래 task 에서 addstyledImport 함수에서

for (const task of macroTasks) {
    task({ styledImport, cssImport, references, program, config, state, t })
  }
const addStyledImport = ({ references, program, t, styledImport, state }) => {
  ...
  if (state.existingStyledIdentifier) return

  addImport({
    types: t,
    program,
    name: styledImport.import,
    mod: styledImport.from,
    identifier: state.styledIdentifier,
  })
}
function addImport({ types: t, program, mod, name, identifier }) {
  const importName =
    name === 'default'
      ? [t.importDefaultSpecifier(identifier)]
      : name
      ? [t.importSpecifier(identifier, t.identifier(name))]
      : []
  program.unshiftContainer(
    'body',
    t.importDeclaration(importName, t.stringLiteral(mod))
  )
}

아래와 같이 import 문을 body에 추가하게 만든다.

 

이와 같은 일련의 과정을 통해서 twin.macro는 사용자 설정에 맞추어 해당 라이브러리에서 스타일엔진을 제공할  수 있게 한다.

 

이렇게 컴파일 단계에서 노드를 추가하고 업데이트하고 삭제하는 등의 다양한 작업을 바벨을 통해서 할 수 있다.

 

바벨이 어떻게 동작하고 바벨 플러그인이 무엇인지 바벨 매크로는 무엇인지 예시를 보며 알아봤다.

 

바벨은 트랜스파일러의 역할을 하지만, 이에 그치지 않고 어떻게 동작 하는지 알아봤다.

바벨은 트랜스파일러의 역할을 주로 하지만, AST기반으로 노드를 직접 다루기에 더 많은 일을 할 수 있는 멋진 도구이다.

 

 

참고)

https://github.com/ben-rogerson/twin.macro

 

GitHub - ben-rogerson/twin.macro: 🦹‍♂️ Twin blends the magic of Tailwind with the flexibility of css-in-js (emotion, st

🦹‍♂️ Twin blends the magic of Tailwind with the flexibility of css-in-js (emotion, styled-components, stitches and goober) at build time. - GitHub - ben-rogerson/twin.macro: 🦹‍♂️ Twin blends the ma...

github.com

https://babeljs.io/blog/2017/09/11/zero-config-with-babel-macros

 

Zero-config code transformation with babel-plugin-macros · Babel

Babel started out as a transpiler to let you write the latest version of the ECMAScript specification but ship to environments that don't implement those features yet. But it has become much more than that. ["Compilers are the New Frameworks"](https://tomd

babeljs.io

https://github.com/kentcdodds/babel-plugin-macros

 

GitHub - kentcdodds/babel-plugin-macros: 🎣 Allows you to build simple compile-time libraries

🎣 Allows you to build simple compile-time libraries - GitHub - kentcdodds/babel-plugin-macros: 🎣 Allows you to build simple compile-time libraries

github.com

https://lihautan.com/babel-macros/

 

Babel macros | Tan Li Hau

In this article, I am going to talk about Babel macros. In my previous post, "Creating custom JavaScript syntax with Babel", I've shown you detailed steps on how you can create a custom syntax and write transform plugin or polyfills so that the syntax can

lihautan.com

babel-plugin-macros Usage for macros authors

 

GitHub - kentcdodds/babel-plugin-macros: 🎣 Allows you to build simple compile-time libraries

🎣 Allows you to build simple compile-time libraries - GitHub - kentcdodds/babel-plugin-macros: 🎣 Allows you to build simple compile-time libraries

github.com

Zero-config code transformation with babel-plugin-macros

 

Zero-config code transformation with babel-plugin-macros · Babel

Babel started out as a transpiler to let you write the latest version of the ECMAScript specification but ship to environments that don't implement those features yet. But it has become much more than that. ["Compilers are the New Frameworks"](https://tomd

babeljs.io

Awesome list for Babel macros

 

GitHub - jgierer12/awesome-babel-macros: A collection of awesome babel macros and related resources

A collection of awesome babel macros and related resources - GitHub - jgierer12/awesome-babel-macros: A collection of awesome babel macros and related resources

github.com

 

반응형