質問やフィードバックがありましたら、フォームからお願いします
本文は台湾華語で、ChatGPT で翻訳している記事なので、不確かな部分や間違いがあるかもしれません。ご了承ください
前言
現在 css-in-JS
は一般的な開発ソリューションとなっており、フロントエンド開発においてこの手法が主流となっている理由はいくつかあります:
BEM
やOOCSS
などの命名手法と比べて、css-in-JS は主に開発ツールを通じて利用され、CSS の命名衝突の問題を根本的に解決します。- 過去には、エンジニアがスタイルを書く際に、CSS にプログラマティックでモジュール化された機能(例:ループ、ネストされた CSS、関数など)を望んでいたため、大抵は
SASS
を使用して開発していました。 - インタラクションと体験を重視するフロントエンド開発では、JavaScript と CSS が相互に連携できること(たとえばパラメータの伝達や動的な変数の調整)を望んでいます。
React
において、現在最も人気のあるソリューションは styled-components でしょう。
styled-components
は主に Tagged template と JavaScript API を利用し、動的な状況でスタイルを挿入することができ、開発者が望む機能を実現します。
const Title = styled.h2`
font-size: 24px;
color: ${props => props.color};
`;
const Component = () => {
return <Title color="red">Hello World</Title>
};
- モジュール化:すべてのコンポーネントの CSS クラス名はハッシュ化され、命名衝突を気にする必要がありません。1つのコンポーネントには1つのスタイルがあり、コンポーネント指向の開発の概念に合致します。
- プログラマティック:スタイルが JavaScript の中に書かれているため、ループや条件分岐を行うことも問題ありません。
- 動的なパラメータの伝達:たとえば、例の中では
prop
を通じて色を決定できます。
もちろん、問題も明らかです。このような実行時の宣言に依存することで、アプリ全体のレンダリングパフォーマンスが低下します。一般的なシーンではまだ許容できるかもしれませんが、この記事 The unseen performance costs of modern CSS-in-JS libraries in React Apps によれば、動的 CSS はパフォーマンスにおいて問題を引き起こす可能性があります。この文書は、React で50個の div をレンダリングする場合、styled-components
のパフォーマンスがほぼ半分遅くなることを示しています。
興味深いことに、この例では平均して CSS-in-JS の実装は 56.6% 高価です。プロダクションモードでは状況が異なるか見てみましょう。プロダクションモードでの再レンダリングのタイミングは以下の通りです:
主な理由のいくつかは、styled-components
がスタイル付きコンポーネントを作成するたびに Context Consumer の読み込みが必要になり、パフォーマンスに影響を与えることです。別の理由として、styled-components は実行時にハウスキーピングを行う必要があり、prop が変更されるとクラス名を再生成したり、スタイルルールを計算したりする必要があります。
css-in-js のソリューションは、開発者体験や CSS の既存の問題を改善しましたが、実行時の負担を増加させることも避けられません。そのため、開発者は linaria
を開発し、ゼロランタイムの css-in-js ライブラリを強調しています。
linaria とは?
動的にスタイルを調整することがパフォーマンスの負担を増加させるため、すべてのスタイルを CSS ファイルに書くのは柔軟性に欠けます。バランスを取ることができるソリューションはあるのでしょうか?これが今日話す linaria
です。
linaria はゼロランタイムの CSS in JS ライブラリです。
linaria は実行時がない CSS in JS ライブラリであり、以下のような特徴があります:
- JS の中で CSS を書きますが、ランタイムがありません
styled-components
のように props を動的に渡すことができます- sourcemap をサポートしています
- JavaScript を通じて CSS にロジックを書くことができます
- プリプロセッサをサポートしています
みんな少しイライラするかもしれません。なぜ CSS-in-JS には新しいものが次々と登場するのでしょうか。
しかし、これは良いことだと思います。現状に不満を持ったり、問題点を改善しようとするのはエンジニアの性です。もちろん、最終的に選ぶ道が最善の解決策であるとは限りませんが、これらの試みがあってこそ、より良いソリューションへと一歩一歩進むことができます。
linaria の使い方(React の例)
基本的に linaria
は React と一緒に使う必要はありませんが、ここでは React を例に挙げて説明します。
webpack 設定と babel の準備
yarn add --dev webpack webpack-cli webpack-dev-server mini-css-extract-plugin css-loader file-loader babel-loader @linaria/webpack-loader
yarn add --dev @babel/preset @babel/core @babel/preset-env @babel/preset-react
yarn add @linaria/core @linaria/react @linaria/babel @linaria/shaker
yarn add react react-dom
webpack.config ファイルの設定
公式が提供している基本的な設定ファイルを基に、必要に応じて修正します:
const webpack = require('webpack');
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const dev = process.env.NODE_ENV !== 'production';
module.exports = {
mode: dev ? 'development' : 'production',
devtool: 'source-map',
entry: {
app: './src/index',
},
output: {
path: path.resolve(__dirname, 'dist'),
publicPath: '/dist/',
filename: '[name].bundle.js',
},
optimization: {
noEmitOnErrors: true,
},
plugins: [
new webpack.DefinePlugin({
'process.env': { NODE_ENV: JSON.stringify(process.env.NODE_ENV) },
}),
new MiniCssExtractPlugin({ filename: 'styles.css' }),
],
module: {
rules: [
{
test: /\\.js$/,
exclude: /node_modules/,
use: [
{ loader: 'babel-loader' },
{
loader: '@linaria/webpack-loader',
options: { sourceMap: dev },
},
],
},
{
test: /\\.css$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
options: {
hmr: process.env.NODE_ENV !== 'production',
},
},
{
loader: 'css-loader',
options: { sourceMap: dev },
},
],
},
{
test: /\\.(jpg|png|gif|woff|woff2|eot|ttf|svg)$/,
use: [{ loader: 'file-loader' }],
},
],
},
devServer: {
contentBase: [path.join(__dirname, 'public')],
historyApiFallback: true,
},
};
.babelrc の設定
{
"presets": [
"@babel/preset-env",
["@babel/preset-react", {
"runtime": "automatic"
}],
"module:@linaria/babel"
]
}
src/index.js ファイルを追加
import React from 'react';
import { render } from 'react-dom';
import App from './App';
render(<App />, document.body)
src/App.js ファイルを追加
import { styled } from '@linaria/react';
const Title = styled.h1`
font-size: ${props => props.size || 10}px;`;
const App = () => {
return <div>
<Title size={10}>Hello World</Title>
</div>
}
export default App;
スクリプトを追加
{
"scripts": {
"dev": "webpack --mode=development serve",
"build": "webpack --mode=development",
"build:prod": "webpack --mode=production"
}
}
public/index.html を追加
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test</title>
<!-- linaria がコンパイルした -->
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<script src="/app.bundle.js"></script>
</body>
</html>
その後、npm run dev
を実行して dev サーバーを立ち上げると、問題なく動作するはずです。
実行結果
もし成功した場合、コンパイル後の HTML と CSS は以下のようになります:
<h1 size="20" class="tm0as6w" style="--tm0as6w-0:20px;">Hello World</h1>
.tm0as6w {
font-size: var(--tm0as6w-0);
}
いくつかのことを観察できます:
font-size
の内容は CSS 変数によって定義されています- レンダリング後のコンポーネントはインラインスタイルに CSS 変数が追加されています。
つまり、linaria
自体は実行時に style
を挿入するのではなく、あらかじめ CSS をコンパイルした後、実行時には CSS 変数を更新するだけです。これにより、スタイルを動的に更新したり、各動的タグ ID を記録したりすることに比べて、実行時のパフォーマンスへの影響はそれほど大きくありません。
props の更新はどうする?
もし props
の更新によって CSS が変更された場合、たとえば例の中で size={20}
を size={21}
に更新した場合はどうすればよいのでしょうか?
linaria では、計算結果を CSS 変数に更新するだけで済みます。原理は以下のようになります:
const Title = styled.h1`
font-size: ${props => props.size || 10}px;`;
// babel によって解析された後
const Title = styled('h1')({
...,
vars: {
'tm0as6w-0': [props => props.size || '', 'px'];
}
})
styled.h1
は babel によって変換され、この形式になります。vars
内の情報はコンパイル時に作成されます。実行時に props
が変更されると、インラインスタイルの CSS 変数が更新され、動的更新が実現されます。
Linaria の原理
詳細な原理はリポジトリ内の HOW IT WORKS に記載されており、ここではできるだけ多くの実装詳細をカバーします。
経験豊富なエンジニアであれば、linaria を使用する際には必ず babel
と一緒に使う必要があることに気付くでしょう。これは、linaria が正しくスタイルを解析し、分割するためには編纂された層に依存する必要があるからです。CSS または styled API を直接使用すると、以下のような警告が表示されます(babel や @linaria/webpack-loader を設定していない場合):
Uncaught Error: Using the "styled" tag in runtime is not supported.
Make sure you have set up the Babel plugin correctly. See <https://github.com/callstack/linaria#setup>
ソースコードはこちらのようになります:
if (process.env.NODE_ENV !== 'production') {
if (Array.isArray(options)) {
// 文字列の配列を受け取った場合は、タグとして使用されます
throw new Error(
'Using the "styled" tag in runtime is not supported. Make sure you have set up the Babel plugin correctly. See <https://github.com/callstack/linaria#setup>'
);
}
}
linaria の原理を理解するには、まず babel の基本的な概念を理解する必要があります。babel は基本的に二つのステップに分かれます:
- 構文解析
- 構文変換
構文解析
まず、AST explorer を使って、先ほどのコンポーネントを解析した後の AST がどうなるかを見てみましょう:
import { styled } from '@linaria/react';
const Title = styled.h1`
font-size: ${props => props.size || 10}px;`;
解析後の AST はこちらで確認できます。
parser によって解析された後、このコンポーネントは VariableDeclaration
に分割されます。
その Declarator
は MemberExpression
(styled.h1)で、続いて TemplateLiteral
(つまり `\で囲まれた部分)と
expressionsがあります。その中には
ArrowFunctionExpression` (props ⇒ props.size || 10) が含まれています。
ここまで解析されると、linaria は CSS クラス名を生成し、スタイルを解析するために十分な情報を持っています。
構文変換
linaria
の構文変換は主に babel プラグインを通じて行われます。全体の実装ロジックは packages/babel/src/evaluators/templateProcessor.ts を参照してください。
次に、内部の変換手順を簡単に説明します。
-
テンプレートリテラルの文字列を cssText に入れます。
-
すべての
quasi
(${}
で囲まれた部分と文字列の分割点)を走査します。-
${}
で囲まれた expression が関数でない場合、ここで解析され、計算された値がcssText
に入れられます。// 値をプレバルする試み if ( options.evaluate && !(t.isFunctionExpression(ex) || t.isArrowFunctionExpression(ex)) ) { const value = valueCache.get(ex.node); // 簡略化 if (value && typeof value !== 'function') { cssText += stripLines(loc, value); return; } }
-
expression の情報を
interpolations
に入れ、CSS 変数を追加します。if (styled) { const id = `${slug}-${i}`; interpolations.push({ id, node: ex.node, source: ex.getSource() || generator(ex.node).code, unit: '', }); cssText += `var(--${id})`; }
-
styled.h1
の形式をコンパイルされたコードに変換します:name
とclass
を追加します:ここで name はコンポーネントの元の名前で、class はコンパイル後に linaria が生成したクラス名です。vars
を追加します:各${}
内の expression が
props.push( t.objectProperty(t.identifier('name'), t.stringLiteral(displayName!)) ); props.push( t.objectProperty(t.identifier('class'), t.stringLiteral(className!)) ); props.push( t.objectProperty( t.identifier('vars'), t.objectExpression( Object.keys(result).map((key) => { const { id, node, unit } = result[key]; const items = [node]; if (unit) { items.push(t.stringLiteral(unit)); } return t.objectProperty( t.stringLiteral(id), t.arrayExpression(items) ); }) ) ) ); } path.replaceWith( t.callExpression( t.callExpression( t.identifier(state.file.metadata.localName || 'styled'), [styled.component.node] ), [t.objectExpression(props)] ) );
-
解析後、元のコードは以下のように変わります:
const Title = styled('h1')({
name: 'Title',
class: 'tm0as6w',
vars: {
'tm0as6w-0': [props => props.size, 'px'],
},
});
そして CSS ファイルが生成されます:
.tm0as6w {
font-size:var(--tm0as6w-0);
}
styled
の実装
次に styled
の内部がどのように実装されているかを見てみましょう。まず、babel 変換後のプログラムコードはすでに以下のように変換されています:
const Container = styled('h1')({
name: 'Title',
class: 'tm0as6w',
vars: {
'tm0as6w-0': [props => props.size, 'px'],
},
});
その後、styled
の実装を観察します(packages/react/src/styled.ts):
-
コンパイル後の
className
と元のclassName
を組み合わせます。filteredProps.className = cx( filteredProps.className || className, options.class );
-
vars
内に属性がある場合、それを走査し、スタイルに CSS 変数を割り当てます。for (const name in vars) { const variable = vars[name]; const result = variable[0]; const unit = variable[1] || ''; const value = typeof result === 'function' ? result(props) : result; warnIfInvalid(value, options.name); style[`--${name}`] = `${value}${unit}`; } filteredProps.style = Object.assign(style, filteredProps.style); }
-
React.createElement
を呼び出します。if ((tag as any).__linaria && tag !== component) { // 基になるタグがスタイル付きコンポーネントである場合、`as` プロパティを転送します // さもなければ、基になるコンポーネントのスタイルは無視されます filteredProps.as = component; return React.createElement(tag, filteredProps); }
-
コンポーネントの元の名前を
displayName
に割り当てます(デバッグしやすくするため)。(Result as any).displayName = options.name;
css
linaria が提供する styled
を使用するだけでなく、単独で css という API を使用して linaria を利用することもできます。css
という API はクラス名を返し、同様に CSS ファイルも生成されます。例えば、以下のプログラムコードでは、これら二つの宣言がそれぞれ二つのクラス名を生成します。
import { css, cx } from '@linaria/core';
const weight = css`
font-weight: bold;
`;
const size = css`
font-size: 12px;
`;
export default function App() {
return <div className={cx(weight, size)}>Hello World</div>;
}
コードを変換する際、コンパイル時にはクラス名がすでに決定されているため、実際にはこれら二つの変数は文字列に変わります:
// babel 変換後
const weight = "wm0as6w"; // ビルド時に生成されたクラス
const size = "s13mnax5"; // ビルド時に生成されたクラス
この css
は、babel に「ねえ、CSS で囲まれたプログラムコードがあれば、それを分析して CSS を生成するよ」と伝えるようなものです。したがって、ランタイムで呼び出すとエラーが発生します:(https://github.com/callstack/linaria/blob/master/packages/core/src/css.ts)
export default function css(
_strings: TemplateStringsArray,
..._exprs: Array<string | number | CSSProperties | StyledMeta>
): string {
throw new Error(
'Using the "css" tag in runtime is not supported. Make sure you have set up the Babel plugin correctly.'
);
}
開発上の注意事項
linaria はビルド時に CSS をコンパイルするため、次のことはできません:(css
という API を使用する際)
const size = css`
font-size: ${props => props.size}px;
`;
以下のようなエラーが表示されます:
The CSS cannot contain JavaScript expressions when using the 'css' tag. To evaluate the expressions at build time, pass 'evaluate: true' to the babel plugin.
また、実行時の変数を使用することもできません:
const Title = styled.h1`
font-size: ${window.innerHeight}px;
`;
以下のようなエラーが表示されます:
Make sure you are not using a browser or Node specific API and all the variables are available in static context.
Linaria have to extract pieces of your code to resolve the interpolated values.
Defining styled component or class will not work inside:
- function,
- class,
- method,
- loop,
because it cannot be statically determined in which context you use them.
That's why some variables may be not defined during evaluation.
ただし、関数式の場合は実行時に実行されるため、問題ありません:
// これは問題ありません
const Title = styled.h1`
font-size: ${() => window.innerHeight}px;
`;
また、一部の API は誤ってランタイムと混同されることがあります。例えば、以下の例(実際の開発ではこのように行うことは少ないかもしれません):
const Title = styled.h1`
font-size: 20px;
.${Math.random()} {
font-size: 20px;
}
`;
この宣言は ビルド時 に class
を生成するため、CSS は以下のようになります:
.tm0as6w {
font-size: 20px;
}
.tm0as6w .0.7732446605094165 {
font-size: 20px;
}
つまり、ランタイムの際に動的にクラスを生成するのではなく、再コンパイルしない限りクラス名は変わらないということです。
要するに、${}
内に関数式またはアロー関数式がある場合、linaria はビルド時に CSS 変数を生成し、実際の内容はランタイムで決定されます。
${}
内に関数式やアロー関数式がない場合、linaria はビルド時に内容を実行し、それを CSS ファイルに入れようとします。
どのようにデバッグする?
linaria はビルド時にコードを変更するため、ソースマップをサポートしています。開発者は、ブラウザがソースマップをサポートしている限り、元のコードを簡単に見ることができます。
感想
前述のように、linaria
にはさまざまな利点がありますが、ソフトウェア開発においては銀の弾丸は存在しません。現在のところ、linaria
はランタイムで単独使用することができず、必ず babel と一緒に解析しなければ機能しません。多くの開発にとって大きな問題ではないと思いますが、ほとんどのプロジェクトは多かれ少なかれ babel と webpack を使用しています。
また、linaria は theme
をサポートしていません。つまり、styled-components
の中で以下のようにすることはできません:
const Title = styled.h1`
color: ${props => props.theme.MAIN};
`
また、真の動的スタイルも実現できません:(これでは静的解析ができなくなります)
const Title = styled.h1`
font-size: 30px;
${isMobile && css`
font-size: ${props => props.mobileSize}px;
`}
`;
さらに、CSS 変数は現在 IE 11 をサポートしていません。この部分は開発シーンの選択に依存しますが、linaria
を試してその背後にある考え方を理解することを強くお勧めします。
最近のトレンドに気付いている方は多いかもしれませんが、現在はコンパイルを通じて解決しようとする開発アプローチが増えてきています。たとえば、以前紹介した Svelte
や今回紹介した linaria
も同様です。コンパイルの助けを借りることで、実行時のパフォーマンスを節約し、コンパイル時にエラーをチェックしてランタイムのバグを回避することができます。
JavaScript を使用すれば、特定の CSS が使用されているかどうかを簡単に確認できます:
import { css } from '@linaria/core'
const text = css`
font-size: 20px;
`;
const App = () => {
return <div>hello world</div>
}
export default App;
例えば、この変数 text
が使用されていない場合、linaria は css
を生成しません。つまり、linaria
自体はツリーシェイクが可能です。
今後、開発において解決が難しいパフォーマンスの問題が発生した場合、コンパイルの観点から考えることで新しい解決策を見つけられるかもしれません。
参考リソース
- WHY linaria
- zero runtime css in js
- The unseen performance costs of modern CSS-in-JS libraries in React apps
- Use CSS variable instead of React Context
後記
前の同僚 @kai に意見をいただき感謝します。ゼロランタイムという概念は新しいものではなく、数年前の CSS-in-JS 戦争の中でいくつかのライブラリがゼロランタイムを強調していました。例えば、astroturf や初期バージョンの emotion などです。ただし、現在のところ linaria はこの考え方を一歩進めて、API を styled-components
により近づけたため、急速に注目を集めている理由の一つかもしれません。
この記事が役に立ったと思ったら、下のリンクからコーヒーを奢ってくれると嬉しいです ☕ 私の普通の一日が輝かしいものになります ✨
☕Buy me a coffee