Xử lý lỗi mất style khi render lần đầu với Ant Design trong Next.js

Vấn đề gặp phải

Sau khi nâng cấp các thư viện trong dự án blog vì một số vấn đề bảo mật gần đây của Next.js và React, tôi đã gặp phải một lỗi khá khó chịu như sau:

Khi trang được render lần đầu, giao diện bị mất toàn bộ style của Ant Design. Người dùng sẽ thấy một giao diện tạm thời không có style trước khi ứng dụng được tải hoàn toàn trên client. Dự án sử dụng Next.js phiên bản 14 với App Router và React 18. Các thư viện liên quan bao gồm "@ant-design/nextjs-registry": "^1.3.0""antd": "^5.14.2".

Hướng tiếp cận ban đầu

Do Ant Design là thư viện UI sử dụng CSS-in-JS, nên theo tài liệu chính thức, chúng ta cần sử dụng component <AntdRegistry> để bao bọc toàn bộ layout. Component này giúp thu thập các style từ server side rendering (SSR) và chèn chúng vào thẻ <script> để hiển thị đúng ngay lần render đầu tiên phía client.

// src/app/layout.tsx

import { AntdRegistry } from '@ant-design/nextjs-registry'

export default async function RootLayout({
  children
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <html lang="en">
      <head>
        {/* ... */}
      </head>
      <body>
        <AntdRegistry>
          {/* ... nội dung trang web */}
        </AntdRegistry>
      </body>
    </html>
  )
}

Tuy nhiên dù kiểm tra kỹ lại với tài liệu hướng dẫn cũng như hỏi ý kiến AI, vẫn không tìm ra điểm sai sót nào rõ ràng. Đến lúc đó, tôi tình cờ đọc thấy phần lưu ý dành cho Pages Router về việc sử dụng Ant Design.

Phân tích nguyên nhân

Có khả năng cao rằng vấn đề tôi gặp phải xuất phát từ sự khác biệt phiên bản giữa hai thư viện phụ thuộc: @ant-design/cssinjs. Thực hiện lệnh:

npm ls @ant-design/cssinjs

Kết quả trả về cho thấy:

├─┬ @ant-design/nextjs-registry@1.3.0
│ └── @ant-design/cssinjs@2.0.1
└─┬ antd@5.14.2
  └── @ant-design/cssinjs@1.24.0 deduped

Như vậy, @ant-design/nextjs-registry đang dùng một phiên bản khác của @ant-design/cssinjs so với phiên bản mà antd yêu cầu. Đây chính là nguyên nhân gây ra lỗi.

Giải pháp đơn giản là hạ cấp @ant-design/nextjs-registry xuống phiên bản 1.2.0 – lúc này cả hai đều dùng cùng phiên bản @ant-design/cssinjs@1.24.0, và lỗi được khắc phục:

├─┬ @ant-design/nextjs-registry@1.2.0
│ └── @ant-design/cssinjs@1.24.0
└─┬ antd@5.14.2
  └── @ant-design/cssinjs@1.24.0 deduped

Khám phá bên trong @ant-design/nextjs-registry

Chi tiết về AntdRegistry

Lỗi đã được sửa nhưng điều khiến tôi tò mò là cơ chế hoạt động thực sự của @ant-design/nextjs-registry. Xem qua mã nguồn tại repo GitHub:

https://github.com/ant-design/nextjs-registry

// /src/AntdRegistry.tsx
'use client';

import type { StyleProviderProps } from '@ant-design/cssinjs';
import type { FC } from 'react';
import { createCache, extractStyle, StyleProvider } from '@ant-design/cssinjs';
import { useServerInsertedHTML } from 'next/navigation';
import React, { useState } from 'react';

type AntdRegistryProps = Omit<StyleProviderProps, 'cache'>;

const AntdRegistry: FC<AntdRegistryProps> = (props) => {
  const [cache] = useState(() => createCache());

  useServerInsertedHTML(() => {
    const styleText = extractStyle(cache, { plain: true, once: true });

    if (styleText.includes('.data-ant-cssinjs-cache-path{content:"";}')) {
      return null;
    }

    return (
      <style
        id="antd-cssinjs"
        data-rc-order="prepend"
        data-rc-priority="-1000"
        dangerouslySetInnerHTML={{ __html: styleText }}
      />
    );
  });

  return <StyleProvider {...props} cache={cache} />;
};

export default AntdRegistry;

Đoạn code trên chủ yếu sử dụng hook useServerInsertedHTML của Next.js để đưa chuỗi style vào DOM, gần giống với cách xử lý trong Pages Router.

Vai trò của @ant-design/cssinjs

Một điểm quan trọng nằm ở dòng:

const [cache] = useState(() => createCache())

Thư viện @ant-design/cssinjs làm ba công việc chính:

  1. Tạo ID duy nhất cho mỗi instance.
  2. (Chỉ chạy phía client) Di chuyển các thẻ style từ body sang head và loại bỏ trùng lặp.
export function createCache() {
  const cssinjsInstanceId = Math.random().toString(12).slice(2);

  if (typeof document !== 'undefined' && document.head && document.body) {
    const styles = document.body.querySelectorAll(`style[${ATTR_MARK}]`) || [];
    const { firstChild } = document.head;

    Array.from(styles).forEach((style) => {
      (style as any)[CSS_IN_JS_INSTANCE] =
        (style as any)[CSS_IN_JS_INSTANCE] || cssinjsInstanceId;

      if ((style as any)[CSS_IN_JS_INSTANCE] === cssinjsInstanceId) {
        document.head.insertBefore(style, firstChild);
      }
    });

    const styleHash: Record<string, boolean> = {};
    Array.from(document.querySelectorAll(`style[${ATTR_MARK}]`)).forEach(
      (style) => {
        const hash = style.getAttribute(ATTR_MARK)!;
        if (styleHash[hash]) {
          if ((style as any)[CSS_IN_JS_INSTANCE] === cssinjsInstanceId) {
            style.parentNode?.removeChild(style);
          }
        } else {
          styleHash[hash] = true;
        }
      },
    );
  }

  return new CacheEntity(cssinjsInstanceId);
}
  1. Trả về một đối tượng kiểu Map chứa thông tin style, được truyền vào qua StyleProvider để các component con cập nhật style cần thiết.

Cấu trúc tương tự như sau:

export type KeyType = string | number;
type ValueType = [number, any];

class Entity {
    instanceId: string;
    cache: Map<string, ValueType>;
    extracted: Set<string>;
    get(keys: KeyType[]): ValueType | null;
    opGet(keyPathStr: string): ValueType | null;
    update(keys: KeyType[], valueFn: (origin: ValueType | null) => ValueType | null): void;
    opUpdate(keyPathStr: string, valueFn: (origin: ValueType | null) => ValueType | null): void;
}

StyleProvider về cơ bản chỉ là một Context Provider bình thường:

const StyleContext = React.createContext<StyleContextProps>({
  hashPriority: 'low',
  cache: createCache(),
  defaultCache: true,
  autoPrefix: false,
})

export const StyleProvider: React.FC<StyleProviderProps> = (props) => {
  return (
    <StyleContext.Provider value={context}>{children}</StyleContext.Provider>
  );
};

Luồng gọi hàm trong Ant Design Components

Dựa trên component Button, luồng gọi có thể mô tả như sau:

Luồng dữ liệu đi từ các công cụ hỗ trợ CSS-in-JS đến component Ant Design cuối cùng.

  • CSSInJS Cơ sở: genStyleUtils → genStyleHooks → genComponentStyleHook → useStyleRegister → useGlobalCache
  • Ant Design Component: useStyle → Button → JSX

Trong useGlobalCache, hàm React.useContext(StyleContext) được gọi để lấy đối tượng cache và cập nhật nó thông qua phương thức onUpdate.

Kết luận

Lỗi này khá điển hình khi nâng cấp thư viện trong hệ sinh thái JavaScript. Cách giải quyết rất đơn giản: đảm bảo rằng @ant-design/nextjs-registryantd dùng chung phiên bản của @ant-design/cssinjs. Trường hợp này, hạ cấp từ v1.3.0 xuống v1.2.0 là đủ để ổn định hệ thống.

Trong tương lai, nếu bạn gặp tình trạng tương tự với Ant Design + Next.js App Router, hãy kiểm tra ngay xem các gói phụ thuộc có tương thích về phiên bản hay chưa. Đôi khi vấn đề không quá phức tạp, chỉ là do xung đột phiên bản gây ra.

Ngoài ra, việc tìm hiểu sâu hơn về cơ chế bên trong AntdRegistry cho thấy rằng nó hoạt động bằng cách thu thập style phía server rồi chèn vào thẻ <style> khi render lần đầu ở client – nhờ đó tránh được hiện tượng nhấp nháy giao diện.

Thẻ: nextjs antd cssinjs react frontend

Đăng vào ngày 1 tháng 8 lúc 10:32