linaria - ランタイム不要の CSS-in-JS ソリューション
はじめに
現在、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 performamance costs of modern CSS-in-JS libraries in React Apps によれば、動的な CSS はパフォーマンスの怪物になり得ると示されている。この記事では、React で 50 個の div をレンダリングする際、styled-components を使うとパフォーマンスがほぼ半分に低下したと指摘している。
So interestingly enough, on average, the CSS-in-JS implementation is 56.6% more expensive in this example. Let’s see if things are different in production mode. The timings of the re-renders in production mode can be seen below:
主な理由としては、styled-components が styled コンポーネントを生成するたびに Context Consumer の読み取りが必要となり、パフォーマンスに影響を与える点が挙げられる。もう一つは、styled-components が実行時にハウスキーピング処理を行う必要があり、prop が変更された際にクラス名を再生成したりスタイルルールを再計算したりしなければならない点だ。
css-in-js ソリューションは開発者体験と CSS 既存の課題を改善したが、ランタイムの負荷を不可避的に増やしてしまった。そこで、ゼロランタイムを強調する css-in-js ライブラリ linaria を開発したエンジニアたちが現れた。
linaria とは?

動的にスタイルを調整するとパフォーマンスの負荷が増加し、スタイルをすべて CSS ファイルに書くと柔軟性に欠ける。そのトレードオフを取れるソリューションはないだろうか?それこそが、今回紹介する linaria だ。
linaria is Zero-runtime CSS in JS library.
linaria はランタイム不要を強調する CSS-in-JS ライブラリであり、以下のような特徴を持っている:
- JS 内に CSS を記述するが、ランタイムが存在しない
styled-componentsのように動的に props を渡せる- ソースマップをサポート
- 同様に 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;
script の追加
{
"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 を実行して開発サーバーを立ち上げれば問題ないはずだ。
実行結果
正常に実行されると、コンパイル後の 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 がスタイルを正しく解析・分割するために、一段階のコンパイルプロセスに依存しているからだ。Babel を適用しなかったり、@linaria/webpack-loader を設定せずに直接 css や styled の API を呼び出すと、次のような警告が出る:
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)) {
// We received a strings array since it's used as a tag
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 の処理は基本的に以下の2つのステップに分解できる:
- 構文解析(パース)
- 構文変換(トランスパイル)
構文解析
まず AST explorer を使って、先ほどのコンポーネントが AST に変換されるとどうなるかを解析してみよう:
import { styled } from '@linaria/react';
const Title = styled.h1`
font-size: ${props => props.size || 10}px;`;

解析後の AST はこちらで確認できる。
パーサーによる解析後、このコンポーネントは1つの 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に格納する。// Try to preval the value 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 変数を1つ追加する。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'],
},
});
そして、1つの 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にプロパティが存在する場合、プロパティを走査してstyleに 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) { // If the underlying tag is a styled component, forward the `as` prop // Otherwise the styles from the underlying component will be ignored filteredProps.as = component; return React.createElement(tag, filteredProps); } -
元のコンポーネント名を
displayNameに設定する(デバッグを容易にするため)(Result as any).displayName = options.name;
css
linaria が提供する styled を利用するだけでなく、単独で css という API を使って linaria を利用することもできる。css API はクラス名を返し、同様に CSS ファイルを生成する。例えば以下のコードでは、2つの宣言がそれぞれ2つのクラス名を生成する。
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>;
}
コード変換の際、コンパイル時にクラス名が決定されているため、実際にはこれら2つの変数は文字列になる:
// 經過 babel 轉換後
const weight = "wm0as6w"; // build time 生成的 class
const size = "s13mnax5"; // build time 生成的 class
この css は、「css で囲まれたコードを解析し、その内容から CSS を構築せよ」と Babel に伝えるマーカーのような役割を果たしている。そのため、ランタイムで呼び出すとエラーが発生する:(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.
ただし、関数式(Function expression)であればランタイムで実行されるため問題ない:
// 這樣沒問題
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;
}
ランタイムで動的にクラスが生成されるわけではないため、再コンパイルしない限りクラス名が変わることはない。
まとめると、${} の中身が Function expression または Arrow function expression の場合、linaria はビルド時に CSS 変数を生成し、実際の内容はランタイムで決定される。
${} の中身が関数式でない場合、linaria はビルド時にそれを実行しようとし、その内容を CSS ファイルに出力する。
デバッグ方法
linaria はビルド時にコードを変更するため、当然ながら source map に対応している。ブラウザが source map をサポートしていれば、開発者は元のソースコード定義を容易に確認できる。
所感
ここまで 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 自体でツリーシェイキング(tree-shake)が可能なのだ。
今後、開発で解決困難なパフォーマンスの問題に直面したときは、コンパイルの観点からアプローチしてみると、新しい活路が見出せるかもしれない。
参考資料
- 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 に近づけたことが、徐々に注目を集めている理由の一つなのかもしれない。
関連記事
- Three.js で僕の部屋を表現する 僕が React Three Fiber を使って実際の自分の部屋をブラウザ上に再現し、実世界のオブジェクトを目録に見立て、空間の記憶を通してここ数年の生活と仕事について語った話。
- フロントエンドで画像を扱う際に注意すべきこと Jake Archibaldの記事を起点に、現代のレスポンシブ画像の書き方を整理する。なぜwidth/heightを付ける必要があるのか、CSSのaspect-ratioはいつ使うべきか、AVIFとWebPの選び方、そしてpicture/source/srcsetを使ったモバイル向け画像の切り替えについて。
- CSS field-sizing — たった1行のCSSでフォーム要素を自動リサイズする かつてtextareaの自動高さ調整は、JavaScriptでscrollHeightを監視するしかなかった。しかしCSSのfield-sizing: contentなら、わずか1行で代替でき、textarea、input、selectに対応している。本記事では従来のやり方のペインポイントと、field-sizingの使い方をまとめる。
- リンクの下線をもっと見栄え良くする:text-underline-offset デフォルトでは下線と文字が近すぎて、このスタイルを好まないデザイナーもいるし、僕自身もあまり綺麗ではないと感じていた。