组件库样式隔离方案
背景
现阶段有同学反馈在一个子应用中,同时使用多个版本的相同M4B组件时,会产生样式冲突问题,导致样式错乱,多数情况存在于当前子应用安装了A@2.0.0和B@1.0.0,但是B@1.0.0又强依赖A@1.0.0,需要尽早解决此问题。
方案汇总
方案1 —— 基于hash版本号
依赖package.json中的唯一性,通过在样式中增加一个hashed-version
老代码
.m4b-button {
...
}
新代码
注入方式1
受限于arco的构建工具无法对静态变量替换,只能使用方案2中的token注入脚本进行批量刷新
// old
.m4b-button {
height: 20px;
}
// new
@import './token.less';
.m4b-button[data-v=~'@{hashed-version}'] {
...
}
import { HashedVersion } from './token.ts';
function Button() {
return <button class="m4b-button" data-v={HashedVersion} />
}
注入方式2
后期更换构建脚本后可以通过自动读取的方式注入变量,不需要增加文件。
// less可以打通node世界,可以使用@less-plugins/get-hashed-version
// 增加getHashedVersion函数自动获取当前less文件最近的package.json文件中的hashed-version
@m4b-button-version: getHashedVersion();
.m4b-button[data-v=~'@{hashed-version}'] {
...
}
function Button() {
// 利用全局静态值替换,打包完后自动注入
return <button class="m4b-button" data-v={process.env.M4B_BUTTON_HASHED_VERSION} />
}
优点
- 在不同版本之间能够保证完全隔离,一个版本的样式,只会影响一个版本。
- 非常适用于新组件
缺点
- 现阶段M4B样式中存在部分样式不规范的问题,需要改造,改造成本关联样式规范程度,一次性改造完后后续不需要再次改造。
- 对于现有样式,如果业务仓库中存在样式覆盖,可能会受到因为权重导致的不生效问题。
- 老的样式覆盖依旧存在
- 用户感知组件开发者也感知
- tailwind的样式因为权重问题可能无法生效
- 老版本arco不支持属性透传,无法支持data-v
- 一旦包含data-v,就意味着tailwind的权重不会生效
- 解决方案:@layer css
- 组件一定要支持data-v传参
- @arco-design/web-react从2.38.1开始才支持。
方案1.1
样式全部重构成带hash前缀的。
- 优点
- 一旦业务中所有依赖的该组件版本号都是重构后的版本,是后续所有样式不会干扰。
- 缺点
- 是对原有样式需要改造,原有权重可能会提升,影响现存业务仓库中的样式。
- 由于原来的样式编写比较混乱,可能需要一定的改造成本,但是每个组件仅需一次改造。
- 如果业务中存在改造后 的版本A和,改造前的版本B,那么B中的样式依然会适用于A。
方案1.2
原有样式依旧保存,后续新增样式采用hash的方式。
- 优点
- 兼容老版本,原有样式依旧存在,后续新增的样式保证了隔离。
- 缺点
- 老样式依旧会存在样式覆盖。
方案1.3 ✅
基于方案1.1,利用:where零权重的特点进行基于hash生成样式。
@m4b-button-version: getHashedVersion();
.m4b-button:where([data-v=~'@{hashed-version}']) {
...
}
- 优点
- 不会对原有权重造成任何影响,原有的样式覆盖依旧生效。
- 缺点
- :where需要chrome88版本及以上才可以生效。
方案2 —— 基于hash中缀
依赖package.json中的唯一性,通过在样式中增加一个hashed-version
老代码
.m4b-button {
...
}
新代码
@import '~@m4b-design/utils/es/styles/index.less';
@import './token.less';
// 注入样式变量和依赖的组件样式变量
.load-m4b-alert-token();
.load-m4b-alert-style(@m4b-alert-prefix-cls) {
// 与原有样式保持一致
.@{m4b-alert-prefix-cls} {
}
}
// 生成普通版本样式 .m4b-alert
.load-m4b-alert-style(@m4b-alert-prefix-cls);
// 生成带hash版本样式 .m4b-xxx-alert
.load-m4b-alert-style(@m4b-alert-prefix-cls-with-hashed-version);
需要对ts注入token.ts
// 由脚本读取package.json生成
export const M4B_ALERT_VERSION = '1.3.21-alpha.1';
// 由脚本读取package.json后利用统一的hash算法生成
export const M4B_ALERT_HASHED_VERSION = '7azdi9';
// 导出当前组件所依赖的组件的token,方便样式覆盖
export * from '@m4b-design/button/es/token';
需要对less注入token.less
@import (multiple) '~@m4b-design/utils/es/styles/index.less';
@import '~@m4b-design/button/es/style/token.less';
// 生成mixin方便样式变量注入
.load-m4b-alert-token() {
.load-m4b-button-token();
@m4b-alert-version: ~'1.3.21-alpha.1';
@m4b-alert-hashed-version: ~'7azdi9';
@m4b-alert-prefix-cls: ~'@{m4b-prefix-cls}-alert';
@m4b-alert-prefix-cls-with-hashed-version: ~'@{m4b-prefix-cls}-@{m4b-alert-hashed-version}-alert';
}
需要对代码进行修改(alert.tsx)
export function usePrefix(suffix: string, styleIsolationTag: string) {
// 在ConfigProvider中增加styleIsolation变量方便读取当前是否需要样式隔离
const { prefixCls: globalPrefixCls, styleIsolation } = useConfigContext();
// 根据是否隔离生成.m4b-alert还是.m4b-xxx-alert
const prefixCls = [globalPrefixCls, styleIsolation && styleIsolationTag, suffix]
.filter(Boolean)
.join('-');
return prefixCls;
}
const prefixCls = usePrefix('alert', M4B_ALERT_HASHED_VERSION);
新增批量脚本工具注入token
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable no-console */
/* eslint-disable no-unused-expressions */
import 'zx/globals';
import hash from '@emotion/hash';
import changeCase from 'change-case';
const rootPath = path.resolve(__dirname, '..');
const packagePath = path.resolve(rootPath, 'packages');
const ignoreDir = [
'config-provider',
'component-template',
'components',
'iconfordemo',
'icon',
'utils',
'locale',
'variant-numeric',
'shared',
];
const components = (await fs.readdir(packagePath)).filter((dir) => !ignoreDir.includes(dir));
echo`${components.join(',')}`;
await Promise.allSettled(components.map(genToken));
async function init(componentName) {
const componentPath = path.resolve(packagePath, componentName);
const styleFiles = await glob(path.resolve(componentPath, 'src', 'style', '**/*.less'));
for (const styleFile of styleFiles) {
console.log(styleFile);
}
// await fs.appendFile(path.resolve(componentPath, 'src', 'index.tsx'), `\nexport * as TOKEN from './token';`);
}
function pickM4bComponent(pkgJson) {
const res = [];
for (const key of Object.keys(pkgJson.dependencies ?? {})) {
if (key.startsWith('@m4b-design/')) {
const name = key.replace('@m4b-design/', '');
if (name !== 'utils' && name !== 'shared' && name !== 'config-provider') {
res.push(name);
}
}
}
return res;
}
async function genToken(componentName) {
console.log('genToken', componentName);
const componentPath = path.resolve(packagePath, componentName);
const constantName = changeCase.constantCase(componentName);
const paramName = changeCase.paramCase(componentName);
const pkgJson = await fs.readJSON(path.resolve(componentPath, 'package.json'));
const version = pkgJson.version;
const hashedVersion = hash(version);
const depComponents = pickM4bComponent(pkgJson);
await fs.writeFile(
path.resolve(componentPath, 'src', 'token.ts'),
[
`export const M4B_${constantName}_VERSION = '${version}';`,
`export const M4B_${constantName}_HASHED_VERSION = '${hashedVersion}';`,
...depComponents.map((name) => `export * from '@m4b-design/${name}/es/token';`),
].join('\n')
);
// console.log('===', path.resolve(componentPath, 'src', 'style', 'token.less'));
await fs.writeFile(
path.resolve(componentPath, 'src', 'style', 'token.less'),
[
`@import (multiple) '~@m4b-design/utils/es/styles/index.less';`,
...depComponents.map((name) => `@import '~@m4b-design/${name}/es/style/token.less';`),
``,
`.load-m4b-${paramName}-token() {`,
...depComponents.map((name) => ` .load-m4b-${name}-token();`),
` @m4b-${paramName}-version: ~'${version}';`,
` @m4b-${paramName}-hashed-version: ~'${hashedVersion}';`,
` @m4b-${paramName}-prefix-cls: ~'@{m4b-prefix-cls}-${paramName}';`,
` @m4b-${paramName}-prefix-cls-with-hashed-version: ~'@{m4b-prefix-cls}-@{m4b-${paramName}-hashed-version}-${paramName}';`,
`}`,
].join('\n')
);
}
优点
- 完全的隔离
- 权重不增加
缺点
- 样式覆盖无法像往常一样利用className进行覆盖,要加hash
- Export Hash (JS / Less)
- 手写覆盖 -> 升级后
- 解决方案:
// 一个特殊的scope(页面,组件)外层
.lyg {
position: relative;
& {
// 在单独的scope中注入button变量
@import (multiple) '~@m4b-design/button@1.1.24-alpha.7/es/style/token.less';
.load-m4b-button-token();
.@{m4b-button-prefix-cls-with-hashed-version} {
height: 10px;
}
}
& {
// 在单独的scope中注入button变量
@import (multiple) '~@m4b-design/alert@1.3.22-alpha.4/es/style/token.less';
.load-m4b-alert-token();
.@{m4b-button-prefix-cls-with-hashed-version} {
height: 20px;
}
}
}
- 用户不感知但组件开发者需要感知
- 插槽类型的组件的样式覆盖还有问题
.m4b-xxx-form {
// input-number并非form的依赖,所以无法拿到当前input-number的正确classname
.m4b-input-number {
}
}
- 解决方案
// 对于所有的className,都注入一个空的className,比如.m4b-x-alert
// 该className仅用作通用类名处理
const prefixClsX = usePrefix('x-alert');
.m4b-xxx-form {
.m4b-x-input-number {
}
}
- 对于不同的样式工具,需要注入不同的文件
- token.ts
- token.less
- token.scss
- tailwind的样式因为权重问题可能无法生效
方案3(临时) —— 手动更新前缀的特供版
新老代码一致,修改packages/utils/src/styles/variable.less中的变量,对所有的包都增加tag,发布时带tag发布,业务强制锁死版本为该版本。