在當今前端開發中,組件化研發模式已然是大行其道,各類基於組件化的搭建系統更是層出不窮,在提高業務研發效率的同時,組件化面臨着一個痛點—組件樣式隔離的問題。javascript
一個組件可能會被多個業務頁面所使用,若是不作任何處理,業務在使用該組件的過程當中就極有可能會發生組件與組件或者組件與頁面由於class命名撞衫而致使的樣式覆蓋問題,最終使頁面展現異常。css
針對這個問題目前業界也已經有了不少成熟的方案,包括 css module, css in js 以及BEM命名約定等等。可是這些方案在編碼體驗和最終構建產物上都或多或少的存在一些問題。html
以最爲典型和經常使用的 css module 方案爲例,它須要在 jsx 中進行 className 的動態綁定,致使的問題是須要先編寫樣式而不是先編寫元素結構和定義 className。另一個問題是在 className 寫法上因爲須要使用獲取對象屬性的寫法,會致使一些使用連字符的樣式類名須要用中括號才行,好比以下代碼:前端
.container-title {
color: red;
}
複製代碼
import React from 'react';
import style from './App.css';
export default () => {
return (
<h1 className={style["container-title"]}> Hello World </h1>
);
};
複製代碼
這種編碼方式確實算不上優雅,畢竟對於前端開發者來講最爽最熟悉的確定仍是直接編寫 className 字符串,而後在 css 文件中去編寫對應 class 的樣式。此外其編譯產物中 className 的值會變成一個哈希字符串,以下所示:java
<h1 class="_3zyde4l1yATCOkgn-DBWEL">
Hello World
</h1>
複製代碼
._3zyde4l1yATCOkgn-DBWEL {
color: red;
}
複製代碼
雖然類名確實變成獨一無二了,可是可讀性極差而且若是在做爲其餘組件的子組件使用時,若是父組件想要覆蓋子組件樣式,這種狀況下就無法兒支持了。node
其餘的像 css in js 這種須要在 js 中編寫樣式,這自己就不太符合關注點分離的開發習慣,不只會致使js文件的膨脹,而且其構建產物中樣式大可能是經過 style 內聯的形式,這種方式對於樣式複寫也會形成較高的成本。react
鋪墊作了這麼多,接下來會介紹我對於解決 jsx 組件樣式隔離問題的最佳實踐,它來源於我本身思考並開發的兩個 webpack-loader。webpack
你須要在組件研發腳手架的 webpack 配置中添加 scope-jsx-loader 和 scope-css-loader。使用示例以下:git
先完成 loader 安裝github
npm i scope-jsx-loader scope-css-loader --save-dev
複製代碼
而後能夠在 webpack 中進行 loader 添加
module.exports = {
module: {
rules: [
{
test: /\.(t|j)sx$/i,
exclude: /node_modules/,
use: [
{
loader: 'babel-loader'
},
{
loader: 'ts-loader'
},
{
loader: 'scope-jsx-loader'
}
],
},
{
test: /\.(c|sc|sa)ss$/i,
use: [
{
loader: 'style-loader',
},
{
loader: 'css-loader',
},
{
loader: 'sass-loader',
},
{
loader: 'scope-css-loader'
}
],
}
]
}
}
複製代碼
scope-jsx-loader 負責對 jsx 文件進行解析和轉換,它會查找 jsx 中全部的 className, 而後將每一個 className 的值轉換爲 ${className}-${hash}
的模式。
scope-css-loader 負責同步樣式文件中對應類名的變動,將類名選擇器轉換爲 .${className}-${hash}
這個過程徹底是在構建環節自動進行的,你不須要像 css module 那樣關注 jsx 和樣式文件關聯的細節,能夠正常編寫 className 和樣式文件,以一個 rax 組件爲例:
jsx 文件代碼:
import { createElement } from 'rax';
import View from 'rax-view';
import Text from 'rax-text';
import Image from 'rax-image';
import './index.scss';
interface ComponentData {
bgColor: string;
}
interface PropsData {
fields: ComponentData;
}
const Demo = (props: PropsData) => {
const { bgColor } = props.fields;
const style = {
backgroundColor: bgColor,
};
return (
<View className="component-container" style={style}> <Image className="container-img" source={{ uri: 'https://gw.alicdn.com/tfs/TB1LYpTL1L2gK0jSZFmXXc7iXXa-260-260.jpg' }} /> <Text className="container-text">Welcome to develop a component</Text> </View> ); }; export default Demo; 複製代碼
index.scss 文件代碼:
.component-container {
background-color: #fff;
.container-img {
width: 100rpx;
margin: 60rpx auto;
height: 100rpx;
}
.rax-view{
font-size: 12px;
}
.container-text {
width: 100%;
text-align: center;
font-size: 24rpx;
font-weight: 500;
}
}
複製代碼
其編譯生成的 html 代碼效果以下所示:
css 代碼效果以下所示:
經過 ${className}-${hash}
的方式,咱們就完成了組件樣式的隔離,確保了不會發生類名全局污染的問題。整個過程對於開發者是無感的,他們能夠用最簡潔的開發方式來編寫代碼。接下來我會爲你解析整個實現過程的內在原理。
上述的 hash 值由 md5 根據當前組件的 npm 包名進行生成,爲了不增長過多字符串致使組件的包體積大幅增長,我只取了 hash 字符串的前8位字符。這種方式既保障了類名的可讀性,也保障了組件的類名惟一性。hash 生成代碼以下所示:
const md5 = require('md5');
const path = require("path");
const computedHash = {};
const computeHash = (pkgName) => {
if (computedHash[pkgName]) {
return computedHash[pkgName]
}
const hash = md5(pkgName).substr(0, 8);
computedHash[pkgName] = hash;
return hash;
}
const cwd = process.cwd();
const pkgName = require(path.join(cwd, 'package.json')).name;
const hash = computeHash(pkgName);
複製代碼
這裏須要解釋一下的是爲何使用組件的 npm 包名而不是 jsx 文件的路徑來生成 hash。若是使用文件路徑的方式,本地構建生成的 hash 字符串和雲端構建生成的 hash 字符串會不一致,同一個組件被多我的協做開發時不一樣開發者在本地構建生成的 hash 字符串也會不一致,這種不一致帶來的後果就是當該組件做爲子組件嵌入到父組件中使用時,父組件因爲沒法肯定子組件的類名,就沒法完成對子組件的樣式複寫。而使用 npm 包名就不存在這個問題了,對於一個組件來講,其 npm 包名是惟一的,這樣其生成的 hash 值也是惟一的,且不會發生變化。
完成 jsx 文件中 className 值的修改以後,還須要將上文中生成的 hash 值傳遞給樣式文件,完成樣式文件中相關類名的修改。這裏會涉及到兩個問題:
第一個問題能夠經過給 jsx 中引入的樣式文件添加查詢字符串的方式來解決,示例代碼以下:
const styleReg = /\.(c|sc|sa|le)ss/g;
return source.replace(styleReg, (match) => {
return `${match}?scopeId=${hash}`;
});
複製代碼
在 scope-css-loader 中會解析 scopedId 的參數來獲取哈希值。
第二個問題的解法其實也很是簡單,一共分爲兩步。第一步,先統計jsx文件中有哪些 className, 這裏須要注意的一點是 className 的編寫是能夠支持多個類名以空格形式組合的,好比:
<h1 className="hello1 hello2" />
複製代碼
在這種寫法下 h1 這個元素實際上是有 hello1 和 hello2 兩個類名,須要單獨進行收集,且每一個類名都須要單獨添加 hash,考慮到添加 hash 的過程自己也須要查找類名,類名的統計和替換能夠放在一塊兒作,代碼以下所示:
const classNameReg = /className=\"([^"]+)\"/g;
// 負責收集須要轉換的樣式類名
let classnames = [];
return source.replace(classNameReg, (match) => {
const classValues = match.match(/className=\"([^"]+)\"/)[1].trim().split(" ");
// 轉化成帶.的選擇器
classnames = classnames.concat(classValues.map(item => `.${item}`));
return `className="${classValues.map(item => `${item.trim()}-${hash}`).join(" ")}"`;
})
複製代碼
這裏有個問題須要說明一下,爲何咱們須要作這一步的收集工做。可能有人會問,樣式文件中寫的類名不該該都是我在 jsx 文件中編寫的 className 嗎?我難道不能在樣式文件中全量給全部的類名都添加 hash 值嗎?答案是固然不能,由於在樣式文件中是有可能存在如下代碼的:
div{
font-size: 12px;
}
複製代碼
在這種狀況下很明顯是不能給 div 添加 hash 值的。但是既然這樣那過濾一下選擇器就行了啊,只給用類名的選擇器添加 hash 不就能夠了嗎?答案也是不行,由於還有可能會存在下述這種代碼:
.container{
.next-btn{
color: #fff;
}
}
複製代碼
.next-btn 多是你在組件中使用的一個 fusion 或 antd 的按鈕組件,你想覆蓋其樣式,因此寫了這麼一行代碼。這種狀況下. next-btn 很明顯也是不該該添加 hash 的,這樣會致使你須要的樣式覆蓋失效。
基於上述緣由咱們不能無腦的對樣式文件中的類名進行全局替換,須要進行這一步的收集工做。
接下里是第二步,須要將收集到的類名傳入到樣式文件中。這個也很簡單,能夠借鑑 hash 值傳遞的方式,在以前的基礎上添加一個 classnames 參數便可,代碼以下所示:
const styleReg = /\.(c|sc|sa|le)ss/g;
return source.replace(styleReg, (match) => {
return `${match}?scopeId=${hash}&classnames=${classnames.join('($$)')}`;
});
複製代碼
這時候 jsx 中樣式文件的引用就由
import './index.scss'
複製代碼
變成了
import './index.scss?scopedId=f1954ada&classnames=.component-container($$).container-img($$).container-text'
複製代碼
剩下的工做就是 scope-css-loader 對樣式文件進行解析和替換。這個過程也是分爲兩步。
第一步,解析 scopeId 和 classnames 參數,代碼以下所示:
const qs = require('qs');
const resourceQuery = qs.parse(this.resource.split('?')[1]);
const scopeId = resourceQuery.scopeId;
const classnames = resourceQuery.classnames && resourceQuery.classnames.split('($$)');
複製代碼
第二步,使用 scopeId 和 classnames 進行類名替換,這裏須要注意的一點是在獲取樣式文件中的類名時須要考慮到後代選擇器的場景,好比:
.a .b{
color: #fff;
}
複製代碼
在這種場景下,a 和 b 是兩個獨立的類名,須要單獨進行處理。類名解析和替換的代碼以下所示:
const classNameReg = /\.([^{]+)(\s*)\{/g;
if (scopeId && classnames) {
return source.replace(classNameReg, (matchItem) => {
const theClassName = matchItem.match(/\.([^{]+)(\s*)\{/)[1].trim();
// 兼容css的後代選擇器模式,好比 .a .b{}
const classValues = theClassName.split(/(\s+)/);
const ultiClassName = classValues.map((item, index) => {
const checkValue = index === 0 ? `.${item}` : item;
// 判斷是否在須要替換的類名名單中
return classnames.indexOf(checkValue) >= 0 ? `${checkValue}-${scopeId}` : checkValue;
}).join(" ");
return `${ultiClassName} {`;
})
}
複製代碼
至此全部的工做就完結了,整個過程其實沒有特別難的地方,核心仍是須要考慮和兼容開發者的各類編碼場景,好比上文中提到的後代選擇器以及 className 中的多類名問題等等,這裏有其餘考慮不周全的地方也歡迎你們進行指正。
本文主要介紹了我對於 jsx 組件進行樣式隔離的最佳實踐,目前該方案已經集成到了咱們團隊內的組件腳手架中。若是你有其餘更好的方案也歡迎隨時與我進行交流探討,後續我也會將這部分能力支持到其餘構建工具。
咱們是業務平臺-體驗技術團隊,目前正在全力打造全新的阿里巴巴業務中臺基礎設施,不管是技術深度仍是業務場景都有很是大的挑戰,歡迎各位前端或者後端大佬的加入。
聯繫方式: 微信:longmaost 郵箱:mozheng.sh@alibaba-inc.com