半熟前端

軟體工程師 / 台灣人 / 在前端的路上一邊探索其他領域的可能性

前端

linaria - 不需要 runtime 的 CSS-in-JS 解決方案

linaria - 不需要 runtime 的 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,能夠在動態時期插入 style,進而達到上面提到的開發者想要的事情。

const Title = styled.h2`
  font-size: 24px;
  color: ${props => props.color};
`;

const Component = () => {
  return <Title color="red">Hello World</Title> 
};
  • 模組化:所有的 component css classname 都會被 hash 過,不用怕命名衝突。一個元件一個樣式,符合元件化開發的概念
  • 程式化:因為樣式寫在 JavaScript 裡面,要跑迴圈、判斷式都沒問題。
  • 動態傳遞參數:例如在範例中可以透過 prop 來決定顏色。

當然問題也很明顯,這種依賴執行時期的宣告,會讓整個 App 的渲染效能變差。在一般場景下或許還可以接受,但是根據這一篇文章 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 需要在執行時期去做 house keeping 的動作,當 prop 改變時可能要重新生成 class name、計算 style rule。

雖然 css-in-js 的解決方案改善了開發者體驗與 CSS 的既有問題,但也不可避免增加了 runtime 上的負擔。因此有開發者開發了 linaria,強調 zero runtime 的 css-in-js 的函式庫。

linaria 是什麼?

linaria-logo@2x

既然動態調整樣式會增加效能負擔,將樣式全部寫在 css 檔案又缺乏彈性,有沒有一個可以權衡的方案?這也就是今天要講的 linaria

linaria is Zero-runtime CSS in JS library.

linaria 是一個強調沒有 runtime 的 css in js 的函式庫,他的特色有下面幾個:

  • 在 JS 裡頭寫 CSS,但是沒有 runtime
  • 可以像 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;

加入 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 把 dev server 跑起來應該就沒有問題了。

執行結果

如果執行成功,你應該會成功會看到編譯後的 html 與 css 長這個樣子:

<h1 size="20" class="tm0as6w" style="--tm0as6w-0:20px;">Hello World</h1>
.tm0as6w {
  font-size: var(--tm0as6w-0);
}

你可以觀察到一些事情:

  • font-size 的內容由一個 css variable 定義
  • 渲染後的元件會在 inline style 上加入 css variable。

也就是說 linaria 本身並不是在 runtime 時期插入 style ,而是預先編譯好 css 之後在 runtime 時只會去更新 css variable,這樣子比起動態更新樣式、或是去紀錄每個動態 tag id,對於 runtime 的性能影響就不會那麼大。

props 更新怎麼辦?

如果 props 的更新造成了 css 的改變怎麼辦?像是在範例中如果我們將 size={20} 更新為 size={21} 時要怎麼處理?

linaria 當中,我們只需要把計算的結果更新給 css variable 就可以了。原理大概像這個樣子:

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 是由編譯時期建立的。在 runtime props 改變時就會更新 inline style 的 css variable,進而達到動態更新的效果。

Linaria 原理

詳細的原理有寫在 repo 當中的 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)) {
        // 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 基本上可以拆成兩個步驟:

  • 語法解析
  • 語法轉換

語法解析

首先我們先透過 AST explorer 來解析一下剛剛的元件轉成 AST 之後會是什麼:

import { styled } from '@linaria/react';

const Title = styled.h1`
  font-size: ${props => props.size || 10}px;`;

_2020-12-07_15.20.45

解析後的 AST 可以到這裏查看。

可以發現經由 parser 解析之後,這個元件可以被切分為一個 VariableDeclaration

它的 Declarator 是一個 MemberExpression (styled.h1),之後是一個 TemplateLiteral (也就是 `\包起來的部分)與跟expressions。裏頭有一個ArrowFunctionExpression` (props ⇒ props.size || 10)。

解析到這邊,linaria 已經有足夠的資訊生成 css classname 與解析樣式。

語法轉換

linaria 語法轉換主要是透過 babel plugin 完成,整個實作邏輯可以參考 packages/babel/src/evaluators/templateProcessor.ts

接下來簡單講解裡頭的轉換步驟。

  • 將 tempalate literal 的字串放入 cssText 當中。

  • 遍歷所有 quasi (用 ${} 包起來的地方跟字串的分割點)

    • ${} 包起來的 expression 且不是 function 的話會在這個時候解析並且將計算好的值放入 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 variable
     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 的形式轉換為編譯後的程式碼:

    • 加入 nameclass:在這邊 name 會是元件原本的名稱,class 是編譯後 linaria 生成的 class name

    • 加入 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):

  1. 將編譯後的 className 與原有的 className 組合在一起

    filteredProps.className = cx(
     filteredProps.className || className,
     options.class
    );
  2. 如果 vars 裡頭有屬性,遍歷屬性並且將 style 賦值為 css variable

    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);
    }
  3. 呼叫 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);
    }
  4. 將元件原本的名稱賦值給 displayName (方便 debug)

    (Result as any).displayName = options.name;

css

除了使用 linaria 提供的 styled 之外,也可以單獨使用 css 這個 API 來使用 linaria。css 這個 API 會回傳一個 classname,然後一樣會生成一份 css 檔案。例如下面這個程式碼,這兩個宣告分別會生成兩個 class name。

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>;
}

在轉換程式碼的時候,因為編譯時期已經決定好 class 名稱了,實際上這兩個變數會變成字串:

// 經過 babel 轉換後
const weight = "wm0as6w"; // build time 生成的 class
const size = "s13mnax5"; // build time 生成的 class

這個 css 有點像是一個標記,告訴 babel 說,嘿任何有用 css 包起來的程式碼,我要分析他們裡面的東西建立出 css。所以在 runtime 呼叫也會出現錯誤:(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 是在 build time 時期編譯 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.

或者任何需要 runtime 的變數也沒辦法順利編譯:

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 的話因為會在 runtime 執行所以沒有問題:

// 這樣沒問題
const Title = styled.h1`
  font-size: ${() => window.innerHeight}px;
`;

另外,有些 API 會不小心跟 runtime 搞混,例如下面這個範例(在實際開發可能不會這麼做):

const Title = styled.h1`
  font-size: 20px;
  .${Math.random()} {
    font-size: 20px;
  }
`;

這個宣告會在 build time 生成 class,所以 css 會長這樣:

.tm0as6w {
    font-size: 20px;
}

.tm0as6w .0.7732446605094165 {
    font-size: 20px;
}

而不是在 runtime 的時候動態生成 class,所以除非重新編譯一次,不然 class name 是不會改變的。

總結來說,如果 ${} 裡面是一個 Function expression 或是 Arrow function expression,linaria 會在 build time 生成一個 css variable,實際內容則是在 runtime 時決定。

如果 ${} 裡面不是 function expression 或是 arrow function expression,linaria 會試著在 build time 執行並且將內容放入 css 檔案。

如何除錯?

linaria 是在 build time 修改程式碼,自然有支援 source map。開發者可以很容易看到原始碼定義,只要瀏覽器有支援 source map。

心得

前面提到 linaria 各種優勢,但軟體開發中沒有銀彈,目前 linaria 沒辦法單獨在 runtime 使用,一定要搭配 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 以及理解一下背後的思想。

不知道大家是否有注意到一個趨勢,目前有越來越多的開發應用,都是試圖從 compile 下手,例如之前介紹的 Svelte 與今天介紹的 linaria 都是一樣。藉由 compile 的幫助,可以省下不少執行時期的效能,也可以在 compile 時檢查一些錯誤避免 runtime 的 bug 出現。

透過 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 的。

以後如果在開發上出現一些難解的效能問題時,不妨從 compile 的角度思考,說不定可以找到新大陸。

參考資源

後記

感謝前同事 @kai 提供意見。其實 zero-runtime 這個概念不算創新,在幾年前的 CSS-in-JS 大戰當中就有一些函式庫是強調 zero-runtime,像是 astroturf 或是早期版本的 emotion。只是目前看起來 linaria 將這樣的思想近一步實作後讓 API 跟 styled-components 更靠近一步,或許是讓他逐漸竄紅的原因之一也說不定。