源码导读 classnames

159 阅读4分钟

前言

classnames 最为React 官方推荐的工具库, 在平时开发React项目, 都避免不了会使用到它. 它主要功能就是用来判定 classname 的属性名是否动态添加. 例如开发一个点击切换, 选中模块需要展示高亮效果功能时, 使用 classnames 就显得十分方便.

使用安装

安装

npm install classnames --save

引用

import classnames  from 'classnames';

使用

Ï<div classname={classnames("default",{"active":true})} >
    hello world
</div>

classnames 函数第一个参数传入默认的class 名称, 第二个参数是一个对象, key 值是 class名称, 如果value 为 true, 则存在,否则去掉. 这应该是平时用的最多的一种写法. 除此之外classnames 还支持以下写法:

classNames('foo', 'bar'); // => 'foo bar'
classNames({ `foo-${bar}`: true }); // => 'foo-bar'
classNames({ foo: true }, { bar: true }); // => 'foo bar'
classNames({ foo: true, bar: true }); // => 'foo bar'

// 存在多个参数判定场景
classNames('foo', { bar: true, duck: false }, 'baz', { quux: true }); // => 'foo bar baz quux'

// 自动忽略为空的参数包括 0 
classNames(null, false, 'bar', undefined, 0, 1, { baz: null }, ''); // => 'bar 1'

// 传入数组格式, 自动递归平铺
const arr = ['b', { c: true, d: false }];
classNames('a', arr); // => 'a b c'

源码导读

仓库地址: github.com/JedWatson/c…

目录结构

.
├── CONTRIBUTING.md
├── HISTORY.md
├── LICENSE
├── README.md
├── benchmarks // 测试文件目录
│   ├── benchmarks.html
│   ├── fixtures.js // 测试数据
│   ├── package-lock.json
│   ├── package.json
│   ├── run.js// 跑测试文件的入口
│   ├── runChecks.js
│   ├── runInBrowser.js
│   └── runSuite.js
├── bind.d.ts
├── bind.js // bind 方法实现
├── dedupe.d.ts
├── dedupe.js // dedupe 版本实现
├── index.d.ts
├── index.js // classnames方法的实现
├── package-lock.json
├── package.json // 查看依赖和单测调试的入口
├── tests
│   ├── bind.js
│   ├── dedupe.js
│   ├── index.js
│   └── types.ts
├── tsconfig.json
├── tslint.json
└── yarn.lock

classNames 实现

/*!
	Copyright (c) 2018 Jed Watson.
	Licensed under the MIT License (MIT), see
	<http://jedwatson.github.io/classnames>
*/
/* global define */

(function () {
	'use strict';

	var hasOwn = {}.hasOwnProperty;

	function classNames() {
		var classes = [];

		for (var i = 0; i < arguments.length; i++) { // 遍历参数
			var arg = arguments[i];
			if (!arg) continue; // 当前参数是空或者不存在,跳过本次循环开始下次循环
 
			var argType = typeof arg; // 获取参数类型

			if (argType === 'string' || argType === 'number') {
				classes.push(arg); // 常规类型直接放入数组里面
			} else if (Array.isArray(arg)) {
				if (arg.length) {
					var inner = classNames.apply(null, arg); // 数组就递归调用,使用apply,避免直接传入参数
					if (inner) {
						classes.push(inner); 
					}
				}
			} else if (argType === 'object') {
				//	增加了判定传入对象原型链上的toString方式是否被重写的判定,重写了就直接使用重写后的值
				if (arg.toString !== Object.prototype.toString && !arg.toString.toString().includes('[native code]')) {
					classes.push(arg.toString());
					continue;
				}
				// 没有重写, 就直接遍历对象,获取value值为true 的key值
				for (var key in arg) {
					if (hasOwn.call(arg, key) && arg[key]) {
						classes.push(key);
					}
				}
			}
		}
		return classes.join(' ');
	}
	// 模块化加载
	if (typeof module !== 'undefined' && module.exports) {
		classNames.default = classNames;
		module.exports = classNames;
	// 判定AMD加载
	} else if (typeof define === 'function' && typeof define.amd === 'object' && define.amd) { 
		// register as 'classnames', consistent with npm package name
		define('classnames', [], function () {
			return classNames;
		});
	} else {
		// 直接挂载到window上,全局加载
		window.classNames = classNames; 
	}
}());

小结:

classNames 的核心功能实现就是判定传入参数的类型,根据类型做不同的处理,然后都统一放入到自定义的数组中, 最后直接返回空格拼接的字符串。

参数类型的处理主要分以下三种情况:

  • 基本类型 stringnumber 直接push到数组里面
  • 如果是数组类型, 通过递归的方式, 再次调用 classNames 方法, 而调用的方式采用了 apply , 原因是因为 apply 的第二个参数是一个 类数组, 这样可以传递未知的参数。
  • 如果是对象的话, 先判定是否重写原型链上 toString 方法,重写了,就直接传入方法返回的值;没有则遍历对象,判定值为true,则传入对应的key。

dedupe 版本

dedupe 版本用来去除重复和对象值为false的 classNames

const classNames = require('classnames/dedupe');
classNames('foo', 'foo', 'bar'); // => 'foo bar'
classNames('foo', { foo: false, bar: true }); // => 'bar'

源码实现

(function () {
	'use strict';

	var classNames = (function () {
		// don't inherit from Object so we can skip hasOwnProperty check later
		// <http://stackoverflow.com/questions/15518328/creating-js-object-with-object-createnull#answer-21079232>
		function StorageObject() { }
		StorageObject.prototype = Object.create(null);

		function _parseArray(resultSet, array) {
			var length = array.length;

			for (var i = 0; i < length; ++i) {
				_parse(resultSet, array[i]); // 遍历数组, 继续递归调用解析方法 _parse
			}
		}

		var hasOwn = {}.hasOwnProperty;
		// 如果是数字默认就是true
		function _parseNumber(resultSet, num) {
			resultSet[num] = true;
		}

		function _parseObject(resultSet, object) {
			// 判定是否重新对象上的 toString 方法
			if (object.toString !== Object.prototype.toString && !object.toString.toString().includes('[native code]')) {
				resultSet[object.toString()] = true;
				return;
			}

			for (var k in object) {
				if (hasOwn.call(object, k)) {
					// set value to false instead of deleting it to avoid changing object structure
					// <https://www.smashingmagazine.com/2012/11/writing-fast-memory-efficient-javascript/#de-referencing-misconceptions>
					resultSet[k] = !!object[k];
				}
			}
		}

		var SPACE = /\s+/; // \s 表示匹配所有的空白字符,包括空格、制表符、换页符, + 表示匹配表达式一次或多次
		function _parseString(resultSet, str) {
			var array = str.split(SPACE); // 分割字符串,转换成数组
			var length = array.length;

			for (var i = 0; i < length; ++i) {
				resultSet[array[i]] = true;
			}
		}

		function _parse(resultSet, arg) { // 格式遍历的数据, 根据参数类型, 调用不同的解析方法
			if (!arg) return;
			var argType = typeof arg;

			// 'foo bar'
			if (argType === 'string') {
				_parseString(resultSet, arg);

				// ['foo', 'bar', ...]
			} else if (Array.isArray(arg)) {
				_parseArray(resultSet, arg);

				// { 'foo': true, ... }
			} else if (argType === 'object') {
				_parseObject(resultSet, arg);

				// '130'
			} else if (argType === 'number') {
				_parseNumber(resultSet, arg);
			}
		}

		function _classNames() {
			// don't leak arguments
			// <https://github.com/petkaantonov/bluebird/wiki/Optimization-killers#32-leaking-arguments>
			// 为了避免 arguments 的泄露, 采用代理的方式,重新生成一个参数数组
			var len = arguments.length;
			var args = Array(len);
			for (var i = 0; i < len; i++) {
				args[i] = arguments[i];
			}

			var classSet = new StorageObject();
			_parseArray(classSet, args); // 调用解析数组的方法, 主要是采用递归的方法,来处理参数

			var list = [];

			for (var k in classSet) { // 遍历对象, 将结果放在数组对象中
				if (classSet[k]) {
					list.push(k)
				}
			}

			return list.join(' ');
		}

		return _classNames;
	})();
	...
}());

小结:

dedupe 版本的实现和 classname 的整体实现上大同小异. 对于基本类型和复杂类型的处理判定逻辑基本是一致的.

而为了实现类名的去重, 作者将类数组 argument 先转换成了数组, 目的是为了避免内存泄露. 然后定义一个新的对象来保存每次遍历解析后的值, 如果存在重复的key, 那么后面的key就会覆盖掉前面的key, 最终实现类型名的去重.

bind 版本

使用方式

import classNames from 'classnames/bind'
import styles from './submit-button.module.css';
let cx = classNames.bind(styles);
var className = cx('foo', ['bar'], { baz: true }); // => "abc def xyz"

bind 主要适用于 [css-module](<https://github.com/css-modules/css-modules>) 的场景 ,本质上其实是解决了重复写css对象名称的操作。 如果使用 css-module 的时候,不采用bind的方式, 而是直接使用 classNames, 那么上面的代码就是这样写的

import classNames from 'classnames'
import styles from './submit-button.module.css';
var className = classNames(styles['foo'],[styles['bar']],{ 
	[styles['baz']]:true
 }); // => "abc def xyz"

最终功能效果是一样的, 但是从视觉效果上来看, 确实不够美观优雅,而且显得很臃肿, 给人一种重复编码的感觉。

源码实现

(function () {
	'use strict';

	var hasOwn = {}.hasOwnProperty;

	function classNames () {
		var classes = [];

		for (var i = 0; i < arguments.length; i++) {
			var arg = arguments[i];
			if (!arg) continue;

			var argType = typeof arg;

			if (argType === 'string' || argType === 'number') {
				classes.push(this && this[arg] || arg); //增加对绑定对象value的获取
			} else if (Array.isArray(arg)) {
				classes.push(classNames.apply(this, arg));
			} else if (argType === 'object') {
				if (arg.toString !== Object.prototype.toString && !arg.toString.toString().includes('[native code]')) {
					classes.push(arg.toString());
					continue;
				}

				for (var key in arg) {
					if (hasOwn.call(arg, key) && arg[key]) {
						classes.push(this && this[key] || key);// 增加对绑定对象value的获取
					}
				}
			}
		}
		return classes.join(' ');
	}
	...
}());

小结

bind 版本的实现就是在原来 classNames 版本的基础上, 在取值的时候,添加了从 this 对象上获取value的操作,如果 this 对象上不存在该值, 就直接将传入的key值,作为class的属性名传入。

从整体源码分析来看, classNames 的实现并不算很复杂,三种版本的实现都是大同小异的,但确实有很多地方值得我们学习,尤其是对于一些边界场景的考虑。 但是作为一个github 上 star 16.8k 的优秀项目来说,单测肯定是必不可少的。 因此 classNames 的单测实现也是值得学习和借鉴的

单测实现

benchmarks 是单测文件文件所在的目录. 单测试执行的入口文件 benchmarks/run.js

首先是使用 assert 断言, 测试工具函数是否能够和预设的值相等

assert.equal(sortClasses(local.apply(null, fixture.args)), sortClasses(fixture.expected));
assert.equal(sortClasses(dedupe.apply(null, fixture.args)), sortClasses(fixture.expected));
assert.equal(sortClasses(npm.apply(null, fixture.args)), sortClasses(fixture.expected));
assert.equal(sortClasses(npmDedupe.apply(null, fixture.args)), sortClasses(fixture.expected));

其次是使用 benchmarks 来做性能基准测试

function runSuite (local, npm, dedupe, npmDedupe, fixture, log) {
	var suite = new benchmark.Suite();

	suite.add('local#' + fixture.description, function () {
		local.apply(null, fixture.args);
	});

	suite.add('  npm#' + fixture.description, function () {
		npm.apply(null, fixture.args);
	});

	suite.add('local/dedupe#' + fixture.description, function () {
		dedupe.apply(null, fixture.args);
	});

	suite.add('  npm/dedupe#' + fixture.description, function () {
		npmDedupe.apply(null, fixture.args);
	});

	// after each cycle
	suite.on('cycle', function (event) {
		log('* ' + String(event.target));
	});
	// other handling
	suite.on('complete', function () {
		log('\n> Fastest is' + (' ' + this.filter('fastest').map(result => result.name).join(' | ')).replace(/\s+/, ' ') + '\n');
	});

	suite.on('error', function (event) {
		log(event.target.error.message);
		throw event.target.error;
	});

	suite.run();
}

测试结果:

Debugger attached.
* local#strings x 11,059,300 ops/sec ±0.62% (93 runs sampled)
*   npm#strings x 11,364,089 ops/sec ±0.51% (97 runs sampled)
* local/dedupe#strings x 4,185,074 ops/sec ±0.60% (89 runs sampled)
*   npm/dedupe#strings x 4,256,622 ops/sec ±0.46% (97 runs sampled)

> Fastest is npm#strings

* local#object x 13,133,142 ops/sec ±0.39% (98 runs sampled)
*   npm#object x 12,983,708 ops/sec ±0.75% (95 runs sampled)
* local/dedupe#object x 7,953,071 ops/sec ±1.26% (96 runs sampled)
*   npm/dedupe#object x 8,052,713 ops/sec ±0.96% (91 runs sampled)

> Fastest is local#object

* local#strings, object x 10,240,852 ops/sec ±0.71% (95 runs sampled)
*   npm#strings, object x 9,988,439 ops/sec ±0.47% (97 runs sampled)
* local/dedupe#strings, object x 4,352,901 ops/sec ±0.52% (98 runs sampled)
*   npm/dedupe#strings, object x 4,210,615 ops/sec ±0.99% (95 runs sampled)

> Fastest is local#strings, object

* local#mix x 5,997,558 ops/sec ±0.41% (95 runs sampled)
*   npm#mix x 5,978,421 ops/sec ±0.72% (95 runs sampled)
* local/dedupe#mix x 1,752,118 ops/sec ±1.45% (95 runs sampled)
*   npm/dedupe#mix x 1,723,078 ops/sec ±1.78% (92 runs sampled)

> Fastest is local#mix |   npm#mix

* local#arrays x 2,168,758 ops/sec ±0.44% (96 runs sampled)
*   npm#arrays x 2,125,009 ops/sec ±1.30% (96 runs sampled)
* local/dedupe#arrays x 1,831,783 ops/sec ±0.53% (91 runs sampled)
*   npm/dedupe#arrays x 1,817,788 ops/sec ±0.50% (96 runs sampled)

> Fastest is local#arrays

ops/sec 测试结果以每秒钟执行测试代码的次数(Ops/sec)显示,这个数值越大越好