CSSNestedDeclarations를 사용하면 CSS 중첩이 개선됨

게시일: 2024년 10월 8일

CSS 중첩의 몇 가지 이상한 버그를 수정하기 위해 CSS 작업 그룹은 CSSNestedDeclarations 인터페이스를 CSS 중첩 사양에 추가하기로 결정했습니다. 이번 추가로 스타일 규칙 뒤에 오는 선언이 더 이상 위로 이동하지 않으며, 그 밖에도 몇 가지 개선사항이 있습니다.

이러한 변경사항은 Chrome 버전 130부터 사용할 수 있으며 Firefox Nightly 132 및 Safari Technology Preview 204에서 테스트할 수 있습니다.

브라우저 지원

  • Chrome: 130
  • Edge: 130.
  • Firefox: 132.
  • Safari: 지원되지 않음

CSSNestedDeclarations 없이 CSS 중첩 문제

CSS 중첩의 문제 중 하나는 원래 다음 스니펫이 처음 예상한 대로 작동하지 않는다는 것입니다.

.foo {
    width: fit-content;

    @media screen {
        background-color: red;
    }
    
    background-color: green;
}

코드를 보면 <div class=foo> 요소에 green background-color가 있다고 가정할 수 있습니다. background-color: green; 선언이 마지막에 나오기 때문입니다. 하지만 버전 130 이전의 Chrome에서는 그렇지 않습니다. CSSNestedDeclarations를 지원하지 않는 버전에서 요소의 background-colorred입니다.

130 이전의 Chrome에서 실제 규칙을 파싱한 후 사용되는 방식은 다음과 같습니다.

.foo {
    width: fit-content;
    background-color: green;

    @media screen {
        & {
            background-color: red;
        }
    }
}

파싱 후 CSS에 두 가지 변경사항이 적용되었습니다.

  • background-color: green;가 위로 이동하여 다른 두 선언과 합류했습니다.
  • 중첩된 CSSMediaRule& 선택기를 사용하여 선언을 추가 CSSStyleRule로 래핑하도록 다시 작성되었습니다.

여기서 볼 수 있는 또 다른 일반적인 변경사항은 파서가 지원하지 않는 속성을 삭제하는 것입니다.

CSSStyleRule에서 cssText를 다시 읽어 '파싱 후 CSS'를 직접 검사할 수 있습니다.

이 대화형 플레이그라운드에서 직접 사용해 보세요.

이 CSS가 재작성되는 이유는 무엇인가요?

이 내부 재작성이 발생한 이유를 이해하려면 이 CSSStyleRule가 CSS 객체 모델 (CSSOM)에서 어떻게 표현되는지 이해해야 합니다.

Chrome 130 이전에서는 이전에 공유된 CSS 스니펫이 다음과 같이 직렬화됩니다.

↳ CSSStyleRule
  .type = STYLE_RULE
  .selectorText = ".foo"
  .resolvedSelectorText = ".foo"
  .specificity = "(0,1,0)"
  .style (CSSStyleDeclaration, 2) =
    - width: fit-content
    - background-color: green
  .cssRules (CSSRuleList, 1) =
    ↳ CSSMediaRule
    .type = MEDIA_RULE
    .cssRules (CSSRuleList, 1) =
      ↳ CSSStyleRule
        .type = STYLE_RULE
        .selectorText = "&"
        .resolvedSelectorText = ":is(.foo)"
        .specificity = "(0,1,0)"
        .style (CSSStyleDeclaration, 1) =
          - background-color: red

CSSStyleRule에 있는 모든 속성 중에서 이 경우 다음 두 가지 속성이 관련이 있습니다.

  • 선언을 나타내는 CSSStyleDeclaration 인스턴스인 style 속성
  • 중첩된 모든 CSSRule 객체를 보유하는 CSSRuleListcssRules 속성

CSS 스니펫의 모든 선언이 CSStyleRulestyle 속성에 끝나므로 정보가 손실됩니다. style 속성을 살펴보면 background-color: green가 중첩된 CSSMediaRule 뒤에 선언되었는지 알 수 없습니다.

↳ CSSStyleRule
  .type = STYLE_RULE
  .selectorText = ".foo"
  .style (CSSStyleDeclaration, 2) =
    - width: fit-content
    - background-color: green
  .cssRules (CSSRuleList, 1) =
    ↳ …

이는 CSS 엔진이 제대로 작동하려면 스타일 규칙 콘텐츠의 시작 부분에 표시되는 속성을 다른 규칙과 섞여 표시되는 속성과 구분할 수 있어야 하기 때문에 문제가 됩니다.

CSSMediaRule 내부의 선언이 갑자기 CSSStyleRule로 래핑되는 경우: CSSMediaRule가 선언을 포함하도록 설계되지 않았기 때문입니다.

CSSMediaRulecssRules 속성을 통해 액세스할 수 있는 중첩된 규칙을 포함할 수 있으므로 선언은 자동으로 CSSStyleRule로 래핑됩니다.

↳ CSSMediaRule
  .type = MEDIA_RULE
  .cssRules (CSSRuleList, 1) =
    ↳ CSSStyleRule
      .type = STYLE_RULE
      .selectorText = "&"
      .resolvedSelectorText = ":is(.foo)"
      .specificity = "(0,1,0)"
      .style (CSSStyleDeclaration, 1) =
        - background-color: red

이 문제를 해결하려면 어떻게 해야 하나요?

CSS 작업 그룹은 이 문제를 해결하기 위한 몇 가지 옵션을 살펴봤습니다.

제안된 해결 방법 중 하나는 중첩 선택자 (&)를 사용하여 모든 노출 선언을 중첩된 CSSStyleRule로 래핑하는 것이었습니다. 이 아이디어는 &:is(…)로 디슈가링될 때 발생하는 다음과 같은 원치 않는 부작용 등의 여러 가지 이유로 폐기되었습니다.

  • 이는 특이성에 영향을 미칩니다. 이는 :is()가 가장 구체적인 인수의 특정성을 인계받기 때문입니다.
  • 원래 외부 선택기의 가상 요소와는 잘 작동하지 않습니다. 이는 :is()가 선택자 목록 인수에서 가상 요소를 허용하지 않기 때문입니다.

다음과 같은 예시를 살펴보겠습니다.

#foo, .foo, .foo::before {
  width: fit-content;
  background-color: red;

  @media screen {
    background-color: green;
  }
}

Chrome 130 이전에는 이 스니펫을 파싱하면 다음과 같이 됩니다.

#foo,
.foo,
.foo::before {
  width: fit-content;
  background-color: red;

  @media screen {
    & {
      background-color: green;
    }
  }
}

& 선택기로 중첩된 CSSRule는 다음과 같은 이유로 문제가 됩니다.

  • :is(#foo, .foo)까지 아래로 평면화되며, 그 과정에서 .foo::before가 선택기 목록에서 삭제됩니다.
  • 나중에 덮어쓰기가 더 어려워지는 (1,0,0)의 특이성을 갖습니다.

규칙이 직렬화되는 항목을 검사하여 이를 확인할 수 있습니다.

↳ CSSStyleRule
  .type = STYLE_RULE
  .selectorText = "#foo, .foo, .foo::before"
  .resolvedSelectorText = "#foo, .foo, .foo::before"
  .specificity = (1,0,0),(0,1,0),(0,1,1)
  .style (CSSStyleDeclaration, 2) =
    - width: fit-content
    - background-color: red
  .cssRules (CSSRuleList, 1) =
    ↳ CSSMediaRule
      .type = MEDIA_RULE
      .cssRules (CSSRuleList, 1) =
        ↳ CSSStyleRule
          .type = STYLE_RULE
          .selectorText = "&"
          .resolvedSelectorText = ":is(#foo, .foo, .foo::before)"
          .specificity = (1,0,0)
          .style (CSSStyleDeclaration, 1) =
            - background-color: green

시각적으로는 .foo::beforebackground-colorgreen 대신 red임을 의미합니다.

CSS 작업 그룹에서 고려한 또 다른 접근 방식은 모든 중첩 선언을 @nest 규칙으로 래핑하는 것이었습니다. 이로 인해 개발자 환경이 저하되어 취소되었습니다.

CSSNestedDeclarations 인터페이스 소개

CSS 작업 그룹이 결정한 해결 방법은 중첩 선언 규칙을 도입하는 것입니다.

이 중첩 선언 규칙은 Chrome 130부터 Chrome에 구현됩니다.

브라우저 지원

  • Chrome: 130
  • Edge: 130.
  • Firefox: 132.
  • Safari: 지원되지 않음

중첩 선언 규칙을 도입하면 CSS 파서가 연속된 직접 중첩 선언을 CSSNestedDeclarations 인스턴스로 자동으로 래핑하도록 변경됩니다. 직렬화되면 이 CSSNestedDeclarations 인스턴스는 CSSStyleRulecssRules 속성에 포함됩니다.

다음 CSSStyleRule를 예로 다시 들겠습니다.

.foo {
  width: fit-content;

  @media screen {
    background-color: red;
  }
    
  background-color: green;
}

Chrome 130 이상에서 직렬화하면 다음과 같이 표시됩니다.

↳ CSSStyleRule
  .type = STYLE_RULE
  .selectorText = ".foo"
  .resolvedSelectorText = ".foo"
  .specificity = (0,1,0)
  .style (CSSStyleDeclaration, 1) =
    - width: fit-content
  .cssRules (CSSRuleList, 2) =
    ↳ CSSMediaRule
      .type = MEDIA_RULE
      .cssRules (CSSRuleList, 1) =
        ↳ CSSNestedDeclarations
          .style (CSSStyleDeclaration, 1) =
            - background-color: red
    ↳ CSSNestedDeclarations
      .style (CSSStyleDeclaration, 1) =
        - background-color: green

CSSNestedDeclarations 규칙은 CSSRuleList에서 끝나므로 파서는 background-color: green 선언의 위치를 유지할 수 있습니다. 즉, CSSMediaRule의 일부인 background-color: red 선언 뒤에 있습니다.

또한 CSSNestedDeclarations 인스턴스가 있으면 현재 삭제된 잠재적인 솔루션이 될 수 있는 다른 부작용이 발생하지 않습니다. 중첩 선언 규칙은 동일한 특정성 동작으로 상위 스타일 규칙과 정확히 동일한 요소 및 의사 요소와 일치합니다.

CSSStyleRulecssText를 다시 읽으면 이를 확인할 수 있습니다. 중첩 선언 규칙 덕분에 입력 CSS와 동일합니다.

.foo {
  width: fit-content;

  @media screen {
    background-color: red;
  }
    
  background-color: green;
}

정책에 대한 이해

즉, Chrome 130부터 CSS 중첩이 훨씬 개선되었습니다. 그러나 중첩 규칙이 있는 기본 선언을 인터리빙하는 경우 일부 코드를 검토해야 할 수도 있습니다.

멋진 @starting-style를 사용하는 다음 예를 살펴보세요.

/* This does not work in Chrome 130 */
#mypopover:popover-open {
  @starting-style {
    opacity: 0;
    scale: 0.5;
  }

  opacity: 1;
  scale: 1;
}

Chrome 130 이전에는 이러한 선언이 호이스팅되었습니다. opacity: 1;scale: 1; 선언이 CSSStyleRule.style로 이동하고 그 뒤에 CSSStyleRule.cssRulesCSSStartingStyleRule (@starting-style 규칙을 나타냄)가 표시됩니다.

Chrome 130부터 선언이 더 이상 호이스팅되지 않으며 CSSStyleRule.cssRules에 중첩된 CSSRule 객체가 2개 생성됩니다. 순서대로 CSSStartingStyleRule (@starting-style 규칙을 나타냄) 1개와 opacity: 1; scale: 1; 선언을 포함하는 CSSNestedDeclarations 1개가 있습니다.

이러한 동작이 변경되어 @starting-style 선언이 CSSNestedDeclarations 인스턴스에 포함된 선언으로 덮어쓰여져 진입 애니메이션이 삭제됩니다.

코드를 수정하려면 @starting-style 블록이 일반 선언 뒤에 오는지 확인합니다. 다음과 같습니다.

/* This works in Chrome 130 */
#mypopover:popover-open {
  opacity: 1;
  scale: 1;

  @starting-style {
    opacity: 0;
    scale: 0.5;
  }
}

CSS 중첩을 사용할 때 중첩된 선언을 중첩된 규칙 위에 유지하면 CSS 중첩을 지원하는 모든 브라우저의 모든 버전에서 코드가 대부분 제대로 작동합니다.

마지막으로 CSSNestedDeclarations의 사용 가능 여부를 기능 감지하려면 다음 JavaScript 스니펫을 사용하면 됩니다.

if (!("CSSNestedDeclarations" in self && "style" in CSSNestedDeclarations.prototype)) {
  // CSSNestedDeclarations is not available
}