浅显易懂的classnames源码分析

classnames是一个用来将类名有条件地连接在一起的简易JavaScript工具

最近偶然的机会开启了跟随大佬们进行源码学习的酸爽之旅。万分幸运的是,在大佬的指点下,我第一站选择了相对简单的工具库------classnames

开门见山

打开package.json文件,可以清晰的发现classnames的入口文件。但是这里有个容易忽略的点,classnames其实是有三个版本的:

  • 默认版本 (index.js)
  • 去重版本 (dedupe.js)
  • bind版本 (bind.js)
json 复制代码
    "main": "index.js",

实际开发过程中,可针对不同的需求使用不同的版本。

直抒胸臆

默认版本

话不多说,我们先来根据测试用例来看一下默认版本的使用方法。

使用方式

  1. 使用对象的方式定义className
javascript 复制代码
    assert.equal(classNames({
        a: true,
        b: false,
        c: 0,
        d: null,
        e: undefined,
        f: 1
    }), 'a f');
  1. 使用基本数据类型的方式定义className
javascript 复制代码
    assert.equal(classNames('a', 0, null, undefined, true, 1, 'b'), 'a 1 b');
  1. 使用数组的方式定义className
javascript 复制代码
    assert.equal(classNames(['a', 'b']), 'a b');
  1. 复写toString的方式定义className
javascript 复制代码
    assert.equal(classNames({
        toString: function () { return 'classFromMethod'; }
    }), 'classFromMethod');
  1. 混用的方式定义className
javascript 复制代码
    assert.equal(classNames({a: true}, 'b', 0), 'a b');

源码

打开源代码,粗略一看,总计不到60行的代码,真可谓是短小精悍

我们这里就跳跃性的直接看核心代码,快速的梳理清楚核心逻辑即可。

  1. 将所有符合条件的className同意收集到classes中,然后返回一个以空格拼接的字符串;
javascript 复制代码
    var classes = [];
    
    ...
    
    return classes.join(' ');
  1. 对于隐式转换结果为false的值,直接忽略掉;
javascript 复制代码
    if (!arg) continue;
  1. 对于stringnumber类型,直接视为className插入classes中;
javascript 复制代码
    if (argType === 'string' || argType === 'number') {
        classes.push(arg);
    }
  1. 对于array类型,进行递归调用classnames,将最终拼接好的字符串视为className插入classes中;
javascript 复制代码
    if (Array.isArray(arg)) {
        ...
        var inner = classNames.apply(null, arg);
        if (inner) {
            classes.push(inner);
        }
    }
  1. 对于复写了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());
    }
  1. 对于普通对象类型,则遍历对象的每一组键值对,将值为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的固有顺序。

  1. 首先,定义一个原型链上没有继承任何属性和方法的空对象,然后通过寄生的方式创建一个原型链指向空对象的对象;
javascript 复制代码
    function StorageObject() {}
    StorageObject.prototype = Object.create(null);
    
    var classSet = new StorageObject();

这样做的最大好处在于,后续对classSet对象进行 for...in... 遍历的时候,不会牵扯到原型链上的属性,也就不需要使用前面说的 Object.prototype.toString 的方法进行判断了。当然,最要是我们用不到Object原型上的任何方法。

  1. 接下来就是遍历每一个参数来产生对应的className,其中,args就是传入classnames的arguments参数。
javascript 复制代码
    _parseArray(classSet, args);

接下来的部分就是classnames的核心逻辑

  1. 对于字符串类型的参数,将字符串以 然后以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;
    }
  1. 如果是数字的话,则直接以key-value的形式插入到classSet中去;
javascript 复制代码
    resultSet[num] = true;
  1. 如果是数组的话,则继续进行递归调用,直到全部处理完成;
javascript 复制代码
    for (var i = 0; i < length; ++i) {
            _parse(resultSet, array[i]);
    }
  1. 如果是普通对象的话,则循环遍历对象的每一个key,然后key-value的形式插入到classSet中去;
javascript 复制代码
    for (var k in object) {
        if (hasOwn.call(object, k)) {
            resultSet[k] = !!object[k];
        }
    }
  1. 如果是自定义了toString方法的对象的话,则以toString()的调用结果作为key,值为true的方式插入到classSet中去;
javascript 复制代码
    resultSet[object.toString()] = true;
  1. 最后就是将classSet先转化为数组,然后以 join(' ') 的方式导出最终的className。
javascript 复制代码
    var list = [];

    for (var k in classSet) {
        if (classSet[k]) {
            list.push(k)
        }
    }

    return list.join(' ');

打完收功

希望上面有关classnames的内容对大家有所帮助,如果大家有疑问或者其它观点,可以在下方进行留言交流。

相关推荐
木子020443 分钟前
前端VUE项目启动方式
前端·javascript·vue.js
endingCode1 小时前
45.坑王驾到第九期:Mac安装typescript后tsc命令无效的问题
javascript·macos·typescript
Myli_ing2 小时前
HTML的自动定义倒计时,这个配色存一下
前端·javascript·html
I_Am_Me_3 小时前
【JavaEE进阶】 JavaScript
开发语言·javascript·ecmascript
℘团子এ3 小时前
vue3中如何上传文件到腾讯云的桶(cosbrowser)
前端·javascript·腾讯云
学习前端的小z3 小时前
【前端】深入理解 JavaScript 逻辑运算符的优先级与短路求值机制
开发语言·前端·javascript
前端百草阁3 小时前
【TS简单上手,快速入门教程】————适合零基础
javascript·typescript
彭世瑜3 小时前
ts: TypeScript跳过检查/忽略类型检查
前端·javascript·typescript
Backstroke fish3 小时前
Token刷新机制
前端·javascript·vue.js·typescript·vue
zwjapple3 小时前
typescript里面正则的使用
开发语言·javascript·正则表达式