解读 classnames 源码

155 阅读5分钟

前言

在 React 组件中,点击按钮将一段文本的颜色由黑色改为红色,简单粗暴的做法如下:

const App = () => {
  const [color, setColor] = useState('#000')

  const onChangeTextColor = () => {
    setColor('red')
  }

  return (
    <div>
      <p style={{ color }}>this is text </p>
      <button onClick={onChangeTextColor}>change text color</button>
    </div>
  )
}

直接使用行内样式做法,使用 state 控制 style 的样式。再把这个例子加深难度,比如要求字体调大为 20px,上下给 8px 的内边距,再给字体添加 background-color 等等

const App = () => {
  const [color, setColor] = useState('#000')
  const [fontSize, setFontSize] = useState('14px')
  const [bgColor, setBgColor] = useState('#fff')

  const onChangeTextColor = () => {
    setColor('red')
    setFontSize('20px')
    setBgColor('#e8e8e8')
  }

  return (
    <div>
      <p style={{ color, fontSize, backgroundColor: bgColor }}>this is text </p>
      <button onClick={onChangeTextColor}>change text color</button>
    </div>
  )
}

通过对比发现,随着改变的样式变多,需要开发人员花费更多精力维护行内样式,并且行内样式导致样式与 HTML 杂糅在一起。那么如何解决这个问题呢? 可以试试社区提供的 classnames 工具库

classnames

classnames 库通过 JavaScript 动态控制标签的类名。常用于 React 项目里用来控制组件的类名。它有如下的优点:

  • 避免在组件里写行内样式
  • 配置性高,比如通过对象动态配置类名

快速开始

在根目录安装 classnames 依赖

npm i classnames --save

现在用 classnames 优化前面的例子,下面是具体的实现

const App = () => {
  const [visible, setVisible] = useState(false)

  const onChangeTextColor = () => {
    setVisible(true)
  }

  return (
    <div>
      <p style={classnames({ button: visible })}>this is text </p>
      <button onClick={onChangeTextColor}>change text color</button>
    </div>
  )
}

下面是样式文件

.button {
  color: red;
  font-size: 20px;
  background-color: #e8e8e8;
}

使用 classnames 既解耦样式与 HTML,也可以动态改变元素的样式

接下来一起看看 classnames 多种用法

// 这是最常见的用法
classNames('foo', 'bar')
// => 'foo bar'

// 通过变量控制每个类名,visible 为 false,则结果为 'foo', 为 true,则结果为 'foo bar'
classNames('foo', { bar: visible })
// => 'foo bar'

// 比如在 Tag 组件里,控制 Tag 的状态的类名可以写成下列形式,但 type 必须有默认值,比如默认 default
classNames('tag', `is-${type}`)
// => 'tag is-default'

// type 无默认值, 不推荐
classNames('tag', `is-${type}`)
// => 'tag is-undefined'

// 为 false 的值会被过滤掉
classNames(null, false, 'bar', undefined, 0, 1, { baz: null }, '')
// => 'bar 1'

源码分析

我们研究下 classnames 的具体实现。在 GitHub 上找到 classnames 的源码。classnames 的代码很少,仅仅只有 51 行。下面逐行分析源码

;(function () {
  'use strict'

  //...
})()

这是 IIFE (立即调用函数表达式),使用 IIFE 可以做到作用域隔离。

var hasOwn = {}.hasOwnProperty

Object 类型原型对象上的 hasOwnProperty 方法存入变量中,方便之后使用 hasOwnProperty 方法时,不用顺着原型链查找该方法,提高性能。

接下来,便是 classnames 库的核心代码,classNames 方法的具体实现,同时也是 classnames 库唯一的 API。

function classNames() {
  var classes = []

  for (var i = 0; i < arguments.length; i++) {
    var arg = arguments[i]
    /*
            如果传入的值有 null、 undefined、 ""、false、NaN 
            等值直接跳过本次循环,进行下一次循环
        */
    if (!arg) continue

    var argType = typeof arg
    /*
            classNames 真正处理四类数据,分别为字符串、数值、数组和对象
        */
    /*
        如果传入的参数是字符串或数值类型,直接添加到最终的结果里。
        eg。 classNames('foo', 1) => "foo 1"
       */
    if (argType === 'string' || argType === 'number') {
      classes.push(arg)
      /*
            如果传入的是数组类型,就递归遍历数组
        */
    } else if (Array.isArray(arg)) {
      if (arg.length) {
        var inner = classNames.apply(null, arg)
        if (inner) {
          classes.push(inner)
        }
      }
      /*
            如果传入的参数是对象类型。则分为两种情况:
            1. 对象没有 toString 属性;
            classNames({foo: true}) => "foo"
            2. 对象有 toString 属性;
            classNames({"foo": toString: 'bar'}) => "bar"
            */
    } else if (argType === 'object') {
      if (arg.toString !== Object.prototype.toString) {
        classes.push(arg.toString())
      } else {
        for (var key in arg) {
          if (hasOwn.call(arg, key) && arg[key]) {
            classes.push(key)
          }
        }
      }
    }
  }

  return classes.join(' ')
}

梳理一下整个 classNames 方法流程

  1. 声明一个空数组变量 classes,最后将 classes 里所有元素以空格符分隔组成字符串;
  2. 使用 for 循环对传入 classNames 方法的参数迭代;
  3. 若传入的参数经过隐式转换为 false,则将其过滤,结束本次循环,进行下一次循环;
  4. 参数为字符串或数值类型,则将其参数添加到 classes 数组中;
  5. 参数为数组类型,则进行递归处理,继续从步骤 1 开始,直至最后将返回值 inner(一维数组)添加到 classes 数组中;
  6. 参数如果为对象类型,对象的属性值中没有 toString 时,直接根据属性值的 true/false 判断该属性是否成为其类名,如果有 toString,它的值作为其类名。

但是目前广泛使用的最新的 classnames v2.2.6 版本中。

// classnames v2.2.6
// ...
else if (argType === 'object') {
    for (var key in arg) {
        if (hasOwn.call(arg, key) && arg[key]) {
            classes.push(key);
        }
    }
}
// ...

注意:对参数为对象类型的情况只做了简单处理,只要对象属性的属性值为 true,即将该属性追加到 classes 数组中。

最后,我们来看看 classNames 定义的模块导出部分源码

if (typeof module !== 'undefined' && module.exports) {
  /*
        commonJS 规范
    */
  classNames.default = classNames
  module.exports = classNames
} else if (
  /*
        amd 规范
    */
  typeof define === 'function' &&
  typeof define.amd === 'object' &&
  define.amd
) {
  define('classnames', [], function () {
    return classNames
  })
} else {
  /*
        直接暴露到全局
        默认为 browser 环境
    */
  window.classNames = classNames
}

classnames 使用了 UMD 模块导出规范。

  1. 先判断是否存在 module 对象,如果存在,即在 Node.js 环境中,遵循 commonJS 规范导出;
  2. 如果存在 define 方法,即为浏览器环境中,遵循 AMD 规范导出;
  3. 如果 moduledefine 都不存在,直接将其暴露到全局环境(默认为浏览器环境)中,挂载到 window 全局对象上。

挑战者 clsx

clsx 是一个用于有条件构造 className 字符串的小型 (239B) 实用程序。还可以作为模块的更快、更小的直接替代品 classnames。其用法同 classnames 基本一样

参考链接

❤️爱心双连击

  1. 如果你觉得文章对你有帮助,就点个赞支持下吧,你的赞是我最大的动力。
  2. 关注公众号前端加油宝,里面有更精彩的文章,谢谢🙏