🐜

Antd v4 からv5 への移行について

2023/09/29に公開

Next.jsプロジェクトのUIライブラリにAntdを使っている。
ずっと使っていると「そろそろv5に移行しないと継続が厳しくなるかも」という状況が来たので移行対応したまとめ。

Ant Design公式ガイド
https://ant.design/docs/react/migration-v5
https://ant.design/docs/react/migrate-less-variables

antdでのCSS-in-JSのソリューション
https://ant.design/docs/blog/css-in-js

Next.jsでantdを使う
https://ant.design/docs/react/use-with-next

v4 > v5移行に役立ちそうなガイド
https://github.com/ant-design/ant-design/discussions/40317
https://ant-design.github.io/antd-style/guide/migrate-less-codemod

Antd v5についてのまとめ

コンポーネントのpropsの指定方法の変更もあるが、大きく変わったことはRemove less, adopt CSS-in-JSである。つまり、「lessファイルなどで定義していたスタイルはtsxファイルで定義するように変更しよう」という大きな変更がある。Next.jsでantd v4を使っている場合は、lessファイルを読み込むための専用プラグインなどを使っていたので、v5にすればこれが外せるようになる。

一般的なCSS-in-JSでは、<style>タグが挿入(シリアライズ)されたかを識別するのに、ハッシュ値を使用する。
よく挙げられるCSS-in-JSの問題点として、コンポーネントに渡したpropsの変更に伴ってスタイルも変更されることでハッシュ値の再計算が高頻度に行われてパフォーマンスに影響が挙げられる。(ライブラリとブラウザの二重解析)

各種ライブラリではこれらに対処する様々なアプローチを持っている。(ex: ゼロランタイムCSS-in-JS)

antdは前提としてpropsの変更がコンポーネントのスタイルに影響を与えることはない。スタイルの変更が起きるのは、

  1. テーマ変数(Token)の変更
  2. antdのバージョン(Version)変更

の2つの場合のみである。
それ以外はスタイルは最初のマウント時に一度だけ生成され、必要なスタイルはキャッシュから与えられる。これを「コンポーネントレベルのCSS-in-JS」だと謳っている。

さらにテーマデザインの概念的なものも確認してみる。

Design Token

テーマやコンポーネント全体のスタイルに影響を与える最小の要素。less時代はテーマ変数(ex: @primary-color)を使っていたが、v5からはデザイントークン指向になっていく。

Seed Token…デザイン意図の基本単位

Map Token…Seed Tokenから派生したベースになるトークン

Alias Token…Map Tokenから派生したコンポーネント向けトークン

Component Token

各コンポーネントのスタイルをカスタマイズ(グローバルトークンを上書き)するためのトークン。

https://ant.design/docs/react/migrate-less-variables#component-token

Algorithm

Seed TokenからMap Tokenへ派生させる際の計算方式。プリセットで3種類のテーマが用意されている。

  • defaultAlgorithm
  • darkAlgorithm
  • compactAlgorithm

ConfigProvider

antdのコンポーネント群を扱えるようにするUIライブラリではお決まりのProviderコンポーネント。v4と異なる点はthemepropsを渡せるようになったこと。ちなみにConfigProviderはネストできる。

ただし、注意点としてmessage.xxxやModal.xxxやnotification.xxxなどの静的関数にはコンテキスト情報が共有されていないためこれらで設定したスタイルが適用されない。
解決策として、App.useApp()を使う。

私が修正したケースでは、以下を参考にして、antdから直接notificationやModalやmessageをインポートするのをESLintのルール(no-restricted-imports)で封じた
https://ant.design/components/app#global-scene-redux-scene

v5からのCSSの書き方

基本

各コンポーネントファイルでは、antd-stylecreateStylesを使ってuseStylesを定義。コンポーネント内でuseStylesを使用して、stylesを取得してclassNameに割り当てる。tsxに書いていたlessのインポート文は削除する

tokenを使うことで、ダークモードやコンパクトモードで余計なデザイン調整を挟まずに済むので、useStylesの定義時にはなるべくtokenを使うと動的テーマにも綺麗に対応できる。

import { createStyles } from 'antd-style';

const useStyles = createStyles(({ token, css }) => {
  return {
    button: css`
      padding: ${token.paddingSM}px;
    `
  };
});

const TestButton: React.FC = observer(() => {
    const { styles, theme } = useStyles();

    return (
        <Button className={styles.button} />
    )
})

less時代の :global (局所カスタマイズ)について

less時代に:globalでantdコンポーネントのクラス名を指定してスタイルを上書きしていた部分は、createStylesを使うとそのままクラス内で指定するだけでよさそうだ。場合によっては!importantをつける必要があるかもしれない。
以前のようにantdのクラス名指定のために「:globalを追加して...」という手間が減った。

before
.inactiveMenu {
  :global {
    .ant-menu-item-icon {
      opacity: 0.4;
    }
  }
}
after
const useStyles = createStyles(({ token, css }) => {
  return {
      inactiveMenu: css`
        .ant-menu-item-icon {
          opacity: 0.4;
        }
      `,
  }
}

メディアクエリや擬似要素の扱い

メディアクエリや擬似要素については、基本的にCSS Nesting Moduleを活用してuseStyles内部で定義したクラスの中で指定するようにした。

CSS Nesting Moduleが使えるようになっていたのは割と最近で驚いた。

before
.content {
  padding: 32px 48px;
}
@media screen and (max-width: @screen-sm-max) {
  .content {
    padding: 32px 0;
  }
}

.newDate {
  font-size: @font-size-sm;
}

.newDate::before {
  content: 'New';
  margin-right: @margin-xs;
}
after
const useStyles = createStyles(({ token, css }) => {
  return {
    content: css`
      padding: 32px 48px;
      @media screen and (max-width: ${token.screenSMMax}px) {
        padding: 32px 0px;
      }
    `
    newDate: css`
      font-size: ${token.fontSize}px;
      &::before {
        content: 'New';
        margin-right: ${token.marginXS}px;
      }
    `
  };
});

createStylesを使わず、コンポーネント内でトークンを使う

createStylesを使ってuseStylesを定義していれば useStyles().theme.token でトークンを抽出できるが、定義していない場合はuseTokenを使うと取得できる。(コンポーネント内でトークンを使う場合はuseTokenに統一した方が綺麗になりそう)

import { theme, Typography } from 'antd';

const HelloText: React.FC = () => {
  const { token } = theme.useToken();

  return (
      <Typography.Text style={{ fontSize: token.fontSizeLG }}>
        Hello
      </Typography.Text>
  );
}

 
 

ただ、message.xxxやModal.xxxなどの静的関数でstyleでトークンを使ったり局所カスタマイズする方法がわからなかったので、これは今後修正されていくかもしれない。(すでにあれば教えてほしい)

移行のために実施したこと

移行においては、いきなり置き換えるとlessファイルが読み込めず、動作確認ができず大ダメージを受けるので、v4とv5両方のパッケージをインストールして、lessファイルも読み込める状態にしてからantd-styleを使った記法に置換していく。

  1. パッケージのインストール

以下のようにpackage.jsonを変更する(npm install and@^5.9.0 antd-style antd-v4@npm:antd@^4.20.6)

...
- "antd": "^4.20.6",
+ "antd": "^5.9.0",
+ "antd-style": "^3.4.5",
+ "antd-v4": "npm:antd@^4.20.6",
...
  1. _app.tsxでv4とv5のConfigProviderを両方ラップして、import 'antd/dist/reset.css';を追加する
import { Auth0Provider } from '@auth0/auth0-react';
import { ConfigProvider as V5ConfigProvider } from 'antd';
import { ConfigProvider } from 'antd-v4';
import 'antd/dist/reset.css';

...

const App: React.FC<Props> = ({ Component, pageProps }) => {

  return (
    ...
        <ConfigProvider
          dropdownMatchSelectWidth={true}
          form={{ validateMessages }}
        >
          <V5ConfigProvider
            popupMatchSelectWidth={true}
            form={{ validateMessages }}
            theme={{
              token: {
                colorPrimary: '#00b96b', // 反映確認のため分かりやすい色
              },
            }}
          >
            <AppLayout Component={Component} />
          </V5ConfigProvider>
        </ConfigProvider>
      
    ...
  );
};

export default App;
  1. antd v5でpropsの指定やimport先が変わったところを修正(後述の移行時の修正記録メモを参照)
  2. CSS moduleのファイルをcreateStyles()を使った記法に置き換える。(自前のスクリプトを作成して実行して、置換が失敗した部分は手直し)
自前スクリプト(完全な精度ではないのであくまで参考程度でお願いします)
const fs = require('fs');
const path = require('path');

type TargetFile = {
  tsx: string;
  less: string;
};

// Search for tsx and module.less files to be rewritten
function searchReplaceTargetFiles() {
  const tsxFiles: TargetFile[] = [];
  const srcDir = path.resolve(__dirname, '../../src');

  // eslint-disable-next-line no-console
  console.log(`🔍 Search replace target in src`);

  const search = (dir) => {
    const files = fs.readdirSync(dir);

    for (const file of files) {
      const filePath = path.join(dir, file);

      if (fs.statSync(filePath).isDirectory()) {
        search(filePath);
      } else if (filePath.endsWith('.tsx')) {
        const content = fs.readFileSync(filePath, 'utf8').toString();
        if (content.includes('import styles from')) {
          const lessFilePath = content.match(/import styles from '(.*)';/)[1];
          const isRelativePath = lessFilePath.startsWith('.');
          tsxFiles.push({
            tsx: filePath,
            less: path.join(isRelativePath ? dir : srcDir, lessFilePath),
          });
        }
      }
    }

    return tsxFiles;
  };

  return search(srcDir);
}

// Geenerate definition of useStyles from module.less
function generateUseStyles(contentCSSModule: string) {
  const lines = contentCSSModule.split('\n');
  const styles = {};

  let currentStyle = '';
  let currentProperties = '';
  let isNested = false;
  const currentGlobalSelector = { isGlobalSelector: false, nestCount: 0 };

  for (const line of lines) {
    if (line.trim().startsWith(':global')) {
      // global selector
      currentGlobalSelector.isGlobalSelector = true;
    } else if (currentGlobalSelector.isGlobalSelector) {
      // css properties in global selector
      if (line.trim().endsWith('{')) {
        currentProperties += line.trim().concat('\n');
        currentGlobalSelector.nestCount++;
      } else if (line.trim() === '}') {
        if (currentGlobalSelector.nestCount === 0) {
          currentGlobalSelector.isGlobalSelector = false;
        } else {
          currentProperties += line.trim().concat('\n');
          currentGlobalSelector.nestCount--;
        }
      } else {
        currentProperties += line.trim().concat('\n');
      }
    } else if (line.trim().startsWith('.') && line.trim().endsWith('{')) {
      // start of css properties
      if (isNested) {
        styles[currentStyle] = currentProperties.replace(/\n$/, '');
      }
      currentStyle = line.replace('.', '').trim().replace('{', '').trim();
      currentProperties = '';
      isNested = true;
    } else if (line.trim() === '}') {
      // end of css properties
      styles[currentStyle] = currentProperties.replace(/\n$/, '');
      currentStyle = '';
      currentProperties = '';
      isNested = false;
    } else {
      // css properties
      currentProperties += line.trim().concat('\n');
    }
  }

  // Sanitize empty styles
  Object.keys(styles).forEach((style) => {
    if (styles[style].trim() === '') {
      delete styles[style];
    }
  });

  return `import { createStyles } from 'antd-style';

const useStyles = createStyles(({ token, css }) => {
  return {
${Object.keys(styles)
  .map((style) => {
    return `    ${style}: css\`
      ${styles[style]}
    \`,`;
  })
  .join('\n')}
  };
});`;
}

// Execute replacement
function replaceAntdV5Styles(targetFiles: TargetFile[]) {
  targetFiles
    .forEach((file) => {
      // eslint-disable-next-line no-console
      console.info(`💅 Try to modify file:${file.tsx} and delete file:${file.less}`);

      let tsxContent = fs.readFileSync(file.tsx, 'utf8').toString();
      const lessContent = fs.readFileSync(file.less, 'utf8').toString();

      const replacements = [
        // delete `import styles from 'xxx.less';` and replace `import { createStyle } from ‘antd-style’;`
        {
          pattern: /import styles from '(.*)';/,
          replacement: generateUseStyles(lessContent),
        },
        // insert `const { styles } = useStyles();` in comoponent
        {
          pattern: /const (\w+): React.FC(.*) => {/g,
          replacement: `const $1: React.FC$2 => {\n  const { styles } = useStyles();`,
        },
      ];

      // Replace tsx content
      replacements.forEach(({ pattern, replacement }) => {
        tsxContent = tsxContent.replace(pattern, replacement);
      });

      // Rewrite tsx file
      fs.writeFile(file.tsx, tsxContent, 'utf8', (writeErr) => {
        // eslint-disable-next-line no-console
        if (writeErr) {
          // eslint-disable-next-line no-console
          console.error(`🤦Failed to replace content: ${file.tsx}`);
        }
      });
    });
}

// Delete unnecessary less files in src
// function deleteLessFiles(targetFiles: TargetFile[]) {
//   targetFiles.forEach((file) => {
//     fs.unlinkSync(file.less);
//   });
// }

const targetFiles = searchReplaceTargetFiles();
replaceAntdV5Styles(targetFiles);
// deleteLessFiles();

export {};
  1. next.config.js内にあるnext-plugin-antd-lessの設定など、lessのために施していた設定をすべて削除

移行時の修正記録メモ

色々修正した時の記録。基本ワーニングに書いてあることはわかりやすいので直しやすいとは思う。
参考になればぜひ。

スタイルの修正

  • bodyにユーザーエージェントスタイルが設定されてしまうので、import 'antd/dist/reset.css';を追加する

ワーニングが出た箇所の修正

  • Modalのvisibleopenに変える
  • SliderのtooltipVisible={false}tooltip={{open: false}}に変える
  • DrawerのclassNamerootClassNameに変える
  • messageのwarnwarningを使う
  • notificationのclosedestroyを使う
  • Localeのimportはantd/lib/localeを使う
  • TypographyPropsではなく、ComponentProps<typeof Typography>を使う
  • Dropdownではoverlayではなく、menuを使う
<Dropdown
  menu={{
    items: myMenuItems,
    className: styles.menu,
  }}
  trigger={['click']}
>
  • DatePickerのgeneratePickerはV5では以下のように定義する
import { DatePicker as CustomDatePicker } from 'antd';
...

const DatePicker = CustomDatePicker.generatePicker<Dayjs>(dayjsGenerateConfig);
  • TabsPaneはTabsのitemsを使って表現する
  • SelectのdropdownMatchSelectWidthpopupMatchSelectWidthを代わりに使う

 
 
 

移行を終えて

プロジェクト内にlessファイルが100個以上あったので、v5に追従するのは大変だった。
npx @chenshuai2144/less2cssinjs less2js -i srcでワンライナーで修正できるみたいなのも目にしたがうまく動かなかった。さすがに中規模以上になるとワンライナーで置換するのは難しいのかもしれない。
結論的には「自前でlessファイルからcreateStylesを使ってtsxファイルにuseStylesを定義するスクリプトを用意した」が、完璧な精度ではなかったので、変換がうまくいかない部分は手直しした。「完璧な精度のスクリプトをつくる vs 多少の精度を犠牲に手直しもする」というタイパで葛藤して後者を選択した。

Next.jsをv13.4.7より上に上げると、カスタムしたはずのテーマトークンが効かずデフォルトのテーマトークンになるという事象が発生したので、Next.jsを一番最新にすることはまだできなかった。antdのマイナーバージョンやパッチバージョンが上がるだけでも仕様が変わったりもしているように見えるので小さいバージョン変化もウォッチしておいた方がいいのかもしれない。

Discussion