classnames是一个用来将类名有条件地连接在一起的简易JavaScript工具
最近偶然的机会开启了跟随大佬们进行源码学习的酸爽之旅。万分幸运的是,在大佬的指点下,我第一站选择了相对简单的工具库------classnames。
开门见山
打开package.json文件,可以清晰的发现classnames的入口文件。但是这里有个容易忽略的点,classnames其实是有三个版本的:
- 默认版本 (index.js)
- 去重版本 (dedupe.js)
- bind版本 (bind.js)
json
"main": "index.js",
实际开发过程中,可针对不同的需求使用不同的版本。
直抒胸臆
默认版本
话不多说,我们先来根据测试用例来看一下默认版本的使用方法。
使用方式
- 使用对象的方式定义className
javascript
assert.equal(classNames({
a: true,
b: false,
c: 0,
d: null,
e: undefined,
f: 1
}), 'a f');
- 使用基本数据类型的方式定义className
javascript
assert.equal(classNames('a', 0, null, undefined, true, 1, 'b'), 'a 1 b');
- 使用数组的方式定义className
javascript
assert.equal(classNames(['a', 'b']), 'a b');
- 复写toString的方式定义className
javascript
assert.equal(classNames({
toString: function () { return 'classFromMethod'; }
}), 'classFromMethod');
- 混用的方式定义className
javascript
assert.equal(classNames({a: true}, 'b', 0), 'a b');
源码
打开源代码,粗略一看,总计不到60行的代码,真可谓是短小精悍。
我们这里就跳跃性的直接看核心代码,快速的梳理清楚核心逻辑即可。
- 将所有符合条件的className同意收集到classes中,然后返回一个以空格拼接的字符串;
javascript
var classes = [];
...
return classes.join(' ');
- 对于隐式转换结果为false的值,直接忽略掉;
javascript
if (!arg) continue;
- 对于string 和number类型,直接视为className插入classes中;
javascript
if (argType === 'string' || argType === 'number') {
classes.push(arg);
}
- 对于array类型,进行递归调用classnames,将最终拼接好的字符串视为className插入classes中;
javascript
if (Array.isArray(arg)) {
...
var inner = classNames.apply(null, arg);
if (inner) {
classes.push(inner);
}
}
- 对于复写了toString方法的对象类型,那么直接调用对象的toString方法,将返回的字符串视为className插入classes中;
这里特别说明一点,在V8引擎中,对于native实现的 built-in 函数,调用函数的toString方法输出的结果为 function XXX() { [native code] }
;通过这一特性,我们就可以区分toString方法是否被复写过了。
javascript
if (arg.toString !== Object.prototype.toString && !arg.toString.toString().includes('[native code]')) {
classes.push(arg.toString());
}
- 对于普通对象类型,则遍历对象的每一组键值对,将值为true的键作为className插入classes中;
javascript
for (var key in arg) {
if (hasOwn.call(arg, key) && arg[key]) {
classes.push(key);
}
}
到这里classnames默认版本的核心代码基本就结束了,不得不再次感慨classnames的短小精悍。如果没有看过源码,很难想象在jsx中面临的className的可读性差,维护性差的问题就这样轻松的解决掉了。
bind版本
bind版本主要是针对于css模块化的场景,我们在使用css-in-js的时候,通常会面临着书写复杂、可读性差的情况,例如:
javascript
className={`${style['todo__list__item']} ${style['todo__list__item--' + props.status}`}
相对于默认版本来说,bind版本的使用方式和源码基本没有太大变化。
使用方式
bind版本只需要在使用将classnames的this指向对应的css模块化即可,使用的使用方式跟默认版本没有任何区别。
javascript
import style from './index.module.less';
const classNamesBound = classNames.bind(style);
classNamesBound('todo__list__item', `todo__list__item--${props.status}`);
小小知识点:
使用bind版本时,当绑定的classname在this中找不到对应的css时,会默认采用当前绑定的classname。这一点在使用css模块化的时候尤其好用。
源码
bind版本的源码相对于默认版本来说,基本一致,细微的差别在于两处(同一个东西)。
javascript
classes.push(this && this[arg] || arg);
相信对于有一定开发经验的小伙伴来说,很容易理解,就是在插入className之前,先到this上查找一下是否存在对应的className。如果存在的话,采用this上的,否则,采用key。
由于在使用之前,我们通过bind方法,将this的作用域指向了style(也就是我们说到CSS Module),所以,这行核心的意思就是:优先采用模块化的className,然后采用默认的className。
去重版本
去重版本跟默认版本的使用方式完全一致,只是在源码上存在差异。
使用方式
去重版本就是字面意思上所看到的,将多个className进行去重,后面的className覆盖前面的className。例如:
javascript
dedupe('foo', 'foobar', 'bar', { foo: false });
// 'foobar bar'
dedupe('foo', 'bar', 'foo', 'bar', { foo: true });
// 'foo bar'
源码
去重版本的核心是利用对象的key进行覆盖的特性进行去重。当然,也产生了一丝丝的副作用,正如我们上面的例子中所看到的那样,className的顺序完全按照从前到后出现的顺序进行排列,后面的className只会覆盖前面className的显隐,但是不会改变className的固有顺序。
- 首先,定义一个原型链上没有继承任何属性和方法的空对象,然后通过寄生的方式创建一个原型链指向空对象的对象;
javascript
function StorageObject() {}
StorageObject.prototype = Object.create(null);
var classSet = new StorageObject();
这样做的最大好处在于,后续对classSet对象进行 for...in... 遍历的时候,不会牵扯到原型链上的属性,也就不需要使用前面说的 Object.prototype.toString 的方法进行判断了。当然,最要是我们用不到Object原型上的任何方法。
- 接下来就是遍历每一个参数来产生对应的className,其中,args就是传入classnames的arguments参数。
javascript
_parseArray(classSet, args);
接下来的部分就是classnames的核心逻辑
- 对于字符串类型的参数,将字符串以 , 然后以key-value的形式分别插入到classSet中去;
javascript
var SPACE = /\s+/;
...
var array = str.split(SPACE);
var length = array.length;
for (var i = 0; i < length; ++i) {
resultSet[array[i]] = true;
}
- 如果是数字的话,则直接以key-value的形式插入到classSet中去;
javascript
resultSet[num] = true;
- 如果是数组的话,则继续进行递归调用,直到全部处理完成;
javascript
for (var i = 0; i < length; ++i) {
_parse(resultSet, array[i]);
}
- 如果是普通对象的话,则循环遍历对象的每一个key,然后key-value的形式插入到classSet中去;
javascript
for (var k in object) {
if (hasOwn.call(object, k)) {
resultSet[k] = !!object[k];
}
}
- 如果是自定义了toString方法的对象的话,则以toString()的调用结果作为key,值为true的方式插入到classSet中去;
javascript
resultSet[object.toString()] = true;
- 最后就是将classSet先转化为数组,然后以 join(' ') 的方式导出最终的className。
javascript
var list = [];
for (var k in classSet) {
if (classSet[k]) {
list.push(k)
}
}
return list.join(' ');
打完收功
希望上面有关classnames的内容对大家有所帮助,如果大家有疑问或者其它观点,可以在下方进行留言交流。