web局部(antd)主题方案演进

背景

当web应用存在着预览场景时,在进阶体验中势必存在主题配色这样的需求。

切换主题即整体修改网页中各元素的样式

成熟的ui库一般都会将用到的关键css属性抽离为单一文件,以供开发者定制颜色、元素间距等。开发者通过修改预设变量值,打包成不同的主题样式文件,在预览时使用主题相应的样式文件进行全局替换即可达到主题切换的目的。

本文以下部分均以antd为例,其他诸如bootstrapmaterial-design-lite等实际开发工作均大同小异。

antd是由蚂蚁金服官方维护的,主要针对企业级应用场景设计的前端组件库

当然,预览区域如果是隔离的(独立页面),那本文完结。。。

但预览区域如果和配置区域耦合在一起,并使用了相同的ui库,该如何应对?所以,诞生了局部主题方案

方案制定

css通过规则权重来确认生效的样式,后面的样式定义覆盖前面的:

.text {
    color: #000;
}

// 这样做会导致预览区的样式污染配置区的样式
.text {
    color: #111;
}

// 常见做法是增加一层容器来增加权重
.theme-container .text {
    color: #111;
}

方案一:ui组件库增加外层class

自己定义的属性外层增加一个容器class尚不是难事,但如何为整个antd库增加外层class?

方案二:预览区作用域隔离

当然,也可以人为地将预览区域隔离开来,使用iframeweb components方案都理论可行,不过解决局部样式问题的同时带来了其他的问题需要攻克:

  1. 配置区域和预览区域存在大量拖拽交互时,在技术上、体验上是否可行?
  2. 将整个ui样式库全部内置入web components是否可行?

本文首先使用方案一进行实施,方案二还在继续摸索中,待有突破性进展以后再做分享。

为antd库增加外层class

如何定制antd主题

官方提供了几种定制主题的方式,以下是项目中的具体实现:

// theme.less

// 由于antd内部使用了utf8编码的文字符号而不是使用\u****的Unicode码点表示
// 在cdn样式文件返回头里没有明确指明utf-8时,可能存在文字符号乱码的可能
// 编译生成过程中通过css-loader也会自动转换,而本文场景是直接编译
// 所以,为了安全起见,最好指定编码格式
@charset "utf-8";

@import "/path/to/node_modules/antd/dist/antd.less";

// 覆盖变量定义
// 所有变量详见 https://github.com/ant-design/ant-design/blob/master/components/style/themes/default.less
@primary-color: #F15B41;

由于需要给样式增加外层class,postcss的插件机制更能符合定制需求,于是使用postcss来处理less。编译脚本大致如下:

#!/usr/bin/env node
const fs = require("fs")
const postcss = require("postcss")
const less = require('postcss-less-engine')
const autoprefixer = require('autoprefixer')
const clean = require('postcss-clean')

const lessContent = fs.readFileSync('/path/to/theme.less', 'utf-8')

postcss([
    // 插件配置
    less(),
    autoprefixer(),
    clean(),
]).process(lessContent, {
    parser: less.parser,
    src: '/path/to/theme.less',
}).then(result => {
    const cssContent = result.css
    
    fs.writeFileSync('/path/to/theme.css', cssContent)
})

这样就能得到使用新配色的主题样式文件了。

@import的使用

目前,css预处理语言中我只使用过less/scss,antd使用的是less,本文会偶尔拿出scss来进行比较说明。

由于scss文档中明确说明了@import可以放于选择器下,less文档中并没有提到,一开始我担心不支持该类语法。百闻不如一试,其实less也是支持的:

// theme.less
@charset "utf-8";
.theme-container {
    @import "/path/to/node_modules/antd/dist/antd.less";
    @primary-color: #F15B41;
}

现在所有的新主题样式在theme-container下的权重更高,预览区的样式会覆盖配置区的样式,同时不影响配置区本身的样式。

不过,通过查看生成的css(开发阶段可以先去除autoprefixer、clean两个插件)很快发现了问题。由于每个ui库都会有自己的全局样式,如果将less统一塞在theme-container下会使得全局样式无效:

// 编译出来的全局css
.theme-container html {}

.theme-container body {}

不过这种问题整体范围来说影响不大,即使无效也可能没有大问题,出了问题只要简单区分一下即可:

// theme.less
@charset "utf-8";

@import "/path/to/node_modules/antd/lib/style/index.less";

.theme-container {
    @import "/path/to/node_modules/antd/lib/style/components.less";
}

@primary-color: #F15B41;

这样就结束了么?可能一些ui库已经搞定了,不过,antd的故事只是刚刚开始

难题重重

多个&(父类选择器)的问题

&平时使用的很多,但大部分场景下不会在一条规则中使用复数个&,整个antd用到的其实也不多,但是这些地方就出了问题:

// 原版
.ant-alert {
    &&-no-icon {
        padding: 8px;
    }
}
// 编译后
.ant-alert.ant-alert-no-icon {
    padding: 8px;
}

// 增加外层容器后
.theme-container {
    .ant-alert {
        &&-no-icon {
            padding: 8px;
        }
    }   
}
.theme-container .ant-alert.theme-container .ant-alert-no-icon {
  padding: 8px;
}

这个编译结果是正确的,但不符合预期,造成了样式失效。那如何克服呢?

&是否可以写死父类选择器的值?

这个方案可以实现,原本antd的less文件内就有很多变量可供使用。

@alert: ant-alert;

.theme-container {
  .ant-alert {
    &.@{alert}-no-icon {
          padding: 8px;
      }
  }
}

&是否可以编译为最近的父类?

我查了很久,当我看到这个issue#1075时,一个已经讨论了快5年的issue仍然是open的。我就知道这个方案悬了。

其实里面有很多设想,包括写本文时最新关联的这个issue#3053,都是解决该场景的一种设想。

而为什么有这么多奇淫技巧?因为scss是支持的。。。

我稍微花了些时间尝试了下scss在该场景下的实现,原理大致是将&对应的选择器内容作为入参,通过函数将选择器内容进行自定义修改:

// 参考地址:
// https://medium.com/@jakobud/how-to-do-sass-grandparent-selectors-b8666dcaf961

// scss还不支持&&连写,所以换个例子
.theme-container {
    .ant-collapse {
        & &-item-disabled {
            cursor: not-allowed;
        }
    }   
}
// 原本的编译结果
.theme-container .ant-collapse .theme-container .ant-collapse-item-disabled {
  cursor: not-allowed;
}

// scss下可以这么做
@function get_last_selector($str) {
    $selector: nth($str, 1);
    $last: nth($selector, length($selector));
    @return $last;
}
.theme-container {
    .ant-collapse {
        $parent: get_last_selector(&);
        & #{$parent}-item-disabled {
            cursor: not-allowed;
        }
    }   
}
// 编译结果
.theme-container .ant-collapse .ant-collapse-item-disabled {
  cursor: not-allowed;
}

虽然上述的每种方案都理论可行,不过到最后都无法落地,因为涉及到的改动代码量很大,有些甚至需要修改源码,会导致维护成本过高。

方案调整

新增postcss插件

postcss的编译流程大致是:

less => css => optimze(autoprefix + clean)

由于一开始在less层面能区分开antd的全局样式和组件样式,所以一直致力于在less阶段解决问题。但经过了上面的种种波折,在less层面的所有方案基本都无法实施了。

包括antd,所有的ui库都会有一个prefix头来区分样式,这其实也变相提供了在css层面区分开全局样式和组件样式的能力。

所以,只需在css => optimze阶段新增一个插件来为每条组件样式增加一个父类选择器就可以实现局部主题了!

即,最终流程会变为:

less => css => prefix => optimze

// theme.less
// 不需要再使用@import而带来多&问题了
@charset "utf-8";

@import "/path/to/node_modules/antd/dist/antd.less";
@primary-color: #F15B41;

// 插件代码
const prefixPlugin = postcss.plugin('prefix', (PREFIX = '.theme-container') => {
  const process = node => {
    node.walkRules(rule => {
      rule.selectors = rule.selectors.map(selector => {
        if (selector.startsWith('.ant')) {
          return `${PREFIX} ${selector}`
        }
        return selector
      })
    })
  }

  return process
})

// 编译脚本内加入这个插件
postcss([
    less(),
    autoprefixer(),
    clean(),
]).then(() => {
    // ...
})

局部主题到最后几行代码就搞定了,antd的故事终于结束了。哦不,等等,还有一个小问题。。。

改造部分组件

预览区域的部分组件没有受到局部主题样式影响

antd提供的部分组件,如Modal、Message等的实现,都是在body下插入dom,所以诸如这类样式:

.theme-container .ant-modal { ... }

在应用内都是无法生效的。解决这类问题有两种方式:

修改组件的挂载位置,让局部主题样式生效

class Preview extends PureComponent {
    getContainer = () => {
        const container = document.createElement('div')
        document.querySelector('.theme-container').appendChild(container)
        
        return container
    }

    render() {
        return (
            <div id="preview-area">
                <Modal visible getContainer={this.getContainer}>
                    content
                </Modal>
            </div>
        );
    }
}

修改组件的类名,让配置区域和预览区域的类名区分开来

class Preview extends PureComponent {
    render() {
        return (
            <div id="preview-area">
                <Modal visible prefixCls="custom-modal">
                    content
                </Modal>
            </div>
        );
    }
}

// 插件也需要相应的修改一下
const prefixPlugin = postcss.plugin('prefix', (PREFIX = '.theme-container') => {
  const process = node => {
    node.walkRules(rule => {
      rule.selectors = rule.selectors.map(selector => {
        if (selector.startsWith('.ant')) {
            if (selector.startsWith('ant-modal', 1)) {
                return selector.replace(/ant-(modal)/g, 'custom-$1')
            }
            return `${PREFIX} ${selector}`
        }
        return selector
      })
    })
  }

  return process
})

后记

局部主题样式是一个非常业务化的场景,需求面较小,比较难提出一些非常通用的知识点。但在方案的制定过程中,需要了解不少less的特性、postcss的编译流程以及如何编写插件,这些可能在其他实战中得到更广泛的应用,故成此文,将整个方案的演进过程详尽记录下来,希望能启迪有类似需求的场景并方便自己之后回溯。

从长远来说,web components可能是这类场景的最佳解决方案,目前的方案还比较偏黑科技,所以这个方案并不会停止演进,让实现变得更合理、更规范。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,684评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,143评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,214评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,788评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,796评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,665评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,027评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,679评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,346评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,664评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,766评论 1 331
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,412评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,015评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,974评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,073评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,501评论 2 343

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,448评论 25 707
  • 无意中看到zhangwnag大佬分享的webpack教程感觉受益匪浅,特此分享以备自己日后查看,也希望更多的人看到...
    小小字符阅读 8,140评论 7 35
  • 题记:微信中有人打招呼,通过后一聊,发现彼此竟是十几年未见的儿时玩伴,互叙别后经历,颇多感慨。 看着熟悉的姓名...
    清都漫语阅读 144评论 0 2