Repository: vuejs/vue-loader
Pull request #331 (2016-08-30 11:32:50)

CSS modules
Problems in scoped CSS:

1. Animation name conflict
2. Only last css selector is scoped:

```html
Child:
<template>
  <section>
    <div class="header">
      <p>I am red.</p>
    </div>
    <p>I am not.</p> <!-- this becomes red -->
  </section>
</template>
<style scoped>
  .header p { 
    color: red; 
  }
</style>

Parent:
<template>
  <section class="header"> <!-- Oops -->
    <child-component></child-component>
  </section>
</template>
```

CSS modules can solve these problems and is compatible with `scoped`.

# Add CSS modules support

## Basic

Simply use [CSS modules](https://github.com/css-modules/css-modules) with `<style module="moduleName">` and `class="moduleName.className"`

```html
<style module="style">
  .red { color: red; }
</style>
<template>
  <h2 class="style.red"></h2>
</template>
```

becomes:

```html
<style module="style">
  ._8x_KsHmyrocTNd7akA_LL { color: red; }
</style>
<template>
  <h2 class="_8x_KsHmyrocTNd7akA_LL"></h2>
</template>
```

Class names are unique. So your style won't affect any other component.

Tips:

1. With `module`, animation names are also converted. Feel free to write short class & animation names!
2. With `module` + `scoped`, you can limit your style to your component more strictly.
3. With `module` only, you can style `<slot>` in your component.

## Binding classes

*Static* class names in binding expression are converted, too.

```html
<template>
  <h3 class="{{ ['style.red'] }}"></h3>
  <h4 v-bind:class="['style.red']"></h4>
  <h5 :class="condition ? 'style.red' : ''"></h5>
  <h6 :class="['style.red', { 'style.red': isRed }, blue]"></h6>
</template>
```

becomes:

```html
<template>
  <h3 class="{{ ['_8x_KsHmyrocTNd7akA_LL'] }}"></h3>
  <h4 v-bind:class="['_8x_KsHmyrocTNd7akA_LL']"></h4>
  <h5 :class="condition ? '_8x_KsHmyrocTNd7akA_LL' : ''"></h5>
  <h6 :class="['_8x_KsHmyrocTNd7akA_LL', { '_8x_KsHmyrocTNd7akA_LL': isRed }, blue]"></h6>
</template>
```

Note: Here, "static class name" means class name in single quotes.

Complex ones (eg. `'a' + 'b'`) are not supported (and not recommend in Vue).

## Getting local class name in script

In some cases, you will like to control your class name in script.
You can do it like this:

```html
<script>
  export default {
    loader: {
      // tell loader to inject `$styles` as a computed property.
      styles: '$styles'
    },
    computed: {
      className() {
        if (condition) {
          return this.$styles.moduleName.className
        }
        return 'something-else'
      }
    }
  }
</script>
<template>
  <h2 :class="className"></h2>
</template>
```

rguanghui: 👍 @yyx990803 
yyx990803: I think searching for static replacement in templates seems inconsistent:

- plain strings in template expressions will be magically converted
- plain strings in JavaScript will not.

I think this can be quite confusing. Instead, I would:

- always auto inject `$styles` if cssModule is used
- just use the `$styles` variable for binding classes in all places.
YiSiWang: * `$styles` is not always needed. In most cases, we are just writing plain class name, or simple js expression in binding classes.
* Always inject `$styles` will enlarge file size with useless codes.
* Class name replacement is done in compile time, which provides compile time error rather than runtime.
yyx990803: - The API needs to be consistent and clear. Currently it feels hacky because it adds a lot of mental complexity to the usage:

  - the user now needs to be aware that the templates will go through a separate compilation process that rewrites static strings in template expressions, which is different from Vue's runtime behavior.

  - needs to be aware of edge cases like concatenated class names not supported in template expressions. This can actually be quite common e.g. `:class="'button-' + type"`

  - different usage when doing it inside JavaScript.

- Not sure what errors can be caught when replacing class names...

To me it's much more important to have a simple mindset when using scoped CSS.
YiSiWang: That's right. I will remove class replacement in js expressions.
YiSiWang: # Add CSS modules support

Simply use CSS Modules with `<style module="moduleName">`.

`moudleName` will be injected as a computed property.

Example:

```html
<style module="style">
  .red { color: red; }
  /*
    becomes
    ._8x_KsHmyrocTNd7akA_LL { color: red; }
  */
</style>

<script>
  export default {
    ready() {
      console.log(this.style.red)
      // => _8x_KsHmyrocTNd7akA_LL
    }
  }
</script>

<template>
  <h2 v-bind:class="style.red"></h2>
</template>
```
YiSiWang: Use `scoped`,`lang` and `module` combined:
```html
<style scoped lang="stylus" module="style">
.red
  color: red
</style>
```
YiSiWang: @yyx990803 
JounQin: Do we need this actually?

If you need `CSS Module` support, just extract style file specifically and use `css-loader` to handler the styles then do this:

``` javascript
import classes from '/path/to/style'

export default {
    data() {
        classes
    }
}
```

Then, we could use `classes.clazzz` in the template!

So, on my opinion, this pr is unnecessary.
YiSiWang: @JounQin 
1. `scoped`
2. Single File Component
JounQin: @YiSiWang I think there is no need to use `scoped` with `CSS Module` nor pursue single file component so mightily which makes the component a bit more confusing. 😄 
speedornothing: 一个项目由大量组件组成，大量组件需要考虑内部样式的命名，这样 @JounQin 的方式会有些不便之处：

* 对每个需要使用 CSS Modules 的组件，都需要编写一些重复代码，比如引入一个外部样式，然后把样式设置到组件的 data 属性中
* 必须是 external css， 这和 .vue 单文件的方式不兼容，使用不方便

更倾向于 尤大 的建议( 使用 $styles )，另外对 @YiSiWang 的方法有个疑问，如果已有的某个 computed 属性与 style#module#name 相同会发生什么？

( 英语不好，打汉字不会被鄙视吧.. )
YiSiWang: @speedornothing 
注入的计算属性的名字是由`<style module="xxx">`决定的，也就是，由组件的维护者决定的，维护者当然也知道自己组件有哪些计算属性，因此命名冲突不是问题。
yyx990803: @YiSiWang why do we need to explicitly declare the injected variable? Using a reserved key like `$styles` seems simpler, and also it becomes more obvious when you are using the module class names.
YiSiWang: @yyx990803 There might be multiple `<style module>` tags when use external css.

Though I don't think multiple moduled `<style>` tags are useful, it's worse to limit only one moduled tag or mix all local classes into `$style`.
yyx990803: @YiSiWang I think we can default to `$styles` when the `module` attribute has no value. You can still use self-defined names **if** you need multiple style tags.
YiSiWang: Great idea! I will work on it.

But, when use multiple no-module-value `<style>`s (let's call it `default module`), should it throw an error or mix their class names? I prefer the former.
yyx990803: @YiSiWang yeah, throwing an error in that case sounds fine.
YiSiWang: @yyx990803 Docs updated.

Patch:
diff --git a/docs/en/SUMMARY.md b/docs/en/SUMMARY.md
index 1c4d10f..c8ed4b7 100644
--- a/docs/en/SUMMARY.md
+++ b/docs/en/SUMMARY.md
@@ -4,6 +4,7 @@
 - Features
   - [ES2015 and Babel](features/es2015.md)
   - [Scoped CSS](features/scoped-css.md)
+  - [CSS Modules](features/css-modules.md)
   - [PostCSS and Autoprefixer](features/postcss.md)
   - [Hot Reload](features/hot-reload.md)
 - Configurations
diff --git a/docs/en/features/css-modules.md b/docs/en/features/css-modules.md
new file mode 100644
index 0000000..b69b742
--- /dev/null
+++ b/docs/en/features/css-modules.md
@@ -0,0 +1,56 @@
+# CSS Modules
+
+[CSS Modules](https://github.com/css-modules/css-modules) aims to solve class & animation name conflicts. It replaces all the local names with unique hashes and provides a name-to-hash map. So you can write short and general names without worrying any conflict!
+
+With vue-loader, you can simply use CSS Modules with `<style module>`.
+
+The name-to-hash map `$style` will be injected as a computed property.
+
+Example:
+
+```html
+<style module>
+  .red { color: red; }
+  /*
+    becomes
+    ._8x_KsHmyrocTNd7akA_LL { color: red; }
+  */
+</style>
+
+<template>
+  <h2 v-bind:class="$style.red"></h2>
+</template>
+
+<script>
+  export default {
+    ready() {
+      console.log(this.$style.red)
+      // => _8x_KsHmyrocTNd7akA_LL
+    }
+  }
+</script>
+```
+
+If you need mutiple `<style>` tags with `module` (or you hate `$style` being injected), you can specify the module name with `<style module="moduleName">`. `moduleName` will get injected instead.
+
+Example:
+
+```html
+<style module="foo" src="..."></style>
+<style module="bar" src="..."></style>
+
+<template>
+  <h2 v-bind:class="foo.red"></h2>
+  <h2 v-bind:class="bar.red"></h2>
+</template>
+```
+
+## Tips
+
+1. Animation names also get transformed. So, it's recommended to use animations with CSS modules.
+
+2. You can use `scoped` and `module` together to avoid problems in descendant selectors.
+
+3. Use `module` only (without `scoped`), you are able to style `<slot>`s and children components. But styling children components breaks the principle of components. You can put `<slot>` in a classed wrapper and style it under that class.
+
+4. You can expose the class name of component's root element for theming.
diff --git a/docs/en/features/scoped-css.md b/docs/en/features/scoped-css.md
index 4a1f197..f2bb9d1 100644
--- a/docs/en/features/scoped-css.md
+++ b/docs/en/features/scoped-css.md
@@ -48,4 +48,4 @@ Into the following:
 
 4. **Scoped styles do not eliminate the need for classes**. Due to the way browsers render various CSS selectors, `p { color: red }` will be many times slower when scoped (i.e. when combined with an attribute selector). If you use classes or ids instead, such as in `.example { color: red }`, then you virtually eliminate that performance hit. [Here's a playground](http://stevesouders.com/efws/css-selectors/csscreate.php) where you can test the differences yourself.
 
-5. **Be careful with descendant selectors in recursive components!** For a CSS rule with the selector `.a .b`, if the element that matches `.a` contains a recursive child component, then all `.b` in that child component will be matched by the rule.
+5. **Be careful with descendant selectors in recursive components!** For a CSS rule with the selector `.a .b`, if the element that matches `.a` contains a recursive child component, then all `.b` in that child component will be matched by the rule. To avoid class name conflicts, you can use [CSS Modules](features/css-modules.md).
diff --git a/lib/loader.js b/lib/loader.js
index f6e479a..b41a09a 100644
--- a/lib/loader.js
+++ b/lib/loader.js
@@ -64,7 +64,7 @@ module.exports = function (content) {
       // disable all configuration loaders
       '!!' +
       // get loader string for pre-processors
-      getLoaderString(type, part, scoped) +
+      getLoaderString(type, part, index, scoped) +
       // select the corresponding part from the vue file
       getSelectorString(type, index || 0) +
       // the url to the actual vuefile
@@ -81,17 +81,40 @@ module.exports = function (content) {
   function getRequireForImportString (type, impt, scoped) {
     return loaderUtils.stringifyRequest(loaderContext,
       '!!' +
-      getLoaderString(type, impt, scoped) +
+      getLoaderString(type, impt, -1, scoped) +
       impt.src
     )
   }
 
-  function getLoaderString (type, part, scoped) {
+  function addCssModulesToLoader (loader, part, index) {
+    if (!part.module) return loader
+    return loader.replace(/((?:^|!)css(?:-loader)?)(\?[^!]*)?/, function (m, $1, $2) {
+      // $1: !css-loader
+      // $2: ?a=b
+      var option = loaderUtils.parseQuery($2)
+      option.modules = true
+      option.importLoaders = true
+      option.localIdentName = '[hash:base64]'
+      if (index !== -1) {
+        // Note:
+        //   Class name is generated according to its filename.
+        //   Different <style> tags in the same .vue file may generate same names.
+        //   Append `_[index]` to class name to avoid this.
+        option.localIdentName += '_' + index
+      }
+      return $1 + '?' + JSON.stringify(option)
+    })
+  }
+
+  function getLoaderString (type, part, index, scoped) {
     var lang = part.lang || defaultLang[type]
     var loader = loaders[lang]
     var rewriter = getRewriter(type, scoped)
     var injectString = (type === 'script' && query.inject) ? 'inject!' : ''
     if (loader !== undefined) {
+      if (type === 'style') {
+        loader = addCssModulesToLoader(loader, part, index)
+      }
       // inject rewriter before css/html loader for
       // extractTextPlugin use cases
       if (rewriterInjectRE.test(loader)) {
@@ -108,7 +131,8 @@ module.exports = function (content) {
         case 'template':
           return defaultLoaders.html + '!' + rewriter + templateLoader + '?raw&engine=' + lang + '!'
         case 'style':
-          return defaultLoaders.css + '!' + rewriter + lang + '!'
+          loader = addCssModulesToLoader(defaultLoaders.css, part, index)
+          return loader + '!' + rewriter + lang + '!'
         case 'script':
           return injectString + lang + '!'
       }
@@ -143,7 +167,8 @@ module.exports = function (content) {
 
   var parts = parse(content, fileName, this.sourceMap)
   var hasLocalStyles = false
-  var output = 'var __vue_script__, __vue_template__\n'
+  var output = 'var __vue_script__, __vue_template__\n' +
+               'var __vue_styles__ = {}\n'
 
   // check if there are any template syntax errors
   var templateWarnings = parts.template.length && parts.template[0].warnings
@@ -151,16 +176,31 @@ module.exports = function (content) {
     templateWarnings.forEach(this.emitError)
   }
 
+  var cssModules = {}
+  function setCssModule (style, require) {
+    if (!style.module) return require
+    if (style.module in cssModules) {
+      loaderContext.emitError('CSS module name "' + style.module + '" is not unique!')
+      return require
+    }
+    cssModules[style.module] = true
+    return '__vue_styles__["' + style.module + '"] = ' + require + '\n'
+  }
+
   // add requires for src imports
   parts.styleImports.forEach(function (impt) {
     if (impt.scoped) hasLocalStyles = true
-    output += getRequireForImport('style', impt, impt.scoped)
+    if (impt.module === '') impt.module = '$style'
+    var requireString = getRequireForImport('style', impt, impt.scoped, impt.module)
+    output += setCssModule(impt, requireString)
   })
 
   // add requires for styles
   parts.style.forEach(function (style, i) {
     if (style.scoped) hasLocalStyles = true
-    output += getRequire('style', style, i, style.scoped)
+    if (style.module === '') style.module = '$style'
+    var requireString = getRequire('style', style, i, style.scoped, style.module)
+    output += setCssModule(style, requireString)
   })
 
   // add require for script
@@ -203,11 +243,18 @@ module.exports = function (content) {
     output +=
       'module.exports = __vue_script__ || {}\n' +
       'if (module.exports.__esModule) module.exports = module.exports.default\n' +
+      'var __vue_options__ = typeof module.exports === "function" ' +
+        '? (module.exports.options || (module.exports.options = {})) ' +
+        ': module.exports\n' +
       'if (__vue_template__) {\n' +
-        '(typeof module.exports === "function" ' +
-          '? (module.exports.options || (module.exports.options = {})) ' +
-          ': module.exports).template = __vue_template__\n' +
-      '}\n'
+        '__vue_options__.template = __vue_template__\n' +
+      '}\n' +
+      // inject style modules as computed properties
+      'if (!__vue_options__.computed) __vue_options__.computed = {}\n' +
+      'Object.keys(__vue_styles__).forEach(function (key) {\n' +
+        'var module = __vue_styles__[key]\n' +
+        '__vue_options__.computed[key] = function () { return module }\n' +
+      '})\n'
     // hot reload
     if (
       !this.minimize &&
diff --git a/lib/parser.js b/lib/parser.js
index a353635..03d4514 100644
--- a/lib/parser.js
+++ b/lib/parser.js
@@ -40,6 +40,7 @@ module.exports = function (content, filename, needMap) {
     var lang = getAttribute(node, 'lang')
     var src = getAttribute(node, 'src')
     var scoped = getAttribute(node, 'scoped') != null
+    var module = getAttribute(node, 'module')
     var warnings = null
     var map = null
 
@@ -64,7 +65,8 @@ module.exports = function (content, filename, needMap) {
         output.styleImports.push({
           src: src,
           lang: lang,
-          scoped: scoped
+          scoped: scoped,
+          module: module
         })
       } else if (type === 'template') {
         output.template.push({
@@ -151,6 +153,7 @@ module.exports = function (content, filename, needMap) {
     output[type].push({
       lang: lang,
       scoped: scoped,
+      module: module,
       content: result,
       map: map && map.toJSON(),
       warnings: warnings


Test patch:
diff --git a/test/fixtures/css-modules.vue b/test/fixtures/css-modules.vue
new file mode 100644
index 0000000..8646cb8
--- /dev/null
+++ b/test/fixtures/css-modules.vue
@@ -0,0 +1,20 @@
+<style module="style">
+.red {
+  color: red;
+}
+@keyframes fade {
+  from { opacity: 1; } to { opacity: 0; }
+}
+.animate {
+  animation: fade 1s;
+}
+</style>
+
+<style scoped lang="stylus" module>
+.red
+  color: red
+</style>
+
+<script>
+module.exports = {}
+</script>
diff --git a/test/test.js b/test/test.js
index 8644b3e..2abbc05 100644
--- a/test/test.js
+++ b/test/test.js
@@ -34,7 +34,7 @@ describe('vue-loader', function () {
 
   function getFile (file, cb) {
     fs.readFile(path.resolve(outputDir, file), 'utf-8', function (err, data) {
-      expect(err).to.be.not.exist
+      expect(err).to.not.exist
       cb(data)
     })
   }
@@ -276,4 +276,37 @@ describe('vue-loader', function () {
       done()
     })
   })
+
+  it('css-modules', function (done) {
+    test({
+      entry: './test/fixtures/css-modules.vue'
+    }, function (window) {
+      var module = window.vueModule
+
+      // get local class name
+      var className = module.computed.style().red
+      expect(className).to.match(/^_/)
+
+      // class name in style
+      var style = [].slice.call(window.document.querySelectorAll('style')).map(function (style) {
+        return style.textContent
+      }).join('\n')
+      expect(style).to.contain('.' + className + ' {\n  color: red;\n}')
+
+      // animation name
+      var match = style.match(/@keyframes\s+(\S+)\s+{/)
+      expect(match).to.have.length(2)
+      var animationName = match[1]
+      expect(animationName).to.not.equal('fade')
+      expect(style).to.contain('animation: ' + animationName + ' 1s;')
+
+      // default module + pre-processor + scoped
+      var anotherClassName = module.computed.$style().red
+      expect(anotherClassName).to.match(/^_/).and.not.equal(className)
+      var id = '_v-' + hash(require.resolve('./fixtures/css-modules.vue'))
+      expect(style).to.contain('.' + anotherClassName + '[' + id + ']')
+
+      done()
+    })
+  })
 })