Tính năng lồng CSS được cải thiện nhờ CSSNestedDeclarations

Ngày xuất bản: 8 tháng 10 năm 2024

Để khắc phục một số vấn đề kỳ lạ với tính năng lồng CSS, Nhóm làm việc về CSS đã quyết định thêm giao diện CSSNestedDeclarations vào Thông số kỹ thuật về tính năng lồng CSS. Với lần bổ sung này, các nội dung khai báo đứng sau quy tắc kiểu không còn dịch chuyển lên trên, cùng với một số điểm cải tiến khác.

Những thay đổi này có trong Chrome từ phiên bản 130 và sẵn sàng để thử nghiệm trong Firefox Nightly 132 và Safari Technology Preview 204.

Hỗ trợ trình duyệt

  • Chrome: 130.
  • Edge: 130.
  • Firefox: 132.
  • Safari: không được hỗ trợ.

Vấn đề với việc lồng CSS mà không có CSSNestedDeclarations

Một trong những lưu ý về việc lồng CSS là ban đầu, đoạn mã sau đây không hoạt động như bạn mong đợi:

.foo {
    width: fit-content;

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

Khi nhìn vào mã, bạn sẽ giả định rằng phần tử <div class=foo>green background-color vì phần khai báo background-color: green; xuất hiện cuối cùng. Nhưng điều này không đúng với phiên bản Chrome trước phiên bản 130. Trong các phiên bản đó, thiếu tính năng hỗ trợ CSSNestedDeclarations, background-color của phần tử là red.

Sau khi phân tích cú pháp quy tắc thực tế, Chrome trước phiên bản 130 sẽ sử dụng như sau:

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

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

CSS sau khi phân tích cú pháp đã trải qua hai thay đổi:

  • background-color: green; đã được di chuyển lên trên để kết hợp với hai nội dung khai báo còn lại.
  • CSSMediaRule lồng nhau đã được viết lại để gói các phần khai báo của nó trong một CSSStyleRule bổ sung bằng bộ chọn &.

Một thay đổi điển hình khác mà bạn sẽ thấy ở đây là trình phân tích cú pháp đang loại bỏ các thuộc tính mà trình phân tích cú pháp này không hỗ trợ.

Bạn có thể tự kiểm tra "CSS sau khi phân tích cú pháp" bằng cách đọc lại cssText từ CSSStyleRule.

Hãy tự mình thử trong sân chơi tương tác này:

Tại sao CSS này được viết lại?

Để hiểu lý do xảy ra quá trình viết lại nội bộ này, bạn cần hiểu cách CSSStyleRule này được biểu thị trong Mô hình đối tượng CSS (CSSOM).

Trong Chrome trước phiên bản 130, đoạn mã CSS được chia sẻ trước đó sẽ chuyển đổi tuần tự thành như sau:

↳ 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

Trong số tất cả các thuộc tính mà CSSStyleRule có, hai thuộc tính sau đây có liên quan trong trường hợp này:

  • Thuộc tính style là một thực thể CSSStyleDeclaration đại diện cho các nội dung khai báo.
  • Thuộc tính cssRules là một CSSRuleList chứa tất cả các đối tượng CSSRule lồng nhau.

Vì tất cả các phần khai báo từ đoạn mã CSS đều kết thúc trong thuộc tính style của CSStyleRule, nên sẽ có thông tin bị mất. Khi xem thuộc tính style, bạn không thể biết rõ background-color: green được khai báo sau CSSMediaRule lồng nhau.

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

Điều này gây ra vấn đề vì để công cụ CSS hoạt động đúng cách, công cụ này phải có khả năng phân biệt các thuộc tính xuất hiện ở đầu nội dung của quy tắc kiểu với các thuộc tính xuất hiện xen kẽ với các quy tắc khác.

Đối với các nội dung khai báo bên trong CSSMediaRule đột nhiên bị bao bọc trong một CSSStyleRule: nguyên nhân là do CSSMediaRule không được thiết kế để chứa nội dung khai báo.

CSSMediaRule có thể chứa các quy tắc lồng nhau – có thể truy cập thông qua thuộc tính cssRules – nên nội dung khai báo sẽ tự động được gói trong một 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

Làm cách nào để giải quyết vấn đề này?

Nhóm làm việc về CSS đã xem xét một số phương án để giải quyết vấn đề này.

Một trong những giải pháp được đề xuất là gói tất cả các nội dung khai báo trần trong một CSSStyleRule lồng nhau bằng bộ chọn lồng nhau (&). Ý tưởng này đã bị loại bỏ vì nhiều lý do, bao gồm cả các hiệu ứng phụ không mong muốn sau đây của việc đơn giản hoá & thành :is(…):

  • Nó có ảnh hưởng đến tính cụ thể. Điều này là do :is() sẽ thay thế tính cụ thể của đối số cụ thể nhất.
  • Tính năng này không hoạt động tốt với các phần tử giả trong bộ chọn bên ngoài ban đầu. Nguyên nhân là do :is() không chấp nhận các phần tử giả trong đối số danh sách bộ chọn.

Hãy xem ví dụ sau đây:

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

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

Sau khi phân tích cú pháp, đoạn mã đó sẽ trở thành đoạn mã sau trong Chrome phiên bản trước 130:

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

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

Đây là một vấn đề vì CSSRule được lồng với bộ chọn &:

  • Làm phẳng thành :is(#foo, .foo), loại bỏ .foo::before khỏi danh sách bộ chọn trong quá trình này.
  • Có độ cụ thể là (1,0,0), khiến việc ghi đè sau này trở nên khó khăn hơn.

Bạn có thể kiểm tra điều này bằng cách kiểm tra xem quy tắc sẽ chuyển đổi tuần tự thành:

↳ 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

Nhìn bề ngoài, điều này cũng có nghĩa là background-color của .foo::beforered thay vì green.

Một phương pháp khác mà Nhóm làm việc về CSS xem xét là yêu cầu bạn gói tất cả các nội dung khai báo lồng nhau trong một quy tắc @nest. Tính năng này đã bị loại bỏ do trải nghiệm của nhà phát triển bị chậm lại.

Giới thiệu giao diện CSSNestedDeclarations

Giải pháp mà Nhóm làm việc về CSS đã quyết định là giới thiệu quy tắc khai báo lồng nhau.

Quy tắc khai báo lồng nhau này được triển khai trong Chrome bắt đầu từ Chrome 130.

Hỗ trợ trình duyệt

  • Chrome: 130.
  • Edge: 130.
  • Firefox: 132.
  • Safari: không được hỗ trợ.

Việc ra mắt quy tắc khai báo lồng nhau sẽ thay đổi trình phân tích cú pháp CSS để tự động gói các nội dung khai báo lồng trực tiếp liên tiếp trong một phiên bản CSSNestedDeclarations. Khi được chuyển đổi tuần tự, thực thể CSSNestedDeclarations này sẽ kết thúc trong thuộc tính cssRules của CSSStyleRule.

Lấy lại ví dụ về CSSStyleRule sau:

.foo {
  width: fit-content;

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

Khi được chuyển đổi tuần tự trong Chrome 130 trở lên, mã này sẽ có dạng như sau:

↳ 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

Vì quy tắc CSSNestedDeclarations kết thúc trong CSSRuleList, nên trình phân tích cú pháp có thể giữ lại vị trí khai báo background-color: green: sau khai báo background-color: red (là một phần của CSSMediaRule).

Hơn nữa, việc có thực thể CSSNestedDeclarations không gây ra bất kỳ tác dụng phụ khó chịu nào cho các giải pháp tiềm năng khác (hiện đã bị loại bỏ) gây ra: Quy tắc khai báo lồng ghép khớp hoàn toàn các phần tử và phần tử giả như quy tắc kiểu gốc, có cùng hành vi cụ thể.

Bằng chứng cho điều này là đọc lại cssText của CSSStyleRule. Nhờ quy tắc khai báo lồng nhau, CSS này giống với CSS đầu vào:

.foo {
  width: fit-content;

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

Ý nghĩa của chính sách này đối với bạn

Điều này có nghĩa là tính năng lồng CSS đã được cải thiện rất nhiều kể từ Chrome 130. Tuy nhiên, điều đó cũng có nghĩa là bạn có thể phải kiểm tra một số đoạn mã nếu xen kẽ nội dung khai báo đơn thuần với các quy tắc lồng nhau.

Hãy xem ví dụ sau đây sử dụng @starting-style tuyệt vời

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

  opacity: 1;
  scale: 1;
}

Trước Chrome 130, các nội dung khai báo đó sẽ được chuyển lên trên. Cuối cùng, bạn sẽ có các phần khai báo opacity: 1;scale: 1; chuyển vào CSSStyleRule.style, theo sau là CSSStartingStyleRule (đại diện cho quy tắc @starting-style) trong CSSStyleRule.cssRules.

Kể từ Chrome 130 trở đi, các phần khai báo sẽ không còn được chuyển lên trên nữa và bạn sẽ có hai đối tượng CSSRule lồng nhau trong CSSStyleRule.cssRules. Theo thứ tự: một CSSStartingStyleRule (đại diện cho quy tắc @starting-style) và một CSSNestedDeclarations chứa nội dung khai báo opacity: 1; scale: 1;.

Do hành vi thay đổi này, các nội dung khai báo @starting-style sẽ bị ghi đè bởi các nội dung khai báo có trong thực thể CSSNestedDeclarations, do đó xoá ảnh động mục nhập.

Để sửa mã, hãy đảm bảo rằng khối @starting-style nằm sau các phần khai báo thông thường. Chẳng hạn như:

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

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

Nếu bạn luôn cập nhật các quy tắc lồng nhau ở trên các quy tắc lồng nhau khi sử dụng CSS, việc lồng mã của bạn hầu như sẽ hoạt động tốt với tất cả phiên bản của tất cả các trình duyệt hỗ trợ tính năng lồng CSS.

Cuối cùng, nếu muốn phát hiện tính năng có sẵn của CSSNestedDeclarations, bạn có thể sử dụng đoạn mã JavaScript sau:

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