ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • HTML과 CSS 전처리, 템플릿의 표현력
    프로그래밍/Web 2022. 9. 11. 01:55

    별다른건 아니고, 예전에 찾았었던 자료들 정리나 하려고 쓰는 글.

    그래도 굳이 쓰게된 동기를 찾는다면 JSX와 CSS In JS의 표현력에 관해 발전할 여지가 있는지이다.

     

    PHP를 사용하던 페이스북이 XHP(xhp-php5-extension, XHP: Introduction)를 만들고, 대체할 제품으로 ReactJSX, CSS-In-JS(슬라이드)를 소개하더니, 종국에는 서버 컴포넌트까지 만들어 내면서 PHP를 대체 가능하게 만들어버렸다.

     

    난 이러한 과정에서 과연 표현력의 손실이 있는가가 궁금했다.

    우리가 보지 못한 다른 가능성이 있는가도 말이다.

     

    전처리, 템플릿 엔진

    전처리기는 입력 데이터를 처리해 다른 프로그램에 대한 입력으로 사용되는 출력물을 만들어내는 프로그램이다.

    템플릿 엔진은 결과 문서를 생성하기 위해 데이터 모델과 템플릿을 결합하는 프로그램이다.

    템플릿 엔진이란?

     

    전처리기는 정적, 템플릿 엔진은 보다 동적인 형태라 할 수 있겠다.

    전처리기와 달리 외부의 데이터를 가공해서 사용한다는게 특징이라면 특징.

     

    HTML

    HTML 템플릿 엔진들

    템플릿 엔진 비교

    먼저 HTML부터 시작하자.

     

    Markdown 및 마크업 종류

    마크다운은 문서 작성용으로 최고다.

    일부 문법은 아쉽지만 Org Mode, 모니위키, 미디어위키, AsciiDoc과 함께 읽기좋은 마크업 언어중 하나.

     

    단점으로 뽑자면 Includes와 같은 전처리기의 필수 기능이 없다.

     

    Pug 류

    Pug, Haml, Slim류는 XML 문법의 흔적을 제거하고 파이썬 내지는 Emmet과 비슷하게 만든다.

    코드를 칠때는 좋으나, 인덴트만으로 구분하므로 명확한 블럭이 없어 읽을때가 괴로운 것 같다.

     

    PHP, ERB 류

    반면 ERB, Liquid, Nunjucks등은 HTML에 상대적으로 가깝게 만들어져 있다.

    HandlebarsMustache의 {{# start}}{{/ end}}보다 {% start %}{% end %}가 보기 좋은 것 같다.

    전반적인 문법은 Nunjucks가 가장 깔끔해보이는 듯.

     

    그러고보니 Nunjucks도 모질라 작품이네;;

    아무래도 나랑 모질라가 취향이 잘 맞나보다.

     

    Thymeleaf, Jinja2, Velocity, Freemarker등도 모두 이쪽에 가깝다.

     

    JSX와의 비교

    난 SFC도 매력적이지만 Ryan Carniato와 동일한 이유로 JSX 방식을 선호한다.

     

    JSX는 자바스크립트 스펙을 사용하기 때문에 Local variables, Filters, Math에서는 완벽한 우위라 할 수 있다.

    Templating과 Includes는 {변수} 혹은 {JSX.Element}처럼 Curly brace로 사용이 가능하고, Slot쪽은 Props와 HOC 패턴으로 그럭저럭 대체 가능하다. [RadixUI Slot, Vue Slots in React, React Slot]

    <head>쪽도 JSX의 특성상 사용하지는 못하지만 react-helmet-async로 구현이 가능하다.

     

    JSX의 가장 커다란 아쉬움은 Logic과 Loops라 할 수 있겠다.

    삼항/비트 연산자와 map()으로 가능하긴 하나 중첩된 삼항 연산자가 나타나는등 복잡한 조건이라면, 읽기 쉽지 않은 것이 사실이다.

    SolidJS는 Control Flow라 하여 지원하고 있으며, 컴포넌트이기 때문에 리엑트도 하려면 React If처럼 가능하긴 하다.

    SolidJS의 제어 컴포넌트들을 보면 상당히 직관적이고 유연하다.

    // 일반적인 If-Else 조건
    <Show when={state.count > 0} fallback={<div>Loading...</div>}>
      <div>My Content</div>
    </Show>
    
    // state.user 값이 변경될때만 실행하며, state.user 값을 받아서 사용할 수 있음
    <Show when={state.user} fallback={<div>Loading...</div>}>
      {(user) => <div>{user.firstName}</div>}
    </Show>
    
    // 여러가지 조건이 필요할 때
    <Switch fallback={<div>Not Found</div>}>
      <Match when={state.route === "home"}>
        <Home />
      </Match>
      <Match when={state.route === "settings"}>
        <Settings />
      </Match>
    </Switch
    
    // 변경된 항목만 효율적으로 업데이트하는 참조 키 루프
    <For each={state.list} fallback={<div>Loading...</div>}>
      {(item, index) => (
        <div>
          #{index()} {item}
        </div>
      )}
    </For>

     

    이는 앵귤러JS에서 ng-ifng-repeat이라 하여 디렉티브(Directive)라는 이름으로 더 간결하게 제공하고 있었다.

    후속제품인 앵귤러Vue에서도 제공하는 기능으로 리엑트로 오면서 손실된 기능이라 할 수 있다.

     

    다음 케이스의 대부분은 Control Flow 컴포넌트와 겹치는 것 같지만, 라인수가 줄어들어서 선호하는 사람이 있을 수도.

    역시 SolidJS의 방식이 꽤 좋은데, 리엑트에 익숙할 독자들을 위해 리엑트가 디렉티브를 지원한다 가정해 약간 변형해보았다.

    function model(el, value) {
      const [field, setField] = value;
      useEffect(() => el.value = field, [el, field]);
      el.addEventListener("input", (e) => setField(e.target.value));
    }
    
    function Input() {
      const [name, setName] = setState("");
      return <input type="text" use:model={[name, setName]} />;
    }

     

    디렉티브에서 주목할 것은 useRef(null)을 쓸일이 줄어들 수 있다는 점. (디렉티브는 ref에 대한 문법 설탕에 가깝다)

    전역 디렉티브는 JSX 네임스페이스에 추가해야할 필요가 있으므로, 익명 디렉티브도 보고싶다.

    function Input() {
      const focus = (el) => {
        el.focus();
      }
    
      return <input type="text" use={focus} />;
    }

     

    기타 언급해볼만한 것들

    XSLT와 Xquery

    XSLT(Extensible Stylesheet Language Transformations)는 XML을 다른 형식으로 변환할 수 있도록 만든 포맷이다.

    XSLT 문서를 보면 알겠지만 템플릿 엔진들과 비슷한 면과 문법들이 존재한다.

     

    XQuery는 이거.. JSX아냐? 라는 생각이 들만큼 비슷하며,

    문법의 하위집합인 XPATH는 XML 노드들을 참조하는 방법으로 우리에게 익숙한 파일 경로방식을 사용한다.

     <html><body>
     {
       for $act in doc("hamlet.xml")//ACT
       let $speakers := distinct-values($act//SPEAKER)
       return
         <div>
           <h1>{ string($act/TITLE) }</h1>
           <ul>
           {
             for $speaker in $speakers
             return <li>{ $speaker }</li>
           }
           </ul>
         </div>
     }
     </body></html>

     

    SaonicaJSXSLT와 XPATH를 지원하는 대표적인 라이브러리다.

     

    JSP

    JSP는 PHP와 거의 유사한데 Scriptlet, Declaration, Expression으로 나뉘어 있다.

    복잡한 스크립팅이나 정의는 Return 보다는 함수 본문에 있는게 맞다고 생각하기에 Scriptlet이나 Declatation이 굳이 JSX에 필요하다 생각하지는 않는다.

     

    JSP의 문제 중 하나는 WAS를 사용해 서블렛으로 변환하는 과정이 필요하다.

    Web Server와 WAS의 차이와 웹 서비스 구조

     

     

    Marko

    Marko는 HTML 확장의 끝판왕인 템플릿 엔진이다.

    앞서나온 Pug, PHP 종류 문법의 장점만을 가져왔으며, 자바스크립트와의 연동이 충분히 고려되어있다.

    인수 타입은 컴파일 시간에 검사할 수 있으니 크게 문제될 것은 없어보인다.

    // Emmet과 비슷
    <div.count/> // <div class="count"/>
    <span#my-id/> // <span id="my-id"/>
    
    // 자바스크립트의 값들을 바로 사용가능
    <div class=myClassName/>
    <input type="checkbox" checked=isChecked />
    <custom-tag string="Hello"/>
    <custom-tag number=1/>
    <custom-tag template-string=`Hello ${name}`/>
    <custom-tag boolean=true/>
    <custom-tag array=[1, 2, 3]/>
    <custom-tag object={ hello: "world" }/>
    <custom-tag variable=name/>
    <custom-tag function-call=user.getName()/>

     

    이번에는 제어구문을 보자.

    if문은 default argmument, for문은 children callback을 네이티브하게 지원해주고 있다.

    {% start %}{% end %}보다도 깔끔하게 설계 되었고, For문에 있어 Fallback기능이 없어 아쉬울뿐 자바스크립트의 문법을 모두 가능하다.

    ${}의 보간방식도 자바스크립트의 Template literal과 일관성이 있어 호감.

    Vue의 명명된 슬롯과 비슷한 attribute tag도 제공한다.

    // 조건문
    <if(user.loggedOut)>
      <a href="/login">Log in</a>
    </if>
    <else-if(!user.trappedForever)>
      <a href="/logout">Log out</a>
    </else-if>
    <else>
      Hey ${user.name}!
    </else>
    
    // For 문
    // 배열: <for|item, index, array| of=array>
    // 객체: <for|key, value| in=object>
    // for문: <for|value| from=first to=last step=increment>
    <ul>
      <for|color, index| of=colors>
        <li>${index}: ${color}</li>
      </for>
    </ul>
    
    // @는 attribute 태그
    <my-select>
      <@option>A</@option>
      <@option>B</@option>
    </my-select>
    // 사용법
    <my-select
      option=[
        { renderBody: "A" },
        { renderBody: "B" }
      ]
    />

     

    이벤트, 컨텍스트, 비동기 문등을 보아도 더 없이 깔끔해보인다.

    // 이벤트
    <button on-click(() => alert("Clicked! 🎉"))>
      Celebrate click
    </button>
    
    // 컨텍스트
    <context coupon=input.coupon on-buy(handleBuy)>
      <!-- Somewhere nested in the container will be the buy button -->
      <fancy-container/>
    </context>
    <context|{ coupon }, emit| from="fancy-form">
      Coupon: ${coupon}.
      <button on-click(emit, "buy")>Buy</button>
    </context>
    
    // 비동기
    <await(searchResultsPromise)>
      <@then|person|>
        Hello ${person.name}!
      </@then>
      <@catch|err|>
        The error was: ${err.message}.
      </@catch>
    </await>
    
    // 태그 이름 보간
    <${input.renderBody} number=1337 />
    
    // 재사용
    <macro|{ name }| name="welcome-message">
      <h1>Hello ${name}!</h1>
    </macro>
    <welcome-message name="Patrick"/>
    <welcome-message name="Austin"/>

     

    이는 XML과 거의 흡사한 JSX 스펙보다는 HTML에 가깝고 유연하게 만들어져서 가능했다.

    깔끔한 제어구문들은 2021년에 소개된 Marko Tags API라는 새로운 설계의 결과다.

     

    HTMX

    HTMX는 HTML 속성들을 확장해 AJAX, CSS Transition, Websocket등을 사용가능하게 만든다.

    일종의 디렉티브라 생각해도 되겠다.

     

    다음과 같이 작동할 수 있다면 멋지지 않을까?

    HTMX기준 인코딩application/x-www-form-urlencoded를 사용하는 것 같다.

    function MyComponent() {
      return (
        <div>
          <div id="response-div" />
          <button
            use:post="/register"
            use:target="#response-div" // 또는 useRef 값
            use:swap={<CancleButton />}
          >
              Register!
          </button>
        </div>
      );
    }

     

    HLisp와 Hiccup

    컴포넌트가 함수라면 tag들도 함수로 나타낼 수 있지 않을까?

    그리고 속성들은 키워드 인수를 사용한다면 모두 함수처럼 나타낼 수 있지 않을까?

    Hoplon이라는 클로져 스크립트 라이브러리가 HLisp문법을 사용해 이 방식으로 동작한다.

    function Counter() {
      const [count, setCount] = useState(0);
      return div([
        h1(count, class="header", style={ fontSize: "12px" }),
        button("+1", onClick=(() => setCount(count + 1))),
        button("-1", onClick=(() => setCount(count - 1)))
      ]);
    }

     

    리스프 특유의 매크로까지 동원한다면 가능한 표현력은 극도로 상승한다.

    물론 리엑트에서 위와 같이 작성한다면 매번 호출되며 Hooks가 작동하지 않는 사소한(?) 단점이 있다.

     

    그렇다면 보다 정적인 구조로 바꾸어주어야 한다.

    클로저의 세계에서는 키워드벡터에 들어갈 수  있으므로 각 태그들은 키워드를 사용하고, 컴포넌트 함수들도 벡터에 넣음으로서 정적인 구조를 만들 수 있다.

    속성들이야 Map을 사용하면 되고.

    ReagentHiccup이 이 방식으로 작동한다.

     

    정리

    JSX 자체는 충분히 훌륭하다.

    XML에 가까운 적은 문법의 추가만으로 직관적인 HTML 표현을 해주었다.

    그러나 Marko의 구문이 더 깔끔하며, 가지 않은 길임에는 틀림없어 보인다.

     

    Marko 구문을 쓸 수 있다면 Best이겠지만 Babel, 에디터 지원등 전체적인 생태계를 갈아엎어야 하기 때문에 현실적으로 힘들다고 생각한다.

    대신 Control Flow 컴포넌트들을 제공하고, 디렉티브를 지원하게 된다면 어떨까.

     

    예를 들어 다음과 같은 카운터 예가 있다고 해보자.

    function Counter() {
      const [count, setCount] = useState(0);
      return (
        <div>
          <button onClick={() => setCount(count + 1)}>+1</button>
          <button onClick={() => setCount(count - 1)}>-1</button>
          <Header count={count} />
          <Lists count={count}>
            {
              (i) => <li key={i}>{i}</li>
            }
          </Lists>
        </div>
      );
    }
    
    function Header({ count }) {
      if(count > 5) {
        return <p>{count}</p>;
      }
      const SizedHeader = `h${ count > 0 ? count : 1 }`;
      return <SizedHeader>{count}</SizedHeader>;
    }
    
    function Lists({ count, children }) {
      const list = Array.from(
        {length: count },
        (_, i) => children(i)
      );
      return (
        <ul>
          {list}
        </ul>
      );
    }

     

    Control Flow 컴포넌트들을 제공하고, 디렉티브를 지원하면 이렇게 만들 수 있다.

    Array.from( { length: count }, () => {})같은 이상한 구문을 생각해야하는 오버헤드가 줄어들고 코드는 선언적으로 변한다.

    function Counter() {
      const [count, setCount] = useState(0);
      return (
        <div>
          <button onClick={() => setCount(count + 1)}>+1</button>
          <button onClick={() => setCount(count - 1)}>-1</button>
          <Header count={count} />
          <Lists count={count}>
            {
              (i) => <li key={i}>{i}</li>
            }
          </Lists>
        </div>
      );
    }
    
    function Header({ count }) {
      const SizedHeader = `h${ count > 0 ? count : 1 }`;
      return (
        <If condition={count > 5}>
          <Then>
            <p>{count}</p>
          </Then>
          <Else>
            <SizedHeader>{count}</SizedHeader>
          </Else>
        </If>
      );
    }
    
    function Lists({ count, children }) {
      return (
        <ul use:each={count}>
          {
            (_, i) => children(i)
          }
        </ul>
      );
    }

     

    그리고 JSX를 더욱 확장해 Marko 방식과 결합하면 더욱 간결하게 만들 수 있을 것이다.

    Marko Tags API 레퍼런스와 비교했을때 엄밀하게는 차이가 있는데,  JSX와 보다유사하게 만든 의사코드일 뿐이므로 양해바란다.

    그래도 몇가지 원칙하에 작성했으니 적당한(?) 트랜스파일러가 있다면 변환 가능하지 않을까

    • JSX의 슈퍼셋
    • 태그 선언 내에서 보간은 ${}로 함
    • Marko와 비슷하지만, JSX처럼 {}를 사용하므로 태그내에서 Object는 ({ key: value })처럼 사용해야 함
    function Counter() {
      const [count, setCount] = useState(0);
      return (
        <div>
          <button onClick() { setCount(count + 1); }>+1</button>
          <button onClick() { setCount(count - 1); }>-1</button>
          <Header(count) />
          <Lists|i| count={count}>
            <li key={i}>{i}</li>
          </Lists>
        </div>
      );
    }
    
    function Header(_, count) {
      return (
        <If(count > 5)>
          <p>{count}</p>;
        </If>
        <Else>
          <h${count > 0 ? count : 1}>{count}</>
        </Else>
      );
    }
    
    function Lists({ count, children }) {
      return (
        <ul use:for|i| use:in(count)>
          {children(i)}
        </ul>
      );
    }

     

    CSS

    CSS 관리와 표현의 문제들

    CSS의 표현력에 관해서는 트위터깃허브갤에 이미 작성한 적이 있다.

    그리고 다음 글은 반드시 읽어보기 바란다.

     

    위 글에서 나오는 문제의식들이 있다.

    1. Global namespace: 모든 스타일이 전역적으로 선언되어 중복되지 않는 class 이름을 적용해야 하는 문제
    2. Dependencies: CSS와 JS간의 의존관계를 관리하기 힘든 문제
    3. Dead Code Elimination: 기능 추가, 변경, 삭제 과정에서 불필요한 CSS를 제거하기 어려운 문제
    4. Minification: 클래스 이름의 최소화 문제
    5. Sharing Constants: JS 코드와 상태 값을 공유할 수 없는 문제
    6. Non-deterministic Resolution: CSS 로드 순서에 따라 스타일 우선 순위가 달라지는 문제
    7. Breaking Isolation: CSS의 외부 수정을 관리하기 어려운 문제(캡슐화)

    그리고 트위터에서 지적되었던 문제들도 있다.

    1. 조합적 폭발: 고대비, 다크모드, 모바일-태블릿-데스크톱-TV를 다루려면 16가지에 대응이 되야하며 OS, RTL등등을 지원하려면 더 많은 조합들이 $2^n$으로 발생한다
    2. 다차원적 위계: CSS의 시각적 위계와 HTML의 정보구조 위계
    3. 디자인 일관성: 디자인적 규칙을 일관적으로 관리할 수 있어야 함

     

    위에서는 조합적 폭발이 별개 아닌 것처럼 보이지만, 실제로 "올바르게" 구성하기에는 많은 어려움이 따른다.

    내가 유지관리하고 있는 프로젝트의 탭 바의 레이아웃 구성을 만드는 코드(정의, 컴파일 결과)를 보고와보자 ㅋㅋㅋㅋㅋ

    브라우저 버전, OS, 활성화 가능한 창 버튼, 전체화면 여부, One liner나 숨기기와 같은 레이아웃 종류등등 많은 것을 모두 고려해야 했다.

     

    해결하기 쉬운 문제들

    위의 문제들의 순서에는 일관성 없으니 그룹핑부터 해도록 하자.

     

    CSS와 JS 통신

    먼저 CSS In JS를 사용함으로서 매우 쉽게 해결이 가능한 문제들이다.

    JS 만으로 CSS를 관리하면 상태 값 공유는 JS에 있는 값을 사용하면 되고, 의존 관계 역시 JS의 값이 우선되므로 해결이 가능하다.

    • Sharing Constants: JS 코드와 상태 값을 공유
    • Dependencies: CSS와 JS간의 의존관계를 관리

     

    라이브러리 구현에 있어서는 CSS var가 큰 역할을 해주지 않았나 싶다.

     

    Class 이름

    Class 이름의 많은 것은 CSS module이 해결해 줄 수 있다.

    여기서 핵심은 쉐도잉 지원으로, Scoped CSS의 긍정적인 면이라 할 수 있다.

    What are CSS Modules? A visual introduction

    • Global namespace: 해시값을 추가적으로 적용하면 전역적으로 고유해짐
    • Minification: 해시값만 남기면 클래스 이름을 축소하기가 쉬움
    • Non-deterministic Resolution(일부): BEM 방법론처럼 Specificity를 하나로 관리하도록 만들어 일부 해결
    • Breaking Isolation(일부): 이름이 겹치지 않으므로(무책임하지만ㅠㅠ) Scoped class를 하나 더 만들어 확장하기

     

    트위터 불평글에서 Scoped CSS에 관해 내가 공감한다는 부분이 바로 Non-deterministic Resolution과 Breaking Isolation이다.

    이는 복잡도 부채를 뭉게거나 땜빵처리함으로서 달성하고 있으며 완전한 해결책이라고는 볼 수 없다.

     

    논리와 반복적 표현

    그리고 일부 해결이 가능한 문제가 더 있다.

    • 조합적 폭발(일부): 전처리기나 CSS In JS에서 논리와 반복적 표현이 가능해짐으로 인해 "잘 작성하면" 해결 가능

    역시 트위터 글에서 지적하듯, 각 위계에 따른 표현을 해결해야 적절한 관리가 가능해진다.

     

    해결하기 어려운 문제들

    컴파일러 인프라

    • Dead Code Elimination: AST를 만들고 번들러를 이용해 추적해야 하며, Split된 CSS는 런타임시 어떻게 불러와야 할지에 대한 문제가 생긴다.

     

    디자인 표현

    • 조합적 폭발: 조합을 쉽고 버그없이 할 수 있어야 한다
    • 다차원적 위계: 시각적 위계와 의미론적 위계를 결국 모두 다룰 수 있어야 한다

     

    관리 문제

    • Non-deterministic Resolution: 스타일 우선 순위를 개발자가 쉽게 정할 수 있도록 해야 한다
    • Breaking Isolation: 확장 대상의 CSS를 명시적으로 알 수 있으며, Override와 Emit이 쉬워야 한다
    • 디자인 일관성: 중앙에서 관리할 필요가 있다

     

    전처리기와 CSS In JS: 리터럴 구문

    아직도 해결해야 할 문제들이 많지만, 도화지가 될 매체의 장점과 한계는 분명히 알고 있어야 도움이 된다.

    또한 우리가 찾지못했던 문제의 공백이 있을지도 모른다.

    다양한 CSS In JS를 먼저 살펴보지 않은 이유는 전처리기가 일반 CSS와 비슷하기 때문이다.

     

    이 파트 부터는 표현력이 좋은 임의의 CSS In JS 라이브러리가 있다는 가정하에 진행한다.

    새로운 CSS In JS 라이브러리에 대한 제안이 될 수도 있겠다.

    올해 말쯤에 프로젝트들이 정리되면 만들어보고 싶긴한데...

    제작하려는 목적으로 설계하는 것처럼 진행하느라 조금 늘어짐은 감안바란다. (일종의 RFC 느낌)

    또한 정적 CSS 추출등의 최적화를 감안하지 않은 API들이 있다.

     

    Template String vs Object Style

    CSS In JS 세계에서 CSS를 표현할 때는 두가지 방법이 있다.

    Template string과 Object style이다.

     

    그냥 사용하기에는 템플릿 문자열이 좋아보인다.

    CSS 속성과 문법을 그대로 사용가능하기 때문이다.

    템플릿 문자열 vs 객체 스타일

     

    그러나 오브젝트 스타일은 CSSOM과 동일한 표현방식이고 많은 보간이 필요할 때 나은 방식이다.

     

    타입스크립트를 사용하면 타입을 지원한다는 장점 또한 존재한다.

    CSS In JS를 사용하는 이유는 기본적으로 많은 규칙을 다루고, JS와의 상호작용을 위해 있다.

    때문에 CSS In JS을 사용한다면 Object 스타일을 기본으로 두어야 한다.

     

    디자인 규칙이 적고 JS와의 상호작용이 불필요하다면 CSS 전처리기를 사용함이 더 낫다.

     

    CSS In JS의 근본적 한계

    이제 Object Style을 기본적으로 사용한다 가정하고 단점들을 파보도록 하자.

     

    CSS In JS의 근본적 한계라 하면, CSS 문법이 리터럴이 아니라는 것이다.

    때문에 컬러코드는 기본적으로 에디터에서 picker등이 동작하지 않으며, CSS 유닛("px", "rem", "vw"등)과 값들("visible", "space-between" 등)을 String으로 취급하지 않고는 사용할 수가 없다.

    // 이하 css()를 클래스이름을 생성하는 임의의 CSS In JS 함수로 취급한다
    const sample = css({
      // 색상
      color: "red",
      background: "#EEEEEE",
    
      // 단위
      fontSize: "16px",
      fontSize: "1rem",
    
      // 값
      visibility: "visible",
      justifyContent: "space-between"
    });

     

    우선, 이 중에서 컬러코드는 확장기능(VS Code, Emacs) 덕분에 어느정도 해결이 가능하다.

     

    단위 기능을 CSS가 아닌 범용 언어에서 구현이 가능하냐고 묻는다면 러스트글에도 적었지만 F#은 구현해두었다.

    타입시스템과 언어 구문의 지원이 필요하지만 진지하게 UI와 스타일링을 다루려면 필요한 기능이라 생각된다.

    let dpiValue = 150.0<dpi>
    let inchValue = 8.0<inch>
    let pxValue = 1200.0<px>

    CSS의 각 단위에 대한 변환은 내가 포크해서 작성해둔 라이브러리를 참고하면 된다.

     

    이 대신 잇몸으로..

    px과 같이 일반적인 값을 받는 속성이라면, JSS의 default-unit이나  Vanilla Extract의 unitless property처럼 정의 할 수는 있겠다.

    const sample = css({
      // cast to pixels
      padding: 10,
      marginTop: 25,
    
      // unitless properties
      flexGrow: 1,
      opacity: 0.5
    });

     

    속성의 이름을 지을때도 한계가 있다.

    파싱 문제로 인해 특수기호 중 '$'와 '_'만이 사용가능하고 "::before"이나 ".my-selector"처럼 다른 특수기호가 들어갈 경우에는 "로 감싸줘야 동작한다.

    const sample = {
      $name$: "$ is literal",
      _name_: "_ is literal",
      "::before": "is not literal",
      ".my-selector": "is not literal"
    };

     

    SCSS와 중첩구문

    SCSS가 선도한 부분은 중첩구문이다.

    길다란 font-size, font-weight 속성을 중첩 가능하도록 만들고, &를 이용해 직관적인 셀렉터 중첩도 지원한다.

    .sample {
      width: 200px;
      font: {
        /* 속성중첩 */
        size: 10px;
        weight: bold;
      }
    
      /* 셀렉터 중첩 */
      p {
        color: red;
      }
      &:hover p {
        background: blue;
      }
      a & {
        background: grey;
      }
    }

    다음은 컴파일된 결과.

    .sample {
      width: 200px;
      font-size: 10px;
      font-weight: bold;
    }
    .sample p {
      color: red;
    }
    .sample:hover p {
      background: blue;
    }
    a .sample {
      background: grey;
    }

     

    CSS In JS에서는 다음과 같이 할 수 있을 것 이다.

    • 단일 값은 속성의 값으로 취급
    • 값이 객체 형식일때, 속성에 "&"이 포함되면 셀렉터로 취급
      Emotion의 방식이며, Vanilla Extract의 complex selector 방식은 중첩을 강요해 별로라고 생각한다
    • 값이 객체 형식일때, 속성에 "&"이 포함되지 않으면 중첩 프로퍼티로 취급
    const sample = css({
      width: 200,
      font: {
        size: 10,
        weight: "bold"
      },
    
      "& p": {
        color: "red"
      },
      "&:hover p": {
        background: "blue"
      },
      "a &": {
        background: "grey"
      }
    });

     

    Default unit의 예처럼 무언가 간단하게 만들 수 있지 않을까?

    Vanilla Extract에서는 Simple pseudo selectors라 하여 Pseudo-classesPseudo-elements는 "&" 표시가 없어도 되게 만들었다. (자식 선택자 ">"나 "[attribute]"에도 적용될 수 있다)

    :hover나 ::before는 대부분의 경우 부모요소를 사용하므로 더 나은 기본값이라 할 수 있다.

    그러나 파싱 문제 때문에  "로 감싸는 구문을 강요한다는 문제가 있다.

    const sample = css({
      ":hover": {
        background: "blue"
      },
      "::before": {
        background: "grey"
      }
    });

     

    Panda CSS는 _로 시작하는 Conditional Styles을 가지고 있다. ('_'과 '$'만이 속성이름으로 자유롭게 사용할 수 있다)

    또한 조건 스타일때 base 속성은 원본에 적용되도록 하여 관심사를 모을 수 있는 Property based condition 중첩구문을 지원한다.

    Panda CSS 접근법의 문제라면 _dark가 &.dark, .dark &를 의미하는 것처럼 임의의 클래스를 삽입하는 Adhoc스러움이다.

    이는 프레임워크를 위한 프리셋이면 모를까 리터럴 구문으로 적용하기에는 맞지 않다.

     

    대신 _가 :으로 치환되는 방식을 사용함이 낫다.

    추가된 규칙을 정리해보자.

    • 값이 객체 형식일때, 속성의 시작이 "_"과 "__"일 경우 각각 ":"과 "::"로 대체
    • 값이 객체 형식일때, 속성의 시작이 ":"일 경우 "&:"로 컴파일
    • 값이 객체 형식일때, base나 presudo 셀렉터를 적용가능
    const sample = css({
      background: "#EEEEEE",
      _hover: {
        background: "blue"
      },
      __before: {
        background: "grey"
      },
    
      // 또는
      background: {
        base: "#EEEEEE",
        _hover: "blue",
        __before: "grey"
      }
    });

     

    감이 좋은 사람이라면, 무언가 이상하다는 점을 알아차렸을 것이다.

    속성 중첩과 조건 중첩이 충돌할 수 있다는 점이다.

    다행히 "base"와 동일한 이름을 가지는 속성이 없으므로 충돌하지 않는다.

    const sample = css({
      width: 200,
      margin: {
        // 속성 중첩
        base: "5% 0",
        block: "0.5rem",
        inline: "3%",
    
        // 조건 중첩
        _hover: "6% 0",
        __before: {
          base: "2rem 3rem",
          block: "2rem",
          inline: "3rem"
        }
      },
    });

     

    비교적 사용이 적을 프로퍼티 중첩의 이름 마지막에 $가 있으면 명시적으로 사용하는 방식도 생각해보았지만, 오히려 어색했다.

    const sample = css({
      width: 200,
      margin$: {
        block: "0.5rem",
        inline: "3%",
        __before: {
          block: "2rem",
          inline: "3rem"
        }
      },
    
      margin: {
        base: "5% 0",
        _hover: "6% 0",
        __before: base: "2rem 3rem"
      }
    });

     

    여러가지 셀렉터를 함께 사용할 때는 속성에 ","가 있을 때를 감지해 split하면 된다.

    • 값이 객체 형식일때, 속성에 ","이 포함되어 있으면 여러 셀렉터가 있음
    const sample = css({
      "_hover, _active": {
        color: "red"
      }
    });

     

    Less와 속성 병합

    Less의 가장 큰 특징은 lazy하다는 점이다.

    그러나 자바스크립트는 strict함이 기본이기에 좋지 않은 방식이라 CSS In JS에 적용하기에는 좋지 않은 방식이라 생각한다.

    import하는 방식도 재미있지만, 자바스크립트 세계에서는 번들러가 해줘야 할 일이라고 생각한다.

     

    변수 이름 기반 참조도 마찬가지다.

    window["VAR_NAME"] 또는 this["VAR_NAME"]처럼 참조할 수는 있지만, 너무 비직관적이고 실수를 조장한다고 생각한다.

    C언어의 포인터에서 헷갈리는 사람들이 많음을 감안해보자.

    /* 일관성을 위해 SCSS 구문처럼 사용 */
    $primary: green;
    .section {
      $color: primary;
    
      .element {
        color: $$color; /* green */
      }
    }

     

    하지만 Merge properties은 꽤 유용해보인다.

    box-shadowtransform처럼 길다란 속성들은 한줄로 작성하기가 어려웠기 때문이다.

    .sample {
      /* Merge with Comma */
      box-shadow+: inset 0 0 10px #555;
      box-shadow+: 0 0 20px black;
    
      /* Merge with Space */
      transform+_: scale(2);
      transform+_: rotate(15deg);
    }
    .sample {
      box-shadow: inset 0 0 10px #555, 0 0 20px black;
      transform: scale(2) rotate(15deg);
    }

     

    자바스크립트에서 특수문자는 앞서 언급했듯이 _와 $만 사용가능하다.

    스페이스는 _과 비슷하므로 콤마에는 $을 붙이는게 좋을듯 하다.

    • 속성 이름의 마지막에 '$'가 있으면 이어지는 속성을 콤마로 합치고, '_'가 있으면 스페이스로 합친다
    const sample = css({
      /* Merge with Comma */
      boxShadow$: "inset 0 0 10px #555",
      boxShadow$: "0 0 20px black",
    
      /* Merge with Space */
      transform_: "scale(2)",
      transform_: "rotate(15deg)"
    });

     

    CSS In JS 세계(Emotion, Vanilla Extract)에서는 속성 값의 Fallback을 위한 고유한 접근법도 있다.

    const sample = css({
      // In Firefox and IE the "overflow: overlay" will be
      // ignored and the "overflow: auto" will be applied
      overflow: ['auto', 'overlay']
    });
    .sample {
      overflow: auto;
      overflow: overlay;
    }

     

    위의 구문들도 배열을 사용해버리면 어떨까?

    • 값이 배열일 때 일반적인 경우면 Fallback으로 취급
    • $로 끝나면 ','로 이으며, _으로 끝나면 공백으로 이음
    const sample = css({
      overflow: ["auto", "overlay"],
      boxShadow$: ["inset 0 0 10px #555", "0 0 20px black"],
      transform_: ["scale(2)", "rotate(15deg)"]
    });

     

    또는 griffel의 shorthands처럼 함수를 제공할 수도 있다.

     

    Stylus와 참조

    Stylus참조에 대한 아이디어가 재미있다.

    /* 역시 SCSS 구문처럼 수정됨 */
    .sample {
      &_level1 {
        &_level2 {
          /* 루트 */
          ^[0]:hover {
            width: 0px;
          }
    
          /* 정방향 */
          ^[1]:hover {
            width: 1px;
          }
          ^[2]:hover {
            width: 2px;
          }
          
    
          /* 역방향 */
          ^[-1]:hover {
            width: -1px;
          }
          ^[-2]:hover {
            width: -1px;
          }
        }
      }
    }

     

    그러나 깊이를 숫자로 표현하면 Less의 변수 이름기반 참조처럼 혼동이 올 여지가 커보인다.

    .sample:hover {
      width: 0px;
    }
    
    .sample_level1:hover {
      width: 1px;
    }
    .sample_level1_level2:hover {
      width: 2px;
    }
    
    .sample_level1:hover {
      width: -1px;
    }
    .sample:hover {
      width: -2px;
    }

     

    대신 XPath와 비슷한 경로 기반 참조는 사용하기 좋아보인다. (CSS In JS에 적합하지 않다고 생각하지만..)

    .sample
      &_level1 {
        &_level2 {
          /* 항상 루트 참조 */
          /:hover {
            width: 0px;
          }
    
          /* 항상 첫번째 참조 */
          ~/:hover {
            width: 1px;
          }
    
          /* 상대 참조 */
          ../:hover {
            width: 2px;
          }
        }
      }
    }
    :hover {
      width: 0px;
    }
    .sample:hover {
      width: 1px;
    }
    .sample_level1:hover {
      width: 2px;
    }

     

    다른 라이브러리중에서는 JSS의 셀렉터 참조의 아이디어도 좋아보인다.

    const styles = {
      container: {
        // Reference the local rule "button".
        '& $button': {
          padding: '10px'
        },
      },
      button: {
        color: 'grey'
      }
    }
    .container-0:focus .button-1 {
      color: blue;
    }
    .button-1 {
      color: grey;
    }

     

    문제는 앞서 속성에 "&"이 포함되야 셀렉터로 취급한다고 정의했기 때문이다.

    그렇다면 어떻게 해결할 수 있을까?

     

    답은 Vanilla Extract의 셀렉터 참조 방식이다.

    const parent = css({
      width: "1px"
    });
    
    const child = css({
      [`${parent} &`]: {
        width: "2px"
      }
    });
    .parent {
      width: 1px;
    }
    .parent .child {
      width: 2px;
    }

     

    이 방법은 시스템적으로 중첩을 줄이는 설계를 권장하므로 좋다.

    문제는 Vanilla Extract가 그렇듯 상호 참조가 일어날때는 접근자를 사용해야 할 필요가 있다.

    const parent = css({
      [`&:hover ${child}`]: {
        width: "1px"
      }
    });
    const child = css({
      get [`${parent} &`]() {
        return {
          width: "2px"
        }
      }
    });
    .parent:hover .child {
      width: 1px;
    }
    .parent .child {
      width: 2px;
    }

     

    이럴때는 JSS의 방식으로 돌아가보자.

    클래스가 첫번째 레벨에만 있다고 가정한다면 (Vanilla extract의 styleVariants, Griffel의 makeStyles)

    JSS와 같이 로컬 규칙에서 명시적으로 참조할 수 있지 않을까?

    이를 다루어주는 함수를 하나 추가하자.

    const classes = cssVariants({
      parent: {
        ":hover $child": {
          width: "1px"
        }
      },
      child: {
        "$parent &": {
          width: "2px"
        }
      }
    });

     

    selector 참조기능에 대한 탐색을 충분히 한 것 같다.

    속성값 참조는 어떨까? stylus에서 완성도 있게 제공하고 있다.

    .sample {
      width: 100px;
      margin-left: @width;
      margin-top: -(@width / 2);
    }
    .sample {
      width: 100px;
      margin-left: 100px;
      margin-top: -50px;
    }

     

    CSS In JS에서는 어떻게 기능을 제공할 수 있을까?

    그냥 참조할 때는 Stylus와 똑같이 해도 되겠지만, 계산이 필요한 경우에는  명시적인 계산을 하도록 해야 한다.

    const sample = css({
      width: 100,
      marginLeft: "@width",
      marginTop: "calc(-(@width / 2))"
    });

    컴파일 되기 전 중간 형식은 역시 접근자를 활용하면 쉽게 구현할 수 있다.

    const sample = css({
      width: 100,
      get marginLeft() {
        return this.width;
      }
      get marginTop() {
        return -(this.width / 2);
      }
    });

     

    PostCSS와 CSS 확장

    3대 CSS 전처리기를 살펴보았는데, 뒤따라 항상 언급되는 존재가 있다.

    바로 CSS계의 Babel인 PostCSS.

     

    그 중에서도 postcss-preset-env는 기본값으로 다양한 문법을 확장해준다.

    예를 들어 간편한 미디어쿼리 범위가 있다.

    @media (480px <= width < 768px) {
      .sample {
        font-family: system-ui;
      }
    }
    @media (min-width: 480px) and (max-width: 767.98px) {
      .sample {
          font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif;
      }
    }

     

    벤더 prefix도 적절히 잘 해주니, 기본값으로 설정 후 세부적인 룰은 유저들에게 맡기면 될 것 같다.

     

    정리

    좋다. 이제 (가상)CSS In JS의 리터럴 구문이 탄생했다.

    이해를 돕기위한 예일 뿐이므로, 보통은 다음처럼 길게 중첩될일이 없을 것이다.

    const sample = cssVariants({
      parent: {
        width: 200, // Unitless
        margin: {
          // 속성 중첩
          base: "5% 0",
          block: "0.5rem",
          inline: {
            base: "3%",
            "h1 &, & div": "4%" // 셀렉터 중첩
          },
    
          // 조건 중첩
          _hover: "6% 0",
          __before: {
            block: "2rem",
            inline: "calc(@block / 2)" // 속성 참조
          }
        }
      },
      child: {
        // 셀렉터 참조
        "$parent &": {
            overflow: ["auto", "overlay"],  // Fallback
            boxShadow$: ["inset 0 0 10px #555", "0 0 20px black"], // 콤마로 속성 병합
            transform_: ["scale(2)", "rotate(15deg)"]  // 공백으로 속성 병합
        },
        // PostCSS 연계
        "@media": {
          "width < 480px": {
            font-family: system-ui
          },
          "480px <= width < 768px": {
            color: color-mix(in lch, purple 50%, plum 50%)
          }
        }
      }
    });
    .parent {
      width: 200px;
      margin: 5% 0;
      margin-block: 0.5rem;
      margin-inline: 3%;
    }
    h1 .parent,
    .parent div {
      margin-inline: 4%;
    }
    
    .parent:hover {
      margin: 6% 0;
    }
    .parent::before {
      margin-block: 2rem;
      margin-inline: 1rem;
    }
    
    .parent .child {
      overflow: auto;
      overflow: overlay;
      box-shadow: inset 0 0 10px #555, 0 0 20px black;
      transform: scale(2) rotate(15deg);
    }
    
    @media (max-width: 479.98px) {
      .child {
        font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif;
      }
    }
    @media (min-width: 480px) and (max-width: 767.98px) {
      .child {
        color: rgb(175, 92, 174);
      }
    }

     

    설계가 꽤 합리적이지 않은가?

    CSS-to-vanilla-extract처럼 변환해주는 툴링이 있으면 더욱 편히 사용할 수 있을 것이다.

     

    스타일 합성: 동적 케이스

    합성(Composition)

    방금 전 리터럴 구문은 사실상 CSS 클래스 1개일때를 위한 문법이다.

    function Sample() {
      return <div className={css({ color: "red", padding: 3 })}>내용</div>;
    }

     

    현실적으로는 여러가지 클래스가 사용되기 마련이다.

    function Sample() {
      const activate = useState(true);
      return <div className={`class1 class2 ${activate && "class3"}`}>내용</div>;
    }

     

    이럴때 필요한 방식이 합성이다.

    Emotion, Vanilla Extract등에서 사용되어 검증된 방법.

    function Sample() {
      const activate = useState(true);
      return <div className={css(["class1", "class2", activate && "class3"])}>내용</div>;
    }

     

    API Wrapper로만 만들면 상관없겠지만..

    모든 부분을 직접 구현하려면, Vanilla Extract가 언급하듯 단일 클래스로 컴파일 되어야 특이도 문제가 생기지 않는다.

    The Shorthand-Longhand Problem in Atomic CSS라는 글도 읽어봄직하다.

    const base = css({ padding: 12 });
    const primary = css([base, { background: "blue", "p &": { color: "red" } }]);
    .base {
      padding: 12px;
    }
    .primary {
      background: blue;
    }
    p .primary {
      color: red;
    }

     

    UI = f( State )

    리엑트와 Flutter가 주장하듯 UI는 함수라고 생각할 수 있다.

    플루터

     

    그렇다면 Style이 함수가 아닐 이유가 있는가?

    Fela의 접근법과 동일하며, 이 철학에 따라 Flutter는 Padding도 위젯으로 구현한다.

     

    const sample = css((props) => ({
      color: "red",
      background: props.bgColor
    }));
    
    function Sample() {
      return <div className={sample({ bgColor: "blue" })}>내용</div>;
    }

     

    평가된 CSS는 다음과 같아야 할 것이다.

    .sample {
      color: red;
    }
    .sample_background_wnefh {
      background: blue;
    }

    이렇게 만들어두면 다양한 케이스에 대응 가능할 것이다.

    • sample({ bgColor: "#fff" })처럼 다른 색상이 사용되면 새로운 클래스 생성
    • sample()처럼 값이 들어있지 않은 경우에 .sample만 평가되어 사용

     

    리엑트의 컨텍스트를 활용하는 Lucid도 재미있지만 이 방식이 더 깔끔하다고 생각한다.

    대신 property가 함수일때는 함수를 평가하는 방식을 지원하는 방법은 좋을 수도?

    const sample = css({
      color: "red",
      background: (props) => props.bgColor
    });
    
    function Sample() {
      return <div className={sample({ bgColor: "blue" })}>내용</div>;
    }

     

    한가지 문제는 return 타입 추론이 어려워진다는 점이다.

    때문에 이렇게 다이나믹한 경우는 항상 함수로 return하는 함수로 분리를 할 수도 있다.

    // 함수가 인수이므로 타입추론 가능
    const sample1 = css(({ color }) => ({
      color
    }));
    
    // 속성이 함수면???
    const sample2 = css({
      color: ({color}) => color
    });

     

    대략 이렇게 해보자는 이야기입니다.

    // string(class이름) 반환
    const static = css({
      color: "red",
    });
    
    // function 반환
    const dynamic = rules(({ color }) => ({
      color,
      background: ({ bg } = { bg: "blue" }) => bg
    }));
    
    function Sample() {
      return <>
        <div className={
      // 항상 sting 반환
      css([
        static,
        dynamic,  // background: blue로 자동평가
        dynamic({ // background: #222로 class 생성
          bg: "#222"
        }),
        dynamic({ // color: white, background: blue
          color: "white"
        })
      ])
      }>내용</div></>;
    }

     

    이제 Mixin이 하는 일의 상당부분도 커버할 수 있게 되었다!!

     

    인라인 CSS: 시각적 위계

     

    인라인과 AtomicCSS의 장단점

    시대를 거슬러 올라가 인라인 스타일을 사용하던 시대를 상상해보자.

    <p>일반 텍스트<span style="color: red; text-decoration: underline">빨간색과 밑줄</span></p>

     

    인라인하는 방식이 작성하기는 쉬웠으나 여러 단점들이 있었다.

    1. 미디어 쿼리, 선택자, 의사요소, 애니메이션등의 기능제한
    2. 반복적으로 작성해야 하며, 문서의 사이즈도 커짐
    3. 컨텐츠와 서식의 분리가 불가능
    4. 길어져서 읽기가 힘듦
    5. 의미의 구분이 어려움

     

    다행히 1번과 2번 문제는 Atomic CSS(또는 Utility first)기반의 프레임워크들(Tailwind, Windi CSS, Uno CSS, )이 나오면서 해결되었다.

     

    첫번째는 문법을 클래스로 만들어 버리는 것이다.

     

    게다가 Atomic CSS 고유의 장점이 생겼으니, 클래스이기 때문에 다음에서 "text-red-600"처럼 동일한 시각적인 요소를 가질경우 한번만 정의가 된다.

    <p>일반 텍스트<span class="text-red-600 decoration-solid">빨간색과 밑줄</span></p>
    <div class="text-red-600 bg-white">요소 1</div>

     

    때문에 장기적으로 CSS 파일의 크기가 줄어들 수 있다.

     

    위의 이미지에 있는 것처럼 타이핑 줄이기와 편리한 문법도 장점이며,

    CSS 자체와는 디커플링이 되므로 text-red-600이 머터리얼 컬러시스템의 빨간색에서 부트스트랩 컬러시스템의 빨간색으로 바뀐다고 하여도 변경하지 않아도 된다.

    또한 TailwindCSS처럼 체계적으로 스타일들을 미리 만들어두면 개발자는 값을 가져다가 쓰기만 하면 그만이다.

     

    반복되는 표현? tailwind의 @apply 같은게 있다면 해결할 수 있으며, 의미론적인 표현도 어느정도 달성할 수 있다.

    .select2-dropdown {
      @apply rounded-b-lg shadow-md;
    }
    .select2-search {
      @apply border border-gray-300 rounded;
    }
    .select2-results__group {
      @apply text-lg font-bold text-gray-900;
    }

     

    이로서 인라인 기능제한과 반복적 표현 문제를 어느정도 피하고 편리한 유틸들이 생겨나는등 장점이 있지만,

    한편으로는 Atomic CSS 고유의 단점들도 생긴다.

    미리 만들어두는 스타일들을 사용하게 되면 커스텀이 어려워지고, 프레임워크의 독단적인 문법을 사용하므로 기존 CSS 지식을 사용하기 어려워진다.

    독단적인 문법이므로 타입시스템 활용이나 코드 하이라이팅도 상대적으로 어렵다.

     

    이제 한번 정리해두고 가자.

     

    장점

    • 다양한 CSS 기능 사용 가능
    • 타이핑을 줄이고, 편리한 문법 사용 가능
    • CSS의 시각적 값들을 중앙에서 조정 가능
    • 체계적으로 만들어졌을 때 값을 가져다가 쓰기만 하면 됨
    • 특정한 크기 이상 부터는 컴파일 결과가 완만히 증가

     

    단점

    • 커스텀과 임의의 CSS 작성이 어려움
    • CSS에서 알던 속성들과 지식 활용이 어려운 독단적 문법
    • 타입시스템과 하이라이팅 활용이 어려움
    • 여전히 반복적으로 작성 (기존)
    • 컨텐츠와 서식의 분리가 불가능 (기존)
    • 의미의 구분이 어려움 (기존)

     

    AtomicCSS 개념분해와 역할

    여기서 여러가지 이슈가 혼재함을 알 수 있다.

    1. 자체적 문법
      • 장점: 다양한 CSS 기능을 사용 가능하게 하고, 구문을 짧게 줄여 편리
      • 단점: CSS에서 알던 문법과 다르고, 타입시스템과 하이라이팅 활용이 어려움
    2. 속성의 이름과 값
      • 장점: 타이핑을 줄이며, CSS의 시각적 값들을 중앙에서 조정 가능
      • 단점: CSS에서 알던 속성과 값을 활용하기 어려움
    3. 프레임워크
      • 장점: 체계적으로 만들어졌을 때 값을 가져다가 쓰기만 하면 됨
      • 단점: 커스텀이 어려움

     

    먼저 자체적 문법에 대해 생각해보자.

    리터럴 문법과 비교했을때 "다양한 CSS 기능을 사용 가능"이 장점이 될 수 있는가?

    아니다.

     

    그렇다면 구문을 짧게 줄여주기 정도만 장점이 되고,

    오히려 기존 문법과 다르다거나, 타입시스템과 하이라이팅 활용의 어려움처럼 해결하기 어려운 근본적인 문제가 생겨난다.

     

    자체문법은 아쉽지만... 버려야만 한다.

    !important -> !처럼 간단한 문법은 CSS In JS 컴파일 과정이나 PostCSS Plugin으로 제공해 충분히 해줄수 있다.

     

    이제 2가지만이 남았다.

    프레임워크와 메타-프레임워크 구성을 위한 속성과 값 매핑 도구.

    결국 AtomicCSS가 존재하는 목적은 인라인 CSS와 같은 시각적인 매핑이다.

     

    앞서 @apply를 통해 의미론적으로 만들수는 있다고 했지만,

    의미론적인 방법을 제대로 사용하려면  daisyUI처럼 프레임워크를 새로 재창조하는 수준을 거쳐야 한다.

    <button class="bg-indigo-600 px-4 py-3 text-center text-sm font-semibold inline-block text-white cursor-pointer uppercase transition duration-200 ease-in-out rounded-md hover:bg-indigo-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-600 focus-visible:ring-offset-2 active:scale-95">
      Tailwind Button
    </button>
    <button class="btn btn-primary">
      daisyUI Button
    </button>

     

    따라서 AtomicCSS에서 @apply같은 합성구문은 시각적 요소를 결합되는 용도로 사용해야지,

    어중간하게 의미론을 따지려하지 말아야 한다.

    프레임워크와 메타-프레임워크 구성을 위한 디자인 토큰 생성과 속성과 값 매핑 도구만으로 사용하자는 것 이다.

     

    시각적 요소의 결합으로 설명할 수 있는 대표적인 예는 텍스트 색과 배경색을 들 수 있다.

    라이트모드와 다크모드까지 고려했을 때 4가지의 클래스를 하나로 묶어취급함은 괜찮지 않은가?

    • text-black bg-white
    • dark:text-white dark:bg-gray-900

     

    어찌되었건 위계가 다르다면 배중률이 적용되며, 다른 레이어에서 전문적으로 다루도록 하자.

     

    AtomicCSS 프레임워크와 테마

    시각적 표현을 위한 좋은 프레임워크의 조건은 무엇이 있을까?

    1. 색, 폰트, 간격, 레이아웃, 반응형등 다양한 시각적 요소를 다룸
    2. 시각적 요소들의 구성이 체계적이어야 함
    3. 특정 까다롭거나 반복적인 패턴들은 묶어서 제공
    4. 관용적인 프리셋 값들

     

    시각적 요소와 구성

    이 중에서 1번과 2번은 기본적으로 스타일가이드 이야기에 가까우므로 프로그래머 관점에서 다루는 글의 범위를 넘어선다. 하고픈 이야기는 있지만..

     

    대신 알아두어야 할 점은 구성에 있어 각종 스펙과 룰을 정한다는 것은 기본적으로 top-down 형태의 일이라는 점이다.

    대표적인 스펙으로는 W3C 디자인토큰, Styeld SystemThemeUI를 들 수 있다.

    예를 들어 다음과 같은 스펙이 있다고 가정해보자.

    const theme = {
      spaces: [0, 4, 8, 16, 32, 64],
      colors: {
        text: "#000",
        background: "#fff",
        primary: "#07c"
      }
    };

     

    space와 colors는 Theme key라 할 수 있다.

    그리고 각 테마키의 값들은 특정한 CSS 프로퍼티들에서 사용될 수 있다.

    Styled System 기준으로 사용되는 프로퍼티 규약을 적용했을 때 다음과 같다.

    • space는 margin,  padding, gird-gap 등에 사용
    • colors는 color, background-color, border-color에 사용

    예컨대 background-color: "primary"와 border-color: "primary"는 "#07c"라는 색상을 사용하게 되는 것이다.

    사실 테마쪽은 Vanilla Extract가 너무나도 작업을 잘해놓았기 때문에 소소한 개선만 필요할뿐, 특별한 불만이 없다.

    하지만 단일값, 배열, 중첩등을 테마가 충분히 다룰 수 있기 때문에 우선 확장했다.

    그리고 직접 값을 다루기 위해 values를 추가했다.

    const { className: themeClass, vars, values } = theme({
      // 단일
      rootSize: "10px",
    
      // 배열
      spaces: [0, 4, 8],
    
      // 중첩
      colors: {
        text: "#000",
        background: "#fff",
        indigo: {
          600: "#4f46e5",
          700: "#4338ca"
        }
      }
    });
    
    const { className: otherThemeClass } = theme(vars, {
      rootSize: "1rem",
      spaces: [0, 2, 4],
      colors: {
        // text는 그대로 활용
        background: "blue",
        indigo: {
          600: "#5a46e5",
          700: "#4338ca"
        }
      }
    });

     

    위의 JS와 CSS는 각각 다음 모습과 같을 것이다.

    const themeClass = "themeClass";
    const otherThemeClass = "otherThemeClass";
    
    const themeClassValues = {
      rootSize: "var(--rootSize)",
      spaces: ["var(--spaces-0)", "var(--spaces-1)", "var(--spaces-2)"],
      colors: {
        text: "var(--colors-text)",
        background: "var(--colors-background)",
        indigo: {
          600: "var(--colors-indigo-600)",
          700: "var(--colors-indigo-700)"
        }
      }
    };
    
    const themeClassValues = {
      rootSize: "10px",
      spaces: [0, 4, 8],
      colors: {
        text: "#000",
        background: "#fff",
        indigo: {
          600: "#4f46e5",
          700: "#4338ca"
        }
      }
    };
    
    const otherThemeClassValues = {
      rootSize: "1rem",
      spaces: [0, 2, 4],
      colors: {
        text: "#000",
        background: "blue",
        indigo: {
          600: "#5a46e5",
          700: "#4338ca"
        }
      }
    };
    .themeClass {
      --rootSize: 10px;
    
      /* 자동적 변환이 안됨!! */
      --spaces-0: 0;
      --spaces-1: 4;
      --spaces-2: 8;
    
      /* 중첩은 속성이 이어써짐 */
      --colors-text: #000;
      --colors-background: #fff;
      --colors-indigo-600: "#4f46e5";
      --colors-indigo-700: "#4338ca";
    }
    
    .otherThemeClass {
      --rootSize: 1rem;
    
      --spaces-0: 0;
      --spaces-1: 2;
      --spaces-2: 4;
    
      --colors-text: #000; /* 재활용된 text */
      --colors-background: blue;
      --colors-indigo-600: "#5a46e5";
      --colors-indigo-700: "#4338ca";
    }

     

    CSS 결과물을 상상해보는 과정에서 한가지 문제를 발견했다.

    자동 변환이 작동하지 않는다.

    어쩌면 당연한게 현재 상태로 적용될 property를 알 수 없기 때문이다.

     

    대신 언급했던 W3C 스펙을 가지고 계약에 의한 토큰 설계를 할 수 있을 것이다.

    const { className: themeClass, contracts } = theme({
      spaces: tokenConstract({
        $type: "dimension",
        $value: [0, 4, 8]
      }),
      colors: tokenConstract({
        $type: "color",
        $value: {
          text: "#000"
        }
      })
    });
    
    const myContracted = themeContract({
      spaces: {
        $type: "dimension"
      },
      colors: {
        text: {
          $type: "color"
        }
      }
    })
    // contracts나 myContracted 사용, 이제 자동 변환이 수행된다
    const { className: newThemeClass } = theme(contracts, {
      spaces: [3, 6, 9],
      colors: {
        text: "#888"
      }
    });

     

    이제 테마의 마지막이다.

    하지만 설계를 크게 바꾸어야 할 문제다.

    참조를 하고 싶을 때는 어떻게 해야 하지?

    const { vars } = theme({
      rootSize: "10px",
      get newSize() { return this.rootSize },
    
      spaces: [0, 4, 8],
      spaces: {
        small: &spaces[0],
        medium: &spaces[1],
        large: &spaces[2]
      },
    });

     

    newSize는 rootSize와 똑같은 기본값을 가지는 새로운 값을 만들고 있고,

    두번째 space는 답이 없어보인다.

    물론 다음과 같이 할 수 있긴 하다 ㅋㅋ

    const { vars } = theme({
      spaces: defineProperty([0, 4, 8], {
        small: (arr) => arr[0],
        medium: (arr) => arr[1],
        large: (arr) => arr[2]
      }),
    });
    
    function defineProperty(arr, properties) {
      const customProperties = Object.keys(properties).reduce((acc, key) => {
        acc[key] = {
          get: function () {
            return properties[key](this);
          }
        };
        return acc;
      }, {});
    
      return Object.defineProperties(arr, customProperties);
    }

     

    다만 유저가 원하는 방식은 아닐 것이다.

    따라서 라이브러리가 자동적으로 수행하도록 theme()를 변경해야 할 필요가 있다.

    다음은 내가 새로 제안하는 Theme 함수 제안이다.

    • consts: 규약되고 JS에서 값은 존재하나, var()로 사용되지 않는 값들
    • values: 규약되고, var()로 나타나는 값들
    • alias: 새로운 var가 생기지 않고, 자바스크립트 상에서만 참조함
    • fallbacks: values에 있는 값들을 참조할 때
    • clones: values와 기본값만 같고, 런타임에 상호변경 여지가 있을 때
    const { vars } = theme({
      // 상수
      consts: {
        constSize: "50%"
      },
    
      // 메인 값들
      values: {
        rootSize: "10px",
        spaces: tokenConstract({
          $type: "dimension",
          $value: [0, 4, 8]
        })
      },
    
      // 기존 var()를 사용하고 자바스크립트 상에서만 참조
      alias: ({ spaces }) => ({
        spaces: {
          // 구조는 동일해야 vars.spaces.small 처럼 사용가능
          small: spaces[0],
          medium: spaces[1],
          large: ({ spaces }) => spaces[2] // 각 속성마다 할 수도
        }
      }),
    
      // 새로운 var을 생성하지만 참조함
      fallbacks: ({ rootSize }) => ({
        sameSize: rootSize,
        otherSize: [rootSize, "1rem"]
      }),
    
      // 기본 값만 같은 하드포크
      clones: ({ rootSize }) => ({
        newSize: rootSize
      })
    });
    .themeClass {
      /* Consts는 자바스크립트에서만 접근 가능 */
    
      --rootSize: 10px;
    
      /* tokenConstract에 의한 기본값 변환!! */
      --spaces-0: 0px;
      --spaces-1: 4px;
      --spaces-2: 8px;
    
      /* Alias는 자바스크립트에서만 접근 가능 */
    
      /* Fallback */
      --sameSize: var(--rootSize);
      --otherSize: var(--rootSize, 1rem);
    
      /* 기본값만 같음 */
      --newSize: 10px;
    }

     

    css var() 값을 런타임 중에 바꾸는 가장 쉬운 방법 중 하나는 Vanilla Extract의 Dynamic이라는 패키지를 사용하는 것이다.

    다른 플랫폼으로 export가 필요할 경우는 아마존의 style-dictionary를 참고할 만하다.

     

    까다롭거나 반복적인 패턴

    SASS, Less, Stylus 같은 전처리들이 가지고 있는 각종 빌트인 함수들이 맨땅의 JS에게는 존재치 아니한다.

     

    대신 우리에게 polished라는 매우 준수한 라이브러리가 있다.

    이외에 AdorableCSS는 까다로운 Flexbox를 대체하기 위해 Autolayout와 Hbox, 까다로운 포지션 문제를 다루기 위해 PinLayout과 비슷한 Position Layer를 가지고 있다.

    PandaCSS 마찬가지로 Container, Stack, Wrap, Aspect Ratio등을 위한 많은 패턴들을 가지고 있다.

     

    미디어쿼리의 경우 css-in-js-media, vanilla-syrup, Sass MQ등처럼 편리하게 다룰 수 있는 것들이 있다.

    Keyframe도 마찬가지(Vanilla Extract, Fela)

     

    패턴뿐만 아니라 Box처럼 반복적인 패턴의 컴포넌트나 sanitize.css같은 reset 글로벌 스타일도 제공할 수 있다.

     

    관용적인 프리셋 값들

    관용적인 값들이란 무엇을 뜻할까?

     

    첫째, 다음처럼 원본을 변형해도 알아볼만한 값들이다.

    • w = width   h = height   m = margin   p = padding   b = border   r = border-radius
    • z = z-index   bg = background   c = color   mt = margin-top   pr = padding-right

     

    둘째, mastercss의 반응형 디자인 기준처럼 다양한 상황을 다룰 수 있어야 한다.

    • @3xs = 360px, iPhone 6, 7, 8, X, 11, 12 / Galaxy S8 / HTC One…
    • @2xs = 480px, Blackberry Passport / Amazon Kindle Fire HD 7 …
    • @xs = 600px, LG G Pad 8.3 / Amazon Kindle Fire …
    • @sm = 768px, Microsoft Surface / iPad Pro 9.7 / iPad Mini …
    • @md = 1024px, iPad Pro 12.9 / Microsoft Surface Pro 3 …
    • @lg = 1280px, Google Chromebook Pixel / Samsung Chromebook …
    • @xl = 1440px, Macbook Air 2020 M1 / MacBook Pro 15 …
    • @2xl = 1600px, Dell Inspiron 14 series …
    • @3xl = 1920px, Dell UltraSharp U2412M / Dell S2340M / Apple iMac 21.5-inch …
    • @4xl = 2560px, Dell UltraSharp U2711 / Apple iMac 27-inch …

     

    마지막으로 normalize.css에 reset.css의 더 나은 값을 사용하는 sanitize.css처럼 합리적이고 기본적인 값의 커버리지가 넓어야한다.

     

    이제 AtomicCSS 메타-프레임워크 툴링을 구상해볼 차례다.

     

    AtomicCSS 문법 쪼개기

    갑자기 시각적 요소 프레임워크를 위한 툴을 만들자고 하니 어찌 접근해야 할지 감이 잡히지 않을 수도 있다.

    그렇다면 어찌해야할까?

     

    AtomicCSS 프레임워크가 제공하는 문법과 기능들을 보고 구현이 가능하도록 만들어주면 된다.

    자체문법은 포기한다고 하지 않았나요?라고 묻는다면, AtomicCSS 프레임워크들이 제공하는 class 문법을 포기하자는 말이었지,

    Javacript 문법을 기반으로 하여 재구성하지 않겠다는 의미는 아니었다.

     

    AtomicCSS 프레임워크의 문법을 살펴보자.

    다음은 위에서 나왔던Tailwind  버튼의 예시다.

    <button class="bg-indigo-600 px-4 py-3 text-center text-sm font-semibold inline-block text-white cursor-pointer uppercase transition duration-200 ease-in-out rounded-md hover:bg-indigo-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-600 focus-visible:ring-offset-2 active:scale-95">
      Tailwind Button
    </button>

     

    총 4가지의 종류가 있음을 알 수 있다.

    • hover:bg-indigo-700
    • bg-indigo-600
    • px-4
    • uppercase

    이를 제외하면 임의의 값과 mastercss의 그룹 정도가 있으며, 나머지 구문들은 모두 위의 파생일 뿐이다.

     

    간단한 값부터 복잡하거나 예외적인 문법 순으로 거슬러 올라가보자.

    먼저 가장 간단한 uppercase는 "uppercase" 혹은 { uppercase: true }정도로 사용할 수 있으면 될 것이다.

    px-4는 { px: 4 }로 사용할 수 있다.

     

    그럼 bg-indigo-600은?

    우선 속성인 bg와 값인 indigo-600으로 나누어 { bg: "indigo-600" }가 된다.

    이때 indigo-600은 위에서 언급했던 테마의 색상으로 생각할 수 있으며, 타입시스템이 작용하도록 수정해야 할 필요가 있다.

    { bg: indigo[600] }이나 { bg: { indigo: 600 }}처럼 나타낼 수 있겠다.

     

    hover:bg-indigo-700에서 특이점은 hover일텐데, 우리가 앞서 정의했던 리터럴 구문을 활용하면 쉽게 정의할 수 있다.

    { _hover: { bg: indigo[700] } }

     

    임의의 값은 리터럴 CSS 값과의 합성을 하거나 Fallback을 할 수 있다.

    그룹은 리터럴 구문의 중첩 기능을 사용하면 구현할 수 있다.

    일단 합성을 고려하지 않고, 정리된 버전이다.

    // 리터럴과 같은 이름 - 사용법도 같음
    const { css } = defineRules({
      // 무언가 규칙정의
      // Value의 fallback 허용 활성화 가정
    });
    const sample = css({
      uppercase: true,
      px: 4,
      bg: indigo[600],
      _hover: {
        bg: indigo[700]
      },
    
      color: "#000", // Fallback 될 임의의 값
      "_hover, _active": {
        color: "red" // 중첩되어 그룹화된 값
      }
    });

     

    겉보기로는 간단해 보이긴 하나, 합성이 없어 아쉽고 indigo는 대체 어디서 생겨난 값인지 알 수 가 없다.

    이는 숨겨진 복잡성이 예상외로 커 twind, twin.macro, sprinkles등 수많은 Atomic CSS in JS들이 있음에도 무엇하나 시원하게 해결하지 못했던 이유이다.

     

    다행히 우리는 합성 파트의 통찰력을 따라갈 수 있다.

    const { css } = defineRules({
      // ...
    });
    const sample = css([
      "uppercase",
      ({ indigo }) => ({
        px: 4,
        bg: indigo[600],
        _hover: {
          bg: indigo[700]
        },
    
        color: "#000",
        "_hover, _active": {
          color: "red"
        }
      })
    ]);

     

    한가지 이상한 점이라면 함수를 사용하는데, css라는 이름이라는 점 때문이다.

     

    하지만..아까 테마를 사용하기로 했죠?

    직접 context를 노출해 주었다면 어떨까요?

    const { vars: themeVars } = theme({
      // ...
      colors: {
        indigo: {
          600: "#4f46e5",
          700: "#4338ca"
        }
      }
    });
    
    const { css } = defineRules({
      contexts: {
        indigo: themeVars.colors.indigo
      }
      // ...
    });

     

    rules가 첫번째에 context, 두번째에 유저 인수를 받는 구조라면 말이 된다!!

    const { css, rules } = defineRules({ /* ... */ });
    
    // 정적
    const static1 = css([
      "uppercase",
      {
        px: 4,
      }
    ]);
    
    // 여전히 정적임
    const sample2 = css([
      "uppercase",
      ({ indigo }) => ({
        bg: indigo[600],
        border: ({ indigo }) => indigo[700]
      })
    ]);
    
    // 동적
    const dynamic = rules([
      "uppercase",
      ({ indigo }, ({ size })) => {
        px: size,
        bg: (_, { bgColor }) => bgColor,
        
        color: indigo[600]
      }
    ]);
    
    function Sample() {
      return (
        <div
          className={css([
            static1,
            static2,
            dynamic, // color: indigo[600]으로 자동평가
            dynamic({
              // color: indigo[600], px: 6
              size: 6
            }),
    
            // 함수 - indigo[600], bg: indigo[700]
            dynamic(({ indigo }) => {
              bgColor: indigo[700];
            })
          ])}
        >
          내용
        </div>
      );
    }

     

    디자인 토큰 매핑

    본격적으로 구상해볼 차례이다.

    지금까지 살펴본 옵션은 2가지였다.

    fallback을 가능하게 해서 color: "#000"처럼 기본 CSS 값들을 쓸 수 있게 만들지(Strict mode 여부)와 context 제공.

    // css, rules, variants등 함수 제공
    const { css } = defineRules({
      strict: false, // 기본 CSS 문법들을 쓸 수 있게 strict 모드 해제
      contexts: {
        spaces: themeVars.spaces,
        indigo: themeVars.colors.indigo
      }
    });

     

    그런데 핵심적인 사안을 빼먹었다.

    바로 padding-inline인 px와 background인 bg로 매핑하는 능력이다.

    const { css } = defineRules({
      // rules에서 쓰였던 기능은 모두 사용 가능
      rules: {
        px: (value) => ({ paddingInline: theme.spaces[value] }),
        bg: {
          background: (value) => value
        },
    
        // 두번째 인수는 context
        px: (value, { spaces }) => ({ paddingInline: spaces[value] })
      }
    });

     

    px의 경우 theme.spaces에서만 가능한 값으로 제한이 되어있는 모습을 볼 수 있다.

    원본 css값에도 할 수 있을까? 이는 Vanilla Extract의 sprinkles가 잘 해주고 있다.

    const { css } = defineRules({
      // CSS에서 사용가능한 기능 제약
      properties: {
        // 배열에 있는 값만 허용
        display: ["none", "inline"],
        paddingLeft: theme.spaces,
    
        // 속성 전체
        color: true,   // strict: true 일때도 속성이 허용됨
        border: false, // strict: false 일때도 속성이 거부됨
      }
    });

     

    하나 더 찾아보자.

    px는 padding-left, padding-right라고 생각할 수도 있다.

    일전에 나온 griffel의 shorthands 상황처럼 제약이 있을 경우 paddingLeft, paddingRight 값을 연결해주어야 할 것이다.

    const { css } = defineRules({
      shortcuts: {
        pl: "paddingLeft",
        pr: "paddingRight",
        px: ["paddingLeft", "paddingRight"],
      }
    });

     

    shortcuts에서 매핑한 속성을 활용하고 싶을수도 있다.

    그리고 활용시 동적인 요소가 필요할 수 도 있다.

    const { css } = defineRules({
      shortcuts: {
        px: ["paddingLeft", "paddingRight"],
        py: ["paddingTop", "paddingBottom"],
    
        p: ["px", "py"], // 생성되며 계산
        get p() { return [this.px, this.py]; }, // 순수 문법 활용
    
        p: (useY) => ["px", useY && "py"] // 함수
      }
    });

     

    이제 지나치게 정적인 Vanilla Extract나 무서운 규식이를 쓰는 uno보다 작성하기 편해보이지만,

    무심코 빼먹은게 2개나 된다ㅠㅠ

    • 단일 값: 계속 쓰여왔던 uppercase등 AdorableCSS에서 언급했던 유니크한 값들
    • 조건부 스타일: Vanilla Extract sprinkles에 있는 그것, PandaCSS에서 리터럴로 있기에는 Ad-Hoc 하다며 비판했던 물건

     

    조금 전까지 하던 작업들과 비슷한 단일 값부터 생각해봅시다.

    기본적으로 원래 css 리터럴과 비슷하되, rules처럼 context 정도는 받아서 작업할 수 있다면 좋겠죠?

    const { css } = defineRules({
      values: {
        uppercase: {
          textTransform: "uppercase"
        },
        indigoBG: ({ indigo }) => ({
          background: indigo[600]
        })
      }
    });

     

    마지막 조건부 스타일이다.

    단일 셀렉터부터 Mixin과 비슷한 스타일까지 모두 가능하도록 구성했다.

    const { css } = defineRules({
      defaultCondition: "light",
      conditions: {
        // 단일
        light: "",
        dark: "&.dark, .dark &",
        
        // 여럿
        dark: ["&.dark", ".dark &"],
    
        // 중첩
        osLight: {},
        osDark: { "@media": "(prefers-color-scheme: dark)" },
    
        // 함수
        hovAct: (children) => ({
          _hover: {
            base: {
              ...children
            },
            _active: {
              ...children
            }
          }
        })
      }
    });

     

    기존보다 확연히 나아졌지만, API의 설계가 일부 잘못된게 있다.

    conditions를 그룹으로 묶었을 때, 3가지로 나타난다.

    • light-dark
    • osLight-osDark
    • hovAct

     

    이때 light와 osLight가 동시에 기본값이 될 수는 없는 걸까?

    그냥 배열로 표시하면 된다.

    const { css } = defineRules({
      defaultCondition: ["light", "osLight"],
      conditions: { /* ... */ }
    });

     

    이제 편리한 방법으로 토큰들을 기존 속성들에 매핑할 수 있게 되었다.

    하지만 관리의 문제는 아직 해결하지 못한다.

     

    위의 방식대로라면 One big rules 파일이 되어 제어가 어렵기 때문이다.

    Vanilla extract는 여기서 좋은 아이디어를 가지고 있다.

    바로 여러 룰을 만든 후 합성이다.

     

    Theme 정의를 할때처럼 규약들을 export하고 합치는 방법은 어떨까?

    const { contracts: responsiveContracts } = defineRules({ /* ... */ });
    const { contracts: colorContracts } = defineRules({ /* ... */ });
    
    const { css, rules } = combineRules(responsiveContracts, colorContracts);

     

    이제 상당히 쓸만해 보인다!!

    다른 세부적인 유틸과 기능이 필요할 수도 있겠지만, 이 글은 큰 그림을 위주로 다루기에 이만 끝을 내도록 하자.

     

    정리

    AtomicCSS는 인라인 스타일에서 비롯된 방법이므로, 시각적 위계에 최적화 되어 있음을 알 수 있었다.

    대표적으로 color: rgb(220 38 38);라는 값을 tailwind에서는 text-red-600만으로 표현해준다.

    이는 시각적 위계의 결합도가 줄어드는 추상화라 할 수 있다.

     

    지금은 .text-red-600가 rgb(220 38 38)일 수 있겠지만,

    디자이너의 필요에 의해 rgb(250, 40, 40)과 같은 값으로 조정하면 전체 시스템에 바로 적용된다는 것이다.

    이렇게 타이핑을 줄이고 한번의 추상화로 시각적요소들을 중앙 집중형으로 만들 수 있기도 하였지만 여러 문제가 파생하였다.

     

    독단적인 문법이라 기존의 지식을 활용할 수 없이 배움을 강요하고, 타입시스템과 하이라이팅 활용이 어려웠다.

    또한 이미 만들어진 시스템에 가까워 커스텀과 임의의 CSS 작성이 상대적으로 어려웠다.

    마지막으로 중앙 집중형 시스템이지만, 효과적으로 제어해주는 시스템이 부실했다.

     

    독단적 문법 문제는 타입스크립트를 활용하는 CSS In JS로 해결할 수 있다.

    defineRuels는 기존의 커스텀이 어려웠던 시스템에서 벗어나, 도메인마다 bottom-up으로 정의하여 개별의 팀들이 사용할 수 있는 Meta Atomic Util이다.

    대신 커다란 규칙을 만들때는 어려움이 있으므로, combineRules를 통해 결합할 수 있도록 했다.

     

    하지만, 라이트모드와 다크모드 시스템의 전환이나 Accent 색의 변경을 한번에 수행하려면 어떻게 할까?

    여기서 필요한게 중앙화된 규약의 존재며, Theme 정의가 빛을 발하는 지점이다.

    쉽게 말해 일반적으로 defineRules는 key 수준에서 동작하며, Theme는 Value 수준에서 동작한다.

     

    자, 새로운 Theme API를 정의했고, 디자인 토큰 매핑과 사용까지 총 3가지에 대한 재정의를 했다.

     

    Variants - 의미론적 위계

    들어서기

    Tailwind와 daisyUI의 사용을 다시 한번 보자.

    <button class="bg-indigo-600 px-4 py-3 text-center text-sm font-semibold inline-block text-white cursor-pointer uppercase transition duration-200 ease-in-out rounded-md hover:bg-indigo-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-600 focus-visible:ring-offset-2 active:scale-95">
      Tailwind Button
    </button>
    <button class="btn btn-primary">
      daisyUI Button
    </button>

     

    Tailwind 문법을 우리가 정의한 가상의 AtomicCSS 라이브러리로 순진하게 옮기면 대략 다음과 같이 표현할 수 있다.

    const { css } = defineRules({ /* ... */ });
    const buttonStyle = css([
      "uppercase",
      "transition",
    
      ({ indigo }) => ({
        bg: indigo[600],
        px: 4,
        py: 3,
        text: ["center", "sm"],
        font: "semibold",
        inline: "block",
        text: "white",
        cursor: "pointer",
        duration: 200,
        ease: "in-out",
        rounded: "md",
        _hover: {
          bg: indigo[700]
        },
        _focusVisible: {
          outline: "none",
          ring: {
            base: 2,
            color: indigo[600],
            offset: 2
          }
        },
        _active: {
          scale: 95
        }
      })
    ]);

     

    그렇다면 daisyUI는 이렇게 표현할 수 없는걸까?

    const button = Something({ /* ... */ });
    const buttonStyle = button("primary");

     

    또한 BEM의 Element와 Modifier를 이렇게 표현할 수는 없는걸까?

    <form class="form form--theme-xmas form--simple">
      <input class="form__input" type="text" />
      <input
        class="form__submit form__submit--disabled"
        type="submit" />
    </form>
    <form class={ form(["base", "simple", { theme: "xmas" }]) }>
      <input class={ form("input") } type="text" />
      <input
        class={ form({ sumit: "disabled" }) }
        type="submit" />
    </form>

     

    클래스가 1개와 매핑되던 의미론을 넘어서, 응집력을 갖추게 된다.

    게다가 "simple"과 theme: "xmas"가 동시에 존재해야 활성화하는 조건도 알아서 적용해준다고 상상 해봅시다.

     

    이제부터 우리가 하려는 일이다.

     

    기본기능

    앞에서 약간은 동적으로 보일 수 있는 Variants라는 기능을 만든적이 있었다.

    const classes = cssVariants({
      parent: {
        ":hover $child": {
          width: "1px"
        }
      },
      child: {
        "$parent &": {
          width: "2px"
        }
      }
    });

     

    가장 기본적인 사용법은 다음과 같을 것이다.

    function Sample() {
      return (
        <div className={classes.parent}>
          <div> className={classes["child"]}></div>
        </div>
      );
    }

     

    합성과 함수를 사용하는 모습도 보일 수 있다.

    const base = css({ padding: 12 });
    const classes = variants({
      parent: [base, {
        width: "1px"
      }],
      child: ({width}) => ({
        width
      })
    });
    
    function Sample() {
      return (
        <div className={classes.parent}>
          <div> className={classes.child(5)}></div>
        </div>
      );
    }

     

    게다가 Vanilla Extract는 mapping하여 여러 Variants를 찍어내는 기능을 제공한다.

    다음은 classes["small"]과 classes["large"]를 만들어낸다.

    const base = css({ padding: 12 });
    const classes = cssVariants(
      {
        small: "1rem",
        large: "10rem"
      },
      (size) => [base, { width: size }]
    );

     

    같은 네임스페이스를 가져 보기 응집력이 강화 되었고 함수를 사용할 수는 있지만,

    앞선 작업들을 하려면 로직을 하나씩 작업해줘야 할 것 같다.

     

    동적 Variants

    Variants로 유명한 stitches는 일반 룰 정의하는 함수 내부에서 variants를 설정할 수 있다.

     

    우리의 가상 라이브러리는 함수를 반환하는 rules에서 수행할 수 있다.

    const sample = rules({
      color: "red",
      variants: {
        color: {
          white: {
            color: "white",
            background: {
              base: "black",
              _hover: "gray"
            }
          }
        },
        outlined: {
          true: {
            borderColor: "blue"
          }
        }
      }
    });
    
    function Sample() {
      return (
        <div className={sample({ color: "white", outlined: true })}>
          내용
        </div>
      );
    }

     

    그러나 사용법이 우리가 정의했던 방법에 비해서 약간 아쉽다. 개선해보자.

    outlined의 경우 defineRules의 values처럼 toggleVariants블럭으로 옮긴 후, 호출시 배열을 활용해 직관적으로 만들 수 있다.

    const sample = rules({
      color: "red",
      toggleVariants: {
        outlined: {
          borderColor: "blue"
        }
      },
      variants: { /* ... */ }
    });
    
    function Sample() {
      return (
        <div className={sample(["outlined", { color: "white" }])}>
          내용
        </div>
      );
    }

     

    variants 구현에 있어 기존 함수형 프로퍼티를 사용해보는건 어떨까?

    앞서 말했듯 안타깝게도 variant 자체는 이미 switch-match 조건이 포함된 것과 다름 없기에 개선되기가 어렵다.

    대신 함수형 프로퍼티를 사용하면 컨텍스트의 값을 받아서 처리할 수 있어서 서로 보완적이다.

    // 똑같은 동작
    const sample1 = rules((props) => {
      if("color" in props) {
        if(props.color == "white") {
          return ({
            color: "white",
            background: props.bgColor
          });
        }
      }
    });
    
    const sample2 = rules({
      variants: {
        color: {
          white: {
            color: "white",
            background: ({ bgColor }) => bgColor
          }
        }
      }
    });

     

    Stitches에 있는 Variant는 이게 끝이 아니다.

    기본 Variants를 설정할 수 있음은 당연하고,

    const sample = rules({
      // 기본값
      defaultsVariants: ["outlined", {
        color: "white",
      }],
    
      // Variants 들
      toggleVariants: {
        outlined: { /* ... */ }
      },
      variants: {
        color: {
          white: { /*... */ },
          gray: { /*... */ },
        }
        size: {
          small: { /* ... */ },
          big: { /* ... */ }
        }
      }
    });

     

    무려 조건에 따른 패턴 매칭이 들어가 스타일 적용로직을 선언적으로 만든다.

    그리고 호출하면 컴파일 타임에 정적으로 클래스 이름이 생성된다.

    const sample = rules({
      /* 위와 같음 */
    
      // 패턴 패칭 후 적용
      compoundVariants: [
        ["outlined", { color: "white", css: { /* YOUR_STYLE */ } ],
        {
          color: gray,
          size: small,
          css: { /* YOUR_STYLE */ }
        }
      ]
    });

    간단한 케이스는 기본 Variants의 이름 참조를 통해서도 가능할 것이다.

    const sample = rules({
      variants: {
        color: {
          white: { /*... */ },
          gray: { /*... */ },
        }
        size: {
          small: { /* ... */ },
          big: {
            $white: {
              // CSS
            }
          }
        }
      }
    });

     

    아까 AtomicCSS에서도 적용 가능하지 않을까?

    const { css } = defineRules({
      defaultCondition: "light",
      conditions: {
        light: "",
        dark: ["&.dark", ".dark &"],
        osLight: {},
        osDark: { "@media": "(prefers-color-scheme: dark)" },
        hovAct: (children) => ({
          _hover: {
            base: {
              ...children
            },
            _active: {
              ...children
            }
          }
        })
      },
      compoundConditions: [
        {
          when: ["light", "osLight"],
          condition: "&.pureLight"
        },
        {
          when: ["light", "hovAct"],
          condition: (children) => ({ /* Something */ })
        }
      ]
    });

     

    가지 않은 길

    Stylable라는 CSS 전처리기의 Pseudo class를 보면 st-state라는 이름으로 흥미로운 기능을 제공한다.

    1. Boolean 타입, Enum 타입, String과 Number 타입으로 나뉨
    2. Enum은 특정한 문자열 인수만 가능하며, 기본값 정의 가능
    3. String/Number 타입은 각각의 타입 인수에 관한 validation 함수들을 설정 가능
    .sample {
      -st-states:
        /* Boolean 타입 */
        toggled,
        loading,
    
        /* Enum 타입 */
        size(enum(small, medium, large)),
        color(enum(red, green, blue)) green, /* green이 기본값 */
    
        /* String - Number 타입 */
        category(string),
        ranking(number),
        category(string(regex('^user'), minLength(5))), /* 유효성 검사들 */
        x(number(min(2), max(6), multipleOf(2)));
    }
    /* 상태 1개 */
    .sample:toggled {}
    
    /* 상태 2개 */
    .sample:toggled:loading {}
    
    /* size에 medium이 포함되어 있음 */
    .sample:size(medium) {}
    
    /* size에 huge가 없음 */
    .sample:size(huge) {}
    
    /* green이 기본값으로 사용 */
    .sample:color {}
    
    /* 유효성 검사를 거친 Stirng과 Number */
    .sample:category(kitchen) {}
    .sample:x(4) {}

     

    1, 2번은 boolean과 일반 Variant이며, 3번은 함수형 프로퍼티와도 같다.

    런타임에서 사용법도 비슷하다.

    import { st, classes } from './sheet.st.css'; 
    
    // active states
    st(classes.part, {
      isOn: true,      // boolean
      size: 'small'    // string or enum
      place: 1         // number state
    });
    
    // un-active states - only 'part' class
    st(classes.part, {
      isFirst: false,  // boolean
      size: undefined  // string or enum
      place: undefined // number
    });

     

    정리

    Stitches의 놀라운 작업 덕분에 API 일관성을 갖추기 빼고는 할 일이 없었다.

    AtomicCSS의 토큰을 Variants에서 쓰기 또한 그저 defineRules의 리턴값에서 함수를 할당받기 외에는 할 일이 없다.

    const { cssVariants, rules } = defineRules({ /* ... */ });
    
    const sample1 = cssVariants({
      parent: {
        ":hover $child": {
          w: "1rem"
        }
      },
      child: {
        "$parent &": {
          w: 2
        }
      }
    });
    
    // 함수의 첫번째는 입력 값, 두번째는 Context이다.
    const sample2 = ruels({
      c: "red",
      bg: "primary",
      variants: {
        color: {
          indigo: {
            bg: (_, {indigo}) => indigo[600]
          },
          custom: ({ color }) => ({
            bg: color.custom
          })
        }
      }
    });

     

    HTML & CSS & JS

    Styled Component

    지금까지 HTML + JS 그리고 CSS + JS를 다루었다.

    그렇다면 HTML과 CSS 조합을 인간친화적으로 만들면 어떨까?

     

    그게 바로 그 유명한 Styled Components 되시겠다.

    function Sample() {
      return <div>
        <Button>Normal Button</Button>
        <Button primary>Primary Button</Button>
      </div>;
    }

     

    WindiCSS의 Attributify Mode도 살펴보라.

    이 얼마나 훌륭한 생각인가?

    <button 
      bg="blue-400 hover:blue-500 dark:blue-500 dark:hover:blue-600"
      text="sm white"
      font="mono light"
      p="y-2 x-4"
      border="2 rounded blue-200"
    >
      Button
    </button>

     

    컴포넌트에 대한 attributify mode까지 작동되면 처음부터 끝까지

    구문의 동형성을 달성할 수 있다.

    // 리터럴 구문
    const base = css({ color: "#444" });
    const smaple1 = rules({ color }) => ({ background: color }));
    const sample2 = css(["pure-class-name", base, sample1({ color: "red" }), { width: 10 }]);
    
    // Atomic 구문
    const { css: atomic } = defineRules({
      // ...
    });
    const sample3 = atomic(["uppercase", { px: 4, _hover: { bg: "red" } }]);
    
    // variants 구문
    const variants = rules({
      variants: {
        // ...
      }
    });
    const sample4 = variants(["outlined", { accent: "pink" }]);
    
    // styled 구문
    const Sample5 = styled("button", { /* Something like rules() */ });
    const element1 = <Sample5 outlined accent="pink">Button</Sample5>;
    
    // Atomic Styled 구문
    const { styled: atomicStyled } = defineRules({
      // ...
    });
    const Sample6 = atomicStyled("div", { /* Something like atomic rules() */ });
    const element2 = <Sample6 px={4} bg={({ indigo }) => indigo[600]} />;

     

    이 글에서 제안한 함수는 10개도 되지 않지만 꽤나 강력합니다.

    • 핵심: css(), rules(),  defineRules(), styled()
    • 테마: theme()  tokenConstract(), themeConstract()
    • 기타: cssVariants(), combineRules()

     

    컴파일과 빌드 인프라

    빌드 인프라의 핵심은 편의성과 성능이라 할 수 있다.

    Marko like JSX구문이 아니라 스타일을 다루는데도 컴파일러 인프라는 편의성을 제공해준다.

     

    Vanilla Extract의 경우 스타일과 컴포넌트 코드를 함께 둘 수(co-location) 없고,

    Stylelint도 지원하지 않는데 모두 빌드 인프라의 문제다.

     

    이걸 어느정도 해결한게 Linariamacaron이다.

     

    개인적으로 최적화에 대한 아이디어는 CSS Blocks를 좋아한다.

     

    로직과 분리

    뷰와 로직, 그리고 뷰와 스타일은 분리가 가능한가?

    완전히는 아니지만 '격리'될 수는 있다.

     

    원래 HTML - CSS - JS가 그러했고,

    Literal - Theme - Atomic - Variants - Styled Component 순으로 쌓아 올린 가상의 CSS In JS 라이브러리 또한 CSS 관리를 각단계로 훌륭히 나눌 수 있음을 보여줬다.

     

    뷰 동작과 스타일의 분리의 예시로,

    어도비의 React Spectrum Libraries는 상태 - 동작 - 컴포넌트의 분리를 수행한다.

    아키텍처

    • State: 뷰에 대한 가정없이 상태만 관리
    • Behavior: 플랫폼에 따른 이벤트, 접근성, 국제화를 고려한 동작
    • Component:  State와 Behavior를 사용하고, 디자인 시스템과 함께 제공

     

    요즘 Headless UI, Radix Primitives, Ark-UI, Zag처럼 좋은 State, Behavior Components들을 제공하는 라이브러리가 늘어나고 있다.

    스타일에서도 Kuma UI처럼 스타일에 관한 Headless UI도 나오고 있다.

    또한 shadcn/ui처럼 좋은 가이드가 있어 적용하기에 좋지 않을까.

     

    각종 디자인 시스템에 대한 비교는 OpenUI가 잘 되어 있다.

     

    정리

    선언적이고 작성 / 관리하기 쉬운 구문과 라이브러리 API를 가상이지만 만들어보았다.

    개인적으로 그 결과가 깔끔하고 마음에 드는데 배경에는 몇가지 원칙이 있다.

    1. 로직을 나열하기보다는 선언적일 것
    2. 각 레이어의 API는 동형적으로 만들어져야 함
    3. 표현과 내용은 서로를 전제하므로 고려해야함
    4. 위계가(관점이) 다르면 배중률이 적용

     

    개선 가능한 부분들을 다시 한번 정리해보았습니다.

     

    HTML

    • JSX의 제어와 참조는 컴포넌트와 디렉티브를 만들어 개선할 수 있다
    • class와 ID는 emmet 구문을 사용해 직관적으로 표현할 수 있다
    • 프로퍼티에서 자바스크립트 구문을 그대로 사용할 수 있다
    • Default argmument과 Children callback관련 문법을 제공할 수 있다
    • 태그 이름을 직접 보간할 수도 있다

     

    CSS

    • 타입 안전해야 하며, 단위를 위해서는 언어적 지원이 필요
    • 리터럴 구문은 중첩, 속성 병합, 참조, 합성, 함수를 다룰수 있어야 함
    • Atomic CSS는 시각적인 위계를 다루고 디자인 토큰을 매핑하기 쉬워야 함
    • Atomic CSS를 잘 쓰기 위해서는 Theme에 대한 지원이 필요
    • Theme는 규약을 정할 수 있으며, CSS 컴파일 결과와 JS에서 사용을 고려해야 함
    • 중앙화된 프레임워크는 충분한 유틸리티와 관용적인 프리셋을 가져야 함
    • Variants는 의미론적 위계를 다루며, Atomic CSS의 활용도 충분히 가능
    • 리터럴부터 Styled 컴포넌트까지 거의 동일한 문법으로 사용가능
     

    끝.

    댓글

Designed by black7375.