classnames 这个库可能大家已经很熟悉了。在开发中,经常会用到处理动态类名的情况,比如触发某个动作改变元素的样式的时候需要动态为该元素增加或者删除相关的样式。
一般的解决思路是通过字符串拼接的形式为元素增加新类名。
比如这个在classnames
官方文档中提供的例子:我们想实现一个根据不同的鼠标行为,按钮呈现出不同的状态,在按下时增加阴影效果,在鼠标划过时增加高亮效果。根据不同的事件类型为button
增加不同的class
类名:
js
import React, { useState } from 'react';
export default function Button (props) {
const [isPressed, setIsPressed] = useState(false);
const [isHovered, setIsHovered] = useState(false);
// 基础类名
let btnClass = 'btn';
// 点击时增加btn-pressed类名
if (isPressed) btnClass += ' btn-pressed';
// 鼠标划过时增加btn-over类名
else if (isHovered) btnClass += ' btn-over';
// 添加不同的鼠标事件
return (
<button
className={btnClass}
onMouseDown={() => setIsPressed(true)}
onMouseUp={() => setIsPressed(false)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{props.label}
</button>
);
}
可以看到,使用字符串拼接的方式可以实现我们的需求。但是!看起来十分不优雅,而且非常不易阅读,维护性也非常差,那么有没有一种比较优雅的方式维护不同的class
类名之间的对应关系呢?
有。
classnames
可以帮你完成各种class
类名的拼接工作。
一. 基本使用
安装
js
npm install classnames
引入
js
// require引入方式
const classNames = require('classnames');
// import引入方式
import classNames from 'classnames'
使用
其实classnames
最核心的功能就是拼接,支持传入多种数据格式,最终都会返回被拼接好的class
类名:
js
const classname = 'class1 class2 ...'
- 最基础的用法:传入两个字符串
js
classNames('foo', 'bar'); // => 'foo bar'
最终反映在dom中的结果:
js
<div class="foo bar"></div>
- 支持任意位置传入对象结构,其中字符串的拼接与否,取决于
value
位置的boolean
是否为true
js
classNames('foo', { bar: true }); // => 'foo bar'
classNames('foo', { bar: false }); // => 'foo'
- 对象结构
key
位置为字符串
js
classNames({ 'foo-bar': true }); // => 'foo-bar'
classNames({ 'foo-bar': false }); // => ''
- 多个对象组合,取
value
位置的boolean
为true
的集合
js
classNames({ foo: true }, { bar: true }); // => 'foo bar'
classNames({ foo: true, bar: false }); // => 'foo'
- 对象结构
key
位置为变量
js
const classStr = 'foo'
classNames({ [classStr]: true }); // => 'foo'
- 多参数不同类型组合
js
classNames('foo', { bar: true, duck: false }, 'baz', { quux: true }); // => 'foo bar baz quux'
const t1 = 'moo'
classNames(t1, null, false, 'bar', undefined, 0, 1, { baz: null }, ''); // => 'moo bar'
value
位置表达式用法
js
const classStr = 'foo'
const b1 = true
const b2 = false
classNames({ [classStr]: b1 && !b2 }); // => 'foo'
接下来就可以愉快的用classnames
改造上面使用字符串拼接的案例:
js
import React, { useState } from 'react';
import classNames from 'classnames';
export default function Button (props) {
const [isPressed, setIsPressed] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const btnClass = classNames({
btn: true,
'btn-pressed': isPressed,
'btn-over': !isPressed && isHovered,
});
return (
<button
className={btnClass}
onMouseDown={() => setIsPressed(true)}
onMouseUp={() => setIsPressed(false)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{props.label}
</button>
);
}
可以看到,所有对button
这个按钮动态样式的操作,都被聚拢到了btnClass
这个classNames
函数的返回之中,我们只需要维护控制动态样式的变量isPressed
和isHovered
。
还可以用classnames
实现动态tab
的切换功能,这也是一个非常常见的功能:
js
import { FC, useState } from "react";
import styles from "./ClassNameTest.module.scss";
import classNames from "classnames";
const ClassNameTest: FC = () => {
const [currentIndex, setCurrentIndex] = useState();
const tabList = [
{
id: 1,
text: "first tab",
},
{
id: 2,
text: "second tab",
},
{
id: 3,
text: "third tab",
},
];
function handleClick(id: number) {
setCurrentIndex(id);
}
const boxClass = styles.box;
const activeTabClass = styles.activetab;
// classNames函数中控制下标值切换添加activeTabClass类名
// 替代了常用的三元表达式的方式
return (
<div className={styles.container}>
{tabList.map((item) => {
const boxClassNames = classNames({
[boxClass]: true,
[activeTabClass]: item.id === currentIndex,
});
return (
<div
className={boxClassNames}
key={item.id}
onClick={() => handleClick(item.id)}
>
{item.text}
</div>
);
})}
</div>
);
};
export default ClassNameTest;
// style
.container {
display: flex;
.box {
width: 200px;
border: 2px solid #333;
margin: 0 10px;
text-align: center;
}
}
.activetab {
color: #fff;
background: blue;
}
二. 怎么实现?
根据上面的用法可以看出来,classnames
的作用就是拼接,不管你是什么类型,但凡是符合规范的数据结构,都会统统进行拼接,最后返回拼接的字符串。
实现思路很简单:取出所有的参数值,根据不同的类型取出最终的字符串,最后拼接在一起。
parseValue
函数根据不同类型取值,最终返回值会与其他值拼接,最后由appendClass
函数返回本次循环的拼接结果。
js
// 未设置参数是为了不限制参数的个数,可通过 arguments 获取所有的参数
export default function classNames () {
let classes = '';
// 通过arguments获取所有参数
for (let i = 0; i < arguments.length; i++) {
const arg = arguments[i];
if (arg) {
// parseValue处理不同类型的值
// appendClass拼接字符串
classes = appendClass(classes, parseValue(arg));
}
}
return classes;
}
js
// 拼接参数
function appendClass (value, newClass) {
if (!newClass) {
return value;
}
return value ? (value + ' ' + newClass) : newClass;
}
parseValue
函数针对不同类型处理数据。
注意!最新版本的源码中移除了对number类型的参数支持!
- 数组类型
进入classNames
函数后会通过获取到的arguments
数组进行遍历每一项。如果是数组类型,获取到数组后通过再次调用classNames
函数递归执行。最终获取到最底层的字符串类型进行拼接。
js
if (Array.isArray(arg)) {
return classNames.apply(null, arg);
}
- 字符串类型
直接返回值进行拼接
js
if (typeof arg === 'string') {
return arg;
}
- 对象类型
在处理对象类型时,加入了判断对象是否被用户重写了toString
方法。
由于对象的原始的toString()
函数是位于该对象的原型的原型对象中的,只要在该对象的原型中新建一个toString()
函数就可覆盖对象的原始的toString()
函数。
如何判断是否为对象原生对象?看一下原生对象的字符串表现:
js
Object.toString(); //"function Object() { [native code] }"
String.toString(); //"function String() { [native code] }"
如果重写了调用重写后toString
方法的值。如果没有重写遍历检查对象的每一个自有属性的value
是否为true
,为true
加入到拼接的结果中。
js
// hasOwnProperty检查属性是否为自有属性
const hasOwn = {}.hasOwnProperty;
// 不是原生的toString方法并且arg.toString不是原生对象
if (arg.toString !== Object.prototype.toString && !arg.toString.toString().includes('[native code]')) {
return arg.toString();
}
let classes = '';
for (const key in arg) {
// 自有属性且value值为true
if (hasOwn.call(arg, key) && arg[key]) {
classes = appendClass(classes, key);
}
}
最终的完整代码:
js
const hasOwn = {}.hasOwnProperty;
export default function classNames () {
let classes = '';
for (let i = 0; i < arguments.length; i++) {
const arg = arguments[i];
if (arg) {
classes = appendClass(classes, parseValue(arg));
}
}
return classes;
}
function parseValue (arg) {
// 字符串
if (typeof arg === 'string') {
return arg;
}
// 非对象类型,返回空
if (typeof arg !== 'object') {
return '';
}
// 数组
if (Array.isArray(arg)) {
return classNames.apply(null, arg);
}
// 重写了toString()?
if (arg.toString !== Object.prototype.toString && !arg.toString.toString().includes('[native code]')) {
return arg.toString();
}
let classes = '';
// 没重写,遍历每个属性,依次拼接到结果字符串中
for (const key in arg) {
// 自有属性且value为true
if (hasOwn.call(arg, key) && arg[key]) {
classes = appendClass(classes, key);
}
}
return classes;
}
function appendClass (value, newClass) {
if (!newClass) {
return value;
}
return value ? (value + ' ' + newClass) : newClass;
}
三. bind版本
bind 版本可以通过 bind 指定读取属性的对象,传入 classNames
的对象先作为 key 到绑定的对象中寻找 value,如果找到对应的 value
则返回,没找到直接返回。
js
let obj = {
foo: 'a',
bar: 'b',
baz: 'c'
};
let res = classNames.bind(obj);
let className = res('foo', ['bar'], { baz: true }); // => "a b c"
主要的实现原理就是在拼接之前判断对象中的key是否可以通过this
获取到值:
js
// 绑定了this
classes.push(this && this[arg] || arg);
完整代码:
js
const hasOwn = {}.hasOwnProperty;
export default function classNames () {
let classes = '';
for (let i = 0; i < arguments.length; i++) {
const arg = arguments[i];
if (arg) {
classes = appendClass(classes, parseValue.call(this, arg));
}
}
return classes;
}
function parseValue (arg) {
if (typeof arg === 'string') {
return this && this[arg] || arg;
}
if (typeof arg !== 'object') {
return '';
}
if (Array.isArray(arg)) {
return classNames.apply(this, arg);
}
if (arg.toString !== Object.prototype.toString && !arg.toString.toString().includes('[native code]')) {
return arg.toString();
}
let classes = '';
for (const key in arg) {
if (hasOwn.call(arg, key) && arg[key]) {
classes = appendClass(classes, this && this[key] || key);
}
}
return classes;
}
function appendClass (value, newClass) {
if (!newClass) {
return value;
}
return value ? (value + ' ' + newClass) : newClass;
}
四. 去重版本
不只是单纯的拼接,而是有去重操作。
js
classNames('foo', 'foo', 'bar'); // => 'foo bar'
classNames('foo', { foo: false, bar: true }); // => 'bar'
主要的实现原理就是利用对象来存储进行去重,根据对象键值对的 value 是否为 true,来决定是否放入结果对象,再进行拼接处理返回最终的字符串,其中对象结构的数据如果用户自定义了toString
方法,则将toString
作为key保存。
完整代码:
js
function StorageObject () {}
StorageObject.prototype = Object.create(null);
export default function classNames () {
const classSet = new StorageObject();
appendArray(classSet, arguments);
let classes = '';
for (const key in classSet) {
if (classSet[key]) {
classes += classes ? (' ' + key) : key;
}
}
return classes;
}
function appendValue (classSet, arg) {
if (!arg) return;
const argType = typeof arg;
if (argType === 'string') {
appendString(classSet, arg);
} else if (Array.isArray(arg)) {
appendArray(classSet, arg);
} else if (argType === 'object') {
appendObject(classSet, arg);
}
}
const SPACE = /\s+/;
function appendString (classSet, str) {
// 根据空格进行分割
const array = str.split(SPACE);
const length = array.length;
for (let i = 0; i < length; i++) {
classSet[array[i]] = true;
}
}
function appendArray (classSet, array) {
const length = array.length;
for (let i = 0; i < length; i++) {
appendValue(classSet, array[i]);
}
}
const hasOwn = {}.hasOwnProperty;
function appendObject (classSet, object) {
if (
object.toString !== Object.prototype.toString &&
!object.toString.toString().includes('[native code]')
) {
classSet[object.toString()] = true;
return;
}
for (const k in object) {
if (hasOwn.call(object, k)) {
classSet[k] = !!object[k];
}
}
}