Babel (2): macro
바벨은 플러그인을 사용하여 커스텀하게 변환 작업을 할 수 있다.
하지만 Babel 플러그인에는 몇 가지 문제가 있다.
- 프로젝트의 코드를 볼 때 해당 코드를 변환하는 플러그인이 있는지 모를 수 있기 때문에 혼동을 일으킬 수 있음.
- 전역적으로 구성하거나 대역 외( .babelrc또는 webpack 구성에서)로 구성해야 함.
- 모든 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
https://babeljs.io/blog/2017/09/11/zero-config-with-babel-macros
https://github.com/kentcdodds/babel-plugin-macros
https://lihautan.com/babel-macros/
babel-plugin-macros Usage for macros authors
Zero-config code transformation with babel-plugin-macros