【脱ランタイムCSS in JS】styled-componentsを別のCSS in JSに自動置換するCLIツールの開発
1.はじめに
本記事はサイボウズの夏インターン(2022年)で取り組んだ内容の紹介を行います。インターンでは、サイボウズのフロントエンド領域における横断的組織であるフロントエンドエキスパートチームに配属されました。5日間のインターン期間でstyled-componentsを別のCSS in JSに自動置換するCLIツール・extract-styledの開発に取り組みました。
2. extract-styledの紹介
実装したextract-styledは、以下のようなCLI経由の実行を通して、styled-componentsで定義されたReactコンポーネントをtarget
に指定した任意のCSS in JSに変換することができます。(現時点では変換先としてlinaria・vanilla-extractに対応)
$ extract-styled \\
--path ./src/components/**/*.tsx \\ # 変換対象となるstyled-componentsでスタイリングされたReactコンポーネントのpath.
--target vanilla-extract \\ # 変換先のCSS in JSの名前の名前.
--output ./ \\ # 出力先となるpath.
--ignore stories test # ignoreするpath. storybookとtestをignore.
変換の具体例を見て見ましょう。--target vanilla-extract
を指定するとstyled-componentsで定義されたコンポーネント(変換前)からvanilla-extractによるスタイリングの定義(変換後)を生成することが可能です。
変換前 (styled-componentsで定義されたスタイリング)
import styled from 'styled-components';
import * as React from 'react';
type Props = {};
const Button: React.VFC<Props> = () => <button>Button</button>;
const StyledButton = styled(Button)`
font-size: 1em;
margin: 1em;
padding: 0.25em 1em;
border: 2px solid palevioletred;
border-radius: 3px;
`;
export { StyledButton as Button };
変換後 (vanilla-extract)
import { style } from '@vanilla-extract/css';
export const button = style({
fontSize: '1em',
margin: '1em',
padding: '0.25em 1em',
border: '2px solid palevioletred',
borderRadius: '3px'
});
以降ではextract-styledを実装した背景や内部実装に関する説明を行います。
3. CSS in JSのおさらい
内部実装を見ていく前に、前提知識として、CSS in JSを簡単におさらいします。CSS in JSは、以下のように、JavaScript(TypeScript)で記述されたReactコンポーネントと同一のファイル内にスタイリングを定義することを可能にします。
import styled from 'styled-components'
interface Props {
primary?: boolean
}
const Button = styled.button<Props>`
font-size: 1em;
margin: 1em;
padding: 0.25em 1em;
border: 2px solid palevioletred;
border-radius: 3px;
`
export function ButtonGroup() {
return (
<div>
<Button>Normal</Button>
<Button primary>Primary</Button>
</div>
)
}
CSS in JSを利用することで「クラス名の衝突を防ぐことができる」「JSによる動的なスタイリングの変化を容易に、かつ直感的に実装できる」など様々なメリットが得られます。CSS in JSのライブラリには様々な選択肢が存在しますが、その中でも、最もよく知られているライブラリの一つがstyled-componentsです。
4. styled-components移行検討の背景
ここでは、サイボウズ社内でstyled-componentsの移行を検討するに至った背景について説明します。現在、サイボウズの多くのプロダクトでstyled-componentsによりスタイリングが施されたReactコンポーネントが実装されています。
styled-componentsの重要な特徴の一つとして、ビルド後に静的なCSSファイル(.css
)を生成せず、JavaScriptの実行により全てのスタイリングを施すという点が挙げられます。これは、styled-componentsを凌ぐ代表的なCSS in JSであるemotionも同様です。
これらのCSS in JSが採用する「JavaScriptの実行によりスタイリングを施す」という方法は「変数・props等を活用した動的なCSSの実装がしやすい」などメリットもありますが、レンダリング時、JavaScriptの実行によるオーバーヘッドが生じてしまうというパフォーマンス上の課題があります。
一方でCSS modulesやlinaria・vanilla-extractをはじめとするzero-runtime CSS in JSなどでは、ビルド後に静的なCSSファイルが生成され、スタイリングもそれら(.css
)から施されます。
「JavaScriptの実行によるスタイリング」と「静的なCSSファイルによるスタイリング」のどちらが早いかはケースバイケースですが、後者の方がパフォーマンス面では優れていることが多いです。
先日話題になったEmotionのメンテナーによるEmotionとSass Modules(静的なCSSを出力)を用いたレンダリング時間の比較実験 ( Why We're Breaking Up with CSS-in-JS )では、Emotionと比べ、Sass Modulesの方がレンダリングにかかる時間が約48%も短かったことが報告されています。
こういったstyled-componentsをはじめとするruntime CSS in JSの問題点から、断定はできませんが、将来的にstyled-componentsが使用されたReactコンポーネントが負債化する可能性が懸念されます。そのため、それらを、主にzero-runtime CSS in JSをはじめとする他のCSS in JSに移行することを検討するに至りました。
以上の背景から、styled-componentsで定義されたReactコンポーネントを他のCSS in JSによるスタイリングに自動で変換するextract-styledの開発に取り組みました。
5. 実装
本章ではextract-styledの内部実装に関する説明を行います。まずは、extract-styledのコア機能であるJSXコードの変換に関する概要図を見て見ましょう
概要図からわかる通り、JSXコードの変換は「JSXのAST取得(構文解析)」と「各CSS in JSへの変換」という大きく2つのステップに分けられます。
5.1 JSXのAST取得(構文解析)
extract-styledでは、変換対象となるstyled-componentsによりスタイリングされたReactコンポーネントをASTに変換しています。
ここで、ASTに関して簡単に説明を行います。AST (Abstract Syntax Tree, 抽象構文木)は、プログラムのソースコードを木構造で構造的に表現したものを指します。ここでは一例として、const hoge = 1 + 1;
に対するASTを見てみましょう。
JavaScript(TypeScript)の場合、ASTをJSON形式で取得することが可能です。なお、ASTの生成はこちらのサイトから簡単に試せます。
Reactコンポーネントから、ASTという木構造の中間表現を取得することで、必要情報の探索やコードの上書き等の作業がやりやすくなります。
extract-styledでは、ASTの生成を@babel/parserによって実装しました。@babel/parserを用いることで、JSXを含めたJavaScript(TypeScript)プログラムのASTを、以下のようにたった数行のコードで取得できます。
import fs from 'node:fs/promises';
import { parse } from '@babel/parser';
const jsxCode = await fs.readFile('{path to jsx(tsx)}', 'utf-8'); // jsxファイルを読み込む
const ast = parse(jsxCode, {
plugins: ['typescript', 'jsx'],
sourceType: 'module'
}); // AST(JSON形式)を生成
このようにして取得したJSON形式のASTを、@babel/traverse等で探索し「styled-componentsによるスタイリングの定義」をはじめとする必要情報を抽出しました。抽出した情報を用いて、変換先として指定されたCSS in JSの形式に沿ったスタイリングを生成します。次章ではvanilla-extractを例に、各CSS in JSへの変換を見ていきましょう
5.2 各CSS in JSへの変換 (vanilla-extract)
ここでは、vanilla-extractを例に、5.1で取得した情報を用いたCSS in JSの生成を見ていきます。styled-components→vanilla-extractの変換例は2章に掲載があります。
5.1で取得したstyled-componentsで定義されたスタイリングはpropsによる動的なスタイリング等の例外を除いて、形式はCSSと同じです。そこで、まずはCSSの情報を抽出すべく、stylisというツールを用いてCSSのASTを取得しました。@babel/parser同様、stylisによるASTの取得も簡単に実装ができます。
import { compile } from 'stylis';
const cssCode = `...cssの実装...`
const cssAst: CssNode[] = compile(cssCode); // ASTを生成
取得したCSSのASTをvanilla-extractに変換します。これには、CSSのASTを再帰的に探索し、vanilla-extractの形式に変換するgenerateVanillaCode
という関数を実装しました。以下が実装になります。
import { camelCase } from 'camel-case';
import { CssNode } from '../types';
/**
* CSSのASTからObject形式のスタイリングを生成する.
*/
const generateCssObject = (ast: CssNode[], keyRoot: string) => {
let objects: Record<string, Record<string, string>> = {};
const generatHelper = (node: CssNode[], key: string) => {
for (const n of node as CssNode[]) {
if (!Array.isArray(n.children)) {
objects[key] = {
...objects[key],
[camelCase(n.props)]: n.children as string
};
} else {
generatHelper(n.children, camelCase(`${key}-${n.props[0]}`));
}
}
};
generatHelper(ast, keyRoot);
return objects;
};
/**
* CSSのASTからvanilla-extractのコードを生成する.
*/
export const generateVanillaCode = (ast: CssNode[], keyRoot: string) => {
const cssObject = generateCssObject(ast, keyRoot);
const styleCode = Object.entries(cssObject)
.map(([key, value]) => {
return `
export const ${key} = style(${JSON.stringify(value)});
`;
})
.join('');
const code = `
import { style } from '@vanilla-extract/css';
${styleCode}
`;
return code;
};
6. まとめ
以上がextract-styledの紹介になります。今回開発したextract-styledによって、styled-componentsで定義されたコンポーネントを任意のCSS in JSに自動で置換することができます。extract-styledは「JSを用いた動的なスタイリングが未対応である点」や「変換先のCSS in JSが限られている」機能面ではまだまだ未完成な部分も多いです。
extract-styledの開発を通して触れたJSの構文解析やコード生成などの実装は、普段の開発では中々得ることができない経験でした。5日間という短い期間ではありましたが、インターンを通して、実際のフロントエンド基盤を改善できるツールに携われたことは非常に嬉しかったです。
フロントエンドエキスパートの方々・インターンを企画してくださった人事の方々に改めて御礼申し上げます。本当にありがとうございました!
7. 参考文献・関連資料
Discussion