jsx组件样式隔离的最佳实践

前言

在当今前端开发中,组件化研发模式已然是大行其道,各类基于组件化的搭建系统更是层出不穷,在提高业务研发效率的同时,组件化面临着一个痛点—组件样式隔离的问题。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-loaderscope-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 值传递给样式文件,完成样式文件中相关类名的修改。这里会涉及到两个问题:

  • 如何传递 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

相关文章
相关标签/搜索