React CSS Modules, TypeScript, Webpack, with Babel Plugin

When

2017/02/17

At

[webpack-sake](https://ng-sake.connpass.com/event/50773/)

---

## 自己紹介

ちきさん
(Tomohiro Noguchi)

Twitter/GitHub/Qiita: [@ovrmrw](https://twitter.com/ovrmrw)

ただのSIer。

Angular Japan User Group (ng-japan)スタッフ。

![3a2512bb-aa72-4515-af42-1f1721252f39.jpg](https://qiita-image-store.s3.amazonaws.com/0/74793/c753b39c-1581-ea1d-87d1-181f37bda60d.jpeg)

---

(ここから **CSS Modules** と **React CSS Modules** の話)

===

GitHubリポジトリ [ovrmrw/react-webpack-css-modules-typescript-example](https://github.com/ovrmrw/react-webpack-css-modules-typescript-example)

---

## CSS Modules

---

よくあるCSS ModulesのJSコード(React)

```
import React from 'react'
import styles from './table.css'

export default class Table extends React.Component {
  render () {
    return <div className={styles.table}>
      <div className={styles.row}>
        <div className={styles.cell}>A0</div>
        <div className={styles.cell}>B0</div>
      </div>
    </div>
  }
}
```

CSSを `styles` オブジェクトに格納して `{styles.foo}` を `className` に代入する。

```
import styles from './table.css'
↓
<div className={styles.table}>
```

これでコンポーネントに閉じたCSSを実現できる。

---

注意点: CSSのクラス名はキャメルケース。

```
<div className={styles.myStyleName}>
```

上記のようにstyleをJSオブジェクトとして扱うため、CSSの原則に反してキャメルケースで書かなければならない。

```css
.myStyleName {
  color: green;
  background: red;
}

.otherStyleName {
  composes: className;
  color: yellow;
}
```

キモい。

---

## React CSS Modules

---

キャメルケース縛りがなくなる。
以下はDecoratorを使ったReact CSS ModulesのJSコード。

```
import React from 'react'
import CSSModules from 'react-css-modules'
import styles from './table.css'

@CSSModules(styles)
export default class extends React.Component {
  render () {
    return <div styleName='table'>
      <div styleName='row'>
        <div styleName='cell'>A0</div>
        <div styleName='cell'>B0</div>
      </div>
    </div>
  }
}
```

CSSを `styles` オブジェクトに格納して `styleName` プロパティで参照する。CSS Modulesより若干すっきりする。

```
import styles from './table.css'
↓
@CSSModules(styles)
↓
<div styleName='table'>
```

CSSのクラス名をキャメルケースで書かなくて良い。

> React CSS Modules component automates loading of CSS Modules using `styleName` property.

GitHub: [gajus/react-css-modules](https://github.com/gajus/react-css-modules)

---

### Decorator: `@CSSModules(styles)`

---

### Angularみたい...!! :arrow_heading_up:

---

### しかしReact CSS Modulesの物語はここで終わらない

---

## babel-plugin-react-css-modules

---

babel-plugin-react-css-modulesを使ったJSコード

```
import React from 'react'
import './table.css'

export default class Table extends React.Component {
  render () {
    return <div styleName='table'>
      <div styleName='row'>
        <div styleName='cell'>A0</div>
        <div styleName='cell'>B0</div>
      </div>
    </div>
  }
}
```

Decoratorが消えて、もはや何が起きているのかわからない。しかしこれでCSSは当たっている。

```
import './table.css'
↓
<div styleName='table'>
```

CSSのクラス名をキャメルケースで書かなくて良いというメリットは同じ。

GitHub: [gajus/babel-plugin-react-css-modules](https://github.com/gajus/babel-plugin-react-css-modules)

---

### VERY COOL :sunglasses:

---

### でもBabel Pluginなのか...

---

TypeScriptでも使いたいとかそんなこと...

---

### できるんです！！

![pr_source.png](https://qiita-image-store.s3.amazonaws.com/0/74793/5652e287-4fb0-95bc-6b11-b927589ffed0.png)

---

ちなみにTSで書くとこんな感じ。

```
import * as React from 'react'
import './table.css'

export class Table extends React.Component<{}, {}> {
  render () {
    return <div styleName='table'>
      <div styleName='row'>
        <div styleName='cell'>A0</div>
        <div styleName='cell'>B0</div>
      </div>
    </div>
  }
}
```

JSで書いたときとほぼ変わらない。

---

CSS Modulesによりこれらの機能を使ってCSSを書ける。

- Import (*@value*)
- Custom Properties
- Inheritance (*composes*)

```css:app.css
:root {
  --custom-bg-color: yellow;
}

.my-style {
  background-color: var(--custom-bg-color); /* using Custom Property */
  color: blue;
  font-weight: bold;
}
```

```css:child.css
@value appModule: "./app.css"; /* importing .app/css */

:root {
  --custom-bg-color: orange;
}

.my-style {
  composes: my-style from appModule; /* extending .my-style from app.css */
  background-color: var(--custom-bg-color); /* using Custom Property */
}
```

---

(DEMO)

---

(ここからDEMO用に書いたWebpack設定の話。つまり本題)

---

## Webpack構成(module.rules)

- **.ts, .tsx**
    - awesome-typescirpt-loader
    - babel-loader
        - plugins: `transform-react-jsx`, `react-css-modules`
- **.css**
    - postcss-loader
        - plugins: `postcss-cssnext`, `postcss-modules-values`
    - css-loader
        - modules: `true`
    - style-loader

---

## tsconfig.jsonのコツ

```json:tsconfig.json
{
  "compilerOptions": {
    "module": "es2015",
    "moduleResolution": "node",
    "jsx": "preserve",
    "sourceMap": true
  }
}
```

- WebpackかBabelで再コンパイルされるのでtscでは `"module": "es2015"` で出力する。
- JSXの変換はBabelの段階でやる必要があるのでtscでは `"jsx": "preserve"` で出力する。
- ここで `"sourceMap": true` を指定しておくと最終的にTSコードにbreakpointを置けるようになる。

---

webpack.config.js の全コード。
これぐらいは諳(そら)で書けるようになりたい。

```js:webpack.config.js
const webpack = require('webpack')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const CompressionWebpackPlugin = require('compression-webpack-plugin')
const ExtractTextPlugin = require('extract-text-webpack-plugin')

module.exports = function (env = {}) {
  console.log('env:', env)

  const isProduction = !!env.production
  const context = process.cwd()
  const cssModulesScopedName = '[path]___[name]__[local]___[hash:base64:5]'
  let entry
  let outputFilename
  let plugins = []
  let moduleRules = []

  ////////////////////////////////////// entry
  entry = isProduction ?
    // production
    ['./config/polyfills.ts', './src/index.ts'] :
    // development
    {
      main: './src/index.ts',
      vendor: './config/vendor.ts',
      polyfills: './config/polyfills.ts',
    }

  ////////////////////////////////////// output.filename
  outputFilename = isProduction ?
    // production
    'static/js/bundle.[chunkhash].js' :
    // development
    'static/js/[name].js'

  ////////////////////////////////////// plugins
  plugins.push(
    new HtmlWebpackPlugin({
      template: 'public/index.html',
      filename: 'index.html'
    })
  )

  if (isProduction) {
    // production
    plugins.push(
      new CompressionWebpackPlugin({
        test: /\.js$/
      }),
      new ExtractTextPlugin('static/css/bundle.[chunkhash].css')
    )
  } else {
    // development
    plugins.push(
      new webpack.optimize.CommonsChunkPlugin({
        name: ['vendor', 'polyfills'],
      })
    )
  }

  ////////////////////////////////////// module.rules
  moduleRules.push(
    {
      test: /\.tsx?$/,
      exclude: [/node_modules/],
      use: [
        {
          loader: 'babel-loader',
          options: {
            presets: isProduction ?
              // production
              ['latest'] :
              // development
              [],
            plugins: [
              'transform-react-jsx',
              ['react-css-modules', { generateScopedName: cssModulesScopedName }],
            ]
          }
        },
        'awesome-typescript-loader'
      ]
    },
    {
      test: /\.css$/,
      use: isProduction ?
        // production
        ExtractTextPlugin.extract({
          fallback: 'style-loader',
          use: [
            {
              loader: 'css-loader',
              options: {
                modules: true,
                importLoaders: 1,
                sourceMap: true,
                localIdentName: cssModulesScopedName,
              }
            },
            'postcss-loader',
          ]
        }) :
        // development
        ['style-loader',
          {
            loader: 'css-loader',
            options: {
              modules: true,
              importLoaders: 1,
              sourceMap: true,
              localIdentName: cssModulesScopedName,
            }
          },
          'postcss-loader',
        ]
    }
  )

  return {
    context,
    entry,
    output: {
      filename: outputFilename,
      path: 'dist'
    },
    resolve: {
      extensions: ['.ts', '.tsx', '.js', '.json']
    },
    module: {
      rules: moduleRules,
    },
    plugins,
    devtool: 'source-map',
    performance: false,
  }
}
```

---

## Webpackとりあえずこれだけ！ "entry"

```js
  entry = isProduction ?
    // production
    ['./config/polyfills.ts', './src/index.ts'] :
    // development
    {
      main: './src/index.ts',
      vendor: './config/vendor.ts',
      polyfills: './config/polyfills.ts',
    }
```

- production
    - JSファイルは1つになれば良いのでエントリーポイントを分けない。ただしpolyfillは読み込む。
- development
    - バンドルの時間を最小限にするため可能な限りエントリーポイントを分ける。mainエントリーポイントは自分が書いたコードのみをバンドルするようにする。


---

## Webpackとりあえずこれだけ！ "output"

```js
  outputFilename = isProduction ?
    // production
    'static/js/bundle.[chunkhash].js' :
    // development
    'static/js/[name].js'

  return {
    output: {
      filename: outputFilename,
      path: 'dist'
    }
  }
```

- production
    - 生成する度にhashを付与する。 `[chunkhash]` の使用が推奨される。
- development
    - エントリーポイント毎にファイルが生成されるよう `[name]` を使う。

---

## Webpackとりあえずこれだけ！ "plugins" (1/2)

```js
  plugins.push(
    new HtmlWebpackPlugin({
      template: 'public/index.html',
      filename: 'index.html'
    })
  )
```

`public/index.html` をテンプレートとして、生成されたCSSとJSのリンクを挿入して `dist/index.html` に書き出す。

---

## Webpackとりあえずこれだけ！ "plugins" (2/2)

```js
  if (isProduction) {
    // production
    plugins.push(
      new CompressionWebpackPlugin({
        test: /\.js$/
      }),
      new ExtractTextPlugin('static/css/bundle.[chunkhash].css')
    )
  } else {
    // development
    plugins.push(
      new webpack.optimize.CommonsChunkPlugin({
        name: ['vendor', 'polyfills'],
      })
    )
  }
```

- production
    - `compression-webpack-plugin` を使ってバンドルされたJSファイルの `*.js.gz` ファイルを生成する。
    - `extract-text-webpack-plugin` を使ってバンドルからCSSを抽出して別ファイルとして書き出す。
- development
    - `CommonsChunkPlugin` を使って各エントリーポイントをHTMLファイルに挿入するときの順番を指定する。

--- 

## Webpackとりあえずこれだけ！ "module.rules" (1/2)

/\.tsx?$/ の場合

```js
    {
      test: /\.tsx?$/,
      exclude: [/node_modules/],
      use: [
        {
          loader: 'babel-loader',
          options: {
            presets: isProduction ?
              // production
              ['latest'] :
              // development
              [],
            plugins: [
              'transform-react-jsx',
              ['react-css-modules', { generateScopedName: cssModulesScopedName }],
            ]
          }
        },
        'awesome-typescript-loader'
      ]
    }
```

```json:tsconfig.json(再掲)
{
  "compilerOptions": {
    "module": "es2015",
    "moduleResolution": "node",
    "jsx": "preserve",
    "sourceMap": true
  }
}
```

- `awesome-typescript-loader` の段階では `es2015` でコンパイルJSXはそのままにする。
- `babel-loader` の段階でJSXをコンパイルしてReact CSS Modulesのデコレーション処理を適用する。

---

## Webpackとりあえずこれだけ！ "module.rules" (2/2)

/\.css$/ の場合

```js
    {
      test: /\.css$/,
      use: isProduction ?
        // production
        ExtractTextPlugin.extract({
          fallback: 'style-loader',
          use: [
            {
              loader: 'css-loader',
              options: {
                modules: true,
                importLoaders: 1,
                sourceMap: true,
                localIdentName: cssModulesScopedName,
              }
            },
            'postcss-loader',
          ]
        }) :
        // development
        ['style-loader',
          {
            loader: 'css-loader',
            options: {
              modules: true,
              importLoaders: 1,
              sourceMap: true,
              localIdentName: cssModulesScopedName,
            }
          },
          'postcss-loader',
        ]
    }
```

```js:postcss.config.js
module.exports = {
  plugins: [
    require('postcss-cssnext')({ /* ...options */ }),
    require('postcss-modules-values'),
  ]
}
```

- `postcss-loader` により未来的CSSが標準CSSにコンパイルされる。
- `css-loader` によりCSSファイルをCSS Modulesで扱えるように読み込む。
- `style-loader` によりCSSをDOMに適用する。

---

あとはこれ見て 
[https://github.com/ovrmrw/react-webpack-css-modules-typescript-example/blob/master/webpack.config.js](https://github.com/ovrmrw/react-webpack-css-modules-typescript-example/blob/master/webpack.config.js)

---

(まとめ)

---

## ReactでもAngularのようにCSSをクローズドにできる:raised_hands:

---

## Angular使おう:raised_hands:

---

### Thanks!