编程知识 cdmana.com

畅谈React material-ui的样式方案

Google 在 2014年提出了 material design 的设计理念,极具颠覆性,在国外非常受欢迎。

各类前端框架也都出现了material design风格的组件库,其中 React 最受欢迎的 material design 风格组件库非 material-ui 莫属。

mui-org/material-ui

这个开源库目前收获了 61415 star

ec4c67f75d9a0560dedf713d8b5134cd.jpeg


npm 的周下载量达到了 139万

数据摆在那儿,我也就不需要继续“吹捧” material-ui 了。

本文主要是带大家一起领略一下 material-ui 的样式方案


@material-ui/styles

material-ui 将自己的样式方案独立发布成一个 npm 包,叫 @material-ui/styles (@material-ui/styles


CSS-in-JS

material-ui 样式方案拥抱了 CSS-in-JS ,据他们的文档,他们也层尝试使用 LESS 等其他方案,但是发现有明显的局限性,最后拥抱了 CSS-in-JS —— 解锁了 主题嵌套、动态样式、自支持 等特性

(关于 css-in-js 和 其他 css 方案的对比,本文不展开讨论,有兴趣的读者可以自行搜索)


官方文档宣称material-ui 样式方案有以下优点



如何使用

material-ui 样式方案支持三种形式的API,但底层的代码和逻辑是一致的。

代码示范

  1. Hook API
import React from 'react';
import { makeStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';

const useStyles = makeStyles({ // css 对象
  root: {
    background'linear-gradient(45deg, #FE6B8B 30%, #FF8E53 90%)',
    border0,
    borderRadius3,
    boxShadow'0 3px 5px 2px rgba(255, 105, 135, .3)',
    color'white',
    height48,
    padding'0 30px',
  },
});

export default function Hook({
  const classes = useStyles(); // classes 对象
  return <Button className={classes.root}>Hook</Button>;
}

通过 makeStyles API ,传入一个描述CSS的对象(下面简称 css对象),就能得到一个自定义的Hook,通常命名为 useStyles (参考React官方文档里自定义hook的命名规范

在函数式组件内调用这个Hook,得到一个对象,通常命名为 classes (注意是复数,读者可以结合后面的内容思考一下为什么是复数)

最后,将 classes 对象的“对应值” 赋给 组件的 className 属性,就成功定制了这个组件的样式。

细节:classes对象的 root 属性 对应的就是 css对象的 root 属性


2. Styled components API

import React from 'react';
import { styled } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';

const MyButton = styled(Button)({
  background'linear-gradient(45deg, #FE6B8B 30%, #FF8E53 90%)',
  border0,
  borderRadius3,
  boxShadow'0 3px 5px 2px rgba(255, 105, 135, .3)',
  color'white',
  height48,
  padding'0 30px',
});

export default function StyledComponents({
  return <MyButton>Styled Components</MyButton>;
}

使用 styled API 就能采用类似于 styled-components 的语法,当然还是有区别的 —— styled-components 用的是 es6 tagged template literals 而 material-ui styled API 返回函数的参数是css对象


3. Higher-order component API

import React from 'react';
import PropTypes from 'prop-types';
import { withStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';

const styles = {
  root: {
    background'linear-gradient(45deg, #FE6B8B 30%, #FF8E53 90%)',
    border0,
    borderRadius3,
    boxShadow'0 3px 5px 2px rgba(255, 105, 135, .3)',
    color'white',
    height48,
    padding'0 30px',
  },
};

function HigherOrderComponent(props{ 
  const { classes } = props;
  return <Button className={classes.root}>Higher-order component</Button>;
}

HigherOrderComponent.propTypes = {
  classes: PropTypes.object.isRequired,
};

export default withStyles(styles)(HigherOrderComponent);

使用 withStyles API 接收一个 css 对象返回一个高阶组件函数。

material-ui 的 Higher-order component API 不仅能用于函数组件也能用于类组件,但在 Hook 大行其道后这个 API 慢慢退出舞台了。


(本文接下来都只使用 makeStyles API 做后面的代码示范)


底层原理

material-ui 上述的API 在底层都会生成一些“随机的html class 名称” 以及 相应的 css 规则

以 上面的 makeStyles demo 为例,会生成以下 css 规则插入到 html head

9674bf3578e9ff048bd262fa853f2cf5.jpeg

自定义样式按钮 对应的 html 片段如下

5299500e4ed0935bf509af2409b91f92.png



小结

上述这三种形式的API都能定制化组件的样式。查看源码就能发现其实 styled 和 withStyles 底层都调用了 makeStyles API

https://github.com/mui-org/material-ui/blob/4b560848aa3b38b9cadc198c624abeb810aaf6a4/packages/material-ui-styles/src/withStyles/withStyles.js#L38

https://github.com/mui-org/material-ui/blob/4b560848aa3b38b9cadc198c624abeb810aaf6a4/packages/material-ui-styles/src/styled/styled.js#L52

底层原理是动态生成 css 规则插入到 html 中

这三个API的使用方法中,最关键的都是两部分:

  1. css 对象
  2. 目标组件

接下来将细聊 css 对象的构成和使用。



嵌套选择器

import React from 'react';
import { makeStyles } from '@material-ui/core/styles';

const useStyles = makeStyles({
  root: {
    color'red',
    '& p': {
      margin0,
      color'green',
      '& span': {
        color'blue',
      },
    },
  },
});

export default function NestedStylesHook({
  const classes = useStyles();

  return (
    <div className={classes.root}>
      This is red since it is inside the root.
      <p>
        This is green since it is inside the paragraph{' '}
        <span>and this is blue since it is inside the span</span>
      </p>
    </div>
  );
}

上面的代码可以得到以下效果

622637b648e15d9a1b8e6265623a8a61.png

实际生成的 css 规则如下


fcad7254b4dc95a14ba27a50da4732b7.png


根据传入值动态调整

上述的 css 对象并不一定是单纯的简单对象。

可以将函数传递给makeStyles (“插值”),这样一来根据组件的属性可以变换生成的样式的值。 此函数可以运用于样式规则的级别,也可以放在 CSS 属性级别

例1:

const useStyles = makeStyles({
  // 样式规则
  foo: props => ({
    backgroundColor: props.backgroundColor,
  }),
  bar: {
    // CSS 属性
    color: props => props.color,
  },
});

function MyComponent({
  // 模拟组件的props
  const props = { backgroundColor: 'black', color: 'white' };
  // Pass the props as the first argument of useStyles()
  const classes = useStyles(props);

  return <div className={`${classes.foo} ${classes.bar}`} />
}


例2:

import React from 'react';
import PropTypes from 'prop-types';
import { makeStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';

const useStyles = makeStyles({
  root: {
    background(props) =>
      props.color === 'red'
        ? 'linear-gradient(45deg, #FE6B8B 30%, #FF8E53 90%)'
        : 'linear-gradient(45deg, #2196F3 30%, #21CBF3 90%)',
    border0,
    borderRadius3,
    boxShadow(props) =>
      props.color === 'red'
        ? '0 3px 5px 2px rgba(255, 105, 135, .3)'
        : '0 3px 5px 2px rgba(33, 203, 243, .3)',
    color'white',
    height48,
    padding'0 30px',
    margin8,
  },
});

function MyButton(props{
  const { color, ...other } = props;
  const classes = useStyles(props);
  return <Button className={classes.root} {...other} />;
}

MyButton.propTypes = {
  color: PropTypes.oneOf(['blue', 'red']).isRequired,
};

export default function AdaptingHook() {
  return (
    <React.Fragment>
      <MyButton color="red">Red</MyButton>
      <MyButton color="blue">Blue</MyButton>
    </React.Fragment>
  );
}

效果图:

cd25c330a69bc2c3de675e6ae390c8e6.png

上述代码的两个按钮对应的html代码

679785b715185df5d8c79b10a1452cb1.jpeg

发现它们共享了一个 class 名 —— makeStyles-root-1,同时各有一个特别的class名 makeStyles-root-2 和 makeStyles-root-3

对于 makeStyles-root-1 我们不出意外地在 html head 里找到了对应的 css 规则

eb6c2aa45b5bba93adbaebae80a42486.png

但是在 html 里却找不到 makeStyles-root-2 和 makeStyles-root-3

在 Chrome 开发者工具看到这两个 class 样式的标注是 constructed stylesheet

57c64e2125a91f6b497b1d0ea91560c0.png

constructed stylesheet 是一种通过 js 调用特别的API 构造 css 规则的技术,具体细节可以查看文档

https://developers.google.com/web/updates/2019/02/constructable-stylesheets


覆盖样式 —— classes 属性

接下来换个角度,假如你使用 material-ui 样式方案创作了一些组件(提供了默认的样式)并作为公用组件分享给其他模块使用,如何让调用端可自定义这些组件的样式呢?

上面提到的 根据传入值动态调整 是一种解决方案,但很明显这是一种只允许微调的方案;而且这个方案的实现通常比较繁琐 —— 如果允许调整所有样式,那么所有样式在css对象中都得是函数。

假如以下是我们设计的一个组件代码

// 一个样式内联表
const useStyles = makeStyles({
  root: {}, // 一个样式规则
  label: {}, // 一个嵌套的样式规则
});

function Nested(props{
  const classes = useStyles();
  return (
    <button className={classes.root}> // class 值 'makeStyles-root-1'
      <span className={classes.label}> // class 值 'makeStyles-label-2'
        嵌套的
      </span>
    </button>
  );
}

function Parent({
  return <Nested />
}

那么我在 Parent 中如何自定义这个 Nested 组件的 span 样式呢?

我们可以利用 classes属性 覆盖样式

const useStyles = makeStyles({
  root: {}, // 一个样式规则
  label: {}, // 一个嵌套的样式规则
});

function Nested(props{
  const classes = useStyles(props); // 注意这里的参数
  return (
    <button className={classes.root}> // class 值 'makeStyles-root-1'
      <span className={classes.label}> // class 值 'makeStyles-label-2 my-label'
        嵌套的
      </span>
    </button>
  );
}

function Parent({
  return <Nested classes={{ label: 'my-label' }} /> 
}

上面的代码中,我们往 useStyles 这个hook中传入 props 作为参数 (其实是为了将 classes 传进去)

而在 Parent 的代码中,我们给 Nested 传入 classes 属性(这个命名是个规范,必须遵守

最终的效果生成的 html 中 span 的 class 值是 ‘makeStyles-label-2 my-label’ —— 注意 my-label 的顺序在后面,因此它的样式会覆盖前面 makeStyles-label-2 的样式。

上面的 my-label 只是为了更好的说明,下面用更实际的例子

const useStyles = makeStyles({
  root: {}, // 一个样式规则
  label: {
    fontSize30,
    color'red'
  }, // 一个嵌套的样式规则
});

function Nested(props{
  const classes = useStyles(props); // 注意这里的参数
  return (
    <button className={classes.root}>
      <span className={classes.label}> 
        嵌套的
      </span>
    </button>
  );
}


const useStyles2 = makeStyles({
  mylabel: {
    color'green' // 覆盖样式
  }, 
});

function Parent({
  const classes = useStyles2();
  return <Nested classes={{ label: classes.mylabel }} /> 
}

最终效果是

87f36294fa743733de36af3d624492d0.png

打开 Chrome 开发者工具查看样式

266213c0eafa628a162b980830952b6a.png



可见 绿色字体样式覆盖了红色字体样式, 即调用者成功覆盖了默认样式



material-ui 的组件是如何允许用户自定义样式的

上面提到的所有内容都是 @material-ui/styles 的设计和使用,接下来,一起看看 @material-ui/core 这个组件库中的组件是如何允许用户自定义样式的

以 Button 为例,当我们查看Button API的时候 Button API - Material-UI

可以看到这样一些内容

77bc84b08d51f63f55a7fdb914fbe898.jpeg

以及

d956e1275776fb4ea5c55db1bef21b1b.jpeg

根据文档提示,让我们尝试自定义当 Button 的 variant="text" 且 color="primary" 时的样式

import React from 'react';
import PropTypes from 'prop-types';
import { makeStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';

const useStyles = makeStyles({
  mybutton: {
    color'purple',
    fontSize30,
  },
});


export default function AdaptingHook({
  const classes = useStyles();
  return (
    <React.Fragment>
      <Button variant='text' color='primary' 
           classes={{textPrimary: classes.mybutton}}>Hi</Button>
    </React.Fragment>
  );
}

注意 classses 对象的属性名字是 textPrimary

得到的效果是一个 紫色的大小为 30 的按钮

949af1a595eae7f130031d80a9aaff25.png

我们查看这个按钮的HTML代码

并不意外地发现了 makeStyles-mybutton-95 class值

但是,当我们Button 的 color="primary" 改为 color="secondary" 时,却变成了一个 红色普通大小的按钮 (默认主题下 secondary 的颜色通常是红色)

53f75a72ce8e569f2b98ad4ca6b402f6.png

html 代码中也不见了 makeStyles-mybutton-95 class值

1f63452419091da6947a2e078783e934.png


这是如何做到的呢,查看源码 https://github.com/mui-org/material-ui/blob/cdbaeefab373c13022cb4891ba878fe85b7d4154/packages/material-ui/src/Button/Button.js#L300

73b5f17ee5f66aebcdbaf09cca98fff9.jpeg

首先源码中使用了 clsx 一个生成 class 名的工具库(可以查看 lukeed/clsx )

clsx的其中一个用法是,当对象中的属性值为 true 时,这个属性名将加入 class 的值的一部分。

即:当 variant 为 text, color 为 primary 时, color !== 'default'&&color !== 'inherit' 为true,因此 classes['textPrimary'] 将作为 最终 class 的值的一部分,因此按钮显示出 我们自定义的紫色30大小

而当 当 variant 为 text, color 为 secondary 时, color !== 'default'&&color !== 'inherit' 为true, 因此 classes['textSecondary'] 将作为 最终 class 的值的一部分,但是因为我们传入的 classes 没有 textSecondary 这个属性,所以无效,最终显示默认的红色普通大小的样式。




总结

你所在前端项目使用的样式方案还是 LESS, SASS 甚至是 css 吗?

material-ui 样式解决方案 是不是对你有所冲击或者启示呢?欢迎留言评论。

如果觉得文章质量不错,请积极地点赞、喜欢、收藏三连!


p.s. material-ui 样式方案中还有其他内容,如主题、JSS 插件等,个人感觉难度比较小,文档也易懂,(或者过于生僻,很少使用),本文就不做介绍了,如果有读者强烈要求写某个部分,我再继续补充。



参考链接:

https://material-ui.com/zh/styles/basics/

styled-components

https://github.com/mui-org/material-ui/blob/master/packages/material-ui/src/Button/Button.js


版权声明
本文为[osc_sfm9hgdb]所创,转载请带上原文链接,感谢
https://my.oschina.net/u/4412037/blog/4837696

Scroll to Top