26
24

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

React CSS Modules, TypeScript, Webpack, with Babel Plugin

Last updated at Posted at 2017-02-17
1 / 36

When

2017/02/17

At

webpack-sake


自己紹介

ちきさん
(Tomohiro Noguchi)

Twitter/GitHub/Qiita: @ovrmrw

ただのSIer。

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

3a2512bb-aa72-4515-af42-1f1721252f39.jpg


(ここから CSS ModulesReact CSS Modules の話)

===

GitHubリポジトリ 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の原則に反してキャメルケースで書かなければならない。

.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


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


VERY COOL :sunglasses:


でもBabel Pluginなのか...


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


できるんです!!

pr_source.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)
app.css
:root {
  --custom-bg-color: yellow;
}

.my-style {
  background-color: var(--custom-bg-color); /* using Custom Property */
  color: blue;
  font-weight: bold;
}
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のコツ

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 の全コード。
これぐらいは諳(そら)で書けるようになりたい。

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"

  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"

  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)

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

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


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

  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?$/ の場合

    {
      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'
      ]
    }
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$/ の場合

    {
      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',
        ]
    }
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


(まとめ)


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


Angular使おう:raised_hands:


Thanks!

26
24
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
26
24

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?