Vue中的作用域CSS和CSS模块的差异

发布于 大漠

现代Web开发中的CSS离完美还差得远,这并不奇怪。现在,项目通常是相当的复杂的,而CSS样式又是全局性的,所以到最后总是极容易地发生样式冲突:样式相互覆盖隐式地级联到我们未考虑到的元素

为了减轻CSS存在的主要痛点,我们在项目中普遍采用BEM的方法来。不过这只能解决CSS问题中的一小部分。

对我们来说是幸运的,社区已经开发出了可以帮助我们更彻底地解决问题的解决方案。你可能已经听说过CSS ModulesStyled ComponetnsGlamorousJSS。这些只是我们今天可以添加到项目中的一些最流行的工具。如果你对这个话题感兴趣,你可以查看这篇文章:@Indrek Lasn详细介绍了CSS in JS的全部思想

使用Vue-cli构建的Vue应用程序提供了两个很棒的内置解决方案:作用域CSSCSS Modules。它们都有一些优点和缺点,所以让我们仔细看看哪种解决方案更适合你。

作用域CSS

在Vue中引入了CSS作用域scoped这个概念,scoped的设计思想就是让当前组件的样式不会影响到其他地方的样式,编译出来的选择器将会带上data-v-hash的方式来应用到对应的组件中,这样一来,CSS也不需要添加额外的选择器。也将解决CSS中选择器作用域和选择器权重的问题。

在Vue中,为了让作用域样式工作,只需要在<style>标签添加scoped属性:

<!-- Button.vue -->
<template>
    <button class="btn">
        <slot></slot>
    </button>
</template>

<style scoped>
    .btn {
        color: red;
    }
</style>

通过使用PostCSS并将上面的示例转换为以下内容,它仅将我们的样式应用于相同的组件中的元素:

就像你看到的一样,整个过程不需要做什么就可以达到很好的效果:作用域样式 (CSS中一直以来令人头痛的问题之一)。

现在假设你需要调整Button组件的宽度,你可以像平常使用一样,在调用这个组件的地方添加一个额外的class来设置其样式:

<!-- App.vue -->
<template>
    <div id="app">
        <Button class="btn-lg">click</Button>
    </div>
</template>

<script>
    import Button from "./components/Button";

    export default {
        name: "App",
        components: {
            Button
        }
    };
</script>

<style scoped>
    .btn-lg {
        padding: 10px 30px;
    }
</style>

转换后就像下面这样:

这次还是一样,不需要做什么就可以很好的控制样式。

不过请注意:这个特性存在一个缺陷,即如果你子组件的元素上有一个类已经在这个父组件中定义过了,那么这个父组件的样式就也会应用到子组件上。只不过其权重没有子组件同类名的重。比如下面这个示例:

<!-- Button.vue -->
<template>
    <button class="btn btn-lg">
        <slot></slot>
    </button>
</template>

<style scoped>
.btn {
    color: red;
}
.btn-lg {
    padding: 10px 20px;
    border: 2px solid red;
}
</style>

<!-- App.vue -->
<template>
    <div id="app">
        <Button class="btn-lg">click</Button>
    </div>
</template>

<script>
    import Button from "./components/Button";

    export default {
        name: "App",
        components: {
            Button
        }
    };
</script>

<style scoped>
.btn-lg {
    padding: 30px;
    border: 5px solid green;
}
</style>

编译出来的效果如下:

还有一些情况是我们需要对子组件的深层次结构设置样式。虽然这种做法并不受推荐,而且应该尽量去避免。比如下面这个示例,Button组件下有一个<span>标签,而在调用Button组件的父组件App中设置span样式:

<!-- Button.vue -->
<template>
    <button class="btn">
        <span>
            <slot></slot>
        </span>
    </button>
</template>

<style scoped>
.btn {
    color: red;
}
</style>

<!-- App.vue -->
<template>
    <div id="app">
        <Button class="btn-lg">click</Button>
    </div>
</template>

<script>
    import Button from "./components/Button";

    export default {
        name: "App",
        components: {
            Button
        }
    };
</script>

<style scoped>
.btn span {
    color: green;
    font-weight: bold;
    border: 1px solid green;
    padding: 10px;
}
</style>

编译出来的结果如下:

从上面的结果可以看出来,在父组件App.vue中的样式:

.btn span {
    color: green;
    font-weight: bold;
    border: 1px solid green;
    padding: 10px;
}

上面这段样式并没有编译出来,运用到子组件Button.vue中的span中。

scoped样式中,这种情况可以使用>>>连接符或者/deep/来解决:

<!-- App.vue -->
<style scoped>
    .btn >>> span {
        color: green;
        font-weight: bold;
        border: 1px solid green;
        padding: 10px;
    }
</style>

此时虽然依旧是在App.vuescoped控制Button.vue组件中span,但上面不同的是,这次样式生效。编译出来的结果如下:

另外使用作用域样式还存在一个问题。那就是对v-html中内在的标签样式不生效。比如下面这个示例:

<!-- Button.vue -->
<template>
    <button class="btn">
        <slot></slot>
    </button>
</template>

<style scoped>
.btn {
    color: red;
}
</style>

<!-- App.vue -->
<template>
    <div id="app">
        <Button class="btn-lg" v-html="vhtml"></Button>
    </div>
</template>

<script>
    import Button from "./components/Button";

    export default {
        name: "App",
        data () {
            return {
                vhtml: 'Click <strong>7</strong>'
            }
        },
        components: {
            Button
        }
    };
</script>

<style scoped>
strong {
    color: green;
    border: 1px solid green;
    padding: 10px;
}
</style>

编译出来的结果如下:

从上图可以看出来,v-html中的strong标签样式并未生效。和前面在父组件的scoped中设置子组件内部标签未生效一样。当然,其解决方案也是同样的,**使用>>>连接符或/deep/**可以让v-html中的标签样式生效。比如上面的示例,可以将代码修改为:

<!-- App.vue -->
<style scoped>
    .btn /deep/ strong {
        color: green;
        border: 1px solid green;
        padding: 10px;
    }
</style>

这个时候v-html中的strong样式生效了,如下图所示:

话又说回来,虽然>>>/deep/可以帮助我们穿透已封装好的组件中的样式,但这也失去了组件封装的效果。再次回到以前CSS中令人头痛的问题:CSS作用域

简单的小结一下,在Vue中scoped属性的渲染规则:

  • 给DOM节点添加一个不重复的data属性(比如data-v-7ba5bd90)来表示他的唯一性
  • 在每个CSS选择器末尾(编译后生成的CSS)加一个当前组件的data属性选择器(如[data-v-7ba5bd90])来私有化样式。选择器末尾的data属性和其对应的DOM中的data属性相匹配
  • 如果组件内部包含有其他组件,只会给其他组件的最外层标签加上当前组件的data属性

上面我们看到的是Vue机制内作用域CSS的使用。在Vue中,除了作用域CSS之外,还有另外一种机制,那就是CSS Modules,即模块化CSS

CSS Modules

CSS Modules的流行起源于React社区,它获得了社区的迅速的采用。Vue更甚之,其强大,简便的特性在加上Vue-cli对其开箱即用的支持,将其发展到另一个高度。

在Vue中使用CSS Modules和作用域CSS同样的简单。和作用域CSS类似,在<style>标签中添加module属性。比如像下面这样:

<style module>
    .btn {
        color: red;
    }
</style>

然后在<template>里这样写:

<template>
    <button :class="$style.btn">{{msg}}</button>
</template>

这个时候编译出来的效果如下:

正如上图所示,:class="$style.btn"会被vue-template-compiler编译成为.Button_btn_3ykLd这个类名,并且样式的选择器也自动发生了相应的变化。

但在这里有一点需要注意,我们平时有可能在类名中会使用分隔线,比如:

<style module>
    .btn-lg {
        border: 1px solid red;
        padding: 10px 30px;
    }
</style>

如果通过$style调用该类名时要是写成$style.btn-lg,这样写是一个不合法的JavaScript变量名。此时在编译的时候,会报一个错话信息:

按钮的样式也不会生效。如果要生效,我们需要通过下面这样的方式来写:

<template>
    <button :class="$style['btn-lg']">{{msg}}</button>
</template>

编译出来的结果如下:

除了$style.btn-lg这种方式会报错之外,写在驼峰($style.btnLg)的也会报错

上面说的module属性会经由Vue-loader编译后,在我们的component产生一个叫$style的隐藏的computed属性。也就是说,我们甚至可以在Vue生命周期的created钩子中取得由CSS Modules生成的class类名:

<script>
export default {
    
    created () {
        console.log(this.$style['btn-lg'])
    }

}
</script>

在浏览器的console中可以看到modules编译出来对应的类名:

利用这样的特性,在<template>也可以这样写:

<!-- App.vue -->
<template>
    <div id="app">
        <Button msg="Default Button" />
        <Button :class="{[$style['btn-lg']]: isLg}" msg="Larger Button" />
        <Button :class="{[$style['btn-sm']]: isSm}" msg="Smaller Button" />
    </div>
</template>

<script>
    import Button from './components/Button'

    export default {
        name: 'app',
        components: {
            Button
        },
        data () {
            return {
                isLg: true,
                isSm: false
            }
        }
    }
</script>

<style module>
.btn-lg {
    padding: 15px 30px;
}

.btn-sm {
    padding: 5px;
}
</style>

这个时候编译出来的结果如下:

如上图所示,当data中的isLg属性值为true时,Larger Button按钮的padding变了,按钮也同时变大了。除此之外,我们还可以通过propsclass传到子组件中。比如像下面这样使用:

<!-- Button.vue -->
<template>
    <button :class="[$style.btn, primaryClass]">{{msg}}</button>
</template>

<script>
    export default {
        name: 'Button',
        props: {
            msg: String,
            primaryClass: ''
        }
    }
</script>

<style module>
    .btn {
        border: 1px solid #ccc;
        border-radius: 3px;
        padding: 5px 15px;
        background: #fefefe;
        margin: 5px;
    }
</style>

<!-- App.vue -->
<template>
    <div id="app">
        <Button msg="Default Button" />
        <Button :class="{[$style['btn-lg']]: isLg}" msg="Larger Button" />
        <Button :class="{[$style['btn-sm']]: isSm}" msg="Smaller Button" />
        <Button msg="Primary Button" :primaryClass="$style['btn-primary']" />
    </div>
</template>

<script>
    import Button from './components/Button'

    export default {
        name: 'app',
        components: {
            Button
        },
        data () {
            return {
                isLg: true,
                isSm: false
            }
        }
    }
</script>

<style module>
    .btn-lg {
        padding: 15px 30px;
    }

    .btn-sm {
        padding: 5px;
    }

    .btn-primary {
        background: rgb(54, 152, 244);
        border-color: rgb(32, 108, 221);
        color: #fff;
    }
</style>

编译出来的效果如下图所示:

如果我们想要在JavaScript里面将独立的CSS文件作为CSS模块来加载的话,需要在.css文件名前添加.module前缀,比如:

<script>
    import barStyle from './src/style/bar.module.css'
</script>

如果你是在项目中引入的是处理器文件也是如此,比如.scss文件:

<script>
    import fooSassStyle from './src/scss/foo.module.scss'
</script>

如果你觉得这样比较麻烦,可以在vue.config.js文件中css.modules设为true

// vue.config.js
module.exports = {
    css: {
        modules: true
    }
}

注意,上面的示例创建的项目是使用Vue-cli 3创建的。如果是使用Webpack的话,需要根据Webpack的相关机制进行配制。

从上面的示例中我们可以看出。使用modulescoped不一样的地方就是在于所有创建的类可以通过$style对象获取。因此类要应用到元素上,就需要通过:class来绑定$style这个对象。它的好处是,当我们在HTML中查看这个元素时,我们可以立刻知道它所属的是哪个组件。如果你够细心的话,可以看到编译出来的类名,都会以组件名为前缀,比如:

除了这个好处之外,还有另一个好处,即: 一切都变成显式的了,我们拥有了彻底的控制权

总结

不管是CSS Modules还是作用域CSS,这两种方案都非常简单,易用。在某种程度上解决的是同样的痛点(CSS的痛)。那么你应该选择哪种呢?

scoped样式的使用不需要额外的知识,给人舒适的感觉。它所存在的局限,也正它的使用简单的原因。它可以用于支持小型到中型的Web应用程序。在更大的Web应用程序或更复杂的场景中,对于CSS的运用,我们更希望它是显式的,更具有控制权。比如说,你的样式可以在多组件中重用时,那么scoped的局限性就更为明显了。反之,CSS Modules的出现,正好解决了这些问题,不过也要付出一定的代价,那就是需要通过$style来引用。虽然在<template>中大量使用$style,让人看起来很蛋疼,但它会让你的样式更加安全和灵活,更易于控制。CSS Modules还有一个好处就是可以使用JavaScript获取到我们定义的一些变量,这样我们就不需要手动保持其在多个文件中同步。

最后还是那句话,任何解决CSS的方案,没有最好的,只有最合适的! 我们应该根据自己的项目、场景和团队进行选择。当然,不管选择哪种方案,都是为了帮助我们更好的控制样式,解决原生CSS中存在的痛点。最后希望这篇文章对大家有所帮助。Air Zoom Pegasus 35