CSS Modules 詳解及 React 中實踐

CSS 是前端領域中進化最慢的一塊。由於 ES2015/2016 的快速普及和 Babel/Webpack 等工具的迅猛發展,CSS 被遠遠甩在了後面,逐漸成爲大型項目工程化的痛點。也變成了前端走向徹底模塊化前必須解決的難題。

CSS 模塊化的解決方案有很多,但主要有兩類。一類是徹底拋棄 CSS,使用 JS 或 JSON 來寫樣式。Radiumjsxstylereact-style 屬於這一類。優點是能給 CSS 提供 JS 同樣強大的模塊化能力;缺點是不能利用成熟的 CSS 預處理器(或後處理器) Sass/Less/PostCSS,:hover 和 :active 僞類處理起來複雜。另一類是依舊使用 CSS,但使用 JS 來管理樣式依賴,代表是 CSS Modules。CSS Modules 能最大化地結合現有 CSS 生態和 JS 模塊化能力,API 簡潔到幾乎零學習成本。發佈時依舊編譯出單獨的 JS 和 CSS。它並不依賴於 React,只要你使用 Webpack,可以在 Vue/Angular/jQuery 中使用。是我認爲目前最好的 CSS 模塊化解決方案。近期在項目中大量使用,下面具體分享下實踐中的細節和想法。

CSS 模塊化遇到了哪些問題?

CSS 模塊化重要的是要解決好兩個問題:CSS 樣式的導入和導出。靈活按需導入以便複用代碼;導出時要能夠隱藏內部作用域,以免造成全局污染。Sass/Less/PostCSS 等前仆後繼試圖解決 CSS 編程能力弱的問題,結果它們做的也確實優秀,但這並沒有解決模塊化最重要的問題。Facebook 工程師 Vjeux 首先拋出了 React 開發中遇到的一系列 CSS 相關問題。加上我個人的看法,總結如下:

  1. 全局污染
    CSS 使用全局選擇器機制來設置樣式,優點是方便重寫樣式。缺點是所有的樣式都是全局生效,樣式可能被錯誤覆蓋,因此產生了非常醜陋的 !important,甚至 inline !important 和複雜的選擇器權重計數表,提高犯錯概率和使用成本。Web Components 標準中的 Shadow DOM 能徹底解決這個問題,但它的做法有點極端,樣式徹底局部化,造成外部無法重寫樣式,損失了靈活性。
  2. 命名混亂
    由於全局污染的問題,多人協同開發時爲了避免樣式衝突,選擇器越來越複雜,容易形成不同的命名風格,很難統一。樣式變多後,命名將更加混亂。
  3. 依賴管理不徹底
    組件應該相互獨立,引入一個組件時,應該只引入它所需要的 CSS 樣式。但現在的做法是除了要引入 JS,還要再引入它的 CSS,而且 Saas/Less 很難實現對每個組件都編譯出單獨的 CSS,引入所有模塊的 CSS 又造成浪費。JS 的模塊化已經非常成熟,如果能讓 JS 來管理 CSS 依賴是很好的解決辦法。Webpack 的 css-loader 提供了這種能力。
  4. 無法共享變量
    複雜組件要使用 JS 和 CSS 來共同處理樣式,就會造成有些變量在 JS 和 CSS 中冗餘,Sass/PostCSS/CSS 等都不提供跨 JS 和 CSS 共享變量這種能力。
  5. 代碼壓縮不徹底
    由於移動端網絡的不確定性,現在對 CSS 壓縮已經到了變態的程度。很多壓縮工具爲了節省一個字節會把 '16px' 轉成 '1pc'。但對非常長的 class 名卻無能爲力,力沒有用到刀刃上。

上面的問題如果只憑 CSS 自身是無法解決的,如果是通過 JS 來管理 CSS 就很好解決,因此 Vjuex 給出的解決方案是完全的 CSS in JS,但這相當於完全拋棄 CSS,在 JS 中以 Object 語法來寫 CSS,估計剛看到的小夥伴都受驚了。直到出現了 CSS Modules。

CSS Modules 模塊化方案

CSS Modules Logo

CSS Modules 內部通過 ICSS 來解決樣式導入和導出這兩個問題。分別對應 :import 和 :export 兩個新增的僞類。

:import("path/to/dep.css") {
  localAlias: keyFromDep;
  /* ... */
}
:export {
  exportedKey: exportedValue;
  /* ... */
}

但直接使用這兩個關鍵字編程太麻煩,實際項目中很少會直接使用它們,我們需要的是用 JS 來管理 CSS 的能力。結合 Webpack 的 css-loader 後,就可以在 CSS 中定義樣式,在 JS 中導入。

啓用 CSS Modules

// webpack.config.js
css?modules&localIdentName=[name]__[local]-[hash:base64:5]

加上 modules 即爲啓用,localIdentName 是設置生成樣式的命名規則。

/* components/Button.css */
.normal { /* normal 相關的所有樣式 */ }
.disabled { /* disabled 相關的所有樣式 */ }
/* components/Button.js */
import styles from './Button.css';

console.log(styles);

buttonElem.outerHTML = `<button class=${styles.normal}>Submit</button>`

生成的 HTML 是

<button class="button--normal-abc53">Submit</button>

注意到 button--normal-abc53 是 CSS Modules 按照 localIdentName 自動生成的 class 名。其中的 abc53 是按照給定算法生成的序列碼。經過這樣混淆處理後,class 名基本就是唯一的,大大降低了項目中樣式覆蓋的機率。同時在生產環境下修改規則,生成更短的 class 名,可以提高 CSS 的壓縮率。

上例中 console 打印的結果是:

Object {
  normal: 'button--normal-abc53',
  disabled: 'button--disabled-def884',
}

CSS Modules 對 CSS 中的 class 名都做了處理,使用對象來保存原 class 和混淆後 class 的對應關係。

通過這些簡單的處理,CSS Modules 實現了以下幾點:

  • 所有樣式都是 local 的,解決了命名衝突和全局污染問題
  • class 名生成規則配置靈活,可以此來壓縮 class 名
  • 只需引用組件的 JS 就能搞定組件所有的 JS 和 CSS
  • 依然是 CSS,幾乎 0 學習成本

樣式默認局部

使用了 CSS Modules 後,就相當於給每個 class 名外加加了一個 :local,以此來實現樣式的局部化,如果你想切換到全局模式,使用對應的 :global

.normal {
  color: green;
}

/* 以上與下面等價 */
:local(.normal) {
  color: green; 
}

/* 定義全局樣式 */
:global(.btn) {
  color: red;
}

/* 定義多個全局樣式 */
:global {
  .link {
    color: green;
  }
  .box {
    color: yellow;
  }
}

Compose 來組合樣式

對於樣式複用,CSS Modules 只提供了唯一的方式來處理:composes 組合

/* components/Button.css */
.base { /* 所有通用的樣式 */ }

.normal {
  composes: base;
  /* normal 其它樣式 */
}

.disabled {
  composes: base;
  /* disabled 其它樣式 */
}
import styles from './Button.css';

buttonElem.outerHTML = `<button class=${styles.normal}>Submit</button>`

生成的 HTML 變爲

<button class="button--base-daf62 button--normal-abc53">Submit</button>

由於在 .normal 中 composes 了 .base,編譯後會 normal 會變成兩個 class。

composes 還可以組合外部文件中的樣式。

/* settings.css */
.primary-color {
  color: #f40;
}

/* components/Button.css */
.base { /* 所有通用的樣式 */ }

.primary {
  composes: base;
  composes: primary-color from './settings.css';
  /* primary 其它樣式 */
}

對於大多數項目,有了 composes 後已經不再需要 Sass/Less/PostCSS。但如果你想用的話,由於 composes 不是標準的 CSS 語法,編譯時會報錯。就只能使用預處理器自己的語法來做樣式複用了。

class 命名技巧

CSS Modules 的命名規範是從 BEM 擴展而來。BEM 把樣式名分爲 3 個級別,分別是:

  • Block:對應模塊名,如 Dialog
  • Element:對應模塊中的節點名 Confirm Button
  • Modifier:對應節點相關的狀態,如 disabled、highlight

綜上,BEM 最終得到的 class 名爲 dialog__confirm-button--highlight。使用雙符號 __ 和 -- 是爲了和區塊內單詞間的分隔符區分開來。雖然看起來有點奇怪,但 BEM 被非常多的大型項目和團隊採用。我們實踐下來也很認可這種命名方法。

CSS Modules 中 CSS 文件名恰好對應 Block 名,只需要再考慮 Element 和 Modifier。BEM 對應到 CSS Modules 的做法是:

/* .dialog.css */
.ConfirmButton--disabled {
}

你也可以不遵循完整的命名規範,使用 camelCase 的寫法把 Block 和 Modifier 放到一起:

/* .dialog.css */
.disabledConfirmButton {
}

如何實現CSS,JS變量共享

注:CSS Modules 中沒有變量的概念,這裏的 CSS 變量指的是 Sass 中的變量。

上面提到的 :export 關鍵字可以把 CSS 中的 變量輸出到 JS 中。下面演示如何在 JS 中讀取 Sass 變量:

/* config.scss */
$primary-color: #f40;

:export {
  primaryColor: $primary-color;
}
/* app.js */
import style from 'config.scss';

// 會輸出 #F40
console.log(style.primaryColor);

CSS Modules 使用技巧

CSS Modules 是對現有的 CSS 做減法。爲了追求簡單可控,作者建議遵循如下原則:

  • 不使用選擇器,只使用 class 名來定義樣式
  • 不層疊多個 class,只使用一個 class 把所有樣式定義好
  • 所有樣式通過 composes 組合來實現複用
  • 不嵌套

上面兩條原則相當於削弱了樣式中最靈活的部分,初使用者很難接受。第一條實踐起來難度不大,但第二條如果模塊狀態過多時,class 數量將成倍上升。

一定要知道,上面之所以稱爲建議,是因爲 CSS Modules 並不強制你一定要這麼做。聽起來有些矛盾,由於多數 CSS 項目存在深厚的歷史遺留問題,過多的限制就意味着增加遷移成本和與外部合作的成本。初期使用中肯定需要一些折衷。幸運的是,CSS Modules 這點做的很好:

如果我對一個元素使用多個 class 呢?

沒問題,樣式照樣生效。

如何我在一個 style 文件中使用同名 class 呢?

沒問題,這些同名 class 編譯後雖然可能是隨機碼,但仍是同名的。

如果我在 style 文件中使用僞類,標籤選擇器等呢?
沒問題,所有這些選擇器將不被轉換,原封不動的出現在編譯後的 css 中。也就是說 CSS Modules 只會轉換 class 名和 id 選擇器名相關的樣式。

但注意,上面 3 個“如果”儘量不要發生。

CSS Modules 結合 React 實踐

在 className 處直接使用 css 中 class 名即可。

/* dialog.css */
.root {}
.confirm {}
.disabledConfirm {}
import classNames from 'classnames';
import styles from './dialog.css';

export default class Dialog extends React.Component {
  render() {
    const cx = classNames({
      [styles.confirm]: !this.state.disabled,
      [styles.disabledConfirm]: this.state.disabled
    });

    return <div className={styles.root}>
      <a className={cx}>Confirm</a>
      ...
    </div>
  }
}

注意,一般把組件最外層節點對應的 class 名稱爲 root。這裏使用了 classnames 庫來操作 class 名。
如果你不想頻繁的輸入 styles.**,可以試一下 react-css-modules,它通過高階函數的形式來避免重複輸入 styles.**

CSS Modules 結合歷史遺留項目實踐

好的技術方案除了功能強大炫酷,還要能做到現有項目能平滑遷移。CSS Modules 在這一點上表現的非常靈活。

外部如何覆蓋局部樣式

當生成混淆的 class 名後,可以解決命名衝突,但因爲無法預知最終 class 名,不能通過一般選擇器覆蓋。我們現在項目中的實踐是可以給組件關鍵節點加上 data-role 屬性,然後通過屬性選擇器來覆蓋樣式。

// dialog.js
  return <div className={styles.root} data-role='dialog-root'>
      <a className={styles.disabledConfirm} data-role='dialog-confirm-btn'>Confirm</a>
      ...
  </div>
// dialog.css
[data-role="dialog-root"] {
  // override style
}

因爲 CSS Modules 只會轉變類選擇器,所以這裏的屬性選擇器不需要添加 :global

如何與全局樣式共存

前端項目不可避免會引入 normalize.css 或其它一類全局 css 文件。使用 Webpack 可以讓全局樣式和 CSS Modules 的局部樣式和諧共存。下面是我們項目中使用的 webpack 部分配置代碼:

module: {
  loaders: [{
    test: /\.jsx?$/,
    loader: 'babel'
  }, {
    test: /\.scss$/,
    exclude: path.resolve(__dirname, 'src/styles'),
    loader: 'style!css?modules&localIdentName=[name]__[local]!sass?sourceMap=true'
  }, {
    test: /\.scss$/,
    include: path.resolve(__dirname, 'src/styles'),
    loader: 'style!css!sass?sourceMap=true'
  }]
}
/* src/app.js */
import './styles/app.scss';
import Component from './view/Component'

/* src/views/Component.js */
// 以下爲組件相關樣式
import './Component.scss';

目錄結構如下:

src
├── app.js
├── styles
│   ├── app.scss
│   └── normalize.scss
└── views
    ├── Component.js
    └── Component.scss

這樣所有全局的樣式都放到 src/styles/app.scss 中引入就可以了。其它所有目錄包括 src/views 中的樣式都是局部的。

總結

CSS Modules 很好的解決了 CSS 目前面臨的模塊化難題。支持與 Sass/Less/PostCSS 等搭配使用,能充分利用現有技術積累。同時也能和全局樣式靈活搭配,便於項目中逐步遷移至 CSS Modules。CSS Modules 的實現也屬輕量級,未來有標準解決方案後可以低成本遷移。如果你的產品中正好遇到類似問題,非常值得一試。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章