较完善的 Ant-Design 换肤方案

1,232 阅读4分钟

前言

预览地址: Ant Design Theme

const App = () => {
  return (
    <ThemeProvider
        theme={{
            name: 'dark',
            variables: { 'primary-color': '#00ff00' },
        }}
    >
      <Button type="primary">Primary Button</Button>
    </ThemeProvider>
  );
};

开箱即用 antd-theme 欢迎 Star ❤

现有方案缺陷

  • CSS 变量

    • 无法支持复杂表达式 mix(var(--primary-color), #fff, 20%)
    • 浏览器兼容性有限
  • 多套 CSS 主题

    • 主题样式固定,无法实时切换主题色
    • CSS 代码分离不好处理
  • less 动态切换

    • 需要引入less runtime 体积较大
    • 切换主题时会对整个样式表进行 parse -> eval -> genCSS 处理,导致切换速度很慢

想要实现的目标

  • 提供接口支持实时修改 @primary-color, @border-radius-base 之类的参数
  • 浏览器页面刷新后默认就是切换后的样式,没有多余的动画、明显的加载过程
  • 打包体积小,无需打包多个样式文件
  • 代码分离友好,不同页面的样式可以分别加载

实现过程

编译时把不同皮肤需要修改的less变量、表达式留空, 在运行时填充。

比如

.btn {
    background: @primary-color;
    &:active {
        background: mix(@primary-color, white, 10%);
    }
}

会编译成

/* less-loader!./style.less */
.btn {
    background: "[theme:primaryColor,default:#1890ff]";
}
.btn:active {
    background: "[theme:e8efafb1,default:#40a9ff]";
}

这种样式是不能直接加载到页面中的的, 所以会进一步处理成

/* themed-style-loader!./style.css */

var loadStyle = require('./load-themed-style').loadStyle;

var css = `
.btn {
    background: "[theme:primaryColor,default:#1890ff]";
}
.btn:active {
    background: "[theme:e8efafb1,default:#40a9ff]";
}
`;

loadStyle(css);

同时也会编译出不同皮肤的替换变量,并注入到特定的文件 themes.js

/* themes.js */
module.exports = {
    default: {
        primaryColor: '#1890ff',
        e8efafb1: '#40a9ff'
    },
    dark: {
        primaryColor: '#1890ee',
        e8efafb1: '#40a9ee'
    },
    compact: {
        primaryColor: '#1890dd',
        e8efafb1: '#40a9dd'
    }
}

皮肤加载器大概长这样

/* load-themed-style.js */
var themes = require('./themes.js')
var styles = [];

var loadStyle = function(css) {
    styles.push(css);
    applyStyles();
}

var loadTheme = function(name) {
    applyStyles(themes[name]);
}

var applyStyles = function(variables) {
    var css = styles.join('').replace(
        /"\[theme:([\w]+),default:(\S+)\]"/,
        function(_, themeSlot, defaultValue){ 
            return varialbes && varialbes[themeSlot] || defaultValue;
        }
    );
    // 生成好的css插入到页面中
}

module.exports = {
    loadStyle,
    loadTheme
}

最后调用 loadTheme('xx') 就可以切换到对应的皮肤

变量实时修改

插件如果分析到某个表达式依赖了需要实时修改的变量,就会把该表达式对应的 AST 注入到 themes.js 里面

/* themes.js */

// @primary-color
var expr1 = {
    type: 'Variable',
    name: '@primary-color'
};

// mix(@primary-color, white, 10%)
var expr2 = {
    type: 'Call',
    name: 'mix',
    args: [
        {
            type: 'Variable',
            name: '@primary-color'
        },
        {
            type: 'Color',
            rgb: [255, 255, 255],
            alpha: 255,
        },
        {
            type: 'Dimension',
            value: 10,
            unit: '%'
        }
    ]
};

module.exports = {
    default: {
        background: 'white',
        primaryColor: { expr: expr1, default: '#1890ff' },
        e8efafb1: { expr: expr2, default: '#40a9ff' }
    },
    dark: {
        background: 'black',
        primaryColor: { expr: expr1, default: '#1890ff' },
        e8efafb1: { expr: expr2, default: '#1890ff' }
    },
    ...
}

loadTheme 会根据传入的实时变量和皮肤里面的 AST 计算出填充值,填充留空并应用修改

var loadTheme = function(name, runtimeVariables) {
    // 根据传入实时变量计算出本次的皮肤变量
    var themeVariables = compute(
        themes[name],
        runtimeVariables
    );
    // 应用样式
    applyStyles(themeVariables);
}

现在调用 loadTheme('xx', { 'primary-color': '#xxxxxx' }) 就可以实时的修改页面主色调

遇到的问题

  • colorPalette 函数

ant-design 内部使用了 ~`colorPalette('@{background}', 7)` 内联 Javascript 块, 导致无法跟踪表达式的变量依赖, 所以在 less 解析之前对样式代码进行预处理,全部替换成 colorPalette(@background, 7) 并提供对应的 colorPalette 函数实现。

  • Mixins 展开 & CSS Guards 转换

换肤方案是基于CSS属性替换, 在所有皮肤下同一个组件生成出来的样式需要行数一致且表达式hash一致。

// Mixin
.button-color(@color) {
    color: @color;
}

.btn-primary {
    &:active {
        // CSS Guard 1
        & when (@theme = dark) {
            .button-color(@primary-7);
        }
        
        // CSS Guard 2
        & when not (@theme = dark) {
            .button-color(~`colorPalette('@{btn-primary-bg}', 7)`);
        }
    }
}

上面的按钮样式在默认皮肤下会生成

.btn-primary:active {
    color: "[theme:primary7]";
}

暗黑模式下生成的却是

/* sha1(colorPalette(@btn-primary-bg, 7))= 9ebde6df87d1def7be1e8e5c80144b793cb1e2c2 */
.btn-primary:active {
    color: "[theme:9ebde6df]";
}

这样就会出现运行时变量填充错误,因此需要修改样式解析后的AST。先对 Mixin 调用进行展开, 然后对 CSS Guards 进行转换,在 AST 执行之前上面的按钮样式会被转换成:

.btn-primary {
    &:active {
        color: if(@theme = darak, @primary-7);
        color: if(not @theme = dark, colorPalette(@btn-primary-bg, 7));
    }
}

转换后的代码就可以愉快的用上文的方式进行处理了, 这里产生的多个color定义会在运行时拿到皮肤变量后进行删除处理。

限制

  • 递归 Mixin Call 的循环变量不能作为皮肤变量, 比如 ant-design 栅格系统相关代码中的 @grid-columns

    .loop-grid-columns(@index, @class) when (@index > 0) {
      // ...
      .@{ant-prefix}-col@{class}-order-@{index} {
        order: @index;
      }
      .loop-grid-columns((@index - 1), @class);
    }
    
    .loop-grid-columns(@grid-columns, @class);
    
  • postcss-position 不兼容

    postcss-position 会直接对 css 中的 position 属性值进行 value.match(/^static|absolute|fixed|relative.../).toString(),

    '"[theme:position,default:relative]"'.match(...) === null

    因此会出现在编译过程中的报错,具体的 postcss-position 代码在 这里

What's Next

  • 支持 CSS Variable Backend 配置

    开启 CSS Variable Backend 后样式文件会编译成

    /* less-loader!./style.less */
    .btn {
        background: var(--primaryColor, #1890ff);
    }
    .btn:active {
        background: var(--e8efafb1, #40a9ff);
    }
    

    applyStyle 的内部实现调整为 style.setProperty(--primaryColor, '#xxxxxx')

  • requestIdleCallback

    采用 React Fiber 类似的方案对样式的渲染过程进行异步处理,避免过多样式同步渲染导致的页面卡顿