⤴Top⤴

CSS in JS

博客分类: 前端

CSS in JS

CSS in JS

什么是 CSS in JS

对于以往的开发,通常是采用”关注点分离”的模式,即:

其中 CSS 的发展相较下来是十分缓慢的,到现在仍然无法解决以下几个问题:

CSS 模块化的解决方案有很多,但主要有两类:

CSS Modules

localIdentName

CSS Modules 中可以定义局部和全局变量:

/* 下面两个等价,默认给每个 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

要注册一个新元素时,需要通过 window.customElements 获取注册表实例并调用其 define 方法:

// <my-element></my-element>

window.customElements.define('my-element', MyElement);

为了避免未来的冲突,所有自定义标签必须加连词符 -,同时原生 HTML 标签保证绝不包含此类连词符

现在页面上的每个 <my-element> 标签都与一个 MyElement 元素对应。 页面每解析一个该标签就调用一次 MyElement 的构造函数。除了标签创建时会调用构造函数,还有一系列生命周期函数会在特定时刻被调用:

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:

video

组合(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>

vue-scoped

当然官方还是更推荐一些基于 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>

vue-css-module.png

当然你可以定义不止一个 <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,可以有以下三种值:

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:

ViewEncapsulation-Native.png

设置为 Emulated,注意看样式变成了:

/* 对应 html 中的 _ngcontent-kty-c0 属性 */
.test[_ngcontent-kty-c0] {...}

ViewEncapsulation-Emulated.png

设置为 Native:

ViewEncapsulation-None.png

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

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>
}

我们可以从下图看到类名修改的规则为 组件名 + 类名 + 四位随机数字:

material-ui-jss

参考链接

  1. 你的前端框架要被 Web 组件取代了 By Danny Moerkerke
  2. [译]组件化开发利器:Web Components 标准 By 老沙322
  3. Angular 2 ViewEncapsulation By Semlinker
  4. 神奇的 Shadow DOM By 暖暖
  5. CSS Modules 使用详解 By 张颖
  6. praveenpuglia/shadow-dom.md - github