CSS in JS
什么是 CSS in JS
对于以往的开发,通常是采用”关注点分离”的模式,即:
- HTML - 负责网页的结构,又称语义层
- CSS - 负责网页的样式,又称视觉层
- JavaScript - 负责网页的逻辑和交互,又称逻辑层或交互层
其中 CSS 的发展相较下来是十分缓慢的,到现在仍然无法解决以下几个问题:
CSS 模块化的解决方案有很多,但主要有两类:
- CSS Modules - 采用 CSS,搭配 webpack 使用
- CSS in JS - 抛弃 CSS,通过 JS 来写样式
- 缺点是不能利用成熟的 CSS 预处理器,复用性差,需要编辑器支持代码补全和高亮
CSS Modules
localIdentName
CSS Modules 中可以定义局部和全局变量:
- :local - 通过 localIdentName 规则处理
- :global - 样式编译后不变
/* 下面两个等价,默认给每个 class 名外加加了一个 `:local` */
.normal {
color: green;
}
:local(.normal) {
color: green;
}
/* 全局样式 */
:global {
.link {
color: green;
}
.box {
color: yellow;
}
}
CSS Modules 与 webpack 的 css-loader
可以很好地搭配使用:
// modules 设置为 true,则启用 CSS Modules
module.exports = {
module: {
rules: [
{
test: /\.css$/,
loader: 'css-loader',
options: {
modules: true,
},
},
],
},
};
这样的话,样式会被转换成一串哈希值,不便于我们去定位,我们可以自定义生成样式的命名规则:
// modules 即为启用,localIdentName 是设置生成样式的命名规则
// [name]表示文件名,[local]表示类名,[hash:base64:5] 是按照给定算法生成的序列码
options: {
modules: true,
localIdentName: '[path][name]__[local]-[hash:base64:5]',
},
然后我们看以下这个栗子:
/* test.css */
.active {...}
.disabled {...}
import styles from './test.css';
console.log(styles);
/*
* Object {
* active: 'h1__active-abc53',
* disabled: 'h1__disabled-def84',
* }
*/
elem.outerHTML = `<h1 class=${styles.active}>CSS Modules</h1>`
可以最终看到生成的 HTML 是:
<h1 class="h1__active-abc53"> Processing... </h1>
组合 Composes
对于样式复用,CSS Modules 只提供了唯一的方式来处理,即 composes 组合:
.className {
color: green;
background: red;
}
/* 编译时会编译成两个 class */
.otherClassName {
composes: className;
color: yellow;
}
当然 composes 还可以组合外部样式文件:
/* settings.css */
.primary-color {
color: #f40;
}
/* components/Button.css */
.base { /* 所有通用的样式 */ }
.primary {
composes: base;
composes: primary-color from './settings.css';
/* primary 其它样式 */
}
:export
:export 关键字可以把 CSS 中的变量输出到 JS 中:
$primary-color: #f40;
:export {
primaryColor: $primary-color;
}
import style from 'index.scss';
console.log(style.primaryColor); // #f40
与 React 搭配使用的话推荐 react-css-modules 👈
Web Components 规范
谈到组件化的话必须说到 Web Components,它通过一种标准化的非侵入的方式封装一个组件,每个组件能组织好它自身的 HTML 结构、CSS 样式、JavaScript 代码,并且不会干扰页面上的其他元素。这意味着你无需 React 或 Angular 等框架也能创建组件。不仅如此,这些组件还都可以无缝集成到这些框架中,并支持跨平台。Web Components 主要由以下四种技术组成:
- Custom elements(自定义元素) - 一组 JavaScript API,允许您定义 custom elements 及其行为,然后可以在您的用户界面中按照需要使用它们
- Custom Elements 可以继承原生 HTMLElement 类和其子类。
- 通过
customElements.define()
维护自定义标签注册表。 - 特定生命周期函数在标签创建、添加到 DOM、属性被修改等时刻调用
- Shadow DOM(影子 DOM) - 一组 JavaScript API,用于将封装的“影子” DOM 树附加到元素(与主文档 DOM 分开呈现)并控制其关联的功能。通过这种方式,您可以保持元素的功能私有,这样它们就可以被脚本化和样式化,而不用担心与文档的其他部分发生冲突
- HTML templates(HTML 模板) -
<template>
和<slot>
元素使您可以编写不在呈现页面中显示的标记模板。然后它们可以作为自定义元素结构的基础被多次重用 - HTML Imports(HTML 导入) - 一旦定义了自定义组件,最简单的重用它的方法就是使其定义细节保存在一个单独的文件中,然后使用导入机制将其导入到想要实际使用它的页面中
Custom elements
要注册一个新元素时,需要通过 window.customElements
获取注册表实例并调用其 define
方法:
// <my-element></my-element>
window.customElements.define('my-element', MyElement);
为了避免未来的冲突,所有自定义标签必须加连词符 -,同时原生 HTML 标签保证绝不包含此类连词符
现在页面上的每个 <my-element>
标签都与一个 MyElement 元素对应。 页面每解析一个该标签就调用一次 MyElement 的构造函数。除了标签创建时会调用构造函数,还有一系列生命周期函数会在特定时刻被调用:
- connectedCallback - 当元素被添加到文档中时调用。这个函数可能多次调用,比如标签移动、移除或重新添加时
- disconnectedCallback - 与 connectedCallback 相对应
- attributeChangeCallback - 元素属性更改时调用
class GreetingElement extends HTMLElement {
constructor() {
super();
this._name = 'Stranger';
}
connectedCallback() {
this.addEventListener('click', e => alert(`Hello, ${this._name}!`));
}
attributeChangedCallback(attrName, oldValue, newValue) {
if (attrName === 'name') {
if (newValue) {
this._name = newValue;
} else {
this._name = 'Stranger';
}
}
}
doSomething() { // 创建公共 API
// do something in this method
}
}
// 一旦属性的值发生变动,就将使用属性的名称、其当前值及其新值调用 attributeChangedCallback
GreetingElement.observedAttributes = ['name'];
customElements.define('hey-there', GreetingElement);
// 从外部组件调用定义的 API
const element = document.querySelector('my-element');
element.doSomething();
如果要继承一个 HTML 原生标签,你可能会想定义一个看起来完全不同新标签。比如让 <hey-there>
去继承 <button>
:
class GreetingElement extends HTMLButtonElement {...}
// 同时要在自定义标签注册表中体现出继承一个已有标签
customElements.define('hey-there', GreetingElement, { extends: 'button' });
我们应该用被继承的标签加 is 属性来表示这种继承关系,而不是直接用自定义标签:
<button is="hey-there" name="World">Howdy</button>
Shadow DOM
我们写出了友好的 custom element,也为其添加了漂亮的样式。现在我们想把它用在我们的站点上,也想把代码分享出去,让更多的人用在他们的网站上。但是我们怎么避免自定义 <button>
标签和其他网站的 css 冲突?答案是使用Shadow DOM。
Shadow DOM 标准提出了 shadow root(影子根) 的概念。使用 Shadow DOM 时,自定义元素的 HTML 和 CSS 会完全封装在组件内部。这意味着该元素将在文档的 DOM 树中显示为单个 HTML 标签,其内部 HTML 结构则放在一个 #shadow-root 中:
<div class="shadow-host">Hello, world!</div>
<script>
// 影子宿主(shadow host)
var shadowHost = document.querySelector('.shadow-host');
// 创建影子根(shadow root)
// createShadowRoot 方法已弃用
// var shadowRoot = shadowHost.createShadowRoot();
var shadowRoot = shadowHost.attachShadow({ mode: 'open' });
// shadowHost.shadowRoot // the shadow root. 若选择 closed,则为 null
// shadowHost.shadowRoot.host // the el itself.
// 影子根作为影子树的第一个节点,其他的节点比如p节点都是它的子节点。
shadowRoot.innerHTML = '<p class="shadow-root">Shadow DOM</p>';
</script>
当我们通过常规选择器选择 shadow DOM 里的元素时,会发现无法获取:
document.querySelector('.shadow-root') // null
// 若要获取的话,需要通过 shadowRoot
var container = document.querySelector('.shadow-host')
container.shadowRoot.querySelector('shadow-root')
mode: open
可以在开发工具中检查,并通过查询、配置任何公开的 CSS 属性或监听它抛出的事件来交互,反之则是 ‘closed’
我们可以随便找一个视频,找到 video
标签,然后在浏览器设置里开启 Show user agent shadow DOM
,我们会发现视频标签下也藏了嵌套的 shadow DOM:
组合(Composition) 是将 Shadow DOM 树与用户提供的标记组合在一起的过程。这是通过插槽 <slot>
元素完成的,该元素本质上是 Shadow DOM 中的占位符,其中呈现用户提供的标记:
<!-- 带有名字的 slot -->
<image-gallery>
<img src="foo.jpg" slot="image">
<img src="b.arjpg" slot="image">
</image-gallery>
带有名称的 slot 告诉组件应该在其 Shadow DOM 中的什么位置呈现它们:
<div id="container">
<div class="images">
<slot name="image"></slot>
</div>
</div>
<!-- 生成 -->
<div id="container">
<div class="images">
<slot name="image">
<img src="foo.jpg" slot="image">
<img src="bar.jpg" slot="image">
</slot>
</div>
</div>
Vue scoped & module
Vue 主张为组件样式设置作用域,对于应用来说,顶级 App 组件和布局组件中的样式可以是全局的,但是其它所有组件都应该是有作用域的。我们可以通过 scoped 特性来实现隔离作用域:
<!-- 使用 `scoped` 特性,这类似于 Shadow DOM 中的样式封装 -->
<template>
<div class="example">hi</div>
</template>
<style scoped>
.example {
color: red;
}
</style>
由于 vue-loader 内部通过 PostCSS 处理其样式,通过它来实现以下转换:
<template>
<div class="example" data-v-f3f3eg9>hi</div>
</template>
<style>
.example[data-v-f3f3eg9] {
color: red;
}
</style>
如果你希望 scoped 样式中的一个选择器能够作用得“更深”,例如影响子组件,你可以使用 >>>
或 /deep/
操作符来实现深度作用选择器:
<style scoped>
.a >>> .b { /* ... */ }
</style>
当然官方还是更推荐一些基于 class 的策略,比如上面提到的 CSS Modules,它是一个用于模块化和组合 CSS 的流行系统。vue-loader 提供了与 CSS 模块的一流集成,可以作为模拟 CSS 作用域的替代方案:
<template>
<button :class="[$style.button, $style.buttonClose]">X</button>
</template>
<!-- 使用 CSS Modules -->
<!-- 将为 css-loader 打开 CSS Modules 模式,生成的 CSS 对象将为组件注入一个名叫 $style 的计算属性,你可以在你的模块中使用动态 class 绑定 -->
<style module>
.button {
border: none;
border-radius: 2px;
}
.buttonClose {
background-color: red;
}
</style>
<!-- 也可以在 JS 中访问 $style 属性 -->
<script>
export default {
created () {
console.log(this.$style.button)
// -> "HelloWorld_color_1DT2e"
// an identifier generated based on filename and className.
}
}
</script>
当然你可以定义不止一个 <style>,为了避免被覆盖,你可以通过设置 module 属性来为它们定义注入后计算属性的名称:
<template>
<button :class="a.button">X</button>
<button :class="b.button">X</button>
</template>
<style module="a">
/* identifiers injected as a */
</style>
<style module="b">
/* identifiers injected as b */
</style>
Angular ViewEncapsulation
Angular 采用了视图封装的枚举类型 ViewEncapsulation,可以有以下三种值:
- ViewEncapsulation.Emulated - 默认项,无 Shadow DOM,但是通过 Angular 提供的样式包装机制来封装组件,使得组件的样式不受外部影响
- ViewEncapsulation.Native - 使用原生的 Shadow DOM 特性。已弃用,改为 ShadowDom
- ViewEncapsulation.None - 无 Shadow DOM,并且也无样式包装
import { Component, ViewEncapsulation } from '@angular/core';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.less'],
encapsulation: ViewEncapsulation.None,
})
export class AppComponent {
title = 'angular-demo';
}
接下来我们对比下三种设置下的 HTML,首先是设置为 None:
设置为 Emulated,注意看样式变成了:
/* 对应 html 中的 _ngcontent-kty-c0 属性 */
.test[_ngcontent-kty-c0] {...}
设置为 Native:
React CSS-in-JS
React 一直推崇组件化思想,将三种语言融合到一起,从而创建出没有外部依赖的独立可复用的组件。React 不仅仅封装了 HTML,即 JSX
(具体可以查看这一节),也封装了 CSS,其实就是简单定义一个样式对象:
const style = {
color: 'orange',
fontSize: '12px',
}
function App() {
return (
<div style={style}>hello</div>
)
}
样式对象里的写法,比如 fontSize,其实就是通过 style 可以访问的样式属性列表 👈
styled-components
这种封装的 CSS 写法似乎又回到了解放前,为了加强 React 的 CSS 操作,一系列第三方库应运而生。github 有个项目也对这些库做了个整理,可以点击这里查看,其中比较火的有 styled-components:
import React from 'react';
import styled from 'styled-components';
// Create a <Title> react component that renders an <h1> which is centered, palevioletred and sized at 1.5em
// Returns a function that accepts a tagged template literal and turns it into a StyledComponent
const Title = styled.h1`
font-size: 1.5em;
text-align: center;
color: palevioletred;
`;
// Create a <Wrapper> react component that renders a <section> with some padding and a papayawhip background
const Wrapper = styled.section`
padding: 4em;
background: papayawhip;
`;
const OrangeTitle = styled(Title)`
color: orange;
`
// Use them like any other React component – except they're styled!
<Wrapper>
<Title>Hello World, this is my first styled component!</Title>
</Wrapper>
当然,它还支持一些更复杂的写法,比如传参:
const padding = '3em'
const Section = styled.section`
color: white;
/* Pass variables as inputs */
padding: ${padding};
/* Adjust the background from the properties */
background: ${props => props.background};
`
render(
<Section background="cornflowerblue">
✨ Magic
</Section>
)
让我们再看看它是怎么做到隔离作用域的:
styled-components 这种样式写法其实就是 ES6 里的标签模板(tagged template),更多的 API 也可以参考这里 👈
JSS
material-ui UI 框架通过 JSS 也实现了 CSS in JS 的写法,具体可以查看这一篇 👈
但是 Material-UI 的样式解决方案受到许多其他 CSS-in-JS 库的启发,例如上面提到的 styled-components,这些仍在实验版本中 👈
同样,我们也看看它怎么做到隔离作用域的,比如 CouponComponent
组件,添加一个名为 title
的样式:
const styles = {
title: {
height: '55px',
lineHeight: '60px',
fontSize: '18px',
textIndent: '1em',
},
}
...
return {
...
<div className={classes.title} style=>{titleName}</div>
}
我们可以从下图看到类名修改的规则为 组件名 + 类名 + 四位随机数字
:
参考链接
- 你的前端框架要被 Web 组件取代了 By Danny Moerkerke
- [译]组件化开发利器:Web Components 标准 By 老沙322
- Angular 2 ViewEncapsulation By Semlinker
- 神奇的 Shadow DOM By 暖暖
- CSS Modules 使用详解 By 张颖
- praveenpuglia/shadow-dom.md - github