Tailwindcss 오픈소스 기여 과정기

SeongHyeon Bae·2024년 4월 16일
1

오픈소스 까보기

목록 보기
4/5
post-thumbnail

계기

tailwindcss는 현재 프론트엔드 분야에서 스타일링을 위해 많이 쓰이는 라이브러리 중 하나입니다. 제가 맡은 프로젝트에서도 tailwindcss를 사용하고 있는데요. arbitrary의 calc 사용 중 공백을 이용한 가독성 증가를 원했습니다.

<div className="w-[calc(100px + 200px)]"></div>

위와 같이 + 양옆에 공백을 주어 가독성을 높인 class를 작성하였습니다.
(css에서도 operator 사용 시 문법적으로 공백이 필요하다고 합니다. 참고)

하지만 tailwind에서는 className의 공백을 기준으로 파싱을 하기 때문에 w-[calc(100px , +, 200px)] 와 같이 독립적으로 분리가 되었고 결국 적용 되지 않았습니다.

w-[calc(100px+200px)] 와 같이 공백을 제거해 문법에 맞게 작성하면 가장 단순한 해결 방식이지만 분명 저와 비슷한 불편함을 겪는 개발자가 있을 것이고 tailwindcss에 기여해보면 좋지 않을까 생각하여서 도전해보았습니다.

과정

먼저 tailwindcss의 git을 클론 받았습니다. 많은 파일이 존재하였는데요. 먼저 테스트 파일을 보면 독립적으로 실행되는 과정을 따라갈 수 있지 않을까? 생각하여 arbitrary에 해당하는 테스트 파일을 확인하였습니다.

tests/arbitrary-values.test.js


// tests/arbitrary-values.test.js
it('should support slashes in arbitrary modifiers', () => {
  let config = {
    content: [{ raw: html`<div class="text-lg/[calc(50px/1rem)]"></div>` }],
  }

  return run('@tailwind utilities', config).then((result) => {
    return expect(result.css).toMatchFormattedCss(css`
      .text-lg\/\[calc\(50px\/1rem\)\] {
        font-size: 1.125rem;
        line-height: calc(50px / 1rem);
      }
    `)
  })
})

config 객체 안 content에 우리가 원하는 값들이 배열형태로 들어가는 것을 확인할 수 있습니다. 그럼 이 raw 안에 있는 class가 어떻게 파싱이 되어서 원하는 css로 변하게 될 수 있을까요? 아마 run을 실행하면 어떠한 함수가 실행이 되고 Promise를 리턴하는 것 같습니다. 이 run이 어떤 함수인지에 대해서 좀 더 살펴봅시다.

tests/util/run.js

//tests/util/run.js

export function run(input, config, plugin = tailwind) {
  let { currentTestName } = expect.getState()

  return postcss(plugin(config)).process(input, {
    from: `${path.resolve(__filename)}?test=${currentTestName}`,
  })
}

plugin 이라는 함수에 config가 인자로 들어가고 그 반환값을 postcss로 받아서 처리하는 군요. 일반적으로 postcss는 js의 스타일을 css로 변경해 주는 툴 입니다. 저희는 변환하기 전 calc 내부의 공백을 제거하는 것이 목적이므로 plugin 내부에서 해결책을 찾을 수 있을거라 추측할 수 있습니다.

src/plugin.js

// src/plugin.js

module.exports = function tailwindcss(configOrPath) {
  return {
    postcssPlugin: 'tailwindcss',
    plugins: [
      env.DEBUG &&
        function (root) {
          console.log('\n')
          console.time('JIT TOTAL')
          return root
        },
      async function (root, result) {
        // Use the path for the `@config` directive if it exists, otherwise use the
        // path for the file being processed
        configOrPath = findAtConfigPath(root, result) ?? configOrPath

        let context = setupTrackingContext(configOrPath)

        if (root.type === 'document') {
          let roots = root.nodes.filter((node) => node.type === 'root')

          for (const root of roots) {
            if (root.type === 'root') {
              await processTailwindFeatures(context)(root, result)
            }
          }

          return
        }

        await processTailwindFeatures(context)(root, result)
      },
      env.DEBUG &&
        function (root) {
          console.timeEnd('JIT TOTAL')
          console.log('\n')
          return root
        },
    ].filter(Boolean),
  }
}

이 부분을 해석하는데 조금 어려움이 있었는데요. 여기서는 plugins 배열을 반환하는데 그 안에는 여러 함수들이 존재합니다. 그 중 디버깅과는 관련없으니 제거를 하면 하나의 함수가 남겠군요. 저희는 Path와 관련된(url과 같은) config가 아니기에 configOrPath는 config와 같습니다.

이제 새로운 개념인 context가 나오는데요. 해당 부분을 hover해보면 다음과 같음을 알 수 있습니다.

context를 생성하는 함수를 반환하는 함수? 쯤으로 생각해도 좋을 것 같네요. 그럼 이 부분을 사용하는 processTailwindFeatures 을 찾아봅시다.

src/processTailwindFeatures.js

// src/processTailwindFeatures.js

export default function processTailwindFeatures(setupContext) {
  return async function (root, result) {
    let { tailwindDirectives, applyDirectives } = normalizeTailwindDirectives(root)

    // Partition apply rules that are found in the css
    // itself.
    partitionApplyAtRules()(root, result)

    let context = setupContext({
      tailwindDirectives,
      applyDirectives,
      registerDependency(dependency) {
        result.messages.push({
          plugin: 'tailwindcss',
          parent: result.opts.from,
          ...dependency,
        })
      },
      createContext(tailwindConfig, changedContent) {
        return createContext(tailwindConfig, changedContent, root)
      },
    })(root, result)
    if (context.tailwindConfig.separator === '-') {
      throw new Error(
        "The '-' character cannot be used as a custom separator in JIT mode due to parsing ambiguity. Please use another character like '_' instead."
      )
    }

    issueFlagNotices(context.tailwindConfig)

    await expandTailwindAtRules(context)(root, result)
	...많은 함수
  }
}

꽤나 복잡한 함수인데요. 함수명으로 유추해보면 테일윈드 기능들을 처리하는 함수쯤으로 해석할 수 있습니다. 이곳에서 아까 의문점인 context을 사용하는데요. setupContext 로 props를 받아서 사용을 합니다. 그럼 이 context에는 어떠한 것이 있을까? 궁금하실텐데요. 해당 부분을 콘솔로 찍어보면 tailwind의 Config들, corePlugins, 유저로 부터 받은 커스텀 plugins, changedContent 등등 너무 많은 정보들이 context에 담겨있습니다. 여기서 가장 중요한 부분은 changedContent 인데요. 이 부분에는 저희가 처음에 넣었던 class관련 정보들이 들어있습니다.

changedContent: [
        {
          content: '<div class="text-lg/[calc(50px/1rem)]"></div>',
          extension: 'html'
        }
      ],

그리고 expandTailwindAtRules 이 함수 실행 이후에는 changedContent 내부는 빈 배열이 되며, text-lg/[calc(50px/1rem)] 해당 클래스는 candidateRuleCache, classCache 등 context의 여러 프로퍼티에 들어가는 것을 알 수 있는데요. 이를 통해 expandTailwindAtRules 함수에서 어떠한 변환이 이루어 짐을 추측할 수 있습니다.

expandTailwindAtRules 부분도 꽤나 복잡한것 같지만 변수명이 직관적이기에 어떠한 역할을 하는 부분인지 짐작이 가능합니다. 전처리 부분을 제외한 핵심 부분을 보겠습니다.

src/lib/expandTailwindAtRules.js


// src/lib/expandTailwindAtRules.js
    let regexParserContent = []
    for (let item of context.changedContent) {
      let transformer = getTransformer(context.tailwindConfig, item.extension)
      let extractor = getExtractor(context, item.extension)
      regexParserContent.push([item, { transformer, extractor }])
    }

    const BATCH_SIZE = 500

    for (let i = 0; i < regexParserContent.length; i += BATCH_SIZE) {
      let batch = regexParserContent.slice(i, i + BATCH_SIZE)
      await Promise.all(
        batch.map(async ([{ file, content }, { transformer, extractor }]) => {
          content = file ? await fs.promises.readFile(file, 'utf8') : content
          getClassCandidates(transformer(content), extractor, candidates, seen)
        })
      )
    }

첫번째 for문에서는 아까 저희가 봤던 changedContent 프로퍼티에 접근하여 extension에 따른 변환 함수와 추출함수를 regexParserContent 배열에 넣어주고 있네요.

이후 두번째 for문에서 getClassCandidates 함수를 사용하고 있고 content를 감싸는 transformer 의 경우 builtInTransformers를 보면 svelt 일 경우 변환이 되는 구조라 저희는 content가 그대로 사용됩니다.

// Scans template contents for possible classes. This is a hot path on initial build but
// not too important for subsequent builds. The faster the better though — if we can speed
// up these regexes by 50% that could cut initial build time by like 20%.
function getClassCandidates(content, extractor, candidates, seen) {
  if (!extractorCache.has(extractor)) {
    extractorCache.set(extractor, new LRU({ maxSize: 25000 }))
  }
  for (let line of content.split('\n')) {
    line = line.trim()

    if (seen.has(line)) {
      continue
    }
    seen.add(line)

    if (extractorCache.get(extractor).has(line)) {
      for (let match of extractorCache.get(extractor).get(line)) {
        candidates.add(match)
      }
    } else {
      let extractorMatches = extractor(line).filter((s) => s !== '!*')
      let lineMatchesSet = new Set(extractorMatches)

      for (let match of lineMatchesSet) {
        candidates.add(match)
      }

      extractorCache.get(extractor).set(line, lineMatchesSet)
    }
  }
}

지금까지 잘 따라와 주셔서 감사합니다. 마지막 분석할 함수입니다. getClassCandidates 함수는 class가 될수있는 후보들을 뽑아내는 함수입니다. for문 내부에서는 content의 line별 split을 진행하고 해당 공백을 제거합니다. seen을 통해 이전에 추출을 했던 부분은 캐싱하는 작업까지 알 수 있습니다. 그 뒤 아래부분에서는 해당 Line을 추출하고 class 후보로 지정하는 함수임을 확인하였습니다.

저는 추출하기 전 calc 내부의 공백을 제거해주면 될것 같다고 판단하여 다음과 같은 함수를 작성하여 수정하였습니다.


function removeSpacesInsideCalc(content) {
  return content.replace(/calc\(([^)]*)\)/g, (_, p1) => `calc(${p1.replace(/\s+/g, '')})`)
}

function getClassCandidates(content, extractor, candidates, seen) {
  if (!extractorCache.has(extractor)) {
    extractorCache.set(extractor, new LRU({ maxSize: 25000 }))
  }
  for (let line of content.split('\n')) {
    line = line.trim()
    line = removeSpacesInsideCalc(line) // 해당 부분 추가
	
    ...
  }
}

이를 통해 class 후보를 확인하기 전 calc의 공백을 제거하여 하나의 클래스로 인식할 수 있게 하였고 테스트 코드를 작성하여 정상적으로 작동함을 확인하였습니다.

it('should support arbitrary calc values with spaces`', () => {
  let config = {
    content: [
      {
        raw: html`<div
          class="w-[calc(100px + 200px)] h-[calc(3rem - 1rem)] text-lg/[calc(50px / 1rem)]"
        ></div>`,
      },
    ],
  }

  return run('@tailwind utilities', config).then((result) => {
    expect(result.css).toMatchFormattedCss(css`
      .h-\[calc\(3rem-1rem\)\] {
        height: 2rem;
      }
      .w-\[calc\(100px\+200px\)\] {
        width: 300px;
      }
      .text-lg\/\[calc\(50px\/1rem\)\] {
        font-size: 1.125rem;
        line-height: calc(50px / 1rem);
      }
    `)
  })
})

후기

결론적으로는 reject 당했습니다 ㅎㅎ... tailwind 컨트리뷰터분이 친절하게 피드백을 주셨는데요! 결론적으로는 공백으로 클래스를 구분하는 것은 html 고유의 특성이므로 수정해서는 안되는것 같아요.

한편으로는 아쉬움이 많이 남지만 해당 과정에서 tailwind의 동작과정을 짧게나마 알아볼 수 있었고 유명한 개발자와 소통을 해볼 수 있었다는 뿌듯함이 있었던것 같습니다. 또한 해당 부분의 이슈들을 찾아보다 보니 자연스럽게 영어도 늘 수 있어서 재밌었습니다!

profile
FE 개발자

0개의 댓글