Antd v4 からv5 への移行について
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の変更がコンポーネントのスタイルに影響を与えることはない。スタイルの変更が起きるのは、
- テーマ変数(Token)の変更
- 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と異なる点はtheme
propsを渡せるようになったこと。ちなみに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-styleのcreateStyles
を使って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を追加して...」という手間が減った。
.inactiveMenu {
:global {
.ant-menu-item-icon {
opacity: 0.4;
}
}
}
const useStyles = createStyles(({ token, css }) => {
return {
inactiveMenu: css`
.ant-menu-item-icon {
opacity: 0.4;
}
`,
}
}
メディアクエリや擬似要素の扱い
メディアクエリや擬似要素については、基本的にCSS Nesting Moduleを活用してuseStyles
内部で定義したクラスの中で指定するようにした。
CSS Nesting Moduleが使えるようになっていたのは割と最近で驚いた。
.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;
}
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
を使った記法に置換していく。
- パッケージのインストール
以下のように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",
...
- _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;
- antd v5でpropsの指定やimport先が変わったところを修正(後述の
移行時の修正記録メモ
を参照) - 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 {};
-
next.config.js
内にあるnext-plugin-antd-lessの設定など、lessのために施していた設定をすべて削除
移行時の修正記録メモ
色々修正した時の記録。基本ワーニングに書いてあることはわかりやすいので直しやすいとは思う。
参考になればぜひ。
スタイルの修正
- bodyにユーザーエージェントスタイルが設定されてしまうので、
import 'antd/dist/reset.css';
を追加する
ワーニングが出た箇所の修正
- Modalの
visible
はopen
に変える - Sliderの
tooltipVisible={false}
をtooltip={{open: false}}
に変える - Drawerの
className
をrootClassName
に変える - messageの
warn
はwarning
を使う - notificationの
close
はdestroy
を使う - 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の
dropdownMatchSelectWidth
はpopupMatchSelectWidth
を代わりに使う
移行を終えて
プロジェクト内にlessファイルが100個以上あったので、v5に追従するのは大変だった。
npx @chenshuai2144/less2cssinjs less2js -i src
でワンライナーで修正できるみたいなのも目にしたがうまく動かなかった。さすがに中規模以上になるとワンライナーで置換するのは難しいのかもしれない。
結論的には「自前でlessファイルからcreateStyles
を使ってtsxファイルにuseStyles
を定義するスクリプトを用意した」が、完璧な精度ではなかったので、変換がうまくいかない部分は手直しした。「完璧な精度のスクリプトをつくる vs 多少の精度を犠牲に手直しもする」というタイパで葛藤して後者を選択した。
Next.jsをv13.4.7より上に上げると、カスタムしたはずのテーマトークンが効かずデフォルトのテーマトークンになるという事象が発生したので、Next.jsを一番最新にすることはまだできなかった。antdのマイナーバージョンやパッチバージョンが上がるだけでも仕様が変わったりもしているように見えるので小さいバージョン変化もウォッチしておいた方がいいのかもしれない。
Discussion