源码精读系列(classnames):通过debug告诉你如何将所有的class连接在一起

1,223 阅读2分钟

我正在参与掘金会员专属活动-源码共读第一期,点击参与

要点速览

  1. 为什么要有classnames?
  2. classnames这个库的几种使用方式。
  3. 从测试用例入手,通过vscode的debug来调试主版本。
  4. bind版本是如何与css modules结合的?

classnames的由来

在书写class类名的时候,我们经常会遇到需要动态添加class的场景,拿一个按钮举例,要根据外部传入的参数来渲染按钮的disabled状态、大小以及是否显示loading:

export function Button(props) {
  const { isLoading, disabled, size } = props
  const prefix = 'm'
  const loadingClass = isLoading ? `${prefix}-button--loading` : ''
  const disabledClass = disabled ? `${prefix}-button--disabled` : ''
  const buttonSize = size ? `${prefix}-button--${size}` : ''
  return (
    <button className={`${loadingClass} ${disabledClass} ${buttonSize}`}>
      点击
    </button>
  )
}

这么写会有一个非常痛苦的点,需要去将所有通过计算得来的类名拼接成一个以空格为间隔的字符串,管理起来就很麻烦。正如classnames给自己的定位:是一个用来将类名有条件地连接在一起的简易JavaScript工具。

A simple JavaScript utility for conditionally joining classNames together.

classnames的使用方式(明确看源码的调试目的)

我们只有带着问题去看源码才可以知道源码里究竟为什么这么写,classnames这个库很简单,主要就做了以下处理:

  1. 接受任意数量的参数
  2. 对象会通过判断值是否为true,来加入相应的class类
  3. 会忽略多种假值(false、null、0、undefined)
  4. 接受数组的时候会进行扁平化
classNames('foo', 'bar'); // => 'foo bar'
classNames('foo', { bar: true }); // => 'foo bar'
classNames({ 'foo-bar': true }); // => 'foo-bar'
classNames({ 'foo-bar': false }); // => ''
classNames({ foo: true }, { bar: true }); // => 'foo bar'
classNames({ foo: true, bar: true }); // => 'foo bar'

// lots of arguments of various types
classNames('foo', { bar: true, duck: false }, 'baz', { quux: true }); // => 'foo bar baz quux'

// other falsy values are just ignored
classNames(null, false, 'bar', undefined, 0, 1, { baz: null }, ''); // => 'bar 1'

var arr = ['b', { c: true, d: false }];
classNames('a', arr); // => 'a b c'

开启debug调试之旅

通过github拉下项目,使用任意一个包管理器(pnpm|yarn|npm|bower)将依赖安装起来,观察到package.json中描述的入口文件指向的是index.js,这里就是包的源码入口,还有其他两个版本,一个bind.js(为css modules而生),另外一个是dedupe.js(能够删除重复的类名)。

测试框架

我们可以看到classnames包中有一个tests文件夹,里面写了各种测试用例,从测试用例入手,往往是了解一个库的最佳方式。该包采用了mocha来做单元测试,想要了解mocha库的使用,可以戳这里✋🏻,我们只要知道describe用来描述一个测试套件,it函数对应着一个个测试用例的描述,回调中会通过assert.equal判断需要测试的用例和预期结果是否一致就行了。

vscode的debug配置

那么如何通过vscode自带的debug能力来调试一个node程序呢?在tests文件夹中是mocha编写的测试文件,直接通过node是执行不了的。我们在根目录下创建.vscode文件夹和launch.json。并在launch.json中写入以下配置,表示此次需要调试的文件是tests/index.js,然后在你想要调试的测试用例,针对每一个assert.equal语句打上断点,运行debug面板就可以开始愉快的调试之旅了。

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Run mocha",
      "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha",
      "stopOnEntry": false,
      "args": [
        "tests/index.js",
        "--no-timeouts"
      ],
      "cwd": "${workspaceFolder}",
      "runtimeExecutable": null,
    }
  ]
}

调试真假值的测试用例

我们现将断点打在tests/index.js的第31行,也就是joins arrays of class names and ignore falsy values这个测试用例,将一组class判断为真值的给连接起来,忽略假值。可以看到调试面板中的classnames函数接收了7个参数,然后对arguments参数进行遍历处理。

image.png

处理流程分了以下四种情况,classes变量保存最终的类名数组:

  1. 如果当前参数是假值,直接略过,包含了null、undefined、0、''这几种情况(对应源码第18行)。
  2. 判断当前参数是否是string或者是number类型,是的话就压入最终的classes数组中(对应源码第22行)。
  3. 如果是数组类型,就会进行递归处理,将递归后的结果压入classes数组中,因为classnames的返回值也是一个class字符串(对应源码第26行)。
  4. 通过判断当前的参数是不是对象,如果是的话,有两种情况,一种是该对象的toString函数为自身重写的,并不是继承于Object原型以及判断是不是浏览器原native的代码实现的 built-in 函数,而不是JavaScript代码。另外一种就会遍历当前的object,将value为真值的代码压入classes数组。
if (arg.toString !== Object.prototype.toString && !arg.toString.toString().includes('[native code]')) {
    classes.push(arg.toString());
    continue;
}

对于第四点,我们可以发现:如果重写了对象的toString方法,是不是可以打印出我们既定想要的值。先新建一个全新的测试文件a.js,将launch.json的入口改为a.js,写入以下代码进行调试,可以发现最终打印出来的类名是aaa,而不是a了。

var assert = require('assert');
var classNames = require('./');

let obj = { a: true }
obj.toString = function() {
  return 'aaa'
}

describe('classNames', function () {
	it('测试用例', function () {
		assert.equal(classNames(obj), 'aaa');
	});
})

bind版本探索

bind的版本是怎么对css modules的类名生效的呢?首先我们需要了解一些概念。 css modules是为了解决css模块化的问题的,让类名具有局部作用域,避免全局污染,

import styles from './styles.css'; 
export function Button() {
    return (
        <button className={`${styles.primary}`}>点击</button>
    )
}

最终生成的类名可能是这样的:

<div className="_11WCef26_Es5wh51-24eUa"></div>

导出的styles其实是一个模块化的对象映射,对于每一个styles.xxx,都对应着一个class的类名,我们可以观察到测试用例里面,有一个cssModulesMock的对象,该对象就是用来模拟css模块化对象的。 在使用bind版本的时候,需要将classnames的指向绑定到导入的styles对象上(通过bind来改变this的指向)。

var classNamesBound = classNames.bind(cssModulesMock);

两个版本的区别就在这个bind身上,那具体有什么不同呢?有一个好方法,如果是使用vsCode编辑器的,可以使用右键点击index.js文件,选中Select for Compare,紧接着右键点击bind.js文件,选中Compare with Selected,就可以轻松的比对出差异了。

主要体现在push进入classes的操作上,因为我们需要去判断传入的参数是否在styles对象上有值,如果有的话,传入对应的值,没有则压入参数。

classes.push(this && this[arg] || arg);

在这里还会有一个问题,为什么需要”||”的操作?找不到值就不压入数组了。这是因为有些全局的类,并不是从css modules导出的,有可能也需要加入,很经典的例子,用过原子化css(例如windicss等)配合css modules的就知道。

总结

通过此次源码阅读,可以看到classnames这个库其实很简单,主要是会忽略一些平时不注意的知识点。这个库与框架没有的关系,平时都用在react上面,如果用在vue上面其实也可以(但官方提供了动态绑定class和scoped了,就不用添油加醋了)。

  1. 涉及到隐式转换(过滤假值的作用)
  2. 原型链相关知识,利用重写的toString来输出字符串
  3. bind和apply的应用(改变this指向)